#!/usr/bin/env python3
"""Market regime scorer — deterministic, no AI, meant to run on a cron.

Pulls a fixed set of free market indicators, each casts an integer vote on a
contrarian accumulation axis (positive = value/fear = good for longs, negative
= froth/greed = risk), sums the votes into a regime label, and writes
regime.json for a Freqtrade filter (or a status page) to read.

Votes are intentionally simple and fully commented so thresholds can be tuned
by hand. A failed source is EXCLUDED from the score and flagged loudly in the
output — a partial score is never presented as if it were complete.

Sources (all reachable from the Riyadh server, verified 2026-07-07):
  Fear & Greed   api.alternative.me
  BTC daily/fund Binance spot + futures
  AHR999         computed from 200 daily closes + coin-age model
  MVRV Z-score   bitcoin-data.com
  Stablecoin cap DefiLlama
"""
import json
import math
import os
import sys
import time
import urllib.request
from datetime import datetime, timezone

GENESIS = datetime(2009, 1, 3, tzinfo=timezone.utc)
UA = {"User-Agent": "Mozilla/5.0"}
OUT = "regime.json"
# 每次运行追加一行历史（regime.json 只是快照，无法用于回测回放；
# 攒够历史后可量化 6 因子闸门相对纯 200DMA 代理的增量，见 docs/market-regime.md）
HIST = "regime_history.jsonl"


def get(url, timeout=25):
    req = urllib.request.Request(url, headers=UA)
    for attempt in range(3):
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.load(r)
        except Exception:
            if attempt == 2:
                raise
            time.sleep(2 * (attempt + 1))


def bucket(value, bands):
    """bands: list of (upper_exclusive_or_None, vote, label); first match wins."""
    for upper, vote, label in bands:
        if upper is None or value < upper:
            return vote, label
    return 0, "?"


# --- indicators: each returns dict(name, value, display, vote, note) ---

def ind_fear_greed():
    d = get("https://api.alternative.me/fng/?limit=1")["data"][0]
    v = int(d["value"])
    vote, label = bucket(v, [
        (25, +2, "extreme fear"),   # contrarian: fear = accumulate
        (45, +1, "fear"),
        (55, 0, "neutral"),
        (75, -1, "greed"),
        (None, -2, "extreme greed"),
    ])
    return dict(name="Fear&Greed", value=v, display=str(v), vote=vote, note=label)


def ind_ahr999():
    rows = get("https://api.binance.com/api/v3/klines"
               "?symbol=BTCUSDT&interval=1d&limit=200")
    closes = [float(r[4]) for r in rows]
    price = closes[-1]
    geomean = math.exp(sum(math.log(c) for c in closes) / len(closes))
    age = (datetime.now(timezone.utc) - GENESIS).days
    fitted = 10 ** (5.84 * math.log10(age) - 17.01)  # coin-age valuation model
    ahr = (price / geomean) * (price / fitted)
    vote, label = bucket(ahr, [
        (0.45, +2, "bottom zone"),
        (1.2, +1, "DCA zone"),
        (5, 0, "neutral"),
        (None, -2, "top zone"),
    ])
    return dict(name="AHR999", value=round(ahr, 4), display=f"{ahr:.3f}",
                vote=vote, note=label)


def ind_200dma():
    rows = get("https://api.binance.com/api/v3/klines"
               "?symbol=BTCUSDT&interval=1d&limit=200")
    closes = [float(r[4]) for r in rows]
    dma = sum(closes) / len(closes)
    dev = (closes[-1] / dma - 1) * 100
    vote, label = bucket(dev, [
        (-15, +1, "deep below 200DMA"),
        (20, 0, "around 200DMA"),
        (None, -1, "stretched above 200DMA"),
    ])
    return dict(name="200DMA_dev", value=round(dev, 2), display=f"{dev:+.1f}%",
                vote=vote, note=label)


def ind_funding():
    d = get("https://fapi.binance.com/fapi/v1/premiumIndex?symbol=BTCUSDT")
    fr = float(d["lastFundingRate"]) * 100  # per 8h, in %
    vote, label = bucket(fr, [
        (-0.005, +1, "negative funding (bearish crowd)"),
        (0.03, 0, "normal funding"),
        (None, -1, "hot funding (crowded longs)"),
    ])
    return dict(name="BTC_funding", value=round(fr, 4), display=f"{fr:+.3f}%",
                vote=vote, note=label)


def ind_stablecoin():
    data = get("https://stablecoins.llama.fi/stablecoincharts/all")
    series = [float(p["totalCirculatingUSD"]["peggedUSD"]) for p in data]
    now, prev = series[-1], series[-31]  # ~30 daily points back
    chg = (now / prev - 1) * 100
    vote, label = bucket(chg, [
        (-0.5, -1, "supply shrinking (capital leaving)"),
        (0.5, 0, "supply flat"),
        (None, +1, "supply growing (fresh liquidity)"),
    ])
    return dict(name="Stablecoin_30d", value=round(chg, 2), display=f"{chg:+.2f}%",
                vote=vote, note=label, extra=f"${now/1e9:.1f}B")


def ind_mvrv():
    d = get("https://bitcoin-data.com/v1/mvrv-zscore/last")
    z = float(d["mvrvZscore"])
    vote, label = bucket(z, [
        (0, +2, "deep undervalued"),
        (2, +1, "accumulation"),
        (5, 0, "neutral"),
        (7, -1, "elevated"),
        (None, -2, "cycle top"),
    ])
    return dict(name="MVRV_Zscore", value=round(z, 4), display=f"{z:.2f}",
                vote=vote, note=label)


INDICATORS = [ind_fear_greed, ind_ahr999, ind_200dma,
              ind_funding, ind_stablecoin, ind_mvrv]


def label_for(score, n):
    # score is sum of votes; range roughly -9..+9 with all sources up
    if score >= 5:
        return "RISK_ON_STRONG"
    if score >= 2:
        return "RISK_ON"
    if score > -2:
        return "NEUTRAL"
    if score > -5:
        return "RISK_OFF"
    return "RISK_OFF_STRONG"


def main():
    results, errors, score = [], [], 0
    for fn in INDICATORS:
        try:
            r = fn()
            results.append(r)
            score += r["vote"]
        except Exception as e:
            errors.append({"name": fn.__name__.replace("ind_", ""),
                           "error": str(e)[:120]})

    regime = label_for(score, len(results))
    out = {
        "ts": datetime.now(timezone.utc).isoformat(),
        "regime": regime,
        "score": score,
        "sources_ok": len(results),
        "sources_failed": len(errors),
        "indicators": results,
        "errors": errors,
        # Freqtrade filter reads this: True = allow new longs
        "allow_long": regime in ("RISK_ON_STRONG", "RISK_ON", "NEUTRAL"),
    }
    # 原子替换，避免策略恰好读到正在写入的半截 JSON。
    tmp_out = f"{OUT}.tmp"
    with open(tmp_out, "w") as f:
        json.dump(out, f, indent=1)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp_out, OUT)

    hist_line = {
        "ts": out["ts"],
        "regime": regime,
        "score": score,
        "allow_long": out["allow_long"],
        "indicators": {r["name"]: {"value": r["value"], "vote": r["vote"]}
                       for r in results},
        "failed": [e["name"] for e in errors],
    }
    with open(HIST, "a") as f:
        f.write(json.dumps(hist_line) + "\n")

    tag = "  ⚠ DEGRADED" if errors else ""
    print(f"[{out['ts']}] REGIME: {regime}  score={score:+d}  "
          f"({len(results)}/{len(INDICATORS)} sources){tag}")
    for r in results:
        extra = f"  {r['extra']}" if r.get("extra") else ""
        print(f"  {r['vote']:+d}  {r['name']:<15} {r['display']:>9}  "
              f"{r['note']}{extra}")
    for e in errors:
        print(f"   ✗  {e['name']:<15} FAILED: {e['error']}")

    # fail loud: non-zero exit if any source is down, so cron mail/logs catch it
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()
