From 7330774e3a57bc459b78f7421a9e764bd1fdaef0 Mon Sep 17 00:00:00 2001 From: Alex Schneider Date: Mon, 16 Mar 2026 16:51:28 +0100 Subject: [PATCH 1/2] feat: add Adanos social sentiment intelligence source Add stock-level social sentiment data from Adanos API (api.adanos.org), aggregating Reddit and X/Twitter sentiment analysis with buzz scores, trend detection, and sector breakdowns. New source (apis/sources/adanos.mjs): - Trending stocks with buzz scores, sentiment, and trend direction - Sector-level sentiment aggregation - Cross-platform data (Reddit + X/Twitter) - Aggregate market sentiment signals (bullish/bearish consensus) - Graceful degradation when API key not configured Dashboard integration: - V2.sentiment section in synthesizer with trending, sectors, and signals - Three new trade ideas: retail euphoria divergence, social fear confirmation, and social momentum clustering - Delta engine tracks social_sentiment (numeric) and sentiment_trending (count) metrics between sweeps Set ADANOS_API_KEY in .env to enable. Free key at api.adanos.org/docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 3 + apis/briefing.mjs | 8 ++- apis/sources/adanos.mjs | 124 ++++++++++++++++++++++++++++++++++++++++ dashboard/inject.mjs | 48 ++++++++++++++++ lib/delta/engine.mjs | 4 ++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 apis/sources/adanos.mjs diff --git a/.env.example b/.env.example index 00b6683..5ac331f 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ ACLED_EMAIL= # OAuth2 password grant (API keys deprecated Sept 2025) ACLED_PASSWORD= +# Stock social sentiment from Reddit, X/Twitter (free: api.adanos.org/docs) +ADANOS_API_KEY= + # === Server Configuration === # Dashboard server port diff --git a/apis/briefing.mjs b/apis/briefing.mjs index 4b916ba..d7468c7 100644 --- a/apis/briefing.mjs +++ b/apis/briefing.mjs @@ -43,6 +43,9 @@ import { briefing as space } from './sources/space.mjs'; // === Tier 5: Live Market Data === import { briefing as yfinance } from './sources/yfinance.mjs'; +// === Tier 6: Social Sentiment === +import { briefing as adanos } from './sources/adanos.mjs'; + export async function runSource(name, fn, ...args) { const start = Date.now(); try { @@ -54,7 +57,7 @@ export async function runSource(name, fn, ...args) { } export async function fullBriefing() { - console.error('[Crucix] Starting intelligence sweep — 27 sources...'); + console.error('[Crucix] Starting intelligence sweep — 28 sources...'); const start = Date.now(); const results = await Promise.allSettled([ @@ -94,6 +97,9 @@ export async function fullBriefing() { // Tier 5: Live Market Data runSource('YFinance', yfinance), + + // Tier 6: Social Sentiment + runSource('Adanos', adanos), ]); const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message }); diff --git a/apis/sources/adanos.mjs b/apis/sources/adanos.mjs new file mode 100644 index 0000000..8a7a158 --- /dev/null +++ b/apis/sources/adanos.mjs @@ -0,0 +1,124 @@ +// Adanos — Social Sentiment Intelligence for Stocks +// Multi-platform sentiment analysis: Reddit, X/Twitter, Polymarket +// Provides trending stocks, buzz scores, sentiment breakdowns, and sector analysis +// Free API key required: https://api.adanos.org/docs +// Updates every ~15 minutes (Reddit), ~2 hours (X/Twitter) + +import { safeFetch } from '../utils/fetch.mjs'; +import '../utils/env.mjs'; + +const BASE = 'https://api.adanos.org'; + +async function apiFetch(path, apiKey) { + return safeFetch(`${BASE}${path}`, { + timeout: 12000, + headers: { 'X-API-Key': apiKey }, + }); +} + +// Fetch trending stocks from a platform +async function fetchTrending(platform, apiKey, limit = 20) { + const data = await apiFetch(`/${platform}/stocks/v1/trending?days=7&limit=${limit}`, apiKey); + if (data?.error) return []; + return (Array.isArray(data) ? data : []).map(s => ({ + ticker: s.ticker, + name: s.company_name || null, + buzz: s.buzz_score, + trend: s.trend, + mentions: s.mentions, + sentiment: s.sentiment_score, + bullishPct: s.bullish_pct, + bearishPct: s.bearish_pct, + upvotes: s.total_upvotes || 0, + })); +} + +// Fetch sector-level aggregation +async function fetchSectors(apiKey) { + const data = await apiFetch('/reddit/stocks/v1/trending/sectors?days=7', apiKey); + if (data?.error) return []; + return (Array.isArray(data) ? data : []).map(s => ({ + sector: s.sector, + buzz: s.buzz_score, + trend: s.trend, + mentions: s.mentions, + sentiment: s.sentiment_score, + bullishPct: s.bullish_pct, + bearishPct: s.bearish_pct, + topTickers: s.top_tickers || [], + })); +} + +export async function briefing() { + const apiKey = process.env.ADANOS_API_KEY; + if (!apiKey) { + return { + source: 'Adanos', + timestamp: new Date().toISOString(), + status: 'no_key', + message: 'Adanos API key required. Register free at https://api.adanos.org/docs and set ADANOS_API_KEY in .env', + }; + } + + const [reddit, x, sectors] = await Promise.all([ + fetchTrending('reddit', apiKey), + fetchTrending('x', apiKey), + fetchSectors(apiKey), + ]); + + // Compute aggregate market sentiment from Reddit (larger sample) + const allStocks = reddit.length ? reddit : x; + const totalMentions = allStocks.reduce((s, t) => s + t.mentions, 0); + let avgSentiment = 0; + let avgBullish = 0; + let avgBearish = 0; + if (totalMentions > 0) { + for (const t of allStocks) { + const w = t.mentions / totalMentions; + avgSentiment += t.sentiment * w; + avgBullish += t.bullishPct * w; + avgBearish += t.bearishPct * w; + } + } + + const sorted = [...allStocks].sort((a, b) => b.sentiment - a.sentiment); + const topBullish = sorted.slice(0, 5).filter(t => t.sentiment > 0); + const topBearish = sorted.slice(-5).reverse().filter(t => t.sentiment < 0); + + // Generate signals + const signals = []; + if (avgBullish > 65) signals.push(`Extreme bullish consensus at ${avgBullish.toFixed(0)}% — contrarian caution`); + if (avgBearish > 45) signals.push(`Elevated bearish sentiment at ${avgBearish.toFixed(0)}% — fear may be peaking`); + + const risingCount = allStocks.filter(t => t.trend === 'rising').length; + const fallingCount = allStocks.filter(t => t.trend === 'falling').length; + if (risingCount > allStocks.length * 0.6) signals.push(`Broad momentum: ${risingCount}/${allStocks.length} tickers rising`); + if (fallingCount > allStocks.length * 0.5) signals.push(`Broad weakness: ${fallingCount}/${allStocks.length} tickers falling`); + + const hotSectors = [...sectors].sort((a, b) => b.buzz - a.buzz).slice(0, 3); + for (const s of hotSectors) { + if (s.buzz > 60) signals.push(`${s.sector} sector buzz elevated at ${s.buzz.toFixed(0)} — ${s.trend}`); + } + + return { + source: 'Adanos', + timestamp: new Date().toISOString(), + reddit: { trending: reddit, sectors }, + x: { trending: x }, + aggregate: { + totalTickers: allStocks.length, + avgSentiment: parseFloat(avgSentiment.toFixed(3)), + bullishPct: Math.round(avgBullish), + bearishPct: Math.round(avgBearish), + topBullish, + topBearish, + hotSectors, + }, + signals, + }; +} + +if (process.argv[1]?.endsWith('adanos.mjs')) { + const data = await briefing(); + console.log(JSON.stringify(data, null, 2)); +} diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index bdad625..b21340d 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -303,6 +303,33 @@ export function generateIdeas(V2) { } } + // Adanos Social Sentiment Signals + const sentAgg = V2.sentiment?.aggregate || {}; + if (sentAgg.bullishPct > 65 && vix && vix.value < 18) { + ideas.push({ + title: 'Retail Euphoria vs Low Vol', + text: `Social sentiment ${sentAgg.bullishPct}% bullish but VIX at ${vix.value.toFixed(0)} — retail exuberance with low hedging. Contrarian caution warranted.`, + type: 'watch', confidence: 'Medium', horizon: 'tactical' + }); + } + if (sentAgg.bearishPct > 40 && vix && vix.value > 22) { + ideas.push({ + title: 'Social Fear Confirming Vol Spike', + text: `Social sentiment ${sentAgg.bearishPct}% bearish + VIX ${vix.value.toFixed(0)}. Retail and institutional fear aligned — capitulation may be near.`, + type: 'watch', confidence: 'Medium', horizon: 'swing' + }); + } + const sentTrending = V2.sentiment?.trending || []; + const risingTickers = sentTrending.filter(t => t.trend === 'rising' && t.buzz > 50); + if (risingTickers.length >= 3) { + const names = risingTickers.slice(0, 3).map(t => t.ticker).join(', '); + ideas.push({ + title: 'Social Momentum Cluster', + text: `${risingTickers.length} tickers with rising buzz >50: ${names}. Social momentum often precedes volume spikes.`, + type: 'long', confidence: 'Medium', horizon: 'swing' + }); + } + return ideas.slice(0, 8); } @@ -410,6 +437,26 @@ export async function synthesize(data) { n: name, err: Boolean(src.error), stale: Boolean(src.stale) })); + // === Adanos Social Sentiment === + const adanosData = data.sources.Adanos || {}; + const sentiment = adanosData.error ? { trending: [], sectors: [], signals: [] } : { + trending: (adanosData.reddit?.trending || []).slice(0, 15).map(t => ({ + ticker: t.ticker, name: t.name, buzz: t.buzz, trend: t.trend, + mentions: t.mentions, sentiment: t.sentiment, + bullishPct: t.bullishPct, bearishPct: t.bearishPct, + })), + xTrending: (adanosData.x?.trending || []).slice(0, 10).map(t => ({ + ticker: t.ticker, name: t.name, buzz: t.buzz, trend: t.trend, + mentions: t.mentions, sentiment: t.sentiment, + })), + sectors: (adanosData.reddit?.sectors || []).map(s => ({ + sector: s.sector, buzz: s.buzz, trend: s.trend, + sentiment: s.sentiment, topTickers: s.topTickers, + })), + aggregate: adanosData.aggregate || {}, + signals: adanosData.signals || [], + }; + // === Yahoo Finance live market data === const yfData = data.sources.YFinance || {}; const yfQuotes = yfData.quotes || {}; @@ -455,6 +502,7 @@ export async function synthesize(data) { sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones }, tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, who, fred, energy, bls, treasury, gscpi, defense, noaa, acled, gdelt, space, health, news, + sentiment, // Adanos social sentiment data markets, // Live Yahoo Finance market data ideas: [], ideasSource: 'disabled', // newsFeed for ticker (merged RSS + GDELT + Telegram) diff --git a/lib/delta/engine.mjs b/lib/delta/engine.mjs index d42a958..46cf38c 100644 --- a/lib/delta/engine.mjs +++ b/lib/delta/engine.mjs @@ -18,6 +18,7 @@ const DEFAULT_NUMERIC_THRESHOLDS = { '10y_yield': 3, usd_index: 1, mortgage: 2, + social_sentiment: 15, // % swing in aggregate social sentiment }; const DEFAULT_COUNT_THRESHOLDS = { @@ -30,6 +31,7 @@ const DEFAULT_COUNT_THRESHOLDS = { sdr_online: 3, // ±3 receivers news_count: 5, // ±5 news items sources_ok: 1, // any source going down matters + sentiment_trending: 3, // ±3 trending tickers }; // ─── Metric Definitions ────────────────────────────────────────────────────── @@ -46,6 +48,7 @@ const NUMERIC_METRICS = [ { key: '10y_yield', extract: d => d.fred?.find(f => f.id === 'DGS10')?.value, label: '10Y Yield' }, { key: 'usd_index', extract: d => d.fred?.find(f => f.id === 'DTWEXBGS')?.value, label: 'USD Index' }, { key: 'mortgage', extract: d => d.fred?.find(f => f.id === 'MORTGAGE30US')?.value, label: '30Y Mortgage' }, + { key: 'social_sentiment', extract: d => d.sentiment?.aggregate?.avgSentiment, label: 'Social Sentiment (Avg)' }, ]; const COUNT_METRICS = [ @@ -58,6 +61,7 @@ const COUNT_METRICS = [ { key: 'sdr_online', extract: d => d.sdr?.online || 0, label: 'SDR Receivers' }, { key: 'news_count', extract: d => (d.news?.length ?? d.news?.count) || 0, label: 'News Items' }, { key: 'sources_ok', extract: d => d.meta?.sourcesOk || 0, label: 'Sources OK' }, + { key: 'sentiment_trending', extract: d => d.sentiment?.trending?.length || 0, label: 'Trending Tickers (Sentiment)' }, ]; // Risk-sensitive keys: used for determining overall direction From d3b7d2b805ca112e269508a59136939488faabb4 Mon Sep 17 00:00:00 2001 From: Alex Schneider Date: Thu, 19 Mar 2026 16:46:41 +0100 Subject: [PATCH 2/2] fix: harden adanos source parsing --- apis/sources/adanos.mjs | 50 +++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/apis/sources/adanos.mjs b/apis/sources/adanos.mjs index 8a7a158..5ed3a75 100644 --- a/apis/sources/adanos.mjs +++ b/apis/sources/adanos.mjs @@ -1,6 +1,6 @@ // Adanos — Social Sentiment Intelligence for Stocks -// Multi-platform sentiment analysis: Reddit, X/Twitter, Polymarket -// Provides trending stocks, buzz scores, sentiment breakdowns, and sector analysis +// Structured stock sentiment from Reddit and X/Twitter, plus Reddit sector analysis +// Provides trending stocks, buzz scores, sentiment breakdowns, and sector signals // Free API key required: https://api.adanos.org/docs // Updates every ~15 minutes (Reddit), ~2 hours (X/Twitter) @@ -16,6 +16,16 @@ async function apiFetch(path, apiKey) { }); } +function toFiniteNumber(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +function toNullableNumber(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + // Fetch trending stocks from a platform async function fetchTrending(platform, apiKey, limit = 20) { const data = await apiFetch(`/${platform}/stocks/v1/trending?days=7&limit=${limit}`, apiKey); @@ -23,13 +33,13 @@ async function fetchTrending(platform, apiKey, limit = 20) { return (Array.isArray(data) ? data : []).map(s => ({ ticker: s.ticker, name: s.company_name || null, - buzz: s.buzz_score, + buzz: toFiniteNumber(s.buzz_score), trend: s.trend, - mentions: s.mentions, - sentiment: s.sentiment_score, - bullishPct: s.bullish_pct, - bearishPct: s.bearish_pct, - upvotes: s.total_upvotes || 0, + mentions: toFiniteNumber(s.mentions), + sentiment: toNullableNumber(s.sentiment_score), + bullishPct: toNullableNumber(s.bullish_pct), + bearishPct: toNullableNumber(s.bearish_pct), + upvotes: toFiniteNumber(s.total_upvotes), })); } @@ -39,12 +49,12 @@ async function fetchSectors(apiKey) { if (data?.error) return []; return (Array.isArray(data) ? data : []).map(s => ({ sector: s.sector, - buzz: s.buzz_score, + buzz: toFiniteNumber(s.buzz_score), trend: s.trend, - mentions: s.mentions, - sentiment: s.sentiment_score, - bullishPct: s.bullish_pct, - bearishPct: s.bearish_pct, + mentions: toFiniteNumber(s.mentions), + sentiment: toNullableNumber(s.sentiment_score), + bullishPct: toNullableNumber(s.bullish_pct), + bearishPct: toNullableNumber(s.bearish_pct), topTickers: s.top_tickers || [], })); } @@ -68,20 +78,22 @@ export async function briefing() { // Compute aggregate market sentiment from Reddit (larger sample) const allStocks = reddit.length ? reddit : x; - const totalMentions = allStocks.reduce((s, t) => s + t.mentions, 0); + const weightedStocks = allStocks.filter(t => t.mentions > 0); + const totalMentions = weightedStocks.reduce((sum, ticker) => sum + ticker.mentions, 0); let avgSentiment = 0; let avgBullish = 0; let avgBearish = 0; if (totalMentions > 0) { - for (const t of allStocks) { + for (const t of weightedStocks) { const w = t.mentions / totalMentions; - avgSentiment += t.sentiment * w; - avgBullish += t.bullishPct * w; - avgBearish += t.bearishPct * w; + avgSentiment += toFiniteNumber(t.sentiment) * w; + avgBullish += toFiniteNumber(t.bullishPct) * w; + avgBearish += toFiniteNumber(t.bearishPct) * w; } } - const sorted = [...allStocks].sort((a, b) => b.sentiment - a.sentiment); + const scoredStocks = allStocks.filter(t => t.sentiment != null); + const sorted = [...scoredStocks].sort((a, b) => b.sentiment - a.sentiment); const topBullish = sorted.slice(0, 5).filter(t => t.sentiment > 0); const topBearish = sorted.slice(-5).reverse().filter(t => t.sentiment < 0);