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 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` (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.
**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")

173
trader.py
View File

@@ -107,6 +107,47 @@ 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": 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
# price only once the session has closed and settled for a few minutes.
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]:
"""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."""
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 []
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)
@@ -484,21 +526,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)
@@ -831,7 +888,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,
@@ -923,12 +991,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 ---")
@@ -941,6 +1010,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")
@@ -976,7 +1049,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}")
@@ -1018,9 +1091,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
@@ -1082,7 +1156,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)
@@ -1115,7 +1189,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
@@ -1195,10 +1269,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:
@@ -1220,7 +1295,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:
@@ -1341,7 +1416,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
@@ -1712,17 +1787,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:
@@ -1742,8 +1822,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.
@@ -1839,6 +1918,9 @@ 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)
@@ -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.")
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.")
@@ -1894,26 +1983,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)]
@@ -1930,11 +2022,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:
@@ -1942,7 +2034,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)
# ---------------------------------------------------------------------------
@@ -1953,6 +2045,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")
@@ -1965,6 +2059,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")
@@ -2013,6 +2108,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")
@@ -2023,6 +2119,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")
@@ -2037,6 +2134,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",
@@ -2044,6 +2142,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;