deb9501cbe
Co-authored-by: Cursor <cursoragent@cursor.com>
2278 lines
92 KiB
Python
2278 lines
92 KiB
Python
# 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_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 _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
|
||
margin = ctp_margin if ctp_margin > 0 else None
|
||
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,
|
||
)
|
||
if margin is None or float(margin or 0) <= 0:
|
||
ctp_margin = float(ctp.get("margin") or 0) if ctp else 0.0
|
||
est_margin = pos_metrics.get("margin")
|
||
margin = ctp_margin if ctp_margin > 0 else est_margin
|
||
margin_source = "ctp" if ctp_margin > 0 else "estimate"
|
||
else:
|
||
margin_source = "ctp"
|
||
if position_pct is None or float(position_pct or 0) <= 0:
|
||
position_pct = (
|
||
round(float(margin) / capital * 100, 2)
|
||
if capital > 0 and margin
|
||
else pos_metrics.get("position_pct")
|
||
)
|
||
else:
|
||
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)
|
||
return {
|
||
"key": row_key,
|
||
"source": "ctp" if ctp else "local",
|
||
"source_label": source_label,
|
||
"sync_pending": ctp is None and mon is not None,
|
||
"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 not fast:
|
||
_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
|
||
# 每秒轻量刷新;每 5 秒做一次 CTP 持仓/挂单对账,避免频繁 query 导致 vnctptd 崩溃
|
||
return _refresh_trading_live_snapshot(fast=(_pos_refresh_tick["n"] % 5 != 0))
|
||
|
||
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,
|
||
)
|