Files
qihuo/install_trading.py
T

2344 lines
95 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""期货下单、可开仓品种、策略交易路由注册。"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime
from typing import Any, Callable, Optional
from flask import flash, jsonify, redirect, render_template, request, url_for, Response, stream_with_context
from contract_specs import calc_position_metrics, get_contract_spec
from fee_specs import calc_fee_breakdown
from kline_stream import sse_format
from market_sessions import is_trading_session
from position_sizing import (
MODE_AMOUNT,
MODE_FIXED,
DEFAULT_MAX_ORDER_LOTS,
calc_lots_by_amount,
calc_lots_by_risk,
calc_margin_usage_pct,
calc_order_tick_metrics,
normalize_sizing_mode,
)
from recommend_store import (
recommend_payload,
refresh_recommend_cache,
)
from recommend_stream import recommend_hub, schedule_recommend_refresh, start_recommend_worker
from position_stream import position_hub, start_position_worker
from ctp_reconnect import start_ctp_reconnect_worker
from ctp_premarket_connect import start_ctp_premarket_connect_worker
from ctp_fee_worker import start_ctp_fee_worker
from order_pending import (
cancel_pending_monitor,
pending_auto_cancel_remaining,
reconcile_pending_orders,
)
from db_conn import execute_retry
from sl_tp_guard import (
cancel_monitor_exit_orders,
ensure_monitor_order_columns,
monitor_order_status,
monitor_source_label,
place_monitor_exit_orders,
reconcile_monitors_without_position,
start_sl_tp_guard_worker,
write_manual_close_trade_log,
)
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, PRODUCT_CATEGORIES, position_symbol_meta
from trading_context import (
TRADING_MODE_LIVE,
TRADING_MODE_SIM,
get_account_capital,
get_fixed_amount,
get_fixed_lots,
get_max_margin_pct,
get_pending_order_timeout_min,
get_pending_order_timeout_sec,
get_risk_percent,
get_sizing_mode,
get_trailing_be_tick_buffer,
get_trading_mode,
trading_mode_label,
)
from ctp_symbol import ths_to_vnpy_symbol
from vnpy_bridge import (
_ctp_td_lock,
ctp_cancel_order,
ctp_connect,
ctp_estimate_margin_one_lot,
ctp_get_account,
ctp_get_tick_price,
ctp_list_active_orders,
ctp_list_positions,
ctp_status,
execute_order,
get_bridge,
set_position_refresh_callback,
)
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
def _sizing_mode_label(mode: str) -> str:
m = normalize_sizing_mode(mode)
if m == MODE_AMOUNT:
return "固定金额"
return "固定手数"
def _symbol_display_fields(sym: str) -> dict:
meta = position_symbol_meta(sym)
name = meta.get("name") or sym
return {
"symbol": name,
"symbol_name": name,
"symbol_exchange": meta.get("exchange") or "",
"symbol_is_main": bool(meta.get("is_main")),
}
def _schedule_recommend_refresh() -> None:
from db_conn import DB_PATH
schedule_recommend_refresh(
db_path=DB_PATH,
get_capital_fn=_capital,
quote_fn=_main_quote,
init_tables_fn=lambda c: init_strategy_tables(c),
get_mode_fn=lambda: get_trading_mode(get_setting),
get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting),
get_sizing_mode_fn=lambda: get_sizing_mode(get_setting),
get_fixed_lots_fn=lambda: get_fixed_lots(get_setting),
)
def _recommend_payload(conn) -> dict:
mode = get_trading_mode(get_setting)
return recommend_payload(
conn,
live_capital=_capital(conn),
max_margin_pct=get_max_margin_pct(get_setting),
trading_mode=mode,
sizing_mode=get_sizing_mode(get_setting),
fixed_lots=get_fixed_lots(get_setting),
)
def _settings_dict() -> dict:
return {
"trading_mode": get_trading_mode(get_setting),
"position_sizing_mode": get_sizing_mode(get_setting),
"risk_percent": str(get_risk_percent(get_setting)),
"max_margin_pct": str(get_max_margin_pct(get_setting)),
}
def _capital(conn) -> float:
return get_account_capital(conn, get_setting)
def _main_quote(product_ths: str) -> Optional[dict]:
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)
price = None
if codes:
price = fetch_price(
sym,
codes.get("market_code", ""),
codes.get("sina_code", ""),
)
return {
"ths_code": sym,
"price": price,
"display": main.get("display") or sym,
"name": main.get("name") or p.get("name"),
}
return None
def _ctp_account(mode: str) -> dict:
try:
return ctp_get_account(mode)
except Exception:
return {}
def _ctp_positions(
mode: str,
*,
refresh_if_empty: bool = True,
refresh_margin: bool = False,
) -> list:
try:
return ctp_list_positions(
mode,
refresh_if_empty=refresh_if_empty,
refresh_margin=refresh_margin,
)
except Exception:
return []
def _ctp_pos_to_ths_code(p: dict) -> str:
sym = (p.get("symbol") or "").strip()
if not sym:
return ""
codes = ths_to_codes(sym)
if codes:
return codes.get("ths_code") or sym
return sym
def _resolve_position_margin(
*,
sym: str,
direction: str,
lots: int,
entry: float,
mode: str,
ctp: Optional[dict] = None,
mon_margin: Optional[float] = None,
est_margin: Optional[float] = None,
) -> tuple[Optional[float], str]:
"""占用保证金:柜台持仓 > CTP 合约率估算 > 本地规格估算 > 库内缓存。"""
ctp_margin = float(ctp.get("margin") or 0) if ctp else 0.0
if ctp_margin > 0:
return round(ctp_margin, 2), "ctp"
connected = bool(ctp_status(mode).get("connected"))
ths_sym = sym
if ctp:
ths_sym = _ctp_pos_to_ths_code(ctp) or sym
else:
codes = ths_to_codes(sym)
if codes and codes.get("ths_code"):
ths_sym = codes["ths_code"]
if connected and ths_sym and entry > 0 and lots > 0:
per_lot = ctp_estimate_margin_one_lot(
mode, ths_sym, entry, direction=direction,
)
if per_lot and per_lot > 0:
return round(per_lot * lots, 2), "ctp"
if est_margin and float(est_margin) > 0:
return round(float(est_margin), 2), "estimate"
if not connected and mon_margin and float(mon_margin) > 0:
return round(float(mon_margin), 2), "db"
return None, "estimate"
def _ensure_monitors_from_ctp(conn, mode: str) -> None:
"""CTP 有持仓但本地无监控时,自动补写一条 active 记录供展示。"""
if not ctp_status(mode).get("connected"):
return
for p in _ctp_positions(mode, refresh_if_empty=True):
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
direction = p.get("direction") or "long"
ths = _ctp_pos_to_ths_code(p)
if not ths:
continue
existing = _find_active_monitor(conn, ths, direction)
if existing:
_sync_monitor_from_ctp(
conn, int(existing["id"]), ths, direction, mode, ctp=p,
capital=_capital(conn),
)
continue
sl, tp, trailing_be, initial_sl = _restore_sl_tp_from_closed(conn, ths, direction)
ctp_open = (p.get("open_time") or "").strip()
mid = _upsert_open_monitor(
conn,
sym=ths,
direction=direction,
lots=lots,
price=float(p.get("avg_price") or 0),
sl=sl,
tp=tp,
trailing_be=trailing_be,
ctp_open_time=ctp_open or None,
monitor_type="ctp_sync",
)
if initial_sl is not None and sl is not None:
conn.execute(
"UPDATE trade_order_monitors SET initial_stop_loss=? WHERE id=?",
(initial_sl, mid),
)
def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
if a and b and a.split(".")[0] == b.split(".")[0]:
return True
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
if a == vnpy_sym.lower():
return True
except Exception:
pass
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ctp_sym)
if vnpy_sym.lower() == b.split(".")[0]:
return True
except Exception:
pass
return False
def _holding_duration(open_time: str, now_iso: str) -> str:
try:
from app import calc_holding_duration
open_s = (open_time or "").strip().replace("T", " ")[:19]
now_s = (now_iso or "").strip().replace("T", " ")[:19]
if not open_s or not now_s:
return ""
return calc_holding_duration(open_s, now_s)
except Exception:
return ""
def _restore_sl_tp_from_closed(conn, sym: str, direction: str) -> tuple:
"""重启后从最近关闭的同品种监控恢复止盈止损。"""
direction = (direction or "long").strip().lower()
for r in conn.execute(
"SELECT symbol, direction, stop_loss, take_profit, trailing_be, initial_stop_loss "
"FROM trade_order_monitors WHERE status='closed' ORDER BY id DESC LIMIT 80"
).fetchall():
row = dict(r)
if (row.get("direction") or "long") != direction:
continue
if not _match_ctp_symbol(sym, row.get("symbol") or ""):
continue
if row.get("stop_loss") is None and row.get("take_profit") is None:
continue
return (
row.get("stop_loss"),
row.get("take_profit"),
int(row.get("trailing_be") or 0),
row.get("initial_stop_loss"),
)
return None, None, 0, None
def _ctp_position_keys(mode: str) -> set[tuple[str, str]]:
keys: set[tuple[str, str]] = set()
for p in _ctp_positions(mode):
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
sym = (p.get("symbol") or "").lower()
direction = p.get("direction") or "long"
keys.add((sym, direction))
return keys
def _monitor_matches_ctp_position(mon: dict, position_keys: set[tuple[str, str]]) -> bool:
ms = mon.get("symbol") or ""
md = mon.get("direction") or "long"
for ps, pd in position_keys:
if pd != md:
continue
if _match_ctp_symbol(ps, ms):
return True
return False
def _sync_trade_monitors_with_ctp(conn, mode: str) -> int:
"""关闭无对应 CTP 持仓的监控,并撤销残留止盈止损挂单。"""
return reconcile_monitors_without_position(conn, mode)
def _effective_active_position_count(conn, mode: str) -> int:
if ctp_status(mode).get("connected"):
return len(_ctp_position_keys(mode))
row = conn.execute(
"SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='active'"
).fetchone()
return int(row["n"] or 0)
def _build_pending_orders(conn, mode: str) -> list[dict]:
pending: list[dict] = []
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall():
mon = dict(r)
sym = mon.get("symbol") or ""
direction = mon.get("direction") or "long"
lots = int(mon.get("lots") or 0)
base = {
"symbol_code": sym,
"direction": direction,
"direction_label": "做多" if direction == "long" else "做空",
"lots": lots,
"source": "monitor",
"monitor_id": mon.get("id"),
**_symbol_display_fields(sym),
}
sl = mon.get("stop_loss")
tp = mon.get("take_profit")
if sl is not None:
pending.append({
**base,
"order_kind": "stop_loss",
"label": "止损监控",
"price": float(sl),
})
if tp is not None:
pending.append({
**base,
"order_kind": "take_profit",
"label": "止盈监控",
"price": float(tp),
})
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC"
).fetchall():
mon = dict(r)
sym = mon.get("symbol") or ""
pending.append({
"symbol_code": sym,
"direction": mon.get("direction") or "long",
"direction_label": "做多" if (mon.get("direction") or "long") == "long" else "做空",
"lots": int(mon.get("lots") or 0),
"price": float(mon.get("order_price") or mon.get("entry_price") or 0),
"order_kind": "open_pending",
"label": "开仓挂单中",
"source": "monitor",
"monitor_id": mon.get("id"),
"can_cancel_order": is_trading_session(),
"cancel_allowed": is_trading_session(),
**_symbol_display_fields(sym),
})
ctp_st = ctp_status(mode)
if ctp_st.get("connected"):
for o in _ctp_active_orders(mode):
sym = o.get("symbol") or ""
offset_s = (o.get("offset") or "").upper()
kind = "limit"
label = "委托挂单"
if "CLOSE" in offset_s:
label = "平仓委托"
pending.append({
"symbol_code": sym,
"direction": o.get("direction") or "long",
"direction_label": "做多" if o.get("direction") == "long" else "做空",
"lots": int(o.get("lots") or 0),
"price": float(o.get("price") or 0),
"order_kind": kind,
"label": label,
"source": "ctp",
"order_id": o.get("order_id"),
"can_cancel_order": is_trading_session(),
"cancel_allowed": is_trading_session(),
**_symbol_display_fields(sym),
})
return pending
def _ctp_active_orders(mode: str) -> list:
try:
return ctp_list_active_orders(mode)
except Exception:
return []
def _canonical_position_key(symbol: str, direction: str) -> str:
sym = (symbol or "").strip()
d = (direction or "long").strip().lower()
try:
vnpy_sym, _ = ths_to_vnpy_symbol(sym)
return f"{vnpy_sym.lower()}:{d}"
except Exception:
return f"{sym.lower()}:{d}"
def _find_active_monitor(conn, symbol: str, direction: str) -> Optional[dict]:
direction = (direction or "long").strip().lower()
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall():
row = dict(r)
if (row.get("direction") or "long") != direction:
continue
if _match_ctp_symbol(symbol, row.get("symbol") or ""):
return row
return None
def _close_duplicate_monitors(conn, symbol: str, direction: str, keep_id: int) -> None:
direction = (direction or "long").strip().lower()
for r in conn.execute(
"SELECT id, symbol, direction FROM trade_order_monitors WHERE status='active'"
).fetchall():
if int(r["id"]) == int(keep_id):
continue
if (r["direction"] or "long") != direction:
continue
if _match_ctp_symbol(symbol, r["symbol"] or ""):
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(r["id"],),
)
def _upsert_open_monitor(
conn,
*,
sym: str,
direction: str,
lots: int,
price: float,
sl,
tp,
trailing_be: int,
ctp_open_time: Optional[str] = None,
open_time: Optional[str] = None,
monitor_type: str = "manual",
status: str = "active",
vt_order_id: Optional[str] = None,
order_price: Optional[float] = None,
) -> int:
ensure_monitor_order_columns(conn)
codes = ths_to_codes(sym) or {}
sl_f = float(sl) if sl not in (None, "") else None
tp_f = float(tp) if tp not in (None, "") else None
now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
status_val = status if status in ("pending", "active") else "active"
order_px = float(order_price if order_price is not None else price)
existing = _find_active_monitor(conn, sym, direction)
if not existing:
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC"
).fetchall():
row = dict(r)
if (row.get("direction") or "long") != (direction or "long").strip().lower():
continue
if _match_ctp_symbol(sym, row.get("symbol") or ""):
existing = row
break
if existing:
mid = int(existing["id"])
existing_status = (existing.get("status") or "active").strip().lower()
if existing_status == "active" and status_val == "pending":
status_val = "active"
initial_sl = existing.get("initial_stop_loss")
if sl_f is None:
sl_f = float(existing["stop_loss"]) if existing.get("stop_loss") is not None else None
if tp_f is None:
tp_f = float(existing["take_profit"]) if existing.get("take_profit") is not None else None
if sl_f is not None and initial_sl is None:
initial_sl = sl_f
if not trailing_be:
trailing_be = int(existing.get("trailing_be") or 0)
open_time_val = (existing.get("open_time") or "").strip() or now_s
if open_time:
open_time_val = open_time
elif monitor_type == "ctp_sync" and ctp_open_time:
open_time_val = ctp_open_time
vt_val = vt_order_id or existing.get("vt_order_id")
conn.execute(
"""UPDATE trade_order_monitors SET
symbol=?, symbol_name=?, market_code=?, lots=?, entry_price=?,
stop_loss=?, take_profit=?, initial_stop_loss=?, trailing_be=?, open_time=?,
monitor_type=?, status=?, vt_order_id=?, order_price=?
WHERE id=?""",
(
sym,
codes.get("name", sym),
codes.get("market_code", ""),
lots,
price,
sl_f,
tp_f,
initial_sl,
trailing_be,
open_time_val,
monitor_type if monitor_type != "manual" else (existing.get("monitor_type") or "manual"),
status_val,
vt_val,
order_px,
mid,
),
)
else:
if open_time:
open_time_val = open_time
elif monitor_type == "ctp_sync" and ctp_open_time:
open_time_val = ctp_open_time
else:
open_time_val = now_s
conn.execute(
"""INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, initial_stop_loss, trailing_be,
open_time, monitor_type, status, vt_order_id, order_price
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
sym,
codes.get("name", sym),
codes.get("market_code", ""),
direction,
lots,
price,
sl_f,
tp_f,
sl_f,
trailing_be,
open_time_val,
monitor_type,
status_val,
vt_order_id,
order_px,
),
)
mid = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
if status_val == "active":
_close_duplicate_monitors(conn, sym, direction, mid)
return mid
def _sync_monitor_from_ctp(
conn,
mid: int,
sym: str,
direction: str,
mode: str,
*,
ctp: Optional[dict] = None,
capital: float = 0.0,
) -> None:
"""CTP 同步:均价、现价、保证金、仓位占比写入数据库;不覆盖期货下单的开仓时间。"""
positions = [ctp] if ctp else _ctp_positions(mode, refresh_if_empty=False, refresh_margin=True)
for p in positions:
if not p or int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
continue
if not _match_ctp_symbol(p.get("symbol") or "", sym):
continue
row = conn.execute(
"SELECT open_time, monitor_type FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
db_open = (row["open_time"] or "").strip() if row else ""
monitor_type = (row["monitor_type"] or "manual").strip().lower() if row else "manual"
ctp_open = (p.get("open_time") or "").strip() or None
open_time_val = db_open
if monitor_type == "ctp_sync" and ctp_open:
open_time_val = ctp_open
lots = int(p.get("lots") or 0)
entry = float(p.get("avg_price") or 0)
ctp_margin = float(p.get("margin") or 0)
float_pnl = p.get("pnl")
if float_pnl is not None:
float_pnl = round(float(float_pnl), 2)
mark = None
if ctp_status(mode).get("connected"):
mark = ctp_get_tick_price(mode, sym)
if mark is None or mark <= 0:
mark = entry if entry else None
est = calc_position_metrics(
direction, entry, entry, entry, lots, mark or entry, capital, sym,
).get("margin")
margin, _src = _resolve_position_margin(
sym=sym,
direction=direction,
lots=lots,
entry=entry,
mode=mode,
ctp=p,
est_margin=est,
)
position_pct = None
if margin and capital > 0:
position_pct = round(float(margin) / float(capital) * 100, 2)
execute_retry(
conn,
"""UPDATE trade_order_monitors SET lots=?, entry_price=?,
open_time=?, margin=?, position_pct=?, mark_price=?, float_pnl=?
WHERE id=?""",
(
lots,
entry,
open_time_val,
margin,
position_pct,
float(mark) if mark else None,
float_pnl,
mid,
),
)
return
def _sync_monitor_lots_from_ctp(
conn, mid: int, sym: str, direction: str, mode: str, *, ctp: Optional[dict] = None,
) -> None:
_sync_monitor_from_ctp(
conn, mid, sym, direction, mode, ctp=ctp, capital=_capital(conn),
)
def _compose_position_row(
conn,
*,
mon: Optional[dict],
ctp: Optional[dict],
mode: str,
capital: float,
now_iso: str,
fast: bool = False,
) -> Optional[dict]:
if not mon and not ctp:
return None
if mon:
sym = (mon.get("symbol") or "").strip()
direction = mon.get("direction") or "long"
lots = int(mon.get("lots") or 0)
entry = float(mon.get("entry_price") or 0)
source_label = monitor_source_label(mon.get("monitor_type"))
open_time = (mon.get("open_time") or "").strip()
open_time_source = "order"
margin = mon.get("margin")
position_pct = mon.get("position_pct")
mark = mon.get("mark_price")
float_pnl = mon.get("float_pnl")
if float_pnl is not None:
float_pnl = round(float(float_pnl), 2)
else:
sym = (ctp.get("symbol") or "").strip()
direction = ctp.get("direction") or "long"
lots = int(ctp.get("lots") or 0)
entry = float(ctp.get("avg_price") or 0)
source_label = "CTP 柜台"
open_time = (ctp.get("open_time") or "").strip()
open_time_source = "ctp"
margin = None
position_pct = None
mark = None
float_pnl = ctp.get("pnl")
if float_pnl is not None:
float_pnl = round(float(float_pnl), 2)
if lots <= 0:
return None
if ctp:
if ctp.get("pnl") is not None:
float_pnl = round(float(ctp["pnl"]), 2)
if not mon:
ctp_lots = int(ctp.get("lots") or 0)
if ctp_lots > 0:
lots = ctp_lots
if float(ctp.get("avg_price") or 0) > 0:
entry = float(ctp.get("avg_price") or 0)
ctp_margin = float(ctp.get("margin") or 0)
if (margin is None or float(margin or 0) <= 0) and ctp_margin > 0:
margin = ctp_margin
codes = ths_to_codes(sym)
tick = calc_order_tick_metrics(sym, lots, entry)
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
holding = _holding_duration(open_time, now_iso) if open_time else ""
if (mark is None or float(mark or 0) <= 0) and not fast and ctp_status(mode).get("connected"):
live_mark = ctp_get_tick_price(mode, sym)
if live_mark and live_mark > 0:
mark = live_mark
if (mark is None or float(mark or 0) <= 0) and not fast and codes:
mark = fetch_price(
sym,
codes.get("market_code", ""),
codes.get("sina_code", ""),
)
if mark is None or mark <= 0:
mark = entry if entry else None
close_est = float(mark) if mark and mark > 0 else entry
if float_pnl is None and mark and entry:
pos_tmp = calc_position_metrics(
direction, entry, sl or entry, tp or entry, lots, mark, capital, sym,
)
float_pnl = pos_tmp.get("float_pnl")
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,
)
mon_margin = margin
margin, margin_source = _resolve_position_margin(
sym=sym,
direction=direction,
lots=lots,
entry=entry,
mode=mode,
ctp=ctp,
mon_margin=mon_margin if isinstance(mon_margin, (int, float)) else None,
est_margin=pos_metrics.get("margin"),
)
if margin and capital > 0:
position_pct = round(float(margin) / float(capital) * 100, 2)
elif position_pct is None or float(position_pct or 0) <= 0:
position_pct = pos_metrics.get("position_pct")
elif position_pct is not None:
position_pct = float(position_pct)
order_st = monitor_order_status(
mon or {}, mode=mode, ths_code=sym, direction=direction,
)
pending_for_row: list[dict] = []
if sl is not None:
pending_for_row.append({
"order_kind": "stop_loss",
"label": "止损监控",
"price": sl,
"lots": lots,
"source": "monitor",
"monitor_id": mon["id"] if mon else None,
})
if tp is not None:
pending_for_row.append({
"order_kind": "take_profit",
"label": "止盈监控",
"price": tp,
"lots": lots,
"source": "monitor",
"monitor_id": mon["id"] if mon else None,
})
row_key = _canonical_position_key(sym, direction)
ctp_st = ctp_status(mode)
sync_pending = (
mon is not None
and ctp is None
and bool(ctp_st.get("connected"))
)
return {
"key": row_key,
"source": "ctp" if ctp else "local",
"source_label": source_label,
"sync_pending": sync_pending,
"monitor_id": mon["id"] if mon else None,
"symbol_code": sym,
**_symbol_display_fields(sym),
"direction": direction,
"direction_label": "做多" if direction == "long" else "做空",
"lots": lots,
"entry_price": entry,
"stop_loss": sl,
"take_profit": tp,
"open_time": open_time or None,
"open_time_source": open_time_source or None,
"holding_duration": holding or None,
"mark_price": mark,
"current_price": mark,
"margin": margin,
"margin_source": margin_source,
"position_pct": position_pct,
"risk_amount": pos_metrics.get("risk_amount") if sl is not None else None,
"reward_amount": pos_metrics.get("reward_amount") if tp is not None else None,
"risk_pct": pos_metrics.get("risk_pct") if sl is not None else None,
"rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None,
"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"],
"fee_source": fee_info.get("fee_source") or "local",
"est_pnl_net": est_net,
"sl_order_active": order_st.get("sl_monitoring"),
"tp_order_active": order_st.get("tp_monitoring"),
"sl_monitoring": order_st.get("sl_monitoring"),
"tp_monitoring": order_st.get("tp_monitoring"),
"can_place_orders": False,
"tick_value_total": tick.get("tick_value_total"),
"price_precision": tick.get("price_precision"),
"tick_size": tick.get("tick_size"),
"can_close": True,
"close_allowed": is_trading_session(),
"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,
}
def _compose_pending_row(
mon: dict,
*,
mode: str,
capital: float,
now_iso: str,
) -> Optional[dict]:
sym = (mon.get("symbol") or "").strip()
direction = (mon.get("direction") or "long").strip().lower()
lots = int(mon.get("lots") or 0)
if not sym or lots <= 0:
return None
order_price = float(mon.get("order_price") or mon.get("entry_price") or 0)
codes = ths_to_codes(sym)
sl = float(mon["stop_loss"]) if mon.get("stop_loss") is not None else None
tp = float(mon["take_profit"]) if mon.get("take_profit") is not None else None
pos_metrics = calc_position_metrics(
direction, order_price, sl or order_price, tp or order_price, lots, order_price, capital, sym,
)
open_time = (mon.get("open_time") or "").strip()
timeout_sec = get_pending_order_timeout_sec(get_setting)
remain = pending_auto_cancel_remaining(mon, timeout_sec=timeout_sec)
return {
"key": f"{_canonical_position_key(sym, direction)}:pending:{mon.get('id')}",
"order_state": "pending",
"source": "pending",
"source_label": "委托挂单中",
"sync_pending": True,
"monitor_id": mon.get("id"),
"symbol_code": sym,
**_symbol_display_fields(sym),
"direction": direction,
"direction_label": "做多" if direction == "long" else "做空",
"lots": lots,
"entry_price": order_price,
"order_price": order_price,
"stop_loss": sl,
"take_profit": tp,
"open_time": open_time or None,
"holding_duration": _holding_duration(open_time, now_iso) if open_time else None,
"mark_price": order_price,
"current_price": order_price,
"margin": pos_metrics.get("margin"),
"margin_source": "estimate",
"position_pct": pos_metrics.get("position_pct"),
"risk_amount": pos_metrics.get("risk_amount") if sl is not None else None,
"reward_amount": pos_metrics.get("reward_amount") if tp is not None else None,
"rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None,
"float_pnl": None,
"est_fee": None,
"can_close": False,
"close_allowed": False,
"can_cancel_order": is_trading_session(),
"cancel_allowed": is_trading_session(),
"auto_cancel_sec": remain,
"pending_timeout_sec": timeout_sec,
"pending_timeout_min": max(1, timeout_sec // 60),
"vt_order_id": mon.get("vt_order_id"),
"sl_order_active": False,
"tp_order_active": False,
"sl_monitoring": bool(sl is not None),
"tp_monitoring": bool(tp is not None),
"can_place_orders": False,
"pending_orders": [],
"trailing_be": bool(mon.get("trailing_be")),
"trailing_r_locked": int(mon.get("trailing_r_locked") or 0),
}
def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> None:
reconcile_pending_orders(
conn,
mode,
match_symbol_fn=_match_ctp_symbol,
sync_monitor_fn=_sync_monitor_from_ctp,
capital=capital,
list_positions_fn=_ctp_positions,
timeout_sec=get_pending_order_timeout_sec(get_setting),
)
def _build_trading_live_rows(conn, *, fast: bool = False) -> list[dict]:
from zoneinfo import ZoneInfo
tz = ZoneInfo("Asia/Shanghai")
now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M")
mode = get_trading_mode(get_setting)
capital = _capital(conn)
ensure_monitor_order_columns(conn)
monitors_raw = [
dict(r) for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active' ORDER BY id DESC"
).fetchall()
]
monitor_by_key: dict[str, dict] = {}
for mon in monitors_raw:
key = _canonical_position_key(mon.get("symbol") or "", mon.get("direction") or "long")
if key not in monitor_by_key:
monitor_by_key[key] = mon
ctp_list: list[dict] = (
_ctp_positions(mode, refresh_if_empty=not fast, refresh_margin=not fast)
if ctp_status(mode).get("connected") else []
)
ctp_by_key: dict[str, dict] = {}
for p in ctp_list:
if int(p.get("lots") or 0) <= 0:
continue
key = _canonical_position_key(p.get("symbol") or "", p.get("direction") or "long")
ctp_by_key[key] = p
rows: list[dict] = []
used_ctp_keys: set[str] = set()
for key, mon in monitor_by_key.items():
ctp = ctp_by_key.get(key)
if not ctp:
for ck, cp in ctp_by_key.items():
if ck in used_ctp_keys:
continue
if (cp.get("direction") or "long") != (mon.get("direction") or "long"):
continue
if _match_ctp_symbol(cp.get("symbol") or "", mon.get("symbol") or ""):
ctp = cp
used_ctp_keys.add(ck)
break
elif key in ctp_by_key:
used_ctp_keys.add(key)
if ctp and mon and not fast:
_sync_monitor_from_ctp(
conn, int(mon["id"]), mon.get("symbol") or "",
mon.get("direction") or "long", mode, ctp=ctp,
capital=capital,
)
mon = _find_active_monitor(conn, mon.get("symbol") or "", mon.get("direction") or "long") or mon
try:
row = _compose_position_row(
conn, mon=mon, ctp=ctp, mode=mode, capital=capital, now_iso=now_iso,
fast=fast,
)
if row:
rows.append(row)
except Exception as exc:
logger.warning("compose monitor row failed: %s", exc)
for key, ctp in ctp_by_key.items():
if key in used_ctp_keys:
continue
matched = False
for uk in used_ctp_keys:
if uk == key:
matched = True
break
if matched:
continue
for existing in rows:
if _match_ctp_symbol(
ctp.get("symbol") or "", existing.get("symbol_code") or "",
) and (ctp.get("direction") or "long") == (existing.get("direction") or "long"):
matched = True
break
if matched:
continue
mon = _find_active_monitor(
conn, ctp.get("symbol") or "", ctp.get("direction") or "long",
)
try:
row = _compose_position_row(
conn, mon=mon, ctp=ctp, mode=mode, capital=capital, now_iso=now_iso,
fast=fast,
)
if row:
rows.append(row)
except Exception as exc:
logger.warning("compose ctp row failed: %s", exc)
seen: set[str] = set()
deduped: list[dict] = []
for row in rows:
rk = row.get("key") or f"{row.get('symbol_code')}:{row.get('direction')}"
if rk in seen:
continue
seen.add(rk)
deduped.append(row)
pending_raw = [
dict(r) for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='pending' ORDER BY id DESC"
).fetchall()
]
for mon in pending_raw:
try:
prow = _compose_pending_row(
mon, mode=mode, capital=capital, now_iso=now_iso,
)
if prow:
deduped.insert(0, prow)
except Exception as exc:
logger.warning("compose pending row failed: %s", exc)
return deduped
def _build_trading_live_payload(conn, *, fast: bool = False) -> dict:
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
capital = _capital(conn)
if not fast and ctp_st.get("connected"):
_reconcile_pending(conn, mode, capital=capital)
if ctp_st.get("connected"):
_ensure_monitors_from_ctp(conn, mode)
rows = _build_trading_live_rows(conn, fast=fast)
pending_orders = _build_pending_orders(conn, mode)
risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
return {
"ok": True,
"rows": rows,
"pending_orders": pending_orders,
"capital": capital,
"ctp_status": ctp_st,
"trading_mode_label": trading_mode_label(get_setting),
"risk_status": risk,
"trading_session": is_trading_session(),
"pending_order_timeout_min": get_pending_order_timeout_min(get_setting),
}
def _refresh_trading_live_snapshot(*, fast: bool = False) -> dict:
mode = get_trading_mode(get_setting)
if not fast and ctp_status(mode).get("connected"):
try:
with _ctp_td_lock:
get_bridge().refresh_positions()
except Exception as exc:
logger.debug("refresh positions before snapshot: %s", exc)
conn = get_db()
try:
init_strategy_tables(conn)
payload = _build_trading_live_payload(conn, fast=fast)
conn.commit()
return payload
finally:
conn.close()
def _push_position_snapshot_async(*, fast: bool = False) -> None:
def _run() -> None:
try:
payload = _refresh_trading_live_snapshot(fast=fast)
position_hub.broadcast("positions", payload)
except Exception as exc:
logger.debug("push position snapshot: %s", exc)
threading.Thread(target=_run, daemon=True).start()
def _bootstrap_trading_runtime() -> None:
"""进程启动:立刻读库展示持仓,并异步连 CTP。"""
set_position_refresh_callback(
lambda: _push_position_snapshot_async(fast=False)
)
def _warm() -> None:
try:
payload = _refresh_trading_live_snapshot(fast=True)
position_hub.set_snapshot(payload)
position_hub.broadcast("positions", payload)
except Exception as exc:
logger.warning("bootstrap position snapshot: %s", exc)
threading.Thread(target=_warm, daemon=True, name="position-bootstrap").start()
try:
from vnpy_bridge import ctp_start_connect
mode = get_trading_mode(get_setting)
ctp_start_connect(mode, force=False)
except Exception as exc:
logger.debug("bootstrap ctp connect: %s", exc)
@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, active_count=_effective_active_position_count(conn, mode))
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
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)
max_pct = get_max_margin_pct(get_setting)
rec_cache = _recommend_payload(conn)
if rec_cache.get("needs_refresh"):
_schedule_recommend_refresh()
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,
active_trend=dict(active_trend) if active_trend else None,
monitor_count=monitor_count,
roll_count=roll_count,
sizing_mode=sizing,
sizing_mode_label=_sizing_mode_label(sizing),
fixed_lots=get_fixed_lots(get_setting),
fixed_amount=get_fixed_amount(get_setting),
risk_percent=get_risk_percent(get_setting),
max_margin_pct=get_max_margin_pct(get_setting),
pending_order_timeout_min=get_pending_order_timeout_min(get_setting),
recommend_rows=rec_cache.get("rows") or [],
recommend_updated_at=rec_cache.get("updated_at"),
product_categories=PRODUCT_CATEGORIES,
)
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)
payload = _build_trading_live_payload(conn, fast=True)
position_hub.set_snapshot(payload)
return jsonify(payload)
finally:
conn.close()
@app.route("/api/trading/stream")
@login_required
def api_trading_stream():
from queue import Empty
def generate():
q = position_hub.subscribe()
try:
snap = position_hub.get_snapshot()
if snap:
yield sse_format("positions", snap)
else:
payload = _refresh_trading_live_snapshot(fast=True)
position_hub.set_snapshot(payload)
yield sse_format("positions", payload)
while True:
try:
msg = q.get(timeout=25)
yield sse_format(msg["event"], msg["data"])
except Empty:
yield ": heartbeat\n\n"
finally:
position_hub.unsubscribe(q)
return Response(
generate(),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/trading/monitor/upsert", methods=["POST"])
@login_required
def api_trading_monitor_upsert():
"""为已有持仓补充/更新本地止盈止损监控。"""
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)
conn = get_db()
try:
init_strategy_tables(conn)
mon = _find_active_monitor(conn, sym, direction)
has_pos = bool(mon)
ths_sym = sym
if ctp_status(mode).get("connected"):
for p in _ctp_positions(mode, refresh_if_empty=False):
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)
ths_sym = _ctp_pos_to_ths_code(p) or sym
break
if not has_pos:
return jsonify({"ok": False, "error": "未找到对应持仓"}), 400
trailing_be = 1 if d.get("trailing_be") else (
int(mon.get("trailing_be") or 0) if mon else 0
)
mid = _upsert_open_monitor(
conn,
sym=ths_sym,
direction=direction,
lots=lots,
price=entry,
sl=sl,
tp=tp,
trailing_be=trailing_be,
)
conn.commit()
_push_position_snapshot_async()
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():
"""本地监控模式:清理旧版柜台挂单,不再向交易所挂止盈止损。"""
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():
d = request.get_json(silent=True) or {}
try:
monitor_id = int(d.get("monitor_id") or 0)
except (TypeError, ValueError):
monitor_id = 0
if monitor_id <= 0:
return jsonify({"ok": False, "error": "无效的监控记录"}), 400
conn = get_db()
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status IN ('active', 'pending')",
(monitor_id,),
).fetchone()
if not row:
return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404
mon = dict(row)
if (mon.get("status") or "").strip().lower() == "pending":
if not is_trading_session():
return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403
ok, msg = cancel_pending_monitor(conn, mon, mode)
_push_position_snapshot_async(fast=False)
return jsonify({"ok": ok, "message": msg})
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(monitor_id,),
)
conn.commit()
_push_position_snapshot_async(fast=False)
return jsonify({"ok": True, "message": "已取消本地止盈止损监控"})
finally:
conn.close()
@app.route("/api/trading/monitor/cancel-open", methods=["POST"])
@login_required
def api_trading_monitor_cancel_open():
"""撤销 pending 开仓委托(柜台撤单 + 关闭本地记录)。"""
d = request.get_json(silent=True) or {}
try:
monitor_id = int(d.get("monitor_id") or 0)
except (TypeError, ValueError):
monitor_id = 0
if monitor_id <= 0:
return jsonify({"ok": False, "error": "无效的委托记录"}), 400
conn = get_db()
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
if not is_trading_session():
return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403
row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status='pending'",
(monitor_id,),
).fetchone()
if not row:
return jsonify({"ok": False, "error": "未找到挂单中的开仓委托"}), 404
ok, msg = cancel_pending_monitor(conn, dict(row), mode)
_push_position_snapshot_async(fast=False)
return jsonify({"ok": ok, "message": msg})
finally:
conn.close()
@app.route("/api/trading/order/cancel", methods=["POST"])
@login_required
def api_trading_order_cancel():
"""撤销柜台未成交委托(按 vt_order_id)。"""
d = request.get_json(silent=True) or {}
order_id = (d.get("order_id") or "").strip()
if not order_id:
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
if not is_trading_session():
return jsonify({"ok": False, "error": "不在交易时间段,无法撤单"}), 403
ok = ctp_cancel_order(mode, order_id)
_push_position_snapshot_async(fast=False)
if not ok:
return jsonify({"ok": False, "error": "撤单失败,委托可能已成交或已撤销"}), 400
return jsonify({"ok": True, "message": "撤单已提交"})
@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"
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:
execute_order(
conn, mode=mode, offset=offset, symbol=sym, direction=direction,
lots=lots, price=price, settings=_settings_dict(),
order_type="market",
)
write_manual_close_trade_log(
conn,
mon,
symbol=sym,
direction=direction,
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()
try:
from ctp_trade_sync import sync_trade_logs_from_ctp
sync_trade_logs_from_ctp(conn, mode, capital=capital, trading_mode=mode)
conn.commit()
except Exception as exc:
logger.debug("sync trades after close: %s", exc)
conn.close()
_push_position_snapshot_async()
return jsonify({"ok": True, "message": "已平仓;交易记录将按柜台成交同步"})
except ValueError as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400
@app.route("/strategy")
@login_required
@_nav("strategy")
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)
margin_pct = get_max_margin_pct(get_setting)
if sizing == MODE_AMOUNT:
lots, err = calc_lots_by_amount(
entry, sl, direction, get_fixed_amount(get_setting), sym,
capital=capital, max_margin_pct=margin_pct,
)
if err:
return jsonify({"ok": False, "error": err}), 400
elif sizing == MODE_FIXED:
lots = get_fixed_lots(get_setting)
else:
try:
lots = max(1, int(d.get("lots") or 1))
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
order_type = (d.get("order_type") or d.get("price_type") or "limit").strip().lower()
if order_type == "market" and price <= 0:
codes = ths_to_codes(sym)
price = fetch_price(
sym,
codes.get("market_code", "") if codes else "",
codes.get("sina_code", "") if codes else "",
) or 0
if not sym or price <= 0:
return jsonify({"ok": False, "error": "品种或价格无效"}), 400
conn = get_db()
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
if offset.startswith("open"):
_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))
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 403
ctp_st = ctp_status(mode)
if not ctp_st.get("connected"):
conn.close()
if get_bridge().connect_in_progress():
return jsonify({"ok": False, "error": "CTP 连接中,请稍候再下单"}), 400
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
sizing = get_sizing_mode(get_setting)
if offset.startswith("open") and sizing == MODE_AMOUNT:
sl = float(d.get("stop_loss") or 0)
if sl <= 0:
conn.close()
return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400
lots_calc, err = calc_lots_by_amount(
price, sl, direction, get_fixed_amount(get_setting), sym,
capital=_capital(conn), max_margin_pct=get_max_margin_pct(get_setting),
)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
lots = lots_calc or lots
elif offset.startswith("open") and sizing == MODE_FIXED:
lots = get_fixed_lots(get_setting)
margin_pct = get_max_margin_pct(get_setting)
usage = calc_margin_usage_pct(
_ctp_positions(mode),
_capital(conn),
extra_symbol=sym if offset.startswith("open") else "",
extra_lots=lots if offset.startswith("open") else 0,
extra_price=price if offset.startswith("open") else 0,
)
if offset.startswith("open") and usage > margin_pct:
conn.close()
return jsonify({
"ok": False,
"error": f"保证金占用 {usage:.1f}% 超过上限 {margin_pct:g}%(可在系统设置修改)",
}), 403
if lots > DEFAULT_MAX_ORDER_LOTS:
conn.close()
return jsonify({
"ok": False,
"error": f"单笔手数 {lots} 超过上限 {DEFAULT_MAX_ORDER_LOTS},请加大止损距离或改固定手数",
}), 400
try:
result = execute_order(
conn,
mode=mode,
offset=offset,
symbol=sym,
direction=direction,
lots=lots,
price=price,
settings=_settings_dict(),
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"):
from zoneinfo import ZoneInfo
sl = d.get("stop_loss")
tp = d.get("take_profit")
trailing_be = 1 if d.get("trailing_be") else 0
open_ts = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
vt_order_id = str(result.get("order_id") or "")
mid = _upsert_open_monitor(
conn,
sym=sym,
direction=direction,
lots=lots,
price=price,
sl=sl,
tp=tp,
trailing_be=trailing_be,
open_time=open_ts,
monitor_type="manual",
status="pending",
vt_order_id=vt_order_id or None,
order_price=price,
)
conn.commit()
_reconcile_pending(conn, mode, capital=_capital(conn))
st_row = conn.execute(
"SELECT status FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
filled = st_row and (st_row["status"] or "").strip().lower() == "active"
if not filled:
try:
get_bridge().refresh_positions()
except Exception:
pass
_reconcile_pending(conn, mode, capital=_capital(conn))
st_row = conn.execute(
"SELECT status FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
filled = st_row and (st_row["status"] or "").strip().lower() == "active"
if filled:
_sync_monitor_from_ctp(
conn, mid, sym, direction, mode, capital=_capital(conn),
)
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)
cancel_monitor_exit_orders(conn, dict(mon_row), mode=mode)
except Exception as exc:
logger.warning("清理旧版止盈止损挂单失败: %s", exc)
conn.commit()
_push_position_snapshot_async(fast=False)
msg = (
f"开仓成功 · {lots}"
if filled
else (
f"委托已提交 · {lots} 手挂单中"
f"{get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)"
)
)
conn.commit()
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
conn.close()
_push_position_snapshot_async()
return jsonify({
"ok": True,
"result": result,
"lots": lots,
"message": msg if offset.startswith("open") else "委托已提交柜台",
"filled": filled if offset.startswith("open") else None,
})
except (ValueError, RuntimeError) 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():
from vnpy_bridge import ctp_start_connect
mode = get_trading_mode(get_setting)
body = request.get_json(silent=True) or {}
force = bool(body.get("force"))
info = ctp_start_connect(mode, force=force)
st = info.get("status") or ctp_status(mode)
acc = _ctp_account(mode) if st.get("connected") else {}
if st.get("connected"):
return jsonify({"ok": True, "status": st, "account": acc})
if info.get("connecting") or info.get("started"):
return jsonify({
"ok": True,
"connecting": True,
"status": st,
"account": acc,
})
if info.get("cooldown"):
return jsonify({
"ok": False,
"cooldown": True,
"error": st.get("last_error") or "CTP 登录冷却中",
"status": st,
"account": acc,
}), 400
return jsonify({
"ok": False,
"error": st.get("last_error") or "CTP 连接未启动",
"status": st,
"account": acc,
}), 400
@app.route("/api/ctp/status")
@login_required
def api_ctp_status():
mode = get_trading_mode(get_setting)
st = ctp_status(mode)
acc = {}
if st.get("connected"):
try:
acc = _ctp_account(mode)
except Exception:
acc = {}
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, active_count=_effective_active_position_count(conn, mode))
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()
try:
payload = _recommend_payload(conn)
return jsonify({"ok": True, **payload})
finally:
conn.close()
@app.route("/api/recommend/stream")
@login_required
def api_recommend_stream():
from queue import Empty
def generate():
q = recommend_hub.subscribe()
try:
conn = get_db()
try:
payload = _recommend_payload(conn)
finally:
conn.close()
yield sse_format("recommend", {"ok": True, **payload})
while True:
try:
msg = q.get(timeout=25)
yield sse_format(msg["event"], msg["data"])
except Empty:
yield ": heartbeat\n\n"
finally:
recommend_hub.unsubscribe(q)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@app.route("/api/recommend/refresh", methods=["POST"])
@login_required
def api_recommend_refresh():
"""手动触发一次后台刷新(仍写入数据库)。"""
conn = get_db()
try:
init_strategy_tables(conn)
capital = _capital(conn)
mode = get_trading_mode(get_setting)
rows = refresh_recommend_cache(
conn, capital, _main_quote, trading_mode=mode,
max_margin_pct=get_max_margin_pct(get_setting),
)
max_pct = get_max_margin_pct(get_setting)
payload = _recommend_payload(conn)
recommend_hub.broadcast("recommend", {"ok": True, **payload})
return jsonify({"ok": True, "count": len(rows), **payload})
finally:
conn.close()
@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
from db_conn import DB_PATH
def _init_tables(conn):
init_strategy_tables(conn)
start_recommend_worker(
db_path=DB_PATH,
get_capital_fn=_capital,
quote_fn=_main_quote,
init_tables_fn=_init_tables,
get_mode_fn=lambda: get_trading_mode(get_setting),
get_max_margin_pct_fn=lambda: get_max_margin_pct(get_setting),
get_sizing_mode_fn=lambda: get_sizing_mode(get_setting),
get_fixed_lots_fn=lambda: get_fixed_lots(get_setting),
)
start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
start_ctp_premarket_connect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
start_sl_tp_guard_worker(
db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables,
get_capital_fn=_capital,
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
notify_fn=send_wechat_msg,
interval=1,
)
_pos_refresh_tick = {"n": 0}
def _position_worker_refresh() -> dict:
_pos_refresh_tick["n"] += 1
mode = get_trading_mode(get_setting)
connected = bool(ctp_status(mode).get("connected"))
# 已连接时每 2 秒完整对账;未连接时每 5 秒轻量刷新(禁止 query_position
if connected:
use_fast = _pos_refresh_tick["n"] % 2 != 0
else:
use_fast = _pos_refresh_tick["n"] % 5 != 0
payload = _refresh_trading_live_snapshot(fast=use_fast)
if connected and use_fast and any(
r.get("sync_pending") for r in (payload.get("rows") or [])
):
payload = _refresh_trading_live_snapshot(fast=False)
return payload
start_position_worker(
refresh_fn=_position_worker_refresh,
interval=1,
)
_bootstrap_trading_runtime()
start_ctp_fee_worker(
get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)