desic-okx-agent 0.2.0 → 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 +353 -0
- package/README.md +194 -190
- 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 +758 -27
- 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.d.ts +3 -0
- package/dist/setup/wizard.js +131 -6
- 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 +11 -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,159 @@
|
|
|
1
|
+
"""Rolling indicators over the confirmed one-minute series.
|
|
2
|
+
|
|
3
|
+
Only ``1m`` is accepted. A higher-timeframe active bucket can still be revised
|
|
4
|
+
before it confirms, which makes it unsuitable for append-only indicator state:
|
|
5
|
+
the cached value would silently depend on when it was first computed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .timeframe import Bar
|
|
11
|
+
|
|
12
|
+
_LOOKUP_LIMIT = 5_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IndicatorError(Exception):
|
|
16
|
+
"""Raised when an indicator is requested outside its contract."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IndicatorCache:
|
|
20
|
+
"""Incrementally maintained EMA and Wilder ATR series.
|
|
21
|
+
|
|
22
|
+
Values are appended once per confirmed bar and never recomputed, so a
|
|
23
|
+
context retained from an earlier bar reads its own historical value rather
|
|
24
|
+
than a later one.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_atr", "_bars", "_ema")
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._bars: list[Bar] = []
|
|
31
|
+
self._ema: dict[int, list[float | None]] = {}
|
|
32
|
+
self._atr: dict[int, list[float | None]] = {}
|
|
33
|
+
|
|
34
|
+
def append(self, bar: Bar) -> None:
|
|
35
|
+
self._bars.append(bar)
|
|
36
|
+
for period, series in self._ema.items():
|
|
37
|
+
series.append(self._next_ema(period, series))
|
|
38
|
+
for period, series in self._atr.items():
|
|
39
|
+
series.append(self._next_atr(period, series))
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def length(self) -> int:
|
|
43
|
+
return len(self._bars)
|
|
44
|
+
|
|
45
|
+
def ema(self, period: int, offset: int, visible: int) -> float | None:
|
|
46
|
+
series = self._ensure(self._ema, period, self._next_ema)
|
|
47
|
+
return self._read(series, offset, visible)
|
|
48
|
+
|
|
49
|
+
def atr(self, period: int, offset: int, visible: int) -> float | None:
|
|
50
|
+
series = self._ensure(self._atr, period, self._next_atr)
|
|
51
|
+
return self._read(series, offset, visible)
|
|
52
|
+
|
|
53
|
+
def _ensure(
|
|
54
|
+
self,
|
|
55
|
+
store: dict[int, list[float | None]],
|
|
56
|
+
period: int,
|
|
57
|
+
step: object,
|
|
58
|
+
) -> list[float | None]:
|
|
59
|
+
if not isinstance(period, int) or isinstance(period, bool) or period <= 0:
|
|
60
|
+
raise IndicatorError("period must be a positive integer")
|
|
61
|
+
if period > _LOOKUP_LIMIT:
|
|
62
|
+
raise IndicatorError(f"period must be at most {_LOOKUP_LIMIT}")
|
|
63
|
+
series = store.get(period)
|
|
64
|
+
if series is None:
|
|
65
|
+
# Backfill so a first request mid-run yields the same values it
|
|
66
|
+
# would have had if it were requested from the first bar.
|
|
67
|
+
series = []
|
|
68
|
+
store[period] = series
|
|
69
|
+
compute = step # type: ignore[assignment]
|
|
70
|
+
for index in range(len(self._bars)):
|
|
71
|
+
series.append(compute(period, series, index + 1)) # type: ignore[operator]
|
|
72
|
+
return series
|
|
73
|
+
|
|
74
|
+
def _read(self, series: list[float | None], offset: int, visible: int) -> float | None:
|
|
75
|
+
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
|
|
76
|
+
raise IndicatorError("offset must be a non-negative integer")
|
|
77
|
+
index = visible - 1 - offset
|
|
78
|
+
if index < 0 or index >= len(series):
|
|
79
|
+
return None
|
|
80
|
+
return series[index]
|
|
81
|
+
|
|
82
|
+
def _next_ema(
|
|
83
|
+
self,
|
|
84
|
+
period: int,
|
|
85
|
+
series: list[float | None],
|
|
86
|
+
upto: int | None = None,
|
|
87
|
+
) -> float | None:
|
|
88
|
+
count = len(self._bars) if upto is None else upto
|
|
89
|
+
if count < period:
|
|
90
|
+
return None
|
|
91
|
+
if count == period:
|
|
92
|
+
# Seed with the simple mean of the first `period` closes.
|
|
93
|
+
return sum(self._bars[index].close for index in range(period)) / period
|
|
94
|
+
previous = series[count - 2] if count - 2 < len(series) else None
|
|
95
|
+
if previous is None:
|
|
96
|
+
return sum(self._bars[index].close for index in range(count - period, count)) / period
|
|
97
|
+
# Only the newest close is needed once seeded, keeping each bar O(1).
|
|
98
|
+
multiplier = 2.0 / (period + 1)
|
|
99
|
+
return (self._bars[count - 1].close - previous) * multiplier + previous
|
|
100
|
+
|
|
101
|
+
def _next_atr(
|
|
102
|
+
self,
|
|
103
|
+
period: int,
|
|
104
|
+
series: list[float | None],
|
|
105
|
+
upto: int | None = None,
|
|
106
|
+
) -> float | None:
|
|
107
|
+
count = len(self._bars) if upto is None else upto
|
|
108
|
+
# True range needs a prior close, so ATR starts one bar later than EMA.
|
|
109
|
+
if count < period + 1:
|
|
110
|
+
return None
|
|
111
|
+
if count == period + 1:
|
|
112
|
+
# Seed with the mean of the first `period` true ranges.
|
|
113
|
+
return sum(self._true_range(index) for index in range(1, count)) / period
|
|
114
|
+
previous = series[count - 2] if count - 2 < len(series) else None
|
|
115
|
+
if previous is None:
|
|
116
|
+
return sum(self._true_range(index) for index in range(1, period + 1)) / period
|
|
117
|
+
# Wilder smoothing needs only the newest true range, so each bar stays
|
|
118
|
+
# O(1); recomputing the whole range list here would make a long run
|
|
119
|
+
# quadratic in bar count.
|
|
120
|
+
return (previous * (period - 1) + self._true_range(count - 1)) / period
|
|
121
|
+
|
|
122
|
+
def _true_range(self, index: int) -> float:
|
|
123
|
+
bar = self._bars[index]
|
|
124
|
+
previous_close = self._bars[index - 1].close
|
|
125
|
+
return max(
|
|
126
|
+
bar.high - bar.low,
|
|
127
|
+
abs(bar.high - previous_close),
|
|
128
|
+
abs(bar.low - previous_close),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class IndicatorView:
|
|
133
|
+
"""Strategy-facing indicator accessor bound to one cutoff."""
|
|
134
|
+
|
|
135
|
+
__slots__ = ("_cache", "_instrument_id", "_visible")
|
|
136
|
+
|
|
137
|
+
def __init__(self, cache: IndicatorCache, instrument_id: str, visible: int) -> None:
|
|
138
|
+
self._cache = cache
|
|
139
|
+
self._instrument_id = instrument_id
|
|
140
|
+
self._visible = visible
|
|
141
|
+
|
|
142
|
+
def ema(self, instrument_id: str, interval: str, period: int, offset: int = 0) -> float | None:
|
|
143
|
+
self._check(instrument_id, interval)
|
|
144
|
+
return self._cache.ema(period, offset, self._visible)
|
|
145
|
+
|
|
146
|
+
def atr(self, instrument_id: str, interval: str, period: int, offset: int = 0) -> float | None:
|
|
147
|
+
self._check(instrument_id, interval)
|
|
148
|
+
return self._cache.atr(period, offset, self._visible)
|
|
149
|
+
|
|
150
|
+
def _check(self, instrument_id: str, interval: str) -> None:
|
|
151
|
+
if instrument_id != self._instrument_id:
|
|
152
|
+
raise IndicatorError(
|
|
153
|
+
f"Indicators are available for {self._instrument_id}, not {instrument_id}"
|
|
154
|
+
)
|
|
155
|
+
if interval != "1m":
|
|
156
|
+
raise IndicatorError(
|
|
157
|
+
"Indicators accept only the confirmed '1m' series; a higher-timeframe "
|
|
158
|
+
"active bucket can still be revised before it confirms"
|
|
159
|
+
)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""One long-lived strategy session, driven one bar at a time.
|
|
2
|
+
|
|
3
|
+
The backtest ``Engine`` owns a virtual account: it matches orders, charges fees,
|
|
4
|
+
and tracks a position it computed itself. None of that applies here. A live fill
|
|
5
|
+
is decided by the exchange, so the position handed to strategy code is the one
|
|
6
|
+
the exchange reports — read fresh before every evaluation and passed in by the
|
|
7
|
+
host.
|
|
8
|
+
|
|
9
|
+
That is why this is a separate driver rather than a mode of ``Engine``. Reusing
|
|
10
|
+
the virtual ledger would leave two sources of truth for the same position, and the
|
|
11
|
+
moment a real fill differed from the simulated one — partial fill, manual close,
|
|
12
|
+
liquidation — the strategy would be deciding against a position that does not
|
|
13
|
+
exist.
|
|
14
|
+
|
|
15
|
+
Everything a strategy actually touches *is* shared with the backtest:
|
|
16
|
+
``SeriesSet``, ``IndicatorCache``, ``MarketView``, ``StrategyContext``, the action
|
|
17
|
+
constructors, and the source policy. Only the account state differs, because only
|
|
18
|
+
the account state can differ.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .actions import Decision, StrategyError
|
|
24
|
+
from .context import MarketView, SeriesSet, StrategyContext, immutable_params
|
|
25
|
+
from .indicators import IndicatorView
|
|
26
|
+
from .portfolio import LONG, SHORT, OpenOrder, PortfolioView, Position
|
|
27
|
+
from .timeframe import Bar, DataContractError
|
|
28
|
+
|
|
29
|
+
# Bars retained in memory. A session runs for days, so the window is bounded; this
|
|
30
|
+
# is far more than any indicator lookback needs and keeps memory flat.
|
|
31
|
+
MAX_RETAINED_BARS = 20_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LiveSession:
|
|
35
|
+
"""Evaluates one strategy against one instrument, one confirmed bar at a time."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_bars", "_handlers", "_instrument_id", "_intervals", "_last_cutoff", "_params", "_series", "_started")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
instrument_id: str,
|
|
42
|
+
handlers: dict[str, object],
|
|
43
|
+
params: dict[str, object],
|
|
44
|
+
intervals: list[str],
|
|
45
|
+
) -> None:
|
|
46
|
+
self._instrument_id = instrument_id
|
|
47
|
+
self._handlers = handlers
|
|
48
|
+
self._params = immutable_params(params)
|
|
49
|
+
self._intervals = intervals
|
|
50
|
+
self._series = SeriesSet(instrument_id, intervals)
|
|
51
|
+
self._bars: list[Bar] = []
|
|
52
|
+
self._started = False
|
|
53
|
+
self._last_cutoff: int | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def bar_count(self) -> int:
|
|
57
|
+
return len(self._bars)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def last_cutoff_ms(self) -> int | None:
|
|
61
|
+
return self._last_cutoff
|
|
62
|
+
|
|
63
|
+
def warm(self, bars: list[Bar]) -> int:
|
|
64
|
+
"""Feeds history without evaluating anything.
|
|
65
|
+
|
|
66
|
+
Indicators need a warm-up prefix, and running the strategy over it would
|
|
67
|
+
emit decisions for minutes that have already passed. Same rule as the
|
|
68
|
+
backtest's preload: warm-up produces no decisions.
|
|
69
|
+
"""
|
|
70
|
+
for bar in bars:
|
|
71
|
+
self._append(bar)
|
|
72
|
+
return len(self._bars)
|
|
73
|
+
|
|
74
|
+
def evaluate(self, bar: Bar, portfolio: PortfolioView) -> Decision:
|
|
75
|
+
"""Appends one confirmed bar and returns the single decision for it.
|
|
76
|
+
|
|
77
|
+
``portfolio`` is the exchange's own view, read by the host immediately
|
|
78
|
+
before this call.
|
|
79
|
+
"""
|
|
80
|
+
self._append(bar)
|
|
81
|
+
context = self._context(bar, portfolio)
|
|
82
|
+
|
|
83
|
+
if not self._started:
|
|
84
|
+
self._started = True
|
|
85
|
+
start_handler = self._handlers.get("on_start")
|
|
86
|
+
if callable(start_handler):
|
|
87
|
+
decision = start_handler(context)
|
|
88
|
+
if not isinstance(decision, Decision) or decision.action != "no_action":
|
|
89
|
+
raise StrategyError("on_start must return ctx.no_action(...)")
|
|
90
|
+
|
|
91
|
+
handler = self._handlers.get("on_bar")
|
|
92
|
+
if not callable(handler):
|
|
93
|
+
raise StrategyError("A strategy must define on_bar(ctx)")
|
|
94
|
+
decision = handler(context)
|
|
95
|
+
if not isinstance(decision, Decision):
|
|
96
|
+
raise StrategyError(
|
|
97
|
+
"A handler must return one of ctx.no_action, ctx.open_long, ctx.open_short, "
|
|
98
|
+
"ctx.close_long, ctx.close_short, or ctx.cancel_order"
|
|
99
|
+
)
|
|
100
|
+
self._last_cutoff = bar.close_time_ms
|
|
101
|
+
return decision
|
|
102
|
+
|
|
103
|
+
def _append(self, bar: Bar) -> None:
|
|
104
|
+
if not bar.confirmed:
|
|
105
|
+
# An unconfirmed bar can still be revised. Acting on one would mean
|
|
106
|
+
# deciding against a candle that has not finished happening.
|
|
107
|
+
raise DataContractError("A live session accepts only confirmed bars")
|
|
108
|
+
previous = self._bars[-1] if self._bars else None
|
|
109
|
+
if previous is not None:
|
|
110
|
+
if bar.open_time_ms == previous.open_time_ms:
|
|
111
|
+
raise DataContractError(f"Bar {bar.open_time_ms} was already fed to this session")
|
|
112
|
+
expected = previous.close_time_ms
|
|
113
|
+
if bar.open_time_ms != expected:
|
|
114
|
+
# A gap means the strategy would see a jump the market did not
|
|
115
|
+
# make. The host is expected to repair the window and retry rather
|
|
116
|
+
# than have this smoothed over.
|
|
117
|
+
raise DataContractError(
|
|
118
|
+
f"Bar {bar.open_time_ms} breaks continuity: expected open {expected}"
|
|
119
|
+
)
|
|
120
|
+
self._bars.append(bar)
|
|
121
|
+
self._series.push(bar)
|
|
122
|
+
# Trimming keeps the retained window bounded. `SeriesSet` holds its own
|
|
123
|
+
# incremental indicator state, which is append-only and unaffected.
|
|
124
|
+
if len(self._bars) > MAX_RETAINED_BARS:
|
|
125
|
+
del self._bars[: len(self._bars) - MAX_RETAINED_BARS]
|
|
126
|
+
|
|
127
|
+
def _context(self, bar: Bar, portfolio: PortfolioView) -> StrategyContext:
|
|
128
|
+
# `visible` is the full retained length: every bar in a live session is
|
|
129
|
+
# confirmed and in the past, so there is no future prefix to hide.
|
|
130
|
+
return StrategyContext(
|
|
131
|
+
kind="bar",
|
|
132
|
+
as_of_ms=bar.close_time_ms,
|
|
133
|
+
instrument_id=self._instrument_id,
|
|
134
|
+
interval="1m",
|
|
135
|
+
bar=bar,
|
|
136
|
+
market=MarketView(self._instrument_id, self._bars, len(self._bars), self._series.snapshot()),
|
|
137
|
+
portfolio=portfolio,
|
|
138
|
+
params=self._params,
|
|
139
|
+
indicators=IndicatorView(self._series.indicators, self._instrument_id, len(self._bars)),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_portfolio(value: object, instrument_id: str) -> PortfolioView:
|
|
144
|
+
"""Builds the strategy-visible account snapshot from the host's payload.
|
|
145
|
+
|
|
146
|
+
Fields are read by their exact documented names and every one is required.
|
|
147
|
+
Defaulting a missing balance to zero would let a protocol mismatch present
|
|
148
|
+
itself as a flat account, and a strategy would then open a position believing
|
|
149
|
+
it holds none.
|
|
150
|
+
"""
|
|
151
|
+
if not isinstance(value, dict):
|
|
152
|
+
raise DataContractError("'portfolio' must be an object")
|
|
153
|
+
return PortfolioView(
|
|
154
|
+
cash_usdt=_required_float(value, "cashUsdt"),
|
|
155
|
+
equity_usdt=_required_float(value, "equityUsdt"),
|
|
156
|
+
used_margin_usdt=_required_float(value, "usedMarginUsdt"),
|
|
157
|
+
available_margin_usdt=_required_float(value, "availableMarginUsdt"),
|
|
158
|
+
positions=_parse_positions(value.get("positions"), instrument_id),
|
|
159
|
+
open_orders=_parse_open_orders(value.get("openOrders"), instrument_id),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_positions(value: object, instrument_id: str) -> tuple[Position, ...]:
|
|
164
|
+
if value is None:
|
|
165
|
+
return ()
|
|
166
|
+
if not isinstance(value, list):
|
|
167
|
+
raise DataContractError("'portfolio.positions' must be an array")
|
|
168
|
+
positions: list[Position] = []
|
|
169
|
+
for index, row in enumerate(value):
|
|
170
|
+
if not isinstance(row, dict):
|
|
171
|
+
raise DataContractError(f"portfolio.positions[{index}] must be an object")
|
|
172
|
+
side = str(row.get("side") or "")
|
|
173
|
+
if side not in (LONG, SHORT):
|
|
174
|
+
raise DataContractError(f"portfolio.positions[{index}].side must be '{LONG}' or '{SHORT}'")
|
|
175
|
+
quantity = _required_float(row, "quantity", f"portfolio.positions[{index}]")
|
|
176
|
+
if quantity <= 0:
|
|
177
|
+
# The exchange reports a closed position by omitting it, so a zero
|
|
178
|
+
# quantity here means the host built the payload wrong.
|
|
179
|
+
raise DataContractError(f"portfolio.positions[{index}].quantity must be greater than zero")
|
|
180
|
+
positions.append(
|
|
181
|
+
Position(
|
|
182
|
+
instrumentId=str(row.get("instrumentId") or instrument_id),
|
|
183
|
+
side=side,
|
|
184
|
+
quantity=quantity,
|
|
185
|
+
averageEntryPrice=_required_float(row, "averageEntryPrice", f"portfolio.positions[{index}]"),
|
|
186
|
+
markPrice=_required_float(row, "markPrice", f"portfolio.positions[{index}]"),
|
|
187
|
+
contractValue=_required_float(row, "contractValue", f"portfolio.positions[{index}]"),
|
|
188
|
+
notionalUsdt=_required_float(row, "notionalUsdt", f"portfolio.positions[{index}]"),
|
|
189
|
+
usedMarginUsdt=_required_float(row, "usedMarginUsdt", f"portfolio.positions[{index}]"),
|
|
190
|
+
leverage=_required_float(row, "leverage", f"portfolio.positions[{index}]"),
|
|
191
|
+
marginSafetyMultiplier=_optional_float(row.get("marginSafetyMultiplier"), 1.0),
|
|
192
|
+
unrealizedPnlUsdt=_required_float(row, "unrealizedPnlUsdt", f"portfolio.positions[{index}]"),
|
|
193
|
+
entryFeeUsdt=_optional_float(row.get("entryFeeUsdt"), 0.0),
|
|
194
|
+
stopLossPrice=_nullable_float(row.get("stopLossPrice")),
|
|
195
|
+
takeProfitPrice=_nullable_float(row.get("takeProfitPrice")),
|
|
196
|
+
openedAtMs=int(_optional_float(row.get("openedAtMs"), 0.0)),
|
|
197
|
+
updatedAtMs=int(_optional_float(row.get("updatedAtMs"), 0.0)),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return tuple(positions)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_open_orders(value: object, instrument_id: str) -> tuple[OpenOrder, ...]:
|
|
204
|
+
if value is None:
|
|
205
|
+
return ()
|
|
206
|
+
if not isinstance(value, list):
|
|
207
|
+
raise DataContractError("'portfolio.openOrders' must be an array")
|
|
208
|
+
orders: list[OpenOrder] = []
|
|
209
|
+
for index, row in enumerate(value):
|
|
210
|
+
if not isinstance(row, dict):
|
|
211
|
+
raise DataContractError(f"portfolio.openOrders[{index}] must be an object")
|
|
212
|
+
identifier = str(row.get("id") or "")
|
|
213
|
+
if not identifier:
|
|
214
|
+
# `cancel_order` references this id, so an order without one could be
|
|
215
|
+
# seen but never cancelled.
|
|
216
|
+
raise DataContractError(f"portfolio.openOrders[{index}].id is required")
|
|
217
|
+
orders.append(
|
|
218
|
+
OpenOrder(
|
|
219
|
+
id=identifier,
|
|
220
|
+
instrumentId=str(row.get("instrumentId") or instrument_id),
|
|
221
|
+
action=str(row.get("action") or ""),
|
|
222
|
+
quantity=_required_float(row, "quantity", f"portfolio.openOrders[{index}]"),
|
|
223
|
+
filledQuantity=_optional_float(row.get("filledQuantity"), 0.0),
|
|
224
|
+
status=str(row.get("status") or "open"),
|
|
225
|
+
price=_nullable_float(row.get("price")),
|
|
226
|
+
createdAtMs=int(_optional_float(row.get("createdAtMs"), 0.0)),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return tuple(orders)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _required_float(row: dict[str, object], key: str, label: str = "portfolio") -> float:
|
|
233
|
+
if key not in row or row[key] is None:
|
|
234
|
+
raise DataContractError(f"{label}.{key} is required")
|
|
235
|
+
return _finite(row[key], f"{label}.{key}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _optional_float(value: object, fallback: float) -> float:
|
|
239
|
+
return fallback if value is None else _finite(value, "value")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _nullable_float(value: object) -> float | None:
|
|
243
|
+
return None if value is None else _finite(value, "value")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _finite(value: object, label: str) -> float:
|
|
247
|
+
try:
|
|
248
|
+
parsed = float(value) # type: ignore[arg-type]
|
|
249
|
+
except (TypeError, ValueError) as error:
|
|
250
|
+
raise DataContractError(f"{label} must be a number") from error
|
|
251
|
+
if parsed != parsed or parsed in (float("inf"), float("-inf")):
|
|
252
|
+
raise DataContractError(f"{label} must be finite")
|
|
253
|
+
return parsed
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Static source policy for user-authored strategies.
|
|
2
|
+
|
|
3
|
+
This runs before any strategy code is imported. It is defence in depth, not a
|
|
4
|
+
security boundary: the process already receives no credentials, no exchange
|
|
5
|
+
client, and no order API. Its job is to catch the mistakes and reaches that
|
|
6
|
+
would otherwise fail confusingly deep inside a run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
ALLOWED_IMPORT_ROOTS = frozenset(
|
|
15
|
+
{"collections", "dataclasses", "math", "numpy", "pandas", "statistics", "typing"}
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Dynamic attribute access and host reach. `getattr` is rejected on purpose:
|
|
19
|
+
# probing for a field spelling hides a protocol mismatch that should fail loudly.
|
|
20
|
+
FORBIDDEN_NAMES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"getattr",
|
|
23
|
+
"setattr",
|
|
24
|
+
"delattr",
|
|
25
|
+
"dir",
|
|
26
|
+
"vars",
|
|
27
|
+
"globals",
|
|
28
|
+
"locals",
|
|
29
|
+
"eval",
|
|
30
|
+
"exec",
|
|
31
|
+
"compile",
|
|
32
|
+
"__import__",
|
|
33
|
+
"open",
|
|
34
|
+
"input",
|
|
35
|
+
"help",
|
|
36
|
+
"breakpoint",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
REQUIRED_HANDLER = "on_bar"
|
|
41
|
+
OPTIONAL_HANDLERS = frozenset({"on_start"})
|
|
42
|
+
MAX_SOURCE_BYTES = 256 * 1024
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PolicyViolation:
|
|
47
|
+
line: int
|
|
48
|
+
code: str
|
|
49
|
+
message: str
|
|
50
|
+
|
|
51
|
+
def as_dict(self) -> dict[str, object]:
|
|
52
|
+
return {"line": self.line, "code": self.code, "message": self.message}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_source(source: str) -> list[PolicyViolation]:
|
|
56
|
+
"""Returns every violation found, ordered by line, or an empty list."""
|
|
57
|
+
violations: list[PolicyViolation] = []
|
|
58
|
+
encoded = source.encode("utf-8")
|
|
59
|
+
if len(encoded) > MAX_SOURCE_BYTES:
|
|
60
|
+
return [
|
|
61
|
+
PolicyViolation(
|
|
62
|
+
1,
|
|
63
|
+
"source_too_large",
|
|
64
|
+
f"Strategy source is {len(encoded)} bytes, above the {MAX_SOURCE_BYTES} byte limit",
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
tree = ast.parse(source)
|
|
70
|
+
except SyntaxError as error:
|
|
71
|
+
return [PolicyViolation(error.lineno or 1, "syntax_error", f"Syntax error: {error.msg}")]
|
|
72
|
+
|
|
73
|
+
violations.extend(_check_imports(tree))
|
|
74
|
+
violations.extend(_check_names(tree))
|
|
75
|
+
violations.extend(_check_handlers(tree))
|
|
76
|
+
violations.sort(key=lambda item: (item.line, item.code))
|
|
77
|
+
return violations
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_imports(tree: ast.AST) -> list[PolicyViolation]:
|
|
81
|
+
found: list[PolicyViolation] = []
|
|
82
|
+
for node in ast.walk(tree):
|
|
83
|
+
if isinstance(node, ast.Import):
|
|
84
|
+
for alias in node.names:
|
|
85
|
+
root = alias.name.split(".")[0]
|
|
86
|
+
if root not in ALLOWED_IMPORT_ROOTS:
|
|
87
|
+
found.append(
|
|
88
|
+
PolicyViolation(
|
|
89
|
+
node.lineno,
|
|
90
|
+
"forbidden_import",
|
|
91
|
+
f"Import of '{alias.name}' is not allowed. Allowed roots: "
|
|
92
|
+
+ ", ".join(sorted(ALLOWED_IMPORT_ROOTS)),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
elif isinstance(node, ast.ImportFrom):
|
|
96
|
+
# A bare relative import has no module name; it can only reach code
|
|
97
|
+
# outside the single-module strategy contract.
|
|
98
|
+
if node.level and node.level > 0:
|
|
99
|
+
found.append(
|
|
100
|
+
PolicyViolation(
|
|
101
|
+
node.lineno,
|
|
102
|
+
"forbidden_import",
|
|
103
|
+
"Relative imports are not allowed in a strategy module",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
root = (node.module or "").split(".")[0]
|
|
108
|
+
if root not in ALLOWED_IMPORT_ROOTS:
|
|
109
|
+
found.append(
|
|
110
|
+
PolicyViolation(
|
|
111
|
+
node.lineno,
|
|
112
|
+
"forbidden_import",
|
|
113
|
+
f"Import from '{node.module}' is not allowed. Allowed roots: "
|
|
114
|
+
+ ", ".join(sorted(ALLOWED_IMPORT_ROOTS)),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return found
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_names(tree: ast.AST) -> list[PolicyViolation]:
|
|
121
|
+
found: list[PolicyViolation] = []
|
|
122
|
+
for node in ast.walk(tree):
|
|
123
|
+
if isinstance(node, ast.Name) and node.id in FORBIDDEN_NAMES:
|
|
124
|
+
found.append(
|
|
125
|
+
PolicyViolation(
|
|
126
|
+
node.lineno,
|
|
127
|
+
"forbidden_name",
|
|
128
|
+
f"'{node.id}' is not available to a strategy. Read documented fields directly.",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
elif isinstance(node, ast.Attribute) and node.attr.startswith("__"):
|
|
132
|
+
found.append(
|
|
133
|
+
PolicyViolation(
|
|
134
|
+
node.lineno,
|
|
135
|
+
"dunder_access",
|
|
136
|
+
f"Access to '{node.attr}' is not allowed",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
elif isinstance(node, (ast.AsyncFunctionDef, ast.Await, ast.AsyncFor, ast.AsyncWith)):
|
|
140
|
+
found.append(
|
|
141
|
+
PolicyViolation(
|
|
142
|
+
node.lineno,
|
|
143
|
+
"async_not_supported",
|
|
144
|
+
"Strategy handlers must be synchronous",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_handlers(tree: ast.Module) -> list[PolicyViolation]:
|
|
151
|
+
found: list[PolicyViolation] = []
|
|
152
|
+
handlers: dict[str, ast.FunctionDef] = {}
|
|
153
|
+
for node in tree.body:
|
|
154
|
+
if isinstance(node, ast.FunctionDef) and (
|
|
155
|
+
node.name == REQUIRED_HANDLER or node.name in OPTIONAL_HANDLERS
|
|
156
|
+
):
|
|
157
|
+
handlers[node.name] = node
|
|
158
|
+
|
|
159
|
+
if REQUIRED_HANDLER not in handlers:
|
|
160
|
+
found.append(
|
|
161
|
+
PolicyViolation(
|
|
162
|
+
1,
|
|
163
|
+
"missing_handler",
|
|
164
|
+
f"A strategy must define '{REQUIRED_HANDLER}(ctx)'",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
for name, node in handlers.items():
|
|
169
|
+
arguments = node.args
|
|
170
|
+
positional = [*arguments.posonlyargs, *arguments.args]
|
|
171
|
+
if (
|
|
172
|
+
len(positional) != 1
|
|
173
|
+
or arguments.vararg is not None
|
|
174
|
+
or arguments.kwarg is not None
|
|
175
|
+
or arguments.kwonlyargs
|
|
176
|
+
):
|
|
177
|
+
found.append(
|
|
178
|
+
PolicyViolation(
|
|
179
|
+
node.lineno,
|
|
180
|
+
"handler_signature",
|
|
181
|
+
f"'{name}' must take exactly one positional argument (ctx)",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
for inner in ast.walk(node):
|
|
185
|
+
if isinstance(inner, (ast.Yield, ast.YieldFrom)):
|
|
186
|
+
found.append(
|
|
187
|
+
PolicyViolation(
|
|
188
|
+
inner.lineno,
|
|
189
|
+
"generator_handler",
|
|
190
|
+
f"'{name}' must return a decision, not yield",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return found
|