feat: 非交易时段禁开仓、移动保本与交易结果分类。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 13:33:17 +08:00
parent 598a1407e1
commit f31164076f
9 changed files with 387 additions and 49 deletions
+9
View File
@@ -377,6 +377,8 @@ def init_db():
set_setting("risk_percent", "1") set_setting("risk_percent", "1")
if not get_setting("max_margin_pct"): if not get_setting("max_margin_pct"):
set_setting("max_margin_pct", "30") set_setting("max_margin_pct", "30")
if not get_setting("trailing_be_tick_buffer"):
set_setting("trailing_be_tick_buffer", "2")
if not get_setting("fee_source_mode"): if not get_setting("fee_source_mode"):
set_setting("fee_source_mode", "ctp") set_setting("fee_source_mode", "ctp")
set_setting("fee_source_mode", "ctp") set_setting("fee_source_mode", "ctp")
@@ -1654,6 +1656,12 @@ def settings():
except ValueError: except ValueError:
flash("保证金比例无效") flash("保证金比例无效")
return redirect(url_for("settings")) return redirect(url_for("settings"))
try:
tb = int(float(request.form.get("trailing_be_tick_buffer", "2") or 2))
set_setting("trailing_be_tick_buffer", str(max(1, min(20, tb))))
except ValueError:
flash("移动保本缓冲无效")
return redirect(url_for("settings"))
flash("交易模式已保存") flash("交易模式已保存")
elif action == "nav": elif action == "nav":
items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES} items = {k: request.form.get(f"nav_{k}") == "on" for k in NAV_TOGGLES}
@@ -1694,6 +1702,7 @@ def settings():
position_sizing_mode=get_setting("position_sizing_mode", "risk"), position_sizing_mode=get_setting("position_sizing_mode", "risk"),
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"),
nav_items=get_nav_items(get_setting), nav_items=get_nav_items(get_setting),
nav_toggles=NAV_TOGGLES, nav_toggles=NAV_TOGGLES,
) )
+99 -15
View File
@@ -38,6 +38,7 @@ from sl_tp_guard import (
place_monitor_exit_orders, place_monitor_exit_orders,
reconcile_monitors_without_position, reconcile_monitors_without_position,
start_sl_tp_guard_worker, start_sl_tp_guard_worker,
write_manual_close_trade_log,
) )
from risk.account_risk_lib import ( from risk.account_risk_lib import (
assert_can_open, assert_can_open,
@@ -61,6 +62,7 @@ from trading_context import (
get_max_margin_pct, get_max_margin_pct,
get_risk_percent, get_risk_percent,
get_sizing_mode, get_sizing_mode,
get_trailing_be_tick_buffer,
get_trading_mode, get_trading_mode,
trading_mode_label, trading_mode_label,
) )
@@ -375,6 +377,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"tick_size": tick.get("tick_size"), "tick_size": tick.get("tick_size"),
"can_close": True, "can_close": True,
"pending_orders": pending_for_row, "pending_orders": pending_for_row,
"trailing_be": bool(mon.get("trailing_be")) if mon else False,
"trailing_r_locked": int(mon.get("trailing_r_locked") or 0) if mon else 0,
}) })
return rows return rows
@@ -535,19 +539,35 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
break break
codes = ths_to_codes(sym) codes = ths_to_codes(sym)
now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if "trailing_be" in d:
trailing_be = 1 if d.get("trailing_be") else 0
elif mon:
trailing_be = int(mon.get("trailing_be") or 0)
else:
trailing_be = 0
ensure_monitor_order_columns(conn)
if mon: if mon:
initial_sl = mon.get("initial_stop_loss")
if sl is not None and initial_sl is None:
initial_sl = sl
conn.execute( conn.execute(
"""UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=? """UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=?,
initial_stop_loss=?, trailing_be=?
WHERE id=?""", WHERE id=?""",
(sl, tp, lots, entry or mon.get("entry_price"), mon["id"]), (
sl, tp, lots, entry or mon.get("entry_price"),
initial_sl, trailing_be,
mon["id"],
),
) )
mid = mon["id"] mid = mon["id"]
else: else:
conn.execute( conn.execute(
"""INSERT INTO trade_order_monitors ( """INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price, symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, open_time, monitor_type, status stop_loss, take_profit, initial_stop_loss, trailing_be,
) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", open_time, monitor_type, status
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""",
( (
sym, sym,
codes.get("name", sym) if codes else sym, codes.get("name", sym) if codes else sym,
@@ -557,6 +577,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
entry, entry,
sl, sl,
tp, tp,
sl,
trailing_be,
now_s, now_s,
"manual", "manual",
), ),
@@ -667,21 +689,67 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
conn.close() conn.close()
return jsonify({"ok": False, "error": "品种或价格无效"}), 400 return jsonify({"ok": False, "error": "品种或价格无效"}), 400
offset = "close_long" if direction == "long" else "close_short" offset = "close_long" if direction == "long" else "close_short"
capital = _capital(conn)
mon = None
mid = int(d.get("monitor_id") or 0)
if mid:
row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status='active'",
(mid,),
).fetchone()
if row:
mon = dict(row)
if not mon:
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active'"
).fetchall():
row = dict(r)
if row.get("direction") != direction:
continue
if _match_ctp_symbol(sym, row.get("symbol") or ""):
mon = row
mid = int(row["id"])
break
entry = float(mon.get("entry_price") or 0) if mon else 0.0
if entry <= 0:
for p in _ctp_positions(mode):
if int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
continue
if _match_ctp_symbol(p.get("symbol") or "", sym):
entry = float(p.get("avg_price") or price)
break
try: try:
execute_order( execute_order(
conn, mode=mode, offset=offset, symbol=sym, direction=direction, conn, mode=mode, offset=offset, symbol=sym, direction=direction,
lots=lots, price=price, settings=_settings_dict(), lots=lots, price=price, settings=_settings_dict(),
order_type="market",
) )
if source == "program": write_manual_close_trade_log(
mid = int(d.get("monitor_id") or 0) conn,
if mid: mon,
conn.execute( symbol=sym,
"UPDATE trade_order_monitors SET status='closed' WHERE id=?", direction=direction,
(mid,), lots=lots,
) close_price=price,
entry_price=entry or price,
trading_mode=mode,
capital=capital,
stop_loss=float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None,
take_profit=float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None,
open_time=(mon.get("open_time") or "") if mon else "",
symbol_name=(mon.get("symbol_name") or "") if mon else "",
market_code=(mon.get("market_code") or "") if mon else "",
)
if mid:
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
conn.commit() conn.commit()
conn.close() conn.close()
return jsonify({"ok": True}) return jsonify({"ok": True, "message": "已平仓并记入交易记录(手动平仓)"})
except ValueError as exc: except ValueError as exc:
conn.close() conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400 return jsonify({"ok": False, "error": str(exc)}), 400
@@ -830,6 +898,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
mode = get_trading_mode(get_setting) mode = get_trading_mode(get_setting)
if offset.startswith("open"): if offset.startswith("open"):
_sync_trade_monitors_with_ctp(conn, mode) _sync_trade_monitors_with_ctp(conn, mode)
if not is_trading_session():
conn.close()
return jsonify({"ok": False, "error": "不在交易时间段"}), 403
if d.get("trailing_be") and not d.get("stop_loss"):
conn.close()
return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400
err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode)) err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode))
if err: if err:
conn.close() conn.close()
@@ -886,9 +960,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
settings=_settings_dict(), settings=_settings_dict(),
order_type=order_type, order_type=order_type,
) )
if offset.startswith("open") and d.get("trailing_be") and not d.get("stop_loss"):
conn.close()
return jsonify({"ok": False, "error": "开启移动保本须填写止损价"}), 400
if offset.startswith("open"): if offset.startswith("open"):
sl = d.get("stop_loss") sl = d.get("stop_loss")
tp = d.get("take_profit") tp = d.get("take_profit")
trailing_be = 1 if d.get("trailing_be") else 0
import time import time
time.sleep(2.0) time.sleep(2.0)
actual_lots = lots actual_lots = lots
@@ -904,11 +982,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
break break
if has_pos: if has_pos:
codes = ths_to_codes(sym) codes = ths_to_codes(sym)
sl_f = float(sl) if sl else None
ensure_monitor_order_columns(conn)
conn.execute( conn.execute(
"""INSERT INTO trade_order_monitors ( """INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price, symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, open_time, monitor_type, status stop_loss, take_profit, initial_stop_loss, trailing_be,
) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", open_time, monitor_type, status
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?, 'active')""",
( (
sym, sym,
codes.get("name", sym) if codes else sym, codes.get("name", sym) if codes else sym,
@@ -916,8 +997,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
direction, direction,
actual_lots, actual_lots,
price, price,
float(sl) if sl else None, sl_f,
float(tp) if tp else None, float(tp) if tp else None,
sl_f,
trailing_be,
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"manual", "manual",
), ),
@@ -1414,6 +1497,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
get_mode_fn=lambda: get_trading_mode(get_setting), get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables, init_tables_fn=_init_tables,
get_capital_fn=_capital, get_capital_fn=_capital,
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
notify_fn=send_wechat_msg, notify_fn=send_wechat_msg,
interval=1, interval=1,
) )
+222 -27
View File
@@ -37,8 +37,13 @@ _closing_lock = threading.Lock()
MONITOR_ORDER_COLUMNS = ( MONITOR_ORDER_COLUMNS = (
"ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT", "ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT",
"ALTER TABLE trade_order_monitors ADD COLUMN tp_vt_order_id TEXT", "ALTER TABLE trade_order_monitors ADD COLUMN tp_vt_order_id TEXT",
"ALTER TABLE trade_order_monitors ADD COLUMN trailing_be INTEGER DEFAULT 0",
"ALTER TABLE trade_order_monitors ADD COLUMN initial_stop_loss REAL",
"ALTER TABLE trade_order_monitors ADD COLUMN trailing_r_locked INTEGER DEFAULT 0",
) )
TRADE_RESULTS = ("止损", "止盈", "移动止盈", "保本止盈", "手动平仓")
def ensure_monitor_order_columns(conn) -> None: def ensure_monitor_order_columns(conn) -> None:
for sql in MONITOR_ORDER_COLUMNS: for sql in MONITOR_ORDER_COLUMNS:
@@ -139,31 +144,55 @@ def _monitor_type_label(raw: str) -> str:
return mapping.get(raw or "", raw or "程序监控") return mapping.get(raw or "", raw or "程序监控")
def _write_trade_log( def _result_for_close(mon: dict, reason: str) -> str:
"""平仓结果:止损 / 止盈 / 移动止盈 / 保本止盈 / 手动平仓。"""
if reason == "manual":
return "手动平仓"
if reason == "take_profit":
return "止盈"
if not mon.get("trailing_be"):
return "止损"
locked = int(mon.get("trailing_r_locked") or 0)
if locked >= 2:
return "移动止盈"
if locked >= 1:
return "保本止盈"
return "止损"
def write_trade_log(
conn, conn,
mon: dict,
*, *,
symbol: str,
direction: str,
entry_price: float,
close_price: float, close_price: float,
reason: str, lots: float,
result: str,
trading_mode: str, trading_mode: str,
stop_loss: Optional[float] = None,
take_profit: Optional[float] = None,
open_time: str = "",
symbol_name: str = "",
market_code: str = "",
sina_code: str = "",
monitor_type: str = "期货下单",
capital: float = 0.0, capital: float = 0.0,
) -> None: ) -> None:
"""止盈/止损触发平仓后写入 trade_logs。""" """写入 trade_logs(程序平仓 / 手动平仓)"""
sym = (mon.get("symbol") or "").strip() sym = (symbol or "").strip()
direction = (mon.get("direction") or "long").strip().lower() direction = (direction or "long").strip().lower()
entry = float(mon.get("entry_price") or close_price) entry = float(entry_price or close_price)
sl_raw = mon.get("stop_loss") sl = float(stop_loss) if stop_loss is not None else entry
tp_raw = mon.get("take_profit") tp = float(take_profit) if take_profit is not None else entry
sl = float(sl_raw) if sl_raw is not None else entry
tp = float(tp_raw) if tp_raw is not None else entry
lots = float(mon.get("lots") or 1)
open_time = (mon.get("open_time") or "").strip()
close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M") close_time = datetime.now(TZ).strftime("%Y-%m-%dT%H:%M")
codes = ths_to_codes(sym) or {} if not sina_code or not market_code:
sina_code = codes.get("sina_code") or "" codes = ths_to_codes(sym) or {}
symbol_name = mon.get("symbol_name") or sym sina_code = sina_code or codes.get("sina_code") or ""
market_code = mon.get("market_code") or codes.get("market_code") or "" market_code = market_code or codes.get("market_code") or ""
if not symbol_name:
symbol_name = sym
metrics = calc_position_metrics( metrics = calc_position_metrics(
direction, entry, sl, tp, lots, close_price, capital, sym, direction, entry, sl, tp, lots, close_price, capital, sym,
@@ -173,7 +202,6 @@ def _write_trade_log(
sym, entry, close_price, lots, open_time, close_time, trading_mode=trading_mode, sym, entry, close_price, lots, open_time, close_time, trading_mode=trading_mode,
) )
pnl_net = round(pnl - fee, 2) pnl_net = round(pnl - fee, 2)
result = "止盈" if reason == "take_profit" else "止损"
try: try:
from app import holding_to_minutes from app import holding_to_minutes
@@ -192,11 +220,11 @@ def _write_trade_log(
symbol_name, symbol_name,
market_code, market_code,
sina_code, sina_code,
_monitor_type_label(mon.get("monitor_type") or ""), monitor_type,
direction, direction,
entry, entry,
sl_raw if sl_raw is not None else sl, stop_loss if stop_loss is not None else sl,
tp_raw if tp_raw is not None else tp, take_profit if take_profit is not None else tp,
close_price, close_price,
lots, lots,
metrics.get("margin"), metrics.get("margin"),
@@ -206,14 +234,169 @@ def _write_trade_log(
pnl, pnl,
fee, fee,
pnl_net, pnl_net,
result, result if result in TRADE_RESULTS else "手动平仓",
), ),
) )
try: try:
from stats_engine import refresh_stats_cache from stats_engine import refresh_stats_cache
refresh_stats_cache(conn, capital) refresh_stats_cache(conn, capital)
except Exception as exc: except Exception as exc:
logger.debug("stats refresh after SL/TP close: %s", exc) logger.debug("stats refresh after close: %s", exc)
def _write_trade_log(
conn,
mon: dict,
*,
close_price: float,
reason: str,
trading_mode: str,
capital: float = 0.0,
) -> None:
sym = (mon.get("symbol") or "").strip()
sl_raw = mon.get("stop_loss")
tp_raw = mon.get("take_profit")
initial_sl = mon.get("initial_stop_loss")
write_trade_log(
conn,
symbol=sym,
direction=mon.get("direction") or "long",
entry_price=float(mon.get("entry_price") or close_price),
close_price=close_price,
lots=float(mon.get("lots") or 1),
result=_result_for_close(mon, reason),
trading_mode=trading_mode,
stop_loss=float(initial_sl) if initial_sl is not None else (
float(sl_raw) if sl_raw is not None else None
),
take_profit=float(tp_raw) if tp_raw is not None else None,
open_time=(mon.get("open_time") or "").strip(),
symbol_name=mon.get("symbol_name") or sym,
market_code=mon.get("market_code") or "",
monitor_type=_monitor_type_label(mon.get("monitor_type") or ""),
capital=capital,
)
def write_manual_close_trade_log(
conn,
mon: Optional[dict],
*,
symbol: str,
direction: str,
lots: float,
close_price: float,
entry_price: float,
trading_mode: str,
capital: float = 0.0,
stop_loss: Optional[float] = None,
take_profit: Optional[float] = None,
open_time: str = "",
symbol_name: str = "",
market_code: str = "",
) -> None:
"""程序内点击平仓按钮 → 手动平仓。"""
if mon:
write_trade_log(
conn,
symbol=(mon.get("symbol") or symbol).strip(),
direction=mon.get("direction") or direction,
entry_price=float(mon.get("entry_price") or entry_price),
close_price=close_price,
lots=float(mon.get("lots") or lots),
result="手动平仓",
trading_mode=trading_mode,
stop_loss=float(mon["initial_stop_loss"]) if mon.get("initial_stop_loss") is not None else (
float(mon["stop_loss"]) if mon.get("stop_loss") is not None else stop_loss
),
take_profit=float(mon["take_profit"]) if mon.get("take_profit") is not None else take_profit,
open_time=(mon.get("open_time") or open_time).strip(),
symbol_name=mon.get("symbol_name") or symbol_name,
market_code=mon.get("market_code") or market_code,
monitor_type=_monitor_type_label(mon.get("monitor_type") or ""),
capital=capital,
)
return
write_trade_log(
conn,
symbol=symbol,
direction=direction,
entry_price=entry_price,
close_price=close_price,
lots=lots,
result="手动平仓",
trading_mode=trading_mode,
stop_loss=stop_loss,
take_profit=take_profit,
open_time=open_time,
symbol_name=symbol_name,
market_code=market_code,
capital=capital,
)
def _update_trailing_stop_loss(
conn,
mon: dict,
mark: float,
*,
be_tick_mult: int,
) -> dict:
"""达 1R 移保本(开仓±N跳),达 2R 移 1R,依次类推。"""
if not mon.get("trailing_be"):
return mon
entry = float(mon.get("entry_price") or 0)
initial_sl = mon.get("initial_stop_loss")
if initial_sl is None:
initial_sl = mon.get("stop_loss")
try:
initial_sl_f = float(initial_sl) if initial_sl is not None else None
except (TypeError, ValueError):
return mon
if not entry or initial_sl_f is None:
return mon
direction = (mon.get("direction") or "long").strip().lower()
sym = (mon.get("symbol") or "").strip()
tick = _tick_size(sym)
r = abs(entry - initial_sl_f)
if r < tick * 0.5:
return mon
profit_r = (mark - entry) / r if direction == "long" else (entry - mark) / r
if profit_r < 1.0:
return mon
level = int(profit_r)
locked = int(mon.get("trailing_r_locked") or 0)
if level <= locked:
return mon
if level == 1:
new_sl = entry + be_tick_mult * tick if direction == "long" else entry - be_tick_mult * tick
else:
new_sl = entry + (level - 1) * r if direction == "long" else entry - (level - 1) * r
new_sl = round(new_sl, 4)
try:
current_sl = float(mon.get("stop_loss") or 0)
except (TypeError, ValueError):
current_sl = 0.0
if direction == "long" and new_sl <= current_sl + tick * 0.01:
return mon
if direction == "short" and new_sl >= current_sl - tick * 0.01:
return mon
mid = mon.get("id")
conn.execute(
"UPDATE trade_order_monitors SET stop_loss=?, trailing_r_locked=? WHERE id=?",
(new_sl, level, mid),
)
conn.commit()
mon["stop_loss"] = new_sl
mon["trailing_r_locked"] = level
logger.info("移动保本 monitor=%s %dR 止损→%s", mid, level, new_sl)
return mon
def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool: def _sl_triggered(direction: str, sl: float, mark: float, tick: float) -> bool:
@@ -359,14 +542,14 @@ def _execute_local_close(
) )
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],)) conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
conn.commit() conn.commit()
label = "止盈" if reason == "take_profit" else "止损" result_label = _result_for_close(mon, reason)
logger.info( logger.info(
"止盈止损本地触发 monitor=%s reason=%s %s %s %d手 @%s", "止盈止损本地触发 monitor=%s result=%s %s %s %d手 @%s",
mon.get("id"), reason, sym, direction, lots, mark, mon.get("id"), result_label, sym, direction, lots, mark,
) )
if notify_fn: if notify_fn:
try: try:
notify_fn(f"{label}平仓 {sym} {direction} {lots}手 @{mark},已记入交易记录") notify_fn(f"{result_label} {sym} {direction} {lots}手 @{mark},已记入交易记录")
except Exception as exc: except Exception as exc:
logger.debug("SL/TP notify failed: %s", exc) logger.debug("SL/TP notify failed: %s", exc)
@@ -377,6 +560,7 @@ def check_monitors_locally(
*, *,
capital: float = 0.0, capital: float = 0.0,
notify_fn: Callable[[str], None] | None = None, notify_fn: Callable[[str], None] | None = None,
be_tick_mult: int = 2,
) -> int: ) -> int:
"""扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。""" """扫描 active 监控,本地比对行情;触发止盈/止损(含跳空穿透)后立刻市价平仓并记交易记录。"""
ensure_monitor_order_columns(conn) ensure_monitor_order_columns(conn)
@@ -417,6 +601,13 @@ def check_monitors_locally(
continue continue
tick = _tick_size(sym) tick = _tick_size(sym)
if mon.get("trailing_be"):
mon = _update_trailing_stop_loss(conn, mon, mark, be_tick_mult=be_tick_mult)
try:
sl_f = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else sl_f
except (TypeError, ValueError):
pass
reason = None reason = None
if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick):
reason = "take_profit" reason = "take_profit"
@@ -507,6 +698,7 @@ def start_sl_tp_guard_worker(
get_mode_fn: Callable[[], str], get_mode_fn: Callable[[], str],
init_tables_fn: Callable | None = None, init_tables_fn: Callable | None = None,
get_capital_fn: Callable | None = None, get_capital_fn: Callable | None = None,
get_be_tick_buffer_fn: Callable[[], int] | None = None,
notify_fn: Callable[[str], None] | None = None, notify_fn: Callable[[str], None] | None = None,
interval: int = CHECK_INTERVAL_SEC, interval: int = CHECK_INTERVAL_SEC,
) -> None: ) -> None:
@@ -547,6 +739,9 @@ def start_sl_tp_guard_worker(
mode, mode,
capital=capital, capital=capital,
notify_fn=notify_fn, notify_fn=notify_fn,
be_tick_mult=(
get_be_tick_buffer_fn() if get_be_tick_buffer_fn else 2
),
) )
if n: if n:
logger.info("止盈止损本地监控: 触发平仓 %d", n) logger.info("止盈止损本地监控: 触发平仓 %d", n)
+5 -1
View File
@@ -32,7 +32,11 @@
.market-hint{font-size:.7rem;margin-top:.25rem} .market-hint{font-size:.7rem;margin-top:.25rem}
.trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem} .trade-action-row{display:flex;flex-direction:column;gap:.45rem;margin:.85rem 0 .55rem}
.trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%} .trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%}
.trade-action-row .btn-open:disabled{opacity:.65;cursor:wait} .trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)}
.trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)}
.trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none}
.trailing-be-toggle input{width:auto;margin:0}
.session-hint{font-size:.72rem;margin:.35rem 0 0;text-align:center}
.trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem} .trade-order-msg{font-size:.82rem;text-align:center;margin:0;padding:.35rem}
.trade-order-msg.ok{color:var(--profit)} .trade-order-msg.ok{color:var(--profit)}
.trade-order-msg.err{color:var(--loss)} .trade-order-msg.err{color:var(--loss)}
+30 -3
View File
@@ -116,6 +116,18 @@
} }
} }
function updateSessionUi() {
var btnOpen = document.getElementById('btn-open');
var sessionHint = document.getElementById('session-hint');
if (btnOpen) {
btnOpen.disabled = !isTradingSession;
btnOpen.classList.toggle('btn-session-off', !isTradingSession);
}
if (sessionHint) {
sessionHint.hidden = !!isTradingSession;
}
}
function entryPrice() { function entryPrice() {
if (priceType === 'market') return lastQuotePrice; if (priceType === 'market') return lastQuotePrice;
return parseFloat(priceInput && priceInput.value) || 0; return parseFloat(priceInput && priceInput.value) || 0;
@@ -330,7 +342,17 @@
return; return;
} }
var lots = effectiveLots(); var lots = effectiveLots();
var trailingBeEl = document.getElementById('trailing-be');
if (offset === 'open') { if (offset === 'open') {
if (!isTradingSession) {
showOrderMsg('不在交易时间段', false);
return;
}
var trailingOn = !!(trailingBeEl && trailingBeEl.checked);
if (trailingOn && !(slInput && slInput.value)) {
showOrderMsg('开启移动保本须填写止损价', false);
return;
}
if (isRiskMode() && lots <= 0) { if (isRiskMode() && lots <= 0) {
showOrderMsg('请填写止损,系统将自动计算手数', false); showOrderMsg('请填写止损,系统将自动计算手数', false);
return; return;
@@ -359,7 +381,8 @@
price: price, price: price,
order_type: priceType, order_type: priceType,
stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null, stop_loss: slInput && slInput.value ? parseFloat(slInput.value) : null,
take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null take_profit: tpInput && tpInput.value ? parseFloat(tpInput.value) : null,
trailing_be: !!(trailingBeEl && trailingBeEl.checked)
}; };
fetch('/api/trade/order', { fetch('/api/trade/order', {
method: 'POST', method: 'POST',
@@ -379,8 +402,8 @@
showOrderMsg('网络错误,请重试', false); showOrderMsg('网络错误,请重试', false);
}).finally(function () { }).finally(function () {
if (btnOpen) { if (btnOpen) {
btnOpen.disabled = false;
btnOpen.textContent = '开仓'; btnOpen.textContent = '开仓';
updateSessionUi();
} }
}); });
} }
@@ -471,7 +494,9 @@
'<div class="pos-card-meta">来源 <strong>' + (row.source_label || 'CTP') + '</strong> · 柜台浮盈' + '<div class="pos-card-meta">来源 <strong>' + (row.source_label || 'CTP') + '</strong> · 柜台浮盈' +
(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>' : '') + '</div>' + (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>' +
'<div class="pos-metrics">' + '<div class="pos-metrics">' +
'<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>' +
@@ -646,6 +671,7 @@
return row.stop_loss != null || row.take_profit != null; return row.stop_loss != null || row.take_profit != null;
}); });
schedulePositionPoll(); schedulePositionPoll();
updateSessionUi();
if (!connected) { if (!connected) {
if (connecting) { if (connecting) {
list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>'; list.innerHTML = '<div class="empty-hint">CTP 连接中,请稍候…</div>';
@@ -794,6 +820,7 @@
document.addEventListener('visibilitychange', function () { document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') pollPositions(); if (document.visibilityState === 'visible') pollPositions();
}); });
updateSessionUi();
scheduleQuote(); scheduleQuote();
}); });
})(); })();
+4 -2
View File
@@ -84,6 +84,8 @@
<span class="cell-readonly cell-edit-hide"> <span class="cell-readonly cell-edit-hide">
{% if t.result == '止盈' %}<span class="badge profit">{{ t.result }}</span> {% if t.result == '止盈' %}<span class="badge profit">{{ t.result }}</span>
{% elif t.result == '止损' %}<span class="badge loss">{{ t.result }}</span> {% elif t.result == '止损' %}<span class="badge loss">{{ t.result }}</span>
{% elif t.result == '移动止盈' %}<span class="badge profit">{{ t.result }}</span>
{% elif t.result == '保本止盈' %}<span class="badge profit">{{ t.result }}</span>
{% elif t.result == '手动平仓' %}<span class="badge result-manual">{{ t.result }}</span> {% elif t.result == '手动平仓' %}<span class="badge result-manual">{{ t.result }}</span>
{% else %}<span class="badge result-external">{{ t.result }}</span>{% endif %} {% else %}<span class="badge result-external">{{ t.result }}</span>{% endif %}
{% if t.verified %}<span class="badge active" style="margin-left:.25rem">已核对</span>{% endif %} {% if t.verified %}<span class="badge active" style="margin-left:.25rem">已核对</span>{% endif %}
@@ -92,8 +94,8 @@
<option value="手动平仓" {% if t.result=='手动平仓' %}selected{% endif %}>手动平仓</option> <option value="手动平仓" {% if t.result=='手动平仓' %}selected{% endif %}>手动平仓</option>
<option value="止盈" {% if t.result=='止盈' %}selected{% endif %}>止盈</option> <option value="止盈" {% if t.result=='止盈' %}selected{% endif %}>止盈</option>
<option value="止损" {% if t.result=='止损' %}selected{% endif %}>止损</option> <option value="止损" {% if t.result=='止损' %}selected{% endif %}>止损</option>
<option value="外部平仓" {% if t.result=='外部平仓' %}selected{% endif %}>外部平仓</option> <option value="移动止盈" {% if t.result=='移动止盈' %}selected{% endif %}>移动止盈</option>
<option value="时间平仓" {% if t.result=='时间平仓' %}selected{% endif %}>时间平仓</option> <option value="保本止盈" {% if t.result=='保本止盈' %}selected{% endif %}>保本止盈</option>
</select> </select>
</td> </td>
<td> <td>
+5 -1
View File
@@ -67,10 +67,14 @@
<label>保证金占用上限(%</label> <label>保证金占用上限(%</label>
<input name="max_margin_pct" type="number" step="1" min="1" max="100" value="{{ max_margin_pct }}"> <input name="max_margin_pct" type="number" step="1" min="1" max="100" value="{{ max_margin_pct }}">
</div> </div>
<div class="field">
<label>移动保本缓冲(最小变动价位倍数)</label>
<input name="trailing_be_tick_buffer" type="number" step="1" min="1" max="20" value="{{ trailing_be_tick_buffer }}">
</div>
</div> </div>
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button> <button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
<p class="hint" style="margin-top:.75rem;margin-bottom:0"> <p class="hint" style="margin-top:.75rem;margin-bottom:0">
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。在 <code>.env</code> 配置 <code>SIMNOW_USER</code>,于「持仓监控」连接 CTP;权益与行情优先来自柜台 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳(玉米 N=2 即 +2 点,棉花 N=2 即 +10 点);达 2R 移至 1R,依次类推。<code>.env</code> 配置 <code>SIMNOW_USER</code>,于「持仓监控」连接 CTP。
</p> </p>
</form> </form>
</div> </div>
+5
View File
@@ -81,7 +81,12 @@
</div> </div>
<div class="trade-action-row"> <div class="trade-action-row">
<label class="trailing-be-toggle">
<input type="checkbox" id="trailing-be" checked>
<span>移动保本</span>
</label>
<button type="button" class="btn-primary btn-open" id="btn-open">开仓</button> <button type="button" class="btn-primary btn-open" id="btn-open">开仓</button>
<p class="hint session-hint text-muted" id="session-hint" hidden>不在交易时间段</p>
<p class="trade-order-msg" id="order-msg" hidden></p> <p class="trade-order-msg" id="order-msg" hidden></p>
</div> </div>
+8
View File
@@ -32,6 +32,14 @@ def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
return 30.0 return 30.0
def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
"""移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。"""
try:
return max(1, min(20, int(float(get_setting("trailing_be_tick_buffer", "2") or 2))))
except (TypeError, ValueError):
return 2
def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float: def get_account_capital(conn, get_setting: Callable[[str, str], str]) -> float:
"""优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。""" """优先 SimNow/期货公司 CTP 权益;未连接时用设置中的参考资金。"""
del conn del conn