Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
24 changes: 24 additions & 0 deletions backend_api_python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions backend_api_python/app/config/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
204 changes: 204 additions & 0 deletions backend_api_python/app/data_providers/adanos_sentiment.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions backend_api_python/app/routes/global_market.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand All @@ -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 (
Expand Down Expand Up @@ -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():
Expand Down
7 changes: 7 additions & 0 deletions backend_api_python/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading