Add key-level auto trade, AI analysis, and trading UX improvements.

Key monitors use 5m close triggers with WeChat alerts and box/convergence auto orders; add pending-order worker, structured WeChat notify, AI settings/messages, session clock, CTP margin sizing, and dual-layer position limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 10:36:56 +08:00
parent 0109b59f27
commit 840e88daad
33 changed files with 2514 additions and 143 deletions
+305 -13
View File
@@ -17,7 +17,7 @@ from flask import flash, jsonify, redirect, render_template, request, url_for, R
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_night_trading_session, is_trading_session
from market_sessions import is_night_trading_session, is_trading_session, trading_session_clock
from position_sizing import (
MODE_AMOUNT,
MODE_FIXED,
@@ -25,12 +25,14 @@ from position_sizing import (
calc_lots_by_amount,
calc_lots_by_risk,
calc_margin_usage_pct,
cap_lots_for_margin_budget,
calc_order_tick_metrics,
normalize_sizing_mode,
)
from product_recommend import (
assert_product_allowed_for_capital,
should_apply_small_account_scope,
small_account_margin_recommendations,
small_account_scope_hint,
SMALL_ACCOUNT_SCOPE_LABEL,
)
@@ -44,6 +46,7 @@ from ctp_settings import is_ctp_auto_connect_enabled
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 pending_order_worker import start_pending_order_worker
from order_pending import (
cancel_pending_monitor,
pending_auto_cancel_remaining,
@@ -71,7 +74,7 @@ from risk.account_risk_lib import (
trading_day_label,
)
from strategy.strategy_db import init_strategy_tables
from strategy.strategy_roll_lib import preview_roll
from strategy.strategy_roll_lib import avg_entry_after_add, preview_roll
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
from strategy.strategy_trend_lib import (
compute_trend_plan_futures,
@@ -93,6 +96,7 @@ from trading_context import (
get_pending_order_timeout_min,
get_pending_order_timeout_sec,
get_recommend_capital,
get_roll_max_margin_pct,
get_risk_percent,
get_sizing_mode,
get_trailing_be_tick_buffer,
@@ -1015,7 +1019,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
margin = ctp_margin
codes = ths_to_codes(sym)
tick = calc_order_tick_metrics(sym, lots, entry)
tick = calc_order_tick_metrics(sym, lots, entry, trading_mode=mode)
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 ""
@@ -1282,8 +1286,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"trailing_r_locked": 0,
}
def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> None:
reconcile_pending_orders(
def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> dict[str, int]:
return reconcile_pending_orders(
conn,
mode,
match_symbol_fn=_match_ctp_symbol,
@@ -1512,6 +1516,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"risk_status": risk,
"trading_session": is_trading_session(),
"night_session": is_night_trading_session(),
"session_clock": trading_session_clock(),
"pending_order_timeout_min": get_pending_order_timeout_min(get_setting),
"sync_state": trading_state.sync_state,
"sync_label": trading_state.sync_label(),
@@ -1636,6 +1641,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if rec_cache.get("needs_refresh"):
_schedule_recommend_refresh()
ctp_connected = is_ctp_connected(get_setting)
margin_rec = small_account_margin_recommendations()
return render_template(
"trade.html",
trading_mode=mode,
@@ -1663,6 +1669,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
capital, ctp_connected=ctp_connected,
),
small_account_scope_hint=small_account_scope_hint(ctp_connected=ctp_connected),
small_account_margin_rec=margin_rec if should_apply_small_account_scope(
capital, ctp_connected=ctp_connected,
) else None,
session_clock=trading_session_clock(),
roll_max_margin_pct=get_roll_max_margin_pct(get_setting),
product_categories=PRODUCT_CATEGORIES,
)
finally:
@@ -2060,11 +2071,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
lots_f = max(1, int(float(lots)))
except (TypeError, ValueError):
lots_f = 1
metrics = calc_order_tick_metrics(sym, lots_f, price)
mode = get_trading_mode(get_setting)
metrics = calc_order_tick_metrics(sym, lots_f, price, trading_mode=mode)
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):
@@ -2111,10 +2122,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
conn.close()
sizing = get_sizing_mode(get_setting)
margin_pct = get_max_margin_pct(get_setting)
sizing_info = {}
if sizing == MODE_AMOUNT:
lots, err = calc_lots_by_amount(
lots, err, sizing_info = calc_lots_by_amount(
entry, sl, direction, get_fixed_amount(get_setting), sym,
capital=capital, max_margin_pct=margin_pct,
trading_mode=get_trading_mode(get_setting),
)
if err:
return jsonify({"ok": False, "error": err}), 400
@@ -2126,8 +2139,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
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})
tick = calc_order_tick_metrics(
sym, lots, entry, direction=direction, trading_mode=get_trading_mode(get_setting),
)
return jsonify({
"ok": True, "lots": lots, "sizing_mode": sizing,
"metrics": metrics, "tick": tick, "capital": capital,
"sizing_info": sizing_info,
})
@app.route("/api/trade/order", methods=["POST"])
@login_required
@@ -2184,9 +2203,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if sl <= 0:
conn.close()
return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400
lots_calc, err = calc_lots_by_amount(
lots_calc, err, _sizing_info = 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),
trading_mode=mode,
)
if err:
conn.close()
@@ -2201,6 +2221,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
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,
extra_direction=direction if offset.startswith("open") else "long",
trading_mode=mode,
)
if offset.startswith("open") and usage > margin_pct:
conn.close()
@@ -2313,7 +2335,52 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
)
)
conn.commit()
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
if offset.startswith("open"):
from db_conn import DB_PATH
from ai_worker import schedule_ai_event_analysis
from trade_notify import notify_manual_open_filled
if filled:
open_sl = float(d.get("stop_loss") or 0) if d.get("stop_loss") else None
open_tp = None if d.get("trailing_be") else d.get("take_profit")
if open_tp is not None:
try:
open_tp = float(open_tp)
except (TypeError, ValueError):
open_tp = None
codes = ths_to_codes(sym) or {}
if open_sl and open_sl > 0:
notify_manual_open_filled(
send_wechat=send_wechat_msg,
get_setting=get_setting,
mode_label=trading_mode_label(get_setting),
sym=sym,
symbol_name=codes.get("name") or sym,
direction=direction,
entry=price,
sl=open_sl,
tp=open_tp,
lots=lots,
capital=_capital(conn),
order_id=str(result.get("order_id") or ""),
trailing_be=bool(d.get("trailing_be")),
be_tick_buffer=get_trailing_be_tick_buffer(get_setting),
schedule_ai_fn=schedule_ai_event_analysis,
db_path=DB_PATH,
)
else:
send_wechat_msg(
f"{trading_mode_label(get_setting)} 开仓 {sym} {direction} {lots}手 @{price}"
)
elif not filled:
send_wechat_msg(
f"委托已提交 · {sym} {direction} {lots}手挂单中"
f"{get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)"
)
elif not offset.startswith("open"):
send_wechat_msg(
f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}"
)
conn.close()
_push_position_snapshot_async()
return jsonify({
@@ -2587,6 +2654,57 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}")
return jsonify({"ok": True, "plan_id": plan_id, "plan": plan})
def _apply_roll_margin_cap(
preview: dict,
*,
conn,
mode: str,
mon: dict,
capital: float,
) -> tuple[dict, Optional[str]]:
"""滚仓:风险算手数后再按滚仓保证金上限收紧。"""
if not preview:
return preview, "预览无效"
sym = mon["symbol"]
direction = (mon.get("direction") or "long").strip().lower()
price = float(preview.get("add_price") or 0)
qty_existing = float(mon.get("lots") or 0)
entry_existing = float(mon.get("entry_price") or 0)
mult = int(get_contract_spec(sym).get("mult") or 1)
roll_pct = get_roll_max_margin_pct(get_setting)
add_lots = int(preview.get("add_lots") or 0)
positions = _ctp_positions(mode, refresh_if_empty=False)
capped, usage = cap_lots_for_margin_budget(
positions, capital, sym, direction, price, add_lots, roll_pct, trading_mode=mode,
)
if capped < 1:
return preview, f"滚仓后保证金占用将超过上限 {roll_pct:g}%"
out = dict(preview)
if capped < add_lots:
out["add_lots"] = capped
out["qty_after"] = int(qty_existing + capped)
out["avg_entry_after"] = round(
avg_entry_after_add(qty_existing, entry_existing, capped, price), 4,
)
sl = float(out.get("new_stop_loss") or 0)
tp = float(out.get("initial_take_profit") or 0)
new_avg = float(out["avg_entry_after"])
new_qty = float(out["qty_after"])
if direction == "long":
out["loss_at_sl"] = round((new_avg - sl) * new_qty * mult, 2)
out["reward_at_tp"] = round((tp - new_avg) * new_qty * mult, 2)
else:
out["loss_at_sl"] = round((sl - new_avg) * new_qty * mult, 2)
out["reward_at_tp"] = round((new_avg - tp) * new_qty * mult, 2)
out["margin_capped"] = True
out["margin_cap_note"] = (
f"按滚仓保证金上限 {roll_pct:g}% 收紧:"
f"风险算 {add_lots} 手 → 实际 {capped}"
)
out["margin_usage_pct"] = round(usage, 2)
out["roll_max_margin_pct"] = roll_pct
return out, None
@app.route("/api/strategy/roll/preview", methods=["POST"])
@login_required
def api_roll_preview():
@@ -2618,6 +2736,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
)
if err:
return jsonify({"ok": False, "error": err}), 400
preview, merr = _apply_roll_margin_cap(
preview, conn=conn, mode=get_trading_mode(get_setting), mon=dict(mon), capital=capital,
)
if merr:
return jsonify({"ok": False, "error": merr}), 400
return jsonify({"ok": True, "preview": preview})
@app.route("/api/strategy/roll/execute", methods=["POST"])
@@ -2637,6 +2760,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
sym = mon["symbol"]
spec = get_contract_spec(sym)
capital = _capital(conn)
mode = get_trading_mode(get_setting)
prev, err = preview_roll(
direction=mon["direction"],
symbol=sym,
@@ -2653,8 +2777,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
prev, merr = _apply_roll_margin_cap(
prev, conn=conn, mode=mode, mon=dict(mon), capital=capital,
)
if merr:
conn.close()
return jsonify({"ok": False, "error": merr}), 400
price = float(prev["add_price"])
mode = get_trading_mode(get_setting)
try:
execute_order(
conn, mode=mode, offset="open", symbol=sym,
@@ -2801,6 +2930,153 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
app._check_trend_plans = check_trend_plans
def _execute_key_breakout(conn, row, bar, break_side):
"""关键位箱体/收敛:5m 收盘突破后自动市价开仓。"""
from key_monitor_lib import (
calc_breakout_sl_tp,
format_auto_breakout_msg,
normalize_monitor_type,
resolve_order_direction,
)
sym = (row.get("symbol") or "").strip()
bar_time = str(bar.get("time") or "")[:19]
monitor_type = normalize_monitor_type(row.get("monitor_type") or "")
trade_mode = row.get("trade_mode") or "顺势"
direction = resolve_order_direction(break_side, trade_mode)
trailing_be = int(row.get("trailing_be") or 0)
try:
rr = float(row.get("risk_reward") or (3 if trailing_be else 2))
except (TypeError, ValueError):
rr = 3.0 if trailing_be else 2.0
if trailing_be and rr < 3:
rr = 3.0
def _notify(ok: bool, detail: str, **kw):
send_wechat_msg(format_auto_breakout_msg(
row,
break_side=break_side,
direction=direction,
entry=kw.get("entry", 0),
sl=kw.get("sl", 0),
tp=kw.get("tp", 0),
lots=kw.get("lots", 0),
bar_time=bar_time,
ok=ok,
detail=detail,
))
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
_notify(False, "CTP 未连接")
return False, "CTP 未连接"
if not is_trading_session():
_notify(False, "非交易时段")
return False, "非交易时段"
try:
entry = float(bar.get("close") or 0)
except (TypeError, ValueError):
_notify(False, "K 线收盘价无效")
return False, "K 线收盘价无效"
if entry <= 0:
_notify(False, "K 线收盘价无效")
return False, "K 线收盘价无效"
sl, tp = calc_breakout_sl_tp(
sym=sym, direction=direction, entry=entry, bar=bar, risk_reward=rr,
)
err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode))
if err:
_notify(False, err, entry=entry, sl=sl, tp=tp, lots=0)
return False, err
capital = _capital(conn)
lots, lot_err = calc_lots_by_risk(
entry, sl, direction, capital, get_risk_percent(get_setting), sym,
max_margin_pct=get_max_margin_pct(get_setting), trading_mode=mode,
)
if lot_err or not lots:
msg = lot_err or "手数计算失败"
_notify(False, msg, entry=entry, sl=sl, tp=tp, lots=0)
return False, msg
result = execute_order(
conn,
mode=mode,
offset="open",
symbol=sym,
direction=direction,
lots=lots,
price=entry,
settings=_settings_dict(),
order_type="market",
)
open_ts = bar_time.replace("T", " ") if bar_time else datetime.now().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=entry,
sl=sl,
tp=tp,
trailing_be=trailing_be,
open_time=open_ts,
monitor_type=monitor_type,
status="pending",
vt_order_id=vt_order_id or None,
order_price=entry,
)
_reconcile_pending(conn, mode, capital=capital)
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"
rejected = st_row and (st_row["status"] or "").strip().lower() == "closed"
if rejected:
conn.commit()
_notify(False, "委托被柜台拒绝或撤销", entry=entry, sl=sl, tp=tp, lots=lots)
return False, "委托被拒绝"
if filled:
_sync_monitor_from_ctp(
conn, mid, sym, direction, mode, capital=capital,
)
conn.commit()
if filled:
from db_conn import DB_PATH
from ai_worker import schedule_ai_event_analysis
from trade_notify import notify_key_breakout_open
notify_key_breakout_open(
send_wechat=send_wechat_msg,
get_setting=get_setting,
mode_label=trading_mode_label(get_setting),
row=row,
break_side=break_side,
bar_time=bar_time,
direction=direction,
entry=entry,
sl=sl,
tp=tp,
lots=lots,
capital=capital,
order_id=vt_order_id,
schedule_ai_fn=schedule_ai_event_analysis,
db_path=DB_PATH,
)
_push_position_snapshot_async(fast=False)
return True, "已下单" if filled else "委托已提交"
except Exception as exc:
logger.warning("key breakout auto order: %s", exc)
_notify(False, str(exc))
return False, str(exc)
app._execute_key_breakout = _execute_key_breakout
@app.route("/settings/trading", methods=["POST"])
@login_required
def settings_trading_post():
@@ -2882,3 +3158,19 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)
from ai_worker import start_ai_worker
start_ai_worker(
db_path=DB_PATH,
get_setting_fn=get_setting,
set_setting_fn=set_setting,
send_wechat_fn=send_wechat_msg,
)
start_pending_order_worker(
db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables,
get_capital_fn=_capital,
reconcile_fn=_reconcile_pending,
on_changed_fn=lambda: _push_position_snapshot_async(fast=False),
)