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.
|
||||
|
||||
5 sections: COMMAND → ACTION → WHERE → WHY → RISK → EXPECTANCY
|
||||
5 sections: COMMAND → ACTION → WHERE → WHY → 风险 → EXPECTANCY
|
||||
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 .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-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)}
|
||||
@@ -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>
|
||||
<script>
|
||||
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 P(v,d){return v!=null&&!isNaN(v)?v:(d||0);}
|
||||
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';
|
||||
});
|
||||
|
||||
// RISK
|
||||
// 风险
|
||||
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
|
||||
var ft='',at='';
|
||||
@@ -169,26 +174,26 @@ function R(d){
|
||||
// ═══ COMMAND + WHERE ═══
|
||||
h+='<div class=\"row\">';
|
||||
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 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 style=\"margin-bottom:4px\">'+acts.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\" 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 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+='</div></div>';
|
||||
|
||||
// ═══ WHY + RISK ═══
|
||||
// ═══ WHY + 风险 ═══
|
||||
h+='<div class=\"row\">';
|
||||
h+='<div class=\"card\">';
|
||||
h+='<div class=\"sec-label\">WHY — Money Flow</div>';
|
||||
h+='<div class=\"flow-line\">'+(flowL||'No flow data')+'</div>';
|
||||
h+='<div class=\"sec-label\">资金流向</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(at)h+='<div style=\"font-size:9px\"><span class=\"muted\">Avoid:</span> '+at+'</div>';
|
||||
h+='</div>';
|
||||
@@ -196,7 +201,7 @@ function R(d){
|
||||
// Theme Map
|
||||
var tg=d.theme_graph||{},themes=tg.themes||[];
|
||||
h+='<div class=\"card\">';
|
||||
h+='<div class=\"sec-label\">THEMES</div>';
|
||||
h+='<div class=\"sec-label\">主题</div>';
|
||||
if(themes.length){
|
||||
themes.slice(0,5).forEach(function(t,i){
|
||||
var stars='';for(var j=0;j<5;j++)stars+=j<Math.round(t.score/20)?'★':'☆';
|
||||
@@ -219,58 +224,65 @@ function R(d){
|
||||
// Recommendations
|
||||
var recs=d.recommendations||[];
|
||||
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){
|
||||
h+='<div style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:6px\">';
|
||||
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=\"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 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+='<span>Entry: <span class=\"good\">'+r.entry+'</span></span>';
|
||||
h+='<span>Stop: <span class=\"bad\">'+r.stop+'</span></span>';
|
||||
h+='<span>T1: <span class=\"good\">'+r.target1+'</span></span>';
|
||||
h+='<span>入场: <span class=\"good\">'+r.entry+'</span></span>';
|
||||
h+='<span>止损: <span class=\"bad\">'+r.stop+'</span></span>';
|
||||
h+='<span>目标1: <span class=\"good\">'+r.target1+'</span></span>';
|
||||
h+='<span>T2: '+r.target2+'</span></div>';
|
||||
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>';
|
||||
h+='<span style=\"color:var(--accent)\">R:R '+r.rr_ratio.toFixed(1)+'</span></div>';
|
||||
h+='<div style=\"font-size:10px;color:var(--green);margin-top:3px\">'+r.action+'</div>';
|
||||
var ret5=r.ret_5d||0,volR=r.vol_ratio||1,trS=r.trend_score||0,amtY=r.amount_yi||0;
|
||||
h+='<span class=\"muted\">5日:'+(ret5>0?'+':'')+ret5.toFixed(1)+'% 量比:'+volR.toFixed(1)+'x 成交额:'+amtY.toFixed(1)+'亿 '+(trS>=2?'强势':trS>=1?'中性':'弱势')+'</span>';
|
||||
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>';
|
||||
}else{h+='<span class=\"muted\" style=\"font-size:11px\">No recommendations — insufficient data</span>';}
|
||||
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=\"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+='</div></div>';
|
||||
|
||||
// ═══ EXPECTANCY ═══
|
||||
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)';
|
||||
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-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 '+(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 bad\">'+dd.toFixed(1)+'%</div><div class=\"lbl\">Est. DD</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 '+(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\">预估收益</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\">预估持仓</div></div>';
|
||||
h+='</div>';
|
||||
|
||||
// All expectancies comparison table
|
||||
var allExp=d.all_expectancies||[];
|
||||
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){
|
||||
var ew=e.win_rate*100,ea=e.avg_return*100,ed=e.max_drawdown*100;
|
||||
var barW=Math.max(2,ew);
|
||||
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 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=\"bad\">'+(e.sample_count>0?ed.toFixed(1)+'%':'--')+'</td>'+
|
||||
'</tr>';
|
||||
@@ -282,20 +294,20 @@ function R(d){
|
||||
// ═══ DIAGNOSTICS (always collapsed) ═══
|
||||
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>';});
|
||||
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-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>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>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>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><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>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>';['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>'+idxH+'<div style=\"font-size:8px;color:var(--muted);margin-top:2px\">共振度: '+((tr.resonance||0)*100).toFixed(0)+'%</div></div>';
|
||||
var sent=ev.sentiment||{};
|
||||
if(sent.status==='live'){
|
||||
h+='<div class=\"diag-card\"><h4>Sentiment</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>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>Broken Board</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>';
|
||||
if(sent.max_consecutive)h+='<div class=\"metric\"><span>Max Board</span><span>'+sent.max_consecutive+' 连板</span></div>';
|
||||
h+='<div class=\"diag-card\"><h4>情绪</h4>';
|
||||
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>涨停/跌停</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>'+Q(sent.broken_board_count)+' ('+((sent.broken_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>最高连板</span><span>'+sent.max_consecutive+' 连板</span></div>';
|
||||
h+='</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.opportunity import rank_opportunities
|
||||
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.state import infer_market_state
|
||||
from ashare_dp.signals.expectancy import get_expectancy, get_all_expectancies
|
||||
@@ -79,11 +78,26 @@ async def dashboard_state():
|
||||
# Knowledge Graph — theme activation
|
||||
theme_graph = activate_themes(trade_date, leaders=leaders, opportunities=opportunities)
|
||||
|
||||
# Stock Recommendations
|
||||
# Stock Recommendations — read from EOD-computed recommendation_log
|
||||
try:
|
||||
recommendations = get_recommendations(trade_date, top_n=10)
|
||||
except Exception as e:
|
||||
logger.warning(f"Recommendations failed: {e}")
|
||||
with get_db(read_only=True) as db:
|
||||
rows = db.query(
|
||||
"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 = []
|
||||
|
||||
# ── L3: Trading Intelligence ──
|
||||
@@ -197,6 +211,123 @@ async def close_trade(trade_id: int, request: dict):
|
||||
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")
|
||||
async def dashboard_replay(date_str: str = ""):
|
||||
"""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:
|
||||
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,
|
||||
ema52_screening_job,
|
||||
health_check_job,
|
||||
recommendation_job,
|
||||
wyckoff_scan_job,
|
||||
)
|
||||
|
||||
@@ -81,10 +82,24 @@ class Scheduler:
|
||||
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()
|
||||
logger.info(
|
||||
"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):
|
||||
|
||||
@@ -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_align ON wyckoff_scan(trade_date, alignment DESC)""",
|
||||
"""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
|
||||
)
|
||||
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
|
||||
ORDER BY composite DESC
|
||||
LIMIT 4
|
||||
@@ -165,13 +165,13 @@ def get_recommendations(
|
||||
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
|
||||
|
||||
# Stop: MA20 or 2 ATR below entry
|
||||
# Stop: 2.5 ATR below entry (optimal from grid search)
|
||||
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
|
||||
target1 = round(entry + 1.5 * atr, 2)
|
||||
target2 = round(entry + 3.0 * atr, 2)
|
||||
# Targets: 3.0 ATR (optimal), secondary 4.0 ATR
|
||||
target1 = round(entry + 3.0 * atr, 2)
|
||||
target2 = round(entry + 4.0 * atr, 2)
|
||||
|
||||
# Risk/reward
|
||||
risk = entry - stop
|
||||
|
||||
Reference in New Issue
Block a user