← 返回 下载 dashboard.py
"""交互式选股看板:把最新因子快照嵌入 HTML,前端实时调因子权重与周期。

生成的是**单文件、离线可用**的看板:不依赖后端,双击即可打开,
所有因子权重/周期调整都在浏览器内即时重算排序。
"""
from __future__ import annotations

import json
from typing import Dict

HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>多因子选股看板 · __ASOF__</title>
<style>
  :root{
    --ink:#1b2130; --sub:#5f6779; --line:#e4e8f0; --bg:#f6f7fb; --card:#fff;
    --red:#c0392b; --green:#1e8e5a; --accent:#2b5fd9; --accent-soft:#eaf0ff;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{font-family:"Microsoft YaHei","PingFang SC",system-ui,sans-serif;background:var(--bg);
       color:var(--ink);font-size:14px;line-height:1.55}
  .wrap{max-width:1220px;margin:0 auto;padding:24px 18px 56px}
  h1{font-size:21px;letter-spacing:.3px}
  .meta{color:var(--sub);font-size:12px;margin:6px 0 18px}
  .layout{display:grid;grid-template-columns:300px 1fr;gap:16px;align-items:start}
  .panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px}
  .panel h2{font-size:13px;color:var(--sub);font-weight:600;letter-spacing:.5px;margin-bottom:10px}
  .panel h2:not(:first-child){margin-top:20px}
  .periods{display:flex;flex-wrap:wrap;gap:6px}
  .periods button{border:1px solid var(--line);background:#fff;color:var(--ink);border-radius:6px;
    padding:5px 9px;font-size:12.5px;cursor:pointer;transition:.12s}
  .periods button:hover{border-color:#c3cfe8}
  .periods button.on{background:var(--accent);border-color:var(--accent);color:#fff}
  .customrow{display:flex;align-items:center;gap:8px;margin-top:8px;font-size:12px;color:var(--sub)}
  .customrow input{width:64px;padding:4px 6px;border:1px solid var(--line);border-radius:6px;font-size:12px}
  .slider{margin:9px 0}
  .slider .top{display:flex;justify-content:space-between;font-size:12.5px;margin-bottom:3px}
  .slider .val{color:var(--accent);font-variant-numeric:tabular-nums}
  .slider .val.off{color:#b9bfcd}
  input[type=range]{width:100%;accent-color:var(--accent);height:16px}
  .presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
  .presets button{border:1px dashed #c9d2e6;background:#fbfcff;border-radius:14px;padding:3px 9px;
    font-size:11.5px;color:var(--sub);cursor:pointer}
  .presets button:hover{color:var(--accent);border-color:var(--accent)}
  .summary{background:var(--accent-soft);border:1px solid #cfdcff;border-radius:8px;padding:10px 12px;
    font-size:12.5px;color:#22336b;margin-top:14px;line-height:1.7}
  .summary b{font-size:16px;color:var(--accent)}
  table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);
    border-radius:10px;overflow:hidden}
  th,td{padding:9px 10px;text-align:left;border-bottom:1px solid var(--line);font-size:13px}
  th{background:#eef1f8;color:var(--sub);font-weight:600;font-size:12px;white-space:nowrap}
  tbody tr{cursor:pointer}
  tbody tr:hover{background:#fafbff}
  tbody tr.sel{background:#fdf6f5}
  td.num{text-align:right;font-variant-numeric:tabular-nums}
  .code{font-family:Consolas,Menlo,monospace;color:var(--sub)}
  .bar{position:relative;height:6px;background:#eef1f8;border-radius:3px;width:110px;display:inline-block;
    vertical-align:middle;overflow:hidden}
  .bar>i{position:absolute;left:0;top:0;bottom:0;background:var(--accent);border-radius:3px}
  .pos{color:var(--red)}.neg{color:var(--green)}
  .contrib{background:#fbfcff}
  .contrib td{padding:10px 14px;font-size:12.5px;color:var(--sub)}
  .chips{display:flex;flex-wrap:wrap;gap:6px}
  .chip{border:1px solid var(--line);border-radius:12px;padding:2px 8px;background:#fff;font-size:12px}
  .chip b{color:var(--ink)}
  .toolbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
  .toolbar .hint{font-size:12px;color:var(--sub)}
  .btn{border:1px solid var(--line);background:#fff;border-radius:6px;padding:5px 11px;font-size:12.5px;
    cursor:pointer;color:var(--ink)}
  .btn:hover{border-color:var(--accent);color:var(--accent)}
  .note{margin-top:18px;color:var(--sub);font-size:12px;line-height:1.8}
  .disclaim{margin-top:20px;padding:12px 14px;background:#fff8e6;border:1px solid #f0dfa8;border-radius:8px;
    color:#6b5b1e;font-size:12px}
  @media(max-width:900px){.layout{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="wrap">
  <h1>多因子选股看板</h1>
  <div class="meta">信号日 <b>__ASOF__</b> | 样本池 __UNIVERSE__ 只 | 调整左侧因子权重与周期,右侧排序实时更新</div>

  <div class="layout">
    <div class="panel">
      <h2>持有周期</h2>
      <div class="periods" id="periods"></div>
      <div class="customrow">
        <span>自定义</span><input id="customDays" type="number" min="3" max="250" placeholder="交易日">
        <button class="btn" id="applyCustom">应用</button>
      </div>

      <h2>因子权重</h2>
      <div id="sliders"></div>
      <div class="presets">
        <button data-preset="momentum">动量型</button>
        <button data-preset="lowvol">低波型</button>
        <button data-preset="balanced">均衡型</button>
        <button data-preset="reversal">反转型</button>
      </div>

      <h2>组合设置</h2>
      <div class="slider">
        <div class="top"><span>推荐股票数</span><span class="val" id="topNVal">12</span></div>
        <input type="range" id="topN" min="3" max="30" step="1" value="12">
      </div>
      <div class="slider">
        <div class="top"><span>现金缓冲</span><span class="val" id="cashVal">5%</span></div>
        <input type="range" id="cash" min="0" max="30" step="1" value="5">
      </div>
      <div class="slider">
        <div class="top"><span>单票上限</span><span class="val" id="capVal">12%</span></div>
        <input type="range" id="cap" min="5" max="30" step="1" value="12">
      </div>
      <div class="slider">
        <div class="top"><span>趋势过滤(200日线之上)</span><span class="val" id="filterVal">开</span></div>
        <input type="range" id="filter" min="0" max="1" step="1" value="1">
      </div>

      <div class="summary" id="summary"></div>
    </div>

    <div>
      <div class="toolbar">
        <div class="hint" id="hint"></div>
        <div>
          <button class="btn" id="exportCsv">导出 CSV</button>
        </div>
      </div>
      <table>
        <thead>
          <tr>
            <th style="width:38px">#</th><th style="width:86px">代码</th><th>名称</th>
            <th style="width:150px">综合得分</th><th style="width:66px">建议权重</th>
            <th style="width:74px">收盘</th><th style="width:88px">距200日线</th><th style="width:82px">年化波动</th>
          </tr>
        </thead>
        <tbody id="tbody"></tbody>
      </table>
      <div class="note">
        得分 = Σ(因子权重 × 因子截面 Z 值),Z 值经 MAD 3 倍去极值后标准化;候选池已剔除上市不足 260 个交易日、
        因子缺失过多的标的。点击任意一行可展开该股各因子贡献。
      </div>
      <div class="disclaim"><b>免责声明</b>:本看板输出为量化模型基于历史价量数据的排序结果,仅供研究参考,
        不构成任何投资建议。市场有风险,投资需谨慎。过往表现不预示未来收益。</div>
    </div>
  </div>
</div>

<script>
var DATA = __DATA_JSON__;
var DEFAULTS = __DEFAULTS__;

var FACTORS = [
  {key:'momentum', label:'动量', periodic:'mom'},
  {key:'quality_momentum', label:'质量动量', periodic:null},
  {key:'low_vol', label:'低波', periodic:null},
  {key:'trend', label:'趋势', periodic:'trend'},
  {key:'reversal', label:'短期反转', periodic:null},
  {key:'high_52w', label:'52周高点', periodic:null},
  {key:'liquidity', label:'流动性', periodic:null}
];

var PERIODS = [
  {label:'周', days:5, mom:20, trend:20, rev:0.20},
  {label:'双周', days:10, mom:60, trend:60, rev:0.10},
  {label:'月', days:20, mom:120, trend:120, rev:0.00},
  {label:'季', days:60, mom:180, trend:200, rev:0.00},
  {label:'半年', days:120, mom:250, trend:250, rev:0.00},
  {label:'年', days:250, mom:250, trend:250, rev:0.00}
];

var state = {
  periodIdx: 2,
  custom: null,
  weights: Object.assign({}, DEFAULTS.weights),
  topN: DEFAULTS.topN,
  cash: 0.05,
  cap: 0.12,
  trendFilter: true,
  expanded: null
};

function currentPeriod(){
  if (state.custom) { return state.custom; }
  return PERIODS[state.periodIdx];
}

function factorKey(f){
  var p = currentPeriod();
  if (f.key === 'momentum') return 'mom' + p.mom;
  if (f.key === 'trend') return 'trend' + p.trend;
  return f.key;
}

/* 截面去极值(MAD 3x) + Z-score,返回 code -> z */
function zscore(values){
  var codes = Object.keys(values).filter(function(c){ return isFinite(values[c]); });
  if (codes.length < 8) return {};
  var arr = codes.map(function(c){ return values[c]; }).sort(function(a,b){ return a-b; });
  function median(a){
    var n = a.length;
    return n % 2 ? a[(n-1)/2] : (a[n/2-1] + a[n/2]) / 2;
  }
  var med = median(arr);
  var devs = arr.map(function(v){ return Math.abs(v - med); }).sort(function(a,b){ return a-b; });
  var scale = 1.4826 * median(devs);
  var clipped = {};
  codes.forEach(function(c){
    var v = values[c];
    if (scale > 1e-12) { v = Math.max(med - 3*scale, Math.min(med + 3*scale, v)); }
    clipped[c] = v;
  });
  var mean = 0; codes.forEach(function(c){ mean += clipped[c]; }); mean /= codes.length;
  var sd = 0; codes.forEach(function(c){ sd += Math.pow(clipped[c]-mean, 2); }); sd = Math.sqrt(sd / (codes.length - 1));
  var out = {};
  codes.forEach(function(c){ out[c] = sd > 1e-12 ? (clipped[c]-mean)/sd : 0; });
  return out;
}

function compute(){
  var p = currentPeriod();
  var zByFactor = {};
  var used = 0;
  FACTORS.forEach(function(f){
    var w = state.weights[f.key] || 0;
    if (w <= 0) return;
    var key = factorKey(f);
    var vals = {};
    DATA.stocks.forEach(function(s){ if (s[key] !== null && s[key] !== undefined) vals[s.code] = s[key]; });
    var z = zscore(vals);
    if (Object.keys(z).length > 0) { zByFactor[f.key] = z; used++; }
  });

  var rows = [];
  DATA.stocks.forEach(function(s){
    if (state.trendFilter && (s.ma200_dev === null || s.ma200_dev < 0)) return;
    var total = 0, contrib = {}, covered = 0;
    FACTORS.forEach(function(f){
      var z = zByFactor[f.key];
      if (!z) return;
      var v = z[s.code];
      if (v === undefined || !isFinite(v)) return;
      var c = (state.weights[f.key] || 0) * v;
      contrib[f.key] = c; total += c; covered++;
    });
    if (covered < Math.max(2, Math.floor(used/2))) return;
    rows.push({code:s.code, name:s.name, score:total, contrib:contrib,
               close:s.close, ma200_dev:s.ma200_dev, vol60:s.vol60});
  });
  rows.sort(function(a,b){ return b.score - a.score; });
  var selected = rows.slice(0, state.topN);
  var base = 1 - state.cash;
  var wEach = selected.length ? Math.min(state.cap, base/selected.length) : 0;
  selected.forEach(function(r){ r.weight = wEach; });
  return {rows:rows, selected:selected, wEach:wEach, used:used, p:p};
}

function pct(x, digits){
  if (x === null || x === undefined || !isFinite(x)) return '-';
  return (x*100).toFixed(digits === undefined ? 1 : digits) + '%';
}

function render(){
  var res = compute();
  var tb = document.getElementById('tbody');
  tb.innerHTML = '';
  var maxAbs = 1e-9;
  res.rows.forEach(function(r){ maxAbs = Math.max(maxAbs, Math.abs(r.score)); });

  res.selected.forEach(function(r, i){
    var tr = document.createElement('tr');
    tr.className = 'sel';
    var barW = Math.max(2, Math.round(Math.abs(r.score)/maxAbs*100));
    tr.innerHTML =
      '<td class="num">' + (i+1) + '</td>' +
      '<td class="code">' + r.code + '</td>' +
      '<td>' + r.name + '</td>' +
      '<td><span class="bar"><i style="width:' + barW + '%"></i></span> ' + r.score.toFixed(2) + '</td>' +
      '<td class="num">' + pct(r.weight, 1) + '</td>' +
      '<td class="num">' + r.close.toFixed(2) + '</td>' +
      '<td class="num ' + (r.ma200_dev >= 0 ? 'pos' : 'neg') + '">' + pct(r.ma200_dev) + '</td>' +
      '<td class="num">' + pct(r.vol60) + '</td>';
    tr.addEventListener('click', function(){
      state.expanded = (state.expanded === r.code) ? null : r.code;
      render();
    });
    tb.appendChild(tr);

    if (state.expanded === r.code) {
      var chips = FACTORS.map(function(f){
        if (!(f.key in r.contrib)) return '';
        var v = r.contrib[f.key];
        var col = v >= 0 ? 'pos' : 'neg';
        return '<span class="chip">' + f.label + ' <b class="' + col + '">' + (v>=0?'+':'') + v.toFixed(2) + '</b></span>';
      }).join('');
      var ctr = document.createElement('tr');
      ctr.className = 'contrib';
      ctr.innerHTML = '<td colspan="8"><div class="chips">' + chips + '</div></td>';
      tb.appendChild(ctr);
    }
  });

  if (!res.selected.length) {
    tb.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#8a93a6;padding:26px">当前设置下无候选标的(可尝试关闭趋势过滤或调低持仓数)</td></tr>';
  }

  document.getElementById('summary').innerHTML =
    '周期 <b>' + res.p.label + '</b>(' + res.p.days + ' 个交易日)<br>' +
    '推荐 <b>' + res.selected.length + '</b> 只 | 合计仓位 ' + pct(res.wEach*res.selected.length, 1) + '<br>' +
    '单票 ' + pct(res.wEach, 1) + ' | 候选池 ' + res.rows.length + ' 只 | 生效因子 ' + res.used + ' 个';

  document.getElementById('hint').textContent =
    '动量窗口 ' + res.p.mom + ' 日 · 趋势窗口 ' + res.p.trend + ' 日 · 排序实时计算';
}

function buildControls(){
  var pc = document.getElementById('periods');
  PERIODS.forEach(function(p, i){
    var b = document.createElement('button');
    b.textContent = p.label;
    b.className = (i === state.periodIdx && !state.custom) ? 'on' : '';
    b.addEventListener('click', function(){
      state.periodIdx = i;
      state.custom = null;
      document.getElementById('customDays').value = '';
      syncPeriodButtons();
      render();
    });
    pc.appendChild(b);
  });

  var sl = document.getElementById('sliders');
  FACTORS.forEach(function(f){
    var wrap = document.createElement('div');
    wrap.className = 'slider';
    var v = Math.round((state.weights[f.key] || 0) * 100);
    wrap.innerHTML =
      '<div class="top"><span>' + f.label + '</span><span class="val" id="val_' + f.key + '">' + v + '</span></div>' +
      '<input type="range" id="w_' + f.key + '" min="0" max="100" step="1" value="' + v + '">';
    sl.appendChild(wrap);
    wrap.querySelector('input').addEventListener('input', function(e){
      state.weights[f.key] = Number(e.target.value) / 100;
      document.getElementById('val_' + f.key).textContent = e.target.value;
      render();
    });
  });

  document.getElementById('topN').addEventListener('input', function(e){
    state.topN = Number(e.target.value);
    document.getElementById('topNVal').textContent = e.target.value;
    render();
  });
  document.getElementById('topN').value = state.topN;
  document.getElementById('topNVal').textContent = state.topN;

  document.getElementById('cash').addEventListener('input', function(e){
    state.cash = Number(e.target.value) / 100;
    document.getElementById('cashVal').textContent = e.target.value + '%';
    render();
  });
  document.getElementById('cap').addEventListener('input', function(e){
    state.cap = Number(e.target.value) / 100;
    document.getElementById('capVal').textContent = e.target.value + '%';
    render();
  });
  document.getElementById('filter').addEventListener('input', function(e){
    state.trendFilter = Number(e.target.value) === 1;
    document.getElementById('filterVal').textContent = state.trendFilter ? '开' : '关';
    render();
  });

  document.getElementById('applyCustom').addEventListener('click', function(){
    var d = Number(document.getElementById('customDays').value);
    if (!d || d < 3 || d > 250) { alert('请输入 3~250 之间的交易日数'); return; }
    var momW = Math.max(20, Math.min(250, Math.round(d*3)));
    var trendW = Math.max(20, Math.min(250, Math.round(d*6)));
    state.custom = {label:'自定义', days:d, mom:nearest(MOM_WINDOWS, momW), trend:nearest(TREND_WINDOWS, trendW), rev:0};
    syncPeriodButtons();
    render();
  });

  document.querySelectorAll('.presets button').forEach(function(b){
    b.addEventListener('click', function(){
      var p = b.getAttribute('data-preset');
      var preset = PRESETS[p];
      FACTORS.forEach(function(f){
        var v = Math.round((preset[f.key] || 0) * 100);
        state.weights[f.key] = v / 100;
        var el = document.getElementById('w_' + f.key);
        if (el) { el.value = v; }
        var lab = document.getElementById('val_' + f.key);
        if (lab) { lab.textContent = v; }
      });
      render();
    });
  });

  document.getElementById('exportCsv').addEventListener('click', function(){
    var res = compute();
    var lines = ['排名,代码,名称,综合得分,建议权重,收盘,距200日线,年化波动,周期'];
    res.selected.forEach(function(r,i){
      lines.push([i+1, r.code, r.name, r.score.toFixed(3), (r.weight*100).toFixed(1)+'%',
        r.close, pct(r.ma200_dev), pct(r.vol60), res.p.label].join(','));
    });
    var blob = new Blob(['\ufeff' + lines.join('\n')], {type:'text/csv;charset=utf-8'});
    var a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'recommend_' + DATA.asOf + '_' + res.p.label + '.csv';
    a.click();
  });
}

var MOM_WINDOWS = __MOM_WINDOWS__;
var TREND_WINDOWS = __TREND_WINDOWS__;
var PRESETS = __PRESETS__;

function nearest(arr, v){
  var best = arr[0];
  arr.forEach(function(x){ if (Math.abs(x - v) < Math.abs(best - v)) best = x; });
  return best;
}

function syncPeriodButtons(){
  var btns = document.querySelectorAll('#periods button');
  for (var i=0;i<btns.length;i++){
    btns[i].className = (i === state.periodIdx && !state.custom) ? 'on' : '';
  }
}

buildControls();
render();
</script>
</body>
</html>"""


def build_dashboard(snapshot: Dict, names: Dict[str, str], period_cfg: Dict,
                    weights: Dict[str, float], top_n: int) -> str:
    """生成交互式看板 HTML。"""
    stocks = []
    for s in snapshot["stocks"]:
        item = dict(s)
        item["name"] = names.get(s["code"], "")
        stocks.append(item)

    data = {
        "asOf": snapshot["as_of"],
        "stocks": stocks,
        "factorLabels": {
            "momentum": "动量", "quality_momentum": "质量动量", "low_vol": "低波",
            "trend": "趋势", "reversal": "短期反转", "high_52w": "52周高点", "liquidity": "流动性",
        },
    }
    defaults = {"weights": weights, "topN": top_n}
    presets = {
        "momentum": {"momentum": 0.4, "quality_momentum": 0.2, "trend": 0.25, "high_52w": 0.1, "low_vol": 0.05},
        "lowvol": {"low_vol": 0.45, "quality_momentum": 0.2, "trend": 0.15, "high_52w": 0.1, "liquidity": 0.1},
        "balanced": {"momentum": 0.25, "quality_momentum": 0.15, "low_vol": 0.2, "trend": 0.2,
                     "reversal": 0.05, "high_52w": 0.1, "liquidity": 0.05},
        "reversal": {"reversal": 0.45, "low_vol": 0.2, "liquidity": 0.15, "trend": 0.2},
    }

    html = HTML_TEMPLATE
    html = html.replace("__DATA_JSON__", json.dumps(data, ensure_ascii=False))
    html = html.replace("__DEFAULTS__", json.dumps(defaults, ensure_ascii=False))
    html = html.replace("__PRESETS__", json.dumps(presets, ensure_ascii=False))
    html = html.replace("__MOM_WINDOWS__", json.dumps([20, 60, 120, 180, 250]))
    html = html.replace("__TREND_WINDOWS__", json.dumps([20, 60, 120, 200, 250]))
    html = html.replace("__ASOF__", str(snapshot["as_of"]))
    html = html.replace("__UNIVERSE__", str(len(stocks)))
    return html