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,152 @@
|
|
|
1
|
+
"""Virtual account state for a backtest.
|
|
2
|
+
|
|
3
|
+
Margin is a deliberately conservative research boundary, not an exchange
|
|
4
|
+
liquidation model: opening reserves ``notional / leverage * safety_multiplier``
|
|
5
|
+
from equity. When an adverse move exhausts that collateral the engine closes at
|
|
6
|
+
the conservative available price and records ``margin_exhaustion``. It must
|
|
7
|
+
never be read as an OKX liquidation estimate.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
|
|
14
|
+
LONG = "long"
|
|
15
|
+
SHORT = "short"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Position:
|
|
20
|
+
instrumentId: str # noqa: N815 - protocol field name
|
|
21
|
+
side: str
|
|
22
|
+
quantity: float
|
|
23
|
+
averageEntryPrice: float # noqa: N815
|
|
24
|
+
markPrice: float # noqa: N815
|
|
25
|
+
contractValue: float # noqa: N815
|
|
26
|
+
notionalUsdt: float # noqa: N815
|
|
27
|
+
usedMarginUsdt: float # noqa: N815
|
|
28
|
+
leverage: float
|
|
29
|
+
marginSafetyMultiplier: float # noqa: N815
|
|
30
|
+
unrealizedPnlUsdt: float # noqa: N815
|
|
31
|
+
entryFeeUsdt: float # noqa: N815
|
|
32
|
+
stopLossPrice: float | None # noqa: N815
|
|
33
|
+
takeProfitPrice: float | None # noqa: N815
|
|
34
|
+
openedAtMs: int # noqa: N815
|
|
35
|
+
updatedAtMs: int # noqa: N815
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class OpenOrder:
|
|
40
|
+
id: str
|
|
41
|
+
instrumentId: str # noqa: N815
|
|
42
|
+
action: str
|
|
43
|
+
quantity: float
|
|
44
|
+
filledQuantity: float # noqa: N815
|
|
45
|
+
status: str
|
|
46
|
+
price: float | None
|
|
47
|
+
createdAtMs: int # noqa: N815
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Fill:
|
|
52
|
+
id: str
|
|
53
|
+
orderId: str # noqa: N815
|
|
54
|
+
instrumentId: str # noqa: N815
|
|
55
|
+
action: str
|
|
56
|
+
quantity: float
|
|
57
|
+
price: float
|
|
58
|
+
notionalUsdt: float # noqa: N815
|
|
59
|
+
feeUsdt: float # noqa: N815
|
|
60
|
+
filledAtMs: int # noqa: N815
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Trade:
|
|
65
|
+
id: str
|
|
66
|
+
instrumentId: str # noqa: N815
|
|
67
|
+
side: str
|
|
68
|
+
quantity: float
|
|
69
|
+
entryPrice: float # noqa: N815
|
|
70
|
+
exitPrice: float # noqa: N815
|
|
71
|
+
usedMarginUsdt: float # noqa: N815
|
|
72
|
+
leverage: float
|
|
73
|
+
realizedPnlUsdt: float # noqa: N815
|
|
74
|
+
feesUsdt: float # noqa: N815
|
|
75
|
+
openedAtMs: int # noqa: N815
|
|
76
|
+
closedAtMs: int # noqa: N815
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class PortfolioView:
|
|
81
|
+
"""Immutable point-in-time account snapshot handed to strategy code."""
|
|
82
|
+
|
|
83
|
+
cash_usdt: float
|
|
84
|
+
equity_usdt: float
|
|
85
|
+
used_margin_usdt: float
|
|
86
|
+
available_margin_usdt: float
|
|
87
|
+
positions: tuple[Position, ...] = ()
|
|
88
|
+
open_orders: tuple[OpenOrder, ...] = ()
|
|
89
|
+
recent_fills: tuple[Fill, ...] = ()
|
|
90
|
+
trades: tuple[Trade, ...] = ()
|
|
91
|
+
|
|
92
|
+
def position(self, instrument_id: str, side: str) -> Position | None:
|
|
93
|
+
"""The open position for one side, or ``None`` when flat.
|
|
94
|
+
|
|
95
|
+
This is a method taking both arguments, never a bare ``position``
|
|
96
|
+
property; sizing and side are always explicit.
|
|
97
|
+
"""
|
|
98
|
+
if side not in (LONG, SHORT):
|
|
99
|
+
raise ValueError(f"side must be '{LONG}' or '{SHORT}', received {side!r}")
|
|
100
|
+
for item in self.positions:
|
|
101
|
+
if item.instrumentId == instrument_id and item.side == side:
|
|
102
|
+
return item
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def positions_for(self, instrument_id: str) -> tuple[Position, ...]:
|
|
106
|
+
return tuple(item for item in self.positions if item.instrumentId == instrument_id)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class MutablePosition:
|
|
111
|
+
"""Engine-internal position. Strategy code never sees this type."""
|
|
112
|
+
|
|
113
|
+
side: str
|
|
114
|
+
quantity: float
|
|
115
|
+
average_entry_price: float
|
|
116
|
+
used_margin_usdt: float
|
|
117
|
+
leverage: float
|
|
118
|
+
margin_safety_multiplier: float
|
|
119
|
+
entry_fee_usdt: float
|
|
120
|
+
stop_loss_price: float | None
|
|
121
|
+
take_profit_price: float | None
|
|
122
|
+
opened_at_ms: int
|
|
123
|
+
updated_at_ms: int
|
|
124
|
+
|
|
125
|
+
def notional(self, price: float, contract_value: float) -> float:
|
|
126
|
+
return self.quantity * contract_value * price
|
|
127
|
+
|
|
128
|
+
def unrealized(self, mark_price: float, contract_value: float) -> float:
|
|
129
|
+
delta = mark_price - self.average_entry_price
|
|
130
|
+
if self.side == SHORT:
|
|
131
|
+
delta = -delta
|
|
132
|
+
return delta * self.quantity * contract_value
|
|
133
|
+
|
|
134
|
+
def with_updated_time(self, now_ms: int) -> "MutablePosition":
|
|
135
|
+
return replace(self, updated_at_ms=now_ms)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class AccountState:
|
|
140
|
+
"""Engine-internal ledger accumulated across a run."""
|
|
141
|
+
|
|
142
|
+
cash_usdt: float
|
|
143
|
+
position: MutablePosition | None = None
|
|
144
|
+
open_orders: list[OpenOrder] = field(default_factory=list)
|
|
145
|
+
fills: list[Fill] = field(default_factory=list)
|
|
146
|
+
trades: list[Trade] = field(default_factory=list)
|
|
147
|
+
used_margin_usdt: float = 0.0
|
|
148
|
+
|
|
149
|
+
def equity(self, mark_price: float, contract_value: float) -> float:
|
|
150
|
+
if self.position is None:
|
|
151
|
+
return self.cash_usdt
|
|
152
|
+
return self.cash_usdt + self.position.unrealized(mark_price, contract_value)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Backtest metrics and the serialized report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .engine import RunResult
|
|
9
|
+
from .portfolio import LONG
|
|
10
|
+
|
|
11
|
+
MINUTES_PER_YEAR = 365 * 24 * 60
|
|
12
|
+
|
|
13
|
+
# Annualizing a very short window produces a meaningless figure, so it is capped
|
|
14
|
+
# rather than reported verbatim.
|
|
15
|
+
ANNUALIZED_RETURN_CAP_PCT = 1_000_000.0
|
|
16
|
+
# Above this, `math.exp` overflows; the cap already applies well before it.
|
|
17
|
+
_MAX_GROWTH_EXPONENT = 100.0
|
|
18
|
+
# Annualized figures below this window length are withheld rather than shown.
|
|
19
|
+
# A five-day run extrapolates to five-digit percentages that read as precision
|
|
20
|
+
# but carry none, and any ratio built on them inherits that noise.
|
|
21
|
+
MINIMUM_ANNUALIZATION_DAYS = 30
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Metrics:
|
|
26
|
+
initial_equity_usdt: float
|
|
27
|
+
final_equity_usdt: float
|
|
28
|
+
net_profit_usdt: float
|
|
29
|
+
return_pct: float
|
|
30
|
+
max_drawdown_pct: float
|
|
31
|
+
max_drawdown_usdt: float
|
|
32
|
+
max_drawdown_at_ms: int | None
|
|
33
|
+
longest_drawdown_bars: int
|
|
34
|
+
calmar: float | None
|
|
35
|
+
annualized_return_pct: float | None
|
|
36
|
+
annualization_reliable: bool
|
|
37
|
+
sharpe: float | None
|
|
38
|
+
sortino: float | None
|
|
39
|
+
trade_count: int
|
|
40
|
+
long_trade_count: int
|
|
41
|
+
short_trade_count: int
|
|
42
|
+
win_rate_pct: float | None
|
|
43
|
+
profit_factor: float | None
|
|
44
|
+
expectancy_usdt: float | None
|
|
45
|
+
average_win_usdt: float | None
|
|
46
|
+
average_loss_usdt: float | None
|
|
47
|
+
largest_win_usdt: float | None
|
|
48
|
+
largest_loss_usdt: float | None
|
|
49
|
+
average_holding_bars: float | None
|
|
50
|
+
max_consecutive_wins: int
|
|
51
|
+
max_consecutive_losses: int
|
|
52
|
+
total_fees_usdt: float
|
|
53
|
+
fees_pct_of_gross: float | None
|
|
54
|
+
turnover_usdt: float
|
|
55
|
+
exposure_pct: float
|
|
56
|
+
evaluation_bars: int
|
|
57
|
+
evaluation_days: float
|
|
58
|
+
trades_per_day: float | None
|
|
59
|
+
margin_exhausted: bool
|
|
60
|
+
|
|
61
|
+
def as_dict(self) -> dict[str, object]:
|
|
62
|
+
return {
|
|
63
|
+
"initialEquityUsdt": _round(self.initial_equity_usdt),
|
|
64
|
+
"finalEquityUsdt": _round(self.final_equity_usdt),
|
|
65
|
+
"netProfitUsdt": _round(self.net_profit_usdt),
|
|
66
|
+
"returnPct": _round(self.return_pct),
|
|
67
|
+
"maxDrawdownPct": _round(self.max_drawdown_pct),
|
|
68
|
+
"maxDrawdownUsdt": _round(self.max_drawdown_usdt),
|
|
69
|
+
"maxDrawdownAtMs": self.max_drawdown_at_ms,
|
|
70
|
+
"longestDrawdownBars": self.longest_drawdown_bars,
|
|
71
|
+
"calmar": _optional(self.calmar),
|
|
72
|
+
"annualizedReturnPct": _optional(self.annualized_return_pct),
|
|
73
|
+
"annualizationReliable": self.annualization_reliable,
|
|
74
|
+
"sharpe": _optional(self.sharpe),
|
|
75
|
+
"sortino": _optional(self.sortino),
|
|
76
|
+
"tradeCount": self.trade_count,
|
|
77
|
+
"longTradeCount": self.long_trade_count,
|
|
78
|
+
"shortTradeCount": self.short_trade_count,
|
|
79
|
+
"winRatePct": _optional(self.win_rate_pct),
|
|
80
|
+
"profitFactor": _optional(self.profit_factor),
|
|
81
|
+
"expectancyUsdt": _optional(self.expectancy_usdt),
|
|
82
|
+
"averageWinUsdt": _optional(self.average_win_usdt),
|
|
83
|
+
"averageLossUsdt": _optional(self.average_loss_usdt),
|
|
84
|
+
"largestWinUsdt": _optional(self.largest_win_usdt),
|
|
85
|
+
"largestLossUsdt": _optional(self.largest_loss_usdt),
|
|
86
|
+
"averageHoldingBars": _optional(self.average_holding_bars),
|
|
87
|
+
"maxConsecutiveWins": self.max_consecutive_wins,
|
|
88
|
+
"maxConsecutiveLosses": self.max_consecutive_losses,
|
|
89
|
+
"totalFeesUsdt": _round(self.total_fees_usdt),
|
|
90
|
+
"feesPctOfGross": _optional(self.fees_pct_of_gross),
|
|
91
|
+
"turnoverUsdt": _round(self.turnover_usdt),
|
|
92
|
+
"exposurePct": _round(self.exposure_pct),
|
|
93
|
+
"evaluationBars": self.evaluation_bars,
|
|
94
|
+
"evaluationDays": _round(self.evaluation_days),
|
|
95
|
+
"tradesPerDay": _optional(self.trades_per_day),
|
|
96
|
+
"marginExhausted": self.margin_exhausted,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def calculate_metrics(result: RunResult, initial_equity_usdt: float) -> Metrics:
|
|
101
|
+
curve = result.equity_curve
|
|
102
|
+
final_equity = curve[-1].equity_usdt if curve else initial_equity_usdt
|
|
103
|
+
return_pct = (
|
|
104
|
+
0.0 if initial_equity_usdt <= 0 else (final_equity / initial_equity_usdt - 1) * 100
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
peak = initial_equity_usdt
|
|
108
|
+
max_drawdown = 0.0
|
|
109
|
+
max_drawdown_usdt = 0.0
|
|
110
|
+
max_drawdown_at: int | None = None
|
|
111
|
+
longest_drawdown = 0
|
|
112
|
+
current_drawdown = 0
|
|
113
|
+
for point in curve:
|
|
114
|
+
if point.equity_usdt >= peak:
|
|
115
|
+
peak = point.equity_usdt
|
|
116
|
+
current_drawdown = 0
|
|
117
|
+
else:
|
|
118
|
+
current_drawdown += 1
|
|
119
|
+
longest_drawdown = max(longest_drawdown, current_drawdown)
|
|
120
|
+
if peak > 0:
|
|
121
|
+
depth_pct = (peak - point.equity_usdt) / peak * 100
|
|
122
|
+
if depth_pct > max_drawdown:
|
|
123
|
+
max_drawdown = depth_pct
|
|
124
|
+
max_drawdown_usdt = peak - point.equity_usdt
|
|
125
|
+
max_drawdown_at = point.time_ms
|
|
126
|
+
|
|
127
|
+
evaluation_days = result.evaluation_bars / 1_440
|
|
128
|
+
annualized = _annualized_return(initial_equity_usdt, final_equity, result.evaluation_bars)
|
|
129
|
+
reliable = evaluation_days >= MINIMUM_ANNUALIZATION_DAYS
|
|
130
|
+
# Calmar divides an annualized figure by drawdown, so it inherits the
|
|
131
|
+
# extrapolation error. On a five-day window it reads in the tens of
|
|
132
|
+
# thousands, which looks like precision and is noise; withhold both.
|
|
133
|
+
calmar = None
|
|
134
|
+
if reliable and annualized is not None and max_drawdown > 0:
|
|
135
|
+
calmar = annualized / max_drawdown
|
|
136
|
+
|
|
137
|
+
# Trade PnL is measured net of fees. Using gross PnL here would report a
|
|
138
|
+
# profit factor above 1 for a strategy whose equity actually fell, because
|
|
139
|
+
# fees on a high-turnover run can exceed the gross edge entirely.
|
|
140
|
+
net_pnls = [trade.realizedPnlUsdt - trade.feesUsdt for trade in result.trades]
|
|
141
|
+
wins = [value for value in net_pnls if value > 0]
|
|
142
|
+
losses = [value for value in net_pnls if value < 0]
|
|
143
|
+
gross_win = sum(wins)
|
|
144
|
+
gross_loss = abs(sum(losses))
|
|
145
|
+
total_fees = sum(fill.feeUsdt for fill in result.fills)
|
|
146
|
+
gross_pnl = sum(trade.realizedPnlUsdt for trade in result.trades)
|
|
147
|
+
holding = [
|
|
148
|
+
(trade.closedAtMs - trade.openedAtMs) / 60_000
|
|
149
|
+
for trade in result.trades
|
|
150
|
+
if trade.closedAtMs > trade.openedAtMs
|
|
151
|
+
]
|
|
152
|
+
wins_streak, losses_streak = _streaks(net_pnls)
|
|
153
|
+
|
|
154
|
+
return Metrics(
|
|
155
|
+
initial_equity_usdt=initial_equity_usdt,
|
|
156
|
+
final_equity_usdt=final_equity,
|
|
157
|
+
net_profit_usdt=final_equity - initial_equity_usdt,
|
|
158
|
+
return_pct=return_pct,
|
|
159
|
+
max_drawdown_pct=max_drawdown,
|
|
160
|
+
max_drawdown_usdt=max_drawdown_usdt,
|
|
161
|
+
max_drawdown_at_ms=max_drawdown_at,
|
|
162
|
+
longest_drawdown_bars=longest_drawdown,
|
|
163
|
+
calmar=calmar,
|
|
164
|
+
annualized_return_pct=annualized if reliable else None,
|
|
165
|
+
annualization_reliable=reliable,
|
|
166
|
+
sharpe=_sharpe(curve, downside_only=False),
|
|
167
|
+
sortino=_sharpe(curve, downside_only=True),
|
|
168
|
+
trade_count=len(result.trades),
|
|
169
|
+
long_trade_count=sum(1 for trade in result.trades if trade.side == LONG),
|
|
170
|
+
short_trade_count=sum(1 for trade in result.trades if trade.side != LONG),
|
|
171
|
+
win_rate_pct=None if not net_pnls else len(wins) / len(net_pnls) * 100,
|
|
172
|
+
profit_factor=None if gross_loss <= 0 else gross_win / gross_loss,
|
|
173
|
+
expectancy_usdt=None if not net_pnls else sum(net_pnls) / len(net_pnls),
|
|
174
|
+
average_win_usdt=None if not wins else gross_win / len(wins),
|
|
175
|
+
average_loss_usdt=None if not losses else -gross_loss / len(losses),
|
|
176
|
+
largest_win_usdt=max(wins) if wins else None,
|
|
177
|
+
largest_loss_usdt=min(losses) if losses else None,
|
|
178
|
+
average_holding_bars=None if not holding else sum(holding) / len(holding),
|
|
179
|
+
max_consecutive_wins=wins_streak,
|
|
180
|
+
max_consecutive_losses=losses_streak,
|
|
181
|
+
total_fees_usdt=total_fees,
|
|
182
|
+
# How much of the gross edge the costs consumed. Above 100% means fees
|
|
183
|
+
# alone turned a winning signal into a loss.
|
|
184
|
+
fees_pct_of_gross=None if abs(gross_pnl) <= 0 else total_fees / abs(gross_pnl) * 100,
|
|
185
|
+
turnover_usdt=sum(fill.notionalUsdt for fill in result.fills),
|
|
186
|
+
exposure_pct=0.0
|
|
187
|
+
if result.evaluation_bars == 0
|
|
188
|
+
else result.exposed_bars / result.evaluation_bars * 100,
|
|
189
|
+
evaluation_bars=result.evaluation_bars,
|
|
190
|
+
evaluation_days=evaluation_days,
|
|
191
|
+
trades_per_day=None if evaluation_days <= 0 else len(result.trades) / evaluation_days,
|
|
192
|
+
margin_exhausted=result.margin_exhausted,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _annualized_return(initial: float, final: float, bars: int) -> float | None:
|
|
197
|
+
years = max(1, bars) / MINUTES_PER_YEAR
|
|
198
|
+
if initial <= 0 or final <= 0 or years <= 0:
|
|
199
|
+
return None
|
|
200
|
+
# Compute in log space: a short window raises the growth ratio to a huge
|
|
201
|
+
# exponent, which overflows a float before any cap could be applied.
|
|
202
|
+
exponent = math.log(final / initial) / years
|
|
203
|
+
if exponent > _MAX_GROWTH_EXPONENT:
|
|
204
|
+
return ANNUALIZED_RETURN_CAP_PCT
|
|
205
|
+
value = (math.exp(exponent) - 1) * 100
|
|
206
|
+
return max(-100.0, min(value, ANNUALIZED_RETURN_CAP_PCT))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _sharpe(curve: list, downside_only: bool) -> float | None:
|
|
210
|
+
"""Annualized Sharpe, or Sortino when only downside deviation counts.
|
|
211
|
+
|
|
212
|
+
Returns are per evaluation bar (one minute), so the annualization factor is
|
|
213
|
+
the square root of the number of minutes in a year.
|
|
214
|
+
"""
|
|
215
|
+
if len(curve) < 3:
|
|
216
|
+
return None
|
|
217
|
+
returns: list[float] = []
|
|
218
|
+
for index in range(1, len(curve)):
|
|
219
|
+
previous = curve[index - 1].equity_usdt
|
|
220
|
+
if previous <= 0:
|
|
221
|
+
continue
|
|
222
|
+
returns.append(curve[index].equity_usdt / previous - 1)
|
|
223
|
+
if len(returns) < 2:
|
|
224
|
+
return None
|
|
225
|
+
mean = sum(returns) / len(returns)
|
|
226
|
+
sample = [value for value in returns if value < 0] if downside_only else returns
|
|
227
|
+
if len(sample) < 2:
|
|
228
|
+
return None
|
|
229
|
+
variance = sum((value - (0.0 if downside_only else mean)) ** 2 for value in sample) / (len(sample) - 1)
|
|
230
|
+
deviation = math.sqrt(variance)
|
|
231
|
+
if deviation <= 0:
|
|
232
|
+
return None
|
|
233
|
+
return mean / deviation * math.sqrt(MINUTES_PER_YEAR)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _streaks(values: list[float]) -> tuple[int, int]:
|
|
237
|
+
best_wins = 0
|
|
238
|
+
best_losses = 0
|
|
239
|
+
wins = 0
|
|
240
|
+
losses = 0
|
|
241
|
+
for value in values:
|
|
242
|
+
if value > 0:
|
|
243
|
+
wins += 1
|
|
244
|
+
losses = 0
|
|
245
|
+
elif value < 0:
|
|
246
|
+
losses += 1
|
|
247
|
+
wins = 0
|
|
248
|
+
else:
|
|
249
|
+
wins = 0
|
|
250
|
+
losses = 0
|
|
251
|
+
best_wins = max(best_wins, wins)
|
|
252
|
+
best_losses = max(best_losses, losses)
|
|
253
|
+
return best_wins, best_losses
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def build_report(result: RunResult, initial_equity_usdt: float) -> dict[str, object]:
|
|
257
|
+
metrics = calculate_metrics(result, initial_equity_usdt)
|
|
258
|
+
return {
|
|
259
|
+
"status": result.status,
|
|
260
|
+
"metrics": metrics.as_dict(),
|
|
261
|
+
"equity": [
|
|
262
|
+
{
|
|
263
|
+
"timeMs": point.time_ms,
|
|
264
|
+
"equityUsdt": _round(point.equity_usdt),
|
|
265
|
+
"realizedCashUsdt": _round(point.realized_cash_usdt),
|
|
266
|
+
"unrealizedPnlUsdt": _round(point.unrealized_pnl_usdt),
|
|
267
|
+
}
|
|
268
|
+
for point in result.equity_curve
|
|
269
|
+
],
|
|
270
|
+
"trades": [
|
|
271
|
+
{
|
|
272
|
+
"id": trade.id,
|
|
273
|
+
"side": trade.side,
|
|
274
|
+
"quantity": _round(trade.quantity),
|
|
275
|
+
"entryPrice": _round(trade.entryPrice),
|
|
276
|
+
"exitPrice": _round(trade.exitPrice),
|
|
277
|
+
"realizedPnlUsdt": _round(trade.realizedPnlUsdt),
|
|
278
|
+
# Net of this trade's own fees, matching how win rate and profit
|
|
279
|
+
# factor are computed.
|
|
280
|
+
"netPnlUsdt": _round(trade.realizedPnlUsdt - trade.feesUsdt),
|
|
281
|
+
"feesUsdt": _round(trade.feesUsdt),
|
|
282
|
+
"returnPct": _round(
|
|
283
|
+
0.0
|
|
284
|
+
if trade.usedMarginUsdt <= 0
|
|
285
|
+
else (trade.realizedPnlUsdt - trade.feesUsdt) / trade.usedMarginUsdt * 100
|
|
286
|
+
),
|
|
287
|
+
"holdingBars": max(0, (trade.closedAtMs - trade.openedAtMs) // 60_000),
|
|
288
|
+
"openedAtMs": trade.openedAtMs,
|
|
289
|
+
"closedAtMs": trade.closedAtMs,
|
|
290
|
+
"isLong": trade.side == LONG,
|
|
291
|
+
}
|
|
292
|
+
for trade in result.trades
|
|
293
|
+
],
|
|
294
|
+
"fills": [
|
|
295
|
+
{
|
|
296
|
+
"id": fill.id,
|
|
297
|
+
"action": fill.action,
|
|
298
|
+
"quantity": _round(fill.quantity),
|
|
299
|
+
"price": _round(fill.price),
|
|
300
|
+
"feeUsdt": _round(fill.feeUsdt),
|
|
301
|
+
"filledAtMs": fill.filledAtMs,
|
|
302
|
+
}
|
|
303
|
+
for fill in result.fills
|
|
304
|
+
],
|
|
305
|
+
"actions": result.actions,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _round(value: float) -> float:
|
|
310
|
+
"""Rounds to 10 decimals so a report hash is stable across platforms."""
|
|
311
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
312
|
+
return value
|
|
313
|
+
if math.isnan(value) or math.isinf(value):
|
|
314
|
+
return 0.0
|
|
315
|
+
return round(float(value), 10)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _optional(value: float | None) -> float | None:
|
|
319
|
+
return None if value is None else _round(value)
|