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

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()