@stocksharp/trading-controls 1.2.0 → 1.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 (59) hide show
  1. package/README.md +95 -16
  2. package/dist/esm/black-scholes.js +147 -0
  3. package/dist/esm/black-scholes.js.map +1 -0
  4. package/dist/esm/control-types.js +4 -0
  5. package/dist/esm/control-types.js.map +1 -1
  6. package/dist/esm/index.js +18 -0
  7. package/dist/esm/index.js.map +1 -1
  8. package/dist/esm/log-monitor-widget.js +265 -0
  9. package/dist/esm/log-monitor-widget.js.map +1 -0
  10. package/dist/esm/log-tree.js +96 -0
  11. package/dist/esm/log-tree.js.map +1 -0
  12. package/dist/esm/option-desk-widget.js +322 -0
  13. package/dist/esm/option-desk-widget.js.map +1 -0
  14. package/dist/esm/pnl-curve.js +129 -0
  15. package/dist/esm/pnl-curve.js.map +1 -0
  16. package/dist/esm/statistics-widget.js +194 -0
  17. package/dist/esm/statistics-widget.js.map +1 -0
  18. package/dist/esm/strategies-widget.js +348 -0
  19. package/dist/esm/strategies-widget.js.map +1 -0
  20. package/dist/sstradingcontrols.js +1307 -47
  21. package/dist/sstradingcontrols.js.map +4 -4
  22. package/dist/types/black-scholes.d.ts +28 -0
  23. package/dist/types/black-scholes.d.ts.map +1 -0
  24. package/dist/types/control-types.d.ts +4 -0
  25. package/dist/types/control-types.d.ts.map +1 -1
  26. package/dist/types/index.d.ts +16 -1
  27. package/dist/types/index.d.ts.map +1 -1
  28. package/dist/types/log-monitor-widget.d.ts +42 -0
  29. package/dist/types/log-monitor-widget.d.ts.map +1 -0
  30. package/dist/types/log-tree.d.ts +34 -0
  31. package/dist/types/log-tree.d.ts.map +1 -0
  32. package/dist/types/option-desk-widget.d.ts +68 -0
  33. package/dist/types/option-desk-widget.d.ts.map +1 -0
  34. package/dist/types/pnl-curve.d.ts +43 -0
  35. package/dist/types/pnl-curve.d.ts.map +1 -0
  36. package/dist/types/statistics-widget.d.ts +29 -0
  37. package/dist/types/statistics-widget.d.ts.map +1 -0
  38. package/dist/types/strategies-widget.d.ts +63 -0
  39. package/dist/types/strategies-widget.d.ts.map +1 -0
  40. package/dist/types/trading-data.d.ts +9 -0
  41. package/dist/types/trading-data.d.ts.map +1 -1
  42. package/package.json +27 -2
  43. package/screenshots/log-monitor.png +0 -0
  44. package/screenshots/option-desk.png +0 -0
  45. package/screenshots/panels.jpg +0 -0
  46. package/screenshots/statistics.png +0 -0
  47. package/screenshots/strategies.png +0 -0
  48. package/src/black-scholes.ts +199 -0
  49. package/src/control-types.ts +4 -0
  50. package/src/index.ts +41 -0
  51. package/src/log-monitor-widget.ts +312 -0
  52. package/src/log-tree.ts +131 -0
  53. package/src/option-desk-widget.ts +422 -0
  54. package/src/pnl-curve.ts +204 -0
  55. package/src/statistics-widget.ts +226 -0
  56. package/src/strategies-widget.ts +435 -0
  57. package/src/trading-data.ts +22 -0
  58. package/styles/trading-controls.css +356 -0
  59. package/translation-keys.json +70 -1
@@ -0,0 +1,199 @@
1
+ // Option pricing and the greeks, as pure functions.
2
+ //
3
+ // The desktop desk gets these from StockSharp.Algo.Derivatives, which a browser cannot call. A
4
+ // host could compute them and send them down, and one that already does should - the desk takes
5
+ // them either way. But a host that has bid, ask and an expiry has everything the arithmetic
6
+ // needs, and making it stand up a pricing service before it can draw a chain is a poor trade.
7
+ //
8
+ // So the maths ships, and it ships here, apart from the control: a wrong greek is not visible
9
+ // on screen the way a wrong column is, and the only defence is checking the numbers against
10
+ // values that are known.
11
+ //
12
+ // Conventions follow the desk that reads them, not the textbook: vega is per one point of
13
+ // volatility, theta is per calendar day, and rho is per one point of rate - the units a trader
14
+ // works in, rather than per 1.0 of each, which nobody quotes.
15
+
16
+ export const OptionTypes = {
17
+ Call: 'call',
18
+ Put: 'put',
19
+ } as const;
20
+
21
+ export type OptionType = typeof OptionTypes[keyof typeof OptionTypes];
22
+
23
+ /// Everything a price depends on. Time is in years, rates and volatility are fractions - 0.05
24
+ /// is five percent, not five.
25
+ export interface OptionInputs {
26
+ assetPrice: number;
27
+ strike: number;
28
+ /// Years remaining. Zero once expired, which makes every greek zero and the premium
29
+ /// intrinsic.
30
+ timeToExpiry: number;
31
+ riskFree: number;
32
+ dividend: number;
33
+ /// Volatility, as a fraction.
34
+ deviation: number;
35
+ }
36
+
37
+ export interface Greeks {
38
+ delta: number;
39
+ gamma: number;
40
+ /// Per one point of volatility.
41
+ vega: number;
42
+ /// Per calendar day.
43
+ theta: number;
44
+ /// Per one point of rate.
45
+ rho: number;
46
+ }
47
+
48
+ const SQRT_2PI = Math.sqrt(2 * Math.PI);
49
+ const DAYS_IN_YEAR = 365;
50
+
51
+ /// The standard normal CDF.
52
+ ///
53
+ /// Hart's rational approximation, which is what pricing libraries use and what a desk's numbers
54
+ /// are expected to agree with: accurate to about 1e-15 across the range, and exactly a half at
55
+ /// the mean rather than nearly so. The cheaper Abramowitz-and-Stegun fit is good to 1e-7, which
56
+ /// sounds like enough until a greek that should be symmetric is not.
57
+ export function normalCdf(x: number): number {
58
+ const z = Math.abs(x);
59
+
60
+ // Beyond this the tail is smaller than double precision can carry anyway.
61
+ if (z > 37) return x > 0 ? 1 : 0;
62
+
63
+ const e = Math.exp(-(z * z) / 2);
64
+ let tail: number;
65
+
66
+ if (z < 7.07106781186547) {
67
+ let b = 3.52624965998911e-02 * z + 0.700383064443688;
68
+ b = b * z + 6.37396220353165;
69
+ b = b * z + 33.912866078383;
70
+ b = b * z + 112.079291497871;
71
+ b = b * z + 221.213596169931;
72
+ b = b * z + 220.206867912376;
73
+
74
+ let d = 8.83883476483184e-02 * z + 1.75566716318264;
75
+ d = d * z + 16.064177579207;
76
+ d = d * z + 86.7807322029461;
77
+ d = d * z + 296.564248779674;
78
+ d = d * z + 637.333633378831;
79
+ d = d * z + 793.826512519948;
80
+ d = d * z + 440.413735824752;
81
+
82
+ tail = (e * b) / d;
83
+ } else {
84
+ // A continued fraction, which is what stays accurate once the polynomial ratio above
85
+ // starts losing digits to cancellation.
86
+ let b = z + 0.65;
87
+ b = z + 4 / b;
88
+ b = z + 3 / b;
89
+ b = z + 2 / b;
90
+ b = z + 1 / b;
91
+ tail = e / (b * 2.506628274631);
92
+ }
93
+
94
+ return x > 0 ? 1 - tail : tail;
95
+ }
96
+
97
+ /// The standard normal density.
98
+ export function normalPdf(x: number): number {
99
+ return Math.exp(-0.5 * x * x) / SQRT_2PI;
100
+ }
101
+
102
+ /// d1, or zero when there is no time or no volatility for the division to mean anything.
103
+ export function d1(inputs: OptionInputs): number {
104
+ const { assetPrice, strike, timeToExpiry, riskFree, dividend, deviation } = inputs;
105
+ const spread = deviation * Math.sqrt(timeToExpiry);
106
+
107
+ if (spread === 0 || assetPrice <= 0 || strike <= 0) return 0;
108
+
109
+ return (Math.log(assetPrice / strike) + (riskFree - dividend + (deviation * deviation) / 2) * timeToExpiry) / spread;
110
+ }
111
+
112
+ /// d2, which is d1 less one standard deviation of the remaining time.
113
+ export function d2(inputs: OptionInputs): number {
114
+ const spread = inputs.deviation * Math.sqrt(inputs.timeToExpiry);
115
+ return spread === 0 ? 0 : d1(inputs) - spread;
116
+ }
117
+
118
+ /// What the option is worth.
119
+ ///
120
+ /// At expiry, or with no volatility, that is its intrinsic value - the formula degenerates to
121
+ /// exactly that, and saying so explicitly keeps a zero denominator out of the general case.
122
+ export function premium(type: OptionType, inputs: OptionInputs): number {
123
+ const { assetPrice, strike, timeToExpiry, riskFree, dividend, deviation } = inputs;
124
+
125
+ if (timeToExpiry <= 0 || deviation <= 0)
126
+ return Math.max(0, type === OptionTypes.Call ? assetPrice - strike : strike - assetPrice);
127
+
128
+ const a = d1(inputs);
129
+ const b = d2(inputs);
130
+ const carried = assetPrice * Math.exp(-dividend * timeToExpiry);
131
+ const discounted = strike * Math.exp(-riskFree * timeToExpiry);
132
+
133
+ return type === OptionTypes.Call
134
+ ? carried * normalCdf(a) - discounted * normalCdf(b)
135
+ : discounted * normalCdf(-b) - carried * normalCdf(-a);
136
+ }
137
+
138
+ /// Every greek at once: they share d1, and computing them together is both cheaper and the only
139
+ /// way they are guaranteed to describe the same moment.
140
+ export function greeks(type: OptionType, inputs: OptionInputs): Greeks {
141
+ const { assetPrice, strike, timeToExpiry, riskFree, dividend, deviation } = inputs;
142
+ const sign = type === OptionTypes.Call ? 1 : -1;
143
+
144
+ if (timeToExpiry <= 0 || deviation <= 0 || assetPrice <= 0) {
145
+ // An expired option still has a delta - it is one or nothing, depending on which side of
146
+ // the strike it finished. Everything else has stopped moving.
147
+ const inTheMoney = type === OptionTypes.Call ? assetPrice > strike : assetPrice < strike;
148
+ return { delta: inTheMoney ? sign : 0, gamma: 0, vega: 0, theta: 0, rho: 0 };
149
+ }
150
+
151
+ const a = d1(inputs);
152
+ const b = d2(inputs);
153
+ const sqrtT = Math.sqrt(timeToExpiry);
154
+ const density = normalPdf(a);
155
+ const carry = Math.exp(-dividend * timeToExpiry);
156
+ const discount = Math.exp(-riskFree * timeToExpiry);
157
+
158
+ const delta = sign * carry * normalCdf(sign * a);
159
+ const gamma = (carry * density) / (assetPrice * deviation * sqrtT);
160
+
161
+ // Scaled the way a desk quotes them: vega per one volatility point, rho per one rate point,
162
+ // theta per calendar day.
163
+ const vega = assetPrice * carry * density * sqrtT * 0.01;
164
+ const rho = sign * strike * timeToExpiry * discount * normalCdf(sign * b) * 0.01;
165
+
166
+ const theta = (
167
+ -(assetPrice * carry * density * deviation) / (2 * sqrtT)
168
+ - sign * riskFree * strike * discount * normalCdf(sign * b)
169
+ + sign * dividend * assetPrice * carry * normalCdf(sign * a)
170
+ ) / DAYS_IN_YEAR;
171
+
172
+ return { delta, gamma, vega, theta, rho };
173
+ }
174
+
175
+ /// The volatility that would produce this price, or null when none would.
176
+ ///
177
+ /// Bisection rather than Newton: vega collapses far from the money and deep in time, and a
178
+ /// Newton step divided by a vanishing vega walks off to nonsense. Halving cannot, and forty
179
+ /// steps over a range this wide is finer than any quote.
180
+ export function impliedVolatility(type: OptionType, inputs: OptionInputs, price: number): number | null {
181
+ if (!(price > 0) || inputs.timeToExpiry <= 0) return null;
182
+
183
+ const at = (deviation: number): number => premium(type, { ...inputs, deviation });
184
+
185
+ let low = 1e-6;
186
+ let high = 5;
187
+
188
+ // A price outside what any volatility in that range can produce has no answer, and
189
+ // returning the nearest bound would be a number that means nothing.
190
+ if (price < at(low) || price > at(high)) return null;
191
+
192
+ for (let step = 0; step < 60; step++) {
193
+ const mid = (low + high) / 2;
194
+ if (at(mid) < price) low = mid;
195
+ else high = mid;
196
+ }
197
+
198
+ return (low + high) / 2;
199
+ }
@@ -22,6 +22,10 @@ export const ControlTypes = {
22
22
  OrderBook: 'orderbook',
23
23
  TradeFeed: 'tradefeed',
24
24
  OrderEntry: 'orderEntry',
25
+ Statistics: 'statistics',
26
+ LogMonitor: 'logMonitor',
27
+ Strategies: 'strategies',
28
+ OptionDesk: 'optionDesk',
25
29
  } as const;
26
30
 
27
31
  export type ControlType = typeof ControlTypes[keyof typeof ControlTypes];
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  OrderType,
22
22
  PositionRow,
23
23
  QuoteStats,
24
+ StatisticRow,
24
25
  TradeRow,
25
26
  } from './trading-data.js';
26
27
 
@@ -67,6 +68,41 @@ export type { BookLevel, OrderBookFrame, QuoteLevel } from './trading-data.js';
67
68
  // where a class name cannot reach.
68
69
  export type { CanvasPalette } from './trading-host.js';
69
70
 
71
+ // The cumulative P&L curve, shared by everything that draws one: a strategy row's
72
+ // sparkline and a statistics card are the same curve at two sizes. A backtest's own
73
+ // equity is not here - that one shares the price axis and belongs to the chart engine.
74
+ export { compressPnl, drawPnlCurve, pnlCurve } from './pnl-curve.js';
75
+
76
+ export type { PnlBox, PnlCurve, PnlCurveContext, PnlCurveStyle, PnlPoint } from './pnl-curve.js';
77
+
78
+ export { OptionDeskWidget, greekPlaces, greekScales, scaleChain, sideGreeks } from './option-desk-widget.js';
79
+
80
+ export type { OptionChainContext, OptionDeskDeps, OptionSide, OptionStrike } from './option-desk-widget.js';
81
+
82
+ // Option pricing, for a host that has quotes and an expiry but no pricing service. A host
83
+ // that computes its own greeks sends them instead and none of this is reached.
84
+ export { OptionTypes, d1, d2, greeks, impliedVolatility, normalCdf, normalPdf, premium } from './black-scholes.js';
85
+
86
+ export type { Greeks, OptionInputs, OptionType } from './black-scholes.js';
87
+
88
+ export { StrategiesWidget, StrategyStates } from './strategies-widget.js';
89
+
90
+ export type { StrategiesActions, StrategiesDeps, StrategyRow, StrategyState } from './strategies-widget.js';
91
+
92
+ export { LogMonitorWidget } from './log-monitor-widget.js';
93
+
94
+ export type { LogMonitorDeps } from './log-monitor-widget.js';
95
+
96
+ // The log's own shapes: how sources nest, and which messages a view of them keeps. A host
97
+ // rendering its own view of the same feed reads these rather than reinventing the filter.
98
+ export { LogLevels, buildLogTree, keepLog, subtreeOf } from './log-tree.js';
99
+
100
+ export type { LogLevel, LogMessageRow, LogSourceNode, LogTreeNode, LogView } from './log-tree.js';
101
+
102
+ export { StatisticsWidget, formatStatistic } from './statistics-widget.js';
103
+
104
+ export type { StatisticsDeps } from './statistics-widget.js';
105
+
70
106
  export { TradeFeedWidget } from './tradefeed-widget.js';
71
107
 
72
108
  export type { TradeFeedDeps } from './tradefeed-widget.js';
@@ -81,3 +117,8 @@ export type { FeedBubble, FeedTick } from './tradefeed-aggregator.js';
81
117
  export { layoutBubbles } from './tradefeed-bubbles.js';
82
118
 
83
119
  export type { BubbleAxisTick, BubbleLane, BubbleLaneShape, BubbleLayout, BubbleLayoutInput, BubbleShape } from './tradefeed-bubbles.js';
120
+
121
+ // The grid's own menu, worded by the host. Exported because a host that builds a table of its
122
+ // own - a run's log beside the blotters, say - needs the same menu in the same words, and the
123
+ // alternative is every consumer restating thirty labels the package already knows.
124
+ export { makeGridMenu } from './grid-menu.js';
@@ -0,0 +1,312 @@
1
+ // Log monitor — multi-instance.
2
+ //
3
+ // Two panes: the tree of everything that writes a log on the left, and on the right what the
4
+ // selected source and everything under it has written. The desktop version copies each message
5
+ // into every ancestor's list, so one line is held as many times as it has ancestors and its own
6
+ // counter reports copies rather than messages. Here a message is held once and carries the id
7
+ // of the source that wrote it; selecting a node filters by its subtree.
8
+ //
9
+ // A live log has no end, so this one has a cap. The desktop's grows for the lifetime of the
10
+ // window - nothing trims it - which a browser tab cannot afford.
11
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
12
+ import { ControlTypes } from './control-types.js';
13
+ import { makeGridMenu } from './grid-menu.js';
14
+ import { TradingHost, assertHost } from './trading-host.js';
15
+ import {
16
+ LogLevels, buildLogTree, keepLog, subtreeOf,
17
+ type LogLevel, type LogMessageRow, type LogSourceNode, type LogTreeNode,
18
+ } from './log-tree.js';
19
+ import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
20
+
21
+ /// What the monitor needs beyond the host port. Nothing: a log is written to, not acted on.
22
+ export interface LogMonitorDeps {
23
+ host: TradingHost;
24
+ /// How many messages to keep. Older ones fall off the front. Defaults to 5000 - enough to
25
+ /// scroll back through a session, small enough that a chatty connector cannot fill a tab.
26
+ maxMessages?: number;
27
+ }
28
+
29
+ const DEFAULT_MAX = 5_000;
30
+
31
+ /// The letter a level shows in the narrow column, and the class that colours the row.
32
+ ///
33
+ /// Info gets a letter here. The desktop leaves its cell blank - there is simply no trigger for
34
+ /// it among the four that exist - so the commonest level is the one with no marking at all,
35
+ /// which is an omission rather than a decision.
36
+ const LEVEL_LETTER: Record<string, string> = {
37
+ [LogLevels.Error]: 'E',
38
+ [LogLevels.Warning]: 'W',
39
+ [LogLevels.Info]: 'I',
40
+ [LogLevels.Debug]: 'D',
41
+ [LogLevels.Verbose]: 'V',
42
+ };
43
+
44
+ const ALL_LEVELS: LogLevel[] = [LogLevels.Error, LogLevels.Warning, LogLevels.Info, LogLevels.Debug, LogLevels.Verbose];
45
+
46
+ export class LogMonitorWidget {
47
+ static TYPE = ControlTypes.LogMonitor;
48
+
49
+ rootEl: HTMLElement;
50
+ el: HTMLElement | null;
51
+ // `//` rather than `///` from here down — see the note in positions-widget.
52
+ _host: TradingHost;
53
+ _max: number;
54
+ _closeBtn: HTMLElement | null;
55
+ _clearBtn: HTMLElement | null;
56
+ _exportBtn: HTMLElement | null;
57
+ _treeEl: HTMLElement | null;
58
+ _filterEl: HTMLInputElement | null;
59
+ _sources: LogSourceNode[];
60
+ _messages: LogMessageRow[];
61
+ _levels: Set<string>;
62
+ _text: string;
63
+ _selected: string | null;
64
+ _grid: DataGrid<LogMessageRow> | null;
65
+
66
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: LogMonitorDeps): LogMonitorWidget {
67
+ const host = assertHost(deps?.host, 'LogMonitorWidget');
68
+ const root = LogMonitorWidget._buildRoot(host);
69
+ root.id = makePanelId(LogMonitorWidget.TYPE);
70
+ hostEl.appendChild(root);
71
+ return new LogMonitorWidget(root, state || {}, deps);
72
+ }
73
+
74
+ // The panel's markup: a source tree, a toolbar of level toggles and a text box, and the
75
+ // message table. The level toggles are buttons rather than checkboxes because they are read
76
+ // as a row of states, and each carries its letter so the toolbar and the column agree.
77
+ static _buildRoot(host: TradingHost): HTMLElement {
78
+ const title = host.t('LogMonitor');
79
+ return makePanelRoot('log-monitor-panel', title, [
80
+ makeElement('div', 'panel-header', {}, [
81
+ makeElement('span', '', {}, [title]),
82
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
83
+ ]),
84
+ makeElement('div', 'panel-body log-monitor-body', {}, [
85
+ makeElement('div', 'log-sources', { role: 'tree', 'aria-label': host.t('LogSources') }, []),
86
+ makeElement('div', 'log-messages', {}, [
87
+ makeElement('div', 'log-toolbar', { role: 'toolbar', 'aria-label': host.t('LogMonitorActions') }, [
88
+ ...ALL_LEVELS.map(level => makeElement('button', `log-level-toggle log-level-${level} is-on`, {
89
+ type: 'button',
90
+ 'data-level': level,
91
+ 'aria-pressed': 'true',
92
+ title: levelTitle(host, level),
93
+ }, [LEVEL_LETTER[level]])),
94
+ makeElement('input', 'form-control form-control-sm log-filter', {
95
+ type: 'search',
96
+ placeholder: host.t('Filter'),
97
+ 'aria-label': host.t('Filter'),
98
+ }, []),
99
+ makeIconButton('bt-icon-btn log-clear-btn', host.t('ClearItems'), 'bi-eraser', {}),
100
+ makeIconButton('bt-icon-btn panel-export-btn', host.t('ExportToExcel'), 'bi-file-earmark-spreadsheet', {}),
101
+ ]),
102
+ // The table sits in a box of its own rather than being a flex child: a table
103
+ // told to fill a column stretches its rows to do it, so a log holding one
104
+ // line drew that line a panel tall - and with the toolbar and the table as
105
+ // the only two children there was nowhere for a long log to scroll.
106
+ makeElement('div', 'log-table-scroll', {}, [
107
+ makeElement('table', 'terminal-table log-table', { role: 'table', 'aria-label': host.t('LogMessages') }, [
108
+ makeElement('thead', '', {}, []),
109
+ makeElement('tbody', 'log-body', {}, []),
110
+ ]),
111
+ ]),
112
+ ]),
113
+ ]),
114
+ ]);
115
+ }
116
+
117
+ constructor(rootEl: HTMLElement, _state: Record<string, unknown>, deps: LogMonitorDeps) {
118
+ this._host = assertHost(deps?.host, 'LogMonitorWidget');
119
+ this._max = deps?.maxMessages && deps.maxMessages > 0 ? deps.maxMessages : DEFAULT_MAX;
120
+
121
+ this.rootEl = rootEl;
122
+ this.el = this.rootEl.querySelector('.log-body');
123
+ this._treeEl = this.rootEl.querySelector('.log-sources');
124
+ this._filterEl = this.rootEl.querySelector('.log-filter');
125
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
126
+ this._clearBtn = this.rootEl.querySelector('.log-clear-btn');
127
+ this._exportBtn = this.rootEl.querySelector('.panel-export-btn');
128
+
129
+ this._sources = [];
130
+ this._messages = [];
131
+ this._levels = new Set<string>(ALL_LEVELS);
132
+ this._text = '';
133
+ this._selected = null;
134
+
135
+ this._closeBtn?.addEventListener('click', (e) => { e.preventDefault(); this._host.close(); });
136
+ this._clearBtn?.addEventListener('click', (e) => { e.preventDefault(); this.clear(); });
137
+ this._exportBtn?.addEventListener('click', (e) => { e.preventDefault(); this._export(); });
138
+
139
+ for (const toggle of Array.from(this.rootEl.querySelectorAll<HTMLElement>('.log-level-toggle'))) {
140
+ toggle.addEventListener('click', (e) => {
141
+ e.preventDefault();
142
+ const level = toggle.getAttribute('data-level') ?? '';
143
+ if (this._levels.has(level)) this._levels.delete(level);
144
+ else this._levels.add(level);
145
+ toggle.classList.toggle('is-on', this._levels.has(level));
146
+ toggle.setAttribute('aria-pressed', this._levels.has(level) ? 'true' : 'false');
147
+ this._render();
148
+ });
149
+ }
150
+
151
+ this._filterEl?.addEventListener('input', () => {
152
+ this._text = this._filterEl?.value ?? '';
153
+ this._render();
154
+ });
155
+
156
+ const head = this.rootEl.querySelector('.log-table thead');
157
+ this._grid = head && this.el
158
+ ? new DataGrid<LogMessageRow>({
159
+ head: head as HTMLElement,
160
+ body: this.el,
161
+ columns: this._columns(),
162
+ // The order it happened in. A log read out of order is not a log.
163
+ defaultSort: { col: 'time', dir: 'asc' },
164
+ rowKey: (m) => String(m.id),
165
+ emptyText: this._host.t('NoLogMessages'),
166
+ rowClass: (m) => `log-row log-row-${m.level}`,
167
+ contextMenu: makeGridMenu(this._host),
168
+ selection: 'multi',
169
+ })
170
+ : null;
171
+
172
+ this._host.register(this);
173
+ }
174
+
175
+ dispose(): void {
176
+ this._grid?.destroy();
177
+ this._host.unregister(this);
178
+ try { this.rootEl.remove(); } catch { /* already detached */ }
179
+ }
180
+
181
+ /// The sources that exist. Sent whole: a source that has gone is gone from the tree, and
182
+ /// the selection falls back to everything rather than to a node nobody can see.
183
+ setSources(sources: LogSourceNode[]): void {
184
+ this._sources = sources || [];
185
+ if (this._selected !== null && !this._sources.some(s => s.id === this._selected)) this._selected = null;
186
+ this._renderTree();
187
+ this._render();
188
+ }
189
+
190
+ /// Add what has just been written. The oldest fall off the front once the cap is reached.
191
+ append(messages: LogMessageRow[]): void {
192
+ if (!messages || messages.length === 0) return;
193
+
194
+ this._messages.push(...messages);
195
+ if (this._messages.length > this._max) this._messages.splice(0, this._messages.length - this._max);
196
+ this._render();
197
+ }
198
+
199
+ /// Forget every message. The sources stay: they still exist, they have just gone quiet.
200
+ clear(): void {
201
+ this._messages = [];
202
+ this._render();
203
+ }
204
+
205
+ /// Show one source's subtree, or everything when given null.
206
+ select(sourceId: string | null): void {
207
+ this._selected = sourceId;
208
+ this._renderTree();
209
+ this._render();
210
+ }
211
+
212
+ /// What is on screen, after every filter.
213
+ visible(): LogMessageRow[] {
214
+ const sources = subtreeOf(this._sources, this._selected);
215
+ return this._messages.filter(m => keepLog(m, { levels: this._levels, text: this._text, sources }));
216
+ }
217
+
218
+ _render(): void {
219
+ this._grid?.setRows(this.visible());
220
+ }
221
+
222
+ _renderTree(): void {
223
+ const tree = this._treeEl;
224
+ if (tree === null) return;
225
+
226
+ tree.innerHTML = '';
227
+ tree.appendChild(this._treeRow({ id: '', name: this._host.t('AllSources'), depth: 0, children: [] }, null));
228
+ for (const root of buildLogTree(this._sources)) this._appendNode(tree, root);
229
+ }
230
+
231
+ _appendNode(into: HTMLElement, node: LogTreeNode): void {
232
+ into.appendChild(this._treeRow(node, node.id));
233
+ for (const child of node.children) this._appendNode(into, child);
234
+ }
235
+
236
+ // Flat rows with an indent rather than nested lists: the tree is read, not restructured,
237
+ // and one list is what a keyboard walks through in the order the eye does.
238
+ _treeRow(node: LogTreeNode, id: string | null): HTMLElement {
239
+ const selected = this._selected === id;
240
+ const row = makeElement('button', `log-source${selected ? ' is-selected' : ''}`, {
241
+ type: 'button',
242
+ role: 'treeitem',
243
+ 'aria-selected': selected ? 'true' : 'false',
244
+ 'aria-level': String(node.depth + 1),
245
+ style: `padding-left: ${0.5 + node.depth * 0.85}rem`,
246
+ }, [node.name]);
247
+
248
+ row.addEventListener('click', (e) => { e.preventDefault(); this.select(id); });
249
+ return row;
250
+ }
251
+
252
+ _export(): void {
253
+ this._grid?.download('log', this._host.t('LogMonitor'));
254
+ }
255
+
256
+ _columns(): GridColumn<LogMessageRow>[] {
257
+ const label = (key: string) => this._host.t(key);
258
+ return [
259
+ {
260
+ key: 'source',
261
+ header: label('Source'),
262
+ exportable: true,
263
+ value: (m) => m.source ?? this._sourceName(m.sourceId),
264
+ },
265
+ {
266
+ key: 'time',
267
+ header: label('Time'),
268
+ exportable: true,
269
+ value: (m) => m.time,
270
+ render: (m) => this._host.presentation.timeText(m.time),
271
+ exportValue: (m) => this._host.presentation.timeText(m.time),
272
+ },
273
+ {
274
+ key: 'level',
275
+ header: label('Type'),
276
+ headerClass: 'log-level-col',
277
+ exportable: true,
278
+ cellClass: (m) => `log-level-cell log-level-${m.level}`,
279
+ value: (m) => m.level,
280
+ render: (m) => LEVEL_LETTER[m.level] ?? String(m.level ?? '').slice(0, 1).toUpperCase(),
281
+ // The sheet carries the level's name, not the letter the column has room for.
282
+ exportValue: (m) => m.level,
283
+ },
284
+ {
285
+ key: 'message',
286
+ header: label('Message'),
287
+ exportable: true,
288
+ cellClass: () => 'log-message-cell',
289
+ value: (m) => m.message,
290
+ },
291
+ ];
292
+ }
293
+
294
+ _sourceName(id: string): string {
295
+ return this._sources.find(s => s.id === id)?.name ?? id;
296
+ }
297
+ }
298
+
299
+ /// What a level's toggle is called.
300
+ ///
301
+ /// Literal keys at the point they are asked for - see the note on the same shape in
302
+ /// strategies-widget: a key built from a value is invisible to the scan that generates
303
+ /// `translation-keys.json`, and reaches the user untranslated.
304
+ function levelTitle(host: TradingHost, level: LogLevel): string {
305
+ switch (level) {
306
+ case LogLevels.Error: return host.t('Errors');
307
+ case LogLevels.Warning: return host.t('Warnings');
308
+ case LogLevels.Info: return host.t('Messages');
309
+ case LogLevels.Debug: return host.t('Debug');
310
+ default: return host.t('Verbose');
311
+ }
312
+ }