cicboy commited on
Commit
5d2f635
·
1 Parent(s): 856dc82

update analytics_tool.py, sentiment_tool.py and app.py

Browse files
Files changed (3) hide show
  1. app.py +2 -2
  2. tools/analytics_tool.py +46 -23
  3. tools/sentiment_tool.py +162 -241
app.py CHANGED
@@ -58,7 +58,7 @@ historical_agent = Agent(
58
  verbose=False,
59
  allow_delegations=True,
60
  tools=[historical_data_tool],
61
- llm="gpt-4o"
62
  )
63
 
64
  analytics_agent = Agent(
@@ -71,7 +71,7 @@ analytics_agent = Agent(
71
  verbose=False,
72
  allow_delegations=False,
73
  tools=[analytics_tool],
74
- llm="gpt-4o"
75
  )
76
 
77
  strategy_agent = Agent(
 
58
  verbose=False,
59
  allow_delegations=True,
60
  tools=[historical_data_tool],
61
+ llm="gpt-4o-mini"
62
  )
63
 
64
  analytics_agent = Agent(
 
71
  verbose=False,
72
  allow_delegations=False,
73
  tools=[analytics_tool],
74
+ llm="gpt-4o-mini"
75
  )
76
 
77
  strategy_agent = Agent(
tools/analytics_tool.py CHANGED
@@ -16,7 +16,7 @@ class AnalyticsTool(BaseTool):
16
  description: str = (
17
  "Aggregates structured market, historical, and sentiment data to produce "
18
  "quantitative indicators including pct_change, volatility, trend, sentiment, "
19
- "alignment consistency, and a composite confidence score."
20
  )
21
  args_schema: Type[BaseModel] = AnalyticsInput
22
 
@@ -32,7 +32,6 @@ class AnalyticsTool(BaseTool):
32
  trend = historical_data.get("trend")
33
  sentiment = sentiment_data.get("sentiment")
34
 
35
- # Validate required fields
36
  if price is None or pct_change is None or trend is None or sentiment is None:
37
  return {
38
  "error": (
@@ -44,37 +43,55 @@ class AnalyticsTool(BaseTool):
44
  sentiment = sentiment.lower()
45
 
46
  # ============================================================
47
- # 2) Alignment logic
48
  # ============================================================
49
 
50
- aligned = (
51
- (trend == "upward" and sentiment == "bullish") or
52
- (trend == "downward" and sentiment == "bearish")
53
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  # ============================================================
56
- # 3) Composite score (bounded to [-1, 1])
57
  # ============================================================
58
 
59
- score = 0
60
-
61
- # pct_change contribution
62
- score += pct_change / 10 # scaled
63
 
64
- # alignment contribution
65
- score += 0.2 if aligned else -0.2
 
66
 
67
- # sentiment contribution
68
- if sentiment == "bullish":
69
- score += 0.1
70
- elif sentiment == "bearish":
71
- score -= 0.1
72
 
73
- # Bound between -1 and 1
74
  score = round(max(-1, min(1, score)), 2)
75
 
76
  # ============================================================
77
- # 4) Final structured output
78
  # ============================================================
79
 
80
  return {
@@ -83,11 +100,17 @@ class AnalyticsTool(BaseTool):
83
  "volatility_pct": volatility,
84
  "trend": trend,
85
  "sentiment": sentiment,
 
 
 
86
  "alignment": "aligned" if aligned else "divergent",
87
  "composite_score": score,
88
  "summary": (
89
- f"Trend={trend}, Sentiment={sentiment}, Alignment="
90
- f"{'aligned' if aligned else 'divergent'}, Score={score}"
 
 
 
91
  ),
92
  }
93
 
 
16
  description: str = (
17
  "Aggregates structured market, historical, and sentiment data to produce "
18
  "quantitative indicators including pct_change, volatility, trend, sentiment, "
19
+ "sentiment_strength, confidence, alignment, and a composite score."
20
  )
21
  args_schema: Type[BaseModel] = AnalyticsInput
22
 
 
32
  trend = historical_data.get("trend")
33
  sentiment = sentiment_data.get("sentiment")
34
 
 
35
  if price is None or pct_change is None or trend is None or sentiment is None:
36
  return {
37
  "error": (
 
43
  sentiment = sentiment.lower()
44
 
45
  # ============================================================
46
+ # 2) Sentiment strength & confidence (new)
47
  # ============================================================
48
 
49
+ # Pull from SentimentTool if present
50
+ sentiment_strength = sentiment_data.get("sentiment_strength")
51
+ sentiment_confidence = sentiment_data.get("confidence")
52
+
53
+ # ---- Backwards-compatible defaults ----
54
+ if sentiment_strength is None:
55
+ sentiment_strength = {
56
+ "bullish": 0.7,
57
+ "neutral": 0.0,
58
+ "bearish": -0.7
59
+ }.get(sentiment, 0.0)
60
+
61
+ if sentiment_confidence is None:
62
+ # Basic proxy confidence using number of headlines/comments
63
+ news_count = len(sentiment_data.get("news_headlines", []))
64
+ reddit_count = len(sentiment_data.get("reddit_comments", []))
65
+ sources = news_count + reddit_count
66
+ sentiment_confidence = min(1.0, 0.2 + 0.1 * sources)
67
+
68
+ # Effective weighted sentiment
69
+ effective_sentiment = sentiment_strength * sentiment_confidence
70
 
71
  # ============================================================
72
+ # 3) Alignment logic (upgraded)
73
  # ============================================================
74
 
75
+ aligned = (
76
+ (trend == "upward" and effective_sentiment > 0.2) or
77
+ (trend == "downward" and effective_sentiment < -0.2)
78
+ )
79
 
80
+ # ============================================================
81
+ # 4) Composite score (new formula)
82
+ # ============================================================
83
 
84
+ score = (
85
+ (pct_change / 10) + # Trend effect
86
+ (effective_sentiment * 1.5) - # Strong weight for sentiment
87
+ (volatility / 100 if volatility else 0) # Penalize volatility
88
+ )
89
 
90
+ # Bound between [-1, 1]
91
  score = round(max(-1, min(1, score)), 2)
92
 
93
  # ============================================================
94
+ # 5) Final structured output
95
  # ============================================================
96
 
97
  return {
 
100
  "volatility_pct": volatility,
101
  "trend": trend,
102
  "sentiment": sentiment,
103
+ "sentiment_strength": round(sentiment_strength, 3),
104
+ "sentiment_confidence": round(sentiment_confidence, 3),
105
+ "effective_sentiment": round(effective_sentiment, 3),
106
  "alignment": "aligned" if aligned else "divergent",
107
  "composite_score": score,
108
  "summary": (
109
+ f"Trend={trend}, Sentiment={sentiment}, "
110
+ f"Strength={round(sentiment_strength,3)}, "
111
+ f"Confidence={round(sentiment_confidence,3)}, "
112
+ f"Alignment={'aligned' if aligned else 'divergent'}, "
113
+ f"Score={score}"
114
  ),
115
  }
116
 
tools/sentiment_tool.py CHANGED
@@ -1,14 +1,17 @@
 
 
1
  import os
2
  import json
3
  import requests
4
- from typing import Type, List
5
 
 
6
  from crewai.tools import BaseTool
7
  from openai import OpenAI
8
- from pydantic import BaseModel, Field
9
 
10
  # -----------------------------
11
- # Environment variables
12
  # -----------------------------
13
  SERPER_API_KEY = os.getenv("SERPER_API_KEY")
14
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
@@ -17,286 +20,204 @@ client = OpenAI(api_key=OPENAI_API_KEY)
17
 
18
 
19
  # -----------------------------
20
- # Input schema
21
  # -----------------------------
22
  class SentimentInput(BaseModel):
 
23
  query: str = Field(
24
  default="bitcoin",
25
- description="Cryptocurrency name the user is asking about, e.g. 'bitcoin', 'ethereum', 'solana'."
26
  )
27
 
28
 
29
- # -----------------------------
30
- # Sentiment Tool
31
- # -----------------------------
32
  class SentimentTool(BaseTool):
 
 
 
 
 
 
 
 
 
 
 
33
  name: str = "get_crypto_sentiment"
34
  description: str = (
35
- "Fetches recent cryptocurrency news and Reddit discussions for a given coin, "
36
- "then returns structured sentiment JSON based on Serper (Google News + "
37
- "r/CryptoMarkets comments) and OpenAI analysis."
38
  )
39
- # IMPORTANT: args_schema (not arg_schema) for Pydantic v2 + CrewAI
40
  args_schema: Type[BaseModel] = SentimentInput
41
 
42
- # -----------------------------------------
43
- # Helper: dynamic coin keywords via CoinGecko
44
- # -----------------------------------------
45
- def _coin_keywords(self, coin: str) -> List[str]:
46
- """
47
- Build a keyword set for matching Reddit comments:
48
- - coin name
49
- - no-space version
50
- - CoinGecko ticker symbol (e.g. btc, eth, sol) when available
51
- """
52
- coin = coin.lower().strip()
53
- keywords = set()
54
-
55
- if not coin:
56
- return ["bitcoin", "btc"]
57
-
58
- # Base name variants
59
- keywords.add(coin) # "bitcoin"
60
- keywords.add(coin.replace(" ", "")) # "shiba inu" -> "shibainu"
61
- keywords.add(coin.split()[0]) # first word e.g. "shiba"
62
- if len(coin) >= 3:
63
- keywords.add(coin[:3]) # crude fallback, e.g. "bit"
64
-
65
- # Try to get symbol from CoinGecko
66
- try:
67
- # First attempt: assume user input matches CoinGecko ID
68
- cg_url = f"https://api.coingecko.com/api/v3/coins/{coin}"
69
- r = requests.get(cg_url, timeout=5)
70
- if r.status_code != 200:
71
- # Fallback: use /search when ID doesn't match
72
- search_url = "https://api.coingecko.com/api/v3/search"
73
- sr = requests.get(search_url, params={"query": coin}, timeout=5)
74
- if sr.status_code == 200:
75
- results = sr.json().get("coins", [])
76
- if results:
77
- first_id = results[0].get("id")
78
- if first_id:
79
- r = requests.get(
80
- f"https://api.coingecko.com/api/v3/coins/{first_id}",
81
- timeout=5
82
- )
83
-
84
- if r.status_code == 200:
85
- data = r.json()
86
- symbol = data.get("symbol", "").lower()
87
- if symbol:
88
- keywords.add(symbol) # "btc"
89
- keywords.add(symbol.upper()) # "BTC"
90
- keywords.add(symbol + " price")
91
- keywords.add(coin + " price")
92
- except Exception:
93
- # If CoinGecko fails, we still have the base keywords
94
- pass
95
-
96
- return list({k for k in keywords if k})
97
-
98
- # -----------------------------------------
99
- # Helper: fetch recent news headlines
100
- # -----------------------------------------
101
- def _fetch_news(self, query: str) -> List[str]:
102
  if not SERPER_API_KEY:
103
- return []
104
 
105
- try:
106
- url = "https://google.serper.dev/news"
107
- headers = {
108
- "X-API-KEY": SERPER_API_KEY,
109
- "Content-Type": "application/json"
110
- }
111
- payload = {
112
- "q": f"{query} crypto",
113
- "num": 10
114
- }
115
-
116
- r = requests.post(url, headers=headers, json=payload, timeout=10)
117
- r.raise_for_status()
118
- news_items = r.json().get("news", [])
119
- return [n.get("title", "").strip() for n in news_items[:10] if n.get("title")]
120
- except Exception:
121
- return []
122
-
123
- # -----------------------------------------
124
- # Helper: find recent r/CryptoMarkets posts (last 7 days)
125
- # -----------------------------------------
126
- def _fetch_reddit_post_urls(self, keywords: List[str]) -> List[str]:
127
- """
128
- Use Serper search to find r/CryptoMarkets/comments posts in the last 7 days
129
- matching the coin keywords.
130
- """
131
- if not SERPER_API_KEY:
132
- return []
133
 
134
  try:
135
- query_string = " OR ".join(f'"{k}"' for k in keywords)
136
- search_query = f"({query_string}) site:reddit.com/r/CryptoMarkets/comments"
 
 
137
 
138
- url = "https://google.serper.dev/search"
139
- headers = {
140
- "X-API-KEY": SERPER_API_KEY,
141
- "Content-Type": "application/json"
142
- }
143
- payload = {
144
- "q": search_query,
145
- "num": 10,
146
- "tbs": "qdr:w" # last 7 days
147
- }
148
 
149
- r = requests.post(url, headers=headers, json=payload, timeout=10)
150
- r.raise_for_status()
151
-
152
- organic_results = r.json().get("organic", [])
153
- urls = [
154
- item.get("link")
155
- for item in organic_results
156
- if "/comments/" in (item.get("link") or "")
157
- ]
158
- return [u for u in urls if u]
159
- except Exception:
160
- return []
161
-
162
- # -----------------------------------------
163
- # Helper: scrape Reddit comments from Serper
164
- # -----------------------------------------
165
- def _scrape_reddit_comments(self, urls: List[str], keywords: List[str]) -> List[str]:
166
- """
167
- Use Serper /scrape to pull text blocks from Reddit threads.
168
- Keep only early blocks (top comments) that mention the coin keywords.
169
- """
170
- if not SERPER_API_KEY:
171
- return []
172
 
173
- comments: List[str] = []
 
174
 
175
- for link in urls[:3]: # limit to 3 threads for speed & cost
176
- try:
177
- url = "https://google.serper.dev/scrape"
178
- headers = {
179
- "X-API-KEY": SERPER_API_KEY,
180
- "Content-Type": "application/json"
181
- }
182
- payload = {"url": link}
183
-
184
- r = requests.post(url, headers=headers, json=payload, timeout=10)
185
- r.raise_for_status()
186
-
187
- blocks = r.json().get("blocks", [])
188
- text_blocks = [b.get("text", "") for b in blocks[:20]]
189
-
190
- for t in text_blocks:
191
- text = (t or "").strip()
192
- if not text:
193
- continue
194
- lower = text.lower()
195
- # basic relevance: contains any coin keyword and is not tiny
196
- if any(k.lower() in lower for k in keywords) and len(text) > 40:
197
- comments.append(text)
198
-
199
- except Exception:
200
- # Skip any failed scrape silently
201
- continue
202
-
203
- # Cap to 10 highest-signal comments
204
- return comments[:10]
205
-
206
- # -----------------------------------------
207
- # Main execution
208
- # -----------------------------------------
209
- def _run(self, query: str = "bitcoin") -> dict:
210
- """
211
- End-to-end sentiment pipeline:
212
- - Build coin keyword set (coin name + ticker via CoinGecko)
213
- - Fetch Serper News for the coin
214
- - Fetch r/CryptoMarkets posts in last 7 days and scrape comments
215
- - Ask OpenAI (gpt-4.1) to return structured JSON sentiment.
216
- """
217
- if not OPENAI_API_KEY:
218
- return {"error": "OPENAI_API_KEY missing in environment."}
219
- if not SERPER_API_KEY:
220
  return {
221
- "error": "SERPER_API_KEY missing in environment. "
222
- "Cannot fetch news/reddit sentiment."
 
 
 
 
223
  }
224
 
225
- try:
226
- coin = query.strip()
227
- if not coin:
228
- coin = "bitcoin"
229
-
230
- # 1) Build keyword set (coin + ticker)
231
- keywords = self._coin_keywords(coin)
232
-
233
- # 2) Fetch news
234
- news_headlines = self._fetch_news(coin)
235
-
236
- # 3) Fetch & scrape Reddit comments
237
- reddit_urls = self._fetch_reddit_post_urls(keywords)
238
- reddit_comments = self._scrape_reddit_comments(reddit_urls, keywords)
239
-
240
- # 4) Build combined context
241
- combined_text = (
242
- "NEWS HEADLINES:\n"
243
- + ("\n".join(f"- {h}" for h in news_headlines) if news_headlines else "None")
244
- + "\n\nREDDIT COMMENTS (r/CryptoMarkets):\n"
245
- + ("\n".join(f"- {c}" for c in reddit_comments) if reddit_comments else "None")
246
- )
247
 
248
- # 5) Ask OpenAI for structured sentiment JSON
249
- prompt = f"""
250
- You are a crypto sentiment analyst.
251
 
252
- You are given recent NEWS HEADLINES and REDDIT COMMENTS about the coin "{coin}".
 
253
 
254
- Your job:
255
- 1. Decide whether the overall sentiment is bullish, bearish, or neutral.
256
- 2. Write a short reasoning explaining why, referencing both news and reddit if available.
257
- 3. Return ONLY valid JSON in this exact format:
258
 
259
  {{
260
  "sentiment": "bullish" | "bearish" | "neutral",
261
- "reasoning": "short explanation tying together news + reddit, if both exist",
262
- "news_headlines": [...], // list of strings, may be empty
263
- "reddit_comments": [...] // list of strings, may be empty
 
 
264
  }}
265
 
266
- Do NOT wrap the JSON in backticks or any extra text.
267
- Just return the JSON object.
268
-
269
- DATA:
270
- {combined_text}
271
  """
272
 
 
273
  completion = client.chat.completions.create(
274
  model="gpt-4.1",
275
  temperature=0.2,
276
  messages=[
277
- {"role": "system", "content": "You are a precise crypto sentiment classifier."},
278
- {"role": "user", "content": prompt}
279
  ]
280
  )
281
 
282
- raw_content = completion.choices[0].message.content.strip()
283
 
284
- # Try to parse JSON; if it fails, wrap raw content
285
  try:
286
- parsed = json.loads(raw_content)
287
- # Ensure we always attach raw data as well for downstream tools if needed
288
- parsed.setdefault("news_headlines", news_headlines)
289
- parsed.setdefault("reddit_comments", reddit_comments)
290
- return parsed
291
- except Exception:
292
- # Fallback: return structured-ish dict with raw model output
293
- return {
294
- "sentiment": None,
295
- "reasoning": "Model did not return valid JSON; raw content preserved.",
296
- "news_headlines": news_headlines,
297
- "reddit_comments": reddit_comments,
298
- "raw_model_output": raw_content,
299
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
 
301
  except Exception as e:
302
- return {"error": f"SentimentTool failed: {str(e)}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tools/sentiment_tool.py
2
+
3
  import os
4
  import json
5
  import requests
6
+ from typing import Type, List, Any, Dict, Optional
7
 
8
+ from pydantic import BaseModel, Field
9
  from crewai.tools import BaseTool
10
  from openai import OpenAI
11
+
12
 
13
  # -----------------------------
14
+ # Environment
15
  # -----------------------------
16
  SERPER_API_KEY = os.getenv("SERPER_API_KEY")
17
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
 
20
 
21
 
22
  # -----------------------------
23
+ # Input Schema
24
  # -----------------------------
25
  class SentimentInput(BaseModel):
26
+ """Input schema for sentiment analysis tool."""
27
  query: str = Field(
28
  default="bitcoin",
29
+ description="Cryptocurrency or asset to evaluate sentiment for.",
30
  )
31
 
32
 
33
+ # ===================================================================
34
+ # SENTIMENT TOOL (NEWS-ONLY VERSION)
35
+ # ===================================================================
36
  class SentimentTool(BaseTool):
37
+ """
38
+ Fetches recent crypto news via Serper and produces aggregated sentiment
39
+ using GPT-4.1 with:
40
+ - sentiment: bullish / bearish / neutral
41
+ - sentiment_strength: float [-1, 1]
42
+ - confidence: float [0, 1]
43
+ - themes: emergent topics
44
+ - reasoning: summary explanation
45
+ - news_headlines: titles used
46
+ """
47
+
48
  name: str = "get_crypto_sentiment"
49
  description: str = (
50
+ "Fetches crypto news via Serper and classifies sentiment with strength, "
51
+ "confidence, themes, and explanation. News-only version."
 
52
  )
 
53
  args_schema: Type[BaseModel] = SentimentInput
54
 
55
+ # -----------------------------------------------------
56
+ # Fetch news (Serper)
57
+ # -----------------------------------------------------
58
+ def _fetch_news(self, query: str, max_results: int = 12) -> (List[str], Optional[str]):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  if not SERPER_API_KEY:
60
+ return [], "SERPER_API_KEY missing"
61
 
62
+ url = "https://google.serper.dev/news"
63
+ headers = {"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"}
64
+ payload = {"q": f"{query} cryptocurrency", "num": max_results}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  try:
67
+ resp = requests.post(url, headers=headers, json=payload, timeout=10)
68
+ resp.raise_for_status()
69
+ news_items = resp.json().get("news", []) or []
70
+ titles = [n.get("title", "").strip() for n in news_items if n.get("title")]
71
 
72
+ # Deduplicate while preserving order
73
+ seen, unique = set(), []
74
+ for t in titles:
75
+ if t not in seen:
76
+ seen.add(t)
77
+ unique.append(t)
 
 
 
 
78
 
79
+ return unique, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
+ except Exception as e:
82
+ return [], f"Serper error: {str(e)}"
83
 
84
+ # -----------------------------------------------------
85
+ # LLM Sentiment Aggregation
86
+ # -----------------------------------------------------
87
+ def _analyze_with_llm(self, coin: str, headlines: List[str]) -> Dict[str, Any]:
88
+
89
+ if not headlines:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  return {
91
+ "sentiment": "neutral",
92
+ "sentiment_strength": 0.0,
93
+ "confidence": 0.0,
94
+ "reasoning": "No news available; defaulting to neutral.",
95
+ "news_headlines": [],
96
+ "themes": []
97
  }
98
 
99
+ headlines_block = "\n".join(f"{i+1}. {h}" for i, h in enumerate(headlines))
100
+
101
+ prompt = f"""
102
+ You are a professional crypto macro-sentiment analyst.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
+ Analyze the following recent news headlines about "{coin}" and determine
105
+ aggregate sentiment.
 
106
 
107
+ Headlines:
108
+ {headlines_block}
109
 
110
+ Return STRICT JSON ONLY in this format:
 
 
 
111
 
112
  {{
113
  "sentiment": "bullish" | "bearish" | "neutral",
114
+ "sentiment_strength": number, // -1.0 to +1.0
115
+ "confidence": number, // 0.0 to 1.0
116
+ "reasoning": "short explanation",
117
+ "news_headlines": [...],
118
+ "themes": [...]
119
  }}
120
 
121
+ Rules:
122
+ - Consider macro context, price action, regulatory tone, adoption, and risk sentiment.
123
+ - No extra text. JSON only.
 
 
124
  """
125
 
126
+ try:
127
  completion = client.chat.completions.create(
128
  model="gpt-4.1",
129
  temperature=0.2,
130
  messages=[
131
+ {"role": "system", "content": "Return ONLY valid JSON. You are precise."},
132
+ {"role": "user", "content": prompt}
133
  ]
134
  )
135
 
136
+ raw = completion.choices[0].message.content.strip()
137
 
138
+ # Attempt direct JSON load
139
  try:
140
+ parsed = json.loads(raw)
141
+ except:
142
+ # Try to extract JSON substring
143
+ start, end = raw.find("{"), raw.rfind("}")
144
+ if start == -1 or end == -1:
145
+ raise ValueError("No JSON found in model output.")
146
+ parsed = json.loads(raw[start:end+1])
147
+
148
+ # Validate sentiment
149
+ sentiment = parsed.get("sentiment", "neutral").lower()
150
+ if sentiment not in {"bullish", "bearish", "neutral"}:
151
+ sentiment = "neutral"
152
+
153
+ # Clip strength + confidence
154
+ def clip(val, lo, hi, default):
155
+ try:
156
+ v = float(val)
157
+ return max(lo, min(hi, v))
158
+ except:
159
+ return default
160
+
161
+ strength = clip(parsed.get("sentiment_strength"), -1.0, 1.0, 0.0)
162
+ confidence = clip(parsed.get("confidence"), 0.0, 1.0, 0.0)
163
+
164
+ themes = parsed.get("themes", [])
165
+ if not isinstance(themes, list):
166
+ themes = []
167
+
168
+ used = parsed.get("news_headlines", headlines)
169
+ if not isinstance(used, list) or not used:
170
+ used = headlines
171
+
172
+ return {
173
+ "sentiment": sentiment,
174
+ "sentiment_strength": strength,
175
+ "confidence": confidence,
176
+ "reasoning": parsed.get("reasoning", ""),
177
+ "news_headlines": used,
178
+ "themes": themes
179
+ }
180
 
181
  except Exception as e:
182
+ return {
183
+ "sentiment": "neutral",
184
+ "sentiment_strength": 0.0,
185
+ "confidence": 0.0,
186
+ "reasoning": f"LLM sentiment failure: {str(e)}",
187
+ "news_headlines": headlines,
188
+ "themes": []
189
+ }
190
+
191
+ # -----------------------------------------------------
192
+ # Main Entrypoint
193
+ # -----------------------------------------------------
194
+ def _run(self, query: str = "bitcoin") -> Dict[str, Any]:
195
+
196
+ if not OPENAI_API_KEY:
197
+ return {
198
+ "sentiment": "neutral",
199
+ "sentiment_strength": 0.0,
200
+ "confidence": 0.0,
201
+ "reasoning": "OPENAI_API_KEY missing; neutral fallback.",
202
+ "news_headlines": [],
203
+ "themes": []
204
+ }
205
+
206
+ # Fetch news
207
+ headlines, news_error = self._fetch_news(query)
208
+
209
+ if news_error and not headlines:
210
+ return {
211
+ "sentiment": "neutral",
212
+ "sentiment_strength": 0.0,
213
+ "confidence": 0.0,
214
+ "reasoning": f"No news available: {news_error}",
215
+ "news_headlines": [],
216
+ "themes": []
217
+ }
218
+
219
+ # Analyze
220
+ sentiment = self._analyze_with_llm(query, headlines)
221
+
222
+ sentiment["news_error"] = news_error
223
+ return sentiment