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:
325
execution.py
Normal file
325
execution.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Shared long-only order sizing and cash-funded execution for replay and trading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ENGINE_VERSION = "cash-ledger-pit-v2"
|
||||
|
||||
|
||||
def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
|
||||
return cash + sum(shares * prices.get(t, 0.0) for t, shares in holdings.items())
|
||||
|
||||
|
||||
def lot_rule(ticker: str, lot_size: int) -> tuple[int, int]:
|
||||
# STAR orders start at 200 shares, then increase by one (SSE rule 6.7).
|
||||
return (1, 200) if lot_size == 100 and ticker.startswith("688") and ticker.endswith(".SS") else (lot_size, lot_size)
|
||||
|
||||
|
||||
def market_permissions(market, day, raw: pd.Series, previous: pd.Series, volume: pd.Series | None = None):
|
||||
fresh = raw.notna() & np.isfinite(raw) & (raw > 0)
|
||||
if volume is not None:
|
||||
fresh &= volume.reindex(raw.index).gt(0)
|
||||
buy, sell = fresh.copy(), fresh.copy()
|
||||
tax = 0.0
|
||||
if market == "cn":
|
||||
limits = pd.Series(.10, index=raw.index)
|
||||
for t in raw.index:
|
||||
if str(t).startswith("688") or (str(t).startswith("30") and day >= pd.Timestamp("2020-08-24")):
|
||||
limits[t] = .20
|
||||
change = raw / previous.reindex(raw.index) - 1
|
||||
# A closing print at the limit does not establish an available fill.
|
||||
buy &= ~(change >= limits - .002)
|
||||
sell &= ~(change <= -limits + .002)
|
||||
# MOF announcement 2023-39: half-rate from 2023-08-28, sells only.
|
||||
tax = .0005 if day >= pd.Timestamp("2023-08-28") else .001
|
||||
return set(raw.index[buy]), set(raw.index[sell]), tax
|
||||
|
||||
|
||||
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)
|
||||
step, minimum = lot_rule(t, lot_size)
|
||||
if want <= 0:
|
||||
final[t] = 0.0 # full exit, odd lot included
|
||||
else:
|
||||
delta = int((want - cur) / step) * step
|
||||
final[t] = cur + (delta if abs(delta) >= minimum else 0)
|
||||
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):
|
||||
step, minimum = lot_rule(t, lot_size)
|
||||
extra = minimum if final[t] == holdings.get(t, 0) else step
|
||||
lot_value = extra * prices[t]
|
||||
if gap > 0.5 * lot_value and lot_value <= budget:
|
||||
final[t] += extra
|
||||
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}.
|
||||
"""
|
||||
weights = np.array(list(target_weights.values()), dtype=float)
|
||||
if not np.isfinite(weights).all() or (weights < 0).any() or weights.sum() > 1 + 1e-8:
|
||||
raise ValueError("Targets must be finite, long-only and cash-funded (sum <= 1)")
|
||||
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 not math.isfinite(price) or 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 and target_shares > 0:
|
||||
continue
|
||||
if abs(delta) < 1e-10:
|
||||
continue
|
||||
if integer_shares and abs(delta) < 1:
|
||||
continue
|
||||
|
||||
raw.append({
|
||||
"ticker": ticker,
|
||||
"shares_delta": int(round(delta)) if integer_shares else delta,
|
||||
"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, sell_tax: float = 0.0) -> None:
|
||||
"""Execute trades: update holdings and cash in state, append to trade_log.
|
||||
|
||||
Sells release cash before buys. Every buy must fit cash after all fees,
|
||||
including fractional-share orders.
|
||||
|
||||
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"]
|
||||
|
||||
# Sell first so buys cannot implicitly borrow against unsettled target changes.
|
||||
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
|
||||
|
||||
us_fee_per_share = fee_per_share
|
||||
for trade in ordered:
|
||||
ticker = trade["ticker"]
|
||||
delta = trade["shares_delta"]
|
||||
price = prices.get(ticker)
|
||||
if price is None or not math.isfinite(price) or price <= 0:
|
||||
continue
|
||||
if delta < 0:
|
||||
delta = -min(-delta, holdings.get(ticker, 0.0))
|
||||
if abs(delta) < 1e-10:
|
||||
continue
|
||||
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 — resize to the cash available after fees.
|
||||
if (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
|
||||
# Both branches of max(floor, base + per-share fee) must fit.
|
||||
affordable = min((cash - fixed_fee) / (price * (1 + tx_cost)),
|
||||
(cash - fee_base) / affordable_price_unit)
|
||||
if integer_shares:
|
||||
step, minimum = lot_rule(ticker, lot_size)
|
||||
affordable = max(0, math.floor(affordable / step)) * step
|
||||
if affordable < minimum:
|
||||
continue
|
||||
else:
|
||||
affordable = max(0.0, affordable * (1 - 1e-12))
|
||||
if affordable <= 1e-10:
|
||||
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
|
||||
commission += cost * sell_tax
|
||||
if commission > cash + cost:
|
||||
continue
|
||||
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 1e-10):
|
||||
del holdings[ticker]
|
||||
|
||||
state["trade_log"].append({
|
||||
"date": trade_date,
|
||||
"action": "BUY" if delta > 0 else "SELL",
|
||||
"ticker": ticker,
|
||||
"shares": abs(delta),
|
||||
"price": round(price, 6),
|
||||
"value": cost,
|
||||
"commission": commission,
|
||||
})
|
||||
|
||||
if cash < -1e-7:
|
||||
raise ValueError("Execution would create an unfunded cash balance")
|
||||
state["cash"] = max(cash, 0.0)
|
||||
if integer_shares:
|
||||
state["holdings"] = {k: int(round(v)) for k, v in holdings.items() if abs(v) >= 0.5}
|
||||
else:
|
||||
state["holdings"] = {k: v for k, v in holdings.items() if abs(v) >= 1e-10}
|
||||
|
||||
|
||||
def rebalance(state: dict, targets: dict, prices: dict, fresh: set, *,
|
||||
transaction_cost: float = 0.001, fixed_fee: float = 0.0,
|
||||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||||
min_trade_value: float = 50.0, resize_band: float = 0.10,
|
||||
integer_shares: bool = False, lot_size: int = 1,
|
||||
trade_date: str = "", can_buy: set | None = None,
|
||||
can_sell: set | None = None, sell_tax: float = 0.0) -> list[dict]:
|
||||
value = portfolio_value(state["holdings"], prices, state["cash"])
|
||||
floor = max(min_trade_value, .001 * value) if min_trade_value else 0.0
|
||||
frozen = (set(targets) | set(state["holdings"])) - fresh
|
||||
orders = compute_trades(state["holdings"], state["cash"], targets, prices,
|
||||
floor, integer_shares, lot_size, frozen, resize_band)
|
||||
orders = [o for o in orders
|
||||
if ((o["shares_delta"] > 0 and (can_buy is None or o["ticker"] in can_buy))
|
||||
or (o["shares_delta"] < 0 and (can_sell is None or o["ticker"] in can_sell)))]
|
||||
first = len(state["trade_log"])
|
||||
execute_trades(state, orders, prices, transaction_cost, fixed_fee, fee_base,
|
||||
fee_per_share, trade_date, integer_shares, lot_size, sell_tax)
|
||||
return state["trade_log"][first:]
|
||||
|
||||
|
||||
def simulate_book(targets: pd.DataFrame, prices: pd.DataFrame, capital: float,
|
||||
*, fresh_prices: pd.DataFrame | None = None,
|
||||
contributions: pd.Series | None = None,
|
||||
cash_returns: pd.Series | None = None,
|
||||
can_buy: pd.DataFrame | None = None, can_sell: pd.DataFrame | None = None,
|
||||
sell_tax: pd.Series | None = None, market: str | None = None,
|
||||
previous_prices: pd.DataFrame | None = None,
|
||||
volume: pd.DataFrame | None = None, **options):
|
||||
"""Targets are executed at their row's close; stale marks never authorize fills."""
|
||||
raw = prices if fresh_prices is None else fresh_prices.reindex_like(prices)
|
||||
marks = prices.ffill()
|
||||
previous = marks.shift(1) if previous_prices is None else previous_prices.reindex_like(prices)
|
||||
targets = targets.reindex_like(prices).fillna(0.0)
|
||||
flows = (contributions.reindex(prices.index).fillna(0.0) if contributions is not None
|
||||
else pd.Series(0.0, index=prices.index))
|
||||
rates = (cash_returns.reindex(prices.index).fillna(0.0) if cash_returns is not None
|
||||
else pd.Series(0.0, index=prices.index))
|
||||
state = {"holdings": {}, "cash": float(capital), "trade_log": []}
|
||||
equity, units, values, cash = [], [], [], []
|
||||
unit_count = float(capital)
|
||||
for i, day in enumerate(prices.index):
|
||||
quote = marks.iloc[i].dropna().to_dict()
|
||||
state["cash"] *= 1 + float(rates.iloc[i])
|
||||
before = portfolio_value(state["holdings"], quote, state["cash"])
|
||||
flow = float(flows.iloc[i])
|
||||
if flow < 0:
|
||||
raise ValueError("Only nonnegative contributions are supported")
|
||||
unit_count += flow / (before / unit_count) if before > 0 else 0.0
|
||||
state["cash"] += flow
|
||||
fresh = set(raw.columns[(raw.iloc[i].notna() & (raw.iloc[i] > 0)).to_numpy()])
|
||||
allowed_buy, allowed_sell, tax = market_permissions(market, day, raw.iloc[i], previous.iloc[i],
|
||||
volume.loc[day] if volume is not None else None)
|
||||
row = targets.iloc[i]
|
||||
rebalance(state, row[row != 0].to_dict(), quote, fresh,
|
||||
can_buy=set(can_buy.columns[can_buy.loc[day]]) if can_buy is not None else allowed_buy,
|
||||
can_sell=set(can_sell.columns[can_sell.loc[day]]) if can_sell is not None else allowed_sell,
|
||||
sell_tax=float(sell_tax.loc[day]) if sell_tax is not None else tax,
|
||||
trade_date=str(day.date()), **options)
|
||||
value = portfolio_value(state["holdings"], quote, state["cash"])
|
||||
equity.append(value)
|
||||
units.append(value / unit_count * capital)
|
||||
cash.append(state["cash"])
|
||||
values.append(before)
|
||||
return {"equity": pd.Series(equity, index=prices.index),
|
||||
"unit_equity": pd.Series(units, index=prices.index),
|
||||
"cash": pd.Series(cash, index=prices.index),
|
||||
"pretrade_equity": pd.Series(values, index=prices.index),
|
||||
"trade_log": state["trade_log"], "holdings": state["holdings"]}
|
||||
Reference in New Issue
Block a user