@stocksharp/trading-controls 0.1.0 → 1.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/README.md +92 -6
- package/dist/esm/active-orders-widget.js +9 -0
- package/dist/esm/active-orders-widget.js.map +1 -1
- package/dist/esm/control-types.js +3 -0
- package/dist/esm/control-types.js.map +1 -1
- package/dist/esm/grid-menu.js +54 -0
- package/dist/esm/grid-menu.js.map +1 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/order-entry-widget.js +654 -0
- package/dist/esm/order-entry-widget.js.map +1 -0
- package/dist/esm/orderbook-depth.js +51 -0
- package/dist/esm/orderbook-depth.js.map +1 -0
- package/dist/esm/orderbook-widget.js +792 -0
- package/dist/esm/orderbook-widget.js.map +1 -0
- package/dist/esm/positions-widget.js +9 -0
- package/dist/esm/positions-widget.js.map +1 -1
- package/dist/esm/trade-history-widget.js +8 -0
- package/dist/esm/trade-history-widget.js.map +1 -1
- package/dist/esm/tradefeed-aggregator.js +58 -0
- package/dist/esm/tradefeed-aggregator.js.map +1 -0
- package/dist/esm/tradefeed-bubbles.js +131 -0
- package/dist/esm/tradefeed-bubbles.js.map +1 -0
- package/dist/esm/tradefeed-widget.js +692 -0
- package/dist/esm/tradefeed-widget.js.map +1 -0
- package/dist/esm/trading-host.js +2 -0
- package/dist/esm/trading-host.js.map +1 -1
- package/dist/esm/watchlist-widget.js +2 -0
- package/dist/esm/watchlist-widget.js.map +1 -1
- package/dist/sstradingcontrols.js +3346 -25
- package/dist/sstradingcontrols.js.map +3 -3
- package/dist/types/active-orders-widget.d.ts.map +1 -1
- package/dist/types/control-types.d.ts +3 -0
- package/dist/types/control-types.d.ts.map +1 -1
- package/dist/types/grid-menu.d.ts +4 -0
- package/dist/types/grid-menu.d.ts.map +1 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/order-entry-widget.d.ts +86 -0
- package/dist/types/order-entry-widget.d.ts.map +1 -0
- package/dist/types/orderbook-depth.d.ts +19 -0
- package/dist/types/orderbook-depth.d.ts.map +1 -0
- package/dist/types/orderbook-widget.d.ts +105 -0
- package/dist/types/orderbook-widget.d.ts.map +1 -0
- package/dist/types/positions-widget.d.ts.map +1 -1
- package/dist/types/trade-history-widget.d.ts.map +1 -1
- package/dist/types/tradefeed-aggregator.d.ts +15 -0
- package/dist/types/tradefeed-aggregator.d.ts.map +1 -0
- package/dist/types/tradefeed-bubbles.d.ts +39 -0
- package/dist/types/tradefeed-bubbles.d.ts.map +1 -0
- package/dist/types/tradefeed-widget.d.ts +78 -0
- package/dist/types/tradefeed-widget.d.ts.map +1 -0
- package/dist/types/trading-data.d.ts +22 -0
- package/dist/types/trading-data.d.ts.map +1 -1
- package/dist/types/trading-host.d.ts +8 -0
- package/dist/types/trading-host.d.ts.map +1 -1
- package/dist/types/watchlist-widget.d.ts.map +1 -1
- package/package.json +21 -2
- package/screenshots/panels.jpg +0 -0
- package/src/active-orders-widget.ts +9 -0
- package/src/control-types.ts +3 -0
- package/src/grid-menu.ts +67 -0
- package/src/index.ts +31 -0
- package/src/order-entry-widget.ts +733 -0
- package/src/orderbook-depth.ts +99 -0
- package/src/orderbook-widget.ts +896 -0
- package/src/positions-widget.ts +9 -0
- package/src/trade-history-widget.ts +8 -0
- package/src/tradefeed-aggregator.ts +95 -0
- package/src/tradefeed-bubbles.ts +194 -0
- package/src/tradefeed-widget.ts +744 -0
- package/src/trading-data.ts +168 -121
- package/src/trading-host.ts +326 -300
- package/src/watchlist-widget.ts +2 -0
- package/styles/theme.css +23 -0
- package/styles/trading-controls.css +911 -0
- package/translation-keys.json +103 -2
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// Order book — multi-instance.
|
|
2
|
+
//
|
|
3
|
+
// Builds its own DOM (see `_buildRoot`) rather than cloning a <template> out of
|
|
4
|
+
// the page, so it can be constructed by any host that supplies a `TradingHost`.
|
|
5
|
+
// Every instance keeps its own symbol, depth, row layout and side order, and a
|
|
6
|
+
// page may hold as many ladders as it has room for.
|
|
7
|
+
//
|
|
8
|
+
// The ladder is not a table and so not a `DataGrid`: a level is a row of three
|
|
9
|
+
// figures behind a proportional volume bar and a heat tint, both of which are
|
|
10
|
+
// measured per level. The measurements are handed to the stylesheet as custom
|
|
11
|
+
// properties (`--t-ob-bar`, `--t-ob-heat`, `--t-ob-sent`) so the widths and the
|
|
12
|
+
// colours stay in CSS, where every other look in this package lives. The one
|
|
13
|
+
// exception is the depth chart above the ladder: it is drawn on a canvas, which
|
|
14
|
+
// no class name can reach, so its colours come from
|
|
15
|
+
// `host.presentation.canvasPalette()` and its geometry from `orderbook-depth.ts`.
|
|
16
|
+
//
|
|
17
|
+
// Where the data comes from: the host pushes frames in through `applyFrame`,
|
|
18
|
+
// one per server frame, fanned to every live instance — which is why each
|
|
19
|
+
// instance filters by its own symbol, and why `host.register(this)` at the end
|
|
20
|
+
// of the constructor is what makes a ladder update at all. The first frame of a
|
|
21
|
+
// (re)subscription is a snapshot and replaces the ledger; the rest are diffs
|
|
22
|
+
// (`quantity: 0` deletes a level) carrying a per-symbol sequence. A gap in that
|
|
23
|
+
// sequence means a frame was missed and the diffs after it cannot be trusted,
|
|
24
|
+
// so the ladder asks the market-data client to resend the symbol from scratch
|
|
25
|
+
// rather than drifting. Own resting orders come from
|
|
26
|
+
// `marketData.getOrders()` — the badge is an overlay on the levels, not a
|
|
27
|
+
// second subscription.
|
|
28
|
+
import { formatPrice, formatQty } from './formatters.js';
|
|
29
|
+
import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
|
|
30
|
+
import { ControlTypes } from './control-types.js';
|
|
31
|
+
import { MarketDataLevels, assertHost } from './trading-host.js';
|
|
32
|
+
import { depthPolyline, depthSide } from './orderbook-depth.js';
|
|
33
|
+
// StockSharp's OrderStates.Active. Only a resting order sits on the book, so
|
|
34
|
+
// only a resting order earns the "your size is here" badge. The full enum is
|
|
35
|
+
// declared once in `active-orders-widget.ts`; importing it here would pull that
|
|
36
|
+
// blotter — and the grid it renders with — into every bundle that wanted a
|
|
37
|
+
// ladder and nothing else.
|
|
38
|
+
const RESTING_STATE = 3;
|
|
39
|
+
// Heat tint per level, as the percentage of the direction colour mixed into the
|
|
40
|
+
// row background. Faint at the shallowest level and still faint at the largest,
|
|
41
|
+
// because the volume bar behind the row is what carries the size — the tint
|
|
42
|
+
// only separates a wall from its neighbours.
|
|
43
|
+
const HEAT_FLOOR_PCT = 6;
|
|
44
|
+
const HEAT_RANGE_PCT = 18;
|
|
45
|
+
export class OrderBookWidget {
|
|
46
|
+
static create(hostEl, state, deps) {
|
|
47
|
+
// Assert before building: the markup below is localized through the
|
|
48
|
+
// host, so a missing host has to fail here rather than render a panel
|
|
49
|
+
// captioned with raw English keys.
|
|
50
|
+
const host = assertHost(deps?.host, 'OrderBookWidget');
|
|
51
|
+
const root = OrderBookWidget._buildRoot(host);
|
|
52
|
+
root.id = makePanelId(OrderBookWidget.TYPE);
|
|
53
|
+
hostEl.appendChild(root);
|
|
54
|
+
return new OrderBookWidget(root, state || {}, deps);
|
|
55
|
+
}
|
|
56
|
+
// The panel's markup. The host stylesheet reads this structure, and a
|
|
57
|
+
// docking host lifts `.panel-header`'s children into its tab strip — which
|
|
58
|
+
// is why the header holds every control the ladder has.
|
|
59
|
+
//
|
|
60
|
+
// The `+` is built here rather than left out: the port documents `spawn` as
|
|
61
|
+
// this control's gesture, and a button nothing renders is a capability
|
|
62
|
+
// nobody can reach.
|
|
63
|
+
static _buildRoot(host) {
|
|
64
|
+
const title = host.t('OrderBook');
|
|
65
|
+
return makePanelRoot('orderbook-panel', title, [
|
|
66
|
+
makeElement('div', 'panel-header', {}, [
|
|
67
|
+
makeElement('span', 'ob-symbol-label', { title: host.t('ClickToChangeSymbol'), role: 'button', tabindex: '0' }, ['--']),
|
|
68
|
+
makeElement('div', 'ob-depth-selector', { role: 'group', 'aria-label': host.t('OrderBookDepth') }, OrderBookWidget.DEPTHS.map(depth => makeElement('button', 'btn-ob-depth', {
|
|
69
|
+
type: 'button',
|
|
70
|
+
'data-depth': String(depth),
|
|
71
|
+
'aria-label': host.t('LevelsCount', depth),
|
|
72
|
+
'aria-pressed': 'false',
|
|
73
|
+
}, [String(depth)]))),
|
|
74
|
+
makeElement('div', 'ob-view-selector', { role: 'group', 'aria-label': host.t('OrderBookView') }, [
|
|
75
|
+
makeIconButton('btn-ob-view', host.t('OrderBookViewDiagonal'), 'bi-distribute-horizontal', { type: 'button', 'data-view': 'diagonal', 'aria-pressed': 'false' }),
|
|
76
|
+
makeIconButton('btn-ob-view', host.t('OrderBookViewStacked'), 'bi-table', { type: 'button', 'data-view': 'stacked', 'aria-pressed': 'false' }),
|
|
77
|
+
]),
|
|
78
|
+
makeIconButton('btn-ob-invert', host.t('OrderBookInvertSides'), 'bi-arrow-down-up', { type: 'button', 'aria-pressed': 'false' }),
|
|
79
|
+
makeIconButton('btn-ob-depthtoggle', host.t('ToggleDepthChart'), 'bi-graph-up', { type: 'button', 'aria-pressed': 'false' }),
|
|
80
|
+
makeElement('span', 'spread-label', { 'aria-live': 'polite' }, ['--']),
|
|
81
|
+
makeIconButton('bt-icon-btn ob-add-btn', host.t('AddOrderbook'), 'bi-plus-lg', { type: 'button' }),
|
|
82
|
+
makeIconButton('bt-icon-btn bt-icon-cancel ob-close-btn', host.t('RemoveOrderbook'), 'bi-x', { type: 'button' }),
|
|
83
|
+
]),
|
|
84
|
+
makeElement('canvas', 'ob-depth-chart', { 'aria-hidden': 'true' }, []),
|
|
85
|
+
makeElement('div', 'ob-col-headers', { 'aria-hidden': 'true' }, [
|
|
86
|
+
makeElement('span', 'ob-col-price', {}, [host.t('Price')]),
|
|
87
|
+
makeElement('span', 'ob-col-qty', {}, [host.t('Qty')]),
|
|
88
|
+
makeElement('span', 'ob-col-total', {}, [host.t('Total')]),
|
|
89
|
+
]),
|
|
90
|
+
makeElement('div', 'orderbook-content', { 'aria-live': 'polite', 'aria-atomic': 'false' }, [
|
|
91
|
+
makeElement('div', 'orderbook-asks', { 'aria-label': host.t('AskOrders') }, []),
|
|
92
|
+
makeElement('div', 'orderbook-mid', { 'aria-label': host.t('MidPrice') }, [
|
|
93
|
+
makeElement('span', 'ob-mid-price', {}, ['--']),
|
|
94
|
+
makeElement('span', 'ob-mid-spread', { 'aria-live': 'polite' }, []),
|
|
95
|
+
]),
|
|
96
|
+
makeElement('div', 'orderbook-bids', { 'aria-label': host.t('BidOrders') }, []),
|
|
97
|
+
]),
|
|
98
|
+
makeElement('div', 'ob-sentiment', { 'aria-label': host.t('OrderBookSentiment') }, [
|
|
99
|
+
makeElement('div', 'ob-sent-bid', {}, [
|
|
100
|
+
makeElement('span', 'ob-sent-label', {}, [host.t('BidShort')]),
|
|
101
|
+
makeElement('span', 'ob-sent-bid-pct', {}, ['--']),
|
|
102
|
+
]),
|
|
103
|
+
makeElement('div', 'ob-sent-ask', {}, [
|
|
104
|
+
makeElement('span', 'ob-sent-ask-pct', {}, ['--']),
|
|
105
|
+
makeElement('span', 'ob-sent-label', {}, [host.t('AskShort')]),
|
|
106
|
+
]),
|
|
107
|
+
]),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
constructor(rootEl, state, deps) {
|
|
111
|
+
this._host = assertHost(deps?.host, 'OrderBookWidget');
|
|
112
|
+
for (const name of ['onPriceSelected', 'onPriceExecuted', 'maxDepth', 'pixelRatio']) {
|
|
113
|
+
if (typeof deps?.[name] !== 'function')
|
|
114
|
+
throw new Error(`OrderBookWidget: dep "${name}" is required`);
|
|
115
|
+
}
|
|
116
|
+
this.rootEl = rootEl;
|
|
117
|
+
this._deps = deps;
|
|
118
|
+
this.asksEl = this.rootEl.querySelector('.orderbook-asks');
|
|
119
|
+
this.bidsEl = this.rootEl.querySelector('.orderbook-bids');
|
|
120
|
+
this.midEl = this.rootEl.querySelector('.ob-mid-price');
|
|
121
|
+
this.spreadEl = this.rootEl.querySelector('.spread-label');
|
|
122
|
+
this.contentEl = this.rootEl.querySelector('.orderbook-content');
|
|
123
|
+
this.depthChartEl = this.rootEl.querySelector('.ob-depth-chart');
|
|
124
|
+
this._midSpreadEl = this.rootEl.querySelector('.ob-mid-spread');
|
|
125
|
+
this._sentimentEl = this.rootEl.querySelector('.ob-sentiment');
|
|
126
|
+
this._sentBidPctEl = this.rootEl.querySelector('.ob-sent-bid-pct');
|
|
127
|
+
this._sentAskPctEl = this.rootEl.querySelector('.ob-sent-ask-pct');
|
|
128
|
+
this._symbolLabel = this.rootEl.querySelector('.ob-symbol-label');
|
|
129
|
+
this._depthBtns = Array.from(this.rootEl.querySelectorAll('.btn-ob-depth'));
|
|
130
|
+
this._viewBtns = Array.from(this.rootEl.querySelectorAll('.btn-ob-view'));
|
|
131
|
+
this._invertBtn = this.rootEl.querySelector('.btn-ob-invert');
|
|
132
|
+
this._depthToggleBtn = this.rootEl.querySelector('.btn-ob-depthtoggle');
|
|
133
|
+
this._addBtn = this.rootEl.querySelector('.ob-add-btn');
|
|
134
|
+
this._closeBtn = this.rootEl.querySelector('.ob-close-btn');
|
|
135
|
+
// Per-panel state is whatever the host persisted, so every value is read
|
|
136
|
+
// defensively and falls back to this control's own default.
|
|
137
|
+
const saved = state;
|
|
138
|
+
this._prevQuantities = new Map();
|
|
139
|
+
this._bidsByPrice = new Map();
|
|
140
|
+
this._asksByPrice = new Map();
|
|
141
|
+
this._lastSequence = 0;
|
|
142
|
+
this._currentSymbol = null;
|
|
143
|
+
this._followsActive = saved.followsActive === true;
|
|
144
|
+
this._view = saved.view === 'diagonal' ? 'diagonal' : 'stacked';
|
|
145
|
+
this._invertSides = saved.invertSides === true;
|
|
146
|
+
// On by default; only an explicit false hides it.
|
|
147
|
+
this._showDepthChart = saved.showDepthChart !== false;
|
|
148
|
+
let depth = OrderBookWidget._isDepth(saved.depth) ? saved.depth : OrderBookWidget._deepest();
|
|
149
|
+
// The follows-active instance restores the settings the user chose the
|
|
150
|
+
// last time, which live in the host's preferences rather than in this
|
|
151
|
+
// panel's state — there is one of it per page and it outlives any
|
|
152
|
+
// particular layout. Pinned instances carry theirs in `state` above.
|
|
153
|
+
if (this._followsActive) {
|
|
154
|
+
const preferences = this._host.preferences;
|
|
155
|
+
const savedDepth = Number(preferences.get(OrderBookWidget.DEPTH_KEY, null));
|
|
156
|
+
if (OrderBookWidget._isDepth(savedDepth))
|
|
157
|
+
depth = savedDepth;
|
|
158
|
+
const savedView = preferences.get(OrderBookWidget.VIEW_KEY, null);
|
|
159
|
+
if (savedView === 'diagonal' || savedView === 'stacked')
|
|
160
|
+
this._view = savedView;
|
|
161
|
+
const savedInvert = preferences.get(OrderBookWidget.INVERT_KEY, null);
|
|
162
|
+
if (savedInvert !== null)
|
|
163
|
+
this._invertSides = savedInvert === 'true';
|
|
164
|
+
const savedDepthChart = preferences.get(OrderBookWidget.DEPTHCHART_KEY, null);
|
|
165
|
+
if (savedDepthChart !== null)
|
|
166
|
+
this._showDepthChart = savedDepthChart === 'true';
|
|
167
|
+
}
|
|
168
|
+
this._depth = this._fitDepth(depth);
|
|
169
|
+
this._wireHeader();
|
|
170
|
+
this._wireBookClicks();
|
|
171
|
+
this._refreshDepthButtons();
|
|
172
|
+
this._applyViewState();
|
|
173
|
+
this._refreshViewButtons();
|
|
174
|
+
this._refreshInvertButton();
|
|
175
|
+
this._refreshDepthToggleButton();
|
|
176
|
+
// A follows-active instance ignores any persisted symbol: it inherits
|
|
177
|
+
// the live one from the host on boot, and honouring a stale one here
|
|
178
|
+
// would flash a previous session's instrument until that arrives.
|
|
179
|
+
if (saved.symbol && !this._followsActive)
|
|
180
|
+
this.setSymbol(saved.symbol);
|
|
181
|
+
// Self-register — this is how incoming frames find their way here, and
|
|
182
|
+
// an unregistered ladder is one that never updates.
|
|
183
|
+
this._host.register(this);
|
|
184
|
+
}
|
|
185
|
+
/// Release the market-data subscription, leave the fan-out and remove the
|
|
186
|
+
/// panel. Called by the host when the panel is destroyed.
|
|
187
|
+
dispose() {
|
|
188
|
+
if (this._currentSymbol)
|
|
189
|
+
void this._host.trading.marketData.removeSymbol(this._currentSymbol).catch(() => { });
|
|
190
|
+
this._host.unregister(this);
|
|
191
|
+
try {
|
|
192
|
+
this.rootEl.remove();
|
|
193
|
+
}
|
|
194
|
+
catch { /* already detached */ }
|
|
195
|
+
}
|
|
196
|
+
/// Switch to a new instrument: drop the old subscription, take a new one and
|
|
197
|
+
/// blank the ledger, because a diff for the old symbol must never be applied
|
|
198
|
+
/// to the new one.
|
|
199
|
+
setSymbol(symbol) {
|
|
200
|
+
if (!symbol)
|
|
201
|
+
return;
|
|
202
|
+
const next = String(symbol).toUpperCase();
|
|
203
|
+
if (next === this._currentSymbol)
|
|
204
|
+
return;
|
|
205
|
+
const previous = this._currentSymbol;
|
|
206
|
+
this._currentSymbol = next;
|
|
207
|
+
this._resetLedger();
|
|
208
|
+
if (this._symbolLabel)
|
|
209
|
+
this._symbolLabel.textContent = next;
|
|
210
|
+
this._render();
|
|
211
|
+
// addSymbol / removeSymbol are refcounted by the client, so two ladders
|
|
212
|
+
// on one symbol hold one subscription and neither can drop the other's.
|
|
213
|
+
const marketData = this._host.trading.marketData;
|
|
214
|
+
if (previous)
|
|
215
|
+
void marketData.removeSymbol(previous).catch(() => { });
|
|
216
|
+
// A ladder needs the depth, and the header reads best bid/ask, so it
|
|
217
|
+
// asks for the whole set rather than for quotes alone.
|
|
218
|
+
void marketData.addSymbol(next, MarketDataLevels.Full).catch(() => { });
|
|
219
|
+
// A pinned instance remembers its instrument so a reload restores this
|
|
220
|
+
// exact ladder; the follows-active one deliberately does not, because it
|
|
221
|
+
// tracks whatever the host is showing.
|
|
222
|
+
if (!this._followsActive)
|
|
223
|
+
this._persistState({ symbol: next });
|
|
224
|
+
}
|
|
225
|
+
getSymbol() { return this._currentSymbol; }
|
|
226
|
+
getDepth() { return this._depth; }
|
|
227
|
+
/// True for the instance that follows the host's active symbol. A page has
|
|
228
|
+
/// at most one, and a host that feeds a best-bid/ask from a ladder asks for
|
|
229
|
+
/// it by this.
|
|
230
|
+
isFollowsActive() { return this._followsActive; }
|
|
231
|
+
/// The ledger as levels, best price first. Public because a host reads the
|
|
232
|
+
/// touch off the ladder rather than keeping a second copy of the book.
|
|
233
|
+
getBids() {
|
|
234
|
+
return OrderBookWidget._sorted(this._bidsByPrice, -1);
|
|
235
|
+
}
|
|
236
|
+
getAsks() {
|
|
237
|
+
return OrderBookWidget._sorted(this._asksByPrice, 1);
|
|
238
|
+
}
|
|
239
|
+
// The three setters below repaint BEFORE they persist: `_persistState` ends
|
|
240
|
+
// in a layout flush and the host serializes the live panel to produce it, so
|
|
241
|
+
// persisting first would write out the panel as it looked before the change
|
|
242
|
+
// the user just made.
|
|
243
|
+
setDepth(depth) {
|
|
244
|
+
if (!OrderBookWidget._isDepth(depth))
|
|
245
|
+
return;
|
|
246
|
+
this._depth = this._fitDepth(depth);
|
|
247
|
+
this._refreshDepthButtons();
|
|
248
|
+
this._render();
|
|
249
|
+
if (this._followsActive)
|
|
250
|
+
this._host.preferences.set(OrderBookWidget.DEPTH_KEY, String(this._depth));
|
|
251
|
+
else
|
|
252
|
+
this._persistState({ depth: this._depth });
|
|
253
|
+
}
|
|
254
|
+
setView(view) {
|
|
255
|
+
if (view !== 'diagonal' && view !== 'stacked')
|
|
256
|
+
return;
|
|
257
|
+
this._view = view;
|
|
258
|
+
this._applyViewState();
|
|
259
|
+
this._refreshViewButtons();
|
|
260
|
+
if (this._followsActive)
|
|
261
|
+
this._host.preferences.set(OrderBookWidget.VIEW_KEY, view);
|
|
262
|
+
else
|
|
263
|
+
this._persistState({ view });
|
|
264
|
+
}
|
|
265
|
+
/// Put the bid block above the mid line and the ask block below it, the
|
|
266
|
+
/// reverse of the convention every Western exchange UI follows.
|
|
267
|
+
setInvertSides(invert) {
|
|
268
|
+
this._invertSides = invert === true;
|
|
269
|
+
this._applyViewState();
|
|
270
|
+
this._refreshInvertButton();
|
|
271
|
+
if (this._followsActive)
|
|
272
|
+
this._host.preferences.set(OrderBookWidget.INVERT_KEY, this._invertSides ? 'true' : 'false');
|
|
273
|
+
else
|
|
274
|
+
this._persistState({ invertSides: this._invertSides });
|
|
275
|
+
}
|
|
276
|
+
/// Show or hide the depth chart above the ladder. The canvas keeps its last
|
|
277
|
+
/// bitmap while hidden, so showing it again brings the chart straight back
|
|
278
|
+
/// and the next frame repaints it.
|
|
279
|
+
setShowDepthChart(show) {
|
|
280
|
+
this._showDepthChart = show === true;
|
|
281
|
+
this._applyViewState();
|
|
282
|
+
this._refreshDepthToggleButton();
|
|
283
|
+
if (this._followsActive)
|
|
284
|
+
this._host.preferences.set(OrderBookWidget.DEPTHCHART_KEY, this._showDepthChart ? 'true' : 'false');
|
|
285
|
+
this._persistState({ showDepthChart: this._showDepthChart });
|
|
286
|
+
}
|
|
287
|
+
/// Apply one frame. A snapshot replaces the ledger; a diff merges into it,
|
|
288
|
+
/// with `quantity: 0` deleting a level. A break in the sequence means a
|
|
289
|
+
/// frame was missed, so the ladder asks for a fresh snapshot instead of
|
|
290
|
+
/// applying a diff to state it can no longer trust.
|
|
291
|
+
///
|
|
292
|
+
/// Every instance receives every frame, so the symbol filter here is what
|
|
293
|
+
/// keeps a frame for one instrument out of a ladder watching another.
|
|
294
|
+
applyFrame(frame) {
|
|
295
|
+
if (!frame || !frame.symbol)
|
|
296
|
+
return;
|
|
297
|
+
if (this._currentSymbol && frame.symbol !== this._currentSymbol)
|
|
298
|
+
return;
|
|
299
|
+
if (this._currentSymbol !== frame.symbol) {
|
|
300
|
+
this._currentSymbol = frame.symbol;
|
|
301
|
+
// The follows-active instance can receive its first frame before the
|
|
302
|
+
// host calls setSymbol, whose early return on an unchanged symbol
|
|
303
|
+
// would then skip the label.
|
|
304
|
+
if (this._symbolLabel)
|
|
305
|
+
this._symbolLabel.textContent = frame.symbol;
|
|
306
|
+
}
|
|
307
|
+
const sequence = frame.sequence ?? 0;
|
|
308
|
+
if (frame.isSnapshot) {
|
|
309
|
+
this._resetLedger();
|
|
310
|
+
this._applySnapshotSide(this._bidsByPrice, frame.bids, 'bid', frame.symbol, sequence);
|
|
311
|
+
this._applySnapshotSide(this._asksByPrice, frame.asks, 'ask', frame.symbol, sequence);
|
|
312
|
+
this._lastSequence = sequence;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
if (this._lastSequence !== 0 && sequence !== this._lastSequence + 1) {
|
|
316
|
+
this._anomaly(`sequence gap: expected ${this._lastSequence + 1}, got ${sequence} sym=${frame.symbol} — requesting snapshot`);
|
|
317
|
+
this._lastSequence = 0;
|
|
318
|
+
void this._requestResubscribe(frame.symbol);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
this._applyDiffSide(this._bidsByPrice, frame.bids, 'bid', frame.symbol, sequence);
|
|
322
|
+
this._applyDiffSide(this._asksByPrice, frame.asks, 'ask', frame.symbol, sequence);
|
|
323
|
+
this._lastSequence = sequence || this._lastSequence;
|
|
324
|
+
}
|
|
325
|
+
this._validateBookCross(frame.symbol, sequence, frame.isSnapshot === true);
|
|
326
|
+
this._render();
|
|
327
|
+
}
|
|
328
|
+
_wireHeader() {
|
|
329
|
+
for (const button of this._depthBtns) {
|
|
330
|
+
button.addEventListener('click', (e) => {
|
|
331
|
+
e.preventDefault();
|
|
332
|
+
this.setDepth(Number(button.dataset.depth));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
for (const button of this._viewBtns) {
|
|
336
|
+
button.addEventListener('click', (e) => {
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
const view = button.dataset.view;
|
|
339
|
+
if (view === 'diagonal' || view === 'stacked')
|
|
340
|
+
this.setView(view);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
this._invertBtn?.addEventListener('click', (e) => {
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
this.setInvertSides(!this._invertSides);
|
|
346
|
+
});
|
|
347
|
+
this._depthToggleBtn?.addEventListener('click', (e) => {
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
this.setShowDepthChart(!this._showDepthChart);
|
|
350
|
+
});
|
|
351
|
+
// Another ladder, starting where this one is — the most natural copy
|
|
352
|
+
// gesture, and the host decides where it goes.
|
|
353
|
+
this._addBtn?.addEventListener('click', (e) => {
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
e.stopPropagation();
|
|
356
|
+
this._host.spawn({
|
|
357
|
+
symbol: this._currentSymbol,
|
|
358
|
+
depth: this._depth,
|
|
359
|
+
view: this._view,
|
|
360
|
+
invertSides: this._invertSides,
|
|
361
|
+
showDepthChart: this._showDepthChart,
|
|
362
|
+
followsActive: false,
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
this._closeBtn?.addEventListener('click', (e) => {
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
e.stopPropagation();
|
|
368
|
+
this._host.close();
|
|
369
|
+
});
|
|
370
|
+
// Picking an instrument retargets THIS ladder only — a user who wanted
|
|
371
|
+
// the whole page to follow would have used the host's own instrument
|
|
372
|
+
// selector. Picking on the follows-active instance detaches it: it
|
|
373
|
+
// becomes a pinned ladder on the chosen symbol, identical to any other.
|
|
374
|
+
this._symbolLabel?.addEventListener('click', (e) => {
|
|
375
|
+
e.preventDefault();
|
|
376
|
+
e.stopPropagation();
|
|
377
|
+
this._host.trading.pickInstrument((symbol) => {
|
|
378
|
+
if (this._followsActive) {
|
|
379
|
+
this._followsActive = false;
|
|
380
|
+
this._persistState({ followsActive: false });
|
|
381
|
+
}
|
|
382
|
+
this.setSymbol(symbol);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
// A click anywhere on a level, caught once on the container: the rows are
|
|
387
|
+
// rebuilt on every frame, so a listener per row would be re-bound dozens of
|
|
388
|
+
// times a second.
|
|
389
|
+
//
|
|
390
|
+
// Plain click prefills an order at that price and leaves the sending to the
|
|
391
|
+
// user. Ctrl/Cmd click sends at once — a single click doing that is how a
|
|
392
|
+
// sneeze on the bid becomes a fill.
|
|
393
|
+
_wireBookClicks() {
|
|
394
|
+
this.contentEl?.addEventListener('click', (e) => {
|
|
395
|
+
const target = e.target;
|
|
396
|
+
const row = target?.closest('.ob-row');
|
|
397
|
+
if (!row)
|
|
398
|
+
return;
|
|
399
|
+
const price = Number(row.dataset.price);
|
|
400
|
+
if (!Number.isFinite(price))
|
|
401
|
+
return;
|
|
402
|
+
// A bid row is where the user sells and an ask row is where they
|
|
403
|
+
// buy, so the row carries the side of the order, not of the level.
|
|
404
|
+
const side = row.dataset.side === 'buy' ? 0 : 1;
|
|
405
|
+
if (e.ctrlKey || e.metaKey)
|
|
406
|
+
this._deps.onPriceExecuted(price, side);
|
|
407
|
+
else
|
|
408
|
+
this._deps.onPriceSelected(price, side);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
_refreshDepthButtons() {
|
|
412
|
+
for (const button of this._depthBtns) {
|
|
413
|
+
const on = Number(button.dataset.depth) === this._depth;
|
|
414
|
+
button.classList.toggle('active', on);
|
|
415
|
+
button.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
_refreshViewButtons() {
|
|
419
|
+
for (const button of this._viewBtns) {
|
|
420
|
+
const on = button.dataset.view === this._view;
|
|
421
|
+
button.classList.toggle('active', on);
|
|
422
|
+
button.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
_refreshInvertButton() {
|
|
426
|
+
this._invertBtn?.classList.toggle('active', this._invertSides);
|
|
427
|
+
this._invertBtn?.setAttribute('aria-pressed', this._invertSides ? 'true' : 'false');
|
|
428
|
+
}
|
|
429
|
+
_refreshDepthToggleButton() {
|
|
430
|
+
this._depthToggleBtn?.classList.toggle('active', this._showDepthChart);
|
|
431
|
+
this._depthToggleBtn?.setAttribute('aria-pressed', this._showDepthChart ? 'true' : 'false');
|
|
432
|
+
}
|
|
433
|
+
// The variant classes on the panel root the stylesheet keys the two layouts,
|
|
434
|
+
// the side order and the depth chart off.
|
|
435
|
+
_applyViewState() {
|
|
436
|
+
this.rootEl.classList.toggle('ob-view-stacked', this._view === 'stacked');
|
|
437
|
+
this.rootEl.classList.toggle('ob-view-diagonal', this._view === 'diagonal');
|
|
438
|
+
this.rootEl.classList.toggle('ob-invert', this._invertSides);
|
|
439
|
+
this.rootEl.classList.toggle('ob-hide-depth', !this._showDepthChart);
|
|
440
|
+
}
|
|
441
|
+
// Record what this panel remembers, then ask the host to flush the layout.
|
|
442
|
+
// Two calls because they are two decisions, and every caller here is a
|
|
443
|
+
// setting the user just changed by hand — so it is worth flushing.
|
|
444
|
+
_persistState(patch) {
|
|
445
|
+
this._host.persistState(patch);
|
|
446
|
+
this._host.saveLayout();
|
|
447
|
+
}
|
|
448
|
+
_resetLedger() {
|
|
449
|
+
this._bidsByPrice.clear();
|
|
450
|
+
this._asksByPrice.clear();
|
|
451
|
+
this._prevQuantities.clear();
|
|
452
|
+
this._lastSequence = 0;
|
|
453
|
+
}
|
|
454
|
+
// The host answers how many levels it has room for, so a phone-sized page
|
|
455
|
+
// narrows the ladder without this control knowing what a viewport is.
|
|
456
|
+
_fitDepth(depth) {
|
|
457
|
+
const room = Number(this._deps.maxDepth());
|
|
458
|
+
if (!Number.isFinite(room) || room < 1)
|
|
459
|
+
return depth;
|
|
460
|
+
return Math.min(depth, room);
|
|
461
|
+
}
|
|
462
|
+
static _isDepth(depth) {
|
|
463
|
+
return typeof depth === 'number' && OrderBookWidget.DEPTHS.includes(depth);
|
|
464
|
+
}
|
|
465
|
+
static _deepest() {
|
|
466
|
+
return OrderBookWidget.DEPTHS[OrderBookWidget.DEPTHS.length - 1];
|
|
467
|
+
}
|
|
468
|
+
static _sorted(ledger, direction) {
|
|
469
|
+
return [...ledger.entries()]
|
|
470
|
+
.filter(([price, quantity]) => Number.isFinite(price) && quantity > 0)
|
|
471
|
+
.sort((left, right) => direction * (left[0] - right[0]))
|
|
472
|
+
.map(([price, quantity]) => ({ price, quantity }));
|
|
473
|
+
}
|
|
474
|
+
_applySnapshotSide(ledger, levels, side, symbol, sequence) {
|
|
475
|
+
for (const level of levels || []) {
|
|
476
|
+
const quantity = level.quantity ?? 0;
|
|
477
|
+
if (level.price == null || !Number.isFinite(level.price) || !(quantity > 0)) {
|
|
478
|
+
this._anomaly(`snapshot ${side} level dropped: price=${level.price} qty=${level.quantity} sym=${symbol} seq=${sequence}`);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
ledger.set(level.price, quantity);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
_applyDiffSide(ledger, levels, side, symbol, sequence) {
|
|
485
|
+
for (const level of levels || []) {
|
|
486
|
+
// A level with a non-finite price is wire corruption: applying it
|
|
487
|
+
// lands a NaN key in the ledger, which sorts unpredictably and
|
|
488
|
+
// renders as a phantom row between two real levels.
|
|
489
|
+
if (level.price == null || !Number.isFinite(level.price)) {
|
|
490
|
+
this._anomaly(`invalid ${side} price: price=${level.price} qty=${level.quantity} sym=${symbol} seq=${sequence} — ignoring`);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const quantity = level.quantity ?? 0;
|
|
494
|
+
if (quantity < 0) {
|
|
495
|
+
this._anomaly(`negative ${side} qty: price=${level.price} qty=${quantity} sym=${symbol} seq=${sequence} — ignoring`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (quantity === 0) {
|
|
499
|
+
if (!ledger.has(level.price)) {
|
|
500
|
+
this._anomaly(`delete missing ${side}: price=${level.price} sym=${symbol} seq=${sequence}`);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
ledger.delete(level.price);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
ledger.set(level.price, quantity);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Best bid below best ask, or something upstream is broken — usually a diff
|
|
511
|
+
// applied to stale state after a gap the sequence check did not catch.
|
|
512
|
+
_validateBookCross(symbol, sequence, wasSnapshot) {
|
|
513
|
+
if (this._bidsByPrice.size === 0 || this._asksByPrice.size === 0)
|
|
514
|
+
return;
|
|
515
|
+
let bestBid = -Infinity;
|
|
516
|
+
let bestAsk = Infinity;
|
|
517
|
+
for (const price of this._bidsByPrice.keys())
|
|
518
|
+
if (price > bestBid)
|
|
519
|
+
bestBid = price;
|
|
520
|
+
for (const price of this._asksByPrice.keys())
|
|
521
|
+
if (price < bestAsk)
|
|
522
|
+
bestAsk = price;
|
|
523
|
+
if (bestBid >= bestAsk) {
|
|
524
|
+
this._anomaly(`crossed book: bid=${bestBid} >= ask=${bestAsk} sym=${symbol} seq=${sequence} ${wasSnapshot ? 'snapshot' : 'diff'}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Ask for the symbol from scratch. Idempotent as far as the user is
|
|
528
|
+
// concerned: the ladder blanks for a frame and refills.
|
|
529
|
+
async _requestResubscribe(symbol) {
|
|
530
|
+
try {
|
|
531
|
+
await this._host.trading.marketData.resubscribe(symbol, MarketDataLevels.Full);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
this._anomaly(`resubscribe after a gap failed for ${symbol}: ${err}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// Wire trouble the user cannot see and support has to, through the port.
|
|
538
|
+
_anomaly(message) {
|
|
539
|
+
this._host.log(`OrderBookWidget: ${message}`);
|
|
540
|
+
}
|
|
541
|
+
_render() {
|
|
542
|
+
this._paint(this.getBids(), this.getAsks());
|
|
543
|
+
}
|
|
544
|
+
_paint(bids, asks) {
|
|
545
|
+
if (!this.asksEl || !this.bidsEl)
|
|
546
|
+
return;
|
|
547
|
+
const visibleAsks = asks.slice(0, this._depth);
|
|
548
|
+
const visibleBids = bids.slice(0, this._depth);
|
|
549
|
+
// The bar is scaled per side — a 100k bid wall must not flatten a normal
|
|
550
|
+
// 200-lot ask ladder on the other half — while the heat tint is scaled
|
|
551
|
+
// across both, so the two halves stay perceptually comparable.
|
|
552
|
+
const maxBid = Math.max(...visibleBids.map(level => level.quantity), 1);
|
|
553
|
+
const maxAsk = Math.max(...visibleAsks.map(level => level.quantity), 1);
|
|
554
|
+
const maxBoth = Math.max(maxBid, maxAsk, 1);
|
|
555
|
+
const own = this._ownQuantities();
|
|
556
|
+
const painted = new Map();
|
|
557
|
+
// Asks are rendered worst-first so the best ask ends up next to the mid
|
|
558
|
+
// line, while the cumulative total runs the other way — the best ask
|
|
559
|
+
// reads `qty == total` and the sum grows as the eye moves away from the
|
|
560
|
+
// spread.
|
|
561
|
+
const askTotals = [];
|
|
562
|
+
let running = 0;
|
|
563
|
+
for (const level of visibleAsks) {
|
|
564
|
+
running += level.quantity;
|
|
565
|
+
askTotals.push(running);
|
|
566
|
+
}
|
|
567
|
+
const askRows = [];
|
|
568
|
+
for (let i = visibleAsks.length - 1; i >= 0; i--) {
|
|
569
|
+
askRows.push(this._levelRow(visibleAsks[i], {
|
|
570
|
+
rowClass: 'ob-row ask',
|
|
571
|
+
orderSide: 'buy',
|
|
572
|
+
key: `a_${visibleAsks[i].price}`,
|
|
573
|
+
cumulative: askTotals[i],
|
|
574
|
+
barPct: (visibleAsks[i].quantity / maxAsk) * 100,
|
|
575
|
+
heatPct: HEAT_FLOOR_PCT + HEAT_RANGE_PCT * (visibleAsks[i].quantity / maxBoth),
|
|
576
|
+
// More size on the offer is pressure downward, so a growing ask
|
|
577
|
+
// level flashes in the falling colour.
|
|
578
|
+
growthClass: 'flash-red',
|
|
579
|
+
shrinkClass: 'flash-green',
|
|
580
|
+
ownQuantity: own.asks.get(OrderBookWidget._priceKey(visibleAsks[i].price)),
|
|
581
|
+
painted,
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
this.asksEl.replaceChildren(...askRows);
|
|
585
|
+
running = 0;
|
|
586
|
+
const bidRows = [];
|
|
587
|
+
for (const level of visibleBids) {
|
|
588
|
+
running += level.quantity;
|
|
589
|
+
bidRows.push(this._levelRow(level, {
|
|
590
|
+
rowClass: 'ob-row bid',
|
|
591
|
+
orderSide: 'sell',
|
|
592
|
+
key: `b_${level.price}`,
|
|
593
|
+
cumulative: running,
|
|
594
|
+
barPct: (level.quantity / maxBid) * 100,
|
|
595
|
+
heatPct: HEAT_FLOOR_PCT + HEAT_RANGE_PCT * (level.quantity / maxBoth),
|
|
596
|
+
growthClass: 'flash-green',
|
|
597
|
+
shrinkClass: 'flash-red',
|
|
598
|
+
ownQuantity: own.bids.get(OrderBookWidget._priceKey(level.price)),
|
|
599
|
+
painted,
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
this.bidsEl.replaceChildren(...bidRows);
|
|
603
|
+
this._prevQuantities = painted;
|
|
604
|
+
if (asks.length > 0 && bids.length > 0) {
|
|
605
|
+
const bestAsk = asks[0].price;
|
|
606
|
+
const bestBid = bids[0].price;
|
|
607
|
+
const spread = bestAsk - bestBid;
|
|
608
|
+
if (this.midEl)
|
|
609
|
+
this.midEl.textContent = formatPrice((bestAsk + bestBid) / 2);
|
|
610
|
+
if (this._midSpreadEl)
|
|
611
|
+
this._midSpreadEl.textContent = formatPrice(spread);
|
|
612
|
+
if (this.spreadEl)
|
|
613
|
+
this.spreadEl.textContent = this._host.t('Spread: {0}', formatPrice(spread));
|
|
614
|
+
}
|
|
615
|
+
this._paintSentiment(visibleBids, visibleAsks);
|
|
616
|
+
this._paintDepthChart(visibleBids, visibleAsks);
|
|
617
|
+
}
|
|
618
|
+
// One level. Every measurement it carries — the bar's share of its side, the
|
|
619
|
+
// heat tint — reaches the stylesheet as a custom property, so the widths and
|
|
620
|
+
// the colours are still declared in CSS.
|
|
621
|
+
_levelRow(level, options) {
|
|
622
|
+
const quantity = makeElement('span', 'qty', {}, [formatQty(level.quantity)]);
|
|
623
|
+
if (options.ownQuantity) {
|
|
624
|
+
quantity.appendChild(makeElement('span', 'own-badge', { title: this._host.t('YourOrder') }, [formatQty(options.ownQuantity)]));
|
|
625
|
+
}
|
|
626
|
+
const row = makeElement('div', options.rowClass, {
|
|
627
|
+
'data-side': options.orderSide,
|
|
628
|
+
'data-price': String(level.price),
|
|
629
|
+
}, [
|
|
630
|
+
makeElement('span', 'price', {}, [formatPrice(level.price)]),
|
|
631
|
+
quantity,
|
|
632
|
+
makeElement('span', 'total', {}, [formatQty(options.cumulative)]),
|
|
633
|
+
makeElement('span', 'bar', {}, []),
|
|
634
|
+
]);
|
|
635
|
+
row.style.setProperty('--t-ob-bar', `${options.barPct.toFixed(0)}%`);
|
|
636
|
+
row.style.setProperty('--t-ob-heat', `${options.heatPct.toFixed(1)}%`);
|
|
637
|
+
if (options.ownQuantity)
|
|
638
|
+
row.classList.add('own');
|
|
639
|
+
const previous = this._prevQuantities.get(options.key);
|
|
640
|
+
if (previous !== undefined && previous !== level.quantity)
|
|
641
|
+
row.classList.add(level.quantity > previous ? options.growthClass : options.shrinkClass);
|
|
642
|
+
options.painted.set(options.key, level.quantity);
|
|
643
|
+
return row;
|
|
644
|
+
}
|
|
645
|
+
// This session's resting orders on this symbol, summed per price — the "your
|
|
646
|
+
// size is here" badge is an overlay on the ledger, so it reads the client's
|
|
647
|
+
// own order cache rather than subscribing to anything.
|
|
648
|
+
//
|
|
649
|
+
// Prices are keyed as fixed-point text: two floats that print the same are
|
|
650
|
+
// the same level to a user, and `===` on them is not.
|
|
651
|
+
_ownQuantities() {
|
|
652
|
+
const bids = new Map();
|
|
653
|
+
const asks = new Map();
|
|
654
|
+
if (!this._currentSymbol)
|
|
655
|
+
return { bids, asks };
|
|
656
|
+
for (const order of this._host.trading.marketData.getOrders() || []) {
|
|
657
|
+
if (!order || order.instrument !== this._currentSymbol)
|
|
658
|
+
continue;
|
|
659
|
+
if (order.status !== RESTING_STATE)
|
|
660
|
+
continue;
|
|
661
|
+
if (order.limitPrice == null || order.side == null)
|
|
662
|
+
continue;
|
|
663
|
+
const side = this._host.presentation.isBuy(order.side) ? bids : asks;
|
|
664
|
+
const key = OrderBookWidget._priceKey(order.limitPrice);
|
|
665
|
+
const left = Number(order.balance ?? order.quantity) || 0;
|
|
666
|
+
side.set(key, (side.get(key) || 0) + left);
|
|
667
|
+
}
|
|
668
|
+
return { bids, asks };
|
|
669
|
+
}
|
|
670
|
+
static _priceKey(price) {
|
|
671
|
+
return Number(price).toFixed(8);
|
|
672
|
+
}
|
|
673
|
+
// The bid/ask split under the ladder, as one measurement the stylesheet
|
|
674
|
+
// turns into two widths.
|
|
675
|
+
//
|
|
676
|
+
// It sums the VISIBLE levels only, so the figure moves when the user changes
|
|
677
|
+
// depth. A whole-book share needs the server to aggregate one, which the
|
|
678
|
+
// frame does not carry today.
|
|
679
|
+
_paintSentiment(bids, asks) {
|
|
680
|
+
if (!this._sentimentEl)
|
|
681
|
+
return;
|
|
682
|
+
const bidSum = bids.reduce((sum, level) => sum + level.quantity, 0);
|
|
683
|
+
const askSum = asks.reduce((sum, level) => sum + level.quantity, 0);
|
|
684
|
+
const total = bidSum + askSum;
|
|
685
|
+
if (total <= 0) {
|
|
686
|
+
this._sentimentEl.style.setProperty('--t-ob-sent', '50%');
|
|
687
|
+
if (this._sentBidPctEl)
|
|
688
|
+
this._sentBidPctEl.textContent = '--';
|
|
689
|
+
if (this._sentAskPctEl)
|
|
690
|
+
this._sentAskPctEl.textContent = '--';
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
// Rounded so the two halves sum to exactly 100 — a strip reading 99% is
|
|
694
|
+
// read as a bug rather than as rounding.
|
|
695
|
+
const askPct = Math.round((askSum / total) * 100);
|
|
696
|
+
const bidPct = 100 - askPct;
|
|
697
|
+
this._sentimentEl.style.setProperty('--t-ob-sent', `${bidPct}%`);
|
|
698
|
+
if (this._sentBidPctEl)
|
|
699
|
+
this._sentBidPctEl.textContent = `${bidPct}%`;
|
|
700
|
+
if (this._sentAskPctEl)
|
|
701
|
+
this._sentAskPctEl.textContent = `${askPct}%`;
|
|
702
|
+
}
|
|
703
|
+
// The depth curve above the ladder. Everything shaped is in
|
|
704
|
+
// `orderbook-depth.ts`; what is left here is the canvas plumbing and the
|
|
705
|
+
// host's palette, because a canvas takes colours as values and a class name
|
|
706
|
+
// cannot reach one.
|
|
707
|
+
_paintDepthChart(bids, asks) {
|
|
708
|
+
const canvas = this.depthChartEl;
|
|
709
|
+
if (!canvas || !this._showDepthChart)
|
|
710
|
+
return;
|
|
711
|
+
const ratio = Number(this._deps.pixelRatio()) || 1;
|
|
712
|
+
const cssWidth = canvas.clientWidth || 0;
|
|
713
|
+
const cssHeight = canvas.clientHeight || 0;
|
|
714
|
+
if (cssWidth < 4 || cssHeight < 4)
|
|
715
|
+
return;
|
|
716
|
+
const width = Math.floor(cssWidth * ratio);
|
|
717
|
+
const height = Math.floor(cssHeight * ratio);
|
|
718
|
+
if (canvas.width !== width)
|
|
719
|
+
canvas.width = width;
|
|
720
|
+
if (canvas.height !== height)
|
|
721
|
+
canvas.height = height;
|
|
722
|
+
const ctx = canvas.getContext('2d');
|
|
723
|
+
if (!ctx)
|
|
724
|
+
return;
|
|
725
|
+
ctx.clearRect(0, 0, width, height);
|
|
726
|
+
if (bids.length === 0 && asks.length === 0)
|
|
727
|
+
return;
|
|
728
|
+
const pad = 2 * ratio;
|
|
729
|
+
const midX = Math.floor(width / 2);
|
|
730
|
+
const geometry = {
|
|
731
|
+
midX,
|
|
732
|
+
pad,
|
|
733
|
+
halfWidth: midX - pad,
|
|
734
|
+
baseY: height - pad,
|
|
735
|
+
innerHeight: height - pad * 2,
|
|
736
|
+
};
|
|
737
|
+
const bidSide = depthSide(bids, -1, geometry);
|
|
738
|
+
const askSide = depthSide(asks, 1, geometry);
|
|
739
|
+
const maxTotal = Math.max(bidSide ? bidSide.total : 0, askSide ? askSide.total : 0, 1);
|
|
740
|
+
const palette = this._host.presentation.canvasPalette();
|
|
741
|
+
if (bidSide)
|
|
742
|
+
this._strokeDepthSide(ctx, depthPolyline(bidSide, maxTotal, geometry), palette.up, geometry, ratio);
|
|
743
|
+
if (askSide)
|
|
744
|
+
this._strokeDepthSide(ctx, depthPolyline(askSide, maxTotal, geometry), palette.down, geometry, ratio);
|
|
745
|
+
// The mid line, so the two halves read as two sides of one price rather
|
|
746
|
+
// than as two charts.
|
|
747
|
+
ctx.globalAlpha = 0.4;
|
|
748
|
+
ctx.fillStyle = palette.grid;
|
|
749
|
+
ctx.fillRect(midX - Math.max(1, ratio / 2), 0, Math.max(1, ratio), height);
|
|
750
|
+
ctx.globalAlpha = 1;
|
|
751
|
+
}
|
|
752
|
+
// Area under the step curve, then the curve on top. Opacity is varied here
|
|
753
|
+
// rather than in the colour, so the host states one solid colour per
|
|
754
|
+
// direction and this decides how much of it a fill is worth.
|
|
755
|
+
_strokeDepthSide(ctx, line, color, geometry, ratio) {
|
|
756
|
+
if (line.length === 0)
|
|
757
|
+
return;
|
|
758
|
+
ctx.beginPath();
|
|
759
|
+
ctx.moveTo(line[0][0], geometry.baseY);
|
|
760
|
+
for (const [x, y] of line)
|
|
761
|
+
ctx.lineTo(x, y);
|
|
762
|
+
ctx.lineTo(line[line.length - 1][0], geometry.baseY);
|
|
763
|
+
ctx.closePath();
|
|
764
|
+
ctx.globalAlpha = 0.18;
|
|
765
|
+
ctx.fillStyle = color;
|
|
766
|
+
ctx.fill();
|
|
767
|
+
ctx.beginPath();
|
|
768
|
+
ctx.moveTo(line[0][0], line[0][1]);
|
|
769
|
+
for (let i = 1; i < line.length; i++)
|
|
770
|
+
ctx.lineTo(line[i][0], line[i][1]);
|
|
771
|
+
ctx.lineWidth = Math.max(1.5, 1.5 * ratio);
|
|
772
|
+
ctx.lineJoin = 'miter';
|
|
773
|
+
ctx.globalAlpha = 0.95;
|
|
774
|
+
ctx.strokeStyle = color;
|
|
775
|
+
ctx.stroke();
|
|
776
|
+
ctx.globalAlpha = 1;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
OrderBookWidget.TYPE = ControlTypes.OrderBook;
|
|
780
|
+
/// The depths the ladder offers, in the order the header shows them.
|
|
781
|
+
OrderBookWidget.DEPTHS = [5, 10];
|
|
782
|
+
/// Persisted depth for the instance that follows the host's active symbol.
|
|
783
|
+
/// Pinned instances carry their depth in their own per-panel state instead,
|
|
784
|
+
/// which is what makes two ladders on one page able to differ.
|
|
785
|
+
OrderBookWidget.DEPTH_KEY = 'terminal.obDepth';
|
|
786
|
+
/// Persisted row layout for the follows-active instance.
|
|
787
|
+
OrderBookWidget.VIEW_KEY = 'terminal.obView';
|
|
788
|
+
/// Persisted "bids above the mid line" flag for the follows-active instance.
|
|
789
|
+
OrderBookWidget.INVERT_KEY = 'terminal.obInvert';
|
|
790
|
+
/// Persisted depth-chart visibility for the follows-active instance.
|
|
791
|
+
OrderBookWidget.DEPTHCHART_KEY = 'terminal.obDepthChart';
|
|
792
|
+
//# sourceMappingURL=orderbook-widget.js.map
|