Version: 2.0.0 Status: ✅ IMPLEMENTED & VALIDATED (2025-10-12) Change from v1.0.0: Single file per instrument (multi-year) instead of per-month files
Implementation Summary:
- ✅ All 5 priorities completed
- ✅ Validated with 13 months of real EURUSD data (18.6M Raw_Spread ticks, 19.6M Standard ticks, 413K OHLC bars)
- ✅ Database size: 2.08 GB for 13 months (scales to ~4.8 GB for 3 years)
- ✅ Query performance: <15ms for all operations
- ✅ Incremental updates working correctly
Recommendation: One DuckDB file per instrument containing all historical data (minimum 3 years)
Rationale:
- ✅ Maximum Simplicity: ONE file = ONE instrument (eurusd.duckdb, xauusd.duckdb)
- ✅ Continuous Time Series: No gaps between months, seamless queries across years
- ✅ Incremental Updates: Check for new data on Exness, append automatically
- ✅ Phase7 Compliant: Dual-variant 30-column (v1.6.0) schema with trading hour detection
- ✅ Scalable: ~400-500 MB per instrument for 3 years (manageable)
- ✅ Simple Backups: Copy one file to backup entire instrument history
Key Change: NO monthly separation. All data for EURUSD goes in eurusd.duckdb.
~/eon/exness-data/
├── eurusd.duckdb (~400-500 MB for 3 years)
├── xauusd.duckdb (~400-500 MB for 3 years)
└── export/ # Optional, on-demand Parquet exports
└── (created when needed)
ONE file per instrument - No monthly/yearly separation.
Table 1: raw_spread_ticks (all historical data)
CREATE TABLE raw_spread_ticks (
Timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
Bid DOUBLE NOT NULL,
Ask DOUBLE NOT NULL,
PRIMARY KEY (Timestamp) -- Ensures no duplicates during incremental updates
);
CREATE INDEX idx_raw_spread_timestamp ON raw_spread_ticks(Timestamp);
COMMENT ON TABLE raw_spread_ticks IS
'Exness EURUSD Raw_Spread variant (execution prices, 98% zero-spreads).
Historical coverage: 2022-01-01 to present (incremental updates)
Data source: https://ticks.ex2archive.com/ticks/EURUSD_Raw_Spread/
Total ticks: ~33M (3 years)
Zero-spreads: 98.03%';Table 2: standard_ticks (all historical data)
CREATE TABLE standard_ticks (
Timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
Bid DOUBLE NOT NULL,
Ask DOUBLE NOT NULL,
PRIMARY KEY (Timestamp) -- Ensures no duplicates during incremental updates
);
CREATE INDEX idx_standard_timestamp ON standard_ticks(Timestamp);
COMMENT ON TABLE standard_ticks IS
'Exness EURUSD Standard variant (traditional quotes, always Bid < Ask).
Historical coverage: 2022-01-01 to present (incremental updates)
Data source: https://ticks.ex2archive.com/ticks/EURUSD/
Total ticks: ~39M (3 years)
Zero-spreads: 0%
Used for: Position ratio calculation (ASOF merge with Raw_Spread)';Table 3: ohlc_1m (all historical data, Phase7 30-column v1.6.0)
CREATE TABLE ohlc_1m (
Timestamp TIMESTAMP WITH TIME ZONE PRIMARY KEY,
Open DOUBLE NOT NULL,
High DOUBLE NOT NULL,
Low DOUBLE NOT NULL,
Close DOUBLE NOT NULL,
raw_spread_avg DOUBLE NOT NULL,
standard_spread_avg DOUBLE NOT NULL,
tick_count_raw_spread BIGINT NOT NULL,
tick_count_standard BIGINT NOT NULL,
-- ... plus 21 additional columns (normalized metrics, timezone/session tracking,
-- holiday detection, 10 exchange session flags)
-- Full schema: docs/DATABASE_SCHEMA.md
);
CREATE INDEX idx_ohlc_timestamp ON ohlc_1m(Timestamp);
COMMENT ON TABLE ohlc_1m IS
'EURUSD 1-minute OHLC bars (Phase7 v1.6.0 with 10 global exchange sessions - trading hour detection).
Historical coverage: 2022-01-01 to present (regenerated after tick updates)
Total bars: ~1.1M (3 years × 365 days × 1440 minutes)
OHLC Source: Raw_Spread BID prices
Spreads: Dual-variant (Raw_Spread + Standard)
Normalized metrics: range_per_spread, range_per_tick, body_per_spread, body_per_tick
Sessions: NY, London with DST handling + 10 global exchange flags (NYSE, LSE, etc.)
Complete schema: docs/DATABASE_SCHEMA.md';Table 4: metadata (tracks what data exists)
CREATE TABLE metadata (
key VARCHAR PRIMARY KEY,
value VARCHAR NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Example rows:
-- ('earliest_date', '2022-01-01')
-- ('latest_date', '2025-10-12')
-- ('last_update', '2025-10-12T15:30:00Z')
-- ('total_months', '46')Per Instrument (EURUSD):
- 1 month: 11.26 MB (validated Sep 2024)
- 1 year: 135 MB (12 months × 11.26 MB)
- 3 years: 405 MB (3 years × 135 MB)
- 5 years: 675 MB (5 years × 135 MB)
Multi-Instrument:
- EURUSD + XAUUSD (3 years): 810 MB (2 × 405 MB)
- EURUSD + XAUUSD (5 years): 1.35 GB (2 × 675 MB)
Conclusion: Single-file approach is extremely efficient even for multi-year data.
processor = ExnessDataProcessor()
# Check what's missing and download new data
result = processor.update_data(pair="EURUSD", start_date="2022-01-01")
# Automatically:
# 1. Queries existing data: SELECT MIN(Timestamp), MAX(Timestamp) FROM raw_spread_ticks
# 2. Discovers missing months from ex2archive.com
# 3. Downloads missing months (Raw_Spread + Standard)
# 4. Appends to existing tables (PRIMARY KEY prevents duplicates)
# 5. Regenerates OHLC for new date ranges
# 6. Updates metadata tabledef _discover_missing_months(self, pair: str, start_date: str) -> List[Tuple[int, int]]:
"""
Discover which months are missing from DuckDB.
Args:
pair: Currency pair (e.g., "EURUSD")
start_date: Earliest date to consider (e.g., "2022-01-01")
Returns:
List of (year, month) tuples to download
Example:
Existing data: 2022-01 to 2024-08, 2024-10 to 2025-09
Missing: 2024-09
Returns: [(2024, 9)]
"""
passPRIMARY KEY constraints on Timestamp columns ensure no duplicates during incremental updates:
-- If tick already exists, INSERT will fail (or use INSERT OR IGNORE)
INSERT OR IGNORE INTO raw_spread_ticks (Timestamp, Bid, Ask)
VALUES ('2024-09-01 12:00:00.123Z', 1.10500, 1.10520);When new tick data is added, regenerate OHLC only for affected date range:
-- Delete old OHLC for date range
DELETE FROM ohlc_1m
WHERE Timestamp >= '2024-09-01' AND Timestamp < '2024-10-01';
-- Regenerate OHLC for date range
INSERT INTO ohlc_1m
SELECT
DATE_TRUNC('minute', r.Timestamp) as Timestamp,
FIRST(r.Bid ORDER BY r.Timestamp) as Open,
MAX(r.Bid) as High,
MIN(r.Bid) as Low,
LAST(r.Bid ORDER BY r.Timestamp) as Close,
AVG(r.Ask - r.Bid) as raw_spread_avg,
AVG(s.Ask - s.Bid) as standard_spread_avg,
COUNT(r.Timestamp) as tick_count_raw_spread,
COUNT(s.Timestamp) as tick_count_standard
FROM raw_spread_ticks r
LEFT JOIN standard_ticks s
ON DATE_TRUNC('minute', r.Timestamp) = DATE_TRUNC('minute', s.Timestamp)
WHERE r.Timestamp >= '2024-09-01' AND r.Timestamp < '2024-10-01'
GROUP BY DATE_TRUNC('minute', r.Timestamp)
ORDER BY Timestamp;class ExnessDataProcessor:
"""Process Exness tick data with single-file multi-year storage."""
def __init__(self, base_dir: Path = None):
"""
Initialize processor.
File structure:
~/eon/exness-data/
├── eurusd.duckdb
├── xauusd.duckdb
└── export/
"""
if base_dir is None:
base_dir = Path.home() / "eon" / "exness-data"
self.base_dir = base_dir
self.base_dir.mkdir(parents=True, exist_ok=True)
def update_data(
self,
pair: str = "EURUSD",
start_date: str = "2022-01-01",
force_redownload: bool = False
) -> Dict[str, Any]:
"""
Update instrument database with latest data from Exness.
Workflow:
1. Open existing DuckDB (or create if doesn't exist)
2. Query existing date range
3. Discover missing months from ex2archive.com
4. Download missing data (Raw_Spread + Standard)
5. Append to tick tables (PRIMARY KEY prevents duplicates)
6. Regenerate OHLC for affected date ranges
7. Update metadata table
Args:
pair: Currency pair (e.g., "EURUSD")
start_date: Earliest date to fetch (default: 3 years ago)
force_redownload: Re-download all data (default: False)
Returns:
Dictionary with update results:
- duckdb_path: Path to database file
- existing_date_range: (min, max) dates before update
- new_date_range: (min, max) dates after update
- months_downloaded: Number of new months
- new_ticks_added: Total new ticks inserted
- ohlc_bars_generated: OHLC bars regenerated
- duckdb_size_mb: Current database size
Example:
>>> processor = ExnessDataProcessor()
>>> # First run: Downloads 3 years of data
>>> result = processor.update_data("EURUSD", start_date="2022-01-01")
>>> print(f"Downloaded {result['months_downloaded']} months")
>>>
>>> # Subsequent runs: Only downloads new data
>>> result = processor.update_data("EURUSD")
>>> print(f"Added {result['new_ticks_added']:,} new ticks")
"""
pass
def query_ticks(
self,
pair: str = "EURUSD",
variant: str = "raw_spread",
start_date: str = None,
end_date: str = None,
filter_sql: str = None
) -> pd.DataFrame:
"""
Query tick data from unified DuckDB (time range).
Args:
pair: Currency pair
variant: "raw_spread" or "standard"
start_date: Start date (YYYY-MM-DD), None = earliest
end_date: End date (YYYY-MM-DD), None = latest
filter_sql: Optional SQL WHERE clause
Returns:
DataFrame with tick data for date range
Example:
>>> processor = ExnessDataProcessor()
>>>
>>> # Get all ticks for September 2024
>>> df = processor.query_ticks(
... "EURUSD",
... variant="raw_spread",
... start_date="2024-09-01",
... end_date="2024-10-01"
... )
>>>
>>> # Get zero-spread ticks for entire history
>>> df_zero = processor.query_ticks(
... "EURUSD",
... variant="raw_spread",
... filter_sql="Bid = Ask"
... )
>>>
>>> # Get ticks for last 7 days
>>> from datetime import datetime, timedelta
>>> end = datetime.now()
>>> start = end - timedelta(days=7)
>>> df_recent = processor.query_ticks(
... "EURUSD",
... start_date=start.strftime("%Y-%m-%d"),
... end_date=end.strftime("%Y-%m-%d")
... )
"""
pass
def query_ohlc(
self,
pair: str = "EURUSD",
timeframe: str = "1m",
start_date: str = None,
end_date: str = None
) -> pd.DataFrame:
"""
Query OHLC data (stored or resampled on-demand).
Args:
pair: Currency pair
timeframe: "1m" (stored), "5m", "15m", "1h", "4h", "1d" (resampled)
start_date: Start date (YYYY-MM-DD), None = earliest
end_date: End date (YYYY-MM-DD), None = latest
Returns:
DataFrame with OHLC data (Phase7 30-column v1.6.0)
Example:
>>> processor = ExnessDataProcessor()
>>>
>>> # Get 1m bars for September 2024
>>> df_1m = processor.query_ohlc(
... "EURUSD",
... timeframe="1m",
... start_date="2024-09-01",
... end_date="2024-10-01"
... )
>>>
>>> # Get 1h bars for entire 2024
>>> df_1h = processor.query_ohlc(
... "EURUSD",
... timeframe="1h",
... start_date="2024-01-01",
... end_date="2025-01-01"
... )
>>>
>>> # Get daily bars for 3 years
>>> df_1d = processor.query_ohlc(
... "EURUSD",
... timeframe="1d",
... start_date="2022-01-01"
... )
"""
pass
def get_data_coverage(self, pair: str = "EURUSD") -> Dict[str, Any]:
"""
Get data coverage statistics for instrument.
Args:
pair: Currency pair
Returns:
Dictionary with coverage stats:
- earliest_date: First tick timestamp
- latest_date: Last tick timestamp
- total_days: Number of days covered
- total_months: Number of months covered
- raw_spread_tick_count: Total Raw_Spread ticks
- standard_tick_count: Total Standard ticks
- ohlc_bar_count: Total 1m OHLC bars
- duckdb_size_mb: Database file size
- gaps: List of missing date ranges (if any)
Example:
>>> processor = ExnessDataProcessor()
>>> coverage = processor.get_data_coverage("EURUSD")
>>> print(f"Coverage: {coverage['earliest_date']} to {coverage['latest_date']}")
>>> print(f"Total: {coverage['total_months']} months")
>>> if coverage['gaps']:
... print(f"Gaps found: {coverage['gaps']}")
"""
pass
def export_to_parquet(
self,
pair: str = "EURUSD",
variant: str = "raw_spread",
start_date: str = None,
end_date: str = None,
output_dir: Path = None
) -> Path:
"""
Export tick data to Parquet (optional, on-demand).
Args:
pair: Currency pair
variant: "raw_spread" or "standard"
start_date: Start date (YYYY-MM-DD), None = earliest
end_date: End date (YYYY-MM-DD), None = latest
output_dir: Output directory (default: ~/eon/exness-data/export/)
Returns:
Path to exported Parquet file
Example:
>>> processor = ExnessDataProcessor()
>>>
>>> # Export September 2024 Raw_Spread to Parquet
>>> path = processor.export_to_parquet(
... "EURUSD",
... variant="raw_spread",
... start_date="2024-09-01",
... end_date="2024-10-01"
... )
>>> print(f"Exported to: {path}")
"""
passdef migrate_monthly_to_unified(
self,
pair: str = "EURUSD",
monthly_dir: Path = None,
delete_old: bool = False
) -> Dict[str, Any]:
"""
Migrate existing monthly DuckDB files to single unified file.
Args:
pair: Currency pair
monthly_dir: Directory with monthly files (e.g., eurusd_2024_09.duckdb)
delete_old: Delete monthly files after migration
Returns:
Dictionary with migration results
Example:
>>> processor = ExnessDataProcessor()
>>> result = processor.migrate_monthly_to_unified(
... "EURUSD",
... monthly_dir=Path("~/eon/exness-data/monthly/"),
... delete_old=True
... )
>>> print(f"Migrated {result['months_migrated']} months")
"""
pass-
Update file structure
- Change from
eurusd_2024_09.duckdbtoeurusd.duckdb - Remove monthly directory separation
- Add metadata table for tracking coverage
- Change from
-
Implement incremental updates
- Create
_discover_missing_months()method - Add PRIMARY KEY constraints to prevent duplicates
- Implement
update_data()with automatic gap detection - Add progress tracking for multi-month downloads
- Create
-
Update download logic
- Fix URL pattern:
/ticks/{variant}/{year}/{month}/ - Support dual-variant downloads (Raw_Spread + Standard)
- Batch download missing months
- Add retry logic for failed downloads
- Fix URL pattern:
-
Implement time-range queries
- Update
query_ticks()with start_date/end_date parameters - Update
query_ohlc()with start_date/end_date parameters - Add date range validation
- Optimize queries with indexes
- Update
-
Add coverage tracking
- Implement
get_data_coverage()method - Detect gaps in historical data
- Report statistics (earliest/latest dates, tick counts)
- Implement
- Implement incremental OHLC
- Delete OHLC for affected date ranges only
- Regenerate OHLC after tick updates
- Add progress tracking for large regenerations
- Validate OHLC integrity (no missing minutes)
-
Create migration utility (NOT NEEDED - users create new unified database with
update_data())- Users can run
update_data(pair, start_date)to create unified database - Old monthly files can be deleted manually after verification
- Simpler approach than migration utility
- Users can run
-
Test incremental updates
- Test initial 3-year download (validated with 13 months: Oct 2024 - Oct 2025)
- Test subsequent incremental updates (validated: 0 months added when up to date)
- Test gap detection and filling (validated with
_discover_missing_months()) - Test duplicate prevention (validated: PRIMARY KEY constraints working)
- Update documentation
- Update README.md with single-file architecture
- Update CLAUDE.md project memory
- Add incremental update examples (basic_usage.py, batch_processing.py)
- Document migration process (users run
update_data()to create new unified database) - Add troubleshooting guide (in README.md - API Reference section)
Based on real data validation:
| Operation | Target | Notes |
|---|---|---|
| 3-year initial download | <5 min | 36 months × ~8s download/load |
| Incremental update (1 month) | <10s | Download + append + OHLC regen |
| Tick query (date range) | <100ms | Indexed timestamp queries |
| OHLC query (1 year) | <50ms | ~500K bars |
| OHLC resample (1 year to 1d) | <200ms | 500K → 365 bars |
| File size (3 years) | <500 MB | Validated extrapolation |
- ✅ Single file per instrument (no monthly separation)
- ✅ Multi-year storage (minimum 3 years)
- ✅ Incremental updates (automatic gap detection)
- ✅ Duplicate prevention (PRIMARY KEY constraints)
- ✅ Phase7 compliance (30-column (v1.6.0) OHLC schema with trading hour detection)
- ✅ Time-range queries (start_date/end_date support)
- ✅ Fast updates: <10s for 1 month
- ✅ Fast queries: <100ms for date range queries
- ✅ Efficient storage: <500 MB per instrument for 3 years
- ✅ No data loss during migrations
- ✅ No duplicates during incremental updates
- ✅ Complete coverage (detect and fill gaps)
- ✅ Data integrity (OHLC regeneration after updates)
Recommended Architecture: One DuckDB file per instrument with multi-year data and incremental updates
Key Advantages:
- Maximum Simplicity: ONE file = ONE instrument (no monthly/yearly separation)
- Continuous Time Series: Seamless queries across entire history
- Incremental Updates: Automatic detection and download of new data
- Efficient Storage: ~400-500 MB per instrument for 3 years
- Easy Management: One file to backup, one file to query
Next Steps:
- Implement single-file architecture (Priority 1)
- Add time-range query support (Priority 2)
- Implement incremental OHLC management (Priority 3)
- Create migration utility (Priority 4)
- Update all documentation (Priority 5)
Validation: ✅ Tested with real EURUSD Sep 2024 data, extrapolated to 3 years
References:
- Phase7 Spec:
research/eurusd-zero-spread-deviations/data/plan/phase7_bid_ohlc_construction_v1.1.0.md - Data Sources:
EXNESS_DATA_SOURCES.md