"""Probe Schedule 13D/13G ownership filings as a low-frequency idle-alpha sleeve.

This is a standalone research helper. It does not modify shared runtime code.

Data sources used by the probe:
- local symbol/issuer/CIK mappings from Postgres (`symbol_master`, `issuer_master`)
- local snapshot store bars/features from the chosen experiment config
- SEC quarterly EDGAR company index files (`company.idx`) cached under `data/cache`
- SEC filing text for the matched ownership filings, also cached locally

The probe is intentionally conservative:
- it treats filings as actionable only after the filing date, entering next open
- it defaults to initial 13D / 13D amendments / initial 13G only
- it requires basic liquidity via 20d average dollar volume
- it sizes the additive overlay only from residual `cash_available`
"""

from __future__ import annotations

import argparse
import asyncio
import datetime as dt
import json
import re
from collections import Counter, defaultdict
from dataclasses import dataclass
from html import unescape
from pathlib import Path
from typing import Any

import requests
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine

from apps.backtester.run import _build_merged_snapshot_store, load_manifest, resolve_config
from apps.tools.put_spread_overlay_probe import _build_runner, _compute_probe_metrics
from libs.backtest.domain import DailyPortfolioState
from libs.backtest.metrics import (
    compute_max_drawdown_pct,
    compute_sharpe_ratio,
    compute_total_return_pct,
)
from libs.common.logging import configure_logging
from libs.common.config import get_settings

USER_AGENT = "fithia2-ownership-13d13g-probe/1.0 (local research; contact: dev@example.com)"
TARGET_FORMS = (
    "SC 13D",
    "SC 13D/A",
    "SC 13G",
    "SCHEDULE 13D",
    "SCHEDULE 13D/A",
    "SCHEDULE 13G",
)
OPTIONAL_13G_AMENDMENT_FORMS = (
    "SC 13G/A",
    "SCHEDULE 13G/A",
)
INDEX_LINE_RE = re.compile(
    r"^(?P<company>.+?)\s{2,}"
    r"(?P<form>SC 13D/A|SC 13D|SC 13G/A|SC 13G|SCHEDULE 13D/A|SCHEDULE 13D|SCHEDULE 13G/A|SCHEDULE 13G)"
    r"\s+(?P<cik>\d+)\s+(?P<filed>\d{4}-\d{2}-\d{2})\s+(?P<filename>edgar/data/.+)$"
)
XML_VALUE_PATTERNS: dict[str, tuple[str, ...]] = {
    "owner": ("reportingPersonName",),
    "percent": ("percentOfClass", "classPercent", "percentageOfClassSecurities"),
    "event_date": ("dateOfEvent", "eventDateRequiresFilingThisStatement", "date5PercentOwnership"),
    "purpose": ("transactionPurpose",),
    "aggregate": (
        "aggregateAmountOwned",
        "reportingPersonBeneficiallyOwnedAggregateNumberOfShares",
    ),
}
HTML_OWNER_RE = re.compile(
    r"Names of Reporting Persons.*?<div class=\"text\">(.*?)</div>",
    re.IGNORECASE | re.DOTALL,
)
HTML_OWNER_BOLD_RE = re.compile(
    r"NAMES OF REPORTING PERSONS.*?<B>(.*?)</B>",
    re.IGNORECASE | re.DOTALL,
)
HTML_PERCENT_RE = re.compile(
    r"Percent of class(?: represented by amount in row \([0-9]+\)|:).*?"
    r"<div class=\"(?:text|largetext)\">(.*?)</div>",
    re.IGNORECASE | re.DOTALL,
)
HTML_PERCENT_BOLD_RE = re.compile(
    r"PERCENT OF CLASS REPRESENTED BY AMOUNT IN ROW \([0-9]+\).*?<B>([0-9]+(?:\.[0-9]+)?%)",
    re.IGNORECASE | re.DOTALL,
)
HTML_PURPOSE_RE = re.compile(
    r"Item 4\..*?(?:Purpose of Transaction|Ownership).*?<div class=\"(?:text|largetext)\">(.*?)</div>",
    re.IGNORECASE | re.DOTALL,
)
PLAIN_FILED_BY_OWNER_RE = re.compile(
    r"FILED BY:.*?COMPANY CONFORMED NAME:\s+([^\n]+)",
    re.IGNORECASE | re.DOTALL,
)
PLAIN_ITEM2_OWNER_RE = re.compile(
    r"Item 2\.\s+.*?Name of person filing:\s*-+\s*([^\n]+)",
    re.IGNORECASE | re.DOTALL,
)
PLAIN_PERCENT_RE = re.compile(
    r"Percent of class represented by amount in Row\s+\d+\)?\s+([0-9]+(?:\.[0-9]+)?%)",
    re.IGNORECASE | re.DOTALL,
)
PLAIN_PURPOSE_RE = re.compile(
    r"Item 4\.\s+Purpose of Transaction\.(.*?)(?:Item 5\.|Item 6\.)",
    re.IGNORECASE | re.DOTALL,
)
TAG_VALUE_RE_TEMPLATE = r"<{tag}>(.*?)</{tag}>"
ACTIVIST_KEYWORDS = (
    "board",
    "director",
    "proxy",
    "strategic alternative",
    "strategic alternatives",
    "sale process",
    "merger",
    "acquisition",
    "take-private",
    "take private",
    "tender offer",
    "restructuring",
    "spin-off",
    "spinoff",
    "capital allocation",
    "shareholder value",
    "engage with management",
    "nominate",
)
NEGATIVE_PURPOSE_PHRASES = (
    "no plans or proposals",
    "not have any present plans or proposals",
)
HOUSEKEEPING_PURPOSE_PHRASES = (
    "continued to hold",
    "shareholding percentage",
    "shareholding percent",
    "no amendment to this item",
    "change in the number of outstanding",
    "number of outstanding shares",
    "resulted solely from",
    "solely as a result of",
    "solely due to",
)


@dataclass(frozen=True)
class FilingRecord:
    symbol: str
    cik: str
    company_name: str
    form_type: str
    filing_date: dt.date
    filename: str


@dataclass(frozen=True)
class OwnershipEvent:
    symbol: str
    cik: str
    filing_date: dt.date
    form_type: str
    owner_name: str | None
    owner_key: str
    percent_owned: float | None
    aggregate_shares: float | None
    event_date: dt.date | None
    purpose_text: str | None
    purpose_housekeeping_flag: bool
    activist_flag: bool
    is_amendment: bool
    prior_percent_owned: float | None
    percent_delta_points: float | None
    prior_form_group: str | None
    is_initial_for_owner: bool
    is_13g_to_13d_transition: bool
    ownership_strength_score: int


@dataclass(frozen=True)
class OwnershipSpec:
    name: str
    form_groups: tuple[str, ...]
    hold_days: int
    min_percent_owned: float = 0.0
    min_percent_delta_points: float = 0.0
    require_amendment: bool = False
    require_initial: bool = False
    require_activist: bool = False
    require_13g_to_13d_transition: bool = False
    min_avg_dollar_volume: float = 10_000_000.0
    max_positions: int = 6
    max_new_per_day: int = 2
    min_cash_ratio_for_overlay: float = 0.25
    max_idle_deploy_pct: float = 1.0


def _parse_date(value: str, *, is_end: bool = False) -> dt.date:
    parts = value.split("-")
    if len(parts) == 1 and len(value) == 4 and value.isdigit():
        year = int(value)
        return dt.date(year, 12, 31) if is_end else dt.date(year, 1, 1)
    if len(parts) == 2 and all(part.isdigit() for part in parts):
        year = int(parts[0])
        month = int(parts[1])
        if is_end:
            next_month = dt.date(year + (month // 12), (month % 12) + 1, 1)
            return next_month - dt.timedelta(days=1)
        return dt.date(year, month, 1)
    return dt.date.fromisoformat(value)


def _year_quarter_range(start_date: dt.date, end_date: dt.date) -> list[tuple[int, int]]:
    year = start_date.year
    quarter = (start_date.month - 1) // 3 + 1
    end_key = (end_date.year, (end_date.month - 1) // 3 + 1)
    quarters: list[tuple[int, int]] = []
    while (year, quarter) <= end_key:
        quarters.append((year, quarter))
        quarter += 1
        if quarter == 5:
            quarter = 1
            year += 1
    return quarters


def _index_cache_path(cache_dir: Path, year: int, quarter: int) -> Path:
    return cache_dir / "indices" / f"{year}_Q{quarter}_company.idx"


def _filing_cache_path(cache_dir: Path, filename: str) -> Path:
    accession = filename.split("/")[-1]
    return cache_dir / "filings" / accession


def _ensure_company_index(
    session: requests.Session,
    cache_dir: Path,
    year: int,
    quarter: int,
) -> Path:
    path = _index_cache_path(cache_dir, year, quarter)
    if path.exists() and path.stat().st_size > 0:
        return path
    path.parent.mkdir(parents=True, exist_ok=True)
    url = f"https://www.sec.gov/Archives/edgar/full-index/{year}/QTR{quarter}/company.idx"
    response = session.get(url, timeout=90)
    response.raise_for_status()
    path.write_text(response.text)
    return path


def _normalize_form_group(form_type: str) -> str:
    form_upper = form_type.upper()
    return "13D" if "13D" in form_upper else "13G"


def _iter_index_records(path: Path, *, include_13g_amendments: bool) -> list[FilingRecord]:
    allowed_forms = set(TARGET_FORMS)
    if include_13g_amendments:
        allowed_forms.update(OPTIONAL_13G_AMENDMENT_FORMS)
    rows: list[FilingRecord] = []
    for line in path.read_text(errors="ignore").splitlines():
        match = INDEX_LINE_RE.match(line.rstrip())
        if match is None:
            continue
        form_type = match.group("form").strip().upper()
        if form_type not in allowed_forms:
            continue
        rows.append(
            FilingRecord(
                symbol="",
                cik=match.group("cik").zfill(10),
                company_name=match.group("company").strip(),
                form_type=form_type,
                filing_date=dt.date.fromisoformat(match.group("filed")),
                filename=match.group("filename").strip(),
            )
        )
    return rows


async def _load_symbol_cik_map(allowed_symbols: set[str]) -> tuple[dict[str, str], dict[str, str]]:
    engine = create_async_engine(get_settings().postgres_dsn)
    try:
        async with engine.connect() as conn:
            result = await conn.execute(
                text(
                    """
                    select
                        upper(sm.ticker) as ticker,
                        lpad(coalesce(im.cik, ''), 10, '0') as cik
                    from symbol_master sm
                    join issuer_master im on im.issuer_id = sm.issuer_id
                    where sm.is_primary = true
                      and upper(sm.ticker) = any(:symbols)
                    """
                ),
                {"symbols": sorted(allowed_symbols)},
            )
            rows = result.all()
    finally:
        await engine.dispose()
    symbol_to_cik = {ticker: cik for ticker, cik in rows if cik and cik.strip("0")}
    cik_to_symbol = {cik: ticker for ticker, cik in symbol_to_cik.items()}
    return symbol_to_cik, cik_to_symbol


def _fetch_filing_text(session: requests.Session, cache_dir: Path, filename: str) -> str:
    path = _filing_cache_path(cache_dir, filename)
    if path.exists() and path.stat().st_size > 0:
        return path.read_text(errors="ignore")
    path.parent.mkdir(parents=True, exist_ok=True)
    url = f"https://www.sec.gov/Archives/{filename}"
    response = session.get(url, timeout=60)
    response.raise_for_status()
    path.write_text(response.text)
    return response.text


def _clean_text_value(value: str | None) -> str | None:
    if value is None:
        return None
    cleaned = unescape(re.sub(r"<[^>]+>", " ", value))
    cleaned = re.sub(r"\s+", " ", cleaned.replace("\xa0", " ")).strip()
    return cleaned or None


def _extract_tag_values(text_value: str, tag: str) -> list[str]:
    pattern = re.compile(TAG_VALUE_RE_TEMPLATE.format(tag=re.escape(tag)), re.IGNORECASE | re.DOTALL)
    values = [_clean_text_value(match) for match in pattern.findall(text_value)]
    return [value for value in values if value]


def _extract_first_date(text_value: str, tags: tuple[str, ...]) -> dt.date | None:
    for tag in tags:
        for raw in _extract_tag_values(text_value, tag):
            for fmt in ("%m/%d/%Y", "%Y-%m-%d", "%m/%d/%y"):
                try:
                    return dt.datetime.strptime(raw, fmt).date()
                except ValueError:
                    continue
    return None


def _extract_numeric_candidates(text_value: str, tags: tuple[str, ...]) -> list[float]:
    values: list[float] = []
    for tag in tags:
        for raw in _extract_tag_values(text_value, tag):
            cleaned = raw.replace("%", "").replace(",", "").strip()
            try:
                values.append(float(cleaned))
            except ValueError:
                continue
    return values


def _extract_owner_names(text_value: str) -> list[str]:
    names = _extract_tag_values(text_value, "reportingPersonName")
    if names:
        return names
    html_match = HTML_OWNER_RE.search(text_value)
    if html_match:
        cleaned = _clean_text_value(html_match.group(1))
        if cleaned:
            return [cleaned]
    html_bold_match = HTML_OWNER_BOLD_RE.search(text_value)
    if html_bold_match:
        cleaned = _clean_text_value(html_bold_match.group(1))
        if cleaned:
            return [cleaned]
    for pattern in (PLAIN_ITEM2_OWNER_RE, PLAIN_FILED_BY_OWNER_RE):
        match = pattern.search(text_value)
        if match:
            cleaned = _clean_text_value(match.group(1))
            if cleaned:
                return [cleaned]
    return []


def _extract_percent_owned(text_value: str) -> float | None:
    candidates = _extract_numeric_candidates(text_value, XML_VALUE_PATTERNS["percent"])
    filtered = [value for value in candidates if 0.0 <= value <= 100.0]
    if filtered:
        return max(filtered)
    html_match = HTML_PERCENT_RE.search(text_value)
    if html_match:
        cleaned = _clean_text_value(html_match.group(1))
        if cleaned is not None:
            match = re.search(r"([0-9]+(?:\.[0-9]+)?)", cleaned)
            if match:
                try:
                    value = float(match.group(1))
                    if 0.0 <= value <= 100.0:
                        return value
                except ValueError:
                    return None
    html_bold_match = HTML_PERCENT_BOLD_RE.search(text_value)
    if html_bold_match:
        try:
            return float(html_bold_match.group(1).replace("%", ""))
        except ValueError:
            return None
    plain_match = PLAIN_PERCENT_RE.search(text_value)
    if plain_match:
        try:
            return float(plain_match.group(1).replace("%", ""))
        except ValueError:
            return None
    return None


def _extract_aggregate_shares(text_value: str) -> float | None:
    candidates = _extract_numeric_candidates(text_value, XML_VALUE_PATTERNS["aggregate"])
    if candidates:
        return max(candidates)
    return None


def _extract_purpose_text(text_value: str) -> str | None:
    for tag in XML_VALUE_PATTERNS["purpose"]:
        values = _extract_tag_values(text_value, tag)
        if values:
            return values[0]
    html_match = HTML_PURPOSE_RE.search(text_value)
    if html_match:
        return _clean_text_value(html_match.group(1))
    plain_match = PLAIN_PURPOSE_RE.search(text_value)
    if plain_match:
        return _clean_text_value(plain_match.group(1))
    return None


def _is_activist_purpose(purpose_text: str | None, form_group: str) -> bool:
    if not purpose_text:
        return False
    lowered = purpose_text.lower()
    if any(phrase in lowered for phrase in NEGATIVE_PURPOSE_PHRASES):
        return any(keyword in lowered for keyword in ACTIVIST_KEYWORDS if "board" in keyword or "proxy" in keyword)
    if form_group != "13D":
        return False
    return any(keyword in lowered for keyword in ACTIVIST_KEYWORDS)


def _is_housekeeping_purpose(purpose_text: str | None, activist_flag: bool) -> bool:
    if not purpose_text or activist_flag:
        return False
    lowered = purpose_text.lower()
    return any(phrase in lowered for phrase in HOUSEKEEPING_PURPOSE_PHRASES)


def _compute_ownership_strength_score(
    *,
    form_group: str,
    activist_flag: bool,
    is_initial_for_owner: bool,
    is_13g_to_13d_transition: bool,
    percent_owned: float | None,
    percent_delta_points: float | None,
    purpose_housekeeping_flag: bool,
) -> int:
    score = 0
    if form_group == "13D":
        score += 3
    if activist_flag:
        score += 3
    if is_13g_to_13d_transition:
        score += 2
    if is_initial_for_owner:
        score += 1
    if (percent_owned or 0.0) >= 5.0:
        score += 1
    if (percent_delta_points or 0.0) >= 1.0:
        score += 1
    if purpose_housekeeping_flag:
        score -= 3
    if form_group == "13G" and not activist_flag:
        score -= 2
    return score


def _normalize_owner_key(owner_name: str | None) -> str:
    if not owner_name:
        return "unknown"
    cleaned = re.sub(r"[^a-z0-9]+", " ", owner_name.lower()).strip()
    cleaned = re.sub(r"\s+", " ", cleaned)
    return cleaned or "unknown"


def _parse_ownership_event(record: FilingRecord, text_value: str) -> OwnershipEvent:
    owner_names = _extract_owner_names(text_value)
    owner_name = owner_names[0] if owner_names else None
    form_group = _normalize_form_group(record.form_type)
    percent_owned = _extract_percent_owned(text_value)
    aggregate_shares = _extract_aggregate_shares(text_value)
    event_date = _extract_first_date(text_value, XML_VALUE_PATTERNS["event_date"])
    purpose_text = _extract_purpose_text(text_value)
    activist_flag = _is_activist_purpose(purpose_text, form_group)
    return OwnershipEvent(
        symbol=record.symbol,
        cik=record.cik,
        filing_date=record.filing_date,
        form_type=record.form_type,
        owner_name=owner_name,
        owner_key=_normalize_owner_key(owner_name),
        percent_owned=percent_owned,
        aggregate_shares=aggregate_shares,
        event_date=event_date,
        purpose_text=purpose_text,
        purpose_housekeeping_flag=_is_housekeeping_purpose(purpose_text, activist_flag),
        activist_flag=activist_flag,
        is_amendment="/A" in record.form_type,
        prior_percent_owned=None,
        percent_delta_points=None,
        prior_form_group=None,
        is_initial_for_owner=False,
        is_13g_to_13d_transition=False,
        ownership_strength_score=0,
    )


def _attach_history_context(events: list[OwnershipEvent]) -> list[OwnershipEvent]:
    grouped: dict[tuple[str, str], list[OwnershipEvent]] = defaultdict(list)
    for event in events:
        grouped[(event.symbol, event.owner_key)].append(event)
    enriched: list[OwnershipEvent] = []
    for _, rows in grouped.items():
        rows_sorted = sorted(rows, key=lambda row: (row.filing_date, row.form_type))
        prior_percent: float | None = None
        prior_form_group: str | None = None
        for row in rows_sorted:
            current_form_group = _normalize_form_group(row.form_type)
            delta = None
            if row.percent_owned is not None and prior_percent is not None:
                delta = row.percent_owned - prior_percent
            enriched.append(
                OwnershipEvent(
                    symbol=row.symbol,
                    cik=row.cik,
                    filing_date=row.filing_date,
                    form_type=row.form_type,
                    owner_name=row.owner_name,
                    owner_key=row.owner_key,
                    percent_owned=row.percent_owned,
                    aggregate_shares=row.aggregate_shares,
                    event_date=row.event_date,
                    purpose_text=row.purpose_text,
                    purpose_housekeeping_flag=row.purpose_housekeeping_flag,
                    activist_flag=row.activist_flag,
                    is_amendment=row.is_amendment,
                    prior_percent_owned=prior_percent,
                    percent_delta_points=delta,
                    prior_form_group=prior_form_group,
                    is_initial_for_owner=prior_form_group is None,
                    is_13g_to_13d_transition=(prior_form_group == "13G" and current_form_group == "13D"),
                    ownership_strength_score=_compute_ownership_strength_score(
                        form_group=current_form_group,
                        activist_flag=row.activist_flag,
                        is_initial_for_owner=(prior_form_group is None),
                        is_13g_to_13d_transition=(prior_form_group == "13G" and current_form_group == "13D"),
                        percent_owned=row.percent_owned,
                        percent_delta_points=delta,
                        purpose_housekeeping_flag=row.purpose_housekeeping_flag,
                    ),
                )
            )
            if row.percent_owned is not None:
                prior_percent = row.percent_owned
            prior_form_group = current_form_group
    return sorted(enriched, key=lambda row: (row.filing_date, row.symbol, row.owner_key, row.form_type))


def _default_specs() -> list[OwnershipSpec]:
    return [
        OwnershipSpec(
            name="13d_initial_pct5_hold20",
            form_groups=("13D",),
            hold_days=20,
            min_percent_owned=5.0,
            require_initial=True,
        ),
        OwnershipSpec(
            name="13d_initial_pct10_hold20",
            form_groups=("13D",),
            hold_days=20,
            min_percent_owned=10.0,
            require_initial=True,
        ),
        OwnershipSpec(
            name="13d_activist_pct5_hold40",
            form_groups=("13D",),
            hold_days=40,
            min_percent_owned=5.0,
            require_activist=True,
        ),
        OwnershipSpec(
            name="13d_raise_1pp_hold20",
            form_groups=("13D",),
            hold_days=20,
            min_percent_owned=5.0,
            min_percent_delta_points=1.0,
            require_amendment=True,
        ),
        OwnershipSpec(
            name="13g_initial_pct10_hold20",
            form_groups=("13G",),
            hold_days=20,
            min_percent_owned=10.0,
            require_initial=True,
            min_avg_dollar_volume=20_000_000.0,
        ),
        OwnershipSpec(
            name="13g_to_13d_transition_hold20",
            form_groups=("13D",),
            hold_days=20,
            min_percent_owned=5.0,
            require_13g_to_13d_transition=True,
        ),
    ]


def _load_matched_filings(
    *,
    store: Any,
    start_date: dt.date,
    end_date: dt.date,
    cache_dir: Path,
    include_13g_amendments: bool,
    session: requests.Session,
) -> tuple[list[FilingRecord], dict[str, int]]:
    allowed_symbols = {str(symbol).upper() for symbol in store._bars.keys()}
    symbol_to_cik, cik_to_symbol = asyncio.run(_load_symbol_cik_map(allowed_symbols))
    allowed_ciks = set(cik_to_symbol.keys())

    matched: list[FilingRecord] = []
    counts: Counter[str] = Counter()
    for year, quarter in _year_quarter_range(start_date, end_date):
        path = _ensure_company_index(session, cache_dir, year, quarter)
        for row in _iter_index_records(path, include_13g_amendments=include_13g_amendments):
            if row.cik not in allowed_ciks:
                continue
            if row.filing_date < start_date or row.filing_date > end_date:
                continue
            symbol = cik_to_symbol[row.cik]
            matched.append(
                FilingRecord(
                    symbol=symbol,
                    cik=row.cik,
                    company_name=row.company_name,
                    form_type=row.form_type,
                    filing_date=row.filing_date,
                    filename=row.filename,
                )
            )
            counts[row.form_type] += 1
    matched.sort(key=lambda row: (row.filing_date, row.symbol, row.form_type, row.filename))
    return matched, dict(sorted(counts.items(), key=lambda item: (-item[1], item[0])))


def _build_events(
    *,
    records: list[FilingRecord],
    cache_dir: Path,
    session: requests.Session,
) -> tuple[list[OwnershipEvent], dict[str, int]]:
    parsed_events: list[OwnershipEvent] = []
    stats = {"records": len(records), "parsed": 0, "missing_percent": 0, "missing_owner": 0, "errors": 0}
    for record in records:
        try:
            text_value = _fetch_filing_text(session, cache_dir, record.filename)
            event = _parse_ownership_event(record, text_value)
            parsed_events.append(event)
            stats["parsed"] += 1
            if event.percent_owned is None:
                stats["missing_percent"] += 1
            if not event.owner_name:
                stats["missing_owner"] += 1
        except Exception:
            stats["errors"] += 1
    return _attach_history_context(parsed_events), stats


def _build_entries_for_spec(
    *,
    store: Any,
    events: list[OwnershipEvent],
    spec: OwnershipSpec,
) -> dict[dt.date, list[dict[str, Any]]]:
    trading_days = store.all_trading_days()
    trading_index = {date: idx for idx, date in enumerate(trading_days)}
    entries: dict[dt.date, list[dict[str, Any]]] = defaultdict(list)

    for event in events:
        if _normalize_form_group(event.form_type) not in spec.form_groups:
            continue
        if spec.require_amendment and not event.is_amendment:
            continue
        if spec.require_initial and not event.is_initial_for_owner:
            continue
        if spec.require_activist and not event.activist_flag:
            continue
        if spec.require_13g_to_13d_transition and not event.is_13g_to_13d_transition:
            continue
        if (event.percent_owned or 0.0) < spec.min_percent_owned:
            continue
        if spec.min_percent_delta_points > 0 and (event.percent_delta_points or 0.0) < spec.min_percent_delta_points:
            continue

        entry_date = next((date for date in trading_days if date > event.filing_date), None)
        if entry_date is None:
            continue
        entry_idx = trading_index.get(entry_date)
        if entry_idx is None or entry_idx + spec.hold_days >= len(trading_days):
            continue
        exit_date = trading_days[entry_idx + spec.hold_days]
        entry_bar = store.get_bar(event.symbol, entry_date)
        exit_bar = store.get_bar(event.symbol, exit_date)
        if not entry_bar or not exit_bar or not entry_bar.get("open") or not exit_bar.get("close"):
            continue
        features = store.get_market_features(event.symbol, entry_date) or {}
        adv = float(features.get("avg_dollar_volume_20d") or 0.0)
        if adv < spec.min_avg_dollar_volume:
            continue

        entries[entry_date].append(
            {
                "symbol": event.symbol,
                "exit_date": exit_date,
                "score": (
                    1 if event.activist_flag else 0,
                    float(event.percent_delta_points or 0.0),
                    float(event.percent_owned or 0.0),
                ),
                "owner_key": event.owner_key,
                "percent_owned": float(event.percent_owned or 0.0),
                "percent_delta_points": float(event.percent_delta_points or 0.0),
                "activist_flag": event.activist_flag,
                "form_type": event.form_type,
                "adv_20d": adv,
            }
        )
    return entries


def _simulate_equal_weight_curve(
    *,
    store: Any,
    start_date: dt.date,
    end_date: dt.date,
    entries_by_date: dict[dt.date, list[dict[str, Any]]],
    spec: OwnershipSpec,
    capital: float,
) -> tuple[list[DailyPortfolioState], int]:
    trading_days = [date for date in store.all_trading_days() if start_date <= date <= end_date]
    equity = capital
    peak = capital
    curve: list[DailyPortfolioState] = []
    open_positions: list[dict[str, Any]] = []
    trade_count = 0

    for date in trading_days:
        if date in entries_by_date and len(open_positions) < spec.max_positions:
            existing_symbols = {position["symbol"] for position in open_positions}
            ranked = sorted(entries_by_date[date], key=lambda row: row["score"], reverse=True)
            added = 0
            for row in ranked:
                if row["symbol"] in existing_symbols:
                    continue
                bar = store.get_bar(row["symbol"], date)
                if not bar or not bar.get("open"):
                    continue
                open_positions.append(
                    {
                        "symbol": row["symbol"],
                        "exit_date": row["exit_date"],
                        "prev_price": float(bar["open"]),
                    }
                )
                existing_symbols.add(row["symbol"])
                trade_count += 1
                added += 1
                if added >= spec.max_new_per_day or len(open_positions) >= spec.max_positions:
                    break

        if open_positions:
            daily_returns: list[float] = []
            updated_positions: list[dict[str, Any]] = []
            for position in open_positions:
                bar = store.get_bar(position["symbol"], date)
                if not bar or not bar.get("close"):
                    continue
                close_price = float(bar["close"])
                prev_price = float(position["prev_price"])
                if prev_price <= 0:
                    continue
                daily_returns.append(close_price / prev_price - 1.0)
                updated_positions.append(
                    {
                        "symbol": position["symbol"],
                        "exit_date": position["exit_date"],
                        "prev_price": close_price,
                    }
                )
            if daily_returns:
                equity *= 1.0 + sum(daily_returns) / len(daily_returns)
            open_positions = [position for position in updated_positions if position["exit_date"] > date]

        peak = max(peak, equity)
        curve.append(
            DailyPortfolioState(
                date=date,
                equity=equity,
                sizing_equity=equity,
                cash_available=equity,
                gross_exposure=float(len(open_positions)) * 10.0,
                net_exposure=float(len(open_positions)) * 10.0,
                reserved_risk_budget=0.0,
                unrealized_pnl=0.0,
                realized_pnl=0.0,
                open_positions=[position["symbol"] for position in open_positions],
                daily_new_risk_used=0.0,
                peak_equity=peak,
                current_drawdown_pct=((peak - equity) / peak * 100.0) if peak > 0 else 0.0,
            )
        )
    return curve, trade_count


def _simulate_idle_cash_overlay(
    *,
    base_curve: list[DailyPortfolioState],
    store: Any,
    entries_by_date: dict[dt.date, list[dict[str, Any]]],
    spec: OwnershipSpec,
) -> tuple[list[DailyPortfolioState], int, float]:
    adjusted_curve: list[DailyPortfolioState] = []
    active_positions: list[dict[str, Any]] = []
    overlay_pnl = 0.0
    overlay_trade_count = 0
    peak = 0.0

    for state in base_curve:
        if state.equity <= 0:
            adjusted_curve.append(state)
            continue

        cash_ratio = state.cash_available / state.equity if state.equity > 0 else 0.0
        if state.date in entries_by_date and cash_ratio >= spec.min_cash_ratio_for_overlay:
            budget = float(state.cash_available) * spec.max_idle_deploy_pct
            used_notional = sum(float(position["notional"]) for position in active_positions)
            free_budget = max(0.0, budget - used_notional)
            if free_budget > 0 and len(active_positions) < spec.max_positions:
                existing_symbols = {position["symbol"] for position in active_positions}
                ranked = sorted(entries_by_date[state.date], key=lambda row: row["score"], reverse=True)
                candidates: list[dict[str, Any]] = []
                for row in ranked:
                    if row["symbol"] in existing_symbols:
                        continue
                    if len(candidates) >= spec.max_new_per_day or len(active_positions) + len(candidates) >= spec.max_positions:
                        break
                    bar = store.get_bar(row["symbol"], state.date)
                    if not bar or not bar.get("open"):
                        continue
                    candidates.append(row)
                if candidates:
                    per_position_notional = free_budget / len(candidates)
                    for row in candidates:
                        bar = store.get_bar(row["symbol"], state.date)
                        active_positions.append(
                            {
                                "symbol": row["symbol"],
                                "exit_date": row["exit_date"],
                                "prev_price": float(bar["open"]),
                                "notional": per_position_notional,
                            }
                        )
                        overlay_trade_count += 1

        if active_positions:
            updated_positions: list[dict[str, Any]] = []
            for position in active_positions:
                bar = store.get_bar(position["symbol"], state.date)
                if not bar or not bar.get("close"):
                    continue
                close_price = float(bar["close"])
                prev_price = float(position["prev_price"])
                if prev_price <= 0:
                    continue
                overlay_pnl += float(position["notional"]) * (close_price / prev_price - 1.0)
                updated_positions.append(
                    {
                        "symbol": position["symbol"],
                        "exit_date": position["exit_date"],
                        "prev_price": close_price,
                        "notional": float(position["notional"]),
                    }
                )
            active_positions = [position for position in updated_positions if position["exit_date"] > state.date]

        adjusted_equity = float(state.equity) + overlay_pnl
        peak = max(peak, adjusted_equity)
        adjusted_curve.append(
            state.model_copy(
                update={
                    "equity": adjusted_equity,
                    "peak_equity": peak,
                    "current_drawdown_pct": ((peak - adjusted_equity) / peak * 100.0) if peak > 0 else 0.0,
                }
            )
        )

    return adjusted_curve, overlay_trade_count, round(overlay_pnl, 2)


def _yearly_return_map(curve: list[DailyPortfolioState]) -> dict[str, float]:
    if not curve:
        return {}
    year_start: dict[int, float] = {}
    year_end: dict[int, float] = {}
    for state in curve:
        year_start.setdefault(state.date.year, state.equity)
        year_end[state.date.year] = state.equity
    return {
        str(year): round((year_end[year] / year_start[year] - 1.0) * 100.0, 2)
        for year in sorted(year_start)
        if year_start[year] > 0
    }


def _period_stats(curve: list[DailyPortfolioState], start_date: dt.date) -> dict[str, Any] | None:
    points = [state for state in curve if state.date >= start_date]
    if not points:
        return None
    return {
        "start_date": points[0].date.isoformat(),
        "end_date": points[-1].date.isoformat(),
        "return_pct": round(compute_total_return_pct(points) or 0.0, 2),
        "max_dd_pct": round(compute_max_drawdown_pct(points) or 0.0, 2),
        "sharpe_ratio": round(compute_sharpe_ratio(points) or 0.0, 3),
    }


def _parse_period_start(value: str) -> tuple[str, dt.date]:
    if "=" in value:
        label, raw_date = value.split("=", 1)
    else:
        label, raw_date = value, value
    return label, _parse_date(raw_date)


def _run_probe(args: argparse.Namespace) -> list[dict[str, Any]]:
    start_date = _parse_date(args.start)
    end_date = _parse_date(args.end, is_end=True)
    period_starts = [_parse_period_start(value) for value in args.period_start]
    session = requests.Session()
    session.headers.update({"User-Agent": args.user_agent})

    rows: list[dict[str, Any]] = []
    for config_path in args.config:
        manifest = load_manifest(config_path)
        config = resolve_config(manifest)
        store = _build_merged_snapshot_store(
            manifest,
            config,
            snapshot_dir_override=None,
        ).slice_by_date_range(start_date, end_date)
        matched_filings, form_counts = _load_matched_filings(
            store=store,
            start_date=start_date,
            end_date=end_date,
            cache_dir=Path(args.cache_dir),
            include_13g_amendments=args.include_13g_amendments,
            session=session,
        )
        events, parse_stats = _build_events(
            records=matched_filings,
            cache_dir=Path(args.cache_dir),
            session=session,
        )
        base_runner, _ = _build_runner(
            config_path,
            start_date,
            end_date,
            args.capital,
            parking_preset=None,
        )
        base_metrics = _compute_probe_metrics(base_runner._equity_curve)

        for spec in _default_specs():
            entries = _build_entries_for_spec(store=store, events=events, spec=spec)
            standalone_curve, standalone_trades = _simulate_equal_weight_curve(
                store=store,
                start_date=start_date,
                end_date=end_date,
                entries_by_date=entries,
                spec=spec,
                capital=args.capital,
            )
            overlay_curve, overlay_trades, overlay_pnl = _simulate_idle_cash_overlay(
                base_curve=base_runner._equity_curve,
                store=store,
                entries_by_date=entries,
                spec=spec,
            )
            standalone_metrics = {
                "total_return_pct": round(compute_total_return_pct(standalone_curve) or 0.0, 2),
                "max_drawdown_pct": round(compute_max_drawdown_pct(standalone_curve) or 0.0, 2),
                "sharpe_ratio": round(compute_sharpe_ratio(standalone_curve) or 0.0, 3),
            }
            overlay_metrics = _compute_probe_metrics(overlay_curve)
            row: dict[str, Any] = {
                "config": config_path,
                "window_start": start_date.isoformat(),
                "window_end": end_date.isoformat(),
                "available_trading_start": store.all_trading_days()[0].isoformat() if store.all_trading_days() else None,
                "available_trading_end": store.all_trading_days()[-1].isoformat() if store.all_trading_days() else None,
                "matched_form_counts": form_counts,
                "parse_stats": parse_stats,
                "event_count": len(events),
                "spec": spec.name,
                "signals": int(sum(len(value) for value in entries.values())),
                "signal_days": len(entries),
                "standalone_trades": standalone_trades,
                "standalone": standalone_metrics,
                "standalone_yearly_return_pct": _yearly_return_map(standalone_curve),
                "base_no_parking": base_metrics.__dict__,
                "overlay": overlay_metrics.__dict__,
                "overlay_trades": overlay_trades,
                "overlay_realized_pnl": overlay_pnl,
                "overlay_delta_return_pct": round(
                    overlay_metrics.total_return_pct - base_metrics.total_return_pct,
                    2,
                ),
                "overlay_delta_dd_pct": round(
                    overlay_metrics.max_drawdown_pct - base_metrics.max_drawdown_pct,
                    2,
                ),
            }
            for label, period_start in period_starts:
                row[f"{label}_standalone"] = _period_stats(standalone_curve, period_start)
                row[f"{label}_overlay"] = _period_stats(overlay_curve, period_start)
                row[f"{label}_base"] = _period_stats(base_runner._equity_curve, period_start)
            rows.append(row)
    return rows


def _print_rows(rows: list[dict[str, Any]], period_labels: list[str]) -> None:
    for row in rows:
        standalone = row["standalone"]
        overlay = row["overlay"]
        print(f"{row['config']} :: {row['spec']}")
        print(
            "  filings/events: "
            f"{sum(row['matched_form_counts'].values())} matched | {row['event_count']} parsed events | "
            f"{row['signals']} signals on {row['signal_days']} days"
        )
        print(
            "  standalone: "
            f"return {standalone['total_return_pct']:.2f}% | "
            f"dd {standalone['max_drawdown_pct']:.2f}% | "
            f"sharpe {standalone['sharpe_ratio']:.3f} | trades {row['standalone_trades']}"
        )
        print(
            "  overlay:    "
            f"return {overlay['total_return_pct']:.2f}% | "
            f"dd {overlay['max_drawdown_pct']:.2f}% | "
            f"sharpe {overlay['sharpe_ratio']:.3f} | trades {row['overlay_trades']}"
        )
        print(
            "  delta:      "
            f"return {row['overlay_delta_return_pct']:+.2f}%p | "
            f"dd {row['overlay_delta_dd_pct']:+.2f}%p | "
            f"overlay pnl ${row['overlay_realized_pnl']:.2f}"
        )
        for label in period_labels:
            overlay_period = row.get(f"{label}_overlay")
            base_period = row.get(f"{label}_base")
            if overlay_period and base_period:
                print(
                    f"  {label}: base {base_period['return_pct']:.2f}% / {base_period['max_dd_pct']:.2f}%dd | "
                    f"overlay {overlay_period['return_pct']:.2f}% / {overlay_period['max_dd_pct']:.2f}%dd"
                )


def main() -> None:
    parser = argparse.ArgumentParser(description="Probe Schedule 13D/13G ownership sleeve additivity")
    parser.add_argument("--config", action="append", required=True)
    parser.add_argument("--start", required=True)
    parser.add_argument("--end", required=True)
    parser.add_argument("--capital", type=float, default=10_000.0)
    parser.add_argument("--cache-dir", default="data/cache/sec_13d13g")
    parser.add_argument("--user-agent", default=USER_AGENT)
    parser.add_argument(
        "--include-13g-amendments",
        action="store_true",
        help="Include 13G/A records. Disabled by default because annual passive amendments dominate the raw feed.",
    )
    parser.add_argument(
        "--period-start",
        action="append",
        default=[],
        help="Sub-period start. Format: label=YYYY-MM-DD (repeatable).",
    )
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    configure_logging("WARNING")
    rows = _run_probe(args)
    if args.json:
        print(json.dumps(rows, indent=2))
        return
    period_labels = [value.split("=", 1)[0] if "=" in value else value for value in args.period_start]
    _print_rows(rows, period_labels)


if __name__ == "__main__":
    main()
