Awannit commited on
Commit
b615b41
·
1 Parent(s): 89764d3

feat(nepse): long daily history from Merolagani under the exchange and archive

Browse files
src/history.ts CHANGED
@@ -148,9 +148,11 @@ export async function fetchHistory(
148
  }
149
 
150
  // NEPSE never goes through the seed cache below: its latest session changes
151
- // with every poll, and nepse/history.ts caches its own upstream (the
152
- // archive) while reading the live session from memory. Nothing older than
153
- // the archive exists to backfill.
 
 
154
  if (klass === AssetClass.Nepse) {
155
  const seed = await nepseSeed(symbol, tf);
156
  return { initial: seed.bars, backfillFn: () => empty(), session: seed.session };
 
148
  }
149
 
150
  // NEPSE never goes through the seed cache below: its latest session changes
151
+ // with every poll, and nepse/history.ts caches its own upstreams (the
152
+ // archive, the exchange's index history and the long series) while reading
153
+ // the live session from memory. The seed is the whole series — at most
154
+ // ~6,700 daily bars, the index since 1997, the size of an equity's daily
155
+ // snapshot — so there is nothing left to backfill.
156
  if (klass === AssetClass.Nepse) {
157
  const seed = await nepseSeed(symbol, tf);
158
  return { initial: seed.bars, backfillFn: () => empty(), session: seed.session };
src/nepse/archive.ts CHANGED
@@ -6,7 +6,8 @@
6
  * static JSON on GitHub Pages, regenerated every trading evening (~17:00 NPT),
7
  * from 2024-03-04, ~520 symbols. It has no index series (history/NEPSE.json is
8
  * a 404); the index's daily history comes from the exchange instead
9
- * (index-history.ts).
 
10
  *
11
  * Decoding is a port of `decodeNepseHistory` + `collapseCarryForward` in the
12
  * web app's packages/domain/src/nepse.ts — the server cannot import that
 
6
  * static JSON on GitHub Pages, regenerated every trading evening (~17:00 NPT),
7
  * from 2024-03-04, ~520 symbols. It has no index series (history/NEPSE.json is
8
  * a 404); the index's daily history comes from the exchange instead
9
+ * (index-history.ts). Everything before 2024 comes from Merolagani's long
10
+ * series underneath (long-history.ts); this archive's figures win on its dates.
11
  *
12
  * Decoding is a port of `decodeNepseHistory` + `collapseCarryForward` in the
13
  * web app's packages/domain/src/nepse.ts — the server cannot import that
src/nepse/history.ts CHANGED
@@ -6,9 +6,13 @@
6
  * it holds that session, else the history's own row). Weekly and
7
  * monthly bars come with the overlay the engine needs to keep
8
  * replacing the live session's share as it trades. A company's
9
- * sessions come from the public archive (years of them); the
10
- * index's from the exchange's own index history (the last
11
- * year — the archive has no index series).
 
 
 
 
12
  *
13
  * 1m … 4H the latest session only, in one-minute bars (the floorsheet
14
  * rebuild for what the feed did not watch, then its tape),
@@ -29,6 +33,7 @@ import type { SessionOverlay } from '../engine.js';
29
  import { nepseTicker } from '../symbols.js';
30
  import { nepseFeed } from '../feeds/nepse.js';
31
  import { archiveHistory, type ArchiveSession } from './archive.js';
 
32
  import { sessionBook, dayToBar, INDEX_KEY } from './book.js';
33
  import { nepseBucketStart, sessionTimeSec } from './time.js';
34
 
@@ -98,7 +103,12 @@ export async function nepseSeed(symbol: string, tf: Timeframe): Promise<NepseSee
98
  if (!ticker) return { bars: [] };
99
 
100
  if (tf === '1D' || tf === '1W' || tf === '1M') {
101
- const sessions = (ticker === INDEX_KEY ? await nepseFeed.indexSessions() : await archiveHistory(ticker)) ?? [];
 
 
 
 
 
102
  // ---- synchronous from here (see header) ----
103
  return composeSessionSeed(sessions, sessionBook.day(ticker), tf);
104
  }
 
6
  * it holds that session, else the history's own row). Weekly and
7
  * monthly bars come with the overlay the engine needs to keep
8
  * replacing the live session's share as it trades. A company's
9
+ * recent sessions come from the public archive (since March
10
+ * 2024); the index's from the exchange's own index history (the
11
+ * last year — the archive has no index series). Under both lies
12
+ * Merolagani's long series (long-history.ts): the index since
13
+ * 1997, a company since 2010 or its listing. The recent source
14
+ * wins on every date it covers; the long one only fills the
15
+ * years before it.
16
  *
17
  * 1m … 4H the latest session only, in one-minute bars (the floorsheet
18
  * rebuild for what the feed did not watch, then its tape),
 
33
  import { nepseTicker } from '../symbols.js';
34
  import { nepseFeed } from '../feeds/nepse.js';
35
  import { archiveHistory, type ArchiveSession } from './archive.js';
36
+ import { longHistory, mergeSessions } from './long-history.js';
37
  import { sessionBook, dayToBar, INDEX_KEY } from './book.js';
38
  import { nepseBucketStart, sessionTimeSec } from './time.js';
39
 
 
103
  if (!ticker) return { bars: [] };
104
 
105
  if (tf === '1D' || tf === '1W' || tf === '1M') {
106
+ const index = ticker === INDEX_KEY;
107
+ const [recent, long] = await Promise.all([
108
+ index ? nepseFeed.indexSessions() : archiveHistory(ticker),
109
+ longHistory(ticker, index),
110
+ ]);
111
+ const sessions = mergeSessions(long, recent);
112
  // ---- synchronous from here (see header) ----
113
  return composeSessionSeed(sessions, sessionBook.day(ticker), tf);
114
  }
src/nepse/index-history.ts CHANGED
@@ -6,7 +6,9 @@
6
  * daily view was a single bar. The exchange publishes the index's own daily
7
  * sessions (`/api/nots/index/history/58`) — open, high, low, close and
8
  * turnover per business date, the figures its own site charts — covering the
9
- * last year (230 sessions on 2026-09-25). One request, a few kilobytes.
 
 
10
  *
11
  * CACHED SIX HOURS, like the archive: the history gains one row per trading
12
  * evening, and the session still trading comes from the session book anyway
 
6
  * daily view was a single bar. The exchange publishes the index's own daily
7
  * sessions (`/api/nots/index/history/58`) — open, high, low, close and
8
  * turnover per business date, the figures its own site charts — covering the
9
+ * last year (230 sessions on 2026-09-25; paging and date parameters are
10
+ * ignored). One request, a few kilobytes. The years before come from
11
+ * Merolagani's long series (long-history.ts); these figures win on their dates.
12
  *
13
  * CACHED SIX HOURS, like the archive: the history gains one row per trading
14
  * evening, and the session still trading comes from the session book anyway
src/nepse/long-history.ts ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * NEPSE's long daily history — Merolagani's chart feed.
3
+ *
4
+ * ===========================================================================
5
+ * WHY A THIRD SOURCE
6
+ * ===========================================================================
7
+ *
8
+ * The exchange serves exactly ONE YEAR of daily history, for the index and for
9
+ * every company alike, and ignores every paging and date parameter. Verified
10
+ * 2026-09-25 against `/api/nots/index/history/58` and
11
+ * `/api/nots/market/security/price/{id}`: `page`, `size`, `startDate` and
12
+ * `endDate` all return the same 230 sessions (2025-09-23 → 2026-09-24) or an
13
+ * empty page. The public archive (archive.ts) starts on 2024-03-04. So the
14
+ * index chart began a year ago and a company's two and a half years ago, on an
15
+ * exchange that has traded since 1994.
16
+ *
17
+ * Merolagani's chart feed — the one its own charts draw from, in TradingView's
18
+ * UDF shape `{ s, t[], o[], h[], l[], c[], v[] }` — holds the index from July
19
+ * 1997 and each company from 2010 or from its listing if later. Asked for
20
+ * UNADJUSTED figures (`isAdjust=0`), they are the exchange's own prices:
21
+ * compared the same day with the exchange's history, all 230 overlapping index
22
+ * sessions were equal in open, high, low and close, and NABIL's 229 in prices
23
+ * and volumes (one open differed by Rs 2, identically in every third-party
24
+ * source). Unadjusted, like the archive and the live feed, so a bonus-share
25
+ * date is the same step on every source and a chart never mixes the two.
26
+ *
27
+ * ===========================================================================
28
+ * HOW IT IS USED
29
+ * ===========================================================================
30
+ *
31
+ * As the FLOOR under the other sources, never over them (`mergeSessions`): on
32
+ * any date the exchange's index history or the archive also covers, theirs is
33
+ * the figure. What this adds is only the years they do not have — and the odd
34
+ * session the archive missed. If Merolagani is down, blocks the request or
35
+ * changes its shape, the chart is exactly what it was before this module: the
36
+ * other sources stand on their own. `NEPSE_LONG_HISTORY_ENABLED=false` switches
37
+ * it off without a deploy.
38
+ *
39
+ * ===========================================================================
40
+ * WHAT THE PAYLOAD SAYS, AND HOW IT IS READ
41
+ * ===========================================================================
42
+ *
43
+ * - DATES. Each bar is stamped at some moment of its business date in UTC
44
+ * (05:45Z in the older years, 20:45Z lately, a handful elsewhere), so the
45
+ * UTC calendar date IS the business date. Checked against the exchange:
46
+ * all of its 230 dates present, none added.
47
+ * - THE INDEX BEFORE MID-2016 IS CLOSES ONLY (open = high = low = close). It
48
+ * is drawn as that — a flat bar is what a close-only day is — rather than
49
+ * given an invented range.
50
+ * - The index's `v` is rupee turnover, not a quantity; index bars carry no
51
+ * volume anywhere else in Strix, so it is dropped (see index-history.ts).
52
+ * - A few old bars have a high below their open or close; the range is
53
+ * widened to contain both, as the archive decoder does.
54
+ * - Figures are rounded to the exchange's two decimals (older index values
55
+ * arrive with five).
56
+ * - `{ s: "no_data" }` is a real answer — a symbol the feed does not carry
57
+ * (debentures with a slash in the ticker, a listing it has not picked up).
58
+ * It is cached like a success, so a chart of such a security does not ask
59
+ * again on every subscribe.
60
+ *
61
+ * ===========================================================================
62
+ * COST
63
+ * ===========================================================================
64
+ *
65
+ * One request per security, only when a daily/weekly/monthly chart of it is
66
+ * opened and the six-hour copy has expired: at most four a day per security
67
+ * anybody watches, a few hundred kilobytes at most (the index's 29 years). A
68
+ * failure is remembered for a minute, so an outage costs one timeout per
69
+ * security per minute, not one per chart opened.
70
+ */
71
+
72
+ import { createLogger } from '../log.js';
73
+ import type { ArchiveSession } from './archive.js';
74
+
75
+ const log = createLogger('nepse-long-history');
76
+
77
+ const BASE = process.env.NEPSE_LONG_HISTORY_BASE
78
+ ?? 'https://merolagani.com/handlers/TechnicalChartHandler.ashx';
79
+
80
+ const TTL_MS = 6 * 60 * 60 * 1000;
81
+ const RETRY_AFTER_FAILURE_MS = 60_000;
82
+ /** ~0.4 MB for a company, ~0.8 MB for the index: this bounds the cache near 50 MB. */
83
+ const MAX_ENTRIES = 100;
84
+ const TIMEOUT_MS = 10_000;
85
+
86
+ /** 1990-01-01 — before the exchange's first session (1994). */
87
+ const RANGE_START_SEC = 631_152_000;
88
+
89
+ /** The feed's tickers are letters and digits; anything else it does not carry. */
90
+ const TICKER = /^[A-Z0-9]{1,20}$/;
91
+
92
+ export function longHistoryEnabled(): boolean {
93
+ return process.env.NEPSE_LONG_HISTORY_ENABLED !== 'false';
94
+ }
95
+
96
+ function num(v: unknown): number | null {
97
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
98
+ }
99
+
100
+ const round2 = (v: number): number => Math.round(v * 100) / 100;
101
+
102
+ /**
103
+ * Decode one chart-feed payload into daily sessions, oldest first, one per
104
+ * date. `index` drops the volume (see the header). Pure — exported for tests.
105
+ *
106
+ * An empty result means "nothing usable" — `no_data`, a shape change, or
107
+ * arrays that disagree in length (read positionally, a shorter array would
108
+ * shift every later field onto the wrong day, so the payload is refused whole).
109
+ */
110
+ export function decodeChartHistory(payload: unknown, index: boolean, nowMs: number = Date.now()): ArchiveSession[] {
111
+ const p = (payload ?? {}) as Record<string, unknown>;
112
+ if (p.s !== 'ok') return [];
113
+ const cols = ['t', 'o', 'h', 'l', 'c', 'v'].map((k) => p[k]);
114
+ if (!cols.every(Array.isArray)) return [];
115
+ const [t, o, h, l, c, v] = cols as unknown[][];
116
+ if (!cols.every((col) => (col as unknown[]).length === t.length)) return [];
117
+
118
+ // A bar stamped more than two days ahead of now is not a session.
119
+ const latestSec = Math.floor(nowMs / 1000) + 2 * 86_400;
120
+ const byDate = new Map<string, ArchiveSession>();
121
+ for (let i = 0; i < t.length; i++) {
122
+ const time = num(t[i]);
123
+ const close = num(c[i]);
124
+ if (time === null || time < RANGE_START_SEC || time > latestSec || close === null || close <= 0) continue;
125
+ const date = new Date(time * 1000).toISOString().slice(0, 10);
126
+ // Missing open/high/low fall back to the close, never to zero: a zero
127
+ // low draws a spike to the axis that no session ever made.
128
+ const open = num(o[i]) ?? 0;
129
+ const high = num(h[i]) ?? 0;
130
+ const low = num(l[i]) ?? 0;
131
+ const o2 = round2(open > 0 ? open : close);
132
+ const c2 = round2(close);
133
+ const h2 = round2(high > 0 ? high : close);
134
+ const l2 = round2(low > 0 ? low : close);
135
+ const vol = index ? 0 : Math.max(0, num(v[i]) ?? 0);
136
+ byDate.set(date, {
137
+ date,
138
+ open: o2,
139
+ high: Math.max(h2, o2, c2),
140
+ low: Math.min(l2, o2, c2),
141
+ close: c2,
142
+ volume: vol,
143
+ turnover: 0,
144
+ });
145
+ }
146
+ return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
147
+ }
148
+
149
+ /**
150
+ * The long series with the recent sources laid over it. `recent` — the
151
+ * exchange's index history, or the archive for a company — is the figure on
152
+ * every date it covers; `long` fills only the dates it does not. Either may be
153
+ * missing. Oldest first. Pure — exported for tests.
154
+ */
155
+ export function mergeSessions(
156
+ long: readonly ArchiveSession[] | null,
157
+ recent: readonly ArchiveSession[] | null,
158
+ ): ArchiveSession[] {
159
+ if (!long || long.length === 0) return recent ? [...recent] : [];
160
+ if (!recent || recent.length === 0) return [...long];
161
+ const byDate = new Map<string, ArchiveSession>();
162
+ for (const s of long) byDate.set(s.date, s);
163
+ for (const s of recent) byDate.set(s.date, s);
164
+ return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
165
+ }
166
+
167
+ /** The request for one ticker. Exported for tests. */
168
+ export function longHistoryUrl(ticker: string, nowMs: number = Date.now()): string {
169
+ const params = new URLSearchParams({
170
+ type: 'get_advanced_chart',
171
+ symbol: ticker,
172
+ resolution: '1D',
173
+ rangeStartDate: String(RANGE_START_SEC),
174
+ rangeEndDate: String(Math.floor(nowMs / 1000) + 2 * 86_400),
175
+ from: '',
176
+ isAdjust: '0',
177
+ currencyCode: 'NPR',
178
+ });
179
+ return `${BASE}?${params.toString()}`;
180
+ }
181
+
182
+ interface Entry { sessions: ArchiveSession[]; fetchedAt: number; }
183
+
184
+ /**
185
+ * A six-hour cache of the feed, one entry per ticker, with failures remembered
186
+ * for a minute. The fetch is injected so the cache is testable without the
187
+ * network.
188
+ */
189
+ export class LongHistoryCache {
190
+ private readonly entries = new Map<string, Entry>();
191
+ private readonly failedAt = new Map<string, number>();
192
+ private readonly inflight = new Map<string, Promise<ArchiveSession[] | null>>();
193
+
194
+ constructor(
195
+ private readonly fetchPayload: (ticker: string) => Promise<unknown>,
196
+ private readonly clock: () => number = Date.now,
197
+ ) {}
198
+
199
+ /** Sessions oldest first, [] for a ticker the feed does not carry, null when it cannot be read. */
200
+ get(tickerRaw: string, index: boolean): Promise<ArchiveSession[] | null> {
201
+ const ticker = tickerRaw.toUpperCase();
202
+ if (!TICKER.test(ticker)) return Promise.resolve([]);
203
+ const now = this.clock();
204
+ const hit = this.entries.get(ticker);
205
+ if (hit && now - hit.fetchedAt < TTL_MS) return Promise.resolve(hit.sessions);
206
+ const failed = this.failedAt.get(ticker);
207
+ if (failed !== undefined && now - failed < RETRY_AFTER_FAILURE_MS) return Promise.resolve(hit?.sessions ?? null);
208
+ const running = this.inflight.get(ticker);
209
+ if (running) return running;
210
+
211
+ const p = (async (): Promise<ArchiveSession[] | null> => {
212
+ try {
213
+ const payload = await this.fetchPayload(ticker);
214
+ const noData = (payload as { s?: unknown } | null)?.s === 'no_data';
215
+ const sessions = noData ? [] : decodeChartHistory(payload, index, this.clock());
216
+ // Anything but a clean "no data" that decodes to nothing is a
217
+ // shape change or a bad answer far more often than a real empty
218
+ // series: never let it replace a good copy, never cache it.
219
+ if (!noData && sessions.length === 0) throw new Error('unreadable payload');
220
+ this.entries.delete(ticker);
221
+ this.entries.set(ticker, { sessions, fetchedAt: this.clock() });
222
+ this.failedAt.delete(ticker);
223
+ while (this.entries.size > MAX_ENTRIES) {
224
+ const oldest = this.entries.keys().next().value;
225
+ if (oldest === undefined) break;
226
+ this.entries.delete(oldest);
227
+ }
228
+ return sessions;
229
+ } catch (e) {
230
+ this.failedAt.set(ticker, this.clock());
231
+ log.warn(`${ticker} failed:`, e instanceof Error ? e.message : e);
232
+ // A stale series beats none: it is history, and history does not change.
233
+ return hit?.sessions ?? null;
234
+ } finally {
235
+ this.inflight.delete(ticker);
236
+ }
237
+ })();
238
+ this.inflight.set(ticker, p);
239
+ return p;
240
+ }
241
+ }
242
+
243
+ async function fetchChartPayload(ticker: string): Promise<unknown> {
244
+ const controller = new AbortController();
245
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
246
+ try {
247
+ const res = await fetch(longHistoryUrl(ticker), {
248
+ signal: controller.signal,
249
+ headers: { Accept: 'application/json, text/plain, */*' },
250
+ });
251
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
252
+ // Served as text/plain; parse the body ourselves.
253
+ return JSON.parse(await res.text());
254
+ } finally {
255
+ clearTimeout(timer);
256
+ }
257
+ }
258
+
259
+ const cache = new LongHistoryCache(fetchChartPayload);
260
+
261
+ /**
262
+ * A ticker's long daily history (`NEPSE` for the index), oldest first; [] when
263
+ * the feed does not carry it, null when it cannot be read or is switched off.
264
+ */
265
+ export function longHistory(ticker: string, index: boolean): Promise<ArchiveSession[] | null> {
266
+ if (!longHistoryEnabled()) return Promise.resolve(null);
267
+ return cache.get(ticker, index);
268
+ }
test/nepse.test.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
  import { isTickAcceptable, isBucketAligned } from '../src/calendar.js';
25
  import { setNepseSession, noteNepsePoll } from '../src/nepse/session-state.js';
26
  import { indexDaysToSessions, IndexHistoryCache } from '../src/nepse/index-history.js';
 
 
27
  import type { IndexDay } from '../src/nepse/client.js';
28
  import { sessionBook } from '../src/nepse/book.js';
29
  import { buildQuote, NEPSE_SESSION_CLOSE_MAX_AGE_MS, type QuoteRow } from '../src/quotes.js';
@@ -412,5 +414,92 @@ console.log('\n[13] NEPSE quotes are read from the session book');
412
  setNepseSession(false, null);
413
  }
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  console.log(`\n${failed === 0 ? '✓ PASS' : '✗ FAIL'} — ${passed} passed, ${failed} failed`);
416
  process.exit(failed === 0 ? 0 : 1);
 
24
  import { isTickAcceptable, isBucketAligned } from '../src/calendar.js';
25
  import { setNepseSession, noteNepsePoll } from '../src/nepse/session-state.js';
26
  import { indexDaysToSessions, IndexHistoryCache } from '../src/nepse/index-history.js';
27
+ import { decodeChartHistory, mergeSessions, longHistoryUrl, LongHistoryCache } from '../src/nepse/long-history.js';
28
+ import type { ArchiveSession } from '../src/nepse/archive.js';
29
  import type { IndexDay } from '../src/nepse/client.js';
30
  import { sessionBook } from '../src/nepse/book.js';
31
  import { buildQuote, NEPSE_SESSION_CLOSE_MAX_AGE_MS, type QuoteRow } from '../src/quotes.js';
 
414
  setNepseSession(false, null);
415
  }
416
 
417
+ console.log('\n[14] the long series (Merolagani\'s chart feed) under the recent sources');
418
+ {
419
+ // Stamps as the feed sends them (2026-09-25): 20:45Z lately, 05:45Z in
420
+ // older years — both on the business date in UTC.
421
+ const stamp = (date: string, hhmm = '20:45') => Date.parse(`${date}T${hhmm}:00Z`) / 1000;
422
+ const now = Date.parse('2026-09-25T12:00:00Z');
423
+ const payload = {
424
+ s: 'ok',
425
+ t: [stamp('2010-01-04', '05:45'), stamp('2016-03-02', '05:45'), stamp('2026-09-23'), stamp('2026-09-24'), stamp('2026-09-24'), stamp('2026-10-10'), stamp('2026-09-22')],
426
+ o: [543.2, 1400, 2670.28, 2611.03, 2611.03, 2700, 2650],
427
+ h: [543.2, 1398.5, 2671.04, 2629.94, 2629.94, 2700, 2662.71],
428
+ l: [543.2, 1390, 2613.13, 2605.32, 2605.32, 2700, 2637.57],
429
+ c: [543.20417, 1396.12345, 2618.03, 2629.81, 2629.81, 2700, 0],
430
+ v: [0, 5e8, 6356611395.73, 5447313168.2, 5447313168.2, 1, 7e9],
431
+ };
432
+ const idx = decodeChartHistory(payload, true, now);
433
+ check('one session per business date, oldest first', JSON.stringify(idx.map((s) => s.date)) === '["2010-01-04","2016-03-02","2026-09-23","2026-09-24"]', JSON.stringify(idx.map((s) => s.date)));
434
+ check('a 20:45Z stamp is its own UTC date (the exchange\'s 2026-09-24 session)',
435
+ idx[3]?.close === 2629.81 && idx[3]?.open === 2611.03 && idx[3]?.high === 2629.94 && idx[3]?.low === 2605.32);
436
+ check('a close-only day stays flat, rounded to two decimals', idx[0]?.open === 543.2 && idx[0]?.close === 543.2 && idx[0]?.high === 543.2);
437
+ check('a high below the open is widened to contain it', idx[1]?.high === 1400 && idx[1]?.close === 1396.12);
438
+ check('the index carries no volume (its v is rupee turnover)', idx.every((s) => s.volume === 0));
439
+ check('a bar stamped weeks ahead is not a session', !idx.some((s) => s.date === '2026-10-10'));
440
+ check('a zero close is dropped, not drawn to the axis', !idx.some((s) => s.date === '2026-09-22'));
441
+ const company = decodeChartHistory({ ...payload, t: payload.t.slice(2, 4), o: payload.o.slice(2, 4), h: payload.h.slice(2, 4), l: payload.l.slice(2, 4), c: payload.c.slice(2, 4), v: [88903, 70749] }, false, now);
442
+ check('a company keeps its traded quantity', company[1]?.volume === 70749);
443
+ check('no_data decodes to nothing', decodeChartHistory({ s: 'no_data', nextTime: 1 }, false, now).length === 0);
444
+ check('arrays of different lengths are refused whole', decodeChartHistory({ ...payload, c: payload.c.slice(1) }, false, now).length === 0);
445
+
446
+ const url = new URL(longHistoryUrl('NABIL', now));
447
+ check('asks for unadjusted daily bars of the ticker', url.searchParams.get('isAdjust') === '0'
448
+ && url.searchParams.get('resolution') === '1D' && url.searchParams.get('symbol') === 'NABIL');
449
+ check('…from before the exchange opened', Number(url.searchParams.get('rangeStartDate')) < Date.UTC(1994, 0, 1) / 1000);
450
+
451
+ const s = (date: string, close: number): ArchiveSession => ({ date, open: close, high: close, low: close, close, volume: 1, turnover: 0 });
452
+ const long = [s('2010-01-04', 1), s('2024-03-04', 2), s('2024-03-05', 3), s('2024-03-06', 4)];
453
+ const recent = [s('2024-03-04', 20), s('2024-03-06', 40), s('2024-03-07', 50)];
454
+ const merged = mergeSessions(long, recent);
455
+ check('the recent source wins on every date it covers', merged.find((m) => m.date === '2024-03-04')?.close === 20 && merged.find((m) => m.date === '2024-03-06')?.close === 40);
456
+ check('the long series fills the years before, and a session the archive missed',
457
+ merged[0]?.date === '2010-01-04' && merged.find((m) => m.date === '2024-03-05')?.close === 3);
458
+ check('merged oldest first, one per date', JSON.stringify(merged.map((m) => m.date)) === '["2010-01-04","2024-03-04","2024-03-05","2024-03-06","2024-03-07"]');
459
+ check('either side missing is the other alone', mergeSessions(null, recent).length === 3 && mergeSessions(long, null).length === 4 && mergeSessions(null, null).length === 0);
460
+
461
+ // The seed composes the merged series exactly as before.
462
+ const seed = composeSessionSeed(merged, null, '1M');
463
+ check('a monthly seed spans the long series (January 2010, March 2024)',
464
+ seed.bars.length === 2 && seed.bars[0]?.time === nepseBucketStart('1M', Date.parse('2010-01-04T06:00:00Z')) && seed.bars[1]?.close === 50,
465
+ JSON.stringify(seed.bars));
466
+
467
+ // The cache: TTL, no_data, failures, coalescing.
468
+ let t = Date.parse('2026-09-25T12:00:00Z');
469
+ let calls = 0;
470
+ let mode: 'ok' | 'no_data' | 'fail' | 'garbage' = 'ok';
471
+ const cache = new LongHistoryCache(async () => {
472
+ calls++;
473
+ if (mode === 'fail') throw new Error('down');
474
+ if (mode === 'garbage') return { s: 'ok', t: [1] };
475
+ if (mode === 'no_data') return { s: 'no_data' };
476
+ return payload;
477
+ }, () => t);
478
+ const [a, b] = await Promise.all([cache.get('nepse', true), cache.get('NEPSE', true)]);
479
+ check('two charts opening together share one request', calls === 1 && a === b && (a?.length ?? 0) === 4);
480
+ t += 60 * 60_000;
481
+ await cache.get('NEPSE', true);
482
+ check('served from the cache inside six hours', calls === 1);
483
+ t += 6 * 60 * 60_000;
484
+ mode = 'garbage';
485
+ const stale = await cache.get('NEPSE', true);
486
+ check('an unreadable answer never replaces a good copy', calls === 2 && stale?.length === 4);
487
+ mode = 'ok';
488
+ await cache.get('NEPSE', true);
489
+ check('…and is remembered for a minute rather than retried per chart', calls === 2);
490
+ t += 61_000;
491
+ await cache.get('NEPSE', true);
492
+ check('retried after the minute', calls === 3);
493
+ mode = 'no_data';
494
+ const none = await cache.get('GBILD', false);
495
+ const again = await cache.get('GBILD', false);
496
+ check('no_data is a real answer, cached like one', none !== null && none.length === 0 && again?.length === 0 && calls === 4);
497
+ mode = 'fail';
498
+ const down = await cache.get('NICA', false);
499
+ check('down with no copy → null (the other sources stand alone)', down === null && calls === 5);
500
+ const slash = await cache.get('GBILD86/87', false);
501
+ check('a ticker the feed cannot carry is not asked for', slash?.length === 0 && calls === 5);
502
+ }
503
+
504
  console.log(`\n${failed === 0 ? '✓ PASS' : '✗ FAIL'} — ${passed} passed, ${failed} failed`);
505
  process.exit(failed === 0 ? 0 : 1);