tradeblocks-mcp 3.0.2 → 3.3.1

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 (218) hide show
  1. package/README.md +85 -80
  2. package/dist/{chunk-LDKTV7GW.js → chunk-27S67XW3.js} +24 -52
  3. package/dist/chunk-27S67XW3.js.map +1 -0
  4. package/dist/{chunk-FGZH632F.js → chunk-2E63THNI.js} +13 -11
  5. package/dist/chunk-2E63THNI.js.map +1 -0
  6. package/dist/{chunk-QTTR7AAW.js → chunk-2I3S2ZLT.js} +325 -84
  7. package/dist/chunk-2I3S2ZLT.js.map +1 -0
  8. package/dist/{chunk-4BLCXNQ6.js → chunk-4HZLVUOZ.js} +179 -183
  9. package/dist/chunk-4HZLVUOZ.js.map +1 -0
  10. package/dist/{chunk-XXYOUIZY.js → chunk-NWNSKALN.js} +63 -80
  11. package/dist/chunk-NWNSKALN.js.map +1 -0
  12. package/dist/{chunk-PRAYH3RT.js → chunk-WNTYA7ER.js} +29 -12
  13. package/dist/chunk-WNTYA7ER.js.map +1 -0
  14. package/dist/{chunk-BKQ4PM4Y.js → chunk-YZA4RS76.js} +24 -18
  15. package/dist/chunk-YZA4RS76.js.map +1 -0
  16. package/dist/{chunk-W2PP3LEH.js → chunk-ZTJXC3LS.js} +17 -8
  17. package/dist/chunk-ZTJXC3LS.js.map +1 -0
  18. package/dist/daily-log-processor-MZW2XBZY.js +9 -0
  19. package/dist/{daily-logs-store-D3QC2XPV.js → daily-logs-store-3C3OTGPK.js} +2 -2
  20. package/dist/iv-solver-worker.js +9 -1
  21. package/dist/market-provider-6U33TQUT.js +16 -0
  22. package/dist/{proto-3HZTCWK4.js → proto-V23GIZG2.js} +2 -2
  23. package/dist/{sync-PO4IPCYV.js → sync-3UBC37VW.js} +7 -7
  24. package/dist/test-exports.js +876 -483
  25. package/dist/test-exports.js.map +1 -1
  26. package/dist/trade-processor-DKDCWGKD.js +9 -0
  27. package/dist/{trades-store-LOL3FQSF.js → trades-store-R3NL25T2.js} +2 -2
  28. package/package.json +2 -6
  29. package/server/{chunk-CSDVJPBB.js → chunk-536NSFED.js} +29 -12
  30. package/server/chunk-536NSFED.js.map +1 -0
  31. package/server/{chunk-WA5AAPCH.js → chunk-A3UKD64A.js} +17 -8
  32. package/server/chunk-A3UKD64A.js.map +1 -0
  33. package/server/{chunk-PNKG7RY7.js → chunk-OMUDNJBJ.js} +63 -80
  34. package/server/chunk-OMUDNJBJ.js.map +1 -0
  35. package/server/{chunk-72GKJE2U.js → chunk-RJDJHZ5Y.js} +934 -335
  36. package/server/chunk-RJDJHZ5Y.js.map +1 -0
  37. package/server/{chunk-NZO6PT64.js → chunk-RM4B2VKS.js} +167 -177
  38. package/server/chunk-RM4B2VKS.js.map +1 -0
  39. package/server/{chunk-5EBXHT6C.js → chunk-SY6GDNVI.js} +24 -18
  40. package/server/chunk-SY6GDNVI.js.map +1 -0
  41. package/server/{chunk-FBNDMCT5.js → chunk-T66KH2XH.js} +13 -11
  42. package/server/chunk-T66KH2XH.js.map +1 -0
  43. package/server/{chunk-NRFXAJF7.js → chunk-W5E7FHC4.js} +24 -52
  44. package/server/chunk-W5E7FHC4.js.map +1 -0
  45. package/server/{config-6IZXEFEX.js → config-DK7KOMNL.js} +1 -1
  46. package/server/config-DK7KOMNL.js.map +1 -0
  47. package/server/daily-log-processor-B2VPU2FQ.js +10 -0
  48. package/server/{daily-logs-store-NU7M4L5C.js → daily-logs-store-YFNXTB7J.js} +2 -2
  49. package/server/http-server.js +2 -5
  50. package/server/index.js +2497 -2029
  51. package/server/index.js.map +1 -1
  52. package/server/iv-solver-worker.js +9 -1
  53. package/server/market-provider-L56RFHAC.js +17 -0
  54. package/server/{proto-DWRZJO4E.js → proto-YZ4TLX2N.js} +2 -2
  55. package/server/{sync-QFI5L7S7.js → sync-CENKDN53.js} +7 -7
  56. package/server/trade-processor-3PI4LHZI.js +10 -0
  57. package/server/{trades-store-VY26MWER.js → trades-store-RTTEYMXH.js} +2 -2
  58. package/src/auth/clients-store.ts +4 -4
  59. package/src/auth/code-store.ts +1 -1
  60. package/src/auth/config.ts +11 -12
  61. package/src/auth/login-page.ts +8 -10
  62. package/src/auth/provider.ts +35 -30
  63. package/src/auth/token.ts +17 -15
  64. package/src/db/backtest-schemas.ts +38 -8
  65. package/src/db/connection.ts +107 -40
  66. package/src/db/data-root.ts +22 -0
  67. package/src/db/index.ts +37 -5
  68. package/src/db/json-adapters.ts +22 -31
  69. package/src/db/json-migration.ts +11 -15
  70. package/src/db/json-store.ts +5 -3
  71. package/src/db/market-datasets.ts +48 -15
  72. package/src/db/market-schemas.ts +11 -5
  73. package/src/db/market-views.ts +119 -32
  74. package/src/db/parquet-writer.ts +7 -7
  75. package/src/db/profile-schemas.ts +39 -22
  76. package/src/db/schemas.ts +2 -2
  77. package/src/http-server.ts +7 -12
  78. package/src/index.ts +8 -9
  79. package/src/market/ingestor/index.ts +1 -4
  80. package/src/market/ingestor/market-ingestor.ts +140 -67
  81. package/src/market/ingestor/types.ts +1 -3
  82. package/src/market/stores/chain-sql.ts +2 -8
  83. package/src/market/stores/chain-store.ts +1 -4
  84. package/src/market/stores/duckdb-chain-store.ts +3 -14
  85. package/src/market/stores/duckdb-enriched-store.ts +3 -13
  86. package/src/market/stores/duckdb-quote-store.ts +10 -27
  87. package/src/market/stores/duckdb-spot-store.ts +4 -20
  88. package/src/market/stores/enriched-sql.ts +3 -9
  89. package/src/market/stores/enriched-store.ts +2 -2
  90. package/src/market/stores/parquet-chain-store.ts +5 -22
  91. package/src/market/stores/parquet-enriched-store.ts +4 -16
  92. package/src/market/stores/parquet-oi-daily-store.ts +3 -14
  93. package/src/market/stores/parquet-quote-store.ts +9 -29
  94. package/src/market/stores/parquet-spot-store.ts +14 -33
  95. package/src/market/stores/quote-store.ts +2 -10
  96. package/src/market/stores/spot-sql.ts +3 -15
  97. package/src/market/stores/spot-store.ts +1 -1
  98. package/src/market/tickers/defaults.json +6 -6
  99. package/src/market/tickers/loader.ts +2 -7
  100. package/src/market/tickers/registry.ts +5 -15
  101. package/src/market/tickers/schemas.ts +2 -6
  102. package/src/models/strategy-profile.ts +39 -39
  103. package/src/sync/block-sync.ts +44 -65
  104. package/src/sync/index.ts +1 -4
  105. package/src/sync/metadata.ts +14 -20
  106. package/src/test-exports.ts +139 -123
  107. package/src/tools/analysis.ts +180 -159
  108. package/src/tools/batch-exit-analysis.ts +100 -59
  109. package/src/tools/blocks/analysis.ts +116 -101
  110. package/src/tools/blocks/comparison.ts +281 -364
  111. package/src/tools/blocks/core.ts +208 -253
  112. package/src/tools/blocks/health.ts +571 -563
  113. package/src/tools/blocks/index.ts +2 -0
  114. package/src/tools/blocks/paired-comparison.ts +425 -0
  115. package/src/tools/blocks/similarity.ts +316 -283
  116. package/src/tools/edge-decay.ts +124 -197
  117. package/src/tools/exit-analysis.ts +130 -77
  118. package/src/tools/greeks-attribution.ts +84 -35
  119. package/src/tools/guides.ts +4 -6
  120. package/src/tools/imports.ts +11 -14
  121. package/src/tools/market-data.ts +1067 -757
  122. package/src/tools/market-enrichment.ts +3 -3
  123. package/src/tools/market-fetch.ts +148 -67
  124. package/src/tools/market-imports.ts +12 -12
  125. package/src/tools/middleware/sync-middleware.ts +5 -6
  126. package/src/tools/performance.ts +185 -302
  127. package/src/tools/profile-analysis.ts +52 -66
  128. package/src/tools/profiles.ts +106 -69
  129. package/src/tools/regime-advisor.ts +20 -45
  130. package/src/tools/replay.ts +81 -77
  131. package/src/tools/reports/discrepancies.ts +298 -328
  132. package/src/tools/reports/fields.ts +7 -25
  133. package/src/tools/reports/helpers.ts +18 -49
  134. package/src/tools/reports/predictive.ts +27 -70
  135. package/src/tools/reports/slippage-trends.ts +315 -345
  136. package/src/tools/reports/slippage.ts +1 -4
  137. package/src/tools/reports/strategy-matches.ts +399 -441
  138. package/src/tools/schema.ts +43 -40
  139. package/src/tools/shared/filters.ts +3 -9
  140. package/src/tools/snapshot.ts +9 -30
  141. package/src/tools/sql.ts +15 -14
  142. package/src/tools/tickers.ts +1 -4
  143. package/src/utils/batch-exit-analysis.ts +31 -42
  144. package/src/utils/black-scholes.ts +39 -29
  145. package/src/utils/block-loader.ts +69 -83
  146. package/src/utils/calibration-probe.ts +3 -4
  147. package/src/utils/chain-loader.ts +3 -3
  148. package/src/utils/csv-discovery.ts +16 -22
  149. package/src/utils/data-quality.ts +24 -36
  150. package/src/utils/exit-triggers.ts +91 -96
  151. package/src/utils/field-timing.ts +94 -79
  152. package/src/utils/filter-predicates.ts +13 -9
  153. package/src/utils/flatfile-importer.ts +94 -64
  154. package/src/utils/greeks-decomposition.ts +152 -100
  155. package/src/utils/iv-solver-pool.ts +55 -25
  156. package/src/utils/iv-solver-worker.ts +5 -5
  157. package/src/utils/market-enricher.ts +528 -497
  158. package/src/utils/market-importer.ts +31 -12
  159. package/src/utils/market-provider.ts +21 -23
  160. package/src/utils/massive-tier.ts +5 -7
  161. package/src/utils/migrate-option-data-helpers.ts +2 -8
  162. package/src/utils/option-quote-greeks.ts +25 -31
  163. package/src/utils/option-time.ts +4 -8
  164. package/src/utils/output-formatter.ts +1 -4
  165. package/src/utils/provider-capabilities.ts +1 -4
  166. package/src/utils/providers/massive.ts +59 -93
  167. package/src/utils/providers/thetadata/backfill.ts +14 -23
  168. package/src/utils/providers/thetadata/client.ts +12 -8
  169. package/src/utils/providers/thetadata/decode.ts +2 -20
  170. package/src/utils/providers/thetadata/endpoints.ts +32 -40
  171. package/src/utils/providers/thetadata/join.ts +11 -10
  172. package/src/utils/providers/thetadata/proto.ts +12 -10
  173. package/src/utils/providers/thetadata/quote-mid-greeks.ts +5 -5
  174. package/src/utils/providers/thetadata.ts +11 -10
  175. package/src/utils/quote-enricher.ts +4 -4
  176. package/src/utils/quote-parquet-projection.ts +3 -11
  177. package/src/utils/sample-date-selector.ts +3 -5
  178. package/src/utils/schema-metadata.ts +102 -70
  179. package/src/utils/ticker.ts +5 -9
  180. package/src/utils/trade-replay.ts +77 -68
  181. package/dist/chunk-4BLCXNQ6.js.map +0 -1
  182. package/dist/chunk-BKQ4PM4Y.js.map +0 -1
  183. package/dist/chunk-FGZH632F.js.map +0 -1
  184. package/dist/chunk-LDKTV7GW.js.map +0 -1
  185. package/dist/chunk-PRAYH3RT.js.map +0 -1
  186. package/dist/chunk-QTTR7AAW.js.map +0 -1
  187. package/dist/chunk-W2PP3LEH.js.map +0 -1
  188. package/dist/chunk-XXYOUIZY.js.map +0 -1
  189. package/dist/daily-log-processor-6MWJ23JK.js +0 -9
  190. package/dist/market-provider-VDRJUEF2.js +0 -16
  191. package/dist/trade-processor-NHU4VWRX.js +0 -9
  192. package/manifest.json +0 -141
  193. package/server/chunk-5EBXHT6C.js.map +0 -1
  194. package/server/chunk-72GKJE2U.js.map +0 -1
  195. package/server/chunk-CSDVJPBB.js.map +0 -1
  196. package/server/chunk-FBNDMCT5.js.map +0 -1
  197. package/server/chunk-NRFXAJF7.js.map +0 -1
  198. package/server/chunk-NZO6PT64.js.map +0 -1
  199. package/server/chunk-PNKG7RY7.js.map +0 -1
  200. package/server/chunk-WA5AAPCH.js.map +0 -1
  201. package/server/config-6IZXEFEX.js.map +0 -1
  202. package/server/daily-log-processor-Y3PVSVBM.js +0 -10
  203. package/server/market-provider-VOYYVYWT.js +0 -17
  204. package/server/trade-processor-JWVS37KM.js +0 -10
  205. /package/dist/{daily-log-processor-6MWJ23JK.js.map → daily-log-processor-MZW2XBZY.js.map} +0 -0
  206. /package/dist/{daily-logs-store-D3QC2XPV.js.map → daily-logs-store-3C3OTGPK.js.map} +0 -0
  207. /package/dist/{market-provider-VDRJUEF2.js.map → market-provider-6U33TQUT.js.map} +0 -0
  208. /package/dist/{proto-3HZTCWK4.js.map → proto-V23GIZG2.js.map} +0 -0
  209. /package/dist/{sync-PO4IPCYV.js.map → sync-3UBC37VW.js.map} +0 -0
  210. /package/dist/{trade-processor-NHU4VWRX.js.map → trade-processor-DKDCWGKD.js.map} +0 -0
  211. /package/dist/{trades-store-LOL3FQSF.js.map → trades-store-R3NL25T2.js.map} +0 -0
  212. /package/server/{daily-log-processor-Y3PVSVBM.js.map → daily-log-processor-B2VPU2FQ.js.map} +0 -0
  213. /package/server/{daily-logs-store-NU7M4L5C.js.map → daily-logs-store-YFNXTB7J.js.map} +0 -0
  214. /package/server/{market-provider-VOYYVYWT.js.map → market-provider-L56RFHAC.js.map} +0 -0
  215. /package/server/{proto-DWRZJO4E.js.map → proto-YZ4TLX2N.js.map} +0 -0
  216. /package/server/{sync-QFI5L7S7.js.map → sync-CENKDN53.js.map} +0 -0
  217. /package/server/{trade-processor-JWVS37KM.js.map → trade-processor-3PI4LHZI.js.map} +0 -0
  218. /package/server/{trades-store-VY26MWER.js.map → trades-store-RTTEYMXH.js.map} +0 -0
@@ -15,10 +15,7 @@
15
15
  import { z } from "zod";
16
16
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
17
  import { loadBlock } from "../utils/block-loader.ts";
18
- import {
19
- createToolOutput,
20
- formatPercent,
21
- } from "../utils/output-formatter.ts";
18
+ import { createToolOutput, formatPercent } from "../utils/output-formatter.ts";
22
19
  import type { Trade } from "@tradeblocks/lib";
23
20
  import { getConnection } from "../db/connection.ts";
24
21
  import { withFullSync } from "./middleware/sync-middleware.ts";
@@ -147,7 +144,11 @@ function formatTradeDate(date: Date | string): string {
147
144
  * Convert DuckDB query result to an array of Record objects.
148
145
  * Handles BigInt to Number conversion for JSON compatibility.
149
146
  */
150
- function resultToRecords(result: { columnCount: number; columnName(i: number): string; getRows(): Iterable<unknown[]> }): Record<string, unknown>[] {
147
+ function resultToRecords(result: {
148
+ columnCount: number;
149
+ columnName(i: number): string;
150
+ getRows(): Iterable<unknown[]>;
151
+ }): Record<string, unknown>[] {
151
152
  const columnCount = result.columnCount;
152
153
  const colNames: string[] = [];
153
154
  for (let i = 0; i < columnCount; i++) {
@@ -193,7 +194,7 @@ function uniqueTradeLookupKeys(trades: Trade[]): MarketLookupKey[] {
193
194
  }
194
195
 
195
196
  function recordsByTickerDate(
196
- records: Record<string, unknown>[]
197
+ records: Record<string, unknown>[],
197
198
  ): Map<string, Record<string, unknown>> {
198
199
  const mapped = new Map<string, Record<string, unknown>>();
199
200
  for (const record of records) {
@@ -275,12 +276,9 @@ export function registerMarketDataTools(
275
276
  "Returns warnings when market data is partially missing.",
276
277
  inputSchema: z.object({
277
278
  blockId: z.string().describe("Block ID to analyze"),
278
- segmentBy: z.enum([
279
- "volRegime",
280
- "termStructure",
281
- "dayOfWeek",
282
- "gapDirection",
283
- ]).describe("Market dimension to segment by"),
279
+ segmentBy: z
280
+ .enum(["volRegime", "termStructure", "dayOfWeek", "gapDirection"])
281
+ .describe("Market dimension to segment by"),
284
282
  strategy: z.string().optional().describe("Filter to specific strategy"),
285
283
  ticker: z.string().optional().describe("Underlying ticker symbol (default: SPX)"),
286
284
  }),
@@ -291,9 +289,7 @@ export function registerMarketDataTools(
291
289
  let trades = block.trades;
292
290
 
293
291
  if (strategy) {
294
- trades = trades.filter(
295
- (t) => t.strategy.toLowerCase() === strategy.toLowerCase()
296
- );
292
+ trades = trades.filter((t) => t.strategy.toLowerCase() === strategy.toLowerCase());
297
293
  }
298
294
 
299
295
  if (trades.length === 0) {
@@ -309,7 +305,7 @@ export function registerMarketDataTools(
309
305
  const conn = await getConnection(baseDir);
310
306
 
311
307
  // Check data availability and collect warnings
312
- const resolvedTicker = normalizeTicker(ticker || '') || DEFAULT_MARKET_TICKER;
308
+ const resolvedTicker = normalizeTicker(ticker || "") || DEFAULT_MARKET_TICKER;
313
309
  const availability = await checkDataAvailability(stores, resolvedTicker);
314
310
 
315
311
  const { sql: lagSql, params: lagParams } = buildLookaheadFreeQuery(tradeKeys);
@@ -350,7 +346,10 @@ export function registerMarketDataTools(
350
346
  switch (segmentBy) {
351
347
  case "volRegime": {
352
348
  const val = getNum(marketData, "prev_Vol_Regime");
353
- if (isNaN(val)) { lagExcluded++; continue; }
349
+ if (isNaN(val)) {
350
+ lagExcluded++;
351
+ continue;
352
+ }
354
353
  segmentValue = val;
355
354
  segmentKey = String(val);
356
355
  segmentLabel = getVolRegimeLabel(val);
@@ -358,7 +357,10 @@ export function registerMarketDataTools(
358
357
  }
359
358
  case "termStructure": {
360
359
  const val = getNum(marketData, "prev_Term_Structure_State");
361
- if (isNaN(val)) { lagExcluded++; continue; }
360
+ if (isNaN(val)) {
361
+ lagExcluded++;
362
+ continue;
363
+ }
362
364
  segmentValue = val;
363
365
  segmentKey = String(val);
364
366
  segmentLabel = getTermStructureLabel(val);
@@ -371,7 +373,10 @@ export function registerMarketDataTools(
371
373
  break;
372
374
  case "gapDirection": {
373
375
  const gapPct = getNum(marketData, "Gap_Pct");
374
- if (isNaN(gapPct)) { lagExcluded++; continue; }
376
+ if (isNaN(gapPct)) {
377
+ lagExcluded++;
378
+ continue;
379
+ }
375
380
  segmentValue = gapPct > 0.1 ? "up" : gapPct < -0.1 ? "down" : "flat";
376
381
  segmentKey = segmentValue;
377
382
  segmentLabel = `Gap ${segmentValue}`;
@@ -419,16 +424,19 @@ export function registerMarketDataTools(
419
424
 
420
425
  const winningTrades = seg.trades.filter((t) => t.isWin);
421
426
  const losingTrades = seg.trades.filter((t) => !t.isWin);
422
- const avgWin = winningTrades.length > 0
423
- ? winningTrades.reduce((sum, t) => sum + t.pl, 0) / winningTrades.length
424
- : 0;
425
- const avgLoss = losingTrades.length > 0
426
- ? losingTrades.reduce((sum, t) => sum + t.pl, 0) / losingTrades.length
427
- : 0;
427
+ const avgWin =
428
+ winningTrades.length > 0
429
+ ? winningTrades.reduce((sum, t) => sum + t.pl, 0) / winningTrades.length
430
+ : 0;
431
+ const avgLoss =
432
+ losingTrades.length > 0
433
+ ? losingTrades.reduce((sum, t) => sum + t.pl, 0) / losingTrades.length
434
+ : 0;
428
435
 
429
436
  const grossWins = winningTrades.reduce((sum, t) => sum + t.pl, 0);
430
437
  const grossLosses = Math.abs(losingTrades.reduce((sum, t) => sum + t.pl, 0));
431
- const profitFactor = grossLosses > 0 ? grossWins / grossLosses : grossWins > 0 ? null : 0;
438
+ const profitFactor =
439
+ grossLosses > 0 ? grossWins / grossLosses : grossWins > 0 ? null : 0;
432
440
 
433
441
  return {
434
442
  segment: seg.segment,
@@ -494,7 +502,7 @@ export function registerMarketDataTools(
494
502
  isError: true,
495
503
  };
496
504
  }
497
- })
505
+ }),
498
506
  );
499
507
 
500
508
  // ---------------------------------------------------------------------------
@@ -512,371 +520,606 @@ export function registerMarketDataTools(
512
520
  inputSchema: z.object({
513
521
  blockId: z.string().describe("Block ID to analyze"),
514
522
  strategy: z.string().optional().describe("Filter to specific strategy"),
515
- strategyName: z.string().optional().describe("Strategy profile name. When provided, auto-filters to that strategy's trades and cross-references suggestions against profile's entry_filters."),
516
- minImprovementPct: z.number().optional().describe("Only suggest filters with >= X% win rate improvement (default: 3)"),
523
+ strategyName: z
524
+ .string()
525
+ .optional()
526
+ .describe(
527
+ "Strategy profile name. When provided, auto-filters to that strategy's trades and cross-references suggestions against profile's entry_filters.",
528
+ ),
529
+ minImprovementPct: z
530
+ .number()
531
+ .optional()
532
+ .describe("Only suggest filters with >= X% win rate improvement (default: 3)"),
517
533
  ticker: z.string().optional().describe("Underlying ticker symbol (default: SPX)"),
518
534
  }),
519
535
  },
520
- withFullSync(baseDir, async ({ blockId, strategy, strategyName, minImprovementPct = 3, ticker }) => {
521
- try {
522
- const block = await loadBlock(baseDir, blockId);
523
- let trades = block.trades;
524
-
525
- // If strategyName provided, use it to filter trades (takes precedence over strategy)
526
- const effectiveStrategy = strategyName || strategy;
527
- if (effectiveStrategy) {
528
- trades = filterByStrategy(trades, effectiveStrategy);
529
- // Single-strategy fallback: profile strategyName may differ from CSV strategy label
530
- if (trades.length === 0 && block.trades.length > 0) {
531
- const uniqueStrategies = new Set(block.trades.map((t) => t.strategy));
532
- if (uniqueStrategies.size === 1) {
533
- trades = block.trades;
536
+ withFullSync(
537
+ baseDir,
538
+ async ({ blockId, strategy, strategyName, minImprovementPct = 3, ticker }) => {
539
+ try {
540
+ const block = await loadBlock(baseDir, blockId);
541
+ let trades = block.trades;
542
+
543
+ // If strategyName provided, use it to filter trades (takes precedence over strategy)
544
+ const effectiveStrategy = strategyName || strategy;
545
+ if (effectiveStrategy) {
546
+ trades = filterByStrategy(trades, effectiveStrategy);
547
+ // Single-strategy fallback: profile strategyName may differ from CSV strategy label
548
+ if (trades.length === 0 && block.trades.length > 0) {
549
+ const uniqueStrategies = new Set(block.trades.map((t) => t.strategy));
550
+ if (uniqueStrategies.size === 1) {
551
+ trades = block.trades;
552
+ }
534
553
  }
535
554
  }
536
- }
537
555
 
538
- // Load profile for cross-referencing if strategyName provided
539
- let profileEntryFilters: Array<{ field: string; operator: string; value: unknown; description?: string }> | null = null;
540
- if (strategyName) {
541
- const conn = await getConnection(baseDir);
542
- const profile = await getProfile(conn, blockId, strategyName, baseDir);
543
- if (profile) {
544
- profileEntryFilters = profile.entryFilters;
556
+ // Load profile for cross-referencing if strategyName provided
557
+ let profileEntryFilters: Array<{
558
+ field: string;
559
+ operator: string;
560
+ value: unknown;
561
+ description?: string;
562
+ }> | null = null;
563
+ if (strategyName) {
564
+ const conn = await getConnection(baseDir);
565
+ const profile = await getProfile(conn, blockId, strategyName, baseDir);
566
+ if (profile) {
567
+ profileEntryFilters = profile.entryFilters;
568
+ }
545
569
  }
546
- }
547
570
 
548
- // Note: strategy filtering is now handled above via effectiveStrategy
571
+ // Note: strategy filtering is now handled above via effectiveStrategy
549
572
 
550
- if (trades.length === 0) {
551
- return {
552
- content: [{ type: "text", text: "No trades found" }],
553
- isError: true,
554
- };
555
- }
573
+ if (trades.length === 0) {
574
+ return {
575
+ content: [{ type: "text", text: "No trades found" }],
576
+ isError: true,
577
+ };
578
+ }
556
579
 
557
- // Collect unique trade keys (ticker+date) and query DuckDB for market data
558
- const tradeKeys = uniqueTradeLookupKeys(trades);
580
+ // Collect unique trade keys (ticker+date) and query DuckDB for market data
581
+ const tradeKeys = uniqueTradeLookupKeys(trades);
559
582
 
560
- const conn = await getConnection(baseDir);
583
+ const conn = await getConnection(baseDir);
561
584
 
562
- // Check data availability and collect warnings
563
- const resolvedTickerSF = normalizeTicker(ticker || '') || DEFAULT_MARKET_TICKER;
564
- const availabilitySF = await checkDataAvailability(stores, resolvedTickerSF);
585
+ // Check data availability and collect warnings
586
+ const resolvedTickerSF = normalizeTicker(ticker || "") || DEFAULT_MARKET_TICKER;
587
+ const availabilitySF = await checkDataAvailability(stores, resolvedTickerSF);
565
588
 
566
- const { sql, params } = buildLookaheadFreeQuery(tradeKeys);
567
- const dailyResult = await conn.runAndReadAll(sql, params);
568
- const dailyRecords = resultToRecords(dailyResult);
569
- const daily = recordsByTickerDate(dailyRecords);
589
+ const { sql, params } = buildLookaheadFreeQuery(tradeKeys);
590
+ const dailyResult = await conn.runAndReadAll(sql, params);
591
+ const dailyRecords = resultToRecords(dailyResult);
592
+ const daily = recordsByTickerDate(dailyRecords);
570
593
 
571
- // Match trades to market data
572
- interface EnrichedTrade {
573
- trade: Trade;
574
- market: Record<string, unknown> | null;
575
- }
594
+ // Match trades to market data
595
+ interface EnrichedTrade {
596
+ trade: Trade;
597
+ market: Record<string, unknown> | null;
598
+ }
576
599
 
577
- const enrichedTrades: EnrichedTrade[] = trades.map((trade) => {
578
- const lookup = getTradeLookupKey(trade);
579
- return {
580
- trade,
581
- market:
582
- daily.get(marketTickerDateKey(lookup.ticker, lookup.date)) || null,
583
- };
584
- });
600
+ const enrichedTrades: EnrichedTrade[] = trades.map((trade) => {
601
+ const lookup = getTradeLookupKey(trade);
602
+ return {
603
+ trade,
604
+ market: daily.get(marketTickerDateKey(lookup.ticker, lookup.date)) || null,
605
+ };
606
+ });
585
607
 
586
- const matchedTrades = enrichedTrades.filter((t) => t.market !== null);
608
+ const matchedTrades = enrichedTrades.filter((t) => t.market !== null);
587
609
 
588
- if (matchedTrades.length < 10) {
589
- return {
590
- content: [{ type: "text", text: "Not enough trades matched to market data for analysis (need at least 10)" }],
591
- isError: true,
592
- };
593
- }
610
+ if (matchedTrades.length < 10) {
611
+ return {
612
+ content: [
613
+ {
614
+ type: "text",
615
+ text: "Not enough trades matched to market data for analysis (need at least 10)",
616
+ },
617
+ ],
618
+ isError: true,
619
+ };
620
+ }
594
621
 
595
- // Calculate current stats
596
- const currentWins = matchedTrades.filter((t) => t.trade.pl > 0).length;
597
- const currentWinRate = (currentWins / matchedTrades.length) * 100;
598
- const currentTotalPl = matchedTrades.reduce((sum, t) => sum + t.trade.pl, 0);
622
+ // Calculate current stats
623
+ const currentWins = matchedTrades.filter((t) => t.trade.pl > 0).length;
624
+ const currentWinRate = (currentWins / matchedTrades.length) * 100;
625
+ const currentTotalPl = matchedTrades.reduce((sum, t) => sum + t.trade.pl, 0);
626
+
627
+ // Test various filters
628
+ interface FilterSuggestion {
629
+ filter: string;
630
+ condition: {
631
+ field: string;
632
+ operator: string;
633
+ value: number | number[] | string;
634
+ lagged: boolean;
635
+ };
636
+ tradesRemoved: number;
637
+ winnersRemoved: number;
638
+ losersRemoved: number;
639
+ newWinRate: number;
640
+ newTotalPl: number;
641
+ winRateDelta: number;
642
+ plDelta: number;
643
+ confidence: "high" | "medium" | "low";
644
+ }
599
645
 
600
- // Test various filters
601
- interface FilterSuggestion {
602
- filter: string;
603
- condition: {
646
+ const suggestions: FilterSuggestion[] = [];
647
+
648
+ // Test filter functions
649
+ const testFilters: Array<{
650
+ name: string;
604
651
  field: string;
605
652
  operator: string;
606
653
  value: number | number[] | string;
654
+ test: (m: Record<string, unknown>) => boolean;
607
655
  lagged: boolean;
608
- };
609
- tradesRemoved: number;
610
- winnersRemoved: number;
611
- losersRemoved: number;
612
- newWinRate: number;
613
- newTotalPl: number;
614
- winRateDelta: number;
615
- plDelta: number;
616
- confidence: "high" | "medium" | "low";
617
- }
656
+ }> = [
657
+ // Open-known filters (same-day values)
658
+ // Gap filters
659
+ {
660
+ name: "Skip when |Gap_Pct| > 0.5%",
661
+ field: "Gap_Pct",
662
+ operator: ">",
663
+ value: 0.5,
664
+ test: (m) => Math.abs(getNum(m, "Gap_Pct")) > 0.5,
665
+ lagged: false,
666
+ },
667
+ {
668
+ name: "Skip when |Gap_Pct| > 0.8%",
669
+ field: "Gap_Pct",
670
+ operator: ">",
671
+ value: 0.8,
672
+ test: (m) => Math.abs(getNum(m, "Gap_Pct")) > 0.8,
673
+ lagged: false,
674
+ },
675
+ {
676
+ name: "Skip when |Gap_Pct| > 1.0%",
677
+ field: "Gap_Pct",
678
+ operator: ">",
679
+ value: 1.0,
680
+ test: (m) => Math.abs(getNum(m, "Gap_Pct")) > 1.0,
681
+ lagged: false,
682
+ },
683
+ // Day of week
684
+ {
685
+ name: "Skip Fridays",
686
+ field: "Day_of_Week",
687
+ operator: "==",
688
+ value: 6,
689
+ test: (m) => getNum(m, "Day_of_Week") === 6,
690
+ lagged: false,
691
+ },
692
+ {
693
+ name: "Skip Mondays",
694
+ field: "Day_of_Week",
695
+ operator: "==",
696
+ value: 2,
697
+ test: (m) => getNum(m, "Day_of_Week") === 2,
698
+ lagged: false,
699
+ },
700
+ // OPEX
701
+ {
702
+ name: "Skip OPEX days",
703
+ field: "Is_Opex",
704
+ operator: "==",
705
+ value: 1,
706
+ test: (m) => getNum(m, "Is_Opex") === 1,
707
+ lagged: false,
708
+ },
709
+ // VIX_Open filters (open-known)
710
+ {
711
+ name: "Skip when VIX_Open > 25",
712
+ field: "VIX_Open",
713
+ operator: ">",
714
+ value: 25,
715
+ test: (m) => getNum(m, "VIX_Open") > 25,
716
+ lagged: false,
717
+ },
718
+ {
719
+ name: "Skip when VIX_Open > 30",
720
+ field: "VIX_Open",
721
+ operator: ">",
722
+ value: 30,
723
+ test: (m) => getNum(m, "VIX_Open") > 30,
724
+ lagged: false,
725
+ },
726
+ // VIX_Gap_Pct filters (open-known)
727
+ {
728
+ name: "Skip when |VIX_Gap_Pct| > 10%",
729
+ field: "VIX_Gap_Pct",
730
+ operator: ">",
731
+ value: 10,
732
+ test: (m) => Math.abs(getNum(m, "VIX_Gap_Pct")) > 10,
733
+ lagged: false,
734
+ },
735
+ {
736
+ name: "Skip when |VIX_Gap_Pct| > 15%",
737
+ field: "VIX_Gap_Pct",
738
+ operator: ">",
739
+ value: 15,
740
+ test: (m) => Math.abs(getNum(m, "VIX_Gap_Pct")) > 15,
741
+ lagged: false,
742
+ },
743
+
744
+ // Close-derived filters (prior trading day values via LAG CTE)
745
+ // VIX (close-derived)
746
+ {
747
+ name: "Skip when prior-day VIX > 25",
748
+ field: "VIX_Close",
749
+ operator: ">",
750
+ value: 25,
751
+ test: (m) => getNum(m, "prev_VIX_Close") > 25,
752
+ lagged: true,
753
+ },
754
+ {
755
+ name: "Skip when prior-day VIX > 30",
756
+ field: "VIX_Close",
757
+ operator: ">",
758
+ value: 30,
759
+ test: (m) => getNum(m, "prev_VIX_Close") > 30,
760
+ lagged: true,
761
+ },
762
+ {
763
+ name: "Skip when prior-day VIX < 14",
764
+ field: "VIX_Close",
765
+ operator: "<",
766
+ value: 14,
767
+ test: (m) => getNum(m, "prev_VIX_Close") < 14,
768
+ lagged: true,
769
+ },
770
+ // VIX spike (close-derived)
771
+ {
772
+ name: "Skip when prior-day VIX_Spike > 5%",
773
+ field: "VIX_Spike_Pct",
774
+ operator: ">",
775
+ value: 5,
776
+ test: (m) => getNum(m, "prev_VIX_Spike_Pct") > 5,
777
+ lagged: true,
778
+ },
779
+ {
780
+ name: "Skip when prior-day VIX_Spike > 8%",
781
+ field: "VIX_Spike_Pct",
782
+ operator: ">",
783
+ value: 8,
784
+ test: (m) => getNum(m, "prev_VIX_Spike_Pct") > 8,
785
+ lagged: true,
786
+ },
787
+ // Term structure (close-derived)
788
+ {
789
+ name: "Skip prior-day backwardation",
790
+ field: "Term_Structure_State",
791
+ operator: "==",
792
+ value: -1,
793
+ test: (m) => getNum(m, "prev_Term_Structure_State") === -1,
794
+ lagged: true,
795
+ },
796
+ // Vol regime (close-derived)
797
+ {
798
+ name: "Skip prior-day Vol Regime 5-6 (High/Extreme)",
799
+ field: "Vol_Regime",
800
+ operator: "in",
801
+ value: [5, 6],
802
+ test: (m) => getNum(m, "prev_Vol_Regime") >= 5,
803
+ lagged: true,
804
+ },
805
+ {
806
+ name: "Skip prior-day Vol Regime 1 (Very Low)",
807
+ field: "Vol_Regime",
808
+ operator: "==",
809
+ value: 1,
810
+ test: (m) => getNum(m, "prev_Vol_Regime") === 1,
811
+ lagged: true,
812
+ },
813
+ // Consecutive days (close-derived)
814
+ {
815
+ name: "Skip after prior-day 4+ consecutive up",
816
+ field: "Consecutive_Days",
817
+ operator: ">=",
818
+ value: 4,
819
+ test: (m) => getNum(m, "prev_Consecutive_Days") >= 4,
820
+ lagged: true,
821
+ },
822
+ {
823
+ name: "Skip after prior-day 4+ consecutive down",
824
+ field: "Consecutive_Days",
825
+ operator: "<=",
826
+ value: -4,
827
+ test: (m) => getNum(m, "prev_Consecutive_Days") <= -4,
828
+ lagged: true,
829
+ },
830
+ // RSI (close-derived)
831
+ {
832
+ name: "Skip when prior-day RSI > 70",
833
+ field: "RSI_14",
834
+ operator: ">",
835
+ value: 70,
836
+ test: (m) => getNum(m, "prev_RSI_14") > 70,
837
+ lagged: true,
838
+ },
839
+ {
840
+ name: "Skip when prior-day RSI < 30",
841
+ field: "RSI_14",
842
+ operator: "<",
843
+ value: 30,
844
+ test: (m) => getNum(m, "prev_RSI_14") < 30,
845
+ lagged: true,
846
+ },
847
+
848
+ // Realized Vol filters (close-derived, from market.enriched)
849
+ {
850
+ name: "Skip when prior-day 5D realized vol > 1.5%",
851
+ field: "Realized_Vol_5D",
852
+ operator: ">",
853
+ value: 1.5,
854
+ test: (m) => getNum(m, "prev_Realized_Vol_5D") > 1.5,
855
+ lagged: true,
856
+ },
857
+ {
858
+ name: "Skip when prior-day 20D realized vol > 1.2%",
859
+ field: "Realized_Vol_20D",
860
+ operator: ">",
861
+ value: 1.2,
862
+ test: (m) => getNum(m, "prev_Realized_Vol_20D") > 1.2,
863
+ lagged: true,
864
+ },
865
+
866
+ // IVP filters (close-derived, from market.enriched ivr/ivp columns)
867
+ {
868
+ name: "Skip when prior-day VIX_IVP > 80 (top 20% historically elevated vol)",
869
+ field: "VIX_IVP",
870
+ operator: ">",
871
+ value: 80,
872
+ test: (m) => getNum(m, "prev_VIX_IVP") > 80,
873
+ lagged: true,
874
+ },
875
+ {
876
+ name: "Skip when prior-day VIX_IVP < 20 (bottom 20% historically suppressed vol)",
877
+ field: "VIX_IVP",
878
+ operator: "<",
879
+ value: 20,
880
+ test: (m) => getNum(m, "prev_VIX_IVP") < 20,
881
+ lagged: true,
882
+ },
883
+
884
+ // Prior_Range_vs_ATR filter (open-known, from market.enriched — same-day value)
885
+ {
886
+ name: "Skip when Prior_Range_vs_ATR > 1.5 (prior day had outsized range)",
887
+ field: "Prior_Range_vs_ATR",
888
+ operator: ">",
889
+ value: 1.5,
890
+ test: (m) => getNum(m, "Prior_Range_vs_ATR") > 1.5,
891
+ lagged: false,
892
+ },
893
+ {
894
+ name: "Skip when Prior_Range_vs_ATR < 0.5 (prior day had compressed range)",
895
+ field: "Prior_Range_vs_ATR",
896
+ operator: "<",
897
+ value: 0.5,
898
+ test: (m) => getNum(m, "Prior_Range_vs_ATR") < 0.5,
899
+ lagged: false,
900
+ },
901
+ ];
902
+
903
+ for (const filterDef of testFilters) {
904
+ // For lagged filters, exclude trades with NaN lag values from evaluation
905
+ // (prevents NaN comparisons from silently passing all tests and biasing results)
906
+ let pool = matchedTrades;
907
+ if (filterDef.lagged) {
908
+ const prevField = `prev_${filterDef.field}`;
909
+ pool = matchedTrades.filter((t) => {
910
+ const val = getNum(t.market as Record<string, unknown>, prevField);
911
+ return !isNaN(val);
912
+ });
913
+ }
618
914
 
619
- const suggestions: FilterSuggestion[] = [];
620
-
621
- // Test filter functions
622
- const testFilters: Array<{
623
- name: string;
624
- field: string;
625
- operator: string;
626
- value: number | number[] | string;
627
- test: (m: Record<string, unknown>) => boolean;
628
- lagged: boolean;
629
- }> = [
630
- // Open-known filters (same-day values)
631
- // Gap filters
632
- { name: "Skip when |Gap_Pct| > 0.5%", field: "Gap_Pct", operator: ">", value: 0.5, test: (m) => Math.abs(getNum(m, "Gap_Pct")) > 0.5, lagged: false },
633
- { name: "Skip when |Gap_Pct| > 0.8%", field: "Gap_Pct", operator: ">", value: 0.8, test: (m) => Math.abs(getNum(m, "Gap_Pct")) > 0.8, lagged: false },
634
- { name: "Skip when |Gap_Pct| > 1.0%", field: "Gap_Pct", operator: ">", value: 1.0, test: (m) => Math.abs(getNum(m, "Gap_Pct")) > 1.0, lagged: false },
635
- // Day of week
636
- { name: "Skip Fridays", field: "Day_of_Week", operator: "==", value: 6, test: (m) => getNum(m, "Day_of_Week") === 6, lagged: false },
637
- { name: "Skip Mondays", field: "Day_of_Week", operator: "==", value: 2, test: (m) => getNum(m, "Day_of_Week") === 2, lagged: false },
638
- // OPEX
639
- { name: "Skip OPEX days", field: "Is_Opex", operator: "==", value: 1, test: (m) => getNum(m, "Is_Opex") === 1, lagged: false },
640
- // VIX_Open filters (open-known)
641
- { name: "Skip when VIX_Open > 25", field: "VIX_Open", operator: ">", value: 25, test: (m) => getNum(m, "VIX_Open") > 25, lagged: false },
642
- { name: "Skip when VIX_Open > 30", field: "VIX_Open", operator: ">", value: 30, test: (m) => getNum(m, "VIX_Open") > 30, lagged: false },
643
- // VIX_Gap_Pct filters (open-known)
644
- { name: "Skip when |VIX_Gap_Pct| > 10%", field: "VIX_Gap_Pct", operator: ">", value: 10, test: (m) => Math.abs(getNum(m, "VIX_Gap_Pct")) > 10, lagged: false },
645
- { name: "Skip when |VIX_Gap_Pct| > 15%", field: "VIX_Gap_Pct", operator: ">", value: 15, test: (m) => Math.abs(getNum(m, "VIX_Gap_Pct")) > 15, lagged: false },
646
-
647
- // Close-derived filters (prior trading day values via LAG CTE)
648
- // VIX (close-derived)
649
- { name: "Skip when prior-day VIX > 25", field: "VIX_Close", operator: ">", value: 25, test: (m) => getNum(m, "prev_VIX_Close") > 25, lagged: true },
650
- { name: "Skip when prior-day VIX > 30", field: "VIX_Close", operator: ">", value: 30, test: (m) => getNum(m, "prev_VIX_Close") > 30, lagged: true },
651
- { name: "Skip when prior-day VIX < 14", field: "VIX_Close", operator: "<", value: 14, test: (m) => getNum(m, "prev_VIX_Close") < 14, lagged: true },
652
- // VIX spike (close-derived)
653
- { name: "Skip when prior-day VIX_Spike > 5%", field: "VIX_Spike_Pct", operator: ">", value: 5, test: (m) => getNum(m, "prev_VIX_Spike_Pct") > 5, lagged: true },
654
- { name: "Skip when prior-day VIX_Spike > 8%", field: "VIX_Spike_Pct", operator: ">", value: 8, test: (m) => getNum(m, "prev_VIX_Spike_Pct") > 8, lagged: true },
655
- // Term structure (close-derived)
656
- { name: "Skip prior-day backwardation", field: "Term_Structure_State", operator: "==", value: -1, test: (m) => getNum(m, "prev_Term_Structure_State") === -1, lagged: true },
657
- // Vol regime (close-derived)
658
- { name: "Skip prior-day Vol Regime 5-6 (High/Extreme)", field: "Vol_Regime", operator: "in", value: [5, 6], test: (m) => getNum(m, "prev_Vol_Regime") >= 5, lagged: true },
659
- { name: "Skip prior-day Vol Regime 1 (Very Low)", field: "Vol_Regime", operator: "==", value: 1, test: (m) => getNum(m, "prev_Vol_Regime") === 1, lagged: true },
660
- // Consecutive days (close-derived)
661
- { name: "Skip after prior-day 4+ consecutive up", field: "Consecutive_Days", operator: ">=", value: 4, test: (m) => getNum(m, "prev_Consecutive_Days") >= 4, lagged: true },
662
- { name: "Skip after prior-day 4+ consecutive down", field: "Consecutive_Days", operator: "<=", value: -4, test: (m) => getNum(m, "prev_Consecutive_Days") <= -4, lagged: true },
663
- // RSI (close-derived)
664
- { name: "Skip when prior-day RSI > 70", field: "RSI_14", operator: ">", value: 70, test: (m) => getNum(m, "prev_RSI_14") > 70, lagged: true },
665
- { name: "Skip when prior-day RSI < 30", field: "RSI_14", operator: "<", value: 30, test: (m) => getNum(m, "prev_RSI_14") < 30, lagged: true },
666
-
667
- // Realized Vol filters (close-derived, from market.enriched)
668
- { name: "Skip when prior-day 5D realized vol > 1.5%", field: "Realized_Vol_5D", operator: ">", value: 1.5, test: (m) => getNum(m, "prev_Realized_Vol_5D") > 1.5, lagged: true },
669
- { name: "Skip when prior-day 20D realized vol > 1.2%", field: "Realized_Vol_20D", operator: ">", value: 1.2, test: (m) => getNum(m, "prev_Realized_Vol_20D") > 1.2, lagged: true },
670
-
671
- // IVP filters (close-derived, from market.enriched ivr/ivp columns)
672
- { name: "Skip when prior-day VIX_IVP > 80 (top 20% historically elevated vol)", field: "VIX_IVP", operator: ">", value: 80, test: (m) => getNum(m, "prev_VIX_IVP") > 80, lagged: true },
673
- { name: "Skip when prior-day VIX_IVP < 20 (bottom 20% historically suppressed vol)", field: "VIX_IVP", operator: "<", value: 20, test: (m) => getNum(m, "prev_VIX_IVP") < 20, lagged: true },
674
-
675
- // Prior_Range_vs_ATR filter (open-known, from market.enriched — same-day value)
676
- { name: "Skip when Prior_Range_vs_ATR > 1.5 (prior day had outsized range)", field: "Prior_Range_vs_ATR", operator: ">", value: 1.5, test: (m) => getNum(m, "Prior_Range_vs_ATR") > 1.5, lagged: false },
677
- { name: "Skip when Prior_Range_vs_ATR < 0.5 (prior day had compressed range)", field: "Prior_Range_vs_ATR", operator: "<", value: 0.5, test: (m) => getNum(m, "Prior_Range_vs_ATR") < 0.5, lagged: false },
678
- ];
679
-
680
- for (const filterDef of testFilters) {
681
- // For lagged filters, exclude trades with NaN lag values from evaluation
682
- // (prevents NaN comparisons from silently passing all tests and biasing results)
683
- let pool = matchedTrades;
684
- if (filterDef.lagged) {
685
- const prevField = `prev_${filterDef.field}`;
686
- pool = matchedTrades.filter((t) => {
687
- const val = getNum(t.market as Record<string, unknown>, prevField);
688
- return !isNaN(val);
689
- });
690
- }
915
+ if (pool.length < 10) continue;
691
916
 
692
- if (pool.length < 10) continue;
917
+ // Identify trades that would be removed
918
+ const removed = pool.filter((t) => filterDef.test(t.market as Record<string, unknown>));
919
+ const remaining = pool.filter(
920
+ (t) => !filterDef.test(t.market as Record<string, unknown>),
921
+ );
693
922
 
694
- // Identify trades that would be removed
695
- const removed = pool.filter((t) => filterDef.test(t.market as Record<string, unknown>));
696
- const remaining = pool.filter((t) => !filterDef.test(t.market as Record<string, unknown>));
923
+ if (removed.length === 0 || remaining.length < 5) continue;
697
924
 
698
- if (removed.length === 0 || remaining.length < 5) continue;
925
+ const poolWins = pool.filter((t) => t.trade.pl > 0).length;
926
+ const poolWinRate = (poolWins / pool.length) * 100;
927
+ const poolTotalPl = pool.reduce((sum, t) => sum + t.trade.pl, 0);
699
928
 
700
- const poolWins = pool.filter((t) => t.trade.pl > 0).length;
701
- const poolWinRate = (poolWins / pool.length) * 100;
702
- const poolTotalPl = pool.reduce((sum, t) => sum + t.trade.pl, 0);
929
+ const winnersRemoved = removed.filter((t) => t.trade.pl > 0).length;
930
+ const losersRemoved = removed.length - winnersRemoved;
703
931
 
704
- const winnersRemoved = removed.filter((t) => t.trade.pl > 0).length;
705
- const losersRemoved = removed.length - winnersRemoved;
932
+ const newWins = remaining.filter((t) => t.trade.pl > 0).length;
933
+ const newWinRate = (newWins / remaining.length) * 100;
934
+ const newTotalPl = remaining.reduce((sum, t) => sum + t.trade.pl, 0);
706
935
 
707
- const newWins = remaining.filter((t) => t.trade.pl > 0).length;
708
- const newWinRate = (newWins / remaining.length) * 100;
709
- const newTotalPl = remaining.reduce((sum, t) => sum + t.trade.pl, 0);
936
+ const winRateDelta = newWinRate - poolWinRate;
937
+ const plDelta = newTotalPl - poolTotalPl;
710
938
 
711
- const winRateDelta = newWinRate - poolWinRate;
712
- const plDelta = newTotalPl - poolTotalPl;
939
+ // Only include if improvement meets threshold
940
+ if (winRateDelta >= minImprovementPct) {
941
+ // Determine confidence based on sample size
942
+ let confidence: "high" | "medium" | "low" = "low";
943
+ if (removed.length >= 10 && remaining.length >= 20) {
944
+ confidence = "high";
945
+ } else if (removed.length >= 5 && remaining.length >= 10) {
946
+ confidence = "medium";
947
+ }
713
948
 
714
- // Only include if improvement meets threshold
715
- if (winRateDelta >= minImprovementPct) {
716
- // Determine confidence based on sample size
717
- let confidence: "high" | "medium" | "low" = "low";
718
- if (removed.length >= 10 && remaining.length >= 20) {
719
- confidence = "high";
720
- } else if (removed.length >= 5 && remaining.length >= 10) {
721
- confidence = "medium";
949
+ suggestions.push({
950
+ filter: filterDef.name,
951
+ condition: {
952
+ field: filterDef.field,
953
+ operator: filterDef.operator,
954
+ value: filterDef.value,
955
+ lagged: filterDef.lagged,
956
+ },
957
+ tradesRemoved: removed.length,
958
+ winnersRemoved,
959
+ losersRemoved,
960
+ newWinRate: Math.round(newWinRate * 100) / 100,
961
+ newTotalPl: Math.round(newTotalPl * 100) / 100,
962
+ winRateDelta: Math.round(winRateDelta * 100) / 100,
963
+ plDelta: Math.round(plDelta * 100) / 100,
964
+ confidence,
965
+ });
722
966
  }
723
-
724
- suggestions.push({
725
- filter: filterDef.name,
726
- condition: {
727
- field: filterDef.field,
728
- operator: filterDef.operator,
729
- value: filterDef.value,
730
- lagged: filterDef.lagged,
731
- },
732
- tradesRemoved: removed.length,
733
- winnersRemoved,
734
- losersRemoved,
735
- newWinRate: Math.round(newWinRate * 100) / 100,
736
- newTotalPl: Math.round(newTotalPl * 100) / 100,
737
- winRateDelta: Math.round(winRateDelta * 100) / 100,
738
- plDelta: Math.round(plDelta * 100) / 100,
739
- confidence,
740
- });
741
967
  }
742
- }
743
968
 
744
- // Sort by win rate improvement
745
- suggestions.sort((a, b) => b.winRateDelta - a.winRateDelta);
969
+ // Sort by win rate improvement
970
+ suggestions.sort((a, b) => b.winRateDelta - a.winRateDelta);
746
971
 
747
- // Take top 10
748
- const topSuggestions = suggestions.slice(0, 10);
972
+ // Take top 10
973
+ const topSuggestions = suggestions.slice(0, 10);
749
974
 
750
- // Generate composite filter suggestions from pairs of top-performing standalone filters
751
- const baseWinRate = currentWinRate;
752
- const significantFilters = suggestions.filter(s => s.winRateDelta > 3);
753
-
754
- interface CompositeSuggestion {
755
- name: string;
756
- type: "composite";
757
- projectedWinRate: number;
758
- projectedAvgPl: number;
759
- tradesAffected: number;
760
- improvement: number;
761
- }
975
+ // Generate composite filter suggestions from pairs of top-performing standalone filters
976
+ const baseWinRate = currentWinRate;
977
+ const significantFilters = suggestions.filter((s) => s.winRateDelta > 3);
762
978
 
763
- const compositeSuggestions: CompositeSuggestion[] = [];
764
-
765
- // Build a map from filter name to the original test function and lagged flag
766
- const filterTestMap = new Map<string, { test: (m: Record<string, unknown>) => boolean; lagged: boolean; field: string }>();
767
- for (const fd of testFilters) {
768
- filterTestMap.set(fd.name, { test: fd.test, lagged: fd.lagged, field: fd.field });
769
- }
770
-
771
- for (let i = 0; i < significantFilters.length; i++) {
772
- for (let j = i + 1; j < significantFilters.length; j++) {
773
- const filterA = significantFilters[i];
774
- const filterB = significantFilters[j];
979
+ interface CompositeSuggestion {
980
+ name: string;
981
+ type: "composite";
982
+ projectedWinRate: number;
983
+ projectedAvgPl: number;
984
+ tradesAffected: number;
985
+ improvement: number;
986
+ }
775
987
 
776
- const testA = filterTestMap.get(filterA.filter);
777
- const testB = filterTestMap.get(filterB.filter);
778
- if (!testA || !testB) continue;
988
+ const compositeSuggestions: CompositeSuggestion[] = [];
779
989
 
780
- // Build pool: exclude NaN-lag trades for lagged fields in either filter
781
- let pool = matchedTrades;
782
- if (testA.lagged) {
783
- const prevField = `prev_${testA.field}`;
784
- pool = pool.filter((t) => !isNaN(getNum(t.market as Record<string, unknown>, prevField)));
785
- }
786
- if (testB.lagged) {
787
- const prevField = `prev_${testB.field}`;
788
- pool = pool.filter((t) => !isNaN(getNum(t.market as Record<string, unknown>, prevField)));
789
- }
990
+ // Build a map from filter name to the original test function and lagged flag
991
+ const filterTestMap = new Map<
992
+ string,
993
+ { test: (m: Record<string, unknown>) => boolean; lagged: boolean; field: string }
994
+ >();
995
+ for (const fd of testFilters) {
996
+ filterTestMap.set(fd.name, { test: fd.test, lagged: fd.lagged, field: fd.field });
997
+ }
790
998
 
791
- // Find trades that match BOTH filters (would be skipped by both)
792
- const bothMatchTrades = pool.filter(t => {
793
- const m = t.market as Record<string, unknown>;
794
- return testA.test(m) && testB.test(m);
795
- });
999
+ for (let i = 0; i < significantFilters.length; i++) {
1000
+ for (let j = i + 1; j < significantFilters.length; j++) {
1001
+ const filterA = significantFilters[i];
1002
+ const filterB = significantFilters[j];
1003
+
1004
+ const testA = filterTestMap.get(filterA.filter);
1005
+ const testB = filterTestMap.get(filterB.filter);
1006
+ if (!testA || !testB) continue;
1007
+
1008
+ // Build pool: exclude NaN-lag trades for lagged fields in either filter
1009
+ let pool = matchedTrades;
1010
+ if (testA.lagged) {
1011
+ const prevField = `prev_${testA.field}`;
1012
+ pool = pool.filter(
1013
+ (t) => !isNaN(getNum(t.market as Record<string, unknown>, prevField)),
1014
+ );
1015
+ }
1016
+ if (testB.lagged) {
1017
+ const prevField = `prev_${testB.field}`;
1018
+ pool = pool.filter(
1019
+ (t) => !isNaN(getNum(t.market as Record<string, unknown>, prevField)),
1020
+ );
1021
+ }
796
1022
 
797
- if (bothMatchTrades.length < 5) continue;
798
-
799
- const compositeWinRate = bothMatchTrades.filter(t => t.trade.pl > 0).length / bothMatchTrades.length * 100;
800
- const compositeAvgPl = bothMatchTrades.reduce((sum, t) => sum + t.trade.pl, 0) / bothMatchTrades.length;
801
-
802
- // Only surface if composite win rate is materially better than either standalone filter alone
803
- const improvement = compositeWinRate - Math.max(filterA.winRateDelta + baseWinRate, filterB.winRateDelta + baseWinRate);
804
- if (improvement > 2) {
805
- compositeSuggestions.push({
806
- name: `${filterA.filter} AND ${filterB.filter}`,
807
- type: "composite",
808
- projectedWinRate: Math.round(compositeWinRate * 100) / 100,
809
- projectedAvgPl: Math.round(compositeAvgPl * 100) / 100,
810
- tradesAffected: bothMatchTrades.length,
811
- improvement: Math.round(improvement * 100) / 100,
1023
+ // Find trades that match BOTH filters (would be skipped by both)
1024
+ const bothMatchTrades = pool.filter((t) => {
1025
+ const m = t.market as Record<string, unknown>;
1026
+ return testA.test(m) && testB.test(m);
812
1027
  });
1028
+
1029
+ if (bothMatchTrades.length < 5) continue;
1030
+
1031
+ const compositeWinRate =
1032
+ (bothMatchTrades.filter((t) => t.trade.pl > 0).length / bothMatchTrades.length) *
1033
+ 100;
1034
+ const compositeAvgPl =
1035
+ bothMatchTrades.reduce((sum, t) => sum + t.trade.pl, 0) / bothMatchTrades.length;
1036
+
1037
+ // Only surface if composite win rate is materially better than either standalone filter alone
1038
+ const improvement =
1039
+ compositeWinRate -
1040
+ Math.max(filterA.winRateDelta + baseWinRate, filterB.winRateDelta + baseWinRate);
1041
+ if (improvement > 2) {
1042
+ compositeSuggestions.push({
1043
+ name: `${filterA.filter} AND ${filterB.filter}`,
1044
+ type: "composite",
1045
+ projectedWinRate: Math.round(compositeWinRate * 100) / 100,
1046
+ projectedAvgPl: Math.round(compositeAvgPl * 100) / 100,
1047
+ tradesAffected: bothMatchTrades.length,
1048
+ improvement: Math.round(improvement * 100) / 100,
1049
+ });
1050
+ }
813
1051
  }
814
1052
  }
815
- }
816
-
817
- // Sort composites by improvement, take top 5
818
- compositeSuggestions.sort((a, b) => b.improvement - a.improvement);
819
- const topCompositeSuggestions = compositeSuggestions.slice(0, 5);
820
1053
 
821
- const summary = `Filter analysis: ${blockId} | ${topSuggestions.length} standalone, ${topCompositeSuggestions.length} composite suggestions (min ${minImprovementPct}% improvement)`;
822
-
823
- const sfResponseData: Record<string, unknown> = {
824
- blockId,
825
- lagNote: "Close-derived fields (VIX_Close, Vol_Regime, RSI_14, Consecutive_Days, VIX_Spike_Pct, Term_Structure_State, Realized_Vol_5D, Realized_Vol_20D, VIX_IVR, VIX_IVP) use prior trading day values to prevent lookahead bias. Open-known fields (Gap_Pct, VIX_Open, VIX_Gap_Pct, Prior_Range_vs_ATR, Day_of_Week, Is_Opex) use same-day values.",
826
- strategy: effectiveStrategy || null,
827
- strategyName: strategyName || null,
828
- currentStats: {
829
- trades: matchedTrades.length,
830
- winRate: Math.round(currentWinRate * 100) / 100,
831
- totalPl: Math.round(currentTotalPl * 100) / 100,
832
- },
833
- suggestedFilters: topSuggestions,
834
- compositeSuggestions: topCompositeSuggestions,
835
- minImprovementThreshold: minImprovementPct,
836
- };
1054
+ // Sort composites by improvement, take top 5
1055
+ compositeSuggestions.sort((a, b) => b.improvement - a.improvement);
1056
+ const topCompositeSuggestions = compositeSuggestions.slice(0, 5);
1057
+
1058
+ const summary = `Filter analysis: ${blockId} | ${topSuggestions.length} standalone, ${topCompositeSuggestions.length} composite suggestions (min ${minImprovementPct}% improvement)`;
1059
+
1060
+ const sfResponseData: Record<string, unknown> = {
1061
+ blockId,
1062
+ lagNote:
1063
+ "Close-derived fields (VIX_Close, Vol_Regime, RSI_14, Consecutive_Days, VIX_Spike_Pct, Term_Structure_State, Realized_Vol_5D, Realized_Vol_20D, VIX_IVR, VIX_IVP) use prior trading day values to prevent lookahead bias. Open-known fields (Gap_Pct, VIX_Open, VIX_Gap_Pct, Prior_Range_vs_ATR, Day_of_Week, Is_Opex) use same-day values.",
1064
+ strategy: effectiveStrategy || null,
1065
+ strategyName: strategyName || null,
1066
+ currentStats: {
1067
+ trades: matchedTrades.length,
1068
+ winRate: Math.round(currentWinRate * 100) / 100,
1069
+ totalPl: Math.round(currentTotalPl * 100) / 100,
1070
+ },
1071
+ suggestedFilters: topSuggestions,
1072
+ compositeSuggestions: topCompositeSuggestions,
1073
+ minImprovementThreshold: minImprovementPct,
1074
+ };
837
1075
 
838
- if (availabilitySF.warnings.length > 0) {
839
- sfResponseData.warnings = availabilitySF.warnings;
840
- }
1076
+ if (availabilitySF.warnings.length > 0) {
1077
+ sfResponseData.warnings = availabilitySF.warnings;
1078
+ }
841
1079
 
842
- // Cross-reference suggestions with profile entry_filters when strategyName provided
843
- if (profileEntryFilters && profileEntryFilters.length > 0) {
844
- const profileContext: Array<{
845
- suggestion: string;
846
- field: string;
847
- status: "already_in_profile" | "new_suggestion";
848
- matchedFilter?: { field: string; operator: string; value: unknown };
849
- }> = [];
1080
+ // Cross-reference suggestions with profile entry_filters when strategyName provided
1081
+ if (profileEntryFilters && profileEntryFilters.length > 0) {
1082
+ const profileContext: Array<{
1083
+ suggestion: string;
1084
+ field: string;
1085
+ status: "already_in_profile" | "new_suggestion";
1086
+ matchedFilter?: { field: string; operator: string; value: unknown };
1087
+ }> = [];
1088
+
1089
+ for (const suggestion of topSuggestions) {
1090
+ const matchedFilter = profileEntryFilters.find(
1091
+ (f) => f.field === suggestion.condition.field,
1092
+ );
1093
+ profileContext.push({
1094
+ suggestion: suggestion.filter,
1095
+ field: suggestion.condition.field,
1096
+ status: matchedFilter ? "already_in_profile" : "new_suggestion",
1097
+ matchedFilter: matchedFilter
1098
+ ? {
1099
+ field: matchedFilter.field,
1100
+ operator: matchedFilter.operator,
1101
+ value: matchedFilter.value,
1102
+ }
1103
+ : undefined,
1104
+ });
1105
+ }
850
1106
 
851
- for (const suggestion of topSuggestions) {
852
- const matchedFilter = profileEntryFilters.find(
853
- (f) => f.field === suggestion.condition.field
854
- );
855
- profileContext.push({
856
- suggestion: suggestion.filter,
857
- field: suggestion.condition.field,
858
- status: matchedFilter ? "already_in_profile" : "new_suggestion",
859
- matchedFilter: matchedFilter
860
- ? { field: matchedFilter.field, operator: matchedFilter.operator, value: matchedFilter.value }
861
- : undefined,
862
- });
1107
+ sfResponseData.profile_context = {
1108
+ strategyName,
1109
+ existingFilters: profileEntryFilters.length,
1110
+ crossReference: profileContext,
1111
+ };
863
1112
  }
864
1113
 
865
- sfResponseData.profile_context = {
866
- strategyName,
867
- existingFilters: profileEntryFilters.length,
868
- crossReference: profileContext,
1114
+ return createToolOutput(summary, sfResponseData);
1115
+ } catch (error) {
1116
+ return {
1117
+ content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
1118
+ isError: true,
869
1119
  };
870
1120
  }
871
-
872
- return createToolOutput(summary, sfResponseData);
873
- } catch (error) {
874
- return {
875
- content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
876
- isError: true,
877
- };
878
- }
879
- })
1121
+ },
1122
+ ),
880
1123
  );
881
1124
 
882
1125
  // ---------------------------------------------------------------------------
@@ -899,239 +1142,278 @@ export function registerMarketDataTools(
899
1142
  startDate: z.string().optional().describe("Start date filter (YYYY-MM-DD)"),
900
1143
  endDate: z.string().optional().describe("End date filter (YYYY-MM-DD)"),
901
1144
  ticker: z.string().optional().describe("Underlying ticker symbol (default: SPX)"),
902
- includeOutcomeFields: z.boolean().default(false).describe("Include same-day close values (lookahead). Defaults to false for safety."),
903
- includeIntradayContext: z.boolean().default(false).describe(
904
- "Include raw intraday bars from market.spot (intradayBars array with time/open/high/low/close per bar). Requires 1 additional DuckDB query."
905
- ),
906
- limit: z.number().min(1).max(500).default(50).describe("Max trades to return (default: 50, max: 500)"),
1145
+ includeOutcomeFields: z
1146
+ .boolean()
1147
+ .default(false)
1148
+ .describe("Include same-day close values (lookahead). Defaults to false for safety."),
1149
+ includeIntradayContext: z
1150
+ .boolean()
1151
+ .default(false)
1152
+ .describe(
1153
+ "Include raw intraday bars from market.spot (intradayBars array with time/open/high/low/close per bar). Requires 1 additional DuckDB query.",
1154
+ ),
1155
+ limit: z
1156
+ .number()
1157
+ .min(1)
1158
+ .max(500)
1159
+ .default(50)
1160
+ .describe("Max trades to return (default: 50, max: 500)"),
907
1161
  offset: z.number().min(0).default(0).describe("Pagination offset (default: 0)"),
908
1162
  }),
909
1163
  },
910
- withFullSync(baseDir, async ({ blockId, strategy, startDate, endDate, ticker, includeOutcomeFields, includeIntradayContext, limit, offset }) => {
911
- try {
912
- const block = await loadBlock(baseDir, blockId);
913
- let trades = block.trades;
1164
+ withFullSync(
1165
+ baseDir,
1166
+ async ({
1167
+ blockId,
1168
+ strategy,
1169
+ startDate,
1170
+ endDate,
1171
+ ticker,
1172
+ includeOutcomeFields,
1173
+ includeIntradayContext,
1174
+ limit,
1175
+ offset,
1176
+ }) => {
1177
+ try {
1178
+ const block = await loadBlock(baseDir, blockId);
1179
+ let trades = block.trades;
1180
+
1181
+ // Filter before paginate
1182
+ trades = filterByStrategy(trades, strategy);
1183
+ trades = filterByDateRange(trades, startDate, endDate);
1184
+
1185
+ if (trades.length === 0) {
1186
+ return {
1187
+ content: [{ type: "text", text: "No trades found matching filters" }],
1188
+ isError: true,
1189
+ };
1190
+ }
914
1191
 
915
- // Filter before paginate
916
- trades = filterByStrategy(trades, strategy);
917
- trades = filterByDateRange(trades, startDate, endDate);
1192
+ const totalTrades = trades.length;
1193
+ const paginated = trades.slice(offset, offset + limit);
1194
+
1195
+ // Short-circuit if offset is past the end (empty page)
1196
+ if (paginated.length === 0) {
1197
+ return createToolOutput(
1198
+ `Enriched trades: ${blockId} | 0/0 matched | offset ${offset}, limit ${limit}`,
1199
+ {
1200
+ blockId,
1201
+ strategy: strategy || null,
1202
+ lagNote: "",
1203
+ tradesTotal: totalTrades,
1204
+ returned: 0,
1205
+ offset,
1206
+ hasMore: false,
1207
+ tradesMatched: 0,
1208
+ unmatchedDates: [],
1209
+ trades: [],
1210
+ },
1211
+ );
1212
+ }
918
1213
 
919
- if (trades.length === 0) {
920
- return {
921
- content: [{ type: "text", text: "No trades found matching filters" }],
922
- isError: true,
923
- };
924
- }
1214
+ // Collect unique ticker+date keys from paginated trades only.
1215
+ const tradeKeys = uniqueTradeLookupKeys(paginated);
925
1216
 
926
- const totalTrades = trades.length;
927
- const paginated = trades.slice(offset, offset + limit);
1217
+ // Query DuckDB for lookahead-free market data
1218
+ const conn = await getConnection(baseDir);
928
1219
 
929
- // Short-circuit if offset is past the end (empty page)
930
- if (paginated.length === 0) {
931
- return createToolOutput(
932
- `Enriched trades: ${blockId} | 0/0 matched | offset ${offset}, limit ${limit}`,
933
- {
934
- blockId,
935
- strategy: strategy || null,
936
- lagNote: "",
937
- tradesTotal: totalTrades,
938
- returned: 0,
939
- offset,
940
- hasMore: false,
941
- tradesMatched: 0,
942
- unmatchedDates: [],
943
- trades: [],
944
- }
945
- );
946
- }
1220
+ // Check data availability and collect warnings
1221
+ const resolvedTickerET = normalizeTicker(ticker || "") || DEFAULT_MARKET_TICKER;
1222
+ const availabilityET = await checkDataAvailability(stores, resolvedTickerET, {
1223
+ checkIntraday: includeIntradayContext,
1224
+ });
947
1225
 
948
- // Collect unique ticker+date keys from paginated trades only.
949
- const tradeKeys = uniqueTradeLookupKeys(paginated);
1226
+ const { sql: lagSql, params: lagParams } = buildLookaheadFreeQuery(tradeKeys);
1227
+ const dailyResult = await conn.runAndReadAll(lagSql, lagParams);
1228
+ const dailyRecords = resultToRecords(dailyResult);
1229
+ const daily = recordsByTickerDate(dailyRecords);
1230
+
1231
+ // Optionally query outcome (same-day close) data
1232
+ let outcomeMap: Map<string, Record<string, unknown>> | null = null;
1233
+ if (includeOutcomeFields) {
1234
+ const { sql: outcomeSql, params: outcomeParams } = buildOutcomeQuery(tradeKeys);
1235
+ const outcomeResult = await conn.runAndReadAll(outcomeSql, outcomeParams);
1236
+ const outcomeRecords = resultToRecords(outcomeResult);
1237
+ outcomeMap = recordsByTickerDate(outcomeRecords);
1238
+ }
950
1239
 
951
- // Query DuckDB for lookahead-free market data
952
- const conn = await getConnection(baseDir);
1240
+ // Optionally query intraday bar data
1241
+ let intradayBarsByKey: Map<
1242
+ string,
1243
+ Array<{ time: string; open: number; high: number; low: number; close: number }>
1244
+ > | null = null;
1245
+
1246
+ if (includeIntradayContext) {
1247
+ // Read intraday bars per (ticker, date) via SpotStore. The store
1248
+ // returns BarRow[] in (date, time) order; we group by ticker+date
1249
+ // so the downstream consumer can index by trade key.
1250
+ intradayBarsByKey = new Map<
1251
+ string,
1252
+ Array<{ time: string; open: number; high: number; low: number; close: number }>
1253
+ >();
1254
+ for (const key of tradeKeys) {
1255
+ const bars = await stores.spot.readBars(key.ticker, key.date, key.date);
1256
+ if (bars.length === 0) continue;
1257
+ const mapKey = marketTickerDateKey(key.ticker, key.date);
1258
+ const list = intradayBarsByKey.get(mapKey) ?? [];
1259
+ for (const bar of bars) {
1260
+ // Defense-in-depth: skip underlying bars with zero/null OHLC
1261
+ // (provider gaps from the spot ingest). Raw bars are left
1262
+ // unfiltered upstream so option tickers can keep legitimate
1263
+ // "no trade" zeros; underlyings always have a real price, so
1264
+ // a zero is a bug that would corrupt downstream high/low/range
1265
+ // computations.
1266
+ if (
1267
+ !Number.isFinite(bar.open) ||
1268
+ bar.open <= 0 ||
1269
+ !Number.isFinite(bar.high) ||
1270
+ bar.high <= 0 ||
1271
+ !Number.isFinite(bar.low) ||
1272
+ bar.low <= 0 ||
1273
+ !Number.isFinite(bar.close) ||
1274
+ bar.close <= 0
1275
+ )
1276
+ continue;
1277
+ list.push({
1278
+ time: String(bar.time),
1279
+ open: Number(bar.open),
1280
+ high: Number(bar.high),
1281
+ low: Number(bar.low),
1282
+ close: Number(bar.close),
1283
+ });
1284
+ }
1285
+ intradayBarsByKey.set(mapKey, list);
1286
+ }
1287
+ }
953
1288
 
954
- // Check data availability and collect warnings
955
- const resolvedTickerET = normalizeTicker(ticker || '') || DEFAULT_MARKET_TICKER;
956
- const availabilityET = await checkDataAvailability(stores, resolvedTickerET, { checkIntraday: includeIntradayContext });
1289
+ // Helper: clean numeric value (BigInt -> Number, NaN -> null)
1290
+ const cleanVal = (val: unknown): unknown => {
1291
+ if (typeof val === "bigint") return Number(val);
1292
+ if (typeof val === "number" && isNaN(val)) return null;
1293
+ return val === undefined ? null : val;
1294
+ };
957
1295
 
958
- const { sql: lagSql, params: lagParams } = buildLookaheadFreeQuery(tradeKeys);
959
- const dailyResult = await conn.runAndReadAll(lagSql, lagParams);
960
- const dailyRecords = resultToRecords(dailyResult);
961
- const daily = recordsByTickerDate(dailyRecords);
1296
+ // Build enriched trades
1297
+ const unmatchedDates: string[] = [];
1298
+ let matched = 0;
1299
+
1300
+ const enrichedTrades = paginated.map((trade) => {
1301
+ const lookup = getTradeLookupKey(trade);
1302
+ const marketKey = marketTickerDateKey(lookup.ticker, lookup.date);
1303
+ const marketData = daily.get(marketKey);
1304
+
1305
+ const commissions = trade.openingCommissionsFees + trade.closingCommissionsFees;
1306
+
1307
+ const baseTrade: Record<string, unknown> = {
1308
+ dateOpened: lookup.date,
1309
+ ticker: lookup.ticker,
1310
+ timeOpened: trade.timeOpened,
1311
+ strategy: trade.strategy,
1312
+ legs: trade.legs,
1313
+ pl: trade.pl,
1314
+ numContracts: trade.numContracts,
1315
+ premium: trade.premium,
1316
+ reasonForClose: trade.reasonForClose || null,
1317
+ commissions,
1318
+ };
962
1319
 
963
- // Optionally query outcome (same-day close) data
964
- let outcomeMap: Map<string, Record<string, unknown>> | null = null;
965
- if (includeOutcomeFields) {
966
- const { sql: outcomeSql, params: outcomeParams } = buildOutcomeQuery(tradeKeys);
967
- const outcomeResult = await conn.runAndReadAll(outcomeSql, outcomeParams);
968
- const outcomeRecords = resultToRecords(outcomeResult);
969
- outcomeMap = recordsByTickerDate(outcomeRecords);
970
- }
1320
+ if (!marketData) {
1321
+ unmatchedDates.push(`${lookup.date}|${lookup.ticker}`);
1322
+ baseTrade.entryContext = null;
1323
+ return baseTrade;
1324
+ }
971
1325
 
972
- // Optionally query intraday bar data
973
- let intradayBarsByKey: Map<string, Array<{time: string, open: number, high: number, low: number, close: number}>> | null = null;
974
-
975
- if (includeIntradayContext) {
976
- // Read intraday bars per (ticker, date) via SpotStore. The store
977
- // returns BarRow[] in (date, time) order; we group by ticker+date
978
- // so the downstream consumer can index by trade key.
979
- intradayBarsByKey = new Map<string, Array<{time: string, open: number, high: number, low: number, close: number}>>();
980
- for (const key of tradeKeys) {
981
- const bars = await stores.spot.readBars(key.ticker, key.date, key.date);
982
- if (bars.length === 0) continue;
983
- const mapKey = marketTickerDateKey(key.ticker, key.date);
984
- const list = intradayBarsByKey.get(mapKey) ?? [];
985
- for (const bar of bars) {
986
- // Defense-in-depth: skip underlying bars with zero/null OHLC
987
- // (provider gaps from the spot ingest). Raw bars are left
988
- // unfiltered upstream so option tickers can keep legitimate
989
- // "no trade" zeros; underlyings always have a real price, so
990
- // a zero is a bug that would corrupt downstream high/low/range
991
- // computations.
992
- if (
993
- !Number.isFinite(bar.open) || bar.open <= 0 ||
994
- !Number.isFinite(bar.high) || bar.high <= 0 ||
995
- !Number.isFinite(bar.low) || bar.low <= 0 ||
996
- !Number.isFinite(bar.close) || bar.close <= 0
997
- ) continue;
998
- list.push({
999
- time: String(bar.time),
1000
- open: Number(bar.open),
1001
- high: Number(bar.high),
1002
- low: Number(bar.low),
1003
- close: Number(bar.close),
1004
- });
1326
+ matched++;
1327
+
1328
+ // Build sameDay: open-known + static fields
1329
+ const sameDay: Record<string, unknown> = {};
1330
+ for (const field of OPEN_KNOWN_FIELDS) {
1331
+ sameDay[field] = cleanVal(marketData[field]);
1332
+ }
1333
+ for (const field of STATIC_FIELDS) {
1334
+ sameDay[field] = cleanVal(marketData[field]);
1005
1335
  }
1006
- intradayBarsByKey.set(mapKey, list);
1007
- }
1008
- }
1009
1336
 
1010
- // Helper: clean numeric value (BigInt -> Number, NaN -> null)
1011
- const cleanVal = (val: unknown): unknown => {
1012
- if (typeof val === "bigint") return Number(val);
1013
- if (typeof val === "number" && isNaN(val)) return null;
1014
- return val === undefined ? null : val;
1015
- };
1337
+ // Build priorDay: close-derived fields (read prev_* columns)
1338
+ const priorDay: Record<string, unknown> = {};
1339
+ for (const field of CLOSE_KNOWN_FIELDS) {
1340
+ priorDay[field] = cleanVal(marketData[`prev_${field}`]);
1341
+ }
1016
1342
 
1017
- // Build enriched trades
1018
- const unmatchedDates: string[] = [];
1019
- let matched = 0;
1343
+ baseTrade.entryContext = { sameDay, priorDay };
1344
+
1345
+ // Outcome fields (opt-in, same-day close values)
1346
+ if (includeOutcomeFields && outcomeMap) {
1347
+ const outcomeData = outcomeMap.get(marketKey);
1348
+ if (outcomeData) {
1349
+ const outcomeFields: Record<string, unknown> = {};
1350
+ for (const field of CLOSE_KNOWN_FIELDS) {
1351
+ outcomeFields[field] = cleanVal(outcomeData[field]);
1352
+ }
1353
+ baseTrade.outcomeFields = outcomeFields;
1354
+ }
1355
+ }
1020
1356
 
1021
- const enrichedTrades = paginated.map(trade => {
1022
- const lookup = getTradeLookupKey(trade);
1023
- const marketKey = marketTickerDateKey(lookup.ticker, lookup.date);
1024
- const marketData = daily.get(marketKey);
1025
-
1026
- const commissions = trade.openingCommissionsFees + trade.closingCommissionsFees;
1027
-
1028
- const baseTrade: Record<string, unknown> = {
1029
- dateOpened: lookup.date,
1030
- ticker: lookup.ticker,
1031
- timeOpened: trade.timeOpened,
1032
- strategy: trade.strategy,
1033
- legs: trade.legs,
1034
- pl: trade.pl,
1035
- numContracts: trade.numContracts,
1036
- premium: trade.premium,
1037
- reasonForClose: trade.reasonForClose || null,
1038
- commissions,
1039
- };
1357
+ // Intraday bars (opt-in, raw bar arrays per ticker+date)
1358
+ if (includeIntradayContext && intradayBarsByKey) {
1359
+ const bars = intradayBarsByKey.get(marketKey) || null;
1360
+ baseTrade.intradayBars = bars;
1361
+ }
1040
1362
 
1041
- if (!marketData) {
1042
- unmatchedDates.push(`${lookup.date}|${lookup.ticker}`);
1043
- baseTrade.entryContext = null;
1044
1363
  return baseTrade;
1045
- }
1046
-
1047
- matched++;
1364
+ });
1048
1365
 
1049
- // Build sameDay: open-known + static fields
1050
- const sameDay: Record<string, unknown> = {};
1051
- for (const field of OPEN_KNOWN_FIELDS) {
1052
- sameDay[field] = cleanVal(marketData[field]);
1366
+ const sortedUnmatchedDates = [...new Set(unmatchedDates)].sort();
1367
+
1368
+ // Build lagNote
1369
+ let lagNote =
1370
+ "Entry context uses lookahead-free temporal joins on ticker+date via market.enriched + market.spot_daily LEFT JOIN VIX tickers + market.enriched_context. " +
1371
+ "Same-day (open-known) fields: Gap_Pct, VIX_Open, VIX_Gap_Pct, Prior_Range_vs_ATR, Day_of_Week, Month, Is_Opex. " +
1372
+ "Prior-day (close-derived) fields: VIX_Close, RSI_14, Vol_Regime, Realized_Vol_5D, Realized_Vol_20D, VIX_IVR, VIX_IVP, Term_Structure_State, etc. — " +
1373
+ "use the previous trading day's close-derived values to prevent lookahead bias.";
1374
+ if (includeOutcomeFields) {
1375
+ lagNote +=
1376
+ " Outcome fields contain same-day close-derived values and represent information NOT available at trade entry time.";
1053
1377
  }
1054
- for (const field of STATIC_FIELDS) {
1055
- sameDay[field] = cleanVal(marketData[field]);
1056
- }
1057
-
1058
- // Build priorDay: close-derived fields (read prev_* columns)
1059
- const priorDay: Record<string, unknown> = {};
1060
- for (const field of CLOSE_KNOWN_FIELDS) {
1061
- priorDay[field] = cleanVal(marketData[`prev_${field}`]);
1378
+ if (includeIntradayContext) {
1379
+ lagNote +=
1380
+ " intradayBars contains raw OHLC bar arrays from market.spot keyed by ticker+date.";
1062
1381
  }
1063
1382
 
1064
- baseTrade.entryContext = { sameDay, priorDay };
1383
+ // Build response data
1384
+ const responseData: Record<string, unknown> = {
1385
+ blockId,
1386
+ strategy: strategy || null,
1387
+ lagNote,
1388
+ tradesTotal: totalTrades,
1389
+ returned: paginated.length,
1390
+ offset,
1391
+ hasMore: offset + limit < totalTrades,
1392
+ tradesMatched: matched,
1393
+ unmatchedDates: sortedUnmatchedDates,
1394
+ trades: enrichedTrades,
1395
+ };
1065
1396
 
1066
- // Outcome fields (opt-in, same-day close values)
1067
- if (includeOutcomeFields && outcomeMap) {
1068
- const outcomeData = outcomeMap.get(marketKey);
1069
- if (outcomeData) {
1070
- const outcomeFields: Record<string, unknown> = {};
1071
- for (const field of CLOSE_KNOWN_FIELDS) {
1072
- outcomeFields[field] = cleanVal(outcomeData[field]);
1073
- }
1074
- baseTrade.outcomeFields = outcomeFields;
1075
- }
1397
+ if (includeOutcomeFields) {
1398
+ responseData.lookaheadWarning =
1399
+ "WARNING: outcomeFields contain same-day close-derived values that were NOT available at trade entry time. Do not use these for entry signal analysis.";
1076
1400
  }
1077
1401
 
1078
- // Intraday bars (opt-in, raw bar arrays per ticker+date)
1079
- if (includeIntradayContext && intradayBarsByKey) {
1080
- const bars = intradayBarsByKey.get(marketKey) || null;
1081
- baseTrade.intradayBars = bars;
1402
+ if (availabilityET.warnings.length > 0) {
1403
+ responseData.warnings = availabilityET.warnings;
1082
1404
  }
1083
1405
 
1084
- return baseTrade;
1085
- });
1086
-
1087
- const sortedUnmatchedDates = [...new Set(unmatchedDates)].sort();
1088
-
1089
- // Build lagNote
1090
- let lagNote =
1091
- "Entry context uses lookahead-free temporal joins on ticker+date via market.enriched + market.spot_daily LEFT JOIN VIX tickers + market.enriched_context. " +
1092
- "Same-day (open-known) fields: Gap_Pct, VIX_Open, VIX_Gap_Pct, Prior_Range_vs_ATR, Day_of_Week, Month, Is_Opex. " +
1093
- "Prior-day (close-derived) fields: VIX_Close, RSI_14, Vol_Regime, Realized_Vol_5D, Realized_Vol_20D, VIX_IVR, VIX_IVP, Term_Structure_State, etc. — " +
1094
- "use the previous trading day's close-derived values to prevent lookahead bias.";
1095
- if (includeOutcomeFields) {
1096
- lagNote += " Outcome fields contain same-day close-derived values and represent information NOT available at trade entry time.";
1097
- }
1098
- if (includeIntradayContext) {
1099
- lagNote += " intradayBars contains raw OHLC bar arrays from market.spot keyed by ticker+date.";
1100
- }
1101
-
1102
- // Build response data
1103
- const responseData: Record<string, unknown> = {
1104
- blockId,
1105
- strategy: strategy || null,
1106
- lagNote,
1107
- tradesTotal: totalTrades,
1108
- returned: paginated.length,
1109
- offset,
1110
- hasMore: offset + limit < totalTrades,
1111
- tradesMatched: matched,
1112
- unmatchedDates: sortedUnmatchedDates,
1113
- trades: enrichedTrades,
1114
- };
1115
-
1116
- if (includeOutcomeFields) {
1117
- responseData.lookaheadWarning =
1118
- "WARNING: outcomeFields contain same-day close-derived values that were NOT available at trade entry time. Do not use these for entry signal analysis.";
1119
- }
1406
+ const summary = `Enriched trades: ${blockId} | ${matched}/${paginated.length} matched | offset ${offset}, limit ${limit}`;
1120
1407
 
1121
- if (availabilityET.warnings.length > 0) {
1122
- responseData.warnings = availabilityET.warnings;
1408
+ return createToolOutput(summary, responseData);
1409
+ } catch (error) {
1410
+ return {
1411
+ content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
1412
+ isError: true,
1413
+ };
1123
1414
  }
1124
-
1125
- const summary = `Enriched trades: ${blockId} | ${matched}/${paginated.length} matched | offset ${offset}, limit ${limit}`;
1126
-
1127
- return createToolOutput(summary, responseData);
1128
- } catch (error) {
1129
- return {
1130
- content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
1131
- isError: true,
1132
- };
1133
- }
1134
- })
1415
+ },
1416
+ ),
1135
1417
  );
1136
1418
 
1137
1419
  // ---------------------------------------------------------------------------
@@ -1161,245 +1443,273 @@ export function registerMarketDataTools(
1161
1443
  ticker: z.string().default("SPX").describe("Ticker symbol (default: SPX)"),
1162
1444
  startDate: z.string().describe("Start date (YYYY-MM-DD)"),
1163
1445
  endDate: z.string().optional().describe("End date (YYYY-MM-DD, defaults to today)"),
1164
- startTime: z.string().default("0930").describe("ORB window start in HHMM format (default: '0930')"),
1165
- endTime: z.string().default("1000").describe("ORB window end in HHMM format (default: '1000')"),
1166
- useHighLow: z.boolean().default(true).describe("Use high/low for range (true) or close prices (false)"),
1167
- barResolution: z.string().optional().describe("Expected bar resolution in minutes (e.g., '15'). Auto-detected if omitted."),
1446
+ startTime: z
1447
+ .string()
1448
+ .default("0930")
1449
+ .describe("ORB window start in HHMM format (default: '0930')"),
1450
+ endTime: z
1451
+ .string()
1452
+ .default("1000")
1453
+ .describe("ORB window end in HHMM format (default: '1000')"),
1454
+ useHighLow: z
1455
+ .boolean()
1456
+ .default(true)
1457
+ .describe("Use high/low for range (true) or close prices (false)"),
1458
+ barResolution: z
1459
+ .string()
1460
+ .optional()
1461
+ .describe("Expected bar resolution in minutes (e.g., '15'). Auto-detected if omitted."),
1168
1462
  limit: z.number().min(1).max(500).default(100).describe("Max days to return"),
1169
1463
  }),
1170
1464
  },
1171
- withFullSync(baseDir, async ({ ticker, startDate, endDate, startTime, endTime, useHighLow, barResolution, limit }) => {
1172
- try {
1173
- const normalizedTicker = normalizeTicker(ticker) || "SPX";
1174
- const end = endDate || new Date().toISOString().split("T")[0];
1175
-
1176
- // Convert HHMM to HH:MM for SQL comparison
1177
- const sqlStartTime = hhmmToSqlTime(startTime);
1178
- const sqlEndTime = hhmmToSqlTime(endTime);
1179
-
1180
- // Check data availability
1181
- const availability = await checkDataAvailability(stores, normalizedTicker, { checkIntraday: true });
1182
-
1183
- // Data availability + bar reads flow through SpotStore; the window
1184
- // aggregation and breakout-detection logic runs in TypeScript over
1185
- // the BarRow[] returned by readBars.
1186
-
1187
- // Quick check: is there any spot data for this ticker over the requested range?
1188
- // Use a wide bracket so the "no data at all" case is distinguishable from
1189
- // "no data in the requested range" (the latter is handled below by an empty
1190
- // `byDate` map).
1191
- const wideCoverage = await stores.spot.getCoverage(
1192
- normalizedTicker,
1193
- "2000-01-01",
1194
- new Date().toISOString().split("T")[0],
1195
- );
1196
- if (wideCoverage.totalDates === 0) {
1197
- return createToolOutput(
1198
- `ORB (${normalizedTicker}): No intraday data available`,
1199
- {
1465
+ withFullSync(
1466
+ baseDir,
1467
+ async ({
1468
+ ticker,
1469
+ startDate,
1470
+ endDate,
1471
+ startTime,
1472
+ endTime,
1473
+ useHighLow,
1474
+ barResolution,
1475
+ limit,
1476
+ }) => {
1477
+ try {
1478
+ const normalizedTicker = normalizeTicker(ticker) || "SPX";
1479
+ const end = endDate || new Date().toISOString().split("T")[0];
1480
+
1481
+ // Convert HHMM to HH:MM for SQL comparison
1482
+ const sqlStartTime = hhmmToSqlTime(startTime);
1483
+ const sqlEndTime = hhmmToSqlTime(endTime);
1484
+
1485
+ // Check data availability
1486
+ const availability = await checkDataAvailability(stores, normalizedTicker, {
1487
+ checkIntraday: true,
1488
+ });
1489
+
1490
+ // Data availability + bar reads flow through SpotStore; the window
1491
+ // aggregation and breakout-detection logic runs in TypeScript over
1492
+ // the BarRow[] returned by readBars.
1493
+
1494
+ // Quick check: is there any spot data for this ticker over the requested range?
1495
+ // Use a wide bracket so the "no data at all" case is distinguishable from
1496
+ // "no data in the requested range" (the latter is handled below by an empty
1497
+ // `byDate` map).
1498
+ const wideCoverage = await stores.spot.getCoverage(
1499
+ normalizedTicker,
1500
+ "2000-01-01",
1501
+ new Date().toISOString().split("T")[0],
1502
+ );
1503
+ if (wideCoverage.totalDates === 0) {
1504
+ return createToolOutput(`ORB (${normalizedTicker}): No intraday data available`, {
1200
1505
  query: { ticker: normalizedTicker, startTime, endTime, startDate, endDate: end },
1201
1506
  warnings: availability.warnings,
1202
1507
  days: [],
1203
1508
  stats: { totalDays: 0 },
1204
- }
1205
- );
1206
- }
1207
-
1208
- // Determine bar resolution: use provided value or auto-detect from first available date
1209
- let resolvedBarResolution: number | null = null;
1210
- if (barResolution !== undefined && barResolution !== null) {
1211
- const parsed = parseInt(barResolution, 10);
1212
- if (!isNaN(parsed) && parsed > 0) {
1213
- resolvedBarResolution = parsed;
1509
+ });
1214
1510
  }
1215
- } else if (wideCoverage.earliest) {
1216
- // Auto-detect: read the first available date's bars, compute the gap
1217
- // between the first two distinct times. SpotStore returns rows ordered
1218
- // by (date, time) so we can scan in order.
1219
- try {
1220
- const sampleBars = await stores.spot.readBars(
1221
- normalizedTicker,
1222
- wideCoverage.earliest,
1223
- wideCoverage.earliest,
1224
- );
1225
- const distinctTimes: string[] = [];
1226
- for (const bar of sampleBars) {
1227
- const t = String(bar.time);
1228
- if (distinctTimes.length === 0 || distinctTimes[distinctTimes.length - 1] !== t) {
1229
- distinctTimes.push(t);
1230
- if (distinctTimes.length === 10) break;
1231
- }
1511
+
1512
+ // Determine bar resolution: use provided value or auto-detect from first available date
1513
+ let resolvedBarResolution: number | null = null;
1514
+ if (barResolution !== undefined && barResolution !== null) {
1515
+ const parsed = parseInt(barResolution, 10);
1516
+ if (!isNaN(parsed) && parsed > 0) {
1517
+ resolvedBarResolution = parsed;
1232
1518
  }
1233
- if (distinctTimes.length >= 2) {
1234
- const t1 = distinctTimes[0];
1235
- const t2 = distinctTimes[1];
1236
- const [h1, m1] = t1.split(":").map(Number);
1237
- const [h2, m2] = t2.split(":").map(Number);
1238
- const gap = (h2 * 60 + m2) - (h1 * 60 + m1);
1239
- if (gap > 0) {
1240
- resolvedBarResolution = gap;
1519
+ } else if (wideCoverage.earliest) {
1520
+ // Auto-detect: read the first available date's bars, compute the gap
1521
+ // between the first two distinct times. SpotStore returns rows ordered
1522
+ // by (date, time) so we can scan in order.
1523
+ try {
1524
+ const sampleBars = await stores.spot.readBars(
1525
+ normalizedTicker,
1526
+ wideCoverage.earliest,
1527
+ wideCoverage.earliest,
1528
+ );
1529
+ const distinctTimes: string[] = [];
1530
+ for (const bar of sampleBars) {
1531
+ const t = String(bar.time);
1532
+ if (distinctTimes.length === 0 || distinctTimes[distinctTimes.length - 1] !== t) {
1533
+ distinctTimes.push(t);
1534
+ if (distinctTimes.length === 10) break;
1535
+ }
1241
1536
  }
1537
+ if (distinctTimes.length >= 2) {
1538
+ const t1 = distinctTimes[0];
1539
+ const t2 = distinctTimes[1];
1540
+ const [h1, m1] = t1.split(":").map(Number);
1541
+ const [h2, m2] = t2.split(":").map(Number);
1542
+ const gap = h2 * 60 + m2 - (h1 * 60 + m1);
1543
+ if (gap > 0) {
1544
+ resolvedBarResolution = gap;
1545
+ }
1546
+ }
1547
+ } catch {
1548
+ // If auto-detection fails, proceed without resolution filtering
1242
1549
  }
1243
- } catch {
1244
- // If auto-detection fails, proceed without resolution filtering
1245
1550
  }
1246
- }
1247
-
1248
- // Read all in-range bars and group by date for ORB computation. The
1249
- // `useHighLow` toggle picks raw high/low vs close-of-bar high/low.
1250
- const allBars = await stores.spot.readBars(normalizedTicker, startDate, end);
1251
-
1252
- // Group by date, preserving (time)-ascending order from readBars.
1253
- // Defense-in-depth: skip underlying bars with zero/null OHLC (provider
1254
- // gaps from the spot ingest). Without this, a zero-low minute in the
1255
- // opening window would corrupt ORB_Low and the breakout/range output.
1256
- const barsByDate = new Map<string, typeof allBars>();
1257
- for (const bar of allBars) {
1258
- if (
1259
- !Number.isFinite(bar.open) || bar.open <= 0 ||
1260
- !Number.isFinite(bar.high) || bar.high <= 0 ||
1261
- !Number.isFinite(bar.low) || bar.low <= 0 ||
1262
- !Number.isFinite(bar.close) || bar.close <= 0
1263
- ) continue;
1264
- const arr = barsByDate.get(bar.date);
1265
- if (arr) arr.push(bar);
1266
- else barsByDate.set(bar.date, [bar]);
1267
- }
1268
1551
 
1269
- type BreakoutCondition = "HighFirst" | "LowFirst" | "HighOnly" | "LowOnly" | "NoBreakout";
1270
-
1271
- interface OrbDayResult {
1272
- date: string;
1273
- ORB_High: number;
1274
- ORB_Low: number;
1275
- ORB_Range: number;
1276
- ORB_Range_Pct: number;
1277
- ORB_Open: number | null;
1278
- breakout_condition: BreakoutCondition;
1279
- breakout_up_time: string | null;
1280
- breakout_down_time: string | null;
1281
- entry_triggered: boolean;
1282
- }
1283
-
1284
- // Compute ORB window + breakouts per date in TypeScript over the
1285
- // grouped bars.
1286
- const days: OrbDayResult[] = [];
1287
- const sortedDates = [...barsByDate.keys()].sort();
1288
- for (const date of sortedDates) {
1289
- const dayBars = barsByDate.get(date)!;
1290
- // ORB window: time in [sqlStartTime, sqlEndTime]
1291
- const windowBars = dayBars.filter(
1292
- (b) => String(b.time) >= sqlStartTime && String(b.time) <= sqlEndTime,
1293
- );
1294
- if (windowBars.length === 0) continue;
1295
-
1296
- let orbHigh = -Infinity;
1297
- let orbLow = Infinity;
1298
- let orbOpen: number | null = null;
1299
- for (const b of windowBars) {
1300
- const hi = useHighLow ? Number(b.high) : Number(b.close);
1301
- const lo = useHighLow ? Number(b.low) : Number(b.close);
1302
- if (hi > orbHigh) orbHigh = hi;
1303
- if (lo < orbLow) orbLow = lo;
1304
- if (String(b.time) === sqlStartTime && orbOpen === null) {
1305
- orbOpen = Number(b.open);
1306
- }
1307
- }
1308
- const orbRange = orbHigh - orbLow;
1309
- const orbRangePct = orbLow > 0 ? (orbRange / orbLow) * 100 : 0;
1310
-
1311
- // Breakout window: time strictly > sqlEndTime
1312
- let breakoutUpTime: string | null = null;
1313
- let breakoutDownTime: string | null = null;
1314
- for (const b of dayBars) {
1315
- const t = String(b.time);
1316
- if (t <= sqlEndTime) continue;
1317
- const upHit = useHighLow ? Number(b.high) > orbHigh : Number(b.close) > orbHigh;
1318
- const downHit = useHighLow ? Number(b.low) < orbLow : Number(b.close) < orbLow;
1319
- if (upHit && breakoutUpTime === null) breakoutUpTime = t;
1320
- if (downHit && breakoutDownTime === null) breakoutDownTime = t;
1321
- if (breakoutUpTime !== null && breakoutDownTime !== null) break;
1552
+ // Read all in-range bars and group by date for ORB computation. The
1553
+ // `useHighLow` toggle picks raw high/low vs close-of-bar high/low.
1554
+ const allBars = await stores.spot.readBars(normalizedTicker, startDate, end);
1555
+
1556
+ // Group by date, preserving (time)-ascending order from readBars.
1557
+ // Defense-in-depth: skip underlying bars with zero/null OHLC (provider
1558
+ // gaps from the spot ingest). Without this, a zero-low minute in the
1559
+ // opening window would corrupt ORB_Low and the breakout/range output.
1560
+ const barsByDate = new Map<string, typeof allBars>();
1561
+ for (const bar of allBars) {
1562
+ if (
1563
+ !Number.isFinite(bar.open) ||
1564
+ bar.open <= 0 ||
1565
+ !Number.isFinite(bar.high) ||
1566
+ bar.high <= 0 ||
1567
+ !Number.isFinite(bar.low) ||
1568
+ bar.low <= 0 ||
1569
+ !Number.isFinite(bar.close) ||
1570
+ bar.close <= 0
1571
+ )
1572
+ continue;
1573
+ const arr = barsByDate.get(bar.date);
1574
+ if (arr) arr.push(bar);
1575
+ else barsByDate.set(bar.date, [bar]);
1322
1576
  }
1323
1577
 
1324
- let breakoutCondition: BreakoutCondition;
1325
- if (breakoutUpTime !== null && breakoutDownTime !== null) {
1326
- breakoutCondition = breakoutUpTime < breakoutDownTime ? "HighFirst" : "LowFirst";
1327
- } else if (breakoutUpTime !== null) {
1328
- breakoutCondition = "HighOnly";
1329
- } else if (breakoutDownTime !== null) {
1330
- breakoutCondition = "LowOnly";
1331
- } else {
1332
- breakoutCondition = "NoBreakout";
1578
+ type BreakoutCondition = "HighFirst" | "LowFirst" | "HighOnly" | "LowOnly" | "NoBreakout";
1579
+
1580
+ interface OrbDayResult {
1581
+ date: string;
1582
+ ORB_High: number;
1583
+ ORB_Low: number;
1584
+ ORB_Range: number;
1585
+ ORB_Range_Pct: number;
1586
+ ORB_Open: number | null;
1587
+ breakout_condition: BreakoutCondition;
1588
+ breakout_up_time: string | null;
1589
+ breakout_down_time: string | null;
1590
+ entry_triggered: boolean;
1333
1591
  }
1334
1592
 
1335
- days.push({
1336
- date,
1337
- ORB_High: Math.round(orbHigh * 100) / 100,
1338
- ORB_Low: Math.round(orbLow * 100) / 100,
1339
- ORB_Range: Math.round(orbRange * 100) / 100,
1340
- ORB_Range_Pct: Math.round(orbRangePct * 10000) / 10000,
1341
- ORB_Open: orbOpen !== null ? Math.round(orbOpen * 100) / 100 : null,
1342
- breakout_condition: breakoutCondition,
1343
- breakout_up_time: breakoutUpTime,
1344
- breakout_down_time: breakoutDownTime,
1345
- entry_triggered: breakoutCondition !== "NoBreakout",
1346
- });
1347
- }
1593
+ // Compute ORB window + breakouts per date in TypeScript over the
1594
+ // grouped bars.
1595
+ const days: OrbDayResult[] = [];
1596
+ const sortedDates = [...barsByDate.keys()].sort();
1597
+ for (const date of sortedDates) {
1598
+ const dayBars = barsByDate.get(date)!;
1599
+ // ORB window: time in [sqlStartTime, sqlEndTime]
1600
+ const windowBars = dayBars.filter(
1601
+ (b) => String(b.time) >= sqlStartTime && String(b.time) <= sqlEndTime,
1602
+ );
1603
+ if (windowBars.length === 0) continue;
1604
+
1605
+ let orbHigh = -Infinity;
1606
+ let orbLow = Infinity;
1607
+ let orbOpen: number | null = null;
1608
+ for (const b of windowBars) {
1609
+ const hi = useHighLow ? Number(b.high) : Number(b.close);
1610
+ const lo = useHighLow ? Number(b.low) : Number(b.close);
1611
+ if (hi > orbHigh) orbHigh = hi;
1612
+ if (lo < orbLow) orbLow = lo;
1613
+ if (String(b.time) === sqlStartTime && orbOpen === null) {
1614
+ orbOpen = Number(b.open);
1615
+ }
1616
+ }
1617
+ const orbRange = orbHigh - orbLow;
1618
+ const orbRangePct = orbLow > 0 ? (orbRange / orbLow) * 100 : 0;
1619
+
1620
+ // Breakout window: time strictly > sqlEndTime
1621
+ let breakoutUpTime: string | null = null;
1622
+ let breakoutDownTime: string | null = null;
1623
+ for (const b of dayBars) {
1624
+ const t = String(b.time);
1625
+ if (t <= sqlEndTime) continue;
1626
+ const upHit = useHighLow ? Number(b.high) > orbHigh : Number(b.close) > orbHigh;
1627
+ const downHit = useHighLow ? Number(b.low) < orbLow : Number(b.close) < orbLow;
1628
+ if (upHit && breakoutUpTime === null) breakoutUpTime = t;
1629
+ if (downHit && breakoutDownTime === null) breakoutDownTime = t;
1630
+ if (breakoutUpTime !== null && breakoutDownTime !== null) break;
1631
+ }
1348
1632
 
1349
- const totalDays = days.length;
1633
+ let breakoutCondition: BreakoutCondition;
1634
+ if (breakoutUpTime !== null && breakoutDownTime !== null) {
1635
+ breakoutCondition = breakoutUpTime < breakoutDownTime ? "HighFirst" : "LowFirst";
1636
+ } else if (breakoutUpTime !== null) {
1637
+ breakoutCondition = "HighOnly";
1638
+ } else if (breakoutDownTime !== null) {
1639
+ breakoutCondition = "LowOnly";
1640
+ } else {
1641
+ breakoutCondition = "NoBreakout";
1642
+ }
1350
1643
 
1351
- // Compute aggregate stats
1352
- const avgOrbRangePct = totalDays > 0
1353
- ? days.reduce((sum, d) => sum + d.ORB_Range_Pct, 0) / totalDays
1354
- : 0;
1644
+ days.push({
1645
+ date,
1646
+ ORB_High: Math.round(orbHigh * 100) / 100,
1647
+ ORB_Low: Math.round(orbLow * 100) / 100,
1648
+ ORB_Range: Math.round(orbRange * 100) / 100,
1649
+ ORB_Range_Pct: Math.round(orbRangePct * 10000) / 10000,
1650
+ ORB_Open: orbOpen !== null ? Math.round(orbOpen * 100) / 100 : null,
1651
+ breakout_condition: breakoutCondition,
1652
+ breakout_up_time: breakoutUpTime,
1653
+ breakout_down_time: breakoutDownTime,
1654
+ entry_triggered: breakoutCondition !== "NoBreakout",
1655
+ });
1656
+ }
1355
1657
 
1356
- const breakdownByCondition = {
1357
- HighFirst: days.filter((d) => d.breakout_condition === "HighFirst").length,
1358
- LowFirst: days.filter((d) => d.breakout_condition === "LowFirst").length,
1359
- HighOnly: days.filter((d) => d.breakout_condition === "HighOnly").length,
1360
- LowOnly: days.filter((d) => d.breakout_condition === "LowOnly").length,
1361
- NoBreakout: days.filter((d) => d.breakout_condition === "NoBreakout").length,
1362
- };
1658
+ const totalDays = days.length;
1363
1659
 
1364
- // Apply limit
1365
- const limitedDays = days.slice(0, limit);
1660
+ // Compute aggregate stats
1661
+ const avgOrbRangePct =
1662
+ totalDays > 0 ? days.reduce((sum, d) => sum + d.ORB_Range_Pct, 0) / totalDays : 0;
1366
1663
 
1367
- const summary =
1368
- `ORB (${normalizedTicker}, ${startTime}-${endTime}): ${startDate} to ${end} | ` +
1369
- `${totalDays} days, avg range ${formatPercent(avgOrbRangePct)}`;
1664
+ const breakdownByCondition = {
1665
+ HighFirst: days.filter((d) => d.breakout_condition === "HighFirst").length,
1666
+ LowFirst: days.filter((d) => d.breakout_condition === "LowFirst").length,
1667
+ HighOnly: days.filter((d) => d.breakout_condition === "HighOnly").length,
1668
+ LowOnly: days.filter((d) => d.breakout_condition === "LowOnly").length,
1669
+ NoBreakout: days.filter((d) => d.breakout_condition === "NoBreakout").length,
1670
+ };
1370
1671
 
1371
- const responseData: Record<string, unknown> = {
1372
- query: {
1373
- ticker: normalizedTicker,
1374
- startTime,
1375
- endTime,
1376
- sqlStartTime,
1377
- sqlEndTime,
1378
- startDate,
1379
- endDate: end,
1380
- useHighLow,
1381
- barResolution: resolvedBarResolution !== null ? String(resolvedBarResolution) : "auto",
1382
- },
1383
- stats: {
1384
- totalDays,
1385
- avgOrbRangePct: Math.round(avgOrbRangePct * 10000) / 10000,
1386
- breakdownByCondition,
1387
- },
1388
- returned: limitedDays.length,
1389
- days: limitedDays,
1390
- };
1672
+ // Apply limit
1673
+ const limitedDays = days.slice(0, limit);
1674
+
1675
+ const summary =
1676
+ `ORB (${normalizedTicker}, ${startTime}-${endTime}): ${startDate} to ${end} | ` +
1677
+ `${totalDays} days, avg range ${formatPercent(avgOrbRangePct)}`;
1678
+
1679
+ const responseData: Record<string, unknown> = {
1680
+ query: {
1681
+ ticker: normalizedTicker,
1682
+ startTime,
1683
+ endTime,
1684
+ sqlStartTime,
1685
+ sqlEndTime,
1686
+ startDate,
1687
+ endDate: end,
1688
+ useHighLow,
1689
+ barResolution:
1690
+ resolvedBarResolution !== null ? String(resolvedBarResolution) : "auto",
1691
+ },
1692
+ stats: {
1693
+ totalDays,
1694
+ avgOrbRangePct: Math.round(avgOrbRangePct * 10000) / 10000,
1695
+ breakdownByCondition,
1696
+ },
1697
+ returned: limitedDays.length,
1698
+ days: limitedDays,
1699
+ };
1391
1700
 
1392
- if (availability.warnings.length > 0) {
1393
- responseData.warnings = availability.warnings;
1394
- }
1701
+ if (availability.warnings.length > 0) {
1702
+ responseData.warnings = availability.warnings;
1703
+ }
1395
1704
 
1396
- return createToolOutput(summary, responseData);
1397
- } catch (error) {
1398
- return {
1399
- content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
1400
- isError: true,
1401
- };
1402
- }
1403
- })
1705
+ return createToolOutput(summary, responseData);
1706
+ } catch (error) {
1707
+ return {
1708
+ content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
1709
+ isError: true,
1710
+ };
1711
+ }
1712
+ },
1713
+ ),
1404
1714
  );
1405
1715
  }