portfolio_opt / data_repository.py
engineportf's picture
Upload folder using huggingface_hub
ba790e8 verified
Raw
History Blame Contribute Delete
11.9 kB
from dataclasses import dataclass
from typing import List, Dict, Tuple, Optional
import pandas as pd
import logging
from data import fetch_fama_french_factors, fetch_data, fetch_risk_free_rate, fetch_risk_free_series, build_monthly_returns
from database import get_pg_engine
from core_engine import build_spread_map, load_portfolio_state_dict
from core_types import PortfolioState
from config import AppConfig
logger = logging.getLogger('portfolio_engine')
@dataclass
class DataSnapshot:
opt_tickers: List[str]
opt_returns_df: pd.DataFrame
bench_rets_monthly: pd.Series
opt_ff_df: Optional[pd.DataFrame]
rfr: float
vol_raw: Optional[pd.Series]
display_df: pd.DataFrame
bench_display: pd.Series
master_state: PortfolioState
spread_map: Dict[str, float]
train_yrs: float
test_yrs: float
# Full-resolution data needed by the pipeline orchestrator
returns_df: pd.DataFrame = None
bench_rets: pd.Series = None
raw: Dict = None
prices: Dict = None
eq_bench: str = "SPY"
vol_bench: str = "^VIX"
rfr_bench: str = "^TNX"
def guess_currency(ticker: str) -> str:
if ticker.endswith('.AT') or ticker.endswith('.DE') or ticker.endswith('.PA') or ticker.endswith('.MI') or ticker.endswith('.AS') or ticker.endswith('.MC'): return 'EUR'
if ticker.endswith('.L'): return 'GBP'
if ticker.endswith('.T'): return 'JPY'
if ticker.endswith('.AX'): return 'AUD'
if ticker.endswith('.TO'): return 'CAD'
if ticker.endswith('.SW'): return 'CHF'
return 'USD'
class DataRepository:
"""
Repository layer responsible for fetching, cleaning, and assembling
all market data, benchmarks, and portfolio state required by the engine.
"""
def __init__(self, cfg: AppConfig):
self.cfg = cfg
self.trading_days = self.cfg.get("trading_days_per_year", 252)
def fetch_all(self, input_tickers: List[str], model_id: int) -> DataSnapshot:
b = self.cfg.get("benchmarks", {})
eq_bench = b.get("equity", "SPY")
vol_bench = b.get("volatility", "^VIX")
rfr_bench = b.get("risk_free", "^TNX")
# --- Options Parsing Logic ---
original_input_tickers = list(input_tickers)
option_tickers = [t for t in input_tickers if t.startswith('OPT:')]
standard_tickers = [t for t in input_tickers if not t.startswith('OPT:')]
for ot in option_tickers:
parts = ot.split(':')
if len(parts) >= 2:
underlying = parts[1]
if underlying not in standard_tickers:
standard_tickers.append(underlying)
input_tickers = standard_tickers
# ----------------------
# --- Currency Logic ---
curr_sym = self.cfg.get("currency_symbol", "$")
currency_map = {'$': 'USD', '€': 'EUR', '£': 'GBP', '¥': 'JPY', 'CHF': 'CHF'}
base_currency = currency_map.get(curr_sym, 'USD')
fx_tickers_needed = set()
ticker_to_currency = {}
for t in input_tickers:
c = guess_currency(t)
ticker_to_currency[t] = c
if c != base_currency and c != 'USD':
fx_tickers_needed.add(f"{c}{base_currency}=X")
elif c == 'USD' and base_currency != 'USD':
# e.g. base is EUR, ticker is AAPL(USD), need USDEUR=X
fx_tickers_needed.add(f"USD{base_currency}=X")
augmented_tickers = list(input_tickers) + list(fx_tickers_needed)
# ----------------------
ff_df = fetch_fama_french_factors() if model_id in [4, 5] else None
years_to_fetch = self.cfg.get('data_history_years', 15.0)
valid_tickers = fetch_data(augmented_tickers, b, years=years_to_fetch, cfg=self.cfg.model_dump() if hasattr(self.cfg, 'model_dump') else dict(self.cfg))
self.cfg["risk_free_rate"] = fetch_risk_free_rate(rfr_bench, self.cfg.get("risk_free_rate", 0.05))
rfr_series = fetch_risk_free_series(rfr_bench)
final_rfr = rfr_series if not rfr_series.empty else self.cfg["risk_free_rate"]
macro_tickers = {eq_bench, vol_bench, rfr_bench, "^IRX"}
opt_tickers = [t for t in valid_tickers if t not in macro_tickers or t in input_tickers]
if not opt_tickers:
raise SystemExit("No Usable Tickers found.")
legacy_state_dict = load_portfolio_state_dict() if self.cfg.get('_use_saved_basis', False) else {}
pg_engine = get_pg_engine()
raw, prices = {}, {}
all_tks = list(dict.fromkeys(opt_tickers + list(macro_tickers)))
if all_tks:
ph = ",".join(["?"] * len(all_tks)) if pg_engine.name == 'sqlite' else ",".join(["%s"] * len(all_tks))
query = f"SELECT ticker, date, close_price FROM daily_prices WHERE ticker IN ({ph}) ORDER BY date"
try:
df_all = pd.read_sql(query, pg_engine, params=tuple(all_tks))
if not df_all.empty:
df_all['date'] = pd.to_datetime(df_all['date'])
for t, group in df_all.groupby('ticker'):
group = group.drop_duplicates(subset=['date'], keep='last')
prices[t] = group.iloc[-1]['close_price']
raw[t] = group.set_index('date')['close_price']
except Exception as e:
logger.warning(f"DB read failed, returning empty context: {e}")
# --- Apply Currency Conversion ---
for t in input_tickers:
if t not in raw: continue
c = ticker_to_currency.get(t, 'USD')
if c != base_currency:
fx_pair = f"{c}{base_currency}=X"
if c == 'USD':
fx_pair = f"USD{base_currency}=X"
if fx_pair in raw:
# Align indices and multiply
fx_series = raw[fx_pair]
aligned_fx = fx_series.reindex(raw[t].index).ffill().bfill()
raw[t] = raw[t] * aligned_fx
prices[t] = prices[t] * aligned_fx.iloc[-1]
else:
logger.warning(f"FX pair {fx_pair} missing for {t}. Using unadjusted {c} prices.")
# ---------------------------------
# Enforce forward-fill to bridge mismatched international trading holidays (US vs EU).
for k in raw:
raw[k] = raw[k].ffill()
bench_rets = raw[eq_bench].pct_change().dropna() if eq_bench in raw else pd.Series(dtype=float)
vol_raw = raw.get(vol_bench, None)
MIN_DAYS_SHORT = self.trading_days * 2
all_rets = {}
for t in opt_tickers:
if t not in raw: continue
s = raw[t].pct_change().dropna(how='all')
if len(s) >= MIN_DAYS_SHORT: all_rets[t] = s
# --- Options Synthetic Generation ---
import numpy as np
from options_pricing import black_scholes_call, _d1_d2
from scipy.stats import norm
for ot in option_tickers:
parts = ot.split(':')
if len(parts) >= 5:
# OPT:AAPL:C:150:0.5
underlying = parts[1]
op_type = parts[2].upper()
try:
strike = float(parts[3])
ttm = float(parts[4])
except:
continue
if underlying in raw:
s_underlying = raw[underlying]
r_series = final_rfr.reindex(s_underlying.index).ffill().fillna(0.04) if isinstance(final_rfr, pd.Series) else pd.Series(final_rfr, index=s_underlying.index)
synthetic_prices = []
for date, S in s_underlying.items():
rate = r_series.loc[date]
if op_type == 'C':
price = black_scholes_call(S, strike, ttm, rate, 0.20)
else:
# Put price
d1, d2 = _d1_d2(S, strike, ttm, rate, 0.20, 0.0)
price = strike * np.exp(-rate * ttm) * norm.cdf(-d2) - S * norm.cdf(-d1)
synthetic_prices.append(max(price, 1e-6)) # Avoid exact zero
opt_series = pd.Series(synthetic_prices, index=s_underlying.index)
opt_rets = opt_series.pct_change().dropna()
opt_rets = opt_rets.replace([np.inf, -np.inf], 0.0).clip(-0.99, 5.0)
if len(opt_rets) >= MIN_DAYS_SHORT:
all_rets[ot] = opt_rets
if ot not in opt_tickers:
opt_tickers.append(ot)
# ------------------------------------
if not all_rets: raise SystemExit("No usable tickers with enough history.")
returns_df = pd.DataFrame(all_rets)
# Pad delisted assets
for col in returns_df.columns:
if returns_df[col].min() <= -0.99:
dead_indices = returns_df[col][returns_df[col] <= -0.99].index
if len(dead_indices) > 0:
dead_idx = dead_indices[0]
returns_df.loc[dead_idx:, col] = returns_df.loc[dead_idx:, col].fillna(0.0)
returns_df = returns_df.dropna()
final_opt_tickers = [t for t in opt_tickers if t in returns_df.columns]
spread_map = build_spread_map(final_opt_tickers, self.cfg.get("sector_map", {}))
if self.cfg.get('return_frequency', 'daily') == 'monthly':
opt_returns_df = build_monthly_returns(returns_df)
bench_rets_monthly = build_monthly_returns(pd.DataFrame({eq_bench: bench_rets}))[eq_bench] if not bench_rets.empty else bench_rets
opt_ff_df = build_monthly_returns(ff_df) if ff_df is not None else None
self.cfg['_trading_periods'] = 12
else:
opt_returns_df, bench_rets_monthly, opt_ff_df = returns_df, bench_rets, ff_df
self.cfg['_trading_periods'] = self.trading_days
DISPLAY_DAYS = self.trading_days * 6
display_df = returns_df.iloc[-DISPLAY_DAYS:] if len(returns_df) > DISPLAY_DAYS else returns_df
bench_display = bench_rets.reindex(display_df.index).dropna()
final_tickers = list(returns_df.columns)
master_state = PortfolioState.build(final_tickers, prices, legacy_state_dict, self.cfg)
total_days = len(returns_df)
OOS_TEST_DAYS = int(min(total_days * 0.2, 504)) # Dynamic 20% test up to 2 years
OOS_TRAIN_DAYS = max(100, total_days - OOS_TEST_DAYS)
train_yrs = OOS_TRAIN_DAYS / self.trading_days
test_yrs = OOS_TEST_DAYS / self.trading_days
return DataSnapshot(
opt_tickers=final_opt_tickers,
opt_returns_df=opt_returns_df,
bench_rets_monthly=bench_rets_monthly,
opt_ff_df=opt_ff_df,
rfr=final_rfr,
vol_raw=vol_raw,
display_df=display_df,
bench_display=bench_display,
master_state=master_state,
spread_map=spread_map,
train_yrs=train_yrs,
test_yrs=test_yrs,
returns_df=returns_df,
bench_rets=bench_rets,
raw=raw,
prices=prices,
eq_bench=eq_bench,
vol_bench=vol_bench,
rfr_bench=rfr_bench
)