feat: 计仓改为固定手数/固定金额,推荐过滤与CTP保证金,下单与持仓UI优化
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+55
-26
@@ -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))
|
||||
|
||||
+70
-32
@@ -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:
|
||||
|
||||
+43
-1
@@ -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
|
||||
|
||||
+10
-1
@@ -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})
|
||||
|
||||
+84
-36
@@ -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 ? ' · <span class="text-profit">止损监控中</span>' : '') +
|
||||
(row.tp_order_active ? ' · <span class="text-profit">止盈监控中</span>' : '') +
|
||||
(row.trailing_be ? ' · <span class="text-accent">移动保本' +
|
||||
(row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '</span>' : '') + '</div>' +
|
||||
(row.tp_order_active ? ' · <span class="text-profit">止盈监控中</span>' : '') + '</div>' +
|
||||
'<div class="pos-metrics">' +
|
||||
'<div class="cell"><label>移动保本</label><div>' +
|
||||
(row.trailing_be ?
|
||||
'<span class="text-accent">已开启' + (row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '</span>' :
|
||||
'<span class="text-muted">未开启</span>') +
|
||||
'</div></div>' +
|
||||
'<div class="cell"><label>持仓均价</label><div>' + fmtNum(row.entry_price) + '</div></div>' +
|
||||
'<div class="cell"><label>当前价格</label><div>' + (row.current_price != null ? fmtNum(row.current_price) : '--') + '</div></div>' +
|
||||
'<div class="cell"><label>止损</label><div>' + (row.stop_loss != null ? fmtNum(row.stop_loss) : '--') + '</div></div>' +
|
||||
@@ -861,7 +908,7 @@
|
||||
'<td>' + (r.price != null ? r.price : '—') + '</td>' +
|
||||
'<td>' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '</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 + (r.margin_source === 'ctp' ? ' <span class="text-muted">(柜台)</span>' : '') : '—') + '</td>' +
|
||||
'<td>' + (r.open_fee_one_lot != null ? r.open_fee_one_lot : '—') + '</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>' +
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
+27
-6
@@ -54,14 +54,18 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>计仓模式</label>
|
||||
<select name="position_sizing_mode">
|
||||
<option value="risk" {% if position_sizing_mode == 'risk' %}selected{% endif %}>以损定仓</option>
|
||||
<option value="fixed" {% if position_sizing_mode == 'fixed' %}selected{% endif %}>固定张数</option>
|
||||
<select name="position_sizing_mode" id="position-sizing-mode">
|
||||
<option value="fixed" {% if position_sizing_mode == 'fixed' %}selected{% endif %}>固定手数</option>
|
||||
<option value="amount" {% if position_sizing_mode in ('amount', 'risk') %}selected{% endif %}>固定金额</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>单笔风险比例(以损定仓,%)</label>
|
||||
<input name="risk_percent" type="number" step="0.1" min="0.1" max="100" value="{{ risk_percent }}">
|
||||
<div class="field" id="field-fixed-lots" {% if position_sizing_mode in ('amount', 'risk') %}hidden{% endif %}>
|
||||
<label>固定手数(手)</label>
|
||||
<input name="fixed_lots" type="number" step="1" min="1" value="{{ fixed_lots }}">
|
||||
</div>
|
||||
<div class="field" id="field-fixed-amount" {% if position_sizing_mode not in ('amount', 'risk') %}hidden{% endif %}>
|
||||
<label>固定金额(元)</label>
|
||||
<input name="fixed_amount" type="number" step="100" min="1" value="{{ fixed_amount }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>保证金占用上限(%)</label>
|
||||
@@ -146,3 +150,20 @@
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var sel = document.getElementById('position-sizing-mode');
|
||||
var lotsField = document.getElementById('field-fixed-lots');
|
||||
var amountField = document.getElementById('field-fixed-amount');
|
||||
function syncSizingFields() {
|
||||
if (!sel) return;
|
||||
var isAmount = sel.value === 'amount';
|
||||
if (lotsField) lotsField.hidden = isAmount;
|
||||
if (amountField) amountField.hidden = !isAmount;
|
||||
}
|
||||
if (sel) sel.addEventListener('change', syncSizingFields);
|
||||
syncSizingFields();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+13
-8
@@ -31,7 +31,11 @@
|
||||
<div class="status-row">
|
||||
<span class="text-muted">计仓</span>
|
||||
<strong id="sizing-label">{{ sizing_mode_label }}</strong>
|
||||
{% if sizing_mode == 'risk' %}<span class="text-muted">· 单笔风险 {{ risk_percent }}%</span>{% endif %}
|
||||
{% if sizing_mode == 'fixed' %}
|
||||
<span class="text-muted">· {{ fixed_lots }} 手</span>
|
||||
{% elif sizing_mode in ('amount', 'risk') %}
|
||||
<span class="text-muted">· {{ '%.0f'|format(fixed_amount) }} 元</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,8 +57,8 @@
|
||||
</div>
|
||||
<div class="trade-field" id="field-lots">
|
||||
<label class="text-label">手数</label>
|
||||
<input type="number" id="trade-lots" min="1" step="1" value="1" {% if sizing_mode == 'risk' %}hidden{% endif %}>
|
||||
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="填写止损后自动计算" {% if sizing_mode != 'risk' %}hidden{% endif %}>
|
||||
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="—">
|
||||
<input type="hidden" id="trade-lots" value="{{ fixed_lots if sizing_mode == 'fixed' else '1' }}">
|
||||
<p class="hint lots-warn text-loss" id="lots-warn" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,8 +99,6 @@
|
||||
<p class="hint" id="trade-metrics-hint">填写品种后显示精度与每跳价值;策略自动化请用 <a href="{{ url_for('strategy_page') }}">策略交易</a>。</p>
|
||||
{% if ctp_status.last_error %}
|
||||
<p class="text-loss ctp-install-hint" style="font-size:.78rem;margin-top:.35rem">{{ ctp_status.last_error }}</p>
|
||||
{% else %}
|
||||
<p class="text-muted ctp-install-hint" style="font-size:.72rem;margin-top:.35rem">报单需安装 vnpy 并连接 CTP(SimNow 模拟盘)。</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +116,9 @@
|
||||
<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(capital) }}</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> 元。
|
||||
{% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ <strong>{{ fixed_lots }}</strong> 手的品种。{% endif %}
|
||||
保证金优先读取 CTP 柜台合约信息。
|
||||
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
|
||||
</p>
|
||||
<div class="trade-table-wrap">
|
||||
@@ -135,7 +139,7 @@
|
||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
||||
<td>{% if r.ref_stop_loss %}{{ r.ref_stop_loss }}{% 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 }}{% if r.margin_source == 'ctp' %} <span class="text-muted">(柜台)</span>{% endif %}{% 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.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>
|
||||
@@ -154,7 +158,8 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
|
||||
window.TRADE_RISK_PERCENT = {{ risk_percent }};
|
||||
window.TRADE_FIXED_LOTS = {{ fixed_lots|tojson }};
|
||||
window.TRADE_FIXED_AMOUNT = {{ fixed_amount|tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/trade.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
+15
-1
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user