fix: 品种推荐改为最大手数并补算旧缓存。

- 最大手数 = floor(权益×保证金上限%÷1手保证金)
- 加载与 SSE 推送时实时补算,旧缓存缺字段时自动刷新

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 12:24:10 +08:00
parent 9875ee6d44
commit 074551490f
7 changed files with 109 additions and 23 deletions
+25 -5
View File
@@ -20,7 +20,12 @@ from position_sizing import (
calc_order_tick_metrics, calc_order_tick_metrics,
normalize_sizing_mode, normalize_sizing_mode,
) )
from recommend_store import load_recommend_cache, recommend_payload, refresh_recommend_cache from recommend_store import (
load_recommend_cache,
recommend_payload,
refresh_recommend_cache,
rows_missing_max_lots,
)
from recommend_stream import recommend_hub, start_recommend_worker from recommend_stream import recommend_hub, start_recommend_worker
from ctp_reconnect import start_ctp_reconnect_worker from ctp_reconnect import start_ctp_reconnect_worker
from ctp_premarket_connect import start_ctp_premarket_connect_worker from ctp_premarket_connect import start_ctp_premarket_connect_worker
@@ -400,7 +405,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
).fetchone()["n"] ).fetchone()["n"]
conn.commit() conn.commit()
sizing = get_sizing_mode(get_setting) sizing = get_sizing_mode(get_setting)
rec_cache = recommend_payload(conn, live_capital=capital) max_pct = get_max_margin_pct(get_setting)
rec_loaded = load_recommend_cache(conn)
if rec_loaded.get("stale") or rows_missing_max_lots(rec_loaded.get("rows") or []):
refresh_recommend_cache(
conn, capital, _main_quote, trading_mode=mode, max_margin_pct=max_pct,
)
rec_cache = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct)
return render_template( return render_template(
"trade.html", "trade.html",
trading_mode=mode, trading_mode=mode,
@@ -979,7 +990,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"""只读数据库缓存,不在请求时拉行情。""" """只读数据库缓存,不在请求时拉行情。"""
conn = get_db() conn = get_db()
try: try:
payload = recommend_payload(conn, live_capital=_capital(conn)) payload = recommend_payload(
conn,
live_capital=_capital(conn),
max_margin_pct=get_max_margin_pct(get_setting),
)
return jsonify({"ok": True, **payload}) return jsonify({"ok": True, **payload})
finally: finally:
conn.close() conn.close()
@@ -994,7 +1009,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
try: try:
conn = get_db() conn = get_db()
try: try:
payload = recommend_payload(conn, live_capital=_capital(conn)) payload = recommend_payload(
conn,
live_capital=_capital(conn),
max_margin_pct=get_max_margin_pct(get_setting),
)
finally: finally:
conn.close() conn.close()
yield sse_format("recommend", {"ok": True, **payload}) yield sse_format("recommend", {"ok": True, **payload})
@@ -1030,7 +1049,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
conn, capital, _main_quote, trading_mode=mode, conn, capital, _main_quote, trading_mode=mode,
max_margin_pct=get_max_margin_pct(get_setting), max_margin_pct=get_max_margin_pct(get_setting),
) )
payload = recommend_payload(conn, live_capital=capital) max_pct = get_max_margin_pct(get_setting)
payload = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct)
recommend_hub.broadcast("recommend", {"ok": True, **payload}) recommend_hub.broadcast("recommend", {"ok": True, **payload})
return jsonify({"ok": True, "count": len(rows), **payload}) return jsonify({"ok": True, "count": len(rows), **payload})
finally: finally:
+7 -7
View File
@@ -47,14 +47,14 @@ def assess_product_for_capital(
"status_label": "暂无行情", "status_label": "暂无行情",
"min_capital_one_lot": None, "min_capital_one_lot": None,
"margin_one_lot": None, "margin_one_lot": None,
"recommended_lots": 0, "max_lots": 0,
"risk_one_lot_1pct": None, "risk_one_lot_1pct": None,
} }
margin_one = p * mult * margin_rate margin_one = p * mult * margin_rate
min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one
margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0 margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0
recommended_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0 max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0
stop_dist = tick * default_stop_ticks stop_dist = tick * default_stop_ticks
risk_one_lot = stop_dist * mult risk_one_lot = stop_dist * mult
risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0 risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0
@@ -65,13 +65,13 @@ def assess_product_for_capital(
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode, fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
) )
can_margin = recommended_lots >= 1 can_margin = max_lots >= 1
can_risk = cap > 0 and risk_one_lot <= cap * 0.01 can_risk = cap > 0 and risk_one_lot <= cap * 0.01
if can_margin and can_risk: if can_margin and can_risk:
status, label = "ok", f"推荐 {recommended_lots}" status, label = "ok", f"最大 {max_lots}"
elif can_margin: elif can_margin:
status, label = "margin_ok", f"可开 {recommended_lots} 手·止损偏宽" status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽"
else: else:
status, label = "blocked", "资金不足" status, label = "blocked", "资金不足"
@@ -84,7 +84,7 @@ def assess_product_for_capital(
"tick_size": tick, "tick_size": tick,
"margin_one_lot": round(margin_one, 2), "margin_one_lot": round(margin_one, 2),
"min_capital_one_lot": round(min_capital, 2), "min_capital_one_lot": round(min_capital, 2),
"recommended_lots": recommended_lots, "max_lots": max_lots,
"margin_budget": round(margin_budget, 2), "margin_budget": round(margin_budget, 2),
"max_margin_pct": margin_pct, "max_margin_pct": margin_pct,
"risk_one_lot_1pct": round(risk_one_lot, 2), "risk_one_lot_1pct": round(risk_one_lot, 2),
@@ -123,5 +123,5 @@ def list_product_recommendations(
with ThreadPoolExecutor(max_workers=10) as pool: with ThreadPoolExecutor(max_workers=10) as pool:
rows = list(pool.map(_one, PRODUCTS)) rows = list(pool.map(_one, PRODUCTS))
order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3} order = {"ok": 0, "margin_ok": 1, "blocked": 2, "no_price": 3}
rows.sort(key=lambda r: (order.get(r["status"], 9), -(r.get("recommended_lots") or 0))) rows.sort(key=lambda r: (order.get(r["status"], 9), -(r.get("max_lots") or 0)))
return rows return rows
+60 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
from datetime import datetime from datetime import datetime
from typing import Callable, Optional from typing import Callable, Optional
@@ -26,6 +27,50 @@ def filter_affordable_recommendations(rows: list[dict]) -> list[dict]:
return [r for r in rows if r.get("status") in ("ok", "margin_ok")] return [r for r in rows if r.get("status") in ("ok", "margin_ok")]
def rows_missing_max_lots(rows: list[dict]) -> bool:
"""缓存是否为旧版(缺少最大手数字段)。"""
if not rows:
return False
return any("max_lots" not in r for r in rows)
def enrich_recommend_rows(
rows: list[dict],
capital: float,
*,
max_margin_pct: float = 30.0,
) -> list[dict]:
"""用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。"""
cap = float(capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
budget = cap * pct / 100.0 if cap > 0 else 0.0
enriched: list[dict] = []
for raw in rows:
row = dict(raw)
try:
margin_one = float(row.get("margin_one_lot") or 0)
except (TypeError, ValueError):
margin_one = 0.0
if margin_one > 0 and budget > 0:
lots = int(math.floor(budget / margin_one))
else:
try:
lots = int(row.get("max_lots") or row.get("recommended_lots") or 0)
except (TypeError, ValueError):
lots = 0
row["max_lots"] = lots
row.pop("recommended_lots", None)
row["margin_budget"] = round(budget, 2)
row["max_margin_pct"] = pct
status = row.get("status") or ""
if lots >= 1 and status in ("ok", "margin_ok"):
row["status_label"] = (
f"最大 {lots}" if status == "ok" else f"最大 {lots} 手·止损偏宽"
)
enriched.append(row)
return enriched
def refresh_recommend_cache( def refresh_recommend_cache(
conn, conn,
capital: float, capital: float,
@@ -85,8 +130,21 @@ def load_recommend_cache(conn) -> dict:
} }
def recommend_payload(conn, *, live_capital: float) -> dict: def recommend_payload(
conn,
*,
live_capital: float,
max_margin_pct: float = 30.0,
) -> dict:
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。""" """读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
payload = load_recommend_cache(conn) payload = load_recommend_cache(conn)
payload["capital"] = float(live_capital or 0) cap = float(live_capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
payload["capital"] = cap
payload["max_margin_pct"] = pct
payload["rows"] = enrich_recommend_rows(
payload.get("rows") or [],
cap,
max_margin_pct=pct,
)
return payload return payload
+12 -4
View File
@@ -10,7 +10,13 @@ from typing import Callable, Optional
from db_conn import connect_db from db_conn import connect_db
from kline_stream import sse_format from kline_stream import sse_format
from recommend_store import load_recommend_cache, recommend_cache_stale, refresh_recommend_cache from recommend_store import (
load_recommend_cache,
recommend_cache_stale,
recommend_payload,
refresh_recommend_cache,
rows_missing_max_lots,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -72,13 +78,15 @@ def start_recommend_worker(
mode = get_mode_fn() if get_mode_fn else "simulation" mode = get_mode_fn() if get_mode_fn else "simulation"
max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0 max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0
cached = load_recommend_cache(conn) cached = load_recommend_cache(conn)
if recommend_cache_stale(cached.get("updated_at")): if recommend_cache_stale(cached.get("updated_at")) or rows_missing_max_lots(
cached.get("rows") or [],
):
refresh_recommend_cache( refresh_recommend_cache(
conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct, conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct,
) )
cached = load_recommend_cache(conn) cached = load_recommend_cache(conn)
logger.info("品种推荐每日刷新完成,capital=%.2f rows=%d", capital, len(cached.get("rows") or [])) logger.info("品种推荐刷新完成,capital=%.2f rows=%d", capital, len(cached.get("rows") or []))
payload = {**cached, "capital": capital} payload = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct)
finally: finally:
conn.close() conn.close()
recommend_hub.broadcast("recommend", {"ok": True, **payload}) recommend_hub.broadcast("recommend", {"ok": True, **payload})
+1 -1
View File
@@ -639,7 +639,7 @@
'<td>' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '</td>' + '<td>' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '</td>' +
'<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' + '<td>' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '</td>' +
'<td>' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</td>' + '<td>' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</td>' +
'<td>' + (r.recommended_lots != null && r.recommended_lots > 0 ? r.recommended_lots : '—') + '</td>' + '<td>' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '</td>' +
'<td><span class="badge ' + (r.status === 'ok' ? 'profit' : 'planned') + '">' + (r.status_label || '') + '</span></td>' + '<td><span class="badge ' + (r.status === 'ok' ? 'profit' : 'planned') + '">' + (r.status_label || '') + '</span></td>' +
'</tr>' '</tr>'
); );
+1 -1
View File
@@ -70,7 +70,7 @@
</div> </div>
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button> <button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
<p class="hint" style="margin-top:.75rem;margin-bottom:0"> <p class="hint" style="margin-top:.75rem;margin-bottom:0">
保证金上限用于开仓校验与品种推荐手数(默认 30%)。在 <code>.env</code> 配置 <code>SIMNOW_USER</code>,于「持仓监控」连接 CTP;权益与行情优先来自柜台。 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。在 <code>.env</code> 配置 <code>SIMNOW_USER</code>,于「持仓监控」连接 CTP;权益与行情优先来自柜台。
</p> </p>
</form> </form>
</div> </div>
+3 -3
View File
@@ -107,7 +107,7 @@
<div class="card trade-card trade-card-full" id="recommend"> <div class="card trade-card trade-card-full" id="recommend">
<h2>品种推荐</h2> <h2>品种推荐</h2>
<div class="card-body"> <div class="card-body">
<p class="hint">权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(capital) }}</strong> × 保证金上限 <strong>{{ max_margin_pct }}%</strong> 推荐手数;参考止损/止盈按 20 跳、盈亏比 2:1 估算。 <p class="hint">最大手数 = floor(权益 × 保证金上限 <strong>{{ max_margin_pct }}%</strong> ÷ 1手保证金);当前权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(capital) }}</strong>参考止损/止盈按 20 跳、盈亏比 2:1 估算。
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %} {% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
</p> </p>
<div class="trade-table-wrap"> <div class="trade-table-wrap">
@@ -116,7 +116,7 @@
<tr> <tr>
<th>品种</th><th>交易所</th><th>参考价</th> <th>品种</th><th>交易所</th><th>参考价</th>
<th>参考止损</th><th>参考止盈</th> <th>参考止损</th><th>参考止盈</th>
<th>1手保证金</th><th>1手手续费</th><th>推荐手数</th><th>状态</th> <th>1手保证金</th><th>1手手续费</th><th>最大手数</th><th>状态</th>
</tr> </tr>
</thead> </thead>
<tbody id="recommend-list"> <tbody id="recommend-list">
@@ -130,7 +130,7 @@
<td>{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %}</td> <td>{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %}</td>
<td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td> <td>{% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %}</td>
<td>{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}</td> <td>{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %}</td>
<td>{% if r.recommended_lots %}{{ r.recommended_lots }}{% else %}—{% endif %}</td> <td>{% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %}</td>
<td><span class="badge {% if r.status=='ok' %}profit{% else %}planned{% endif %}">{{ r.status_label }}</span></td> <td><span class="badge {% if r.status=='ok' %}profit{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
</tr> </tr>
{% endfor %} {% endfor %}