@stocksharp/trading-controls 0.1.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/LICENSE +30 -0
- package/NOTICE +21 -0
- package/README.md +240 -0
- package/dist/esm/active-orders-widget.js +382 -0
- package/dist/esm/active-orders-widget.js.map +1 -0
- package/dist/esm/control-types.js +23 -0
- package/dist/esm/control-types.js.map +1 -0
- package/dist/esm/dom.js +55 -0
- package/dist/esm/dom.js.map +1 -0
- package/dist/esm/formatters.js +69 -0
- package/dist/esm/formatters.js.map +1 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/positions-widget.js +258 -0
- package/dist/esm/positions-widget.js.map +1 -0
- package/dist/esm/trade-history-widget.js +197 -0
- package/dist/esm/trade-history-widget.js.map +1 -0
- package/dist/esm/trading-data.js +26 -0
- package/dist/esm/trading-data.js.map +1 -0
- package/dist/esm/trading-host.js +79 -0
- package/dist/esm/trading-host.js.map +1 -0
- package/dist/esm/watchlist-widget.js +481 -0
- package/dist/esm/watchlist-widget.js.map +1 -0
- package/dist/sstradingcontrols.js +1740 -0
- package/dist/sstradingcontrols.js.map +7 -0
- package/dist/types/active-orders-widget.d.ts +52 -0
- package/dist/types/active-orders-widget.d.ts.map +1 -0
- package/dist/types/control-types.d.ts +8 -0
- package/dist/types/control-types.d.ts.map +1 -0
- package/dist/types/dom.d.ts +6 -0
- package/dist/types/dom.d.ts.map +1 -0
- package/dist/types/formatters.d.ts +8 -0
- package/dist/types/formatters.d.ts.map +1 -0
- package/dist/types/index.d.ts +16 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/positions-widget.d.ts +37 -0
- package/dist/types/positions-widget.d.ts.map +1 -0
- package/dist/types/trade-history-widget.d.ts +25 -0
- package/dist/types/trade-history-widget.d.ts.map +1 -0
- package/dist/types/trading-data.d.ts +55 -0
- package/dist/types/trading-data.d.ts.map +1 -0
- package/dist/types/trading-host.d.ts +62 -0
- package/dist/types/trading-host.d.ts.map +1 -0
- package/dist/types/watchlist-widget.d.ts +60 -0
- package/dist/types/watchlist-widget.d.ts.map +1 -0
- package/package.json +129 -0
- package/src/active-orders-widget.ts +411 -0
- package/src/control-types.ts +24 -0
- package/src/dom.ts +68 -0
- package/src/formatters.ts +72 -0
- package/src/index.ts +52 -0
- package/src/positions-widget.ts +305 -0
- package/src/trade-history-widget.ts +221 -0
- package/src/trading-data.ts +121 -0
- package/src/trading-host.ts +300 -0
- package/src/watchlist-widget.ts +513 -0
- package/styles/theme.css +86 -0
- package/styles/trading-controls.css +469 -0
- package/translation-keys.json +58 -0
|
@@ -0,0 +1,1740 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var SSTradingControls = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
21
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
22
|
+
|
|
23
|
+
// src/index.ts
|
|
24
|
+
var index_exports = {};
|
|
25
|
+
__export(index_exports, {
|
|
26
|
+
ActiveOrdersWidget: () => ActiveOrdersWidget,
|
|
27
|
+
ControlTypes: () => ControlTypes,
|
|
28
|
+
MarketDataLevels: () => MarketDataLevels,
|
|
29
|
+
OrderStates: () => OrderStates,
|
|
30
|
+
PRESENTATION_CLASSES: () => PRESENTATION_CLASSES,
|
|
31
|
+
PositionsWidget: () => PositionsWidget,
|
|
32
|
+
TradeHistoryWidget: () => TradeHistoryWidget,
|
|
33
|
+
WatchlistWidget: () => WatchlistWidget,
|
|
34
|
+
assertHost: () => assertHost,
|
|
35
|
+
cleanRejectReason: () => cleanRejectReason,
|
|
36
|
+
formatPnl: () => formatPnl,
|
|
37
|
+
formatPrice: () => formatPrice,
|
|
38
|
+
formatQty: () => formatQty,
|
|
39
|
+
formatTime: () => formatTime,
|
|
40
|
+
makeElement: () => makeElement,
|
|
41
|
+
makeIcon: () => makeIcon,
|
|
42
|
+
makeIconButton: () => makeIconButton,
|
|
43
|
+
makePanelId: () => makePanelId,
|
|
44
|
+
makePanelRoot: () => makePanelRoot
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// src/trading-host.ts
|
|
48
|
+
var PRESENTATION_CLASSES = {
|
|
49
|
+
sideClass: ["side-buy", "side-sell"],
|
|
50
|
+
pnlClass: ["pnl-positive", "pnl-negative"]
|
|
51
|
+
};
|
|
52
|
+
var MarketDataLevels = {
|
|
53
|
+
/// Level 1 only: best bid/ask and last price.
|
|
54
|
+
Quotes: "l1only",
|
|
55
|
+
/// Level 1 plus every tick.
|
|
56
|
+
Tape: "tape",
|
|
57
|
+
/// Level 1, ticks and market depth.
|
|
58
|
+
Full: "full"
|
|
59
|
+
};
|
|
60
|
+
function assertHost(host, controlName) {
|
|
61
|
+
_required(host, "host", "object", controlName);
|
|
62
|
+
_required(host.isPrimary, "host.isPrimary", "boolean", controlName);
|
|
63
|
+
_required(host.t, "host.t", "function", controlName);
|
|
64
|
+
_required(host.presentation, "host.presentation", "object", controlName);
|
|
65
|
+
_required(host.presentation.sideText, "host.presentation.sideText", "function", controlName);
|
|
66
|
+
_required(host.presentation.typeText, "host.presentation.typeText", "function", controlName);
|
|
67
|
+
_required(host.presentation.statusText, "host.presentation.statusText", "function", controlName);
|
|
68
|
+
_required(host.presentation.sideClass, "host.presentation.sideClass", "function", controlName);
|
|
69
|
+
_required(host.presentation.pnlClass, "host.presentation.pnlClass", "function", controlName);
|
|
70
|
+
_required(host.preferences, "host.preferences", "object", controlName);
|
|
71
|
+
_required(host.preferences.get, "host.preferences.get", "function", controlName);
|
|
72
|
+
_required(host.preferences.set, "host.preferences.set", "function", controlName);
|
|
73
|
+
_required(host.cache, "host.cache", "object", controlName);
|
|
74
|
+
_required(host.cache.get, "host.cache.get", "function", controlName);
|
|
75
|
+
_required(host.cache.set, "host.cache.set", "function", controlName);
|
|
76
|
+
_required(host.trading, "host.trading", "object", controlName);
|
|
77
|
+
_required(host.trading.api, "host.trading.api", "object", controlName);
|
|
78
|
+
_required(host.trading.api.getExecutions, "host.trading.api.getExecutions", "function", controlName);
|
|
79
|
+
_required(host.trading.api.searchInstruments, "host.trading.api.searchInstruments", "function", controlName);
|
|
80
|
+
_required(host.trading.marketData, "host.trading.marketData", "object", controlName);
|
|
81
|
+
_required(host.trading.marketData.addSymbol, "host.trading.marketData.addSymbol", "function", controlName);
|
|
82
|
+
_required(host.trading.marketData.removeSymbol, "host.trading.marketData.removeSymbol", "function", controlName);
|
|
83
|
+
_required(host.trading.marketData.resubscribe, "host.trading.marketData.resubscribe", "function", controlName);
|
|
84
|
+
_required(host.trading.marketData.getOrders, "host.trading.marketData.getOrders", "function", controlName);
|
|
85
|
+
_required(host.trading.portfolioId, "host.trading.portfolioId", "function", controlName);
|
|
86
|
+
_required(host.trading.pickInstrument, "host.trading.pickInstrument", "function", controlName);
|
|
87
|
+
_required(host.ticker, "host.ticker", "object", controlName);
|
|
88
|
+
_required(host.ticker.publish, "host.ticker.publish", "function", controlName);
|
|
89
|
+
_required(host.allow, "host.allow", "function", controlName);
|
|
90
|
+
_required(host.log, "host.log", "function", controlName);
|
|
91
|
+
_required(host.close, "host.close", "function", controlName);
|
|
92
|
+
_required(host.spawn, "host.spawn", "function", controlName);
|
|
93
|
+
_required(host.persistState, "host.persistState", "function", controlName);
|
|
94
|
+
_required(host.saveLayout, "host.saveLayout", "function", controlName);
|
|
95
|
+
_required(host.register, "host.register", "function", controlName);
|
|
96
|
+
_required(host.unregister, "host.unregister", "function", controlName);
|
|
97
|
+
_required(host.broadcast, "host.broadcast", "function", controlName);
|
|
98
|
+
return host;
|
|
99
|
+
}
|
|
100
|
+
function _required(value, path, kind, controlName) {
|
|
101
|
+
if (value === null || value === void 0 || typeof value !== kind)
|
|
102
|
+
throw new Error(`${controlName}: ${path} is required`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/control-types.ts
|
|
106
|
+
var ControlTypes = {
|
|
107
|
+
Watchlist: "watchlist",
|
|
108
|
+
ActiveOrders: "activeOrders",
|
|
109
|
+
TradeHistory: "tradeHistory",
|
|
110
|
+
Positions: "positions"
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// src/formatters.ts
|
|
114
|
+
function formatPrice(price) {
|
|
115
|
+
if (price == null || isNaN(Number(price))) return "--";
|
|
116
|
+
const n = Number(price);
|
|
117
|
+
const a = Math.abs(n);
|
|
118
|
+
let prec = 2;
|
|
119
|
+
if (a < 1) prec = 4;
|
|
120
|
+
if (a < 0.1) prec = 5;
|
|
121
|
+
if (a < 1e-3) prec = 6;
|
|
122
|
+
if (a >= 1e3) prec = 1;
|
|
123
|
+
if (a >= 1e4) prec = 0;
|
|
124
|
+
return n.toFixed(prec);
|
|
125
|
+
}
|
|
126
|
+
function formatQty(qty) {
|
|
127
|
+
if (qty == null) return "--";
|
|
128
|
+
const n = Number(qty);
|
|
129
|
+
if (!isFinite(n)) return "--";
|
|
130
|
+
return n.toLocaleString(void 0, { maximumFractionDigits: 8 });
|
|
131
|
+
}
|
|
132
|
+
function formatTime(dateStr) {
|
|
133
|
+
const d = new Date(dateStr);
|
|
134
|
+
return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
135
|
+
}
|
|
136
|
+
function formatPnl(pnl) {
|
|
137
|
+
if (pnl == null) return "--";
|
|
138
|
+
const val = Number(pnl);
|
|
139
|
+
const sign = val >= 0 ? "+" : "";
|
|
140
|
+
return sign + val.toFixed(2);
|
|
141
|
+
}
|
|
142
|
+
function cleanRejectReason(reason) {
|
|
143
|
+
if (!reason) return "";
|
|
144
|
+
const i = reason.indexOf("{");
|
|
145
|
+
const j = reason.lastIndexOf("}");
|
|
146
|
+
if (i >= 0 && j > i) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(reason.substring(i, j + 1));
|
|
149
|
+
if (parsed && typeof parsed.message === "string" && parsed.message.length > 0)
|
|
150
|
+
return parsed.message;
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return reason;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/dom.ts
|
|
158
|
+
function makeElement(tag, className, attrs, children) {
|
|
159
|
+
const element = document.createElement(tag);
|
|
160
|
+
if (className) element.className = className;
|
|
161
|
+
for (const [name, value] of Object.entries(attrs)) element.setAttribute(name, value);
|
|
162
|
+
for (const child of children)
|
|
163
|
+
element.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
|
164
|
+
return element;
|
|
165
|
+
}
|
|
166
|
+
function makeIcon(iconClass) {
|
|
167
|
+
return makeElement("i", `bi ${iconClass}`, {}, []);
|
|
168
|
+
}
|
|
169
|
+
function makeIconButton(className, label, iconClass, attrs) {
|
|
170
|
+
return makeElement(
|
|
171
|
+
"button",
|
|
172
|
+
className,
|
|
173
|
+
{ title: label, "aria-label": label, ...attrs },
|
|
174
|
+
[makeIcon(iconClass)]
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
function makePanelRoot(modifierClass, label, children) {
|
|
178
|
+
return makeElement("div", `terminal-panel ${modifierClass}`, { role: "region", "aria-label": label }, children);
|
|
179
|
+
}
|
|
180
|
+
function makePanelId(type) {
|
|
181
|
+
return `panel-${type}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e3).toString(36)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// node_modules/@stocksharp/grids/src/table-sort.ts
|
|
185
|
+
var SortDirections = {
|
|
186
|
+
Asc: "asc",
|
|
187
|
+
Desc: "desc"
|
|
188
|
+
};
|
|
189
|
+
function readByKey(row, key) {
|
|
190
|
+
return row === null || row === void 0 ? void 0 : row[key];
|
|
191
|
+
}
|
|
192
|
+
var TableSort = class _TableSort {
|
|
193
|
+
// `defaultSort` is the order applied when no header is explicitly selected — the table's
|
|
194
|
+
// resting sort. Pass null only for a table that genuinely has no meaningful default (raw row
|
|
195
|
+
// order); every real blotter should name one so its first paint is deterministic.
|
|
196
|
+
constructor(headerEl, accessors, onChange, defaultSort) {
|
|
197
|
+
__publicField(this, "col");
|
|
198
|
+
__publicField(this, "dir");
|
|
199
|
+
__publicField(this, "_headerEl");
|
|
200
|
+
__publicField(this, "_accessors");
|
|
201
|
+
__publicField(this, "_onChange");
|
|
202
|
+
__publicField(this, "_default");
|
|
203
|
+
this.col = null;
|
|
204
|
+
this.dir = SortDirections.Asc;
|
|
205
|
+
this._headerEl = headerEl;
|
|
206
|
+
this._accessors = accessors || {};
|
|
207
|
+
this._onChange = onChange;
|
|
208
|
+
this._default = defaultSort;
|
|
209
|
+
this._updateHeader();
|
|
210
|
+
headerEl?.addEventListener("click", (e) => {
|
|
211
|
+
const tgt = e.target;
|
|
212
|
+
const th = tgt?.closest("[data-sort]");
|
|
213
|
+
if (!th) return;
|
|
214
|
+
const col = th.dataset.sort;
|
|
215
|
+
if (this.col !== col) {
|
|
216
|
+
this.col = col;
|
|
217
|
+
this.dir = SortDirections.Asc;
|
|
218
|
+
} else if (this.dir === SortDirections.Asc) this.dir = SortDirections.Desc;
|
|
219
|
+
else {
|
|
220
|
+
this.col = null;
|
|
221
|
+
this.dir = SortDirections.Asc;
|
|
222
|
+
}
|
|
223
|
+
this._updateHeader();
|
|
224
|
+
this._onChange();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// The order actually in effect: the explicitly-picked column, else the table's default.
|
|
228
|
+
_effective() {
|
|
229
|
+
return this.col ? { col: this.col, dir: this.dir } : this._default;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Sorted copy of rows by the effective sort (explicit column or table default). Rows with no
|
|
233
|
+
* value for the sort column group at the end. Always a copy: a caller told its array is never
|
|
234
|
+
* reordered must be able to rely on that for one row and no sort as much as for a sorted many.
|
|
235
|
+
*/
|
|
236
|
+
apply(rows) {
|
|
237
|
+
if (!Array.isArray(rows)) return rows;
|
|
238
|
+
const eff = this._effective();
|
|
239
|
+
if (!eff || rows.length < 2) return [...rows];
|
|
240
|
+
const col = eff.col;
|
|
241
|
+
const acc = this._accessors[col] || ((r) => readByKey(r, col));
|
|
242
|
+
const factor = eff.dir === SortDirections.Asc ? 1 : -1;
|
|
243
|
+
return [...rows].sort((a, b) => {
|
|
244
|
+
const av = acc(a);
|
|
245
|
+
const bv = acc(b);
|
|
246
|
+
const aEmpty = av === null || av === void 0 || av === "";
|
|
247
|
+
const bEmpty = bv === null || bv === void 0 || bv === "";
|
|
248
|
+
if (aEmpty && bEmpty) return 0;
|
|
249
|
+
if (aEmpty) return 1;
|
|
250
|
+
if (bEmpty) return -1;
|
|
251
|
+
return _TableSort._compare(av, bv) * factor;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
static _compare(a, b) {
|
|
255
|
+
const an = typeof a === "number" ? a : Number(a);
|
|
256
|
+
const bn = typeof b === "number" ? b : Number(b);
|
|
257
|
+
if (isFinite(an) && isFinite(bn)) return an - bn;
|
|
258
|
+
return String(a).localeCompare(String(b));
|
|
259
|
+
}
|
|
260
|
+
_updateHeader() {
|
|
261
|
+
if (!this._headerEl) return;
|
|
262
|
+
const eff = this._effective();
|
|
263
|
+
this._headerEl.querySelectorAll("[data-sort]").forEach((el) => {
|
|
264
|
+
const th = el;
|
|
265
|
+
const active = !!eff && eff.col === th.dataset.sort;
|
|
266
|
+
th.classList.toggle("sort-asc", active && eff.dir === SortDirections.Asc);
|
|
267
|
+
th.classList.toggle("sort-desc", active && eff.dir === SortDirections.Desc);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// node_modules/@stocksharp/grids/src/table-export.ts
|
|
273
|
+
var XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n';
|
|
274
|
+
var CONTENT_TYPES = XML_DECL + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>';
|
|
275
|
+
var ROOT_RELS = XML_DECL + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
|
|
276
|
+
var WORKBOOK_RELS = XML_DECL + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>';
|
|
277
|
+
function workbookXml(sheetName) {
|
|
278
|
+
return XML_DECL + `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="${escapeXml(sheetName)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
|
|
279
|
+
}
|
|
280
|
+
function escapeXml(s) {
|
|
281
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
282
|
+
}
|
|
283
|
+
function colRef(i) {
|
|
284
|
+
let ref = "";
|
|
285
|
+
for (let n = i; n >= 0; n = Math.floor(n / 26) - 1)
|
|
286
|
+
ref = String.fromCharCode(65 + n % 26) + ref;
|
|
287
|
+
return ref;
|
|
288
|
+
}
|
|
289
|
+
function cellXml(ref, value) {
|
|
290
|
+
if (value === null || value === void 0 || value === "") return "";
|
|
291
|
+
if (typeof value === "number" && isFinite(value))
|
|
292
|
+
return `<c r="${ref}"><v>${value}</v></c>`;
|
|
293
|
+
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(String(value))}</t></is></c>`;
|
|
294
|
+
}
|
|
295
|
+
function sheetXml(headers, rows) {
|
|
296
|
+
let xml = XML_DECL + '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>';
|
|
297
|
+
const all = [headers, ...rows];
|
|
298
|
+
for (let r = 0; r < all.length; r++) {
|
|
299
|
+
xml += `<row r="${r + 1}">`;
|
|
300
|
+
for (let c = 0; c < all[r].length; c++)
|
|
301
|
+
xml += cellXml(colRef(c) + (r + 1), all[r][c]);
|
|
302
|
+
xml += "</row>";
|
|
303
|
+
}
|
|
304
|
+
return xml + "</sheetData></worksheet>";
|
|
305
|
+
}
|
|
306
|
+
var CRC_TABLE = (() => {
|
|
307
|
+
const table = new Uint32Array(256);
|
|
308
|
+
for (let n = 0; n < 256; n++) {
|
|
309
|
+
let c = n;
|
|
310
|
+
for (let k = 0; k < 8; k++)
|
|
311
|
+
c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
312
|
+
table[n] = c >>> 0;
|
|
313
|
+
}
|
|
314
|
+
return table;
|
|
315
|
+
})();
|
|
316
|
+
function crc32(data) {
|
|
317
|
+
let crc = 4294967295;
|
|
318
|
+
for (let i = 0; i < data.length; i++)
|
|
319
|
+
crc = CRC_TABLE[(crc ^ data[i]) & 255] ^ crc >>> 8;
|
|
320
|
+
return (crc ^ 4294967295) >>> 0;
|
|
321
|
+
}
|
|
322
|
+
function zip(entries) {
|
|
323
|
+
const encoder = new TextEncoder();
|
|
324
|
+
const parts = [];
|
|
325
|
+
const central = [];
|
|
326
|
+
let offset = 0;
|
|
327
|
+
for (const entry of entries) {
|
|
328
|
+
const name = encoder.encode(entry.name);
|
|
329
|
+
const data = encoder.encode(entry.text);
|
|
330
|
+
const crc = crc32(data);
|
|
331
|
+
const local = new Uint8Array(30 + name.length);
|
|
332
|
+
const lv = new DataView(local.buffer);
|
|
333
|
+
lv.setUint32(0, 67324752, true);
|
|
334
|
+
lv.setUint16(4, 20, true);
|
|
335
|
+
lv.setUint32(14, crc, true);
|
|
336
|
+
lv.setUint32(18, data.length, true);
|
|
337
|
+
lv.setUint32(22, data.length, true);
|
|
338
|
+
lv.setUint16(26, name.length, true);
|
|
339
|
+
local.set(name, 30);
|
|
340
|
+
parts.push(local, data);
|
|
341
|
+
const dir = new Uint8Array(46 + name.length);
|
|
342
|
+
const dv = new DataView(dir.buffer);
|
|
343
|
+
dv.setUint32(0, 33639248, true);
|
|
344
|
+
dv.setUint16(4, 20, true);
|
|
345
|
+
dv.setUint16(6, 20, true);
|
|
346
|
+
dv.setUint32(16, crc, true);
|
|
347
|
+
dv.setUint32(20, data.length, true);
|
|
348
|
+
dv.setUint32(24, data.length, true);
|
|
349
|
+
dv.setUint16(28, name.length, true);
|
|
350
|
+
dv.setUint32(42, offset, true);
|
|
351
|
+
dir.set(name, 46);
|
|
352
|
+
central.push(dir);
|
|
353
|
+
offset += local.length + data.length;
|
|
354
|
+
}
|
|
355
|
+
const dirSize = central.reduce((sum, d) => sum + d.length, 0);
|
|
356
|
+
const eocd = new Uint8Array(22);
|
|
357
|
+
const ev = new DataView(eocd.buffer);
|
|
358
|
+
ev.setUint32(0, 101010256, true);
|
|
359
|
+
ev.setUint16(8, entries.length, true);
|
|
360
|
+
ev.setUint16(10, entries.length, true);
|
|
361
|
+
ev.setUint32(12, dirSize, true);
|
|
362
|
+
ev.setUint32(16, offset, true);
|
|
363
|
+
return new Blob([...parts, ...central, eocd], {
|
|
364
|
+
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
var TableExport = {
|
|
368
|
+
/**
|
|
369
|
+
* Build the workbook and trigger a browser download as
|
|
370
|
+
* `<baseName>-YYYYMMDD-HHMMSS.xlsx`. Rows are exported exactly as the
|
|
371
|
+
* caller passes them (i.e. in the currently rendered/sorted order).
|
|
372
|
+
*/
|
|
373
|
+
download(baseName, sheetName, headers, rows) {
|
|
374
|
+
const safeSheet = (sheetName || "").replace(/[\[\]:*?/\\]/g, " ").slice(0, 31).trim() || "Sheet1";
|
|
375
|
+
const blob = zip([
|
|
376
|
+
{ name: "[Content_Types].xml", text: CONTENT_TYPES },
|
|
377
|
+
{ name: "_rels/.rels", text: ROOT_RELS },
|
|
378
|
+
{ name: "xl/workbook.xml", text: workbookXml(safeSheet) },
|
|
379
|
+
{ name: "xl/_rels/workbook.xml.rels", text: WORKBOOK_RELS },
|
|
380
|
+
{ name: "xl/worksheets/sheet1.xml", text: sheetXml(headers, rows) }
|
|
381
|
+
]);
|
|
382
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
|
|
383
|
+
const a = document.createElement("a");
|
|
384
|
+
a.href = URL.createObjectURL(blob);
|
|
385
|
+
a.download = `${baseName}-${stamp}.xlsx`;
|
|
386
|
+
document.body.appendChild(a);
|
|
387
|
+
a.click();
|
|
388
|
+
a.remove();
|
|
389
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5e3);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// node_modules/@stocksharp/grids/src/data-grid.ts
|
|
394
|
+
var GridPinnedPlacements = {
|
|
395
|
+
Top: "top",
|
|
396
|
+
Bottom: "bottom"
|
|
397
|
+
};
|
|
398
|
+
var DataGrid = class _DataGrid {
|
|
399
|
+
constructor(options) {
|
|
400
|
+
__publicField(this, "options");
|
|
401
|
+
__publicField(this, "sort");
|
|
402
|
+
__publicField(this, "_rows");
|
|
403
|
+
__publicField(this, "_rowEls");
|
|
404
|
+
__publicField(this, "_cellEls");
|
|
405
|
+
for (const col of options.columns) {
|
|
406
|
+
if (col.exportable && !col.value && !col.exportValue)
|
|
407
|
+
throw new Error(`DataGrid: column "${col.key}" is exportable but declares no value to export`);
|
|
408
|
+
}
|
|
409
|
+
this.options = options;
|
|
410
|
+
this._rows = [];
|
|
411
|
+
this._rowEls = /* @__PURE__ */ new Map();
|
|
412
|
+
this._cellEls = /* @__PURE__ */ new Map();
|
|
413
|
+
const accessors = {};
|
|
414
|
+
for (const col of options.columns) {
|
|
415
|
+
if (col.value) accessors[col.key] = col.value;
|
|
416
|
+
}
|
|
417
|
+
this._renderHead();
|
|
418
|
+
this.sort = new TableSort(options.head, accessors, () => this.render(), options.defaultSort);
|
|
419
|
+
this._paint();
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The rows the grid holds, in the order they were handed over. The grid keeps
|
|
423
|
+
* the caller's array by reference: a caller that merges a live delta into it
|
|
424
|
+
* and calls `render()` repaints from the current contents.
|
|
425
|
+
*/
|
|
426
|
+
get rows() {
|
|
427
|
+
return this._rows;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Rows in the order currently in effect (the picked column, else the table
|
|
431
|
+
* default). A sorted copy — the held array is never reordered.
|
|
432
|
+
*/
|
|
433
|
+
sortedRows() {
|
|
434
|
+
return this.sort.apply(this._rows);
|
|
435
|
+
}
|
|
436
|
+
setRows(rows) {
|
|
437
|
+
this._rows = rows || [];
|
|
438
|
+
this.render();
|
|
439
|
+
}
|
|
440
|
+
render() {
|
|
441
|
+
this._paint();
|
|
442
|
+
if (this.options.afterRender) this.options.afterRender();
|
|
443
|
+
}
|
|
444
|
+
_paint() {
|
|
445
|
+
this._rowEls.clear();
|
|
446
|
+
this._cellEls.clear();
|
|
447
|
+
const pinned = this.options.pinnedRows ? this.options.pinnedRows() : [];
|
|
448
|
+
const sorted = this.sortedRows();
|
|
449
|
+
const painted = this.options.renderLimit != null ? sorted.slice(0, this.options.renderLimit) : sorted;
|
|
450
|
+
const children = [];
|
|
451
|
+
for (const row of pinned) {
|
|
452
|
+
if (row.place === GridPinnedPlacements.Top) children.push(this._pinnedRow(row));
|
|
453
|
+
}
|
|
454
|
+
if (painted.length === 0 && pinned.length === 0) children.push(this._emptyRow());
|
|
455
|
+
for (const row of painted) children.push(this._bodyRow(row));
|
|
456
|
+
for (const row of pinned) {
|
|
457
|
+
if (row.place === GridPinnedPlacements.Bottom) children.push(this._pinnedRow(row));
|
|
458
|
+
}
|
|
459
|
+
this.options.body.replaceChildren(...children);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The rendered row with this key, or null when it is not on screen (filtered
|
|
463
|
+
* out, or past `renderLimit`).
|
|
464
|
+
*/
|
|
465
|
+
rowElement(rowKey) {
|
|
466
|
+
return this._rowEls.get(rowKey) || null;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The rendered cell at (row, column) — how a caller patches one cell in place
|
|
470
|
+
* instead of repainting the table, and how it finds the cell to edit.
|
|
471
|
+
*/
|
|
472
|
+
cellElement(rowKey, columnKey) {
|
|
473
|
+
return this._cellEls.get(rowKey)?.get(columnKey) || null;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Header captions and cell values of the exportable columns, over every held
|
|
477
|
+
* row in the order in effect. Pure — separate from `download` so the sheet
|
|
478
|
+
* content can be asserted without a browser.
|
|
479
|
+
*/
|
|
480
|
+
exportData() {
|
|
481
|
+
const columns = this.options.columns.filter((col) => col.exportable);
|
|
482
|
+
return {
|
|
483
|
+
headers: columns.map((col) => col.header),
|
|
484
|
+
rows: this.sortedRows().map((row) => columns.map((col) => {
|
|
485
|
+
const accessor = col.exportValue || col.value;
|
|
486
|
+
return accessor ? accessor(row) : null;
|
|
487
|
+
}))
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** Export to `<baseName>-<timestamp>.xlsx` in the order on screen. */
|
|
491
|
+
download(baseName, sheetName) {
|
|
492
|
+
const data = this.exportData();
|
|
493
|
+
TableExport.download(baseName, sheetName, data.headers, data.rows);
|
|
494
|
+
}
|
|
495
|
+
_renderHead() {
|
|
496
|
+
const tr = document.createElement("tr");
|
|
497
|
+
for (const col of this.options.columns) {
|
|
498
|
+
const th = document.createElement("th");
|
|
499
|
+
th.setAttribute("scope", "col");
|
|
500
|
+
if (col.headerClass) th.className = col.headerClass;
|
|
501
|
+
if (col.value) th.dataset.sort = col.key;
|
|
502
|
+
if (col.headerHidden) {
|
|
503
|
+
const label = document.createElement("span");
|
|
504
|
+
label.className = "visually-hidden";
|
|
505
|
+
label.textContent = col.header;
|
|
506
|
+
th.appendChild(label);
|
|
507
|
+
} else {
|
|
508
|
+
th.textContent = col.header;
|
|
509
|
+
}
|
|
510
|
+
tr.appendChild(th);
|
|
511
|
+
}
|
|
512
|
+
this.options.head.replaceChildren(tr);
|
|
513
|
+
}
|
|
514
|
+
_bodyRow(row) {
|
|
515
|
+
const tr = document.createElement("tr");
|
|
516
|
+
const key = this.options.rowKey(row);
|
|
517
|
+
const rowClass = this.options.rowClass ? this.options.rowClass(row) : "";
|
|
518
|
+
if (rowClass) tr.className = rowClass;
|
|
519
|
+
tr.dataset.rowKey = key;
|
|
520
|
+
const cells = /* @__PURE__ */ new Map();
|
|
521
|
+
for (const col of this.options.columns) {
|
|
522
|
+
const td = document.createElement("td");
|
|
523
|
+
td.dataset.col = col.key;
|
|
524
|
+
const cellClass = col.cellClass ? col.cellClass(row) : "";
|
|
525
|
+
if (cellClass) td.className = cellClass;
|
|
526
|
+
const content = col.render ? col.render(row) : _DataGrid._text(col.value ? col.value(row) : null);
|
|
527
|
+
if (content !== null && typeof content === "object" && "nodeType" in content) td.appendChild(content);
|
|
528
|
+
else td.textContent = _DataGrid._text(content);
|
|
529
|
+
if (col.bindCell) col.bindCell(td, row);
|
|
530
|
+
tr.appendChild(td);
|
|
531
|
+
cells.set(col.key, td);
|
|
532
|
+
}
|
|
533
|
+
if (this.options.bindRow) this.options.bindRow(tr, row);
|
|
534
|
+
this._rowEls.set(key, tr);
|
|
535
|
+
this._cellEls.set(key, cells);
|
|
536
|
+
return tr;
|
|
537
|
+
}
|
|
538
|
+
_pinnedRow(pinned) {
|
|
539
|
+
if (pinned.cells.length > this.options.columns.length) {
|
|
540
|
+
throw new Error(`DataGrid: pinned row "${pinned.key}" declares ${pinned.cells.length} cells for ${this.options.columns.length} columns`);
|
|
541
|
+
}
|
|
542
|
+
const tr = document.createElement("tr");
|
|
543
|
+
if (pinned.className) tr.className = pinned.className;
|
|
544
|
+
tr.dataset.rowKey = pinned.key;
|
|
545
|
+
const cells = /* @__PURE__ */ new Map();
|
|
546
|
+
for (let i = 0; i < this.options.columns.length; i++) {
|
|
547
|
+
const col = this.options.columns[i];
|
|
548
|
+
const td = document.createElement("td");
|
|
549
|
+
td.dataset.col = col.key;
|
|
550
|
+
const cell = pinned.cells[i];
|
|
551
|
+
if (cell) {
|
|
552
|
+
if (cell.className) td.className = cell.className;
|
|
553
|
+
if (typeof cell.content === "string") td.textContent = cell.content;
|
|
554
|
+
else td.appendChild(cell.content);
|
|
555
|
+
}
|
|
556
|
+
tr.appendChild(td);
|
|
557
|
+
cells.set(col.key, td);
|
|
558
|
+
}
|
|
559
|
+
this._rowEls.set(pinned.key, tr);
|
|
560
|
+
this._cellEls.set(pinned.key, cells);
|
|
561
|
+
return tr;
|
|
562
|
+
}
|
|
563
|
+
_emptyRow() {
|
|
564
|
+
const tr = document.createElement("tr");
|
|
565
|
+
const td = document.createElement("td");
|
|
566
|
+
td.className = "grid-empty";
|
|
567
|
+
td.colSpan = this.options.columns.length;
|
|
568
|
+
td.textContent = this.options.emptyText;
|
|
569
|
+
tr.appendChild(td);
|
|
570
|
+
return tr;
|
|
571
|
+
}
|
|
572
|
+
static _text(value) {
|
|
573
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// src/active-orders-widget.ts
|
|
578
|
+
var OrderStates = {
|
|
579
|
+
PendingRisk: 1,
|
|
580
|
+
Sent: 2,
|
|
581
|
+
Active: 3,
|
|
582
|
+
PartiallyFilled: 4,
|
|
583
|
+
Filled: 5,
|
|
584
|
+
Rejected: 6,
|
|
585
|
+
Cancelled: 7
|
|
586
|
+
};
|
|
587
|
+
var _ActiveOrdersWidget = class _ActiveOrdersWidget {
|
|
588
|
+
static create(hostEl, state, deps) {
|
|
589
|
+
const host = assertHost(deps?.host, "ActiveOrdersWidget");
|
|
590
|
+
const root = _ActiveOrdersWidget._buildRoot(host);
|
|
591
|
+
root.id = makePanelId(_ActiveOrdersWidget.TYPE);
|
|
592
|
+
hostEl.appendChild(root);
|
|
593
|
+
return new _ActiveOrdersWidget(root, state || {}, deps);
|
|
594
|
+
}
|
|
595
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
596
|
+
// docking host lifts `.panel-header`'s children into its tab strip.
|
|
597
|
+
//
|
|
598
|
+
// The rail's first two buttons are the ones that could not come across
|
|
599
|
+
// unchanged from the terminal's Razor template: they were inline
|
|
600
|
+
// `onclick="terminalApp.cancelAll()"` / `refreshOrders()`, references to a
|
|
601
|
+
// global this control must not have. They carry `.panel-cancel-all-btn` and
|
|
602
|
+
// `.panel-refresh-btn` now — the latter the name the other blotters' rails
|
|
603
|
+
// already give the identical button — and are wired to deps. Cancel-all
|
|
604
|
+
// keeps a distinct accessible name from its tooltip, which is why it states
|
|
605
|
+
// `aria-label` rather than taking the tooltip for both.
|
|
606
|
+
static _buildRoot(host) {
|
|
607
|
+
return makePanelRoot("active-orders-panel", host.t("ActiveOrders"), [
|
|
608
|
+
makeElement("div", "panel-header", {}, [
|
|
609
|
+
makeElement("span", "", {}, [host.t("OpenOrders")]),
|
|
610
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
611
|
+
]),
|
|
612
|
+
makeElement("div", "panel-body panel-body-with-rail", {}, [
|
|
613
|
+
makeElement("div", "panel-body-content", {}, [
|
|
614
|
+
makeElement("table", "terminal-table active-orders-table", { role: "table", "aria-label": host.t("ActiveOrdersList") }, [
|
|
615
|
+
makeElement("thead", "", {}, []),
|
|
616
|
+
makeElement("tbody", "active-orders-body", {}, [])
|
|
617
|
+
])
|
|
618
|
+
]),
|
|
619
|
+
makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("ActiveOrdersActions") }, [
|
|
620
|
+
makeIconButton(
|
|
621
|
+
"bt-icon-btn bt-icon-cancel-all panel-cancel-all-btn",
|
|
622
|
+
host.t("CancelAll"),
|
|
623
|
+
"bi-x-circle",
|
|
624
|
+
{ "aria-label": host.t("CancelAllOrders") }
|
|
625
|
+
),
|
|
626
|
+
makeIconButton("bt-icon-btn panel-refresh-btn", host.t("Refresh"), "bi-arrow-clockwise", {}),
|
|
627
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
|
|
628
|
+
])
|
|
629
|
+
])
|
|
630
|
+
]);
|
|
631
|
+
}
|
|
632
|
+
constructor(rootEl, _state, deps) {
|
|
633
|
+
this._host = assertHost(deps?.host, "ActiveOrdersWidget");
|
|
634
|
+
for (const name of ["cancelOrder", "dismissOrder", "editOrderField", "replaceOrder", "cancelAllOrders", "refreshOrders"]) {
|
|
635
|
+
if (typeof deps?.[name] !== "function")
|
|
636
|
+
throw new Error(`ActiveOrdersWidget: dep "${name}" is required`);
|
|
637
|
+
}
|
|
638
|
+
this.rootEl = rootEl;
|
|
639
|
+
this._deps = deps;
|
|
640
|
+
this.el = this.rootEl.querySelector(".active-orders-body");
|
|
641
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
642
|
+
this._cancelAllBtn = this.rootEl.querySelector(".panel-cancel-all-btn");
|
|
643
|
+
this._refreshBtn = this.rootEl.querySelector(".panel-refresh-btn");
|
|
644
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
645
|
+
this._orders = [];
|
|
646
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
647
|
+
e.preventDefault();
|
|
648
|
+
this._host.close();
|
|
649
|
+
});
|
|
650
|
+
this._cancelAllBtn?.addEventListener("click", (e) => {
|
|
651
|
+
e.preventDefault();
|
|
652
|
+
this._deps.cancelAllOrders();
|
|
653
|
+
});
|
|
654
|
+
this._refreshBtn?.addEventListener("click", (e) => {
|
|
655
|
+
e.preventDefault();
|
|
656
|
+
this._deps.refreshOrders();
|
|
657
|
+
});
|
|
658
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
659
|
+
e.preventDefault();
|
|
660
|
+
this._export();
|
|
661
|
+
});
|
|
662
|
+
const head = this.rootEl.querySelector(".active-orders-table thead");
|
|
663
|
+
this._grid = head && this.el ? new DataGrid({
|
|
664
|
+
head,
|
|
665
|
+
body: this.el,
|
|
666
|
+
columns: this._columns(),
|
|
667
|
+
// Newest order on top by default (localId is monotonic) — the resting view puts the
|
|
668
|
+
// user's most recent actions where they look, instead of raw cache order. Natural
|
|
669
|
+
// array order is "newest first" too (deltas unshift), but only by accident; any
|
|
670
|
+
// header click re-renders the same array through the sorter.
|
|
671
|
+
defaultSort: { col: "id", dir: "desc" },
|
|
672
|
+
// The register tx, not the friendly localId shown in the ID column: it is what
|
|
673
|
+
// cancel / replace / inline edit address an order by.
|
|
674
|
+
rowKey: (o) => String(o.id),
|
|
675
|
+
emptyText: this._host.t("NoActiveOrders"),
|
|
676
|
+
// Terminal rows are greyed out so the live ones stay visually prominent.
|
|
677
|
+
rowClass: (o) => _ActiveOrdersWidget._rowClass(o)
|
|
678
|
+
}) : null;
|
|
679
|
+
this._host.register(this);
|
|
680
|
+
}
|
|
681
|
+
dispose() {
|
|
682
|
+
this._host.unregister(this);
|
|
683
|
+
try {
|
|
684
|
+
this.rootEl.remove();
|
|
685
|
+
} catch {
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
update(orders) {
|
|
689
|
+
this._orders = orders || [];
|
|
690
|
+
this._grid?.setRows(this._orders);
|
|
691
|
+
}
|
|
692
|
+
// Export the table to .xlsx in the currently rendered (sorted) order, with the
|
|
693
|
+
// same localized side/type/status texts the user sees on screen — the columns
|
|
694
|
+
// carry both forms, so the sheet cannot drift from the table.
|
|
695
|
+
_export() {
|
|
696
|
+
this._grid?.download("orders", this._host.t("OpenOrders"));
|
|
697
|
+
}
|
|
698
|
+
/// Apply a single order delta from the feed. Insert if new, merge if known.
|
|
699
|
+
/// Nothing drops out of the list: Filled and Cancelled rows stay so the user
|
|
700
|
+
/// can watch an order's transitions instead of having a row vanish, and a
|
|
701
|
+
/// Rejected row stays so its reason can be read on hover and dismissed
|
|
702
|
+
/// explicitly through the × button.
|
|
703
|
+
applyDelta(order) {
|
|
704
|
+
if (!order || order.id == null) return;
|
|
705
|
+
const idx = this._orders.findIndex((o) => o.id === order.id);
|
|
706
|
+
if (idx >= 0)
|
|
707
|
+
this._orders[idx] = { ...this._orders[idx], ...order };
|
|
708
|
+
else
|
|
709
|
+
this._orders.unshift(order);
|
|
710
|
+
this.update(this._orders);
|
|
711
|
+
}
|
|
712
|
+
/// Remove a row from the local view without touching the server. Used by the
|
|
713
|
+
/// × button on a terminal order (nothing left to cancel), and in any other
|
|
714
|
+
/// client-side dismiss path.
|
|
715
|
+
removeOrder(orderId) {
|
|
716
|
+
const idx = this._orders.findIndex((o) => o.id === orderId);
|
|
717
|
+
if (idx >= 0) {
|
|
718
|
+
this._orders.splice(idx, 1);
|
|
719
|
+
this.update(this._orders);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
getOrder(orderId) {
|
|
723
|
+
return this._orders.find((o) => o.id === orderId);
|
|
724
|
+
}
|
|
725
|
+
startInlineEdit(orderId, field) {
|
|
726
|
+
const order = this.getOrder(orderId);
|
|
727
|
+
if (!order) return;
|
|
728
|
+
const cell = this._grid?.cellElement(String(orderId), field);
|
|
729
|
+
if (!cell || cell.querySelector("input")) return;
|
|
730
|
+
const currentValue = order[field];
|
|
731
|
+
const input = document.createElement("input");
|
|
732
|
+
input.type = "number";
|
|
733
|
+
input.className = "inline-edit-input";
|
|
734
|
+
input.value = String(currentValue ?? "");
|
|
735
|
+
input.step = field === "quantity" ? "1" : "0.01";
|
|
736
|
+
input.min = field === "quantity" ? "1" : "0.01";
|
|
737
|
+
cell.textContent = "";
|
|
738
|
+
cell.appendChild(input);
|
|
739
|
+
input.focus();
|
|
740
|
+
input.select();
|
|
741
|
+
const commit = () => {
|
|
742
|
+
const newVal = parseFloat(input.value);
|
|
743
|
+
if (isNaN(newVal) || newVal <= 0) {
|
|
744
|
+
this.update(this._orders);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (newVal === currentValue) {
|
|
748
|
+
this.update(this._orders);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const updated = { quantity: order.quantity, limitPrice: order.limitPrice, stopPrice: order.stopPrice };
|
|
752
|
+
updated[field] = newVal;
|
|
753
|
+
this._deps.replaceOrder(orderId, updated.quantity, updated.limitPrice, updated.stopPrice);
|
|
754
|
+
};
|
|
755
|
+
input.addEventListener("blur", commit);
|
|
756
|
+
input.addEventListener("keydown", (e) => {
|
|
757
|
+
if (e.key === "Enter") {
|
|
758
|
+
e.preventDefault();
|
|
759
|
+
input.blur();
|
|
760
|
+
}
|
|
761
|
+
if (e.key === "Escape") {
|
|
762
|
+
this.update(this._orders);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
// The blotter's single column declaration: what the header says, what the
|
|
767
|
+
// grid sorts on, what the cell shows, how it is coloured, and what reaches
|
|
768
|
+
// the exported sheet.
|
|
769
|
+
_columns() {
|
|
770
|
+
const label = (key) => this._host.t(key);
|
|
771
|
+
const presentation = this._host.presentation;
|
|
772
|
+
return [
|
|
773
|
+
{
|
|
774
|
+
key: "id",
|
|
775
|
+
header: label("ID"),
|
|
776
|
+
exportable: true,
|
|
777
|
+
// Per-user friendly counter (1, 2, 3, …) issued by the host. `o.id`
|
|
778
|
+
// keeps the register tx for cancel / replace routing; `o.localId` is
|
|
779
|
+
// just the badge the user wants to see. Rows cached from before the
|
|
780
|
+
// counter shipped have no localId — fall back to id so they don't go
|
|
781
|
+
// blank until the next snapshot replay reassigns one.
|
|
782
|
+
value: (o) => o.localId ?? o.id
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
key: "instrument",
|
|
786
|
+
header: label("Sym"),
|
|
787
|
+
exportable: true,
|
|
788
|
+
value: (o) => o.instrument
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
key: "side",
|
|
792
|
+
header: label("Side"),
|
|
793
|
+
exportable: true,
|
|
794
|
+
value: (o) => o.side,
|
|
795
|
+
render: (o) => presentation.sideText(o.side),
|
|
796
|
+
cellClass: (o) => presentation.sideClass(o.side),
|
|
797
|
+
exportValue: (o) => presentation.sideText(o.side)
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
key: "type",
|
|
801
|
+
header: label("Type"),
|
|
802
|
+
exportable: true,
|
|
803
|
+
value: (o) => o.type,
|
|
804
|
+
render: (o) => presentation.typeText(o.type, o.limitPrice, o.stopPrice),
|
|
805
|
+
exportValue: (o) => presentation.typeText(o.type, o.limitPrice, o.stopPrice)
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
key: "quantity",
|
|
809
|
+
header: label("Qty"),
|
|
810
|
+
exportable: true,
|
|
811
|
+
value: (o) => o.quantity,
|
|
812
|
+
render: (o) => formatQty(o.quantity),
|
|
813
|
+
cellClass: (o) => _ActiveOrdersWidget._editableClass(o, "quantity"),
|
|
814
|
+
bindCell: (td, o) => this._bindInlineEdit(td, o, "quantity")
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
key: "limitPrice",
|
|
818
|
+
header: label("Price"),
|
|
819
|
+
exportable: true,
|
|
820
|
+
value: (o) => o.limitPrice,
|
|
821
|
+
render: (o) => o.limitPrice ? formatPrice(o.limitPrice) : label("MKT"),
|
|
822
|
+
cellClass: (o) => _ActiveOrdersWidget._editableClass(o, "limitPrice"),
|
|
823
|
+
bindCell: (td, o) => this._bindInlineEdit(td, o, "limitPrice"),
|
|
824
|
+
exportValue: (o) => o.limitPrice ?? label("MKT")
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
key: "stopPrice",
|
|
828
|
+
header: label("Stop"),
|
|
829
|
+
exportable: true,
|
|
830
|
+
value: (o) => o.stopPrice,
|
|
831
|
+
render: (o) => o.stopPrice ? formatPrice(o.stopPrice) : "--",
|
|
832
|
+
cellClass: (o) => _ActiveOrdersWidget._editableClass(o, "stopPrice"),
|
|
833
|
+
bindCell: (td, o) => this._bindInlineEdit(td, o, "stopPrice"),
|
|
834
|
+
exportValue: (o) => o.stopPrice ?? ""
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
key: "status",
|
|
838
|
+
header: label("Status"),
|
|
839
|
+
exportable: true,
|
|
840
|
+
value: (o) => o.status,
|
|
841
|
+
render: (o) => this._statusCell(o),
|
|
842
|
+
exportValue: (o) => presentation.statusText(o.status)
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
key: "actions",
|
|
846
|
+
header: label("Actions"),
|
|
847
|
+
headerHidden: true,
|
|
848
|
+
exportable: false,
|
|
849
|
+
cellClass: () => "position-actions",
|
|
850
|
+
render: (o) => this._actionButton(o)
|
|
851
|
+
}
|
|
852
|
+
];
|
|
853
|
+
}
|
|
854
|
+
static _rowClass(order) {
|
|
855
|
+
if (order.status === OrderStates.Filled) return "order-filled";
|
|
856
|
+
if (order.status === OrderStates.Rejected) return "order-rejected";
|
|
857
|
+
if (order.status === OrderStates.Cancelled) return "order-cancelled";
|
|
858
|
+
return "";
|
|
859
|
+
}
|
|
860
|
+
// An order can still be modified while the venue holds it. A market order has
|
|
861
|
+
// no limit price and an unconditional order no stop price, so those cells have
|
|
862
|
+
// nothing to edit even then.
|
|
863
|
+
static _canEdit(order, field) {
|
|
864
|
+
const modifiable = order.status === OrderStates.Active || order.status === OrderStates.Sent;
|
|
865
|
+
return modifiable && (field === "quantity" || !!order[field]);
|
|
866
|
+
}
|
|
867
|
+
static _editableClass(order, field) {
|
|
868
|
+
return _ActiveOrdersWidget._canEdit(order, field) ? "cell-editable" : "";
|
|
869
|
+
}
|
|
870
|
+
// Double-click to edit belongs to the whole cell rather than to a control
|
|
871
|
+
// inside it, which is why it is wired here instead of returned by render().
|
|
872
|
+
_bindInlineEdit(td, order, field) {
|
|
873
|
+
if (!_ActiveOrdersWidget._canEdit(order, field)) return;
|
|
874
|
+
td.addEventListener("dblclick", () => this._deps.editOrderField(order.id, field));
|
|
875
|
+
}
|
|
876
|
+
// Status text, plus a hoverable icon carrying the rejection reason. Some
|
|
877
|
+
// venues wrap the human-readable rejection inside a JSON blob;
|
|
878
|
+
// cleanRejectReason extracts `.message` and falls back to the raw string.
|
|
879
|
+
// The reason goes on the element's title property, so no quote escaping is
|
|
880
|
+
// involved the way it was when this row was built as markup.
|
|
881
|
+
_statusCell(order) {
|
|
882
|
+
const text = this._host.presentation.statusText(order.status);
|
|
883
|
+
const reason = order.status === OrderStates.Rejected ? cleanRejectReason(order.rejectReason) : "";
|
|
884
|
+
if (!reason) return text;
|
|
885
|
+
const cell = document.createDocumentFragment();
|
|
886
|
+
cell.appendChild(document.createTextNode(text + " "));
|
|
887
|
+
const icon = document.createElement("i");
|
|
888
|
+
icon.className = "bi bi-question-circle reject-reason-icon";
|
|
889
|
+
icon.title = reason;
|
|
890
|
+
cell.appendChild(icon);
|
|
891
|
+
return cell;
|
|
892
|
+
}
|
|
893
|
+
// The × button, wired to the controller through deps. Status decides what it
|
|
894
|
+
// means: Pending / Active is a real cancel over the wire, while Filled /
|
|
895
|
+
// Rejected / Cancelled have nothing left to cancel — the row is history kept
|
|
896
|
+
// around so the user can read its final state, so the button dismisses it
|
|
897
|
+
// locally.
|
|
898
|
+
_actionButton(order) {
|
|
899
|
+
const isTerminal = order.status === OrderStates.Filled || order.status === OrderStates.Rejected || order.status === OrderStates.Cancelled;
|
|
900
|
+
const title = isTerminal ? this._host.t("Dismiss") : this._host.t("Cancel order");
|
|
901
|
+
const button = document.createElement("button");
|
|
902
|
+
button.type = "button";
|
|
903
|
+
button.className = "bt-icon-btn bt-icon-cancel";
|
|
904
|
+
button.title = title;
|
|
905
|
+
button.setAttribute("aria-label", `${title} #${order.id}`);
|
|
906
|
+
const icon = document.createElement("i");
|
|
907
|
+
icon.className = "bi bi-x-circle";
|
|
908
|
+
button.appendChild(icon);
|
|
909
|
+
button.addEventListener("click", () => {
|
|
910
|
+
if (isTerminal) this._deps.dismissOrder(order.id);
|
|
911
|
+
else this._deps.cancelOrder(order.id);
|
|
912
|
+
});
|
|
913
|
+
return button;
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
_ActiveOrdersWidget.TYPE = ControlTypes.ActiveOrders;
|
|
917
|
+
var ActiveOrdersWidget = _ActiveOrdersWidget;
|
|
918
|
+
|
|
919
|
+
// src/positions-widget.ts
|
|
920
|
+
var _PositionsWidget = class _PositionsWidget {
|
|
921
|
+
static create(hostEl, state, deps) {
|
|
922
|
+
const host = assertHost(deps?.host, "PositionsWidget");
|
|
923
|
+
const root = _PositionsWidget._buildRoot(host);
|
|
924
|
+
root.id = makePanelId(_PositionsWidget.TYPE);
|
|
925
|
+
hostEl.appendChild(root);
|
|
926
|
+
return new _PositionsWidget(root, state || {}, deps);
|
|
927
|
+
}
|
|
928
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
929
|
+
// docking host lifts `.panel-header`'s children into its tab strip.
|
|
930
|
+
//
|
|
931
|
+
// The rail's refresh button is the one thing that could not come across
|
|
932
|
+
// unchanged from the terminal's Razor template: it used to be an inline
|
|
933
|
+
// `onclick="terminalApp.refreshPositions()"`, which is a reference to a
|
|
934
|
+
// global this control must not have. It carries `.panel-refresh-btn` now —
|
|
935
|
+
// the name the trade-history rail already gave the identical button — and
|
|
936
|
+
// is wired to the `refreshPositions` dep.
|
|
937
|
+
static _buildRoot(host) {
|
|
938
|
+
const title = host.t("OpenPositions");
|
|
939
|
+
return makePanelRoot("positions-panel", title, [
|
|
940
|
+
makeElement("div", "panel-header", {}, [
|
|
941
|
+
makeElement("span", "", {}, [host.t("Positions")]),
|
|
942
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
943
|
+
]),
|
|
944
|
+
makeElement("div", "panel-body panel-body-with-rail", {}, [
|
|
945
|
+
makeElement("div", "panel-body-content", {}, [
|
|
946
|
+
makeElement("table", "terminal-table positions-table", { role: "table", "aria-label": title }, [
|
|
947
|
+
makeElement("thead", "", {}, []),
|
|
948
|
+
makeElement("tbody", "positions-body", {}, [])
|
|
949
|
+
])
|
|
950
|
+
]),
|
|
951
|
+
makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("PositionsActions") }, [
|
|
952
|
+
makeIconButton("bt-icon-btn panel-refresh-btn", host.t("Refresh"), "bi-arrow-clockwise", {}),
|
|
953
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
|
|
954
|
+
])
|
|
955
|
+
])
|
|
956
|
+
]);
|
|
957
|
+
}
|
|
958
|
+
constructor(rootEl, _state, deps) {
|
|
959
|
+
this._host = assertHost(deps?.host, "PositionsWidget");
|
|
960
|
+
for (const name of ["closePosition", "reversePosition", "refreshPositions"]) {
|
|
961
|
+
if (typeof deps?.[name] !== "function")
|
|
962
|
+
throw new Error(`PositionsWidget: dep "${name}" is required`);
|
|
963
|
+
}
|
|
964
|
+
this.rootEl = rootEl;
|
|
965
|
+
this._deps = deps;
|
|
966
|
+
this.el = this.rootEl.querySelector(".positions-body");
|
|
967
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
968
|
+
this._refreshBtn = this.rootEl.querySelector(".panel-refresh-btn");
|
|
969
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
970
|
+
this._positions = [];
|
|
971
|
+
this._balance = null;
|
|
972
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
973
|
+
e.preventDefault();
|
|
974
|
+
this._host.close();
|
|
975
|
+
});
|
|
976
|
+
this._refreshBtn?.addEventListener("click", (e) => {
|
|
977
|
+
e.preventDefault();
|
|
978
|
+
this._deps.refreshPositions();
|
|
979
|
+
});
|
|
980
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
981
|
+
e.preventDefault();
|
|
982
|
+
this._export();
|
|
983
|
+
});
|
|
984
|
+
const head = this.rootEl.querySelector(".positions-table thead");
|
|
985
|
+
this._grid = head && this.el ? new DataGrid({
|
|
986
|
+
head,
|
|
987
|
+
body: this.el,
|
|
988
|
+
columns: this._columns(),
|
|
989
|
+
// Alphabetical by instrument at rest — a stable, predictable order (positions have no
|
|
990
|
+
// natural "newest") the user can scan by symbol.
|
|
991
|
+
defaultSort: { col: "instrument", dir: "asc" },
|
|
992
|
+
// Same identity applyDelta matches on: id alone isn't unique (id=0 for
|
|
993
|
+
// unsaved snapshots), and a portfolio can hold one position per instrument.
|
|
994
|
+
rowKey: (p) => _PositionsWidget._key(p),
|
|
995
|
+
emptyText: this._host.t("No positions"),
|
|
996
|
+
// Re-read on every render, so the cash figures track the live balance
|
|
997
|
+
// without the widget having to repaint the position rows itself.
|
|
998
|
+
pinnedRows: () => this._pinnedRows()
|
|
999
|
+
}) : null;
|
|
1000
|
+
this._host.register(this);
|
|
1001
|
+
}
|
|
1002
|
+
dispose() {
|
|
1003
|
+
this._host.unregister(this);
|
|
1004
|
+
try {
|
|
1005
|
+
this.rootEl.remove();
|
|
1006
|
+
} catch {
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
update(positions) {
|
|
1010
|
+
this._positions = positions || [];
|
|
1011
|
+
this._grid?.setRows(this._positions);
|
|
1012
|
+
}
|
|
1013
|
+
/// Null clears the pinned row — which is not the same as an all-zero
|
|
1014
|
+
/// balance, and reads differently: no row at all versus a row of zeros.
|
|
1015
|
+
updateBalance(balance) {
|
|
1016
|
+
this._balance = balance;
|
|
1017
|
+
this._grid?.render();
|
|
1018
|
+
}
|
|
1019
|
+
/// Apply a single position delta. Match by (portfolioId, instrumentId) — id
|
|
1020
|
+
/// alone isn't unique (id=0 for unsaved snapshots). Quantity hitting zero
|
|
1021
|
+
/// closes the row.
|
|
1022
|
+
applyDelta(position) {
|
|
1023
|
+
if (!position) return;
|
|
1024
|
+
const idx = this._positions.findIndex((p) => p.portfolioId === position.portfolioId && (position.instrumentId != null && p.instrumentId === position.instrumentId || position.instrumentId == null && p.instrument === position.instrument));
|
|
1025
|
+
const closed = (position.quantity || 0) === 0;
|
|
1026
|
+
if (idx >= 0) {
|
|
1027
|
+
if (closed) this._positions.splice(idx, 1);
|
|
1028
|
+
else this._positions[idx] = { ...this._positions[idx], ...position };
|
|
1029
|
+
} else if (!closed) {
|
|
1030
|
+
this._positions.push(position);
|
|
1031
|
+
}
|
|
1032
|
+
this.update(this._positions);
|
|
1033
|
+
}
|
|
1034
|
+
// Export the table to .xlsx in the currently rendered (sorted) order. The
|
|
1035
|
+
// balance summary is a pinned row — a different shape from a position — so
|
|
1036
|
+
// the grid keeps it out of the sheet.
|
|
1037
|
+
_export() {
|
|
1038
|
+
this._grid?.download("positions", this._host.t("Positions"));
|
|
1039
|
+
}
|
|
1040
|
+
// The blotter's single column declaration: what the header says, what the
|
|
1041
|
+
// grid sorts on, what the cell shows, how it is coloured, and what reaches
|
|
1042
|
+
// the exported sheet. Prices are formatted on screen but exported raw, so
|
|
1043
|
+
// the sheet stays numeric.
|
|
1044
|
+
_columns() {
|
|
1045
|
+
const label = (key) => this._host.t(key);
|
|
1046
|
+
return [
|
|
1047
|
+
{
|
|
1048
|
+
key: "instrument",
|
|
1049
|
+
header: label("Symbol"),
|
|
1050
|
+
exportable: true,
|
|
1051
|
+
value: (p) => p.instrument
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
key: "quantity",
|
|
1055
|
+
header: label("Qty"),
|
|
1056
|
+
exportable: true,
|
|
1057
|
+
value: (p) => p.quantity,
|
|
1058
|
+
cellClass: (p) => (p.quantity ?? 0) > 0 ? "side-buy" : "side-sell"
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
key: "avgPrice",
|
|
1062
|
+
header: label("AvgPrice"),
|
|
1063
|
+
exportable: true,
|
|
1064
|
+
value: (p) => p.avgPrice,
|
|
1065
|
+
render: (p) => formatPrice(p.avgPrice)
|
|
1066
|
+
},
|
|
1067
|
+
{
|
|
1068
|
+
key: "currentPrice",
|
|
1069
|
+
header: label("Current"),
|
|
1070
|
+
exportable: true,
|
|
1071
|
+
value: (p) => p.currentPrice,
|
|
1072
|
+
render: (p) => formatPrice(p.currentPrice)
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
key: "pnl",
|
|
1076
|
+
header: label("PnL"),
|
|
1077
|
+
exportable: true,
|
|
1078
|
+
value: (p) => _PositionsWidget._totalPnl(p),
|
|
1079
|
+
render: (p) => formatPnl(_PositionsWidget._totalPnl(p)),
|
|
1080
|
+
cellClass: (p) => this._host.presentation.pnlClass(_PositionsWidget._totalPnl(p))
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
key: "actions",
|
|
1084
|
+
header: label("Actions"),
|
|
1085
|
+
headerHidden: true,
|
|
1086
|
+
exportable: false,
|
|
1087
|
+
cellClass: () => "position-actions",
|
|
1088
|
+
render: (p) => this._actionButtons(p)
|
|
1089
|
+
}
|
|
1090
|
+
];
|
|
1091
|
+
}
|
|
1092
|
+
// The cash balance, pinned above the positions. It does not share the shape
|
|
1093
|
+
// the position columns read, so it supplies its own cells; it is not a row
|
|
1094
|
+
// of data, so it is neither sorted nor exported. Being content, it also
|
|
1095
|
+
// suppresses the "no positions" row — a table showing a balance is not empty.
|
|
1096
|
+
_pinnedRows() {
|
|
1097
|
+
const balance = this._balance;
|
|
1098
|
+
if (!balance) return [];
|
|
1099
|
+
const fmt = (v) => (v || 0).toLocaleString(void 0, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
1100
|
+
return [{
|
|
1101
|
+
key: "balance",
|
|
1102
|
+
className: "balance-row",
|
|
1103
|
+
place: GridPinnedPlacements.Top,
|
|
1104
|
+
cells: [
|
|
1105
|
+
{ content: this._host.t("USD"), className: "balance-ccy" },
|
|
1106
|
+
{ content: "$" + fmt(balance.available), className: "balance-available" },
|
|
1107
|
+
{ content: this._host.t("Locked: ${0}", fmt(balance.locked)), className: "balance-locked" },
|
|
1108
|
+
{ content: "", className: "" },
|
|
1109
|
+
{ content: "$" + fmt(balance.total), className: "balance-total" }
|
|
1110
|
+
]
|
|
1111
|
+
}];
|
|
1112
|
+
}
|
|
1113
|
+
// Realized and unrealized together — what the PnL column shows, sorts and
|
|
1114
|
+
// exports, so the three cannot drift apart.
|
|
1115
|
+
static _totalPnl(position) {
|
|
1116
|
+
return (position.unrealizedPnl || 0) + (position.realizedPnl || 0);
|
|
1117
|
+
}
|
|
1118
|
+
static _key(position) {
|
|
1119
|
+
return `${position.portfolioId}:${position.instrumentId ?? position.instrument}`;
|
|
1120
|
+
}
|
|
1121
|
+
// Close and reverse, wired to the controller through deps. Real buttons with
|
|
1122
|
+
// their own listeners — the symbol used to be interpolated into an inline
|
|
1123
|
+
// onclick attribute, which is why it needed quote-escaping first.
|
|
1124
|
+
_actionButtons(position) {
|
|
1125
|
+
const cell = document.createDocumentFragment();
|
|
1126
|
+
cell.appendChild(this._actionButton(
|
|
1127
|
+
"bt-icon-cancel",
|
|
1128
|
+
"bi-x-circle",
|
|
1129
|
+
this._host.t("Close position"),
|
|
1130
|
+
this._host.t("Close position on {0}", position.instrument),
|
|
1131
|
+
() => this._deps.closePosition(position.portfolioId, position.instrumentId, position.instrument)
|
|
1132
|
+
));
|
|
1133
|
+
cell.appendChild(this._actionButton(
|
|
1134
|
+
"bt-icon-reverse",
|
|
1135
|
+
"bi-arrow-left-right",
|
|
1136
|
+
this._host.t("Reverse position"),
|
|
1137
|
+
this._host.t("Reverse position on {0}", position.instrument),
|
|
1138
|
+
() => this._deps.reversePosition(position.portfolioId, position.instrumentId, position.instrument)
|
|
1139
|
+
));
|
|
1140
|
+
return cell;
|
|
1141
|
+
}
|
|
1142
|
+
_actionButton(styleClass, iconClass, title, label, onClick) {
|
|
1143
|
+
const button = document.createElement("button");
|
|
1144
|
+
button.type = "button";
|
|
1145
|
+
button.className = `bt-icon-btn ${styleClass}`;
|
|
1146
|
+
button.title = title;
|
|
1147
|
+
button.setAttribute("aria-label", label);
|
|
1148
|
+
const icon = document.createElement("i");
|
|
1149
|
+
icon.className = `bi ${iconClass}`;
|
|
1150
|
+
button.appendChild(icon);
|
|
1151
|
+
button.addEventListener("click", onClick);
|
|
1152
|
+
return button;
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
_PositionsWidget.TYPE = ControlTypes.Positions;
|
|
1156
|
+
var PositionsWidget = _PositionsWidget;
|
|
1157
|
+
|
|
1158
|
+
// src/trade-history-widget.ts
|
|
1159
|
+
var _TradeHistoryWidget = class _TradeHistoryWidget {
|
|
1160
|
+
static create(hostEl, state, deps) {
|
|
1161
|
+
const host = assertHost(deps?.host, "TradeHistoryWidget");
|
|
1162
|
+
const root = _TradeHistoryWidget._buildRoot(host);
|
|
1163
|
+
root.id = makePanelId(_TradeHistoryWidget.TYPE);
|
|
1164
|
+
hostEl.appendChild(root);
|
|
1165
|
+
return new _TradeHistoryWidget(root, state || {}, deps);
|
|
1166
|
+
}
|
|
1167
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
1168
|
+
// docking host lifts `.panel-header`'s children into its tab strip.
|
|
1169
|
+
static _buildRoot(host) {
|
|
1170
|
+
const title = host.t("TradeHistory");
|
|
1171
|
+
return makePanelRoot("trade-history-panel", title, [
|
|
1172
|
+
makeElement("div", "panel-header", {}, [
|
|
1173
|
+
makeElement("span", "", {}, [title]),
|
|
1174
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
1175
|
+
]),
|
|
1176
|
+
makeElement("div", "panel-body panel-body-with-rail", {}, [
|
|
1177
|
+
makeElement("div", "panel-body-content", {}, [
|
|
1178
|
+
makeElement("table", "terminal-table trade-history-table", { role: "table", "aria-label": host.t("TradeHistoryList") }, [
|
|
1179
|
+
makeElement("thead", "", {}, []),
|
|
1180
|
+
makeElement("tbody", "trade-history-body", {}, [])
|
|
1181
|
+
])
|
|
1182
|
+
]),
|
|
1183
|
+
makeElement("div", "panel-rail", { role: "toolbar", "aria-label": host.t("TradeHistoryActions") }, [
|
|
1184
|
+
makeIconButton("bt-icon-btn panel-refresh-btn", host.t("Refresh"), "bi-arrow-clockwise", {}),
|
|
1185
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", {})
|
|
1186
|
+
])
|
|
1187
|
+
])
|
|
1188
|
+
]);
|
|
1189
|
+
}
|
|
1190
|
+
constructor(rootEl, _state, deps) {
|
|
1191
|
+
this._host = assertHost(deps?.host, "TradeHistoryWidget");
|
|
1192
|
+
this.rootEl = rootEl;
|
|
1193
|
+
this.bodyEl = this.rootEl.querySelector(".trade-history-body");
|
|
1194
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
1195
|
+
this._refreshBtn = this.rootEl.querySelector(".panel-refresh-btn");
|
|
1196
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
1197
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
1198
|
+
e.preventDefault();
|
|
1199
|
+
this.dispose();
|
|
1200
|
+
this._host.close();
|
|
1201
|
+
});
|
|
1202
|
+
this._refreshBtn?.addEventListener("click", (e) => {
|
|
1203
|
+
e.preventDefault();
|
|
1204
|
+
void this.refresh();
|
|
1205
|
+
});
|
|
1206
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
1207
|
+
e.preventDefault();
|
|
1208
|
+
this._export();
|
|
1209
|
+
});
|
|
1210
|
+
this._rows = [];
|
|
1211
|
+
const head = this.rootEl.querySelector(".trade-history-table thead");
|
|
1212
|
+
this._grid = head && this.bodyEl ? new DataGrid({
|
|
1213
|
+
head,
|
|
1214
|
+
body: this.bodyEl,
|
|
1215
|
+
columns: this._columns(),
|
|
1216
|
+
// Most recent fill on top by default — the natural way to read a trade blotter.
|
|
1217
|
+
defaultSort: { col: "time", dir: "desc" },
|
|
1218
|
+
rowKey: (t) => String(t.id),
|
|
1219
|
+
emptyText: this._host.t("No trade history")
|
|
1220
|
+
}) : null;
|
|
1221
|
+
this._host.register(this);
|
|
1222
|
+
}
|
|
1223
|
+
dispose() {
|
|
1224
|
+
this._host.unregister(this);
|
|
1225
|
+
try {
|
|
1226
|
+
this.rootEl.remove();
|
|
1227
|
+
} catch {
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/// Reload against whichever portfolio the host says is active right now.
|
|
1231
|
+
///
|
|
1232
|
+
/// Two guards, and they are not the same one. No portfolio means there is
|
|
1233
|
+
/// nothing to show — a guest session has none. A portfolio the host will
|
|
1234
|
+
/// not `allow` reading means the session cannot make the call at all: this
|
|
1235
|
+
/// is somebody's executed trades, so a page that has a portfolio id lying
|
|
1236
|
+
/// around but no live credential must not fire the request and collect a
|
|
1237
|
+
/// 401 for it. The panel asks the host rather than inspecting a token,
|
|
1238
|
+
/// because what counts as a usable session is the host's to know.
|
|
1239
|
+
///
|
|
1240
|
+
/// Order matters: the portfolio check runs first, so a session with no
|
|
1241
|
+
/// portfolio is simply quiet — `allow` is the one that may put a sign-in
|
|
1242
|
+
/// prompt on screen, and reaching it means the page did have an account to
|
|
1243
|
+
/// load.
|
|
1244
|
+
async refresh() {
|
|
1245
|
+
const pf = this._host.trading.portfolioId();
|
|
1246
|
+
if (!pf || !this._grid) return;
|
|
1247
|
+
if (!this._host.allow("load trade history")) return;
|
|
1248
|
+
try {
|
|
1249
|
+
this._rows = await this._host.trading.api.getExecutions(pf, null, 200) || [];
|
|
1250
|
+
this._grid.setRows(this._rows);
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
this._host.log(`TradeHistoryWidget: failed to load trade history: ${err}`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// Export the table to .xlsx in the currently rendered (sorted) order.
|
|
1256
|
+
_export() {
|
|
1257
|
+
this._grid?.download("trades", this._host.t("TradeHistory"));
|
|
1258
|
+
}
|
|
1259
|
+
// The blotter's single column declaration. The two id columns read `#42` on
|
|
1260
|
+
// screen and export the bare number, so the sheet stays sortable as a number.
|
|
1261
|
+
_columns() {
|
|
1262
|
+
const label = (key) => this._host.t(key);
|
|
1263
|
+
const presentation = this._host.presentation;
|
|
1264
|
+
return [
|
|
1265
|
+
{
|
|
1266
|
+
key: "id",
|
|
1267
|
+
header: label("ID"),
|
|
1268
|
+
exportable: true,
|
|
1269
|
+
value: (t) => t.id,
|
|
1270
|
+
render: (t) => "#" + t.id,
|
|
1271
|
+
cellClass: () => "mono-id"
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
key: "time",
|
|
1275
|
+
header: label("Time"),
|
|
1276
|
+
exportable: true,
|
|
1277
|
+
value: (t) => t.executedAt || t.time,
|
|
1278
|
+
render: (t) => formatTime(t.executedAt || t.time),
|
|
1279
|
+
exportValue: (t) => formatTime(t.executedAt || t.time)
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
key: "symbol",
|
|
1283
|
+
header: label("Sym"),
|
|
1284
|
+
exportable: true,
|
|
1285
|
+
value: (t) => t.instrumentSymbol || t.symbol
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
key: "side",
|
|
1289
|
+
header: label("Side"),
|
|
1290
|
+
exportable: true,
|
|
1291
|
+
value: (t) => t.side,
|
|
1292
|
+
render: (t) => presentation.sideText(t.side),
|
|
1293
|
+
cellClass: (t) => presentation.sideClass(t.side),
|
|
1294
|
+
exportValue: (t) => presentation.sideText(t.side)
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
key: "quantity",
|
|
1298
|
+
header: label("Qty"),
|
|
1299
|
+
exportable: true,
|
|
1300
|
+
value: (t) => t.quantity,
|
|
1301
|
+
render: (t) => formatQty(t.quantity)
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
key: "price",
|
|
1305
|
+
header: label("Price"),
|
|
1306
|
+
exportable: true,
|
|
1307
|
+
value: (t) => t.price,
|
|
1308
|
+
render: (t) => formatPrice(t.price)
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
key: "order",
|
|
1312
|
+
header: label("Order"),
|
|
1313
|
+
exportable: true,
|
|
1314
|
+
value: (t) => t.order || t.orderId,
|
|
1315
|
+
render: (t) => "#" + (t.order || t.orderId || ""),
|
|
1316
|
+
cellClass: () => "mono-id"
|
|
1317
|
+
}
|
|
1318
|
+
];
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
_TradeHistoryWidget.TYPE = ControlTypes.TradeHistory;
|
|
1322
|
+
var TradeHistoryWidget = _TradeHistoryWidget;
|
|
1323
|
+
|
|
1324
|
+
// src/watchlist-widget.ts
|
|
1325
|
+
var _WatchlistWidget = class _WatchlistWidget {
|
|
1326
|
+
static create(hostEl, state, deps) {
|
|
1327
|
+
const host = assertHost(deps?.host, "WatchlistWidget");
|
|
1328
|
+
const root = _WatchlistWidget._buildRoot(host);
|
|
1329
|
+
root.id = makePanelId(_WatchlistWidget.TYPE);
|
|
1330
|
+
hostEl.appendChild(root);
|
|
1331
|
+
return new _WatchlistWidget(root, state || {}, deps);
|
|
1332
|
+
}
|
|
1333
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
1334
|
+
// docking host lifts `.panel-header`'s children — and, for this panel,
|
|
1335
|
+
// `.watchlist-search-row` as well — into its tab strip, which is why the
|
|
1336
|
+
// search row is a sibling of the header rather than a child of it.
|
|
1337
|
+
//
|
|
1338
|
+
// "All" and "Favorites" are the built-in tabs. The rest are appended at
|
|
1339
|
+
// runtime from the distinct categories of the loaded instruments, so the
|
|
1340
|
+
// tab set is configured through whatever assigns those categories rather
|
|
1341
|
+
// than being spelled out here — see _renderCategoryTabs.
|
|
1342
|
+
static _buildRoot(host) {
|
|
1343
|
+
const search = host.t("SearchInstruments");
|
|
1344
|
+
const favorites = host.t("Favorites");
|
|
1345
|
+
return makePanelRoot("watchlist-panel", host.t("Watchlist"), [
|
|
1346
|
+
makeElement("div", "panel-header", {}, [
|
|
1347
|
+
makeElement("span", "", {}, [host.t("Markets")]),
|
|
1348
|
+
makeIconButton("bt-icon-btn panel-export-btn", host.t("ExportToExcel"), "bi-file-earmark-spreadsheet", { type: "button" }),
|
|
1349
|
+
makeIconButton("bt-icon-btn bt-icon-cancel panel-close-btn", host.t("ClosePanel"), "bi-x", { type: "button" })
|
|
1350
|
+
]),
|
|
1351
|
+
makeElement("div", "watchlist-search-row", {}, [
|
|
1352
|
+
makeElement(
|
|
1353
|
+
"input",
|
|
1354
|
+
"form-control form-control-sm watchlist-search-input watchlist-search",
|
|
1355
|
+
{ type: "text", placeholder: search, autocomplete: "off", "aria-label": search },
|
|
1356
|
+
[]
|
|
1357
|
+
)
|
|
1358
|
+
]),
|
|
1359
|
+
makeElement("div", "watchlist-tabs", { role: "tablist", "aria-label": host.t("WatchlistFilter") }, [
|
|
1360
|
+
makeElement("button", "wl-tab active", { "data-filter": "all", role: "tab", "aria-selected": "true" }, [host.t("All")]),
|
|
1361
|
+
makeElement("button", "wl-tab", { "data-filter": "favorites", role: "tab", "aria-selected": "false", "aria-label": favorites, title: favorites }, ["\u2605"])
|
|
1362
|
+
]),
|
|
1363
|
+
makeElement("div", "watchlist-pane", {}, [
|
|
1364
|
+
makeElement("table", "watchlist-table", {}, [
|
|
1365
|
+
makeElement("thead", "watchlist-headers", {}, []),
|
|
1366
|
+
makeElement("tbody", "watchlist-body", {}, [])
|
|
1367
|
+
])
|
|
1368
|
+
])
|
|
1369
|
+
]);
|
|
1370
|
+
}
|
|
1371
|
+
constructor(rootEl, _state, deps) {
|
|
1372
|
+
this._host = assertHost(deps?.host, "WatchlistWidget");
|
|
1373
|
+
if (typeof deps?.onSelect !== "function")
|
|
1374
|
+
throw new Error('WatchlistWidget: dep "onSelect" is required');
|
|
1375
|
+
this.rootEl = rootEl;
|
|
1376
|
+
this._deps = deps;
|
|
1377
|
+
this.api = this._host.trading.api;
|
|
1378
|
+
this.instruments = [];
|
|
1379
|
+
this.stats = /* @__PURE__ */ new Map();
|
|
1380
|
+
this.favorites = new Set(this._loadFavorites());
|
|
1381
|
+
this.filter = "all";
|
|
1382
|
+
this.search = "";
|
|
1383
|
+
this.currentSymbol = null;
|
|
1384
|
+
this._subscribedSyms = /* @__PURE__ */ new Set();
|
|
1385
|
+
this.wsClient = this._host.trading.marketData;
|
|
1386
|
+
this.bodyEl = this.rootEl.querySelector(".watchlist-body");
|
|
1387
|
+
this.searchEl = this.rootEl.querySelector(".watchlist-search");
|
|
1388
|
+
this.tabsEl = this.rootEl.querySelector(".watchlist-tabs");
|
|
1389
|
+
this.sortHeaderEl = this.rootEl.querySelector(".watchlist-headers");
|
|
1390
|
+
this._closeBtn = this.rootEl.querySelector(".panel-close-btn");
|
|
1391
|
+
this._exportBtn = this.rootEl.querySelector(".panel-export-btn");
|
|
1392
|
+
this._closeBtn?.addEventListener("click", (e) => {
|
|
1393
|
+
e.preventDefault();
|
|
1394
|
+
this.dispose();
|
|
1395
|
+
this._host.close();
|
|
1396
|
+
});
|
|
1397
|
+
this._exportBtn?.addEventListener("click", (e) => {
|
|
1398
|
+
e.preventDefault();
|
|
1399
|
+
this._export();
|
|
1400
|
+
});
|
|
1401
|
+
this._grid = this.sortHeaderEl && this.bodyEl ? new DataGrid({
|
|
1402
|
+
head: this.sortHeaderEl,
|
|
1403
|
+
body: this.bodyEl,
|
|
1404
|
+
columns: this._columns(),
|
|
1405
|
+
// Symbol A→Z at rest — an instrument list has no natural "newest",
|
|
1406
|
+
// and alphabetical is what the user scans by.
|
|
1407
|
+
defaultSort: { col: "symbol", dir: "asc" },
|
|
1408
|
+
rowKey: (i) => String(i.symbol),
|
|
1409
|
+
emptyText: this._host.t("No instruments"),
|
|
1410
|
+
rowClass: (i) => i.symbol === this.currentSymbol ? "wl-row wl-current" : "wl-row",
|
|
1411
|
+
bindRow: (tr, i) => tr.addEventListener("click", () => this._deps.onSelect(i.symbol)),
|
|
1412
|
+
// Paint a screenful, export and subscribe over the whole filtered set.
|
|
1413
|
+
renderLimit: _WatchlistWidget.RENDER_CAP,
|
|
1414
|
+
// A header click re-renders through the grid, so this is the only
|
|
1415
|
+
// place that sees every change to the visible set — including a sort
|
|
1416
|
+
// the widget was never told about.
|
|
1417
|
+
afterRender: () => {
|
|
1418
|
+
this._syncSubs();
|
|
1419
|
+
this._publishVisible();
|
|
1420
|
+
}
|
|
1421
|
+
}) : null;
|
|
1422
|
+
this._bind();
|
|
1423
|
+
this._host.register(this);
|
|
1424
|
+
void this.init();
|
|
1425
|
+
}
|
|
1426
|
+
dispose() {
|
|
1427
|
+
for (const sym of this._subscribedSyms) {
|
|
1428
|
+
try {
|
|
1429
|
+
void this.wsClient?.removeSymbol(sym);
|
|
1430
|
+
} catch {
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
this._subscribedSyms.clear();
|
|
1434
|
+
this._host.unregister(this);
|
|
1435
|
+
try {
|
|
1436
|
+
this.rootEl.remove();
|
|
1437
|
+
} catch {
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
async init() {
|
|
1441
|
+
try {
|
|
1442
|
+
this.instruments = await this.api.searchInstruments("");
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
this._host.log(`WatchlistWidget: failed to load instruments: ${err}`);
|
|
1445
|
+
this.instruments = [];
|
|
1446
|
+
}
|
|
1447
|
+
this._renderCategoryTabs();
|
|
1448
|
+
this._render();
|
|
1449
|
+
}
|
|
1450
|
+
// Build the filter tabs from the assigned instrument categories. "All" and
|
|
1451
|
+
// "Favorites" are built into the markup; here we append one tab per distinct
|
|
1452
|
+
// non-empty category found in the loaded instruments. Re-runnable: clears
|
|
1453
|
+
// any category tabs a previous load added.
|
|
1454
|
+
_renderCategoryTabs() {
|
|
1455
|
+
if (!this.tabsEl) return;
|
|
1456
|
+
this.tabsEl.querySelectorAll(".wl-tab[data-category]").forEach((el) => el.remove());
|
|
1457
|
+
const cats = Array.from(new Set(
|
|
1458
|
+
this.instruments.map((i) => (i.category || "").trim()).filter((c) => c.length > 0)
|
|
1459
|
+
)).sort((a, b) => a.localeCompare(b));
|
|
1460
|
+
for (const cat of cats) {
|
|
1461
|
+
const btn = document.createElement("button");
|
|
1462
|
+
btn.className = "wl-tab";
|
|
1463
|
+
btn.setAttribute("role", "tab");
|
|
1464
|
+
btn.setAttribute("aria-selected", "false");
|
|
1465
|
+
btn.dataset.filter = cat;
|
|
1466
|
+
btn.dataset.category = "1";
|
|
1467
|
+
btn.textContent = cat;
|
|
1468
|
+
this.tabsEl.appendChild(btn);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
/// Highlight the instrument the rest of the page is showing. Patches the two
|
|
1472
|
+
/// affected rows rather than repainting: a repaint here would restart any
|
|
1473
|
+
/// flash animation currently running in a price cell.
|
|
1474
|
+
setCurrentSymbol(symbol) {
|
|
1475
|
+
const previous = this.currentSymbol;
|
|
1476
|
+
this.currentSymbol = symbol;
|
|
1477
|
+
if (!this._grid) return;
|
|
1478
|
+
if (previous) this._grid.rowElement(String(previous))?.classList.remove("wl-current");
|
|
1479
|
+
if (symbol) this._grid.rowElement(String(symbol))?.classList.add("wl-current");
|
|
1480
|
+
}
|
|
1481
|
+
onPriceUpdate(symbol, price) {
|
|
1482
|
+
if (price == null || !isFinite(price)) return;
|
|
1483
|
+
let full = symbol;
|
|
1484
|
+
if (!full.includes("@")) {
|
|
1485
|
+
const match = this.instruments.find((i) => String(i.symbol).split("@")[0] === symbol);
|
|
1486
|
+
if (match?.symbol) full = match.symbol;
|
|
1487
|
+
}
|
|
1488
|
+
const cur = this.stats.get(full);
|
|
1489
|
+
const prev = cur?.lastPrice;
|
|
1490
|
+
const baselineKey = _WatchlistWidget._baselineKey(full);
|
|
1491
|
+
let baseline = cur?.baseline;
|
|
1492
|
+
if (baseline == null) {
|
|
1493
|
+
const stored = this._host.cache.get(baselineKey, null);
|
|
1494
|
+
baseline = stored ? Number(stored) : void 0;
|
|
1495
|
+
}
|
|
1496
|
+
if (baseline == null || !isFinite(baseline) || baseline === 0) {
|
|
1497
|
+
baseline = price;
|
|
1498
|
+
this._host.cache.set(baselineKey, String(price));
|
|
1499
|
+
}
|
|
1500
|
+
const chgPct = baseline ? (price - baseline) / baseline * 100 : 0;
|
|
1501
|
+
this.stats.set(full, { ...cur || {}, lastPrice: price, baseline, chgPct });
|
|
1502
|
+
this._updateRow(full, prev);
|
|
1503
|
+
}
|
|
1504
|
+
static _baselineKey(symbol) {
|
|
1505
|
+
const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1506
|
+
return _WatchlistWidget.BASELINE_KEY_PREFIX + day + ":" + symbol;
|
|
1507
|
+
}
|
|
1508
|
+
_bind() {
|
|
1509
|
+
if (this.searchEl) {
|
|
1510
|
+
this.searchEl.addEventListener("input", () => {
|
|
1511
|
+
this.search = (this.searchEl.value || "").trim().toLowerCase();
|
|
1512
|
+
this._render();
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
if (this.tabsEl) {
|
|
1516
|
+
this.tabsEl.addEventListener("click", (e) => {
|
|
1517
|
+
const tgt = e.target;
|
|
1518
|
+
const btn = tgt?.closest(".wl-tab");
|
|
1519
|
+
if (!btn) return;
|
|
1520
|
+
this.filter = btn.dataset.filter || "all";
|
|
1521
|
+
this.tabsEl.querySelectorAll(".wl-tab").forEach((b) => b.classList.toggle("active", b === btn));
|
|
1522
|
+
this._render();
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
// The instruments matching the search box and the active tab, unsorted —
|
|
1527
|
+
// the grid owns the order.
|
|
1528
|
+
_filtered() {
|
|
1529
|
+
const q = this.search;
|
|
1530
|
+
const rows = [];
|
|
1531
|
+
for (const inst of this.instruments) {
|
|
1532
|
+
const sym = (inst.symbol || "").toUpperCase();
|
|
1533
|
+
const symL = sym.toLowerCase();
|
|
1534
|
+
const shortSym = symL.split("@")[0];
|
|
1535
|
+
const exch = (inst.exchange || "").toLowerCase();
|
|
1536
|
+
const qualified = `${shortSym}/${exch}`;
|
|
1537
|
+
const qualifiedAt = `${shortSym}@${exch}`;
|
|
1538
|
+
if (q && !symL.includes(q) && !(inst.name || "").toLowerCase().includes(q) && !qualified.includes(q) && !qualifiedAt.includes(q)) continue;
|
|
1539
|
+
if (this.filter === "favorites") {
|
|
1540
|
+
if (!this.favorites.has(sym)) continue;
|
|
1541
|
+
} else if (this.filter !== "all") {
|
|
1542
|
+
if ((inst.category || "").toLowerCase() !== this.filter.toLowerCase()) continue;
|
|
1543
|
+
}
|
|
1544
|
+
rows.push(inst);
|
|
1545
|
+
}
|
|
1546
|
+
return rows;
|
|
1547
|
+
}
|
|
1548
|
+
_render() {
|
|
1549
|
+
this._grid?.setRows(this._filtered());
|
|
1550
|
+
}
|
|
1551
|
+
// The blotter's single column declaration. Prices and percentages come off
|
|
1552
|
+
// the live stats map rather than the instrument row, so a quote arriving
|
|
1553
|
+
// between two renders is picked up by the next one.
|
|
1554
|
+
_columns() {
|
|
1555
|
+
const label = (key) => this._host.t(key);
|
|
1556
|
+
return [
|
|
1557
|
+
{
|
|
1558
|
+
key: "symbol",
|
|
1559
|
+
header: label("Symbol"),
|
|
1560
|
+
exportable: true,
|
|
1561
|
+
value: (i) => i.symbol,
|
|
1562
|
+
cellClass: () => "wl-sym-cell",
|
|
1563
|
+
render: (i) => this._symbolCell(i),
|
|
1564
|
+
exportValue: (i) => String(i.symbol).split("@")[0]
|
|
1565
|
+
},
|
|
1566
|
+
{
|
|
1567
|
+
key: "last",
|
|
1568
|
+
header: label("Last"),
|
|
1569
|
+
headerClass: "ta-right",
|
|
1570
|
+
exportable: true,
|
|
1571
|
+
value: (i) => this._statsOf(i).lastPrice,
|
|
1572
|
+
cellClass: () => "wl-last",
|
|
1573
|
+
render: (i) => _WatchlistWidget._priceText(this._statsOf(i).lastPrice),
|
|
1574
|
+
exportValue: (i) => this._statsOf(i).lastPrice ?? ""
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
key: "chgPct",
|
|
1578
|
+
header: label("Change24hPct"),
|
|
1579
|
+
headerClass: "ta-right",
|
|
1580
|
+
exportable: true,
|
|
1581
|
+
value: (i) => this._statsOf(i).chgPct,
|
|
1582
|
+
cellClass: (i) => ("wl-chg " + _WatchlistWidget._changeClass(this._statsOf(i).chgPct)).trim(),
|
|
1583
|
+
render: (i) => _WatchlistWidget._changeText(this._statsOf(i).chgPct),
|
|
1584
|
+
exportValue: (i) => this._statsOf(i).chgPct ?? ""
|
|
1585
|
+
}
|
|
1586
|
+
];
|
|
1587
|
+
}
|
|
1588
|
+
_statsOf(instrument) {
|
|
1589
|
+
return this.stats.get(String(instrument.symbol)) ?? {};
|
|
1590
|
+
}
|
|
1591
|
+
// The favourite star plus the short symbol. The star stops the click from
|
|
1592
|
+
// reaching the row, whose own listener switches the host's instrument.
|
|
1593
|
+
_symbolCell(instrument) {
|
|
1594
|
+
const cell = document.createDocumentFragment();
|
|
1595
|
+
const star = document.createElement("button");
|
|
1596
|
+
star.type = "button";
|
|
1597
|
+
star.className = this.favorites.has(instrument.symbol) ? "wl-fav active" : "wl-fav";
|
|
1598
|
+
star.title = this._host.t("Favorite");
|
|
1599
|
+
star.textContent = "\u2605";
|
|
1600
|
+
star.addEventListener("click", (e) => {
|
|
1601
|
+
e.stopPropagation();
|
|
1602
|
+
this._toggleFavorite(instrument.symbol);
|
|
1603
|
+
});
|
|
1604
|
+
cell.appendChild(star);
|
|
1605
|
+
const label = document.createElement("span");
|
|
1606
|
+
label.className = "wl-sym";
|
|
1607
|
+
label.textContent = String(instrument.symbol).split("@")[0];
|
|
1608
|
+
cell.appendChild(label);
|
|
1609
|
+
return cell;
|
|
1610
|
+
}
|
|
1611
|
+
// Export the current filtered+sorted view to .xlsx — every matching
|
|
1612
|
+
// instrument, without the RENDER_CAP truncation applied to the DOM.
|
|
1613
|
+
_export() {
|
|
1614
|
+
this._grid?.download("watchlist", this._host.t("Markets"));
|
|
1615
|
+
}
|
|
1616
|
+
// Patch the price and change cells of one row in place. A full render would
|
|
1617
|
+
// replace the cell element and so cancel the flash animation started here.
|
|
1618
|
+
_updateRow(symbol, prevPrice) {
|
|
1619
|
+
if (!this._grid) return;
|
|
1620
|
+
const st = this.stats.get(symbol) || {};
|
|
1621
|
+
const lastEl = this._grid.cellElement(String(symbol), "last");
|
|
1622
|
+
const chgEl = this._grid.cellElement(String(symbol), "chgPct");
|
|
1623
|
+
if (lastEl && st.lastPrice != null) {
|
|
1624
|
+
lastEl.textContent = _WatchlistWidget._priceText(st.lastPrice);
|
|
1625
|
+
if (prevPrice != null && st.lastPrice !== prevPrice) {
|
|
1626
|
+
lastEl.classList.remove("flash-up", "flash-down");
|
|
1627
|
+
void lastEl.offsetWidth;
|
|
1628
|
+
lastEl.classList.add(st.lastPrice > prevPrice ? "flash-up" : "flash-down");
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (chgEl && st.chgPct != null && isFinite(Number(st.chgPct))) {
|
|
1632
|
+
chgEl.textContent = _WatchlistWidget._changeText(st.chgPct);
|
|
1633
|
+
chgEl.classList.remove("up", "down");
|
|
1634
|
+
chgEl.classList.add(_WatchlistWidget._changeClass(st.chgPct));
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
_toggleFavorite(symbol) {
|
|
1638
|
+
if (!this._host.allow("add to favorites")) return;
|
|
1639
|
+
if (this.favorites.has(symbol)) this.favorites.delete(symbol);
|
|
1640
|
+
else this.favorites.add(symbol);
|
|
1641
|
+
this._saveFavorites();
|
|
1642
|
+
this._host.broadcast((w) => {
|
|
1643
|
+
if (w !== this) {
|
|
1644
|
+
w.favorites = new Set(this.favorites);
|
|
1645
|
+
w._render();
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
this._render();
|
|
1649
|
+
}
|
|
1650
|
+
_loadFavorites() {
|
|
1651
|
+
try {
|
|
1652
|
+
const raw = this._host.preferences.get(_WatchlistWidget.FAVORITES_KEY, null);
|
|
1653
|
+
return raw ? JSON.parse(raw) : [];
|
|
1654
|
+
} catch {
|
|
1655
|
+
return [];
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
_saveFavorites() {
|
|
1659
|
+
try {
|
|
1660
|
+
this._host.preferences.set(_WatchlistWidget.FAVORITES_KEY, JSON.stringify(Array.from(this.favorites)));
|
|
1661
|
+
} catch {
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
// The symbols at the top of the current order — the ones a user is actually
|
|
1665
|
+
// looking at, and the set both the subscriptions and the host's ticker are
|
|
1666
|
+
// sized to.
|
|
1667
|
+
_visible() {
|
|
1668
|
+
return this._grid ? this._grid.sortedRows().slice(0, _WatchlistWidget.VISIBLE_CAP) : [];
|
|
1669
|
+
}
|
|
1670
|
+
// Diff the desired set of visible symbols against currently-held live-price
|
|
1671
|
+
// subscriptions, then add/remove to converge. Called after every render that
|
|
1672
|
+
// may have changed the visible list (filter, sort, search). add/removeSymbol
|
|
1673
|
+
// refcount internally, so another control's subscription on the same symbol
|
|
1674
|
+
// is not affected.
|
|
1675
|
+
_syncSubs() {
|
|
1676
|
+
if (!this.wsClient) return;
|
|
1677
|
+
const desired = new Set(this._visible().map((i2) => String(i2.symbol).split("@")[0]));
|
|
1678
|
+
for (const sym of this._subscribedSyms) {
|
|
1679
|
+
if (!desired.has(sym)) {
|
|
1680
|
+
try {
|
|
1681
|
+
void this.wsClient.removeSymbol(sym);
|
|
1682
|
+
} catch {
|
|
1683
|
+
}
|
|
1684
|
+
this._subscribedSyms.delete(sym);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
let i = 0;
|
|
1688
|
+
for (const sym of desired) {
|
|
1689
|
+
if (this._subscribedSyms.has(sym)) continue;
|
|
1690
|
+
this._subscribedSyms.add(sym);
|
|
1691
|
+
setTimeout(() => {
|
|
1692
|
+
try {
|
|
1693
|
+
void this.wsClient.addSymbol(sym, MarketDataLevels.Quotes);
|
|
1694
|
+
} catch {
|
|
1695
|
+
}
|
|
1696
|
+
}, i * 80);
|
|
1697
|
+
i++;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
// Tell the host which symbols are on screen. Only the primary instance
|
|
1701
|
+
// speaks for the page: duplicated watchlist panels have their own filters,
|
|
1702
|
+
// and the page has one ticker for them to fight over.
|
|
1703
|
+
_publishVisible() {
|
|
1704
|
+
if (!this._host.isPrimary) return;
|
|
1705
|
+
this._host.ticker.publish(this._visible().map((i) => String(i.symbol)), this.stats);
|
|
1706
|
+
}
|
|
1707
|
+
static _priceText(price) {
|
|
1708
|
+
const n = Number(price);
|
|
1709
|
+
if (price == null || !isFinite(n) || n === 0) return "--";
|
|
1710
|
+
if (n >= 1) return n.toFixed(2);
|
|
1711
|
+
if (n >= 0.01) return n.toFixed(4);
|
|
1712
|
+
return n.toFixed(6);
|
|
1713
|
+
}
|
|
1714
|
+
static _changeText(chgPct) {
|
|
1715
|
+
const pct = Number(chgPct);
|
|
1716
|
+
if (chgPct == null || !isFinite(pct)) return "--";
|
|
1717
|
+
return `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`;
|
|
1718
|
+
}
|
|
1719
|
+
static _changeClass(chgPct) {
|
|
1720
|
+
const pct = Number(chgPct);
|
|
1721
|
+
if (chgPct == null || !isFinite(pct)) return "";
|
|
1722
|
+
return pct >= 0 ? "up" : "down";
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
_WatchlistWidget.FAVORITES_KEY = "terminal_watchlist_favorites";
|
|
1726
|
+
// Cache-key prefix for the per-session baseline price used to compute the
|
|
1727
|
+
// change %. Day-scoped so the baseline resets every UTC day and the
|
|
1728
|
+
// percent matches "since today open" intuitively. (Without a REST bars
|
|
1729
|
+
// endpoint there is no broker-side day-open available, and the watchlist
|
|
1730
|
+
// deliberately avoids REST — the live stream is its only source.) It goes
|
|
1731
|
+
// in the host's cache rather than its preferences: one key per symbol per
|
|
1732
|
+
// day is scratch, not a setting worth syncing.
|
|
1733
|
+
_WatchlistWidget.BASELINE_KEY_PREFIX = "wlBaseline:";
|
|
1734
|
+
_WatchlistWidget.VISIBLE_CAP = 30;
|
|
1735
|
+
_WatchlistWidget.RENDER_CAP = 300;
|
|
1736
|
+
_WatchlistWidget.TYPE = ControlTypes.Watchlist;
|
|
1737
|
+
var WatchlistWidget = _WatchlistWidget;
|
|
1738
|
+
return __toCommonJS(index_exports);
|
|
1739
|
+
})();
|
|
1740
|
+
//# sourceMappingURL=sstradingcontrols.js.map
|