exness-data-preprocess 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,899 @@
1
+ """
2
+ Core processor for Exness forex tick data.
3
+
4
+ Architecture (v2.0.0 - Unified Single-File Multi-Year):
5
+ 1. One DuckDB file per instrument (e.g., eurusd.duckdb)
6
+ 2. Three tables: raw_spread_ticks, standard_ticks, ohlc_1m
7
+ 3. Dual-variant downloads (Raw_Spread + Standard) for Phase7 compliance
8
+ 4. Incremental updates with automatic gap detection
9
+ 5. Metadata table for coverage tracking
10
+
11
+ Phase7 Schema (9 columns):
12
+ - Timestamp, Open, High, Low, Close (BID-based from Raw_Spread)
13
+ - raw_spread_avg, standard_spread_avg (dual spread tracking)
14
+ - tick_count_raw_spread, tick_count_standard (dual tick counts)
15
+
16
+ Storage: ~135 MB/year, ~405 MB for 3 years per instrument
17
+ """
18
+
19
+ import zipfile
20
+ from pathlib import Path
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+ from urllib.error import URLError
23
+ from urllib.request import urlretrieve
24
+
25
+ import duckdb
26
+ import pandas as pd
27
+
28
+
29
+ class ExnessDataProcessor:
30
+ """
31
+ Process Exness forex tick data with unified multi-year architecture.
32
+
33
+ Architecture (v2.0.0):
34
+ - One DuckDB file per instrument (eurusd.duckdb not monthly files)
35
+ - Dual-variant storage (Raw_Spread + Standard) for Phase7
36
+ - Incremental updates with automatic gap detection
37
+ - 9-column OHLC schema with dual spreads and tick counts
38
+ - 3-year minimum historical coverage
39
+
40
+ Features:
41
+ - Downloads from ticks.ex2archive.com with correct URL pattern
42
+ - PRIMARY KEY constraints prevent duplicates during updates
43
+ - Metadata table tracks earliest/latest dates
44
+ - Direct SQL queries on ticks without loading into memory
45
+ - On-demand OHLC resampling for any timeframe
46
+
47
+ Example:
48
+ >>> processor = ExnessDataProcessor()
49
+ >>> # Initial 3-year download
50
+ >>> result = processor.update_data("EURUSD", start_date="2022-01-01")
51
+ >>> print(f"Downloaded {result['months_added']} months")
52
+ >>> # Query ticks for date range
53
+ >>> df_ticks = processor.query_ticks("EURUSD", start_date="2024-01-01")
54
+ >>> # Query OHLC
55
+ >>> df_ohlc = processor.query_ohlc("EURUSD", timeframe="1h")
56
+ """
57
+
58
+ def __init__(self, base_dir: Path = None):
59
+ """
60
+ Initialize processor.
61
+
62
+ Args:
63
+ base_dir: Base directory for data storage. Defaults to ~/eon/exness-data/
64
+ """
65
+ if base_dir is None:
66
+ base_dir = Path.home() / "eon" / "exness-data"
67
+
68
+ self.base_dir = base_dir
69
+ self.temp_dir = base_dir / "temp"
70
+
71
+ # Create directories
72
+ self.base_dir.mkdir(parents=True, exist_ok=True)
73
+ self.temp_dir.mkdir(parents=True, exist_ok=True)
74
+
75
+ def download_exness_zip(
76
+ self, year: int, month: int, pair: str = "EURUSD", variant: str = "Raw_Spread"
77
+ ) -> Optional[Path]:
78
+ """
79
+ Download Exness ZIP file for specific month and variant.
80
+
81
+ Args:
82
+ year: Year (e.g., 2024)
83
+ month: Month (1-12)
84
+ pair: Currency pair (default: EURUSD)
85
+ variant: Data variant ("Raw_Spread" or "" for Standard)
86
+
87
+ Returns:
88
+ Path to downloaded ZIP file, or None if download failed
89
+
90
+ Example:
91
+ >>> processor = ExnessDataProcessor()
92
+ >>> raw_zip = processor.download_exness_zip(2024, 9, variant="Raw_Spread")
93
+ >>> std_zip = processor.download_exness_zip(2024, 9, variant="")
94
+ """
95
+ # Construct symbol name
96
+ symbol = f"{pair}_{variant}" if variant else pair
97
+
98
+ # Correct URL pattern: /ticks/{symbol}/{year}/{month}/
99
+ url = f"https://ticks.ex2archive.com/ticks/{symbol}/{year}/{month:02d}/Exness_{symbol}_{year}_{month:02d}.zip"
100
+ zip_path = self.temp_dir / f"Exness_{symbol}_{year}_{month:02d}.zip"
101
+
102
+ if zip_path.exists():
103
+ return zip_path
104
+
105
+ try:
106
+ print(f"Downloading: {url}")
107
+ urlretrieve(url, zip_path)
108
+ size_mb = zip_path.stat().st_size / 1024 / 1024
109
+ print(f"✓ Downloaded: {size_mb:.2f} MB")
110
+ return zip_path
111
+ except URLError as e:
112
+ print(f"✗ Download failed: {e}")
113
+ return None
114
+
115
+ def _get_or_create_db(self, pair: str) -> Path:
116
+ """
117
+ Get DuckDB path and ensure schema exists.
118
+
119
+ Creates tables if they don't exist:
120
+ - raw_spread_ticks (PRIMARY KEY on Timestamp)
121
+ - standard_ticks (PRIMARY KEY on Timestamp)
122
+ - ohlc_1m (Phase7 9-column schema)
123
+ - metadata (coverage tracking)
124
+ """
125
+ duckdb_path = self.base_dir / f"{pair.lower()}.duckdb"
126
+ conn = duckdb.connect(str(duckdb_path))
127
+
128
+ # Create raw_spread_ticks table
129
+ conn.execute("""
130
+ CREATE TABLE IF NOT EXISTS raw_spread_ticks (
131
+ Timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
132
+ Bid DOUBLE NOT NULL,
133
+ Ask DOUBLE NOT NULL,
134
+ PRIMARY KEY (Timestamp)
135
+ )
136
+ """)
137
+
138
+ # Add table and column comments for raw_spread_ticks
139
+ conn.execute("""
140
+ COMMENT ON TABLE raw_spread_ticks IS
141
+ 'Exness Raw_Spread variant (execution prices, ~98% zero-spreads).
142
+ Data source: https://ticks.ex2archive.com/ticks/{SYMBOL}_Raw_Spread/{YEAR}/{MONTH}/'
143
+ """)
144
+ conn.execute(
145
+ "COMMENT ON COLUMN raw_spread_ticks.Timestamp IS 'Microsecond-precision tick timestamp (UTC)'"
146
+ )
147
+ conn.execute("COMMENT ON COLUMN raw_spread_ticks.Bid IS 'Bid price (execution price)'")
148
+ conn.execute("COMMENT ON COLUMN raw_spread_ticks.Ask IS 'Ask price (execution price)'")
149
+
150
+ # Create standard_ticks table
151
+ conn.execute("""
152
+ CREATE TABLE IF NOT EXISTS standard_ticks (
153
+ Timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
154
+ Bid DOUBLE NOT NULL,
155
+ Ask DOUBLE NOT NULL,
156
+ PRIMARY KEY (Timestamp)
157
+ )
158
+ """)
159
+
160
+ # Add table and column comments for standard_ticks
161
+ conn.execute("""
162
+ COMMENT ON TABLE standard_ticks IS
163
+ 'Exness Standard variant (traditional quotes, 0% zero-spreads, always Bid < Ask).
164
+ Data source: https://ticks.ex2archive.com/ticks/{SYMBOL}/{YEAR}/{MONTH}/'
165
+ """)
166
+ conn.execute(
167
+ "COMMENT ON COLUMN standard_ticks.Timestamp IS 'Microsecond-precision tick timestamp (UTC)'"
168
+ )
169
+ conn.execute("COMMENT ON COLUMN standard_ticks.Bid IS 'Bid price (always < Ask)'")
170
+ conn.execute("COMMENT ON COLUMN standard_ticks.Ask IS 'Ask price (always > Bid)'")
171
+
172
+ # Create metadata table
173
+ conn.execute("""
174
+ CREATE TABLE IF NOT EXISTS metadata (
175
+ key VARCHAR PRIMARY KEY,
176
+ value VARCHAR NOT NULL,
177
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
178
+ )
179
+ """)
180
+
181
+ # Add table and column comments for metadata
182
+ conn.execute("""
183
+ COMMENT ON TABLE metadata IS
184
+ 'Database coverage tracking and statistics (earliest/latest dates, tick counts, etc.)'
185
+ """)
186
+ conn.execute(
187
+ "COMMENT ON COLUMN metadata.key IS 'Metadata key identifier (e.g., earliest_date, latest_date)'"
188
+ )
189
+ conn.execute("COMMENT ON COLUMN metadata.value IS 'Metadata value (string representation)'")
190
+ conn.execute("COMMENT ON COLUMN metadata.updated_at IS 'Last update timestamp'")
191
+
192
+ # Create ohlc_1m table (initially empty, generated on demand)
193
+ conn.execute("""
194
+ CREATE TABLE IF NOT EXISTS ohlc_1m (
195
+ Timestamp TIMESTAMP WITH TIME ZONE PRIMARY KEY,
196
+ Open DOUBLE NOT NULL,
197
+ High DOUBLE NOT NULL,
198
+ Low DOUBLE NOT NULL,
199
+ Close DOUBLE NOT NULL,
200
+ raw_spread_avg DOUBLE,
201
+ standard_spread_avg DOUBLE,
202
+ tick_count_raw_spread BIGINT,
203
+ tick_count_standard BIGINT
204
+ )
205
+ """)
206
+
207
+ # Add table and column comments for ohlc_1m
208
+ conn.execute("""
209
+ COMMENT ON TABLE ohlc_1m IS
210
+ 'Phase7 v1.1.0 1-minute OHLC bars (BID-only from Raw_Spread, dual-variant spreads and tick counts).
211
+ OHLC Source: Raw_Spread BID prices. Spreads: Dual-variant (Raw_Spread + Standard).'
212
+ """)
213
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Timestamp IS 'Minute-aligned bar timestamp'")
214
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Open IS 'Opening price (first Raw_Spread Bid)'")
215
+ conn.execute("COMMENT ON COLUMN ohlc_1m.High IS 'High price (max Raw_Spread Bid)'")
216
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Low IS 'Low price (min Raw_Spread Bid)'")
217
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Close IS 'Closing price (last Raw_Spread Bid)'")
218
+ conn.execute(
219
+ "COMMENT ON COLUMN ohlc_1m.raw_spread_avg IS 'Average spread from Raw_Spread variant (NULL if no ticks)'"
220
+ )
221
+ conn.execute(
222
+ "COMMENT ON COLUMN ohlc_1m.standard_spread_avg IS 'Average spread from Standard variant (NULL if no Standard ticks for that minute)'"
223
+ )
224
+ conn.execute(
225
+ "COMMENT ON COLUMN ohlc_1m.tick_count_raw_spread IS 'Number of ticks from Raw_Spread variant (NULL if no ticks)'"
226
+ )
227
+ conn.execute(
228
+ "COMMENT ON COLUMN ohlc_1m.tick_count_standard IS 'Number of ticks from Standard variant (NULL if no Standard ticks for that minute)'"
229
+ )
230
+
231
+ conn.close()
232
+ return duckdb_path
233
+
234
+ def _load_ticks_from_zip(self, zip_path: Path) -> pd.DataFrame:
235
+ """Load ticks from ZIP file into DataFrame."""
236
+ with zipfile.ZipFile(zip_path, "r") as zf:
237
+ csv_name = zip_path.stem + ".csv"
238
+ with zf.open(csv_name) as csv_file:
239
+ df = pd.read_csv(
240
+ csv_file, usecols=["Timestamp", "Bid", "Ask"], parse_dates=["Timestamp"]
241
+ )
242
+
243
+ # Convert to UTC timezone-aware
244
+ df["Timestamp"] = pd.to_datetime(df["Timestamp"], utc=True)
245
+ return df
246
+
247
+ def _append_ticks_to_db(self, duckdb_path: Path, df: pd.DataFrame, table_name: str) -> int:
248
+ """
249
+ Append ticks to DuckDB table.
250
+
251
+ PRIMARY KEY constraint automatically prevents duplicates.
252
+
253
+ Returns:
254
+ Number of rows inserted (may be less than df length if duplicates)
255
+ """
256
+ conn = duckdb.connect(str(duckdb_path))
257
+
258
+ try:
259
+ # Get count before insert
260
+ count_before = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
261
+
262
+ # Use INSERT OR IGNORE to skip duplicates
263
+ conn.register("df_temp", df)
264
+ conn.execute(f"""
265
+ INSERT OR IGNORE INTO {table_name}
266
+ SELECT * FROM df_temp
267
+ """)
268
+
269
+ # Get count after insert
270
+ count_after = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
271
+ rows_inserted = count_after - count_before
272
+ finally:
273
+ conn.close()
274
+
275
+ return rows_inserted
276
+
277
+ def _discover_missing_months(self, pair: str, start_date: str) -> List[Tuple[int, int]]:
278
+ """
279
+ Discover which months need to be downloaded.
280
+
281
+ Args:
282
+ pair: Currency pair
283
+ start_date: Earliest date to consider (YYYY-MM-DD)
284
+
285
+ Returns:
286
+ List of (year, month) tuples to download
287
+ """
288
+ duckdb_path = self.base_dir / f"{pair.lower()}.duckdb"
289
+
290
+ if not duckdb_path.exists():
291
+ # No database yet - need full historical download
292
+ # From start_date to current month
293
+ from datetime import datetime
294
+
295
+ start = datetime.strptime(start_date, "%Y-%m-%d")
296
+ today = datetime.now()
297
+
298
+ months = []
299
+ current = start
300
+ while current <= today:
301
+ months.append((current.year, current.month))
302
+ # Next month
303
+ if current.month == 12:
304
+ current = current.replace(year=current.year + 1, month=1)
305
+ else:
306
+ current = current.replace(month=current.month + 1)
307
+
308
+ return months
309
+
310
+ # Query existing coverage
311
+ conn = duckdb.connect(str(duckdb_path), read_only=True)
312
+ try:
313
+ result = conn.execute("""
314
+ SELECT
315
+ DATE_TRUNC('month', MIN(Timestamp)) as earliest,
316
+ DATE_TRUNC('month', MAX(Timestamp)) as latest
317
+ FROM raw_spread_ticks
318
+ """).fetchone()
319
+
320
+ if result and result[0]:
321
+ # Find gaps in coverage
322
+ # For now, simple approach: fill from earliest to current
323
+ # TODO: Implement gap detection within range
324
+ earliest = pd.to_datetime(result[0])
325
+ latest = pd.to_datetime(result[1])
326
+
327
+ # Need months before earliest or after latest
328
+ from datetime import datetime
329
+
330
+ start = datetime.strptime(start_date, "%Y-%m-%d")
331
+ today = datetime.now()
332
+
333
+ months = []
334
+
335
+ # Before earliest
336
+ current = start
337
+ while current.year * 100 + current.month < earliest.year * 100 + earliest.month:
338
+ months.append((current.year, current.month))
339
+ if current.month == 12:
340
+ current = current.replace(year=current.year + 1, month=1)
341
+ else:
342
+ current = current.replace(month=current.month + 1)
343
+
344
+ # After latest
345
+ current = latest
346
+ if current.month == 12:
347
+ current = current.replace(year=current.year + 1, month=1)
348
+ else:
349
+ current = current.replace(month=current.month + 1)
350
+
351
+ while current.year * 100 + current.month <= today.year * 100 + today.month:
352
+ months.append((current.year, current.month))
353
+ if current.month == 12:
354
+ current = current.replace(year=current.year + 1, month=1)
355
+ else:
356
+ current = current.replace(month=current.month + 1)
357
+
358
+ return months
359
+ else:
360
+ # Empty database
361
+ from datetime import datetime
362
+
363
+ start = datetime.strptime(start_date, "%Y-%m-%d")
364
+ today = datetime.now()
365
+
366
+ months = []
367
+ current = start
368
+ while current <= today:
369
+ months.append((current.year, current.month))
370
+ if current.month == 12:
371
+ current = current.replace(year=current.year + 1, month=1)
372
+ else:
373
+ current = current.replace(month=current.month + 1)
374
+
375
+ return months
376
+ finally:
377
+ conn.close()
378
+
379
+ def update_data(
380
+ self,
381
+ pair: str = "EURUSD",
382
+ start_date: str = "2022-01-01",
383
+ force_redownload: bool = False,
384
+ delete_zip: bool = True,
385
+ ) -> Dict[str, Any]:
386
+ """
387
+ Update instrument database with latest data from Exness.
388
+
389
+ Workflow:
390
+ 1. Ensure database and schema exist
391
+ 2. Discover missing months
392
+ 3. Download missing months (Raw_Spread + Standard)
393
+ 4. Append ticks to tables (PRIMARY KEY prevents duplicates)
394
+ 5. Regenerate OHLC for new data
395
+ 6. Update metadata
396
+
397
+ Args:
398
+ pair: Currency pair (e.g., "EURUSD")
399
+ start_date: Earliest date to download (YYYY-MM-DD)
400
+ force_redownload: Force re-download even if data exists
401
+ delete_zip: Delete ZIP files after processing
402
+
403
+ Returns:
404
+ Dictionary with update results:
405
+ - duckdb_path: Path to database file
406
+ - months_added: Number of months downloaded
407
+ - raw_ticks_added: Number of Raw_Spread ticks added
408
+ - standard_ticks_added: Number of Standard ticks added
409
+ - ohlc_bars: Total OHLC bars after update
410
+ - duckdb_size_mb: Database file size
411
+
412
+ Example:
413
+ >>> processor = ExnessDataProcessor()
414
+ >>> # Initial 3-year download
415
+ >>> result = processor.update_data("EURUSD", start_date="2022-01-01")
416
+ >>> print(f"Downloaded {result['months_added']} months")
417
+ >>> # Incremental update (only new months)
418
+ >>> result = processor.update_data("EURUSD")
419
+ >>> print(f"Added {result['months_added']} new months")
420
+ """
421
+ print(f"\n{'=' * 70}")
422
+ print(f"Updating {pair} database")
423
+ print(f"{'=' * 70}")
424
+
425
+ # Step 1: Get or create database
426
+ duckdb_path = self._get_or_create_db(pair)
427
+ print(f"Database: {duckdb_path}")
428
+
429
+ # Step 2: Discover missing months
430
+ print(f"\nDiscovering missing months (from {start_date})...")
431
+ missing_months = self._discover_missing_months(pair, start_date)
432
+ print(f"Found {len(missing_months)} months to download")
433
+
434
+ if not missing_months:
435
+ print("\n✓ Database is up to date")
436
+ return {
437
+ "duckdb_path": duckdb_path,
438
+ "months_added": 0,
439
+ "raw_ticks_added": 0,
440
+ "standard_ticks_added": 0,
441
+ "ohlc_bars": 0,
442
+ "duckdb_size_mb": duckdb_path.stat().st_size / 1024 / 1024,
443
+ }
444
+
445
+ # Step 3: Download and append ticks
446
+ raw_ticks_total = 0
447
+ standard_ticks_total = 0
448
+ months_success = 0
449
+
450
+ for year, month in missing_months:
451
+ print(f"\n--- Processing {year}-{month:02d} ---")
452
+
453
+ # Download Raw_Spread variant
454
+ raw_zip = self.download_exness_zip(year, month, pair, variant="Raw_Spread")
455
+ if raw_zip is None:
456
+ print(f"⚠️ Skipping {year}-{month:02d} (Raw_Spread not available)")
457
+ continue
458
+
459
+ # Download Standard variant
460
+ std_zip = self.download_exness_zip(year, month, pair, variant="")
461
+ if std_zip is None:
462
+ print(f"⚠️ Skipping {year}-{month:02d} (Standard not available)")
463
+ if delete_zip and raw_zip.exists():
464
+ raw_zip.unlink()
465
+ continue
466
+
467
+ # Load ticks
468
+ df_raw = self._load_ticks_from_zip(raw_zip)
469
+ df_std = self._load_ticks_from_zip(std_zip)
470
+
471
+ # Append to database
472
+ raw_added = self._append_ticks_to_db(duckdb_path, df_raw, "raw_spread_ticks")
473
+ std_added = self._append_ticks_to_db(duckdb_path, df_std, "standard_ticks")
474
+
475
+ print(f"✓ Added {raw_added:,} Raw_Spread ticks")
476
+ print(f"✓ Added {std_added:,} Standard ticks")
477
+
478
+ raw_ticks_total += raw_added
479
+ standard_ticks_total += std_added
480
+ months_success += 1
481
+
482
+ # Delete ZIPs
483
+ if delete_zip:
484
+ raw_zip.unlink()
485
+ std_zip.unlink()
486
+
487
+ # Step 4: Regenerate OHLC for all new data
488
+ if months_success > 0:
489
+ print("\nRegenerating OHLC (Phase7 9-column schema)...")
490
+ self._regenerate_ohlc(duckdb_path)
491
+ print("✓ OHLC regenerated")
492
+
493
+ # Step 5: Get final stats
494
+ conn = duckdb.connect(str(duckdb_path), read_only=True)
495
+ ohlc_bars = conn.execute("SELECT COUNT(*) FROM ohlc_1m").fetchone()[0]
496
+ conn.close()
497
+
498
+ duckdb_size_mb = duckdb_path.stat().st_size / 1024 / 1024
499
+
500
+ print(f"\n{'=' * 70}")
501
+ print("Update Summary")
502
+ print(f"{'=' * 70}")
503
+ print(f"Months added: {months_success}")
504
+ print(f"Raw_Spread ticks: {raw_ticks_total:,}")
505
+ print(f"Standard ticks: {standard_ticks_total:,}")
506
+ print(f"OHLC bars: {ohlc_bars:,}")
507
+ print(f"Database size: {duckdb_size_mb:.2f} MB")
508
+
509
+ return {
510
+ "duckdb_path": duckdb_path,
511
+ "months_added": months_success,
512
+ "raw_ticks_added": raw_ticks_total,
513
+ "standard_ticks_added": standard_ticks_total,
514
+ "ohlc_bars": ohlc_bars,
515
+ "duckdb_size_mb": duckdb_size_mb,
516
+ }
517
+
518
+ def _regenerate_ohlc(self, duckdb_path: Path) -> None:
519
+ """
520
+ Regenerate OHLC table with Phase7 9-column schema.
521
+
522
+ Uses LEFT JOIN to combine Raw_Spread and Standard variants.
523
+ """
524
+ conn = duckdb.connect(str(duckdb_path))
525
+
526
+ # Delete existing OHLC data
527
+ conn.execute("DELETE FROM ohlc_1m")
528
+
529
+ # Generate Phase7 9-column OHLC
530
+ conn.execute("""
531
+ INSERT INTO ohlc_1m
532
+ SELECT
533
+ DATE_TRUNC('minute', r.Timestamp) as Timestamp,
534
+ FIRST(r.Bid ORDER BY r.Timestamp) as Open,
535
+ MAX(r.Bid) as High,
536
+ MIN(r.Bid) as Low,
537
+ LAST(r.Bid ORDER BY r.Timestamp) as Close,
538
+ AVG(r.Ask - r.Bid) as raw_spread_avg,
539
+ AVG(s.Ask - s.Bid) as standard_spread_avg,
540
+ COUNT(r.Timestamp) as tick_count_raw_spread,
541
+ COUNT(s.Timestamp) as tick_count_standard
542
+ FROM raw_spread_ticks r
543
+ LEFT JOIN standard_ticks s
544
+ ON DATE_TRUNC('minute', r.Timestamp) = DATE_TRUNC('minute', s.Timestamp)
545
+ GROUP BY DATE_TRUNC('minute', r.Timestamp)
546
+ ORDER BY Timestamp
547
+ """)
548
+
549
+ conn.close()
550
+
551
+ def query_ticks(
552
+ self,
553
+ pair: str = "EURUSD",
554
+ variant: str = "raw_spread",
555
+ start_date: Optional[str] = None,
556
+ end_date: Optional[str] = None,
557
+ filter_sql: Optional[str] = None,
558
+ ) -> pd.DataFrame:
559
+ """
560
+ Query tick data with optional date range filtering.
561
+
562
+ Args:
563
+ pair: Currency pair
564
+ variant: "raw_spread" or "standard"
565
+ start_date: Start date (YYYY-MM-DD), inclusive
566
+ end_date: End date (YYYY-MM-DD), inclusive
567
+ filter_sql: Additional SQL WHERE clause
568
+
569
+ Returns:
570
+ DataFrame with tick data
571
+
572
+ Example:
573
+ >>> processor = ExnessDataProcessor()
574
+ >>> # Query all Raw_Spread ticks for 2024
575
+ >>> df = processor.query_ticks("EURUSD", start_date="2024-01-01", end_date="2024-12-31")
576
+ >>> # Query Standard ticks with custom filter
577
+ >>> df = processor.query_ticks("EURUSD", variant="standard", filter_sql="Bid > 1.10")
578
+ """
579
+ duckdb_path = self.base_dir / f"{pair.lower()}.duckdb"
580
+ if not duckdb_path.exists():
581
+ raise FileNotFoundError(f"Database not found: {duckdb_path}")
582
+
583
+ table_name = "raw_spread_ticks" if variant == "raw_spread" else "standard_ticks"
584
+
585
+ where_clauses = []
586
+ if start_date:
587
+ where_clauses.append(f"Timestamp >= '{start_date}'")
588
+ if end_date:
589
+ where_clauses.append(f"Timestamp <= '{end_date}'")
590
+ if filter_sql:
591
+ where_clauses.append(f"({filter_sql})")
592
+
593
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
594
+
595
+ conn = duckdb.connect(str(duckdb_path), read_only=True)
596
+ df = conn.execute(f"""
597
+ SELECT * FROM {table_name}
598
+ WHERE {where_sql}
599
+ ORDER BY Timestamp
600
+ """).df()
601
+ conn.close()
602
+
603
+ return df
604
+
605
+ def query_ohlc(
606
+ self,
607
+ pair: str = "EURUSD",
608
+ timeframe: str = "1m",
609
+ start_date: Optional[str] = None,
610
+ end_date: Optional[str] = None,
611
+ ) -> pd.DataFrame:
612
+ """
613
+ Query OHLC data with optional date range filtering and resampling.
614
+
615
+ Args:
616
+ pair: Currency pair
617
+ timeframe: Timeframe (1m, 5m, 15m, 1h, 4h, 1d)
618
+ start_date: Start date (YYYY-MM-DD), inclusive
619
+ end_date: End date (YYYY-MM-DD), inclusive
620
+
621
+ Returns:
622
+ DataFrame with OHLC data (Phase7 9-column schema)
623
+
624
+ Example:
625
+ >>> processor = ExnessDataProcessor()
626
+ >>> # Query 1m OHLC for January 2024
627
+ >>> df = processor.query_ohlc("EURUSD", timeframe="1m", start_date="2024-01-01", end_date="2024-01-31")
628
+ >>> # Query 1h OHLC (on-demand resampling)
629
+ >>> df = processor.query_ohlc("EURUSD", timeframe="1h", start_date="2024-01-01")
630
+ """
631
+ duckdb_path = self.base_dir / f"{pair.lower()}.duckdb"
632
+
633
+ if not duckdb_path.exists():
634
+ raise FileNotFoundError(f"Database not found: {duckdb_path}")
635
+
636
+ # Build WHERE clause for date filtering
637
+ where_clauses = []
638
+ if start_date:
639
+ where_clauses.append(f"Timestamp >= '{start_date}'")
640
+ if end_date:
641
+ where_clauses.append(f"Timestamp <= '{end_date}'")
642
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
643
+
644
+ conn = duckdb.connect(str(duckdb_path), read_only=True)
645
+
646
+ if timeframe == "1m":
647
+ df = conn.execute(f"""
648
+ SELECT * FROM ohlc_1m
649
+ WHERE {where_sql}
650
+ ORDER BY Timestamp
651
+ """).df()
652
+ else:
653
+ # Resample to higher timeframes
654
+ timeframe_config = {
655
+ "5m": (5, "minute"),
656
+ "15m": (15, "minute"),
657
+ "30m": (30, "minute"),
658
+ "1h": (60, "hour"),
659
+ "4h": (240, "hour"),
660
+ "1d": (1440, "day"),
661
+ }
662
+
663
+ if timeframe not in timeframe_config:
664
+ raise ValueError(f"Unsupported timeframe: {timeframe}")
665
+
666
+ minutes, trunc_unit = timeframe_config[timeframe]
667
+
668
+ # For hour and day, use DATE_TRUNC directly
669
+ if trunc_unit in ("hour", "day"):
670
+ df = conn.execute(f"""
671
+ SELECT
672
+ DATE_TRUNC('{trunc_unit}', Timestamp) as Timestamp,
673
+ FIRST(Open ORDER BY Timestamp) as Open,
674
+ MAX(High) as High,
675
+ MIN(Low) as Low,
676
+ LAST(Close ORDER BY Timestamp) as Close,
677
+ AVG(raw_spread_avg) as raw_spread_avg,
678
+ AVG(standard_spread_avg) as standard_spread_avg,
679
+ SUM(tick_count_raw_spread) as tick_count_raw_spread,
680
+ SUM(tick_count_standard) as tick_count_standard
681
+ FROM ohlc_1m
682
+ WHERE {where_sql}
683
+ GROUP BY DATE_TRUNC('{trunc_unit}', Timestamp)
684
+ ORDER BY Timestamp
685
+ """).df()
686
+ else:
687
+ # For sub-hour intervals, use time bucketing
688
+ df = conn.execute(f"""
689
+ SELECT
690
+ TIME_BUCKET(INTERVAL '{minutes} minutes', Timestamp) as Timestamp,
691
+ FIRST(Open ORDER BY Timestamp) as Open,
692
+ MAX(High) as High,
693
+ MIN(Low) as Low,
694
+ LAST(Close ORDER BY Timestamp) as Close,
695
+ AVG(raw_spread_avg) as raw_spread_avg,
696
+ AVG(standard_spread_avg) as standard_spread_avg,
697
+ SUM(tick_count_raw_spread) as tick_count_raw_spread,
698
+ SUM(tick_count_standard) as tick_count_standard
699
+ FROM ohlc_1m
700
+ WHERE {where_sql}
701
+ GROUP BY TIME_BUCKET(INTERVAL '{minutes} minutes', Timestamp)
702
+ ORDER BY Timestamp
703
+ """).df()
704
+
705
+ conn.close()
706
+ return df
707
+
708
+ def add_schema_comments(self, pair: str = "EURUSD") -> bool:
709
+ """
710
+ Add or update COMMENT ON statements to existing database.
711
+
712
+ This method is idempotent - it can be run multiple times safely.
713
+ Use this to retrofit self-documentation to existing databases.
714
+
715
+ Args:
716
+ pair: Currency pair
717
+
718
+ Returns:
719
+ True if successful, False if database doesn't exist
720
+
721
+ Example:
722
+ >>> processor = ExnessDataProcessor()
723
+ >>> # Add comments to existing database
724
+ >>> processor.add_schema_comments("EURUSD")
725
+ >>> # Verify comments were added
726
+ >>> conn = duckdb.connect("~/eon/exness-data/eurusd.duckdb", read_only=True)
727
+ >>> result = conn.execute("SELECT table_name, comment FROM duckdb_tables()").df()
728
+ >>> print(result)
729
+ """
730
+ duckdb_path = self.base_dir / f"{pair.lower()}.duckdb"
731
+
732
+ if not duckdb_path.exists():
733
+ print(f"Database not found: {duckdb_path}")
734
+ return False
735
+
736
+ print(f"Adding schema comments to: {duckdb_path}")
737
+ conn = duckdb.connect(str(duckdb_path))
738
+
739
+ # Add table and column comments for raw_spread_ticks
740
+ conn.execute("""
741
+ COMMENT ON TABLE raw_spread_ticks IS
742
+ 'Exness Raw_Spread variant (execution prices, ~98% zero-spreads).
743
+ Data source: https://ticks.ex2archive.com/ticks/{SYMBOL}_Raw_Spread/{YEAR}/{MONTH}/'
744
+ """)
745
+ conn.execute(
746
+ "COMMENT ON COLUMN raw_spread_ticks.Timestamp IS 'Microsecond-precision tick timestamp (UTC)'"
747
+ )
748
+ conn.execute("COMMENT ON COLUMN raw_spread_ticks.Bid IS 'Bid price (execution price)'")
749
+ conn.execute("COMMENT ON COLUMN raw_spread_ticks.Ask IS 'Ask price (execution price)'")
750
+
751
+ # Add table and column comments for standard_ticks
752
+ conn.execute("""
753
+ COMMENT ON TABLE standard_ticks IS
754
+ 'Exness Standard variant (traditional quotes, 0% zero-spreads, always Bid < Ask).
755
+ Data source: https://ticks.ex2archive.com/ticks/{SYMBOL}/{YEAR}/{MONTH}/'
756
+ """)
757
+ conn.execute(
758
+ "COMMENT ON COLUMN standard_ticks.Timestamp IS 'Microsecond-precision tick timestamp (UTC)'"
759
+ )
760
+ conn.execute("COMMENT ON COLUMN standard_ticks.Bid IS 'Bid price (always < Ask)'")
761
+ conn.execute("COMMENT ON COLUMN standard_ticks.Ask IS 'Ask price (always > Bid)'")
762
+
763
+ # Add table and column comments for metadata
764
+ conn.execute("""
765
+ COMMENT ON TABLE metadata IS
766
+ 'Database coverage tracking and statistics (earliest/latest dates, tick counts, etc.)'
767
+ """)
768
+ conn.execute(
769
+ "COMMENT ON COLUMN metadata.key IS 'Metadata key identifier (e.g., earliest_date, latest_date)'"
770
+ )
771
+ conn.execute("COMMENT ON COLUMN metadata.value IS 'Metadata value (string representation)'")
772
+ conn.execute("COMMENT ON COLUMN metadata.updated_at IS 'Last update timestamp'")
773
+
774
+ # Add table and column comments for ohlc_1m
775
+ conn.execute("""
776
+ COMMENT ON TABLE ohlc_1m IS
777
+ 'Phase7 v1.1.0 1-minute OHLC bars (BID-only from Raw_Spread, dual-variant spreads and tick counts).
778
+ OHLC Source: Raw_Spread BID prices. Spreads: Dual-variant (Raw_Spread + Standard).'
779
+ """)
780
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Timestamp IS 'Minute-aligned bar timestamp'")
781
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Open IS 'Opening price (first Raw_Spread Bid)'")
782
+ conn.execute("COMMENT ON COLUMN ohlc_1m.High IS 'High price (max Raw_Spread Bid)'")
783
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Low IS 'Low price (min Raw_Spread Bid)'")
784
+ conn.execute("COMMENT ON COLUMN ohlc_1m.Close IS 'Closing price (last Raw_Spread Bid)'")
785
+ conn.execute(
786
+ "COMMENT ON COLUMN ohlc_1m.raw_spread_avg IS 'Average spread from Raw_Spread variant (NULL if no ticks)'"
787
+ )
788
+ conn.execute(
789
+ "COMMENT ON COLUMN ohlc_1m.standard_spread_avg IS 'Average spread from Standard variant (NULL if no Standard ticks for that minute)'"
790
+ )
791
+ conn.execute(
792
+ "COMMENT ON COLUMN ohlc_1m.tick_count_raw_spread IS 'Number of ticks from Raw_Spread variant (NULL if no ticks)'"
793
+ )
794
+ conn.execute(
795
+ "COMMENT ON COLUMN ohlc_1m.tick_count_standard IS 'Number of ticks from Standard variant (NULL if no Standard ticks for that minute)'"
796
+ )
797
+
798
+ conn.close()
799
+ print(f"✓ Schema comments added to {pair}")
800
+ return True
801
+
802
+ def add_schema_comments_all(self) -> Dict[str, bool]:
803
+ """
804
+ Add schema comments to all DuckDB files in base_dir.
805
+
806
+ Returns:
807
+ Dictionary mapping pair names to success status
808
+
809
+ Example:
810
+ >>> processor = ExnessDataProcessor()
811
+ >>> results = processor.add_schema_comments_all()
812
+ >>> print(f"Updated {sum(results.values())} databases")
813
+ """
814
+ results = {}
815
+
816
+ # Find all .duckdb files
817
+ for db_path in self.base_dir.glob("*.duckdb"):
818
+ pair = db_path.stem.upper()
819
+ results[pair] = self.add_schema_comments(pair)
820
+
821
+ return results
822
+
823
+ def get_data_coverage(self, pair: str = "EURUSD") -> Dict[str, Any]:
824
+ """
825
+ Get data coverage information for an instrument.
826
+
827
+ Args:
828
+ pair: Currency pair
829
+
830
+ Returns:
831
+ Dictionary with coverage information:
832
+ - database_exists: Whether database file exists
833
+ - duckdb_path: Path to database file
834
+ - duckdb_size_mb: Database file size
835
+ - raw_spread_ticks: Number of Raw_Spread ticks
836
+ - standard_ticks: Number of Standard ticks
837
+ - ohlc_bars: Number of 1m OHLC bars
838
+ - earliest_date: Earliest tick timestamp
839
+ - latest_date: Latest tick timestamp
840
+ - date_range_days: Number of days covered
841
+
842
+ Example:
843
+ >>> processor = ExnessDataProcessor()
844
+ >>> coverage = processor.get_data_coverage("EURUSD")
845
+ >>> print(f"Coverage: {coverage['earliest_date']} to {coverage['latest_date']}")
846
+ >>> print(f"Total: {coverage['raw_spread_ticks']:,} ticks")
847
+ """
848
+ duckdb_path = self.base_dir / f"{pair.lower()}.duckdb"
849
+
850
+ if not duckdb_path.exists():
851
+ return {
852
+ "database_exists": False,
853
+ "duckdb_path": str(duckdb_path),
854
+ "duckdb_size_mb": 0,
855
+ "raw_spread_ticks": 0,
856
+ "standard_ticks": 0,
857
+ "ohlc_bars": 0,
858
+ "earliest_date": None,
859
+ "latest_date": None,
860
+ "date_range_days": 0,
861
+ }
862
+
863
+ conn = duckdb.connect(str(duckdb_path), read_only=True)
864
+
865
+ # Get counts
866
+ raw_count = conn.execute("SELECT COUNT(*) FROM raw_spread_ticks").fetchone()[0]
867
+ std_count = conn.execute("SELECT COUNT(*) FROM standard_ticks").fetchone()[0]
868
+ ohlc_count = conn.execute("SELECT COUNT(*) FROM ohlc_1m").fetchone()[0]
869
+
870
+ # Get date range
871
+ result = conn.execute("""
872
+ SELECT
873
+ MIN(Timestamp) as earliest,
874
+ MAX(Timestamp) as latest
875
+ FROM raw_spread_ticks
876
+ """).fetchone()
877
+
878
+ conn.close()
879
+
880
+ if result and result[0]:
881
+ earliest = pd.to_datetime(result[0])
882
+ latest = pd.to_datetime(result[1])
883
+ date_range_days = (latest - earliest).days
884
+ else:
885
+ earliest = None
886
+ latest = None
887
+ date_range_days = 0
888
+
889
+ return {
890
+ "database_exists": True,
891
+ "duckdb_path": str(duckdb_path),
892
+ "duckdb_size_mb": duckdb_path.stat().st_size / 1024 / 1024,
893
+ "raw_spread_ticks": raw_count,
894
+ "standard_ticks": std_count,
895
+ "ohlc_bars": ohlc_count,
896
+ "earliest_date": str(earliest) if earliest else None,
897
+ "latest_date": str(latest) if latest else None,
898
+ "date_range_days": date_range_days,
899
+ }