aether-quant 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. aether_quant-0.0.1.dist-info/METADATA +7 -0
  2. aether_quant-0.0.1.dist-info/RECORD +99 -0
  3. aether_quant-0.0.1.dist-info/WHEEL +5 -0
  4. aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
  5. aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
  6. aether_quant-0.0.1.dist-info/top_level.txt +13 -0
  7. analyzer/README.md +69 -0
  8. analyzer/__init__.py +17 -0
  9. analyzer/market_analyzer.py +521 -0
  10. aq_cli.py +3633 -0
  11. audit/README.md +47 -0
  12. audit/__init__.py +38 -0
  13. audit/hash_chain.py +52 -0
  14. audit/postgres_audit.py +61 -0
  15. audit/postgres_worker.py +402 -0
  16. audit/redis_queue.py +124 -0
  17. audit/status_export.py +62 -0
  18. data_pipeline/README.md +257 -0
  19. data_pipeline/__init__.py +6 -0
  20. data_pipeline/bar_synthesis.py +97 -0
  21. data_pipeline/dividend_backfill.py +243 -0
  22. data_pipeline/factor_file_backfill.py +276 -0
  23. data_pipeline/fetch.py +206 -0
  24. data_pipeline/fred_backfill.py +435 -0
  25. data_pipeline/ib_backfill.py +287 -0
  26. data_pipeline/v2_manifest.py +75 -0
  27. data_pipeline/yfinance_backfill.py +501 -0
  28. evaluation/README.md +152 -0
  29. evaluation/__init__.py +73 -0
  30. evaluation/ablation.py +310 -0
  31. evaluation/benchmark_comparison.py +195 -0
  32. evaluation/confidence_threshold_calibration.py +157 -0
  33. evaluation/factor_neutralization_check.py +94 -0
  34. evaluation/feature_parity.py +321 -0
  35. evaluation/feature_reconciliation.py +286 -0
  36. evaluation/impact_model.py +77 -0
  37. evaluation/kill_switch_replay.py +186 -0
  38. evaluation/limit_fill_simulator.py +152 -0
  39. evaluation/model_predictions.py +192 -0
  40. evaluation/monte_carlo.py +161 -0
  41. evaluation/public_benchmarks.py +117 -0
  42. evaluation/rank_book_simulator.py +494 -0
  43. evaluation/rank_ic_core.py +204 -0
  44. evaluation/rank_signal_calibration.py +690 -0
  45. evaluation/rolling_ic_gate_calibration.py +190 -0
  46. evaluation/rolling_ic_gate_replay.py +138 -0
  47. execution/README.md +281 -0
  48. execution/__init__.py +80 -0
  49. execution/config_cache.py +48 -0
  50. execution/cost_model.py +213 -0
  51. execution/lean_config_render.py +131 -0
  52. execution/live_credentials.py +38 -0
  53. execution/live_credentials_io.py +52 -0
  54. execution/order_gate.py +388 -0
  55. execution/paper_readiness.py +181 -0
  56. execution/paper_readiness_io.py +54 -0
  57. execution/paper_readiness_report.py +103 -0
  58. execution/paper_readiness_scheduler.py +137 -0
  59. execution/reconciliation.py +160 -0
  60. execution/runtime_config_io.py +38 -0
  61. execution/secret_scan.py +73 -0
  62. features/README.md +77 -0
  63. features/__init__.py +137 -0
  64. features/alt_data_features.py +154 -0
  65. features/bond_features.py +275 -0
  66. features/cross_asset_sensitivity.py +79 -0
  67. features/derivatives_macro_features.py +72 -0
  68. features/macro_features.py +79 -0
  69. features/options_greeks.py +329 -0
  70. features/sector_map.py +53 -0
  71. features/technical_indicators.py +185 -0
  72. generate_backtest_report.py +311 -0
  73. generate_evaluation_report.py +712 -0
  74. inference/README.md +105 -0
  75. inference/__init__.py +36 -0
  76. inference/exported_model.py +929 -0
  77. inference/parallel_inference.py +160 -0
  78. inference/strategy_selector_inference.py +75 -0
  79. json_safety.py +130 -0
  80. portfolio/README.md +447 -0
  81. portfolio/__init__.py +41 -0
  82. portfolio/book_construction.py +786 -0
  83. portfolio/book_neutrality.py +178 -0
  84. portfolio/hrp_allocation.py +159 -0
  85. portfolio/options_arbitrage_detector.py +232 -0
  86. portfolio/options_assignment_risk.py +119 -0
  87. portfolio/options_margin_sizing.py +333 -0
  88. portfolio/options_strategy.py +1999 -0
  89. portfolio/rank_signal.py +219 -0
  90. portfolio/rolling_ic_gate.py +183 -0
  91. risk/README.md +275 -0
  92. risk/__init__.py +38 -0
  93. risk/asset_class_router.py +584 -0
  94. risk/forex_risk.py +158 -0
  95. risk/futures_risk.py +202 -0
  96. risk/kill_switch.py +261 -0
  97. risk/manual_override.py +118 -0
  98. risk/position_sizing.py +303 -0
  99. risk/rl_sizing.py +189 -0
@@ -0,0 +1,243 @@
1
+ """Dividend ex-date/amount history backfill + forward cadence-based
2
+ projection - powers portfolio/options_assignment_risk.py's early-
3
+ call-assignment signal (development/Problems.md - early-assignment
4
+ probability/pricing modeling).
5
+
6
+ No dividend $ amounts/ex-div dates/schedule exist anywhere else in this
7
+ codebase - data_pipeline/fred_backfill.py only pulls Treasury yield/credit-
8
+ spread series, and data_pipeline/yfinance_backfill.py's auto_adjust=True
9
+ OHLCV downloads discard raw dividend events before they ever reach a Lean
10
+ zip. This module fetches actual historical ex-dividend dates/amounts via
11
+ yfinance (dev-only dependency, deferred import - mirrors
12
+ yfinance_backfill.py's own convention) and derives a best-effort FORWARD
13
+ projection from historical cadence - NOT yfinance's own forward
14
+ Ticker.calendar/get_earnings_dates fields, which are scraped from Yahoo's
15
+ UI and empirically unreliable/frequently stale or absent for less-followed
16
+ tickers. This is a deliberate accuracy-over-completeness choice; every
17
+ consumer must treat the projection as a best-effort estimate, never a
18
+ confirmed date (see estimate_next_ex_dividend_date()'s own
19
+ "confidence"/"method" fields).
20
+
21
+ Same two-safety-boundary shape as fred_backfill.py/yfinance_backfill.py:
22
+ 1. Writing the local cache is gated by --apply (default: dry run only).
23
+ 2. Never touches config.json - this module's only output is
24
+ data/reference/dividend_schedule/*.json, read back once at main.py
25
+ init (Lean backtests are date-bounded and must never make a live HTTP
26
+ call mid-run) - refreshed by re-running this module with --apply, not
27
+ intraday.
28
+
29
+ Usage:
30
+ python -m data_pipeline.dividend_backfill [--tickers AAPL MSFT] [--apply]
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import logging
38
+ import statistics
39
+ from datetime import date
40
+ from pathlib import Path
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ ROOT = Path(__file__).resolve().parents[1]
45
+ CONFIG_PATH = ROOT / "config.json"
46
+ DIVIDEND_SCHEDULE_CACHE_DIR = ROOT / "data" / "reference" / "dividend_schedule"
47
+
48
+ MIN_HISTORY_EVENTS_FOR_ESTIMATE = 2
49
+ CADENCE_LOOKBACK_EVENTS = 8
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Pure helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def estimate_next_ex_dividend_date(history: list[dict], as_of: date) -> dict:
58
+ """Pure. Projects the next ex-div date from historical CADENCE (median
59
+ days between consecutive ex_dates over the trailing
60
+ CADENCE_LOOKBACK_EVENTS events), rolled forward from the most recent
61
+ ex_date <= as_of - NOT a yfinance forward-calendar call (see module
62
+ docstring). Returns {"estimated_next_ex_date": iso-date | None,
63
+ "estimated_amount": float | None, "cadence_days": int | None,
64
+ "confidence": "low"|"medium"|"none", "method": str}.
65
+
66
+ "none" confidence (every numeric field None) when fewer than
67
+ MIN_HISTORY_EVENTS_FOR_ESTIMATE events are available as-of as_of - an
68
+ unknown schedule must never be indistinguishable from a genuinely "no
69
+ dividend expected" signal, the same None-vs-neutral-default
70
+ distinction every other pure signal function in this codebase draws.
71
+ "medium" only with a reasonably regular cadence over enough lookback
72
+ events; "low" otherwise. Never raises."""
73
+ past_events = sorted(
74
+ (event for event in history if event.get("ex_date") is not None and event["ex_date"] <= as_of),
75
+ key=lambda event: event["ex_date"],
76
+ )
77
+ if len(past_events) < MIN_HISTORY_EVENTS_FOR_ESTIMATE:
78
+ return {
79
+ "estimated_next_ex_date": None,
80
+ "estimated_amount": None,
81
+ "cadence_days": None,
82
+ "confidence": "none",
83
+ "method": "insufficient_history",
84
+ }
85
+
86
+ lookback = past_events[-CADENCE_LOOKBACK_EVENTS:]
87
+ gaps = [(lookback[i]["ex_date"] - lookback[i - 1]["ex_date"]).days for i in range(1, len(lookback))]
88
+ cadence_days = max(round(statistics.median(gaps)), 1)
89
+ most_recent = lookback[-1]
90
+ estimated_next = most_recent["ex_date"]
91
+ # Roll forward past `as_of` by whole cadence steps - handles a stale
92
+ # cache (as_of well past the last known event) without ever guessing a
93
+ # date already in the past.
94
+ while estimated_next <= as_of:
95
+ estimated_next = date.fromordinal(estimated_next.toordinal() + cadence_days)
96
+
97
+ spread = (max(gaps) - min(gaps)) if len(gaps) > 1 else 0
98
+ confidence = "medium" if len(gaps) >= 3 and spread <= 15 else "low"
99
+
100
+ return {
101
+ "estimated_next_ex_date": estimated_next.isoformat(),
102
+ "estimated_amount": most_recent["amount"],
103
+ "cadence_days": cadence_days,
104
+ "confidence": confidence,
105
+ "method": "historical_cadence_projection - not a confirmed forward date",
106
+ }
107
+
108
+
109
+ def dividend_schedule_payload(ticker: str, history: list[dict], as_of: date) -> dict:
110
+ """Composes fetch_dividend_history()'s raw history +
111
+ estimate_next_ex_dividend_date()'s projection into the exact shape
112
+ write_dividend_schedule() persists / main.py reads back."""
113
+ estimate = estimate_next_ex_dividend_date(history, as_of)
114
+ return {
115
+ "ticker": ticker,
116
+ "fetched_at": as_of.isoformat(),
117
+ "history": [{"ex_date": event["ex_date"].isoformat(), "amount": event["amount"]} for event in history],
118
+ "next_ex_dividend_estimate": estimate,
119
+ }
120
+
121
+
122
+ def option_underlying_tickers(config: dict) -> list[str]:
123
+ """Every ticker this module is actually useful for: an option asset's
124
+ underlying_ticker, or an equity asset's own ticker directly. Pure,
125
+ de-duplicated, order-preserving, never raises on a malformed asset
126
+ entry (missing keys just get skipped)."""
127
+ tickers: list[str] = []
128
+ seen: set[str] = set()
129
+ for asset in config.get("phase1", {}).get("universe", {}).get("assets", []):
130
+ security_type = asset.get("security_type")
131
+ ticker = asset.get("underlying_ticker") if security_type == "option" else asset.get("ticker")
132
+ if ticker and ticker not in seen:
133
+ seen.add(ticker)
134
+ tickers.append(ticker)
135
+ return tickers
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # The only function that imports yfinance - deferred, mirrors
140
+ # yfinance_backfill.py's own convention.
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ def fetch_dividend_history(yahoo_symbol: str) -> list[dict]:
145
+ """Real historical ex-dividend dates + per-share amounts via
146
+ yfinance.Ticker(yahoo_symbol).dividends. Returns [] on any fetch
147
+ failure (network, delisted, no dividend history) - never raises, same
148
+ fail-open convention as yfinance_backfill.fetch_yahoo_ohlcv()."""
149
+ try:
150
+ import yfinance as yf # deferred - dev-only dependency, never in requirements.txt
151
+
152
+ series = yf.Ticker(yahoo_symbol).dividends
153
+ except Exception as exc:
154
+ logger.warning("fetch_dividend_history(%s): fetch failed — %s", yahoo_symbol, exc)
155
+ return []
156
+
157
+ if series is None or series.empty:
158
+ logger.warning("fetch_dividend_history(%s): no dividend history returned", yahoo_symbol)
159
+ return []
160
+
161
+ rows = []
162
+ for index, amount in series.items():
163
+ row_date = index.date() if hasattr(index, "date") else index
164
+ rows.append({"ex_date": row_date, "amount": float(amount)})
165
+ return sorted(rows, key=lambda row: row["ex_date"])
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Orchestration
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ def write_dividend_schedule(cache_dir: Path, ticker: str, history: list[dict], as_of: date) -> dict:
174
+ """Writes data/reference/dividend_schedule/<ticker>.json. Returns the
175
+ payload written (for CLI reporting)."""
176
+ payload = dividend_schedule_payload(ticker, history, as_of)
177
+ cache_dir.mkdir(parents=True, exist_ok=True)
178
+ output_path = cache_dir / f"{ticker}.json"
179
+ output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
180
+ return payload
181
+
182
+
183
+ def load_cached_dividend_schedule(ticker: str, cache_dir: Path = DIVIDEND_SCHEDULE_CACHE_DIR) -> dict:
184
+ """Reads back data/reference/dividend_schedule/<ticker>.json. Returns
185
+ {} when missing/unreadable (fresh clone, backfill never run for this
186
+ ticker) - callers (main.py) must treat an empty dict as "no dividend
187
+ schedule known", never a crash, same convention as
188
+ fred_backfill.load_cached_fred_series()."""
189
+ path = cache_dir / f"{ticker}.json"
190
+ if not path.exists():
191
+ return {}
192
+ try:
193
+ return json.loads(path.read_text(encoding="utf-8"))
194
+ except (ValueError, OSError) as exc:
195
+ logger.warning("load_cached_dividend_schedule(%s): unreadable cache file — %s", ticker, exc)
196
+ return {}
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # CLI
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ def main() -> None:
205
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s")
206
+ parser = argparse.ArgumentParser(
207
+ description="Aether Quant dividend history backfill - offline/manual only, "
208
+ "no API key required, never run inside Lean or a Docker worker."
209
+ )
210
+ parser.add_argument(
211
+ "--tickers", nargs="*", default=None, help="Restrict to these tickers (default: every option underlying/equity in phase1.universe.assets)"
212
+ )
213
+ parser.add_argument("--apply", action="store_true", help="Actually write the local cache (default: dry run, report only)")
214
+ parser.add_argument("--config-path", type=Path, default=CONFIG_PATH)
215
+ parser.add_argument("--cache-dir", type=Path, default=DIVIDEND_SCHEDULE_CACHE_DIR)
216
+ args = parser.parse_args()
217
+
218
+ with args.config_path.open("r", encoding="utf-8") as f:
219
+ config = json.load(f)
220
+
221
+ tickers = args.tickers if args.tickers else option_underlying_tickers(config)
222
+ as_of = date.today()
223
+
224
+ print(f"{'APPLY' if args.apply else 'DRY RUN'} — fetching dividend history for {len(tickers)} ticker(s):\n")
225
+ for ticker in tickers:
226
+ history = fetch_dividend_history(ticker)
227
+ if not history:
228
+ print(f"- {ticker}: no_data_returned, events_fetched=0")
229
+ continue
230
+ payload = dividend_schedule_payload(ticker, history, as_of)
231
+ if args.apply:
232
+ write_dividend_schedule(args.cache_dir, ticker, history, as_of)
233
+ action = "written"
234
+ else:
235
+ action = "dry_run"
236
+ print(f"- {ticker}: {action}, events_fetched={len(history)}, next_ex_estimate={payload['next_ex_dividend_estimate']}")
237
+
238
+ if not args.apply:
239
+ print("\nDry run only — no cache files were written. Re-run with --apply to write the cache.")
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
@@ -0,0 +1,276 @@
1
+ """Lean-format split/dividend factor-file backfill for equity tickers
2
+ missing a local factor file (development/Problems.md #91/#97/#99).
3
+
4
+ train.py::apply_split_adjustments() backward-adjusts each ticker's raw
5
+ OHLCV using data/equity/usa/factor_files/<ticker>.csv - the same
6
+ adjustment DataNormalizationMode.Adjusted already applies to main.py's
7
+ live/backtest data feed at runtime (Lean's default for
8
+ self.add_equity(ticker, self.resolution)). When that local file is
9
+ missing, load_factor_file() returns None and apply_split_adjustments()
10
+ silently no-ops - offline trains on RAW, unadjusted prices while live
11
+ never has this problem. Of 104 configured assets (77 equities), only 22
12
+ have a local factor file; the other 63 don't - this whole data/ tree is
13
+ gitignored, so this is a local/incomplete data pull, not a deliberate
14
+ scope decision anywhere in the code.
15
+
16
+ This module derives real Lean-format factor files for the missing
17
+ tickers from yfinance's own dividend/split history (dev-only dependency,
18
+ deferred import - mirrors dividend_backfill.py's/yfinance_backfill.py's
19
+ own convention), computing each row's CUMULATIVE backward-adjustment
20
+ factor the same way Lean's own factor files do (confirmed by reading a
21
+ real one, data/equity/usa/factor_files/aapl.csv, directly: earliest row
22
+ carries the largest cumulative adjustment, format is
23
+ date(YYYYMMDD),price_factor,split_factor,reference_price, no header,
24
+ terminal sentinel row 20501231,1,1,0).
25
+
26
+ No changes to train.py are needed or made here - load_factor_file()/
27
+ apply_split_adjustments() already do the right thing the moment a file
28
+ exists at the expected path; this module is purely a data-generation
29
+ task.
30
+
31
+ Same two-safety-boundary shape as dividend_backfill.py/fred_backfill.py/
32
+ yfinance_backfill.py:
33
+ 1. Writing factor files is gated by --apply (default: dry run only).
34
+ 2. Never touches config.json - this module's only output is
35
+ data/equity/usa/factor_files/*.csv, read back by train.py's own
36
+ dataset-build pipeline (`aq train --dataset-only`), not at Lean
37
+ runtime (main.py never reads these).
38
+
39
+ Usage:
40
+ python -m data_pipeline.factor_file_backfill [--tickers NVDA GE] [--apply]
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import json
47
+ import logging
48
+ from pathlib import Path
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ ROOT = Path(__file__).resolve().parents[1]
53
+ CONFIG_PATH = ROOT / "config.json"
54
+ FACTOR_FILES_DIR = ROOT / "data" / "equity" / "usa" / "factor_files"
55
+
56
+ TERMINAL_SENTINEL_ROW = {"factor_date": "20501231", "price_factor": 1.0, "split_factor": 1.0, "reference_price": 0.0}
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Pure helpers
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def configured_equity_tickers(config: dict) -> list[str]:
65
+ """Every phase1.universe.assets entry with security_type == "equity",
66
+ de-duplicated, order-preserving. Deliberately broader than
67
+ dividend_backfill.option_underlying_tickers() (which only resolves
68
+ option underlyings) - factor files are needed for every directly-
69
+ tradeable equity/ETF asset, including bond ETFs (SHY/IEF/TLT/AGG/...),
70
+ which also pay real distributions and are also security_type "equity".
71
+ Never raises on a malformed asset entry - missing keys are skipped."""
72
+ tickers: list[str] = []
73
+ seen: set[str] = set()
74
+ for asset in config.get("phase1", {}).get("universe", {}).get("assets", []):
75
+ if asset.get("security_type") != "equity":
76
+ continue
77
+ ticker = asset.get("ticker")
78
+ if ticker and ticker not in seen:
79
+ seen.add(ticker)
80
+ tickers.append(ticker)
81
+ return tickers
82
+
83
+
84
+ def tickers_missing_factor_file(tickers: list[str], factor_files_dir: Path = FACTOR_FILES_DIR) -> list[str]:
85
+ """Filters `tickers` down to those with no local
86
+ data/equity/usa/factor_files/<ticker-lower>.csv yet - the default CLI
87
+ scope, same "no flag = sane default" pattern as dividend_backfill.py's
88
+ --tickers default."""
89
+ return [ticker for ticker in tickers if not (factor_files_dir / f"{ticker.lower()}.csv").exists()]
90
+
91
+
92
+ def compute_lean_factor_rows(history) -> list[dict]:
93
+ """Pure. `history` is a pandas DataFrame with a DatetimeIndex plus
94
+ Close/Dividends/Stock Splits columns - fetch_corporate_actions()'s
95
+ exact return shape (matches yfinance's own Ticker.history(actions=True)
96
+ column names).
97
+
98
+ Walks real events (Dividends != 0 or Stock Splits != 0) newest-to-
99
+ oldest, accumulating:
100
+ - price_factor *= (1 - dividend_amount / close_on_prior_session)
101
+ per dividend event (a cash dividend doesn't change share count,
102
+ only the ex-date price drop needs correcting)
103
+ - split_factor *= 1 / split_ratio per split event
104
+ emitting one row per event date carrying the CUMULATIVE factor as of
105
+ that date - Lean's own convention (confirmed against the real
106
+ data/equity/usa/factor_files/aapl.csv: earliest row carries the
107
+ largest cumulative adjustment, converging to ~1.0 near the most
108
+ recent event). `reference_price` is the raw Close on that event's own
109
+ date (matches the real file's 4th column, an audit reference, never
110
+ consumed by the adjustment formula itself).
111
+
112
+ Returns [] when `history` has zero real dividend/split events - no
113
+ row is fabricated for a ticker that never needed adjusting.
114
+
115
+ Always appends TERMINAL_SENTINEL_ROW last (Lean's own far-future
116
+ sentinel, matching every real factor file's last row) - EXCEPT when
117
+ there are zero real events, in which case [] is returned instead of a
118
+ sentinel-only file (see this module's docstring / write_factor_file()
119
+ callers: a missing file and a sentinel-only file resolve identically
120
+ through train.py's merge_asof(direction="forward") + .fillna(1.0), so
121
+ skipping is lower-risk than writing a file that isn't needed)."""
122
+ events = []
123
+ for event_date, row in history.iterrows():
124
+ dividend = float(row.get("Dividends", 0.0) or 0.0)
125
+ split = float(row.get("Stock Splits", 0.0) or 0.0)
126
+ if dividend == 0.0 and split == 0.0:
127
+ continue
128
+ events.append(
129
+ {
130
+ "date": event_date,
131
+ "dividend": dividend,
132
+ "split_ratio": split,
133
+ "close": float(row["Close"]),
134
+ "prior_close": None,
135
+ }
136
+ )
137
+
138
+ if not events:
139
+ return []
140
+
141
+ close_series = history["Close"]
142
+ for event in events:
143
+ prior_close = close_series.loc[:event["date"]].iloc[:-1]
144
+ event["prior_close"] = float(prior_close.iloc[-1]) if not prior_close.empty else float(event["close"])
145
+
146
+ events.sort(key=lambda event: event["date"], reverse=True)
147
+
148
+ # Cumulative factors are computed walking newest-to-oldest (each older
149
+ # event's factor must already include every later event's adjustment),
150
+ # but the OUTPUT is written oldest-first with the terminal sentinel
151
+ # last - matching every real Lean factor file's own on-disk order
152
+ # (confirmed against data/equity/usa/factor_files/aapl.csv). Not
153
+ # strictly required for correctness (train.py::load_factor_file()
154
+ # re-sorts by date on read regardless), but keeps a generated file
155
+ # directly diffable against a real one.
156
+ rows: list[dict] = []
157
+ price_factor = 1.0
158
+ split_factor = 1.0
159
+ for event in events:
160
+ if event["dividend"] != 0.0 and event["prior_close"] > 0.0:
161
+ price_factor *= 1.0 - (event["dividend"] / event["prior_close"])
162
+ if event["split_ratio"] != 0.0:
163
+ split_factor *= 1.0 / event["split_ratio"]
164
+ event_date = event["date"]
165
+ rows.append(
166
+ {
167
+ "factor_date": event_date.strftime("%Y%m%d") if hasattr(event_date, "strftime") else str(event_date),
168
+ "price_factor": price_factor,
169
+ "split_factor": split_factor,
170
+ "reference_price": event["close"],
171
+ }
172
+ )
173
+
174
+ rows.reverse()
175
+ rows.append(dict(TERMINAL_SENTINEL_ROW))
176
+ return rows
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # The only function that imports yfinance - deferred, mirrors
181
+ # dividend_backfill.py's/yfinance_backfill.py's own convention.
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def fetch_corporate_actions(yahoo_symbol: str):
186
+ """Real historical Close/Dividends/Stock Splits via ONE yfinance call
187
+ (Ticker.history(period="max", auto_adjust=False, actions=True)) - the
188
+ raw close series and the corporate-action events come back aligned by
189
+ date in a single round-trip, so compute_lean_factor_rows() never needs
190
+ a second network call for "close on the session before the ex-date".
191
+ Returns None on any fetch failure (network, delisted, no history) -
192
+ never raises, same fail-open convention as
193
+ dividend_backfill.fetch_dividend_history()."""
194
+ try:
195
+ import yfinance as yf # deferred - dev-only dependency, never in requirements.txt
196
+
197
+ history = yf.Ticker(yahoo_symbol).history(period="max", auto_adjust=False, actions=True)
198
+ except Exception as exc:
199
+ logger.warning("fetch_corporate_actions(%s): fetch failed — %s", yahoo_symbol, exc)
200
+ return None
201
+
202
+ if history is None or history.empty:
203
+ logger.warning("fetch_corporate_actions(%s): no history returned", yahoo_symbol)
204
+ return None
205
+ return history
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Orchestration
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ def write_factor_file(output_dir: Path, ticker: str, rows: list[dict]) -> Path:
214
+ """Writes data/equity/usa/factor_files/<ticker-lower>.csv, no header,
215
+ Lean's exact column order (date,price_factor,split_factor,
216
+ reference_price) - matches train.py::load_factor_file()'s own read
217
+ shape (names=["factor_date", "price_factor", "split_factor",
218
+ "reference_price"]) column-for-column."""
219
+ output_dir.mkdir(parents=True, exist_ok=True)
220
+ output_path = output_dir / f"{ticker.lower()}.csv"
221
+ lines = [
222
+ f"{row['factor_date']},{row['price_factor']},{row['split_factor']},{row['reference_price']}"
223
+ for row in rows
224
+ ]
225
+ output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
226
+ return output_path
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # CLI
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ def main() -> None:
235
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s — %(message)s")
236
+ parser = argparse.ArgumentParser(
237
+ description="Aether Quant Lean factor-file backfill - offline/manual only, "
238
+ "no API key required, never run inside Lean or a Docker worker."
239
+ )
240
+ parser.add_argument(
241
+ "--tickers", nargs="*", default=None,
242
+ help="Restrict to these tickers (default: every configured equity ticker missing a local factor file)",
243
+ )
244
+ parser.add_argument("--apply", action="store_true", help="Actually write factor files (default: dry run, report only)")
245
+ parser.add_argument("--config-path", type=Path, default=CONFIG_PATH)
246
+ parser.add_argument("--output-dir", type=Path, default=FACTOR_FILES_DIR)
247
+ args = parser.parse_args()
248
+
249
+ with args.config_path.open("r", encoding="utf-8") as f:
250
+ config = json.load(f)
251
+
252
+ tickers = args.tickers if args.tickers else tickers_missing_factor_file(configured_equity_tickers(config), args.output_dir)
253
+
254
+ print(f"{'APPLY' if args.apply else 'DRY RUN'} — checking corporate-action history for {len(tickers)} ticker(s):\n")
255
+ for ticker in tickers:
256
+ history = fetch_corporate_actions(ticker)
257
+ if history is None:
258
+ print(f"- {ticker}: no_data_returned")
259
+ continue
260
+ rows = compute_lean_factor_rows(history)
261
+ if not rows:
262
+ print(f"- {ticker}: skipped_no_corporate_actions (identity fallback is already correct, no file needed)")
263
+ continue
264
+ if args.apply:
265
+ write_factor_file(args.output_dir, ticker, rows)
266
+ action = "written"
267
+ else:
268
+ action = "dry_run"
269
+ print(f"- {ticker}: {action}, factor_rows={len(rows) - 1} (+ terminal sentinel)")
270
+
271
+ if not args.apply:
272
+ print("\nDry run only — no factor files were written. Re-run with --apply to write them.")
273
+
274
+
275
+ if __name__ == "__main__":
276
+ main()