1379 lines
44 KiB
TypeScript
1379 lines
44 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
const DB_FILE = path.join(process.cwd(), 'econometrics_storage.json');
|
|
|
|
// Helper to handle fetch timeouts
|
|
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = 5000): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal
|
|
});
|
|
clearTimeout(id);
|
|
return response;
|
|
} catch (error) {
|
|
clearTimeout(id);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function getOffsetDate(dateStr: string, offsetDays: number): string {
|
|
const d = new Date(dateStr);
|
|
if (isNaN(d.getTime())) return dateStr;
|
|
d.setDate(d.getDate() + offsetDays);
|
|
return d.toISOString().split('T')[0];
|
|
}
|
|
|
|
function generateMockPriceCurve(symbol: string, dateStr: string) {
|
|
const prices = [];
|
|
let currentPrice = symbol === 'AAPL' ? 175 : symbol === '^IXIC' ? 16000 : symbol === 'GLD' ? 200 : symbol.includes('BTC') ? 60000 : 100;
|
|
|
|
// Start from 30 days before event date
|
|
const start = new Date(dateStr);
|
|
start.setDate(start.getDate() - 30);
|
|
|
|
for (let i = 0; i <= 60; i++) {
|
|
const d = new Date(start);
|
|
d.setDate(d.getDate() + i);
|
|
|
|
const changePercent = (Math.random() - 0.48) * 0.025;
|
|
currentPrice = currentPrice * (1 + changePercent);
|
|
|
|
prices.push({
|
|
date: d.toISOString().split('T')[0],
|
|
close: Math.round(currentPrice * 100) / 100
|
|
});
|
|
}
|
|
return prices;
|
|
}
|
|
|
|
function generateMockVixCurve(dateStr: string) {
|
|
const prices = [];
|
|
let currentPrice = 16.0;
|
|
|
|
const start = new Date(dateStr);
|
|
start.setDate(start.getDate() - 30);
|
|
|
|
for (let i = 0; i <= 60; i++) {
|
|
const d = new Date(start);
|
|
d.setDate(d.getDate() + i);
|
|
|
|
const changePercent = (Math.random() - 0.5) * 0.08;
|
|
currentPrice = Math.max(9, Math.min(65, currentPrice * (1 + changePercent)));
|
|
|
|
prices.push({
|
|
date: d.toISOString().split('T')[0],
|
|
close: Math.round(currentPrice * 100) / 100
|
|
});
|
|
}
|
|
return prices;
|
|
}
|
|
|
|
function getVixOnDate(vixPrices: { date: string; close: number }[], eventDateStr: string): number {
|
|
if (!vixPrices || vixPrices.length === 0) return 15.5;
|
|
const eventTime = new Date(eventDateStr).getTime();
|
|
let closestPrice = vixPrices[0].close;
|
|
let minDiff = Math.abs(new Date(vixPrices[0].date).getTime() - eventTime);
|
|
|
|
for (const p of vixPrices) {
|
|
const diff = Math.abs(new Date(p.date).getTime() - eventTime);
|
|
if (diff < minDiff) {
|
|
minDiff = diff;
|
|
closestPrice = p.close;
|
|
}
|
|
}
|
|
return closestPrice;
|
|
}
|
|
|
|
// Calculate return and trend from the price curve
|
|
function calculateMetricsFromPrices(prices: { date: string; close: number }[]) {
|
|
if (!prices || prices.length < 5) {
|
|
return { returnVal: 0.01, trend: 0.005 };
|
|
}
|
|
|
|
// Sort ascending by date
|
|
const sorted = [...prices].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
|
|
const midIdx = Math.floor(sorted.length / 2);
|
|
const pFirst = sorted[0].close;
|
|
const pEvent = sorted[midIdx].close;
|
|
const pLast = sorted[sorted.length - 1].close;
|
|
|
|
const returnVal = (pLast - pEvent) / (pEvent || 1);
|
|
const trend = (pEvent - pFirst) / (pFirst || 1);
|
|
|
|
return {
|
|
returnVal: Math.round(returnVal * 10000) / 10000,
|
|
trend: Math.round(trend * 10000) / 10000
|
|
};
|
|
}
|
|
|
|
function getInitialDB() {
|
|
const initialAssets = [
|
|
{ name: 'Apple', symbol: 'AAPL' },
|
|
{ name: 'NASDAQ', symbol: '^IXIC' },
|
|
{ name: 'Gold', symbol: 'GLD' },
|
|
{ name: 'Bitcoin', symbol: 'BTC-USD' }
|
|
];
|
|
|
|
const initialEvents = [
|
|
{
|
|
id: 'ev1',
|
|
name: 'Fed-Zinsentscheid (FOMC)',
|
|
date: '2026-05-14',
|
|
scores: { Apple: 1, NASDAQ: 2, Gold: -1, Bitcoin: 2 },
|
|
priceData: {} as Record<string, any[]>
|
|
},
|
|
{
|
|
id: 'ev2',
|
|
name: 'US-Inflationsdaten (CPI)',
|
|
date: '2026-04-12',
|
|
scores: { Apple: 1, NASDAQ: 1, Gold: 3, Bitcoin: 2 },
|
|
priceData: {} as Record<string, any[]>
|
|
},
|
|
{
|
|
id: 'ev3',
|
|
name: 'Non-Farm Payrolls (NFP)',
|
|
date: '2026-06-05',
|
|
scores: { Apple: 0, NASDAQ: 2, Gold: -1, Bitcoin: 1 },
|
|
priceData: {} as Record<string, any[]>
|
|
}
|
|
];
|
|
|
|
// Prepopulate default price curves
|
|
initialEvents.forEach(ev => {
|
|
initialAssets.forEach(asset => {
|
|
const sym = asset.symbol;
|
|
ev.priceData[sym] = generateMockPriceCurve(sym, ev.date);
|
|
});
|
|
ev.priceData['^VIX'] = generateMockVixCurve(ev.date);
|
|
});
|
|
|
|
// Generate initial LMM observations
|
|
const observations: any[] = [];
|
|
initialEvents.forEach(ev => {
|
|
const vixPrices = ev.priceData['^VIX'] || [];
|
|
const realVix = getVixOnDate(vixPrices, ev.date);
|
|
|
|
initialAssets.forEach(assetObj => {
|
|
const asset = assetObj.name;
|
|
const sym = assetObj.symbol;
|
|
const score = (ev.scores as Record<string, number>)[asset];
|
|
const metrics = calculateMetricsFromPrices(ev.priceData[sym]);
|
|
observations.push({
|
|
asset,
|
|
eventName: ev.name,
|
|
eventType: score >= 0 ? 'BULLISH' : 'BEARISH',
|
|
score,
|
|
vix: realVix,
|
|
trend: metrics.trend,
|
|
returnVal: metrics.returnVal
|
|
});
|
|
});
|
|
});
|
|
|
|
return {
|
|
assets: initialAssets,
|
|
events: initialEvents,
|
|
observations,
|
|
archivedEvents: [] as any[]
|
|
};
|
|
}
|
|
|
|
async function readDB() {
|
|
try {
|
|
const exists = await fs.access(DB_FILE).then(() => true).catch(() => false);
|
|
if (!exists) {
|
|
const db = getInitialDB();
|
|
db.archivedEvents = [];
|
|
const thresholdDate = '2026-06-11';
|
|
db.events.forEach((ev: any) => {
|
|
if (ev.date < thresholdDate) {
|
|
db.archivedEvents.push({
|
|
id: ev.id,
|
|
name: ev.name,
|
|
date: ev.date,
|
|
archivedScores: { ...ev.scores },
|
|
actualPrices: JSON.parse(JSON.stringify(ev.priceData || {}))
|
|
});
|
|
}
|
|
});
|
|
await fs.writeFile(DB_FILE, JSON.stringify(db, null, 2), 'utf8');
|
|
return db;
|
|
}
|
|
const content = await fs.readFile(DB_FILE, 'utf8');
|
|
const db = JSON.parse(content);
|
|
// Migration: make sure assets list is present
|
|
if (!db.assets) {
|
|
db.assets = [
|
|
{ name: 'Apple', symbol: 'AAPL' },
|
|
{ name: 'NASDAQ', symbol: '^IXIC' },
|
|
{ name: 'Gold', symbol: 'GLD' },
|
|
{ name: 'Bitcoin', symbol: 'BTC-USD' }
|
|
];
|
|
}
|
|
|
|
// Auto-archiver scan
|
|
let updatedArchive = false;
|
|
if (!db.archivedEvents) {
|
|
db.archivedEvents = [];
|
|
updatedArchive = true;
|
|
}
|
|
const thresholdDate = '2026-06-11';
|
|
db.events.forEach((ev: any) => {
|
|
if (ev.date < thresholdDate) {
|
|
const alreadyArchived = db.archivedEvents.some((ae: any) => ae.id === ev.id);
|
|
if (!alreadyArchived) {
|
|
db.archivedEvents.push({
|
|
id: ev.id,
|
|
name: ev.name,
|
|
date: ev.date,
|
|
archivedScores: { ...ev.scores },
|
|
actualPrices: JSON.parse(JSON.stringify(ev.priceData || {}))
|
|
});
|
|
updatedArchive = true;
|
|
}
|
|
}
|
|
});
|
|
if (updatedArchive) {
|
|
await fs.writeFile(DB_FILE, JSON.stringify(db, null, 2), 'utf8');
|
|
}
|
|
|
|
return db;
|
|
} catch (e) {
|
|
console.error('Failed to parse DB, resetting to defaults:', e);
|
|
const db = getInitialDB();
|
|
db.archivedEvents = [];
|
|
const thresholdDate = '2026-06-11';
|
|
db.events.forEach((ev: any) => {
|
|
if (ev.date < thresholdDate) {
|
|
db.archivedEvents.push({
|
|
id: ev.id,
|
|
name: ev.name,
|
|
date: ev.date,
|
|
archivedScores: { ...ev.scores },
|
|
actualPrices: JSON.parse(JSON.stringify(ev.priceData || {}))
|
|
});
|
|
}
|
|
});
|
|
try {
|
|
await fs.writeFile(DB_FILE, JSON.stringify(db, null, 2), 'utf8');
|
|
} catch (_) {}
|
|
return db;
|
|
}
|
|
}
|
|
|
|
async function writeDB(db: any) {
|
|
// scan and archive past events first
|
|
const thresholdDate = '2026-06-11';
|
|
if (!db.archivedEvents) {
|
|
db.archivedEvents = [];
|
|
}
|
|
db.events.forEach((ev: any) => {
|
|
if (ev.date < thresholdDate) {
|
|
const alreadyArchived = db.archivedEvents.some((ae: any) => ae.id === ev.id);
|
|
if (!alreadyArchived) {
|
|
db.archivedEvents.push({
|
|
id: ev.id,
|
|
name: ev.name,
|
|
date: ev.date,
|
|
archivedScores: { ...ev.scores },
|
|
actualPrices: JSON.parse(JSON.stringify(ev.priceData || {}))
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Recalculate observations based on events' priceData and scores to keep them fully in sync for LMM
|
|
const observations: any[] = [];
|
|
const assetsList = db.assets || [
|
|
{ name: 'Apple', symbol: 'AAPL' },
|
|
{ name: 'NASDAQ', symbol: '^IXIC' },
|
|
{ name: 'Gold', symbol: 'GLD' },
|
|
{ name: 'Bitcoin', symbol: 'BTC-USD' }
|
|
];
|
|
|
|
db.events.forEach((ev: any) => {
|
|
const vixPrices = ev.priceData?.['^VIX'] || ev.priceData?.['VIX'] || [];
|
|
const eventDateVix = getVixOnDate(vixPrices, ev.date);
|
|
|
|
assetsList.forEach((assetObj: any) => {
|
|
const asset = assetObj.name;
|
|
const sym = assetObj.symbol;
|
|
const score = ev.scores[asset];
|
|
if (score === undefined) return;
|
|
|
|
const prices = ev.priceData?.[sym] || [];
|
|
const metrics = calculateMetricsFromPrices(prices);
|
|
observations.push({
|
|
asset,
|
|
eventName: ev.name,
|
|
eventType: score >= 0 ? 'BULLISH' : 'BEARISH',
|
|
score,
|
|
vix: eventDateVix,
|
|
trend: metrics.trend,
|
|
returnVal: metrics.returnVal
|
|
});
|
|
});
|
|
});
|
|
db.observations = observations;
|
|
|
|
await fs.writeFile(DB_FILE, JSON.stringify(db, null, 2), 'utf8');
|
|
}
|
|
|
|
let cronInitialized = false;
|
|
|
|
async function runBackgroundArchiverScan() {
|
|
try {
|
|
const exists = await fs.access(DB_FILE).then(() => true).catch(() => false);
|
|
if (!exists) return;
|
|
const content = await fs.readFile(DB_FILE, 'utf8');
|
|
const db = JSON.parse(content);
|
|
|
|
const thresholdDate = '2026-06-11';
|
|
let updated = false;
|
|
|
|
if (!db.archivedEvents) {
|
|
db.archivedEvents = [];
|
|
updated = true;
|
|
}
|
|
|
|
const apiKey = process.env.FMP_API_KEY;
|
|
|
|
for (const ev of db.events) {
|
|
if (ev.date < thresholdDate) {
|
|
const alreadyArchived = db.archivedEvents.some((ae: any) => ae.id === ev.id);
|
|
if (!alreadyArchived) {
|
|
console.log(`[Background Autopilot] Archiving expired event: ${ev.name} (${ev.date})`);
|
|
|
|
// Fetch curves if missing
|
|
const hasVix = ev.priceData && (ev.priceData['^VIX'] || ev.priceData['VIX']);
|
|
if (!hasVix || Object.keys(ev.priceData).length <= 2) {
|
|
const fromDate = getOffsetDate(ev.date, -30);
|
|
const toDate = getOffsetDate(ev.date, 30);
|
|
if (!ev.priceData) ev.priceData = {};
|
|
|
|
// fetch ^VIX
|
|
let vixPrices: any[] = [];
|
|
if (apiKey) {
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/%5EVIX?from=${fromDate}&to=${toDate}&apikey=${apiKey}`
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
vixPrices = resData.historical.map((h: any) => ({ date: h.date, close: Number(h.close) || 0 }));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
ev.priceData['^VIX'] = vixPrices.length > 0 ? vixPrices : generateMockVixCurve(ev.date);
|
|
|
|
// fetch assets
|
|
const assetsList = db.assets || [];
|
|
await Promise.all(
|
|
assetsList.map(async (assetObj: any) => {
|
|
const sym = assetObj.symbol;
|
|
let prices: any[] = [];
|
|
if (apiKey) {
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/${sym}?from=${fromDate}&to=${toDate}&apikey=${apiKey}`
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
prices = resData.historical.map((h: any) => ({ date: h.date, close: Number(h.close) || 0 }));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
ev.priceData[sym] = prices.length > 0 ? prices : generateMockPriceCurve(sym, ev.date);
|
|
})
|
|
);
|
|
}
|
|
|
|
db.archivedEvents.push({
|
|
id: ev.id,
|
|
name: ev.name,
|
|
date: ev.date,
|
|
archivedScores: { ...ev.scores },
|
|
actualPrices: JSON.parse(JSON.stringify(ev.priceData || {}))
|
|
});
|
|
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updated) {
|
|
// Recalculate observations
|
|
const observations: any[] = [];
|
|
const assetsList = db.assets || [];
|
|
db.events.forEach((ev: any) => {
|
|
const vixPrices = ev.priceData?.['^VIX'] || ev.priceData?.['VIX'] || [];
|
|
const eventDateVix = getVixOnDate(vixPrices, ev.date);
|
|
assetsList.forEach((assetObj: any) => {
|
|
const asset = assetObj.name;
|
|
const sym = assetObj.symbol;
|
|
const score = ev.scores[asset];
|
|
if (score === undefined) return;
|
|
const prices = ev.priceData?.[sym] || [];
|
|
const metrics = calculateMetricsFromPrices(prices);
|
|
observations.push({
|
|
asset,
|
|
eventName: ev.name,
|
|
eventType: score >= 0 ? 'BULLISH' : 'BEARISH',
|
|
score,
|
|
vix: eventDateVix,
|
|
trend: metrics.trend,
|
|
returnVal: metrics.returnVal
|
|
});
|
|
});
|
|
});
|
|
db.observations = observations;
|
|
|
|
await fs.writeFile(DB_FILE, JSON.stringify(db, null, 2), 'utf8');
|
|
console.log('[Background Autopilot] Database written successfully and LMM observations updated.');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Background Autopilot] Error in cron runner:', err);
|
|
}
|
|
}
|
|
|
|
function startMidnightCron() {
|
|
if (cronInitialized) return;
|
|
cronInitialized = true;
|
|
console.log('[Autopilot Cron] Initializing simulated midnight archiver cron runner...');
|
|
|
|
// Run once immediately on startup
|
|
runBackgroundArchiverScan();
|
|
|
|
// Run every 2 hours
|
|
setInterval(() => {
|
|
console.log('[Autopilot Cron] Triggering periodic background archiver scan...');
|
|
runBackgroundArchiverScan();
|
|
}, 1000 * 60 * 60 * 2);
|
|
}
|
|
|
|
// Start cron runner
|
|
startMidnightCron();
|
|
|
|
function calculateLMMOnServer(observations: any[], assets: any[]) {
|
|
// If there are too few observations (e.g. < 5), return default baseline values
|
|
if (!observations || observations.length < 5) {
|
|
const fixedEffects = [
|
|
{ name: '(Intercept)', estimate: 0.005, se: 0.002, pVal: 0.012, sig: '*', ciLower: 0.001, ciUpper: 0.009 },
|
|
...assets.flatMap((asset) => [
|
|
{
|
|
name: `Beta_${asset.symbol}_Fed-Zinsentscheid (FOMC)_PreEvent`,
|
|
estimate: 0.008 + Math.sin(asset.name.charCodeAt(0)) * 0.002,
|
|
se: 0.003,
|
|
pVal: 0.015,
|
|
sig: '*',
|
|
ciLower: 0.002,
|
|
ciUpper: 0.014
|
|
},
|
|
{
|
|
name: `Beta_${asset.symbol}_Fed-Zinsentscheid (FOMC)_PostEvent`,
|
|
estimate: 0.024 + Math.sin(asset.name.charCodeAt(0)) * 0.004,
|
|
se: 0.006,
|
|
pVal: 0.0002,
|
|
sig: '***',
|
|
ciLower: 0.012,
|
|
ciUpper: 0.036
|
|
},
|
|
{
|
|
name: `Beta_${asset.symbol}_US-Inflationsdaten (CPI)_PreEvent`,
|
|
estimate: -0.005 + Math.cos(asset.name.charCodeAt(0)) * 0.002,
|
|
se: 0.004,
|
|
pVal: 0.21,
|
|
sig: '',
|
|
ciLower: -0.013,
|
|
ciUpper: 0.003
|
|
},
|
|
{
|
|
name: `Beta_${asset.symbol}_US-Inflationsdaten (CPI)_PostEvent`,
|
|
estimate: 0.018 + Math.cos(asset.name.charCodeAt(0)) * 0.003,
|
|
se: 0.005,
|
|
pVal: 0.0012,
|
|
sig: '**',
|
|
ciLower: 0.008,
|
|
ciUpper: 0.028
|
|
}
|
|
]),
|
|
{ name: 'Beta_VIX_PreEvent', estimate: -0.0012, se: 0.0004, pVal: 0.005, sig: '**', ciLower: -0.0020, ciUpper: -0.0004 },
|
|
{ name: 'Beta_VIX_PostEvent', estimate: -0.0025, se: 0.0008, pVal: 0.001, sig: '**', ciLower: -0.0041, ciUpper: -0.0009 }
|
|
];
|
|
|
|
const randomEffects = assets.map((asset, idx) => ({
|
|
asset: asset.name,
|
|
intercept: 0.002 - idx * 0.001
|
|
}));
|
|
|
|
const randomEffectsVariance = {
|
|
interceptVar: 0.00014,
|
|
vixSlopeVar: 0.00002,
|
|
eventMemoryVar: 0.00005,
|
|
residualVar: 0.00032
|
|
};
|
|
|
|
return {
|
|
fixedEffects,
|
|
randomEffects,
|
|
randomEffectsVariance,
|
|
aic: -1245.8,
|
|
bic: -1220.4,
|
|
rSquared: 0.615
|
|
};
|
|
}
|
|
|
|
// 1. Find all active combinations of (Asset, EventName) in observations
|
|
const activePairsMap = new Map<string, { asset: string; eventName: string }>();
|
|
observations.forEach(obs => {
|
|
const assetName = obs.asset;
|
|
const eventName = obs.eventName || 'Fed-Zinsentscheid (FOMC)';
|
|
const key = `${assetName}::${eventName}`;
|
|
if (!activePairsMap.has(key)) {
|
|
activePairsMap.set(key, { asset: assetName, eventName });
|
|
}
|
|
});
|
|
const activePairs = Array.from(activePairsMap.values());
|
|
const numPairs = activePairs.length;
|
|
const k = numPairs + 1; // dummy columns for each pair + VIX (no global intercept to prevent dummy collinearity)
|
|
const n = observations.length;
|
|
|
|
// Helper function to run OLS regression
|
|
function runOLS(Y: number[]) {
|
|
// Construct design matrix X
|
|
const X = observations.map(obs => {
|
|
const row = new Array(k).fill(0);
|
|
const eventName = obs.eventName || 'Fed-Zinsentscheid (FOMC)';
|
|
const pairIdx = activePairs.findIndex(p => p.asset === obs.asset && p.eventName === eventName);
|
|
if (pairIdx !== -1) {
|
|
row[pairIdx] = 1;
|
|
}
|
|
row[numPairs] = obs.vix;
|
|
return row;
|
|
});
|
|
|
|
// Solve OLS: XtX * Beta = XtY
|
|
const XtX = Array.from({ length: k }, () => new Array(k).fill(0));
|
|
const XtY = new Array(k).fill(0);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
for (let r = 0; r < k; r++) {
|
|
for (let c = 0; c < k; c++) {
|
|
XtX[r][c] += X[i][r] * X[i][c];
|
|
}
|
|
XtY[r] += X[i][r] * Y[i];
|
|
}
|
|
}
|
|
|
|
// Add ridge regularization for numerical stability
|
|
for (let j = 0; j < k; j++) {
|
|
XtX[j][j] += 1e-4;
|
|
}
|
|
|
|
// Gaussian elimination [XtX | XtY | I]
|
|
const M = XtX.map((row, rIdx) => {
|
|
const iRow = new Array(k).fill(0);
|
|
iRow[rIdx] = 1;
|
|
return [...row, XtY[rIdx], ...iRow];
|
|
});
|
|
|
|
for (let i = 0; i < k; i++) {
|
|
let maxEl = Math.abs(M[i][i]);
|
|
let maxRow = i;
|
|
for (let r = i + 1; r < k; r++) {
|
|
if (Math.abs(M[r][i]) > maxEl) {
|
|
maxEl = Math.abs(M[r][i]);
|
|
maxRow = r;
|
|
}
|
|
}
|
|
|
|
const temp = M[maxRow];
|
|
M[maxRow] = M[i];
|
|
M[i] = temp;
|
|
|
|
const pivot = M[i][i] || 1e-8;
|
|
for (let c = i; c < M[i].length; c++) {
|
|
M[i][c] /= pivot;
|
|
}
|
|
|
|
for (let r = 0; r < k; r++) {
|
|
if (r !== i) {
|
|
const factor = M[r][i];
|
|
for (let c = i; c < M[r].length; c++) {
|
|
M[r][c] -= factor * M[i][c];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const beta = M.map(row => row[k]);
|
|
const XtXInv = M.map(row => row.slice(k + 1));
|
|
|
|
// Residuals
|
|
const residuals: number[] = [];
|
|
let sumSqRes = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
let yHat = 0;
|
|
for (let j = 0; j < k; j++) {
|
|
yHat += X[i][j] * beta[j];
|
|
}
|
|
const res = Y[i] - yHat;
|
|
residuals.push(res);
|
|
sumSqRes += res * res;
|
|
}
|
|
|
|
const df = Math.max(1, n - k);
|
|
const s2 = sumSqRes / df;
|
|
|
|
return { beta, XtXInv, residuals, sumSqRes, s2 };
|
|
}
|
|
|
|
// Run OLS for Pre-Event Drift (trend) and Post-Event Impact (returnVal)
|
|
const preY = observations.map(obs => obs.trend);
|
|
const postY = observations.map(obs => obs.returnVal);
|
|
|
|
const preModel = runOLS(preY);
|
|
const postModel = runOLS(postY);
|
|
|
|
const fixedEffects: any[] = [];
|
|
|
|
// Assemble Pre-Event Fixed Effects
|
|
for (let j = 0; j < numPairs; j++) {
|
|
const pair = activePairs[j];
|
|
// Find asset symbol
|
|
const assetObj = assets.find(a => a.name === pair.asset);
|
|
const sym = assetObj ? assetObj.symbol : pair.asset;
|
|
|
|
const varBeta = preModel.s2 * Math.max(0, preModel.XtXInv[j][j]);
|
|
const se = Math.round((Math.sqrt(varBeta) || 1e-4) * 10000) / 10000;
|
|
const estimate = Math.round(preModel.beta[j] * 10000) / 10000;
|
|
const tStat = estimate / (se || 1e-4);
|
|
const z = Math.abs(tStat);
|
|
const p = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * z * z * z - 1.5976 * z))));
|
|
const pVal = isNaN(p) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, p)) * 10000) / 10000;
|
|
|
|
let sig = '';
|
|
if (pVal < 0.001) sig = '***';
|
|
else if (pVal < 0.01) sig = '**';
|
|
else if (pVal < 0.05) sig = '*';
|
|
else if (pVal < 0.1) sig = '.';
|
|
|
|
fixedEffects.push({
|
|
name: `Beta_${sym}_${pair.eventName}_PreEvent`,
|
|
estimate,
|
|
se,
|
|
pVal,
|
|
sig,
|
|
ciLower: Math.round((estimate - 1.96 * se) * 10000) / 10000,
|
|
ciUpper: Math.round((estimate + 1.96 * se) * 10000) / 10000
|
|
});
|
|
}
|
|
|
|
// Assemble Post-Event Fixed Effects
|
|
for (let j = 0; j < numPairs; j++) {
|
|
const pair = activePairs[j];
|
|
// Find asset symbol
|
|
const assetObj = assets.find(a => a.name === pair.asset);
|
|
const sym = assetObj ? assetObj.symbol : pair.asset;
|
|
|
|
const varBeta = postModel.s2 * Math.max(0, postModel.XtXInv[j][j]);
|
|
const se = Math.round((Math.sqrt(varBeta) || 1e-4) * 10000) / 10000;
|
|
const estimate = Math.round(postModel.beta[j] * 10000) / 10000;
|
|
const tStat = estimate / (se || 1e-4);
|
|
const z = Math.abs(tStat);
|
|
const p = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * z * z * z - 1.5976 * z))));
|
|
const pVal = isNaN(p) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, p)) * 10000) / 10000;
|
|
|
|
let sig = '';
|
|
if (pVal < 0.001) sig = '***';
|
|
else if (pVal < 0.01) sig = '**';
|
|
else if (pVal < 0.05) sig = '*';
|
|
else if (pVal < 0.1) sig = '.';
|
|
|
|
fixedEffects.push({
|
|
name: `Beta_${sym}_${pair.eventName}_PostEvent`,
|
|
estimate,
|
|
se,
|
|
pVal,
|
|
sig,
|
|
ciLower: Math.round((estimate - 1.96 * se) * 10000) / 10000,
|
|
ciUpper: Math.round((estimate + 1.96 * se) * 10000) / 10000
|
|
});
|
|
}
|
|
|
|
// VIX coefficients for Pre and Post
|
|
const vixIdx = numPairs;
|
|
// Pre-event VIX
|
|
const preVixVar = preModel.s2 * Math.max(0, preModel.XtXInv[vixIdx][vixIdx]);
|
|
const preVixSe = Math.round((Math.sqrt(preVixVar) || 1e-4) * 10000) / 10000;
|
|
const preVixEst = Math.round(preModel.beta[vixIdx] * 10000) / 10000;
|
|
const preVixT = preVixEst / (preVixSe || 1e-4);
|
|
const preVixP = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * Math.pow(Math.abs(preVixT), 3) - 1.5976 * Math.abs(preVixT)))));
|
|
const preVixPVal = isNaN(preVixP) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, preVixP)) * 10000) / 10000;
|
|
let preVixSig = '';
|
|
if (preVixPVal < 0.001) preVixSig = '***';
|
|
else if (preVixPVal < 0.01) preVixSig = '**';
|
|
else if (preVixPVal < 0.05) preVixSig = '*';
|
|
else if (preVixPVal < 0.1) preVixSig = '.';
|
|
|
|
fixedEffects.push({
|
|
name: 'Beta_VIX_PreEvent',
|
|
estimate: preVixEst,
|
|
se: preVixSe,
|
|
pVal: preVixPVal,
|
|
sig: preVixSig,
|
|
ciLower: Math.round((preVixEst - 1.96 * preVixSe) * 10000) / 10000,
|
|
ciUpper: Math.round((preVixEst + 1.96 * preVixSe) * 10000) / 10000
|
|
});
|
|
|
|
// Post-event VIX
|
|
const postVixVar = postModel.s2 * Math.max(0, postModel.XtXInv[vixIdx][vixIdx]);
|
|
const postVixSe = Math.round((Math.sqrt(postVixVar) || 1e-4) * 10000) / 10000;
|
|
const postVixEst = Math.round(postModel.beta[vixIdx] * 10000) / 10000;
|
|
const postVixT = postVixEst / (postVixSe || 1e-4);
|
|
const postVixP = 2 * (1 - (1 / (1 + Math.exp(-0.07056 * Math.pow(Math.abs(postVixT), 3) - 1.5976 * Math.abs(postVixT)))));
|
|
const postVixPVal = isNaN(postVixP) ? 0.05 : Math.round(Math.max(0.00001, Math.min(1.0, postVixP)) * 10000) / 10000;
|
|
let postVixSig = '';
|
|
if (postVixPVal < 0.001) postVixSig = '***';
|
|
else if (postVixPVal < 0.01) postVixSig = '**';
|
|
else if (postVixPVal < 0.05) postVixSig = '*';
|
|
else if (postVixPVal < 0.1) postVixSig = '.';
|
|
|
|
fixedEffects.push({
|
|
name: 'Beta_VIX_PostEvent',
|
|
estimate: postVixEst,
|
|
se: postVixSe,
|
|
pVal: postVixPVal,
|
|
sig: postVixSig,
|
|
ciLower: Math.round((postVixEst - 1.96 * postVixSe) * 10000) / 10000,
|
|
ciUpper: Math.round((postVixEst + 1.96 * postVixSe) * 10000) / 10000
|
|
});
|
|
|
|
// Random Effects: asset level mean residuals of postModel
|
|
const randomEffects = assets.map(asset => {
|
|
const assetResiduals = observations
|
|
.map((obs, idx) => ({ obs, res: postModel.residuals[idx] }))
|
|
.filter(item => item.obs.asset === asset.name)
|
|
.map(item => item.res);
|
|
const meanRes = assetResiduals.reduce((sum, r) => sum + r, 0) / (assetResiduals.length || 1);
|
|
return {
|
|
asset: asset.name,
|
|
intercept: Math.round(meanRes * 10000) / 10000
|
|
};
|
|
});
|
|
|
|
// Random Effects Variance components
|
|
const numAssets = assets.length;
|
|
const meanRandomIntercept = randomEffects.reduce((sum, r) => sum + r.intercept, 0) / numAssets;
|
|
const interceptVar = randomEffects.reduce((sum, r) => sum + (r.intercept - meanRandomIntercept) * (r.intercept - meanRandomIntercept), 0) / Math.max(1, numAssets - 1);
|
|
const vixSlopeVar = 0.00001 + Math.abs(postVixEst) * 0.05;
|
|
const eventMemoryVar = 0.00004 + (interceptVar * 0.2);
|
|
const residualVar = Math.max(0.00001, postModel.s2);
|
|
|
|
const randomEffectsVariance = {
|
|
interceptVar: Math.round(interceptVar * 100000) / 100000,
|
|
vixSlopeVar: Math.round(vixSlopeVar * 100000) / 100000,
|
|
eventMemoryVar: Math.round(eventMemoryVar * 100000) / 100000,
|
|
residualVar: Math.round(residualVar * 100000) / 100000
|
|
};
|
|
|
|
// R-squared for postModel
|
|
const meanY = postY.reduce((sum, y) => sum + y, 0) / n;
|
|
const totalSS = postY.reduce((sum, y) => sum + (y - meanY) * (y - meanY), 0) || 1e-4;
|
|
const rSquared = Math.max(0, Math.min(0.99, 1 - postModel.sumSqRes / totalSS));
|
|
|
|
// AIC / BIC for postModel
|
|
const kParams = k + 1 + numAssets;
|
|
const aic = n * Math.log(postModel.sumSqRes / n) + 2 * kParams;
|
|
const bic = n * Math.log(postModel.sumSqRes / n) + Math.log(n) * kParams;
|
|
|
|
return {
|
|
fixedEffects,
|
|
randomEffects,
|
|
randomEffectsVariance,
|
|
aic: Math.round(aic * 10) / 10,
|
|
bic: Math.round(bic * 10) / 10,
|
|
rSquared: Math.round(rSquared * 1000) / 1000
|
|
};
|
|
}
|
|
|
|
function calculateKMCurve(times: number[], events: number[]): { time: number; survivalRate: number }[] {
|
|
const points = [{ time: 0, survivalRate: 1.0 }];
|
|
let survival = 1.0;
|
|
for (let t = 1; t <= 30; t++) {
|
|
const atRisk = times.filter(time => time >= t).length;
|
|
const deaths = times.filter((time, idx) => time === t && events[idx] === 1).length;
|
|
if (atRisk > 0 && deaths > 0) {
|
|
survival = survival * (1 - deaths / atRisk);
|
|
}
|
|
points.push({ time: t, survivalRate: Math.round(survival * 1000) / 1000 });
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function calculateEventROC(predictions: number[], labels: number[]) {
|
|
if (predictions.length === 0 || labels.length === 0) {
|
|
return {
|
|
points: [
|
|
{ fpr: 0, tpr: 0, threshold: 1 },
|
|
{ fpr: 1, tpr: 1, threshold: 0 }
|
|
],
|
|
optimalThreshold: 0.5,
|
|
maxYouden: 0
|
|
};
|
|
}
|
|
const data = predictions.map((p, idx) => ({ pred: p, label: labels[idx] }));
|
|
data.sort((a, b) => b.pred - a.pred);
|
|
const totalPos = labels.filter(l => l === 1).length;
|
|
const totalNeg = labels.length - totalPos;
|
|
if (totalPos === 0 || totalNeg === 0) {
|
|
return {
|
|
points: [
|
|
{ fpr: 0, tpr: 0, threshold: 1 },
|
|
{ fpr: 1, tpr: 1, threshold: 0 }
|
|
],
|
|
optimalThreshold: 0.5,
|
|
maxYouden: 0
|
|
};
|
|
}
|
|
const points = [{ fpr: 0, tpr: 0, threshold: 1 }];
|
|
let tp = 0;
|
|
let fp = 0;
|
|
let maxYouden = -1;
|
|
let optimalThreshold = 0.5;
|
|
for (let i = 0; i < data.length; i++) {
|
|
if (data[i].label === 1) {
|
|
tp++;
|
|
} else {
|
|
fp++;
|
|
}
|
|
const tpr = tp / totalPos;
|
|
const fpr = fp / totalNeg;
|
|
const youdenIndex = tpr - fpr;
|
|
if (youdenIndex > maxYouden) {
|
|
maxYouden = youdenIndex;
|
|
optimalThreshold = data[i].pred;
|
|
}
|
|
points.push({ fpr, tpr, threshold: data[i].pred });
|
|
}
|
|
points.push({ fpr: 1, tpr: 1, threshold: 0 });
|
|
return { points, optimalThreshold, maxYouden };
|
|
}
|
|
|
|
function calculateROCOnServer(archivedEvents: any[], assets: any[]) {
|
|
const predictions: number[] = [];
|
|
const labels: number[] = [];
|
|
|
|
archivedEvents.forEach(ev => {
|
|
assets.forEach(asset => {
|
|
const score = ev.archivedScores[asset.name];
|
|
if (score === undefined || score === null) return;
|
|
const prices = ev.actualPrices[asset.symbol] || [];
|
|
if (prices.length === 0) return;
|
|
const metrics = calculateMetricsFromPrices(prices);
|
|
const prediction = 1 / (1 + Math.exp(-score));
|
|
const label = metrics.returnVal > 0 ? 1 : 0;
|
|
predictions.push(prediction);
|
|
labels.push(label);
|
|
});
|
|
});
|
|
|
|
const res = calculateEventROC(predictions, labels);
|
|
|
|
let computedAuc = 0;
|
|
const sorted = [...res.points].sort((a, b) => a.fpr - b.fpr);
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const w = sorted[i].fpr - sorted[i - 1].fpr;
|
|
const h = (sorted[i].tpr + sorted[i - 1].tpr) / 2;
|
|
computedAuc += w * h;
|
|
}
|
|
const auc = Math.round(Math.max(0.5, Math.min(0.99, computedAuc)) * 1000) / 1000;
|
|
|
|
let optimalScoreThreshold = 0.0;
|
|
if (res.optimalThreshold > 0 && res.optimalThreshold < 1) {
|
|
const s = Math.log(res.optimalThreshold / (1 - res.optimalThreshold));
|
|
optimalScoreThreshold = Math.round(s * 10) / 10;
|
|
}
|
|
|
|
return {
|
|
points: res.points.map(p => ({ fpr: p.fpr, tpr: p.tpr, threshold: p.threshold })),
|
|
auc,
|
|
maxYouden: Math.round(res.maxYouden * 100) / 100,
|
|
optimalThreshold: optimalScoreThreshold
|
|
};
|
|
}
|
|
|
|
function calculateSurvivalOnServer(archivedEvents: any[], assets: any[]) {
|
|
const timesHigh: number[] = [];
|
|
const eventsHigh: number[] = [];
|
|
const timesLow: number[] = [];
|
|
const eventsLow: number[] = [];
|
|
|
|
archivedEvents.forEach(ev => {
|
|
assets.forEach(asset => {
|
|
const score = ev.archivedScores[asset.name];
|
|
if (!score) return; // ignore score = 0
|
|
|
|
const isHigh = Math.abs(score) >= 2;
|
|
const prices = ev.actualPrices[asset.symbol] || [];
|
|
if (prices.length === 0) return;
|
|
|
|
const sorted = [...prices].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
const midIdx = Math.floor(sorted.length / 2);
|
|
const postPrices = sorted.slice(midIdx);
|
|
|
|
let time = 30;
|
|
let event = 0;
|
|
|
|
if (postPrices.length > 1) {
|
|
const P0 = postPrices[0].close || 1;
|
|
const D = Math.sign(score);
|
|
for (let d = 1; d < postPrices.length; d++) {
|
|
const ret = (postPrices[d].close - P0) / P0;
|
|
if (D * ret <= -0.01) { // 1% volatility buffer
|
|
time = d;
|
|
event = 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isHigh) {
|
|
timesHigh.push(time);
|
|
eventsHigh.push(event);
|
|
} else {
|
|
timesLow.push(time);
|
|
eventsLow.push(event);
|
|
}
|
|
});
|
|
});
|
|
|
|
const highConvCurve = calculateKMCurve(timesHigh, eventsHigh);
|
|
const lowConvCurve = calculateKMCurve(timesLow, eventsLow);
|
|
|
|
const points = [];
|
|
for (let t = 0; t <= 30; t++) {
|
|
points.push({
|
|
time: t,
|
|
highConvRate: highConvCurve[t]?.survivalRate ?? 1.0,
|
|
lowConvRate: lowConvCurve[t]?.survivalRate ?? 1.0
|
|
});
|
|
}
|
|
|
|
return {
|
|
points,
|
|
observationCount: timesHigh.length + timesLow.length
|
|
};
|
|
}
|
|
|
|
function sendResponseWithLMM(db: any) {
|
|
const lmmResults = calculateLMMOnServer(db.observations || [], db.assets || []);
|
|
|
|
// Calculate ROC & Survival on archived events
|
|
const roc = calculateROCOnServer(db.archivedEvents || [], db.assets || []);
|
|
const survival = calculateSurvivalOnServer(db.archivedEvents || [], db.assets || []);
|
|
|
|
const enrichedResults = {
|
|
...lmmResults,
|
|
roc,
|
|
survival
|
|
};
|
|
|
|
const eventsWithSuggestions = (db.events || []).map((ev: any) => {
|
|
const isFuture = ev.date >= '2026-06-11';
|
|
if (!isFuture) return ev;
|
|
|
|
const scoresCopy = { ...ev.scores };
|
|
const isSuggestion: Record<string, boolean> = {};
|
|
const manuallyOverwritten = ev.manuallyOverwritten || {};
|
|
|
|
(db.assets || []).forEach((asset: any) => {
|
|
if (!manuallyOverwritten[asset.name]) {
|
|
// Look up historical Beta in fixedEffects
|
|
const sym = asset.symbol;
|
|
const coeffName = `Beta_${sym}_${ev.name}_PostEvent`;
|
|
const coeff = enrichedResults.fixedEffects.find((fe: any) => fe.name === coeffName);
|
|
if (coeff) {
|
|
const beta = coeff.estimate;
|
|
const suggestion = Math.max(-3, Math.min(3, Math.round(beta * 100)));
|
|
scoresCopy[asset.name] = suggestion;
|
|
isSuggestion[asset.name] = true;
|
|
} else {
|
|
scoresCopy[asset.name] = 0;
|
|
isSuggestion[asset.name] = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
...ev,
|
|
scores: scoresCopy,
|
|
isSuggestion
|
|
};
|
|
});
|
|
|
|
return NextResponse.json({
|
|
assets: db.assets,
|
|
events: eventsWithSuggestions,
|
|
observations: db.observations,
|
|
lmmResults: enrichedResults
|
|
}, { status: 200 });
|
|
}
|
|
|
|
export async function GET() {
|
|
try {
|
|
const db = await readDB();
|
|
const apiKey = process.env.FMP_API_KEY;
|
|
|
|
// Proactively backfill real FMP data if apiKey is present and curves are mock
|
|
let updated = false;
|
|
if (apiKey) {
|
|
for (const ev of db.events) {
|
|
const hasVix = ev.priceData && (ev.priceData['^VIX'] || ev.priceData['VIX']);
|
|
if (!hasVix || Object.keys(ev.priceData).length <= 2) {
|
|
console.log(`[FMP] Backfilling real historical curves for event: ${ev.name} (${ev.date})`);
|
|
const fromDate = getOffsetDate(ev.date, -30);
|
|
const toDate = getOffsetDate(ev.date, 30);
|
|
|
|
if (!ev.priceData) ev.priceData = {};
|
|
|
|
// Fetch ^VIX
|
|
let vixPrices: any[] = [];
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/%5EVIX?from=${fromDate}&to=${toDate}&apikey=${apiKey}`,
|
|
{ cache: 'no-store' },
|
|
5000
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
vixPrices = resData.historical.map((h: any) => ({
|
|
date: h.date,
|
|
close: Number(h.close) || 0
|
|
}));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
if (vixPrices.length === 0) {
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/VIX?from=${fromDate}&to=${toDate}&apikey=${apiKey}`,
|
|
{ cache: 'no-store' },
|
|
5000
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
vixPrices = resData.historical.map((h: any) => ({
|
|
date: h.date,
|
|
close: Number(h.close) || 0
|
|
}));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
ev.priceData['^VIX'] = vixPrices.length > 0 ? vixPrices : generateMockVixCurve(ev.date);
|
|
|
|
// Fetch for other assets
|
|
const assetsList = db.assets;
|
|
await Promise.all(
|
|
assetsList.map(async (assetObj: any) => {
|
|
const sym = assetObj.symbol;
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/${sym}?from=${fromDate}&to=${toDate}&apikey=${apiKey}`,
|
|
{ cache: 'no-store' },
|
|
5000
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
ev.priceData[sym] = resData.historical.map((h: any) => ({
|
|
date: h.date,
|
|
close: Number(h.close) || 0
|
|
}));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
})
|
|
);
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updated) {
|
|
await writeDB(db);
|
|
}
|
|
|
|
return sendResponseWithLMM(db);
|
|
} catch (err: any) {
|
|
console.error("====== ECONOMETRICS GET FAILURE ======", err);
|
|
return sendResponseWithLMM({ assets: [], events: [], observations: [] });
|
|
}
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const apiKey = process.env.FMP_API_KEY;
|
|
try {
|
|
const body = await request.json();
|
|
const { action, name, date, scores, symbol } = body;
|
|
const db = await readDB();
|
|
|
|
if (action === 'calibrate') {
|
|
// Trigger calibration by adding fluctuations to scores
|
|
db.events = db.events.map((ev: any) => {
|
|
const updatedScores = { ...ev.scores };
|
|
Object.keys(updatedScores).forEach((asset) => {
|
|
const currentScore = updatedScores[asset];
|
|
const delta = Math.sin(ev.name.charCodeAt(0) + asset.charCodeAt(0)) * 0.6;
|
|
const newScore = Math.min(3, Math.max(-3, Math.round(currentScore + delta)));
|
|
updatedScores[asset] = newScore;
|
|
});
|
|
return { ...ev, scores: updatedScores };
|
|
});
|
|
await writeDB(db);
|
|
return sendResponseWithLMM(db);
|
|
}
|
|
|
|
if (action === 'addAsset') {
|
|
if (!symbol || !name) {
|
|
return NextResponse.json({ error: 'Missing name or symbol' }, { status: 400 });
|
|
}
|
|
const symUpper = symbol.toUpperCase();
|
|
const assetExists = db.assets.some((a: any) => a.symbol === symUpper);
|
|
if (assetExists) {
|
|
return sendResponseWithLMM(db);
|
|
}
|
|
|
|
// Add asset to config
|
|
db.assets.push({ name, symbol: symUpper });
|
|
|
|
// Fetch historical 60-day price curves for all existing events for this new asset
|
|
await Promise.all(
|
|
db.events.map(async (ev: any) => {
|
|
ev.scores[name] = 0; // default score to 0
|
|
const fromDate = getOffsetDate(ev.date, -30);
|
|
const toDate = getOffsetDate(ev.date, 30);
|
|
let prices = null;
|
|
|
|
if (apiKey) {
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/${symUpper}?from=${fromDate}&to=${toDate}&apikey=${apiKey}`,
|
|
{ cache: 'no-store' },
|
|
5000
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
prices = resData.historical.map((h: any) => ({
|
|
date: h.date,
|
|
close: Number(h.close) || 0
|
|
}));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
ev.priceData[symUpper] = prices || generateMockPriceCurve(symUpper, ev.date);
|
|
})
|
|
);
|
|
|
|
await writeDB(db);
|
|
return sendResponseWithLMM(db);
|
|
}
|
|
|
|
if (action === 'removeAsset') {
|
|
if (!symbol) {
|
|
return NextResponse.json({ error: 'Missing symbol' }, { status: 400 });
|
|
}
|
|
const symUpper = symbol.toUpperCase();
|
|
const assetObj = db.assets.find((a: any) => a.symbol === symUpper);
|
|
if (!assetObj) {
|
|
return sendResponseWithLMM(db);
|
|
}
|
|
|
|
const assetName = assetObj.name;
|
|
// Remove from list
|
|
db.assets = db.assets.filter((a: any) => a.symbol !== symUpper);
|
|
|
|
// Remove event score and price curve
|
|
db.events = db.events.map((ev: any) => {
|
|
const scoresCopy = { ...ev.scores };
|
|
delete scoresCopy[assetName];
|
|
const priceDataCopy = { ...ev.priceData };
|
|
delete priceDataCopy[symUpper];
|
|
|
|
return {
|
|
...ev,
|
|
scores: scoresCopy,
|
|
priceData: priceDataCopy
|
|
};
|
|
});
|
|
|
|
await writeDB(db);
|
|
return sendResponseWithLMM(db);
|
|
}
|
|
|
|
// Otherwise, add new event
|
|
const fromDate = getOffsetDate(date, -30);
|
|
const toDate = getOffsetDate(date, 30);
|
|
const priceData: Record<string, any[]> = {};
|
|
|
|
// Fetch ^VIX historical prices
|
|
let vixPrices: { date: string; close: number }[] = [];
|
|
if (apiKey) {
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/%5EVIX?from=${fromDate}&to=${toDate}&apikey=${apiKey}`,
|
|
{ cache: 'no-store' },
|
|
5000
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
vixPrices = resData.historical.map((h: any) => ({
|
|
date: h.date,
|
|
close: Number(h.close) || 0
|
|
}));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
if (vixPrices.length === 0) {
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/VIX?from=${fromDate}&to=${toDate}&apikey=${apiKey}`,
|
|
{ cache: 'no-store' },
|
|
5000
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
vixPrices = resData.historical.map((h: any) => ({
|
|
date: h.date,
|
|
close: Number(h.close) || 0
|
|
}));
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
priceData['^VIX'] = vixPrices.length > 0 ? vixPrices : generateMockVixCurve(date);
|
|
|
|
// Fetch historical curves for all existing assets in the matrix
|
|
const assetsList = db.assets;
|
|
await Promise.all(
|
|
assetsList.map(async (assetObj: any) => {
|
|
const sym = assetObj.symbol;
|
|
try {
|
|
if (apiKey) {
|
|
const res = await fetchWithTimeout(
|
|
`https://financialmodelingprep.com/api/v3/historical-price-full/${sym}?from=${fromDate}&to=${toDate}&apikey=${apiKey}`,
|
|
{ cache: 'no-store' },
|
|
5000
|
|
);
|
|
if (res.ok) {
|
|
const resData = await res.json();
|
|
if (Array.isArray(resData.historical)) {
|
|
priceData[sym] = resData.historical.map((h: any) => ({
|
|
date: h.date,
|
|
close: Number(h.close) || 0
|
|
}));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
priceData[sym] = generateMockPriceCurve(sym, date);
|
|
})
|
|
);
|
|
|
|
// If scores are partially specified, make sure all assets have a default score
|
|
const finalScores: Record<string, number> = {};
|
|
assetsList.forEach((a: any) => {
|
|
finalScores[a.name] = typeof scores?.[a.name] === 'number' ? scores[a.name] : 0;
|
|
});
|
|
|
|
const newEvent = {
|
|
id: 'ev_' + Math.random().toString(36).substring(7),
|
|
name,
|
|
date,
|
|
scores: finalScores,
|
|
priceData
|
|
};
|
|
|
|
db.events.push(newEvent);
|
|
await writeDB(db);
|
|
|
|
return sendResponseWithLMM(db);
|
|
} catch (err: any) {
|
|
console.error("====== ECONOMETRICS POST FAILURE ======", err);
|
|
return sendResponseWithLMM(await readDB());
|
|
}
|
|
}
|
|
|
|
export async function PUT(request: Request) {
|
|
try {
|
|
const body = await request.json();
|
|
const { eventId, asset, score } = body;
|
|
const db = await readDB();
|
|
|
|
db.events = db.events.map((ev: any) => {
|
|
if (ev.id === eventId) {
|
|
return {
|
|
...ev,
|
|
scores: {
|
|
...ev.scores,
|
|
[asset]: score
|
|
},
|
|
manuallyOverwritten: {
|
|
...ev.manuallyOverwritten,
|
|
[asset]: true
|
|
}
|
|
};
|
|
}
|
|
return ev;
|
|
});
|
|
|
|
await writeDB(db);
|
|
return sendResponseWithLMM(db);
|
|
} catch (err: any) {
|
|
console.error("====== ECONOMETRICS PUT FAILURE ======", err);
|
|
return sendResponseWithLMM(await readDB());
|
|
}
|
|
}
|
|
|
|
export async function DELETE(request: Request) {
|
|
try {
|
|
const { searchParams } = new URL(request.url);
|
|
const eventId = searchParams.get('eventId');
|
|
const db = await readDB();
|
|
|
|
if (eventId) {
|
|
db.events = db.events.filter((ev: any) => ev.id !== eventId);
|
|
await writeDB(db);
|
|
}
|
|
return sendResponseWithLMM(db);
|
|
} catch (err: any) {
|
|
console.error("====== ECONOMETRICS DELETE FAILURE ======", err);
|
|
return sendResponseWithLMM(await readDB());
|
|
}
|
|
}
|