diff --git a/CLAUDE.md b/CLAUDE.md index 9a5b649..150d10b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`. 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**: diff --git a/bridge.py b/bridge.py index eacb964..f42e946 100644 --- a/bridge.py +++ b/bridge.py @@ -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) diff --git a/tests/test_live_pipeline.py b/tests/test_live_pipeline.py index 0a30743..ea990ab 100644 --- a/tests/test_live_pipeline.py +++ b/tests/test_live_pipeline.py @@ -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") diff --git a/trader.py b/trader.py index e4db54d..972dc56 100644 --- a/trader.py +++ b/trader.py @@ -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//. 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() diff --git a/web/src/client/LivePage.tsx b/web/src/client/LivePage.tsx index 01f05e2..0bda6ee 100644 --- a/web/src/client/LivePage.tsx +++ b/web/src/client/LivePage.tsx @@ -16,6 +16,7 @@ import { formatNumber, formatPct, type EquityPoint, + type LiveBook, type LiveStrategyState, type StrategyCurrency, type StrategyMarket, @@ -40,8 +41,29 @@ async function api(path: string, init?: RequestInit): Promise { 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("us"); + const [books, setBooks] = useState([]); + // null until the book list is known; "" is the original book + const [book, setBook] = useState(null); const [strategies, setStrategies] = useState([]); const [selectedName, setSelectedName] = useState(null); const [loading, setLoading] = useState(true); @@ -49,10 +71,29 @@ export function LivePage() { const [sortKey, setSortKey] = useState("unrealizedPnlPct"); const [filterText, setFilterText] = useState(""); - const refresh = (mk: StrategyMarket = market) => { + useEffect(() => { + api("/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(`/api/strategies/live?market=${mk}`) + api(`/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() { + {books.length > 1 && ( + + )} 加载中...

) : sorted.length === 0 ? ( -

暂无在线策略。运行 quant 的 trader.py auto 后再刷新。

+

{emptyHint(currentBook, market)}

) : (
{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); diff --git a/web/src/server/index.ts b/web/src/server/index.ts index 9594b6e..37a44c6 100644 --- a/web/src/server/index.ts +++ b/web/src/server/index.ts @@ -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); } diff --git a/web/src/server/strategyService.ts b/web/src/server/strategyService.ts index 76b410f..27d37e0 100644 --- a/web/src/server/strategyService.ts +++ b/web/src/server/strategyService.ts @@ -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 { +export async function getLiveStrategies(market: string, strategy?: string, book?: string): Promise { 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 { + return (await runBridge(["books"])) as LiveBook[]; +} + function normalizeBacktestResult(result: BacktestResult): BacktestResult { return { ...result, diff --git a/web/src/shared/strategyTypes.ts b/web/src/shared/strategyTypes.ts index acd3ba6..50ec99a 100644 --- a/web/src/shared/strategyTypes.ts +++ b/web/src/shared/strategyTypes.ts @@ -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 | null; + capital: Record; + // Flat fee per order; null for the original book's tiered schedule. + feePerOrder: Record | null; + strategies: Record; +} + export interface LiveStrategyState { name: string; label: string;