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.
- package/README.md +2 -2
- package/dist/{chunk-FIKAXL2L.js → chunk-37JFR6NF.js} +59 -2
- package/dist/chunk-37JFR6NF.js.map +1 -0
- package/dist/{sync-2TUYF36I.js → sync-H7L3WRTF.js} +2 -2
- package/dist/test-exports.js +2398 -961
- package/dist/test-exports.js.map +1 -1
- package/manifest.json +5 -5
- package/package.json +2 -2
- package/server/{chunk-IBEPOZZK.js → chunk-XNRLTNPB.js} +59 -2
- package/server/chunk-XNRLTNPB.js.map +1 -0
- package/server/index.js +2932 -1621
- package/server/index.js.map +1 -1
- package/server/{sync-37YJXJVW.js → sync-HI5PP6HM.js} +2 -2
- package/dist/chunk-FIKAXL2L.js.map +0 -1
- package/server/chunk-IBEPOZZK.js.map +0 -1
- /package/dist/{sync-2TUYF36I.js.map → sync-H7L3WRTF.js.map} +0 -0
- /package/server/{sync-37YJXJVW.js.map → sync-HI5PP6HM.js.map} +0 -0
package/dist/test-exports.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
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}×tamp.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
|
-
|
|
2099
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2130
|
-
|
|
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
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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/
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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
|
|
2255
|
-
}
|
|
2256
|
-
function _resetProvider() {
|
|
2257
|
-
_cached = null;
|
|
2454
|
+
return (bar.high + bar.low) / 2;
|
|
2258
2455
|
}
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
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
|
|
2479
|
+
return bestDiff <= toleranceSec / 60 ? sortedTimestamps[bestIdx] : void 0;
|
|
2283
2480
|
}
|
|
2284
|
-
function
|
|
2285
|
-
const
|
|
2286
|
-
if (
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
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
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
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 (
|
|
2314
|
-
return
|
|
2496
|
+
if (legsStr.includes("|")) {
|
|
2497
|
+
return parseOOLegs(legsStr);
|
|
2315
2498
|
}
|
|
2316
|
-
|
|
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
|
|
2319
|
-
const
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
-
|
|
2330
|
-
|
|
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
|
-
|
|
2333
|
-
|
|
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
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
const
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
let
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
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 (
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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
|
|
2684
|
+
return path2;
|
|
2391
2685
|
}
|
|
2392
|
-
|
|
2393
|
-
if (
|
|
2394
|
-
return {
|
|
2686
|
+
function computeReplayMfeMae(pnlPath) {
|
|
2687
|
+
if (pnlPath.length === 0) {
|
|
2688
|
+
return { mfe: 0, mae: 0, mfeTimestamp: "", maeTimestamp: "" };
|
|
2395
2689
|
}
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
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
|
-
|
|
2405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
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
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
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 (
|
|
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
|
-
|
|
2463
|
-
|
|
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
|
|
2480
|
-
const
|
|
2481
|
-
|
|
2482
|
-
|
|
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
|
-
`
|
|
2822
|
+
`ThetaTerminal JAR not found at ${config.jarPath}. Expected ${DEFAULT_JAR_NAME} in ${config.homeDir} or set THETADATA_JAR.`
|
|
2485
2823
|
);
|
|
2486
2824
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
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
|
-
|
|
2495
|
-
|
|
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
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
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
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
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
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
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
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
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
|
-
|
|
2539
|
-
const
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
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
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
const
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
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
|
-
|
|
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
|
|
2561
|
-
const
|
|
2562
|
-
|
|
2563
|
-
|
|
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
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
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
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
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
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
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
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
"
|
|
2608
|
-
"
|
|
2609
|
-
"
|
|
2610
|
-
"
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
var
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
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
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
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 (
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
-
|
|
2708
|
-
|
|
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 (
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
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
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
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
|
-
|
|
2761
|
-
|
|
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
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
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}
|
|
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,
|
|
3695
|
+
const { inserted, updated, skipped } = await insertMappedRows(conn, targetTable, mappedRows);
|
|
2802
3696
|
await upsertMarketImportMetadata(conn, {
|
|
2803
|
-
source: `
|
|
3697
|
+
source: `import_market_csv:${filePath}`,
|
|
2804
3698
|
ticker: normalizedTicker,
|
|
2805
|
-
target_table:
|
|
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
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
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
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
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,
|