# -*- coding: utf-8 -*-
"""
factor_chart.py — 壳+数据分离架构
  首次运行生成 chart.html（静态壳），后续只更新 chart_data.json
  浏览器每30秒拉取 chart_data.json，只更新 series data，图例/缩放状态不变

用法:
  python factor_chart.py           # 生成壳+数据
  python factor_chart.py --watch   # 后台持续更新（每30秒）
  python factor_chart.py --data    # 只更新数据
"""

import re, json, sys, time, os
from datetime import datetime

_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _ROOT)
LOG_FILE   = os.path.join(_ROOT, "my_trader.log")
OUT_HTML   = os.path.join(_ROOT, "chart.html")
OUT_DATA   = os.path.join(_ROOT, "chart_data.json")
MAX_POINTS = 400

# 从 config.py 动态读取权重（避免与 config.py 不同步）
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),
            'NEWS': getattr(_cfg, 'W_NEWS', 0.17),
            'SM': getattr(_cfg, 'W_SMART_MONEY', 0.00),
        }
    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,
        }

WEIGHTS = _load_weights()

FACTOR_KEYS = ['TR','OB','TK','OI','FR','MP','VD','BTC','GM','IV','EX','LC','MR']
FACTOR_NAMES = {
    'TR':'趋势','OB':'挂单','TK':'成交','OI':'持仓量',
    'FR':'费率','MP':'痛点','VD':'量变','BTC':'大盘',
    'GM':'Gamma','IV':'波动率','EX':'衰竭','LC':'清算','MR':'回归',
}
COLORS = [
    '#ff6b6b','#ffa94d','#ffe066','#a9e34b','#4dabf7',
    '#74c0fc','#da77f2','#f783ac','#e64980','#12b886',
    '#228be6','#fab005','#7950f2',
]
HIDDEN_DEFAULT = {'FR', 'OI', 'IV', 'MP'}

RE_PRICE = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[ETH\] \$([0-9,.]+) \|'
)
RE_FACTOR = 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+)'
)
RE_NEWS_DS = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[News\] DeepSeek: (.+?) ~'
)
RE_NEWS = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[News\] (?!DeepSeek).+=> ([+-]?\d+)'
)
RE_FLASH = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[FLASH\] ★(\d+) (.+)'
)
RE_TRADE_OPEN = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] INFO: \[交易\] ✅ ETH (\w+) ([\d.]+)张'
)
RE_TRADE_CLOSE = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ \[MyTrader\] (?:INFO|WARNING): \[(?:止盈|止损|决策树)\].+(?:平仓|止盈触发|止损触发)'
)


def parse_log(path):
    prices, factors, news_events, flash_events, trades = {}, [], [], [], []
    ds_cache = {}
    try:
        lines = open(path, encoding='utf-8', errors='replace').readlines()
    except:
        return [], [], [], []

    for line in lines:
        m = RE_PRICE.search(line)
        if m:
            prices[m.group(1)] = float(m.group(2).replace(',', ''))
        m = RE_NEWS_DS.search(line)
        if m:
            ds_cache[m.group(1)] = m.group(2)[:120]
        m = RE_NEWS.search(line)
        if m:
            ts = m.group(1)
            news_events.append({'ts': ts, 'score': int(m.group(2)),
                                 'summary': ds_cache.get(ts, '')})
        m = RE_FLASH.search(line)
        if m:
            flash_events.append({'ts': m.group(1), 'score': int(m.group(2)),
                                  'text': m.group(3)[:100]})
        m = RE_FACTOR.search(line)
        if m:
            ts = m.group(1)
            factors.append({
                'ts': ts, 'price': prices.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(18)), 'dir': m.group(19),
            })
        m = RE_TRADE_OPEN.search(line)
        if m:
            trades.append({'ts': m.group(1), 'action': m.group(2),
                           'size': m.group(3), 'type': 'open'})
        m = RE_TRADE_CLOSE.search(line)
        if m:
            trades.append({'ts': m.group(1), 'action': '', 'size': '', 'type': 'close'})

    last_px = None
    for row in factors:
        if row['price'] is None:
            row['price'] = last_px
        else:
            last_px = row['price']
    factors = factors[-MAX_POINTS:]

    valid_px = [r['price'] for r in factors 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 factors:
            row['price_norm'] = round((row['price'] - px_mid) / (px_range / 2), 3) if row['price'] else None
    else:
        for row in factors:
            row['price_norm'] = None

    return factors, news_events, flash_events, trades


def 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 build_data(factors, news_events, flash_events, trades):
    if not factors:
        return {}
    ts_labels = [r['ts'][11:16] for r in factors]
    ts_full   = [r['ts'][:16]   for r in factors]

    series_data, rate_data = {}, {}
    for k in FACTOR_KEYS:
        vals = [r[k] for r in factors]
        series_data[k] = vals
        rate_data[k]   = calc_rate(vals)
    series_data['ETH']   = [r['price_norm'] for r in factors]
    series_data['Total'] = [r['total']      for r in factors]
    rate_data['ETH']     = calc_rate(series_data['ETH'], scale=5.0)
    rate_data['Total']   = 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 = factors[idx]['total'] if idx < len(factors) 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     = factors[-1]
    valid_px = [r['price'] for r in factors if r['price']]
    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(factors),
    }


def build_shell():
    """生成静态 HTML 壳（只需生成一次）"""

    legend_defs = []
    for i, k in enumerate(FACTOR_KEYS):
        hidden = 'true' if k in HIDDEN_DEFAULT else 'false'
        legend_defs.append({
            'key': k, 'label': f'{k} {FACTOR_NAMES[k]}',
            'color': COLORS[i % len(COLORS)],
            'weight': WEIGHTS.get(k, 0), 'hidden': hidden == 'true',
        })
    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(FACTOR_KEYS):
        c = COLORS[i % len(COLORS)]
        series_defs.append({'name': f'{k} {FACTOR_NAMES[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)

    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" id="footer-tags"></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 || {{}};

  // 更新tooltip formatter（含最新新闻数据）
  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: [] }});

  // 只更新 series data（不重建series，保留图例选中状态）
  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: [] }});

  // 更新header
  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自动刷新';

  // 更新footer因子标签
  var footer = document.getElementById('footer');
  footer.innerHTML = '';
  var WMAP = {json.dumps(WEIGHTS)};
  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);

// ── dataZoom联动 ──
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 main():
    data_only = '--data' in sys.argv
    watch     = '--watch' in sys.argv
    log_path  = LOG_FILE

    # 生成壳（只需一次，除非强制重建）
    if not data_only and not os.path.exists(OUT_HTML):
        shell = build_shell()
        with open(OUT_HTML, 'w', encoding='utf-8') as f:
            f.write(shell)
        print(f"[{datetime.now().strftime('%H:%M:%S')}] 生成壳: {OUT_HTML}")

    while True:
        factors, news_events, flash_events, trades = parse_log(log_path)
        data = build_data(factors, news_events, flash_events, trades)
        with open(OUT_DATA, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False)
        print(f"[{datetime.now().strftime('%H:%M:%S')}] 数据更新: {len(factors)}点 "
              f"{len(news_events)}新闻 {len(flash_events)}快讯 {len(trades)}交易")
        if not watch:
            break
        time.sleep(30)


if __name__ == '__main__':
    main()
