You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fithia2/apps/tools/ownership_13d13g_probe.py

1120 lines
42 KiB
Python

"""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()