Compare commits

...

4 Commits

Author SHA1 Message Date
38a55e7882 feat(books): drop trend_rider_v3_vt24 and v4 from selected_2026_09
v3_vt24 held the same position as v7_vt24. 29 strategies remain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:26:42 +08:00
465189ba9a feat(books): selected_2026_09 adds trend_rider, $2/¥10 per order, CN ¥100k
Adds five trend_rider ETF strategies (best 10-year Sharpe of each version,
plus v7_vt36): 31 strategies in all. Fees per order are $2 and ¥10, and CN
accounts start with ¥100,000. No trade has been executed yet (US accounts
hold only this morning's orders; CN starts 2026-09-28).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 22:13:49 +08:00
5dbabc3e08 feat: named live books; start selected_2026_09 (26 strategies)
A live book is a cohort of strategies, each with its own account, started
on one date with one capital and a flat fee per order (trader.LIVE_BOOKS).
The original book keeps its state in data/; a named book in
data/books/<book>/. The monitor runs every book; auto/status/log/compare/
replay take --book. A fresh account opens on the book's first session.

selected_2026_09: US $10,000 and $1 per order from 2026-09-25, CN ¥50,000
and ¥5 per order from 2026-09-28 (the first CN session after the choice).
Strategies beat their benchmark in the Apr-Sep 2026 point-in-time replay
and passed a 10-year PIT backtest (US Sharpe >= 0.6, MaxDD > -50%; CN
Sharpe >= 0.2, CAGR > 0), no daily variants, at most two frequencies per
factor signal, plus the three 2026-09 research strategies.

bridge.py live --book and bridge.py books feed a book selector on the
live page (/api/strategies/live?book=, /api/strategies/live/books).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 20:46:09 +08:00
905ca4fe05 fix(trader): replay and catch-up trade each day's point-in-time index members
Replaying live books over past sessions used today's constituent list for
every day, so US books bought names (BE, MRVL, ILMN, FLEX, P) before they
joined the S&P 500. session_universes/session_signals now compute each past
session on that day's members (S&P change log, CSI 300 snapshots); the
latest session keeps the live list. Used by auto/monitor catch-up, replay
and simulate. _run_session takes precomputed targets only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 20:29:39 +08:00
9 changed files with 415 additions and 69 deletions

View File

@@ -36,6 +36,7 @@ uv run python trader.py status --market us --strategy recovery_mom_top10 # Por
uv run python trader.py simulate --market us --strategy recovery_mom_top10 --start 2026-01-01 --end 2026-04-01 # Historical replay
uv run python trader.py log --market us --strategy sim_recovery_mom_top10 --start 2026-03-01 # View daily log
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
# Precompute the strategy page's backtests (writes data/precomputed/)
uv run python precompute_backtest.py
@@ -72,6 +73,8 @@ uv run python -m unittest discover tests
**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`), and the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`.
**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).
**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.
**Strategies**:

View File

@@ -23,9 +23,13 @@ import metrics
from backtest_runner import benchmark_curve, run_market
from research.permanent_yearly import equity_with_cashflows
from trader import (
LIVE_BOOKS,
LIVE_EXCLUDED_STRATEGIES,
MARKET_FEE_TIERED,
MONITOR_CAPITAL,
STRATEGY_REGISTRY,
book_dir,
book_strategies,
convert_quote_currency,
strategy_data_market,
strategy_supports_market,
@@ -863,8 +867,10 @@ def cmd_live(args: argparse.Namespace) -> None:
"""
market = args.market
prefix = f"trader_{market}_"
state_files = sorted(glob(os.path.join("data", f"{prefix}*.json")))
book = args.book or None
state_files = sorted(glob(os.path.join(book_dir(book), f"{prefix}*.json")))
wanted = args.strategy
in_book = set(book_strategies(book, market)) if book else None
panels = _load_panels({market, "etfs"})
bench_ticker, bench_label = LIVE_BENCHMARK[market]
@@ -885,6 +891,9 @@ def cmd_live(args: argparse.Namespace) -> None:
if strategy_name in LIVE_EXCLUDED_STRATEGIES or not strategy_supports_market(strategy_name, market):
_log(f" Skipping {strategy_name}: not a live strategy in the {market.upper()} book")
continue
if in_book is not None and strategy_name not in in_book:
_log(f" Skipping {strategy_name}: no longer in book {book}")
continue
try:
with open(path) as f:
@@ -1018,6 +1027,18 @@ def cmd_live(args: argparse.Namespace) -> None:
json.dump(_sanitize_for_json(results), sys.stdout, default=str)
def cmd_books() -> None:
"""Live books: the original book first, then the named ones (newest last)."""
books = [{"id": "", "label": "全部策略(2026-04 起回放)", "startDate": None,
"capital": MONITOR_CAPITAL, "feePerOrder": None,
"strategies": {m: book_strategies(None, m) for m in ("us", "cn")}}]
for book_id, cfg in LIVE_BOOKS.items():
books.append({"id": book_id, "label": cfg["label"], "startDate": cfg["start"],
"capital": cfg["capital"], "feePerOrder": cfg["fee_per_order"],
"strategies": {m: book_strategies(book_id, m) for m in ("us", "cn")}})
json.dump(books, sys.stdout)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -1043,6 +1064,11 @@ def main() -> None:
live_parser = subparsers.add_parser("live", help="Read live trader states")
live_parser.add_argument("--market", default="us", choices=["us", "cn"])
live_parser.add_argument("--strategy", default=None, help="Filter to specific strategy")
live_parser.add_argument("--book", default="", choices=["", *LIVE_BOOKS],
help="Named live book (default: the original book)")
# books
subparsers.add_parser("books", help="List live books")
args = parser.parse_args()
@@ -1052,6 +1078,8 @@ def main() -> None:
cmd_backtest(args)
elif args.command == "live":
cmd_live(args)
elif args.command == "books":
cmd_books()
else:
parser.print_help(sys.stderr)
sys.exit(1)

View File

@@ -3,6 +3,8 @@ order sizing, and how results are reported."""
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import mock
import numpy as np
import pandas as pd
@@ -61,6 +63,47 @@ class PanelRefreshTests(unittest.TestCase):
self.assertEqual(len(cleaned), 4)
class LiveBookTests(unittest.TestCase):
BOOK = {"label": "test", "start": {"us": "2026-09-16"}, "capital": {"us": 5_000.0},
"fee_per_order": {"us": 1.0}, "strategies": {"us": ["momentum"]}}
def _run(self, state: dict):
close = _panel({"AAA": [10, 11, 12, 13, 14], "BBB": [20, 20, 21, 21, 22],
"SPY": [1, 1, 1, 1, 1]})
args = SimpleNamespace(market="us", strategy="momentum", book="test", capital=None,
tx_cost=0.001, fixed_fee=2.0, integer_shares=True)
equal = pd.DataFrame(0.5, index=close.index, columns=["AAA", "BBB"])
with mock.patch.dict(trader.LIVE_BOOKS, {"test": self.BOOK}), \
mock.patch.object(trader, "strategy_universe", lambda m, n: (["AAA", "BBB"], "SPY")), \
mock.patch.object(trader, "pit_candidates", lambda m, n, s: []), \
mock.patch.object(trader, "load_strategy_prices", lambda *a, **k: (close, close, close)), \
mock.patch.object(trader, "session_signals", lambda m, n, t, days, c, o: equal.loc[days]):
return trader._process_sessions(args, state, refresh=False)
def test_a_new_account_starts_on_the_book_date_with_its_capital_and_flat_fee(self):
state = self._run({})
self.assertEqual(min(state["daily_equity"]), "2026-09-16")
self.assertEqual(state["initial_capital"], 5_000.0)
self.assertTrue(state["trade_log"])
self.assertTrue(all(t["commission"] == 1.0 for t in state["trade_log"]))
def test_no_account_is_opened_before_the_book_starts(self):
with mock.patch.dict(self.BOOK, {"start": {"us": "2026-10-01"}}):
self.assertIsNone(self._run({}))
def test_book_strategies_are_live_strategies_of_their_market(self):
for book, cfg in trader.LIVE_BOOKS.items():
for market, names in cfg["strategies"].items():
for name in names:
self.assertIn(name, trader.STRATEGY_REGISTRY, book)
self.assertNotIn(name, trader.LIVE_EXCLUDED_STRATEGIES, book)
self.assertTrue(trader.strategy_supports_market(name, market), (book, name))
self.assertEqual(len(names), len(set(names)), book)
pd.Timestamp(cfg["start"][market])
self.assertGreater(cfg["capital"][market], 0)
self.assertGreaterEqual(cfg["fee_per_order"][market], 0)
class SessionAndOrderTests(unittest.TestCase):
def test_todays_bar_is_not_a_close_until_the_session_settles(self):
raw = _panel({"SPY": [1, 1, 1]}, start="2026-09-22")

View File

@@ -174,6 +174,23 @@ class MembershipTests(unittest.TestCase):
# Never-member columns (benchmarks) are not masked.
self.assertTrue(mask["510300.SS"].all())
def test_past_sessions_never_hold_a_name_before_it_joined_the_index(self):
bars = _bars()
bars["close"]["S00"] *= np.exp(np.linspace(0, 3, len(bars["close"]))) # the best momentum
bars["open"]["S00"] = bars["close"]["S00"]
days = bars["close"].index[-5:]
others = [c for c in bars["close"].columns if c != "S00"]
members = pd.DataFrame(True, index=days[:-1], columns=bars["close"].columns)
members["S00"] = False # joins the index on the last day
with mock.patch.object(trader, "index_members", lambda m, d: members.loc[d]):
runs = trader.session_universes("us", "momentum", others + ["S00"], days)
weights = trader.session_signals("us", "momentum", others + ["S00"], days,
bars["close"], bars["open"])
self.assertEqual([len(r) for _, r in runs], [4, 1])
self.assertNotIn("S00", runs[0][0])
self.assertTrue((weights.loc[days[:-1], "S00"] == 0).all())
self.assertGreater(weights.loc[days[-1], "S00"], 0)
def test_changes_table_with_two_level_headers_is_found(self):
cols = pd.MultiIndex.from_tuples([("Effective Date", "Effective Date"), ("Added", "Ticker"),
("Removed", "Ticker"), ("Reason", "Reason")])

284
trader.py
View File

@@ -40,6 +40,7 @@ import pandas as pd
import yfinance as yf
import data_manager
import universe_history
from strategies.base import BarStrategy
from strategies.buy_and_hold import BuyAndHoldStrategy
from strategies.dual_momentum import DualMomentumStrategy
@@ -106,6 +107,51 @@ RESIZE_BAND = 0.10
# Default live capital per market, in the market's own currency.
MONITOR_CAPITAL = {"us": 10_000.0, "cn": 70_000.0}
# Named live books. A book is a cohort of strategies, each trading its own
# account from the book's start date with the book's capital and a flat fee per
# order (no proportional cost). The original book (every registered strategy,
# MONITOR_CAPITAL, MARKET_FEES) keeps its state files in data/; a named book
# keeps them in data/books/<name>/. The monitor runs every book.
LIVE_BOOKS: dict[str, dict] = {
"selected_2026_09": {
"label": "精选组合(2026-09-25 起)",
# CN starts with the first session after the strategies were chosen
# (the choice was made after the CN close of 2026-09-25).
"start": {"us": "2026-09-25", "cn": "2026-09-28"},
"capital": {"us": 10_000.0, "cn": 100_000.0},
"fee_per_order": {"us": 2.0, "cn": 10.0},
# Chosen on 2026-09-25: beat the benchmark in the Apr-Sep 2026 replay
# (point-in-time members) AND passed a 10-year PIT backtest (US:
# Sharpe >= 0.6, MaxDD > -50%; CN: Sharpe >= 0.2, CAGR > 0); no daily
# variants; at most two frequencies per factor signal. Plus the three
# 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).
"strategies": {
"us": [
"fc_up_cap_quality_mom_biweekly", "fc_up_cap_quality_mom_weekly",
"fc_up_cap_mom_gap_biweekly", "fc_up_cap_mom_gap_weekly",
"fc_rec_mfilt_deep_upvol_monthly", "fc_rec_mfilt_deep_upvol_weekly",
"fc_down_resil_qual_mom_monthly", "fc_down_resil_qual_mom_biweekly",
"fc_mom7m_rec126_monthly", "fc_mom7m_rec126_biweekly",
"fc_rec63_mom_gap_biweekly", "fc_rec63_mom_gap_monthly",
"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",
"trend_rider_v7_vt24", "trend_rider_v7_vt36", "trend_rider_v5_vt30",
],
"cn": [
"fc_up_cap_mom_gap_monthly", "fc_up_cap_mom_gap_biweekly",
"fc_up_cap_quality_mom_biweekly", "fc_up_cap_quality_mom_monthly",
"fc_mom7m_rec126_monthly",
"lowrisk_momentum_top15", "lowbeta_sharpe_mom_top20",
],
},
},
}
# Exchange close per price panel. A bar dated today is treated as a closing
# price only once the session has closed and settled for a few minutes.
MARKET_SESSION = {
@@ -326,6 +372,71 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
return tickers, universe["benchmark"]
def index_members(market: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
"""Point-in-time index membership (``days`` x every ticker that was a member
on one of them), or None for a market without membership history."""
if market == "us":
mask = universe_history.membership_mask(days, tickers=universe_history.all_tickers_ever())
elif market == "cn":
snaps = universe_history.load_csi300_history()
mask = universe_history.csi300_membership_mask(
days, universe_history.all_csi300_tickers_ever(snaps), snaps)
else:
return None
return mask.loc[:, mask.any()]
def session_universes(market: str, strategy_name: str, tickers: list[str],
days: pd.DatetimeIndex) -> list[tuple[list[str], pd.DatetimeIndex]]:
"""Split ``days`` into runs of sessions that share one stock universe.
The latest session trades ``tickers`` (today's list). Earlier sessions
trade the index members of their own day: a replay or catch-up over past
sessions with today's list would buy names before they joined the index,
i.e. names that were added because they had risen. ETF strategies keep
their fixed basket.
"""
if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES or len(days) < 2:
return [(list(tickers), days)]
members = index_members(market, days[:-1])
if members is None:
return [(list(tickers), days)]
extras = set(strategy_extra_tickers(strategy_name))
runs: list[tuple[list[str], list]] = []
for day in days:
if day == days[-1]:
universe = sorted(tickers)
else:
universe = sorted(set(members.columns[members.loc[day].to_numpy()]) | extras)
if runs and runs[-1][0] == universe:
runs[-1][1].append(day)
else:
runs.append((universe, [day]))
return [(u, pd.DatetimeIndex(d)) for u, d in runs]
def pit_candidates(market: str, strategy_name: str, since: pd.Timestamp | None) -> list[str]:
"""Tickers that were index members on any day from ``since`` to today, so a
catch-up or replay has prices for names that have left the index since."""
days = pd.bdate_range(since, pd.Timestamp.today().normalize()) if since is not None else []
if len(days) == 0 or strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES:
return []
members = index_members(market, days)
return [] if members is None else list(members.columns)
def session_signals(market: str, strategy_name: str, tickers: list[str], days: pd.DatetimeIndex,
close: pd.DataFrame, open_data: pd.DataFrame) -> pd.DataFrame:
"""Target weights for each day in ``days``, each computed on that day's
point-in-time universe (see ``session_universes``); columns are the union."""
parts = []
for universe, run in session_universes(market, strategy_name, tickers, days):
universe = filter_tradable_tickers(close, universe)
bars = strategy_bars(market, strategy_name, universe, close, open_data)
parts.append(_signals_for_sessions(strategy_name, open_data[universe], run, bars))
return pd.concat(parts).fillna(0.0)
def strategy_data_market(market: str, strategy_name: str) -> str:
"""Return the cache namespace used for a strategy's price data."""
base_name = strategy_name.removeprefix("sim_")
@@ -419,21 +530,36 @@ def completed_sessions(close_raw: pd.DataFrame, data_market: str, benchmark: str
# Persistent state
# ---------------------------------------------------------------------------
def _state_path(market: str, strategy_name: str) -> str:
return os.path.join("data", f"trader_{market}_{strategy_name}.json")
def book_dir(book: str | None) -> str:
"""Directory holding a book's state files (the original book: data/)."""
return os.path.join("data", "books", book) if book else "data"
def load_state(market: str, strategy_name: str) -> dict:
path = _state_path(market, strategy_name)
def book_strategies(book: str | None, market: str) -> list[str]:
"""Strategies a book runs in ``market`` (the original book: all live ones)."""
names = LIVE_BOOKS[book]["strategies"][market] if book else DEFAULT_MONITOR_STRATEGIES
return [n for n in names if strategy_supports_market(n, market)]
def book_start(book: str | None, market: str) -> pd.Timestamp | None:
return pd.Timestamp(LIVE_BOOKS[book]["start"][market]) if book else None
def _state_path(market: str, strategy_name: str, book: str | None = None) -> str:
return os.path.join(book_dir(book), f"trader_{market}_{strategy_name}.json")
def load_state(market: str, strategy_name: str, book: str | None = None) -> dict:
path = _state_path(market, strategy_name, book)
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
def save_state(state: dict, market: str, strategy_name: str) -> None:
os.makedirs("data", exist_ok=True)
path = _state_path(market, strategy_name)
def save_state(state: dict, market: str, strategy_name: str, book: str | None = None) -> None:
path = _state_path(market, strategy_name, book)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump(state, f, indent=2, default=str)
@@ -766,7 +892,18 @@ def _with_last_known_prices(state: dict, prices: dict) -> dict:
return out
def _book_capital(args, market: str) -> float:
book = getattr(args, "book", None)
if book:
return LIVE_BOOKS[book]["capital"][market]
return args.capital or MONITOR_CAPITAL.get(market, 10_000.0)
def _strategy_fees(args, market: str) -> dict:
book = getattr(args, "book", None)
if book:
return {"tx_cost": 0.0, "fixed_fee": LIVE_BOOKS[book]["fee_per_order"][market],
"fee_base": 0.0, "fee_per_share": 0.0}
tier = MARKET_FEE_TIERED.get(market, {})
return {
"tx_cost": args.tx_cost,
@@ -796,22 +933,18 @@ def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
return weights.shift(-1).reindex(days).fillna(0.0)
def _run_session(state: dict, strategy_name: str, market: str, day: pd.Timestamp,
tickers: list[str], benchmark: str, close_data: pd.DataFrame,
open_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series | None = None,
bars: dict | None = None) -> dict:
"""Signal on ``day``'s open (a BarStrategy: bars through the previous
session), execute at ``day``'s close, record the day.
def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
close_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series) -> dict:
"""Execute ``signal_row`` (targets from ``day``'s open; a BarStrategy: bars
through the previous session, see ``session_signals``) at ``day``'s close
and record the day.
Tickers without a real closing print on ``day`` (forward-filled price) are
valued but not traded. ``signal_row`` supplies precomputed target weights.
valued but not traded.
"""
day_str = str(day.date())
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
if signal_row is None:
signal_row = _signals_for_sessions(strategy_name, open_data[tickers],
pd.DatetimeIndex([day]), bars).loc[day]
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
all_held = sorted(set(target_weights) | set(state["holdings"]))
@@ -862,12 +995,13 @@ def cmd_morning(args):
if not strategy_supports_market(strategy_name, market):
print(f"{strategy_name} does not run in the {market.upper()} book; skipping.")
return
book = getattr(args, "book", None)
tickers, benchmark = strategy_universe(market, strategy_name)
# Load or init state
state = load_state(market, strategy_name)
state = load_state(market, strategy_name, book)
if not state:
capital = args.capital or MONITOR_CAPITAL.get(market, 10_000.0)
capital = _book_capital(args, market)
state = init_state(market, strategy_name, capital)
print(f"--- Initialized new portfolio: {capital:,.0f} cash ---")
@@ -880,6 +1014,10 @@ def cmd_morning(args):
today = open_data.index[-1]
today_str = str(today.date())
start = book_start(book, market)
if start is not None and today < start:
print(f"{strategy_name}: book {book} starts on {start.date()}; no orders on {today_str}.")
return
if state["last_morning"] == today_str:
print(f"Morning already run for {today_str}. Showing pending trades.\n")
@@ -915,7 +1053,7 @@ def cmd_morning(args):
"trades": trades,
}
state["last_morning"] = today_str
save_state(state, market, strategy_name)
save_state(state, market, strategy_name, book)
# Print summary
print(f"\n Portfolio value (at open): ${total:,.2f}")
@@ -957,9 +1095,10 @@ def cmd_evening(args):
"""Evening: record execution at close prices, update portfolio."""
market = args.market
strategy_name = args.strategy
book = getattr(args, "book", None)
tickers, benchmark = strategy_universe(market, strategy_name)
state = load_state(market, strategy_name)
state = load_state(market, strategy_name, book)
if not state:
print("No state found. Run 'morning' first.")
return
@@ -1021,7 +1160,7 @@ def cmd_evening(args):
state["pending_trades"] = None
state["last_evening"] = trade_date
save_state(state, market, strategy_name)
save_state(state, market, strategy_name, book)
n_executed = len(exec_trades)
total_commission = sum(t.get("commission", 0)
@@ -1054,7 +1193,7 @@ def cmd_status(args):
market = args.market
strategy_name = args.strategy
state = load_state(market, strategy_name)
state = load_state(market, strategy_name, getattr(args, "book", None))
if not state:
print("No state found. Run 'morning' to initialize.")
return
@@ -1134,10 +1273,11 @@ def cmd_compare(args):
market = args.market
strategy_names = getattr(args, "strategy", None)
book = getattr(args, "book", None)
# Auto-discover: find all trader_{market}_*.json state files
if not strategy_names:
pattern = os.path.join("data", f"trader_{market}_*.json")
pattern = os.path.join(book_dir(book), f"trader_{market}_*.json")
files = sorted(_glob.glob(pattern))
strategy_names = []
for f in files:
@@ -1159,7 +1299,7 @@ def cmd_compare(args):
# Load all states
states = {}
for name in strategy_names:
state = load_state(market, name)
state = load_state(market, name, book)
if state and state.get("daily_equity"):
states[name] = state
else:
@@ -1280,7 +1420,7 @@ def cmd_log(args):
market = args.market
strategy_name = args.strategy
state = load_state(market, strategy_name)
state = load_state(market, strategy_name, getattr(args, "book", None))
if not state:
print("No state found. Run 'simulate' or 'auto' first.")
return
@@ -1366,16 +1506,17 @@ def cmd_simulate(args):
market = args.market
strategy_name = args.strategy
tickers, benchmark = strategy_universe(market, strategy_name)
start = pd.Timestamp(args.start)
end = pd.Timestamp(args.end)
# Load both open and close data
request = sorted(set(tickers) | set(pit_candidates(market, strategy_name, start)))
close_data, open_data, _ = load_strategy_prices(
market, strategy_name, tickers, benchmark, with_open=True,
market, strategy_name, request, benchmark, with_open=True,
)
tickers = filter_tradable_tickers(close_data, tickers)
# Date range
start = pd.Timestamp(args.start)
end = pd.Timestamp(args.end)
trading_days = close_data.index[(close_data.index >= start) & (close_data.index <= end)]
if len(trading_days) == 0:
@@ -1385,10 +1526,10 @@ def cmd_simulate(args):
# Fresh state for simulation
state = init_state(market, f"sim_{strategy_name}", args.capital)
# Pre-compute strategy weights for the full period (much faster than per-day)
# Append dummy row so shift(1) inside strategy produces valid last-day signal
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
full_weights = _signals_for_sessions(strategy_name, open_data[tickers], trading_days, bars)
# Pre-compute strategy weights for the full period (much faster than per-day),
# each day on its point-in-time index members
full_weights = session_signals(market, strategy_name, tickers, trading_days,
close_data, open_data)
print(f"\n{'='*70}")
print(f" SIMULATION: {strategy_name} | {market.upper()}")
@@ -1650,17 +1791,22 @@ def cmd_monitor(args):
market_fee = MARKET_FEES.get(market, args.fixed_fee)
eligible = [s for s in strategies if strategy_supports_market(s, market)]
runs = [(None, name) for name in eligible] + [
(book, name) for book in LIVE_BOOKS for name in book_strategies(book, market)]
# One download per price panel per phase; strategies then read offline.
try:
refresh_panels(market, eligible, label="monitor")
refresh_panels(market, sorted({name for _, name in runs}), label="monitor")
except Exception as e:
print(f"[monitor] Panel refresh failed ({e}); using stored prices")
for strat_name in eligible:
for book, strat_name in runs:
sub_args = copy.copy(args)
sub_args.refresh = False
sub_args.strategy = strat_name
sub_args.market = market
sub_args.fixed_fee = market_fee
sub_args.book = book
if book:
strat_name = f"{book}/{strat_name}"
print(f"\n[monitor] --- {market.upper()}:{strat_name} ---")
try:
@@ -1680,8 +1826,7 @@ def cmd_monitor(args):
import traceback
traceback.print_exc()
print(f"[monitor] {market.upper()} {phase} done — "
f"{len(eligible)} strategies\n")
print(f"[monitor] {market.upper()} {phase} done — {len(runs)} books x strategies\n")
# Catch up sessions missed while the monitor was down. cmd_auto only
# processes sessions whose close is final, so this is safe at any time.
@@ -1777,9 +1922,15 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
if not strategy_supports_market(strategy_name, market):
print(f"[{label}] {strategy_name} does not run in the {market.upper()} book; skipping.")
return None
fresh = not state
if fresh and start is None:
start = book_start(getattr(args, "book", None), market)
tickers, benchmark = strategy_universe(market, strategy_name)
since = start if not state else (
pd.Timestamp(state["last_evening"]) if state.get("last_evening") else None)
request = sorted(set(tickers) | set(pit_candidates(market, strategy_name, since)))
close_data, open_data, close_raw = load_strategy_prices(
market, strategy_name, tickers, benchmark, with_open=True, refresh=refresh,
market, strategy_name, request, benchmark, with_open=True, refresh=refresh,
)
tickers = filter_tradable_tickers(close_data, tickers)
sessions = completed_sessions(close_raw, strategy_data_market(market, strategy_name), benchmark)
@@ -1787,15 +1938,22 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
print(f"[{label}] No completed session in the price panel.")
return None
if not state:
capital = args.capital or MONITOR_CAPITAL.get(market, 10_000.0)
state = init_state(market, strategy_name, capital)
if fresh:
first = start if start is not None else sessions[-1]
todo = sessions[sessions >= first]
if len(todo) == 0:
print(f"[{label}] {strategy_name}: starts on {first.date()}; no session yet.")
return None
capital = _book_capital(args, market)
state = init_state(market, strategy_name, capital)
print(f"[{label}] Initialized {strategy_name} ({market.upper()}) with {capital:,.0f} cash")
else:
last = state.get("last_evening")
todo = sessions[sessions > pd.Timestamp(last)] if last else sessions[-1:]
first = book_start(getattr(args, "book", None), market)
if last:
todo = sessions[sessions > pd.Timestamp(last)]
else: # created by a morning run: start with the book's first session
todo = sessions[sessions >= first] if first is not None else sessions[-1:]
if len(todo) == 0:
print(f"[{label}] {strategy_name}: {state.get('last_evening')} already processed.")
@@ -1805,12 +1963,10 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
f"{todo[0].date()} .. {todo[-1].date()}")
fees = _strategy_fees(args, market)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
signals = _signals_for_sessions(strategy_name, open_data[tickers], todo, bars)
signals = session_signals(market, strategy_name, tickers, todo, close_data, open_data)
for day in todo:
r = _run_session(state, strategy_name, market, day, tickers, benchmark,
close_data, open_data, close_raw, fees, args.integer_shares,
signal_row=signals.loc[day], bars=bars)
r = _run_session(state, market, day, benchmark, close_data, close_raw, fees,
args.integer_shares, signal_row=signals.loc[day])
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "
@@ -1831,26 +1987,29 @@ def cmd_auto(args):
For CN market (run at 4pm CST):
0 16 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market cn
"""
state = load_state(args.market, args.strategy)
book = getattr(args, "book", None)
state = load_state(args.market, args.strategy, book)
state = _process_sessions(args, state, refresh=getattr(args, "refresh", True))
if state is not None:
save_state(state, args.market, args.strategy)
if state is not None and state.get("last_evening"):
save_state(state, args.market, args.strategy, book)
pnl = state["daily_equity"][state["last_evening"]] - state["initial_capital"]
print(f"[auto] P&L since inception: {pnl:>+,.2f} "
f"({pnl / state['initial_capital']:+.2%}) | state: "
f"{_state_path(args.market, args.strategy)}")
f"{_state_path(args.market, args.strategy, book)}")
def cmd_replay(args):
"""Rebuild live state files by replaying every session from each strategy's start.
Uses exactly the per-session logic of ``auto``. Existing state files are
replaced; their original start date is kept unless ``--start`` is given.
replaced; their original start date is kept unless ``--start`` is given
(a named book replays from its own start date).
"""
import copy
book = getattr(args, "book", None)
names = args.strategy or sorted(
os.path.basename(p)[len(f"trader_{args.market}_"):-len(".json")]
for p in __import__("glob").glob(os.path.join("data", f"trader_{args.market}_*.json"))
for p in __import__("glob").glob(os.path.join(book_dir(book), f"trader_{args.market}_*.json"))
)
live_names = [n for n in names if not n.startswith("sim_") and n in STRATEGY_REGISTRY
and n not in LIVE_EXCLUDED_STRATEGIES and strategy_supports_market(n, args.market)]
@@ -1867,11 +2026,11 @@ def cmd_replay(args):
or not strategy_supports_market(name, args.market):
print(f"[replay] {args.market.upper()}:{name} is not a live strategy here; "
f"removing its state file")
if os.path.exists(_state_path(args.market, name)):
os.remove(_state_path(args.market, name))
if os.path.exists(_state_path(args.market, name, book)):
os.remove(_state_path(args.market, name, book))
continue
old = load_state(args.market, name)
start = pd.Timestamp(args.start) if args.start else (
old = load_state(args.market, name, book)
start = pd.Timestamp(args.start) if args.start else book_start(book, args.market) or (
pd.Timestamp(min(old["daily_equity"])) if old.get("daily_equity") else None
)
if start is None:
@@ -1879,7 +2038,7 @@ def cmd_replay(args):
continue
state = _process_sessions(sub, None, start=start, label="replay", refresh=False)
if state is not None:
save_state(state, args.market, name)
save_state(state, args.market, name, book)
# ---------------------------------------------------------------------------
@@ -1890,6 +2049,8 @@ def main():
parser = argparse.ArgumentParser(description="Daily trading simulation")
sub = parser.add_subparsers(dest="command", required=True)
book_help = "Named live book (default: the original book in data/)"
# Common args
def add_common(p):
p.add_argument("--market", choices=UNIVERSES.keys(), default="us")
@@ -1902,6 +2063,7 @@ def main():
help="Fixed dollar fee per trade")
p.add_argument("--integer-shares", action="store_true", default=False,
help="Only trade whole shares (no fractional)")
p.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
# Morning
p_morning = sub.add_parser("morning", help="Generate trade orders from open prices")
@@ -1950,6 +2112,7 @@ def main():
p_replay.add_argument("--fixed-fee", type=float, default=0.0,
help="Per-trade fee floor (default: per-market MARKET_FEES)")
p_replay.add_argument("--integer-shares", action=argparse.BooleanOptionalAction, default=True)
p_replay.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
# Status (strategy is a free-form string to allow sim_ prefixed names)
p_status = sub.add_parser("status", help="Show current portfolio")
@@ -1960,6 +2123,7 @@ def main():
p_status.add_argument("--tx-cost", type=float, default=0.001)
p_status.add_argument("--fixed-fee", type=float, default=0.0)
p_status.add_argument("--integer-shares", action="store_true", default=False)
p_status.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
# Simulate
p_sim = sub.add_parser("simulate", help="Simulate over a date range")
@@ -1974,6 +2138,7 @@ def main():
help="Strategy name (e.g. sim_recovery_mom_top10)")
p_log.add_argument("--start", default=None, help="Start date filter (YYYY-MM-DD)")
p_log.add_argument("--end", default=None, help="End date filter (YYYY-MM-DD)")
p_log.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
# Compare strategies
p_cmp = sub.add_parser("compare",
@@ -1981,6 +2146,7 @@ def main():
p_cmp.add_argument("--market", choices=UNIVERSES.keys(), default="us")
p_cmp.add_argument("--strategy", nargs="+", default=None,
help="Strategy names to compare (default: auto-discover all)")
p_cmp.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
args = parser.parse_args()

View File

@@ -16,6 +16,7 @@ import {
formatNumber,
formatPct,
type EquityPoint,
type LiveBook,
type LiveStrategyState,
type StrategyCurrency,
type StrategyMarket,
@@ -40,8 +41,29 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
type SortKey = "currentValue" | "unrealizedPnlPct" | "name" | "lastUpdated";
const BOOK_STORAGE_KEY = "live.book";
function storedBook(): string | null {
try {
return window.localStorage.getItem(BOOK_STORAGE_KEY);
} catch {
return null;
}
}
function storeBook(book: string) {
try {
window.localStorage.setItem(BOOK_STORAGE_KEY, book);
} catch {
// storage unavailable: the choice just is not remembered
}
}
export function LivePage() {
const [market, setMarket] = useState<StrategyMarket>("us");
const [books, setBooks] = useState<LiveBook[]>([]);
// null until the book list is known; "" is the original book
const [book, setBook] = useState<string | null>(null);
const [strategies, setStrategies] = useState<LiveStrategyState[]>([]);
const [selectedName, setSelectedName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -49,10 +71,29 @@ export function LivePage() {
const [sortKey, setSortKey] = useState<SortKey>("unrealizedPnlPct");
const [filterText, setFilterText] = useState("");
const refresh = (mk: StrategyMarket = market) => {
useEffect(() => {
api<LiveBook[]>("/api/strategies/live/books")
.then((res) => {
setBooks(res);
const saved = storedBook();
// Default to the newest named book (listed last).
setBook(saved !== null && res.some((b) => b.id === saved) ? saved : (res[res.length - 1]?.id ?? ""));
})
.catch(() => setBook(""));
}, []);
const selectBook = (id: string) => {
storeBook(id);
setBook(id);
};
const currentBook = books.find((b) => b.id === book);
const refresh = (mk: StrategyMarket = market, bk: string | null = book) => {
if (bk === null) return;
setLoading(true);
setError("");
api<LiveStrategyState[]>(`/api/strategies/live?market=${mk}`)
api<LiveStrategyState[]>(`/api/strategies/live?market=${mk}&book=${encodeURIComponent(bk)}`)
.then((res) => {
setStrategies(res);
if (res.length > 0) {
@@ -68,9 +109,9 @@ export function LivePage() {
};
useEffect(() => {
refresh(market);
refresh(market, book);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [market]);
}, [market, book]);
const sorted = useMemo(() => {
const list = [...strategies];
@@ -111,6 +152,20 @@ export function LivePage() {
<button className={market === "us" ? "active" : ""} onClick={() => setMarket("us")}>美股</button>
<button className={market === "cn" ? "active" : ""} onClick={() => setMarket("cn")}>A股</button>
</div>
{books.length > 1 && (
<label className="ghostSelectLabel">
<span>组合</span>
<select
className="ghostSelect"
value={book ?? ""}
onChange={(e) => selectBook(e.target.value)}
>
{books.map((b) => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</label>
)}
<input
className="ghostInput"
placeholder="筛选策略..."
@@ -151,7 +206,7 @@ export function LivePage() {
{loading && sorted.length === 0 ? (
<p className="hint">加载中...</p>
) : sorted.length === 0 ? (
<p className="hint">暂无在线策略。运行 quant 的 trader.py auto 后再刷新。</p>
<p className="hint">{emptyHint(currentBook, market)}</p>
) : (
<div className="liveList">
{sorted.map((s) => (
@@ -203,6 +258,14 @@ export function LivePage() {
);
}
function emptyHint(book: LiveBook | undefined, market: StrategyMarket): string {
if (!book?.startDate) return "暂无在线策略。运行 quant 的 trader.py auto 后再刷新。";
const currency = currencyForMarket(market);
const fee = book.feePerOrder ? ` · 每笔 ${formatMoney(book.feePerOrder[market], currency)}` : "";
return `${book.label}:${book.strategies[market].length} 个策略,各 ${formatMoney(book.capital[market], currency)}${fee},`
+ `${book.startDate[market]} 收盘后出现首日记录。`;
}
function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
const m = strategy;
const currency = currencyOf(m);

View File

@@ -11,7 +11,7 @@ import { WatchlistStore } from "../agent/watchlistStore";
import { loadIpadsConfig } from "./ipadsConfig";
import type { ChartPeriod, WatchStock } from "../shared/types";
import type { BacktestConfig } from "../shared/strategyTypes";
import { listStrategies, runBacktest, getCachedResult, getLiveStrategies } from "./strategyService";
import { listStrategies, runBacktest, getCachedResult, getLiveStrategies, getLiveBooks } from "./strategyService";
const port = Number(process.env.PORT ?? 8787);
const host = process.env.HOST ?? "127.0.0.1";
@@ -284,7 +284,16 @@ app.get("/api/strategies/live", async (request, response, next) => {
try {
const market = String(request.query.market ?? "us");
const strategy = request.query.strategy ? String(request.query.strategy) : undefined;
response.json(await getLiveStrategies(market, strategy));
const book = request.query.book ? String(request.query.book) : undefined;
response.json(await getLiveStrategies(market, strategy, book));
} catch (error) {
next(error);
}
});
app.get("/api/strategies/live/books", async (_request, response, next) => {
try {
response.json(await getLiveBooks());
} catch (error) {
next(error);
}

View File

@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { REPO_ROOT, WEB_DATA_DIR } from "./paths";
import type { BacktestConfig, BacktestResult, LiveStrategyState, StrategyInfo } from "../shared/strategyTypes.js";
import type { BacktestConfig, BacktestResult, LiveBook, LiveStrategyState, StrategyInfo } from "../shared/strategyTypes.js";
const CACHE_DIR = join(WEB_DATA_DIR, "strategy_cache");
const TIMEOUT_MS = Number(process.env.QUANT_BACKTEST_TIMEOUT_MS) || 180_000;
@@ -157,12 +157,17 @@ export async function getCachedResult(id: string): Promise<BacktestResult | null
return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult);
}
export async function getLiveStrategies(market: string, strategy?: string): Promise<LiveStrategyState[]> {
export async function getLiveStrategies(market: string, strategy?: string, book?: string): Promise<LiveStrategyState[]> {
const args = ["live", "--market", market];
if (strategy) args.push("--strategy", strategy);
if (book) args.push("--book", book);
return (await runBridge(args)) as LiveStrategyState[];
}
export async function getLiveBooks(): Promise<LiveBook[]> {
return (await runBridge(["books"])) as LiveBook[];
}
function normalizeBacktestResult(result: BacktestResult): BacktestResult {
return {
...result,

View File

@@ -160,6 +160,18 @@ export interface TradeLogEntry {
commission: number;
}
// A live book: a cohort of strategies, each with its own account, started on
// one date with one capital and fee schedule. id "" is the original book.
export interface LiveBook {
id: string;
label: string;
startDate: Record<StrategyMarket, string> | null;
capital: Record<StrategyMarket, number>;
// Flat fee per order; null for the original book's tiered schedule.
feePerOrder: Record<StrategyMarket, number> | null;
strategies: Record<StrategyMarket, string[]>;
}
export interface LiveStrategyState {
name: string;
label: string;