BizzleBot 62e32fc655 feat: replace ML optimizer with on-chain accumulation zone monitor
Complete rewrite — replaces the ML-based signal optimizer with a transparent
on-chain metric monitoring dashboard. Scrapes 10 metrics from LookIntoBitcoin
(Playwright) and free APIs, scores each 0-10, composite 0-100.

Metrics: Fear & Greed, Puell Multiple, MVRV Z-Score, Drawdown from ATH,
Price vs 200W SMA, Reserve Risk, RHODL Ratio, NUPL, LTH Realized Price,
Hash Ribbons. Auto-refreshes every 15 minutes. Settings page preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:31:29 +00:00

266 lines
9.0 KiB
Python

"""Playwright scraper for LookIntoBitcoin / BitcoinMagazinePro charts."""
import logging
import traceback
log = logging.getLogger(__name__)
BASE_URL = "https://www.lookintobitcoin.com"
CHARTS = {
"puell_multiple": {
"path": "/charts/puell-multiple/",
"traces": ["Puell Multiple"],
},
"mvrv_zscore": {
"path": "/charts/mvrv-zscore/",
"traces": ["Z-Score"],
},
"reserve_risk": {
"path": "/charts/reserve-risk/",
"traces": ["Reserve Risk"],
},
"rhodl_ratio": {
"path": "/charts/rhodl-ratio/",
"traces": ["RHODL Ratio"],
},
"nupl": {
"path": "/charts/relative-unrealized-profit--loss/",
"traces": ["NUPL"],
},
"200w_sma": {
"path": "/charts/200-week-moving-average-heatmap/",
"traces": ["200 Week Moving Average"],
},
"lth_realized_price": {
"path": "/charts/long-term-holder-realized-price/",
"traces": ["Long-Term Holder Realized Price", "BTC Price"],
},
"hash_ribbons": {
"path": "/charts/hash-ribbons/",
"traces": None,
},
"pi_cycle_bottom": {
"path": "/charts/pi-cycle-top-bottom-indicator/",
"traces": None,
},
"lth_supply": {
"path": "/charts/long-term-holder-supply/",
"traces": None,
},
}
def scrape_chart(chart_path, timeout=25000):
"""Scrape a single chart from LookIntoBitcoin. Returns list of trace dicts or None."""
from playwright.sync_api import sync_playwright
store = {"data": None}
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
def handle_response(response):
if "_dash-update-component" in response.url:
try:
store["data"] = response.json()
except Exception:
pass
page.on("response", handle_response)
try:
page.goto(f"{BASE_URL}{chart_path}", timeout=timeout)
page.wait_for_timeout(6000)
except Exception as e:
log.warning("Navigation error for %s: %s", chart_path, e)
finally:
browser.close()
if store["data"]:
try:
return store["data"]["response"]["chart"]["figure"]["data"]
except (KeyError, TypeError):
# Try alternate response structures
try:
resp = store["data"]
if isinstance(resp, dict):
for key in resp:
val = resp[key]
if isinstance(val, dict) and "figure" in val:
return val["figure"]["data"]
if isinstance(val, dict) and "chart" in val:
return val["chart"]["figure"]["data"]
except Exception:
pass
return None
def _find_trace(traces, name):
"""Find a trace by name (case-insensitive partial match)."""
if not traces:
return None
name_lower = name.lower()
# First pass: exact or substring match
for t in traces:
trace_name = t.get("name", "").lower()
if name_lower in trace_name or trace_name in name_lower:
return t
# Second pass: check if all words in name appear in trace name
words = name_lower.split()
for t in traces:
trace_name = t.get("name", "").lower()
if all(w in trace_name for w in words):
return t
return None
def _get_latest_value(trace):
"""Get the most recent non-null y value from a trace."""
if not trace:
return None
y = trace.get("y", [])
for val in reversed(y):
if val is not None:
try:
return float(val)
except (ValueError, TypeError):
continue
return None
def _get_recent_values(trace, n=30):
"""Get the last n non-null values from a trace."""
if not trace:
return []
y = trace.get("y", [])
values = []
for val in reversed(y):
if val is not None:
try:
values.append(float(val))
except (ValueError, TypeError):
continue
if len(values) >= n:
break
values.reverse()
return values
def scrape_all():
"""Scrape all charts and return parsed metric values."""
results = {}
for metric_key, chart_info in CHARTS.items():
log.info("Scraping %s ...", metric_key)
try:
traces = scrape_chart(chart_info["path"])
if not traces:
log.warning("No data for %s", metric_key)
results[metric_key] = {"value": None, "error": "No data returned"}
continue
wanted = chart_info.get("traces")
if metric_key == "puell_multiple":
t = _find_trace(traces, "Puell Multiple")
val = _get_latest_value(t)
results[metric_key] = {
"value": val,
"recent": _get_recent_values(t),
}
elif metric_key == "mvrv_zscore":
t = _find_trace(traces, "Z-Score")
val = _get_latest_value(t)
results[metric_key] = {
"value": val,
"recent": _get_recent_values(t),
}
elif metric_key == "200w_sma":
t = _find_trace(traces, "200 Week Moving Average") or _find_trace(traces, "200 Week MA") or _find_trace(traces, "200W")
val = _get_latest_value(t)
# Also try to find BTC price trace
price_t = _find_trace(traces, "BTC Price") or _find_trace(traces, "Price")
price_val = _get_latest_value(price_t)
results[metric_key] = {
"value": val,
"btc_price": price_val,
"recent": _get_recent_values(t),
}
elif metric_key == "lth_realized_price":
lth_t = _find_trace(traces, "Long-Term Holder Realized Price") or _find_trace(traces, "LTH Realized Price") or _find_trace(traces, "LTH")
price_t = _find_trace(traces, "BTC Price") or _find_trace(traces, "Price")
lth_val = _get_latest_value(lth_t)
price_val = _get_latest_value(price_t)
results[metric_key] = {
"value": lth_val,
"btc_price": price_val,
"recent": _get_recent_values(lth_t),
}
elif metric_key == "hash_ribbons":
# Look for buy/sell signal traces or MA crossover
results[metric_key] = {
"traces": [
{"name": t.get("name", ""), "latest": _get_latest_value(t)}
for t in traces[:6]
],
"value": None,
}
# Try to detect buy signal from trace names/colors
for t in traces:
name = t.get("name", "").lower()
if "buy" in name or "signal" in name:
results[metric_key]["buy_signal"] = True
break
elif metric_key == "lth_supply":
# Get main supply trace
t = traces[0] if traces else None
for candidate in traces:
name = candidate.get("name", "").lower()
if "supply" in name or "lth" in name:
t = candidate
break
recent = _get_recent_values(t, 60)
# Determine trend: compare recent avg to older avg
trend = None
if len(recent) >= 30:
old_avg = sum(recent[:15]) / 15
new_avg = sum(recent[-15:]) / 15
trend = "increasing" if new_avg > old_avg else "decreasing"
results[metric_key] = {
"value": _get_latest_value(t),
"trend": trend,
"recent": _get_recent_values(t),
}
else:
# Generic: grab first non-layout trace with numeric data
t = None
if wanted:
for name in wanted:
t = _find_trace(traces, name)
if t:
break
if not t:
for candidate in traces:
y = candidate.get("y", [])
if y and any(v is not None for v in y[-10:]):
t = candidate
break
val = _get_latest_value(t)
results[metric_key] = {
"value": val,
"recent": _get_recent_values(t),
}
except Exception as e:
log.error("Error scraping %s: %s\n%s", metric_key, e, traceback.format_exc())
results[metric_key] = {"value": None, "error": str(e)}
return results