PaperTrade / research /experiment_features.py
Khanna, Videh Rakesh Rakesh
Add bear-direction fixes, research scripts, market_calendar, and gitignore cleanup
74d4035
Raw
History Blame Contribute Delete
5.62 kB
#!/usr/bin/env python3
"""Backtest-only experimental context builders (reusable for future production integration)."""
from __future__ import annotations
import re
import threading
from dataclasses import dataclass
import yfinance as yf
try:
from social_sentiment import fetch_social_sentiment
except Exception:
fetch_social_sentiment = None
_BULL_WORDS = {
"bullish", "strong", "beat", "growth", "upgrade", "momentum", "rally", "buy",
"outperform", "positive", "profit", "surge",
}
_BEAR_WORDS = {
"bearish", "weak", "miss", "downgrade", "risk", "loss", "fall", "sell",
"underperform", "negative", "crash", "slump",
}
@dataclass
class ExperimentalConfig:
enable_alt_sentiment: bool = False
enable_fundamentals: bool = False
class ExperimentContextBuilder:
"""Thread-safe cache for optional backtest context enrichment."""
def __init__(self, config: ExperimentalConfig):
self.config = config
self._lock = threading.Lock()
self._social_cache: dict[str, dict] = {}
self._fund_cache: dict[str, dict] = {}
def build_news_bundle(self, ticker: str, company: str) -> dict:
"""Return a news dict compatible with ai_forecast.get_ai_forecast()."""
label = "NEUTRAL"
score = 0
summary_parts: list[str] = []
headlines: list[str] = []
if self.config.enable_alt_sentiment:
social = self._get_social_sentiment(ticker, company)
if social:
score += int(social.get("score", 0))
summary_parts.append(social.get("summary", ""))
if social.get("headline"):
headlines.append(social["headline"])
if self.config.enable_fundamentals:
fund = self._get_fundamentals(ticker)
if fund:
score += int(fund.get("score", 0))
summary_parts.append(fund.get("summary", ""))
if fund.get("headline"):
headlines.append(fund["headline"])
if score > 5:
label = "BULLISH"
elif score < -5:
label = "BEARISH"
return {
"label": label,
"score": score,
"summary": " | ".join([p for p in summary_parts if p])[:280],
"key_headline": headlines[0] if headlines else "",
"headlines": headlines[:5],
"source": "backtest-experimental",
}
def _get_social_sentiment(self, ticker: str, company: str) -> dict | None:
if fetch_social_sentiment is None:
return None
with self._lock:
cached = self._social_cache.get(ticker)
if cached is not None:
return cached
try:
text = fetch_social_sentiment(ticker, company) or ""
text_l = text.lower()
bull = sum(1 for w in _BULL_WORDS if w in text_l)
bear = sum(1 for w in _BEAR_WORDS if w in text_l)
raw_score = max(-12, min(12, (bull - bear) * 2))
first_line = (text.splitlines()[0].strip() if text else "social sentiment unavailable")
result = {
"score": raw_score,
"summary": f"Social sentiment score {raw_score:+d}",
"headline": first_line[:140],
}
except Exception:
result = None
with self._lock:
self._social_cache[ticker] = result
return result
def _get_fundamentals(self, ticker: str) -> dict | None:
with self._lock:
cached = self._fund_cache.get(ticker)
if cached is not None:
return cached
try:
info = yf.Ticker(ticker).info or {}
pe = _to_float(info.get("trailingPE"))
de = _to_float(info.get("debtToEquity"))
rev_growth = _to_float(info.get("revenueGrowth"))
fcf = _to_float(info.get("freeCashflow"))
score = 0
checks: list[str] = []
if pe is not None:
if 0 < pe < 35:
score += 2
checks.append(f"PE={pe:.1f}")
elif pe >= 50:
score -= 2
checks.append(f"PE={pe:.1f}")
if de is not None:
if de < 80:
score += 2
checks.append(f"D/E={de:.1f}")
elif de > 180:
score -= 2
checks.append(f"D/E={de:.1f}")
if rev_growth is not None:
if rev_growth > 0.08:
score += 3
checks.append(f"RevGrowth={rev_growth*100:.1f}%")
elif rev_growth < -0.05:
score -= 3
checks.append(f"RevGrowth={rev_growth*100:.1f}%")
if fcf is not None:
if fcf > 0:
score += 2
checks.append("FCF positive")
else:
score -= 2
checks.append("FCF negative")
score = max(-12, min(12, score))
result = {
"score": score,
"summary": f"Fundamentals score {score:+d} ({', '.join(checks[:3])})",
"headline": "Fundamental screen from yfinance",
}
except Exception:
result = None
with self._lock:
self._fund_cache[ticker] = result
return result
def _to_float(v):
try:
if v is None:
return None
return float(v)
except Exception:
return None