@@ -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 */
736759const 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