"""Research probe for SEC Form 4 insider-buy idle-alpha ideas. This tool downloads the SEC's quarterly insider transaction flat files, filters them to the current backtest universe, and tests simple cluster-buy signals as standalone equal-weight curves. It is intentionally conservative: - only exact Form 4 filings are used (no amendments) - only non-derivative P-code acquisitions are used - signals enter on the next trading day's open - signals exit on a fixed future close """ from __future__ import annotations import argparse import csv import datetime as dt import io import json import urllib.request import zipfile from collections import defaultdict from dataclasses import dataclass from pathlib import Path from typing import Any from apps.backtester.run import _build_merged_snapshot_store, load_manifest, resolve_config 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 _DEFAULT_USER_AGENT = "fithia2-form4-probe/1.0 (local research; contact: dev@example.com)" @dataclass(frozen=True) class InsiderSignalSpec: name: str min_owner_count: int min_total_value: float hold_days: int require_officer_or_director: bool = False max_positions: int = 10 max_new_per_day: int = 3 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 _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: year += 1 quarter = 1 return quarters def _quarter_zip_path(cache_dir: Path, year: int, quarter: int) -> Path: return cache_dir / f"{year}q{quarter}_form345.zip" def _quarter_zip_url(year: int, quarter: int) -> str: return ( "https://www.sec.gov/files/structureddata/data/" f"insider-transactions-data-sets/{year}q{quarter}_form345.zip" ) def _ensure_quarter_zip( cache_dir: Path, year: int, quarter: int, *, user_agent: str, ) -> Path: cache_dir.mkdir(parents=True, exist_ok=True) path = _quarter_zip_path(cache_dir, year, quarter) if path.exists() and path.stat().st_size > 0: return path request = urllib.request.Request( _quarter_zip_url(year, quarter), headers={"User-Agent": user_agent}, ) with urllib.request.urlopen(request, timeout=60) as response: data = response.read() path.write_bytes(data) return path def _parse_sec_date(value: str | None) -> dt.date | None: if not value: return None try: return dt.datetime.strptime(value, "%d-%b-%Y").date() except ValueError: return None def _load_form4_cluster_events( *, cache_dir: Path, start_date: dt.date, end_date: dt.date, allowed_symbols: set[str], user_agent: str, ) -> dict[tuple[dt.date, str], dict[str, Any]]: events: dict[tuple[dt.date, str], dict[str, Any]] = {} for year, quarter in _quarter_range(start_date, end_date): path = _ensure_quarter_zip(cache_dir, year, quarter, user_agent=user_agent) with zipfile.ZipFile(path) as zf: submissions: dict[str, dict[str, Any]] = {} with zf.open("SUBMISSION.tsv") as handle: reader = csv.DictReader( io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t", ) for row in reader: symbol = str(row.get("ISSUERTRADINGSYMBOL") or "").strip().upper() if not symbol or symbol not in allowed_symbols: continue if str(row.get("DOCUMENT_TYPE") or "").strip().upper() != "4": continue filing_date = _parse_sec_date(row.get("FILING_DATE")) if filing_date is None or filing_date < start_date or filing_date > end_date: continue submissions[str(row["ACCESSION_NUMBER"])] = { "symbol": symbol, "filing_date": filing_date, } if not submissions: continue relationships: dict[str, list[tuple[str, str]]] = defaultdict(list) with zf.open("REPORTINGOWNER.tsv") as handle: reader = csv.DictReader( io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t", ) for row in reader: accession = str(row["ACCESSION_NUMBER"]) if accession not in submissions: continue owner_cik = str(row.get("RPTOWNERCIK") or "").strip() relationship = str(row.get("RPTOWNER_RELATIONSHIP") or "").strip().lower() relationships[accession].append((owner_cik, relationship)) with zf.open("NONDERIV_TRANS.tsv") as handle: reader = csv.DictReader( io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t", ) for row in reader: accession = str(row["ACCESSION_NUMBER"]) submission = submissions.get(accession) if submission is None: continue if str(row.get("TRANS_CODE") or "").strip().upper() != "P": continue if str(row.get("TRANS_ACQUIRED_DISP_CD") or "").strip().upper() != "A": continue try: shares = float(row.get("TRANS_SHARES") or 0.0) price = float(row.get("TRANS_PRICEPERSHARE") or 0.0) except (TypeError, ValueError): continue if shares <= 0 or price <= 0: continue key = (submission["filing_date"], submission["symbol"]) event = events.setdefault( key, { "filing_date": submission["filing_date"], "symbol": submission["symbol"], "total_value": 0.0, "transaction_count": 0, "owners": set(), "officer_or_director": set(), }, ) event["total_value"] += shares * price event["transaction_count"] += 1 for owner_cik, relationship in relationships.get(accession, []): if not owner_cik: continue event["owners"].add(owner_cik) if "officer" in relationship or "director" in relationship: event["officer_or_director"].add(owner_cik) return events def _build_entries_for_spec( *, store: Any, events: dict[tuple[dt.date, str], dict[str, Any]], spec: InsiderSignalSpec, ) -> 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.values(): owner_count = len(event["owners"]) if owner_count < spec.min_owner_count: continue if float(event["total_value"]) < spec.min_total_value: continue if spec.require_officer_or_director and not event["officer_or_director"]: continue filing_date = event["filing_date"] entry_date = next((date for date in trading_days if date > filing_date), None) if entry_date is None: continue entry_bar = store.get_bar(event["symbol"], entry_date) if not entry_bar or not entry_bar.get("open") or not entry_bar.get("close"): continue entry_index = trading_index.get(entry_date) if entry_index is None or entry_index + spec.hold_days >= len(trading_days): continue exit_date = trading_days[entry_index + spec.hold_days] exit_bar = store.get_bar(event["symbol"], exit_date) if not exit_bar or not exit_bar.get("close"): continue entries[entry_date].append( { "symbol": event["symbol"], "exit_date": exit_date, "score": (owner_count, float(event["total_value"])), "owner_count": owner_count, "total_value": float(event["total_value"]), } ) 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: InsiderSignalSpec, 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"][0], row["score"][1]), 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 _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 _default_specs() -> list[InsiderSignalSpec]: return [ InsiderSignalSpec("owners2_value500k_hold5", 2, 500_000.0, 5), InsiderSignalSpec("owners2_value1m_hold5", 2, 1_000_000.0, 5), InsiderSignalSpec("owners2_value500k_hold10", 2, 500_000.0, 10), InsiderSignalSpec("owners2_value500k_hold5_offdir", 2, 500_000.0, 5, True), InsiderSignalSpec("owners1_value1m_hold5", 1, 1_000_000.0, 5), ] 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) 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) allowed_symbols = {str(symbol).upper() for symbol in store._bars.keys()} events = _load_form4_cluster_events( cache_dir=Path(args.cache_dir), start_date=start_date, end_date=end_date, allowed_symbols=allowed_symbols, user_agent=args.user_agent, ) specs = _default_specs() for spec in specs: entries = _build_entries_for_spec(store=store, events=events, spec=spec) curve, trade_count = _simulate_equal_weight_curve( store=store, start_date=start_date, end_date=end_date, entries_by_date=entries, spec=spec, capital=args.capital, ) rows.append( { "config": config_path, "spec": spec.name, "signals": int(sum(len(value) for value in entries.values())), "trades": trade_count, "total_return_pct": round(compute_total_return_pct(curve) or 0.0, 2), "max_drawdown_pct": round(compute_max_drawdown_pct(curve) or 0.0, 2), "sharpe_ratio": round(compute_sharpe_ratio(curve) or 0.0, 3), "yearly_return_pct": _yearly_return_map(curve), } ) return rows def main() -> None: parser = argparse.ArgumentParser(description="Probe SEC Form 4 insider-buy strategies") 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_form345") parser.add_argument("--user-agent", default=_DEFAULT_USER_AGENT) 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)) else: for row in rows: print(row) if __name__ == "__main__": main()