173 lines
6.4 KiB
Python
173 lines
6.4 KiB
Python
"""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
|