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..5ed3a75 --- /dev/null +++ b/apis/sources/adanos.mjs @@ -0,0 +1,136 @@ +// Adanos — Social Sentiment Intelligence for Stocks +// 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) + +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 }, + }); +} + +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); + if (data?.error) return []; + return (Array.isArray(data) ? data : []).map(s => ({ + ticker: s.ticker, + name: s.company_name || null, + buzz: toFiniteNumber(s.buzz_score), + trend: s.trend, + mentions: toFiniteNumber(s.mentions), + sentiment: toNullableNumber(s.sentiment_score), + bullishPct: toNullableNumber(s.bullish_pct), + bearishPct: toNullableNumber(s.bearish_pct), + upvotes: toFiniteNumber(s.total_upvotes), + })); +} + +// 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: toFiniteNumber(s.buzz_score), + trend: s.trend, + mentions: toFiniteNumber(s.mentions), + sentiment: toNullableNumber(s.sentiment_score), + bullishPct: toNullableNumber(s.bullish_pct), + bearishPct: toNullableNumber(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 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 weightedStocks) { + const w = t.mentions / totalMentions; + avgSentiment += toFiniteNumber(t.sentiment) * w; + avgBullish += toFiniteNumber(t.bullishPct) * w; + avgBearish += toFiniteNumber(t.bearishPct) * w; + } + } + + 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); + + // 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