forecast-daily.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/bash
# Daily self-learning cycle for the forecast journal — WITHOUT the app running.
#
# Why this exists: `forecast_cycle` is only triggered from /api/health, i.e.
# whenever you happen to open the app. On the maintainer's instance that left
# multi-day holes (Aug 2026: entries on the 15th, 14th, 11th, 10th, 7th, 3rd,
# 2nd). The bands calibrate themselves on their own past errors, so an
# irregular cycle means irregular calibration.
#
# So this script does NOT talk to the HTTP API — it calls the engine directly
# and works whether or not the server is up. Schedule it once and the journal
# advances every day (see README > Forecasts).
#
# Three steps, in this order:
# 1. fresh quotes — without them there is nothing to settle matured forecasts against
# 2. learning cycle — settle matured bands, then record today's
# 3. risk radar — the daily reading, also previously tied to opening the app
#
# Best-effort: no network or a Yahoo hiccup is not a failure — it retries
# tomorrow. Always exits 0 so a scheduler does not flag the job as broken.
#
# Usage: ./forecast-daily.sh (honours FINANCE_PROJECT_DIR, like run.sh)
set -uo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_DIR="${KIST_LOG_DIR:-$APP_DIR/logs}"
mkdir -p "$LOG_DIR"
LOG="$LOG_DIR/forecast-$(date +%Y-%m-%d).log"
# Prefer the repo venv (matched to CI, see pytest.ini); fall back to system python.
PY="$APP_DIR/.venv/bin/python"
[ -x "$PY" ] || PY="$(command -v python3)"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] start (python: $PY)" >>"$LOG"
cd "$APP_DIR" || exit 0
"$PY" - >>"$LOG" 2>&1 <<'PYEOF'
import sys
sys.path.insert(0, "server")
import config
config.setup()
import engine_bridge # noqa: F401 — sets sys.path for the engine modules
import market
def step(name, fn):
try:
print(f" {name}: {fn()}")
except Exception as e:
print(f" {name}: ERROR {type(e).__name__}: {str(e)[:120]}")
step("quotes", lambda: {k: v for k, v in market.refresh_cache().items()
if k in ("rows", "stale_days", "data_through", "yahoo_topup")})
step("learning cycle", market.record_and_score_forecasts)
def radar():
import risk_radar
return risk_radar.snapshot()
step("risk radar", radar)
# Current self-calibration, so the log itself says whether learning is on track.
try:
s = market.forecast_selfscore()
for h in s.get("horizons", []):
print(f" coverage h={h['days']}d: {h['coverage_pct']}% "
f"(target {h['target_pct']}%, n={h['scored']}) -> {h['verdict']}")
except Exception as e:
print(f" selfscore: ERROR {type(e).__name__}: {str(e)[:120]}")
PYEOF
echo "[$(date '+%Y-%m-%d %H:%M:%S')] done" >>"$LOG"
# Logs older than 30 days serve no purpose.
find "$LOG_DIR" -name "forecast-*.log" -type f -mtime +30 -delete 2>/dev/null
exit 0