diff --git a/ai_client.py b/ai_client.py new file mode 100644 index 0000000..d973093 --- /dev/null +++ b/ai_client.py @@ -0,0 +1,102 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""AI 接口:Ollama / OpenAI 兼容 API。""" +from __future__ import annotations + +import json +import logging +from typing import Callable, Optional + +import requests + +logger = logging.getLogger(__name__) + + +def is_ai_enabled(get_setting: Callable[[str, str], str]) -> bool: + return (get_setting("ai_enabled", "0") or "0").strip() in ("1", "true", "yes") + + +def get_ai_config(get_setting: Callable[[str, str], str]) -> dict: + provider = (get_setting("ai_provider", "ollama") or "ollama").strip().lower() + if provider not in ("ollama", "openai"): + provider = "ollama" + return { + "enabled": is_ai_enabled(get_setting), + "provider": provider, + "ollama_base_url": (get_setting("ai_ollama_base_url", "http://127.0.0.1:11434") or "").strip().rstrip("/"), + "ollama_model": (get_setting("ai_ollama_model", "qwen2.5:7b") or "qwen2.5:7b").strip(), + "openai_base_url": (get_setting("ai_openai_base_url", "https://api.openai.com/v1") or "").strip().rstrip("/"), + "openai_api_key": (get_setting("ai_openai_api_key", "") or "").strip(), + "openai_model": (get_setting("ai_openai_model", "gpt-4o-mini") or "gpt-4o-mini").strip(), + } + + +def chat_completion( + *, + get_setting: Callable[[str, str], str], + system_prompt: str, + user_prompt: str, + timeout: int = 120, +) -> tuple[bool, str]: + cfg = get_ai_config(get_setting) + if not cfg["enabled"]: + return False, "AI 未启用" + provider = cfg["provider"] + try: + if provider == "ollama": + url = f"{cfg['ollama_base_url']}/api/chat" + payload = { + "model": cfg["ollama_model"], + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "stream": False, + } + resp = requests.post(url, json=payload, timeout=timeout) + resp.raise_for_status() + data = resp.json() + msg = (data.get("message") or {}).get("content") or "" + return True, (msg or "").strip() or "(AI 无回复)" + url = f"{cfg['openai_base_url']}/chat/completions" + headers = { + "Authorization": f"Bearer {cfg['openai_api_key']}", + "Content-Type": "application/json", + } + payload = { + "model": cfg["openai_model"], + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.4, + } + resp = requests.post(url, headers=headers, json=payload, timeout=timeout) + resp.raise_for_status() + data = resp.json() + choices = data.get("choices") or [] + if not choices: + return False, "AI 返回为空" + msg = (choices[0].get("message") or {}).get("content") or "" + return True, (msg or "").strip() or "(AI 无回复)" + except Exception as exc: + logger.warning("AI chat failed (%s): %s", provider, exc) + return False, f"AI 调用失败:{exc}" + + +def analyze_trading_event( + *, + get_setting: Callable[[str, str], str], + event_kind: str, + payload: dict, +) -> tuple[bool, str]: + system = ( + "你是国内期货交易复盘助手。根据提供的结构化交易数据," + "用简洁中文给出 3~6 条要点:风险、纪律、改进建议。" + "不要编造未提供的数据;金额单位为元。" + ) + user = f"事件类型:{event_kind}\n\n数据:\n{json.dumps(payload, ensure_ascii=False, indent=2)}" + return chat_completion(get_setting=get_setting, system_prompt=system, user_prompt=user) diff --git a/ai_messages.py b/ai_messages.py new file mode 100644 index 0000000..623f663 --- /dev/null +++ b/ai_messages.py @@ -0,0 +1,68 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""AI 消息存储与展示。""" +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +TZ = ZoneInfo("Asia/Shanghai") + +CREATE_SQL = """ +CREATE TABLE IF NOT EXISTS ai_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + title TEXT, + content TEXT NOT NULL, + meta_json TEXT, + created_at TEXT NOT NULL +) +""" + + +def ensure_ai_messages_table(conn: sqlite3.Connection) -> None: + conn.execute(CREATE_SQL) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_ai_messages_created ON ai_messages(created_at DESC)" + ) + + +def insert_ai_message( + conn: sqlite3.Connection, + *, + kind: str, + title: str, + content: str, + meta: Optional[dict[str, Any]] = None, +) -> int: + ensure_ai_messages_table(conn) + now = datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S") + cur = conn.execute( + """INSERT INTO ai_messages (kind, title, content, meta_json, created_at) + VALUES (?,?,?,?,?)""", + (kind, title, content, json.dumps(meta or {}, ensure_ascii=False), now), + ) + return int(cur.lastrowid) + + +def list_ai_messages(conn: sqlite3.Connection, *, limit: int = 100) -> list[dict]: + ensure_ai_messages_table(conn) + rows = conn.execute( + "SELECT * FROM ai_messages ORDER BY id DESC LIMIT ?", + (max(1, min(500, int(limit))),), + ).fetchall() + out = [] + for r in rows: + item = dict(r) + try: + item["meta"] = json.loads(item.get("meta_json") or "{}") + except Exception: + item["meta"] = {} + out.append(item) + return out diff --git a/ai_worker.py b/ai_worker.py new file mode 100644 index 0000000..0f2ad64 --- /dev/null +++ b/ai_worker.py @@ -0,0 +1,173 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""AI 后台:开仓/平仓分析、日终持仓报告。""" +from __future__ import annotations + +import json +import logging +import threading +from datetime import datetime +from typing import Callable, Optional +from zoneinfo import ZoneInfo + +logger = logging.getLogger(__name__) +TZ = ZoneInfo("Asia/Shanghai") +DAILY_REPORT_KEY = "ai_daily_report_last_date" + + +def schedule_ai_event_analysis( + *, + db_path: str, + get_setting_fn: Callable[[str, str], str], + kind: str, + title: str, + payload: dict, + send_wechat_fn: Callable[[str], None] | None = None, +) -> None: + """后台线程:调用 AI 并写入 ai_messages。""" + if not (get_setting_fn("ai_enabled", "0") or "0").strip() in ("1", "true", "yes"): + return + + def _run() -> None: + from ai_client import analyze_trading_event + from ai_messages import insert_ai_message + from db_conn import connect_db + + ok, content = analyze_trading_event( + get_setting=get_setting_fn, + event_kind=kind, + payload=payload, + ) + if not ok: + content = f"⚠ {content}" + try: + conn = connect_db(db_path) + try: + insert_ai_message( + conn, + kind=kind, + title=title, + content=content, + meta=payload, + ) + conn.commit() + finally: + conn.close() + if send_wechat_fn and ok: + send_wechat_fn(f"🤖 AI 分析 · {title}\n\n{content[:1800]}") + except Exception as exc: + logger.warning("AI event analysis failed: %s", exc) + + threading.Thread(target=_run, daemon=True, name="ai-event").start() + + +def _today_trading_summary(conn, day: str) -> dict: + rows = conn.execute( + """SELECT symbol, symbol_name, direction, pnl_net, result, close_time + FROM trade_logs WHERE close_time LIKE ? ORDER BY id ASC""", + (f"{day}%",), + ).fetchall() + wins = losses = 0 + pnl_sum = 0.0 + trades = [] + for r in rows: + pnl = float(r["pnl_net"] or 0) + pnl_sum += pnl + if pnl >= 0: + wins += 1 + else: + losses += 1 + trades.append(dict(r)) + positions = conn.execute( + """SELECT symbol, symbol_name, direction, lots, entry_price, stop_loss, take_profit, monitor_type + FROM trade_order_monitors WHERE status='active'""" + ).fetchall() + return { + "date": day, + "trade_count": len(trades), + "wins": wins, + "losses": losses, + "pnl_net_total": round(pnl_sum, 2), + "trades": trades[:20], + "active_positions": [dict(p) for p in positions], + } + + +def maybe_run_daily_ai_report( + *, + db_path: str, + get_setting_fn: Callable[[str, str], str], + set_setting_fn: Callable[[str, str], None], + send_wechat_fn: Callable[[str], None] | None = None, +) -> None: + if not (get_setting_fn("ai_enabled", "0") or "0").strip() in ("1", "true", "yes"): + return + if (get_setting_fn("ai_daily_report_enabled", "1") or "1").strip() not in ("1", "true", "yes"): + return + now = datetime.now(TZ) + day = now.strftime("%Y-%m-%d") + if get_setting_fn(DAILY_REPORT_KEY, "") == day: + return + try: + hour = int(float(get_setting_fn("ai_daily_report_hour", "15") or 15)) + minute = int(float(get_setting_fn("ai_daily_report_minute", "5") or 5)) + except (TypeError, ValueError): + hour, minute = 15, 5 + if (now.hour, now.minute) < (hour, minute): + return + + from ai_client import analyze_trading_event + from ai_messages import insert_ai_message + from db_conn import connect_db + + try: + conn = connect_db(db_path) + try: + summary = _today_trading_summary(conn, day) + ok, content = analyze_trading_event( + get_setting=get_setting_fn, + event_kind="daily_report", + payload=summary, + ) + title = f"{day} 日终持仓与交易报告" + if not ok: + content = f"⚠ {content}" + insert_ai_message(conn, kind="daily_report", title=title, content=content, meta=summary) + conn.commit() + set_setting_fn(DAILY_REPORT_KEY, day) + if send_wechat_fn and ok: + send_wechat_fn(f"🤖 {title}\n\n{content[:1800]}") + finally: + conn.close() + except Exception as exc: + logger.warning("AI daily report failed: %s", exc) + + +def start_ai_worker( + *, + db_path: str, + get_setting_fn: Callable[[str, str], str], + set_setting_fn: Callable[[str, str], None], + send_wechat_fn: Callable[[str], None] | None = None, + interval_sec: int = 60, +) -> None: + import time + + def _loop() -> None: + time.sleep(30) + while True: + try: + maybe_run_daily_ai_report( + db_path=db_path, + get_setting_fn=get_setting_fn, + set_setting_fn=set_setting_fn, + send_wechat_fn=send_wechat_fn, + ) + except Exception as exc: + logger.debug("ai worker: %s", exc) + time.sleep(max(30, interval_sec)) + + threading.Thread(target=_loop, daemon=True, name="ai-worker").start() diff --git a/app.py b/app.py index 89d1beb..b6238ac 100644 --- a/app.py +++ b/app.py @@ -296,6 +296,15 @@ def init_db(): "ALTER TABLE order_plans ADD COLUMN decision_reason TEXT", "ALTER TABLE key_monitors ADD COLUMN status TEXT DEFAULT 'active'", "ALTER TABLE key_monitors ADD COLUMN archived_at TEXT", + "ALTER TABLE key_monitors ADD COLUMN trade_mode TEXT DEFAULT '顺势'", + "ALTER TABLE key_monitors ADD COLUMN risk_reward REAL DEFAULT 2", + "ALTER TABLE key_monitors ADD COLUMN trailing_be INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN last_trigger_bar TEXT", + "ALTER TABLE key_monitors ADD COLUMN alert_push_count INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN alert_last_push_at TEXT", + "ALTER TABLE key_monitors ADD COLUMN alert_break_side TEXT", + "ALTER TABLE key_monitors ADD COLUMN breakout_bar_time TEXT", + "ALTER TABLE key_monitors ADD COLUMN alert_close_price REAL", "ALTER TABLE review_records ADD COLUMN direction TEXT", "ALTER TABLE review_records ADD COLUMN entry_price REAL", "ALTER TABLE review_records ADD COLUMN stop_loss REAL", @@ -411,10 +420,30 @@ def init_db(): set_setting("risk_percent", "1") if not get_setting("max_margin_pct"): set_setting("max_margin_pct", "30") + if not get_setting("roll_max_margin_pct"): + set_setting("roll_max_margin_pct", "50") if not get_setting("trailing_be_tick_buffer"): set_setting("trailing_be_tick_buffer", "2") if not get_setting("pending_order_timeout_min"): set_setting("pending_order_timeout_min", "5") + if not get_setting("ai_enabled"): + set_setting("ai_enabled", "0") + if not get_setting("ai_provider"): + set_setting("ai_provider", "ollama") + if not get_setting("ai_ollama_base_url"): + set_setting("ai_ollama_base_url", "http://127.0.0.1:11434") + if not get_setting("ai_ollama_model"): + set_setting("ai_ollama_model", "qwen2.5:7b") + if not get_setting("ai_openai_base_url"): + set_setting("ai_openai_base_url", "https://api.openai.com/v1") + if not get_setting("ai_openai_model"): + set_setting("ai_openai_model", "gpt-4o-mini") + if not get_setting("ai_daily_report_enabled"): + set_setting("ai_daily_report_enabled", "1") + if not get_setting("ai_daily_report_hour"): + set_setting("ai_daily_report_hour", "15") + if not get_setting("ai_daily_report_minute"): + set_setting("ai_daily_report_minute", "5") if not get_setting("backup_auto_enabled"): set_setting("backup_auto_enabled", "1") if not get_setting("backup_auto_hour"): @@ -651,51 +680,23 @@ def check_order_plans(): def check_key_monitors(): + from db_conn import DB_PATH + from key_monitor_lib import run_key_monitor_check + from trading_context import get_trading_mode + conn = get_db() - rows = conn.execute( - "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL" - ).fetchall() - - for r in rows: - sym = r["symbol"] - typ = r["monitor_type"] - up = r["upper"] - low = r["lower"] - up_trig = r["upper_triggered"] - low_trig = r["lower_triggered"] - name = r["symbol_name"] or sym - pid = r["id"] - sina = r["sina_code"] if "sina_code" in r.keys() else "" - market = r["market_code"] if "market_code" in r.keys() else "" - - p = fetch_price(sym, market, sina) - if not p: - continue - - if typ in ("箱体突破", "收敛突破"): - if p > up and not up_trig: - send_wechat_msg(f"{name} 突破{typ}上沿 {up}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) - ) - if p < low and not low_trig: - send_wechat_msg(f"{name} 跌破{typ}下沿 {low}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) - ) - elif typ == "关键阻力位" and p > up and not up_trig: - send_wechat_msg(f"{name} 突破阻力位 {up}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET upper_triggered=1 WHERE id=?", (pid,) - ) - elif typ == "关键支撑位" and p < low and not low_trig: - send_wechat_msg(f"{name} 跌破支撑位 {low}\n当前价:{p}") - conn.execute( - "UPDATE key_monitors SET lower_triggered=1 WHERE id=?", (pid,) - ) - - conn.commit() - conn.close() + try: + execute_fn = getattr(app, "_execute_key_breakout", None) + run_key_monitor_check( + conn, + db_path=DB_PATH, + get_trading_mode_fn=lambda: get_trading_mode(get_setting), + send_wechat=send_wechat_msg, + execute_breakout_fn=execute_fn, + ) + conn.commit() + finally: + conn.close() def background_task(): @@ -1039,6 +1040,20 @@ def del_plan(pid): return redirect(url_for("plans")) +@app.route("/ai") +@login_required +@require_nav("ai") +def ai_messages_page(): + from ai_messages import list_ai_messages + + conn = get_db() + try: + messages = list_ai_messages(conn, limit=100) + finally: + conn.close() + return render_template("ai_messages.html", messages=messages) + + @app.route("/keys") @login_required def keys(): @@ -1058,23 +1073,52 @@ def keys(): @login_required def add_key(): d = request.form - direction = d.get("direction") symbol = d.get("symbol", "").strip() symbol_name = d.get("symbol_name", "").strip() market_code = d.get("market_code", "").strip() sina_code = d.get("sina_code", "").strip() - if not direction: - flash("请选择多空方向") - return redirect(url_for("keys")) + monitor_type = (d.get("type") or "").strip() if not symbol or not market_code: flash("请从下拉列表选择品种(同花顺合约代码)") return redirect(url_for("keys")) + try: + upper = float(d.get("upper") or 0) + lower = float(d.get("lower") or 0) + except (TypeError, ValueError): + flash("上沿/下沿价格无效") + return redirect(url_for("keys")) + if upper <= lower: + flash("上沿必须大于下沿") + return redirect(url_for("keys")) + + trade_mode = (d.get("trade_mode") or "顺势").strip() + if trade_mode not in ("顺势", "反转"): + trade_mode = "顺势" + try: + risk_reward = float(d.get("risk_reward") or 2) + except (TypeError, ValueError): + risk_reward = 2.0 + risk_reward = max(0.5, min(10.0, risk_reward)) + trailing_be = 1 if d.get("trailing_be") else 0 + if trailing_be and risk_reward < 3: + risk_reward = 3.0 + + direction = (d.get("direction") or "").strip().lower() + if monitor_type in ("箱体突破", "收敛突破"): + direction = direction or "long" + else: + direction = direction or "long" + conn = get_db() conn.execute( """INSERT INTO key_monitors - (symbol, symbol_name, market_code, sina_code, monitor_type, direction, upper, lower) - VALUES (?,?,?,?,?,?,?,?)""", - (symbol, symbol_name, market_code, sina_code, d["type"], direction, float(d["upper"]), float(d["lower"])), + (symbol, symbol_name, market_code, sina_code, monitor_type, direction, + upper, lower, trade_mode, risk_reward, trailing_be) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + ( + symbol, symbol_name, market_code, sina_code, monitor_type, direction, + upper, lower, trade_mode, risk_reward, trailing_be, + ), ) conn.commit() conn.close() @@ -1747,6 +1791,29 @@ def settings(): webhook = request.form.get("wechat_webhook", "").strip() set_setting("wechat_webhook", webhook) flash("企业微信配置已保存") + elif action == "ai": + set_setting("ai_enabled", "1" if request.form.get("ai_enabled") else "0") + provider = (request.form.get("ai_provider") or "ollama").strip().lower() + if provider not in ("ollama", "openai"): + provider = "ollama" + set_setting("ai_provider", provider) + set_setting("ai_ollama_base_url", (request.form.get("ai_ollama_base_url") or "").strip()) + set_setting("ai_ollama_model", (request.form.get("ai_ollama_model") or "").strip()) + set_setting("ai_openai_base_url", (request.form.get("ai_openai_base_url") or "").strip()) + key = (request.form.get("ai_openai_api_key") or "").strip() + if key: + set_setting("ai_openai_api_key", key) + set_setting("ai_openai_model", (request.form.get("ai_openai_model") or "").strip()) + set_setting("ai_daily_report_enabled", "1" if request.form.get("ai_daily_report_enabled") else "0") + try: + set_setting("ai_daily_report_hour", str(max(0, min(23, int(request.form.get("ai_daily_report_hour", "15") or 15))))) + except ValueError: + pass + try: + set_setting("ai_daily_report_minute", str(max(0, min(59, int(request.form.get("ai_daily_report_minute", "5") or 5))))) + except ValueError: + pass + flash("AI 配置已保存") elif action == "trading": mode = request.form.get("trading_mode", "simulation").strip() if mode not in ("simulation", "live"): @@ -1781,6 +1848,12 @@ def settings(): except ValueError: flash("保证金比例无效") return redirect(url_for("settings")) + try: + rmp = float(request.form.get("roll_max_margin_pct", "50") or 50) + set_setting("roll_max_margin_pct", str(max(1.0, min(100.0, rmp)))) + except ValueError: + flash("滚仓保证金比例无效") + return redirect(url_for("settings")) try: tb = int(float(request.form.get("trailing_be_tick_buffer", "2") or 2)) set_setting("trailing_be_tick_buffer", str(max(1, min(20, tb)))) @@ -1893,6 +1966,7 @@ def settings(): except Exception: pass from ctp_settings import get_ctp_settings_for_ui, is_ctp_auto_connect_enabled + from product_recommend import small_account_margin_recommendations return render_template( "settings.html", @@ -1908,6 +1982,8 @@ def settings(): fixed_amount=get_setting("fixed_amount", "5000"), risk_percent=get_setting("risk_percent", "1"), max_margin_pct=get_setting("max_margin_pct", "30"), + roll_max_margin_pct=get_setting("roll_max_margin_pct", "50"), + small_account_margin_rec=small_account_margin_recommendations(), trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"), pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"), nav_items=get_nav_items(get_setting), @@ -1920,6 +1996,16 @@ def settings(): backup_auto_hour=get_setting("backup_auto_hour", "3"), backup_keep_count=get_setting("backup_keep_count", "30"), backup_restore_dir=default_restore_dir(), + ai_enabled=get_setting("ai_enabled", "0") == "1", + ai_provider=get_setting("ai_provider", "ollama"), + ai_ollama_base_url=get_setting("ai_ollama_base_url", "http://127.0.0.1:11434"), + ai_ollama_model=get_setting("ai_ollama_model", "qwen2.5:7b"), + ai_openai_base_url=get_setting("ai_openai_base_url", "https://api.openai.com/v1"), + ai_openai_api_key=get_setting("ai_openai_api_key", ""), + ai_openai_model=get_setting("ai_openai_model", "gpt-4o-mini"), + ai_daily_report_enabled=get_setting("ai_daily_report_enabled", "1") == "1", + ai_daily_report_hour=get_setting("ai_daily_report_hour", "15"), + ai_daily_report_minute=get_setting("ai_daily_report_minute", "5"), ) diff --git a/contract_specs.py b/contract_specs.py index e8e7892..d30d4ff 100644 --- a/contract_specs.py +++ b/contract_specs.py @@ -83,6 +83,42 @@ def get_contract_spec(ths_code: str) -> dict: return dict(DEFAULT_SPEC) +def margin_one_lot( + ths_code: str, + price: float, + *, + direction: str = "long", + trading_mode: str | None = None, +) -> tuple[float, str, dict]: + """1 手保证金。CTP 已连接时优先读柜台合约保证金率,否则用本地参考规格估算。 + + 返回 (保证金, 来源 estimate|ctp, 合约规格片段)。 + """ + spec = get_contract_spec(ths_code) + est = 0.0 + if price and price > 0: + est = round(float(price) * spec["mult"] * spec["margin_rate"], 2) + if trading_mode: + try: + from vnpy_bridge import ctp_estimate_margin_one_lot, ctp_lookup_contract_spec, ctp_status + + if ctp_status(trading_mode).get("connected"): + ctp_margin = ctp_estimate_margin_one_lot( + trading_mode, ths_code, float(price), direction=direction, + ) + if ctp_margin and ctp_margin > 0: + merged = dict(spec) + ctp_spec = ctp_lookup_contract_spec(trading_mode, ths_code) or {} + if ctp_spec.get("mult"): + merged["mult"] = ctp_spec["mult"] + if ctp_spec.get("tick_size"): + merged["tick_size"] = ctp_spec["tick_size"] + return float(ctp_margin), "ctp", merged + except Exception: + pass + return est, "estimate", spec + + def calc_position_metrics( direction: str, entry: float, diff --git a/ctp_trade_sync.py b/ctp_trade_sync.py index f8f5ea5..e8ecb14 100644 --- a/ctp_trade_sync.py +++ b/ctp_trade_sync.py @@ -285,6 +285,36 @@ def sync_trade_logs_from_ctp( row_vals + ("ctp", key, 1), ) stats["synced"] += 1 + try: + from trade_notify import notify_trade_log_close + from trading_context import trading_mode_label + from app import get_setting, send_wechat_msg + from ai_worker import schedule_ai_event_analysis + from db_conn import DB_PATH + + notify_trade_log_close( + send_wechat=send_wechat_msg, + get_setting=get_setting, + mode_label=trading_mode_label(get_setting), + capital=capital, + sym=ths, + symbol_name=codes.get("name") or mon.get("symbol_name") or ths, + direction=direction, + entry=entry, + close_price=close_px, + sl=float(sl) if sl is not None else None, + tp=float(tp) if tp is not None else None, + lots=lots, + pnl_net=pnl_net, + equity_after=equity_after, + holding_minutes=minutes, + result=result, + monitor_type=monitor_type, + schedule_ai_fn=schedule_ai_event_analysis, + db_path=DB_PATH, + ) + except Exception as exc: + logger.debug("ctp close notify: %s", exc) if stats["synced"] or stats["updated"]: try: diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ceb5fa5..f6912fb 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -94,11 +94,11 @@ ## 关键位监控 -**路径**:`/keys` +**路径**:`/keys` · 详细规则见 [KEY_MONITORS.md](./KEY_MONITORS.md) -- 类型:箱体突破、收敛突破、关键阻力、关键支撑 -- 突破规则推送(去重);删除后归档至监控历史 -- 列表约 1 秒轮询 `/api/key_prices` +- **箱体突破 / 收敛突破**:5m 收盘突破 → 顺势/反转自动市价单;止损=突破 K±2 跳;盈亏比默认 2(可改);可选移动保本(默认 3R 止盈) +- **关键支阻区**:上沿阻力 + 下沿支撑;5m 收盘突破 → 微信提醒最多 3 次(间隔约 5 分钟),不自动开仓 +- 删除后归档至监控历史;列表约 1 秒轮询 `/api/key_prices` --- diff --git a/docs/KEY_MONITORS.md b/docs/KEY_MONITORS.md new file mode 100644 index 0000000..49dc3c2 --- /dev/null +++ b/docs/KEY_MONITORS.md @@ -0,0 +1,64 @@ +# 关键位监控 + +**页面路径**:`/keys` + +关键位监控用于在指定价格区间上设置 **5 分钟收盘** 触发规则,分为 **自动单**(箱体/收敛突破)与 **仅微信提醒**(关键支阻区)两类。 + +--- + +## 监控类型 + +### 箱体突破 / 收敛突破(自动单) + +| 项目 | 规则 | +|------|------| +| 触发 | 5 分钟 K 线 **收盘价** 高于上沿或低于下沿 | +| 顺势 / 反转 | 顺势:上破做多、下破做空;反转:上破做空、下破做多 | +| 下单 | CTP 已连接且在交易时段内,自动 **市价开仓** | +| 手数 | 按系统设置的风险比例与保证金上限计算 | +| 止损 | 突破 K 线最低价(多)/ 最高价(空)± **2 个最小变动价位** | +| 盈亏比 | 默认 **2**,可在新增监控时修改(0.5~10) | +| 移动保本 | 可选;开启后盈亏比默认 **3**,达 3R 止盈价自动平仓;同时启用移动保本止损逻辑(达 1R 后抬止损) | +| 成交后 | 进入 **下单监控** 持仓列表,`monitor_type` 显示为「箱体突破」或「收敛突破」 | +| 结案 | 触发并尝试下单后,本条监控移入历史(无论成败,同一根 5m K 线不重复触发) | + +**前提**:CTP 已连接、处于交易时段、账户风控允许开仓。 + +### 关键支阻区(仅提醒) + +| 项目 | 规则 | +|------|------| +| 区间 | **上沿 = 阻力**,**下沿 = 支撑**,合并为一个关键支阻区 | +| 触发 | 5m 收盘突破上沿或跌破下沿 | +| 推送 | 企业微信,格式含突破方向、触发收盘、区间上下沿等 | +| 次数 | 最多 **3 次**,间隔约 **5 分钟**(人工盯盘提醒) | +| 自动开仓 | **否** | +| 结案 | 第 3 次推送后自动归档 | + +历史数据中的「关键阻力位」「关键支撑位」按 **关键支阻区** 同样规则处理。 + +--- + +## 与旧版差异 + +- 旧版:tick 现价触碰即推送,箱体/收敛仅微信提醒 +- 新版:**统一 5m 收盘** 触发;箱体/收敛改为 **自动市价单**;阻力/支撑合并为 **关键支阻区** 三轮微信提醒 + +--- + +## 相关配置 + +- **企业微信 Webhook**:系统设置 → 企业微信推送 +- **风险比例 / 保证金上限**:系统设置 → 交易相关(影响自动单手数) +- **移动保本跳数缓冲**:系统设置 → `trailing_be_tick_buffer`(自动单开启移动保本时生效) + +--- + +## 技术说明 + +- 后台任务 `background_task` 约每 3 秒扫描一次 `key_monitors` +- 5m K 线优先 CTP,否则新浪/本地缓存 +- 自动单逻辑:`key_monitor_lib.py` + `install_trading._execute_key_breakout` +- 止盈止损监控:`sl_tp_guard.py`(移动保本 + 显式止盈价可同时生效) + +详见 [FEATURES.md](./FEATURES.md) 功能总览。 diff --git a/docs/TRADING.md b/docs/TRADING.md index 4f59723..9d1d7c0 100644 --- a/docs/TRADING.md +++ b/docs/TRADING.md @@ -34,6 +34,8 @@ - 用于开仓纪律与仓位限制:按保证金上限计算最大手数,仅展示当前权益下可开的品种 - 每日后台刷新列表(`/api/recommend/stream`) - 最大手数 = floor(权益 × 保证金上限 ÷ 1 手保证金) +- **1 手保证金**:**CTP 已连接** 时优先读取柜台合约的 `long_margin_ratio` / `short_margin_ratio` 与乘数计算(表格标注「柜台」);未连接或合约信息暂不可用时,才用本地参考保证金率估算 +- 开仓前校验、固定金额计仓、保证金占用比例检查均与上述规则一致,避免交易所上调保证金后仍按旧比例显示可开手数 - 展示近一周日线走势、跳空、昨日成交量(手)、成交额 - 可按 **行业** 筛选,支持多字段排序 - **夜盘时段**:品种下拉与可开仓表仅显示有夜盘交易的品种,并带「夜盘」标记 diff --git a/install_trading.py b/install_trading.py index a89ece1..74c0e2d 100644 --- a/install_trading.py +++ b/install_trading.py @@ -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), + ) diff --git a/key_monitor_lib.py b/key_monitor_lib.py new file mode 100644 index 0000000..6706f48 --- /dev/null +++ b/key_monitor_lib.py @@ -0,0 +1,367 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""关键位监控:5 分钟收盘触发、支阻区微信提醒、箱体/收敛自动单。""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +from contract_specs import get_contract_spec +from kline_chart import fetch_market_klines + +logger = logging.getLogger(__name__) + +TZ = ZoneInfo("Asia/Shanghai") + +TYPE_BOX = "箱体突破" +TYPE_CONV = "收敛突破" +TYPE_ZONE = "关键支阻区" +AUTO_TYPES = (TYPE_BOX, TYPE_CONV) +ZONE_TYPES = (TYPE_ZONE, "关键阻力位", "关键支撑位") + +ALERT_MAX_PUSH = 3 +ALERT_INTERVAL_SEC = 300 +SL_TICK_BUFFER = 2 +BAR_PERIOD = "5m" +BAR_MINUTES = 5 + + +def normalize_monitor_type(raw: str) -> str: + t = (raw or "").strip() + if t in ("关键阻力位", "关键支撑位"): + return TYPE_ZONE + return t + + +def is_auto_trade_type(typ: str) -> bool: + return normalize_monitor_type(typ) in AUTO_TYPES + + +def is_zone_type(typ: str) -> bool: + return normalize_monitor_type(typ) == TYPE_ZONE + + +def resolve_order_direction(break_side: str, trade_mode: str) -> str: + """突破方向 + 顺势/反转 → 下单方向。""" + side = (break_side or "").strip().lower() + mode = (trade_mode or "顺势").strip() + if mode == "反转": + return "short" if side == "upper" else "long" + return "long" if side == "upper" else "short" + + +def break_direction_label(break_side: str) -> tuple[str, str]: + if break_side == "upper": + return "向上突破上沿", "long" + return "向下突破下沿", "short" + + +def calc_breakout_sl_tp( + *, + sym: str, + direction: str, + entry: float, + bar: dict, + risk_reward: float, +) -> tuple[float, float]: + tick = float(get_contract_spec(sym).get("tick_size") or 1.0) + bar_high = float(bar.get("high") or entry) + bar_low = float(bar.get("low") or entry) + if direction == "long": + sl = bar_low - SL_TICK_BUFFER * tick + risk = max(entry - sl, tick) + tp = entry + risk * risk_reward + else: + sl = bar_high + SL_TICK_BUFFER * tick + risk = max(sl - entry, tick) + tp = entry - risk * risk_reward + return sl, tp + + +def _parse_bar_time(raw: str) -> Optional[datetime]: + s = (raw or "").strip().replace("T", " ") + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(s[:19], fmt).replace(tzinfo=TZ) + except ValueError: + continue + return None + + +def last_closed_bar(bars: list[dict], now: Optional[datetime] = None) -> Optional[dict]: + """取最近一根已收盘 K 线。""" + dnow = now or datetime.now(TZ) + for bar in reversed(bars or []): + dt = _parse_bar_time(str(bar.get("time") or "")) + if not dt: + continue + bar_end = dt + timedelta(minutes=BAR_MINUTES) + if dnow >= bar_end: + return bar + return None + + +def detect_break_side(close: float, upper: float, lower: float) -> Optional[str]: + if close > upper: + return "upper" + if close < lower: + return "lower" + return None + + +def fetch_closed_5m_bar( + sym: str, + *, + db_path: str, + trading_mode: str, +) -> Optional[dict]: + try: + data = fetch_market_klines( + sym, + BAR_PERIOD, + db_path=db_path, + trading_mode=trading_mode, + prefer_ctp=True, + ) + bars = data.get("bars") or [] + return last_closed_bar(bars) + except Exception as exc: + logger.debug("key monitor kline %s: %s", sym, exc) + return None + + +def _now_iso() -> str: + return datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def archive_monitor(conn, pid: int) -> None: + conn.execute( + "UPDATE key_monitors SET status='archived', archived_at=? WHERE id=?", + (_now_iso(), pid), + ) + + +def format_zone_alert( + row: dict, + *, + break_side: str, + close_price: float, + bar_time: str, + push_index: int, + max_push: int = ALERT_MAX_PUSH, +) -> str: + name = row.get("symbol_name") or row.get("symbol") or "" + upper = float(row.get("upper") or 0) + lower = float(row.get("lower") or 0) + break_label, alert_dir = break_direction_label(break_side) + dir_cn = "多头(long)" if alert_dir == "long" else "空头(short)" + boundary = upper if break_side == "upper" else lower + lines = [ + f"📌 {name} 关键位突破提醒({push_index}/{max_push})", + "", + "🧾 突破概要", + "📌 类型:关键支阻区", + f"⏱ 触发时间:{bar_time}", + f"📊 上沿:{upper:g}|下沿:{lower:g}", + f"💹 触发收盘:{close_price:g}", + f"🎯 {break_label}({dir_cn})", + f"📍 突破价位:{boundary:g}", + "", + "📎 说明", + f"· 人工盯盘,共推送 {max_push} 次(间隔约 {ALERT_INTERVAL_SEC // 60} 分钟)", + "· 推送完毕后本条监控自动结案", + "· 不参与自动开仓", + ] + return "\n".join(lines) + + +def format_auto_breakout_msg( + row: dict, + *, + break_side: str, + direction: str, + entry: float, + sl: float, + tp: float, + lots: int, + bar_time: str, + ok: bool, + detail: str = "", +) -> str: + name = row.get("symbol_name") or row.get("symbol") or "" + typ = normalize_monitor_type(row.get("monitor_type") or "") + trade_mode = row.get("trade_mode") or "顺势" + break_label, _ = break_direction_label(break_side) + dir_cn = "做多" if direction == "long" else "做空" + rr = float(row.get("risk_reward") or 2) + lines = [ + f"{'✅' if ok else '❌'} {name} {typ}自动单", + f"⏱ 5m 收盘:{bar_time}", + f"🎯 {break_label} · {trade_mode} · {dir_cn}", + f"💹 入场:{entry:g} 止损:{sl:g} 止盈:{tp:g}(盈亏比 {rr:g})", + f"📦 手数:{lots}", + ] + if int(row.get("trailing_be") or 0): + lines.append("🛡 已开启移动保本(达目标盈亏比自动止盈)") + if detail: + lines.append(detail) + return "\n".join(lines) + + +def _should_send_followup_push(row: dict, now: datetime) -> bool: + count = int(row.get("alert_push_count") or 0) + if count <= 0 or count >= ALERT_MAX_PUSH: + return False + last_raw = (row.get("alert_last_push_at") or "").strip() + if not last_raw: + return True + try: + last = datetime.fromisoformat(last_raw.replace("Z", "")).replace(tzinfo=TZ) + except ValueError: + return True + return (now - last).total_seconds() >= ALERT_INTERVAL_SEC + + +def _record_zone_push(conn, pid: int, *, break_side: str, bar_time: str, now_iso: str) -> int: + row = conn.execute( + "SELECT alert_push_count FROM key_monitors WHERE id=?", (pid,), + ).fetchone() + count = int(row["alert_push_count"] or 0) + 1 + conn.execute( + """UPDATE key_monitors SET + alert_push_count=?, alert_last_push_at=?, alert_break_side=?, + breakout_bar_time=?, upper_triggered=?, lower_triggered=? + WHERE id=?""", + ( + count, + now_iso, + break_side, + bar_time, + 1 if break_side == "upper" else 0, + 1 if break_side == "lower" else 0, + pid, + ), + ) + return count + + +def _handle_zone_alert( + conn, + row: dict, + *, + break_side: str, + bar: dict, + send_wechat: Callable[[str], None], +) -> None: + pid = int(row["id"]) + now_iso = _now_iso() + bar_time = str(bar.get("time") or "")[:19] + close_price = float(bar.get("close") or 0) + bar_key = bar_time + last_bar = (row.get("last_trigger_bar") or "").strip() + if last_bar == bar_key and int(row.get("alert_push_count") or 0) > 0: + return + + push_n = _record_zone_push(conn, pid, break_side=break_side, bar_time=bar_time, now_iso=now_iso) + conn.execute( + "UPDATE key_monitors SET last_trigger_bar=?, alert_close_price=? WHERE id=?", + (bar_key, close_price, pid), + ) + send_wechat(format_zone_alert( + row, break_side=break_side, close_price=close_price, bar_time=bar_time, push_index=push_n, + )) + if push_n >= ALERT_MAX_PUSH: + archive_monitor(conn, pid) + + +def run_key_monitor_check( + conn, + *, + db_path: str, + get_trading_mode_fn: Callable[[], str], + send_wechat: Callable[[str], None], + execute_breakout_fn: Callable[[Any, dict, str], tuple[bool, str]] | None = None, +) -> None: + """扫描 active 关键位监控(5m 收盘触发)。""" + rows = conn.execute( + "SELECT * FROM key_monitors WHERE status='active' OR status IS NULL" + ).fetchall() + mode = get_trading_mode_fn() + now = datetime.now(TZ) + + for r in rows: + row = dict(r) + pid = int(row["id"]) + sym = (row.get("symbol") or "").strip() + typ = normalize_monitor_type(row.get("monitor_type") or "") + if not sym: + continue + + try: + upper = float(row.get("upper") or 0) + lower = float(row.get("lower") or 0) + except (TypeError, ValueError): + continue + if upper <= lower: + continue + + alert_count = int(row.get("alert_push_count") or 0) + if is_zone_type(typ) and alert_count > 0: + if alert_count >= ALERT_MAX_PUSH: + archive_monitor(conn, pid) + continue + if _should_send_followup_push(row, now): + break_side = (row.get("alert_break_side") or "upper").strip() + bar_time = (row.get("breakout_bar_time") or row.get("last_trigger_bar") or "")[:19] + close_price = float(row.get("alert_close_price") or 0) + if close_price <= 0: + close_price = float(row.get("upper") if break_side == "upper" else row.get("lower") or 0) + push_n = _record_zone_push( + conn, pid, break_side=break_side, bar_time=bar_time, now_iso=_now_iso(), + ) + send_wechat(format_zone_alert( + row, break_side=break_side, close_price=close_price, bar_time=bar_time, push_index=push_n, + )) + if push_n >= ALERT_MAX_PUSH: + archive_monitor(conn, pid) + continue + + bar = fetch_closed_5m_bar(sym, db_path=db_path, trading_mode=mode) + if not bar: + continue + bar_time = str(bar.get("time") or "")[:19] + if not bar_time: + continue + if (row.get("last_trigger_bar") or "").strip() == bar_time: + continue + + try: + close_price = float(bar.get("close") or 0) + except (TypeError, ValueError): + continue + break_side = detect_break_side(close_price, upper, lower) + if not break_side: + continue + + if is_zone_type(typ): + _handle_zone_alert(conn, row, break_side=break_side, bar=bar, send_wechat=send_wechat) + continue + + if is_auto_trade_type(typ): + if not execute_breakout_fn: + logger.warning("key monitor auto trade skipped: no executor") + continue + ok, detail = execute_breakout_fn(conn, row, bar, break_side) + conn.execute( + "UPDATE key_monitors SET last_trigger_bar=?, breakout_bar_time=?, alert_break_side=? WHERE id=?", + (bar_time, bar_time, break_side, pid), + ) + if ok: + archive_monitor(conn, pid) diff --git a/market_sessions.py b/market_sessions.py index 9d613f7..5a013f1 100644 --- a/market_sessions.py +++ b/market_sessions.py @@ -106,6 +106,96 @@ def minutes_until_next_session(now: Optional[datetime] = None) -> Optional[float return (starts[0] - d).total_seconds() / 60.0 +def _session_open_label(dt: datetime) -> str: + h, m = dt.hour, dt.minute + if (h, m) == (9, 0): + return "日盘开盘" + if (h, m) == (13, 30): + return "午盘开盘" + if (h, m) == (21, 0): + return "夜盘开盘" + return "开盘" + + +def _fmt_countdown(seconds: int) -> str: + s = max(0, int(seconds)) + h, rem = divmod(s, 3600) + m, sec = divmod(rem, 60) + if h > 0: + return f"{h}小时{m:02d}分{sec:02d}秒" + if m > 0: + return f"{m}分{sec:02d}秒" + return f"{sec}秒" + + +def _day_close_dt(d: datetime) -> datetime: + return d.replace(hour=15, minute=0, second=0, microsecond=0) + + +def _night_close_dt(d: datetime) -> datetime: + t = d.hour * 60 + d.minute + if t >= 21 * 60: + nxt = (d + timedelta(days=1)).replace(hour=2, minute=30, second=0, microsecond=0) + return nxt + return d.replace(hour=2, minute=30, second=0, microsecond=0) + + +def _current_break_close(d: datetime) -> tuple[Optional[datetime], Optional[datetime], Optional[str], Optional[str]]: + """当前交易段内的休盘/收盘时刻与标签。""" + t = d.hour * 60 + d.minute + if 9 * 60 <= t < 11 * 60 + 30: + br = d.replace(hour=11, minute=30, second=0, microsecond=0) + cl = _day_close_dt(d) + return br, cl, "午间休盘", "日盘收盘" + if 13 * 60 + 30 <= t < 15 * 60: + cl = _day_close_dt(d) + return None, cl, None, "日盘收盘" + if t >= 21 * 60 or t < 2 * 60 + 30: + cl = _night_close_dt(d) + return None, cl, None, "夜盘收盘" + return None, None, None, None + + +def trading_session_clock(now: Optional[datetime] = None) -> dict: + """顶栏展示:当前时间、交易状态、距开盘/休盘/收盘倒计时。""" + d = now or datetime.now(TZ) + if d.tzinfo is None: + d = d.replace(tzinfo=TZ) + else: + d = d.astimezone(TZ) + in_sess = is_trading_session(d) + out = { + "now": d.strftime("%Y-%m-%d %H:%M:%S"), + "now_time": d.strftime("%m-%d %H:%M:%S"), + "in_session": in_sess, + "status_label": "交易时间段" if in_sess else "非交易时间段", + } + if not in_sess: + starts = iter_session_starts(d, hours_ahead=72) + if starts: + nxt = starts[0] + secs = int(max(0, (nxt - d).total_seconds())) + out["next_open_at"] = nxt.strftime("%m-%d %H:%M") + out["next_open_label"] = _session_open_label(nxt) + out["secs_to_open"] = secs + out["countdown_open"] = _fmt_countdown(secs) + return out + br, cl, br_label, cl_label = _current_break_close(d) + if br and br > d: + secs = int((br - d).total_seconds()) + out["break_at"] = br.strftime("%H:%M") + out["break_label"] = br_label or "休盘" + out["secs_to_break"] = secs + out["countdown_break"] = _fmt_countdown(secs) + if cl and cl > d: + secs = int((cl - d).total_seconds()) + out["close_at"] = cl.strftime("%H:%M") + out["close_label"] = cl_label or "收盘" + out["secs_to_close"] = secs + out["countdown_close"] = _fmt_countdown(secs) + return out + + def in_premarket_connect_window( now: Optional[datetime] = None, *, diff --git a/nav_settings.py b/nav_settings.py index 4a9411f..21a1650 100644 --- a/nav_settings.py +++ b/nav_settings.py @@ -15,6 +15,7 @@ NAV_TOGGLES: dict[str, str] = { "plans": "开单计划", "market": "行情K线", "strategy": "策略交易", + "ai": "AI 分析", } DEFAULT_NAV: dict[str, bool] = {k: True for k in NAV_TOGGLES} diff --git a/pending_order_worker.py b/pending_order_worker.py new file mode 100644 index 0000000..44e384d --- /dev/null +++ b/pending_order_worker.py @@ -0,0 +1,82 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""开仓挂单超时:后台定期 reconcile,不依赖 SSE 完整刷新。""" +from __future__ import annotations + +import logging +import threading +import time +from typing import Callable, Optional + +from vnpy_bridge import ctp_status + +logger = logging.getLogger(__name__) + +CHECK_INTERVAL_SEC = 10 +IDLE_INTERVAL_SEC = 45 +DISCONNECTED_SLEEP_SEC = 30 +STARTUP_DELAY_SEC = 15 + + +def start_pending_order_worker( + *, + db_path: str, + get_mode_fn: Callable[[], str], + init_tables_fn: Callable | None = None, + get_capital_fn: Callable | None = None, + reconcile_fn: Callable[..., dict], + on_changed_fn: Callable[[], None] | None = None, + interval: int = CHECK_INTERVAL_SEC, + idle_interval: int = IDLE_INTERVAL_SEC, +) -> None: + """后台线程:存在 pending 开仓监控时定期同步成交/超时撤单。""" + from db_conn import connect_db + + def _loop() -> None: + time.sleep(STARTUP_DELAY_SEC) + while True: + sleep_sec = max(5, idle_interval) + try: + mode = get_mode_fn() + if not ctp_status(mode).get("connected"): + time.sleep(DISCONNECTED_SLEEP_SEC) + continue + + conn = connect_db(db_path) + try: + if init_tables_fn: + init_tables_fn(conn) + pending_n = conn.execute( + "SELECT COUNT(*) AS n FROM trade_order_monitors WHERE status='pending'" + ).fetchone()["n"] + if pending_n <= 0: + time.sleep(sleep_sec) + continue + + sleep_sec = max(1, interval) + capital = 0.0 + if get_capital_fn: + try: + capital = float(get_capital_fn(conn) or 0) + except Exception: + capital = 0.0 + stats = reconcile_fn(conn, mode, capital=capital) or {} + if any(int(stats.get(k) or 0) for k in ("promoted", "cancelled", "closed")): + logger.info( + "pending worker reconcile: promoted=%s cancelled=%s closed=%s", + stats.get("promoted", 0), + stats.get("cancelled", 0), + stats.get("closed", 0), + ) + if on_changed_fn: + on_changed_fn() + finally: + conn.close() + except Exception as exc: + logger.warning("pending order worker: %s", exc) + time.sleep(sleep_sec) + + threading.Thread(target=_loop, daemon=True, name="pending-order-worker").start() diff --git a/position_sizing.py b/position_sizing.py index c8ad7fc..4e747a0 100644 --- a/position_sizing.py +++ b/position_sizing.py @@ -9,7 +9,7 @@ from __future__ import annotations import math from typing import Optional -from contract_specs import get_contract_spec +from contract_specs import get_contract_spec, margin_one_lot MODE_FIXED = "fixed" MODE_AMOUNT = "amount" @@ -57,37 +57,56 @@ def calc_lots_by_amount( capital: float = 0.0, max_lots: Optional[int] = None, max_margin_pct: float = 30.0, -) -> tuple[Optional[int], Optional[str]]: - """固定金额:按止损距离将金额换算为手数。""" + trading_mode: str | None = None, +) -> tuple[Optional[int], Optional[str], dict]: + """固定金额:先按止损距离算手数,再按保证金上限收紧。返回 (手数, 错误, 详情)。""" + info: dict = { + "lots_by_risk": 0, + "lots_by_margin": None, + "capped_by": None, + } try: entry_f = float(entry) sl_f = float(stop_loss) budget = float(amount) cap = float(capital or 0) except (TypeError, ValueError): - return None, "参数格式错误" + return None, "参数格式错误", info if entry_f <= 0 or budget <= 0: - return None, "入场价或固定金额无效" + return None, "入场价或固定金额无效", info per_lot_risk, err = _per_lot_risk(entry_f, sl_f, direction, ths_code) if err: - return None, err + return None, err, info lots = int(math.floor(budget / per_lot_risk)) + info["lots_by_risk"] = lots if lots < 1: - return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手" + return None, f"按固定金额 {budget:.0f} 元,当前止损距离下不足 1 手", info if cap > 0: - spec = get_contract_spec(ths_code) - margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"] + margin_per_lot, _src = margin_one_lot( + ths_code, entry_f, direction=direction, trading_mode=trading_mode, + ) + if margin_per_lot <= 0: + spec = get_contract_spec(ths_code) + margin_per_lot = entry_f * spec["mult"] * spec["margin_rate"] margin_cap = max(1.0, min(100.0, float(max_margin_pct or 30.0))) max_by_margin = ( int(math.floor(cap * margin_cap / 100.0 / margin_per_lot)) if margin_per_lot > 0 else lots ) + info["lots_by_margin"] = max_by_margin + info["margin_per_lot"] = round(margin_per_lot, 2) + info["max_margin_pct"] = margin_cap if max_by_margin < 1: - return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手" + return None, f"按保证金上限 {margin_cap:g}%,当前不足 1 手", info + if max_by_margin < lots: + info["capped_by"] = "margin" lots = min(lots, max_by_margin) cap_lots = max_lots if max_lots is not None else DEFAULT_MAX_ORDER_LOTS - lots = min(lots, cap_lots) - return lots, None + if lots > cap_lots: + lots = cap_lots + info["capped_by"] = info.get("capped_by") or "max_lots" + info["lots"] = lots + return lots, None, info def calc_lots_by_risk( @@ -100,6 +119,7 @@ def calc_lots_by_risk( *, max_lots: Optional[int] = None, max_margin_pct: float = 30.0, + trading_mode: str | None = None, ) -> tuple[Optional[int], Optional[str]]: """策略等场景:按权益百分比风险预算换算手数。""" try: @@ -110,13 +130,22 @@ def calc_lots_by_risk( if cap <= 0 or rp <= 0: return None, "资金或风险比例无效" budget = cap * rp / 100.0 - return calc_lots_by_amount( + lots, err, info = calc_lots_by_amount( entry, stop_loss, direction, budget, ths_code, capital=cap, max_lots=max_lots, max_margin_pct=max_margin_pct, + trading_mode=trading_mode, ) + return lots, err -def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = None) -> dict: +def calc_order_tick_metrics( + ths_code: str, + lots: float, + price: Optional[float] = None, + *, + direction: str = "long", + trading_mode: str | None = None, +) -> dict: """下单区展示:最小变动价位、每跳盈亏、保证金等。""" spec = get_contract_spec(ths_code) mult = int(spec["mult"]) @@ -127,7 +156,22 @@ def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = tick_value_total = round(tick_value_per_lot * lots_i, 2) prec = price_precision_from_tick(tick) mark = float(price) if price else 0.0 - margin_per_lot = round(mark * mult * margin_rate, 2) if mark > 0 else None + margin_per_lot = None + margin_source = "estimate" + if mark > 0: + margin_per_lot, margin_source, spec_used = margin_one_lot( + ths_code, mark, direction=direction, trading_mode=trading_mode, + ) + if spec_used.get("mult"): + mult = int(spec_used["mult"]) + if spec_used.get("tick_size"): + tick = float(spec_used["tick_size"]) + tick_value_per_lot = round(tick * mult, 4) + tick_value_total = round(tick_value_per_lot * lots_i, 2) + prec = price_precision_from_tick(tick) + if margin_per_lot <= 0: + margin_per_lot = round(mark * mult * margin_rate, 2) + margin_source = "estimate" margin_total = round(margin_per_lot * lots_i, 2) if margin_per_lot else None return { "mult": mult, @@ -139,6 +183,7 @@ def calc_order_tick_metrics(ths_code: str, lots: float, price: Optional[float] = "margin_per_lot": margin_per_lot, "margin_total": margin_total, "margin_rate": margin_rate, + "margin_source": margin_source, } @@ -149,6 +194,8 @@ def calc_margin_usage_pct( extra_symbol: str = "", extra_lots: int = 0, extra_price: float = 0, + extra_direction: str = "long", + trading_mode: str | None = None, ) -> float: """当前持仓 + 拟开仓占权益的保证金比例(%)。""" cap = float(capital or 0) @@ -159,13 +206,65 @@ def calc_margin_usage_pct( lots = int(p.get("lots") or 0) if lots <= 0: continue - sym = (p.get("symbol") or "").strip() - entry = float(p.get("avg_price") or p.get("entry_price") or 0) - if entry <= 0: + ctp_margin = float(p.get("margin") or 0) + if ctp_margin > 0: + total += ctp_margin continue - spec = get_contract_spec(sym) - total += entry * spec["mult"] * lots * spec["margin_rate"] + sym = (p.get("symbol") or p.get("symbol_code") or "").strip() + entry = float(p.get("avg_price") or p.get("entry_price") or 0) + direction = (p.get("direction") or "long").strip().lower() + if entry <= 0 or not sym: + continue + per_lot, _, _ = margin_one_lot( + sym, entry, direction=direction, trading_mode=trading_mode, + ) + if per_lot <= 0: + spec = get_contract_spec(sym) + per_lot = entry * spec["mult"] * spec["margin_rate"] + total += per_lot * lots if extra_symbol and extra_lots > 0 and extra_price > 0: - spec = get_contract_spec(extra_symbol) - total += extra_price * spec["mult"] * extra_lots * spec["margin_rate"] + per_lot, _, _ = margin_one_lot( + extra_symbol, extra_price, direction=extra_direction, trading_mode=trading_mode, + ) + if per_lot <= 0: + spec = get_contract_spec(extra_symbol) + per_lot = extra_price * spec["mult"] * spec["margin_rate"] + total += per_lot * extra_lots return round(total / cap * 100.0, 2) + + +def cap_lots_for_margin_budget( + positions: list[dict], + capital: float, + symbol: str, + direction: str, + price: float, + desired_lots: int, + max_margin_pct: float, + trading_mode: str | None = None, +) -> tuple[int, float]: + """在保证金上限内,返回可加仓手数及占用比例。""" + desired = max(0, int(desired_lots or 0)) + if desired <= 0: + return 0, calc_margin_usage_pct(positions, capital, trading_mode=trading_mode) + for lots in range(desired, 0, -1): + usage = calc_margin_usage_pct( + positions, + capital, + extra_symbol=symbol, + extra_lots=lots, + extra_price=price, + extra_direction=direction, + trading_mode=trading_mode, + ) + if usage <= max_margin_pct: + return lots, usage + return 0, calc_margin_usage_pct( + positions, + capital, + extra_symbol=symbol, + extra_lots=desired, + extra_price=price, + extra_direction=direction, + trading_mode=trading_mode, + ) diff --git a/product_recommend.py b/product_recommend.py index d57a5d5..7426999 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -11,7 +11,7 @@ import math from concurrent.futures import ThreadPoolExecutor from typing import Callable, Optional -from contract_specs import get_contract_spec +from contract_specs import get_contract_spec, margin_one_lot from fee_specs import calc_fee_breakdown from recommend_trend import analyze_product_daily, sort_recommend_by_trend from symbols import PRODUCTS, product_category, product_has_night_session @@ -24,6 +24,22 @@ SMALL_ACCOUNT_CAPITAL_MAX = 200_000.0 DISCONNECTED_RECOMMEND_CAPITAL = 100_000.0 SMALL_ACCOUNT_PRODUCT_THS = frozenset({"c", "m", "MA", "rb"}) SMALL_ACCOUNT_SCOPE_LABEL = "玉米、豆粕、甲醇、螺纹钢" +SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT = 30.0 +SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT = 40.0 + + +def small_account_margin_recommendations() -> dict: + """20 万以下账户建议的保证金比例(供系统设置参考)。""" + wan = int(SMALL_ACCOUNT_CAPITAL_MAX // 10_000) + return { + "open_margin_pct": SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT, + "roll_margin_pct": SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT, + "label": ( + f"权益 {wan} 万以下建议:开仓保证金上限 " + f"{int(SMALL_ACCOUNT_RECOMMENDED_OPEN_MARGIN_PCT)}%," + f"滚仓总保证金不超过 {int(SMALL_ACCOUNT_RECOMMENDED_ROLL_MARGIN_PCT)}%" + ), + } def small_account_scope_hint(*, ctp_connected: bool = True) -> str: @@ -146,6 +162,7 @@ def assess_product_for_capital( reward_risk_ratio: float = 2.0, trading_mode: str = "simulation", ctp_connected: bool = True, + main_code: str = "", ) -> dict: """评估单品种在当前资金下是否可交易。""" ths = product.get("ths") or "" @@ -194,7 +211,18 @@ def assess_product_for_capital( "has_night_session": product_has_night_session(product), } - margin_one = p * mult * margin_rate + margin_source = None + code_for_margin = (main_code or "").strip() or (ths + "8888") + if p > 0 and ctp_connected: + margin_one, margin_source, spec_used = margin_one_lot( + code_for_margin, p, trading_mode=trading_mode, + ) + if spec_used.get("mult"): + mult = spec_used["mult"] + if spec_used.get("tick_size"): + tick = float(spec_used["tick_size"]) + else: + margin_one = p * mult * margin_rate min_capital = margin_one / (margin_pct / 100.0) if margin_pct > 0 else margin_one margin_budget = cap * margin_pct / 100.0 if cap > 0 else 0.0 max_lots = int(math.floor(margin_budget / margin_one)) if margin_one > 0 and margin_budget > 0 else 0 @@ -221,8 +249,10 @@ def assess_product_for_capital( status, label = "margin_ok", f"最大 {max_lots} 手·止损偏宽" else: status, label = "blocked", "资金不足" + if margin_source == "ctp" and can_margin: + label += "(柜台保证金)" - return { + row_out = { "ths": ths, "name": name, "exchange": exchange, @@ -245,6 +275,9 @@ def assess_product_for_capital( "status_label": label, "has_night_session": product_has_night_session(product), } + if margin_source: + row_out["margin_source"] = margin_source + return row_out def list_product_recommendations( @@ -267,6 +300,7 @@ def list_product_recommendations( max_margin_pct=max_margin_pct, trading_mode=trading_mode, ctp_connected=ctp_connected, + main_code=main_code, ) main_code = (quote.get("ths_code") or "").strip() row["main_code"] = main_code diff --git a/recommend_store.py b/recommend_store.py index ff8d158..1d0baf8 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -12,7 +12,7 @@ import math from datetime import datetime from typing import Callable, Optional -from contract_specs import get_contract_spec +from contract_specs import get_contract_spec, margin_one_lot from fee_specs import ensure_fee_rates_schema from product_recommend import ( _attach_turnover, @@ -128,16 +128,7 @@ def enrich_recommend_rows( cap = float(capital or 0) pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) budget = cap * pct / 100.0 if cap > 0 else 0.0 - ctp_connected = False - ctp_lookup_spec = None - ctp_estimate_margin_one_lot_fn = None - try: - from vnpy_bridge import ctp_estimate_margin_one_lot, ctp_lookup_contract_spec, ctp_status - ctp_connected = bool(ctp_status(trading_mode).get("connected")) - ctp_lookup_spec = ctp_lookup_contract_spec - ctp_estimate_margin_one_lot_fn = ctp_estimate_margin_one_lot - except Exception: - pass + ctp_connected = _ctp_connected_for_mode(trading_mode) enriched: list[dict] = [] for raw in rows: row = dict(raw) @@ -150,26 +141,27 @@ def enrich_recommend_rows( row["mult"] = spec["mult"] if row.get("tick_size") in (None, ""): row["tick_size"] = float(spec.get("tick_size") or 1.0) - if ctp_connected and main_code and ctp_lookup_spec: - ctp_spec = ctp_lookup_spec(trading_mode, main_code) - if ctp_spec: - if ctp_spec.get("mult"): - row["mult"] = ctp_spec["mult"] - if ctp_spec.get("tick_size"): - row["tick_size"] = ctp_spec["tick_size"] - row["spec_source"] = "ctp" margin_one = 0.0 try: margin_one = float(row.get("margin_one_lot") or 0) except (TypeError, ValueError): margin_one = 0.0 price = float(row.get("price") or 0) - if ctp_connected and main_code and price > 0 and ctp_estimate_margin_one_lot_fn: - ctp_margin = ctp_estimate_margin_one_lot_fn(trading_mode, main_code, price) - if ctp_margin and ctp_margin > 0: - margin_one = ctp_margin - row["margin_one_lot"] = ctp_margin + code_for_margin = main_code or spec_code + if price > 0 and code_for_margin: + margin_one, margin_source, spec_used = margin_one_lot( + code_for_margin, + price, + trading_mode=trading_mode if ctp_connected else None, + ) + if spec_used.get("mult"): + row["mult"] = spec_used["mult"] + if spec_used.get("tick_size"): + row["tick_size"] = spec_used["tick_size"] + row["margin_one_lot"] = margin_one + if margin_source == "ctp": row["margin_source"] = "ctp" + row["spec_source"] = "ctp" if margin_one > 0 and budget > 0: lots = int(math.floor(budget / margin_one)) else: diff --git a/sl_tp_guard.py b/sl_tp_guard.py index d0e072e..2139a6b 100644 --- a/sl_tp_guard.py +++ b/sl_tp_guard.py @@ -231,6 +231,8 @@ def monitor_source_label(raw: str) -> str: "trend": "趋势回调", "roll": "顺势加仓", "ctp_sync": "CTP 柜台", + "箱体突破": "箱体突破", + "收敛突破": "收敛突破", } key = (raw or "manual").strip().lower() return mapping.get(key, raw or "期货下单") @@ -339,6 +341,36 @@ def write_trade_log( refresh_stats_cache(conn, capital) except Exception as exc: logger.debug("stats refresh after close: %s", exc) + try: + from trade_notify import notify_trade_log_close + from trading_context import trading_mode_label + from app import get_setting, send_wechat_msg + from ai_worker import schedule_ai_event_analysis + from db_conn import DB_PATH + + notify_trade_log_close( + send_wechat=send_wechat_msg, + get_setting=get_setting, + mode_label=trading_mode_label(get_setting), + capital=capital, + sym=sym, + symbol_name=symbol_name, + direction=direction, + entry=entry, + close_price=close_price, + sl=stop_loss if stop_loss is not None else None, + tp=take_profit if take_profit is not None else None, + lots=lots, + pnl_net=pnl_net, + equity_after=equity_after, + holding_minutes=minutes, + result=result, + monitor_type=monitor_type, + schedule_ai_fn=schedule_ai_event_analysis, + db_path=DB_PATH, + ) + except Exception as exc: + logger.debug("close notify: %s", exc) def _write_trade_log( @@ -732,7 +764,7 @@ def check_sl_tp_on_tick( pass reason = None - if tp_f is not None and not mon.get("trailing_be") and _tp_triggered(direction, tp_f, mark, tick): + if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): reason = "take_profit" elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): reason = "stop_loss" @@ -813,7 +845,7 @@ def check_monitors_locally( pass reason = None - if tp_f is not None and not mon.get("trailing_be") and _tp_triggered(direction, tp_f, mark, tick): + if tp_f is not None and _tp_triggered(direction, tp_f, mark, tick): reason = "take_profit" elif sl_f is not None and _sl_triggered(direction, sl_f, mark, tick): reason = "stop_loss" diff --git a/static/css/ai_messages.css b/static/css/ai_messages.css new file mode 100644 index 0000000..11ff53e --- /dev/null +++ b/static/css/ai_messages.css @@ -0,0 +1,12 @@ +.ai-page .card-body{display:flex;flex-direction:column;gap:0;min-height:0} +.ai-usage{margin-bottom:.85rem;font-size:.84rem;color:var(--text-muted)} +.ai-usage summary{cursor:pointer;color:var(--accent);font-weight:600;margin-bottom:.35rem} +.ai-usage-body ul{margin:.25rem 0 0 1.1rem;padding:0;line-height:1.55} +.ai-usage-body a{color:var(--accent)} +.ai-section-label{font-size:.9rem;margin:0 0 .65rem;color:var(--text-title);font-weight:600} +.ai-msg-list{max-height:min(70vh,720px);overflow:auto;padding-right:.25rem} +.ai-msg{border:1px solid var(--border);border-radius:10px;padding:.85rem 1rem;margin-bottom:.75rem;background:rgba(255,255,255,.02)} +.ai-msg-head{display:flex;justify-content:space-between;gap:.5rem;font-size:.75rem;color:var(--text-muted);margin-bottom:.35rem} +.ai-msg-kind{text-transform:uppercase;letter-spacing:.04em;color:var(--accent)} +.ai-msg-title{font-size:.95rem;margin:0 0 .5rem;color:var(--text-title)} +.ai-msg-body{margin:0;white-space:pre-wrap;font-family:inherit;font-size:.84rem;line-height:1.55;color:var(--text-main)} diff --git a/static/css/keys.css b/static/css/keys.css new file mode 100644 index 0000000..35162fa --- /dev/null +++ b/static/css/keys.css @@ -0,0 +1,13 @@ +.key-rules{margin-bottom:.75rem;font-size:.82rem;color:var(--text-muted)} +.key-rules summary{cursor:pointer;color:var(--accent);font-weight:600;margin-bottom:.35rem} +.key-rules-body{padding:.35rem 0 .15rem} +.key-rules-body ul{margin:.25rem 0 .5rem 1.1rem;padding:0} +.key-rules-body li{margin:.15rem 0} +.key-check{display:inline-flex;align-items:center;gap:.35rem;font-size:.82rem;flex:1;min-width:0;margin:0} +.key-check-text{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.line-key-actions{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:nowrap} +.line-key-actions.is-hidden{display:none!important} +.line-key-actions .key-submit-btn{flex-shrink:0;min-width:5.5rem;padding:.55rem 1.1rem} +.line-key-actions.key-actions-zone{justify-content:flex-end} +.line-key-actions.key-actions-zone .key-check{display:none} +#key-trade-mode-wrap.is-hidden,#key-rr-wrap.is-hidden{display:none!important} diff --git a/static/css/trade.css b/static/css/trade.css index 170126d..55cab29 100644 --- a/static/css/trade.css +++ b/static/css/trade.css @@ -22,6 +22,8 @@ .trade-top-bar-main{display:flex;flex-wrap:wrap;gap:.5rem .65rem;align-items:center;flex:1;min-width:0} .trade-top-bar-actions{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center} .trade-top-hint{font-size:.72rem;white-space:nowrap} +.trade-session-clock{font-size:.78rem;line-height:1.45} +.session-clock-detail strong{color:var(--accent);font-weight:600} .btn-ctp-sm{padding:.4rem .9rem;font-size:.8rem;width:auto;white-space:nowrap} .trade-card{margin-bottom:0;height:100%;display:flex;flex-direction:column} .trade-card h2{margin-bottom:.35rem;flex-shrink:0} @@ -85,7 +87,10 @@ .pos-main-badge{font-size:.68rem;vertical-align:middle} .pos-change-up{color:var(--profit)} .rec-change-down{color:var(--loss)} -#recommend .trade-table-wrap{max-height:min(70vh,520px)} +#recommend .trade-table-wrap{max-height:none;overflow:visible} +#recommend.card{height:auto} +#recommend .card-body{display:flex;flex-direction:column} +#recommend .trade-table-wrap{flex:0 0 auto} #positions .card-body.card-scroll{flex:1;max-height:none;overflow-y:auto} .pos-pending-orders{margin-top:.55rem;padding-top:.55rem;border-top:1px dashed var(--table-border)} .pos-pending-orders .pending-title{font-size:.68rem;color:var(--text-muted);margin-bottom:.35rem} diff --git a/static/js/keys.js b/static/js/keys.js index 78ce392..086f78d 100644 --- a/static/js/keys.js +++ b/static/js/keys.js @@ -4,6 +4,32 @@ */ (function () { var keyTimer = null; + var typeEl = document.getElementById('key-type'); + var tradeModeWrap = document.getElementById('key-trade-mode-wrap'); + var rrWrap = document.getElementById('key-rr-wrap'); + var rrEl = document.getElementById('key-rr'); + var trailingWrap = document.getElementById('key-trailing-wrap'); + var trailingEl = document.getElementById('key-trailing'); + var rowActions = document.getElementById('key-row-actions'); + var rowPrices = document.getElementById('key-row-prices'); + + function isAutoType(typ) { + return typ === '箱体突破' || typ === '收敛突破'; + } + + function syncKeyForm() { + var typ = typeEl ? typeEl.value : ''; + var auto = isAutoType(typ); + if (tradeModeWrap) tradeModeWrap.classList.toggle('is-hidden', !auto); + if (rrWrap) rrWrap.classList.toggle('is-hidden', !auto); + if (trailingWrap) trailingWrap.classList.toggle('is-hidden', !auto); + if (rowActions) rowActions.classList.toggle('key-actions-zone', !auto); + if (rowPrices) rowPrices.classList.toggle('key-zone-mode', !auto); + if (!auto && trailingEl) trailingEl.checked = false; + if (auto && trailingEl && trailingEl.checked && rrEl) { + if (parseFloat(rrEl.value) < 3) rrEl.value = '3'; + } + } function fmtDist(v) { if (v === null || v === undefined) return '--'; @@ -44,8 +70,20 @@ keyTimer = setInterval(pollKeyPrices, 1000); } - if (window.qihuoPageBoot) window.qihuoPageBoot(startPolling, '#key-monitor-list'); - else if (window.qihuoOnPageLoad) window.qihuoOnPageLoad(startPolling); - else document.addEventListener('DOMContentLoaded', startPolling); + function bindForm() { + if (typeEl) typeEl.addEventListener('change', syncKeyForm); + if (trailingEl) { + trailingEl.addEventListener('change', function () { + if (trailingEl.checked && rrEl && parseFloat(rrEl.value) < 3) { + rrEl.value = '3'; + } + }); + } + syncKeyForm(); + } + + if (window.qihuoPageBoot) window.qihuoPageBoot(function () { bindForm(); startPolling(); }, '#key-monitor-list'); + else if (window.qihuoOnPageLoad) window.qihuoOnPageLoad(function () { bindForm(); startPolling(); }); + else document.addEventListener('DOMContentLoaded', function () { bindForm(); startPolling(); }); if (window.qihuoOnPageLeave) window.qihuoOnPageLeave(stopPolling); })(); diff --git a/static/js/settings.js b/static/js/settings.js index c252c5f..4ac64e1 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -21,6 +21,23 @@ } syncSizingFields(); + var aiProviderSel = document.getElementById('ai-provider-select'); + function syncAiProviderCards() { + if (!aiProviderSel) return; + var val = aiProviderSel.value; + document.querySelectorAll('.settings-ai-card[data-ai-provider]').forEach(function (card) { + var active = card.getAttribute('data-ai-provider') === val; + card.classList.toggle('is-active', active); + var badge = card.querySelector('.settings-ai-card-head .badge'); + if (badge) badge.style.display = active ? '' : 'none'; + }); + } + if (aiProviderSel && !aiProviderSel.dataset.settingsBound) { + aiProviderSel.dataset.settingsBound = '1'; + aiProviderSel.addEventListener('change', syncAiProviderCards); + } + syncAiProviderCards(); + var SETTINGS_FOLD_KEY = 'qihuo_settings_fold'; function setSettingsFold(el, collapsed) { if (!el) return; diff --git a/static/js/strategy.js b/static/js/strategy.js index bda2ca2..7646314 100644 --- a/static/js/strategy.js +++ b/static/js/strategy.js @@ -91,6 +91,10 @@ if (preview.new_stop_loss != null) lines.push('新止损:' + preview.new_stop_loss); if (preview.total_lots != null) lines.push('合计手数:' + preview.total_lots); if (preview.worst_loss != null) lines.push('最坏亏损:' + preview.worst_loss + ' 元'); + if (preview.margin_usage_pct != null) { + lines.push('滚仓后保证金占用:' + preview.margin_usage_pct + '%'); + } + if (preview.margin_cap_note) lines.push(preview.margin_cap_note); if (preview.message) lines.push(preview.message); return lines.length ? lines.join('\n') : JSON.stringify(preview, null, 2); } diff --git a/static/js/trade.js b/static/js/trade.js index dd450ce..4dfebad 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -61,6 +61,7 @@ window.TRADE_FIXED_LOTS = cfg.fixed_lots; window.TRADE_FIXED_AMOUNT = cfg.fixed_amount; window.__RECOMMEND_ROWS__ = cfg.recommend_rows || []; + if (cfg.session_clock) applySessionClock(cfg.session_clock); } catch (e) { /* ignore */ } } @@ -119,6 +120,12 @@ var sym = selectedSymbol(); var maxLots = maxLotsForSymbol(sym); var lots = effectiveLots(); + if (lastSizingInfo && lastSizingInfo.capped_by === 'margin' && lastSizingInfo.lots_by_risk > lots) { + warn.hidden = false; + warn.textContent = '以损定仓 ' + lastSizingInfo.lots_by_risk + ' 手,保证金上限 ' + + (lastSizingInfo.max_margin_pct || '') + '% 收紧为 ' + lots + ' 手'; + return; + } if (maxLots > 0 && lots > maxLots) { warn.hidden = false; warn.textContent = '已超过最大手数 ' + maxLots + ' 手,请调整手数'; @@ -205,6 +212,7 @@ ctpConnected = !!connected; ctpConnecting = !!connecting; isTradingSession = !!data.trading_session; + if (data.session_clock) applySessionClock(data.session_clock); syncCtpBadgeFromStatus(data.ctp_status || { connected: connected, connecting: connecting }); if (data.ctp_status && typeof data.ctp_status.auto_connect_enabled === 'boolean') { ctpAutoConnectEnabled = data.ctp_status.auto_connect_enabled; @@ -380,6 +388,71 @@ } var lastPreviewMetrics = null; + var lastSizingInfo = null; + var sessionClockBase = null; + var sessionClockTickTimer = null; + + function fmtCountdown(secs) { + secs = Math.max(0, parseInt(secs, 10) || 0); + var h = Math.floor(secs / 3600); + var m = Math.floor((secs % 3600) / 60); + var s = secs % 60; + if (h > 0) return h + '小时' + (m < 10 ? '0' : '') + m + '分' + (s < 10 ? '0' : '') + s + '秒'; + if (m > 0) return m + '分' + (s < 10 ? '0' : '') + s + '秒'; + return s + '秒'; + } + + function fmtClockNow(d) { + var mo = d.getMonth() + 1; + var da = d.getDate(); + var pad = function (n) { return n < 10 ? '0' + n : String(n); }; + return pad(mo) + '-' + pad(da) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); + } + + function tickSessionClock() { + var base = sessionClockBase; + if (!base || !base.clock) return; + var c = base.clock; + var elapsed = Math.floor((Date.now() - base.at) / 1000); + var nowEl = document.getElementById('clock-now'); + if (nowEl && c.now) { + var t = new Date(String(c.now).replace(/-/g, '/')); + if (!isNaN(t.getTime())) { + t.setSeconds(t.getSeconds() + elapsed); + nowEl.textContent = fmtClockNow(t); + } + } + var statusEl = document.getElementById('clock-status'); + if (statusEl) { + statusEl.textContent = c.status_label || (c.in_session ? '交易时间段' : '非交易时间段'); + statusEl.className = c.in_session ? 'text-profit' : 'text-muted'; + } + var detail = document.getElementById('clock-detail'); + if (!detail) return; + var html = ''; + if (!c.in_session && c.secs_to_open != null) { + html = ' · 下次' + (c.next_open_label || '开盘') + ' ' + (c.next_open_at || '') + + ' · 距开盘 ' + fmtCountdown(c.secs_to_open - elapsed) + ''; + } else if (c.in_session) { + if (c.secs_to_break != null) { + html += ' · 距' + (c.break_label || '休盘') + ' ' + + fmtCountdown(c.secs_to_break - elapsed) + ''; + } + if (c.secs_to_close != null) { + html += ' · 距' + (c.close_label || '收盘') + ' ' + + fmtCountdown(c.secs_to_close - elapsed) + ''; + } + } + detail.innerHTML = html; + } + + function applySessionClock(clock) { + if (!clock) return; + sessionClockBase = { clock: clock, at: Date.now() }; + tickSessionClock(); + if (sessionClockTickTimer) clearInterval(sessionClockTickTimer); + sessionClockTickTimer = setInterval(tickSessionClock, 1000); + } function setPriceType(type) { priceType = type === 'market' ? 'market' : 'limit'; @@ -633,6 +706,7 @@ lotsCalc.placeholder = data.error || '无法计算'; } lastPreviewMetrics = null; + lastSizingInfo = null; updateRRDisplay(); checkLotsLimit(); return; @@ -641,12 +715,14 @@ if (lotsInput) lotsInput.value = String(data.lots || ''); lotsCalc.placeholder = isAmountMode() ? '填写止损后自动计算' : '—'; lastPreviewMetrics = data.metrics || null; + lastSizingInfo = data.sizing_info || null; updateRRDisplay(); checkLotsLimit(); scheduleQuote(); }).catch(function () { if (isAmountMode()) lotsCalc.placeholder = '计算失败'; lastPreviewMetrics = null; + lastSizingInfo = null; updateRRDisplay(); }); } @@ -1631,6 +1707,10 @@ } function cleanupTradePage() { + if (sessionClockTickTimer) { + clearInterval(sessionClockTickTimer); + sessionClockTickTimer = null; + } if (positionSource) { positionSource.close(); positionSource = null; diff --git a/templates/ai_messages.html b/templates/ai_messages.html new file mode 100644 index 0000000..f9705b8 --- /dev/null +++ b/templates/ai_messages.html @@ -0,0 +1,39 @@ +{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} +{% extends "base.html" %} +{% block title %}AI 消息 - 国内期货 · 交易复盘系统{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} +
+

AI 分析 · 使用说明

+
+
+ 使用说明 +
+
    +
  • 系统设置 →「AI 分析 · 使用说明」中配置 Ollama 或 OpenAI 并启用
  • +
  • 开仓 / 平仓:成交后自动生成简要复盘(本页下方列表)
  • +
  • 日终报告:每个交易日按设定时刻汇总当日盈亏与持仓
  • +
  • 配置企业微信后,日终报告摘要会同步推送到群
  • +
+
+
+ +
+ {% for m in messages %} +
+
+ {{ m.kind }} + +
+ {% if m.title %}

{{ m.title }}

{% endif %} +
{{ m.content }}
+
+ {% else %} +

暂无 AI 消息。开启 AI 并完成一笔交易,或等待日终报告后此处会显示分析。

+ {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 82d6e8a..51c60e1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -60,6 +60,7 @@ {% if nav_items.strategy %}策略交易{% endif %} {% if nav_items.plans %}开单计划{% endif %} 关键位监控 + {% if nav_items.ai %}AI 分析{% endif %} {% if nav_items.market %}行情K线{% endif %} 交易记录与复盘 统计分析 diff --git a/templates/keys.html b/templates/keys.html index 55f84f4..50e7dc9 100644 --- a/templates/keys.html +++ b/templates/keys.html @@ -1,12 +1,34 @@ {# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #} {% extends "base.html" %} {% block title %}关键位监控 - 国内期货 · 交易复盘系统{% endblock %} +{% block extra_css %} + +{% endblock %} {% block content %}

新增监控

-
+
+ 规则说明 +
+

箱体突破 / 收敛突破(自动单)

+
    +
  • 触发:5 分钟 K 线收盘收在上沿或下沿之外
  • +
  • 顺势:上破做多、下破做空;反转:上破做空、下破做多
  • +
  • 自动市价开仓;止损 = 突破 K 线极值 ± 2 跳
  • +
  • 盈亏比默认 2(可改);开启移动保本时默认 3,达目标价自动止盈
  • +
  • 成交后进入「下单监控」持仓,来源备注为监控类型
  • +
+

关键支阻区(仅提醒)

+
    +
  • 上沿 = 阻力,下沿 = 支撑,合并为一个区间
  • +
  • 5m 收盘突破上沿或跌破下沿 → 微信推送(最多 3 次,间隔约 5 分钟)
  • +
  • 推送完毕后自动结案,不参与自动开仓
  • +
+
+
+
@@ -17,22 +39,31 @@
- - - - - +
+ +
-
- - - +
+ + +
+ +
+
+
+ +
@@ -41,13 +72,22 @@
{{ k.symbol_name or k.symbol }} {{ k.monitor_type }} - {{ '多' if k.direction == 'long' else '空' }} + {% if k.monitor_type in ('箱体突破', '收敛突破') %} + {{ k.trade_mode or '顺势' }} + {% endif %} + {% if k.trailing_be %} + 移动保本 + {% endif %}
现价:-- 距上-- 距下--
-
上{{ k.upper }} 下{{ k.lower }}
+
上沿 {{ k.upper }} · 下沿 {{ k.lower }} + {% if k.monitor_type in ('箱体突破', '收敛突破') %} + · 盈亏比 {{ k.risk_reward or 2 }} + {% endif %} +
{% else %} @@ -61,13 +101,21 @@

监控历史

- + {% for k in history %} - + diff --git a/templates/settings.html b/templates/settings.html index ed4005e..dae2344 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -85,6 +85,42 @@ .settings-admin-row .settings-compact-card > form .btn-primary{padding:.42rem .7rem;font-size:.78rem} .settings-admin-row .settings-password-form{grid-template-columns:1fr;gap:.45rem .55rem} .settings-admin-row .settings-password-form input{padding:.4rem .55rem;font-size:.78rem} +.settings-ai-full{margin-bottom:1.25rem} +.settings-ai-full .settings-fold.card{min-height:auto;height:auto} +.settings-ai-usage{margin-bottom:1rem;font-size:.84rem;color:var(--text-muted)} +.settings-ai-usage summary{cursor:pointer;color:var(--accent);font-weight:600;margin-bottom:.4rem} +.settings-ai-usage-body ul{margin:.25rem 0 0 1.1rem;padding:0;line-height:1.55} +.settings-ai-usage-body li{margin:.2rem 0} +.settings-ai-form{max-width:none} +.settings-ai-form input[type="checkbox"]{width:auto;flex-shrink:0;margin:0} +.settings-ai-form label.check-inline{ + display:inline-flex;align-items:center;gap:.45rem;width:auto; + cursor:pointer;margin-bottom:0;font-size:.85rem;color:var(--text-muted) +} +.settings-ai-cards-row{ + display:grid;grid-template-columns:1fr 1fr;gap:.85rem;margin-bottom:.85rem +} +.settings-ai-card{ + border:1px solid var(--border);border-radius:10px; + padding:.85rem 1rem;background:var(--card-inner) +} +.settings-ai-card.is-active{border-color:var(--accent);box-shadow:0 0 0 1px rgba(56,189,248,.25)} +.settings-ai-card-head{ + display:flex;align-items:center;justify-content:space-between;gap:.5rem; + margin-bottom:.65rem;font-size:.92rem;font-weight:600;color:var(--text-title) +} +.settings-ai-card .field{margin-bottom:.55rem} +.settings-ai-card .field:last-child{margin-bottom:0} +.settings-ai-daily{ + border:1px solid var(--border);border-radius:10px; + padding:.85rem 1rem;background:var(--card-inner);margin-bottom:.85rem +} +.settings-ai-daily-grid{display:grid;grid-template-columns:auto 1fr 1fr;gap:.65rem .75rem;align-items:end} +.settings-ai-daily-grid .check-inline{align-self:center} +@media (max-width:768px){ + .settings-ai-cards-row{grid-template-columns:1fr} + .settings-ai-daily-grid{grid-template-columns:1fr} +} .settings-page .settings-fold.card{padding:0;overflow:hidden} .settings-page .split-grid .settings-fold.card{min-height:auto;height:auto} .settings-fold-head{ @@ -180,6 +216,10 @@ +
+ + +
@@ -191,8 +231,12 @@

- 保证金上限用于开仓校验与品种最大手数估算(默认 30%)。移动保本:达 1R 后止损移至开仓价 ± N 跳。 - 挂单超时:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置。 + 开仓保证金上限用于开仓校验与品种最大手数估算(默认 30%)。固定金额计仓时先按止损算手数,再按保证金上限收紧。 + 滚仓保证金上限为滚仓后总持仓占用上限(默认 50%,可在下方修改)。 + 移动保本:达 1R 后止损移至开仓价 ± N 跳。 + 挂单超时:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。 + {{ small_account_margin_rec.label }}。 + CTP 账号与前置在下方「CTP 连接」中配置。

{% endcall %} @@ -369,6 +413,91 @@ {% endcall %} +
+ {% call settings_card('ai', 'AI 分析 · 使用说明') %} +
+ 使用说明 +
+
    +
  • 触发时机:开仓成交、平仓入账、日终报告(默认日盘 15:05,可在下方修改)
  • +
  • Ollama:服务器需能访问填写的地址(如本机 127.0.0.1:11434
  • +
  • OpenAI 兼容:支持 DeepSeek、硅基流动等 OpenAI 格式 API
  • +
  • 输出位置:分析写入导航「AI 消息」;若已配置企业微信,日终报告会同步推送摘要
  • +
  • 不替代交易:AI 仅作复盘与风险提示,下单仍以系统规则与 CTP 为准
  • +
+
+
+
+ +
+ +
+
+ + +
+
+
+
+ 本地 Ollama + {% if ai_provider == 'ollama' %}当前{% endif %} +
+
+ + +
+
+ + +
+
+
+
+ OpenAI 兼容 + {% if ai_provider == 'openai' %}当前{% endif %} +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

日终报告(国内期货日盘收盘后推送一次)

+
+ +
+ + +
+
+ + +
+
+
+ + + {% endcall %} +
+
{% call settings_card('backup', '数据备份与恢复', 'settings-compact-card') %}

diff --git a/templates/trade.html b/templates/trade.html index a547999..7294237 100644 --- a/templates/trade.html +++ b/templates/trade.html @@ -17,6 +17,23 @@ {% if ctp_account.available is defined and ctp_status.connected %} 可用 {{ '%.2f'|format(ctp_account.available) }} {% endif %} + + · {{ session_clock.now_time }} + · {{ session_clock.status_label }} + + {% if not session_clock.in_session and session_clock.next_open_at %} + · 下次{{ session_clock.next_open_label }} {{ session_clock.next_open_at }} + · 距开盘 {{ session_clock.countdown_open }} + {% elif session_clock.in_session %} + {% if session_clock.countdown_break %} + · 距{{ session_clock.break_label }} {{ session_clock.countdown_break }} + {% endif %} + {% if session_clock.countdown_close %} + · 距{{ session_clock.close_label }} {{ session_clock.countdown_close }} + {% endif %} + {% endif %} + +

品种类型方向上沿下沿归档
品种类型模式上沿下沿归档
{{ k.symbol_name or k.symbol }} {{ k.monitor_type }}{{ '多' if k.direction == 'long' else '空' }} + {% if k.monitor_type in ('箱体突破', '收敛突破') %} + {{ k.trade_mode or '顺势' }}{% if k.trailing_be %} · 移动保本{% endif %} + {% elif k.monitor_type in ('关键阻力位', '关键支撑位') %} + 支阻区 + {% else %} + 提醒 + {% endif %} + {{ k.upper }} {{ k.lower }} {{ k.archived_at[:16] if k.archived_at else '' }}