feat: 推荐选股落库追踪、参数优化与 Dashboard 中文化

增加 recommendation_log 与定时任务,按网格搜索结果收紧止损/目标 ATR,并补齐绩效核验接口与界面本地化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 15:06:27 +08:00
co-authored by Cursor
parent 03851d2247
commit 9ceee1ef16
7 changed files with 496 additions and 52 deletions
+52 -40
View File
@@ -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)+' &nbsp;Hlth '+(trend*100).toFixed(0)+' &nbsp;Fear '+(fear*100).toFixed(0)+' &nbsp;Liq '+(liq*100).toFixed(0)+' &nbsp;'+Q(pb.bias)+' &nbsp;C'+cashP+'/T'+trendP+'/T20</span></div>';
h+='<div class=\"cmd-meta\" style=\"border-top:none;padding-top:2px\"><span>共识 '+(conf*100).toFixed(0)+' &nbsp;健康 '+(trend*100).toFixed(0)+' &nbsp;恐惧 '+(fear*100).toFixed(0)+' &nbsp;流动 '+(liq*100).toFixed(0)+' &nbsp;'+Q(pb.bias)+' &nbsp;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>';
+136 -5
View File
@@ -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)."""
+46
View File
@@ -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")
+16 -1
View File
@@ -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):
+18
View File
@@ -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)""",
]
+222
View File
@@ -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),
}
+6 -6
View File
@@ -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