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:
+102
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) 功能总览。
|
||||
@@ -34,6 +34,8 @@
|
||||
- 用于开仓纪律与仓位限制:按保证金上限计算最大手数,仅展示当前权益下可开的品种
|
||||
- 每日后台刷新列表(`/api/recommend/stream`)
|
||||
- 最大手数 = floor(权益 × 保证金上限 ÷ 1 手保证金)
|
||||
- **1 手保证金**:**CTP 已连接** 时优先读取柜台合约的 `long_margin_ratio` / `short_margin_ratio` 与乘数计算(表格标注「柜台」);未连接或合约信息暂不可用时,才用本地参考保证金率估算
|
||||
- 开仓前校验、固定金额计仓、保证金占用比例检查均与上述规则一致,避免交易所上调保证金后仍按旧比例显示可开手数
|
||||
- 展示近一周日线走势、跳空、昨日成交量(手)、成交额
|
||||
- 可按 **行业** 筛选,支持多字段排序
|
||||
- **夜盘时段**:品种下拉与可开仓表仅显示有夜盘交易的品种,并带「夜盘」标记
|
||||
|
||||
+305
-13
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
@@ -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}
|
||||
@@ -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
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user