#!/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"
Generated: {html.escape(payload['generated_at'])}
Held-out decision: {decision_html}
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))}
| Scope | Accuracy | Direction Acc | BUY Precision | SELL Precision | BUY Rate | HOLD Rate | SELL 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 Status | Min Lift | Keep + | Keep - | Acc Delta | Direction Delta | BUY Delta | SELL Delta |
|---|