Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
147 changes: 88 additions & 59 deletions dashboard/inject.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -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.`,
Expand All @@ -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',
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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 || [];
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 ---');
Expand Down
16 changes: 12 additions & 4 deletions dashboard/public/jarvis.html
Original file line number Diff line number Diff line change
Expand Up @@ -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=>`
<div class="idea-card">
Expand All @@ -1446,8 +1447,8 @@
${idea.risk ? `<div class="idea-text" style="color:var(--warn);margin-top:3px">Risk: ${idea.risk}</div>` : ''}
</div>`).join('') : `<div style="padding:20px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:11px">
<div style="font-size:24px;margin-bottom:8px;opacity:0.3">&#9888;</div>
<div>LLM NOT CONFIGURED</div>
<div style="font-size:9px;margin-top:6px;opacity:0.6">Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas</div>
<div>${t('ideas.noTriggers','NO SIGNAL TRIGGERS')}</div>
<div style="font-size:9px;margin-top:6px;opacity:0.6">${t('ideas.noTriggersHint','No signal thresholds crossed this sweep. Set LLM_PROVIDER + credentials in .env for AI-generated ideas.')}</div>
</div>`;


Expand Down Expand Up @@ -1481,7 +1482,7 @@
</div>
</div>`;
const ideasPanel = `<div class="g-panel lp-ideas">
<div class="sec-head"><h3>${t('panels.tradeIdeas','Leverageable Ideas')}</h3>${D.ideasSource==='llm'?'<span class="ideas-src llm">'+t('ideas.aiEnhanced','AI ENHANCED')+'</span>':D.ideasSource==='disabled'?'<span class="ideas-src static">'+t('ideas.llmOff','LLM OFF')+'</span>':'<span class="ideas-src static">'+t('ideas.pending','PENDING')+'</span>'}</div>
<div class="sec-head"><h3>${t('panels.tradeIdeas','Leverageable Ideas')}</h3>${ideasBadge(D.ideasSource)}</div>
${ideasHtml}
<div class="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.</div>
</div>`;
Expand Down Expand Up @@ -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(/&#39;/g,"'").replace(/&#33;/g,"!").replace(/&amp;/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 '<span class="ideas-src llm">'+t('ideas.aiEnhanced','AI ENHANCED')+'</span>';
if(src==='rules') return '<span class="ideas-src static">'+t('ideas.signalBased','SIGNAL BASED')+'</span>';
return '<span class="ideas-src static">'+t('ideas.pending','PENDING')+'</span>';
}
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 ===
Expand Down
5 changes: 4 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading