@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.
- package/README.md +204 -17
- package/dist/esm/black-scholes.js +147 -0
- package/dist/esm/black-scholes.js.map +1 -0
- package/dist/esm/chart-engine.js +15 -0
- package/dist/esm/chart-engine.js.map +1 -0
- package/dist/esm/control-types.js +8 -0
- package/dist/esm/control-types.js.map +1 -1
- package/dist/esm/equity-widget.js +261 -0
- package/dist/esm/equity-widget.js.map +1 -0
- package/dist/esm/heatmap-grid.js +271 -0
- package/dist/esm/heatmap-grid.js.map +1 -0
- package/dist/esm/index.js +37 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/log-monitor-widget.js +265 -0
- package/dist/esm/log-monitor-widget.js.map +1 -0
- package/dist/esm/log-tree.js +96 -0
- package/dist/esm/log-tree.js.map +1 -0
- package/dist/esm/optimization-heatmap-widget.js +303 -0
- package/dist/esm/optimization-heatmap-widget.js.map +1 -0
- package/dist/esm/option-desk-widget.js +322 -0
- package/dist/esm/option-desk-widget.js.map +1 -0
- package/dist/esm/option-smile-widget.js +252 -0
- package/dist/esm/option-smile-widget.js.map +1 -0
- package/dist/esm/pnl-curve.js +133 -0
- package/dist/esm/pnl-curve.js.map +1 -0
- package/dist/esm/statistics-widget.js +194 -0
- package/dist/esm/statistics-widget.js.map +1 -0
- package/dist/esm/strategies-widget.js +348 -0
- package/dist/esm/strategies-widget.js.map +1 -0
- package/dist/esm/surface-grid.js +282 -0
- package/dist/esm/surface-grid.js.map +1 -0
- package/dist/esm/surface-widget.js +379 -0
- package/dist/esm/surface-widget.js.map +1 -0
- package/dist/sstradingcontrols.js +2982 -295
- package/dist/sstradingcontrols.js.map +4 -4
- package/dist/types/black-scholes.d.ts +28 -0
- package/dist/types/black-scholes.d.ts.map +1 -0
- package/dist/types/chart-engine.d.ts +3 -0
- package/dist/types/chart-engine.d.ts.map +1 -0
- package/dist/types/control-types.d.ts +8 -0
- package/dist/types/control-types.d.ts.map +1 -1
- package/dist/types/equity-widget.d.ts +34 -0
- package/dist/types/equity-widget.d.ts.map +1 -0
- package/dist/types/heatmap-grid.d.ts +88 -0
- package/dist/types/heatmap-grid.d.ts.map +1 -0
- package/dist/types/index.d.ts +31 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/log-monitor-widget.d.ts +42 -0
- package/dist/types/log-monitor-widget.d.ts.map +1 -0
- package/dist/types/log-tree.d.ts +34 -0
- package/dist/types/log-tree.d.ts.map +1 -0
- package/dist/types/optimization-heatmap-widget.d.ts +45 -0
- package/dist/types/optimization-heatmap-widget.d.ts.map +1 -0
- package/dist/types/option-desk-widget.d.ts +68 -0
- package/dist/types/option-desk-widget.d.ts.map +1 -0
- package/dist/types/option-smile-widget.d.ts +39 -0
- package/dist/types/option-smile-widget.d.ts.map +1 -0
- package/dist/types/pnl-curve.d.ts +46 -0
- package/dist/types/pnl-curve.d.ts.map +1 -0
- package/dist/types/statistics-widget.d.ts +29 -0
- package/dist/types/statistics-widget.d.ts.map +1 -0
- package/dist/types/strategies-widget.d.ts +63 -0
- package/dist/types/strategies-widget.d.ts.map +1 -0
- package/dist/types/surface-grid.d.ts +64 -0
- package/dist/types/surface-grid.d.ts.map +1 -0
- package/dist/types/surface-widget.d.ts +64 -0
- package/dist/types/surface-widget.d.ts.map +1 -0
- package/dist/types/trading-data.d.ts +9 -0
- package/dist/types/trading-data.d.ts.map +1 -1
- package/package.json +54 -3
- package/screenshots/equity.png +0 -0
- package/screenshots/log-monitor.png +0 -0
- package/screenshots/optimization-surface.png +0 -0
- package/screenshots/optimization.png +0 -0
- package/screenshots/option-desk.png +0 -0
- package/screenshots/option-smile.png +0 -0
- package/screenshots/panels.jpg +0 -0
- package/screenshots/statistics.png +0 -0
- package/screenshots/strategies.png +0 -0
- package/src/black-scholes.ts +199 -0
- package/src/chart-engine.global.ts +64 -0
- package/src/chart-engine.ts +29 -0
- package/src/control-types.ts +8 -0
- package/src/equity-widget.ts +289 -0
- package/src/heatmap-grid.ts +405 -0
- package/src/index.ts +87 -0
- package/src/log-monitor-widget.ts +312 -0
- package/src/log-tree.ts +131 -0
- package/src/optimization-heatmap-widget.ts +347 -0
- package/src/option-desk-widget.ts +422 -0
- package/src/option-smile-widget.ts +281 -0
- package/src/pnl-curve.ts +216 -0
- package/src/statistics-widget.ts +226 -0
- package/src/strategies-widget.ts +435 -0
- package/src/surface-grid.ts +410 -0
- package/src/surface-widget.ts +457 -0
- package/src/trading-data.ts +22 -0
- package/styles/trading-controls.css +637 -0
- package/translation-keys.json +83 -1
|
@@ -24,8 +24,22 @@ var SSTradingControls = (() => {
|
|
|
24
24
|
var index_exports = {};
|
|
25
25
|
__export(index_exports, {
|
|
26
26
|
ActiveOrdersWidget: () => ActiveOrdersWidget,
|
|
27
|
+
AreaSeries: () => AreaSeries,
|
|
27
28
|
ControlTypes: () => ControlTypes,
|
|
29
|
+
CrosshairMode: () => CrosshairMode,
|
|
30
|
+
DEFAULT_VIEW: () => DEFAULT_VIEW,
|
|
31
|
+
EquityWidget: () => EquityWidget,
|
|
32
|
+
HeatDirections: () => HeatDirections,
|
|
33
|
+
LineSeries: () => LineSeries,
|
|
34
|
+
LogLevels: () => LogLevels,
|
|
35
|
+
LogMonitorWidget: () => LogMonitorWidget,
|
|
36
|
+
MAX_PITCH: () => MAX_PITCH,
|
|
37
|
+
MIN_PITCH: () => MIN_PITCH,
|
|
28
38
|
MarketDataLevels: () => MarketDataLevels,
|
|
39
|
+
OptimizationHeatmapWidget: () => OptimizationHeatmapWidget,
|
|
40
|
+
OptionDeskWidget: () => OptionDeskWidget,
|
|
41
|
+
OptionSmileWidget: () => OptionSmileWidget,
|
|
42
|
+
OptionTypes: () => OptionTypes,
|
|
29
43
|
OrderBookWidget: () => OrderBookWidget,
|
|
30
44
|
OrderEntrySides: () => OrderEntrySides,
|
|
31
45
|
OrderEntryTypes: () => OrderEntryTypes,
|
|
@@ -33,22 +47,59 @@ var SSTradingControls = (() => {
|
|
|
33
47
|
OrderStates: () => OrderStates,
|
|
34
48
|
PRESENTATION_CLASSES: () => PRESENTATION_CLASSES,
|
|
35
49
|
PositionsWidget: () => PositionsWidget,
|
|
50
|
+
StatisticsWidget: () => StatisticsWidget,
|
|
51
|
+
StrategiesWidget: () => StrategiesWidget,
|
|
52
|
+
StrategyStates: () => StrategyStates,
|
|
53
|
+
SurfaceWidget: () => SurfaceWidget,
|
|
36
54
|
TradeFeedWidget: () => TradeFeedWidget,
|
|
37
55
|
TradeHistoryWidget: () => TradeHistoryWidget,
|
|
38
56
|
WatchlistWidget: () => WatchlistWidget,
|
|
39
57
|
aggregateBubbles: () => aggregateBubbles,
|
|
40
58
|
assertHost: () => assertHost,
|
|
59
|
+
buildLogTree: () => buildLogTree,
|
|
60
|
+
clampView: () => clampView,
|
|
41
61
|
cleanRejectReason: () => cleanRejectReason,
|
|
62
|
+
compressPnl: () => compressPnl,
|
|
63
|
+
createChart: () => createChart,
|
|
64
|
+
d1: () => d1,
|
|
65
|
+
d2: () => d2,
|
|
66
|
+
dragView: () => dragView,
|
|
67
|
+
drawPnlCurve: () => drawPnlCurve,
|
|
42
68
|
formatPnl: () => formatPnl,
|
|
43
69
|
formatPrice: () => formatPrice,
|
|
44
70
|
formatQty: () => formatQty,
|
|
71
|
+
formatStatistic: () => formatStatistic,
|
|
45
72
|
formatTime: () => formatTime,
|
|
73
|
+
greekPlaces: () => greekPlaces,
|
|
74
|
+
greekScales: () => greekScales,
|
|
75
|
+
greeks: () => greeks,
|
|
76
|
+
heatScale: () => heatScale,
|
|
77
|
+
hitHeatmap: () => hitHeatmap,
|
|
78
|
+
impliedVolatility: () => impliedVolatility,
|
|
79
|
+
keepLog: () => keepLog,
|
|
46
80
|
layoutBubbles: () => layoutBubbles,
|
|
81
|
+
layoutHeatmap: () => layoutHeatmap,
|
|
47
82
|
makeElement: () => makeElement,
|
|
83
|
+
makeGridMenu: () => makeGridMenu,
|
|
48
84
|
makeIcon: () => makeIcon,
|
|
49
85
|
makeIconButton: () => makeIconButton,
|
|
50
86
|
makePanelId: () => makePanelId,
|
|
51
|
-
makePanelRoot: () => makePanelRoot
|
|
87
|
+
makePanelRoot: () => makePanelRoot,
|
|
88
|
+
normalCdf: () => normalCdf,
|
|
89
|
+
normalPdf: () => normalPdf,
|
|
90
|
+
pnlCurve: () => pnlCurve,
|
|
91
|
+
premium: () => premium,
|
|
92
|
+
project: () => project,
|
|
93
|
+
scaleChain: () => scaleChain,
|
|
94
|
+
sideGreeks: () => sideGreeks,
|
|
95
|
+
sideVolatility: () => sideVolatility,
|
|
96
|
+
sortedChain: () => sortedChain,
|
|
97
|
+
subtreeOf: () => subtreeOf,
|
|
98
|
+
surfaceLayout: () => surfaceLayout,
|
|
99
|
+
tintOf: () => tintOf,
|
|
100
|
+
toSmileSeries: () => toSmileSeries,
|
|
101
|
+
valueAt: () => valueAt,
|
|
102
|
+
zoomView: () => zoomView
|
|
52
103
|
});
|
|
53
104
|
|
|
54
105
|
// src/trading-host.ts
|
|
@@ -120,13 +171,21 @@ var SSTradingControls = (() => {
|
|
|
120
171
|
Positions: "positions",
|
|
121
172
|
OrderBook: "orderbook",
|
|
122
173
|
TradeFeed: "tradefeed",
|
|
123
|
-
OrderEntry: "orderEntry"
|
|
174
|
+
OrderEntry: "orderEntry",
|
|
175
|
+
Statistics: "statistics",
|
|
176
|
+
LogMonitor: "logMonitor",
|
|
177
|
+
Strategies: "strategies",
|
|
178
|
+
OptionDesk: "optionDesk",
|
|
179
|
+
OptionSmile: "optionSmile",
|
|
180
|
+
Equity: "equity",
|
|
181
|
+
OptimizationHeatmap: "optimizationHeatmap",
|
|
182
|
+
OptimizationSurface: "optimizationSurface"
|
|
124
183
|
};
|
|
125
184
|
|
|
126
185
|
// src/formatters.ts
|
|
127
|
-
function formatPrice(
|
|
128
|
-
if (
|
|
129
|
-
const n = Number(
|
|
186
|
+
function formatPrice(price2) {
|
|
187
|
+
if (price2 == null || isNaN(Number(price2))) return "--";
|
|
188
|
+
const n = Number(price2);
|
|
130
189
|
const a = Math.abs(n);
|
|
131
190
|
let prec = 2;
|
|
132
191
|
if (a < 1) prec = 4;
|
|
@@ -815,6 +874,13 @@ var SSTradingControls = (() => {
|
|
|
815
874
|
copyRows: (count) => `Copy selected rows (${count})`,
|
|
816
875
|
exportXlsx: "Export to .xlsx"
|
|
817
876
|
};
|
|
877
|
+
var CONTROL_TAGS = ["SELECT", "INPUT", "BUTTON", "TEXTAREA", "A", "OPTION", "LABEL"];
|
|
878
|
+
function isControl(event) {
|
|
879
|
+
const target = event.target;
|
|
880
|
+
if (target === null || target === void 0) return false;
|
|
881
|
+
if (target.isContentEditable === true) return true;
|
|
882
|
+
return CONTROL_TAGS.includes(String(target.tagName ?? "").toUpperCase());
|
|
883
|
+
}
|
|
818
884
|
var _DataGrid = class _DataGrid {
|
|
819
885
|
constructor(options) {
|
|
820
886
|
__publicField(this, "options");
|
|
@@ -1727,6 +1793,7 @@ var SSTradingControls = (() => {
|
|
|
1727
1793
|
tr.addEventListener("mousedown", (event) => {
|
|
1728
1794
|
const button = event.button;
|
|
1729
1795
|
if (button !== void 0 && button !== 0) return;
|
|
1796
|
+
if (isControl(event)) return;
|
|
1730
1797
|
event.preventDefault();
|
|
1731
1798
|
_DataGrid._active = this;
|
|
1732
1799
|
this._clickRow(key, event);
|
|
@@ -1788,6 +1855,7 @@ var SSTradingControls = (() => {
|
|
|
1788
1855
|
if (group) group.rows.push(row);
|
|
1789
1856
|
else groups.set(key, { label: col ? this._displayText(col, row) : key, rows: [row] });
|
|
1790
1857
|
}
|
|
1858
|
+
if (this.options.groupOrder === "rows") return groups;
|
|
1791
1859
|
return new Map([...groups.entries()].sort((a, b) => this._collator.compare(a[1].label, b[1].label)));
|
|
1792
1860
|
}
|
|
1793
1861
|
_groupRow(key, label, count) {
|
|
@@ -1802,6 +1870,8 @@ var SSTradingControls = (() => {
|
|
|
1802
1870
|
else td.appendChild(content);
|
|
1803
1871
|
tr.appendChild(td);
|
|
1804
1872
|
tr.addEventListener("click", () => this.toggleGroup(key));
|
|
1873
|
+
tr.style.cursor = "pointer";
|
|
1874
|
+
tr.style.userSelect = "none";
|
|
1805
1875
|
return tr;
|
|
1806
1876
|
}
|
|
1807
1877
|
_emptyRow() {
|
|
@@ -2792,8 +2862,8 @@ var SSTradingControls = (() => {
|
|
|
2792
2862
|
if (previous) this._grid.rowElement(String(previous))?.classList.remove("wl-current");
|
|
2793
2863
|
if (symbol) this._grid.rowElement(String(symbol))?.classList.add("wl-current");
|
|
2794
2864
|
}
|
|
2795
|
-
onPriceUpdate(symbol,
|
|
2796
|
-
if (
|
|
2865
|
+
onPriceUpdate(symbol, price2) {
|
|
2866
|
+
if (price2 == null || !isFinite(price2)) return;
|
|
2797
2867
|
let full = symbol;
|
|
2798
2868
|
if (!full.includes("@")) {
|
|
2799
2869
|
const match = this.instruments.find((i) => String(i.symbol).split("@")[0] === symbol);
|
|
@@ -2808,11 +2878,11 @@ var SSTradingControls = (() => {
|
|
|
2808
2878
|
baseline = stored ? Number(stored) : void 0;
|
|
2809
2879
|
}
|
|
2810
2880
|
if (baseline == null || !isFinite(baseline) || baseline === 0) {
|
|
2811
|
-
baseline =
|
|
2812
|
-
this._host.cache.set(baselineKey, String(
|
|
2881
|
+
baseline = price2;
|
|
2882
|
+
this._host.cache.set(baselineKey, String(price2));
|
|
2813
2883
|
}
|
|
2814
|
-
const chgPct = baseline ? (
|
|
2815
|
-
this.stats.set(full, { ...cur || {}, lastPrice:
|
|
2884
|
+
const chgPct = baseline ? (price2 - baseline) / baseline * 100 : 0;
|
|
2885
|
+
this.stats.set(full, { ...cur || {}, lastPrice: price2, baseline, chgPct });
|
|
2816
2886
|
this._updateRow(full, prev);
|
|
2817
2887
|
}
|
|
2818
2888
|
static _baselineKey(symbol) {
|
|
@@ -3018,9 +3088,9 @@ var SSTradingControls = (() => {
|
|
|
3018
3088
|
if (!this._host.isPrimary) return;
|
|
3019
3089
|
this._host.ticker.publish(this._visible().map((i) => String(i.symbol)), this.stats);
|
|
3020
3090
|
}
|
|
3021
|
-
static _priceText(
|
|
3022
|
-
const n = Number(
|
|
3023
|
-
if (
|
|
3091
|
+
static _priceText(price2) {
|
|
3092
|
+
const n = Number(price2);
|
|
3093
|
+
if (price2 == null || !isFinite(n) || n === 0) return "--";
|
|
3024
3094
|
if (n >= 1) return n.toFixed(2);
|
|
3025
3095
|
if (n >= 0.01) return n.toFixed(4);
|
|
3026
3096
|
return n.toFixed(6);
|
|
@@ -3292,15 +3362,15 @@ var SSTradingControls = (() => {
|
|
|
3292
3362
|
}
|
|
3293
3363
|
/// A reference price off the tape. Both columns follow the market while the
|
|
3294
3364
|
/// form is untouched; the field the trader is typing in is left alone.
|
|
3295
|
-
setLimitPrice(
|
|
3296
|
-
this._lastPrice =
|
|
3297
|
-
this._writePrices(this._bestBid > 0 ? this._bestBid :
|
|
3365
|
+
setLimitPrice(price2) {
|
|
3366
|
+
this._lastPrice = price2;
|
|
3367
|
+
this._writePrices(this._bestBid > 0 ? this._bestBid : price2, this._bestAsk > 0 ? this._bestAsk : price2, false);
|
|
3298
3368
|
}
|
|
3299
3369
|
/// The same reference price, force-written: an explicit host gesture
|
|
3300
3370
|
/// ("price this order here") outranks whatever was typed.
|
|
3301
|
-
seedLimitPrice(
|
|
3302
|
-
this._lastPrice =
|
|
3303
|
-
this._writePrices(this._bestBid > 0 ? this._bestBid :
|
|
3371
|
+
seedLimitPrice(price2) {
|
|
3372
|
+
this._lastPrice = price2;
|
|
3373
|
+
this._writePrices(this._bestBid > 0 ? this._bestBid : price2, this._bestAsk > 0 ? this._bestAsk : price2, true);
|
|
3304
3374
|
}
|
|
3305
3375
|
/// Best bid and offer. The passive seed for a limit form is Buy = best bid
|
|
3306
3376
|
/// and Sell = best ask — joining the book on each side, so the spread in the
|
|
@@ -3325,10 +3395,10 @@ var SSTradingControls = (() => {
|
|
|
3325
3395
|
/// Force one column's price and let ticks resume updating it afterwards —
|
|
3326
3396
|
/// what a click on a book level or a chart price means. Quantized to the
|
|
3327
3397
|
/// instrument's tick, so a raw chart coordinate does not fail validation.
|
|
3328
|
-
setPrice(side,
|
|
3398
|
+
setPrice(side, price2) {
|
|
3329
3399
|
const input = this._input(side, PRICE_INPUT);
|
|
3330
3400
|
if (!input) return;
|
|
3331
|
-
const text = this._formatPriceForInput(
|
|
3401
|
+
const text = this._formatPriceForInput(price2);
|
|
3332
3402
|
if (!text) return;
|
|
3333
3403
|
input.dataset.userEdited = "0";
|
|
3334
3404
|
input.value = text;
|
|
@@ -3560,10 +3630,10 @@ var SSTradingControls = (() => {
|
|
|
3560
3630
|
this._writePrice(OrderEntrySides.Sell, sellPrice, reset);
|
|
3561
3631
|
for (const side of _OrderEntryWidget.SIDES) this._recalculate(side);
|
|
3562
3632
|
}
|
|
3563
|
-
_writePrice(side,
|
|
3633
|
+
_writePrice(side, price2, reset) {
|
|
3564
3634
|
const input = this._input(side, PRICE_INPUT);
|
|
3565
3635
|
if (!input) return;
|
|
3566
|
-
const text = this._formatPriceForInput(
|
|
3636
|
+
const text = this._formatPriceForInput(price2);
|
|
3567
3637
|
if (!text) return;
|
|
3568
3638
|
if (reset) {
|
|
3569
3639
|
input.dataset.userEdited = "0";
|
|
@@ -3576,8 +3646,8 @@ var SSTradingControls = (() => {
|
|
|
3576
3646
|
}
|
|
3577
3647
|
// Round a price to the instrument's tick and cut the float tail: a mid price
|
|
3578
3648
|
// of (bid+ask)/2 otherwise reaches the field as 0.44625000000000004.
|
|
3579
|
-
_formatPriceForInput(
|
|
3580
|
-
const n = Number(
|
|
3649
|
+
_formatPriceForInput(price2) {
|
|
3650
|
+
const n = Number(price2);
|
|
3581
3651
|
if (!isFinite(n)) return "";
|
|
3582
3652
|
const tickSize = Number(this._instrument?.tickSize) || 0;
|
|
3583
3653
|
if (tickSize > 0)
|
|
@@ -3595,16 +3665,16 @@ var SSTradingControls = (() => {
|
|
|
3595
3665
|
const total = this._cols[side]?.querySelector(TOTAL_VALUE);
|
|
3596
3666
|
if (!total) return;
|
|
3597
3667
|
const quantity = this._number(side, QTY_INPUT) ?? 0;
|
|
3598
|
-
let
|
|
3668
|
+
let price2;
|
|
3599
3669
|
if (this._orderType === OrderEntryTypes.Limit || this._orderType === OrderEntryTypes.StopLimit) {
|
|
3600
|
-
|
|
3670
|
+
price2 = this._number(side, PRICE_INPUT) ?? 0;
|
|
3601
3671
|
} else if (this._orderType === OrderEntryTypes.Stop) {
|
|
3602
|
-
|
|
3672
|
+
price2 = this._number(side, STOP_INPUT) ?? this._lastPrice;
|
|
3603
3673
|
} else {
|
|
3604
3674
|
const bbo = side === OrderEntrySides.Buy ? this._bestAsk : this._bestBid;
|
|
3605
|
-
|
|
3675
|
+
price2 = bbo > 0 ? bbo : this._lastPrice;
|
|
3606
3676
|
}
|
|
3607
|
-
total.textContent = quantity > 0 &&
|
|
3677
|
+
total.textContent = quantity > 0 && price2 > 0 ? (quantity * price2).toLocaleString(void 0, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "--";
|
|
3608
3678
|
}
|
|
3609
3679
|
// The estimate line doubles as the validation message and the submit button
|
|
3610
3680
|
// follows it. A disabled pad reads as invalid for the same reason: there is
|
|
@@ -3634,20 +3704,20 @@ var SSTradingControls = (() => {
|
|
|
3634
3704
|
var OrderEntryWidget = _OrderEntryWidget;
|
|
3635
3705
|
|
|
3636
3706
|
// src/orderbook-depth.ts
|
|
3637
|
-
function depthSide(levels,
|
|
3707
|
+
function depthSide(levels, direction2, geometry) {
|
|
3638
3708
|
if (levels.length === 0) return null;
|
|
3639
3709
|
const best = levels[0].price;
|
|
3640
3710
|
const worst = levels[levels.length - 1].price;
|
|
3641
3711
|
const span = Math.abs(best - worst);
|
|
3642
3712
|
if (!(span > 0)) return null;
|
|
3643
3713
|
const reach = geometry.halfWidth - geometry.pad;
|
|
3644
|
-
const points = [{ x: geometry.midX +
|
|
3714
|
+
const points = [{ x: geometry.midX + direction2 * geometry.pad, cumulative: 0 }];
|
|
3645
3715
|
let cumulative = 0;
|
|
3646
3716
|
for (const level of levels) {
|
|
3647
3717
|
cumulative += level.quantity;
|
|
3648
3718
|
const distance = Math.abs(level.price - best);
|
|
3649
3719
|
points.push({
|
|
3650
|
-
x: geometry.midX +
|
|
3720
|
+
x: geometry.midX + direction2 * (geometry.pad + distance / span * reach),
|
|
3651
3721
|
cumulative
|
|
3652
3722
|
});
|
|
3653
3723
|
}
|
|
@@ -4009,11 +4079,11 @@ var SSTradingControls = (() => {
|
|
|
4009
4079
|
const target = e.target;
|
|
4010
4080
|
const row = target?.closest(".ob-row");
|
|
4011
4081
|
if (!row) return;
|
|
4012
|
-
const
|
|
4013
|
-
if (!Number.isFinite(
|
|
4082
|
+
const price2 = Number(row.dataset.price);
|
|
4083
|
+
if (!Number.isFinite(price2)) return;
|
|
4014
4084
|
const side = row.dataset.side === "buy" ? 0 : 1;
|
|
4015
|
-
if (e.ctrlKey || e.metaKey) this._deps.onPriceExecuted(
|
|
4016
|
-
else this._deps.onPriceSelected(
|
|
4085
|
+
if (e.ctrlKey || e.metaKey) this._deps.onPriceExecuted(price2, side);
|
|
4086
|
+
else this._deps.onPriceSelected(price2, side);
|
|
4017
4087
|
});
|
|
4018
4088
|
}
|
|
4019
4089
|
_refreshDepthButtons() {
|
|
@@ -4072,8 +4142,8 @@ var SSTradingControls = (() => {
|
|
|
4072
4142
|
static _deepest() {
|
|
4073
4143
|
return _OrderBookWidget.DEPTHS[_OrderBookWidget.DEPTHS.length - 1];
|
|
4074
4144
|
}
|
|
4075
|
-
static _sorted(ledger,
|
|
4076
|
-
return [...ledger.entries()].filter(([
|
|
4145
|
+
static _sorted(ledger, direction2) {
|
|
4146
|
+
return [...ledger.entries()].filter(([price2, quantity]) => Number.isFinite(price2) && quantity > 0).sort((left, right) => direction2 * (left[0] - right[0])).map(([price2, quantity]) => ({ price: price2, quantity }));
|
|
4077
4147
|
}
|
|
4078
4148
|
_applySnapshotSide(ledger, levels, side, symbol, sequence) {
|
|
4079
4149
|
for (const level of levels || []) {
|
|
@@ -4113,8 +4183,8 @@ var SSTradingControls = (() => {
|
|
|
4113
4183
|
if (this._bidsByPrice.size === 0 || this._asksByPrice.size === 0) return;
|
|
4114
4184
|
let bestBid = -Infinity;
|
|
4115
4185
|
let bestAsk = Infinity;
|
|
4116
|
-
for (const
|
|
4117
|
-
for (const
|
|
4186
|
+
for (const price2 of this._bidsByPrice.keys()) if (price2 > bestBid) bestBid = price2;
|
|
4187
|
+
for (const price2 of this._asksByPrice.keys()) if (price2 < bestAsk) bestAsk = price2;
|
|
4118
4188
|
if (bestBid >= bestAsk) {
|
|
4119
4189
|
this._anomaly(`crossed book: bid=${bestBid} >= ask=${bestAsk} sym=${symbol} seq=${sequence} ${wasSnapshot ? "snapshot" : "diff"}`);
|
|
4120
4190
|
}
|
|
@@ -4250,8 +4320,8 @@ var SSTradingControls = (() => {
|
|
|
4250
4320
|
}
|
|
4251
4321
|
return { bids, asks };
|
|
4252
4322
|
}
|
|
4253
|
-
static _priceKey(
|
|
4254
|
-
return Number(
|
|
4323
|
+
static _priceKey(price2) {
|
|
4324
|
+
return Number(price2).toFixed(8);
|
|
4255
4325
|
}
|
|
4256
4326
|
// The bid/ask split under the ladder, as one measurement the stylesheet
|
|
4257
4327
|
// turns into two widths.
|
|
@@ -4354,259 +4424,182 @@ var SSTradingControls = (() => {
|
|
|
4354
4424
|
_OrderBookWidget.DEPTHCHART_KEY = "terminal.obDepthChart";
|
|
4355
4425
|
var OrderBookWidget = _OrderBookWidget;
|
|
4356
4426
|
|
|
4357
|
-
// src/
|
|
4358
|
-
function
|
|
4359
|
-
if (
|
|
4360
|
-
const
|
|
4361
|
-
const
|
|
4362
|
-
const
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4427
|
+
// src/pnl-curve.ts
|
|
4428
|
+
function compressPnl(points, count) {
|
|
4429
|
+
if (points.length <= count || count < 2) return points.slice();
|
|
4430
|
+
const first = points[0];
|
|
4431
|
+
const last = points[points.length - 1];
|
|
4432
|
+
const span = last.time - first.time;
|
|
4433
|
+
if (!(span > 0)) {
|
|
4434
|
+
const step = (points.length - 1) / (count - 1);
|
|
4435
|
+
return Array.from({ length: count }, (_, i) => points[Math.round(i * step)]);
|
|
4436
|
+
}
|
|
4437
|
+
const buckets = count - 1;
|
|
4438
|
+
const out = [first];
|
|
4439
|
+
let bucket = 0;
|
|
4440
|
+
for (let i = 1; i < points.length; i++) {
|
|
4441
|
+
const index = Math.min(buckets - 1, Math.floor((points[i].time - first.time) / span * buckets));
|
|
4442
|
+
if (index > bucket) {
|
|
4443
|
+
out.push(points[i - 1]);
|
|
4444
|
+
bucket = index;
|
|
4370
4445
|
}
|
|
4371
|
-
const bucketSize = Math.ceil(side.length / budget);
|
|
4372
|
-
for (let start = 0; start < side.length; start += bucketSize)
|
|
4373
|
-
bubbles.push(merge(side.slice(start, start + bucketSize)));
|
|
4374
4446
|
}
|
|
4375
|
-
|
|
4447
|
+
if (out[out.length - 1] !== last) out.push(last);
|
|
4448
|
+
return out;
|
|
4376
4449
|
}
|
|
4377
|
-
function
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
const
|
|
4386
|
-
const
|
|
4450
|
+
function pnlCurve(points, box) {
|
|
4451
|
+
const clean = points.filter((p) => p !== null && p !== void 0 && Number.isFinite(p.time) && Number.isFinite(p.value)).slice().sort((a, b) => a.time - b.time);
|
|
4452
|
+
if (clean.length < 2) return null;
|
|
4453
|
+
const drawable = Math.max(2, Math.round(box.width - box.padX * 2));
|
|
4454
|
+
const shown = compressPnl(clean, drawable);
|
|
4455
|
+
const values = shown.map((p) => p.value);
|
|
4456
|
+
const min = Math.min(0, ...values);
|
|
4457
|
+
const max = Math.max(0, ...values);
|
|
4458
|
+
const left = box.padX;
|
|
4459
|
+
const right = box.width - box.padX;
|
|
4460
|
+
const top = box.padY;
|
|
4461
|
+
const bottom = box.height - box.padY;
|
|
4462
|
+
const firstTime = shown[0].time;
|
|
4463
|
+
const lastTime = shown[shown.length - 1].time;
|
|
4464
|
+
const timeSpan = lastTime - firstTime;
|
|
4465
|
+
const valueSpan = max - min;
|
|
4466
|
+
const x = (time) => timeSpan === 0 ? left : left + (time - firstTime) / timeSpan * (right - left);
|
|
4467
|
+
const y = (value) => valueSpan === 0 ? top : bottom - (value - min) / valueSpan * (bottom - top);
|
|
4468
|
+
const curve = shown.map((p) => [x(p.time), y(p.value)]);
|
|
4469
|
+
const zeroY = y(0);
|
|
4470
|
+
const last = shown[shown.length - 1].value;
|
|
4387
4471
|
return {
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4472
|
+
points: curve,
|
|
4473
|
+
// Closed along the baseline rather than along the foot of the box: a run entirely above
|
|
4474
|
+
// water fills the gap between itself and zero, and nothing below it.
|
|
4475
|
+
area: [...curve, [curve[curve.length - 1][0], zeroY], [curve[0][0], zeroY]],
|
|
4476
|
+
zeroY,
|
|
4477
|
+
min,
|
|
4478
|
+
max,
|
|
4479
|
+
from: firstTime,
|
|
4480
|
+
to: lastTime,
|
|
4481
|
+
last,
|
|
4482
|
+
positive: last >= 0
|
|
4396
4483
|
};
|
|
4397
4484
|
}
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
const
|
|
4422
|
-
|
|
4423
|
-
(height - 2 * PAD_Y - LANE_GAP * (input.lanes.length - 1)) / input.lanes.length
|
|
4424
|
-
);
|
|
4425
|
-
input.lanes.forEach((lane, position) => {
|
|
4426
|
-
const laneTop = named ? PAD_Y + position * (laneHeight + LANE_GAP) : PAD_Y;
|
|
4427
|
-
const laneSpan = named ? laneHeight : Math.max(MIN_LANE_HEIGHT, height - 2 * PAD_Y);
|
|
4428
|
-
const scale = priceScale(lane.ticks, laneTop, laneSpan);
|
|
4429
|
-
layout.lanes.push({
|
|
4430
|
-
symbol: lane.symbol,
|
|
4431
|
-
label: named ? { x: LABEL_GAP, y: laneTop + 2 } : null,
|
|
4432
|
-
separatorY: named && position > 0 ? laneTop - LANE_GAP / 2 : null,
|
|
4433
|
-
ticks: lane.ticks.length === 0 ? [] : axisTicks(scale)
|
|
4434
|
-
});
|
|
4435
|
-
if (lane.ticks.length === 0) return;
|
|
4436
|
-
const buckets = Math.max(MIN_BUCKETS, Math.min(MAX_BUCKETS, Math.floor(drawWidth / BUCKET_WIDTH)));
|
|
4437
|
-
const bubbles = aggregateBubbles(lane.ticks, buckets);
|
|
4438
|
-
let maxQuantity = 0;
|
|
4439
|
-
for (const bubble of bubbles) if (bubble.quantity > maxQuantity) maxQuantity = bubble.quantity;
|
|
4440
|
-
const quantityScale = Math.max(maxQuantity, Number.EPSILON);
|
|
4441
|
-
const radiusCap = Math.min(MAX_RADIUS, laneSpan * RADIUS_LANE_SHARE);
|
|
4442
|
-
for (const bubble of bubbles) {
|
|
4443
|
-
layout.bubbles.push({
|
|
4444
|
-
// Newest print (index 0) at the right edge, oldest at the left.
|
|
4445
|
-
x: PAD_X + drawWidth * (total <= 1 ? 1 : 1 - bubble.index / (total - 1)),
|
|
4446
|
-
y: scale.top + scale.height * (1 - (bubble.price - scale.min) / scale.range),
|
|
4447
|
-
radius: MIN_RADIUS + Math.sqrt(Math.max(0, bubble.quantity) / quantityScale) * radiusCap,
|
|
4448
|
-
bubble
|
|
4449
|
-
});
|
|
4450
|
-
}
|
|
4451
|
-
});
|
|
4452
|
-
layout.bubbles.sort((left, right) => right.bubble.index - left.bubble.index);
|
|
4453
|
-
return layout;
|
|
4454
|
-
}
|
|
4455
|
-
function priceScale(ticks, top, height) {
|
|
4456
|
-
let min = Infinity;
|
|
4457
|
-
let max = -Infinity;
|
|
4458
|
-
for (const tick of ticks) {
|
|
4459
|
-
if (tick.price < min) min = tick.price;
|
|
4460
|
-
if (tick.price > max) max = tick.price;
|
|
4461
|
-
}
|
|
4462
|
-
if (!isFinite(min) || !isFinite(max)) {
|
|
4463
|
-
min = 0;
|
|
4464
|
-
max = 0;
|
|
4465
|
-
}
|
|
4466
|
-
return { top, height, min, range: Math.max(max - min, Number.EPSILON) };
|
|
4485
|
+
function drawPnlCurve(ctx, curve, box, style) {
|
|
4486
|
+
const colour = curve.positive ? style.up : style.down;
|
|
4487
|
+
ctx.clearRect(0, 0, box.width, box.height);
|
|
4488
|
+
ctx.setLineDash([2, 3]);
|
|
4489
|
+
ctx.strokeStyle = style.baseline;
|
|
4490
|
+
ctx.lineWidth = 1;
|
|
4491
|
+
ctx.beginPath();
|
|
4492
|
+
ctx.moveTo(box.padX, curve.zeroY);
|
|
4493
|
+
ctx.lineTo(box.width - box.padX, curve.zeroY);
|
|
4494
|
+
ctx.stroke();
|
|
4495
|
+
ctx.setLineDash([]);
|
|
4496
|
+
ctx.globalAlpha = style.fillOpacity;
|
|
4497
|
+
ctx.fillStyle = colour;
|
|
4498
|
+
ctx.beginPath();
|
|
4499
|
+
ctx.moveTo(curve.area[0][0], curve.area[0][1]);
|
|
4500
|
+
for (const [px, py] of curve.area.slice(1)) ctx.lineTo(px, py);
|
|
4501
|
+
ctx.closePath();
|
|
4502
|
+
ctx.fill();
|
|
4503
|
+
ctx.globalAlpha = 1;
|
|
4504
|
+
ctx.strokeStyle = colour;
|
|
4505
|
+
ctx.lineWidth = style.lineWidth;
|
|
4506
|
+
ctx.beginPath();
|
|
4507
|
+
ctx.moveTo(curve.points[0][0], curve.points[0][1]);
|
|
4508
|
+
for (const [px, py] of curve.points.slice(1)) ctx.lineTo(px, py);
|
|
4509
|
+
ctx.stroke();
|
|
4467
4510
|
}
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
});
|
|
4511
|
+
|
|
4512
|
+
// src/chart-engine.global.ts
|
|
4513
|
+
function engine() {
|
|
4514
|
+
const found = globalThis.SSChart;
|
|
4515
|
+
if (found === void 0 || found === null) {
|
|
4516
|
+
throw new Error(
|
|
4517
|
+
"The chart panels need @stocksharp/chart. Load its bundle (sschart.js) before sstradingcontrols.js, or import this package from npm rather than as a script."
|
|
4518
|
+
);
|
|
4477
4519
|
}
|
|
4478
|
-
return
|
|
4479
|
-
}
|
|
4480
|
-
function priceFormatter(min, max) {
|
|
4481
|
-
const magnitude = Math.max(Math.abs(min), Math.abs(max));
|
|
4482
|
-
let digits = 6;
|
|
4483
|
-
if (magnitude >= 1e3) digits = 0;
|
|
4484
|
-
else if (magnitude >= 10) digits = 2;
|
|
4485
|
-
else if (magnitude >= 0.1) digits = 4;
|
|
4486
|
-
return (price) => price.toLocaleString(void 0, {
|
|
4487
|
-
minimumFractionDigits: digits,
|
|
4488
|
-
maximumFractionDigits: digits
|
|
4489
|
-
});
|
|
4520
|
+
return found;
|
|
4490
4521
|
}
|
|
4522
|
+
var createChart = (container, options) => engine().createChart(container, options);
|
|
4523
|
+
var AreaSeries = new Proxy({}, {
|
|
4524
|
+
get: (_target, key) => engine().AreaSeries[key]
|
|
4525
|
+
});
|
|
4526
|
+
var LineSeries = new Proxy({}, {
|
|
4527
|
+
get: (_target, key) => engine().LineSeries[key]
|
|
4528
|
+
});
|
|
4529
|
+
var CrosshairMode = new Proxy({}, {
|
|
4530
|
+
get: (_target, key) => engine().CrosshairMode[key]
|
|
4531
|
+
});
|
|
4491
4532
|
|
|
4492
|
-
// src/
|
|
4493
|
-
var
|
|
4494
|
-
var
|
|
4495
|
-
var
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4533
|
+
// src/equity-widget.ts
|
|
4534
|
+
var FILL_TOP_ALPHA = 0.28;
|
|
4535
|
+
var FILL_BOTTOM_ALPHA = 0.02;
|
|
4536
|
+
var LINE_WIDTH = 2;
|
|
4537
|
+
function toSeries(points) {
|
|
4538
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4539
|
+
const out = [];
|
|
4540
|
+
for (const point of [...points].sort((a, b) => a.time - b.time)) {
|
|
4541
|
+
if (!Number.isFinite(point.time) || !Number.isFinite(point.value)) continue;
|
|
4542
|
+
const second = Math.floor(point.time / 1e3);
|
|
4543
|
+
if (seen.has(second)) out[out.length - 1] = { time: second, value: point.value };
|
|
4544
|
+
else out.push({ time: second, value: point.value });
|
|
4545
|
+
seen.add(second);
|
|
4546
|
+
}
|
|
4547
|
+
return out;
|
|
4548
|
+
}
|
|
4549
|
+
var _EquityWidget = class _EquityWidget {
|
|
4502
4550
|
static create(hostEl, state, deps) {
|
|
4503
|
-
const host = assertHost(deps?.host, "
|
|
4504
|
-
const root =
|
|
4505
|
-
root.id = makePanelId(
|
|
4551
|
+
const host = assertHost(deps?.host, "EquityWidget");
|
|
4552
|
+
const root = _EquityWidget._buildRoot(host);
|
|
4553
|
+
root.id = makePanelId(_EquityWidget.TYPE);
|
|
4506
4554
|
hostEl.appendChild(root);
|
|
4507
|
-
return new
|
|
4555
|
+
return new _EquityWidget(root, state || {}, deps);
|
|
4508
4556
|
}
|
|
4509
|
-
// The panel's markup. The host stylesheet reads this structure, and a
|
|
4510
|
-
// docking host lifts `.panel-header`'s children into its tab strip.
|
|
4511
|
-
//
|
|
4512
|
-
// Two `+` buttons, and they are not the same gesture: the one in the header
|
|
4513
|
-
// pins another instrument to THIS feed, the one beside it asks the host for
|
|
4514
|
-
// another feed panel. The tooltips are what tell them apart, so neither is
|
|
4515
|
-
// an icon on its own.
|
|
4516
4557
|
static _buildRoot(host) {
|
|
4517
|
-
const
|
|
4518
|
-
|
|
4519
|
-
return makePanelRoot("tradefeed-panel tf-tab-market tf-view-list", host.t("TradeFeed"), [
|
|
4558
|
+
const title = host.t("Equity");
|
|
4559
|
+
return makePanelRoot("equity-panel", title, [
|
|
4520
4560
|
makeElement("div", "panel-header", {}, [
|
|
4521
|
-
makeElement("span", "", {}, [
|
|
4522
|
-
|
|
4523
|
-
makeIconButton("bt-icon-btn
|
|
4524
|
-
makeElement("div", "tradefeed-view-toggle tf-view-toggle", { role: "group", "aria-label": host.t("TradeFeedView") }, [
|
|
4525
|
-
makeIconButton("tf-view-btn", host.t("ListView"), "bi-list-ul", { type: "button", "data-view": "list" }),
|
|
4526
|
-
makeIconButton("tf-view-btn", host.t("BubbleChart"), "bi-circle-fill", { type: "button", "data-view": "bubbles" })
|
|
4527
|
-
]),
|
|
4561
|
+
makeElement("span", "", {}, [title]),
|
|
4562
|
+
makeElement("span", "equity-last", {}, []),
|
|
4563
|
+
makeIconButton("bt-icon-btn equity-reset-btn", host.t("ResetView"), "bi-arrow-clockwise", { type: "button" }),
|
|
4528
4564
|
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
4529
4565
|
]),
|
|
4530
|
-
makeElement("div", "
|
|
4531
|
-
makeElement("
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
// tape sortable and gives it the grid's menu and selection.
|
|
4538
|
-
makeElement("div", "tradefeed-content tf-market", { "aria-live": "polite" }, [
|
|
4539
|
-
makeElement("table", "terminal-table tradefeed-table", { role: "table", "aria-label": marketTrades }, [
|
|
4540
|
-
makeElement("thead", "", {}, []),
|
|
4541
|
-
makeElement("tbody", "", {}, [])
|
|
4542
|
-
])
|
|
4543
|
-
]),
|
|
4544
|
-
makeElement("div", "tradefeed-content tf-my", {}, [
|
|
4545
|
-
makeElement("table", "terminal-table tradefeed-table", { role: "table", "aria-label": host.t("My trades") }, [
|
|
4546
|
-
makeElement("thead", "", {}, []),
|
|
4547
|
-
makeElement("tbody", "", {}, [])
|
|
4566
|
+
makeElement("div", "panel-body", {}, [
|
|
4567
|
+
makeElement("div", "equity-chart", { role: "img", "aria-label": host.t("PnLChart") }, [
|
|
4568
|
+
// Over the curve rather than in the panel header, for the same reason the
|
|
4569
|
+
// smile's is: a reading about a moment belongs beside that moment, and a host
|
|
4570
|
+
// that lifts panel headers into a tab strip would put it in a tab title.
|
|
4571
|
+
makeElement("div", "equity-hover", {}, []),
|
|
4572
|
+
makeElement("div", "equity-empty", {}, [host.t("NoEquity")])
|
|
4548
4573
|
])
|
|
4549
|
-
])
|
|
4550
|
-
makeElement("canvas", "tradefeed-bubbles tf-bubbles", { "aria-hidden": "true" }, []),
|
|
4551
|
-
makeElement("div", "tf-bubble-tooltip", { role: "tooltip", hidden: "" }, [])
|
|
4574
|
+
])
|
|
4552
4575
|
]);
|
|
4553
4576
|
}
|
|
4554
|
-
constructor(rootEl,
|
|
4555
|
-
this._host = assertHost(deps?.host, "
|
|
4577
|
+
constructor(rootEl, _state, deps) {
|
|
4578
|
+
this._host = assertHost(deps?.host, "EquityWidget");
|
|
4556
4579
|
this.rootEl = rootEl;
|
|
4557
|
-
this.extrasEl = this.rootEl.querySelector(".tf-extras");
|
|
4558
|
-
this.bubbleCanvas = this.rootEl.querySelector(".tf-bubbles");
|
|
4559
|
-
this._tabsEl = this.rootEl.querySelector(".tf-tabs");
|
|
4560
|
-
this._viewToggleEl = this.rootEl.querySelector(".tf-view-toggle");
|
|
4561
|
-
this._tooltipEl = this.rootEl.querySelector(".tf-bubble-tooltip");
|
|
4562
|
-
this._addBtn = this.rootEl.querySelector(".panel-add-btn");
|
|
4563
4580
|
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
4564
|
-
this.
|
|
4565
|
-
this.
|
|
4566
|
-
this.
|
|
4567
|
-
this.
|
|
4568
|
-
this.
|
|
4569
|
-
this.
|
|
4570
|
-
this.
|
|
4571
|
-
this.
|
|
4572
|
-
this._bubbleHits = [];
|
|
4573
|
-
this._canvasSize = null;
|
|
4581
|
+
this._resetBtn = this.rootEl.querySelector(".equity-reset-btn");
|
|
4582
|
+
this._lastEl = this.rootEl.querySelector(".equity-last");
|
|
4583
|
+
this._hoverEl = this.rootEl.querySelector(".equity-hover");
|
|
4584
|
+
this._emptyEl = this.rootEl.querySelector(".equity-empty");
|
|
4585
|
+
this._chartEl = this.rootEl.querySelector(".equity-chart");
|
|
4586
|
+
this._chart = null;
|
|
4587
|
+
this._series = null;
|
|
4588
|
+
this._points = [];
|
|
4574
4589
|
this._resizeObserver = null;
|
|
4575
|
-
this._marketGrid = this._makeGrid(".tf-market");
|
|
4576
|
-
this._myGrid = this._makeGrid(".tf-my");
|
|
4577
|
-
const saved = this._host.preferences.get(_TradeFeedWidget.VIEW_KEY, null);
|
|
4578
|
-
this.view = saved === "bubbles" ? "bubbles" : "list";
|
|
4579
|
-
this._activeSymbol = null;
|
|
4580
|
-
this._extraSymbols = new Set(Array.isArray(state.extras) ? state.extras : []);
|
|
4581
|
-
this._addBtn?.addEventListener("click", (e) => {
|
|
4582
|
-
e.preventDefault();
|
|
4583
|
-
this._host.spawn({ extras: [...this._extraSymbols] });
|
|
4584
|
-
});
|
|
4585
4590
|
this._closeBtn?.addEventListener("click", (e) => {
|
|
4586
4591
|
e.preventDefault();
|
|
4587
|
-
this.dispose();
|
|
4588
4592
|
this._host.close();
|
|
4589
4593
|
});
|
|
4590
|
-
this.
|
|
4594
|
+
this._resetBtn?.addEventListener("click", (e) => {
|
|
4591
4595
|
e.preventDefault();
|
|
4592
|
-
|
|
4593
|
-
this._host.trading.pickInstrument((symbol) => void this.addExtraSymbol(symbol));
|
|
4596
|
+
this.resetZoom();
|
|
4594
4597
|
});
|
|
4595
|
-
this.
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
this._applyView();
|
|
4599
|
-
this._renderExtras();
|
|
4600
|
-
for (const symbol of this._extraSymbols)
|
|
4601
|
-
void this._host.trading.marketData.addSymbol(symbol, MarketDataLevels.Tape).catch(() => {
|
|
4602
|
-
});
|
|
4603
|
-
if (this.bubbleCanvas && typeof ResizeObserver === "function") {
|
|
4604
|
-
this._resizeObserver = new ResizeObserver(() => {
|
|
4605
|
-
this._sizeCanvas();
|
|
4606
|
-
this._renderBubbles();
|
|
4607
|
-
});
|
|
4608
|
-
this._resizeObserver.observe(this.bubbleCanvas);
|
|
4598
|
+
if (this._chartEl !== null && typeof ResizeObserver !== "undefined") {
|
|
4599
|
+
this._resizeObserver = new ResizeObserver(() => this._fit());
|
|
4600
|
+
this._resizeObserver.observe(this._chartEl);
|
|
4609
4601
|
}
|
|
4602
|
+
this._render();
|
|
4610
4603
|
this._host.register(this);
|
|
4611
4604
|
}
|
|
4612
4605
|
dispose() {
|
|
@@ -4614,35 +4607,2729 @@ var SSTradingControls = (() => {
|
|
|
4614
4607
|
this._resizeObserver?.disconnect();
|
|
4615
4608
|
} catch {
|
|
4616
4609
|
}
|
|
4617
|
-
this.
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4610
|
+
this._resizeObserver = null;
|
|
4611
|
+
try {
|
|
4612
|
+
this._chart?.remove();
|
|
4613
|
+
} catch {
|
|
4614
|
+
}
|
|
4615
|
+
this._chart = null;
|
|
4616
|
+
this._series = null;
|
|
4622
4617
|
this._host.unregister(this);
|
|
4623
4618
|
try {
|
|
4624
4619
|
this.rootEl.remove();
|
|
4625
4620
|
} catch {
|
|
4626
4621
|
}
|
|
4627
4622
|
}
|
|
4628
|
-
///
|
|
4629
|
-
///
|
|
4630
|
-
///
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
this.
|
|
4634
|
-
this.
|
|
4623
|
+
/// Show this run. The whole curve at once: it is a cumulative figure, so a set of points that
|
|
4624
|
+
/// no longer contains an earlier sample is a different run rather than a longer one.
|
|
4625
|
+
///
|
|
4626
|
+
/// `time` is unix milliseconds.
|
|
4627
|
+
update(points) {
|
|
4628
|
+
this._points = points || [];
|
|
4629
|
+
this._render();
|
|
4635
4630
|
}
|
|
4636
|
-
///
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4631
|
+
/// Show the whole run again, after a zoom.
|
|
4632
|
+
resetZoom() {
|
|
4633
|
+
try {
|
|
4634
|
+
this._chart?.timeScale().fitContent();
|
|
4635
|
+
} catch {
|
|
4636
|
+
}
|
|
4637
|
+
}
|
|
4638
|
+
/// The engine, for a host that wants to add to this chart - a benchmark line, a marker at a
|
|
4639
|
+
/// drawdown. Null until there is something to draw.
|
|
4640
|
+
chart() {
|
|
4641
|
+
return this._chart;
|
|
4642
|
+
}
|
|
4643
|
+
// Built on first use rather than in the constructor: the engine measures its container, and
|
|
4644
|
+
// a panel that has not been laid out yet has none.
|
|
4645
|
+
_ensureChart() {
|
|
4646
|
+
if (this._chart !== null) return true;
|
|
4647
|
+
if (this._chartEl === null) return false;
|
|
4648
|
+
const palette = this._host.presentation.canvasPalette();
|
|
4649
|
+
this._chart = createChart(this._chartEl, {
|
|
4650
|
+
layout: {
|
|
4651
|
+
background: { type: "solid", color: "transparent" },
|
|
4652
|
+
textColor: palette.grid,
|
|
4653
|
+
fontFamily: palette.font,
|
|
4654
|
+
fontSize: 11,
|
|
4655
|
+
attributionLogo: false
|
|
4656
|
+
},
|
|
4657
|
+
grid: { vertLines: { color: palette.grid }, horzLines: { color: palette.grid } },
|
|
4658
|
+
crosshair: { mode: CrosshairMode.Normal },
|
|
4659
|
+
rightPriceScale: { borderColor: palette.grid },
|
|
4660
|
+
timeScale: {
|
|
4661
|
+
borderColor: palette.grid,
|
|
4662
|
+
timeVisible: true,
|
|
4663
|
+
secondsVisible: true,
|
|
4664
|
+
// The engine dates an axis in UTC unless told otherwise, and everything else on
|
|
4665
|
+
// the page - the readout beside this one included - reads in the browser's zone.
|
|
4666
|
+
// Two labels for one instant, three hours apart, is worse than either.
|
|
4667
|
+
timeZone: browserTimeZone()
|
|
4668
|
+
}
|
|
4669
|
+
});
|
|
4670
|
+
this._series = this._chart.addSeries(AreaSeries, {
|
|
4671
|
+
lineWidth: LINE_WIDTH,
|
|
4672
|
+
lineColor: palette.up,
|
|
4673
|
+
topColor: withAlpha(palette.up, FILL_TOP_ALPHA),
|
|
4674
|
+
bottomColor: withAlpha(palette.up, FILL_BOTTOM_ALPHA)
|
|
4675
|
+
});
|
|
4676
|
+
this._chart.subscribeCrosshairMove((param) => this._renderHover(param));
|
|
4677
|
+
return true;
|
|
4678
|
+
}
|
|
4679
|
+
_render() {
|
|
4680
|
+
const series = toSeries(this._points);
|
|
4681
|
+
const empty = series.length < 2;
|
|
4682
|
+
if (this._emptyEl !== null) this._emptyEl.hidden = !empty;
|
|
4683
|
+
this._renderLast(empty ? null : series[series.length - 1].value);
|
|
4684
|
+
if (empty) return;
|
|
4685
|
+
if (!this._ensureChart() || this._series === null) return;
|
|
4686
|
+
const palette = this._host.presentation.canvasPalette();
|
|
4687
|
+
const last = series[series.length - 1].value;
|
|
4688
|
+
const colour = last >= 0 ? palette.up : palette.down;
|
|
4689
|
+
this._series.applyOptions({
|
|
4690
|
+
lineColor: colour,
|
|
4691
|
+
topColor: withAlpha(colour, FILL_TOP_ALPHA),
|
|
4692
|
+
bottomColor: withAlpha(colour, FILL_BOTTOM_ALPHA)
|
|
4693
|
+
});
|
|
4694
|
+
this._series.setData(series);
|
|
4695
|
+
this._fit();
|
|
4696
|
+
}
|
|
4697
|
+
_fit() {
|
|
4698
|
+
if (this._chart === null || this._chartEl === null) return;
|
|
4699
|
+
const rect = this._chartEl.getBoundingClientRect();
|
|
4700
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
4701
|
+
try {
|
|
4702
|
+
this._chart.resize(rect.width, rect.height);
|
|
4703
|
+
} catch {
|
|
4704
|
+
}
|
|
4705
|
+
}
|
|
4706
|
+
// Where the run stands, in the header.
|
|
4707
|
+
_renderLast(value) {
|
|
4708
|
+
const el = this._lastEl;
|
|
4709
|
+
if (el === null) return;
|
|
4710
|
+
el.textContent = value === null ? "" : formatPnl(value);
|
|
4711
|
+
el.className = value === null ? "equity-last" : `equity-last ${this._host.presentation.pnlClass(value)}`;
|
|
4712
|
+
}
|
|
4713
|
+
// What the pointer is over. The engine hands over the moment and the value at it; the moment
|
|
4714
|
+
// is worded by the host, because what a time reads as is the page's business and not this
|
|
4715
|
+
// package's.
|
|
4716
|
+
_renderHover(param) {
|
|
4717
|
+
const el = this._hoverEl;
|
|
4718
|
+
if (el === null) return;
|
|
4719
|
+
const point = this._series === null ? void 0 : param.seriesData.get(this._series);
|
|
4720
|
+
const value = point?.value;
|
|
4721
|
+
if (param.time === null || value === void 0 || !Number.isFinite(value)) {
|
|
4722
|
+
el.textContent = "";
|
|
4723
|
+
el.className = "equity-hover";
|
|
4724
|
+
return;
|
|
4725
|
+
}
|
|
4726
|
+
const moment = new Date(Number(param.time) * 1e3);
|
|
4727
|
+
el.textContent = `${this._host.presentation.timeText(moment)} ${formatPnl(value)}`;
|
|
4728
|
+
el.className = `equity-hover ${this._host.presentation.pnlClass(value)}`;
|
|
4729
|
+
}
|
|
4730
|
+
};
|
|
4731
|
+
_EquityWidget.TYPE = ControlTypes.Equity;
|
|
4732
|
+
var EquityWidget = _EquityWidget;
|
|
4733
|
+
function browserTimeZone() {
|
|
4734
|
+
try {
|
|
4735
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
4736
|
+
} catch {
|
|
4737
|
+
return "UTC";
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
function withAlpha(colour, alpha) {
|
|
4741
|
+
return `color-mix(in srgb, ${colour} ${Math.round(alpha * 100)}%, transparent)`;
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4744
|
+
// src/statistics-widget.ts
|
|
4745
|
+
function rank(rows) {
|
|
4746
|
+
const first = /* @__PURE__ */ new Map();
|
|
4747
|
+
for (const row of rows) {
|
|
4748
|
+
const seen = first.get(row.category);
|
|
4749
|
+
if (seen === void 0 || row.order < seen) first.set(row.category, row.order);
|
|
4750
|
+
}
|
|
4751
|
+
return rows.map((row) => ({ ...row, categoryRank: first.get(row.category) ?? row.order }));
|
|
4752
|
+
}
|
|
4753
|
+
var _StatisticsWidget = class _StatisticsWidget {
|
|
4754
|
+
static create(hostEl, state, deps) {
|
|
4755
|
+
const host = assertHost(deps?.host, "StatisticsWidget");
|
|
4756
|
+
const root = _StatisticsWidget._buildRoot(host);
|
|
4757
|
+
root.id = makePanelId(_StatisticsWidget.TYPE);
|
|
4758
|
+
hostEl.appendChild(root);
|
|
4759
|
+
return new _StatisticsWidget(root, state || {}, deps);
|
|
4760
|
+
}
|
|
4761
|
+
// The panel's markup. No rail action but the export: a statistic is produced by the
|
|
4762
|
+
// strategy and there is nothing here to cancel, reload or edit.
|
|
4763
|
+
static _buildRoot(host) {
|
|
4764
|
+
const title = host.t("Statistics");
|
|
4765
|
+
return makePanelRoot("statistics-panel", title, [
|
|
4766
|
+
makeElement("div", "panel-header", {}, [
|
|
4767
|
+
makeElement("span", "", {}, [title]),
|
|
4768
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
4769
|
+
]),
|
|
4770
|
+
makeElement("div", "panel-body panel-body-with-rail", {}, [
|
|
4771
|
+
makeElement("div", "panel-body-content", {}, [
|
|
4772
|
+
makeElement("table", "terminal-table statistics-table", { role: "table", "aria-label": host.t("StatisticsList") }, [
|
|
4773
|
+
makeElement("thead", "", {}, []),
|
|
4774
|
+
makeElement("tbody", "statistics-body", {}, [])
|
|
4775
|
+
])
|
|
4776
|
+
]),
|
|
4777
|
+
makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("StatisticsActions") }, [
|
|
4778
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
|
|
4779
|
+
])
|
|
4780
|
+
])
|
|
4781
|
+
]);
|
|
4782
|
+
}
|
|
4783
|
+
constructor(rootEl, _state, deps) {
|
|
4784
|
+
this._host = assertHost(deps?.host, "StatisticsWidget");
|
|
4785
|
+
this.rootEl = rootEl;
|
|
4786
|
+
this.el = this.rootEl.querySelector(".statistics-body");
|
|
4787
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
4788
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
4789
|
+
this._rows = [];
|
|
4790
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
4791
|
+
e.preventDefault();
|
|
4792
|
+
this._host.close();
|
|
4793
|
+
});
|
|
4794
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
4795
|
+
e.preventDefault();
|
|
4796
|
+
this._export();
|
|
4797
|
+
});
|
|
4798
|
+
const head = this.rootEl.querySelector(".statistics-table thead");
|
|
4799
|
+
this._grid = head && this.el ? new DataGrid({
|
|
4800
|
+
head,
|
|
4801
|
+
body: this.el,
|
|
4802
|
+
columns: this._columns(),
|
|
4803
|
+
// Not a sort the reader chose: the registry hands every parameter an order, and
|
|
4804
|
+
// the bands it assigns are what puts profit above drawdown above trade counts.
|
|
4805
|
+
defaultSort: { col: "order", dir: "asc" },
|
|
4806
|
+
rowKey: (r) => String(r.key),
|
|
4807
|
+
emptyText: this._host.t("NoStatistics"),
|
|
4808
|
+
contextMenu: makeGridMenu(this._host),
|
|
4809
|
+
// The groups sit where the registry order puts them, not where the
|
|
4810
|
+
// alphabet would: profit, then trades, then positions, then orders.
|
|
4811
|
+
groupOrder: "rows",
|
|
4812
|
+
selection: "multi"
|
|
4813
|
+
}) : null;
|
|
4814
|
+
this._grid?.setState({ group: "category", hidden: ["category", "order"] });
|
|
4815
|
+
this._host.register(this);
|
|
4816
|
+
}
|
|
4817
|
+
dispose() {
|
|
4818
|
+
this._grid?.destroy();
|
|
4819
|
+
this._host.unregister(this);
|
|
4820
|
+
try {
|
|
4821
|
+
this.rootEl.remove();
|
|
4822
|
+
} catch {
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
/// Show these statistics. The whole set at once: a strategy publishes its parameters as one
|
|
4826
|
+
/// table and a row that vanished from it has stopped existing, not stopped changing.
|
|
4827
|
+
update(rows) {
|
|
4828
|
+
this._rows = rank(rows || []);
|
|
4829
|
+
this._grid?.setRows(this._rows);
|
|
4830
|
+
}
|
|
4831
|
+
_export() {
|
|
4832
|
+
this._grid?.download("statistics", this._host.t("Statistics"));
|
|
4833
|
+
}
|
|
4834
|
+
// Two visible columns and one that only groups. The order column is hidden as well: it
|
|
4835
|
+
// decides the sort and means nothing to a reader.
|
|
4836
|
+
_columns() {
|
|
4837
|
+
const label = (key) => this._host.t(key);
|
|
4838
|
+
return [
|
|
4839
|
+
{
|
|
4840
|
+
key: "category",
|
|
4841
|
+
header: label("Category"),
|
|
4842
|
+
exportable: false,
|
|
4843
|
+
// The group's place, not its name: groups are laid out in the order the
|
|
4844
|
+
// registry gives their parameters, so profit comes before trade counts.
|
|
4845
|
+
// Grouping on the name would order them alphabetically, and on the
|
|
4846
|
+
// translated name would reorder them with the reader's language.
|
|
4847
|
+
value: (r) => r.categoryRank,
|
|
4848
|
+
text: (r) => r.categoryText || r.category
|
|
4849
|
+
},
|
|
4850
|
+
{
|
|
4851
|
+
key: "order",
|
|
4852
|
+
header: label("Order"),
|
|
4853
|
+
exportable: false,
|
|
4854
|
+
value: (r) => r.order
|
|
4855
|
+
},
|
|
4856
|
+
{
|
|
4857
|
+
key: "name",
|
|
4858
|
+
header: label("Name"),
|
|
4859
|
+
exportable: true,
|
|
4860
|
+
value: (r) => r.name,
|
|
4861
|
+
bindCell: (td, r) => {
|
|
4862
|
+
if (r.description) td.setAttribute("title", r.description);
|
|
4863
|
+
}
|
|
4864
|
+
},
|
|
4865
|
+
{
|
|
4866
|
+
key: "value",
|
|
4867
|
+
header: label("Value"),
|
|
4868
|
+
exportable: true,
|
|
4869
|
+
cellClass: () => "statistic-value",
|
|
4870
|
+
// Sorts on the raw value so a number sorts as a number, reads as the text below.
|
|
4871
|
+
value: (r) => r.value,
|
|
4872
|
+
render: (r) => formatStatistic(r.value),
|
|
4873
|
+
exportValue: (r) => r.value ?? ""
|
|
4874
|
+
}
|
|
4875
|
+
];
|
|
4876
|
+
}
|
|
4877
|
+
};
|
|
4878
|
+
_StatisticsWidget.TYPE = ControlTypes.Statistics;
|
|
4879
|
+
var StatisticsWidget = _StatisticsWidget;
|
|
4880
|
+
function formatStatistic(value) {
|
|
4881
|
+
if (value === null || value === void 0 || value === "") return "";
|
|
4882
|
+
if (typeof value === "number") {
|
|
4883
|
+
if (!isFinite(value)) return "";
|
|
4884
|
+
return String(Math.round(value * 100) / 100);
|
|
4885
|
+
}
|
|
4886
|
+
if (value instanceof Date) return isoDate(value);
|
|
4887
|
+
if (typeof value === "string") {
|
|
4888
|
+
const at = /^\d{4}-\d{2}-\d{2}([T ]|$)/.test(value) ? new Date(value) : null;
|
|
4889
|
+
if (at !== null && !isNaN(at.getTime())) return isoDate(at);
|
|
4890
|
+
return value;
|
|
4891
|
+
}
|
|
4892
|
+
return String(value);
|
|
4893
|
+
}
|
|
4894
|
+
function isoDate(at) {
|
|
4895
|
+
return at.toISOString().slice(0, 10);
|
|
4896
|
+
}
|
|
4897
|
+
|
|
4898
|
+
// src/heatmap-grid.ts
|
|
4899
|
+
var HeatDirections = {
|
|
4900
|
+
Higher: "higher",
|
|
4901
|
+
Lower: "lower"
|
|
4902
|
+
};
|
|
4903
|
+
var LEFT_GUTTER = 54;
|
|
4904
|
+
var RIGHT_PAD = 6;
|
|
4905
|
+
var TOP_BAND = 26;
|
|
4906
|
+
var BOTTOM_BAND = 30;
|
|
4907
|
+
var TICK_GAP = 4;
|
|
4908
|
+
var LABEL_HEIGHT = 13;
|
|
4909
|
+
var CELL_INSET = 0.5;
|
|
4910
|
+
var LEGEND_WIDTH = 140;
|
|
4911
|
+
var LEGEND_HEIGHT = 8;
|
|
4912
|
+
var LEGEND_Y = 14;
|
|
4913
|
+
var LEGEND_STEPS = 28;
|
|
4914
|
+
var MIN_TICK_WIDTH = 34;
|
|
4915
|
+
var MIN_TICK_HEIGHT = 13;
|
|
4916
|
+
var NUMERIC = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\s*%?$/;
|
|
4917
|
+
function foldCells(cells) {
|
|
4918
|
+
const folded = /* @__PURE__ */ new Map();
|
|
4919
|
+
const order = [];
|
|
4920
|
+
for (const cell of cells) {
|
|
4921
|
+
if (cell === null || cell === void 0 || !Number.isFinite(cell.value)) continue;
|
|
4922
|
+
const id = pairKey(cell.x, cell.y);
|
|
4923
|
+
const found = folded.get(id);
|
|
4924
|
+
if (found === void 0) {
|
|
4925
|
+
folded.set(id, { x: cell.x, y: cell.y, sum: cell.value, count: 1 });
|
|
4926
|
+
order.push(id);
|
|
4927
|
+
} else {
|
|
4928
|
+
found.sum += cell.value;
|
|
4929
|
+
found.count += 1;
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
return order.map((id) => {
|
|
4933
|
+
const fold = folded.get(id);
|
|
4934
|
+
return { x: fold.x, y: fold.y, value: fold.sum / fold.count, count: fold.count };
|
|
4935
|
+
});
|
|
4936
|
+
}
|
|
4937
|
+
function axisValues(values) {
|
|
4938
|
+
const unique = [];
|
|
4939
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4940
|
+
for (const value of values) {
|
|
4941
|
+
if (seen.has(value)) continue;
|
|
4942
|
+
seen.add(value);
|
|
4943
|
+
unique.push(value);
|
|
4944
|
+
}
|
|
4945
|
+
if (unique.every((value) => NUMERIC.test(value)))
|
|
4946
|
+
return unique.sort((left, right) => numberOf(left) - numberOf(right));
|
|
4947
|
+
return unique.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
4948
|
+
}
|
|
4949
|
+
function heatScale(buckets) {
|
|
4950
|
+
let min = Infinity;
|
|
4951
|
+
let max = -Infinity;
|
|
4952
|
+
for (const bucket of buckets) {
|
|
4953
|
+
if (bucket.value < min) min = bucket.value;
|
|
4954
|
+
if (bucket.value > max) max = bucket.value;
|
|
4955
|
+
}
|
|
4956
|
+
if (!isFinite(min) || !isFinite(max)) {
|
|
4957
|
+
min = 0;
|
|
4958
|
+
max = 0;
|
|
4959
|
+
}
|
|
4960
|
+
const anchor = min <= 0 && max >= 0 ? 0 : (min + max) / 2;
|
|
4961
|
+
const reach = Math.max(Math.abs(max - anchor), Math.abs(anchor - min));
|
|
4962
|
+
return { anchor, reach: reach > 0 ? reach : 1, min, max };
|
|
4963
|
+
}
|
|
4964
|
+
function tintOf(value, scale, betterWhen) {
|
|
4965
|
+
const distance = (value - scale.anchor) / scale.reach;
|
|
4966
|
+
const signed = betterWhen === HeatDirections.Lower ? -distance : distance;
|
|
4967
|
+
return Math.max(-1, Math.min(1, signed));
|
|
4968
|
+
}
|
|
4969
|
+
function valueAt(tint, scale, betterWhen) {
|
|
4970
|
+
const distance = betterWhen === HeatDirections.Lower ? -tint : tint;
|
|
4971
|
+
return scale.anchor + distance * scale.reach;
|
|
4972
|
+
}
|
|
4973
|
+
function layoutHeatmap(input) {
|
|
4974
|
+
const buckets = foldCells(input.cells);
|
|
4975
|
+
if (buckets.length === 0) return null;
|
|
4976
|
+
const columns = axisValues(buckets.map((bucket) => bucket.x));
|
|
4977
|
+
const rows = axisValues(buckets.map((bucket) => bucket.y));
|
|
4978
|
+
const scale = heatScale(buckets);
|
|
4979
|
+
const plot = {
|
|
4980
|
+
x: LEFT_GUTTER,
|
|
4981
|
+
y: TOP_BAND,
|
|
4982
|
+
width: Math.max(1, input.width - LEFT_GUTTER - RIGHT_PAD),
|
|
4983
|
+
height: Math.max(1, input.height - TOP_BAND - BOTTOM_BAND)
|
|
4984
|
+
};
|
|
4985
|
+
const cellWidth = plot.width / columns.length;
|
|
4986
|
+
const cellHeight = plot.height / rows.length;
|
|
4987
|
+
const rectOf = (column, row) => ({
|
|
4988
|
+
x: plot.x + column * cellWidth + CELL_INSET,
|
|
4989
|
+
// Row 0 sits at the FOOT of the plot: this is a chart, and a chart's Y grows upward. A
|
|
4990
|
+
// table would put it at the top, and then the map and its axis would disagree about
|
|
4991
|
+
// which corner is which.
|
|
4992
|
+
y: plot.y + plot.height - (row + 1) * cellHeight + CELL_INSET,
|
|
4993
|
+
width: Math.max(1, cellWidth - CELL_INSET * 2),
|
|
4994
|
+
height: Math.max(1, cellHeight - CELL_INSET * 2)
|
|
4995
|
+
});
|
|
4996
|
+
const measured = new Map(buckets.map((bucket) => [pairKey(bucket.x, bucket.y), bucket]));
|
|
4997
|
+
const cells = [];
|
|
4998
|
+
const gaps = [];
|
|
4999
|
+
for (let row = 0; row < rows.length; row++) {
|
|
5000
|
+
for (let column = 0; column < columns.length; column++) {
|
|
5001
|
+
const bucket = measured.get(pairKey(columns[column], rows[row]));
|
|
5002
|
+
if (bucket === void 0) {
|
|
5003
|
+
gaps.push({ x: columns[column], y: rows[row], rect: rectOf(column, row) });
|
|
5004
|
+
continue;
|
|
5005
|
+
}
|
|
5006
|
+
cells.push({
|
|
5007
|
+
bucket,
|
|
5008
|
+
rect: rectOf(column, row),
|
|
5009
|
+
tint: tintOf(bucket.value, scale, input.betterWhen),
|
|
5010
|
+
best: false
|
|
5011
|
+
});
|
|
5012
|
+
}
|
|
5013
|
+
}
|
|
5014
|
+
let best = cells[0];
|
|
5015
|
+
for (const shape of cells) if (shape.tint > best.tint) best = shape;
|
|
5016
|
+
best.best = true;
|
|
5017
|
+
const xTicks = tickIndices(columns.length, tickStep(columns.length, plot.width, MIN_TICK_WIDTH)).map((index) => ({
|
|
5018
|
+
text: columns[index],
|
|
5019
|
+
x: plot.x + (index + 0.5) * cellWidth,
|
|
5020
|
+
y: plot.y + plot.height + TICK_GAP
|
|
5021
|
+
}));
|
|
5022
|
+
const yTicks = tickIndices(rows.length, tickStep(rows.length, plot.height, MIN_TICK_HEIGHT)).map((index) => ({
|
|
5023
|
+
text: rows[index],
|
|
5024
|
+
x: plot.x - TICK_GAP,
|
|
5025
|
+
y: plot.y + plot.height - (index + 0.5) * cellHeight
|
|
5026
|
+
}));
|
|
5027
|
+
return {
|
|
5028
|
+
plot,
|
|
5029
|
+
columns,
|
|
5030
|
+
rows,
|
|
5031
|
+
cells,
|
|
5032
|
+
gaps,
|
|
5033
|
+
xTicks,
|
|
5034
|
+
yTicks,
|
|
5035
|
+
xTitle: { text: input.xLabel, x: plot.x + plot.width / 2, y: plot.y + plot.height + TICK_GAP + LABEL_HEIGHT },
|
|
5036
|
+
// Above the plot rather than beside it: a title down the left edge would have to be
|
|
5037
|
+
// rotated, and a rotation is a transform the control would then have to unwind around
|
|
5038
|
+
// everything else it draws.
|
|
5039
|
+
yTitle: { text: input.yLabel, x: 0, y: 0 },
|
|
5040
|
+
legend: legendOf(plot, scale, input.betterWhen),
|
|
5041
|
+
scale
|
|
5042
|
+
};
|
|
5043
|
+
}
|
|
5044
|
+
function hitHeatmap(layout, x, y) {
|
|
5045
|
+
for (const shape of layout.cells) {
|
|
5046
|
+
const rect = shape.rect;
|
|
5047
|
+
if (x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height)
|
|
5048
|
+
return shape;
|
|
5049
|
+
}
|
|
5050
|
+
return null;
|
|
5051
|
+
}
|
|
5052
|
+
function legendOf(plot, scale, betterWhen) {
|
|
5053
|
+
const right = plot.x + plot.width;
|
|
5054
|
+
const left = Math.max(plot.x, right - LEGEND_WIDTH);
|
|
5055
|
+
const stepWidth = (right - left) / LEGEND_STEPS;
|
|
5056
|
+
const steps = [];
|
|
5057
|
+
for (let step = 0; step < LEGEND_STEPS; step++) {
|
|
5058
|
+
steps.push({
|
|
5059
|
+
// Half a pixel of overlap: abutting fills leave a seam wherever the boundary is
|
|
5060
|
+
// fractional, and a key with a comb of background through it reads as a scale with
|
|
5061
|
+
// holes in it.
|
|
5062
|
+
rect: { x: left + step * stepWidth, y: LEGEND_Y, width: stepWidth + 0.5, height: LEGEND_HEIGHT },
|
|
5063
|
+
tint: -1 + 2 * step / (LEGEND_STEPS - 1)
|
|
5064
|
+
});
|
|
5065
|
+
}
|
|
5066
|
+
return {
|
|
5067
|
+
steps,
|
|
5068
|
+
low: { value: valueAt(-1, scale, betterWhen), x: left, y: 0 },
|
|
5069
|
+
anchor: { value: scale.anchor, x: (left + right) / 2, y: 0 },
|
|
5070
|
+
high: { value: valueAt(1, scale, betterWhen), x: right, y: 0 }
|
|
5071
|
+
};
|
|
5072
|
+
}
|
|
5073
|
+
function tickStep(count, span, room) {
|
|
5074
|
+
const slots = Math.max(1, Math.floor(span / room));
|
|
5075
|
+
return Math.max(1, Math.ceil(count / slots));
|
|
5076
|
+
}
|
|
5077
|
+
function tickIndices(count, step) {
|
|
5078
|
+
const indices = [];
|
|
5079
|
+
for (let index = 0; index < count; index += step) indices.push(index);
|
|
5080
|
+
const last = count - 1;
|
|
5081
|
+
if (indices[indices.length - 1] !== last) {
|
|
5082
|
+
if (last - indices[indices.length - 1] < step) indices.pop();
|
|
5083
|
+
indices.push(last);
|
|
5084
|
+
}
|
|
5085
|
+
return indices;
|
|
5086
|
+
}
|
|
5087
|
+
function pairKey(x, y) {
|
|
5088
|
+
return `${x}\0${y}`;
|
|
5089
|
+
}
|
|
5090
|
+
function numberOf(value) {
|
|
5091
|
+
return Number(value.trim().replace(/%$/, ""));
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5094
|
+
// src/optimization-heatmap-widget.ts
|
|
5095
|
+
var GROUND_ALPHA = 0.1;
|
|
5096
|
+
var GAP_ALPHA = 0.25;
|
|
5097
|
+
var LABEL_ALPHA = 0.65;
|
|
5098
|
+
var BEST_WIDTH = 2;
|
|
5099
|
+
var TOOLTIP_OFFSET = 12;
|
|
5100
|
+
var TOOLTIP_MARGIN = 4;
|
|
5101
|
+
var _OptimizationHeatmapWidget = class _OptimizationHeatmapWidget {
|
|
5102
|
+
static create(hostEl, state, deps) {
|
|
5103
|
+
const host = assertHost(deps?.host, "OptimizationHeatmapWidget");
|
|
5104
|
+
const root = _OptimizationHeatmapWidget._buildRoot(host);
|
|
5105
|
+
root.id = makePanelId(_OptimizationHeatmapWidget.TYPE);
|
|
5106
|
+
hostEl.appendChild(root);
|
|
5107
|
+
return new _OptimizationHeatmapWidget(root, state || {}, deps);
|
|
5108
|
+
}
|
|
5109
|
+
// The panel's markup. The metric's name sits in the header rather than on the canvas: it is
|
|
5110
|
+
// the caption the drawn legend is read against, and putting it in the chrome costs the map
|
|
5111
|
+
// no pixels and leaves the text selectable and sized by the stylesheet.
|
|
5112
|
+
static _buildRoot(host) {
|
|
5113
|
+
const title = host.t("OptimizationHeatmap");
|
|
5114
|
+
return makePanelRoot("optimization-heatmap-panel", title, [
|
|
5115
|
+
makeElement("div", "panel-header", {}, [
|
|
5116
|
+
makeElement("span", "", {}, [title]),
|
|
5117
|
+
makeElement("span", "heatmap-metric", {}, []),
|
|
5118
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
5119
|
+
]),
|
|
5120
|
+
makeElement("div", "panel-body", {}, [
|
|
5121
|
+
makeElement("canvas", "heatmap-canvas", { role: "img", "aria-label": host.t("OptimizationHeatmapChart") }, []),
|
|
5122
|
+
makeElement("div", "heatmap-empty", { hidden: "" }, [host.t("NoOptimizationResults")]),
|
|
5123
|
+
makeElement("div", "heatmap-tooltip", { role: "tooltip", hidden: "" }, [])
|
|
5124
|
+
])
|
|
5125
|
+
]);
|
|
5126
|
+
}
|
|
5127
|
+
constructor(rootEl, _state, deps) {
|
|
5128
|
+
this._host = assertHost(deps?.host, "OptimizationHeatmapWidget");
|
|
5129
|
+
this.rootEl = rootEl;
|
|
5130
|
+
this.canvasEl = this.rootEl.querySelector(".heatmap-canvas");
|
|
5131
|
+
this._metricEl = this.rootEl.querySelector(".heatmap-metric");
|
|
5132
|
+
this._emptyEl = this.rootEl.querySelector(".heatmap-empty");
|
|
5133
|
+
this._tooltipEl = this.rootEl.querySelector(".heatmap-tooltip");
|
|
5134
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
5135
|
+
this._ctx = this.canvasEl?.getContext("2d") ?? null;
|
|
5136
|
+
this._data = null;
|
|
5137
|
+
this._layout = null;
|
|
5138
|
+
this._size = null;
|
|
5139
|
+
this._resizeObserver = null;
|
|
5140
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
5141
|
+
e.preventDefault();
|
|
5142
|
+
this._host.close();
|
|
5143
|
+
});
|
|
5144
|
+
this._bindHover();
|
|
5145
|
+
this._setEmpty(true);
|
|
5146
|
+
if (this.canvasEl && typeof ResizeObserver === "function") {
|
|
5147
|
+
this._resizeObserver = new ResizeObserver(() => this._render());
|
|
5148
|
+
this._resizeObserver.observe(this.canvasEl);
|
|
5149
|
+
}
|
|
5150
|
+
this._host.register(this);
|
|
5151
|
+
}
|
|
5152
|
+
dispose() {
|
|
5153
|
+
try {
|
|
5154
|
+
this._resizeObserver?.disconnect();
|
|
5155
|
+
} catch {
|
|
5156
|
+
}
|
|
5157
|
+
this._host.unregister(this);
|
|
5158
|
+
try {
|
|
5159
|
+
this.rootEl.remove();
|
|
5160
|
+
} catch {
|
|
5161
|
+
}
|
|
5162
|
+
}
|
|
5163
|
+
/// Show this map. The whole of it at once: a pair that has dropped out of the set has stopped
|
|
5164
|
+
/// existing, and leaving its cell behind would report a run that is no longer in the report.
|
|
5165
|
+
update(data) {
|
|
5166
|
+
this._data = data;
|
|
5167
|
+
if (this._metricEl !== null) this._metricEl.textContent = data.metricLabel;
|
|
5168
|
+
this._hideTooltip();
|
|
5169
|
+
this._render();
|
|
5170
|
+
}
|
|
5171
|
+
// The canvas in CSS pixels, and its backing store in device pixels. Without the second the
|
|
5172
|
+
// map is drawn at a third of the resolution the screen has, and a lattice of hairlines is
|
|
5173
|
+
// exactly the drawing that shows it.
|
|
5174
|
+
_sizeCanvas() {
|
|
5175
|
+
const canvas = this.canvasEl;
|
|
5176
|
+
if (!canvas) return;
|
|
5177
|
+
const rect = canvas.getBoundingClientRect();
|
|
5178
|
+
const ratio = typeof devicePixelRatio === "number" && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
|
5179
|
+
const width = Math.max(1, Math.floor(rect.width));
|
|
5180
|
+
const height = Math.max(1, Math.floor(rect.height));
|
|
5181
|
+
if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
|
|
5182
|
+
canvas.width = width * ratio;
|
|
5183
|
+
canvas.height = height * ratio;
|
|
5184
|
+
this._ctx?.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
5185
|
+
}
|
|
5186
|
+
this._size = { width, height };
|
|
5187
|
+
}
|
|
5188
|
+
_render() {
|
|
5189
|
+
const ctx = this._ctx;
|
|
5190
|
+
if (ctx === null) return;
|
|
5191
|
+
this._sizeCanvas();
|
|
5192
|
+
const size = this._size;
|
|
5193
|
+
if (size === null) return;
|
|
5194
|
+
ctx.clearRect(0, 0, size.width, size.height);
|
|
5195
|
+
this._layout = null;
|
|
5196
|
+
const data = this._data;
|
|
5197
|
+
if (data === null) {
|
|
5198
|
+
this._setEmpty(true);
|
|
5199
|
+
return;
|
|
5200
|
+
}
|
|
5201
|
+
const layout = layoutHeatmap({
|
|
5202
|
+
width: size.width,
|
|
5203
|
+
height: size.height,
|
|
5204
|
+
cells: data.cells,
|
|
5205
|
+
betterWhen: data.betterWhen,
|
|
5206
|
+
xLabel: data.xLabel,
|
|
5207
|
+
yLabel: data.yLabel
|
|
5208
|
+
});
|
|
5209
|
+
this._setEmpty(layout === null);
|
|
5210
|
+
if (layout === null) return;
|
|
5211
|
+
this._layout = layout;
|
|
5212
|
+
const palette = this._host.presentation.canvasPalette();
|
|
5213
|
+
ctx.font = palette.font;
|
|
5214
|
+
ctx.lineWidth = 1;
|
|
5215
|
+
for (const shape of layout.cells) this._paintTint(ctx, shape.rect, shape.tint, palette.grid, palette.up, palette.down);
|
|
5216
|
+
ctx.globalAlpha = GAP_ALPHA;
|
|
5217
|
+
ctx.strokeStyle = palette.grid;
|
|
5218
|
+
for (const gap of layout.gaps) {
|
|
5219
|
+
ctx.beginPath();
|
|
5220
|
+
ctx.moveTo(gap.rect.x, gap.rect.y + gap.rect.height);
|
|
5221
|
+
ctx.lineTo(gap.rect.x + gap.rect.width, gap.rect.y);
|
|
5222
|
+
ctx.stroke();
|
|
5223
|
+
}
|
|
5224
|
+
const best = layout.cells.find((shape) => shape.best);
|
|
5225
|
+
if (best !== void 0) {
|
|
5226
|
+
ctx.globalAlpha = 1;
|
|
5227
|
+
ctx.strokeStyle = palette.grid;
|
|
5228
|
+
ctx.lineWidth = BEST_WIDTH;
|
|
5229
|
+
_OptimizationHeatmapWidget._outline(ctx, best.rect, BEST_WIDTH / 2);
|
|
5230
|
+
ctx.lineWidth = 1;
|
|
5231
|
+
}
|
|
5232
|
+
for (const step of layout.legend.steps)
|
|
5233
|
+
this._paintTint(ctx, step.rect, step.tint, palette.grid, palette.up, palette.down);
|
|
5234
|
+
ctx.globalAlpha = LABEL_ALPHA;
|
|
5235
|
+
ctx.fillStyle = palette.grid;
|
|
5236
|
+
ctx.textBaseline = "top";
|
|
5237
|
+
ctx.textAlign = "center";
|
|
5238
|
+
for (const tick of layout.xTicks) ctx.fillText(tick.text, tick.x, tick.y);
|
|
5239
|
+
ctx.fillText(layout.xTitle.text, layout.xTitle.x, layout.xTitle.y);
|
|
5240
|
+
ctx.fillText(formatStatistic(layout.legend.anchor.value), layout.legend.anchor.x, layout.legend.anchor.y);
|
|
5241
|
+
ctx.textAlign = "left";
|
|
5242
|
+
ctx.fillText(layout.yTitle.text, layout.yTitle.x, layout.yTitle.y);
|
|
5243
|
+
ctx.fillText(formatStatistic(layout.legend.low.value), layout.legend.low.x, layout.legend.low.y);
|
|
5244
|
+
ctx.textAlign = "right";
|
|
5245
|
+
ctx.fillText(formatStatistic(layout.legend.high.value), layout.legend.high.x, layout.legend.high.y);
|
|
5246
|
+
ctx.textBaseline = "middle";
|
|
5247
|
+
for (const tick of layout.yTicks) ctx.fillText(tick.text, tick.x, tick.y);
|
|
5248
|
+
ctx.globalAlpha = 1;
|
|
5249
|
+
}
|
|
5250
|
+
// The ground, then as much of the direction colour as the cell earned. Two fills rather than
|
|
5251
|
+
// one blended colour: blending would need a background this control is not allowed to know.
|
|
5252
|
+
_paintTint(ctx, rect, tint, ground, up, down) {
|
|
5253
|
+
ctx.globalAlpha = GROUND_ALPHA;
|
|
5254
|
+
ctx.fillStyle = ground;
|
|
5255
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
5256
|
+
if (tint === 0) return;
|
|
5257
|
+
ctx.globalAlpha = Math.abs(tint);
|
|
5258
|
+
ctx.fillStyle = tint > 0 ? up : down;
|
|
5259
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
5260
|
+
}
|
|
5261
|
+
static _outline(ctx, rect, inset) {
|
|
5262
|
+
const left = rect.x + inset;
|
|
5263
|
+
const top = rect.y + inset;
|
|
5264
|
+
const right = rect.x + rect.width - inset;
|
|
5265
|
+
const bottom = rect.y + rect.height - inset;
|
|
5266
|
+
ctx.beginPath();
|
|
5267
|
+
ctx.moveTo(left, top);
|
|
5268
|
+
ctx.lineTo(right, top);
|
|
5269
|
+
ctx.lineTo(right, bottom);
|
|
5270
|
+
ctx.lineTo(left, bottom);
|
|
5271
|
+
ctx.closePath();
|
|
5272
|
+
ctx.stroke();
|
|
5273
|
+
}
|
|
5274
|
+
_setEmpty(empty) {
|
|
5275
|
+
if (this._emptyEl === null) return;
|
|
5276
|
+
if (empty) this._emptyEl.removeAttribute("hidden");
|
|
5277
|
+
else this._emptyEl.setAttribute("hidden", "");
|
|
5278
|
+
}
|
|
5279
|
+
_bindHover() {
|
|
5280
|
+
const canvas = this.canvasEl;
|
|
5281
|
+
if (!canvas) return;
|
|
5282
|
+
canvas.addEventListener("mousemove", (e) => {
|
|
5283
|
+
const layout = this._layout;
|
|
5284
|
+
if (layout === null) {
|
|
5285
|
+
this._hideTooltip();
|
|
5286
|
+
return;
|
|
5287
|
+
}
|
|
5288
|
+
const rect = canvas.getBoundingClientRect();
|
|
5289
|
+
const x = e.clientX - rect.left;
|
|
5290
|
+
const y = e.clientY - rect.top;
|
|
5291
|
+
const hit = hitHeatmap(layout, x, y);
|
|
5292
|
+
if (hit !== null) this._showTooltip(hit, x, y);
|
|
5293
|
+
else this._hideTooltip();
|
|
5294
|
+
});
|
|
5295
|
+
canvas.addEventListener("mouseleave", () => this._hideTooltip());
|
|
5296
|
+
}
|
|
5297
|
+
_showTooltip(shape, x, y) {
|
|
5298
|
+
const tooltip = this._tooltipEl;
|
|
5299
|
+
const canvas = this.canvasEl;
|
|
5300
|
+
const data = this._data;
|
|
5301
|
+
if (!tooltip || !canvas || data === null) return;
|
|
5302
|
+
const lines = [
|
|
5303
|
+
this._tooltipLine(data.xLabel, shape.bucket.x),
|
|
5304
|
+
this._tooltipLine(data.yLabel, shape.bucket.y),
|
|
5305
|
+
this._tooltipLine(data.metricLabel, formatStatistic(shape.bucket.value))
|
|
5306
|
+
];
|
|
5307
|
+
if (shape.bucket.count > 1)
|
|
5308
|
+
lines.push(this._tooltipLine(this._host.t("Runs"), String(shape.bucket.count)));
|
|
5309
|
+
if (shape.best)
|
|
5310
|
+
lines.push(makeElement("div", "heatmap-tt-row heatmap-tt-best", {}, [this._host.t("Best")]));
|
|
5311
|
+
tooltip.replaceChildren(...lines);
|
|
5312
|
+
tooltip.removeAttribute("hidden");
|
|
5313
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
5314
|
+
const parentRect = tooltip.offsetParent?.getBoundingClientRect() ?? canvasRect;
|
|
5315
|
+
const originX = canvasRect.left - parentRect.left;
|
|
5316
|
+
const originY = canvasRect.top - parentRect.top;
|
|
5317
|
+
const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN;
|
|
5318
|
+
const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN;
|
|
5319
|
+
tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET, maxX))}px`;
|
|
5320
|
+
tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET, maxY))}px`;
|
|
5321
|
+
}
|
|
5322
|
+
_tooltipLine(label, value) {
|
|
5323
|
+
return makeElement("div", "heatmap-tt-row", {}, [
|
|
5324
|
+
makeElement("span", "heatmap-tt-label", {}, [label]),
|
|
5325
|
+
makeElement("span", "heatmap-tt-value", {}, [value])
|
|
5326
|
+
]);
|
|
5327
|
+
}
|
|
5328
|
+
_hideTooltip() {
|
|
5329
|
+
this._tooltipEl?.setAttribute("hidden", "");
|
|
5330
|
+
}
|
|
5331
|
+
};
|
|
5332
|
+
_OptimizationHeatmapWidget.TYPE = ControlTypes.OptimizationHeatmap;
|
|
5333
|
+
var OptimizationHeatmapWidget = _OptimizationHeatmapWidget;
|
|
5334
|
+
|
|
5335
|
+
// src/surface-grid.ts
|
|
5336
|
+
var MIN_PITCH = 0.12;
|
|
5337
|
+
var MAX_PITCH = 1.45;
|
|
5338
|
+
var DEFAULT_VIEW = { yaw: -0.7, pitch: 0.62, zoom: 1 };
|
|
5339
|
+
var DRAG_TO_RADIANS = 8e-3;
|
|
5340
|
+
var MIN_ZOOM = 0.4;
|
|
5341
|
+
var MAX_ZOOM = 4;
|
|
5342
|
+
function clampView(view) {
|
|
5343
|
+
const twoPi = Math.PI * 2;
|
|
5344
|
+
return {
|
|
5345
|
+
// Wrapped rather than clamped: turning the surface right round is a gesture a reader
|
|
5346
|
+
// makes on purpose, to see the far side of a ridge.
|
|
5347
|
+
yaw: (view.yaw % twoPi + twoPi) % twoPi,
|
|
5348
|
+
pitch: Math.min(MAX_PITCH, Math.max(MIN_PITCH, view.pitch)),
|
|
5349
|
+
zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, view.zoom))
|
|
5350
|
+
};
|
|
5351
|
+
}
|
|
5352
|
+
function dragView(view, dx, dy) {
|
|
5353
|
+
return clampView({
|
|
5354
|
+
yaw: view.yaw + dx * DRAG_TO_RADIANS,
|
|
5355
|
+
// Dragging down tips the surface towards a top view, which is the direction the gesture
|
|
5356
|
+
// suggests: the far edge comes up to meet the pointer.
|
|
5357
|
+
pitch: view.pitch + dy * DRAG_TO_RADIANS,
|
|
5358
|
+
zoom: view.zoom
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
5361
|
+
function zoomView(view, factor) {
|
|
5362
|
+
return clampView({ yaw: view.yaw, pitch: view.pitch, zoom: view.zoom * factor });
|
|
5363
|
+
}
|
|
5364
|
+
function project(nx, ny, nz, view, box) {
|
|
5365
|
+
const cy = Math.cos(view.yaw);
|
|
5366
|
+
const sy = Math.sin(view.yaw);
|
|
5367
|
+
const rx = nx * cy - ny * sy;
|
|
5368
|
+
const ry = nx * sy + ny * cy;
|
|
5369
|
+
const cp = Math.cos(view.pitch);
|
|
5370
|
+
const sp = Math.sin(view.pitch);
|
|
5371
|
+
const span = Math.min(box.width, box.height) / 2;
|
|
5372
|
+
const unit = span * view.zoom / Math.SQRT2;
|
|
5373
|
+
return {
|
|
5374
|
+
x: box.width / 2 + rx * unit,
|
|
5375
|
+
// Screen y grows downward, so height subtracts.
|
|
5376
|
+
y: box.height / 2 + (ry * sp - nz * cp) * unit,
|
|
5377
|
+
// Larger is nearer the viewer.
|
|
5378
|
+
depth: ry * cp + nz * sp
|
|
5379
|
+
};
|
|
5380
|
+
}
|
|
5381
|
+
function surfaceLayout(input) {
|
|
5382
|
+
const buckets = foldCells(input.cells);
|
|
5383
|
+
if (buckets.length === 0) return null;
|
|
5384
|
+
const xValues = axisOrder(buckets.map((b) => b.x));
|
|
5385
|
+
const yValues = axisOrder(buckets.map((b) => b.y));
|
|
5386
|
+
if (xValues.length < 2 || yValues.length < 2) return null;
|
|
5387
|
+
const scale = heatScale(buckets);
|
|
5388
|
+
const at = /* @__PURE__ */ new Map();
|
|
5389
|
+
for (const bucket of buckets) at.set(`${bucket.x} ${bucket.y}`, bucket);
|
|
5390
|
+
const box = { width: input.width, height: input.height };
|
|
5391
|
+
const view = clampView(input.view);
|
|
5392
|
+
const corners = [];
|
|
5393
|
+
const vertices = [];
|
|
5394
|
+
for (let ix = 0; ix < xValues.length; ix++) {
|
|
5395
|
+
const column = [];
|
|
5396
|
+
for (let iy = 0; iy < yValues.length; iy++) {
|
|
5397
|
+
const bucket = at.get(`${xValues[ix]} ${yValues[iy]}`);
|
|
5398
|
+
const tint = bucket === void 0 ? 0 : tintOf(bucket.value, scale, input.betterWhen);
|
|
5399
|
+
const nx = axisPosition(ix, xValues.length);
|
|
5400
|
+
const ny = axisPosition(iy, yValues.length);
|
|
5401
|
+
const point = project(nx, ny, (tint + 1) / 2, view, box);
|
|
5402
|
+
column.push({ ...point, tint });
|
|
5403
|
+
if (bucket !== void 0) {
|
|
5404
|
+
vertices.push({
|
|
5405
|
+
at: [point.x, point.y],
|
|
5406
|
+
x: xValues[ix],
|
|
5407
|
+
y: yValues[iy],
|
|
5408
|
+
value: bucket.value,
|
|
5409
|
+
depth: point.depth
|
|
5410
|
+
});
|
|
5411
|
+
}
|
|
5412
|
+
}
|
|
5413
|
+
corners.push(column);
|
|
5414
|
+
}
|
|
5415
|
+
const quads = [];
|
|
5416
|
+
for (let ix = 0; ix + 1 < xValues.length; ix++) {
|
|
5417
|
+
for (let iy = 0; iy + 1 < yValues.length; iy++) {
|
|
5418
|
+
const near = at.get(`${xValues[ix]} ${yValues[iy]}`);
|
|
5419
|
+
const holes = [
|
|
5420
|
+
near,
|
|
5421
|
+
at.get(`${xValues[ix + 1]} ${yValues[iy]}`),
|
|
5422
|
+
at.get(`${xValues[ix + 1]} ${yValues[iy + 1]}`),
|
|
5423
|
+
at.get(`${xValues[ix]} ${yValues[iy + 1]}`)
|
|
5424
|
+
];
|
|
5425
|
+
if (near === void 0 || holes.some((b2) => b2 === void 0)) continue;
|
|
5426
|
+
const a = corners[ix][iy];
|
|
5427
|
+
const b = corners[ix + 1][iy];
|
|
5428
|
+
const c = corners[ix + 1][iy + 1];
|
|
5429
|
+
const d = corners[ix][iy + 1];
|
|
5430
|
+
quads.push({
|
|
5431
|
+
points: [[a.x, a.y], [b.x, b.y], [c.x, c.y], [d.x, d.y]],
|
|
5432
|
+
// The mean of the corners, so a face slopes in colour the way it slopes in height
|
|
5433
|
+
// rather than taking the tint of whichever corner happened to be first.
|
|
5434
|
+
tint: (a.tint + b.tint + c.tint + d.tint) / 4,
|
|
5435
|
+
depth: (a.depth + b.depth + c.depth + d.depth) / 4,
|
|
5436
|
+
bucket: near
|
|
5437
|
+
});
|
|
5438
|
+
}
|
|
5439
|
+
}
|
|
5440
|
+
quads.sort((p, q) => p.depth - q.depth);
|
|
5441
|
+
const floor = (nx, ny) => {
|
|
5442
|
+
const p = project(nx, ny, 0, view, box);
|
|
5443
|
+
return [p.x, p.y];
|
|
5444
|
+
};
|
|
5445
|
+
const outward = (from, to) => {
|
|
5446
|
+
const centre = floor(0, 0);
|
|
5447
|
+
const midX = (from[0] + to[0]) / 2;
|
|
5448
|
+
const midY = (from[1] + to[1]) / 2;
|
|
5449
|
+
const dx = midX - centre[0];
|
|
5450
|
+
const dy = midY - centre[1];
|
|
5451
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
5452
|
+
return [dx / length, dy / length];
|
|
5453
|
+
};
|
|
5454
|
+
const depthAt = (nx, ny) => project(nx, ny, 0, view, box).depth;
|
|
5455
|
+
const xSide = depthAt(0, -1) >= depthAt(0, 1) ? -1 : 1;
|
|
5456
|
+
const ySide = depthAt(-1, 0) >= depthAt(1, 0) ? -1 : 1;
|
|
5457
|
+
const xEdge = [floor(-1, xSide), floor(1, xSide)];
|
|
5458
|
+
const yEdge = [floor(ySide, -1), floor(ySide, 1)];
|
|
5459
|
+
const xAway = outward(xEdge[0], xEdge[1]);
|
|
5460
|
+
const yAway = outward(yEdge[0], yEdge[1]);
|
|
5461
|
+
const zBottom = project(-ySide, -xSide, 0, view, box);
|
|
5462
|
+
const zTop = project(-ySide, -xSide, 1, view, box);
|
|
5463
|
+
const zAway = outward([zBottom.x, zBottom.y], [zTop.x, zTop.y]);
|
|
5464
|
+
return {
|
|
5465
|
+
quads,
|
|
5466
|
+
vertices,
|
|
5467
|
+
axes: [
|
|
5468
|
+
{
|
|
5469
|
+
from: xEdge[0],
|
|
5470
|
+
to: xEdge[1],
|
|
5471
|
+
axis: "x",
|
|
5472
|
+
ticks: axisTicks(xValues, (i) => floor(axisPosition(i, xValues.length), xSide), xAway)
|
|
5473
|
+
},
|
|
5474
|
+
{
|
|
5475
|
+
from: yEdge[0],
|
|
5476
|
+
to: yEdge[1],
|
|
5477
|
+
axis: "y",
|
|
5478
|
+
ticks: axisTicks(yValues, (i) => floor(ySide, axisPosition(i, yValues.length)), yAway)
|
|
5479
|
+
},
|
|
5480
|
+
{
|
|
5481
|
+
from: [zBottom.x, zBottom.y],
|
|
5482
|
+
to: [zTop.x, zTop.y],
|
|
5483
|
+
axis: "z",
|
|
5484
|
+
// Five marks up the height, which is what the colour scale runs over: the floor is
|
|
5485
|
+
// the worst run and the top the best. Worded by the caller, which knows the metric.
|
|
5486
|
+
ticks: heightTicks(view, box, zAway, -ySide, -xSide)
|
|
5487
|
+
}
|
|
5488
|
+
],
|
|
5489
|
+
xValues,
|
|
5490
|
+
yValues,
|
|
5491
|
+
scale
|
|
5492
|
+
};
|
|
5493
|
+
}
|
|
5494
|
+
var SURFACE_HEIGHT_TICKS = [0, 0.25, 0.5, 0.75, 1];
|
|
5495
|
+
var MAX_AXIS_TICKS = 8;
|
|
5496
|
+
function axisTicks(values, at, away) {
|
|
5497
|
+
if (values.length === 0) return [];
|
|
5498
|
+
const step = Math.max(1, Math.ceil(values.length / MAX_AXIS_TICKS));
|
|
5499
|
+
const ticks = [];
|
|
5500
|
+
for (let i = 0; i < values.length; i += step) ticks.push({ at: at(i), label: values[i], away });
|
|
5501
|
+
const last = values.length - 1;
|
|
5502
|
+
if (last > 0 && last % step !== 0)
|
|
5503
|
+
ticks.push({ at: at(last), label: values[last], away });
|
|
5504
|
+
return ticks;
|
|
5505
|
+
}
|
|
5506
|
+
function heightTicks(view, box, away, nx, ny) {
|
|
5507
|
+
return SURFACE_HEIGHT_TICKS.map((height) => {
|
|
5508
|
+
const point = project(nx, ny, height, view, box);
|
|
5509
|
+
return { at: [point.x, point.y], label: String(height), away };
|
|
5510
|
+
});
|
|
5511
|
+
}
|
|
5512
|
+
function nearestVertex(vertices, x, y, reach) {
|
|
5513
|
+
let best = null;
|
|
5514
|
+
let bestDistance = reach;
|
|
5515
|
+
for (const vertex of vertices) {
|
|
5516
|
+
const distance = Math.hypot(vertex.at[0] - x, vertex.at[1] - y);
|
|
5517
|
+
if (distance > bestDistance) continue;
|
|
5518
|
+
if (distance === bestDistance && best !== null && vertex.depth <= best.depth) continue;
|
|
5519
|
+
best = vertex;
|
|
5520
|
+
bestDistance = distance;
|
|
5521
|
+
}
|
|
5522
|
+
return best;
|
|
5523
|
+
}
|
|
5524
|
+
function axisOrder(values) {
|
|
5525
|
+
const distinct = [...new Set(values)];
|
|
5526
|
+
const numeric = distinct.every((v) => v.trim() !== "" && isFinite(Number(v)));
|
|
5527
|
+
return numeric ? distinct.sort((a, b) => Number(a) - Number(b)) : distinct.sort((a, b) => a.localeCompare(b));
|
|
5528
|
+
}
|
|
5529
|
+
function axisPosition(index, count) {
|
|
5530
|
+
return count < 2 ? 0 : index / (count - 1) * 2 - 1;
|
|
5531
|
+
}
|
|
5532
|
+
|
|
5533
|
+
// src/surface-widget.ts
|
|
5534
|
+
var FACE_ALPHA = 0.85;
|
|
5535
|
+
var EDGE_ALPHA = 0.35;
|
|
5536
|
+
var FLOOR_ALPHA = 0.5;
|
|
5537
|
+
var EDGE_WIDTH = 1;
|
|
5538
|
+
var LABEL_ALPHA2 = 0.8;
|
|
5539
|
+
var WHEEL_STEP = 1.12;
|
|
5540
|
+
var WHEEL_UNITS_PER_NOTCH = 100;
|
|
5541
|
+
var WHEEL_MAX_NOTCHES = 2;
|
|
5542
|
+
function wheelNotches(deltaY, deltaMode) {
|
|
5543
|
+
if (!Number.isFinite(deltaY) || deltaY === 0) return 0;
|
|
5544
|
+
const perNotch = deltaMode === 1 ? 3 : deltaMode === 2 ? 1 : WHEEL_UNITS_PER_NOTCH;
|
|
5545
|
+
const notches = deltaY / perNotch;
|
|
5546
|
+
return Math.max(-WHEEL_MAX_NOTCHES, Math.min(WHEEL_MAX_NOTCHES, notches));
|
|
5547
|
+
}
|
|
5548
|
+
var TICK_LENGTH = 5;
|
|
5549
|
+
var TICK_GAP2 = 4;
|
|
5550
|
+
var CAPTION_OFFSET = 34;
|
|
5551
|
+
var HOVER_REACH = 22;
|
|
5552
|
+
var HOVER_RING = 5;
|
|
5553
|
+
var _SurfaceWidget = class _SurfaceWidget {
|
|
5554
|
+
static create(hostEl, state, deps) {
|
|
5555
|
+
const host = assertHost(deps?.host, "SurfaceWidget");
|
|
5556
|
+
const root = _SurfaceWidget._buildRoot(host);
|
|
5557
|
+
root.id = makePanelId(_SurfaceWidget.TYPE);
|
|
5558
|
+
hostEl.appendChild(root);
|
|
5559
|
+
return new _SurfaceWidget(root, state || {}, deps);
|
|
5560
|
+
}
|
|
5561
|
+
static _buildRoot(host) {
|
|
5562
|
+
const title = host.t("OptimizationSurface");
|
|
5563
|
+
return makePanelRoot("surface-panel", title, [
|
|
5564
|
+
makeElement("div", "panel-header", {}, [
|
|
5565
|
+
makeElement("span", "", {}, [title]),
|
|
5566
|
+
makeElement("span", "surface-metric", {}, []),
|
|
5567
|
+
makeIconButton("bt-icon-btn surface-reset-btn", host.t("ResetView"), "bi-arrow-clockwise", { type: "button" }),
|
|
5568
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
5569
|
+
]),
|
|
5570
|
+
makeElement("div", "panel-body", {}, [
|
|
5571
|
+
makeElement("div", "surface-chart", {}, [
|
|
5572
|
+
makeElement("canvas", "surface-canvas", { role: "img", "aria-label": host.t("OptimizationSurfaceChart") }, []),
|
|
5573
|
+
// Over the canvas rather than in the panel header: a reading about the point
|
|
5574
|
+
// under the pointer belongs beside that point. A host that lifts its panel
|
|
5575
|
+
// headers into a tab strip would otherwise put the reading in a tab title.
|
|
5576
|
+
makeElement("div", "surface-readout", {}, []),
|
|
5577
|
+
makeElement("div", "surface-empty", { hidden: "" }, [host.t("NoOptimizationResults")])
|
|
5578
|
+
])
|
|
5579
|
+
])
|
|
5580
|
+
]);
|
|
5581
|
+
}
|
|
5582
|
+
constructor(rootEl, _state, deps) {
|
|
5583
|
+
this._host = assertHost(deps?.host, "SurfaceWidget");
|
|
5584
|
+
this.rootEl = rootEl;
|
|
5585
|
+
this.canvasEl = this.rootEl.querySelector(".surface-canvas");
|
|
5586
|
+
this._emptyEl = this.rootEl.querySelector(".surface-empty");
|
|
5587
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
5588
|
+
this._resetBtn = this.rootEl.querySelector(".surface-reset-btn");
|
|
5589
|
+
this._ctx = this.canvasEl?.getContext("2d") ?? null;
|
|
5590
|
+
this._data = null;
|
|
5591
|
+
this._layout = null;
|
|
5592
|
+
this._hover = null;
|
|
5593
|
+
this._hoverEl = this.rootEl.querySelector(".surface-readout");
|
|
5594
|
+
this._view = DEFAULT_VIEW;
|
|
5595
|
+
this._resizeObserver = null;
|
|
5596
|
+
this._pointers = /* @__PURE__ */ new Map();
|
|
5597
|
+
this._pinch = 0;
|
|
5598
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
5599
|
+
e.preventDefault();
|
|
5600
|
+
this._host.close();
|
|
5601
|
+
});
|
|
5602
|
+
this._resetBtn?.addEventListener("click", (e) => {
|
|
5603
|
+
e.preventDefault();
|
|
5604
|
+
this._view = DEFAULT_VIEW;
|
|
5605
|
+
this._render();
|
|
5606
|
+
});
|
|
5607
|
+
this._bindGestures();
|
|
5608
|
+
if (this.canvasEl !== null && typeof ResizeObserver !== "undefined") {
|
|
5609
|
+
this._resizeObserver = new ResizeObserver(() => this._render());
|
|
5610
|
+
this._resizeObserver.observe(this.canvasEl);
|
|
5611
|
+
}
|
|
5612
|
+
this._host.register(this);
|
|
5613
|
+
}
|
|
5614
|
+
dispose() {
|
|
5615
|
+
this._resizeObserver?.disconnect();
|
|
5616
|
+
this._resizeObserver = null;
|
|
5617
|
+
this._host.unregister(this);
|
|
5618
|
+
try {
|
|
5619
|
+
this.rootEl.remove();
|
|
5620
|
+
} catch {
|
|
5621
|
+
}
|
|
5622
|
+
}
|
|
5623
|
+
/// Show these results. The whole set at once: a surface is one sweep, and half of one drawn
|
|
5624
|
+
/// over the other half would be a landscape of two different runs.
|
|
5625
|
+
update(data) {
|
|
5626
|
+
this._data = data;
|
|
5627
|
+
this._render();
|
|
5628
|
+
}
|
|
5629
|
+
/// The view the surface is currently seen from, so a host can persist it.
|
|
5630
|
+
view() {
|
|
5631
|
+
return { ...this._view };
|
|
5632
|
+
}
|
|
5633
|
+
/// Put the surface back where it started.
|
|
5634
|
+
resetView() {
|
|
5635
|
+
this._view = DEFAULT_VIEW;
|
|
5636
|
+
this._render();
|
|
5637
|
+
}
|
|
5638
|
+
// One pointer turns, two zoom, a wheel zooms. Pointer events cover mouse, pen and touch, so
|
|
5639
|
+
// there is one implementation rather than one per input device.
|
|
5640
|
+
_bindGestures() {
|
|
5641
|
+
const canvas = this.canvasEl;
|
|
5642
|
+
if (canvas === null) return;
|
|
5643
|
+
canvas.addEventListener("pointerdown", (event) => {
|
|
5644
|
+
event.preventDefault();
|
|
5645
|
+
try {
|
|
5646
|
+
canvas.setPointerCapture(event.pointerId);
|
|
5647
|
+
} catch {
|
|
5648
|
+
}
|
|
5649
|
+
this._pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
5650
|
+
this._pinch = this._pointerSpread();
|
|
5651
|
+
});
|
|
5652
|
+
canvas.addEventListener("pointermove", (event) => {
|
|
5653
|
+
const previous = this._pointers.get(event.pointerId);
|
|
5654
|
+
if (previous === void 0) return;
|
|
5655
|
+
event.preventDefault();
|
|
5656
|
+
this._pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
5657
|
+
if (this._pointers.size >= 2) {
|
|
5658
|
+
const spread = this._pointerSpread();
|
|
5659
|
+
if (this._pinch > 0 && spread > 0) this._view = zoomView(this._view, spread / this._pinch);
|
|
5660
|
+
this._pinch = spread;
|
|
5661
|
+
} else {
|
|
5662
|
+
this._view = dragView(this._view, event.clientX - previous.x, event.clientY - previous.y);
|
|
5663
|
+
}
|
|
5664
|
+
this._render();
|
|
5665
|
+
});
|
|
5666
|
+
const release = (event) => {
|
|
5667
|
+
this._pointers.delete(event.pointerId);
|
|
5668
|
+
this._pinch = this._pointerSpread();
|
|
5669
|
+
};
|
|
5670
|
+
canvas.addEventListener("pointerup", release);
|
|
5671
|
+
canvas.addEventListener("pointercancel", release);
|
|
5672
|
+
canvas.addEventListener("pointerleave", release);
|
|
5673
|
+
canvas.addEventListener("pointermove", (event) => {
|
|
5674
|
+
if (this._pointers.size > 0) return;
|
|
5675
|
+
const box = canvas.getBoundingClientRect();
|
|
5676
|
+
this._setHover(event.clientX - box.left, event.clientY - box.top);
|
|
5677
|
+
});
|
|
5678
|
+
canvas.addEventListener("pointerleave", () => this._setHover(null, null));
|
|
5679
|
+
canvas.addEventListener("wheel", (event) => {
|
|
5680
|
+
event.preventDefault();
|
|
5681
|
+
const notches = wheelNotches(event.deltaY, event.deltaMode);
|
|
5682
|
+
if (notches === 0) return;
|
|
5683
|
+
this._view = zoomView(this._view, Math.pow(WHEEL_STEP, -notches));
|
|
5684
|
+
this._render();
|
|
5685
|
+
}, { passive: false });
|
|
5686
|
+
}
|
|
5687
|
+
// The vertex under the pointer, and a repaint when it changes. Compared by identity of the
|
|
5688
|
+
// grid pair rather than of the object: a re-layout builds new vertices for the same runs.
|
|
5689
|
+
_setHover(x, y) {
|
|
5690
|
+
const found = x === null || y === null || this._layout === null ? null : nearestVertex(this._layout.vertices, x, y, HOVER_REACH);
|
|
5691
|
+
const before = this._hover;
|
|
5692
|
+
const same = before !== null && found !== null && before.x === found.x && before.y === found.y;
|
|
5693
|
+
if (same || before === null && found === null) return;
|
|
5694
|
+
this._hover = found;
|
|
5695
|
+
this._renderHover();
|
|
5696
|
+
this._render();
|
|
5697
|
+
}
|
|
5698
|
+
// Which pair it is and what it measured, in the panel's own strip over the canvas.
|
|
5699
|
+
_renderHover() {
|
|
5700
|
+
const el = this._hoverEl;
|
|
5701
|
+
if (el === null || this._data === null) return;
|
|
5702
|
+
const hover = this._hover;
|
|
5703
|
+
if (hover === null) {
|
|
5704
|
+
el.textContent = "";
|
|
5705
|
+
return;
|
|
5706
|
+
}
|
|
5707
|
+
el.textContent = `${this._data.xLabel} ${hover.x} \xB7 ${this._data.yLabel} ${hover.y} ${formatPnl(hover.value)}`;
|
|
5708
|
+
}
|
|
5709
|
+
// How far apart the two pointers are, or zero when there are not two.
|
|
5710
|
+
_pointerSpread() {
|
|
5711
|
+
const points = [...this._pointers.values()];
|
|
5712
|
+
if (points.length < 2) return 0;
|
|
5713
|
+
return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
|
|
5714
|
+
}
|
|
5715
|
+
_render() {
|
|
5716
|
+
const canvas = this.canvasEl;
|
|
5717
|
+
const ctx = this._ctx;
|
|
5718
|
+
if (canvas === null || ctx === null) return;
|
|
5719
|
+
const box = canvas.getBoundingClientRect();
|
|
5720
|
+
const width = Math.round(box.width);
|
|
5721
|
+
const height = Math.round(box.height);
|
|
5722
|
+
if (width <= 0 || height <= 0) return;
|
|
5723
|
+
const ratio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
|
|
5724
|
+
canvas.width = Math.round(width * ratio);
|
|
5725
|
+
canvas.height = Math.round(height * ratio);
|
|
5726
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
5727
|
+
ctx.clearRect(0, 0, width, height);
|
|
5728
|
+
const metric = this.rootEl.querySelector(".surface-metric");
|
|
5729
|
+
if (metric !== null) metric.textContent = this._data?.metricLabel ?? "";
|
|
5730
|
+
const layout = this._data === null ? null : surfaceLayout({
|
|
5731
|
+
width,
|
|
5732
|
+
height,
|
|
5733
|
+
cells: this._data.cells,
|
|
5734
|
+
betterWhen: this._data.betterWhen,
|
|
5735
|
+
view: this._view
|
|
5736
|
+
});
|
|
5737
|
+
this._layout = layout;
|
|
5738
|
+
if (this._emptyEl !== null) this._emptyEl.hidden = layout !== null;
|
|
5739
|
+
if (layout === null || this._data === null) {
|
|
5740
|
+
this._hover = null;
|
|
5741
|
+
this._renderHover();
|
|
5742
|
+
return;
|
|
5743
|
+
}
|
|
5744
|
+
const palette = this._host.presentation.canvasPalette();
|
|
5745
|
+
this._drawFloor(ctx, layout, palette.grid);
|
|
5746
|
+
this._drawFaces(ctx, layout, palette);
|
|
5747
|
+
this._drawAxes(ctx, layout, palette);
|
|
5748
|
+
this._drawHover(ctx, palette);
|
|
5749
|
+
}
|
|
5750
|
+
// The three axes: the two the grid is swept over and the one it is measured against, each with
|
|
5751
|
+
// its own marks. A landscape with no numbers on it is a picture of a shape, not a reading.
|
|
5752
|
+
_drawAxes(ctx, layout, palette) {
|
|
5753
|
+
if (this._data === null) return;
|
|
5754
|
+
ctx.font = palette.font;
|
|
5755
|
+
ctx.textBaseline = "middle";
|
|
5756
|
+
ctx.strokeStyle = palette.grid;
|
|
5757
|
+
ctx.fillStyle = palette.grid;
|
|
5758
|
+
ctx.lineWidth = EDGE_WIDTH;
|
|
5759
|
+
for (const axis of layout.axes) {
|
|
5760
|
+
ctx.globalAlpha = LABEL_ALPHA2;
|
|
5761
|
+
for (const tick of axis.ticks) {
|
|
5762
|
+
const [ax, ay] = tick.at;
|
|
5763
|
+
const [dx, dy] = tick.away;
|
|
5764
|
+
ctx.beginPath();
|
|
5765
|
+
ctx.moveTo(ax, ay);
|
|
5766
|
+
ctx.lineTo(ax + dx * TICK_LENGTH, ay + dy * TICK_LENGTH);
|
|
5767
|
+
ctx.stroke();
|
|
5768
|
+
const tx = ax + dx * (TICK_LENGTH + TICK_GAP2);
|
|
5769
|
+
const ty = ay + dy * (TICK_LENGTH + TICK_GAP2);
|
|
5770
|
+
ctx.textAlign = Math.abs(dx) < 0.4 ? "center" : dx > 0 ? "left" : "right";
|
|
5771
|
+
ctx.fillText(this._tickLabel(axis.axis, tick.label, layout), tx, ty);
|
|
5772
|
+
}
|
|
5773
|
+
const midX = (axis.from[0] + axis.to[0]) / 2;
|
|
5774
|
+
const midY = (axis.from[1] + axis.to[1]) / 2;
|
|
5775
|
+
const away = axis.ticks.length > 0 ? axis.ticks[0].away : [0, 1];
|
|
5776
|
+
ctx.globalAlpha = 1;
|
|
5777
|
+
ctx.textAlign = "center";
|
|
5778
|
+
ctx.fillText(
|
|
5779
|
+
this._axisCaption(axis.axis),
|
|
5780
|
+
midX + away[0] * CAPTION_OFFSET,
|
|
5781
|
+
midY + away[1] * CAPTION_OFFSET
|
|
5782
|
+
);
|
|
5783
|
+
}
|
|
5784
|
+
ctx.globalAlpha = 1;
|
|
5785
|
+
}
|
|
5786
|
+
// The vertical axis is measured in the metric, so its 0..1 heights are worded as figures; the
|
|
5787
|
+
// other two carry the parameter values as they were swept.
|
|
5788
|
+
_tickLabel(axis, label, layout) {
|
|
5789
|
+
if (axis !== "z" || this._data === null) return label;
|
|
5790
|
+
const height = Number(label);
|
|
5791
|
+
return formatPnl(valueAt(height * 2 - 1, layout.scale, this._data.betterWhen));
|
|
5792
|
+
}
|
|
5793
|
+
_axisCaption(axis) {
|
|
5794
|
+
if (this._data === null) return "";
|
|
5795
|
+
if (axis === "x") return this._data.xLabel;
|
|
5796
|
+
if (axis === "y") return this._data.yLabel;
|
|
5797
|
+
return this._data.metricLabel;
|
|
5798
|
+
}
|
|
5799
|
+
// A ring on the vertex under the pointer, so the figure in the strip has a place on the
|
|
5800
|
+
// landscape rather than being a number beside a picture.
|
|
5801
|
+
_drawHover(ctx, palette) {
|
|
5802
|
+
const hover = this._hover;
|
|
5803
|
+
if (hover === null) return;
|
|
5804
|
+
ctx.beginPath();
|
|
5805
|
+
ctx.arc(hover.at[0], hover.at[1], HOVER_RING, 0, Math.PI * 2);
|
|
5806
|
+
ctx.strokeStyle = palette.up;
|
|
5807
|
+
ctx.lineWidth = 2;
|
|
5808
|
+
ctx.globalAlpha = 1;
|
|
5809
|
+
ctx.stroke();
|
|
5810
|
+
}
|
|
5811
|
+
// The frame the surface stands on, so a reader can tell which way the grid runs even when
|
|
5812
|
+
// the landscape hides its own edges.
|
|
5813
|
+
_drawFloor(ctx, layout, colour) {
|
|
5814
|
+
ctx.globalAlpha = FLOOR_ALPHA;
|
|
5815
|
+
ctx.strokeStyle = colour;
|
|
5816
|
+
ctx.lineWidth = EDGE_WIDTH;
|
|
5817
|
+
for (const axis of layout.axes) {
|
|
5818
|
+
ctx.beginPath();
|
|
5819
|
+
ctx.moveTo(axis.from[0], axis.from[1]);
|
|
5820
|
+
ctx.lineTo(axis.to[0], axis.to[1]);
|
|
5821
|
+
ctx.stroke();
|
|
5822
|
+
}
|
|
5823
|
+
ctx.globalAlpha = 1;
|
|
5824
|
+
}
|
|
5825
|
+
// Far to near, each face filled by how good it is and outlined so the grid stays legible
|
|
5826
|
+
// where two faces are nearly the same colour.
|
|
5827
|
+
_drawFaces(ctx, layout, palette) {
|
|
5828
|
+
for (const quad of layout.quads) {
|
|
5829
|
+
ctx.beginPath();
|
|
5830
|
+
ctx.moveTo(quad.points[0][0], quad.points[0][1]);
|
|
5831
|
+
for (const [x, y] of quad.points.slice(1)) ctx.lineTo(x, y);
|
|
5832
|
+
ctx.closePath();
|
|
5833
|
+
ctx.globalAlpha = FACE_ALPHA * Math.max(0.18, Math.abs(quad.tint));
|
|
5834
|
+
ctx.fillStyle = quad.tint >= 0 ? palette.up : palette.down;
|
|
5835
|
+
ctx.fill();
|
|
5836
|
+
ctx.globalAlpha = EDGE_ALPHA;
|
|
5837
|
+
ctx.strokeStyle = palette.grid;
|
|
5838
|
+
ctx.lineWidth = EDGE_WIDTH;
|
|
5839
|
+
ctx.stroke();
|
|
5840
|
+
}
|
|
5841
|
+
ctx.globalAlpha = 1;
|
|
5842
|
+
}
|
|
5843
|
+
};
|
|
5844
|
+
_SurfaceWidget.TYPE = ControlTypes.OptimizationSurface;
|
|
5845
|
+
var SurfaceWidget = _SurfaceWidget;
|
|
5846
|
+
|
|
5847
|
+
// src/black-scholes.ts
|
|
5848
|
+
var OptionTypes = {
|
|
5849
|
+
Call: "call",
|
|
5850
|
+
Put: "put"
|
|
5851
|
+
};
|
|
5852
|
+
var SQRT_2PI = Math.sqrt(2 * Math.PI);
|
|
5853
|
+
var DAYS_IN_YEAR = 365;
|
|
5854
|
+
function normalCdf(x) {
|
|
5855
|
+
const z = Math.abs(x);
|
|
5856
|
+
if (z > 37) return x > 0 ? 1 : 0;
|
|
5857
|
+
const e = Math.exp(-(z * z) / 2);
|
|
5858
|
+
let tail;
|
|
5859
|
+
if (z < 7.07106781186547) {
|
|
5860
|
+
let b = 0.0352624965998911 * z + 0.700383064443688;
|
|
5861
|
+
b = b * z + 6.37396220353165;
|
|
5862
|
+
b = b * z + 33.912866078383;
|
|
5863
|
+
b = b * z + 112.079291497871;
|
|
5864
|
+
b = b * z + 221.213596169931;
|
|
5865
|
+
b = b * z + 220.206867912376;
|
|
5866
|
+
let d = 0.0883883476483184 * z + 1.75566716318264;
|
|
5867
|
+
d = d * z + 16.064177579207;
|
|
5868
|
+
d = d * z + 86.7807322029461;
|
|
5869
|
+
d = d * z + 296.564248779674;
|
|
5870
|
+
d = d * z + 637.333633378831;
|
|
5871
|
+
d = d * z + 793.826512519948;
|
|
5872
|
+
d = d * z + 440.413735824752;
|
|
5873
|
+
tail = e * b / d;
|
|
5874
|
+
} else {
|
|
5875
|
+
let b = z + 0.65;
|
|
5876
|
+
b = z + 4 / b;
|
|
5877
|
+
b = z + 3 / b;
|
|
5878
|
+
b = z + 2 / b;
|
|
5879
|
+
b = z + 1 / b;
|
|
5880
|
+
tail = e / (b * 2.506628274631);
|
|
5881
|
+
}
|
|
5882
|
+
return x > 0 ? 1 - tail : tail;
|
|
5883
|
+
}
|
|
5884
|
+
function normalPdf(x) {
|
|
5885
|
+
return Math.exp(-0.5 * x * x) / SQRT_2PI;
|
|
5886
|
+
}
|
|
5887
|
+
function d1(inputs) {
|
|
5888
|
+
const { assetPrice, strike, timeToExpiry, riskFree, dividend, deviation } = inputs;
|
|
5889
|
+
const spread = deviation * Math.sqrt(timeToExpiry);
|
|
5890
|
+
if (spread === 0 || assetPrice <= 0 || strike <= 0) return 0;
|
|
5891
|
+
return (Math.log(assetPrice / strike) + (riskFree - dividend + deviation * deviation / 2) * timeToExpiry) / spread;
|
|
5892
|
+
}
|
|
5893
|
+
function d2(inputs) {
|
|
5894
|
+
const spread = inputs.deviation * Math.sqrt(inputs.timeToExpiry);
|
|
5895
|
+
return spread === 0 ? 0 : d1(inputs) - spread;
|
|
5896
|
+
}
|
|
5897
|
+
function premium(type, inputs) {
|
|
5898
|
+
const { assetPrice, strike, timeToExpiry, riskFree, dividend, deviation } = inputs;
|
|
5899
|
+
if (timeToExpiry <= 0 || deviation <= 0)
|
|
5900
|
+
return Math.max(0, type === OptionTypes.Call ? assetPrice - strike : strike - assetPrice);
|
|
5901
|
+
const a = d1(inputs);
|
|
5902
|
+
const b = d2(inputs);
|
|
5903
|
+
const carried = assetPrice * Math.exp(-dividend * timeToExpiry);
|
|
5904
|
+
const discounted = strike * Math.exp(-riskFree * timeToExpiry);
|
|
5905
|
+
return type === OptionTypes.Call ? carried * normalCdf(a) - discounted * normalCdf(b) : discounted * normalCdf(-b) - carried * normalCdf(-a);
|
|
5906
|
+
}
|
|
5907
|
+
function greeks(type, inputs) {
|
|
5908
|
+
const { assetPrice, strike, timeToExpiry, riskFree, dividend, deviation } = inputs;
|
|
5909
|
+
const sign = type === OptionTypes.Call ? 1 : -1;
|
|
5910
|
+
if (timeToExpiry <= 0 || deviation <= 0 || assetPrice <= 0) {
|
|
5911
|
+
const inTheMoney = type === OptionTypes.Call ? assetPrice > strike : assetPrice < strike;
|
|
5912
|
+
return { delta: inTheMoney ? sign : 0, gamma: 0, vega: 0, theta: 0, rho: 0 };
|
|
5913
|
+
}
|
|
5914
|
+
const a = d1(inputs);
|
|
5915
|
+
const b = d2(inputs);
|
|
5916
|
+
const sqrtT = Math.sqrt(timeToExpiry);
|
|
5917
|
+
const density = normalPdf(a);
|
|
5918
|
+
const carry = Math.exp(-dividend * timeToExpiry);
|
|
5919
|
+
const discount = Math.exp(-riskFree * timeToExpiry);
|
|
5920
|
+
const delta = sign * carry * normalCdf(sign * a);
|
|
5921
|
+
const gamma = carry * density / (assetPrice * deviation * sqrtT);
|
|
5922
|
+
const vega = assetPrice * carry * density * sqrtT * 0.01;
|
|
5923
|
+
const rho = sign * strike * timeToExpiry * discount * normalCdf(sign * b) * 0.01;
|
|
5924
|
+
const theta = (-(assetPrice * carry * density * deviation) / (2 * sqrtT) - sign * riskFree * strike * discount * normalCdf(sign * b) + sign * dividend * assetPrice * carry * normalCdf(sign * a)) / DAYS_IN_YEAR;
|
|
5925
|
+
return { delta, gamma, vega, theta, rho };
|
|
5926
|
+
}
|
|
5927
|
+
function impliedVolatility(type, inputs, price2) {
|
|
5928
|
+
if (!(price2 > 0) || inputs.timeToExpiry <= 0) return null;
|
|
5929
|
+
const at = (deviation) => premium(type, { ...inputs, deviation });
|
|
5930
|
+
let low = 1e-6;
|
|
5931
|
+
let high = 5;
|
|
5932
|
+
if (price2 < at(low) || price2 > at(high)) return null;
|
|
5933
|
+
for (let step = 0; step < 60; step++) {
|
|
5934
|
+
const mid = (low + high) / 2;
|
|
5935
|
+
if (at(mid) < price2) low = mid;
|
|
5936
|
+
else high = mid;
|
|
5937
|
+
}
|
|
5938
|
+
return (low + high) / 2;
|
|
5939
|
+
}
|
|
5940
|
+
|
|
5941
|
+
// src/option-desk-widget.ts
|
|
5942
|
+
var _OptionDeskWidget = class _OptionDeskWidget {
|
|
5943
|
+
static create(hostEl, state, deps) {
|
|
5944
|
+
const host = assertHost(deps?.host, "OptionDeskWidget");
|
|
5945
|
+
const root = _OptionDeskWidget._buildRoot(host);
|
|
5946
|
+
root.id = makePanelId(_OptionDeskWidget.TYPE);
|
|
5947
|
+
hostEl.appendChild(root);
|
|
5948
|
+
return new _OptionDeskWidget(root, state || {}, deps);
|
|
5949
|
+
}
|
|
5950
|
+
static _buildRoot(host) {
|
|
5951
|
+
const title = host.t("OptionDesk");
|
|
5952
|
+
return makePanelRoot("option-desk-panel", title, [
|
|
5953
|
+
makeElement("div", "panel-header", {}, [
|
|
5954
|
+
makeElement("span", "", {}, [title]),
|
|
5955
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
5956
|
+
]),
|
|
5957
|
+
makeElement("div", "panel-body panel-body-with-rail", {}, [
|
|
5958
|
+
makeElement("div", "panel-body-content", {}, [
|
|
5959
|
+
makeElement("table", "terminal-table option-desk-table", { role: "table", "aria-label": host.t("OptionChain") }, [
|
|
5960
|
+
makeElement("thead", "", {}, []),
|
|
5961
|
+
makeElement("tbody", "option-desk-body", {}, [])
|
|
5962
|
+
])
|
|
5963
|
+
]),
|
|
5964
|
+
makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("OptionDeskActions") }, [
|
|
5965
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
|
|
5966
|
+
])
|
|
5967
|
+
])
|
|
5968
|
+
]);
|
|
5969
|
+
}
|
|
5970
|
+
constructor(rootEl, _state, deps) {
|
|
5971
|
+
this._host = assertHost(deps?.host, "OptionDeskWidget");
|
|
5972
|
+
this.rootEl = rootEl;
|
|
5973
|
+
this.el = this.rootEl.querySelector(".option-desk-body");
|
|
5974
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
5975
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
5976
|
+
this._rows = [];
|
|
5977
|
+
this._context = {};
|
|
5978
|
+
this._places = greekScales([], {});
|
|
5979
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
5980
|
+
e.preventDefault();
|
|
5981
|
+
this._host.close();
|
|
5982
|
+
});
|
|
5983
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
5984
|
+
e.preventDefault();
|
|
5985
|
+
this._export();
|
|
5986
|
+
});
|
|
5987
|
+
const head = this.rootEl.querySelector(".option-desk-table thead");
|
|
5988
|
+
this._grid = head && this.el ? new DataGrid({
|
|
5989
|
+
head,
|
|
5990
|
+
body: this.el,
|
|
5991
|
+
columns: this._columns(),
|
|
5992
|
+
// By strike, ascending. A chain has exactly one order and it is not negotiable:
|
|
5993
|
+
// the strip is read as a ladder, and re-sorting it by any column destroys that.
|
|
5994
|
+
defaultSort: { col: "strike", dir: "asc" },
|
|
5995
|
+
rowKey: (r) => String(r.strike),
|
|
5996
|
+
emptyText: this._host.t("NoOptions"),
|
|
5997
|
+
rowClass: (r) => this._rowClass(r),
|
|
5998
|
+
contextMenu: makeGridMenu(this._host),
|
|
5999
|
+
selection: "multi"
|
|
6000
|
+
}) : null;
|
|
6001
|
+
this._grid?.setState({
|
|
6002
|
+
hidden: [
|
|
6003
|
+
"callRho",
|
|
6004
|
+
"callTheta",
|
|
6005
|
+
"callHv",
|
|
6006
|
+
"callTheor",
|
|
6007
|
+
"putRho",
|
|
6008
|
+
"putTheta",
|
|
6009
|
+
"putHv",
|
|
6010
|
+
"putTheor"
|
|
6011
|
+
]
|
|
6012
|
+
});
|
|
6013
|
+
this._host.register(this);
|
|
6014
|
+
}
|
|
6015
|
+
dispose() {
|
|
6016
|
+
this._grid?.destroy();
|
|
6017
|
+
this._host.unregister(this);
|
|
6018
|
+
try {
|
|
6019
|
+
this.rootEl.remove();
|
|
6020
|
+
} catch {
|
|
6021
|
+
}
|
|
6022
|
+
}
|
|
6023
|
+
/// Show this chain, priced against this context.
|
|
6024
|
+
///
|
|
6025
|
+
/// Both together, always: a chain and the underlying it is priced off are one observation,
|
|
6026
|
+
/// and refreshing them separately shows greeks computed from a price that has moved.
|
|
6027
|
+
update(strikes, context = {}) {
|
|
6028
|
+
this._context = context ?? {};
|
|
6029
|
+
this._rows = scaleChain(strikes ?? [], this._context);
|
|
6030
|
+
this._places = greekScales(this._rows, this._context);
|
|
6031
|
+
this._grid?.setRows(this._rows);
|
|
6032
|
+
}
|
|
6033
|
+
/// The rows as the desk holds them, scales and intrinsic values resolved.
|
|
6034
|
+
rows() {
|
|
6035
|
+
return this._rows;
|
|
6036
|
+
}
|
|
6037
|
+
_export() {
|
|
6038
|
+
this._grid?.download("option-chain", this._host.t("OptionDesk"));
|
|
6039
|
+
}
|
|
6040
|
+
// Which side of the money this strike is on. A chain is read from the money outwards, and
|
|
6041
|
+
// the line between the two halves is what a reader finds first.
|
|
6042
|
+
_rowClass(row) {
|
|
6043
|
+
const asset = this._context.assetPrice;
|
|
6044
|
+
if (asset === null || asset === void 0) return "option-row";
|
|
6045
|
+
return `option-row ${row.strike < asset ? "option-itm-call" : "option-itm-put"}`;
|
|
6046
|
+
}
|
|
6047
|
+
_columns() {
|
|
6048
|
+
const label = (key) => this._host.t(key);
|
|
6049
|
+
const side = (which) => {
|
|
6050
|
+
const prefix = which;
|
|
6051
|
+
const at = (r) => r[which];
|
|
6052
|
+
const g = (r) => sideGreeks(r, which, this._context);
|
|
6053
|
+
return [
|
|
6054
|
+
{ key: `${prefix}Rho`, header: label("Rho"), exportable: true, value: (r) => g(r)?.rho ?? null, render: (r) => decimals(g(r)?.rho, this._places.rho) },
|
|
6055
|
+
{ key: `${prefix}Theta`, header: label("Theta"), exportable: true, value: (r) => g(r)?.theta ?? null, render: (r) => decimals(g(r)?.theta, this._places.theta) },
|
|
6056
|
+
{ key: `${prefix}Vega`, header: label("Vega"), exportable: true, value: (r) => g(r)?.vega ?? null, render: (r) => decimals(g(r)?.vega, this._places.vega) },
|
|
6057
|
+
{ key: `${prefix}Gamma`, header: label("Gamma"), exportable: true, value: (r) => g(r)?.gamma ?? null, render: (r) => decimals(g(r)?.gamma, this._places.gamma) },
|
|
6058
|
+
{ key: `${prefix}Delta`, header: label("Delta"), exportable: true, value: (r) => g(r)?.delta ?? null, render: (r) => decimals(g(r)?.delta, this._places.delta) },
|
|
6059
|
+
{ key: `${prefix}Bid`, header: label("Bid"), exportable: true, cellClass: () => "option-bid", value: (r) => at(r).bid ?? null, render: (r) => price(at(r).bid) },
|
|
6060
|
+
{ key: `${prefix}Ask`, header: label("Ask"), exportable: true, cellClass: () => "option-ask", value: (r) => at(r).ask ?? null, render: (r) => price(at(r).ask) },
|
|
6061
|
+
{ key: `${prefix}Theor`, header: label("TheorPrice"), exportable: true, value: (r) => at(r).theoretical ?? null, render: (r) => price(at(r).theoretical) },
|
|
6062
|
+
{
|
|
6063
|
+
key: `${prefix}Volume`,
|
|
6064
|
+
header: label("Volume"),
|
|
6065
|
+
exportable: true,
|
|
6066
|
+
value: (r) => at(r).volume ?? 0,
|
|
6067
|
+
render: (r) => this._bar(at(r).volume, which === "call" ? r.maxCallVolume : r.maxPutVolume, which, formatQty(at(r).volume ?? 0)),
|
|
6068
|
+
exportValue: (r) => at(r).volume ?? 0
|
|
6069
|
+
},
|
|
6070
|
+
{
|
|
6071
|
+
key: `${prefix}Oi`,
|
|
6072
|
+
header: label("OI"),
|
|
6073
|
+
exportable: true,
|
|
6074
|
+
value: (r) => at(r).openInterest ?? 0,
|
|
6075
|
+
render: (r) => this._bar(at(r).openInterest, which === "call" ? r.maxCallOpenInterest : r.maxPutOpenInterest, which, formatQty(at(r).openInterest ?? 0)),
|
|
6076
|
+
exportValue: (r) => at(r).openInterest ?? 0
|
|
6077
|
+
},
|
|
6078
|
+
{ key: `${prefix}Symbol`, header: which === "call" ? label("Call") : label("Put"), exportable: true, value: (r) => at(r).symbol ?? "" },
|
|
6079
|
+
{
|
|
6080
|
+
key: `${prefix}IvBid`,
|
|
6081
|
+
header: label("IVBid"),
|
|
6082
|
+
exportable: true,
|
|
6083
|
+
value: (r) => at(r).ivBid ?? null,
|
|
6084
|
+
render: (r) => this._bar(at(r).ivBid, r.maxVolatility, "iv", percent(at(r).ivBid)),
|
|
6085
|
+
exportValue: (r) => at(r).ivBid ?? ""
|
|
6086
|
+
},
|
|
6087
|
+
{
|
|
6088
|
+
key: `${prefix}IvAsk`,
|
|
6089
|
+
header: label("IVAsk"),
|
|
6090
|
+
exportable: true,
|
|
6091
|
+
value: (r) => at(r).ivAsk ?? null,
|
|
6092
|
+
render: (r) => this._bar(at(r).ivAsk, r.maxVolatility, "iv", percent(at(r).ivAsk)),
|
|
6093
|
+
exportValue: (r) => at(r).ivAsk ?? ""
|
|
6094
|
+
},
|
|
6095
|
+
{
|
|
6096
|
+
key: `${prefix}IvLast`,
|
|
6097
|
+
header: label("IVLast"),
|
|
6098
|
+
exportable: true,
|
|
6099
|
+
value: (r) => at(r).ivLast ?? null,
|
|
6100
|
+
render: (r) => this._bar(at(r).ivLast, r.maxVolatility, "iv", percent(at(r).ivLast)),
|
|
6101
|
+
exportValue: (r) => at(r).ivLast ?? ""
|
|
6102
|
+
},
|
|
6103
|
+
{
|
|
6104
|
+
key: `${prefix}Hv`,
|
|
6105
|
+
header: label("HV"),
|
|
6106
|
+
exportable: true,
|
|
6107
|
+
value: (r) => at(r).historicalVolatility ?? null,
|
|
6108
|
+
render: (r) => this._bar(at(r).historicalVolatility, r.maxVolatility, "iv", percent(at(r).historicalVolatility)),
|
|
6109
|
+
exportValue: (r) => at(r).historicalVolatility ?? ""
|
|
6110
|
+
}
|
|
6111
|
+
];
|
|
6112
|
+
};
|
|
6113
|
+
const puts = side("put").slice().reverse();
|
|
6114
|
+
return [
|
|
6115
|
+
...side("call"),
|
|
6116
|
+
{
|
|
6117
|
+
key: "strike",
|
|
6118
|
+
header: label("Strike"),
|
|
6119
|
+
exportable: true,
|
|
6120
|
+
cellClass: () => "option-strike",
|
|
6121
|
+
value: (r) => r.strike,
|
|
6122
|
+
render: (r) => price(r.strike)
|
|
6123
|
+
},
|
|
6124
|
+
{
|
|
6125
|
+
key: "intrinsic",
|
|
6126
|
+
header: label("IntrinsicValue"),
|
|
6127
|
+
exportable: true,
|
|
6128
|
+
cellClass: () => "option-intrinsic",
|
|
6129
|
+
// Whichever side is in the money at this strike; the other is worth nothing to
|
|
6130
|
+
// exercise, and showing both would be one number and one zero on every row.
|
|
6131
|
+
value: (r) => Math.max(r.callIntrinsic, r.putIntrinsic),
|
|
6132
|
+
render: (r) => price(Math.max(r.callIntrinsic, r.putIntrinsic))
|
|
6133
|
+
},
|
|
6134
|
+
...puts
|
|
6135
|
+
];
|
|
6136
|
+
}
|
|
6137
|
+
// A figure with its share of the strip behind it. The bar is a width, not a drawing: it
|
|
6138
|
+
// scales with the cell, it prints, and it needs no canvas per row.
|
|
6139
|
+
_bar(value, max, kind, text) {
|
|
6140
|
+
if (value === null || value === void 0 || !isFinite(value) || value <= 0) return text;
|
|
6141
|
+
const cell = document.createElement("span");
|
|
6142
|
+
cell.className = "option-bar-cell";
|
|
6143
|
+
const bar = document.createElement("span");
|
|
6144
|
+
bar.className = `option-bar option-bar-${kind}`;
|
|
6145
|
+
bar.style.width = `${Math.min(100, value / (max || 1) * 100).toFixed(1)}%`;
|
|
6146
|
+
cell.appendChild(bar);
|
|
6147
|
+
const label = document.createElement("span");
|
|
6148
|
+
label.className = "option-bar-text";
|
|
6149
|
+
label.textContent = text;
|
|
6150
|
+
cell.appendChild(label);
|
|
6151
|
+
return cell;
|
|
6152
|
+
}
|
|
6153
|
+
};
|
|
6154
|
+
_OptionDeskWidget.TYPE = ControlTypes.OptionDesk;
|
|
6155
|
+
var OptionDeskWidget = _OptionDeskWidget;
|
|
6156
|
+
function scaleChain(strikes, context) {
|
|
6157
|
+
const asset = context.assetPrice ?? null;
|
|
6158
|
+
const maxOf = (pick, sides) => Math.max(0, ...strikes.flatMap((r) => sides(r).map((s) => pick(s) ?? 0)).filter((v) => isFinite(v)));
|
|
6159
|
+
const maxCallVolume = maxOf((s) => s.volume, (r) => [r.call]);
|
|
6160
|
+
const maxPutVolume = maxOf((s) => s.volume, (r) => [r.put]);
|
|
6161
|
+
const maxCallOpenInterest = maxOf((s) => s.openInterest, (r) => [r.call]);
|
|
6162
|
+
const maxPutOpenInterest = maxOf((s) => s.openInterest, (r) => [r.put]);
|
|
6163
|
+
const maxVolatility = Math.max(
|
|
6164
|
+
maxOf((s) => s.ivBid, (r) => [r.call, r.put]),
|
|
6165
|
+
maxOf((s) => s.ivAsk, (r) => [r.call, r.put]),
|
|
6166
|
+
maxOf((s) => s.ivLast, (r) => [r.call, r.put]),
|
|
6167
|
+
maxOf((s) => s.historicalVolatility, (r) => [r.call, r.put])
|
|
6168
|
+
);
|
|
6169
|
+
return strikes.map((row) => ({
|
|
6170
|
+
...row,
|
|
6171
|
+
maxCallVolume,
|
|
6172
|
+
maxPutVolume,
|
|
6173
|
+
maxCallOpenInterest,
|
|
6174
|
+
maxPutOpenInterest,
|
|
6175
|
+
maxVolatility,
|
|
6176
|
+
callIntrinsic: asset === null ? 0 : Math.max(0, asset - row.strike),
|
|
6177
|
+
putIntrinsic: asset === null ? 0 : Math.max(0, row.strike - asset)
|
|
6178
|
+
}));
|
|
6179
|
+
}
|
|
6180
|
+
function sideGreeks(row, which, context) {
|
|
6181
|
+
const side = row[which];
|
|
6182
|
+
if (side.greeks !== void 0) return side.greeks;
|
|
6183
|
+
const deviation = side.ivLast ?? side.ivBid ?? side.ivAsk ?? side.historicalVolatility ?? null;
|
|
6184
|
+
const assetPrice = context.assetPrice ?? null;
|
|
6185
|
+
const timeToExpiry = context.timeToExpiry ?? null;
|
|
6186
|
+
if (deviation === null || assetPrice === null || timeToExpiry === null) return null;
|
|
6187
|
+
return greeks(which === "call" ? OptionTypes.Call : OptionTypes.Put, {
|
|
6188
|
+
assetPrice,
|
|
6189
|
+
strike: row.strike,
|
|
6190
|
+
timeToExpiry,
|
|
6191
|
+
riskFree: context.riskFree ?? 0,
|
|
6192
|
+
dividend: context.dividend ?? 0,
|
|
6193
|
+
deviation
|
|
6194
|
+
});
|
|
6195
|
+
}
|
|
6196
|
+
var GREEK_KEYS = ["delta", "gamma", "vega", "theta", "rho"];
|
|
6197
|
+
var GREEK_DIGITS = 4;
|
|
6198
|
+
var GREEK_MIN_PLACES = 2;
|
|
6199
|
+
var GREEK_MAX_PLACES = 8;
|
|
6200
|
+
function greekPlaces(values) {
|
|
6201
|
+
const scale = Math.min(...values.filter((v) => typeof v === "number" && isFinite(v) && v !== 0).map(Math.abs));
|
|
6202
|
+
if (!isFinite(scale)) return GREEK_MIN_PLACES;
|
|
6203
|
+
const places = GREEK_DIGITS - 1 - Math.floor(Math.log10(scale));
|
|
6204
|
+
return Math.min(GREEK_MAX_PLACES, Math.max(GREEK_MIN_PLACES, places));
|
|
6205
|
+
}
|
|
6206
|
+
function greekScales(rows, context) {
|
|
6207
|
+
const all = rows.flatMap((r) => [sideGreeks(r, OptionTypes.Call, context), sideGreeks(r, OptionTypes.Put, context)]).filter((g) => g !== null);
|
|
6208
|
+
const places = {};
|
|
6209
|
+
for (const key of GREEK_KEYS) places[key] = greekPlaces(all.map((g) => g[key]));
|
|
6210
|
+
return places;
|
|
6211
|
+
}
|
|
6212
|
+
function decimals(value, places) {
|
|
6213
|
+
if (value === null || value === void 0 || !isFinite(value)) return "";
|
|
6214
|
+
return value.toFixed(places);
|
|
6215
|
+
}
|
|
6216
|
+
function price(value) {
|
|
6217
|
+
if (value === null || value === void 0 || !isFinite(value)) return "";
|
|
6218
|
+
return formatPrice(value);
|
|
6219
|
+
}
|
|
6220
|
+
function percent(value) {
|
|
6221
|
+
if (value === null || value === void 0 || !isFinite(value)) return "";
|
|
6222
|
+
return `${(value * 100).toFixed(2)}%`;
|
|
6223
|
+
}
|
|
6224
|
+
|
|
6225
|
+
// src/option-smile-widget.ts
|
|
6226
|
+
var LINE_WIDTH2 = 2;
|
|
6227
|
+
function sideVolatility(side) {
|
|
6228
|
+
if (side === void 0 || side === null) return null;
|
|
6229
|
+
const last = side.ivLast;
|
|
6230
|
+
if (typeof last === "number" && isFinite(last) && last > 0) return last;
|
|
6231
|
+
const bid = side.ivBid;
|
|
6232
|
+
const ask = side.ivAsk;
|
|
6233
|
+
const quoted = [bid, ask].filter((v) => typeof v === "number" && isFinite(v) && v > 0);
|
|
6234
|
+
if (quoted.length === 0) return null;
|
|
6235
|
+
return quoted.reduce((sum, v) => sum + v, 0) / quoted.length;
|
|
6236
|
+
}
|
|
6237
|
+
function sortedChain(strikes) {
|
|
6238
|
+
return [...strikes].filter((row) => row !== null && row !== void 0 && isFinite(row.strike)).sort((a, b) => a.strike - b.strike);
|
|
6239
|
+
}
|
|
6240
|
+
function toSmileSeries(chain, put) {
|
|
6241
|
+
const out = [];
|
|
6242
|
+
for (const row of chain) {
|
|
6243
|
+
const volatility = sideVolatility(put ? row.put : row.call);
|
|
6244
|
+
if (volatility === null) out.push({ time: row.strike });
|
|
6245
|
+
else out.push({ time: row.strike, value: volatility * 100 });
|
|
6246
|
+
}
|
|
6247
|
+
return out;
|
|
6248
|
+
}
|
|
6249
|
+
var _OptionSmileWidget = class _OptionSmileWidget {
|
|
6250
|
+
static create(hostEl, state, deps) {
|
|
6251
|
+
const host = assertHost(deps?.host, "OptionSmileWidget");
|
|
6252
|
+
const root = _OptionSmileWidget._buildRoot(host);
|
|
6253
|
+
root.id = makePanelId(_OptionSmileWidget.TYPE);
|
|
6254
|
+
hostEl.appendChild(root);
|
|
6255
|
+
return new _OptionSmileWidget(root, state || {}, deps);
|
|
6256
|
+
}
|
|
6257
|
+
static _buildRoot(host) {
|
|
6258
|
+
const title = host.t("OptionSmile");
|
|
6259
|
+
return makePanelRoot("option-smile-panel", title, [
|
|
6260
|
+
makeElement("div", "panel-header", {}, [
|
|
6261
|
+
makeElement("span", "", {}, [title]),
|
|
6262
|
+
makeIconButton("bt-icon-btn option-smile-reset-btn", host.t("ResetView"), "bi-arrow-clockwise", { type: "button" }),
|
|
6263
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
6264
|
+
]),
|
|
6265
|
+
makeElement("div", "panel-body", {}, [
|
|
6266
|
+
makeElement("div", "option-smile-legend", {}, [
|
|
6267
|
+
makeElement("span", "option-smile-axis", {}, [host.t("ImpliedVolatility")]),
|
|
6268
|
+
makeElement("span", "option-smile-key", {}, [
|
|
6269
|
+
makeElement("span", "option-smile-dot option-smile-dot-call", {}, []),
|
|
6270
|
+
host.t("Call")
|
|
6271
|
+
]),
|
|
6272
|
+
makeElement("span", "option-smile-key", {}, [
|
|
6273
|
+
makeElement("span", "option-smile-dot option-smile-dot-put", {}, []),
|
|
6274
|
+
host.t("Put")
|
|
6275
|
+
]),
|
|
6276
|
+
makeElement("span", "option-smile-spot", {}, [])
|
|
6277
|
+
]),
|
|
6278
|
+
makeElement("div", "option-smile-chart", { role: "img", "aria-label": host.t("OptionChain") }, [
|
|
6279
|
+
// Over the chart, not in the panel header: a reading about the point under the
|
|
6280
|
+
// pointer belongs beside that point, and a host that lifts panel headers into a
|
|
6281
|
+
// tab strip would otherwise write it into a tab title.
|
|
6282
|
+
makeElement("div", "option-smile-hover", {}, []),
|
|
6283
|
+
makeElement("div", "option-smile-empty", {}, [host.t("NoOptions")])
|
|
6284
|
+
])
|
|
6285
|
+
])
|
|
6286
|
+
]);
|
|
6287
|
+
}
|
|
6288
|
+
constructor(rootEl, _state, deps) {
|
|
6289
|
+
this._host = assertHost(deps?.host, "OptionSmileWidget");
|
|
6290
|
+
this.rootEl = rootEl;
|
|
6291
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
6292
|
+
this._resetBtn = this.rootEl.querySelector(".option-smile-reset-btn");
|
|
6293
|
+
this._emptyEl = this.rootEl.querySelector(".option-smile-empty");
|
|
6294
|
+
this._chartEl = this.rootEl.querySelector(".option-smile-chart");
|
|
6295
|
+
this._spotEl = this.rootEl.querySelector(".option-smile-spot");
|
|
6296
|
+
this._hoverEl = this.rootEl.querySelector(".option-smile-hover");
|
|
6297
|
+
this._chart = null;
|
|
6298
|
+
this._call = null;
|
|
6299
|
+
this._put = null;
|
|
6300
|
+
this._strikes = [];
|
|
6301
|
+
this._context = {};
|
|
6302
|
+
this._resizeObserver = null;
|
|
6303
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
6304
|
+
e.preventDefault();
|
|
6305
|
+
this._host.close();
|
|
6306
|
+
});
|
|
6307
|
+
this._resetBtn?.addEventListener("click", (e) => {
|
|
6308
|
+
e.preventDefault();
|
|
6309
|
+
this.resetZoom();
|
|
6310
|
+
});
|
|
6311
|
+
if (this._chartEl !== null && typeof ResizeObserver !== "undefined") {
|
|
6312
|
+
this._resizeObserver = new ResizeObserver(() => this._fit());
|
|
6313
|
+
this._resizeObserver.observe(this._chartEl);
|
|
6314
|
+
}
|
|
6315
|
+
this._render();
|
|
6316
|
+
this._host.register(this);
|
|
6317
|
+
}
|
|
6318
|
+
dispose() {
|
|
6319
|
+
try {
|
|
6320
|
+
this._resizeObserver?.disconnect();
|
|
6321
|
+
} catch {
|
|
6322
|
+
}
|
|
6323
|
+
this._resizeObserver = null;
|
|
6324
|
+
try {
|
|
6325
|
+
this._chart?.remove();
|
|
6326
|
+
} catch {
|
|
6327
|
+
}
|
|
6328
|
+
this._chart = null;
|
|
6329
|
+
this._call = null;
|
|
6330
|
+
this._put = null;
|
|
6331
|
+
this._host.unregister(this);
|
|
6332
|
+
try {
|
|
6333
|
+
this.rootEl.remove();
|
|
6334
|
+
} catch {
|
|
6335
|
+
}
|
|
6336
|
+
}
|
|
6337
|
+
/// Show this chain, priced against this context.
|
|
6338
|
+
///
|
|
6339
|
+
/// The desk's two arguments unchanged, and for the same reason it takes them together: they
|
|
6340
|
+
/// are one observation. A smile marked with a spot the curves never saw is two moments.
|
|
6341
|
+
update(strikes, context = {}) {
|
|
6342
|
+
this._strikes = strikes || [];
|
|
6343
|
+
this._context = context || {};
|
|
6344
|
+
this._render();
|
|
6345
|
+
}
|
|
6346
|
+
/// Show the whole chain again, after a zoom.
|
|
6347
|
+
resetZoom() {
|
|
6348
|
+
try {
|
|
6349
|
+
this._chart?.timeScale().fitContent();
|
|
6350
|
+
} catch {
|
|
6351
|
+
}
|
|
6352
|
+
}
|
|
6353
|
+
/// The engine, for a host that wants to add to this chart - a second expiry, a marker.
|
|
6354
|
+
chart() {
|
|
6355
|
+
return this._chart;
|
|
6356
|
+
}
|
|
6357
|
+
_ensureChart() {
|
|
6358
|
+
if (this._chart !== null) return true;
|
|
6359
|
+
if (this._chartEl === null) return false;
|
|
6360
|
+
const palette = this._host.presentation.canvasPalette();
|
|
6361
|
+
this._chart = createChart(this._chartEl, {
|
|
6362
|
+
layout: {
|
|
6363
|
+
background: { type: "solid", color: "transparent" },
|
|
6364
|
+
textColor: palette.grid,
|
|
6365
|
+
fontFamily: palette.font,
|
|
6366
|
+
fontSize: 11,
|
|
6367
|
+
attributionLogo: false
|
|
6368
|
+
},
|
|
6369
|
+
grid: { vertLines: { color: palette.grid }, horzLines: { color: palette.grid } },
|
|
6370
|
+
crosshair: { mode: CrosshairMode.Normal },
|
|
6371
|
+
rightPriceScale: { borderColor: palette.grid },
|
|
6372
|
+
timeScale: {
|
|
6373
|
+
borderColor: palette.grid,
|
|
6374
|
+
// Evenly spaced by listing, and worded as strikes: the axis carries the strike
|
|
6375
|
+
// itself, so the label and the value can never drift apart.
|
|
6376
|
+
mode: "ordinal",
|
|
6377
|
+
formatter: (value) => formatPrice(Number(value))
|
|
6378
|
+
}
|
|
6379
|
+
});
|
|
6380
|
+
this._call = this._chart.addSeries(LineSeries, { lineWidth: LINE_WIDTH2, color: palette.up });
|
|
6381
|
+
this._put = this._chart.addSeries(LineSeries, { lineWidth: LINE_WIDTH2, color: palette.down });
|
|
6382
|
+
this._chart.subscribeCrosshairMove((param) => this._renderHover(param));
|
|
6383
|
+
return true;
|
|
6384
|
+
}
|
|
6385
|
+
_render() {
|
|
6386
|
+
const chain = sortedChain(this._strikes);
|
|
6387
|
+
const quoted = chain.some((row) => sideVolatility(row.call) !== null || sideVolatility(row.put) !== null);
|
|
6388
|
+
if (this._emptyEl !== null) this._emptyEl.hidden = quoted;
|
|
6389
|
+
this._renderSpot();
|
|
6390
|
+
if (!quoted) return;
|
|
6391
|
+
if (!this._ensureChart() || this._call === null || this._put === null) return;
|
|
6392
|
+
this._call.setData(toSmileSeries(chain, false));
|
|
6393
|
+
this._put.setData(toSmileSeries(chain, true));
|
|
6394
|
+
this._fit();
|
|
6395
|
+
}
|
|
6396
|
+
_fit() {
|
|
6397
|
+
if (this._chart === null || this._chartEl === null) return;
|
|
6398
|
+
const rect = this._chartEl.getBoundingClientRect();
|
|
6399
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
6400
|
+
try {
|
|
6401
|
+
this._chart.resize(rect.width, rect.height);
|
|
6402
|
+
} catch {
|
|
6403
|
+
}
|
|
6404
|
+
}
|
|
6405
|
+
// Where the money is. Not a line on the chart: an ordinal axis has a rung per listed strike
|
|
6406
|
+
// and the spot is between two of them, so it is stated rather than drawn at a place the
|
|
6407
|
+
// ladder does not have.
|
|
6408
|
+
_renderSpot() {
|
|
6409
|
+
const el = this._spotEl;
|
|
6410
|
+
if (el === null) return;
|
|
6411
|
+
const spot = this._context.assetPrice;
|
|
6412
|
+
if (typeof spot !== "number" || !isFinite(spot)) {
|
|
6413
|
+
el.textContent = "";
|
|
6414
|
+
return;
|
|
6415
|
+
}
|
|
6416
|
+
el.textContent = `${this._host.t("Underlying")} ${formatPrice(spot)}`;
|
|
6417
|
+
}
|
|
6418
|
+
// What the pointer is over: the strike and both sides at it. Both, because a smile is read by
|
|
6419
|
+
// the distance between the two curves - a readout naming one of them answers half of it.
|
|
6420
|
+
_renderHover(param) {
|
|
6421
|
+
const el = this._hoverEl;
|
|
6422
|
+
if (el === null) return;
|
|
6423
|
+
if (param.time === null) {
|
|
6424
|
+
el.textContent = "";
|
|
6425
|
+
return;
|
|
6426
|
+
}
|
|
6427
|
+
const at = (series) => {
|
|
6428
|
+
const point = series === null ? void 0 : param.seriesData.get(series);
|
|
6429
|
+
const value = point?.value;
|
|
6430
|
+
return typeof value === "number" && isFinite(value) ? `${value.toFixed(2)}%` : "--";
|
|
6431
|
+
};
|
|
6432
|
+
el.textContent = `${formatPrice(Number(param.time))} ${at(this._call)} / ${at(this._put)}`;
|
|
6433
|
+
}
|
|
6434
|
+
};
|
|
6435
|
+
_OptionSmileWidget.TYPE = ControlTypes.OptionSmile;
|
|
6436
|
+
var OptionSmileWidget = _OptionSmileWidget;
|
|
6437
|
+
|
|
6438
|
+
// src/strategies-widget.ts
|
|
6439
|
+
var StrategyStates = {
|
|
6440
|
+
Stopped: "stopped",
|
|
6441
|
+
Starting: "starting",
|
|
6442
|
+
Started: "started",
|
|
6443
|
+
Stopping: "stopping"
|
|
6444
|
+
};
|
|
6445
|
+
var SPARK_WIDTH = 140;
|
|
6446
|
+
var SPARK_HEIGHT = 26;
|
|
6447
|
+
var _StrategiesWidget = class _StrategiesWidget {
|
|
6448
|
+
static create(hostEl, state, deps) {
|
|
6449
|
+
const host = assertHost(deps?.host, "StrategiesWidget");
|
|
6450
|
+
const root = _StrategiesWidget._buildRoot(host);
|
|
6451
|
+
root.id = makePanelId(_StrategiesWidget.TYPE);
|
|
6452
|
+
hostEl.appendChild(root);
|
|
6453
|
+
return new _StrategiesWidget(root, state || {}, deps);
|
|
6454
|
+
}
|
|
6455
|
+
static _buildRoot(host) {
|
|
6456
|
+
const title = host.t("Strategies");
|
|
6457
|
+
return makePanelRoot("strategies-panel", title, [
|
|
6458
|
+
makeElement("div", "panel-header", {}, [
|
|
6459
|
+
makeElement("span", "", {}, [title]),
|
|
6460
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
6461
|
+
]),
|
|
6462
|
+
makeElement("div", "panel-body panel-body-with-rail", {}, [
|
|
6463
|
+
makeElement("div", "panel-body-content", {}, [
|
|
6464
|
+
makeElement("table", "terminal-table strategies-table", { role: "table", "aria-label": host.t("StrategiesList") }, [
|
|
6465
|
+
makeElement("thead", "", {}, []),
|
|
6466
|
+
makeElement("tbody", "strategies-body", {}, [])
|
|
6467
|
+
])
|
|
6468
|
+
]),
|
|
6469
|
+
makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("StrategiesActions") }, [
|
|
6470
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
|
|
6471
|
+
])
|
|
6472
|
+
])
|
|
6473
|
+
]);
|
|
6474
|
+
}
|
|
6475
|
+
constructor(rootEl, _state, deps) {
|
|
6476
|
+
this._host = assertHost(deps?.host, "StrategiesWidget");
|
|
6477
|
+
this._deps = deps;
|
|
6478
|
+
this.rootEl = rootEl;
|
|
6479
|
+
this.el = this.rootEl.querySelector(".strategies-body");
|
|
6480
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
6481
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
6482
|
+
this._rows = [];
|
|
6483
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
6484
|
+
e.preventDefault();
|
|
6485
|
+
this._host.close();
|
|
6486
|
+
});
|
|
6487
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
6488
|
+
e.preventDefault();
|
|
6489
|
+
this._export();
|
|
6490
|
+
});
|
|
6491
|
+
const head = this.rootEl.querySelector(".strategies-table thead");
|
|
6492
|
+
this._grid = head && this.el ? new DataGrid({
|
|
6493
|
+
head,
|
|
6494
|
+
body: this.el,
|
|
6495
|
+
columns: this._columns(),
|
|
6496
|
+
// By name: a dashboard is a list someone reads down looking for one strategy,
|
|
6497
|
+
// and a list that reorders itself as P&L moves cannot be read that way.
|
|
6498
|
+
defaultSort: { col: "name", dir: "asc" },
|
|
6499
|
+
rowKey: (s) => String(s.id),
|
|
6500
|
+
emptyText: this._host.t("NoStrategies"),
|
|
6501
|
+
rowClass: (s) => `strategy-row strategy-${s.state}${s.error ? " strategy-failed" : ""}`,
|
|
6502
|
+
contextMenu: makeGridMenu(this._host),
|
|
6503
|
+
selection: "multi"
|
|
6504
|
+
}) : null;
|
|
6505
|
+
this._host.register(this);
|
|
6506
|
+
}
|
|
6507
|
+
dispose() {
|
|
6508
|
+
this._grid?.destroy();
|
|
6509
|
+
this._host.unregister(this);
|
|
6510
|
+
try {
|
|
6511
|
+
this.rootEl.remove();
|
|
6512
|
+
} catch {
|
|
6513
|
+
}
|
|
6514
|
+
}
|
|
6515
|
+
/// Show these strategies. The whole set: one that is gone from it has been removed, and
|
|
6516
|
+
/// leaving its row behind would offer a Stop for something that no longer runs.
|
|
6517
|
+
update(rows) {
|
|
6518
|
+
this._rows = rows || [];
|
|
6519
|
+
this._grid?.setRows(this._rows);
|
|
6520
|
+
}
|
|
6521
|
+
_export() {
|
|
6522
|
+
this._grid?.download("strategies", this._host.t("Strategies"));
|
|
6523
|
+
}
|
|
6524
|
+
_columns() {
|
|
6525
|
+
const label = (key) => this._host.t(key);
|
|
6526
|
+
const presentation = this._host.presentation;
|
|
6527
|
+
return [
|
|
6528
|
+
{
|
|
6529
|
+
key: "state",
|
|
6530
|
+
header: label("State"),
|
|
6531
|
+
exportable: true,
|
|
6532
|
+
value: (s) => s.state,
|
|
6533
|
+
render: (s) => this._stateCell(s),
|
|
6534
|
+
cellClass: (s) => `strategy-state strategy-state-${s.state}`,
|
|
6535
|
+
exportValue: (s) => s.state
|
|
6536
|
+
},
|
|
6537
|
+
{
|
|
6538
|
+
key: "actions",
|
|
6539
|
+
header: label("Actions"),
|
|
6540
|
+
headerHidden: true,
|
|
6541
|
+
exportable: false,
|
|
6542
|
+
cellClass: () => "strategy-actions",
|
|
6543
|
+
render: (s) => this._actionCell(s)
|
|
6544
|
+
},
|
|
6545
|
+
{
|
|
6546
|
+
key: "online",
|
|
6547
|
+
header: label("Online"),
|
|
6548
|
+
exportable: true,
|
|
6549
|
+
value: (s) => s.online ? 1 : 0,
|
|
6550
|
+
render: (s) => s.online ? "\u25CF" : "\u25CB",
|
|
6551
|
+
cellClass: (s) => s.online ? "strategy-online is-on" : "strategy-online",
|
|
6552
|
+
exportValue: (s) => s.online ? label("Yes") : label("No")
|
|
6553
|
+
},
|
|
6554
|
+
{
|
|
6555
|
+
key: "tradingMode",
|
|
6556
|
+
header: label("Trading"),
|
|
6557
|
+
exportable: true,
|
|
6558
|
+
value: (s) => s.tradingMode ?? "",
|
|
6559
|
+
render: (s) => this._tradingCell(s),
|
|
6560
|
+
exportValue: (s) => s.tradingMode ?? ""
|
|
6561
|
+
},
|
|
6562
|
+
{ key: "name", header: label("Name"), exportable: true, value: (s) => s.name },
|
|
6563
|
+
{ key: "portfolio", header: label("Portfolio"), exportable: true, value: (s) => s.portfolio ?? "" },
|
|
6564
|
+
{ key: "security", header: label("Sym"), exportable: true, value: (s) => s.security ?? "" },
|
|
6565
|
+
{
|
|
6566
|
+
key: "position",
|
|
6567
|
+
header: label("Position"),
|
|
6568
|
+
exportable: true,
|
|
6569
|
+
value: (s) => s.position ?? 0,
|
|
6570
|
+
render: (s) => this._positionCell(s),
|
|
6571
|
+
cellClass: (s) => `strategy-position ${presentation.pnlClass(s.position ?? 0)}`,
|
|
6572
|
+
exportValue: (s) => s.position ?? 0
|
|
6573
|
+
},
|
|
6574
|
+
{ key: "orders", header: label("OrderCount"), exportable: true, value: (s) => s.ordersCount ?? 0 },
|
|
6575
|
+
{ key: "trades", header: label("NumOfTrades"), exportable: true, value: (s) => s.tradesCount ?? 0 },
|
|
6576
|
+
{
|
|
6577
|
+
key: "pnlChange",
|
|
6578
|
+
header: label("PnLChange"),
|
|
6579
|
+
exportable: true,
|
|
6580
|
+
value: (s) => s.pnlChange ?? 0,
|
|
6581
|
+
render: (s) => `${direction(s.pnlChange ?? 0)} ${formatPnl(s.pnlChange ?? 0)}`,
|
|
6582
|
+
cellClass: (s) => presentation.pnlClass(s.pnlChange ?? 0),
|
|
6583
|
+
exportValue: (s) => s.pnlChange ?? 0
|
|
6584
|
+
},
|
|
6585
|
+
{
|
|
6586
|
+
key: "pnlChart",
|
|
6587
|
+
header: label("PnLChart"),
|
|
6588
|
+
exportable: false,
|
|
6589
|
+
cellClass: () => "strategy-spark",
|
|
6590
|
+
render: (s) => this._sparkline(s)
|
|
6591
|
+
},
|
|
6592
|
+
{
|
|
6593
|
+
key: "realized",
|
|
6594
|
+
header: label("RealizedProfit"),
|
|
6595
|
+
exportable: true,
|
|
6596
|
+
value: (s) => s.realized ?? 0,
|
|
6597
|
+
render: (s) => formatPnl(s.realized ?? 0),
|
|
6598
|
+
cellClass: (s) => presentation.pnlClass(s.realized ?? 0),
|
|
6599
|
+
exportValue: (s) => s.realized ?? 0
|
|
6600
|
+
},
|
|
6601
|
+
{
|
|
6602
|
+
key: "unrealized",
|
|
6603
|
+
header: label("UnrealizedProfit"),
|
|
6604
|
+
exportable: true,
|
|
6605
|
+
value: (s) => s.unrealized ?? 0,
|
|
6606
|
+
render: (s) => formatPnl(s.unrealized ?? 0),
|
|
6607
|
+
cellClass: (s) => presentation.pnlClass(s.unrealized ?? 0),
|
|
6608
|
+
exportValue: (s) => s.unrealized ?? 0
|
|
6609
|
+
},
|
|
6610
|
+
{
|
|
6611
|
+
key: "error",
|
|
6612
|
+
header: label("Error"),
|
|
6613
|
+
exportable: true,
|
|
6614
|
+
cellClass: () => "strategy-error",
|
|
6615
|
+
value: (s) => s.error ?? ""
|
|
6616
|
+
}
|
|
6617
|
+
];
|
|
6618
|
+
}
|
|
6619
|
+
// A dot and the state's own word. The dot is what is read across a list of twenty; the word
|
|
6620
|
+
// is what tells Starting from Started, which a colour alone cannot.
|
|
6621
|
+
_stateCell(row) {
|
|
6622
|
+
const cell = document.createDocumentFragment();
|
|
6623
|
+
const failed = row.error !== void 0 && row.error !== null && String(row.error).length > 0;
|
|
6624
|
+
const dot = document.createElement("span");
|
|
6625
|
+
dot.className = "strategy-dot";
|
|
6626
|
+
dot.textContent = "\u25CF";
|
|
6627
|
+
if (failed) dot.title = String(row.error);
|
|
6628
|
+
cell.appendChild(dot);
|
|
6629
|
+
const text = document.createElement("span");
|
|
6630
|
+
text.className = "strategy-state-text";
|
|
6631
|
+
text.textContent = failed && row.state === StrategyStates.Stopped ? this._host.t("Error") : stateText(this._host, row.state);
|
|
6632
|
+
if (failed) text.title = String(row.error);
|
|
6633
|
+
cell.appendChild(text);
|
|
6634
|
+
return cell;
|
|
6635
|
+
}
|
|
6636
|
+
// Only the buttons this host can actually carry out, and only where the state allows them.
|
|
6637
|
+
// A strategy that is already running has nothing to start.
|
|
6638
|
+
_actionCell(row) {
|
|
6639
|
+
const cell = document.createDocumentFragment();
|
|
6640
|
+
const deps = this._deps;
|
|
6641
|
+
const add = (action, when, title, icon, cls) => {
|
|
6642
|
+
if (action === void 0) return;
|
|
6643
|
+
const button = makeIconButton(`bt-icon-btn ${cls}`, title, icon, { type: "button" });
|
|
6644
|
+
if (!when) button.disabled = true;
|
|
6645
|
+
else button.addEventListener("click", () => action.call(deps, row.id));
|
|
6646
|
+
cell.appendChild(button);
|
|
6647
|
+
};
|
|
6648
|
+
add(deps.start, row.state === StrategyStates.Stopped, this._host.t("Start"), "bi-play-fill", "strategy-start-btn");
|
|
6649
|
+
add(deps.stop, row.state === StrategyStates.Started, this._host.t("Stop"), "bi-stop-fill", "strategy-stop-btn");
|
|
6650
|
+
add(deps.riskRules, true, this._host.t("RiskManagement"), "bi-shield-exclamation", "strategy-risk-btn");
|
|
6651
|
+
add(deps.openStrategy, true, this._host.t("OpenStrategy"), "bi-box-arrow-up-right", "strategy-open-btn");
|
|
6652
|
+
return cell;
|
|
6653
|
+
}
|
|
6654
|
+
// The position, and next to it the one gesture that acts on it. Flattening is only
|
|
6655
|
+
// meaningful while the strategy runs and only when it holds something.
|
|
6656
|
+
_positionCell(row) {
|
|
6657
|
+
const position = row.position ?? 0;
|
|
6658
|
+
const text = formatQty(position);
|
|
6659
|
+
if (this._deps.closePosition === void 0) return text;
|
|
6660
|
+
const cell = document.createDocumentFragment();
|
|
6661
|
+
const value = makeElement("span", "strategy-position-value", {}, [text]);
|
|
6662
|
+
cell.appendChild(value);
|
|
6663
|
+
const button = makeIconButton("bt-icon-btn strategy-flatten-btn", this._host.t("ClosePosition"), "bi-x-octagon", { type: "button" });
|
|
6664
|
+
if (row.state !== StrategyStates.Started || position === 0) button.disabled = true;
|
|
6665
|
+
else button.addEventListener("click", () => this._deps.closePosition?.(row.id));
|
|
6666
|
+
cell.appendChild(button);
|
|
6667
|
+
return cell;
|
|
6668
|
+
}
|
|
6669
|
+
// The one editable cell, and only when the host can carry the change out.
|
|
6670
|
+
_tradingCell(row) {
|
|
6671
|
+
const modes = this._deps.tradingModes ?? [];
|
|
6672
|
+
if (this._deps.setTradingMode === void 0 || modes.length === 0) return row.tradingMode ?? "";
|
|
6673
|
+
const select = document.createElement("select");
|
|
6674
|
+
select.className = "form-control form-control-sm strategy-mode";
|
|
6675
|
+
select.disabled = row.state !== StrategyStates.Stopped;
|
|
6676
|
+
for (const mode of modes) {
|
|
6677
|
+
const option = document.createElement("option");
|
|
6678
|
+
option.value = mode;
|
|
6679
|
+
option.textContent = this._host.t(mode);
|
|
6680
|
+
if (mode === row.tradingMode) option.selected = true;
|
|
6681
|
+
select.appendChild(option);
|
|
6682
|
+
}
|
|
6683
|
+
if (!select.disabled)
|
|
6684
|
+
select.addEventListener("change", () => this._deps.setTradingMode?.(row.id, select.value));
|
|
6685
|
+
return select;
|
|
6686
|
+
}
|
|
6687
|
+
// The run's own curve, at the size a cell has. Coloured by where it ended, which is the
|
|
6688
|
+
// whole reason a glance at the column tells one strategy from another.
|
|
6689
|
+
_sparkline(row) {
|
|
6690
|
+
const box = { width: SPARK_WIDTH, height: SPARK_HEIGHT, padX: 1, padY: 2 };
|
|
6691
|
+
const curve = pnlCurve(row.pnl ?? [], box);
|
|
6692
|
+
if (curve === null) return "";
|
|
6693
|
+
const canvas = document.createElement("canvas");
|
|
6694
|
+
canvas.className = "strategy-spark-canvas";
|
|
6695
|
+
const ratio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
|
|
6696
|
+
canvas.width = Math.round(box.width * ratio);
|
|
6697
|
+
canvas.height = Math.round(box.height * ratio);
|
|
6698
|
+
canvas.style.width = `${box.width}px`;
|
|
6699
|
+
canvas.style.height = `${box.height}px`;
|
|
6700
|
+
const ctx = canvas.getContext("2d");
|
|
6701
|
+
if (ctx !== null) {
|
|
6702
|
+
const palette = this._host.presentation.canvasPalette();
|
|
6703
|
+
const scaled = { width: canvas.width, height: canvas.height, padX: box.padX * ratio, padY: box.padY * ratio };
|
|
6704
|
+
drawPnlCurve(ctx, pnlCurve(row.pnl ?? [], scaled) ?? curve, scaled, {
|
|
6705
|
+
up: palette.up,
|
|
6706
|
+
down: palette.down,
|
|
6707
|
+
baseline: palette.grid,
|
|
6708
|
+
lineWidth: 1.5 * ratio,
|
|
6709
|
+
fillOpacity: 0.35
|
|
6710
|
+
});
|
|
6711
|
+
}
|
|
6712
|
+
return canvas;
|
|
6713
|
+
}
|
|
6714
|
+
};
|
|
6715
|
+
_StrategiesWidget.TYPE = ControlTypes.Strategies;
|
|
6716
|
+
var StrategiesWidget = _StrategiesWidget;
|
|
6717
|
+
function stateText(host, state) {
|
|
6718
|
+
switch (state) {
|
|
6719
|
+
case StrategyStates.Started:
|
|
6720
|
+
return host.t("Started");
|
|
6721
|
+
case StrategyStates.Starting:
|
|
6722
|
+
return host.t("Starting");
|
|
6723
|
+
case StrategyStates.Stopping:
|
|
6724
|
+
return host.t("Stopping");
|
|
6725
|
+
default:
|
|
6726
|
+
return host.t("Stopped");
|
|
6727
|
+
}
|
|
6728
|
+
}
|
|
6729
|
+
function direction(value) {
|
|
6730
|
+
if (value > 0) return "\u25B2";
|
|
6731
|
+
if (value < 0) return "\u25BC";
|
|
6732
|
+
return "";
|
|
6733
|
+
}
|
|
6734
|
+
|
|
6735
|
+
// src/log-tree.ts
|
|
6736
|
+
var LogLevels = {
|
|
6737
|
+
Error: "error",
|
|
6738
|
+
Warning: "warning",
|
|
6739
|
+
Info: "info",
|
|
6740
|
+
Debug: "debug",
|
|
6741
|
+
Verbose: "verbose"
|
|
6742
|
+
};
|
|
6743
|
+
function buildLogTree(sources) {
|
|
6744
|
+
const byId = /* @__PURE__ */ new Map();
|
|
6745
|
+
for (const s of sources)
|
|
6746
|
+
byId.set(s.id, { ...s, name: s.name && s.name.length > 0 ? s.name : "\u2014", depth: 0, children: [] });
|
|
6747
|
+
const roots = [];
|
|
6748
|
+
for (const node of byId.values()) {
|
|
6749
|
+
const parent = node.parentId != null ? byId.get(node.parentId) : void 0;
|
|
6750
|
+
if (parent !== void 0 && parent !== node && !descends(byId, parent, node.id)) parent.children.push(node);
|
|
6751
|
+
else roots.push(node);
|
|
6752
|
+
}
|
|
6753
|
+
const setDepth = (node, depth) => {
|
|
6754
|
+
node.depth = depth;
|
|
6755
|
+
for (const child of node.children) setDepth(child, depth + 1);
|
|
6756
|
+
};
|
|
6757
|
+
for (const root of roots) setDepth(root, 0);
|
|
6758
|
+
return roots;
|
|
6759
|
+
}
|
|
6760
|
+
function descends(byId, node, id) {
|
|
6761
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6762
|
+
let at = node;
|
|
6763
|
+
while (at !== void 0 && !seen.has(at.id)) {
|
|
6764
|
+
if (at.id === id) return true;
|
|
6765
|
+
seen.add(at.id);
|
|
6766
|
+
at = at.parentId != null ? byId.get(at.parentId) : void 0;
|
|
6767
|
+
}
|
|
6768
|
+
return false;
|
|
6769
|
+
}
|
|
6770
|
+
function subtreeOf(sources, selectedId) {
|
|
6771
|
+
if (selectedId === null) return null;
|
|
6772
|
+
const children = /* @__PURE__ */ new Map();
|
|
6773
|
+
for (const s of sources) {
|
|
6774
|
+
if (s.parentId == null) continue;
|
|
6775
|
+
const siblings = children.get(s.parentId);
|
|
6776
|
+
if (siblings === void 0) children.set(s.parentId, [s.id]);
|
|
6777
|
+
else siblings.push(s.id);
|
|
6778
|
+
}
|
|
6779
|
+
const out = /* @__PURE__ */ new Set();
|
|
6780
|
+
const walk = (id) => {
|
|
6781
|
+
if (out.has(id)) return;
|
|
6782
|
+
out.add(id);
|
|
6783
|
+
for (const child of children.get(id) ?? []) walk(child);
|
|
6784
|
+
};
|
|
6785
|
+
walk(selectedId);
|
|
6786
|
+
return out;
|
|
6787
|
+
}
|
|
6788
|
+
function keepLog(message, view) {
|
|
6789
|
+
if (!view.levels.has(message.level)) return false;
|
|
6790
|
+
if (view.sources !== null && !view.sources.has(message.sourceId)) return false;
|
|
6791
|
+
const text = view.text.trim();
|
|
6792
|
+
if (text.length === 0) return true;
|
|
6793
|
+
return (message.message ?? "").toLowerCase().includes(text.toLowerCase());
|
|
6794
|
+
}
|
|
6795
|
+
|
|
6796
|
+
// src/log-monitor-widget.ts
|
|
6797
|
+
var DEFAULT_MAX = 5e3;
|
|
6798
|
+
var LEVEL_LETTER = {
|
|
6799
|
+
[LogLevels.Error]: "E",
|
|
6800
|
+
[LogLevels.Warning]: "W",
|
|
6801
|
+
[LogLevels.Info]: "I",
|
|
6802
|
+
[LogLevels.Debug]: "D",
|
|
6803
|
+
[LogLevels.Verbose]: "V"
|
|
6804
|
+
};
|
|
6805
|
+
var ALL_LEVELS = [LogLevels.Error, LogLevels.Warning, LogLevels.Info, LogLevels.Debug, LogLevels.Verbose];
|
|
6806
|
+
var _LogMonitorWidget = class _LogMonitorWidget {
|
|
6807
|
+
static create(hostEl, state, deps) {
|
|
6808
|
+
const host = assertHost(deps?.host, "LogMonitorWidget");
|
|
6809
|
+
const root = _LogMonitorWidget._buildRoot(host);
|
|
6810
|
+
root.id = makePanelId(_LogMonitorWidget.TYPE);
|
|
6811
|
+
hostEl.appendChild(root);
|
|
6812
|
+
return new _LogMonitorWidget(root, state || {}, deps);
|
|
6813
|
+
}
|
|
6814
|
+
// The panel's markup: a source tree, a toolbar of level toggles and a text box, and the
|
|
6815
|
+
// message table. The level toggles are buttons rather than checkboxes because they are read
|
|
6816
|
+
// as a row of states, and each carries its letter so the toolbar and the column agree.
|
|
6817
|
+
static _buildRoot(host) {
|
|
6818
|
+
const title = host.t("LogMonitor");
|
|
6819
|
+
return makePanelRoot("log-monitor-panel", title, [
|
|
6820
|
+
makeElement("div", "panel-header", {}, [
|
|
6821
|
+
makeElement("span", "", {}, [title]),
|
|
6822
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
6823
|
+
]),
|
|
6824
|
+
makeElement("div", "panel-body log-monitor-body", {}, [
|
|
6825
|
+
makeElement("div", "log-sources", { role: "tree", "aria-label": host.t("LogSources") }, []),
|
|
6826
|
+
makeElement("div", "log-messages", {}, [
|
|
6827
|
+
makeElement("div", "log-toolbar", { role: "toolbar", "aria-label": host.t("LogMonitorActions") }, [
|
|
6828
|
+
...ALL_LEVELS.map((level) => makeElement("button", `log-level-toggle log-level-${level} is-on`, {
|
|
6829
|
+
type: "button",
|
|
6830
|
+
"data-level": level,
|
|
6831
|
+
"aria-pressed": "true",
|
|
6832
|
+
title: levelTitle(host, level)
|
|
6833
|
+
}, [LEVEL_LETTER[level]])),
|
|
6834
|
+
makeElement("input", "form-control form-control-sm log-filter", {
|
|
6835
|
+
type: "search",
|
|
6836
|
+
placeholder: host.t("Filter"),
|
|
6837
|
+
"aria-label": host.t("Filter")
|
|
6838
|
+
}, []),
|
|
6839
|
+
makeIconButton("bt-icon-btn log-clear-btn", host.t("ClearItems"), "bi-eraser", {}),
|
|
6840
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
|
|
6841
|
+
]),
|
|
6842
|
+
// The table sits in a box of its own rather than being a flex child: a table
|
|
6843
|
+
// told to fill a column stretches its rows to do it, so a log holding one
|
|
6844
|
+
// line drew that line a panel tall - and with the toolbar and the table as
|
|
6845
|
+
// the only two children there was nowhere for a long log to scroll.
|
|
6846
|
+
makeElement("div", "log-table-scroll", {}, [
|
|
6847
|
+
makeElement("table", "terminal-table log-table", { role: "table", "aria-label": host.t("LogMessages") }, [
|
|
6848
|
+
makeElement("thead", "", {}, []),
|
|
6849
|
+
makeElement("tbody", "log-body", {}, [])
|
|
6850
|
+
])
|
|
6851
|
+
])
|
|
6852
|
+
])
|
|
6853
|
+
])
|
|
6854
|
+
]);
|
|
6855
|
+
}
|
|
6856
|
+
constructor(rootEl, _state, deps) {
|
|
6857
|
+
this._host = assertHost(deps?.host, "LogMonitorWidget");
|
|
6858
|
+
this._max = deps?.maxMessages && deps.maxMessages > 0 ? deps.maxMessages : DEFAULT_MAX;
|
|
6859
|
+
this.rootEl = rootEl;
|
|
6860
|
+
this.el = this.rootEl.querySelector(".log-body");
|
|
6861
|
+
this._treeEl = this.rootEl.querySelector(".log-sources");
|
|
6862
|
+
this._filterEl = this.rootEl.querySelector(".log-filter");
|
|
6863
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
6864
|
+
this._clearBtn = this.rootEl.querySelector(".log-clear-btn");
|
|
6865
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
6866
|
+
this._sources = [];
|
|
6867
|
+
this._messages = [];
|
|
6868
|
+
this._levels = new Set(ALL_LEVELS);
|
|
6869
|
+
this._text = "";
|
|
6870
|
+
this._selected = null;
|
|
6871
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
6872
|
+
e.preventDefault();
|
|
6873
|
+
this._host.close();
|
|
6874
|
+
});
|
|
6875
|
+
this._clearBtn?.addEventListener("click", (e) => {
|
|
6876
|
+
e.preventDefault();
|
|
6877
|
+
this.clear();
|
|
6878
|
+
});
|
|
6879
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
6880
|
+
e.preventDefault();
|
|
6881
|
+
this._export();
|
|
6882
|
+
});
|
|
6883
|
+
for (const toggle of Array.from(this.rootEl.querySelectorAll(".log-level-toggle"))) {
|
|
6884
|
+
toggle.addEventListener("click", (e) => {
|
|
6885
|
+
e.preventDefault();
|
|
6886
|
+
const level = toggle.getAttribute("data-level") ?? "";
|
|
6887
|
+
if (this._levels.has(level)) this._levels.delete(level);
|
|
6888
|
+
else this._levels.add(level);
|
|
6889
|
+
toggle.classList.toggle("is-on", this._levels.has(level));
|
|
6890
|
+
toggle.setAttribute("aria-pressed", this._levels.has(level) ? "true" : "false");
|
|
6891
|
+
this._render();
|
|
6892
|
+
});
|
|
6893
|
+
}
|
|
6894
|
+
this._filterEl?.addEventListener("input", () => {
|
|
6895
|
+
this._text = this._filterEl?.value ?? "";
|
|
6896
|
+
this._render();
|
|
6897
|
+
});
|
|
6898
|
+
const head = this.rootEl.querySelector(".log-table thead");
|
|
6899
|
+
this._grid = head && this.el ? new DataGrid({
|
|
6900
|
+
head,
|
|
6901
|
+
body: this.el,
|
|
6902
|
+
columns: this._columns(),
|
|
6903
|
+
// The order it happened in. A log read out of order is not a log.
|
|
6904
|
+
defaultSort: { col: "time", dir: "asc" },
|
|
6905
|
+
rowKey: (m) => String(m.id),
|
|
6906
|
+
emptyText: this._host.t("NoLogMessages"),
|
|
6907
|
+
rowClass: (m) => `log-row log-row-${m.level}`,
|
|
6908
|
+
contextMenu: makeGridMenu(this._host),
|
|
6909
|
+
selection: "multi"
|
|
6910
|
+
}) : null;
|
|
6911
|
+
this._host.register(this);
|
|
6912
|
+
}
|
|
6913
|
+
dispose() {
|
|
6914
|
+
this._grid?.destroy();
|
|
6915
|
+
this._host.unregister(this);
|
|
6916
|
+
try {
|
|
6917
|
+
this.rootEl.remove();
|
|
6918
|
+
} catch {
|
|
6919
|
+
}
|
|
6920
|
+
}
|
|
6921
|
+
/// The sources that exist. Sent whole: a source that has gone is gone from the tree, and
|
|
6922
|
+
/// the selection falls back to everything rather than to a node nobody can see.
|
|
6923
|
+
setSources(sources) {
|
|
6924
|
+
this._sources = sources || [];
|
|
6925
|
+
if (this._selected !== null && !this._sources.some((s) => s.id === this._selected)) this._selected = null;
|
|
6926
|
+
this._renderTree();
|
|
6927
|
+
this._render();
|
|
6928
|
+
}
|
|
6929
|
+
/// Add what has just been written. The oldest fall off the front once the cap is reached.
|
|
6930
|
+
append(messages) {
|
|
6931
|
+
if (!messages || messages.length === 0) return;
|
|
6932
|
+
this._messages.push(...messages);
|
|
6933
|
+
if (this._messages.length > this._max) this._messages.splice(0, this._messages.length - this._max);
|
|
6934
|
+
this._render();
|
|
6935
|
+
}
|
|
6936
|
+
/// Forget every message. The sources stay: they still exist, they have just gone quiet.
|
|
6937
|
+
clear() {
|
|
6938
|
+
this._messages = [];
|
|
6939
|
+
this._render();
|
|
6940
|
+
}
|
|
6941
|
+
/// Show one source's subtree, or everything when given null.
|
|
6942
|
+
select(sourceId) {
|
|
6943
|
+
this._selected = sourceId;
|
|
6944
|
+
this._renderTree();
|
|
6945
|
+
this._render();
|
|
6946
|
+
}
|
|
6947
|
+
/// What is on screen, after every filter.
|
|
6948
|
+
visible() {
|
|
6949
|
+
const sources = subtreeOf(this._sources, this._selected);
|
|
6950
|
+
return this._messages.filter((m) => keepLog(m, { levels: this._levels, text: this._text, sources }));
|
|
6951
|
+
}
|
|
6952
|
+
_render() {
|
|
6953
|
+
this._grid?.setRows(this.visible());
|
|
6954
|
+
}
|
|
6955
|
+
_renderTree() {
|
|
6956
|
+
const tree = this._treeEl;
|
|
6957
|
+
if (tree === null) return;
|
|
6958
|
+
tree.innerHTML = "";
|
|
6959
|
+
tree.appendChild(this._treeRow({ id: "", name: this._host.t("AllSources"), depth: 0, children: [] }, null));
|
|
6960
|
+
for (const root of buildLogTree(this._sources)) this._appendNode(tree, root);
|
|
6961
|
+
}
|
|
6962
|
+
_appendNode(into, node) {
|
|
6963
|
+
into.appendChild(this._treeRow(node, node.id));
|
|
6964
|
+
for (const child of node.children) this._appendNode(into, child);
|
|
6965
|
+
}
|
|
6966
|
+
// Flat rows with an indent rather than nested lists: the tree is read, not restructured,
|
|
6967
|
+
// and one list is what a keyboard walks through in the order the eye does.
|
|
6968
|
+
_treeRow(node, id) {
|
|
6969
|
+
const selected = this._selected === id;
|
|
6970
|
+
const row = makeElement("button", `log-source${selected ? " is-selected" : ""}`, {
|
|
6971
|
+
type: "button",
|
|
6972
|
+
role: "treeitem",
|
|
6973
|
+
"aria-selected": selected ? "true" : "false",
|
|
6974
|
+
"aria-level": String(node.depth + 1),
|
|
6975
|
+
style: `padding-left: ${0.5 + node.depth * 0.85}rem`
|
|
6976
|
+
}, [node.name]);
|
|
6977
|
+
row.addEventListener("click", (e) => {
|
|
6978
|
+
e.preventDefault();
|
|
6979
|
+
this.select(id);
|
|
6980
|
+
});
|
|
6981
|
+
return row;
|
|
6982
|
+
}
|
|
6983
|
+
_export() {
|
|
6984
|
+
this._grid?.download("log", this._host.t("LogMonitor"));
|
|
6985
|
+
}
|
|
6986
|
+
_columns() {
|
|
6987
|
+
const label = (key) => this._host.t(key);
|
|
6988
|
+
return [
|
|
6989
|
+
{
|
|
6990
|
+
key: "source",
|
|
6991
|
+
header: label("Source"),
|
|
6992
|
+
exportable: true,
|
|
6993
|
+
value: (m) => m.source ?? this._sourceName(m.sourceId)
|
|
6994
|
+
},
|
|
6995
|
+
{
|
|
6996
|
+
key: "time",
|
|
6997
|
+
header: label("Time"),
|
|
6998
|
+
exportable: true,
|
|
6999
|
+
value: (m) => m.time,
|
|
7000
|
+
render: (m) => this._host.presentation.timeText(m.time),
|
|
7001
|
+
exportValue: (m) => this._host.presentation.timeText(m.time)
|
|
7002
|
+
},
|
|
7003
|
+
{
|
|
7004
|
+
key: "level",
|
|
7005
|
+
header: label("Type"),
|
|
7006
|
+
headerClass: "log-level-col",
|
|
7007
|
+
exportable: true,
|
|
7008
|
+
cellClass: (m) => `log-level-cell log-level-${m.level}`,
|
|
7009
|
+
value: (m) => m.level,
|
|
7010
|
+
render: (m) => LEVEL_LETTER[m.level] ?? String(m.level ?? "").slice(0, 1).toUpperCase(),
|
|
7011
|
+
// The sheet carries the level's name, not the letter the column has room for.
|
|
7012
|
+
exportValue: (m) => m.level
|
|
7013
|
+
},
|
|
7014
|
+
{
|
|
7015
|
+
key: "message",
|
|
7016
|
+
header: label("Message"),
|
|
7017
|
+
exportable: true,
|
|
7018
|
+
cellClass: () => "log-message-cell",
|
|
7019
|
+
value: (m) => m.message
|
|
7020
|
+
}
|
|
7021
|
+
];
|
|
7022
|
+
}
|
|
7023
|
+
_sourceName(id) {
|
|
7024
|
+
return this._sources.find((s) => s.id === id)?.name ?? id;
|
|
7025
|
+
}
|
|
7026
|
+
};
|
|
7027
|
+
_LogMonitorWidget.TYPE = ControlTypes.LogMonitor;
|
|
7028
|
+
var LogMonitorWidget = _LogMonitorWidget;
|
|
7029
|
+
function levelTitle(host, level) {
|
|
7030
|
+
switch (level) {
|
|
7031
|
+
case LogLevels.Error:
|
|
7032
|
+
return host.t("Errors");
|
|
7033
|
+
case LogLevels.Warning:
|
|
7034
|
+
return host.t("Warnings");
|
|
7035
|
+
case LogLevels.Info:
|
|
7036
|
+
return host.t("Messages");
|
|
7037
|
+
case LogLevels.Debug:
|
|
7038
|
+
return host.t("Debug");
|
|
7039
|
+
default:
|
|
7040
|
+
return host.t("Verbose");
|
|
7041
|
+
}
|
|
7042
|
+
}
|
|
7043
|
+
|
|
7044
|
+
// src/tradefeed-aggregator.ts
|
|
7045
|
+
function aggregateBubbles(ticks, maxBucketsPerSide) {
|
|
7046
|
+
if (!ticks || ticks.length === 0) return [];
|
|
7047
|
+
const budget = Math.max(1, Math.floor(maxBucketsPerSide));
|
|
7048
|
+
const buys = [];
|
|
7049
|
+
const sells = [];
|
|
7050
|
+
for (const tick of ticks) (tick.buy ? buys : sells).push(tick);
|
|
7051
|
+
const bubbles = [];
|
|
7052
|
+
for (const side of [buys, sells]) {
|
|
7053
|
+
if (side.length === 0) continue;
|
|
7054
|
+
if (side.length <= budget) {
|
|
7055
|
+
for (const tick of side) bubbles.push({ ...tick, count: 1 });
|
|
7056
|
+
continue;
|
|
7057
|
+
}
|
|
7058
|
+
const bucketSize = Math.ceil(side.length / budget);
|
|
7059
|
+
for (let start = 0; start < side.length; start += bucketSize)
|
|
7060
|
+
bubbles.push(merge(side.slice(start, start + bucketSize)));
|
|
7061
|
+
}
|
|
7062
|
+
return bubbles;
|
|
7063
|
+
}
|
|
7064
|
+
function merge(slice) {
|
|
7065
|
+
let quantity = 0;
|
|
7066
|
+
let weighted = 0;
|
|
7067
|
+
for (const tick of slice) {
|
|
7068
|
+
const q = tick.quantity || 0;
|
|
7069
|
+
quantity += q;
|
|
7070
|
+
weighted += q * tick.price;
|
|
7071
|
+
}
|
|
7072
|
+
const price2 = quantity > 0 ? weighted / quantity : slice[0].price;
|
|
7073
|
+
const middle = slice[Math.floor(slice.length / 2)];
|
|
7074
|
+
return {
|
|
7075
|
+
symbol: slice[0].symbol,
|
|
7076
|
+
side: slice[0].side,
|
|
7077
|
+
buy: slice[0].buy,
|
|
7078
|
+
price: price2,
|
|
7079
|
+
quantity,
|
|
7080
|
+
time: middle.time,
|
|
7081
|
+
index: middle.index,
|
|
7082
|
+
count: slice.length
|
|
7083
|
+
};
|
|
7084
|
+
}
|
|
7085
|
+
|
|
7086
|
+
// src/tradefeed-bubbles.ts
|
|
7087
|
+
var PAD_X = 10;
|
|
7088
|
+
var PAD_Y = 10;
|
|
7089
|
+
var AXIS_WIDTH = 56;
|
|
7090
|
+
var LABEL_GAP = 4;
|
|
7091
|
+
var LANE_GAP = 2;
|
|
7092
|
+
var MIN_LANE_HEIGHT = 20;
|
|
7093
|
+
var AXIS_TICKS = 3;
|
|
7094
|
+
var MIN_RADIUS = 2;
|
|
7095
|
+
var MAX_RADIUS = 22;
|
|
7096
|
+
var RADIUS_LANE_SHARE = 0.4;
|
|
7097
|
+
var BUCKET_WIDTH = 8;
|
|
7098
|
+
var MIN_BUCKETS = 6;
|
|
7099
|
+
var MAX_BUCKETS = 30;
|
|
7100
|
+
function layoutBubbles(input) {
|
|
7101
|
+
const { width, height, total } = input;
|
|
7102
|
+
const axisX = Math.max(PAD_X + 1, width - AXIS_WIDTH);
|
|
7103
|
+
const drawWidth = Math.max(1, axisX - PAD_X);
|
|
7104
|
+
const layout = { axisX, labelX: axisX + LABEL_GAP, lanes: [], bubbles: [] };
|
|
7105
|
+
const lanes = input.lanes.filter((lane) => lane.ticks.length > 0);
|
|
7106
|
+
if (lanes.length === 0) return layout;
|
|
7107
|
+
const named = input.lanes.length > 1;
|
|
7108
|
+
const laneHeight = Math.max(
|
|
7109
|
+
MIN_LANE_HEIGHT,
|
|
7110
|
+
(height - 2 * PAD_Y - LANE_GAP * (input.lanes.length - 1)) / input.lanes.length
|
|
7111
|
+
);
|
|
7112
|
+
input.lanes.forEach((lane, position) => {
|
|
7113
|
+
const laneTop = named ? PAD_Y + position * (laneHeight + LANE_GAP) : PAD_Y;
|
|
7114
|
+
const laneSpan = named ? laneHeight : Math.max(MIN_LANE_HEIGHT, height - 2 * PAD_Y);
|
|
7115
|
+
const scale = priceScale(lane.ticks, laneTop, laneSpan);
|
|
7116
|
+
layout.lanes.push({
|
|
7117
|
+
symbol: lane.symbol,
|
|
7118
|
+
label: named ? { x: LABEL_GAP, y: laneTop + 2 } : null,
|
|
7119
|
+
separatorY: named && position > 0 ? laneTop - LANE_GAP / 2 : null,
|
|
7120
|
+
ticks: lane.ticks.length === 0 ? [] : axisTicks2(scale)
|
|
7121
|
+
});
|
|
7122
|
+
if (lane.ticks.length === 0) return;
|
|
7123
|
+
const buckets = Math.max(MIN_BUCKETS, Math.min(MAX_BUCKETS, Math.floor(drawWidth / BUCKET_WIDTH)));
|
|
7124
|
+
const bubbles = aggregateBubbles(lane.ticks, buckets);
|
|
7125
|
+
let maxQuantity = 0;
|
|
7126
|
+
for (const bubble of bubbles) if (bubble.quantity > maxQuantity) maxQuantity = bubble.quantity;
|
|
7127
|
+
const quantityScale = Math.max(maxQuantity, Number.EPSILON);
|
|
7128
|
+
const radiusCap = Math.min(MAX_RADIUS, laneSpan * RADIUS_LANE_SHARE);
|
|
7129
|
+
for (const bubble of bubbles) {
|
|
7130
|
+
layout.bubbles.push({
|
|
7131
|
+
// Newest print (index 0) at the right edge, oldest at the left.
|
|
7132
|
+
x: PAD_X + drawWidth * (total <= 1 ? 1 : 1 - bubble.index / (total - 1)),
|
|
7133
|
+
y: scale.top + scale.height * (1 - (bubble.price - scale.min) / scale.range),
|
|
7134
|
+
radius: MIN_RADIUS + Math.sqrt(Math.max(0, bubble.quantity) / quantityScale) * radiusCap,
|
|
7135
|
+
bubble
|
|
7136
|
+
});
|
|
7137
|
+
}
|
|
7138
|
+
});
|
|
7139
|
+
layout.bubbles.sort((left, right) => right.bubble.index - left.bubble.index);
|
|
7140
|
+
return layout;
|
|
7141
|
+
}
|
|
7142
|
+
function priceScale(ticks, top, height) {
|
|
7143
|
+
let min = Infinity;
|
|
7144
|
+
let max = -Infinity;
|
|
7145
|
+
for (const tick of ticks) {
|
|
7146
|
+
if (tick.price < min) min = tick.price;
|
|
7147
|
+
if (tick.price > max) max = tick.price;
|
|
7148
|
+
}
|
|
7149
|
+
if (!isFinite(min) || !isFinite(max)) {
|
|
7150
|
+
min = 0;
|
|
7151
|
+
max = 0;
|
|
7152
|
+
}
|
|
7153
|
+
return { top, height, min, range: Math.max(max - min, Number.EPSILON) };
|
|
7154
|
+
}
|
|
7155
|
+
function axisTicks2(scale) {
|
|
7156
|
+
const format = priceFormatter(scale.min, scale.min + scale.range);
|
|
7157
|
+
const ticks = [];
|
|
7158
|
+
for (let step = 0; step < AXIS_TICKS; step++) {
|
|
7159
|
+
const fraction = step / (AXIS_TICKS - 1);
|
|
7160
|
+
ticks.push({
|
|
7161
|
+
text: format(scale.min + scale.range - scale.range * fraction),
|
|
7162
|
+
y: scale.top + scale.height * fraction
|
|
7163
|
+
});
|
|
7164
|
+
}
|
|
7165
|
+
return ticks;
|
|
7166
|
+
}
|
|
7167
|
+
function priceFormatter(min, max) {
|
|
7168
|
+
const magnitude = Math.max(Math.abs(min), Math.abs(max));
|
|
7169
|
+
let digits = 6;
|
|
7170
|
+
if (magnitude >= 1e3) digits = 0;
|
|
7171
|
+
else if (magnitude >= 10) digits = 2;
|
|
7172
|
+
else if (magnitude >= 0.1) digits = 4;
|
|
7173
|
+
return (price2) => price2.toLocaleString(void 0, {
|
|
7174
|
+
minimumFractionDigits: digits,
|
|
7175
|
+
maximumFractionDigits: digits
|
|
7176
|
+
});
|
|
7177
|
+
}
|
|
7178
|
+
|
|
7179
|
+
// src/tradefeed-widget.ts
|
|
7180
|
+
var FILL_ALPHA = 0.55;
|
|
7181
|
+
var STROKE_ALPHA = 0.9;
|
|
7182
|
+
var LABEL_ALPHA3 = 0.55;
|
|
7183
|
+
var SEPARATOR_ALPHA = 0.12;
|
|
7184
|
+
var RULE_ALPHA = 0.08;
|
|
7185
|
+
var HOVER_SLACK = 6;
|
|
7186
|
+
var TOOLTIP_OFFSET2 = 12;
|
|
7187
|
+
var TOOLTIP_MARGIN2 = 4;
|
|
7188
|
+
var _TradeFeedWidget = class _TradeFeedWidget {
|
|
7189
|
+
static create(hostEl, state, deps) {
|
|
7190
|
+
const host = assertHost(deps?.host, "TradeFeedWidget");
|
|
7191
|
+
const root = _TradeFeedWidget._buildRoot(host);
|
|
7192
|
+
root.id = makePanelId(_TradeFeedWidget.TYPE);
|
|
7193
|
+
hostEl.appendChild(root);
|
|
7194
|
+
return new _TradeFeedWidget(root, state || {}, deps);
|
|
7195
|
+
}
|
|
7196
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
7197
|
+
// docking host lifts `.panel-header`'s children into its tab strip.
|
|
7198
|
+
//
|
|
7199
|
+
// Two `+` buttons, and they are not the same gesture: the one in the header
|
|
7200
|
+
// pins another instrument to THIS feed, the one beside it asks the host for
|
|
7201
|
+
// another feed panel. The tooltips are what tell them apart, so neither is
|
|
7202
|
+
// an icon on its own.
|
|
7203
|
+
static _buildRoot(host) {
|
|
7204
|
+
const addInstrument = host.t("AddInstrument");
|
|
7205
|
+
const marketTrades = host.t("MarketTrades");
|
|
7206
|
+
return makePanelRoot("tradefeed-panel tf-tab-market tf-view-list", host.t("TradeFeed"), [
|
|
7207
|
+
makeElement("div", "panel-header", {}, [
|
|
7208
|
+
makeElement("span", "", {}, [marketTrades]),
|
|
7209
|
+
makeIconButton("bt-icon-btn tf-add-symbol-btn", addInstrument, "bi-plus-lg", { type: "button" }),
|
|
7210
|
+
makeIconButton("bt-icon-btn panel-add-btn", host.t("Add trade feed"), "bi-window-plus", { type: "button" }),
|
|
7211
|
+
makeElement("div", "tradefeed-view-toggle tf-view-toggle", { role: "group", "aria-label": host.t("TradeFeedView") }, [
|
|
7212
|
+
makeIconButton("tf-view-btn", host.t("ListView"), "bi-list-ul", { type: "button", "data-view": "list" }),
|
|
7213
|
+
makeIconButton("tf-view-btn", host.t("BubbleChart"), "bi-circle-fill", { type: "button", "data-view": "bubbles" })
|
|
7214
|
+
]),
|
|
7215
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
7216
|
+
]),
|
|
7217
|
+
makeElement("div", "tf-tabs", { role: "tablist", "aria-label": host.t("Trade feed tabs") }, [
|
|
7218
|
+
makeElement("button", "tf-tab active", { type: "button", role: "tab", "aria-selected": "true", "data-tab": "market" }, [marketTrades]),
|
|
7219
|
+
makeElement("button", "tf-tab", { type: "button", role: "tab", "aria-selected": "false", "data-tab": "my" }, [host.t("My trades")])
|
|
7220
|
+
]),
|
|
7221
|
+
makeElement("div", "tf-extras", { role: "list", "aria-label": host.t("ExtraInstruments"), hidden: "" }, []),
|
|
7222
|
+
// Each tape is a DataGrid over the shared table skin: the <thead>
|
|
7223
|
+
// is left empty for the grid to fill, which is also what makes the
|
|
7224
|
+
// tape sortable and gives it the grid's menu and selection.
|
|
7225
|
+
makeElement("div", "tradefeed-content tf-market", { "aria-live": "polite" }, [
|
|
7226
|
+
makeElement("table", "terminal-table tradefeed-table", { role: "table", "aria-label": marketTrades }, [
|
|
7227
|
+
makeElement("thead", "", {}, []),
|
|
7228
|
+
makeElement("tbody", "", {}, [])
|
|
7229
|
+
])
|
|
7230
|
+
]),
|
|
7231
|
+
makeElement("div", "tradefeed-content tf-my", {}, [
|
|
7232
|
+
makeElement("table", "terminal-table tradefeed-table", { role: "table", "aria-label": host.t("My trades") }, [
|
|
7233
|
+
makeElement("thead", "", {}, []),
|
|
7234
|
+
makeElement("tbody", "", {}, [])
|
|
7235
|
+
])
|
|
7236
|
+
]),
|
|
7237
|
+
makeElement("canvas", "tradefeed-bubbles tf-bubbles", { "aria-hidden": "true" }, []),
|
|
7238
|
+
makeElement("div", "tf-bubble-tooltip", { role: "tooltip", hidden: "" }, [])
|
|
7239
|
+
]);
|
|
7240
|
+
}
|
|
7241
|
+
constructor(rootEl, state, deps) {
|
|
7242
|
+
this._host = assertHost(deps?.host, "TradeFeedWidget");
|
|
7243
|
+
this.rootEl = rootEl;
|
|
7244
|
+
this.extrasEl = this.rootEl.querySelector(".tf-extras");
|
|
7245
|
+
this.bubbleCanvas = this.rootEl.querySelector(".tf-bubbles");
|
|
7246
|
+
this._tabsEl = this.rootEl.querySelector(".tf-tabs");
|
|
7247
|
+
this._viewToggleEl = this.rootEl.querySelector(".tf-view-toggle");
|
|
7248
|
+
this._tooltipEl = this.rootEl.querySelector(".tf-bubble-tooltip");
|
|
7249
|
+
this._addBtn = this.rootEl.querySelector(".panel-add-btn");
|
|
7250
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
7251
|
+
this._addSymbolBtn = this.rootEl.querySelector(".tf-add-symbol-btn");
|
|
7252
|
+
this._bubbleCtx = this.bubbleCanvas?.getContext("2d") ?? null;
|
|
7253
|
+
this.trades = [];
|
|
7254
|
+
this.bubbleTrades = [];
|
|
7255
|
+
this.myTrades = [];
|
|
7256
|
+
this.avgQty = 100;
|
|
7257
|
+
this.tab = "market";
|
|
7258
|
+
this._seq = 0;
|
|
7259
|
+
this._bubbleHits = [];
|
|
7260
|
+
this._canvasSize = null;
|
|
7261
|
+
this._resizeObserver = null;
|
|
7262
|
+
this._marketGrid = this._makeGrid(".tf-market");
|
|
7263
|
+
this._myGrid = this._makeGrid(".tf-my");
|
|
7264
|
+
const saved = this._host.preferences.get(_TradeFeedWidget.VIEW_KEY, null);
|
|
7265
|
+
this.view = saved === "bubbles" ? "bubbles" : "list";
|
|
7266
|
+
this._activeSymbol = null;
|
|
7267
|
+
this._extraSymbols = new Set(Array.isArray(state.extras) ? state.extras : []);
|
|
7268
|
+
this._addBtn?.addEventListener("click", (e) => {
|
|
7269
|
+
e.preventDefault();
|
|
7270
|
+
this._host.spawn({ extras: [...this._extraSymbols] });
|
|
7271
|
+
});
|
|
7272
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
7273
|
+
e.preventDefault();
|
|
7274
|
+
this.dispose();
|
|
7275
|
+
this._host.close();
|
|
7276
|
+
});
|
|
7277
|
+
this._addSymbolBtn?.addEventListener("click", (e) => {
|
|
7278
|
+
e.preventDefault();
|
|
7279
|
+
e.stopPropagation();
|
|
7280
|
+
this._host.trading.pickInstrument((symbol) => void this.addExtraSymbol(symbol));
|
|
7281
|
+
});
|
|
7282
|
+
this._bindTabs();
|
|
7283
|
+
this._bindViewToggle();
|
|
7284
|
+
this._bindBubbleHover();
|
|
7285
|
+
this._applyView();
|
|
7286
|
+
this._renderExtras();
|
|
7287
|
+
for (const symbol of this._extraSymbols)
|
|
7288
|
+
void this._host.trading.marketData.addSymbol(symbol, MarketDataLevels.Tape).catch(() => {
|
|
7289
|
+
});
|
|
7290
|
+
if (this.bubbleCanvas && typeof ResizeObserver === "function") {
|
|
7291
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
7292
|
+
this._sizeCanvas();
|
|
7293
|
+
this._renderBubbles();
|
|
7294
|
+
});
|
|
7295
|
+
this._resizeObserver.observe(this.bubbleCanvas);
|
|
7296
|
+
}
|
|
7297
|
+
this._host.register(this);
|
|
7298
|
+
}
|
|
7299
|
+
dispose() {
|
|
7300
|
+
try {
|
|
7301
|
+
this._resizeObserver?.disconnect();
|
|
7302
|
+
} catch {
|
|
7303
|
+
}
|
|
7304
|
+
this._marketGrid?.destroy();
|
|
7305
|
+
this._myGrid?.destroy();
|
|
7306
|
+
for (const symbol of this._extraSymbols)
|
|
7307
|
+
void this._host.trading.marketData.removeSymbol(symbol).catch(() => {
|
|
7308
|
+
});
|
|
7309
|
+
this._host.unregister(this);
|
|
7310
|
+
try {
|
|
7311
|
+
this.rootEl.remove();
|
|
7312
|
+
} catch {
|
|
7313
|
+
}
|
|
7314
|
+
}
|
|
7315
|
+
/// Which symbol the rest of the page is showing. The feed accepts prints
|
|
7316
|
+
/// for it without it being pinned, and re-syncs so the symbol column
|
|
7317
|
+
/// appears or disappears with the pinned set.
|
|
7318
|
+
setActiveSymbol(symbol) {
|
|
7319
|
+
this._activeSymbol = symbol || null;
|
|
7320
|
+
this._renderExtras();
|
|
7321
|
+
this._renderBubbles();
|
|
7322
|
+
}
|
|
7323
|
+
/// Replace the tape wholesale — what a host does when the page moves to a
|
|
7324
|
+
/// different instrument. The flash baseline resets with it: a new
|
|
7325
|
+
/// instrument's history is history, not fifty arrivals at once.
|
|
7326
|
+
setTrades(trades) {
|
|
7327
|
+
const rows = (trades || []).map((t) => this._stamp(t));
|
|
7328
|
+
this.trades = rows.slice(0, _TradeFeedWidget.MAX_ROWS);
|
|
7329
|
+
this.bubbleTrades = rows.slice(0, _TradeFeedWidget.MAX_BUBBLES);
|
|
7330
|
+
if (this.trades.length > 0) {
|
|
7331
|
+
const total = this.trades.reduce((sum, t) => sum + (t.quantity ?? 0), 0);
|
|
7332
|
+
this.avgQty = total / this.trades.length || this.avgQty;
|
|
4646
7333
|
}
|
|
4647
7334
|
this._marketGrid?.flashReset();
|
|
4648
7335
|
this._marketGrid?.setRows([...this.trades]);
|
|
@@ -4963,7 +7650,7 @@ var SSTradingControls = (() => {
|
|
|
4963
7650
|
_TradeFeedWidget._rule(ctx, 0, size.width, lane.separatorY);
|
|
4964
7651
|
}
|
|
4965
7652
|
if (lane.label) {
|
|
4966
|
-
ctx.globalAlpha =
|
|
7653
|
+
ctx.globalAlpha = LABEL_ALPHA3;
|
|
4967
7654
|
ctx.fillStyle = palette.grid;
|
|
4968
7655
|
ctx.textBaseline = "top";
|
|
4969
7656
|
ctx.textAlign = "left";
|
|
@@ -4973,7 +7660,7 @@ var SSTradingControls = (() => {
|
|
|
4973
7660
|
ctx.globalAlpha = RULE_ALPHA;
|
|
4974
7661
|
ctx.strokeStyle = palette.grid;
|
|
4975
7662
|
_TradeFeedWidget._rule(ctx, 0, layout.axisX - 2, tick.y);
|
|
4976
|
-
ctx.globalAlpha =
|
|
7663
|
+
ctx.globalAlpha = LABEL_ALPHA3;
|
|
4977
7664
|
ctx.fillStyle = palette.grid;
|
|
4978
7665
|
ctx.textBaseline = "middle";
|
|
4979
7666
|
ctx.textAlign = "left";
|
|
@@ -5049,10 +7736,10 @@ var SSTradingControls = (() => {
|
|
|
5049
7736
|
const parentRect = tooltip.offsetParent?.getBoundingClientRect() ?? canvasRect;
|
|
5050
7737
|
const originX = canvasRect.left - parentRect.left;
|
|
5051
7738
|
const originY = canvasRect.top - parentRect.top;
|
|
5052
|
-
const maxX = originX + canvasRect.width - tooltip.offsetWidth -
|
|
5053
|
-
const maxY = originY + canvasRect.height - tooltip.offsetHeight -
|
|
5054
|
-
tooltip.style.left = `${Math.max(0, Math.min(originX + x +
|
|
5055
|
-
tooltip.style.top = `${Math.max(0, Math.min(originY + y +
|
|
7739
|
+
const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN2;
|
|
7740
|
+
const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN2;
|
|
7741
|
+
tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET2, maxX))}px`;
|
|
7742
|
+
tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET2, maxY))}px`;
|
|
5056
7743
|
}
|
|
5057
7744
|
_tooltipLine(label, value, valueClass) {
|
|
5058
7745
|
const row = makeElement("div", "tf-bbtt-row", {}, [
|