diff --git a/README.md b/README.md index a05f962a..71fbdb84 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ The server runs a sweep cycle every 15 minutes (configurable). Each cycle: 1. Queries all 27 sources in parallel (~30s) 2. Synthesizes raw data into dashboard format 3. Computes delta from previous run (what changed, escalated, de-escalated) — visible in the **Sweep Delta** panel on the dashboard -4. Generates LLM trade ideas (if configured) +4. Generates trade ideas — LLM-generated if a provider is configured, otherwise (or if the provider fails) the deterministic signal-rule engine 5. Evaluates breaking news alerts — multi-tier (FLASH / PRIORITY / ROUTINE) with semantic dedup. Sends to Telegram and/or Discord if configured. Works with LLM evaluation or falls back to rule-based alerting when LLM is unavailable. 6. Pushes update to all connected browsers via SSE diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index 1b067a78..fbd88e3e 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -17,6 +17,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); // === Helpers === +const isNum = v => typeof v === 'number' && Number.isFinite(v); + const cyrillic = /[\u0400-\u04FF]/; function isEnglish(text) { if (!text) return false; @@ -257,73 +259,92 @@ export async function fetchAllNews() { } // === Leverageable Ideas from Signals === +/** + * Deterministic, signal-based idea generation. Used as the baseline layer when + * the LLM is disabled or its call fails, so the Ideas panel is never empty just + * because a provider timed out. + * + * Every source is optional: a sweep with a failed FRED/EIA/BLS source must still + * produce whatever ideas the surviving signals support rather than throwing. + */ export function generateIdeas(V2) { + if (!V2) return []; const ideas = []; - const vix = V2.fred.find(f => f.id === 'VIXCLS'); - const hy = V2.fred.find(f => f.id === 'BAMLH0A0HYM2'); - const spread = V2.fred.find(f => f.id === 'T10Y2Y'); - - if (V2.tg.urgent.length > 3 && V2.energy.wti > 68) { + const fred = Array.isArray(V2.fred) ? V2.fred : []; + const bls = Array.isArray(V2.bls) ? V2.bls : []; + const thermal = Array.isArray(V2.thermal) ? V2.thermal : []; + const urgent = Array.isArray(V2.tg?.urgent) ? V2.tg.urgent : []; + const energy = V2.energy || {}; + const wtiRecent = Array.isArray(energy.wtiRecent) ? energy.wtiRecent : []; + + const vix = fred.find(f => f.id === 'VIXCLS'); + const hy = fred.find(f => f.id === 'BAMLH0A0HYM2'); + const spread = fred.find(f => f.id === 'T10Y2Y'); + + if (urgent.length > 3 && energy.wti > 68) { ideas.push({ title: 'Conflict-Energy Nexus Active', - text: `${V2.tg.urgent.length} urgent conflict signals with WTI at $${V2.energy.wti}. Geopolitical risk premium may expand. Consider energy exposure.`, + text: `${urgent.length} urgent conflict signals with WTI at $${energy.wti}. Geopolitical risk premium may expand. Consider energy exposure.`, type: 'long', confidence: 'Medium', horizon: 'swing' }); } - if (vix && vix.value > 20) { + if (isNum(vix?.value) && vix.value > 20) { ideas.push({ title: 'Elevated Volatility Regime', text: `VIX at ${vix.value} — fear premium elevated. Portfolio hedges justified. Short-term equity upside is capped.`, type: 'hedge', confidence: vix.value > 25 ? 'High' : 'Medium', horizon: 'tactical' }); } - if (vix && vix.value > 20 && hy && hy.value > 3) { + if (isNum(vix?.value) && vix.value > 20 && isNum(hy?.value) && hy.value > 3) { ideas.push({ title: 'Safe Haven Demand Rising', text: `VIX ${vix.value} + HY spread ${hy.value}% = risk-off building. Gold, treasuries, quality dividends may outperform.`, type: 'hedge', confidence: 'Medium', horizon: 'tactical' }); } - if (V2.energy.wtiRecent.length > 1) { - const latest = V2.energy.wtiRecent[0]; - const oldest = V2.energy.wtiRecent[V2.energy.wtiRecent.length - 1]; - const pct = ((latest - oldest) / oldest * 100).toFixed(1); - if (Math.abs(pct) > 3) { - ideas.push({ - title: pct > 0 ? 'Oil Momentum Building' : 'Oil Under Pressure', - text: `WTI moved ${pct > 0 ? '+' : ''}${pct}% recently to $${V2.energy.wti}/bbl. ${pct > 0 ? 'Energy and commodity names benefit.' : 'Demand concerns may be emerging.'}`, - type: pct > 0 ? 'long' : 'watch', confidence: 'Medium', horizon: 'swing' - }); + if (wtiRecent.length > 1) { + const latest = wtiRecent[0]; + const oldest = wtiRecent[wtiRecent.length - 1]; + // Guard against a zero/absent oldest price — otherwise pct is Infinity/NaN. + if (isNum(latest) && isNum(oldest) && oldest !== 0) { + const pct = (latest - oldest) / oldest * 100; + if (Math.abs(pct) > 3) { + ideas.push({ + title: pct > 0 ? 'Oil Momentum Building' : 'Oil Under Pressure', + text: `WTI moved ${pct > 0 ? '+' : ''}${pct.toFixed(1)}% recently to $${energy.wti}/bbl. ${pct > 0 ? 'Energy and commodity names benefit.' : 'Demand concerns may be emerging.'}`, + type: pct > 0 ? 'long' : 'watch', confidence: 'Medium', horizon: 'swing' + }); + } } } - if (spread) { + if (isNum(spread?.value)) { ideas.push({ title: spread.value > 0 ? 'Yield Curve Normalizing' : 'Yield Curve Inverted', text: `10Y-2Y spread at ${spread.value.toFixed(2)}. ${spread.value > 0 ? 'Recession signal fading — cyclical rotation possible.' : 'Inversion persists — defensive positioning warranted.'}`, type: 'watch', confidence: 'Medium', horizon: 'strategic' }); } - const debt = parseFloat(V2.treasury.totalDebt); - if (debt > 35e12) { + const debt = parseFloat(V2.treasury?.totalDebt); + if (isNum(debt) && debt > 35e12) { ideas.push({ title: 'Fiscal Trajectory Supports Hard Assets', text: `National debt at $${(debt / 1e12).toFixed(1)}T. Long-term gold, bitcoin, and real asset appreciation thesis intact.`, type: 'long', confidence: 'High', horizon: 'strategic' }); } - const totalThermal = V2.thermal.reduce((s, t) => s + t.det, 0); - if (totalThermal > 30000 && V2.tg.urgent.length > 2) { + const totalThermal = thermal.reduce((s, t) => s + (t?.det || 0), 0); + if (totalThermal > 30000 && urgent.length > 2) { ideas.push({ title: 'Satellite Confirms Conflict Intensity', - text: `${totalThermal.toLocaleString()} thermal detections + ${V2.tg.urgent.length} urgent OSINT flags. Defense sector procurement may accelerate.`, + text: `${totalThermal.toLocaleString()} thermal detections + ${urgent.length} urgent OSINT flags. Defense sector procurement may accelerate.`, type: 'watch', confidence: 'Medium', horizon: 'swing' }); } // Yield Curve + Labor Interaction - const unemployment = V2.bls.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE'); - const payrolls = V2.bls.find(b => b.id === 'CES0000000001' || b.id === 'PAYEMS'); - if (spread && unemployment && payrolls) { + const unemployment = bls.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE'); + const payrolls = bls.find(b => b.id === 'CES0000000001' || b.id === 'PAYEMS'); + if (isNum(spread?.value) && isNum(unemployment?.value) && payrolls) { const weakLabor = (unemployment.value > 4.3) || (payrolls.momChange && payrolls.momChange < -50); if (spread.value > 0.3 && weakLabor) { ideas.push({ @@ -336,9 +357,9 @@ export function generateIdeas(V2) { // ACLED Conflict + Energy Momentum const conflictEvents = V2.acled?.totalEvents || 0; - if (conflictEvents > 50 && V2.energy.wtiRecent.length > 1) { - const wtiMove = V2.energy.wtiRecent[0] - V2.energy.wtiRecent[V2.energy.wtiRecent.length - 1]; - if (wtiMove > 2) { + if (conflictEvents > 50 && wtiRecent.length > 1) { + const wtiMove = wtiRecent[0] - wtiRecent[wtiRecent.length - 1]; + if (isNum(wtiMove) && wtiMove > 2) { ideas.push({ title: 'Conflict Fueling Energy Momentum', text: `${conflictEvents} ACLED events this week + WTI up $${wtiMove.toFixed(1)}. Conflict-energy transmission channel active.`, @@ -349,7 +370,7 @@ export function generateIdeas(V2) { // Defense + Conflict Intensity const totalFatalities = V2.acled?.totalFatalities || 0; - const totalThermalAll = V2.thermal.reduce((s, t) => s + t.det, 0); + const totalThermalAll = totalThermal; if (totalFatalities > 500 && totalThermalAll > 20000) { ideas.push({ title: 'Defense Procurement Acceleration Signal', @@ -359,7 +380,7 @@ export function generateIdeas(V2) { } // HY Spread + VIX Divergence - if (hy && vix) { + if (isNum(hy?.value) && isNum(vix?.value)) { const hyWide = hy.value > 3.5; const vixLow = vix.value < 18; const hyTight = hy.value < 2.5; @@ -380,9 +401,9 @@ export function generateIdeas(V2) { } // Supply Chain + Inflation Pipeline - const ppi = V2.bls.find(b => b.id === 'WPUFD49104' || b.id === 'PCU--PCU--'); - const cpi = V2.bls.find(b => b.id === 'CUUR0000SA0' || b.id === 'CPIAUCSL'); - if (ppi && cpi && V2.gscpi) { + const ppi = bls.find(b => b.id === 'WPUFD49104' || b.id === 'PCU--PCU--'); + const cpi = bls.find(b => b.id === 'CUUR0000SA0' || b.id === 'CPIAUCSL'); + if (ppi && cpi && isNum(V2.gscpi?.value)) { const supplyPressure = V2.gscpi.value > 0.5; const ppiRising = ppi.momChangePct > 0.3; if (supplyPressure && ppiRising) { @@ -397,6 +418,28 @@ export function generateIdeas(V2) { return ideas.slice(0, 8); } +/** + * Resolve the Ideas panel contents for a synthesized sweep. + * + * The LLM is an *enhancement* layer, not a prerequisite: whenever it is absent, + * returns nothing, or throws, we fall back to the deterministic signal-based + * engine so the panel is still populated. + * + * @returns {Promise<{ideas: Array, ideasSource: 'llm'|'rules'}>} + */ +export async function resolveIdeas(llmProvider, V2, delta = null, previousIdeas = []) { + if (llmProvider?.isConfigured) { + try { + const llmIdeas = await generateLLMIdeas(llmProvider, V2, delta, previousIdeas); + if (llmIdeas?.length) return { ideas: llmIdeas, ideasSource: 'llm' }; + console.warn('[Ideas] LLM returned no ideas — falling back to signal rules'); + } catch (err) { + console.warn('[Ideas] LLM idea generation failed, falling back to signal rules:', err.message); + } + } + return { ideas: generateIdeas(V2), ideasSource: 'rules' }; +} + // === Synthesize raw sweep data into dashboard format === export async function synthesize(data) { const liveAirHotspots = data.sources.OpenSky?.hotspots || []; @@ -610,11 +653,15 @@ export async function synthesize(data) { tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, who, fred, energy, metals, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news, markets, // Live Yahoo Finance market data - ideas: [], ideasSource: 'disabled', + ideas: [], ideasSource: 'rules', // newsFeed for ticker (merged RSS + GDELT + Telegram) newsFeed: buildNewsFeed(news, gdeltData, tgUrgent, tgTop), }; + // Baseline signal-based ideas so the panel is populated even before (or without) + // an LLM pass. Callers that run the LLM overwrite these via resolveIdeas(). + V2.ideas = generateIdeas(V2); + return V2; } @@ -696,29 +743,11 @@ async function cliInject() { const V2 = await synthesize(data); const llmProvider = createLLMProvider(config.llm); - if (llmProvider?.isConfigured) { - try { - console.log(`[LLM] Generating ideas via ${llmProvider.name}...`); - const llmIdeas = await generateLLMIdeas(llmProvider, V2, null, []); - if (llmIdeas?.length) { - V2.ideas = llmIdeas; - V2.ideasSource = 'llm'; - console.log(`[LLM] Generated ${llmIdeas.length} ideas`); - } else { - V2.ideas = []; - V2.ideasSource = 'llm-failed'; - console.log('[LLM] No ideas returned'); - } - } catch (err) { - V2.ideas = []; - V2.ideasSource = 'llm-failed'; - console.log('[LLM] Idea generation failed:', err.message); - } - } else { - V2.ideas = []; - V2.ideasSource = 'disabled'; - } - console.log(`Generated ${V2.ideas.length} leverageable ideas`); + if (llmProvider?.isConfigured) console.log(`[LLM] Generating ideas via ${llmProvider.name}...`); + const resolved = await resolveIdeas(llmProvider, V2, null, []); + V2.ideas = resolved.ideas; + V2.ideasSource = resolved.ideasSource; + console.log(`Generated ${V2.ideas.length} leverageable ideas (${V2.ideasSource})`); const json = JSON.stringify(V2); console.log('\n--- Synthesis ---'); diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 05a5c7af..4dcf2a74 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -1433,7 +1433,8 @@ }).join(''); const tickerDuration = Math.max(20, feed.length * 2.5); - // Leverageable Ideas (LLM-only feature) + // Leverageable Ideas — LLM-generated when a provider is configured, otherwise + // the deterministic signal-rule engine. const hasIdeas = D.ideas && D.ideas.length > 0; const ideasHtml = hasIdeas ? (D.ideas||[]).map(idea=>`
@@ -1446,8 +1447,8 @@ ${idea.risk ? `
Risk: ${idea.risk}
` : ''}
`).join('') : `
-
LLM NOT CONFIGURED
-
Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas
+
${t('ideas.noTriggers','NO SIGNAL TRIGGERS')}
+
${t('ideas.noTriggersHint','No signal thresholds crossed this sweep. Set LLM_PROVIDER + credentials in .env for AI-generated ideas.')}
`; @@ -1481,7 +1482,7 @@ `; const ideasPanel = `
-

${t('panels.tradeIdeas','Leverageable Ideas')}

${D.ideasSource==='llm'?''+t('ideas.aiEnhanced','AI ENHANCED')+'':D.ideasSource==='disabled'?''+t('ideas.llmOff','LLM OFF')+'':''+t('ideas.pending','PENDING')+''}
+

${t('panels.tradeIdeas','Leverageable Ideas')}

${ideasBadge(D.ideasSource)}
${ideasHtml}
FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.
`; @@ -1552,6 +1553,13 @@ // === HELPERS === function getAge(d){const ms=Date.now()-new Date(d).getTime();const h=Math.floor(ms/3600000);if(h<1)return 'just now';if(h<24)return h+'h ago';return Math.floor(h/24)+'d ago'} function cleanText(t){return t.replace(/'/g,"'").replace(/!/g,"!").replace(/&/g,"&").replace(/<[^>]+>/g,'')} +// Provenance badge for the Ideas panel. 'llm' = AI-generated, 'rules' = deterministic +// signal engine (the fallback when no LLM is configured or the provider failed). +function ideasBadge(src){ + if(src==='llm') return ''+t('ideas.aiEnhanced','AI ENHANCED')+''; + if(src==='rules') return ''+t('ideas.signalBased','SIGNAL BASED')+''; + return ''+t('ideas.pending','PENDING')+''; +} function safeExternalUrl(raw){try{const u=new URL(raw,location.href);return u.protocol==='http:'||u.protocol==='https:'?u.toString():null}catch{return null}} // === BOOT SEQUENCE === diff --git a/locales/en.json b/locales/en.json index cfc74bba..1434211a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -121,7 +121,10 @@ "pending": "PENDING", "llmNotConfigured": "LLM NOT CONFIGURED", "llmHelp": "Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas", - "disclosure": "FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results." + "disclosure": "FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.", + "signalBased": "SIGNAL BASED", + "noTriggers": "NO SIGNAL TRIGGERS", + "noTriggersHint": "No signal thresholds crossed this sweep. Set LLM_PROVIDER + credentials in .env for AI-generated ideas." }, "regions": { "world": "World", diff --git a/locales/fr.json b/locales/fr.json index 0762b5b7..73f64c43 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -121,7 +121,10 @@ "pending": "EN ATTENTE", "llmNotConfigured": "LLM NON CONFIGURÉ", "llmHelp": "Définir LLM_PROVIDER + identifiants dans .env pour activer les idées de trade IA", - "disclosure": "À TITRE INFORMATIF UNIQUEMENT. Ceci ne constitue pas un conseil financier, une recommandation d'achat ou de vente de titre, ni une sollicitation quelconque. Toutes les observations basées sur les signaux sont dérivées de données OSINT publiques et ne doivent pas être utilisées pour prendre des décisions d'investissement. Consultez un conseiller financier agréé avant tout investissement. Les performances passées ne garantissent pas les résultats futurs." + "disclosure": "À TITRE INFORMATIF UNIQUEMENT. Ceci ne constitue pas un conseil financier, une recommandation d'achat ou de vente de titre, ni une sollicitation quelconque. Toutes les observations basées sur les signaux sont dérivées de données OSINT publiques et ne doivent pas être utilisées pour prendre des décisions d'investissement. Consultez un conseiller financier agréé avant tout investissement. Les performances passées ne garantissent pas les résultats futurs.", + "signalBased": "BASE SIGNAUX", + "noTriggers": "AUCUN SEUIL FRANCHI", + "noTriggersHint": "Aucun seuil de signal franchi lors de ce balayage. Definissez LLM_PROVIDER + identifiants dans .env pour des idees generees par IA." }, "regions": { "world": "Monde", diff --git a/package.json b/package.json index 5b90bf29..c0da07ae 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "start": "node server.mjs", + "test": "node --test \"test/*.test.mjs\"", "dev": "node --trace-warnings server.mjs", "sweep": "node apis/briefing.mjs", "inject": "node dashboard/inject.mjs", diff --git a/server.mjs b/server.mjs index b45d869a..1d1b6655 100644 --- a/server.mjs +++ b/server.mjs @@ -10,10 +10,9 @@ import { exec } from 'child_process'; import config from './crucix.config.mjs'; import { getLocale, currentLanguage, getSupportedLocales } from './lib/i18n.mjs'; import { fullBriefing } from './apis/briefing.mjs'; -import { synthesize, generateIdeas } from './dashboard/inject.mjs'; +import { synthesize, resolveIdeas } from './dashboard/inject.mjs'; import { MemoryManager } from './lib/delta/index.mjs'; import { createLLMProvider } from './lib/llm/index.mjs'; -import { generateLLMIdeas } from './lib/llm/ideas.mjs'; import { TelegramAlerter } from './lib/alerts/telegram.mjs'; import { DiscordAlerter } from './lib/alerts/discord.mjs'; @@ -338,29 +337,14 @@ async function runSweepCycle() { const delta = memory.addRun(synthesized); synthesized.delta = delta; - // 5. LLM-powered trade ideas (LLM-only feature) — isolated so failures don't kill sweep - if (llmProvider?.isConfigured) { - try { - console.log('[Crucix] Generating LLM trade ideas...'); - const previousIdeas = memory.getLastRun()?.ideas || []; - const llmIdeas = await generateLLMIdeas(llmProvider, synthesized, delta, previousIdeas); - if (llmIdeas) { - synthesized.ideas = llmIdeas; - synthesized.ideasSource = 'llm'; - console.log(`[Crucix] LLM generated ${llmIdeas.length} ideas`); - } else { - synthesized.ideas = []; - synthesized.ideasSource = 'llm-failed'; - } - } catch (llmErr) { - console.error('[Crucix] LLM ideas failed (non-fatal):', llmErr.message); - synthesized.ideas = []; - synthesized.ideasSource = 'llm-failed'; - } - } else { - synthesized.ideas = []; - synthesized.ideasSource = 'disabled'; - } + // 5. Trade ideas — LLM when available, deterministic signal rules otherwise. + // resolveIdeas() never throws, so a provider outage cannot blank the panel. + if (llmProvider?.isConfigured) console.log('[Crucix] Generating LLM trade ideas...'); + const previousIdeas = memory.getLastRun()?.ideas || []; + const resolvedIdeas = await resolveIdeas(llmProvider, synthesized, delta, previousIdeas); + synthesized.ideas = resolvedIdeas.ideas; + synthesized.ideasSource = resolvedIdeas.ideasSource; + console.log(`[Crucix] ${synthesized.ideas.length} ideas generated (${synthesized.ideasSource})`); // 6. Alert evaluation — Telegram + Discord (LLM with rule-based fallback, multi-tier, semantic dedup) if (delta?.summary?.totalChanges > 0) { diff --git a/test/ideas-rules.test.mjs b/test/ideas-rules.test.mjs new file mode 100644 index 00000000..adb8c065 --- /dev/null +++ b/test/ideas-rules.test.mjs @@ -0,0 +1,228 @@ +// Tests for the deterministic signal-based idea engine and the LLM/rules +// fallback resolver in dashboard/inject.mjs. + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateIdeas, resolveIdeas } from '../dashboard/inject.mjs'; + +/** Minimal synthesized-sweep shape, matching what synthesize() produces. */ +function makeSweep(overrides = {}) { + return { + fred: [], + bls: [], + thermal: [], + tg: { urgent: [] }, + energy: { wtiRecent: [] }, + treasury: { totalDebt: '0' }, + acled: { totalEvents: 0, totalFatalities: 0 }, + gscpi: null, + ...overrides, + }; +} + +const fred = (id, value) => ({ id, label: id, value }); +const urgentPosts = n => Array.from({ length: n }, (_, i) => ({ text: `urgent ${i}` })); +const titles = ideas => ideas.map(i => i.title); + +describe('generateIdeas — degraded and empty sweeps', () => { + test('returns an empty array for a sweep with no signals', () => { + assert.deepEqual(generateIdeas(makeSweep()), []); + }); + + test('returns an empty array rather than throwing on null input', () => { + assert.deepEqual(generateIdeas(null), []); + assert.deepEqual(generateIdeas(undefined), []); + }); + + test('tolerates a sweep where every source failed (missing keys)', () => { + // A sweep in which FRED/EIA/BLS/Telegram all errored leaves these undefined. + assert.deepEqual(generateIdeas({}), []); + }); + + test('tolerates null-valued metrics without emitting bogus ideas', () => { + const sweep = makeSweep({ + fred: [fred('VIXCLS', null), fred('T10Y2Y', undefined), fred('BAMLH0A0HYM2', null)], + bls: [{ id: 'UNRATE', value: null }], + }); + assert.deepEqual(generateIdeas(sweep), []); + }); +}); + +describe('generateIdeas — individual signal rules', () => { + test('flags an elevated volatility regime above VIX 20', () => { + const ideas = generateIdeas(makeSweep({ fred: [fred('VIXCLS', 22)] })); + const vol = ideas.find(i => i.title === 'Elevated Volatility Regime'); + assert.ok(vol, 'expected an Elevated Volatility Regime idea'); + assert.equal(vol.type, 'hedge'); + assert.equal(vol.confidence, 'Medium'); + }); + + test('raises confidence to High above VIX 25', () => { + const ideas = generateIdeas(makeSweep({ fred: [fred('VIXCLS', 30)] })); + assert.equal(ideas.find(i => i.title === 'Elevated Volatility Regime').confidence, 'High'); + }); + + test('does not flag volatility at or below the VIX 20 threshold', () => { + const ideas = generateIdeas(makeSweep({ fred: [fred('VIXCLS', 20)] })); + assert.equal(ideas.find(i => i.title === 'Elevated Volatility Regime'), undefined); + }); + + test('flags safe-haven demand when VIX and HY spreads are both elevated', () => { + const ideas = generateIdeas(makeSweep({ + fred: [fred('VIXCLS', 26), fred('BAMLH0A0HYM2', 4)], + })); + assert.ok(titles(ideas).includes('Safe Haven Demand Rising')); + }); + + test('flags the conflict-energy nexus on urgent OSINT plus firm crude', () => { + const ideas = generateIdeas(makeSweep({ + tg: { urgent: urgentPosts(4) }, + energy: { wti: 80, wtiRecent: [] }, + })); + const nexus = ideas.find(i => i.title === 'Conflict-Energy Nexus Active'); + assert.ok(nexus); + assert.equal(nexus.type, 'long'); + assert.match(nexus.text, /4 urgent conflict signals/); + }); + + test('reads oil momentum from the WTI history', () => { + const up = generateIdeas(makeSweep({ energy: { wti: 90, wtiRecent: [90, 85, 80] } })); + assert.ok(titles(up).includes('Oil Momentum Building')); + + const down = generateIdeas(makeSweep({ energy: { wti: 70, wtiRecent: [70, 75, 80] } })); + assert.ok(titles(down).includes('Oil Under Pressure')); + }); + + test('ignores WTI history when the oldest price is zero', () => { + // Previously divided by zero and produced an Infinity% move. + const ideas = generateIdeas(makeSweep({ energy: { wti: 80, wtiRecent: [80, 0] } })); + assert.equal(ideas.find(i => /^Oil /.test(i.title)), undefined); + }); + + test('reports yield curve state in both directions', () => { + const inverted = generateIdeas(makeSweep({ fred: [fred('T10Y2Y', -0.5)] })); + assert.ok(titles(inverted).includes('Yield Curve Inverted')); + + const normal = generateIdeas(makeSweep({ fred: [fred('T10Y2Y', 0.5)] })); + assert.ok(titles(normal).includes('Yield Curve Normalizing')); + }); + + test('flags the fiscal trajectory above $35T of debt', () => { + const ideas = generateIdeas(makeSweep({ treasury: { totalDebt: '36000000000000' } })); + const fiscal = ideas.find(i => i.title === 'Fiscal Trajectory Supports Hard Assets'); + assert.ok(fiscal); + assert.equal(fiscal.confidence, 'High'); + }); + + test('flags credit stress that equity volatility is ignoring', () => { + const ideas = generateIdeas(makeSweep({ + fred: [fred('VIXCLS', 15), fred('BAMLH0A0HYM2', 4)], + })); + assert.ok(titles(ideas).includes('Credit Stress Ignored by Equity Vol')); + }); + + test('flags satellite confirmation of conflict intensity', () => { + const ideas = generateIdeas(makeSweep({ + thermal: [{ det: 20000 }, { det: 15000 }], + tg: { urgent: urgentPosts(3) }, + })); + assert.ok(titles(ideas).includes('Satellite Confirms Conflict Intensity')); + }); + + test('caps output at 8 ideas', () => { + // Trip as many rules simultaneously as possible. + const ideas = generateIdeas(makeSweep({ + fred: [fred('VIXCLS', 30), fred('BAMLH0A0HYM2', 4), fred('T10Y2Y', 0.5)], + bls: [ + { id: 'UNRATE', value: 5 }, + { id: 'PAYEMS', value: 1, momChange: -100 }, + { id: 'CPIAUCSL', value: 300 }, + { id: 'WPUFD49104', value: 250, momChangePct: 1 }, + ], + thermal: [{ det: 40000 }], + tg: { urgent: urgentPosts(6) }, + energy: { wti: 90, wtiRecent: [90, 80] }, + treasury: { totalDebt: '36000000000000' }, + acled: { totalEvents: 100, totalFatalities: 900 }, + gscpi: { value: 1.2, interpretation: 'elevated' }, + })); + assert.equal(ideas.length, 8); + }); +}); + +describe('generateIdeas — output contract', () => { + test('every idea carries the fields the dashboard renders', () => { + const ideas = generateIdeas(makeSweep({ + fred: [fred('VIXCLS', 30), fred('BAMLH0A0HYM2', 4), fred('T10Y2Y', 0.5)], + treasury: { totalDebt: '36000000000000' }, + })); + assert.ok(ideas.length > 0); + for (const idea of ideas) { + assert.equal(typeof idea.title, 'string', 'title must be a string'); + assert.ok(idea.title.length > 0); + assert.equal(typeof idea.text, 'string', 'text must be a string'); + assert.ok(['long', 'hedge', 'watch'].includes(idea.type), `unexpected type: ${idea.type}`); + assert.ok(['Low', 'Medium', 'High'].includes(idea.confidence), `unexpected confidence: ${idea.confidence}`); + assert.ok(['tactical', 'swing', 'strategic'].includes(idea.horizon), `unexpected horizon: ${idea.horizon}`); + // No NaN/Infinity leaking into user-facing copy. + assert.doesNotMatch(idea.text, /NaN|Infinity|undefined/); + } + }); +}); + +describe('resolveIdeas — LLM with rule-based fallback', () => { + const sweep = makeSweep({ fred: [fred('VIXCLS', 30)] }); + + test('falls back to signal rules when no provider is configured', async () => { + const result = await resolveIdeas(null, sweep); + assert.equal(result.ideasSource, 'rules'); + assert.ok(result.ideas.length > 0); + }); + + test('falls back to signal rules when the provider is unconfigured', async () => { + const result = await resolveIdeas({ isConfigured: false }, sweep); + assert.equal(result.ideasSource, 'rules'); + assert.ok(result.ideas.length > 0); + }); + + test('falls back to signal rules when the LLM throws', async () => { + const provider = { + isConfigured: true, + name: 'boom', + complete: async () => { throw new Error('upstream 503'); }, + }; + const result = await resolveIdeas(provider, sweep); + assert.equal(result.ideasSource, 'rules'); + assert.ok(result.ideas.length > 0, 'a provider outage must not blank the panel'); + }); + + test('falls back to signal rules when the LLM returns unusable output', async () => { + const provider = { + isConfigured: true, + name: 'garbage', + complete: async () => ({ text: 'not json at all' }), + }; + const result = await resolveIdeas(provider, sweep); + assert.equal(result.ideasSource, 'rules'); + assert.ok(result.ideas.length > 0); + }); + + test('prefers LLM ideas when the provider returns them', async () => { + const provider = { + isConfigured: true, + name: 'good', + complete: async () => ({ + text: JSON.stringify([{ + title: 'LLM Idea', + rationale: 'because', + type: 'long', + confidence: 'High', + horizon: 'swing', + }]), + }), + }; + const result = await resolveIdeas(provider, sweep); + assert.equal(result.ideasSource, 'llm'); + assert.equal(result.ideas[0].title, 'LLM Idea'); + }); +});