← Files Public Equity InvestingARCHIVED FILE

skills/portfolio-risk-management/scripts/score_hedge_candidates.py

12.8 KB · Oct 5, 2026 · 12:04 UTC

↓ Download file

#!/usr/bin/env python3
"""Materialize a deterministic hedge candidate scorecard and basis-risk ledger."""

from __future__ import annotations

import argparse
import csv
import json
import math
from datetime import datetime
from pathlib import Path
from typing import Any

REQUIRED = ["hedge", "hedge_type", "risk_hedged"]

SCORE_WEIGHTS = {
    "exposure_fit_score": 1.30,
    "thesis_preservation_score": 1.25,
    "relationship_stability_score": 0.95,
    "downside_behavior_score": 1.15,
    "tenor_alignment_score": 0.80,
    "cost_carry_score": 0.90,
    "liquidity_score": 0.85,
    "implementation_complexity_score": 0.60,
    "basis_risk_score": 1.20,
}

SCORECARD_FIELDS = [
    "rank",
    "hedge",
    "hedge_type",
    "risk_hedged",
    "target_position",
    "composite_score",
    "bucket",
    "valid_score_count",
    "implementation_status",
    "as_of",
    "source",
    "readiness_status",
    "live_pricing_status",
    "borrow_status",
    "option_chain_status",
    "risk_model_status",
    "basis_risk",
    "basis_risk_mitigation",
    "monitoring_trigger",
    "warnings",
]

BASIS_FIELDS = [
    "hedge",
    "hedge_type",
    "risk_hedged",
    "basis_risk",
    "severity",
    "why_it_matters",
    "mitigation",
    "monitoring_trigger",
    "implementation_status",
    "as_of",
    "source",
    "readiness_status",
    "live_pricing_status",
    "borrow_status",
    "option_chain_status",
    "risk_model_status",
]

READINESS_FIELDS = [
    "live_pricing_status",
    "borrow_status",
    "option_chain_status",
    "risk_model_status",
]

READY_STATUSES = {"current", "available", "confirmed", "validated", "not_applicable", "n/a", "na"}

CREDIT_HEDGE_WARNING = (
    "Credit hedge construction belongs in Credit Markets; use here only as "
    "public-equity risk context."
)

CREDIT_HEDGE_TYPE_PATTERNS = {
    "cds",
    "credit default swap",
    "bond",
    "bonds",
    "loan",
    "loans",
    "bank loan",
    "leveraged loan",
    "high yield",
    "investment grade",
    "spread dv01",
    "spread_dv01",
    "spread hedge",
    "credit spread",
    "cs01",
    "dv01",
    "capital structure",
    "capital-structure",
    "distressed",
    "recovery",
    "covenant",
    "credit hedge",
    "credit instrument",
    "credit security",
}


def _normalized_text(value: Any) -> str:
    return str(value or "").strip().lower().replace("_", " ").replace("/", " ").replace("-", " ")


def is_credit_hedge_type(value: Any) -> bool:
    text = _normalized_text(value)
    if not text:
        return False
    return any(
        pattern.replace("_", " ").replace("-", " ") in text
        for pattern in CREDIT_HEDGE_TYPE_PATTERNS
    )


def row_has_credit_hedge(row: dict[str, str]) -> bool:
    return any(
        is_credit_hedge_type(row.get(field))
        for field in ["hedge_type", "instrument_type", "security_type", "asset_class"]
    )


def parse_score(value: Any, field: str, warnings: list[str]) -> float | None:
    text = str(value or "").strip().replace(",", "")
    if not text:
        return None
    try:
        score = float(text)
    except ValueError:
        warnings.append(f"{field} invalid numeric value {value!r}")
        return None
    if not math.isfinite(score):
        warnings.append(f"{field} is not finite")
        return None
    if score < 0:
        warnings.append(f"{field} negative score excluded")
        return None
    if score <= 5:
        return score / 5.0 * 100.0
    if score <= 100:
        return score
    warnings.append(f"{field} above 100 capped at 100")
    return 100.0


def bucket(score: float | None) -> str:
    if score is None:
        return "needs_scoring_inputs"
    if score >= 80:
        return "primary hedge candidate"
    if score >= 65:
        return "usable with checks"
    if score >= 50:
        return "watchlist / situational"
    return "reject or resize instead"


def severity_from_basis(score: float | None, text: str) -> str:
    lowered = text.lower()
    if any(
        term in lowered
        for term in ["high", "severe", "correlation break", "over-hedge", "wrong tenor"]
    ):
        return "high"
    if score is None:
        return "needs_review"
    if score >= 75:
        return "low"
    if score >= 50:
        return "medium"
    return "high"


def valid_date(value: str) -> bool:
    try:
        datetime.strptime(value, "%Y-%m-%d")
    except ValueError:
        return False
    return True


def readiness_status(row: dict[str, str], warnings: list[str]) -> str:
    as_of = str(row.get("as_of") or row.get("source_as_of") or "").strip()
    source = str(row.get("source") or "").strip()
    if not as_of:
        warnings.append("missing as_of")
    elif not valid_date(as_of):
        warnings.append("as_of invalid date; expected YYYY-MM-DD")
    if not source:
        warnings.append("missing source")

    missing_statuses: list[str] = []
    weak_statuses: list[str] = []
    for field in READINESS_FIELDS:
        status = str(row.get(field) or "").strip().lower()
        if not status:
            missing_statuses.append(field)
        elif status not in READY_STATUSES:
            weak_statuses.append(f"{field}={status}")

    if missing_statuses:
        warnings.append("missing readiness fields: " + ", ".join(missing_statuses))
    if weak_statuses:
        warnings.append("non-ready readiness fields: " + ", ".join(weak_statuses))

    if not as_of or not source or missing_statuses:
        return "screen-grade"
    if weak_statuses:
        return "needs-targeted-checks"
    return "implementation-data-ready"


def load_rows(path: Path) -> list[dict[str, str]]:
    with path.open(newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        fields = [str(f or "").strip() for f in (reader.fieldnames or [])]
        missing = [field for field in REQUIRED if field not in fields]
        if missing:
            raise ValueError("missing required columns: " + ", ".join(missing))
        rows = [dict(row) for row in reader]
    if not rows:
        raise ValueError("input CSV must include at least one hedge candidate")
    return rows


def score_row(row: dict[str, str], index: int) -> dict[str, Any]:
    warnings: list[str] = []
    for field in REQUIRED:
        if not str(row.get(field) or "").strip():
            raise ValueError(f"row {index}: {field} is required")
    weighted = 0.0
    weight_total = 0.0
    valid_count = 0
    score_values: dict[str, float | None] = {}
    for field, weight in SCORE_WEIGHTS.items():
        value = parse_score(row.get(field), field, warnings)
        score_values[field] = value
        if value is None:
            continue
        weighted += value * weight
        weight_total += weight
        valid_count += 1
    composite = round(weighted / weight_total, 2) if weight_total else None
    if valid_count == 0:
        warnings.append("no valid score fields provided")
    if not str(row.get("basis_risk") or "").strip():
        warnings.append("missing basis_risk")
    if not str(row.get("implementation_status") or "").strip():
        warnings.append("missing implementation_status")
    readiness = readiness_status(row, warnings)
    credit_handoff = row_has_credit_hedge(row)
    if credit_handoff:
        warnings.append(CREDIT_HEDGE_WARNING)
        readiness = "route_to_credit_markets"

    basis_score = score_values.get("basis_risk_score")
    implementation_status = str(row.get("implementation_status") or "").strip()
    if credit_handoff:
        implementation_status = "route_to_credit_markets"
    candidate_bucket = bucket(composite)
    if credit_handoff:
        candidate_bucket = "route to Credit Markets"
    return {
        "rank": 0,
        "hedge": str(row.get("hedge") or "").strip(),
        "hedge_type": str(row.get("hedge_type") or "").strip(),
        "risk_hedged": str(row.get("risk_hedged") or "").strip(),
        "target_position": str(row.get("target_position") or "").strip(),
        "retained_exposure": str(row.get("retained_exposure") or "").strip(),
        "composite_score": composite,
        "bucket": candidate_bucket,
        "valid_score_count": valid_count,
        "implementation_status": implementation_status,
        "as_of": str(row.get("as_of") or row.get("source_as_of") or "").strip(),
        "source": str(row.get("source") or "").strip(),
        "readiness_status": readiness,
        "live_pricing_status": str(row.get("live_pricing_status") or "").strip(),
        "borrow_status": str(row.get("borrow_status") or "").strip(),
        "option_chain_status": str(row.get("option_chain_status") or "").strip(),
        "risk_model_status": str(row.get("risk_model_status") or "").strip(),
        "basis_risk": str(row.get("basis_risk") or "").strip(),
        "basis_risk_mitigation": str(
            row.get("basis_risk_mitigation") or row.get("mitigation") or ""
        ).strip(),
        "monitoring_trigger": str(row.get("monitoring_trigger") or "").strip(),
        "basis_risk_severity": severity_from_basis(basis_score, str(row.get("basis_risk") or "")),
        "warnings": "; ".join(warnings),
    }


def materialize(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
    scored = [score_row(row, idx) for idx, row in enumerate(rows, start=2)]
    scored.sort(
        key=lambda row: (
            row["readiness_status"] == "route_to_credit_markets",
            -1 if row["composite_score"] is None else -row["composite_score"],
            row["hedge"],
        )
    )
    for rank, row in enumerate(scored, start=1):
        row["rank"] = rank
    return scored


def write_csv(path: Path, fields: list[str], rows: list[dict[str, Any]]) -> None:
    with path.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=fields)
        writer.writeheader()
        for row in rows:
            writer.writerow(
                {field: "" if row.get(field) is None else row.get(field, "") for field in fields}
            )


def basis_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    out = []
    for row in rows:
        out.append(
            {
                "hedge": row["hedge"],
                "hedge_type": row["hedge_type"],
                "risk_hedged": row["risk_hedged"],
                "basis_risk": row["basis_risk"],
                "severity": row["basis_risk_severity"],
                "why_it_matters": "Hedge can fail if this mismatch dominates the target exposure.",
                "mitigation": row["basis_risk_mitigation"],
                "monitoring_trigger": row["monitoring_trigger"],
                "implementation_status": row["implementation_status"],
                "as_of": row["as_of"],
                "source": row["source"],
                "readiness_status": row["readiness_status"],
                "live_pricing_status": row["live_pricing_status"],
                "borrow_status": row["borrow_status"],
                "option_chain_status": row["option_chain_status"],
                "risk_model_status": row["risk_model_status"],
            }
        )
    return out


def markdown(rows: list[dict[str, Any]]) -> str:
    lines = [
        "# Hedge Candidate Scorecard",
        "",
        "> Deterministic materializer from supplied inputs only. Confirm live pricing, borrow, options, liquidity, and risk-model data before implementation.",
        "",
        "| Rank | Hedge | Type | Risk Hedged | Score | Bucket | Readiness | Implementation | Basis Risk | Warnings |",
        "|---:|---|---|---|---:|---|---|---|---|---|",
    ]
    for row in rows:
        score = "" if row["composite_score"] is None else f"{row['composite_score']:.2f}"
        lines.append(
            f"| {row['rank']} | {row['hedge']} | {row['hedge_type']} | {row['risk_hedged']} | {score} | {row['bucket']} | {row['readiness_status']} | {row['implementation_status']} | {row['basis_risk']} | {row['warnings']} |"
        )
    return "\n".join(lines) + "\n"


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Score supplied public-equity-investing hedge candidates."
    )
    parser.add_argument("input_csv", type=Path)
    parser.add_argument("--output-dir", type=Path, default=Path("output"))
    args = parser.parse_args()
    try:
        rows = materialize(load_rows(args.input_csv))
    except Exception as exc:
        print(f"ERROR: {exc}")
        return 1
    args.output_dir.mkdir(parents=True, exist_ok=True)
    write_csv(args.output_dir / "hedge_scorecard.csv", SCORECARD_FIELDS, rows)
    write_csv(args.output_dir / "basis_risk_ledger.csv", BASIS_FIELDS, basis_rows(rows))
    (args.output_dir / "hedge_scorecard.json").write_text(
        json.dumps({"rows": rows, "basis_risk_ledger": basis_rows(rows)}, indent=2) + "\n",
        encoding="utf-8",
    )
    (args.output_dir / "hedge_scorecard_support_note.md").write_text(
        markdown(rows), encoding="utf-8"
    )
    print(f"Wrote hedge scorecard outputs to {args.output_dir}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 74eac397cc4c5661b5c799076291faddf9e358da7bdfbd716e7d6cf9c1417ced