feat(盯盘): 推荐操作精选层——72h新鲜度+RR降序+现金预算贪心装入(上限5只)
老爸: 太多推荐=没有推荐。机制: - 候选: tag非空 且 reassessed_at 72h内(陈旧信号不算推荐) - 排序: rr_ratio 降序 - 预算: 从 portfolio_summary 读现金/总资产, 每只建议仓位 (position_advice解析%或默认8%) 贪心累加, 耗尽即止, 最多5只 - 落选者降回持仓/自选自然组 - /api/watch 返回 cash/budget_pct/rec_used_pct, 前端显示预算条 - 推荐行仓位列显示'建议X%'
This commit is contained in:
@@ -191,6 +191,57 @@ def get_watch():
|
||||
|
||||
results.append(d)
|
||||
|
||||
# ── 推荐操作精选层(2026-07-21 老爸:太多推荐=没有推荐)──
|
||||
# 候选 = tag 非空 且 72h 内有新鲜重评;按 RR 降序;贪心装入现金预算;最多 5 只。
|
||||
# 落选者降回其自然分组(持仓/自选)。
|
||||
import re as _re
|
||||
from datetime import datetime as _dt, timedelta as _td
|
||||
conn2 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db")
|
||||
try:
|
||||
_pr = conn2.execute("SELECT cash, total_assets FROM portfolio_summary WHERE id=1").fetchone()
|
||||
_cash = float(_pr[0] or 0)
|
||||
_total = float(_pr[1] or 0)
|
||||
finally:
|
||||
conn2.close()
|
||||
_budget_pct = (_cash / _total * 100) if _total > 0 else 0
|
||||
_fresh_cutoff = _dt.now() - _td(hours=72)
|
||||
|
||||
def _is_fresh(d):
|
||||
ra = d.get('reassessed_at') or ''
|
||||
if not ra:
|
||||
return False
|
||||
try:
|
||||
return _dt.fromisoformat(str(ra)[:19]) >= _fresh_cutoff
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _sugg_pct(d):
|
||||
# 从 position_advice 解析百分比(如 "8%(理由...)"),失败默认 8%
|
||||
m = _re.search(r'(\d+(?:\.\d+)?)\s*%', d.get('position_advice') or '')
|
||||
if m:
|
||||
try:
|
||||
v = float(m.group(1))
|
||||
if 0 < v <= 30:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return 8.0
|
||||
|
||||
_cands = [d for d in results if d['sort_group'] == 0 and _is_fresh(d)]
|
||||
_cands.sort(key=lambda x: x.get('rr_ratio') or 0, reverse=True)
|
||||
_selected, _cum = [], 0.0
|
||||
for d in _cands:
|
||||
pct = _sugg_pct(d)
|
||||
if len(_selected) < 5 and _cum + pct <= _budget_pct + 1e-9:
|
||||
d['suggested_position_pct'] = pct
|
||||
_cum += pct
|
||||
_selected.append(d)
|
||||
_sel_codes = {d['code'] for d in _selected}
|
||||
for d in results:
|
||||
if d['sort_group'] == 0 and d['code'] not in _sel_codes:
|
||||
# 落选:降回自然分组
|
||||
d['sort_group'] = 1 if d['decision_type'] == '持仓策略' else 2
|
||||
|
||||
# 排序:group → signal_rank → group-internal (持仓按position_pct desc, 自选按rr desc)
|
||||
def skey(x):
|
||||
g = x['sort_group']
|
||||
@@ -207,7 +258,10 @@ def get_watch():
|
||||
d.pop('_pos', None)
|
||||
d.pop('_rr', None)
|
||||
|
||||
return json.dumps({"stocks": results, "count": len(results)}, ensure_ascii=False)
|
||||
return json.dumps({"stocks": results, "count": len(results),
|
||||
"cash": _cash, "total_assets": _total,
|
||||
"budget_pct": round(_budget_pct, 2),
|
||||
"rec_used_pct": round(_cum, 2)}, ensure_ascii=False)
|
||||
|
||||
|
||||
@app.route("/api/strategy_history/<code>")
|
||||
|
||||
+7
-2
@@ -1434,7 +1434,9 @@ async function renderWatch() {
|
||||
|
||||
const buyZone = (el_ && eh_) ? el_.toFixed(1) + '~' + eh_.toFixed(1) : '—';
|
||||
const sltp = (sl_ || tp_) ? '损' + (sl_||'—') + '/盈' + (tp_||'—') : '—';
|
||||
const posDisplay = posPct ? posPct.toFixed(1) + '%' : '—';
|
||||
const posDisplay = (isRec && s.suggested_position_pct)
|
||||
? '建议' + s.suggested_position_pct + '%'
|
||||
: (posPct ? posPct.toFixed(1) + '%' : '—');
|
||||
|
||||
// 当前操作策略摘要
|
||||
const strat = fmtStrategy(s);
|
||||
@@ -1462,8 +1464,11 @@ async function renderWatch() {
|
||||
}
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
// 更新时间
|
||||
// 更新时间 + 推荐预算
|
||||
html += '<div class="text-xs text-slate-600 mt-1">' + (data.updated_at ? '数据: ' + data.updated_at : '') + '</div>';
|
||||
if (data.budget_pct !== undefined) {
|
||||
html += '<div class="text-xs text-amber-500/70 mt-1">💰 现金 ' + (data.cash/10000).toFixed(1) + '万 / 总资产 ' + (data.total_assets/10000).toFixed(1) + '万 — 推荐操作预算 ' + data.budget_pct + '%(已用 ' + data.rec_used_pct + '%)</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
// 自动刷新:每30秒
|
||||
|
||||
Reference in New Issue
Block a user