Session 15: Intelligence hardening — park factors, weather, Tank01 prefetch, pace factors, signal audit, founder pricing fix (1405 tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Weather service (Session 15).
|
||||
*
|
||||
* Open-Meteo proxy. No API key required (the service is free for
|
||||
* non-commercial use and sportsbook analytics is firmly non-
|
||||
* commercial intelligence). 5s hard timeout, 1h Redis cache,
|
||||
* graceful degrade (returns null on any failure — never throws,
|
||||
* never blocks the grade).
|
||||
*
|
||||
* Outputs are normalized to the units North-American bettors think
|
||||
* in: temperature in Fahrenheit, wind in mph. Open-Meteo's defaults
|
||||
* are Celsius + km/h, so we request the imperial units directly via
|
||||
* query params.
|
||||
*
|
||||
* Cache key: `weather:{lat}:{lon}:{hour}` — keyed by the current
|
||||
* UTC hour so two requests within the same hour hit cache. Slightly
|
||||
* less precise than a sliding TTL but matches Open-Meteo's hourly
|
||||
* forecast cadence and keeps cache churn bounded.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { cacheGet, cacheSet } = require('../utils/redis');
|
||||
|
||||
const BASE_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const HTTP_TIMEOUT_MS = 5_000;
|
||||
const CACHE_TTL_SEC = 3600; // 1h — Open-Meteo refreshes hourly
|
||||
|
||||
function currentHourBucket() {
|
||||
const d = new Date();
|
||||
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}${String(d.getUTCHours()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildKey(lat, lon) {
|
||||
// 2-decimal precision is enough for a city-scale lookup and
|
||||
// collapses neighboring venues onto the same cache key (no real-
|
||||
// world impact — they share the same weather).
|
||||
const latKey = Number(lat).toFixed(2);
|
||||
const lonKey = Number(lon).toFixed(2);
|
||||
return `weather:${latKey}:${lonKey}:${currentHourBucket()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* getWeather — fetch current weather conditions for a lat/lon.
|
||||
*
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @returns {Promise<{temp_f:number|null, wind_mph:number|null, wind_dir:number|null, precip_mm:number|null} | null>}
|
||||
*
|
||||
* Returns null on:
|
||||
* - invalid coordinates
|
||||
* - upstream timeout / 5xx
|
||||
* - missing fields in the response
|
||||
*
|
||||
* The grading engine and reasoning builder both treat null as "no
|
||||
* signal" — features are simply omitted from the prop's overlay.
|
||||
*/
|
||||
async function getWeather(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
|
||||
const cacheKey = buildKey(lat, lon);
|
||||
try {
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
} catch {
|
||||
// Redis hiccup — proceed to network.
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(BASE_URL, {
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
params: {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
current: 'temperature_2m,wind_speed_10m,wind_direction_10m,precipitation',
|
||||
temperature_unit: 'fahrenheit',
|
||||
wind_speed_unit: 'mph',
|
||||
precipitation_unit: 'mm', // mm is the universal precipitation unit
|
||||
},
|
||||
});
|
||||
const current = res.data?.current;
|
||||
if (!current) return null;
|
||||
const out = {
|
||||
temp_f: Number.isFinite(current.temperature_2m) ? current.temperature_2m : null,
|
||||
wind_mph: Number.isFinite(current.wind_speed_10m) ? current.wind_speed_10m : null,
|
||||
wind_dir: Number.isFinite(current.wind_direction_10m) ? current.wind_direction_10m : null,
|
||||
precip_mm: Number.isFinite(current.precipitation) ? current.precipitation : null,
|
||||
_fetched_at: new Date().toISOString(),
|
||||
};
|
||||
try { await cacheSet(cacheKey, out, CACHE_TTL_SEC); } catch { /* graceful */ }
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Open-Meteo down, timeout, 5xx — silently degrade.
|
||||
if (err && err.code !== 'ECONNABORTED') {
|
||||
console.warn('[weatherService] fetch failed:', err.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWeather,
|
||||
__internals: { BASE_URL, HTTP_TIMEOUT_MS, CACHE_TTL_SEC, buildKey, currentHourBucket },
|
||||
};
|
||||
Reference in New Issue
Block a user