tradeblocks-mcp 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,11 @@ import { getSofrRateByKey } from "@tradeblocks/lib";
2
2
  import type { ContractRow } from "./chain-loader.ts";
3
3
  import { computeLegGreeks } from "./black-scholes.ts";
4
4
  import { computeFractionalDte } from "./option-time.ts";
5
+ import {
6
+ getSharedIvSolverPool,
7
+ type IvSolveColumns,
8
+ type IvSolverPool,
9
+ } from "./iv-solver-pool.ts";
5
10
 
6
11
  export type QuoteGreeksSource = "massive" | "thetadata" | "computed";
7
12
  export type QuoteGreeksMode = "auto" | "provider" | "compute";
@@ -58,6 +63,21 @@ function isFiniteNumber(value: unknown): value is number {
58
63
  return typeof value === "number" && Number.isFinite(value);
59
64
  }
60
65
 
66
+ // `getSofrRateByKey` does a binary search over the rate table on every call,
67
+ // but within a quote-ingest batch the date is constant (one partition = one
68
+ // trading day) and across batches a date repeats for every row of that day.
69
+ // Memoize the lookup by date key — the function is a pure deterministic map
70
+ // from key → rate, so the cached value is identical to a fresh lookup.
71
+ const sofrRateByDateKey = new Map<string, number>();
72
+
73
+ function memoizedSofrRate(dateKey: string): number {
74
+ const cached = sofrRateByDateKey.get(dateKey);
75
+ if (cached !== undefined) return cached;
76
+ const rate = getSofrRateByKey(dateKey);
77
+ sofrRateByDateKey.set(dateKey, rate);
78
+ return rate;
79
+ }
80
+
61
81
  export function hasQuoteGreeks(row: QuoteGreekFields): boolean {
62
82
  return isFiniteNumber(row.delta ?? null)
63
83
  && isFiniteNumber(row.gamma ?? null)
@@ -123,7 +143,7 @@ export function computeQuoteGreeks(params: {
123
143
  if (!(optionPrice > 0) || !(underlyingPrice > 0) || !(strike > 0)) return null;
124
144
  const dte = computeFractionalDte(date, time.slice(0, 5), expiration);
125
145
  if (!(dte >= 0)) return null;
126
- const riskFreeRate = getSofrRateByKey(date) / 100;
146
+ const riskFreeRate = memoizedSofrRate(date) / 100;
127
147
  const result = computeLegGreeks(
128
148
  optionPrice,
129
149
  underlyingPrice,
@@ -152,7 +172,7 @@ export function buildUnderlyingPriceKey(date: string, time: string): string {
152
172
  return `${date}|${time.slice(0, 5)}`;
153
173
  }
154
174
 
155
- export function applyQuoteGreeks<T extends QuoteGreekFields>(params: {
175
+ export interface ApplyQuoteGreeksParams<T extends QuoteGreekFields> {
156
176
  rows: T[];
157
177
  getDate: (row: T) => string;
158
178
  getTime: (row: T) => string;
@@ -161,19 +181,10 @@ export function applyQuoteGreeks<T extends QuoteGreekFields>(params: {
161
181
  getUnderlyingPrice: (date: string, time: string) => number | undefined;
162
182
  mode?: QuoteGreeksMode;
163
183
  defaultProviderSource?: Exclude<QuoteGreeksSource, "computed">;
164
- }): QuoteGreeksStats {
165
- const {
166
- rows,
167
- getDate,
168
- getTime,
169
- getMid,
170
- getContractMeta,
171
- getUnderlyingPrice,
172
- mode = "auto",
173
- defaultProviderSource,
174
- } = params;
184
+ }
175
185
 
176
- const stats: QuoteGreeksStats = {
186
+ function emptyStats(): QuoteGreeksStats {
187
+ return {
177
188
  rowsVisited: 0,
178
189
  existingGreeksRows: 0,
179
190
  computedRows: 0,
@@ -182,61 +193,245 @@ export function applyQuoteGreeks<T extends QuoteGreekFields>(params: {
182
193
  mathFailedRows: 0,
183
194
  unresolvedRows: 0,
184
195
  };
196
+ }
185
197
 
186
- for (const row of rows) {
187
- stats.rowsVisited++;
188
- if (mode !== "compute" && hasExistingQuoteGreeks(row)) {
189
- normalizeExistingQuoteGreeks(row, defaultProviderSource);
190
- stats.existingGreeksRows++;
191
- continue;
192
- }
198
+ /**
199
+ * Outcome of resolving a single row up to (but not including) the IV solve.
200
+ * Skip kinds are fully accounted in stats and never solved. `kind: "compute"`
201
+ * carries the flat solve inputs directly (no nested object — kept flat so the
202
+ * parallel path can write them straight into typed-array columns). This is the
203
+ * single source of truth for resolution, shared by the inline and parallel
204
+ * apply functions so they cannot diverge.
205
+ *
206
+ * `type` is 0 = call ("C"), 1 = put ("P") — the same encoding the worker uses.
207
+ */
208
+ type RowResolution =
209
+ | { kind: "existing" }
210
+ | { kind: "missingContract" }
211
+ | { kind: "providerUnresolved" }
212
+ | { kind: "missingUnderlying" }
213
+ | { kind: "mathFailed" }
214
+ | {
215
+ kind: "compute";
216
+ optionPrice: number;
217
+ underlyingPrice: number;
218
+ strike: number;
219
+ dte: number;
220
+ type: 0 | 1;
221
+ riskFreeRate: number;
222
+ };
223
+
224
+ function resolveQuoteGreeksRow<T extends QuoteGreekFields>(
225
+ row: T,
226
+ params: ApplyQuoteGreeksParams<T>,
227
+ defaultProviderSource: Exclude<QuoteGreeksSource, "computed"> | undefined,
228
+ mode: QuoteGreeksMode,
229
+ ): RowResolution {
230
+ if (mode !== "compute" && hasExistingQuoteGreeks(row)) {
231
+ normalizeExistingQuoteGreeks(row, defaultProviderSource);
232
+ return { kind: "existing" };
233
+ }
234
+
235
+ const meta = params.getContractMeta(row);
236
+ if (!meta) return { kind: "missingContract" };
237
+
238
+ if (mode === "provider") return { kind: "providerUnresolved" };
239
+
240
+ const date = params.getDate(row);
241
+ const time = params.getTime(row).slice(0, 5);
242
+ const underlyingPrice = params.getUnderlyingPrice(date, time);
243
+ if (!(underlyingPrice != null && underlyingPrice > 0)) {
244
+ return { kind: "missingUnderlying" };
245
+ }
246
+
247
+ // Same guards and rate convention as computeQuoteGreeks — kept in lockstep
248
+ // so the solve inputs are identical whether solved inline or on a worker.
249
+ const optionPrice = params.getMid(row);
250
+ if (!(optionPrice > 0) || !(meta.strike > 0)) return { kind: "mathFailed" };
251
+ const dte = computeFractionalDte(date, time, meta.expiration);
252
+ if (!(dte >= 0)) return { kind: "mathFailed" };
253
+ const riskFreeRate = memoizedSofrRate(date) / 100;
254
+
255
+ return {
256
+ kind: "compute",
257
+ optionPrice,
258
+ underlyingPrice,
259
+ strike: meta.strike,
260
+ dte,
261
+ type: meta.contract_type === "call" ? 0 : 1,
262
+ riskFreeRate,
263
+ };
264
+ }
193
265
 
194
- const meta = getContractMeta(row);
195
- if (!meta) {
266
+ function writeComputedGreeks<T extends QuoteGreekFields>(
267
+ row: T,
268
+ greeks: { delta: number; gamma: number; theta: number; vega: number; iv: number },
269
+ riskFreeRate: number,
270
+ ): void {
271
+ row.delta = greeks.delta;
272
+ row.gamma = greeks.gamma;
273
+ row.theta = greeks.theta;
274
+ row.vega = greeks.vega;
275
+ row.iv = greeks.iv;
276
+ row.greeks_source = "computed";
277
+ row.greeks_revision = OPTION_QUOTE_GREEKS_REVISION;
278
+ row.rate_type = OPTION_QUOTE_GREEKS_RATE_TYPE;
279
+ row.rate_value = riskFreeRate;
280
+ row.gamma_source = OPTION_QUOTE_GREEKS_GAMMA_SOURCE;
281
+ }
282
+
283
+ function tallySkip(stats: QuoteGreeksStats, kind: RowResolution["kind"]): void {
284
+ switch (kind) {
285
+ case "existing":
286
+ stats.existingGreeksRows++;
287
+ return;
288
+ case "missingContract":
196
289
  stats.missingContractRows++;
197
290
  stats.unresolvedRows++;
291
+ return;
292
+ case "providerUnresolved":
293
+ stats.unresolvedRows++;
294
+ return;
295
+ case "missingUnderlying":
296
+ stats.missingUnderlyingRows++;
297
+ stats.unresolvedRows++;
298
+ return;
299
+ case "mathFailed":
300
+ stats.mathFailedRows++;
301
+ stats.unresolvedRows++;
302
+ return;
303
+ case "compute":
304
+ return;
305
+ }
306
+ }
307
+
308
+ export function applyQuoteGreeks<T extends QuoteGreekFields>(
309
+ params: ApplyQuoteGreeksParams<T>,
310
+ ): QuoteGreeksStats {
311
+ const { rows, mode = "auto", defaultProviderSource } = params;
312
+ const stats = emptyStats();
313
+
314
+ for (const row of rows) {
315
+ stats.rowsVisited++;
316
+ const resolution = resolveQuoteGreeksRow(row, params, defaultProviderSource, mode);
317
+ if (resolution.kind !== "compute") {
318
+ tallySkip(stats, resolution.kind);
198
319
  continue;
199
320
  }
200
321
 
201
- if (mode === "provider") {
322
+ const result = computeLegGreeks(
323
+ resolution.optionPrice,
324
+ resolution.underlyingPrice,
325
+ resolution.strike,
326
+ resolution.dte,
327
+ resolution.type === 0 ? "C" : "P",
328
+ resolution.riskFreeRate,
329
+ OPTION_QUOTE_GREEKS_DIVIDEND_YIELD,
330
+ );
331
+ if (!hasQuoteGreeks(result)) {
332
+ stats.mathFailedRows++;
202
333
  stats.unresolvedRows++;
203
334
  continue;
204
335
  }
336
+ writeComputedGreeks(
337
+ row,
338
+ {
339
+ delta: result.delta as number,
340
+ gamma: result.gamma as number,
341
+ theta: result.theta as number,
342
+ vega: result.vega as number,
343
+ iv: result.iv as number,
344
+ },
345
+ resolution.riskFreeRate,
346
+ );
347
+ stats.computedRows++;
348
+ }
205
349
 
206
- const date = getDate(row);
207
- const time = getTime(row).slice(0, 5);
208
- const underlyingPrice = getUnderlyingPrice(date, time);
209
- if (!(underlyingPrice != null && underlyingPrice > 0)) {
210
- stats.missingUnderlyingRows++;
211
- stats.unresolvedRows++;
350
+ return stats;
351
+ }
352
+
353
+ /**
354
+ * Parallel sibling of `applyQuoteGreeks`. Resolves every row on the calling
355
+ * thread using the exact same `resolveQuoteGreeksRow` logic, then fans the
356
+ * CPU-bound IV solve out across a worker pool. Greeks and provenance written
357
+ * back are bit-identical to the inline path (the pool runs the same
358
+ * `computeLegGreeks`); only the location of the solve loop changes.
359
+ *
360
+ * The pool degrades to inline automatically for small batches / single-core
361
+ * hosts (see iv-solver-pool.ts), so this is safe to call unconditionally.
362
+ */
363
+ export async function applyQuoteGreeksParallel<T extends QuoteGreekFields>(
364
+ params: ApplyQuoteGreeksParams<T> & { pool?: IvSolverPool },
365
+ ): Promise<QuoteGreeksStats> {
366
+ const { rows, mode = "auto", defaultProviderSource } = params;
367
+ const stats = emptyStats();
368
+ const pool = params.pool ?? getSharedIvSolverPool();
369
+ const n = rows.length;
370
+
371
+ // Resolve directly into the solve columns — no intermediate object array.
372
+ // Sized to the row count (the compute subset is <= n); the unused tail is
373
+ // dropped via `count` before dispatch.
374
+ const optionPrice = new Float64Array(n);
375
+ const underlyingPrice = new Float64Array(n);
376
+ const strike = new Float64Array(n);
377
+ const dte = new Float64Array(n);
378
+ const riskFreeRate = new Float64Array(n);
379
+ const dividendYield = new Float64Array(n);
380
+ const type = new Uint8Array(n);
381
+ // Maps the j-th compute job back to its source row index, so results land on
382
+ // the right row.
383
+ const jobRowIndex = new Int32Array(n);
384
+
385
+ let count = 0;
386
+ for (let i = 0; i < n; i++) {
387
+ stats.rowsVisited++;
388
+ const resolution = resolveQuoteGreeksRow(rows[i], params, defaultProviderSource, mode);
389
+ if (resolution.kind !== "compute") {
390
+ tallySkip(stats, resolution.kind);
212
391
  continue;
213
392
  }
393
+ optionPrice[count] = resolution.optionPrice;
394
+ underlyingPrice[count] = resolution.underlyingPrice;
395
+ strike[count] = resolution.strike;
396
+ dte[count] = resolution.dte;
397
+ riskFreeRate[count] = resolution.riskFreeRate;
398
+ dividendYield[count] = OPTION_QUOTE_GREEKS_DIVIDEND_YIELD;
399
+ type[count] = resolution.type;
400
+ jobRowIndex[count] = i;
401
+ count++;
402
+ }
403
+
404
+ if (count === 0) return stats;
214
405
 
215
- const greeks = computeQuoteGreeks({
216
- optionPrice: getMid(row),
217
- underlyingPrice,
218
- strike: meta.strike,
219
- date,
220
- time,
221
- expiration: meta.expiration,
222
- contractType: meta.contract_type,
223
- });
224
- if (!greeks) {
406
+ const columns: IvSolveColumns = {
407
+ count,
408
+ optionPrice: optionPrice.subarray(0, count),
409
+ underlyingPrice: underlyingPrice.subarray(0, count),
410
+ strike: strike.subarray(0, count),
411
+ dte: dte.subarray(0, count),
412
+ riskFreeRate: riskFreeRate.subarray(0, count),
413
+ dividendYield: dividendYield.subarray(0, count),
414
+ type: type.subarray(0, count),
415
+ };
416
+
417
+ const result = await pool.solveColumns(columns);
418
+ for (let j = 0; j < count; j++) {
419
+ if (result.ok[j] !== 1) {
225
420
  stats.mathFailedRows++;
226
421
  stats.unresolvedRows++;
227
422
  continue;
228
423
  }
229
-
230
- row.delta = greeks.delta;
231
- row.gamma = greeks.gamma;
232
- row.theta = greeks.theta;
233
- row.vega = greeks.vega;
234
- row.iv = greeks.iv;
235
- row.greeks_source = greeks.greeks_source;
236
- row.greeks_revision = greeks.greeks_revision;
237
- row.rate_type = greeks.rate_type;
238
- row.rate_value = greeks.rate_value;
239
- row.gamma_source = greeks.gamma_source;
424
+ writeComputedGreeks(
425
+ rows[jobRowIndex[j]],
426
+ {
427
+ delta: result.delta[j],
428
+ gamma: result.gamma[j],
429
+ theta: result.theta[j],
430
+ vega: result.vega[j],
431
+ iv: result.iv[j],
432
+ },
433
+ riskFreeRate[j],
434
+ );
240
435
  stats.computedRows++;
241
436
  }
242
437
 
@@ -38,6 +38,21 @@ const PRICE_TYPE_FACTORS = [
38
38
 
39
39
  const ET_MINUTE_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
40
40
 
41
+ // Constructing an Intl.DateTimeFormat is expensive (it builds an ICU formatter)
42
+ // and the options are constant. Build it once at module load instead of per
43
+ // call — this function runs once per decoded quote row, so a per-row construct
44
+ // dominated the decode cost on dense chains.
45
+ const ET_MINUTE_FORMAT = new Intl.DateTimeFormat("en-CA", {
46
+ timeZone: "America/New_York",
47
+ year: "numeric",
48
+ month: "2-digit",
49
+ day: "2-digit",
50
+ hour: "2-digit",
51
+ minute: "2-digit",
52
+ hour12: false,
53
+ hourCycle: "h23",
54
+ });
55
+
41
56
  export function thetaPriceToNumber(price: ThetaPriceLike): number {
42
57
  return price.type === 0 ? Number.NaN : price.value * PRICE_TYPE_FACTORS[price.type];
43
58
  }
@@ -45,16 +60,7 @@ export function thetaPriceToNumber(price: ThetaPriceLike): number {
45
60
  export function thetaTimestampToEtMinute(value: string | number | Date): string {
46
61
  if (typeof value === "string" && ET_MINUTE_PATTERN.test(value)) return value;
47
62
  const date = value instanceof Date ? value : new Date(value);
48
- const parts = new Intl.DateTimeFormat("en-CA", {
49
- timeZone: "America/New_York",
50
- year: "numeric",
51
- month: "2-digit",
52
- day: "2-digit",
53
- hour: "2-digit",
54
- minute: "2-digit",
55
- hour12: false,
56
- hourCycle: "h23",
57
- }).formatToParts(date);
63
+ const parts = ET_MINUTE_FORMAT.formatToParts(date);
58
64
  const get = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
59
65
  return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}`;
60
66
  }