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 @@
|
|
|
1
|
+
"""topstep_backtest.fills"""
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Tier-0 bar-based fill model — the pessimistic execution realism layer.
|
|
2
|
+
|
|
3
|
+
Anti-optimism is the whole point: whenever a bar leaves the fill ambiguous,
|
|
4
|
+
this model resolves it AGAINST the trader. Concretely:
|
|
5
|
+
|
|
6
|
+
* **Participation firewall (no look-ahead):** an order interacts with a bar
|
|
7
|
+
only if it was accepted at/before the bar's OPEN (``accepted_ts <=
|
|
8
|
+
bar.ts_event``). An order created on a bar's close signal therefore first
|
|
9
|
+
participates in the NEXT bar — the classic backtest look-ahead bug is
|
|
10
|
+
structurally impossible.
|
|
11
|
+
* **Limits need trade-THROUGH:** by default a resting limit does not fill on
|
|
12
|
+
an exact touch of its level — the bar must trade at least one tick past it
|
|
13
|
+
(an exact-touch extreme is exactly where real queues don't get filled).
|
|
14
|
+
This applies at the OPEN too: a bar opening exactly at the level is a touch,
|
|
15
|
+
not price improvement, so it does not fill at the open under the default;
|
|
16
|
+
the order can still fill at its level if the bar later trades through.
|
|
17
|
+
``fill_limit_on_touch=True`` relaxes both for sensitivity analysis.
|
|
18
|
+
* **Stops always pay slippage:** a triggered stop fills ``stop_slippage_ticks``
|
|
19
|
+
ADVERSE of its trigger, and a gap through the stop fills at the (worse) open
|
|
20
|
+
plus slippage — never at the stop price itself. The slipped price MAY exceed
|
|
21
|
+
the bar's high/low: real stop slippage escapes the bar's printed range, and
|
|
22
|
+
clamping it back inside would be optimistic. That is intentional.
|
|
23
|
+
* **Marketable-at-open limits DO take the improvement:** an open STRICTLY
|
|
24
|
+
better than the level is genuine price improvement, not optimism — a resting
|
|
25
|
+
buy limit above the open would fill at the open in reality too.
|
|
26
|
+
* **Honest timestamps:** open-instant fills are stamped with the bar's OPEN
|
|
27
|
+
time (``bar.ts_event``); every intrabar fill is stamped with the bar's CLOSE
|
|
28
|
+
time (``bar.ts_init``) — the honest upper bound for an unknown intrabar time.
|
|
29
|
+
* **Trigger levels:** every fill carries ``trigger_price`` — the LEVEL that
|
|
30
|
+
triggered it (stop level, limit level, or the open), never the
|
|
31
|
+
slippage-adjusted price. The broker orders intrabar events by trigger.
|
|
32
|
+
|
|
33
|
+
All prices are produced by integer-tick arithmetic on the instrument grid;
|
|
34
|
+
a fill price can never land off-grid. Fills are for the FULL remaining
|
|
35
|
+
quantity (Tier-0 models no partials).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from decimal import Decimal
|
|
41
|
+
|
|
42
|
+
import msgspec
|
|
43
|
+
from topstep_sdk import OrderSide, OrderStatus, OrderType
|
|
44
|
+
|
|
45
|
+
from ..core.money import from_ticks, to_ticks
|
|
46
|
+
from ..protocols import Bar, Fill, Liquidity, MarketContext, PricePath, WorkingOrder
|
|
47
|
+
|
|
48
|
+
__all__ = ["BarFillConfig", "BarFillModel"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BarFillConfig(msgspec.Struct, frozen=True):
|
|
52
|
+
"""Knobs of the Tier-0 pessimism model (all default to the conservative side)."""
|
|
53
|
+
|
|
54
|
+
stop_slippage_ticks: int = 1
|
|
55
|
+
"""Adverse ticks added to EVERY triggered stop fill (gap or intrabar)."""
|
|
56
|
+
|
|
57
|
+
market_slippage_ticks: int = 0
|
|
58
|
+
"""Adverse ticks on market fills (0: the modeled micros are liquid enough)."""
|
|
59
|
+
|
|
60
|
+
fill_limit_on_touch: bool = False
|
|
61
|
+
"""False (default) = a limit fills only if the bar trades >= 1 tick THROUGH
|
|
62
|
+
its level; True = an exact touch of the level suffices."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Execution(msgspec.Struct, frozen=True):
|
|
66
|
+
"""Internal: where/when along the path an order executed."""
|
|
67
|
+
|
|
68
|
+
price: Decimal
|
|
69
|
+
trigger: Decimal # the LEVEL that triggered the fill (see Fill.trigger_price)
|
|
70
|
+
seq: int
|
|
71
|
+
at_open: bool # True -> executed at the bar's open instant
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class BarFillModel:
|
|
75
|
+
"""Tier-0 ``FillModel``: decides fills from OHLC bars + the shared path.
|
|
76
|
+
|
|
77
|
+
Conforms structurally to ``protocols.FillModel``. Deterministic — no
|
|
78
|
+
randomness at all at this tier.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, config: BarFillConfig | None = None) -> None:
|
|
82
|
+
self._config = config if config is not None else BarFillConfig()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def config(self) -> BarFillConfig:
|
|
86
|
+
return self._config
|
|
87
|
+
|
|
88
|
+
def try_fill(
|
|
89
|
+
self,
|
|
90
|
+
order: WorkingOrder,
|
|
91
|
+
ctx: MarketContext,
|
|
92
|
+
path: PricePath,
|
|
93
|
+
) -> list[Fill]:
|
|
94
|
+
bar = ctx.bar
|
|
95
|
+
if bar is None: # Tier-0 always sets it; refuse to guess otherwise
|
|
96
|
+
return []
|
|
97
|
+
if order.status != OrderStatus.OPEN or order.remaining <= 0:
|
|
98
|
+
return []
|
|
99
|
+
# Participation firewall: the order must have existed at/before this
|
|
100
|
+
# bar's OPEN. An order accepted at a bar's close (ts_init) first
|
|
101
|
+
# participates in the NEXT bar.
|
|
102
|
+
if order.accepted_ts > bar.ts_event:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
tick = ctx.instrument.tick_size
|
|
106
|
+
otype = OrderType(order.type)
|
|
107
|
+
if otype == OrderType.MARKET:
|
|
108
|
+
execution = self._market(order, bar, tick)
|
|
109
|
+
elif otype == OrderType.LIMIT:
|
|
110
|
+
execution = self._limit(order, bar, path, tick)
|
|
111
|
+
elif otype == OrderType.STOP:
|
|
112
|
+
if order.stop_price is None:
|
|
113
|
+
return []
|
|
114
|
+
execution = self._stop(order, bar, path, tick, order.stop_price)
|
|
115
|
+
elif otype == OrderType.TRAILING_STOP:
|
|
116
|
+
# The broker maintains trail_stop_price; without it there is no
|
|
117
|
+
# trigger to evaluate.
|
|
118
|
+
if order.trail_stop_price is None:
|
|
119
|
+
return []
|
|
120
|
+
execution = self._stop(order, bar, path, tick, order.trail_stop_price)
|
|
121
|
+
else:
|
|
122
|
+
# STOP_LIMIT (and any other type) is unsupported at Tier-0; the
|
|
123
|
+
# broker rejects placement, and we never fill it here.
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
if execution is None:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
liquidity = Liquidity.MAKER if otype == OrderType.LIMIT else Liquidity.TAKER
|
|
130
|
+
# Open-instant fills happen at the bar's OPEN time; an intrabar fill's
|
|
131
|
+
# true time is unknown, so it is stamped at the bar's CLOSE (ts_init) —
|
|
132
|
+
# the honest upper bound. Never ctx.ts_event: the broker sets that to
|
|
133
|
+
# the bar OPEN, which would make every fill look like an open fill.
|
|
134
|
+
return [
|
|
135
|
+
Fill(
|
|
136
|
+
order_id=order.order_id,
|
|
137
|
+
price=execution.price,
|
|
138
|
+
qty=order.remaining, # Tier-0: always the full remaining qty
|
|
139
|
+
ts_event=bar.ts_event if execution.at_open else bar.ts_init,
|
|
140
|
+
seq=execution.seq,
|
|
141
|
+
liquidity=liquidity,
|
|
142
|
+
trigger_price=execution.trigger,
|
|
143
|
+
)
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# -- per-type semantics -------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _market(self, order: WorkingOrder, bar: Bar, tick: Decimal) -> _Execution:
|
|
149
|
+
"""MARKET: fill at the open, slipped ADVERSE (buy up / sell down)."""
|
|
150
|
+
slip = self._config.market_slippage_ticks
|
|
151
|
+
open_ticks = to_ticks(bar.open, tick)
|
|
152
|
+
if _is_buy(order.side):
|
|
153
|
+
price = from_ticks(open_ticks + slip, tick)
|
|
154
|
+
else:
|
|
155
|
+
price = from_ticks(open_ticks - slip, tick)
|
|
156
|
+
# Trigger is the OPEN (the touch point), not the slipped price.
|
|
157
|
+
return _Execution(price=price, trigger=from_ticks(open_ticks, tick), seq=0, at_open=True)
|
|
158
|
+
|
|
159
|
+
def _limit(
|
|
160
|
+
self,
|
|
161
|
+
order: WorkingOrder,
|
|
162
|
+
bar: Bar,
|
|
163
|
+
path: PricePath,
|
|
164
|
+
tick: Decimal,
|
|
165
|
+
) -> _Execution | None:
|
|
166
|
+
"""LIMIT: an open STRICTLY through the level takes the improvement; an
|
|
167
|
+
open exactly AT the level is a touch (fills at the open only in touch
|
|
168
|
+
mode); else require the bar to reach (touch mode) or trade one tick
|
|
169
|
+
THROUGH (default) the level."""
|
|
170
|
+
level = order.limit_price
|
|
171
|
+
if level is None:
|
|
172
|
+
return None
|
|
173
|
+
level = from_ticks(to_ticks(level, tick), tick) # enforce on-grid
|
|
174
|
+
open_price = from_ticks(to_ticks(bar.open, tick), tick) # enforce on-grid
|
|
175
|
+
touch = self._config.fill_limit_on_touch
|
|
176
|
+
|
|
177
|
+
if _is_buy(order.side):
|
|
178
|
+
if bar.open < level or (touch and bar.open == level):
|
|
179
|
+
# Marketable at the open: genuine price improvement (or an
|
|
180
|
+
# exact open-touch in touch mode) — fill at the open.
|
|
181
|
+
return _Execution(price=open_price, trigger=open_price, seq=0, at_open=True)
|
|
182
|
+
# An open exactly at the level under the default is only a touch:
|
|
183
|
+
# fall through to the resting logic, which still fills at the
|
|
184
|
+
# level if the bar trades a tick THROUGH it.
|
|
185
|
+
required = level if touch else level - tick
|
|
186
|
+
if bar.low > required:
|
|
187
|
+
return None
|
|
188
|
+
seq = _first_seq_reaching_down(path, level)
|
|
189
|
+
else:
|
|
190
|
+
if bar.open > level or (touch and bar.open == level):
|
|
191
|
+
return _Execution(price=open_price, trigger=open_price, seq=0, at_open=True)
|
|
192
|
+
required = level if touch else level + tick
|
|
193
|
+
if bar.high < required:
|
|
194
|
+
return None
|
|
195
|
+
seq = _first_seq_reaching_up(path, level)
|
|
196
|
+
|
|
197
|
+
if seq is None: # bar range says reachable but path disagrees: no fill
|
|
198
|
+
return None
|
|
199
|
+
return _Execution(price=level, trigger=level, seq=seq, at_open=False)
|
|
200
|
+
|
|
201
|
+
def _stop(
|
|
202
|
+
self,
|
|
203
|
+
order: WorkingOrder,
|
|
204
|
+
bar: Bar,
|
|
205
|
+
path: PricePath,
|
|
206
|
+
tick: Decimal,
|
|
207
|
+
trigger: Decimal,
|
|
208
|
+
) -> _Execution | None:
|
|
209
|
+
"""STOP / TRAILING_STOP: gap-through fills at the open + slippage
|
|
210
|
+
(never at the trigger); an intrabar trigger fills at trigger +
|
|
211
|
+
slippage. The slipped price MAY escape the bar's high/low — real
|
|
212
|
+
slippage does, and clamping would be optimistic."""
|
|
213
|
+
slip = self._config.stop_slippage_ticks
|
|
214
|
+
trigger_ticks = to_ticks(trigger, tick) # also enforces on-grid
|
|
215
|
+
level = from_ticks(trigger_ticks, tick) # the on-grid stop level S
|
|
216
|
+
open_price = from_ticks(to_ticks(bar.open, tick), tick) # enforce on-grid
|
|
217
|
+
|
|
218
|
+
if _is_buy(order.side):
|
|
219
|
+
if bar.open >= trigger:
|
|
220
|
+
# Gapped through the stop: pay the (worse) open, plus slippage.
|
|
221
|
+
# The trigger (touch point) is the OPEN, not the stop level.
|
|
222
|
+
price = from_ticks(to_ticks(bar.open, tick) + slip, tick)
|
|
223
|
+
return _Execution(price=price, trigger=open_price, seq=0, at_open=True)
|
|
224
|
+
seq = _first_seq_reaching_up(path, trigger)
|
|
225
|
+
if seq is None:
|
|
226
|
+
return None
|
|
227
|
+
# Trigger is the stop level S; the PRICE is S slipped adverse.
|
|
228
|
+
return _Execution(
|
|
229
|
+
price=from_ticks(trigger_ticks + slip, tick),
|
|
230
|
+
trigger=level,
|
|
231
|
+
seq=seq,
|
|
232
|
+
at_open=False,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if bar.open <= trigger:
|
|
236
|
+
price = from_ticks(to_ticks(bar.open, tick) - slip, tick)
|
|
237
|
+
return _Execution(price=price, trigger=open_price, seq=0, at_open=True)
|
|
238
|
+
seq = _first_seq_reaching_down(path, trigger)
|
|
239
|
+
if seq is None:
|
|
240
|
+
return None
|
|
241
|
+
return _Execution(
|
|
242
|
+
price=from_ticks(trigger_ticks - slip, tick),
|
|
243
|
+
trigger=level,
|
|
244
|
+
seq=seq,
|
|
245
|
+
at_open=False,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_buy(side: OrderSide | int) -> bool:
|
|
250
|
+
return int(side) == int(OrderSide.BUY)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _first_seq_reaching_down(path: PricePath, level: Decimal) -> int | None:
|
|
254
|
+
"""Index of the first path segment on which price reaches ``level`` from
|
|
255
|
+
above (the segment's lower end is at/below the level)."""
|
|
256
|
+
for i in range(len(path) - 1):
|
|
257
|
+
if min(path[i].price, path[i + 1].price) <= level:
|
|
258
|
+
return i
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _first_seq_reaching_up(path: PricePath, level: Decimal) -> int | None:
|
|
263
|
+
"""Index of the first path segment on which price reaches ``level`` from
|
|
264
|
+
below (the segment's upper end is at/above the level)."""
|
|
265
|
+
for i in range(len(path) - 1):
|
|
266
|
+
if max(path[i].price, path[i + 1].price) >= level:
|
|
267
|
+
return i
|
|
268
|
+
return None
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Topstep fee model: per-side commission + exchange + NFA costs.
|
|
2
|
+
|
|
3
|
+
Conforms to ``protocols.FeeModel``. Costs are charged PER SIDE (entry AND
|
|
4
|
+
exit), split per the SDK ``HalfTradeModel`` convention::
|
|
5
|
+
|
|
6
|
+
fees = (exchange + nfa) * qty (venue + regulatory)
|
|
7
|
+
commissions = commission * qty (broker)
|
|
8
|
+
|
|
9
|
+
Rates follow docs/topstep-rules.md §7 (researched July 2026, medium
|
|
10
|
+
confidence — reconcile against a real account blotter before trusting
|
|
11
|
+
micro-scalp verdicts; for micros the round-turn cost can rival the edge).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
|
|
18
|
+
import msgspec
|
|
19
|
+
from topstep_sdk import OrderSide
|
|
20
|
+
|
|
21
|
+
from ..core.instruments import SPECS, InstrumentSpec
|
|
22
|
+
from ..protocols import Liquidity
|
|
23
|
+
|
|
24
|
+
__all__ = ["FeeSchedule", "TopstepFees"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FeeSchedule(msgspec.Struct, frozen=True):
|
|
28
|
+
"""Per-side cost components for one product (all USD Decimals)."""
|
|
29
|
+
|
|
30
|
+
commission_per_side: Decimal
|
|
31
|
+
exchange_per_side: Decimal
|
|
32
|
+
nfa_per_side: Decimal
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_MINI_COMMISSION = Decimal("0.50")
|
|
36
|
+
_MICRO_COMMISSION = Decimal("0.25")
|
|
37
|
+
|
|
38
|
+
# NFA regulatory fee per side. Topstep publishes ~$0.01-0.02; we take the
|
|
39
|
+
# conservative (higher) end so fee drag is never understated.
|
|
40
|
+
_NFA_PER_SIDE = Decimal("0.02")
|
|
41
|
+
|
|
42
|
+
# CME Group exchange + clearing, non-member, per side (docs §7).
|
|
43
|
+
# NG, SI and SIL are ESTIMATES inferred from their asset-class siblings
|
|
44
|
+
# (CL-class energy mini; GC/MGC-class metals) — not from a published rate
|
|
45
|
+
# card. Verify against the CME Fee Finder before trusting their verdicts.
|
|
46
|
+
_EXCHANGE_PER_SIDE: dict[str, Decimal] = {
|
|
47
|
+
"ES": Decimal("1.38"),
|
|
48
|
+
"NQ": Decimal("1.38"),
|
|
49
|
+
"YM": Decimal("1.38"),
|
|
50
|
+
"RTY": Decimal("1.38"),
|
|
51
|
+
"MES": Decimal("0.35"),
|
|
52
|
+
"MNQ": Decimal("0.35"),
|
|
53
|
+
"MYM": Decimal("0.35"),
|
|
54
|
+
"M2K": Decimal("0.35"),
|
|
55
|
+
"CL": Decimal("1.50"),
|
|
56
|
+
"MCL": Decimal("0.50"),
|
|
57
|
+
"NG": Decimal("1.50"), # estimate (CL-class energy mini)
|
|
58
|
+
"GC": Decimal("1.65"),
|
|
59
|
+
"MGC": Decimal("1.10"),
|
|
60
|
+
"SI": Decimal("1.65"), # estimate (GC-class metals mini)
|
|
61
|
+
"SIL": Decimal("1.10"), # estimate (MGC-class metals micro)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_DEFAULT_SCHEDULES: dict[str, FeeSchedule] = {
|
|
65
|
+
symbol: FeeSchedule(
|
|
66
|
+
commission_per_side=_MICRO_COMMISSION if spec.is_micro else _MINI_COMMISSION,
|
|
67
|
+
exchange_per_side=_EXCHANGE_PER_SIDE[symbol],
|
|
68
|
+
nfa_per_side=_NFA_PER_SIDE,
|
|
69
|
+
)
|
|
70
|
+
for symbol, spec in SPECS.items()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Unknown symbol -> conservative MINI-level default: assume the expensive
|
|
74
|
+
# (mini commission, CL-class exchange rate) case so an unrecognized product
|
|
75
|
+
# never gets its costs accidentally understated.
|
|
76
|
+
_UNKNOWN_SCHEDULE = FeeSchedule(
|
|
77
|
+
commission_per_side=Decimal("0.50"),
|
|
78
|
+
exchange_per_side=Decimal("1.50"),
|
|
79
|
+
nfa_per_side=Decimal("0.02"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TopstepFees:
|
|
84
|
+
"""Per-side Topstep fee model (``protocols.FeeModel``).
|
|
85
|
+
|
|
86
|
+
``overrides`` (keyed by product symbol) replace the built-in schedule for
|
|
87
|
+
those symbols — the calibration hook for reconciling against a real
|
|
88
|
+
account blotter.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, overrides: dict[str, FeeSchedule] | None = None) -> None:
|
|
92
|
+
self._overrides: dict[str, FeeSchedule] = dict(overrides) if overrides else {}
|
|
93
|
+
|
|
94
|
+
def schedule_for(self, symbol: str) -> FeeSchedule:
|
|
95
|
+
"""The effective schedule for ``symbol``: override > built-in > conservative default."""
|
|
96
|
+
override = self._overrides.get(symbol)
|
|
97
|
+
if override is not None:
|
|
98
|
+
return override
|
|
99
|
+
return _DEFAULT_SCHEDULES.get(symbol, _UNKNOWN_SCHEDULE)
|
|
100
|
+
|
|
101
|
+
def fee(
|
|
102
|
+
self,
|
|
103
|
+
instrument: InstrumentSpec,
|
|
104
|
+
side: OrderSide,
|
|
105
|
+
qty: int,
|
|
106
|
+
liquidity: Liquidity,
|
|
107
|
+
) -> tuple[Decimal, Decimal]:
|
|
108
|
+
"""Cost of one side of ``qty`` contracts: ``(fees, commissions)``.
|
|
109
|
+
|
|
110
|
+
``fees = (exchange + nfa) * qty``; ``commissions = commission * qty``
|
|
111
|
+
(the SDK ``HalfTradeModel`` split). Topstep's schedule does not vary
|
|
112
|
+
by side or maker/taker at this tier, so ``side``/``liquidity`` are
|
|
113
|
+
accepted for protocol conformance but do not change the amounts.
|
|
114
|
+
"""
|
|
115
|
+
if qty <= 0:
|
|
116
|
+
raise ValueError(f"qty must be positive, got {qty}")
|
|
117
|
+
schedule = self.schedule_for(instrument.symbol)
|
|
118
|
+
fees = (schedule.exchange_per_side + schedule.nfa_per_side) * qty
|
|
119
|
+
commissions = schedule.commission_per_side * qty
|
|
120
|
+
return fees, commissions
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Deterministic intrabar price path construction (Tier-0).
|
|
2
|
+
|
|
3
|
+
One path is built per bar and shared by the fill model AND the rule engine's
|
|
4
|
+
breach check, so the relative ordering of fills vs. rule breaches inside a bar
|
|
5
|
+
is decided by ONE walk of the bar — never two competing opinions.
|
|
6
|
+
|
|
7
|
+
The path is always four points, ``seq`` 0..3::
|
|
8
|
+
|
|
9
|
+
OPEN -> EXTREME_FIRST -> EXTREME_SECOND -> CLOSE
|
|
10
|
+
|
|
11
|
+
The only degree of freedom is which extreme comes first, and it is resolved
|
|
12
|
+
PESSIMISTICALLY against the trader's open position:
|
|
13
|
+
|
|
14
|
+
* net long -> the LOW comes first (adverse move hits stops/breaches earliest)
|
|
15
|
+
* net short -> the HIGH comes first
|
|
16
|
+
* flat -> the extreme NEARER the open comes first (the statistically more
|
|
17
|
+
likely first touch); an exact tie breaks to the LOW, deterministically.
|
|
18
|
+
|
|
19
|
+
Degenerate bars (``high == low``, doji, single-print) still yield a valid
|
|
20
|
+
4-point path — points may repeat a price, and zero-length segments are fine.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from ..protocols import Bar, PathPoint, PointKind, PricePath
|
|
26
|
+
|
|
27
|
+
__all__ = ["build_path"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_path(bar: Bar, position_dir: int) -> PricePath:
|
|
31
|
+
"""Build the pessimistic 4-point intrabar path for ``bar``.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
bar: The completed OHLCV bar to walk.
|
|
35
|
+
position_dir: The trader's net position direction while this bar
|
|
36
|
+
prints: ``+1`` net long, ``-1`` net short, ``0`` flat.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The 4-point ``PricePath`` (seq 0..3): OPEN, the adverse-first ordering
|
|
40
|
+
of the two extremes, then CLOSE.
|
|
41
|
+
"""
|
|
42
|
+
if position_dir > 0:
|
|
43
|
+
# Long: the low is the adverse extreme — visit it first.
|
|
44
|
+
first, second = bar.low, bar.high
|
|
45
|
+
elif position_dir < 0:
|
|
46
|
+
# Short: the high is the adverse extreme — visit it first.
|
|
47
|
+
first, second = bar.high, bar.low
|
|
48
|
+
elif bar.open - bar.low <= bar.high - bar.open:
|
|
49
|
+
# Flat: extreme nearer the open first; exact tie -> low first.
|
|
50
|
+
first, second = bar.low, bar.high
|
|
51
|
+
else:
|
|
52
|
+
first, second = bar.high, bar.low
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
PathPoint(seq=0, kind=PointKind.OPEN, price=bar.open),
|
|
56
|
+
PathPoint(seq=1, kind=PointKind.EXTREME_FIRST, price=first),
|
|
57
|
+
PathPoint(seq=2, kind=PointKind.EXTREME_SECOND, price=second),
|
|
58
|
+
PathPoint(seq=3, kind=PointKind.CLOSE, price=bar.close),
|
|
59
|
+
)
|