@forgecharts/sdk 1.1.23
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/package.json +50 -0
- package/src/__tests__/backwardCompatibility.test.ts +191 -0
- package/src/__tests__/candleInvariant.test.ts +500 -0
- package/src/__tests__/public-api-surface.ts +76 -0
- package/src/__tests__/timeframeBoundary.test.ts +583 -0
- package/src/api/DrawingManager.ts +188 -0
- package/src/api/EventBus.ts +53 -0
- package/src/api/IndicatorDAG.ts +389 -0
- package/src/api/IndicatorRegistry.ts +47 -0
- package/src/api/LayoutManager.ts +72 -0
- package/src/api/PaneManager.ts +129 -0
- package/src/api/ReferenceAPI.ts +195 -0
- package/src/api/TChart.ts +881 -0
- package/src/api/createChart.ts +43 -0
- package/src/api/drawing tools/fib gann menu/fibRetracement.ts +27 -0
- package/src/api/drawing tools/lines menu/crossLine.ts +21 -0
- package/src/api/drawing tools/lines menu/disjointChannel.ts +74 -0
- package/src/api/drawing tools/lines menu/extendedLine.ts +22 -0
- package/src/api/drawing tools/lines menu/flatTopBottom.ts +45 -0
- package/src/api/drawing tools/lines menu/horizontal.ts +24 -0
- package/src/api/drawing tools/lines menu/horizontalRay.ts +25 -0
- package/src/api/drawing tools/lines menu/infoLine.ts +127 -0
- package/src/api/drawing tools/lines menu/insidePitchfork.ts +21 -0
- package/src/api/drawing tools/lines menu/modifiedSchiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/parallelChannel.ts +47 -0
- package/src/api/drawing tools/lines menu/pitchfork.ts +15 -0
- package/src/api/drawing tools/lines menu/ray.ts +28 -0
- package/src/api/drawing tools/lines menu/regressionTrend.ts +157 -0
- package/src/api/drawing tools/lines menu/schiffPitchfork.ts +18 -0
- package/src/api/drawing tools/lines menu/trendAngle.ts +64 -0
- package/src/api/drawing tools/lines menu/trendline.ts +16 -0
- package/src/api/drawing tools/lines menu/vertical.ts +16 -0
- package/src/api/drawing tools/pointers menu/crosshair.ts +17 -0
- package/src/api/drawing tools/pointers menu/cursor.ts +16 -0
- package/src/api/drawing tools/pointers menu/demonstration.ts +35 -0
- package/src/api/drawing tools/pointers menu/dot.ts +26 -0
- package/src/api/drawing tools/shapes menu/rectangle.ts +24 -0
- package/src/api/drawing tools/shapes menu/text.ts +30 -0
- package/src/api/drawingUtils.ts +82 -0
- package/src/core/CanvasLayer.ts +77 -0
- package/src/core/Chart.ts +917 -0
- package/src/core/CoordTransform.ts +282 -0
- package/src/core/Crosshair.ts +207 -0
- package/src/core/IndicatorEngine.ts +216 -0
- package/src/core/InteractionManager.ts +899 -0
- package/src/core/PriceScale.ts +133 -0
- package/src/core/Series.ts +132 -0
- package/src/core/TimeScale.ts +175 -0
- package/src/datafeed/DatafeedConnector.ts +300 -0
- package/src/engine/CandleEngine.ts +458 -0
- package/src/engine/__tests__/CandleEngine.test.ts +402 -0
- package/src/engine/candleInvariants.ts +172 -0
- package/src/engine/mergeUtils.ts +93 -0
- package/src/engine/timeframeUtils.ts +118 -0
- package/src/index.ts +190 -0
- package/src/internal.ts +41 -0
- package/src/licensing/ChartRuntimeResolver.ts +380 -0
- package/src/licensing/LicenseManager.ts +131 -0
- package/src/licensing/__tests__/ChartRuntimeResolver.test.ts +207 -0
- package/src/licensing/__tests__/LicenseManager.test.ts +180 -0
- package/src/licensing/licenseTypes.ts +19 -0
- package/src/pine/PineCompiler.ts +68 -0
- package/src/pine/diagnostics.ts +30 -0
- package/src/pine/index.ts +7 -0
- package/src/pine/pine-ast.ts +163 -0
- package/src/pine/pine-lexer.ts +265 -0
- package/src/pine/pine-parser.ts +439 -0
- package/src/pine/pine-transpiler.ts +301 -0
- package/src/pixi/LayerName.ts +35 -0
- package/src/pixi/PixiCandlestickRenderer.ts +125 -0
- package/src/pixi/PixiChart.ts +425 -0
- package/src/pixi/PixiCrosshairRenderer.ts +134 -0
- package/src/pixi/PixiDrawingRenderer.ts +121 -0
- package/src/pixi/PixiGridRenderer.ts +136 -0
- package/src/pixi/PixiLayerManager.ts +102 -0
- package/src/renderers/CandlestickRenderer.ts +130 -0
- package/src/renderers/HistogramRenderer.ts +63 -0
- package/src/renderers/LineRenderer.ts +77 -0
- package/src/theme/colors.ts +21 -0
- package/src/tools/barDivergenceCheck.ts +305 -0
- package/src/trading/TradingOverlayStore.ts +161 -0
- package/src/trading/UnmanagedIngestion.ts +156 -0
- package/src/trading/__tests__/ManagedTradingController.test.ts +338 -0
- package/src/trading/__tests__/TradingOverlayStore.test.ts +323 -0
- package/src/trading/__tests__/UnmanagedIngestion.test.ts +205 -0
- package/src/trading/managed/ManagedTradingController.ts +292 -0
- package/src/trading/managed/managedCapabilities.ts +98 -0
- package/src/trading/managed/managedTypes.ts +151 -0
- package/src/trading/tradingTypes.ts +135 -0
- package/src/tscript/TScriptIndicator.ts +54 -0
- package/src/tscript/ast.ts +105 -0
- package/src/tscript/lexer.ts +190 -0
- package/src/tscript/parser.ts +334 -0
- package/src/tscript/runtime.ts +525 -0
- package/src/tscript/series.ts +84 -0
- package/src/types/IChart.ts +56 -0
- package/src/types/IRenderer.ts +16 -0
- package/src/types/ISeries.ts +30 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +15 -0
- package/vitest.config.ts +25 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CandleEngine — the single source of truth for live candle data.
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
*
|
|
6
|
+
* WebSocket feed
|
|
7
|
+
* ↓
|
|
8
|
+
* Market Data Normalizer (normalizeOhlcv — handles strings, ms vs s)
|
|
9
|
+
* ↓
|
|
10
|
+
* CandleEngine (applyCandleUpdate — O(1) update path)
|
|
11
|
+
* ↓
|
|
12
|
+
* Chart Update Adapter (onBarUpdated / onResync callbacks)
|
|
13
|
+
* ↓
|
|
14
|
+
* Chart Library (series.update / series.setData)
|
|
15
|
+
*
|
|
16
|
+
* Guarantees:
|
|
17
|
+
* - open is never overwritten on an existing live candle
|
|
18
|
+
* - high can only increase within a candle
|
|
19
|
+
* - low can only decrease within a candle
|
|
20
|
+
* - close updates on every tick
|
|
21
|
+
* - out-of-order ticks are silently discarded
|
|
22
|
+
* - gaps are detected and reported for async REST backfill
|
|
23
|
+
* - the engine is framework-agnostic (no DOM, React, or chart-lib imports)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Timeframe } from '@forgecharts/types';
|
|
27
|
+
import { timeframeToMs, getBucketStart, getCalendarBucketStart } from './timeframeUtils';
|
|
28
|
+
import { getMissingBarCount, mergeBars } from './mergeUtils';
|
|
29
|
+
import {
|
|
30
|
+
assertOhlcRelationships,
|
|
31
|
+
assertBucketAlignment,
|
|
32
|
+
assertNotFinalized,
|
|
33
|
+
assertOpenImmutability,
|
|
34
|
+
assertNoDuplicateAppend,
|
|
35
|
+
assertFinalizedUnchanged,
|
|
36
|
+
} from './candleInvariants';
|
|
37
|
+
|
|
38
|
+
// ─── Public types ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalised candle shape used throughout the engine.
|
|
42
|
+
*
|
|
43
|
+
* `timeMs` — millisecond timestamp (used for bucket identity comparisons)
|
|
44
|
+
* `time` — second timestamp (used for chart library rendering)
|
|
45
|
+
*/
|
|
46
|
+
export interface CandleBar {
|
|
47
|
+
/** Candle open-time in milliseconds — the primary bucket key. */
|
|
48
|
+
timeMs: number;
|
|
49
|
+
/** Candle open-time in seconds — passed directly to the chart library. */
|
|
50
|
+
time: number;
|
|
51
|
+
open: number;
|
|
52
|
+
high: number;
|
|
53
|
+
low: number;
|
|
54
|
+
close: number;
|
|
55
|
+
volume: number;
|
|
56
|
+
/** True once the next candle has started and this bar is fully formed. */
|
|
57
|
+
isClosed: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Raw OHLCV input accepted by the engine.
|
|
62
|
+
* Values may be number or numeric string; `time` may be milliseconds or seconds.
|
|
63
|
+
*/
|
|
64
|
+
export interface RawOHLCV {
|
|
65
|
+
time: number | string;
|
|
66
|
+
open: number | string;
|
|
67
|
+
high: number | string;
|
|
68
|
+
low: number | string;
|
|
69
|
+
close: number | string;
|
|
70
|
+
volume?: number | string;
|
|
71
|
+
isClosed?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Describes what the engine did with a live update. */
|
|
75
|
+
export type UpdateResult =
|
|
76
|
+
| { type: 'update'; bar: CandleBar }
|
|
77
|
+
| { type: 'append'; bar: CandleBar; previousBarClosed: boolean }
|
|
78
|
+
| { type: 'gap'; bar: CandleBar; missingCount: number }
|
|
79
|
+
| { type: 'ignore' };
|
|
80
|
+
|
|
81
|
+
/** Gap information emitted via `onGapDetected`. */
|
|
82
|
+
export interface GapInfo {
|
|
83
|
+
fromTimeMs: number;
|
|
84
|
+
toTimeMs: number;
|
|
85
|
+
missingCount: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CandleEngineOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Enable structured debug logging to the console.
|
|
91
|
+
* Log every incoming tick with action, timestamps, and resulting OHLCV.
|
|
92
|
+
*/
|
|
93
|
+
debug?: boolean;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fired when the live stream skips one or more candle buckets.
|
|
97
|
+
* Use this to trigger an async REST backfill; do not block the live stream.
|
|
98
|
+
*/
|
|
99
|
+
onGapDetected?: (info: GapInfo) => void;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fired after every bar mutation (update or append).
|
|
103
|
+
* The chart adapter should call `series.update(bar)` here.
|
|
104
|
+
*/
|
|
105
|
+
onBarUpdated?: (bar: CandleBar, result: UpdateResult) => void;
|
|
106
|
+
|
|
107
|
+
/** Fired when a candle is finalised — the next candle's first tick arrived. */
|
|
108
|
+
onBarClosed?: (bar: CandleBar) => void;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fired after the full bar array changes (backfillGap / handleReconnect).
|
|
112
|
+
* The chart adapter should call `series.setData(bars)` here to fully resync.
|
|
113
|
+
*/
|
|
114
|
+
onResync?: (bars: readonly CandleBar[]) => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Internal normalizer ─────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalise a raw OHLCV object into a CandleBar.
|
|
121
|
+
*
|
|
122
|
+
* - Coerces all fields to `number` (handles numeric strings from the feed).
|
|
123
|
+
* - Auto-detects ms vs s timestamps: values > 1e12 are treated as milliseconds.
|
|
124
|
+
* - Aligns `timeMs` to the correct candle-open boundary, with calendar
|
|
125
|
+
* alignment for `1w` (Monday) and `1M` (first of month).
|
|
126
|
+
*
|
|
127
|
+
* Never use `Date.now()` for candle time — always use the feed-provided value.
|
|
128
|
+
*/
|
|
129
|
+
function normalizeOhlcv(raw: RawOHLCV, intervalMs: number, tf: string): CandleBar {
|
|
130
|
+
const rawTime = Number(raw.time);
|
|
131
|
+
|
|
132
|
+
// Auto-detect: Unix ms timestamps are > 1e12 (year 2001+).
|
|
133
|
+
// If the value is smaller, treat it as seconds and convert.
|
|
134
|
+
const timeMs = rawTime > 1e12 ? rawTime : rawTime * 1000;
|
|
135
|
+
|
|
136
|
+
// Calendar-aware bucket alignment (handles 1w/1M specially)
|
|
137
|
+
const bucketMs = getCalendarBucketStart(timeMs, tf);
|
|
138
|
+
const timeSec = Math.floor(bucketMs / 1000);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
timeMs: bucketMs,
|
|
142
|
+
time: timeSec,
|
|
143
|
+
open: Number(raw.open),
|
|
144
|
+
high: Number(raw.high),
|
|
145
|
+
low: Number(raw.low),
|
|
146
|
+
close: Number(raw.close),
|
|
147
|
+
volume: Number(raw.volume ?? 0),
|
|
148
|
+
isClosed: raw.isClosed ?? false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── CandleEngine ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export class CandleEngine {
|
|
155
|
+
/** Authoritative ordered bar array. Only the rightmost bar may be open. */
|
|
156
|
+
private _bars: CandleBar[] = [];
|
|
157
|
+
private _timeframe: Timeframe = '1m';
|
|
158
|
+
private _intervalMs: number = 60_000;
|
|
159
|
+
private readonly _opts: CandleEngineOptions;
|
|
160
|
+
|
|
161
|
+
constructor(options: CandleEngineOptions = {}) {
|
|
162
|
+
this._opts = options;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Initialisation ──────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Seed the engine with historical bars and set the active timeframe.
|
|
169
|
+
* All existing bars are replaced.
|
|
170
|
+
*
|
|
171
|
+
* Historical bars loaded from REST are assumed closed, except possibly
|
|
172
|
+
* the last one (which may represent the still-forming candle).
|
|
173
|
+
*
|
|
174
|
+
* @param history Raw bars from a REST endpoint (oldest → newest).
|
|
175
|
+
* @param timeframe The active timeframe label (e.g. '1m', '1h').
|
|
176
|
+
*/
|
|
177
|
+
initialize(history: RawOHLCV[], timeframe: Timeframe): void {
|
|
178
|
+
this._timeframe = timeframe;
|
|
179
|
+
this._intervalMs = timeframeToMs(timeframe);
|
|
180
|
+
|
|
181
|
+
// Normalise, deduplicate by timeMs (latest entry wins), sort newest-last
|
|
182
|
+
const seen = new Map<number, CandleBar>();
|
|
183
|
+
for (const raw of history) {
|
|
184
|
+
const bar = normalizeOhlcv(raw, this._intervalMs, this._timeframe);
|
|
185
|
+
seen.set(bar.timeMs, bar);
|
|
186
|
+
}
|
|
187
|
+
this._bars = Array.from(seen.values()).sort((a, b) => a.timeMs - b.timeMs);
|
|
188
|
+
|
|
189
|
+
// Mark all bars except the last as closed
|
|
190
|
+
for (let i = 0; i < this._bars.length - 1; i++) {
|
|
191
|
+
this._bars[i]!.isClosed = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this._debugLog('initialize', {
|
|
195
|
+
timeframe,
|
|
196
|
+
intervalMs: this._intervalMs,
|
|
197
|
+
barCount: this._bars.length,
|
|
198
|
+
firstTime: this._bars[0]?.timeMs,
|
|
199
|
+
lastTime: this._bars[this._bars.length - 1]?.timeMs,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Live updates ────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Process a single live OHLCV tick from the WebSocket feed.
|
|
207
|
+
*
|
|
208
|
+
* Algorithm (mirrors TradingView / Binance candle engine behaviour):
|
|
209
|
+
*
|
|
210
|
+
* 1. Normalise: coerce strings to numbers, detect ms vs s, align to bucket.
|
|
211
|
+
* 2. Empty array → append as first bar.
|
|
212
|
+
* 3. incoming.timeMs === last.timeMs → SAME CANDLE (update in-place):
|
|
213
|
+
* open = unchanged (first tick's open is authoritative)
|
|
214
|
+
* high = max(last.high, inc.high, inc.close)
|
|
215
|
+
* low = min(last.low, inc.low, inc.close)
|
|
216
|
+
* close = inc.close
|
|
217
|
+
* volume = inc.volume
|
|
218
|
+
* 4. incoming.timeMs > last.timeMs → NEW CANDLE:
|
|
219
|
+
* - Detect and report any gap (missing buckets between last and incoming).
|
|
220
|
+
* - Mark last bar as closed, fire onBarClosed.
|
|
221
|
+
* - Append incoming as a fresh live candle.
|
|
222
|
+
* 5. incoming.timeMs < last.timeMs → OUT-OF-ORDER: discard silently.
|
|
223
|
+
*
|
|
224
|
+
* @returns UpdateResult indicating what was done — the chart adapter uses
|
|
225
|
+
* this to choose between series.update() and series.setData().
|
|
226
|
+
*/
|
|
227
|
+
applyLiveUpdate(raw: RawOHLCV): UpdateResult {
|
|
228
|
+
const incoming = normalizeOhlcv(raw, this._intervalMs, this._timeframe);
|
|
229
|
+
|
|
230
|
+
// Invariant: incoming bar must be bucket-aligned and OHLC-valid
|
|
231
|
+
assertBucketAlignment(incoming, this._timeframe, 'applyLiveUpdate:normalize');
|
|
232
|
+
assertOhlcRelationships(incoming, 'applyLiveUpdate:normalize');
|
|
233
|
+
|
|
234
|
+
// ── First bar ever ─────────────────────────────────────────────────────
|
|
235
|
+
if (this._bars.length === 0) {
|
|
236
|
+
this._bars.push(incoming);
|
|
237
|
+
const result: UpdateResult = { type: 'append', bar: incoming, previousBarClosed: false };
|
|
238
|
+
this._debugLog('append:first', incoming);
|
|
239
|
+
this._opts.onBarUpdated?.(incoming, result);
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const last = this._bars[this._bars.length - 1]!;
|
|
244
|
+
|
|
245
|
+
// ── Same candle: update in-place (O(1)) ────────────────────────────────
|
|
246
|
+
if (incoming.timeMs === last.timeMs) {
|
|
247
|
+
// Invariant: must not mutate a finalized candle
|
|
248
|
+
assertNotFinalized(last, 'applyLiveUpdate:same-bucket');
|
|
249
|
+
|
|
250
|
+
const prevOpen = last.open;
|
|
251
|
+
const prevCount = this._bars.length;
|
|
252
|
+
|
|
253
|
+
// open is intentionally not updated — the first tick's open is correct
|
|
254
|
+
last.high = Math.max(last.high, incoming.high, incoming.close);
|
|
255
|
+
last.low = Math.min(last.low, incoming.low, incoming.close);
|
|
256
|
+
last.close = incoming.close;
|
|
257
|
+
last.volume = incoming.volume;
|
|
258
|
+
|
|
259
|
+
// Invariant: open must be unchanged, no spurious append must have occurred
|
|
260
|
+
assertOpenImmutability(prevOpen, last, 'applyLiveUpdate:same-bucket');
|
|
261
|
+
assertOhlcRelationships(last, 'applyLiveUpdate:same-bucket');
|
|
262
|
+
assertNoDuplicateAppend(prevCount, this._bars.length, 'applyLiveUpdate:same-bucket');
|
|
263
|
+
|
|
264
|
+
const result: UpdateResult = { type: 'update', bar: last };
|
|
265
|
+
this._debugLog('update', last);
|
|
266
|
+
this._opts.onBarUpdated?.(last, result);
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Out-of-order: older than the current live candle ──────────────────
|
|
271
|
+
if (incoming.timeMs < last.timeMs) {
|
|
272
|
+
this._debugLog('ignore', {
|
|
273
|
+
incomingTimeMs: incoming.timeMs,
|
|
274
|
+
lastTimeMs: last.timeMs,
|
|
275
|
+
delta: last.timeMs - incoming.timeMs,
|
|
276
|
+
});
|
|
277
|
+
return { type: 'ignore' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── New candle (incoming.timeMs > last.timeMs) ─────────────────────────
|
|
281
|
+
|
|
282
|
+
// Gap detection: how many candle buckets are missing between last and new?
|
|
283
|
+
const missingCount = getMissingBarCount(last.timeMs, incoming.timeMs, this._intervalMs);
|
|
284
|
+
if (missingCount > 0) {
|
|
285
|
+
const gapInfo: GapInfo = { fromTimeMs: last.timeMs, toTimeMs: incoming.timeMs, missingCount };
|
|
286
|
+
this._debugLog('gap', gapInfo);
|
|
287
|
+
this._opts.onGapDetected?.(gapInfo);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Finalise the previous live candle
|
|
291
|
+
const prevLastBar = { ...last }; // snapshot for post-append invariant check
|
|
292
|
+
last.isClosed = true;
|
|
293
|
+
this._opts.onBarClosed?.(last);
|
|
294
|
+
|
|
295
|
+
// Append the new candle
|
|
296
|
+
this._bars.push(incoming);
|
|
297
|
+
|
|
298
|
+
// Invariant: previous bar must be closed and not mutated after the append
|
|
299
|
+
assertFinalizedUnchanged(prevLastBar, this._bars, 'applyLiveUpdate:new-bucket');
|
|
300
|
+
assertOhlcRelationships(incoming, 'applyLiveUpdate:new-bucket');
|
|
301
|
+
|
|
302
|
+
const result: UpdateResult = missingCount > 0
|
|
303
|
+
? { type: 'gap', bar: incoming, missingCount }
|
|
304
|
+
: { type: 'append', bar: incoming, previousBarClosed: true };
|
|
305
|
+
|
|
306
|
+
this._debugLog(result.type, incoming);
|
|
307
|
+
this._opts.onBarUpdated?.(incoming, result);
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Gap backfill ────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Merge REST-fetched bars into the engine to fill a detected gap.
|
|
315
|
+
*
|
|
316
|
+
* Incoming bars are treated as authoritative closed candles.
|
|
317
|
+
* The current live candle is preserved if present.
|
|
318
|
+
*
|
|
319
|
+
* After calling this, `onResync` fires — the chart adapter must call
|
|
320
|
+
* `series.setData(engine.getBars())` to fully resync the chart.
|
|
321
|
+
*
|
|
322
|
+
* @param rawBars Historical bars from the REST endpoint covering the gap.
|
|
323
|
+
*/
|
|
324
|
+
backfillGap(rawBars: RawOHLCV[]): void {
|
|
325
|
+
const incoming = rawBars.map(r => {
|
|
326
|
+
const bar = normalizeOhlcv(r, this._intervalMs, this._timeframe);
|
|
327
|
+
bar.isClosed = true; // REST bars are closed and authoritative
|
|
328
|
+
return bar;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Preserve the live candle's open state after merge
|
|
332
|
+
const liveBefore = this._bars[this._bars.length - 1];
|
|
333
|
+
|
|
334
|
+
this._bars = mergeBars(this._bars, incoming);
|
|
335
|
+
|
|
336
|
+
// Re-apply live candle's isClosed=false if it survived the merge
|
|
337
|
+
if (liveBefore && !liveBefore.isClosed) {
|
|
338
|
+
const last = this._bars[this._bars.length - 1];
|
|
339
|
+
if (last && last.timeMs === liveBefore.timeMs) {
|
|
340
|
+
last.isClosed = false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
this._debugLog('backfillGap', { filled: rawBars.length, totalBars: this._bars.length });
|
|
345
|
+
this._opts.onResync?.(this._bars);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Reconnect reconciliation ────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Reconcile after a WebSocket reconnect.
|
|
352
|
+
*
|
|
353
|
+
* Fetch a rolling overlap window from REST (e.g. last 50 bars or 2 hours)
|
|
354
|
+
* and pass them here. The engine merges them with the existing bar array,
|
|
355
|
+
* correcting any candles that were missed or corrupted during the outage.
|
|
356
|
+
*
|
|
357
|
+
* After reconciliation, `onResync` fires so the chart adapter can resync
|
|
358
|
+
* the chart series with `series.setData(engine.getBars())`.
|
|
359
|
+
*
|
|
360
|
+
* @param recentBars REST bars covering the reconnect overlap window.
|
|
361
|
+
*/
|
|
362
|
+
handleReconnect(recentBars: RawOHLCV[]): void {
|
|
363
|
+
const incoming = recentBars.map(r => normalizeOhlcv(r, this._intervalMs, this._timeframe));
|
|
364
|
+
this._bars = mergeBars(this._bars, incoming);
|
|
365
|
+
this._debugLog('handleReconnect', { count: recentBars.length, totalBars: this._bars.length });
|
|
366
|
+
this._opts.onResync?.(this._bars);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Trade-tick stub ─────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Stub for future tick/trade-based candle construction.
|
|
373
|
+
*
|
|
374
|
+
* When implemented, this will:
|
|
375
|
+
* - Assign the trade to its bucket using `getBucketStart(timeMs, intervalMs)`.
|
|
376
|
+
* - Create a new candle if the bucket is absent.
|
|
377
|
+
* - Update OHLCV: open/close from trade price, high/low expanded, volume summed.
|
|
378
|
+
*
|
|
379
|
+
* Not yet implemented — structure is in place for future extension.
|
|
380
|
+
*/
|
|
381
|
+
applyTradeTick(
|
|
382
|
+
_trade: { time: number | string; price: number | string; size?: number | string },
|
|
383
|
+
): void {
|
|
384
|
+
// TODO: implement tick-based candle construction
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Read access ─────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Returns a readonly view of the authoritative bar array.
|
|
391
|
+
* Do not mutate — call `applyLiveUpdate`, `backfillGap`, etc. instead.
|
|
392
|
+
*/
|
|
393
|
+
getBars(): readonly CandleBar[] {
|
|
394
|
+
return this._bars;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Returns the most recent (possibly still live) bar, or null if empty. */
|
|
398
|
+
getLastBar(): CandleBar | null {
|
|
399
|
+
return this._bars[this._bars.length - 1] ?? null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Prepend older historical bars fetched during a lazy scroll-left page load.
|
|
404
|
+
* Only bars older than the current oldest bar are accepted; duplicates and
|
|
405
|
+
* out-of-range bars are silently dropped.
|
|
406
|
+
* Fires `onResync` so the chart adapter updates the series with `setData`.
|
|
407
|
+
*
|
|
408
|
+
* @param rawBars Historical bars from the REST endpoint, oldest → newest.
|
|
409
|
+
*/
|
|
410
|
+
prependHistory(rawBars: RawOHLCV[]): void {
|
|
411
|
+
const oldest = this._bars[0];
|
|
412
|
+
const cutoff = oldest ? oldest.timeMs : Infinity;
|
|
413
|
+
|
|
414
|
+
const incoming = rawBars
|
|
415
|
+
.map(r => {
|
|
416
|
+
const bar = normalizeOhlcv(r, this._intervalMs, this._timeframe);
|
|
417
|
+
bar.isClosed = true;
|
|
418
|
+
return bar;
|
|
419
|
+
})
|
|
420
|
+
.filter(bar => bar.timeMs < cutoff);
|
|
421
|
+
|
|
422
|
+
if (incoming.length === 0) return;
|
|
423
|
+
|
|
424
|
+
incoming.sort((a, b) => a.timeMs - b.timeMs);
|
|
425
|
+
this._bars = [...incoming, ...this._bars];
|
|
426
|
+
|
|
427
|
+
this._debugLog('prependHistory', {
|
|
428
|
+
prepended: incoming.length,
|
|
429
|
+
firstTime: this._bars[0]?.timeMs,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
this._opts.onResync?.(this._bars);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Clear all state.
|
|
437
|
+
* Call before switching symbol or timeframe, or on a hard disconnect.
|
|
438
|
+
*/
|
|
439
|
+
reset(): void {
|
|
440
|
+
this._bars = [];
|
|
441
|
+
this._debugLog('reset', { timeframe: this._timeframe });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Private helpers ──────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
private _debugLog(action: string, data: object): void {
|
|
447
|
+
if (!this._opts.debug) return;
|
|
448
|
+
console.debug('[CANDLE_ENGINE]', {
|
|
449
|
+
action,
|
|
450
|
+
timeframe: this._timeframe,
|
|
451
|
+
barCount: this._bars.length,
|
|
452
|
+
liveBar: this._bars[this._bars.length - 1]
|
|
453
|
+
? { open: this._bars[this._bars.length - 1]!.open, high: this._bars[this._bars.length - 1]!.high, low: this._bars[this._bars.length - 1]!.low, close: this._bars[this._bars.length - 1]!.close }
|
|
454
|
+
: null,
|
|
455
|
+
...data,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|