From 9e3fb9f3aa0c59d61242f561083d3edafad462e4 Mon Sep 17 00:00:00 2001 From: Alex Schneider Date: Sat, 27 Jun 2026 07:32:39 +0200 Subject: [PATCH] feat: add optional Adanos sentiment features --- docs/api-integration.md | 49 ++++++++ tests/test_adanos_sentiment.py | 106 +++++++++++++++++ utilities/adanos_sentiment.py | 204 +++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+) create mode 100644 tests/test_adanos_sentiment.py create mode 100644 utilities/adanos_sentiment.py diff --git a/docs/api-integration.md b/docs/api-integration.md index 3af52a2..b7eafa4 100644 --- a/docs/api-integration.md +++ b/docs/api-integration.md @@ -287,6 +287,55 @@ def store_price_data_sqlite(ticker, data, db_path): conn.close() ``` +## Adanos Market Sentiment Integration (Optional) + +### Overview + +Adanos provides optional market sentiment and attention features for equities +from Reddit, X / FinTwit, financial news, and Polymarket. AmpyFin does not +require Adanos for core training, backtesting, or execution. Use it only when +you want to add external sentiment features to a ticker universe before running +your own strategy or model pipeline. + +### Setup and Configuration + +```bash +# .env file or shell environment +ADANOS_API_KEY=your_adanos_api_key +``` + +### Feature Retrieval + +```python +from utilities.adanos_sentiment import fetch_adanos_features + +sentiment_features = fetch_adanos_features( + ["AAPL", "MSFT", "NVDA"], + source="reddit", + start_date="2026-06-01", + end_date="2026-06-27", +) + +print(sentiment_features.columns) +# Index([ +# "Ticker", "adanos_source", "adanos_sentiment_score", +# "adanos_buzz_score", "adanos_bullish_pct", "adanos_bearish_pct", +# "adanos_mentions", "adanos_trend" +# ]) +``` + +### Merging with Existing AmpyFin Data + +```python +from utilities.common_utils import fetch_price_from_db + +price_data = fetch_price_from_db(start_date, end_date, ["AAPL", "MSFT", "NVDA"]) +price_with_sentiment = price_data.merge(sentiment_features, on="Ticker", how="left") +``` + +Keep sentiment features separate from order execution and broker credentials. +They are external research inputs, not trade instructions. + ## Weights & Biases Integration ### Overview diff --git a/tests/test_adanos_sentiment.py b/tests/test_adanos_sentiment.py new file mode 100644 index 0000000..9fbbe17 --- /dev/null +++ b/tests/test_adanos_sentiment.py @@ -0,0 +1,106 @@ +import json + +import pandas as pd +import pytest + +from utilities import adanos_sentiment as adanos + + +class DummyResponse: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + +def test_normalize_adanos_stock_payload(): + payload = { + "ticker": "AAPL", + "sentiment_score": "0.42", + "buzz_score": 73.5, + "bullish_pct": 61.2, + "bearish_pct": 18.4, + "mentions": "128", + "trend": "rising", + } + + result = adanos.normalize_adanos_stock_payload("AAPL", payload, source="reddit") + + assert result == { + "Ticker": "AAPL", + "adanos_source": "reddit", + "adanos_sentiment_score": 0.42, + "adanos_buzz_score": 73.5, + "adanos_bullish_pct": 61.2, + "adanos_bearish_pct": 18.4, + "adanos_mentions": 128, + "adanos_trend": "rising", + } + + +def test_adanos_features_from_payloads_preserves_columns(): + payloads = { + "AAPL": {"ticker": "AAPL", "sentiment_score": 0.2, "mentions": 10}, + "MSFT": {"ticker": "MSFT", "buzz_score": 66, "trend": "stable"}, + } + + result = adanos.adanos_features_from_payloads(payloads) + + assert list(result.columns) == adanos.FEATURE_COLUMNS + assert result["Ticker"].tolist() == ["AAPL", "MSFT"] + assert pd.isna(result.loc[1, "adanos_sentiment_score"]) + + +def test_error_payload_is_rejected(): + with pytest.raises(adanos.AdanosSentimentError, match="error payload"): + adanos.normalize_adanos_stock_payload("AAPL", {"detail": "Invalid API key"}) + + +def test_fetch_adanos_features_uses_api_key_and_date_params(monkeypatch): + requests = [] + + def fake_urlopen(request, timeout): + requests.append((request, timeout)) + return DummyResponse( + { + "ticker": "AAPL", + "sentiment_score": 0.5, + "buzz_score": 70, + "mentions": 20, + } + ) + + monkeypatch.setattr(adanos, "urlopen", fake_urlopen) + + result = adanos.fetch_adanos_features( + ["AAPL"], + api_key="sk_test", + source="news", + start_date="2026-06-01", + end_date="2026-06-27", + base_url="https://example.test", + timeout=3, + ) + + request, timeout = requests[0] + assert ( + request.full_url + == "https://example.test/news/stocks/v1/stock/AAPL?from=2026-06-01&to=2026-06-27" + ) + assert request.headers["X-api-key"] == "sk_test" + assert timeout == 3 + assert result.loc[0, "adanos_buzz_score"] == 70 + + +def test_fetch_adanos_features_requires_api_key(monkeypatch): + monkeypatch.delenv(adanos.ADANOS_API_KEY_ENV, raising=False) + + with pytest.raises(ValueError, match=adanos.ADANOS_API_KEY_ENV): + adanos.fetch_adanos_features(["AAPL"]) diff --git a/utilities/adanos_sentiment.py b/utilities/adanos_sentiment.py new file mode 100644 index 0000000..c3e4188 --- /dev/null +++ b/utilities/adanos_sentiment.py @@ -0,0 +1,204 @@ +"""Optional Adanos market sentiment feature helpers. + +AmpyFin's core price and strategy pipelines remain independent from Adanos. +Use these helpers when you explicitly want to enrich a ticker universe with +external sentiment, buzz, and attention features. +""" + +from __future__ import annotations + +import json +import os +from datetime import date, datetime +from math import isfinite +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + +import pandas as pd + +ADANOS_BASE_URL = "https://api.adanos.org" +ADANOS_API_KEY_ENV = "ADANOS_API_KEY" + +SOURCE_ENDPOINTS = { + "reddit": "/reddit/stocks/v1/stock/{ticker}", + "x": "/x/stocks/v1/stock/{ticker}", + "news": "/news/stocks/v1/stock/{ticker}", + "polymarket": "/polymarket/stocks/v1/stock/{ticker}", +} + +FEATURE_COLUMNS = [ + "Ticker", + "adanos_source", + "adanos_sentiment_score", + "adanos_buzz_score", + "adanos_bullish_pct", + "adanos_bearish_pct", + "adanos_mentions", + "adanos_trend", +] + + +class AdanosSentimentError(RuntimeError): + """Raised when Adanos sentiment data cannot be fetched or normalized.""" + + +def _to_float(value: Any) -> float | None: + if isinstance(value, bool) or value is None: + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if isfinite(parsed) else None + + +def _to_int(value: Any) -> int | None: + if isinstance(value, bool) or value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _clean_text(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _format_date(value: date | datetime | str | None) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + return str(value) + + +def _build_stock_url( + ticker: str, + source: str, + base_url: str, + start_date: date | datetime | str | None, + end_date: date | datetime | str | None, +) -> str: + if source not in SOURCE_ENDPOINTS: + valid_sources = ", ".join(sorted(SOURCE_ENDPOINTS)) + raise ValueError( + f"Unsupported Adanos source '{source}'. Expected one of: {valid_sources}" + ) + + ticker_path = quote(ticker.upper(), safe="") + path = SOURCE_ENDPOINTS[source].format(ticker=ticker_path) + params = { + key: value + for key, value in { + "from": _format_date(start_date), + "to": _format_date(end_date), + }.items() + if value + } + query = f"?{urlencode(params)}" if params else "" + return f"{base_url.rstrip('/')}{path}{query}" + + +def normalize_adanos_stock_payload( + ticker: str, payload: dict[str, Any], source: str = "reddit" +) -> dict[str, Any]: + """Convert one Adanos stock sentiment response into AmpyFin feature columns.""" + + if any(payload.get(key) for key in ("error", "errors", "detail", "message")): + raise AdanosSentimentError(f"Adanos returned an error payload for {ticker}") + + symbol = _clean_text(payload.get("ticker") or payload.get("symbol") or ticker) + if symbol is None: + raise AdanosSentimentError("Adanos payload must include a ticker or symbol") + + mentions = _to_int(payload.get("mentions")) + if mentions is None: + mentions = _to_int(payload.get("trade_count")) + + return { + "Ticker": symbol.upper(), + "adanos_source": source, + "adanos_sentiment_score": _to_float(payload.get("sentiment_score")), + "adanos_buzz_score": _to_float(payload.get("buzz_score")), + "adanos_bullish_pct": _to_float(payload.get("bullish_pct")), + "adanos_bearish_pct": _to_float(payload.get("bearish_pct")), + "adanos_mentions": mentions, + "adanos_trend": _clean_text(payload.get("trend")), + } + + +def adanos_features_from_payloads( + payloads: dict[str, dict[str, Any]], + source: str = "reddit", +) -> pd.DataFrame: + """Build an AmpyFin-ready feature DataFrame from pre-fetched Adanos payloads.""" + + rows = [ + normalize_adanos_stock_payload(ticker, payload, source=source) + for ticker, payload in payloads.items() + ] + return pd.DataFrame(rows, columns=FEATURE_COLUMNS) + + +def fetch_adanos_features( + tickers: list[str], + api_key: str | None = None, + source: str = "reddit", + start_date: date | datetime | str | None = None, + end_date: date | datetime | str | None = None, + base_url: str = ADANOS_BASE_URL, + timeout: int = 10, +) -> pd.DataFrame: + """Fetch Adanos stock sentiment features for a ticker list. + + Args: + tickers: Stock ticker symbols to enrich. + api_key: Optional Adanos API key. Falls back to the ``ADANOS_API_KEY`` + environment variable. + source: One of ``reddit``, ``x``, ``news``, or ``polymarket``. + start_date: Optional API ``from`` date. + end_date: Optional API ``to`` date. + base_url: Adanos API base URL, injectable for tests. + timeout: Request timeout in seconds. + + Returns: + DataFrame keyed by ``Ticker`` with ``adanos_*`` feature columns. + """ + + resolved_api_key = api_key or os.getenv(ADANOS_API_KEY_ENV) + if not resolved_api_key: + raise ValueError( + f"Set {ADANOS_API_KEY_ENV} or pass api_key to fetch Adanos sentiment features" + ) + + payloads: dict[str, dict[str, Any]] = {} + for ticker in tickers: + url = _build_stock_url(ticker, source, base_url, start_date, end_date) + request = Request( + url, headers={"X-API-Key": resolved_api_key, "Accept": "application/json"} + ) + try: + with urlopen(request, timeout=timeout) as response: + payloads[ticker] = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raise AdanosSentimentError( + f"Adanos request failed for {ticker}: HTTP {exc.code}" + ) from exc + except URLError as exc: + raise AdanosSentimentError( + f"Adanos request failed for {ticker}: {exc.reason}" + ) from exc + except json.JSONDecodeError as exc: + raise AdanosSentimentError( + f"Adanos returned invalid JSON for {ticker}" + ) from exc + + return adanos_features_from_payloads(payloads, source=source)