Skip to content
Merged
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
6 changes: 5 additions & 1 deletion apis/utils/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ function loadEnv(filePath) {
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed.slice(eq + 1).trim();
let val = trimmed.slice(eq + 1).trim();
// Strip surrounding quotes (single or double) to support special characters
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (!process.env[key]) { process.env[key] = val; loaded++; }
}
return loaded;
Expand Down
1 change: 1 addition & 0 deletions crucix.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "./apis/utils/env.mjs"; // Load .env first

export default {
port: parseInt(process.env.PORT) || 3117,
publicUrl: process.env.PUBLIC_URL || null,
refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15,

llm: {
Expand Down
158 changes: 151 additions & 7 deletions lib/alerts/discord.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const TIER_CONFIG = {
ROUTINE: { color: 0x3498DB, label: 'ROUTINE', cooldownMs: 60 * 60 * 1000, maxPerHour: 2 },
};

const ACTIONABLE_IDEA_TIER = 'PRIORITY';
const ACTIONABLE_IDEA_MAX_PER_SWEEP = 2;
const EMBED_LIMITS = {
title: 256,
description: 3600,
fieldValue: 1024,
};

// Slash command definitions for Discord's API
const SLASH_COMMANDS = [
{ name: 'status', description: 'System health, last sweep time, source status' },
Expand Down Expand Up @@ -59,23 +67,23 @@ export class DiscordAlerter {
intents: [GatewayIntentBits.Guilds],
});

// Register slash commands
await this._registerCommands(REST, Routes, SlashCommandBuilder);

// Handle slash command interactions
this._client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
await this._handleCommand(interaction);
});

// Connect
await this._client.login(this.botToken);

this._client.once('ready', () => {
// Register ready handler before login so we don't miss the event
this._client.once('ready', async () => {
this._ready = true;
console.log(`[Discord] Bot online as ${this._client.user.tag}`);
// Register slash commands after login so client.user.id is available
await this._registerCommands(REST, Routes, SlashCommandBuilder);
});

// Connect
await this._client.login(this.botToken);

} catch (err) {
if (err.code === 'MODULE_NOT_FOUND' || err.message?.includes('Cannot find')) {
console.warn('[Discord] discord.js not installed. Run: npm install discord.js');
Expand Down Expand Up @@ -421,6 +429,142 @@ export class DiscordAlerter {
};
}

// ─── Kalshi-Style Actionable Idea Alerts ────────────────────────────────

/**
* Post HIGH confidence, short-horizon ideas as actionable Kalshi-style alerts.
* @param {Array} ideas — LLM-generated ideas from the sweep
*/
async sendActionableIdeas(ideas) {
if (!this.isConfigured || !ideas?.length) return;
if (this._isMuted()) return;

// Filter: HIGH confidence + short horizon (Intraday or Days)
const actionable = ideas.filter(i =>
i.confidence === 'HIGH' &&
['Intraday', 'Days', 'Weeks'].includes(i.horizon) &&
['LONG', 'SHORT', 'HEDGE'].includes(i.type)
);

if (actionable.length === 0) return;

if (!this._checkRateLimit(ACTIONABLE_IDEA_TIER)) {
console.log(`[Discord] Rate limited for actionable ideas (${ACTIONABLE_IDEA_TIER})`);
return false;
}

// Dedup: don't re-alert the same idea title within 6 hours
const now = Date.now();
if (!this._alertedIdeas) this._alertedIdeas = new Map();
const fresh = actionable.filter(idea => {
const key = idea.title.toLowerCase().replace(/\s+/g, '-');
const last = this._alertedIdeas.get(key);
if (last && (now - last) < 6 * 60 * 60 * 1000) return false;
idea._alertKey = key;
return true;
}).slice(0, ACTIONABLE_IDEA_MAX_PER_SWEEP);

if (fresh.length === 0) return;

let sentAny = false;
for (const idea of fresh) {
const typeEmoji = { LONG: '📈', SHORT: '📉', HEDGE: '🛡️' }[idea.type] || '👁️';
const horizonEmoji = { Intraday: '⚡', Days: '📅', Weeks: '🗓️' }[idea.horizon] || '⏳';

const embed = this._embed(
this._truncate(`${typeEmoji} ACTIONABLE: ${idea.title}`, EMBED_LIMITS.title),
this._truncate(`**${idea.type}** ${idea.ticker} · Confidence: 🟢 HIGH · Horizon: ${horizonEmoji} ${idea.horizon}\n\n` +
`${idea.rationale}\n\n` +
`⚠️ **Risk:** ${idea.risk}`, EMBED_LIMITS.description),
idea.type === 'SHORT' ? 0xE74C3C : idea.type === 'HEDGE' ? 0x95A5A6 : 0x2ECC71
);

const fields = [
{ name: 'Ticker', value: this._truncate(idea.ticker || '—', EMBED_LIMITS.fieldValue), inline: true },
{ name: 'Direction', value: this._truncate(idea.type, EMBED_LIMITS.fieldValue), inline: true },
{ name: 'Horizon', value: this._truncate(idea.horizon, EMBED_LIMITS.fieldValue), inline: true },
];

if (idea.signals?.length) {
const signals = idea.signals.map(s => String(s)).filter(Boolean).slice(0, 8).join('\n');
fields.push({ name: 'Supporting Signals', value: this._truncate(signals, EMBED_LIMITS.fieldValue), inline: false });
}

fields.push({ name: '🎯 Prediction Market Angle', value: this._truncate(this._kalshiAngle(idea), EMBED_LIMITS.fieldValue), inline: false });

if (embed.setFields) {
embed.setFields(fields);
embed.setFooter({ text: `Crucix Actionable Ideas · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` });
} else {
embed.fields = fields;
embed.footer = { text: `Crucix Actionable Ideas · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` };
}

const sent = await this.sendMessage(null, [embed]);
if (sent) {
this._alertedIdeas.set(idea._alertKey, now);
sentAny = true;
console.log(`[Discord] Actionable idea sent: ${idea.type} ${idea.ticker} (${idea.horizon})`);
} else {
console.warn(`[Discord] Actionable idea send failed: ${idea.type} ${idea.ticker} (${idea.horizon})`);
}
}

if (sentAny) this._recordAlert(ACTIONABLE_IDEA_TIER);
return sentAny;
}

/**
* Generate a Kalshi-relevant angle for a given idea.
*/
_kalshiAngle(idea) {
const ticker = idea.ticker || '';
const type = idea.type;
const signals = (idea.signals || []).join(' ');

// Oil / Energy
if (/BNO|USO|WTI|Brent|CL=F/i.test(ticker + signals)) {
return type === 'LONG'
? '🛢️ Look for YES on "Oil above $X" contracts or NO on "Oil below $X" contracts'
: '🛢️ Look for YES on "Oil below $X" contracts';
}
// Gold / Metals
if (/GLD|Gold|Silver|SLV/i.test(ticker + signals)) {
return type === 'LONG'
? '🥇 Look for YES on "Gold above $X" contracts'
: '🥇 Look for YES on "Gold below $X" contracts';
}
// Rates / Treasury
if (/ZT|ZN|ZB|DGS|Treasury|Fed.*Fund|MORTGAGE/i.test(ticker + signals)) {
return '📊 Look for Fed rate decision contracts or Treasury yield range contracts';
}
// Indexes
if (/SPY|QQQ|SPX|NDX|IWM/i.test(ticker)) {
return type === 'LONG'
? '📈 Look for YES on "S&P above X" or daily range contracts'
: '📉 Look for YES on "S&P below X" or daily range contracts';
}
// Defense / Geopolitical
if (/ITA|LMT|RTX|defense|conflict/i.test(ticker + idea.rationale)) {
return '🌍 Look for geopolitical event contracts (conflict escalation, sanctions, etc.)';
}
// Crypto
if (/BTC|ETH|crypto/i.test(ticker + signals)) {
return type === 'LONG'
? '₿ Look for YES on "Bitcoin above $X" contracts'
: '₿ Look for YES on "Bitcoin below $X" contracts';
}
// Generic
return `Look for event contracts related to ${ticker || 'this sector'} directional moves`;
}

_truncate(value, max, fallback = '—') {
const text = String(value ?? '').trim();
if (!text) return fallback;
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 1))}…`;
}

// ─── Rule-Based Fallback (same logic as Telegram) ───────────────────────

_ruleBasedEvaluation(signals, delta) {
Expand Down
12 changes: 11 additions & 1 deletion lib/llm/gemini.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export class GeminiProvider extends LLMProvider {
contents: [{ parts: [{ text: userMessage }] }],
generationConfig: {
maxOutputTokens: opts.maxTokens || 4096,
// Gemini 2.5 models use thinking tokens from a separate budget;
// set thinkingConfig to keep reasoning concise
thinkingConfig: { thinkingBudget: 1024 },
},
}),
signal: AbortSignal.timeout(opts.timeout || 60000),
Expand All @@ -34,7 +37,14 @@ export class GeminiProvider extends LLMProvider {
}

const data = await res.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
// Gemini 2.5+ models may return multiple parts (thinking + response)
// Filter out thinking parts and concatenate the rest
const parts = data.candidates?.[0]?.content?.parts || [];
const text = parts
.filter(p => !p.thought) // Skip thinking/reasoning parts
.map(p => p.text || '')
.join('\n')
.trim() || '';

return {
text,
Expand Down
19 changes: 14 additions & 5 deletions lib/llm/ideas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ Output ONLY valid JSON array. Each object:
}`;

try {
const result = await provider.complete(systemPrompt, context, { maxTokens: 4096, timeout: 90000 });
const result = await provider.complete(systemPrompt, context, { maxTokens: 8192, timeout: 90000 });
const ideas = parseIdeasResponse(result.text);
if (ideas && ideas.length > 0) {
return ideas;
}
console.warn('[LLM Ideas] No valid ideas parsed from response');
console.warn('[LLM Ideas] No valid ideas parsed from response. Raw length:', result.text?.length, 'First 1000 chars:', JSON.stringify(result.text?.slice(0, 1000)));
return null;
} catch (err) {
console.error('[LLM Ideas] Generation failed:', err.message);
Expand Down Expand Up @@ -167,10 +167,19 @@ function compactSweepForLLM(data, delta, previousIdeas) {
function parseIdeasResponse(text) {
if (!text) return null;

// Strip markdown code block wrappers
// Strip markdown code block wrappers (handles trailing whitespace, thinking tags, etc.)
let cleaned = text.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
// Extract content from code blocks anywhere in the response
const codeBlockMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
if (codeBlockMatch) {
cleaned = codeBlockMatch[1].trim();
} else if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```\s*$/, '');
}
// Strip any leading/trailing non-JSON text (find the array)
const arrayMatch = cleaned.match(/(\[[\s\S]*\])/);
if (arrayMatch) {
cleaned = arrayMatch[1];
}

try {
Expand Down
11 changes: 9 additions & 2 deletions server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ if (telegramAlerter.isConfigured) {
`Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`,
`LLM: ${llmStatus}`,
`SSE clients: ${sseClients.size}`,
`Dashboard: http://localhost:${config.port}`,
`Dashboard: ${config.publicUrl || `http://localhost:${config.port}`}`,
].join('\n');
});

Expand Down Expand Up @@ -169,7 +169,7 @@ if (discordAlerter.isConfigured) {
`Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`,
`LLM: ${llmStatus}`,
`SSE clients: ${sseClients.size}`,
`Dashboard: http://localhost:${config.port}`,
`Dashboard: ${config.publicUrl || `http://localhost:${config.port}`}`,
].join('\n');
});

Expand Down Expand Up @@ -376,6 +376,13 @@ async function runSweepCycle() {
}
}

// 7. Post actionable ideas to Discord (HIGH confidence, short horizon, Kalshi-style)
if (discordAlerter.isConfigured && synthesized.ideas?.length > 0) {
discordAlerter.sendActionableIdeas(synthesized.ideas).catch(err => {
console.error('[Crucix] Discord idea alert error:', err.message);
});
}

// Prune old alerted signals
memory.pruneAlertedSignals();

Expand Down
Loading