Skip to content

Commit f7450c3

Browse files
authored
Merge pull request #54 from adanos-software/codex/adanos-market-sentiment
Add optional Adanos market sentiment provider
2 parents e0f16d9 + a4de971 commit f7450c3

9 files changed

Lines changed: 502 additions & 0 deletions

File tree

‎DEVELOPMENT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ See `backend_api_python/env.example` for the full list. Key variables:
130130
| `SECRET_KEY` | **yes** | JWT signing key — must be changed from default |
131131
| `ADMIN_USER` / `ADMIN_PASSWORD` | yes | Initial admin credentials |
132132
| `TWELVE_DATA_API_KEY` | no | Twelve Data for forex/commodities |
133+
| `ADANOS_API_KEY` | no | Optional Adanos Market Sentiment for US stock tickers |
133134
| `OPENAI_API_KEY` or `OPENROUTER_API_KEY` | no | AI analysis features |
134135
| `CACHE_ENABLED` | no | Set `true` to use Redis (auto-set in Docker) |
135136

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ Use `backend_api_python/env.example` as the primary template. Key areas include:
497497
| Billing | `BILLING_ENABLED`, `BILLING_COST_AI_ANALYSIS` |
498498
| Membership | `MEMBERSHIP_MONTHLY_PRICE_USD`, `MEMBERSHIP_MONTHLY_CREDITS` |
499499
| USDT Payment | `USDT_PAY_ENABLED`, `USDT_TRC20_XPUB`, `TRONGRID_API_KEY` |
500+
| Optional data APIs | `TWELVE_DATA_API_KEY`, `FINNHUB_API_KEY`, `TIINGO_API_KEY`, `ADANOS_API_KEY` |
500501
| Proxy | `PROXY_URL` |
501502
| Workers | `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`, `ENABLE_REFLECTION_WORKER` |
502503
| AI tuning | `ENABLE_AI_ENSEMBLE`, `ENABLE_CONFIDENCE_CALIBRATION`, `AI_ENSEMBLE_MODELS` |

‎backend_api_python/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,36 @@ POST /api/users/change-password - Change own password
177177
```text
178178
GET /api/health
179179
GET /api/indicator/kline
180+
GET /api/global-market/adanos-sentiment?tickers=AAPL,TSLA
180181
POST /api/fast-analysis/analyze - Fast AI analysis (main entry)
181182
GET /api/fast-analysis/history - Analysis history
182183
GET /api/fast-analysis/similar-patterns - RAG similar patterns
183184
POST /api/fast-analysis/feedback - User feedback on analysis
184185
```
185186

187+
### Optional Adanos Market Sentiment
188+
189+
Set `ADANOS_API_KEY` to enable optional US stock sentiment enrichment from the
190+
Adanos Market Sentiment API. If the key is not configured, the endpoint returns
191+
`enabled=false` and the rest of QuantDinger continues to work normally.
192+
193+
```bash
194+
ADANOS_API_KEY=your_adanos_key
195+
ADANOS_SENTIMENT_SOURCE=reddit # reddit, x, news, or polymarket
196+
```
197+
198+
Example:
199+
200+
```text
201+
GET /api/global-market/adanos-sentiment?tickers=AAPL,TSLA&source=reddit&days=7
202+
```
203+
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+
186210
## AI analysis & memory
187211

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

‎backend_api_python/app/config/api_keys.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ def TWELVE_DATA_API_KEY(cls):
3939
from app.utils.config_loader import load_addon_config
4040
val = load_addon_config().get('twelve_data', {}).get('api_key')
4141
return val if val else ''
42+
43+
@property
44+
def ADANOS_API_KEY(cls):
45+
"""Adanos Market Sentiment API key (optional)."""
46+
env_val = os.getenv('ADANOS_API_KEY', '').strip()
47+
if env_val:
48+
return env_val
49+
from app.utils.config_loader import load_addon_config
50+
val = load_addon_config().get('adanos', {}).get('api_key')
51+
return val if val else ''
4252

4353
@property
4454
def OPENROUTER_API_KEY(cls):
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Optional Adanos Market Sentiment provider for US stock tickers."""
2+
from __future__ import annotations
3+
4+
import math
5+
import os
6+
import re
7+
from typing import Any, Dict, Iterable, List, Optional
8+
9+
import requests
10+
11+
from app.config import APIKeys
12+
from app.utils.logger import get_logger
13+
14+
logger = get_logger(__name__)
15+
16+
DEFAULT_BASE_URL = "https://api.adanos.org"
17+
DEFAULT_SOURCE = "reddit"
18+
DEFAULT_DAYS = 7
19+
DEFAULT_TIMEOUT = 10
20+
MAX_TICKERS = 20
21+
22+
SUPPORTED_SOURCES = {
23+
"reddit": "reddit/stocks/v1",
24+
"x": "x/stocks/v1",
25+
"news": "news/stocks/v1",
26+
"polymarket": "polymarket/stocks/v1",
27+
}
28+
29+
_TICKER_RE = re.compile(r"^\$?[A-Z][A-Z0-9.]{0,9}$")
30+
31+
32+
def parse_tickers(value: str | Iterable[str] | None) -> List[str]:
33+
"""Normalize comma-separated or iterable stock ticker input."""
34+
if value is None:
35+
return []
36+
37+
raw_values = value.split(",") if isinstance(value, str) else list(value)
38+
tickers: List[str] = []
39+
seen = set()
40+
41+
for raw in raw_values:
42+
ticker = str(raw or "").strip().replace("$", "").upper()
43+
if not ticker or not _TICKER_RE.match(ticker):
44+
continue
45+
if ticker not in seen:
46+
tickers.append(ticker)
47+
seen.add(ticker)
48+
if len(tickers) >= MAX_TICKERS:
49+
break
50+
51+
return tickers
52+
53+
54+
def normalize_source(source: Optional[str]) -> str:
55+
"""Return a supported Adanos source name."""
56+
normalized = (source or os.getenv("ADANOS_SENTIMENT_SOURCE") or DEFAULT_SOURCE).strip().lower()
57+
if normalized == "twitter":
58+
normalized = "x"
59+
if normalized not in SUPPORTED_SOURCES:
60+
raise ValueError(f"Unsupported Adanos sentiment source: {source}")
61+
return normalized
62+
63+
64+
def _to_float(value: Any) -> Optional[float]:
65+
if value in (None, ""):
66+
return None
67+
try:
68+
number = float(value)
69+
except (TypeError, ValueError):
70+
return None
71+
return number if math.isfinite(number) else None
72+
73+
74+
def _to_int(value: Any) -> Optional[int]:
75+
number = _to_float(value)
76+
return int(number) if number is not None else None
77+
78+
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 []
94+
95+
96+
def _normalize_record(record: Dict[str, Any], source: str) -> Optional[Dict[str, Any]]:
97+
ticker = str(record.get("ticker") or record.get("symbol") or "").strip().replace("$", "").upper()
98+
if not ticker:
99+
return None
100+
101+
return {
102+
"ticker": ticker,
103+
"company_name": _pick(record, "company_name", "name", "company"),
104+
"source": source,
105+
"sentiment_score": _to_float(_pick(record, "sentiment_score", "sentiment", "score")),
106+
"buzz_score": _to_float(_pick(record, "buzz_score", "buzz")),
107+
"bullish_pct": _to_float(record.get("bullish_pct")),
108+
"bearish_pct": _to_float(record.get("bearish_pct")),
109+
"mentions": _to_int(_pick(record, "mentions", "mention_count")),
110+
"source_count": _to_int(record.get("source_count")),
111+
"subreddit_count": _to_int(record.get("subreddit_count")),
112+
"unique_posts": _to_int(record.get("unique_posts")),
113+
"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")),
119+
"trend": record.get("trend"),
120+
"trend_history": record.get("trend_history") if isinstance(record.get("trend_history"), list) else [],
121+
}
122+
123+
124+
def _api_base_url() -> str:
125+
return (os.getenv("ADANOS_API_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/")
126+
127+
128+
def _timeout() -> int:
129+
try:
130+
return max(1, int(os.getenv("ADANOS_API_TIMEOUT", str(DEFAULT_TIMEOUT))))
131+
except ValueError:
132+
return DEFAULT_TIMEOUT
133+
134+
135+
def fetch_adanos_market_sentiment(
136+
tickers: str | Iterable[str] | None,
137+
*,
138+
source: Optional[str] = None,
139+
days: int = DEFAULT_DAYS,
140+
api_key: Optional[str] = None,
141+
base_url: Optional[str] = None,
142+
timeout: Optional[int] = None,
143+
session: Any = requests,
144+
) -> Dict[str, Any]:
145+
"""Fetch optional Adanos Market Sentiment snapshots.
146+
147+
This provider is intentionally fail-open: if no API key is configured, it
148+
returns ``enabled=false`` instead of raising. That keeps QuantDinger fully
149+
usable without Adanos.
150+
"""
151+
parsed_tickers = parse_tickers(tickers)
152+
selected_source = normalize_source(source)
153+
selected_days = max(1, min(int(days or DEFAULT_DAYS), 365))
154+
155+
result = {
156+
"enabled": False,
157+
"provider": "adanos",
158+
"source": selected_source,
159+
"days": selected_days,
160+
"tickers": parsed_tickers,
161+
"stocks": [],
162+
"error": None,
163+
}
164+
165+
if not parsed_tickers:
166+
result["error"] = "No valid stock tickers provided"
167+
return result
168+
169+
selected_key = str(api_key if api_key is not None else APIKeys.ADANOS_API_KEY).strip()
170+
if not selected_key:
171+
result["error"] = "ADANOS_API_KEY is not configured"
172+
return result
173+
174+
result["enabled"] = True
175+
endpoint = f"{(base_url or _api_base_url()).rstrip('/')}/{SUPPORTED_SOURCES[selected_source]}/compare"
176+
177+
try:
178+
response = session.get(
179+
endpoint,
180+
params={"tickers": ",".join(parsed_tickers), "days": selected_days},
181+
headers={"Accept": "application/json", "X-API-Key": selected_key},
182+
timeout=timeout or _timeout(),
183+
)
184+
except requests.RequestException as exc:
185+
logger.warning("Adanos sentiment request failed: %s", exc)
186+
result["error"] = str(exc)
187+
return result
188+
189+
if response.status_code != 200:
190+
result["error"] = f"Adanos API returned HTTP {response.status_code}"
191+
return result
192+
193+
try:
194+
payload = response.json()
195+
except ValueError:
196+
result["error"] = "Adanos API returned invalid JSON"
197+
return result
198+
199+
result["stocks"] = [
200+
normalized
201+
for normalized in (_normalize_record(record, selected_source) for record in _rows(payload))
202+
if normalized is not None
203+
]
204+
return result

‎backend_api_python/app/routes/global_market.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- GET /api/global-market/news - Financial news (with lang param)
1717
- GET /api/global-market/calendar - Economic calendar
1818
- GET /api/global-market/sentiment - Fear & Greed / VIX
19+
- GET /api/global-market/adanos-sentiment - Optional Adanos stock sentiment
1920
- GET /api/global-market/opportunities - Trading opportunities scanner
2021
"""
2122

@@ -39,6 +40,7 @@
3940
fetch_fear_greed_index, fetch_vix, fetch_dollar_index,
4041
fetch_yield_curve, fetch_vxn, fetch_gvz, fetch_put_call_ratio,
4142
)
43+
from app.data_providers.adanos_sentiment import fetch_adanos_market_sentiment
4244
from app.data_providers.news import fetch_financial_news, get_economic_calendar
4345
from app.data_providers.heatmap import generate_heatmap_data
4446
from app.data_providers.opportunities import (
@@ -229,6 +231,33 @@ def market_sentiment():
229231
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
230232

231233

234+
@global_market_bp.route("/adanos-sentiment", methods=["GET"])
235+
@login_required
236+
def adanos_market_sentiment():
237+
"""Get optional Adanos Market Sentiment for selected US stock tickers."""
238+
try:
239+
tickers = request.args.get("tickers", "")
240+
source = request.args.get("source")
241+
days = int(request.args.get("days") or 7)
242+
cache_key = f"adanos_sentiment:{source or 'default'}:{days}:{tickers.upper()}"
243+
244+
cached = get_cached(cache_key, 300)
245+
if cached:
246+
return jsonify({"code": 1, "msg": "success", "data": cached})
247+
248+
data = fetch_adanos_market_sentiment(tickers, source=source, days=days)
249+
if data.get("enabled") and not data.get("error"):
250+
set_cached(cache_key, data, 300)
251+
252+
return jsonify({"code": 1, "msg": "success", "data": data})
253+
254+
except ValueError as e:
255+
return jsonify({"code": 0, "msg": str(e), "data": None}), 400
256+
except Exception as e:
257+
logger.error("adanos_market_sentiment failed: %s", e, exc_info=True)
258+
return jsonify({"code": 0, "msg": str(e), "data": None}), 500
259+
260+
232261
@global_market_bp.route("/opportunities", methods=["GET"])
233262
@login_required
234263
def trading_opportunities():

‎backend_api_python/env.example‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,13 @@ TIINGO_TIMEOUT=10
209209
# Free tier: 800 API credits/day, 8 requests/minute. https://twelvedata.com
210210
TWELVE_DATA_API_KEY=
211211

212+
# Adanos Market Sentiment (optional US stock sentiment enrichment)
213+
# If ADANOS_API_KEY is empty, /api/global-market/adanos-sentiment returns enabled=false.
214+
ADANOS_API_KEY=
215+
ADANOS_SENTIMENT_SOURCE=reddit
216+
ADANOS_API_BASE_URL=https://api.adanos.org
217+
ADANOS_API_TIMEOUT=10
218+
212219
# AI search / news
213220
SEARCH_PROVIDER=google
214221
SEARCH_MAX_RESULTS=10

0 commit comments

Comments
 (0)