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:
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
|
||||
from modules.notify.routes import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -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,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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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