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/dev/analysis/concurrent_position_analysi...

170 lines
5.7 KiB
Python

"""Concurrent Position Analysis: Position clustering and correlation.
Are simultaneous positions correlated? Analyzes trade blotter to find
overlapping position groups and their sector distribution.
Usage:
python -m dev.analysis.concurrent_position_analysis \
--run-dir runs/run_20260313_xyz [--output-dir ./data/analysis]
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
from collections import Counter
from pathlib import Path
from typing import Any
import pyarrow.parquet as pq
def _load_blotter(run_dir: Path) -> list[dict[str, Any]]:
"""Load trade blotter from parquet."""
blotter_path = run_dir / "trade_blotter.parquet"
if not blotter_path.exists():
# Try CSV fallback
csv_path = run_dir / "trade_blotter.csv"
if csv_path.exists():
rows = []
with open(csv_path) as f:
reader = csv.DictReader(f)
for row in reader:
rows.append(row)
return rows
raise FileNotFoundError(f"No trade blotter found in {run_dir}")
table = pq.read_table(blotter_path)
return table.to_pylist()
def _parse_date(val: Any) -> dt.date | None:
"""Parse a date value from various formats."""
if val is None:
return None
if isinstance(val, dt.date):
return val
if isinstance(val, dt.datetime):
return val.date()
try:
return dt.date.fromisoformat(str(val)[:10])
except (ValueError, TypeError):
return None
def analyze(trades: list[dict[str, Any]], output_dir: Path) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
# Parse entry/exit dates
positions: list[dict[str, Any]] = []
for t in trades:
entry = _parse_date(t.get("entry_date"))
exit_ = _parse_date(t.get("exit_date"))
if entry and exit_:
positions.append({
"trade_id": t.get("trade_id", ""),
"symbol": t.get("symbol", ""),
"sector": t.get("sector", "UNKNOWN"),
"entry_date": entry,
"exit_date": exit_,
})
if not positions:
print("No valid trades found.")
return
# Build daily position timeline
all_dates = set()
for p in positions:
d = p["entry_date"]
while d <= p["exit_date"]:
all_dates.add(d)
d += dt.timedelta(days=1)
daily_positions: dict[dt.date, list[dict[str, Any]]] = {}
for d in sorted(all_dates):
active = [p for p in positions if p["entry_date"] <= d <= p["exit_date"]]
if active:
daily_positions[d] = active
# Compute concurrent position stats
concurrent_counts = [len(v) for v in daily_positions.values()]
max_concurrent = max(concurrent_counts) if concurrent_counts else 0
avg_concurrent = sum(concurrent_counts) / len(concurrent_counts) if concurrent_counts else 0
# Find overlap groups (days with >1 position)
overlap_days = {d: ps for d, ps in daily_positions.items() if len(ps) > 1}
# Sector distribution in overlap groups
overlap_sector_counts: Counter[str] = Counter()
overlap_symbol_counts: Counter[str] = Counter()
for d, ps in overlap_days.items():
for p in ps:
overlap_sector_counts[p["sector"]] += 1
overlap_symbol_counts[p["symbol"]] += 1
# Peak overlap days
peak_days = sorted(overlap_days.items(), key=lambda x: len(x[1]), reverse=True)[:10]
# CSV: daily concurrent position counts
csv_rows: list[dict[str, Any]] = []
for d in sorted(daily_positions):
ps = daily_positions[d]
sectors = Counter(p["sector"] for p in ps)
csv_rows.append({
"date": str(d),
"concurrent_positions": len(ps),
"unique_sectors": len(sectors),
"sectors": "|".join(f"{s}:{c}" for s, c in sectors.most_common()),
"symbols": "|".join(p["symbol"] for p in ps),
})
csv_path = output_dir / "concurrent_position_analysis.csv"
if csv_rows:
fieldnames = list(csv_rows[0].keys())
with open(csv_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(csv_rows)
# Console output
print(f"\n{'='*70}")
print(f"Concurrent Position Analysis — {len(positions)} trades")
print(f"{'='*70}")
print(f"\n Total trading days with positions: {len(daily_positions)}")
print(f" Days with >1 concurrent position: {len(overlap_days)}")
print(f" Max concurrent positions: {max_concurrent}")
print(f" Avg concurrent positions: {avg_concurrent:.1f}")
if overlap_sector_counts:
print(f"\n Sector distribution in overlap periods:")
for sector, count in overlap_sector_counts.most_common():
print(f" {sector:<20} {count:>5} position-days")
if peak_days:
print(f"\n Top 10 peak overlap days:")
for d, ps in peak_days:
symbols = ", ".join(p["symbol"] for p in ps)
sectors = set(p["sector"] for p in ps)
print(f" {d} {len(ps)} positions sectors={len(sectors)} [{symbols}]")
print(f"\nCSV written to: {csv_path}")
def main() -> None:
parser = argparse.ArgumentParser(description="Concurrent Position Analysis")
parser.add_argument("--run-dir", required=True, help="Path to backtest run directory")
parser.add_argument("--output-dir", default="./data/analysis", help="Output directory")
args = parser.parse_args()
trades = _load_blotter(Path(args.run_dir))
if not trades:
print("No trades found in blotter.")
return
analyze(trades, Path(args.output_dir))
if __name__ == "__main__":
main()