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:
@@ -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**:
|
||||
|
||||
30
bridge.py
30
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)
|
||||
|
||||
@@ -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
173
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/<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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user