fix: unify backtest execution and add forward validation candidates

Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band.

Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
This commit is contained in:
2026-10-01 02:24:01 -07:00
parent 761725d2c8
commit 722cade974
45 changed files with 1561 additions and 1021 deletions

503
trader.py
View File

@@ -40,6 +40,9 @@ import pandas as pd
import yfinance as yf
import data_manager
from signals import session_targets
from execution import (ENGINE_VERSION, compute_trades, execute_trades, portfolio_value, rebalance, market_permissions,
_allocate_lots, _per_trade_commission)
import universe_history
from strategies.base import BarStrategy
from strategies.agent_review import AgentReviewStrategy
@@ -312,6 +315,54 @@ STRATEGY_REGISTRY = {
"trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10),
}
# Execution-only candidates retain their baseline signals and a separate record.
EXECUTION_VARIANTS = {
"overnight_recovery_top10_rb20": ("overnight_recovery_top10", "us"),
"us_three_sleeve_rb20": ("us_three_sleeve", "us"),
"us_momentum_qqq_trend_rb20": ("us_momentum_qqq_trend", "us"),
"lowbeta_sharpe_mom_top20_rb20": ("lowbeta_sharpe_mom_top20", "cn"),
}
def _resize_variant(factory):
def build(**kwargs):
strategy = factory(**kwargs)
strategy.execution_resize_band = .20
return strategy
return build
for _variant, (_base, _market) in EXECUTION_VARIANTS.items():
STRATEGY_REGISTRY[_variant] = _resize_variant(STRATEGY_REGISTRY[_base])
def canonical_strategy_name(name):
name = name.removeprefix("sim_")
return EXECUTION_VARIANTS.get(name, (name, None))[0]
def strategy_resize_band(name):
return .20 if name.removeprefix("sim_") in EXECUTION_VARIANTS else RESIZE_BAND
LIVE_BOOKS["validation_2026_10"] = {
"label": "修复后前瞻对照(2026-10-02 起)",
"start": {"us": "2026-10-02", "cn": "2026-10-02"},
"capital": {"us": 10_000.0, "cn": 70_000.0},
"fee_per_order": {"us": 2.0, "cn": 5.0},
"fee_schedule": {
"us": {"tx_cost": .001, "fixed_fee": 2., "fee_base": 1.88, "fee_per_share": .009},
"cn": {"tx_cost": .001, "fixed_fee": 5., "fee_base": 0., "fee_per_share": 0.},
},
"strategies": {
"us": ["overnight_recovery_top10", "overnight_recovery_top10_rb20",
"us_three_sleeve", "us_three_sleeve_rb20",
"us_momentum_qqq_trend", "us_momentum_qqq_trend_rb20"],
"cn": ["lowbeta_sharpe_mom_top20", "lowbeta_sharpe_mom_top20_rb20"],
},
}
ETF_STRATEGY_UNIVERSES = {
"trend_rider_v3_us": sorted(set(ETF_UNIVERSE)),
"trend_rider_v3_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
@@ -347,18 +398,17 @@ AGENT_REVIEW_STRATEGIES = {"overnight_recovery_agent_top10"}
# Registered strategies the live monitor does not run, with the reason.
LIVE_EXCLUDED_STRATEGIES = {
"buy_and_hold": "equal weight across ~500 names is not investable with whole "
"shares at live capital (every slice is below the minimum trade)",
"buy_and_hold": "benchmark is reported separately from strategy forward accounts",
}
DEFAULT_MONITOR_STRATEGIES = [
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES and name not in EXECUTION_VARIANTS
]
def strategy_extra_tickers(strategy_name: str) -> list[str]:
"""Fixed ETF tickers a mixed stock+ETF strategy needs beside its stock universe."""
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(strategy_name.removeprefix("sim_"), []))
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(canonical_strategy_name(strategy_name), []))
def strategy_fields(strategy_name: str) -> tuple[str, ...]:
@@ -370,6 +420,8 @@ def strategy_fields(strategy_name: str) -> tuple[str, ...]:
def strategy_supports_market(strategy_name: str, market: str) -> bool:
"""ETF-based strategies trade USD-listed ETFs, so they only run in the US book."""
base_name = strategy_name.removeprefix("sim_")
if base_name in EXECUTION_VARIANTS:
return market == EXECUTION_VARIANTS[base_name][1]
if base_name in ETF_STRATEGY_UNIVERSES or base_name in MIXED_STRATEGY_EXTRA_TICKERS \
or base_name in AGENT_REVIEW_STRATEGIES:
return market == "us"
@@ -383,7 +435,7 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
trade fixed USD/HK ETF baskets and use SPY as the regime benchmark.
Mixed strategies (e.g. V6) get the stock universe + extra ETF tickers.
"""
base_name = strategy_name.removeprefix("sim_")
base_name = canonical_strategy_name(strategy_name)
if base_name in ETF_STRATEGY_UNIVERSES:
return ETF_STRATEGY_UNIVERSES[base_name], "SPY"
@@ -400,66 +452,69 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
def index_members(market: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
"""Point-in-time index membership (``days`` x every ticker that was a member
on one of them), or None for a market without membership history."""
if market == "us":
mask = universe_history.membership_mask(days, tickers=universe_history.all_tickers_ever())
elif market == "cn":
snaps = universe_history.load_csi300_history()
mask = universe_history.csi300_membership_mask(
days, universe_history.all_csi300_tickers_ever(snaps), snaps)
else:
if market not in ("us", "cn"):
return None
return mask.loc[:, mask.any()]
def session_universes(market: str, strategy_name: str, tickers: list[str],
days: pd.DatetimeIndex) -> list[tuple[list[str], pd.DatetimeIndex]]:
"""Split ``days`` into runs of sessions that share one stock universe.
The latest session trades ``tickers`` (today's list). Earlier sessions
trade the index members of their own day: a replay or catch-up over past
sessions with today's list would buy names before they joined the index,
i.e. names that were added because they had risen. ETF strategies keep
their fixed basket.
"""
if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES or len(days) < 2:
return [(list(tickers), days)]
members = index_members(market, days[:-1])
if members is None:
return [(list(tickers), days)]
extras = set(strategy_extra_tickers(strategy_name))
runs: list[tuple[list[str], list]] = []
for day in days:
if day == days[-1]:
universe = sorted(tickers)
else:
universe = sorted(set(members.columns[members.loc[day].to_numpy()]) | extras)
if runs and runs[-1][0] == universe:
runs[-1][1].append(day)
else:
runs.append((universe, [day]))
return [(u, pd.DatetimeIndex(d)) for u, d in runs]
return universe_history.pit_membership(market, days)
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."""
days = pd.bdate_range(since, pd.Timestamp.today().normalize()) if since is not None else []
days = pd.bdate_range(since or "2000-01-01", pd.Timestamp.today().normalize())
if len(days) == 0 or strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES:
return []
members = index_members(market, days)
return [] if members is None else list(members.columns)
def make_strategy(name: str, market: str):
strategy = STRATEGY_REGISTRY[name.removeprefix("sim_")]()
if isinstance(strategy, CompositeAlphaStrategy):
strategy.benchmark = UNIVERSES[market]["benchmark"]
if isinstance(strategy, BuyAndHoldStrategy):
strategy.asset = UNIVERSES[market]["benchmark"]
return strategy
def strategy_signal_tickers(name: str, market: str) -> list[str]:
strategy = make_strategy(name, market)
if isinstance(strategy, CompositeAlphaStrategy):
return [strategy.benchmark]
return [strategy.asset] if isinstance(strategy, BuyAndHoldStrategy) else []
def session_signals(market: str, strategy_name: str, tickers: list[str], days: pd.DatetimeIndex,
close: pd.DataFrame, open_data: pd.DataFrame) -> pd.DataFrame:
"""Target weights for each day in ``days``, each computed on that day's
point-in-time universe (see ``session_universes``); columns are the union."""
parts = []
for universe, run in session_universes(market, strategy_name, tickers, days):
universe = filter_tradable_tickers(close, universe)
bars = strategy_bars(market, strategy_name, universe, close, open_data)
parts.append(_signals_for_sessions(strategy_name, open_data[universe], run, bars))
return pd.concat(parts).fillna(0.0)
close: pd.DataFrame, open_data: pd.DataFrame,
*, bars: dict | None = None, member: pd.DataFrame | None = None,
strategy=None) -> pd.DataFrame:
if len(days) == 0:
return pd.DataFrame(index=days, columns=tickers, dtype=float)
close = close.loc[:days[-1]]
benchmark = "SPY" if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES else UNIVERSES[market]["benchmark"]
if benchmark in close and benchmark in open_data:
valid_calendar = close[benchmark].notna() | open_data[benchmark].reindex(close.index).notna()
close = close.loc[valid_calendar]
if days[-1] not in close.index:
close = close.reindex(close.index.union(days))
pure_etf = strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES
if member is None and not pure_etf:
member = index_members(market, close.index)
extras = strategy_extra_tickers(strategy_name) + strategy_signal_tickers(strategy_name, market)
cols = (list(member.columns) + extras if member is not None else list(tickers) + extras)
cols = sorted(set(filter_tradable_tickers(close, cols)))
eligible = (member.reindex(index=close.index, columns=cols).fillna(False)
if member is not None else pd.DataFrame(True, index=close.index, columns=cols))
for t in extras:
if t in eligible:
eligible[t] = True
strategy = strategy if strategy is not None else make_strategy(strategy_name, market)
if bars is None:
bars = strategy_bars(market, strategy_name, cols, close, open_data)
if bars is not None:
bars = {f: b.reindex(index=close.index, columns=cols) for f, b in bars.items()}
targets = session_targets(strategy, close[cols], open_data.reindex(index=close.index, columns=cols),
bars, eligible)
return targets.reindex(days).fillna(0.0)
def strategy_data_market(market: str, strategy_name: str) -> str:
@@ -483,7 +538,7 @@ def convert_quote_currency(panel: pd.DataFrame) -> pd.DataFrame:
if not hk:
return panel
if HKD_FX_TICKER in panel.columns:
fx = panel[HKD_FX_TICKER].ffill().bfill().fillna(HKD_PEG_MIDPOINT)
fx = panel[HKD_FX_TICKER].ffill().fillna(HKD_PEG_MIDPOINT)
else:
print(f" WARNING: {HKD_FX_TICKER} not in panel; converting HK prices at "
f"the peg midpoint {HKD_PEG_MIDPOINT}", file=sys.stderr)
@@ -501,6 +556,13 @@ def price_request(market: str, tickers: list[str]) -> list[str]:
return request
def refresh_membership(market: str) -> None:
if market == "us":
universe_history.load_sp500_history()
elif market == "cn":
universe_history.load_csi300_history()
def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
benchmark: str, with_open: bool = False, refresh: bool = True):
"""Load a strategy's prices in its portfolio currency, refreshing them first
@@ -511,9 +573,10 @@ def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
stale forward-filled price from a real one.
"""
data_market = strategy_data_market(market, strategy_name)
fields = ["close", "open"] if with_open else ["close"]
fields = ["close", "open", "volume"] if with_open else ["close", "volume"]
fields += [f for f in strategy_fields(strategy_name) if f not in fields]
if refresh:
refresh_membership(market)
request = price_request(market, list(tickers) + [benchmark])
panels = data_manager.update(data_market, request, fields=fields)
else:
@@ -609,10 +672,6 @@ def init_state(market: str, strategy_name: str, capital: float) -> dict:
# Core logic
# ---------------------------------------------------------------------------
def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
stock_value = sum(holdings.get(t, 0) * prices.get(t, 0) for t in holdings)
return cash + stock_value
def strategy_bars(market: str, strategy_name: str, tickers: list[str],
close: pd.DataFrame, open_data: pd.DataFrame | None) -> dict | None:
@@ -643,192 +702,6 @@ def bar_session_weights(strategy: BarStrategy, bars: dict, days: pd.DatetimeInde
return strategy.generate_signals(hist).reindex(days).fillna(0.0)
def _allocate_lots(desired: dict, holdings: dict, prices: dict, cash: float,
lot_size: int, reserve: float) -> dict:
"""Whole-lot share targets: round each move toward zero, then spend the
remaining cash one lot at a time on the names furthest below target,
as long as the lot brings them nearer (nearest-lot rounding)."""
final, spend = {}, 0.0
for t, want in desired.items():
cur = holdings.get(t, 0.0)
if want <= 0:
final[t] = 0.0 # full exit, odd lot included
else:
final[t] = cur + int((want - cur) / lot_size) * lot_size
spend += (final[t] - cur) * prices[t]
budget = cash - spend - reserve
short = sorted(((desired[t] - final[t]) * prices[t], t) for t in desired if desired[t] > 0)
for gap, t in reversed(short):
lot_value = lot_size * prices[t]
if gap > 0.5 * lot_value and lot_value <= budget:
final[t] += lot_size
budget -= lot_value
return final
def compute_trades(holdings: dict, cash: float, target_weights: dict,
prices: dict, min_trade_value: float = 50.0,
integer_shares: bool = False, lot_size: int = 1,
frozen: set | None = None, resize_band: float = 0.0) -> list[dict]:
"""
Compute trades needed to move from current holdings to target weights.
If integer_shares=True, positions move in whole multiples of ``lot_size``
(e.g. 100-share A-share board lots): each move is rounded toward zero and
leftover cash then buys one more lot for the names furthest below target
when that lot brings them nearer; a position whose target is zero is
always sold in full, odd lot included.
A held position with a nonzero target is not resized while it is within
``resize_band`` (fraction of its target value) of the target.
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
Returns list of {ticker, shares_delta, direction, est_value}.
"""
total = portfolio_value(holdings, prices, cash)
frozen = frozen or set()
desired = {}
for ticker in sorted(set(target_weights) | set(holdings)):
if ticker in frozen:
continue
price = prices.get(ticker, 0.0)
if price <= 0:
continue
target_weight = target_weights.get(ticker, 0.0)
target_value = total * target_weight
current_shares = holdings.get(ticker, 0.0)
if (current_shares > 0 and target_weight > 0
and abs(target_value - current_shares * price) < resize_band * target_value):
continue
desired[ticker] = target_value / price
targets = (_allocate_lots(desired, holdings, prices, cash, lot_size,
reserve=0.002 * total + 10.0 * len(desired))
if integer_shares else desired)
raw = []
for ticker, target_shares in targets.items():
price = prices[ticker]
current_shares = holdings.get(ticker, 0.0)
delta = target_shares - current_shares
trade_value = abs(delta * price)
if trade_value < min_trade_value:
continue
if integer_shares and abs(delta) < 1:
continue
raw.append({
"ticker": ticker,
"shares_delta": int(round(delta)) if integer_shares else round(delta, 4),
"direction": "BUY" if delta > 0 else "SELL",
"est_value": round(trade_value, 2),
"price": round(price, 6),
"target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
"current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
})
return raw
def _per_trade_commission(
shares: float,
price: float,
tx_cost: float,
fixed_fee: float,
fee_base: float = 0.0,
fee_per_share: float = 0.0,
) -> float:
"""Commission for one trade.
Matches the IBKR-style tiered formula used by the backtest engine:
commission = bps_cost + max(fixed_fee, fee_base + fee_per_share * shares)
With fee_base=0 and fee_per_share=0 this degenerates to the flat
fixed-fee model (legacy behavior).
"""
bps_cost = abs(shares) * price * tx_cost
per_trade = fee_base + fee_per_share * abs(shares)
floor = max(fixed_fee, per_trade)
return bps_cost + floor
def execute_trades(state: dict, trades: list[dict], prices: dict,
tx_cost: float = 0.001, fixed_fee: float = 0.0,
fee_base: float = 0.0, fee_per_share: float = 0.0,
trade_date: str = "", integer_shares: bool = False,
lot_size: int = 1) -> None:
"""Execute trades: update holdings and cash in state, append to trade_log.
When integer_shares=True, sells are executed first to free up cash,
then buys are executed only if sufficient cash is available.
Per-trade commission supports both the legacy flat ``fixed_fee`` and
the IBKR-style tiered ``max(fixed_fee, fee_base + fee_per_share*shares)``
schedule used by the backtest engine.
"""
holdings = state["holdings"]
cash = state["cash"]
# When using integer shares, execute sells first to free cash for buys
sells = [t for t in trades if t["shares_delta"] < 0]
buys = [t for t in trades if t["shares_delta"] > 0]
ordered = sells + buys if integer_shares else trades
us_fee_per_share = fee_per_share
for trade in ordered:
ticker = trade["ticker"]
delta = trade["shares_delta"]
price = prices.get(ticker, trade["price"])
cost = abs(delta * price)
# The per-share schedule applies to US-listed shares only; HK lines
# (often < $1/share) pay the proportional cost and the ticket floor.
fee_per_share = 0.0 if ticker.endswith(".HK") else us_fee_per_share
commission = _per_trade_commission(
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
if delta > 0:
# BUY — skip if insufficient cash in integer mode
if integer_shares and (cost + commission) > cash:
# Try buying fewer shares that we can afford, accounting for
# the per-share variable component of the commission.
affordable_price_unit = price * (1 + tx_cost) + fee_per_share
if affordable_price_unit <= 0:
continue
affordable = int((cash - max(fixed_fee, fee_base)) / affordable_price_unit)
affordable = affordable // lot_size * lot_size
if affordable < 1:
continue
delta = affordable
cost = abs(delta * price)
commission = _per_trade_commission(
delta, price, tx_cost, fixed_fee, fee_base, fee_per_share,
)
cash -= (cost + commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta
else:
# SELL
cash += (cost - commission)
holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative
# Remove zero holdings
if ticker in holdings and abs(holdings[ticker]) < (0.5 if integer_shares else 0.001):
del holdings[ticker]
state["trade_log"].append({
"date": trade_date,
"action": "BUY" if delta > 0 else "SELL",
"ticker": ticker,
"shares": abs(delta) if integer_shares else round(abs(delta), 4),
"price": round(price, 6),
"value": round(cost, 2),
"commission": round(commission, 2),
})
state["cash"] = round(cash, 2)
if integer_shares:
state["holdings"] = {k: int(round(v)) for k, v in holdings.items() if abs(v) >= 0.5}
else:
state["holdings"] = {k: round(v, 4) for k, v in holdings.items() if abs(v) >= 0.001}
def record_daily_snapshot(state: dict, date_str: str, prices: dict,
trades: list[dict], prev_equity: float) -> None:
@@ -865,6 +738,7 @@ def record_daily_snapshot(state: dict, date_str: str, prices: dict,
})
entry = {
"engine_version": state.get("engine_version"),
"date": date_str,
"equity": round(total, 2),
"cash": round(cash, 2),
@@ -927,6 +801,8 @@ def _book_capital(args, market: str) -> float:
def _strategy_fees(args, market: str) -> dict:
book = getattr(args, "book", None)
if book:
if "fee_schedule" in LIVE_BOOKS[book]:
return dict(LIVE_BOOKS[book]["fee_schedule"][market])
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, {})
@@ -949,18 +825,19 @@ def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
depend on data after t), so one pass equals running each day separately.
"""
strategy = STRATEGY_REGISTRY[strategy_name]()
if isinstance(strategy, BarStrategy):
return bar_session_weights(strategy, bars, days)
hist = open_data.loc[:days[-1]]
dummy = pd.DataFrame([hist.iloc[-1].values], index=[hist.index[-1] + pd.Timedelta(days=1)],
columns=hist.columns)
weights = strategy.generate_signals(pd.concat([hist, dummy]))
return weights.shift(-1).reindex(days).fillna(0.0)
close = bars["close"].loc[:days[-1]] if bars is not None else hist
if days[-1] not in close.index:
close = close.reindex(close.index.union(days))
if bars is not None:
bars = {f: b.reindex(close.index) for f, b in bars.items()}
return session_targets(strategy, close, hist.reindex(close.index), bars).reindex(days).fillna(0.0)
def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
close_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
integer_shares: bool, signal_row: pd.Series) -> dict:
integer_shares: bool, signal_row: pd.Series,
volume_row: pd.Series | None = None) -> dict:
"""Execute ``signal_row`` (targets from ``day``'s open; a BarStrategy: bars
through the previous session, see ``session_signals``) at ``day``'s close
and record the day.
@@ -969,6 +846,10 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
valued but not traded.
"""
day_str = str(day.date())
if state.get("engine_version") != ENGINE_VERSION:
state.setdefault("engine_changes", []).append({"first_session": day_str, "version": ENGINE_VERSION,
"recorded_at": datetime.now(timezone.utc).isoformat()})
state["engine_version"] = ENGINE_VERSION
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
@@ -980,15 +861,18 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
frozen = {t for t in all_held if t not in raw_row.index or pd.isna(raw_row[t])}
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
trades = compute_trades(
state["holdings"], state["cash"], target_weights, close_prices,
min_trade_value=max(50, pre_value * 0.001),
integer_shares=integer_shares, lot_size=lot_size, frozen=frozen,
resize_band=RESIZE_BAND,
)
previous_rows = close_data.loc[close_data.index < day]
previous = previous_rows.iloc[-1] if len(previous_rows) else pd.Series(dtype=float)
allowed_buy, allowed_sell, sell_tax = market_permissions(market, day, raw_row, previous, volume_row)
n_log = len(state["trade_log"])
execute_trades(state, trades, close_prices, trade_date=day_str,
integer_shares=integer_shares, lot_size=lot_size, **fees)
actual = rebalance(state, target_weights, close_prices, set(raw_row.dropna().index),
transaction_cost=fees["tx_cost"], fixed_fee=fees["fixed_fee"],
fee_base=fees.get("fee_base", 0), fee_per_share=fees.get("fee_per_share", 0),
integer_shares=integer_shares, lot_size=lot_size,
trade_date=day_str, resize_band=strategy_resize_band(state["strategy"]),
can_buy=allowed_buy, can_sell=allowed_sell, sell_tax=sell_tax)
trades = [{**t, "direction": t["action"], "est_value": t["value"],
"shares_delta": t["shares"] * (1 if t["action"] == "BUY" else -1)} for t in actual]
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
previous = [d for d in sorted(state["daily_equity"]) if d < day_str]
@@ -1031,7 +915,7 @@ def cmd_morning(args):
print(f"--- Initialized new portfolio: {capital:,.0f} cash ---")
# Download data (close + open)
close_data, open_data, _ = load_strategy_prices(
close_data, open_data, close_raw = load_strategy_prices(
market, strategy_name, tickers, benchmark, with_open=True,
refresh=getattr(args, "refresh", True),
)
@@ -1053,9 +937,8 @@ def cmd_morning(args):
print(f"{'='*60}")
# Run strategy on open prices (a BarStrategy: on completed bars)
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
row = _signals_for_sessions(strategy_name, open_data[tickers],
pd.DatetimeIndex([today]), bars).loc[today]
row = session_signals(market, strategy_name, tickers, pd.DatetimeIndex([today]),
close_raw, open_data).loc[today]
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
# Get today's open prices for trade computation
@@ -1069,7 +952,7 @@ def cmd_morning(args):
open_prices, min_trade_value=max(50, total * 0.001),
integer_shares=args.integer_shares,
lot_size=MARKET_LOT_SIZE.get(market, 1),
resize_band=RESIZE_BAND)
resize_band=strategy_resize_band(strategy_name))
# Store pending
state["pending_trades"] = {
@@ -1140,7 +1023,7 @@ def cmd_evening(args):
return
# Get close prices
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
tickers = filter_tradable_tickers(close_data, tickers)
target_date = pd.Timestamp(trade_date)
@@ -1166,24 +1049,16 @@ def cmd_evening(args):
# Recompute trades with close prices for execution
target_weights = pending.get("target_weights", {})
lot_size = MARKET_LOT_SIZE.get(market, 1)
exec_trades = compute_trades(
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares, lot_size=lot_size,
)
execute_trades(state, exec_trades, close_prices, trade_date=trade_date,
integer_shares=args.integer_shares, lot_size=lot_size,
**_strategy_fees(args, market))
first_log = len(state["trade_log"])
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
_run_session(state, market, target_date, benchmark, close_data, close_raw,
_strategy_fees(args, market), args.integer_shares,
pd.Series(target_weights, dtype=float),
volume_row=volume.reindex(close_data.index).loc[target_date] if volume is not None else None)
exec_trades = [{**t, "direction": t["action"], "est_value": t["value"]}
for t in state["trade_log"][first_log:]]
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][trade_date] = round(post_value, 2)
# Record daily snapshot so daily_log stays complete even on no-trade days
eq_vals = list(state["daily_equity"].values())
prev_eq = eq_vals[-2] if len(eq_vals) >= 2 else state["initial_capital"]
record_daily_snapshot(state, trade_date, close_prices, exec_trades, prev_eq)
state["pending_trades"] = None
state["last_evening"] = trade_date
save_state(state, market, strategy_name, book)
@@ -1225,7 +1100,7 @@ def cmd_status(args):
# Get latest prices
tickers, benchmark = strategy_universe(market, strategy_name)
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
last_date = close_data.index[-1]
all_held = list(state["holdings"].keys())
@@ -1536,13 +1411,14 @@ def cmd_simulate(args):
# Load both open and close data
request = sorted(set(tickers) | set(pit_candidates(market, strategy_name, start)))
close_data, open_data, _ = load_strategy_prices(
close_data, open_data, close_raw = load_strategy_prices(
market, strategy_name, request, benchmark, with_open=True,
)
tickers = filter_tradable_tickers(close_data, tickers)
# Date range
trading_days = close_data.index[(close_data.index >= start) & (close_data.index <= end)]
sessions = completed_sessions(close_raw, strategy_data_market(market, strategy_name), benchmark)
trading_days = sessions[(sessions >= start) & (sessions <= end)]
if len(trading_days) == 0:
print(f"No trading days found between {args.start} and {args.end}")
@@ -1554,7 +1430,7 @@ def cmd_simulate(args):
# Pre-compute strategy weights for the full period (much faster than per-day),
# each day on its point-in-time index members
full_weights = session_signals(market, strategy_name, tickers, trading_days,
close_data, open_data)
close_raw, open_data)
print(f"\n{'='*70}")
print(f" SIMULATION: {strategy_name} | {market.upper()}")
@@ -1566,52 +1442,16 @@ def cmd_simulate(args):
total_trades = 0
total_commission = 0.0
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
for i, day in enumerate(trading_days):
day_str = str(day.date())
# Morning: get target weights from pre-computed matrix
if day in full_weights.index:
row = full_weights.loc[day]
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
else:
target_weights = {}
# Get open and close prices
all_held = list(set(list(target_weights.keys()) + list(state["holdings"].keys())))
open_prices = get_prices_for_date(all_held, day, open_data)
close_prices = get_prices_for_date(all_held + [benchmark], day, close_data)
if not close_prices:
continue
# Compute portfolio value before trades (at close, reflecting overnight changes)
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
# Compute and execute trades at close prices
lot_size = MARKET_LOT_SIZE.get(market, 1)
trades = compute_trades(
state["holdings"], state["cash"], target_weights,
close_prices, min_trade_value=max(50, pre_value * 0.001),
integer_shares=args.integer_shares, lot_size=lot_size,
resize_band=RESIZE_BAND,
)
execute_trades(state, trades, close_prices,
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
trade_date=day_str, integer_shares=args.integer_shares,
lot_size=lot_size)
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
state["daily_equity"][day_str] = round(post_value, 2)
# Record daily snapshot (holdings + operations)
prev_eq_val = args.capital if i == 0 else list(state["daily_equity"].values())[-2] if len(state["daily_equity"]) >= 2 else args.capital
record_daily_snapshot(state, day_str, close_prices, trades, prev_eq_val)
day_trades = len(trades)
day_commission = sum(
t.get("commission", 0) for t in state["trade_log"][-day_trades:]
) if day_trades else 0
stats = _run_session(state, market, day, benchmark, close_data, close_raw,
_strategy_fees(args, market), args.integer_shares,
full_weights.loc[day],
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
post_value = stats["value"]
day_trades, day_commission = stats["trades"], stats["commission"]
total_trades += day_trades
total_commission += day_commission
@@ -1935,6 +1775,7 @@ def cmd_monitor(args):
def refresh_panels(market: str, strategy_names: list[str], label: str) -> None:
"""Refresh every price panel the strategies read, once, for the union of their tickers."""
refresh_membership(market)
requests: dict[str, set[str]] = {}
fields: dict[str, set[str]] = {}
for name in strategy_names:
@@ -2001,10 +1842,12 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
f"{todo[0].date()} .. {todo[-1].date()}")
fees = _strategy_fees(args, market)
signals = session_signals(market, strategy_name, tickers, todo, close_data, open_data)
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
signals = session_signals(market, strategy_name, tickers, todo, close_raw, open_data)
for day in todo:
r = _run_session(state, market, day, benchmark, close_data, close_raw, fees,
args.integer_shares, signal_row=signals.loc[day])
args.integer_shares, signal_row=signals.loc[day],
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "