dexbot 1.6.0 → 1.6.2

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +5 -4
  3. package/analysis/ama_fitting/package.json +1 -1
  4. package/analysis/tradingview/tradingview_uplot_chart_generator.ts +19 -5
  5. package/analysis/trend_detection/package.json +1 -1
  6. package/claw/package.json +1 -1
  7. package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
  8. package/claw/runtimes/openclaw-plugin/package.json +1 -1
  9. package/claw/tests/test_claw_mcp_transport.ts +2 -2
  10. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
  11. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +19 -5
  12. package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
  13. package/dist/dexbot.js +1 -1
  14. package/dist/dexbot.js.map +1 -1
  15. package/dist/market_adapter/inputs/fetch_book_data.js +2 -2
  16. package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -1
  17. package/dist/market_adapter/inputs/fetch_lp_data.d.ts +22 -3
  18. package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
  19. package/dist/market_adapter/inputs/fetch_lp_data.js +15 -15
  20. package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
  21. package/dist/market_adapter/inputs/kibana_feed_source.js +2 -2
  22. package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -1
  23. package/dist/market_adapter/inputs/window_cache.d.ts +54 -8
  24. package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -1
  25. package/dist/market_adapter/inputs/window_cache.js +328 -87
  26. package/dist/market_adapter/inputs/window_cache.js.map +1 -1
  27. package/dist/modules/account_bots.js +2 -2
  28. package/dist/modules/account_bots.js.map +1 -1
  29. package/dist/modules/bitshares-native/subscriptions.d.ts +1 -1
  30. package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
  31. package/dist/modules/bitshares-native/subscriptions.js +18 -4
  32. package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
  33. package/dist/modules/bitshares_client.d.ts.map +1 -1
  34. package/dist/modules/bitshares_client.js +10 -0
  35. package/dist/modules/bitshares_client.js.map +1 -1
  36. package/dist/modules/constants.d.ts +12 -3
  37. package/dist/modules/constants.d.ts.map +1 -1
  38. package/dist/modules/constants.js +66 -20
  39. package/dist/modules/constants.js.map +1 -1
  40. package/dist/modules/credit_runtime.d.ts.map +1 -1
  41. package/dist/modules/credit_runtime.js +24 -6
  42. package/dist/modules/credit_runtime.js.map +1 -1
  43. package/dist/modules/dexbot_class.d.ts +34 -6
  44. package/dist/modules/dexbot_class.d.ts.map +1 -1
  45. package/dist/modules/dexbot_class.js +94 -17
  46. package/dist/modules/dexbot_class.js.map +1 -1
  47. package/dist/modules/dexbot_cow_runtime.d.ts +57 -6
  48. package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
  49. package/dist/modules/dexbot_cow_runtime.js +474 -38
  50. package/dist/modules/dexbot_cow_runtime.js.map +1 -1
  51. package/dist/modules/dexbot_fill_runtime.d.ts +53 -1
  52. package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
  53. package/dist/modules/dexbot_fill_runtime.js +277 -9
  54. package/dist/modules/dexbot_fill_runtime.js.map +1 -1
  55. package/dist/modules/dexbot_maintenance_runtime.d.ts +69 -0
  56. package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
  57. package/dist/modules/dexbot_maintenance_runtime.js +277 -7
  58. package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
  59. package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
  60. package/dist/modules/dexbot_startup_runtime.js +6 -1
  61. package/dist/modules/dexbot_startup_runtime.js.map +1 -1
  62. package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
  63. package/dist/modules/dexbot_state_recovery.js +32 -4
  64. package/dist/modules/dexbot_state_recovery.js.map +1 -1
  65. package/dist/modules/market_adapter_whitelist.js +1 -1
  66. package/dist/modules/market_adapter_whitelist.js.map +1 -1
  67. package/dist/modules/order/grid.d.ts +3 -1
  68. package/dist/modules/order/grid.d.ts.map +1 -1
  69. package/dist/modules/order/grid.js +93 -16
  70. package/dist/modules/order/grid.js.map +1 -1
  71. package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
  72. package/dist/modules/order/grid_reconcile_internal.js +73 -7
  73. package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
  74. package/dist/modules/order/logger.js +1 -1
  75. package/dist/modules/order/logger.js.map +1 -1
  76. package/dist/modules/order/manager.d.ts +14 -1
  77. package/dist/modules/order/manager.d.ts.map +1 -1
  78. package/dist/modules/order/manager.js +54 -2
  79. package/dist/modules/order/manager.js.map +1 -1
  80. package/dist/modules/order/sync_engine.d.ts.map +1 -1
  81. package/dist/modules/order/sync_engine.js +183 -10
  82. package/dist/modules/order/sync_engine.js.map +1 -1
  83. package/dist/modules/order/utils/math.d.ts +65 -15
  84. package/dist/modules/order/utils/math.d.ts.map +1 -1
  85. package/dist/modules/order/utils/math.js +93 -22
  86. package/dist/modules/order/utils/math.js.map +1 -1
  87. package/dist/modules/order/utils/order.d.ts +51 -1
  88. package/dist/modules/order/utils/order.d.ts.map +1 -1
  89. package/dist/modules/order/utils/order.js +122 -2
  90. package/dist/modules/order/utils/order.js.map +1 -1
  91. package/dist/modules/utils/errors.d.ts +22 -0
  92. package/dist/modules/utils/errors.d.ts.map +1 -1
  93. package/dist/modules/utils/errors.js +30 -0
  94. package/dist/modules/utils/errors.js.map +1 -1
  95. package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
  96. package/dist/scripts/generate_market_adapter_whitelist.js +3 -2
  97. package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
  98. package/docs/BITSHARES_ONBOARDING.md +14 -6
  99. package/docs/COPY_ON_WRITE_MASTER_PLAN.md +1 -1
  100. package/docs/DEXBOT_COMPARISON.md +3 -7
  101. package/docs/EVOLUTION.md +9 -7
  102. package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +4 -9
  103. package/docs/GRID_RECALCULATION.md +8 -7
  104. package/docs/LIFECYCLE.md +2 -2
  105. package/docs/README.md +9 -1
  106. package/docs/architecture.md +7 -7
  107. package/docs/developer_guide.md +4 -4
  108. package/market_adapter/README.md +14 -11
  109. package/package.json +1 -1
  110. package/scripts/README.md +4 -4
  111. package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +0 -2455
  112. package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +0 -218
  113. package/analysis/tradingview/h-bts_tradingview.html +0 -1570
  114. package/analysis/tradingview/t-bts_tradingview.html +0 -1570
@@ -1,20 +1,33 @@
1
1
  'use strict';
2
2
  /**
3
- * WINDOW CACHE — generic persistent bucket cache for windowed Kibana candle fetches.
3
+ * WINDOW CACHE — persistent bucket cache for windowed Kibana candle fetches.
4
4
  *
5
- * Problem: callers anchor their range at floored-"now", so every run shifts all
6
- * windows and an exact time-range match would invalidate the whole cache.
7
- * This module makes reuse range-aware instead: sibling chunk files are loaded
8
- * once, buckets inside the new window are kept, and only missing buckets are
9
- * queried (plus a tail refresh for late-indexed trades).
5
+ * Storage is decoupled from querying: candles live in fixed calendar-month
6
+ * shards (`<base>.shard_YYYY-MM.json`, UTC), one file per month with a stable
7
+ * name that never shifts. A run maps its requested range onto the overlapping
8
+ * shards, loads ONLY those files, fetches only genuinely missing buckets, and
9
+ * writes back ONLY shards that gained buckets or query coverage. Pure-reuse
10
+ * runs perform zero writes and zero deletes.
10
11
  *
11
- * Chunk files live next to `outPath`:
12
- * `<base>.chunk_<ii>_<YYYY-MM-DD>_<YYYY-MM-DD><ext>`
13
- * each holding `{ meta, candles }`. Callers supply:
14
- * - `requestKey` — opaque identity object stored in each chunk's meta,
12
+ * Each shard holds `{ meta, candles }` where `meta.queriedRanges` records the
13
+ * spans actually queried to produce the data (monotonically unioned on every
14
+ * write). Missing buckets are pruned only against recorded query coverage —
15
+ * the absence of local buckets alone never certifies history as empty.
16
+ *
17
+ * Legacy `*.chunk_<ii>_<start>_<end>.json` files (run-relative naming) are
18
+ * still read: their buckets and query coverage are absorbed into the
19
+ * overlapping shards, and a legacy file is deleted once every one of its
20
+ * buckets provably lives in a shard. Disjoint legacy files are simply never
21
+ * loaded and never touched — narrow runs cannot wipe older history by
22
+ * construction (no orphan-deletion pass exists anymore).
23
+ *
24
+ * Callers supply:
25
+ * - `requestKey` — opaque identity object stored in each shard's meta,
15
26
  * - `isMatch(meta, requestKey)` — same-pool/feed/interval/assets check,
16
27
  * - `fetchRange(gteIso, lteIso)` — query one (sub-)range, gap-filled grid,
17
- * - `metaForWindow(window)` — meta to persist with a (re)fetched chunk.
28
+ * - `metaForWindow(window)` — identity meta fields (source/feed/pool/...);
29
+ * the runner overrides timeRange with the shard bounds and drops
30
+ * chunkIndex, which is meaningless for stable shards.
18
31
  *
19
32
  * Node-only (disk I/O via storage). Browser-safe code must not import this.
20
33
  */
@@ -34,22 +47,61 @@ const TAIL_REFRESH_HOURS = 48;
34
47
  // no trades stay empty forever, so re-querying them every shifted rerun is
35
48
  // pure waste. Only the recent past is re-checked (see TAIL_REFRESH_HOURS).
36
49
  const IMMUTABLE_WINDOW_AGE_MS = 7 * 24 * 3600 * 1000;
37
- function siblingChunkFiles(outPath) {
50
+ // ─── Month-shard naming ───────────────────────────────────────────────────────
51
+ // Shard key is the UTC calendar month; bounds are half-open [start, end) so
52
+ // every bucket timestamp maps to exactly one shard (a bucket exactly at a
53
+ // month boundary belongs to the new month).
54
+ function shardKeyForTimestamp(tsMs) {
55
+ const d = new Date(Number(tsMs));
56
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
57
+ }
58
+ function shardBoundsForKey(key) {
59
+ const parts = String(key).split('-').map(Number);
60
+ const start = Date.UTC(parts[0], parts[1] - 1, 1);
61
+ const end = parts[1] === 12 ? Date.UTC(parts[0] + 1, 0, 1) : Date.UTC(parts[0], parts[1], 1);
62
+ return { start, end };
63
+ }
64
+ function shardKeysForRange(gteMs, lteMs) {
65
+ const keys = [];
66
+ let cursor = new Date(Date.UTC(new Date(Number(gteMs)).getUTCFullYear(), new Date(Number(gteMs)).getUTCMonth(), 1));
67
+ const last = new Date(Number(lteMs));
68
+ while (cursor <= last) {
69
+ keys.push(shardKeyForTimestamp(cursor.getTime()));
70
+ cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1));
71
+ }
72
+ return keys;
73
+ }
74
+ function shardRangeOverlaps(shardKey, gteMs, lteMs) {
75
+ const { start, end } = shardBoundsForKey(shardKey);
76
+ return start <= lteMs && end > gteMs;
77
+ }
78
+ function shardPathFor(outPath, shardKey) {
79
+ const parsed = path.parse(outPath);
80
+ return path.join(parsed.dir, `${parsed.name}.shard_${shardKey}${parsed.ext}`);
81
+ }
82
+ function siblingCacheFiles(outPath) {
38
83
  const resolved = path.resolve(outPath);
39
84
  const parsed = path.parse(resolved);
40
85
  if (!storage.exists(parsed.dir))
41
86
  return [];
42
- const prefix = `${parsed.name}.chunk_`;
43
- return storage.readdir(parsed.dir)
44
- .filter((name) => name.startsWith(prefix) && name.endsWith(parsed.ext))
45
- .map((name) => path.join(parsed.dir, name))
46
- .sort();
47
- }
48
- function chunkPathFor(outPath, index, window) {
49
- const parsed = path.parse(outPath);
50
- const start = String(window.gte).slice(0, 10);
51
- const end = String(window.lte).slice(0, 10);
52
- return path.join(parsed.dir, `${parsed.name}.chunk_${String(index).padStart(2, '0')}_${start}_${end}${parsed.ext}`);
87
+ const shardPrefix = `${parsed.name}.shard_`;
88
+ const legacyPrefix = `${parsed.name}.chunk_`;
89
+ const out = [];
90
+ for (const name of storage.readdir(parsed.dir)) {
91
+ if (!name.endsWith(parsed.ext))
92
+ continue;
93
+ if (name.startsWith(shardPrefix)) {
94
+ const key = name.slice(shardPrefix.length, name.length - parsed.ext.length);
95
+ if (!/^\d{4}-\d{2}$/.test(key))
96
+ continue;
97
+ out.push({ file: path.join(parsed.dir, name), kind: 'shard', shardKey: key });
98
+ }
99
+ else if (name.startsWith(legacyPrefix)) {
100
+ out.push({ file: path.join(parsed.dir, name), kind: 'legacy', shardKey: null });
101
+ }
102
+ }
103
+ out.sort((a, b) => (a.file < b.file ? -1 : 1));
104
+ return out;
53
105
  }
54
106
  function readCacheChunk(chunkFile, requestKey, isMatch) {
55
107
  // Same-identity check WITHOUT the timeRange match — any chunk for this
@@ -86,28 +138,30 @@ function readCacheChunk(chunkFile, requestKey, isMatch) {
86
138
  return null;
87
139
  }
88
140
  }
89
- // Previously-proven coverage surviving a same-filename overwrite, clipped
90
- // to the window being persisted. Sound: any in-window range the old file
91
- // vouched for was really queried, and had it held trades those candles
92
- // would be in that same file (hence carried forward as reusable, never
93
- // missing). Out-of-window parts are dropped by the clip, so nothing is
94
- // vouched for beyond what the rewritten file can testify to.
95
- function priorQueriedInWindow(chunkFile, requestKey, isMatch, gteMs, lteMs) {
96
- const chunk = readCacheChunk(chunkFile, requestKey, isMatch);
97
- if (!chunk)
98
- return [];
99
- return chunk.queried
100
- .filter((q) => q.lte > gteMs && q.gte < lteMs)
101
- .map((q) => ({ gte: Math.max(q.gte, gteMs), lte: Math.min(q.lte, lteMs) }));
141
+ function rangesOverlap(aGte, aLte, bGte, bLte) {
142
+ return aGte <= bLte && aLte >= bGte;
102
143
  }
103
- function loadBucketCache(outPath, requestKey, isMatch) {
144
+ function loadBucketCache(outPath, requestKey, isMatch, range) {
104
145
  const byTs = new Map();
105
146
  const fileCover = [];
147
+ const legacy = [];
148
+ const shards = [];
106
149
  let files = 0;
107
- for (const file of siblingChunkFiles(outPath)) {
108
- const chunk = readCacheChunk(file, requestKey, isMatch);
150
+ const scoped = range && Number.isFinite(range.gte) && Number.isFinite(range.lte);
151
+ for (const entry of siblingCacheFiles(outPath)) {
152
+ if (entry.kind === 'shard' && entry.shardKey) {
153
+ // Shards outside the requested range are never even opened —
154
+ // a narrow run reads only the months it needs.
155
+ if (scoped && !shardRangeOverlaps(entry.shardKey, range.gte, range.lte))
156
+ continue;
157
+ }
158
+ const chunk = readCacheChunk(entry.file, requestKey, isMatch);
109
159
  if (!chunk)
110
160
  continue;
161
+ if (entry.kind === 'legacy' && scoped
162
+ && chunk.rangeGte !== null && chunk.rangeLte !== null
163
+ && !rangesOverlap(chunk.rangeGte, chunk.rangeLte, range.gte, range.lte))
164
+ continue;
111
165
  files += 1;
112
166
  fileCover.push({ gte: chunk.rangeGte, lte: chunk.rangeLte, count: chunk.candles.length, queried: chunk.queried });
113
167
  for (const c of chunk.candles) {
@@ -120,8 +174,14 @@ function loadBucketCache(outPath, requestKey, isMatch) {
120
174
  if (!prev || Number(c[5] || 0) > Number(prev[5] || 0))
121
175
  byTs.set(ts, c);
122
176
  }
177
+ if (entry.kind === 'legacy') {
178
+ legacy.push({ file: entry.file, candles: chunk.candles.filter((c) => Array.isArray(c)), queried: chunk.queried });
179
+ }
180
+ else if (entry.shardKey) {
181
+ shards.push({ file: entry.file, shardKey: entry.shardKey, candles: chunk.candles.filter((c) => Array.isArray(c)), queried: chunk.queried });
182
+ }
123
183
  }
124
- return { byTs, files, fileCover };
184
+ return { byTs, files, fileCover, legacy, shards };
125
185
  }
126
186
  function cachedCandlesInRange(localCache, gteMs, lteMs) {
127
187
  const out = [];
@@ -132,6 +192,47 @@ function cachedCandlesInRange(localCache, gteMs, lteMs) {
132
192
  out.sort((a, b) => a[0] - b[0]);
133
193
  return out;
134
194
  }
195
+ // ─── Queried-range set ops ────────────────────────────────────────────────────
196
+ // Coverage provenance: normalize recorded spans (sort, merge overlapping or
197
+ // bucket-adjacent) so growth checks and absorption decisions are exact.
198
+ function unionQueriedRanges(ranges, bucketMs) {
199
+ const clean = (ranges || [])
200
+ .filter((q) => q && Number.isFinite(Number(q.gte)) && Number.isFinite(Number(q.lte)) && Number(q.lte) >= Number(q.gte))
201
+ .map((q) => ({ gte: Number(q.gte), lte: Number(q.lte) }))
202
+ .sort((a, b) => a.gte - b.gte || a.lte - b.lte);
203
+ const merged = [];
204
+ const gap = Number.isFinite(Number(bucketMs)) && Number(bucketMs) > 0 ? Number(bucketMs) : 0;
205
+ for (const q of clean) {
206
+ const top = merged[merged.length - 1];
207
+ if (top && q.gte <= top.lte + gap) {
208
+ if (q.lte > top.lte)
209
+ top.lte = q.lte;
210
+ }
211
+ else {
212
+ merged.push({ gte: q.gte, lte: q.lte });
213
+ }
214
+ }
215
+ return merged;
216
+ }
217
+ function rangesCoveredBy(have, want) {
218
+ for (const w of want || []) {
219
+ let covered = false;
220
+ for (const h of have || []) {
221
+ if (h.gte <= w.gte && h.lte >= w.lte) {
222
+ covered = true;
223
+ break;
224
+ }
225
+ }
226
+ if (!covered)
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+ function clipRangeTo(q, gteMs, lteMs) {
232
+ const gte = Math.max(q.gte, gteMs);
233
+ const lte = Math.min(q.lte, lteMs);
234
+ return lte >= gte ? { gte, lte } : null;
235
+ }
135
236
  function addUtcMonths(date, months) {
136
237
  const result = new Date(date.getTime());
137
238
  const day = result.getUTCDate();
@@ -227,32 +328,20 @@ function persistCacheChunk(chunkFile, meta, candles) {
227
328
  };
228
329
  writeJsonAtomic(chunkFile, payload);
229
330
  }
230
- // Stale chunk files accumulate when shifted windows change date-based file
231
- // names (an hour shift crossing midnight orphans the old name). Only files
232
- // whose embedded meta matches this request are eligible, and only once the
233
- // caller has completed all windows — a failed run never deletes.
234
- function cleanupOrphanCacheChunks(outPath, requestKey, isMatch, activeFiles) {
235
- const removed = [];
236
- try {
237
- for (const file of siblingChunkFiles(outPath)) {
238
- if (activeFiles.has(path.resolve(file)))
239
- continue;
240
- if (!readCacheChunk(file, requestKey, isMatch))
241
- continue;
242
- try {
243
- storage.unlink(file);
244
- removed.push(file);
245
- }
246
- catch (err) {
247
- console.warn(` Could not remove orphan chunk ${path.relative(process.cwd(), file)}: ${getErrorMessage(err)}`);
248
- }
331
+ function candlesEqual(a, b) {
332
+ if (a.length !== b.length)
333
+ return false;
334
+ for (let i = 0; i < a.length; i++) {
335
+ const x = a[i];
336
+ const y = b[i];
337
+ if (!Array.isArray(x) || !Array.isArray(y) || x.length !== y.length)
338
+ return false;
339
+ for (let j = 0; j < x.length; j++) {
340
+ if (Number(x[j]) !== Number(y[j]))
341
+ return false;
249
342
  }
250
343
  }
251
- catch (err) {
252
- console.warn(` Orphan chunk cleanup skipped: ${getErrorMessage(err)}`);
253
- return [];
254
- }
255
- return removed;
344
+ return true;
256
345
  }
257
346
  /**
258
347
  * Reuse plan for one window: buckets already on disk + the ranges that still
@@ -383,9 +472,14 @@ async function fetchRangeLogged(fetchRange, opts) {
383
472
  function higherVolumeWins(existing, incoming) {
384
473
  return incoming[5] > existing[5] ? incoming : existing;
385
474
  }
475
+ function sortedCandles(byTs) {
476
+ return [...byTs.values()].sort((a, b) => Number(a[0]) - Number(b[0]));
477
+ }
386
478
  /**
387
- * Run windows with bucket-level reuse. `windows` entries are
388
- * `{ index, gte, lte, file }` (1-based index). Returns merged candles.
479
+ * Run windows with bucket-level reuse against month-shard storage. `windows`
480
+ * entries are `{ index, gte, lte }` (1-based index, fetch-planning splits —
481
+ * `chunkMonths` controls query batching only, never file layout). Returns
482
+ * merged candles clipped to the requested windows.
389
483
  *
390
484
  * Fetch policy per window: exact reuse when nothing is missing, sub-range
391
485
  * queries merged over local when gaps are small (and `allowSubFetch`), else
@@ -394,6 +488,13 @@ function higherVolumeWins(existing, incoming) {
394
488
  * fetch reports partial (`{ candles, complete: false }`) is merged into
395
489
  * this run's output but NOT persisted, so the missing side is re-queried
396
490
  * on the next run instead of being baked in as gap-filled zeros.
491
+ *
492
+ * Persistence is per shard and write-on-change only: a shard file is
493
+ * rewritten solely when it gains buckets or query coverage. Reuse-only runs
494
+ * touch nothing on disk. Legacy `*.chunk_*` files overlapping the run are
495
+ * absorbed (buckets + coverage folded into the shards) and deleted once
496
+ * every one of their buckets provably lives in a shard; disjoint legacy
497
+ * files are never loaded and never deleted.
397
498
  */
398
499
  async function runCachedWindows(opts) {
399
500
  const { windows, outPath, requestKey, isMatch, metaForWindow, fetchRange, bucketMs } = opts;
@@ -406,24 +507,96 @@ async function runCachedWindows(opts) {
406
507
  timeoutMs: opts.fetchTimeoutMs,
407
508
  onRetry: opts.onFetchRetry,
408
509
  };
409
- const localCache = loadBucketCache(outPath, requestKey, isMatch);
510
+ const bounds = windows.map((w) => ({
511
+ gte: Date.parse(String(w.gte)),
512
+ lte: Date.parse(String(w.lte)),
513
+ }));
514
+ const finiteBounds = bounds.every((b) => Number.isFinite(b.gte) && Number.isFinite(b.lte));
515
+ const overall = finiteBounds && bounds.length > 0
516
+ ? { gte: Math.min(...bounds.map((b) => b.gte)), lte: Math.max(...bounds.map((b) => b.lte)) }
517
+ : null;
518
+ // Scoped load: only shards/legacy files overlapping the run are opened.
519
+ const localCache = loadBucketCache(outPath, requestKey, isMatch, overall);
410
520
  if (localCache.files > 0) {
411
- console.log(` Local cache: ${localCache.files} chunk files, ${localCache.byTs.size} buckets — fetching only what is missing`);
521
+ console.log(` Local cache: ${localCache.files} file(s), ${localCache.byTs.size} buckets — fetching only what is missing`);
522
+ }
523
+ // In-memory shard states for every month the run touches, seeded from
524
+ // whatever shard files already exist.
525
+ const shardStates = new Map();
526
+ if (overall) {
527
+ for (const key of shardKeysForRange(overall.gte, overall.lte)) {
528
+ const file = shardPathFor(outPath, key);
529
+ const existing = localCache.shards.find((s) => s.shardKey === key);
530
+ const candles = new Map();
531
+ if (existing) {
532
+ for (const c of existing.candles) {
533
+ const ts = Number(c[0]);
534
+ if (Number.isFinite(ts))
535
+ candles.set(ts, c);
536
+ }
537
+ }
538
+ shardStates.set(key, {
539
+ key, file, candles,
540
+ queried: unionQueriedRanges(existing?.queried ?? [], bucketMs),
541
+ pendingQueried: [],
542
+ });
543
+ }
544
+ // Fold legacy query coverage into the overlapping shards so the
545
+ // provenance survives absorption (a span the legacy file really
546
+ // queried stays proven-queried after the file is gone).
547
+ for (const leg of localCache.legacy) {
548
+ for (const q of leg.queried) {
549
+ for (const state of shardStates.values()) {
550
+ const { start, end } = shardBoundsForKey(state.key);
551
+ const clipped = clipRangeTo(q, start, end);
552
+ if (clipped)
553
+ state.pendingQueried.push(clipped);
554
+ }
555
+ }
556
+ }
412
557
  }
558
+ const noteCompletedWindow = (gteMs, lteMs, candles, queried) => {
559
+ // Later windows plan against what earlier windows proved: fresh
560
+ // buckets join the pool and fresh coverage joins the cover, so a
561
+ // re-query inside one run never fetches the same span twice.
562
+ // Only complete windows feed this — partial data stays in this
563
+ // run's output and is re-queried next run.
564
+ for (const c of candles) {
565
+ if (!Array.isArray(c))
566
+ continue;
567
+ const ts = Number(c[0]);
568
+ if (!Number.isFinite(ts))
569
+ continue;
570
+ const prev = localCache.byTs.get(ts);
571
+ if (!prev || Number(c[5] || 0) > Number(prev[5] || 0))
572
+ localCache.byTs.set(ts, c);
573
+ }
574
+ if (queried.length > 0) {
575
+ localCache.fileCover.push({ gte: gteMs, lte: lteMs, count: candles.length, queried });
576
+ }
577
+ // Fan window results out to the shards they fall in.
578
+ for (const state of shardStates.values()) {
579
+ const { start, end } = shardBoundsForKey(state.key);
580
+ for (const c of candles) {
581
+ const ts = Number(c[0]);
582
+ if (!Number.isFinite(ts) || ts < start || ts >= end)
583
+ continue;
584
+ const prev = state.candles.get(ts);
585
+ if (!prev || Number(c[5] || 0) > Number(prev[5] || 0))
586
+ state.candles.set(ts, c);
587
+ }
588
+ for (const q of queried) {
589
+ const clipped = clipRangeTo(q, start, end);
590
+ if (clipped)
591
+ state.pendingQueried.push(clipped);
592
+ }
593
+ }
594
+ };
413
595
  let merged = [];
414
596
  for (const windowEntry of windows) {
415
597
  const tag = formatWindowLine('Chunk', windowEntry.index, total, windowEntry.gte, windowEntry.lte);
416
598
  const gteMs = Date.parse(String(windowEntry.gte));
417
599
  const lteMs = Date.parse(String(windowEntry.lte));
418
- // Exact-match fast path: chunk file already holds this exact range.
419
- const exact = readCacheChunk(windowEntry.file, requestKey, isMatch);
420
- if (exact && exact.rangeGte === gteMs && exact.rangeLte === lteMs) {
421
- console.log(`${tag} (cached ${exact.candles.length} candles)`);
422
- merged = merged.length === 0
423
- ? exact.candles
424
- : mergeCandles(merged, exact.candles, { onCollision: higherVolumeWins });
425
- continue;
426
- }
427
600
  const plan = planWindowReuse(localCache, {
428
601
  gteMs, lteMs, bucketMs,
429
602
  isTail: windowEntry.index === total,
@@ -433,7 +606,8 @@ async function runCachedWindows(opts) {
433
606
  const reusableNote = reusable.length > 0 ? `, ${reusable.length} buckets local` : '';
434
607
  if (inputsValid && missing.length === 0 && (reusable.length > 0 || localCache.files > 0)) {
435
608
  console.log(`${tag} (reused ${reusable.length} local buckets, nothing missing)`);
436
- persistCacheChunk(windowEntry.file, { ...metaForWindow(windowEntry), fetchedAt: new Date().toISOString(), queriedRanges: priorQueriedInWindow(windowEntry.file, requestKey, isMatch, gteMs, lteMs) }, reusable);
609
+ // Reuse is read-only: shard files already hold these buckets, so
610
+ // nothing is rewritten.
437
611
  merged = merged.length === 0
438
612
  ? reusable
439
613
  : mergeCandles(merged, reusable, { onCollision: higherVolumeWins });
@@ -463,10 +637,8 @@ async function runCachedWindows(opts) {
463
637
  // Clamp to the window (drops the one-bucket over-fetch above).
464
638
  candles = mergedLocal.filter((c) => Number(c[0]) >= gteMs && Number(c[0]) <= lteMs);
465
639
  // Claimed coverage is the canonical missing ranges (a conservative
466
- // subset of what was actually queried with the +1-bucket overlap),
467
- // plus previously-proven in-window coverage surviving the rewrite.
468
- queriedRanges = missing.map((m) => ({ gte: m.gte, lte: m.lte }))
469
- .concat(priorQueriedInWindow(windowEntry.file, requestKey, isMatch, gteMs, lteMs));
640
+ // subset of what was actually queried with the +1-bucket overlap).
641
+ queriedRanges = missing.map((m) => ({ gte: m.gte, lte: m.lte }));
470
642
  }
471
643
  else {
472
644
  if (reusable.length > 0) {
@@ -481,7 +653,7 @@ async function runCachedWindows(opts) {
481
653
  queriedRanges = [{ gte: gteMs, lte: lteMs }];
482
654
  }
483
655
  if (windowComplete) {
484
- persistCacheChunk(windowEntry.file, { ...metaForWindow(windowEntry), fetchedAt: new Date().toISOString(), queriedRanges }, candles);
656
+ noteCompletedWindow(gteMs, lteMs, candles, queriedRanges);
485
657
  }
486
658
  else {
487
659
  console.log(`${tag} (partial — kept for this run, not cached; will re-query next run)`);
@@ -490,11 +662,80 @@ async function runCachedWindows(opts) {
490
662
  ? candles
491
663
  : mergeCandles(merged, candles, { onCollision: higherVolumeWins });
492
664
  }
493
- const removed = cleanupOrphanCacheChunks(outPath, requestKey, isMatch, new Set(windows.map((w) => path.resolve(w.file))));
494
- if (removed.length > 0) {
495
- console.log(` Cleaned ${removed.length} orphan chunk file(s): ${removed.map((f) => path.basename(f)).join(', ')}`);
665
+ // Flush: rewrite only shards that gained buckets or coverage. Everything
666
+ // else on disk is already current.
667
+ if (overall) {
668
+ const fetchedAt = new Date().toISOString();
669
+ for (const state of shardStates.values()) {
670
+ const { start, end } = shardBoundsForKey(state.key);
671
+ // Absorb every loaded bucket in this shard's span (legacy files
672
+ // included): presence on disk already makes a bucket reusable,
673
+ // so folding it into its home shard preserves trust semantics
674
+ // exactly while letting the legacy file retire below.
675
+ for (const [ts, c] of localCache.byTs) {
676
+ if (ts < start || ts >= end)
677
+ continue;
678
+ const prev = state.candles.get(ts);
679
+ if (!prev || Number(c[5] || 0) > Number(prev[5] || 0))
680
+ state.candles.set(ts, c);
681
+ }
682
+ const before = sortedCandles(new Map((localCache.shards.find((s) => s.shardKey === state.key)?.candles || [])
683
+ .filter((c) => Array.isArray(c) && Number.isFinite(Number(c[0])))
684
+ .map((c) => [Number(c[0]), c])));
685
+ const after = sortedCandles(state.candles);
686
+ const union = unionQueriedRanges(state.queried.concat(state.pendingQueried), bucketMs);
687
+ if (candlesEqual(before, after) && rangesCoveredBy(state.queried, union))
688
+ continue;
689
+ const synthWindow = {
690
+ index: 0,
691
+ gte: new Date(start).toISOString(),
692
+ lte: new Date(end).toISOString(),
693
+ };
694
+ const meta = { ...metaForWindow(synthWindow), fetchedAt, queriedRanges: union };
695
+ meta.timeRange = { gte: synthWindow.gte, lte: synthWindow.lte };
696
+ meta.shard = state.key;
697
+ delete meta.chunkIndex;
698
+ persistCacheChunk(state.file, meta, after);
699
+ state.queried = union;
700
+ state.pendingQueried = [];
701
+ }
702
+ // Retire legacy files whose every bucket now provably lives in a
703
+ // shard. Buckets outside this run's range block retirement (their
704
+ // home shards were not loaded), so nothing absorbable is ever lost —
705
+ // a later wider run retires them. Disjoint legacy files were never
706
+ // loaded and are never touched.
707
+ const absorbed = [];
708
+ for (const leg of localCache.legacy) {
709
+ let complete = true;
710
+ for (const c of leg.candles) {
711
+ const ts = Number(c?.[0]);
712
+ if (!Number.isFinite(ts))
713
+ continue;
714
+ if (overall && (ts < overall.gte || ts > overall.lte)) {
715
+ complete = false;
716
+ break;
717
+ }
718
+ const state = shardStates.get(shardKeyForTimestamp(ts));
719
+ if (!state || !state.candles.has(ts)) {
720
+ complete = false;
721
+ break;
722
+ }
723
+ }
724
+ if (!complete)
725
+ continue;
726
+ try {
727
+ storage.unlink(leg.file);
728
+ absorbed.push(leg.file);
729
+ }
730
+ catch (err) {
731
+ console.warn(` Could not absorb legacy chunk ${path.relative(process.cwd(), leg.file)}: ${getErrorMessage(err)}`);
732
+ }
733
+ }
734
+ if (absorbed.length > 0) {
735
+ console.log(` Absorbed ${absorbed.length} legacy chunk file(s): ${absorbed.map((f) => path.basename(f)).join(', ')}`);
736
+ }
496
737
  }
497
738
  return merged;
498
739
  }
499
- export { TAIL_REFRESH_HOURS, IMMUTABLE_WINDOW_AGE_MS, siblingChunkFiles, chunkPathFor, readCacheChunk, priorQueriedInWindow, loadBucketCache, cachedCandlesInRange, addUtcMonths, normalizeDateInput, buildFetchWindowsFromRange, findMissingBucketRanges, pruneImmutableGaps, persistCacheChunk, cleanupOrphanCacheChunks, planWindowReuse, formatWindowLine, fetchRangeLogged, fetchRangeWithRetry, runCachedWindows, };
740
+ export { TAIL_REFRESH_HOURS, IMMUTABLE_WINDOW_AGE_MS, shardKeyForTimestamp, shardBoundsForKey, shardKeysForRange, shardPathFor, readCacheChunk, loadBucketCache, cachedCandlesInRange, unionQueriedRanges, rangesCoveredBy, addUtcMonths, normalizeDateInput, buildFetchWindowsFromRange, findMissingBucketRanges, pruneImmutableGaps, persistCacheChunk, planWindowReuse, formatWindowLine, fetchRangeLogged, fetchRangeWithRetry, runCachedWindows, };
500
741
  //# sourceMappingURL=window_cache.js.map