@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.
- package/README.md +35 -4
- package/cordis.patch.yml +6 -2
- package/package.json +5 -3
- package/skills/stage-panel/SKILL.md +107 -0
- package/skills/stage-panel/references/diagram-selection.md +80 -0
- package/skills/stage-panel/references/html-patterns.md +93 -0
- package/skills/stage-panel/references/visual-style.md +43 -0
- package/skills/stage-panel/submode/code/SKILL.md +17 -0
- package/skills/stage-panel/submode/code/examples/go-hello-channel.json +36 -0
- package/skills/stage-panel/submode/code/examples/go-http-api-basics.json +58 -0
- package/skills/stage-panel/submode/code/examples/py-agent-loop.json +79 -0
- package/skills/stage-panel/submode/code/examples/py-ai-parameters-intro.json +65 -0
- package/skills/stage-panel/submode/code/examples/py-fizzbuzz-intro.json +74 -0
- package/skills/stage-panel/submode/code/examples/py-training-loop.json +114 -0
- package/skills/stage-panel/submode/code/examples/rs-move-basics.json +118 -0
- package/skills/stage-panel/submode/code/references/host-toolchains.md +61 -0
- package/skills/stage-panel/submode/code/schemas/lesson.schema.json +225 -0
- package/skills/stage-panel/submode/code/templates/lab.html +561 -0
- package/skills/stage-panel/submode/game-study/SKILL.md +16 -0
- package/skills/stage-panel/submode/game-study/examples/hft-microstructure.json +60 -0
- package/skills/stage-panel/submode/game-study/examples/securities-law.json +57 -0
- package/skills/stage-panel/submode/game-study/schemas/quest.schema.json +95 -0
- package/skills/stage-panel/submode/game-study/templates/quiz.html +516 -0
- package/skills/stage-panel/submode/learn/SKILL.md +16 -0
- package/skills/stage-panel/submode/learn/examples/epsilon-delta.json +140 -0
- package/skills/stage-panel/submode/learn/examples/template-showcase.json +147 -0
- package/skills/stage-panel/submode/learn/schemas/lesson.schema.json +184 -0
- package/skills/stage-panel/submode/learn/templates/lesson.html +782 -0
- package/skills/stage-panel/submode/ml/SKILL.md +9 -0
- package/skills/stage-panel/submode/ml/examples/gd.json +7 -0
- package/skills/stage-panel/submode/ml/templates/gd.html +217 -0
- package/skills/stage-panel/submode/quant/SKILL.md +9 -0
- package/skills/stage-panel/submode/quant/references/export_backtest_data.py +192 -0
- package/skills/stage-panel/submode/quant/templates/dashboard.html +381 -0
- package/src/client.js +177 -15
- package/src/index.js +11 -0
- package/src/skills.js +88 -0
|
@@ -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("参考模板 — 请复制到你的项目并修改导入路径和因子注册表。")
|