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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user