Files
2026-06-26 20:50:07 +08:00

173 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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