Files
worldmonitor/api/macro-signals.js
Elie Habib c353cf2070 Reduce egress costs, add PWA support, fix Polymarket and Railway relay
Egress optimization:
- Add s-maxage + stale-while-revalidate to all API endpoints for Vercel CDN caching
- Add vercel.json with immutable caching for hashed assets
- Add gzip compression to sidecar responses >1KB
- Add gzip to Railway RSS responses (4 paths previously uncompressed)
- Increase polling intervals: markets/crypto 60s→120s, ETF/macro/stablecoins 60s→180s
- Remove hardcoded Railway URL from theater-posture.js (now env-var only)

PWA / Service Worker:
- Add vite-plugin-pwa with autoUpdate strategy
- Cache map tiles (CacheFirst), fonts (StaleWhileRevalidate), static assets
- NetworkOnly for all /api/* routes (real-time data must be fresh)
- Manual SW registration (web only, skip Tauri)
- Add offline fallback page
- Replace manual manifest with plugin-generated manifest

Polymarket fix:
- Route dev proxy through production Vercel (bypasses JA3 blocking)
- Add 4th fallback tier: production URL as absolute fallback

Desktop/Sidecar:
- Dual-backend cache (_upstash-cache.js): Redis cloud + in-memory+file desktop
- Settings window OK/Cancel redesign
- Runtime config and secret injection improvements
2026-02-14 19:53:04 +04:00

285 lines
11 KiB
JavaScript

export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_TTL = 300;
let cachedResponse = null;
let cacheTimestamp = 0;
async function fetchJSON(url, timeout = 8000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(id);
}
}
function rateOfChange(prices, days) {
if (!prices || prices.length < days + 1) return null;
const recent = prices[prices.length - 1];
const past = prices[prices.length - 1 - days];
if (!past || past === 0) return null;
return ((recent - past) / past) * 100;
}
function sma(prices, period) {
if (!prices || prices.length < period) return null;
const slice = prices.slice(-period);
return slice.reduce((a, b) => a + b, 0) / period;
}
function extractClosePrices(chart) {
try {
const result = chart?.chart?.result?.[0];
return result?.indicators?.quote?.[0]?.close?.filter(p => p != null) || [];
} catch {
return [];
}
}
function extractVolumes(chart) {
try {
const result = chart?.chart?.result?.[0];
return result?.indicators?.quote?.[0]?.volume?.filter(v => v != null) || [];
} catch {
return [];
}
}
function extractAlignedPriceVolume(chart) {
try {
const result = chart?.chart?.result?.[0];
const closes = result?.indicators?.quote?.[0]?.close || [];
const volumes = result?.indicators?.quote?.[0]?.volume || [];
const pairs = [];
for (let i = 0; i < closes.length; i++) {
if (closes[i] != null && volumes[i] != null) {
pairs.push({ price: closes[i], volume: volumes[i] });
}
}
return pairs;
} catch {
return [];
}
}
function buildFallbackResult() {
return {
timestamp: new Date().toISOString(),
verdict: 'UNKNOWN',
bullishCount: 0,
totalCount: 0,
signals: {
liquidity: { status: 'UNKNOWN', value: null, sparkline: [] },
flowStructure: { status: 'UNKNOWN', btcReturn5: null, qqqReturn5: null },
macroRegime: { status: 'UNKNOWN', qqqRoc20: null, xlpRoc20: null },
technicalTrend: {
status: 'UNKNOWN',
btcPrice: null,
sma50: null,
sma200: null,
vwap30d: null,
mayerMultiple: null,
sparkline: [],
},
hashRate: { status: 'UNKNOWN', change30d: null },
miningCost: { status: 'UNKNOWN' },
fearGreed: { status: 'UNKNOWN', value: null, history: [] },
},
meta: { qqqSparkline: [] },
unavailable: true,
};
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (req.method === 'OPTIONS') {
if (isDisallowedOrigin(req)) {
return new Response(null, { status: 403, headers: cors });
}
return new Response(null, { status: 204, headers: cors });
}
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { ...cors, 'Content-Type': 'application/json' } });
}
const now = Date.now();
if (cachedResponse && now - cacheTimestamp < CACHE_TTL * 1000) {
return new Response(JSON.stringify(cachedResponse), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=600` },
});
}
try {
const yahooBase = 'https://query1.finance.yahoo.com/v8/finance/chart';
const [jpyChart, btcChart, qqqChart, xlpChart, fearGreed, mempoolHash] = await Promise.allSettled([
fetchJSON(`${yahooBase}/JPY=X?range=1y&interval=1d`),
fetchJSON(`${yahooBase}/BTC-USD?range=1y&interval=1d`),
fetchJSON(`${yahooBase}/QQQ?range=1y&interval=1d`),
fetchJSON(`${yahooBase}/XLP?range=1y&interval=1d`),
fetchJSON('https://api.alternative.me/fng/?limit=30&format=json'),
fetchJSON('https://mempool.space/api/v1/mining/hashrate/1m'),
]);
const jpyPrices = jpyChart.status === 'fulfilled' ? extractClosePrices(jpyChart.value) : [];
const btcPrices = btcChart.status === 'fulfilled' ? extractClosePrices(btcChart.value) : [];
const btcVolumes = btcChart.status === 'fulfilled' ? extractVolumes(btcChart.value) : [];
const btcAligned = btcChart.status === 'fulfilled' ? extractAlignedPriceVolume(btcChart.value) : [];
const qqqPrices = qqqChart.status === 'fulfilled' ? extractClosePrices(qqqChart.value) : [];
const xlpPrices = xlpChart.status === 'fulfilled' ? extractClosePrices(xlpChart.value) : [];
// 1. Liquidity Signal (JPY 30d ROC)
const jpyRoc30 = rateOfChange(jpyPrices, 30);
const liquidityStatus = jpyRoc30 !== null
? (jpyRoc30 < -2 ? 'SQUEEZE' : 'NORMAL')
: 'UNKNOWN';
// 2. Flow Structure (BTC vs QQQ 5d return)
const btcReturn5 = rateOfChange(btcPrices, 5);
const qqqReturn5 = rateOfChange(qqqPrices, 5);
let flowStatus = 'UNKNOWN';
if (btcReturn5 !== null && qqqReturn5 !== null) {
const gap = btcReturn5 - qqqReturn5;
flowStatus = Math.abs(gap) > 5 ? 'PASSIVE GAP' : 'ALIGNED';
}
// 3. Macro Regime (QQQ/XLP 20d ROC)
const qqqRoc20 = rateOfChange(qqqPrices, 20);
const xlpRoc20 = rateOfChange(xlpPrices, 20);
let regimeStatus = 'UNKNOWN';
if (qqqRoc20 !== null && xlpRoc20 !== null) {
regimeStatus = qqqRoc20 > xlpRoc20 ? 'RISK-ON' : 'DEFENSIVE';
}
// 4. Technical Trend (BTC vs SMA50 + VWAP)
const btcSma50 = sma(btcPrices, 50);
const btcSma200 = sma(btcPrices, 200);
const btcCurrent = btcPrices.length > 0 ? btcPrices[btcPrices.length - 1] : null;
// Compute VWAP from aligned price/volume pairs (30d)
let btcVwap = null;
if (btcAligned.length >= 30) {
const last30 = btcAligned.slice(-30);
let sumPV = 0, sumV = 0;
for (const { price, volume } of last30) {
sumPV += price * volume;
sumV += volume;
}
if (sumV > 0) btcVwap = +(sumPV / sumV).toFixed(0);
}
let trendStatus = 'UNKNOWN';
let mayerMultiple = null;
if (btcCurrent && btcSma50) {
const aboveSma = btcCurrent > btcSma50 * 1.02;
const belowSma = btcCurrent < btcSma50 * 0.98;
const aboveVwap = btcVwap ? btcCurrent > btcVwap : null;
if (aboveSma && aboveVwap !== false) trendStatus = 'BULLISH';
else if (belowSma && aboveVwap !== true) trendStatus = 'BEARISH';
else trendStatus = 'NEUTRAL';
}
if (btcCurrent && btcSma200) {
mayerMultiple = +(btcCurrent / btcSma200).toFixed(2);
}
// 5. Hash Rate
let hashStatus = 'UNKNOWN';
let hashChange = null;
if (mempoolHash.status === 'fulfilled') {
const hr = mempoolHash.value?.hashrates || mempoolHash.value;
if (Array.isArray(hr) && hr.length >= 2) {
const recent = hr[hr.length - 1]?.avgHashrate || hr[hr.length - 1];
const older = hr[0]?.avgHashrate || hr[0];
if (recent && older && older > 0) {
hashChange = +((recent - older) / older * 100).toFixed(1);
hashStatus = hashChange > 3 ? 'GROWING' : hashChange < -3 ? 'DECLINING' : 'STABLE';
}
}
}
// 6. Mining Cost (hashrate-based model)
let miningStatus = 'UNKNOWN';
if (btcCurrent && hashChange !== null) {
miningStatus = btcCurrent > 60000 ? 'PROFITABLE' : btcCurrent > 40000 ? 'TIGHT' : 'SQUEEZE';
}
// 7. Fear & Greed
let fgValue = null;
let fgLabel = 'UNKNOWN';
let fgHistory = [];
if (fearGreed.status === 'fulfilled' && fearGreed.value?.data) {
const data = fearGreed.value.data;
const parsed = parseInt(data[0]?.value, 10);
fgValue = Number.isFinite(parsed) ? parsed : null;
fgLabel = data[0]?.value_classification || 'UNKNOWN';
fgHistory = data.slice(0, 30).map(d => ({
value: parseInt(d.value, 10),
date: new Date(parseInt(d.timestamp, 10) * 1000).toISOString().slice(0, 10),
})).reverse();
}
// Sparkline data
const btcSparkline = btcPrices.slice(-30);
const qqqSparkline = qqqPrices.slice(-30);
const jpySparkline = jpyPrices.slice(-30);
// Overall Verdict
let bullishCount = 0;
let totalCount = 0;
const signals = [
{ name: 'Liquidity', status: liquidityStatus, bullish: liquidityStatus === 'NORMAL' },
{ name: 'Flow Structure', status: flowStatus, bullish: flowStatus === 'ALIGNED' },
{ name: 'Macro Regime', status: regimeStatus, bullish: regimeStatus === 'RISK-ON' },
{ name: 'Technical Trend', status: trendStatus, bullish: trendStatus === 'BULLISH' },
{ name: 'Hash Rate', status: hashStatus, bullish: hashStatus === 'GROWING' },
{ name: 'Mining Cost', status: miningStatus, bullish: miningStatus === 'PROFITABLE' },
{ name: 'Fear & Greed', status: fgLabel, bullish: fgValue !== null && fgValue > 50 },
];
for (const s of signals) {
if (s.status !== 'UNKNOWN') {
totalCount++;
if (s.bullish) bullishCount++;
}
}
const verdict = totalCount === 0 ? 'UNKNOWN' : (bullishCount / totalCount >= 0.57 ? 'BUY' : 'CASH');
const result = {
timestamp: new Date().toISOString(),
verdict,
bullishCount,
totalCount,
signals: {
liquidity: { status: liquidityStatus, value: jpyRoc30 !== null ? +jpyRoc30.toFixed(2) : null, sparkline: jpySparkline },
flowStructure: { status: flowStatus, btcReturn5: btcReturn5 !== null ? +btcReturn5.toFixed(2) : null, qqqReturn5: qqqReturn5 !== null ? +qqqReturn5.toFixed(2) : null },
macroRegime: { status: regimeStatus, qqqRoc20: qqqRoc20 !== null ? +qqqRoc20.toFixed(2) : null, xlpRoc20: xlpRoc20 !== null ? +xlpRoc20.toFixed(2) : null },
technicalTrend: { status: trendStatus, btcPrice: btcCurrent, sma50: btcSma50 ? +btcSma50.toFixed(0) : null, sma200: btcSma200 ? +btcSma200.toFixed(0) : null, vwap30d: btcVwap, mayerMultiple, sparkline: btcSparkline },
hashRate: { status: hashStatus, change30d: hashChange },
miningCost: { status: miningStatus },
fearGreed: { status: fgLabel, value: fgValue, history: fgHistory },
},
meta: { qqqSparkline },
};
cachedResponse = result;
cacheTimestamp = now;
return new Response(JSON.stringify(result), {
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=600` },
});
} catch (err) {
const fallback = cachedResponse || buildFallbackResult();
cachedResponse = fallback;
cacheTimestamp = now;
return new Response(JSON.stringify(fallback), {
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=30, s-maxage=60, stale-while-revalidate=30' },
});
}
}