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.
- exness_data_preprocess/__init__.py +51 -0
- exness_data_preprocess/api.py +216 -0
- exness_data_preprocess/cli.py +199 -0
- exness_data_preprocess/processor.py +899 -0
- exness_data_preprocess-0.1.0.dist-info/METADATA +506 -0
- exness_data_preprocess-0.1.0.dist-info/RECORD +10 -0
- exness_data_preprocess-0.1.0.dist-info/WHEEL +4 -0
- exness_data_preprocess-0.1.0.dist-info/entry_points.txt +2 -0
- exness_data_preprocess-0.1.0.dist-info/licenses/AUTHORS.md +24 -0
- exness_data_preprocess-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|