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.
- aether_quant-0.0.1.dist-info/METADATA +7 -0
- aether_quant-0.0.1.dist-info/RECORD +99 -0
- aether_quant-0.0.1.dist-info/WHEEL +5 -0
- aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
- aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
- aether_quant-0.0.1.dist-info/top_level.txt +13 -0
- analyzer/README.md +69 -0
- analyzer/__init__.py +17 -0
- analyzer/market_analyzer.py +521 -0
- aq_cli.py +3633 -0
- audit/README.md +47 -0
- audit/__init__.py +38 -0
- audit/hash_chain.py +52 -0
- audit/postgres_audit.py +61 -0
- audit/postgres_worker.py +402 -0
- audit/redis_queue.py +124 -0
- audit/status_export.py +62 -0
- data_pipeline/README.md +257 -0
- data_pipeline/__init__.py +6 -0
- data_pipeline/bar_synthesis.py +97 -0
- data_pipeline/dividend_backfill.py +243 -0
- data_pipeline/factor_file_backfill.py +276 -0
- data_pipeline/fetch.py +206 -0
- data_pipeline/fred_backfill.py +435 -0
- data_pipeline/ib_backfill.py +287 -0
- data_pipeline/v2_manifest.py +75 -0
- data_pipeline/yfinance_backfill.py +501 -0
- evaluation/README.md +152 -0
- evaluation/__init__.py +73 -0
- evaluation/ablation.py +310 -0
- evaluation/benchmark_comparison.py +195 -0
- evaluation/confidence_threshold_calibration.py +157 -0
- evaluation/factor_neutralization_check.py +94 -0
- evaluation/feature_parity.py +321 -0
- evaluation/feature_reconciliation.py +286 -0
- evaluation/impact_model.py +77 -0
- evaluation/kill_switch_replay.py +186 -0
- evaluation/limit_fill_simulator.py +152 -0
- evaluation/model_predictions.py +192 -0
- evaluation/monte_carlo.py +161 -0
- evaluation/public_benchmarks.py +117 -0
- evaluation/rank_book_simulator.py +494 -0
- evaluation/rank_ic_core.py +204 -0
- evaluation/rank_signal_calibration.py +690 -0
- evaluation/rolling_ic_gate_calibration.py +190 -0
- evaluation/rolling_ic_gate_replay.py +138 -0
- execution/README.md +281 -0
- execution/__init__.py +80 -0
- execution/config_cache.py +48 -0
- execution/cost_model.py +213 -0
- execution/lean_config_render.py +131 -0
- execution/live_credentials.py +38 -0
- execution/live_credentials_io.py +52 -0
- execution/order_gate.py +388 -0
- execution/paper_readiness.py +181 -0
- execution/paper_readiness_io.py +54 -0
- execution/paper_readiness_report.py +103 -0
- execution/paper_readiness_scheduler.py +137 -0
- execution/reconciliation.py +160 -0
- execution/runtime_config_io.py +38 -0
- execution/secret_scan.py +73 -0
- features/README.md +77 -0
- features/__init__.py +137 -0
- features/alt_data_features.py +154 -0
- features/bond_features.py +275 -0
- features/cross_asset_sensitivity.py +79 -0
- features/derivatives_macro_features.py +72 -0
- features/macro_features.py +79 -0
- features/options_greeks.py +329 -0
- features/sector_map.py +53 -0
- features/technical_indicators.py +185 -0
- generate_backtest_report.py +311 -0
- generate_evaluation_report.py +712 -0
- inference/README.md +105 -0
- inference/__init__.py +36 -0
- inference/exported_model.py +929 -0
- inference/parallel_inference.py +160 -0
- inference/strategy_selector_inference.py +75 -0
- json_safety.py +130 -0
- portfolio/README.md +447 -0
- portfolio/__init__.py +41 -0
- portfolio/book_construction.py +786 -0
- portfolio/book_neutrality.py +178 -0
- portfolio/hrp_allocation.py +159 -0
- portfolio/options_arbitrage_detector.py +232 -0
- portfolio/options_assignment_risk.py +119 -0
- portfolio/options_margin_sizing.py +333 -0
- portfolio/options_strategy.py +1999 -0
- portfolio/rank_signal.py +219 -0
- portfolio/rolling_ic_gate.py +183 -0
- risk/README.md +275 -0
- risk/__init__.py +38 -0
- risk/asset_class_router.py +584 -0
- risk/forex_risk.py +158 -0
- risk/futures_risk.py +202 -0
- risk/kill_switch.py +261 -0
- risk/manual_override.py +118 -0
- risk/position_sizing.py +303 -0
- 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.
|