# -*- coding: utf-8 -*-
"""
dashboard.py — 统一图表入口

--mode=live    轻量实时看板（原 factor_chart.py）
                 生成 chart.html + chart_data.json，浏览器30s自动刷新
                 --watch  持续更新（每30秒）
                 --data   只更新数据，不重建壳

--mode=detail  深度因子走势图（原 factor_detail_charts.py）
                 生成 factor_detail_charts.html，包含全局相关系数矩阵
                 --watch  持续更新（每60秒）+ 启动本地 HTTP 服务

用法:
  python tools/dashboard.py --mode=live
  python tools/dashboard.py --mode=live --watch
  python tools/dashboard.py --mode=detail
  python tools/dashboard.py --mode=detail --watch
"""

import json, math, os, re, sys, time, subprocess
from datetime import datetime, timezone, timedelta

_ROOT  = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_TOOLS = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _ROOT)

from tools.log_parser import (
    FK, FN, FC, DEFAULT_LOG,
    parse_rows, parse_news_events, pearson, normalize_prices,
    RE_PRICE, RE_FACTOR,
)

# ── 输出路径 ──────────────────────────────────────────────────
LIVE_HTML     = os.path.join(_ROOT, "chart.html")
LIVE_DATA     = os.path.join(_ROOT, "chart_data.json")
DETAIL_HTML   = os.path.join(_ROOT, "factor_detail_charts.html")
CHART_TMPL_JS = os.path.join(_ROOT, "dashboard/chart_template.js")
GAP_CACHE     = os.path.join(_ROOT, "gap_prices.json")
CHART_TS_TXT  = os.path.join(_ROOT, "chart_ts.txt")

# ── 因子颜色（live 模式）────────────────────────────────────
COLORS_LIVE = [
    '#ff6b6b', '#ffa94d', '#ffe066', '#a9e34b', '#4dabf7',
    '#74c0fc', '#da77f2', '#f783ac', '#e64980', '#12b886',
    '#228be6', '#fab005', '#7950f2', '#ffd700', '#00ced1',
]
HIDDEN_DEFAULT = {'FR', 'OI', 'IV', 'MP', 'CT'}

MAX_LIVE   = 400
MAX_DETAIL = 6000


# ── 动态读取权重 ──────────────────────────────────────────────
def _load_weights():
    try:
        import config as _cfg
        return {
            'TR':  getattr(_cfg, 'W_TREND',          0.08),
            'OB':  getattr(_cfg, 'W_ORDERBOOK',       0.06),
            'TK':  getattr(_cfg, 'W_TAKER',           0.10),
            'OI':  getattr(_cfg, 'W_OI_CHANGE',       0.02),
            'FR':  getattr(_cfg, 'W_FUNDING',         0.04),
            'MP':  getattr(_cfg, 'W_MAXPAIN',         0.04),
            'VD':  getattr(_cfg, 'W_VOL_DELTA',       0.10),
            'BTC': getattr(_cfg, 'W_BTC_CORR',        0.06),
            'GM':  getattr(_cfg, 'W_GAMMA',           0.06),
            'IV':  getattr(_cfg, 'W_IV',              0.02),
            'EX':  getattr(_cfg, 'W_TAKER_EXHAUST',   0.07),
            'LC':  getattr(_cfg, 'W_LIQ_COOLDOWN',    0.13),
            'MR':  getattr(_cfg, 'W_MEAN_REVERT',     0.05),
            'SM':  getattr(_cfg, 'W_SMART_MONEY',     0.00),
            'CT':  getattr(_cfg, 'COPY_TRADE_WEIGHT', 0.10),
            'RT':  getattr(_cfg, 'W_RETAIL_POSITION',  0.05),
            'MM':  getattr(_cfg, 'W_MM_POSITION',      0.05),
            'LT':  getattr(_cfg, 'W_LIQ_TRIGGER',      0.06),
            'TX':  getattr(_cfg, 'W_TOXIC_FLOW',       0.06),
        }
    except Exception:
        return {
            'TR': 0.08, 'OB': 0.06, 'TK': 0.10, 'OI': 0.02, 'FR': 0.04, 'MP': 0.04,
            'VD': 0.10, 'BTC': 0.06, 'GM': 0.06, 'IV': 0.02, 'EX': 0.07, 'LC': 0.13,
            'MR': 0.05, 'NEWS': 0.17, 'SM': 0.00, 'CT': 0.10,
        }


# ════════════════════════════════════════════════════════════
#  LIVE 模式（原 factor_chart.py）
# ════════════════════════════════════════════════════════════

def _live_parse_extra(rows):
    """解析新闻/快讯事件（live 模式专用）"""
    news_events, flash_events = parse_news_events()
    # 匹配简洁版 trade open/close（仅用 rows 中已有事件格式）
    trade_opens  = []
    trade_closes = []
    try:
        re_open  = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[交易\] ✅ ETH (\w+) ([\d.]+)张')
        re_close = re.compile(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] (?:INFO|WARNING): \[(?:止盈|止损|决策树)\].+(?:平仓|止盈触发|止损触发)')
        for line in open(DEFAULT_LOG, encoding='utf-8', errors='replace'):
            m = re_open.search(line)
            if m:
                trade_opens.append({'ts': m.group(1), 'action': m.group(2), 'size': m.group(3), 'type': 'open'})
            m = re_close.search(line)
            if m:
                trade_closes.append({'ts': m.group(1), 'action': '', 'size': '', 'type': 'close'})
    except Exception:
        pass
    return news_events, flash_events, trade_opens + trade_closes


def _live_calc_rate(values, scale=3.0):
    rates = [None]
    for i in range(1, len(values)):
        if values[i] is None or values[i - 1] is None:
            rates.append(None)
        else:
            rates.append(round(max(-1, min(1, (values[i] - values[i - 1]) * scale)), 3))
    return rates


def _live_build_data(rows, news_events, flash_events, trades):
    if not rows:
        return {}

    # 价格归一化
    valid_px = [r['price'] for r in rows if r['price']]
    if valid_px:
        px_min, px_max = min(valid_px), max(valid_px)
        px_range = px_max - px_min if px_max != px_min else 1
        px_mid   = (px_max + px_min) / 2
        for row in rows:
            row['price_norm'] = round((row['price'] - px_mid) / (px_range / 2), 3) if row['price'] else None
    else:
        for row in rows:
            row['price_norm'] = None

    ts_labels = [r['ts'][11:16] for r in rows]
    ts_full   = [r['ts'][:16]   for r in rows]

    series_data, rate_data = {}, {}
    for k in FK:
        vals = [r.get(k) for r in rows]
        series_data[k] = vals
        rate_data[k]   = _live_calc_rate(vals)
    series_data['ETH']   = [r['price_norm'] for r in rows]
    series_data['Total'] = [r['total']      for r in rows]
    rate_data['ETH']     = _live_calc_rate(series_data['ETH'],   scale=5.0)
    rate_data['Total']   = _live_calc_rate(series_data['Total'], scale=5.0)

    news_tooltip = {}
    for ev in news_events:
        key = ev['ts'][:16]
        news_tooltip[key] = f"[新闻{ev['score']:+d}] {ev['summary'][:80]}"
    for ev in flash_events:
        key = ev['ts'][:16]
        existing = news_tooltip.get(key, '')
        news_tooltip[key] = (existing + '\n' if existing else '') + f"[★{ev['score']}] {ev['text'][:80]}"

    mark_lines = []
    for ev in news_events:
        ev_ts = ev['ts'][:16]
        idx = next((i for i, t in enumerate(ts_full) if t >= ev_ts), None)
        if idx is not None:
            color = '#51cf66' if ev['score'] > 0 else ('#ff6b6b' if ev['score'] < 0 else '#aaa')
            mark_lines.append({'idx': idx, 'color': color, 'label': f"新闻{ev['score']:+d}", 'type': 'dashed'})
    for ev in flash_events:
        ev_ts = ev['ts'][:16]
        idx = next((i for i, t in enumerate(ts_full) if t >= ev_ts), None)
        if idx is not None:
            color = '#ffa94d' if ev['score'] >= 8 else '#555'
            mark_lines.append({'idx': idx, 'color': color, 'label': f"★{ev['score']}", 'type': 'dotted'})

    mark_points = []
    for t in trades:
        t_short = t['ts'][:16]
        idx = next((i for i, ts in enumerate(ts_full) if ts >= t_short), None)
        if idx is not None:
            val = rows[idx]['total'] if idx < len(rows) else 0
            if t['type'] == 'open':
                color  = '#51cf66' if t['action'] == 'LONG' else '#ff6b6b'
                label  = f"开{t['action']}"
                rotate = 180 if t['action'] == 'SHORT' else 0
            else:
                color, label, rotate = '#ffd43b', '平仓', 0
            mark_points.append({'idx': idx, 'val': val, 'color': color, 'label': label, 'rotate': rotate})

    last = rows[-1]
    return {
        'ts': ts_labels, 'ts_full': ts_full,
        'series': series_data, 'rates': rate_data,
        'news_tooltip': news_tooltip,
        'mark_lines': mark_lines, 'mark_points': mark_points,
        'last_price': last['price'] or 0,
        'last_total': last['total'], 'last_dir': last['dir'],
        'px_min': min(valid_px) if valid_px else 0,
        'px_max': max(valid_px) if valid_px else 0,
        'updated': datetime.now().strftime('%H:%M:%S'),
        'n_points': len(rows),
    }


def _live_build_shell(weights):
    FK_LIVE = [k for k in FK if k != 'SM']  # SM 权重0，默认隐藏

    legend_defs = []
    for i, k in enumerate(FK_LIVE):
        legend_defs.append({
            'key': k, 'label': f'{k} {FN[k]}',
            'color': COLORS_LIVE[i % len(COLORS_LIVE)],
            'weight': weights.get(k, 0), 'hidden': k in HIDDEN_DEFAULT,
        })
    legend_defs.append({'key': 'ETH',   'label': 'ETH价格',  'color': '#ffd43b', 'weight': 0, 'hidden': False})
    legend_defs.append({'key': 'Total', 'label': 'Total总分', 'color': '#ffffff', 'weight': 0, 'hidden': False})

    series_defs = []
    rate_defs   = []
    for i, k in enumerate(FK_LIVE):
        c = COLORS_LIVE[i % len(COLORS_LIVE)]
        series_defs.append({'name': f'{k} {FN[k]}', 'key': k, 'color': c, 'width': 1.5})
        rate_defs.append(  {'name': f'{k}速率',      'key': k, 'color': c, 'width': 1.2})
    series_defs.append({'name': 'ETH价格(归一化)', 'key': 'ETH',   'color': '#ffd43b', 'width': 2.5})
    series_defs.append({'name': 'Total总分',       'key': 'Total', 'color': '#ffffff', 'width': 3.0})
    rate_defs.append(  {'name': 'ETH价格速率',     'key': 'ETH',   'color': '#ffd43b', 'width': 2.5})
    rate_defs.append(  {'name': 'Total速率',       'key': 'Total', 'color': '#ffffff', 'width': 3.0})

    legend_json = json.dumps(legend_defs,  ensure_ascii=False)
    series_json = json.dumps(series_defs,  ensure_ascii=False)
    rate_json   = json.dumps(rate_defs,    ensure_ascii=False)
    weights_json = json.dumps(weights)

    return f"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>ETH 因子走势</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#0d1117; color:#c9d1d9; font-family:'Segoe UI',monospace; }}
#header {{ padding:8px 16px; background:#161b22; border-bottom:1px solid #30363d;
           display:flex; align-items:center; gap:16px; flex-wrap:wrap; }}
#header h2 {{ margin:0; font-size:15px; color:#e6edf3; }}
.stat {{ font-size:11px; color:#8b949e; }}
.stat b {{ font-size:13px; color:#e6edf3; }}
#legend-bar {{ padding:5px 16px; background:#161b22; border-bottom:1px solid #30363d;
               display:flex; flex-wrap:wrap; gap:4px; }}
#legend-bar button {{ padding:2px 9px; border-radius:4px; font-size:11px;
                      cursor:pointer; transition:all .15s; }}
#legend-bar button:hover {{ filter:brightness(1.3); }}
#main {{ width:100%; height:calc(56vh - 50px); }}
#rate {{ width:100%; height:calc(37vh - 50px); border-top:2px solid #21262d; }}
#footer {{ padding:5px 16px; background:#161b22; border-top:1px solid #30363d;
           display:flex; flex-wrap:wrap; gap:4px; font-size:11px; }}
.ftag {{ padding:2px 7px; border-radius:3px; background:#21262d; cursor:default; }}
.pos {{ color:#51cf66; }} .neg {{ color:#ff6b6b; }} .neu {{ color:#8b949e; }}
</style>
</head>
<body>
<div id="header">
  <h2>📊 ETH 因子走势</h2>
  <div class="stat">价格: <b id="h-price">-</b></div>
  <div class="stat">总分: <b id="h-total">-</b></div>
  <div class="stat">信号: <b id="h-dir">-</b></div>
  <div class="stat" style="color:#ffd43b;font-size:11px" id="h-px-range"></div>
  <div class="stat">数据点: <b id="h-pts">-</b></div>
  <div class="stat" style="color:#444" id="h-updated">-</div>
</div>
<div id="legend-bar"></div>
<div id="main"></div>
<div id="rate"></div>
<div id="footer"></div>

<script>
var LEGEND_DEFS  = {legend_json};
var SERIES_DEFS  = {series_json};
var RATE_DEFS    = {rate_json};
var DATA_URL     = 'chart_data.json';
var REFRESH_MS   = 30000;

var chart     = echarts.init(document.getElementById('main'), 'dark');
var rateChart = echarts.init(document.getElementById('rate'), 'dark');

function makeSeries(defs, dataMap) {{
  return defs.map(function(d) {{
    return {{
      name: d.name, type: 'line',
      data: dataMap[d.key] || [],
      smooth: d.key !== 'Total',
      lineStyle: {{ width: d.width }},
      symbol: 'none',
      color: d.color,
      z: d.key === 'Total' ? 10 : (d.key === 'ETH' ? 8 : 1),
    }};
  }});
}}

var xAxisCfg = {{
  type: 'category', data: [],
  axisLabel: {{ color:'#8b949e', fontSize:10, interval:'auto' }},
  splitLine: {{ lineStyle: {{ color:'#1c2128' }} }}
}};
var yAxisCfg = {{
  type:'value', min:-1.1, max:1.1,
  axisLabel: {{ color:'#8b949e', fontSize:10 }},
  splitLine: {{ lineStyle: {{ color:'#1c2128' }} }}
}};
var dataZoomCfg = [
  {{ type:'inside', start:0, end:100 }},
  {{ type:'slider', height:18, bottom:5, start:0, end:100, textStyle:{{ color:'#8b949e' }} }}
];

function tooltipFormatter(newsData, tsFull, showNews) {{
  return function(params) {{
    var idx = params[0] ? params[0].dataIndex : 0;
    var tsKey = tsFull ? tsFull[idx] : '';
    var s = '<b>' + (params[0] ? params[0].axisValue : '') + '</b>';
    if (showNews && newsData && newsData[tsKey]) {{
      s += '<br/><span style="color:#ffa94d">📰 ' + newsData[tsKey].replace(/\\n/g,'<br/>') + '</span>';
    }}
    s += '<br/>';
    params.forEach(function(p) {{
      if (p.value == null) return;
      var v = typeof p.value === 'number' ? p.value.toFixed(3) : p.value;
      s += '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:'
           + p.color + ';margin-right:4px"></span>' + p.seriesName + ': <b>' + v + '</b><br/>';
    }});
    return s;
  }};
}}

chart.setOption({{
  backgroundColor:'#0d1117', animation:false,
  tooltip:{{ trigger:'axis', axisPointer:{{ type:'cross', label:{{ backgroundColor:'#1c2128' }} }},
             backgroundColor:'#1c2128', borderColor:'#30363d',
             textStyle:{{ color:'#c9d1d9', fontSize:11 }},
             extraCssText:'max-width:420px;white-space:pre-wrap;',
             formatter: tooltipFormatter(null, null, true) }},
  legend:{{ show:false }},
  grid:{{ left:55, right:20, top:10, bottom:45 }},
  xAxis: xAxisCfg,
  yAxis: {{ ...yAxisCfg, name:'因子值 [-1,+1]' }},
  dataZoom: dataZoomCfg,
  series: makeSeries(SERIES_DEFS, {{}})
}});
rateChart.setOption({{
  backgroundColor:'#0d1117', animation:false,
  tooltip:{{ trigger:'axis', axisPointer:{{ type:'cross' }},
             backgroundColor:'#1c2128', borderColor:'#30363d',
             textStyle:{{ color:'#c9d1d9', fontSize:11 }},
             formatter: tooltipFormatter(null, null, false) }},
  legend:{{ show:false }},
  grid:{{ left:55, right:20, top:10, bottom:45 }},
  xAxis: xAxisCfg,
  yAxis: {{ ...yAxisCfg, name:'速率 [-1,+1]' }},
  dataZoom: dataZoomCfg,
  series: makeSeries(RATE_DEFS, {{}})
}});

var hiddenSet = new Set(LEGEND_DEFS.filter(function(d){{ return d.hidden; }}).map(function(d){{ return d.key; }}));

function getSeriesName(key, isRate) {{
  if (!isRate) {{
    if (key === 'ETH')   return 'ETH价格(归一化)';
    if (key === 'Total') return 'Total总分';
    var d = LEGEND_DEFS.find(function(x){{ return x.key===key; }});
    return d ? d.key + ' ' + d.label.split(' ').slice(1).join(' ') : key;
  }} else {{
    if (key === 'ETH')   return 'ETH价格速率';
    if (key === 'Total') return 'Total速率';
    return key + '速率';
  }}
}}

function renderLegend() {{
  var bar = document.getElementById('legend-bar');
  bar.innerHTML = '';
  LEGEND_DEFS.forEach(function(d) {{
    var hidden = hiddenSet.has(d.key);
    var btn = document.createElement('button');
    btn.textContent = d.label;
    btn.style.cssText = 'border:1px solid ' + (hidden ? '#333' : d.color) + ';'
      + 'background:' + (hidden ? '#1c2128' : d.color + '22') + ';'
      + 'color:' + (hidden ? '#555' : d.color) + ';';
    btn.onclick = function() {{
      if (hiddenSet.has(d.key)) hiddenSet.delete(d.key);
      else hiddenSet.add(d.key);
      chart.dispatchAction({{ type:'legendToggleSelect', name: getSeriesName(d.key, false) }});
      rateChart.dispatchAction({{ type:'legendToggleSelect', name: getSeriesName(d.key, true) }});
      renderLegend();
    }};
    bar.appendChild(btn);
  }});
}}
renderLegend();

var _tsFull = [];
var _newsData = {{}};

function buildMarkLines(markLines) {{
  var data = [
    [{{ yAxis:0.35,  lineStyle:{{ color:'#51cf66', type:'dashed', width:1.5 }},
       label:{{ formatter:'+0.35', color:'#51cf66', fontSize:10 }} }}, {{ yAxis:0.35 }}],
    [{{ yAxis:-0.35, lineStyle:{{ color:'#ff6b6b', type:'dashed', width:1.5 }},
       label:{{ formatter:'-0.35', color:'#ff6b6b', fontSize:10 }} }}, {{ yAxis:-0.35 }}],
    [{{ yAxis:0, lineStyle:{{ color:'#444', type:'solid', width:1 }} }}, {{ yAxis:0 }}],
  ];
  (markLines || []).forEach(function(ml) {{
    data.push([
      {{ xAxis: ml.idx, lineStyle:{{ color:ml.color, type:ml.type, width:1.2 }},
         label:{{ formatter:ml.label, color:ml.color, fontSize:9, position:'insideStartTop' }} }},
      {{ xAxis: ml.idx }}
    ]);
  }});
  return data;
}}

function buildMarkPoints(markPoints) {{
  return (markPoints || []).map(function(mp) {{
    return {{
      coord: [mp.idx, mp.val],
      name: mp.label,
      itemStyle: {{ color: mp.color }},
      symbol: mp.rotate ? 'triangle' : (mp.label === '平仓' ? 'diamond' : 'triangle'),
      symbolRotate: mp.rotate || 0,
      symbolSize: 12,
      label: {{ show:true, formatter:mp.label, color:mp.color, fontSize:10, position:'top' }},
    }};
  }});
}}

function applyData(d) {{
  if (!d || !d.ts) return;
  _tsFull   = d.ts_full || [];
  _newsData = d.news_tooltip || {{}};

  chart.setOption({{
    tooltip: {{ formatter: tooltipFormatter(_newsData, _tsFull, true) }},
    xAxis:   {{ data: d.ts }},
  }}, {{ replaceMerge: [] }});
  rateChart.setOption({{
    tooltip: {{ formatter: tooltipFormatter(null, _tsFull, false) }},
    xAxis:   {{ data: d.ts }},
  }}, {{ replaceMerge: [] }});

  var mainUpdates = SERIES_DEFS.map(function(sd) {{
    var upd = {{ name: sd.name, data: d.series[sd.key] || [] }};
    if (sd.key === 'Total') {{
      upd.markLine  = {{ silent:false, data: buildMarkLines(d.mark_lines) }};
      upd.markPoint = {{ data: buildMarkPoints(d.mark_points), animation:false }};
    }}
    return upd;
  }});
  var rateUpdates = RATE_DEFS.map(function(rd) {{
    return {{ name: rd.name, data: d.rates[rd.key] || [] }};
  }});

  chart.setOption({{ series: mainUpdates }}, {{ replaceMerge: [] }});
  rateChart.setOption({{ series: rateUpdates }}, {{ replaceMerge: [] }});

  var dirColor = d.last_dir === 'LONG' ? '#51cf66' : (d.last_dir === 'SHORT' ? '#ff6b6b' : '#aaa');
  document.getElementById('h-price').textContent   = '$' + (d.last_price || 0).toLocaleString('en', {{minimumFractionDigits:2, maximumFractionDigits:2}});
  document.getElementById('h-total').style.color   = dirColor;
  document.getElementById('h-total').textContent   = (d.last_total >= 0 ? '+' : '') + (d.last_total || 0).toFixed(3);
  document.getElementById('h-dir').style.color     = dirColor;
  document.getElementById('h-dir').textContent     = d.last_dir || '-';
  document.getElementById('h-px-range').textContent= '价格区间: $' + Math.round(d.px_min) + '~$' + Math.round(d.px_max);
  document.getElementById('h-pts').textContent     = d.n_points || 0;
  document.getElementById('h-updated').textContent = '更新: ' + (d.updated || '') + ' · 30s自动刷新';

  var footer = document.getElementById('footer');
  footer.innerHTML = '';
  var WMAP = {weights_json};
  LEGEND_DEFS.slice(0, -2).forEach(function(ld) {{
    var v = (d.series[ld.key] || []);
    var last = v[v.length-1];
    if (last == null) return;
    var w = WMAP[ld.key] || 0;
    var cls = last > 0.05 ? 'pos' : (last < -0.05 ? 'neg' : 'neu');
    var div = document.createElement('div');
    div.className = 'ftag ' + cls;
    div.title = '权重' + w + ' 贡献' + (last*w >= 0 ? '+' : '') + (last*w).toFixed(3);
    div.textContent = ld.label + ': ' + (last >= 0 ? '+' : '') + last.toFixed(2);
    footer.appendChild(div);
  }});
}}

function fetchData() {{
  fetch(DATA_URL + '?t=' + Date.now())
    .then(function(r) {{ return r.json(); }})
    .then(function(d) {{ applyData(d); }})
    .catch(function(e) {{ console.warn('数据加载失败', e); }});
}}

fetchData();
setInterval(fetchData, REFRESH_MS);

var _syncing = false;
function syncZoom(src, dst) {{
  src.on('datazoom', function(e) {{
    if (_syncing) return; _syncing = true;
    var s = e.start  !== undefined ? e.start  : (e.batch && e.batch[0] ? e.batch[0].start  : undefined);
    var en= e.end    !== undefined ? e.end    : (e.batch && e.batch[0] ? e.batch[0].end    : undefined);
    if (s !== undefined) dst.dispatchAction({{ type:'dataZoom', start:s, end:en }});
    _syncing = false;
  }});
}}
syncZoom(chart, rateChart);
syncZoom(rateChart, chart);

window.addEventListener('resize', function() {{ chart.resize(); rateChart.resize(); }});
</script>
</body>
</html>"""


def run_live(data_only=False, watch=False):
    weights = _load_weights()

    if not data_only and not os.path.exists(LIVE_HTML):
        shell = _live_build_shell(weights)
        with open(LIVE_HTML, 'w', encoding='utf-8') as f:
            f.write(shell)
        print(f"[{datetime.now().strftime('%H:%M:%S')}] 生成壳: {LIVE_HTML}")

    while True:
        rows, _events = parse_rows(max_rows=MAX_LIVE)
        news_events, flash_events, trades = _live_parse_extra(rows)
        data = _live_build_data(rows, news_events, flash_events, trades)
        with open(LIVE_DATA, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False)
        print(f"[{datetime.now().strftime('%H:%M:%S')}] live: {len(rows)}点 "
              f"{len(news_events)}新闻 {len(flash_events)}快讯 {len(trades)}交易")
        if not watch:
            break
        time.sleep(30)


# ════════════════════════════════════════════════════════════
#  DETAIL 模式（原 factor_detail_charts.py）
# ════════════════════════════════════════════════════════════

def _detail_fill_gaps(rows):
    """补充日志空缺期间的价格（OKX API + 本地缓存）"""
    from datetime import datetime as _dt
    GAP_FILL = 5 * 60

    price_cache = {}
    if os.path.exists(GAP_CACHE):
        try:
            raw = json.load(open(GAP_CACHE, encoding='utf-8'))
            price_cache = raw.get('prices', {})
        except Exception:
            pass

    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')
            if (t1g - t0g).total_seconds() > GAP_FILL:
                gaps.append((rows[i - 1]['ts'], rows[i]['ts']))
        except Exception:
            pass

    fill_rows   = []
    needs_fetch = []
    for g_start, g_end in gaps:
        t0g = _dt.strptime(g_start, '%Y-%m-%d %H:%M:%S')
        t1g = _dt.strptime(g_end,   '%Y-%m-%d %H:%M:%S')
        cur = t0g.replace(second=0) + timedelta(minutes=1)
        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 miss_start:
                    needs_fetch.append((miss_start, cur))
                    miss_start = None
            else:
                miss_start = miss_start or cur
            cur += timedelta(minutes=1)
        if miss_start:
            needs_fetch.append((miss_start, t1g))

    if needs_fetch:
        try:
            import requests
            from config import OKX_BASE_URL, PROXIES
            sess = requests.Session()
            if PROXIES:
                sess.proxies = PROXIES
            new_count = 0
            for ms, me in needs_fetch:
                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)
                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} 条新价格数据")
            with open(GAP_CACHE, 'w', encoding='utf-8') as cf:
                json.dump({'prices': price_cache}, cf, ensure_ascii=False)
        except Exception as e:
            print(f"价格补充失败（继续生成）: {e}")

    if fill_rows:
        null_factors = {k: None for k in FK}
        null_factors.update({'total': None, 'dir': None, 'news': 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)} 条价格数据")

    return rows


def _detail_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]
    pn = normalize_prices(real)
    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 _detail_corr_table_html(ov):
    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)'

    rows_html = ''
    for k in FK:
        d = ov[k]
        c = FC[k]
        cells = f'<th style="color:{c}">{k}</th><td class="fn">{FN[k]}</td>'
        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>'
        rows_html += f'<tr>{cells}</tr>\n'
    return rows_html


def _detail_news_compress(rows):
    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 = (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])
    return news_list, nr_runs


def _detail_build_html(rows, events, ov):
    n = len(rows)
    t0, t1 = rows[0]['ts'], rows[-1]['ts']
    now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    ovr = _detail_corr_table_html(ov)

    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})
    news_list, nr_runs = _detail_news_compress(rows)

    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'
        'const NL=' + json.dumps(news_list) + ';\n'
        'const NR=' + json.dumps(nr_runs) + ';\n'
        '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_tpl = open(CHART_TMPL_JS, encoding='utf-8').read()

    return (
        '<!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(58vh - 70px);min-height:340px}\n'
        '.rbox{padding:0 10px;height:calc(22vh - 40px);min-height:130px;border-top:2px 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'
        '.chart-label{padding:3px 16px 1px;font-size:10px;color:#444;border-top:1px solid #1a1a1a;letter-spacing:.5px}\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="chart-label">ETH 历史价格走势 + 历史总分走势</div>\n'
        '<div class="cbox"><canvas id="cPrice"></canvas></div>\n'
        '<div class="chart-label">各指标分数对比（点击上方因子按钮切换）</div>\n'
        '<div class="rbox"><canvas id="cSub"></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>'
    )


def run_detail(watch=False, no_open=False):
    PORT = 8765

    def run_once(open_browser=False):
        print(f"[{datetime.now().strftime('%H:%M:%S')}] detail: parsing...", flush=True)
        rows, events = parse_rows(max_rows=MAX_DETAIL)
        if not rows:
            print("no data")
            return
        rows = _detail_fill_gaps(rows)
        print(f"  rows:{len(rows)} events:{len(events)}", flush=True)
        ov = _detail_overview(rows)
        page = _detail_build_html(rows, events, ov)
        with open(DETAIL_HTML, 'w', encoding='utf-8') as f:
            f.write(page)
        ts_now = str(int(time.time()))
        with open(CHART_TS_TXT, 'w') as f:
            f.write(ts_now)
        print(f"  done: {DETAIL_HTML} ({os.path.getsize(DETAIL_HTML) // 1024}KB)", flush=True)
        if open_browser:
            subprocess.Popen(['start', f'http://localhost:{PORT}/{DETAIL_HTML}'], shell=True)

    if watch:
        import threading, http.server
        class _Handler(http.server.SimpleHTTPRequestHandler):
            def log_message(self, *a): pass

        def _serve():
            os.chdir(_ROOT)
            with http.server.HTTPServer(('', PORT), _Handler) as srv:
                srv.serve_forever()

        threading.Thread(target=_serve, daemon=True).start()
        print(f"HTTP服务: http://localhost:{PORT}/factor_detail_charts.html", flush=True)
        run_once(open_browser=not no_open)
        print("--watch: 每60秒自动更新（Ctrl+C停止）", flush=True)
        while True:
            time.sleep(60)
            try:
                run_once()
            except Exception as e:
                print(f"更新失败: {e}", flush=True)
    else:
        run_once(open_browser=not no_open)


# ════════════════════════════════════════════════════════════
#  入口
# ════════════════════════════════════════════════════════════

def main():
    import argparse
    ap = argparse.ArgumentParser(description='ETH 因子可视化看板')
    ap.add_argument('--mode',    choices=['live', 'detail'], default='live',
                    help='live=轻量实时看板  detail=深度因子走势图')
    ap.add_argument('--watch',   action='store_true', help='持续更新模式')
    ap.add_argument('--data',    action='store_true', help='[live] 只更新数据')
    ap.add_argument('--no-open', action='store_true', help='不自动打开浏览器')
    args = ap.parse_args()

    if args.mode == 'live':
        run_live(data_only=args.data, watch=args.watch)
    else:
        run_detail(watch=args.watch, no_open=args.no_open)


if __name__ == '__main__':
    main()
