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.
- package/CHANGELOG.md +33 -0
- package/README.md +5 -4
- package/analysis/ama_fitting/package.json +1 -1
- package/analysis/tradingview/tradingview_uplot_chart_generator.ts +19 -5
- package/analysis/trend_detection/package.json +1 -1
- package/claw/package.json +1 -1
- package/claw/runtimes/openclaw-plugin/openclaw.plugin.json +1 -1
- package/claw/runtimes/openclaw-plugin/package.json +1 -1
- package/claw/tests/test_claw_mcp_transport.ts +2 -2
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.d.ts.map +1 -1
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js +19 -5
- package/dist/analysis/tradingview/tradingview_uplot_chart_generator.js.map +1 -1
- package/dist/dexbot.js +1 -1
- package/dist/dexbot.js.map +1 -1
- package/dist/market_adapter/inputs/fetch_book_data.js +2 -2
- package/dist/market_adapter/inputs/fetch_book_data.js.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts +22 -3
- package/dist/market_adapter/inputs/fetch_lp_data.d.ts.map +1 -1
- package/dist/market_adapter/inputs/fetch_lp_data.js +15 -15
- package/dist/market_adapter/inputs/fetch_lp_data.js.map +1 -1
- package/dist/market_adapter/inputs/kibana_feed_source.js +2 -2
- package/dist/market_adapter/inputs/kibana_feed_source.js.map +1 -1
- package/dist/market_adapter/inputs/window_cache.d.ts +54 -8
- package/dist/market_adapter/inputs/window_cache.d.ts.map +1 -1
- package/dist/market_adapter/inputs/window_cache.js +328 -87
- package/dist/market_adapter/inputs/window_cache.js.map +1 -1
- package/dist/modules/account_bots.js +2 -2
- package/dist/modules/account_bots.js.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.d.ts +1 -1
- package/dist/modules/bitshares-native/subscriptions.d.ts.map +1 -1
- package/dist/modules/bitshares-native/subscriptions.js +18 -4
- package/dist/modules/bitshares-native/subscriptions.js.map +1 -1
- package/dist/modules/bitshares_client.d.ts.map +1 -1
- package/dist/modules/bitshares_client.js +10 -0
- package/dist/modules/bitshares_client.js.map +1 -1
- package/dist/modules/constants.d.ts +12 -3
- package/dist/modules/constants.d.ts.map +1 -1
- package/dist/modules/constants.js +66 -20
- package/dist/modules/constants.js.map +1 -1
- package/dist/modules/credit_runtime.d.ts.map +1 -1
- package/dist/modules/credit_runtime.js +24 -6
- package/dist/modules/credit_runtime.js.map +1 -1
- package/dist/modules/dexbot_class.d.ts +34 -6
- package/dist/modules/dexbot_class.d.ts.map +1 -1
- package/dist/modules/dexbot_class.js +94 -17
- package/dist/modules/dexbot_class.js.map +1 -1
- package/dist/modules/dexbot_cow_runtime.d.ts +57 -6
- package/dist/modules/dexbot_cow_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_cow_runtime.js +474 -38
- package/dist/modules/dexbot_cow_runtime.js.map +1 -1
- package/dist/modules/dexbot_fill_runtime.d.ts +53 -1
- package/dist/modules/dexbot_fill_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_fill_runtime.js +277 -9
- package/dist/modules/dexbot_fill_runtime.js.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.d.ts +69 -0
- package/dist/modules/dexbot_maintenance_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_maintenance_runtime.js +277 -7
- package/dist/modules/dexbot_maintenance_runtime.js.map +1 -1
- package/dist/modules/dexbot_startup_runtime.d.ts.map +1 -1
- package/dist/modules/dexbot_startup_runtime.js +6 -1
- package/dist/modules/dexbot_startup_runtime.js.map +1 -1
- package/dist/modules/dexbot_state_recovery.d.ts.map +1 -1
- package/dist/modules/dexbot_state_recovery.js +32 -4
- package/dist/modules/dexbot_state_recovery.js.map +1 -1
- package/dist/modules/market_adapter_whitelist.js +1 -1
- package/dist/modules/market_adapter_whitelist.js.map +1 -1
- package/dist/modules/order/grid.d.ts +3 -1
- package/dist/modules/order/grid.d.ts.map +1 -1
- package/dist/modules/order/grid.js +93 -16
- package/dist/modules/order/grid.js.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.d.ts.map +1 -1
- package/dist/modules/order/grid_reconcile_internal.js +73 -7
- package/dist/modules/order/grid_reconcile_internal.js.map +1 -1
- package/dist/modules/order/logger.js +1 -1
- package/dist/modules/order/logger.js.map +1 -1
- package/dist/modules/order/manager.d.ts +14 -1
- package/dist/modules/order/manager.d.ts.map +1 -1
- package/dist/modules/order/manager.js +54 -2
- package/dist/modules/order/manager.js.map +1 -1
- package/dist/modules/order/sync_engine.d.ts.map +1 -1
- package/dist/modules/order/sync_engine.js +183 -10
- package/dist/modules/order/sync_engine.js.map +1 -1
- package/dist/modules/order/utils/math.d.ts +65 -15
- package/dist/modules/order/utils/math.d.ts.map +1 -1
- package/dist/modules/order/utils/math.js +93 -22
- package/dist/modules/order/utils/math.js.map +1 -1
- package/dist/modules/order/utils/order.d.ts +51 -1
- package/dist/modules/order/utils/order.d.ts.map +1 -1
- package/dist/modules/order/utils/order.js +122 -2
- package/dist/modules/order/utils/order.js.map +1 -1
- package/dist/modules/utils/errors.d.ts +22 -0
- package/dist/modules/utils/errors.d.ts.map +1 -1
- package/dist/modules/utils/errors.js +30 -0
- package/dist/modules/utils/errors.js.map +1 -1
- package/dist/scripts/generate_market_adapter_whitelist.d.ts.map +1 -1
- package/dist/scripts/generate_market_adapter_whitelist.js +3 -2
- package/dist/scripts/generate_market_adapter_whitelist.js.map +1 -1
- package/docs/BITSHARES_ONBOARDING.md +14 -6
- package/docs/COPY_ON_WRITE_MASTER_PLAN.md +1 -1
- package/docs/DEXBOT_COMPARISON.md +3 -7
- package/docs/EVOLUTION.md +9 -7
- package/docs/FUND_MOVEMENT_AND_ACCOUNTING.md +4 -9
- package/docs/GRID_RECALCULATION.md +8 -7
- package/docs/LIFECYCLE.md +2 -2
- package/docs/README.md +9 -1
- package/docs/architecture.md +7 -7
- package/docs/developer_guide.md +4 -4
- package/market_adapter/README.md +14 -11
- package/package.json +1 -1
- package/scripts/README.md +4 -4
- package/analysis/results/ama_sweep_results_lp_pool_133_1h.json +0 -2455
- package/analysis/results/bot_fitting_results_lp_pool_133_1h.json +0 -218
- package/analysis/tradingview/h-bts_tradingview.html +0 -1570
- package/analysis/tradingview/t-bts_tradingview.html +0 -1570
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/**
|
|
3
|
-
* WINDOW CACHE —
|
|
3
|
+
* WINDOW CACHE — persistent bucket cache for windowed Kibana candle fetches.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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`
|
|
388
|
-
* `{ index, gte, lte
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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,
|
|
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
|