""" Paper trading bridge — event-driven bar-by-bar simulation. PaperTrader Simulates live order execution using the same logic as the backtester, but processes one bar at a time. Maintains live state (position, equity, trades). Usage: from ferro_ta.analysis.live import PaperTrader trader = PaperTrader(initial_capital=100_000) for bar in streaming_bars: signal = my_strategy(bar) result = trader.on_bar( open_=bar.open, high=bar.high, low=bar.low, close=bar.close, signal=signal ) if result.filled: print(f"Order filled at {result.fill_price}") """ from __future__ import annotations import math from dataclasses import dataclass from typing import Optional @dataclass class BarResult: """Result of processing one bar through PaperTrader.""" bar_index: int filled: bool # whether an order was executed this bar fill_price: float # NaN if no fill position: float # position after this bar equity: float # equity after this bar (normalized, initial = 1.0) equity_abs: float # absolute equity in currency units pnl_bar: float # P&L this bar as fraction of initial capital regime: Optional[int] = None # regime label if regime detection is enabled @dataclass class TradeRecord: """Record of a completed round-trip trade.""" entry_bar: int exit_bar: int entry_price: float exit_price: float position: float # +1 long, -1 short pnl_pct: float # P&L as fraction of initial capital pnl_abs: float # P&L in currency units class PaperTrader: """Event-driven paper trading simulator. Processes bars one at a time, maintaining live state. Supports stop-loss, take-profit, trailing stop, and breakeven stop. Parameters ---------- initial_capital : float Starting capital in base currency. stop_loss_pct : float Stop-loss distance from entry (fraction). 0 = disabled. take_profit_pct : float Take-profit distance from entry (fraction). 0 = disabled. trailing_stop_pct : float Trailing stop distance (fraction). 0 = disabled. breakeven_pct : float Move stop to breakeven when this profit is reached. 0 = disabled. slippage_bps : float Slippage in basis points per fill. commission_model : optional CommissionModel Full commission model. None = zero commission. """ def __init__( self, initial_capital: float = 100_000.0, stop_loss_pct: float = 0.0, take_profit_pct: float = 0.0, trailing_stop_pct: float = 0.0, breakeven_pct: float = 0.0, slippage_bps: float = 0.0, commission_model=None, ) -> None: self.initial_capital = float(initial_capital) self.stop_loss_pct = float(stop_loss_pct) self.take_profit_pct = float(take_profit_pct) self.trailing_stop_pct = float(trailing_stop_pct) self.breakeven_pct = float(breakeven_pct) self.slippage_bps = float(slippage_bps) self.commission_model = commission_model # Live state self._position: float = 0.0 self._entry_price: float = float("nan") self._equity: float = 1.0 # normalized self._prev_close: float = float("nan") self._bar_index: int = 0 self._trail_high: float = float("nan") self._trail_low: float = float("nan") self._breakeven_activated: bool = False self._breakeven_stop: float = float("nan") self._trades: list[TradeRecord] = [] self._equity_history: list[float] = [] # One-bar-lag signal state self._pending_signal: float = 0.0 self._first_bar: bool = True def _close_position(self) -> None: """Reset all trade-tracking state to flat (mirrors Rust OhlcvState.close_position).""" self._position = 0.0 self._entry_price = float("nan") self._trail_high = float("nan") self._trail_low = float("nan") self._breakeven_activated = False self._breakeven_stop = float("nan") def _commission_cost(self, fill_price: float, pos_size: float) -> float: """Compute commission cost as fraction of initial capital.""" if self.commission_model is None: return 0.0 try: trade_value = abs(pos_size) * fill_price * self.initial_capital if hasattr(self.commission_model, "cost_fraction"): return self.commission_model.cost_fraction( trade_value, 1.0, pos_size > 0, self.initial_capital ) except Exception: pass return 0.0 def on_bar( self, open_: float, high: float, low: float, close: float, signal: float, ) -> BarResult: """Process one bar and return a BarResult. signal : float Desired position (+1, -1, or 0). Applied next bar (standard bar-by-bar logic). For this bar, the signal from the PREVIOUS bar is acted upon. """ nan = float("nan") slip = self.slippage_bps / 10_000.0 bar_idx = self._bar_index self._bar_index += 1 # On the very first bar: record signal, no action (no prev signal yet) if self._first_bar: self._pending_signal = signal self._first_bar = False self._prev_close = close self._equity_history.append(self._equity) return BarResult( bar_index=bar_idx, filled=False, fill_price=nan, position=self._position, equity=self._equity, equity_abs=self._equity * self.initial_capital, pnl_bar=0.0, ) # The signal to act on this bar is from the previous call desired_pos = ( self._pending_signal if not math.isnan(self._pending_signal) else 0.0 ) # Store current bar's signal for next bar self._pending_signal = signal prev_close = self._prev_close self._prev_close = close strategy_return = 0.0 fill_price_this_bar = nan filled = False forced_close = False # ---- Update trailing stop water marks ---- if self.trailing_stop_pct > 0.0: if self._position > 0.0 and not math.isnan(self._trail_high): self._trail_high = max(self._trail_high, high) if self._position < 0.0 and not math.isnan(self._trail_low): self._trail_low = min(self._trail_low, low) close_ret = (close - prev_close) / prev_close if prev_close != 0.0 else 0.0 # ---- Trailing stop check ---- if ( self.trailing_stop_pct > 0.0 and self._position != 0.0 and not math.isnan(self._entry_price) ): if self._position > 0.0 and not math.isnan(self._trail_high): trail_stop = self._trail_high * (1.0 - self.trailing_stop_pct) if low <= trail_stop: stop_ret = ( (trail_stop - prev_close) / prev_close if prev_close != 0.0 else -self.trailing_stop_pct ) comm = self._commission_cost(trail_stop, self._position) strategy_return = self._position * stop_ret - slip - comm fill_price_this_bar = trail_stop filled = True self._record_trade(bar_idx, trail_stop) self._close_position() forced_close = True elif self._position < 0.0 and not math.isnan(self._trail_low): trail_stop = self._trail_low * (1.0 + self.trailing_stop_pct) if high >= trail_stop: stop_ret = ( (trail_stop - prev_close) / prev_close if prev_close != 0.0 else self.trailing_stop_pct ) comm = self._commission_cost(trail_stop, self._position) strategy_return = self._position * stop_ret - slip - comm fill_price_this_bar = trail_stop filled = True self._record_trade(bar_idx, trail_stop) self._close_position() forced_close = True # ---- Breakeven stop activation ---- if ( self.breakeven_pct > 0.0 and self._position != 0.0 and not math.isnan(self._entry_price) and not self._breakeven_activated ): if self._position > 0.0 and high >= self._entry_price * ( 1.0 + self.breakeven_pct ): self._breakeven_activated = True self._breakeven_stop = self._entry_price elif self._position < 0.0 and low <= self._entry_price * ( 1.0 - self.breakeven_pct ): self._breakeven_activated = True self._breakeven_stop = self._entry_price # ---- SL/TP combined bracket check ---- if ( not forced_close and self._position != 0.0 and not math.isnan(self._entry_price) ): entry = self._entry_price has_stop = self._breakeven_activated or self.stop_loss_pct > 0.0 stop_long = ( self._breakeven_stop if self._breakeven_activated else entry * (1.0 - self.stop_loss_pct) ) stop_short = ( self._breakeven_stop if self._breakeven_activated else entry * (1.0 + self.stop_loss_pct) ) has_tp = self.take_profit_pct > 0.0 tp_long = entry * (1.0 + self.take_profit_pct) tp_short = entry * (1.0 - self.take_profit_pct) if self._position > 0.0: sl_triggered = has_stop and low <= stop_long tp_triggered = has_tp and high >= tp_long if sl_triggered and tp_triggered: sl_dist = abs(open_ - stop_long) tp_dist = abs(tp_long - open_) if sl_dist <= tp_dist: # SL first sr = ( (stop_long - prev_close) / prev_close if prev_close != 0.0 else -self.stop_loss_pct ) comm = self._commission_cost(stop_long, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = stop_long else: sr = ( (tp_long - prev_close) / prev_close if prev_close != 0.0 else self.take_profit_pct ) comm = self._commission_cost(tp_long, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = tp_long filled = True self._record_trade(bar_idx, fill_price_this_bar) self._close_position() forced_close = True elif sl_triggered: sr = ( (stop_long - prev_close) / prev_close if prev_close != 0.0 else -self.stop_loss_pct ) comm = self._commission_cost(stop_long, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = stop_long filled = True self._record_trade(bar_idx, stop_long) self._close_position() forced_close = True elif tp_triggered: sr = ( (tp_long - prev_close) / prev_close if prev_close != 0.0 else self.take_profit_pct ) comm = self._commission_cost(tp_long, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = tp_long filled = True self._record_trade(bar_idx, tp_long) self._close_position() forced_close = True elif self._position < 0.0: sl_triggered = has_stop and high >= stop_short tp_triggered = has_tp and low <= tp_short if sl_triggered and tp_triggered: sl_dist = abs(stop_short - open_) tp_dist = abs(open_ - tp_short) if sl_dist <= tp_dist: sr = ( (stop_short - prev_close) / prev_close if prev_close != 0.0 else self.stop_loss_pct ) comm = self._commission_cost(stop_short, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = stop_short else: sr = ( (tp_short - prev_close) / prev_close if prev_close != 0.0 else -self.take_profit_pct ) comm = self._commission_cost(tp_short, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = tp_short filled = True self._record_trade(bar_idx, fill_price_this_bar) self._close_position() forced_close = True elif sl_triggered: sr = ( (stop_short - prev_close) / prev_close if prev_close != 0.0 else self.stop_loss_pct ) comm = self._commission_cost(stop_short, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = stop_short filled = True self._record_trade(bar_idx, stop_short) self._close_position() forced_close = True elif tp_triggered: sr = ( (tp_short - prev_close) / prev_close if prev_close != 0.0 else -self.take_profit_pct ) comm = self._commission_cost(tp_short, self._position) strategy_return = self._position * sr - slip - comm fill_price_this_bar = tp_short filled = True self._record_trade(bar_idx, tp_short) self._close_position() forced_close = True # ---- Normal signal execution ---- if not forced_close: pos_changed = abs(desired_pos - self._position) > 1e-12 # Fill at open (market_open mode, same as Rust default) base_fill = open_ if desired_pos > self._position: actual_fill = base_fill * (1.0 + slip) elif desired_pos < self._position: actual_fill = base_fill * (1.0 - slip) else: actual_fill = base_fill if pos_changed: fill_price_this_bar = actual_fill filled = True old_pos = self._position if desired_pos != 0.0 and old_pos == 0.0: r = ( desired_pos * (close - actual_fill) / actual_fill if actual_fill != 0.0 else 0.0 ) comm = self._commission_cost(actual_fill, desired_pos) strategy_return = r - comm self._set_entry(bar_idx, actual_fill, desired_pos) elif desired_pos == 0.0: r = ( old_pos * (actual_fill - prev_close) / prev_close if prev_close != 0.0 else 0.0 ) comm = self._commission_cost(actual_fill, old_pos) strategy_return = r - comm self._record_trade(bar_idx, actual_fill) self._close_position() else: exit_r = ( old_pos * (actual_fill - prev_close) / prev_close if prev_close != 0.0 else 0.0 ) entry_r = ( desired_pos * (close - actual_fill) / actual_fill if actual_fill != 0.0 else 0.0 ) exit_comm = self._commission_cost(actual_fill, old_pos) entry_comm = self._commission_cost(actual_fill, desired_pos) strategy_return = exit_r + entry_r - exit_comm - entry_comm if old_pos != 0.0: self._record_trade(bar_idx, actual_fill) self._set_entry(bar_idx, actual_fill, desired_pos) self._position = desired_pos else: # Hold: full bar return (close-to-close on existing position) strategy_return = self._position * close_ret # Update equity prev_equity = self._equity self._equity = self._equity * (1.0 + strategy_return) pnl_bar = self._equity - prev_equity self._equity_history.append(self._equity) return BarResult( bar_index=bar_idx, filled=filled, fill_price=fill_price_this_bar, position=self._position, equity=self._equity, equity_abs=self._equity * self.initial_capital, pnl_bar=pnl_bar, ) def _record_trade(self, exit_bar: int, exit_price: float) -> None: """Record a completed round-trip trade.""" if math.isnan(self._entry_price): return entry_price = self._entry_price pos = self._position # P&L = position * (exit - entry) / entry as fraction if entry_price != 0.0: pnl_pct = pos * (exit_price - entry_price) / entry_price else: pnl_pct = 0.0 pnl_abs = pnl_pct * self.initial_capital self._trades.append( TradeRecord( entry_bar=getattr(self, "_trade_entry_bar", 0), exit_bar=exit_bar, entry_price=entry_price, exit_price=exit_price, position=pos, pnl_pct=pnl_pct, pnl_abs=pnl_abs, ) ) def _set_entry(self, bar_idx: int, fill_price: float, pos: float) -> None: """Set entry state — call after position changes to new non-zero position.""" self._entry_price = fill_price self._trade_entry_bar = bar_idx self._trail_high = fill_price if pos > 0.0 else float("nan") self._trail_low = fill_price if pos < 0.0 else float("nan") self._breakeven_activated = False self._breakeven_stop = float("nan") @property def position(self) -> float: """Current open position.""" return self._position @property def equity(self) -> float: """Current normalized equity.""" return self._equity @property def equity_abs(self) -> float: """Current absolute equity in base currency.""" return self._equity * self.initial_capital @property def trades(self) -> list[TradeRecord]: """List of completed trades.""" return list(self._trades) @property def equity_curve(self) -> list[float]: """Equity history (normalized).""" return list(self._equity_history) def reset(self) -> None: """Reset all state to initial values.""" self._position = 0.0 self._entry_price = float("nan") self._equity = 1.0 self._prev_close = float("nan") self._bar_index = 0 self._trail_high = float("nan") self._trail_low = float("nan") self._breakeven_activated = False self._breakeven_stop = float("nan") self._trades = [] self._equity_history = [] self._pending_signal = 0.0 self._first_bar = True