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
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()