189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""
|
|
Macro series log — tracks changes to actual_value and forecast_value over time.
|
|
Called after each calendar sync to detect and persist revisions.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _parse_num(v) -> Optional[float]:
|
|
"""Convert FF text value (e.g. '178K', '0.3%', '-92K') to float in display unit.
|
|
Strips unit suffix without multiplying — '114K' → 114.0, '0.3%' → 0.3.
|
|
Matches JavaScript parseFloat behaviour so stored values match chart display."""
|
|
if v is None:
|
|
return None
|
|
s = str(v).strip().replace('%', '').replace(',', '')
|
|
if s and s[-1].upper() in ('K', 'M', 'B'):
|
|
s = s[:-1].strip()
|
|
try:
|
|
return float(s)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def log_if_changed(conn, series_id: str, event_name: str, event_date: str,
|
|
actual_value, forecast_value, previous_value,
|
|
currency: str = '', impact: str = '',
|
|
source: str = 'calendar_sync') -> bool:
|
|
"""
|
|
Compare (actual, forecast) against the last log entry for this series+date.
|
|
Insert a new row only if something changed or it's the first time we see it.
|
|
Returns True if a row was inserted.
|
|
"""
|
|
actual_num = _parse_num(actual_value)
|
|
forecast_num = _parse_num(forecast_value)
|
|
previous_num = _parse_num(previous_value)
|
|
|
|
# Nothing to log if both actual and forecast are None
|
|
if actual_num is None and forecast_num is None:
|
|
return False
|
|
|
|
last = conn.execute(
|
|
"""SELECT actual_value, forecast_value FROM macro_series_log
|
|
WHERE series_id=? AND event_date=?
|
|
ORDER BY logged_at DESC LIMIT 1""",
|
|
(series_id, event_date)
|
|
).fetchone()
|
|
|
|
if last is None:
|
|
changed = 'new'
|
|
else:
|
|
prev_actual = last['actual_value']
|
|
prev_forecast = last['forecast_value']
|
|
actual_changed = actual_num != prev_actual and actual_num is not None
|
|
forecast_changed = forecast_num != prev_forecast and forecast_num is not None
|
|
if not actual_changed and not forecast_changed:
|
|
return False
|
|
if actual_changed and forecast_changed:
|
|
changed = 'both'
|
|
elif actual_changed:
|
|
changed = 'actual'
|
|
else:
|
|
changed = 'forecast'
|
|
|
|
conn.execute(
|
|
"""INSERT INTO macro_series_log
|
|
(series_id, event_name, event_date, actual_value, forecast_value,
|
|
previous_value, currency, impact, logged_at, source, changed)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(series_id, event_name or '', event_date,
|
|
actual_num, forecast_num, previous_num,
|
|
currency or '', impact or '',
|
|
datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
|
|
source, changed)
|
|
)
|
|
conn.commit()
|
|
return True
|
|
|
|
|
|
def backfill_from_ff_calendar(conn) -> dict:
|
|
"""
|
|
Seed macro_series_log from existing ff_calendar rows (historical).
|
|
Only inserts rows that don't already exist (idempotent).
|
|
Filters by ff_currency to avoid multi-country contamination.
|
|
"""
|
|
currency_map = _series_currency_map()
|
|
|
|
rows = conn.execute(
|
|
"""SELECT series_id, event_name, event_date, actual_value, forecast_value,
|
|
previous_value, currency, impact, fetched_at
|
|
FROM ff_calendar
|
|
WHERE series_id IS NOT NULL AND series_id != ''
|
|
AND (actual_value IS NOT NULL OR forecast_value IS NOT NULL)
|
|
ORDER BY event_date ASC"""
|
|
).fetchall()
|
|
|
|
inserted = 0
|
|
for r in rows:
|
|
expected = currency_map.get(r['series_id'])
|
|
if expected and r['currency'] != expected:
|
|
continue
|
|
already = conn.execute(
|
|
"SELECT 1 FROM macro_series_log WHERE series_id=? AND event_date=? AND source='backfill' LIMIT 1",
|
|
(r['series_id'], r['event_date'])
|
|
).fetchone()
|
|
if already:
|
|
continue
|
|
|
|
actual_num = _parse_num(r['actual_value'])
|
|
forecast_num = _parse_num(r['forecast_value'])
|
|
previous_num = _parse_num(r['previous_value'])
|
|
if actual_num is None and forecast_num is None:
|
|
continue
|
|
|
|
# For past events use fetched_at (when we got the data); for future/no-actual use now
|
|
now_str = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
|
if actual_num is not None:
|
|
logged_at = r['fetched_at'] or r['event_date'] # historical: keep original timestamp
|
|
changed = 'actual'
|
|
else:
|
|
logged_at = now_str # forecast-only: logged_at = time of backfill run
|
|
changed = 'forecast'
|
|
|
|
conn.execute(
|
|
"""INSERT OR IGNORE INTO macro_series_log
|
|
(series_id, event_name, event_date, actual_value, forecast_value,
|
|
previous_value, currency, impact, logged_at, source, changed)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
|
(r['series_id'], r['event_name'] or '', r['event_date'],
|
|
actual_num, forecast_num, previous_num,
|
|
r['currency'] or '', r['impact'] or '',
|
|
logged_at, 'backfill', changed)
|
|
)
|
|
inserted += 1
|
|
|
|
conn.commit()
|
|
logger.info(f"[macro_series_log] backfill: {inserted} rows inserted from ff_calendar")
|
|
return {"inserted": inserted, "scanned": len(rows)}
|
|
|
|
|
|
def _series_currency_map() -> dict:
|
|
"""Return {series_id: ff_currency} for series that have a currency restriction."""
|
|
try:
|
|
from services.fred_bootstrap import FRED_SERIES
|
|
return {sid: meta['ff_currency'] for sid, meta in FRED_SERIES.items() if meta.get('ff_currency')}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def scan_and_log_all(conn, source: str = 'calendar_sync') -> dict:
|
|
"""
|
|
After a calendar sync, scan all ff_calendar rows with series_id and log any changes.
|
|
Filters by ff_currency to avoid multi-country flip-flop (e.g. US vs CA unemployment).
|
|
"""
|
|
currency_map = _series_currency_map()
|
|
|
|
rows = conn.execute(
|
|
"""SELECT series_id, event_name, event_date, actual_value, forecast_value,
|
|
previous_value, currency, impact
|
|
FROM ff_calendar
|
|
WHERE series_id IS NOT NULL AND series_id != ''
|
|
AND (actual_value IS NOT NULL OR forecast_value IS NOT NULL)"""
|
|
).fetchall()
|
|
|
|
logged = 0
|
|
for r in rows:
|
|
expected = currency_map.get(r['series_id'])
|
|
if expected and r['currency'] != expected:
|
|
continue
|
|
did_log = log_if_changed(
|
|
conn,
|
|
series_id=r['series_id'],
|
|
event_name=r['event_name'] or '',
|
|
event_date=r['event_date'],
|
|
actual_value=r['actual_value'],
|
|
forecast_value=r['forecast_value'],
|
|
previous_value=r['previous_value'],
|
|
currency=r['currency'] or '',
|
|
impact=r['impact'] or '',
|
|
source=source,
|
|
)
|
|
if did_log:
|
|
logged += 1
|
|
|
|
logger.info(f"[macro_series_log] scan_and_log_all: {logged} changes logged out of {len(rows)} rows")
|
|
return {"logged": logged, "scanned": len(rows)}
|