aether-quant 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aether_quant-0.0.1.dist-info/METADATA +7 -0
- aether_quant-0.0.1.dist-info/RECORD +99 -0
- aether_quant-0.0.1.dist-info/WHEEL +5 -0
- aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
- aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
- aether_quant-0.0.1.dist-info/top_level.txt +13 -0
- analyzer/README.md +69 -0
- analyzer/__init__.py +17 -0
- analyzer/market_analyzer.py +521 -0
- aq_cli.py +3633 -0
- audit/README.md +47 -0
- audit/__init__.py +38 -0
- audit/hash_chain.py +52 -0
- audit/postgres_audit.py +61 -0
- audit/postgres_worker.py +402 -0
- audit/redis_queue.py +124 -0
- audit/status_export.py +62 -0
- data_pipeline/README.md +257 -0
- data_pipeline/__init__.py +6 -0
- data_pipeline/bar_synthesis.py +97 -0
- data_pipeline/dividend_backfill.py +243 -0
- data_pipeline/factor_file_backfill.py +276 -0
- data_pipeline/fetch.py +206 -0
- data_pipeline/fred_backfill.py +435 -0
- data_pipeline/ib_backfill.py +287 -0
- data_pipeline/v2_manifest.py +75 -0
- data_pipeline/yfinance_backfill.py +501 -0
- evaluation/README.md +152 -0
- evaluation/__init__.py +73 -0
- evaluation/ablation.py +310 -0
- evaluation/benchmark_comparison.py +195 -0
- evaluation/confidence_threshold_calibration.py +157 -0
- evaluation/factor_neutralization_check.py +94 -0
- evaluation/feature_parity.py +321 -0
- evaluation/feature_reconciliation.py +286 -0
- evaluation/impact_model.py +77 -0
- evaluation/kill_switch_replay.py +186 -0
- evaluation/limit_fill_simulator.py +152 -0
- evaluation/model_predictions.py +192 -0
- evaluation/monte_carlo.py +161 -0
- evaluation/public_benchmarks.py +117 -0
- evaluation/rank_book_simulator.py +494 -0
- evaluation/rank_ic_core.py +204 -0
- evaluation/rank_signal_calibration.py +690 -0
- evaluation/rolling_ic_gate_calibration.py +190 -0
- evaluation/rolling_ic_gate_replay.py +138 -0
- execution/README.md +281 -0
- execution/__init__.py +80 -0
- execution/config_cache.py +48 -0
- execution/cost_model.py +213 -0
- execution/lean_config_render.py +131 -0
- execution/live_credentials.py +38 -0
- execution/live_credentials_io.py +52 -0
- execution/order_gate.py +388 -0
- execution/paper_readiness.py +181 -0
- execution/paper_readiness_io.py +54 -0
- execution/paper_readiness_report.py +103 -0
- execution/paper_readiness_scheduler.py +137 -0
- execution/reconciliation.py +160 -0
- execution/runtime_config_io.py +38 -0
- execution/secret_scan.py +73 -0
- features/README.md +77 -0
- features/__init__.py +137 -0
- features/alt_data_features.py +154 -0
- features/bond_features.py +275 -0
- features/cross_asset_sensitivity.py +79 -0
- features/derivatives_macro_features.py +72 -0
- features/macro_features.py +79 -0
- features/options_greeks.py +329 -0
- features/sector_map.py +53 -0
- features/technical_indicators.py +185 -0
- generate_backtest_report.py +311 -0
- generate_evaluation_report.py +712 -0
- inference/README.md +105 -0
- inference/__init__.py +36 -0
- inference/exported_model.py +929 -0
- inference/parallel_inference.py +160 -0
- inference/strategy_selector_inference.py +75 -0
- json_safety.py +130 -0
- portfolio/README.md +447 -0
- portfolio/__init__.py +41 -0
- portfolio/book_construction.py +786 -0
- portfolio/book_neutrality.py +178 -0
- portfolio/hrp_allocation.py +159 -0
- portfolio/options_arbitrage_detector.py +232 -0
- portfolio/options_assignment_risk.py +119 -0
- portfolio/options_margin_sizing.py +333 -0
- portfolio/options_strategy.py +1999 -0
- portfolio/rank_signal.py +219 -0
- portfolio/rolling_ic_gate.py +183 -0
- risk/README.md +275 -0
- risk/__init__.py +38 -0
- risk/asset_class_router.py +584 -0
- risk/forex_risk.py +158 -0
- risk/futures_risk.py +202 -0
- risk/kill_switch.py +261 -0
- risk/manual_override.py +118 -0
- risk/position_sizing.py +303 -0
- risk/rl_sizing.py +189 -0
|
@@ -0,0 +1,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()
|