@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,226 @@
1
+ // Strategy statistics — multi-instance.
2
+ //
3
+ // A read-only table of everything a strategy reports about itself: net profit, drawdown, trade
4
+ // counts, latencies. One row per parameter, grouped by the area it belongs to.
5
+ //
6
+ // The desktop grid derives its rows by reflecting over the strategy's statistic parameters, and
7
+ // a browser has no equivalent - so the rows arrive from the consumer already resolved, name and
8
+ // description translated, with the two things that decide where a row sits: a stable category
9
+ // key and the registry order. Both matter. Grouping on the translated category would regroup
10
+ // the table when the language changes, and sorting by name or by value would scatter parameters
11
+ // that are read together.
12
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
13
+ import { ControlTypes } from './control-types.js';
14
+ import { makeGridMenu } from './grid-menu.js';
15
+ import { TradingHost, assertHost } from './trading-host.js';
16
+ import type { StatisticRow } from './trading-data.js';
17
+ import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
18
+
19
+ /// What the table needs beyond the host port. Nothing: there is nothing here to act on.
20
+ export interface StatisticsDeps {
21
+ host: TradingHost;
22
+ }
23
+
24
+ /// A row with its category's place worked out.
25
+ interface RankedRow extends StatisticRow {
26
+ categoryRank: number;
27
+ }
28
+
29
+ /// Give every category the place of its first parameter.
30
+ ///
31
+ /// Taken from the rows rather than assumed: the registry bands its orders by category today
32
+ /// - profit below a hundred, trades from a hundred - but a consumer numbering them some
33
+ /// other way still gets its groups in the order it asked for.
34
+ function rank(rows: readonly StatisticRow[]): RankedRow[] {
35
+ const first = new Map<string, number>();
36
+ for (const row of rows) {
37
+ const seen = first.get(row.category);
38
+ if (seen === undefined || row.order < seen) first.set(row.category, row.order);
39
+ }
40
+
41
+ return rows.map(row => ({ ...row, categoryRank: first.get(row.category) ?? row.order }));
42
+ }
43
+
44
+ export class StatisticsWidget {
45
+ static TYPE = ControlTypes.Statistics;
46
+
47
+ rootEl: HTMLElement;
48
+ el: HTMLElement | null;
49
+ // `//` rather than `///` from here down — see the note in positions-widget.
50
+ _host: TradingHost;
51
+ _closeBtn: HTMLElement | null;
52
+ _exportBtn: HTMLElement | null;
53
+ _rows: RankedRow[];
54
+ _grid: DataGrid<RankedRow> | null;
55
+
56
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: StatisticsDeps): StatisticsWidget {
57
+ const host = assertHost(deps?.host, 'StatisticsWidget');
58
+ const root = StatisticsWidget._buildRoot(host);
59
+ root.id = makePanelId(StatisticsWidget.TYPE);
60
+ hostEl.appendChild(root);
61
+ return new StatisticsWidget(root, state || {}, deps);
62
+ }
63
+
64
+ // The panel's markup. No rail action but the export: a statistic is produced by the
65
+ // strategy and there is nothing here to cancel, reload or edit.
66
+ static _buildRoot(host: TradingHost): HTMLElement {
67
+ const title = host.t('Statistics');
68
+ return makePanelRoot('statistics-panel', title, [
69
+ makeElement('div', 'panel-header', {}, [
70
+ makeElement('span', '', {}, [title]),
71
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
72
+ ]),
73
+ makeElement('div', 'panel-body panel-body-with-rail', {}, [
74
+ makeElement('div', 'panel-body-content', {}, [
75
+ makeElement('table', 'terminal-table statistics-table', { role: 'table', 'aria-label': host.t('StatisticsList') }, [
76
+ makeElement('thead', '', {}, []),
77
+ makeElement('tbody', 'statistics-body', {}, []),
78
+ ]),
79
+ ]),
80
+ makeElement('div', 'panel-rail', { role: 'toolbar', 'aria-label': host.t('StatisticsActions') }, [
81
+ makeIconButton('bt-icon-btn panel-export-btn', host.t('ExportToExcel'), 'bi-file-earmark-spreadsheet', {}),
82
+ ]),
83
+ ]),
84
+ ]);
85
+ }
86
+
87
+ constructor(rootEl: HTMLElement, _state: Record<string, unknown>, deps: StatisticsDeps) {
88
+ this._host = assertHost(deps?.host, 'StatisticsWidget');
89
+
90
+ this.rootEl = rootEl;
91
+ this.el = this.rootEl.querySelector('.statistics-body');
92
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
93
+ this._exportBtn = this.rootEl.querySelector('.panel-export-btn');
94
+ this._rows = [];
95
+
96
+ this._closeBtn?.addEventListener('click', (e) => {
97
+ e.preventDefault();
98
+ this._host.close();
99
+ });
100
+
101
+ this._exportBtn?.addEventListener('click', (e) => {
102
+ e.preventDefault();
103
+ this._export();
104
+ });
105
+
106
+ const head = this.rootEl.querySelector('.statistics-table thead');
107
+ this._grid = head && this.el
108
+ ? new DataGrid<RankedRow>({
109
+ head: head as HTMLElement,
110
+ body: this.el,
111
+ columns: this._columns(),
112
+ // Not a sort the reader chose: the registry hands every parameter an order, and
113
+ // the bands it assigns are what puts profit above drawdown above trade counts.
114
+ defaultSort: { col: 'order', dir: 'asc' },
115
+ rowKey: (r) => String(r.key),
116
+ emptyText: this._host.t('NoStatistics'),
117
+ contextMenu: makeGridMenu(this._host),
118
+ // The groups sit where the registry order puts them, not where the
119
+ // alphabet would: profit, then trades, then positions, then orders.
120
+ groupOrder: 'rows',
121
+ selection: 'multi',
122
+ })
123
+ : null;
124
+
125
+ // Grouped on the key, never on the caption: the caption is translated and would
126
+ // regroup the table under the reader's language. Neither the key nor the order is a
127
+ // column anyone reads, so both are hidden - declared, so the grid can group and sort
128
+ // on them, and out of the way.
129
+ this._grid?.setState({ group: 'category', hidden: ['category', 'order'] });
130
+
131
+ this._host.register(this);
132
+ }
133
+
134
+ dispose(): void {
135
+ this._grid?.destroy();
136
+ this._host.unregister(this);
137
+ try { this.rootEl.remove(); } catch { /* already detached */ }
138
+ }
139
+
140
+ /// Show these statistics. The whole set at once: a strategy publishes its parameters as one
141
+ /// table and a row that vanished from it has stopped existing, not stopped changing.
142
+ update(rows: StatisticRow[]): void {
143
+ this._rows = rank(rows || []);
144
+ this._grid?.setRows(this._rows);
145
+ }
146
+
147
+ _export(): void {
148
+ this._grid?.download('statistics', this._host.t('Statistics'));
149
+ }
150
+
151
+ // Two visible columns and one that only groups. The order column is hidden as well: it
152
+ // decides the sort and means nothing to a reader.
153
+ _columns(): GridColumn<RankedRow>[] {
154
+ const label = (key: string) => this._host.t(key);
155
+ return [
156
+ {
157
+ key: 'category',
158
+ header: label('Category'),
159
+ exportable: false,
160
+ // The group's place, not its name: groups are laid out in the order the
161
+ // registry gives their parameters, so profit comes before trade counts.
162
+ // Grouping on the name would order them alphabetically, and on the
163
+ // translated name would reorder them with the reader's language.
164
+ value: (r) => r.categoryRank,
165
+ text: (r) => r.categoryText || r.category,
166
+ },
167
+ {
168
+ key: 'order',
169
+ header: label('Order'),
170
+ exportable: false,
171
+ value: (r) => r.order,
172
+ },
173
+ {
174
+ key: 'name',
175
+ header: label('Name'),
176
+ exportable: true,
177
+ value: (r) => r.name,
178
+ bindCell: (td, r) => {
179
+ if (r.description) td.setAttribute('title', r.description);
180
+ },
181
+ },
182
+ {
183
+ key: 'value',
184
+ header: label('Value'),
185
+ exportable: true,
186
+ cellClass: () => 'statistic-value',
187
+ // Sorts on the raw value so a number sorts as a number, reads as the text below.
188
+ value: (r) => r.value as string | number | null,
189
+ render: (r) => formatStatistic(r.value),
190
+ exportValue: (r) => r.value ?? '',
191
+ },
192
+ ];
193
+ }
194
+ }
195
+
196
+ /// A statistic as text.
197
+ ///
198
+ /// Three shapes reach this: a number, a moment, and everything else. A number is rounded to two
199
+ /// places and shown without trailing zeros - a profit of 11055.75 and a count of 1340 both read
200
+ /// the way they are meant to. A moment is a date: these are run-level facts (the day of the
201
+ /// maximum drawdown), and the hour would be noise. Anything with no value yet is a blank cell
202
+ /// rather than a zero, which would read as a measured nothing.
203
+ export function formatStatistic(value: unknown): string {
204
+ if (value === null || value === undefined || value === '') return '';
205
+
206
+ if (typeof value === 'number') {
207
+ if (!isFinite(value)) return '';
208
+ return String(Math.round(value * 100) / 100);
209
+ }
210
+
211
+ if (value instanceof Date) return isoDate(value);
212
+
213
+ if (typeof value === 'string') {
214
+ // A date only when it is unambiguously one: a bare number in a string stays a number,
215
+ // and a name stays a name.
216
+ const at = /^\d{4}-\d{2}-\d{2}([T ]|$)/.test(value) ? new Date(value) : null;
217
+ if (at !== null && !isNaN(at.getTime())) return isoDate(at);
218
+ return value;
219
+ }
220
+
221
+ return String(value);
222
+ }
223
+
224
+ function isoDate(at: Date): string {
225
+ return at.toISOString().slice(0, 10);
226
+ }
@@ -0,0 +1,435 @@
1
+ // Strategies dashboard — multi-instance.
2
+ //
3
+ // A row per strategy: what state it is in, what it is trading, what it has done, and the
4
+ // controls to start it, stop it and flatten it. This is the panel a browser client exists for -
5
+ // the strategies run on a server and the page is how someone reaches them.
6
+ //
7
+ // Three deliberate departures from the desktop version, each because copying it would carry a
8
+ // defect across. Its Settings button is wired to a disabled command in both shipped hosts, so a
9
+ // viewer sees a permanently dead control - that button is not here, and the rest are declared
10
+ // through deps, so a host that cannot do a thing does not show a button for it. Its P&L
11
+ // sparkline is filled green unconditionally, which paints a losing run in the winning colour;
12
+ // here the curve is coloured by where the run ended. And its P&L change is measured from the
13
+ // first non-zero P&L and never re-anchored, so a restarted strategy keeps measuring from before
14
+ // the restart; here the consumer states the number it means.
15
+ import { formatPnl, formatQty } from './formatters.js';
16
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
17
+ import { ControlTypes } from './control-types.js';
18
+ import { makeGridMenu } from './grid-menu.js';
19
+ import { TradingHost, assertHost } from './trading-host.js';
20
+ import { drawPnlCurve, pnlCurve, type PnlPoint } from './pnl-curve.js';
21
+ import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
22
+
23
+ /// The states a strategy moves through, as the wire spells them.
24
+ export const StrategyStates = {
25
+ Stopped: 'stopped',
26
+ Starting: 'starting',
27
+ Started: 'started',
28
+ Stopping: 'stopping',
29
+ } as const;
30
+
31
+ export type StrategyState = typeof StrategyStates[keyof typeof StrategyStates];
32
+
33
+ /// One strategy, as the dashboard reads it.
34
+ export interface StrategyRow {
35
+ id: string;
36
+ name: string;
37
+ state: StrategyState | string;
38
+ /// Whether it is both formed and connected. Two conditions the desktop ANDs into one
39
+ /// indicator, and the consumer does the same here: a half-online strategy is not online.
40
+ online?: boolean;
41
+ /// What it is allowed to do. The one editable cell.
42
+ tradingMode?: string;
43
+ portfolio?: string;
44
+ security?: string;
45
+ position?: number | null;
46
+ ordersCount?: number | null;
47
+ tradesCount?: number | null;
48
+ /// Change against whatever the consumer anchors to.
49
+ pnlChange?: number | null;
50
+ realized?: number | null;
51
+ unrealized?: number | null;
52
+ /// The cumulative P&L curve, sampled at the points it changed.
53
+ pnl?: PnlPoint[];
54
+ /// The last thing that went wrong, if anything has.
55
+ error?: string;
56
+ }
57
+
58
+ /// What a host can let someone do to a strategy. Every one optional and independently so: a
59
+ /// read-only dashboard passes none and shows no buttons, and a host that can start and stop but
60
+ /// not open shows exactly those two.
61
+ export interface StrategiesActions {
62
+ start?(id: string): void;
63
+ stop?(id: string): void;
64
+ closePosition?(id: string): void;
65
+ openStrategy?(id: string): void;
66
+ riskRules?(id: string): void;
67
+ setTradingMode?(id: string, mode: string): void;
68
+ }
69
+
70
+ export interface StrategiesDeps extends StrategiesActions {
71
+ host: TradingHost;
72
+ /// The trading modes a strategy can be put in, in the order they should be offered.
73
+ ///
74
+ /// Each is worded through `t()`, so these strings are also translation keys - and they are
75
+ /// the host's own, not the package's: they name what this host can put a run in, so they are
76
+ /// absent from `translation-keys.json` and the host answers for them itself.
77
+ tradingModes?: readonly string[];
78
+ }
79
+
80
+ const SPARK_WIDTH = 140;
81
+ const SPARK_HEIGHT = 26;
82
+
83
+ export class StrategiesWidget {
84
+ static TYPE = ControlTypes.Strategies;
85
+
86
+ rootEl: HTMLElement;
87
+ el: HTMLElement | null;
88
+ // `//` rather than `///` from here down — see the note in positions-widget.
89
+ _host: TradingHost;
90
+ _deps: StrategiesDeps;
91
+ _closeBtn: HTMLElement | null;
92
+ _exportBtn: HTMLElement | null;
93
+ _rows: StrategyRow[];
94
+ _grid: DataGrid<StrategyRow> | null;
95
+
96
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: StrategiesDeps): StrategiesWidget {
97
+ const host = assertHost(deps?.host, 'StrategiesWidget');
98
+ const root = StrategiesWidget._buildRoot(host);
99
+ root.id = makePanelId(StrategiesWidget.TYPE);
100
+ hostEl.appendChild(root);
101
+ return new StrategiesWidget(root, state || {}, deps);
102
+ }
103
+
104
+ static _buildRoot(host: TradingHost): HTMLElement {
105
+ const title = host.t('Strategies');
106
+ return makePanelRoot('strategies-panel', title, [
107
+ makeElement('div', 'panel-header', {}, [
108
+ makeElement('span', '', {}, [title]),
109
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
110
+ ]),
111
+ makeElement('div', 'panel-body panel-body-with-rail', {}, [
112
+ makeElement('div', 'panel-body-content', {}, [
113
+ makeElement('table', 'terminal-table strategies-table', { role: 'table', 'aria-label': host.t('StrategiesList') }, [
114
+ makeElement('thead', '', {}, []),
115
+ makeElement('tbody', 'strategies-body', {}, []),
116
+ ]),
117
+ ]),
118
+ makeElement('div', 'panel-rail', { role: 'toolbar', 'aria-label': host.t('StrategiesActions') }, [
119
+ makeIconButton('bt-icon-btn panel-export-btn', host.t('ExportToExcel'), 'bi-file-earmark-spreadsheet', {}),
120
+ ]),
121
+ ]),
122
+ ]);
123
+ }
124
+
125
+ constructor(rootEl: HTMLElement, _state: Record<string, unknown>, deps: StrategiesDeps) {
126
+ this._host = assertHost(deps?.host, 'StrategiesWidget');
127
+ this._deps = deps;
128
+
129
+ this.rootEl = rootEl;
130
+ this.el = this.rootEl.querySelector('.strategies-body');
131
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
132
+ this._exportBtn = this.rootEl.querySelector('.panel-export-btn');
133
+ this._rows = [];
134
+
135
+ this._closeBtn?.addEventListener('click', (e) => { e.preventDefault(); this._host.close(); });
136
+ this._exportBtn?.addEventListener('click', (e) => { e.preventDefault(); this._export(); });
137
+
138
+ const head = this.rootEl.querySelector('.strategies-table thead');
139
+ this._grid = head && this.el
140
+ ? new DataGrid<StrategyRow>({
141
+ head: head as HTMLElement,
142
+ body: this.el,
143
+ columns: this._columns(),
144
+ // By name: a dashboard is a list someone reads down looking for one strategy,
145
+ // and a list that reorders itself as P&L moves cannot be read that way.
146
+ defaultSort: { col: 'name', dir: 'asc' },
147
+ rowKey: (s) => String(s.id),
148
+ emptyText: this._host.t('NoStrategies'),
149
+ rowClass: (s) => `strategy-row strategy-${s.state}${s.error ? ' strategy-failed' : ''}`,
150
+ contextMenu: makeGridMenu(this._host),
151
+ selection: 'multi',
152
+ })
153
+ : null;
154
+
155
+ this._host.register(this);
156
+ }
157
+
158
+ dispose(): void {
159
+ this._grid?.destroy();
160
+ this._host.unregister(this);
161
+ try { this.rootEl.remove(); } catch { /* already detached */ }
162
+ }
163
+
164
+ /// Show these strategies. The whole set: one that is gone from it has been removed, and
165
+ /// leaving its row behind would offer a Stop for something that no longer runs.
166
+ update(rows: StrategyRow[]): void {
167
+ this._rows = rows || [];
168
+ this._grid?.setRows(this._rows);
169
+ }
170
+
171
+ _export(): void {
172
+ this._grid?.download('strategies', this._host.t('Strategies'));
173
+ }
174
+
175
+ _columns(): GridColumn<StrategyRow>[] {
176
+ const label = (key: string) => this._host.t(key);
177
+ const presentation = this._host.presentation;
178
+
179
+ return [
180
+ {
181
+ key: 'state',
182
+ header: label('State'),
183
+ exportable: true,
184
+ value: (s) => s.state,
185
+ render: (s) => this._stateCell(s),
186
+ cellClass: (s) => `strategy-state strategy-state-${s.state}`,
187
+ exportValue: (s) => s.state,
188
+ },
189
+ {
190
+ key: 'actions',
191
+ header: label('Actions'),
192
+ headerHidden: true,
193
+ exportable: false,
194
+ cellClass: () => 'strategy-actions',
195
+ render: (s) => this._actionCell(s),
196
+ },
197
+ {
198
+ key: 'online',
199
+ header: label('Online'),
200
+ exportable: true,
201
+ value: (s) => (s.online ? 1 : 0),
202
+ render: (s) => (s.online ? '●' : '○'),
203
+ cellClass: (s) => (s.online ? 'strategy-online is-on' : 'strategy-online'),
204
+ exportValue: (s) => (s.online ? label('Yes') : label('No')),
205
+ },
206
+ {
207
+ key: 'tradingMode',
208
+ header: label('Trading'),
209
+ exportable: true,
210
+ value: (s) => s.tradingMode ?? '',
211
+ render: (s) => this._tradingCell(s),
212
+ exportValue: (s) => s.tradingMode ?? '',
213
+ },
214
+ { key: 'name', header: label('Name'), exportable: true, value: (s) => s.name },
215
+ { key: 'portfolio', header: label('Portfolio'), exportable: true, value: (s) => s.portfolio ?? '' },
216
+ { key: 'security', header: label('Sym'), exportable: true, value: (s) => s.security ?? '' },
217
+ {
218
+ key: 'position',
219
+ header: label('Position'),
220
+ exportable: true,
221
+ value: (s) => s.position ?? 0,
222
+ render: (s) => this._positionCell(s),
223
+ cellClass: (s) => `strategy-position ${presentation.pnlClass(s.position ?? 0)}`,
224
+ exportValue: (s) => s.position ?? 0,
225
+ },
226
+ { key: 'orders', header: label('OrderCount'), exportable: true, value: (s) => s.ordersCount ?? 0 },
227
+ { key: 'trades', header: label('NumOfTrades'), exportable: true, value: (s) => s.tradesCount ?? 0 },
228
+ {
229
+ key: 'pnlChange',
230
+ header: label('PnLChange'),
231
+ exportable: true,
232
+ value: (s) => s.pnlChange ?? 0,
233
+ render: (s) => `${direction(s.pnlChange ?? 0)} ${formatPnl(s.pnlChange ?? 0)}`,
234
+ cellClass: (s) => presentation.pnlClass(s.pnlChange ?? 0),
235
+ exportValue: (s) => s.pnlChange ?? 0,
236
+ },
237
+ {
238
+ key: 'pnlChart',
239
+ header: label('PnLChart'),
240
+ exportable: false,
241
+ cellClass: () => 'strategy-spark',
242
+ render: (s) => this._sparkline(s),
243
+ },
244
+ {
245
+ key: 'realized',
246
+ header: label('RealizedProfit'),
247
+ exportable: true,
248
+ value: (s) => s.realized ?? 0,
249
+ render: (s) => formatPnl(s.realized ?? 0),
250
+ cellClass: (s) => presentation.pnlClass(s.realized ?? 0),
251
+ exportValue: (s) => s.realized ?? 0,
252
+ },
253
+ {
254
+ key: 'unrealized',
255
+ header: label('UnrealizedProfit'),
256
+ exportable: true,
257
+ value: (s) => s.unrealized ?? 0,
258
+ render: (s) => formatPnl(s.unrealized ?? 0),
259
+ cellClass: (s) => presentation.pnlClass(s.unrealized ?? 0),
260
+ exportValue: (s) => s.unrealized ?? 0,
261
+ },
262
+ {
263
+ key: 'error',
264
+ header: label('Error'),
265
+ exportable: true,
266
+ cellClass: () => 'strategy-error',
267
+ value: (s) => s.error ?? '',
268
+ },
269
+ ];
270
+ }
271
+
272
+ // A dot and the state's own word. The dot is what is read across a list of twenty; the word
273
+ // is what tells Starting from Started, which a colour alone cannot.
274
+ _stateCell(row: StrategyRow): string | Node {
275
+ const cell = document.createDocumentFragment();
276
+
277
+ const failed = row.error !== undefined && row.error !== null && String(row.error).length > 0;
278
+
279
+ const dot = document.createElement('span');
280
+ dot.className = 'strategy-dot';
281
+ dot.textContent = '●';
282
+ // The reason sits on the dot as well as on the word: the dot is what a reader looks at
283
+ // first, and a tooltip only the word carries is one nobody finds.
284
+ if (failed) dot.title = String(row.error);
285
+ cell.appendChild(dot);
286
+
287
+ const text = document.createElement('span');
288
+ text.className = 'strategy-state-text';
289
+ // A run that stopped because something went wrong says so, rather than reading exactly
290
+ // like one the user stopped and differing only by the colour of the dot. The colour is
291
+ // the same statement made a second time, for a board too narrow to show the error
292
+ // column; the word is what a reader gets first.
293
+ text.textContent = failed && row.state === StrategyStates.Stopped
294
+ ? this._host.t('Error')
295
+ : stateText(this._host, row.state);
296
+ if (failed) text.title = String(row.error);
297
+ cell.appendChild(text);
298
+
299
+ return cell;
300
+ }
301
+
302
+ // Only the buttons this host can actually carry out, and only where the state allows them.
303
+ // A strategy that is already running has nothing to start.
304
+ _actionCell(row: StrategyRow): Node {
305
+ const cell = document.createDocumentFragment();
306
+ const deps = this._deps;
307
+
308
+ const add = (
309
+ action: ((id: string) => void) | undefined,
310
+ when: boolean,
311
+ title: string,
312
+ icon: string,
313
+ cls: string,
314
+ ): void => {
315
+ if (action === undefined) return;
316
+
317
+ const button = makeIconButton(`bt-icon-btn ${cls}`, title, icon, { type: 'button' }) as HTMLButtonElement;
318
+ if (!when) button.disabled = true;
319
+ else button.addEventListener('click', () => action.call(deps, row.id));
320
+ cell.appendChild(button);
321
+ };
322
+
323
+ add(deps.start, row.state === StrategyStates.Stopped, this._host.t('Start'), 'bi-play-fill', 'strategy-start-btn');
324
+ add(deps.stop, row.state === StrategyStates.Started, this._host.t('Stop'), 'bi-stop-fill', 'strategy-stop-btn');
325
+ add(deps.riskRules, true, this._host.t('RiskManagement'), 'bi-shield-exclamation', 'strategy-risk-btn');
326
+ add(deps.openStrategy, true, this._host.t('OpenStrategy'), 'bi-box-arrow-up-right', 'strategy-open-btn');
327
+
328
+ return cell;
329
+ }
330
+
331
+ // The position, and next to it the one gesture that acts on it. Flattening is only
332
+ // meaningful while the strategy runs and only when it holds something.
333
+ _positionCell(row: StrategyRow): string | Node {
334
+ const position = row.position ?? 0;
335
+ const text = formatQty(position);
336
+ if (this._deps.closePosition === undefined) return text;
337
+
338
+ const cell = document.createDocumentFragment();
339
+ // The figure sits in a box of a fixed least width, so the button beside it stays put
340
+ // when a minus sign appears or a digit is dropped. Without it the whole column shifts
341
+ // every time a position crosses zero.
342
+ const value = makeElement('span', 'strategy-position-value', {}, [text]);
343
+ cell.appendChild(value);
344
+
345
+ const button = makeIconButton('bt-icon-btn strategy-flatten-btn', this._host.t('ClosePosition'), 'bi-x-octagon', { type: 'button' }) as HTMLButtonElement;
346
+ if (row.state !== StrategyStates.Started || position === 0) button.disabled = true;
347
+ else button.addEventListener('click', () => this._deps.closePosition?.(row.id));
348
+ cell.appendChild(button);
349
+
350
+ return cell;
351
+ }
352
+
353
+ // The one editable cell, and only when the host can carry the change out.
354
+ _tradingCell(row: StrategyRow): string | Node {
355
+ const modes = this._deps.tradingModes ?? [];
356
+ if (this._deps.setTradingMode === undefined || modes.length === 0) return row.tradingMode ?? '';
357
+
358
+ const select = document.createElement('select');
359
+ select.className = 'form-control form-control-sm strategy-mode';
360
+ // Only on a stopped run, the way start is only on a stopped one and stop only on a
361
+ // started one: the mode is what the run will be started with, not a lever to pull while
362
+ // it is trading.
363
+ select.disabled = row.state !== StrategyStates.Stopped;
364
+ for (const mode of modes) {
365
+ const option = document.createElement('option');
366
+ option.value = mode;
367
+ option.textContent = this._host.t(mode);
368
+ if (mode === row.tradingMode) option.selected = true;
369
+ select.appendChild(option);
370
+ }
371
+ if (!select.disabled)
372
+ select.addEventListener('change', () => this._deps.setTradingMode?.(row.id, select.value));
373
+ return select;
374
+ }
375
+
376
+ // The run's own curve, at the size a cell has. Coloured by where it ended, which is the
377
+ // whole reason a glance at the column tells one strategy from another.
378
+ _sparkline(row: StrategyRow): string | Node {
379
+ const box = { width: SPARK_WIDTH, height: SPARK_HEIGHT, padX: 1, padY: 2 };
380
+ const curve = pnlCurve(row.pnl ?? [], box);
381
+ if (curve === null) return '';
382
+
383
+ const canvas = document.createElement('canvas');
384
+ canvas.className = 'strategy-spark-canvas';
385
+
386
+ // Two sizes, and they are not the same one. The backing store is in device pixels, so
387
+ // a curve on a 2x screen is drawn at 2x; the CSS box stays the size the column has.
388
+ // Sized only in CSS pixels, as this was, every stroke lands on half the pixels it
389
+ // should and the whole sparkline reads as a smudge.
390
+ const ratio = typeof window === 'undefined' ? 1 : (window.devicePixelRatio || 1);
391
+ canvas.width = Math.round(box.width * ratio);
392
+ canvas.height = Math.round(box.height * ratio);
393
+ canvas.style.width = `${box.width}px`;
394
+ canvas.style.height = `${box.height}px`;
395
+
396
+ const ctx = canvas.getContext('2d');
397
+ if (ctx !== null) {
398
+ const palette = this._host.presentation.canvasPalette();
399
+ const scaled = { width: canvas.width, height: canvas.height, padX: box.padX * ratio, padY: box.padY * ratio };
400
+ drawPnlCurve(ctx, pnlCurve(row.pnl ?? [], scaled) ?? curve, scaled, {
401
+ up: palette.up,
402
+ down: palette.down,
403
+ baseline: palette.grid,
404
+ lineWidth: 1.5 * ratio,
405
+ fillOpacity: 0.35,
406
+ });
407
+ }
408
+
409
+ return canvas;
410
+ }
411
+ }
412
+
413
+ /// What a state is called here.
414
+ ///
415
+ /// Each key is a literal at the point it is asked for, rather than one value picked and then
416
+ /// translated: `translation-keys.json` is generated by scanning the sources for exactly that
417
+ /// shape, so a key assembled from a value never reaches the list, no host learns to translate
418
+ /// it, and it arrives at the user as itself.
419
+ function stateText(host: TradingHost, state: StrategyState | string): string {
420
+ switch (state) {
421
+ case StrategyStates.Started: return host.t('Started');
422
+ case StrategyStates.Starting: return host.t('Starting');
423
+ case StrategyStates.Stopping: return host.t('Stopping');
424
+ default: return host.t('Stopped');
425
+ }
426
+ }
427
+
428
+ /// Which way a figure moved, as one character. Blank for no change: an arrow that always points
429
+ /// somewhere says a strategy is moving when it is not.
430
+ function direction(value: number): string {
431
+ if (value > 0) return '▲';
432
+ if (value < 0) return '▼';
433
+ return '';
434
+ }
435
+