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:
70
.claude/skills/agent-stock-review/SKILL.md
Normal file
70
.claude/skills/agent-stock-review/SKILL.md
Normal 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
5
.gitignore
vendored
@@ -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_*/
|
||||
|
||||
20
CLAUDE.md
20
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_<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
276
agent_review.py
Normal 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()
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
194
strategies/agent_review.py
Normal 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)
|
||||
76
tests/test_agent_review.py
Normal file
76
tests/test_agent_review.py
Normal 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()
|
||||
33
trader.py
33
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user