功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层
说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。
## 新增能力
1) `hdiv backtest --mode daily --start <日期>`
- src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
- 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
- `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
- `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
(卖出/减仓同样留痕,否则「为什么卖」缺证据)
- 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
因此不使用训练段、不冻结分布,阈值口径一律 rolling
- 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)
2) PIT 批量取数层 src/hdiv/universe/pit.py
- PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
—— 以保证与逐日单点查询**结果等价**
- 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
- src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)
3) 每日增量同步 `hdiv sync daily`
- src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
支持 `--dry-run` 先看待抓清单
- deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
- 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
(该表已存在于库中,`ddl plan` 返回 0 个待执行动作)
4) Web 与文档
- 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
web/favicon.svg)
- README / docs/user-guide.md / docs/implementation-status.md 同步更新:
三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段
## 测试
tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。
已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**
未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
`SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
(31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
区间 2024-03-01~03-15),说明该路径可正常完成。
## 已知待改进
- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
This commit is contained in:
@@ -105,6 +105,8 @@ table.data th.l,table.data td.l{text-align:left}
|
||||
table.data td.num{font-family:var(--mono)}
|
||||
table.data tbody tr:hover{background:#F1F5F9}
|
||||
table.data tbody tr:last-child td{border-bottom:none}
|
||||
/* 已清仓清单:理由很长,截断显示(悬停看全文),免得整张表横向滚动 */
|
||||
table.data td.reason{max-width:280px;overflow:hidden;text-overflow:ellipsis}
|
||||
.pager{display:flex;gap:10px;align-items:center;justify-content:flex-end;
|
||||
margin-top:12px;font-size:12px;color:var(--text-muted)}
|
||||
|
||||
|
||||
+523
-86
@@ -428,7 +428,19 @@ async function viewStock(symbol) {
|
||||
|
||||
<div class="card">
|
||||
<h2>K线 · 股息率 · PE · 回撤</h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">区间</span>
|
||||
<input type="date" id="sp-from" style="width:auto">
|
||||
<span class="small muted">~</span>
|
||||
<input type="date" id="sp-to" style="width:auto">
|
||||
<button data-act="sp-apply">应用</button>
|
||||
<button data-act="sp-reset">重置</button>
|
||||
<span class="small muted" id="sp-hint"></span>
|
||||
</div>
|
||||
<div id="c_main" class="chart tall"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
区间只影响这张走势图;右侧分布统计与雷达图仍是<b>画像当时的全历史</b>口径。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
@@ -452,41 +464,23 @@ async function viewStock(symbol) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const spState = {data: null, from: '', to: ''};
|
||||
|
||||
function mountStock(d) {
|
||||
const ch = d.chart || {};
|
||||
if (!ch.dates || !ch.dates.length) return;
|
||||
const grid = (t,h) => ({left:62,right:62,top:t,height:h});
|
||||
chart('c_main', {
|
||||
tooltip:{trigger:'axis',axisPointer:{type:'cross'}},
|
||||
axisPointer:{link:[{xAxisIndex:'all'}]},
|
||||
legend:{top:0,textStyle:{color:'#64748B'}},
|
||||
grid:[grid(34,190),grid(262,120),grid(420,110)],
|
||||
xAxis:[
|
||||
{type:'category',data:ch.dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:ch.dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:ch.dates,gridIndex:2,
|
||||
axisLabel:{color:'#64748B',formatter:v=>String(v).slice(0,7)}}],
|
||||
yAxis:[
|
||||
{scale:true,gridIndex:0,name:'价格',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:1,name:'股息率%',axisLabel:{color:'#64748B',formatter:v=>(v*100).toFixed(1)},
|
||||
splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:2,name:'PE',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}],
|
||||
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:8}],
|
||||
series:[
|
||||
{name:'收盘价',type:'line',xAxisIndex:0,yAxisIndex:0,data:ch.close,showSymbol:false,
|
||||
lineStyle:{width:1.4,color:COLORS[0]}},
|
||||
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:ch.dv_yield,showSymbol:false,
|
||||
lineStyle:{width:1.6,color:COLORS[1]},areaStyle:{opacity:.12},
|
||||
markLine:{silent:true,symbol:'none',data:[
|
||||
...(d.yield_summary && d.yield_summary.p75 != null ? [{yAxis:d.yield_summary.p75,
|
||||
lineStyle:{color:COLORS[4],type:'dashed'},
|
||||
label:{formatter:'买入 P75',color:COLORS[4],position:'insideEndTop'}}] : []),
|
||||
...(d.yield_summary && d.yield_summary.p25 != null ? [{yAxis:d.yield_summary.p25,
|
||||
lineStyle:{color:COLORS[3],type:'dashed'},
|
||||
label:{formatter:'卖出 P25',color:COLORS[3],position:'insideEndBottom'}}] : [])]}},
|
||||
{name:'PE(TTM)',type:'line',xAxisIndex:2,yAxisIndex:2,data:ch.pe_ttm,showSymbol:false,
|
||||
lineStyle:{width:1.2,color:COLORS[2]}}]
|
||||
});
|
||||
spState.data = d;
|
||||
spState.from = ''; spState.to = ''; // 换股票回到全区间
|
||||
const from = document.getElementById('sp-from');
|
||||
const to = document.getElementById('sp-to');
|
||||
if (from && to) {
|
||||
// 画像序列是快照,边界就是能选的全部;不设上限会让人以为能选到今天
|
||||
from.min = to.min = ch.dates[0];
|
||||
from.max = to.max = ch.dates[ch.dates.length - 1];
|
||||
from.value = ch.dates[0];
|
||||
to.value = ch.dates[ch.dates.length - 1];
|
||||
}
|
||||
paintStockMain();
|
||||
|
||||
// 直方图(用序列值前端分箱,属于纯呈现,不是业务计算)
|
||||
const vals = (ch.dv_yield || []).filter(v => v != null && v > 0);
|
||||
@@ -521,6 +515,63 @@ function mountStock(d) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 画画像走势图;区间是纯前端截取(画像序列已全量在手,不必再取数)。 */
|
||||
function paintStockMain() {
|
||||
const d = spState.data;
|
||||
const ch = d && d.chart;
|
||||
if (!ch || !ch.dates || !ch.dates.length) return;
|
||||
const all = ch.dates;
|
||||
let i0 = 0, i1 = all.length - 1;
|
||||
if (spState.from) { const k = all.findIndex(x => x >= spState.from); if (k >= 0) i0 = k; }
|
||||
if (spState.to) {
|
||||
for (let i = all.length - 1; i >= 0; i--) { if (all[i] <= spState.to) { i1 = i; break; } }
|
||||
}
|
||||
if (i1 < i0) { toast('开始日期不能晚于结束日期', true); return; }
|
||||
const dates = all.slice(i0, i1 + 1);
|
||||
const cut = k => ((ch[k] || []).length === all.length ? ch[k].slice(i0, i1 + 1) : []);
|
||||
|
||||
const hint = document.getElementById('sp-hint');
|
||||
if (hint) {
|
||||
const asof = (d.profile_run && d.profile_run.asof_date) || all[all.length - 1];
|
||||
hint.textContent = `${dates[0]} ~ ${dates[dates.length - 1]} · ${dates.length} 个交易日`
|
||||
+ ` · 画像快照止于 ${asof}`;
|
||||
}
|
||||
|
||||
disposeChart('c_main');
|
||||
const grid = (t,h) => ({left:62,right:62,top:t,height:h});
|
||||
chart('c_main', {
|
||||
tooltip:{trigger:'axis',axisPointer:{type:'cross'}},
|
||||
axisPointer:{link:[{xAxisIndex:'all'}]},
|
||||
legend:{top:0,textStyle:{color:'#64748B'}},
|
||||
grid:[grid(34,190),grid(262,120),grid(420,110)],
|
||||
xAxis:[
|
||||
{type:'category',data:dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:dates,gridIndex:2,
|
||||
axisLabel:{color:'#64748B',formatter:v=>String(v).slice(0,7)}}],
|
||||
yAxis:[
|
||||
{scale:true,gridIndex:0,name:'价格',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:1,name:'股息率%',axisLabel:{color:'#64748B',formatter:v=>(v*100).toFixed(1)},
|
||||
splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:2,name:'PE',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}],
|
||||
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:8}],
|
||||
series:[
|
||||
{name:'收盘价',type:'line',xAxisIndex:0,yAxisIndex:0,data:cut('close'),showSymbol:false,
|
||||
lineStyle:{width:1.4,color:COLORS[0]},itemStyle:{color:COLORS[0]}},
|
||||
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:cut('dv_yield'),showSymbol:false,
|
||||
lineStyle:{width:1.6,color:COLORS[1]},itemStyle:{color:COLORS[1]},areaStyle:{opacity:.12},
|
||||
markLine:{silent:true,symbol:'none',data:[
|
||||
...(d.yield_summary && d.yield_summary.p75 != null ? [{yAxis:d.yield_summary.p75,
|
||||
lineStyle:{color:COLORS[4],type:'dashed'},
|
||||
label:{formatter:'买入 P75',color:COLORS[4],position:'insideEndTop'}}] : []),
|
||||
...(d.yield_summary && d.yield_summary.p25 != null ? [{yAxis:d.yield_summary.p25,
|
||||
lineStyle:{color:COLORS[3],type:'dashed'},
|
||||
label:{formatter:'卖出 P25',color:COLORS[3],position:'insideEndBottom'}}] : [])]}},
|
||||
{name:'PE(TTM)',type:'line',xAxisIndex:2,yAxisIndex:2,data:cut('pe_ttm'),showSymbol:false,
|
||||
lineStyle:{width:1.2,color:COLORS[2]},itemStyle:{color:COLORS[2]}}]
|
||||
});
|
||||
}
|
||||
|
||||
const LABEL = {dv_yield:'股息率',pe_ttm:'PE(TTM)',pb:'PB',ps_ttm:'PS(TTM)',close:'收盘价',
|
||||
drawdown:'回撤',ttm_dps:'TTM 每股分红',dps:'每股分红',roe:'ROE',roic:'ROIC',
|
||||
gross_margin:'毛利率',net_margin:'净利率',debt_ratio:'资产负债率',
|
||||
@@ -609,6 +660,52 @@ async function viewBacktests(params) {
|
||||
}
|
||||
|
||||
/* ---------------- 视图:回测详情 ---------------- */
|
||||
/** 个股画像闸门:买入信号触发后,用当日可见数据重算画像再逐条核验。 */
|
||||
function profileGateCard(g) {
|
||||
const rules = (g && g.rules) || [];
|
||||
if (!rules.length) return ''; // 该回测没配画像闸门 → 不占版面
|
||||
const STAT = {current_value: '当日值',
|
||||
current_percentile: '当日值在窗口分布中的分位'};
|
||||
const OP = {'>=': '≥', '<=': '≤', '>': '>', '<': '<', '==': '='};
|
||||
const fmtVal = (metric, v) => {
|
||||
if (v == null) return '—';
|
||||
const u = UNIT[metric] || 'ratio';
|
||||
if (u === 'pct') return pct(v, 2);
|
||||
if (u === 'int') return num(v, 0) + (metric.endsWith('_years') ? ' 年' : '');
|
||||
if (u === 'price') return num(v, 2) + ' 元';
|
||||
return num(v, 2); // ratio:倍数/覆盖
|
||||
};
|
||||
const meta = [
|
||||
`画像窗口 ${num(g.window_years, 0)} 年`,
|
||||
g.on_unverifiable === 'pass'
|
||||
? '数据缺失/样本不足时:放行'
|
||||
: '数据缺失/样本不足时:保守不买(REJECT)',
|
||||
g.min_window_coverage ? `最小窗口覆盖率 ${pct(g.min_window_coverage, 0)}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="card">
|
||||
<h2>个股画像筛选条件
|
||||
<span class="badge ${g.enabled ? 'info' : 'dim'}">${g.enabled ? '已启用' : '未启用'}</span></h2>
|
||||
<div class="callout">
|
||||
这是<b>买入的第二道闸门</b>:股息率分位触发买入之后,用<b>当日可见的数据</b>
|
||||
重算一次个股画像,再逐条核验,不通过的票直接剔除
|
||||
(信号类型 <code class="mono">REJECT</code>,每条规则当时的实际值与阈值可在下方
|
||||
「未成交信号与原因」里看到)。<b>取不到值不等于通过</b>,按下方「数据缺失」口径处理。
|
||||
</div>
|
||||
<div class="toolbar"><span class="small muted">${esc(meta)}</span></div>
|
||||
<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">#</th><th class="l">画像指标</th>
|
||||
<th class="l">核验口径</th><th class="l">必须满足</th></tr></thead>
|
||||
<tbody>${rules.map((r, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td class="l">${esc(LABEL[r.metric] || r.metric)}</td>
|
||||
<td class="l small">${esc(STAT[r.stat] || r.stat || '—')}</td>
|
||||
<td class="l mono">${esc(OP[r.op] || r.op || '')} ${esc(fmtVal(r.metric, r.value))}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function viewBacktestDetail(runId) {
|
||||
const b = await api(`backtests/${runId}`);
|
||||
const m = {};
|
||||
@@ -681,6 +778,18 @@ async function viewBacktestDetail(runId) {
|
||||
<div id="pf-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="closed-card">
|
||||
<h2>已清仓了结 <span class="badge info" id="closed-count">…</span></h2>
|
||||
<div class="callout">
|
||||
期末<b>不再持有</b>的个股(已全部卖出)。<b>卖出后至今</b>是清仓日收盘到最新收盘的涨跌:
|
||||
<b class="loss">跌</b>说明这笔卖对了,<b class="gain">涨</b>说明可能卖早了。
|
||||
<b>收益率</b> = 已实现盈亏 ÷ 买入金额,即这笔投资的累计收益率(不是年化;
|
||||
已清仓,所以全部盈亏都已落袋)。
|
||||
点代码可打开该股在本次回测中的走势图(默认已经画到最新行情)。
|
||||
</div>
|
||||
<div id="closed-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>回测条件</h2>
|
||||
<div class="tw"><table class="data">
|
||||
@@ -691,6 +800,8 @@ async function viewBacktestDetail(runId) {
|
||||
${b.strategy.description ? `<div class="callout" style="margin-top:12px">${esc(b.strategy.description)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
${profileGateCard(b.strategy.profile_gate)}
|
||||
|
||||
<div class="card">
|
||||
<h2>逐笔成交与理由 <span class="badge info" id="trade-count">…</span></h2>
|
||||
<div class="callout">
|
||||
@@ -700,6 +811,30 @@ async function viewBacktestDetail(runId) {
|
||||
<div id="trades"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="du-card" style="display:none">
|
||||
<h2>每日动态股票池 <span class="badge info" id="du-badge">…</span></h2>
|
||||
<div class="callout">
|
||||
本模式<b>每个交易日</b>按当时可见数据重建股票池(PIT)。
|
||||
每个决策日的入选成员都留了痕,可回答「某天为什么是这些股票」。
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">决策日</span>
|
||||
<select id="du-date" style="width:auto"></select>
|
||||
<span class="small muted" id="du-note"></span>
|
||||
</div>
|
||||
<div id="du-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tradeprof-card" style="display:none">
|
||||
<h2>成交个股的实时画像 <span class="badge info" id="tradeprof-badge">…</span></h2>
|
||||
<div class="callout">
|
||||
每一笔成交,都附上<b>该笔决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
|
||||
这与「个股画像」页的批量画像同一定义,只是按决策日取值 ——
|
||||
它回答的是「当时凭什么买/卖」。点代码可进入个股页看完整曲线与全部画像指标。
|
||||
</div>
|
||||
<div id="tradeprof"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="skips-card" style="display:none">
|
||||
<h2>未成交信号与原因</h2>
|
||||
<div class="callout warn">这些信号「想交易但没成交」,用于判断约束是否实质影响绩效。</div>
|
||||
@@ -824,6 +959,8 @@ async function mountBacktestDetail(runId) {
|
||||
|
||||
const t = await api(`backtests/${runId}/trades?size=200`);
|
||||
document.getElementById('trade-count').textContent = t.total + ' 笔';
|
||||
// 成交个股的实时画像(仅 daily 模式有留痕;无留痕时整张卡片不显示)
|
||||
renderTradedProfiles(runId, t.items);
|
||||
const host = document.getElementById('trades');
|
||||
if (!t.items.length) { host.innerHTML = '<div class="empty">无成交记录</div>'; }
|
||||
else {
|
||||
@@ -845,6 +982,14 @@ async function mountBacktestDetail(runId) {
|
||||
${t.total > t.items.length ? `<div class="pager">仅显示前 ${t.items.length} / ${t.total} 笔</div>` : ''}`;
|
||||
}
|
||||
|
||||
// 已清仓了结清单(期末不再持有的个股 + 清仓后至今涨跌)
|
||||
try {
|
||||
renderClosedPositions(runId, await api(`backtests/${runId}/closed-positions`));
|
||||
} catch (e) {
|
||||
const host = document.getElementById('closed-body');
|
||||
if (host) host.innerHTML = `<div class="callout fail">加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const sk = await api(`backtests/${runId}/signals`);
|
||||
if (sk.items && sk.items.length) {
|
||||
@@ -860,6 +1005,178 @@ async function mountBacktestDetail(runId) {
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
} catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ }
|
||||
|
||||
try { await mountDailyUniverse(runId); }
|
||||
catch (e) { /* 每日动态股票池只在 --mode daily 存在,失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
/* ---------------- 回测详情:成交个股的实时画像(--mode daily) ---------------- */
|
||||
|
||||
/** 把每笔成交的 `reason.profile` 汇总成一张表。
|
||||
*
|
||||
* 数据源:`GET /api/backtests/{id}/trades` 的 `items[].reason.profile`
|
||||
* (由引擎在决策当日写入,不是事后重算)。非 daily 模式或关闭了
|
||||
* `daily.profile_on_trade` 时没有任何留痕,整张卡片不显示。
|
||||
*/
|
||||
/** 已清仓了结清单。清仓复盘真正要回答的是「这笔卖对了没有」。 */
|
||||
function renderClosedPositions(runId, cp) {
|
||||
const host = document.getElementById('closed-body');
|
||||
const badge = document.getElementById('closed-count');
|
||||
if (!host) return;
|
||||
const s = cp.summary || {};
|
||||
if (badge) badge.textContent = `${num(s.count, 0)} 只`;
|
||||
if (!cp.items || !cp.items.length) {
|
||||
host.innerHTML = '<div class="empty">本次回测没有已清仓的个股'
|
||||
+ '(期末仍持有的都在上方「持仓明细」里)</div>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<span class="small muted">共 <b>${num(s.count, 0)}</b> 只 · 合计已实现
|
||||
<b class="${sign(s.realized_pnl)}">${yi(s.realized_pnl)}</b> ·
|
||||
清仓后<b class="gain">上涨 ${num(s.since_sell_up, 0)}</b> /
|
||||
<b class="loss">下跌 ${num(s.since_sell_down, 0)}</b> 只
|
||||
${s.asof ? `· 价格截至 <span class="mono">${esc(s.asof)}</span>` : ''}</span>
|
||||
</div>
|
||||
<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
|
||||
<th class="l">持仓区间</th><th>持有天数</th>
|
||||
<th>买入金额</th><th>卖出金额</th>
|
||||
<th>已实现盈亏</th><th>收益率</th>
|
||||
<th>清仓日</th><th>卖出后至今</th>
|
||||
<th class="l">清仓理由</th></tr></thead>
|
||||
<tbody>${cp.items.map(x => `<tr>
|
||||
<td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}"
|
||||
class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="l">${esc(x.name || '—')}</td>
|
||||
<td class="l small">${esc(x.industry || '—')}</td>
|
||||
<td class="l num small">${esc(x.first_hold || '—')} ~ ${esc(x.last_hold || '—')}</td>
|
||||
<td class="num">${num(x.hold_days, 0)}</td>
|
||||
<td class="num">${num(x.buy_amount, 0)}</td>
|
||||
<td class="num">${num(x.sell_amount, 0)}</td>
|
||||
<td class="num ${sign(x.realized_pnl)}">${num(x.realized_pnl, 0)}</td>
|
||||
<td class="num ${sign(x.return_pct)}">${pct(x.return_pct)}</td>
|
||||
<td class="num">${esc(x.last_sell || '—')}</td>
|
||||
<td class="num ${sign(x.since_sell_pct)}">${pct(x.since_sell_pct)}</td>
|
||||
<td class="l small reason" title="${esc(x.sell_reason || '')}">${esc(x.sell_reason || '—')}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
function renderTradedProfiles(runId, items) {
|
||||
const card = document.getElementById('tradeprof-card');
|
||||
const host = document.getElementById('tradeprof');
|
||||
if (!card || !host) return;
|
||||
const rows = (items || []).filter(t => t.reason && t.reason.profile);
|
||||
if (!rows.length) return; // 无留痕 → 不显示该卡片
|
||||
card.style.display = '';
|
||||
const syms = new Set(rows.map(t => t.symbol));
|
||||
const badge = document.getElementById('tradeprof-badge');
|
||||
if (badge) badge.textContent = `${syms.size} 只 / ${rows.length} 笔成交`;
|
||||
|
||||
const numOr = (v, u) => (v === null || v === undefined ? '—' : fmtUnit(v, u));
|
||||
host.innerHTML = `<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">代码</th><th>信号日</th><th>成交日</th><th>方向</th>
|
||||
<th>股息率</th><th>股息率分位</th><th>PE(TTM)</th><th>PB</th>
|
||||
<th>ROE(5年均)</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th>
|
||||
<th>当日池内</th></tr></thead>
|
||||
<tbody>${rows.map(t => {
|
||||
const p = t.reason.profile || {};
|
||||
const v = p.values || {};
|
||||
const pc = p.percentiles || {};
|
||||
const inPool = t.reason.in_universe;
|
||||
return `<tr>
|
||||
<td class="l"><a class="mono"
|
||||
href="#/backtests/${esc(runId)}/stocks/${esc(t.symbol)}">${esc(t.symbol)}</a></td>
|
||||
<td class="num">${esc(t.signal_date)}</td>
|
||||
<td class="num">${esc(t.execution_date)}</td>
|
||||
<td><span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span></td>
|
||||
<td class="num">${numOr(v.dv_yield, 'pct')}</td>
|
||||
<td class="num">${pc.dv_yield == null ? '—' : num(pc.dv_yield, 1) + '%'}</td>
|
||||
<td class="num">${numOr(v.pe_ttm, 'ratio')}</td>
|
||||
<td class="num">${numOr(v.pb, 'ratio')}</td>
|
||||
<td class="num">${numOr(v.roe_avg, 'pct')}</td>
|
||||
<td class="num">${numOr(v.dividend_continuity_years, 'int')} 年</td>
|
||||
<td class="num">${numOr(v.payout_ratio, 'pct')}</td>
|
||||
<td class="num">${v.fcf_dividend_cover == null ? '—'
|
||||
: num(v.fcf_dividend_cover, 2) + 'x'}</td>
|
||||
<td>${inPool === undefined || inPool === null ? '—'
|
||||
: (inPool ? '<span class="badge ok">在</span>'
|
||||
: '<span class="badge warn">已出</span>')}</td></tr>`;
|
||||
}).join('')}</tbody></table></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
「当日池内 = 已出」表示该笔发生在持仓<b>掉出当日股票池之后</b>
|
||||
(<code>pool_exit_action: hold</code> 下只减不加 / 按分位卖出)。
|
||||
每行的数值都取自该笔成交的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------------- 回测详情:每日动态股票池(--mode daily) ---------------- */
|
||||
|
||||
const duState = {runId: null, timeline: [], date: null};
|
||||
|
||||
async function mountDailyUniverse(runId) {
|
||||
const card = document.getElementById('du-card');
|
||||
if (!card) return;
|
||||
const tl = await api(`backtests/${runId}/daily-universe`);
|
||||
if (!tl.available || !tl.timeline || !tl.timeline.length) return; // 非 daily 模式
|
||||
duState.runId = runId;
|
||||
duState.timeline = tl.timeline;
|
||||
card.style.display = '';
|
||||
|
||||
const sizes = tl.timeline.map(x => x.member_count);
|
||||
const uniq = new Set();
|
||||
document.getElementById('du-badge').textContent =
|
||||
`${tl.timeline.length} 个有成员的决策日 · 池内 ${Math.min(...sizes)}~${Math.max(...sizes)} 只`;
|
||||
|
||||
const sel = document.getElementById('du-date');
|
||||
sel.innerHTML = tl.timeline.map(x =>
|
||||
`<option value="${esc(x.trade_date)}"${x.trade_date === tl.trade_date ? ' selected' : ''}>`
|
||||
+ `${esc(x.trade_date)}(${x.member_count} 只)</option>`).join('');
|
||||
sel.addEventListener('change', () => loadDailyUniverse(sel.value));
|
||||
duState.date = tl.trade_date;
|
||||
await loadDailyUniverse(duState.date);
|
||||
}
|
||||
|
||||
async function loadDailyUniverse(date) {
|
||||
const host = document.getElementById('du-body');
|
||||
const note = document.getElementById('du-note');
|
||||
if (!host) return;
|
||||
host.innerHTML = '<div class="loading">加载中…</div>';
|
||||
const d = await api(`backtests/${duState.runId}/daily-universe?date=${encodeURIComponent(date)}`);
|
||||
const items = d.members || [];
|
||||
if (note) {
|
||||
const row = duState.timeline.find(x => x.trade_date === date) || {};
|
||||
note.textContent = row.listed_count
|
||||
? `市场候选 ${row.listed_count} 只 → 实际筛选 ${row.candidate_count ?? '—'} 只 → 入选 ${items.length} 只`
|
||||
: '';
|
||||
}
|
||||
if (!items.length) { host.innerHTML = '<div class="empty">该日无入选成员</div>'; return; }
|
||||
host.innerHTML = `<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
|
||||
<th>股息率</th><th>PE(TTM)</th><th>PB</th><th>总市值</th>
|
||||
<th>5年ROE</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th></tr></thead>
|
||||
<tbody>${items.map(x => {
|
||||
const v = x.values || {};
|
||||
return `<tr>
|
||||
<td class="l mono">${esc(x.symbol)}</td>
|
||||
<td class="l">${esc(x.name || '—')}</td>
|
||||
<td class="l small">${esc(x.industry || '—')}</td>
|
||||
<td class="num">${pct(x.dividend_yield)}</td>
|
||||
<td class="num">${num(v.pe_ttm)}</td>
|
||||
<td class="num">${num(v.pb)}</td>
|
||||
<td class="num">${yi(x.total_mv)}</td>
|
||||
<td class="num">${pct(x.roe_avg)}</td>
|
||||
<td class="num">${num(v.dividend_continuity_years,0)} 年</td>
|
||||
<td class="num">${pct(v.payout_ratio)}</td>
|
||||
<td class="num">${num(v.fcf_dividend_cover)}x</td></tr>`;
|
||||
}).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
「股息率」是<b>筛选口径</b>(<code>universe.yml: yield_source</code>,默认自算 TTM);
|
||||
PE/PB 来自 <code>daily_basic</code>;5 年 ROE、支付率、FCF 覆盖来自年报(PIT:
|
||||
只用当时已公告的财报)。
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------------- 回测详情:持仓明细 ---------------- */
|
||||
@@ -958,7 +1275,8 @@ function shiftPortfolio(delta) {
|
||||
|
||||
/* ---------------- 视图:回测内个股买卖点 ---------------- */
|
||||
|
||||
const stState = {runId: null, symbol: null, data: null, shown: {}};
|
||||
const stState = {runId: null, symbol: null, data: null, shown: {},
|
||||
from: '', to: '', seq: 0, rtDone: false};
|
||||
|
||||
async function viewBacktestStock(runId, symbol) {
|
||||
let d;
|
||||
@@ -994,25 +1312,47 @@ async function viewBacktestStock(runId, symbol) {
|
||||
|
||||
<div class="card">
|
||||
<h2>趋势与买卖点</h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">区间</span>
|
||||
<input type="date" id="st-from" style="width:auto">
|
||||
<span class="small muted">~</span>
|
||||
<input type="date" id="st-to" style="width:auto">
|
||||
<button data-act="st-apply">应用</button>
|
||||
<button data-act="st-reset">重置</button>
|
||||
<span class="small muted" id="st-range-hint"></span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">显示指标(勾几项就是几联图,自上而下排列):</span>
|
||||
${d.available_series.map(k => `<label class="check">
|
||||
<input type="checkbox" class="st-ser" value="${k}"
|
||||
${['close','dv_yield','pe_ttm','roe'].includes(k) ? 'checked' : ''}>
|
||||
${esc(SERIES_LABEL[k] || k)}</label>`).join('')}
|
||||
<button data-act="st-apply" style="margin-left:auto">应用</button>
|
||||
</div>
|
||||
<div id="c_stock" class="chart"></div>
|
||||
<div id="c_stock_note"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
每个指标<b>独占一个面板</b>(N 联图):时间轴、缩放与十字光标上下联动,
|
||||
便于对照同一时点的估值与质量读数。<b>▲ 买入 / ▼ 卖出</b> 同时标注在<b>每个面板</b>上,
|
||||
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。
|
||||
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。<br/>
|
||||
默认区间 = 首笔成交<b>往前 5 年</b>(股息率分位的判据窗口)→ <b>该股最新行情</b>:
|
||||
往前留够判据,才能回答「当时凭什么买」;一直画到今天,
|
||||
才能回答「卖飞了还是卖对了」。区间可自由改。
|
||||
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
|
||||
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="rt-profile-card">
|
||||
<h2>决策时点实时画像 <span class="badge info" id="rt-badge">…</span></h2>
|
||||
<div class="callout">
|
||||
这是<b>每个买卖决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
|
||||
与「个股画像」页的批量画像同一定义,但按<b>决策日</b>取值 ——
|
||||
它回答的是「当时凭什么买/卖」,而不是「今天回头看它长什么样」。
|
||||
数值来自成交记录的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
|
||||
</div>
|
||||
<div id="rt-profile"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>逐笔成交明细 <span class="badge info">${st.trade_count}</span></h2>
|
||||
${st.trade_count ? `<div class="tw"><table class="data">
|
||||
@@ -1036,6 +1376,62 @@ async function viewBacktestStock(runId, symbol) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 决策时点实时画像:把成交记录里的 reason_json.profile 渲染成表。
|
||||
*
|
||||
* 数据来自 `GET /api/backtests/{id}/stocks/{symbol}` 的 trades[].reason.profile,
|
||||
* 即引擎在决策当日算出的 PIT 画像快照 —— 不是事后重算。
|
||||
*/
|
||||
function renderRtProfile(trades) {
|
||||
const rows = (trades || []).filter(t => t.reason && t.reason.profile);
|
||||
const host = document.getElementById('rt-profile');
|
||||
const badge = document.getElementById('rt-badge');
|
||||
if (!host) return;
|
||||
if (badge) badge.textContent = rows.length ? rows.length + ' 个决策时点' : '无留痕';
|
||||
if (!rows.length) {
|
||||
host.innerHTML = '<div class="empty">本次回测的成交记录里没有画像留痕'
|
||||
+ '(仅 <code>--mode daily</code> 且 <code>daily.profile_on_trade: true</code> 时记录)</div>';
|
||||
return;
|
||||
}
|
||||
// 优先展示的判据指标(与闸门规则同一批),其余放在展开区
|
||||
const PRIMARY = ['dv_yield', 'dv_yield_pct', 'pe_ttm', 'pb', 'roe_avg',
|
||||
'dividend_continuity_years', 'payout_ratio', 'fcf_dividend_cover'];
|
||||
host.innerHTML = rows.map(t => {
|
||||
const p = t.reason.profile || {};
|
||||
const vals = p.values || {};
|
||||
const pcts = p.percentiles || {};
|
||||
const cells = PRIMARY.map(k => {
|
||||
if (k === 'dv_yield_pct') {
|
||||
return `<td class="num">${pcts.dv_yield == null ? '—' : num(pcts.dv_yield, 1) + '%'}</td>`;
|
||||
}
|
||||
const u = UNIT[k] || 'ratio';
|
||||
return `<td class="num">${fmtUnit(vals[k], u)}</td>`;
|
||||
}).join('');
|
||||
// 其余指标(全历史/各窗口的完整快照)折叠展示,避免默认刷屏
|
||||
const others = Object.keys(vals).filter(k => !PRIMARY.includes(k) && k !== 'dv_yield_pct')
|
||||
.sort().map(k => `<span class="mono small" style="margin-right:10px">`
|
||||
+ `${esc(LABEL[k] || k)}=${fmtUnit(vals[k], UNIT[k] || 'ratio')}</span>`).join('');
|
||||
const gate = t.reason.profile_gate;
|
||||
const gateTxt = gate
|
||||
? `<span class="badge ${gate.verdict === 'PASS' ? 'ok' : 'warn'}">闸门 ${esc(gate.verdict)}</span>`
|
||||
: '';
|
||||
return `<div style="border-top:1px solid var(--line);padding:10px 0">
|
||||
<div class="small" style="margin-bottom:6px">
|
||||
<b>${esc(t.signal_date)}</b>
|
||||
<span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span>
|
||||
${gateTxt}
|
||||
<span class="muted">画像窗口 ${esc(String(p.window_years ?? '—'))} 年 · 决策日 ${esc(p.asof || '—')}</span>
|
||||
</div>
|
||||
<div class="tw"><table class="data"><thead><tr>
|
||||
${PRIMARY.map(k => `<th>${esc(k === 'dv_yield_pct' ? '股息率分位'
|
||||
: (LABEL[k] || k))}</th>`).join('')}
|
||||
</tr></thead><tbody><tr>${cells}</tr></tbody></table></div>
|
||||
${others ? `<details style="margin-top:6px"><summary class="small muted">
|
||||
展开该决策日的全部 ${Object.keys(vals).length} 项画像指标</summary>
|
||||
<div style="margin-top:6px">${others}</div></details>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const SERIES_LABEL = {close: '股价', dv_yield: '股息率', pe_ttm: 'PE(TTM)',
|
||||
pb: 'PB', roe: 'ROE', drawdown: '回撤'};
|
||||
// 面板排列顺序:股价永远在首位(买卖点以成交价标注),其余按 估值 → 质量 → 风险 排。
|
||||
@@ -1067,10 +1463,18 @@ function mountBacktestStock(runId, symbol) {
|
||||
return;
|
||||
}
|
||||
host.innerHTML = '<div class="loading">加载中…</div>';
|
||||
// 区间留空 = 用后端缺省区间(持仓起点 → 该股最新行情)
|
||||
const range = (stState.from ? `&start=${encodeURIComponent(stState.from)}` : '')
|
||||
+ (stState.to ? `&end=${encodeURIComponent(stState.to)}` : '');
|
||||
const seq = ++stState.seq;
|
||||
const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`
|
||||
+ `?series=${shown.join(',')}`);
|
||||
+ `?series=${shown.join(',')}${range}`);
|
||||
if (seq !== stState.seq) return; // 连续改区间时丢掉过期响应
|
||||
stState.data = d;
|
||||
// 决策时点实时画像与 K 线无关(不随 series/区间变化),只在本次挂载渲染一次
|
||||
if (!stState.rtDone) { renderRtProfile(d.trades); stState.rtDone = true; }
|
||||
host.innerHTML = '';
|
||||
syncStockRange(d.range);
|
||||
paintStockPanels(host, d, shown);
|
||||
};
|
||||
render().catch(e => {
|
||||
@@ -1082,6 +1486,24 @@ function mountBacktestStock(runId, symbol) {
|
||||
return render;
|
||||
}
|
||||
|
||||
/** 把实际生效的区间写回日期框,并把该股行情边界设成 min/max。 */
|
||||
function syncStockRange(r) {
|
||||
const from = document.getElementById('st-from');
|
||||
const to = document.getElementById('st-to');
|
||||
if (from && to) {
|
||||
if (r.available_start) { from.min = r.available_start; to.min = r.available_start; }
|
||||
if (r.available_end) { from.max = r.available_end; to.max = r.available_end; }
|
||||
from.value = r.start || '';
|
||||
to.value = r.end || '';
|
||||
}
|
||||
const hint = document.getElementById('st-range-hint');
|
||||
if (hint) {
|
||||
hint.textContent = `${r.start} ~ ${r.end} · ${r.points} 个交易日`
|
||||
+ (r.downsampled ? '(已降采样显示)' : '')
|
||||
+ (r.available_end ? ` · 该股行情至 ${r.available_end}` : '');
|
||||
}
|
||||
}
|
||||
|
||||
/** 把选中的指标画成 N 联图:每个指标一个 grid,共用一个时间轴与缩放。 */
|
||||
function paintStockPanels(host, d, shown) {
|
||||
const N = shown.length;
|
||||
@@ -1100,20 +1522,9 @@ function paintStockPanels(host, d, shown) {
|
||||
tradesOn.get(t.execution_date).push(t);
|
||||
});
|
||||
|
||||
// 成交日可能落在价格区间之外(实测 000338.SZ 的卖出在区间最后一天之后一天),
|
||||
// 直接用日期当类目会把这笔成交整笔丢掉。这里把这些日期按序补进横轴,
|
||||
// 让股价面板仍能按成交价标出买卖点。
|
||||
const extraDates = [...new Set(d.trades.map(t => t.execution_date))]
|
||||
.filter(dt => !dateIndex.has(dt)).sort();
|
||||
const axisDates = d.dates.slice();
|
||||
extraDates.forEach(dt => {
|
||||
let lo = 0, hi = axisDates.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (axisDates[mid] < dt) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
axisDates.splice(lo, 0, dt);
|
||||
});
|
||||
// 区间外的成交不占横轴类目(用户把区间收窄时,把 2021 年的买卖点塞进
|
||||
// 2024 年的横轴会把图彻底搞乱),只用下方文字提示它们的存在。
|
||||
const outside = d.trades.filter(t => !dateIndex.has(t.execution_date));
|
||||
|
||||
const titles = [], grids = [], xAxis = [], yAxis = [], series = [];
|
||||
shown.forEach((k, i) => {
|
||||
@@ -1122,7 +1533,7 @@ function paintStockPanels(host, d, shown) {
|
||||
const top = gridTop(i);
|
||||
grids.push({left: 76, right: 26, top, height: PANEL_H});
|
||||
xAxis.push({
|
||||
type: 'category', data: axisDates, gridIndex: i,
|
||||
type: 'category', data: d.dates, gridIndex: i,
|
||||
// 两端留 1% 空隙:首/末成交日的三角标不会被画到 grid 外面切掉
|
||||
boundaryGap: ['1%', '1%'],
|
||||
axisTick: {show: false},
|
||||
@@ -1153,28 +1564,22 @@ function paintStockPanels(host, d, shown) {
|
||||
});
|
||||
series.push({
|
||||
name: SERIES_LABEL[k] || k, type: 'line', xAxisIndex: i, yAxisIndex: i,
|
||||
// 指标序列按补过日期的横轴对齐(多出来的位置为 null,折线自然断开)
|
||||
data: k === 'close' && !extraDates.length
|
||||
? vals : axisDates.map(dt => {
|
||||
const j = dateIndex.get(dt);
|
||||
return j === undefined ? null : vals[j];
|
||||
}),
|
||||
showSymbol: false, sampling: 'lttb', z: 5,
|
||||
data: vals, showSymbol: false, sampling: 'lttb', z: 5,
|
||||
lineStyle: {width: k === 'close' ? 1.6 : 1.3, color},
|
||||
itemStyle: {color},
|
||||
...(k === 'dv_yield' ? {areaStyle: {opacity: 0.08, color}} : {}),
|
||||
});
|
||||
// 买卖点画在**每个**面板上:取该指标在成交日的取值,
|
||||
// 这样能直接看出「买在多少股息率 / 多少 PE」。
|
||||
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致;
|
||||
// 区间外的成交日只有成交价、没有指标值,因此只画在股价面板。
|
||||
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致。
|
||||
[['BUY', '买入', COLORS[4], 'triangle', k === 'close' ? 12 : 8],
|
||||
['SELL', '卖出', COLORS[3], 'diamond', k === 'close' ? 12 : 8]]
|
||||
.forEach(([side, cn, c, sym, size]) => {
|
||||
const pts = d.trades.filter(t => t.side === side).map(t => {
|
||||
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
|
||||
const j = dateIndex.get(t.execution_date);
|
||||
const v = j === undefined ? null : vals[j];
|
||||
if (j === undefined) return null; // 区间外:见下方文字提示
|
||||
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
|
||||
const v = vals[j];
|
||||
return v == null ? null : [t.execution_date, v];
|
||||
}).filter(Boolean);
|
||||
if (!pts.length) return;
|
||||
@@ -1229,17 +1634,16 @@ function paintStockPanels(host, d, shown) {
|
||||
series,
|
||||
});
|
||||
|
||||
// 区间外的成交只画得出股价面板,明确说明,避免读者以为图上没卖点就是没卖过
|
||||
// 区间外的成交画不出来(横轴上没有那一天),必须说明,
|
||||
// 否则读者会以为「图上没有卖点 = 这笔没卖过」
|
||||
const note = document.getElementById('c_stock_note');
|
||||
if (note) {
|
||||
const items = extraDates.map(dt => (tradesOn.get(dt) || []).map(t =>
|
||||
`${dt} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`).join('、'))
|
||||
.filter(Boolean);
|
||||
const items = outside.map(t =>
|
||||
`${t.execution_date} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`);
|
||||
note.innerHTML = items.length ? `<div class="callout warn" style="margin-top:12px">
|
||||
有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外:
|
||||
${esc(items.join(';'))}。<br/>
|
||||
这些成交只能按<b>成交价</b>标在「股价」面板上,
|
||||
股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
|
||||
有 ${items.length} 笔成交不在所选区间(${esc(d.range.start)} ~ ${esc(d.range.end)})内,
|
||||
因此没有画在图上:${esc(items.join(';'))}。<br/>
|
||||
把区间放宽(或点「重置」)即可看到这些买卖点。
|
||||
</div>` : '';
|
||||
}
|
||||
}
|
||||
@@ -1453,7 +1857,11 @@ async function viewArchive() {
|
||||
}
|
||||
|
||||
/* ---------------- 路由 ---------------- */
|
||||
let currentPath = '';
|
||||
// 「已渲染路径」缓存。哨兵值必须是 null,**不能是空串**:首页(hash 为空)解析出的
|
||||
// path 正是 '',若用 '' 表示「还没渲染」,render() 会在首页一开始就
|
||||
// `path === currentPath` 提前返回 —— 概览页永远停在 index.html 里那句「加载中…」。
|
||||
// 同理,所有「强制重渲染」的地方一律写 currentPath = null。
|
||||
let currentPath = null;
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#/, '') || '/';
|
||||
const [path, qs] = raw.split('?');
|
||||
@@ -1497,6 +1905,8 @@ async function render() {
|
||||
else if (parts[0] === 'backtests' && parts.length === 4 && parts[2] === 'stocks') {
|
||||
html = await viewBacktestStock(parts[1], parts[3]);
|
||||
after = () => { stState.runId = parts[1]; stState.symbol = parts[3];
|
||||
stState.from = ''; stState.to = ''; // 换股票回到默认区间
|
||||
stState.rtDone = false; // 换股票要重渲染画像留痕
|
||||
mountBacktestStock(parts[1], parts[3]); };
|
||||
}
|
||||
else if (parts[0] === 'backtests' && parts.length === 2) {
|
||||
@@ -1545,43 +1955,70 @@ document.addEventListener('click', async e => {
|
||||
if (scope === 'b' && cur.universe_run_id)
|
||||
location.hash = `#/universes/${cur.universe_run_id}`;
|
||||
else if (scope === 'u' && location.hash.includes(id))
|
||||
{ currentPath=''; render(); }
|
||||
else { currentPath=''; render(); }
|
||||
{ currentPath=null; render(); }
|
||||
else { currentPath=null; render(); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (act === 'archive') {
|
||||
await patch(`${base}/${id}`, {archived: btn.dataset.to === '1'});
|
||||
toast(btn.dataset.to === '1' ? '已归档' : '已取消归档');
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
if (act === 'delete') {
|
||||
const del = btn.dataset.to === '1';
|
||||
if (del && !confirm('确认删除这条记录?\n\n(软删除:记录会被隐藏,但数据仍完整保留,可随时恢复)')) return;
|
||||
await patch(`${base}/${id}`, {deleted: del});
|
||||
toast(del ? '已删除(可从「归档」页恢复)' : '已恢复');
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
if (act === 'pf-shift') {
|
||||
shiftPortfolio(parseInt(btn.dataset.d, 10));
|
||||
return;
|
||||
}
|
||||
if (act === 'st-apply') {
|
||||
if (act === 'st-apply' || act === 'st-reset') {
|
||||
const host = document.getElementById('c_stock');
|
||||
if (host) {
|
||||
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
|
||||
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
|
||||
disposeCharts();
|
||||
const {parts} = parseHash();
|
||||
mountBacktestStock(parts[1], parts[3]);
|
||||
if (!host) return;
|
||||
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
|
||||
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
|
||||
if (act === 'st-reset') {
|
||||
stState.from = ''; stState.to = '';
|
||||
} else {
|
||||
const from = (document.getElementById('st-from') || {}).value || '';
|
||||
const to = (document.getElementById('st-to') || {}).value || '';
|
||||
if (from && to && from > to) {
|
||||
toast('开始日期不能晚于结束日期', true); return;
|
||||
}
|
||||
stState.from = from; stState.to = to;
|
||||
}
|
||||
disposeCharts();
|
||||
const {parts} = parseHash();
|
||||
mountBacktestStock(parts[1], parts[3]);
|
||||
return;
|
||||
}
|
||||
if (act === 'sp-apply' || act === 'sp-reset') {
|
||||
if (!spState.data) return;
|
||||
if (act === 'sp-reset') {
|
||||
spState.from = ''; spState.to = '';
|
||||
const ch = (spState.data.chart || {});
|
||||
const from = document.getElementById('sp-from');
|
||||
const to = document.getElementById('sp-to');
|
||||
if (from && ch.dates) from.value = ch.dates[0];
|
||||
if (to && ch.dates) to.value = ch.dates[ch.dates.length - 1];
|
||||
} else {
|
||||
const from = (document.getElementById('sp-from') || {}).value || '';
|
||||
const to = (document.getElementById('sp-to') || {}).value || '';
|
||||
if (from && to && from > to) { toast('开始日期不能晚于结束日期', true); return; }
|
||||
spState.from = from; spState.to = to;
|
||||
}
|
||||
paintStockMain();
|
||||
return;
|
||||
}
|
||||
if (act === 'page') {
|
||||
const {parts, q} = parseHash();
|
||||
q.set('page', btn.dataset.p);
|
||||
location.hash = `#/${parts.join('/')}?${q.toString()}`;
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
} catch (err) { toast(err.message, true); }
|
||||
return;
|
||||
@@ -1595,7 +2032,7 @@ document.addEventListener('click', async e => {
|
||||
const fEl = document.getElementById('mfilter');
|
||||
if (fEl) { q.set('passed', fEl.value); q.set('page', '1'); }
|
||||
location.hash = `#/${parts.join('/')}${q.toString() ? '?' + q.toString() : ''}`;
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1615,7 +2052,7 @@ document.addEventListener('change', e => {
|
||||
if (e.target.id === 'incArch') q.set('archived', e.target.checked ? '1' : '0');
|
||||
if (e.target.id === 'incDel') q.set('deleted', e.target.checked ? '1' : '0');
|
||||
location.hash = `#/${parts.join('/')}?${q.toString()}`;
|
||||
currentPath = ''; render();
|
||||
currentPath = null; render();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1625,7 +2062,7 @@ document.addEventListener('keydown', e => {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => { currentPath = ''; render(); });
|
||||
window.addEventListener('hashchange', () => { currentPath = null; render(); });
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
(async function boot() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="高股息回测系统">
|
||||
<rect width="64" height="64" rx="14" fill="#1E40AF"/>
|
||||
<g fill="#FFFFFF">
|
||||
<rect x="15" y="36" width="8" height="13" rx="2"/>
|
||||
<rect x="28" y="27" width="8" height="22" rx="2"/>
|
||||
<rect x="41" y="16" width="8" height="33" rx="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -4,6 +4,10 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>高股息回测系统</title>
|
||||
<!-- 相对路径:站点挂在 /ggx/ 下时解析为 /ggx/app/favicon.svg。
|
||||
不声明的话浏览器会自动去请求站点根的 /favicon.ico,而那个位置不归本项目
|
||||
的 nginx 配置管(每次打开页面都 404,并把错误写进 nginx error_log)。 -->
|
||||
<link rel="icon" type="image/svg+xml" href="app/favicon.svg">
|
||||
<link rel="stylesheet" href="app/app.css">
|
||||
<script src="assets/echarts.min.js"></script>
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user