"""交互式选股看板:把最新因子快照嵌入 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