Files
qihuo/install_trading.py
T
2026-06-24 14:20:15 +08:00

1370 lines
57 KiB
Python

"""期货下单、品种推荐、策略交易路由注册。"""
from __future__ import annotations
import json
import logging
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 position_sizing import (
MODE_FIXED,
MODE_RISK,
DEFAULT_MAX_ORDER_LOTS,
calc_lots_by_risk,
calc_order_tick_metrics,
normalize_sizing_mode,
)
from recommend_store import load_recommend_cache, recommend_payload, refresh_recommend_cache
from recommend_stream import recommend_hub, start_recommend_worker
from ctp_reconnect import start_ctp_reconnect_worker
from ctp_fee_worker import start_ctp_fee_worker
from sl_tp_guard import (
ensure_monitor_order_columns,
monitor_order_status,
place_monitor_exit_orders,
start_sl_tp_guard_worker,
sync_all_sl_tp_orders,
)
from risk.account_risk_lib import (
assert_can_open,
get_risk_status,
on_mood_journal_freeze,
on_user_initiated_close,
parse_mood_issues,
reduce_cooloff_after_journal,
trading_day_label,
)
from strategy.strategy_db import init_strategy_tables
from strategy.strategy_roll_lib import preview_roll
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
from strategy.strategy_trend_lib import compute_trend_plan_futures, trend_dca_level_reached
from strategy.strategy_snapshot_lib import STRATEGY_ROLL, STRATEGY_TREND
from symbols import ths_to_codes, resolve_main_contract, PRODUCTS
from trading_context import (
TRADING_MODE_LIVE,
TRADING_MODE_SIM,
get_account_capital,
get_risk_percent,
get_sizing_mode,
get_trading_mode,
trading_mode_label,
)
from ctp_symbol import ths_to_vnpy_symbol
from vnpy_bridge import (
ctp_connect,
ctp_get_account,
ctp_list_active_orders,
ctp_list_positions,
ctp_status,
execute_order,
get_bridge,
)
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 _settings_dict() -> dict:
return {
"trading_mode": get_trading_mode(get_setting),
"position_sizing_mode": get_sizing_mode(get_setting),
"risk_percent": str(get_risk_percent(get_setting)),
}
def _capital(conn) -> float:
return get_account_capital(conn, get_setting)
def _main_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) -> list:
try:
return ctp_list_positions(mode)
except Exception:
return []
def _match_ctp_symbol(ctp_sym: str, ths: str) -> bool:
a = (ctp_sym or "").lower()
b = (ths or "").lower()
if a == b:
return True
try:
vnpy_sym, _ = ths_to_vnpy_symbol(ths)
return a == vnpy_sym.lower()
except Exception:
return False
def _holding_duration(open_time: str, now_iso: str) -> str:
try:
from app import calc_holding_duration
return calc_holding_duration(open_time, now_iso)
except Exception:
return ""
def _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 持仓的 active 监控(委托被拒或未成交的幽灵记录)。"""
if not ctp_status(mode).get("connected"):
return 0
position_keys = _ctp_position_keys(mode)
closed = 0
for r in conn.execute("SELECT * FROM trade_order_monitors WHERE status='active'").fetchall():
mon = dict(r)
if _monitor_matches_ctp_position(mon, position_keys):
continue
conn.execute("UPDATE trade_order_monitors SET status='closed' WHERE id=?", (mon["id"],))
closed += 1
return closed
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,
"symbol": mon.get("symbol_name") or sym,
"direction": direction,
"direction_label": "做多" if direction == "long" else "做空",
"lots": lots,
"source": "monitor",
"monitor_id": mon.get("id"),
}
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),
})
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,
"symbol": 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"),
})
return pending
def _ctp_active_orders(mode: str) -> list:
try:
return ctp_list_active_orders(mode)
except Exception:
return []
def _build_trading_live_rows(conn) -> list[dict]:
from zoneinfo import ZoneInfo
tz = ZoneInfo("Asia/Shanghai")
now_iso = datetime.now(tz).strftime("%Y-%m-%dT%H:%M")
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
rows: list[dict] = []
capital = _capital(conn)
if not ctp_st.get("connected"):
return rows
ensure_monitor_order_columns(conn)
# 程序监控仅用于补充止损/止盈,持仓以 CTP 柜台为准
monitor_map: dict[tuple[str, str], dict] = {}
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active'"
).fetchall():
key = (r["symbol"].lower(), r["direction"])
monitor_map[key] = dict(r)
for p in _ctp_positions(mode):
sym = (p.get("symbol") or "").strip()
direction = p.get("direction") or "long"
lots = int(p.get("lots") or 0)
if lots <= 0:
continue
entry = float(p.get("avg_price") or 0)
float_pnl = p.get("pnl")
if float_pnl is not None:
float_pnl = round(float(float_pnl), 2)
codes = ths_to_codes(sym)
tick = calc_order_tick_metrics(sym, lots, entry)
mon = None
for (ms, md), mv in monitor_map.items():
if md != direction:
continue
if ms == sym.lower() or _match_ctp_symbol(sym, ms):
mon = mv
break
sl = float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None
tp = float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None
open_time = (mon.get("open_time") or "") if mon else ""
holding = _holding_duration(open_time, now_iso) if open_time else ""
mark = None
if codes:
mark = fetch_price(
sym,
codes.get("market_code", ""),
codes.get("sina_code", ""),
)
close_est = float(mark) if mark and mark > 0 else entry
fee_info = calc_fee_breakdown(
sym,
entry,
close_est,
lots,
open_time or now_iso,
now_iso,
trading_mode=mode,
)
est_net = None
if float_pnl is not None:
est_net = round(float(float_pnl) - fee_info["total_fee"], 2)
pos_metrics = calc_position_metrics(
direction,
entry,
sl if sl is not None else entry,
tp if tp is not None else entry,
lots,
mark,
capital,
sym,
)
order_st = monitor_order_status(
mon or {}, mode=mode, ths_code=sym, direction=direction,
)
can_place = bool(
mon
and (mon.get("stop_loss") is not None or mon.get("take_profit") is not None)
and (order_st.get("needs_sl_order") or order_st.get("needs_tp_order"))
)
pending_for_row: list[dict] = []
if sl is not None:
pending_for_row.append({
"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,
})
rows.append({
"key": f"ctp:{sym.lower()}:{direction}",
"source": "ctp",
"source_label": "CTP 柜台",
"monitor_id": mon["id"] if mon else None,
"symbol": codes.get("name", sym) if codes else sym,
"symbol_code": sym,
"direction": direction,
"direction_label": "做多" if direction == "long" else "做空",
"lots": lots,
"entry_price": entry,
"stop_loss": sl,
"take_profit": tp,
"open_time": open_time or None,
"holding_duration": holding or None,
"mark_price": mark,
"current_price": mark,
"margin": pos_metrics.get("margin"),
"position_pct": pos_metrics.get("position_pct"),
"float_pnl": float_pnl,
"est_fee": fee_info["total_fee"],
"est_fee_open": fee_info["open_fee"],
"est_fee_close": fee_info["close_fee"],
"est_fee_close_type": fee_info["close_type"],
"est_pnl_net": est_net,
"sl_order_active": order_st.get("sl_order_active"),
"tp_order_active": order_st.get("tp_order_active"),
"can_place_orders": can_place,
"tick_value_total": tick.get("tick_value_total"),
"price_precision": tick.get("price_precision"),
"tick_size": tick.get("tick_size"),
"can_close": True,
"pending_orders": pending_for_row,
})
return rows
@app.route("/trade")
@login_required
def trade_page():
return redirect(url_for("positions"))
@app.route("/positions")
@login_required
def positions():
conn = get_db()
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
_sync_trade_monitors_with_ctp(conn, 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)
rec_cache = recommend_payload(conn, live_capital=capital)
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="以损定仓" if sizing == MODE_RISK else "固定张数",
risk_percent=get_risk_percent(get_setting),
recommend_rows=rec_cache.get("rows") or [],
recommend_updated_at=rec_cache.get("updated_at"),
)
finally:
conn.close()
@app.route("/recommend")
@login_required
def recommend_page():
return redirect(url_for("positions") + "#recommend")
@app.route("/api/trading/live")
@login_required
def api_trading_live():
conn = get_db()
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
_sync_trade_monitors_with_ctp(conn, mode)
sync_all_sl_tp_orders(conn, mode)
rows = _build_trading_live_rows(conn)
pending_orders = _build_pending_orders(conn, mode)
capital = _capital(conn)
risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode))
conn.commit()
return jsonify({
"rows": rows,
"pending_orders": pending_orders,
"capital": capital,
"ctp_status": ctp_st,
"trading_mode_label": trading_mode_label(get_setting),
"risk_status": risk,
})
finally:
conn.close()
@app.route("/api/trading/monitor/upsert", methods=["POST"])
@login_required
def api_trading_monitor_upsert():
"""为已有 CTP 持仓补充/更新本地止盈止损监控。"""
d = request.get_json(silent=True) or {}
sym = (d.get("symbol_code") or d.get("symbol") or "").strip()
direction = (d.get("direction") or "long").strip().lower()
try:
lots = max(1, int(d.get("lots") or 1))
entry = float(d.get("entry_price") or d.get("entry") or 0)
sl = float(d["stop_loss"]) if d.get("stop_loss") not in (None, "") else None
tp = float(d["take_profit"]) if d.get("take_profit") not in (None, "") else None
except (TypeError, ValueError, KeyError):
return jsonify({"ok": False, "error": "参数无效"}), 400
if not sym:
return jsonify({"ok": False, "error": "缺少品种代码"}), 400
if sl is None and tp is None:
return jsonify({"ok": False, "error": "请至少填写止损或止盈"}), 400
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
has_pos = False
for p in _ctp_positions(mode):
if int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
continue
if _match_ctp_symbol(p.get("symbol") or "", sym):
has_pos = True
lots = int(p.get("lots") or lots)
entry = float(p.get("avg_price") or entry or 0)
sym = (p.get("symbol") or sym).strip()
break
if not has_pos:
return jsonify({"ok": False, "error": "柜台无对应持仓"}), 400
conn = get_db()
try:
init_strategy_tables(conn)
mon = None
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active'"
).fetchall():
row = dict(r)
if row.get("direction") != direction:
continue
if _match_ctp_symbol(sym, row.get("symbol") or ""):
mon = row
break
codes = ths_to_codes(sym)
now_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if mon:
conn.execute(
"""UPDATE trade_order_monitors SET stop_loss=?, take_profit=?, lots=?, entry_price=?
WHERE id=?""",
(sl, tp, lots, entry or mon.get("entry_price"), mon["id"]),
)
mid = mon["id"]
else:
conn.execute(
"""INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, open_time, monitor_type, status
) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""",
(
sym,
codes.get("name", sym) if codes else sym,
codes.get("market_code", "") if codes else "",
direction,
lots,
entry,
sl,
tp,
now_s,
"manual",
),
)
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.commit()
mon_row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
if mon_row and (sl is not None or tp is not None):
try:
ensure_monitor_order_columns(conn)
place_monitor_exit_orders(conn, dict(mon_row), mode=mode, force=False)
except Exception as exc:
logger.warning("补充止盈止损后自动委托失败: %s", exc)
return jsonify({"ok": True, "monitor_id": mid, "message": "止盈止损已保存"})
finally:
conn.close()
@app.route("/api/trading/monitor/place-orders", methods=["POST"])
@login_required
def api_trading_monitor_place_orders():
"""按开仓快照向 CTP 挂止盈止损平仓委托。"""
d = request.get_json(silent=True) or {}
try:
monitor_id = int(d.get("monitor_id") or 0)
except (TypeError, ValueError):
monitor_id = 0
conn = get_db()
try:
init_strategy_tables(conn)
ensure_monitor_order_columns(conn)
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
mon = None
if monitor_id > 0:
row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=? AND status='active'",
(monitor_id,),
).fetchone()
mon = dict(row) if row else None
if not mon:
sym = (d.get("symbol_code") or "").strip()
direction = (d.get("direction") or "long").strip().lower()
for r in conn.execute(
"SELECT * FROM trade_order_monitors WHERE status='active'"
).fetchall():
row = dict(r)
if row.get("direction") != direction:
continue
if _match_ctp_symbol(sym, row.get("symbol") or ""):
mon = row
break
if not mon:
return jsonify({"ok": False, "error": "未找到有效监控快照"}), 404
result = place_monitor_exit_orders(
conn, mon, mode=mode, force=bool(d.get("force")),
)
if not result.get("ok"):
return jsonify(result), 400
return jsonify(result)
finally:
conn.close()
@app.route("/api/trading/monitor/dismiss", methods=["POST"])
@login_required
def api_trading_monitor_dismiss():
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)
row = conn.execute(
"SELECT id FROM trade_order_monitors WHERE id=? AND status='active'",
(monitor_id,),
).fetchone()
if not row:
return jsonify({"ok": False, "error": "记录不存在或已关闭"}), 404
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(monitor_id,),
)
conn.commit()
return jsonify({"ok": True, "message": "已取消本地止盈止损监控"})
finally:
conn.close()
@app.route("/api/trading/close", methods=["POST"])
@login_required
def api_trading_close():
d = request.get_json(silent=True) or {}
source = (d.get("source") or "").strip()
conn = get_db()
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected") and source in ("ctp", "program"):
conn.close()
return jsonify({"ok": False, "error": "请先连接 CTP"}), 400
sym = (d.get("symbol_code") or d.get("symbol") or "").strip()
direction = (d.get("direction") or "long").strip().lower()
try:
lots = max(1, int(d.get("lots") or 1))
price = float(d.get("price") or 0)
except (TypeError, ValueError):
conn.close()
return jsonify({"ok": False, "error": "参数无效"}), 400
if not sym or price <= 0:
conn.close()
return jsonify({"ok": False, "error": "品种或价格无效"}), 400
offset = "close_long" if direction == "long" else "close_short"
try:
execute_order(
conn, mode=mode, offset=offset, symbol=sym, direction=direction,
lots=lots, price=price, settings=_settings_dict(),
)
if source == "program":
mid = int(d.get("monitor_id") or 0)
if mid:
conn.execute(
"UPDATE trade_order_monitors SET status='closed' WHERE id=?",
(mid,),
)
conn.commit()
conn.close()
return jsonify({"ok": True})
except ValueError as exc:
conn.close()
return jsonify({"ok": False, "error": str(exc)}), 400
@app.route("/strategy")
@login_required
@_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)
if sizing == MODE_RISK:
lots, err = calc_lots_by_risk(entry, sl, direction, capital, get_risk_percent(get_setting), sym)
if err:
return jsonify({"ok": False, "error": err}), 400
else:
try:
lots = max(1, int(d.get("lots") or 1))
except (TypeError, ValueError):
lots = 1
metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym)
tick = calc_order_tick_metrics(sym, lots, entry)
return jsonify({"ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital})
@app.route("/api/trade/order", methods=["POST"])
@login_required
def api_trade_order():
d = request.get_json(silent=True) or {}
sym = (d.get("symbol") or "").strip()
offset = (d.get("offset") or "open").strip().lower()
direction = (d.get("direction") or "long").strip().lower()
try:
lots = max(1, int(d.get("lots") or 1))
price = float(d.get("price") or 0)
except (TypeError, ValueError):
return jsonify({"ok": False, "error": "手数或价格无效"}), 400
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)
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_RISK:
sl = float(d.get("stop_loss") or 0)
if sl <= 0:
conn.close()
return jsonify({"ok": False, "error": "以损定仓模式须填写止损价"}), 400
lots_calc, err = calc_lots_by_risk(
price, sl, direction, _capital(conn), get_risk_percent(get_setting), sym,
)
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
lots = lots_calc or lots
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"):
sl = d.get("stop_loss")
tp = d.get("take_profit")
import time
time.sleep(2.0)
actual_lots = lots
has_pos = False
for p in _ctp_positions(mode):
if int(p.get("lots") or 0) <= 0:
continue
if (p.get("direction") or "long") != direction:
continue
if _match_ctp_symbol(p.get("symbol") or "", sym):
has_pos = True
actual_lots = int(p.get("lots") or lots)
break
if has_pos:
codes = ths_to_codes(sym)
conn.execute(
"""INSERT INTO trade_order_monitors (
symbol, symbol_name, market_code, direction, lots, entry_price,
stop_loss, take_profit, open_time, monitor_type, status
) VALUES (?,?,?,?,?,?,?,?,?,?, 'active')""",
(
sym,
codes.get("name", sym) if codes else sym,
codes.get("market_code", "") if codes else "",
direction,
actual_lots,
price,
float(sl) if sl else None,
float(tp) if tp else None,
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"manual",
),
)
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
mon_row = conn.execute(
"SELECT * FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
if mon_row and (sl or tp):
try:
ensure_monitor_order_columns(conn)
place_monitor_exit_orders(conn, dict(mon_row), mode=mode, force=False)
except Exception as exc:
logger.warning("开仓后自动挂止盈止损失败: %s", exc)
conn.commit()
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
conn.close()
return jsonify({"ok": True, "result": result, "lots": lots, "message": "委托已提交柜台,限价单需成交后才会显示持仓"})
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,
})
try:
st = ctp_connect(mode, force=force)
acc = _ctp_account(mode)
return jsonify({"ok": True, "status": st, "account": acc})
except Exception as exc:
st = ctp_status(mode)
return jsonify({"ok": False, "error": str(exc), "status": st}), 400
@app.route("/api/ctp/status")
@login_required
def api_ctp_status():
mode = get_trading_mode(get_setting)
st = ctp_status(mode)
acc = _ctp_account(mode) if st.get("connected") else {}
return jsonify({"ok": True, "status": st, "account": acc})
@app.route("/api/account_snapshot")
@login_required
def api_account_snapshot():
conn = get_db()
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
_sync_trade_monitors_with_ctp(conn, 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, live_capital=_capital(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, live_capital=_capital(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)
payload = recommend_payload(conn, live_capital=capital)
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),
)
start_ctp_reconnect_worker(get_mode_fn=lambda: get_trading_mode(get_setting))
start_sl_tp_guard_worker(
db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables,
)
start_ctp_fee_worker(
get_mode_fn=lambda: get_trading_mode(get_setting),
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)