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:
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user