fix: preserve metrics with atomic persistence
This commit is contained in:
+73
-46
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user