← back to Ken
src/lib/weather-pulse.js
97 lines
// weather-pulse.js — OPTIONAL free "weather-news pulse" signal for bertha (ken).
//
// Reads the NWS Area Forecast Discussion (AFD) text for a market's forecast office via
// r.jina.ai ($0, no API key) and returns a SMALL, CAPPED confidence nudge for CONTESTED
// weather markets only. This is the free agent-reach doctrine applied to weather: the
// underlying reader is `curl https://r.jina.ai/<nws-url>` (present on the box).
//
// HARD RAILS:
// - READ-ONLY. Never sizes or places an order; never touches risk.js or the live-money gates.
// - ADVISORY. Returns a nudge in [-CAP, +CAP] (default ±0.03) that model.js adds to p_yes,
// then re-clamps to [0.01, 0.99]. It cannot flip a confident market.
// - GATED. Disabled unless WEATHER_PULSE=1. Off => { nudge: 0 } (byte-identical behavior).
// - FAIL-SAFE. Any error / timeout / missing office / non-contested market => { nudge: 0 }.
// A reader failure must NEVER break a prediction.
//
// Env knobs: WEATHER_PULSE=1 (enable), WEATHER_PULSE_CAP (default 0.03),
// WEATHER_PULSE_CONTEST (default 0.10, the |p-0.5| band that counts as contested).
const CAP = Number(process.env.WEATHER_PULSE_CAP || 0.03);
const CONTEST = Number(process.env.WEATHER_PULSE_CONTEST || 0.10);
const enabled = () => process.env.WEATHER_PULSE === '1';
// Conservative directional read of AFD text toward "the event variable goes UP".
// Returns a score in [-1, 1]; 0 when the language is mixed/unclear (the common case).
// Deliberately modest — the value is the plumbing + transparency; tune the lexicon later.
function scoreAfd(text, variable) {
if (!text || text.length < 200) return 0;
const t = text.toLowerCase();
const up = ['warmer', 'above normal', 'above average', 'record heat', 'heat wave', 'ridge',
'wetter', 'heavy rain', 'above-normal precipitation', 'increasing', 'trending up',
'higher than', 'exceed'];
const down = ['cooler', 'below normal', 'below average', 'cold front', 'trough', 'drier',
'little to no', 'decreasing', 'trending down', 'lower than', 'unlikely to exceed'];
// Confidence words scale the signal; hedge words shrink it.
const conf = (t.match(/high confidence|confident|well[- ]advertised|strong signal/g) || []).length;
const hedge = (t.match(/uncertain|low confidence|spread|could|may|possible|difficult to|question/g) || []).length;
let s = 0;
for (const w of up) if (t.includes(w)) s += 1;
for (const w of down) if (t.includes(w)) s -= 1;
if (s === 0) return 0;
const dir = Math.sign(s);
// magnitude in [0,1]: base on hit count, dampened by hedging, boosted by confidence words
let mag = Math.min(1, Math.abs(s) / 6);
mag *= (1 + Math.min(0.5, conf * 0.1)) / (1 + Math.min(1, hedge * 0.15));
return Math.max(-1, Math.min(1, dir * mag));
}
// Resolve an NWS office code from a city name, reusing ken's own weather.js helpers.
// city -> {lat,lon} (getCityCoords) -> NWS /points -> properties.gridId (the office).
// Returns null on any miss/failure (caller then no-ops).
async function resolveOffice(location) {
try {
const w = require('./weather');
const coords = w.getCityCoords ? w.getCityCoords(location) : null;
if (!coords || coords.lat == null) return null;
const point = await w.getGridPoint(coords.lat, coords.lon);
return point?.properties?.gridId || null;
} catch { return null; }
}
// Fetch the AFD text for an NWS office (e.g. "OKX"). Returns '' on any failure.
async function fetchAfd(office) {
const nws = `https://forecast.weather.gov/product.php?site=${office}&issuedby=${office}` +
`&product=AFD&format=txt&version=1&glossary=0`;
const url = `https://r.jina.ai/${nws}`;
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) throw new Error('afd_http_' + res.status);
return await res.text();
}
/**
* @param {object} a
* @param {number} a.pYes current model probability [0,1]
* @param {string} a.variable e.g. "tmax"
* @param {string} [a.office] NWS office code (e.g. "OKX"); resolved from location if absent
* @param {string} [a.location] city name (e.g. "New York") to resolve the office from
* @returns {Promise<{nudge:number, reason:string, score?:number, office?:string}>}
*/
async function weatherPulse({ pYes, variable, office, location } = {}) {
try {
if (!enabled()) return { nudge: 0, reason: 'disabled' };
if (typeof pYes !== 'number' || Math.abs(pYes - 0.5) >= CONTEST)
return { nudge: 0, reason: 'not_contested' };
if (!office && location) office = await resolveOffice(location);
if (!office) return { nudge: 0, reason: 'no_office' };
const text = await fetchAfd(office);
const s = scoreAfd(text, variable); // [-1, 1]
const nudge = Math.max(-CAP, Math.min(CAP, s * CAP));
return { nudge: Math.round(nudge * 1000) / 1000, reason: 'afd', score: Math.round(s * 100) / 100, office };
} catch (e) {
return { nudge: 0, reason: 'error:' + (e && e.message ? e.message : 'x') };
}
}
module.exports = { weatherPulse, scoreAfd };