@arcaneorion/dsh-teaching-board 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +8 -3
  2. package/cordis.patch.yml +6 -2
  3. package/package.json +5 -3
  4. package/skills/stage-panel/SKILL.md +107 -0
  5. package/skills/stage-panel/references/diagram-selection.md +80 -0
  6. package/skills/stage-panel/references/html-patterns.md +93 -0
  7. package/skills/stage-panel/references/visual-style.md +43 -0
  8. package/skills/stage-panel/submode/code/SKILL.md +17 -0
  9. package/skills/stage-panel/submode/code/examples/go-hello-channel.json +36 -0
  10. package/skills/stage-panel/submode/code/examples/go-http-api-basics.json +58 -0
  11. package/skills/stage-panel/submode/code/examples/py-agent-loop.json +79 -0
  12. package/skills/stage-panel/submode/code/examples/py-ai-parameters-intro.json +65 -0
  13. package/skills/stage-panel/submode/code/examples/py-fizzbuzz-intro.json +74 -0
  14. package/skills/stage-panel/submode/code/examples/py-training-loop.json +114 -0
  15. package/skills/stage-panel/submode/code/examples/rs-move-basics.json +118 -0
  16. package/skills/stage-panel/submode/code/references/host-toolchains.md +61 -0
  17. package/skills/stage-panel/submode/code/schemas/lesson.schema.json +225 -0
  18. package/skills/stage-panel/submode/code/templates/lab.html +561 -0
  19. package/skills/stage-panel/submode/game-study/SKILL.md +16 -0
  20. package/skills/stage-panel/submode/game-study/examples/hft-microstructure.json +60 -0
  21. package/skills/stage-panel/submode/game-study/examples/securities-law.json +57 -0
  22. package/skills/stage-panel/submode/game-study/schemas/quest.schema.json +95 -0
  23. package/skills/stage-panel/submode/game-study/templates/quiz.html +516 -0
  24. package/skills/stage-panel/submode/learn/SKILL.md +16 -0
  25. package/skills/stage-panel/submode/learn/examples/epsilon-delta.json +140 -0
  26. package/skills/stage-panel/submode/learn/examples/template-showcase.json +147 -0
  27. package/skills/stage-panel/submode/learn/schemas/lesson.schema.json +184 -0
  28. package/skills/stage-panel/submode/learn/templates/lesson.html +782 -0
  29. package/skills/stage-panel/submode/ml/SKILL.md +9 -0
  30. package/skills/stage-panel/submode/ml/examples/gd.json +7 -0
  31. package/skills/stage-panel/submode/ml/templates/gd.html +217 -0
  32. package/skills/stage-panel/submode/quant/SKILL.md +9 -0
  33. package/skills/stage-panel/submode/quant/references/export_backtest_data.py +192 -0
  34. package/skills/stage-panel/submode/quant/templates/dashboard.html +381 -0
  35. package/src/index.js +11 -0
  36. package/src/skills.js +88 -0
@@ -0,0 +1,381 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>因子回测仪表盘 · factor-lab</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
8
+ <style>
9
+ :root {
10
+ /* 呼应 visual-style.md:蓝灰科幻底 + 高对比黄 + 硬黑描边 + 小圆角 */
11
+ --bg: #0f2233; --surface: #16334a; --surface2: #1b3d57;
12
+ --border: #07111b; --text: #dbeaf5; --text2: #93b3c9;
13
+ --accent: #ffd43b; --green: #7ee787; --red: #ff8a8a; --orange: #ffb454;
14
+ --line: #07111b;
15
+ }
16
+ * { margin: 0; padding: 0; box-sizing: border-box; }
17
+ body {
18
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
19
+ background: var(--bg); color: var(--text); min-height: 100vh;
20
+ }
21
+ .topbar {
22
+ background: var(--surface); border-bottom: 2px solid var(--border);
23
+ padding: 14px 24px; display: flex; align-items: center; gap: 16px;
24
+ position: sticky; top: 0; z-index: 100;
25
+ }
26
+ .topbar .logo { font-size: 18px; font-weight: 700; color: var(--accent); }
27
+ .topbar .sep { color: var(--text2); }
28
+ .topbar .sub { color: var(--text2); font-size: 13px; }
29
+
30
+ .tabs { display: flex; padding: 0 24px; background: var(--surface); border-bottom: 2px solid var(--border); overflow-x: auto; }
31
+ .tab { padding: 12px 20px; cursor: pointer; color: var(--text2); border-bottom: 2px solid transparent; font-size: 14px; white-space: nowrap; transition: all 0.2s; }
32
+ .tab:hover { color: var(--text); }
33
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
34
+ .tab.error { color: var(--red); opacity: 0.5; }
35
+ .tab.error.active { opacity: 1; border-bottom-color: var(--red); color: var(--red); }
36
+
37
+ .main { padding: 20px 24px; max-width: 1800px; margin: 0 auto; }
38
+
39
+ .section-title { font-size: 12px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; margin-top: 24px; }
40
+
41
+ .metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 8px; }
42
+ .metric-card { background: var(--surface); border: 2px solid var(--border); border-radius: 6px; padding: 12px 14px; text-align: center; }
43
+ .metric-card .label { font-size: 10px; color: var(--text2); text-transform: uppercase; margin-bottom: 4px; }
44
+ .metric-card .value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
45
+ .metric-card .value.positive { color: var(--green); }
46
+ .metric-card .value.negative { color: var(--red); }
47
+ .metric-card .value.neutral { color: var(--text); }
48
+
49
+ .charts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 24px; }
50
+ .chart-box { background: var(--surface); border: 2px solid var(--border); border-radius: 6px; padding: 14px; }
51
+ .chart-box.full { grid-column: 1 / -1; }
52
+ .chart-box .title { font-size: 12px; font-weight: 600; color: var(--text2); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
53
+ .chart-box .canvas-wrap { position: relative; width: 100%; height: 260px; }
54
+ .chart-box .canvas-wrap canvas { width: 100% !important; height: 100% !important; }
55
+
56
+ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: var(--text2); text-align: center; }
57
+ .empty-state .icon { font-size: 48px; margin-bottom: 12px; }
58
+ .empty-state .msg { font-size: 15px; margin-bottom: 6px; }
59
+
60
+ /* 截面明细表 */
61
+ .xsec-box { background: var(--surface); border: 2px solid var(--border); border-radius: 6px; padding: 14px; }
62
+ .xsec-box .title { font-size: 12px; font-weight: 600; color: var(--text2); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
63
+ .xsec-tabs { display: flex; gap: 4px; margin-bottom: 12px; flex-wrap: wrap; }
64
+ .xsec-tab { padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px; background: var(--surface2); color: var(--text2); border: 2px solid var(--border); transition: all 0.15s; }
65
+ .xsec-tab:hover { color: var(--text); }
66
+ .xsec-tab.active { background: var(--accent); color: var(--line); border-color: var(--accent); font-weight: 700; }
67
+ .xsec-tab.stats { font-size: 11px; color: var(--text2); padding: 6px 8px; }
68
+ table.xsec { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
69
+ table.xsec th { text-align: left; padding: 6px 10px; color: var(--text2); font-weight: 500; border-bottom: 2px solid var(--border); font-size: 10px; text-transform: uppercase; }
70
+ table.xsec td { padding: 5px 10px; border-bottom: 1px solid rgba(147,179,201,0.25); }
71
+ table.xsec tr:hover td { background: var(--surface2); }
72
+ table.xsec .sym { color: var(--accent); font-weight: 600; }
73
+ table.xsec .pos { color: var(--green); }
74
+ table.xsec .neg { color: var(--red); }
75
+
76
+ @media (max-width: 900px) { .charts { grid-template-columns: 1fr; } }
77
+ </style>
78
+ </head>
79
+ <body>
80
+
81
+ <div class="topbar">
82
+ <span class="logo">factor-lab</span>
83
+ <span class="sep">/</span>
84
+ <span class="sub">单因子回测仪表盘</span>
85
+ </div>
86
+
87
+ <div class="tabs" id="tabs"></div>
88
+
89
+ <div class="main">
90
+ <div class="section-title">📊 IC 分析</div>
91
+ <div class="metrics" id="icMetrics"></div>
92
+
93
+ <div class="section-title" style="margin-top:14px;">⚡ 多空绩效</div>
94
+ <div class="metrics" id="lsMetrics"></div>
95
+
96
+ <div class="section-title" style="margin-top:14px;">📈 图表</div>
97
+ <div class="charts">
98
+ <div class="chart-box"><div class="title">📈 Rank IC 时序</div><div class="canvas-wrap"><canvas id="icChart"></canvas></div></div>
99
+ <div class="chart-box"><div class="title">📊 IC 分布直方图</div><div class="canvas-wrap"><canvas id="icHist"></canvas></div></div>
100
+ <div class="chart-box"><div class="title">📉 分层累计收益</div><div class="canvas-wrap"><canvas id="cumChart"></canvas></div></div>
101
+ <div class="chart-box"><div class="title">⚡ 多空累计收益</div><div class="canvas-wrap"><canvas id="lsChart"></canvas></div></div>
102
+ <div class="chart-box"><div class="title">📋 分组月均收益</div><div class="canvas-wrap"><canvas id="barChart"></canvas></div></div>
103
+ <div class="chart-box"><div class="title">📅 每月多空收益</div><div class="canvas-wrap"><canvas id="lsMonthly"></canvas></div></div>
104
+ <div class="chart-box"><div class="title">📐 因子值分布 (最新截面)</div><div class="canvas-wrap"><canvas id="distChart"></canvas></div></div>
105
+ <div class="chart-box"><div class="title">🏆 全因子 ICIR 对比</div><div class="canvas-wrap"><canvas id="compareChart"></canvas></div></div>
106
+ </div>
107
+
108
+ <div class="xsec-box" id="xsecBox">
109
+ <div class="title">📋 截面明细 — 各组 Top 10 股票</div>
110
+ <div class="xsec-tabs" id="xsecTabs"></div>
111
+ <div id="xsecStats" style="font-size:11px;color:var(--text2);margin-bottom:8px;"></div>
112
+ <table class="xsec" id="xsecTable"></table>
113
+ </div>
114
+ </div>
115
+
116
+ <script>
117
+ // ======================== 数据内嵌 ========================
118
+ const allData = __BACKTEST_DATA__;
119
+ let activeFactor = null;
120
+ let selectedGroup = 'G0';
121
+
122
+ function initTabs() {
123
+ const tabsEl = document.getElementById('tabs');
124
+ const factors = Object.entries(allData).filter(([k]) => !k.startsWith('_'));
125
+ if (factors.length === 0) {
126
+ tabsEl.innerHTML = '<div class="tab error">暂无数据</div>';
127
+ showEmpty('没有可用的回测数据。请运行: uv run --project .. python export_backtest_data.py');
128
+ return;
129
+ }
130
+ factors.forEach(([key, info]) => {
131
+ const tab = document.createElement('div');
132
+ tab.className = 'tab';
133
+ if (info.error) tab.className += ' error';
134
+ tab.textContent = info.label || key;
135
+ tab.dataset.key = key;
136
+ tab.addEventListener('click', () => selectFactor(key));
137
+ tabsEl.appendChild(tab);
138
+ });
139
+ const firstOk = factors.find(([k, v]) => !v.error);
140
+ selectFactor(firstOk ? firstOk[0] : factors[0][0]);
141
+ }
142
+
143
+ function selectFactor(key) {
144
+ activeFactor = key;
145
+ document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.key === key));
146
+ const info = allData[key];
147
+ if (!info || info.error) {
148
+ showEmpty(`因子 "${info?.label || key}" 数据不可用: ${info?.error || '未知错误'}`);
149
+ return;
150
+ }
151
+ selectedGroup = 'G0';
152
+ renderInfo(info);
153
+ }
154
+
155
+ // ======================== 渲染 ========================
156
+ let charts = {};
157
+
158
+ const CHART_BOX_ORDER = ['icChart','icHist','cumChart','lsChart','barChart','lsMonthly','distChart','compareChart'];
159
+
160
+ function destroyCharts() {
161
+ Object.values(charts).forEach(c => c.destroy());
162
+ charts = {};
163
+ }
164
+
165
+ function showEmpty(msg) {
166
+ destroyCharts();
167
+ document.getElementById('icMetrics').innerHTML = '';
168
+ document.getElementById('lsMetrics').innerHTML = '';
169
+ // 图表区
170
+ document.querySelectorAll('.chart-box').forEach(box => {
171
+ const t = box.querySelector('.title');
172
+ box.innerHTML = '';
173
+ if (t) box.appendChild(t);
174
+ box.insertAdjacentHTML('beforeend', `<div class="empty-state"><div class="icon">📭</div><div class="msg">${msg}</div></div>`);
175
+ });
176
+ // 截面区
177
+ document.getElementById('xsecBox').style.display = 'none';
178
+ }
179
+
180
+ // 恢复 canvas 元素
181
+ function restoreCanvases() {
182
+ document.querySelectorAll('.chart-box').forEach(box => {
183
+ const titleEl = box.querySelector('.title');
184
+ if (!box.querySelector('canvas') && titleEl) {
185
+ const titleText = titleEl.textContent;
186
+ let canvasId = '';
187
+ if (titleText.includes('IC 时序')) canvasId = 'icChart';
188
+ else if (titleText.includes('分布直方图') && titleText.includes('IC')) canvasId = 'icHist';
189
+ else if (titleText.includes('分层累计')) canvasId = 'cumChart';
190
+ else if (titleText.includes('多空累计')) canvasId = 'lsChart';
191
+ else if (titleText.includes('分组月均')) canvasId = 'barChart';
192
+ else if (titleText.includes('每月多空')) canvasId = 'lsMonthly';
193
+ else if (titleText.includes('因子值分布')) canvasId = 'distChart';
194
+ else if (titleText.includes('ICIR 对比')) canvasId = 'compareChart';
195
+ if (canvasId) {
196
+ box.innerHTML = '';
197
+ box.appendChild(titleEl);
198
+ box.insertAdjacentHTML('beforeend', `<div class="canvas-wrap"><canvas id="${canvasId}"></canvas></div>`);
199
+ }
200
+ }
201
+ });
202
+ }
203
+
204
+ function renderInfo(info) {
205
+ destroyCharts();
206
+ restoreCanvases();
207
+ document.getElementById('xsecBox').style.display = 'block';
208
+
209
+ // ---- IC 指标卡片 ----
210
+ const s = info.ic_summary;
211
+ const icMean = s['IC 均值'];
212
+ const icir = s['ICIR'];
213
+ const icCards = [
214
+ { L:'IC 均值', V:icMean, C:icMean>0.02?'positive':icMean<-0.02?'negative':'neutral', F:v=>v.toFixed(4) },
215
+ { L:'IC 标准差', V:s['IC 标准差'], C:'neutral', F:v=>v.toFixed(4) },
216
+ { L:'ICIR', V:icir, C:icir>0.5?'positive':icir<-0.5?'negative':'neutral', F:v=>v.toFixed(3) },
217
+ { L:'IC > 0 占比', V:s['IC > 0 占比'], C:s['IC > 0 占比']>0.55?'positive':'neutral', F:v=>(v*100).toFixed(1)+'%' },
218
+ { L:'|IC| > 0.02', V:s['|IC| > 0.02 占比'], C:'neutral', F:v=>(v*100).toFixed(1)+'%' },
219
+ { L:'月数', V:info.ic_series.length, C:'neutral', F:v=>v },
220
+ ];
221
+ document.getElementById('icMetrics').innerHTML = icCards.map(m => `<div class="metric-card"><div class="label">${m.L}</div><div class="value ${m.C}">${m.F(m.V)}</div></div>`).join('');
222
+
223
+ // ---- 多空指标卡片 ----
224
+ const lm = info.long_short_metrics || {};
225
+ const lsCards = [
226
+ { L:'年化夏普', V:lm.sharpe_annual||0, C:(lm.sharpe_annual||0)>0?'positive':'negative', F:v=>v.toFixed(2) },
227
+ { L:'胜率', V:lm.win_rate||0, C:(lm.win_rate||0)>0.5?'positive':'neutral', F:v=>(v*100).toFixed(1)+'%' },
228
+ { L:'最大回撤', V:lm.max_drawdown||0, C:(lm.max_drawdown||0)>-0.1?'positive':'negative', F:v=>(v*100).toFixed(1)+'%' },
229
+ { L:'累计收益', V:lm.total_return||0, C:(lm.total_return||0)>0?'positive':'negative', F:v=>(v*100).toFixed(1)+'%' },
230
+ ];
231
+ document.getElementById('lsMetrics').innerHTML = lsCards.map(m => `<div class="metric-card"><div class="label">${m.L}</div><div class="value ${m.C}">${m.F(m.V)}</div></div>`).join('');
232
+
233
+ // ---- 图表 1: IC 时序 ----
234
+ const icData = info.ic_series;
235
+ const icLabels = icData.map(d => d.date.slice(0, 7));
236
+ const icValues = icData.map(d => d.ic);
237
+ charts.ic = new Chart(document.getElementById('icChart'), {
238
+ type: 'bar', data: { labels: icLabels, datasets: [{ data: icValues, backgroundColor: icValues.map(v => v>=0?'rgba(38,166,154,0.7)':'rgba(239,83,80,0.6)'), borderWidth:0, borderRadius:2 }] },
239
+ options: chartOpts('bar', { plugins:{ tooltip:{ callbacks:{ label:ctx=>`IC: ${ctx.raw.toFixed(4)}` } } }, scales:{ y:{ title:{ display:true, text:'Rank IC' } } } })
240
+ });
241
+
242
+ // ---- 图表 2: IC 分布 ----
243
+ const bins=15, mn=Math.min(...icValues), mx=Math.max(...icValues), bw=(mx-mn)/bins||0.01;
244
+ const hd=Array(bins).fill(0); icValues.forEach(v=>{ const i=Math.min(Math.floor((v-mn)/bw),bins-1); hd[i]++; });
245
+ charts.hist = new Chart(document.getElementById('icHist'), {
246
+ type: 'bar', data: { labels: Array.from({length:bins},(_,i)=>(mn+bw*i).toFixed(2)), datasets: [{ data: hd, backgroundColor:'rgba(91,141,239,0.6)', borderWidth:0, borderRadius:2 }] },
247
+ options: chartOpts('bar', { scales:{ y:{ title:{display:true,text:'频数'} }, x:{ title:{display:true,text:'IC 值'} } } })
248
+ });
249
+
250
+ // ---- 图表 3: 分层累计收益 ----
251
+ const cum=info.cum_returns, gc=['#ef5350','#ffa726','#ffee58','#66bb6a','#26a69a'];
252
+ charts.cum = new Chart(document.getElementById('cumChart'), {
253
+ type: 'line', data: { datasets: Object.entries(cum).map(([g,pts],i)=>({ label:g, data:pts.map(p=>({x:p.date.slice(0,7),y:p.cum_return})), borderColor:gc[i], backgroundColor:'transparent', borderWidth:1.5, pointRadius:0, tension:0.1 })) },
254
+ options: chartOpts('line', { scales:{ y:{ title:{display:true,text:'累计收益 (1=基准)'} } }, plugins:{ tooltip:{ callbacks:{ label:ctx=>`${ctx.dataset.label}: ${(ctx.raw.y||ctx.raw).toFixed(3)}` } } } })
255
+ });
256
+
257
+ // ---- 图表 4: 多空累计收益 ----
258
+ const lsData = info.long_short;
259
+ if(lsData && lsData.length>0) {
260
+ let cl=1;
261
+ charts.ls = new Chart(document.getElementById('lsChart'), {
262
+ type: 'line', data: { datasets: [{ label:'Long-Short', data: lsData.map(p=>{ cl*=(1+p.return); return {x:p.date.slice(0,7),y:cl}; }), borderColor:'rgba(91,141,239,1)', backgroundColor:'rgba(91,141,239,0.1)', fill:true, borderWidth:2, pointRadius:0, tension:0.1 }] },
263
+ options: chartOpts('line', { scales:{ y:{ title:{display:true,text:'累计收益'} } } })
264
+ });
265
+ }
266
+
267
+ // ---- 图表 5: 分组月均收益 ----
268
+ const grAvg=info.group_avg_return, gk=Object.keys(grAvg).sort(), gv=gk.map(k=>grAvg[k]*100);
269
+ charts.bar = new Chart(document.getElementById('barChart'), {
270
+ type: 'bar', data: { labels:gk, datasets: [{ data:gv, backgroundColor:gv.map((_,i)=>i===0?'rgba(239,83,80,0.7)':i===gv.length-1?'rgba(38,166,154,0.7)':'rgba(91,141,239,0.6)'), borderWidth:0, borderRadius:3 }] },
271
+ options: chartOpts('bar', { scales:{ y:{ title:{display:true,text:'月均收益 (%)'} } }, plugins:{ tooltip:{ callbacks:{ label:ctx=>`${ctx.raw.toFixed(2)}%` } } } })
272
+ });
273
+
274
+ // ---- 图表 6: 每月多空收益柱状图 (NEW) ----
275
+ if(lsData && lsData.length>0) {
276
+ const lsLabels=lsData.map(d=>d.date.slice(0,7)), lsVals=lsData.map(d=>d.return*100);
277
+ charts.lsMonthly = new Chart(document.getElementById('lsMonthly'), {
278
+ type: 'bar', data: { labels:lsLabels, datasets: [{ data:lsVals, backgroundColor:lsVals.map(v=>v>=0?'rgba(38,166,154,0.6)':'rgba(239,83,80,0.5)'), borderWidth:0, borderRadius:2 }] },
279
+ options: chartOpts('bar', { scales:{ y:{ title:{display:true,text:'多空收益 (%)'} } }, plugins:{ tooltip:{ callbacks:{ label:ctx=>`${ctx.raw.toFixed(2)}%` } } } })
280
+ });
281
+ }
282
+
283
+ // ---- 图表 7: 因子值分布直方图 (NEW) ----
284
+ const fd = info.factor_distribution;
285
+ if(fd && fd.histogram && fd.histogram.length>0) {
286
+ const distBins=fd.histogram, distLabels=distBins.map(b=>b.bin_start.toFixed(2)), distCounts=distBins.map(b=>b.count);
287
+ charts.dist = new Chart(document.getElementById('distChart'), {
288
+ type: 'bar', data: { labels:distLabels, datasets: [{ data:distCounts, backgroundColor:'rgba(255,167,38,0.6)', borderWidth:0, borderRadius:1 }] },
289
+ options: chartOpts('bar', { scales:{ y:{ title:{display:true,text:'股票数'} }, x:{ title:{display:true,text:'因子值'} } } })
290
+ });
291
+ }
292
+
293
+ // ---- 图表 8: 全因子 ICIR 对比 ----
294
+ renderCompareChart();
295
+
296
+ // ---- 截面明细表 (NEW) ----
297
+ renderCrossSection(info);
298
+ }
299
+
300
+ // ======================== 全因子 ICIR 对比 ========================
301
+ function renderCompareChart() {
302
+ const factors=Object.entries(allData).filter(([k,v])=>!k.startsWith('_')&&!v.error);
303
+ const labels=factors.map(([k,v])=>v.label), icirVals=factors.map(([k,v])=>v.ic_summary['ICIR']);
304
+ const icMeanVals=factors.map(([k,v])=>v.ic_summary['IC 均值']), hi=factors.findIndex(([k])=>k===activeFactor);
305
+ charts.compare = new Chart(document.getElementById('compareChart'), {
306
+ type: 'bar', data: { labels, datasets: [{ label:'ICIR', data:icirVals, backgroundColor:factors.map((_,i)=>i===hi?(icirVals[i]>0?'rgba(38,166,154,0.9)':'rgba(239,83,80,0.9)'):(icirVals[i]>0?'rgba(38,166,154,0.35)':'rgba(239,83,80,0.3)')), borderWidth:0, borderRadius:3 }] },
307
+ options: chartOpts('bar', { plugins:{ tooltip:{ callbacks:{ label:ctx=>{ const i=ctx.dataIndex; return [`ICIR: ${icirVals[i].toFixed(3)}`,`IC均值: ${icMeanVals[i].toFixed(4)}`]; } } }, legend:{display:false} }, scales:{ y:{ title:{display:true,text:'ICIR'} } } })
308
+ });
309
+ }
310
+
311
+ // ======================== 截面明细表 ========================
312
+ function renderCrossSection(info) {
313
+ const xs = info.cross_section;
314
+ if(!xs || !xs.groups) { document.getElementById('xsecBox').style.display='none'; return; }
315
+
316
+ const groups = Object.keys(xs.groups).sort();
317
+ // 顶部 Tab 导航
318
+ const tabsHtml = groups.map(g => {
319
+ const gd = xs.groups[g];
320
+ const fwdMean = gd.forward_mean ? (gd.forward_mean*100).toFixed(2) : '-';
321
+ return `<span class="xsec-tab ${g===selectedGroup?'active':''}" data-group="${g}" onclick="switchGroup('${g}')">${g}</span>`;
322
+ }).join('');
323
+ document.getElementById('xsecTabs').innerHTML = tabsHtml;
324
+
325
+ switchGroupTable(xs);
326
+ }
327
+
328
+ function switchGroup(g) {
329
+ selectedGroup = g;
330
+ document.querySelectorAll('.xsec-tab').forEach(t => t.classList.toggle('active', t.dataset.group===g));
331
+ const info = allData[activeFactor];
332
+ if(info) switchGroupTable(info.cross_section);
333
+ }
334
+
335
+ function switchGroupTable(xs) {
336
+ const gd = xs.groups[selectedGroup];
337
+ if(!gd) return;
338
+
339
+ const fwdMean = gd.forward_mean ? (gd.forward_mean*100).toFixed(2) : '-';
340
+ document.getElementById('xsecStats').innerHTML = `${selectedGroup}: ${gd.count} 只 | 因子均值=${gd.factor_mean.toFixed(4)} | 因子标准差=${gd.factor_std.toFixed(4)} | 下月均收益=${fwdMean}%`;
341
+
342
+ const stocks = gd.top10 || [];
343
+ if(stocks.length===0) { document.getElementById('xsecTable').innerHTML='<tr><td colspan="4">无数据</td></tr>'; return; }
344
+ document.getElementById('xsecTable').innerHTML = `<thead><tr><th>#</th><th>代码</th><th>因子值</th><th>下月收益</th></tr></thead><tbody>${
345
+ stocks.map((s,i) => `<tr>
346
+ <td style="color:var(--text2)">${i+1}</td>
347
+ <td class="sym">${s.symbol}</td>
348
+ <td>${s.factor_value.toFixed(4)}</td>
349
+ <td class="${(s.forward_return||0)>=0?'pos':'neg'}">${s.forward_return?((s.forward_return)*100).toFixed(2)+'%':'-'}</td>
350
+ </tr>`).join('')
351
+ }</tbody>`;
352
+ }
353
+
354
+ // ======================== 图表配置 ========================
355
+ function chartOpts(type, overrides={}) {
356
+ const base = {
357
+ responsive:true, maintainAspectRatio:false, animation:{duration:400},
358
+ plugins:{
359
+ legend:{ labels:{ color:'#93b3c9', usePointStyle:true, boxWidth:8, padding:12, font:{size:11} } },
360
+ tooltip:{ backgroundColor:'#16334a', borderColor:'#07111b', borderWidth:2, titleColor:'#dbeaf5', bodyColor:'#dbeaf5', padding:10 }
361
+ },
362
+ scales:{
363
+ x:{ ticks:{ color:'#93b3c9', maxTicksLimit:12, maxRotation:45, font:{size:10} }, grid:{ color:'rgba(147,179,201,0.2)' } },
364
+ y:{ ticks:{ color:'#93b3c9', font:{size:10} }, grid:{ color:'rgba(147,179,201,0.2)' }, beginAtZero: type==='bar' }
365
+ }
366
+ };
367
+ return deepMerge(base, overrides);
368
+ }
369
+ function deepMerge(target, source) {
370
+ const out = Object.assign({}, target);
371
+ for(const key of Object.keys(source)) {
372
+ if(source[key] && typeof source[key]==='object' && !Array.isArray(source[key])) out[key]=deepMerge(target[key]||{},source[key]);
373
+ else out[key]=source[key];
374
+ }
375
+ return out;
376
+ }
377
+
378
+ initTabs();
379
+ </script>
380
+ </body>
381
+ </html>
package/src/index.js CHANGED
@@ -3,6 +3,11 @@
3
3
  * 定位:会话内「教学平面」——把 agent 生成的 self-contained HTML 投影为可讲解、
4
4
  * 可勾画、可截图的板书界面。
5
5
  *
6
+ * 两层都在这个包里:
7
+ * 能力层 = 本文件注册的 stage_* 工具 + client 半的「面板」视图;
8
+ * 行为层 = src/skills.js 把包内 skills/stage-panel/ 注册成 bundled skill。
9
+ * 挂上插件就同时拿到两者——不再依赖任何 preset 单独装一份使用手册。
10
+ *
6
11
  * 原生设计(单一数据流):只注册模型工具,无私有状态、无 RPC、无 wait。
7
12
  * 「面板」状态 = 会话中最近一次 stage_* 工具调用的参数(client 从会话快照消费);
8
13
  * 用户点选/回传 = 真实用户消息(DSH 原生输入流)。
@@ -13,11 +18,17 @@
13
18
  * 消息进入会话,模型在下一轮就能看见。这与 stage_choice 的机制完全一致。
14
19
  */
15
20
  import { defineTool } from '@deepseek-ai/dsh-tools'
21
+ import { createSkillProvider } from './skills.js'
16
22
 
17
23
  export const name = 'teaching-board'
18
24
  export const inject = ['tools']
19
25
 
20
26
  export function apply(ctx) {
27
+ // 行为层:把自带的使用手册发布进技能目录。这里用可选读取而不是 inject——
28
+ // 技能注册是增益,不该让白板因为缺 skills 服务而整个不挂载。
29
+ const skills = ctx.get('skills')
30
+ if (skills !== undefined) skills.registerProvider(createSkillProvider)
31
+
21
32
  const textRender = (_args, value) => [{ type: 'text', text: value }]
22
33
 
23
34
  ctx.tools.register(defineTool({
package/src/skills.js ADDED
@@ -0,0 +1,88 @@
1
+ /** 教学平面的「行为层」:把本包自带的使用手册注册成一个 bundled skill。
2
+ *
3
+ * 为什么由插件自己注册:纪律要跟着能力走。任何挂载本插件的会话(不论用哪个 preset)
4
+ * 都应该在技能目录里看到 stage-panel。手册若只放在某个 preset 的 skills/ 里,
5
+ * 其余会话就会停在「有工具、没纪律」的状态——白板能开,但不知道该怎么用。
6
+ *
7
+ * 与官方 @deepseek-ai/dsh-skill-badge 同一模式:list() 只给候选,get() 才读正文;
8
+ * resourceBase 指向本包目录,正文里的相对路径(references/…、submode/…)才能解析。
9
+ * provider 由 skills 服务托管,随插件 fiber 一起注销,不需要手动 cleanup。
10
+ */
11
+ import { readFile } from 'node:fs/promises'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const SKILL_NAME = 'stage-panel'
15
+ const PROVIDER_NAME = 'teaching-board'
16
+ const SKILL_DIR = new URL('../skills/stage-panel/', import.meta.url)
17
+ const SKILL_BODY_URL = new URL('SKILL.md', SKILL_DIR)
18
+ const RESOURCE_BASE = { kind: 'directory', path: fileURLToPath(SKILL_DIR) }
19
+ const INVOCATION = { modelInvocable: true, userInvocable: true }
20
+ /** `@deepseek-ai/dsh-skill` 导出的 BUNDLED_SKILL_RANK,随包发布技能的档次。
21
+ * 这里写字面量而不 import:本插件只依赖 dsh-tools,不为了一个排序常量多一条依赖边。
22
+ * rank 只用于同一层内的重名排序(project > runtime > user),跨层由层远近决定。 */
23
+ const BUNDLED_SKILL_RANK = 600
24
+ /** frontmatter 缺 description 时的兜底;正常情况下以 SKILL.md 为准(单一真相)。 */
25
+ const FALLBACK_DESCRIPTION = 'DSH 教学平面(板书面板)使用手册。'
26
+
27
+ /** 拆掉正文首部的 YAML frontmatter:它服务于「被文件系统扫描」那种用法,
28
+ * 由 provider 读取时属于元数据而非正文。只解析需要的一层 key: value。 */
29
+ function splitFrontmatter(raw) {
30
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
31
+ if (match === null) return { meta: {}, content: raw }
32
+ const meta = {}
33
+ for (const line of match[1].split(/\r?\n/)) {
34
+ const entry = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line.trim())
35
+ if (entry !== null) meta[entry[1]] = entry[2].trim()
36
+ }
37
+ return { meta, content: raw.slice(match[0].length).replace(/^\n+/, '') }
38
+ }
39
+
40
+ /** 构造 stage-panel 的 bundled 技能 provider(交给 ctx.skills.registerProvider)。 */
41
+ export function createSkillProvider() {
42
+ let loaded
43
+ const load = () => {
44
+ if (loaded === undefined) {
45
+ loaded = readFile(SKILL_BODY_URL, 'utf8').then((raw) => {
46
+ const { meta, content } = splitFrontmatter(raw)
47
+ return { description: meta.description ?? FALLBACK_DESCRIPTION, content }
48
+ })
49
+ }
50
+ return loaded
51
+ }
52
+
53
+ return {
54
+ name: PROVIDER_NAME,
55
+ async list() {
56
+ let description = FALLBACK_DESCRIPTION
57
+ try {
58
+ description = (await load()).description
59
+ } catch {
60
+ // 手册文件缺失时仍让目录条目可用,正文读取会在 get() 处明确报错。
61
+ }
62
+ return [{
63
+ name: SKILL_NAME,
64
+ description,
65
+ invocation: INVOCATION,
66
+ provider: PROVIDER_NAME,
67
+ source: 'bundled',
68
+ resourceBase: RESOURCE_BASE,
69
+ rank: BUNDLED_SKILL_RANK,
70
+ locator: SKILL_BODY_URL,
71
+ path: fileURLToPath(SKILL_BODY_URL),
72
+ }]
73
+ },
74
+ async get() {
75
+ const { description, content } = await load()
76
+ return {
77
+ name: SKILL_NAME,
78
+ description,
79
+ invocation: INVOCATION,
80
+ provider: PROVIDER_NAME,
81
+ source: 'bundled',
82
+ resourceBase: RESOURCE_BASE,
83
+ content,
84
+ path: fileURLToPath(SKILL_BODY_URL),
85
+ }
86
+ },
87
+ }
88
+ }