@@ -435,6 +435,49 @@ def _path_metrics(
435435 }
436436
437437
438+ def _profile_drawdown_cap_bps (* , benchmark_drawdown_bps : int , benchmark_multiple_bps : int ) -> int :
439+ """Return the profile envelope for one *paired* benchmark path.
440+
441+ A stress path must never relax the envelope applied to an unrelated
442+ walk-forward or bootstrap path. The calculation is therefore deliberately
443+ per-scenario; callers must not aggregate benchmark drawdowns before they
444+ call this helper.
445+ """
446+ return min (10_000 , (benchmark_drawdown_bps * benchmark_multiple_bps + 9_999 ) // 10_000 )
447+
448+
449+ def _family_growth_summary (
450+ paths : Sequence [Mapping [str , Any ]], metrics : Sequence [Mapping [str , int ]]
451+ ) -> tuple [int , bool ]:
452+ """Summarize growth with equal evidence-family influence.
453+
454+ A producer may emit more bootstrap paths than historical paths. Counting
455+ every path in one global pool would let that implementation detail outweigh
456+ walk-forward or stress evidence. We take the lower median inside each
457+ required family, then the lower median of the three family summaries.
458+
459+ A family is growth-positive only when at least two thirds of *its* paths
460+ are positive. At least two of the three independent evidence families
461+ must meet that test. This retains the prior two-thirds policy while making
462+ the policy invariant to how many valid bootstrap replicas were supplied.
463+ """
464+ family_log_growth : dict [str , list [int ]] = {kind : [] for kind in _SCENARIO_KINDS }
465+ for path , metric in zip (paths , metrics , strict = True ):
466+ family_log_growth [path ["scenario_kind" ]].append (metric ["log_growth_ppm" ])
467+
468+ # Coverage is checked before this helper is called, so every list is
469+ # non-empty. Keeping the guard makes the pure helper fail closed if reused.
470+ if any (not values for values in family_log_growth .values ()):
471+ _fail ("scenario evidence family coverage is incomplete" )
472+
473+ family_medians = [median_low (values ) for values in family_log_growth .values ()]
474+ positive_families = sum (
475+ sum (value > 0 for value in values ) * 3 >= len (values ) * 2
476+ for values in family_log_growth .values ()
477+ )
478+ return median_low (family_medians ), positive_families * 3 >= len (_SCENARIO_KINDS ) * 2
479+
480+
438481def _parked_recommendation (value : Mapping [str , Any ], reasons : list [str ]) -> dict [str , Any ]:
439482 recommendation : dict [str , Any ] = {
440483 "schema" : RISK_COMPOSER_RECOMMENDATION_SCHEMA_ID ,
@@ -482,19 +525,28 @@ def compose_long_horizon_risk_recommendation(value: Any) -> dict[str, Any]:
482525 for path in paths
483526 ]
484527 worst_benchmark_drawdown = max (item ["benchmark_drawdown_bps" ] for item in metrics )
485- profile_drawdown_cap = (worst_benchmark_drawdown * benchmark_multiple_bps + 9_999 ) // 10_000
528+ profile_drawdown_caps = [
529+ _profile_drawdown_cap_bps (
530+ benchmark_drawdown_bps = item ["benchmark_drawdown_bps" ],
531+ benchmark_multiple_bps = benchmark_multiple_bps ,
532+ )
533+ for item in metrics
534+ ]
486535 positive_growth = sum (item ["log_growth_ppm" ] > 0 for item in metrics )
487- scenario_count = len ( metrics )
536+ family_median_log_growth , families_meet_growth_requirement = _family_growth_summary ( paths , metrics )
488537 eligible = (
489- positive_growth * 3 >= scenario_count * 2
490- and max (item ["max_drawdown_bps" ] for item in metrics ) <= profile_drawdown_cap
538+ families_meet_growth_requirement
539+ and all (
540+ item ["max_drawdown_bps" ] <= profile_drawdown_cap
541+ for item , profile_drawdown_cap in zip (metrics , profile_drawdown_caps , strict = True )
542+ )
491543 )
492544 frontier .append (
493545 {
494546 "scale_bps" : scale_bps ,
495- "median_log_growth_ppm" : median_low ( item [ "log_growth_ppm" ] for item in metrics ) ,
547+ "median_log_growth_ppm" : family_median_log_growth ,
496548 "positive_growth_scenarios" : positive_growth ,
497- "scenario_count" : scenario_count ,
549+ "scenario_count" : len ( metrics ) ,
498550 "worst_max_drawdown_bps" : max (item ["max_drawdown_bps" ] for item in metrics ),
499551 "worst_relative_drawdown_bps" : max (item ["relative_drawdown_bps" ] for item in metrics ),
500552 "worst_benchmark_drawdown_bps" : worst_benchmark_drawdown ,
@@ -507,9 +559,6 @@ def compose_long_horizon_risk_recommendation(value: Any) -> dict[str, Any]:
507559 if not eligible_frontier :
508560 return _parked_recommendation (validated , ["NO_SCALE_MEETS_COMPOUNDING_AND_DRAWDOWN_CONSTRAINTS" ])
509561 chosen = max (eligible_frontier , key = lambda item : (item ["median_log_growth_ppm" ], - item ["scale_bps" ]))
510- maximum_drawdown = (
511- chosen ["worst_benchmark_drawdown_bps" ] * benchmark_multiple_bps + 9_999
512- ) // 10_000
513562 recommendation : dict [str , Any ] = {
514563 "schema" : RISK_COMPOSER_RECOMMENDATION_SCHEMA_ID ,
515564 "candidate" : dict (validated ["candidate" ]),
@@ -519,7 +568,10 @@ def compose_long_horizon_risk_recommendation(value: Any) -> dict[str, Any]:
519568 "status" : "ADVISORY_RECOMMENDATION_READY" ,
520569 "reason_codes" : [],
521570 "recommended_scale_bps" : chosen ["scale_bps" ],
522- "recommended_max_drawdown_bps" : maximum_drawdown ,
571+ # This is the maximum *observed* drawdown in the selected paired P3
572+ # paths. Eligibility has already enforced a separate profile envelope
573+ # for every path; do not collapse those envelopes into one policy limit.
574+ "recommended_max_drawdown_bps" : chosen ["worst_max_drawdown_bps" ],
523575 "frontier" : frontier ,
524576 "recommendation_sha256" : "" ,
525577 }
0 commit comments