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.
170 lines
5.7 KiB
Python
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()
|