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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 10:36:56 +08:00
parent 0109b59f27
commit 840e88daad
33 changed files with 2514 additions and 143 deletions
+102
View File
@@ -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)
+68
View File
@@ -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
+173
View File
@@ -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()
+137 -51
View File
@@ -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"),
)
+36
View File
@@ -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,
+30
View File
@@ -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:
+4 -4
View File
@@ -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`
---
+64
View File
@@ -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) 功能总览。
+2
View File
@@ -34,6 +34,8 @@
- 用于开仓纪律与仓位限制:按保证金上限计算最大手数,仅展示当前权益下可开的品种
- 每日后台刷新列表(`/api/recommend/stream`
- 最大手数 = floor(权益 × 保证金上限 ÷ 1 手保证金)
- **1 手保证金**:**CTP 已连接** 时优先读取柜台合约的 `long_margin_ratio` / `short_margin_ratio` 与乘数计算(表格标注「柜台」);未连接或合约信息暂不可用时,才用本地参考保证金率估算
- 开仓前校验、固定金额计仓、保证金占用比例检查均与上述规则一致,避免交易所上调保证金后仍按旧比例显示可开手数
- 展示近一周日线走势、跳空、昨日成交量(手)、成交额
- 可按 **行业** 筛选,支持多字段排序
- **夜盘时段**:品种下拉与可开仓表仅显示有夜盘交易的品种,并带「夜盘」标记
+305 -13
View File
@@ -17,7 +17,7 @@ from flask import flash, jsonify, redirect, render_template, request, url_for, R
from contract_specs import calc_position_metrics, get_contract_spec
from fee_specs import calc_fee_breakdown
from kline_stream import sse_format
from market_sessions import is_night_trading_session, is_trading_session
from market_sessions import is_night_trading_session, is_trading_session, trading_session_clock
from position_sizing import (
MODE_AMOUNT,
MODE_FIXED,
@@ -25,12 +25,14 @@ from position_sizing import (
calc_lots_by_amount,
calc_lots_by_risk,
calc_margin_usage_pct,
cap_lots_for_margin_budget,
calc_order_tick_metrics,
normalize_sizing_mode,
)
from product_recommend import (
assert_product_allowed_for_capital,
should_apply_small_account_scope,
small_account_margin_recommendations,
small_account_scope_hint,
SMALL_ACCOUNT_SCOPE_LABEL,
)
@@ -44,6 +46,7 @@ from ctp_settings import is_ctp_auto_connect_enabled
from ctp_reconnect import start_ctp_reconnect_worker
from ctp_premarket_connect import start_ctp_premarket_connect_worker
from ctp_fee_worker import start_ctp_fee_worker
from pending_order_worker import start_pending_order_worker
from order_pending import (
cancel_pending_monitor,
pending_auto_cancel_remaining,
@@ -71,7 +74,7 @@ from risk.account_risk_lib import (
trading_day_label,
)
from strategy.strategy_db import init_strategy_tables
from strategy.strategy_roll_lib import preview_roll
from strategy.strategy_roll_lib import avg_entry_after_add, preview_roll
from strategy.strategy_snapshot_lib import list_snapshots, save_snapshot
from strategy.strategy_trend_lib import (
compute_trend_plan_futures,
@@ -93,6 +96,7 @@ from trading_context import (
get_pending_order_timeout_min,
get_pending_order_timeout_sec,
get_recommend_capital,
get_roll_max_margin_pct,
get_risk_percent,
get_sizing_mode,
get_trailing_be_tick_buffer,
@@ -1015,7 +1019,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
margin = ctp_margin
codes = ths_to_codes(sym)
tick = calc_order_tick_metrics(sym, lots, entry)
tick = calc_order_tick_metrics(sym, lots, entry, trading_mode=mode)
sl = float(mon["stop_loss"]) if mon and mon.get("stop_loss") is not None else None
tp = float(mon["take_profit"]) if mon and mon.get("take_profit") is not None else None
holding = _holding_duration(open_time, now_iso) if open_time else ""
@@ -1282,8 +1286,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"trailing_r_locked": 0,
}
def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> None:
reconcile_pending_orders(
def _reconcile_pending(conn, mode: str, *, capital: float = 0.0) -> dict[str, int]:
return reconcile_pending_orders(
conn,
mode,
match_symbol_fn=_match_ctp_symbol,
@@ -1512,6 +1516,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
"risk_status": risk,
"trading_session": is_trading_session(),
"night_session": is_night_trading_session(),
"session_clock": trading_session_clock(),
"pending_order_timeout_min": get_pending_order_timeout_min(get_setting),
"sync_state": trading_state.sync_state,
"sync_label": trading_state.sync_label(),
@@ -1636,6 +1641,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if rec_cache.get("needs_refresh"):
_schedule_recommend_refresh()
ctp_connected = is_ctp_connected(get_setting)
margin_rec = small_account_margin_recommendations()
return render_template(
"trade.html",
trading_mode=mode,
@@ -1663,6 +1669,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
capital, ctp_connected=ctp_connected,
),
small_account_scope_hint=small_account_scope_hint(ctp_connected=ctp_connected),
small_account_margin_rec=margin_rec if should_apply_small_account_scope(
capital, ctp_connected=ctp_connected,
) else None,
session_clock=trading_session_clock(),
roll_max_margin_pct=get_roll_max_margin_pct(get_setting),
product_categories=PRODUCT_CATEGORIES,
)
finally:
@@ -2060,11 +2071,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
lots_f = max(1, int(float(lots)))
except (TypeError, ValueError):
lots_f = 1
metrics = calc_order_tick_metrics(sym, lots_f, price)
mode = get_trading_mode(get_setting)
metrics = calc_order_tick_metrics(sym, lots_f, price, trading_mode=mode)
spec = get_contract_spec(sym)
name = codes.get("name", sym) if codes else sym
pos_long = pos_short = 0
mode = get_trading_mode(get_setting)
ctp_st = ctp_status(mode)
if ctp_st.get("connected"):
for p in _ctp_positions(mode):
@@ -2111,10 +2122,12 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
conn.close()
sizing = get_sizing_mode(get_setting)
margin_pct = get_max_margin_pct(get_setting)
sizing_info = {}
if sizing == MODE_AMOUNT:
lots, err = calc_lots_by_amount(
lots, err, sizing_info = calc_lots_by_amount(
entry, sl, direction, get_fixed_amount(get_setting), sym,
capital=capital, max_margin_pct=margin_pct,
trading_mode=get_trading_mode(get_setting),
)
if err:
return jsonify({"ok": False, "error": err}), 400
@@ -2126,8 +2139,14 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
except (TypeError, ValueError):
lots = 1
metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym)
tick = calc_order_tick_metrics(sym, lots, entry)
return jsonify({"ok": True, "lots": lots, "sizing_mode": sizing, "metrics": metrics, "tick": tick, "capital": capital})
tick = calc_order_tick_metrics(
sym, lots, entry, direction=direction, trading_mode=get_trading_mode(get_setting),
)
return jsonify({
"ok": True, "lots": lots, "sizing_mode": sizing,
"metrics": metrics, "tick": tick, "capital": capital,
"sizing_info": sizing_info,
})
@app.route("/api/trade/order", methods=["POST"])
@login_required
@@ -2184,9 +2203,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if sl <= 0:
conn.close()
return jsonify({"ok": False, "error": "固定金额模式须填写止损价"}), 400
lots_calc, err = calc_lots_by_amount(
lots_calc, err, _sizing_info = calc_lots_by_amount(
price, sl, direction, get_fixed_amount(get_setting), sym,
capital=_capital(conn), max_margin_pct=get_max_margin_pct(get_setting),
trading_mode=mode,
)
if err:
conn.close()
@@ -2201,6 +2221,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
extra_symbol=sym if offset.startswith("open") else "",
extra_lots=lots if offset.startswith("open") else 0,
extra_price=price if offset.startswith("open") else 0,
extra_direction=direction if offset.startswith("open") else "long",
trading_mode=mode,
)
if offset.startswith("open") and usage > margin_pct:
conn.close()
@@ -2313,7 +2335,52 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
)
)
conn.commit()
send_wechat_msg(f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}")
if offset.startswith("open"):
from db_conn import DB_PATH
from ai_worker import schedule_ai_event_analysis
from trade_notify import notify_manual_open_filled
if filled:
open_sl = float(d.get("stop_loss") or 0) if d.get("stop_loss") else None
open_tp = None if d.get("trailing_be") else d.get("take_profit")
if open_tp is not None:
try:
open_tp = float(open_tp)
except (TypeError, ValueError):
open_tp = None
codes = ths_to_codes(sym) or {}
if open_sl and open_sl > 0:
notify_manual_open_filled(
send_wechat=send_wechat_msg,
get_setting=get_setting,
mode_label=trading_mode_label(get_setting),
sym=sym,
symbol_name=codes.get("name") or sym,
direction=direction,
entry=price,
sl=open_sl,
tp=open_tp,
lots=lots,
capital=_capital(conn),
order_id=str(result.get("order_id") or ""),
trailing_be=bool(d.get("trailing_be")),
be_tick_buffer=get_trailing_be_tick_buffer(get_setting),
schedule_ai_fn=schedule_ai_event_analysis,
db_path=DB_PATH,
)
else:
send_wechat_msg(
f"{trading_mode_label(get_setting)} 开仓 {sym} {direction} {lots}手 @{price}"
)
elif not filled:
send_wechat_msg(
f"委托已提交 · {sym} {direction} {lots}手挂单中"
f"{get_pending_order_timeout_sec(get_setting) // 60} 分钟未成交自动撤单)"
)
elif not offset.startswith("open"):
send_wechat_msg(
f"{trading_mode_label(get_setting)} {offset} {sym} {direction} {lots}手 @{price}"
)
conn.close()
_push_position_snapshot_async()
return jsonify({
@@ -2587,6 +2654,57 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
send_wechat_msg(f"趋势回调首仓 {sym} {plan['first_lots']}")
return jsonify({"ok": True, "plan_id": plan_id, "plan": plan})
def _apply_roll_margin_cap(
preview: dict,
*,
conn,
mode: str,
mon: dict,
capital: float,
) -> tuple[dict, Optional[str]]:
"""滚仓:风险算手数后再按滚仓保证金上限收紧。"""
if not preview:
return preview, "预览无效"
sym = mon["symbol"]
direction = (mon.get("direction") or "long").strip().lower()
price = float(preview.get("add_price") or 0)
qty_existing = float(mon.get("lots") or 0)
entry_existing = float(mon.get("entry_price") or 0)
mult = int(get_contract_spec(sym).get("mult") or 1)
roll_pct = get_roll_max_margin_pct(get_setting)
add_lots = int(preview.get("add_lots") or 0)
positions = _ctp_positions(mode, refresh_if_empty=False)
capped, usage = cap_lots_for_margin_budget(
positions, capital, sym, direction, price, add_lots, roll_pct, trading_mode=mode,
)
if capped < 1:
return preview, f"滚仓后保证金占用将超过上限 {roll_pct:g}%"
out = dict(preview)
if capped < add_lots:
out["add_lots"] = capped
out["qty_after"] = int(qty_existing + capped)
out["avg_entry_after"] = round(
avg_entry_after_add(qty_existing, entry_existing, capped, price), 4,
)
sl = float(out.get("new_stop_loss") or 0)
tp = float(out.get("initial_take_profit") or 0)
new_avg = float(out["avg_entry_after"])
new_qty = float(out["qty_after"])
if direction == "long":
out["loss_at_sl"] = round((new_avg - sl) * new_qty * mult, 2)
out["reward_at_tp"] = round((tp - new_avg) * new_qty * mult, 2)
else:
out["loss_at_sl"] = round((sl - new_avg) * new_qty * mult, 2)
out["reward_at_tp"] = round((new_avg - tp) * new_qty * mult, 2)
out["margin_capped"] = True
out["margin_cap_note"] = (
f"按滚仓保证金上限 {roll_pct:g}% 收紧:"
f"风险算 {add_lots} 手 → 实际 {capped}"
)
out["margin_usage_pct"] = round(usage, 2)
out["roll_max_margin_pct"] = roll_pct
return out, None
@app.route("/api/strategy/roll/preview", methods=["POST"])
@login_required
def api_roll_preview():
@@ -2618,6 +2736,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
)
if err:
return jsonify({"ok": False, "error": err}), 400
preview, merr = _apply_roll_margin_cap(
preview, conn=conn, mode=get_trading_mode(get_setting), mon=dict(mon), capital=capital,
)
if merr:
return jsonify({"ok": False, "error": merr}), 400
return jsonify({"ok": True, "preview": preview})
@app.route("/api/strategy/roll/execute", methods=["POST"])
@@ -2637,6 +2760,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
sym = mon["symbol"]
spec = get_contract_spec(sym)
capital = _capital(conn)
mode = get_trading_mode(get_setting)
prev, err = preview_roll(
direction=mon["direction"],
symbol=sym,
@@ -2653,8 +2777,13 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
if err:
conn.close()
return jsonify({"ok": False, "error": err}), 400
prev, merr = _apply_roll_margin_cap(
prev, conn=conn, mode=mode, mon=dict(mon), capital=capital,
)
if merr:
conn.close()
return jsonify({"ok": False, "error": merr}), 400
price = float(prev["add_price"])
mode = get_trading_mode(get_setting)
try:
execute_order(
conn, mode=mode, offset="open", symbol=sym,
@@ -2801,6 +2930,153 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
app._check_trend_plans = check_trend_plans
def _execute_key_breakout(conn, row, bar, break_side):
"""关键位箱体/收敛:5m 收盘突破后自动市价开仓。"""
from key_monitor_lib import (
calc_breakout_sl_tp,
format_auto_breakout_msg,
normalize_monitor_type,
resolve_order_direction,
)
sym = (row.get("symbol") or "").strip()
bar_time = str(bar.get("time") or "")[:19]
monitor_type = normalize_monitor_type(row.get("monitor_type") or "")
trade_mode = row.get("trade_mode") or "顺势"
direction = resolve_order_direction(break_side, trade_mode)
trailing_be = int(row.get("trailing_be") or 0)
try:
rr = float(row.get("risk_reward") or (3 if trailing_be else 2))
except (TypeError, ValueError):
rr = 3.0 if trailing_be else 2.0
if trailing_be and rr < 3:
rr = 3.0
def _notify(ok: bool, detail: str, **kw):
send_wechat_msg(format_auto_breakout_msg(
row,
break_side=break_side,
direction=direction,
entry=kw.get("entry", 0),
sl=kw.get("sl", 0),
tp=kw.get("tp", 0),
lots=kw.get("lots", 0),
bar_time=bar_time,
ok=ok,
detail=detail,
))
try:
init_strategy_tables(conn)
mode = get_trading_mode(get_setting)
if not ctp_status(mode).get("connected"):
_notify(False, "CTP 未连接")
return False, "CTP 未连接"
if not is_trading_session():
_notify(False, "非交易时段")
return False, "非交易时段"
try:
entry = float(bar.get("close") or 0)
except (TypeError, ValueError):
_notify(False, "K 线收盘价无效")
return False, "K 线收盘价无效"
if entry <= 0:
_notify(False, "K 线收盘价无效")
return False, "K 线收盘价无效"
sl, tp = calc_breakout_sl_tp(
sym=sym, direction=direction, entry=entry, bar=bar, risk_reward=rr,
)
err = assert_can_open(conn, active_count=_effective_active_position_count(conn, mode))
if err:
_notify(False, err, entry=entry, sl=sl, tp=tp, lots=0)
return False, err
capital = _capital(conn)
lots, lot_err = calc_lots_by_risk(
entry, sl, direction, capital, get_risk_percent(get_setting), sym,
max_margin_pct=get_max_margin_pct(get_setting), trading_mode=mode,
)
if lot_err or not lots:
msg = lot_err or "手数计算失败"
_notify(False, msg, entry=entry, sl=sl, tp=tp, lots=0)
return False, msg
result = execute_order(
conn,
mode=mode,
offset="open",
symbol=sym,
direction=direction,
lots=lots,
price=entry,
settings=_settings_dict(),
order_type="market",
)
open_ts = bar_time.replace("T", " ") if bar_time else datetime.now().strftime("%Y-%m-%d %H:%M:%S")
vt_order_id = str(result.get("order_id") or "")
mid = _upsert_open_monitor(
conn,
sym=sym,
direction=direction,
lots=lots,
price=entry,
sl=sl,
tp=tp,
trailing_be=trailing_be,
open_time=open_ts,
monitor_type=monitor_type,
status="pending",
vt_order_id=vt_order_id or None,
order_price=entry,
)
_reconcile_pending(conn, mode, capital=capital)
st_row = conn.execute(
"SELECT status FROM trade_order_monitors WHERE id=?", (mid,),
).fetchone()
filled = st_row and (st_row["status"] or "").strip().lower() == "active"
rejected = st_row and (st_row["status"] or "").strip().lower() == "closed"
if rejected:
conn.commit()
_notify(False, "委托被柜台拒绝或撤销", entry=entry, sl=sl, tp=tp, lots=lots)
return False, "委托被拒绝"
if filled:
_sync_monitor_from_ctp(
conn, mid, sym, direction, mode, capital=capital,
)
conn.commit()
if filled:
from db_conn import DB_PATH
from ai_worker import schedule_ai_event_analysis
from trade_notify import notify_key_breakout_open
notify_key_breakout_open(
send_wechat=send_wechat_msg,
get_setting=get_setting,
mode_label=trading_mode_label(get_setting),
row=row,
break_side=break_side,
bar_time=bar_time,
direction=direction,
entry=entry,
sl=sl,
tp=tp,
lots=lots,
capital=capital,
order_id=vt_order_id,
schedule_ai_fn=schedule_ai_event_analysis,
db_path=DB_PATH,
)
_push_position_snapshot_async(fast=False)
return True, "已下单" if filled else "委托已提交"
except Exception as exc:
logger.warning("key breakout auto order: %s", exc)
_notify(False, str(exc))
return False, str(exc)
app._execute_key_breakout = _execute_key_breakout
@app.route("/settings/trading", methods=["POST"])
@login_required
def settings_trading_post():
@@ -2882,3 +3158,19 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
get_setting_fn=get_setting,
set_setting_fn=set_setting,
)
from ai_worker import start_ai_worker
start_ai_worker(
db_path=DB_PATH,
get_setting_fn=get_setting,
set_setting_fn=set_setting,
send_wechat_fn=send_wechat_msg,
)
start_pending_order_worker(
db_path=DB_PATH,
get_mode_fn=lambda: get_trading_mode(get_setting),
init_tables_fn=_init_tables,
get_capital_fn=_capital,
reconcile_fn=_reconcile_pending,
on_changed_fn=lambda: _push_position_snapshot_async(fast=False),
)
+367
View File
@@ -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)
+90
View File
@@ -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,
*,
+1
View File
@@ -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}
+82
View File
@@ -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()
+121 -22
View File
@@ -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,
)
+37 -3
View File
@@ -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
+16 -24
View File
@@ -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:
+34 -2
View File
@@ -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"
+12
View File
@@ -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)}
+13
View File
@@ -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}
+6 -1
View File
@@ -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}
+41 -3
View File
@@ -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);
})();
+17
View File
@@ -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;
+4
View File
@@ -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);
}
+80
View File
@@ -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 || '') +
' · 距开盘 <strong>' + fmtCountdown(c.secs_to_open - elapsed) + '</strong>';
} else if (c.in_session) {
if (c.secs_to_break != null) {
html += ' · 距' + (c.break_label || '休盘') + ' <strong>' +
fmtCountdown(c.secs_to_break - elapsed) + '</strong>';
}
if (c.secs_to_close != null) {
html += ' · 距' + (c.close_label || '收盘') + ' <strong>' +
fmtCountdown(c.secs_to_close - elapsed) + '</strong>';
}
}
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;
+39
View File
@@ -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 %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/ai_messages.css') }}">
{% endblock %}
{% block content %}
<div class="card ai-page">
<h2>AI 分析 · 使用说明</h2>
<div class="card-body">
<details class="ai-usage" open>
<summary>使用说明</summary>
<div class="ai-usage-body">
<ul>
<li><a href="{{ url_for('settings') }}">系统设置</a> →「AI 分析 · 使用说明」中配置 Ollama 或 OpenAI 并启用</li>
<li><strong>开仓 / 平仓</strong>:成交后自动生成简要复盘(本页下方列表)</li>
<li><strong>日终报告</strong>:每个交易日按设定时刻汇总当日盈亏与持仓</li>
<li>配置企业微信后,日终报告摘要会同步推送到群</li>
</ul>
</div>
</details>
<h3 class="ai-section-label">分析消息</h3>
<div class="ai-msg-list card-scroll">
{% for m in messages %}
<article class="ai-msg" data-kind="{{ m.kind }}">
<header class="ai-msg-head">
<span class="ai-msg-kind">{{ m.kind }}</span>
<time>{{ m.created_at }}</time>
</header>
{% if m.title %}<h3 class="ai-msg-title">{{ m.title }}</h3>{% endif %}
<pre class="ai-msg-body">{{ m.content }}</pre>
</article>
{% else %}
<p class="text-muted empty-hint">暂无 AI 消息。开启 AI 并完成一笔交易,或等待日终报告后此处会显示分析。</p>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
+1
View File
@@ -60,6 +60,7 @@
{% if nav_items.strategy %}<a href="{{ url_for('strategy_page') }}" class="{% if request.endpoint in ('strategy_page', 'strategy_records_page') %}active{% endif %}">策略交易</a>{% endif %}
{% if nav_items.plans %}<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>{% endif %}
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
{% if nav_items.ai %}<a href="{{ url_for('ai_messages_page') }}" class="{% if request.endpoint == 'ai_messages_page' %}active{% endif %}">AI 分析</a>{% endif %}
{% if nav_items.market %}<a href="{{ url_for('market_page') }}" class="{% if request.endpoint == 'market_page' %}active{% endif %}">行情K线</a>{% endif %}
<a href="{{ url_for('records') }}" class="{% if request.endpoint in ('records', 'trades') %}active{% endif %}">交易记录与复盘</a>
<a href="{{ url_for('stats') }}" class="{% if request.endpoint == 'stats' %}active{% endif %}">统计分析</a>
+65 -17
View File
@@ -1,12 +1,34 @@
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
{% extends "base.html" %}
{% block title %}关键位监控 - 国内期货 · 交易复盘系统{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/keys.css') }}">
{% endblock %}
{% block content %}
<div class="split-grid">
<div class="card">
<h2>新增监控</h2>
<div class="card-body">
<form action="{{ url_for('add_key') }}" method="post" class="form-compact">
<details class="key-rules" open>
<summary>规则说明</summary>
<div class="key-rules-body">
<p><strong>箱体突破 / 收敛突破(自动单)</strong></p>
<ul>
<li>触发:<strong>5 分钟 K 线收盘</strong>收在上沿或下沿之外</li>
<li>顺势:上破做多、下破做空;反转:上破做空、下破做多</li>
<li>自动<strong>市价开仓</strong>;止损 = 突破 K 线极值 ± 2 跳</li>
<li>盈亏比默认 2(可改);开启移动保本时默认 3,达目标价自动止盈</li>
<li>成交后进入「下单监控」持仓,来源备注为监控类型</li>
</ul>
<p><strong>关键支阻区(仅提醒)</strong></p>
<ul>
<li>上沿 = 阻力,下沿 = 支撑,合并为一个区间</li>
<li>5m 收盘突破上沿或跌破下沿 → 微信推送(最多 3 次,间隔约 5 分钟)</li>
<li>推送完毕后自动结案,<strong>不参与自动开仓</strong></li>
</ul>
</div>
</details>
<form action="{{ url_for('add_key') }}" method="post" class="form-compact" id="key-add-form">
<div class="form-line line-3">
<div class="symbol-wrap symbol-mains">
<input type="text" class="symbol-input" placeholder="主力合约" autocomplete="off" required>
@@ -17,22 +39,31 @@
<div class="symbol-dropdown"></div>
<div class="symbol-selected"></div>
</div>
<select name="type" required>
<select name="type" id="key-type" required>
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
<option value="关键阻力位">关键阻力位</option>
<option value="关键支撑位">关键支撑位</option>
</select>
<select name="direction" required>
<option value="">方向</option>
<option value="long">做多</option>
<option value="short">做空</option>
<option value="关键支阻区">关键支阻区</option>
</select>
<div id="key-trade-mode-wrap">
<select name="trade_mode" id="key-trade-mode">
<option value="顺势">顺势</option>
<option value="反转">反转</option>
</select>
</div>
</div>
<div class="form-line line-3">
<input name="upper" type="number" step="0.0001" placeholder="上沿/阻力" required>
<input name="lower" type="number" step="0.0001" placeholder="下沿/支撑" required>
<button type="submit" class="btn-primary">添加</button>
<div class="form-line line-3" id="key-row-prices">
<input name="upper" type="number" step="0.0001" placeholder="上沿阻力" required>
<input name="lower" type="number" step="0.0001" placeholder="下沿支撑" required>
<div id="key-rr-wrap">
<input name="risk_reward" id="key-rr" type="number" step="0.1" min="0.5" max="10" value="2" placeholder="盈亏比">
</div>
</div>
<div class="form-line line-key-actions" id="key-row-actions">
<label class="key-check" id="key-trailing-wrap">
<input type="checkbox" name="trailing_be" id="key-trailing" value="1">
<span class="key-check-text">移动保本(默认盈亏比 3,达 3R 止盈)</span>
</label>
<button type="submit" class="btn-primary key-submit-btn">添加</button>
</div>
</form>
<h3 class="section-label">监控列表</h3>
@@ -41,13 +72,22 @@
<div class="list-item key-item" data-key-id="{{ k.id }}" style="padding:.75rem;font-size:.85rem">
<div>
<strong>{{ k.symbol_name or k.symbol }}</strong> {{ k.monitor_type }}
<span class="badge dir">{{ '多' if k.direction == 'long' else '空' }}</span>
{% if k.monitor_type in ('箱体突破', '收敛突破') %}
<span class="badge planned">{{ k.trade_mode or '顺势' }}</span>
{% endif %}
{% if k.trailing_be %}
<span class="badge profit">移动保本</span>
{% endif %}
</div>
<div class="key-live">
<span class="live-price-line">现价:<span class="live-price">--</span></span>
<span class="live-dist">距上<span class="dist-up">--</span> 距下<span class="dist-down">--</span></span>
</div>
<div>上{{ k.upper }} {{ k.lower }}</div>
<div>沿 {{ k.upper }} · 下沿 {{ k.lower }}
{% if k.monitor_type in ('箱体突破', '收敛突破') %}
· 盈亏比 {{ k.risk_reward or 2 }}
{% endif %}
</div>
<a href="{{ url_for('del_key', pid=k.id) }}" class="btn-del" onclick="return confirm('移入历史?')"></a>
</div>
{% else %}
@@ -61,13 +101,21 @@
<h2>监控历史</h2>
<div class="card-body card-scroll">
<table>
<thead><tr><th>品种</th><th>类型</th><th>方向</th><th>上沿</th><th>下沿</th><th>归档</th></tr></thead>
<thead><tr><th>品种</th><th>类型</th><th>模式</th><th>上沿</th><th>下沿</th><th>归档</th></tr></thead>
<tbody>
{% for k in history %}
<tr>
<td>{{ k.symbol_name or k.symbol }}</td>
<td>{{ k.monitor_type }}</td>
<td><span class="badge dir">{{ '多' if k.direction == 'long' else '空' }}</span></td>
<td>
{% if k.monitor_type in ('箱体突破', '收敛突破') %}
{{ k.trade_mode or '顺势' }}{% if k.trailing_be %} · 移动保本{% endif %}
{% elif k.monitor_type in ('关键阻力位', '关键支撑位') %}
支阻区
{% else %}
提醒
{% endif %}
</td>
<td>{{ k.upper }}</td>
<td>{{ k.lower }}</td>
<td>{{ k.archived_at[:16] if k.archived_at else '' }}</td>
+131 -2
View File
@@ -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 @@
<label>保证金占用上限(%</label>
<input name="max_margin_pct" type="number" step="1" min="1" max="100" value="{{ max_margin_pct }}">
</div>
<div class="field">
<label>滚仓保证金占用上限(%</label>
<input name="roll_max_margin_pct" type="number" step="1" min="1" max="100" value="{{ roll_max_margin_pct }}">
</div>
<div class="field">
<label>移动保本缓冲(最小变动价位倍数)</label>
<input name="trailing_be_tick_buffer" type="number" step="1" min="1" max="20" value="{{ trailing_be_tick_buffer }}">
@@ -191,8 +231,12 @@
</div>
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
保证金上限用于开仓校验与品种最大手数估算(默认 30%)。<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。CTP 账号与前置在下方「CTP 连接」中配置
开仓保证金上限用于开仓校验与品种最大手数估算(默认 30%)。固定金额计仓时<strong>先按止损算手数,再按保证金上限收紧</strong>
滚仓保证金上限为滚仓后<strong>总持仓</strong>占用上限(默认 50%,可在下方修改)
<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。
<span class="text-muted">{{ small_account_margin_rec.label }}。</span>
CTP 账号与前置在下方「CTP 连接」中配置。
</p>
</form>
{% endcall %}
@@ -369,6 +413,91 @@
{% endcall %}
</div>
<div class="settings-ai-full">
{% call settings_card('ai', 'AI 分析 · 使用说明') %}
<details class="settings-ai-usage" open>
<summary>使用说明</summary>
<div class="settings-ai-usage-body">
<ul>
<li><strong>触发时机</strong>:开仓成交、平仓入账、日终报告(默认日盘 15:05,可在下方修改)</li>
<li><strong>Ollama</strong>:服务器需能访问填写的地址(如本机 <code>127.0.0.1:11434</code></li>
<li><strong>OpenAI 兼容</strong>:支持 DeepSeek、硅基流动等 OpenAI 格式 API</li>
<li><strong>输出位置</strong>:分析写入导航「AI 消息」;若已配置企业微信,日终报告会同步推送摘要</li>
<li><strong>不替代交易</strong>:AI 仅作复盘与风险提示,下单仍以系统规则与 CTP 为准</li>
</ul>
</div>
</details>
<form action="{{ url_for('settings') }}" method="post" class="settings-ai-form">
<input type="hidden" name="action" value="ai">
<div class="field" style="margin-bottom:.75rem">
<label class="check-inline">
<input type="checkbox" name="ai_enabled" value="1" {% if ai_enabled %}checked{% endif %}>
<span>启用 AI 分析</span>
</label>
</div>
<div class="field" style="margin-bottom:.85rem;max-width:280px">
<label>当前使用的提供商</label>
<select name="ai_provider" id="ai-provider-select">
<option value="ollama" {% if ai_provider == 'ollama' %}selected{% endif %}>本地 Ollama</option>
<option value="openai" {% if ai_provider == 'openai' %}selected{% endif %}>OpenAI 兼容 API</option>
</select>
</div>
<div class="settings-ai-cards-row">
<div class="settings-ai-card{% if ai_provider == 'ollama' %} is-active{% endif %}" data-ai-provider="ollama">
<div class="settings-ai-card-head">
<span>本地 Ollama</span>
{% if ai_provider == 'ollama' %}<span class="badge profit">当前</span>{% endif %}
</div>
<div class="field">
<label>接口地址</label>
<input name="ai_ollama_base_url" type="url" placeholder="http://127.0.0.1:11434" value="{{ ai_ollama_base_url }}">
</div>
<div class="field">
<label>模型名</label>
<input name="ai_ollama_model" type="text" placeholder="qwen2.5:7b" value="{{ ai_ollama_model }}">
</div>
</div>
<div class="settings-ai-card{% if ai_provider == 'openai' %} is-active{% endif %}" data-ai-provider="openai">
<div class="settings-ai-card-head">
<span>OpenAI 兼容</span>
{% if ai_provider == 'openai' %}<span class="badge profit">当前</span>{% endif %}
</div>
<div class="field">
<label>API Base URL</label>
<input name="ai_openai_base_url" type="url" placeholder="https://api.openai.com/v1" value="{{ ai_openai_base_url }}">
</div>
<div class="field">
<label>API Key</label>
<input name="ai_openai_api_key" type="password" placeholder="sk-..." value="{{ ai_openai_api_key }}" autocomplete="off">
</div>
<div class="field">
<label>模型名</label>
<input name="ai_openai_model" type="text" placeholder="gpt-4o-mini" value="{{ ai_openai_model }}">
</div>
</div>
</div>
<div class="settings-ai-daily">
<p class="hint" style="margin:0 0 .65rem">日终报告(国内期货日盘收盘后推送一次)</p>
<div class="settings-ai-daily-grid">
<label class="check-inline">
<input type="checkbox" name="ai_daily_report_enabled" value="1" {% if ai_daily_report_enabled %}checked{% endif %}>
<span>启用</span>
</label>
<div class="field" style="margin:0">
<label>报告时刻(时)</label>
<input name="ai_daily_report_hour" type="number" min="0" max="23" step="1" value="{{ ai_daily_report_hour }}">
</div>
<div class="field" style="margin:0">
<label>报告时刻(分)</label>
<input name="ai_daily_report_minute" type="number" min="0" max="59" step="1" value="{{ ai_daily_report_minute }}">
</div>
</div>
</div>
<button type="submit" class="btn-primary">保存 AI 配置</button>
</form>
{% endcall %}
</div>
<div class="split-grid settings-admin-row">
{% call settings_card('backup', '数据备份与恢复', 'settings-compact-card') %}
<p class="settings-backup-meta">
+20 -1
View File
@@ -17,6 +17,23 @@
{% if ctp_account.available is defined and ctp_status.connected %}
<span class="text-muted">可用 <strong id="avail-display">{{ '%.2f'|format(ctp_account.available) }}</strong></span>
{% endif %}
<span class="text-muted trade-session-clock" id="session-clock-wrap">
· <span id="clock-now">{{ session_clock.now_time }}</span>
· <span id="clock-status" class="{% if session_clock.in_session %}text-profit{% else %}text-muted{% endif %}">{{ session_clock.status_label }}</span>
<span id="clock-detail" class="session-clock-detail">
{% if not session_clock.in_session and session_clock.next_open_at %}
· 下次{{ session_clock.next_open_label }} {{ session_clock.next_open_at }}
· 距开盘 <strong id="clock-countdown-open">{{ session_clock.countdown_open }}</strong>
{% elif session_clock.in_session %}
{% if session_clock.countdown_break %}
· 距{{ session_clock.break_label }} <strong id="clock-countdown-break">{{ session_clock.countdown_break }}</strong>
{% endif %}
{% if session_clock.countdown_close %}
· 距{{ session_clock.close_label }} <strong id="clock-countdown-close">{{ session_clock.countdown_close }}</strong>
{% endif %}
{% endif %}
</span>
</span>
</div>
<div class="trade-top-bar-actions">
<button type="button" class="btn-primary btn-ctp-sm" id="btn-ctp-connect"
@@ -135,6 +152,7 @@
<p class="hint">最大手数 = floor(权益 × 保证金上限 <strong>{{ max_margin_pct }}%</strong> ÷ 1手保证金);当前权益 <strong class="text-accent" id="rec-capital">{{ '%.2f'|format(recommend_capital) }}</strong> 元。
{% if sizing_mode == 'fixed' %}仅显示最大手数 ≥ <strong>{{ fixed_lots }}</strong> 手的品种。{% endif %}
{% if small_account_scope %}<span class="text-muted">{{ small_account_scope_hint }}。</span>{% endif %}
{% if small_account_margin_rec %}<span class="text-muted">{{ small_account_margin_rec.label }}。</span>{% endif %}
{% if night_session %}<span class="text-muted">当前为夜盘时段,品种下拉与下表仅显示有夜盘品种;带「夜盘」标记。</span>{% elif not small_account_scope %}<span class="text-muted">有夜盘交易的品种带「夜盘」标记。</span>{% endif %}
保证金优先读取 CTP 柜台合约信息。
{% if recommend_updated_at %}<span class="text-muted">每日后台更新 · 最近 {{ recommend_updated_at }}</span>{% else %}<span class="text-muted" id="rec-updated">等待今日后台刷新…</span>{% endif %}
@@ -237,7 +255,8 @@
'fixed_amount': fixed_amount,
'product_categories': product_categories | default([]),
'recommend_rows': recommend_rows | default([]),
'ctp_auto_connect': ctp_auto_connect
'ctp_auto_connect': ctp_auto_connect,
'session_clock': session_clock
} | tojson }}</script>
<script src="{{ url_for('static', filename='js/trade.js') }}?v={{ asset_v }}"></script>
{% endblock %}
+225
View File
@@ -0,0 +1,225 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""交易事件推送:企业微信 + AI 分析。"""
from __future__ import annotations
from typing import Callable, Optional
from contract_specs import calc_position_metrics, get_contract_spec
from sl_tp_guard import monitor_source_label
from wechat_notify import format_close_done, format_key_open_success, format_open_success
def _risk_amount(capital: float, risk_percent: float) -> Optional[float]:
try:
return round(float(capital) * float(risk_percent) / 100.0, 2)
except (TypeError, ValueError):
return None
def notify_manual_open_filled(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
sym: str,
symbol_name: str,
direction: str,
entry: float,
sl: Optional[float],
tp: Optional[float],
lots: int,
capital: float,
order_id: str = "",
trailing_be: bool = False,
be_tick_buffer: int = 2,
schedule_ai_fn=None,
db_path: str = "",
) -> None:
if not sl:
return
spec = get_contract_spec(sym)
tick = float(spec.get("tick_size") or 1.0)
try:
rp = float(get_setting("risk_percent", "1") or 1)
except (TypeError, ValueError):
rp = 1.0
metrics = calc_position_metrics(direction, entry, sl, tp or entry, lots, entry, capital, sym)
msg = format_open_success(
symbol_name=symbol_name,
symbol=sym,
direction=direction,
mode_label=mode_label,
order_id=order_id,
entry=entry,
stop_loss=float(sl),
take_profit=float(tp) if tp else None,
lots=lots,
capital=capital,
margin=metrics.get("margin"),
margin_pct=metrics.get("position_pct"),
risk_percent=rp,
risk_amount=_risk_amount(capital, rp),
trailing_be=trailing_be,
be_tick_buffer=be_tick_buffer,
tick_size=tick,
source="期货下单",
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="open",
title=f"{symbol_name or sym} 开仓",
payload={
"symbol": sym,
"direction": direction,
"entry": entry,
"stop_loss": sl,
"take_profit": tp,
"lots": lots,
"capital": capital,
},
send_wechat_fn=None,
)
def notify_key_breakout_open(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
row: dict,
break_side: str,
bar_time: str,
direction: str,
entry: float,
sl: float,
tp: float,
lots: int,
capital: float,
order_id: str = "",
schedule_ai_fn=None,
db_path: str = "",
) -> None:
sym = row.get("symbol") or ""
name = row.get("symbol_name") or sym
trailing_be = bool(int(row.get("trailing_be") or 0))
try:
rp = float(get_setting("risk_percent", "1") or 1)
be_buf = int(float(get_setting("trailing_be_tick_buffer", "2") or 2))
except (TypeError, ValueError):
rp, be_buf = 1.0, 2
spec = get_contract_spec(sym)
tick = float(spec.get("tick_size") or 1.0)
metrics = calc_position_metrics(direction, entry, sl, tp, lots, entry, capital, sym)
msg = format_key_open_success(
symbol_name=name,
symbol=sym,
monitor_type=row.get("monitor_type") or "",
trade_mode=row.get("trade_mode") or "顺势",
bar_time=bar_time,
break_side=break_side,
direction=direction,
mode_label=mode_label,
order_id=order_id,
entry=entry,
stop_loss=sl,
take_profit=tp,
lots=lots,
capital=capital,
margin=metrics.get("margin"),
margin_pct=metrics.get("position_pct"),
risk_percent=rp,
risk_amount=_risk_amount(capital, rp),
trailing_be=trailing_be,
be_tick_buffer=be_buf,
tick_size=tick,
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="key_open",
title=f"{name} 关键位开仓",
payload={
"monitor_type": row.get("monitor_type"),
"trade_mode": row.get("trade_mode"),
"break_side": break_side,
"entry": entry,
"stop_loss": sl,
"take_profit": tp,
"lots": lots,
},
)
def notify_trade_log_close(
*,
send_wechat: Callable[[str], None],
get_setting: Callable[[str, str], str],
mode_label: str,
capital: float,
sym: str,
symbol_name: str,
direction: str,
entry: float,
close_price: float,
sl: Optional[float],
tp: Optional[float],
lots: float,
pnl_net: float,
equity_after: Optional[float],
holding_minutes: int,
result: str,
monitor_type: str = "",
schedule_ai_fn=None,
db_path: str = "",
) -> None:
src = monitor_source_label(monitor_type) if monitor_type else "期货下单"
note = ""
if tp and sl:
if direction == "long":
if close_price > tp or close_price < sl:
note = "成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)"
else:
if close_price < tp or close_price > sl:
note = "成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)"
msg = format_close_done(
symbol_name=symbol_name,
symbol=sym,
mode_label=mode_label,
direction=direction,
result=result,
pnl_net=pnl_net,
equity_after=equity_after,
capital=capital,
entry=entry,
close_price=close_price,
stop_loss=sl,
take_profit=tp,
lots=lots,
holding_minutes=holding_minutes,
note=note,
)
send_wechat(msg)
if schedule_ai_fn and db_path:
schedule_ai_fn(
db_path=db_path,
get_setting_fn=get_setting,
kind="close",
title=f"{symbol_name or sym} 平仓",
payload={
"source": src,
"result": result,
"pnl_net": pnl_net,
"entry": entry,
"close_price": close_price,
"lots": lots,
},
)
+8
View File
@@ -51,6 +51,14 @@ def get_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
return 30.0
def get_roll_max_margin_pct(get_setting: Callable[[str, str], str]) -> float:
"""滚仓后总保证金占权益上限(%),默认 50。"""
try:
return max(1.0, min(100.0, float(get_setting("roll_max_margin_pct", "50") or 50)))
except (TypeError, ValueError):
return 50.0
def get_trailing_be_tick_buffer(get_setting: Callable[[str, str], str]) -> int:
"""移动保本:止损移至开仓价 ± N 个最小变动价位(默认 2)。"""
try:
+183
View File
@@ -0,0 +1,183 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""企业微信推送:开仓 / 平仓 / 关键位 结构化消息。"""
from __future__ import annotations
from typing import Optional
def _dir_label(direction: str) -> str:
d = (direction or "long").strip().lower()
return "多头(long" if d == "long" else "空头(short"
def _dir_emoji(direction: str) -> str:
d = (direction or "long").strip().lower()
return "📈" if d == "long" else "📉"
def fmt_holding(minutes: int) -> str:
m = max(0, int(minutes or 0))
if m >= 1440:
return f"{m // 1440}{m % 1440 // 60}小时{m % 60}分钟"
if m >= 60:
return f"{m // 60}小时{m % 60}分钟"
return f"{m}分钟"
def calc_rr(entry: float, sl: float, tp: float, direction: str) -> Optional[float]:
try:
entry_f, sl_f, tp_f = float(entry), float(sl), float(tp)
except (TypeError, ValueError):
return None
risk = abs(entry_f - sl_f)
if risk <= 0:
return None
reward = (tp_f - entry_f) if direction == "long" else (entry_f - tp_f)
if reward <= 0:
return None
return round(reward / risk, 2)
def format_open_success(
*,
symbol_name: str,
symbol: str,
direction: str,
mode_label: str,
order_id: str = "",
entry: float,
stop_loss: float,
take_profit: Optional[float],
lots: int,
capital: float,
margin: Optional[float],
margin_pct: Optional[float],
risk_percent: float,
risk_amount: Optional[float],
trailing_be: bool = False,
be_tick_buffer: int = 2,
tick_size: float = 1.0,
source: str = "期货下单",
extra_lines: Optional[list[str]] = None,
) -> str:
"""正常 / 关键位开仓成功推送。"""
name = symbol_name or symbol
emoji = _dir_emoji(direction)
rr = calc_rr(entry, stop_loss, take_profit, direction) if take_profit else None
lines = [
f"{emoji} {name} 开仓成功",
f"💼 账户:{mode_label}",
"",
"🧾 订单基础信息",
f"📌 来源:{source}",
]
if order_id:
lines.append(f"🔖 委托号:{order_id}")
lines.extend([
f"📈 方向:{_dir_label(direction)}",
f"⚠ 单笔风控:{risk_percent:g}%"
+ (f"{risk_amount:.2f}" if risk_amount is not None else ""),
"",
"📊 仓位配置",
f"账户权益:{capital:.2f}",
f"开仓手数:{lots}",
])
if margin is not None:
lines.append(f"占用保证金:{margin:.2f}")
if margin_pct is not None:
lines.append(f"仓位占比:{margin_pct:.2f}%")
lines.extend(["", "🎯 价位 & 盈亏比", f"开仓价:{entry:g}", f"止损价:{stop_loss:g}"])
if take_profit is not None:
lines.append(f"止盈价:{take_profit:g}")
if rr is not None:
lines.append(f"计划盈亏比:RR {rr:g} : 1")
if trailing_be:
be_px = entry - be_tick_buffer * tick_size if direction == "long" else entry + be_tick_buffer * tick_size
lines.append(f"移动保本:1.0R → {be_px:g}(缓冲 {be_tick_buffer} 跳)")
lines.extend(["", "📌 状态", "✅ 已进入下单监控,本地 SL/TP 守护"])
if extra_lines:
lines.extend(extra_lines)
return "\n".join(lines)
def format_key_open_success(
*,
symbol_name: str,
symbol: str,
monitor_type: str,
trade_mode: str,
bar_time: str,
break_side: str,
**kwargs,
) -> str:
side_label = "向上突破" if break_side == "upper" else "向下突破"
extra = [
"",
"📎 关键位触发",
f"类型:{monitor_type}",
f"模式:{trade_mode} · {side_label}",
f"5m 收盘:{bar_time}",
]
source = f"{monitor_type}·{trade_mode}"
return format_open_success(
symbol_name=symbol_name,
symbol=symbol,
source=source,
extra_lines=extra,
**kwargs,
)
def format_close_done(
*,
symbol_name: str,
symbol: str,
mode_label: str,
direction: str,
result: str,
pnl_net: float,
equity_after: Optional[float],
capital: float,
entry: float,
close_price: float,
stop_loss: Optional[float],
take_profit: Optional[float],
lots: float,
holding_minutes: int = 0,
order_id: str = "",
note: str = "",
) -> str:
"""平仓完成推送。"""
name = symbol_name or symbol
emoji = "📈" if pnl_net >= 0 else "📉"
pnl_sign = "+" if pnl_net >= 0 else ""
lines = [
f"{emoji} {name} 平仓完成",
f"💼 账户:{mode_label}",
"",
"🧾 平仓概要",
]
if order_id:
lines.append(f"🔖 平仓单号:{order_id}")
lines.extend([
f"📌 方向:{_dir_label(direction)}",
f"📌 平仓结果:{result}",
f"💰 本单净盈亏:{pnl_sign}{pnl_net:.2f}",
f"⏱ 持仓时长:{fmt_holding(holding_minutes)}",
f"💵 账户权益:{equity_after if equity_after is not None else capital:.2f}",
"",
"🎯 价位(计划)",
f"开仓价:{entry:g}",
f"平仓价:{close_price:g}",
])
if take_profit is not None:
lines.append(f"止盈价:{take_profit:g}")
if stop_loss is not None:
lines.append(f"止损价:{stop_loss:g}")
if note:
lines.extend(["", "📎 备注", note])
return "\n".join(lines)