feat: 计仓改为固定手数/固定金额,推荐过滤与CTP保证金,下单与持仓UI优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 15:31:34 +08:00
parent c302e1f3ca
commit 9772f3d986
11 changed files with 387 additions and 119 deletions
+36 -8
View File
@@ -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"),
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+33
View File
@@ -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)