fund-style-drift / agent /agent.py
Nav772's picture
Fix NAV chart: cumulative return index instead of raw returns
0d22d0c
Raw
History Blame Contribute Delete
7.44 kB
# agent/agent.py
import os
import json
import pandas as pd
from anthropic import Anthropic
from dotenv import load_dotenv
from agent.tools.french_factor_fetcher import get_french_factors, FrenchFactorInput
from agent.tools.fund_price_fetcher import get_fund_returns, FundPriceInput
from agent.tools.factor_regression_engine import run_factor_regression, FactorRegressionInput
from agent.tools.drift_detection_engine import detect_drift, DriftDetectionInput
from agent.tools.nport_parser import parse_nport, NportInput
from agent.tools.fund_metadata_fetcher import get_fund_metadata, FundMetadataInput
from agent.tools.chart_generator import generate_charts, ChartInput
from prompts.style_drift import SYSTEM_PROMPT
load_dotenv()
client = Anthropic()
START_DATE = "2019-01"
END_DATE = "2025-12"
def run_agent(ticker: str) -> dict:
"""
Orchestrates the full fund style drift analysis pipeline for a single ticker.
Returns a dict with all report components.
"""
ticker = ticker.upper().strip()
print(f"\n{'='*60}")
print(f"Running style drift analysis for {ticker}")
print(f"{'='*60}")
report = {
"ticker": ticker,
"error": None,
"metadata": None,
"regression": None,
"drift": None,
"holdings": None,
"charts": None,
"narrative": None,
}
# Step 1: Fund metadata
print(f"[1/7] Fetching metadata...")
metadata = get_fund_metadata(FundMetadataInput(ticker=ticker))
if metadata.error and not metadata.name:
print(f" Warning: {metadata.error}")
report["metadata"] = metadata.model_dump()
print(f" Fund: {metadata.name}")
# Step 2: Factor data
print(f"[2/7] Fetching FF6 factor data...")
factors = get_french_factors(FrenchFactorInput(
start_date=START_DATE,
end_date=END_DATE
))
if factors.error:
report["error"] = f"Factor data failed: {factors.error}"
return report
print(f" Observations: {factors.num_observations}, cache_used: {factors.cache_used}")
# Step 3: Fund price/NAV data
print(f"[3/7] Fetching fund returns...")
prices = get_fund_returns(FundPriceInput(
ticker=ticker,
start_date=START_DATE,
end_date=END_DATE
))
if prices.error:
report["error"] = f"Price data failed: {prices.error}"
return report
print(f" Observations: {prices.num_observations}, missing: {prices.missing_months}")
# Step 4: Full-period factor regression
print(f"[4/7] Running factor regression...")
regression = run_factor_regression(FactorRegressionInput(
ticker=ticker,
returns=prices.returns,
factors=factors.factors,
start_date=START_DATE,
end_date=END_DATE
))
if regression.error:
report["error"] = f"Regression failed: {regression.error}"
return report
print(f" Adj R²: {regression.adj_r_squared}, Alpha: {regression.alpha:.4f}")
report["regression"] = regression.model_dump()
# Step 5: Drift detection
print(f"[5/7] Running drift detection...")
drift = detect_drift(DriftDetectionInput(
ticker=ticker,
returns=prices.returns,
factors=factors.factors,
start_date=START_DATE,
end_date=END_DATE
))
if drift.error:
report["error"] = f"Drift detection failed: {drift.error}"
return report
print(f" Windows: {drift.num_windows}, Drift events: {len(drift.drift_events)}")
report["drift"] = {
"num_windows": drift.num_windows,
"drift_events": [e.model_dump() for e in drift.drift_events],
"rolling_windows": [w.model_dump() for w in drift.rolling_windows],
}
# Step 6: N-PORT holdings (best-effort, non-blocking)
print(f"[6/7] Fetching N-PORT holdings...")
holdings = parse_nport(NportInput(ticker=ticker, max_holdings=10))
if holdings.error:
print(f" Warning: {holdings.error} (continuing without holdings)")
else:
print(f" Holdings: {holdings.total_holdings}, top 10 concentration: {holdings.top10_concentration}%")
report["holdings"] = holdings.model_dump()
# Build cumulative return index (base 100) for NAV chart
# More comparable across funds than raw prices
returns_series = pd.Series(prices.returns)
cumulative = (1 + returns_series).cumprod() * 100
monthly_prices_for_chart = {k: round(float(v), 4) for k, v in cumulative.items()}
# Step 7: Charts
print(f"[7/7] Generating charts...")
charts = generate_charts(ChartInput(
ticker=ticker,
fund_name=metadata.name,
monthly_prices=monthly_prices_for_chart,
factor_loadings=regression.factor_loadings,
factor_tstats=regression.factor_tstats,
rolling_windows=[w.model_dump() for w in drift.rolling_windows],
drift_events=[e.model_dump() for e in drift.drift_events]
))
if charts.error:
print(f" Warning: {charts.error}")
report["charts"] = {
"nav_chart_json": charts.nav_chart_json,
"loadings_chart_json": charts.loadings_chart_json,
"rolling_chart_json": charts.rolling_chart_json,
}
print(f" Charts generated successfully")
# Step 8: Claude narrative
print(f"[8/8] Generating narrative...")
narrative = _generate_narrative(report)
report["narrative"] = narrative
print(f" Narrative length: {len(narrative)} chars")
print(f"\nAnalysis complete for {ticker}")
return report
def _generate_narrative(report: dict) -> str:
"""
Sends the quantitative results to Claude Sonnet for plain-English interpretation.
Returns the narrative as a string.
"""
# Build a clean summary for Claude - exclude chart JSONs (too large)
payload = {
"ticker": report["ticker"],
"metadata": report["metadata"],
"regression": report["regression"],
"drift_summary": {
"num_windows": report["drift"]["num_windows"],
"num_drift_events": len(report["drift"]["drift_events"]),
# Send only the top 10 most significant drift events by z-score
"top_drift_events": sorted(
report["drift"]["drift_events"],
key=lambda x: abs(x["z_score"]),
reverse=True
)[:10],
},
"holdings": {
"fund_name": report["holdings"]["fund_name"],
"period_ending": report["holdings"]["period_ending"],
"total_holdings": report["holdings"]["total_holdings"],
"top10_concentration": report["holdings"]["top10_concentration"],
"top_holdings": report["holdings"]["top_holdings"][:10] if report["holdings"]["top_holdings"] else [],
} if report["holdings"] else None,
}
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": f"Write the fund analysis narrative for the following data:\n\n{json.dumps(payload, indent=2)}"
}
]
)
return response.content[0].text
if __name__ == "__main__":
# Test with ARKK - we know its story
report = run_agent("ARKK")
print("\n" + "="*60)
print("NARRATIVE OUTPUT")
print("="*60)
print(report["narrative"])
if report["error"]:
print(f"\nERROR: {report['error']}")