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.
- topstep_backtest/__init__.py +43 -0
- topstep_backtest/clock/__init__.py +1 -0
- topstep_backtest/clock/live_clock.py +82 -0
- topstep_backtest/clock/test_clock.py +133 -0
- topstep_backtest/core/__init__.py +1 -0
- topstep_backtest/core/ids.py +23 -0
- topstep_backtest/core/instruments.py +167 -0
- topstep_backtest/core/money.py +160 -0
- topstep_backtest/core/time.py +125 -0
- topstep_backtest/data/__init__.py +1 -0
- topstep_backtest/data/clean.py +86 -0
- topstep_backtest/data/feed.py +56 -0
- topstep_backtest/data/synthetic.py +137 -0
- topstep_backtest/data/validator.py +215 -0
- topstep_backtest/data/wrangler.py +306 -0
- topstep_backtest/engine/__init__.py +1 -0
- topstep_backtest/engine/backtest.py +209 -0
- topstep_backtest/execution/__init__.py +1 -0
- topstep_backtest/execution/rejections.py +53 -0
- topstep_backtest/execution/sim_broker.py +1436 -0
- topstep_backtest/fills/__init__.py +1 -0
- topstep_backtest/fills/bar_fill.py +268 -0
- topstep_backtest/fills/fees.py +120 -0
- topstep_backtest/fills/path.py +59 -0
- topstep_backtest/harness.py +446 -0
- topstep_backtest/indicators/__init__.py +46 -0
- topstep_backtest/indicators/base.py +57 -0
- topstep_backtest/indicators/library.py +303 -0
- topstep_backtest/indicators/talib_adapter.py +657 -0
- topstep_backtest/metrics/__init__.py +5 -0
- topstep_backtest/metrics/stats.py +153 -0
- topstep_backtest/protocols.py +473 -0
- topstep_backtest/py.typed +0 -0
- topstep_backtest/rules/__init__.py +1 -0
- topstep_backtest/rules/kernel.py +281 -0
- topstep_backtest/rules/params.py +74 -0
- topstep_backtest/strategy/__init__.py +20 -0
- topstep_backtest/strategy/base.py +118 -0
- topstep_backtest/strategy/symbol.py +344 -0
- topstep_backtest/strategy/tracker.py +151 -0
- topstep_backtest-0.1.0.dist-info/METADATA +250 -0
- topstep_backtest-0.1.0.dist-info/RECORD +44 -0
- topstep_backtest-0.1.0.dist-info/WHEEL +4 -0
- 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
|