File size: 51,805 Bytes
11f6676 1af30de 069e91a 1af30de 7d92bb6 1af30de 11f6676 c7d3d86 11f6676 1af30de 11f6676 1af30de 11f6676 7d92bb6 11f6676 1af30de 11f6676 1af30de 7d92bb6 1af30de 11f6676 069e91a 11f6676 069e91a 11f6676 1af30de b2a48d6 1af30de 7d92bb6 1af30de 11f6676 7d92bb6 11f6676 1af30de 11f6676 1af30de 069e91a 1af30de 069e91a 1af30de 11f6676 1af30de 11f6676 1af30de 069e91a 1af30de 11f6676 1af30de 069e91a 1af30de 069e91a 1af30de 069e91a 1af30de 7d92bb6 1af30de 069e91a 1af30de 11f6676 1af30de 11f6676 1af30de 11f6676 1af30de 11f6676 1af30de 11f6676 b2a48d6 11f6676 1af30de 11f6676 7a57e8c 11f6676 d1a22ba 11f6676 f30ccd2 11f6676 f30ccd2 11f6676 7a57e8c c7d3d86 11f6676 b2a48d6 11f6676 b2a48d6 1af30de b2a48d6 1af30de b2a48d6 1af30de 11f6676 c7d3d86 11f6676 c7d3d86 11f6676 1af30de 11f6676 1af30de 11f6676 069e91a 11f6676 1af30de 11f6676 1af30de 11f6676 1af30de 11f6676 b2a48d6 11f6676 b2a48d6 1af30de d3911cc 1af30de d3911cc 1af30de d3911cc 1af30de d1a22ba 1af30de d1a22ba 1af30de d1a22ba 11f6676 b2a48d6 11f6676 f6456c2 7a57e8c 11f6676 7a57e8c 11f6676 b2a48d6 1af30de 11f6676 1af30de 11f6676 d3911cc 5634d87 11f6676 d3911cc 11f6676 d3911cc 11f6676 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 | /**
* CandleEngine β server-owned canonical bar store.
*
* Architecture:
*
* Tick βββΊ CandleEngine.onTick(symbol, price, volume, timestamp)
* β
* ββββΊ applies session calendar (rejects off-hours ticks)
* ββββΊ updates the 1m BarBucket (single source of truth)
* ββββΊ broadcasts {t:'tick'} (conflated, 250ms) for header/watchlist
* ββββΊ on bar rollover OR each tick:
* updates derived bars (5m, 15m, 1H, 4H, 1D, 1W, 1M)
* emits {t:'bar', closed:false} to bar subscribers
* on rollover, emits {t:'bar', closed:true} for the
* just-closed bar, then opens the next.
*
* Single source of truth = 1m bars. All other timeframes are derived by
* aggregating consecutive 1m bars (intraday) or by re-bucketing time
* (daily/weekly/monthly). This eliminates the "alignment drift between
* history and live" class of bugs entirely.
*
* Memory: ring buffers, BAR_BUCKET_CAPACITY per (sym, tf). Evicted after
* BUCKET_IDLE_EVICTION_MS of no subscribers.
*
* Thread model: Node single-threaded; no locks needed. Every operation is
* synchronous from the caller's perspective.
*/
import { EventEmitter } from 'node:events';
import {
AssetClass, Bar, Tick, Timeframe, TIMEFRAMES,
} from './types.js';
import { bucketStart, isTickAcceptable, isBucketAligned, alignToBucket } from './calendar.js';
import { getClass, getValidFromSec } from './symbols.js';
import {
BUCKET_IDLE_EVICTION_MS, BAR_CONFLATION_INTERVAL_MS,
MAX_SERVER_BAR_MEMORY_MB, BUCKET_IDLE_EVICTION_MIN, MAX_BARS_PER_BUCKET,
} from './config.js';
import { createLogger } from './log.js';
import { recordFeedAge } from './load.js';
/**
* Conservative per-bar memory footprint used for the server-wide LRU budget.
* A Bar object holds 6 floats Γ 8 bytes = 48 bytes of payload; add ~16 bytes
* of V8 object-header overhead = ~64 bytes. We pick 48 (payload-only) to
* remain a conservative *lower* bound on real memory β the LRU is happy
* underestimating since the budget bites earlier, not later, than the true
* heap usage. Real overhead is absorbed by the 10 % headroom built into
* `evictLRU` (drains to 90 % of budget).
*/
const BAR_BYTES = 48;
const log = createLogger('engine');
/**
* Defensive OHLC sanity check. Returns null if the bar passes, otherwise a
* short reason string. Providers occasionally return bars with high<open,
* NaN volume, or negative values β those must never enter a bucket.
*/
function validateBarOhlc(b: Bar): string | null {
if (!isFinite(b.time) || b.time <= 0) return 'bad-time';
if (!isFinite(b.open) || !isFinite(b.high) || !isFinite(b.low) || !isFinite(b.close)) return 'nan-ohlc';
if (b.open <= 0 || b.high <= 0 || b.low <= 0 || b.close <= 0) return 'non-positive-ohlc';
if (!isFinite(b.volume) || b.volume < 0) return 'bad-volume';
if (b.high < Math.max(b.open, b.close)) return 'high<max(o,c)';
if (b.low > Math.min(b.open, b.close)) return 'low>min(o,c)';
if (b.high < b.low) return 'high<low';
return null;
}
/** Max ticks held in a bucket's seed-window queue. On overflow, the oldest is
* dropped β at typical streaming rates (β€10 ticks/sec/sym) 500 covers a
* ~50-second seed window, well beyond a normal Alpaca/Yahoo fetch (1β3 s). */
const PENDING_TICKS_MAX = 500;
interface PendingTick {
price: number;
volume: number;
timestamp: number;
}
// ---------------------------------------------------------------------------
// BarBucket β ring buffer of bars for ONE (symbol, timeframe)
// ---------------------------------------------------------------------------
export class BarBucket {
public readonly symbol: string;
public readonly klass: AssetClass;
public readonly tf: Timeframe;
/** Bars in ascending order by .time. */
private bars: Bar[] = [];
/** Wall-clock ms of the last activity (tick or subscribe). */
public lastActivityMs: number = Date.now();
/**
* Wall-clock ms of the most recent read access (snapshot / applyTick /
* acquire). Used by EngineRegistry.evictLRU to pick the coldest idle
* bucket when bar-memory exceeds budget.
*/
public lastQueriedAt: number = Date.now();
/** Subscriber ref-count β when 0 the bucket is a candidate for eviction. */
public refCount: number = 0;
/**
* Per-bucket monotonic sequence number. Bumped on every emitted bar
* event (including rollover's two events). Snapshots and catchups also
* stamp the current seq so clients can drop out-of-order WS frames.
*
* Counts up to Number.MAX_SAFE_INTEGER before wrapping back to 1 (it never
* returns 0). At 1000 events/sec that's a ~285,000-year lifetime, so the
* wrap is purely theoretical β but keeping it strictly increasing-then-
* reset (rather than the old 31-bit `| 0`, which wrapped every ~25 days)
* means a long-lived bucket can never silently start handing out seq values
* a client has already seen and would therefore drop.
*/
private seq: number = 0;
/**
* Instrument identity boundary (epoch sec) β bars whose bucket-aligned
* start precedes it are rejected in seedHistory/prependHistory. This is
* the AUTHORITATIVE enforcement point: the comparison runs after
* alignToBucket, so it is immune to provider stamp conventions (the
* history layer's raw-stamp filter is a bulk/efficiency pass only).
* Undefined for continuous-identity assets. See SymbolMeta.validFromUtc.
*/
public readonly validFromSec?: number;
constructor(symbol: string, klass: AssetClass, tf: Timeframe, validFromSec?: number) {
this.symbol = symbol;
this.klass = klass;
this.tf = tf;
this.validFromSec = validFromSec;
}
/** True once seedHistory has been called. Prevents ticks from creating
* bars in an empty bucket that's still waiting for history to load. */
private seeded: boolean = false;
/** True while a history fetch is in-flight for this bucket. Ticks arriving
* during this window are queued (not dropped) and replayed after seed. */
private seeding: boolean = false;
/** Ticks captured during the seed window. Replayed in order once seed
* completes. Capped at PENDING_TICKS_MAX β oldest is dropped on overflow. */
private pendingTicks: PendingTick[] = [];
/**
* NEPSE 1D / 1W / 1M only β see CandleEngine.applySessionBar.
*
* `sessionTime` is the 1D start of the session currently folded into the
* last bar; `sessionBase` is that last bar WITHOUT it (the earlier sessions
* of the same week or month). Each update replaces the session's share
* instead of adding to it, which is what keeps a weekly bar exact while its
* current day is still trading. Always null for 1D: a day has one session.
*/
public sessionTime: number | null = null;
public sessionBase: Bar | null = null;
public setSessionOverlay(overlay: SessionOverlay | undefined): void {
this.sessionTime = overlay?.sessionTime ?? null;
this.sessionBase = this.tf === '1D' ? null : overlay?.base ?? null;
}
/**
* Fold the latest figures of one session into this period bucket.
* `session` is the exchange's whole-session bar, stamped at its 1D start.
*/
public applySession(session: Bar): TickResult {
this.lastQueriedAt = Date.now();
const periodStart = bucketStart(this.klass, this.tf, session.time * 1000);
const last = this.lastBar();
if (!last || periodStart > last.time) {
// First session of a new day/week/month.
const fresh: Bar = { ...session, time: periodStart };
this.bars.push(fresh);
this.sessionTime = session.time;
this.sessionBase = null;
return last
? { kind: 'rollover', closed: { ...last }, fresh: { ...fresh } }
: { kind: 'open', current: { ...fresh } };
}
if (periodStart < last.time) return { kind: 'stale' };
if (this.sessionTime !== session.time) {
// A later session inside the same week or month: everything already
// in the bar is final, and becomes the base this session builds on.
this.sessionBase = this.tf === '1D' ? null : { ...last };
this.sessionTime = session.time;
}
const base = this.sessionBase;
last.open = base ? base.open : session.open;
last.high = base ? Math.max(base.high, session.high) : session.high;
last.low = base ? Math.min(base.low, session.low) : session.low;
last.close = session.close;
last.volume = (base ? base.volume : 0) + session.volume;
return { kind: 'update', current: { ...last } };
}
public bumpSeq(): number {
// Wrap only at the float-safe-integer ceiling (never 0) instead of the
// old 31-bit `| 0`, which silently wrapped every ~25 days of uptime.
if (this.seq >= Number.MAX_SAFE_INTEGER) this.seq = 1;
else this.seq += 1;
return this.seq;
}
public currentSeq(): number { return this.seq; }
/** Test seam: park the sequence at a chosen value to exercise wrap. */
public __setSeqForTest(v: number): void { this.seq = v; }
/**
* Replace the entire bar set (used at history-bootstrap time).
*
* Defensive: validates OHLC, drops invalid bars, and re-aligns any bar
* whose `time` doesn't match `bucketStart()`. This is the safety net for
* provider-vs-engine timestamp drift (e.g. OANDA H4 with custom
* alignmentTimezone) that was the root cause of the Silver 4H phantom.
*/
public seedHistory(bars: Bar[]): void {
const out: Bar[] = [];
let invalid = 0;
let realigned = 0;
let preBoundary = 0;
for (const raw of bars) {
const reason = validateBarOhlc(raw);
if (reason) {
invalid++;
continue;
}
let b: Bar = raw;
if (!isBucketAligned(this.klass, this.tf, raw.time)) {
const aligned = alignToBucket(this.klass, this.tf, raw.time);
b = { ...raw, time: aligned };
realigned++;
}
// Instrument identity boundary β checked AFTER alignment so a
// provider stamp above the boundary whose bucket starts below it
// is still rejected (alignment only moves times backward).
if (this.validFromSec !== undefined && b.time < this.validFromSec) {
preBoundary++;
continue;
}
out.push(b);
}
// Sort ascending + collapse duplicates (later overwrites earlier β bars
// arrive in chronological order from providers, so "later" = "newer
// close" which is what we want for the in-progress bar at the right edge).
out.sort((a, b) => a.time - b.time);
const deduped: Bar[] = [];
for (const b of out) {
const tail = deduped[deduped.length - 1];
if (tail && tail.time === b.time) {
// Merge OHLC: open from earlier, h/l aggregated, close from later, volume summed.
deduped[deduped.length - 1] = {
time: tail.time,
open: tail.open,
high: Math.max(tail.high, b.high),
low: Math.min(tail.low, b.low),
close: b.close,
volume: tail.volume + b.volume,
};
} else {
deduped.push(b);
}
}
this.bars = deduped;
this.seeded = true;
this.seeding = false;
this.lastActivityMs = Date.now();
this.lastQueriedAt = this.lastActivityMs;
if (invalid > 0 || realigned > 0 || preBoundary > 0) {
log.warn(`seedHistory ${this.symbol}/${this.tf}: ${invalid} invalid, ${realigned} realigned, ${preBoundary} pre-boundary, ${this.bars.length} kept`);
}
}
public isSeeded(): boolean { return this.seeded; }
/**
* Approximate in-memory footprint of this bucket's bar array, in bytes.
* Used by EngineRegistry.totalBarMemory / evictLRU. See BAR_BYTES above
* for the conservative per-bar estimate.
*/
public get byteSize(): number {
return this.bars.length * BAR_BYTES;
}
/**
* Prepend older bars (backfill chunk). Bars MUST be strictly older than
* any bar currently in the bucket β overlapping/duplicate timestamps are
* silently dropped (the seeded range is authoritative for its window).
*
* Validates and realigns each input bar the same way `seedHistory` does,
* preserving the per-bucket invariant: all bars sorted ascending, all
* timestamps bucket-aligned, no duplicates. Returns the deduped, sorted,
* strictly-older bars that actually landed in the bucket β `bars` is what
* the WS hub should rebroadcast (never include filtered-out overlap).
*/
public prependHistory(olderBars: Bar[]): { added: number; bars: Bar[] } {
if (!this.seeded) {
// Prepend onto an unseeded bucket would be ambiguous (no boundary
// for the overlap filter). Caller MUST seed first. Drop silently.
return { added: 0, bars: [] };
}
if (olderBars.length === 0) return { added: 0, bars: [] };
const oldestExisting = this.bars[0]?.time ?? Number.POSITIVE_INFINITY;
const validated: Bar[] = [];
let invalid = 0;
let realigned = 0;
let overlapped = 0;
let preBoundary = 0;
for (const raw of olderBars) {
const reason = validateBarOhlc(raw);
if (reason) { invalid++; continue; }
let b: Bar = raw;
if (!isBucketAligned(this.klass, this.tf, raw.time)) {
const aligned = alignToBucket(this.klass, this.tf, raw.time);
b = { ...raw, time: aligned };
realigned++;
}
// Instrument identity boundary β post-alignment, same as seedHistory.
if (this.validFromSec !== undefined && b.time < this.validFromSec) {
preBoundary++;
continue;
}
// Strictly older than the seeded range β no merge, no overwrite.
if (b.time >= oldestExisting) { overlapped++; continue; }
validated.push(b);
}
if (validated.length === 0) {
if (invalid > 0 || overlapped > 0 || preBoundary > 0) {
log.debug(`prependHistory ${this.symbol}/${this.tf}: 0 added (${invalid} invalid, ${overlapped} overlap, ${preBoundary} pre-boundary)`);
}
return { added: 0, bars: [] };
}
validated.sort((a, b) => a.time - b.time);
// Dedup within the prepend itself (provider can return overlapping pages).
const deduped: Bar[] = [];
for (const b of validated) {
const tail = deduped[deduped.length - 1];
if (tail && tail.time === b.time) {
deduped[deduped.length - 1] = {
time: tail.time,
open: tail.open,
high: Math.max(tail.high, b.high),
low: Math.min(tail.low, b.low),
close: b.close,
volume: tail.volume + b.volume,
};
} else {
deduped.push(b);
}
}
// Splice prepend in front; the seeded range stays untouched.
this.bars = deduped.concat(this.bars);
// Per-bucket safety cap: a bucket that stays subscribed through a deep
// backfill is never an LRU eviction candidate, so bound its growth here
// by trimming the OLDEST bars (keep the live right edge). Invisible to
// clients β the prepend chunk was already returned for broadcast above;
// only the server-retained copy is bounded. (Live append is the hot path
// and is intentionally NOT capped here.)
if (this.bars.length > MAX_BARS_PER_BUCKET) {
const dropped = this.bars.length - MAX_BARS_PER_BUCKET;
this.bars = this.bars.slice(dropped);
log.warn(`prependHistory ${this.symbol}/${this.tf}: trimmed ${dropped} oldest bar(s) at MAX_BARS_PER_BUCKET=${MAX_BARS_PER_BUCKET}`);
}
this.lastActivityMs = Date.now();
this.lastQueriedAt = this.lastActivityMs;
if (invalid > 0 || realigned > 0 || overlapped > 0 || preBoundary > 0) {
log.debug(`prependHistory ${this.symbol}/${this.tf}: +${deduped.length} (${invalid} invalid, ${realigned} realigned, ${overlapped} overlap, ${preBoundary} pre-boundary)`);
}
return { added: deduped.length, bars: deduped };
}
/** True between `beginSeed()` and the subsequent `seedHistory()` /
* `markSeedEmpty()` call β ticks are queued, not applied. */
public isSeeding(): boolean { return this.seeding; }
/** Mark this bucket as awaiting a history seed. Idempotent. Ticks that
* arrive while seeding is true are pushed onto pendingTicks. */
public beginSeed(): void {
this.seeding = true;
this.lastActivityMs = Date.now();
}
/** Called when the history fetch returned 0 bars (provider down / quota
* exhausted / symbol off-hours with no recent data). Clears the seeding
* flag so we stop queueing ticks, but leaves `seeded = false` so a
* subsequent re-subscribe re-attempts the fetch. Drains the pending
* queue (no baseline to apply them to). */
public markSeedEmpty(): void {
this.seeding = false;
this.pendingTicks = [];
this.lastActivityMs = Date.now();
}
/** Queue a tick that arrived during the seed window. Capped at
* PENDING_TICKS_MAX β when full, the oldest tick is discarded. */
public enqueueTick(price: number, volume: number, timestamp: number): void {
if (this.pendingTicks.length >= PENDING_TICKS_MAX) {
this.pendingTicks.shift();
}
this.pendingTicks.push({ price, volume, timestamp });
}
/** Pop the pending tick queue. Returned in arrival order so the engine
* can replay them via the normal applyTick path. */
public drainPendingTicks(): PendingTick[] {
const drained = this.pendingTicks;
this.pendingTicks = [];
return drained;
}
/** Return a defensive copy of all bars (used for snapshot/catchup). */
public snapshot(): Bar[] {
this.lastQueriedAt = Date.now();
return this.bars.map(b => ({ ...b }));
}
/** Bars with time >= since (catch-up after reconnect). */
public barsSince(since: number): Bar[] {
// Binary-search lower bound
let lo = 0, hi = this.bars.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (this.bars[mid].time < since) lo = mid + 1; else hi = mid;
}
return this.bars.slice(lo).map(b => ({ ...b }));
}
/** The most recent (possibly in-progress) bar, or null if empty. */
public lastBar(): Bar | null {
return this.bars.length > 0 ? this.bars[this.bars.length - 1] : null;
}
public size(): number { return this.bars.length; }
/**
* Apply a tick to the bar bucket. Returns the result of the operation,
* letting the engine know what to broadcast.
*/
public applyTick(price: number, volume: number, nowMs: number): TickResult {
this.lastQueriedAt = Date.now();
const newStartSec = bucketStart(this.klass, this.tf, nowMs);
const last = this.lastBar();
// Case 1: bucket is empty β open the first bar.
if (!last) {
const bar: Bar = {
time: newStartSec, open: price, high: price, low: price, close: price,
volume,
};
this.bars.push(bar);
return { kind: 'open', current: { ...bar } };
}
// Case 2: still within the current bar β update H/L/C/V in place.
if (newStartSec === last.time) {
if (price > last.high) last.high = price;
if (price < last.low) last.low = price;
last.close = price;
last.volume += volume;
return { kind: 'update', current: { ...last } };
}
// Case 3: new period started.
if (newStartSec > last.time) {
// GUARD: For long-period bars, do NOT create new bars from live
// ticks. These should only come from history fetches. Live ticks
// just update the last bar in-place. This prevents phantom
// candles caused by calendar boundary misalignment between
// history provider timestamps and our computed buckets.
//
// Always guarded: 1W, 1M (every asset class).
// Also guarded for forex/commodity/index: 1D β the 17:00 ET
// cutover doesn't always land where bucketStart computes for a
// live tick arriving near the boundary, and the OANDA history
// is the authoritative source for daily forex/metals bars.
const dailyForexGuard = this.tf === '1D'
&& (this.klass === AssetClass.Forex
|| this.klass === AssetClass.Commodity
|| this.klass === AssetClass.Index);
if (this.tf === '1W' || this.tf === '1M' || dailyForexGuard) {
// Update the existing last bar instead of creating a new one
if (price > last.high) last.high = price;
if (price < last.low) last.low = price;
last.close = price;
last.volume += volume;
return { kind: 'update', current: { ...last } };
}
const closedCopy: Bar = { ...last };
const fresh: Bar = {
time: newStartSec,
open: price, high: price, low: price, close: price,
volume,
};
this.bars.push(fresh);
return { kind: 'rollover', closed: closedCopy, fresh: { ...fresh } };
}
// Case 4: backward timestamp (clock skew or out-of-order tick). Drop.
// Log when the skew is significant β this surfaces "latest candle
// not updating" bugs where every tick lands in an older bucket than
// the most recent bar.
const skewSec = last.time - newStartSec;
if (skewSec > 60) {
log.warn(`stale tick ${this.symbol}/${this.tf}: tick bucket=${newStartSec} last=${last.time} skew=${skewSec}s`);
}
return { kind: 'stale' };
}
/**
* Force-close the current bar without consuming a new tick. Useful for
* the rollover-timer pathway (e.g. forex on quiet weekends) to ensure
* bars roll over even when no trades arrive.
*/
public forceRollover(nowMs: number): TickResult {
const last = this.lastBar();
if (!last) return { kind: 'stale' };
const newStartSec = bucketStart(this.klass, this.tf, nowMs);
if (newStartSec === last.time) return { kind: 'stale' };
if (newStartSec < last.time) return { kind: 'stale' };
// Roll over by carrying the close price forward as the new bar's open.
const closedCopy: Bar = { ...last };
const fresh: Bar = {
time: newStartSec,
open: last.close, high: last.close, low: last.close, close: last.close,
volume: 0,
};
this.bars.push(fresh);
return { kind: 'rollover', closed: closedCopy, fresh: { ...fresh } };
}
}
type TickResult =
| { kind: 'stale' }
| { kind: 'open'; current: Bar }
| { kind: 'update'; current: Bar }
| { kind: 'rollover'; closed: Bar; fresh: Bar };
/** How a seeded NEPSE period bucket relates to the session still trading. See BarBucket.sessionBase. */
export interface SessionOverlay {
sessionTime: number;
base: Bar | null;
}
/** Timeframes whose NEPSE bars follow the exchange's session figures, not ticks. */
function isSessionTf(tf: Timeframe): boolean {
return tf === '1D' || tf === '1W' || tf === '1M';
}
// ---------------------------------------------------------------------------
// Engine events
// ---------------------------------------------------------------------------
export interface BarEvent {
symbol: string;
tf: Timeframe;
bar: Bar;
closed: boolean;
/** Per-bucket monotonic sequence β clients drop frames with seq <= lastSeq. */
seq: number;
}
export interface TickBroadcastEvent {
symbol: string;
price: number;
timestamp: number;
}
interface EngineEvents {
bar: (e: BarEvent) => void;
tick: (e: TickBroadcastEvent) => void;
}
// ---------------------------------------------------------------------------
// CandleEngine
// ---------------------------------------------------------------------------
export class CandleEngine extends EventEmitter {
/** Map: symbol β Map<Timeframe, BarBucket> */
private readonly buckets = new Map<string, Map<Timeframe, BarBucket>>();
/** Last-broadcasted tick price per symbol (so we can dedupe redundant emits). */
private readonly lastTickPrice = new Map<string, number>();
/** Conflation throttle β wall time of last broadcast per symbol. */
private readonly lastTickEmitAt = new Map<string, number>();
/** Per-symbol latest price snapshot (used by clients subscribing later). */
private readonly latestPrice = new Map<string, number>();
/**
* The trade's OWN time (Tick.timestamp) for the price in `latestPrice` β
* written in the same statement, so the two always describe one trade.
* Absent when the feed sent no usable time. See getLatestTradeAt.
*/
private readonly latestTradeAt = new Map<string, number>();
/** Rollover timer β sweeps every 5 s to close inactive bars. */
private rolloverTimer: NodeJS.Timeout | null = null;
/** Eviction timer β sweeps every minute to drop idle buckets. */
private evictionTimer: NodeJS.Timeout | null = null;
/**
* Debounce flag for maybeEvictLRU. A market-open burst (or a 3-way backfill
* wave of up to ~300 prepends) calls maybeEvictLRU many times in one
* synchronous run; without this, each call does an O(buckets) totalBarMemory
* scan. The flag collapses a burst to a single scheduled eviction.
*/
private evictPending = false;
public on<E extends keyof EngineEvents>(event: E, listener: EngineEvents[E]): this {
return super.on(event, listener);
}
public emit<E extends keyof EngineEvents>(event: E, ...args: Parameters<EngineEvents[E]>): boolean {
return super.emit(event, ...args);
}
public start(): void {
if (this.rolloverTimer || this.evictionTimer) return;
// One-time migration: prior builds bucketed OANDA H4 on raw UTC clock
// (00/04/08/12/16/20 UTC). The current build anchors H4 to 17:00 ET
// (per OANDA's H4 "day-alignment" spec) so existing-process buckets
// hold misaligned bars. Evict them so the first subscribe re-seeds
// with correctly-anchored history.
this.evictStaleOandaH4OnBoot();
// Roll bars over even in absence of ticks (e.g. quiet symbol over weekend).
this.rolloverTimer = setInterval(() => this.sweepRollovers(), 5_000);
this.evictionTimer = setInterval(() => this.sweepEvictions(), 60_000);
log.info('CandleEngine started');
}
/**
* Boot-time eviction of OANDA-class 4H buckets. See `start()`. Safe to call
* before any client subscribes β buckets at refCount===0 are dropped
* outright; the next subscribe re-seeds them from HistoryFetcher.
*/
private evictStaleOandaH4OnBoot(): void {
let evicted = 0;
for (const [sym, perSym] of this.buckets) {
const bucket = perSym.get('4H');
if (!bucket) continue;
if (bucket.klass !== AssetClass.Forex
&& bucket.klass !== AssetClass.Commodity
&& bucket.klass !== AssetClass.Index) continue;
perSym.delete('4H');
if (perSym.size === 0) this.buckets.delete(sym);
evicted++;
}
if (evicted > 0) log.info(`evicted ${evicted} stale OANDA-class 4H bucket(s) on boot (re-anchor to 17:00 ET)`);
}
public stop(): void {
if (this.rolloverTimer) clearInterval(this.rolloverTimer);
if (this.evictionTimer) clearInterval(this.evictionTimer);
this.rolloverTimer = null;
this.evictionTimer = null;
}
// -----------------------------------------------------------------------
// Tick ingestion (the hot path)
// -----------------------------------------------------------------------
/**
* Ingest a normalized tick. The engine routes it to all BarBuckets for the
* symbol and emits bar updates + a conflated tick broadcast.
*
* Off-hours ticks are FILTERED at the feed-source level (see oanda.ts /
* alpaca.ts which both check calendar.isTickAcceptable before forwarding).
* Here we just trust the caller β calling isTickAcceptable per tick would
* recompute ET parts hundreds of times per second.
*/
public onTick(t: Tick): void {
if (!isFinite(t.price) || t.price <= 0) return;
const klass = getClass(t.symbol);
if (!klass) return; // Unknown symbol β drop
this.latestPrice.set(t.symbol, t.price);
if (isFinite(t.timestamp) && t.timestamp > 0) this.latestTradeAt.set(t.symbol, t.timestamp);
else this.latestTradeAt.delete(t.symbol);
// Crypto trades around the clock with a real trade time, so its age says how
// far behind the feed itself is running, whatever else the market is doing.
if (klass === 'crypto') recordFeedAge(Date.now() - t.timestamp);
// 1) Conflated tick broadcast for MarketHeader/WatchlistPanel
this.maybeEmitTick(t.symbol, t.price, t.timestamp);
// 2) Update bar buckets if any exist
const tfBuckets = this.buckets.get(t.symbol);
if (!tfBuckets || tfBuckets.size === 0) return;
const nepse = klass === AssetClass.Nepse;
for (const bucket of tfBuckets.values()) {
// NEPSE daily/weekly/monthly bars take the exchange's own session
// figures (applySessionBar), never tick samples.
if (nepse && isSessionTf(bucket.tf)) continue;
// Seed window: queue ticks instead of dropping. The engine replays
// them in order via applyTick once the bucket is seeded, so the
// right-edge live bar reflects activity that happened during the
// (potentially 1β3s) Alpaca/Yahoo history fetch.
//
// NEPSE is the exception, and must be: its seed is read from the
// feed's session book in the same turn the bucket is seeded, AFTER
// the fetch β so the book already contains every tick that arrived
// meanwhile. Replaying them too would count their volume twice.
if (bucket.isSeeding()) {
if (!nepse) bucket.enqueueTick(t.price, t.volume, t.timestamp);
continue;
}
// Not seeding and not yet seeded β the bucket was created but no
// seed was ever attempted (or the seed returned 0 bars and was
// marked empty). Drop the tick β there's no baseline to apply it
// to, and a fresh seed will run on the next client subscribe.
if (!bucket.isSeeded()) continue;
const result = bucket.applyTick(t.price, t.volume, t.timestamp);
bucket.lastActivityMs = Date.now();
this.emitFromTickResult(bucket, result);
}
}
/** How often a symbol's tick may be broadcast. The hub sets it by client count. */
private tickIntervalMs = BAR_CONFLATION_INTERVAL_MS;
public setTickInterval(ms: number): void { this.tickIntervalMs = ms; }
private maybeEmitTick(symbol: string, price: number, timestamp: number): void {
const now = Date.now();
const last = this.lastTickEmitAt.get(symbol) ?? 0;
const lastPrice = this.lastTickPrice.get(symbol);
if (lastPrice === price && now - last < this.tickIntervalMs) return;
if (now - last < this.tickIntervalMs) return;
this.lastTickEmitAt.set(symbol, now);
this.lastTickPrice.set(symbol, price);
this.emit('tick', { symbol, price, timestamp });
}
private emitFromTickResult(bucket: BarBucket, r: TickResult): void {
switch (r.kind) {
case 'stale':
return;
case 'open':
case 'update':
this.emit('bar', {
symbol: bucket.symbol, tf: bucket.tf,
bar: r.current, closed: false, seq: bucket.bumpSeq(),
});
return;
case 'rollover':
// Emit final 'closed:true' for the just-finished bar, then open the new one.
// Each gets its own seq so the client can distinguish them.
this.emit('bar', {
symbol: bucket.symbol, tf: bucket.tf,
bar: r.closed, closed: true, seq: bucket.bumpSeq(),
});
this.emit('bar', {
symbol: bucket.symbol, tf: bucket.tf,
bar: r.fresh, closed: false, seq: bucket.bumpSeq(),
});
return;
}
}
// -----------------------------------------------------------------------
// Bucket lifecycle (subscribe / unsubscribe / seed)
// -----------------------------------------------------------------------
/**
* Ensure a bucket exists for (symbol, tf) and return it. The caller is
* responsible for incrementing refCount via `acquire()`.
*/
public getOrCreateBucket(symbol: string, tf: Timeframe): BarBucket | null {
const klass = getClass(symbol);
if (!klass) return null;
let perSym = this.buckets.get(symbol);
if (!perSym) {
perSym = new Map();
this.buckets.set(symbol, perSym);
}
let bucket = perSym.get(tf);
if (!bucket) {
bucket = new BarBucket(symbol, klass, tf, getValidFromSec(symbol));
perSym.set(tf, bucket);
}
return bucket;
}
public getBucket(symbol: string, tf: Timeframe): BarBucket | undefined {
return this.buckets.get(symbol)?.get(tf);
}
/** Increment refCount for a (sym, tf) subscription. */
public acquire(symbol: string, tf: Timeframe): BarBucket | null {
const bucket = this.getOrCreateBucket(symbol, tf);
if (!bucket) return null;
bucket.refCount += 1;
bucket.lastActivityMs = Date.now();
bucket.lastQueriedAt = bucket.lastActivityMs;
return bucket;
}
/** Decrement refCount. When 0, the bucket becomes eligible for eviction. */
public release(symbol: string, tf: Timeframe): void {
const bucket = this.getBucket(symbol, tf);
if (!bucket) return;
bucket.refCount = Math.max(0, bucket.refCount - 1);
bucket.lastActivityMs = Date.now();
}
/**
* Mark a bucket as awaiting a history seed. Called by client-hub BEFORE
* `await fetchHistory(...)` so that any ticks arriving during the fetch
* window are queued (not dropped) and replayed once the seed lands.
*/
public beginSeed(symbol: string, tf: Timeframe): void {
const bucket = this.getOrCreateBucket(symbol, tf);
if (!bucket) return;
bucket.beginSeed();
}
/**
* Seed a bucket's history (called once after the first subscribe, with
* data from the HistoryFetcher). Clears the seeding flag and replays any
* ticks captured during the fetch window in arrival order.
*/
public seedBucketHistory(symbol: string, tf: Timeframe, bars: Bar[], overlay?: SessionOverlay): void {
const bucket = this.getOrCreateBucket(symbol, tf);
if (!bucket) return;
bucket.seedHistory(bars);
bucket.setSessionOverlay(overlay);
// Replay any ticks queued while the fetch was in flight. applyTick
// handles backward-time ticks via its own stale-guard, so we don't
// need to pre-filter β and emitting via the normal path lets bar
// subscribers see the catch-up updates with monotonic seq numbers.
const drained = bucket.drainPendingTicks();
for (const pt of drained) {
if (!isFinite(pt.price) || pt.price <= 0) continue;
const result = bucket.applyTick(pt.price, pt.volume, pt.timestamp);
bucket.lastActivityMs = Date.now();
this.emitFromTickResult(bucket, result);
}
// Deep seeds (potentially thousands of bars Γ hundreds of subscriptions)
// can push total bar memory over budget. Evict idle buckets if so.
this.maybeEvictLRU();
}
/**
* Mark a bucket's seed attempt as having returned 0 bars (provider
* unavailable or symbol has no recent data). Clears `seeding` so we stop
* queueing ticks, but leaves `seeded === false` so the client's retry
* triggers a fresh fetch on its next `sub_bars`.
*/
public markSeedEmpty(symbol: string, tf: Timeframe): void {
const bucket = this.getBucket(symbol, tf);
if (!bucket) return;
bucket.markSeedEmpty();
}
/**
* Prepend a chunk of older bars (backfill page) to an already-seeded
* bucket. The bucket's seeded range is authoritative β any input bar
* whose timestamp falls inside it is dropped (no merge, no overwrite).
*
* Returns `{ added }` reporting how many bars actually landed after
* validation + overlap filter, or `null` if the bucket doesn't exist
* (e.g., evicted between the fetch and the prepend).
*
* Triggers server-wide LRU eviction post-write when total bar memory
* exceeds MAX_SERVER_BAR_MEMORY_MB.
*/
public prependHistory(symbol: string, tf: Timeframe, olderBars: Bar[]): { added: number; bars: Bar[] } | null {
const bucket = this.getBucket(symbol, tf);
if (!bucket) return null;
const result = bucket.prependHistory(olderBars);
if (result.added > 0) this.maybeEvictLRU();
return result;
}
/**
* Sum of `byteSize` across every bucket in the engine. Bytes; convert
* to MB before comparing to MAX_SERVER_BAR_MEMORY_MB.
*/
public totalBarMemory(): number {
let total = 0;
for (const perSym of this.buckets.values()) {
for (const bucket of perSym.values()) {
total += bucket.byteSize;
}
}
return total;
}
/**
* Server-level LRU eviction. Called after every bar-array growth point
* (seedBucketHistory, prependHistory) β if total bar memory > budget,
* drop the oldest idle (refCount===0) buckets until total β€ 90 % of
* budget OR no idle candidates remain.
*
* Subscribed buckets are NEVER evicted regardless of how large they grow.
* If all candidates are referenced and we're over budget, a single WARN
* is logged per call β the process keeps running (real overhead is small
* compared to V8 heap budget; this is a soft limit).
*/
/** Cumulative since boot. Exposed via diagnostics() β see the note there. */
private evictionsTotal = 0;
private bytesFreedTotal = 0;
private budgetPressureEvents = 0;
public evictLRU(): { evicted: number; bytesFreed: number } {
const budgetBytes = MAX_SERVER_BAR_MEMORY_MB * 1_048_576;
let total = this.totalBarMemory();
if (total <= budgetBytes) return { evicted: 0, bytesFreed: 0 };
const targetBytes = Math.floor(budgetBytes * 0.9);
const now = Date.now();
const minIdleMs = BUCKET_IDLE_EVICTION_MIN * 60_000;
const candidates: Array<{ sym: string; tf: Timeframe; bucket: BarBucket }> = [];
for (const [sym, perSym] of this.buckets) {
for (const [tf, bucket] of perSym) {
if (bucket.refCount > 0) continue;
if (now - bucket.lastQueriedAt < minIdleMs) continue;
candidates.push({ sym, tf, bucket });
}
}
candidates.sort((a, b) => a.bucket.lastQueriedAt - b.bucket.lastQueriedAt);
let evicted = 0;
let bytesFreed = 0;
for (const { sym, tf, bucket } of candidates) {
if (total <= targetBytes) break;
const sz = bucket.byteSize;
const perSym = this.buckets.get(sym);
if (!perSym) continue;
perSym.delete(tf);
if (perSym.size === 0) this.buckets.delete(sym);
total -= sz;
bytesFreed += sz;
evicted++;
const idleMin = Math.round((now - bucket.lastQueriedAt) / 60_000);
log.info(`evicted ${sym}:${tf} (${Math.round(sz / 1024)} KB, idle ${idleMin}m) β heap budget`);
}
this.evictionsTotal += evicted;
this.bytesFreedTotal += bytesFreed;
if (total > budgetBytes && candidates.length === 0) {
this.budgetPressureEvents++;
log.warn(`memory budget exceeded with no idle buckets β consider raising STRIX_MAX_BAR_MEMORY_MB or reducing concurrent deep subscriptions (total=${Math.round(total / 1_048_576)} MB / budget=${MAX_SERVER_BAR_MEMORY_MB} MB)`);
}
return { evicted, bytesFreed };
}
/**
* Internal: trigger LRU eviction iff memory exceeds budget. Debounced β a
* synchronous burst of calls schedules exactly one eviction on the next
* event-loop turn (setImmediate fires before the next I/O/tick), instead of
* running an O(buckets) scan per call. evictLRU() re-checks the budget and
* is a no-op if memory has since dropped under it, so the deferral is safe.
* Direct evictLRU() callers (e.g. tests) are unaffected.
*/
private maybeEvictLRU(): void {
if (this.evictPending) return;
const budgetBytes = MAX_SERVER_BAR_MEMORY_MB * 1_048_576;
if (this.totalBarMemory() <= budgetBytes) return;
this.evictPending = true;
setImmediate(() => {
this.evictPending = false;
this.evictLRU();
});
}
/**
* NEPSE: fold the exchange's latest whole-session figures into the 1D, 1W
* and 1M buckets of `symbol` (whichever exist and are seeded), and emit the
* change like any bar update.
*
* Why not ticks: the daily candle has an authoritative source β the
* exchange's own open/high/low/last/volume for the session β while ticks
* are samples taken every few seconds that can miss the trade that set the
* high. The feed calls this after every poll that changed a security.
*/
public applySessionBar(symbol: string, session: Bar): void {
if (validateBarOhlc(session)) return;
const perSym = this.buckets.get(symbol);
if (!perSym) return;
for (const tf of ['1D', '1W', '1M'] as const) {
const bucket = perSym.get(tf);
if (!bucket || !bucket.isSeeded()) continue;
this.emitFromTickResult(bucket, bucket.applySession(session));
bucket.lastActivityMs = Date.now();
}
}
public getLatestPrice(symbol: string): number {
return this.latestPrice.get(symbol) ?? 0;
}
/**
* Wall time of the most recent tick BROADCAST for a symbol, or 0 if none.
*
* This is the only per-symbol observation timestamp the engine keeps:
* `latestPrice` is a bare number with no time attached. `lastTickEmitAt` is
* written by `maybeEmitTick`, which is throttled to at most one broadcast per
* BAR_CONFLATION_INTERVAL_MS β so while ticks flow it trails the true last
* tick by under that interval, and when ticks stop it freezes.
*
* That makes it CONSERVATIVE for freshness: it over-states age by at most one
* conflation interval and never under-states it. /quotes reports it verbatim
* as `asOf` rather than synthesising `Date.now()`, which would present a
* stored price as a live observation.
*/
public getLatestTickAt(symbol: string): number {
return this.lastTickEmitAt.get(symbol) ?? 0;
}
/**
* When the trade behind `getLatestPrice` happened, by the feed's own clock
* (UNIX ms), or 0 when unknown. Unlike `getLatestTickAt` β the throttled
* BROADCAST time, which can trail the price or, for a trade the feed
* delivered late, post-date it β this always belongs to the price itself.
* Use it to decide which session a price is from (quotes.ts).
*/
public getLatestTradeAt(symbol: string): number {
return this.latestTradeAt.get(symbol) ?? 0;
}
public setLatestPrice(symbol: string, price: number): void {
if (!(isFinite(price) && price > 0)) return;
this.latestPrice.set(symbol, price);
// A price set by hand has no trade time; never pair it with an old one.
this.latestTradeAt.delete(symbol);
}
// -----------------------------------------------------------------------
// Background sweeps
// -----------------------------------------------------------------------
private sweepRollovers(): void {
const now = Date.now();
for (const perSym of this.buckets.values()) {
for (const bucket of perSym.values()) {
// CRITICAL: Do NOT force-roll bars during off-hours.
// This prevents phantom empty candles from appearing when
// the market is closed (e.g., stocks on weekends, forex on Sat).
if (!isTickAcceptable(bucket.klass, now)) continue;
// Never force-roll NEPSE. Its intraday bars exist only for
// minutes that traded β the same rule its seed (the session
// tape and floorsheet) follows β so a bucket that had lived
// through a quiet stretch would otherwise disagree with a fresh
// seed of the same stock. Its session bars follow the exchange.
if (bucket.klass === AssetClass.Nepse) continue;
// Never force-roll weekly/monthly β same guard as applyTick.
// Also never force-roll daily for OANDA-class (forex/commodity/
// index) β those are aligned to 17:00 ET and we trust history.
if (bucket.tf === '1W' || bucket.tf === '1M') continue;
if (bucket.tf === '1D'
&& (bucket.klass === AssetClass.Forex
|| bucket.klass === AssetClass.Commodity
|| bucket.klass === AssetClass.Index)) continue;
// Don't force-roll unseeded buckets
if (!bucket.isSeeded()) continue;
const last = bucket.lastBar();
if (!last) continue;
const expectedStart = bucketStart(bucket.klass, bucket.tf, now);
if (expectedStart > last.time) {
// Time has moved past this bar's period β roll over.
const result = bucket.forceRollover(now);
this.emitFromTickResult(bucket, result);
}
}
}
}
private sweepEvictions(): void {
const now = Date.now();
for (const [sym, perSym] of this.buckets) {
for (const [tf, bucket] of perSym) {
if (bucket.refCount > 0) continue;
if (now - bucket.lastActivityMs < BUCKET_IDLE_EVICTION_MS) continue;
perSym.delete(tf);
log.info(`evicted idle bucket ${sym}/${tf}`);
}
if (perSym.size === 0) this.buckets.delete(sym);
}
}
// -----------------------------------------------------------------------
// Diagnostics
// -----------------------------------------------------------------------
/**
* Diagnostics, including whether the bar-memory budget is ACTUALLY BINDING.
*
* `barMemoryMb` vs `barBudgetMb` is the question that matters. The budget
* (default 2048) is often described as "2 GB of a 16 GB box", which reads
* like waste β it is not. The real ceiling is the V8 old-space set in the
* Dockerfile (`--max-old-space-size=4096`), and the bar cache deliberately
* sits at half of that so the other half remains for the Node baseline, WS
* send buffers, parsers and GC headroom. The box's remaining ~12 GB is
* unusable by this process without raising the V8 flag too.
*
* Raising both is possible but NOT automatically an improvement: a larger
* old-space means longer major GC pauses, and on 2 vCPUs a GC pause is
* event-loop lag β the exact signal that now governs client admission
* (load.ts). More cached history could therefore cost concurrent users.
*
* So the numbers below exist to settle it with evidence. If, after real
* traffic, `evictionsTotal` stays 0 and `barMemoryMb` never approaches
* `barBudgetMb`, the budget is not the constraint and raising it would
* achieve nothing. If `budgetPressureEvents` climbs, it is binding and
* both values should be raised together.
*/
/**
* Last traded price seen for a symbol, or undefined if none has arrived
* since boot. Read-only view of the conflation map the tick path already
* maintains β no new bookkeeping.
*
* Added for watch-live.ts, which needs the current price of a symbol that
* did NOT produce the tick it is reacting to (a condition spanning two
* symbols). Keeping a second price book there would be a copy of this one.
*/
public lastPrice(symbol: string): number | undefined {
return this.lastTickPrice.get(symbol);
}
public diagnostics(): {
symbols: number;
buckets: number;
totalBars: number;
barMemoryMb: number;
barBudgetMb: number;
evictionsTotal: number;
mbFreedTotal: number;
budgetPressureEvents: number;
} {
let buckets = 0;
let totalBars = 0;
for (const perSym of this.buckets.values()) {
for (const b of perSym.values()) {
buckets++;
totalBars += b.size();
}
}
return {
symbols: this.buckets.size,
buckets,
totalBars,
barMemoryMb: Math.round(this.totalBarMemory() / 1_048_576),
barBudgetMb: MAX_SERVER_BAR_MEMORY_MB,
evictionsTotal: this.evictionsTotal,
mbFreedTotal: Math.round(this.bytesFreedTotal / 1_048_576),
budgetPressureEvents: this.budgetPressureEvents,
};
}
/** Set of all timeframes (compile-time constant). */
public readonly allTimeframes: readonly Timeframe[] = TIMEFRAMES;
}
// ---------------------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------------------
export const engine = new CandleEngine();
|