Files

173 lines
6.4 KiB
Python
Raw Permalink Normal View History

2026-06-26 20:50:07 +08:00
"""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