@arcaneorion/dsh-teaching-board 0.4.1 → 0.5.1

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 (37) hide show
  1. package/README.md +35 -4
  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/client.js +177 -15
  36. package/src/index.js +11 -0
  37. package/src/skills.js +88 -0
@@ -0,0 +1,9 @@
1
+ # ml —— ML 可视化面板(面板交互模式·DSH 版)
2
+
3
+ 可视化模板 `templates/gd.html`(如梯度下降过程);
4
+ 示例参数 `examples/gd.json`(可视化目标与参数样例)。
5
+
6
+ ## 投递(DSH 原生)
7
+
8
+ - 训练/优化过程可视化渲染 → `stage_panel`;
9
+ - 可配合 `stage_choice` 切换视角(损失曲线 / 参数平面 / 批次动画)。
@@ -0,0 +1,7 @@
1
+ {
2
+ "title": "梯度下降:拟合 y = wx + b",
3
+ "subtitle": "前端实时计算 · 拖滑块看学习率如何改变收敛路径 · 不走任何 run 通道",
4
+ "data_points": [[0, 1], [1, 3], [2, 5], [3, 7]],
5
+ "config": {"lr": 0.05, "iterations": 200, "init_w": 0, "init_b": 0},
6
+ "target": {"w": 2, "b": 1, "note": "y = 2x + 1 的真值"}
7
+ }
@@ -0,0 +1,217 @@
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>梯度下降可视化</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1518; --panel: #10191d; --deep: #0a1013;
10
+ --line: #1f2d33; --text: #cfe3ea; --dim: #6f8a96;
11
+ --blue: #4fc4cf; --gold: #e8b96b; --pink: #e57373; --green: #6bd098;
12
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Consolas, monospace;
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; background: var(--bg); color: var(--text);
16
+ font: 14px/1.5 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
17
+ .wrap { padding: 18px 22px 30px; max-width: 1180px; margin: 0 auto; }
18
+ h1 { font-size: 19px; margin: 0 0 2px; font-weight: 600; }
19
+ .sub { color: var(--dim); font-size: 13px; margin-bottom: 16px; }
20
+ .ctrl { display: flex; flex-wrap: wrap; gap: 18px 26px; align-items: end;
21
+ background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
22
+ padding: 14px 18px; margin-bottom: 16px; }
23
+ .ctrl label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--dim); }
24
+ .ctrl .val { color: var(--blue); font-family: var(--mono); font-size: 13px; }
25
+ .ctrl input[type=range] { width: 170px; accent-color: var(--blue); cursor: pointer; }
26
+ .ctrl button { background: var(--deep); color: var(--text); border: 1px solid var(--line);
27
+ border-radius: 7px; padding: 8px 14px; cursor: pointer; font-size: 13px; }
28
+ .ctrl button:hover { border-color: var(--blue); color: var(--blue); }
29
+ .ctrl button.on { background: var(--blue); color: #06181b; border-color: var(--blue); }
30
+ .readouts { display: flex; gap: 22px; font-family: var(--mono); font-size: 13px; }
31
+ .readouts span b { color: var(--gold); font-weight: 600; }
32
+ .readouts .warn { color: var(--pink); }
33
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
34
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px 8px; }
35
+ .card h2 { font-size: 13px; margin: 0 0 8px; color: var(--dim); font-weight: 500; }
36
+ .card svg, .card canvas { width: 100%; height: 230px; display: block; }
37
+ .legend { display: flex; gap: 14px; font-size: 11px; color: var(--dim); margin-top: 4px; flex-wrap: wrap; }
38
+ .legend i { display: inline-block; width: 14px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 4px; }
39
+ @media (max-width: 820px) { .grid { grid-template-columns: 1fr; } }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div class="wrap">
44
+ <h1 id="title">梯度下降:拟合 y = wx + b</h1>
45
+ <div class="sub" id="subtitle">前端实时计算 · 拖滑块看学习率如何改变收敛路径 · 不走任何 run 通道</div>
46
+
47
+ <div class="ctrl">
48
+ <label>学习率 lr <span class="val" id="lrVal">0.050</span>
49
+ <input type="range" id="lr" min="-3" max="0" step="0.01" value="-1.30"></label>
50
+ <label>迭代次数 <span class="val" id="itVal">200</span>
51
+ <input type="range" id="it" min="10" max="500" step="10" value="200"></label>
52
+ <label>数据噪声 <span class="val" id="nsVal">0.00</span>
53
+ <input type="range" id="ns" min="0" max="1.5" step="0.05" value="0"></label>
54
+ <label>初始 w <span class="val" id="iwVal">0.0</span>
55
+ <input type="range" id="iw" min="-1" max="3" step="0.1" value="0"></label>
56
+ <label>初始 b <span class="val" id="ibVal">0.0</span>
57
+ <input type="range" id="ib" min="-1" max="2" step="0.1" value="0"></label>
58
+ <div style="display:flex;gap:8px">
59
+ <button id="play">▶ 播放训练</button>
60
+ <button id="reset">重置</button>
61
+ </div>
62
+ <div class="readouts" id="ro"></div>
63
+ </div>
64
+
65
+ <div class="grid">
66
+ <div class="card"><h2>① 数据与拟合直线</h2>
67
+ <svg id="fit" viewBox="0 0 400 230" preserveAspectRatio="none"></svg>
68
+ <div class="legend"><span><i style="border-color:var(--gold)"></i>数据点</span>
69
+ <span><i style="border-color:var(--blue)"></i>当前模型</span>
70
+ <span><i style="border-color:var(--dim);border-top-style:dashed"></i>真值 y=2x+1</span></div></div>
71
+ <div class="card"><h2>② Loss 等高面 + 梯度下降路径</h2>
72
+ <canvas id="surf" width="460" height="230"></canvas>
73
+ <div class="legend"><span>横轴=w · 纵轴=b · 颜色=loss(深=低)· 白线=GD 路径</span></div></div>
74
+ <div class="card"><h2>③ Loss 曲线(对数纵轴)</h2>
75
+ <svg id="loss" viewBox="0 0 400 230" preserveAspectRatio="none"></svg>
76
+ <div class="legend"><span><i style="border-color:var(--pink)"></i>loss</span></div></div>
77
+ <div class="card"><h2>④ 参数轨迹 w, b</h2>
78
+ <svg id="param" viewBox="0 0 400 230" preserveAspectRatio="none"></svg>
79
+ <div class="legend"><span><i style="border-color:var(--blue)"></i>w→2.0</span>
80
+ <span><i style="border-color:var(--green)"></i>b→1.0</span></div></div>
81
+ </div>
82
+ </div>
83
+
84
+ <script>
85
+ const DATA = __ML_DATA__;
86
+ document.getElementById('title').textContent = DATA.title || '梯度下降:拟合 y = wx + b';
87
+ document.getElementById('subtitle').textContent = DATA.subtitle || '';
88
+ const BASE = DATA.data_points || [[0,1],[1,3],[2,5],[3,7]];
89
+ const TARGET = DATA.target || {w:2.0, b:1.0};
90
+ const W_RANGE = [0, 3.6], B_RANGE = [-0.5, 2.6];
91
+
92
+ const $ = id => document.getElementById(id);
93
+ function loss(w,b,d){let s=0;for(const[x,y]of d){const e=w*x+b-y;s+=e*e;}return s/d.length;}
94
+ function train(d,lr,iters,iw,ib){
95
+ let w=iw,b=ib;const n=d.length;const h=[{w,b,l:loss(w,b,d)}];
96
+ for(let i=0;i<iters;i++){
97
+ let dw=0,db=0;
98
+ for(const[x,y]of d){const e=w*x+b-y;dw+=2*e*x/n;db+=2*e/n;}
99
+ w-=lr*dw;b-=lr*db;h.push({w,b,l:loss(w,b,d)});
100
+ if(!isFinite(w)||!isFinite(b)||Math.abs(w)>1e6||Math.abs(b)>1e6)break;
101
+ }
102
+ return h;
103
+ }
104
+ function noisy(){const ns=+$('ns').value;return BASE.map(([x,y])=>[x,y+(Math.random()-0.5)*2*ns]);}
105
+
106
+ // ---- SVG helpers ----
107
+ const NS='http://www.w3.org/2000/svg';
108
+ function el(t,a){const e=document.createElementNS(NS,t);for(const k in a)e.setAttribute(k,a[k]);return e;}
109
+ function axis(svg,x0,y0,x1,y1){
110
+ svg.appendChild(el('line',{x1:x0,y1:y0,x2:x1,y2:y0,stroke:'#2a3a42','stroke-width':1}));
111
+ svg.appendChild(el('line',{x1:x0,y1:y0,x2:x0,y2:y1,stroke:'#2a3a42','stroke-width':1}));
112
+ }
113
+ function fitPlot(hist,step){
114
+ const svg=$('fit');svg.innerHTML='';const W=400,H=230,M=34;
115
+ axis(svg,M,H-M,W-8,H-8);
116
+ const xs=BASE.map(p=>p[0]),ys=BASE.map(p=>p[1]);
117
+ const xmin=Math.min(...xs)-0.5,xmax=Math.max(...xs)+0.5;
118
+ const ymin=Math.min(...ys)-1,ymax=Math.max(...ys)+2;
119
+ const sx=x=>M+(x-xmin)/(xmax-xmin)*(W-M-8);
120
+ const sy=y=>H-M-(y-ymin)/(ymax-ymin)*(H-M-8);
121
+ // target line
122
+ svg.appendChild(el('line',{x1:sx(xmin),y1:sy(TARGET.w*xmin+TARGET.b),x2:sx(xmax),y2:sy(TARGET.w*xmax+TARGET.b),stroke:'#6f8a96','stroke-width':1.5,'stroke-dasharray':'5 4'}));
123
+ // data
124
+ for(const[x,y]of BASE)svg.appendChild(el('circle',{cx:sx(x),cy:sy(y),r:4.5,fill:'#e8b96b'}));
125
+ // current model
126
+ const s=hist[Math.min(step,hist.length-1)];
127
+ if(s&&isFinite(s.w)&&isFinite(s.b))
128
+ svg.appendChild(el('line',{x1:sx(xmin),y1:sy(s.w*xmin+s.b),x2:sx(xmax),y2:sy(s.w*xmax+s.b),stroke:'#4fc4cf','stroke-width':2.4}));
129
+ }
130
+ function lossPlot(hist,step){
131
+ const svg=$('loss');svg.innerHTML='';const W=400,H=230,M=34;
132
+ axis(svg,M,H-M,W-8,H-8);
133
+ const ls=hist.map(h=>Math.max(h.l,1e-9));
134
+ const lmin=Math.min(...ls),lmax=Math.max(...ls);
135
+ const lx=i=>M+i/(hist.length-1)*(W-M-8);
136
+ const ly=l=>H-M-(Math.log(Math.max(l,1e-9))-Math.log(lmin))/(Math.log(lmax)-Math.log(lmin)||0)*(H-M-8);
137
+ let d='';for(let i=0;i<=Math.min(step,hist.length-1);i++){d+=(i?'L':'M')+lx(i).toFixed(1)+' '+ly(hist[i].l).toFixed(1);}
138
+ svg.appendChild(el('path',{d,stroke:'#e57373','stroke-width':2,fill:'none'}));
139
+ }
140
+ function paramPlot(hist,step){
141
+ const svg=$('param');svg.innerHTML='';const W=400,H=230,M=34;
142
+ axis(svg,M,H-M,W-8,H-8);
143
+ const ws=hist.map(h=>h.w),bs=hist.map(h=>h.b);
144
+ const lo=Math.min(...ws,...bs,TARGET.w,TARGET.b)-0.3,hi=Math.max(...ws,...bs,TARGET.w,TARGET.b)+0.3;
145
+ const sx=i=>M+i/(hist.length-1)*(W-M-8);
146
+ const sy=v=>H-M-(v-lo)/(hi-lo)*(H-M-8);
147
+ svg.appendChild(el('line',{x1:M,y1:sy(TARGET.w),x2:W-8,y2:sy(TARGET.w),stroke:'#4fc4cf','stroke-width':1,'stroke-dasharray':'4 4',opacity:.4}));
148
+ svg.appendChild(el('line',{x1:M,y1:sy(TARGET.b),x2:W-8,y2:sy(TARGET.b),stroke:'#6bd098','stroke-width':1,'stroke-dasharray':'4 4',opacity:.4}));
149
+ let dw='',db='';
150
+ for(let i=0;i<=Math.min(step,hist.length-1);i++){dw+=(i?'L':'M')+sx(i).toFixed(1)+' '+sy(hist[i].w).toFixed(1);db+=(i?'L':'M')+sx(i).toFixed(1)+' '+sy(hist[i].b).toFixed(1);}
151
+ svg.appendChild(el('path',{d:dw,stroke:'#4fc4cf','stroke-width':2,fill:'none'}));
152
+ svg.appendChild(el('path',{d:db,stroke:'#6bd098','stroke-width':2,fill:'none'}));
153
+ }
154
+ // ---- loss surface heatmap (canvas) + GD path ----
155
+ function surfPlot(hist,step){
156
+ const cv=$('surf'),ctx=cv.getContext('2d');const W=cv.width,H=cv.height;
157
+ const gw=64,gh=32;const cw=W/gw,chh=H/gh;
158
+ const [wa,wb]=W_RANGE,[ba,bb]=B_RANGE;
159
+ let grid=new Float32Array(gw*gh),lmax=0;
160
+ for(let j=0;j<gh;j++)for(let i=0;i<gw;i++){
161
+ const w=wa+(wb-wa)*i/(gw-1),b=ba+(bb-ba)*j/(gh-1);
162
+ const l=Math.log1p(loss(w,b,BASE));grid[j*gw+i]=l;if(l>lmax)lmax=l;
163
+ }
164
+ for(let j=0;j<gh;j++)for(let i=0;i<gw;i++){
165
+ const t=grid[j*gw+i]/(lmax||1);
166
+ // dark-teal (low) -> gold (high)
167
+ const r=Math.round(20+t*220),g=Math.round(40+t*150),b=Math.round(50+t*80);
168
+ ctx.fillStyle=`rgb(${r},${g},${b})`;ctx.fillRect(i*cw,j*chh,cw+1,chh+1);
169
+ }
170
+ // GD path
171
+ const px=w=>(w-wa)/(wb-wa)*W,py=b=>(b-ba)/(bb-ba)*H;
172
+ ctx.strokeStyle='#f4f7f9';ctx.lineWidth=1.6;ctx.beginPath();
173
+ for(let i=0;i<=Math.min(step,hist.length-1);i++){
174
+ const x=px(hist[i].w),y=py(hist[i].b);i?ctx.lineTo(x,y):ctx.moveTo(x,y);
175
+ }
176
+ ctx.stroke();
177
+ const s=hist[Math.min(step,hist.length-1)];
178
+ if(s){ctx.fillStyle='#e57373';ctx.beginPath();ctx.arc(px(s.w),py(s.b),4,0,7);ctx.fill();}
179
+ // frame
180
+ ctx.strokeStyle='#1f2d33';ctx.lineWidth=1;ctx.strokeRect(0,0,W,H);
181
+ }
182
+
183
+ let hist,step=0,timer=0,d;
184
+ function compute(){
185
+ const lr=Math.pow(10,+$('lr').value);
186
+ $('lrVal').textContent=lr.toFixed(3);
187
+ const it=+$('it').value;$('itVal').textContent=it;
188
+ $('nsVal').textContent=(+$('ns').value).toFixed(2);
189
+ $('iwVal').textContent=(+$('iw').value).toFixed(1);
190
+ $('ibVal').textContent=(+$('ib').value).toFixed(1);
191
+ d=noisy();hist=train(d,lr,it,+$('iw').value,+$('ib').value);
192
+ step=0;redraw();updateRO();
193
+ }
194
+ function redraw(){fitPlot(hist,step);lossPlot(hist,step);paramPlot(hist,step);surfPlot(hist,step);}
195
+ function updateRO(){
196
+ const s=hist[step];
197
+ const diverged=s.l>hist[0].l*1.5||!isFinite(s.l);
198
+ $('ro').innerHTML=`<span>step <b>${step}</b></span><span>w=<b>${s.w.toFixed(3)}</b></span><span>b=<b>${s.b.toFixed(3)}</b></span><span>loss=<b>${s.l.toFixed(5)}</b></span>${diverged?'<span class="warn">⚠ 发散</span>':''}`;
199
+ }
200
+ $('play').addEventListener('click',function(){
201
+ if(timer){clearInterval(timer);timer=0;this.textContent='▶ 播放训练';this.classList.remove('on');return;}
202
+ this.textContent='⏸ 暂停';this.classList.add('on');
203
+ const it=+$('it').value;const dur=2200;const dt=Math.max(20,dur/it);
204
+ timer=setInterval(()=>{
205
+ step=Math.min(step+Math.max(1,Math.floor(it/40)),hist.length-1);
206
+ redraw();updateRO();
207
+ if(step>=hist.length-1){clearInterval(timer);timer=0;$('play').textContent='▶ 播放训练';$('play').classList.remove('on');}
208
+ },dt);
209
+ });
210
+ function applyConfig(){const c=DATA.config||{};if(c.lr!=null)$('lr').value=Math.log10(c.lr);if(c.iterations!=null)$('it').value=c.iterations;if(c.init_w!=null)$('iw').value=c.init_w;if(c.init_b!=null)$('ib').value=c.init_b;$('ns').value=0;}
211
+ $('reset').addEventListener('click',()=>{applyConfig();compute();});
212
+ ['lr','it','ns','iw','ib'].forEach(id=>$(id).addEventListener('input',()=>{if(timer){clearInterval(timer);timer=0;$('play').textContent='▶ 播放训练';$('play').classList.remove('on');}compute();}));
213
+ applyConfig();
214
+ compute();
215
+ </script>
216
+ </body>
217
+ </html>
@@ -0,0 +1,9 @@
1
+ # quant —— 因子回测仪表盘(面板交互模式·DSH 版)
2
+
3
+ 模板 `templates/dashboard.html`(因子指标/净值/回撤仪表盘);
4
+ 因子数据导出辅助脚本 `references/export_backtest_data.py`(用 `nix shell nixpkgs#python311 -c python3 ...` 运行)。
5
+
6
+ ## 投递(DSH 原生)
7
+
8
+ 1. 回测结果整理成 self-contained dashboard HTML → `stage_panel`;
9
+ 2. 需要下钻/切换因子等动作 → `stage_choice`(点选 = 用户消息)。
@@ -0,0 +1,192 @@
1
+ """
2
+ 回测数据导出脚本 — Quant Panel 参考实现
3
+ ------------------------------------
4
+ 将此脚本复制到你的量化项目中,适配你的因子计算和回测引擎。
5
+ 输出 JSON 文件供 `localweb quant` 命令使用。
6
+
7
+ Schema 要求见 localweb/quant/SKILL.md
8
+ """
9
+
10
+ import json
11
+ import numpy as np
12
+ from pathlib import Path
13
+
14
+ OUTPUT_DIR = Path(__file__).parent / "output"
15
+
16
+
17
+ class NpEncoder(json.JSONEncoder):
18
+ def default(self, obj):
19
+ if isinstance(obj, (np.integer,)):
20
+ return int(obj)
21
+ if isinstance(obj, (np.floating,)):
22
+ return float(obj)
23
+ if isinstance(obj, np.ndarray):
24
+ return obj.tolist()
25
+ if isinstance(obj, np.bool_):
26
+ return bool(obj)
27
+ if isinstance(obj, pd.Timestamp):
28
+ return obj.isoformat()
29
+ return super().default(obj)
30
+
31
+
32
+ def long_short_metrics(long_short: "pd.Series") -> dict:
33
+ """多空绩效:年化夏普、胜率、最大回撤、累计收益"""
34
+ import pandas as pd
35
+ ls = long_short.dropna()
36
+ if len(ls) == 0:
37
+ return {"sharpe_annual": 0, "win_rate": 0, "max_drawdown": 0, "total_return": 0}
38
+ monthly_mean = float(ls.mean())
39
+ monthly_std = float(ls.std())
40
+ sharpe = float(monthly_mean / monthly_std * np.sqrt(12)) if monthly_std else 0
41
+ win_rate = float((ls > 0).mean())
42
+ cum = (1 + ls).cumprod()
43
+ peak = cum.cummax()
44
+ dd = (cum - peak) / peak
45
+ max_dd = float(dd.min()) if len(dd) else 0
46
+ total_ret = float(cum.iloc[-1] - 1) if len(cum) else 0
47
+ return {
48
+ "sharpe_annual": round(sharpe, 3),
49
+ "win_rate": round(win_rate, 4),
50
+ "max_drawdown": round(max_dd, 4),
51
+ "total_return": round(total_ret, 4),
52
+ }
53
+
54
+
55
+ def factor_distribution(factor_df: "pd.DataFrame") -> dict:
56
+ """因子值分布统计"""
57
+ import pandas as pd
58
+ df = factor_df.copy()
59
+ df["date"] = pd.to_datetime(df["date"])
60
+ latest_date = df["date"].max()
61
+ latest = df[df["date"] == latest_date].dropna(subset=["factor_value"])
62
+ if len(latest) < 20:
63
+ return {"histogram": [], "stats": {}, "date": str(latest_date.date())}
64
+ vals = latest["factor_value"].dropna()
65
+ counts, bins = np.histogram(vals, bins=20)
66
+ histogram = [
67
+ {"bin_start": round(float(bins[i]), 4), "bin_end": round(float(bins[i + 1]), 4), "count": int(c)}
68
+ for i, c in enumerate(counts)
69
+ ]
70
+ stats = {
71
+ "mean": round(float(vals.mean()), 4),
72
+ "std": round(float(vals.std()), 4),
73
+ "min": round(float(vals.min()), 4),
74
+ "p25": round(float(vals.quantile(0.25)), 4),
75
+ "p50": round(float(vals.quantile(0.50)), 4),
76
+ "p75": round(float(vals.quantile(0.75)), 4),
77
+ "max": round(float(vals.max()), 4),
78
+ "skew": round(float(vals.skew()), 3),
79
+ "n_stocks": int(len(vals)),
80
+ }
81
+ return {"histogram": histogram, "stats": stats, "date": str(latest_date.date())}
82
+
83
+
84
+ def cross_section_detail(merged: "pd.DataFrame") -> dict:
85
+ """每组 Top 10 股票明细"""
86
+ import pandas as pd
87
+ df = merged.copy()
88
+ df["date"] = pd.to_datetime(df["date"])
89
+ latest_date = df["date"].max()
90
+ latest = df[df["date"] == latest_date].copy()
91
+ if len(latest) < 20:
92
+ return {"groups": {}, "date": str(latest_date.date())}
93
+ latest["quantile"] = pd.qcut(
94
+ latest["factor_value"].rank(method="first"),
95
+ q=5, labels=False, duplicates="drop",
96
+ )
97
+ groups = {}
98
+ for q in sorted(latest["quantile"].unique()):
99
+ sub = latest[latest["quantile"] == q].sort_values("factor_value", ascending=(q == 0))
100
+ stocks = []
101
+ for _, row in sub.head(10).iterrows():
102
+ stocks.append({
103
+ "symbol": str(row["symbol"]),
104
+ "factor_value": round(float(row["factor_value"]), 4),
105
+ "forward_return": round(float(row["forward_return"]), 4),
106
+ })
107
+ all_grp = latest[latest["quantile"] == q]
108
+ groups[f"G{int(q)}"] = {
109
+ "count": int(len(all_grp)),
110
+ "factor_mean": round(float(all_grp["factor_value"].mean()), 4),
111
+ "factor_std": round(float(all_grp["factor_value"].std()), 4),
112
+ "forward_mean": round(float(all_grp["forward_return"].mean()), 4),
113
+ "top10": stocks,
114
+ }
115
+ return {"groups": groups, "date": str(latest_date.date())}
116
+
117
+
118
+ def export_one(name: str, label: str, factor_df, backtest_result) -> dict:
119
+ """
120
+ 组装单个因子的完整回测数据。
121
+
122
+ 参数:
123
+ name: 因子键名 (如 "momentum")
124
+ label: 因子显示名 (如 "动量(12-1月)")
125
+ factor_df: 因子 DataFrame [date, symbol, factor_value]
126
+ backtest_result: run_backtest() 返回的 dict
127
+
128
+ 返回: 符合 Quant Panel Schema 的 dict
129
+ """
130
+ ic_series = backtest_result["ic_series"]
131
+ cum_returns = backtest_result["cum_returns"]
132
+ long_short = backtest_result["long_short"]
133
+ gr = backtest_result["group_returns"]
134
+ merged = backtest_result["merged_data"]
135
+
136
+ return {
137
+ "label": label,
138
+ "ic_summary": {
139
+ k: (float(v) if isinstance(v, (np.floating, float)) else v)
140
+ for k, v in backtest_result["ic_summary"].items()
141
+ },
142
+ "ic_series": [
143
+ {"date": str(d.date()), "ic": float(v)}
144
+ for d, v in ic_series.items()
145
+ ],
146
+ "cum_returns": {
147
+ f"G{int(q)}": [
148
+ {"date": str(d.date()), "cum_return": float(v)}
149
+ for d, v in s.items()
150
+ ]
151
+ for q, s in sorted(cum_returns.items())
152
+ },
153
+ "long_short": [
154
+ {"date": str(d.date()), "return": float(v)}
155
+ for d, v in long_short.items()
156
+ ],
157
+ "long_short_metrics": long_short_metrics(long_short),
158
+ "group_avg_return": {
159
+ f"G{int(q)}": round(float(gr[gr["group"] == q]["return"].mean()), 6)
160
+ for q in sorted(gr["group"].unique())
161
+ },
162
+ "factor_distribution": factor_distribution(factor_df),
163
+ "cross_section": cross_section_detail(merged),
164
+ }
165
+
166
+
167
+ # ============================================================
168
+ # 使用示例 (根据你的项目修改)
169
+ # ============================================================
170
+ if __name__ == "__main__":
171
+ import pandas as pd
172
+ # 替换为你的因子注册表和回测引擎导入
173
+ # from factors import FACTOR_REGISTRY, calc_factor
174
+ # from backtest_factor.engine import run_backtest
175
+
176
+ # 示例:
177
+ # data = {}
178
+ # for name, (label, func) in FACTOR_REGISTRY.items():
179
+ # try:
180
+ # factor_df = calc_factor(name)
181
+ # result = run_backtest(factor_df, factor_name=label)
182
+ # data[name] = export_one(name, label, factor_df, result)
183
+ # except Exception as e:
184
+ # data[name] = {"label": label, "error": str(e)}
185
+ #
186
+ # OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
187
+ # out_path = OUTPUT_DIR / "backtest_data.json"
188
+ # with open(out_path, "w", encoding="utf-8") as f:
189
+ # json.dump(data, f, ensure_ascii=False, indent=2, cls=NpEncoder)
190
+ # print(f"导出完成: {out_path}")
191
+
192
+ print("参考模板 — 请复制到你的项目并修改导入路径和因子注册表。")