← 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 };