tradeblocks-mcp 2.2.6 → 2.3.0

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.
@@ -9,6 +9,7 @@ import {
9
9
  downgradeToReadOnly,
10
10
  ensureProfilesSchema,
11
11
  getConnection,
12
+ getIntradayWriteTable,
12
13
  getMarketImportMetadata,
13
14
  getProfile,
14
15
  importCsv,
@@ -27,7 +28,7 @@ import {
27
28
  upgradeToReadWrite,
28
29
  upsertMarketImportMetadata,
29
30
  upsertProfile
30
- } from "./chunk-FIKAXL2L.js";
31
+ } from "./chunk-37JFR6NF.js";
31
32
  import "./chunk-MIVAK2ZU.js";
32
33
  import "./chunk-PWM4JHGO.js";
33
34
  import "./chunk-KRHCV4RF.js";
@@ -1827,6 +1828,18 @@ var MassiveSnapshotResponseSchema = z.object({
1827
1828
  results: z.array(MassiveSnapshotContractSchema),
1828
1829
  next_url: z.string().optional()
1829
1830
  });
1831
+ var MassiveContractReferenceSchema = z.object({
1832
+ ticker: z.string(),
1833
+ strike_price: z.number(),
1834
+ expiration_date: z.string(),
1835
+ contract_type: z.string(),
1836
+ exercise_style: z.string().optional().default("american")
1837
+ });
1838
+ var MassiveContractListResponseSchema = z.object({
1839
+ results: z.array(MassiveContractReferenceSchema),
1840
+ next_url: z.string().nullable().optional(),
1841
+ count: z.number().optional()
1842
+ });
1830
1843
  var MASSIVE_BASE_URL = "https://api.massive.com";
1831
1844
  var MASSIVE_MAX_LIMIT = 5e4;
1832
1845
  var MASSIVE_MAX_PAGES = 500;
@@ -1857,6 +1870,26 @@ function nanosToETMinuteKey(nanosTimestamp) {
1857
1870
  const time = massiveTimestampToETTime(ms);
1858
1871
  return `${date} ${time}`;
1859
1872
  }
1873
+ function etOffsetMinutesForDate(dateStr) {
1874
+ const probe = /* @__PURE__ */ new Date(`${dateStr}T12:00:00Z`);
1875
+ const offsetToken = probe.toLocaleString("en-US", {
1876
+ timeZone: "America/New_York",
1877
+ timeZoneName: "shortOffset"
1878
+ }).match(/GMT([+-]\d{1,2})(?::(\d{2}))?/);
1879
+ if (!offsetToken) {
1880
+ throw new Error(`Unable to resolve ET offset for ${dateStr}`);
1881
+ }
1882
+ const hours = Number(offsetToken[1]);
1883
+ const minutes = offsetToken[2] ? Number(offsetToken[2]) : 0;
1884
+ return hours * 60 + Math.sign(hours || 1) * minutes;
1885
+ }
1886
+ function etDateTimeToUtcIso(dateStr, timeStr) {
1887
+ const [year, month, day] = dateStr.split("-").map(Number);
1888
+ const [hour, minute] = timeStr.split(":").map(Number);
1889
+ const offsetMinutes = etOffsetMinutesForDate(dateStr);
1890
+ const utcMs = Date.UTC(year, month - 1, day, hour, minute) - offsetMinutes * 6e4;
1891
+ return new Date(utcMs).toISOString().replace(".000Z", "Z");
1892
+ }
1860
1893
  function getApiKey() {
1861
1894
  const key = process.env.MASSIVE_API_KEY;
1862
1895
  if (!key) {
@@ -1986,6 +2019,27 @@ function mapContract(contract) {
1986
2019
  }
1987
2020
  var MassiveProvider = class {
1988
2021
  name = "massive";
2022
+ capabilities() {
2023
+ const tier = (process.env.MASSIVE_DATA_TIER ?? "").toLowerCase();
2024
+ return {
2025
+ tradeBars: true,
2026
+ quotes: tier === "quotes",
2027
+ greeks: false,
2028
+ // Massive does not provide greeks — we compute via BSM
2029
+ flatFiles: true,
2030
+ // S3 flat files available via rclone
2031
+ bulkByRoot: false,
2032
+ // Massive is per-ticker, not bulk-by-root
2033
+ perTicker: true,
2034
+ minuteBars: true,
2035
+ dailyBars: true,
2036
+ dataAvailability: {
2037
+ option: { from: "2014-01-02" },
2038
+ index: { from: "2023-02-14" },
2039
+ stock: { from: "2014-01-02" }
2040
+ }
2041
+ };
2042
+ }
1989
2043
  async fetchBars(options) {
1990
2044
  const apiKey = getApiKey();
1991
2045
  const {
@@ -2057,22 +2111,6 @@ var MassiveProvider = class {
2057
2111
  url = null;
2058
2112
  }
2059
2113
  }
2060
- const quotesEnabled2 = process.env.MASSIVE_QUOTES_ENABLED === "true" || process.env.MASSIVE_QUOTES_ENABLED === "1";
2061
- if (quotesEnabled2 && assetClass === "option" && timespan !== "day" && allRows.length > 0) {
2062
- const quotesMap = await this.fetchQuotesForBars(apiTicker, headers, from, to);
2063
- if (quotesMap.size > 0) {
2064
- for (const row of allRows) {
2065
- if (row.time != null) {
2066
- const key = `${row.date} ${row.time}`;
2067
- const quote = quotesMap.get(key);
2068
- if (quote != null) {
2069
- row.bid = quote.bid;
2070
- row.ask = quote.ask;
2071
- }
2072
- }
2073
- }
2074
- }
2075
- }
2076
2114
  return allRows;
2077
2115
  }
2078
2116
  /**
@@ -2080,54 +2118,54 @@ var MassiveProvider = class {
2080
2118
  * Returns a Map keyed by "YYYY-MM-DD HH:MM" ET minute key.
2081
2119
  * Any error (network, HTTP error, parse failure) silently returns an empty Map.
2082
2120
  */
2121
+ /**
2122
+ * Fetch the last NBBO quote for each trading minute in the date range.
2123
+ *
2124
+ * Uses per-minute requests (`order=desc&limit=1`) in parallel batches.
2125
+ * This is much faster than paginating through tick-level quotes: ~400
2126
+ * small requests vs millions of raw NBBO ticks. With concurrency=20,
2127
+ * a full day completes in ~6-7 seconds and results are cached so
2128
+ * subsequent fetches are instant.
2129
+ */
2083
2130
  async fetchQuotesForBars(apiTicker, headers, from, to) {
2084
2131
  const result = /* @__PURE__ */ new Map();
2085
- try {
2086
- let url = `${MASSIVE_BASE_URL}/v3/quotes/${encodeURIComponent(apiTicker)}?timestamp.gte=${from}&timestamp.lte=${to}&order=asc&limit=${MASSIVE_MAX_LIMIT}`;
2087
- const seenCursors = /* @__PURE__ */ new Set();
2088
- const QUOTES_MAX_PAGES = 100;
2089
- let pageCount = 0;
2090
- while (url) {
2091
- pageCount++;
2092
- if (pageCount > QUOTES_MAX_PAGES) {
2093
- break;
2094
- }
2095
- let response;
2132
+ const startDate = /* @__PURE__ */ new Date(from + "T00:00:00");
2133
+ const endDate = /* @__PURE__ */ new Date(to + "T00:00:00");
2134
+ const encodedTicker = encodeURIComponent(apiTicker);
2135
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
2136
+ const day = d.getDay();
2137
+ if (day === 0 || day === 6) continue;
2138
+ const dateStr = d.toISOString().slice(0, 10);
2139
+ const fromTs = etDateTimeToUtcIso(dateStr, "09:30");
2140
+ const toTs = etDateTimeToUtcIso(dateStr, "16:00");
2141
+ let url = `${MASSIVE_BASE_URL}/v3/quotes/${encodedTicker}?timestamp.gte=${fromTs}&timestamp.lte=${toTs}&limit=50000&order=asc`;
2142
+ const dayQuotes = [];
2143
+ let pages = 0;
2144
+ while (url && pages < 50) {
2145
+ pages++;
2096
2146
  try {
2097
- response = await fetch(url, {
2098
- headers,
2099
- signal: AbortSignal.timeout(3e4)
2100
- });
2147
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(3e4) });
2148
+ if (!response.ok) break;
2149
+ const json = await response.json();
2150
+ const parsed = MassiveQuotesResponseSchema.safeParse(json);
2151
+ if (!parsed.success) break;
2152
+ dayQuotes.push(...parsed.data.results);
2153
+ url = parsed.data.next_url ?? null;
2101
2154
  } catch {
2102
- return result;
2103
- }
2104
- if (!response.ok) {
2105
- return result;
2106
- }
2107
- const json = await response.json();
2108
- const parsed = MassiveQuotesResponseSchema.safeParse(json);
2109
- if (!parsed.success) {
2110
- return result;
2111
- }
2112
- const data = parsed.data;
2113
- for (const quote of data.results) {
2114
- const key = nanosToETMinuteKey(quote.sip_timestamp);
2115
- result.set(key, { bid: quote.bid_price, ask: quote.ask_price });
2116
- }
2117
- if (data.next_url) {
2118
- const nextUrlObj = new URL(data.next_url);
2119
- const cursor = nextUrlObj.searchParams.get("cursor") ?? data.next_url;
2120
- if (seenCursors.has(cursor)) {
2121
- break;
2122
- }
2123
- seenCursors.add(cursor);
2124
- url = data.next_url;
2125
- } else {
2126
- url = null;
2155
+ break;
2127
2156
  }
2128
2157
  }
2129
- } catch {
2130
- return /* @__PURE__ */ new Map();
2158
+ const byMinute = /* @__PURE__ */ new Map();
2159
+ for (const q of dayQuotes) {
2160
+ const key = nanosToETMinuteKey(q.sip_timestamp);
2161
+ const [keyDate, keyTime] = key.split(" ");
2162
+ if (keyDate !== dateStr || !keyTime) continue;
2163
+ if (keyTime < "09:30" || keyTime > "16:00") continue;
2164
+ byMinute.set(key, { bid: q.bid_price, ask: q.ask_price });
2165
+ }
2166
+ for (const [key, val] of byMinute) {
2167
+ result.set(key, val);
2168
+ }
2131
2169
  }
2132
2170
  return result;
2133
2171
  }
@@ -2137,6 +2175,129 @@ var MassiveProvider = class {
2137
2175
  const headers = { Authorization: `Bearer ${apiKey}` };
2138
2176
  return this.fetchQuotesForBars(apiTicker, headers, from, to);
2139
2177
  }
2178
+ async downloadFlatFile(date, assetClass) {
2179
+ const [year, month] = date.split("-");
2180
+ const assetPathMap = {
2181
+ option: "us_options_opra/minute_aggs_v1",
2182
+ index: "us_indices/minute_aggs_v1",
2183
+ stock: "us_stocks_sip/minute_aggs_v1"
2184
+ };
2185
+ const assetPath = assetPathMap[assetClass] ?? assetPathMap.stock;
2186
+ const s3Path = `s3massive:flatfiles/${assetPath}/${year}/${month}/${date}.csv.gz`;
2187
+ const tmpDir = assetClass === "index" ? "/tmp/massive-flat-index" : "/tmp/massive-flat";
2188
+ const localPath = `${tmpDir}/${date}.csv.gz`;
2189
+ const { existsSync: existsSync2, mkdirSync: mkdirSync2 } = await import("fs");
2190
+ const { execFile: execFile2 } = await import("child_process");
2191
+ const { promisify: promisify2 } = await import("util");
2192
+ const execFileAsync2 = promisify2(execFile2);
2193
+ mkdirSync2(tmpDir, { recursive: true });
2194
+ if (existsSync2(localPath)) return localPath;
2195
+ try {
2196
+ await execFileAsync2("rclone", ["copy", s3Path, `${tmpDir}/`], { timeout: 12e4 });
2197
+ } catch {
2198
+ return null;
2199
+ }
2200
+ return existsSync2(localPath) ? localPath : null;
2201
+ }
2202
+ async downloadBulkData(options) {
2203
+ const { date, dataset, assetClass, tickers, outputPath } = options;
2204
+ const { existsSync: exists, mkdirSync: mkdir } = await import("fs");
2205
+ if (exists(outputPath)) {
2206
+ return { rowCount: 0, skipped: true };
2207
+ }
2208
+ const availability = this.capabilities().dataAvailability?.[assetClass];
2209
+ if (availability && date < availability.from) {
2210
+ return { rowCount: 0, skipped: true };
2211
+ }
2212
+ const { execFile: execFile2 } = await import("child_process");
2213
+ const { promisify: promisify2 } = await import("util");
2214
+ const { dirname } = await import("path");
2215
+ const execFileAsync2 = promisify2(execFile2);
2216
+ const s3PathMap = {
2217
+ option: {
2218
+ minute_bars: "us_options_opra/minute_aggs_v1",
2219
+ daily_bars: "us_options_opra/day_aggs_v1",
2220
+ trades: "us_options_opra/trades_v1"
2221
+ },
2222
+ index: {
2223
+ minute_bars: "us_indices/minute_aggs_v1",
2224
+ daily_bars: "us_indices/day_aggs_v1"
2225
+ }
2226
+ };
2227
+ const s3Subpath = s3PathMap[assetClass]?.[dataset];
2228
+ if (!s3Subpath) {
2229
+ throw new Error(`Unsupported asset class/dataset combination: ${assetClass}/${dataset}`);
2230
+ }
2231
+ const [year, month] = date.split("-");
2232
+ const s3Path = `s3massive:flatfiles/${s3Subpath}/${year}/${month}/${date}.csv.gz`;
2233
+ const tmpDir = `/tmp/massive-bulk-${assetClass}-${dataset}`;
2234
+ const localCsv = `${tmpDir}/${date}.csv.gz`;
2235
+ mkdir(tmpDir, { recursive: true });
2236
+ try {
2237
+ if (!exists(localCsv)) {
2238
+ await execFileAsync2("rclone", ["copy", s3Path, `${tmpDir}/`], { timeout: 3e5 });
2239
+ if (!exists(localCsv)) {
2240
+ throw new Error(`rclone download failed \u2014 file not found: ${localCsv}`);
2241
+ }
2242
+ }
2243
+ const prefix = assetClass === "option" ? "O:" : "I:";
2244
+ const tickerConditions = tickers.map((t) => `ticker LIKE '${prefix}${t}%'`).join(" OR ");
2245
+ const whereClause = `WHERE ${tickerConditions}`;
2246
+ const isOption = assetClass === "option";
2247
+ const csvColumns = isOption ? "columns = {'ticker': 'VARCHAR', 'volume': 'BIGINT', 'open': 'DOUBLE', 'close': 'DOUBLE', 'high': 'DOUBLE', 'low': 'DOUBLE', 'window_start': 'BIGINT', 'transactions': 'BIGINT'}" : "columns = {'ticker': 'VARCHAR', 'open': 'DOUBLE', 'close': 'DOUBLE', 'high': 'DOUBLE', 'low': 'DOUBLE', 'window_start': 'BIGINT'}";
2248
+ const etConversion = `
2249
+ make_timestamp(CAST(window_start / 1000 AS BIGINT)) AS utc_ts,
2250
+ CASE
2251
+ WHEN month(make_timestamp(CAST(window_start / 1000 AS BIGINT))) > 3
2252
+ AND month(make_timestamp(CAST(window_start / 1000 AS BIGINT))) < 11 THEN 4
2253
+ WHEN month(make_timestamp(CAST(window_start / 1000 AS BIGINT))) = 3
2254
+ AND day(make_timestamp(CAST(window_start / 1000 AS BIGINT))) >= 8 THEN 4
2255
+ WHEN month(make_timestamp(CAST(window_start / 1000 AS BIGINT))) = 11
2256
+ AND day(make_timestamp(CAST(window_start / 1000 AS BIGINT))) < 7 THEN 4
2257
+ ELSE 5
2258
+ END AS et_offset
2259
+ `;
2260
+ const sql = `
2261
+ COPY (
2262
+ SELECT
2263
+ replace(replace(ticker, 'O:', ''), 'I:', '') AS ticker,
2264
+ strftime(utc_ts - et_offset * INTERVAL '1' HOUR, '%Y-%m-%d') AS date,
2265
+ strftime(utc_ts - et_offset * INTERVAL '1' HOUR, '%H:%M') AS time,
2266
+ open,
2267
+ high,
2268
+ low,
2269
+ close,
2270
+ NULL::DOUBLE AS bid,
2271
+ NULL::DOUBLE AS ask
2272
+ FROM (
2273
+ SELECT *, ${etConversion}
2274
+ FROM read_csv('${localCsv}', ${csvColumns}, header = true, compression = 'gzip')
2275
+ ${whereClause}
2276
+ ) sub
2277
+ ) TO '${outputPath}' (FORMAT PARQUET, COMPRESSION ZSTD)
2278
+ `;
2279
+ const { DuckDBInstance } = await import("@duckdb/node-api");
2280
+ const db = await DuckDBInstance.create(":memory:");
2281
+ const conn = await db.connect();
2282
+ try {
2283
+ mkdir(dirname(outputPath), { recursive: true });
2284
+ await conn.run(sql);
2285
+ const countResult = await conn.runAndReadAll(
2286
+ `SELECT count(*) AS cnt FROM '${outputPath}'`
2287
+ );
2288
+ const rowCount = Number(countResult.getRows()[0][0]);
2289
+ return { rowCount, skipped: false };
2290
+ } finally {
2291
+ conn.closeSync();
2292
+ }
2293
+ } finally {
2294
+ try {
2295
+ const { unlinkSync: unlinkSync2 } = await import("fs");
2296
+ if (exists(localCsv)) unlinkSync2(localCsv);
2297
+ } catch {
2298
+ }
2299
+ }
2300
+ }
2140
2301
  async fetchOptionSnapshot(options) {
2141
2302
  const apiKey = getApiKey();
2142
2303
  const { underlying } = options;
@@ -2219,571 +2380,1304 @@ var MassiveProvider = class {
2219
2380
  underlying_ticker: underlyingTicker
2220
2381
  };
2221
2382
  }
2222
- };
2223
-
2224
- // src/utils/providers/thetadata.ts
2225
- var ThetaDataProvider = class {
2226
- name = "thetadata";
2227
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
2228
- async fetchBars(options) {
2229
- throw new Error("ThetaData provider not yet implemented \u2014 set MARKET_DATA_PROVIDER=massive");
2230
- }
2231
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
2232
- async fetchOptionSnapshot(options) {
2233
- throw new Error("ThetaData provider not yet implemented \u2014 set MARKET_DATA_PROVIDER=massive");
2383
+ async fetchContractList(options) {
2384
+ const apiKey = getApiKey();
2385
+ const { underlying, as_of, expired = true, expiration_date_gte, expiration_date_lte } = options;
2386
+ const headers = { Authorization: `Bearer ${apiKey}` };
2387
+ const params = new URLSearchParams({
2388
+ underlying_ticker: underlying,
2389
+ limit: "1000"
2390
+ });
2391
+ if (expiration_date_gte || expiration_date_lte) {
2392
+ if (expiration_date_gte) params.set("expiration_date.gte", expiration_date_gte);
2393
+ if (expiration_date_lte) params.set("expiration_date.lte", expiration_date_lte);
2394
+ } else {
2395
+ params.set("as_of", as_of);
2396
+ }
2397
+ if (expired) {
2398
+ params.set("expired", "true");
2399
+ }
2400
+ let url = `${MASSIVE_BASE_URL}/v3/reference/options/contracts?${params.toString()}`;
2401
+ const allContracts = [];
2402
+ const seenCursors = /* @__PURE__ */ new Set();
2403
+ let pageCount = 0;
2404
+ while (url) {
2405
+ pageCount++;
2406
+ if (pageCount > MASSIVE_MAX_PAGES) {
2407
+ throw new Error(
2408
+ `Pagination safety limit reached (${MASSIVE_MAX_PAGES} pages) -- possible API issue`
2409
+ );
2410
+ }
2411
+ const response = await fetchWithRetry(url, headers);
2412
+ if (response.status === 401) {
2413
+ throw new Error("MASSIVE_API_KEY rejected by Massive.com -- check your key");
2414
+ }
2415
+ if (!response.ok) {
2416
+ throw new Error(`Massive.com API error: HTTP ${response.status} ${response.statusText}`);
2417
+ }
2418
+ const json = await response.json();
2419
+ const parsed = MassiveContractListResponseSchema.safeParse(json);
2420
+ if (!parsed.success) {
2421
+ const issues = parsed.error.issues.map((i) => `${String(i.path.join("."))}: ${i.message}`).join("; ");
2422
+ throw new Error(`Massive contract list response validation failed: ${issues}`);
2423
+ }
2424
+ for (const contract of parsed.data.results) {
2425
+ allContracts.push({
2426
+ ticker: fromMassiveTicker(contract.ticker),
2427
+ contract_type: contract.contract_type,
2428
+ strike: contract.strike_price,
2429
+ expiration: contract.expiration_date,
2430
+ exercise_style: contract.exercise_style ?? "american"
2431
+ });
2432
+ }
2433
+ if (parsed.data.next_url) {
2434
+ const nextUrlObj = new URL(parsed.data.next_url);
2435
+ const cursor = nextUrlObj.searchParams.get("cursor") ?? parsed.data.next_url;
2436
+ if (seenCursors.has(cursor)) {
2437
+ throw new Error(`Pagination loop detected -- cursor repeated: ${cursor.slice(0, 50)}...`);
2438
+ }
2439
+ seenCursors.add(cursor);
2440
+ url = parsed.data.next_url;
2441
+ } else {
2442
+ url = null;
2443
+ }
2444
+ }
2445
+ return { contracts: allContracts, underlying };
2234
2446
  }
2235
2447
  };
2236
2448
 
2237
- // src/utils/market-provider.ts
2238
- var _cached = null;
2239
- function getProvider() {
2240
- if (_cached) return _cached;
2241
- const name = (process.env.MARKET_DATA_PROVIDER ?? "massive").toLowerCase();
2242
- switch (name) {
2243
- case "massive":
2244
- _cached = new MassiveProvider();
2245
- break;
2246
- case "thetadata":
2247
- _cached = new ThetaDataProvider();
2248
- break;
2249
- default:
2250
- throw new Error(
2251
- `Unknown MARKET_DATA_PROVIDER: "${name}". Supported: massive, thetadata`
2252
- );
2449
+ // src/utils/trade-replay.ts
2450
+ function markPrice(bar) {
2451
+ if (bar.bid != null && bar.ask != null && (bar.bid > 0 || bar.ask > 0)) {
2452
+ return (bar.bid + bar.ask) / 2;
2253
2453
  }
2254
- return _cached;
2255
- }
2256
- function _resetProvider() {
2257
- _cached = null;
2454
+ return (bar.high + bar.low) / 2;
2258
2455
  }
2259
-
2260
- // src/utils/market-importer.ts
2261
- var REQUIRED_SCHEMA_FIELDS = {
2262
- daily: ["date", "open", "high", "low", "close"],
2263
- context: ["date"],
2264
- intraday: ["date", "time", "open", "high", "low", "close"],
2265
- _context_derived: ["date"]
2266
- // Phase 75: cross-ticker derived fields
2267
- };
2268
- var CONFLICT_TARGETS = {
2269
- daily: "(ticker, date)",
2270
- context: "(date)",
2271
- intraday: "(ticker, date, time)",
2272
- _context_derived: "(date)"
2273
- // Phase 75: PK is date only
2274
- };
2275
- function validateColumnMapping(columnMapping, targetTable) {
2276
- const schemaValues = Object.values(columnMapping);
2277
- const required = REQUIRED_SCHEMA_FIELDS[targetTable] ?? [];
2278
- let missing = required.filter((field) => !schemaValues.includes(field));
2279
- if (targetTable === "intraday" && missing.includes("time") && schemaValues.includes("date")) {
2280
- missing = missing.filter((f) => f !== "time");
2456
+ function findNearestTimestamp(sortedTimestamps, target, toleranceSec = 60) {
2457
+ if (sortedTimestamps.length === 0) return void 0;
2458
+ const targetMin = timestampToMinutes(target);
2459
+ if (targetMin === null) return void 0;
2460
+ let lo = 0, hi = sortedTimestamps.length - 1;
2461
+ let bestIdx = 0;
2462
+ let bestDiff = Infinity;
2463
+ while (lo <= hi) {
2464
+ const mid = lo + hi >>> 1;
2465
+ const midMin = timestampToMinutes(sortedTimestamps[mid]);
2466
+ if (midMin === null) {
2467
+ lo = mid + 1;
2468
+ continue;
2469
+ }
2470
+ const diff = Math.abs(midMin - targetMin);
2471
+ if (diff < bestDiff) {
2472
+ bestDiff = diff;
2473
+ bestIdx = mid;
2474
+ }
2475
+ if (midMin < targetMin) lo = mid + 1;
2476
+ else if (midMin > targetMin) hi = mid - 1;
2477
+ else break;
2281
2478
  }
2282
- return { valid: missing.length === 0, missingFields: missing };
2479
+ return bestDiff <= toleranceSec / 60 ? sortedTimestamps[bestIdx] : void 0;
2283
2480
  }
2284
- function parseCSV(content) {
2285
- const lines = content.replace(/^\uFEFF/, "").trim().split("\n");
2286
- if (lines.length < 2) {
2287
- return { headers: [], rows: [] };
2288
- }
2289
- const headers = lines[0].trim().split(",").map((h) => h.trim());
2290
- const rows = [];
2291
- for (let i = 1; i < lines.length; i++) {
2292
- const line = lines[i].trim();
2293
- if (!line) continue;
2294
- const values = line.split(",");
2295
- const row = {};
2296
- headers.forEach((h, idx) => {
2297
- row[h] = values[idx]?.trim() ?? "";
2298
- });
2299
- rows.push(row);
2300
- }
2301
- return { headers, rows };
2481
+ function timestampToMinutes(ts) {
2482
+ const timePart = ts.split(" ")[1];
2483
+ if (!timePart) return null;
2484
+ const [h, m] = timePart.split(":").map(Number);
2485
+ if (isNaN(h) || isNaN(m)) return null;
2486
+ return h * 60 + m;
2302
2487
  }
2303
- function parseFlexibleDate(value) {
2304
- const numeric = Number(value);
2305
- if (!isNaN(numeric) && numeric > 1e8) {
2306
- return new Date(numeric * 1e3).toLocaleDateString("en-CA", {
2307
- timeZone: "America/New_York",
2308
- year: "numeric",
2309
- month: "2-digit",
2310
- day: "2-digit"
2311
- });
2488
+ var COMPACT_LEG_RE = /^([A-Z]+)\s+(\d+(?:\.\d+)?)\s*(C|P)$/i;
2489
+ var COMPACT_NO_ROOT_RE = /^(\d+(?:\.\d+)?)\s*(C|P)$/i;
2490
+ var VERBOSE_LEG_RE = /^([A-Z]+)\s+\w+\s+(\d+(?:\.\d+)?)\s+(Call|Put)$/i;
2491
+ var OO_LEG_RE = /^(\d+)\s+(\w+)\s+(\d+)\s+(\d+(?:\.\d+)?)\s+(C|P)\s+(STO|BTO|STC|BTC)\s+(\d+(?:\.\d+)?)$/i;
2492
+ function parseLegsString(legsStr) {
2493
+ if (!legsStr || legsStr.trim() === "") {
2494
+ throw new Error('Cannot parse legs "" \u2014 use hypothetical mode with explicit strikes');
2312
2495
  }
2313
- if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
2314
- return value;
2496
+ if (legsStr.includes("|")) {
2497
+ return parseOOLegs(legsStr);
2315
2498
  }
2316
- return null;
2499
+ const parts = legsStr.includes("/") ? legsStr.split("/") : [legsStr];
2500
+ const legs = [];
2501
+ let inheritedRoot = "";
2502
+ for (let i = 0; i < parts.length; i++) {
2503
+ const raw = parts[i].trim();
2504
+ let root;
2505
+ let strike;
2506
+ let type;
2507
+ const compactMatch = raw.match(COMPACT_LEG_RE);
2508
+ if (compactMatch) {
2509
+ root = compactMatch[1].toUpperCase();
2510
+ strike = parseFloat(compactMatch[2]);
2511
+ type = compactMatch[3].toUpperCase();
2512
+ } else {
2513
+ const noRootMatch = raw.match(COMPACT_NO_ROOT_RE);
2514
+ if (noRootMatch && inheritedRoot) {
2515
+ root = inheritedRoot;
2516
+ strike = parseFloat(noRootMatch[1]);
2517
+ type = noRootMatch[2].toUpperCase();
2518
+ } else {
2519
+ const verboseMatch = raw.match(VERBOSE_LEG_RE);
2520
+ if (verboseMatch) {
2521
+ root = verboseMatch[1].toUpperCase();
2522
+ strike = parseFloat(verboseMatch[2]);
2523
+ type = verboseMatch[3].toLowerCase() === "call" ? "C" : "P";
2524
+ } else {
2525
+ throw new Error(
2526
+ `Cannot parse legs "${legsStr}" \u2014 use hypothetical mode with explicit strikes`
2527
+ );
2528
+ }
2529
+ }
2530
+ }
2531
+ if (i === 0) inheritedRoot = root;
2532
+ const quantity = i === 0 ? 1 : i % 2 === 0 ? 1 : -1;
2533
+ legs.push({ root, strike, type, quantity });
2534
+ }
2535
+ return legs;
2317
2536
  }
2318
- function parseFlexibleTime(value) {
2319
- const numeric = Number(value);
2320
- if (!isNaN(numeric) && numeric > 1e8) {
2321
- const d = new Date(numeric * 1e3);
2322
- return d.toLocaleTimeString("en-US", {
2323
- timeZone: "America/New_York",
2324
- hour: "2-digit",
2325
- minute: "2-digit",
2326
- hour12: false
2537
+ function parseOOLegs(legsStr) {
2538
+ const segments = legsStr.split("|").map((s) => s.trim());
2539
+ const legs = [];
2540
+ const seen = /* @__PURE__ */ new Set();
2541
+ for (const seg of segments) {
2542
+ const match = seg.match(OO_LEG_RE);
2543
+ if (!match) {
2544
+ throw new Error(
2545
+ `Cannot parse OO leg segment "${seg}" \u2014 use hypothetical mode with explicit strikes`
2546
+ );
2547
+ }
2548
+ const contracts = parseInt(match[1], 10);
2549
+ const month = match[2];
2550
+ const day = match[3];
2551
+ const strike = parseFloat(match[4]);
2552
+ const type = match[5].toUpperCase();
2553
+ const direction = match[6].toUpperCase();
2554
+ const price = parseFloat(match[7]);
2555
+ const key = `${month}${day}:${strike}${type}`;
2556
+ if (seen.has(key)) continue;
2557
+ seen.add(key);
2558
+ legs.push({
2559
+ root: "",
2560
+ // OO format doesn't include root — caller provides via trade's ticker field
2561
+ strike,
2562
+ type,
2563
+ quantity: direction === "BTO" ? 1 : -1,
2564
+ entryPrice: price,
2565
+ contracts,
2566
+ expiryHint: `${month} ${day}`
2327
2567
  });
2328
2568
  }
2329
- if (/^\d{2}:\d{2}$/.test(value)) {
2330
- return value;
2569
+ return legs;
2570
+ }
2571
+ function buildOccTicker(root, expiry, type, strike) {
2572
+ const [yyyy, mm, dd] = expiry.split("-");
2573
+ const yy = yyyy.slice(2);
2574
+ const strikeInt = Math.round(strike * 1e3);
2575
+ const strikePadded = String(strikeInt).padStart(8, "0");
2576
+ return `${root}${yy}${mm}${dd}${type}${strikePadded}`;
2577
+ }
2578
+ function computeStrategyPnlPath(legs, barsByLeg, greeksConfig) {
2579
+ if (legs.length === 0 || barsByLeg.length === 0) return [];
2580
+ for (const bars of barsByLeg) {
2581
+ if (bars.length === 0) return [];
2331
2582
  }
2332
- if (/^\d{4}$/.test(value)) {
2333
- return `${value.slice(0, 2)}:${value.slice(2)}`;
2583
+ const legMaps = barsByLeg.map((bars) => {
2584
+ const map = /* @__PURE__ */ new Map();
2585
+ for (const bar of bars) {
2586
+ const ts = `${bar.date} ${bar.time ?? ""}`.trim();
2587
+ map.set(ts, bar);
2588
+ }
2589
+ return map;
2590
+ });
2591
+ const allTimestamps = /* @__PURE__ */ new Set();
2592
+ for (const bars of barsByLeg) {
2593
+ for (const bar of bars) {
2594
+ allTimestamps.add(`${bar.date} ${bar.time ?? ""}`.trim());
2595
+ }
2334
2596
  }
2335
- return null;
2336
- }
2337
- function applyColumnMapping(rows, columnMapping, ticker, targetTable) {
2338
- const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
2339
- const result = [];
2340
- for (const row of rows) {
2341
- const mapped = {};
2342
- let hasNullDate = false;
2343
- for (const [sourceCol, schemaCol] of Object.entries(columnMapping)) {
2344
- const rawValue = row[sourceCol] ?? "";
2345
- if (schemaCol === "date") {
2346
- const parsedDate = parseFlexibleDate(rawValue);
2347
- if (parsedDate === null) {
2348
- console.warn(`[market-importer] Skipping row with unparseable date value: "${rawValue}"`);
2349
- hasNullDate = true;
2350
- break;
2351
- }
2352
- mapped[schemaCol] = parsedDate;
2353
- } else if (schemaCol === "time") {
2354
- const parsedTime = parseFlexibleTime(rawValue);
2355
- if (parsedTime === null) {
2356
- console.warn(`[market-importer] Skipping row with unparseable time value: "${rawValue}"`);
2357
- hasNullDate = true;
2358
- break;
2359
- }
2360
- mapped[schemaCol] = parsedTime;
2361
- } else {
2362
- if (rawValue === "" || rawValue === "NaN" || rawValue === "NA") {
2363
- mapped[schemaCol] = null;
2364
- } else {
2365
- const numVal = parseFloat(rawValue);
2366
- mapped[schemaCol] = isNaN(numVal) ? rawValue : numVal;
2367
- }
2597
+ const sortedTimestamps = [...allTimestamps].sort();
2598
+ const path2 = [];
2599
+ const lastBar = new Array(legs.length).fill(void 0);
2600
+ for (const ts of sortedTimestamps) {
2601
+ let complete = true;
2602
+ const legPrices = [];
2603
+ let strategyPnl = 0;
2604
+ for (let i = 0; i < legs.length; i++) {
2605
+ const bar = legMaps[i].get(ts);
2606
+ if (bar) {
2607
+ lastBar[i] = bar;
2368
2608
  }
2609
+ const effective = bar ?? lastBar[i];
2610
+ if (!effective) {
2611
+ complete = false;
2612
+ break;
2613
+ }
2614
+ const hl2 = markPrice(effective);
2615
+ legPrices.push(hl2);
2616
+ strategyPnl += (hl2 - legs[i].entryPrice) * legs[i].quantity * legs[i].multiplier;
2369
2617
  }
2370
- if (hasNullDate) continue;
2371
- if (!("date" in mapped)) continue;
2372
- if (targetTable === "intraday" && !("time" in mapped)) {
2373
- const dateSourceCol = Object.entries(columnMapping).find(([, schema]) => schema === "date")?.[0];
2374
- if (dateSourceCol) {
2375
- const rawDateValue = row[dateSourceCol] ?? "";
2376
- const numericDate = Number(rawDateValue);
2377
- if (!isNaN(numericDate) && numericDate > 1e8) {
2378
- const parsedTime = parseFlexibleTime(rawDateValue);
2379
- if (parsedTime) {
2380
- mapped["time"] = parsedTime;
2618
+ if (complete) {
2619
+ const point = { timestamp: ts, strategyPnl, legPrices };
2620
+ if (greeksConfig) {
2621
+ let underlyingPrice = greeksConfig.underlyingPrices.get(ts);
2622
+ if (underlyingPrice === void 0 && greeksConfig.sortedTimestamps) {
2623
+ const nearest = findNearestTimestamp(greeksConfig.sortedTimestamps, ts, 60);
2624
+ if (nearest) underlyingPrice = greeksConfig.underlyingPrices.get(nearest);
2625
+ }
2626
+ if (underlyingPrice === void 0) {
2627
+ const dateOnly = ts.split(" ")[0];
2628
+ underlyingPrice = greeksConfig.underlyingPrices.get(dateOnly);
2629
+ }
2630
+ if (underlyingPrice !== void 0) {
2631
+ point.underlyingPrice = underlyingPrice;
2632
+ const legGreeksArr = [];
2633
+ let netDelta = 0, netGamma = 0, netTheta = 0, netVega = 0;
2634
+ let allNull = true;
2635
+ for (let j = 0; j < legs.length; j++) {
2636
+ const legCfg = greeksConfig.legs[j];
2637
+ if (!legCfg || !legCfg.expiryDate) {
2638
+ legGreeksArr.push({ delta: null, gamma: null, theta: null, vega: null, iv: null });
2639
+ continue;
2640
+ }
2641
+ const dateStr = ts.split(" ")[0];
2642
+ const timePart = ts.split(" ")[1] ?? "09:30";
2643
+ const [eyy, emm, edd] = legCfg.expiryDate.split("-").map(Number);
2644
+ const [byy, bmm, bdd] = dateStr.split("-").map(Number);
2645
+ const [hh, min] = timePart.split(":").map(Number);
2646
+ const expiryMs = new Date(eyy, emm - 1, edd).getTime() + 16 * 60 * 60 * 1e3;
2647
+ const barMs = new Date(byy, bmm - 1, bdd).getTime() + (hh * 60 + min) * 60 * 1e3;
2648
+ const dte = (expiryMs - barMs) / (1e3 * 60 * 60 * 24);
2649
+ if (dte <= 0) {
2650
+ legGreeksArr.push({ delta: null, gamma: null, theta: null, vega: null, iv: null });
2651
+ continue;
2652
+ }
2653
+ const g = computeLegGreeks(
2654
+ legPrices[j],
2655
+ underlyingPrice,
2656
+ legCfg.strike,
2657
+ dte,
2658
+ legCfg.type,
2659
+ greeksConfig.riskFreeRate,
2660
+ greeksConfig.dividendYield
2661
+ );
2662
+ legGreeksArr.push(g);
2663
+ if (g.delta !== null) {
2664
+ allNull = false;
2665
+ const weight = legs[j].quantity * legs[j].multiplier / 100;
2666
+ netDelta += g.delta * weight;
2667
+ netGamma += g.gamma * weight;
2668
+ netTheta += g.theta * weight;
2669
+ netVega += g.vega * weight;
2670
+ }
2381
2671
  }
2672
+ point.legGreeks = legGreeksArr;
2673
+ point.netDelta = allNull ? null : netDelta;
2674
+ point.netGamma = allNull ? null : netGamma;
2675
+ point.netTheta = allNull ? null : netTheta;
2676
+ point.netVega = allNull ? null : netVega;
2677
+ const ivpDate = ts.split(" ")[0];
2678
+ point.ivp = greeksConfig.ivpByDate?.get(ivpDate) ?? null;
2382
2679
  }
2383
2680
  }
2681
+ path2.push(point);
2384
2682
  }
2385
- if (targetTable === "daily" || targetTable === "intraday") {
2386
- mapped["ticker"] = normalizedTicker;
2387
- }
2388
- result.push(mapped);
2389
2683
  }
2390
- return result;
2684
+ return path2;
2391
2685
  }
2392
- async function insertMappedRows(conn, targetTable, mappedRows) {
2393
- if (mappedRows.length === 0) {
2394
- return { inserted: 0, updated: 0, skipped: 0 };
2686
+ function computeReplayMfeMae(pnlPath) {
2687
+ if (pnlPath.length === 0) {
2688
+ return { mfe: 0, mae: 0, mfeTimestamp: "", maeTimestamp: "" };
2395
2689
  }
2396
- const tableName = `market.${targetTable}`;
2397
- const conflictTarget = CONFLICT_TARGETS[targetTable];
2398
- const columnSet = /* @__PURE__ */ new Set();
2399
- for (const row of mappedRows) {
2400
- for (const key of Object.keys(row)) {
2401
- columnSet.add(key);
2690
+ let mfe = pnlPath[0].strategyPnl;
2691
+ let mae = pnlPath[0].strategyPnl;
2692
+ let mfeTimestamp = pnlPath[0].timestamp;
2693
+ let maeTimestamp = pnlPath[0].timestamp;
2694
+ for (let i = 1; i < pnlPath.length; i++) {
2695
+ const pnl = pnlPath[i].strategyPnl;
2696
+ if (pnl > mfe) {
2697
+ mfe = pnl;
2698
+ mfeTimestamp = pnlPath[i].timestamp;
2402
2699
  }
2403
- }
2404
- const columns = Array.from(columnSet);
2405
- const beforeResult = await conn.runAndReadAll(`SELECT COUNT(*) FROM ${tableName}`);
2406
- const beforeCount = Number(beforeResult.getRows()[0][0]);
2407
- const conflictKeys = new Set(
2408
- CONFLICT_TARGETS[targetTable].replace(/[()]/g, "").split(",").map((s) => s.trim())
2409
- );
2410
- const updateCols = columns.filter((c) => !conflictKeys.has(c));
2411
- const conflictAction = updateCols.length > 0 ? `DO UPDATE SET ${updateCols.map((c) => `${c} = EXCLUDED.${c}`).join(", ")}` : "DO NOTHING";
2412
- const columnList = columns.join(", ");
2413
- const BATCH_SIZE = 500;
2414
- for (let i = 0; i < mappedRows.length; i += BATCH_SIZE) {
2415
- const batch = mappedRows.slice(i, i + BATCH_SIZE);
2416
- const values = [];
2417
- const valuePlaceholders = [];
2418
- for (const row of batch) {
2419
- const rowPlaceholders = [];
2420
- for (const col of columns) {
2421
- values.push(row[col] ?? null);
2422
- rowPlaceholders.push(`$${values.length}`);
2423
- }
2424
- valuePlaceholders.push(`(${rowPlaceholders.join(", ")})`);
2700
+ if (pnl < mae) {
2701
+ mae = pnl;
2702
+ maeTimestamp = pnlPath[i].timestamp;
2425
2703
  }
2426
- const sql = `INSERT INTO ${tableName} (${columnList}) VALUES ${valuePlaceholders.join(", ")} ON CONFLICT ${conflictTarget} ${conflictAction}`;
2427
- await conn.run(sql, values);
2428
2704
  }
2429
- const afterResult = await conn.runAndReadAll(`SELECT COUNT(*) FROM ${tableName}`);
2430
- const afterCount = Number(afterResult.getRows()[0][0]);
2431
- const inserted = afterCount - beforeCount;
2432
- const updated = updateCols.length > 0 ? mappedRows.length - inserted : 0;
2433
- const skipped = updateCols.length > 0 ? 0 : mappedRows.length - inserted;
2434
- return { inserted, updated, skipped };
2705
+ return { mfe, mae, mfeTimestamp, maeTimestamp };
2435
2706
  }
2436
- function computeDateRange(rows) {
2437
- const dates = [];
2438
- for (const row of rows) {
2439
- const d = row["date"];
2440
- if (typeof d === "string" && d) {
2441
- dates.push(d);
2442
- }
2443
- }
2444
- if (dates.length === 0) return null;
2445
- dates.sort();
2446
- return { min: dates[0], max: dates[dates.length - 1] };
2707
+
2708
+ // src/utils/providers/thetadata-terminal.ts
2709
+ import { closeSync, existsSync, mkdirSync, openSync, symlinkSync, unlinkSync } from "fs";
2710
+ import { spawn, execFile } from "child_process";
2711
+ import net from "net";
2712
+ import os from "os";
2713
+ import path from "path";
2714
+ import { promisify } from "util";
2715
+ var execFileAsync = promisify(execFile);
2716
+ var DEFAULT_PORT = 25503;
2717
+ var DEFAULT_HOST = "127.0.0.1";
2718
+ var DEFAULT_TIMEOUT_MS = 3e4;
2719
+ var DEFAULT_JAR_NAME = "ThetaTerminalv3.jar";
2720
+ var DEFAULT_CREDS_NAME = "creds.txt";
2721
+ function defaultThetaTerminalHome() {
2722
+ return path.join(
2723
+ os.homedir(),
2724
+ "Library",
2725
+ "Application Support",
2726
+ "tradeblocks",
2727
+ "lib",
2728
+ "thetadata"
2729
+ );
2447
2730
  }
2448
- async function triggerEnrichment(conn, ticker, targetTable, _dateRange, skipEnrichment) {
2449
- if (skipEnrichment) {
2450
- return {
2451
- status: "skipped",
2452
- message: "skip_enrichment=true; call enrich_market_data to populate computed fields."
2731
+ function legacyThetaTerminalHome() {
2732
+ return path.join(
2733
+ os.homedir(),
2734
+ "Library",
2735
+ "Application Support",
2736
+ "tradeblocks",
2737
+ "thetadata"
2738
+ );
2739
+ }
2740
+ function parseJavaMajorVersion(output) {
2741
+ const legacy = output.match(/version "1\.(\d+)\./);
2742
+ if (legacy) return Number(legacy[1]);
2743
+ const modern = output.match(/version "(\d+)(?:[.\-+]|")/);
2744
+ if (modern) return Number(modern[1]);
2745
+ return null;
2746
+ }
2747
+ function resolveThetaTerminalConfig(env = process.env) {
2748
+ const defaultHome = defaultThetaTerminalHome();
2749
+ const legacyHome = legacyThetaTerminalHome();
2750
+ const homeDir = env.THETADATA_HOME ? path.resolve(env.THETADATA_HOME) : existsSync(defaultHome) || !existsSync(legacyHome) ? defaultHome : legacyHome;
2751
+ const jarPath = env.THETADATA_JAR ? path.resolve(env.THETADATA_JAR) : path.join(homeDir, DEFAULT_JAR_NAME);
2752
+ const credsPath = env.THETADATA_CREDS_FILE ? path.resolve(env.THETADATA_CREDS_FILE) : path.join(homeDir, DEFAULT_CREDS_NAME);
2753
+ return {
2754
+ homeDir,
2755
+ jarPath,
2756
+ credsPath,
2757
+ managedCredsPath: path.join(homeDir, DEFAULT_CREDS_NAME),
2758
+ logPath: path.join(homeDir, "ThetaTerminal.log"),
2759
+ host: env.THETADATA_HOST || DEFAULT_HOST,
2760
+ port: Number.parseInt(env.THETADATA_PORT || String(DEFAULT_PORT), 10) || DEFAULT_PORT,
2761
+ javaCmd: env.JAVA_HOME ? path.join(env.JAVA_HOME, "bin", "java") : "java",
2762
+ startTimeoutMs: Number.parseInt(env.THETADATA_START_TIMEOUT_MS || String(DEFAULT_TIMEOUT_MS), 10) || DEFAULT_TIMEOUT_MS
2763
+ };
2764
+ }
2765
+ async function detectJavaMajorVersion(javaCmd) {
2766
+ try {
2767
+ const { stdout, stderr } = await execFileAsync(javaCmd, ["-version"]);
2768
+ return parseJavaMajorVersion(`${stdout}
2769
+ ${stderr}`);
2770
+ } catch (error) {
2771
+ const err = error;
2772
+ return parseJavaMajorVersion(`${err.stdout || ""}
2773
+ ${err.stderr || ""}`);
2774
+ }
2775
+ }
2776
+ async function isThetaTerminalRunning(host, port, timeoutMs = 1e3) {
2777
+ return await new Promise((resolve) => {
2778
+ const socket = net.createConnection({ host, port });
2779
+ let settled = false;
2780
+ const finish = (value) => {
2781
+ if (settled) return;
2782
+ settled = true;
2783
+ socket.destroy();
2784
+ resolve(value);
2453
2785
  };
2786
+ socket.setTimeout(timeoutMs);
2787
+ socket.once("connect", () => finish(true));
2788
+ socket.once("timeout", () => finish(false));
2789
+ socket.once("error", () => finish(false));
2790
+ });
2791
+ }
2792
+ function ensureManagedCredsFile(config) {
2793
+ mkdirSync(config.homeDir, { recursive: true });
2794
+ if (!existsSync(config.credsPath)) {
2795
+ throw new Error(
2796
+ `ThetaData creds file not found at ${config.credsPath}. Place ${DEFAULT_CREDS_NAME} in ${config.homeDir} or set THETADATA_CREDS_FILE.`
2797
+ );
2454
2798
  }
2455
- if (targetTable !== "daily") {
2456
- return {
2457
- status: "skipped",
2458
- message: `Enrichment only runs for daily table imports; skipping for ${targetTable}.`
2459
- };
2799
+ if (path.resolve(config.credsPath) === path.resolve(config.managedCredsPath)) {
2800
+ return;
2460
2801
  }
2461
2802
  try {
2462
- const result = await runEnrichment(conn, ticker, {});
2463
- const summaryParts = [
2464
- `Tier 1: ${result.tier1.status}${result.tier1.fieldsWritten !== void 0 ? ` (${result.tier1.fieldsWritten} fields)` : ""}${result.tier1.reason ? ` \u2014 ${result.tier1.reason}` : ""}`,
2465
- `Tier 2: ${result.tier2.status}${result.tier2.fieldsWritten !== void 0 ? ` (${result.tier2.fieldsWritten} fields)` : ""}${result.tier2.reason ? ` \u2014 ${result.tier2.reason}` : ""}`,
2466
- `Tier 3: ${result.tier3.status}${result.tier3.reason ? ` \u2014 ${result.tier3.reason}` : ""}`
2467
- ];
2468
- return {
2469
- status: "complete",
2470
- message: `Enriched ${result.rowsEnriched} rows for ${ticker} through ${result.enrichedThrough ?? "N/A"}. ${summaryParts.join("; ")}`
2471
- };
2472
- } catch (err) {
2473
- return {
2474
- status: "error",
2475
- message: `Enrichment failed for ${ticker}: ${err instanceof Error ? err.message : String(err)}`
2476
- };
2803
+ if (existsSync(config.managedCredsPath)) unlinkSync(config.managedCredsPath);
2804
+ } catch {
2477
2805
  }
2806
+ symlinkSync(config.credsPath, config.managedCredsPath);
2478
2807
  }
2479
- async function importMarketCsvFile(conn, options) {
2480
- const { filePath, ticker, targetTable, columnMapping, dryRun = false, skipEnrichment = false } = options;
2481
- const validation = validateColumnMapping(columnMapping, targetTable);
2482
- if (!validation.valid) {
2808
+ async function waitForThetaTerminal(config) {
2809
+ const deadline = Date.now() + config.startTimeoutMs;
2810
+ while (Date.now() < deadline) {
2811
+ if (await isThetaTerminalRunning(config.host, config.port, 1e3)) return;
2812
+ await new Promise((resolve) => setTimeout(resolve, 500));
2813
+ }
2814
+ throw new Error(
2815
+ `ThetaTerminal did not start listening on ${config.host}:${config.port} within ${config.startTimeoutMs}ms. Check ${config.logPath}.`
2816
+ );
2817
+ }
2818
+ async function ensureThetaTerminalRunning(env = process.env) {
2819
+ const config = resolveThetaTerminalConfig(env);
2820
+ if (!existsSync(config.jarPath)) {
2483
2821
  throw new Error(
2484
- `Column mapping missing required fields for market.${targetTable}: ${validation.missingFields.join(", ")}`
2822
+ `ThetaTerminal JAR not found at ${config.jarPath}. Expected ${DEFAULT_JAR_NAME} in ${config.homeDir} or set THETADATA_JAR.`
2485
2823
  );
2486
2824
  }
2487
- let content;
2488
- try {
2489
- content = await fs.readFile(filePath, "utf-8");
2490
- } catch (error) {
2491
- const msg = error instanceof Error ? error.message : String(error);
2492
- throw new Error(`Failed to read CSV file at "${filePath}": ${msg}`);
2825
+ const javaMajor = await detectJavaMajorVersion(config.javaCmd);
2826
+ if (javaMajor == null || javaMajor < 21) {
2827
+ throw new Error(
2828
+ `ThetaTerminal requires Java 21+. Found ${javaMajor ?? "unknown"} via ${config.javaCmd}.`
2829
+ );
2493
2830
  }
2494
- const { rows } = parseCSV(content);
2495
- if (rows.length === 0) {
2496
- throw new Error(`CSV file "${filePath}" has no data rows`);
2831
+ if (await isThetaTerminalRunning(config.host, config.port, 1e3)) {
2832
+ return "already_running";
2497
2833
  }
2498
- const mappedRows = applyColumnMapping(rows, columnMapping, ticker, targetTable);
2499
- if (mappedRows.length === 0) {
2500
- throw new Error(
2501
- `After applying column mapping, 0 valid rows remain from CSV file "${filePath}"`
2834
+ ensureManagedCredsFile(config);
2835
+ mkdirSync(config.homeDir, { recursive: true });
2836
+ const stdoutFd = openSync(config.logPath, "a");
2837
+ const stderrFd = openSync(config.logPath, "a");
2838
+ try {
2839
+ const child = spawn(
2840
+ config.javaCmd,
2841
+ ["-jar", config.jarPath],
2842
+ {
2843
+ cwd: config.homeDir,
2844
+ detached: true,
2845
+ stdio: ["ignore", stdoutFd, stderrFd]
2846
+ }
2502
2847
  );
2848
+ child.unref();
2849
+ } finally {
2850
+ closeSync(stdoutFd);
2851
+ closeSync(stderrFd);
2503
2852
  }
2504
- const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
2505
- const dateRange = computeDateRange(mappedRows);
2506
- if (dryRun) {
2507
- return {
2508
- rowsInserted: 0,
2509
- rowsUpdated: 0,
2510
- rowsSkipped: 0,
2511
- inputRowCount: mappedRows.length,
2512
- dateRange,
2513
- enrichment: {
2514
- status: "skipped",
2515
- message: `dry_run=true; no data written. Would import ${mappedRows.length} rows.`
2853
+ await waitForThetaTerminal(config);
2854
+ return "started";
2855
+ }
2856
+
2857
+ // src/utils/providers/thetadata.ts
2858
+ var THETADATA_BASE_URL = "http://127.0.0.1:25503";
2859
+ var THETADATA_TIMEOUT_MS = 3e4;
2860
+ var THETADATA_RETRY_BASE_MS = 250;
2861
+ var THETADATA_MAX_ATTEMPTS = 4;
2862
+ var RETRYABLE_THETA_STATUS_CODES = /* @__PURE__ */ new Set([429, 474, 571]);
2863
+ var THETA_ERROR_NAMES = {
2864
+ 404: "NO_IMPL",
2865
+ 429: "OS_LIMIT",
2866
+ 470: "GENERAL",
2867
+ 471: "PERMISSION",
2868
+ 472: "NO_DATA",
2869
+ 473: "INVALID_PARAMS",
2870
+ 474: "DISCONNECTED",
2871
+ 475: "TERMINAL_PARSE",
2872
+ 476: "WRONG_IP",
2873
+ 477: "NO_PAGE_FOUND",
2874
+ 478: "INVALID_SESSION_ID",
2875
+ 571: "SERVER_STARTING",
2876
+ 572: "UNCAUGHT_ERROR"
2877
+ };
2878
+ function getBaseUrl(env = process.env) {
2879
+ return env.THETADATA_BASE_URL || THETADATA_BASE_URL;
2880
+ }
2881
+ function getTimeoutMs(env = process.env) {
2882
+ return Number.parseInt(
2883
+ env.THETADATA_REQUEST_TIMEOUT_MS || String(THETADATA_TIMEOUT_MS),
2884
+ 10
2885
+ ) || THETADATA_TIMEOUT_MS;
2886
+ }
2887
+ function getRetryBaseMs(env = process.env) {
2888
+ return Number.parseInt(
2889
+ env.THETADATA_RETRY_BASE_MS || String(THETADATA_RETRY_BASE_MS),
2890
+ 10
2891
+ ) || THETADATA_RETRY_BASE_MS;
2892
+ }
2893
+ function getMaxAttempts(env = process.env) {
2894
+ return Number.parseInt(
2895
+ env.THETADATA_MAX_ATTEMPTS || String(THETADATA_MAX_ATTEMPTS),
2896
+ 10
2897
+ ) || THETADATA_MAX_ATTEMPTS;
2898
+ }
2899
+ async function maybeEnsureThetaRunning(env = process.env) {
2900
+ if (env.THETADATA_SKIP_AUTO_START === "1") return;
2901
+ await ensureThetaTerminalRunning(env);
2902
+ }
2903
+ function compactDate(date) {
2904
+ return date.replaceAll("-", "");
2905
+ }
2906
+ function formatDate(date) {
2907
+ const year = date.getUTCFullYear();
2908
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
2909
+ const day = String(date.getUTCDate()).padStart(2, "0");
2910
+ return `${year}-${month}-${day}`;
2911
+ }
2912
+ function parseIsoDate(date) {
2913
+ return /* @__PURE__ */ new Date(`${date}T12:00:00Z`);
2914
+ }
2915
+ function splitDateRangeByMonth(from, to) {
2916
+ const start = parseIsoDate(from);
2917
+ const end = parseIsoDate(to);
2918
+ const chunks = [];
2919
+ let cursor = new Date(start);
2920
+ while (cursor <= end) {
2921
+ const chunkStart = new Date(cursor);
2922
+ const chunkEnd = new Date(Date.UTC(
2923
+ cursor.getUTCFullYear(),
2924
+ cursor.getUTCMonth() + 1,
2925
+ 0,
2926
+ 12,
2927
+ 0,
2928
+ 0
2929
+ ));
2930
+ if (chunkEnd > end) chunkEnd.setTime(end.getTime());
2931
+ chunks.push({ from: formatDate(chunkStart), to: formatDate(chunkEnd) });
2932
+ cursor = new Date(Date.UTC(
2933
+ chunkEnd.getUTCFullYear(),
2934
+ chunkEnd.getUTCMonth(),
2935
+ chunkEnd.getUTCDate() + 1,
2936
+ 12,
2937
+ 0,
2938
+ 0
2939
+ ));
2940
+ }
2941
+ return chunks;
2942
+ }
2943
+ function toThetaInterval(timespan, multiplier = 1) {
2944
+ if (timespan === "day") return null;
2945
+ if (timespan === "hour") {
2946
+ if (multiplier !== 1) {
2947
+ throw new Error(`ThetaData does not support ${multiplier}h bars`);
2948
+ }
2949
+ return "1h";
2950
+ }
2951
+ const minuteIntervals = /* @__PURE__ */ new Set([1, 5, 10, 15, 30]);
2952
+ if (!minuteIntervals.has(multiplier)) {
2953
+ throw new Error(`ThetaData does not support ${multiplier}m bars`);
2954
+ }
2955
+ return `${multiplier}m`;
2956
+ }
2957
+ function formatStrike(strike) {
2958
+ return strike.toFixed(3);
2959
+ }
2960
+ function toThetaRight(right) {
2961
+ return right;
2962
+ }
2963
+ function normalizeThetaRight(value) {
2964
+ const raw = String(value ?? "").trim().toLowerCase();
2965
+ if (raw === "c" || raw === "call") return "call";
2966
+ if (raw === "p" || raw === "put") return "put";
2967
+ throw new Error(`Unsupported ThetaData option right: ${String(value)}`);
2968
+ }
2969
+ function parseOccTicker(ticker) {
2970
+ const match = ticker.match(/^([A-Z]+)(\d{2})(\d{2})(\d{2})(C|P)(\d{8})$/);
2971
+ if (!match) {
2972
+ throw new Error(`Invalid OCC option ticker: ${ticker}`);
2973
+ }
2974
+ const root = match[1];
2975
+ const expiration = `20${match[2]}-${match[3]}-${match[4]}`;
2976
+ const right = match[5] === "C" ? "call" : "put";
2977
+ const strike = Number.parseInt(match[6], 10) / 1e3;
2978
+ return { root, expiration, right, strike };
2979
+ }
2980
+ function thetaTimestampToEtDate(timestamp) {
2981
+ return timestamp.slice(0, 10);
2982
+ }
2983
+ function thetaTimestampToEtTime(timestamp) {
2984
+ return timestamp.slice(11, 16);
2985
+ }
2986
+ function toNumber(value) {
2987
+ if (value == null) return null;
2988
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
2989
+ if (typeof value === "string") {
2990
+ const trimmed = value.trim();
2991
+ if (!trimmed) return null;
2992
+ const parsed = Number(trimmed);
2993
+ return Number.isFinite(parsed) ? parsed : null;
2994
+ }
2995
+ return null;
2996
+ }
2997
+ function toInteger(value) {
2998
+ const num = toNumber(value);
2999
+ return num == null ? null : Math.trunc(num);
3000
+ }
3001
+ function thetaKey(parts) {
3002
+ return [
3003
+ parts.symbol,
3004
+ parts.expiration,
3005
+ parts.strike.toFixed(3),
3006
+ parts.right
3007
+ ].join("|");
3008
+ }
3009
+ function inferExerciseStyle(symbol) {
3010
+ const europeanRoots = /* @__PURE__ */ new Set([
3011
+ "SPX",
3012
+ "SPXW",
3013
+ "XSP",
3014
+ "NDX",
3015
+ "NDXP",
3016
+ "RUT",
3017
+ "RUTW",
3018
+ "VIX",
3019
+ "VIX9D",
3020
+ "DJX"
3021
+ ]);
3022
+ return europeanRoots.has(symbol.toUpperCase()) ? "european" : "american";
3023
+ }
3024
+ function breakEvenFor(contractType, strike, midpoint) {
3025
+ const value = contractType === "call" ? strike + midpoint : strike - midpoint;
3026
+ return Number(value.toFixed(6));
3027
+ }
3028
+ function filterSnapshotContract(contract, options) {
3029
+ if (options.contract_type && contract.right !== options.contract_type) return false;
3030
+ if (options.expiration_date_gte && contract.expiration < options.expiration_date_gte) return false;
3031
+ if (options.expiration_date_lte && contract.expiration > options.expiration_date_lte) return false;
3032
+ if (options.strike_price_gte != null && contract.strike < options.strike_price_gte) return false;
3033
+ if (options.strike_price_lte != null && contract.strike > options.strike_price_lte) return false;
3034
+ return true;
3035
+ }
3036
+ function sleep(ms) {
3037
+ return new Promise((resolve) => setTimeout(resolve, ms));
3038
+ }
3039
+ function thetaErrorLabel(status) {
3040
+ return THETA_ERROR_NAMES[status] ?? `HTTP_${status}`;
3041
+ }
3042
+ function thetaErrorMessage(status, body) {
3043
+ const prefix = `ThetaData ${status} ${thetaErrorLabel(status)}`;
3044
+ const trimmed = body.trim();
3045
+ if (!trimmed) return prefix;
3046
+ return `${prefix}: ${trimmed}`;
3047
+ }
3048
+ async function requestThetaArray(endpointPath, params, env = process.env) {
3049
+ await maybeEnsureThetaRunning(env);
3050
+ const url = new URL(endpointPath, getBaseUrl(env));
3051
+ for (const [key, value] of Object.entries(params)) {
3052
+ if (value == null) continue;
3053
+ url.searchParams.set(key, String(value));
3054
+ }
3055
+ if (!url.searchParams.has("format")) {
3056
+ url.searchParams.set("format", "json");
3057
+ }
3058
+ const maxAttempts = getMaxAttempts(env);
3059
+ const retryBaseMs = getRetryBaseMs(env);
3060
+ let lastError = null;
3061
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3062
+ try {
3063
+ const response = await fetch(url, {
3064
+ signal: AbortSignal.timeout(getTimeoutMs(env))
3065
+ });
3066
+ const text = await response.text();
3067
+ if (response.status === 472 && text.trim().startsWith("No data found for your request")) {
3068
+ return [];
2516
3069
  }
2517
- };
3070
+ if (!response.ok) {
3071
+ if (RETRYABLE_THETA_STATUS_CODES.has(response.status) && attempt < maxAttempts) {
3072
+ await sleep(retryBaseMs * attempt);
3073
+ continue;
3074
+ }
3075
+ throw new Error(thetaErrorMessage(response.status, text));
3076
+ }
3077
+ const trimmed = text.trim();
3078
+ if (!trimmed) return [];
3079
+ if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) {
3080
+ throw new Error(trimmed);
3081
+ }
3082
+ let parsed;
3083
+ try {
3084
+ parsed = JSON.parse(trimmed);
3085
+ } catch (error) {
3086
+ throw new Error(
3087
+ `ThetaData returned invalid JSON from ${endpointPath}: ${error instanceof Error ? error.message : String(error)}`
3088
+ );
3089
+ }
3090
+ if (Array.isArray(parsed)) return parsed;
3091
+ if (parsed && typeof parsed === "object") {
3092
+ const object = parsed;
3093
+ if (Array.isArray(object.data)) return object.data;
3094
+ if (Array.isArray(object.response)) return object.response;
3095
+ }
3096
+ throw new Error(`ThetaData ${endpointPath} returned unexpected JSON shape`);
3097
+ } catch (error) {
3098
+ const err = error instanceof Error ? error : new Error(String(error));
3099
+ lastError = err;
3100
+ const isTransportError = err.message === "fetch failed";
3101
+ if ((isTransportError || err.name === "AbortError") && attempt < maxAttempts) {
3102
+ await sleep(retryBaseMs * attempt);
3103
+ continue;
3104
+ }
3105
+ throw err;
3106
+ }
2518
3107
  }
2519
- const { inserted, updated, skipped } = await insertMappedRows(conn, targetTable, mappedRows);
2520
- await upsertMarketImportMetadata(conn, {
2521
- source: `import_market_csv:${filePath}`,
2522
- ticker: normalizedTicker,
2523
- target_table: targetTable,
2524
- max_date: dateRange?.max ?? null,
2525
- enriched_through: null,
2526
- synced_at: /* @__PURE__ */ new Date()
2527
- });
2528
- const enrichment = await triggerEnrichment(conn, normalizedTicker, targetTable, dateRange, skipEnrichment);
3108
+ throw lastError ?? new Error(`ThetaData request failed for ${endpointPath}`);
3109
+ }
3110
+ function flattenThetaRows(rows) {
3111
+ const flat = [];
3112
+ for (const row of rows) {
3113
+ if (Array.isArray(row.data) && row.contract && typeof row.contract === "object") {
3114
+ for (const datum of row.data) {
3115
+ if (!datum || typeof datum !== "object") continue;
3116
+ flat.push({
3117
+ ...row.contract,
3118
+ ...datum
3119
+ });
3120
+ }
3121
+ continue;
3122
+ }
3123
+ flat.push(row);
3124
+ }
3125
+ return flat;
3126
+ }
3127
+ function mapIntradayRow(row, ticker) {
3128
+ const timestamp = String(row.timestamp ?? "");
2529
3129
  return {
2530
- rowsInserted: inserted,
2531
- rowsUpdated: updated,
2532
- rowsSkipped: skipped,
2533
- inputRowCount: mappedRows.length,
2534
- dateRange,
2535
- enrichment
3130
+ date: thetaTimestampToEtDate(timestamp),
3131
+ time: thetaTimestampToEtTime(timestamp),
3132
+ open: toNumber(row.open) ?? 0,
3133
+ high: toNumber(row.high) ?? 0,
3134
+ low: toNumber(row.low) ?? 0,
3135
+ close: toNumber(row.close) ?? 0,
3136
+ volume: toInteger(row.volume) ?? 0,
3137
+ ticker
2536
3138
  };
2537
3139
  }
2538
- async function importFromDatabase(conn, options) {
2539
- const { dbPath, query, ticker, targetTable, columnMapping, dryRun = false, skipEnrichment = false } = options;
2540
- const validation = validateColumnMapping(columnMapping, targetTable);
2541
- if (!validation.valid) {
2542
- throw new Error(
2543
- `Column mapping missing required fields for market.${targetTable}: ${validation.missingFields.join(", ")}`
2544
- );
3140
+ function mapEodRow(row, ticker) {
3141
+ const dateToken = String(row.date ?? row.timestamp ?? row.created ?? row.last_trade ?? "");
3142
+ return {
3143
+ date: dateToken.slice(0, 10),
3144
+ open: toNumber(row.open) ?? 0,
3145
+ high: toNumber(row.high) ?? 0,
3146
+ low: toNumber(row.low) ?? 0,
3147
+ close: toNumber(row.close) ?? 0,
3148
+ volume: toInteger(row.volume) ?? 0,
3149
+ ticker,
3150
+ bid: toNumber(row.bid) ?? void 0,
3151
+ ask: toNumber(row.ask) ?? void 0
3152
+ };
3153
+ }
3154
+ function normalizedMidpoint(bid, ask) {
3155
+ if (bid > 0 && ask > 0) return Number(((bid + ask) / 2).toFixed(6));
3156
+ return Math.max(bid, ask, 0);
3157
+ }
3158
+ async function fetchOptionHistoryRows(ticker, from, to, endpoint, interval) {
3159
+ const occ = parseOccTicker(ticker);
3160
+ const chunks = splitDateRangeByMonth(from, to);
3161
+ const rows = [];
3162
+ for (const chunk of chunks) {
3163
+ const params = {
3164
+ symbol: occ.root,
3165
+ expiration: compactDate(occ.expiration),
3166
+ strike: formatStrike(occ.strike),
3167
+ right: toThetaRight(occ.right),
3168
+ format: "json",
3169
+ ...interval ? { interval } : {},
3170
+ ...chunk.from === chunk.to ? { date: compactDate(chunk.from) } : { start_date: compactDate(chunk.from), end_date: compactDate(chunk.to) }
3171
+ };
3172
+ rows.push(...flattenThetaRows(
3173
+ await requestThetaArray(`/v3/option/history/${endpoint}`, params)
3174
+ ));
2545
3175
  }
2546
- const EXT_ALIAS = "ext_import_source";
2547
- await conn.run(`ATTACH '${dbPath}' AS ${EXT_ALIAS} (READ_ONLY)`);
2548
- try {
2549
- const rawResult = await conn.runAndReadAll(query);
2550
- const resultColumnNames = rawResult.columnNames();
2551
- const resultRows = rawResult.getRows();
2552
- const rows = resultRows.map((row) => {
2553
- const obj = {};
2554
- resultColumnNames.forEach((colName, idx) => {
2555
- const val = row[idx];
2556
- obj[colName] = val === null || val === void 0 ? "" : String(val);
3176
+ return rows;
3177
+ }
3178
+ async function fetchSymbolHistoryRows(ticker, from, to, assetClass, endpoint, interval) {
3179
+ const chunks = splitDateRangeByMonth(from, to);
3180
+ const rows = [];
3181
+ for (const chunk of chunks) {
3182
+ const params = {
3183
+ symbol: ticker,
3184
+ format: "json",
3185
+ ...interval ? { interval } : {},
3186
+ ...chunk.from === chunk.to ? { date: compactDate(chunk.from) } : { start_date: compactDate(chunk.from), end_date: compactDate(chunk.to) }
3187
+ };
3188
+ rows.push(...flattenThetaRows(
3189
+ await requestThetaArray(`/v3/${assetClass}/history/${endpoint}`, params)
3190
+ ));
3191
+ }
3192
+ return rows;
3193
+ }
3194
+ var ThetaDataProvider = class {
3195
+ name = "thetadata";
3196
+ capabilities() {
3197
+ return {
3198
+ tradeBars: true,
3199
+ quotes: true,
3200
+ greeks: true,
3201
+ flatFiles: false,
3202
+ bulkByRoot: true,
3203
+ perTicker: false,
3204
+ minuteBars: true,
3205
+ dailyBars: true
3206
+ };
3207
+ }
3208
+ async fetchBars(options) {
3209
+ const {
3210
+ ticker,
3211
+ from,
3212
+ to,
3213
+ timespan = "day",
3214
+ multiplier = 1,
3215
+ assetClass = "stock"
3216
+ } = options;
3217
+ if (timespan === "day") {
3218
+ const rows2 = assetClass === "option" ? await fetchOptionHistoryRows(ticker, from, to, "eod", null) : await fetchSymbolHistoryRows(ticker, from, to, assetClass, "eod", null);
3219
+ return rows2.map((row) => mapEodRow(row, ticker)).sort((a, b) => a.date.localeCompare(b.date));
3220
+ }
3221
+ const interval = toThetaInterval(timespan, multiplier);
3222
+ const rows = assetClass === "option" ? await fetchOptionHistoryRows(ticker, from, to, "ohlc", interval) : await fetchSymbolHistoryRows(ticker, from, to, assetClass, "ohlc", interval);
3223
+ return rows.map((row) => mapIntradayRow(row, ticker)).sort((a, b) => {
3224
+ const left = `${a.date} ${a.time ?? ""}`;
3225
+ const right = `${b.date} ${b.time ?? ""}`;
3226
+ return left.localeCompare(right);
3227
+ });
3228
+ }
3229
+ async fetchQuotes(ticker, from, to) {
3230
+ const rows = await fetchOptionHistoryRows(ticker, from, to, "quote", "1m");
3231
+ const quotes = /* @__PURE__ */ new Map();
3232
+ for (const row of rows) {
3233
+ const timestamp = String(row.timestamp ?? "");
3234
+ if (!timestamp) continue;
3235
+ const bid = toNumber(row.bid);
3236
+ const ask = toNumber(row.ask);
3237
+ if (bid == null || ask == null) continue;
3238
+ quotes.set(`${thetaTimestampToEtDate(timestamp)} ${thetaTimestampToEtTime(timestamp)}`, {
3239
+ bid,
3240
+ ask
2557
3241
  });
2558
- return obj;
3242
+ }
3243
+ return quotes;
3244
+ }
3245
+ async fetchContractList(options) {
3246
+ const { underlying, as_of, expiration_date_gte, expiration_date_lte } = options;
3247
+ const maxDte = expiration_date_lte ? Math.max(
3248
+ 0,
3249
+ Math.round(
3250
+ (parseIsoDate(expiration_date_lte).getTime() - parseIsoDate(as_of).getTime()) / 864e5
3251
+ )
3252
+ ) : void 0;
3253
+ const rows = await requestThetaArray("/v3/option/list/contracts/quote", {
3254
+ symbol: underlying,
3255
+ date: compactDate(as_of),
3256
+ format: "json",
3257
+ ...maxDte != null ? { max_dte: maxDte } : {}
2559
3258
  });
2560
- const mappedRows = applyColumnMapping(rows, columnMapping, ticker, targetTable);
2561
- const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
2562
- const dateRange = computeDateRange(mappedRows);
2563
- if (dryRun) {
3259
+ const contracts = [];
3260
+ for (const row of rows) {
3261
+ const symbol = String(row.symbol ?? underlying).toUpperCase();
3262
+ const expiration = String(row.expiration ?? "");
3263
+ const strike = toNumber(row.strike);
3264
+ if (!expiration || strike == null) continue;
3265
+ const contractType = normalizeThetaRight(row.right);
3266
+ if (expiration_date_gte && expiration < expiration_date_gte) continue;
3267
+ if (expiration_date_lte && expiration > expiration_date_lte) continue;
3268
+ contracts.push({
3269
+ ticker: buildOccTicker(
3270
+ symbol,
3271
+ expiration,
3272
+ contractType === "call" ? "C" : "P",
3273
+ strike
3274
+ ),
3275
+ contract_type: contractType,
3276
+ strike,
3277
+ expiration,
3278
+ exercise_style: inferExerciseStyle(symbol)
3279
+ });
3280
+ }
3281
+ return { contracts, underlying };
3282
+ }
3283
+ async fetchOptionSnapshot(options) {
3284
+ const firstOrderRows = flattenThetaRows(await requestThetaArray("/v3/option/snapshot/greeks/first_order", {
3285
+ symbol: options.underlying,
3286
+ expiration: "*",
3287
+ format: "json"
3288
+ }));
3289
+ if (firstOrderRows.length === 0) {
2564
3290
  return {
2565
- rowsInserted: 0,
2566
- rowsUpdated: 0,
2567
- rowsSkipped: 0,
2568
- inputRowCount: mappedRows.length,
2569
- dateRange,
2570
- enrichment: {
2571
- status: "skipped",
2572
- message: `dry_run=true; no data written. Would import ${mappedRows.length} rows.`
2573
- }
3291
+ contracts: [],
3292
+ underlying_price: 0,
3293
+ underlying_ticker: options.underlying
2574
3294
  };
2575
3295
  }
2576
- const { inserted, updated, skipped } = await insertMappedRows(conn, targetTable, mappedRows);
2577
- await upsertMarketImportMetadata(conn, {
2578
- source: `import_from_database:${dbPath}`,
2579
- ticker: normalizedTicker,
2580
- target_table: targetTable,
2581
- max_date: dateRange?.max ?? null,
2582
- enriched_through: null,
2583
- synced_at: /* @__PURE__ */ new Date()
3296
+ let underlyingPrice = toNumber(firstOrderRows[0].underlying_price) ?? 0;
3297
+ const filtered = firstOrderRows.filter((row) => {
3298
+ const strike = toNumber(row.strike);
3299
+ const expiration = String(row.expiration ?? "");
3300
+ if (strike == null || !expiration) return false;
3301
+ return filterSnapshotContract({
3302
+ symbol: String(row.symbol ?? options.underlying).toUpperCase(),
3303
+ expiration,
3304
+ strike,
3305
+ right: normalizeThetaRight(row.right)
3306
+ }, options);
3307
+ });
3308
+ const oiPromise = requestThetaArray("/v3/option/snapshot/open_interest", {
3309
+ symbol: options.underlying,
3310
+ expiration: "*",
3311
+ format: "json"
3312
+ }).then(flattenThetaRows).catch(() => []);
3313
+ const expirations = [...new Set(filtered.map((row) => String(row.expiration)))];
3314
+ const tradePromises = expirations.map(async (expiration) => {
3315
+ try {
3316
+ const rows = flattenThetaRows(await requestThetaArray("/v3/option/snapshot/trade", {
3317
+ symbol: options.underlying,
3318
+ expiration,
3319
+ format: "json"
3320
+ }));
3321
+ return { expiration, rows };
3322
+ } catch {
3323
+ return { expiration, rows: [] };
3324
+ }
3325
+ });
3326
+ const [oiRows, tradeGroups] = await Promise.all([
3327
+ oiPromise,
3328
+ Promise.all(tradePromises)
3329
+ ]);
3330
+ const openInterestByKey = /* @__PURE__ */ new Map();
3331
+ for (const row of oiRows) {
3332
+ const symbol = String(row.symbol ?? options.underlying).toUpperCase();
3333
+ const expiration = String(row.expiration ?? "");
3334
+ const strike = toNumber(row.strike);
3335
+ if (!expiration || strike == null) continue;
3336
+ openInterestByKey.set(thetaKey({
3337
+ symbol,
3338
+ expiration,
3339
+ strike,
3340
+ right: normalizeThetaRight(row.right)
3341
+ }), toInteger(row.open_interest) ?? 0);
3342
+ }
3343
+ const tradePriceByKey = /* @__PURE__ */ new Map();
3344
+ for (const group of tradeGroups) {
3345
+ for (const row of group.rows) {
3346
+ const symbol = String(row.symbol ?? options.underlying).toUpperCase();
3347
+ const expiration = String(row.expiration ?? "");
3348
+ const strike = toNumber(row.strike);
3349
+ const price = toNumber(row.price);
3350
+ if (!expiration || strike == null || price == null) continue;
3351
+ tradePriceByKey.set(thetaKey({
3352
+ symbol,
3353
+ expiration,
3354
+ strike,
3355
+ right: normalizeThetaRight(row.right)
3356
+ }), price);
3357
+ }
3358
+ }
3359
+ const contracts = filtered.map((row) => {
3360
+ const symbol = String(row.symbol ?? options.underlying).toUpperCase();
3361
+ const expiration = String(row.expiration);
3362
+ const strike = toNumber(row.strike) ?? 0;
3363
+ const contractType = normalizeThetaRight(row.right);
3364
+ const bid = toNumber(row.bid) ?? 0;
3365
+ const ask = toNumber(row.ask) ?? 0;
3366
+ const midpoint = normalizedMidpoint(bid, ask);
3367
+ const rowUnderlying = toNumber(row.underlying_price) ?? 0;
3368
+ if (underlyingPrice === 0 && rowUnderlying > 0) {
3369
+ underlyingPrice = rowUnderlying;
3370
+ }
3371
+ const key = thetaKey({ symbol, expiration, strike, right: contractType });
3372
+ return {
3373
+ ticker: buildOccTicker(
3374
+ symbol,
3375
+ expiration,
3376
+ contractType === "call" ? "C" : "P",
3377
+ strike
3378
+ ),
3379
+ underlying_ticker: options.underlying,
3380
+ underlying_price: rowUnderlying || underlyingPrice,
3381
+ contract_type: contractType,
3382
+ strike,
3383
+ expiration,
3384
+ exercise_style: inferExerciseStyle(symbol),
3385
+ delta: toNumber(row.delta),
3386
+ gamma: null,
3387
+ theta: toNumber(row.theta),
3388
+ vega: toNumber(row.vega),
3389
+ iv: toNumber(row.implied_vol),
3390
+ greeks_source: "thetadata",
3391
+ bid,
3392
+ ask,
3393
+ midpoint,
3394
+ last_price: tradePriceByKey.get(key) ?? null,
3395
+ open_interest: openInterestByKey.get(key) ?? 0,
3396
+ volume: 0,
3397
+ break_even: breakEvenFor(contractType, strike, midpoint)
3398
+ };
3399
+ });
3400
+ contracts.sort((a, b) => {
3401
+ const left = `${a.expiration}|${a.strike.toFixed(3)}|${a.contract_type}`;
3402
+ const right = `${b.expiration}|${b.strike.toFixed(3)}|${b.contract_type}`;
3403
+ return left.localeCompare(right);
2584
3404
  });
2585
- const enrichment = await triggerEnrichment(conn, normalizedTicker, targetTable, dateRange, skipEnrichment);
2586
3405
  return {
2587
- rowsInserted: inserted,
2588
- rowsUpdated: updated,
2589
- rowsSkipped: skipped,
2590
- inputRowCount: mappedRows.length,
2591
- dateRange,
2592
- enrichment
3406
+ contracts,
3407
+ underlying_price: underlyingPrice,
3408
+ underlying_ticker: options.underlying
2593
3409
  };
2594
- } finally {
2595
- try {
2596
- await conn.run(`DETACH ${EXT_ALIAS}`);
2597
- } catch {
2598
- }
2599
3410
  }
3411
+ };
3412
+
3413
+ // src/utils/market-provider.ts
3414
+ var _cached = null;
3415
+ function getProvider() {
3416
+ if (_cached) return _cached;
3417
+ const name = (process.env.MARKET_DATA_PROVIDER ?? "massive").toLowerCase();
3418
+ switch (name) {
3419
+ case "massive":
3420
+ _cached = new MassiveProvider();
3421
+ break;
3422
+ case "thetadata":
3423
+ _cached = new ThetaDataProvider();
3424
+ break;
3425
+ default:
3426
+ throw new Error(
3427
+ `Unknown MARKET_DATA_PROVIDER: "${name}". Supported: massive, thetadata`
3428
+ );
3429
+ }
3430
+ return _cached;
2600
3431
  }
2601
- var INDEX_TICKERS2 = /* @__PURE__ */ new Set([
2602
- "VIX",
2603
- "VIX9D",
2604
- "VIX3M",
2605
- "SPX",
2606
- "NDX",
2607
- "RUT",
2608
- "DJX",
2609
- "VXN",
2610
- "OVX",
2611
- "GVZ"
2612
- ]);
2613
- var OCC_TICKER_REGEX = /^[A-Z]+\d{6}[CP]\d{8}$/;
2614
- function detectAssetClass(ticker) {
2615
- if (INDEX_TICKERS2.has(ticker.toUpperCase())) return "index";
2616
- if (OCC_TICKER_REGEX.test(ticker.toUpperCase())) return "option";
2617
- return "stock";
2618
- }
2619
- async function importFromApi(conn, options) {
2620
- const {
2621
- ticker,
2622
- from,
2623
- to,
2624
- targetTable,
2625
- timespan = "minute",
2626
- multiplier = 1,
2627
- assetClass,
2628
- dryRun = false,
2629
- skipEnrichment = false
2630
- } = options;
2631
- const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
2632
- if (targetTable === "daily") {
2633
- const resolvedClass2 = assetClass ?? detectAssetClass(normalizedTicker);
2634
- const rows2 = await getProvider().fetchBars({
2635
- ticker: normalizedTicker,
2636
- from,
2637
- to,
2638
- timespan: "day",
2639
- multiplier: 1,
2640
- assetClass: resolvedClass2
3432
+ function _resetProvider() {
3433
+ _cached = null;
3434
+ }
3435
+
3436
+ // src/utils/market-importer.ts
3437
+ var REQUIRED_SCHEMA_FIELDS = {
3438
+ daily: ["date", "open", "high", "low", "close"],
3439
+ context: ["date"],
3440
+ intraday: ["date", "time", "open", "high", "low", "close"],
3441
+ _context_derived: ["date"]
3442
+ // Phase 75: cross-ticker derived fields
3443
+ };
3444
+ var CONFLICT_TARGETS = {
3445
+ daily: "(ticker, date)",
3446
+ context: "(date)",
3447
+ intraday: "(ticker, date, time)",
3448
+ _context_derived: "(date)"
3449
+ // Phase 75: PK is date only
3450
+ };
3451
+ function validateColumnMapping(columnMapping, targetTable) {
3452
+ const schemaValues = Object.values(columnMapping);
3453
+ const required = REQUIRED_SCHEMA_FIELDS[targetTable] ?? [];
3454
+ let missing = required.filter((field) => !schemaValues.includes(field));
3455
+ if (targetTable === "intraday" && missing.includes("time") && schemaValues.includes("date")) {
3456
+ missing = missing.filter((f) => f !== "time");
3457
+ }
3458
+ return { valid: missing.length === 0, missingFields: missing };
3459
+ }
3460
+ function parseCSV(content) {
3461
+ const lines = content.replace(/^\uFEFF/, "").trim().split("\n");
3462
+ if (lines.length < 2) {
3463
+ return { headers: [], rows: [] };
3464
+ }
3465
+ const headers = lines[0].trim().split(",").map((h) => h.trim());
3466
+ const rows = [];
3467
+ for (let i = 1; i < lines.length; i++) {
3468
+ const line = lines[i].trim();
3469
+ if (!line) continue;
3470
+ const values = line.split(",");
3471
+ const row = {};
3472
+ headers.forEach((h, idx) => {
3473
+ row[h] = values[idx]?.trim() ?? "";
2641
3474
  });
2642
- const mappedRows2 = rows2.map((row) => ({
2643
- date: row.date,
2644
- open: row.open,
2645
- high: row.high,
2646
- low: row.low,
2647
- close: row.close,
2648
- ticker: row.ticker
2649
- }));
2650
- const dateRange2 = computeDateRange(mappedRows2);
2651
- if (dryRun) {
2652
- return {
2653
- rowsInserted: 0,
2654
- rowsUpdated: 0,
2655
- rowsSkipped: 0,
2656
- inputRowCount: mappedRows2.length,
2657
- dateRange: dateRange2,
2658
- enrichment: {
2659
- status: "skipped",
2660
- message: `dry_run=true; no data written. Would import ${mappedRows2.length} rows.`
2661
- }
2662
- };
2663
- }
2664
- const { inserted: inserted2, updated: updated2, skipped: skipped2 } = await insertMappedRows(conn, "daily", mappedRows2);
2665
- await upsertMarketImportMetadata(conn, {
2666
- source: `import_from_api:daily:${normalizedTicker}`,
2667
- ticker: normalizedTicker,
2668
- target_table: "daily",
2669
- max_date: dateRange2?.max ?? null,
2670
- enriched_through: null,
2671
- synced_at: /* @__PURE__ */ new Date()
3475
+ rows.push(row);
3476
+ }
3477
+ return { headers, rows };
3478
+ }
3479
+ function parseFlexibleDate(value) {
3480
+ const numeric = Number(value);
3481
+ if (!isNaN(numeric) && numeric > 1e8) {
3482
+ return new Date(numeric * 1e3).toLocaleDateString("en-CA", {
3483
+ timeZone: "America/New_York",
3484
+ year: "numeric",
3485
+ month: "2-digit",
3486
+ day: "2-digit"
2672
3487
  });
2673
- const enrichment = await triggerEnrichment(conn, normalizedTicker, "daily", dateRange2, skipEnrichment);
2674
- return {
2675
- rowsInserted: inserted2,
2676
- rowsUpdated: updated2,
2677
- rowsSkipped: skipped2,
2678
- inputRowCount: mappedRows2.length,
2679
- dateRange: dateRange2,
2680
- enrichment
2681
- };
2682
3488
  }
2683
- if (targetTable === "context") {
2684
- const contextTickers = ["VIX", "VIX9D", "VIX3M"];
2685
- let totalInserted = 0;
2686
- let totalUpdated = 0;
2687
- let totalSkipped = 0;
2688
- let totalInput = 0;
2689
- let combinedDateRange = null;
2690
- for (const ctxTicker of contextTickers) {
2691
- const bars = await getProvider().fetchBars({ ticker: ctxTicker, from, to, timespan: "day", assetClass: "index" });
2692
- const mappedRows2 = bars.map((bar) => ({
2693
- ticker: ctxTicker,
2694
- date: bar.date,
2695
- open: bar.open,
2696
- high: bar.high,
2697
- low: bar.low,
2698
- close: bar.close
2699
- }));
2700
- totalInput += mappedRows2.length;
2701
- if (mappedRows2.length === 0) continue;
2702
- const dateRange2 = computeDateRange(mappedRows2);
2703
- if (dateRange2) {
2704
- if (!combinedDateRange) {
2705
- combinedDateRange = { ...dateRange2 };
3489
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
3490
+ return value;
3491
+ }
3492
+ return null;
3493
+ }
3494
+ function parseFlexibleTime(value) {
3495
+ const numeric = Number(value);
3496
+ if (!isNaN(numeric) && numeric > 1e8) {
3497
+ const d = new Date(numeric * 1e3);
3498
+ return d.toLocaleTimeString("en-US", {
3499
+ timeZone: "America/New_York",
3500
+ hour: "2-digit",
3501
+ minute: "2-digit",
3502
+ hour12: false
3503
+ });
3504
+ }
3505
+ if (/^\d{2}:\d{2}$/.test(value)) {
3506
+ return value;
3507
+ }
3508
+ if (/^\d{4}$/.test(value)) {
3509
+ return `${value.slice(0, 2)}:${value.slice(2)}`;
3510
+ }
3511
+ return null;
3512
+ }
3513
+ function applyColumnMapping(rows, columnMapping, ticker, targetTable) {
3514
+ const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
3515
+ const result = [];
3516
+ for (const row of rows) {
3517
+ const mapped = {};
3518
+ let hasNullDate = false;
3519
+ for (const [sourceCol, schemaCol] of Object.entries(columnMapping)) {
3520
+ const rawValue = row[sourceCol] ?? "";
3521
+ if (schemaCol === "date") {
3522
+ const parsedDate = parseFlexibleDate(rawValue);
3523
+ if (parsedDate === null) {
3524
+ console.warn(`[market-importer] Skipping row with unparseable date value: "${rawValue}"`);
3525
+ hasNullDate = true;
3526
+ break;
3527
+ }
3528
+ mapped[schemaCol] = parsedDate;
3529
+ } else if (schemaCol === "time") {
3530
+ const parsedTime = parseFlexibleTime(rawValue);
3531
+ if (parsedTime === null) {
3532
+ console.warn(`[market-importer] Skipping row with unparseable time value: "${rawValue}"`);
3533
+ hasNullDate = true;
3534
+ break;
3535
+ }
3536
+ mapped[schemaCol] = parsedTime;
3537
+ } else {
3538
+ if (rawValue === "" || rawValue === "NaN" || rawValue === "NA") {
3539
+ mapped[schemaCol] = null;
2706
3540
  } else {
2707
- if (dateRange2.min < combinedDateRange.min) combinedDateRange.min = dateRange2.min;
2708
- if (dateRange2.max > combinedDateRange.max) combinedDateRange.max = dateRange2.max;
3541
+ const numVal = parseFloat(rawValue);
3542
+ mapped[schemaCol] = isNaN(numVal) ? rawValue : numVal;
2709
3543
  }
2710
3544
  }
2711
- if (!dryRun) {
2712
- const { inserted: inserted2, updated: updated2, skipped: skipped2 } = await insertMappedRows(conn, "daily", mappedRows2);
2713
- totalInserted += inserted2;
2714
- totalUpdated += updated2;
2715
- totalSkipped += skipped2;
2716
- await upsertMarketImportMetadata(conn, {
2717
- source: `import_from_api:daily:${ctxTicker}`,
2718
- ticker: ctxTicker,
2719
- target_table: "daily",
2720
- max_date: dateRange2?.max ?? null,
2721
- enriched_through: null,
2722
- synced_at: /* @__PURE__ */ new Date()
2723
- });
2724
- }
2725
3545
  }
2726
- if (dryRun) {
2727
- return {
2728
- rowsInserted: 0,
2729
- rowsUpdated: 0,
2730
- rowsSkipped: 0,
2731
- inputRowCount: totalInput,
2732
- dateRange: combinedDateRange,
2733
- enrichment: {
2734
- status: "skipped",
2735
- message: `dry_run=true; no data written. Would import ${totalInput} rows for ${contextTickers.join(", ")} into market.daily.`
3546
+ if (hasNullDate) continue;
3547
+ if (!("date" in mapped)) continue;
3548
+ if (targetTable === "intraday" && !("time" in mapped)) {
3549
+ const dateSourceCol = Object.entries(columnMapping).find(([, schema]) => schema === "date")?.[0];
3550
+ if (dateSourceCol) {
3551
+ const rawDateValue = row[dateSourceCol] ?? "";
3552
+ const numericDate = Number(rawDateValue);
3553
+ if (!isNaN(numericDate) && numericDate > 1e8) {
3554
+ const parsedTime = parseFlexibleTime(rawDateValue);
3555
+ if (parsedTime) {
3556
+ mapped["time"] = parsedTime;
3557
+ }
2736
3558
  }
2737
- };
3559
+ }
2738
3560
  }
2739
- let enrichment;
2740
- if (skipEnrichment) {
2741
- enrichment = {
2742
- status: "skipped",
2743
- message: "skip_enrichment=true; call enrich_market_data to populate computed fields."
2744
- };
2745
- } else {
2746
- try {
2747
- const tier2Result = await runContextEnrichment(conn);
2748
- enrichment = {
2749
- status: tier2Result.status === "complete" || tier2Result.status === "skipped" ? "complete" : "error",
2750
- message: tier2Result.reason ?? `Tier 2 enrichment: ${tier2Result.fieldsWritten ?? 0} fields`
2751
- };
2752
- } catch (e) {
2753
- enrichment = {
2754
- status: "error",
2755
- message: `Tier 2 enrichment failed: ${e instanceof Error ? e.message : String(e)}`
2756
- };
3561
+ if (targetTable === "daily" || targetTable === "intraday") {
3562
+ mapped["ticker"] = normalizedTicker;
3563
+ }
3564
+ result.push(mapped);
3565
+ }
3566
+ return result;
3567
+ }
3568
+ async function insertMappedRows(conn, targetTable, mappedRows) {
3569
+ if (mappedRows.length === 0) {
3570
+ return { inserted: 0, updated: 0, skipped: 0 };
3571
+ }
3572
+ const tableName = `market.${targetTable}`;
3573
+ const conflictTarget = CONFLICT_TARGETS[targetTable];
3574
+ const columnSet = /* @__PURE__ */ new Set();
3575
+ for (const row of mappedRows) {
3576
+ for (const key of Object.keys(row)) {
3577
+ columnSet.add(key);
3578
+ }
3579
+ }
3580
+ const columns = Array.from(columnSet);
3581
+ const beforeResult = await conn.runAndReadAll(`SELECT COUNT(*) FROM ${tableName}`);
3582
+ const beforeCount = Number(beforeResult.getRows()[0][0]);
3583
+ const conflictKeys = new Set(
3584
+ CONFLICT_TARGETS[targetTable].replace(/[()]/g, "").split(",").map((s) => s.trim())
3585
+ );
3586
+ const updateCols = columns.filter((c) => !conflictKeys.has(c));
3587
+ const conflictAction = updateCols.length > 0 ? `DO UPDATE SET ${updateCols.map((c) => `${c} = EXCLUDED.${c}`).join(", ")}` : "DO NOTHING";
3588
+ const columnList = columns.join(", ");
3589
+ const BATCH_SIZE = 500;
3590
+ for (let i = 0; i < mappedRows.length; i += BATCH_SIZE) {
3591
+ const batch = mappedRows.slice(i, i + BATCH_SIZE);
3592
+ const values = [];
3593
+ const valuePlaceholders = [];
3594
+ for (const row of batch) {
3595
+ const rowPlaceholders = [];
3596
+ for (const col of columns) {
3597
+ values.push(row[col] ?? null);
3598
+ rowPlaceholders.push(`$${values.length}`);
2757
3599
  }
3600
+ valuePlaceholders.push(`(${rowPlaceholders.join(", ")})`);
3601
+ }
3602
+ const sql = `INSERT INTO ${tableName} (${columnList}) VALUES ${valuePlaceholders.join(", ")} ON CONFLICT ${conflictTarget} ${conflictAction}`;
3603
+ await conn.run(sql, values);
3604
+ }
3605
+ const afterResult = await conn.runAndReadAll(`SELECT COUNT(*) FROM ${tableName}`);
3606
+ const afterCount = Number(afterResult.getRows()[0][0]);
3607
+ const inserted = afterCount - beforeCount;
3608
+ const updated = updateCols.length > 0 ? mappedRows.length - inserted : 0;
3609
+ const skipped = updateCols.length > 0 ? 0 : mappedRows.length - inserted;
3610
+ return { inserted, updated, skipped };
3611
+ }
3612
+ function computeDateRange(rows) {
3613
+ const dates = [];
3614
+ for (const row of rows) {
3615
+ const d = row["date"];
3616
+ if (typeof d === "string" && d) {
3617
+ dates.push(d);
2758
3618
  }
3619
+ }
3620
+ if (dates.length === 0) return null;
3621
+ dates.sort();
3622
+ return { min: dates[0], max: dates[dates.length - 1] };
3623
+ }
3624
+ async function triggerEnrichment(conn, ticker, targetTable, _dateRange, skipEnrichment) {
3625
+ if (skipEnrichment) {
2759
3626
  return {
2760
- rowsInserted: totalInserted,
2761
- rowsUpdated: totalUpdated,
2762
- rowsSkipped: totalSkipped,
2763
- inputRowCount: totalInput,
2764
- dateRange: combinedDateRange,
2765
- enrichment
3627
+ status: "skipped",
3628
+ message: "skip_enrichment=true; call enrich_market_data to populate computed fields."
2766
3629
  };
2767
3630
  }
2768
- const resolvedClass = assetClass ?? detectAssetClass(normalizedTicker);
2769
- const rows = await getProvider().fetchBars({
2770
- ticker: normalizedTicker,
2771
- from,
2772
- to,
2773
- timespan,
2774
- multiplier,
2775
- assetClass: resolvedClass
2776
- });
2777
- const mappedRows = rows.filter((row) => row.time !== void 0).map((row) => ({
2778
- ticker: row.ticker,
2779
- date: row.date,
2780
- time: row.time,
2781
- open: row.open,
2782
- high: row.high,
2783
- low: row.low,
2784
- close: row.close
2785
- // volume intentionally omitted — not in intraday schema
2786
- }));
3631
+ if (targetTable !== "daily") {
3632
+ return {
3633
+ status: "skipped",
3634
+ message: `Enrichment only runs for daily table imports; skipping for ${targetTable}.`
3635
+ };
3636
+ }
3637
+ try {
3638
+ const result = await runEnrichment(conn, ticker, {});
3639
+ const summaryParts = [
3640
+ `Tier 1: ${result.tier1.status}${result.tier1.fieldsWritten !== void 0 ? ` (${result.tier1.fieldsWritten} fields)` : ""}${result.tier1.reason ? ` \u2014 ${result.tier1.reason}` : ""}`,
3641
+ `Tier 2: ${result.tier2.status}${result.tier2.fieldsWritten !== void 0 ? ` (${result.tier2.fieldsWritten} fields)` : ""}${result.tier2.reason ? ` \u2014 ${result.tier2.reason}` : ""}`,
3642
+ `Tier 3: ${result.tier3.status}${result.tier3.reason ? ` \u2014 ${result.tier3.reason}` : ""}`
3643
+ ];
3644
+ return {
3645
+ status: "complete",
3646
+ message: `Enriched ${result.rowsEnriched} rows for ${ticker} through ${result.enrichedThrough ?? "N/A"}. ${summaryParts.join("; ")}`
3647
+ };
3648
+ } catch (err) {
3649
+ return {
3650
+ status: "error",
3651
+ message: `Enrichment failed for ${ticker}: ${err instanceof Error ? err.message : String(err)}`
3652
+ };
3653
+ }
3654
+ }
3655
+ async function importMarketCsvFile(conn, options) {
3656
+ const { filePath, ticker, targetTable, columnMapping, dryRun = false, skipEnrichment = false } = options;
3657
+ const validation = validateColumnMapping(columnMapping, targetTable);
3658
+ if (!validation.valid) {
3659
+ throw new Error(
3660
+ `Column mapping missing required fields for market.${targetTable}: ${validation.missingFields.join(", ")}`
3661
+ );
3662
+ }
3663
+ let content;
3664
+ try {
3665
+ content = await fs.readFile(filePath, "utf-8");
3666
+ } catch (error) {
3667
+ const msg = error instanceof Error ? error.message : String(error);
3668
+ throw new Error(`Failed to read CSV file at "${filePath}": ${msg}`);
3669
+ }
3670
+ const { rows } = parseCSV(content);
3671
+ if (rows.length === 0) {
3672
+ throw new Error(`CSV file "${filePath}" has no data rows`);
3673
+ }
3674
+ const mappedRows = applyColumnMapping(rows, columnMapping, ticker, targetTable);
3675
+ if (mappedRows.length === 0) {
3676
+ throw new Error(
3677
+ `After applying column mapping, 0 valid rows remain from CSV file "${filePath}"`
3678
+ );
3679
+ }
3680
+ const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
2787
3681
  const dateRange = computeDateRange(mappedRows);
2788
3682
  if (dryRun) {
2789
3683
  return {
@@ -2794,61 +3688,343 @@ async function importFromApi(conn, options) {
2794
3688
  dateRange,
2795
3689
  enrichment: {
2796
3690
  status: "skipped",
2797
- message: `dry_run=true; no data written. Would import ${mappedRows.length} intraday rows.`
3691
+ message: `dry_run=true; no data written. Would import ${mappedRows.length} rows.`
2798
3692
  }
2799
3693
  };
2800
3694
  }
2801
- const { inserted, updated, skipped } = await insertMappedRows(conn, "intraday", mappedRows);
3695
+ const { inserted, updated, skipped } = await insertMappedRows(conn, targetTable, mappedRows);
2802
3696
  await upsertMarketImportMetadata(conn, {
2803
- source: `import_from_api:intraday:${normalizedTicker}`,
3697
+ source: `import_market_csv:${filePath}`,
2804
3698
  ticker: normalizedTicker,
2805
- target_table: "intraday",
3699
+ target_table: targetTable,
2806
3700
  max_date: dateRange?.max ?? null,
2807
3701
  enriched_through: null,
2808
3702
  synced_at: /* @__PURE__ */ new Date()
2809
3703
  });
3704
+ const enrichment = await triggerEnrichment(conn, normalizedTicker, targetTable, dateRange, skipEnrichment);
2810
3705
  return {
2811
3706
  rowsInserted: inserted,
2812
3707
  rowsUpdated: updated,
2813
3708
  rowsSkipped: skipped,
2814
3709
  inputRowCount: mappedRows.length,
2815
3710
  dateRange,
2816
- enrichment: {
2817
- status: "skipped",
2818
- message: "Enrichment only runs for daily and context table imports; skipping for intraday."
2819
- }
3711
+ enrichment
2820
3712
  };
2821
3713
  }
2822
-
2823
- // src/utils/analysis-stats.ts
2824
- function round2(n) {
2825
- return Math.round(n * 100) / 100;
2826
- }
2827
- function computeSliceStats(pls) {
2828
- if (pls.length === 0) {
3714
+ async function importFromDatabase(conn, options) {
3715
+ const { dbPath, query, ticker, targetTable, columnMapping, dryRun = false, skipEnrichment = false } = options;
3716
+ const validation = validateColumnMapping(columnMapping, targetTable);
3717
+ if (!validation.valid) {
3718
+ throw new Error(
3719
+ `Column mapping missing required fields for market.${targetTable}: ${validation.missingFields.join(", ")}`
3720
+ );
3721
+ }
3722
+ const EXT_ALIAS = "ext_import_source";
3723
+ await conn.run(`ATTACH '${dbPath}' AS ${EXT_ALIAS} (READ_ONLY)`);
3724
+ try {
3725
+ const rawResult = await conn.runAndReadAll(query);
3726
+ const resultColumnNames = rawResult.columnNames();
3727
+ const resultRows = rawResult.getRows();
3728
+ const rows = resultRows.map((row) => {
3729
+ const obj = {};
3730
+ resultColumnNames.forEach((colName, idx) => {
3731
+ const val = row[idx];
3732
+ obj[colName] = val === null || val === void 0 ? "" : String(val);
3733
+ });
3734
+ return obj;
3735
+ });
3736
+ const mappedRows = applyColumnMapping(rows, columnMapping, ticker, targetTable);
3737
+ const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
3738
+ const dateRange = computeDateRange(mappedRows);
3739
+ if (dryRun) {
3740
+ return {
3741
+ rowsInserted: 0,
3742
+ rowsUpdated: 0,
3743
+ rowsSkipped: 0,
3744
+ inputRowCount: mappedRows.length,
3745
+ dateRange,
3746
+ enrichment: {
3747
+ status: "skipped",
3748
+ message: `dry_run=true; no data written. Would import ${mappedRows.length} rows.`
3749
+ }
3750
+ };
3751
+ }
3752
+ const { inserted, updated, skipped } = await insertMappedRows(conn, targetTable, mappedRows);
3753
+ await upsertMarketImportMetadata(conn, {
3754
+ source: `import_from_database:${dbPath}`,
3755
+ ticker: normalizedTicker,
3756
+ target_table: targetTable,
3757
+ max_date: dateRange?.max ?? null,
3758
+ enriched_through: null,
3759
+ synced_at: /* @__PURE__ */ new Date()
3760
+ });
3761
+ const enrichment = await triggerEnrichment(conn, normalizedTicker, targetTable, dateRange, skipEnrichment);
2829
3762
  return {
2830
- tradeCount: 0,
2831
- wins: 0,
2832
- losses: 0,
2833
- winRate: 0,
2834
- totalPl: 0,
2835
- avgPl: 0,
2836
- avgWin: 0,
2837
- avgLoss: 0,
2838
- profitFactor: 0
3763
+ rowsInserted: inserted,
3764
+ rowsUpdated: updated,
3765
+ rowsSkipped: skipped,
3766
+ inputRowCount: mappedRows.length,
3767
+ dateRange,
3768
+ enrichment
2839
3769
  };
3770
+ } finally {
3771
+ try {
3772
+ await conn.run(`DETACH ${EXT_ALIAS}`);
3773
+ } catch {
3774
+ }
2840
3775
  }
2841
- const winPls = pls.filter((p) => p > 0);
2842
- const lossPls = pls.filter((p) => p <= 0);
2843
- const wins = winPls.length;
2844
- const losses = lossPls.length;
2845
- const winRate = wins / pls.length * 100;
2846
- const totalPl = pls.reduce((sum, p) => sum + p, 0);
2847
- const avgPl = totalPl / pls.length;
2848
- const avgWin = wins > 0 ? winPls.reduce((s, p) => s + p, 0) / wins : 0;
2849
- const avgLoss = losses > 0 ? lossPls.reduce((s, p) => s + p, 0) / losses : 0;
2850
- const grossWins = winPls.reduce((s, p) => s + p, 0);
2851
- const grossLosses = Math.abs(lossPls.reduce((s, p) => s + p, 0));
3776
+ }
3777
+ var INDEX_TICKERS2 = /* @__PURE__ */ new Set([
3778
+ "VIX",
3779
+ "VIX9D",
3780
+ "VIX3M",
3781
+ "SPX",
3782
+ "NDX",
3783
+ "RUT",
3784
+ "DJX",
3785
+ "VXN",
3786
+ "OVX",
3787
+ "GVZ"
3788
+ ]);
3789
+ var OCC_TICKER_REGEX = /^[A-Z]+\d{6}[CP]\d{8}$/;
3790
+ function detectAssetClass(ticker) {
3791
+ if (INDEX_TICKERS2.has(ticker.toUpperCase())) return "index";
3792
+ if (OCC_TICKER_REGEX.test(ticker.toUpperCase())) return "option";
3793
+ return "stock";
3794
+ }
3795
+ async function importFromApi(conn, options) {
3796
+ const {
3797
+ ticker,
3798
+ from,
3799
+ to,
3800
+ targetTable,
3801
+ timespan = "minute",
3802
+ multiplier = 1,
3803
+ assetClass,
3804
+ dryRun = false,
3805
+ skipEnrichment = false
3806
+ } = options;
3807
+ const normalizedTicker = normalizeTicker(ticker) ?? ticker.toUpperCase();
3808
+ if (targetTable === "daily") {
3809
+ const resolvedClass2 = assetClass ?? detectAssetClass(normalizedTicker);
3810
+ const rows2 = await getProvider().fetchBars({
3811
+ ticker: normalizedTicker,
3812
+ from,
3813
+ to,
3814
+ timespan: "day",
3815
+ multiplier: 1,
3816
+ assetClass: resolvedClass2
3817
+ });
3818
+ const mappedRows2 = rows2.map((row) => ({
3819
+ date: row.date,
3820
+ open: row.open,
3821
+ high: row.high,
3822
+ low: row.low,
3823
+ close: row.close,
3824
+ ticker: row.ticker
3825
+ }));
3826
+ const dateRange2 = computeDateRange(mappedRows2);
3827
+ if (dryRun) {
3828
+ return {
3829
+ rowsInserted: 0,
3830
+ rowsUpdated: 0,
3831
+ rowsSkipped: 0,
3832
+ inputRowCount: mappedRows2.length,
3833
+ dateRange: dateRange2,
3834
+ enrichment: {
3835
+ status: "skipped",
3836
+ message: `dry_run=true; no data written. Would import ${mappedRows2.length} rows.`
3837
+ }
3838
+ };
3839
+ }
3840
+ const { inserted: inserted2, updated: updated2, skipped: skipped2 } = await insertMappedRows(conn, "daily", mappedRows2);
3841
+ await upsertMarketImportMetadata(conn, {
3842
+ source: `import_from_api:daily:${normalizedTicker}`,
3843
+ ticker: normalizedTicker,
3844
+ target_table: "daily",
3845
+ max_date: dateRange2?.max ?? null,
3846
+ enriched_through: null,
3847
+ synced_at: /* @__PURE__ */ new Date()
3848
+ });
3849
+ const enrichment = await triggerEnrichment(conn, normalizedTicker, "daily", dateRange2, skipEnrichment);
3850
+ return {
3851
+ rowsInserted: inserted2,
3852
+ rowsUpdated: updated2,
3853
+ rowsSkipped: skipped2,
3854
+ inputRowCount: mappedRows2.length,
3855
+ dateRange: dateRange2,
3856
+ enrichment
3857
+ };
3858
+ }
3859
+ if (targetTable === "context") {
3860
+ const contextTickers = ["VIX", "VIX9D", "VIX3M"];
3861
+ let totalInserted = 0;
3862
+ let totalUpdated = 0;
3863
+ let totalSkipped = 0;
3864
+ let totalInput = 0;
3865
+ let combinedDateRange = null;
3866
+ for (const ctxTicker of contextTickers) {
3867
+ const bars = await getProvider().fetchBars({ ticker: ctxTicker, from, to, timespan: "day", assetClass: "index" });
3868
+ const mappedRows2 = bars.map((bar) => ({
3869
+ ticker: ctxTicker,
3870
+ date: bar.date,
3871
+ open: bar.open,
3872
+ high: bar.high,
3873
+ low: bar.low,
3874
+ close: bar.close
3875
+ }));
3876
+ totalInput += mappedRows2.length;
3877
+ if (mappedRows2.length === 0) continue;
3878
+ const dateRange2 = computeDateRange(mappedRows2);
3879
+ if (dateRange2) {
3880
+ if (!combinedDateRange) {
3881
+ combinedDateRange = { ...dateRange2 };
3882
+ } else {
3883
+ if (dateRange2.min < combinedDateRange.min) combinedDateRange.min = dateRange2.min;
3884
+ if (dateRange2.max > combinedDateRange.max) combinedDateRange.max = dateRange2.max;
3885
+ }
3886
+ }
3887
+ if (!dryRun) {
3888
+ const { inserted: inserted2, updated: updated2, skipped: skipped2 } = await insertMappedRows(conn, "daily", mappedRows2);
3889
+ totalInserted += inserted2;
3890
+ totalUpdated += updated2;
3891
+ totalSkipped += skipped2;
3892
+ await upsertMarketImportMetadata(conn, {
3893
+ source: `import_from_api:daily:${ctxTicker}`,
3894
+ ticker: ctxTicker,
3895
+ target_table: "daily",
3896
+ max_date: dateRange2?.max ?? null,
3897
+ enriched_through: null,
3898
+ synced_at: /* @__PURE__ */ new Date()
3899
+ });
3900
+ }
3901
+ }
3902
+ if (dryRun) {
3903
+ return {
3904
+ rowsInserted: 0,
3905
+ rowsUpdated: 0,
3906
+ rowsSkipped: 0,
3907
+ inputRowCount: totalInput,
3908
+ dateRange: combinedDateRange,
3909
+ enrichment: {
3910
+ status: "skipped",
3911
+ message: `dry_run=true; no data written. Would import ${totalInput} rows for ${contextTickers.join(", ")} into market.daily.`
3912
+ }
3913
+ };
3914
+ }
3915
+ let enrichment;
3916
+ if (skipEnrichment) {
3917
+ enrichment = {
3918
+ status: "skipped",
3919
+ message: "skip_enrichment=true; call enrich_market_data to populate computed fields."
3920
+ };
3921
+ } else {
3922
+ try {
3923
+ const tier2Result = await runContextEnrichment(conn);
3924
+ enrichment = {
3925
+ status: tier2Result.status === "complete" || tier2Result.status === "skipped" ? "complete" : "error",
3926
+ message: tier2Result.reason ?? `Tier 2 enrichment: ${tier2Result.fieldsWritten ?? 0} fields`
3927
+ };
3928
+ } catch (e) {
3929
+ enrichment = {
3930
+ status: "error",
3931
+ message: `Tier 2 enrichment failed: ${e instanceof Error ? e.message : String(e)}`
3932
+ };
3933
+ }
3934
+ }
3935
+ return {
3936
+ rowsInserted: totalInserted,
3937
+ rowsUpdated: totalUpdated,
3938
+ rowsSkipped: totalSkipped,
3939
+ inputRowCount: totalInput,
3940
+ dateRange: combinedDateRange,
3941
+ enrichment
3942
+ };
3943
+ }
3944
+ const resolvedClass = assetClass ?? detectAssetClass(normalizedTicker);
3945
+ const rows = await getProvider().fetchBars({
3946
+ ticker: normalizedTicker,
3947
+ from,
3948
+ to,
3949
+ timespan,
3950
+ multiplier,
3951
+ assetClass: resolvedClass
3952
+ });
3953
+ const mappedRows = rows.filter((row) => row.time !== void 0).map((row) => ({
3954
+ ticker: row.ticker,
3955
+ date: row.date,
3956
+ time: row.time,
3957
+ open: row.open,
3958
+ high: row.high,
3959
+ low: row.low,
3960
+ close: row.close
3961
+ // volume intentionally omitted — not in intraday schema
3962
+ }));
3963
+ const dateRange = computeDateRange(mappedRows);
3964
+ if (dryRun) {
3965
+ return {
3966
+ rowsInserted: 0,
3967
+ rowsUpdated: 0,
3968
+ rowsSkipped: 0,
3969
+ inputRowCount: mappedRows.length,
3970
+ dateRange,
3971
+ enrichment: {
3972
+ status: "skipped",
3973
+ message: `dry_run=true; no data written. Would import ${mappedRows.length} intraday rows.`
3974
+ }
3975
+ };
3976
+ }
3977
+ const { inserted, updated, skipped } = await insertMappedRows(conn, "intraday", mappedRows);
3978
+ await upsertMarketImportMetadata(conn, {
3979
+ source: `import_from_api:intraday:${normalizedTicker}`,
3980
+ ticker: normalizedTicker,
3981
+ target_table: "intraday",
3982
+ max_date: dateRange?.max ?? null,
3983
+ enriched_through: null,
3984
+ synced_at: /* @__PURE__ */ new Date()
3985
+ });
3986
+ return {
3987
+ rowsInserted: inserted,
3988
+ rowsUpdated: updated,
3989
+ rowsSkipped: skipped,
3990
+ inputRowCount: mappedRows.length,
3991
+ dateRange,
3992
+ enrichment: {
3993
+ status: "skipped",
3994
+ message: "Enrichment only runs for daily and context table imports; skipping for intraday."
3995
+ }
3996
+ };
3997
+ }
3998
+
3999
+ // src/utils/analysis-stats.ts
4000
+ function round2(n) {
4001
+ return Math.round(n * 100) / 100;
4002
+ }
4003
+ function computeSliceStats(pls) {
4004
+ if (pls.length === 0) {
4005
+ return {
4006
+ tradeCount: 0,
4007
+ wins: 0,
4008
+ losses: 0,
4009
+ winRate: 0,
4010
+ totalPl: 0,
4011
+ avgPl: 0,
4012
+ avgWin: 0,
4013
+ avgLoss: 0,
4014
+ profitFactor: 0
4015
+ };
4016
+ }
4017
+ const winPls = pls.filter((p) => p > 0);
4018
+ const lossPls = pls.filter((p) => p <= 0);
4019
+ const wins = winPls.length;
4020
+ const losses = lossPls.length;
4021
+ const winRate = wins / pls.length * 100;
4022
+ const totalPl = pls.reduce((sum, p) => sum + p, 0);
4023
+ const avgPl = totalPl / pls.length;
4024
+ const avgWin = wins > 0 ? winPls.reduce((s, p) => s + p, 0) / wins : 0;
4025
+ const avgLoss = losses > 0 ? lossPls.reduce((s, p) => s + p, 0) / losses : 0;
4026
+ const grossWins = winPls.reduce((s, p) => s + p, 0);
4027
+ const grossLosses = Math.abs(lossPls.reduce((s, p) => s + p, 0));
2852
4028
  let profitFactor;
2853
4029
  if (grossLosses > 0) {
2854
4030
  profitFactor = grossWins / grossLosses;
@@ -4100,330 +5276,194 @@ async function handleRegimeAllocationAdvisor(input, baseDir) {
4100
5276
  allHiddenEdges.push({
4101
5277
  strategyName: profile.strategyName,
4102
5278
  regime: label,
4103
- winRate: stats.winRate,
4104
- overallWinRate: overallStats.winRate
4105
- });
4106
- } else {
4107
- classification = "neutral";
4108
- }
4109
- }
4110
- regimePerformance[label] = { stats, isExpected, classification };
4111
- }
4112
- const allocationPct = profile.positionSizing?.liveAllocationPct ?? profile.positionSizing?.allocationPct;
4113
- strategies.push({
4114
- strategyName: profile.strategyName,
4115
- blockId: profile.blockId,
4116
- structureType: profile.structureType,
4117
- underlying: profile.underlying ?? void 0,
4118
- allocationPct,
4119
- expectedRegimes: profile.expectedRegimes,
4120
- regimePerformance,
4121
- tradeCount: trades.length,
4122
- matchedToMarket: matched.length,
4123
- unmatchedCount
4124
- });
4125
- } catch (err) {
4126
- warnings.push(
4127
- `Error processing strategy '${profile.strategyName}' (block: ${profile.blockId}): ${err.message}`
4128
- );
4129
- }
4130
- }
4131
- const regimeOverview = {};
4132
- for (const [label, pls] of Object.entries(regimeAggPls)) {
4133
- let strategiesActive = 0;
4134
- for (const strategy of strategies) {
4135
- if (strategy.regimePerformance[label]) {
4136
- strategiesActive++;
4137
- }
4138
- }
4139
- regimeOverview[label] = {
4140
- strategiesActive,
4141
- totalTrades: pls.length,
4142
- combinedStats: computeSliceStats(pls)
4143
- };
4144
- }
4145
- const summary = {
4146
- totalStrategies: profiles.length,
4147
- profiled: strategies.length,
4148
- skippedNoRegimes,
4149
- skippedNoMarket,
4150
- thesisViolations: allThesisViolations,
4151
- hiddenEdges: allHiddenEdges
4152
- };
4153
- const summaryText = `Regime allocation advisor: ${strategies.length}/${profiles.length} strategies analyzed. ${allThesisViolations.length} thesis violation(s), ${allHiddenEdges.length} hidden edge(s). ` + (skippedNoRegimes > 0 ? `${skippedNoRegimes} skipped (no expectedRegimes). ` : "") + (skippedNoMarket > 0 ? `${skippedNoMarket} skipped (no market data). ` : "");
4154
- return createToolOutput(summaryText, {
4155
- strategies,
4156
- summary,
4157
- regimeOverview,
4158
- warnings,
4159
- profileUpgradeHints
4160
- });
4161
- }
4162
-
4163
- // src/utils/trade-replay.ts
4164
- function markPrice(bar) {
4165
- if (bar.bid != null && bar.ask != null && (bar.bid > 0 || bar.ask > 0)) {
4166
- return (bar.bid + bar.ask) / 2;
4167
- }
4168
- return (bar.high + bar.low) / 2;
4169
- }
4170
- function findNearestTimestamp(sortedTimestamps, target, toleranceSec = 60) {
4171
- if (sortedTimestamps.length === 0) return void 0;
4172
- const targetMin = timestampToMinutes(target);
4173
- if (targetMin === null) return void 0;
4174
- let lo = 0, hi = sortedTimestamps.length - 1;
4175
- let bestIdx = 0;
4176
- let bestDiff = Infinity;
4177
- while (lo <= hi) {
4178
- const mid = lo + hi >>> 1;
4179
- const midMin = timestampToMinutes(sortedTimestamps[mid]);
4180
- if (midMin === null) {
4181
- lo = mid + 1;
4182
- continue;
4183
- }
4184
- const diff = Math.abs(midMin - targetMin);
4185
- if (diff < bestDiff) {
4186
- bestDiff = diff;
4187
- bestIdx = mid;
4188
- }
4189
- if (midMin < targetMin) lo = mid + 1;
4190
- else if (midMin > targetMin) hi = mid - 1;
4191
- else break;
4192
- }
4193
- return bestDiff <= toleranceSec / 60 ? sortedTimestamps[bestIdx] : void 0;
4194
- }
4195
- function timestampToMinutes(ts) {
4196
- const timePart = ts.split(" ")[1];
4197
- if (!timePart) return null;
4198
- const [h, m] = timePart.split(":").map(Number);
4199
- if (isNaN(h) || isNaN(m)) return null;
4200
- return h * 60 + m;
4201
- }
4202
- var COMPACT_LEG_RE = /^([A-Z]+)\s+(\d+(?:\.\d+)?)\s*(C|P)$/i;
4203
- var COMPACT_NO_ROOT_RE = /^(\d+(?:\.\d+)?)\s*(C|P)$/i;
4204
- var VERBOSE_LEG_RE = /^([A-Z]+)\s+\w+\s+(\d+(?:\.\d+)?)\s+(Call|Put)$/i;
4205
- var OO_LEG_RE = /^(\d+)\s+(\w+)\s+(\d+)\s+(\d+(?:\.\d+)?)\s+(C|P)\s+(STO|BTO|STC|BTC)\s+(\d+(?:\.\d+)?)$/i;
4206
- function parseLegsString(legsStr) {
4207
- if (!legsStr || legsStr.trim() === "") {
4208
- throw new Error('Cannot parse legs "" \u2014 use hypothetical mode with explicit strikes');
4209
- }
4210
- if (legsStr.includes("|")) {
4211
- return parseOOLegs(legsStr);
4212
- }
4213
- const parts = legsStr.includes("/") ? legsStr.split("/") : [legsStr];
4214
- const legs = [];
4215
- let inheritedRoot = "";
4216
- for (let i = 0; i < parts.length; i++) {
4217
- const raw = parts[i].trim();
4218
- let root;
4219
- let strike;
4220
- let type;
4221
- const compactMatch = raw.match(COMPACT_LEG_RE);
4222
- if (compactMatch) {
4223
- root = compactMatch[1].toUpperCase();
4224
- strike = parseFloat(compactMatch[2]);
4225
- type = compactMatch[3].toUpperCase();
4226
- } else {
4227
- const noRootMatch = raw.match(COMPACT_NO_ROOT_RE);
4228
- if (noRootMatch && inheritedRoot) {
4229
- root = inheritedRoot;
4230
- strike = parseFloat(noRootMatch[1]);
4231
- type = noRootMatch[2].toUpperCase();
4232
- } else {
4233
- const verboseMatch = raw.match(VERBOSE_LEG_RE);
4234
- if (verboseMatch) {
4235
- root = verboseMatch[1].toUpperCase();
4236
- strike = parseFloat(verboseMatch[2]);
4237
- type = verboseMatch[3].toLowerCase() === "call" ? "C" : "P";
4238
- } else {
4239
- throw new Error(
4240
- `Cannot parse legs "${legsStr}" \u2014 use hypothetical mode with explicit strikes`
4241
- );
4242
- }
4243
- }
4244
- }
4245
- if (i === 0) inheritedRoot = root;
4246
- const quantity = i === 0 ? 1 : i % 2 === 0 ? 1 : -1;
4247
- legs.push({ root, strike, type, quantity });
4248
- }
4249
- return legs;
4250
- }
4251
- function parseOOLegs(legsStr) {
4252
- const segments = legsStr.split("|").map((s) => s.trim());
4253
- const legs = [];
4254
- const seen = /* @__PURE__ */ new Set();
4255
- for (const seg of segments) {
4256
- const match = seg.match(OO_LEG_RE);
4257
- if (!match) {
4258
- throw new Error(
4259
- `Cannot parse OO leg segment "${seg}" \u2014 use hypothetical mode with explicit strikes`
4260
- );
4261
- }
4262
- const contracts = parseInt(match[1], 10);
4263
- const month = match[2];
4264
- const day = match[3];
4265
- const strike = parseFloat(match[4]);
4266
- const type = match[5].toUpperCase();
4267
- const direction = match[6].toUpperCase();
4268
- const price = parseFloat(match[7]);
4269
- const key = `${month}${day}:${strike}${type}`;
4270
- if (seen.has(key)) continue;
4271
- seen.add(key);
4272
- legs.push({
4273
- root: "",
4274
- // OO format doesn't include root — caller provides via trade's ticker field
4275
- strike,
4276
- type,
4277
- quantity: direction === "BTO" ? 1 : -1,
4278
- entryPrice: price,
4279
- contracts,
4280
- expiryHint: `${month} ${day}`
4281
- });
4282
- }
4283
- return legs;
4284
- }
4285
- function buildOccTicker(root, expiry, type, strike) {
4286
- const [yyyy, mm, dd] = expiry.split("-");
4287
- const yy = yyyy.slice(2);
4288
- const strikeInt = Math.round(strike * 1e3);
4289
- const strikePadded = String(strikeInt).padStart(8, "0");
4290
- return `${root}${yy}${mm}${dd}${type}${strikePadded}`;
4291
- }
4292
- function computeStrategyPnlPath(legs, barsByLeg, greeksConfig) {
4293
- if (legs.length === 0 || barsByLeg.length === 0) return [];
4294
- for (const bars of barsByLeg) {
4295
- if (bars.length === 0) return [];
4296
- }
4297
- const legMaps = barsByLeg.map((bars) => {
4298
- const map = /* @__PURE__ */ new Map();
4299
- for (const bar of bars) {
4300
- const ts = `${bar.date} ${bar.time ?? ""}`.trim();
4301
- map.set(ts, bar);
4302
- }
4303
- return map;
4304
- });
4305
- const allTimestamps = /* @__PURE__ */ new Set();
4306
- for (const bars of barsByLeg) {
4307
- for (const bar of bars) {
4308
- allTimestamps.add(`${bar.date} ${bar.time ?? ""}`.trim());
4309
- }
4310
- }
4311
- const sortedTimestamps = [...allTimestamps].sort();
4312
- const path = [];
4313
- const lastBar = new Array(legs.length).fill(void 0);
4314
- for (const ts of sortedTimestamps) {
4315
- let complete = true;
4316
- const legPrices = [];
4317
- let strategyPnl = 0;
4318
- for (let i = 0; i < legs.length; i++) {
4319
- const bar = legMaps[i].get(ts);
4320
- if (bar) {
4321
- lastBar[i] = bar;
4322
- }
4323
- const effective = bar ?? lastBar[i];
4324
- if (!effective) {
4325
- complete = false;
4326
- break;
4327
- }
4328
- const hl2 = markPrice(effective);
4329
- legPrices.push(hl2);
4330
- strategyPnl += (hl2 - legs[i].entryPrice) * legs[i].quantity * legs[i].multiplier;
4331
- }
4332
- if (complete) {
4333
- const point = { timestamp: ts, strategyPnl, legPrices };
4334
- if (greeksConfig) {
4335
- let underlyingPrice = greeksConfig.underlyingPrices.get(ts);
4336
- if (underlyingPrice === void 0 && greeksConfig.sortedTimestamps) {
4337
- const nearest = findNearestTimestamp(greeksConfig.sortedTimestamps, ts, 60);
4338
- if (nearest) underlyingPrice = greeksConfig.underlyingPrices.get(nearest);
4339
- }
4340
- if (underlyingPrice === void 0) {
4341
- const dateOnly = ts.split(" ")[0];
4342
- underlyingPrice = greeksConfig.underlyingPrices.get(dateOnly);
4343
- }
4344
- if (underlyingPrice !== void 0) {
4345
- const legGreeksArr = [];
4346
- let netDelta = 0, netGamma = 0, netTheta = 0, netVega = 0;
4347
- let allNull = true;
4348
- for (let j = 0; j < legs.length; j++) {
4349
- const legCfg = greeksConfig.legs[j];
4350
- if (!legCfg || !legCfg.expiryDate) {
4351
- legGreeksArr.push({ delta: null, gamma: null, theta: null, vega: null, iv: null });
4352
- continue;
4353
- }
4354
- const dateStr = ts.split(" ")[0];
4355
- const timePart = ts.split(" ")[1] ?? "09:30";
4356
- const [eyy, emm, edd] = legCfg.expiryDate.split("-").map(Number);
4357
- const [byy, bmm, bdd] = dateStr.split("-").map(Number);
4358
- const [hh, min] = timePart.split(":").map(Number);
4359
- const expiryMs = new Date(eyy, emm - 1, edd).getTime() + 16 * 60 * 60 * 1e3;
4360
- const barMs = new Date(byy, bmm - 1, bdd).getTime() + (hh * 60 + min) * 60 * 1e3;
4361
- const dte = (expiryMs - barMs) / (1e3 * 60 * 60 * 24);
4362
- if (dte <= 0) {
4363
- legGreeksArr.push({ delta: null, gamma: null, theta: null, vega: null, iv: null });
4364
- continue;
4365
- }
4366
- const g = computeLegGreeks(
4367
- legPrices[j],
4368
- underlyingPrice,
4369
- legCfg.strike,
4370
- dte,
4371
- legCfg.type,
4372
- greeksConfig.riskFreeRate,
4373
- greeksConfig.dividendYield
4374
- );
4375
- legGreeksArr.push(g);
4376
- if (g.delta !== null) {
4377
- allNull = false;
4378
- const weight = legs[j].quantity * legs[j].multiplier / 100;
4379
- netDelta += g.delta * weight;
4380
- netGamma += g.gamma * weight;
4381
- netTheta += g.theta * weight;
4382
- netVega += g.vega * weight;
4383
- }
5279
+ winRate: stats.winRate,
5280
+ overallWinRate: overallStats.winRate
5281
+ });
5282
+ } else {
5283
+ classification = "neutral";
4384
5284
  }
4385
- point.legGreeks = legGreeksArr;
4386
- point.netDelta = allNull ? null : netDelta;
4387
- point.netGamma = allNull ? null : netGamma;
4388
- point.netTheta = allNull ? null : netTheta;
4389
- point.netVega = allNull ? null : netVega;
4390
- const ivpDate = ts.split(" ")[0];
4391
- point.ivp = greeksConfig.ivpByDate?.get(ivpDate) ?? null;
4392
5285
  }
5286
+ regimePerformance[label] = { stats, isExpected, classification };
4393
5287
  }
4394
- path.push(point);
5288
+ const allocationPct = profile.positionSizing?.liveAllocationPct ?? profile.positionSizing?.allocationPct;
5289
+ strategies.push({
5290
+ strategyName: profile.strategyName,
5291
+ blockId: profile.blockId,
5292
+ structureType: profile.structureType,
5293
+ underlying: profile.underlying ?? void 0,
5294
+ allocationPct,
5295
+ expectedRegimes: profile.expectedRegimes,
5296
+ regimePerformance,
5297
+ tradeCount: trades.length,
5298
+ matchedToMarket: matched.length,
5299
+ unmatchedCount
5300
+ });
5301
+ } catch (err) {
5302
+ warnings.push(
5303
+ `Error processing strategy '${profile.strategyName}' (block: ${profile.blockId}): ${err.message}`
5304
+ );
4395
5305
  }
4396
5306
  }
4397
- return path;
4398
- }
4399
- function computeReplayMfeMae(pnlPath) {
4400
- if (pnlPath.length === 0) {
4401
- return { mfe: 0, mae: 0, mfeTimestamp: "", maeTimestamp: "" };
4402
- }
4403
- let mfe = pnlPath[0].strategyPnl;
4404
- let mae = pnlPath[0].strategyPnl;
4405
- let mfeTimestamp = pnlPath[0].timestamp;
4406
- let maeTimestamp = pnlPath[0].timestamp;
4407
- for (let i = 1; i < pnlPath.length; i++) {
4408
- const pnl = pnlPath[i].strategyPnl;
4409
- if (pnl > mfe) {
4410
- mfe = pnl;
4411
- mfeTimestamp = pnlPath[i].timestamp;
4412
- }
4413
- if (pnl < mae) {
4414
- mae = pnl;
4415
- maeTimestamp = pnlPath[i].timestamp;
5307
+ const regimeOverview = {};
5308
+ for (const [label, pls] of Object.entries(regimeAggPls)) {
5309
+ let strategiesActive = 0;
5310
+ for (const strategy of strategies) {
5311
+ if (strategy.regimePerformance[label]) {
5312
+ strategiesActive++;
5313
+ }
4416
5314
  }
5315
+ regimeOverview[label] = {
5316
+ strategiesActive,
5317
+ totalTrades: pls.length,
5318
+ combinedStats: computeSliceStats(pls)
5319
+ };
4417
5320
  }
4418
- return { mfe, mae, mfeTimestamp, maeTimestamp };
5321
+ const summary = {
5322
+ totalStrategies: profiles.length,
5323
+ profiled: strategies.length,
5324
+ skippedNoRegimes,
5325
+ skippedNoMarket,
5326
+ thesisViolations: allThesisViolations,
5327
+ hiddenEdges: allHiddenEdges
5328
+ };
5329
+ const summaryText = `Regime allocation advisor: ${strategies.length}/${profiles.length} strategies analyzed. ${allThesisViolations.length} thesis violation(s), ${allHiddenEdges.length} hidden edge(s). ` + (skippedNoRegimes > 0 ? `${skippedNoRegimes} skipped (no expectedRegimes). ` : "") + (skippedNoMarket > 0 ? `${skippedNoMarket} skipped (no market data). ` : "");
5330
+ return createToolOutput(summaryText, {
5331
+ strategies,
5332
+ summary,
5333
+ regimeOverview,
5334
+ warnings,
5335
+ profileUpgradeHints
5336
+ });
4419
5337
  }
4420
5338
 
4421
5339
  // src/tools/replay.ts
4422
5340
  import { z as z5 } from "zod";
4423
5341
 
4424
5342
  // src/utils/bar-cache.ts
5343
+ function getDataTier() {
5344
+ const tier = (process.env.MASSIVE_DATA_TIER ?? "").toLowerCase();
5345
+ if (tier === "quotes" || tier === "trades" || tier === "ohlc") return tier;
5346
+ if (process.env.MASSIVE_QUOTES_ENABLED === "true" || process.env.MASSIVE_QUOTES_ENABLED === "1") return "quotes";
5347
+ return "ohlc";
5348
+ }
4425
5349
  function quotesEnabled() {
4426
- return process.env.MASSIVE_QUOTES_ENABLED === "true" || process.env.MASSIVE_QUOTES_ENABLED === "1";
5350
+ return getDataTier() === "quotes";
5351
+ }
5352
+ function mergeQuoteBars(tradeBars, quotesMap, ticker) {
5353
+ if (quotesMap.size === 0) return tradeBars;
5354
+ const tradeBarKeys = /* @__PURE__ */ new Set();
5355
+ for (const bar of tradeBars) {
5356
+ if (bar.time) tradeBarKeys.add(`${bar.date} ${bar.time}`);
5357
+ }
5358
+ const syntheticBars = [];
5359
+ for (const [key, quote] of quotesMap) {
5360
+ if (tradeBarKeys.has(key)) continue;
5361
+ if (quote.bid <= 0 && quote.ask <= 0) continue;
5362
+ const mid = (quote.bid + quote.ask) / 2;
5363
+ const [date, time] = key.split(" ");
5364
+ if (!date || !time) continue;
5365
+ syntheticBars.push({
5366
+ date,
5367
+ time,
5368
+ open: mid,
5369
+ high: mid,
5370
+ low: mid,
5371
+ close: mid,
5372
+ bid: quote.bid,
5373
+ ask: quote.ask,
5374
+ volume: 0,
5375
+ ticker
5376
+ });
5377
+ }
5378
+ if (syntheticBars.length === 0) {
5379
+ for (const bar of tradeBars) {
5380
+ if (bar.time && bar.bid == null && bar.ask == null) {
5381
+ const quote = quotesMap.get(`${bar.date} ${bar.time}`);
5382
+ if (quote) {
5383
+ bar.bid = quote.bid;
5384
+ bar.ask = quote.ask;
5385
+ }
5386
+ }
5387
+ }
5388
+ return tradeBars;
5389
+ }
5390
+ for (const bar of tradeBars) {
5391
+ if (bar.time && bar.bid == null && bar.ask == null) {
5392
+ const quote = quotesMap.get(`${bar.date} ${bar.time}`);
5393
+ if (quote) {
5394
+ bar.bid = quote.bid;
5395
+ bar.ask = quote.ask;
5396
+ }
5397
+ }
5398
+ }
5399
+ const merged = [...tradeBars, ...syntheticBars];
5400
+ merged.sort((a, b) => {
5401
+ const ka = `${a.date} ${a.time ?? ""}`;
5402
+ const kb = `${b.date} ${b.time ?? ""}`;
5403
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
5404
+ });
5405
+ return merged;
5406
+ }
5407
+ async function enrichWithQuotes(bars, ticker, from, to) {
5408
+ const provider = getProvider();
5409
+ if (!provider.fetchQuotes) return { bars, newBars: [] };
5410
+ let quotesMap;
5411
+ try {
5412
+ quotesMap = await provider.fetchQuotes(ticker, from, to);
5413
+ } catch {
5414
+ return { bars, newBars: [] };
5415
+ }
5416
+ if (quotesMap.size === 0) return { bars, newBars: [] };
5417
+ const merged = mergeQuoteBars(bars, quotesMap, ticker);
5418
+ const originalKeys = new Set(bars.map((b) => `${b.date} ${b.time}`));
5419
+ const added = merged.filter((b) => !originalKeys.has(`${b.date} ${b.time}`));
5420
+ return { bars: merged, newBars: added };
5421
+ }
5422
+ async function cacheNewBars(newBars, ticker, conn, baseDir) {
5423
+ if (newBars.length === 0) return;
5424
+ try {
5425
+ const c = conn ?? await getConnection(baseDir ?? ".");
5426
+ const escaped = ticker.replace(/'/g, "''");
5427
+ const values = newBars.filter((b) => b.time).map(
5428
+ (b) => `('${escaped}', '${b.date}', '${b.time}', ${b.open}, ${b.high}, ${b.low}, ${b.close}, ${b.bid ?? "NULL"}, ${b.ask ?? "NULL"})`
5429
+ );
5430
+ for (let i = 0; i < values.length; i += 500) {
5431
+ const chunk = values.slice(i, i + 500);
5432
+ await c.run(
5433
+ `INSERT OR REPLACE INTO ${getIntradayWriteTable()} (ticker, date, time, open, high, low, close, bid, ask) VALUES ${chunk.join(", ")}`
5434
+ );
5435
+ }
5436
+ } catch {
5437
+ }
5438
+ }
5439
+ async function readCachedBars(opts) {
5440
+ const { ticker, from, to, conn } = opts;
5441
+ try {
5442
+ const escaped = ticker.replace(/'/g, "''");
5443
+ const cached = await conn.runAndReadAll(
5444
+ `SELECT open, high, low, close, bid, ask, time, date
5445
+ FROM market.intraday
5446
+ WHERE ticker = '${escaped}'
5447
+ AND date >= '${from}'
5448
+ AND date <= '${to}'
5449
+ ORDER BY date, time`
5450
+ );
5451
+ const rows = cached.getRows();
5452
+ return rows.map((row) => ({
5453
+ open: Number(row[0]),
5454
+ high: Number(row[1]),
5455
+ low: Number(row[2]),
5456
+ close: Number(row[3]),
5457
+ bid: row[4] != null ? Number(row[4]) : void 0,
5458
+ ask: row[5] != null ? Number(row[5]) : void 0,
5459
+ time: String(row[6]),
5460
+ date: String(row[7]),
5461
+ ticker,
5462
+ volume: 0
5463
+ }));
5464
+ } catch {
5465
+ return [];
5466
+ }
4427
5467
  }
4428
5468
  async function fetchBarsWithCache(opts) {
4429
5469
  const { ticker, from, to, timespan, assetClass, baseDir } = opts;
@@ -4439,7 +5479,13 @@ async function fetchBarsWithCache(opts) {
4439
5479
  ORDER BY date, time`
4440
5480
  );
4441
5481
  const rows = cached.getRows();
4442
- if (rows.length > 0) {
5482
+ const cachedDates = new Set(rows.map((r) => String(r[7])));
5483
+ const calendarDays = Math.round(
5484
+ (new Date(to).getTime() - new Date(from).getTime()) / 864e5
5485
+ );
5486
+ const expectedTradingDays = Math.max(1, Math.ceil(calendarDays * 5 / 7));
5487
+ const isPartialHit = rows.length > 0 && from !== to && cachedDates.size < expectedTradingDays * 0.7;
5488
+ if (rows.length > 0 && (!isPartialHit || opts.skipQuotes)) {
4443
5489
  const bars2 = rows.map((row) => ({
4444
5490
  open: Number(row[0]),
4445
5491
  high: Number(row[1]),
@@ -4453,41 +5499,14 @@ async function fetchBarsWithCache(opts) {
4453
5499
  volume: 0
4454
5500
  // market.intraday has no volume column
4455
5501
  }));
4456
- const missingQuotes = assetClass === "option" && quotesEnabled() && bars2.some((b) => b.bid == null && b.ask == null);
4457
- if (missingQuotes) {
4458
- try {
4459
- const provider = getProvider();
4460
- if (provider.fetchQuotes) {
4461
- const quotesMap = await provider.fetchQuotes(ticker, from, to);
4462
- if (quotesMap.size > 0) {
4463
- const updates = [];
4464
- for (const bar of bars2) {
4465
- if (bar.time != null) {
4466
- const key = `${bar.date} ${bar.time}`;
4467
- const quote = quotesMap.get(key);
4468
- if (quote != null) {
4469
- bar.bid = quote.bid;
4470
- bar.ask = quote.ask;
4471
- updates.push(
4472
- `('${escaped}', '${bar.date}', '${bar.time}', ${quote.bid}, ${quote.ask})`
4473
- );
4474
- }
4475
- }
4476
- }
4477
- if (updates.length > 0) {
4478
- const updateConn = opts.conn ?? await getConnection(baseDir ?? ".");
4479
- for (let i = 0; i < updates.length; i += 500) {
4480
- const chunk = updates.slice(i, i + 500);
4481
- await updateConn.run(
4482
- `UPDATE market.intraday AS m SET bid = v.bid, ask = v.ask
4483
- FROM (VALUES ${chunk.join(", ")}) AS v(ticker, date, time, bid, ask)
4484
- WHERE m.ticker = v.ticker AND m.date = v.date AND m.time = v.time`
4485
- );
4486
- }
4487
- }
4488
- }
4489
- }
4490
- } catch {
5502
+ if (assetClass === "option" && quotesEnabled() && !opts.skipQuotes) {
5503
+ const dates = new Set(bars2.map((b) => b.date));
5504
+ const barsPerDay = dates.size > 0 ? bars2.length / dates.size : 0;
5505
+ const hasQuotes = bars2.some((b) => b.bid != null && b.ask != null);
5506
+ if (barsPerDay < 200 || !hasQuotes) {
5507
+ const { bars: enriched, newBars } = await enrichWithQuotes(bars2, ticker, from, to);
5508
+ await cacheNewBars(newBars, ticker, opts.conn, baseDir);
5509
+ return enriched;
4491
5510
  }
4492
5511
  }
4493
5512
  return bars2;
@@ -4507,6 +5526,25 @@ async function fetchBarsWithCache(opts) {
4507
5526
  return [];
4508
5527
  }
4509
5528
  if (bars.length === 0) return [];
5529
+ if (assetClass === "option" && quotesEnabled() && !opts.skipQuotes) {
5530
+ const { bars: enriched } = await enrichWithQuotes(bars, ticker, from, to);
5531
+ const allBars = enriched;
5532
+ try {
5533
+ const conn = opts.conn ?? await getConnection(baseDir ?? ".");
5534
+ const escaped = ticker.replace(/'/g, "''");
5535
+ const values = allBars.filter((b) => b.time).map(
5536
+ (b) => `('${escaped}', '${b.date}', '${b.time}', ${b.open}, ${b.high}, ${b.low}, ${b.close}, ${b.bid ?? "NULL"}, ${b.ask ?? "NULL"})`
5537
+ );
5538
+ for (let i = 0; i < values.length; i += 500) {
5539
+ const chunk = values.slice(i, i + 500);
5540
+ await conn.run(
5541
+ `INSERT OR REPLACE INTO ${getIntradayWriteTable()} (ticker, date, time, open, high, low, close, bid, ask) VALUES ${chunk.join(", ")}`
5542
+ );
5543
+ }
5544
+ } catch {
5545
+ }
5546
+ return enriched;
5547
+ }
4510
5548
  try {
4511
5549
  const conn = opts.conn ?? await getConnection(baseDir ?? ".");
4512
5550
  const escaped = ticker.replace(/'/g, "''");
@@ -4516,13 +5554,88 @@ async function fetchBarsWithCache(opts) {
4516
5554
  for (let i = 0; i < values.length; i += 500) {
4517
5555
  const chunk = values.slice(i, i + 500);
4518
5556
  await conn.run(
4519
- `INSERT OR REPLACE INTO market.intraday (ticker, date, time, open, high, low, close, bid, ask) VALUES ${chunk.join(", ")}`
5557
+ `INSERT OR REPLACE INTO ${getIntradayWriteTable()} (ticker, date, time, open, high, low, close, bid, ask) VALUES ${chunk.join(", ")}`
4520
5558
  );
4521
5559
  }
4522
5560
  } catch {
4523
5561
  }
4524
5562
  return bars;
4525
5563
  }
5564
+ async function fetchEntryBarsForCandidates(tickers, entryDate, conn) {
5565
+ const result = /* @__PURE__ */ new Map();
5566
+ if (tickers.length === 0) return result;
5567
+ const escapedTickers = tickers.map((t) => `'${t.replace(/'/g, "''")}'`);
5568
+ try {
5569
+ const cached = await conn.runAndReadAll(
5570
+ `SELECT ticker, open, high, low, close, bid, ask, time, date
5571
+ FROM market.intraday
5572
+ WHERE ticker IN (${escapedTickers.join(", ")})
5573
+ AND date = '${entryDate}'
5574
+ ORDER BY ticker, time`
5575
+ );
5576
+ const rows = cached.getRows();
5577
+ for (const row of rows) {
5578
+ const ticker = String(row[0]);
5579
+ const bar = {
5580
+ ticker,
5581
+ open: Number(row[1]),
5582
+ high: Number(row[2]),
5583
+ low: Number(row[3]),
5584
+ close: Number(row[4]),
5585
+ bid: row[5] != null ? Number(row[5]) : void 0,
5586
+ ask: row[6] != null ? Number(row[6]) : void 0,
5587
+ time: String(row[7]),
5588
+ date: String(row[8]),
5589
+ volume: 0
5590
+ };
5591
+ const existing = result.get(ticker);
5592
+ if (existing) existing.push(bar);
5593
+ else result.set(ticker, [bar]);
5594
+ }
5595
+ } catch {
5596
+ }
5597
+ return result;
5598
+ }
5599
+ async function fetchBarsForLegsBulk(legs, fromDate, conn) {
5600
+ const result = /* @__PURE__ */ new Map();
5601
+ if (legs.length === 0) return result;
5602
+ const maxExpiry = legs.reduce((max, l) => l.expiration > max ? l.expiration : max, fromDate);
5603
+ const escaped = legs.map((l) => `'${l.ticker.replace(/'/g, "''")}'`).join(", ");
5604
+ try {
5605
+ const cached = await conn.runAndReadAll(
5606
+ `SELECT ticker, open, high, low, close, bid, ask, time, date
5607
+ FROM market.intraday
5608
+ WHERE ticker IN (${escaped})
5609
+ AND date >= '${fromDate}'
5610
+ AND date <= '${maxExpiry}'
5611
+ ORDER BY ticker, date, time`
5612
+ );
5613
+ for (const row of cached.getRows()) {
5614
+ const ticker = String(row[0]);
5615
+ const bar = {
5616
+ ticker,
5617
+ open: Number(row[1]),
5618
+ high: Number(row[2]),
5619
+ low: Number(row[3]),
5620
+ close: Number(row[4]),
5621
+ bid: row[5] != null ? Number(row[5]) : void 0,
5622
+ ask: row[6] != null ? Number(row[6]) : void 0,
5623
+ time: String(row[7]),
5624
+ date: String(row[8]),
5625
+ volume: 0
5626
+ };
5627
+ const existing = result.get(ticker);
5628
+ if (existing) existing.push(bar);
5629
+ else result.set(ticker, [bar]);
5630
+ }
5631
+ } catch {
5632
+ await Promise.all(legs.map(async (leg) => {
5633
+ const bars = await readCachedBars({ ticker: leg.ticker, from: fromDate, to: leg.expiration, conn });
5634
+ if (bars.length > 0) result.set(leg.ticker, bars);
5635
+ }));
5636
+ }
5637
+ return result;
5638
+ }
4526
5639
 
4527
5640
  // src/tools/replay.ts
4528
5641
  var replayTradeSchema = z5.object({
@@ -4555,6 +5668,9 @@ var replayTradeSchema = z5.object({
4555
5668
  ),
4556
5669
  close_at: z5.enum(["trade", "expiry"]).default("trade").describe(
4557
5670
  "When to end the P&L path: 'trade' (default) truncates at the trade's actual close time, 'expiry' shows full path through option expiry. Only applies to tradelog mode."
5671
+ ),
5672
+ skip_quotes: z5.boolean().default(false).describe(
5673
+ "Skip NBBO quote enrichment for option bars. Faster, but uses cached trade bars / HL2 marks."
4558
5674
  )
4559
5675
  });
4560
5676
  var MONTH_MAP = {
@@ -4581,15 +5697,18 @@ function resolveOODateRange(parsedLegs, tradeYear, tradeOpenDate) {
4581
5697
  const hints = parsedLegs.filter((l) => l.expiryHint).map((l) => resolveOOExpiryHint(l.expiryHint, tradeYear));
4582
5698
  if (hints.length === 0) return null;
4583
5699
  const sorted = [...hints].sort();
4584
- const minDate = sorted[0];
4585
5700
  const maxDate = sorted[sorted.length - 1];
4586
- if (minDate === maxDate) {
4587
- return { from: tradeOpenDate, to: maxDate };
4588
- }
4589
- return { from: minDate, to: maxDate };
5701
+ return { from: tradeOpenDate, to: maxDate };
4590
5702
  }
4591
5703
  async function handleReplayTrade(params, baseDir, injectedConn) {
4592
- const { legs: inputLegs, block_id, trade_index, multiplier, close_at } = params;
5704
+ const {
5705
+ legs: inputLegs,
5706
+ block_id,
5707
+ trade_index,
5708
+ multiplier,
5709
+ close_at,
5710
+ skip_quotes
5711
+ } = params;
4593
5712
  let { open_date, close_date } = params;
4594
5713
  let tradeCloseTimestamp;
4595
5714
  let replayLegs;
@@ -4611,7 +5730,7 @@ async function handleReplayTrade(params, baseDir, injectedConn) {
4611
5730
  `SELECT legs, premium, date_opened, date_closed, ticker, num_contracts, time_closed
4612
5731
  FROM trades.trade_data
4613
5732
  WHERE block_id = '${block_id.replace(/'/g, "''")}'
4614
- ORDER BY date_opened
5733
+ ORDER BY date_opened, rowid
4615
5734
  LIMIT 1 OFFSET ${trade_index}`
4616
5735
  );
4617
5736
  const rows = result.getRows();
@@ -4683,7 +5802,8 @@ async function handleReplayTrade(params, baseDir, injectedConn) {
4683
5802
  timespan: "minute",
4684
5803
  assetClass: "option",
4685
5804
  conn: injectedConn,
4686
- baseDir
5805
+ baseDir,
5806
+ skipQuotes: skip_quotes
4687
5807
  });
4688
5808
  if (bars.length > 0) return bars;
4689
5809
  const rootMatch = occTicker.match(/^([A-Z]+)/);
@@ -4698,7 +5818,8 @@ async function handleReplayTrade(params, baseDir, injectedConn) {
4698
5818
  timespan: "minute",
4699
5819
  assetClass: "option",
4700
5820
  conn: injectedConn,
4701
- baseDir
5821
+ baseDir,
5822
+ skipQuotes: skip_quotes
4702
5823
  });
4703
5824
  if (fallbackBars.length > 0) {
4704
5825
  const leg = replayLegs.find((l) => l.occTicker === occTicker);
@@ -5077,8 +6198,8 @@ function decomposeGreeks(config) {
5077
6198
  let stepResidual = 0;
5078
6199
  const groupVegaAccum = legGroups ? legGroups.map(() => 0) : void 0;
5079
6200
  const legCount = Math.min(legs.length, cur.legPrices?.length ?? 0, next.legPrices?.length ?? 0);
5080
- const S1 = underlyingPrices?.get(cur.timestamp);
5081
- const S2 = underlyingPrices?.get(next.timestamp);
6201
+ const S1 = cur.underlyingPrice ?? underlyingPrices?.get(cur.timestamp);
6202
+ const S2 = next.underlyingPrice ?? underlyingPrices?.get(next.timestamp);
5082
6203
  for (let j = 0; j < legCount; j++) {
5083
6204
  const positionSize = legs[j].quantity * legs[j].multiplier;
5084
6205
  const legActualChange = ((next.legPrices?.[j] ?? 0) - (cur.legPrices?.[j] ?? 0)) * positionSize;
@@ -5731,7 +6852,8 @@ var decomposeGreeksSchema = z7.object({
5731
6852
  label: z7.string(),
5732
6853
  leg_indices: z7.array(z7.number())
5733
6854
  })).optional().describe("Leg grouping for per-group vega attribution (e.g., front_month vs back_month)"),
5734
- format: z7.enum(["summary", "full"]).default("summary").describe("'summary' shows ranked factors, 'full' includes per-step contributions")
6855
+ format: z7.enum(["summary", "full"]).default("summary").describe("'summary' shows ranked factors, 'full' includes per-step contributions"),
6856
+ skip_quotes: z7.boolean().default(false).describe("Skip NBBO quote enrichment for option bars. Faster, but lower precision.")
5735
6857
  });
5736
6858
  var REVERSE_ROOT_MAP = {
5737
6859
  SPXW: "SPX",
@@ -5782,7 +6904,8 @@ async function handleAnalyzeExitTriggers(params, baseDir, injectedConn) {
5782
6904
  close_date,
5783
6905
  multiplier,
5784
6906
  format: "full",
5785
- close_at: "trade"
6907
+ close_at: "trade",
6908
+ skip_quotes: false
5786
6909
  },
5787
6910
  baseDir,
5788
6911
  injectedConn
@@ -5883,7 +7006,8 @@ async function handleDecomposeGreeks(params, baseDir, injectedConn) {
5883
7006
  close_date,
5884
7007
  multiplier,
5885
7008
  leg_groups,
5886
- format
7009
+ format,
7010
+ skip_quotes
5887
7011
  } = params;
5888
7012
  const replayResult = await handleReplayTrade(
5889
7013
  {
@@ -5894,7 +7018,8 @@ async function handleDecomposeGreeks(params, baseDir, injectedConn) {
5894
7018
  close_date,
5895
7019
  multiplier,
5896
7020
  format: "full",
5897
- close_at: "trade"
7021
+ close_at: "trade",
7022
+ skip_quotes
5898
7023
  },
5899
7024
  baseDir,
5900
7025
  injectedConn
@@ -5906,16 +7031,11 @@ async function handleDecomposeGreeks(params, baseDir, injectedConn) {
5906
7031
  "No greeks data available. Ensure MASSIVE_API_KEY is set and underlying price data exists."
5907
7032
  );
5908
7033
  }
5909
- const underlyingTicker = extractUnderlyingTicker(replayLegs[0]?.occTicker ?? "");
5910
- let underlyingPrices;
5911
- if (pnlPath.length > 0 && underlyingTicker) {
5912
- const firstDate = pnlPath[0].timestamp.slice(0, 10);
5913
- const lastDate = pnlPath[pnlPath.length - 1].timestamp.slice(0, 10);
5914
- underlyingPrices = await fetchPriceMap(
5915
- underlyingTicker,
5916
- firstDate,
5917
- lastDate
5918
- );
7034
+ const underlyingPrices = /* @__PURE__ */ new Map();
7035
+ for (const point of pnlPath) {
7036
+ if (point.underlyingPrice !== void 0) {
7037
+ underlyingPrices.set(point.timestamp, point.underlyingPrice);
7038
+ }
5919
7039
  }
5920
7040
  const legGroupDefs = leg_groups?.map((g) => ({
5921
7041
  label: g.label,
@@ -5942,7 +7062,7 @@ async function handleDecomposeGreeks(params, baseDir, injectedConn) {
5942
7062
  const result = decomposeGreeks({
5943
7063
  pnlPath,
5944
7064
  legs: replayLegs,
5945
- underlyingPrices,
7065
+ underlyingPrices: underlyingPrices.size > 0 ? underlyingPrices : void 0,
5946
7066
  legGroups: legGroupDefs,
5947
7067
  legPricingInputs,
5948
7068
  riskFreeRate: 0.045,
@@ -6321,7 +7441,8 @@ async function handleBatchExitAnalysis(params, baseDir, injectedConn) {
6321
7441
  trade_index: tradeIdx,
6322
7442
  multiplier,
6323
7443
  format: "full",
6324
- close_at: "trade"
7444
+ close_at: "trade",
7445
+ skip_quotes: false
6325
7446
  },
6326
7447
  baseDir,
6327
7448
  injectedConn
@@ -6398,6 +7519,308 @@ async function handleBatchExitAnalysis(params, baseDir, injectedConn) {
6398
7519
  }
6399
7520
  return result;
6400
7521
  }
7522
+
7523
+ // src/utils/quote-enricher.ts
7524
+ function shouldSkipEnrichment(barCount, densityThreshold = 200) {
7525
+ return barCount >= densityThreshold;
7526
+ }
7527
+ function buildEnrichmentPlan(input) {
7528
+ if (!input.providerSupportsQuotes) return [];
7529
+ if (input.tickers.length === 0) return [];
7530
+ const plan = [];
7531
+ for (const tickerSpec of input.tickers) {
7532
+ const dates = expandDateRange(tickerSpec.fromDate, tickerSpec.toDate);
7533
+ for (const date of dates) {
7534
+ const key = `${tickerSpec.ticker}:${date}`;
7535
+ const existingBarCount = input.existingCoverage.get(key) ?? 0;
7536
+ if (!shouldSkipEnrichment(existingBarCount)) {
7537
+ plan.push({
7538
+ ticker: tickerSpec.ticker,
7539
+ date,
7540
+ existingBarCount
7541
+ });
7542
+ }
7543
+ }
7544
+ }
7545
+ return plan;
7546
+ }
7547
+ function expandDateRange(fromDate, toDate) {
7548
+ const dates = [];
7549
+ const from = /* @__PURE__ */ new Date(fromDate + "T00:00:00Z");
7550
+ const to = /* @__PURE__ */ new Date(toDate + "T00:00:00Z");
7551
+ const current = new Date(from);
7552
+ while (current <= to) {
7553
+ dates.push(current.toISOString().slice(0, 10));
7554
+ current.setUTCDate(current.getUTCDate() + 1);
7555
+ }
7556
+ return dates;
7557
+ }
7558
+
7559
+ // src/tools/greeks-attribution.ts
7560
+ import { z as z9 } from "zod";
7561
+ var getGreeksAttributionSchema = z9.object({
7562
+ block_id: z9.string().describe("Block ID to analyze"),
7563
+ mode: z9.enum(["summary", "instance"]).default("summary").describe("summary: block-level attribution. instance: single trade time-series."),
7564
+ trade_index: z9.number().int().min(0).optional().describe("Trade index (required for instance mode). Use get_block_info to find trade indices."),
7565
+ skip_quotes: z9.boolean().default(true).describe("Use cached bar data only (fast). Set false to fetch NBBO quotes for higher precision."),
7566
+ detailed: z9.boolean().default(false).describe("false: 5 factors (delta, gamma, theta, vega, residual). true: adds charm, vanna."),
7567
+ strategy: z9.string().optional().describe("Filter to trades matching this strategy name (case-insensitive).")
7568
+ });
7569
+ var COLLAPSE_MAP = {
7570
+ charm: "delta",
7571
+ vanna: "vega"
7572
+ };
7573
+ var FACTOR_ORDER = ["theta", "vega", "delta", "gamma", "residual", "time_and_vol", "charm", "vanna"];
7574
+ function collapseFactors(factors, detailed) {
7575
+ const totals = /* @__PURE__ */ new Map();
7576
+ for (const f of factors) {
7577
+ const targetName = !detailed && COLLAPSE_MAP[f.factor] || f.factor;
7578
+ totals.set(targetName, (totals.get(targetName) ?? 0) + f.totalPnl);
7579
+ }
7580
+ return totals;
7581
+ }
7582
+ function computeAttribution(totals, totalPnl, grossAttributionFlow) {
7583
+ const entries = [];
7584
+ for (const [factor, pnl] of totals) {
7585
+ entries.push({
7586
+ factor,
7587
+ pnl: Math.round(pnl * 100) / 100,
7588
+ pct: totalPnl !== 0 ? Math.round(pnl / totalPnl * 1e3) / 10 : 0,
7589
+ ...grossAttributionFlow && grossAttributionFlow !== 0 ? { pct_of_gross: Math.round(pnl / grossAttributionFlow * 1e3) / 10 } : {}
7590
+ });
7591
+ }
7592
+ entries.sort((a, b) => {
7593
+ const ai = FACTOR_ORDER.indexOf(a.factor);
7594
+ const bi = FACTOR_ORDER.indexOf(b.factor);
7595
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
7596
+ });
7597
+ return entries;
7598
+ }
7599
+ function computeGrossAttributionFlow(totals) {
7600
+ let gross = 0;
7601
+ for (const pnl of totals.values()) {
7602
+ gross += Math.abs(pnl);
7603
+ }
7604
+ return gross;
7605
+ }
7606
+ function assessPrecision(residualPnl, totalPnl) {
7607
+ if (totalPnl === 0) return { precision: "high" };
7608
+ const residualPct = Math.abs(residualPnl / totalPnl) * 100;
7609
+ if (residualPct > 25) {
7610
+ return {
7611
+ precision: "low",
7612
+ hint: `Residual is ${Math.round(residualPct)}%. Re-run with skip_quotes=false for NBBO-based pricing.`
7613
+ };
7614
+ }
7615
+ return { precision: "high" };
7616
+ }
7617
+ async function handleGetGreeksAttribution(params, baseDir, injectedConn) {
7618
+ const { block_id, mode, trade_index, skip_quotes, detailed, strategy } = params;
7619
+ if (mode === "instance") {
7620
+ if (trade_index == null) {
7621
+ throw new Error("trade_index is required for instance mode");
7622
+ }
7623
+ return handleInstanceMode(block_id, trade_index, skip_quotes, detailed, baseDir, injectedConn);
7624
+ }
7625
+ return handleSummaryMode(block_id, skip_quotes, detailed, strategy, baseDir, injectedConn);
7626
+ }
7627
+ async function handleSummaryMode(block_id, skip_quotes, detailed, strategy, baseDir, injectedConn) {
7628
+ const conn = injectedConn ?? await getConnection(baseDir);
7629
+ const selectedTradesQuery = strategy ? `SELECT trade_index, pl
7630
+ FROM (
7631
+ SELECT ROW_NUMBER() OVER (ORDER BY date_opened, rowid) - 1 AS trade_index, pl, strategy
7632
+ FROM trades.trade_data
7633
+ WHERE block_id = $1
7634
+ )
7635
+ WHERE LOWER(strategy) = LOWER($2)
7636
+ ORDER BY trade_index` : `SELECT ROW_NUMBER() OVER (ORDER BY date_opened, rowid) - 1 AS trade_index, pl
7637
+ FROM trades.trade_data
7638
+ WHERE block_id = $1
7639
+ ORDER BY trade_index`;
7640
+ const selectedTradesParams = strategy ? [block_id, strategy] : [block_id];
7641
+ const selectedTradesResult = await conn.runAndReadAll(selectedTradesQuery, selectedTradesParams);
7642
+ const selectedTrades = selectedTradesResult.getRows().map((row) => ({
7643
+ tradeIndex: Number(row[0] ?? 0),
7644
+ actualPl: Number(row[1] ?? 0)
7645
+ }));
7646
+ const totalTrades = selectedTrades.length;
7647
+ if (totalTrades === 0) {
7648
+ throw new Error(
7649
+ strategy ? `No trades found for block "${block_id}" with strategy "${strategy}"` : `No trades found for block "${block_id}"`
7650
+ );
7651
+ }
7652
+ const accumulated = /* @__PURE__ */ new Map();
7653
+ let decomposed = 0;
7654
+ let skipped = 0;
7655
+ let actualTotalPnl = 0;
7656
+ let markTotalPnl = 0;
7657
+ const BATCH_SIZE = 10;
7658
+ for (let batch = 0; batch < totalTrades; batch += BATCH_SIZE) {
7659
+ const batchEnd = Math.min(batch + BATCH_SIZE, totalTrades);
7660
+ const promises = [];
7661
+ for (let i = batch; i < batchEnd; i++) {
7662
+ const trade = selectedTrades[i];
7663
+ promises.push(
7664
+ handleDecomposeGreeks(
7665
+ {
7666
+ block_id,
7667
+ trade_index: trade.tradeIndex,
7668
+ format: "summary",
7669
+ multiplier: 100,
7670
+ skip_quotes
7671
+ },
7672
+ baseDir,
7673
+ injectedConn
7674
+ ).then((result) => {
7675
+ for (const factor of result.factors) {
7676
+ accumulated.set(factor.factor, (accumulated.get(factor.factor) ?? 0) + factor.totalPnl);
7677
+ }
7678
+ actualTotalPnl += trade.actualPl;
7679
+ markTotalPnl += result.totalPnlChange;
7680
+ decomposed++;
7681
+ }).catch(() => {
7682
+ skipped++;
7683
+ })
7684
+ );
7685
+ }
7686
+ await Promise.allSettled(promises);
7687
+ }
7688
+ if (decomposed === 0) {
7689
+ return {
7690
+ block_id,
7691
+ trades_decomposed: 0,
7692
+ trades_skipped: skipped,
7693
+ trades_total: totalTrades,
7694
+ total_pnl: 0,
7695
+ mark_total_pnl: 0,
7696
+ execution_edge: 0,
7697
+ gross_attribution_flow: 0,
7698
+ attribution: [],
7699
+ precision: "low",
7700
+ hint: "No trades could be decomposed. Ensure market data is cached for the trade dates."
7701
+ };
7702
+ }
7703
+ const collapsed = collapseFactors(
7704
+ [...accumulated.entries()].map(([factor, totalPnl]) => ({
7705
+ factor,
7706
+ totalPnl,
7707
+ pctOfTotal: 0,
7708
+ steps: []
7709
+ })),
7710
+ detailed
7711
+ );
7712
+ const grossAttributionFlow = computeGrossAttributionFlow(collapsed);
7713
+ const attribution = computeAttribution(collapsed, actualTotalPnl, grossAttributionFlow);
7714
+ const residualPnl = collapsed.get("residual") ?? 0;
7715
+ const precisionBase = grossAttributionFlow !== 0 ? grossAttributionFlow : markTotalPnl;
7716
+ const { precision, hint } = assessPrecision(residualPnl, precisionBase);
7717
+ const executionEdge = actualTotalPnl - markTotalPnl;
7718
+ const hints = [];
7719
+ if (hint) hints.push(hint);
7720
+ const edgeRatio = Math.abs(actualTotalPnl) > 0.01 ? Math.abs(executionEdge) / Math.abs(actualTotalPnl) : 0;
7721
+ if (edgeRatio > 3) {
7722
+ hints.push(
7723
+ `Execution edge is ${Math.round(edgeRatio)}x the actual P&L \u2014 mark-to-market pricing may be based on sparse or low-quality data. ` + (skip_quotes ? `Re-run with skip_quotes=false for NBBO-based marks.` : `Consider whether intraday bar coverage is sufficient for this date range.`)
7724
+ );
7725
+ }
7726
+ return {
7727
+ block_id,
7728
+ trades_decomposed: decomposed,
7729
+ trades_skipped: skipped,
7730
+ trades_total: totalTrades,
7731
+ total_pnl: Math.round(actualTotalPnl * 100) / 100,
7732
+ mark_total_pnl: Math.round(markTotalPnl * 100) / 100,
7733
+ execution_edge: Math.round(executionEdge * 100) / 100,
7734
+ gross_attribution_flow: Math.round(grossAttributionFlow * 100) / 100,
7735
+ attribution,
7736
+ precision,
7737
+ ...hints.length > 0 ? { hint: hints.join(" ") } : {}
7738
+ };
7739
+ }
7740
+ async function handleInstanceMode(block_id, trade_index, skip_quotes, detailed, baseDir, injectedConn) {
7741
+ const conn = injectedConn ?? await getConnection(baseDir);
7742
+ const tradeResult = await conn.runAndReadAll(
7743
+ `SELECT date_opened, date_closed, pl FROM trades.trade_data
7744
+ WHERE block_id = $1
7745
+ ORDER BY date_opened, rowid
7746
+ LIMIT 1 OFFSET $2`,
7747
+ [block_id, trade_index]
7748
+ );
7749
+ const tradeRows = tradeResult.getRows();
7750
+ if (tradeRows.length === 0) {
7751
+ throw new Error(`Trade index ${trade_index} not found in block "${block_id}"`);
7752
+ }
7753
+ const tradeDate = String(tradeRows[0][0] ?? "");
7754
+ const closeDate = String(tradeRows[0][1] ?? tradeDate);
7755
+ const actualPnl = Number(tradeRows[0][2] ?? 0);
7756
+ const result = await handleDecomposeGreeks(
7757
+ {
7758
+ block_id,
7759
+ trade_index,
7760
+ format: "full",
7761
+ multiplier: 100,
7762
+ skip_quotes
7763
+ },
7764
+ baseDir,
7765
+ injectedConn
7766
+ );
7767
+ const stepCount = result.stepCount;
7768
+ const datesResult = await conn.runAndReadAll(
7769
+ `SELECT DISTINCT date FROM market.intraday
7770
+ WHERE date >= $1 AND date <= $2
7771
+ ORDER BY date`,
7772
+ [tradeDate, closeDate]
7773
+ );
7774
+ const tradingDates = datesResult.getRows().map((r) => String(r[0]));
7775
+ const getStepDate = (i) => i < tradingDates.length ? tradingDates[i] : `day-${i}`;
7776
+ const factorSteps = /* @__PURE__ */ new Map();
7777
+ for (const f of result.factors) {
7778
+ factorSteps.set(f.factor, f.steps);
7779
+ }
7780
+ const steps = [];
7781
+ for (let i = 0; i <= stepCount; i++) {
7782
+ const entry = {
7783
+ date: getStepDate(i),
7784
+ delta: getStepValue(factorSteps, "delta", i, detailed ? 0 : factorSteps.get("charm")?.[i] ?? 0),
7785
+ gamma: getStepValue(factorSteps, "gamma", i, 0),
7786
+ theta: getStepValue(factorSteps, "theta", i, 0),
7787
+ vega: getStepValue(factorSteps, "vega", i, detailed ? 0 : factorSteps.get("vanna")?.[i] ?? 0),
7788
+ residual: getStepValue(factorSteps, "residual", i, 0)
7789
+ };
7790
+ if (factorSteps.has("time_and_vol")) {
7791
+ entry.time_and_vol = getStepValue(factorSteps, "time_and_vol", i, 0);
7792
+ }
7793
+ if (detailed) {
7794
+ entry.charm = factorSteps.get("charm")?.[i] ?? 0;
7795
+ entry.vanna = factorSteps.get("vanna")?.[i] ?? 0;
7796
+ }
7797
+ steps.push(entry);
7798
+ }
7799
+ const collapsed = collapseFactors(result.factors, detailed);
7800
+ const grossAttributionFlow = computeGrossAttributionFlow(collapsed);
7801
+ const attribution = computeAttribution(collapsed, actualPnl, grossAttributionFlow);
7802
+ const executionEdge = actualPnl - result.totalPnlChange;
7803
+ const filteredSteps = filterSparseSteps(steps);
7804
+ return {
7805
+ block_id,
7806
+ trade_index,
7807
+ trade_date: tradeDate,
7808
+ total_pnl: Math.round(actualPnl * 100) / 100,
7809
+ mark_total_pnl: Math.round(result.totalPnlChange * 100) / 100,
7810
+ execution_edge: Math.round(executionEdge * 100) / 100,
7811
+ gross_attribution_flow: Math.round(grossAttributionFlow * 100) / 100,
7812
+ steps: filteredSteps,
7813
+ attribution
7814
+ };
7815
+ }
7816
+ function filterSparseSteps(steps) {
7817
+ return steps.filter(
7818
+ (s) => s.delta !== 0 || s.gamma !== 0 || s.theta !== 0 || s.vega !== 0 || s.residual !== 0 || (s.time_and_vol ?? 0) !== 0 || (s.charm ?? 0) !== 0 || (s.vanna ?? 0) !== 0
7819
+ );
7820
+ }
7821
+ function getStepValue(factorSteps, factor, index, collapsedAddition) {
7822
+ return Math.round(((factorSteps.get(factor)?.[index] ?? 0) + collapsedAddition) * 100) / 100;
7823
+ }
6401
7824
  export {
6402
7825
  BACHELIER_DTE_THRESHOLD,
6403
7826
  CLOSE_KNOWN_FIELDS,
@@ -6425,6 +7848,7 @@ export {
6425
7848
  analyzeExitTriggers,
6426
7849
  analyzeExitTriggersSchema,
6427
7850
  analyzeStructureFitSchema,
7851
+ assessPrecision,
6428
7852
  bachelierDelta,
6429
7853
  bachelierGamma,
6430
7854
  bachelierPrice,
@@ -6436,6 +7860,7 @@ export {
6436
7860
  bsPrice,
6437
7861
  bsTheta,
6438
7862
  bsVega,
7863
+ buildEnrichmentPlan,
6439
7864
  buildFilterPredicate,
6440
7865
  buildLookaheadFreeQuery,
6441
7866
  buildOccTicker,
@@ -6447,10 +7872,13 @@ export {
6447
7872
  classifyTrendDirection,
6448
7873
  classifyVolRegime,
6449
7874
  closeConnection,
7875
+ collapseFactors,
6450
7876
  computeATR,
6451
7877
  computeAggregateStats,
7878
+ computeAttribution,
6452
7879
  computeConsecutiveDays,
6453
7880
  computeEMA,
7881
+ computeGrossAttributionFlow,
6454
7882
  computeIVP,
6455
7883
  computeIVR,
6456
7884
  computeIntradayTimingFields,
@@ -6472,13 +7900,18 @@ export {
6472
7900
  discoverCsvFiles,
6473
7901
  ensureProfilesSchema,
6474
7902
  evaluateTrigger,
7903
+ fetchBarsForLegsBulk,
6475
7904
  fetchBarsWithCache,
7905
+ fetchEntryBarsForCandidates,
6476
7906
  filterByDateRange,
6477
7907
  filterByStrategy,
6478
7908
  filterDailyLogsByDateRange,
7909
+ filterSparseSteps,
6479
7910
  findNearestTimestamp,
6480
7911
  fromMassiveTicker,
6481
7912
  getConnection,
7913
+ getDataTier,
7914
+ getGreeksAttributionSchema,
6482
7915
  getMarketImportMetadata,
6483
7916
  getOptionSnapshotSchema,
6484
7917
  getProfile,
@@ -6489,6 +7922,7 @@ export {
6489
7922
  handleBatchExitAnalysis,
6490
7923
  handleDecomposeGreeks,
6491
7924
  handleDeleteProfile,
7925
+ handleGetGreeksAttribution,
6492
7926
  handleGetOptionSnapshot,
6493
7927
  handleGetStrategyProfile,
6494
7928
  handleListProfiles,
@@ -6513,17 +7947,20 @@ export {
6513
7947
  markPrice,
6514
7948
  massiveTimestampToETDate,
6515
7949
  massiveTimestampToETTime,
7950
+ mergeQuoteBars,
6516
7951
  nanosToETMinuteKey,
6517
7952
  parseLegsString,
6518
7953
  pdf,
6519
7954
  performTailRiskAnalysis,
6520
7955
  portfolioStructureMapSchema,
6521
7956
  profileStrategySchema,
7957
+ readCachedBars,
6522
7958
  regimeAllocationAdvisorSchema,
6523
7959
  replayTradeSchema,
6524
7960
  resolveOODateRange,
6525
7961
  runContextEnrichment,
6526
7962
  runEnrichment,
7963
+ shouldSkipEnrichment,
6527
7964
  solveIV,
6528
7965
  solveNormalIV,
6529
7966
  syncAllBlocks,