From 761725d2c8f73907777f968cffde9249ff0d300b Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Wed, 30 Sep 2026 20:25:20 +0800 Subject: [PATCH] feat: agent-reviewed overnight_recovery_agent_top10; guard monitor hot-reload overnight_recovery_agent_top10 (strategies/agent_review.py) re-ranks the overnight_recovery top 50 with an LLM agent's daily scores: final = quant percentile + 0.25 x score (-2..+2), vetoes for five event types, top 10 on the base strategy's rebalance rows and hysteresis. Score files are stamped by `agent_review.py submit` and steer a session only if stamped before its 16:00 New York close; without one the strategy holds exactly what overnight_recovery_top10 holds (checked on the live panel 2016-2026). - agent_review.py: candidates / submit / evaluate; skill .claude/skills/agent-stock-review drives the daily research. - US only, skipped by precompute (no history before its first score file); runs in the original book and in selected_2026_09 (from the book's start). - .gitignore tracks .claude/skills/ and ignores data/agent/. Monitor fix: hot-reload re-imports trader.py but not strategies/*. After the 2026-09-29 strategy commits the stale modules made every phase's panel refresh fail ("Unknown signal 'low_risk'") and US books skipped 09-29 until a restart. _reload_strategies now builds every factory and keeps the old registry when any fails; CLAUDE.md says to restart the monitor after changing strategy code. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/agent-stock-review/SKILL.md | 70 ++++++ .gitignore | 5 +- CLAUDE.md | 20 +- agent_review.py | 276 +++++++++++++++++++++ bridge.py | 7 + precompute_backtest.py | 4 +- strategies/agent_review.py | 194 +++++++++++++++ tests/test_agent_review.py | 76 ++++++ trader.py | 33 ++- 9 files changed, 676 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/agent-stock-review/SKILL.md create mode 100644 agent_review.py create mode 100644 strategies/agent_review.py create mode 100644 tests/test_agent_review.py diff --git a/.claude/skills/agent-stock-review/SKILL.md b/.claude/skills/agent-stock-review/SKILL.md new file mode 100644 index 0000000..f93cf6b --- /dev/null +++ b/.claude/skills/agent-stock-review/SKILL.md @@ -0,0 +1,70 @@ +--- +name: agent-stock-review +description: 每个美股交易日为 overnight_recovery_agent_top10 的 50 只量化候选做财报、基本面、产业逻辑和事件风险分析,打分并提交(agent_review.py)。用户说"跑今天的 agent 打分 / agent review / 给候选股打分"时使用;不用于研究回测或其他策略。 +--- + +# Agent 候选股打分 + +`overnight_recovery_agent_top10`(`strategies/agent_review.py`)每天用你的打分重排量化 top 50,持有 10 只。你的分数会直接进入 live 账本的决策,所以只写有证据的判断。 + +## 规则(2026-09-30 固定,不要改) + +- `final = quant_pct + 0.25 × score`。`quant_pct` 是量化排名在 50 只里的百分位(第 1 名 = 1,第 50 名 = 0);`score` 是整数 −2..+2,未打分按 0。 +- 持有 `final` 前 10;已持有的名字排在前 30 就不卖。每 10 个交易日调仓一次,和对照组 `overnight_recovery_top10` 同一天。 +- 否决(veto)的名字直接剔除;持仓被否决时当天卖出,由候选里最好的未持有名字补位。 +- 分数只能影响你提交之后的交易:文件必须在下一交易日 16:00(纽约时间)前提交,截止时间见候选包的 `deadline_utc`。 + +量化排名已经包含价格动量。你的分数要回答的是价格数据看不到的东西:财报和指引、基本面、需求链条逻辑、未来 1–2 个月的催化剂和事件风险。 + +## 步骤 + +1. 生成候选包:`uv run python agent_review.py candidates`。如果包里的 `as_of` 早于最近一个美股交易日(monitor 没刷新数据),加 `--refresh` 再跑一次。包在 `data/agent/us/candidates_.json`。 +2. 读包:`holdings`(策略和对照组的当前持仓)、`sessions_until_rebalance`(0 = 下一交易日按你的分数调仓;其他值 = 在那之前只有否决生效)、每只候选的 `previous`(上次的分数和摘要)、`customers_named` / `named_as_customer_by`(10-K 里的客户关系)。 +3. 分级处理,控制成本: + - 完整研究:当前持仓;排名前 30;新进候选;上次分数超过 5 个交易日;以及上次打分后发布了财报、8-K 或重大新闻的名字。 + - 其余名字:确认上次打分后没有新的申报文件或新闻,沿用上次分数,`summary` 写明"沿用 <日期>,无新信息"。 + - `sessions_until_rebalance > 0` 时,至少要完整检查所有持仓有没有否决事件。 +4. 每只做完整研究的股票,从一手来源取当前信息,并记下链接和日期: + - SEC EDGAR:最新 10-Q/10-K;8-K 的 Item 2.02 是业绩,1.01/2.01/8.01 是交易和重大事项。 + - 公司业绩新闻稿和电话会要点。 + - 有日期的可靠新闻。 + + 不要凭记忆写训练截止日之后的事实;你的记忆可能过时,一律以当前查到的为准。 +5. 按四个维度各写一两句: + - **财报**:最近一个季度的收入和 EPS 增速、利润率趋势、指引上调或下调、有没有超预期,下次财报日期(10 个交易日内要注明)。 + - **基本面**:增长能否持续、盈利能力、负债、自由现金流、稀释或回购,估值和自身历史及同行比。 + - **逻辑**:谁在为它的产品付钱,这份需求是在加速还是见顶;它在链条上处于哪一环(参考包里的客户关系);这波上涨有没有盈利预期上修支撑,还是主要靠资金;竞争威胁;未来 1–2 个月的催化剂。 + - **风险**:事件风险;是否属于下面的否决类别。 +6. 打分(和另外 49 只候选相比,看未来 2–8 周): + - `+2`:盈利预期或指引在上修,而且前面有价格还没反映的催化剂。 + - `+1`:基本面支持当前的动量。 + - `0`:没有优势、信息矛盾或信息不足。 + - `−1`:动量没有基本面支撑,增长放缓,或估值过高且预期在下修。 + - `−2`:逻辑在破坏:指引下调、需求转移,或会计上有疑点但还不够否决。 + + 大约一半应该是 0,±2 最多 8 只左右。不要按行业偏好打分;和上次相比改分要写原因。 +7. 否决只用于下面五类,并且必须有申报文件或新闻链接: + - `pending_cash_acquisition`:已签最终协议,以现金或固定价格被收购,股价被锁定。 + - `fraud_or_restatement`:财务重述、审计师辞任,或有监管介入的可信造假指控。 + - `regulatory_action`:DOJ、SEC、FTC 等的行动实质影响业务,或面临退市。 + - `large_equity_offering`:已宣布的增发或 ATM 超过总股本约 5%。 + - `binary_event`:10 个交易日内有 FDA 审批、判决之类结果二选一、股价可能大幅双向波动的事件。 +8. 写草稿 `data/agent/us/draft_.json`: + + ```json + {"as_of": "<包里的 as_of>", "model": "<你的模型 id>", + "scores": {"MU": {"score": 1, "veto": false, "veto_reason": null, "confidence": 0.6, + "earnings": "...", "fundamentals": "...", "logic": "...", "risks": "...", + "summary": "一两句结论", "sources": [{"url": "...", "date": "2026-09-24"}]}, + "...": {}}} + ``` + + 50 只候选必须全部出现,不能多也不能少。 +9. 提交:`uv run python agent_review.py submit data/agent/us/draft_.json`。被拒绝就按提示修改草稿后重交。不要改动已经存下的 `scores_*.json`,它们是 point-in-time 记录;重交会另存一份新文件,以最新的一份为准。 +10. 向用户汇报: + - 上调和下调各前 5 名及理由; + - 否决了哪些股票,证据是什么; + - 下次调仓前后预计和对照组持仓的差别; + - 是否赶上了截止时间。 + +隔几周跑一次 `uv run python agent_review.py evaluate`,看打分的 IC、否决的效果,以及本策略相对对照组的收益。样本少于约 50 个交易日时不要下结论。 diff --git a/.gitignore b/.gitignore index 1b70c1e..fc7c307 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -.claude +.claude/* +!.claude/skills/ .qoder/ # Python @@ -24,6 +25,8 @@ data/csi300_history.json # Trader state — per-machine, regenerated by auto/simulate data/trader_*.json data/books/ +# Agent candidate packs and score files (agent_review.py) — per-machine forward record +data/agent/ # Factor attribution output and cached factors data/attribution_*/ diff --git a/CLAUDE.md b/CLAUDE.md index 18ca88e..874389c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,11 @@ uv run python trader.py log --market us --strategy sim_recovery_mom_top10 --star uv run python trader.py simulate --integer-shares --fixed-fee 2.0 ... # Integer shares mode uv run python trader.py compare --market us --book selected_2026_09 # A named live book +# Agent-reviewed strategy overnight_recovery_agent_top10: daily loop (skill .claude/skills/agent-stock-review) +uv run python agent_review.py candidates # after the US close: top-50 pack -> data/agent/us/candidates_.json +uv run python agent_review.py submit data/agent/us/draft_.json # before 16:00 New York of the next session +uv run python agent_review.py evaluate # score IC, veto outcomes, book vs overnight_recovery_top10 + # Precompute the strategy page's backtests (writes data/precomputed/) uv run python precompute_backtest.py @@ -67,6 +72,14 @@ PYTHONPATH=. uv run python -m research.lab.fetch_lazy_prices && PYTHONPATH=. uv PYTHONPATH=. uv run --with numba python -m research.lab.earnings # earnings-announcement return (needs sec_8k_earnings.csv) PYTHONPATH=. uv run --with numba python -m research.lab.industry # industry momentum on SIC groups PYTHONPATH=. uv run python -m research.lab.fetch_us_mid holdings|prices|status # PIT S&P 400/600 from iShares holdings (research/lab/panel_mid.py) +PYTHONPATH=. uv run python -m research.lab.review_live_us # Apr-Sep 2026 replay review: attribution, AI-chain stages, missed winners +PYTHONPATH=. uv run python -m research.lab.fetch_supply_links [--universe mid] # 10-K customer/supplier/competitor mentions (sec_10k_links.csv) +PYTHONPATH=. uv run --with numba python -m research.lab.lead_lag --part all # economic-link momentum, lead-lag network, earnings spillover +PYTHONPATH=. uv run --with numba python -m research.lab.theme_propagation # does a hot industry's linked laggard catch up? +PYTHONPATH=. uv run --with numba python -m research.lab.lead_lag_small # the same links on S&P 400/600, 2019-26 +PYTHONPATH=. uv run python -m research.lab.sector_cycle # business-cycle rotation, sector momentum/reversal, semis cycle +uv run python -m research.lab.fetch_wiki_attention && PYTHONPATH=. uv run --with numba python -m research.lab.attention # Wikipedia-pageview attention +PYTHONPATH=. uv run --with numba python -m research.lab.concentration # industry caps on the momentum sleeve # Setup uv sync # Install/sync dependencies @@ -91,9 +104,9 @@ uv run python -m unittest discover tests **Backtest engine** (`main.py`): Orchestrates data loading, strategy execution, and visualization. `backtest()` has two modes. Close mode (no `open_data`, used by `main.py` and old research scripts) is vectorized and trades at the close the signal saw. Live mode (`open_data` given, used by the web UI through `backtest_runner.py`) keeps the book like `trader.py`: same signal timing (`strategy_positions`), holdings that drift, the $50/0.1% order floor, `trader.RESIZE_BAND`, fees on the orders placed (`simulate_live_book`). -**Daily trader** (`trader.py`): Live/forward-testing system with persistent portfolio state in `data/trader_{market}_{strategy}.json`. The `auto` subcommand runs both signal generation and execution in a single invocation — designed for cron. The `simulate` subcommand replays a date range day-by-day with realistic portfolio tracking (fractional shares, cash, commissions). Orders: whole A-share lots are allocated nearest-lot within cash (`_allocate_lots`); a held name is resized only when ≥ `RESIZE_BAND` (10%) off target. Available strategies: `recovery_mom_top10`, `recovery_mom_top20`, `momentum`, `momentum_quality`, `dual_momentum`, `inverse_vol`, `trend_following`, `buy_and_hold`, 32 factor-combo strategies (`fc_{signal}_{freq}` — see `strategies/factor_combo.py`), the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`, `lowrisk_top20`, and the US sleeve portfolios `us_three_sleeve` and `us_momentum_qqq_trend`. +**Daily trader** (`trader.py`): Live/forward-testing system with persistent portfolio state in `data/trader_{market}_{strategy}.json`. The `auto` subcommand runs both signal generation and execution in a single invocation — designed for cron. The `simulate` subcommand replays a date range day-by-day with realistic portfolio tracking (fractional shares, cash, commissions). Orders: whole A-share lots are allocated nearest-lot within cash (`_allocate_lots`); a held name is resized only when ≥ `RESIZE_BAND` (10%) off target. Available strategies: `recovery_mom_top10`, `recovery_mom_top20`, `momentum`, `momentum_quality`, `dual_momentum`, `inverse_vol`, `trend_following`, `buy_and_hold`, 32 factor-combo strategies (`fc_{signal}_{freq}` — see `strategies/factor_combo.py`), the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`, `lowrisk_top20`, the US sleeve portfolios `us_three_sleeve` and `us_momentum_qqq_trend`, and the agent-reviewed `overnight_recovery_agent_top10` (US only, forward only). -**Live books** (`trader.LIVE_BOOKS`): a book is a cohort of strategies, each with its own account, started on one date with one capital and a flat fee per order. The original book (every live strategy, `MONITOR_CAPITAL`, `MARKET_FEES`) keeps state in `data/trader_*.json`; a named book keeps it in `data/books//`. The monitor runs every book; `auto`/`status`/`log`/`compare`/`replay` and `bridge.py live` take `--book`, and `bridge.py books` lists them for the UI's book selector. `selected_2026_09` (29 strategies, US $10,000 / CN ¥100,000, $2 / ¥10 per order) starts on 2026-09-25 (US) and 2026-09-28 (CN). +**Live books** (`trader.LIVE_BOOKS`): a book is a cohort of strategies, each with its own account, started on one date with one capital and a flat fee per order. The original book (every live strategy, `MONITOR_CAPITAL`, `MARKET_FEES`) keeps state in `data/trader_*.json`; a named book keeps it in `data/books//`. The monitor runs every book; `auto`/`status`/`log`/`compare`/`replay` and `bridge.py live` take `--book`, and `bridge.py books` lists them for the UI's book selector. `selected_2026_09` (30 strategies, US $10,000 / CN ¥100,000, $2 / ¥10 per order) starts on 2026-09-25 (US) and 2026-09-28 (CN); `overnight_recovery_agent_top10` joined it on 2026-09-30 and starts with the book. **Strategy protocol** (`strategies/base.py`): All strategies inherit from `Strategy` ABC and implement `generate_signals(data) → DataFrame` where the returned DataFrame contains portfolio weights (rows = dates, columns = assets, values sum to ~1.0 per row). Each strategy is responsible for applying its own 1-day lag via `.shift(1)` to avoid lookahead bias — the backtest engine does not shift. A `BarStrategy` declares `fields` (close/open/high/low/volume) and receives `{field: DataFrame}`; live it uses completed bars only (row d from bars through d-1, traded at the close of d), while close-price strategies read the session's open as their latest price. @@ -111,6 +124,7 @@ uv run python -m unittest discover tests - `robust_momentum.py` — Bar strategies from the 2026-09 PIT research (report: https://claude.ai/artifact/Mo9nrzs5SWj5FfyshERyWS): US `overnight_recovery` (overnight-return momentum + distance from 126d low), CN `lowrisk_momentum` (amplitude-cut momentum + low beta + price-volume corr + momentum/vol) and close-only `lowbeta_sharpe_mom`. Top-N with rank hysteresis, rebalance every 10 days, weights drift between rebalances. `low_risk` (US `lowrisk_top20`: idiosyncratic vol + low beta, top 20, monthly) is the defensive sleeve of the 2026-09-29 US research (report: https://claude.ai/artifact/VGhZvKkS9UBYZkLeqGjcNC). - `sleeve_portfolio.py` — Several strategies in one account with fixed capital shares. `us_three_sleeve` = 1/3 each of `overnight_recovery_top10`, `lowrisk_top20`, `trend_rider_v7_vt24` (a mixed stock+ETF strategy); sleeve shares drift with their returns and reset at a month start once any is >25% off. 2011-2026 research: Sharpe 1.21 vs 0.88-1.03 for the sleeves alone. Research finding behind it: choosing factors by their recent record (rotation, factor momentum, last year's winner) did not beat a fixed composite; construction knobs (N, weighting, dynamic N) barely matter. On synthetic 3x ETFs from 1990 (`research/lab/letf_history.py`), TrendRider V7's 2016-2026 record does not hold: 1990-99 Sharpe 0.2-0.3, 2003-07 negative, 1990-2026 about 0.7 with MaxDD -58% (vt24) to -69% (vt36). - `qqq_trend.py` — `QQQVolTrend`: QQQ exposure = 1{QQQ > 200-day average} × min(1.5, 30% / EWMA vol), held as TQQQ (E/3) + SHY. 1990-2026 on synthetic ETFs: 16.0% vs QQQ 14.9%, MaxDD -37% vs -83%; 2011-2026 on real ETFs it trails QQQ (17.5% vs 19.1%) with a smaller drawdown. `us_momentum_qqq_trend` (in `sleeve_portfolio.py`) holds it half and half with `overnight_recovery_top10`. Research behind it (`research/lab/qqq_scan.py`, `beat_qqq.py`): measured as alpha against QQQ with a 2006-16 design / 2017-26 holdout split, momentum top 10 is the only stock selection whose alpha held (+10%/yr, t 1.4); single factors, walk-forward ML, a mega-cap universe, SEC insider trades, 10-K/10-Q text similarity, earnings-announcement returns, industry momentum and a PIT S&P 400/600 universe (`panel_mid.py`) showed none. In the web engine (2016-09 to 2026-09, $2/order) `us_momentum_qqq_trend` earns 21.0% vs QQQ 20.9%, MaxDD -32%. +- `agent_review.py` — `overnight_recovery_agent_top10`: overnight_recovery's top 50, re-ranked each day by an LLM agent's scores (−2..+2 from filings, fundamentals, demand-chain logic, event risk; `final = quant percentile + 0.25 × score`), vetoes for five event types, top 10 on the base strategy's rebalance rows and hysteresis. Score files in `data/agent/us/` are stamped by `agent_review.py submit` and steer a session only if stamped before its 16:00 New York close; without one the strategy holds exactly what `overnight_recovery_top10` holds, so the two books' difference measures the agent. `AGENT_REVIEW_STRATEGIES` in trader.py keeps it US-only and out of `precompute_backtest.py` (it has no history before its first score file). Research behind the design (2026-09-30): picking 10 of the quant top 50 at random earns 13.4%/yr vs 26.2% for the top 10 (2006-2026), and an LLM cannot be backtested before its training cutoff, so the record starts 2026-09-29. - `factor_combo.py` — Configurable factor-combination strategies with daily/weekly/biweekly/monthly rebalancing. US champions: `rec_mfilt+deep_upvol`, `ma200+mom7m+rec126`, `rec_mfilt+ma200`, `mom7m+rec126`. CN champions: `up_cap+quality_mom`, `down_resil+qual_mom`, `rec63+mom_gap`, `up_cap+mom_gap` — these were mined on today's CSI 300 members; on PIT members they earn ~3-7% a year (2016-2026). All registered in trader.py as `fc_{signal}_{freq}` (e.g., `fc_rec_mfilt_deep_upvol_monthly`). 32 new strategies total. **Metrics** (`metrics.py`): Standalone functions for portfolio analytics (Sharpe, Sortino, Calmar, max drawdown, etc.). `summary()` prints a formatted report and returns a dict. @@ -140,7 +154,7 @@ systemctl --user enable --now quant-trading-monitor quant-trading-api quant-trad journalctl --user -u quant-trading-monitor -f ``` -The monitor catches up on startup, so restarting it between phases is safe. If it starts mid-day (after open, before close), it falls back to `auto` mode for the evening phase. +The monitor catches up on startup, so restarting it between phases is safe. Its hot-reload re-imports trader.py only, not `strategies/`: after changing strategy code, restart the monitor (a registry whose strategies fail to build is rejected and logged with "restart the monitor"). If it starts mid-day (after open, before close), it falls back to `auto` mode for the evening phase. Compare live books after a while: diff --git a/agent_review.py b/agent_review.py new file mode 100644 index 0000000..381328f --- /dev/null +++ b/agent_review.py @@ -0,0 +1,276 @@ +""" +Daily loop of the agent-reviewed strategy ``overnight_recovery_agent_top10`` +(strategies/agent_review.py): build the candidate pack, store the agent's +scores with a point-in-time stamp, and measure the scores against what +happened. + + uv run python agent_review.py candidates [--refresh] # pack for the next session + uv run python agent_review.py submit data/agent/us/draft_.json + uv run python agent_review.py evaluate # IC, vetoes, book vs control + +Run ``candidates`` after the US close (the monitor's evening phase stores the +bars at 16:35 New York time); ``submit`` must finish before 16:00 New York +time of the next session for the scores to steer that session's close. +The skill .claude/skills/agent-stock-review drives the research step. +""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +import sys +from datetime import datetime, timezone + +import numpy as np +import pandas as pd + +from strategies import agent_review as ar + +NAME = "overnight_recovery_agent_top10" +CONTROL = "overnight_recovery_top10" +ROOT = os.path.dirname(os.path.abspath(__file__)) +LINKS = os.path.join(ROOT, "data", "research", "sec_10k_links.csv") +SECTORS = os.path.join(ROOT, "data", "us_sectors.csv") +SIC = [os.path.join(ROOT, "data", "research", f) for f in ("sec_sic.csv", "mid_sec_sic.csv")] # names of newer members + + +def _next_session(as_of: pd.Timestamp) -> pd.Timestamp: + return as_of + pd.offsets.BDay(1) # exchange holidays make the real one later + + +def _latest_scores_raw() -> dict | None: + files = sorted(glob.glob(os.path.join(ar.SCORE_DIR, "scores_*.json"))) + if not files: + return None + with open(files[-1]) as fh: + return json.load(fh) + + +def _links(tickers: list[str]) -> dict[str, dict[str, list[str]]]: + """Customers each candidate names in its latest 10-K, and the S&P 500 + filers that name it as a customer (research/lab/fetch_supply_links.py).""" + if not os.path.exists(LINKS): + return {} + d = pd.read_csv(LINKS, usecols=["filer", "filed", "target", "n_customer"]) + d = d[d["n_customer"] > 0] + latest = d.groupby("filer")["filed"].transform("max") + d = d[d["filed"] == latest] + out = {} + for t in tickers: + out[t] = {"customers_named": sorted(d.loc[d["filer"] == t, "target"].unique())[:8], + "named_as_customer_by": sorted(d.loc[d["target"] == t, "filer"].unique())[:8]} + return out + + +def cmd_candidates(args) -> None: + import trader + tickers, bench = trader.strategy_universe("us", NAME) + close, open_, close_raw = trader.load_strategy_prices("us", NAME, tickers, bench, with_open=True, + refresh=args.refresh) + tickers = trader.filter_tradable_tickers(close, tickers) + as_of = trader.completed_sessions(close_raw, "us", bench)[-1] + close, open_ = close.loc[:as_of], open_.loc[:as_of] + bars = trader.strategy_bars("us", NAME, tickers, close, open_) + strat = trader.STRATEGY_REGISTRY[NAME]() + cand = strat.candidates(bars) + + c = close[cand.index].ffill() + r = lambda k: (c.iloc[-1] / c.iloc[-1 - k] - 1).round(4) # noqa: E731 + vol = c.pct_change(fill_method=None).iloc[-63:].std() * np.sqrt(252) + off_high = c.iloc[-1] / c.iloc[-252:].max() - 1 + sec = pd.read_csv(SECTORS).set_index("ticker") if os.path.exists(SECTORS) else pd.DataFrame() + frames = [pd.read_csv(p) for p in SIC if os.path.exists(p)] + sic = (pd.concat(frames).drop_duplicates("ticker").set_index("ticker") if frames + else pd.DataFrame(columns=["name", "sicDescription"])) + held = {} + for label, name in (("agent_strategy", NAME), ("control", CONTROL)): + st = trader.load_state("us", name) + last = st.get("daily_log", [{}])[-1] if st.get("daily_log") else {} + eq = last.get("equity") or 1.0 + held[label] = {t: round(h["value"] / eq, 4) for t, h in last.get("holdings", {}).items()} + prev = _latest_scores_raw() + prev_rows = prev.get("scores", {}) if prev else {} + links = _links(list(cand.index)) + + rows = [] + for t, row in cand.iterrows(): + p = prev_rows.get(t) + rows.append({ + "ticker": t, "rank": int(row["rank"]), "quant_pct": round(float(row["quant_pct"]), 4), + "name": sec["Security"].get(t) if "Security" in sec and t in sec.index else sic["name"].get(t), + "sec_industry": sic["sicDescription"].get(t), + "sector": sec["GICS Sector"].get(t) if "GICS Sector" in sec else None, + "sub_industry": sec["GICS Sub-Industry"].get(t) if "GICS Sub-Industry" in sec else None, + "ret_5d": float(r(5)[t]), "ret_21d": float(r(21)[t]), "ret_63d": float(r(63)[t]), + "ret_252d": float(r(252)[t]), "vol_63d": round(float(vol[t]), 4), + "off_52w_high": round(float(off_high[t]), 4), + "overnight_mom_12m": float(row["overnight_mom_12m"]), "recovery_126d": float(row["recovery_126d"]), + "held_weight": {k: v.get(t, 0.0) for k, v in held.items()}, + **links.get(t, {}), + "previous": ({"as_of": prev["as_of"], "score": p.get("score"), "veto": p.get("veto", False), + "summary": p.get("summary", "")} if p else None), + }) + nxt = _next_session(as_of) + k = len(close) - 1 # pre-shift row of as_of: its weights trade at the next close + until = (-(k - strat.warmup)) % strat.rebal_freq + pack = { + "strategy": NAME, "as_of": str(as_of.date()), "next_session": str(nxt.date()), + "deadline_utc": str(ar.session_deadline(nxt)), + # 0: the next session rebalances on these scores; otherwise only vetoes act until then + "sessions_until_rebalance": int(until), + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "rule": {"agent_weight": ar.AGENT_WEIGHT, "score_range": list(ar.SCORE_RANGE), + "veto_reasons": list(ar.VETO_REASONS), "top_n": strat.top_n, "pool": strat.pool, + "rebal_freq": strat.rebal_freq}, + "holdings": held, + "candidates": rows, + } + os.makedirs(ar.SCORE_DIR, exist_ok=True) + path = os.path.join(ar.SCORE_DIR, f"candidates_{as_of.date()}.json") + with open(path, "w") as fh: + json.dump(pack, fh, indent=1, ensure_ascii=False, default=str) + print(f"{len(rows)} candidates for the session after {as_of.date()} -> {path}") + print(f"submit before {pack['deadline_utc']} (16:00 New York on {nxt.date()}) to steer that close; " + f"{'the next session rebalances' if until == 0 else f'next rebalance in {until} sessions (vetoes act daily)'}") + + +def validate(sub: dict, pack: dict) -> tuple[dict, list[str]]: + """Normalized scores and warnings; raises ValueError on a bad file.""" + want = [c["ticker"] for c in pack["candidates"]] + rows = sub.get("scores") + if not isinstance(rows, dict): + raise ValueError('"scores" must map ticker -> {score, veto, ...}') + missing, extra = sorted(set(want) - set(rows)), sorted(set(rows) - set(want)) + if missing or extra: + raise ValueError(f"tickers must match the {len(want)} candidates; missing {missing}, extra {extra}") + lo, hi = ar.SCORE_RANGE + out, warn = {}, [] + for t in want: + v = rows[t] + s = v.get("score") + if not isinstance(s, int) or isinstance(s, bool) or not lo <= s <= hi: + raise ValueError(f"{t}: score must be an integer in [{lo}, {hi}], got {s!r}") + veto = bool(v.get("veto", False)) + reason = v.get("veto_reason") + if veto and reason not in ar.VETO_REASONS: + raise ValueError(f"{t}: veto_reason must be one of {ar.VETO_REASONS}, got {reason!r}") + if veto and not str(v.get("risks", "")).strip(): + raise ValueError(f"{t}: a veto needs the evidence in 'risks'") + if not str(v.get("summary", "")).strip(): + raise ValueError(f"{t}: 'summary' is required") + out[t] = {"score": s, "veto": veto, "veto_reason": reason if veto else None, + "confidence": v.get("confidence"), + **{k: v.get(k, "") for k in ("earnings", "fundamentals", "logic", "risks", "summary")}, + "sources": v.get("sources", [])} + extreme = sum(abs(v["score"]) == hi for v in out.values()) + if extreme > 10: + warn.append(f"{extreme} names at +/-{hi}; the rubric expects at most about 10") + return out, warn + + +def cmd_submit(args) -> None: + with open(args.file) as fh: + sub = json.load(fh) + as_of = sub.get("as_of") + pack_path = os.path.join(ar.SCORE_DIR, f"candidates_{as_of}.json") + if not os.path.exists(pack_path): + sys.exit(f"no candidate pack for as_of={as_of!r} ({pack_path})") + with open(pack_path) as fh: + pack = json.load(fh) + try: + scores, warn = validate(sub, pack) + except ValueError as exc: + sys.exit(f"rejected: {exc}") + now = pd.Timestamp.now(tz="UTC") + deadline = pd.Timestamp(pack["deadline_utc"]) + out = {"strategy": NAME, "as_of": as_of, "created_at": now.isoformat(timespec="seconds"), + "model": sub.get("model", ""), "n_candidates": len(scores), "scores": scores} + path = os.path.join(ar.SCORE_DIR, f"scores_{as_of}_{now:%H%M%S}.json") + with open(path, "w") as fh: + json.dump(out, fh, indent=1, ensure_ascii=False) + for w in warn: + print(f"warning: {w}") + if now > deadline: + print(f"warning: stamped after the {pack['next_session']} deadline ({deadline}); " + f"these scores steer later sessions only") + ups = sorted((t for t, v in scores.items() if v["score"] > 0), key=lambda t: -scores[t]["score"]) + vetoes = [t for t, v in scores.items() if v["veto"]] + print(f"stored {path}: {len(ups)} raised, {sum(v['score'] < 0 for v in scores.values())} lowered, " + f"vetoes {vetoes or 'none'}") + + +def cmd_evaluate(args) -> None: + import trader + latest = {} # the last submission of each as_of + for p in sorted(glob.glob(os.path.join(ar.SCORE_DIR, "scores_*.json"))): + latest[os.path.basename(p)[7:17]] = p + files = [latest[k] for k in sorted(latest)] + if not files: + sys.exit("no score files yet") + close = pd.read_csv(os.path.join(ROOT, "data", "us.csv"), index_col=0, parse_dates=True).sort_index() + rows, vetoes = [], [] + for path in files: + with open(path) as fh: + d = json.load(fh) + with open(os.path.join(ar.SCORE_DIR, f"candidates_{d['as_of']}.json")) as fh: + pack = json.load(fh) + after = close.index[close.index > pd.Timestamp(d["as_of"])] + for h in (5, 10): + if len(after) <= h: + continue + s0, s1 = after[0], after[h] + q = {c["ticker"]: c["quant_pct"] for c in pack["candidates"]} + df = pd.DataFrame({"agent": {t: v["score"] for t, v in d["scores"].items()}, "quant": q}) + df["fwd"] = (close.loc[s1] / close.loc[s0] - 1).reindex(df.index) + df = df.dropna() + rows.append({"as_of": d["as_of"], "h": h, "n": len(df), + "ic_agent": df["agent"].rank().corr(df["fwd"].rank()), + "ic_quant": df["quant"].rank().corr(df["fwd"].rank()), + "top_minus_rest": df.loc[df["agent"] > 0, "fwd"].mean() - df.loc[df["agent"] <= 0, "fwd"].mean()}) + if h == 10: + for t, v in d["scores"].items(): + if v.get("veto") and t in df.index: + vetoes.append({"as_of": d["as_of"], "ticker": t, "reason": v["veto_reason"], + "fwd10_vs_candidates": df.at[t, "fwd"] - df["fwd"].mean()}) + R = pd.DataFrame(rows) + print(f"{len(files)} scored sessions ({min(latest)} .. {max(latest)})") + for h, g in (R.groupby("h") if len(R) else []): + def t(x): + x = x.dropna() + return x.mean() / x.std() * np.sqrt(len(x)) if len(x) > 2 and x.std() > 0 else float("nan") + print(f" {h:>2}-day: files {len(g)} | IC agent {g['ic_agent'].mean():+.3f} (t {t(g['ic_agent']):.2f}) " + f"| IC quant {g['ic_quant'].mean():+.3f} (t {t(g['ic_quant']):.2f}) " + f"| raised minus rest {g['top_minus_rest'].mean()*100:+.2f}% (overlapping windows inflate t)") + if vetoes: + V = pd.DataFrame(vetoes) + print(f" vetoes: {len(V)}, 10-day return vs candidate mean {V['fwd10_vs_candidates'].mean()*100:+.2f}% " + f"(negative = the veto helped)") + first = pd.Timestamp(json.load(open(files[0]))["as_of"]) + eq = {} + for name in (NAME, CONTROL): + e = pd.Series(trader.load_state("us", name).get("daily_equity", {}), dtype=float) + e.index = pd.to_datetime(e.index) + eq[name] = e.sort_index().loc[first:] + E = pd.DataFrame(eq).dropna() + if len(E) > 1: + ret = E.iloc[-1] / E.iloc[0] - 1 + print(f" books since {E.index[0].date()}: agent {ret[NAME]*100:+.2f}% vs control {ret[CONTROL]*100:+.2f}% " + f"({len(E)} sessions)") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("candidates", help="write the candidate pack for the next session") + p.add_argument("--refresh", action="store_true", help="download prices first (the monitor normally has)") + p = sub.add_parser("submit", help="validate and store an agent scores file") + p.add_argument("file") + sub.add_parser("evaluate", help="score IC, veto outcomes, and the book against its control") + args = ap.parse_args() + {"candidates": cmd_candidates, "submit": cmd_submit, "evaluate": cmd_evaluate}[args.cmd](args) + + +if __name__ == "__main__": + main() diff --git a/bridge.py b/bridge.py index 966f735..53adef7 100644 --- a/bridge.py +++ b/bridge.py @@ -472,6 +472,13 @@ STRATEGY_META = { "params": {}, "markets": ["us"], }, + # --- Agent-reviewed (live from 2026-09-29; no backtest of its own) --- + "overnight_recovery_agent_top10": { + "label": "Agent Review: Overnight Mom + Recovery Top 50 -> 10", + "category": "stock_picker", + "params": {}, + "markets": ["us"], + }, } diff --git a/precompute_backtest.py b/precompute_backtest.py index 9ca2e03..9457c26 100644 --- a/precompute_backtest.py +++ b/precompute_backtest.py @@ -33,7 +33,7 @@ from bridge import ( _build_strategy_result, _sanitize_for_json, ) -from trader import MONITOR_CAPITAL, STRATEGY_REGISTRY, strategy_supports_market +from trader import AGENT_REVIEW_STRATEGIES, MONITOR_CAPITAL, STRATEGY_REGISTRY, strategy_supports_market # Initial capital per market, in local currency — the same amounts the live @@ -160,6 +160,8 @@ def main(): us_strategies = [] cn_strategies = [] for name in STRATEGY_REGISTRY: + if name in AGENT_REVIEW_STRATEGIES: # forward only: no history before its first score file + continue meta = STRATEGY_META.get(name, {"markets": ["us", "cn"]}) markets = [m for m in meta.get("markets", ["us", "cn"]) if strategy_supports_market(name, m)] if "us" in markets: diff --git a/strategies/agent_review.py b/strategies/agent_review.py new file mode 100644 index 0000000..6b97dc0 --- /dev/null +++ b/strategies/agent_review.py @@ -0,0 +1,194 @@ +""" +Agent-reviewed momentum (US): the overnight_recovery score picks 50 +candidates, an LLM agent scores each one from its filings, fundamentals and +business logic, and a fixed blend of the two picks 10. + +Daily workflow (``agent_review.py`` at the repo root, skill +``.claude/skills/agent-stock-review``): + 1. ``agent_review.py candidates`` writes the candidate pack for the next + session to data/agent/us/candidates_.json; ``as_of`` is the last + completed session, i.e. the bars the next decision uses. + 2. The agent researches the candidates and writes a scores JSON. + 3. ``agent_review.py submit `` validates it and stores it as + data/agent/us/scores__.json with a ``created_at`` stamp. + Files are never overwritten, so a replay sees what was known at the time. + +Decision rule, fixed on 2026-09-30 before any forward data: + final = pct + AGENT_WEIGHT * agent_score + pct the name's place among the 50 candidates by the + overnight_recovery score, 1 for the best down to 0 + agent_score integer in [-2, 2]; 0 for a name the file does not score + Vetoed names are dropped. The top 10 by ``final`` are held, with the base + strategy's hysteresis (a held name stays while its final rank is below + top_n * (1 + buffer) = 30) and on the same rebalance rows as + overnight_recovery_top10 (every 10 rows). Between rebalances, a held name + vetoed in a fresh file (scored on the latest bars) is sold and its weight + goes to the best candidate not held. + +Point in time: a scores file may steer the weights traded at the close of +session s only if its as_of is before s, it is at most MAX_AGE_DAYS calendar +days old, and it was stamped before 16:00 New York time on s. Without such a +file the strategy uses the quant order alone and holds exactly what +overnight_recovery_top10 holds, so the difference between the two books +measures the agent. +""" + +from __future__ import annotations + +import glob +import json +import os +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd + +from strategies.base import BarStrategy +from strategies.robust_momentum import SIGNALS, overnight_momentum, recovery + +SCORE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "agent", "us") +AGENT_WEIGHT = 0.25 +MAX_AGE_DAYS = 7 +SCORE_RANGE = (-2, 2) +VETO_REASONS = ("pending_cash_acquisition", "fraud_or_restatement", "regulatory_action", + "large_equity_offering", "binary_event") +_NY = "America/New_York" + + +@dataclass +class AgentScores: + as_of: pd.Timestamp # last session the agent's data covered + created_at: pd.Timestamp # UTC stamp from ``submit`` + scores: dict[str, int] = field(default_factory=dict) + vetoes: frozenset[str] = frozenset() + + +def load_scores(score_dir: str = SCORE_DIR) -> list[AgentScores]: + out = [] + for path in sorted(glob.glob(os.path.join(score_dir, "scores_*.json"))): + with open(path) as fh: + d = json.load(fh) + rows = d.get("scores", {}) + out.append(AgentScores( + as_of=pd.Timestamp(d["as_of"]), + created_at=pd.Timestamp(d["created_at"]).tz_convert("UTC"), + scores={t: int(v.get("score", 0)) for t, v in rows.items()}, + vetoes=frozenset(t for t, v in rows.items() if v.get("veto")), + )) + return out + + +def session_deadline(session: pd.Timestamp) -> pd.Timestamp: + """16:00 New York time on ``session``, in UTC.""" + return (pd.Timestamp(session.date()).tz_localize(_NY) + pd.Timedelta(hours=16)).tz_convert("UTC") + + +def usable_scores(files: list[AgentScores], session: pd.Timestamp) -> AgentScores | None: + """The latest file that may steer the close of ``session``.""" + deadline = session_deadline(session) + ok = [f for f in files if f.as_of < session and f.created_at <= deadline + and (session - f.as_of).days <= MAX_AGE_DAYS] + return max(ok, key=lambda f: (f.as_of, f.created_at)) if ok else None + + +def blended_order(score_row: np.ndarray, pool: int, agent: AgentScores | None, + columns: list[str]) -> list[int]: + """Candidate column indices ordered by ``final`` (best first), vetoes removed.""" + valid = np.where(~np.isnan(score_row))[0] + cand = valid[np.argsort(-score_row[valid], kind="stable")][:pool] + k = len(cand) + pct = 1.0 - np.arange(k) / max(k - 1, 1) + if agent is None: + return list(cand) + final = np.array([pct[j] + AGENT_WEIGHT * agent.scores.get(columns[i], 0) for j, i in enumerate(cand)]) + order = [cand[j] for j in np.argsort(-final, kind="stable")] + return [i for i in order if columns[i] not in agent.vetoes] + + +class AgentReviewStrategy(BarStrategy): + def __init__(self, signal: str = "overnight_recovery", top_n: int = 10, pool: int = 50, + rebal_freq: int = 10, buffer: float = 2.0, warmup: int = 252, + score_dir: str = SCORE_DIR): + self.fields, self._score = SIGNALS[signal] + self.top_n, self.pool, self.rebal_freq = top_n, pool, rebal_freq + self.buffer, self.warmup, self.score_dir = buffer, warmup, score_dir + + def quant_score(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame: + """The base score, NaN for names without a price (or outside the index + when a backtest passes ``eligible``); row t uses bars through t.""" + close = bars["close"] + panel = {f: bars[f].reindex(index=close.index, columns=close.columns) for f in self.fields} + eligible = close.notna() + if "eligible" in bars: + eligible &= bars["eligible"].reindex_like(close).fillna(False).astype(bool) + return self._score(panel).where(eligible) + + def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame: + close = bars["close"] + S = self.quant_score(bars).to_numpy(dtype=float) + C = close.ffill().to_numpy(dtype=float) + dates, cols = close.index, list(close.columns) + files = load_scores(self.score_dir) + n, m = S.shape + W = np.zeros((n, m)) + w = np.zeros(m) + for k in range(self.warmup, n): + if w.any(): # drift with prices since the previous row + g = C[k] / C[k - 1] + w = w * np.where(np.isfinite(g), g, 1.0) + w = w / w.sum() if w.sum() > 0 else w + # weights of pre-shift row k trade at the close of the next session + session = dates[k + 1] if k + 1 < n else dates[k] + pd.offsets.BDay(1) + agent = usable_scores(files, session) if files else None + if (k - self.warmup) % self.rebal_freq == 0: + w = self._rebalance(S[k], w > 0, agent, cols) + elif agent is not None and agent.as_of == dates[k] and w.any(): + w = self._replace_vetoed(S[k], w, agent, cols) + W[k] = w + weights = pd.DataFrame(W, index=dates, columns=close.columns) + return weights.shift(1).fillna(0.0) + + def _rebalance(self, row, held, agent, cols) -> np.ndarray: + w = np.zeros(len(cols)) + if (~np.isnan(row)).sum() < self.top_n: + return w + order = blended_order(row, self.pool, agent, cols) + rank = {i: r for r, i in enumerate(order)} + keep = sorted((i for i in np.where(held)[0] if rank.get(i, np.inf) < self.top_n * (1 + self.buffer)), + key=lambda i: rank[i])[:self.top_n] + chosen = list(keep) + for i in order: + if len(chosen) >= self.top_n: + break + if i not in chosen: + chosen.append(i) + if chosen: + w[chosen] = 1.0 / len(chosen) + return w + + def _replace_vetoed(self, row, w, agent, cols) -> np.ndarray: + vetoed = [i for i in np.where(w > 0)[0] if cols[i] in agent.vetoes] + if not vetoed: + return w + w = w.copy() + spare = [i for i in blended_order(row, self.pool, agent, cols) if w[i] == 0] + for i in vetoed: + if spare: + w[spare.pop(0)] = w[i] + w[i] = 0.0 + return w / w.sum() if w.sum() > 0 else w + + def candidates(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame: + """The ``pool`` candidates on the latest bars (what the next session's + decision ranks), best first, with the base score and its factors.""" + close = bars["close"] + score = self.quant_score(bars).iloc[-1].dropna().sort_values(ascending=False, kind="stable") + top = score.head(self.pool) + on = overnight_momentum(bars["open"], close).iloc[-1] + rec = recovery(close).iloc[-1] + return pd.DataFrame({ + "rank": np.arange(1, len(top) + 1), + "quant_pct": 1.0 - np.arange(len(top)) / max(len(top) - 1, 1), + "overnight_mom_12m": on.reindex(top.index).round(4), + "recovery_126d": rec.reindex(top.index).round(4), + }, index=top.index) diff --git a/tests/test_agent_review.py b/tests/test_agent_review.py new file mode 100644 index 0000000..8718384 --- /dev/null +++ b/tests/test_agent_review.py @@ -0,0 +1,76 @@ +"""Contracts of the agent-reviewed strategy: without score files it holds +exactly what its base strategy holds, a scores file only steers sessions it +was stamped before, and a vetoed holding is replaced the same session.""" + +import json +import os +import tempfile +import unittest + +import numpy as np +import pandas as pd + +from strategies.agent_review import AgentReviewStrategy, session_deadline +from strategies.robust_momentum import RobustMomentumStrategy +from tests.test_robust_strategies import _bars + + +def _write(score_dir: str, as_of, created_at, scores: dict) -> None: + rows = {t: {"score": s, "veto": s == "veto", "summary": "x"} for t, s in scores.items()} + for v in rows.values(): + if v["veto"]: + v["score"] = 0 + path = os.path.join(score_dir, f"scores_{pd.Timestamp(as_of).date()}_{len(os.listdir(score_dir)):06d}.json") + with open(path, "w") as fh: + json.dump({"as_of": str(pd.Timestamp(as_of).date()), "created_at": pd.Timestamp(created_at).isoformat(), + "scores": rows}, fh) + + +class AgentReviewTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.bars = _bars(n_days=420, n_assets=40) + self.kw = dict(top_n=5, pool=20, rebal_freq=5, buffer=0.5, warmup=260) + + def tearDown(self): + self.tmp.cleanup() + + def _agent(self): + return AgentReviewStrategy(score_dir=self.dir, **self.kw).generate_signals(self.bars) + + def test_without_score_files_it_holds_what_the_base_strategy_holds(self): + base = RobustMomentumStrategy("overnight_recovery", top_n=5, rebal_freq=5, buffer=0.5, + warmup=260).generate_signals(self.bars) + pd.testing.assert_frame_equal(self._agent(), base, atol=1e-12) + + def test_scores_stamped_after_the_session_close_do_not_steer_it(self): + dates = self.bars["close"].index + k = 260 + 5 * 20 # a rebalance row; its weights trade at dates[k + 1] + before = self._agent() + worst = before.columns[before.iloc[k + 1] == 0][0] + late = session_deadline(dates[k + 1]) + pd.Timedelta(minutes=1) + _write(self.dir, dates[k], late, {worst: 2, **{t: -2 for t in before.columns[before.iloc[k + 1] > 0]}}) + pd.testing.assert_frame_equal(self._agent(), before, atol=1e-12) + _write(self.dir, dates[k], session_deadline(dates[k + 1]) - pd.Timedelta(hours=1), + {t: -2 for t in before.columns[before.iloc[k + 1] > 0]}) + after = self._agent() + pd.testing.assert_frame_equal(after.iloc[:k + 1], before.iloc[:k + 1], atol=1e-12) + self.assertNotEqual(set(after.columns[after.iloc[k + 1] > 0]), set(before.columns[before.iloc[k + 1] > 0])) + + def test_a_vetoed_holding_is_replaced_between_rebalances(self): + dates = self.bars["close"].index + k = 260 + 5 * 20 + 2 # not a rebalance row + before = self._agent() + held = list(before.columns[before.iloc[k + 1] > 0]) + _write(self.dir, dates[k], session_deadline(dates[k + 1]) - pd.Timedelta(hours=2), {held[0]: "veto"}) + after = self._agent() + now = set(after.columns[after.iloc[k + 1] > 0]) + self.assertNotIn(held[0], now) + self.assertEqual(len(now), len(held)) + self.assertAlmostEqual(float(after.iloc[k + 1].sum()), 1.0, places=9) + self.assertTrue(np.allclose(after.iloc[:k + 1], before.iloc[:k + 1])) + + +if __name__ == "__main__": + unittest.main() diff --git a/trader.py b/trader.py index d4dd59e..c590fa4 100644 --- a/trader.py +++ b/trader.py @@ -42,6 +42,7 @@ import yfinance as yf import data_manager import universe_history from strategies.base import BarStrategy +from strategies.agent_review import AgentReviewStrategy from strategies.buy_and_hold import BuyAndHoldStrategy from strategies.dual_momentum import DualMomentumStrategy from strategies.factor_combo import FactorComboStrategy @@ -129,7 +130,9 @@ LIVE_BOOKS: dict[str, dict] = { # strategies from the 2026-09 PIT research, and the trend_rider ETF # strategies (lost 7-12% in Apr-Sep, 10-year Sharpe 1.0-1.4): v7_vt24 # and v5_vt30 (best 10-year Sharpe of their version) and v7_vt36 (the - # V7+VT36 champion). + # V7+VT36 champion). overnight_recovery_agent_top10 was added on + # 2026-09-30 at the user's request; it starts with the book, and until + # its first agent score file it holds what overnight_recovery_top10 holds. "strategies": { "us": [ "fc_up_cap_quality_mom_biweekly", "fc_up_cap_quality_mom_weekly", @@ -141,7 +144,7 @@ LIVE_BOOKS: dict[str, dict] = { "fc_ma200_mom7m_rec126_monthly", "fc_ma200_mom7m_rec126_biweekly", "fc_rec_mfilt_ma200_monthly", "fc_rec_mfilt_ma200_biweekly", "hybrid_alpha_top10", "recovery_mom_top10", - "overnight_recovery_top10", + "overnight_recovery_top10", "overnight_recovery_agent_top10", "trend_rider_v7_vt24", "trend_rider_v7_vt36", "trend_rider_v5_vt30", ], "cn": [ @@ -301,6 +304,9 @@ STRATEGY_REGISTRY = { "us_three_sleeve": lambda **kw: us_three_sleeve(), # half overnight_recovery_top10, half QQQ 200-day trend with a 30% vol target (TQQQ + SHY) "us_momentum_qqq_trend": lambda **kw: us_momentum_qqq_trend(), + # overnight_recovery's top 50, re-ranked with an LLM agent's daily scores + # (strategies/agent_review.py, agent_review.py); live from 2026-09-29, no backtest of its own + "overnight_recovery_agent_top10": lambda **kw: AgentReviewStrategy(), # --- TrendRiderV6: stock-picking + V5 regime engine --- "trend_rider_v6": lambda **kw: TrendRiderV6(), "trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10), @@ -334,6 +340,11 @@ MIXED_STRATEGY_EXTRA_TICKERS = { "us_momentum_qqq_trend": MOMENTUM_QQQ_TREND_ETFS, } +# Strategies steered by agent score files (data/agent/us/). US only; before +# their first score file they hold what their base strategy holds, so they +# have no backtest of their own and precompute_backtest.py skips them. +AGENT_REVIEW_STRATEGIES = {"overnight_recovery_agent_top10"} + # Registered strategies the live monitor does not run, with the reason. LIVE_EXCLUDED_STRATEGIES = { "buy_and_hold": "equal weight across ~500 names is not investable with whole " @@ -359,7 +370,8 @@ def strategy_fields(strategy_name: str) -> tuple[str, ...]: def strategy_supports_market(strategy_name: str, market: str) -> bool: """ETF-based strategies trade USD-listed ETFs, so they only run in the US book.""" base_name = strategy_name.removeprefix("sim_") - if base_name in ETF_STRATEGY_UNIVERSES or base_name in MIXED_STRATEGY_EXTRA_TICKERS: + if base_name in ETF_STRATEGY_UNIVERSES or base_name in MIXED_STRATEGY_EXTRA_TICKERS \ + or base_name in AGENT_REVIEW_STRATEGIES: return market == "us" return True @@ -1770,10 +1782,23 @@ def cmd_monitor(args): return min(all_candidates, key=lambda x: x[0]) def _reload_strategies(): - """Re-read STRATEGY_REGISTRY to pick up new strategies without restart.""" + """Re-read STRATEGY_REGISTRY to pick up new strategies without restart. + + Only trader.py is reloaded; strategies/* modules stay as first + imported. A registry whose entries need newer strategy code would + fail at every phase (2026-09-29: one stale signal name broke the panel + refresh for every book), so every factory is built first and the old + registry is kept if any fails; changed strategy code needs a restart. + """ import importlib import trader as _self_mod importlib.reload(_self_mod) + for name, factory in _self_mod.STRATEGY_REGISTRY.items(): + try: + factory() + except Exception as exc: + raise RuntimeError(f"{name} needs strategy code this process has not loaded " + f"({exc}); restart the monitor") from exc current = list(_self_mod.DEFAULT_MONITOR_STRATEGIES) return current, _self_mod.STRATEGY_REGISTRY