aether-quant 0.0.1__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.
Files changed (99) hide show
  1. aether_quant-0.0.1.dist-info/METADATA +7 -0
  2. aether_quant-0.0.1.dist-info/RECORD +99 -0
  3. aether_quant-0.0.1.dist-info/WHEEL +5 -0
  4. aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
  5. aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
  6. aether_quant-0.0.1.dist-info/top_level.txt +13 -0
  7. analyzer/README.md +69 -0
  8. analyzer/__init__.py +17 -0
  9. analyzer/market_analyzer.py +521 -0
  10. aq_cli.py +3633 -0
  11. audit/README.md +47 -0
  12. audit/__init__.py +38 -0
  13. audit/hash_chain.py +52 -0
  14. audit/postgres_audit.py +61 -0
  15. audit/postgres_worker.py +402 -0
  16. audit/redis_queue.py +124 -0
  17. audit/status_export.py +62 -0
  18. data_pipeline/README.md +257 -0
  19. data_pipeline/__init__.py +6 -0
  20. data_pipeline/bar_synthesis.py +97 -0
  21. data_pipeline/dividend_backfill.py +243 -0
  22. data_pipeline/factor_file_backfill.py +276 -0
  23. data_pipeline/fetch.py +206 -0
  24. data_pipeline/fred_backfill.py +435 -0
  25. data_pipeline/ib_backfill.py +287 -0
  26. data_pipeline/v2_manifest.py +75 -0
  27. data_pipeline/yfinance_backfill.py +501 -0
  28. evaluation/README.md +152 -0
  29. evaluation/__init__.py +73 -0
  30. evaluation/ablation.py +310 -0
  31. evaluation/benchmark_comparison.py +195 -0
  32. evaluation/confidence_threshold_calibration.py +157 -0
  33. evaluation/factor_neutralization_check.py +94 -0
  34. evaluation/feature_parity.py +321 -0
  35. evaluation/feature_reconciliation.py +286 -0
  36. evaluation/impact_model.py +77 -0
  37. evaluation/kill_switch_replay.py +186 -0
  38. evaluation/limit_fill_simulator.py +152 -0
  39. evaluation/model_predictions.py +192 -0
  40. evaluation/monte_carlo.py +161 -0
  41. evaluation/public_benchmarks.py +117 -0
  42. evaluation/rank_book_simulator.py +494 -0
  43. evaluation/rank_ic_core.py +204 -0
  44. evaluation/rank_signal_calibration.py +690 -0
  45. evaluation/rolling_ic_gate_calibration.py +190 -0
  46. evaluation/rolling_ic_gate_replay.py +138 -0
  47. execution/README.md +281 -0
  48. execution/__init__.py +80 -0
  49. execution/config_cache.py +48 -0
  50. execution/cost_model.py +213 -0
  51. execution/lean_config_render.py +131 -0
  52. execution/live_credentials.py +38 -0
  53. execution/live_credentials_io.py +52 -0
  54. execution/order_gate.py +388 -0
  55. execution/paper_readiness.py +181 -0
  56. execution/paper_readiness_io.py +54 -0
  57. execution/paper_readiness_report.py +103 -0
  58. execution/paper_readiness_scheduler.py +137 -0
  59. execution/reconciliation.py +160 -0
  60. execution/runtime_config_io.py +38 -0
  61. execution/secret_scan.py +73 -0
  62. features/README.md +77 -0
  63. features/__init__.py +137 -0
  64. features/alt_data_features.py +154 -0
  65. features/bond_features.py +275 -0
  66. features/cross_asset_sensitivity.py +79 -0
  67. features/derivatives_macro_features.py +72 -0
  68. features/macro_features.py +79 -0
  69. features/options_greeks.py +329 -0
  70. features/sector_map.py +53 -0
  71. features/technical_indicators.py +185 -0
  72. generate_backtest_report.py +311 -0
  73. generate_evaluation_report.py +712 -0
  74. inference/README.md +105 -0
  75. inference/__init__.py +36 -0
  76. inference/exported_model.py +929 -0
  77. inference/parallel_inference.py +160 -0
  78. inference/strategy_selector_inference.py +75 -0
  79. json_safety.py +130 -0
  80. portfolio/README.md +447 -0
  81. portfolio/__init__.py +41 -0
  82. portfolio/book_construction.py +786 -0
  83. portfolio/book_neutrality.py +178 -0
  84. portfolio/hrp_allocation.py +159 -0
  85. portfolio/options_arbitrage_detector.py +232 -0
  86. portfolio/options_assignment_risk.py +119 -0
  87. portfolio/options_margin_sizing.py +333 -0
  88. portfolio/options_strategy.py +1999 -0
  89. portfolio/rank_signal.py +219 -0
  90. portfolio/rolling_ic_gate.py +183 -0
  91. risk/README.md +275 -0
  92. risk/__init__.py +38 -0
  93. risk/asset_class_router.py +584 -0
  94. risk/forex_risk.py +158 -0
  95. risk/futures_risk.py +202 -0
  96. risk/kill_switch.py +261 -0
  97. risk/manual_override.py +118 -0
  98. risk/position_sizing.py +303 -0
  99. risk/rl_sizing.py +189 -0
@@ -0,0 +1,501 @@
1
+ """Yahoo Finance historical data backfill (Phase V2-19.5).
2
+
3
+ Manual, offline maintenance script — never runs inside the Lean container or
4
+ any always-on Docker worker (mirrors train_topology.py's "never runs in the
5
+ Lean container" status, not performance/trigger_worker.py's continuous-worker
6
+ one). Fills gaps in thin local Lean zips (e.g. ETHUSD/LTCUSD, which only have
7
+ a few scattered days of real Coinbase minute data — see
8
+ train.py::ensure_derived_crypto_daily_series() and development/Changelog.md's
9
+ Phase-9 entry) using `yfinance`.
10
+
11
+ Two independent safety boundaries, both requiring an explicit human step:
12
+ 1. Writing/overwriting zip files is gated by --apply (default: dry run only).
13
+ 2. config.json's available_from/available_to are NEVER edited by this
14
+ script, --apply or not — train.py::build_asset_quality() only counts rows
15
+ inside those configured windows (config.json phase9.asset_quality), so
16
+ widening the zip alone does nothing until a human widens the window too.
17
+ This script only prints the suggested new values.
18
+
19
+ Usage:
20
+ python -m data_pipeline.yfinance_backfill [--tickers ETHUSD LTCUSD] [--apply]
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import logging
28
+ import math
29
+ from datetime import date, datetime, timedelta
30
+ from pathlib import Path
31
+ from typing import Callable
32
+ from zipfile import ZipFile
33
+
34
+ import pandas as pd
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ ROOT = Path(__file__).resolve().parents[1]
39
+ CONFIG_PATH = ROOT / "config.json"
40
+
41
+ # Internal ticker -> Yahoo symbol, used only when the asset's own
42
+ # config.json "backfill" block doesn't set an explicit "symbol".
43
+ YAHOO_TICKER_OVERRIDES = {
44
+ "ETHUSD": "ETH-USD",
45
+ "BTCUSD": "BTC-USD",
46
+ "LTCUSD": "LTC-USD",
47
+ }
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Pure helpers
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def yahoo_symbol_for(ticker: str, backfill_config: dict) -> str:
56
+ """backfill_config["symbol"] wins if present, else YAHOO_TICKER_OVERRIDES,
57
+ else the internal ticker unchanged (equities: AAPL -> AAPL)."""
58
+ explicit = backfill_config.get("symbol")
59
+ if explicit:
60
+ return str(explicit)
61
+ return YAHOO_TICKER_OVERRIDES.get(ticker, ticker)
62
+
63
+
64
+ def detect_gap(asset: dict, existing_rows: list[dict]) -> dict:
65
+ """Pure: compares the asset's configured backfill_from/backfill_to
66
+ against the dates already present in its Lean zip (existing_rows, each a
67
+ dict with a "date" key holding a datetime.date). Returns whether a real
68
+ gap exists and the date range that should actually be fetched.
69
+ """
70
+ backfill_config = asset.get("backfill") or {}
71
+ if not backfill_config:
72
+ return {"needs_backfill": False, "missing_before": None, "missing_after": None, "fetch_start": None, "fetch_end": None}
73
+
74
+ fetch_start = date.fromisoformat(backfill_config["backfill_from"])
75
+ fetch_end = date.fromisoformat(backfill_config["backfill_to"])
76
+
77
+ existing_dates = [row["date"] for row in existing_rows]
78
+ existing_min = min(existing_dates) if existing_dates else None
79
+ existing_max = max(existing_dates) if existing_dates else None
80
+
81
+ missing_before = fetch_start if (existing_min is None or fetch_start < existing_min) else None
82
+ missing_after = fetch_end if (existing_max is None or fetch_end > existing_max) else None
83
+
84
+ return {
85
+ "needs_backfill": missing_before is not None or missing_after is not None,
86
+ "missing_before": missing_before,
87
+ "missing_after": missing_after,
88
+ "fetch_start": fetch_start,
89
+ "fetch_end": fetch_end,
90
+ }
91
+
92
+
93
+ def scale_for_lean(rows: list[dict], security_type: str) -> list[dict]:
94
+ """Applies train.py's x10000 integer convention for equities (Lean's
95
+ deflated-price format — see train.py::load_lean_bars()); crypto rows
96
+ pass through unscaled, matching
97
+ train.py::ensure_derived_crypto_daily_series(). Pure — no I/O.
98
+ """
99
+ if security_type != "equity":
100
+ return [dict(row) for row in rows]
101
+
102
+ scaled = []
103
+ for row in rows:
104
+ scaled.append(
105
+ {
106
+ "date": row["date"],
107
+ "open": round(row["open"] * 10000),
108
+ "high": round(row["high"] * 10000),
109
+ "low": round(row["low"] * 10000),
110
+ "close": round(row["close"] * 10000),
111
+ "volume": row["volume"],
112
+ }
113
+ )
114
+ return scaled
115
+
116
+
117
+ def rows_to_lean_csv(rows: list[dict]) -> str:
118
+ """Exact row format train.py::ensure_derived_crypto_daily_series() writes:
119
+ f"{date:%Y%m%d} 00:00,{open},{high},{low},{close},{volume}", one row per
120
+ line, sorted by date.
121
+ """
122
+ ordered = sorted(rows, key=lambda row: row["date"])
123
+ lines = [
124
+ f"{row['date'].strftime('%Y%m%d')} 00:00,{row['open']},{row['high']},{row['low']},{row['close']},{row['volume']}"
125
+ for row in ordered
126
+ ]
127
+ return "\n".join(lines) + ("\n" if lines else "")
128
+
129
+
130
+ def _numeric(value: str):
131
+ try:
132
+ return int(value)
133
+ except ValueError:
134
+ return float(value)
135
+
136
+
137
+ def _read_existing_lean_rows(path: Path) -> list[dict]:
138
+ """Reads a Lean daily zip's rows back into the same dict shape
139
+ scale_for_lean()/rows_to_lean_csv() use, preserving whatever numeric
140
+ representation (int or float) is already stored — no rescaling."""
141
+ rows: list[dict] = []
142
+ with ZipFile(path) as archive:
143
+ member = archive.namelist()[0]
144
+ with archive.open(member) as handle:
145
+ text = handle.read().decode("utf-8")
146
+
147
+ for line in text.splitlines():
148
+ if not line.strip():
149
+ continue
150
+ date_field, open_v, high_v, low_v, close_v, volume_v = line.split(",")
151
+ row_date = datetime.strptime(date_field.split()[0], "%Y%m%d").date()
152
+ rows.append(
153
+ {
154
+ "date": row_date,
155
+ "open": _numeric(open_v),
156
+ "high": _numeric(high_v),
157
+ "low": _numeric(low_v),
158
+ "close": _numeric(close_v),
159
+ "volume": _numeric(volume_v),
160
+ }
161
+ )
162
+ return rows
163
+
164
+
165
+ def write_lean_zip(output_zip: Path, ticker: str, new_rows: list[dict], *, merge_with_existing: bool = True) -> None:
166
+ """Mirrors train.py::ensure_derived_crypto_daily_series()'s
167
+ ZipFile(output_zip, 'w') pattern, member name f'{ticker.lower()}.csv'.
168
+
169
+ When merge_with_existing, reads the existing zip's rows first and keys
170
+ the merge by date — existing (real Lean) rows always win on overlap;
171
+ new_rows only fill dates that were genuinely missing.
172
+ """
173
+ merged_by_date: dict[date, dict] = {}
174
+ if merge_with_existing and output_zip.exists():
175
+ for row in _read_existing_lean_rows(output_zip):
176
+ merged_by_date[row["date"]] = row
177
+
178
+ for row in new_rows:
179
+ merged_by_date.setdefault(row["date"], row)
180
+
181
+ csv_text = rows_to_lean_csv(list(merged_by_date.values()))
182
+
183
+ output_zip.parent.mkdir(parents=True, exist_ok=True)
184
+ member_name = f"{ticker.lower()}.csv"
185
+ with ZipFile(output_zip, "w") as archive:
186
+ archive.writestr(member_name, csv_text)
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # V4.10 — forex sibling of the 5-column mid-price OHLCV functions above.
191
+ # Lean's real forex daily CSV is a 10-column bid/ask format (confirmed
192
+ # directly against the real local sample zips under
193
+ # data/forex/{oanda,fxcm}/daily/ - see data/forex/readme.md), not the flat
194
+ # OHLCV shape equity/crypto use. Yahoo Finance's own FX data is mid/last
195
+ # OHLC only (no real bid/ask spread), so synthesize_forex_bid_ask_row()
196
+ # below is an honest, documented zero-spread APPROXIMATION for backtest/
197
+ # training purposes only, never a live spread feed (development/
198
+ # Problems.md #66). The real local sample data (2007+) DOES carry genuine
199
+ # historical bid != ask spread, so the merge path here preserves full
200
+ # 10-field rows on overlap, never collapsing existing real rows to a
201
+ # synthesized mid — same "existing/real rows always win" convention as
202
+ # write_lean_zip() above.
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def synthesize_forex_bid_ask_row(row: dict) -> dict:
207
+ """Yahoo Finance's FX data is mid/last OHLC only, no real bid/ask
208
+ spread — this is a documented, honest zero-spread APPROXIMATION for
209
+ backtest/training purposes only, never a live spread feed (see
210
+ development/Problems.md #66). Duplicates the same OHLC values into
211
+ both the bid and ask column groups, volume 0 in both — matches the
212
+ real Lean forex daily CSV shape (data/forex/readme.md) without
213
+ inventing a fake spread number."""
214
+ return {
215
+ "date": row["date"],
216
+ "bid_open": row["open"],
217
+ "bid_high": row["high"],
218
+ "bid_low": row["low"],
219
+ "bid_close": row["close"],
220
+ "bid_volume": 0,
221
+ "ask_open": row["open"],
222
+ "ask_high": row["high"],
223
+ "ask_low": row["low"],
224
+ "ask_close": row["close"],
225
+ "ask_volume": 0,
226
+ }
227
+
228
+
229
+ def forex_rows_to_lean_csv(rows: list[dict]) -> str:
230
+ """10-column bid/ask Lean forex daily CSV — the FX sibling of
231
+ rows_to_lean_csv(). Rows already carry all 10 value fields (see
232
+ synthesize_forex_bid_ask_row()/_read_existing_lean_forex_rows() below)
233
+ — this function only formats and sorts, never synthesizes."""
234
+ ordered = sorted(rows, key=lambda row: row["date"])
235
+ lines = [
236
+ f"{row['date'].strftime('%Y%m%d')} 00:00,"
237
+ f"{row['bid_open']},{row['bid_high']},{row['bid_low']},{row['bid_close']},{row['bid_volume']},"
238
+ f"{row['ask_open']},{row['ask_high']},{row['ask_low']},{row['ask_close']},{row['ask_volume']}"
239
+ for row in ordered
240
+ ]
241
+ return "\n".join(lines) + ("\n" if lines else "")
242
+
243
+
244
+ def _read_existing_lean_forex_rows(path: Path) -> list[dict]:
245
+ """Forex sibling of _read_existing_lean_rows() — 11-field split (date
246
+ + 10 bid/ask values), preserves real bid/ask exactly as stored, no
247
+ collapsing to mid."""
248
+ rows: list[dict] = []
249
+ with ZipFile(path) as archive:
250
+ member = archive.namelist()[0]
251
+ with archive.open(member) as handle:
252
+ text = handle.read().decode("utf-8")
253
+
254
+ for line in text.splitlines():
255
+ if not line.strip():
256
+ continue
257
+ (
258
+ date_field, bid_open, bid_high, bid_low, bid_close, bid_volume,
259
+ ask_open, ask_high, ask_low, ask_close, ask_volume,
260
+ ) = line.split(",")
261
+ row_date = datetime.strptime(date_field.split()[0], "%Y%m%d").date()
262
+ rows.append(
263
+ {
264
+ "date": row_date,
265
+ "bid_open": _numeric(bid_open), "bid_high": _numeric(bid_high),
266
+ "bid_low": _numeric(bid_low), "bid_close": _numeric(bid_close),
267
+ "bid_volume": _numeric(bid_volume),
268
+ "ask_open": _numeric(ask_open), "ask_high": _numeric(ask_high),
269
+ "ask_low": _numeric(ask_low), "ask_close": _numeric(ask_close),
270
+ "ask_volume": _numeric(ask_volume),
271
+ }
272
+ )
273
+ return rows
274
+
275
+
276
+ def write_lean_forex_zip(output_zip: Path, ticker: str, new_ohlcv_rows: list[dict], *, merge_with_existing: bool = True) -> None:
277
+ """Forex sibling of write_lean_zip() — same call signature (plain
278
+ 5-field OHLCV rows, the exact shape fetch_adhoc_asset() already
279
+ produces via scale_for_lean()), so data_pipeline/fetch.py's
280
+ ASSET_CLASS_CONFIG write_fn swap needs no caller changes. Synthesizes
281
+ each NEW row via synthesize_forex_bid_ask_row(), merges by date
282
+ against _read_existing_lean_forex_rows() (existing REAL rows always
283
+ win — identical convention to write_lean_zip()), writes via
284
+ forex_rows_to_lean_csv()."""
285
+ merged_by_date: dict[date, dict] = {}
286
+ if merge_with_existing and output_zip.exists():
287
+ for row in _read_existing_lean_forex_rows(output_zip):
288
+ merged_by_date[row["date"]] = row
289
+
290
+ for row in new_ohlcv_rows:
291
+ merged_by_date.setdefault(row["date"], synthesize_forex_bid_ask_row(row))
292
+
293
+ csv_text = forex_rows_to_lean_csv(list(merged_by_date.values()))
294
+
295
+ output_zip.parent.mkdir(parents=True, exist_ok=True)
296
+ member_name = f"{ticker.lower()}.csv"
297
+ with ZipFile(output_zip, "w") as archive:
298
+ archive.writestr(member_name, csv_text)
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # The only function that imports yfinance — deferred, mirrors
303
+ # experience/redis_queue.py's deferred `import redis`.
304
+ # ---------------------------------------------------------------------------
305
+
306
+
307
+ def fetch_yahoo_ohlcv(symbol: str, start: str, end: str) -> list[dict]:
308
+ """Real dollar/crypto prices, unscaled. Returns [] on any failure or
309
+ empty response — never raises, so one bad symbol never aborts a
310
+ multi-asset run."""
311
+ try:
312
+ import yfinance as yf # deferred — dev-only dependency, never in requirements.txt
313
+
314
+ # auto_adjust=True: Yahoo applies its own split/dividend adjustment
315
+ # before this ever reaches a Lean zip. This module today only ever
316
+ # backfills crypto gaps (no splits/dividends), but train.py reading
317
+ # its own equity Lean zips unadjusted was a real, separate bug (see
318
+ # train.py::apply_split_adjustments()/development/Problems.md) -
319
+ # this flag stays correct here so a future equity backfill (this
320
+ # module's docstring already anticipates non-crypto assets) doesn't
321
+ # reintroduce the same class of unadjusted-price corruption.
322
+ frame = yf.download(symbol, start=start, end=end, auto_adjust=True, progress=False)
323
+ except Exception as exc:
324
+ logger.warning("fetch_yahoo_ohlcv(%s): fetch failed — %s", symbol, exc)
325
+ return []
326
+
327
+ if frame is None or frame.empty:
328
+ logger.warning("fetch_yahoo_ohlcv(%s): no data returned for %s..%s", symbol, start, end)
329
+ return []
330
+
331
+ # Newer yfinance always returns MultiIndex columns (price field, ticker),
332
+ # even for a single-ticker download — `record["Open"]` on that shape
333
+ # returns a length-1 Series instead of a scalar, and `float(...)` on it
334
+ # only works today via a deprecated pandas fallback (FutureWarning: will
335
+ # raise TypeError in a future pandas). Flattening to the price-field
336
+ # level here keeps `record["Open"]` a plain scalar regardless of which
337
+ # yfinance/pandas version is installed.
338
+ if isinstance(frame.columns, pd.MultiIndex):
339
+ frame.columns = frame.columns.get_level_values(0)
340
+
341
+ # V5.4.7 (development/Problems.md #123): rate-limiting / delisted
342
+ # responses often return a NON-empty frame whose rows are all NaN -
343
+ # `float(record["Open"])` happily produced nan and write_lean_zip
344
+ # persisted it as genuine OHLCV. Rows without a finite OHLC set are
345
+ # now dropped; a frame that is ALL such rows degrades to [] (the same
346
+ # honest "no data" path as the empty-frame case above).
347
+ rows = []
348
+ skipped_non_finite = 0
349
+ for index, record in frame.iterrows():
350
+ row_date = index.date() if hasattr(index, "date") else index
351
+ try:
352
+ open_p = float(record["Open"])
353
+ high = float(record["High"])
354
+ low = float(record["Low"])
355
+ close_p = float(record["Close"])
356
+ volume = float(record["Volume"])
357
+ except (TypeError, ValueError, KeyError):
358
+ skipped_non_finite += 1
359
+ continue
360
+ if not all(math.isfinite(v) for v in (open_p, high, low, close_p)):
361
+ skipped_non_finite += 1
362
+ continue
363
+ rows.append(
364
+ {
365
+ "date": row_date,
366
+ "open": open_p,
367
+ "high": high,
368
+ "low": low,
369
+ "close": close_p,
370
+ "volume": volume,
371
+ }
372
+ )
373
+ if skipped_non_finite:
374
+ logger.warning(
375
+ "fetch_yahoo_ohlcv(%s): skipped %d non-finite row(s) from %s..%s",
376
+ symbol, skipped_non_finite, start, end,
377
+ )
378
+ return rows
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Orchestration
383
+ # ---------------------------------------------------------------------------
384
+
385
+
386
+ def plan_backfill(config: dict, *, tickers: list[str] | None = None) -> list[dict]:
387
+ """Orchestrates over config["phase1"]["universe"]["assets"] entries that
388
+ carry an optional "backfill" sub-block. Reads existing zip rows (if the
389
+ zip already exists) purely to compute the gap — no network I/O here."""
390
+ plans = []
391
+ for asset in config["phase1"]["universe"]["assets"]:
392
+ backfill_config = asset.get("backfill")
393
+ if not backfill_config:
394
+ continue
395
+ if tickers and asset["ticker"] not in tickers:
396
+ continue
397
+
398
+ output_zip = ROOT / asset["data_path"]
399
+ existing_rows = _read_existing_lean_rows(output_zip) if output_zip.exists() else []
400
+ gap = detect_gap(asset, existing_rows)
401
+
402
+ plans.append(
403
+ {
404
+ "ticker": asset["ticker"],
405
+ "security_type": asset["security_type"],
406
+ "yahoo_symbol": yahoo_symbol_for(asset["ticker"], backfill_config),
407
+ "data_path": str(output_zip),
408
+ "gap": gap,
409
+ }
410
+ )
411
+ return plans
412
+
413
+
414
+ def run_backfill(config: dict, plan: list[dict], *, apply: bool, fetch_fn: Callable = fetch_yahoo_ohlcv) -> dict:
415
+ """apply=False (default): dry run — fetches nothing written to disk.
416
+ apply=True: writes merged zips via write_lean_zip(). fetch_fn is the
417
+ injection point for tests (never real yfinance in tests)."""
418
+ entries = []
419
+ for plan_entry in plan:
420
+ gap = plan_entry["gap"]
421
+ if not gap["needs_backfill"]:
422
+ entries.append({**plan_entry, "action": "skipped_no_gap", "rows_fetched": 0})
423
+ continue
424
+
425
+ # V5.4.7 (development/Problems.md #123): yf.download's `end` is
426
+ # EXCLUSIVE while backfill_to/fetch_end is inclusive - without the
427
+ # +1 day, the configured end date could never be fetched, so
428
+ # needs_backfill stayed True forever and every run refetched.
429
+ fetch_end_exclusive = gap["fetch_end"] + timedelta(days=1)
430
+ rows = fetch_fn(
431
+ plan_entry["yahoo_symbol"],
432
+ gap["fetch_start"].isoformat(),
433
+ fetch_end_exclusive.isoformat(),
434
+ )
435
+ scaled_rows = scale_for_lean(rows, plan_entry["security_type"])
436
+
437
+ if not scaled_rows:
438
+ action = "no_data_returned"
439
+ elif apply:
440
+ write_lean_zip(Path(plan_entry["data_path"]), plan_entry["ticker"], scaled_rows, merge_with_existing=True)
441
+ action = "written"
442
+ else:
443
+ action = "dry_run"
444
+
445
+ dates = [row["date"] for row in rows]
446
+ entries.append(
447
+ {
448
+ **plan_entry,
449
+ "action": action,
450
+ "rows_fetched": len(rows),
451
+ "suggested_available_from": min(dates) if dates else None,
452
+ "suggested_available_to": max(dates) if dates else None,
453
+ }
454
+ )
455
+ return {"apply": apply, "entries": entries}
456
+
457
+
458
+ # ---------------------------------------------------------------------------
459
+ # CLI
460
+ # ---------------------------------------------------------------------------
461
+
462
+
463
+ def main() -> None:
464
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s")
465
+ parser = argparse.ArgumentParser(
466
+ description="Aether Quant Yahoo Finance historical data backfill (V2-19.5) — "
467
+ "offline/manual only, never run inside Lean or a Docker worker."
468
+ )
469
+ parser.add_argument("--tickers", nargs="*", default=None, help="Restrict to these tickers (default: all assets with a 'backfill' block)")
470
+ parser.add_argument("--apply", action="store_true", help="Actually write zip files (default: dry run, report only)")
471
+ parser.add_argument("--config-path", type=Path, default=CONFIG_PATH)
472
+ args = parser.parse_args()
473
+
474
+ with args.config_path.open("r", encoding="utf-8") as f:
475
+ config = json.load(f)
476
+
477
+ plan = plan_backfill(config, tickers=args.tickers)
478
+ if not plan:
479
+ print("No assets with a 'backfill' config block found (nothing to do).")
480
+ return
481
+
482
+ report = run_backfill(config, plan, apply=args.apply)
483
+ print(f"{'APPLY' if args.apply else 'DRY RUN'} — {len(report['entries'])} asset(s) evaluated:\n")
484
+ for entry in report["entries"]:
485
+ print(f"- {entry['ticker']} ({entry['yahoo_symbol']}): {entry['action']}, rows_fetched={entry['rows_fetched']}")
486
+ if entry.get("suggested_available_from"):
487
+ print(
488
+ f" If this widens coverage, update config.json manually: "
489
+ f"available_from={entry['suggested_available_from'].isoformat()}"
490
+ )
491
+
492
+ if not args.apply:
493
+ print("\nDry run only — no files were written. Re-run with --apply to write zip files.")
494
+ print(
495
+ "\nNote: config.json's available_from/available_to are never edited automatically. "
496
+ "Update them by hand for the wider history to actually affect training/backtesting."
497
+ )
498
+
499
+
500
+ if __name__ == "__main__":
501
+ main()
evaluation/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # evaluation
2
+
3
+ Offline, cost-aware evaluation of the cross-sectional rank book (V5.1
4
+ Phase 0, development/Problems.md — the phase focused on model prediction
5
+ performance). Answers the question nothing else in this codebase could:
6
+ `train.py::compute_strategy_metrics()` simulates a long-only 1-day-direction
7
+ strategy **gross of costs**, not the rank book, and not net of cost. This
8
+ package turns "how good is this rank prediction" into "what would this have
9
+ actually earned net of fees."
10
+
11
+ Pure numpy/pandas — **deliberately no torch import**, so `performance/` and
12
+ `retraining/` (and any future Postgres-fed monitoring job) can import it
13
+ without pulling in the training stack, the same torch-free-core convention
14
+ `train.py::_rank_ic_from_arrays()` established for its own metric.
15
+
16
+ - `rank_book_simulator.py::simulate_rank_book(...)` — reuses the **exact**
17
+ selection/neutrality machinery `main.py`'s live decision path uses:
18
+ `portfolio.book_construction.build_rank_based_book()` (including its
19
+ hysteresis-aware sticky selection) and
20
+ `portfolio.book_neutrality.apply_book_neutrality()`. Per date: rebalance
21
+ on schedule, equal-weight each leg, neutralize, hold until the next
22
+ rebalance, accrue gross return and cost (`cost_bps_per_side +
23
+ commission_bps`, charged on turnover only at rebalance). Dates with fewer
24
+ than `min_universe_size` eligible rows are **skipped**, not zero-filled.
25
+ - `rank_book_simulator.py::capacity_curve(...)` — sweeps `top_n` (breadth
26
+ check: is the edge concentrated in a handful of names, or genuinely
27
+ spread across the cross-section?) and estimates a rough capacity ceiling
28
+ from the lowest-average-dollar-volume held name.
29
+ - `rank_book_simulator.py::stress_test_costs(...)` — re-runs the simulation
30
+ at 1×/2×/3× the configured cost, answering "does the edge survive if
31
+ real-world costs turn out higher than calibrated."
32
+ - `rank_book_simulator.py::summarize_metric_stability(...)` — cross-window
33
+ sign-flip-fraction + bootstrap-CI summary (used by Phase 4's walk-forward
34
+ evaluation and Phase 5's ablation harness). Duplicates the ~12-line
35
+ bootstrap from `train.py::bootstrap_ic_confidence_interval()` rather than
36
+ importing it, for the same torch-free reason above.
37
+ - `model_predictions.py` — runs every dataset row through
38
+ `inference/exported_model.py` (the **same** torch-free interpreter
39
+ `main.py`'s live decision path uses) to produce a predicted-`<head>`
40
+ column, so every number this package reports is the offline mirror of
41
+ live behavior, not a separately re-derived approximation. Includes a
42
+ torch-free port of `train.py::build_sequence_tensor_dataset()`
43
+ (`build_sequence_windows()`), cross-validated against the original in
44
+ `tests/test_model_predictions.py`.
45
+ - `ablation.py::run_ablation(...)` — isolates the contribution of
46
+ neutrality/hysteresis/cost-model by re-running the simulation with each
47
+ turned off in turn. Runtime-only mechanisms with no offline equivalent
48
+ (gating, topology sizing) report an honest `not_offline_measurable`
49
+ sentinel instead of a fabricated number.
50
+ - `rank_signal_calibration.py` — `calibrate_book_confidence_spread()`
51
+ (derives `min_rank_confidence_spread` from real per-date score
52
+ dispersion); `reconcile_book_history_date()`/
53
+ `replay_book_history_reconciliation()`/
54
+ `summarize_book_history_reconciliation()` (compares a real Lean
55
+ backtest's logged book selections against a fresh offline
56
+ re-derivation — independent or hysteresis-replayed); `summarize_book_member_diversion()`
57
+ (aggregates each book member's final action/reasons, V5.2.6).
58
+ - `confidence_threshold_calibration.py` — derives `min_confidence_to_trade`
59
+ from the real confidence-vs-forward-return relationship, same
60
+ percentile-of-a-real-distribution discipline as the book-spread
61
+ calibration above.
62
+ - `kill_switch_replay.py::replay_kill_switch_over_dataset(...)`/
63
+ `summarize_kill_switch_replay(...)` (V5.2.8) — a day-by-day offline
64
+ replay of the kill-switch + sticky trade-lock state machine against the
65
+ rank book's own return series. Explicitly approximate (see its own
66
+ module docstring): no bypass flags, no `net_edge`/book-selection
67
+ modeling, only the two dataset-derivable `evaluate_kill_switch()`
68
+ inputs plus the drawdown-lock breach.
69
+ - `limit_fill_simulator.py::simulate_limit_fills(...)`/
70
+ `sweep_limit_fill_offsets(...)` (V5.3.1) — an offline counterfactual
71
+ estimate of how often a real limit order would fill vs. time out,
72
+ reusing `execution/order_gate.py::resolve_limit_price()` against the
73
+ dataset's own high/low bars. Answers a different question than the real
74
+ order-events evidence (development/Problems.md #34/#96): "would this
75
+ signal have filled," not "did the real book's actual orders fill."
76
+ - `feature_reconciliation.py::reconcile_feature_snapshot(...)`/
77
+ `summarize_feature_reconciliation(...)` (V5.3.5.3, Problems.md #91/#100)
78
+ — diffs ONE symbol's logged live feature values (book_history.jsonl's
79
+ allowlist-bounded `feature_snapshot` field) against full_dataset.csv's
80
+ same (ticker, date) rows: per-feature deltas, tolerance-gated
81
+ (abs AND rel), NaN-degrading, worst offenders aggregated first. Pure
82
+ diff core only — the CLI (`aq evaluate --reconcile-features --symbol
83
+ XOM`) owns all I/O and joins.
84
+ - `monte_carlo.py::run_monte_carlo(...)` (V5.3.8) — stationary-block (or
85
+ i.i.d.) bootstrap of the offline rank book's daily NET returns into N
86
+ alternative equity histories: per-run curves, red average curve,
87
+ return/Sharpe/maxDD percentile distributions, loss probability.
88
+ Deterministic given seed; pure numpy core — the CLI
89
+ (`aq evaluate --rank-book --monte-carlo`) owns inference + persistence +
90
+ README chart/section refresh.
91
+ - `benchmark_comparison.py` (V5.4.3) — naive strategy baselines
92
+ (momentum top-N / mean-reversion / random-entry) on the same dataset
93
+ and window as the rank book, so the model's edge is stated relative to
94
+ simple benchmarks rather than in isolation. V5.4.4 added
95
+ `close_pivot_from_frame()` (long-format dataset frame → the
96
+ (dates × tickers) close pivot every baseline consumes) and
97
+ `run_all_baselines()` (all five baselines into one report dict), plus a
98
+ real return accrual for the mean-reversion baseline (it previously
99
+ accrued a hardcoded placeholder, so its Sharpe was structurally 0.0).
100
+ - `public_benchmarks.py` (V5.4.4) — well-known PUBLIC benchmark
101
+ baselines computed from the universe's own index/bond proxy columns:
102
+ `compute_sp500_baseline()` (buy-and-hold SPY) and
103
+ `compute_60_40_baseline()` (60% SPY / 40% TLT, daily rebalanced).
104
+ Same `close_pivot` input, same Sharpe formula, and same output shape
105
+ as the strategy baselines above (plus a `status` field — a universe
106
+ without SPY/TLT degrades to `SKIPPED`, never a crash). Zero new data
107
+ dependencies.
108
+
109
+ Both modules are surfaced end-to-end by `aq evaluate --benchmarks`
110
+ (included in `--all`): the CLI writes
111
+ `ml/evaluation/benchmark_comparison.json`, the README's Benchmark
112
+ Comparison section auto-refreshes from it, and the webui's Evaluation tab
113
+ renders it via `/api/evaluation`'s `benchmarks` key (BenchmarkPanel).
114
+
115
+ ## CLI
116
+
117
+ `aq evaluate` (see the root README's CLI Reference for full flag
118
+ descriptions):
119
+
120
+ - Simulation: `--rank-book`, `--capacity`, `--stress`, `--all` (bundles
121
+ the three plus `--calibrate-edge` and `--benchmarks`)
122
+ - Benchmarks (V5.4.4): `--benchmarks` (strategy + public baselines,
123
+ README section + webui panel refresh)
124
+ - Calibration: `--calibrate-edge`, `--calibrate-book-spread`,
125
+ `--calibrate-confidence-threshold`
126
+ - Reconciliation: `--reconcile-book-history`, `--replay-hysteresis`,
127
+ `--reconcile-features` (V5.3.5.3, requires `--symbol`)
128
+ - Monte Carlo (V5.3.8): `--rank-book --monte-carlo`
129
+ (`--mc-runs/--mc-block-size/--mc-seed/--mc-method`)
130
+ - Diagnostics/investigation (not in `--all`): `--ablation`,
131
+ `--replay-kill-switch` (V5.2.8), `--simulate-limit-fills` (V5.3.1,
132
+ `--limit-fill-offset-sweep`), `--walk-forward-summary`
133
+
134
+ Writes `ml/evaluation/*.json`, consumed by `monitoring/evaluation_state.py`
135
+ for the webui's Evaluation tab (`/api/evaluation`).
136
+
137
+ ## Config
138
+
139
+ `phase1.target.ranking.net_performance` (book/cost parameters) and
140
+ `phase_v2.costs` (the entry-decision net-edge gate, `execution/cost_model.py`
141
+ — off by default until calibrated via `aq evaluate --calibrate-edge`).
142
+
143
+ ## Known finding (development/Problems.md)
144
+
145
+ Running `aq evaluate --rank-book` against the live-trained artifacts during
146
+ development surfaced, in real predictions, the compression this repo's own
147
+ Problems.md documents: `rank_20d` predictions clustered in roughly
148
+ `[0.5, 0.7]` instead of spanning `[0, 1]` — direct evidence of the rank
149
+ heads' sigmoid-at-inference/raw-at-training mismatch. `IC` is invariant
150
+ under any monotone transform, which is exactly why no rank-IC-based gate
151
+ ever caught it; this package's net-Sharpe simulation is sensitive to the
152
+ compressed dispersion in a way IC is not.