tradeblocks-mcp 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import {
9
9
  syncAllBlocks,
10
10
  syncBlock,
11
11
  upsertSyncMetadata
12
- } from "./chunk-YIWLVX7Z.js";
12
+ } from "./chunk-5JTQEV7P.js";
13
13
  import "./chunk-76OEJGQX.js";
14
14
  import "./chunk-NLYLE5C6.js";
15
15
  import "./chunk-24O66TM5.js";
@@ -27,4 +27,4 @@ export {
27
27
  syncBlock,
28
28
  upsertSyncMetadata
29
29
  };
30
- //# sourceMappingURL=sync-2POG6KA6.js.map
30
+ //# sourceMappingURL=sync-336543PJ.js.map
@@ -8,14 +8,14 @@ import { z } from "zod";
8
8
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { loadBlock } from "../../utils/block-loader.ts";
10
10
  import { createToolOutput } from "../../utils/output-formatter.ts";
11
- import { rankPredictiveTradeFields } from "@tradeblocks/lib";
11
+ import { holmAdjust, KendallPairWorkLimitError, rankPredictiveTradeFields } from "@tradeblocks/lib";
12
12
  import { filterByStrategy, filterByDateRange } from "../shared/filters.ts";
13
- import { enrichAnalysisTrades, getTradeFieldValue, type EnrichedTrade } from "./helpers.ts";
13
+ import { enrichAnalysisTrades, getTradeFieldValue } from "./helpers.ts";
14
14
  import { withSyncedBlock } from "../middleware/sync-middleware.ts";
15
15
  import { getConnection } from "../../db/connection.ts";
16
16
  import { getProfile } from "../../db/profile-schemas.ts";
17
17
  import { requireCoveredField } from "../../utils/trade-analysis-fields.ts";
18
- import { sweepThresholds } from "../../utils/threshold-sweep.ts";
18
+ import { analyzeFilterCurve } from "../../utils/filter-curve.ts";
19
19
  import { datasetAgeOutput, maxMissedSessionsSchema } from "../../db/dataset-schemas.ts";
20
20
 
21
21
  /**
@@ -27,10 +27,16 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
27
27
  "find_predictive_fields",
28
28
  {
29
29
  description:
30
- "Identify which trade entry conditions predict profitability by calculating Pearson correlations between all numeric fields and a target field (usually P/L). Returns fields ranked by predictive strength.",
30
+ "Identify which trade entry conditions predict profitability using Pearson (default), Spearman or Kendall correlation against a target field. Returns fields ranked by the chosen coefficient's strength.",
31
31
  inputSchema: z.object({
32
32
  blockId: z.string().describe("Block folder name"),
33
33
  maxMissedSessions: maxMissedSessionsSchema,
34
+ method: z
35
+ .enum(["pearson", "spearman", "kendall"])
36
+ .optional()
37
+ .describe(
38
+ "Correlation method; defaults to Pearson. Rank methods measure monotone relationships.",
39
+ ),
34
40
  strategy: z.string().optional().describe("Pre-filter by strategy name (case-insensitive)"),
35
41
  strategyName: z
36
42
  .string()
@@ -69,6 +75,7 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
69
75
  minSamples,
70
76
  includeCustomFields,
71
77
  maxMissedSessions,
78
+ method,
72
79
  }) => {
73
80
  try {
74
81
  const block = await loadBlock(baseDir, blockId);
@@ -112,12 +119,14 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
112
119
  minSamples,
113
120
  includeCustomFields,
114
121
  resolved.catalog.map(({ field }) => ({ field, label: field })),
122
+ method,
115
123
  );
116
124
  const summary = `Found ${fieldsWithData} predictive fields out of ${totalAnalyzed} analyzed`;
117
125
 
118
126
  const structuredData: Record<string, unknown> = {
119
127
  blockId,
120
128
  targetField,
129
+ ...(method ? { method } : {}),
121
130
  filters: {
122
131
  strategy: effectiveStrategy ?? null,
123
132
  strategyName: strategyName ?? null,
@@ -167,6 +176,17 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
167
176
 
168
177
  return createToolOutput(summary, structuredData);
169
178
  } catch (error) {
179
+ if (error instanceof KendallPairWorkLimitError)
180
+ return createToolOutput("Kendall analysis refused: kendall_pair_work_limit", {
181
+ blockId,
182
+ method,
183
+ status: "refused",
184
+ reason: "kendall_pair_work_limit",
185
+ estimatedPairs: error.estimatedPairs,
186
+ pairLimit: error.pairLimit,
187
+ fieldCount: error.fieldCount,
188
+ tradeCount: error.tradeCount,
189
+ });
170
190
  return {
171
191
  content: [
172
192
  {
@@ -192,7 +212,28 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
192
212
  maxMissedSessions: maxMissedSessionsSchema,
193
213
  field: z
194
214
  .string()
195
- .describe("Field to sweep thresholds on (e.g., 'openingVix', 'durationHours')"),
215
+ .optional()
216
+ .describe("Single field to sweep; use field or fields, not both."),
217
+ fields: z
218
+ .array(z.string())
219
+ .min(1)
220
+ .max(10)
221
+ .optional()
222
+ .describe("Fields to compare with Holm-adjusted permutation p-values."),
223
+ objective: z
224
+ .enum(["totalPl", "winRate"])
225
+ .optional()
226
+ .describe(
227
+ "Opt into best-threshold and per-year analysis; defaults to totalPl with fields or permutation.",
228
+ ),
229
+ permutation: z
230
+ .object({
231
+ iterations: z.number().int().min(1).max(499),
232
+ block: z.literal("month").default("month"),
233
+ seed: z.number().int().min(0).max(4294967295).default(42),
234
+ })
235
+ .optional()
236
+ .describe("Selection-adjusted calendar-month block permutation; at most 499 iterations."),
196
237
  mode: z
197
238
  .enum(["lt", "gt", "both"])
198
239
  .default("both")
@@ -221,6 +262,9 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
221
262
  async ({
222
263
  blockId,
223
264
  field,
265
+ fields,
266
+ objective,
267
+ permutation,
224
268
  mode,
225
269
  thresholds,
226
270
  percentileSteps,
@@ -251,206 +295,137 @@ export function registerPredictiveTools(server: McpServer, baseDir: string): voi
251
295
  // Enrich trades
252
296
  const resolved = await enrichAnalysisTrades(baseDir, trades, maxMissedSessions);
253
297
  const enrichedTrades = resolved.trades;
254
- if (
255
- resolved.catalog.some((entry) => entry.field === field) ||
256
- (field.includes(".") && !field.startsWith("custom.") && !field.startsWith("daily."))
257
- )
258
- requireCoveredField(field, resolved.catalog, resolved.values, resolved.datasetAge);
259
-
260
- // Extract field values
261
- const fieldValues: number[] = [];
262
- const tradesWithField: EnrichedTrade[] = [];
263
-
264
- for (const trade of enrichedTrades) {
265
- const value = getTradeFieldValue(trade, field);
266
- if (value !== null) {
267
- fieldValues.push(value);
268
- tradesWithField.push(trade);
269
- }
270
- }
271
-
272
- if (fieldValues.length === 0) {
273
- return {
274
- content: [
298
+ const names = fields ?? (field ? [field] : []);
299
+ if (!names.length || (fields && field))
300
+ throw new Error("Provide exactly one of field or fields.");
301
+ if (new Set(names).size !== names.length)
302
+ throw new Error("fields must not contain duplicates.");
303
+ if (permutation) {
304
+ if ((thresholds?.length ?? 0) > 100 || (percentileSteps?.length ?? 0) > 100)
305
+ return createToolOutput(
306
+ "Filter curve permutation refused: permutation_candidate_limit",
275
307
  {
276
- type: "text",
277
- text: `Field "${field}" has no valid numeric values in the filtered trades.`,
308
+ blockId,
309
+ status: "refused",
310
+ reason: "permutation_candidate_limit",
311
+ candidateLimit: 100,
312
+ thresholdCount: thresholds?.length ?? 0,
313
+ percentileCount: percentileSteps?.length ?? 7,
314
+ requestedFields: names,
315
+ permutation: { ...permutation, status: "refused" },
278
316
  },
279
- ],
280
- };
281
- }
282
-
283
- // Calculate baseline metrics (all trades with valid field values)
284
- const baselinePls = tradesWithField.map((t) => t.pl);
285
- const baselineTotalPl = baselinePls.reduce((a, b) => a + b, 0);
286
- const baselineAvgPl = baselineTotalPl / tradesWithField.length;
287
- const baselineWinners = tradesWithField.filter((t) => t.pl > 0).length;
288
- const baselineWinRate = baselineWinners / tradesWithField.length;
289
-
290
- const baseline = {
291
- count: tradesWithField.length,
292
- winRate: Math.round(baselineWinRate * 10000) / 10000,
293
- avgPl: Math.round(baselineAvgPl * 100) / 100,
294
- totalPl: Math.round(baselineTotalPl * 100) / 100,
295
- uncoveredCount: trades.length - tradesWithField.length,
296
- uncoveredTotalPl:
297
- Math.round(
298
- enrichedTrades
299
- .filter((trade) => getTradeFieldValue(trade, field) === null)
300
- .reduce((sum, trade) => sum + trade.pl, 0) * 100,
301
- ) / 100,
302
- };
303
-
304
- const sweeps = sweepThresholds(
305
- tradesWithField,
306
- (trade) => getTradeFieldValue(trade, field),
307
- thresholds,
308
- percentileSteps,
309
- );
310
- const thresholdsToTest = sweeps.map(({ threshold }) => threshold);
311
-
312
- // Minimum sample size for reliable statistics
313
- const MIN_SAMPLE_SIZE = 30;
314
-
315
- // Helper to calculate metrics for a filtered set of trades
316
- function calculateMetrics(filteredTrades: EnrichedTrade[]): {
317
- count: number;
318
- percentOfTrades: number;
319
- winRate: number;
320
- avgPl: number;
321
- totalPl: number;
322
- winRateDelta: number;
323
- avgPlDelta: number;
324
- lowSampleWarning?: string;
325
- } {
326
- if (filteredTrades.length === 0) {
317
+ );
318
+ const candidates = thresholds?.length || percentileSteps?.length || 7;
319
+ const estimates = names.map((name) => {
320
+ const coveredCount = enrichedTrades.reduce(
321
+ (count, trade) => count + (getTradeFieldValue(trade, name) !== null ? 1 : 0),
322
+ 0,
323
+ );
324
+ // Include sorting values/candidates and filtering/scoring both directions on every sweep.
325
+ const sweepWork =
326
+ coveredCount * Math.ceil(Math.log2(Math.max(2, coveredCount))) +
327
+ 6 * coveredCount * candidates +
328
+ 3 * candidates * Math.ceil(Math.log2(Math.max(2, 2 * candidates)));
327
329
  return {
328
- count: 0,
329
- percentOfTrades: 0,
330
- winRate: 0,
331
- avgPl: 0,
332
- totalPl: 0,
333
- winRateDelta: -baseline.winRate,
334
- avgPlDelta: -baseline.avgPl,
330
+ field: name,
331
+ coveredCount,
332
+ estimatedWork: (permutation.iterations + 2) * sweepWork,
335
333
  };
336
- }
337
-
338
- const pls = filteredTrades.map((t) => t.pl);
339
- const totalPl = pls.reduce((a, b) => a + b, 0);
340
- const avgPl = totalPl / filteredTrades.length;
341
- const winners = filteredTrades.filter((t) => t.pl > 0).length;
342
- const winRate = winners / filteredTrades.length;
343
- const percentOfTrades = (filteredTrades.length / tradesWithField.length) * 100;
344
-
345
- const result: ReturnType<typeof calculateMetrics> = {
346
- count: filteredTrades.length,
347
- percentOfTrades: Math.round(percentOfTrades * 100) / 100,
348
- winRate: Math.round(winRate * 10000) / 10000,
349
- avgPl: Math.round(avgPl * 100) / 100,
350
- totalPl: Math.round(totalPl * 100) / 100,
351
- winRateDelta: Math.round((winRate - baseline.winRate) * 10000) / 10000,
352
- avgPlDelta: Math.round((avgPl - baseline.avgPl) * 100) / 100,
353
- };
354
-
355
- // Add warning for small sample sizes
356
- if (filteredTrades.length < MIN_SAMPLE_SIZE && filteredTrades.length > 0) {
357
- result.lowSampleWarning = `${filteredTrades.length} trades may be insufficient for reliable statistics (recommend >= ${MIN_SAMPLE_SIZE})`;
358
- }
359
-
360
- return result;
361
- }
362
-
363
- // Analyze each threshold
364
- interface ThresholdResult {
365
- threshold: number;
366
- lt?: ReturnType<typeof calculateMetrics>;
367
- gt?: ReturnType<typeof calculateMetrics>;
334
+ });
335
+ const estimatedWork = estimates.reduce((sum, field) => sum + field.estimatedWork, 0);
336
+ const overField = estimates.find((field) => field.estimatedWork > 30_000_000);
337
+ if (overField || estimatedWork > 90_000_000)
338
+ return createToolOutput("Filter curve permutation refused by work admission", {
339
+ blockId,
340
+ status: "refused",
341
+ reason: overField ? "permutation_work_limit" : "permutation_request_work_limit",
342
+ estimatedWork: overField ? overField.estimatedWork : estimatedWork,
343
+ workLimit: overField ? 30_000_000 : 90_000_000,
344
+ permutation: { ...permutation, status: "refused" },
345
+ fields: estimates,
346
+ });
368
347
  }
369
-
370
- const thresholdResults: ThresholdResult[] = [];
371
-
372
- for (const sweep of sweeps) {
373
- const result: ThresholdResult = { threshold: sweep.threshold };
374
- if (mode === "lt" || mode === "both") result.lt = calculateMetrics(sweep.lt);
375
- if (mode === "gt" || mode === "both") result.gt = calculateMetrics(sweep.gt);
376
- thresholdResults.push(result);
377
- }
378
-
379
- // Identify sweet spots
380
- interface SweetSpot {
381
- threshold: number;
382
- direction: "lt" | "gt";
383
- winRateDelta: number;
384
- avgPlDelta: number;
385
- percentOfTrades: number;
386
- score: number;
387
- }
388
-
389
- const sweetSpots: SweetSpot[] = [];
390
-
391
- for (const result of thresholdResults) {
392
- // Check lt direction
393
- if (result.lt) {
394
- const { winRateDelta, avgPlDelta, percentOfTrades } = result.lt;
395
- if (winRateDelta > 0 && avgPlDelta > 0 && percentOfTrades >= 20) {
396
- const score = winRateDelta * avgPlDelta;
397
- sweetSpots.push({
398
- threshold: result.threshold,
399
- direction: "lt",
400
- winRateDelta,
401
- avgPlDelta,
402
- percentOfTrades,
403
- score: Math.round(score * 10000) / 10000,
404
- });
405
- }
348
+ const results = names.map((name) => {
349
+ try {
350
+ if (
351
+ resolved.catalog.some((entry) => entry.field === name) ||
352
+ (name.includes(".") && !name.startsWith("custom.") && !name.startsWith("daily."))
353
+ )
354
+ requireCoveredField(name, resolved.catalog, resolved.values, resolved.datasetAge);
355
+ if (!enrichedTrades.some((trade) => getTradeFieldValue(trade, name) !== null))
356
+ return {
357
+ field: name,
358
+ reason: "no_covered_trades",
359
+ error: `Field "${name}" has no valid numeric values in the filtered trades.`,
360
+ };
361
+ const { analysis, ...curve } = analyzeFilterCurve(
362
+ enrichedTrades,
363
+ (trade) => getTradeFieldValue(trade, name),
364
+ {
365
+ mode,
366
+ thresholds,
367
+ percentileSteps,
368
+ objective: objective ?? (fields || permutation ? "totalPl" : undefined),
369
+ permutation,
370
+ },
371
+ );
372
+ return {
373
+ field: name,
374
+ ...datasetAgeOutput(resolved.datasetAge, [name]),
375
+ ...curve,
376
+ ...analysis,
377
+ };
378
+ } catch (error) {
379
+ if (!fields) throw error;
380
+ return { field: name, reason: "field_unavailable", error: (error as Error).message };
406
381
  }
407
-
408
- // Check gt direction
409
- if (result.gt) {
410
- const { winRateDelta, avgPlDelta, percentOfTrades } = result.gt;
411
- if (winRateDelta > 0 && avgPlDelta > 0 && percentOfTrades >= 20) {
412
- const score = winRateDelta * avgPlDelta;
413
- sweetSpots.push({
414
- threshold: result.threshold,
415
- direction: "gt",
416
- winRateDelta,
417
- avgPlDelta,
418
- percentOfTrades,
419
- score: Math.round(score * 10000) / 10000,
382
+ });
383
+ if (permutation) {
384
+ const tested = results.filter(
385
+ (result) => "permutation" in result && result.permutation?.pValue !== undefined,
386
+ );
387
+ // Refused fields stay in the requested family as conservative p=1, never disappear.
388
+ const adjusted = holmAdjust(
389
+ results.map((result) =>
390
+ "permutation" in result ? (result.permutation?.pValue ?? 1) : 1,
391
+ ),
392
+ );
393
+ for (const result of tested) {
394
+ if ("permutation" in result && result.permutation)
395
+ Object.assign(result.permutation, {
396
+ pValueHolm: adjusted[results.indexOf(result)],
397
+ adjustment:
398
+ names.length === 1 ? "none (one field)" : "Holm across requested fields",
420
399
  });
421
- }
422
400
  }
423
401
  }
424
-
425
- // Sort sweet spots by score (highest first)
426
- sweetSpots.sort((a, b) => b.score - a.score);
427
-
428
- // Build summary
429
- const summary = `Filter curve for ${field}: ${thresholdsToTest.length} thresholds analyzed | ${sweetSpots.length} sweet spots found`;
430
-
431
- const structuredData = {
432
- blockId,
433
- field,
434
- ...datasetAgeOutput(resolved.datasetAge, [field]),
435
- mode,
436
- filters: {
437
- strategy: strategy ?? null,
438
- startDate: startDate ?? null,
439
- endDate: endDate ?? null,
440
- },
441
- tradesAnalyzed: tradesWithField.length,
442
- baseline,
443
- thresholds: thresholdResults,
444
- sweetSpots,
445
- sweetSpotCriteria: {
446
- winRateDelta: "> 0 (win rate improves)",
447
- avgPlDelta: "> 0 (average P/L improves)",
448
- percentOfTrades: ">= 20% (retains meaningful sample)",
449
- scoreFormula: "winRateDelta * avgPlDelta (higher = better)",
450
- },
402
+ const filters = {
403
+ strategy: strategy ?? null,
404
+ startDate: startDate ?? null,
405
+ endDate: endDate ?? null,
451
406
  };
452
-
453
- return createToolOutput(summary, structuredData);
407
+ if (fields)
408
+ return createToolOutput(`Filter curves for ${names.length} fields`, {
409
+ blockId,
410
+ mode,
411
+ filters,
412
+ fields: results,
413
+ });
414
+ const result = results[0];
415
+ if (result.error !== undefined)
416
+ return { content: [{ type: "text", text: result.error }] };
417
+ const { field: resultField, ...curve } = result;
418
+ return createToolOutput(
419
+ `Filter curve for ${field}: ${result.thresholds.length} thresholds analyzed | ${result.sweetSpots.length} sweet spots found`,
420
+ {
421
+ blockId,
422
+ field: resultField,
423
+ ...datasetAgeOutput(resolved.datasetAge, [resultField]),
424
+ mode,
425
+ filters,
426
+ ...curve,
427
+ },
428
+ );
454
429
  } catch (error) {
455
430
  return {
456
431
  content: [