"""Engine contract and result types (doc 02 §2, doc 03). The single most important design decision lives here: **the engine knows nothing about your strategy.** Its input is pre-computed — bars, entry signals, stop/target prices. The engine never decides *where* a stop goes; it only decides *whether* price touched it. That seam separates "the strategy" (caller) from "the simulator" (engine). The intra-bar 4-sub-tick model and pessimistic ordering convention are described in doc 03 §2. Implement them in concrete engine subclasses (e.g. ``shared/core/grid_engine.py`` once your EA is brought in, doc 03 §3). """ from __future__ import annotations from dataclasses import dataclass, field from enum import IntEnum from typing import Any, Protocol, runtime_checkable import numpy as np import pandas as pd from ..instruments.config import InstrumentConfig class Direction(IntEnum): """Trade direction. ``1`` long, ``-1`` short.""" LONG = 1 SHORT = -1 # Pessimistic intra-bar sub-tick order (doc 03 §2). # For a LONG position (stop below, target above): OPEN → LOW → HIGH → CLOSE # For a SHORT position (stop above, target below): OPEN → HIGH → LOW → CLOSE # The pessimistic assumption: price visits the point that hurts an open # position *before* the point that helps it — so a bar that could touch both # stop and target resolves to the stop (the realistic worst case). SUBTICK_ORDER_LONG = ("open", "low", "high", "close") SUBTICK_ORDER_SHORT = ("open", "high", "low", "close") @dataclass class Trade: """One closed trade (a position opened then exited). ``pnl`` includes accumulated swap. ``exit_reason`` documents why the trade closed (stop / target / basket-stop / signal-flip / end-of-data). """ direction: Direction entry_time: pd.Timestamp exit_time: pd.Timestamp entry_price: float exit_price: float lots: float pnl: float # net of swap swap: float # accumulated swap (also folded into pnl) exit_reason: str = "" @dataclass class Position: """An open position held by the engine between entry and exit. Multi-position baskets (grid/martingale) are modelled as a list of ``Position`` objects that share a single basket stop (doc 03 §3, §5). ``sl`` / ``tp`` are mutable because break-even and trailing stops update them over the position's life (doc 03 §6). """ direction: Direction entry_time: pd.Timestamp entry_price: float lots: float open_swap: float = 0.0 # swap accumulated so far on this position sl: float = 0.0 # current stop-loss price (0 = none) tp: float = 0.0 # current take-profit price (0 = none) @dataclass class Result: """Engine output (doc 02 §2). ``trades`` is the list of closed trades; ``equity_curve`` is sampled periodically (e.g. hourly) so memory stays bounded on multi-year runs. """ trades: list[Trade] = field(default_factory=list) equity_curve: pd.DataFrame = field(default_factory=lambda: pd.DataFrame(columns=["timestamp", "balance", "equity"])) final_balance: float = 0.0 initial_deposit: float = 0.0 # Optional floating (open) state at end-of-data, for diagnostics. open_positions: list[Position] = field(default_factory=list) # Free-form diagnostics (max floating drawdown, series count, …). diagnostics: dict[str, Any] = field(default_factory=dict) @runtime_checkable class Engine(Protocol): """Strategy-agnostic bar-by-bar fill simulator. Implementations take **pre-computed** signal + stop/target arrays and simulate fills bar-by-bar with the pessimistic 4-sub-tick model. The engine must **not** compute signals, stops, or sizing beyond what the caller passes in — that boundary is what makes it freezable (doc 04). """ def run( self, bars: pd.DataFrame, signals_long: np.ndarray, signals_short: np.ndarray, sl_prices: np.ndarray, tp_prices: np.ndarray, instrument: InstrumentConfig, sizing: "SizingInputs", initial_deposit: float, ) -> Result: """Run the engine over ``bars`` and return a :class:`Result`. Parameters ---------- bars DataFrame with columns ``[timestamp, open, high, low, close, spread]``. ``spread`` is in price points per bar (may be 0 / NaN if the instrument uses ``FIXED_POINTS``). signals_long, signals_short Boolean arrays, **edge-detected** — ``True`` only on the transition bar, not forward-filled, or the engine re-enters every bar (doc 02 §3). sl_prices, tp_prices The stop-loss / take-profit **price** for an entry on that bar. ``NaN`` where no stop / target applies. The engine never decides *where* a stop goes — only whether price touched it. instrument Per-symbol mechanics (tick value, spread, swap, lot steps). sizing Lot / money mode inputs (doc 05 §4). initial_deposit Account starting balance. Notes ----- **Look-ahead guard:** a signal computed *from* a bar's close must execute on the *next* bar's open, never the same bar's close. """ ... @dataclass class SizingInputs: """Position-sizing inputs (doc 05 §4, doc 03 §4). Mirror your EA's sizing exactly or PnL will be off by a constant factor. Modes, in priority order: 1. ``risk_on_stop``: ``lot = max_loss_money / (stop_distance_points × tick_value)``. 2. ``fixed_lot`` (when ``lot != 0``): ``lot = configured_lot`` (optionally scaled by ``balance / reference_balance`` when ``reference_balance > 0``). 3. ``money`` (when ``lot == 0``): ``lot = amount / open_price / contract_size``. **Money-mode guard:** ``lot`` must be ``0`` to enable money mode — a stray non-zero fixed lot silently sizes every trade wrong. """ lot: float = 0.0 # fixed lot; MUST be 0 for money mode lot_amount: float = 0.0 # cash base for money mode lot_balance: float = 0.0 # 0 = static (research default); >0 = dynamic reference_balance: float = 0.0 # scaling window for fixed-lot compounding risk_money: float = 0.0 # max loss money for risk-on-stop mode max_loss_money: float = 0.0 # alias used by some EAs