Skip to content

Commit 7487eb2

Browse files
committed
wip
1 parent 9b586c2 commit 7487eb2

17 files changed

Lines changed: 267 additions & 110 deletions

cli/src/main/simulator.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ const validateIdea = (idea: any, line: number): string | null => {
5858
return null;
5959
};
6060

61+
const fmtSimRatio = (value: number): string =>
62+
Number.isFinite(value) ? value.toFixed(2) : "inf";
63+
6164
const toMarkdown = (result: ISimulatorResult): string => {
6265
const buckets = Object.entries(result.reports).filter(
6366
([, bucket]) => bucket.reports.length > 0,
@@ -95,13 +98,17 @@ const toMarkdown = (result: ISimulatorResult): string => {
9598
);
9699
}
97100
const sharpeBest = bucket.best.find(({ criterion }) => criterion === "sharpe");
101+
const allowed = (sharpeBest?.report?.authorStats ?? []).filter(({ banned }) => !banned);
98102
lines.push("");
99-
lines.push(`### Allowed authors (sharpe winner rule)`);
103+
lines.push(`### Allowed authors — isolated metrics on the sharpe winner (${allowed.length}, banned in --json)`);
100104
lines.push("");
101-
lines.push(`| Author | Ideas | Hits | HitRate |`);
102-
lines.push(`| --- | --- | --- | --- |`);
103-
for (const stat of (sharpeBest?.authorStats ?? []).filter(({ banned }) => !banned)) {
104-
lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% |`);
105+
lines.push(`| Author | Ideas | Hits | HitRate | Trades | PNL% | Sharpe | Sortino | Recovery |`);
106+
lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
107+
for (const stat of allowed) {
108+
lines.push(
109+
`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% | ` +
110+
`${stat.trades} | ${stat.pnlPercent.toFixed(2)}% | ${fmtSimRatio(stat.sharpe)} | ${fmtSimRatio(stat.sortino)} | ${fmtSimRatio(stat.recoveryFactor)} |`,
111+
);
105112
}
106113
}
107114
return lines.join("\n");

cli/src/main/tune.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,23 +138,35 @@ const toMarkdown = (test: ISimulatorTestResult): string => {
138138
lines.push(`| Sharpe / Sortino | ${fmtRatio(test.report.sharpe)} / ${fmtRatio(test.report.sortino)} |`);
139139
lines.push(`| Recovery factor | ${fmtRatio(test.report.recoveryFactor)} |`);
140140
lines.push(`| Exits | ${Object.entries(test.report.exitReasons).map(([reason, count]) => `${reason}=${count}`).join(", ")} |`);
141+
142+
// сделки — короткий список: markdown не рендерит простыню, полный
143+
// список сделок берётся из --json
144+
const TRADE_CAP = 20;
145+
const shownTrades = test.trades.slice(0, TRADE_CAP);
141146
lines.push("");
142-
lines.push(`## Test trades`);
147+
lines.push(`## Test trades (${shownTrades.length} of ${test.trades.length}${test.trades.length > TRADE_CAP ? `, rest in --json` : ""})`);
143148
lines.push("");
144149
lines.push(`| Direction | Exit | PNL% | Hold | Entry (UTC) |`);
145150
lines.push(`| --- | --- | --- | --- | --- |`);
146-
for (const trade of test.trades) {
151+
for (const trade of shownTrades) {
147152
lines.push(
148153
`| ${trade.direction} | ${trade.exitReason} | ${trade.pnlPercent.toFixed(2)} | ${trade.holdMinutesActual}m | ${new Date(trade.entryTimestamp).toISOString()} |`,
149154
);
150155
}
156+
157+
// трек-рекорд — только ДОПУЩЕННЫЕ авторы с изолированными метриками
158+
// на тестовой точке; забаненных (обычно сотни) в json, не в markdown
159+
const allowed = test.authorStats.filter(({ banned }) => !banned);
151160
lines.push("");
152-
lines.push(`## Frozen author track record (re-derived bans under the point's rule)`);
161+
lines.push(`## Allowed authors — isolated metrics on the frozen point (${allowed.length} of ${test.authorStats.length}, banned in --json)`);
153162
lines.push("");
154-
lines.push(`| Author | Ideas | Hits | Banned |`);
155-
lines.push(`| --- | --- | --- | --- |`);
156-
for (const stat of test.authorStats) {
157-
lines.push(`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${stat.banned ? "yes" : ""} |`);
163+
lines.push(`| Author | Ideas | Hits | HitRate | Trades | PNL% | Sharpe | Sortino | Recovery |`);
164+
lines.push(`| --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
165+
for (const stat of allowed) {
166+
lines.push(
167+
`| ${stat.author} | ${stat.ideas} | ${stat.hits} | ${(stat.hitRate * 100).toFixed(0)}% | ` +
168+
`${stat.trades} | ${stat.pnlPercent.toFixed(2)}% | ${fmtRatio(stat.sharpe)} | ${fmtRatio(stat.sortino)} | ${fmtRatio(stat.recoveryFactor)} |`,
169+
);
158170
}
159171
return lines.join("\n");
160172
};

demo/simulator/src/index.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ writeFileSync("./dump/simulator.done.json", JSON.stringify(result, null, 2));
5858
const sharpeBest = result.reports.close.best.find(({ criterion }) => criterion === "sharpe");
5959
console.log(
6060
"saved; profiles:", result.profileCount,
61-
"allowed:", sharpeBest?.allowedAuthors.length ?? 0,
62-
"banned:", sharpeBest?.bannedAuthors.length ?? 0,
61+
"allowed:", sharpeBest?.report?.allowedAuthors.length ?? 0,
62+
"banned:", sharpeBest?.report?.bannedAuthors.length ?? 0,
6363
);
6464
process.exit(0);

demo/tune/src/index.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ const runTune = async (simulatorName) => {
140140
const sharpeBest = bucket.best.find(({ criterion }) => criterion === "sharpe");
141141
result.push({
142142
config: simulatorName,
143-
authorStats: (sharpeBest?.authorStats ?? []).map(({ author, ideas, hits }) => ({ author, ideas, hits })),
143+
authorStats: (sharpeBest?.report?.authorStats ?? []).map(({ author, ideas, hits }) => ({ author, ideas, hits })),
144144
});
145145
};
146146

src/client/ClientSimulator.ts

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,20 @@ interface IAuthorFilterContext {
275275
profileBanned: boolean[];
276276
}
277277

278+
/**
279+
* Placeholder isolated-simulation metrics. The threshold builders
280+
* (TRAIN/FREEZE) know the rule but not a concrete point, so they
281+
* emit zeros here; ENRICH_AUTHOR_STATS_FN fills the real numbers per
282+
* point at assembly time.
283+
*/
284+
const EMPTY_AUTHOR_METRICS = {
285+
trades: 0,
286+
pnlPercent: 0,
287+
sharpe: 0,
288+
sortino: 0,
289+
recoveryFactor: 0,
290+
} as const;
291+
278292
/**
279293
* Derives the ban-filter rule from a grid point as a discriminated
280294
* union. EVERY rule carries the point's holdMinutes — the grading
@@ -487,6 +501,9 @@ const TRAIN_AUTHOR_FILTER_FN = (
487501
banned:
488502
stat.ideas < minAuthorTrack ||
489503
stat.hits / stat.ideas < minAuthorHitRate,
504+
// изолированные метрики заполняются при сборке точки
505+
// (ENRICH_AUTHOR_STATS_FN) — здесь правило без точки
506+
...EMPTY_AUTHOR_METRICS,
490507
}));
491508
const banned = new Set(
492509
stats.filter(({ banned }) => banned).map(({ author }) => author),
@@ -532,6 +549,8 @@ const FREEZE_AUTHOR_FILTER_FN = (
532549
hits,
533550
hitRate: n ? hits / n : 0,
534551
banned: n < minAuthorTrack || hits / n < minAuthorHitRate,
552+
// изолированные метрики заполняются при сборке точки теста
553+
...EMPTY_AUTHOR_METRICS,
535554
}),
536555
);
537556
const allowed = new Set(
@@ -731,6 +750,10 @@ const COMPUTE_HOLD_STATS_FN = (
731750
* @param filter - Author filter context of the point's ban rule
732751
* @param rangeStartTs - Start of the shared daily bucket window
733752
* @param rangeDays - Number of daily buckets in the shared window
753+
* @param enrich - When true, the report carries the point's own
754+
* authorStats (threshold + isolated per-author metrics on THIS
755+
* point) and allowed/bannedAuthors; false for the isolated
756+
* per-author sub-simulations to avoid recursion.
734757
* @returns Aggregated report and the trade list
735758
*/
736759
const EVALUATE_POINT_FN = (
@@ -739,6 +762,7 @@ const EVALUATE_POINT_FN = (
739762
filter: IAuthorFilterContext,
740763
rangeStartTs: number,
741764
rangeDays: number,
765+
enrich: boolean = true,
742766
): { report: ISimulatorPointReport; trades: ISimulatorTrade[] } => {
743767
const trades: ISimulatorTrade[] = [];
744768
const exitReasons: Record<SimulatorExitReason, number> = {
@@ -850,6 +874,19 @@ const EVALUATE_POINT_FN = (
850874
? Number.POSITIVE_INFINITY
851875
: 0;
852876

877+
// трек-рекорд авторов ЭТОЙ точки: пороговые поля из фильтра
878+
// правила плюс изолированные метрики автора НА ЭТОЙ точке. Для
879+
// изолированных под-симуляций enrich=false — иначе рекурсия
880+
const authorStats = enrich
881+
? ENRICH_AUTHOR_STATS_FN(
882+
filter.stats,
883+
profiles,
884+
point,
885+
rangeStartTs,
886+
rangeDays,
887+
)
888+
: [];
889+
853890
return {
854891
report: {
855892
point,
@@ -868,11 +905,78 @@ const EVALUATE_POINT_FN = (
868905
sharpe,
869906
sortino,
870907
exitReasons,
908+
authorStats,
909+
allowedAuthors: authorStats
910+
.filter(({ banned }) => !banned)
911+
.map(({ author }) => author),
912+
bannedAuthors: authorStats
913+
.filter(({ banned }) => banned)
914+
.map(({ author }) => author),
871915
},
872916
trades,
873917
};
874918
};
875919

920+
/**
921+
* Enriches per-author stats with the metrics of each author's
922+
* ISOLATED simulation on the rule's grid point: for every author,
923+
* EVALUATE_POINT_FN runs over ONLY his profiles with a nobody-banned
924+
* filter — his own slot, nobody absorbing him, the ban ignored (a
925+
* banned author still plays his whole track). The daily-bucket
926+
* window (rangeStartTs/rangeDays) is the run's shared one, so the
927+
* Sharpe/Sortino are comparable to the point's own. Threshold fields
928+
* (ideas/hits/hitRate/banned) are carried through untouched.
929+
*
930+
* @param stats - Threshold stats of the rule (from the filter)
931+
* @param profiles - All directional profiles of the run
932+
* @param point - The grid point the metrics are simulated on
933+
* @param rangeStartTs - Start of the shared daily bucket window
934+
* @param rangeDays - Number of daily buckets in the shared window
935+
* @returns Stats with trades/pnlPercent/sharpe/sortino/recoveryFactor filled
936+
*/
937+
const ENRICH_AUTHOR_STATS_FN = (
938+
stats: ISimulatorAuthorStat[],
939+
profiles: ISimulatorIdeaProfile[],
940+
point: ISimulatorGridPoint,
941+
rangeStartTs: number,
942+
rangeDays: number,
943+
): ISimulatorAuthorStat[] => {
944+
const byAuthor = new Map<string, ISimulatorIdeaProfile[]>();
945+
for (const profile of profiles) {
946+
const list = byAuthor.get(profile.idea.author) ?? [];
947+
list.push(profile);
948+
byAuthor.set(profile.idea.author, list);
949+
}
950+
return stats.map((stat) => {
951+
const own = byAuthor.get(stat.author) ?? [];
952+
// фиктивный фильтр: бан игнорируется — автор отыгрывает весь свой
953+
// трек в собственном слоте (никто не поглощает его посты)
954+
const soloFilter: IAuthorFilterContext = {
955+
stats: [],
956+
banned: new Set<string>(),
957+
bannedIdeas: 0,
958+
profileBanned: own.map(() => false),
959+
};
960+
// enrich=false: изолированная под-симуляция сама трек не строит
961+
const { report } = EVALUATE_POINT_FN(
962+
own,
963+
point,
964+
soloFilter,
965+
rangeStartTs,
966+
rangeDays,
967+
false,
968+
);
969+
return {
970+
...stat,
971+
trades: report.trades,
972+
pnlPercent: report.totalPnlPercent,
973+
sharpe: report.sharpe,
974+
sortino: report.sortino,
975+
recoveryFactor: report.recoveryFactor,
976+
};
977+
});
978+
};
979+
876980
/**
877981
* Builds the cartesian product of grid axes. Meaningless
878982
* combinations DO NOT EXIST: a reach or retain point with lock = 0
@@ -1152,10 +1256,10 @@ const RUN_FN = async (
11521256
reportsByMetric[report.point.authorMetric].reports.push(report);
11531257
}
11541258

1155-
// словари банов — чистая арифметика порогов правила, никакого
1156-
// ранжирования: по одному словарю на уникальное правило, правило
1157-
// идентифицируется собственными полями и лежит в корзине СВОЕЙ
1158-
// метрики
1259+
// словари банов — ЧИСТАЯ пороговая арифметика правила: одно правило
1260+
// -> один словарь, идентификация своими полями, корзина СВОЕЙ
1261+
// метрики. Метрик авторов тут нет — они зависят от всей точки
1262+
// (stop/lock/trailing), а не от правила, и живут на report точки
11591263
for (const { rule, filter } of filterByRule.values()) {
11601264
reportsByMetric[rule.metric].bans.push({
11611265
holdMinutes: rule.holdMinutes,
@@ -1171,7 +1275,6 @@ const RUN_FN = async (
11711275
: rule.metric === "trail"
11721276
? { trailingTakePercent: rule.trailingTakePercent }
11731277
: {}),
1174-
authorStats: filter.stats,
11751278
allowedAuthors: filter.stats
11761279
.filter(({ banned }) => !banned)
11771280
.map(({ author }) => author),
@@ -1197,21 +1300,12 @@ const RUN_FN = async (
11971300
[...eligible].sort(byRankingDesc(ranking.value))[0] ??
11981301
sorted[0] ??
11991302
null;
1200-
// артефакт авторов — под правило бана ИМЕННО ЭТОГО победителя:
1201-
// критерии могут выбрать точки с разными правилами, и белый
1202-
// список — свойство точки, а не корзины
1203-
const winnerStats = winner ? getFilter(winner.point).stats : [];
1303+
// трек-рекорд победителя — не дублируется: он уже лежит на
1304+
// report точки (winner.authorStats / allowed / bannedAuthors)
12041305
const bestEntry: ISimulatorBest = {
12051306
criterion: ranking.criterion,
12061307
report: winner,
12071308
trades: winner ? (tradesByReport.get(winner) ?? []) : [],
1208-
authorStats: winnerStats,
1209-
allowedAuthors: winnerStats
1210-
.filter(({ banned }) => !banned)
1211-
.map(({ author }) => author),
1212-
bannedAuthors: winnerStats
1213-
.filter(({ banned }) => banned)
1214-
.map(({ author }) => author),
12151309
};
12161310
bucket.best.push(bestEntry);
12171311
if (self.params.callbacks?.onRanking) {
@@ -1343,6 +1437,8 @@ const TEST_FN = async (
13431437
trades.map(({ holdMinutesActual }) => holdMinutesActual),
13441438
);
13451439

1440+
// изолированные метрики авторов уже посчитаны на report точки
1441+
// (EVALUATE_POINT_FN, enrich=true) — берём их, не считаем заново
13461442
const result: ISimulatorTestResult = {
13471443
symbol,
13481444
ideasTotal: ideas.length,
@@ -1352,10 +1448,8 @@ const TEST_FN = async (
13521448
point,
13531449
report,
13541450
trades,
1355-
authorStats: filter.stats,
1356-
allowedAuthors: filter.stats
1357-
.filter(({ banned }) => !banned)
1358-
.map(({ author }) => author),
1451+
authorStats: report.authorStats,
1452+
allowedAuthors: report.allowedAuthors,
13591453
bannedAuthors: [...filter.banned],
13601454
avgHoldMinutes: holdStats.avgHoldMinutes,
13611455
p95HoldMinutes: holdStats.p95HoldMinutes,

0 commit comments

Comments
 (0)