nyrobrain 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.
- nyrobrain/__init__.py +7 -0
- nyrobrain/execution/CONTEXT.md +109 -0
- nyrobrain/execution/__init__.py +78 -0
- nyrobrain/execution/aegis.py +92 -0
- nyrobrain/execution/backtest.py +184 -0
- nyrobrain/execution/candles.py +218 -0
- nyrobrain/execution/credentials.py +203 -0
- nyrobrain/execution/data.py +95 -0
- nyrobrain/execution/driver.py +219 -0
- nyrobrain/execution/emitter.py +210 -0
- nyrobrain/execution/examples/README.md +173 -0
- nyrobrain/execution/examples/__init__.py +17 -0
- nyrobrain/execution/examples/oms.py +804 -0
- nyrobrain/execution/examples/over_delivery_guard.py +83 -0
- nyrobrain/execution/examples/sizing_a_target.py +70 -0
- nyrobrain/execution/examples/your_own_placement.py +117 -0
- nyrobrain/execution/executor.py +837 -0
- nyrobrain/execution/freshness.py +108 -0
- nyrobrain/execution/ingest.py +171 -0
- nyrobrain/execution/intake.py +194 -0
- nyrobrain/execution/ladder.py +256 -0
- nyrobrain/execution/metrics.py +463 -0
- nyrobrain/execution/pending.py +96 -0
- nyrobrain/execution/placement.py +177 -0
- nyrobrain/execution/prime.py +232 -0
- nyrobrain/execution/ratelimit.py +97 -0
- nyrobrain/execution/reconciliation.py +213 -0
- nyrobrain/execution/report.py +649 -0
- nyrobrain/execution/rules.py +72 -0
- nyrobrain/execution/runner.py +328 -0
- nyrobrain/execution/sandbox.py +109 -0
- nyrobrain/execution/signal.py +236 -0
- nyrobrain/execution/soak.py +761 -0
- nyrobrain/execution/store.py +95 -0
- nyrobrain/execution/target.py +169 -0
- nyrobrain/execution/testing.py +273 -0
- nyrobrain/execution/venue.py +335 -0
- nyrobrain/py.typed +0 -0
- nyrobrain-0.1.0.dist-info/METADATA +100 -0
- nyrobrain-0.1.0.dist-info/RECORD +41 -0
- nyrobrain-0.1.0.dist-info/WHEEL +4 -0
nyrobrain/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Nyrobrain Python libraries.
|
|
2
|
+
|
|
3
|
+
This package is a namespace and deliberately exports nothing. Siblings are
|
|
4
|
+
expected alongside `nyrobrain.execution` — adrs migrates in later under the same
|
|
5
|
+
brand — so importing `nyrobrain` must never pull an engine, a transport or a
|
|
6
|
+
research stack into the process. Import the subpackage you want.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# nyrobrain.execution
|
|
2
|
+
|
|
3
|
+
The shared language of the execution library. A glossary, not a spec: it fixes what each word means so that the library, the portfolio that feeds it, and the dashboards that watch it all use it the same way.
|
|
4
|
+
|
|
5
|
+
## Language
|
|
6
|
+
|
|
7
|
+
### The instruction
|
|
8
|
+
|
|
9
|
+
**Signal**:
|
|
10
|
+
One portfolio's complete target state at one instant: a target weight for every asset it holds. The whole truth, never a change set — so applying the same signal twice leaves the portfolio where applying it once did.
|
|
11
|
+
_Avoid_: Order, instruction, update, delta, rebalance
|
|
12
|
+
|
|
13
|
+
**Target weight**:
|
|
14
|
+
The share of a portfolio's notional budget an asset should occupy, signed for direction. Positive is long, negative is short. It says what to hold, never what to trade.
|
|
15
|
+
_Avoid_: Allocation, size, position size, exposure
|
|
16
|
+
|
|
17
|
+
**Gross exposure**:
|
|
18
|
+
The sum of a signal's target weights ignoring their signs — the total notional a portfolio wants deployed. A signal is invalid above 1. Distinct from net exposure, which cancels longs against shorts and therefore bounds direction rather than leverage.
|
|
19
|
+
_Avoid_: Total exposure, leverage, sum of weights
|
|
20
|
+
|
|
21
|
+
**Flat**:
|
|
22
|
+
Holding nothing in an asset. A target weight of zero and an asset's absence from a signal both mean flat; a correct publisher states the zero, and an absence is a defect worth alerting on.
|
|
23
|
+
_Avoid_: Closed, exited, zeroed, neutral
|
|
24
|
+
|
|
25
|
+
**Target**:
|
|
26
|
+
The position an asset should hold, in base units, implied by its target weight and the portfolio's budget, leverage and the current price. What we want; never what we do about it.
|
|
27
|
+
_Avoid_: Desired, goal, wanted position, ideal
|
|
28
|
+
|
|
29
|
+
**Actual**:
|
|
30
|
+
The position held at the venue. Read, never remembered — a figure the library keeps for itself between ticks is a figure that can go stale, and stale actuals are what made an OMS buy without end.
|
|
31
|
+
_Avoid_: Current, held, exchange position, real position
|
|
32
|
+
|
|
33
|
+
**Working**:
|
|
34
|
+
Every order of ours that could still fill, counted toward the position we are already committed to. Includes orders sent but not yet acknowledged: a quantity in flight has been committed even though nothing has filled and the venue has not replied.
|
|
35
|
+
_Avoid_: Open orders, pending, resting, outstanding, in-flight
|
|
36
|
+
|
|
37
|
+
**Delta**:
|
|
38
|
+
What remains to be traded for an asset: its target less its actual less its working. Derived afresh each tick, never carried forward, so an under-traded tick corrects itself and a repeated tick does nothing.
|
|
39
|
+
_Avoid_: Diff, gap, remainder, adjustment, order size
|
|
40
|
+
|
|
41
|
+
**Placement**:
|
|
42
|
+
Deciding how to work a delta into the book — how many orders, at what prices, amended or cancelled when. Owned by the user, who may substitute their own. It is told the quantity to trade and never the position, so that position arithmetic cannot re-enter through it.
|
|
43
|
+
_Avoid_: Execution, order strategy, routing, slicing
|
|
44
|
+
|
|
45
|
+
**Driver**:
|
|
46
|
+
The script that assembles an engine or a node, attaches the executor, runs it for a while and writes the report. Owned by the user, like placement — the library ships examples of one, never one of its own. What distinguishes it from placement is scope rather than ownership: placement decides how a single delta reaches the book, a driver decides which venue, which mode, which symbols and for how long.
|
|
47
|
+
_Avoid_: Runner, entrypoint, harness, main
|
|
48
|
+
|
|
49
|
+
**Committed**:
|
|
50
|
+
The quantity we already stand behind for an asset — what our live orders would add to the position if every one of them filled. Placement is judged on how much it changes this, never on the size of the orders it returns: repricing an order changes its price and commits nothing, which is what makes it legal when there is nothing left to trade.
|
|
51
|
+
_Avoid_: Exposure, pending, allocated, reserved
|
|
52
|
+
|
|
53
|
+
**Over-delivery**:
|
|
54
|
+
Placement returning orders that would trade further than the delta, or in the opposite direction. Always a defect: under-trading is bounded by doing nothing and corrects next tick, while over-trading has no ceiling and compounds.
|
|
55
|
+
_Avoid_: Overshoot, overfill, excess
|
|
56
|
+
|
|
57
|
+
**Base asset**:
|
|
58
|
+
What a signal names — the underlying an alpha has a view on, such as `BTC`. Independent of any venue.
|
|
59
|
+
_Avoid_: Ticker, symbol, instrument, coin
|
|
60
|
+
|
|
61
|
+
**Symbol**:
|
|
62
|
+
What a venue trades, such as `BTCUSDT`. One base asset maps to a different symbol on each venue; the mapping is configuration, never part of a signal.
|
|
63
|
+
_Avoid_: Pair, instrument, market, asset
|
|
64
|
+
|
|
65
|
+
**Sandbox**:
|
|
66
|
+
Paper trading against live market data with simulated fills. Needs no exchange credentials, and is therefore not a venue — no order it produces exists anywhere, and its results are a simulation whose realism is a setting rather than a fact.
|
|
67
|
+
_Avoid_: Paper, demo, testnet, simulation, dry run
|
|
68
|
+
|
|
69
|
+
### Provenance and ordering
|
|
70
|
+
|
|
71
|
+
**Publisher**:
|
|
72
|
+
The process that computes a portfolio's target state and emits signals. It owns the portfolio's alphas and their aggregation; the library is only ever its consumer.
|
|
73
|
+
_Avoid_: Producer, sender, portfolio process, upstream
|
|
74
|
+
|
|
75
|
+
**Epoch**:
|
|
76
|
+
A publisher process's identity — fixed for its lifetime and different after every restart. It exists so a restarted publisher, whose sequence has returned to zero, still outranks the stream it replaced.
|
|
77
|
+
_Avoid_: Run id, generation, boot id, version
|
|
78
|
+
|
|
79
|
+
**Sequence**:
|
|
80
|
+
A signal's position within one epoch, increasing with each signal that publisher emits. Meaningless across epochs on its own.
|
|
81
|
+
_Avoid_: Index, counter, offset, id
|
|
82
|
+
|
|
83
|
+
**Supersede**:
|
|
84
|
+
What a signal does to the last one applied when it is genuinely newer, judged on epoch and sequence together. A signal that does not supersede is inert rather than erroneous: redelivery is expected traffic.
|
|
85
|
+
_Avoid_: Override, replace, win, update
|
|
86
|
+
|
|
87
|
+
### Time and health
|
|
88
|
+
|
|
89
|
+
**Expected gap**:
|
|
90
|
+
How long a publisher says it will be before it speaks again, taken from its own aggregation cadence. Not the interval of the alphas it aggregates — those are usually far longer, and a deadline built on them would never fire.
|
|
91
|
+
_Avoid_: Interval, period, frequency, cadence, alpha interval
|
|
92
|
+
|
|
93
|
+
**Missed deadline**:
|
|
94
|
+
The expected gap elapsing, plus grace, without a signal arriving. It describes the health of the feed, never the validity of a signal: one that arrives afterwards is still applied, and applying it is what clears the condition.
|
|
95
|
+
_Avoid_: Timeout, stale signal, late signal, expired
|
|
96
|
+
|
|
97
|
+
**Escalated**:
|
|
98
|
+
The state a portfolio enters after several consecutive missed deadlines — its publisher is presumed gone rather than slow. Escalation stops new orders and cancels resting ones; it never exits positions, because a crash-looping process would then exit and rebuy in a loop, paying the spread each cycle.
|
|
99
|
+
_Avoid_: Halted, stopped, paused, killed, panicked
|
|
100
|
+
|
|
101
|
+
**Actionable**:
|
|
102
|
+
Young enough to trade on. A signal can be the newest ever seen and still too old to act on, in which case it is not traded — a signal that stale means the publisher was broken, and guessing is worse than stopping.
|
|
103
|
+
_Avoid_: Fresh, valid, current, live
|
|
104
|
+
|
|
105
|
+
### Rejection
|
|
106
|
+
|
|
107
|
+
**Invalid**:
|
|
108
|
+
Untrustworthy, and therefore not applied at all. Rejection is always whole-signal: applying the sound part of a target state would leave a position the strategy never asked for and cannot reason about.
|
|
109
|
+
_Avoid_: Malformed, bad, partial, unparseable
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Order execution: portfolio signal in, exchange orders out.
|
|
2
|
+
|
|
3
|
+
The library's job is target-position reconciliation. A signal names a target
|
|
4
|
+
weight per symbol; this package decides what orders that implies against the
|
|
5
|
+
position actually held at the venue, and places them.
|
|
6
|
+
|
|
7
|
+
Nothing nautilus-shaped crosses this package's public boundary. Callers work in
|
|
8
|
+
plain types — symbols as `str`, quantities and weights as `Decimal` — so the
|
|
9
|
+
engine underneath stays an implementation detail and hooks stay testable without
|
|
10
|
+
constructing framework objects.
|
|
11
|
+
|
|
12
|
+
Public so far: the input type, and the sizing and guard arithmetic the executor
|
|
13
|
+
is built on. The executor itself — the nautilus Strategy that drives these on a
|
|
14
|
+
timer — arrives with the hook-surface and testing-surface tickets.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from nyrobrain.execution.intake import Fetch, SignalSource
|
|
18
|
+
from nyrobrain.execution.ladder import LadderConfig, TouchLadder
|
|
19
|
+
from nyrobrain.execution.metrics import AegisMetrics, Fanout, Metric, MetricSink
|
|
20
|
+
from nyrobrain.execution.placement import (
|
|
21
|
+
Action,
|
|
22
|
+
Amend,
|
|
23
|
+
Cancel,
|
|
24
|
+
Context,
|
|
25
|
+
Place,
|
|
26
|
+
Placement,
|
|
27
|
+
Resting,
|
|
28
|
+
UnknownOrder,
|
|
29
|
+
committed_change,
|
|
30
|
+
validate,
|
|
31
|
+
)
|
|
32
|
+
from nyrobrain.execution.signal import (
|
|
33
|
+
MAX_GROSS_EXPOSURE,
|
|
34
|
+
SCHEMA_VERSION,
|
|
35
|
+
InvalidSignal,
|
|
36
|
+
Signal,
|
|
37
|
+
parse_signal,
|
|
38
|
+
)
|
|
39
|
+
from nyrobrain.execution.target import (
|
|
40
|
+
OverDelivery,
|
|
41
|
+
check_intent,
|
|
42
|
+
round_price,
|
|
43
|
+
round_quantity,
|
|
44
|
+
target_quantity,
|
|
45
|
+
trade_delta,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"MAX_GROSS_EXPOSURE",
|
|
50
|
+
"SCHEMA_VERSION",
|
|
51
|
+
"Action",
|
|
52
|
+
"AegisMetrics",
|
|
53
|
+
"Amend",
|
|
54
|
+
"Cancel",
|
|
55
|
+
"Context",
|
|
56
|
+
"Fanout",
|
|
57
|
+
"Fetch",
|
|
58
|
+
"InvalidSignal",
|
|
59
|
+
"LadderConfig",
|
|
60
|
+
"Metric",
|
|
61
|
+
"MetricSink",
|
|
62
|
+
"OverDelivery",
|
|
63
|
+
"Place",
|
|
64
|
+
"Placement",
|
|
65
|
+
"Resting",
|
|
66
|
+
"Signal",
|
|
67
|
+
"SignalSource",
|
|
68
|
+
"TouchLadder",
|
|
69
|
+
"UnknownOrder",
|
|
70
|
+
"check_intent",
|
|
71
|
+
"committed_change",
|
|
72
|
+
"parse_signal",
|
|
73
|
+
"round_price",
|
|
74
|
+
"round_quantity",
|
|
75
|
+
"target_quantity",
|
|
76
|
+
"trade_delta",
|
|
77
|
+
"validate",
|
|
78
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Publishing metrics to Aegis over the JetStream sidecar.
|
|
2
|
+
|
|
3
|
+
Requires the ``aegis`` extra, which carries the gRPC client. The payloads — and
|
|
4
|
+
every decision about what they contain — live in `nyrobrain.execution.metrics`,
|
|
5
|
+
which needs nothing installed at all. A second transport replaces this file and
|
|
6
|
+
inherits the rest.
|
|
7
|
+
|
|
8
|
+
**This is not a NATS client.** `bq-nats-client` depends only on `grpcio` and
|
|
9
|
+
`protobuf`: publishers reach JetStream through a sidecar authenticated by an API
|
|
10
|
+
key and hold no NATS credentials. That is convenient here, and it is also why
|
|
11
|
+
portfolio targets deliberately live in a different account — the sidecar applies
|
|
12
|
+
no per-subject authorization, so anything publishable through it is publishable
|
|
13
|
+
by any holder of a key.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
from nyrobrain.execution.metrics import Metric
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JetStreamClient(Protocol):
|
|
25
|
+
"""The part of `nats_client.NATSClient` this uses.
|
|
26
|
+
|
|
27
|
+
Stated as a protocol so a test can drive the real sink without a sidecar,
|
|
28
|
+
and so the dependency is visible at a glance rather than implied by an
|
|
29
|
+
import.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
async def jetstream(self) -> None: ...
|
|
33
|
+
|
|
34
|
+
async def js_publish(
|
|
35
|
+
self,
|
|
36
|
+
subject: str,
|
|
37
|
+
payload: bytes = b"",
|
|
38
|
+
headers: dict[str, str] | None = None,
|
|
39
|
+
) -> object: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JetStreamSink:
|
|
43
|
+
"""A `MetricSink` that publishes through the sidecar.
|
|
44
|
+
|
|
45
|
+
Deliberately thin, and deliberately not defensive: a publish that fails
|
|
46
|
+
raises. The emitter counts the failure and the report shows it, so a sink
|
|
47
|
+
that swallowed exceptions would turn a dead metrics pipeline into one that
|
|
48
|
+
looks healthy — the exact failure this map exists to prevent, and the one
|
|
49
|
+
nothing downstream detects.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, open_client: Callable[[], JetStreamClient]) -> None:
|
|
53
|
+
#: A factory, not a client, and that is the whole point. `NATSClient`
|
|
54
|
+
#: binds its gRPC channel to an event loop when it is **constructed**,
|
|
55
|
+
#: and a driver builds its publisher before the node has a loop — so a
|
|
56
|
+
#: ready-made client binds to a loop that is closed by the time the
|
|
57
|
+
#: first heartbeat fires, and every publish dies with "attached to a
|
|
58
|
+
#: different loop". Deferring construction to the first publish means
|
|
59
|
+
#: the channel is built in whichever loop is actually going to use it.
|
|
60
|
+
#:
|
|
61
|
+
#: Measured the hard way: a live run reported `published 0, failures 3`
|
|
62
|
+
#: and nothing else until the round started recording the reason.
|
|
63
|
+
self._open_client = open_client
|
|
64
|
+
self._client: JetStreamClient | None = None
|
|
65
|
+
|
|
66
|
+
async def connect(self) -> None:
|
|
67
|
+
"""Build the client and configure JetStream, once.
|
|
68
|
+
|
|
69
|
+
Call this only from the loop that will publish — or not at all, since
|
|
70
|
+
`publish` does it. `js_publish` refuses with "call jetstream() first"
|
|
71
|
+
until it has run, and doing it per publish would add a round trip to
|
|
72
|
+
every row.
|
|
73
|
+
"""
|
|
74
|
+
if self._client is not None:
|
|
75
|
+
return
|
|
76
|
+
client = self._open_client()
|
|
77
|
+
await client.jetstream()
|
|
78
|
+
self._client = client
|
|
79
|
+
|
|
80
|
+
async def publish(self, metric: Metric) -> None:
|
|
81
|
+
# Lazily, so the channel is built in whichever loop is publishing.
|
|
82
|
+
await self.connect()
|
|
83
|
+
assert self._client is not None
|
|
84
|
+
# `msg_id` becomes `Nats-Msg-Id` here and nowhere else. The client does
|
|
85
|
+
# `headers.setdefault("Nats-Msg-Id", uuid4())`, so omitting it would
|
|
86
|
+
# publish successfully while silently losing every cross-publish
|
|
87
|
+
# collapse — retries would still be idempotent, and nothing else would.
|
|
88
|
+
await self._client.js_publish(
|
|
89
|
+
metric.subject,
|
|
90
|
+
metric.payload,
|
|
91
|
+
headers={"Nats-Msg-Id": metric.msg_id},
|
|
92
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Assembling a backtest over a recorded signal timeseries.
|
|
2
|
+
|
|
3
|
+
Requires the ``execution`` extra.
|
|
4
|
+
|
|
5
|
+
`backtest_engine` hands the engine back **unrun**, so the caller decides when
|
|
6
|
+
and whether to run it. That is not ceremony: this function has more callers
|
|
7
|
+
inside the test suite than out of it — the property-based fuzz drives it at 500
|
|
8
|
+
examples with deliberately-broken placements — and those callers need the
|
|
9
|
+
assembly without the run.
|
|
10
|
+
|
|
11
|
+
For a driver that uses it, see `nyrobrain.execution.examples.oms`.
|
|
12
|
+
|
|
13
|
+
Signals and quotes go into `BacktestEngine` as **one timestamp-ordered stream**,
|
|
14
|
+
so they interleave exactly as they would live. That is the whole reason signals
|
|
15
|
+
are a `Data` subclass rather than something handed to the strategy directly —
|
|
16
|
+
hand-sequencing them against the quote timeline is where an off-by-one becomes a
|
|
17
|
+
backtest that looks fine and means nothing.
|
|
18
|
+
|
|
19
|
+
`BacktestEngine` gives the strategy a `TestClock`, so timers fire as the data
|
|
20
|
+
advances and a week of replay does not take a week.
|
|
21
|
+
|
|
22
|
+
**What a run here can and cannot tell you** is set out in `candles`: with quotes
|
|
23
|
+
synthesized from 1m bars, this proves the wiring, the convergence and the
|
|
24
|
+
invariants. It is not evidence about fill quality, and the report withholds
|
|
25
|
+
those numbers rather than inviting the mistake.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import dataclasses
|
|
31
|
+
from collections.abc import Callable, Sequence
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from nautilus_trader.backtest import BacktestEngine
|
|
35
|
+
from nautilus_trader.config import BacktestEngineConfig, LoggerConfig
|
|
36
|
+
from nautilus_trader.model import (
|
|
37
|
+
AccountType,
|
|
38
|
+
ClientId,
|
|
39
|
+
CryptoPerpetual,
|
|
40
|
+
Currency,
|
|
41
|
+
Money,
|
|
42
|
+
OmsType,
|
|
43
|
+
TraderId,
|
|
44
|
+
Venue,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from nyrobrain.execution.candles import QuoteSource
|
|
48
|
+
from nyrobrain.execution.data import as_custom_data
|
|
49
|
+
from nyrobrain.execution.executor import TargetPositionExecutor
|
|
50
|
+
from nyrobrain.execution.placement import Placement
|
|
51
|
+
from nyrobrain.execution.report import (
|
|
52
|
+
RunRecorder,
|
|
53
|
+
)
|
|
54
|
+
from nyrobrain.execution.store import read_signals
|
|
55
|
+
from nyrobrain.execution.venue import InstrumentsNotReady, VenueConfig
|
|
56
|
+
|
|
57
|
+
#: The cache holds 1m bars, and the quote cadence follows from that.
|
|
58
|
+
CANDLE_INTERVAL_NS = 60 * 1_000_000_000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stale_quote_threshold_for(quote_interval_ns: int) -> int:
|
|
62
|
+
"""How old a quote may get before a candle-fed run stops believing it.
|
|
63
|
+
|
|
64
|
+
Three intervals — the same rule the signal deadline uses, so the number
|
|
65
|
+
follows the data rather than the other way round.
|
|
66
|
+
|
|
67
|
+
This exists because the live default is 30 seconds and a candle source is
|
|
68
|
+
three orders of magnitude slower. `candles._expand` stamps all four quotes
|
|
69
|
+
of a bar with the bar's own timestamp, so a 1m source emits four quotes and
|
|
70
|
+
then goes quiet for 59 seconds; against 30s that reads as a dead feed for
|
|
71
|
+
half of every minute. Measured before the override existed: **50,395 of
|
|
72
|
+
70,553 ticks skipped** across a seven-day replay, with the whole suite
|
|
73
|
+
green, because skipping a tick breaks no invariant. It only stops the run
|
|
74
|
+
meaning anything.
|
|
75
|
+
|
|
76
|
+
Named and returned rather than inlined so the rule can be tested directly,
|
|
77
|
+
without standing up an engine to observe it.
|
|
78
|
+
"""
|
|
79
|
+
return 3 * quote_interval_ns
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def backtest_engine(
|
|
83
|
+
*,
|
|
84
|
+
signals: Path,
|
|
85
|
+
config: VenueConfig,
|
|
86
|
+
instruments: Sequence[CryptoPerpetual],
|
|
87
|
+
quotes: QuoteSource,
|
|
88
|
+
placement_for: Callable[[str], Placement] | None = None,
|
|
89
|
+
quote_interval_ns: int = CANDLE_INTERVAL_NS,
|
|
90
|
+
) -> tuple[BacktestEngine, RunRecorder]:
|
|
91
|
+
"""Assemble the engine, run it, and hand back what happened.
|
|
92
|
+
|
|
93
|
+
`instruments` is yours to supply, and there is no default. A backtest has no
|
|
94
|
+
venue to ask, so the tick size, the lot size and the fee schedule are
|
|
95
|
+
modelling inputs — they belong in your project where they can be reviewed,
|
|
96
|
+
not in a file inside this package that is silently authoritative and dated
|
|
97
|
+
to whenever someone last refreshed it. Snapshot them from a live connection
|
|
98
|
+
with `write_instruments(path, cache.instruments())`, or describe them with
|
|
99
|
+
`venue.perpetual(...)`.
|
|
100
|
+
|
|
101
|
+
`quotes` is a `QuoteSource` — a function from instrument to quotes — rather
|
|
102
|
+
than a directory, because a directory is not a format. This used to be
|
|
103
|
+
`candles: Path`, which meant "laid out the way we lay ours out": sharded
|
|
104
|
+
parquet with a date in the filename, produced by nothing outside this
|
|
105
|
+
project. A user whose candles are a CSV, a database, or nautilus's own
|
|
106
|
+
catalog had no way in. `candles.parquet_shards(dir)` is ours, named where
|
|
107
|
+
it is used; anything matching the signature is equally welcome.
|
|
108
|
+
|
|
109
|
+
The date window and the synthetic spread went with it, which is why this
|
|
110
|
+
signature is shorter than it was. Both are properties of a particular
|
|
111
|
+
source — a window matched against a filename, a spread invented because
|
|
112
|
+
candles have no book — and neither means anything to a loader reading real
|
|
113
|
+
quotes, which would have been handed them regardless.
|
|
114
|
+
|
|
115
|
+
`placement_for` is the executor's own resolver, exposed here because a
|
|
116
|
+
driver that can only run the default ladder cannot exercise the guard that
|
|
117
|
+
exists to contain a *misbehaving* placement — and that guard is the one
|
|
118
|
+
protecting users who write their own.
|
|
119
|
+
"""
|
|
120
|
+
# The stale-quote threshold has to match the cadence of the quotes actually
|
|
121
|
+
# being fed, and this driver's are three orders of magnitude slower than a
|
|
122
|
+
# live ticker's. `QuoteTickDataWrangler` stamps all four quotes of a bar with
|
|
123
|
+
# the bar's own timestamp, so a 1m candle source emits four quotes and then
|
|
124
|
+
# goes quiet for 59 seconds — and the live default of 30s reads that as a
|
|
125
|
+
# dead feed for half of every minute. Measured before this existed: 50,395 of
|
|
126
|
+
# 70,553 ticks skipped across a seven-day replay, with the whole suite green,
|
|
127
|
+
# because skipping a tick breaks no invariant. It only stops the run meaning
|
|
128
|
+
# anything.
|
|
129
|
+
#
|
|
130
|
+
# Three intervals, the same rule the signal deadline uses, so the number
|
|
131
|
+
# follows the data rather than the other way round.
|
|
132
|
+
config = dataclasses.replace(
|
|
133
|
+
config,
|
|
134
|
+
freshness=dataclasses.replace(
|
|
135
|
+
config.freshness,
|
|
136
|
+
stale_quote_ns=stale_quote_threshold_for(quote_interval_ns),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Two lists that have to agree, so check it here rather than let it surface
|
|
141
|
+
# as a run that quietly trades nothing. A backtest has no excuse for a
|
|
142
|
+
# missing definition — there is no venue still connecting, only a driver
|
|
143
|
+
# that named a symbol and did not describe it. Live and paper keep the
|
|
144
|
+
# retry, because there "not yet" is a real answer.
|
|
145
|
+
described = {i.raw_symbol.value for i in instruments}
|
|
146
|
+
undescribed = [s for s in config.symbols if s not in described]
|
|
147
|
+
if undescribed:
|
|
148
|
+
raise InstrumentsNotReady(
|
|
149
|
+
f"no instrument described for {', '.join(undescribed)}. A backtest "
|
|
150
|
+
"has no venue to ask, so every configured symbol needs one: add it "
|
|
151
|
+
"with `venue.perpetual(...)`, or snapshot the real definitions with "
|
|
152
|
+
"`write_instruments(path, cache.instruments())` and load that. "
|
|
153
|
+
f"Described: {', '.join(sorted(described)) or '(none)'}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
engine = BacktestEngine(
|
|
157
|
+
config=BacktestEngineConfig(
|
|
158
|
+
trader_id=TraderId("NYRO-001"), logging=LoggerConfig(bypass_logging=True)
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
engine.add_venue(
|
|
162
|
+
venue=Venue(config.venue),
|
|
163
|
+
oms_type=OmsType.NETTING,
|
|
164
|
+
account_type=AccountType.MARGIN,
|
|
165
|
+
base_currency=Currency.from_str(config.currency),
|
|
166
|
+
starting_balances=[
|
|
167
|
+
Money(config.starting_balance, Currency.from_str(config.currency)) # type: ignore[arg-type] # v2 stubs say float; the runtime takes Decimal exactly (verified) and float would lose it
|
|
168
|
+
],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
for instrument in instruments:
|
|
172
|
+
engine.add_instrument(instrument)
|
|
173
|
+
engine.add_data(list(quotes(instrument)))
|
|
174
|
+
|
|
175
|
+
engine.add_data(
|
|
176
|
+
[as_custom_data(s) for s in read_signals(signals)],
|
|
177
|
+
client_id=ClientId("NYRO"),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
recorder = RunRecorder()
|
|
181
|
+
engine.add_strategy(
|
|
182
|
+
TargetPositionExecutor(config, recorder=recorder, placement_for=placement_for)
|
|
183
|
+
)
|
|
184
|
+
return engine, recorder
|