#!/usr/bin/env python3 """Confirm train-learned factor side masks on held-out stocks. The policy keeps only factor sides whose train-stock label lift is positive. It is fixed before held-out replay and does not modify production defaults. """ from __future__ import annotations import argparse import html import json import sys from pathlib import Path from typing import Any import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from scripts.confirm_fixed_sell_pattern_gate import _decision # noqa: E402 from scripts.evaluate_sell_pattern_regime_gates import _prediction_rates # noqa: E402 from scripts.optimize_factor_weights import compute_event_metrics, metric_deltas # noqa: E402 from scripts.search_multi_factor_weight_config import MultiFactorConfig, _now_iso, apply_multi_factor_config # noqa: E402 def _root_path(value: str | Path) -> Path: path = Path(value) return path if path.is_absolute() else ROOT / path def _json_default(value: Any) -> Any: if isinstance(value, np.integer): return int(value) if isinstance(value, np.floating): return float(value) if isinstance(value, np.bool_): return bool(value) if hasattr(value, "isoformat"): return value.isoformat() raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") def _select_stocks(events: pd.DataFrame, *, offset: int, limit: int) -> list[str]: stocks = sorted(events["stock"].astype(str).unique().tolist()) if offset: stocks = stocks[offset:] if limit and limit > 0: stocks = stocks[:limit] return stocks def _split_float_csv(value: str) -> list[float]: return [float(item.strip()) for item in value.split(",") if item.strip()] def _base_label_rates(events: pd.DataFrame) -> dict[str, float]: y = pd.to_numeric(events["y_true"], errors="coerce").fillna(0).to_numpy(dtype=int) return { "buy": float((y == 1).mean() * 100.0) if len(y) else 0.0, "sell": float((y == -1).mean() * 100.0) if len(y) else 0.0, } def _side_lift(events: pd.DataFrame, factor: str, side: str, base_rates: dict[str, float]) -> dict[str, Any]: values = pd.to_numeric(events[f"factor__{factor}"], errors="coerce").fillna(0.0).to_numpy(dtype=float) y = pd.to_numeric(events["y_true"], errors="coerce").fillna(0).to_numpy(dtype=int) mask = values > 0.0 if side == "positive" else values < 0.0 count = int(mask.sum()) if count == 0: return {"count": 0, "precision": None, "lift_pp": None} if side == "positive": precision = float((y[mask] == 1).mean() * 100.0) lift = precision - base_rates["buy"] else: precision = float((y[mask] == -1).mean() * 100.0) lift = precision - base_rates["sell"] return {"count": count, "precision": round(precision, 4), "lift_pp": round(lift, 4)} def _load_config(path: Path) -> MultiFactorConfig: payload = json.loads(path.read_text(encoding="utf-8")) config = payload.get("selected_config") or payload.get("config") or payload return MultiFactorConfig(**config) def learn_policy( events: pd.DataFrame, config: MultiFactorConfig, *, min_lift_pp: float, min_count: int, ) -> dict[str, Any]: base_rates = _base_label_rates(events) factors = [factor for factor, weight in config.weights.items() if abs(float(weight)) > 0.0 and f"factor__{factor}" in events.columns] keep_positive: list[str] = [] keep_negative: list[str] = [] stats: dict[str, Any] = {} for factor in factors: positive = _side_lift(events, factor, "positive", base_rates) negative = _side_lift(events, factor, "negative", base_rates) if positive["count"] >= min_count and positive["lift_pp"] is not None and positive["lift_pp"] >= min_lift_pp: keep_positive.append(factor) if negative["count"] >= min_count and negative["lift_pp"] is not None and negative["lift_pp"] >= min_lift_pp: keep_negative.append(factor) stats[factor] = {"positive": positive, "negative": negative} return { "min_lift_pp": float(min_lift_pp), "min_count": int(min_count), "keep_positive": sorted(keep_positive), "keep_negative": sorted(keep_negative), "factor_stats": stats, } def apply_policy(events: pd.DataFrame, config: MultiFactorConfig, policy: dict[str, Any]) -> pd.DataFrame: output = events.copy() keep_positive = set(policy["keep_positive"]) keep_negative = set(policy["keep_negative"]) for factor in config.weights: column = f"factor__{factor}" if column not in output: continue values = pd.to_numeric(output[column], errors="coerce").fillna(0.0) if factor not in keep_positive: values = values.where(values <= 0.0, 0.0) if factor not in keep_negative: values = values.where(values >= 0.0, 0.0) output[column] = values return output def _evaluate(events: pd.DataFrame, config: MultiFactorConfig, policy: dict[str, Any]) -> dict[str, Any]: candidate_events = apply_policy(events, config, policy) baseline_metrics = compute_event_metrics(events, events["base_pred"].to_numpy(dtype=int)) candidate_pred = apply_multi_factor_config(candidate_events, config) candidate_metrics = compute_event_metrics(events, candidate_pred) deltas = metric_deltas(baseline_metrics, candidate_metrics) return { "baseline": {"metrics": baseline_metrics, "prediction_rates": _prediction_rates(baseline_metrics)}, "candidate": {"metrics": candidate_metrics, "prediction_rates": _prediction_rates(candidate_metrics)}, "deltas": deltas, } def _train_pass(deltas: dict[str, Any], args: argparse.Namespace) -> bool: return ( deltas["accuracy_delta_pp"] >= args.min_accuracy_delta_pp and deltas["direction_accuracy_delta_pp"] >= args.min_direction_accuracy_delta_pp and deltas["buy_precision_delta_pp"] >= args.min_buy_precision_delta_pp and deltas["sell_precision_delta_pp"] >= args.min_sell_precision_delta_pp ) def _rank_train(row: dict[str, Any]) -> tuple[float, ...]: deltas = row["train"]["deltas"] return ( float(row["train_passed"]), float(deltas["sell_precision_delta_pp"]), float(deltas["buy_precision_delta_pp"]), float(deltas["direction_accuracy_delta_pp"]), float(deltas["accuracy_delta_pp"]), -float(row["policy"]["min_lift_pp"]), ) def evaluate(args: argparse.Namespace) -> dict[str, Any]: events = pd.read_pickle(_root_path(args.events_cache)) events["stock"] = events["stock"].astype(str) config = _load_config(_root_path(args.config)) available_weights = { factor: float(weight) for factor, weight in config.weights.items() if f"factor__{factor}" in events.columns and abs(float(weight)) > 0.0 } if not available_weights: raise ValueError("No non-zero config weights are available in the events cache.") config = MultiFactorConfig(weights=available_weights, hold_bias=float(config.hold_bias)) train_stocks = _select_stocks(events, offset=args.train_stock_offset, limit=args.train_limit) heldout_stocks = _select_stocks(events, offset=args.heldout_stock_offset, limit=args.heldout_limit) train = events[events["stock"].isin(train_stocks)].reset_index(drop=True) heldout = events[events["stock"].isin(heldout_stocks)].reset_index(drop=True) rows = [] for min_lift in _split_float_csv(args.min_lift_grid): policy = learn_policy(train, config, min_lift_pp=min_lift, min_count=args.min_count) train_eval = _evaluate(train, config, policy) rows.append( { "policy": policy, "train": train_eval, "train_passed": _train_pass(train_eval["deltas"], args), } ) selected = max(rows, key=_rank_train) heldout_eval = _evaluate(heldout, config, selected["policy"]) heldout_decision = _decision( heldout_eval["deltas"], min_signal_ratio=args.min_signal_ratio, base_metrics=heldout_eval["baseline"]["metrics"], candidate_metrics=heldout_eval["candidate"]["metrics"], ) payload = { "experiment": "train_learned_factor_side_policy_confirmation", "generated_at": _now_iso(), "research_only": True, "production_defaults_modified": False, "events_cache": str(_root_path(args.events_cache)), "config": str(_root_path(args.config)), "validation_design": { "mode": "learn_side_mask_on_train_stocks_confirm_fixed_policy_on_heldout_stocks", "train_stock_offset": args.train_stock_offset, "train_limit": args.train_limit, "heldout_stock_offset": args.heldout_stock_offset, "heldout_limit": args.heldout_limit, "min_count": args.min_count, "min_lift_grid": _split_float_csv(args.min_lift_grid), }, "train_stocks": train_stocks, "heldout_stocks": heldout_stocks, "policy_rows": sorted(rows, key=_rank_train, reverse=True), "selected_policy": selected, "heldout_confirmation": {**heldout_eval, "decision": heldout_decision}, } output_json = _root_path(args.output_json) output_json.parent.mkdir(parents=True, exist_ok=True) output_json.write_text(json.dumps(payload, indent=2, ensure_ascii=False, default=_json_default)) if args.output_html: render_html(payload, _root_path(args.output_html)) return payload def _fmt(value: Any, suffix: str = "") -> str: if value is None: return "-" if isinstance(value, float): return f"{value:.4f}{suffix}" return f"{value}{suffix}" def _metric_row(label: str, item: dict[str, Any]) -> str: metrics = item["metrics"] rates = item["prediction_rates"] return ( f"{html.escape(label)}{_fmt(metrics['accuracy'], '%')}" f"{_fmt(metrics['direction_accuracy'], '%')}{_fmt(metrics['buy_precision'], '%')}" f"{_fmt(metrics['sell_precision'], '%')}{_fmt(rates['buy_rate'], '%')}" f"{_fmt(rates['hold_rate'], '%')}{_fmt(rates['sell_rate'], '%')}" ) def render_html(payload: dict[str, Any], path: Path) -> None: selected = payload["selected_policy"] heldout = payload["heldout_confirmation"] decision_html = 'PASS' if heldout["decision"]["passed"] else 'FAIL' policy = selected["policy"] row_html = [] for row in payload["policy_rows"]: deltas = row["train"]["deltas"] row_html.append( "" f"{'PASS' if row['train_passed'] else 'FAIL'}" f"{_fmt(row['policy']['min_lift_pp'], 'pp')}" f"{len(row['policy']['keep_positive'])}{len(row['policy']['keep_negative'])}" f"{_fmt(deltas['accuracy_delta_pp'], 'pp')}" f"{_fmt(deltas['direction_accuracy_delta_pp'], 'pp')}" f"{_fmt(deltas['buy_precision_delta_pp'], 'pp')}" f"{_fmt(deltas['sell_precision_delta_pp'], 'pp')}" ) body = f""" Train Learned Factor Side Policy Confirmation

Train Learned Factor Side Policy Confirmation

Generated: {html.escape(payload['generated_at'])}

Held-out decision: {decision_html}

Selected Policy

Min lift: {_fmt(policy['min_lift_pp'], 'pp')} · Kept positive sides: {len(policy['keep_positive'])} · Kept negative sides: {len(policy['keep_negative'])}

{html.escape(json.dumps({'keep_positive': policy['keep_positive'], 'keep_negative': policy['keep_negative']}, ensure_ascii=False, indent=2))}

Held-Out Confirmation

{_metric_row('Baseline held-out', heldout['baseline'])} {_metric_row('Candidate held-out', heldout['candidate'])}
ScopeAccuracyDirection AccBUY PrecisionSELL PrecisionBUY RateHOLD RateSELL Rate
Delta{_fmt(heldout['deltas']['accuracy_delta_pp'], 'pp')}{_fmt(heldout['deltas']['direction_accuracy_delta_pp'], 'pp')}{_fmt(heldout['deltas']['buy_precision_delta_pp'], 'pp')}{_fmt(heldout['deltas']['sell_precision_delta_pp'], 'pp')}

Train Policy Sweep

{''.join(row_html)}
Train StatusMin LiftKeep +Keep -Acc DeltaDirection DeltaBUY DeltaSELL Delta
""" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(body) def main() -> int: parser = argparse.ArgumentParser(description="Confirm train-learned factor side policy on held-out stocks.") parser.add_argument("--events-cache", default="docs/validation_runs/factor44_split_problem_v1_buy_factor_interaction_adaptive_macro_1000_events.pkl") parser.add_argument("--config", default="config/multi_factor_overlay.json") parser.add_argument("--train-stock-offset", type=int, default=0) parser.add_argument("--train-limit", type=int, default=1000) parser.add_argument("--heldout-stock-offset", type=int, default=1000) parser.add_argument("--heldout-limit", type=int, default=0) parser.add_argument("--min-count", type=int, default=1000) parser.add_argument("--min-lift-grid", default="0,0.5,1,2,3,5") parser.add_argument("--min-accuracy-delta-pp", type=float, default=0.0) parser.add_argument("--min-buy-precision-delta-pp", type=float, default=0.0) parser.add_argument("--min-sell-precision-delta-pp", type=float, default=0.0) parser.add_argument("--min-direction-accuracy-delta-pp", type=float, default=0.0) parser.add_argument("--min-signal-ratio", type=float, default=0.70) parser.add_argument("--output-json", default="docs/validation_runs/train_learned_side_policy_1000train_199heldout_20260612.json") parser.add_argument("--output-html", default="docs/validation_runs/train_learned_side_policy_1000train_199heldout_20260612.html") args = parser.parse_args() payload = evaluate(args) heldout = payload["heldout_confirmation"] print(f"Selected min lift: {payload['selected_policy']['policy']['min_lift_pp']}") print(f"Kept positive sides: {len(payload['selected_policy']['policy']['keep_positive'])}") print(f"Kept negative sides: {len(payload['selected_policy']['policy']['keep_negative'])}") print(f"Held-out decision: {'PASS' if heldout['decision']['passed'] else 'FAIL'}") print(f"Held-out baseline: {heldout['baseline']['metrics']}") print(f"Held-out candidate: {heldout['candidate']['metrics']}") print(f"Held-out deltas: {heldout['deltas']}") print(f"Saved JSON -> {_root_path(args.output_json)}") if args.output_html: print(f"Saved HTML -> {_root_path(args.output_html)}") return 0 if __name__ == "__main__": raise SystemExit(main())