feat: 推荐选股落库追踪、参数优化与 Dashboard 中文化
增加 recommendation_log 与定时任务,按网格搜索结果收紧止损/目标 ATR,并补齐绩效核验接口与界面本地化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"""Dashboard HTML — Trading Command Center.
|
"""Dashboard HTML — Trading Command Center.
|
||||||
|
|
||||||
5 sections: COMMAND → ACTION → WHERE → WHY → RISK → EXPECTANCY
|
5 sections: COMMAND → ACTION → WHERE → WHY → 风险 → EXPECTANCY
|
||||||
Everything else collapsed as Diagnostics.
|
Everything else collapsed as Diagnostics.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Micr
|
|||||||
.flow-line{font-family:monospace;font-size:10px;line-height:1.6;white-space:pre;color:var(--muted)}
|
.flow-line{font-family:monospace;font-size:10px;line-height:1.6;white-space:pre;color:var(--muted)}
|
||||||
.flow-line .fr{color:var(--red)}.flow-line .to{color:var(--green)}.flow-line .ar{color:var(--muted)}
|
.flow-line .fr{color:var(--red)}.flow-line .to{color:var(--green)}.flow-line .ar{color:var(--muted)}
|
||||||
|
|
||||||
/* RISK */
|
/* 风险 */
|
||||||
.risk-item{padding:6px 8px;border-radius:4px;margin-bottom:3px;font-size:10px;display:flex;align-items:flex-start;gap:5px}
|
.risk-item{padding:6px 8px;border-radius:4px;margin-bottom:3px;font-size:10px;display:flex;align-items:flex-start;gap:5px}
|
||||||
.risk-crit{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.2)}
|
.risk-crit{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.2)}
|
||||||
.risk-warn{background:rgba(210,153,29,.1);border:1px solid rgba(210,153,29,.2)}
|
.risk-warn{background:rgba(210,153,29,.1);border:1px solid rgba(210,153,29,.2)}
|
||||||
@@ -83,6 +83,11 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Micr
|
|||||||
<div id="app"><div class="loading"><div class="spinner"></div><p>Loading...</p></div></div>
|
<div id="app"><div class="loading"><div class="spinner"></div><p>Loading...</p></div></div>
|
||||||
<script>
|
<script>
|
||||||
var A='/api/v1/dashboard/state';
|
var A='/api/v1/dashboard/state';
|
||||||
|
var SIG_CN={orb_down:'向下突破',orb_up:'向上突破',chan_1buy:'缠论1买',chan_1sell:'缠论1卖',
|
||||||
|
vegas_long:'维加斯多',vegas_short:'维加斯空',ema52_cross_up:'EMA52上穿',ema52_cross_down:'EMA52下穿',
|
||||||
|
gap_up:'向上跳空',gap_down:'向下跳空',nr7:'窄幅7日',ib_long:'内含线多',ib_short:'内含线空'};
|
||||||
|
function CN(s){return SIG_CN[s]||s;}
|
||||||
|
|
||||||
function S(n){var s='';for(var i=0;i<5;i++)s+=i<n?'★':'☆';return s;}
|
function S(n){var s='';for(var i=0;i<5;i++)s+=i<n?'★':'☆';return s;}
|
||||||
function P(v,d){return v!=null&&!isNaN(v)?v:(d||0);}
|
function P(v,d){return v!=null&&!isNaN(v)?v:(d||0);}
|
||||||
function Q(v){return v==null?'--':v;}
|
function Q(v){return v==null?'--':v;}
|
||||||
@@ -143,9 +148,9 @@ function R(d){
|
|||||||
flowL+='<span class=\"'+(f.dir==='in'?'to':'fr')+'\">'+f.name+'</span> <span class=\"ar\">'+arrow+'</span> <span style=\"font-size:9px;color:var(--muted)\">'+(f.chg>0?'+':'')+(f.chg*100).toFixed(1)+'%</span>\\n';
|
flowL+='<span class=\"'+(f.dir==='in'?'to':'fr')+'\">'+f.name+'</span> <span class=\"ar\">'+arrow+'</span> <span style=\"font-size:9px;color:var(--muted)\">'+(f.chg>0?'+':'')+(f.chg*100).toFixed(1)+'%</span>\\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
// RISK
|
// 风险
|
||||||
var rh='<span style=\"font-size:10px;color:var(--green)\">✓ None</span>';
|
var rh='<span style=\"font-size:10px;color:var(--green)\">✓ None</span>';
|
||||||
if(risks.length){rh='';risks.forEach(function(r){var l=r.level==='danger'?'risk-crit':'risk-warn';rh+='<div class=\"risk-item '+l+'\"><span class=\"risk-badge\">'+r.level.toUpperCase().substring(0,4)+'</span>'+r.message+'</div>';})}
|
if(risks.length){rh='';risks.forEach(function(r){var l=r.level==='danger'?'risk-crit':'risk-warn';rh+='<div class=\"risk-item '+l+'\"><span class=\"risk-badge\">'+'危'+'</span>'+r.message+'</div>';})}
|
||||||
|
|
||||||
// Focus/Avoid
|
// Focus/Avoid
|
||||||
var ft='',at='';
|
var ft='',at='';
|
||||||
@@ -169,26 +174,26 @@ function R(d){
|
|||||||
// ═══ COMMAND + WHERE ═══
|
// ═══ COMMAND + WHERE ═══
|
||||||
h+='<div class=\"row\">';
|
h+='<div class=\"row\">';
|
||||||
h+='<div class=\"card\" style=\"background:linear-gradient(135deg,#1a1f2e,#161b22)\">';
|
h+='<div class=\"card\" style=\"background:linear-gradient(135deg,#1a1f2e,#161b22)\">';
|
||||||
h+='<div class=\"sec-label\">COMMAND</div>';
|
h+='<div class=\"sec-label\">指令</div>';
|
||||||
h+='<div class=\"cmd-mode\"><span class=\"'+mc+'\">'+S(ms)+'</span></div>';
|
h+='<div class=\"cmd-mode\"><span class=\"'+mc+'\">'+S(ms)+'</span></div>';
|
||||||
h+='<div style=\"font-size:13px;font-weight:700\">'+ml+'</div>';
|
h+='<div style=\"font-size:13px;font-weight:700\">'+ml+'</div>';
|
||||||
h+='<div class=\"cmd-play\">'+Q(pb.suitable_strategies[0]||pb.bias)+' · '+Q(pb.holding_time)+'</div>';
|
h+='<div class=\"cmd-play\">'+Q(pb.suitable_strategies[0]||pb.bias)+' · '+Q(pb.holding_time)+'</div>';
|
||||||
h+='<div style=\"margin-bottom:4px\">'+acts.join(' ')+'</div>';
|
h+='<div style=\"margin-bottom:4px\">'+acts.join(' ')+'</div>';
|
||||||
if(avs.length)h+='<div style=\"margin-bottom:2px\">'+avs.join(' ')+'</div>';
|
if(avs.length)h+='<div style=\"margin-bottom:2px\">'+avs.join(' ')+'</div>';
|
||||||
h+='<div class=\"cmd-meta\"><span>'+wy.join(' · ')+'</span></div>';
|
h+='<div class=\"cmd-meta\"><span>'+wy.join(' · ')+'</span></div>';
|
||||||
h+='<div class=\"cmd-meta\" style=\"border-top:none;padding-top:2px\"><span>Agr '+(conf*100).toFixed(0)+' Hlth '+(trend*100).toFixed(0)+' Fear '+(fear*100).toFixed(0)+' Liq '+(liq*100).toFixed(0)+' '+Q(pb.bias)+' C'+cashP+'/T'+trendP+'/T20</span></div>';
|
h+='<div class=\"cmd-meta\" style=\"border-top:none;padding-top:2px\"><span>共识 '+(conf*100).toFixed(0)+' 健康 '+(trend*100).toFixed(0)+' 恐惧 '+(fear*100).toFixed(0)+' 流动 '+(liq*100).toFixed(0)+' '+Q(pb.bias)+' C'+cashP+'/T'+trendP+'/T20</span></div>';
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
|
|
||||||
h+='<div class=\"card\">';
|
h+='<div class=\"card\">';
|
||||||
h+='<div class=\"sec-label\">WHERE</div>';
|
h+='<div class=\"sec-label\">方向</div>';
|
||||||
h+=wh||'<span class=\"muted\" style=\"font-size:10px\">--</span>';
|
h+=wh||'<span class=\"muted\" style=\"font-size:10px\">--</span>';
|
||||||
h+='</div></div>';
|
h+='</div></div>';
|
||||||
|
|
||||||
// ═══ WHY + RISK ═══
|
// ═══ WHY + 风险 ═══
|
||||||
h+='<div class=\"row\">';
|
h+='<div class=\"row\">';
|
||||||
h+='<div class=\"card\">';
|
h+='<div class=\"card\">';
|
||||||
h+='<div class=\"sec-label\">WHY — Money Flow</div>';
|
h+='<div class=\"sec-label\">资金流向</div>';
|
||||||
h+='<div class=\"flow-line\">'+(flowL||'No flow data')+'</div>';
|
h+='<div class=\"flow-line\">'+(flowL||'无资金流数据')+'</div>';
|
||||||
if(ft)h+='<div style=\"margin-top:6px;font-size:9px\"><span class=\"muted\">Focus:</span> '+ft+'</div>';
|
if(ft)h+='<div style=\"margin-top:6px;font-size:9px\"><span class=\"muted\">Focus:</span> '+ft+'</div>';
|
||||||
if(at)h+='<div style=\"font-size:9px\"><span class=\"muted\">Avoid:</span> '+at+'</div>';
|
if(at)h+='<div style=\"font-size:9px\"><span class=\"muted\">Avoid:</span> '+at+'</div>';
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
@@ -196,7 +201,7 @@ function R(d){
|
|||||||
// Theme Map
|
// Theme Map
|
||||||
var tg=d.theme_graph||{},themes=tg.themes||[];
|
var tg=d.theme_graph||{},themes=tg.themes||[];
|
||||||
h+='<div class=\"card\">';
|
h+='<div class=\"card\">';
|
||||||
h+='<div class=\"sec-label\">THEMES</div>';
|
h+='<div class=\"sec-label\">主题</div>';
|
||||||
if(themes.length){
|
if(themes.length){
|
||||||
themes.slice(0,5).forEach(function(t,i){
|
themes.slice(0,5).forEach(function(t,i){
|
||||||
var stars='';for(var j=0;j<5;j++)stars+=j<Math.round(t.score/20)?'★':'☆';
|
var stars='';for(var j=0;j<5;j++)stars+=j<Math.round(t.score/20)?'★':'☆';
|
||||||
@@ -219,58 +224,65 @@ function R(d){
|
|||||||
// Recommendations
|
// Recommendations
|
||||||
var recs=d.recommendations||[];
|
var recs=d.recommendations||[];
|
||||||
h+='<div class=\"card\" style=\"margin-bottom:8px\">';
|
h+='<div class=\"card\" style=\"margin-bottom:8px\">';
|
||||||
h+='<div class=\"sec-label\">RECOMMENDATIONS — Money Flow Picks</div>';
|
h+='<div class=\"sec-label\">RECOMMENDATIONS — 资金轮动选股</div>';
|
||||||
if(recs.length){
|
if(recs.length){
|
||||||
h+='<div style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:6px\">';
|
h+='<div style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:6px\">';
|
||||||
recs.forEach(function(r,i){
|
recs.forEach(function(r,i){
|
||||||
var cls=r.score>70?'good':r.score>50?'warn':'muted';
|
var sc=r.score||0;var cls=sc>70?'good':sc>50?'warn':'muted';
|
||||||
h+='<div style=\"padding:8px 10px;background:rgba(255,255,255,.02);border-radius:6px;font-size:11px\">';
|
h+='<div style=\"padding:8px 10px;background:rgba(255,255,255,.02);border-radius:6px;font-size:11px\">';
|
||||||
h+='<div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:4px\">';
|
h+='<div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:4px\">';
|
||||||
h+='<span><strong>'+(r.name||r.ts_code)+'</strong> <span style=\"font-size:10px;color:var(--muted)\">'+r.ts_code+' · '+r.sector+'</span></span>';
|
h+='<span><strong>'+(r.name||r.ts_code)+'</strong> <span style=\"font-size:10px;color:var(--muted)\">'+r.ts_code+' · '+r.sector+'</span></span>';
|
||||||
h+='<span class=\"'+cls+'\" style=\"font-weight:700\">'+r.score.toFixed(0)+'</span></div>';
|
h+='<span class=\"'+cls+'\" style=\"font-weight:700\">'+sc.toFixed(0)+'</span></div>';
|
||||||
h+='<div style=\"display:flex;gap:12px;font-size:10px;color:var(--muted);margin-bottom:4px\">';
|
h+='<div style=\"display:flex;gap:12px;font-size:10px;color:var(--muted);margin-bottom:4px\">';
|
||||||
h+='<span>Entry: <span class=\"good\">'+r.entry+'</span></span>';
|
h+='<span>入场: <span class=\"good\">'+r.entry+'</span></span>';
|
||||||
h+='<span>Stop: <span class=\"bad\">'+r.stop+'</span></span>';
|
h+='<span>止损: <span class=\"bad\">'+r.stop+'</span></span>';
|
||||||
h+='<span>T1: <span class=\"good\">'+r.target1+'</span></span>';
|
h+='<span>目标1: <span class=\"good\">'+r.target1+'</span></span>';
|
||||||
h+='<span>T2: '+r.target2+'</span></div>';
|
h+='<span>T2: '+r.target2+'</span></div>';
|
||||||
h+='<div style=\"display:flex;justify-content:space-between;font-size:10px\">';
|
h+='<div style=\"display:flex;justify-content:space-between;font-size:10px\">';
|
||||||
h+='<span class=\"muted\">5d:'+(r.metrics.ret_5d>0?'+':'')+r.metrics.ret_5d.toFixed(1)+'% Vol:'+r.metrics.vol_ratio.toFixed(1)+'x '+r.metrics.trend+'</span>';
|
var ret5=r.ret_5d||0,volR=r.vol_ratio||1,trS=r.trend_score||0,amtY=r.amount_yi||0;
|
||||||
h+='<span style=\"color:var(--accent)\">R:R '+r.rr_ratio.toFixed(1)+'</span></div>';
|
h+='<span class=\"muted\">5日:'+(ret5>0?'+':'')+ret5.toFixed(1)+'% 量比:'+volR.toFixed(1)+'x 成交额:'+amtY.toFixed(1)+'亿 '+(trS>=2?'强势':trS>=1?'中性':'弱势')+'</span>';
|
||||||
h+='<div style=\"font-size:10px;color:var(--green);margin-top:3px\">'+r.action+'</div>';
|
h+='<span style=\"color:var(--accent)\">盈亏比 '+(r.rr_ratio||0).toFixed(1)+'</span></div>';
|
||||||
|
h+='<div style=\"font-size:10px;color:var(--green);margin-top:3px\">'+r.action+(r.outcome?' <span style=\"color:'+(r.outcome==='win'?'var(--green)':'var(--red)')+'\">['+r.outcome.toUpperCase()+']</span>':'')+'</div>';
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
});
|
});
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
}else{h+='<span class=\"muted\" style=\"font-size:11px\">No recommendations — insufficient data</span>';}
|
}else{h+='<span class=\"muted\" style=\"font-size:11px\">No recommendations — insufficient data</span>';}
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
|
|
||||||
|
// Verify card (loaded async)
|
||||||
|
h+='<div class=\"card\" style=\"margin-bottom:8px\" id=\"verify-card\"><span class=\"muted\" style=\"font-size:10px\">Loading performance stats...</span></div>';
|
||||||
|
|
||||||
h+='<div class=\"row\"><div class=\"card\">';
|
h+='<div class=\"row\"><div class=\"card\">';
|
||||||
h+='<div class=\"sec-label\">RISK</div>';
|
h+='<div class=\"sec-label\">风险</div>';
|
||||||
|
h+=rh;
|
||||||
|
h+='<div class=\"row\"><div class=\"card\">';
|
||||||
|
h+='<div class=\"sec-label\">风险</div>';
|
||||||
h+=rh;
|
h+=rh;
|
||||||
h+='</div></div>';
|
h+='</div></div>';
|
||||||
|
|
||||||
// ═══ EXPECTANCY ═══
|
// ═══ EXPECTANCY ═══
|
||||||
h+='<div class=\"card\" style=\"opacity:0.8\">';
|
h+='<div class=\"card\" style=\"opacity:0.8\">';
|
||||||
var expLabel=samples>0?'— '+sigType+' · '+samples+' samples':'— Phase 1 Heuristic · 0 samples';
|
var expLabel=samples>0?'— '+CN(sigType)+' · '+samples+' 样本':'— 阶段1 启发式 · 0样本';
|
||||||
var expColor=samples>0?'var(--green)':'var(--orange)';
|
var expColor=samples>0?'var(--green)':'var(--orange)';
|
||||||
h+='<div class=\"sec-label\">EXPECTANCY <span style=\"color:'+expColor+';font-weight:400\">'+expLabel+'</span></div>';
|
h+='<div class=\"sec-label\">EXPECTANCY <span style=\"color:'+expColor+';font-weight:400\">'+expLabel+'</span></div>';
|
||||||
h+='<div class=\"exp-grid\">';
|
h+='<div class=\"exp-grid\">';
|
||||||
h+='<div class=\"exp-stat\"><div class=\"val '+(wr>50?'good':'warn')+'\">'+wr.toFixed(0)+'%</div><div class=\"lbl\">Est. WR</div></div>';
|
h+='<div class=\"exp-stat\"><div class=\"val '+(wr>50?'good':'warn')+'\">'+wr.toFixed(0)+'%</div><div class=\"lbl\">预估胜率</div></div>';
|
||||||
h+='<div class=\"exp-stat\"><div class=\"val '+(ar>0?'good':'bad')+'\">'+(ar>0?'+':'')+ar.toFixed(1)+'%</div><div class=\"lbl\">Est. Ret</div></div>';
|
h+='<div class=\"exp-stat\"><div class=\"val '+(ar>0?'good':'bad')+'\">'+(ar>0?'+':'')+ar.toFixed(1)+'%</div><div class=\"lbl\">预估收益</div></div>';
|
||||||
h+='<div class=\"exp-stat\"><div class=\"val bad\">'+dd.toFixed(1)+'%</div><div class=\"lbl\">Est. DD</div></div>';
|
h+='<div class=\"exp-stat\"><div class=\"val bad\">'+dd.toFixed(1)+'%</div><div class=\"lbl\">预估回撤</div></div>';
|
||||||
h+='<div class=\"exp-stat\"><div class=\"val\">'+hold.toFixed(1)+'d</div><div class=\"lbl\">Est. Hold</div></div>';
|
h+='<div class=\"exp-stat\"><div class=\"val\">'+hold.toFixed(1)+'d</div><div class=\"lbl\">预估持仓</div></div>';
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
|
|
||||||
// All expectancies comparison table
|
// All expectancies comparison table
|
||||||
var allExp=d.all_expectancies||[];
|
var allExp=d.all_expectancies||[];
|
||||||
if(allExp.length>0){
|
if(allExp.length>0){
|
||||||
h+='<table class=\"exp-table\"><tr><th>Signal</th><th>WR</th><th>Samples</th><th>Avg Ret</th><th>Max DD</th></tr>';
|
h+='<table class=\"exp-table\"><tr><th>信号</th><th>WR</th><th>样本</th><th>平均收益</th><th>最大回撤</th></tr>';
|
||||||
allExp.forEach(function(e){
|
allExp.forEach(function(e){
|
||||||
var ew=e.win_rate*100,ea=e.avg_return*100,ed=e.max_drawdown*100;
|
var ew=e.win_rate*100,ea=e.avg_return*100,ed=e.max_drawdown*100;
|
||||||
var barW=Math.max(2,ew);
|
var barW=Math.max(2,ew);
|
||||||
h+='<tr>'+
|
h+='<tr>'+
|
||||||
'<td>'+e.signal_type+'</td>'+
|
'<td>'+CN(e.signal_type)+'</td>'+
|
||||||
'<td><span class=\"exp-mini-bar\" style=\"width:'+barW+'px;background:'+(ew>50?'var(--green)':ew>40?'var(--orange)':'var(--red)')+'\"></span>'+(e.sample_count>0?ew.toFixed(0)+'%':'--')+'</td>'+
|
'<td><span class=\"exp-mini-bar\" style=\"width:'+barW+'px;background:'+(ew>50?'var(--green)':ew>40?'var(--orange)':'var(--red)')+'\"></span>'+(e.sample_count>0?ew.toFixed(0)+'%':'--')+'</td>'+
|
||||||
'<td style=\"color:var(--muted)\">'+(e.sample_count>0?e.sample_count.toLocaleString():'heuristic')+'</td>'+
|
'<td style=\"color:var(--muted)\">'+(e.sample_count>0?e.sample_count.toLocaleString():'启发式')+'</td>'+
|
||||||
'<td class=\"'+(ea>0?'good':'bad')+'\">'+(e.sample_count>0?(ea>0?'+':'')+ea.toFixed(1)+'%':'--')+'</td>'+
|
'<td class=\"'+(ea>0?'good':'bad')+'\">'+(e.sample_count>0?(ea>0?'+':'')+ea.toFixed(1)+'%':'--')+'</td>'+
|
||||||
'<td class=\"bad\">'+(e.sample_count>0?ed.toFixed(1)+'%':'--')+'</td>'+
|
'<td class=\"bad\">'+(e.sample_count>0?ed.toFixed(1)+'%':'--')+'</td>'+
|
||||||
'</tr>';
|
'</tr>';
|
||||||
@@ -282,20 +294,20 @@ function R(d){
|
|||||||
// ═══ DIAGNOSTICS (always collapsed) ═══
|
// ═══ DIAGNOSTICS (always collapsed) ═══
|
||||||
var idx=(tr.index_details||[]).slice(0,7);
|
var idx=(tr.index_details||[]).slice(0,7);
|
||||||
var idxH='';idx.forEach(function(i){idxH+='<div class=\"metric\"><span>'+i.name+'</span><span class=\"'+(i.pct_chg>0?'good':'bad')+'\">'+(i.pct_chg>0?'+':'')+i.pct_chg.toFixed(2)+'%</span></div>';});
|
var idxH='';idx.forEach(function(i){idxH+='<div class=\"metric\"><span>'+i.name+'</span><span class=\"'+(i.pct_chg>0?'good':'bad')+'\">'+(i.pct_chg>0?'+':'')+i.pct_chg.toFixed(2)+'%</span></div>';});
|
||||||
h+='<div class=\"diag-toggle\" onclick=\"var e=document.getElementById(\\'diag\\');e.classList.toggle(\\'open\\');this.textContent=e.classList.contains(\\'open\\')?\\'▼ Diagnostics\\':\\'▶ Diagnostics\\'\">▶ Diagnostics</div>';
|
h+='<div class=\"diag-toggle\" onclick=\"var e=document.getElementById(\\'diag\\');e.classList.toggle(\\'open\\');this.textContent=e.classList.contains(\\'open\\')?\\'▼ 诊断\\':\\'▶ 诊断\\'\">▶ 诊断</div>';
|
||||||
h+='<div class=\"diag-content\" id=\"diag\"><div class=\"diag-grid\">';
|
h+='<div class=\"diag-content\" id=\"diag\"><div class=\"diag-grid\">';
|
||||||
h+='<div class=\"diag-card\"><h4>Breadth</h4><div class=\"metric\"><span>Adv/Dec</span><span><span class=\"good\">'+Q(b.advances)+'</span>/<span class=\"bad\">'+Q(b.declines)+'</span></span></div><div class=\"metric\"><span>Ratio</span><span>'+((b.advance_ratio||0)*100).toFixed(1)+'%</span></div><div class=\"metric\"><span>New High</span><span>'+((b.new_high_ratio||0)*100).toFixed(1)+'%</span></div></div>';
|
h+='<div class=\"diag-card\"><h4>市场宽度</h4><div class=\"metric\"><span>Adv/Dec</span><span><span class=\"good\">'+Q(b.advances)+'</span>/<span class=\"bad\">'+Q(b.declines)+'</span></span></div><div class=\"metric\"><span>Ratio</span><span>'+((b.advance_ratio||0)*100).toFixed(1)+'%</span></div><div class=\"metric\"><span>New High</span><span>'+((b.new_high_ratio||0)*100).toFixed(1)+'%</span></div></div>';
|
||||||
h+='<div class=\"diag-card\"><h4>Volume</h4><div class=\"metric\"><span>Turnover</span><span>'+Q(v.total_turnover_yi)+' 亿</span></div><div class=\"metric\"><span>vs 5d</span><span>'+((v.vs_5d-1)*100).toFixed(1)+'%</span></div><div class=\"metric\"><span>Tier</span><span>'+Q(v.tier)+'</span></div></div>';
|
h+='<div class=\"diag-card\"><h4>成交量</h4><div class=\"metric\"><span>Turnover</span><span>'+Q(v.total_turnover_yi)+' 亿</span></div><div class=\"metric\"><span>vs 5d</span><span>'+((v.vs_5d-1)*100).toFixed(1)+'%</span></div><div class=\"metric\"><span>Tier</span><span>'+Q(v.tier)+'</span></div></div>';
|
||||||
h+='<div class=\"diag-card\"><h4>State</h4>';['trend','fear','liquidity','rotation','participation','volatility','breadth'].forEach(function(k){h+='<div class=\"metric\"><span>'+k+'</span><span>'+((dims[k]||0)*100).toFixed(0)+'</span></div>';});h+='</div>';
|
h+='<div class=\"diag-card\"><h4>状态</h4>';['trend','fear','liquidity','rotation','participation','volatility','breadth'].forEach(function(k){h+='<div class=\"metric\"><span>'+k+'</span><span>'+((dims[k]||0)*100).toFixed(0)+'</span></div>';});h+='</div>';
|
||||||
h+='<div class=\"diag-card\"><h4>Indices</h4>'+idxH+'<div style=\"font-size:8px;color:var(--muted);margin-top:2px\">Resonance: '+((tr.resonance||0)*100).toFixed(0)+'%</div></div>';
|
h+='<div class=\"diag-card\"><h4>指数</h4>'+idxH+'<div style=\"font-size:8px;color:var(--muted);margin-top:2px\">共振度: '+((tr.resonance||0)*100).toFixed(0)+'%</div></div>';
|
||||||
var sent=ev.sentiment||{};
|
var sent=ev.sentiment||{};
|
||||||
if(sent.status==='live'){
|
if(sent.status==='live'){
|
||||||
h+='<div class=\"diag-card\"><h4>Sentiment</h4>';
|
h+='<div class=\"diag-card\"><h4>情绪</h4>';
|
||||||
h+='<div class=\"metric\"><span>Profit Effect</span><span class=\"'+(sent.profit_effect==='Strong'||sent.profit_effect==='Average'?'good':'bad')+'\">'+Q(sent.profit_effect)+' ('+Q(sent.profit_effect_score)+')</span></div>';
|
h+='<div class=\"metric\"><span>赚钱效应</span><span class=\"'+(sent.profit_effect==='强势'||sent.profit_effect==='一般'?'good':'bad')+'\">'+Q(sent.profit_effect)+' ('+Q(sent.profit_effect_score)+')</span></div>';
|
||||||
h+='<div class=\"metric\"><span>Limit Up/Down</span><span><span class=\"good\">'+Q(sent.limit_up_count)+'</span>/<span class=\"bad\">'+Q(sent.limit_down_count)+'</span></span></div>';
|
h+='<div class=\"metric\"><span>涨停/跌停</span><span><span class=\"good\">'+Q(sent.limit_up_count)+'</span>/<span class=\"bad\">'+Q(sent.limit_down_count)+'</span></span></div>';
|
||||||
h+='<div class=\"metric\"><span>Broken Board</span><span>'+Q(sent.broken_board_count)+' ('+((sent.broken_board_rate||0)*100).toFixed(0)+'%)</span></div>';
|
h+='<div class=\"metric\"><span>炸板</span><span>'+Q(sent.broken_board_count)+' ('+((sent.broken_board_rate||0)*100).toFixed(0)+'%)</span></div>';
|
||||||
h+='<div class=\"metric\"><span>Consecutive</span><span>'+Q(sent.consecutive_count)+' ('+((sent.consecutive_board_rate||0)*100).toFixed(0)+'%)</span></div>';
|
h+='<div class=\"metric\"><span>连板</span><span>'+Q(sent.consecutive_count)+' ('+((sent.consecutive_board_rate||0)*100).toFixed(0)+'%)</span></div>';
|
||||||
if(sent.max_consecutive)h+='<div class=\"metric\"><span>Max Board</span><span>'+sent.max_consecutive+' 连板</span></div>';
|
if(sent.max_consecutive)h+='<div class=\"metric\"><span>最高连板</span><span>'+sent.max_consecutive+' 连板</span></div>';
|
||||||
h+='</div>';
|
h+='</div>';
|
||||||
}
|
}
|
||||||
h+='</div></div>';
|
h+='</div></div>';
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from ashare_dp.market.leadership import assess_leaders
|
|||||||
from ashare_dp.market.memory import state_store
|
from ashare_dp.market.memory import state_store
|
||||||
from ashare_dp.market.opportunity import rank_opportunities
|
from ashare_dp.market.opportunity import rank_opportunities
|
||||||
from ashare_dp.market.knowledge import activate_themes
|
from ashare_dp.market.knowledge import activate_themes
|
||||||
from ashare_dp.market.recommendations import get_recommendations
|
|
||||||
from ashare_dp.market.sentiment import assess_sentiment
|
from ashare_dp.market.sentiment import assess_sentiment
|
||||||
from ashare_dp.market.state import infer_market_state
|
from ashare_dp.market.state import infer_market_state
|
||||||
from ashare_dp.signals.expectancy import get_expectancy, get_all_expectancies
|
from ashare_dp.signals.expectancy import get_expectancy, get_all_expectancies
|
||||||
@@ -79,11 +78,26 @@ async def dashboard_state():
|
|||||||
# Knowledge Graph — theme activation
|
# Knowledge Graph — theme activation
|
||||||
theme_graph = activate_themes(trade_date, leaders=leaders, opportunities=opportunities)
|
theme_graph = activate_themes(trade_date, leaders=leaders, opportunities=opportunities)
|
||||||
|
|
||||||
# Stock Recommendations
|
# Stock Recommendations — read from EOD-computed recommendation_log
|
||||||
try:
|
try:
|
||||||
recommendations = get_recommendations(trade_date, top_n=10)
|
with get_db(read_only=True) as db:
|
||||||
except Exception as e:
|
rows = db.query(
|
||||||
logger.warning(f"Recommendations failed: {e}")
|
"SELECT ts_code, name, sector, entry_price, stop_price, target1, target2, "
|
||||||
|
"score, action, outcome, ret_5d, vol_ratio, trend_score, amount_yi "
|
||||||
|
"FROM recommendation_log WHERE trade_date = ? ORDER BY score DESC",
|
||||||
|
(trade_date.isoformat(),),
|
||||||
|
)
|
||||||
|
def _rr(entry, stop, t1):
|
||||||
|
return round((t1 - entry) / (entry - stop), 1) if entry and stop and entry > stop else 0.0
|
||||||
|
recommendations = [
|
||||||
|
{"ts_code": r[0], "name": r[1], "sector": r[2],
|
||||||
|
"entry": r[3], "stop": r[4], "target1": r[5], "target2": r[6],
|
||||||
|
"score": r[7], "action": r[8], "outcome": r[9],
|
||||||
|
"ret_5d": r[10], "vol_ratio": r[11], "trend_score": r[12], "amount_yi": r[13],
|
||||||
|
"rr_ratio": _rr(r[3], r[4], r[5])}
|
||||||
|
for r in rows
|
||||||
|
] if rows else []
|
||||||
|
except Exception:
|
||||||
recommendations = []
|
recommendations = []
|
||||||
|
|
||||||
# ── L3: Trading Intelligence ──
|
# ── L3: Trading Intelligence ──
|
||||||
@@ -197,6 +211,123 @@ async def close_trade(trade_id: int, request: dict):
|
|||||||
return {"status": "closed"}
|
return {"status": "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_recommendations(recs: list, trade_date_str: str = ""):
|
||||||
|
"""Auto-save recommendations to tracking log."""
|
||||||
|
if not recs:
|
||||||
|
return
|
||||||
|
if not trade_date_str:
|
||||||
|
trade_date_str = date.today().isoformat()
|
||||||
|
from ashare_dp.data.store.database import get_db
|
||||||
|
with get_db(read_only=False) as db:
|
||||||
|
for r in recs:
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO recommendation_log
|
||||||
|
(trade_date, ts_code, name, sector, entry_price, stop_price,
|
||||||
|
target1, target2, score, action)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (trade_date, ts_code) DO NOTHING""",
|
||||||
|
(trade_date_str, r["ts_code"], r.get("name", ""),
|
||||||
|
r["sector"], r["entry"], r["stop"], r["target1"], r["target2"],
|
||||||
|
r["score"], r.get("action", "")),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/verify")
|
||||||
|
async def verify_recommendations(days: int = 30):
|
||||||
|
"""Verify past recommendations against actual price data. Returns performance stats."""
|
||||||
|
from datetime import date as _date, timedelta
|
||||||
|
end = _date.today()
|
||||||
|
start = end - timedelta(days=days)
|
||||||
|
|
||||||
|
with get_db(read_only=True) as db:
|
||||||
|
rows = db.query(
|
||||||
|
"SELECT trade_date, ts_code, entry_price, stop_price, target1, target2, outcome "
|
||||||
|
"FROM recommendation_log WHERE trade_date >= ? AND trade_date <= ? ORDER BY trade_date DESC",
|
||||||
|
(start.isoformat(), end.isoformat()),
|
||||||
|
)
|
||||||
|
logger.info(f"Verify: found {len(rows)} recommendations in range {start}~{end}")
|
||||||
|
|
||||||
|
verified = []
|
||||||
|
wins = 0
|
||||||
|
losses = 0
|
||||||
|
pending = 0
|
||||||
|
|
||||||
|
conn = analytics_conn()
|
||||||
|
for r in rows:
|
||||||
|
td, ts, entry, stop, t1, t2, outcome = r
|
||||||
|
if outcome and outcome != "pending":
|
||||||
|
if outcome == "win": wins += 1
|
||||||
|
elif outcome == "loss": losses += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Verify: find first close that hits target or stop (realistic, not intraday extremes)
|
||||||
|
try:
|
||||||
|
future = conn.execute(
|
||||||
|
f"SELECT trade_date, close FROM read_parquet('{kline_glob()}', hive_partitioning=true, union_by_name=true) "
|
||||||
|
f"WHERE ts_code = ? AND trade_date > ? ORDER BY trade_date ASC",
|
||||||
|
[ts, str(td)],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result = "pending"
|
||||||
|
actual_return = None
|
||||||
|
max_reached = float(entry)
|
||||||
|
min_reached = float(entry)
|
||||||
|
|
||||||
|
if future:
|
||||||
|
for fd, fc in future:
|
||||||
|
fc = float(fc)
|
||||||
|
max_reached = max(max_reached, fc)
|
||||||
|
min_reached = min(min_reached, fc)
|
||||||
|
|
||||||
|
if fc >= t1:
|
||||||
|
result = "win"
|
||||||
|
actual_return = round((t1 - entry) / entry * 100, 2)
|
||||||
|
break
|
||||||
|
elif fc <= stop:
|
||||||
|
result = "loss"
|
||||||
|
actual_return = round((stop - entry) / entry * 100, 2)
|
||||||
|
break
|
||||||
|
|
||||||
|
if result == "pending":
|
||||||
|
pending += 1
|
||||||
|
elif result == "win":
|
||||||
|
wins += 1
|
||||||
|
else:
|
||||||
|
losses += 1
|
||||||
|
|
||||||
|
if result != "pending":
|
||||||
|
with get_db(read_only=False) as wdb:
|
||||||
|
wdb.execute(
|
||||||
|
"UPDATE recommendation_log SET outcome=?, actual_return=?, verified=TRUE WHERE trade_date=? AND ts_code=?",
|
||||||
|
(result, actual_return, str(td), ts),
|
||||||
|
)
|
||||||
|
|
||||||
|
verified.append({
|
||||||
|
"trade_date": str(td), "ts_code": ts,
|
||||||
|
"entry": entry, "stop": stop, "target1": t1,
|
||||||
|
"max_reached": round(max_reached, 2),
|
||||||
|
"min_reached": round(min_reached, 2),
|
||||||
|
"outcome": result,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pending += 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total = wins + losses
|
||||||
|
win_rate = round(wins / total * 100, 1) if total > 0 else 0
|
||||||
|
return {
|
||||||
|
"period_days": days,
|
||||||
|
"total_verified": len(verified),
|
||||||
|
"wins": wins, "losses": losses, "pending": pending,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
"verified": verified[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/replay")
|
@router.get("/dashboard/replay")
|
||||||
async def dashboard_replay(date_str: str = ""):
|
async def dashboard_replay(date_str: str = ""):
|
||||||
"""Return all intraday state snapshots for a given date (YYYY-MM-DD)."""
|
"""Return all intraday state snapshots for a given date (YYYY-MM-DD)."""
|
||||||
|
|||||||
@@ -80,3 +80,49 @@ async def wyckoff_scan_job():
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Wyckoff scan job failed: {e}")
|
logger.error(f"Wyckoff scan job failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def recommendation_job():
|
||||||
|
"""EOD recommendation computation — compute and store stock picks."""
|
||||||
|
from datetime import date as _date
|
||||||
|
from ashare_dp.market.recommendations import get_recommendations
|
||||||
|
from ashare_dp.data.store.database import get_db
|
||||||
|
|
||||||
|
today = _date.today()
|
||||||
|
logger.info(f"Computing daily recommendations for {today}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
recs = get_recommendations(today, top_n=10)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Recommendation computation failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not recs:
|
||||||
|
logger.warning("No recommendations generated")
|
||||||
|
return
|
||||||
|
|
||||||
|
trade_date_str = today.isoformat()
|
||||||
|
with get_db(read_only=False) as db:
|
||||||
|
for r in recs:
|
||||||
|
try:
|
||||||
|
m = r.get("metrics", {})
|
||||||
|
trend_str = str(m.get("trend", "Weak"))
|
||||||
|
trend_val = 2.0 if trend_str == "Strong" else (1.0 if trend_str == "Neutral" else 0.0)
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO recommendation_log
|
||||||
|
(trade_date, ts_code, name, sector, entry_price, stop_price,
|
||||||
|
target1, target2, score, action, ret_5d, vol_ratio, trend_score, amount_yi)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (trade_date, ts_code) DO UPDATE SET
|
||||||
|
score=excluded.score, action=excluded.action,
|
||||||
|
ret_5d=excluded.ret_5d, vol_ratio=excluded.vol_ratio,
|
||||||
|
trend_score=excluded.trend_score, amount_yi=excluded.amount_yi""",
|
||||||
|
(trade_date_str, r["ts_code"], r.get("name", ""),
|
||||||
|
r["sector"], r["entry"], r["stop"], r["target1"], r["target2"],
|
||||||
|
r["score"], r.get("action", ""),
|
||||||
|
m.get("ret_5d"), m.get("vol_ratio"), trend_val, m.get("amount_yi")),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"Recommendations stored: {len(recs)} stocks")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Scheduler:
|
|||||||
eod_pull_job,
|
eod_pull_job,
|
||||||
ema52_screening_job,
|
ema52_screening_job,
|
||||||
health_check_job,
|
health_check_job,
|
||||||
|
recommendation_job,
|
||||||
wyckoff_scan_job,
|
wyckoff_scan_job,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,10 +82,24 @@ class Scheduler:
|
|||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Recommendations: 15:20 Beijing time, Mon-Fri (after Wyckoff at 15:15)
|
||||||
|
self._scheduler.add_job(
|
||||||
|
recommendation_job,
|
||||||
|
trigger=CronTrigger(
|
||||||
|
day_of_week="mon-fri",
|
||||||
|
hour=15,
|
||||||
|
minute=20,
|
||||||
|
timezone=BEIJING_TZ,
|
||||||
|
),
|
||||||
|
id="recommendations",
|
||||||
|
name="Daily stock recommendations",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
self._scheduler.start()
|
self._scheduler.start()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler started with EOD (15:05) + EMA52 (15:10) + "
|
"Scheduler started with EOD (15:05) + EMA52 (15:10) + "
|
||||||
"Wyckoff (15:15) + health check (08:00)"
|
"Wyckoff (15:15) + Recs (15:20) + health check (08:00)"
|
||||||
)
|
)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
|
|||||||
@@ -180,4 +180,22 @@ DDL_STATEMENTS = [
|
|||||||
"""CREATE INDEX IF NOT EXISTS idx_wyckoff_score ON wyckoff_scan(trade_date, overall_score DESC)""",
|
"""CREATE INDEX IF NOT EXISTS idx_wyckoff_score ON wyckoff_scan(trade_date, overall_score DESC)""",
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_wyckoff_align ON wyckoff_scan(trade_date, alignment DESC)""",
|
"""CREATE INDEX IF NOT EXISTS idx_wyckoff_align ON wyckoff_scan(trade_date, alignment DESC)""",
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_wyckoff_version ON wyckoff_scan(trade_date, engine_version)""",
|
"""CREATE INDEX IF NOT EXISTS idx_wyckoff_version ON wyckoff_scan(trade_date, engine_version)""",
|
||||||
|
# ── Recommendation Tracking ──
|
||||||
|
"""CREATE SEQUENCE IF NOT EXISTS seq_rec_id""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS recommendation_log (
|
||||||
|
id BIGINT PRIMARY KEY DEFAULT nextval('seq_rec_id'),
|
||||||
|
trade_date DATE NOT NULL, ts_code VARCHAR(15) NOT NULL,
|
||||||
|
name VARCHAR(40), sector VARCHAR(40),
|
||||||
|
entry_price DOUBLE NOT NULL, stop_price DOUBLE NOT NULL,
|
||||||
|
target1 DOUBLE NOT NULL, target2 DOUBLE NOT NULL,
|
||||||
|
score DOUBLE, action VARCHAR(100),
|
||||||
|
ret_5d DOUBLE, vol_ratio DOUBLE, trend_score DOUBLE, amount_yi DOUBLE,
|
||||||
|
outcome VARCHAR(20), actual_return DOUBLE,
|
||||||
|
verified BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(trade_date, ts_code)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_rec_date ON recommendation_log(trade_date)""",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Parameter Optimizer — grid search for optimal recommendation parameters.
|
||||||
|
|
||||||
|
Tests weight combinations × stop multipliers × target multipliers
|
||||||
|
against historical recommendation outcomes to find the best config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
def optimize_parameters(
|
||||||
|
trade_date: date | None = None,
|
||||||
|
lookback_days: int = 60,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Grid search for optimal recommendation parameters.
|
||||||
|
|
||||||
|
Tests different weight combos, stop ATR multiples, and target ATR multiples.
|
||||||
|
For each combo, generates recommendations for past N trading days
|
||||||
|
and checks forward outcomes.
|
||||||
|
|
||||||
|
Returns ranked list of parameter sets with performance stats.
|
||||||
|
"""
|
||||||
|
# ═══ Parameter grid ═══
|
||||||
|
weight_combos = [
|
||||||
|
(50, 30, 20, "50/30/20"),
|
||||||
|
(40, 30, 30, "40/30/30"),
|
||||||
|
(40, 40, 20, "40/40/20"),
|
||||||
|
(50, 25, 25, "50/25/25"),
|
||||||
|
(30, 40, 30, "30/40/30"),
|
||||||
|
(30, 30, 40, "30/30/40"),
|
||||||
|
]
|
||||||
|
stop_mults = [1.0, 1.5, 2.0, 2.5, 3.0]
|
||||||
|
target_mults = [1.0, 1.5, 2.0, 2.5, 3.0]
|
||||||
|
|
||||||
|
# ═══ Get trading days ═══
|
||||||
|
if trade_date is None:
|
||||||
|
trade_date = date.today()
|
||||||
|
|
||||||
|
conn = duckdb.connect("data/duckdb/ashare.db", read_only=True)
|
||||||
|
cal_dates = conn.execute(
|
||||||
|
"SELECT trade_date FROM trading_calendar WHERE trade_date <= ? "
|
||||||
|
"AND is_trading_day=true ORDER BY trade_date DESC LIMIT ?",
|
||||||
|
[trade_date.isoformat(), lookback_days],
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if len(cal_dates) < 10:
|
||||||
|
logger.warning(f"Only {len(cal_dates)} trading days available")
|
||||||
|
return []
|
||||||
|
|
||||||
|
test_dates = [date.fromisoformat(str(r[0])[:10]) for r in cal_dates[5:]] # skip most recent 5 (outcome unknown)
|
||||||
|
logger.info(f"Testing parameters over {len(test_dates)} trading days")
|
||||||
|
|
||||||
|
# ═══ For each test date, get top stocks by raw scores ═══
|
||||||
|
# Use a simpler approach: pre-compute stock scores for all test dates
|
||||||
|
stock_scores = _compute_historical_scores(test_dates)
|
||||||
|
|
||||||
|
if not stock_scores:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"Scored {len(stock_scores)} stock-date combinations")
|
||||||
|
|
||||||
|
# ═══ Grid search ═══
|
||||||
|
results = []
|
||||||
|
total_combos = len(weight_combos) * len(stop_mults) * len(target_mults)
|
||||||
|
|
||||||
|
for i, (w_mom, w_vol, w_trd, w_label) in enumerate(weight_combos):
|
||||||
|
# Re-rank stocks with these weights
|
||||||
|
ranked = _rerank_stocks(stock_scores, w_mom, w_vol, w_trd)
|
||||||
|
|
||||||
|
for stop_m in stop_mults:
|
||||||
|
for tgt_m in target_mults:
|
||||||
|
# Test this parameter set
|
||||||
|
stats = _test_parameters(ranked, stop_m, tgt_m)
|
||||||
|
if stats["total"] >= 10:
|
||||||
|
stats["weights"] = w_label
|
||||||
|
stats["stop_atr"] = stop_m
|
||||||
|
stats["target_atr"] = tgt_m
|
||||||
|
stats["score"] = round(stats["win_rate"] * 0.6 + stats["avg_return"] * 100 * 0.4, 2)
|
||||||
|
results.append(stats)
|
||||||
|
|
||||||
|
if (len(results) + 1) % 20 == 0:
|
||||||
|
logger.info(f" Tested {len(results)}/{total_combos} combos...")
|
||||||
|
|
||||||
|
# Sort by composite score
|
||||||
|
results.sort(key=lambda r: r["score"], reverse=True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_historical_scores(test_dates: list[date]) -> dict:
|
||||||
|
"""Pre-compute raw stock scores (momentum/vol/trend) for each test date."""
|
||||||
|
conn = duckdb.connect("data/duckdb/ashare.db", read_only=True)
|
||||||
|
scores = {}
|
||||||
|
|
||||||
|
for td in test_dates:
|
||||||
|
try:
|
||||||
|
sql = f"""
|
||||||
|
WITH normalized AS (
|
||||||
|
SELECT ts_code, trade_date, trade_time, close, volume, amount, high, low
|
||||||
|
FROM read_parquet('data/parquet/kline_1d/**/*.parquet', hive_partitioning=true, union_by_name=true)
|
||||||
|
WHERE trade_date <= $td
|
||||||
|
),
|
||||||
|
with_ma AS (
|
||||||
|
SELECT *,
|
||||||
|
AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS ma20,
|
||||||
|
AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS ma60,
|
||||||
|
AVG(volume) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS vol_ma20,
|
||||||
|
(high - low) AS day_range,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time DESC) AS rn
|
||||||
|
FROM normalized
|
||||||
|
),
|
||||||
|
with_atr AS (
|
||||||
|
SELECT *, AVG(day_range) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS atr20
|
||||||
|
FROM with_ma
|
||||||
|
),
|
||||||
|
latest AS (
|
||||||
|
SELECT * FROM with_atr WHERE rn = 1
|
||||||
|
),
|
||||||
|
prev AS (
|
||||||
|
SELECT ts_code, close AS close_prev FROM with_ma WHERE rn = 6
|
||||||
|
)
|
||||||
|
SELECT l.ts_code, l.close, l.atr20, l.amount,
|
||||||
|
(l.close - p.close_prev) / NULLIF(p.close_prev, 0) AS ret_5d,
|
||||||
|
l.volume / NULLIF(l.vol_ma20, 0) AS vol_ratio,
|
||||||
|
CASE WHEN l.close > l.ma20 THEN 1 ELSE 0 END + CASE WHEN l.close > l.ma60 THEN 1 ELSE 0 END AS trend_score
|
||||||
|
FROM latest l LEFT JOIN prev p ON l.ts_code = p.ts_code
|
||||||
|
WHERE l.amount > 2e8
|
||||||
|
"""
|
||||||
|
df = conn.execute(sql, {"td": td.isoformat()}).fetchdf()
|
||||||
|
if not df.empty:
|
||||||
|
scores[td.isoformat()] = df
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
|
def _rerank_stocks(scores: dict, w_mom: float, w_vol: float, w_trd: float) -> dict:
|
||||||
|
"""Re-rank stocks with given weights."""
|
||||||
|
w_sum = w_mom + w_vol + w_trd
|
||||||
|
ranked = {}
|
||||||
|
|
||||||
|
for date_str, df in scores.items():
|
||||||
|
df = df.copy()
|
||||||
|
# Normalize each factor to 0-1 range
|
||||||
|
for col in ["ret_5d", "vol_ratio", "trend_score"]:
|
||||||
|
vals = df[col].dropna()
|
||||||
|
if len(vals) > 0 and vals.std() > 0:
|
||||||
|
df[col + "_norm"] = (df[col] - vals.min()) / (vals.max() - vals.min())
|
||||||
|
else:
|
||||||
|
df[col + "_norm"] = 0.5
|
||||||
|
|
||||||
|
df["composite"] = (
|
||||||
|
df["ret_5d_norm"].fillna(0) * w_mom / w_sum * 100
|
||||||
|
+ df["vol_ratio_norm"].fillna(0) * w_vol / w_sum * 100
|
||||||
|
+ df["trend_score_norm"].fillna(0) * w_trd / w_sum * 100
|
||||||
|
)
|
||||||
|
df = df.sort_values("composite", ascending=False)
|
||||||
|
ranked[date_str] = df.head(10) # top 10 per day
|
||||||
|
|
||||||
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
|
def _test_parameters(ranked: dict, stop_atr: float, target_atr: float) -> dict:
|
||||||
|
"""Test a parameter set against historical outcomes."""
|
||||||
|
conn = duckdb.connect("data/duckdb/ashare.db", read_only=True)
|
||||||
|
wins = 0
|
||||||
|
losses = 0
|
||||||
|
pending = 0
|
||||||
|
returns = []
|
||||||
|
|
||||||
|
for date_str, df in ranked.items():
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
entry = float(row["close"])
|
||||||
|
atr = float(row.get("atr20", entry * 0.03) or entry * 0.03)
|
||||||
|
ts = row["ts_code"]
|
||||||
|
|
||||||
|
stop = entry - stop_atr * atr
|
||||||
|
target = entry + target_atr * atr
|
||||||
|
|
||||||
|
# Check forward outcomes
|
||||||
|
try:
|
||||||
|
future = conn.execute(
|
||||||
|
"SELECT close FROM read_parquet('data/parquet/kline_1d/**/*.parquet', "
|
||||||
|
"hive_partitioning=true, union_by_name=true) "
|
||||||
|
"WHERE ts_code = ? AND trade_date > ? ORDER BY trade_date ASC",
|
||||||
|
[ts, date_str],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for (fc,) in future:
|
||||||
|
fc = float(fc)
|
||||||
|
if fc >= target:
|
||||||
|
wins += 1
|
||||||
|
returns.append((target - entry) / entry)
|
||||||
|
break
|
||||||
|
elif fc <= stop:
|
||||||
|
losses += 1
|
||||||
|
returns.append((stop - entry) / entry)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pending += 1
|
||||||
|
except Exception:
|
||||||
|
pending += 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
total = wins + losses
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"pending": pending,
|
||||||
|
"win_rate": round(wins / total * 100, 1) if total > 0 else 0,
|
||||||
|
"avg_return": round(sum(returns) / len(returns) * 100, 2) if returns else 0,
|
||||||
|
"avg_win": round(sum(r for r in returns if r > 0) / max(1, sum(1 for r in returns if r > 0)) * 100, 2),
|
||||||
|
"avg_loss": round(sum(r for r in returns if r < 0) / max(1, sum(1 for r in returns if r < 0)) * 100, 2),
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ def get_recommendations(
|
|||||||
WHERE l.amount > $min_amt
|
WHERE l.amount > $min_amt
|
||||||
)
|
)
|
||||||
SELECT *,
|
SELECT *,
|
||||||
(COALESCE(ret_5d, 0) * 40 + LEAST(vol_ratio, 3.0) / 3.0 * 30 + trend_score * 15) AS composite
|
(COALESCE(ret_5d, 0) * 50 + LEAST(vol_ratio, 3.0) / 3.0 * 25 + trend_score * 12.5) AS composite
|
||||||
FROM scored
|
FROM scored
|
||||||
ORDER BY composite DESC
|
ORDER BY composite DESC
|
||||||
LIMIT 4
|
LIMIT 4
|
||||||
@@ -165,13 +165,13 @@ def get_recommendations(
|
|||||||
atr_raw = row.get("atr20")
|
atr_raw = row.get("atr20")
|
||||||
atr = float(atr_raw) if atr_raw and not (isinstance(atr_raw, float) and math.isnan(atr_raw)) else entry * 0.03
|
atr = float(atr_raw) if atr_raw and not (isinstance(atr_raw, float) and math.isnan(atr_raw)) else entry * 0.03
|
||||||
|
|
||||||
# Stop: MA20 or 2 ATR below entry
|
# Stop: 2.5 ATR below entry (optimal from grid search)
|
||||||
ma20 = float(row["ma20"] or entry)
|
ma20 = float(row["ma20"] or entry)
|
||||||
stop = round(min(ma20 * 0.97, entry - 2 * atr), 2)
|
stop = round(min(ma20 * 0.97, entry - 2.5 * atr), 2)
|
||||||
|
|
||||||
# Targets
|
# Targets: 3.0 ATR (optimal), secondary 4.0 ATR
|
||||||
target1 = round(entry + 1.5 * atr, 2)
|
target1 = round(entry + 3.0 * atr, 2)
|
||||||
target2 = round(entry + 3.0 * atr, 2)
|
target2 = round(entry + 4.0 * atr, 2)
|
||||||
|
|
||||||
# Risk/reward
|
# Risk/reward
|
||||||
risk = entry - stop
|
risk = entry - stop
|
||||||
|
|||||||
Reference in New Issue
Block a user