fix: preserve metrics with atomic persistence

This commit is contained in:
Hermes Agent
2026-07-26 23:07:30 +00:00
parent 661579abf9
commit 3b1bc9a2bf
4 changed files with 512 additions and 46 deletions
+73 -46
View File
@@ -13,6 +13,7 @@ import sys
import threading
import time
import traceback
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import requests
@@ -28,8 +29,36 @@ sys.path.insert(0, BASE_DIR)
from scrapers import fear_greed, price
from scoring import engine
from dashboard.persistence import (
append_daily_jsonl,
atomic_write_json,
load_json,
load_jsonl_tail,
merge_observation,
onchain_refresh_due,
)
app = FastAPI(title="Bitcoin Accumulation Zone Monitor")
_shutdown_event = threading.Event()
_background_threads = []
@asynccontextmanager
async def lifespan(_app):
"""Own background worker startup and graceful shutdown."""
_shutdown_event.clear()
scraper_thread = threading.Thread(target=scraper_loop, name="scraper-scheduler")
_background_threads.append(scraper_thread)
scraper_thread.start()
try:
yield
finally:
_shutdown_event.set()
for thread in list(_background_threads):
thread.join(timeout=30)
_background_threads.clear()
app = FastAPI(title="Bitcoin Accumulation Zone Monitor", lifespan=lifespan)
CONFIG_DIR = os.path.join(BASE_DIR, "config")
DATA_DIR = os.path.join(BASE_DIR, "data")
@@ -48,18 +77,11 @@ _last_error = None
# ── Cache management ──────────────────────────────────────────────────────
def load_cache():
if os.path.exists(CACHE_PATH):
try:
with open(CACHE_PATH) as f:
return json.load(f)
except Exception:
pass
return {}
return load_json(CACHE_PATH, {})
def save_cache(data):
with open(CACHE_PATH, "w") as f:
json.dump(data, f, indent=2, default=str)
atomic_write_json(CACHE_PATH, data)
def append_history(score_data):
@@ -73,23 +95,11 @@ def append_history(score_data):
for m in score_data.get("metrics", [])
},
}
with open(HISTORY_PATH, "a") as f:
f.write(json.dumps(entry) + "\n")
append_daily_jsonl(HISTORY_PATH, entry)
def load_history():
if not os.path.exists(HISTORY_PATH):
return []
entries = []
with open(HISTORY_PATH) as f:
for line in f:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except Exception:
pass
return entries
return load_jsonl_tail(HISTORY_PATH, limit=90)
# ── Background scraper ────────────────────────────────────────────────────
@@ -111,18 +121,35 @@ def run_scrape(force_full=False):
_scraper_running = True
try:
# Load existing cache to preserve on-chain data
existing_cache = load_cache()
metrics = {}
cycle_errors = []
# 1. Fear & Greed (fast API call)
# Fast metrics fail independently so partial outages retain last-known-good data.
log.info("Fetching Fear & Greed...")
metrics["fear_greed"] = fear_greed.fetch()
try:
metrics["fear_greed"] = merge_observation(
existing_cache.get("fear_greed"), fear_greed.fetch(), source="alternative.me"
)
except Exception as e:
cycle_errors.append(f"Fear & Greed: {e}")
metrics["fear_greed"] = merge_observation(
existing_cache.get("fear_greed"), None,
source="alternative.me", error=str(e),
)
# 2. BTC Price data (fast API calls)
log.info("Fetching BTC price...")
price_current = price.fetch_current()
metrics["price"] = price_current
try:
price_current = price.fetch_current()
metrics["price"] = merge_observation(
existing_cache.get("price"), price_current, source="coingecko"
)
except Exception as e:
cycle_errors.append(f"Price: {e}")
metrics["price"] = merge_observation(
existing_cache.get("price"), None, source="coingecko", error=str(e)
)
price_current = metrics["price"]
log.info("Fetching BTC ATH...")
ath_data = price.fetch_ath()
@@ -169,21 +196,27 @@ def run_scrape(force_full=False):
"active_address_momentum", "txcount_momentum", "nvt_price",
"vdd_multiple"]
has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys)
if force_full or not has_cached_onchain:
# Only do a full Playwright scrape if explicitly requested or no data exists
log.info("Scraping on-chain metrics from LookIntoBitcoin (full refresh requested)...")
refresh_onchain = force_full or onchain_refresh_due(existing_cache.get("_onchain_timestamp"))
if refresh_onchain:
log.info("Refreshing on-chain metrics (forced, missing, or TTL expired)...")
try:
from scrapers import lookintobitcoin
onchain = lookintobitcoin.scrape_all()
metrics.update(onchain)
try:
from scrapers import checkonchain
metrics.update(checkonchain.scrape_all())
onchain.update(checkonchain.scrape_all())
except Exception as e:
log.error("CheckOnChain scraping failed: %s\n%s", e, traceback.format_exc())
_last_error = f"CheckOnChain scraping failed: {e}"
cycle_errors.append(f"CheckOnChain: {e}")
checkonchain_keys = {"sopr", "sellside_risk", "active_address_momentum",
"txcount_momentum", "nvt_price", "vdd_multiple"}
for key in onchain_keys:
source = "checkonchain" if key in checkonchain_keys else "lookintobitcoin"
metrics[key] = merge_observation(
existing_cache.get(key), onchain.get(key), source=source,
error="metric missing from scrape",
)
metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat()
except Exception as e:
log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc())
@@ -224,7 +257,7 @@ def run_scrape(force_full=False):
log.warning("History update failed (non-critical): %s", e)
_last_update = datetime.now(timezone.utc).isoformat()
_last_error = None
_last_error = "; ".join(cycle_errors) if cycle_errors else None
log.info("Scrape cycle complete. Composite score: %s", scored["composite_score"])
except Exception as e:
@@ -241,16 +274,10 @@ def scraper_loop():
has_data = any(cache.get(k, {}).get("value") is not None
for k in ["puell_multiple", "mvrv_zscore", "nupl"])
run_scrape(force_full=not has_data) # Full only if no cached on-chain data
while True:
time.sleep(900) # 15 minutes
while not _shutdown_event.wait(900):
run_scrape() # Quick refresh only
# Start background scraper on import
_scraper_thread = threading.Thread(target=scraper_loop, daemon=True)
_scraper_thread.start()
# ── LLM Settings (preserved from original) ───────────────────────────────
class LLMSettingsUpdate(BaseModel):