From 9772f3d986139313f24c5435e86125e5e96c80a2 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 25 Jun 2026 15:31:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A1=E4=BB=93=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=89=8B=E6=95=B0/=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E9=87=91=E9=A2=9D=EF=BC=8C=E6=8E=A8=E8=8D=90=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E4=B8=8ECTP=E4=BF=9D=E8=AF=81=E9=87=91=EF=BC=8C=E4=B8=8B?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E6=8C=81=E4=BB=93UI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 44 ++++++++++++--- contract_specs.py | 1 + install_trading.py | 81 ++++++++++++++++++--------- position_sizing.py | 102 +++++++++++++++++++++++----------- recommend_store.py | 44 ++++++++++++++- recommend_stream.py | 11 +++- static/js/trade.js | 120 ++++++++++++++++++++++++++++------------ templates/settings.html | 33 +++++++++-- templates/trade.html | 21 ++++--- trading_context.py | 16 +++++- vnpy_bridge.py | 33 +++++++++++ 11 files changed, 387 insertions(+), 119 deletions(-) diff --git a/app.py b/app.py index a5c094c..db4f1a7 100644 --- a/app.py +++ b/app.py @@ -372,7 +372,11 @@ def init_db(): if not get_setting("trading_mode"): set_setting("trading_mode", "simulation") if not get_setting("position_sizing_mode"): - set_setting("position_sizing_mode", "risk") + set_setting("position_sizing_mode", "fixed") + if not get_setting("fixed_lots"): + set_setting("fixed_lots", "1") + if not get_setting("fixed_amount"): + set_setting("fixed_amount", "5000") if not get_setting("risk_percent"): set_setting("risk_percent", "1") if not get_setting("max_margin_pct"): @@ -755,7 +759,13 @@ def api_symbols_mains(): def api_symbols_recommended(): """品种下拉:仅展示当前资金下推荐的品种(与下方品种推荐表一致)。""" from recommend_store import recommend_payload - from trading_context import get_account_capital, get_max_margin_pct + from trading_context import ( + get_account_capital, + get_fixed_lots, + get_max_margin_pct, + get_sizing_mode, + get_trading_mode, + ) conn = get_db() try: @@ -764,6 +774,9 @@ def api_symbols_recommended(): conn, live_capital=capital, max_margin_pct=get_max_margin_pct(get_setting), + trading_mode=get_trading_mode(get_setting), + sizing_mode=get_sizing_mode(get_setting), + fixed_lots=get_fixed_lots(get_setting), ) return jsonify(list_recommended_symbols_grouped(payload.get("rows") or [])) finally: @@ -1639,17 +1652,30 @@ def settings(): mode = request.form.get("trading_mode", "simulation").strip() if mode not in ("simulation", "live"): mode = "simulation" - sizing = request.form.get("position_sizing_mode", "risk").strip() - if sizing not in ("fixed", "risk"): - sizing = "risk" + sizing = request.form.get("position_sizing_mode", "fixed").strip() + if sizing == "risk": + sizing = "amount" + if sizing not in ("fixed", "amount"): + sizing = "fixed" set_setting("trading_mode", mode) set_setting("position_sizing_mode", sizing) + try: + fl = int(float(request.form.get("fixed_lots", "1") or 1)) + set_setting("fixed_lots", str(max(1, fl))) + except ValueError: + flash("固定手数无效") + return redirect(url_for("settings")) + try: + fa = float(request.form.get("fixed_amount", "5000") or 5000) + set_setting("fixed_amount", str(max(1.0, fa))) + except ValueError: + flash("固定金额无效") + return redirect(url_for("settings")) try: rp = float(request.form.get("risk_percent", "1") or 1) set_setting("risk_percent", str(max(0.1, min(100.0, rp)))) except ValueError: - flash("风险比例无效") - return redirect(url_for("settings")) + pass try: mp = float(request.form.get("max_margin_pct", "30") or 30) set_setting("max_margin_pct", str(max(1.0, min(100.0, mp)))) @@ -1699,7 +1725,9 @@ def settings(): username=username, quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))), trading_mode=get_setting("trading_mode", "simulation"), - position_sizing_mode=get_setting("position_sizing_mode", "risk"), + position_sizing_mode=get_setting("position_sizing_mode", "fixed"), + fixed_lots=get_setting("fixed_lots", "1"), + fixed_amount=get_setting("fixed_amount", "5000"), risk_percent=get_setting("risk_percent", "1"), max_margin_pct=get_setting("max_margin_pct", "30"), trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"), diff --git a/contract_specs.py b/contract_specs.py index 939192f..c0fed12 100644 --- a/contract_specs.py +++ b/contract_specs.py @@ -117,5 +117,6 @@ def calc_position_metrics( "position_pct": round(pos_pct, 2), "float_pnl": round(float_pnl, 2) if float_pnl is not None else None, "float_pct": round(float_pct, 2) if float_pct is not None else None, + "reward_amount": round(reward, 2) if reward else None, "rr_ratio": round(rr, 2) if rr is not None else None, } diff --git a/install_trading.py b/install_trading.py index 33fde56..0a48714 100644 --- a/install_trading.py +++ b/install_trading.py @@ -14,9 +14,10 @@ from fee_specs import calc_fee_breakdown from kline_stream import sse_format from market_sessions import is_trading_session from position_sizing import ( + MODE_AMOUNT, MODE_FIXED, - MODE_RISK, DEFAULT_MAX_ORDER_LOTS, + calc_lots_by_amount, calc_lots_by_risk, calc_margin_usage_pct, calc_order_tick_metrics, @@ -62,6 +63,8 @@ from trading_context import ( TRADING_MODE_LIVE, TRADING_MODE_SIM, get_account_capital, + get_fixed_amount, + get_fixed_lots, get_max_margin_pct, get_risk_percent, get_sizing_mode, @@ -90,6 +93,23 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se """注册交易相关路由。""" _nav = require_nav + def _sizing_mode_label(mode: str) -> str: + m = normalize_sizing_mode(mode) + if m == MODE_AMOUNT: + return "固定金额" + return "固定手数" + + def _recommend_payload(conn) -> dict: + mode = get_trading_mode(get_setting) + return recommend_payload( + conn, + live_capital=_capital(conn), + max_margin_pct=get_max_margin_pct(get_setting), + trading_mode=mode, + sizing_mode=get_sizing_mode(get_setting), + fixed_lots=get_fixed_lots(get_setting), + ) + def _settings_dict() -> dict: return { "trading_mode": get_trading_mode(get_setting), @@ -847,11 +867,15 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ) except Exception as exc: logger.warning("positions recommend refresh failed: %s", exc) - rec_cache = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct) + rec_cache = _recommend_payload(conn) if not rec_cache.get("rows") and capital > 0: try: from product_recommend import list_product_recommendations - from recommend_store import enrich_recommend_rows, filter_affordable_recommendations + from recommend_store import ( + enrich_recommend_rows, + filter_affordable_recommendations, + filter_recommend_by_sizing, + ) live_rows = filter_affordable_recommendations( list_product_recommendations( @@ -859,8 +883,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ) ) if live_rows: - rec_cache["rows"] = enrich_recommend_rows( - live_rows, capital, max_margin_pct=max_pct, + enriched = enrich_recommend_rows( + live_rows, capital, max_margin_pct=max_pct, trading_mode=mode, + ) + rec_cache["rows"] = filter_recommend_by_sizing( + enriched, + sizing_mode=sizing, + fixed_lots=get_fixed_lots(get_setting), ) rec_cache["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") except Exception as exc: @@ -877,7 +906,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se monitor_count=monitor_count, roll_count=roll_count, sizing_mode=sizing, - sizing_mode_label="以损定仓" if sizing == MODE_RISK else "固定张数", + sizing_mode_label=_sizing_mode_label(sizing), + fixed_lots=get_fixed_lots(get_setting), + fixed_amount=get_fixed_amount(get_setting), risk_percent=get_risk_percent(get_setting), max_margin_pct=get_max_margin_pct(get_setting), recommend_rows=rec_cache.get("rows") or [], @@ -1262,13 +1293,15 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn.close() sizing = get_sizing_mode(get_setting) margin_pct = get_max_margin_pct(get_setting) - if sizing == MODE_RISK: - lots, err = calc_lots_by_risk( - entry, sl, direction, capital, get_risk_percent(get_setting), sym, - max_margin_pct=margin_pct, + if sizing == MODE_AMOUNT: + lots, err = calc_lots_by_amount( + entry, sl, direction, get_fixed_amount(get_setting), sym, + capital=capital, max_margin_pct=margin_pct, ) if err: return jsonify({"ok": False, "error": err}), 400 + elif sizing == MODE_FIXED: + lots = get_fixed_lots(get_setting) else: try: lots = max(1, int(d.get("lots") or 1)) @@ -1322,19 +1355,21 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se return jsonify({"ok": False, "error": "CTP 连接中,请稍候再下单"}), 400 return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 sizing = get_sizing_mode(get_setting) - if offset.startswith("open") and sizing == MODE_RISK: + if offset.startswith("open") and sizing == MODE_AMOUNT: sl = float(d.get("stop_loss") or 0) if sl <= 0: conn.close() - return jsonify({"ok": False, "error": "以损定仓模式须填写止损价"}), 400 - lots_calc, err = calc_lots_by_risk( - price, sl, direction, _capital(conn), get_risk_percent(get_setting), sym, - max_margin_pct=get_max_margin_pct(get_setting), + return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400 + lots_calc, err = calc_lots_by_amount( + price, sl, direction, get_fixed_amount(get_setting), sym, + capital=_capital(conn), max_margin_pct=get_max_margin_pct(get_setting), ) if err: conn.close() return jsonify({"ok": False, "error": err}), 400 lots = lots_calc or lots + elif offset.startswith("open") and sizing == MODE_FIXED: + lots = get_fixed_lots(get_setting) margin_pct = get_max_margin_pct(get_setting) usage = calc_margin_usage_pct( _ctp_positions(mode), @@ -1482,11 +1517,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se """只读数据库缓存,不在请求时拉行情。""" conn = get_db() try: - payload = recommend_payload( - conn, - live_capital=_capital(conn), - max_margin_pct=get_max_margin_pct(get_setting), - ) + payload = _recommend_payload(conn) return jsonify({"ok": True, **payload}) finally: conn.close() @@ -1501,11 +1532,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se try: conn = get_db() try: - payload = recommend_payload( - conn, - live_capital=_capital(conn), - max_margin_pct=get_max_margin_pct(get_setting), - ) + payload = _recommend_payload(conn) finally: conn.close() yield sse_format("recommend", {"ok": True, **payload}) @@ -1542,7 +1569,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se max_margin_pct=get_max_margin_pct(get_setting), ) max_pct = get_max_margin_pct(get_setting) - payload = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct) + payload = _recommend_payload(conn) recommend_hub.broadcast("recommend", {"ok": True, **payload}) return jsonify({"ok": True, "count": len(rows), **payload}) finally: @@ -1876,6 +1903,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se init_tables_fn=_init_tables, get_mode_fn=lambda: get_trading_mode(get_setting), get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting), + get_sizing_mode_fn=lambda: get_sizing_mode(get_setting), + get_fixed_lots_fn=lambda: get_fixed_lots(get_setting), ) start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) start_ctp_premarket_connect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) diff --git a/position_sizing.py b/position_sizing.py index d491ab8..7a27694 100644 --- a/position_sizing.py +++ b/position_sizing.py @@ -1,4 +1,4 @@ -"""期货计仓:固定张数 / 以损定仓(不含币圈全仓杠杆模式)。""" +"""期货计仓:固定手数 / 固定金额。""" from __future__ import annotations import math @@ -7,15 +7,17 @@ from typing import Optional from contract_specs import get_contract_spec MODE_FIXED = "fixed" -MODE_RISK = "risk" +MODE_AMOUNT = "amount" +MODE_RISK = "amount" # 兼容旧配置「以损定仓」 -# 单笔报单手数上限(防止以损定仓在止损过近时算出超大手数) DEFAULT_MAX_ORDER_LOTS = 50 def normalize_sizing_mode(raw: str) -> str: - m = (raw or MODE_RISK).strip().lower() - return m if m in (MODE_FIXED, MODE_RISK) else MODE_RISK + m = (raw or MODE_FIXED).strip().lower() + if m == "risk": + m = MODE_AMOUNT + return m if m in (MODE_FIXED, MODE_AMOUNT) else MODE_FIXED def price_precision_from_tick(tick_size: float) -> int: @@ -27,6 +29,62 @@ def price_precision_from_tick(tick_size: float) -> int: return len(s.split(".")[1]) +def _per_lot_risk(entry: float, stop_loss: float, direction: str, ths_code: str) -> tuple[float, Optional[str]]: + spec = get_contract_spec(ths_code) + mult = spec["mult"] + d = (direction or "long").strip().lower() + if d == "short": + per_lot = (stop_loss - entry) * mult + else: + per_lot = (entry - stop_loss) * mult + if per_lot <= 0: + return 0.0, "止损方向与入场价不匹配" + return per_lot, None + + +def calc_lots_by_amount( + entry: float, + stop_loss: float, + direction: str, + amount: float, + ths_code: str, + *, + capital: float = 0.0, + max_lots: Optional[int] = None, + max_margin_pct: float = 30.0, +) -> tuple[Optional[int], Optional[str]]: + """固定金额:按止损距离将金额换算为手数。""" + try: + entry_f = float(entry) + sl_f = float(stop_loss) + budget = float(amount) + cap = float(capital or 0) + except (TypeError, ValueError): + return None, "参数格式错误" + if entry_f <= 0 or budget <= 0: + return None, "入场价或固定金额无效" + per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code) + if err: + return None, err + lots = int(math.floor(budget / per_lot_risk)) + if lots < 1: + return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手" + if cap > 0: + spec = get_contract_spec(ths_code) + margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"] + margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0))) + max_by_margin = ( + int(math.floor(cap * margin_cap / 100.0 / margin_per_lot)) + if margin_per_lot > 0 else lots + ) + if max_by_margin < 1: + return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手" + lots = min(lots, max_by_margin) + cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS + lots = min(lots, cap_lots) + return lots, None + + def calc_lots_by_risk( entry: float, stop_loss: float, @@ -38,39 +96,19 @@ def calc_lots_by_risk( max_lots: Optional[int] = None, max_margin_pct: float = 30.0, ) -> tuple[Optional[int], Optional[str]]: - """以损定仓:返回 (手数, 错误信息)。""" + """策略等场景:按权益百分比风险预算换算手数。""" try: - entry_f = float(entry) - sl_f = float(stop_loss) cap = float(capital) rp = float(risk_percent) except (TypeError, ValueError): return None, "参数格式错误" - if entry_f <= 0 or cap <= 0 or rp <= 0: - return None, "入场价、资金或风险比例无效" - spec = get_contract_spec(ths_code) - mult = spec["mult"] - d = (direction or "long").strip().lower() - if d == "short": - per_lot_risk = (sl_f - entry_f) * mult - else: - per_lot_risk = (entry_f - sl_f) * mult - if per_lot_risk <= 0: - return None, "止损方向与入场价不匹配" + if cap <= 0 or rp <= 0: + return None, "资金或风险比例无效" budget = cap * rp / 100.0 - lots = int(math.floor(budget / per_lot_risk)) - if lots < 1: - return None, f"按 {rp}% 风险预算,当前止损距离下不足 1 手" - margin_rate = spec["margin_rate"] - margin_per_lot = entry_f * mult * margin_rate - margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0))) - max_by_margin = int(math.floor(cap * margin_cap / 100.0 / margin_per_lot)) if margin_per_lot > 0 else lots - if max_by_margin < 1: - return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手" - lots = min(lots, max_by_margin) - cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS - lots = min(lots, cap_lots) - return lots, None + return calc_lots_by_amount( + entry, stop_loss, direction, budget, ths_code, + capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct, + ) def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = None) -> dict: diff --git a/recommend_store.py b/recommend_store.py index 0455cae..d4bbaf0 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -59,18 +59,34 @@ def enrich_recommend_rows( capital: float, *, max_margin_pct: float = 30.0, + trading_mode: str = "simulation", ) -> 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 + ctp_connected = False + try: + from vnpy_bridge import ctp_estimate_margin_one_lot, ctp_status + ctp_connected = bool(ctp_status(trading_mode).get("connected")) + except Exception: + pass enriched: list[dict] = [] for raw in rows: row = dict(raw) + margin_one = 0.0 try: margin_one = float(row.get("margin_one_lot") or 0) except (TypeError, ValueError): margin_one = 0.0 + price = float(row.get("price") or 0) + main_code = (row.get("main_code") or "").strip() + if ctp_connected and main_code and price > 0: + ctp_margin = ctp_estimate_margin_one_lot(trading_mode, main_code, price) + if ctp_margin and ctp_margin > 0: + margin_one = ctp_margin + row["margin_one_lot"] = ctp_margin + row["margin_source"] = "ctp" if margin_one > 0 and budget > 0: lots = int(math.floor(budget / margin_one)) else: @@ -84,13 +100,32 @@ def enrich_recommend_rows( row["max_margin_pct"] = pct status = row.get("status") or "" if lots >= 1 and status in ("ok", "margin_ok"): + src = "柜台" if row.get("margin_source") == "ctp" else "估算" row["status_label"] = ( f"最大 {lots} 手" if status == "ok" else f"最大 {lots} 手·止损偏宽" ) + if row.get("margin_source") == "ctp": + row["status_label"] += f"({src}保证金)" + elif lots < 1 and status in ("ok", "margin_ok"): + row["status"] = "blocked" + row["status_label"] = "资金不足" enriched.append(row) return enriched +def filter_recommend_by_sizing( + rows: list[dict], + *, + sizing_mode: str, + fixed_lots: int = 1, +) -> list[dict]: + """固定手数模式下:最大手数低于设定值的品种不展示。""" + if (sizing_mode or "").strip().lower() != "fixed": + return rows + fl = max(1, int(fixed_lots or 1)) + return [r for r in rows if int(r.get("max_lots") or 0) >= fl] + + def refresh_recommend_cache( conn, capital: float, @@ -164,6 +199,9 @@ def recommend_payload( *, live_capital: float, max_margin_pct: float = 30.0, + trading_mode: str = "simulation", + sizing_mode: str = "fixed", + fixed_lots: int = 1, ) -> dict: """读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。""" payload = load_recommend_cache(conn) @@ -172,6 +210,10 @@ def recommend_payload( payload["capital"] = cap payload["max_margin_pct"] = pct rows = payload.get("rows") or [] - payload["rows"] = enrich_recommend_rows(rows, cap, max_margin_pct=pct) + rows = enrich_recommend_rows( + rows, cap, max_margin_pct=pct, trading_mode=trading_mode, + ) + rows = filter_recommend_by_sizing(rows, sizing_mode=sizing_mode, fixed_lots=fixed_lots) + payload["rows"] = rows payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap) return payload diff --git a/recommend_stream.py b/recommend_stream.py index bb4c56f..ea056d8 100644 --- a/recommend_stream.py +++ b/recommend_stream.py @@ -62,6 +62,8 @@ def start_recommend_worker( init_tables_fn: Callable | None = None, get_mode_fn: Callable[[], str] | None = None, get_max_margin_pct_fn: Callable[[], float] | None = None, + get_sizing_mode_fn: Callable[[], str] | None = None, + get_fixed_lots_fn: Callable[[], int] | None = None, interval: int = CHECK_INTERVAL_SEC, ) -> None: """后台每日刷新推荐(每小时检查一次是否需更新),并推送给 SSE 订阅者。""" @@ -83,7 +85,14 @@ def start_recommend_worker( ) cached = load_recommend_cache(conn) logger.info("品种推荐刷新完成,capital=%.2f rows=%d", capital, len(cached.get("rows") or [])) - payload = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct) + payload = recommend_payload( + conn, + live_capital=capital, + max_margin_pct=max_pct, + trading_mode=mode, + sizing_mode=get_sizing_mode_fn() if get_sizing_mode_fn else "fixed", + fixed_lots=get_fixed_lots_fn() if get_fixed_lots_fn else 1, + ) finally: conn.close() recommend_hub.broadcast("recommend", {"ok": True, **payload}) diff --git a/static/js/trade.js b/static/js/trade.js index 7c6ac74..48ac8cb 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -1,5 +1,6 @@ (function () { - var sizingMode = window.TRADE_SIZING_MODE || 'risk'; + var sizingMode = window.TRADE_SIZING_MODE || 'fixed'; + if (sizingMode === 'risk') sizingMode = 'amount'; var list = document.getElementById('position-live-list'); var recommendList = document.getElementById('recommend-list'); var symInput = document.getElementById('trade-symbol'); @@ -48,16 +49,17 @@ return (symInput && symInput.value || '').trim(); } - function isRiskMode() { - return sizingMode === 'risk'; + function isFixedMode() { + return sizingMode === 'fixed'; + } + + function isAmountMode() { + return sizingMode === 'amount'; } function effectiveLots() { - if (isRiskMode()) { - var v = parseInt(lotsCalc && lotsCalc.value, 10); - return v > 0 ? v : 0; - } - return parseInt(lotsInput && lotsInput.value, 10) || 1; + var v = parseInt(lotsCalc && lotsCalc.value, 10); + return v > 0 ? v : 0; } function updateRecommendMaxMaps(data) { @@ -238,9 +240,20 @@ var entry = entryPrice(); var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0; var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0; + var lots = effectiveLots(); + var parts = []; var rr = calcRR(dir, entry, sl, tp); - if (rr) { - el.textContent = '盈亏比 ' + rr + ':1'; + if (rr) parts.push('盈亏比 ' + rr + ':1'); + if (sl > 0 && entry > 0 && lots > 0 && lastPreviewMetrics) { + if (lastPreviewMetrics.risk_amount != null) { + parts.push('止损金额 ' + fmtNum(lastPreviewMetrics.risk_amount) + ' 元'); + } + if (lastPreviewMetrics.reward_amount != null && tp > 0) { + parts.push('止盈金额 ' + fmtNum(lastPreviewMetrics.reward_amount) + ' 元'); + } + } + if (parts.length) { + el.textContent = parts.join(' · '); el.hidden = false; } else { el.textContent = ''; @@ -248,6 +261,7 @@ } } + var lastPreviewMetrics = null; function setPriceType(type) { priceType = type === 'market' ? 'market' : 'limit'; @@ -353,7 +367,7 @@ function refreshQuote() { var sym = selectedSymbol(); - var lots = isRiskMode() ? (effectiveLots() || 1) : (lotsInput ? lotsInput.value : '1'); + var lots = effectiveLots() || (isFixedMode() ? (window.TRADE_FIXED_LOTS || 1) : 1); if (!sym) return; fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots)) .then(function (r) { return r.json(); }) @@ -381,23 +395,39 @@ } function scheduleAutoCalc() { - if (!isRiskMode()) return; clearTimeout(calcTimer); calcTimer = setTimeout(autoCalcLots, 450); } function autoCalcLots() { - if (!isRiskMode() || !lotsCalc) return; + if (!lotsCalc) return; var sym = selectedSymbol(); var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0; var sl = parseFloat(slInput && slInput.value) || 0; - if (!sym || !entry || !sl) { - lotsCalc.value = ''; - lotsCalc.placeholder = '填写止损后自动计算'; - checkLotsLimit(); + var tp = parseFloat(tpInput && tpInput.value) || 0; + if (isFixedMode()) { + var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1; + lotsCalc.value = String(fixedLots); + if (lotsInput) lotsInput.value = String(fixedLots); + if (!sym || !entry) { + lastPreviewMetrics = null; + updateRRDisplay(); + checkLotsLimit(); + return; + } + } else if (isAmountMode()) { + if (!sym || !entry || !sl) { + lotsCalc.value = ''; + lotsCalc.placeholder = '填写止损后自动计算'; + lastPreviewMetrics = null; + updateRRDisplay(); + checkLotsLimit(); + return; + } + lotsCalc.placeholder = '计算中…'; + } else { return; } - lotsCalc.placeholder = '计算中…'; fetch('/api/trade/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -407,20 +437,30 @@ entry: entry, price: entry, stop_loss: sl, - take_profit: parseFloat(tpInput && tpInput.value) || 0 + take_profit: tp }) }).then(function (r) { return r.json(); }).then(function (data) { if (!data.ok) { - lotsCalc.value = ''; - lotsCalc.placeholder = data.error || '无法计算'; + if (isAmountMode()) { + lotsCalc.value = ''; + lotsCalc.placeholder = data.error || '无法计算'; + } + lastPreviewMetrics = null; + updateRRDisplay(); + checkLotsLimit(); return; } - lotsCalc.value = data.lots; - lotsCalc.placeholder = '填写止损后自动计算'; + lotsCalc.value = String(data.lots || ''); + if (lotsInput) lotsInput.value = String(data.lots || ''); + lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—'; + lastPreviewMetrics = data.metrics || null; + updateRRDisplay(); checkLotsLimit(); scheduleQuote(); }).catch(function () { - lotsCalc.placeholder = '计算失败'; + if (isAmountMode()) lotsCalc.placeholder = '计算失败'; + lastPreviewMetrics = null; + updateRRDisplay(); }); } @@ -470,12 +510,16 @@ showOrderMsg('开启移动保本须填写止损价', false); return; } - if (isRiskMode() && lots <= 0) { - showOrderMsg('请填写止损,系统将自动计算手数', false); + if (isAmountMode() && lots <= 0) { + showOrderMsg('请填写止损,系统将按固定金额自动计算手数', false); return; } - if (!isRiskMode() && lots <= 0) { - showOrderMsg('请填写手数', false); + if (isFixedMode() && lots <= 0) { + showOrderMsg('手数无效,请检查系统设置中的固定手数', false); + return; + } + if (lots <= 0) { + showOrderMsg('请填写有效手数', false); return; } var maxLots = maxLotsForSymbol(sym); @@ -627,10 +671,13 @@ ' · 浮盈' + (slTpBtn ? ' · ' + slTpBtn : '') + (row.sl_order_active ? ' · 止损监控中' : '') + - (row.tp_order_active ? ' · 止盈监控中' : '') + - (row.trailing_be ? ' · 移动保本' + - (row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '' : '') + '' + + (row.tp_order_active ? ' · 止盈监控中' : '') + '' + '
' + + '
' + + (row.trailing_be ? + '已开启' + (row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '' : + '未开启') + + '
' + '
' + fmtNum(row.entry_price) + '
' + '
' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '
' + '
' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '
' + @@ -861,7 +908,7 @@ '' + (r.price != null ? r.price : '—') + '' + '' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '' + '' + (r.ref_take_profit != null ? r.ref_take_profit : '—') + '' + - '' + (r.margin_one_lot != null ? r.margin_one_lot : '—') + '' + + '' + (r.margin_one_lot != null ? r.margin_one_lot + (r.margin_source === 'ctp' ? ' (柜台)' : '') : '—') + '' + '' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '' + '' + (r.max_lots != null && r.max_lots > 0 ? r.max_lots : '—') + '' + '' + (r.status_label || '') + '' + @@ -904,10 +951,6 @@ checkLotsLimit(); }); } - if (lotsInput) lotsInput.addEventListener('input', function () { - scheduleQuote(); - checkLotsLimit(); - }); if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit); if (slInput) { slInput.addEventListener('input', function () { @@ -945,6 +988,10 @@ runWhenReady(function () { setPriceType('limit'); + if (isFixedMode() && lotsCalc) { + lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1); + if (lotsInput) lotsInput.value = lotsCalc.value; + } var cached = loadPosCache(); if (cached) { applyPositionsData(cached); @@ -965,5 +1012,6 @@ updateSessionUi(); updateRRDisplay(); scheduleQuote(); + scheduleAutoCalc(); }); })(); diff --git a/templates/settings.html b/templates/settings.html index 1b37a1b..eb4c7e0 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -54,14 +54,18 @@
- + +
-
- - +
+ + +
+
+ +
@@ -146,3 +150,20 @@
{% endblock %} +{% block extra_js %} + +{% endblock %} diff --git a/templates/trade.html b/templates/trade.html index 4b99b6a..b1e20c3 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -31,7 +31,11 @@
计仓 {{ sizing_mode_label }} - {% if sizing_mode == 'risk' %}· 单笔风险 {{ risk_percent }}%{% endif %} + {% if sizing_mode == 'fixed' %} + · {{ fixed_lots }} 手 + {% elif sizing_mode in ('amount', 'risk') %} + · {{ '%.0f'|format(fixed_amount) }} 元 + {% endif %}
@@ -53,8 +57,8 @@
- - + +
@@ -95,8 +99,6 @@

填写品种后显示精度与每跳价值;策略自动化请用 策略交易

{% if ctp_status.last_error %}

{{ ctp_status.last_error }}

- {% else %} -

报单需安装 vnpy 并连接 CTP(SimNow 模拟盘)。

{% endif %} @@ -114,7 +116,9 @@

品种推荐

-

最大手数 = floor(权益 × 保证金上限 {{ max_margin_pct }}% ÷ 1手保证金);当前权益 {{ '%.2f'|format(capital) }} 元。参考止损/止盈按 20 跳、盈亏比 2:1 估算。 +

最大手数 = floor(权益 × 保证金上限 {{ max_margin_pct }}% ÷ 1手保证金);当前权益 {{ '%.2f'|format(capital) }} 元。 + {% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ {{ fixed_lots }} 手的品种。{% endif %} + 保证金优先读取 CTP 柜台合约信息。 {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %}

@@ -135,7 +139,7 @@ {% if r.price %}{{ r.price }}{% else %}—{% endif %} {% if r.ref_stop_loss %}{{ r.ref_stop_loss }}{% else %}—{% endif %} {% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %} - {% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %} + {% if r.margin_one_lot %}{{ r.margin_one_lot }}{% if r.margin_source == 'ctp' %} (柜台){% endif %}{% else %}—{% endif %} {% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %} {% if r.max_lots is not none and r.max_lots > 0 %}{{ r.max_lots }}{% else %}—{% endif %} {{ r.status_label }} @@ -154,7 +158,8 @@ {% block extra_js %} {% endblock %} diff --git a/trading_context.py b/trading_context.py index 6998e07..c76f6fd 100644 --- a/trading_context.py +++ b/trading_context.py @@ -14,7 +14,21 @@ def get_trading_mode(get_setting: Callable[[str, str], str]) -> str: def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str: from position_sizing import normalize_sizing_mode - return normalize_sizing_mode(get_setting("position_sizing_mode", "risk")) + return normalize_sizing_mode(get_setting("position_sizing_mode", "fixed")) + + +def get_fixed_lots(get_setting: Callable[[str, str], str]) -> int: + try: + return max(1, int(float(get_setting("fixed_lots", "1") or 1))) + except (TypeError, ValueError): + return 1 + + +def get_fixed_amount(get_setting: Callable[[str, str], str]) -> float: + try: + return max(1.0, float(get_setting("fixed_amount", "5000") or 5000)) + except (TypeError, ValueError): + return 5000.0 def get_risk_percent(get_setting: Callable[[str, str], str]) -> float: diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 51494ef..2b5c4d6 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -789,6 +789,28 @@ class CtpBridge: def _lookup_position_margin(self, sym: str, direction: str) -> float: return float(self._position_margins.get(self._position_margin_key(sym, direction), 0) or 0) + def estimate_margin_one_lot(self, ths_code: str, price: float) -> Optional[float]: + """用 CTP 合约信息估算 1 手保证金(需已连接并完成合约查询)。""" + if not self._engine or not price or price <= 0: + return None + try: + sym, ex_name = ths_to_vnpy_symbol(ths_code) + exchange = to_vnpy_exchange(ex_name) + vt_symbol = f"{sym}.{exchange.value}" + contract = self._engine.get_contract(vt_symbol) + if not contract: + return None + mult = float(getattr(contract, "size", 0) or 0) + long_r = float(getattr(contract, "long_margin_ratio", 0) or 0) + short_r = float(getattr(contract, "short_margin_ratio", 0) or 0) + ratio = max(long_r, short_r) + if mult <= 0 or ratio <= 0: + return None + return round(float(price) * mult * ratio, 2) + except Exception as exc: + logger.debug("estimate_margin_one_lot %s: %s", ths_code, exc) + return None + def _collect_positions(self) -> list[dict[str, Any]]: if not self._engine: return [] @@ -1074,6 +1096,17 @@ def ctp_get_tick_detail(mode: str, ths_code: str) -> dict[str, Any]: return {} +def ctp_estimate_margin_one_lot(mode: str, ths_code: str, price: float) -> Optional[float]: + b = get_bridge() + if b.connected_mode != mode or not b.ping(): + return None + try: + return b.estimate_margin_one_lot(ths_code, price) + except Exception as exc: + logger.debug("ctp_estimate_margin_one_lot: %s", exc) + return None + + def get_ctp_balance(mode: str) -> Optional[float]: try: acc = ctp_get_account(mode)