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.
Files changed (237) hide show
  1. package/README.en.md +353 -0
  2. package/README.md +194 -190
  3. package/dist/account/private-websocket.js +4 -4
  4. package/dist/account/private-websocket.js.map +1 -1
  5. package/dist/account/service.d.ts +12 -1
  6. package/dist/account/service.js +18 -0
  7. package/dist/account/service.js.map +1 -1
  8. package/dist/bars/rate-limiter.d.ts +18 -0
  9. package/dist/bars/rate-limiter.js +84 -0
  10. package/dist/bars/rate-limiter.js.map +1 -0
  11. package/dist/bars/schema.d.ts +36 -0
  12. package/dist/bars/schema.js +134 -0
  13. package/dist/bars/schema.js.map +1 -0
  14. package/dist/bars/service.d.ts +60 -0
  15. package/dist/bars/service.js +120 -0
  16. package/dist/bars/service.js.map +1 -0
  17. package/dist/bars/store.d.ts +105 -0
  18. package/dist/bars/store.js +415 -0
  19. package/dist/bars/store.js.map +1 -0
  20. package/dist/bars/timeframe.d.ts +40 -0
  21. package/dist/bars/timeframe.js +146 -0
  22. package/dist/bars/timeframe.js.map +1 -0
  23. package/dist/bars/types.d.ts +68 -0
  24. package/dist/bars/types.js +13 -0
  25. package/dist/bars/types.js.map +1 -0
  26. package/dist/cli/data-render.d.ts +37 -0
  27. package/dist/cli/data-render.js +143 -0
  28. package/dist/cli/data-render.js.map +1 -0
  29. package/dist/cli/doctor.js +7 -3
  30. package/dist/cli/doctor.js.map +1 -1
  31. package/dist/cli/index.js +758 -27
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/live-render.d.ts +24 -0
  34. package/dist/cli/live-render.js +85 -0
  35. package/dist/cli/live-render.js.map +1 -0
  36. package/dist/cli/range.d.ts +28 -0
  37. package/dist/cli/range.js +63 -0
  38. package/dist/cli/range.js.map +1 -0
  39. package/dist/cli/render.js +3 -0
  40. package/dist/cli/render.js.map +1 -1
  41. package/dist/cli/strategy-render.d.ts +36 -0
  42. package/dist/cli/strategy-render.js +391 -0
  43. package/dist/cli/strategy-render.js.map +1 -0
  44. package/dist/cli/width.d.ts +18 -0
  45. package/dist/cli/width.js +71 -0
  46. package/dist/cli/width.js.map +1 -0
  47. package/dist/config/loader.js +1 -1
  48. package/dist/config/schema.d.ts +7 -0
  49. package/dist/config/schema.js +24 -0
  50. package/dist/config/schema.js.map +1 -1
  51. package/dist/core/okx-client.d.ts +9 -1
  52. package/dist/core/okx-client.js +14 -5
  53. package/dist/core/okx-client.js.map +1 -1
  54. package/dist/i18n/locale.d.ts +24 -0
  55. package/dist/i18n/locale.js +65 -0
  56. package/dist/i18n/locale.js.map +1 -0
  57. package/dist/i18n/messages.d.ts +333 -0
  58. package/dist/i18n/messages.js +660 -0
  59. package/dist/i18n/messages.js.map +1 -0
  60. package/dist/live/account-snapshot.d.ts +30 -0
  61. package/dist/live/account-snapshot.js +130 -0
  62. package/dist/live/account-snapshot.js.map +1 -0
  63. package/dist/live/cutoff-queue.d.ts +42 -0
  64. package/dist/live/cutoff-queue.js +69 -0
  65. package/dist/live/cutoff-queue.js.map +1 -0
  66. package/dist/live/execution-key.d.ts +23 -0
  67. package/dist/live/execution-key.js +31 -0
  68. package/dist/live/execution-key.js.map +1 -0
  69. package/dist/live/failures.d.ts +37 -0
  70. package/dist/live/failures.js +57 -0
  71. package/dist/live/failures.js.map +1 -0
  72. package/dist/live/gates.d.ts +65 -0
  73. package/dist/live/gates.js +136 -0
  74. package/dist/live/gates.js.map +1 -0
  75. package/dist/live/loop.d.ts +56 -0
  76. package/dist/live/loop.js +197 -0
  77. package/dist/live/loop.js.map +1 -0
  78. package/dist/live/preconditions.d.ts +48 -0
  79. package/dist/live/preconditions.js +69 -0
  80. package/dist/live/preconditions.js.map +1 -0
  81. package/dist/live/reconcile.d.ts +46 -0
  82. package/dist/live/reconcile.js +104 -0
  83. package/dist/live/reconcile.js.map +1 -0
  84. package/dist/live/runner.d.ts +57 -0
  85. package/dist/live/runner.js +160 -0
  86. package/dist/live/runner.js.map +1 -0
  87. package/dist/live/schema.d.ts +18 -0
  88. package/dist/live/schema.js +91 -0
  89. package/dist/live/schema.js.map +1 -0
  90. package/dist/live/service.d.ts +144 -0
  91. package/dist/live/service.js +303 -0
  92. package/dist/live/service.js.map +1 -0
  93. package/dist/live/session.d.ts +85 -0
  94. package/dist/live/session.js +234 -0
  95. package/dist/live/session.js.map +1 -0
  96. package/dist/live/sizing.d.ts +62 -0
  97. package/dist/live/sizing.js +79 -0
  98. package/dist/live/sizing.js.map +1 -0
  99. package/dist/live/store.d.ts +123 -0
  100. package/dist/live/store.js +350 -0
  101. package/dist/live/store.js.map +1 -0
  102. package/dist/live/types.d.ts +82 -0
  103. package/dist/live/types.js +2 -0
  104. package/dist/live/types.js.map +1 -0
  105. package/dist/market/websocket.d.ts +16 -1
  106. package/dist/market/websocket.js +60 -5
  107. package/dist/market/websocket.js.map +1 -1
  108. package/dist/mcp/server.d.ts +1 -0
  109. package/dist/mcp/server.js +15 -1
  110. package/dist/mcp/server.js.map +1 -1
  111. package/dist/network/connectivity.d.ts +9 -1
  112. package/dist/network/connectivity.js +28 -1
  113. package/dist/network/connectivity.js.map +1 -1
  114. package/dist/report/chart-script.d.ts +12 -0
  115. package/dist/report/chart-script.js +146 -0
  116. package/dist/report/chart-script.js.map +1 -0
  117. package/dist/report/compare-html.d.ts +8 -0
  118. package/dist/report/compare-html.js +254 -0
  119. package/dist/report/compare-html.js.map +1 -0
  120. package/dist/report/compare-script.d.ts +12 -0
  121. package/dist/report/compare-script.js +109 -0
  122. package/dist/report/compare-script.js.map +1 -0
  123. package/dist/report/compare.d.ts +61 -0
  124. package/dist/report/compare.js +205 -0
  125. package/dist/report/compare.js.map +1 -0
  126. package/dist/report/fetch.d.ts +20 -0
  127. package/dist/report/fetch.js +56 -0
  128. package/dist/report/fetch.js.map +1 -0
  129. package/dist/report/html.d.ts +54 -0
  130. package/dist/report/html.js +641 -0
  131. package/dist/report/html.js.map +1 -0
  132. package/dist/report/open.d.ts +42 -0
  133. package/dist/report/open.js +114 -0
  134. package/dist/report/open.js.map +1 -0
  135. package/dist/runtime/server.d.ts +16 -1
  136. package/dist/runtime/server.js +112 -9
  137. package/dist/runtime/server.js.map +1 -1
  138. package/dist/setup/installer.d.ts +1 -0
  139. package/dist/setup/installer.js +8 -0
  140. package/dist/setup/installer.js.map +1 -1
  141. package/dist/setup/wizard.d.ts +3 -0
  142. package/dist/setup/wizard.js +131 -6
  143. package/dist/setup/wizard.js.map +1 -1
  144. package/dist/strategy/constants.d.ts +23 -0
  145. package/dist/strategy/constants.js +24 -0
  146. package/dist/strategy/constants.js.map +1 -0
  147. package/dist/strategy/environment.d.ts +52 -0
  148. package/dist/strategy/environment.js +187 -0
  149. package/dist/strategy/environment.js.map +1 -0
  150. package/dist/strategy/instrument.d.ts +29 -0
  151. package/dist/strategy/instrument.js +39 -0
  152. package/dist/strategy/instrument.js.map +1 -0
  153. package/dist/strategy/optimize.d.ts +73 -0
  154. package/dist/strategy/optimize.js +113 -0
  155. package/dist/strategy/optimize.js.map +1 -0
  156. package/dist/strategy/parameter-space.d.ts +59 -0
  157. package/dist/strategy/parameter-space.js +221 -0
  158. package/dist/strategy/parameter-space.js.map +1 -0
  159. package/dist/strategy/python-bridge.d.ts +24 -0
  160. package/dist/strategy/python-bridge.js +114 -0
  161. package/dist/strategy/python-bridge.js.map +1 -0
  162. package/dist/strategy/schema.d.ts +9 -0
  163. package/dist/strategy/schema.js +91 -0
  164. package/dist/strategy/schema.js.map +1 -0
  165. package/dist/strategy/service.d.ts +138 -0
  166. package/dist/strategy/service.js +745 -0
  167. package/dist/strategy/service.js.map +1 -0
  168. package/dist/strategy/settings.d.ts +162 -0
  169. package/dist/strategy/settings.js +243 -0
  170. package/dist/strategy/settings.js.map +1 -0
  171. package/dist/strategy/store.d.ts +96 -0
  172. package/dist/strategy/store.js +367 -0
  173. package/dist/strategy/store.js.map +1 -0
  174. package/dist/strategy/templates.d.ts +11 -0
  175. package/dist/strategy/templates.js +134 -0
  176. package/dist/strategy/templates.js.map +1 -0
  177. package/dist/strategy/types.d.ts +111 -0
  178. package/dist/strategy/types.js +2 -0
  179. package/dist/strategy/types.js.map +1 -0
  180. package/dist/tools/catalog.d.ts +16 -0
  181. package/dist/tools/catalog.js +118 -17
  182. package/dist/tools/catalog.js.map +1 -1
  183. package/dist/trade/service.d.ts +12 -0
  184. package/dist/trade/service.js +24 -7
  185. package/dist/trade/service.js.map +1 -1
  186. package/dist/tui/app.d.ts +23 -0
  187. package/dist/tui/app.js +322 -0
  188. package/dist/tui/app.js.map +1 -0
  189. package/dist/tui/commands.d.ts +70 -0
  190. package/dist/tui/commands.js +313 -0
  191. package/dist/tui/commands.js.map +1 -0
  192. package/dist/tui/entries.d.ts +17 -0
  193. package/dist/tui/entries.js +24 -0
  194. package/dist/tui/entries.js.map +1 -0
  195. package/dist/tui/execute.d.ts +26 -0
  196. package/dist/tui/execute.js +664 -0
  197. package/dist/tui/execute.js.map +1 -0
  198. package/dist/tui/history.d.ts +17 -0
  199. package/dist/tui/history.js +48 -0
  200. package/dist/tui/history.js.map +1 -0
  201. package/dist/tui/index.d.ts +8 -0
  202. package/dist/tui/index.js +48 -0
  203. package/dist/tui/index.js.map +1 -0
  204. package/dist/tui/line-editor.d.ts +44 -0
  205. package/dist/tui/line-editor.js +98 -0
  206. package/dist/tui/line-editor.js.map +1 -0
  207. package/dist/tui/progress.d.ts +23 -0
  208. package/dist/tui/progress.js +46 -0
  209. package/dist/tui/progress.js.map +1 -0
  210. package/dist/tui/settings-editor.d.ts +18 -0
  211. package/dist/tui/settings-editor.js +115 -0
  212. package/dist/tui/settings-editor.js.map +1 -0
  213. package/docs/live-trading.md +455 -0
  214. package/docs/strategy-research.md +597 -0
  215. package/package.json +11 -1
  216. package/python/desic_strategy/__init__.py +34 -0
  217. package/python/desic_strategy/actions.py +158 -0
  218. package/python/desic_strategy/context.py +164 -0
  219. package/python/desic_strategy/engine.py +614 -0
  220. package/python/desic_strategy/indicators.py +159 -0
  221. package/python/desic_strategy/live.py +253 -0
  222. package/python/desic_strategy/policy.py +193 -0
  223. package/python/desic_strategy/portfolio.py +152 -0
  224. package/python/desic_strategy/report.py +319 -0
  225. package/python/desic_strategy/runner.py +574 -0
  226. package/python/desic_strategy/timeframe.py +150 -0
  227. package/python/main.py +18 -0
  228. package/skills/okx-live-trading/SKILL.md +117 -0
  229. package/skills/okx-live-trading/agents/openai.yaml +9 -0
  230. package/skills/okx-live-trading/references/lifecycle.md +128 -0
  231. package/skills/okx-strategy-research/SKILL.md +113 -0
  232. package/skills/okx-strategy-research/agents/openai.yaml +9 -0
  233. package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
  234. package/skills/okx-strategy-research/references/field-traps.md +142 -0
  235. package/skills/okx-strategy-research/references/python-api.md +121 -0
  236. package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
  237. 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)