PR4: Phase 2 Analytics Foundation

Complete analytics module with returns, benchmarks, and performance metrics.

New Modules:
- src/pote/analytics/returns.py: Return calculator for trades
- src/pote/analytics/benchmarks.py: Benchmark comparison & alpha
- src/pote/analytics/metrics.py: Performance aggregations

Scripts:
- scripts/analyze_official.py: Analyze specific official
- scripts/calculate_all_returns.py: System-wide analysis

Tests:
- tests/test_analytics.py: Full coverage of analytics

Features:
 Calculate returns over 30/60/90/180 day windows
 Compare to market benchmarks (SPY, QQQ, etc.)
 Calculate abnormal returns (alpha)
 Aggregate stats by official, sector
 Top performer rankings
 Disclosure timing analysis
 Command-line analysis tools

~1,210 lines of new code, all tested
This commit is contained in:
ilia
2025-12-15 11:33:21 -05:00
parent 02c10c85d6
commit 34aebb1c2e
12 changed files with 1604 additions and 6 deletions
+14
View File
@@ -0,0 +1,14 @@
"""
Analytics module for calculating returns, performance metrics, and signals.
"""
from .returns import ReturnCalculator
from .benchmarks import BenchmarkComparison
from .metrics import PerformanceMetrics
__all__ = [
"ReturnCalculator",
"BenchmarkComparison",
"PerformanceMetrics",
]
+222
View File
@@ -0,0 +1,222 @@
"""
Benchmark comparison for calculating abnormal returns (alpha).
"""
import logging
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session
from .returns import ReturnCalculator
logger = logging.getLogger(__name__)
class BenchmarkComparison:
"""Compare returns against market benchmarks."""
BENCHMARKS = {
"SPY": "S&P 500",
"QQQ": "NASDAQ-100",
"DIA": "Dow Jones",
"IWM": "Russell 2000",
"VTI": "Total Market",
}
def __init__(self, session: Session):
"""
Initialize with database session.
Args:
session: SQLAlchemy session
"""
self.session = session
self.calculator = ReturnCalculator(session)
def calculate_benchmark_return(
self,
benchmark: str,
start_date: date,
end_date: date,
) -> Decimal | None:
"""
Calculate benchmark return over period.
Args:
benchmark: Ticker symbol (e.g., 'SPY' for S&P 500)
start_date: Period start
end_date: Period end
Returns:
Return percentage as Decimal, or None if data unavailable
"""
# Get prices
start_price = self.calculator._get_price_near_date(benchmark, start_date, days_tolerance=5)
end_price = self.calculator._get_price_near_date(benchmark, end_date, days_tolerance=5)
if not start_price or not end_price:
logger.warning(f"Missing price data for {benchmark}")
return None
# Calculate return
return_pct = ((end_price - start_price) / start_price) * 100
return return_pct
def calculate_abnormal_return(
self,
trade_return: Decimal,
benchmark_return: Decimal,
) -> Decimal:
"""
Calculate abnormal return (alpha).
Alpha = Trade Return - Benchmark Return
Args:
trade_return: Return from trade (%)
benchmark_return: Return from benchmark (%)
Returns:
Abnormal return (alpha) as Decimal
"""
return trade_return - benchmark_return
def compare_trade_to_benchmark(
self,
trade,
window_days: int = 90,
benchmark: str = "SPY",
) -> dict | None:
"""
Compare a single trade to benchmark.
Args:
trade: Trade object
window_days: Time window in days
benchmark: Benchmark ticker (default: SPY)
Returns:
Dictionary with comparison metrics:
{
'trade_return': Decimal('15.3'),
'benchmark_return': Decimal('8.5'),
'abnormal_return': Decimal('6.8'),
'beat_market': True,
'benchmark_name': 'S&P 500'
}
"""
# Get trade return
trade_result = self.calculator.calculate_trade_return(trade, window_days)
if not trade_result:
return None
# Get benchmark return over same period
benchmark_return = self.calculate_benchmark_return(
benchmark,
trade_result["transaction_date"],
trade_result["exit_date"],
)
if benchmark_return is None:
logger.warning(f"No benchmark data for {benchmark}")
return None
# Calculate alpha
abnormal_return = self.calculate_abnormal_return(
trade_result["return_pct"],
benchmark_return,
)
return {
"ticker": trade_result["ticker"],
"official_name": trade.official.name,
"trade_return": trade_result["return_pct"],
"benchmark": benchmark,
"benchmark_name": self.BENCHMARKS.get(benchmark, benchmark),
"benchmark_return": benchmark_return,
"abnormal_return": abnormal_return,
"beat_market": abnormal_return > 0,
"window_days": window_days,
"transaction_date": trade_result["transaction_date"],
}
def batch_compare_trades(
self,
window_days: int = 90,
benchmark: str = "SPY",
) -> list[dict]:
"""
Compare all trades to benchmark.
Args:
window_days: Time window
benchmark: Benchmark ticker
Returns:
List of comparison dictionaries
"""
from pote.db.models import Trade
trades = self.session.query(Trade).all()
results = []
for trade in trades:
result = self.compare_trade_to_benchmark(trade, window_days, benchmark)
if result:
result["trade_id"] = trade.id
results.append(result)
logger.info(f"Compared {len(results)}/{len(trades)} trades to {benchmark}")
return results
def calculate_aggregate_alpha(
self,
official_id: int | None = None,
window_days: int = 90,
benchmark: str = "SPY",
) -> dict:
"""
Calculate aggregate abnormal returns.
Args:
official_id: Filter by official (None = all)
window_days: Time window
benchmark: Benchmark ticker
Returns:
Aggregate statistics
"""
from pote.db.models import Trade
query = self.session.query(Trade)
if official_id:
query = query.filter(Trade.official_id == official_id)
trades = query.all()
comparisons = []
for trade in trades:
result = self.compare_trade_to_benchmark(trade, window_days, benchmark)
if result:
comparisons.append(result)
if not comparisons:
return {"error": "No data available"}
# Calculate aggregates
alphas = [c["abnormal_return"] for c in comparisons]
beat_market_count = sum(1 for c in comparisons if c["beat_market"])
return {
"total_trades": len(comparisons),
"avg_alpha": sum(alphas) / len(alphas),
"median_alpha": sorted(alphas)[len(alphas) // 2],
"max_alpha": max(alphas),
"min_alpha": min(alphas),
"beat_market_count": beat_market_count,
"beat_market_rate": beat_market_count / len(comparisons),
"benchmark": self.BENCHMARKS.get(benchmark, benchmark),
"window_days": window_days,
}
+291
View File
@@ -0,0 +1,291 @@
"""
Performance metrics and aggregations.
"""
import logging
from collections import defaultdict
from datetime import date
from sqlalchemy import func
from sqlalchemy.orm import Session
from pote.db.models import Official, Security, Trade
from .benchmarks import BenchmarkComparison
from .returns import ReturnCalculator
logger = logging.getLogger(__name__)
class PerformanceMetrics:
"""Aggregate performance metrics for officials, sectors, etc."""
def __init__(self, session: Session):
"""
Initialize with database session.
Args:
session: SQLAlchemy session
"""
self.session = session
self.calculator = ReturnCalculator(session)
self.benchmark = BenchmarkComparison(session)
def official_performance(
self,
official_id: int,
window_days: int = 90,
benchmark: str = "SPY",
) -> dict:
"""
Get comprehensive performance metrics for an official.
Args:
official_id: Official's database ID
window_days: Return calculation window
benchmark: Benchmark ticker
Returns:
Performance summary dictionary
"""
official = self.session.query(Official).get(official_id)
if not official:
return {"error": "Official not found"}
trades = (
self.session.query(Trade)
.filter(Trade.official_id == official_id)
.all()
)
if not trades:
return {
"name": official.name,
"party": official.party,
"chamber": official.chamber,
"total_trades": 0,
"message": "No trades found",
}
# Calculate returns for all trades
returns_data = []
for trade in trades:
result = self.benchmark.compare_trade_to_benchmark(
trade, window_days, benchmark
)
if result:
returns_data.append(result)
if not returns_data:
return {
"name": official.name,
"total_trades": len(trades),
"message": "Insufficient price data",
}
# Aggregate statistics
trade_returns = [r["trade_return"] for r in returns_data]
alphas = [r["abnormal_return"] for r in returns_data]
# Buy vs Sell breakdown
buys = [t for t in trades if t.side.lower() in ["buy", "purchase"]]
sells = [t for t in trades if t.side.lower() in ["sell", "sale"]]
# Best and worst trades
best_trade = max(returns_data, key=lambda x: x["trade_return"])
worst_trade = min(returns_data, key=lambda x: x["trade_return"])
# Total value traded
total_value = sum(
float(t.value_min or 0) for t in trades if t.value_min
)
return {
"name": official.name,
"party": official.party,
"chamber": official.chamber,
"state": official.state,
"window_days": window_days,
"benchmark": benchmark,
# Trade counts
"total_trades": len(trades),
"trades_analyzed": len(returns_data),
"buy_trades": len(buys),
"sell_trades": len(sells),
# Returns
"avg_return": sum(trade_returns) / len(trade_returns),
"median_return": sorted(trade_returns)[len(trade_returns) // 2],
"max_return": max(trade_returns),
"min_return": min(trade_returns),
# Alpha (abnormal returns)
"avg_alpha": sum(alphas) / len(alphas),
"median_alpha": sorted(alphas)[len(alphas) // 2],
# Win rate
"win_rate": sum(1 for r in trade_returns if r > 0) / len(trade_returns),
"beat_market_rate": sum(1 for a in alphas if a > 0) / len(alphas),
# Best/worst
"best_trade": {
"ticker": best_trade["ticker"],
"return": best_trade["trade_return"],
"date": best_trade["transaction_date"],
},
"worst_trade": {
"ticker": worst_trade["ticker"],
"return": worst_trade["trade_return"],
"date": worst_trade["transaction_date"],
},
# Volume
"total_value_traded": total_value,
}
def sector_analysis(
self,
window_days: int = 90,
benchmark: str = "SPY",
) -> list[dict]:
"""
Analyze performance by sector.
Args:
window_days: Return calculation window
benchmark: Benchmark ticker
Returns:
List of sector performance dictionaries
"""
# Get all trades with security info
trades = (
self.session.query(Trade)
.join(Security)
.all()
)
# Group by sector
sector_data = defaultdict(list)
for trade in trades:
sector = trade.security.sector or "Unknown"
result = self.benchmark.compare_trade_to_benchmark(
trade, window_days, benchmark
)
if result:
sector_data[sector].append(result)
# Aggregate by sector
results = []
for sector, data in sector_data.items():
if not data:
continue
returns = [d["trade_return"] for d in data]
alphas = [d["abnormal_return"] for d in data]
results.append({
"sector": sector,
"trade_count": len(data),
"avg_return": sum(returns) / len(returns),
"avg_alpha": sum(alphas) / len(alphas),
"win_rate": sum(1 for r in returns if r > 0) / len(returns),
"beat_market_rate": sum(1 for a in alphas if a > 0) / len(alphas),
})
# Sort by average alpha
results.sort(key=lambda x: x["avg_alpha"], reverse=True)
return results
def top_performers(
self,
window_days: int = 90,
benchmark: str = "SPY",
limit: int = 10,
) -> list[dict]:
"""
Get top performing officials by average alpha.
Args:
window_days: Return calculation window
benchmark: Benchmark ticker
limit: Number of officials to return
Returns:
List of official performance summaries
"""
officials = self.session.query(Official).all()
performances = []
for official in officials:
perf = self.official_performance(official.id, window_days, benchmark)
if perf.get("trades_analyzed", 0) > 0:
performances.append(perf)
# Sort by average alpha
performances.sort(key=lambda x: x.get("avg_alpha", -999), reverse=True)
return performances[:limit]
def timing_analysis(self) -> dict:
"""
Analyze disclosure lag vs performance.
Returns:
Dictionary with timing statistics
"""
trades = (
self.session.query(Trade)
.filter(Trade.filing_date.isnot(None))
.all()
)
if not trades:
return {"error": "No trades with disclosure dates"}
# Calculate disclosure lags
lags = []
for trade in trades:
if trade.filing_date and trade.transaction_date:
lag = (trade.filing_date - trade.transaction_date).days
lags.append(lag)
return {
"total_trades": len(trades),
"avg_disclosure_lag_days": sum(lags) / len(lags),
"median_disclosure_lag_days": sorted(lags)[len(lags) // 2],
"max_disclosure_lag_days": max(lags),
"min_disclosure_lag_days": min(lags),
}
def summary_statistics(
self,
window_days: int = 90,
benchmark: str = "SPY",
) -> dict:
"""
Get overall system statistics.
Args:
window_days: Return calculation window
benchmark: Benchmark ticker
Returns:
System-wide statistics
"""
# Get counts
official_count = self.session.query(func.count(Official.id)).scalar()
trade_count = self.session.query(func.count(Trade.id)).scalar()
security_count = self.session.query(func.count(Security.id)).scalar()
# Get aggregate alpha
aggregate = self.benchmark.calculate_aggregate_alpha(
official_id=None,
window_days=window_days,
benchmark=benchmark,
)
return {
"total_officials": official_count,
"total_trades": trade_count,
"total_securities": security_count,
"window_days": window_days,
"benchmark": benchmark,
**aggregate,
}
+237
View File
@@ -0,0 +1,237 @@
"""
Return calculator for trades.
Calculates returns over various time windows and compares to benchmarks.
"""
import logging
from datetime import date, timedelta
from decimal import Decimal
import pandas as pd
from sqlalchemy import select
from sqlalchemy.orm import Session
from pote.db.models import Price, Trade
logger = logging.getLogger(__name__)
class ReturnCalculator:
"""Calculate returns for trades over various time windows."""
def __init__(self, session: Session):
"""
Initialize calculator with database session.
Args:
session: SQLAlchemy session
"""
self.session = session
def calculate_trade_return(
self,
trade: Trade,
window_days: int = 90,
) -> dict | None:
"""
Calculate return for a single trade over a time window.
Args:
trade: Trade object
window_days: Number of days to measure return (default: 90)
Returns:
Dictionary with return metrics, or None if data unavailable:
{
'ticker': 'NVDA',
'transaction_date': date(2024, 1, 15),
'window_days': 90,
'entry_price': Decimal('495.00'),
'exit_price': Decimal('650.00'),
'return_pct': Decimal('31.31'),
'return_abs': Decimal('155.00'),
'data_quality': 'complete' # or 'partial', 'missing'
}
"""
ticker = trade.security.ticker
entry_date = trade.transaction_date
exit_date = entry_date + timedelta(days=window_days)
# Get entry price (at or after transaction date)
entry_price = self._get_price_near_date(ticker, entry_date, days_tolerance=5)
if not entry_price:
logger.warning(f"No entry price for {ticker} near {entry_date}")
return None
# Get exit price (at window end)
exit_price = self._get_price_near_date(ticker, exit_date, days_tolerance=5)
if not exit_price:
logger.warning(f"No exit price for {ticker} near {exit_date}")
return None
# Calculate returns
return_abs = exit_price - entry_price
return_pct = (return_abs / entry_price) * 100
# Adjust for sell trades (inverse logic)
if trade.side.lower() in ["sell", "sale"]:
return_pct = -return_pct
return_abs = -return_abs
return {
"ticker": ticker,
"transaction_date": entry_date,
"exit_date": exit_date,
"window_days": window_days,
"entry_price": entry_price,
"exit_price": exit_price,
"return_pct": return_pct,
"return_abs": return_abs,
"side": trade.side,
"data_quality": "complete",
}
def calculate_multiple_windows(
self,
trade: Trade,
windows: list[int] = [30, 60, 90, 180],
) -> dict[int, dict]:
"""
Calculate returns for multiple time windows.
Args:
trade: Trade object
windows: List of window sizes in days
Returns:
Dictionary mapping window_days to return metrics
"""
results = {}
for window in windows:
result = self.calculate_trade_return(trade, window)
if result:
results[window] = result
return results
def calculate_all_trades(
self,
window_days: int = 90,
min_date: date | None = None,
) -> list[dict]:
"""
Calculate returns for all trades in database.
Args:
window_days: Window size in days
min_date: Only calculate for trades after this date
Returns:
List of return dictionaries
"""
query = select(Trade)
if min_date:
query = query.where(Trade.transaction_date >= min_date)
trades = self.session.execute(query).scalars().all()
results = []
for trade in trades:
result = self.calculate_trade_return(trade, window_days)
if result:
result["trade_id"] = trade.id
result["official_name"] = trade.official.name
result["official_party"] = trade.official.party
results.append(result)
logger.info(f"Calculated returns for {len(results)}/{len(trades)} trades")
return results
def _get_price_near_date(
self,
ticker: str,
target_date: date,
days_tolerance: int = 5,
) -> Decimal | None:
"""
Get closing price near a target date.
Args:
ticker: Stock ticker
target_date: Target date
days_tolerance: Search within +/- this many days
Returns:
Closing price as Decimal, or None if not found
"""
start_date = target_date - timedelta(days=days_tolerance)
end_date = target_date + timedelta(days=days_tolerance)
# Query prices near target date
prices = (
self.session.query(Price)
.filter(
Price.ticker == ticker,
Price.date >= start_date,
Price.date <= end_date,
)
.order_by(Price.date)
.all()
)
if not prices:
return None
# Prefer exact match, then closest date
for price in prices:
if price.date == target_date:
return price.close
# Return closest date's price
closest = min(prices, key=lambda p: abs((p.date - target_date).days))
return closest.close
def get_price_series(
self,
ticker: str,
start_date: date,
end_date: date,
) -> pd.DataFrame:
"""
Get price series as DataFrame.
Args:
ticker: Stock ticker
start_date: Start date
end_date: End date
Returns:
DataFrame with columns: date, open, high, low, close, volume
"""
prices = (
self.session.query(Price)
.filter(
Price.ticker == ticker,
Price.date >= start_date,
Price.date <= end_date,
)
.order_by(Price.date)
.all()
)
if not prices:
return pd.DataFrame()
data = [
{
"date": p.date,
"open": float(p.open),
"high": float(p.high),
"low": float(p.low),
"close": float(p.close),
"volume": p.volume,
}
for p in prices
]
return pd.DataFrame(data)