diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8c09594c8..895ff68ac 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -130,6 +130,7 @@ See `backend_api_python/env.example` for the full list. Key variables: | `SECRET_KEY` | **yes** | JWT signing key — must be changed from default | | `ADMIN_USER` / `ADMIN_PASSWORD` | yes | Initial admin credentials | | `TWELVE_DATA_API_KEY` | no | Twelve Data for forex/commodities | +| `ADANOS_API_KEY` | no | Optional Adanos Market Sentiment for US stock tickers | | `OPENAI_API_KEY` or `OPENROUTER_API_KEY` | no | AI analysis features | | `CACHE_ENABLED` | no | Set `true` to use Redis (auto-set in Docker) | diff --git a/README.md b/README.md index 6acae2d11..c216e6c05 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,7 @@ Use `backend_api_python/env.example` as the primary template. Key areas include: | Billing | `BILLING_ENABLED`, `BILLING_COST_AI_ANALYSIS` | | Membership | `MEMBERSHIP_MONTHLY_PRICE_USD`, `MEMBERSHIP_MONTHLY_CREDITS` | | USDT Payment | `USDT_PAY_ENABLED`, `USDT_TRC20_XPUB`, `TRONGRID_API_KEY` | +| Optional data APIs | `TWELVE_DATA_API_KEY`, `FINNHUB_API_KEY`, `TIINGO_API_KEY`, `ADANOS_API_KEY` | | Proxy | `PROXY_URL` | | Workers | `ENABLE_PENDING_ORDER_WORKER`, `ENABLE_PORTFOLIO_MONITOR`, `ENABLE_REFLECTION_WORKER` | | AI tuning | `ENABLE_AI_ENSEMBLE`, `ENABLE_CONFIDENCE_CALIBRATION`, `AI_ENSEMBLE_MODELS` | diff --git a/backend_api_python/README.md b/backend_api_python/README.md index 7fafeef87..2a755f153 100644 --- a/backend_api_python/README.md +++ b/backend_api_python/README.md @@ -177,12 +177,36 @@ POST /api/users/change-password - Change own password ```text GET /api/health GET /api/indicator/kline +GET /api/global-market/adanos-sentiment?tickers=AAPL,TSLA POST /api/fast-analysis/analyze - Fast AI analysis (main entry) GET /api/fast-analysis/history - Analysis history GET /api/fast-analysis/similar-patterns - RAG similar patterns POST /api/fast-analysis/feedback - User feedback on analysis ``` +### Optional Adanos Market Sentiment + +Set `ADANOS_API_KEY` to enable optional US stock sentiment enrichment from the +Adanos Market Sentiment API. If the key is not configured, the endpoint returns +`enabled=false` and the rest of QuantDinger continues to work normally. + +```bash +ADANOS_API_KEY=your_adanos_key +ADANOS_SENTIMENT_SOURCE=reddit # reddit, x, news, or polymarket +``` + +Example: + +```text +GET /api/global-market/adanos-sentiment?tickers=AAPL,TSLA&source=reddit&days=7 +``` + +The response normalizes common compare fields across sources, including +`sentiment_score`, `buzz_score`, `bullish_pct`, `bearish_pct`, `mentions`, +`trend`, `trend_history`, and source-specific activity metrics such as +`subreddit_count`, `unique_tweets`, `source_count`, `trade_count`, +`market_count`, and `total_liquidity`. + ## AI analysis & memory Uses **FastAnalysisService** (single LLM call, multi-factor): diff --git a/backend_api_python/app/config/api_keys.py b/backend_api_python/app/config/api_keys.py index 151f659e1..7ee779a25 100644 --- a/backend_api_python/app/config/api_keys.py +++ b/backend_api_python/app/config/api_keys.py @@ -39,6 +39,16 @@ def TWELVE_DATA_API_KEY(cls): from app.utils.config_loader import load_addon_config val = load_addon_config().get('twelve_data', {}).get('api_key') return val if val else '' + + @property + def ADANOS_API_KEY(cls): + """Adanos Market Sentiment API key (optional).""" + env_val = os.getenv('ADANOS_API_KEY', '').strip() + if env_val: + return env_val + from app.utils.config_loader import load_addon_config + val = load_addon_config().get('adanos', {}).get('api_key') + return val if val else '' @property def OPENROUTER_API_KEY(cls): diff --git a/backend_api_python/app/data_providers/adanos_sentiment.py b/backend_api_python/app/data_providers/adanos_sentiment.py new file mode 100644 index 000000000..62b7a0fe7 --- /dev/null +++ b/backend_api_python/app/data_providers/adanos_sentiment.py @@ -0,0 +1,204 @@ +"""Optional Adanos Market Sentiment provider for US stock tickers.""" +from __future__ import annotations + +import math +import os +import re +from typing import Any, Dict, Iterable, List, Optional + +import requests + +from app.config import APIKeys +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +DEFAULT_BASE_URL = "https://api.adanos.org" +DEFAULT_SOURCE = "reddit" +DEFAULT_DAYS = 7 +DEFAULT_TIMEOUT = 10 +MAX_TICKERS = 20 + +SUPPORTED_SOURCES = { + "reddit": "reddit/stocks/v1", + "x": "x/stocks/v1", + "news": "news/stocks/v1", + "polymarket": "polymarket/stocks/v1", +} + +_TICKER_RE = re.compile(r"^\$?[A-Z][A-Z0-9.]{0,9}$") + + +def parse_tickers(value: str | Iterable[str] | None) -> List[str]: + """Normalize comma-separated or iterable stock ticker input.""" + if value is None: + return [] + + raw_values = value.split(",") if isinstance(value, str) else list(value) + tickers: List[str] = [] + seen = set() + + for raw in raw_values: + ticker = str(raw or "").strip().replace("$", "").upper() + if not ticker or not _TICKER_RE.match(ticker): + continue + if ticker not in seen: + tickers.append(ticker) + seen.add(ticker) + if len(tickers) >= MAX_TICKERS: + break + + return tickers + + +def normalize_source(source: Optional[str]) -> str: + """Return a supported Adanos source name.""" + normalized = (source or os.getenv("ADANOS_SENTIMENT_SOURCE") or DEFAULT_SOURCE).strip().lower() + if normalized == "twitter": + normalized = "x" + if normalized not in SUPPORTED_SOURCES: + raise ValueError(f"Unsupported Adanos sentiment source: {source}") + return normalized + + +def _to_float(value: Any) -> Optional[float]: + if value in (None, ""): + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _to_int(value: Any) -> Optional[int]: + number = _to_float(value) + return int(number) if number is not None else None + + +def _pick(record: Dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in record: + return record.get(key) + return None + + +def _rows(payload: Any) -> List[Dict[str, Any]]: + if isinstance(payload, list): + rows = payload + elif isinstance(payload, dict): + rows = payload.get("stocks") or payload.get("data") or payload.get("results") or [] + else: + rows = [] + return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + + +def _normalize_record(record: Dict[str, Any], source: str) -> Optional[Dict[str, Any]]: + ticker = str(record.get("ticker") or record.get("symbol") or "").strip().replace("$", "").upper() + if not ticker: + return None + + return { + "ticker": ticker, + "company_name": _pick(record, "company_name", "name", "company"), + "source": source, + "sentiment_score": _to_float(_pick(record, "sentiment_score", "sentiment", "score")), + "buzz_score": _to_float(_pick(record, "buzz_score", "buzz")), + "bullish_pct": _to_float(record.get("bullish_pct")), + "bearish_pct": _to_float(record.get("bearish_pct")), + "mentions": _to_int(_pick(record, "mentions", "mention_count")), + "source_count": _to_int(record.get("source_count")), + "subreddit_count": _to_int(record.get("subreddit_count")), + "unique_posts": _to_int(record.get("unique_posts")), + "unique_tweets": _to_int(record.get("unique_tweets")), + "trade_count": _to_int(record.get("trade_count")), + "market_count": _to_int(record.get("market_count")), + "unique_traders": _to_int(record.get("unique_traders")), + "total_upvotes": _to_int(_pick(record, "total_upvotes", "upvotes")), + "total_liquidity": _to_float(record.get("total_liquidity")), + "trend": record.get("trend"), + "trend_history": record.get("trend_history") if isinstance(record.get("trend_history"), list) else [], + } + + +def _api_base_url() -> str: + return (os.getenv("ADANOS_API_BASE_URL") or DEFAULT_BASE_URL).strip().rstrip("/") + + +def _timeout() -> int: + try: + return max(1, int(os.getenv("ADANOS_API_TIMEOUT", str(DEFAULT_TIMEOUT)))) + except ValueError: + return DEFAULT_TIMEOUT + + +def fetch_adanos_market_sentiment( + tickers: str | Iterable[str] | None, + *, + source: Optional[str] = None, + days: int = DEFAULT_DAYS, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + timeout: Optional[int] = None, + session: Any = requests, +) -> Dict[str, Any]: + """Fetch optional Adanos Market Sentiment snapshots. + + This provider is intentionally fail-open: if no API key is configured, it + returns ``enabled=false`` instead of raising. That keeps QuantDinger fully + usable without Adanos. + """ + parsed_tickers = parse_tickers(tickers) + selected_source = normalize_source(source) + selected_days = max(1, min(int(days or DEFAULT_DAYS), 365)) + + result = { + "enabled": False, + "provider": "adanos", + "source": selected_source, + "days": selected_days, + "tickers": parsed_tickers, + "stocks": [], + "error": None, + } + + if not parsed_tickers: + result["error"] = "No valid stock tickers provided" + return result + + selected_key = str(api_key if api_key is not None else APIKeys.ADANOS_API_KEY).strip() + if not selected_key: + result["error"] = "ADANOS_API_KEY is not configured" + return result + + result["enabled"] = True + endpoint = f"{(base_url or _api_base_url()).rstrip('/')}/{SUPPORTED_SOURCES[selected_source]}/compare" + + try: + response = session.get( + endpoint, + params={"tickers": ",".join(parsed_tickers), "days": selected_days}, + headers={"Accept": "application/json", "X-API-Key": selected_key}, + timeout=timeout or _timeout(), + ) + except requests.RequestException as exc: + logger.warning("Adanos sentiment request failed: %s", exc) + result["error"] = str(exc) + return result + + if response.status_code != 200: + result["error"] = f"Adanos API returned HTTP {response.status_code}" + return result + + try: + payload = response.json() + except ValueError: + result["error"] = "Adanos API returned invalid JSON" + return result + + result["stocks"] = [ + normalized + for normalized in (_normalize_record(record, selected_source) for record in _rows(payload)) + if normalized is not None + ] + return result diff --git a/backend_api_python/app/routes/global_market.py b/backend_api_python/app/routes/global_market.py index e2cfc18bd..bdc514a6e 100644 --- a/backend_api_python/app/routes/global_market.py +++ b/backend_api_python/app/routes/global_market.py @@ -16,6 +16,7 @@ - GET /api/global-market/news - Financial news (with lang param) - GET /api/global-market/calendar - Economic calendar - GET /api/global-market/sentiment - Fear & Greed / VIX +- GET /api/global-market/adanos-sentiment - Optional Adanos stock sentiment - GET /api/global-market/opportunities - Trading opportunities scanner """ @@ -39,6 +40,7 @@ fetch_fear_greed_index, fetch_vix, fetch_dollar_index, fetch_yield_curve, fetch_vxn, fetch_gvz, fetch_put_call_ratio, ) +from app.data_providers.adanos_sentiment import fetch_adanos_market_sentiment from app.data_providers.news import fetch_financial_news, get_economic_calendar from app.data_providers.heatmap import generate_heatmap_data from app.data_providers.opportunities import ( @@ -229,6 +231,33 @@ def market_sentiment(): return jsonify({"code": 0, "msg": str(e), "data": None}), 500 +@global_market_bp.route("/adanos-sentiment", methods=["GET"]) +@login_required +def adanos_market_sentiment(): + """Get optional Adanos Market Sentiment for selected US stock tickers.""" + try: + tickers = request.args.get("tickers", "") + source = request.args.get("source") + days = int(request.args.get("days") or 7) + cache_key = f"adanos_sentiment:{source or 'default'}:{days}:{tickers.upper()}" + + cached = get_cached(cache_key, 300) + if cached: + return jsonify({"code": 1, "msg": "success", "data": cached}) + + data = fetch_adanos_market_sentiment(tickers, source=source, days=days) + if data.get("enabled") and not data.get("error"): + set_cached(cache_key, data, 300) + + return jsonify({"code": 1, "msg": "success", "data": data}) + + except ValueError as e: + return jsonify({"code": 0, "msg": str(e), "data": None}), 400 + except Exception as e: + logger.error("adanos_market_sentiment failed: %s", e, exc_info=True) + return jsonify({"code": 0, "msg": str(e), "data": None}), 500 + + @global_market_bp.route("/opportunities", methods=["GET"]) @login_required def trading_opportunities(): diff --git a/backend_api_python/env.example b/backend_api_python/env.example index c49524fa8..2b354774a 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -209,6 +209,13 @@ TIINGO_TIMEOUT=10 # Free tier: 800 API credits/day, 8 requests/minute. https://twelvedata.com TWELVE_DATA_API_KEY= +# Adanos Market Sentiment (optional US stock sentiment enrichment) +# If ADANOS_API_KEY is empty, /api/global-market/adanos-sentiment returns enabled=false. +ADANOS_API_KEY= +ADANOS_SENTIMENT_SOURCE=reddit +ADANOS_API_BASE_URL=https://api.adanos.org +ADANOS_API_TIMEOUT=10 + # AI search / news SEARCH_PROVIDER=google SEARCH_MAX_RESULTS=10 diff --git a/backend_api_python/tests/test_data_providers.py b/backend_api_python/tests/test_data_providers.py index bbc320922..49bf83cc4 100644 --- a/backend_api_python/tests/test_data_providers.py +++ b/backend_api_python/tests/test_data_providers.py @@ -29,3 +29,164 @@ def test_economic_calendar_not_empty(): assert len(events) > 0 assert "name" in events[0] assert "date" in events[0] + + +def test_adanos_sentiment_disabled_without_key(): + from app.data_providers.adanos_sentiment import fetch_adanos_market_sentiment + + result = fetch_adanos_market_sentiment("AAPL, TSLA", api_key="") + + assert result["enabled"] is False + assert result["tickers"] == ["AAPL", "TSLA"] + assert result["stocks"] == [] + assert "ADANOS_API_KEY" in result["error"] + + +def test_adanos_sentiment_fetches_and_normalizes(monkeypatch): + from app.data_providers.adanos_sentiment import fetch_adanos_market_sentiment + + calls = [] + + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return { + "stocks": [ + { + "ticker": "AAPL", + "company_name": "Apple Inc.", + "sentiment_score": "0.21", + "buzz_score": "72.5", + "bullish_pct": "58", + "mentions": "128", + "unique_posts": "32", + "total_upvotes": "1204", + "trend": "rising", + "trend_history": [45.0, 51.5, 72.5], + } + ] + } + + class FakeSession: + @staticmethod + def get(url, **kwargs): + calls.append((url, kwargs)) + return FakeResponse() + + monkeypatch.setenv("ADANOS_API_KEY", "adanos_test_key") + result = fetch_adanos_market_sentiment( + ["$aapl", "AAPL", "bad ticker"], + source="reddit", + days=14, + base_url="https://api.example.test", + session=FakeSession, + ) + + assert result["enabled"] is True + assert result["tickers"] == ["AAPL"] + assert result["stocks"][0] == { + "ticker": "AAPL", + "company_name": "Apple Inc.", + "source": "reddit", + "sentiment_score": 0.21, + "buzz_score": 72.5, + "bullish_pct": 58.0, + "bearish_pct": None, + "mentions": 128, + "source_count": None, + "subreddit_count": None, + "unique_posts": 32, + "unique_tweets": None, + "trade_count": None, + "market_count": None, + "unique_traders": None, + "total_upvotes": 1204, + "total_liquidity": None, + "trend": "rising", + "trend_history": [45.0, 51.5, 72.5], + } + assert calls[0][0] == "https://api.example.test/reddit/stocks/v1/compare" + assert calls[0][1]["params"] == {"tickers": "AAPL", "days": 14} + assert calls[0][1]["headers"]["X-API-Key"] == "adanos_test_key" + + +def test_adanos_sentiment_fail_open_on_http_error(monkeypatch): + from app.data_providers.adanos_sentiment import fetch_adanos_market_sentiment + + class FakeResponse: + status_code = 429 + + class FakeSession: + @staticmethod + def get(url, **kwargs): + return FakeResponse() + + monkeypatch.setenv("ADANOS_API_KEY", "adanos_test_key") + result = fetch_adanos_market_sentiment("NVDA", session=FakeSession) + + assert result["enabled"] is True + assert result["stocks"] == [] + assert result["error"] == "Adanos API returned HTTP 429" + + +def test_adanos_sentiment_handles_list_payload_and_non_finite_numbers(): + from app.data_providers.adanos_sentiment import fetch_adanos_market_sentiment + + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return [ + { + "symbol": "$MSFT", + "name": "Microsoft Corp.", + "sentiment": "NaN", + "buzz": "inf", + "mentions": "bad", + "market_count": "3", + "total_liquidity": "45200.5", + "trend_history": [12.0, 18.5], + }, + "not-a-row", + {}, + ] + + class FakeSession: + @staticmethod + def get(url, **kwargs): + return FakeResponse() + + result = fetch_adanos_market_sentiment( + None, + api_key="adanos_test_key", + session=FakeSession, + ) + assert result["stocks"] == [] + assert result["error"] == "No valid stock tickers provided" + + result = fetch_adanos_market_sentiment( + "MSFT", + api_key="adanos_test_key", + session=FakeSession, + ) + + assert result["stocks"][0]["ticker"] == "MSFT" + assert result["stocks"][0]["company_name"] == "Microsoft Corp." + assert result["stocks"][0]["sentiment_score"] is None + assert result["stocks"][0]["buzz_score"] is None + assert result["stocks"][0]["mentions"] is None + assert result["stocks"][0]["market_count"] == 3 + assert result["stocks"][0]["total_liquidity"] == 45200.5 + assert result["stocks"][0]["trend_history"] == [12.0, 18.5] + + +def test_adanos_sentiment_validates_source(): + import pytest + from app.data_providers.adanos_sentiment import normalize_source + + assert normalize_source("twitter") == "x" + with pytest.raises(ValueError): + normalize_source("unsupported") diff --git a/backend_api_python/tests/test_global_market_adanos.py b/backend_api_python/tests/test_global_market_adanos.py new file mode 100644 index 000000000..06cc4b631 --- /dev/null +++ b/backend_api_python/tests/test_global_market_adanos.py @@ -0,0 +1,65 @@ +"""Tests for the optional Adanos global-market endpoint.""" + + +def _auth_headers(monkeypatch): + from app.utils import auth + + monkeypatch.setattr( + auth, + "verify_token", + lambda token: { + "sub": "tester", + "user_id": 1, + "role": "user", + "token_version": 1, + }, + ) + return {"Authorization": "Bearer test-token"} + + +def test_adanos_sentiment_endpoint_requires_login(client): + resp = client.get("/api/global-market/adanos-sentiment?tickers=AAPL") + + assert resp.status_code == 401 + assert resp.get_json()["msg"] == "Token missing" + + +def test_adanos_sentiment_endpoint_returns_provider_result(client, monkeypatch): + from app.routes import global_market + + calls = [] + + def fake_fetch(tickers, *, source=None, days=7): + calls.append({"tickers": tickers, "source": source, "days": days}) + return { + "enabled": False, + "provider": "adanos", + "source": source, + "days": days, + "tickers": ["AAPL", "TSLA"], + "stocks": [], + "error": "ADANOS_API_KEY is not configured", + } + + monkeypatch.setattr(global_market, "fetch_adanos_market_sentiment", fake_fetch) + + resp = client.get( + "/api/global-market/adanos-sentiment?tickers=aapl,$tsla&source=x&days=14", + headers=_auth_headers(monkeypatch), + ) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["code"] == 1 + assert body["data"]["enabled"] is False + assert calls == [{"tickers": "aapl,$tsla", "source": "x", "days": 14}] + + +def test_adanos_sentiment_endpoint_rejects_invalid_days(client, monkeypatch): + resp = client.get( + "/api/global-market/adanos-sentiment?tickers=AAPL&days=bad", + headers=_auth_headers(monkeypatch), + ) + + assert resp.status_code == 400 + assert resp.get_json()["code"] == 0