diff --git a/CLAUDE.md b/CLAUDE.md index 5d1f471..025648e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ uv run python -m unittest discover tests `precompute_backtest.py` reruns the ledger for cash-injection scenarios; metrics use unit NAV after actual fees. The API and `web/src/shared/strategyTypes.ts` share this cash-flow contract. `ENGINE_VERSION` in `execution.py` identifies the execution protocol; API cache keys also include its version. -**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`), the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`, `lowrisk_top20`, the US sleeve portfolios `us_three_sleeve` and `us_momentum_qqq_trend`, and the agent-reviewed `overnight_recovery_agent_top10` (US only, forward only). +**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. Each `daily_log` entry also records the session `target` and `ideal_return` (what the previous target earned to this close with fractional shares, no fees, no resize band and no idle cash; recorded from 2026-10-13); `compare` prints `Ideal` and `Gap` (live minus ideal) over those days, separating the strategy from $10k whole-share and fee noise. 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`), the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`, `lowrisk_top20`, the US sleeve portfolios `us_three_sleeve` and `us_momentum_qqq_trend`, and the agent-reviewed `overnight_recovery_agent_top10` (US only, forward only). **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` (30 strategies, US $10,000 / CN ¥100,000, $2 / ¥10 per order) starts on 2026-09-25 (US) and 2026-09-28 (CN); `overnight_recovery_agent_top10` joined it on 2026-09-30 and starts with the book. diff --git a/deploy/systemd/quant-trading-monitor.service b/deploy/systemd/quant-trading-monitor.service index aaf099a..6c0f4e9 100644 --- a/deploy/systemd/quant-trading-monitor.service +++ b/deploy/systemd/quant-trading-monitor.service @@ -7,6 +7,9 @@ Type=simple WorkingDirectory=/home/gahow/projects/quant-trading ExecStart=/home/gahow/projects/quant-trading/deploy/start-monitor.sh Restart=always +# SIGTERM from a stop/restart ends the Python process with 143; count it as a +# clean stop so 'Failed' in the journal means a real crash. +SuccessExitStatus=143 RestartSec=10 Environment=PYTHONUNBUFFERED=1 diff --git a/deploy/systemd/quant-trading-overnight-paper.service b/deploy/systemd/quant-trading-overnight-paper.service index 6277850..b5eeee1 100644 --- a/deploy/systemd/quant-trading-overnight-paper.service +++ b/deploy/systemd/quant-trading-overnight-paper.service @@ -7,6 +7,9 @@ Type=simple WorkingDirectory=/home/gahow/projects/quant-trading ExecStart=/home/gahow/projects/quant-trading/deploy/start-overnight-paper.sh Restart=always +# SIGTERM from a stop/restart ends the Python process with 143; count it as a +# clean stop so 'Failed' in the journal means a real crash. +SuccessExitStatus=143 RestartSec=30 Environment=PYTHONUNBUFFERED=1 diff --git a/tests/test_execution_contract.py b/tests/test_execution_contract.py index 518c2be..2aeed2f 100644 --- a/tests/test_execution_contract.py +++ b/tests/test_execution_contract.py @@ -52,6 +52,26 @@ class ExecutionContracts(unittest.TestCase): self.assertAlmostEqual(state["daily_equity"][str(day.date())], batch["equity"].loc[day], places=2) self.assertEqual(state["trade_log"], batch["trade_log"]) + def test_recorded_ideal_return_compounds_to_the_frictionless_ledger(self): + dates = pd.bdate_range("2026-01-05", periods=6) + prices = pd.DataFrame({"A": [100, 110, 104, 70, 90, 95], "B": [20, 21, 22, 22, 23, 21.5]}, index=dates) + targets = pd.DataFrame([[.5, .5], [.5, .5], [0, .9], [0, 1], [.6, .4], [.6, .4]], + index=dates, columns=prices.columns) + ideal = simulate_book(targets, prices, 10000, transaction_cost=0, fixed_fee=0, integer_shares=False, + resize_band=0, min_trade_value=0) + state = trader.init_state("us", "test", 10000) + for day in dates: # live-like: whole shares, fees, the 10% band + trader._run_session(state, "us", day, "B", prices, prices, {"tx_cost": .001, "fixed_fee": 2.}, + True, targets.loc[day]) + log = state["daily_log"] + self.assertNotIn("ideal_return", log[0]) + self.assertEqual(log[2]["target"], {"B": 0.9}) + growth = np.cumprod([1 + e["ideal_return"] for e in log[1:]]) + np.testing.assert_allclose(growth, ideal["equity"].iloc[1:] / ideal["equity"].iloc[0], rtol=1e-6) + window = trader._ideal_window(state) + self.assertEqual(window["start"], str(dates[0].date())) + self.assertAlmostEqual(window["ideal"], growth[-1] - 1) + def test_a_delisted_stock_is_settled_at_its_last_print_in_both_ledgers(self): dates = pd.bdate_range("2026-01-05", periods=9) # A is acquired: last print on day 2, out of the index from day 4. diff --git a/trader.py b/trader.py index 8a1c468..2af0a92 100644 --- a/trader.py +++ b/trader.py @@ -734,11 +734,15 @@ def bar_session_weights(strategy: BarStrategy, bars: dict, days: pd.DatetimeInde def record_daily_snapshot(state: dict, date_str: str, prices: dict, - trades: list[dict], prev_equity: float) -> None: + trades: list[dict], prev_equity: float, + target: dict | None = None, ideal_return: float | None = None) -> None: """Append a daily snapshot to state['daily_log']. Each entry captures the full picture: date, holdings with prices/values, - cash, total equity, daily return, and today's operations. + cash, total equity, daily return, and today's operations. ``target`` is the + weight the strategy asked to hold from this close, and ``ideal_return`` what + the previous entry's target earned to this close without whole shares, fees, + the resize band or idle cash (see ``_ideal_return``). """ holdings = state["holdings"] cash = state["cash"] @@ -778,6 +782,10 @@ def record_daily_snapshot(state: dict, date_str: str, prices: dict, "holdings": positions, "operations": operations, } + if target is not None: + entry["target"] = {t: round(float(w), 6) for t, w in sorted(target.items())} + if ideal_return is not None: + entry["ideal_return"] = round(float(ideal_return), 8) if "daily_log" not in state: state["daily_log"] = [] @@ -797,6 +805,33 @@ def get_prices_for_date(tickers: list[str], date_idx, price_df: pd.DataFrame) -> return {t: float(row[t]) for t in tickers if t in row.index and pd.notna(row[t])} +def _ideal_return(prev_entry: dict | None, day: pd.Timestamp, close_data: pd.DataFrame) -> float | None: + """Return of ``prev_entry``'s target weights from its close to ``day``'s + close: the strategy's own weights held frictionlessly (fractional shares, + no fees, no resize band; the unallocated part earns nothing). A name + without a price on either date contributes 0. None when the previous entry + recorded no target (state written before targets were recorded).""" + if not prev_entry or "target" not in prev_entry or prev_entry["date"] >= str(day.date()): + return None + w = prev_entry["target"] + if not w: + return 0.0 + p0 = get_prices_for_date(list(w), pd.Timestamp(prev_entry["date"]), close_data) + p1 = get_prices_for_date(list(w), day, close_data) + return float(sum(x * (p1[t] / p0[t] - 1) for t, x in w.items() if t in p0 and t in p1 and p0[t] > 0)) + + +def _ideal_window(state: dict) -> dict | None: + """Live vs ideal over the days whose ``ideal_return`` is recorded.""" + log = state.get("daily_log") or [] + k = next((i for i, e in enumerate(log) if e.get("ideal_return") is not None), None) + if k is None or k == 0: + return None + ideal = float(np.prod([1 + e.get("ideal_return", 0.0) for e in log[k:]]) - 1) + live = log[-1]["equity"] / log[k - 1]["equity"] - 1 + return {"start": log[k - 1]["date"], "live": live, "ideal": ideal, "gap": live - ideal} + + def _with_last_known_prices(state: dict, prices: dict) -> dict: """Fill prices of held tickers missing from the panel with their last recorded price. @@ -923,7 +958,9 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str, previous = [d for d in sorted(state["daily_equity"]) if d < day_str] prev_eq = state["daily_equity"][previous[-1]] if previous else state["initial_capital"] state["daily_equity"][day_str] = round(post_value, 2) - record_daily_snapshot(state, day_str, close_prices, trades, prev_eq) + prev_entry = state["daily_log"][-1] if state.get("daily_log") else None + record_daily_snapshot(state, day_str, close_prices, trades, prev_eq, target=target_weights, + ideal_return=_ideal_return(prev_entry, day, close_data)) state["last_morning"] = day_str state["last_evening"] = day_str state["pending_trades"] = None @@ -1317,6 +1354,7 @@ def cmd_compare(args): "n_days": n_days, "n_positions": len(state.get("holdings", {})), "cash": state.get("cash", 0), + "ideal": _ideal_window(state), }) # Sort by total return descending @@ -1324,13 +1362,22 @@ def cmd_compare(args): # Print ranking print(f"\n {'#':<3} {'Strategy':<25} {'Return':>9} {'Ann.Ret':>9} " - f"{'Sharpe':>8} {'MaxDD':>8} {'Trades':>7} {'Final':>14}") - print(f" {'─'*87}") + f"{'Sharpe':>8} {'MaxDD':>8} {'Trades':>7} {'Final':>14} {'Ideal':>8} {'Gap':>7}") + print(f" {'─'*104}") for i, r in enumerate(results, 1): + iw = r["ideal"] + ideal = f"{iw['ideal'] * 100:>+7.1f}% {iw['gap'] * 100:>+6.1f}pp" if iw else f"{'—':>8} {'—':>7}" print(f" {i:<3} {r['name']:<25} {r['total_ret']:>+8.1f}% {r['ann_ret']:>+8.1f}% " f"{r['sharpe']:>8.2f} {r['max_dd']:>7.1f}% {r['n_trades']:>7} " - f"${r['final']:>12,.2f}") + f"${r['final']:>12,.2f} {ideal}") + starts = sorted({r["ideal"]["start"] for r in results if r["ideal"]}) + if starts: + print(f" Ideal: the strategy's own target weights held without whole shares, fees, the resize band " + f"or idle cash, from the close of {starts[0]}" + (f" (latest start {starts[-1]})" if len(starts) > 1 else "") + + ". Gap: live return minus ideal over the same days.") + else: + print(" Ideal: not recorded yet (state files record session targets from the next session on).") # Equity curve comparison (last 20 dates) recent_dates = all_dates[-20:]