diff --git a/install_trading.py b/install_trading.py index 115dd6c..2569b5d 100644 --- a/install_trading.py +++ b/install_trading.py @@ -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) diff --git a/product_recommend.py b/product_recommend.py index 0b92be8..35769ab 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -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: diff --git a/recommend_store.py b/recommend_store.py index 1d0baf8..edce71c 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -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), diff --git a/static/js/trade.js b/static/js/trade.js index 4640803..2bff0ad 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -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( + '持仓占用 ' + fmtNum(meta.margin_used) + ' 元,' + + '剩余额度 ' + fmtNum(meta.margin_budget_remaining) + ' 元' + ); + } if (recIndustryFilter) { parts.push('筛选 ' + shown + ' / 共 ' + 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 = '当前资金下暂无推荐品种(每日后台刷新)'; - 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() { diff --git a/templates/trade.html b/templates/trade.html index 13baa72..c4a09d8 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -172,7 +172,7 @@

可开仓品种

-

最大手数 = floor(权益 × 保证金上限 {{ max_margin_pct }}% ÷ 1手保证金);当前权益 {{ '%.2f'|format(recommend_capital) }} 元。 +

最大手数 = floor((权益 × 保证金上限 {{ max_margin_pct }}% − 已占用保证金) ÷ 1手保证金);当前权益 {{ '%.2f'|format(recommend_capital) }}。 {% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ {{ fixed_lots }} 手的品种。{% endif %} {% if small_account_scope %}{{ small_account_scope_hint }}。{% endif %} {% if small_account_margin_rec %}{{ small_account_margin_rec.label }}。{% endif %}