tradeblocks-mcp 4.2.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.
- package/README.md +72 -1
- package/dist/{chunk-YRD62LDE.js → chunk-35RVWZGT.js} +4 -1
- package/dist/{chunk-4UU7IT2A.js.map → chunk-35RVWZGT.js.map} +1 -1
- package/dist/{chunk-62GCLMQA.js → chunk-CZNPQ574.js} +6 -9
- package/dist/chunk-CZNPQ574.js.map +1 -0
- package/dist/{chunk-4UU7IT2A.js → chunk-ENXBPJ7Y.js} +148 -6
- package/dist/chunk-ENXBPJ7Y.js.map +1 -0
- package/dist/{daily-logs-store-PXB53WEK.js → daily-logs-store-FZRIGYUX.js} +2 -2
- package/dist/{daily-logs-store-65YHFJ5F.js → daily-logs-store-MEZ5S3BR.js} +2 -2
- package/dist/market/provenance/index.js +1 -4
- package/dist/market/provenance/index.js.map +1 -1
- package/dist/{sync-JL7OPLCT.js → sync-P6QUZFSF.js} +3 -3
- package/dist/test-exports.js +320 -279
- package/dist/test-exports.js.map +1 -1
- package/dist/{trades-store-UGZ62A7N.js → trades-store-3TOLBL2W.js} +2 -2
- package/dist/{trades-store-MOFJHRC2.js → trades-store-PNLPRYRJ.js} +2 -2
- package/package.json +1 -1
- package/server/{chunk-J4GWE6JR.js → chunk-5JTQEV7P.js} +307 -255
- package/server/chunk-5JTQEV7P.js.map +1 -0
- package/server/{chunk-7N6HBOAY.js → chunk-PDSUY2X2.js} +148 -6
- package/server/chunk-PDSUY2X2.js.map +1 -0
- package/server/{daily-logs-store-YAXASQN4.js → daily-logs-store-LRVO7COL.js} +2 -2
- package/server/index.js +1326 -928
- package/server/index.js.map +1 -1
- package/server/{sync-ZV3QEGMO.js → sync-336543PJ.js} +3 -3
- package/server/{trades-store-MF7YYLCH.js → trades-store-MXUZLSF5.js} +2 -2
- package/src/db/dataset-schemas.ts +97 -23
- package/src/tools/market-data.ts +327 -245
- package/src/tools/profile-analysis.ts +18 -4
- package/src/tools/reports/fields.ts +16 -11
- package/src/tools/reports/helpers.ts +6 -2
- package/src/tools/reports/predictive.ts +177 -191
- package/src/tools/shared/field-analysis.ts +57 -14
- package/src/tools/shared/filter-objective.ts +73 -0
- package/src/utils/filter-curve.ts +257 -0
- package/src/utils/trade-analysis-fields.ts +19 -4
- package/dist/chunk-62GCLMQA.js.map +0 -1
- package/dist/chunk-YRD62LDE.js.map +0 -1
- package/server/chunk-7N6HBOAY.js.map +0 -1
- package/server/chunk-J4GWE6JR.js.map +0 -1
- /package/dist/{daily-logs-store-65YHFJ5F.js.map → daily-logs-store-FZRIGYUX.js.map} +0 -0
- /package/dist/{daily-logs-store-PXB53WEK.js.map → daily-logs-store-MEZ5S3BR.js.map} +0 -0
- /package/dist/{sync-JL7OPLCT.js.map → sync-P6QUZFSF.js.map} +0 -0
- /package/dist/{trades-store-MOFJHRC2.js.map → trades-store-3TOLBL2W.js.map} +0 -0
- /package/dist/{trades-store-UGZ62A7N.js.map → trades-store-PNLPRYRJ.js.map} +0 -0
- /package/server/{daily-logs-store-YAXASQN4.js.map → daily-logs-store-LRVO7COL.js.map} +0 -0
- /package/server/{sync-ZV3QEGMO.js.map → sync-336543PJ.js.map} +0 -0
- /package/server/{trades-store-MF7YYLCH.js.map → trades-store-MXUZLSF5.js.map} +0 -0
|
@@ -4,35 +4,63 @@ import { sweepThresholds } from "../../utils/threshold-sweep.ts";
|
|
|
4
4
|
import { computeSliceStats } from "../../utils/analysis-stats.ts";
|
|
5
5
|
import { createToolOutput } from "../../utils/output-formatter.ts";
|
|
6
6
|
import { datasetAgeOutput } from "../../db/dataset-schemas.ts";
|
|
7
|
+
import {
|
|
8
|
+
suggestionEvidence,
|
|
9
|
+
rankSuggestions,
|
|
10
|
+
objectiveOutput,
|
|
11
|
+
type FilterObjective,
|
|
12
|
+
type SuggestionEvidence,
|
|
13
|
+
} from "./filter-objective.ts";
|
|
7
14
|
|
|
8
15
|
export async function suggestFieldFilters(
|
|
9
16
|
baseDir: string,
|
|
10
17
|
blockId: string,
|
|
11
18
|
trades: Trade[],
|
|
12
19
|
fields: string[],
|
|
13
|
-
minImprovementPct: number,
|
|
20
|
+
minImprovementPct: number | undefined,
|
|
21
|
+
objective: FilterObjective,
|
|
22
|
+
maxMissedSessions?: number,
|
|
14
23
|
) {
|
|
15
|
-
const { catalog, values, datasetAge } = await resolveAnalysisFields(
|
|
16
|
-
|
|
24
|
+
const { catalog, values, datasetAge } = await resolveAnalysisFields(
|
|
25
|
+
baseDir,
|
|
26
|
+
trades,
|
|
27
|
+
maxMissedSessions,
|
|
28
|
+
);
|
|
29
|
+
const suggestions: (SuggestionEvidence & {
|
|
17
30
|
filter: string;
|
|
18
31
|
condition: { field: string; operator: string; value: number | string; lagged: boolean };
|
|
19
|
-
tradesRemoved: number;
|
|
20
32
|
winnersRemoved: number;
|
|
21
33
|
losersRemoved: number;
|
|
22
34
|
newWinRate: number;
|
|
23
35
|
newTotalPl: number;
|
|
24
36
|
winRateDelta: number;
|
|
25
37
|
plDelta: number;
|
|
26
|
-
|
|
27
|
-
}[] = [];
|
|
38
|
+
})[] = [];
|
|
28
39
|
const coverage: Record<string, { covered: number; uncovered: number }> = {};
|
|
40
|
+
const fieldBaselines: Record<
|
|
41
|
+
string,
|
|
42
|
+
{
|
|
43
|
+
count: number;
|
|
44
|
+
totalPl: number;
|
|
45
|
+
uncoveredCount: number;
|
|
46
|
+
uncoveredTotalPl: number;
|
|
47
|
+
}
|
|
48
|
+
> = {};
|
|
29
49
|
for (const field of fields) {
|
|
30
|
-
requireCoveredField(field, catalog, values);
|
|
50
|
+
requireCoveredField(field, catalog, values, datasetAge);
|
|
31
51
|
const pool = trades
|
|
32
52
|
.map((trade, i) => ({ trade, value: values[i][field] }))
|
|
33
53
|
.filter((item) => item.value !== undefined);
|
|
34
54
|
coverage[field] = { covered: pool.length, uncovered: trades.length - pool.length };
|
|
35
55
|
const baseline = computeSliceStats(pool.map(({ trade }) => trade.pl));
|
|
56
|
+
fieldBaselines[field] = {
|
|
57
|
+
count: pool.length,
|
|
58
|
+
totalPl: baseline.totalPl,
|
|
59
|
+
uncoveredCount: trades.length - pool.length,
|
|
60
|
+
uncoveredTotalPl: computeSliceStats(
|
|
61
|
+
trades.filter((_, i) => values[i][field] === undefined).map((trade) => trade.pl),
|
|
62
|
+
).totalPl,
|
|
63
|
+
};
|
|
36
64
|
const entry = catalog.find((entry) => entry.field === field)!;
|
|
37
65
|
const candidates =
|
|
38
66
|
entry.type === "numeric"
|
|
@@ -53,7 +81,15 @@ export async function suggestFieldFilters(
|
|
|
53
81
|
if (!kept.length || !excluded.size) continue;
|
|
54
82
|
const stats = computeSliceStats(kept.map(({ trade }) => trade.pl));
|
|
55
83
|
const improvement = stats.winRate - baseline.winRate;
|
|
56
|
-
|
|
84
|
+
const evidence = suggestionEvidence(
|
|
85
|
+
objective,
|
|
86
|
+
improvement,
|
|
87
|
+
stats.totalPl - baseline.totalPl,
|
|
88
|
+
kept.length,
|
|
89
|
+
excluded.size,
|
|
90
|
+
minImprovementPct ?? 3,
|
|
91
|
+
);
|
|
92
|
+
if (!evidence) continue;
|
|
57
93
|
const winnersRemoved = candidate.excluded.filter(({ trade }) => trade.pl > 0).length;
|
|
58
94
|
suggestions.push({
|
|
59
95
|
filter: `Skip when ${field} ${candidate.operator} ${candidate.value}`,
|
|
@@ -63,27 +99,29 @@ export async function suggestFieldFilters(
|
|
|
63
99
|
value: candidate.value,
|
|
64
100
|
lagged: entry.timing === "close",
|
|
65
101
|
},
|
|
66
|
-
|
|
102
|
+
...evidence,
|
|
67
103
|
winnersRemoved,
|
|
68
104
|
losersRemoved: excluded.size - winnersRemoved,
|
|
69
105
|
newWinRate: stats.winRate,
|
|
70
106
|
newTotalPl: stats.totalPl,
|
|
71
107
|
winRateDelta: improvement,
|
|
72
108
|
plDelta: stats.totalPl - baseline.totalPl,
|
|
73
|
-
confidence: kept.length >= 100 ? "high" : kept.length >= 30 ? "medium" : "low",
|
|
74
109
|
});
|
|
75
110
|
}
|
|
76
111
|
}
|
|
77
|
-
suggestions.sort((a, b) =>
|
|
112
|
+
suggestions.sort((a, b) => rankSuggestions(objective, a, b));
|
|
78
113
|
return createToolOutput(`Field filter analysis: ${blockId} | ${suggestions.length} suggestions`, {
|
|
79
114
|
blockId,
|
|
80
115
|
fields,
|
|
116
|
+
...objectiveOutput(objective, minImprovementPct),
|
|
81
117
|
currentStats: computeSliceStats(trades.map((trade) => trade.pl)),
|
|
82
118
|
suggestedFilters: suggestions,
|
|
83
119
|
compositeSuggestions: [],
|
|
84
120
|
coverage,
|
|
121
|
+
fieldBaselines,
|
|
122
|
+
comparisonPopulation: "fieldBaselines",
|
|
85
123
|
...datasetAgeOutput(datasetAge, fields),
|
|
86
|
-
minImprovementThreshold: minImprovementPct,
|
|
124
|
+
minImprovementThreshold: objective === "winRate" ? (minImprovementPct ?? 3) : null,
|
|
87
125
|
});
|
|
88
126
|
}
|
|
89
127
|
|
|
@@ -93,9 +131,14 @@ export async function analyzeFieldRegimes(
|
|
|
93
131
|
trades: Trade[],
|
|
94
132
|
field: string,
|
|
95
133
|
bins?: number[],
|
|
134
|
+
maxMissedSessions?: number,
|
|
96
135
|
) {
|
|
97
|
-
const { catalog, values, datasetAge } = await resolveAnalysisFields(
|
|
98
|
-
|
|
136
|
+
const { catalog, values, datasetAge } = await resolveAnalysisFields(
|
|
137
|
+
baseDir,
|
|
138
|
+
trades,
|
|
139
|
+
maxMissedSessions,
|
|
140
|
+
);
|
|
141
|
+
requireCoveredField(field, catalog, values, datasetAge);
|
|
99
142
|
const entry = catalog.find((entry) => entry.field === field)!;
|
|
100
143
|
if (bins && entry.type !== "numeric") throw new Error(`Bins require a numeric field: ${field}`);
|
|
101
144
|
if (bins?.some((value, i) => !Number.isFinite(value) || (i > 0 && value <= bins[i - 1])))
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type FilterObjective = "totalPl" | "winRate";
|
|
2
|
+
|
|
3
|
+
export interface SuggestionEvidence {
|
|
4
|
+
objective: FilterObjective;
|
|
5
|
+
tradesKept: number;
|
|
6
|
+
keptFraction: number;
|
|
7
|
+
tradesRemoved: number;
|
|
8
|
+
confidence: "high" | "medium" | "low";
|
|
9
|
+
warnings: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Both suggestion paths use the same admission and sample-size support rule. */
|
|
13
|
+
export function suggestionEvidence(
|
|
14
|
+
objective: FilterObjective,
|
|
15
|
+
winRateDelta: number,
|
|
16
|
+
plDelta: number,
|
|
17
|
+
kept: number,
|
|
18
|
+
removed: number,
|
|
19
|
+
minImprovementPct: number,
|
|
20
|
+
): SuggestionEvidence | null {
|
|
21
|
+
if (
|
|
22
|
+
!kept ||
|
|
23
|
+
!removed ||
|
|
24
|
+
(objective === "totalPl" ? plDelta <= 0 : winRateDelta < minImprovementPct)
|
|
25
|
+
)
|
|
26
|
+
return null;
|
|
27
|
+
const keptFraction = kept / (kept + removed);
|
|
28
|
+
const lowRetention = keptFraction < 0.2;
|
|
29
|
+
return {
|
|
30
|
+
objective,
|
|
31
|
+
tradesKept: kept,
|
|
32
|
+
keptFraction,
|
|
33
|
+
tradesRemoved: removed,
|
|
34
|
+
confidence: lowRetention
|
|
35
|
+
? "low"
|
|
36
|
+
: removed >= 10 && kept >= 20
|
|
37
|
+
? "high"
|
|
38
|
+
: removed >= 5 && kept >= 10
|
|
39
|
+
? "medium"
|
|
40
|
+
: "low",
|
|
41
|
+
warnings: lowRetention
|
|
42
|
+
? ["Low retention: fewer than 20% of covered trades kept; sample-size support capped at low."]
|
|
43
|
+
: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function rankSuggestions(
|
|
48
|
+
objective: FilterObjective,
|
|
49
|
+
a: { winRateDelta: number; plDelta: number },
|
|
50
|
+
b: { winRateDelta: number; plDelta: number },
|
|
51
|
+
): number {
|
|
52
|
+
return objective === "totalPl"
|
|
53
|
+
? b.plDelta - a.plDelta || b.winRateDelta - a.winRateDelta
|
|
54
|
+
: b.winRateDelta - a.winRateDelta || b.plDelta - a.plDelta;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function objectiveOutput(objective: FilterObjective, minImprovementPct?: number) {
|
|
58
|
+
return {
|
|
59
|
+
objective,
|
|
60
|
+
rankingRule:
|
|
61
|
+
objective === "totalPl"
|
|
62
|
+
? "Positive P&L improvement, ranked by P&L improvement then win-rate gain"
|
|
63
|
+
: "Win-rate gain >= minImprovementPct, ranked by win-rate gain then P&L improvement",
|
|
64
|
+
supportNote:
|
|
65
|
+
"confidence is sample-size support, not statistical confidence; keptFraction < 0.2 caps it at low",
|
|
66
|
+
...(objective === "totalPl" && minImprovementPct !== undefined
|
|
67
|
+
? {
|
|
68
|
+
objectiveNote:
|
|
69
|
+
"minImprovementPct is ignored under totalPl; only positive P&L improvement is admitted.",
|
|
70
|
+
}
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { sweepThresholds } from "./threshold-sweep.ts";
|
|
2
|
+
import { rankSuggestions, type FilterObjective } from "../tools/shared/filter-objective.ts";
|
|
3
|
+
|
|
4
|
+
interface CurveTrade {
|
|
5
|
+
pl: number;
|
|
6
|
+
dateOpened: Date;
|
|
7
|
+
}
|
|
8
|
+
interface Options {
|
|
9
|
+
mode: "lt" | "gt" | "both";
|
|
10
|
+
thresholds?: number[];
|
|
11
|
+
percentileSteps?: number[];
|
|
12
|
+
objective?: FilterObjective;
|
|
13
|
+
permutation?: { iterations: number; block: "month"; seed: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function population(trades: Array<{ pl: number }>) {
|
|
17
|
+
const totalPl = trades.reduce((sum, trade) => sum + trade.pl, 0);
|
|
18
|
+
return {
|
|
19
|
+
count: trades.length,
|
|
20
|
+
totalPl,
|
|
21
|
+
winRate: trades.length ? trades.filter((trade) => trade.pl > 0).length / trades.length : 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The same sweep and objective ranking are used for observed and permuted outcomes. */
|
|
26
|
+
export function analyzeFilterCurve<T extends CurveTrade>(
|
|
27
|
+
trades: T[],
|
|
28
|
+
valueOf: (trade: T) => number | null,
|
|
29
|
+
options: Options,
|
|
30
|
+
) {
|
|
31
|
+
const covered = trades.filter((trade) => valueOf(trade) !== null);
|
|
32
|
+
const base = population(covered);
|
|
33
|
+
const uncovered = population(trades.filter((trade) => valueOf(trade) === null));
|
|
34
|
+
const baseline = {
|
|
35
|
+
count: base.count,
|
|
36
|
+
winRate: Math.round(base.winRate * 10000) / 10000,
|
|
37
|
+
avgPl: Math.round((base.totalPl / base.count) * 100) / 100,
|
|
38
|
+
totalPl: Math.round(base.totalPl * 100) / 100,
|
|
39
|
+
uncoveredCount: uncovered.count,
|
|
40
|
+
uncoveredTotalPl: Math.round(uncovered.totalPl * 100) / 100,
|
|
41
|
+
};
|
|
42
|
+
function metrics(items: T[]) {
|
|
43
|
+
const stats = population(items);
|
|
44
|
+
const avgPl = stats.count ? stats.totalPl / stats.count : 0;
|
|
45
|
+
return {
|
|
46
|
+
count: stats.count,
|
|
47
|
+
percentOfTrades: Math.round((stats.count / base.count) * 10000) / 100,
|
|
48
|
+
winRate: Math.round(stats.winRate * 10000) / 10000,
|
|
49
|
+
avgPl: Math.round(avgPl * 100) / 100,
|
|
50
|
+
totalPl: Math.round(stats.totalPl * 100) / 100,
|
|
51
|
+
winRateDelta: stats.count
|
|
52
|
+
? Math.round((stats.winRate - baseline.winRate) * 10000) / 10000
|
|
53
|
+
: -baseline.winRate,
|
|
54
|
+
avgPlDelta: stats.count ? Math.round((avgPl - baseline.avgPl) * 100) / 100 : -baseline.avgPl,
|
|
55
|
+
...(stats.count > 0 && stats.count < 30
|
|
56
|
+
? {
|
|
57
|
+
lowSampleWarning: `${stats.count} trades may be insufficient for reliable statistics (recommend >= 30)`,
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const sweeps = sweepThresholds(covered, valueOf, options.thresholds, options.percentileSteps);
|
|
63
|
+
const thresholds = sweeps.map((sweep) => ({
|
|
64
|
+
threshold: sweep.threshold,
|
|
65
|
+
...(options.mode !== "gt" ? { lt: metrics(sweep.lt) } : {}),
|
|
66
|
+
...(options.mode !== "lt" ? { gt: metrics(sweep.gt) } : {}),
|
|
67
|
+
}));
|
|
68
|
+
const sweetSpots = thresholds
|
|
69
|
+
.flatMap((row) =>
|
|
70
|
+
(["lt", "gt"] as const).flatMap((direction) => {
|
|
71
|
+
const stats = row[direction];
|
|
72
|
+
if (
|
|
73
|
+
!stats ||
|
|
74
|
+
stats.winRateDelta <= 0 ||
|
|
75
|
+
stats.avgPlDelta <= 0 ||
|
|
76
|
+
stats.percentOfTrades < 20
|
|
77
|
+
)
|
|
78
|
+
return [];
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
threshold: row.threshold,
|
|
82
|
+
direction,
|
|
83
|
+
winRateDelta: stats.winRateDelta,
|
|
84
|
+
avgPlDelta: stats.avgPlDelta,
|
|
85
|
+
percentOfTrades: stats.percentOfTrades,
|
|
86
|
+
score: Math.round(stats.winRateDelta * stats.avgPlDelta * 10000) / 10000,
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
.sort((a, b) => b.score - a.score);
|
|
92
|
+
const legacy = {
|
|
93
|
+
tradesAnalyzed: covered.length,
|
|
94
|
+
baseline,
|
|
95
|
+
thresholds,
|
|
96
|
+
sweetSpots,
|
|
97
|
+
sweetSpotCriteria: {
|
|
98
|
+
winRateDelta: "> 0 (win rate improves)",
|
|
99
|
+
avgPlDelta: "> 0 (average P/L improves)",
|
|
100
|
+
percentOfTrades: ">= 20% (retains meaningful sample)",
|
|
101
|
+
scoreFormula: "winRateDelta * avgPlDelta (higher = better)",
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
if (!options.objective && !options.permutation) return { ...legacy, analysis: undefined };
|
|
105
|
+
const objective = options.objective ?? "totalPl";
|
|
106
|
+
const pairs = covered.map((trade) => ({ value: valueOf(trade)!, pl: trade.pl }));
|
|
107
|
+
function best(items: Array<{ value: number; pl: number }>) {
|
|
108
|
+
const candidates = sweepThresholds(
|
|
109
|
+
items,
|
|
110
|
+
(item) => item.value,
|
|
111
|
+
options.thresholds,
|
|
112
|
+
options.percentileSteps,
|
|
113
|
+
).flatMap((sweep) =>
|
|
114
|
+
(["lt", "gt"] as const).flatMap((direction) => {
|
|
115
|
+
const kept = sweep[direction];
|
|
116
|
+
if (
|
|
117
|
+
(options.mode !== "both" && options.mode !== direction) ||
|
|
118
|
+
!kept.length ||
|
|
119
|
+
kept.length === items.length
|
|
120
|
+
)
|
|
121
|
+
return [];
|
|
122
|
+
const stats = population(kept);
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
threshold: sweep.threshold,
|
|
126
|
+
direction,
|
|
127
|
+
kept,
|
|
128
|
+
plDelta: stats.totalPl - base.totalPl,
|
|
129
|
+
winRateDelta: (stats.winRate - base.winRate) * 100,
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
candidates.sort((a, b) => rankSuggestions(objective, a, b));
|
|
135
|
+
return candidates[0] ?? null;
|
|
136
|
+
}
|
|
137
|
+
const chosen = best(pairs);
|
|
138
|
+
const bestThreshold = chosen
|
|
139
|
+
? {
|
|
140
|
+
threshold: chosen.threshold,
|
|
141
|
+
direction: chosen.direction,
|
|
142
|
+
kept: population(chosen.kept),
|
|
143
|
+
removed: population(
|
|
144
|
+
pairs.filter((pair) =>
|
|
145
|
+
chosen.direction === "lt"
|
|
146
|
+
? pair.value >= chosen.threshold
|
|
147
|
+
: pair.value <= chosen.threshold,
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
objectiveDelta: objective === "totalPl" ? chosen.plDelta : chosen.winRateDelta,
|
|
151
|
+
}
|
|
152
|
+
: null;
|
|
153
|
+
const years = [...new Set(trades.map((trade) => trade.dateOpened.getFullYear()))].sort();
|
|
154
|
+
const perYear = chosen
|
|
155
|
+
? years.map((year) => {
|
|
156
|
+
const annual = covered.filter((trade) => trade.dateOpened.getFullYear() === year);
|
|
157
|
+
const uncovered = population(
|
|
158
|
+
trades.filter(
|
|
159
|
+
(trade) => trade.dateOpened.getFullYear() === year && valueOf(trade) === null,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
const kept = annual.filter((trade) =>
|
|
163
|
+
chosen.direction === "lt"
|
|
164
|
+
? valueOf(trade)! < chosen.threshold
|
|
165
|
+
: valueOf(trade)! > chosen.threshold,
|
|
166
|
+
);
|
|
167
|
+
const removed = annual.filter((trade) =>
|
|
168
|
+
chosen.direction === "lt"
|
|
169
|
+
? valueOf(trade)! >= chosen.threshold
|
|
170
|
+
: valueOf(trade)! <= chosen.threshold,
|
|
171
|
+
);
|
|
172
|
+
const baseline = population(annual);
|
|
173
|
+
const stats = population(kept);
|
|
174
|
+
return {
|
|
175
|
+
year,
|
|
176
|
+
baseline: {
|
|
177
|
+
...baseline,
|
|
178
|
+
uncoveredCount: uncovered.count,
|
|
179
|
+
uncoveredTotalPl: uncovered.totalPl,
|
|
180
|
+
},
|
|
181
|
+
kept: stats,
|
|
182
|
+
removed: population(removed),
|
|
183
|
+
objectiveDelta:
|
|
184
|
+
objective === "totalPl"
|
|
185
|
+
? stats.totalPl - baseline.totalPl
|
|
186
|
+
: (stats.winRate - baseline.winRate) * 100,
|
|
187
|
+
...(annual.length < 30 || kept.length < 30 || removed.length < 30
|
|
188
|
+
? { lowSampleWarning: "Fewer than 30 covered, kept or removed entries" }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
})
|
|
192
|
+
: [];
|
|
193
|
+
let permutation:
|
|
194
|
+
| {
|
|
195
|
+
iterations: number;
|
|
196
|
+
block: "month";
|
|
197
|
+
seed: number;
|
|
198
|
+
blockCount: number;
|
|
199
|
+
status: string;
|
|
200
|
+
reason?: string;
|
|
201
|
+
pValue?: number;
|
|
202
|
+
exceedances?: number;
|
|
203
|
+
correction?: string;
|
|
204
|
+
}
|
|
205
|
+
| undefined;
|
|
206
|
+
if (options.permutation) {
|
|
207
|
+
const ordered = [...covered].sort((a, b) => a.dateOpened.getTime() - b.dateOpened.getTime());
|
|
208
|
+
const blocks = new Map<string, number[]>();
|
|
209
|
+
for (const trade of ordered) {
|
|
210
|
+
const key = `${trade.dateOpened.getFullYear()}-${trade.dateOpened.getMonth()}`;
|
|
211
|
+
if (!blocks.has(key)) blocks.set(key, []);
|
|
212
|
+
blocks.get(key)!.push(trade.pl);
|
|
213
|
+
}
|
|
214
|
+
permutation = { ...options.permutation, blockCount: blocks.size, status: "refused" };
|
|
215
|
+
if (covered.length < 30) permutation.reason = "too_few_covered_trades";
|
|
216
|
+
else if (blocks.size < 8) permutation.reason = "too_few_blocks";
|
|
217
|
+
else if (!chosen) permutation.reason = "no_eligible_threshold";
|
|
218
|
+
else {
|
|
219
|
+
let state = options.permutation.seed >>> 0;
|
|
220
|
+
const rng = () => {
|
|
221
|
+
state = (Math.imul(1664525, state) + 1013904223) >>> 0;
|
|
222
|
+
return state / 4294967296;
|
|
223
|
+
};
|
|
224
|
+
const original = [...blocks.values()];
|
|
225
|
+
let exceedances = 0;
|
|
226
|
+
const observed = objective === "totalPl" ? chosen.plDelta : chosen.winRateDelta;
|
|
227
|
+
const orderedValues = ordered.map((trade) => valueOf(trade)!);
|
|
228
|
+
for (let iteration = 0; iteration < options.permutation.iterations; iteration++) {
|
|
229
|
+
const shuffled = [...original];
|
|
230
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
231
|
+
const j = Math.floor(rng() * (i + 1));
|
|
232
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
233
|
+
}
|
|
234
|
+
const outcomes = shuffled.flat();
|
|
235
|
+
const permuted = outcomes.map((pl, index) => ({ value: orderedValues[index], pl }));
|
|
236
|
+
const winner = best(permuted);
|
|
237
|
+
const score = winner
|
|
238
|
+
? objective === "totalPl"
|
|
239
|
+
? winner.plDelta
|
|
240
|
+
: winner.winRateDelta
|
|
241
|
+
: -Infinity;
|
|
242
|
+
if (score >= observed) exceedances++;
|
|
243
|
+
}
|
|
244
|
+
permutation = {
|
|
245
|
+
...permutation,
|
|
246
|
+
status: "ok",
|
|
247
|
+
exceedances,
|
|
248
|
+
pValue: (exceedances + 1) / (options.permutation.iterations + 1),
|
|
249
|
+
correction: "add-one: (exceedances + 1) / (iterations + 1)",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
...legacy,
|
|
255
|
+
analysis: { objective, bestThreshold, perYear, ...(permutation ? { permutation } : {}) },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -5,15 +5,22 @@ import {
|
|
|
5
5
|
resolveTradeDatasets,
|
|
6
6
|
runtimeFieldCatalog,
|
|
7
7
|
summarizeDatasetAge,
|
|
8
|
+
type DatasetFieldAge,
|
|
8
9
|
} from "../db/dataset-schemas.ts";
|
|
9
10
|
import { buildLookaheadFreeQuery, CLOSE_KNOWN_FIELDS } from "./field-timing.ts";
|
|
10
11
|
import { marketTickerDateKey, resolveTradeTicker } from "./ticker.ts";
|
|
11
12
|
|
|
12
|
-
export async function resolveAnalysisFields(
|
|
13
|
+
export async function resolveAnalysisFields(
|
|
14
|
+
baseDir: string,
|
|
15
|
+
trades: Trade[],
|
|
16
|
+
maxMissedSessions?: number,
|
|
17
|
+
) {
|
|
13
18
|
const conn = await getConnection(baseDir);
|
|
14
19
|
const datasets = await listDatasets(conn);
|
|
15
20
|
const catalog = await runtimeFieldCatalog(conn);
|
|
16
|
-
const datasetMatches = trades.map((trade) =>
|
|
21
|
+
const datasetMatches = trades.map((trade) =>
|
|
22
|
+
resolveTradeDatasets(datasets, trade, maxMissedSessions),
|
|
23
|
+
);
|
|
17
24
|
const keys = trades.map((trade) => {
|
|
18
25
|
const date = new Date(trade.dateOpened);
|
|
19
26
|
const midnightUtc = date.getUTCHours() === 0 && date.getUTCMinutes() === 0;
|
|
@@ -64,9 +71,17 @@ export function requireCoveredField(
|
|
|
64
71
|
field: string,
|
|
65
72
|
catalog: { field: string }[],
|
|
66
73
|
values: Record<string, number | string>[],
|
|
74
|
+
ages?: Record<string, DatasetFieldAge>,
|
|
67
75
|
) {
|
|
68
76
|
if (!catalog.some((entry) => entry.field === field))
|
|
69
77
|
throw new Error(`Unknown analysis field: ${field}`);
|
|
70
|
-
if (!values.some((row) => row[field] !== undefined))
|
|
71
|
-
|
|
78
|
+
if (!values.some((row) => row[field] !== undefined)) {
|
|
79
|
+
const age = ages?.[field];
|
|
80
|
+
const reason = age?.rejectedAttachments
|
|
81
|
+
? `; ${age.rejectedAttachments} refused by maxMissedSessions; ${age.unsupportedSessionAge} session ages unsupported`
|
|
82
|
+
: "";
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Uncovered analysis field: ${field}; no value available at trade entry${reason}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
72
87
|
}
|