436954138f
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
545 lines
21 KiB
Python
545 lines
21 KiB
Python
"""
|
|
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
|