# -*- coding: utf-8 -*-
"""
factor_detail_charts.py — 因子走势交互图表 v3
- 主图：价格+总分默认显示，因子按需叠加
- 开仓/平仓/止盈/止损标注在价格线上（竖线+标签）
- 时间段：快捷按钮 + datetime框 + 鼠标拖选
- 相关系数面板随时间段实时更新
"""
import re, math, json, os, sys, subprocess, requests
from datetime import datetime, timezone

_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _ROOT)
_SYMBOL = sys.argv[1] if len(sys.argv) > 1 else "BTC"
LOG   = os.path.join(_ROOT, "my_trader.log")
OUT   = os.path.join(_ROOT, "dashboard", {"BTC":"strategy_a.html","XAU":"strategy_b.html"}.get(_SYMBOL,"strategy_"+_SYMBOL.lower()+".html"))
MAXN  = 6000

if _SYMBOL == "XAU":
    FK = ['XTR','XMF','XMA','XMO','XVL','XOI','XCF','XVX','XRD','XDX','XEF','XCR','XRSI','XBB','XEX']
    FN = {'XTR':'趋势','XMF':'多周期共振','XMA':'均线排列','XMO':'动量(ROC)','XVL':'成交量','XOI':'持仓变化','XCF':'COT持仓','XVX':'量价背离','XRD':'实际利率','XDX':'美元反向','XEF':'ETF资金流','XCR':'信用风险','XRSI':'RSI回归','XBB':'布林带','XEX':'量能衰竭'}
    FC = {'XTR':'#ff6b6b','XMF':'#ff9f43','XMA':'#ffa502','XMO':'#f368e0','XVL':'#2ed573','XOI':'#1e90ff','XCF':'#a55eea','XVX':'#7bed9f','XRD':'#ff4757','XDX':'#e056fd','XEF':'#3ae374','XCR':'#ff6348','XRSI':'#18dcff','XBB':'#7d5fff','XEX':'#00d2d3'}
    FW = {'XTR':0.06,'XMF':0.06,'XMA':0.04,'XMO':0.04,'XVL':0.08,'XOI':0.06,'XCF':0.06,'XVX':0.04,'XRD':0.10,'XDX':0.10,'XEF':0.06,'XCR':0.06,'XRSI':0.10,'XBB':0.08,'XEX':0.06}
    FD = {'XTR':'1H+4H+日线EMA三重确认','XMF':'15m/1H/4H均线共振','XMA':'MA20/50/200多头排列','XMO':'ROC(12)价格变化率','XVL':'COMEX期货量vs滚动均值','XOI':'OI变化方向+速率','XCF':'CFTC管理基金净持仓(极端反转)','XVX':'缩量涨/放量滞涨检测','XRD':'TIPS实际利率倒数(金价核心驱动)','XDX':'DXY美元指数负相关','XEF':'GLD ETF资金流入流出方向','XCR':'信用利差→避险需求','XRSI':'RSI(14)超买>70/超卖<30回归','XBB':'布林带%B位置+带宽','XEX':'连续缩量+波幅收窄衰竭'}
else:
    FK = ['TR','OB','TK','OI','FR','MP','VD','BTC','GM','IV','EX','LC','MR','SM','MTF','OBL','LL','LEX']
    FN = {'TR':'趋势','OB':'挂单','TK':'成交','OI':'持仓量','FR':'费率','MP':'痛点','VD':'量变','BTC':'BTC','GM':'Gamma','IV':'波动率','EX':'衰竭','LC':'清算','MR':'回归','SM':'聪明钱','MTF':'MTF共振','OBL':'清算占比','LL':'低杠杆','LEX':'清算衰竭'}
    FC = {'TR':'#ff6b6b','OB':'#ffa502','TK':'#2ed573','OI':'#1e90ff','FR':'#a55eea','MP':'#ff4757','VD':'#7bed9f','BTC':'#eccc68','GM':'#e056fd','IV':'#686de0','EX':'#ff6348','LC':'#3ae374','MR':'#18dcff','SM':'#7d5fff','MTF':'#ff9f43','OBL':'#00d2d3','LL':'#f368e0','LEX':'#1dd1a1'}
    FW = {'TR':0.04,'OB':0.10,'TK':0.02,'OI':0.01,'FR':0.10,'MP':0.08,'VD':0.03,'BTC':0.02,'GM':0.02,'IV':0.02,'EX':0.18,'LC':0.04,'MR':0.01,'SM':0.03,'MTF':0.10,'OBL':0.08,'LL':0.04,'LEX':0.06}
    FD = {'TR':'1H+4H EMA交叉趋势方向','OB':'订单簿买卖挂单深度失衡','TK':'主动成交买/卖比例','OI':'持仓量变化方向','FR':'资金费率多空拥挤度','MP':'期权最大痛点磁吸效应','VD':'成交量Delta变化','BTC':'宏观联动(ETH/黄金反相关)','GM':'Gamma做市商行为','IV':'隐含波动率变化','EX':'量能衰竭检测','LC':'清算地图方向(+多/-空)','MR':'短期过热均值回归','SM':'聪明钱(链上+合约现货分歧)','MTF':'多时间框架共振(1m/5m/15m)','OBL':'订单簿vs清算挂单占比','LL':'低杠杆积累检测','LEX':'清算衰竭信号'}

R_PX = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[ETH\] \$([0-9,.]+) \|')
R_F  = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[ETH\] '
    r'TR:([+-]?\d+\.?\d*) OB:([+-]?\d+\.?\d*) FR:([+-]?\d+\.?\d*) '
    r'TK:([+-]?\d+\.?\d*) OI:([+-]?\d+\.?\d*) MP:([+-]?\d+\.?\d*) '
    r'VD:([+-]?\d+\.?\d*) BTC:([+-]?\d+\.?\d*) GM:([+-]?\d+\.?\d*) '
    r'IV:([+-]?\d+\.?\d*) EX:([+-]?\d+\.?\d*) LC:([+-]?\d+\.?\d*) '
    r'MR:([+-]?\d+\.?\d*) SM:([+-]?\d+\.?\d*) '
    r'mom:[+-]?\d+\.?\d* flip:[+-]?\d+\.?\d* => ([+-]?\d+\.?\d*) -> (\w+)')
R_NEWS = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[News\] DeepSeek: AI判断:([+-]?\d+\.?\d*)\(把握(\d+)%[^)]*\) 恐贪:(\d+) (.+?) \[(.+?)\]')
R_OPEN = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*ETH (LONG|SHORT) [\d.]+\S* @ \$([\d.]+) TP:\$([\d.]+) SL:\$([\d.]+)')
R_TP   = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*\[止盈\].*\$([-\d.]+)')
R_SL   = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*\[止损\].*\$([-\d.]+)')
R_LOCK = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*(锁利|因子反向.*锁|强制平仓|决策树.*平)')

def parse():
    if _SYMBOL == "XAU":
        import pymysql
        from pymysql.cursors import DictCursor
        conn = pymysql.connect(host='127.0.0.1',port=3306,user='admin',password='8ggtjny',
            database='coinglass_trader',charset='utf8mb4',cursorclass=DictCursor)
        rows = []
        events = []
        try:
            with conn.cursor() as cur:
                cur.execute("""SELECT price,total_score,direction,
                    trend,orderbook,funding,taker,oi,maxpain,vol_delta,btc_corr,
                    gamma,iv,exhaust,liq_cool,mean_revert,news,smart_money,
                    mtf,ob_liq,low_lev,liq_ex,snapshot_at
                    FROM factor_snapshots WHERE symbol='XAU'
                    ORDER BY snapshot_at ASC LIMIT 6000""")
                for r in cur:
                    ts = r['snapshot_at']
                    if hasattr(ts,'strftime'):
                        from datetime import timezone, timedelta
                        if ts.tzinfo is None: ts = ts.replace(tzinfo=timezone.utc)
                        ts = ts.astimezone(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
                    else: ts = str(ts)[:19]
                    row = {'ts':ts, 'price':float(r['price']or 0),
                           'total':float(r['total_score']or 0),'dir':r['direction']or'WAIT',
                           'XTR':float(r['trend']or 0),'XMF':float(r['mtf']or 0),
                           'XMA':float(r['low_lev']or 0),'XMO':0,
                           'XVL':float(r['taker']or 0),'XOI':float(r['oi']or 0),
                           'XCF':float(r['smart_money']or 0),'XVX':float(r['vol_delta']or 0),
                           'XRD':float(r['liq_cool']or 0),'XDX':float(r['mean_revert']or 0),
                           'XEF':float(r['funding']or 0),'XCR':float(r['btc_corr']or 0),
                           'XRSI':float(r['mean_revert']or 0),'XBB':float(r['ob_liq']or 0),
                           'XEX':float(r['exhaust']or 0)}
                    rows.append(row)
            with conn.cursor() as cur:
                cur.execute("""SELECT direction,entry_price,exit_price,pnl,exit_reason,
                    DATE_FORMAT(opened_at,'%%Y-%%m-%%d %%H:%%i:%%s') as opened,
                    DATE_FORMAT(closed_at,'%%Y-%%m-%%d %%H:%%i:%%s') as closed
                    FROM trades ORDER BY opened_at ASC LIMIT 200""")
                for t in cur:
                    if t['opened']:
                        d = t['direction']
                        events.append({'ts':t['opened'],'type':'open','price':float(t['entry_price']),
                            'label':f"{chr(0x25b2) if d=='LONG' else chr(0x25bc)}{d[0]} @{float(t['entry_price']):.0f}",
                            'color':'#2ed573' if d=='LONG' else '#ff4757','dir':d,'tp':0,'sl':0})
                    if t['closed'] and t['exit_reason']:
                        pnl = float(t.get('pnl',0)or 0)
                        events.append({'ts':t['closed'],'type':'close','price':float(t.get('exit_price',0)or 0),
                            'label':t['exit_reason'][:10],'color':'#2ed573' if pnl>0 else '#ff4757','dir':''})
        finally:
            conn.close()
        prev_dir = None
        for r in rows:
            d = r.get('dir','WAIT')
            if d != prev_dir and d in ('LONG','SHORT'):
                events.append({'ts':r['ts'],'type':'signal','price':r['price'],
                    'label':f"{chr(0x25b2) if d=='LONG' else chr(0x25bc)}{d[0]} @{r['price']:.0f}",
                    'color':'#2ed573' if d=='LONG' else '#ff4757','dir':d})
            prev_dir = d
        return rows[:MAXN], events

    # BTC: original log-based parse
    lines = open(LOG, encoding='utf-8', errors='replace').readlines()
    prices_map, rows = {}, []
    events = []  # {ts, type, price, label}
    last_news = None  # 最近一条 News 行
    for l in lines:
        m = R_PX.search(l)
        if m: prices_map[m.group(1)] = float(m.group(2).replace(',',''))
        m = R_NEWS.search(l)
        if m:
            last_news = {
                'ai': float(m.group(2)),
                'conf': int(m.group(3)),
                'fg': int(m.group(4)),
                'summary': m.group(5).strip()[:80],
                'headline': m.group(6).strip()[:100],
            }
        m = R_F.search(l)
        if m:
            ts = m.group(1)
            rows.append({
                'ts':ts, 'price':prices_map.get(ts),
                'TR':float(m.group(2)),'OB':float(m.group(3)),'FR':float(m.group(4)),
                'TK':float(m.group(5)),'OI':float(m.group(6)),'MP':float(m.group(7)),
                'VD':float(m.group(8)),'BTC':float(m.group(9)),'GM':float(m.group(10)),
                'IV':float(m.group(11)),'EX':float(m.group(12)),'LC':float(m.group(13)),
                'MR':float(m.group(14)),'SM':float(m.group(15)),
                'total':float(m.group(16)),'dir':m.group(17),
                'news': last_news,  # 该 cycle 对应的最新 News
            })
        m = R_OPEN.search(l)
        if m:
            d = m.group(2)
            events.append({'ts':m.group(1),'type':'open','price':float(m.group(3)),
                          'label':f"{'▲' if d=='LONG' else '▼'}{d[0]} @{float(m.group(3)):.0f}",
                          'color':'#2ed573' if d=='LONG' else '#ff4757','dir':d,
                          'tp':float(m.group(4)),'sl':float(m.group(5))})
        m = R_TP.search(l)
        if m:
            events.append({'ts':m.group(1),'type':'tp','price':0,
                          'label':f"止盈 ${float(m.group(2)):+.4f}",
                          'color':'#f0c040','dir':''})
        m = R_SL.search(l)
        if m:
            events.append({'ts':m.group(1),'type':'sl','price':0,
                          'label':f"止损 ${float(m.group(2)):+.4f}",
                          'color':'#ff6348','dir':''})
        m = R_LOCK.search(l)
        if m:
            events.append({'ts':m.group(1),'type':'close','price':0,
                          'label':f"平仓:{m.group(2)[:6]}",
                          'color':'#aaa','dir':''})
    # fill prices
    last = None
    for r in rows:
        if r['price'] is None: r['price'] = last
        else: last = r['price']
    rows = [r for r in rows if r['price'] is not None][-MAXN:]
    # fill event prices from nearest row
    ts_set = {r['ts']:r['price'] for r in rows}
    all_ts = sorted(ts_set.keys())
    for ev in events:
        if ev['price'] == 0 or ev['price'] is None:
            # find closest
            best = None
            for t in all_ts:
                if t <= ev['ts']: best = t
            if best: ev['price'] = ts_set[best]
            else: ev['price'] = ts_set.get(all_ts[0], 0) if all_ts else 0
    # filter events to data range
    if rows:
        t0, t1 = rows[0]['ts'], rows[-1]['ts']
        events = [e for e in events if t0 <= e['ts'] <= t1]

    # 断点处理：检测 >5分钟的空缺，从OKX拉历史1分钟K线补充真实价格
    # 已抓取的价格缓存到 gap_prices.json，避免重复请求
    from datetime import datetime as _dt, timedelta
    GAP_FILL = 5 * 60   # >5分钟才补充
    CACHE_FILE = 'gap_prices.json'

    # 缓存格式：{"prices": {"YYYY-MM-DD HH:MM:SS": price, ...}}
    # 统一 ts->price 字典，不依赖精确 key 匹配
    price_cache = {}
    if os.path.exists(CACHE_FILE):
        try:
            raw = json.load(open(CACHE_FILE, encoding='utf-8'))
            # 兼容旧格式 {key: [{ts,price},...]}
            if 'prices' in raw:
                price_cache = raw['prices']
            else:
                for v in raw.values():
                    if isinstance(v, list):
                        for item in v:
                            if isinstance(item, dict) and 'ts' in item:
                                price_cache[item['ts']] = item['price']
        except:
            price_cache = {}

    # 检测空缺
    gaps = []
    for i in range(1, len(rows)):
        try:
            t0g = _dt.strptime(rows[i-1]['ts'], '%Y-%m-%d %H:%M:%S')
            t1g = _dt.strptime(rows[i]['ts'],   '%Y-%m-%d %H:%M:%S')
            gap = (t1g - t0g).total_seconds()
            if gap > GAP_FILL:
                gaps.append((rows[i-1]['ts'], rows[i]['ts'], int(gap)))
        except:
            pass

    # 对每个空缺，先从缓存取，缺少的才去拉
    fill_rows = []
    needs_fetch = []  # (t0_ms, t1_ms, label)
    for g_start, g_end, g_sec in gaps:
        t0g = _dt.strptime(g_start, '%Y-%m-%d %H:%M:%S')
        t1g = _dt.strptime(g_end,   '%Y-%m-%d %H:%M:%S')
        # 从下一个整分钟开始枚举（对齐秒=0，与缓存key格式一致）
        cur = t0g.replace(second=0) + timedelta(minutes=1)
        missing_ranges = []
        seg_miss_start = None
        while cur < t1g:
            ts_str = cur.strftime('%Y-%m-%d %H:%M:%S')
            if ts_str in price_cache:
                fill_rows.append({'ts': ts_str, 'price': price_cache[ts_str]})
                if seg_miss_start:
                    missing_ranges.append((seg_miss_start, cur))
                    seg_miss_start = None
            else:
                if seg_miss_start is None:
                    seg_miss_start = cur
            cur += timedelta(minutes=1)
        if seg_miss_start:
            missing_ranges.append((seg_miss_start, t1g))

        for ms, me in missing_ranges:
            # 日志 ts 是北京时间（UTC+8），转 UTC 再乘1000
            t0_ms = int((ms - timedelta(hours=8)).replace(tzinfo=timezone.utc).timestamp() * 1000)
            t1_ms = int((me - timedelta(hours=8)).replace(tzinfo=timezone.utc).timestamp() * 1000)
            needs_fetch.append((t0_ms, t1_ms, f"{ms.strftime('%H:%M')}~{me.strftime('%H:%M')}"))

    if needs_fetch:
        print(f"发现 {len(needs_fetch)} 个未缓存区段，正在从OKX补充...", flush=True)
        try:
            from config import OKX_BASE_URL, PROXIES
            sess = requests.Session()
            if PROXIES:
                sess.proxies = PROXIES
            sess.headers['User-Agent'] = 'Mozilla/5.0'
            new_count = 0
            for t0_ms, t1_ms, label in needs_fetch:
                after_ms = t1_ms
                for _ in range(15):
                    r = sess.get(
                        f'{OKX_BASE_URL}/api/v5/market/history-candles',
                        params={'instId':'ETH-USDT-SWAP','bar':'1m',
                                'after':str(after_ms),'before':str(t0_ms),'limit':'100'},
                        timeout=8
                    )
                    data = r.json().get('data', [])
                    if not data:
                        break
                    for c in data:
                        ts_ms_c = int(c[0])
                        if t0_ms <= ts_ms_c <= t1_ms:
                            ts_local = (_dt.utcfromtimestamp(ts_ms_c/1000) + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S')
                            p = float(c[4])
                            if ts_local not in price_cache:
                                price_cache[ts_local] = p
                                fill_rows.append({'ts': ts_local, 'price': p})
                                new_count += 1
                    after_ms = int(data[-1][0]) - 1
                    if after_ms <= t0_ms:
                        break
            print(f"  补充 {new_count} 条新价格数据", flush=True)
            # 持久化（统一格式）
            with open(CACHE_FILE, 'w', encoding='utf-8') as cf:
                json.dump({'prices': price_cache}, cf, ensure_ascii=False)
            print(f"  已保存到 {CACHE_FILE}（共 {len(price_cache)} 条）", flush=True)
        except Exception as e:
            print(f"价格补充失败（继续生成）: {e}", flush=True)
    elif gaps:
        print(f"使用缓存价格数据（共 {len(fill_rows)} 条）", flush=True)

    if fill_rows:
        null_factors = {k: None for k in FK}
        null_factors['total'] = None
        null_factors['dir'] = None
        full_fill = [{**null_factors, 'ts': fr['ts'], 'price': fr['price'], '_filled': True}
                     for fr in fill_rows]
        rows = sorted(rows + full_fill, key=lambda r: r['ts'])
        print(f"  共补充 {len(fill_rows)} 条价格数据", flush=True)

    return rows, events

def pearson(x, y):
    n = len(x)
    if n < 3: return 0.0
    mx, my = sum(x)/n, sum(y)/n
    num = sum((a-mx)*(b-my) for a,b in zip(x,y))
    dx = math.sqrt(sum((a-mx)**2 for a in x))
    dy = math.sqrt(sum((b-my)**2 for b in y))
    return round(num/(dx*dy),4) if dx>1e-10 and dy>1e-10 else 0.0

def overview(rows):
    real = [r for r in rows if not r.get('_filled') and r.get('total') is not None]
    n=len(real); prices=[r['price'] for r in real]
    pmin,pmax=min(prices),max(prices); pr=(pmax-pmin) or 1; pm=(pmax+pmin)/2
    pn=[(p-pm)/(pr/2) for p in prices]; tot=[r['total'] for r in real]
    res={}
    for k in FK:
        fv=[r[k] for r in real]; d={'vt':pearson(fv,tot),'vp':pearson(fv,pn)}
        for lag in [1,3,5,10,20]:
            if n<=lag: d[f'l{lag}']=0.0; continue
            fr=[math.log(prices[i+lag]/prices[i]) if prices[i]>0 else 0 for i in range(n-lag)]
            d[f'l{lag}']=pearson(fv[:n-lag],fr)
        res[k]=d
    return res

def html(rows, events, ov):
    n=len(rows); t0=rows[0]['ts']; t1=rows[-1]['ts']
    now=datetime.now().strftime('%Y-%m-%d %H:%M:%S')

    # 热力图行
    def cc(v):
        a=abs(v)
        if a<0.05: return '#333'
        return f'rgb(0,{int(80+a*175)},50)' if v>0 else f'rgb({int(80+a*175)},30,30)'
    ovr=''
    for k in FK:
        d=ov[k]; c=FC[k]
        cells=f'<td style="background:{cc(d["vt"])}">{d["vt"]:+.3f}</td>'
        cells+=f'<td style="background:{cc(d["vp"])}">{d["vp"]:+.3f}</td>'
        for lag in [1,3,5,10,20]:
            v=d[f'l{lag}']; cells+=f'<td style="background:{cc(v)}">{v:+.3f}</td>'
        ovr+=f'<tr><th style="color:{c}">{k}</th><td class="fn">{FN[k]}</td>{cells}</tr>\n'

    af_data = json.dumps({k:[round(r[k],4) if r[k] is not None else None for r in rows] for k in FK})

    # 数据 JSON（注入到 <script> 里）
    data_js = (
        'const FK=' + json.dumps(FK) + ';\n'
        'const FN=' + json.dumps(FN) + ';\n'
        'const FC=' + json.dumps(FC) + ';\n'
        'const T='  + json.dumps([r['ts'] for r in rows]) + ';\n'
        'const P='  + json.dumps([r['price'] for r in rows]) + ';\n'
        'const S='  + json.dumps([round(r['total'],4) if r['total'] is not None else None for r in rows]) + ';\n'
        'const D='  + json.dumps([r['dir'] for r in rows]) + ';\n'
        'const AF=' + af_data + ';\n'
        'const EV=' + json.dumps(events) + ';\n'
    )
    # NEWS 去重+游程压缩：NL=唯一news列表，NR=[[idx,count],...] 游程编码
    news_list = []
    news_key_map = {}
    ni_arr = []
    for r in rows:
        n = r.get('news')
        if n is None:
            ni_arr.append(-1)
        else:
            # 去重 key：AI值取0.05精度 + 恐贪指数 + 摘要前30字
            key = (round(n['ai']*20)/20, n['fg'], n['summary'][:30])
            if key not in news_key_map:
                news_key_map[key] = len(news_list)
                news_list.append(n)
            ni_arr.append(news_key_map[key])
    # 游程编码
    nr_runs = []
    if ni_arr:
        cur_v, cur_c = ni_arr[0], 1
        for v in ni_arr[1:]:
            if v == cur_v: cur_c += 1
            else: nr_runs.append([cur_v, cur_c]); cur_v, cur_c = v, 1
        nr_runs.append([cur_v, cur_c])
    data_js += (
        'const NL=' + json.dumps(news_list) + ';\n'
        'const NR=' + json.dumps(nr_runs) + ';\n'
        # 展开函数：getNI(gi) -> news index
        'function getNI(gi){let i=0,r=0;for(const[v,c]of NR){if(gi<r+c)return v;r+=c;}return -1;}\n'
    )

    # 读取 JS 模板（纯 JS，无需任何 Python 转义）
    js_tpl = open('chart_template.js', encoding='utf-8').read()

    page = (
        '<!DOCTYPE html>\n'
        '<html lang="zh"><head><meta charset="UTF-8">\n'
        '<meta name="viewport" content="width=device-width,initial-scale=1">\n'
        '<title>ETH 因子走势图</title>\n'
        '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>\n'
        '<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>\n'
        '<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>\n'
        '<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>\n'
        '<style>\n'
        '*{box-sizing:border-box;margin:0;padding:0}\n'
        'body{background:#0a0a0a;color:#ddd;font-family:\'Segoe UI\',system-ui,sans-serif;font-size:13px}\n'
        '.top{padding:12px 16px 4px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}\n'
        '.top h1{font-size:16px;color:#eee} .top .m{color:#555;font-size:11px} .top .m b{color:#888}\n'
        '.tbar{padding:4px 16px 8px;display:flex;gap:5px;flex-wrap:wrap;align-items:center}\n'
        '.tbar button{padding:3px 11px;border:1px solid #444;background:#151515;color:#999;cursor:pointer;\n'
        '  border-radius:3px;font-size:11px;transition:all .1s}\n'
        '.tbar button:hover{background:#222;color:#fff}\n'
        '.tbar button.a{background:#1a7;color:#fff;border-color:#1a7}\n'
        '.tbar input{background:#151515;color:#bbb;border:1px solid #444;padding:2px 6px;border-radius:3px;font-size:11px}\n'
        '.tbar label{color:#666;font-size:11px}\n'
        '.fbar{padding:0 16px 6px;display:flex;gap:3px;flex-wrap:wrap}\n'
        '.fb{padding:3px 9px;border:2px solid;background:transparent;cursor:pointer;border-radius:3px;\n'
        '  font-size:11px;font-weight:700;opacity:.4;transition:all .12s;color:#ccc}\n'
        '.fb:hover{opacity:.7} .fb.on{opacity:1;color:#fff;text-shadow:0 0 6px currentColor}\n'
        '.cbox{padding:0 10px;height:calc(60vh - 80px);min-height:300px}\n'
        '.rbox{padding:0 10px;height:calc(28vh - 60px);min-height:140px;border-top:1px solid #1a1a1a}\n'
        '.cpan{padding:8px 16px;display:flex;gap:5px;flex-wrap:wrap;border-top:1px solid #1a1a1a}\n'
        '.cc{background:#141414;border-radius:5px;padding:5px 10px;min-width:68px;text-align:center;\n'
        '  font-size:11px;border:1px solid #222}\n'
        '.cc .cn{color:#666} .cc .cv{font-size:14px;font-weight:700}\n'
        '.ov{margin:6px 16px;overflow-x:auto}\n'
        '.ov summary{color:#666;cursor:pointer;font-size:11px;margin-bottom:4px}\n'
        '.ov table{border-collapse:collapse;font-size:11px;width:100%}\n'
        '.ov th,.ov td{padding:3px 7px;border:1px solid #1a1a1a;text-align:center}\n'
        '.ov th{background:#111;color:#777} .ov .fn{text-align:left;color:#666;font-weight:normal}\n'
        '</style></head><body>\n\n'
        f'<div class="top">\n'
        f'<h1>ETH 因子走势图</h1>\n'
        f'<span class="m">数据:<b>{n}</b> | <b>{t0[:10]}</b> ~ <b>{t1[:10]}</b> | {now}</span>\n'
        '</div>\n'
        '<div class="tbar">\n'
        '  <button onclick="sr(30)">30m</button>\n'
        '  <button onclick="sr(60)">1h</button>\n'
        '  <button onclick="sr(240)">4h</button>\n'
        '  <button onclick="sr(720)">12h</button>\n'
        '  <button onclick="sr(1440)" id="r1440">1d</button>\n'
        '  <button onclick="sr(4320)">3d</button>\n'
        '  <button onclick="sr(0)" id="rAll">全部</button>\n'
        '  <label style="color:#444">|</label>\n'
        '  <label>从</label><input type="datetime-local" id="dtF" step="60" onchange="cr()">\n'
        '  <label>到</label><input type="datetime-local" id="dtT" step="60" onchange="cr()">\n'
        '  <button onclick="resetZoom()" style="margin-left:8px">重置缩放</button>\n'
        '  <button onclick="clearCustom()" style="margin-left:2px;font-size:10px;color:#666">清除</button>\n'
        '</div>\n'
        '<div class="fbar" id="fbar"></div>\n'
        '<div class="cbox"><canvas id="mc"></canvas></div>\n'
        '<div class="rbox"><canvas id="rc"></canvas></div>\n'
        '<div class="cpan" id="cpan"></div>\n\n'
        '<details class="ov" open>\n'
        '<summary>全局相关系数矩阵</summary>\n'
        '<table>\n'
        '<tr><th></th><th>名称</th><th>vs总分</th><th>vs价格</th><th>lag1</th><th>lag3</th><th>lag5</th><th>lag10</th><th>lag20</th></tr>\n'
        f'{ovr}</table></details>\n\n'
        '<script>\n'
        + data_js +
        js_tpl +
        '\n</script></body></html>'
    )
    return page


def main():
    import time as _time
    no_open = '--no-open' in sys.argv
    watch   = '--watch'   in sys.argv
    PORT    = 8765

    def run_once(open_browser=False):
        print(f"[{datetime.now().strftime('%H:%M:%S')}] parsing...", flush=True)
        rows, events = parse()
        if not rows: print("no data"); return
        print(f"rows:{len(rows)} events:{len(events)} range:{rows[0]['ts']}~{rows[-1]['ts']}", flush=True)
        ov = overview(rows)
        h = html(rows, events, ov)
        with open(OUT,'w',encoding='utf-8') as f: f.write(h)
        ts_now = str(int(_time.time()))
        with open('chart_ts.txt','w') as f: f.write(ts_now)
        print(f"done: {OUT} ({os.path.getsize(OUT)//1024}KB)", flush=True)
        if open_browser:
            subprocess.Popen(['start', f'http://localhost:{PORT}/{OUT}'], shell=True)

    if watch:
        # 启动本地 HTTP 服务（后台线程）
        import threading, http.server
        class _Handler(http.server.SimpleHTTPRequestHandler):
            def log_message(self, *a): pass  # 静默
        def _serve():
            with http.server.HTTPServer(('', PORT), _Handler) as srv:
                srv.serve_forever()
        t = threading.Thread(target=_serve, daemon=True)
        t.start()
        print(f"HTTP服务已启动: http://localhost:{PORT}/{OUT}", flush=True)
        run_once(open_browser=not no_open)
        print("--watch 模式：每60秒自动更新图表（Ctrl+C停止）", flush=True)
        while True:
            _time.sleep(60)
            try:
                run_once(open_browser=False)
            except Exception as e:
                print(f"更新失败: {e}", flush=True)
    else:
        run_once(open_browser=not no_open)
        if not no_open:
            pass  # 直接 file:// 打开，自动刷新不可用（需 --watch）

if __name__=='__main__': main()
