Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions api/analyze-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { VercelRequest, VercelResponse } from '@vercel/node';
import { KeywordMatcher } from '../src/analysis/keyword-matcher';
import { generateSignal, TradingSignal } from '../src/analysis/signal-generator';
import { getMarkets, getArbitrage, getMarketMetadata } from './lib/market-cache';
import { checkRateLimit } from './lib/rate-limit';

function isMalformedJsonError(error: unknown): boolean {
if (!(error instanceof Error)) {
Expand Down Expand Up @@ -48,6 +49,8 @@ export default async function handler(
return;
}

if (!await checkRateLimit(req, res)) return;

const startTime = Date.now();

try {
Expand Down
5 changes: 3 additions & 2 deletions api/cron/collect-tweets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ export default async function handler(
}

// Verify cron secret (Vercel sends this header for authenticated cron calls)
const cronSecret = req.headers.authorization?.replace('Bearer ', '');
if (cronSecret !== process.env.CRON_SECRET) {
const cronSecret = req.headers.authorization?.replace('Bearer ', '') ?? '';
const expectedSecret = process.env.CRON_SECRET ?? '';
if (!expectedSecret || cronSecret !== expectedSecret) {
console.error('[Cron] Unauthorized: Invalid CRON_SECRET');
res.status(401).json({
success: false,
Expand Down
74 changes: 35 additions & 39 deletions api/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { VercelRequest, VercelResponse } from '@vercel/node';
import type { AnalyzedTweet, FeedResponse, AccountCategory } from '../src/types/feed';
import { batchGetFromKV, setFeedCache, getFeedCache, getFeedCacheTimestamp } from './lib/cache-helper';
import { kv } from './lib/vercel-kv';
import { checkRateLimit } from './lib/rate-limit';

// ─── KV Storage Keys ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -76,6 +77,8 @@ export default async function handler(
return;
}

if (!await checkRateLimit(req, res)) return;

try {
// Parse query parameters
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
Expand Down Expand Up @@ -221,51 +224,44 @@ export default async function handler(
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[Feed API] Error:', errorMessage);

const isQuotaError = errorMessage.includes('quota') || errorMessage.includes('max requests limit');

// Fallback to in-memory cache on quota error
if (isQuotaError) {
// Parse query parameters again (they're in try block scope)
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const category = req.query.category as AccountCategory | undefined;
const minUrgency = req.query.minUrgency as string | undefined;
const cacheKey = `${FEED_CACHE_KEY_PREFIX}${category || 'all'}_${minUrgency || 'all'}_${limit}`;

const cachedResponse = getFeedCache(cacheKey);
const cachedAt = getFeedCacheTimestamp(cacheKey);

if (cachedResponse) {
// Modify response to indicate it's cached
const fallbackResponse = {
...cachedResponse,
data: {
...cachedResponse.data,
metadata: {
...cachedResponse.data.metadata,
cached: true,
cached_at: cachedAt ? new Date(cachedAt).toISOString() : null,
cache_age_seconds: cachedAt ? Math.floor((Date.now() - cachedAt) / 1000) : null,
},
// Attempt stale cache fallback for ALL KV errors — not just quota errors.
// A network timeout, auth failure, or Upstash outage should still serve
// the last known-good feed rather than returning a hard 500 that breaks
// the bot's polling loop. cacheKey is re-derived here because it is
// scoped inside the try block above.
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const category = req.query.category as AccountCategory | undefined;
const minUrgency = req.query.minUrgency as string | undefined;
const cacheKey = `${FEED_CACHE_KEY_PREFIX}${category || 'all'}_${minUrgency || 'all'}_${limit}`;

const cachedResponse = getFeedCache(cacheKey);
const cachedAt = getFeedCacheTimestamp(cacheKey);

if (cachedResponse) {
const fallbackResponse = {
...cachedResponse,
data: {
...cachedResponse.data,
metadata: {
...cachedResponse.data.metadata,
stale: true,
cached: true,
cached_at: cachedAt ? new Date(cachedAt).toISOString() : null,
cache_age_seconds: cachedAt ? Math.floor((Date.now() - cachedAt) / 1000) : null,
},
};
},
};

console.log(`[Feed API] Serving cached feed (age: ${fallbackResponse.data.metadata.cache_age_seconds}s)`);
res.setHeader('Cache-Control', FEED_CACHE_CONTROL);
res.status(200).json(fallbackResponse);
return;
}
console.log(`[Feed API] Serving stale cache (age: ${fallbackResponse.data.metadata.cache_age_seconds}s) after error: ${errorMessage}`);
res.setHeader('Cache-Control', FEED_CACHE_CONTROL);
res.status(200).json(fallbackResponse);
return;
}

const sanitized = getSanitizedFeedError(errorMessage);
res.setHeader('Cache-Control', FEED_CACHE_CONTROL);
res.status(isQuotaError ? 503 : sanitized.status).json({
res.status(503).json({
success: false,
error: isQuotaError
? 'Service temporarily unavailable due to quota limits. No cached data available.'
: sanitized.error,
...(sanitized.note && {
note: sanitized.note,
}),
error: 'Feed unavailable. No cached data available.',
});
}
}
Expand Down
27 changes: 11 additions & 16 deletions api/ground-probability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { VercelRequest, VercelResponse } from '@vercel/node';
import { KeywordMatcher } from '../src/analysis/keyword-matcher';
import { getMarkets, getMarketMetadata } from './lib/market-cache';
import { Market, MarketMatch } from '../src/types/market';
import { checkRateLimit } from './lib/rate-limit';

/**
* Ground Probability Endpoint
Expand Down Expand Up @@ -166,6 +167,8 @@ export default async function handler(
return;
}

if (!await checkRateLimit(req, res)) return;

const startTime = Date.now();

try {
Expand All @@ -181,10 +184,10 @@ export default async function handler(
}

// Validate claim
if (!body.claim || typeof body.claim !== 'string') {
if (!body.claim?.trim() || typeof body.claim !== 'string') {
res.status(400).json({
success: false,
error: 'Missing or invalid "claim" field. Must be a string.',
error: 'Missing or invalid "claim" field. Must be a non-empty string.',
});
return;
}
Expand Down Expand Up @@ -217,9 +220,14 @@ export default async function handler(
claim,
llm_estimate = null,
min_confidence = 0.3,
max_markets = 5,
} = body;

// Clamp max_markets to [1, 20] rather than rejecting — any numeric input
// produces a valid result; NaN/undefined/out-of-range all fall back to 5.
const max_markets = Number.isFinite(body.max_markets)
? Math.max(1, Math.min(20, body.max_markets as number))
: 5;

// Validate numeric parameters
if (
typeof min_confidence !== 'number' ||
Expand All @@ -234,19 +242,6 @@ export default async function handler(
return;
}

if (
typeof max_markets !== 'number' ||
!Number.isFinite(max_markets) ||
max_markets < 1 ||
max_markets > 20
) {
res.status(400).json({
success: false,
error: 'max_markets must be between 1 and 20.',
});
return;
}

// Get markets
const markets = await getMarkets();

Expand Down
9 changes: 8 additions & 1 deletion api/lib/cache-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,17 @@ const feedCache = new Map<string, {
ttl: number;
}>();

const MAX_FEED_CACHE_ENTRIES = 50;

/**
* Store feed data in memory for fallback
* Store feed data in memory for fallback.
* Evicts the oldest entry (insertion order) when the cap is reached so
* the Map cannot grow unboundedly inside a warm lambda.
*/
export function setFeedCache(key: string, data: any, ttlMs: number): void {
if (feedCache.size >= MAX_FEED_CACHE_ENTRIES) {
feedCache.delete(feedCache.keys().next().value!);
}
feedCache.set(key, {
data,
timestamp: Date.now(),
Expand Down
9 changes: 6 additions & 3 deletions api/lib/market-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@ let cachedArbitrage: ArbitrageOpportunity[] = [];
let arbCacheTimestamp = 0;
const ARB_CACHE_TTL_MS = (parseInt(process.env.ARBITRAGE_CACHE_TTL_SECONDS || '15', 10)) * 1000;

const POLYMARKET_TARGET_COUNT = parsePositiveInt(process.env.MUSASHI_POLYMARKET_TARGET_COUNT, 1200);
// Keyword filter drops Polymarket yield to ~15 matches/page; 300 topic-relevant
// markets across 20 pages is enough to cover Kalshi's 651 targeted markets.
const POLYMARKET_TARGET_COUNT = parsePositiveInt(process.env.MUSASHI_POLYMARKET_TARGET_COUNT, 300);
const POLYMARKET_MAX_PAGES = parsePositiveInt(process.env.MUSASHI_POLYMARKET_MAX_PAGES, 20);
const KALSHI_TARGET_COUNT = parsePositiveInt(process.env.MUSASHI_KALSHI_TARGET_COUNT, 1000);
const KALSHI_MAX_PAGES = parsePositiveInt(process.env.MUSASHI_KALSHI_MAX_PAGES, 20);

// Stage 0 Session 2: Per-source timeout (5 seconds)
const SOURCE_TIMEOUT_MS = 5000;
// Polymarket: 20 pages × ~1.5s = ~30s. Kalshi: 24 series × 500ms delay = ~15s.
// 60s gives both enough headroom on cold start.
const SOURCE_TIMEOUT_MS = parsePositiveInt(process.env.MUSASHI_SOURCE_TIMEOUT_MS, 60_000);

function parsePositiveInt(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(value ?? '', 10);
Expand Down
47 changes: 47 additions & 0 deletions api/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';

const RATE_LIMIT_MAX_REQUESTS = 30;
const RATE_LIMIT_WINDOW_SECONDS = 60;

/**
* IP-based fixed-window rate limiter backed by Vercel KV (Upstash Redis).
*
* Uses a dynamic import of @vercel/kv so that local development without
* KV credentials degrades gracefully — the catch block returns true,
* allowing all requests through rather than blocking legitimate traffic.
*
* Returns true if the request is within the rate limit, false if it was
* rejected with a 429 response (caller should return immediately).
*/
export async function checkRateLimit(
req: VercelRequest,
res: VercelResponse
): Promise<boolean> {
try {
const rawIp = req.headers['x-forwarded-for'];
const ip = (Array.isArray(rawIp) ? rawIp[0] : rawIp) ?? 'unknown';
const key = `ratelimit:${ip}`;

// Dynamic import matches the pattern in vercel-kv.ts and allows the
// module to load even when @vercel/kv credentials are not configured.
const { kv } = await import('@vercel/kv');
const count: number = await kv.incr(key);
// Set the TTL only on the first increment so the window is fixed, not
// sliding — subsequent increments within the window do not reset it.
if (count === 1) await kv.expire(key, RATE_LIMIT_WINDOW_SECONDS);

if (count > RATE_LIMIT_MAX_REQUESTS) {
res.status(429).json({
success: false,
error: `Rate limit exceeded. Max ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_SECONDS}s window.`,
});
return false;
}

return true;
} catch {
// KV unavailable (local dev, missing credentials, or Upstash outage).
// Fail open — allow the request rather than blocking legitimate traffic.
return true;
}
}
3 changes: 3 additions & 0 deletions api/markets/arbitrage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { getMarkets, getArbitrage, getMarketMetadata } from '../lib/market-cache';
import { checkRateLimit } from '../lib/rate-limit';

export default async function handler(
req: VercelRequest,
Expand All @@ -26,6 +27,8 @@ export default async function handler(
return;
}

if (!await checkRateLimit(req, res)) return;

const startTime = Date.now();

try {
Expand Down
32 changes: 10 additions & 22 deletions src/analysis/keyword-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,8 +955,6 @@ function extractNumericContexts(text: string): Set<string> {
return contexts;
}

const DENOMINATOR_CAP = 5;

interface MatchCounts {
exactMatches: number; // tweet token directly matches market keyword
synonymMatches: number; // tweet token matched via synonym expansion
Expand All @@ -978,7 +976,7 @@ function computeScore(r: MatchCounts, market: Market, matchedKeywords: string[])

// Normalize by keyword list length, capped to avoid penalizing markets
// that happen to have many keywords from description extraction.
const denominator = Math.min(r.totalChecked, DENOMINATOR_CAP);
const denominator = Math.max(1, Math.log10(r.totalChecked) * 2);
const normalized = weighted / denominator;

const totalMatched = r.exactMatches + r.synonymMatches + r.titleMatches + r.entityMatches;
Expand Down Expand Up @@ -1128,24 +1126,20 @@ export class KeywordMatcher {
for (const mk of explicitKeywords) {
if (mk.includes(' ')) {
if (hasWordBoundaryMatch(Array.from(rawTokenSet).join(' '), mk)) {
exactMatches++;
multiWordMatches++;

// Check if this is an entity match
if (isEntity(mk, entities)) {
entityMatches++;
} else {
exactMatches++;
}

matchedKeywords.push(mk);
} else if (hasWordBoundaryMatch(Array.from(expandedTokenSet).join(' '), mk)) {
synonymMatches++;
multiWordMatches++;

// Check if this is an entity match
if (isEntity(mk, entities)) {
entityMatches++;
} else {
synonymMatches++;
}

matchedKeywords.push(mk);
}
}
Expand All @@ -1155,17 +1149,13 @@ export class KeywordMatcher {
for (const mk of explicitKeywords) {
if (!mk.includes(' ') && !matchedKeywords.includes(mk)) {
if (expandedTokenSet.has(mk)) {
if (rawTokenSet.has(mk)) {
if (isEntity(mk, entities)) {
entityMatches++;
} else if (rawTokenSet.has(mk)) {
exactMatches++;
} else {
synonymMatches++;
}

// Check if this is an entity match (people, tickers, orgs)
if (isEntity(mk, entities)) {
entityMatches++;
}

matchedKeywords.push(mk);
}
}
Expand All @@ -1177,13 +1167,11 @@ export class KeywordMatcher {
const titleTokens = extractTitleTokens(market.title);
for (const tt of titleTokens) {
if (!matchedKeywords.includes(tt) && expandedTokenSet.has(tt)) {
titleMatches++;

// Check if this is an entity match
if (isEntity(tt, entities)) {
entityMatches++;
} else {
titleMatches++;
}

matchedKeywords.push(tt);
}
}
Expand Down
Loading