topstep-backtest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. topstep_backtest/__init__.py +43 -0
  2. topstep_backtest/clock/__init__.py +1 -0
  3. topstep_backtest/clock/live_clock.py +82 -0
  4. topstep_backtest/clock/test_clock.py +133 -0
  5. topstep_backtest/core/__init__.py +1 -0
  6. topstep_backtest/core/ids.py +23 -0
  7. topstep_backtest/core/instruments.py +167 -0
  8. topstep_backtest/core/money.py +160 -0
  9. topstep_backtest/core/time.py +125 -0
  10. topstep_backtest/data/__init__.py +1 -0
  11. topstep_backtest/data/clean.py +86 -0
  12. topstep_backtest/data/feed.py +56 -0
  13. topstep_backtest/data/synthetic.py +137 -0
  14. topstep_backtest/data/validator.py +215 -0
  15. topstep_backtest/data/wrangler.py +306 -0
  16. topstep_backtest/engine/__init__.py +1 -0
  17. topstep_backtest/engine/backtest.py +209 -0
  18. topstep_backtest/execution/__init__.py +1 -0
  19. topstep_backtest/execution/rejections.py +53 -0
  20. topstep_backtest/execution/sim_broker.py +1436 -0
  21. topstep_backtest/fills/__init__.py +1 -0
  22. topstep_backtest/fills/bar_fill.py +268 -0
  23. topstep_backtest/fills/fees.py +120 -0
  24. topstep_backtest/fills/path.py +59 -0
  25. topstep_backtest/harness.py +446 -0
  26. topstep_backtest/indicators/__init__.py +46 -0
  27. topstep_backtest/indicators/base.py +57 -0
  28. topstep_backtest/indicators/library.py +303 -0
  29. topstep_backtest/indicators/talib_adapter.py +657 -0
  30. topstep_backtest/metrics/__init__.py +5 -0
  31. topstep_backtest/metrics/stats.py +153 -0
  32. topstep_backtest/protocols.py +473 -0
  33. topstep_backtest/py.typed +0 -0
  34. topstep_backtest/rules/__init__.py +1 -0
  35. topstep_backtest/rules/kernel.py +281 -0
  36. topstep_backtest/rules/params.py +74 -0
  37. topstep_backtest/strategy/__init__.py +20 -0
  38. topstep_backtest/strategy/base.py +118 -0
  39. topstep_backtest/strategy/symbol.py +344 -0
  40. topstep_backtest/strategy/tracker.py +151 -0
  41. topstep_backtest-0.1.0.dist-info/METADATA +250 -0
  42. topstep_backtest-0.1.0.dist-info/RECORD +44 -0
  43. topstep_backtest-0.1.0.dist-info/WHEEL +4 -0
  44. topstep_backtest-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,43 @@
1
+ """topstep-backtest: event-driven Topstep Combine backtesting with live parity.
2
+
3
+ Strategies are written once against the ``Broker``/``Clock`` protocols in
4
+ :mod:`topstep_backtest.protocols` and run unchanged against the ``SimBroker``
5
+ (backtest) or ``topstep_sdk.AsyncTopstepClient`` (live).
6
+
7
+ Quick start (docs/STRATEGY_API.md §3)::
8
+
9
+ from topstep_backtest import AccountSize, Backtest
10
+
11
+ report = Backtest(bars, MyStrategy("CON.F.US.MNQ.U26"),
12
+ account=AccountSize.S50K).run()
13
+ print(report) # verdict + balance path + day trail + summary stats
14
+ report.result # the unchanged frozen BacktestResult
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from importlib.metadata import PackageNotFoundError
20
+ from importlib.metadata import version as _version
21
+
22
+ from . import indicators
23
+ from .harness import Backtest, DataValidationError, Report
24
+ from .metrics import SummaryStats
25
+ from .rules.params import AccountSize
26
+ from .strategy import Strategy, SymbolStrategy
27
+
28
+ try:
29
+ __version__ = _version("topstep-backtest")
30
+ except PackageNotFoundError: # pragma: no cover - source tree without install
31
+ __version__ = "0.0.0.dev0"
32
+
33
+ __all__ = [
34
+ "AccountSize",
35
+ "Backtest",
36
+ "DataValidationError",
37
+ "Report",
38
+ "Strategy",
39
+ "SummaryStats",
40
+ "SymbolStrategy",
41
+ "__version__",
42
+ "indicators",
43
+ ]
@@ -0,0 +1 @@
1
+ """topstep_backtest.clock"""
@@ -0,0 +1,82 @@
1
+ """Wall-time clock — proves ``protocols.Clock`` is implementable on real time.
2
+
3
+ Minimal by design: the M1 backtest engine never drives it. ``now_ns`` reads
4
+ ``time.time_ns()``; alerts/timers are scheduled on the running asyncio event
5
+ loop and a clear ``RuntimeError`` is raised when no loop is running.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import time
12
+ from collections.abc import Callable
13
+ from datetime import UTC, datetime
14
+
15
+ from ..core.time import NS_PER_SEC
16
+ from ..protocols import TimeEvent
17
+
18
+ __all__ = ["LiveClock"]
19
+
20
+
21
+ class LiveClock:
22
+ """Wall-clock ``Clock``: real time, asyncio-scheduled timers/alerts.
23
+
24
+ Same replace-by-name / cancel-by-name semantics as ``TestClock`` so
25
+ time-driven logic is parity-safe across sim and live.
26
+ """
27
+
28
+ def __init__(self) -> None:
29
+ self._handles: dict[str, asyncio.TimerHandle] = {}
30
+
31
+ # -- Clock protocol -----------------------------------------------------
32
+
33
+ def now_ns(self) -> int:
34
+ return time.time_ns()
35
+
36
+ def now(self) -> datetime:
37
+ """Current wall time as a tz-aware UTC datetime."""
38
+ return datetime.now(UTC)
39
+
40
+ def set_time_alert(self, name: str, at_ns: int, cb: Callable[[TimeEvent], None]) -> None:
41
+ """One-shot alert at UTC-ns instant ``at_ns`` (already-past fires ASAP)."""
42
+ loop = self._running_loop()
43
+ self.cancel_timer(name) # replace-by-name
44
+
45
+ def _fire() -> None:
46
+ self._handles.pop(name, None)
47
+ cb(TimeEvent(name=name, ts_ns=at_ns))
48
+
49
+ delay_s = max(0.0, (at_ns - time.time_ns()) / NS_PER_SEC)
50
+ self._handles[name] = loop.call_later(delay_s, _fire)
51
+
52
+ def set_timer(self, name: str, interval_ns: int, cb: Callable[[TimeEvent], None]) -> None:
53
+ """Repeating timer every ``interval_ns``, first due one interval from now."""
54
+ if interval_ns <= 0:
55
+ raise ValueError(f"interval_ns must be positive, got {interval_ns}")
56
+ loop = self._running_loop()
57
+ self.cancel_timer(name) # replace-by-name
58
+ interval_s = interval_ns / NS_PER_SEC
59
+
60
+ def _fire() -> None:
61
+ self._handles[name] = loop.call_later(interval_s, _fire) # re-arm first
62
+ cb(TimeEvent(name=name, ts_ns=time.time_ns()))
63
+
64
+ self._handles[name] = loop.call_later(interval_s, _fire)
65
+
66
+ def cancel_timer(self, name: str) -> None:
67
+ """Cancel an alert OR timer by name; unknown names are a no-op."""
68
+ handle = self._handles.pop(name, None)
69
+ if handle is not None:
70
+ handle.cancel()
71
+
72
+ # -- internals ----------------------------------------------------------
73
+
74
+ @staticmethod
75
+ def _running_loop() -> asyncio.AbstractEventLoop:
76
+ try:
77
+ return asyncio.get_running_loop()
78
+ except RuntimeError as exc:
79
+ raise RuntimeError(
80
+ "LiveClock alerts/timers require a running asyncio event loop: "
81
+ "call set_time_alert/set_timer from within async code"
82
+ ) from exc
@@ -0,0 +1,133 @@
1
+ """Deterministic simulated clock — the engine's only time source in backtests.
2
+
3
+ ``TestClock`` conforms to ``protocols.Clock``. Time only moves via
4
+ ``advance_to``: every alert/timer due at or before the target fires
5
+ synchronously, ordered by ``(due_ts, registration order)``, and ``now_ns()``
6
+ reads the firing event's due timestamp DURING each callback so any logic
7
+ reading the clock inside a handler sees the event's own time. Time never
8
+ moves backward.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from datetime import UTC, datetime, timedelta
15
+ from typing import ClassVar
16
+
17
+ from ..protocols import TimeEvent
18
+
19
+ __all__ = ["TestClock"]
20
+
21
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
22
+
23
+
24
+ class _Entry:
25
+ """One scheduled alert (``interval_ns is None``) or repeating timer."""
26
+
27
+ __slots__ = ("cb", "due_ns", "interval_ns", "name", "seq")
28
+
29
+ def __init__(
30
+ self,
31
+ name: str,
32
+ due_ns: int,
33
+ interval_ns: int | None,
34
+ cb: Callable[[TimeEvent], None],
35
+ seq: int,
36
+ ) -> None:
37
+ self.name = name
38
+ self.due_ns = due_ns
39
+ self.interval_ns = interval_ns
40
+ self.cb = cb
41
+ self.seq = seq
42
+
43
+
44
+ class TestClock:
45
+ """Simulated clock: time advances only when the engine says so.
46
+
47
+ Semantics (binding):
48
+ - ``advance_to(ns)`` with ``ns < now_ns()`` raises ``ValueError``;
49
+ ``ns == now_ns()`` is a no-op advance that still fires anything due
50
+ at exactly ``ns`` not yet fired.
51
+ - Due callbacks fire synchronously in ``(due_ts, registration order)``;
52
+ repeating timers re-arm at ``due + interval`` (original registration
53
+ order kept) and can fire multiple times within one advance.
54
+ - ``set_time_alert`` with ``at_ns <= now`` is immediately due: it fires
55
+ on the next advance, stamped with its scheduled ``at_ns``.
56
+ - Re-registering a name replaces the prior entry (SDK handler
57
+ semantics); ``cancel_timer`` removes either kind by name (missing
58
+ name is a no-op). Alerts are one-shot.
59
+ """
60
+
61
+ # Not a pytest test class despite the name (pytest collects Test*).
62
+ __test__: ClassVar[bool] = False
63
+
64
+ def __init__(self, start_ns: int = 0) -> None:
65
+ self._now_ns = start_ns
66
+ self._entries: dict[str, _Entry] = {}
67
+ self._seq = 0
68
+
69
+ # -- Clock protocol -----------------------------------------------------
70
+
71
+ def now_ns(self) -> int:
72
+ return self._now_ns
73
+
74
+ def now(self) -> datetime:
75
+ """Current time as a tz-aware UTC datetime (exact to the microsecond)."""
76
+ return _EPOCH + timedelta(microseconds=self._now_ns // 1_000)
77
+
78
+ def set_time_alert(self, name: str, at_ns: int, cb: Callable[[TimeEvent], None]) -> None:
79
+ """One-shot alert at ``at_ns``; ``at_ns <= now`` fires on the next advance."""
80
+ self._register(_Entry(name, at_ns, None, cb, self._next_seq()))
81
+
82
+ def set_timer(self, name: str, interval_ns: int, cb: Callable[[TimeEvent], None]) -> None:
83
+ """Repeating timer, first due at ``now + interval_ns``."""
84
+ if interval_ns <= 0:
85
+ raise ValueError(f"interval_ns must be positive, got {interval_ns}")
86
+ self._register(_Entry(name, self._now_ns + interval_ns, interval_ns, cb, self._next_seq()))
87
+
88
+ def cancel_timer(self, name: str) -> None:
89
+ """Remove an alert OR timer by name; unknown names are a no-op."""
90
+ self._entries.pop(name, None)
91
+
92
+ # -- advancement --------------------------------------------------------
93
+
94
+ def advance_to(self, ns: int) -> None:
95
+ """Move time forward to ``ns``, firing everything due on the way.
96
+
97
+ Raises:
98
+ ValueError: If ``ns`` is before the current time (time never
99
+ moves backward).
100
+ """
101
+ if ns < self._now_ns:
102
+ raise ValueError(f"cannot advance backward: target {ns} < now {self._now_ns}")
103
+ while (entry := self._pop_next_due(ns)) is not None:
104
+ due = entry.due_ns
105
+ if entry.interval_ns is not None: # re-arm BEFORE the callback so a
106
+ entry.due_ns = due + entry.interval_ns # cancel/replace inside cb sticks
107
+ self._entries[entry.name] = entry
108
+ # The callback observes the event's time (never moving backward
109
+ # for an alert that was registered already past due).
110
+ self._now_ns = max(self._now_ns, due)
111
+ entry.cb(TimeEvent(name=entry.name, ts_ns=due))
112
+ self._now_ns = ns
113
+
114
+ # -- internals ----------------------------------------------------------
115
+
116
+ def _next_seq(self) -> int:
117
+ self._seq += 1
118
+ return self._seq
119
+
120
+ def _register(self, entry: _Entry) -> None:
121
+ self._entries[entry.name] = entry # replace-by-name
122
+
123
+ def _pop_next_due(self, horizon_ns: int) -> _Entry | None:
124
+ """Remove and return the earliest entry due at/before ``horizon_ns``."""
125
+ best: _Entry | None = None
126
+ for entry in self._entries.values():
127
+ if entry.due_ns > horizon_ns:
128
+ continue
129
+ if best is None or (entry.due_ns, entry.seq) < (best.due_ns, best.seq):
130
+ best = entry
131
+ if best is not None:
132
+ del self._entries[best.name]
133
+ return best
@@ -0,0 +1 @@
1
+ """topstep_backtest.core"""
@@ -0,0 +1,23 @@
1
+ """Deterministic 64-bit id generation (gateway-parity id shapes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["IdGenerator"]
6
+
7
+
8
+ class IdGenerator:
9
+ """Monotonic int id source. Seeded above 2^31 so downstream code that
10
+
11
+ incorrectly stores ids in int32 fails fast in backtests, exactly as it
12
+ would against production gateway ids (which exceed 2^31).
13
+ """
14
+
15
+ __slots__ = ("_next",)
16
+
17
+ def __init__(self, start: int = 5_000_000_000) -> None:
18
+ self._next = start
19
+
20
+ def next(self) -> int:
21
+ value = self._next
22
+ self._next += 1
23
+ return value
@@ -0,0 +1,167 @@
1
+ """Instrument specifications: tick economics, venue, session class, cap weight.
2
+
3
+ The runtime source of truth for tick size/value is the SDK ``ContractModel``;
4
+ the built-in ``SPECS`` table is the offline reference (and consistency check)
5
+ for the products commonly traded on Topstep. ``point_value`` is always derived
6
+ as ``tick_value / tick_size`` and the triple is asserted consistent on load.
7
+
8
+ Position caps count **micro-units**: a mini counts 10, a micro counts 1
9
+ (Topstep's 10:1 ratio), so cap math is pure integer arithmetic.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from decimal import Decimal
15
+ from enum import StrEnum
16
+
17
+ import msgspec
18
+ from topstep_sdk import ContractModel
19
+
20
+ __all__ = [
21
+ "SPECS",
22
+ "InstrumentSpec",
23
+ "SessionClass",
24
+ "Venue",
25
+ "spec_for_contract",
26
+ "spec_for_symbol",
27
+ ]
28
+
29
+
30
+ class Venue(StrEnum):
31
+ """Exchange within CME Group (matters for calendars and fees)."""
32
+
33
+ CME = "CME"
34
+ CBOT = "CBOT"
35
+ NYMEX = "NYMEX"
36
+ COMEX = "COMEX"
37
+
38
+
39
+ class SessionClass(StrEnum):
40
+ """Asset-class RTH template (ET). Globex hours are shared by all."""
41
+
42
+ EQUITY = "EQUITY" # RTH 09:30-16:15 ET
43
+ ENERGY = "ENERGY" # RTH 09:00-14:30 ET
44
+ GOLD = "GOLD" # RTH 08:20-13:30 ET
45
+ SILVER = "SILVER" # RTH 08:25-13:25 ET
46
+
47
+
48
+ class InstrumentSpec(msgspec.Struct, frozen=True):
49
+ """Frozen per-product economics and session metadata.
50
+
51
+ ``cap_units`` is the product's weight toward Topstep's position cap in
52
+ micro-units (mini=10, micro=1).
53
+ """
54
+
55
+ symbol: str
56
+ venue: Venue
57
+ session_class: SessionClass
58
+ tick_size: Decimal
59
+ tick_value: Decimal
60
+ is_micro: bool
61
+
62
+ @property
63
+ def point_value(self) -> Decimal:
64
+ """Dollars per full 1.00 price move (= tick_value / tick_size)."""
65
+ return self.tick_value / self.tick_size
66
+
67
+ @property
68
+ def cap_units(self) -> int:
69
+ """Weight toward the position cap in micro-units (mini=10, micro=1)."""
70
+ return 1 if self.is_micro else 10
71
+
72
+
73
+ def _spec(
74
+ symbol: str,
75
+ venue: Venue,
76
+ session: SessionClass,
77
+ tick_size: str,
78
+ tick_value: str,
79
+ *,
80
+ micro: bool,
81
+ expect_point: str,
82
+ ) -> InstrumentSpec:
83
+ spec = InstrumentSpec(
84
+ symbol=symbol,
85
+ venue=venue,
86
+ session_class=session,
87
+ tick_size=Decimal(tick_size),
88
+ tick_value=Decimal(tick_value),
89
+ is_micro=micro,
90
+ )
91
+ if spec.point_value != Decimal(expect_point): # consistency assertion on load
92
+ raise AssertionError(f"{symbol}: point_value {spec.point_value} != expected {expect_point}")
93
+ return spec
94
+
95
+
96
+ _TABLE: tuple[tuple[str, Venue, SessionClass, str, str, bool, str], ...] = (
97
+ ("ES", Venue.CME, SessionClass.EQUITY, "0.25", "12.50", False, "50"),
98
+ ("MES", Venue.CME, SessionClass.EQUITY, "0.25", "1.25", True, "5"),
99
+ ("NQ", Venue.CME, SessionClass.EQUITY, "0.25", "5.00", False, "20"),
100
+ ("MNQ", Venue.CME, SessionClass.EQUITY, "0.25", "0.50", True, "2"),
101
+ ("YM", Venue.CBOT, SessionClass.EQUITY, "1", "5.00", False, "5"),
102
+ ("MYM", Venue.CBOT, SessionClass.EQUITY, "1", "0.50", True, "0.50"),
103
+ ("RTY", Venue.CME, SessionClass.EQUITY, "0.10", "5.00", False, "50"),
104
+ ("M2K", Venue.CME, SessionClass.EQUITY, "0.10", "0.50", True, "5"),
105
+ ("CL", Venue.NYMEX, SessionClass.ENERGY, "0.01", "10.00", False, "1000"),
106
+ ("MCL", Venue.NYMEX, SessionClass.ENERGY, "0.01", "1.00", True, "100"),
107
+ ("NG", Venue.NYMEX, SessionClass.ENERGY, "0.001", "10.00", False, "10000"),
108
+ ("GC", Venue.COMEX, SessionClass.GOLD, "0.10", "10.00", False, "100"),
109
+ ("MGC", Venue.COMEX, SessionClass.GOLD, "0.10", "1.00", True, "10"),
110
+ ("SI", Venue.COMEX, SessionClass.SILVER, "0.005", "25.00", False, "5000"),
111
+ ("SIL", Venue.COMEX, SessionClass.SILVER, "0.005", "5.00", True, "1000"),
112
+ )
113
+
114
+ SPECS: dict[str, InstrumentSpec] = {
115
+ sym: _spec(sym, venue, sess, tick, value, micro=micro, expect_point=point)
116
+ for sym, venue, sess, tick, value, micro, point in _TABLE
117
+ }
118
+
119
+
120
+ def symbol_of_contract_id(contract_id: str) -> str:
121
+ """Extract the product symbol from a gateway contract id.
122
+
123
+ ``"CON.F.US.MNQ.U26"`` -> ``"MNQ"``; a bare symbol passes through unchanged.
124
+ """
125
+ parts = contract_id.split(".")
126
+ if len(parts) >= 4 and parts[0] == "CON":
127
+ return parts[3]
128
+ if len(parts) == 3 and parts[0] == "F": # symbol_id form "F.US.MNQ"
129
+ return parts[2]
130
+ return contract_id
131
+
132
+
133
+ def spec_for_symbol(symbol: str) -> InstrumentSpec:
134
+ """Look up the built-in spec for a product symbol (raises KeyError if unknown)."""
135
+ return SPECS[symbol]
136
+
137
+
138
+ def spec_for_contract(contract: ContractModel) -> InstrumentSpec:
139
+ """Build a spec from a live SDK ``ContractModel``, validated against the table.
140
+
141
+ The contract's ``tick_size``/``tick_value`` are authoritative; if the symbol
142
+ is in ``SPECS`` the values must agree (a mismatch means the reference table
143
+ is stale and must be corrected — a silent override would corrupt P&L).
144
+ """
145
+ symbol = symbol_of_contract_id(contract.symbol_id or contract.id)
146
+ reference = SPECS.get(symbol)
147
+ if reference is not None:
148
+ if (contract.tick_size, contract.tick_value) != (
149
+ reference.tick_size,
150
+ reference.tick_value,
151
+ ):
152
+ raise ValueError(
153
+ f"{symbol}: gateway tick economics ({contract.tick_size}, {contract.tick_value})"
154
+ f" disagree with reference table ({reference.tick_size}, {reference.tick_value})"
155
+ )
156
+ return reference
157
+ # Unknown product: trust the gateway's tick economics, default conservative
158
+ # metadata, and treat it as a mini (cap-weight 10) so position caps are
159
+ # never accidentally loosened for an unrecognized symbol.
160
+ return InstrumentSpec(
161
+ symbol=symbol,
162
+ venue=Venue.CME,
163
+ session_class=SessionClass.EQUITY,
164
+ tick_size=contract.tick_size,
165
+ tick_value=contract.tick_value,
166
+ is_micro=False,
167
+ )
@@ -0,0 +1,160 @@
1
+ """Exact futures money math: the Decimal <-> integer-tick accounting spine.
2
+
3
+ Every price that enters the engine must land on its instrument's tick grid, and
4
+ every P&L figure is derived from *integer tick deltas* times the tick value —
5
+ never from raw float arithmetic. All grid conversion lives here so "every fill
6
+ price is on the tick grid" is one enforced invariant, not a convention.
7
+
8
+ Rounding is always **explicit and directional** (`RoundMode`); there is no
9
+ silent default rounding anywhere in the engine.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_EVEN, Decimal
15
+ from typing import Literal
16
+
17
+ from topstep_sdk import OrderSide
18
+
19
+ __all__ = [
20
+ "OffGridError",
21
+ "RoundMode",
22
+ "from_ticks",
23
+ "is_on_grid",
24
+ "position_unrealized",
25
+ "realized_pnl",
26
+ "round_to_tick",
27
+ "signed_tick_delta",
28
+ "to_ticks",
29
+ "unrealized_pnl",
30
+ ]
31
+
32
+ RoundMode = Literal["nearest", "up", "down"]
33
+
34
+ _ROUNDING = {
35
+ "nearest": ROUND_HALF_EVEN,
36
+ "up": ROUND_CEILING,
37
+ "down": ROUND_FLOOR,
38
+ }
39
+
40
+
41
+ class OffGridError(ValueError):
42
+ """A price does not lie on the instrument's tick grid."""
43
+
44
+ def __init__(self, price: Decimal, tick_size: Decimal) -> None:
45
+ super().__init__(f"price {price} is not a multiple of tick size {tick_size}")
46
+ self.price = price
47
+ self.tick_size = tick_size
48
+
49
+
50
+ def is_on_grid(price: Decimal, tick_size: Decimal) -> bool:
51
+ """Whether ``price`` is an exact multiple of ``tick_size``."""
52
+ return price % tick_size == 0
53
+
54
+
55
+ def to_ticks(price: Decimal, tick_size: Decimal) -> int:
56
+ """Convert an on-grid price to its integer tick index.
57
+
58
+ Raises:
59
+ OffGridError: If ``price`` is not an exact multiple of ``tick_size``.
60
+ """
61
+ quotient, remainder = divmod(price, tick_size)
62
+ if remainder != 0:
63
+ raise OffGridError(price, tick_size)
64
+ return int(quotient)
65
+
66
+
67
+ def from_ticks(ticks: int, tick_size: Decimal) -> Decimal:
68
+ """Convert an integer tick index back to its exact Decimal price."""
69
+ return ticks * tick_size
70
+
71
+
72
+ def round_to_tick(price: Decimal, tick_size: Decimal, *, mode: RoundMode) -> Decimal:
73
+ """Snap an arbitrary price onto the tick grid with an explicit direction.
74
+
75
+ ``mode="up"`` rounds toward +infinity, ``"down"`` toward -infinity, and
76
+ ``"nearest"`` uses banker's rounding on the tick index. Use directional
77
+ modes when conservatism matters (e.g. rounding a computed level *against*
78
+ the trader).
79
+ """
80
+ ticks = (price / tick_size).to_integral_value(rounding=_ROUNDING[mode])
81
+ return ticks * tick_size
82
+
83
+
84
+ def signed_tick_delta(entry: Decimal, exit: Decimal, tick_size: Decimal) -> int:
85
+ """Integer tick move from ``entry`` to ``exit`` (positive = price went up)."""
86
+ return to_ticks(exit, tick_size) - to_ticks(entry, tick_size)
87
+
88
+
89
+ def _direction(side: OrderSide | int) -> int:
90
+ """+1 for a long (entry side BUY), -1 for a short (entry side SELL)."""
91
+ return 1 if int(side) == int(OrderSide.BUY) else -1
92
+
93
+
94
+ def realized_pnl(
95
+ entry: Decimal,
96
+ exit: Decimal,
97
+ *,
98
+ entry_side: OrderSide | int,
99
+ qty: int,
100
+ tick_size: Decimal,
101
+ tick_value: Decimal,
102
+ ) -> Decimal:
103
+ """Exact realized P&L for closing ``qty`` contracts entered at ``entry``.
104
+
105
+ Both prices must be on the tick grid; the result is an exact Decimal
106
+ (integer ticks x tick_value x qty), before fees.
107
+ """
108
+ if qty <= 0:
109
+ raise ValueError(f"qty must be positive, got {qty}")
110
+ ticks = signed_tick_delta(entry, exit, tick_size)
111
+ return ticks * _direction(entry_side) * tick_value * qty
112
+
113
+
114
+ def unrealized_pnl(
115
+ avg_entry: Decimal,
116
+ mark: Decimal,
117
+ *,
118
+ entry_side: OrderSide | int,
119
+ qty: int,
120
+ tick_size: Decimal,
121
+ tick_value: Decimal,
122
+ ) -> Decimal:
123
+ """Exact open P&L marking ``qty`` contracts at ``mark``.
124
+
125
+ This is THE shared unrealized-P&L computation: the sim broker, the rule
126
+ engine's breach checks, and any live shadow-monitor must all mark through
127
+ this one function so breach timing can never diverge between them.
128
+ """
129
+ return realized_pnl(
130
+ avg_entry,
131
+ mark,
132
+ entry_side=entry_side,
133
+ qty=qty,
134
+ tick_size=tick_size,
135
+ tick_value=tick_value,
136
+ )
137
+
138
+
139
+ def position_unrealized(
140
+ *,
141
+ cost: Decimal,
142
+ qty: int,
143
+ mark: Decimal,
144
+ direction: int,
145
+ point_value: Decimal,
146
+ ) -> Decimal:
147
+ """Exact open P&L for a netted position from its COST BASIS (no division).
148
+
149
+ ``cost`` = sum(entry_price_i * qty_i) over the position's open lots;
150
+ ``direction`` = +1 long / -1 short. Division-free, so it stays exact even
151
+ when the weighted-average entry price would be a non-terminating decimal
152
+ (e.g. three lots averaging to thirds of a tick). This is THE shared
153
+ unrealized computation for netted positions — sim broker, rule-engine
154
+ breach checks, and any live shadow-monitor must all mark through it.
155
+ """
156
+ if qty <= 0:
157
+ raise ValueError(f"qty must be positive, got {qty}")
158
+ if direction not in (1, -1):
159
+ raise ValueError(f"direction must be +1 or -1, got {direction}")
160
+ return (mark * qty - cost) * direction * point_value