Skip to content

Commit a4de971

Browse files
Harden Adanos sentiment integration
1 parent de40194 commit a4de971

4 files changed

Lines changed: 167 additions & 12 deletions

File tree

‎backend_api_python/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@ Example:
201201
GET /api/global-market/adanos-sentiment?tickers=AAPL,TSLA&source=reddit&days=7
202202
```
203203

204+
The response normalizes common compare fields across sources, including
205+
`sentiment_score`, `buzz_score`, `bullish_pct`, `bearish_pct`, `mentions`,
206+
`trend`, `trend_history`, and source-specific activity metrics such as
207+
`subreddit_count`, `unique_tweets`, `source_count`, `trade_count`,
208+
`market_count`, and `total_liquidity`.
209+
204210
## AI analysis & memory
205211

206212
Uses **FastAnalysisService** (single LLM call, multi-factor):

‎backend_api_python/app/data_providers/adanos_sentiment.py‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Optional Adanos Market Sentiment provider for US stock tickers."""
22
from __future__ import annotations
33

4+
import math
45
import os
56
import re
67
from typing import Any, Dict, Iterable, List, Optional
@@ -28,8 +29,11 @@
2829
_TICKER_RE = re.compile(r"^\$?[A-Z][A-Z0-9.]{0,9}$")
2930

3031

31-
def parse_tickers(value: str | Iterable[str]) -> List[str]:
32+
def parse_tickers(value: str | Iterable[str] | None) -> List[str]:
3233
"""Normalize comma-separated or iterable stock ticker input."""
34+
if value is None:
35+
return []
36+
3337
raw_values = value.split(",") if isinstance(value, str) else list(value)
3438
tickers: List[str] = []
3539
seen = set()
@@ -61,19 +65,32 @@ def _to_float(value: Any) -> Optional[float]:
6165
if value in (None, ""):
6266
return None
6367
try:
64-
return float(value)
68+
number = float(value)
6569
except (TypeError, ValueError):
6670
return None
71+
return number if math.isfinite(number) else None
6772

6873

6974
def _to_int(value: Any) -> Optional[int]:
7075
number = _to_float(value)
7176
return int(number) if number is not None else None
7277

7378

74-
def _rows(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
75-
rows = payload.get("stocks") or payload.get("data") or payload.get("results") or []
76-
return rows if isinstance(rows, list) else []
79+
def _pick(record: Dict[str, Any], *keys: str) -> Any:
80+
for key in keys:
81+
if key in record:
82+
return record.get(key)
83+
return None
84+
85+
86+
def _rows(payload: Any) -> List[Dict[str, Any]]:
87+
if isinstance(payload, list):
88+
rows = payload
89+
elif isinstance(payload, dict):
90+
rows = payload.get("stocks") or payload.get("data") or payload.get("results") or []
91+
else:
92+
rows = []
93+
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
7794

7895

7996
def _normalize_record(record: Dict[str, Any], source: str) -> Optional[Dict[str, Any]]:
@@ -83,18 +100,24 @@ def _normalize_record(record: Dict[str, Any], source: str) -> Optional[Dict[str,
83100

84101
return {
85102
"ticker": ticker,
86-
"company_name": record.get("company_name"),
103+
"company_name": _pick(record, "company_name", "name", "company"),
87104
"source": source,
88-
"sentiment_score": _to_float(record.get("sentiment_score", record.get("sentiment", record.get("score")))),
89-
"buzz_score": _to_float(record.get("buzz_score", record.get("buzz"))),
105+
"sentiment_score": _to_float(_pick(record, "sentiment_score", "sentiment", "score")),
106+
"buzz_score": _to_float(_pick(record, "buzz_score", "buzz")),
90107
"bullish_pct": _to_float(record.get("bullish_pct")),
91108
"bearish_pct": _to_float(record.get("bearish_pct")),
92-
"mentions": _to_int(record.get("mentions")),
109+
"mentions": _to_int(_pick(record, "mentions", "mention_count")),
93110
"source_count": _to_int(record.get("source_count")),
94111
"subreddit_count": _to_int(record.get("subreddit_count")),
95-
"trade_count": _to_int(record.get("trade_count")),
112+
"unique_posts": _to_int(record.get("unique_posts")),
96113
"unique_tweets": _to_int(record.get("unique_tweets")),
114+
"trade_count": _to_int(record.get("trade_count")),
115+
"market_count": _to_int(record.get("market_count")),
116+
"unique_traders": _to_int(record.get("unique_traders")),
117+
"total_upvotes": _to_int(_pick(record, "total_upvotes", "upvotes")),
118+
"total_liquidity": _to_float(record.get("total_liquidity")),
97119
"trend": record.get("trend"),
120+
"trend_history": record.get("trend_history") if isinstance(record.get("trend_history"), list) else [],
98121
}
99122

100123

@@ -110,7 +133,7 @@ def _timeout() -> int:
110133

111134

112135
def fetch_adanos_market_sentiment(
113-
tickers: str | Iterable[str],
136+
tickers: str | Iterable[str] | None,
114137
*,
115138
source: Optional[str] = None,
116139
days: int = DEFAULT_DAYS,

‎backend_api_python/tests/test_data_providers.py‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ def json():
6161
"buzz_score": "72.5",
6262
"bullish_pct": "58",
6363
"mentions": "128",
64+
"unique_posts": "32",
65+
"total_upvotes": "1204",
6466
"trend": "rising",
67+
"trend_history": [45.0, 51.5, 72.5],
6568
}
6669
]
6770
}
@@ -94,9 +97,15 @@ def get(url, **kwargs):
9497
"mentions": 128,
9598
"source_count": None,
9699
"subreddit_count": None,
97-
"trade_count": None,
100+
"unique_posts": 32,
98101
"unique_tweets": None,
102+
"trade_count": None,
103+
"market_count": None,
104+
"unique_traders": None,
105+
"total_upvotes": 1204,
106+
"total_liquidity": None,
99107
"trend": "rising",
108+
"trend_history": [45.0, 51.5, 72.5],
100109
}
101110
assert calls[0][0] == "https://api.example.test/reddit/stocks/v1/compare"
102111
assert calls[0][1]["params"] == {"tickers": "AAPL", "days": 14}
@@ -122,6 +131,58 @@ def get(url, **kwargs):
122131
assert result["error"] == "Adanos API returned HTTP 429"
123132

124133

134+
def test_adanos_sentiment_handles_list_payload_and_non_finite_numbers():
135+
from app.data_providers.adanos_sentiment import fetch_adanos_market_sentiment
136+
137+
class FakeResponse:
138+
status_code = 200
139+
140+
@staticmethod
141+
def json():
142+
return [
143+
{
144+
"symbol": "$MSFT",
145+
"name": "Microsoft Corp.",
146+
"sentiment": "NaN",
147+
"buzz": "inf",
148+
"mentions": "bad",
149+
"market_count": "3",
150+
"total_liquidity": "45200.5",
151+
"trend_history": [12.0, 18.5],
152+
},
153+
"not-a-row",
154+
{},
155+
]
156+
157+
class FakeSession:
158+
@staticmethod
159+
def get(url, **kwargs):
160+
return FakeResponse()
161+
162+
result = fetch_adanos_market_sentiment(
163+
None,
164+
api_key="adanos_test_key",
165+
session=FakeSession,
166+
)
167+
assert result["stocks"] == []
168+
assert result["error"] == "No valid stock tickers provided"
169+
170+
result = fetch_adanos_market_sentiment(
171+
"MSFT",
172+
api_key="adanos_test_key",
173+
session=FakeSession,
174+
)
175+
176+
assert result["stocks"][0]["ticker"] == "MSFT"
177+
assert result["stocks"][0]["company_name"] == "Microsoft Corp."
178+
assert result["stocks"][0]["sentiment_score"] is None
179+
assert result["stocks"][0]["buzz_score"] is None
180+
assert result["stocks"][0]["mentions"] is None
181+
assert result["stocks"][0]["market_count"] == 3
182+
assert result["stocks"][0]["total_liquidity"] == 45200.5
183+
assert result["stocks"][0]["trend_history"] == [12.0, 18.5]
184+
185+
125186
def test_adanos_sentiment_validates_source():
126187
import pytest
127188
from app.data_providers.adanos_sentiment import normalize_source
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Tests for the optional Adanos global-market endpoint."""
2+
3+
4+
def _auth_headers(monkeypatch):
5+
from app.utils import auth
6+
7+
monkeypatch.setattr(
8+
auth,
9+
"verify_token",
10+
lambda token: {
11+
"sub": "tester",
12+
"user_id": 1,
13+
"role": "user",
14+
"token_version": 1,
15+
},
16+
)
17+
return {"Authorization": "Bearer test-token"}
18+
19+
20+
def test_adanos_sentiment_endpoint_requires_login(client):
21+
resp = client.get("/api/global-market/adanos-sentiment?tickers=AAPL")
22+
23+
assert resp.status_code == 401
24+
assert resp.get_json()["msg"] == "Token missing"
25+
26+
27+
def test_adanos_sentiment_endpoint_returns_provider_result(client, monkeypatch):
28+
from app.routes import global_market
29+
30+
calls = []
31+
32+
def fake_fetch(tickers, *, source=None, days=7):
33+
calls.append({"tickers": tickers, "source": source, "days": days})
34+
return {
35+
"enabled": False,
36+
"provider": "adanos",
37+
"source": source,
38+
"days": days,
39+
"tickers": ["AAPL", "TSLA"],
40+
"stocks": [],
41+
"error": "ADANOS_API_KEY is not configured",
42+
}
43+
44+
monkeypatch.setattr(global_market, "fetch_adanos_market_sentiment", fake_fetch)
45+
46+
resp = client.get(
47+
"/api/global-market/adanos-sentiment?tickers=aapl,$tsla&source=x&days=14",
48+
headers=_auth_headers(monkeypatch),
49+
)
50+
51+
assert resp.status_code == 200
52+
body = resp.get_json()
53+
assert body["code"] == 1
54+
assert body["data"]["enabled"] is False
55+
assert calls == [{"tickers": "aapl,$tsla", "source": "x", "days": 14}]
56+
57+
58+
def test_adanos_sentiment_endpoint_rejects_invalid_days(client, monkeypatch):
59+
resp = client.get(
60+
"/api/global-market/adanos-sentiment?tickers=AAPL&days=bad",
61+
headers=_auth_headers(monkeypatch),
62+
)
63+
64+
assert resp.status_code == 400
65+
assert resp.get_json()["code"] == 0

0 commit comments

Comments
 (0)