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:
503
trader.py
503
trader.py
@@ -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} "
|
||||
|
||||
Reference in New Issue
Block a user