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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 20:25:20 +08:00
parent ab3acfe4cb
commit 761725d2c8
9 changed files with 676 additions and 9 deletions

View File

@@ -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_<as_of>.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_<as_of>.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_<as_of>.json`。被拒绝就按提示修改草稿后重交。不要改动已经存下的 `scores_*.json`,它们是 point-in-time 记录;重交会另存一份新文件,以最新的一份为准。
10. 向用户汇报:
- 上调和下调各前 5 名及理由;
- 否决了哪些股票,证据是什么;
- 下次调仓前后预计和对照组持仓的差别;
- 是否赶上了截止时间。
隔几周跑一次 `uv run python agent_review.py evaluate`,看打分的 IC、否决的效果,以及本策略相对对照组的收益。样本少于约 50 个交易日时不要下结论。

5
.gitignore vendored
View File

@@ -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_*/

View File

@@ -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_<as_of>.json
uv run python agent_review.py submit data/agent/us/draft_<as_of>.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/<book>/`. 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/<book>/`. 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:

276
agent_review.py Normal file
View File

@@ -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_<as_of>.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()

View File

@@ -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"],
},
}

View File

@@ -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:

194
strategies/agent_review.py Normal file
View File

@@ -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_<as_of>.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 <file>`` validates it and stores it as
data/agent/us/scores_<as_of>_<HHMMSS>.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)

View File

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

View File

@@ -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