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
data_pipeline/fetch.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Ad-hoc historical OHLCV fetch for tickers not (yet) in config.json.
|
|
2
|
+
|
|
3
|
+
Distinct responsibility from yfinance_backfill.py: that module fills gaps in
|
|
4
|
+
already-configured assets via a config.json "backfill" block, and
|
|
5
|
+
deliberately never touches config.json (widening an *existing*, already
|
|
6
|
+
-trained asset's date range is a decision a human should make explicitly).
|
|
7
|
+
This module fetches an explicit date range for *any* ticker, including ones
|
|
8
|
+
with no config.json entry at all, and — on --apply — *does* add a new asset
|
|
9
|
+
block to config.json, because adding a brand-new ticker to the universe is
|
|
10
|
+
this module's entire purpose, not an accidental side effect.
|
|
11
|
+
|
|
12
|
+
Reuses yfinance_backfill.py's config.json-independent pure functions
|
|
13
|
+
(fetch_yahoo_ohlcv, scale_for_lean, write_lean_zip, and - V4.10 - the forex
|
|
14
|
+
sibling write_lean_forex_zip) - no duplicated logic.
|
|
15
|
+
|
|
16
|
+
Never runs `train.py` itself - preparing a ticker for training (fetching
|
|
17
|
+
its data, formatting it for Lean, wiring it into config.json's universe) is
|
|
18
|
+
this module's job; actually training on it stays a deliberate, separate
|
|
19
|
+
step (`python train.py`).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable
|
|
27
|
+
|
|
28
|
+
from json_safety import atomic_write_json
|
|
29
|
+
|
|
30
|
+
from data_pipeline.yfinance_backfill import (
|
|
31
|
+
fetch_yahoo_ohlcv,
|
|
32
|
+
scale_for_lean,
|
|
33
|
+
write_lean_forex_zip,
|
|
34
|
+
write_lean_zip,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
38
|
+
CONFIG_PATH = ROOT / "config.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _crypto_yahoo_symbol(ticker: str) -> str:
|
|
42
|
+
"""BTCUSD -> BTC-USD, DOGEUSD -> DOGE-USD: strip a trailing USD and join
|
|
43
|
+
with a hyphen, matching Yahoo Finance's crypto pair naming convention."""
|
|
44
|
+
base = ticker[:-3] if ticker.upper().endswith("USD") else ticker
|
|
45
|
+
return f"{base}-USD"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _forex_yahoo_symbol(ticker: str) -> str:
|
|
49
|
+
"""EURUSD -> EURUSD=X: Yahoo Finance's FX ticker convention."""
|
|
50
|
+
return f"{ticker}=X"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# One entry per asset class - V3's "futures"/"options" entries below prove
|
|
54
|
+
# the docstring's original claim (adding a class is one new dict entry, not
|
|
55
|
+
# a redesign): both route through data_pipeline/ib_backfill.py instead of
|
|
56
|
+
# Yahoo Finance (yahoo_symbol_fn stays identity - not a real Yahoo symbol
|
|
57
|
+
# for these two, just this dict's "instrument identifier passed to
|
|
58
|
+
# fetch_fn" slot, reused as-is rather than renamed) and carry an
|
|
59
|
+
# extra_asset_fields block (asset_class/data_source) merged into the
|
|
60
|
+
# config.json asset entry fetch_adhoc_asset() writes on --apply, since IB-
|
|
61
|
+
# backed assets need to be identified by the feature/risk layer
|
|
62
|
+
# (risk/asset_class_router.py) independently of Lean's own security_type.
|
|
63
|
+
ASSET_CLASS_CONFIG = {
|
|
64
|
+
"crypto": {
|
|
65
|
+
"security_type": "crypto",
|
|
66
|
+
"market": "coinbase",
|
|
67
|
+
"data_path_fn": lambda ticker: ROOT / "data" / "crypto" / "coinbase" / "daily" / f"{ticker.lower()}_trade.zip",
|
|
68
|
+
"yahoo_symbol_fn": _crypto_yahoo_symbol,
|
|
69
|
+
},
|
|
70
|
+
"stock": {
|
|
71
|
+
"security_type": "equity",
|
|
72
|
+
"market": "usa",
|
|
73
|
+
"data_path_fn": lambda ticker: ROOT / "data" / "equity" / "usa" / "daily" / f"{ticker.lower()}.zip",
|
|
74
|
+
"yahoo_symbol_fn": lambda ticker: ticker,
|
|
75
|
+
},
|
|
76
|
+
"futures": {
|
|
77
|
+
"security_type": "future",
|
|
78
|
+
"market": "cme",
|
|
79
|
+
"data_path_fn": lambda ticker: ROOT / "data" / "future" / "cme" / "daily" / f"{ticker.lower()}.zip",
|
|
80
|
+
"yahoo_symbol_fn": lambda ticker: ticker,
|
|
81
|
+
"extra_asset_fields": {"asset_class": "future", "data_source": "ib"},
|
|
82
|
+
},
|
|
83
|
+
"options": {
|
|
84
|
+
"security_type": "option",
|
|
85
|
+
"market": "usa",
|
|
86
|
+
"data_path_fn": lambda ticker: ROOT / "data" / "option" / "usa" / "daily" / f"{ticker.lower()}.zip",
|
|
87
|
+
"yahoo_symbol_fn": lambda ticker: ticker,
|
|
88
|
+
"extra_asset_fields": {"asset_class": "option", "data_source": "ib"},
|
|
89
|
+
},
|
|
90
|
+
"forex": {
|
|
91
|
+
# V4.10 - yfinance-backed (default fetch_fn below, no IB closure
|
|
92
|
+
# needed - forex is a fully-wired Lean asset class already, only
|
|
93
|
+
# ever blocked by "zero live tickers configured", not IB). market
|
|
94
|
+
# standardized on "oanda" - local sample data exists identically
|
|
95
|
+
# for both oanda/fxcm today, an arbitrary/low-stakes choice.
|
|
96
|
+
# write_fn overrides write_lean_zip() below: Lean's real forex
|
|
97
|
+
# daily format is a 10-column bid/ask CSV
|
|
98
|
+
# (data/forex/readme.md), not the 5-column mid-price OHLCV shape
|
|
99
|
+
# every other asset class here uses.
|
|
100
|
+
"security_type": "forex",
|
|
101
|
+
"market": "oanda",
|
|
102
|
+
"data_path_fn": lambda ticker: ROOT / "data" / "forex" / "oanda" / "daily" / f"{ticker.lower()}.zip",
|
|
103
|
+
"yahoo_symbol_fn": _forex_yahoo_symbol,
|
|
104
|
+
"write_fn": write_lean_forex_zip,
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
ASSET_CLASSES = tuple(ASSET_CLASS_CONFIG.keys())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def add_asset_to_config(config_path: Path, asset_block: dict) -> str:
|
|
111
|
+
"""Appends asset_block to config.json's phase1.universe.assets[] unless
|
|
112
|
+
a block with the same ticker already exists there. Returns "added" or
|
|
113
|
+
"already_exists". Preserves the file's existing exact formatting
|
|
114
|
+
(4-space indent, trailing newline). V5.4.7 (Problems.md #123): the
|
|
115
|
+
rewrite is now atomic (temp file + os.replace) - a crash mid-write of
|
|
116
|
+
the whole system's config previously left a truncated config.json
|
|
117
|
+
behind."""
|
|
118
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
119
|
+
assets = config["phase1"]["universe"]["assets"]
|
|
120
|
+
|
|
121
|
+
if any(existing["ticker"] == asset_block["ticker"] for existing in assets):
|
|
122
|
+
return "already_exists"
|
|
123
|
+
|
|
124
|
+
assets.append(asset_block)
|
|
125
|
+
atomic_write_json(config_path, config, indent=4)
|
|
126
|
+
# Match the historical trailing-newline formatting exactly.
|
|
127
|
+
with config_path.open("a", encoding="utf-8") as handle:
|
|
128
|
+
handle.write("\n")
|
|
129
|
+
return "added"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fetch_adhoc_asset(
|
|
133
|
+
asset_class: str,
|
|
134
|
+
ticker: str,
|
|
135
|
+
start: str,
|
|
136
|
+
end: str,
|
|
137
|
+
*,
|
|
138
|
+
apply: bool,
|
|
139
|
+
fetch_fn: Callable = fetch_yahoo_ohlcv,
|
|
140
|
+
config_path: Path = CONFIG_PATH,
|
|
141
|
+
extra_metadata: dict | None = None,
|
|
142
|
+
) -> dict:
|
|
143
|
+
"""apply=False (default): dry run - fetches but writes nothing (no zip,
|
|
144
|
+
no config.json edit). apply=True: writes the Lean zip via
|
|
145
|
+
write_lean_zip(), then adds a new config.json asset block if this
|
|
146
|
+
ticker isn't already configured. fetch_fn is the injection point for
|
|
147
|
+
tests (never real yfinance in tests), matching
|
|
148
|
+
yfinance_backfill.run_backfill()'s exact convention.
|
|
149
|
+
|
|
150
|
+
extra_metadata is merged into the written config.json asset block, on
|
|
151
|
+
top of class_config["extra_asset_fields"] - the per-fetch-call
|
|
152
|
+
complement to that per-asset-class static dict. Used by futures/options
|
|
153
|
+
fetches (aq_cli.py::cmd_fetch()) to store strike/expiry/right (options)
|
|
154
|
+
and family_ticker (both - defaults to the ticker itself when the
|
|
155
|
+
caller doesn't group multiple dated contracts under one family), so
|
|
156
|
+
train.py::build_derivatives_macro_features_by_date() can later
|
|
157
|
+
recognize and group them for real historical term-structure/put-call-
|
|
158
|
+
ratio computation. Ignored (None) for crypto/stock, which need no such
|
|
159
|
+
metadata."""
|
|
160
|
+
ticker = ticker.upper()
|
|
161
|
+
class_config = ASSET_CLASS_CONFIG[asset_class]
|
|
162
|
+
yahoo_symbol = class_config["yahoo_symbol_fn"](ticker)
|
|
163
|
+
output_zip = class_config["data_path_fn"](ticker)
|
|
164
|
+
security_type = class_config["security_type"]
|
|
165
|
+
|
|
166
|
+
rows = fetch_fn(yahoo_symbol, start, end)
|
|
167
|
+
scaled_rows = scale_for_lean(rows, security_type)
|
|
168
|
+
|
|
169
|
+
dates = [row["date"] for row in rows]
|
|
170
|
+
suggested_from = min(dates) if dates else None
|
|
171
|
+
suggested_to = max(dates) if dates else None
|
|
172
|
+
|
|
173
|
+
config_status = "not_attempted"
|
|
174
|
+
if not scaled_rows:
|
|
175
|
+
action = "no_data_returned"
|
|
176
|
+
elif apply:
|
|
177
|
+
write_fn = class_config.get("write_fn", write_lean_zip)
|
|
178
|
+
write_fn(output_zip, ticker, scaled_rows, merge_with_existing=True)
|
|
179
|
+
action = "written"
|
|
180
|
+
config_status = add_asset_to_config(
|
|
181
|
+
config_path,
|
|
182
|
+
{
|
|
183
|
+
"ticker": ticker,
|
|
184
|
+
"security_type": security_type,
|
|
185
|
+
"market": class_config["market"],
|
|
186
|
+
"data_path": output_zip.relative_to(ROOT).as_posix(),
|
|
187
|
+
"available_from": suggested_from.isoformat(),
|
|
188
|
+
"available_to": suggested_to.isoformat(),
|
|
189
|
+
**class_config.get("extra_asset_fields", {}),
|
|
190
|
+
**(extra_metadata or {}),
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
action = "dry_run"
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"ticker": ticker,
|
|
198
|
+
"asset_class": asset_class,
|
|
199
|
+
"yahoo_symbol": yahoo_symbol,
|
|
200
|
+
"data_path": str(output_zip),
|
|
201
|
+
"action": action,
|
|
202
|
+
"rows_fetched": len(rows),
|
|
203
|
+
"suggested_available_from": suggested_from.isoformat() if suggested_from else None,
|
|
204
|
+
"suggested_available_to": suggested_to.isoformat() if suggested_to else None,
|
|
205
|
+
"config_status": config_status,
|
|
206
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""FRED (Federal Reserve Economic Data) historical series backfill.
|
|
2
|
+
|
|
3
|
+
Powers features/bond_features.py's real yield-curve/credit-spread signals -
|
|
4
|
+
distinct from macro_features.py's existing yield_curve_slope_proxy/
|
|
5
|
+
credit_spread_proxy, which derive a *proxy* from bond-ETF price momentum.
|
|
6
|
+
This module fetches the *actual* Treasury yield/credit-spread series FRED
|
|
7
|
+
publishes, no API key required (FRED's public graph CSV endpoint).
|
|
8
|
+
|
|
9
|
+
Uses `httpx` over HTTP/2, NOT stdlib urllib (V5.1 Phase 2, CODESPACE RUN 1
|
|
10
|
+
diagnosis, development/Problems.md). FRED's graph-export endpoint
|
|
11
|
+
(fredgraph.csv) was directly confirmed - via curl and Python urllib, from
|
|
12
|
+
two independent networks - to hang/reset on plain HTTP/1.1 while
|
|
13
|
+
responding in ~1-2s over HTTP/2; stdlib's urllib has no HTTP/2 support, so
|
|
14
|
+
this is a real, load-bearing dependency, not a convenience one. See
|
|
15
|
+
fetch_fred_series()'s own docstring for the full diagnosis, including why
|
|
16
|
+
the User-Agent matters here in a non-obvious way.
|
|
17
|
+
|
|
18
|
+
Same two-safety-boundary shape as yfinance_backfill.py:
|
|
19
|
+
1. Writing the local cache is gated by --apply (default: dry run only).
|
|
20
|
+
2. Never touches config.json - this module's only output is
|
|
21
|
+
data/reference/fred_series/*.csv, read back by
|
|
22
|
+
load_cached_fred_series() at train/backtest time (Lean backtests are
|
|
23
|
+
date-bounded and must never make a live HTTP call mid-run).
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
python -m data_pipeline.fred_backfill [--series DGS10 DGS2] [--apply]
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import bisect
|
|
33
|
+
import csv
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
from datetime import date, timedelta
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from json_safety import atomic_write_text
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
46
|
+
CONFIG_PATH = ROOT / "config.json"
|
|
47
|
+
FRED_SERIES_CACHE_DIR = ROOT / "data" / "reference" / "fred_series"
|
|
48
|
+
|
|
49
|
+
FRED_CSV_URL = "https://fred.stlouisfed.org/graph/fredgraph.csv?id={series_id}"
|
|
50
|
+
|
|
51
|
+
DEFAULT_BOND_REFERENCE_SERIES = {
|
|
52
|
+
"treasury_3mo": "DGS3MO",
|
|
53
|
+
"treasury_2yr": "DGS2",
|
|
54
|
+
"treasury_5yr": "DGS5",
|
|
55
|
+
"treasury_10yr": "DGS10",
|
|
56
|
+
"treasury_30yr": "DGS30",
|
|
57
|
+
"credit_spread_baa10y": "BAA10Y",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Phase 4.12 (development/Problems.md #71): the alternative-data trio
|
|
61
|
+
# feeding features/alt_data_features.py. Chosen from a wider candidate list
|
|
62
|
+
# (VIX/VXV/NFCI/STLFSI4/UMCSENT/TEDRATE/SOFR/BAMLH0A0HYM2/DTWEXBGS) after
|
|
63
|
+
# live-fetching each one and checking (a) real coverage across
|
|
64
|
+
# phase1.universe.common_window (2014-12-01..2021-03-31) and (b)
|
|
65
|
+
# collinearity against the 8 existing bond_* features and against each
|
|
66
|
+
# other. STLFSI4/NFCI-level/BAA10Y overlap too much (rho 0.67-0.88) to add
|
|
67
|
+
# both; UMCSENT/TEDRATE/SOFR fail on coverage or lag; BAMLH0A0HYM2 has NO
|
|
68
|
+
# usable history before 2023-07-25 through FRED's keyless endpoint (ICE
|
|
69
|
+
# BofA series are license-restricted beyond a short trailing window) - do
|
|
70
|
+
# not re-add it without a paid FRED API key. DTWEXBGS was in this same
|
|
71
|
+
# candidate list and cleared both checks even then - it simply wasn't
|
|
72
|
+
# needed for the original 3-series alt-data trio above (a broadcast LEVEL
|
|
73
|
+
# feature; VIX/VXV/NFCI already covered vol/conditions). V5.1 Phase 2 (item
|
|
74
|
+
# 8 / F2, development/Problems.md) re-adds it below as a cross-asset
|
|
75
|
+
# SENSITIVITY DRIVER instead - a per-asset beta against dollar-index daily
|
|
76
|
+
# CHANGE varies across the cross-section (usable by a ranker) in a way a
|
|
77
|
+
# broadcast dollar-index LEVEL never would have. treasury_10yr_real
|
|
78
|
+
# (DFII10) and breakeven_inflation_10y (T10YIE) are new for the same
|
|
79
|
+
# purpose - real-rate and inflation-expectation sensitivity drivers,
|
|
80
|
+
# neither previously evaluated as a candidate here. GUARDRAIL (plan): FRED
|
|
81
|
+
# coverage for all 3 must be reverified on the Codespace before the first
|
|
82
|
+
# real training run - assume nothing until `aq backfill fred` confirms it;
|
|
83
|
+
# if any lacks 2014-12 -> 2021-03 coverage, drop it and its 2 sensitivity
|
|
84
|
+
# columns (features/cross_asset_sensitivity.py) rather than shipping a
|
|
85
|
+
# constant.
|
|
86
|
+
DEFAULT_ALT_DATA_REFERENCE_SERIES = {
|
|
87
|
+
"implied_volatility_vix": "VIXCLS",
|
|
88
|
+
"implied_volatility_3m": "VXVCLS",
|
|
89
|
+
"financial_conditions_nfci": "NFCI",
|
|
90
|
+
"treasury_10yr_real": "DFII10",
|
|
91
|
+
"breakeven_inflation_10y": "T10YIE",
|
|
92
|
+
"dollar_index": "DTWEXBGS",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Days to subtract from the decision date before doing the as-of lookup
|
|
96
|
+
# (see series_value_asof()) - i.e. how many days after a series' own date
|
|
97
|
+
# column the observation is actually PUBLISHED. VIX/VXV are same-day CBOE
|
|
98
|
+
# closes (0 lag, identical convention to the Treasury/BAA10Y series
|
|
99
|
+
# above). NFCI observations are Friday-dated but released the FOLLOWING
|
|
100
|
+
# Wednesday (+5 calendar days); 7 is a deliberately conservative superset
|
|
101
|
+
# that survives a holiday-shifted release. Verified this session: every
|
|
102
|
+
# cached NFCI row falls on a Friday. DFII10/T10YIE are same-day Treasury-
|
|
103
|
+
# desk marks (0 lag, same convention as the nominal DGS* series).
|
|
104
|
+
# DTWEXBGS (H.10 trade-weighted dollar index) is a Federal Reserve Board
|
|
105
|
+
# release published the following business day (1 lag) - unlike the
|
|
106
|
+
# CBOE/Treasury series above, it is NOT a same-day mark.
|
|
107
|
+
ALT_DATA_PUBLICATION_LAG_DAYS = {
|
|
108
|
+
"implied_volatility_vix": 0,
|
|
109
|
+
"implied_volatility_3m": 0,
|
|
110
|
+
"financial_conditions_nfci": 7,
|
|
111
|
+
"treasury_10yr_real": 0,
|
|
112
|
+
"breakeven_inflation_10y": 0,
|
|
113
|
+
"dollar_index": 1,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Pure helpers
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_fred_csv(text: str, series_id: str, start: str, end: str) -> list[dict]:
|
|
123
|
+
"""Parses FRED's two-column CSV. The date column header has varied
|
|
124
|
+
across FRED API versions ("DATE" historically, "observation_date" as
|
|
125
|
+
of this module's writing) - read positionally (fieldnames[0]/[1])
|
|
126
|
+
rather than by a hardcoded name so a future FRED header rename doesn't
|
|
127
|
+
silently zero out every row. FRED marks holiday/no-observation rows
|
|
128
|
+
with either an empty string or a literal "." in the value column -
|
|
129
|
+
both are dropped, not coerced to 0.0 (a missing yield observation must
|
|
130
|
+
never look like "yield is exactly 0%"). Clipped to [start, end]
|
|
131
|
+
inclusive. Never raises on malformed rows - skips them, matching
|
|
132
|
+
fetch_yahoo_ohlcv()'s "never abort the whole fetch over one bad row"
|
|
133
|
+
convention."""
|
|
134
|
+
start_date = date.fromisoformat(start)
|
|
135
|
+
end_date = date.fromisoformat(end)
|
|
136
|
+
|
|
137
|
+
rows: list[dict] = []
|
|
138
|
+
reader = csv.DictReader(text.splitlines())
|
|
139
|
+
if not reader.fieldnames or len(reader.fieldnames) < 2:
|
|
140
|
+
return rows
|
|
141
|
+
date_column, value_column = reader.fieldnames[0], reader.fieldnames[1]
|
|
142
|
+
for record in reader:
|
|
143
|
+
raw_date = record.get(date_column)
|
|
144
|
+
raw_value = record.get(value_column)
|
|
145
|
+
if not raw_date or raw_value is None or raw_value.strip() in ("", "."):
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
row_date = date.fromisoformat(raw_date.strip())
|
|
149
|
+
value = float(raw_value.strip())
|
|
150
|
+
except ValueError:
|
|
151
|
+
continue
|
|
152
|
+
if row_date < start_date or row_date > end_date:
|
|
153
|
+
continue
|
|
154
|
+
rows.append({"date": row_date, "value": value})
|
|
155
|
+
return rows
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def rows_to_cache_csv(rows: list[dict]) -> str:
|
|
159
|
+
"""date.isoformat(),value - one row per line, sorted by date. Mirrors
|
|
160
|
+
yfinance_backfill.rows_to_lean_csv()'s "pure formatting, sorted" shape."""
|
|
161
|
+
ordered = sorted(rows, key=lambda row: row["date"])
|
|
162
|
+
lines = [f"{row['date'].isoformat()},{row['value']}" for row in ordered]
|
|
163
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cache_csv_to_rows(text: str) -> list[dict]:
|
|
167
|
+
"""Inverse of rows_to_cache_csv() - reads the local cache format back."""
|
|
168
|
+
rows: list[dict] = []
|
|
169
|
+
for line in text.splitlines():
|
|
170
|
+
if not line.strip():
|
|
171
|
+
continue
|
|
172
|
+
raw_date, raw_value = line.split(",")
|
|
173
|
+
rows.append({"date": date.fromisoformat(raw_date), "value": float(raw_value)})
|
|
174
|
+
return rows
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def series_value_asof(rows: list[dict], current_date, publication_lag_days: int = 0) -> float | None:
|
|
178
|
+
"""Most recent observation whose value was PUBLISHED on or before
|
|
179
|
+
current_date - the shared, publication-lag-aware as-of lookup for
|
|
180
|
+
BOTH train.py::build_alt_data_features_by_date() and
|
|
181
|
+
main.py::_build_alt_data_payload(), so the lookahead rule below cannot
|
|
182
|
+
drift between the two callers the way build_bond_features_by_date()'s
|
|
183
|
+
inline _series_asof_lookup() and main.py::_fred_series_asof() already
|
|
184
|
+
have (both correct for a same-day series, since publication_lag_days
|
|
185
|
+
is implicitly 0 there - but that helper has no lag parameter at all,
|
|
186
|
+
so it must never be reused for a lagged series).
|
|
187
|
+
|
|
188
|
+
The lookahead this guards against: FRED dates every observation by its
|
|
189
|
+
REFERENCE period, not its release date. DGS10's 2020-03-16 row is that
|
|
190
|
+
day's close, known that evening (lag 0, safe). NFCI's 2020-03-20 row
|
|
191
|
+
is the week ENDING Friday 2020-03-20, not released until Wednesday
|
|
192
|
+
2020-03-25 (lag ~5-7 days) - a bare bisect_right(dates, current_date)
|
|
193
|
+
would hand a 2020-03-23 decision a number that did not exist for two
|
|
194
|
+
more days. effective_date = current_date - publication_lag_days
|
|
195
|
+
reproduces the exact bisect every existing 0-lag caller already does
|
|
196
|
+
when publication_lag_days=0 (see
|
|
197
|
+
test_series_value_asof_zero_lag_matches_existing_bisect_behavior).
|
|
198
|
+
|
|
199
|
+
`rows` is one series' [{"date": date, "value": float}, ...] - the same
|
|
200
|
+
per-series list load_cached_fred_series() returns. Returns None (never
|
|
201
|
+
raises) if `rows` is empty or every observation postdates
|
|
202
|
+
effective_date."""
|
|
203
|
+
if not rows:
|
|
204
|
+
return None
|
|
205
|
+
target = current_date.date() if hasattr(current_date, "date") else current_date
|
|
206
|
+
effective_date = target - timedelta(days=publication_lag_days)
|
|
207
|
+
ordered = sorted(rows, key=lambda row: row["date"])
|
|
208
|
+
dates = [row["date"] for row in ordered]
|
|
209
|
+
values = [row["value"] for row in ordered]
|
|
210
|
+
position = bisect.bisect_right(dates, effective_date)
|
|
211
|
+
if position == 0:
|
|
212
|
+
return None
|
|
213
|
+
return values[position - 1]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def series_change_asof(
|
|
217
|
+
rows: list[dict],
|
|
218
|
+
current_date,
|
|
219
|
+
publication_lag_days: int = 0,
|
|
220
|
+
periods_back: int = 4,
|
|
221
|
+
) -> float | None:
|
|
222
|
+
"""value_asof(current_date) minus the observation `periods_back`
|
|
223
|
+
OBSERVATIONS (not calendar days) earlier in the same series - e.g.
|
|
224
|
+
periods_back=4 on a weekly series is a 4-week change. Both endpoints
|
|
225
|
+
are read from the same lag-adjusted, sorted index position, so the
|
|
226
|
+
change inherits series_value_asof()'s publication guard by
|
|
227
|
+
construction - there is no code path where the more-recent endpoint is
|
|
228
|
+
unpublished as of current_date.
|
|
229
|
+
|
|
230
|
+
Returns None (never 0.0 - a missing change must not look like "no
|
|
231
|
+
change") when `rows` is empty, current_date predates every
|
|
232
|
+
observation, or fewer than periods_back+1 observations precede
|
|
233
|
+
effective_date."""
|
|
234
|
+
if not rows:
|
|
235
|
+
return None
|
|
236
|
+
target = current_date.date() if hasattr(current_date, "date") else current_date
|
|
237
|
+
effective_date = target - timedelta(days=publication_lag_days)
|
|
238
|
+
ordered = sorted(rows, key=lambda row: row["date"])
|
|
239
|
+
dates = [row["date"] for row in ordered]
|
|
240
|
+
values = [row["value"] for row in ordered]
|
|
241
|
+
position = bisect.bisect_right(dates, effective_date)
|
|
242
|
+
prior_index = position - 1 - periods_back
|
|
243
|
+
if position == 0 or prior_index < 0:
|
|
244
|
+
return None
|
|
245
|
+
return values[position - 1] - values[prior_index]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# The only function that performs network I/O.
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def fetch_fred_series(series_id: str, start: str, end: str) -> list[dict]:
|
|
254
|
+
"""GET FRED's public graph CSV endpoint for series_id, clipped to
|
|
255
|
+
[start, end]. Returns [] on any HTTP/parse failure - never raises, so
|
|
256
|
+
one bad/renamed series id never aborts a multi-series backfill run,
|
|
257
|
+
matching fetch_yahoo_ohlcv()'s convention exactly.
|
|
258
|
+
|
|
259
|
+
MUST use HTTP/2 (http2=True below) - confirmed by direct diagnosis
|
|
260
|
+
(V5.1 Phase 2, CODESPACE RUN 1) that FRED's graph-export endpoint
|
|
261
|
+
hangs/RST_STREAMs on plain HTTP/1.1 requests (reproduced with both
|
|
262
|
+
curl --http1.1 and stdlib urllib, from two independent networks) while
|
|
263
|
+
responding in ~1-2s over HTTP/2. This is why the module now depends on
|
|
264
|
+
httpx instead of stdlib urllib, which cannot speak HTTP/2 at all.
|
|
265
|
+
|
|
266
|
+
Do NOT set a browser-spoofing User-Agent here (a prior version of this
|
|
267
|
+
function did, believing FRED required one - that belief was never
|
|
268
|
+
re-verified and turned out backwards). Under HTTP/2 specifically, a
|
|
269
|
+
"Mozilla/5.0..." User-Agent paired with httpx's own (non-browser) TLS
|
|
270
|
+
fingerprint gets an IMMEDIATE RST_STREAM from FRED's edge - a
|
|
271
|
+
mismatched UA/TLS-fingerprint pair reads as automated traffic
|
|
272
|
+
disguising itself, which is worse than not disguising it at all.
|
|
273
|
+
httpx's own honest default User-Agent (python-httpx/x.y) was verified
|
|
274
|
+
working reliably; leave headers unset."""
|
|
275
|
+
url = FRED_CSV_URL.format(series_id=series_id)
|
|
276
|
+
try:
|
|
277
|
+
with httpx.Client(http2=True, timeout=15.0) as client:
|
|
278
|
+
response = client.get(url)
|
|
279
|
+
response.raise_for_status()
|
|
280
|
+
text = response.text
|
|
281
|
+
except httpx.HTTPError as exc:
|
|
282
|
+
logger.warning("fetch_fred_series(%s): fetch failed — %s", series_id, exc)
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
rows = parse_fred_csv(text, series_id, start, end)
|
|
286
|
+
if not rows:
|
|
287
|
+
logger.warning("fetch_fred_series(%s): no data returned for %s..%s", series_id, start, end)
|
|
288
|
+
return rows
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# Orchestration
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def bond_reference_series(config: dict) -> dict[str, str]:
|
|
297
|
+
"""config["phase1"]["features"]["bond_reference_series"], falling back
|
|
298
|
+
to DEFAULT_BOND_REFERENCE_SERIES so this module works standalone even
|
|
299
|
+
against an older config.json."""
|
|
300
|
+
return {
|
|
301
|
+
**DEFAULT_BOND_REFERENCE_SERIES,
|
|
302
|
+
**config.get("phase1", {}).get("features", {}).get("bond_reference_series", {}),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def fetch_all_bond_reference_series(config: dict, start: str, end: str) -> dict[str, list[dict]]:
|
|
307
|
+
"""One independent fetch per series - a single bad/rate-limited FRED
|
|
308
|
+
series never aborts the others, same convention as
|
|
309
|
+
yfinance_backfill.run_backfill()'s per-asset loop."""
|
|
310
|
+
return {
|
|
311
|
+
series_key: fetch_fred_series(series_id, start, end)
|
|
312
|
+
for series_key, series_id in bond_reference_series(config).items()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def alt_data_reference_series(config: dict) -> dict[str, str]:
|
|
317
|
+
"""Phase 4.12 (development/Problems.md #71) sibling of
|
|
318
|
+
bond_reference_series(): config["phase1"]["features"]["alt_data_reference_series"],
|
|
319
|
+
falling back to DEFAULT_ALT_DATA_REFERENCE_SERIES."""
|
|
320
|
+
return {
|
|
321
|
+
**DEFAULT_ALT_DATA_REFERENCE_SERIES,
|
|
322
|
+
**config.get("phase1", {}).get("features", {}).get("alt_data_reference_series", {}),
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def reference_series(config: dict, group: str = "all") -> dict[str, str]:
|
|
327
|
+
"""Merged view over bond_reference_series()/alt_data_reference_series()
|
|
328
|
+
for the CLI's --group flag. group="bond" or "alt" returns just that
|
|
329
|
+
one map unchanged (bond_reference_series() itself is deliberately left
|
|
330
|
+
untouched by this addition - see test_bond_reference_series_unchanged_by_alt_addition);
|
|
331
|
+
"all" (default) merges both, alt_data keys taking precedence only in
|
|
332
|
+
the pathological case of an actual name collision (none exist today -
|
|
333
|
+
the two dicts' keys are disjoint by construction)."""
|
|
334
|
+
if group == "bond":
|
|
335
|
+
return bond_reference_series(config)
|
|
336
|
+
if group == "alt":
|
|
337
|
+
return alt_data_reference_series(config)
|
|
338
|
+
return {**bond_reference_series(config), **alt_data_reference_series(config)}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def write_fred_series_cache(cache_dir: Path, series_key: str, rows: list[dict]) -> None:
|
|
342
|
+
"""Writes data/reference/fred_series/{series_key}.csv - a local,
|
|
343
|
+
offline-readable cache (never committed as real market data the way
|
|
344
|
+
Lean zips are; refreshed by re-running this module with --apply).
|
|
345
|
+
|
|
346
|
+
V5.4.7 (development/Problems.md #123): the write now MERGES with any
|
|
347
|
+
existing cache file instead of replacing it - the CLI's --start/--end
|
|
348
|
+
clip a narrow refetch (e.g. one series, one recent year), and a full
|
|
349
|
+
replacement silently destroyed every cached observation outside that
|
|
350
|
+
window, degrading bond/macro features via series_value_asof() ever
|
|
351
|
+
after. New rows win on date collisions (fresh fetch is authoritative
|
|
352
|
+
for its own window); dates only in the existing cache are preserved."""
|
|
353
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
output_path = cache_dir / f"{series_key}.csv"
|
|
355
|
+
merged: dict = {row["date"]: row["value"] for row in rows}
|
|
356
|
+
if output_path.exists():
|
|
357
|
+
try:
|
|
358
|
+
for existing_row in cache_csv_to_rows(output_path.read_text(encoding="utf-8")):
|
|
359
|
+
merged.setdefault(existing_row["date"], existing_row["value"])
|
|
360
|
+
except Exception as exc:
|
|
361
|
+
# An unreadable old cache must not block a fresh write - the
|
|
362
|
+
# new rows alone are still strictly better than no cache.
|
|
363
|
+
logger.warning("write_fred_series_cache(%s): unreadable old cache (%s) - replacing.", series_key, exc)
|
|
364
|
+
merged_rows = [{"date": day, "value": value} for day, value in sorted(merged.items())]
|
|
365
|
+
atomic_write_text(output_path, rows_to_cache_csv(merged_rows))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def load_cached_fred_series(cache_dir: Path = FRED_SERIES_CACHE_DIR) -> dict[str, list[dict]]:
|
|
369
|
+
"""Reads back whatever *.csv files exist under cache_dir. Returns {} if
|
|
370
|
+
the directory doesn't exist yet (fresh clone, backfill never run) -
|
|
371
|
+
every caller of this (train.py/main.py) must treat a missing/empty
|
|
372
|
+
series as "neutral-default the corresponding bond feature", never a
|
|
373
|
+
crash, same as macro_features.py's own missing-reference convention."""
|
|
374
|
+
if not cache_dir.exists():
|
|
375
|
+
return {}
|
|
376
|
+
series: dict[str, list[dict]] = {}
|
|
377
|
+
for path in sorted(cache_dir.glob("*.csv")):
|
|
378
|
+
try:
|
|
379
|
+
series[path.stem] = cache_csv_to_rows(path.read_text(encoding="utf-8"))
|
|
380
|
+
except (ValueError, OSError) as exc:
|
|
381
|
+
logger.warning("load_cached_fred_series(%s): unreadable cache file — %s", path, exc)
|
|
382
|
+
return series
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# CLI
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def main() -> None:
|
|
391
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s")
|
|
392
|
+
parser = argparse.ArgumentParser(
|
|
393
|
+
description="Aether Quant FRED historical series backfill - offline/manual only, "
|
|
394
|
+
"no API key required, never run inside Lean or a Docker worker."
|
|
395
|
+
)
|
|
396
|
+
parser.add_argument("--series", nargs="*", default=None, help="Restrict to these series keys (default: all of --group's reference series)")
|
|
397
|
+
parser.add_argument(
|
|
398
|
+
"--group",
|
|
399
|
+
choices=["bond", "alt", "all"],
|
|
400
|
+
default="all",
|
|
401
|
+
help="Which reference-series map to fetch: bond (phase1.features.bond_reference_series), "
|
|
402
|
+
"alt (phase1.features.alt_data_reference_series, Problems.md #71), or all (default, both merged)",
|
|
403
|
+
)
|
|
404
|
+
parser.add_argument("--start", default="1990-01-01")
|
|
405
|
+
parser.add_argument("--end", default=date.today().isoformat())
|
|
406
|
+
parser.add_argument("--apply", action="store_true", help="Actually write the local cache (default: dry run, report only)")
|
|
407
|
+
parser.add_argument("--config-path", type=Path, default=CONFIG_PATH)
|
|
408
|
+
parser.add_argument("--cache-dir", type=Path, default=FRED_SERIES_CACHE_DIR)
|
|
409
|
+
args = parser.parse_args()
|
|
410
|
+
|
|
411
|
+
with args.config_path.open("r", encoding="utf-8") as f:
|
|
412
|
+
config = json.load(f)
|
|
413
|
+
|
|
414
|
+
series_map = reference_series(config, args.group)
|
|
415
|
+
if args.series:
|
|
416
|
+
series_map = {key: value for key, value in series_map.items() if key in args.series}
|
|
417
|
+
|
|
418
|
+
print(f"{'APPLY' if args.apply else 'DRY RUN'} — fetching {len(series_map)} FRED series ({args.start}..{args.end}):\n")
|
|
419
|
+
for series_key, series_id in series_map.items():
|
|
420
|
+
rows = fetch_fred_series(series_id, args.start, args.end)
|
|
421
|
+
if args.apply and rows:
|
|
422
|
+
write_fred_series_cache(args.cache_dir, series_key, rows)
|
|
423
|
+
action = "written"
|
|
424
|
+
elif not rows:
|
|
425
|
+
action = "no_data_returned"
|
|
426
|
+
else:
|
|
427
|
+
action = "dry_run"
|
|
428
|
+
print(f"- {series_key} ({series_id}): {action}, rows_fetched={len(rows)}")
|
|
429
|
+
|
|
430
|
+
if not args.apply:
|
|
431
|
+
print("\nDry run only — no cache files were written. Re-run with --apply to write the cache.")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if __name__ == "__main__":
|
|
435
|
+
main()
|