class PaperTrader:
"""Convert target weights into orders and simulate an auditable paper session."""
def __init__(
self,
*,
initial_capital: float = 100_000.0,
commission_bps: float = 0.0,
slippage_bps: float = 0.0,
policy: RiskPolicy | None = None,
annualization: int = 252,
) -> None:
self.initial_capital = float(initial_capital)
self.commission_bps = float(commission_bps)
self.slippage_bps = float(slippage_bps)
self.policy = policy or RiskPolicy()
self.policy.validate()
self.annualization = int(annualization)
def run(self, prices: pd.DataFrame, target_weights: pd.DataFrame) -> PaperTradingResult:
price_frame = pd.DataFrame(prices, dtype=float).sort_index()
weights = pd.DataFrame(target_weights, dtype=float).reindex(index=price_frame.index, columns=price_frame.columns).ffill().fillna(0.0)
broker = PaperBroker(
self.initial_capital,
commission_bps=self.commission_bps,
slippage_bps=self.slippage_bps,
)
positions_history: list[pd.Series] = []
realized_weight_history: list[pd.Series] = []
equity_history: list[float] = []
cash_history: list[float] = []
order_records: list[dict[str, Any]] = []
risk_records: list[dict[str, Any]] = []
peak_equity = self.initial_capital
for timestamp, current_prices in price_frame.iterrows():
current_positions = pd.Series(broker.positions(), index=price_frame.columns, dtype=float).fillna(0.0)
equity_before = float(broker.cash + (current_positions * current_prices).sum())
peak_equity = max(peak_equity, equity_before)
drawdown = 1 - equity_before / peak_equity if peak_equity > 0 else 1.0
if drawdown >= self.policy.max_drawdown:
desired_weights = pd.Series(0.0, index=price_frame.columns)
risk_records.append({"timestamp": timestamp, "event": "kill_switch", "value": drawdown})
else:
desired_weights = weights.loc[timestamp].clip(
lower=-self.policy.max_position_weight if self.policy.allow_short else 0.0,
upper=self.policy.max_position_weight,
)
gross = float(desired_weights.abs().sum())
if gross > self.policy.max_gross_leverage:
desired_weights *= self.policy.max_gross_leverage / gross
risk_records.append({"timestamp": timestamp, "event": "gross_leverage_scaled", "value": gross})
desired_notional = desired_weights * equity_before
current_notional = current_positions * current_prices
delta_notional = desired_notional - current_notional
turnover = float(delta_notional.abs().sum() / max(equity_before, 1e-12))
if turnover > self.policy.max_daily_turnover:
scale = self.policy.max_daily_turnover / turnover
delta_notional *= scale
risk_records.append({"timestamp": timestamp, "event": "turnover_scaled", "value": turnover})
ordered_deltas = sorted(delta_notional.items(), key=lambda item: float(item[1]))
for symbol, notional in ordered_deltas:
if abs(notional) < 1e-10:
continue
if self.policy.max_order_notional is not None and abs(notional) > self.policy.max_order_notional:
notional = np.sign(notional) * self.policy.max_order_notional
risk_records.append({"timestamp": timestamp, "event": "order_notional_capped", "symbol": symbol, "value": abs(delta_notional[symbol])})
if notional > 0:
available_cash = max(0.0, broker.cash - self.policy.minimum_cash)
estimated_rate = (self.commission_bps + self.slippage_bps) / 10_000.0
affordable = available_cash / max(1.0 + estimated_rate, 1e-12)
if notional > affordable:
risk_records.append({"timestamp": timestamp, "event": "cash_capped", "symbol": symbol, "value": float(notional)})
notional = affordable
if abs(notional) < 1e-10:
continue
quantity = abs(float(notional / current_prices[symbol]))
order = Order(
symbol=str(symbol),
quantity=quantity,
side=OrderSide.BUY if notional > 0 else OrderSide.SELL,
timestamp=timestamp,
)
fill = broker.submit_order(order, float(current_prices[symbol]))
record = asdict(order)
record["timestamp"] = timestamp
record["fill_price"] = fill.price if fill else np.nan
record["commission"] = fill.commission if fill else np.nan
order_records.append(record)
current_positions = pd.Series(broker.positions(), index=price_frame.columns, dtype=float).fillna(0.0)
equity = float(broker.cash + (current_positions * current_prices).sum())
realized_weights = current_positions * current_prices / max(equity, 1e-12)
positions_history.append(current_positions.rename(timestamp))
realized_weight_history.append(realized_weights.rename(timestamp))
equity_history.append(equity)
cash_history.append(broker.cash)
fills = pd.DataFrame([asdict(fill) for fill in broker.fills])
if not fills.empty:
fills = fills.set_index("timestamp")
orders = pd.DataFrame(order_records)
if not orders.empty:
orders = orders.set_index("timestamp")
risk_events = pd.DataFrame(risk_records)
if not risk_events.empty:
risk_events = risk_events.set_index("timestamp")
return PaperTradingResult(
equity=pd.Series(equity_history, index=price_frame.index, name="paper_equity"),
cash=pd.Series(cash_history, index=price_frame.index, name="cash"),
positions=pd.DataFrame(positions_history).reindex(columns=price_frame.columns),
target_weights=weights,
realized_weights=pd.DataFrame(realized_weight_history).reindex(columns=price_frame.columns),
orders=orders,
fills=fills,
risk_events=risk_events,
policy=self.policy,
metadata={
"initial_capital": self.initial_capital,
"commission_bps": self.commission_bps,
"slippage_bps": self.slippage_bps,
"annualization": self.annualization,
},
)