Deduct open position margin from recommend max lots.

Recalculate tradable symbol budgets from remaining margin after CTP usage and refresh the table on position updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-29 09:39:42 +08:00
parent fd2dba22fd
commit 71c480a587
5 changed files with 96 additions and 8 deletions
+6
View File
@@ -1578,6 +1578,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
try:
payload = _refresh_trading_live_snapshot(fast=fast)
position_hub.broadcast("positions", payload)
conn = get_db()
try:
rec = _recommend_payload(conn)
recommend_hub.broadcast("recommend", {"ok": True, **rec})
finally:
conn.close()
except Exception as exc:
logger.debug("push position snapshot: %s", exc)
+4
View File
@@ -163,6 +163,7 @@ def assess_product_for_capital(
trading_mode: str = "simulation",
ctp_connected: bool = True,
main_code: str = "",
margin_used: float = 0.0,
) -> dict:
"""评估单品种在当前资金下是否可交易。"""
ths = product.get("ths") or ""
@@ -225,6 +226,7 @@ def assess_product_for_capital(
margin_one = p * mult * margin_rate
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 = max(0.0, margin_budget - max(0.0, float(margin_used or 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
risk_one_lot = stop_dist * mult
@@ -287,6 +289,7 @@ def list_product_recommendations(
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
ctp_connected: bool = True,
margin_used: float = 0.0,
) -> list[dict]:
"""扫描全部品种并排序:可开且纪律友好 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}"""
@@ -302,6 +305,7 @@ def list_product_recommendations(
trading_mode=trading_mode,
ctp_connected=ctp_connected,
main_code=main_code,
margin_used=margin_used,
)
row["main_code"] = main_code
if main_code:
+61 -3
View File
@@ -117,17 +117,61 @@ def _ctp_connected_for_mode(trading_mode: str) -> bool:
return False
def recommend_margin_used(trading_mode: str) -> float:
"""当前持仓已占用保证金(CTP 柜台优先)。"""
if not _ctp_connected_for_mode(trading_mode):
return 0.0
try:
from vnpy_bridge import ctp_account_margin_used, ctp_list_positions
used = ctp_account_margin_used(trading_mode)
if used is not None and used > 0:
return float(used)
total = 0.0
for p in ctp_list_positions(
trading_mode, refresh_if_empty=False, refresh_margin=True,
):
m = float(p.get("margin") or 0)
if m > 0:
total += m
return round(total, 2) if total > 0 else 0.0
except Exception as exc:
logger.debug("recommend_margin_used: %s", exc)
return 0.0
def margin_budget_info(
capital: float,
max_margin_pct: float,
margin_used: float = 0.0,
) -> dict[str, float]:
"""保证金上限总额、已占用、剩余可开额度。"""
cap = float(capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
total = cap * pct / 100.0 if cap > 0 else 0.0
used = max(0.0, float(margin_used or 0))
remaining = max(0.0, total - used)
return {
"margin_budget_total": round(total, 2),
"margin_used": round(used, 2),
"margin_budget_remaining": round(remaining, 2),
"max_margin_pct": pct,
}
def enrich_recommend_rows(
rows: list[dict],
capital: float,
*,
max_margin_pct: float = 30.0,
trading_mode: str = "simulation",
margin_used: float = 0.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
budget_info = margin_budget_info(cap, max_margin_pct, margin_used)
pct = budget_info["max_margin_pct"]
budget = budget_info["margin_budget_remaining"]
ctp_connected = _ctp_connected_for_mode(trading_mode)
enriched: list[dict] = []
for raw in rows:
@@ -172,6 +216,8 @@ def enrich_recommend_rows(
row["max_lots"] = lots
row.pop("recommended_lots", None)
row["margin_budget"] = round(budget, 2)
row["margin_budget_total"] = budget_info["margin_budget_total"]
row["margin_used"] = budget_info["margin_used"]
row["max_margin_pct"] = pct
status = row.get("status") or ""
if lots >= 1 and status in ("ok", "margin_ok"):
@@ -181,6 +227,8 @@ def enrich_recommend_rows(
)
if row.get("margin_source") == "ctp":
row["status_label"] += f"{src}保证金)"
if budget_info["margin_used"] > 0:
row["status_label"] += "·扣持仓"
elif lots < 1 and status in ("ok", "margin_ok"):
row["status"] = "blocked"
row["status_label"] = "资金不足"
@@ -214,17 +262,24 @@ def refresh_recommend_cache(
*,
trading_mode: str = "simulation",
max_margin_pct: float = 30.0,
margin_used: float | None = None,
) -> list[dict]:
"""后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn)
ensure_fee_rates_schema(conn)
ctp_connected = _ctp_connected_for_mode(trading_mode)
used = (
float(margin_used)
if margin_used is not None
else recommend_margin_used(trading_mode)
)
all_rows = list_product_recommendations(
capital,
quote_fn,
max_margin_pct=max_margin_pct,
trading_mode=trading_mode,
ctp_connected=ctp_connected,
margin_used=used,
)
rows = filter_affordable_recommendations(all_rows)
if not rows and float(capital or 0) > 0:
@@ -293,11 +348,14 @@ def recommend_payload(
payload = load_recommend_cache(conn)
cap = float(live_capital or 0)
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
used = recommend_margin_used(trading_mode)
budget_info = margin_budget_info(cap, pct, used)
payload["capital"] = cap
payload["max_margin_pct"] = pct
payload.update(budget_info)
rows = payload.get("rows") or []
rows = enrich_recommend_rows(
rows, cap, max_margin_pct=pct, trading_mode=trading_mode,
rows, cap, max_margin_pct=pct, trading_mode=trading_mode, margin_used=used,
)
rows = filter_rows_for_account_scope(
rows, cap, ctp_connected=_ctp_connected_for_mode(trading_mode),
+24 -4
View File
@@ -38,6 +38,7 @@
var recommendMaxByProduct = {};
var recommendMaxByCode = {};
var recRowsRaw = [];
var recMeta = {};
var recSortKey = 'trend';
var recSortDesc = true;
var recIndustryFilter = '';
@@ -1454,7 +1455,7 @@
return counts;
}
function updateRecStats(allRows, visibleRows) {
function updateRecStats(allRows, visibleRows, meta) {
var el = document.getElementById('rec-stats');
if (!el) return;
var total = (allRows || []).length;
@@ -1464,6 +1465,12 @@
return;
}
var parts = [];
if (meta && meta.margin_used > 0) {
parts.push(
'持仓占用 <strong>' + fmtNum(meta.margin_used) + '</strong> 元,' +
'剩余额度 <strong class="text-accent">' + fmtNum(meta.margin_budget_remaining) + '</strong> 元'
);
}
if (recIndustryFilter) {
parts.push('筛选 <strong>' + shown + '</strong> / 共 ' + total + ' 个品种');
} else {
@@ -1641,15 +1648,28 @@
function renderRecommendTable() {
var filtered = filterRecommendRows(recRowsRaw);
var sorted = sortRecommendRows(filtered);
updateRecStats(recRowsRaw, sorted);
updateRecStats(recRowsRaw, sorted, recMeta);
renderRecommendRows(sorted);
}
function renderRecommendations(data) {
if (!recommendList || !data) return;
updateRecommendMaxMaps(data);
recMeta = {
margin_used: data.margin_used || 0,
margin_budget_remaining: data.margin_budget_remaining,
margin_budget_total: data.margin_budget_total
};
var recCap = document.getElementById('rec-capital');
if (recCap && data.capital != null) recCap.textContent = Number(data.capital).toFixed(2);
var recMarginHint = document.getElementById('rec-margin-hint');
if (recMarginHint) {
if (recMeta.margin_used > 0) {
recMarginHint.textContent = ' · 已扣持仓占用 ' + fmtNum(recMeta.margin_used) + ' 元';
} else {
recMarginHint.textContent = '';
}
}
var recUpdated = document.getElementById('rec-updated');
if (recUpdated && data.updated_at) {
recUpdated.textContent = '每日后台更新 · 最近 ' + data.updated_at;
@@ -1658,7 +1678,7 @@
recRowsRaw = rows.slice();
if (!rows.length) {
recommendList.innerHTML = '<tr><td colspan="' + REC_COLSPAN + '" class="empty-hint">当前资金下暂无推荐品种(每日后台刷新)</td></tr>';
updateRecStats([], []);
updateRecStats([], [], recMeta);
return;
}
renderRecommendTable();
@@ -1692,7 +1712,7 @@
renderRecommendTable();
});
}
if (recRowsRaw.length) updateRecStats(recRowsRaw, filterRecommendRows(recRowsRaw));
if (recRowsRaw.length) updateRecStats(recRowsRaw, filterRecommendRows(recRowsRaw), recMeta);
}
function connectRecommendStream() {
+1 -1
View File
@@ -172,7 +172,7 @@
<div class="card trade-card trade-card-full" id="recommend">
<h2>可开仓品种</h2>
<div class="card-body">
<p class="hint">最大手数 = floor(权益 × 保证金上限 <strong>{{ max_margin_pct }}%</strong> ÷ 1手保证金);当前权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(recommend_capital) }}</strong> 元。
<p class="hint">最大手数 = floor((权益 × 保证金上限 <strong>{{ max_margin_pct }}%</strong> 已占用保证金) ÷ 1手保证金);当前权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(recommend_capital) }}</strong><span id="rec-margin-hint" class="text-muted"></span>
{% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ <strong>{{ fixed_lots }}</strong> 手的品种。{% endif %}
{% if small_account_scope %}<span class="text-muted">{{ small_account_scope_hint }}。</span>{% endif %}
{% if small_account_margin_rec %}<span class="text-muted">{{ small_account_margin_rec.label }}。</span>{% endif %}