@stocksharp/trading-controls 1.2.0 → 1.4.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 (99) hide show
  1. package/README.md +204 -17
  2. package/dist/esm/black-scholes.js +147 -0
  3. package/dist/esm/black-scholes.js.map +1 -0
  4. package/dist/esm/chart-engine.js +15 -0
  5. package/dist/esm/chart-engine.js.map +1 -0
  6. package/dist/esm/control-types.js +8 -0
  7. package/dist/esm/control-types.js.map +1 -1
  8. package/dist/esm/equity-widget.js +261 -0
  9. package/dist/esm/equity-widget.js.map +1 -0
  10. package/dist/esm/heatmap-grid.js +271 -0
  11. package/dist/esm/heatmap-grid.js.map +1 -0
  12. package/dist/esm/index.js +37 -0
  13. package/dist/esm/index.js.map +1 -1
  14. package/dist/esm/log-monitor-widget.js +265 -0
  15. package/dist/esm/log-monitor-widget.js.map +1 -0
  16. package/dist/esm/log-tree.js +96 -0
  17. package/dist/esm/log-tree.js.map +1 -0
  18. package/dist/esm/optimization-heatmap-widget.js +303 -0
  19. package/dist/esm/optimization-heatmap-widget.js.map +1 -0
  20. package/dist/esm/option-desk-widget.js +322 -0
  21. package/dist/esm/option-desk-widget.js.map +1 -0
  22. package/dist/esm/option-smile-widget.js +252 -0
  23. package/dist/esm/option-smile-widget.js.map +1 -0
  24. package/dist/esm/pnl-curve.js +133 -0
  25. package/dist/esm/pnl-curve.js.map +1 -0
  26. package/dist/esm/statistics-widget.js +194 -0
  27. package/dist/esm/statistics-widget.js.map +1 -0
  28. package/dist/esm/strategies-widget.js +348 -0
  29. package/dist/esm/strategies-widget.js.map +1 -0
  30. package/dist/esm/surface-grid.js +282 -0
  31. package/dist/esm/surface-grid.js.map +1 -0
  32. package/dist/esm/surface-widget.js +379 -0
  33. package/dist/esm/surface-widget.js.map +1 -0
  34. package/dist/sstradingcontrols.js +2982 -295
  35. package/dist/sstradingcontrols.js.map +4 -4
  36. package/dist/types/black-scholes.d.ts +28 -0
  37. package/dist/types/black-scholes.d.ts.map +1 -0
  38. package/dist/types/chart-engine.d.ts +3 -0
  39. package/dist/types/chart-engine.d.ts.map +1 -0
  40. package/dist/types/control-types.d.ts +8 -0
  41. package/dist/types/control-types.d.ts.map +1 -1
  42. package/dist/types/equity-widget.d.ts +34 -0
  43. package/dist/types/equity-widget.d.ts.map +1 -0
  44. package/dist/types/heatmap-grid.d.ts +88 -0
  45. package/dist/types/heatmap-grid.d.ts.map +1 -0
  46. package/dist/types/index.d.ts +31 -1
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/dist/types/log-monitor-widget.d.ts +42 -0
  49. package/dist/types/log-monitor-widget.d.ts.map +1 -0
  50. package/dist/types/log-tree.d.ts +34 -0
  51. package/dist/types/log-tree.d.ts.map +1 -0
  52. package/dist/types/optimization-heatmap-widget.d.ts +45 -0
  53. package/dist/types/optimization-heatmap-widget.d.ts.map +1 -0
  54. package/dist/types/option-desk-widget.d.ts +68 -0
  55. package/dist/types/option-desk-widget.d.ts.map +1 -0
  56. package/dist/types/option-smile-widget.d.ts +39 -0
  57. package/dist/types/option-smile-widget.d.ts.map +1 -0
  58. package/dist/types/pnl-curve.d.ts +46 -0
  59. package/dist/types/pnl-curve.d.ts.map +1 -0
  60. package/dist/types/statistics-widget.d.ts +29 -0
  61. package/dist/types/statistics-widget.d.ts.map +1 -0
  62. package/dist/types/strategies-widget.d.ts +63 -0
  63. package/dist/types/strategies-widget.d.ts.map +1 -0
  64. package/dist/types/surface-grid.d.ts +64 -0
  65. package/dist/types/surface-grid.d.ts.map +1 -0
  66. package/dist/types/surface-widget.d.ts +64 -0
  67. package/dist/types/surface-widget.d.ts.map +1 -0
  68. package/dist/types/trading-data.d.ts +9 -0
  69. package/dist/types/trading-data.d.ts.map +1 -1
  70. package/package.json +54 -3
  71. package/screenshots/equity.png +0 -0
  72. package/screenshots/log-monitor.png +0 -0
  73. package/screenshots/optimization-surface.png +0 -0
  74. package/screenshots/optimization.png +0 -0
  75. package/screenshots/option-desk.png +0 -0
  76. package/screenshots/option-smile.png +0 -0
  77. package/screenshots/panels.jpg +0 -0
  78. package/screenshots/statistics.png +0 -0
  79. package/screenshots/strategies.png +0 -0
  80. package/src/black-scholes.ts +199 -0
  81. package/src/chart-engine.global.ts +64 -0
  82. package/src/chart-engine.ts +29 -0
  83. package/src/control-types.ts +8 -0
  84. package/src/equity-widget.ts +289 -0
  85. package/src/heatmap-grid.ts +405 -0
  86. package/src/index.ts +87 -0
  87. package/src/log-monitor-widget.ts +312 -0
  88. package/src/log-tree.ts +131 -0
  89. package/src/optimization-heatmap-widget.ts +347 -0
  90. package/src/option-desk-widget.ts +422 -0
  91. package/src/option-smile-widget.ts +281 -0
  92. package/src/pnl-curve.ts +216 -0
  93. package/src/statistics-widget.ts +226 -0
  94. package/src/strategies-widget.ts +435 -0
  95. package/src/surface-grid.ts +410 -0
  96. package/src/surface-widget.ts +457 -0
  97. package/src/trading-data.ts +22 -0
  98. package/styles/trading-controls.css +637 -0
  99. package/translation-keys.json +83 -1
@@ -0,0 +1,457 @@
1
+ // Optimisation surface - multi-instance.
2
+ //
3
+ // The heatmap's measurements as a landscape: one metric over two discrete axes, with the metric
4
+ // as height as well as colour. It takes the same input the flat map does, so a consumer shows
5
+ // either, or both, from one set of results.
6
+ //
7
+ // It is turned, tipped and zoomed by hand, and all three gestures arrive through pointer events
8
+ // rather than through mouse and touch handlers written twice. A pointer is a pointer: one down
9
+ // and a drag turns the surface, two down and the distance between them zooms it, a wheel zooms
10
+ // it as well. Nothing here asks which kind of device it is on.
11
+ //
12
+ // No library under this. The projection and the ordering are in `surface-grid.ts` as arithmetic;
13
+ // this file owns the canvas, the gestures and the colours the host supplies.
14
+ import { formatPnl } from './formatters.js';
15
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
16
+ import { ControlTypes } from './control-types.js';
17
+ import { TradingHost, assertHost } from './trading-host.js';
18
+ import { valueAt, type HeatCell, type HeatDirection } from './heatmap-grid.js';
19
+ import {
20
+ DEFAULT_VIEW, dragView, nearestVertex, surfaceLayout, zoomView,
21
+ type SurfaceLayout, type SurfaceVertex, type SurfaceView,
22
+ } from './surface-grid.js';
23
+
24
+ /// What the surface is given. The heatmap's own input, so one set of results feeds both.
25
+ export interface SurfaceData {
26
+ xLabel: string;
27
+ yLabel: string;
28
+ metricLabel: string;
29
+ /// Which way is better. Required for the same reason the flat map requires it: without it a
30
+ /// landscape of drawdowns would raise its worst corner into the peak.
31
+ betterWhen: HeatDirection;
32
+ cells: HeatCell[];
33
+ }
34
+
35
+ /// The panel needs nothing beyond the host port. A face is a cell rather than a run, so there is
36
+ /// nothing here for a click to open - a click turns the surface instead.
37
+ export interface SurfaceDeps {
38
+ host: TradingHost;
39
+ }
40
+
41
+ // Opacity and width, not colour. Every hue is the host's `canvasPalette`; how solid and how thick
42
+ // a thing is drawn is this control's own business.
43
+ const FACE_ALPHA = 0.85;
44
+ const EDGE_ALPHA = 0.35;
45
+ const FLOOR_ALPHA = 0.5;
46
+ const EDGE_WIDTH = 1;
47
+ const LABEL_ALPHA = 0.8;
48
+ // How much one notch of a wheel zooms. A mouse reports a notch as about 100 units and a trackpad
49
+ // reports a stroke as a stream of small ones, so a step taken per EVENT rather than per unit makes
50
+ // the mouse jump the whole range in a few notches while the trackpad crawls. The delta is
51
+ // normalised to notches first, and clamped: a browser that reports a page-sized delta must not
52
+ // zoom a chart by a page.
53
+ const WHEEL_STEP = 1.12;
54
+ const WHEEL_UNITS_PER_NOTCH = 100;
55
+ const WHEEL_MAX_NOTCHES = 2;
56
+
57
+ /// Wheel delta as notches, whatever units the browser chose to report it in.
58
+ export function wheelNotches(deltaY: number, deltaMode: number): number {
59
+ if (!Number.isFinite(deltaY) || deltaY === 0) return 0;
60
+
61
+ // 0 = pixels, 1 = lines, 2 = pages. Firefox reports lines for a mouse and pixels for a
62
+ // trackpad, and the same number means very different things between the two.
63
+ const perNotch = deltaMode === 1 ? 3 : deltaMode === 2 ? 1 : WHEEL_UNITS_PER_NOTCH;
64
+ const notches = deltaY / perNotch;
65
+ return Math.max(-WHEEL_MAX_NOTCHES, Math.min(WHEEL_MAX_NOTCHES, notches));
66
+ }
67
+ /// How long a tick stroke is, and how far its label clears it. In CSS pixels.
68
+ const TICK_LENGTH = 5;
69
+ const TICK_GAP = 4;
70
+
71
+ /// How far an axis caption sits outside the edge it names, past its marks.
72
+ const CAPTION_OFFSET = 34;
73
+
74
+ /// How near a pointer has to come to a measured point before the panel answers for it. Generous,
75
+ /// because a vertex is a point and a pointer is a hand.
76
+ const HOVER_REACH = 22;
77
+
78
+ /// The ring drawn on the point being read.
79
+ const HOVER_RING = 5;
80
+
81
+ export class SurfaceWidget {
82
+ static TYPE = ControlTypes.OptimizationSurface;
83
+
84
+ rootEl: HTMLElement;
85
+ canvasEl: HTMLCanvasElement | null;
86
+ // `//` rather than `///` from here down — see the note in positions-widget.
87
+ _host: TradingHost;
88
+ _closeBtn: HTMLElement | null;
89
+ _resetBtn: HTMLElement | null;
90
+ _emptyEl: HTMLElement | null;
91
+ _ctx: CanvasRenderingContext2D | null;
92
+ _data: SurfaceData | null;
93
+ _view: SurfaceView;
94
+ _resizeObserver: ResizeObserver | null;
95
+ // Every pointer currently down on the canvas, by id. One turns, two zoom.
96
+ _pointers: Map<number, { x: number; y: number }>;
97
+ _pinch: number;
98
+ // The last layout, so a pointer can be answered without projecting the grid again.
99
+ _layout: SurfaceLayout | null;
100
+ _hover: SurfaceVertex | null;
101
+ _hoverEl: HTMLElement | null;
102
+
103
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: SurfaceDeps): SurfaceWidget {
104
+ const host = assertHost(deps?.host, 'SurfaceWidget');
105
+ const root = SurfaceWidget._buildRoot(host);
106
+ root.id = makePanelId(SurfaceWidget.TYPE);
107
+ hostEl.appendChild(root);
108
+ return new SurfaceWidget(root, state || {}, deps);
109
+ }
110
+
111
+ static _buildRoot(host: TradingHost): HTMLElement {
112
+ const title = host.t('OptimizationSurface');
113
+ return makePanelRoot('surface-panel', title, [
114
+ makeElement('div', 'panel-header', {}, [
115
+ makeElement('span', '', {}, [title]),
116
+ makeElement('span', 'surface-metric', {}, []),
117
+ makeIconButton('bt-icon-btn surface-reset-btn', host.t('ResetView'), 'bi-arrow-clockwise', { type: 'button' }),
118
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
119
+ ]),
120
+ makeElement('div', 'panel-body', {}, [
121
+ makeElement('div', 'surface-chart', {}, [
122
+ makeElement('canvas', 'surface-canvas', { role: 'img', 'aria-label': host.t('OptimizationSurfaceChart') }, []),
123
+ // Over the canvas rather than in the panel header: a reading about the point
124
+ // under the pointer belongs beside that point. A host that lifts its panel
125
+ // headers into a tab strip would otherwise put the reading in a tab title.
126
+ makeElement('div', 'surface-readout', {}, []),
127
+ makeElement('div', 'surface-empty', { hidden: '' }, [host.t('NoOptimizationResults')]),
128
+ ]),
129
+ ]),
130
+ ]);
131
+ }
132
+
133
+ constructor(rootEl: HTMLElement, _state: Record<string, unknown>, deps: SurfaceDeps) {
134
+ this._host = assertHost(deps?.host, 'SurfaceWidget');
135
+
136
+ this.rootEl = rootEl;
137
+ this.canvasEl = this.rootEl.querySelector('.surface-canvas');
138
+ this._emptyEl = this.rootEl.querySelector('.surface-empty');
139
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
140
+ this._resetBtn = this.rootEl.querySelector('.surface-reset-btn');
141
+ this._ctx = this.canvasEl?.getContext('2d') ?? null;
142
+ this._data = null;
143
+ this._layout = null;
144
+ this._hover = null;
145
+ this._hoverEl = this.rootEl.querySelector('.surface-readout');
146
+ this._view = DEFAULT_VIEW;
147
+ this._resizeObserver = null;
148
+ this._pointers = new Map();
149
+ this._pinch = 0;
150
+
151
+ this._closeBtn?.addEventListener('click', (e) => { e.preventDefault(); this._host.close(); });
152
+ this._resetBtn?.addEventListener('click', (e) => {
153
+ e.preventDefault();
154
+ this._view = DEFAULT_VIEW;
155
+ this._render();
156
+ });
157
+
158
+ this._bindGestures();
159
+
160
+ // The canvas has no size until the panel is laid out, and a docked panel is resized by
161
+ // the user afterwards; both are the same event as far as this is concerned.
162
+ if (this.canvasEl !== null && typeof ResizeObserver !== 'undefined') {
163
+ this._resizeObserver = new ResizeObserver(() => this._render());
164
+ this._resizeObserver.observe(this.canvasEl);
165
+ }
166
+
167
+ this._host.register(this);
168
+ }
169
+
170
+ dispose(): void {
171
+ this._resizeObserver?.disconnect();
172
+ this._resizeObserver = null;
173
+ this._host.unregister(this);
174
+ try { this.rootEl.remove(); } catch { /* already detached */ }
175
+ }
176
+
177
+ /// Show these results. The whole set at once: a surface is one sweep, and half of one drawn
178
+ /// over the other half would be a landscape of two different runs.
179
+ update(data: SurfaceData): void {
180
+ this._data = data;
181
+ this._render();
182
+ }
183
+
184
+ /// The view the surface is currently seen from, so a host can persist it.
185
+ view(): SurfaceView {
186
+ return { ...this._view };
187
+ }
188
+
189
+ /// Put the surface back where it started.
190
+ resetView(): void {
191
+ this._view = DEFAULT_VIEW;
192
+ this._render();
193
+ }
194
+
195
+ // One pointer turns, two zoom, a wheel zooms. Pointer events cover mouse, pen and touch, so
196
+ // there is one implementation rather than one per input device.
197
+ _bindGestures(): void {
198
+ const canvas = this.canvasEl;
199
+ if (canvas === null) return;
200
+
201
+ canvas.addEventListener('pointerdown', (event: PointerEvent) => {
202
+ // The canvas claims the gesture: without this a drag on a phone scrolls the page
203
+ // under the panel instead of turning what the finger is on.
204
+ event.preventDefault();
205
+ try { canvas.setPointerCapture(event.pointerId); } catch { /* not captured, still works */ }
206
+ this._pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
207
+ this._pinch = this._pointerSpread();
208
+ });
209
+
210
+ canvas.addEventListener('pointermove', (event: PointerEvent) => {
211
+ const previous = this._pointers.get(event.pointerId);
212
+ if (previous === undefined) return;
213
+ event.preventDefault();
214
+ this._pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
215
+
216
+ if (this._pointers.size >= 2) {
217
+ // Two fingers: the distance between them is the zoom, and the turn is left to
218
+ // one finger. Pinching and turning at once reads as neither.
219
+ const spread = this._pointerSpread();
220
+ if (this._pinch > 0 && spread > 0) this._view = zoomView(this._view, spread / this._pinch);
221
+ this._pinch = spread;
222
+ } else {
223
+ this._view = dragView(this._view, event.clientX - previous.x, event.clientY - previous.y);
224
+ }
225
+ this._render();
226
+ });
227
+
228
+ const release = (event: PointerEvent) => {
229
+ this._pointers.delete(event.pointerId);
230
+ this._pinch = this._pointerSpread();
231
+ };
232
+ canvas.addEventListener('pointerup', release);
233
+ canvas.addEventListener('pointercancel', release);
234
+ canvas.addEventListener('pointerleave', release);
235
+
236
+ // What the pointer is over, whenever it is not turning the surface. The nearest measured
237
+ // vertex within reach, or nothing - a landscape has gaps, and naming the far side of one
238
+ // because it happened to be closest in pixels would be a reading nobody asked for.
239
+ canvas.addEventListener('pointermove', (event: PointerEvent) => {
240
+ if (this._pointers.size > 0) return;
241
+ const box = canvas.getBoundingClientRect();
242
+ this._setHover(event.clientX - box.left, event.clientY - box.top);
243
+ });
244
+
245
+ canvas.addEventListener('pointerleave', () => this._setHover(null, null));
246
+
247
+ canvas.addEventListener('wheel', (event: WheelEvent) => {
248
+ // Claimed for the same reason as the drag: a wheel over a chart zooms the chart, and
249
+ // the page scrolling instead is the thing that makes a 3D panel unusable in a page.
250
+ event.preventDefault();
251
+ const notches = wheelNotches(event.deltaY, event.deltaMode);
252
+ if (notches === 0) return;
253
+ this._view = zoomView(this._view, Math.pow(WHEEL_STEP, -notches));
254
+ this._render();
255
+ }, { passive: false });
256
+ }
257
+
258
+
259
+ // The vertex under the pointer, and a repaint when it changes. Compared by identity of the
260
+ // grid pair rather than of the object: a re-layout builds new vertices for the same runs.
261
+ _setHover(x: number | null, y: number | null): void {
262
+ const found = x === null || y === null || this._layout === null
263
+ ? null
264
+ : nearestVertex(this._layout.vertices, x, y, HOVER_REACH);
265
+ const before = this._hover;
266
+ const same = before !== null && found !== null && before.x === found.x && before.y === found.y;
267
+ if (same || (before === null && found === null)) return;
268
+
269
+ this._hover = found;
270
+ this._renderHover();
271
+ this._render();
272
+ }
273
+
274
+ // Which pair it is and what it measured, in the panel's own strip over the canvas.
275
+ _renderHover(): void {
276
+ const el = this._hoverEl;
277
+ if (el === null || this._data === null) return;
278
+
279
+ const hover = this._hover;
280
+ if (hover === null) {
281
+ el.textContent = '';
282
+ return;
283
+ }
284
+ el.textContent = `${this._data.xLabel} ${hover.x} · ${this._data.yLabel} ${hover.y}`
285
+ + ` ${formatPnl(hover.value)}`;
286
+ }
287
+
288
+ // How far apart the two pointers are, or zero when there are not two.
289
+ _pointerSpread(): number {
290
+ const points = [...this._pointers.values()];
291
+ if (points.length < 2) return 0;
292
+ return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
293
+ }
294
+
295
+ _render(): void {
296
+ const canvas = this.canvasEl;
297
+ const ctx = this._ctx;
298
+ if (canvas === null || ctx === null) return;
299
+
300
+ // Measured from the box the stylesheet gives it, and the style is left alone. Writing
301
+ // the measurement back as an inline width would pin the canvas at whatever size it
302
+ // happened to have when it was first drawn - and an inline width beats the stylesheet's
303
+ // 100%, so it could never grow again.
304
+ const box = canvas.getBoundingClientRect();
305
+ const width = Math.round(box.width);
306
+ const height = Math.round(box.height);
307
+ if (width <= 0 || height <= 0) return;
308
+
309
+ // Device pixels for the backing store: on a 2x screen a canvas sized only in CSS pixels
310
+ // draws every stroke at half the resolution of the text beside it.
311
+ const ratio = typeof window === 'undefined' ? 1 : (window.devicePixelRatio || 1);
312
+ canvas.width = Math.round(width * ratio);
313
+ canvas.height = Math.round(height * ratio);
314
+
315
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
316
+ ctx.clearRect(0, 0, width, height);
317
+
318
+ const metric = this.rootEl.querySelector('.surface-metric');
319
+ if (metric !== null) metric.textContent = this._data?.metricLabel ?? '';
320
+
321
+ const layout = this._data === null ? null : surfaceLayout({
322
+ width, height, cells: this._data.cells, betterWhen: this._data.betterWhen, view: this._view,
323
+ });
324
+ this._layout = layout;
325
+
326
+ if (this._emptyEl !== null) this._emptyEl.hidden = layout !== null;
327
+ if (layout === null || this._data === null) {
328
+ this._hover = null;
329
+ this._renderHover();
330
+ return;
331
+ }
332
+
333
+ const palette = this._host.presentation.canvasPalette();
334
+ this._drawFloor(ctx, layout, palette.grid);
335
+ this._drawFaces(ctx, layout, palette);
336
+ this._drawAxes(ctx, layout, palette);
337
+ this._drawHover(ctx, palette);
338
+ }
339
+
340
+ // The three axes: the two the grid is swept over and the one it is measured against, each with
341
+ // its own marks. A landscape with no numbers on it is a picture of a shape, not a reading.
342
+ _drawAxes(
343
+ ctx: CanvasRenderingContext2D,
344
+ layout: SurfaceLayout,
345
+ palette: { grid: string; font: string },
346
+ ): void {
347
+ if (this._data === null) return;
348
+
349
+ ctx.font = palette.font;
350
+ ctx.textBaseline = 'middle';
351
+ ctx.strokeStyle = palette.grid;
352
+ ctx.fillStyle = palette.grid;
353
+ ctx.lineWidth = EDGE_WIDTH;
354
+
355
+ for (const axis of layout.axes) {
356
+ ctx.globalAlpha = LABEL_ALPHA;
357
+ for (const tick of axis.ticks) {
358
+ const [ax, ay] = tick.at;
359
+ const [dx, dy] = tick.away;
360
+ ctx.beginPath();
361
+ ctx.moveTo(ax, ay);
362
+ ctx.lineTo(ax + dx * TICK_LENGTH, ay + dy * TICK_LENGTH);
363
+ ctx.stroke();
364
+
365
+ // A label sits on the far side of its own tick, and leans the way the tick points:
366
+ // marks down the left of the box read right-aligned, marks along the bottom centred.
367
+ const tx = ax + dx * (TICK_LENGTH + TICK_GAP);
368
+ const ty = ay + dy * (TICK_LENGTH + TICK_GAP);
369
+ ctx.textAlign = Math.abs(dx) < 0.4 ? 'center' : (dx > 0 ? 'left' : 'right');
370
+ ctx.fillText(this._tickLabel(axis.axis, tick.label, layout), tx, ty);
371
+ }
372
+
373
+ // The caption past the last mark, so it names the axis without sitting on a number.
374
+ const midX = (axis.from[0] + axis.to[0]) / 2;
375
+ const midY = (axis.from[1] + axis.to[1]) / 2;
376
+ const away = axis.ticks.length > 0 ? axis.ticks[0].away : [0, 1];
377
+ ctx.globalAlpha = 1;
378
+ ctx.textAlign = 'center';
379
+ ctx.fillText(
380
+ this._axisCaption(axis.axis),
381
+ midX + away[0] * CAPTION_OFFSET,
382
+ midY + away[1] * CAPTION_OFFSET,
383
+ );
384
+ }
385
+ ctx.globalAlpha = 1;
386
+ }
387
+
388
+ // The vertical axis is measured in the metric, so its 0..1 heights are worded as figures; the
389
+ // other two carry the parameter values as they were swept.
390
+ _tickLabel(axis: 'x' | 'y' | 'z', label: string, layout: SurfaceLayout): string {
391
+ if (axis !== 'z' || this._data === null) return label;
392
+ const height = Number(label);
393
+ return formatPnl(valueAt(height * 2 - 1, layout.scale, this._data.betterWhen));
394
+ }
395
+
396
+ _axisCaption(axis: 'x' | 'y' | 'z'): string {
397
+ if (this._data === null) return '';
398
+ if (axis === 'x') return this._data.xLabel;
399
+ if (axis === 'y') return this._data.yLabel;
400
+ return this._data.metricLabel;
401
+ }
402
+
403
+ // A ring on the vertex under the pointer, so the figure in the strip has a place on the
404
+ // landscape rather than being a number beside a picture.
405
+ _drawHover(ctx: CanvasRenderingContext2D, palette: { up: string; grid: string }): void {
406
+ const hover = this._hover;
407
+ if (hover === null) return;
408
+
409
+ ctx.beginPath();
410
+ ctx.arc(hover.at[0], hover.at[1], HOVER_RING, 0, Math.PI * 2);
411
+ ctx.strokeStyle = palette.up;
412
+ ctx.lineWidth = 2;
413
+ ctx.globalAlpha = 1;
414
+ ctx.stroke();
415
+ }
416
+
417
+ // The frame the surface stands on, so a reader can tell which way the grid runs even when
418
+ // the landscape hides its own edges.
419
+ _drawFloor(ctx: CanvasRenderingContext2D, layout: SurfaceLayout, colour: string): void {
420
+ ctx.globalAlpha = FLOOR_ALPHA;
421
+ ctx.strokeStyle = colour;
422
+ ctx.lineWidth = EDGE_WIDTH;
423
+ for (const axis of layout.axes) {
424
+ ctx.beginPath();
425
+ ctx.moveTo(axis.from[0], axis.from[1]);
426
+ ctx.lineTo(axis.to[0], axis.to[1]);
427
+ ctx.stroke();
428
+ }
429
+ ctx.globalAlpha = 1;
430
+ }
431
+
432
+ // Far to near, each face filled by how good it is and outlined so the grid stays legible
433
+ // where two faces are nearly the same colour.
434
+ _drawFaces(
435
+ ctx: CanvasRenderingContext2D,
436
+ layout: SurfaceLayout,
437
+ palette: { up: string; down: string; grid: string },
438
+ ): void {
439
+ for (const quad of layout.quads) {
440
+ ctx.beginPath();
441
+ ctx.moveTo(quad.points[0][0], quad.points[0][1]);
442
+ for (const [x, y] of quad.points.slice(1)) ctx.lineTo(x, y);
443
+ ctx.closePath();
444
+
445
+ ctx.globalAlpha = FACE_ALPHA * Math.max(0.18, Math.abs(quad.tint));
446
+ ctx.fillStyle = quad.tint >= 0 ? palette.up : palette.down;
447
+ ctx.fill();
448
+
449
+ ctx.globalAlpha = EDGE_ALPHA;
450
+ ctx.strokeStyle = palette.grid;
451
+ ctx.lineWidth = EDGE_WIDTH;
452
+ ctx.stroke();
453
+ }
454
+ ctx.globalAlpha = 1;
455
+ }
456
+
457
+ }
@@ -100,6 +100,28 @@ export interface TradeRow {
100
100
  }
101
101
 
102
102
  /// One tradable instrument, as the instrument search returns it.
103
+ /// One statistic a strategy reports about itself.
104
+ ///
105
+ /// The desktop grid reflects these off the strategy's parameter objects; a browser is handed
106
+ /// them already resolved. `category` groups and `order` sorts - both are the registry's, not
107
+ /// the reader's, and neither is the translated text beside it.
108
+ export interface StatisticRow {
109
+ /// Stable identity, and what the row is addressed by. The parameter's own kind.
110
+ key: string;
111
+ /// Grouping key. Stable across languages.
112
+ category: string;
113
+ /// What that group is called here.
114
+ categoryText?: string;
115
+ /// Where the parameter sits among its peers. Banded by category by the registry.
116
+ order: number;
117
+ /// Localized caption.
118
+ name: string;
119
+ /// Localized explanation, shown on hover.
120
+ description?: string;
121
+ /// A number, a moment, or a word - whatever the parameter measures. Null until measured.
122
+ value?: number | string | Date | null;
123
+ }
124
+
103
125
  export interface InstrumentRow {
104
126
  /// Qualified symbol — `BTC@IMEX`. The display form drops the venue.
105
127
  symbol?: string;