diff --git a/install_trading.py b/install_trading.py index ea5bf59..c4ce3a6 100644 --- a/install_trading.py +++ b/install_trading.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import logging from datetime import datetime from typing import Any, Callable, Optional @@ -18,10 +19,17 @@ from position_sizing import ( calc_order_tick_metrics, normalize_sizing_mode, ) -from recommend_store import load_recommend_cache, refresh_recommend_cache +from recommend_store import load_recommend_cache, recommend_payload, refresh_recommend_cache from recommend_stream import recommend_hub, start_recommend_worker from ctp_reconnect import start_ctp_reconnect_worker from ctp_fee_worker import start_ctp_fee_worker +from sl_tp_guard import ( + ensure_monitor_order_columns, + monitor_order_status, + place_monitor_exit_orders, + start_sl_tp_guard_worker, + sync_all_sl_tp_orders, +) from risk.account_risk_lib import ( assert_can_open, get_risk_status, @@ -58,6 +66,9 @@ from vnpy_bridge import ( ) +logger = logging.getLogger(__name__) + + def install_trading(app, *, login_required, require_nav, get_db, get_setting, set_setting, fetch_price, send_wechat_msg): """注册交易相关路由。""" _nav = require_nav @@ -238,10 +249,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) rows: list[dict] = [] + capital = _capital(conn) if not ctp_st.get("connected"): return rows + ensure_monitor_order_columns(conn) + # 程序监控仅用于补充止损/止盈,持仓以 CTP 柜台为准 monitor_map: dict[tuple[str, str], dict] = {} for r in conn.execute( @@ -271,6 +285,46 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se break sl = float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None tp = 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 "" + holding = _holding_duration(open_time, now_iso) if open_time else "" + mark = None + if codes: + mark = fetch_price( + sym, + codes.get("market_code", ""), + codes.get("sina_code", ""), + ) + close_est = float(mark) if mark and mark > 0 else entry + fee_info = calc_fee_breakdown( + sym, + entry, + close_est, + lots, + open_time or now_iso, + now_iso, + trading_mode=mode, + ) + est_net = None + if float_pnl is not None: + est_net = round(float(float_pnl) - fee_info["total_fee"], 2) + pos_metrics = calc_position_metrics( + direction, + entry, + sl if sl is not None else entry, + tp if tp is not None else entry, + lots, + mark, + capital, + sym, + ) + order_st = monitor_order_status( + mon or {}, mode=mode, ths_code=sym, direction=direction, + ) + can_place = bool( + mon + and (mon.get("stop_loss") is not None or mon.get("take_profit") is not None) + and (order_st.get("needs_sl_order") or order_st.get("needs_tp_order")) + ) pending_for_row: list[dict] = [] if sl is not None: pending_for_row.append({ @@ -303,8 +357,21 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "entry_price": entry, "stop_loss": sl, "take_profit": tp, - "mark_price": None, + "open_time": open_time or None, + "holding_duration": holding or None, + "mark_price": mark, + "current_price": mark, + "margin": pos_metrics.get("margin"), + "position_pct": pos_metrics.get("position_pct"), "float_pnl": float_pnl, + "est_fee": fee_info["total_fee"], + "est_fee_open": fee_info["open_fee"], + "est_fee_close": fee_info["close_fee"], + "est_fee_close_type": fee_info["close_type"], + "est_pnl_net": est_net, + "sl_order_active": order_st.get("sl_order_active"), + "tp_order_active": order_st.get("tp_order_active"), + "can_place_orders": can_place, "tick_value_total": tick.get("tick_value_total"), "price_precision": tick.get("price_precision"), "tick_size": tick.get("tick_size"), @@ -341,7 +408,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se ).fetchone()["n"] conn.commit() sizing = get_sizing_mode(get_setting) - rec_cache = load_recommend_cache(conn) + rec_cache = recommend_payload(conn, live_capital=capital) return render_template( "trade.html", trading_mode=mode, @@ -376,6 +443,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) _sync_trade_monitors_with_ctp(conn, mode) + sync_all_sl_tp_orders(conn, mode) rows = _build_trading_live_rows(conn) pending_orders = _build_pending_orders(conn, mode) capital = _capital(conn) @@ -392,6 +460,143 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se finally: conn.close() + @app.route("/api/trading/monitor/upsert", methods=["POST"]) + @login_required + def api_trading_monitor_upsert(): + """为已有 CTP 持仓补充/更新本地止盈止损监控。""" + d = request.get_json(silent=True) or {} + sym = (d.get("symbol_code") or d.get("symbol") or "").strip() + direction = (d.get("direction") or "long").strip().lower() + try: + lots = max(1, int(d.get("lots") or 1)) + entry = float(d.get("entry_price") or d.get("entry") or 0) + sl = float(d["stop_loss"]) if d.get("stop_loss") not in (None, "") else None + tp = float(d["take_profit"]) if d.get("take_profit") not in (None, "") else None + except (TypeError, ValueError, KeyError): + return jsonify({"ok": False, "error": "参数无效"}), 400 + if not sym: + return jsonify({"ok": False, "error": "缺少品种代码"}), 400 + if sl is None and tp is None: + return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400 + mode = get_trading_mode(get_setting) + if not ctp_status(mode).get("connected"): + return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 + has_pos = False + 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): + has_pos = True + lots = int(p.get("lots") or lots) + entry = float(p.get("avg_price") or entry or 0) + sym = (p.get("symbol") or sym).strip() + break + if not has_pos: + return jsonify({"ok": False, "error": "柜台无对应持仓"}), 400 + conn = get_db() + try: + init_strategy_tables(conn) + mon = None + 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 + break + codes = ths_to_codes(sym) + now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if mon: + conn.execute( + """UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=? + WHERE id=?""", + (sl, tp, lots, entry or mon.get("entry_price"), mon["id"]), + ) + mid = mon["id"] + else: + conn.execute( + """INSERT INTO trade_order_monitors ( + symbol, symbol_name, market_code, direction, lots, entry_price, + stop_loss, take_profit, open_time, monitor_type, status + ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", + ( + sym, + codes.get("name", sym) if codes else sym, + codes.get("market_code", "") if codes else "", + direction, + lots, + entry, + sl, + tp, + now_s, + "manual", + ), + ) + mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.commit() + mon_row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=?", (mid,), + ).fetchone() + if mon_row and (sl is not None or tp is not None): + try: + ensure_monitor_order_columns(conn) + place_monitor_exit_orders(conn, dict(mon_row), mode=mode, force=False) + except Exception as exc: + logger.warning("补充止盈止损后自动委托失败: %s", exc) + return jsonify({"ok": True, "monitor_id": mid, "message": "止盈止损已保存"}) + finally: + conn.close() + + @app.route("/api/trading/monitor/place-orders", methods=["POST"]) + @login_required + def api_trading_monitor_place_orders(): + """按开仓快照向 CTP 挂止盈止损平仓委托。""" + d = request.get_json(silent=True) or {} + try: + monitor_id = int(d.get("monitor_id") or 0) + except (TypeError, ValueError): + monitor_id = 0 + conn = get_db() + try: + init_strategy_tables(conn) + ensure_monitor_order_columns(conn) + mode = get_trading_mode(get_setting) + if not ctp_status(mode).get("connected"): + return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 + mon = None + if monitor_id > 0: + row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", + (monitor_id,), + ).fetchone() + mon = dict(row) if row else None + if not mon: + sym = (d.get("symbol_code") or "").strip() + direction = (d.get("direction") or "long").strip().lower() + 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 + break + if not mon: + return jsonify({"ok": False, "error": "未找到有效监控快照"}), 404 + result = place_monitor_exit_orders( + conn, mon, mode=mode, force=bool(d.get("force")), + ) + if not result.get("ok"): + return jsonify(result), 400 + return jsonify(result) + finally: + conn.close() + @app.route("/api/trading/monitor/dismiss", methods=["POST"]) @login_required def api_trading_monitor_dismiss(): @@ -646,40 +851,49 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if offset.startswith("open"): sl = d.get("stop_loss") tp = d.get("take_profit") - if sl or tp: - import time - time.sleep(2.0) - actual_lots = lots - has_pos = False - 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): - has_pos = True - actual_lots = int(p.get("lots") or lots) - break - if has_pos: - codes = ths_to_codes(sym) - conn.execute( - """INSERT INTO trade_order_monitors ( - symbol, symbol_name, market_code, direction, lots, entry_price, - stop_loss, take_profit, open_time, monitor_type, status - ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", - ( - sym, - codes.get("name", sym) if codes else sym, - codes.get("market_code", "") if codes else "", - direction, - actual_lots, - price, - float(sl) if sl else None, - float(tp) if tp else None, - datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "manual", - ), - ) + import time + time.sleep(2.0) + actual_lots = lots + has_pos = False + 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): + has_pos = True + actual_lots = int(p.get("lots") or lots) + break + if has_pos: + codes = ths_to_codes(sym) + conn.execute( + """INSERT INTO trade_order_monitors ( + symbol, symbol_name, market_code, direction, lots, entry_price, + stop_loss, take_profit, open_time, monitor_type, status + ) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""", + ( + sym, + codes.get("name", sym) if codes else sym, + codes.get("market_code", "") if codes else "", + direction, + actual_lots, + price, + float(sl) if sl else None, + float(tp) if tp else None, + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "manual", + ), + ) + mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + mon_row = conn.execute( + "SELECT * FROM trade_order_monitors WHERE id=?", (mid,), + ).fetchone() + if mon_row and (sl or tp): + try: + ensure_monitor_order_columns(conn) + place_monitor_exit_orders(conn, dict(mon_row), mode=mode, force=False) + except Exception as exc: + logger.warning("开仓后自动挂止盈止损失败: %s", exc) conn.commit() send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") conn.close() @@ -760,7 +974,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se """只读数据库缓存,不在请求时拉行情。""" conn = get_db() try: - payload = load_recommend_cache(conn) + payload = recommend_payload(conn, live_capital=_capital(conn)) return jsonify({"ok": True, **payload}) finally: conn.close() @@ -775,7 +989,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se try: conn = get_db() try: - payload = load_recommend_cache(conn) + payload = recommend_payload(conn, live_capital=_capital(conn)) finally: conn.close() yield sse_format("recommend", {"ok": True, **payload}) @@ -806,8 +1020,9 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se try: init_strategy_tables(conn) capital = _capital(conn) - rows = refresh_recommend_cache(conn, capital, _main_quote) - payload = load_recommend_cache(conn) + mode = get_trading_mode(get_setting) + rows = refresh_recommend_cache(conn, capital, _main_quote, trading_mode=mode) + payload = recommend_payload(conn, live_capital=capital) recommend_hub.broadcast("recommend", {"ok": True, **payload}) return jsonify({"ok": True, "count": len(rows), **payload}) finally: @@ -1139,8 +1354,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se get_capital_fn=_capital, quote_fn=_main_quote, init_tables_fn=_init_tables, + get_mode_fn=lambda: get_trading_mode(get_setting), ) start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting)) + start_sl_tp_guard_worker( + db_path=DB_PATH, + get_mode_fn=lambda: get_trading_mode(get_setting), + init_tables_fn=_init_tables, + ) start_ctp_fee_worker( get_mode_fn=lambda: get_trading_mode(get_setting), get_setting_fn=get_setting, diff --git a/product_recommend.py b/product_recommend.py index 13b0630..1f6377a 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -5,6 +5,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Callable, Optional from contract_specs import get_contract_spec +from fee_specs import calc_fee_breakdown from symbols import PRODUCTS @@ -21,6 +22,8 @@ def assess_product_for_capital( *, max_position_pct: float = 50.0, default_stop_ticks: int = 20, + reward_risk_ratio: float = 2.0, + trading_mode: str = "simulation", ) -> dict: """评估单品种在当前资金下是否可交易。""" ths = product.get("ths") or "" @@ -50,6 +53,12 @@ def assess_product_for_capital( stop_dist = tick * default_stop_ticks risk_one_lot = stop_dist * mult risk_pct_1lot = (risk_one_lot / cap * 100) if cap > 0 else 999.0 + ref_sl = round(p - stop_dist, 4) + ref_tp = round(p + stop_dist * reward_risk_ratio, 4) + fee_ths = ths + "8888" + fee_info = calc_fee_breakdown( + fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode, + ) can_margin = cap >= min_capital can_risk = cap > 0 and risk_one_lot <= cap * 0.01 @@ -72,6 +81,10 @@ def assess_product_for_capital( "min_capital_one_lot": round(min_capital, 2), "risk_one_lot_1pct": round(risk_one_lot, 2), "risk_pct_1lot_at_1pct_rule": round(risk_pct_1lot, 2), + "ref_stop_loss": ref_sl, + "ref_take_profit": ref_tp, + "open_fee_one_lot": fee_info["open_fee"], + "roundtrip_fee_one_lot": fee_info["total_fee"], "status": status, "status_label": label, } @@ -82,6 +95,7 @@ def list_product_recommendations( quote_fn: Callable[[str], Optional[dict]], *, max_position_pct: float = 50.0, + trading_mode: str = "simulation", ) -> list[dict]: """扫描全部品种并排序:推荐 > 可开 > 不足。quote_fn(品种代码) -> {price, ths_code, ...}""" @@ -90,7 +104,9 @@ def list_product_recommendations( quote = quote_fn(ths) or {} price = quote.get("price") row = assess_product_for_capital( - product, capital, price, max_position_pct=max_position_pct + product, capital, price, + max_position_pct=max_position_pct, + trading_mode=trading_mode, ) main_code = (quote.get("ths_code") or "").strip() row["main_code"] = main_code diff --git a/recommend_store.py b/recommend_store.py index 51b9308..fafdf06 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -30,10 +30,12 @@ def refresh_recommend_cache( conn, capital: float, quote_fn: Callable[[str], Optional[dict]], + *, + trading_mode: str = "simulation", ) -> list[dict]: """后台拉行情、筛选并写入数据库。""" ensure_recommend_tables(conn) - all_rows = list_product_recommendations(capital, quote_fn) + all_rows = list_product_recommendations(capital, quote_fn, trading_mode=trading_mode) rows = filter_affordable_recommendations(all_rows) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn.execute( @@ -49,18 +51,39 @@ def refresh_recommend_cache( return rows +def recommend_cache_stale(updated_at: Optional[str], *, now: Optional[datetime] = None) -> bool: + """缓存是否不是今日更新(需重新拉行情计算)。""" + if not updated_at: + return True + try: + cached_day = datetime.strptime(str(updated_at)[:10], "%Y-%m-%d").date() + except ValueError: + return True + today = (now or datetime.now()).date() + return cached_day != today + + def load_recommend_cache(conn) -> dict: """优先从数据库读取推荐列表。""" ensure_recommend_tables(conn) row = conn.execute("SELECT capital, rows_json, updated_at FROM product_recommend_cache WHERE id=1").fetchone() if not row: - return {"capital": 0.0, "rows": [], "updated_at": None} + return {"capital": 0.0, "rows": [], "updated_at": None, "stale": True} try: rows = json.loads(row["rows_json"] or "[]") except (TypeError, ValueError, json.JSONDecodeError): rows = [] + updated_at = row["updated_at"] return { "capital": float(row["capital"] or 0), "rows": rows if isinstance(rows, list) else [], - "updated_at": row["updated_at"], + "updated_at": updated_at, + "stale": recommend_cache_stale(updated_at), } + + +def recommend_payload(conn, *, live_capital: float) -> dict: + """读取缓存并附带当前权益(展示用,可能与缓存计算时不同)。""" + payload = load_recommend_cache(conn) + payload["capital"] = float(live_capital or 0) + return payload diff --git a/recommend_stream.py b/recommend_stream.py index 85ba827..7201c60 100644 --- a/recommend_stream.py +++ b/recommend_stream.py @@ -10,11 +10,11 @@ from typing import Callable, Optional from db_conn import connect_db from kline_stream import sse_format -from recommend_store import load_recommend_cache, refresh_recommend_cache +from recommend_store import load_recommend_cache, recommend_cache_stale, refresh_recommend_cache logger = logging.getLogger(__name__) -REFRESH_INTERVAL_SEC = 60 +CHECK_INTERVAL_SEC = 3600 class RecommendStreamHub: @@ -55,9 +55,10 @@ def start_recommend_worker( get_capital_fn: Callable, quote_fn: Callable[[str], Optional[dict]], init_tables_fn: Callable | None = None, - interval: int = REFRESH_INTERVAL_SEC, + get_mode_fn: Callable[[], str] | None = None, + interval: int = CHECK_INTERVAL_SEC, ) -> None: - """后台定时刷新推荐并推送给 SSE 订阅者。""" + """后台每日刷新推荐(每小时检查一次是否需更新),并推送给 SSE 订阅者。""" def _loop() -> None: while True: @@ -67,13 +68,18 @@ def start_recommend_worker( if init_tables_fn: init_tables_fn(conn) capital = float(get_capital_fn(conn) or 0) - refresh_recommend_cache(conn, capital, quote_fn) - payload = load_recommend_cache(conn) + mode = get_mode_fn() if get_mode_fn else "simulation" + cached = load_recommend_cache(conn) + if recommend_cache_stale(cached.get("updated_at")): + refresh_recommend_cache(conn, capital, quote_fn, trading_mode=mode) + cached = load_recommend_cache(conn) + logger.info("品种推荐每日刷新完成,capital=%.2f rows=%d", capital, len(cached.get("rows") or [])) + payload = {**cached, "capital": capital} finally: conn.close() recommend_hub.broadcast("recommend", {"ok": True, **payload}) except Exception as exc: logger.warning("recommend worker failed: %s", exc) - time.sleep(max(15, interval)) + time.sleep(max(300, interval)) threading.Thread(target=_loop, daemon=True, name="recommend-worker").start() diff --git a/sl_tp_guard.py b/sl_tp_guard.py new file mode 100644 index 0000000..adf35ac --- /dev/null +++ b/sl_tp_guard.py @@ -0,0 +1,286 @@ +"""止盈止损守护:检测持仓快照,自动/手动向 CTP 挂平仓限价委托。""" +from __future__ import annotations + +import logging +import threading +import time +from typing import Any, Callable, Optional + +from contract_specs import get_contract_spec +from symbols import ths_to_vnpy_symbol +from vnpy_bridge import ( + ctp_list_active_orders, + ctp_list_positions, + ctp_status, + execute_order, +) + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL_SEC = 20 + +MONITOR_ORDER_COLUMNS = ( + "ALTER TABLE trade_order_monitors ADD COLUMN sl_vt_order_id TEXT", + "ALTER TABLE trade_order_monitors ADD COLUMN tp_vt_order_id TEXT", +) + + +def ensure_monitor_order_columns(conn) -> None: + for sql in MONITOR_ORDER_COLUMNS: + try: + conn.execute(sql) + except Exception: + pass + + +def _tick_size(ths_code: str) -> float: + return float(get_contract_spec(ths_code).get("tick_size") or 1.0) + + +def _match_symbol(ctp_sym: str, ths: str) -> bool: + a = (ctp_sym or "").lower() + b = (ths or "").lower() + if a == b: + return True + try: + vnpy_sym, _ = ths_to_vnpy_symbol(ths) + return a == vnpy_sym.lower() + except Exception: + return False + + +def _close_order_direction(hold_direction: str) -> str: + return "short" if hold_direction == "long" else "long" + + +def _price_near(a: float, b: float, tick: float) -> bool: + return abs(float(a) - float(b)) <= max(tick * 0.501, 1e-9) + + +def _find_close_order( + active_orders: list[dict], + *, + ths_code: str, + hold_direction: str, + price: float, + tick: float, +) -> Optional[dict]: + close_dir = _close_order_direction(hold_direction) + for o in active_orders: + sym = o.get("symbol") or "" + if not _match_symbol(sym, ths_code): + continue + offset_s = (o.get("offset") or "").upper() + if "CLOSE" not in offset_s: + continue + if (o.get("direction") or "") != close_dir: + continue + if not _price_near(o.get("price") or 0, price, tick): + continue + return o + return None + + +def _find_position(positions: list[dict], ths_code: str, direction: str) -> Optional[dict]: + for p in positions: + if int(p.get("lots") or 0) <= 0: + continue + if (p.get("direction") or "long") != direction: + continue + if _match_symbol(p.get("symbol") or "", ths_code): + return p + return None + + +def _order_still_active(active_orders: list[dict], vt_order_id: str) -> bool: + if not vt_order_id: + return False + oid = str(vt_order_id).strip() + for o in active_orders: + if str(o.get("order_id") or "") == oid: + return True + return False + + +def place_monitor_exit_orders( + conn, + mon: dict, + *, + mode: str, + force: bool = False, +) -> dict[str, Any]: + """按开仓快照中的止损/止盈价,向 CTP 挂平仓限价单(缺则补)。""" + ensure_monitor_order_columns(conn) + if not ctp_status(mode).get("connected"): + return {"ok": False, "error": "CTP 未连接", "placed": []} + + sym = (mon.get("symbol") or "").strip() + direction = (mon.get("direction") or "long").strip().lower() + sl = mon.get("stop_loss") + tp = mon.get("take_profit") + try: + sl_f = float(sl) if sl is not None else None + tp_f = float(tp) if tp is not None else None + except (TypeError, ValueError): + sl_f, tp_f = None, None + + if sl_f is None and tp_f is None: + return {"ok": False, "error": "快照无止盈止损,无法委托", "placed": []} + + positions = ctp_list_positions(mode) + pos = _find_position(positions, sym, direction) + if not pos: + return {"ok": False, "error": "柜台无对应持仓", "placed": []} + + lots = int(pos.get("lots") or mon.get("lots") or 1) + active = ctp_list_active_orders(mode) + tick = _tick_size(sym) + offset = "close_long" if direction == "long" else "close_short" + placed: list[str] = [] + updates: dict[str, Optional[str]] = {} + + def _maybe_place(kind: str, price: Optional[float], stored_id: str) -> None: + if price is None or price <= 0: + return + existing = _find_close_order( + active, ths_code=sym, hold_direction=direction, price=price, tick=tick, + ) + if existing: + updates[f"{kind}_vt_order_id"] = str(existing.get("order_id") or stored_id or "") + return + if stored_id and _order_still_active(active, stored_id) and not force: + return + result = execute_order( + conn, + mode=mode, + offset=offset, + symbol=sym, + direction=direction, + lots=lots, + price=price, + order_type="limit", + ) + oid = str(result.get("order_id") or "") + updates[f"{kind}_vt_order_id"] = oid + placed.append(f"{kind}@{price}") + + sl_id = str(mon.get("sl_vt_order_id") or "") + tp_id = str(mon.get("tp_vt_order_id") or "") + _maybe_place("sl", sl_f, sl_id) + _maybe_place("tp", tp_f, tp_id) + + if updates: + sl_new = updates.get("sl_vt_order_id", mon.get("sl_vt_order_id")) + tp_new = updates.get("tp_vt_order_id", mon.get("tp_vt_order_id")) + conn.execute( + "UPDATE trade_order_monitors SET sl_vt_order_id=?, tp_vt_order_id=? WHERE id=?", + (sl_new, tp_new, mon["id"]), + ) + conn.commit() + + if not placed and not updates: + return {"ok": True, "message": "无需新委托", "placed": []} + msg = "已提交: " + ", ".join(placed) if placed else "委托已在柜台" + return {"ok": True, "message": msg, "placed": placed} + + +def monitor_order_status( + mon: dict, + *, + mode: str, + ths_code: str, + direction: str, +) -> dict[str, bool]: + """检查快照价位是否已有对应平仓挂单。""" + sl = mon.get("stop_loss") if mon else None + tp = mon.get("take_profit") if mon else None + try: + sl_f = float(sl) if sl is not None else None + tp_f = float(tp) if tp is not None else None + except (TypeError, ValueError): + sl_f, tp_f = None, None + + if not ctp_status(mode).get("connected"): + return { + "sl_order_active": False, + "tp_order_active": False, + "needs_sl_order": sl_f is not None, + "needs_tp_order": tp_f is not None, + } + + active = ctp_list_active_orders(mode) + tick = _tick_size(ths_code) + sl_active = False + tp_active = False + if sl_f is not None: + sl_active = _find_close_order( + active, ths_code=ths_code, hold_direction=direction, price=sl_f, tick=tick, + ) is not None + if tp_f is not None: + tp_active = _find_close_order( + active, ths_code=ths_code, hold_direction=direction, price=tp_f, tick=tick, + ) is not None + + return { + "sl_order_active": sl_active, + "tp_order_active": tp_active, + "needs_sl_order": sl_f is not None and not sl_active, + "needs_tp_order": tp_f is not None and not tp_active, + } + + +def sync_all_sl_tp_orders(conn, mode: str) -> int: + """扫描全部 active 监控,为缺失的止盈止损自动挂单。返回新挂单数。""" + ensure_monitor_order_columns(conn) + if not ctp_status(mode).get("connected"): + return 0 + placed_n = 0 + rows = conn.execute( + "SELECT * FROM trade_order_monitors WHERE status='active'" + ).fetchall() + for r in rows: + mon = dict(r) + st = monitor_order_status( + mon, mode=mode, ths_code=mon.get("symbol") or "", direction=mon.get("direction") or "long", + ) + if not st.get("needs_sl_order") and not st.get("needs_tp_order"): + continue + if mon.get("stop_loss") is None and mon.get("take_profit") is None: + continue + try: + res = place_monitor_exit_orders(conn, mon, mode=mode, force=False) + placed_n += len(res.get("placed") or []) + except Exception as exc: + logger.warning("SL/TP auto place failed monitor=%s: %s", mon.get("id"), exc) + return placed_n + + +def start_sl_tp_guard_worker( + *, + db_path: str, + get_mode_fn: Callable[[], str], + init_tables_fn: Callable | None = None, + interval: int = CHECK_INTERVAL_SEC, +) -> None: + from db_conn import connect_db + + def _loop() -> None: + time.sleep(8) + while True: + try: + mode = get_mode_fn() + if ctp_status(mode).get("connected"): + conn = connect_db(db_path) + try: + if init_tables_fn: + init_tables_fn(conn) + n = sync_all_sl_tp_orders(conn, mode) + if n: + logger.info("止盈止损守护: 新挂 %d 笔委托", n) + finally: + conn.close() + except Exception as exc: + logger.warning("sl_tp_guard worker: %s", exc) + time.sleep(max(10, interval)) + + threading.Thread(target=_loop, daemon=True, name="sl-tp-guard").start() diff --git a/static/css/trade.css b/static/css/trade.css index abcfef8..c9cb196 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -46,11 +46,16 @@ .pos-pending-right{display:flex;align-items:center;gap:.45rem;flex-shrink:0} .pos-dismiss-btn{padding:.2rem .55rem;font-size:.68rem;border-radius:6px;border:1px solid var(--table-border);background:var(--card-inner);color:var(--text-muted);cursor:pointer;width:auto;min-height:auto;line-height:1.3} .pos-dismiss-btn:disabled{opacity:.55;cursor:wait} +.pos-sl-btn{border-color:var(--accent);color:var(--accent)} .pos-pending-item.sl{border-left:3px solid var(--loss)} .pos-pending-item.tp{border-left:3px solid var(--profit)} .pos-pending-item.ctp{border-left:3px solid var(--accent)} .pos-close-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--loss);background:var(--loss-bg);color:var(--loss);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px} .pos-close-btn:disabled{opacity:.55;cursor:wait} +.pos-card-actions{display:flex;gap:.35rem;flex-shrink:0;align-items:center} +.pos-order-btn{padding:.4rem .85rem;font-size:.78rem;border-radius:8px;border:1px solid var(--accent);background:rgba(56,189,248,.1);color:var(--accent);cursor:pointer;white-space:nowrap;width:auto;flex-shrink:0;min-height:36px} +.pos-order-btn:disabled,.pos-order-btn.pos-order-done{opacity:.55;cursor:default;border-color:var(--table-border);background:var(--card-inner);color:var(--text-muted)} +.pos-order-btn:disabled:not(.pos-order-done){cursor:wait} @media (min-width:768px) and (max-width:1100px){ .trade-split .card{min-height:420px} diff --git a/static/js/trade.js b/static/js/trade.js index ad96681..0757d72 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -371,28 +371,148 @@ function buildPosCard(row) { var pnlClass = row.float_pnl > 0 ? 'pnl-pos' : (row.float_pnl < 0 ? 'pnl-neg' : ''); var pnlText = row.float_pnl != null ? ((row.float_pnl >= 0 ? '+' : '') + fmtNum(row.float_pnl) + ' 元') : '--'; + var netClass = row.est_pnl_net > 0 ? 'pnl-pos' : (row.est_pnl_net < 0 ? 'pnl-neg' : ''); var dirBadge = row.direction_label || (row.direction === 'long' ? '做多' : '做空'); + var openT = (row.open_time || '').replace('T', ' ').slice(0, 16); + var slTpBtn = (!row.stop_loss && !row.take_profit && row.can_close) ? + '' : ''; + var orderBtn = ''; + if (row.monitor_id && (row.stop_loss != null || row.take_profit != null)) { + if (row.can_place_orders) { + orderBtn = ''; + } else { + orderBtn = ''; + } + } var closePayload = encodeURIComponent(JSON.stringify({ source: row.source, symbol_code: row.symbol_code, direction: row.direction, lots: row.lots, mark_price: row.mark_price, monitor_id: row.monitor_id || null })); var closeBtn = row.can_close ? '' : ''; + var actionBtns = (orderBtn || closeBtn) ? + '
按权益 {{ '%.2f'|format(capital) }} 元筛选,仅显示可开 1 手的品种。 - {% if recommend_updated_at %}更新 {{ recommend_updated_at }}{% else %}后台刷新中…{% endif %} +
按权益 {{ '%.2f'|format(capital) }} 元筛选,仅显示可开 1 手的品种;参考止损/止盈按 20 跳、盈亏比 2:1 估算。 + {% if recommend_updated_at %}每日后台更新 · 最近 {{ recommend_updated_at }}{% else %}等待今日后台刷新…{% endif %}
| 品种 | 交易所 | 参考价 | 1手保证金 | 建议最低资金 | 状态 | +品种 | 交易所 | 参考价 | +参考止损 | 参考止盈 | +1手保证金 | 1手手续费 | 建议最低资金 | 状态 | {{ r.name }} {{ r.main_code or r.ths }} | {{ r.exchange }} | {% if r.price %}{{ r.price }}{% else %}—{% endif %} | +{% if r.ref_stop_loss %}{{ r.ref_stop_loss }}{% else %}—{% endif %} | +{% if r.ref_take_profit %}{{ r.ref_take_profit }}{% else %}—{% endif %} | {% if r.margin_one_lot %}{{ r.margin_one_lot }}{% else %}—{% endif %} | +{% if r.open_fee_one_lot is defined and r.open_fee_one_lot is not none %}{{ r.open_fee_one_lot }}{% else %}—{% endif %} | {% if r.min_capital_one_lot %}{{ r.min_capital_one_lot }}{% else %}—{% endif %} | {{ r.status_label }} | {% endfor %} {% else %} -
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 等待后台推送推荐… | ||||||||||||||
| 等待今日后台刷新推荐… | ||||||||||||||