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"):
|
if not get_setting("trading_mode"):
|
||||||
set_setting("trading_mode", "simulation")
|
set_setting("trading_mode", "simulation")
|
||||||
if not get_setting("position_sizing_mode"):
|
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"):
|
if not get_setting("risk_percent"):
|
||||||
set_setting("risk_percent", "1")
|
set_setting("risk_percent", "1")
|
||||||
if not get_setting("max_margin_pct"):
|
if not get_setting("max_margin_pct"):
|
||||||
@@ -755,7 +759,13 @@ def api_symbols_mains():
|
|||||||
def api_symbols_recommended():
|
def api_symbols_recommended():
|
||||||
"""品种下拉:仅展示当前资金下推荐的品种(与下方品种推荐表一致)。"""
|
"""品种下拉:仅展示当前资金下推荐的品种(与下方品种推荐表一致)。"""
|
||||||
from recommend_store import recommend_payload
|
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()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
@@ -764,6 +774,9 @@ def api_symbols_recommended():
|
|||||||
conn,
|
conn,
|
||||||
live_capital=capital,
|
live_capital=capital,
|
||||||
max_margin_pct=get_max_margin_pct(get_setting),
|
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 []))
|
return jsonify(list_recommended_symbols_grouped(payload.get("rows") or []))
|
||||||
finally:
|
finally:
|
||||||
@@ -1639,17 +1652,30 @@ def settings():
|
|||||||
mode = request.form.get("trading_mode", "simulation").strip()
|
mode = request.form.get("trading_mode", "simulation").strip()
|
||||||
if mode not in ("simulation", "live"):
|
if mode not in ("simulation", "live"):
|
||||||
mode = "simulation"
|
mode = "simulation"
|
||||||
sizing = request.form.get("position_sizing_mode", "risk").strip()
|
sizing = request.form.get("position_sizing_mode", "fixed").strip()
|
||||||
if sizing not in ("fixed", "risk"):
|
if sizing == "risk":
|
||||||
sizing = "risk"
|
sizing = "amount"
|
||||||
|
if sizing not in ("fixed", "amount"):
|
||||||
|
sizing = "fixed"
|
||||||
set_setting("trading_mode", mode)
|
set_setting("trading_mode", mode)
|
||||||
set_setting("position_sizing_mode", sizing)
|
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:
|
try:
|
||||||
rp = float(request.form.get("risk_percent", "1") or 1)
|
rp = float(request.form.get("risk_percent", "1") or 1)
|
||||||
set_setting("risk_percent", str(max(0.1, min(100.0, rp))))
|
set_setting("risk_percent", str(max(0.1, min(100.0, rp))))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash("风险比例无效")
|
pass
|
||||||
return redirect(url_for("settings"))
|
|
||||||
try:
|
try:
|
||||||
mp = float(request.form.get("max_margin_pct", "30") or 30)
|
mp = float(request.form.get("max_margin_pct", "30") or 30)
|
||||||
set_setting("max_margin_pct", str(max(1.0, min(100.0, mp))))
|
set_setting("max_margin_pct", str(max(1.0, min(100.0, mp))))
|
||||||
@@ -1699,7 +1725,9 @@ def settings():
|
|||||||
username=username,
|
username=username,
|
||||||
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
|
quote_label=get_quote_source_label(ctp_connected=bool(ctp_st.get("connected"))),
|
||||||
trading_mode=get_setting("trading_mode", "simulation"),
|
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"),
|
risk_percent=get_setting("risk_percent", "1"),
|
||||||
max_margin_pct=get_setting("max_margin_pct", "30"),
|
max_margin_pct=get_setting("max_margin_pct", "30"),
|
||||||
trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"),
|
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),
|
"position_pct": round(pos_pct, 2),
|
||||||
"float_pnl": round(float_pnl, 2) if float_pnl is not None else None,
|
"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,
|
"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,
|
"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 kline_stream import sse_format
|
||||||
from market_sessions import is_trading_session
|
from market_sessions import is_trading_session
|
||||||
from position_sizing import (
|
from position_sizing import (
|
||||||
|
MODE_AMOUNT,
|
||||||
MODE_FIXED,
|
MODE_FIXED,
|
||||||
MODE_RISK,
|
|
||||||
DEFAULT_MAX_ORDER_LOTS,
|
DEFAULT_MAX_ORDER_LOTS,
|
||||||
|
calc_lots_by_amount,
|
||||||
calc_lots_by_risk,
|
calc_lots_by_risk,
|
||||||
calc_margin_usage_pct,
|
calc_margin_usage_pct,
|
||||||
calc_order_tick_metrics,
|
calc_order_tick_metrics,
|
||||||
@@ -62,6 +63,8 @@ from trading_context import (
|
|||||||
TRADING_MODE_LIVE,
|
TRADING_MODE_LIVE,
|
||||||
TRADING_MODE_SIM,
|
TRADING_MODE_SIM,
|
||||||
get_account_capital,
|
get_account_capital,
|
||||||
|
get_fixed_amount,
|
||||||
|
get_fixed_lots,
|
||||||
get_max_margin_pct,
|
get_max_margin_pct,
|
||||||
get_risk_percent,
|
get_risk_percent,
|
||||||
get_sizing_mode,
|
get_sizing_mode,
|
||||||
@@ -90,6 +93,23 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
"""注册交易相关路由。"""
|
"""注册交易相关路由。"""
|
||||||
_nav = require_nav
|
_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:
|
def _settings_dict() -> dict:
|
||||||
return {
|
return {
|
||||||
"trading_mode": get_trading_mode(get_setting),
|
"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:
|
except Exception as exc:
|
||||||
logger.warning("positions recommend refresh failed: %s", 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:
|
if not rec_cache.get("rows") and capital > 0:
|
||||||
try:
|
try:
|
||||||
from product_recommend import list_product_recommendations
|
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(
|
live_rows = filter_affordable_recommendations(
|
||||||
list_product_recommendations(
|
list_product_recommendations(
|
||||||
@@ -859,8 +883,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if live_rows:
|
if live_rows:
|
||||||
rec_cache["rows"] = enrich_recommend_rows(
|
enriched = enrich_recommend_rows(
|
||||||
live_rows, capital, max_margin_pct=max_pct,
|
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")
|
rec_cache["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
except Exception as exc:
|
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,
|
monitor_count=monitor_count,
|
||||||
roll_count=roll_count,
|
roll_count=roll_count,
|
||||||
sizing_mode=sizing,
|
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),
|
risk_percent=get_risk_percent(get_setting),
|
||||||
max_margin_pct=get_max_margin_pct(get_setting),
|
max_margin_pct=get_max_margin_pct(get_setting),
|
||||||
recommend_rows=rec_cache.get("rows") or [],
|
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()
|
conn.close()
|
||||||
sizing = get_sizing_mode(get_setting)
|
sizing = get_sizing_mode(get_setting)
|
||||||
margin_pct = get_max_margin_pct(get_setting)
|
margin_pct = get_max_margin_pct(get_setting)
|
||||||
if sizing == MODE_RISK:
|
if sizing == MODE_AMOUNT:
|
||||||
lots, err = calc_lots_by_risk(
|
lots, err = calc_lots_by_amount(
|
||||||
entry, sl, direction, capital, get_risk_percent(get_setting), sym,
|
entry, sl, direction, get_fixed_amount(get_setting), sym,
|
||||||
max_margin_pct=margin_pct,
|
capital=capital, max_margin_pct=margin_pct,
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
return jsonify({"ok": False, "error": err}), 400
|
return jsonify({"ok": False, "error": err}), 400
|
||||||
|
elif sizing == MODE_FIXED:
|
||||||
|
lots = get_fixed_lots(get_setting)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
lots = max(1, int(d.get("lots") or 1))
|
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
|
||||||
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
|
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
|
||||||
sizing = get_sizing_mode(get_setting)
|
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)
|
sl = float(d.get("stop_loss") or 0)
|
||||||
if sl <= 0:
|
if sl <= 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": "以损定仓模式须填写止损价"}), 400
|
return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400
|
||||||
lots_calc, err = calc_lots_by_risk(
|
lots_calc, err = calc_lots_by_amount(
|
||||||
price, sl, direction, _capital(conn), get_risk_percent(get_setting), sym,
|
price, sl, direction, get_fixed_amount(get_setting), sym,
|
||||||
max_margin_pct=get_max_margin_pct(get_setting),
|
capital=_capital(conn), max_margin_pct=get_max_margin_pct(get_setting),
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": err}), 400
|
return jsonify({"ok": False, "error": err}), 400
|
||||||
lots = lots_calc or lots
|
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)
|
margin_pct = get_max_margin_pct(get_setting)
|
||||||
usage = calc_margin_usage_pct(
|
usage = calc_margin_usage_pct(
|
||||||
_ctp_positions(mode),
|
_ctp_positions(mode),
|
||||||
@@ -1482,11 +1517,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
"""只读数据库缓存,不在请求时拉行情。"""
|
"""只读数据库缓存,不在请求时拉行情。"""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
payload = recommend_payload(
|
payload = _recommend_payload(conn)
|
||||||
conn,
|
|
||||||
live_capital=_capital(conn),
|
|
||||||
max_margin_pct=get_max_margin_pct(get_setting),
|
|
||||||
)
|
|
||||||
return jsonify({"ok": True, **payload})
|
return jsonify({"ok": True, **payload})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1501,11 +1532,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
try:
|
try:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
try:
|
try:
|
||||||
payload = recommend_payload(
|
payload = _recommend_payload(conn)
|
||||||
conn,
|
|
||||||
live_capital=_capital(conn),
|
|
||||||
max_margin_pct=get_max_margin_pct(get_setting),
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
yield sse_format("recommend", {"ok": True, **payload})
|
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_margin_pct=get_max_margin_pct(get_setting),
|
||||||
)
|
)
|
||||||
max_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})
|
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||||
return jsonify({"ok": True, "count": len(rows), **payload})
|
return jsonify({"ok": True, "count": len(rows), **payload})
|
||||||
finally:
|
finally:
|
||||||
@@ -1876,6 +1903,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
init_tables_fn=_init_tables,
|
init_tables_fn=_init_tables,
|
||||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||||
get_max_margin_pct_fn=lambda: get_max_margin_pct(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_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))
|
start_ctp_premarket_connect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
|
||||||
|
|||||||
+70
-32
@@ -1,4 +1,4 @@
|
|||||||
"""期货计仓:固定张数 / 以损定仓(不含币圈全仓杠杆模式)。"""
|
"""期货计仓:固定手数 / 固定金额。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -7,15 +7,17 @@ from typing import Optional
|
|||||||
from contract_specs import get_contract_spec
|
from contract_specs import get_contract_spec
|
||||||
|
|
||||||
MODE_FIXED = "fixed"
|
MODE_FIXED = "fixed"
|
||||||
MODE_RISK = "risk"
|
MODE_AMOUNT = "amount"
|
||||||
|
MODE_RISK = "amount" # 兼容旧配置「以损定仓」
|
||||||
|
|
||||||
# 单笔报单手数上限(防止以损定仓在止损过近时算出超大手数)
|
|
||||||
DEFAULT_MAX_ORDER_LOTS = 50
|
DEFAULT_MAX_ORDER_LOTS = 50
|
||||||
|
|
||||||
|
|
||||||
def normalize_sizing_mode(raw: str) -> str:
|
def normalize_sizing_mode(raw: str) -> str:
|
||||||
m = (raw or MODE_RISK).strip().lower()
|
m = (raw or MODE_FIXED).strip().lower()
|
||||||
return m if m in (MODE_FIXED, MODE_RISK) else MODE_RISK
|
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:
|
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])
|
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(
|
def calc_lots_by_risk(
|
||||||
entry: float,
|
entry: float,
|
||||||
stop_loss: float,
|
stop_loss: float,
|
||||||
@@ -38,39 +96,19 @@ def calc_lots_by_risk(
|
|||||||
max_lots: Optional[int] = None,
|
max_lots: Optional[int] = None,
|
||||||
max_margin_pct: float = 30.0,
|
max_margin_pct: float = 30.0,
|
||||||
) -> tuple[Optional[int], Optional[str]]:
|
) -> tuple[Optional[int], Optional[str]]:
|
||||||
"""以损定仓:返回 (手数, 错误信息)。"""
|
"""策略等场景:按权益百分比风险预算换算手数。"""
|
||||||
try:
|
try:
|
||||||
entry_f = float(entry)
|
|
||||||
sl_f = float(stop_loss)
|
|
||||||
cap = float(capital)
|
cap = float(capital)
|
||||||
rp = float(risk_percent)
|
rp = float(risk_percent)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None, "参数格式错误"
|
return None, "参数格式错误"
|
||||||
if entry_f <= 0 or cap <= 0 or rp <= 0:
|
if cap <= 0 or rp <= 0:
|
||||||
return None, "入场价、资金或风险比例无效"
|
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, "止损方向与入场价不匹配"
|
|
||||||
budget = cap * rp / 100.0
|
budget = cap * rp / 100.0
|
||||||
lots = int(math.floor(budget / per_lot_risk))
|
return calc_lots_by_amount(
|
||||||
if lots < 1:
|
entry, stop_loss, direction, budget, ths_code,
|
||||||
return None, f"按 {rp}% 风险预算,当前止损距离下不足 1 手"
|
capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct,
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = None) -> dict:
|
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,
|
capital: float,
|
||||||
*,
|
*,
|
||||||
max_margin_pct: float = 30.0,
|
max_margin_pct: float = 30.0,
|
||||||
|
trading_mode: str = "simulation",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。"""
|
"""用当前权益与保证金比例补算最大可开手数(兼容旧缓存)。"""
|
||||||
cap = float(capital or 0)
|
cap = float(capital or 0)
|
||||||
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
|
||||||
budget = cap * pct / 100.0 if cap > 0 else 0.0
|
budget = 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] = []
|
enriched: list[dict] = []
|
||||||
for raw in rows:
|
for raw in rows:
|
||||||
row = dict(raw)
|
row = dict(raw)
|
||||||
|
margin_one = 0.0
|
||||||
try:
|
try:
|
||||||
margin_one = float(row.get("margin_one_lot") or 0)
|
margin_one = float(row.get("margin_one_lot") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
margin_one = 0.0
|
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:
|
if margin_one > 0 and budget > 0:
|
||||||
lots = int(math.floor(budget / margin_one))
|
lots = int(math.floor(budget / margin_one))
|
||||||
else:
|
else:
|
||||||
@@ -84,13 +100,32 @@ def enrich_recommend_rows(
|
|||||||
row["max_margin_pct"] = pct
|
row["max_margin_pct"] = pct
|
||||||
status = row.get("status") or ""
|
status = row.get("status") or ""
|
||||||
if lots >= 1 and status in ("ok", "margin_ok"):
|
if lots >= 1 and status in ("ok", "margin_ok"):
|
||||||
|
src = "柜台" if row.get("margin_source") == "ctp" else "估算"
|
||||||
row["status_label"] = (
|
row["status_label"] = (
|
||||||
f"最大 {lots} 手" if status == "ok" else f"最大 {lots} 手·止损偏宽"
|
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)
|
enriched.append(row)
|
||||||
return enriched
|
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(
|
def refresh_recommend_cache(
|
||||||
conn,
|
conn,
|
||||||
capital: float,
|
capital: float,
|
||||||
@@ -164,6 +199,9 @@ def recommend_payload(
|
|||||||
*,
|
*,
|
||||||
live_capital: float,
|
live_capital: float,
|
||||||
max_margin_pct: float = 30.0,
|
max_margin_pct: float = 30.0,
|
||||||
|
trading_mode: str = "simulation",
|
||||||
|
sizing_mode: str = "fixed",
|
||||||
|
fixed_lots: int = 1,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
|
"""读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。"""
|
||||||
payload = load_recommend_cache(conn)
|
payload = load_recommend_cache(conn)
|
||||||
@@ -172,6 +210,10 @@ def recommend_payload(
|
|||||||
payload["capital"] = cap
|
payload["capital"] = cap
|
||||||
payload["max_margin_pct"] = pct
|
payload["max_margin_pct"] = pct
|
||||||
rows = payload.get("rows") or []
|
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)
|
payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
+10
-1
@@ -62,6 +62,8 @@ def start_recommend_worker(
|
|||||||
init_tables_fn: Callable | None = None,
|
init_tables_fn: Callable | None = None,
|
||||||
get_mode_fn: Callable[[], str] | None = None,
|
get_mode_fn: Callable[[], str] | None = None,
|
||||||
get_max_margin_pct_fn: Callable[[], float] | 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,
|
interval: int = CHECK_INTERVAL_SEC,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""后台每日刷新推荐(每小时检查一次是否需更新),并推送给 SSE 订阅者。"""
|
"""后台每日刷新推荐(每小时检查一次是否需更新),并推送给 SSE 订阅者。"""
|
||||||
@@ -83,7 +85,14 @@ def start_recommend_worker(
|
|||||||
)
|
)
|
||||||
cached = load_recommend_cache(conn)
|
cached = load_recommend_cache(conn)
|
||||||
logger.info("品种推荐刷新完成,capital=%.2f rows=%d", capital, len(cached.get("rows") or []))
|
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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
recommend_hub.broadcast("recommend", {"ok": True, **payload})
|
||||||
|
|||||||
+84
-36
@@ -1,5 +1,6 @@
|
|||||||
(function () {
|
(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 list = document.getElementById('position-live-list');
|
||||||
var recommendList = document.getElementById('recommend-list');
|
var recommendList = document.getElementById('recommend-list');
|
||||||
var symInput = document.getElementById('trade-symbol');
|
var symInput = document.getElementById('trade-symbol');
|
||||||
@@ -48,16 +49,17 @@
|
|||||||
return (symInput && symInput.value || '').trim();
|
return (symInput && symInput.value || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRiskMode() {
|
function isFixedMode() {
|
||||||
return sizingMode === 'risk';
|
return sizingMode === 'fixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmountMode() {
|
||||||
|
return sizingMode === 'amount';
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveLots() {
|
function effectiveLots() {
|
||||||
if (isRiskMode()) {
|
var v = parseInt(lotsCalc && lotsCalc.value, 10);
|
||||||
var v = parseInt(lotsCalc && lotsCalc.value, 10);
|
return v > 0 ? v : 0;
|
||||||
return v > 0 ? v : 0;
|
|
||||||
}
|
|
||||||
return parseInt(lotsInput && lotsInput.value, 10) || 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRecommendMaxMaps(data) {
|
function updateRecommendMaxMaps(data) {
|
||||||
@@ -238,9 +240,20 @@
|
|||||||
var entry = entryPrice();
|
var entry = entryPrice();
|
||||||
var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0;
|
var sl = slInput && slInput.value ? parseFloat(slInput.value) : 0;
|
||||||
var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0;
|
var tp = tpInput && tpInput.value ? parseFloat(tpInput.value) : 0;
|
||||||
|
var lots = effectiveLots();
|
||||||
|
var parts = [];
|
||||||
var rr = calcRR(dir, entry, sl, tp);
|
var rr = calcRR(dir, entry, sl, tp);
|
||||||
if (rr) {
|
if (rr) parts.push('盈亏比 ' + rr + ':1');
|
||||||
el.textContent = '盈亏比 ' + 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;
|
el.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
el.textContent = '';
|
el.textContent = '';
|
||||||
@@ -248,6 +261,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastPreviewMetrics = null;
|
||||||
|
|
||||||
function setPriceType(type) {
|
function setPriceType(type) {
|
||||||
priceType = type === 'market' ? 'market' : 'limit';
|
priceType = type === 'market' ? 'market' : 'limit';
|
||||||
@@ -353,7 +367,7 @@
|
|||||||
|
|
||||||
function refreshQuote() {
|
function refreshQuote() {
|
||||||
var sym = selectedSymbol();
|
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;
|
if (!sym) return;
|
||||||
fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots))
|
fetch('/api/trade/quote?symbol=' + encodeURIComponent(sym) + '&lots=' + encodeURIComponent(lots))
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
@@ -381,23 +395,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAutoCalc() {
|
function scheduleAutoCalc() {
|
||||||
if (!isRiskMode()) return;
|
|
||||||
clearTimeout(calcTimer);
|
clearTimeout(calcTimer);
|
||||||
calcTimer = setTimeout(autoCalcLots, 450);
|
calcTimer = setTimeout(autoCalcLots, 450);
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoCalcLots() {
|
function autoCalcLots() {
|
||||||
if (!isRiskMode() || !lotsCalc) return;
|
if (!lotsCalc) return;
|
||||||
var sym = selectedSymbol();
|
var sym = selectedSymbol();
|
||||||
var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0;
|
var entry = entryPrice() || parseFloat(priceInput && priceInput.value) || 0;
|
||||||
var sl = parseFloat(slInput && slInput.value) || 0;
|
var sl = parseFloat(slInput && slInput.value) || 0;
|
||||||
if (!sym || !entry || !sl) {
|
var tp = parseFloat(tpInput && tpInput.value) || 0;
|
||||||
lotsCalc.value = '';
|
if (isFixedMode()) {
|
||||||
lotsCalc.placeholder = '填写止损后自动计算';
|
var fixedLots = parseInt(window.TRADE_FIXED_LOTS, 10) || 1;
|
||||||
checkLotsLimit();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
lotsCalc.placeholder = '计算中…';
|
|
||||||
fetch('/api/trade/preview', {
|
fetch('/api/trade/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -407,20 +437,30 @@
|
|||||||
entry: entry,
|
entry: entry,
|
||||||
price: entry,
|
price: entry,
|
||||||
stop_loss: sl,
|
stop_loss: sl,
|
||||||
take_profit: parseFloat(tpInput && tpInput.value) || 0
|
take_profit: tp
|
||||||
})
|
})
|
||||||
}).then(function (r) { return r.json(); }).then(function (data) {
|
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
lotsCalc.value = '';
|
if (isAmountMode()) {
|
||||||
lotsCalc.placeholder = data.error || '无法计算';
|
lotsCalc.value = '';
|
||||||
|
lotsCalc.placeholder = data.error || '无法计算';
|
||||||
|
}
|
||||||
|
lastPreviewMetrics = null;
|
||||||
|
updateRRDisplay();
|
||||||
|
checkLotsLimit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lotsCalc.value = data.lots;
|
lotsCalc.value = String(data.lots || '');
|
||||||
lotsCalc.placeholder = '填写止损后自动计算';
|
if (lotsInput) lotsInput.value = String(data.lots || '');
|
||||||
|
lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—';
|
||||||
|
lastPreviewMetrics = data.metrics || null;
|
||||||
|
updateRRDisplay();
|
||||||
checkLotsLimit();
|
checkLotsLimit();
|
||||||
scheduleQuote();
|
scheduleQuote();
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
lotsCalc.placeholder = '计算失败';
|
if (isAmountMode()) lotsCalc.placeholder = '计算失败';
|
||||||
|
lastPreviewMetrics = null;
|
||||||
|
updateRRDisplay();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,12 +510,16 @@
|
|||||||
showOrderMsg('开启移动保本须填写止损价', false);
|
showOrderMsg('开启移动保本须填写止损价', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isRiskMode() && lots <= 0) {
|
if (isAmountMode() && lots <= 0) {
|
||||||
showOrderMsg('请填写止损,系统将自动计算手数', false);
|
showOrderMsg('请填写止损,系统将按固定金额自动计算手数', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isRiskMode() && lots <= 0) {
|
if (isFixedMode() && lots <= 0) {
|
||||||
showOrderMsg('请填写手数', false);
|
showOrderMsg('手数无效,请检查系统设置中的固定手数', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lots <= 0) {
|
||||||
|
showOrderMsg('请填写有效手数', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var maxLots = maxLotsForSymbol(sym);
|
var maxLots = maxLotsForSymbol(sym);
|
||||||
@@ -627,10 +671,13 @@
|
|||||||
' · 浮盈' +
|
' · 浮盈' +
|
||||||
(slTpBtn ? ' · ' + slTpBtn : '') +
|
(slTpBtn ? ' · ' + slTpBtn : '') +
|
||||||
(row.sl_order_active ? ' · <span class="text-profit">止损监控中</span>' : '') +
|
(row.sl_order_active ? ' · <span class="text-profit">止损监控中</span>' : '') +
|
||||||
(row.tp_order_active ? ' · <span class="text-profit">止盈监控中</span>' : '') +
|
(row.tp_order_active ? ' · <span class="text-profit">止盈监控中</span>' : '') + '</div>' +
|
||||||
(row.trailing_be ? ' · <span class="text-accent">移动保本' +
|
|
||||||
(row.trailing_r_locked ? '(锁' + row.trailing_r_locked + 'R)' : '') + '</span>' : '') + '</div>' +
|
|
||||||
'<div class="pos-metrics">' +
|
'<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>' + 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.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>' +
|
'<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.price != null ? r.price : '—') + '</td>' +
|
||||||
'<td>' + (r.ref_stop_loss != null ? r.ref_stop_loss : '—') + '</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.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.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>' + (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>' +
|
'<td><span class="badge ' + (r.status === 'ok' ? 'profit' : 'planned') + '">' + (r.status_label || '') + '</span></td>' +
|
||||||
@@ -904,10 +951,6 @@
|
|||||||
checkLotsLimit();
|
checkLotsLimit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (lotsInput) lotsInput.addEventListener('input', function () {
|
|
||||||
scheduleQuote();
|
|
||||||
checkLotsLimit();
|
|
||||||
});
|
|
||||||
if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit);
|
if (lotsCalc) lotsCalc.addEventListener('input', checkLotsLimit);
|
||||||
if (slInput) {
|
if (slInput) {
|
||||||
slInput.addEventListener('input', function () {
|
slInput.addEventListener('input', function () {
|
||||||
@@ -945,6 +988,10 @@
|
|||||||
|
|
||||||
runWhenReady(function () {
|
runWhenReady(function () {
|
||||||
setPriceType('limit');
|
setPriceType('limit');
|
||||||
|
if (isFixedMode() && lotsCalc) {
|
||||||
|
lotsCalc.value = String(window.TRADE_FIXED_LOTS || 1);
|
||||||
|
if (lotsInput) lotsInput.value = lotsCalc.value;
|
||||||
|
}
|
||||||
var cached = loadPosCache();
|
var cached = loadPosCache();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
applyPositionsData(cached);
|
applyPositionsData(cached);
|
||||||
@@ -965,5 +1012,6 @@
|
|||||||
updateSessionUi();
|
updateSessionUi();
|
||||||
updateRRDisplay();
|
updateRRDisplay();
|
||||||
scheduleQuote();
|
scheduleQuote();
|
||||||
|
scheduleAutoCalc();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+27
-6
@@ -54,14 +54,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>计仓模式</label>
|
<label>计仓模式</label>
|
||||||
<select name="position_sizing_mode">
|
<select name="position_sizing_mode" id="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>
|
||||||
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field" id="field-fixed-lots" {% if position_sizing_mode in ('amount', 'risk') %}hidden{% endif %}>
|
||||||
<label>单笔风险比例(以损定仓,%)</label>
|
<label>固定手数(手)</label>
|
||||||
<input name="risk_percent" type="number" step="0.1" min="0.1" max="100" value="{{ risk_percent }}">
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>保证金占用上限(%)</label>
|
<label>保证金占用上限(%)</label>
|
||||||
@@ -146,3 +150,20 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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">
|
<div class="status-row">
|
||||||
<span class="text-muted">计仓</span>
|
<span class="text-muted">计仓</span>
|
||||||
<strong id="sizing-label">{{ sizing_mode_label }}</strong>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,8 +57,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="trade-field" id="field-lots">
|
<div class="trade-field" id="field-lots">
|
||||||
<label class="text-label">手数</label>
|
<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="—">
|
||||||
<input type="text" id="trade-lots-calc" class="lots-auto" readonly placeholder="填写止损后自动计算" {% if sizing_mode != 'risk' %}hidden{% endif %}>
|
<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>
|
<p class="hint lots-warn text-loss" id="lots-warn" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,8 +99,6 @@
|
|||||||
<p class="hint" id="trade-metrics-hint">填写品种后显示精度与每跳价值;策略自动化请用 <a href="{{ url_for('strategy_page') }}">策略交易</a>。</p>
|
<p class="hint" id="trade-metrics-hint">填写品种后显示精度与每跳价值;策略自动化请用 <a href="{{ url_for('strategy_page') }}">策略交易</a>。</p>
|
||||||
{% if ctp_status.last_error %}
|
{% if ctp_status.last_error %}
|
||||||
<p class="text-loss ctp-install-hint" style="font-size:.78rem;margin-top:.35rem">{{ ctp_status.last_error }}</p>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +116,9 @@
|
|||||||
<div class="card trade-card trade-card-full" id="recommend">
|
<div class="card trade-card trade-card-full" id="recommend">
|
||||||
<h2>品种推荐</h2>
|
<h2>品种推荐</h2>
|
||||||
<div class="card-body">
|
<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 %}
|
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<div class="trade-table-wrap">
|
<div class="trade-table-wrap">
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
<td>{% if r.price %}{{ r.price }}{% else %}—{% endif %}</td>
|
<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_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.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.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>{% 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>
|
<td><span class="badge {% if r.status=='ok' %}profit{% else %}planned{% endif %}">{{ r.status_label }}</span></td>
|
||||||
@@ -154,7 +158,8 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
window.TRADE_SIZING_MODE = {{ sizing_mode|tojson }};
|
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>
|
||||||
<script src="{{ url_for('static', filename='js/trade.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/trade.js') }}"></script>
|
||||||
{% endblock %}
|
{% 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:
|
def get_sizing_mode(get_setting: Callable[[str, str], str]) -> str:
|
||||||
from position_sizing import normalize_sizing_mode
|
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:
|
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:
|
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)
|
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]]:
|
def _collect_positions(self) -> list[dict[str, Any]]:
|
||||||
if not self._engine:
|
if not self._engine:
|
||||||
return []
|
return []
|
||||||
@@ -1074,6 +1096,17 @@ def ctp_get_tick_detail(mode: str, ths_code: str) -> dict[str, Any]:
|
|||||||
return {}
|
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]:
|
def get_ctp_balance(mode: str) -> Optional[float]:
|
||||||
try:
|
try:
|
||||||
acc = ctp_get_account(mode)
|
acc = ctp_get_account(mode)
|
||||||
|
|||||||
Reference in New Issue
Block a user