Swing_Quant_Engine / backend /intelligence /sentiment_analyzer.py
SiddharthVenba's picture
Initial commit for HF Space
75d9b3c
Raw
History Blame Contribute Delete
4.72 kB
"""
Sentiment Analyzer — Batched target-level financial sentiment via OpenAI gpt-4o-mini.
Processes headlines in a single API call for maximum token efficiency.
"""
import json
import logging
import os
from openai import OpenAI
logger = logging.getLogger(__name__)
SENTIMENT_SYSTEM_PROMPT = """You are a financial sentiment classifier. You will receive numbered headlines.
Return a JSON object with key "results" containing an array. Each element must have these exact keys:
- "idx": the headline number (integer)
- "overall_sentiment": "positive", "negative", or "neutral"
- "confidence": float 0.0 to 1.0
- "targets": array of {"name": string, "sentiment": string} for specific companies mentioned
- "impact_summary": one sentence, max 20 words, explaining financial impact
Distinguish target-level sentiment. Example:
"Netflix dips despite Apple's surge" → Netflix=negative, Apple=positive
You MUST return exactly one result per headline, in order."""
SENTIMENT_USER_TEMPLATE = """Analyze these {count} financial headlines:
{headlines}
Return: {{"results": [{{"idx": 0, "overall_sentiment": "positive", "confidence": 0.8, "targets": [{{"name": "AAPL", "sentiment": "positive"}}], "impact_summary": "Apple gains on strong earnings"}}]}}"""
def analyze_sentiment_batch(headlines: list[dict]) -> list[dict]:
"""
Analyze sentiment for a batch of news items using OpenAI.
Each item should have at least 'title' and optionally 'summary'.
Returns list of sentiment results in same order as input.
"""
if not headlines:
return []
api_key = os.getenv("OPENAI_API_KEY", "")
if not api_key:
logger.warning("No OPENAI_API_KEY set, returning neutral sentiment for all")
return [_neutral("No API key") for _ in headlines]
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
client = OpenAI(api_key=api_key)
# Format headlines for the prompt
formatted = "\n".join(
f"[{i}] {item['title']}"
+ (f" — {item.get('summary', '')[:80]}" if item.get("summary") else "")
for i, item in enumerate(headlines)
)
try:
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SENTIMENT_SYSTEM_PROMPT},
{"role": "user", "content": SENTIMENT_USER_TEMPLATE.format(
count=len(headlines), headlines=formatted
)},
],
temperature=0.1,
max_tokens=max(1500, len(headlines) * 80),
response_format={"type": "json_object"},
)
content = response.choices[0].message.content or "{}"
logger.debug(f"Raw sentiment response: {content[:500]}")
raw = json.loads(content)
# Extract the results array from various possible wrapper keys
results = []
if isinstance(raw, dict):
for key in ("results", "headlines", "analysis", "data", "sentiments"):
if key in raw and isinstance(raw[key], list):
results = raw[key]
break
if not results:
# Try first list value in the dict
for v in raw.values():
if isinstance(v, list) and len(v) > 0:
results = v
break
elif isinstance(raw, list):
results = raw
# Normalize each result to ensure expected keys exist
normalized = []
for r in results:
if not isinstance(r, dict):
continue
normalized.append({
"overall_sentiment": r.get("overall_sentiment", r.get("sentiment", "neutral")),
"confidence": float(r.get("confidence", 0.5)),
"targets": r.get("targets", []),
"impact_summary": r.get("impact_summary", r.get("summary", "")),
})
# Pad if model returned fewer than expected
while len(normalized) < len(headlines):
normalized.append(_neutral("Model returned fewer results"))
tokens = response.usage.total_tokens if response.usage else "?"
logger.info(f"Sentiment analyzed: {len(headlines)} headlines, "
f"{len(results)} results parsed (tokens: {tokens})")
return normalized[:len(headlines)]
except Exception as e:
logger.error(f"Sentiment analysis error: {e}")
return [_neutral(f"Error: {str(e)[:50]}") for _ in headlines]
def _neutral(reason: str = "") -> dict:
return {
"overall_sentiment": "neutral",
"confidence": 0.3,
"targets": [],
"impact_summary": reason or "Unable to analyze",
}