desic-okx-agent 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.en.md +90 -10
  2. package/README.md +76 -10
  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 +757 -26
  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.js +19 -22
  142. package/dist/setup/wizard.js.map +1 -1
  143. package/dist/strategy/constants.d.ts +23 -0
  144. package/dist/strategy/constants.js +24 -0
  145. package/dist/strategy/constants.js.map +1 -0
  146. package/dist/strategy/environment.d.ts +52 -0
  147. package/dist/strategy/environment.js +187 -0
  148. package/dist/strategy/environment.js.map +1 -0
  149. package/dist/strategy/instrument.d.ts +29 -0
  150. package/dist/strategy/instrument.js +39 -0
  151. package/dist/strategy/instrument.js.map +1 -0
  152. package/dist/strategy/optimize.d.ts +73 -0
  153. package/dist/strategy/optimize.js +113 -0
  154. package/dist/strategy/optimize.js.map +1 -0
  155. package/dist/strategy/parameter-space.d.ts +59 -0
  156. package/dist/strategy/parameter-space.js +221 -0
  157. package/dist/strategy/parameter-space.js.map +1 -0
  158. package/dist/strategy/python-bridge.d.ts +24 -0
  159. package/dist/strategy/python-bridge.js +114 -0
  160. package/dist/strategy/python-bridge.js.map +1 -0
  161. package/dist/strategy/schema.d.ts +9 -0
  162. package/dist/strategy/schema.js +91 -0
  163. package/dist/strategy/schema.js.map +1 -0
  164. package/dist/strategy/service.d.ts +138 -0
  165. package/dist/strategy/service.js +745 -0
  166. package/dist/strategy/service.js.map +1 -0
  167. package/dist/strategy/settings.d.ts +162 -0
  168. package/dist/strategy/settings.js +243 -0
  169. package/dist/strategy/settings.js.map +1 -0
  170. package/dist/strategy/store.d.ts +96 -0
  171. package/dist/strategy/store.js +367 -0
  172. package/dist/strategy/store.js.map +1 -0
  173. package/dist/strategy/templates.d.ts +11 -0
  174. package/dist/strategy/templates.js +134 -0
  175. package/dist/strategy/templates.js.map +1 -0
  176. package/dist/strategy/types.d.ts +111 -0
  177. package/dist/strategy/types.js +2 -0
  178. package/dist/strategy/types.js.map +1 -0
  179. package/dist/tools/catalog.d.ts +16 -0
  180. package/dist/tools/catalog.js +118 -17
  181. package/dist/tools/catalog.js.map +1 -1
  182. package/dist/trade/service.d.ts +12 -0
  183. package/dist/trade/service.js +24 -7
  184. package/dist/trade/service.js.map +1 -1
  185. package/dist/tui/app.d.ts +23 -0
  186. package/dist/tui/app.js +322 -0
  187. package/dist/tui/app.js.map +1 -0
  188. package/dist/tui/commands.d.ts +70 -0
  189. package/dist/tui/commands.js +313 -0
  190. package/dist/tui/commands.js.map +1 -0
  191. package/dist/tui/entries.d.ts +17 -0
  192. package/dist/tui/entries.js +24 -0
  193. package/dist/tui/entries.js.map +1 -0
  194. package/dist/tui/execute.d.ts +26 -0
  195. package/dist/tui/execute.js +664 -0
  196. package/dist/tui/execute.js.map +1 -0
  197. package/dist/tui/history.d.ts +17 -0
  198. package/dist/tui/history.js +48 -0
  199. package/dist/tui/history.js.map +1 -0
  200. package/dist/tui/index.d.ts +8 -0
  201. package/dist/tui/index.js +48 -0
  202. package/dist/tui/index.js.map +1 -0
  203. package/dist/tui/line-editor.d.ts +44 -0
  204. package/dist/tui/line-editor.js +98 -0
  205. package/dist/tui/line-editor.js.map +1 -0
  206. package/dist/tui/progress.d.ts +23 -0
  207. package/dist/tui/progress.js +46 -0
  208. package/dist/tui/progress.js.map +1 -0
  209. package/dist/tui/settings-editor.d.ts +18 -0
  210. package/dist/tui/settings-editor.js +115 -0
  211. package/dist/tui/settings-editor.js.map +1 -0
  212. package/docs/live-trading.md +455 -0
  213. package/docs/strategy-research.md +597 -0
  214. package/package.json +10 -1
  215. package/python/desic_strategy/__init__.py +34 -0
  216. package/python/desic_strategy/actions.py +158 -0
  217. package/python/desic_strategy/context.py +164 -0
  218. package/python/desic_strategy/engine.py +614 -0
  219. package/python/desic_strategy/indicators.py +159 -0
  220. package/python/desic_strategy/live.py +253 -0
  221. package/python/desic_strategy/policy.py +193 -0
  222. package/python/desic_strategy/portfolio.py +152 -0
  223. package/python/desic_strategy/report.py +319 -0
  224. package/python/desic_strategy/runner.py +574 -0
  225. package/python/desic_strategy/timeframe.py +150 -0
  226. package/python/main.py +18 -0
  227. package/skills/okx-live-trading/SKILL.md +117 -0
  228. package/skills/okx-live-trading/agents/openai.yaml +9 -0
  229. package/skills/okx-live-trading/references/lifecycle.md +128 -0
  230. package/skills/okx-strategy-research/SKILL.md +113 -0
  231. package/skills/okx-strategy-research/agents/openai.yaml +9 -0
  232. package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
  233. package/skills/okx-strategy-research/references/field-traps.md +142 -0
  234. package/skills/okx-strategy-research/references/python-api.md +121 -0
  235. package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
  236. package/skills/okx-trading/SKILL.md +11 -10
@@ -0,0 +1,641 @@
1
+ import { activeLocale, activeMessages } from "../i18n/locale.js";
2
+ import { MINIMUM_ANNUALIZATION_DAYS } from "../strategy/constants.js";
3
+ import { CHART_SCRIPT } from "./chart-script.js";
4
+ /**
5
+ * Renders a self-contained HTML report.
6
+ *
7
+ * Everything is inlined — no CDN, no fonts, no scripts fetched at view time — so
8
+ * the file opens offline, keeps working when archived next to its run, and never
9
+ * sends the contents of a strategy anywhere.
10
+ *
11
+ * The chart is drawn as SVG paths computed here rather than by a charting
12
+ * library: a 50,000-point curve as JSON plus a bundled library would be
13
+ * megabytes, and the useful information survives downsampling.
14
+ */
15
+ export function renderReportHtml(input) {
16
+ const { run, equity, trades } = input;
17
+ const text = activeMessages();
18
+ const metrics = (run.metrics ?? {});
19
+ const sampled = downsample(equity, 900);
20
+ const drawdown = drawdownSeries(sampled, num(metrics.initialEquityUsdt) ?? 10_000);
21
+ return `<!DOCTYPE html>
22
+ <html lang="${activeLang()}">
23
+ <head>
24
+ <meta charset="utf-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1">
26
+ <title>${escapeHtml(run.id)} · ${escapeHtml(run.strategyName)}</title>
27
+ <style>${STYLES}</style>
28
+ </head>
29
+ <body>
30
+ <main>
31
+ <header>
32
+ <div>
33
+ <h1>${escapeHtml(run.strategyName)}</h1>
34
+ <p class="sub">${escapeHtml(run.instId)} · ${escapeHtml(run.id)}</p>
35
+ </div>
36
+ <div class="headline ${sign(num(metrics.returnPct))}">
37
+ <span class="value">${signed(num(metrics.returnPct), "%")}</span>
38
+ <span class="label">${escapeHtml(text.returnLabel)}</span>
39
+ </div>
40
+ </header>
41
+
42
+ ${renderFacts(run, metrics, text)}
43
+ ${renderWarnings(metrics, text)}
44
+
45
+ <section>
46
+ <h2>${escapeHtml(text.equityHeading)}</h2>
47
+ ${renderChart(sampled, drawdown, num(metrics.initialEquityUsdt) ?? 10_000, text)}
48
+ </section>
49
+
50
+ ${renderMetricGroups(metrics, text)}
51
+ ${renderAssumptions(run, text)}
52
+ ${renderMonthly(equity, text)}
53
+ ${renderDistribution(trades, text)}
54
+ ${renderTrades(trades, text)}
55
+
56
+ <footer>
57
+ <p>${escapeHtml(text.simulationNote)}</p>
58
+ <p class="mono">${escapeHtml(run.dataSnapshotId ?? "-")}</p>
59
+ </footer>
60
+ </main>
61
+ <script>${CHART_SCRIPT}</script>
62
+ </body>
63
+ </html>`;
64
+ }
65
+ function renderFacts(run, metrics, text) {
66
+ const days = num(metrics.evaluationDays);
67
+ const items = [
68
+ [text.statusLabel, run.status],
69
+ [text.barsLabel, `${formatNumber(run.barCount)} (${formatNumber(run.preloadBars)} preload)`],
70
+ ["", days === null ? "-" : `${days.toFixed(1)}d`],
71
+ [
72
+ text.elapsedLabel,
73
+ run.startedAt !== null && run.finishedAt !== null
74
+ ? `${((run.finishedAt - run.startedAt) / 1000).toFixed(1)}s`
75
+ : "-"
76
+ ]
77
+ ];
78
+ return `<div class="facts">${items
79
+ .filter(([label]) => label !== "")
80
+ .map(([label, value]) => `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`)
81
+ .join("")}</div>`;
82
+ }
83
+ function renderWarnings(metrics, text) {
84
+ const warnings = [];
85
+ const feeShare = num(metrics.feesPctOfGross);
86
+ // Costs above the gross edge are the usual reason a plausible signal loses
87
+ // money, so state it rather than leaving it to be inferred.
88
+ if (feeShare !== null && feeShare > 100) {
89
+ warnings.push(`${text.feesLabel} ${feeShare.toFixed(1)}% ${text.feesOfGross}`);
90
+ }
91
+ if (metrics.marginExhausted === true) {
92
+ warnings.push(`${text.marginExhaustedWarning} ${text.marginExhaustedDetail}`);
93
+ }
94
+ if (metrics.annualizationReliable === false) {
95
+ warnings.push(`${text.annualized}: ${text.annualizedWithheld(MINIMUM_ANNUALIZATION_DAYS)}`);
96
+ }
97
+ if (warnings.length === 0)
98
+ return "";
99
+ return `<div class="warnings">${warnings.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>`;
100
+ }
101
+ /**
102
+ * Equity line plus a shaded drawdown band beneath it.
103
+ *
104
+ * Drawdown is the question a bare equity line answers badly: two runs ending at
105
+ * the same number can differ entirely in how much they gave back on the way.
106
+ */
107
+ function renderChart(points, drawdown, initialEquity, text) {
108
+ if (points.length < 2)
109
+ return `<p class="empty">${escapeHtml(text.notEnoughPoints)}</p>`;
110
+ const width = 1000;
111
+ const equityHeight = 260;
112
+ const drawdownHeight = 90;
113
+ const values = points.map((point) => point.equityUsdt);
114
+ const minimum = Math.min(...values);
115
+ const maximum = Math.max(...values);
116
+ const span = maximum - minimum || 1;
117
+ const worst = Math.max(...drawdown, 0.0001);
118
+ const x = (index) => (index / (points.length - 1)) * width;
119
+ const y = (value) => equityHeight - ((value - minimum) / span) * equityHeight;
120
+ const line = values.map((value, index) => `${index === 0 ? "M" : "L"}${x(index).toFixed(1)},${y(value).toFixed(1)}`).join("");
121
+ const area = `${line}L${width},${equityHeight}L0,${equityHeight}Z`;
122
+ const ddPath = drawdown
123
+ .map((value, index) => `${index === 0 ? "M" : "L"}${x(index).toFixed(1)},${((value / worst) * drawdownHeight).toFixed(1)}`)
124
+ .join("");
125
+ const ddArea = `${ddPath}L${width},0L0,0Z`;
126
+ const first = points[0];
127
+ const last = points.at(-1);
128
+ // The break-even line makes "is this above water" answerable at a glance,
129
+ // which a curve scaled to its own range cannot show.
130
+ const breakEven = initialEquity > minimum && initialEquity < maximum
131
+ ? `<line class="baseline" x1="0" y1="${y(initialEquity).toFixed(1)}" x2="${width}" y2="${y(initialEquity).toFixed(1)}"/>`
132
+ : "";
133
+ // Series data for the hover readout. Sent as three parallel arrays rather than
134
+ // an array of objects: on a 900-point curve that is roughly a third of the
135
+ // bytes, and the report is read as one file.
136
+ const series = {
137
+ t: points.map((point) => point.timeMs),
138
+ e: points.map((point) => Number(point.equityUsdt.toFixed(4))),
139
+ d: drawdown.map((value) => Number(value.toFixed(4))),
140
+ initial: initialEquity,
141
+ labels: {
142
+ time: text.hoverTime,
143
+ equity: text.hoverEquity,
144
+ change: text.hoverChange,
145
+ drawdown: text.hoverDrawdown
146
+ }
147
+ };
148
+ return `
149
+ <div class="chart" id="equity-chart" data-series='${escapeAttribute(JSON.stringify(series))}'>
150
+ <svg viewBox="0 -8 ${width} ${equityHeight + 16}" preserveAspectRatio="none" role="img" aria-label="equity curve">
151
+ <defs>
152
+ <linearGradient id="fill" x1="0" y1="0" x2="0" y2="1">
153
+ <stop offset="0%" stop-color="#4f9cf9" stop-opacity="0.28"/>
154
+ <stop offset="100%" stop-color="#4f9cf9" stop-opacity="0"/>
155
+ </linearGradient>
156
+ </defs>
157
+ <path d="${area}" fill="url(#fill)"/>
158
+ ${breakEven}
159
+ <path d="${line}" fill="none" stroke="#4f9cf9" stroke-width="1.6" vector-effect="non-scaling-stroke"/>
160
+ <line class="crosshair" x1="0" y1="-8" x2="0" y2="${equityHeight}" hidden/>
161
+ <circle class="marker" r="3.5" cx="0" cy="0" hidden/>
162
+ </svg>
163
+ <div class="axis"><span>${formatNumber(maximum)}</span><span>${formatNumber(minimum)}</span></div>
164
+ <div class="tooltip" hidden></div>
165
+ </div>
166
+ <div class="chart drawdown" id="drawdown-chart">
167
+ <svg viewBox="0 0 ${width} ${drawdownHeight}" preserveAspectRatio="none" role="img" aria-label="drawdown">
168
+ <path d="${ddArea}" fill="#f2777a" fill-opacity="0.22"/>
169
+ <path d="${ddPath}" fill="none" stroke="#f2777a" stroke-width="1.2" vector-effect="non-scaling-stroke"/>
170
+ <line class="crosshair" x1="0" y1="0" x2="0" y2="${drawdownHeight}" hidden/>
171
+ </svg>
172
+ <div class="axis"><span>0%</span><span>-${worst.toFixed(1)}%</span></div>
173
+ </div>
174
+ <p class="range mono">${escapeHtml(isoMinute(first.timeMs))} → ${escapeHtml(isoMinute(last.timeMs))} UTC · ${formatNumber(points.length)} · ${escapeHtml(text.hoverPrompt)}</p>`;
175
+ }
176
+ function renderMetricGroups(metrics, text) {
177
+ const groups = [
178
+ [
179
+ text.performance,
180
+ [
181
+ [text.netProfit, `${signed(num(metrics.netProfitUsdt), "")} USDT`],
182
+ [text.returnLabel, signed(num(metrics.returnPct), "%")],
183
+ [
184
+ text.annualized,
185
+ metrics.annualizedReturnPct === null || metrics.annualizedReturnPct === undefined
186
+ ? "-"
187
+ : signed(num(metrics.annualizedReturnPct), "%")
188
+ ],
189
+ [text.maxDrawdown, `${plain(num(metrics.maxDrawdownPct), "%")} (${plain(num(metrics.maxDrawdownUsdt), "")})`],
190
+ [text.drawdownRecovery, `${formatNumber(num(metrics.longestDrawdownBars) ?? 0)} bars`]
191
+ ]
192
+ ],
193
+ [
194
+ text.riskAdjusted,
195
+ [
196
+ [text.sharpeLabel, plain(num(metrics.sharpe), "")],
197
+ [text.sortinoLabel, plain(num(metrics.sortino), "")],
198
+ [text.calmarLabel, plain(num(metrics.calmar), "")]
199
+ ]
200
+ ],
201
+ [
202
+ text.activity,
203
+ [
204
+ [
205
+ text.tradesLabel,
206
+ `${formatNumber(num(metrics.tradeCount) ?? 0)} (${text.longShort(num(metrics.longTradeCount) ?? 0, num(metrics.shortTradeCount) ?? 0)})`
207
+ ],
208
+ [text.winRate, plain(num(metrics.winRatePct), "%")],
209
+ [text.profitFactor, plain(num(metrics.profitFactor), "")],
210
+ [text.expectancy, `${signed(num(metrics.expectancyUsdt), "")} USDT`],
211
+ [
212
+ text.averageWinLoss,
213
+ `${signed(num(metrics.averageWinUsdt), "")} / ${signed(num(metrics.averageLossUsdt), "")}`
214
+ ],
215
+ [
216
+ text.largestWinLoss,
217
+ `${signed(num(metrics.largestWinUsdt), "")} / ${signed(num(metrics.largestLossUsdt), "")}`
218
+ ],
219
+ [
220
+ text.streaks,
221
+ `${formatNumber(num(metrics.maxConsecutiveWins) ?? 0)}W / ${formatNumber(num(metrics.maxConsecutiveLosses) ?? 0)}L`
222
+ ],
223
+ [text.holdingTime, `${plain(num(metrics.averageHoldingBars), "")} bars`],
224
+ [text.tradesPerDay, plain(num(metrics.tradesPerDay), "")]
225
+ ]
226
+ ],
227
+ [
228
+ text.costs,
229
+ [
230
+ [text.feesLabel, `${plain(num(metrics.totalFeesUsdt), "")} USDT`],
231
+ [text.feesOfGross, plain(num(metrics.feesPctOfGross), "%")],
232
+ [text.turnover, `${plain(num(metrics.turnoverUsdt), "")} USDT`],
233
+ [text.exposure, plain(num(metrics.exposurePct), "%")]
234
+ ]
235
+ ]
236
+ ];
237
+ return groups
238
+ .map(([title, rows]) => `
239
+ <section>
240
+ <h2>${escapeHtml(title)}</h2>
241
+ <dl>${rows
242
+ .map(([name, value]) => `<div><dt>${escapeHtml(name)}</dt><dd>${escapeHtml(value)}</dd></div>`)
243
+ .join("")}</dl>
244
+ </section>`)
245
+ .join("");
246
+ }
247
+ function renderTrades(trades, text) {
248
+ if (trades.length === 0) {
249
+ return `<section><h2>${escapeHtml(text.tradesHeading)}</h2><p class="empty">${escapeHtml(text.noClosedTrades)}</p></section>`;
250
+ }
251
+ // Running equity per trade answers "when did this go wrong", which a list of
252
+ // individual results cannot.
253
+ let cumulative = 0;
254
+ const rows = trades
255
+ .map((trade, index) => {
256
+ const net = num(trade.netPnlUsdt ?? trade.realizedPnlUsdt);
257
+ cumulative += net ?? 0;
258
+ const opened = num(trade.openedAtMs) ?? 0;
259
+ const closed = num(trade.closedAtMs) ?? 0;
260
+ return `<tr class="${sign(net)}">
261
+ <td class="n dim">${formatNumber(index + 1)}</td>
262
+ <td>${escapeHtml(String(trade.side ?? "-"))}</td>
263
+ <td class="n">${plain(num(trade.quantity), "")}</td>
264
+ <td class="n">${plain(num(trade.entryPrice), "")}</td>
265
+ <td class="n">${plain(num(trade.exitPrice), "")}</td>
266
+ <td class="n">${signed(net, "")}</td>
267
+ <td class="n">${signed(num(trade.returnPct), "%")}</td>
268
+ <td class="n dim">${signed(cumulative, "")}</td>
269
+ <td class="n dim">${plain(num(trade.feesUsdt), "")}</td>
270
+ <td class="n">${escapeHtml(formatHolding(num(trade.holdingBars) ?? 0))}</td>
271
+ <td class="mono">${escapeHtml(isoMinute(opened))}</td>
272
+ <td class="mono">${escapeHtml(closed > 0 ? isoMinute(closed) : "-")}</td>
273
+ </tr>`;
274
+ })
275
+ .join("");
276
+ return `
277
+ <section>
278
+ <h2>${escapeHtml(text.tradesHeading)} <span class="count">${formatNumber(trades.length)}</span></h2>
279
+ <div class="scroll tall">
280
+ <table>
281
+ <thead><tr>
282
+ <th class="n">#</th><th>${escapeHtml(text.colSide)}</th><th class="n">${escapeHtml(text.colQty)}</th>
283
+ <th class="n">${escapeHtml(text.colEntry)}</th><th class="n">${escapeHtml(text.colExit)}</th>
284
+ <th class="n">${escapeHtml(text.colNetPnl)}</th><th class="n">${escapeHtml(text.colReturn)}</th>
285
+ <th class="n">${escapeHtml(text.colCumulative)}</th><th class="n">${escapeHtml(text.colFees)}</th>
286
+ <th class="n">${escapeHtml(text.colHeld)}</th>
287
+ <th>${escapeHtml(text.colOpened)}</th><th>${escapeHtml(text.colClosed)}</th>
288
+ </tr></thead>
289
+ <tbody>${rows}</tbody>
290
+ </table>
291
+ </div>
292
+ </section>`;
293
+ }
294
+ /**
295
+ * The assumptions the run used.
296
+ *
297
+ * A return figure means nothing without the leverage, budget, and costs behind
298
+ * it, and two runs are only comparable when these match.
299
+ */
300
+ function renderAssumptions(run, text) {
301
+ const assumptions = run.assumptions;
302
+ if (!assumptions)
303
+ return "";
304
+ const contract = (assumptions.contract ?? {});
305
+ const fixedBudget = num(assumptions.entryBudgetUsdt);
306
+ const rows = [
307
+ [text.settingEquity, `${plain(num(assumptions.initialEquityUsdt), "")} USDT`],
308
+ [text.settingLeverage, `${plain(num(assumptions.leverage), "")}x`],
309
+ [text.settingMarginSafety, `${plain(num(assumptions.marginSafetyMultiplier), "")}x`],
310
+ [
311
+ text.settingBudgetPct,
312
+ fixedBudget === null
313
+ ? asPercent(num(assumptions.entryBudgetPct))
314
+ : `${plain(fixedBudget, "")} USDT`
315
+ ],
316
+ [text.settingTakerFee, asBps(num(assumptions.takerFeeRate))],
317
+ [text.settingMakerFee, asBps(num(assumptions.makerFeeRate))],
318
+ [text.settingEntrySlippage, `${plain(num(assumptions.entrySlippageBps), "")} bps`],
319
+ [text.settingExitSlippage, `${plain(num(assumptions.exitSlippageBps), "")} bps`],
320
+ [text.settingPreload, `${formatNumber(num(assumptions.preloadBars) ?? 0)}`],
321
+ [text.settingCloseAtEnd, assumptions.closeAtEnd === false ? text.no : text.yes],
322
+ [
323
+ "contract",
324
+ `${plain(num(contract.contractValue), "")} ${String(contract.contractValueCurrency ?? "")} x${plain(num(contract.contractMultiplier), "")}`
325
+ ],
326
+ ["lot / min", `${plain(num(contract.lotSize), "")} / ${plain(num(contract.minSize), "")}`]
327
+ ];
328
+ const params = assumptions.params;
329
+ const paramRow = params && Object.keys(params).length
330
+ ? `<div><dt>params</dt><dd class="mono">${escapeHtml(Object.entries(params).map(([key, value]) => `${key}=${String(value)}`).join(" "))}</dd></div>`
331
+ : "";
332
+ const intervals = Array.isArray(assumptions.intervals) ? assumptions.intervals : [];
333
+ const intervalRow = intervals.length
334
+ ? `<div><dt>timeframes</dt><dd class="mono">${escapeHtml(intervals.join(", "))}</dd></div>`
335
+ : "";
336
+ return `
337
+ <section>
338
+ <h2>${escapeHtml(text.assumptionsHeading)}</h2>
339
+ <dl>${rows
340
+ .map(([name, value]) => `<div><dt>${escapeHtml(name)}</dt><dd>${escapeHtml(value)}</dd></div>`)
341
+ .join("")}${intervalRow}${paramRow}</dl>
342
+ </section>`;
343
+ }
344
+ function asBps(value) {
345
+ return value === null ? "-" : `${(value * 10_000).toFixed(2)} bps`;
346
+ }
347
+ function asPercent(value) {
348
+ return value === null ? "-" : `${(value * 100).toFixed(2)}%`;
349
+ }
350
+ /**
351
+ * Monthly result table.
352
+ *
353
+ * A single total hides whether an edge was steady or came from one month, which
354
+ * is the difference between a strategy and a lucky window.
355
+ */
356
+ function renderMonthly(equity, text) {
357
+ if (equity.length < 2)
358
+ return "";
359
+ const months = new Map();
360
+ for (const point of equity) {
361
+ const key = new Date(point.timeMs).toISOString().slice(0, 7);
362
+ const found = months.get(key);
363
+ if (!found) {
364
+ months.set(key, { start: point.equityUsdt, end: point.equityUsdt, low: point.equityUsdt, high: point.equityUsdt });
365
+ continue;
366
+ }
367
+ found.end = point.equityUsdt;
368
+ found.low = Math.min(found.low, point.equityUsdt);
369
+ found.high = Math.max(found.high, point.equityUsdt);
370
+ }
371
+ if (months.size < 2)
372
+ return "";
373
+ const rows = [...months.entries()]
374
+ .map(([month, values]) => {
375
+ const change = values.start > 0 ? (values.end / values.start - 1) * 100 : 0;
376
+ const depth = values.high > 0 ? ((values.high - values.low) / values.high) * 100 : 0;
377
+ return `<tr class="${sign(change)}">
378
+ <td class="mono">${escapeHtml(month)}</td>
379
+ <td class="n">${plain(values.start, "")}</td>
380
+ <td class="n">${plain(values.end, "")}</td>
381
+ <td class="n">${signed(change, "%")}</td>
382
+ <td class="n dim">${plain(depth, "%")}</td>
383
+ </tr>`;
384
+ })
385
+ .join("");
386
+ return `
387
+ <section>
388
+ <h2>${escapeHtml(text.monthlyHeading)}</h2>
389
+ <div class="scroll">
390
+ <table>
391
+ <thead><tr><th>${escapeHtml(text.colMonth)}</th><th class="n">${escapeHtml(text.colOpenEquity)}</th><th class="n">${escapeHtml(text.colCloseEquity)}</th><th class="n">${escapeHtml(text.colChange)}</th><th class="n">${escapeHtml(text.colWorstDrop)}</th></tr></thead>
392
+ <tbody>${rows}</tbody>
393
+ </table>
394
+ </div>
395
+ </section>`;
396
+ }
397
+ /**
398
+ * Histogram of per-trade results.
399
+ *
400
+ * Shows whether an average came from many similar trades or a few outliers — the
401
+ * same mean can describe either.
402
+ */
403
+ function renderDistribution(trades, text) {
404
+ const values = trades
405
+ .map((trade) => num(trade.netPnlUsdt ?? trade.realizedPnlUsdt))
406
+ .filter((value) => value !== null);
407
+ if (values.length < 5)
408
+ return "";
409
+ const minimum = Math.min(...values);
410
+ const maximum = Math.max(...values);
411
+ if (maximum === minimum)
412
+ return "";
413
+ const buckets = 11;
414
+ const width = (maximum - minimum) / buckets;
415
+ const counts = new Array(buckets).fill(0);
416
+ for (const value of values) {
417
+ const index = Math.min(buckets - 1, Math.floor((value - minimum) / width));
418
+ counts[index] += 1;
419
+ }
420
+ const peak = Math.max(...counts);
421
+ const bars = counts
422
+ .map((count, index) => {
423
+ const from = minimum + index * width;
424
+ const to = from + width;
425
+ const share = peak > 0 ? (count / peak) * 100 : 0;
426
+ const losing = to <= 0;
427
+ return `<div class="bin">
428
+ <span class="range mono">${escapeHtml(compact(from))}…${escapeHtml(compact(to))}</span>
429
+ <span class="track"><span class="fill ${losing ? "down" : "up"}" style="width:${share.toFixed(1)}%"></span></span>
430
+ <span class="count">${formatNumber(count)}</span>
431
+ </div>`;
432
+ })
433
+ .join("");
434
+ return `
435
+ <section>
436
+ <h2>${escapeHtml(text.distributionHeading)}</h2>
437
+ <div class="histogram">${bars}</div>
438
+ </section>`;
439
+ }
440
+ /** Bars as a duration, since "4210 bars" is harder to judge than "2d 22h". */
441
+ function formatHolding(bars) {
442
+ if (bars < 60)
443
+ return `${Math.round(bars)}m`;
444
+ if (bars < 1_440)
445
+ return `${Math.floor(bars / 60)}h${String(Math.round(bars % 60)).padStart(2, "0")}m`;
446
+ const days = Math.floor(bars / 1_440);
447
+ const hours = Math.floor((bars % 1_440) / 60);
448
+ return `${days}d${String(hours).padStart(2, "0")}h`;
449
+ }
450
+ /**
451
+ * Reduces a curve for plotting while keeping its shape.
452
+ *
453
+ * Each bucket contributes both its highest and lowest point, in the order they
454
+ * occurred. Picking one point per bucket — even alternating high and low — drops
455
+ * a spike whenever it lands in a bucket that happens to be looking for the other
456
+ * extreme, which erases exactly the moves a reader is scanning for.
457
+ */
458
+ export function downsample(points, target) {
459
+ if (points.length <= target)
460
+ return points;
461
+ const buckets = Math.max(1, Math.floor(target / 2));
462
+ const size = points.length / buckets;
463
+ const output = [];
464
+ for (let index = 0; index < buckets; index += 1) {
465
+ const start = Math.floor(index * size);
466
+ const end = Math.min(points.length, Math.floor((index + 1) * size));
467
+ if (end <= start)
468
+ continue;
469
+ let lowest = points[start];
470
+ let highest = points[start];
471
+ for (let inner = start; inner < end; inner += 1) {
472
+ const candidate = points[inner];
473
+ if (candidate.equityUsdt < lowest.equityUsdt)
474
+ lowest = candidate;
475
+ if (candidate.equityUsdt > highest.equityUsdt)
476
+ highest = candidate;
477
+ }
478
+ // Preserve chronology so the line does not zig-zag artificially.
479
+ const [first, second] = lowest.timeMs <= highest.timeMs ? [lowest, highest] : [highest, lowest];
480
+ output.push(first);
481
+ if (second !== first)
482
+ output.push(second);
483
+ }
484
+ return output;
485
+ }
486
+ /** Percentage below the running peak at each point. */
487
+ export function drawdownSeries(points, initialEquity) {
488
+ let peak = initialEquity;
489
+ return points.map((point) => {
490
+ peak = Math.max(peak, point.equityUsdt);
491
+ return peak > 0 ? ((peak - point.equityUsdt) / peak) * 100 : 0;
492
+ });
493
+ }
494
+ /** The document's `lang` attribute, so a browser picks the right font stack. */
495
+ function activeLang() {
496
+ return activeLocale();
497
+ }
498
+ function num(value) {
499
+ if (value === null || value === undefined)
500
+ return null;
501
+ const parsed = Number(value);
502
+ return Number.isFinite(parsed) ? parsed : null;
503
+ }
504
+ function sign(value) {
505
+ if (value === null || value === 0)
506
+ return "flat";
507
+ return value > 0 ? "up" : "down";
508
+ }
509
+ function signed(value, suffix) {
510
+ if (value === null)
511
+ return "-";
512
+ return `${value > 0 ? "+" : ""}${compact(value)}${suffix}`;
513
+ }
514
+ function plain(value, suffix) {
515
+ if (value === null)
516
+ return "-";
517
+ return `${compact(value)}${suffix}`;
518
+ }
519
+ function compact(value) {
520
+ const magnitude = Math.abs(value);
521
+ if (magnitude >= 1_000_000)
522
+ return `${(value / 1_000_000).toFixed(2)}M`;
523
+ if (magnitude >= 1_000)
524
+ return value.toFixed(0);
525
+ return value.toFixed(2);
526
+ }
527
+ function formatNumber(value) {
528
+ return Math.round(value).toLocaleString("en-US");
529
+ }
530
+ function isoMinute(ms) {
531
+ return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
532
+ }
533
+ /**
534
+ * Escapes text for HTML.
535
+ *
536
+ * Strategy names and error messages come from user input, so anything
537
+ * interpolated into the document is escaped rather than trusted.
538
+ */
539
+ export function escapeHtml(value) {
540
+ return value
541
+ .replace(/&/g, "&amp;")
542
+ .replace(/</g, "&lt;")
543
+ .replace(/>/g, "&gt;")
544
+ .replace(/"/g, "&quot;")
545
+ .replace(/'/g, "&#39;");
546
+ }
547
+ /**
548
+ * Escapes a value for a single-quoted HTML attribute.
549
+ *
550
+ * The series JSON is carried in an attribute rather than a script literal, so a
551
+ * quote or an angle bracket inside it cannot terminate the attribute or open a
552
+ * tag. Kept separate from `escapeHtml` because that one escapes double quotes
553
+ * and would mangle the JSON's own syntax.
554
+ */
555
+ export function escapeAttribute(value) {
556
+ return value.replace(/&/g, "&amp;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
557
+ }
558
+ const STYLES = `
559
+ :root{--bg:#0f1116;--panel:#171a21;--line:#242833;--text:#e6e8ee;--dim:#8b90a0;--up:#3fb950;--down:#f2777a;--accent:#4f9cf9}
560
+ @media(prefers-color-scheme:light){:root{--bg:#f7f8fa;--panel:#fff;--line:#e3e6ec;--text:#1a1d24;--dim:#6b7280;--up:#1a7f37;--down:#c9372c}}
561
+ *{box-sizing:border-box}
562
+ body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif}
563
+ main{max-width:1080px;margin:0 auto;padding:32px 24px 64px}
564
+ header{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;flex-wrap:wrap;padding-bottom:20px;border-bottom:1px solid var(--line)}
565
+ h1{margin:0;font-size:26px;font-weight:600;letter-spacing:-.01em}
566
+ .sub{margin:4px 0 0;color:var(--dim);font-size:13px}
567
+ .headline{text-align:right}
568
+ .headline .value{display:block;font-size:34px;font-weight:650;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
569
+ .headline .label{color:var(--dim);font-size:12px;text-transform:uppercase;letter-spacing:.08em}
570
+ .up{color:var(--up)}.down{color:var(--down)}.flat{color:var(--dim)}
571
+ .facts{display:flex;gap:28px;flex-wrap:wrap;margin:20px 0 8px;color:var(--dim);font-size:13px}
572
+ .facts span{display:block;text-transform:uppercase;letter-spacing:.06em;font-size:11px}
573
+ .facts strong{color:var(--text);font-weight:550;font-variant-numeric:tabular-nums}
574
+ .warnings{margin:20px 0 0;padding:12px 16px;border-left:3px solid var(--down);background:color-mix(in srgb,var(--down) 8%,transparent);border-radius:0 6px 6px 0}
575
+ .warnings p{margin:4px 0;font-size:13px}
576
+ section{margin-top:32px}
577
+ h2{margin:0 0 14px;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.1em;color:var(--dim)}
578
+ h2 .count{color:var(--dim);font-weight:400}
579
+ dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1px;margin:0;background:var(--line);border:1px solid var(--line);border-radius:8px;overflow:hidden}
580
+ dl>div{background:var(--panel);padding:12px 14px}
581
+ dt{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}
582
+ dd{margin:3px 0 0;font-size:16px;font-weight:550;font-variant-numeric:tabular-nums}
583
+ .chart{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 12px;display:flex;gap:10px;align-items:stretch}
584
+ .chart.drawdown{margin-top:8px;border-radius:8px}
585
+ .chart svg{flex:1;height:240px;width:100%;display:block}
586
+ .chart.drawdown svg{height:80px}
587
+ .axis{display:flex;flex-direction:column;justify-content:space-between;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;min-width:62px;text-align:right}
588
+ .range{margin:8px 0 0;color:var(--dim);font-size:12px}
589
+ .chart{position:relative}
590
+ .chart svg{cursor:crosshair}
591
+ .chart:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
592
+ .baseline{stroke:var(--dim);stroke-width:1;stroke-dasharray:3 4;opacity:.5;vector-effect:non-scaling-stroke}
593
+ .crosshair{stroke:var(--dim);stroke-width:1;vector-effect:non-scaling-stroke;pointer-events:none}
594
+ .marker{fill:var(--accent);stroke:var(--panel);stroke-width:1.5;pointer-events:none}
595
+ .tooltip{position:absolute;top:10px;pointer-events:none;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:8px 10px;font-size:12px;min-width:180px;box-shadow:0 4px 16px rgba(0,0,0,.28);z-index:2}
596
+ .tooltip .row{display:flex;justify-content:space-between;gap:14px;padding:1px 0}
597
+ .tooltip .row span{color:var(--dim)}
598
+ .tooltip .row b{font-weight:550;font-variant-numeric:tabular-nums}
599
+ .scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px}
600
+ table{width:100%;border-collapse:collapse;font-size:13px}
601
+ th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
602
+ th{background:var(--panel);color:var(--dim);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;position:sticky;top:0}
603
+ tbody tr:last-child td{border-bottom:none}
604
+ td.n{text-align:right;font-variant-numeric:tabular-nums}
605
+ tr.up td:nth-child(5),tr.up td:nth-child(6){color:var(--up)}
606
+ tr.down td:nth-child(5),tr.down td:nth-child(6){color:var(--down)}
607
+ .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
608
+ td.dim{color:var(--dim)}
609
+ .scroll.tall{max-height:520px;overflow-y:auto}
610
+ .histogram{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px}
611
+ .bin{display:flex;align-items:center;gap:10px;padding:3px 0}
612
+ .bin .range{min-width:132px;color:var(--dim);text-align:right;flex-shrink:0}
613
+ .bin .track{flex:1;height:12px;background:color-mix(in srgb,var(--line) 60%,transparent);border-radius:3px;overflow:hidden}
614
+ .bin .fill{display:block;height:100%;border-radius:3px}
615
+ .bin .fill.up{background:var(--up)}
616
+ .bin .fill.down{background:var(--down)}
617
+ .bin>.count{min-width:44px;text-align:right;color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;flex-shrink:0}
618
+ .empty{color:var(--dim);font-size:13px}
619
+ footer{margin-top:40px;padding-top:16px;border-top:1px solid var(--line);color:var(--dim);font-size:12px}
620
+ footer p{margin:4px 0}
621
+ `;
622
+ /**
623
+ * Styles for the comparison document.
624
+ *
625
+ * The single-run sheet plus the few rules a matrix table needs, rather than a
626
+ * second copy: the two documents should not drift apart visually, and a duplicated
627
+ * sheet is exactly how that happens.
628
+ */
629
+ export const COMPARE_STYLES = `${STYLES}
630
+ .legend{display:flex;gap:20px;flex-wrap:wrap;margin:20px 0 0;font-size:13px}
631
+ .legend div{display:flex;align-items:center;gap:7px}
632
+ .legend i,th i{display:inline-block;width:10px;height:10px;border-radius:2px;flex-shrink:0}
633
+ th i{margin-right:6px;vertical-align:middle}
634
+ table.matrix th[scope=row]{background:var(--panel);color:var(--dim);text-transform:none;letter-spacing:0;font-size:12px;font-weight:500;text-align:left;position:sticky;left:0}
635
+ table.matrix thead th{text-align:right}
636
+ table.matrix thead th span.dim{display:block;font-weight:400;text-transform:none;letter-spacing:0;font-size:10px}
637
+ td.best{color:var(--up);font-weight:600}
638
+ .chart .tooltip .swatch{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}
639
+ .chart .tooltip .row.head{border-bottom:1px solid var(--line);margin-bottom:3px;padding-bottom:3px}
640
+ `;
641
+ //# sourceMappingURL=html.js.map