"""期货下单、品种推荐、策略交易路由注册。""" from __future__ import annotations import json from datetime import datetime from typing import Any, Callable from flask import flash, jsonify, redirect, render_template, request, url_for from contract_specs import calc_position_metrics, get_contract_spec from fee_specs import calc_fee_breakdown from position_sizing import ( MODE_FIXED, MODE_RISK, calc_lots_by_risk, calc_order_tick_metrics, normalize_sizing_mode, ) from product_recommend import list_product_recommendations from risk.account_risk_lib import ( assert_can_open, get_risk_status, on_mood_journal_freeze, on_user_initiated_close, parse_mood_issues, reduce_cooloff_after_journal, trading_day_label, ) from strategy.strategy_db import init_strategy_tables from strategy.strategy_roll_lib import preview_roll from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND from symbols import ths_to_codes, resolve_main_contract, PRODUCTS from trading_context import ( TRADING_MODE_LIVE, TRADING_MODE_SIM, get_account_capital, get_risk_percent, get_sizing_mode, get_trading_mode, trading_mode_label, ) from ctp_symbol import ths_to_vnpy_symbol from vnpy_bridge import ( ctp_connect, ctp_get_account, ctp_list_positions, ctp_status, execute_order, ) def install_trading(app, *, login_required, get_db, get_setting, set_setting, fetch_price, send_wechat_msg): """注册交易相关路由。""" def _settings_dict() -> dict: return { "trading_mode": get_trading_mode(get_setting), "position_sizing_mode": get_sizing_mode(get_setting), "risk_percent": str(get_risk_percent(get_setting)), } def _capital(conn) -> float: return get_account_capital(conn, get_setting) def _main_price(product_ths: str): for p in PRODUCTS: if p["ths"] == product_ths: main = resolve_main_contract(p) if not main: return None sym = main.get("ths_code") or "" codes = ths_to_codes(sym) if codes: return fetch_price(sym, codes.get("market_code", ""), codes.get("sina_code", "")) return None def _ctp_account(mode: str) -> dict: try: return ctp_get_account(mode) except Exception: return {} def _ctp_positions(mode: str) -> list: try: return ctp_list_positions(mode) except Exception: return [] def _match_ctp_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 _holding_duration(open_time: str, now_iso: str) -> str: try: from app import calc_holding_duration return calc_holding_duration(open_time, now_iso) except Exception: return "" def _build_trading_live_rows(conn) -> list[dict]: from zoneinfo import ZoneInfo tz = ZoneInfo("Asia/Shanghai") now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M") capital = _capital(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) rows: list[dict] = [] seen: set[str] = set() ctp_pairs: list[tuple[str, str]] = [] if ctp_st.get("connected"): for p in _ctp_positions(mode): sym = (p.get("symbol") or "").strip() direction = p.get("direction") or "long" lots = int(p.get("lots") or 0) if lots <= 0: continue ctp_pairs.append((sym, direction)) key = f"ctp:{sym.lower()}:{direction}" seen.add(key) entry = float(p.get("avg_price") or 0) codes = ths_to_codes(sym) mark = fetch_price( sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "", ) spec = get_contract_spec(sym) mult = spec["mult"] float_pnl = p.get("pnl") if mark is not None and entry > 0: if direction == "long": float_pnl = round((mark - entry) * mult * lots, 2) else: float_pnl = round((entry - mark) * mult * lots, 2) tick = calc_order_tick_metrics(sym, lots, mark or entry) rows.append({ "key": key, "source": "ctp", "source_label": "CTP 柜台", "symbol": codes.get("name", sym) if codes else sym, "symbol_code": sym, "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "entry_price": entry, "stop_loss": None, "take_profit": None, "mark_price": mark, "float_pnl": float_pnl, "tick_value_total": tick.get("tick_value_total"), "price_precision": tick.get("price_precision"), "tick_size": tick.get("tick_size"), "can_close": True, }) def _dup_ctp(ths_sym: str, direction: str) -> bool: for cs, d in ctp_pairs: if d != direction: continue if cs.lower() == ths_sym.lower() or _match_ctp_symbol(cs, ths_sym): return True return False monitors = conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" ).fetchall() for r in monitors: sym = r["symbol"] direction = r["direction"] if _dup_ctp(sym, direction): continue key = f"mon:{sym.lower()}:{direction}" entry = float(r["entry_price"] or 0) sl = float(r["stop_loss"]) if r["stop_loss"] is not None else None tp = float(r["take_profit"]) if r["take_profit"] is not None else None lots = float(r["lots"] or 1) codes = ths_to_codes(sym) market = r["market_code"] or (codes.get("market_code", "") if codes else "") or "" sina = codes.get("sina_code", "") if codes else "" mark = fetch_price(sym, market, sina) metrics = calc_position_metrics(direction, entry, sl or entry, tp or entry, lots, mark, capital, sym) fee_info = calc_fee_breakdown(sym, entry, mark or entry, lots, r["open_time"] or "", now_iso) est_net = None if metrics.get("float_pnl") is not None: est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2) rows.append({ "key": key, "source": "program", "source_label": r["monitor_type"] or "程序监控", "monitor_id": r["id"], "symbol": r["symbol_name"] or sym, "symbol_code": sym, "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "entry_price": entry, "stop_loss": sl, "take_profit": tp, "mark_price": mark, "open_time": r["open_time"], "holding_duration": _holding_duration(r["open_time"] or "", now_iso), "float_pnl": metrics.get("float_pnl"), "float_pct": metrics.get("float_pct"), "risk_pct": metrics.get("risk_pct"), "risk_amount": metrics.get("risk_amount"), "rr_ratio": metrics.get("rr_ratio"), "margin": metrics.get("margin"), "position_pct": metrics.get("position_pct"), "est_fee": fee_info["total_fee"], "est_pnl_net": est_net, "can_close": ctp_st.get("connected"), }) legacy = conn.execute( "SELECT * FROM position_monitors WHERE status='active' ORDER BY id DESC" ).fetchall() for r in legacy: sym = r["symbol"] direction = r["direction"] key = f"leg:{sym.lower()}:{direction}" if any(x.get("symbol_code", "").lower() == sym.lower() and x.get("direction") == direction for x in rows): continue entry = float(r["entry_price"]) sl = float(r["stop_loss"]) tp = float(r["take_profit"]) lots = float(r["lots"] or 1) market = r["market_code"] or "" sina = r["sina_code"] or "" mark = fetch_price(sym, market, sina) metrics = calc_position_metrics(direction, entry, sl, tp, lots, mark, capital, sym) fee_info = calc_fee_breakdown(sym, entry, mark or entry, lots, r["open_time"] or "", now_iso) est_net = None if metrics.get("float_pnl") is not None: est_net = round(metrics["float_pnl"] - fee_info["total_fee"], 2) rows.append({ "key": key, "source": "legacy", "source_label": "历史录入", "legacy_id": r["id"], "symbol": r["symbol_name"] or sym, "symbol_code": sym, "direction": direction, "direction_label": "做多" if direction == "long" else "做空", "lots": lots, "entry_price": entry, "stop_loss": sl, "take_profit": tp, "mark_price": mark, "open_time": r["open_time"], "holding_duration": _holding_duration(r["open_time"] or "", now_iso), "float_pnl": metrics.get("float_pnl"), "float_pct": metrics.get("float_pct"), "risk_pct": metrics.get("risk_pct"), "risk_amount": metrics.get("risk_amount"), "rr_ratio": metrics.get("rr_ratio"), "margin": metrics.get("margin"), "position_pct": metrics.get("position_pct"), "est_fee": fee_info["total_fee"], "est_pnl_net": est_net, "can_close": True, "close_url": f"/close_position/{r['id']}", }) return rows @app.route("/trade") @login_required def trade_page(): return redirect(url_for("positions")) @app.route("/positions") @login_required def positions(): conn = get_db() try: init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) capital = _capital(conn) risk = get_risk_status(conn) ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} recommend_rows = list_product_recommendations(capital, _main_price) active_trend = conn.execute( "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" ).fetchone() monitor_count = conn.execute( "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'" ).fetchone()["n"] roll_count = conn.execute( "SELECT COUNT(*) AS n FROM roll_groups WHERE status='active'" ).fetchone()["n"] conn.commit() sizing = get_sizing_mode(get_setting) return render_template( "trade.html", trading_mode=mode, trading_mode_label=trading_mode_label(get_setting), capital=capital, risk_status=risk, ctp_status=ctp_st, ctp_account=ctp_acc, recommend_rows=recommend_rows, active_trend=dict(active_trend) if active_trend else None, monitor_count=monitor_count, roll_count=roll_count, sizing_mode=sizing, sizing_mode_label="以损定仓" if sizing == MODE_RISK else "固定张数", risk_percent=get_risk_percent(get_setting), ) finally: conn.close() @app.route("/recommend") @login_required def recommend_page(): return redirect(url_for("positions") + "#recommend") @app.route("/api/trading/live") @login_required def api_trading_live(): conn = get_db() try: init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) rows = _build_trading_live_rows(conn) capital = _capital(conn) risk = get_risk_status(conn) conn.commit() return jsonify({ "rows": rows, "capital": capital, "ctp_status": ctp_st, "trading_mode_label": trading_mode_label(get_setting), "risk_status": risk, }) finally: conn.close() @app.route("/api/trading/close", methods=["POST"]) @login_required def api_trading_close(): d = request.get_json(silent=True) or {} source = (d.get("source") or "").strip() conn = get_db() init_strategy_tables(conn) mode = get_trading_mode(get_setting) if not ctp_status(mode).get("connected") and source in ("ctp", "program"): conn.close() return jsonify({"ok": False, "error": "请先连接 CTP"}), 400 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)) price = float(d.get("price") or 0) except (TypeError, ValueError): conn.close() return jsonify({"ok": False, "error": "参数无效"}), 400 if not sym or price <= 0: conn.close() return jsonify({"ok": False, "error": "品种或价格无效"}), 400 offset = "close_long" if direction == "long" else "close_short" try: execute_order( conn, mode=mode, offset=offset, symbol=sym, direction=direction, lots=lots, price=price, settings=_settings_dict(), ) if source == "program": mid = int(d.get("monitor_id") or 0) if mid: conn.execute( "UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mid,), ) conn.commit() conn.close() return jsonify({"ok": True}) except ValueError as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 @app.route("/strategy") @login_required def strategy_page(): conn = get_db() init_strategy_tables(conn) capital = _capital(conn) active_trend = conn.execute( "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC LIMIT 1" ).fetchone() monitors = conn.execute( "SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC" ).fetchall() roll_groups = conn.execute( "SELECT * FROM roll_groups WHERE status='active' ORDER BY id DESC" ).fetchall() conn.close() return render_template( "strategy.html", capital=capital, risk_percent=get_risk_percent(get_setting), sizing_mode=get_sizing_mode(get_setting), active_trend=dict(active_trend) if active_trend else None, monitors=[dict(m) for m in monitors], roll_groups=[dict(g) for g in roll_groups], ) @app.route("/strategy/records") @login_required def strategy_records_page(): conn = get_db() init_strategy_tables(conn) trend, roll = list_snapshots(conn) conn.close() return render_template("strategy_records.html", trend_rows=trend, roll_rows=roll) @app.route("/api/trade/quote") @login_required def api_trade_quote(): sym = (request.args.get("symbol") or "").strip() lots = request.args.get("lots") or "1" if not sym: return jsonify({"ok": False, "error": "缺少品种"}), 400 codes = ths_to_codes(sym) price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") try: lots_f = max(1, int(float(lots))) except (TypeError, ValueError): lots_f = 1 metrics = calc_order_tick_metrics(sym, lots_f, price) spec = get_contract_spec(sym) name = codes.get("name", sym) if codes else sym pos_long = pos_short = 0 mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) if ctp_st.get("connected"): for p in _ctp_positions(mode): if not _match_ctp_symbol(p.get("symbol", ""), sym): continue if p["direction"] == "long": pos_long = int(p["lots"]) else: pos_short = int(p["lots"]) max_open = int(_capital(get_db()) / (metrics["margin_per_lot"] or 1)) if metrics.get("margin_per_lot") else 0 return jsonify({ "ok": True, "symbol": sym, "name": name, "price": price, "lots": lots_f, "metrics": metrics, "exchange": codes.get("exchange", "") if codes else "", "pos_long": pos_long, "pos_short": pos_short, "max_open_long": max_open, "max_open_short": max_open, "footer_text": ( f"*{name} 每手{spec['mult']}吨/点 最小变动{metrics['tick_size']} " f"每跳{metrics['tick_value_per_lot']}元/手×{lots_f}={metrics['tick_value_total']}元 " f"精度{metrics['price_precision']}位小数" ), }) @app.route("/api/trade/preview", methods=["POST"]) @login_required def api_trade_preview(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() direction = (d.get("direction") or "long").strip().lower() try: entry = float(d.get("entry") or d.get("price") or 0) sl = float(d.get("stop_loss") or 0) tp = float(d.get("take_profit") or 0) except (TypeError, ValueError): return jsonify({"ok": False, "error": "价格参数无效"}), 400 conn = get_db() capital = _capital(conn) conn.close() sizing = get_sizing_mode(get_setting) if sizing == MODE_RISK: lots, err = calc_lots_by_risk(entry, sl, direction, capital, get_risk_percent(get_setting), sym) if err: return jsonify({"ok": False, "error": err}), 400 else: try: lots = max(1, int(d.get("lots") or 1)) except (TypeError, ValueError): lots = 1 metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym) tick = calc_order_tick_metrics(sym, lots, entry) return jsonify({"ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital}) @app.route("/api/trade/order", methods=["POST"]) @login_required def api_trade_order(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() offset = (d.get("offset") or "open").strip().lower() direction = (d.get("direction") or "long").strip().lower() try: lots = max(1, int(d.get("lots") or 1)) price = float(d.get("price") or 0) except (TypeError, ValueError): return jsonify({"ok": False, "error": "手数或价格无效"}), 400 if not sym or price <= 0: return jsonify({"ok": False, "error": "品种或价格无效"}), 400 conn = get_db() init_strategy_tables(conn) if offset.startswith("open"): err = assert_can_open(conn) if err: conn.close() return jsonify({"ok": False, "error": err}), 403 mode = get_trading_mode(get_setting) sizing = get_sizing_mode(get_setting) if offset.startswith("open") and sizing == MODE_RISK: sl = float(d.get("stop_loss") or 0) if sl <= 0: conn.close() return jsonify({"ok": False, "error": "以损定仓模式须填写止损价"}), 400 lots_calc, err = calc_lots_by_risk(price, sl, direction, _capital(conn), get_risk_percent(get_setting), sym) if err: conn.close() return jsonify({"ok": False, "error": err}), 400 lots = lots_calc or lots try: result = execute_order( conn, mode=mode, offset=offset, symbol=sym, direction=direction, lots=lots, price=price, settings=_settings_dict(), ) if offset.startswith("open"): sl = d.get("stop_loss") tp = d.get("take_profit") 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, lots, price, float(sl) if sl else None, float(tp) if tp else None, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "manual", ), ) conn.commit() send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}") conn.close() return jsonify({"ok": True, "result": result, "lots": lots}) except ValueError as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 except Exception as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 500 @app.route("/api/ctp/connect", methods=["POST"]) @login_required def api_ctp_connect(): mode = get_trading_mode(get_setting) force = bool((request.get_json(silent=True) or {}).get("force")) try: st = ctp_connect(mode, force=force) acc = _ctp_account(mode) return jsonify({"ok": True, "status": st, "account": acc}) except Exception as exc: st = ctp_status(mode) return jsonify({"ok": False, "error": str(exc), "status": st}), 400 @app.route("/api/ctp/status") @login_required def api_ctp_status(): mode = get_trading_mode(get_setting) st = ctp_status(mode) acc = _ctp_account(mode) if st.get("connected") else {} return jsonify({"ok": True, "status": st, "account": acc}) @app.route("/api/account_snapshot") @login_required def api_account_snapshot(): conn = get_db() try: init_strategy_tables(conn) mode = get_trading_mode(get_setting) ctp_st = ctp_status(mode) capital = _capital(conn) risk = get_risk_status(conn) conn.commit() ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} positions = _ctp_positions(mode) if ctp_st.get("connected") else [] return jsonify({ "capital": capital, "trading_mode": mode, "trading_mode_label": trading_mode_label(get_setting), "sizing_mode": get_sizing_mode(get_setting), "risk_status": risk, "ctp_status": ctp_st, "ctp_account": ctp_acc, "positions": positions, }) finally: conn.close() @app.route("/api/recommend/list") @login_required def api_recommend_list(): conn = get_db() capital = _capital(conn) conn.close() return jsonify({"ok": True, "capital": capital, "rows": list_product_recommendations(capital, _main_price)}) @app.route("/api/strategy/trend/preview", methods=["POST"]) @login_required def api_trend_preview(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() conn = get_db() if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone(): conn.close() return jsonify({"ok": False, "error": "已有运行中趋势计划"}), 400 capital = _capital(conn) codes = ths_to_codes(sym) price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") conn.close() if not price: return jsonify({"ok": False, "error": "无法获取现价"}), 400 plan, err = compute_trend_plan_futures( direction=d.get("direction") or "long", stop_loss=float(d.get("stop_loss") or 0), add_upper=float(d.get("add_upper") or 0), take_profit=float(d.get("take_profit") or 0), risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), capital=capital, live_price=price, ths_code=sym, dca_legs=int(d.get("dca_legs") or 5), ) if err: return jsonify({"ok": False, "error": err}), 400 return jsonify({"ok": True, "plan": plan}) @app.route("/api/strategy/trend/execute", methods=["POST"]) @login_required def api_trend_execute(): d = request.get_json(silent=True) or {} sym = (d.get("symbol") or "").strip() conn = get_db() init_strategy_tables(conn) err = assert_can_open(conn) if err: conn.close() return jsonify({"ok": False, "error": err}), 403 capital = _capital(conn) codes = ths_to_codes(sym) price = fetch_price(sym, codes.get("market_code", "") if codes else "", codes.get("sina_code", "") if codes else "") plan, perr = compute_trend_plan_futures( direction=d.get("direction") or "long", stop_loss=float(d.get("stop_loss") or 0), add_upper=float(d.get("add_upper") or 0), take_profit=float(d.get("take_profit") or 0), risk_percent=float(d.get("risk_percent") or get_risk_percent(get_setting)), capital=capital, live_price=price or float(d.get("live_price") or 0), ths_code=sym, ) if perr: conn.close() return jsonify({"ok": False, "error": perr}), 400 mode = get_trading_mode(get_setting) try: execute_order( conn, mode=mode, offset="open", symbol=sym, direction=plan["direction"], lots=plan["first_lots"], price=price, settings=_settings_dict(), ) except ValueError as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur = conn.execute( """INSERT INTO trend_pullback_plans ( status, symbol, symbol_name, direction, stop_loss, add_upper, take_profit, risk_percent, capital_snapshot, plan_margin, target_lots, first_lots, remainder_lots, dca_legs, leg_amounts_json, grid_prices_json, first_order_done, avg_entry_price, lots_open, opened_at ) VALUES ('active',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?,?)""", ( sym, codes.get("name", sym) if codes else sym, plan["direction"], plan["stop_loss"], plan["add_upper"], plan["take_profit"], plan["risk_percent"], plan["capital_snapshot"], plan["plan_margin"], plan["target_lots"], plan["first_lots"], plan["remainder_lots"], plan["dca_legs"], plan["leg_amounts_json"], plan["grid_prices_json"], price, plan["first_lots"], now, ), ) plan_id = cur.lastrowid conn.commit() conn.close() send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}手") return jsonify({"ok": True, "plan_id": plan_id, "plan": plan}) @app.route("/api/strategy/roll/preview", methods=["POST"]) @login_required def api_roll_preview(): d = request.get_json(silent=True) or {} conn = get_db() mon_id = int(d.get("monitor_id") or 0) mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone() conn.close() if not mon: return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 sym = mon["symbol"] spec = get_contract_spec(sym) capital = _capital(get_db()) preview, err = preview_roll( direction=mon["direction"], symbol=sym, qty_existing=float(mon["lots"]), entry_existing=float(mon["entry_price"]), initial_take_profit=float(mon["take_profit"] or 0), add_mode=d.get("add_mode") or "market", new_stop_loss=float(d.get("new_stop_loss") or 0), risk_percent=float(d.get("risk_percent") or 2), capital_base=capital, mult=spec["mult"], add_price=float(d.get("add_price") or mon["entry_price"]), fib_upper=d.get("fib_upper"), fib_lower=d.get("fib_lower"), legs_done=int(d.get("legs_done") or 0), ) if err: return jsonify({"ok": False, "error": err}), 400 return jsonify({"ok": True, "preview": preview}) @app.route("/api/strategy/roll/execute", methods=["POST"]) @login_required def api_roll_execute(): d = request.get_json(silent=True) or {} conn = get_db() init_strategy_tables(conn) mon_id = int(d.get("monitor_id") or 0) mon = conn.execute("SELECT * FROM trade_order_monitors WHERE id=? AND status='active'", (mon_id,)).fetchone() if not mon: conn.close() return jsonify({"ok": False, "error": "无有效持仓监控"}), 400 if conn.execute("SELECT id FROM trend_pullback_plans WHERE status='active'").fetchone(): conn.close() return jsonify({"ok": False, "error": "趋势回调运行中,不可滚仓"}), 400 sym = mon["symbol"] spec = get_contract_spec(sym) capital = _capital(conn) prev, err = preview_roll( direction=mon["direction"], symbol=sym, qty_existing=float(mon["lots"]), entry_existing=float(mon["entry_price"]), initial_take_profit=float(mon["take_profit"] or 0), add_mode=d.get("add_mode") or "market", new_stop_loss=float(d.get("new_stop_loss") or 0), risk_percent=float(d.get("risk_percent") or 2), capital_base=capital, mult=spec["mult"], add_price=float(d.get("add_price") or mon["entry_price"]), ) if err: conn.close() return jsonify({"ok": False, "error": err}), 400 price = float(prev["add_price"]) mode = get_trading_mode(get_setting) try: execute_order( conn, mode=mode, offset="open", symbol=sym, direction=mon["direction"], lots=int(prev["add_lots"]), price=price, settings=_settings_dict(), ) except ValueError as exc: conn.close() return jsonify({"ok": False, "error": str(exc)}), 400 new_lots = int(mon["lots"]) + int(prev["add_lots"]) new_avg = prev["avg_entry_after"] new_sl = prev["new_stop_loss"] conn.execute( "UPDATE trade_order_monitors SET lots=?, entry_price=?, stop_loss=? WHERE id=?", (new_lots, new_avg, new_sl, mon_id), ) grp = conn.execute( "SELECT * FROM roll_groups WHERE order_monitor_id=? AND status='active'", (mon_id,), ).fetchone() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if grp: gid = grp["id"] leg_n = int(grp["leg_count"] or 0) + 1 conn.execute( "UPDATE roll_groups SET leg_count=?, current_stop_loss=?, updated_at=? WHERE id=?", (leg_n, new_sl, now, gid), ) else: cur = conn.execute( """INSERT INTO roll_groups ( order_monitor_id, symbol, direction, initial_take_profit, initial_stop_loss, current_stop_loss, risk_percent, leg_count, status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,1,'active',?,?)""", (mon_id, sym, mon["direction"], mon["take_profit"], mon["stop_loss"], new_sl, float(d.get("risk_percent") or 2), now, now), ) gid = cur.lastrowid leg_n = 1 conn.execute( """INSERT INTO roll_legs (roll_group_id, leg_index, add_mode, fill_price, lots, new_stop_loss, status, created_at) VALUES (?,?,?,?,?,?, 'filled', ?)""", (gid, leg_n, d.get("add_mode") or "market", price, int(prev["add_lots"]), new_sl, now), ) conn.commit() conn.close() return jsonify({"ok": True, "preview": prev}) @app.route("/api/strategy/trend/stop", methods=["POST"]) @login_required def api_trend_stop(): d = request.get_json(silent=True) or {} plan_id = int(d.get("plan_id") or 0) conn = get_db() plan = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (plan_id,)).fetchone() if not plan: conn.close() return jsonify({"ok": False, "error": "计划不存在"}), 404 mode = get_trading_mode(get_setting) price = fetch_price(plan["symbol"]) or float(plan["avg_entry_price"] or 0) try: if int(plan["lots_open"] or 0) > 0: execute_order( conn, mode=mode, offset="close", symbol=plan["symbol"], direction=plan["direction"], lots=int(plan["lots_open"]), price=price, settings=_settings_dict(), ) except ValueError: pass now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn.execute( "UPDATE trend_pullback_plans SET status='stopped_manual', message=?, opened_at=opened_at WHERE id=?", ("手动结束", plan_id), ) save_snapshot( conn, strategy_type=STRATEGY_TREND, source_id=plan_id, symbol=plan["symbol"], direction=plan["direction"], result_label="手动结束", payload=dict(plan), opened_at=plan["opened_at"] or "", ) on_user_initiated_close(conn, trading_day=trading_day_label()) conn.commit() conn.close() return jsonify({"ok": True}) def check_trend_plans(app_ref): """后台:趋势补仓与止盈。""" conn = get_db() init_strategy_tables(conn) rows = conn.execute("SELECT * FROM trend_pullback_plans WHERE status='active'").fetchall() mode = get_trading_mode(get_setting) for plan in rows: sym = plan["symbol"] price = fetch_price(sym) if not price: continue direction = plan["direction"] tp = float(plan["take_profit"] or 0) if tp > 0: hit_tp = (direction == "long" and price >= tp) or (direction == "short" and price <= tp) if hit_tp: try: execute_order( conn, mode=mode, offset="close", symbol=sym, direction=direction, lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(), ) except ValueError: pass conn.execute( "UPDATE trend_pullback_plans SET status='stopped_tp', message=? WHERE id=?", ("程序止盈", plan["id"]), ) save_snapshot( conn, strategy_type=STRATEGY_TREND, source_id=plan["id"], symbol=sym, direction=direction, result_label="止盈", payload=dict(plan), opened_at=plan["opened_at"] or "", ) send_wechat_msg(f"趋势回调止盈 {sym}") continue try: grid = json.loads(plan["grid_prices_json"] or "[]") legs = json.loads(plan["leg_amounts_json"] or "[]") except Exception: grid, legs = [], [] done = int(plan["legs_done"] or 0) if done < len(grid) and done < len(legs): level = float(grid[done]) if trend_dca_level_reached(direction, price, level): add_lots = int(legs[done]) try: execute_order( conn, mode=mode, offset="open", symbol=sym, direction=direction, lots=add_lots, price=price, settings=_settings_dict(), ) new_open = int(plan["lots_open"] or 0) + add_lots old_avg = float(plan["avg_entry_price"] or price) new_avg = (old_avg * int(plan["lots_open"] or 0) + price * add_lots) / new_open if new_open else price conn.execute( """UPDATE trend_pullback_plans SET legs_done=?, lots_open=?, avg_entry_price=? WHERE id=?""", (done + 1, new_open, new_avg, plan["id"]), ) send_wechat_msg(f"趋势回调补仓 {sym} +{add_lots}手 @档位{done+1}") except ValueError: pass conn.commit() conn.close() app._check_trend_plans = check_trend_plans @app.route("/settings/trading", methods=["POST"]) @login_required def settings_trading_post(): return redirect(url_for("settings")) def hook_review_mood(conn, behavior_tags: str, exit_trigger: str, exit_supplement: str): if parse_mood_issues(behavior_tags): on_mood_journal_freeze(conn, trading_day=trading_day_label()) if (exit_trigger or "").strip() == "手动平仓" and (exit_supplement or "").strip(): reduce_cooloff_after_journal(conn, trading_day=trading_day_label()) app._risk_review_hook = hook_review_mood