Restructure into modules/ with single-process CTP and config/ layout.

Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-01 14:42:16 +08:00
parent b354d6c701
commit e5a586f903
209 changed files with 21962 additions and 20963 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
from modules.notify.routes import register
__all__ = ["register"]
+102
View File
@@ -0,0 +1,102 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""AI 接口:Ollama / OpenAI 兼容 API。"""
from __future__ import annotations
import json
import logging
from typing import Callable, Optional
import requests
logger = logging.getLogger(__name__)
def is_ai_enabled(get_setting: Callable[[str, str], str]) -> bool:
return (get_setting("ai_enabled", "0") or "0").strip() in ("1", "true", "yes")
def get_ai_config(get_setting: Callable[[str, str], str]) -> dict:
provider = (get_setting("ai_provider", "ollama") or "ollama").strip().lower()
if provider not in ("ollama", "openai"):
provider = "ollama"
return {
"enabled": is_ai_enabled(get_setting),
"provider": provider,
"ollama_base_url": (get_setting("ai_ollama_base_url", "http://127.0.0.1:11434") or "").strip().rstrip("/"),
"ollama_model": (get_setting("ai_ollama_model", "qwen2.5:7b") or "qwen2.5:7b").strip(),
"openai_base_url": (get_setting("ai_openai_base_url", "https://api.openai.com/v1") or "").strip().rstrip("/"),
"openai_api_key": (get_setting("ai_openai_api_key", "") or "").strip(),
"openai_model": (get_setting("ai_openai_model", "gpt-4o-mini") or "gpt-4o-mini").strip(),
}
def chat_completion(
*,
get_setting: Callable[[str, str], str],
system_prompt: str,
user_prompt: str,
timeout: int = 120,
) -> tuple[bool, str]:
cfg = get_ai_config(get_setting)
if not cfg["enabled"]:
return False, "AI 未启用"
provider = cfg["provider"]
try:
if provider == "ollama":
url = f"{cfg['ollama_base_url']}/api/chat"
payload = {
"model": cfg["ollama_model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
}
resp = requests.post(url, json=payload, timeout=timeout)
resp.raise_for_status()
data = resp.json()
msg = (data.get("message") or {}).get("content") or ""
return True, (msg or "").strip() or "AI 无回复)"
url = f"{cfg['openai_base_url']}/chat/completions"
headers = {
"Authorization": f"Bearer {cfg['openai_api_key']}",
"Content-Type": "application/json",
}
payload = {
"model": cfg["openai_model"],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.4,
}
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
resp.raise_for_status()
data = resp.json()
choices = data.get("choices") or []
if not choices:
return False, "AI 返回为空"
msg = (choices[0].get("message") or {}).get("content") or ""
return True, (msg or "").strip() or "AI 无回复)"
except Exception as exc:
logger.warning("AI chat failed (%s): %s", provider, exc)
return False, f"AI 调用失败:{exc}"
def analyze_trading_event(
*,
get_setting: Callable[[str, str], str],
event_kind: str,
payload: dict,
) -> tuple[bool, str]:
system = (
"你是国内期货交易复盘助手。根据提供的结构化交易数据,"
"用简洁中文给出 3~6 条要点:风险、纪律、改进建议。"
"不要编造未提供的数据;金额单位为元。"
)
user = f"事件类型:{event_kind}\n\n数据:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
return chat_completion(get_setting=get_setting, system_prompt=system, user_prompt=user)
+70
View File
@@ -0,0 +1,70 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""AI 消息存储与展示。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, 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) -> 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,
*,
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 (?,?,?,?,?) RETURNING id""",
(kind, title, content, json.dumps(meta or {}, ensure_ascii=False), now),
)
row = cur.fetchone()
if row is not None:
return int(row["id"] if isinstance(row, dict) else row[0])
return int(cur.lastrowid or 0)
def list_ai_messages(conn, *, limit: int = 100) -> list[dict]:
ensure_ai_messages_table(conn)
rows = conn.execute(
"SELECT * FROM ai_messages ORDER BY id DESC LIMIT ?",
(max(1, min(500, int(limit))),),
).fetchall()
out = []
for r in rows:
item = dict(r)
try:
item["meta"] = json.loads(item.get("meta_json") or "{}")
except Exception:
item["meta"] = {}
out.append(item)
return out
+173
View File
@@ -0,0 +1,173 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""AI 后台:开仓/平仓分析、日终持仓报告。"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime
from typing import Callable, Optional
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai")
DAILY_REPORT_KEY = "ai_daily_report_last_date"
def schedule_ai_event_analysis(
*,
db_path: str,
get_setting_fn: Callable[[str, str], str],
kind: str,
title: str,
payload: dict,
send_wechat_fn: Callable[[str], None] | None = None,
) -> None:
"""后台线程:调用 AI 并写入 ai_messages。"""
if not (get_setting_fn("ai_enabled", "0") or "0").strip() in ("1", "true", "yes"):
return
def _run() -> None:
from modules.notify.ai_client import analyze_trading_event
from modules.notify.ai_messages import insert_ai_message
from modules.core.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 modules.notify.ai_client import analyze_trading_event
from modules.notify.ai_messages import insert_ai_message
from modules.core.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()
+65
View File
@@ -0,0 +1,65 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
"""HTTP routes for notify module."""
from __future__ import annotations
from datetime import date, datetime
from flask import (
Response,
flash,
jsonify,
redirect,
render_template,
request,
send_file,
session,
stream_with_context,
url_for,
)
def register(deps) -> None:
app = deps.app
login_required = deps.login_required
require_nav = deps.require_nav
get_db = deps.get_db
get_setting = deps.get_setting
set_setting = deps.set_setting
fetch_price = deps.fetch_price
send_wechat_msg = deps.send_wechat_msg
touch_stats_cache = deps.touch_stats_cache
get_stats_data = deps.get_stats_data
build_market_quote_payload = deps.build_market_quote_payload
today_str = deps.today_str
expire_old_plans = deps.expire_old_plans
TZ = deps.tz
DB_PATH = deps.db_path
UPLOAD_DIR = deps.upload_dir
OPEN_TYPES = deps.open_types
EXIT_TRIGGERS = deps.exit_triggers
BEHAVIOR_TAGS = deps.behavior_tags
KLINE_PERIODS = deps.kline_periods
KLINE_CUTOFFS = deps.kline_cutoffs
calc_holding_duration = deps.calc_holding_duration
holding_to_minutes = deps.holding_to_minutes
classify_close_result = deps.classify_close_result
calc_rr_ratio = deps.calc_rr_ratio
calc_theoretical_pnl = deps.calc_theoretical_pnl
parse_review_date_filter = deps.parse_review_date_filter
_trading_mode = deps.trading_mode
_ua_is_phone = deps.ua_is_phone
_static_asset_v = deps.static_asset_v
@app.route("/ai")
@login_required
@require_nav("ai")
def ai_messages_page():
from modules.notify.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)
+183
View File
@@ -0,0 +1,183 @@
# Copyright (c) 2025-2026 马建军. All rights reserved.
# 专有软件 — 未经授权禁止复制、传播、转售。
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""企业微信推送:开仓 / 平仓 / 关键位 结构化消息。"""
from __future__ import annotations
from typing import Optional
def _dir_label(direction: str) -> str:
d = (direction or "long").strip().lower()
return "多头(long" if d == "long" else "空头(short"
def _dir_emoji(direction: str) -> str:
d = (direction or "long").strip().lower()
return "📈" if d == "long" else "📉"
def fmt_holding(minutes: int) -> str:
m = max(0, int(minutes or 0))
if m >= 1440:
return f"{m // 1440}{m % 1440 // 60}小时{m % 60}分钟"
if m >= 60:
return f"{m // 60}小时{m % 60}分钟"
return f"{m}分钟"
def calc_rr(entry: float, sl: float, tp: float, direction: str) -> Optional[float]:
try:
entry_f, sl_f, tp_f = float(entry), float(sl), float(tp)
except (TypeError, ValueError):
return None
risk = abs(entry_f - sl_f)
if risk <= 0:
return None
reward = (tp_f - entry_f) if direction == "long" else (entry_f - tp_f)
if reward <= 0:
return None
return round(reward / risk, 2)
def format_open_success(
*,
symbol_name: str,
symbol: str,
direction: str,
mode_label: str,
order_id: str = "",
entry: float,
stop_loss: float,
take_profit: Optional[float],
lots: int,
capital: float,
margin: Optional[float],
margin_pct: Optional[float],
risk_percent: float,
risk_amount: Optional[float],
trailing_be: bool = False,
be_tick_buffer: int = 2,
tick_size: float = 1.0,
source: str = "期货下单",
extra_lines: Optional[list[str]] = None,
) -> str:
"""正常 / 关键位开仓成功推送。"""
name = symbol_name or symbol
emoji = _dir_emoji(direction)
rr = calc_rr(entry, stop_loss, take_profit, direction) if take_profit else None
lines = [
f"{emoji} {name} 开仓成功",
f"💼 账户:{mode_label}",
"",
"🧾 订单基础信息",
f"📌 来源:{source}",
]
if order_id:
lines.append(f"🔖 委托号:{order_id}")
lines.extend([
f"📈 方向:{_dir_label(direction)}",
f"⚠ 单笔风控:{risk_percent:g}%"
+ (f"{risk_amount:.2f}" if risk_amount is not None else ""),
"",
"📊 仓位配置",
f"账户权益:{capital:.2f}",
f"开仓手数:{lots}",
])
if margin is not None:
lines.append(f"占用保证金:{margin:.2f}")
if margin_pct is not None:
lines.append(f"仓位占比:{margin_pct:.2f}%")
lines.extend(["", "🎯 价位 & 盈亏比", f"开仓价:{entry:g}", f"止损价:{stop_loss:g}"])
if take_profit is not None:
lines.append(f"止盈价:{take_profit:g}")
if rr is not None:
lines.append(f"计划盈亏比:RR {rr:g} : 1")
if trailing_be:
be_px = entry - be_tick_buffer * tick_size if direction == "long" else entry + be_tick_buffer * tick_size
lines.append(f"移动保本:1.0R → {be_px:g}(缓冲 {be_tick_buffer} 跳)")
lines.extend(["", "📌 状态", "✅ 已进入下单监控,本地 SL/TP 守护"])
if extra_lines:
lines.extend(extra_lines)
return "\n".join(lines)
def format_key_open_success(
*,
symbol_name: str,
symbol: str,
monitor_type: str,
trade_mode: str,
bar_time: str,
break_side: str,
**kwargs,
) -> str:
side_label = "向上突破" if break_side == "upper" else "向下突破"
extra = [
"",
"📎 关键位触发",
f"类型:{monitor_type}",
f"模式:{trade_mode} · {side_label}",
f"5m 收盘:{bar_time}",
]
source = f"{monitor_type}·{trade_mode}"
return format_open_success(
symbol_name=symbol_name,
symbol=symbol,
source=source,
extra_lines=extra,
**kwargs,
)
def format_close_done(
*,
symbol_name: str,
symbol: str,
mode_label: str,
direction: str,
result: str,
pnl_net: float,
equity_after: Optional[float],
capital: float,
entry: float,
close_price: float,
stop_loss: Optional[float],
take_profit: Optional[float],
lots: float,
holding_minutes: int = 0,
order_id: str = "",
note: str = "",
) -> str:
"""平仓完成推送。"""
name = symbol_name or symbol
emoji = "📈" if pnl_net >= 0 else "📉"
pnl_sign = "+" if pnl_net >= 0 else ""
lines = [
f"{emoji} {name} 平仓完成",
f"💼 账户:{mode_label}",
"",
"🧾 平仓概要",
]
if order_id:
lines.append(f"🔖 平仓单号:{order_id}")
lines.extend([
f"📌 方向:{_dir_label(direction)}",
f"📌 平仓结果:{result}",
f"💰 本单净盈亏:{pnl_sign}{pnl_net:.2f}",
f"⏱ 持仓时长:{fmt_holding(holding_minutes)}",
f"💵 账户权益:{equity_after if equity_after is not None else capital:.2f}",
"",
"🎯 价位(计划)",
f"开仓价:{entry:g}",
f"平仓价:{close_price:g}",
])
if take_profit is not None:
lines.append(f"止盈价:{take_profit:g}")
if stop_loss is not None:
lines.append(f"止损价:{stop_loss:g}")
if note:
lines.extend(["", "📎 备注", note])
return "\n".join(lines)