fund-style-drift / agent /tools /factor_regression_engine.py
Nav772's picture
Week 2: factor regression engine with 4 confidence dimensions
e02a76d
Raw
History Blame Contribute Delete
8.59 kB
# agent/tools/factor_regression_engine.py
import pandas as pd
import numpy as np
import statsmodels.api as sm
from scipy import stats
from pydantic import BaseModel
from typing import Optional
from dotenv import load_dotenv
load_dotenv()
class ConfidenceDimension(BaseModel):
label: str # "high" / "moderate" / "low" / "pass" / "flag" / "adequate" / "marginal" / "insufficient"
metric: float # the underlying number
metric_name: str # what the number is
class FactorRegressionInput(BaseModel):
ticker: str
returns: dict # {"YYYY-MM": float} - from FundPriceOutput.returns
factors: dict # {"YYYY-MM": {factor: value}} - from FrenchFactorOutput.factors
start_date: str # "YYYY-MM"
end_date: str # "YYYY-MM"
class FactorRegressionOutput(BaseModel):
ticker: str
start_date: str
end_date: str
num_observations: int
# Regression results
alpha: float
alpha_tstat: float
alpha_pvalue: float
factor_loadings: dict # {factor_name: loading}
factor_tstats: dict # {factor_name: t_stat}
factor_pvalues: dict # {factor_name: p_value}
r_squared: float
adj_r_squared: float
f_statistic: float
f_pvalue: float
# Confidence dimensions - never collapsed into a single score
conf_fit: ConfidenceDimension
conf_significance: ConfidenceDimension
conf_sample: ConfidenceDimension
conf_normality: ConfidenceDimension
source: str
error: Optional[str] = None
def _label_fit(adj_r2: float) -> str:
if adj_r2 >= 0.70:
return "high"
elif adj_r2 >= 0.40:
return "moderate"
return "low"
def _label_significance(sig_count: int) -> str:
if sig_count >= 4:
return "high"
elif sig_count >= 2:
return "moderate"
return "low"
def _label_sample(n: int) -> str:
if n >= 36:
return "adequate"
elif n >= 24:
return "marginal"
return "insufficient"
def _label_normality(jb_pvalue: float) -> str:
return "pass" if jb_pvalue >= 0.05 else "flag"
def run_factor_regression(inp: FactorRegressionInput) -> FactorRegressionOutput:
try:
# Convert dicts to DataFrames
returns_df = pd.Series(inp.returns, name="fund_return")
returns_df.index = pd.to_datetime(returns_df.index, format="%Y-%m")
returns_df.index = returns_df.index.to_period("M").to_timestamp("M")
# Build factors DataFrame from nested dict
factors_df = pd.DataFrame.from_dict(inp.factors, orient="index")
factors_df.index = pd.to_datetime(factors_df.index, format="%Y-%m")
factors_df.index = factors_df.index.to_period("M").to_timestamp("M")
factors_df.columns = factors_df.columns.str.strip()
# Align on date index
aligned = returns_df.to_frame().join(factors_df, how="inner")
# Filter to requested date range
start = pd.to_datetime(inp.start_date, format="%Y-%m").to_period("M").to_timestamp("M")
end = pd.to_datetime(inp.end_date, format="%Y-%m").to_period("M").to_timestamp("M")
aligned = aligned.loc[start:end]
n = len(aligned)
# Compute excess returns: fund return minus risk-free rate
y = aligned["fund_return"] - aligned["RF"]
# Independent variables: the six factors
factor_cols = ["Mkt-RF", "SMB", "HML", "RMW", "CMA", "Mom"]
X = aligned[factor_cols]
X = sm.add_constant(X)
# Run OLS
model = sm.OLS(y, X).fit()
# Extract results
alpha = float(model.params["const"])
alpha_tstat = float(model.tvalues["const"])
alpha_pvalue = float(model.pvalues["const"])
factor_loadings = {f: round(float(model.params[f]), 6) for f in factor_cols}
factor_tstats = {f: round(float(model.tvalues[f]), 4) for f in factor_cols}
factor_pvalues = {f: round(float(model.pvalues[f]), 4) for f in factor_cols}
r_squared = round(float(model.rsquared), 4)
adj_r_squared = round(float(model.rsquared_adj), 4)
f_statistic = round(float(model.fvalue), 4)
f_pvalue = round(float(model.f_pvalue), 6)
# Confidence dimension 1: fit
conf_fit = ConfidenceDimension(
label=_label_fit(adj_r_squared),
metric=adj_r_squared,
metric_name="adj_r_squared"
)
# Confidence dimension 2: factor significance
sig_count = sum(1 for f in factor_cols if abs(factor_tstats[f]) >= 2.0)
conf_significance = ConfidenceDimension(
label=_label_significance(sig_count),
metric=float(sig_count),
metric_name="significant_factors"
)
# Confidence dimension 3: sample adequacy
conf_sample = ConfidenceDimension(
label=_label_sample(n),
metric=float(n),
metric_name="num_observations"
)
# Confidence dimension 4: residual normality (Jarque-Bera)
jb_stat, jb_pvalue = stats.jarque_bera(model.resid)
conf_normality = ConfidenceDimension(
label=_label_normality(float(jb_pvalue)),
metric=round(float(jb_pvalue), 4),
metric_name="jarque_bera_pvalue"
)
return FactorRegressionOutput(
ticker=inp.ticker,
start_date=aligned.index[0].strftime("%Y-%m"),
end_date=aligned.index[-1].strftime("%Y-%m"),
num_observations=n,
alpha=round(alpha, 6),
alpha_tstat=round(alpha_tstat, 4),
alpha_pvalue=round(alpha_pvalue, 4),
factor_loadings=factor_loadings,
factor_tstats=factor_tstats,
factor_pvalues=factor_pvalues,
r_squared=r_squared,
adj_r_squared=adj_r_squared,
f_statistic=f_statistic,
f_pvalue=f_pvalue,
conf_fit=conf_fit,
conf_significance=conf_significance,
conf_sample=conf_sample,
conf_normality=conf_normality,
source="OLS regression via statsmodels. Factors: Ken French Data Library.",
error=None
)
except Exception as e:
# Return a shell output with error rather than crashing
dummy_dim = ConfidenceDimension(label="low", metric=0.0, metric_name="n/a")
return FactorRegressionOutput(
ticker=inp.ticker,
start_date=inp.start_date,
end_date=inp.end_date,
num_observations=0,
alpha=0.0, alpha_tstat=0.0, alpha_pvalue=0.0,
factor_loadings={}, factor_tstats={}, factor_pvalues={},
r_squared=0.0, adj_r_squared=0.0,
f_statistic=0.0, f_pvalue=0.0,
conf_fit=dummy_dim, conf_significance=dummy_dim,
conf_sample=dummy_dim, conf_normality=dummy_dim,
source="OLS regression via statsmodels",
error=str(e)
)
if __name__ == "__main__":
from agent.tools.french_factor_fetcher import get_french_factors, FrenchFactorInput
from agent.tools.fund_price_fetcher import get_fund_returns, FundPriceInput
# Fetch inputs
factors = get_french_factors(FrenchFactorInput(
start_date="2019-01",
end_date="2025-12"
))
prices = get_fund_returns(FundPriceInput(
ticker="QQQ",
start_date="2019-01",
end_date="2025-12"
))
# Run regression
result = run_factor_regression(FactorRegressionInput(
ticker="QQQ",
returns=prices.returns,
factors=factors.factors,
start_date="2019-01",
end_date="2025-12"
))
print(f"Ticker: {result.ticker}")
print(f"Error: {result.error}")
print(f"Observations: {result.num_observations}")
print(f"Alpha: {result.alpha} (t={result.alpha_tstat}, p={result.alpha_pvalue})")
print(f"R-squared: {result.r_squared}, Adj R-squared: {result.adj_r_squared}")
print(f"\nFactor loadings:")
for f, loading in result.factor_loadings.items():
tstat = result.factor_tstats[f]
sig = "*" if abs(tstat) >= 2.0 else ""
print(f" {f}: {loading:.4f} (t={tstat}){sig}")
print(f"\nConfidence dimensions:")
print(f" Fit: {result.conf_fit.label} (adj_r2={result.conf_fit.metric})")
print(f" Significance: {result.conf_significance.label} ({int(result.conf_significance.metric)}/6 factors significant)")
print(f" Sample: {result.conf_sample.label} ({int(result.conf_sample.metric)} observations)")
print(f" Normality: {result.conf_normality.label} (JB p-value={result.conf_normality.metric})")