outcometick 1.4.0
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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/api/lib/backtest-contract.mjs +318 -0
- package/api/lib/backtest-datasets.mjs +225 -0
- package/api/lib/backtest-manifest.mjs +345 -0
- package/api/lib/coverage-window.mjs +42 -0
- package/api/lib/data-taxonomy.mjs +175 -0
- package/api/lib/venue-path.mjs +16 -0
- package/bin/ot.mjs +4 -0
- package/cli/api-client.mjs +71 -0
- package/cli/commands/fetch.mjs +43 -0
- package/cli/commands/run.mjs +269 -0
- package/cli/commands/status.mjs +102 -0
- package/cli/commands/submit.mjs +77 -0
- package/cli/local-data.mjs +177 -0
- package/cli/ot.mjs +223 -0
- package/index.d.ts +195 -0
- package/index.mjs +2 -0
- package/package.json +58 -0
- package/runner/analyze/index.mjs +40 -0
- package/runner/analyze/javascript.mjs +380 -0
- package/runner/analyze/python.mjs +85 -0
- package/runner/analyze/python_analyze.py +320 -0
- package/runner/archive.mjs +185 -0
- package/runner/engine/book.mjs +226 -0
- package/runner/engine/portfolio.mjs +292 -0
- package/runner/engine/replay.mjs +496 -0
- package/runner/engine/report.mjs +417 -0
- package/runner/events.mjs +190 -0
- package/runner/harness/node/harness.mjs +467 -0
- package/runner/harness/node/sdk/index.d.ts +195 -0
- package/runner/harness/node/sdk/index.mjs +71 -0
- package/runner/harness/node/sdk/package.json +8 -0
- package/runner/harness/protocol.mjs +255 -0
- package/runner/harness/python/harness.py +374 -0
- package/runner/harness/python/otengine.py +523 -0
- package/runner/harness/python/otreplay.py +409 -0
- package/runner/harness/python/outcometick.py +67 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""The event loop and the ctx object, ported from runner/engine/replay.mjs.
|
|
2
|
+
|
|
3
|
+
Same rules as the JavaScript version and the same order of operations, because
|
|
4
|
+
the conformance vectors compare them row for row. In particular:
|
|
5
|
+
|
|
6
|
+
- pending orders are drained against the book as it stood BEFORE the current
|
|
7
|
+
event is applied, then again after, so a delayed order cannot fill against
|
|
8
|
+
depth that arrived after it;
|
|
9
|
+
- hold_s is measured from the FILL, not from the decision, because a fill that
|
|
10
|
+
landed late has not been held as long;
|
|
11
|
+
- instance state resets per market unless the run is in session mode, which is
|
|
12
|
+
the property that lets a run be sharded at all.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
from otengine import Book, BudgetMonitor, Portfolio, Rec, RunAbort, make_rng
|
|
21
|
+
|
|
22
|
+
HOOK_FOR = {"tick": "on_tick", "book": "on_book", "trade": "on_trade"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Live books, keyed by the view that fronts them.
|
|
26
|
+
#
|
|
27
|
+
# The first version stored the Book on the view as `_b`. Python has no privacy
|
|
28
|
+
# and the analyser cannot blanket-refuse single-underscore attributes (a
|
|
29
|
+
# strategy's own `self._entered` is normal), so `ctx.book()._b.ladders[...]`
|
|
30
|
+
# reached the engine-owned ladder — verified: a strategy inserted a level that
|
|
31
|
+
# never existed and filled 1000 contracts at $0.01 in a market whose real book
|
|
32
|
+
# held 10 at $0.90.
|
|
33
|
+
#
|
|
34
|
+
# With the reference in a side table there is no attribute to find.
|
|
35
|
+
_BOOKS: dict[int, Book] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BookView:
|
|
39
|
+
"""A read-only view of a book — the mirror of bookView() in replay.mjs."""
|
|
40
|
+
|
|
41
|
+
__slots__ = ("__weakref__",)
|
|
42
|
+
|
|
43
|
+
def __init__(self, book):
|
|
44
|
+
_BOOKS[id(self)] = book
|
|
45
|
+
|
|
46
|
+
def __setattr__(self, name, value):
|
|
47
|
+
raise AttributeError("the book is read-only")
|
|
48
|
+
|
|
49
|
+
def __getattr__(self, name):
|
|
50
|
+
raise AttributeError(f"{name!r} does not exist on a book view")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def market_id(self):
|
|
54
|
+
return _BOOKS[id(self)].market_id
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ts(self):
|
|
58
|
+
return _BOOKS[id(self)].ts
|
|
59
|
+
|
|
60
|
+
def best(self, side):
|
|
61
|
+
return _BOOKS[id(self)].best(side)
|
|
62
|
+
|
|
63
|
+
def best_bid(self, side):
|
|
64
|
+
return _BOOKS[id(self)].best_bid(side)
|
|
65
|
+
|
|
66
|
+
def depth(self, side, bound=None):
|
|
67
|
+
return _BOOKS[id(self)].depth(side, bound)
|
|
68
|
+
|
|
69
|
+
def bid_depth(self, side, bound=None):
|
|
70
|
+
return _BOOKS[id(self)].bid_depth(side, bound)
|
|
71
|
+
|
|
72
|
+
def levels(self, side, n=10):
|
|
73
|
+
return _BOOKS[id(self)].levels(side, n)
|
|
74
|
+
|
|
75
|
+
def bid_levels(self, side, n=10):
|
|
76
|
+
return _BOOKS[id(self)].bid_levels(side, n)
|
|
77
|
+
|
|
78
|
+
def mid(self, side):
|
|
79
|
+
return _BOOKS[id(self)].mid(side)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Engine internals, keyed by the Ctx that fronts them.
|
|
83
|
+
#
|
|
84
|
+
# Deliberately NOT attributes on Ctx. The earlier version held `_pf`, `_book`
|
|
85
|
+
# and `_history` as ordinary underscore-prefixed fields, so a strategy could
|
|
86
|
+
# reach `ctx._pf.trades` and push a fabricated settled trade into the report —
|
|
87
|
+
# invent a profit, or delete a real loss, and the worker archived it as fact.
|
|
88
|
+
#
|
|
89
|
+
# Python has no true privacy, so this is defence in depth rather than a wall:
|
|
90
|
+
# the side table means there is no attribute to find, `__getattr__` below
|
|
91
|
+
# refuses the old names outright, and the static analyser already refuses
|
|
92
|
+
# `getattr`, `vars` and dunder attribute access, which are the ways back in.
|
|
93
|
+
_INTERNALS: dict[int, dict] = {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Ctx:
|
|
97
|
+
"""The strategy's whole world.
|
|
98
|
+
|
|
99
|
+
Everything the runner will let a strategy touch is on this object; anything
|
|
100
|
+
not here does not exist in the process.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
__slots__ = ("p", "market_id", "__weakref__")
|
|
104
|
+
|
|
105
|
+
def __init__(self, params, portfolio, market_id, log_limit, references,
|
|
106
|
+
series, rng):
|
|
107
|
+
object.__setattr__(self, "p", params)
|
|
108
|
+
object.__setattr__(self, "market_id", market_id)
|
|
109
|
+
_INTERNALS[id(self)] = {
|
|
110
|
+
"now": 0,
|
|
111
|
+
"pf": portfolio,
|
|
112
|
+
"book": None,
|
|
113
|
+
"history": [],
|
|
114
|
+
"logs": [],
|
|
115
|
+
"log_limit": log_limit,
|
|
116
|
+
"refs": references or {},
|
|
117
|
+
"series": series or {},
|
|
118
|
+
"rng": rng,
|
|
119
|
+
"crosschecks": [],
|
|
120
|
+
"log_truncated": False,
|
|
121
|
+
"market": None,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def now(self):
|
|
126
|
+
"""Read-only: ctx.ref()/ctx.ext() use it as the point-in-time cursor,
|
|
127
|
+
so a strategy that could assign it would read future rows."""
|
|
128
|
+
return _INTERNALS[id(self)]["now"]
|
|
129
|
+
|
|
130
|
+
def __getattr__(self, name):
|
|
131
|
+
# Every name, including `_s` — which used to be a convenience property
|
|
132
|
+
# and was therefore a documented route to the live Portfolio and Book.
|
|
133
|
+
# The internals live in a module-level table keyed by id(self); there is
|
|
134
|
+
# no attribute on this object that leads to them.
|
|
135
|
+
raise AttributeError(
|
|
136
|
+
f"{name!r} does not exist on ctx; a strategy reaches the engine "
|
|
137
|
+
"only through the documented methods"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def __setattr__(self, name, value):
|
|
141
|
+
raise AttributeError("ctx is read-only")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def book(self, market_id: str | None = None) -> Book | None:
|
|
145
|
+
if market_id and market_id != self.market_id:
|
|
146
|
+
# Cross-market reads are what session mode is for. Answering here
|
|
147
|
+
# would silently break the sharding guarantee.
|
|
148
|
+
raise RunAbort(
|
|
149
|
+
"E_STATE",
|
|
150
|
+
f'ctx.book({market_id}) from market {self.market_id}: '
|
|
151
|
+
'cross-market state needs mode "session"',
|
|
152
|
+
)
|
|
153
|
+
return _INTERNALS[id(self)]["view"]
|
|
154
|
+
|
|
155
|
+
def history(self, n: int = 1) -> list[dict]:
|
|
156
|
+
hist = _INTERNALS[id(self)]["history"]
|
|
157
|
+
k = max(0, min(int(n or 0), len(hist)))
|
|
158
|
+
# COPIES: handing back the live rows would let a strategy rewrite the
|
|
159
|
+
# series its own indicators are computed from.
|
|
160
|
+
return [Rec(row) for row in hist[len(hist) - k:]]
|
|
161
|
+
|
|
162
|
+
def position(self) -> dict:
|
|
163
|
+
s = _INTERNALS[id(self)]
|
|
164
|
+
return s["pf"].position(self.market_id, s["book"])
|
|
165
|
+
|
|
166
|
+
def log(self, msg: Any) -> None:
|
|
167
|
+
s = _INTERNALS[id(self)]
|
|
168
|
+
if len(s["logs"]) >= s["log_limit"]:
|
|
169
|
+
s["log_truncated"] = True
|
|
170
|
+
return
|
|
171
|
+
s["logs"].append(f'{s["now"]} {msg}')
|
|
172
|
+
|
|
173
|
+
def random(self, seed: int | None = None):
|
|
174
|
+
return _INTERNALS[id(self)]["rng"](seed)
|
|
175
|
+
|
|
176
|
+
def ref(self, name: str):
|
|
177
|
+
feed = _INTERNALS[id(self)]["refs"].get(name)
|
|
178
|
+
if feed is None:
|
|
179
|
+
raise RunAbort("E_MANIFEST", f"reference feed {name} was not declared in the manifest")
|
|
180
|
+
return feed.view_at(self.now)
|
|
181
|
+
|
|
182
|
+
def ext(self, name: str):
|
|
183
|
+
entry = _INTERNALS[id(self)]["series"].get(name)
|
|
184
|
+
if entry is None:
|
|
185
|
+
raise RunAbort("E_MANIFEST", f"series {name} was not declared in the manifest")
|
|
186
|
+
return entry.view_at(self.now)
|
|
187
|
+
|
|
188
|
+
# ---- rolling helpers. Numerically identical to the JavaScript versions. ----
|
|
189
|
+
|
|
190
|
+
def _tail(self, window: int) -> list[float]:
|
|
191
|
+
hist = _INTERNALS[id(self)]["history"]
|
|
192
|
+
n = max(1, min(int(window or 1), len(hist)))
|
|
193
|
+
return [t.get("value") for t in hist[len(hist) - n:]]
|
|
194
|
+
|
|
195
|
+
def zscore(self, value: float, window: int = 60) -> float:
|
|
196
|
+
xs = self._tail(window)
|
|
197
|
+
if len(xs) < 2:
|
|
198
|
+
return 0.0
|
|
199
|
+
mean = sum(xs) / len(xs)
|
|
200
|
+
variance = sum((x - mean) ** 2 for x in xs) / len(xs)
|
|
201
|
+
sd = variance ** 0.5
|
|
202
|
+
return 0.0 if sd == 0 else (value - mean) / sd
|
|
203
|
+
|
|
204
|
+
def sma(self, window: int = 60):
|
|
205
|
+
xs = self._tail(window)
|
|
206
|
+
return (sum(xs) / len(xs)) if xs else None
|
|
207
|
+
|
|
208
|
+
def stdev(self, window: int = 60) -> float:
|
|
209
|
+
xs = self._tail(window)
|
|
210
|
+
if len(xs) < 2:
|
|
211
|
+
return 0.0
|
|
212
|
+
mean = sum(xs) / len(xs)
|
|
213
|
+
return (sum((x - mean) ** 2 for x in xs) / len(xs)) ** 0.5
|
|
214
|
+
|
|
215
|
+
def ema(self, window: int = 60):
|
|
216
|
+
xs = self._tail(window)
|
|
217
|
+
if not xs:
|
|
218
|
+
return None
|
|
219
|
+
k = 2 / (len(xs) + 1)
|
|
220
|
+
acc = 0.0
|
|
221
|
+
for i, x in enumerate(xs):
|
|
222
|
+
acc = x if i == 0 else x * k + acc * (1 - k)
|
|
223
|
+
return acc
|
|
224
|
+
|
|
225
|
+
def assert_outcome(self, _market: Any, outcome: Any) -> None:
|
|
226
|
+
"""Record a cross-check. Never fails the run — it is information.
|
|
227
|
+
|
|
228
|
+
The first argument is IGNORED for everything that matters: it used to
|
|
229
|
+
supply both `official` and `market_id`, so a strategy could book itself
|
|
230
|
+
a recompute match that never happened. The panel's whole value is that
|
|
231
|
+
it is the ARCHIVE's answer. Mirrors replay.mjs.
|
|
232
|
+
"""
|
|
233
|
+
s = _INTERNALS[id(self)]
|
|
234
|
+
official = (s["market"] or {}).get("outcome")
|
|
235
|
+
s["crosschecks"].append({
|
|
236
|
+
"market_id": self.market_id,
|
|
237
|
+
"claimed": outcome,
|
|
238
|
+
"official": official,
|
|
239
|
+
"match": official == outcome,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def replay_market(*, market: dict, events: list, strategy, hooks: dict,
|
|
244
|
+
portfolio: Portfolio | None = None, fill_delay_ms: int = 0,
|
|
245
|
+
log_limit: int = 10_000, budget: BudgetMonitor | None = None,
|
|
246
|
+
references=None, series=None, seed: int = 1,
|
|
247
|
+
fee_bps: float = 0) -> dict:
|
|
248
|
+
market_id = market["market_id"]
|
|
249
|
+
# Attribute access for everything a hook is handed: the docs say
|
|
250
|
+
# `market.strike` and `tick.value`, and they have to be true here.
|
|
251
|
+
# Pre-settle view: `outcome` is a future fact and is stripped. See the
|
|
252
|
+
# matching comment in replay.mjs — a strategy that read it in
|
|
253
|
+
# on_market_open could buy the winning side and the report became
|
|
254
|
+
# meaningless. Only on_settle sees it.
|
|
255
|
+
market_rec = Rec({k: v for k, v in market.items() if k != "outcome"})
|
|
256
|
+
settle_rec = Rec(market)
|
|
257
|
+
pf = portfolio if portfolio is not None else Portfolio(fee_bps=fee_bps)
|
|
258
|
+
book = Book(market_id)
|
|
259
|
+
monitor = budget if budget is not None else BudgetMonitor()
|
|
260
|
+
ctx = Ctx(getattr(strategy, "p", {}) or {}, pf, market_id, log_limit,
|
|
261
|
+
references, series, make_rng(seed))
|
|
262
|
+
state = _INTERNALS[id(ctx)]
|
|
263
|
+
state["book"] = book
|
|
264
|
+
# The engine's own copy of the market, for assert_outcome.
|
|
265
|
+
state["market"] = dict(market)
|
|
266
|
+
state["view"] = BookView(book)
|
|
267
|
+
|
|
268
|
+
pending: list[dict] = []
|
|
269
|
+
|
|
270
|
+
def schedule(at: int, kind: str, payload) -> None:
|
|
271
|
+
i = len(pending)
|
|
272
|
+
while i > 0 and pending[i - 1]["at"] > at:
|
|
273
|
+
i -= 1
|
|
274
|
+
pending.insert(i, {"at": at, "kind": kind, "payload": payload})
|
|
275
|
+
|
|
276
|
+
def drain_until(ts) -> None:
|
|
277
|
+
while pending and pending[0]["at"] <= ts:
|
|
278
|
+
job = pending.pop(0)
|
|
279
|
+
if job["kind"] == "order":
|
|
280
|
+
order = job["payload"]
|
|
281
|
+
res = pf.execute(book, order, job["at"], market_id, how="exit")
|
|
282
|
+
hold = order.get("hold_s") if isinstance(order, dict) else None
|
|
283
|
+
if res and res["filled"] > 0 and hold and not order.get("reduce_only"):
|
|
284
|
+
schedule(job["at"] + int(hold) * 1000, "flatten", {"side": order.get("side")})
|
|
285
|
+
else:
|
|
286
|
+
pf.flatten(market_id, book, job["at"], "hold_expired")
|
|
287
|
+
|
|
288
|
+
def call(canonical: str, *args):
|
|
289
|
+
name = hooks.get(canonical)
|
|
290
|
+
fn = getattr(strategy, name, None) if name else None
|
|
291
|
+
if not callable(fn):
|
|
292
|
+
return None
|
|
293
|
+
t0 = time.perf_counter_ns()
|
|
294
|
+
try:
|
|
295
|
+
out = fn(ctx, *args)
|
|
296
|
+
except RunAbort:
|
|
297
|
+
raise
|
|
298
|
+
except Exception as err: # noqa: BLE001 - a strategy may raise anything
|
|
299
|
+
raise RunAbort("E_RUNTIME", f"{canonical} threw: {err}") from err
|
|
300
|
+
monitor.record((time.perf_counter_ns() - t0) / 1000)
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
def emit(out, ts: int) -> None:
|
|
304
|
+
if out is None:
|
|
305
|
+
return
|
|
306
|
+
orders = out if isinstance(out, list) else [out]
|
|
307
|
+
for order in orders:
|
|
308
|
+
if order is None:
|
|
309
|
+
continue
|
|
310
|
+
row = _as_order(order)
|
|
311
|
+
# Mirrors replay.mjs: refused rather than silently executed as a
|
|
312
|
+
# one-shot IOC.
|
|
313
|
+
tif = row.get("tif") or "ioc"
|
|
314
|
+
if tif != "ioc":
|
|
315
|
+
raise RunAbort(
|
|
316
|
+
"E_MANIFEST",
|
|
317
|
+
f'tif {tif!r} is not supported — only "ioc". Resting orders need a '
|
|
318
|
+
"queue-position model, and guessing at one inflates returns by multiples.",
|
|
319
|
+
)
|
|
320
|
+
schedule(ts + fill_delay_ms, "order", row)
|
|
321
|
+
|
|
322
|
+
call("on_market_open", market_rec)
|
|
323
|
+
if monitor.breached:
|
|
324
|
+
raise RunAbort("E_BUDGET", f"per-event budget exceeded: {monitor.summary()}")
|
|
325
|
+
|
|
326
|
+
# `events` is any ITERABLE, not necessarily a list — the harness passes a
|
|
327
|
+
# generator that pulls one line off stdin per step, so the future is not in
|
|
328
|
+
# the process at all. Nothing below may index it or take its length.
|
|
329
|
+
#
|
|
330
|
+
# A market with no declared close has no cutoff; the last event seen becomes
|
|
331
|
+
# the close, tracked as we go rather than peeked.
|
|
332
|
+
declared_close = market.get("close_ts_ms")
|
|
333
|
+
close_ts = declared_close if declared_close else float("inf")
|
|
334
|
+
seen = 0
|
|
335
|
+
last_ts = 0
|
|
336
|
+
|
|
337
|
+
for ev in events:
|
|
338
|
+
seen += 1
|
|
339
|
+
ts = ev["ts_ms"]
|
|
340
|
+
last_ts = ts
|
|
341
|
+
# Nothing past the close reaches a hook, the book, or the history.
|
|
342
|
+
if ts > close_ts:
|
|
343
|
+
break
|
|
344
|
+
# Everything scheduled strictly before this event resolves against the
|
|
345
|
+
# book as it stood then.
|
|
346
|
+
drain_until(ts - 1)
|
|
347
|
+
state["now"] = ts
|
|
348
|
+
|
|
349
|
+
if ev.get("kind") == "book":
|
|
350
|
+
if ev.get("snapshot"):
|
|
351
|
+
book.snapshot(ts, ev.get("levels") or {})
|
|
352
|
+
else:
|
|
353
|
+
book.delta(ts, ev.get("side"), ev.get("ladder"), ev.get("px"), ev.get("size"))
|
|
354
|
+
drain_until(ts)
|
|
355
|
+
|
|
356
|
+
ev_rec = Rec(ev)
|
|
357
|
+
if ev.get("kind") == "tick":
|
|
358
|
+
# A separate copy from the one the hook is handed — see the matching
|
|
359
|
+
# comment in replay.mjs.
|
|
360
|
+
state["history"].append(Rec(ev))
|
|
361
|
+
|
|
362
|
+
hook = HOOK_FOR.get(ev.get("kind"))
|
|
363
|
+
if hook and hooks.get(hook):
|
|
364
|
+
emit(call(hook, ev_rec), ts)
|
|
365
|
+
|
|
366
|
+
if monitor.breached:
|
|
367
|
+
raise RunAbort("E_BUDGET", f"per-event budget exceeded: {monitor.summary()}")
|
|
368
|
+
|
|
369
|
+
# Queued work lands AT THE CLOSE, never at its own future timestamp — see
|
|
370
|
+
# the matching comment in replay.mjs. Both engines or neither.
|
|
371
|
+
settle_ts = declared_close if declared_close else last_ts
|
|
372
|
+
state["now"] = settle_ts
|
|
373
|
+
drain_until(settle_ts)
|
|
374
|
+
pending.clear()
|
|
375
|
+
|
|
376
|
+
call("on_settle", settle_rec, market.get("outcome"))
|
|
377
|
+
settled = pf.settle(market_id, market["outcome"], settle_ts) if market.get("outcome") else []
|
|
378
|
+
|
|
379
|
+
view = state.pop("view", None)
|
|
380
|
+
if view is not None:
|
|
381
|
+
_BOOKS.pop(id(view), None)
|
|
382
|
+
_INTERNALS.pop(id(ctx), None)
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
"market_id": market_id,
|
|
386
|
+
"asset": market.get("asset"),
|
|
387
|
+
# What the engine PULLED, not what the caller had — see replay.mjs.
|
|
388
|
+
"events": seen,
|
|
389
|
+
"settled": settled,
|
|
390
|
+
"logs": state["logs"],
|
|
391
|
+
"log_truncated": state["log_truncated"],
|
|
392
|
+
"crosschecks": state["crosschecks"],
|
|
393
|
+
"budget": monitor.summary(),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _as_order(order) -> dict:
|
|
398
|
+
"""Accept either an Order object or a plain dict from a strategy."""
|
|
399
|
+
if isinstance(order, dict):
|
|
400
|
+
return order
|
|
401
|
+
return {
|
|
402
|
+
"side": getattr(order, "side", None),
|
|
403
|
+
"size": getattr(order, "size", 0),
|
|
404
|
+
"limit": getattr(order, "limit", None),
|
|
405
|
+
"hold_s": getattr(order, "hold_s", None),
|
|
406
|
+
"reduce_only": getattr(order, "reduce_only", False),
|
|
407
|
+
"tif": getattr(order, "tif", "ioc"),
|
|
408
|
+
"tag": getattr(order, "tag", None),
|
|
409
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The SDK surface a submitted Python strategy imports.
|
|
2
|
+
|
|
3
|
+
Deliberately tiny. `Strategy` is a base class that exists so `entry` can be
|
|
4
|
+
checked against something, and `Order` is a value object. Everything a strategy
|
|
5
|
+
can actually DO arrives through `ctx`, which the runner constructs — there is no
|
|
6
|
+
way to reach the outside from here, because there is nothing here to reach it
|
|
7
|
+
with.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
# Named explicitly so `from __future__ import annotations` does not leak
|
|
13
|
+
# `annotations` into the package's public surface — this module is also
|
|
14
|
+
# published to PyPI, where dir(outcometick) is what a user reads as the API.
|
|
15
|
+
__all__ = ("Strategy", "Order", "SIDES")
|
|
16
|
+
|
|
17
|
+
SIDES = ("UP", "DOWN")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Strategy:
|
|
21
|
+
"""Base class for a submitted strategy.
|
|
22
|
+
|
|
23
|
+
The hooks are not defined here on purpose. A default no-op `on_tick` would
|
|
24
|
+
turn "you declared a hook you did not implement" — a rejection the submitter
|
|
25
|
+
can fix in seconds — into a run that quietly never trades and bills for an
|
|
26
|
+
empty equity curve.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
#: Params from the manifest, injected by the runner before the first hook.
|
|
30
|
+
p: dict = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Order:
|
|
34
|
+
"""An order a hook returns. Never sent — returned, and matched by the runner
|
|
35
|
+
against the depth that was actually resting at that millisecond.
|
|
36
|
+
|
|
37
|
+
`limit` is a bound in whichever direction protects you: a ceiling when
|
|
38
|
+
opening, a floor when reducing.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__slots__ = ("side", "size", "limit", "hold_s", "reduce_only", "tif", "tag")
|
|
42
|
+
|
|
43
|
+
def __init__(self, side, size, limit=None, hold_s=None, reduce_only=False,
|
|
44
|
+
tif="ioc", tag=None):
|
|
45
|
+
if side not in SIDES:
|
|
46
|
+
raise ValueError(f'side must be "UP" or "DOWN", got {side!r}')
|
|
47
|
+
if not (isinstance(size, (int, float)) and size > 0):
|
|
48
|
+
raise ValueError(f"size must be a positive number, got {size!r}")
|
|
49
|
+
if limit is not None and not (0 <= float(limit) <= 1):
|
|
50
|
+
# A binary outcome token trades between 0 and 1. A limit outside
|
|
51
|
+
# that is not a price, and silently clamping it would fill an order
|
|
52
|
+
# the strategy never asked for.
|
|
53
|
+
raise ValueError(f"limit must be between 0 and 1, got {limit!r}")
|
|
54
|
+
if tif != "ioc":
|
|
55
|
+
# Not modelled, so not accepted. See "Not supported yet" in the docs.
|
|
56
|
+
raise ValueError(f'tif must be "ioc"; {tif!r} is not supported yet')
|
|
57
|
+
self.side = side
|
|
58
|
+
self.size = float(size)
|
|
59
|
+
self.limit = None if limit is None else float(limit)
|
|
60
|
+
self.hold_s = None if hold_s is None else int(hold_s)
|
|
61
|
+
self.reduce_only = bool(reduce_only)
|
|
62
|
+
self.tif = tif
|
|
63
|
+
self.tag = tag
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return (f"Order(side={self.side!r}, size={self.size}, limit={self.limit}, "
|
|
67
|
+
f"reduce_only={self.reduce_only})")
|