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>
This commit is contained in:
2026-09-25 20:46:09 +08:00
parent 905ca4fe05
commit 5dbabc3e08
8 changed files with 309 additions and 47 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 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 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 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/) # Precompute the strategy page's backtests (writes data/precomputed/)
uv run python precompute_backtest.py 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`. **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` (26 strategies, US $10,000 / CN ¥50,000, $1 / ¥5 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. **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**: **Strategies**:

View File

@@ -23,9 +23,13 @@ import metrics
from backtest_runner import benchmark_curve, run_market from backtest_runner import benchmark_curve, run_market
from research.permanent_yearly import equity_with_cashflows from research.permanent_yearly import equity_with_cashflows
from trader import ( from trader import (
LIVE_BOOKS,
LIVE_EXCLUDED_STRATEGIES, LIVE_EXCLUDED_STRATEGIES,
MARKET_FEE_TIERED, MARKET_FEE_TIERED,
MONITOR_CAPITAL,
STRATEGY_REGISTRY, STRATEGY_REGISTRY,
book_dir,
book_strategies,
convert_quote_currency, convert_quote_currency,
strategy_data_market, strategy_data_market,
strategy_supports_market, strategy_supports_market,
@@ -863,8 +867,10 @@ def cmd_live(args: argparse.Namespace) -> None:
""" """
market = args.market market = args.market
prefix = f"trader_{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 wanted = args.strategy
in_book = set(book_strategies(book, market)) if book else None
panels = _load_panels({market, "etfs"}) panels = _load_panels({market, "etfs"})
bench_ticker, bench_label = LIVE_BENCHMARK[market] 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): 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") _log(f" Skipping {strategy_name}: not a live strategy in the {market.upper()} book")
continue 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: try:
with open(path) as f: 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) 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 # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1043,6 +1064,11 @@ def main() -> None:
live_parser = subparsers.add_parser("live", help="Read live trader states") 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("--market", default="us", choices=["us", "cn"])
live_parser.add_argument("--strategy", default=None, help="Filter to specific strategy") 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() args = parser.parse_args()
@@ -1052,6 +1078,8 @@ def main() -> None:
cmd_backtest(args) cmd_backtest(args)
elif args.command == "live": elif args.command == "live":
cmd_live(args) cmd_live(args)
elif args.command == "books":
cmd_books()
else: else:
parser.print_help(sys.stderr) parser.print_help(sys.stderr)
sys.exit(1) sys.exit(1)

View File

@@ -3,6 +3,8 @@ order sizing, and how results are reported."""
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace
from unittest import mock
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@@ -61,6 +63,47 @@ class PanelRefreshTests(unittest.TestCase):
self.assertEqual(len(cleaned), 4) 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): class SessionAndOrderTests(unittest.TestCase):
def test_todays_bar_is_not_a_close_until_the_session_settles(self): def test_todays_bar_is_not_a_close_until_the_session_settles(self):
raw = _panel({"SPY": [1, 1, 1]}, start="2026-09-22") raw = _panel({"SPY": [1, 1, 1]}, start="2026-09-22")

173
trader.py
View File

@@ -107,6 +107,47 @@ RESIZE_BAND = 0.10
# Default live capital per market, in the market's own currency. # Default live capital per market, in the market's own currency.
MONITOR_CAPITAL = {"us": 10_000.0, "cn": 70_000.0} 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": 50_000.0},
"fee_per_order": {"us": 1.0, "cn": 5.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.
"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",
],
"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 # 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. # price only once the session has closed and settled for a few minutes.
MARKET_SESSION = { MARKET_SESSION = {
@@ -373,9 +414,10 @@ def session_universes(market: str, strategy_name: str, tickers: list[str],
def pit_candidates(market: str, strategy_name: str, since: pd.Timestamp | None) -> list[str]: 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 """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.""" catch-up or replay has prices for names that have left the index since."""
if since is None or strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES: 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 [] return []
members = index_members(market, pd.bdate_range(since, pd.Timestamp.today().normalize())) members = index_members(market, days)
return [] if members is None else list(members.columns) return [] if members is None else list(members.columns)
@@ -484,21 +526,36 @@ def completed_sessions(close_raw: pd.DataFrame, data_market: str, benchmark: str
# Persistent state # Persistent state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _state_path(market: str, strategy_name: str) -> str: def book_dir(book: str | None) -> str:
return os.path.join("data", f"trader_{market}_{strategy_name}.json") """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: def book_strategies(book: str | None, market: str) -> list[str]:
path = _state_path(market, strategy_name) """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): if os.path.exists(path):
with open(path) as f: with open(path) as f:
return json.load(f) return json.load(f)
return {} return {}
def save_state(state: dict, market: str, strategy_name: str) -> None: def save_state(state: dict, market: str, strategy_name: str, book: str | None = None) -> None:
os.makedirs("data", exist_ok=True) path = _state_path(market, strategy_name, book)
path = _state_path(market, strategy_name) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f: with open(path, "w") as f:
json.dump(state, f, indent=2, default=str) json.dump(state, f, indent=2, default=str)
@@ -831,7 +888,18 @@ def _with_last_known_prices(state: dict, prices: dict) -> dict:
return out 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: 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, {}) tier = MARKET_FEE_TIERED.get(market, {})
return { return {
"tx_cost": args.tx_cost, "tx_cost": args.tx_cost,
@@ -923,12 +991,13 @@ def cmd_morning(args):
if not strategy_supports_market(strategy_name, market): if not strategy_supports_market(strategy_name, market):
print(f"{strategy_name} does not run in the {market.upper()} book; skipping.") print(f"{strategy_name} does not run in the {market.upper()} book; skipping.")
return return
book = getattr(args, "book", None)
tickers, benchmark = strategy_universe(market, strategy_name) tickers, benchmark = strategy_universe(market, strategy_name)
# Load or init state # Load or init state
state = load_state(market, strategy_name) state = load_state(market, strategy_name, book)
if not state: 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) state = init_state(market, strategy_name, capital)
print(f"--- Initialized new portfolio: {capital:,.0f} cash ---") print(f"--- Initialized new portfolio: {capital:,.0f} cash ---")
@@ -941,6 +1010,10 @@ def cmd_morning(args):
today = open_data.index[-1] today = open_data.index[-1]
today_str = str(today.date()) 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: if state["last_morning"] == today_str:
print(f"Morning already run for {today_str}. Showing pending trades.\n") print(f"Morning already run for {today_str}. Showing pending trades.\n")
@@ -976,7 +1049,7 @@ def cmd_morning(args):
"trades": trades, "trades": trades,
} }
state["last_morning"] = today_str state["last_morning"] = today_str
save_state(state, market, strategy_name) save_state(state, market, strategy_name, book)
# Print summary # Print summary
print(f"\n Portfolio value (at open): ${total:,.2f}") print(f"\n Portfolio value (at open): ${total:,.2f}")
@@ -1018,9 +1091,10 @@ def cmd_evening(args):
"""Evening: record execution at close prices, update portfolio.""" """Evening: record execution at close prices, update portfolio."""
market = args.market market = args.market
strategy_name = args.strategy strategy_name = args.strategy
book = getattr(args, "book", None)
tickers, benchmark = strategy_universe(market, strategy_name) tickers, benchmark = strategy_universe(market, strategy_name)
state = load_state(market, strategy_name) state = load_state(market, strategy_name, book)
if not state: if not state:
print("No state found. Run 'morning' first.") print("No state found. Run 'morning' first.")
return return
@@ -1082,7 +1156,7 @@ def cmd_evening(args):
state["pending_trades"] = None state["pending_trades"] = None
state["last_evening"] = trade_date state["last_evening"] = trade_date
save_state(state, market, strategy_name) save_state(state, market, strategy_name, book)
n_executed = len(exec_trades) n_executed = len(exec_trades)
total_commission = sum(t.get("commission", 0) total_commission = sum(t.get("commission", 0)
@@ -1115,7 +1189,7 @@ def cmd_status(args):
market = args.market market = args.market
strategy_name = args.strategy strategy_name = args.strategy
state = load_state(market, strategy_name) state = load_state(market, strategy_name, getattr(args, "book", None))
if not state: if not state:
print("No state found. Run 'morning' to initialize.") print("No state found. Run 'morning' to initialize.")
return return
@@ -1195,10 +1269,11 @@ def cmd_compare(args):
market = args.market market = args.market
strategy_names = getattr(args, "strategy", None) strategy_names = getattr(args, "strategy", None)
book = getattr(args, "book", None)
# Auto-discover: find all trader_{market}_*.json state files # Auto-discover: find all trader_{market}_*.json state files
if not strategy_names: 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)) files = sorted(_glob.glob(pattern))
strategy_names = [] strategy_names = []
for f in files: for f in files:
@@ -1220,7 +1295,7 @@ def cmd_compare(args):
# Load all states # Load all states
states = {} states = {}
for name in strategy_names: for name in strategy_names:
state = load_state(market, name) state = load_state(market, name, book)
if state and state.get("daily_equity"): if state and state.get("daily_equity"):
states[name] = state states[name] = state
else: else:
@@ -1341,7 +1416,7 @@ def cmd_log(args):
market = args.market market = args.market
strategy_name = args.strategy strategy_name = args.strategy
state = load_state(market, strategy_name) state = load_state(market, strategy_name, getattr(args, "book", None))
if not state: if not state:
print("No state found. Run 'simulate' or 'auto' first.") print("No state found. Run 'simulate' or 'auto' first.")
return return
@@ -1712,17 +1787,22 @@ def cmd_monitor(args):
market_fee = MARKET_FEES.get(market, args.fixed_fee) market_fee = MARKET_FEES.get(market, args.fixed_fee)
eligible = [s for s in strategies if strategy_supports_market(s, market)] 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. # One download per price panel per phase; strategies then read offline.
try: try:
refresh_panels(market, eligible, label="monitor") refresh_panels(market, sorted({name for _, name in runs}), label="monitor")
except Exception as e: except Exception as e:
print(f"[monitor] Panel refresh failed ({e}); using stored prices") 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 = copy.copy(args)
sub_args.refresh = False sub_args.refresh = False
sub_args.strategy = strat_name sub_args.strategy = strat_name
sub_args.market = market sub_args.market = market
sub_args.fixed_fee = market_fee 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} ---") print(f"\n[monitor] --- {market.upper()}:{strat_name} ---")
try: try:
@@ -1742,8 +1822,7 @@ def cmd_monitor(args):
import traceback import traceback
traceback.print_exc() traceback.print_exc()
print(f"[monitor] {market.upper()} {phase} done — " print(f"[monitor] {market.upper()} {phase} done — {len(runs)} books x strategies\n")
f"{len(eligible)} strategies\n")
# Catch up sessions missed while the monitor was down. cmd_auto only # 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. # processes sessions whose close is final, so this is safe at any time.
@@ -1839,6 +1918,9 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
if not strategy_supports_market(strategy_name, market): if not strategy_supports_market(strategy_name, market):
print(f"[{label}] {strategy_name} does not run in the {market.upper()} book; skipping.") print(f"[{label}] {strategy_name} does not run in the {market.upper()} book; skipping.")
return None 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) tickers, benchmark = strategy_universe(market, strategy_name)
since = start if not state else ( since = start if not state else (
pd.Timestamp(state["last_evening"]) if state.get("last_evening") else None) pd.Timestamp(state["last_evening"]) if state.get("last_evening") else None)
@@ -1852,15 +1934,22 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
print(f"[{label}] No completed session in the price panel.") print(f"[{label}] No completed session in the price panel.")
return None return None
if not state: if fresh:
capital = args.capital or MONITOR_CAPITAL.get(market, 10_000.0)
state = init_state(market, strategy_name, capital)
first = start if start is not None else sessions[-1] first = start if start is not None else sessions[-1]
todo = sessions[sessions >= first] 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") print(f"[{label}] Initialized {strategy_name} ({market.upper()}) with {capital:,.0f} cash")
else: else:
last = state.get("last_evening") 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: if len(todo) == 0:
print(f"[{label}] {strategy_name}: {state.get('last_evening')} already processed.") print(f"[{label}] {strategy_name}: {state.get('last_evening')} already processed.")
@@ -1894,26 +1983,29 @@ def cmd_auto(args):
For CN market (run at 4pm CST): For CN market (run at 4pm CST):
0 16 * * 1-5 cd /path/to/quant && uv run python trader.py auto --market cn 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)) state = _process_sessions(args, state, refresh=getattr(args, "refresh", True))
if state is not None: if state is not None and state.get("last_evening"):
save_state(state, args.market, args.strategy) save_state(state, args.market, args.strategy, book)
pnl = state["daily_equity"][state["last_evening"]] - state["initial_capital"] pnl = state["daily_equity"][state["last_evening"]] - state["initial_capital"]
print(f"[auto] P&L since inception: {pnl:>+,.2f} " print(f"[auto] P&L since inception: {pnl:>+,.2f} "
f"({pnl / state['initial_capital']:+.2%}) | state: " 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): def cmd_replay(args):
"""Rebuild live state files by replaying every session from each strategy's start. """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 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 import copy
book = getattr(args, "book", None)
names = args.strategy or sorted( names = args.strategy or sorted(
os.path.basename(p)[len(f"trader_{args.market}_"):-len(".json")] 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 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)] and n not in LIVE_EXCLUDED_STRATEGIES and strategy_supports_market(n, args.market)]
@@ -1930,11 +2022,11 @@ def cmd_replay(args):
or not strategy_supports_market(name, args.market): or not strategy_supports_market(name, args.market):
print(f"[replay] {args.market.upper()}:{name} is not a live strategy here; " print(f"[replay] {args.market.upper()}:{name} is not a live strategy here; "
f"removing its state file") f"removing its state file")
if os.path.exists(_state_path(args.market, name)): if os.path.exists(_state_path(args.market, name, book)):
os.remove(_state_path(args.market, name)) os.remove(_state_path(args.market, name, book))
continue continue
old = load_state(args.market, name) old = load_state(args.market, name, book)
start = pd.Timestamp(args.start) if args.start else ( 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 pd.Timestamp(min(old["daily_equity"])) if old.get("daily_equity") else None
) )
if start is None: if start is None:
@@ -1942,7 +2034,7 @@ def cmd_replay(args):
continue continue
state = _process_sessions(sub, None, start=start, label="replay", refresh=False) state = _process_sessions(sub, None, start=start, label="replay", refresh=False)
if state is not None: if state is not None:
save_state(state, args.market, name) save_state(state, args.market, name, book)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1953,6 +2045,8 @@ def main():
parser = argparse.ArgumentParser(description="Daily trading simulation") parser = argparse.ArgumentParser(description="Daily trading simulation")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
book_help = "Named live book (default: the original book in data/)"
# Common args # Common args
def add_common(p): def add_common(p):
p.add_argument("--market", choices=UNIVERSES.keys(), default="us") p.add_argument("--market", choices=UNIVERSES.keys(), default="us")
@@ -1965,6 +2059,7 @@ def main():
help="Fixed dollar fee per trade") help="Fixed dollar fee per trade")
p.add_argument("--integer-shares", action="store_true", default=False, p.add_argument("--integer-shares", action="store_true", default=False,
help="Only trade whole shares (no fractional)") help="Only trade whole shares (no fractional)")
p.add_argument("--book", choices=list(LIVE_BOOKS), default=None, help=book_help)
# Morning # Morning
p_morning = sub.add_parser("morning", help="Generate trade orders from open prices") p_morning = sub.add_parser("morning", help="Generate trade orders from open prices")
@@ -2013,6 +2108,7 @@ def main():
p_replay.add_argument("--fixed-fee", type=float, default=0.0, p_replay.add_argument("--fixed-fee", type=float, default=0.0,
help="Per-trade fee floor (default: per-market MARKET_FEES)") 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("--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) # Status (strategy is a free-form string to allow sim_ prefixed names)
p_status = sub.add_parser("status", help="Show current portfolio") p_status = sub.add_parser("status", help="Show current portfolio")
@@ -2023,6 +2119,7 @@ def main():
p_status.add_argument("--tx-cost", type=float, default=0.001) 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("--fixed-fee", type=float, default=0.0)
p_status.add_argument("--integer-shares", action="store_true", default=False) 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 # Simulate
p_sim = sub.add_parser("simulate", help="Simulate over a date range") p_sim = sub.add_parser("simulate", help="Simulate over a date range")
@@ -2037,6 +2134,7 @@ def main():
help="Strategy name (e.g. sim_recovery_mom_top10)") 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("--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("--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 # Compare strategies
p_cmp = sub.add_parser("compare", p_cmp = sub.add_parser("compare",
@@ -2044,6 +2142,7 @@ def main():
p_cmp.add_argument("--market", choices=UNIVERSES.keys(), default="us") p_cmp.add_argument("--market", choices=UNIVERSES.keys(), default="us")
p_cmp.add_argument("--strategy", nargs="+", default=None, p_cmp.add_argument("--strategy", nargs="+", default=None,
help="Strategy names to compare (default: auto-discover all)") 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() args = parser.parse_args()

View File

@@ -16,6 +16,7 @@ import {
formatNumber, formatNumber,
formatPct, formatPct,
type EquityPoint, type EquityPoint,
type LiveBook,
type LiveStrategyState, type LiveStrategyState,
type StrategyCurrency, type StrategyCurrency,
type StrategyMarket, type StrategyMarket,
@@ -40,8 +41,29 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
type SortKey = "currentValue" | "unrealizedPnlPct" | "name" | "lastUpdated"; 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() { export function LivePage() {
const [market, setMarket] = useState<StrategyMarket>("us"); 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 [strategies, setStrategies] = useState<LiveStrategyState[]>([]);
const [selectedName, setSelectedName] = useState<string | null>(null); const [selectedName, setSelectedName] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -49,10 +71,29 @@ export function LivePage() {
const [sortKey, setSortKey] = useState<SortKey>("unrealizedPnlPct"); const [sortKey, setSortKey] = useState<SortKey>("unrealizedPnlPct");
const [filterText, setFilterText] = useState(""); 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); setLoading(true);
setError(""); setError("");
api<LiveStrategyState[]>(`/api/strategies/live?market=${mk}`) api<LiveStrategyState[]>(`/api/strategies/live?market=${mk}&book=${encodeURIComponent(bk)}`)
.then((res) => { .then((res) => {
setStrategies(res); setStrategies(res);
if (res.length > 0) { if (res.length > 0) {
@@ -68,9 +109,9 @@ export function LivePage() {
}; };
useEffect(() => { useEffect(() => {
refresh(market); refresh(market, book);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [market]); }, [market, book]);
const sorted = useMemo(() => { const sorted = useMemo(() => {
const list = [...strategies]; const list = [...strategies];
@@ -111,6 +152,20 @@ export function LivePage() {
<button className={market === "us" ? "active" : ""} onClick={() => setMarket("us")}>美股</button> <button className={market === "us" ? "active" : ""} onClick={() => setMarket("us")}>美股</button>
<button className={market === "cn" ? "active" : ""} onClick={() => setMarket("cn")}>A股</button> <button className={market === "cn" ? "active" : ""} onClick={() => setMarket("cn")}>A股</button>
</div> </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 <input
className="ghostInput" className="ghostInput"
placeholder="筛选策略..." placeholder="筛选策略..."
@@ -151,7 +206,7 @@ export function LivePage() {
{loading && sorted.length === 0 ? ( {loading && sorted.length === 0 ? (
<p className="hint">加载中...</p> <p className="hint">加载中...</p>
) : sorted.length === 0 ? ( ) : sorted.length === 0 ? (
<p className="hint">暂无在线策略。运行 quant 的 trader.py auto 后再刷新。</p> <p className="hint">{emptyHint(currentBook, market)}</p>
) : ( ) : (
<div className="liveList"> <div className="liveList">
{sorted.map((s) => ( {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 }) { function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
const m = strategy; const m = strategy;
const currency = currencyOf(m); const currency = currencyOf(m);

View File

@@ -11,7 +11,7 @@ import { WatchlistStore } from "../agent/watchlistStore";
import { loadIpadsConfig } from "./ipadsConfig"; import { loadIpadsConfig } from "./ipadsConfig";
import type { ChartPeriod, WatchStock } from "../shared/types"; import type { ChartPeriod, WatchStock } from "../shared/types";
import type { BacktestConfig } from "../shared/strategyTypes"; 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 port = Number(process.env.PORT ?? 8787);
const host = process.env.HOST ?? "127.0.0.1"; const host = process.env.HOST ?? "127.0.0.1";
@@ -284,7 +284,16 @@ app.get("/api/strategies/live", async (request, response, next) => {
try { try {
const market = String(request.query.market ?? "us"); const market = String(request.query.market ?? "us");
const strategy = request.query.strategy ? String(request.query.strategy) : undefined; 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) { } catch (error) {
next(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 { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { REPO_ROOT, WEB_DATA_DIR } from "./paths"; 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 CACHE_DIR = join(WEB_DATA_DIR, "strategy_cache");
const TIMEOUT_MS = Number(process.env.QUANT_BACKTEST_TIMEOUT_MS) || 180_000; 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); 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]; const args = ["live", "--market", market];
if (strategy) args.push("--strategy", strategy); if (strategy) args.push("--strategy", strategy);
if (book) args.push("--book", book);
return (await runBridge(args)) as LiveStrategyState[]; return (await runBridge(args)) as LiveStrategyState[];
} }
export async function getLiveBooks(): Promise<LiveBook[]> {
return (await runBridge(["books"])) as LiveBook[];
}
function normalizeBacktestResult(result: BacktestResult): BacktestResult { function normalizeBacktestResult(result: BacktestResult): BacktestResult {
return { return {
...result, ...result,

View File

@@ -160,6 +160,18 @@ export interface TradeLogEntry {
commission: number; 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 { export interface LiveStrategyState {
name: string; name: string;
label: string; label: string;