desic-okx-agent 0.2.1 → 0.3.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/README.en.md +90 -10
- package/README.md +76 -10
- package/dist/account/private-websocket.js +4 -4
- package/dist/account/private-websocket.js.map +1 -1
- package/dist/account/service.d.ts +12 -1
- package/dist/account/service.js +18 -0
- package/dist/account/service.js.map +1 -1
- package/dist/bars/rate-limiter.d.ts +18 -0
- package/dist/bars/rate-limiter.js +84 -0
- package/dist/bars/rate-limiter.js.map +1 -0
- package/dist/bars/schema.d.ts +36 -0
- package/dist/bars/schema.js +134 -0
- package/dist/bars/schema.js.map +1 -0
- package/dist/bars/service.d.ts +60 -0
- package/dist/bars/service.js +120 -0
- package/dist/bars/service.js.map +1 -0
- package/dist/bars/store.d.ts +105 -0
- package/dist/bars/store.js +415 -0
- package/dist/bars/store.js.map +1 -0
- package/dist/bars/timeframe.d.ts +40 -0
- package/dist/bars/timeframe.js +146 -0
- package/dist/bars/timeframe.js.map +1 -0
- package/dist/bars/types.d.ts +68 -0
- package/dist/bars/types.js +13 -0
- package/dist/bars/types.js.map +1 -0
- package/dist/cli/data-render.d.ts +37 -0
- package/dist/cli/data-render.js +143 -0
- package/dist/cli/data-render.js.map +1 -0
- package/dist/cli/doctor.js +7 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +757 -26
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/live-render.d.ts +24 -0
- package/dist/cli/live-render.js +85 -0
- package/dist/cli/live-render.js.map +1 -0
- package/dist/cli/range.d.ts +28 -0
- package/dist/cli/range.js +63 -0
- package/dist/cli/range.js.map +1 -0
- package/dist/cli/render.js +3 -0
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/strategy-render.d.ts +36 -0
- package/dist/cli/strategy-render.js +391 -0
- package/dist/cli/strategy-render.js.map +1 -0
- package/dist/cli/width.d.ts +18 -0
- package/dist/cli/width.js +71 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/config/loader.js +1 -1
- package/dist/config/schema.d.ts +7 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/core/okx-client.d.ts +9 -1
- package/dist/core/okx-client.js +14 -5
- package/dist/core/okx-client.js.map +1 -1
- package/dist/i18n/locale.d.ts +24 -0
- package/dist/i18n/locale.js +65 -0
- package/dist/i18n/locale.js.map +1 -0
- package/dist/i18n/messages.d.ts +333 -0
- package/dist/i18n/messages.js +660 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/live/account-snapshot.d.ts +30 -0
- package/dist/live/account-snapshot.js +130 -0
- package/dist/live/account-snapshot.js.map +1 -0
- package/dist/live/cutoff-queue.d.ts +42 -0
- package/dist/live/cutoff-queue.js +69 -0
- package/dist/live/cutoff-queue.js.map +1 -0
- package/dist/live/execution-key.d.ts +23 -0
- package/dist/live/execution-key.js +31 -0
- package/dist/live/execution-key.js.map +1 -0
- package/dist/live/failures.d.ts +37 -0
- package/dist/live/failures.js +57 -0
- package/dist/live/failures.js.map +1 -0
- package/dist/live/gates.d.ts +65 -0
- package/dist/live/gates.js +136 -0
- package/dist/live/gates.js.map +1 -0
- package/dist/live/loop.d.ts +56 -0
- package/dist/live/loop.js +197 -0
- package/dist/live/loop.js.map +1 -0
- package/dist/live/preconditions.d.ts +48 -0
- package/dist/live/preconditions.js +69 -0
- package/dist/live/preconditions.js.map +1 -0
- package/dist/live/reconcile.d.ts +46 -0
- package/dist/live/reconcile.js +104 -0
- package/dist/live/reconcile.js.map +1 -0
- package/dist/live/runner.d.ts +57 -0
- package/dist/live/runner.js +160 -0
- package/dist/live/runner.js.map +1 -0
- package/dist/live/schema.d.ts +18 -0
- package/dist/live/schema.js +91 -0
- package/dist/live/schema.js.map +1 -0
- package/dist/live/service.d.ts +144 -0
- package/dist/live/service.js +303 -0
- package/dist/live/service.js.map +1 -0
- package/dist/live/session.d.ts +85 -0
- package/dist/live/session.js +234 -0
- package/dist/live/session.js.map +1 -0
- package/dist/live/sizing.d.ts +62 -0
- package/dist/live/sizing.js +79 -0
- package/dist/live/sizing.js.map +1 -0
- package/dist/live/store.d.ts +123 -0
- package/dist/live/store.js +350 -0
- package/dist/live/store.js.map +1 -0
- package/dist/live/types.d.ts +82 -0
- package/dist/live/types.js +2 -0
- package/dist/live/types.js.map +1 -0
- package/dist/market/websocket.d.ts +16 -1
- package/dist/market/websocket.js +60 -5
- package/dist/market/websocket.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +15 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/network/connectivity.d.ts +9 -1
- package/dist/network/connectivity.js +28 -1
- package/dist/network/connectivity.js.map +1 -1
- package/dist/report/chart-script.d.ts +12 -0
- package/dist/report/chart-script.js +146 -0
- package/dist/report/chart-script.js.map +1 -0
- package/dist/report/compare-html.d.ts +8 -0
- package/dist/report/compare-html.js +254 -0
- package/dist/report/compare-html.js.map +1 -0
- package/dist/report/compare-script.d.ts +12 -0
- package/dist/report/compare-script.js +109 -0
- package/dist/report/compare-script.js.map +1 -0
- package/dist/report/compare.d.ts +61 -0
- package/dist/report/compare.js +205 -0
- package/dist/report/compare.js.map +1 -0
- package/dist/report/fetch.d.ts +20 -0
- package/dist/report/fetch.js +56 -0
- package/dist/report/fetch.js.map +1 -0
- package/dist/report/html.d.ts +54 -0
- package/dist/report/html.js +641 -0
- package/dist/report/html.js.map +1 -0
- package/dist/report/open.d.ts +42 -0
- package/dist/report/open.js +114 -0
- package/dist/report/open.js.map +1 -0
- package/dist/runtime/server.d.ts +16 -1
- package/dist/runtime/server.js +112 -9
- package/dist/runtime/server.js.map +1 -1
- package/dist/setup/installer.d.ts +1 -0
- package/dist/setup/installer.js +8 -0
- package/dist/setup/installer.js.map +1 -1
- package/dist/setup/wizard.js +19 -22
- package/dist/setup/wizard.js.map +1 -1
- package/dist/strategy/constants.d.ts +23 -0
- package/dist/strategy/constants.js +24 -0
- package/dist/strategy/constants.js.map +1 -0
- package/dist/strategy/environment.d.ts +52 -0
- package/dist/strategy/environment.js +187 -0
- package/dist/strategy/environment.js.map +1 -0
- package/dist/strategy/instrument.d.ts +29 -0
- package/dist/strategy/instrument.js +39 -0
- package/dist/strategy/instrument.js.map +1 -0
- package/dist/strategy/optimize.d.ts +73 -0
- package/dist/strategy/optimize.js +113 -0
- package/dist/strategy/optimize.js.map +1 -0
- package/dist/strategy/parameter-space.d.ts +59 -0
- package/dist/strategy/parameter-space.js +221 -0
- package/dist/strategy/parameter-space.js.map +1 -0
- package/dist/strategy/python-bridge.d.ts +24 -0
- package/dist/strategy/python-bridge.js +114 -0
- package/dist/strategy/python-bridge.js.map +1 -0
- package/dist/strategy/schema.d.ts +9 -0
- package/dist/strategy/schema.js +91 -0
- package/dist/strategy/schema.js.map +1 -0
- package/dist/strategy/service.d.ts +138 -0
- package/dist/strategy/service.js +745 -0
- package/dist/strategy/service.js.map +1 -0
- package/dist/strategy/settings.d.ts +162 -0
- package/dist/strategy/settings.js +243 -0
- package/dist/strategy/settings.js.map +1 -0
- package/dist/strategy/store.d.ts +96 -0
- package/dist/strategy/store.js +367 -0
- package/dist/strategy/store.js.map +1 -0
- package/dist/strategy/templates.d.ts +11 -0
- package/dist/strategy/templates.js +134 -0
- package/dist/strategy/templates.js.map +1 -0
- package/dist/strategy/types.d.ts +111 -0
- package/dist/strategy/types.js +2 -0
- package/dist/strategy/types.js.map +1 -0
- package/dist/tools/catalog.d.ts +16 -0
- package/dist/tools/catalog.js +118 -17
- package/dist/tools/catalog.js.map +1 -1
- package/dist/trade/service.d.ts +12 -0
- package/dist/trade/service.js +24 -7
- package/dist/trade/service.js.map +1 -1
- package/dist/tui/app.d.ts +23 -0
- package/dist/tui/app.js +322 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/commands.d.ts +70 -0
- package/dist/tui/commands.js +313 -0
- package/dist/tui/commands.js.map +1 -0
- package/dist/tui/entries.d.ts +17 -0
- package/dist/tui/entries.js +24 -0
- package/dist/tui/entries.js.map +1 -0
- package/dist/tui/execute.d.ts +26 -0
- package/dist/tui/execute.js +664 -0
- package/dist/tui/execute.js.map +1 -0
- package/dist/tui/history.d.ts +17 -0
- package/dist/tui/history.js +48 -0
- package/dist/tui/history.js.map +1 -0
- package/dist/tui/index.d.ts +8 -0
- package/dist/tui/index.js +48 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/line-editor.d.ts +44 -0
- package/dist/tui/line-editor.js +98 -0
- package/dist/tui/line-editor.js.map +1 -0
- package/dist/tui/progress.d.ts +23 -0
- package/dist/tui/progress.js +46 -0
- package/dist/tui/progress.js.map +1 -0
- package/dist/tui/settings-editor.d.ts +18 -0
- package/dist/tui/settings-editor.js +115 -0
- package/dist/tui/settings-editor.js.map +1 -0
- package/docs/live-trading.md +455 -0
- package/docs/strategy-research.md +597 -0
- package/package.json +10 -1
- package/python/desic_strategy/__init__.py +34 -0
- package/python/desic_strategy/actions.py +158 -0
- package/python/desic_strategy/context.py +164 -0
- package/python/desic_strategy/engine.py +614 -0
- package/python/desic_strategy/indicators.py +159 -0
- package/python/desic_strategy/live.py +253 -0
- package/python/desic_strategy/policy.py +193 -0
- package/python/desic_strategy/portfolio.py +152 -0
- package/python/desic_strategy/report.py +319 -0
- package/python/desic_strategy/runner.py +574 -0
- package/python/desic_strategy/timeframe.py +150 -0
- package/python/main.py +18 -0
- package/skills/okx-live-trading/SKILL.md +117 -0
- package/skills/okx-live-trading/agents/openai.yaml +9 -0
- package/skills/okx-live-trading/references/lifecycle.md +128 -0
- package/skills/okx-strategy-research/SKILL.md +113 -0
- package/skills/okx-strategy-research/agents/openai.yaml +9 -0
- package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
- package/skills/okx-strategy-research/references/field-traps.md +142 -0
- package/skills/okx-strategy-research/references/python-api.md +121 -0
- package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
- package/skills/okx-trading/SKILL.md +11 -10
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""The backtest event loop.
|
|
2
|
+
|
|
3
|
+
Execution timing is the core contract: a decision is made at a confirmed
|
|
4
|
+
one-minute close and a market action fills at the *following* bar's open. A
|
|
5
|
+
limit action becomes a pending order at that open and is matched against later
|
|
6
|
+
bars using a deliberately conservative K-line estimate.
|
|
7
|
+
|
|
8
|
+
There is no historical order-book queue here, so a fill is never a promise
|
|
9
|
+
about real exchange latency, queue position, or liquidity.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from .actions import (
|
|
17
|
+
CANCEL_ORDER,
|
|
18
|
+
CLOSE_LONG,
|
|
19
|
+
CLOSE_SHORT,
|
|
20
|
+
LIMIT,
|
|
21
|
+
NO_ACTION,
|
|
22
|
+
OPEN_LONG,
|
|
23
|
+
OPEN_SHORT,
|
|
24
|
+
Decision,
|
|
25
|
+
StrategyError,
|
|
26
|
+
)
|
|
27
|
+
from .context import MarketView, SeriesSet, StrategyContext, immutable_params
|
|
28
|
+
from .indicators import IndicatorView
|
|
29
|
+
from .portfolio import (
|
|
30
|
+
LONG,
|
|
31
|
+
SHORT,
|
|
32
|
+
AccountState,
|
|
33
|
+
Fill,
|
|
34
|
+
MutablePosition,
|
|
35
|
+
OpenOrder,
|
|
36
|
+
PortfolioView,
|
|
37
|
+
Position,
|
|
38
|
+
Trade,
|
|
39
|
+
)
|
|
40
|
+
from .timeframe import Bar
|
|
41
|
+
|
|
42
|
+
# A limit order may take at most this share of a bar's volume. Assuming more
|
|
43
|
+
# would let a backtest fill size the live book could not absorb.
|
|
44
|
+
BAR_VOLUME_FILL_CAP = 0.10
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Instrument:
|
|
49
|
+
"""Contract specification, as reported by the exchange.
|
|
50
|
+
|
|
51
|
+
``contract_value`` is base currency per contract and ``contract_multiplier``
|
|
52
|
+
scales it. Both are needed: OKX reports them separately, and folding the
|
|
53
|
+
multiplier into the value would hide a mismatch when only one changes.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
inst_id: str
|
|
57
|
+
contract_value: float
|
|
58
|
+
lot_size: float
|
|
59
|
+
min_size: float
|
|
60
|
+
tick_size: float
|
|
61
|
+
contract_multiplier: float = 1.0
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def base_per_contract(self) -> float:
|
|
65
|
+
"""Base-currency units one contract represents."""
|
|
66
|
+
return self.contract_value * self.contract_multiplier
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Costs:
|
|
71
|
+
taker_fee_rate: float = 0.0005
|
|
72
|
+
maker_fee_rate: float = 0.0002
|
|
73
|
+
entry_slippage_bps: float = 1.0
|
|
74
|
+
exit_slippage_bps: float = 1.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Sizing:
|
|
79
|
+
"""Host-owned position sizing. A strategy cannot influence these."""
|
|
80
|
+
|
|
81
|
+
leverage: float = 10.0
|
|
82
|
+
margin_safety_multiplier: float = 1.0
|
|
83
|
+
entry_budget_usdt: float | None = None
|
|
84
|
+
entry_budget_pct: float = 0.20
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class RunConfig:
|
|
89
|
+
instrument: Instrument
|
|
90
|
+
initial_equity_usdt: float = 10_000.0
|
|
91
|
+
costs: Costs = field(default_factory=Costs)
|
|
92
|
+
sizing: Sizing = field(default_factory=Sizing)
|
|
93
|
+
preload_bars: int = 2
|
|
94
|
+
close_at_end: bool = True
|
|
95
|
+
limit_order_ttl_bars: int = 60
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class EquityPoint:
|
|
100
|
+
time_ms: int
|
|
101
|
+
equity_usdt: float
|
|
102
|
+
realized_cash_usdt: float
|
|
103
|
+
unrealized_pnl_usdt: float
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class PendingAction:
|
|
108
|
+
decision: Decision
|
|
109
|
+
submitted_at_ms: int
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class RunResult:
|
|
114
|
+
status: str
|
|
115
|
+
equity_curve: list[EquityPoint]
|
|
116
|
+
fills: list[Fill]
|
|
117
|
+
trades: list[Trade]
|
|
118
|
+
actions: list[dict[str, object]]
|
|
119
|
+
evaluation_bars: int
|
|
120
|
+
exposed_bars: int
|
|
121
|
+
margin_exhausted: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Engine:
|
|
125
|
+
"""Drives one strategy over one contiguous confirmed one-minute window."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, config: RunConfig, handlers: dict[str, object], params: dict[str, object]) -> None:
|
|
128
|
+
self._config = config
|
|
129
|
+
self._handlers = handlers
|
|
130
|
+
self._params = immutable_params(params)
|
|
131
|
+
self._state = AccountState(cash_usdt=config.initial_equity_usdt)
|
|
132
|
+
self._sequence = 0
|
|
133
|
+
self._margin_exhausted = False
|
|
134
|
+
|
|
135
|
+
def run(
|
|
136
|
+
self,
|
|
137
|
+
bars: list[Bar],
|
|
138
|
+
intervals: list[str],
|
|
139
|
+
on_progress: object = None,
|
|
140
|
+
) -> RunResult:
|
|
141
|
+
config = self._config
|
|
142
|
+
instrument = config.instrument
|
|
143
|
+
preload = config.preload_bars
|
|
144
|
+
if len(bars) <= preload:
|
|
145
|
+
raise StrategyError("The window must contain at least one evaluation bar after preload")
|
|
146
|
+
|
|
147
|
+
series = SeriesSet(instrument.inst_id, intervals)
|
|
148
|
+
equity_curve: list[EquityPoint] = []
|
|
149
|
+
actions_log: list[dict[str, object]] = []
|
|
150
|
+
pending: PendingAction | None = None
|
|
151
|
+
started = False
|
|
152
|
+
exposed_bars = 0
|
|
153
|
+
evaluation_bars = 0
|
|
154
|
+
|
|
155
|
+
for index, bar in enumerate(bars):
|
|
156
|
+
series.push(bar)
|
|
157
|
+
is_evaluation = index >= preload
|
|
158
|
+
|
|
159
|
+
if is_evaluation:
|
|
160
|
+
# A decision from the previous close acts at this open, before
|
|
161
|
+
# anything else can observe the resulting position.
|
|
162
|
+
if pending is not None:
|
|
163
|
+
self._apply_at_open(pending, bar)
|
|
164
|
+
pending = None
|
|
165
|
+
self._match_open_orders(bar)
|
|
166
|
+
self._apply_protective_exit(bar)
|
|
167
|
+
self._enforce_margin(bar)
|
|
168
|
+
|
|
169
|
+
unrealized = self._unrealized(bar.close)
|
|
170
|
+
equity_curve.append(
|
|
171
|
+
EquityPoint(
|
|
172
|
+
time_ms=bar.close_time_ms,
|
|
173
|
+
equity_usdt=self._state.cash_usdt + unrealized,
|
|
174
|
+
realized_cash_usdt=self._state.cash_usdt,
|
|
175
|
+
unrealized_pnl_usdt=unrealized,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
evaluation_bars += 1
|
|
179
|
+
if self._state.position is not None:
|
|
180
|
+
exposed_bars += 1
|
|
181
|
+
|
|
182
|
+
# The final preloaded bar closes exactly at the evaluation start, so
|
|
183
|
+
# it supplies the first decision context while staying out of every
|
|
184
|
+
# reported statistic.
|
|
185
|
+
if not (is_evaluation or index + 1 == preload):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
context = self._context(bar, index + 1, bars, series)
|
|
189
|
+
if not started:
|
|
190
|
+
started = True
|
|
191
|
+
start_handler = self._handlers.get("on_start")
|
|
192
|
+
if callable(start_handler):
|
|
193
|
+
decision = start_handler(context)
|
|
194
|
+
self._require_no_action(decision)
|
|
195
|
+
|
|
196
|
+
handler = self._handlers.get("on_bar")
|
|
197
|
+
if not callable(handler):
|
|
198
|
+
raise StrategyError("A strategy must define on_bar(ctx)")
|
|
199
|
+
decision = handler(context)
|
|
200
|
+
decision = self._validate(decision)
|
|
201
|
+
if decision.action != NO_ACTION:
|
|
202
|
+
actions_log.append({"asOfMs": bar.close_time_ms, **decision.as_dict()})
|
|
203
|
+
if decision.action == CANCEL_ORDER:
|
|
204
|
+
self._cancel_order(decision.order_id)
|
|
205
|
+
else:
|
|
206
|
+
pending = PendingAction(decision, bar.close_time_ms)
|
|
207
|
+
|
|
208
|
+
if callable(on_progress) and (index + 1) % 512 == 0:
|
|
209
|
+
on_progress(index + 1, len(bars))
|
|
210
|
+
|
|
211
|
+
last_bar = bars[-1]
|
|
212
|
+
if config.close_at_end and self._state.position is not None:
|
|
213
|
+
self._close_position(last_bar.close_time_ms, self._exit_price(last_bar.close, self._state.position.side), "end_of_run")
|
|
214
|
+
if equity_curve:
|
|
215
|
+
equity_curve[-1].equity_usdt = self._state.cash_usdt
|
|
216
|
+
equity_curve[-1].realized_cash_usdt = self._state.cash_usdt
|
|
217
|
+
equity_curve[-1].unrealized_pnl_usdt = 0.0
|
|
218
|
+
# The forced close makes the final bar flat, so it no longer
|
|
219
|
+
# counts toward exposure.
|
|
220
|
+
exposed_bars = max(0, exposed_bars - 1)
|
|
221
|
+
|
|
222
|
+
if callable(on_progress):
|
|
223
|
+
on_progress(len(bars), len(bars))
|
|
224
|
+
return RunResult(
|
|
225
|
+
status="completed",
|
|
226
|
+
equity_curve=equity_curve,
|
|
227
|
+
fills=list(self._state.fills),
|
|
228
|
+
trades=list(self._state.trades),
|
|
229
|
+
actions=actions_log,
|
|
230
|
+
evaluation_bars=evaluation_bars,
|
|
231
|
+
exposed_bars=exposed_bars,
|
|
232
|
+
margin_exhausted=self._margin_exhausted,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# ----- context construction -------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _context(self, bar: Bar, visible: int, bars: list[Bar], series: SeriesSet) -> StrategyContext:
|
|
238
|
+
instrument = self._config.instrument
|
|
239
|
+
market = MarketView(instrument.inst_id, bars, visible, series.snapshot())
|
|
240
|
+
return StrategyContext(
|
|
241
|
+
kind="bar",
|
|
242
|
+
as_of_ms=bar.close_time_ms,
|
|
243
|
+
instrument_id=instrument.inst_id,
|
|
244
|
+
interval="1m",
|
|
245
|
+
bar=bar,
|
|
246
|
+
market=market,
|
|
247
|
+
portfolio=self._portfolio_view(bar.close),
|
|
248
|
+
params=self._params,
|
|
249
|
+
indicators=IndicatorView(series.indicators, instrument.inst_id, visible),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _portfolio_view(self, mark_price: float) -> PortfolioView:
|
|
253
|
+
instrument = self._config.instrument
|
|
254
|
+
state = self._state
|
|
255
|
+
positions: tuple[Position, ...] = ()
|
|
256
|
+
if state.position is not None:
|
|
257
|
+
item = state.position
|
|
258
|
+
positions = (
|
|
259
|
+
Position(
|
|
260
|
+
instrumentId=instrument.inst_id,
|
|
261
|
+
side=item.side,
|
|
262
|
+
quantity=item.quantity,
|
|
263
|
+
averageEntryPrice=item.average_entry_price,
|
|
264
|
+
markPrice=mark_price,
|
|
265
|
+
contractValue=instrument.contract_value,
|
|
266
|
+
notionalUsdt=item.notional(mark_price, instrument.base_per_contract),
|
|
267
|
+
usedMarginUsdt=item.used_margin_usdt,
|
|
268
|
+
leverage=item.leverage,
|
|
269
|
+
marginSafetyMultiplier=item.margin_safety_multiplier,
|
|
270
|
+
unrealizedPnlUsdt=item.unrealized(mark_price, instrument.base_per_contract),
|
|
271
|
+
entryFeeUsdt=item.entry_fee_usdt,
|
|
272
|
+
stopLossPrice=item.stop_loss_price,
|
|
273
|
+
takeProfitPrice=item.take_profit_price,
|
|
274
|
+
openedAtMs=item.opened_at_ms,
|
|
275
|
+
updatedAtMs=item.updated_at_ms,
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
equity = state.equity(mark_price, instrument.base_per_contract)
|
|
279
|
+
return PortfolioView(
|
|
280
|
+
cash_usdt=state.cash_usdt,
|
|
281
|
+
equity_usdt=equity,
|
|
282
|
+
used_margin_usdt=state.used_margin_usdt,
|
|
283
|
+
available_margin_usdt=max(0.0, equity - state.used_margin_usdt),
|
|
284
|
+
positions=positions,
|
|
285
|
+
open_orders=tuple(state.open_orders),
|
|
286
|
+
recent_fills=tuple(state.fills[-50:]),
|
|
287
|
+
trades=tuple(state.trades),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# ----- decision handling ----------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _validate(self, decision: object) -> Decision:
|
|
293
|
+
if not isinstance(decision, Decision):
|
|
294
|
+
raise StrategyError(
|
|
295
|
+
"A handler must return one of ctx.no_action, ctx.open_long, ctx.open_short, "
|
|
296
|
+
"ctx.close_long, ctx.close_short, or ctx.cancel_order"
|
|
297
|
+
)
|
|
298
|
+
if decision.action == CANCEL_ORDER:
|
|
299
|
+
known = {order.id for order in self._state.open_orders}
|
|
300
|
+
if decision.order_id not in known:
|
|
301
|
+
raise StrategyError(
|
|
302
|
+
f"cancel_order references '{decision.order_id}', which is not a current open order"
|
|
303
|
+
)
|
|
304
|
+
return decision
|
|
305
|
+
|
|
306
|
+
def _require_no_action(self, decision: object) -> None:
|
|
307
|
+
if not isinstance(decision, Decision) or decision.action != NO_ACTION:
|
|
308
|
+
raise StrategyError("on_start must return ctx.no_action(...)")
|
|
309
|
+
|
|
310
|
+
def _apply_at_open(self, pending: PendingAction, bar: Bar) -> None:
|
|
311
|
+
decision = pending.decision
|
|
312
|
+
if decision.execution.order_type == LIMIT:
|
|
313
|
+
self._register_limit_order(decision, bar)
|
|
314
|
+
return
|
|
315
|
+
if decision.action in (OPEN_LONG, OPEN_SHORT):
|
|
316
|
+
side = LONG if decision.action == OPEN_LONG else SHORT
|
|
317
|
+
self._open(side, self._entry_price(bar.open, side), bar.close_time_ms, decision, taker=True)
|
|
318
|
+
elif decision.action in (CLOSE_LONG, CLOSE_SHORT):
|
|
319
|
+
side = LONG if decision.action == CLOSE_LONG else SHORT
|
|
320
|
+
if self._state.position is not None and self._state.position.side == side:
|
|
321
|
+
self._close_position(bar.close_time_ms, self._exit_price(bar.open, side), "strategy_close")
|
|
322
|
+
|
|
323
|
+
def _register_limit_order(self, decision: Decision, bar: Bar) -> None:
|
|
324
|
+
quantity = (
|
|
325
|
+
self._entry_quantity(bar.open)
|
|
326
|
+
if decision.action in (OPEN_LONG, OPEN_SHORT)
|
|
327
|
+
else (self._state.position.quantity if self._state.position else 0.0)
|
|
328
|
+
)
|
|
329
|
+
if quantity <= 0:
|
|
330
|
+
return
|
|
331
|
+
self._sequence += 1
|
|
332
|
+
self._state.open_orders.append(
|
|
333
|
+
OpenOrder(
|
|
334
|
+
id=f"ord-{self._sequence}",
|
|
335
|
+
instrumentId=self._config.instrument.inst_id,
|
|
336
|
+
action=decision.action,
|
|
337
|
+
quantity=quantity,
|
|
338
|
+
filledQuantity=0.0,
|
|
339
|
+
status="open",
|
|
340
|
+
price=decision.execution.limit_price,
|
|
341
|
+
createdAtMs=bar.open_time_ms,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _cancel_order(self, order_id: str | None) -> None:
|
|
346
|
+
self._state.open_orders = [order for order in self._state.open_orders if order.id != order_id]
|
|
347
|
+
|
|
348
|
+
def _match_open_orders(self, bar: Bar) -> None:
|
|
349
|
+
"""Conservative K-line matching.
|
|
350
|
+
|
|
351
|
+
A buy requires the bar to trade strictly below its limit and a sell
|
|
352
|
+
strictly above, and any single bar can absorb at most
|
|
353
|
+
``BAR_VOLUME_FILL_CAP`` of its own volume.
|
|
354
|
+
"""
|
|
355
|
+
if not self._state.open_orders:
|
|
356
|
+
return
|
|
357
|
+
remaining: list[OpenOrder] = []
|
|
358
|
+
for order in self._state.open_orders:
|
|
359
|
+
price = order.price
|
|
360
|
+
if price is None:
|
|
361
|
+
remaining.append(order)
|
|
362
|
+
continue
|
|
363
|
+
buying = order.action in (OPEN_LONG, CLOSE_SHORT)
|
|
364
|
+
reached = bar.low < price if buying else bar.high > price
|
|
365
|
+
expired = bar.open_time_ms - order.createdAtMs >= self._config.limit_order_ttl_bars * 60_000
|
|
366
|
+
if not reached:
|
|
367
|
+
if not expired:
|
|
368
|
+
remaining.append(order)
|
|
369
|
+
continue
|
|
370
|
+
capacity = bar.volume * BAR_VOLUME_FILL_CAP
|
|
371
|
+
fillable = min(order.quantity - order.filledQuantity, max(0.0, capacity))
|
|
372
|
+
fillable = self._align_lot(fillable)
|
|
373
|
+
if fillable <= 0:
|
|
374
|
+
if not expired:
|
|
375
|
+
remaining.append(order)
|
|
376
|
+
continue
|
|
377
|
+
if order.action in (OPEN_LONG, OPEN_SHORT):
|
|
378
|
+
side = LONG if order.action == OPEN_LONG else SHORT
|
|
379
|
+
self._open_quantity(side, price, bar.close_time_ms, fillable, None, taker=False)
|
|
380
|
+
else:
|
|
381
|
+
side = LONG if order.action == CLOSE_LONG else SHORT
|
|
382
|
+
if self._state.position is not None and self._state.position.side == side:
|
|
383
|
+
self._reduce_position(bar.close_time_ms, price, fillable, "strategy_close", taker=False)
|
|
384
|
+
filled = order.filledQuantity + fillable
|
|
385
|
+
if filled < order.quantity and not expired:
|
|
386
|
+
remaining.append(
|
|
387
|
+
OpenOrder(
|
|
388
|
+
id=order.id,
|
|
389
|
+
instrumentId=order.instrumentId,
|
|
390
|
+
action=order.action,
|
|
391
|
+
quantity=order.quantity,
|
|
392
|
+
filledQuantity=filled,
|
|
393
|
+
status="partially_filled",
|
|
394
|
+
price=order.price,
|
|
395
|
+
createdAtMs=order.createdAtMs,
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
self._state.open_orders = remaining
|
|
399
|
+
|
|
400
|
+
# ----- position mechanics ---------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _entry_quantity(self, price: float) -> float:
|
|
403
|
+
instrument = self._config.instrument
|
|
404
|
+
sizing = self._config.sizing
|
|
405
|
+
equity = self._state.equity(price, instrument.base_per_contract)
|
|
406
|
+
budget = sizing.entry_budget_usdt or equity * sizing.entry_budget_pct
|
|
407
|
+
budget = min(budget, max(0.0, equity - self._state.used_margin_usdt))
|
|
408
|
+
if budget <= 0:
|
|
409
|
+
return 0.0
|
|
410
|
+
notional = budget * sizing.leverage / sizing.margin_safety_multiplier
|
|
411
|
+
quantity = notional / (price * instrument.base_per_contract)
|
|
412
|
+
return self._align_lot(quantity)
|
|
413
|
+
|
|
414
|
+
def _align_lot(self, quantity: float) -> float:
|
|
415
|
+
instrument = self._config.instrument
|
|
416
|
+
if instrument.lot_size <= 0:
|
|
417
|
+
return max(0.0, quantity)
|
|
418
|
+
steps = int(quantity / instrument.lot_size + 1e-9)
|
|
419
|
+
aligned = steps * instrument.lot_size
|
|
420
|
+
if aligned < instrument.min_size:
|
|
421
|
+
return 0.0
|
|
422
|
+
return aligned
|
|
423
|
+
|
|
424
|
+
def _open(self, side: str, price: float, now_ms: int, decision: Decision, taker: bool) -> None:
|
|
425
|
+
quantity = self._entry_quantity(price)
|
|
426
|
+
if quantity <= 0:
|
|
427
|
+
return
|
|
428
|
+
self._open_quantity(side, price, now_ms, quantity, decision, taker=taker)
|
|
429
|
+
|
|
430
|
+
def _open_quantity(
|
|
431
|
+
self,
|
|
432
|
+
side: str,
|
|
433
|
+
price: float,
|
|
434
|
+
now_ms: int,
|
|
435
|
+
quantity: float,
|
|
436
|
+
decision: Decision | None,
|
|
437
|
+
taker: bool,
|
|
438
|
+
) -> None:
|
|
439
|
+
instrument = self._config.instrument
|
|
440
|
+
sizing = self._config.sizing
|
|
441
|
+
existing = self._state.position
|
|
442
|
+
if existing is not None and existing.side != side:
|
|
443
|
+
# Reversal is explicit: a strategy must close before opening the
|
|
444
|
+
# opposite side, so this is a contract violation, not a flip.
|
|
445
|
+
raise StrategyError(
|
|
446
|
+
f"Cannot open {side} while a {existing.side} position is open; close it first"
|
|
447
|
+
)
|
|
448
|
+
notional = quantity * instrument.base_per_contract * price
|
|
449
|
+
margin = notional / sizing.leverage * sizing.margin_safety_multiplier
|
|
450
|
+
fee = notional * (self._config.costs.taker_fee_rate if taker else self._config.costs.maker_fee_rate)
|
|
451
|
+
if margin > max(0.0, self._state.equity(price, instrument.base_per_contract) - self._state.used_margin_usdt):
|
|
452
|
+
return
|
|
453
|
+
self._state.cash_usdt -= fee
|
|
454
|
+
self._state.used_margin_usdt += margin
|
|
455
|
+
protection = decision.protection if decision is not None else None
|
|
456
|
+
if existing is None:
|
|
457
|
+
self._state.position = MutablePosition(
|
|
458
|
+
side=side,
|
|
459
|
+
quantity=quantity,
|
|
460
|
+
average_entry_price=price,
|
|
461
|
+
used_margin_usdt=margin,
|
|
462
|
+
leverage=sizing.leverage,
|
|
463
|
+
margin_safety_multiplier=sizing.margin_safety_multiplier,
|
|
464
|
+
entry_fee_usdt=fee,
|
|
465
|
+
stop_loss_price=protection.stop_loss_price if protection else None,
|
|
466
|
+
take_profit_price=protection.take_profit_price if protection else None,
|
|
467
|
+
opened_at_ms=now_ms,
|
|
468
|
+
updated_at_ms=now_ms,
|
|
469
|
+
)
|
|
470
|
+
else:
|
|
471
|
+
total = existing.quantity + quantity
|
|
472
|
+
existing.average_entry_price = (
|
|
473
|
+
existing.average_entry_price * existing.quantity + price * quantity
|
|
474
|
+
) / total
|
|
475
|
+
existing.quantity = total
|
|
476
|
+
existing.used_margin_usdt += margin
|
|
477
|
+
existing.entry_fee_usdt += fee
|
|
478
|
+
existing.updated_at_ms = now_ms
|
|
479
|
+
self._record_fill(
|
|
480
|
+
action=OPEN_LONG if side == LONG else OPEN_SHORT,
|
|
481
|
+
quantity=quantity,
|
|
482
|
+
price=price,
|
|
483
|
+
fee=fee,
|
|
484
|
+
now_ms=now_ms,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _reduce_position(
|
|
488
|
+
self,
|
|
489
|
+
now_ms: int,
|
|
490
|
+
price: float,
|
|
491
|
+
quantity: float,
|
|
492
|
+
reason: str,
|
|
493
|
+
taker: bool = True,
|
|
494
|
+
) -> None:
|
|
495
|
+
position = self._state.position
|
|
496
|
+
if position is None:
|
|
497
|
+
return
|
|
498
|
+
instrument = self._config.instrument
|
|
499
|
+
quantity = min(quantity, position.quantity)
|
|
500
|
+
if quantity <= 0:
|
|
501
|
+
return
|
|
502
|
+
notional = quantity * instrument.base_per_contract * price
|
|
503
|
+
fee = notional * (self._config.costs.taker_fee_rate if taker else self._config.costs.maker_fee_rate)
|
|
504
|
+
delta = price - position.average_entry_price
|
|
505
|
+
if position.side == SHORT:
|
|
506
|
+
delta = -delta
|
|
507
|
+
realized = delta * quantity * instrument.base_per_contract
|
|
508
|
+
share = quantity / position.quantity
|
|
509
|
+
released = position.used_margin_usdt * share
|
|
510
|
+
|
|
511
|
+
self._state.cash_usdt += realized - fee
|
|
512
|
+
self._state.used_margin_usdt = max(0.0, self._state.used_margin_usdt - released)
|
|
513
|
+
self._record_fill(
|
|
514
|
+
action=CLOSE_LONG if position.side == LONG else CLOSE_SHORT,
|
|
515
|
+
quantity=quantity,
|
|
516
|
+
price=price,
|
|
517
|
+
fee=fee,
|
|
518
|
+
now_ms=now_ms,
|
|
519
|
+
)
|
|
520
|
+
self._sequence += 1
|
|
521
|
+
self._state.trades.append(
|
|
522
|
+
Trade(
|
|
523
|
+
id=f"trade-{self._sequence}",
|
|
524
|
+
instrumentId=instrument.inst_id,
|
|
525
|
+
side=position.side,
|
|
526
|
+
quantity=quantity,
|
|
527
|
+
entryPrice=position.average_entry_price,
|
|
528
|
+
exitPrice=price,
|
|
529
|
+
usedMarginUsdt=released,
|
|
530
|
+
leverage=position.leverage,
|
|
531
|
+
realizedPnlUsdt=realized,
|
|
532
|
+
feesUsdt=fee + position.entry_fee_usdt * share,
|
|
533
|
+
openedAtMs=position.opened_at_ms,
|
|
534
|
+
closedAtMs=now_ms,
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
position.quantity -= quantity
|
|
538
|
+
position.used_margin_usdt -= released
|
|
539
|
+
position.entry_fee_usdt *= 1 - share
|
|
540
|
+
position.updated_at_ms = now_ms
|
|
541
|
+
if position.quantity <= 1e-12:
|
|
542
|
+
self._state.position = None
|
|
543
|
+
# A full close removes attached protection automatically.
|
|
544
|
+
self._state.open_orders = [
|
|
545
|
+
order for order in self._state.open_orders if order.action in (OPEN_LONG, OPEN_SHORT)
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
def _close_position(self, now_ms: int, price: float, reason: str) -> None:
|
|
549
|
+
position = self._state.position
|
|
550
|
+
if position is None:
|
|
551
|
+
return
|
|
552
|
+
self._reduce_position(now_ms, price, position.quantity, reason)
|
|
553
|
+
|
|
554
|
+
def _apply_protective_exit(self, bar: Bar) -> None:
|
|
555
|
+
position = self._state.position
|
|
556
|
+
if position is None:
|
|
557
|
+
return
|
|
558
|
+
stop = position.stop_loss_price
|
|
559
|
+
take = position.take_profit_price
|
|
560
|
+
if position.side == LONG:
|
|
561
|
+
# When both could trigger inside one bar, assume the adverse one:
|
|
562
|
+
# intrabar order is unknowable from OHLC alone.
|
|
563
|
+
if stop is not None and bar.low <= stop:
|
|
564
|
+
self._close_position(bar.close_time_ms, stop, "stop_loss")
|
|
565
|
+
return
|
|
566
|
+
if take is not None and bar.high >= take:
|
|
567
|
+
self._close_position(bar.close_time_ms, take, "take_profit")
|
|
568
|
+
else:
|
|
569
|
+
if stop is not None and bar.high >= stop:
|
|
570
|
+
self._close_position(bar.close_time_ms, stop, "stop_loss")
|
|
571
|
+
return
|
|
572
|
+
if take is not None and bar.low <= take:
|
|
573
|
+
self._close_position(bar.close_time_ms, take, "take_profit")
|
|
574
|
+
|
|
575
|
+
def _enforce_margin(self, bar: Bar) -> None:
|
|
576
|
+
position = self._state.position
|
|
577
|
+
if position is None:
|
|
578
|
+
return
|
|
579
|
+
instrument = self._config.instrument
|
|
580
|
+
adverse = bar.low if position.side == LONG else bar.high
|
|
581
|
+
equity = self._state.cash_usdt + position.unrealized(adverse, instrument.base_per_contract)
|
|
582
|
+
if equity <= 0:
|
|
583
|
+
self._margin_exhausted = True
|
|
584
|
+
self._close_position(bar.close_time_ms, adverse, "margin_exhaustion")
|
|
585
|
+
|
|
586
|
+
def _record_fill(self, action: str, quantity: float, price: float, fee: float, now_ms: int) -> None:
|
|
587
|
+
instrument = self._config.instrument
|
|
588
|
+
self._sequence += 1
|
|
589
|
+
self._state.fills.append(
|
|
590
|
+
Fill(
|
|
591
|
+
id=f"fill-{self._sequence}",
|
|
592
|
+
orderId=f"ord-{self._sequence}",
|
|
593
|
+
instrumentId=instrument.inst_id,
|
|
594
|
+
action=action,
|
|
595
|
+
quantity=quantity,
|
|
596
|
+
price=price,
|
|
597
|
+
notionalUsdt=quantity * instrument.base_per_contract * price,
|
|
598
|
+
feeUsdt=fee,
|
|
599
|
+
filledAtMs=now_ms,
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
def _entry_price(self, price: float, side: str) -> float:
|
|
604
|
+
slip = self._config.costs.entry_slippage_bps / 10_000
|
|
605
|
+
return price * (1 + slip) if side == LONG else price * (1 - slip)
|
|
606
|
+
|
|
607
|
+
def _exit_price(self, price: float, side: str) -> float:
|
|
608
|
+
slip = self._config.costs.exit_slippage_bps / 10_000
|
|
609
|
+
return price * (1 - slip) if side == LONG else price * (1 + slip)
|
|
610
|
+
|
|
611
|
+
def _unrealized(self, mark_price: float) -> float:
|
|
612
|
+
if self._state.position is None:
|
|
613
|
+
return 0.0
|
|
614
|
+
return self._state.position.unrealized(mark_price, self._config.instrument.base_per_contract)
|