4742a0bb9d
删除日历 UI、bootstrap 与 /api/stats/calendar 注册;保留日/周/月统计表。内照明心档案日历不受影响。 Co-authored-by: Cursor <cursoragent@cursor.com>
9593 lines
380 KiB
Python
9593 lines
380 KiB
Python
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file
|
||
import sqlite3
|
||
import csv
|
||
from io import StringIO
|
||
import time
|
||
import threading
|
||
import requests
|
||
import os
|
||
import re
|
||
import base64
|
||
import json
|
||
import math
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
try:
|
||
from zoneinfo import ZoneInfo
|
||
except ImportError:
|
||
ZoneInfo = None # type: ignore
|
||
from functools import wraps
|
||
import uuid
|
||
import ccxt
|
||
from werkzeug.utils import secure_filename
|
||
|
||
try:
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
except ImportError:
|
||
Image = None # type: ignore
|
||
ImageDraw = None # type: ignore
|
||
ImageFont = None # type: ignore
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
_REPO_ROOT = os.path.dirname(BASE_DIR)
|
||
import sys
|
||
|
||
if _REPO_ROOT not in sys.path:
|
||
sys.path.insert(0, _REPO_ROOT)
|
||
from ai_client import ai_generate, ai_review, ai_short_advice
|
||
from ai_review_lib import (
|
||
build_journal_ai_chart_path,
|
||
collect_images_for_ai_review,
|
||
journal_row_lines_for_ai,
|
||
)
|
||
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
|
||
from fib_key_monitor_lib import (
|
||
FIB_KEY_MONITOR_TYPES,
|
||
KEY_ENTRY_REASON_BY_SIGNAL,
|
||
backfill_missing_key_signal_types,
|
||
calc_fib_plan,
|
||
entry_reason_from_key_signal,
|
||
fib_invalidate_by_mark,
|
||
fib_ratio_from_type,
|
||
is_fib_key_monitor_type,
|
||
key_signal_type_for_trade_record,
|
||
stored_key_signal_type,
|
||
)
|
||
from false_breakout_key_monitor_lib import (
|
||
FALSE_BREAKOUT_MONITOR_TYPE,
|
||
FALSE_BREAKOUT_VALIDITY_HOURS,
|
||
calc_false_breakout_plan,
|
||
expires_at_text,
|
||
false_breakout_gate_preview,
|
||
is_false_breakout_expired,
|
||
is_false_breakout_key_monitor_type,
|
||
is_limit_key_monitor_type,
|
||
key_price_from_row,
|
||
normalize_false_breakout_symbol,
|
||
storage_bounds_from_key_price,
|
||
)
|
||
from strategy_trade_labels import (
|
||
STRATEGY_ENTRY_REASON_OPTIONS,
|
||
apply_order_monitor_source_labels,
|
||
entry_reason_for_monitor_type,
|
||
handoff_trade_miss_reason,
|
||
order_monitor_source_type,
|
||
trade_record_monitor_type as resolve_trade_record_monitor_type,
|
||
trend_plan_id_from_monitor_row,
|
||
)
|
||
from journal_chart_lib import (
|
||
JOURNAL_CHART_DEFAULT_LIMIT,
|
||
JOURNAL_CHART_DEFAULT_TF1,
|
||
JOURNAL_CHART_DEFAULT_TF2,
|
||
JOURNAL_CHART_TF_CHOICES,
|
||
compose_chart_panels,
|
||
marker_points_for_timeframe,
|
||
parse_journal_chart_anchor,
|
||
parse_journal_chart_limit,
|
||
parse_journal_chart_timeframes,
|
||
JOURNAL_CHART_DEFAULT_ANCHOR,
|
||
price_levels_from_marker_payload,
|
||
render_candles_subplot,
|
||
trade_review_fetch_window,
|
||
trim_rows_for_trade_review,
|
||
)
|
||
from key_sl_tp_lib import (
|
||
breakeven_enabled_from_row,
|
||
normalize_sl_tp_mode,
|
||
parse_breakeven_enabled_form,
|
||
plan_key_sl_tp,
|
||
sl_tp_mode_from_row,
|
||
sl_tp_mode_label,
|
||
sl_tp_plan_summary_text,
|
||
)
|
||
from time_close_lib import (
|
||
TIME_CLOSE_RESULT,
|
||
apply_time_close_to_payload,
|
||
ensure_time_close_schema,
|
||
parse_time_close_enabled_form,
|
||
parse_time_close_hours_form,
|
||
should_trigger_time_close,
|
||
time_close_insert_values,
|
||
time_close_label,
|
||
time_close_settings_from_row,
|
||
)
|
||
from manual_sltp_lib import (
|
||
normalize_open_sltp_mode,
|
||
resolve_entrust_sltp_prices,
|
||
resolve_open_sltp_prices,
|
||
)
|
||
from key_monitor_schema_lib import ensure_key_monitor_schema
|
||
from trigger_entry_key_monitor_lib import (
|
||
BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE,
|
||
CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
|
||
TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED,
|
||
TRIGGER_ENTRY_CLOSE_EXPIRED,
|
||
TRIGGER_ENTRY_CLOSE_FILLED,
|
||
TRIGGER_ENTRY_CLOSE_SL_INVALIDATE,
|
||
TRIGGER_ENTRY_CLOSE_TP_INVALIDATE,
|
||
TRIGGER_ENTRY_MONITOR_TYPE,
|
||
TRIGGER_ENTRY_MONITOR_TYPES,
|
||
TRIGGER_ENTRY_VALIDITY_HOURS,
|
||
check_trigger_entry_intent_limit,
|
||
count_pending_trigger_entries,
|
||
is_breakout_trigger_entry_key_monitor_type,
|
||
is_trigger_entry_expired,
|
||
is_trigger_entry_key_monitor_type,
|
||
trigger_entry_expires_at_text,
|
||
trigger_entry_gate_preview,
|
||
trigger_entry_invalidate,
|
||
trigger_should_fire,
|
||
validate_trigger_entry_geometry,
|
||
validate_trigger_entry_rr,
|
||
)
|
||
from position_sizing_lib import (
|
||
OPEN_SOURCE_KEY_AUTO,
|
||
OPEN_SOURCE_KEY_TRIGGER,
|
||
OPEN_SOURCE_MANUAL,
|
||
assert_open_source_allowed,
|
||
compute_full_margin_sizing,
|
||
format_risk_display_text,
|
||
full_margin_requires_flat_position,
|
||
is_full_margin_mode,
|
||
leverage_for_full_margin,
|
||
load_position_sizing_mode,
|
||
mode_label_zh,
|
||
risk_percent_for_storage,
|
||
)
|
||
from key_monitor_full_margin_lib import (
|
||
monitor_type_disallowed_in_full_margin,
|
||
purge_disallowed_key_monitors,
|
||
)
|
||
from auto_transfer_daily_lib import run_auto_transfer_once_per_day
|
||
from key_monitor_lib import (
|
||
KEY_DIRECTION_WATCH,
|
||
KEY_MONITOR_ALERT_ONLY_TYPES,
|
||
KEY_MONITOR_AUTO_TYPES,
|
||
KEY_MONITOR_RS_TYPE,
|
||
KEY_MONITOR_RS_TYPES,
|
||
auto_amp_ok,
|
||
auto_confirm_ok,
|
||
box_breakout_invalidate_by_mark,
|
||
box_breakout_invalidate_edge_label,
|
||
claim_rs_level_notify,
|
||
detect_rs_box_break,
|
||
format_auto_amp_line,
|
||
format_auto_confirm_line,
|
||
key_monitor_rule_template_context,
|
||
notify_interval_elapsed,
|
||
resolve_rs_break_for_alert,
|
||
rs_break_from_direction,
|
||
run_rs_level_alert_tick,
|
||
)
|
||
from order_monitor_display_lib import (
|
||
apply_order_price_display_fields,
|
||
enrich_order_display_fields,
|
||
order_monitor_tpsl_needs_sync,
|
||
)
|
||
from wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook
|
||
from hub_auth import request_allowed as hub_request_allowed
|
||
from instance_nav_lib import request_is_hub_soft_nav
|
||
from hub_volume_rank_lib import resolve_daily_volume_rank
|
||
from history_window_lib import (
|
||
PRESET_CUSTOM,
|
||
PRESET_UTC_LAST24H,
|
||
PRESET_UTC_LAST7D,
|
||
PRESET_UTC_TODAY,
|
||
list_window_redirect_query,
|
||
normalize_bj_datetime_storage,
|
||
resolve_list_window,
|
||
resolve_window,
|
||
sql_list_time_field,
|
||
utc_window_to_bj_sql_strings,
|
||
utc_window_to_utc_sql_strings,
|
||
)
|
||
from trade_result_lib import count_winning_trades, normalize_result_with_pnl
|
||
from trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills
|
||
|
||
|
||
def load_env_file(path):
|
||
if not os.path.exists(path):
|
||
return
|
||
raw_bytes = open(path, "rb").read()
|
||
text = ""
|
||
for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"):
|
||
try:
|
||
text = raw_bytes.decode(enc)
|
||
break
|
||
except Exception:
|
||
continue
|
||
if not text:
|
||
text = raw_bytes.decode("utf-8", errors="ignore")
|
||
text = text.replace("\x00", "")
|
||
for line in text.splitlines():
|
||
raw = line.strip()
|
||
if not raw or raw.startswith("#") or "=" not in raw:
|
||
continue
|
||
key, value = raw.split("=", 1)
|
||
clean_key = key.strip().lstrip("\ufeff")
|
||
if not clean_key.replace("_", "").isalnum():
|
||
continue
|
||
clean_value = value.strip().strip('"').strip("'")
|
||
os.environ[clean_key] = clean_value
|
||
|
||
load_env_file(os.path.join(BASE_DIR, ".env"))
|
||
|
||
|
||
def resolve_path(path_value):
|
||
if os.path.isabs(path_value):
|
||
return path_value
|
||
return os.path.join(BASE_DIR, path_value)
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key")
|
||
|
||
# ====================== 登录配置 ======================
|
||
USERNAME = os.getenv("APP_USERNAME", "dekun")
|
||
PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@")
|
||
AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on")
|
||
|
||
# 企业微信机器人Webhook
|
||
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me")
|
||
SYSTEM_TYPE = "CRYPTO"
|
||
HOST = os.getenv("APP_HOST", "0.0.0.0")
|
||
PORT = int(os.getenv("APP_PORT", "5000"))
|
||
DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true"
|
||
DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db"))
|
||
|
||
# 训练参数(可由 .env 覆盖)
|
||
DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30"))
|
||
DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20"))
|
||
DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50"))
|
||
BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10"))
|
||
ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5"))
|
||
# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8)
|
||
TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))
|
||
# false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR)
|
||
TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv(
|
||
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true"
|
||
).lower() in ("1", "true", "yes", "on")
|
||
APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai")
|
||
|
||
|
||
def _resolve_app_tz():
|
||
if ZoneInfo is not None:
|
||
try:
|
||
return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip())
|
||
except Exception:
|
||
pass
|
||
return timezone(timedelta(hours=8))
|
||
|
||
|
||
APP_TZ = _resolve_app_tz()
|
||
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
|
||
GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip()
|
||
GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip()
|
||
GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
|
||
GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
|
||
# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平)
|
||
GATE_TPSL_TRIGGER_EXPIRATION = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400)))
|
||
GATE_TPSL_PRICE_TYPE = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0"))
|
||
if GATE_TPSL_PRICE_TYPE < 0 or GATE_TPSL_PRICE_TYPE > 2:
|
||
GATE_TPSL_PRICE_TYPE = 0
|
||
GATE_TPSL_USE_POSITION_ORDER = os.getenv("GATE_TPSL_USE_POSITION_ORDER", "true").lower() in ("1", "true", "yes")
|
||
# 仓位类触发单相对 mark/last 的最小间距(%),避免 Gate 1026 AUTO_TRIGGER_PRICE_*_LAST
|
||
GATE_TPSL_LAST_PRICE_GAP_PCT = float(os.getenv("GATE_TPSL_LAST_PRICE_GAP_PCT", "0.05"))
|
||
# 页面展示的交易所名称(多实例/多环境时可按需区分)
|
||
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "Gate.io").strip() or "Gate.io"
|
||
_GATE_DEFAULT_MARGIN_MODE = "cross" if GATE_TD_MODE in ("cross", "cross_margin") else "isolated"
|
||
BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
|
||
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
|
||
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
|
||
KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5"))
|
||
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR", "1.5"))
|
||
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT", "0.5"))
|
||
KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT", "1"))
|
||
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4"))
|
||
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
||
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS", "20")))
|
||
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN", "1.3"))
|
||
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT", "0.03"))
|
||
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT", "0.5"))
|
||
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30")))
|
||
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR", "-2"))
|
||
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR", "-1"))
|
||
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() == "true"
|
||
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
|
||
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
|
||
EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip()
|
||
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
|
||
_LAST_EXCHANGE_PNL_SYNC_AT = 0.0
|
||
|
||
# KEY_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib
|
||
AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true"
|
||
AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30"))
|
||
AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding")
|
||
AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap")
|
||
FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true"
|
||
FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0"))
|
||
# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账
|
||
AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8"))
|
||
POSITION_SIZING_MODE = load_position_sizing_mode()
|
||
WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10"))
|
||
AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120"))
|
||
MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3"))
|
||
RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90"))
|
||
RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3")))
|
||
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
|
||
_APP_STARTED_AT = time.time()
|
||
_RECONCILE_FLAT_STREAK = {}
|
||
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98"))
|
||
TRANSFER_CCY = os.getenv("TRANSFER_CCY", "USDT")
|
||
UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
|
||
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
|
||
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
|
||
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
|
||
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
|
||
from daily_open_limit_lib import (
|
||
build_daily_open_alert_prompt,
|
||
can_trade_new_open,
|
||
check_daily_open_hard_limit,
|
||
count_opens_for_trading_day,
|
||
format_daily_open_counter_line,
|
||
format_daily_open_summary_short,
|
||
load_daily_open_limits_from_env,
|
||
should_send_daily_open_alert,
|
||
)
|
||
|
||
DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env()
|
||
RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2"))
|
||
BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0"))
|
||
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
|
||
BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0"))
|
||
DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower()
|
||
|
||
GATE_SOCKS_PROXY = (os.getenv("GATE_SOCKS_PROXY") or "").strip()
|
||
GATE_HTTP_PROXY = (os.getenv("GATE_HTTP_PROXY") or "").strip()
|
||
GATE_HTTPS_PROXY = (os.getenv("GATE_HTTPS_PROXY") or "").strip()
|
||
|
||
|
||
def build_gate_ccxt_proxies():
|
||
"""
|
||
为 ccxt 配置代理(常用于本机网络不稳定时通过 SSH 动态转发 SOCKS5 出口)。
|
||
|
||
推荐:
|
||
- 本机:ssh -N -D 127.0.0.1:1080 user@vps
|
||
- .env:GATE_SOCKS_PROXY=socks5h://127.0.0.1:1080
|
||
|
||
说明:
|
||
- socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5://
|
||
"""
|
||
socks = GATE_SOCKS_PROXY.strip()
|
||
http = GATE_HTTP_PROXY.strip()
|
||
https = GATE_HTTPS_PROXY.strip() or http
|
||
if socks:
|
||
return {"http": socks, "https": socks}
|
||
if http or https:
|
||
return {"http": http, "https": https}
|
||
return None
|
||
|
||
|
||
GATE_CCXT_PROXIES = build_gate_ccxt_proxies()
|
||
|
||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||
os.makedirs(ORDER_CHART_DIR, exist_ok=True)
|
||
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||
|
||
# Gate.io USDT 永续(swap)
|
||
exchange = ccxt.gateio({
|
||
"enableRateLimit": True,
|
||
"options": {
|
||
"defaultType": "swap",
|
||
"defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE,
|
||
},
|
||
})
|
||
if GATE_CCXT_PROXIES:
|
||
exchange.proxies = GATE_CCXT_PROXIES
|
||
if GATE_API_KEY and GATE_API_SECRET:
|
||
exchange.apiKey = GATE_API_KEY
|
||
exchange.secret = GATE_API_SECRET
|
||
MARKETS_LOADED = False
|
||
ACCOUNT_BALANCE_CACHE = {
|
||
"updated_at": 0.0,
|
||
"funding_usdt": None,
|
||
"trading_usdt": None
|
||
}
|
||
LIQUIDITY_RANK_CACHE = {
|
||
"updated_at": 0.0,
|
||
"version": 0,
|
||
"ranks": {},
|
||
"total": 0,
|
||
}
|
||
|
||
# 企业微信推送
|
||
def send_wechat_msg(content):
|
||
send_wechat_webhook(
|
||
WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS
|
||
)
|
||
|
||
|
||
_BREAKEVEN_EXCHANGE_WARNED_IDS = set()
|
||
|
||
|
||
def _send_breakeven_exchange_warn_once(order_id, message):
|
||
"""移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏。"""
|
||
oid = int(order_id)
|
||
if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS:
|
||
return
|
||
_BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid)
|
||
send_wechat_msg(message)
|
||
|
||
|
||
def _clear_breakeven_exchange_warn(order_id):
|
||
_BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id))
|
||
|
||
|
||
def _wechat_account_label():
|
||
return (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip()
|
||
|
||
|
||
def _wechat_direction_text(direction):
|
||
d = (direction or "").lower()
|
||
return "多头(long)" if d == "long" else "空头(short)"
|
||
|
||
|
||
def _wechat_trading_capital_text(fallback=None):
|
||
try:
|
||
_, trading_capital = get_exchange_capitals(force=True)
|
||
except Exception:
|
||
trading_capital = None
|
||
if trading_capital is not None:
|
||
return f"{round(float(trading_capital), 2)}U"
|
||
if fallback is not None:
|
||
try:
|
||
return f"{round(float(fallback), 2)}U"
|
||
except Exception:
|
||
pass
|
||
return "-"
|
||
|
||
|
||
def build_wechat_close_message(
|
||
symbol,
|
||
direction,
|
||
result,
|
||
pnl_amount,
|
||
hold_seconds=None,
|
||
trigger_price=None,
|
||
current_price=None,
|
||
stop_loss=None,
|
||
take_profit=None,
|
||
close_order_id=None,
|
||
extra_note=None,
|
||
session_capital_fallback=None,
|
||
):
|
||
hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-"
|
||
ep = format_price_for_symbol(symbol, trigger_price)
|
||
cp = format_price_for_symbol(symbol, current_price)
|
||
tp = format_price_for_symbol(symbol, take_profit)
|
||
sl = format_wechat_scalar_2dp(stop_loss)
|
||
cap_txt = _wechat_trading_capital_text(session_capital_fallback)
|
||
try:
|
||
if pnl_amount is not None:
|
||
pv = float(pnl_amount)
|
||
pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U"
|
||
else:
|
||
pnl_disp = "-"
|
||
except (TypeError, ValueError):
|
||
pnl_disp = "-"
|
||
|
||
lines = [
|
||
f"📉 {symbol} 平仓完成",
|
||
f"💼 账户:{_wechat_account_label()}",
|
||
"",
|
||
"🧾 平仓概要",
|
||
f"🔖 平仓单号:{close_order_id or '-'}",
|
||
f"📌 方向:{_wechat_direction_text(direction)}",
|
||
f"📌 平仓结果:{result or '-'}",
|
||
f"💰 本单盈亏:{pnl_disp}",
|
||
f"⏱ 持仓时长:{hold_txt}",
|
||
f"💵 交易账户资金:{cap_txt}",
|
||
"",
|
||
"🎯 价位(计划)",
|
||
f"开仓成交价:{ep}",
|
||
f"离场参考价:{cp}",
|
||
f"止盈价位:{tp}",
|
||
f"止损价位:{sl}",
|
||
]
|
||
if extra_note:
|
||
lines.extend(["", "📎 备注", extra_note])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl):
|
||
sl_fmt = format_wechat_scalar_2dp(new_sl)
|
||
return "\n".join(
|
||
[
|
||
f"# 🛡️ {symbol} 保护位更新",
|
||
f"**账户:{_wechat_account_label()}**",
|
||
"",
|
||
"---",
|
||
"",
|
||
"### 移动保本/止盈",
|
||
f"- 方向:**{_wechat_direction_text(direction)}**",
|
||
f"- 类型:**{arm_txt}**",
|
||
f"- 当前RR:`{round(float(now_rr), 2)}R`",
|
||
f"- 锁定RR:`{round(float(locked_r), 2)}R`",
|
||
f"- 新保护位:`{sl_fmt}`",
|
||
]
|
||
)
|
||
|
||
|
||
def build_wechat_monitor_error_message(symbol, direction, scene, error_text):
|
||
return "\n".join(
|
||
[
|
||
f"# ⚠️ {symbol} 下单监控异常",
|
||
f"**账户:{_wechat_account_label()}**",
|
||
"",
|
||
"---",
|
||
"",
|
||
"### 异常信息",
|
||
f"- 方向:**{_wechat_direction_text(direction)}**",
|
||
f"- 场景:{scene}",
|
||
f"- 错误:{str(error_text)}",
|
||
]
|
||
)
|
||
|
||
|
||
def build_wechat_key_monitor_message(
|
||
symbol,
|
||
direction,
|
||
monitor_type,
|
||
trigger_time,
|
||
key_price,
|
||
confirm_close,
|
||
hard_lines,
|
||
btc8h_status,
|
||
coin4h_status,
|
||
swing4h_pct,
|
||
op_lines,
|
||
risk_tip=None,
|
||
):
|
||
lines = [
|
||
f"# 🎯 {symbol} 关键位确认推送",
|
||
f"**账户:{_wechat_account_label()}**",
|
||
"",
|
||
"---",
|
||
"",
|
||
"### 交易对 / 触发时间",
|
||
f"- 交易对:**{symbol}**",
|
||
f"- 触发时间:`{trigger_time}`",
|
||
"",
|
||
"### 方向与确认K",
|
||
f"- 方向:**{_wechat_direction_text(direction)}**",
|
||
"- 确认K:第二根5m收盘完成",
|
||
"",
|
||
"### 关键价位",
|
||
f"- 类型:**{monitor_type}**",
|
||
f"- 箱体关键位:`{key_price}`",
|
||
f"- 第二根确认收盘价:`{confirm_close}`",
|
||
"",
|
||
"### 硬条件校验结果",
|
||
]
|
||
lines.extend([f"- {x}" for x in hard_lines])
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"### 市场状态说明",
|
||
f"- BTC 8h 状态:**{btc8h_status}**",
|
||
f"- 本币 4h(EMA55) 状态:**{coin4h_status}**",
|
||
f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`",
|
||
"",
|
||
"### 操作提示",
|
||
]
|
||
)
|
||
lines.extend([f"- {x}" for x in op_lines])
|
||
if risk_tip:
|
||
lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"])
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _read_image_base64(image_path):
|
||
try:
|
||
with open(image_path, "rb") as f:
|
||
return base64.b64encode(f.read()).decode("utf-8")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _extract_json_object(text):
|
||
if not text:
|
||
return None
|
||
clean = text.strip()
|
||
if clean.startswith("```"):
|
||
clean = clean.replace("```json", "").replace("```", "").strip()
|
||
try:
|
||
return json.loads(clean)
|
||
except Exception:
|
||
pass
|
||
match = re.search(r"\{[\s\S]*\}", clean)
|
||
if not match:
|
||
return None
|
||
try:
|
||
return json.loads(match.group(0))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _load_font(size):
|
||
if not ImageFont:
|
||
return None
|
||
candidates = [
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||
"C:\\Windows\\Fonts\\msyh.ttc",
|
||
"C:\\Windows\\Fonts\\arial.ttf",
|
||
]
|
||
for path in candidates:
|
||
if path and os.path.exists(path):
|
||
try:
|
||
return ImageFont.truetype(path, size)
|
||
except Exception:
|
||
continue
|
||
try:
|
||
return ImageFont.load_default()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _ohlcv_to_rows(ohlcv):
|
||
rows = []
|
||
for bar in ohlcv or []:
|
||
if not bar or len(bar) < 6:
|
||
continue
|
||
try:
|
||
rows.append(
|
||
{
|
||
"ts": int(bar[0]),
|
||
"o": float(bar[1]),
|
||
"h": float(bar[2]),
|
||
"l": float(bar[3]),
|
||
"c": float(bar[4]),
|
||
"v": float(bar[5]),
|
||
}
|
||
)
|
||
except Exception:
|
||
continue
|
||
return rows
|
||
|
||
|
||
def _local_input_datetime_to_ms(dt_text):
|
||
raw = str(dt_text or "").strip()
|
||
if not raw:
|
||
return None
|
||
raw = raw.replace("T", " ")
|
||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
||
try:
|
||
dt = datetime.strptime(raw, fmt)
|
||
aware = dt.replace(tzinfo=APP_TZ)
|
||
return int(aware.timestamp() * 1000)
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _marker_tag_label(tag):
|
||
t = str(tag or "").strip().upper()
|
||
if t == "ENTRY":
|
||
return "开仓"
|
||
if t == "EXIT":
|
||
return "平仓"
|
||
return str(tag or "")
|
||
|
||
|
||
def _pick_marker_point(rows, target_ts_ms, target_price=None):
|
||
if not rows or target_ts_ms is None:
|
||
return None, None
|
||
idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms)))
|
||
if target_price is not None:
|
||
try:
|
||
p = float(target_price)
|
||
if p > 0:
|
||
return idx, p
|
||
except Exception:
|
||
pass
|
||
return idx, float(rows[idx]["c"])
|
||
|
||
|
||
def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None):
|
||
if not Image or not ImageDraw:
|
||
raise RuntimeError("缺少依赖:Pillow(pip install Pillow)")
|
||
img = Image.new("RGB", (width, height), bg_rgb)
|
||
draw = ImageDraw.Draw(img)
|
||
font = _load_font(14)
|
||
small = _load_font(12)
|
||
|
||
pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28
|
||
plot_w = max(10, width - pad_l - pad_r)
|
||
plot_h = max(10, height - pad_t - pad_b)
|
||
|
||
header_bg = (245, 247, 250)
|
||
draw.rectangle((0, 0, width, pad_t), fill=header_bg)
|
||
if font:
|
||
draw.text((10, 6), title, fill=(25, 35, 60), font=font)
|
||
else:
|
||
draw.text((10, 6), title, fill=(25, 35, 60))
|
||
|
||
if not rows:
|
||
if small:
|
||
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small)
|
||
else:
|
||
draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120))
|
||
return img
|
||
|
||
lo = min(r["l"] for r in rows)
|
||
hi = max(r["h"] for r in rows)
|
||
if hi <= lo:
|
||
hi = lo + 1e-12
|
||
|
||
n = len(rows)
|
||
marker_by_idx = {}
|
||
for mp in marker_points or []:
|
||
try:
|
||
idx = int(mp.get("idx"))
|
||
except Exception:
|
||
continue
|
||
if idx < 0 or idx >= n:
|
||
continue
|
||
marker_by_idx.setdefault(idx, []).append(mp)
|
||
|
||
x0 = pad_l
|
||
for i, r in enumerate(rows):
|
||
x1 = pad_l + int((i + 1) * plot_w / n)
|
||
x_mid = (x0 + x1) // 2
|
||
wick_x = x_mid
|
||
y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h)
|
||
y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h)
|
||
y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h)
|
||
y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h)
|
||
top = min(y_open, y_close)
|
||
bot = max(y_open, y_close)
|
||
up = r["c"] >= r["o"]
|
||
wick_color = (120, 120, 120)
|
||
edge_color = (20, 20, 20)
|
||
draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color)
|
||
body_w = max(1, (x1 - x0) - 2)
|
||
left = x0 + 1
|
||
if bot - top < 2:
|
||
mid = (top + bot) // 2
|
||
draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color)
|
||
else:
|
||
if up:
|
||
draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1)
|
||
else:
|
||
draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1)
|
||
for j, mp in enumerate(marker_by_idx.get(i, [])):
|
||
tag = str(mp.get("tag") or "")
|
||
label = _marker_tag_label(tag)
|
||
m_price = float(mp.get("price") or r["c"])
|
||
y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h)
|
||
y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m))
|
||
x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14
|
||
x_draw = int(x_mid + x_off)
|
||
if tag == "ENTRY":
|
||
m_color = (0, 195, 95)
|
||
tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)]
|
||
text_y = y_m - 36
|
||
else:
|
||
m_color = (235, 65, 65)
|
||
tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)]
|
||
text_y = y_m + 12
|
||
draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1)
|
||
draw.polygon(tri, fill=m_color)
|
||
draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3)
|
||
if font:
|
||
draw.text((x_draw + 8, text_y), label, fill=m_color, font=font)
|
||
else:
|
||
draw.text((x_draw + 8, text_y), label, fill=m_color)
|
||
x0 = x1
|
||
|
||
if len(marker_points or []) >= 2:
|
||
try:
|
||
entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None)
|
||
exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None)
|
||
if entry is not None and exitp is not None:
|
||
ex_i, ex_p = int(entry["idx"]), float(entry["price"])
|
||
xx_i, xx_p = int(exitp["idx"]), float(exitp["price"])
|
||
x_ex = pad_l + int((ex_i + 0.5) * plot_w / n)
|
||
x_xx = pad_l + int((xx_i + 0.5) * plot_w / n)
|
||
y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h)
|
||
y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h)
|
||
draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3)
|
||
except Exception:
|
||
pass
|
||
|
||
# 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息
|
||
if small:
|
||
draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small)
|
||
return img
|
||
|
||
|
||
def _timeframe_period_ms(tf):
|
||
s = (tf or "").strip().lower()
|
||
if s.endswith("m"):
|
||
try:
|
||
return int(s[:-1]) * 60 * 1000
|
||
except ValueError:
|
||
pass
|
||
if s.endswith("h"):
|
||
try:
|
||
return int(s[:-1]) * 3600 * 1000
|
||
except ValueError:
|
||
pass
|
||
if s.endswith("d"):
|
||
try:
|
||
return int(s[:-1]) * 86400 * 1000
|
||
except ValueError:
|
||
pass
|
||
return 300000
|
||
|
||
|
||
def _ohlcv_dict_rows_to_lists(rows, lim):
|
||
if not rows:
|
||
return []
|
||
pick = rows[-lim:] if len(rows) >= lim else rows
|
||
return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick]
|
||
|
||
|
||
def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms):
|
||
"""以 end_ts_ms 为终点向前取 K 线(无 end 则拉最近 limit 根)。"""
|
||
lim = max(2, int(limit or ORDER_CHART_LIMIT))
|
||
try:
|
||
if not end_ts_ms:
|
||
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim)
|
||
else:
|
||
period = _timeframe_period_ms(timeframe)
|
||
since = int(end_ts_ms) - period * (lim + 10)
|
||
ohlcv = exchange.fetch_ohlcv(
|
||
exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20
|
||
)
|
||
except Exception:
|
||
return []
|
||
rows = _ohlcv_to_rows(ohlcv)
|
||
if not rows:
|
||
return []
|
||
if not end_ts_ms:
|
||
return _ohlcv_dict_rows_to_lists(rows, lim)
|
||
filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)]
|
||
if len(filtered) >= 2:
|
||
return _ohlcv_dict_rows_to_lists(filtered, lim)
|
||
return _ohlcv_dict_rows_to_lists(rows, lim)
|
||
|
||
|
||
def generate_multi_timeframe_chart_png(
|
||
exchange_symbol,
|
||
title_prefix,
|
||
timeframes=None,
|
||
limit=None,
|
||
out_dir=None,
|
||
filename=None,
|
||
filename_prefix="chart",
|
||
marker_payload=None,
|
||
marker_timeframes=None,
|
||
layout="grid",
|
||
):
|
||
if not ORDER_CHART_ENABLED:
|
||
return None
|
||
if not Image:
|
||
return None
|
||
requested = list(timeframes or ORDER_CHART_TFS)
|
||
limit = limit or ORDER_CHART_LIMIT
|
||
if layout == "vertical":
|
||
timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2]
|
||
else:
|
||
preferred_layout = ["5m", "15m", "1h", "4h"]
|
||
requested_set = set(requested or [])
|
||
ordered = [tf for tf in preferred_layout if tf in requested_set]
|
||
for tf in requested:
|
||
if tf not in ordered:
|
||
ordered.append(tf)
|
||
timeframes = ordered[:4] if ordered else preferred_layout
|
||
|
||
ensure_markets_loaded()
|
||
panels = []
|
||
cell_w, cell_h = 980, 520
|
||
end_ts_ms = None
|
||
if marker_payload:
|
||
try:
|
||
end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None
|
||
except (TypeError, ValueError):
|
||
end_ts_ms = None
|
||
default_marker_tfs = {str(t).strip().lower() for t in timeframes}
|
||
price_levels = price_levels_from_marker_payload(marker_payload)
|
||
for tf in timeframes:
|
||
rows = []
|
||
try:
|
||
if layout == "vertical" and marker_payload:
|
||
win = trade_review_fetch_window(
|
||
marker_payload.get("entry_ts_ms"),
|
||
marker_payload.get("exit_ts_ms"),
|
||
tf,
|
||
limit,
|
||
anchor=marker_payload.get("chart_anchor"),
|
||
now_ms=marker_payload.get("now_ts_ms"),
|
||
)
|
||
if win:
|
||
ohlcv = exchange.fetch_ohlcv(
|
||
exchange_symbol,
|
||
timeframe=tf,
|
||
since=max(0, int(win["since_ms"])),
|
||
limit=int(win["fetch_limit"]),
|
||
)
|
||
rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win)
|
||
if not rows:
|
||
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
|
||
if not ohlcv and end_ts_ms:
|
||
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit)
|
||
rows = _ohlcv_to_rows(ohlcv)[-limit:]
|
||
except Exception:
|
||
rows = []
|
||
title = f"{title_prefix} | {tf} x{len(rows)}"
|
||
tf_key = str(tf).strip().lower()
|
||
if marker_payload:
|
||
if marker_timeframes:
|
||
marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()}
|
||
else:
|
||
marker_tfs = default_marker_tfs
|
||
else:
|
||
marker_tfs = set()
|
||
points = (
|
||
marker_points_for_timeframe(rows, marker_payload)
|
||
if marker_payload and tf_key in marker_tfs
|
||
else []
|
||
)
|
||
panels.append(
|
||
render_candles_subplot(
|
||
rows,
|
||
title,
|
||
width=cell_w,
|
||
height=cell_h,
|
||
bg_rgb=(255, 255, 255),
|
||
marker_points=points,
|
||
price_levels=price_levels,
|
||
)
|
||
)
|
||
|
||
if not panels:
|
||
return None
|
||
|
||
out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10)
|
||
if out is None:
|
||
return None
|
||
|
||
target_dir = out_dir or ORDER_CHART_DIR
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png"
|
||
out_path = os.path.join(target_dir, fname)
|
||
out.save(out_path, format="PNG")
|
||
return fname
|
||
|
||
|
||
def generate_order_open_chart(
|
||
exchange_symbol,
|
||
title_prefix,
|
||
timeframes=None,
|
||
limit=None,
|
||
opened_at_ms=None,
|
||
entry_price=None,
|
||
):
|
||
marker_payload = None
|
||
if opened_at_ms:
|
||
marker_payload = {
|
||
"entry_ts_ms": opened_at_ms,
|
||
"exit_ts_ms": None,
|
||
"entry_price": entry_price,
|
||
"exit_price": None,
|
||
}
|
||
marker_tfs = (
|
||
{x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()}
|
||
or {"5m", "15m", "1h", "4h"}
|
||
)
|
||
return generate_multi_timeframe_chart_png(
|
||
exchange_symbol,
|
||
title_prefix,
|
||
timeframes=timeframes,
|
||
limit=limit,
|
||
out_dir=ORDER_CHART_DIR,
|
||
filename=None,
|
||
filename_prefix="order",
|
||
marker_payload=marker_payload,
|
||
marker_timeframes=marker_tfs,
|
||
)
|
||
|
||
|
||
def journal_coin_from_symbol(symbol):
|
||
sym = (symbol or "").strip().upper()
|
||
if not sym:
|
||
return ""
|
||
if "/" in sym:
|
||
return sym.split("/")[0].strip()
|
||
if "-" in sym:
|
||
return sym.split("-")[0].strip()
|
||
if sym.endswith("USDT"):
|
||
return sym[:-4].strip()
|
||
return sym
|
||
|
||
|
||
EARLY_EXIT_TRIGGERS = (
|
||
"",
|
||
"止盈",
|
||
"保本止盈",
|
||
"移动止盈",
|
||
TIME_CLOSE_RESULT,
|
||
"手动平仓",
|
||
"止损",
|
||
"其他",
|
||
)
|
||
|
||
# 与用户约定的固定开仓类型
|
||
ENTRY_REASON_OPTIONS = (
|
||
"趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低",
|
||
"趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高",
|
||
"趋势多头:小分歧低吸入场(左侧),确认条件:二次探底",
|
||
"趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶",
|
||
"波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20",
|
||
"关键位箱体突破",
|
||
"关键位收敛突破",
|
||
"关键位斐波0.618",
|
||
"关键位斐波0.786",
|
||
"关键位假突破",
|
||
"关键位回调触价开仓",
|
||
"关键位突破触价开仓",
|
||
) + STRATEGY_ENTRY_REASON_OPTIONS
|
||
|
||
STATS_SEGMENT_DEFS = (
|
||
("all", "全部交易", {"segment": "all"}),
|
||
("manual", "下单监控", {"segment": "manual"}),
|
||
("key_box", "关键位箱体突破", {"segment": "key_box"}),
|
||
("key_conv", "关键位收敛结构", {"segment": "key_conv"}),
|
||
("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}),
|
||
("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}),
|
||
("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}),
|
||
("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}),
|
||
)
|
||
# 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom)
|
||
ENTRY_REASON_OTHER = "__OTHER__"
|
||
|
||
|
||
def normalize_entry_reason(raw, custom_text=None):
|
||
v = str(raw or "").strip()
|
||
if v == ENTRY_REASON_OTHER:
|
||
c = str(custom_text or "").strip()
|
||
return c[:2000] if c else ""
|
||
return v if v in ENTRY_REASON_OPTIONS else ""
|
||
|
||
|
||
def entry_reason_valid_for_storage(s):
|
||
"""允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。"""
|
||
t = str(s or "").strip()
|
||
if not t:
|
||
return True
|
||
if t == ENTRY_REASON_OTHER:
|
||
return False
|
||
if t in ENTRY_REASON_OPTIONS:
|
||
return True
|
||
return 1 <= len(t) <= 2000
|
||
|
||
|
||
def normalize_early_exit_trigger(raw):
|
||
v = str(raw or "").strip()
|
||
return v if v in EARLY_EXIT_TRIGGERS else ""
|
||
|
||
|
||
def compose_early_exit_reason_saved(trigger, note):
|
||
"""Readable single-line string stored in early_exit_reason for legacy consumers."""
|
||
t = normalize_early_exit_trigger(trigger)
|
||
n = str(note or "").strip()
|
||
if t and n:
|
||
return f"{t}|{n}"
|
||
return t or n
|
||
|
||
|
||
def journal_exit_reason_stored(trigger, note):
|
||
"""exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文。"""
|
||
t = normalize_early_exit_trigger(trigger)
|
||
n = str(note or "").strip()
|
||
if t == "手动平仓":
|
||
return n
|
||
return t
|
||
|
||
|
||
def ai_extract_journal_from_image(image_b64):
|
||
prompt = """
|
||
你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。
|
||
要求:
|
||
1) 仅输出一个 JSON 对象。
|
||
2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。
|
||
3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。
|
||
4) 允许字段为空。
|
||
5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写):
|
||
- 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低
|
||
- 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高
|
||
- 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底
|
||
- 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶
|
||
- 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20
|
||
6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):止盈、保本止盈、移动止盈、时间平仓、手动平仓、止损、其他。
|
||
7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。
|
||
8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。
|
||
|
||
JSON 字段:
|
||
{
|
||
"open_datetime": "",
|
||
"close_datetime": "",
|
||
"coin": "",
|
||
"tf": "",
|
||
"pnl": "",
|
||
"expect_rr": "",
|
||
"real_rr": "",
|
||
"entry_reason": "",
|
||
"early_exit_trigger": "",
|
||
"early_exit_note": "",
|
||
"early_exit_reason": "",
|
||
"note": ""
|
||
}
|
||
""".strip()
|
||
try:
|
||
raw = ai_generate(prompt, images_b64=[image_b64], temperature=0.1)
|
||
if raw.startswith("AI 调用失败"):
|
||
return {}
|
||
data = _extract_json_object(raw) or {}
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
trig_in = data.get("early_exit_trigger")
|
||
note_in = data.get("early_exit_note")
|
||
legacy_reason = str(data.get("early_exit_reason") or "").strip()
|
||
out = {
|
||
"open_datetime": str(data.get("open_datetime") or "").strip(),
|
||
"close_datetime": str(data.get("close_datetime") or "").strip(),
|
||
"coin": str(data.get("coin") or "").strip(),
|
||
"tf": str(data.get("tf") or "").strip(),
|
||
"pnl": str(data.get("pnl") or "").strip(),
|
||
"expect_rr": str(data.get("expect_rr") or "").strip(),
|
||
"real_rr": str(data.get("real_rr") or "").strip(),
|
||
"entry_reason": normalize_entry_reason(data.get("entry_reason")),
|
||
"early_exit_trigger": normalize_early_exit_trigger(trig_in),
|
||
"early_exit_note": str(note_in or "").strip(),
|
||
"early_exit_reason": legacy_reason,
|
||
"note": str(data.get("note") or "").strip(),
|
||
}
|
||
if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason:
|
||
out["early_exit_note"] = legacy_reason
|
||
if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason:
|
||
out["early_exit_note"] = legacy_reason
|
||
if out["early_exit_trigger"] != "手动平仓":
|
||
out["early_exit_note"] = ""
|
||
out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"])
|
||
return out
|
||
except Exception:
|
||
return None
|
||
|
||
# 初始化数据库(支持多空方向)
|
||
def init_db():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
c = conn.cursor()
|
||
|
||
# 关键位监控
|
||
c.execute('''CREATE TABLE IF NOT EXISTS key_monitors
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT,
|
||
direction TEXT DEFAULT "long", upper REAL, lower REAL,
|
||
notification_count INTEGER DEFAULT 0, last_notified_at TEXT,
|
||
max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5,
|
||
breakout_limit_pct REAL DEFAULT 1.5,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
|
||
# 订单监控(核心:加 direction 方向字段)
|
||
c.execute('''CREATE TABLE IF NOT EXISTS order_monitors
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long",
|
||
exchange_symbol TEXT,
|
||
trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL,
|
||
margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5,
|
||
trade_style TEXT DEFAULT "trend",
|
||
risk_percent REAL, risk_amount REAL,
|
||
breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL,
|
||
breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL,
|
||
notional_value REAL, position_ratio REAL, base_amount REAL,
|
||
order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT,
|
||
exchange_margin_usdt REAL,
|
||
opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT,
|
||
status TEXT DEFAULT "active")''')
|
||
|
||
# 交易记录(必须存多空)
|
||
c.execute('''CREATE TABLE IF NOT EXISTS trade_records
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT,
|
||
direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL,
|
||
margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0,
|
||
trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL,
|
||
hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER,
|
||
result TEXT, miss_reason TEXT, exchange_trade_id TEXT,
|
||
reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL,
|
||
reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER,
|
||
reviewed_at TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
|
||
c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions
|
||
(session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
|
||
c.execute('''CREATE TABLE IF NOT EXISTS journal_entries
|
||
(id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT,
|
||
coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT,
|
||
expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT,
|
||
early_exit_trigger TEXT, early_exit_note TEXT,
|
||
mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT,
|
||
new_trade_while_occupied TEXT, note TEXT, image TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
|
||
c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews
|
||
(id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
|
||
c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
|
||
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||
c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''')
|
||
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
|
||
ON transfer_logs(transfer_type, transfer_day)
|
||
WHERE transfer_type = 'auto_daily' ''')
|
||
|
||
# 给旧表加 direction 字段(兼容老数据,不报错)
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_margin_usdt REAL")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute(f"ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '{ORDER_MONITOR_TYPE_MANUAL}'")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute(
|
||
"UPDATE order_monitors SET monitor_type=? WHERE monitor_type IS NULL OR TRIM(monitor_type)=''",
|
||
(ORDER_MONITOR_TYPE_MANUAL,),
|
||
)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5")
|
||
except: pass
|
||
try:
|
||
c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5")
|
||
except: pass
|
||
for ddl in (
|
||
"ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT",
|
||
"ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL",
|
||
"ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL",
|
||
"ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL",
|
||
"ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL",
|
||
"ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL",
|
||
"ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER",
|
||
"ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'",
|
||
"ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL",
|
||
"ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0",
|
||
"ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER",
|
||
"ALTER TABLE key_monitors ADD COLUMN session_date TEXT",
|
||
):
|
||
try:
|
||
c.execute(ddl)
|
||
except Exception:
|
||
pass
|
||
ensure_time_close_schema(c)
|
||
ensure_key_monitor_schema(c)
|
||
try:
|
||
c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT")
|
||
except Exception:
|
||
pass
|
||
for ddl in (
|
||
"ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT",
|
||
"ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL",
|
||
"ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT",
|
||
"ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT",
|
||
"ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT",
|
||
"ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL",
|
||
"ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL",
|
||
):
|
||
try:
|
||
c.execute(ddl)
|
||
except Exception:
|
||
pass
|
||
|
||
c.execute(
|
||
"""CREATE TABLE IF NOT EXISTS key_monitor_history
|
||
(id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT,
|
||
upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT,
|
||
close_reason TEXT, closed_at TEXT)"""
|
||
)
|
||
|
||
from strategy_db import init_strategy_tables
|
||
|
||
init_strategy_tables(conn)
|
||
from account_risk_lib import ensure_account_risk_schema
|
||
|
||
ensure_account_risk_schema(conn)
|
||
backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
init_db()
|
||
|
||
|
||
def _purge_key_monitors_if_full_margin():
|
||
if not is_full_margin_mode(POSITION_SIZING_MODE):
|
||
return
|
||
conn = get_db()
|
||
try:
|
||
purge_disallowed_key_monitors(
|
||
conn,
|
||
sizing_mode=POSITION_SIZING_MODE,
|
||
select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(),
|
||
cancel_fib_limit=_cancel_fib_monitor_limit,
|
||
delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)),
|
||
send_wechat=send_wechat_msg,
|
||
)
|
||
conn.commit()
|
||
except Exception as e:
|
||
print(f"[full_margin] purge key monitors: {e}", flush=True)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def get_db():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.row_factory = sqlite3.Row
|
||
return conn
|
||
|
||
|
||
def hub_account_risk_status(conn):
|
||
from account_risk_lib import (
|
||
apply_position_limit_risk,
|
||
compute_account_risk_status,
|
||
enrich_risk_status_countdown,
|
||
ensure_account_risk_schema,
|
||
)
|
||
|
||
ensure_account_risk_schema(conn)
|
||
now = app_now()
|
||
st = compute_account_risk_status(
|
||
conn,
|
||
trading_day=get_trading_day(),
|
||
now=now,
|
||
fmt_local_ms=ms_to_app_local_str,
|
||
)
|
||
st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR)
|
||
from strategy_trade_labels import count_position_limit_active_monitors
|
||
|
||
return apply_position_limit_risk(
|
||
st,
|
||
count_position_limit_active_monitors(conn),
|
||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||
)
|
||
|
||
|
||
def hub_user_initiated_close(
|
||
conn,
|
||
*,
|
||
source,
|
||
count=1,
|
||
trade_record_id=None,
|
||
closed_at_ms=None,
|
||
):
|
||
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
|
||
|
||
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
|
||
on_user_initiated_close(
|
||
conn,
|
||
source=src,
|
||
trade_record_id=trade_record_id,
|
||
closed_at_ms=closed_at_ms,
|
||
trading_day=get_trading_day(),
|
||
now=app_now(),
|
||
count=count,
|
||
)
|
||
|
||
|
||
def app_now():
|
||
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
||
return datetime.now(APP_TZ).replace(tzinfo=None)
|
||
|
||
|
||
def app_now_str():
|
||
return app_now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def utc_now_dt():
|
||
"""当前时刻(UTC,aware)。"""
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def utc_calendar_date_str():
|
||
"""UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算)。"""
|
||
return utc_now_dt().strftime("%Y-%m-%d")
|
||
|
||
|
||
def get_trading_day(now=None):
|
||
"""交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」。"""
|
||
now = now or app_now()
|
||
if getattr(now, "tzinfo", None):
|
||
now = now.astimezone(APP_TZ).replace(tzinfo=None)
|
||
if now.hour < TRADING_DAY_RESET_HOUR:
|
||
return (now - timedelta(days=1)).strftime("%Y-%m-%d")
|
||
return now.strftime("%Y-%m-%d")
|
||
|
||
|
||
TRADE_COMPLETED_RESULTS = (
|
||
"止盈",
|
||
"止损",
|
||
"保本止盈",
|
||
"移动止盈",
|
||
"手动平仓",
|
||
"强制清仓",
|
||
"外部平仓",
|
||
TIME_CLOSE_RESULT,
|
||
)
|
||
|
||
REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", TIME_CLOSE_RESULT)
|
||
|
||
|
||
def parse_dt_for_trading_day(s):
|
||
if not s:
|
||
return None
|
||
s = str(s).strip().replace("Z", "").replace("T", " ")
|
||
if not s:
|
||
return None
|
||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
|
||
try:
|
||
return datetime.strptime(s[:ln], fmt)
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason):
|
||
conn.execute(
|
||
"""INSERT INTO key_monitor_history
|
||
(symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
row["symbol"],
|
||
row["monitor_type"],
|
||
row["direction"] or "long",
|
||
row["upper"],
|
||
row["lower"],
|
||
int(notification_count or 0),
|
||
(last_msg or "")[:800] if last_msg else None,
|
||
close_reason,
|
||
app_now_str(),
|
||
),
|
||
)
|
||
|
||
|
||
def _session_week_bounds(trading_day_str):
|
||
end = datetime.strptime(trading_day_str, "%Y-%m-%d").date()
|
||
start = end - timedelta(days=6)
|
||
return start.strftime("%Y-%m-%d"), trading_day_str
|
||
|
||
|
||
def _calendar_month_bounds(local_dt):
|
||
y, m = local_dt.year, local_dt.month
|
||
start = f"{y:04d}-{m:02d}-01"
|
||
if m == 12:
|
||
end_d = datetime(y, 12, 31).date()
|
||
else:
|
||
end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date()
|
||
return start, end_d.strftime("%Y-%m-%d")
|
||
|
||
|
||
def _count_opens_between(conn, start_td, end_td):
|
||
return conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?",
|
||
(start_td, end_td),
|
||
).fetchone()[0]
|
||
|
||
|
||
def _list_window_from_request():
|
||
return resolve_list_window(request.args, session, default_preset=PRESET_UTC_TODAY)
|
||
|
||
|
||
def _redirect_records():
|
||
qs = list_window_redirect_query(session)
|
||
return redirect(f"/records?{qs}" if qs else "/records")
|
||
|
||
|
||
def _pnl_row_matches_segment(row, segment_key):
|
||
try:
|
||
mt = (row["monitor_type"] or "").strip()
|
||
kst = (row["key_signal_type"] or "").strip()
|
||
except Exception:
|
||
return False
|
||
if segment_key == "all":
|
||
return True
|
||
if segment_key == "manual":
|
||
return mt == ORDER_MONITOR_TYPE_MANUAL and not kst
|
||
if segment_key == "key_box":
|
||
return kst == "箱体突破"
|
||
if segment_key == "key_conv":
|
||
return kst == "收敛突破"
|
||
if segment_key == "key_fib618":
|
||
return kst == "斐波回调0.618"
|
||
if segment_key == "key_fib786":
|
||
return kst == "斐波回调0.786"
|
||
if segment_key == "key_false_breakout":
|
||
return kst == FALSE_BREAKOUT_MONITOR_TYPE
|
||
if segment_key == "key_trigger":
|
||
return kst in TRIGGER_ENTRY_MONITOR_TYPES
|
||
return False
|
||
|
||
|
||
def _count_opens_for_segment(conn, start_td, end_td, segment_key):
|
||
if segment_key == "manual":
|
||
return conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? "
|
||
"AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') "
|
||
"AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')",
|
||
(start_td, end_td, ORDER_MONITOR_TYPE_MANUAL),
|
||
).fetchone()[0]
|
||
kst_map = {
|
||
"key_box": "箱体突破",
|
||
"key_conv": "收敛突破",
|
||
"key_fib618": "斐波回调0.618",
|
||
"key_fib786": "斐波回调0.786",
|
||
"key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE,
|
||
"key_trigger": None, # 见 _count_opens_for_segment 多类型
|
||
}
|
||
if segment_key == "key_trigger":
|
||
placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
|
||
return conn.execute(
|
||
f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? "
|
||
f"AND key_signal_type IN ({placeholders})",
|
||
(start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES),
|
||
).fetchone()[0]
|
||
kst = kst_map.get(segment_key)
|
||
if kst:
|
||
return conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?",
|
||
(start_td, end_td, kst),
|
||
).fetchone()[0]
|
||
return conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?",
|
||
(start_td, end_td),
|
||
).fetchone()[0]
|
||
|
||
|
||
def _load_completed_trade_pnls(conn):
|
||
q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at,
|
||
result, reviewed_result, monitor_type, key_signal_type
|
||
FROM trade_records
|
||
ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC"""
|
||
rows = conn.execute(q).fetchall()
|
||
out = []
|
||
for r in rows:
|
||
effective_result = (r["reviewed_result"] or r["result"] or "").strip()
|
||
if effective_result not in TRADE_COMPLETED_RESULTS:
|
||
continue
|
||
try:
|
||
p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0))
|
||
except (TypeError, ValueError):
|
||
p = 0.0
|
||
t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"])
|
||
td = get_trading_day(t) if t else None
|
||
out.append((p, t, td, r))
|
||
return out
|
||
|
||
|
||
def _compute_period_metrics(trades):
|
||
"""trades: list of (pnl, close_dt, close_trading_day)"""
|
||
trades = [(p, t, td) for p, t, td in trades if t is not None]
|
||
trades.sort(key=lambda x: x[1])
|
||
closed = len(trades)
|
||
wins = sum(1 for p, _, _ in trades if p > 0)
|
||
losses = sum(1 for p, _, _ in trades if p < 0)
|
||
net = round(sum(p for p, _, _ in trades), 2)
|
||
loss_sum_raw = sum(p for p, _, _ in trades if p < 0)
|
||
loss_sum_u = round(abs(loss_sum_raw), 2) if loss_sum_raw < 0 else 0.0
|
||
neg_pnls = [p for p, _, _ in trades if p < 0]
|
||
pos_pnls = [p for p, _, _ in trades if p > 0]
|
||
max_single_loss = round(min(neg_pnls), 2) if neg_pnls else None
|
||
max_single_profit = round(max(pos_pnls), 2) if pos_pnls else None
|
||
cum = peak = max_dd = 0.0
|
||
for p, _, _ in trades:
|
||
cum += p
|
||
peak = max(peak, cum)
|
||
max_dd = max(max_dd, peak - cum)
|
||
max_dd = round(max_dd, 2)
|
||
streak = 0
|
||
for p, _, _ in reversed(trades):
|
||
if p < 0:
|
||
streak += 1
|
||
else:
|
||
break
|
||
daily = {}
|
||
for p, _, td in trades:
|
||
if td:
|
||
daily[td] = daily.get(td, 0.0) + p
|
||
max_loss_streak_days = 0
|
||
worst_day = None
|
||
worst_day_pnl = None
|
||
if daily:
|
||
sorted_days = sorted(daily.keys())
|
||
run = 0
|
||
for d in sorted_days:
|
||
if daily[d] < 0:
|
||
run += 1
|
||
max_loss_streak_days = max(max_loss_streak_days, run)
|
||
else:
|
||
run = 0
|
||
worst_day = min(daily.keys(), key=lambda x: daily[x])
|
||
worst_day_pnl = round(daily[worst_day], 2)
|
||
win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None
|
||
return {
|
||
"closed_count": closed,
|
||
"win_count": wins,
|
||
"loss_count": losses,
|
||
"win_rate_pct": win_rate_pct,
|
||
"net_pnl_u": net,
|
||
"loss_sum_u": loss_sum_u,
|
||
"max_single_loss": max_single_loss,
|
||
"max_single_profit": max_single_profit,
|
||
"max_drawdown_u": max_dd,
|
||
"consecutive_losses": streak,
|
||
"max_loss_streak_days": max_loss_streak_days,
|
||
"worst_day": worst_day,
|
||
"worst_day_pnl": worst_day_pnl,
|
||
"opens_count": 0,
|
||
"range_label": "",
|
||
}
|
||
|
||
|
||
def compute_stats_bundle(conn, trading_day, now_dt=None):
|
||
"""日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。"""
|
||
now_dt = now_dt or app_now()
|
||
pnls = _load_completed_trade_pnls(conn)
|
||
total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0]
|
||
w_start, w_end = _session_week_bounds(trading_day)
|
||
m_start, m_end = _calendar_month_bounds(now_dt)
|
||
|
||
def in_week(tr):
|
||
return tr[2] and w_start <= tr[2] <= w_end
|
||
|
||
def in_month(tr):
|
||
return tr[2] and m_start <= tr[2] <= m_end
|
||
|
||
def slice_metrics(seg_key):
|
||
seg_rows = [tr for tr in pnls if _pnl_row_matches_segment(tr[3], seg_key)]
|
||
day_tr = [(p, t, td) for p, t, td, _r in seg_rows if td == trading_day]
|
||
week_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and w_start <= td <= w_end]
|
||
month_tr = [(p, t, td) for p, t, td, _r in seg_rows if t and m_start <= td <= m_end]
|
||
dm = _compute_period_metrics(day_tr)
|
||
wm = _compute_period_metrics(week_tr)
|
||
mm = _compute_period_metrics(month_tr)
|
||
dm["opens_count"] = _count_opens_for_segment(conn, trading_day, trading_day, seg_key)
|
||
wm["opens_count"] = _count_opens_for_segment(conn, w_start, w_end, seg_key)
|
||
mm["opens_count"] = _count_opens_for_segment(conn, m_start, m_end, seg_key)
|
||
dm["range_label"] = f"北京时间交易日 {trading_day}({TRADING_DAY_RESET_HOUR}:00 切日)"
|
||
wm["range_label"] = f"{w_start} ~ {w_end}(北京日期,近7天)"
|
||
mm["range_label"] = f"{m_start} ~ {m_end}(北京自然月)"
|
||
return dm, wm, mm
|
||
|
||
segments = []
|
||
for seg_key, seg_title, _meta in STATS_SEGMENT_DEFS:
|
||
dm, wm, mm = slice_metrics(seg_key)
|
||
segments.append({"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm})
|
||
|
||
dm, wm, mm = slice_metrics("all")
|
||
|
||
return {
|
||
"trading_day": trading_day,
|
||
"total_opens_all": total_opens_all,
|
||
"day": dm,
|
||
"week": wm,
|
||
"month": mm,
|
||
"segments": segments,
|
||
"stats_reset_hour": TRADING_DAY_RESET_HOUR,
|
||
}
|
||
|
||
|
||
def infer_leverage(symbol):
|
||
sym = (symbol or "").strip().upper()
|
||
if sym.startswith("BTC") or sym.startswith("ETH"):
|
||
return BTC_LEVERAGE
|
||
return ALT_LEVERAGE
|
||
|
||
|
||
def normalize_exchange_symbol(symbol):
|
||
sym = symbol.strip().upper()
|
||
if ":" in sym:
|
||
return sym
|
||
if "/" in sym:
|
||
base, quote = sym.split("/", 1)
|
||
quote_clean = quote.split(":")[0]
|
||
return f"{base}/{quote_clean}:{quote_clean}"
|
||
return sym
|
||
|
||
|
||
def resolve_monitor_exchange_symbol(row):
|
||
"""将监控行上的 symbol / exchange_symbol 统一到 ccxt 永续合约 symbol,便于与 fetch_positions 结果比对。"""
|
||
raw = ""
|
||
try:
|
||
if row["exchange_symbol"]:
|
||
raw = str(row["exchange_symbol"]).strip()
|
||
except (KeyError, IndexError, TypeError):
|
||
raw = ""
|
||
if not raw:
|
||
try:
|
||
raw = str(row["symbol"] or "").strip()
|
||
except (KeyError, IndexError, TypeError):
|
||
raw = ""
|
||
return normalize_exchange_symbol(raw) if raw else ""
|
||
|
||
|
||
def _position_contract_symbol_match(position_symbol, wanted_exchange_symbol):
|
||
if not position_symbol or not wanted_exchange_symbol:
|
||
return False
|
||
a = normalize_exchange_symbol(str(position_symbol).strip())
|
||
b = normalize_exchange_symbol(str(wanted_exchange_symbol).strip())
|
||
return a == b
|
||
|
||
|
||
def _position_matches_wanted_contract(wanted_unified_sym, position_dict):
|
||
"""统一 symbol 比对;不一致时用 Gate 原始 contract 与 ccxt market.id 对齐(兼容 1000PEPE 等命名差异)。"""
|
||
if not wanted_unified_sym or not position_dict:
|
||
return False
|
||
ps = position_dict.get("symbol")
|
||
if _position_contract_symbol_match(ps, wanted_unified_sym):
|
||
return True
|
||
try:
|
||
ensure_markets_loaded()
|
||
mid = (exchange.market(wanted_unified_sym).get("id") or "").strip().upper()
|
||
info = position_dict.get("info") or {}
|
||
c_raw = str(info.get("contract") or "").strip().upper()
|
||
if mid and c_raw and mid == c_raw:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _position_row_effective_contracts(p):
|
||
"""张数:优先 ccxt contracts,否则用 Gate 原始 size/pos(避免统一层为 0 时被误判空仓)。"""
|
||
if not p:
|
||
return 0.0
|
||
info = p.get("info") or {}
|
||
for val in (p.get("contracts"), info.get("size"), info.get("pos")):
|
||
if val is None or val == "":
|
||
continue
|
||
try:
|
||
x = abs(float(val))
|
||
if x > 0:
|
||
return x
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return 0.0
|
||
|
||
|
||
def normalize_symbol_input(symbol):
|
||
sym = (symbol or "").strip().upper()
|
||
if not sym:
|
||
return ""
|
||
if "/" in sym:
|
||
return sym
|
||
if ":" in sym:
|
||
sym = sym.split(":")[0]
|
||
return f"{sym}/USDT"
|
||
|
||
|
||
def normalize_kline_limit(limit_raw, default=200):
|
||
try:
|
||
n = int(limit_raw)
|
||
except Exception:
|
||
return default
|
||
return 200 if n >= 200 else 100
|
||
|
||
|
||
def get_recommended_capital(current_capital):
|
||
if current_capital <= DAILY_LOSS_CAPITAL:
|
||
return DAILY_LOSS_CAPITAL
|
||
if current_capital >= DAILY_PROFIT_CAPITAL:
|
||
return DAILY_PROFIT_CAPITAL
|
||
return DAILY_START_CAPITAL
|
||
|
||
|
||
def ensure_session(conn, session_date):
|
||
row = conn.execute(
|
||
"SELECT * FROM trading_sessions WHERE session_date = ?",
|
||
(session_date,)
|
||
).fetchone()
|
||
if row:
|
||
return row
|
||
conn.execute(
|
||
"INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)",
|
||
(session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL)
|
||
)
|
||
conn.commit()
|
||
return conn.execute(
|
||
"SELECT * FROM trading_sessions WHERE session_date = ?",
|
||
(session_date,)
|
||
).fetchone()
|
||
|
||
|
||
def update_session_capital(conn, session_date, pnl_amount):
|
||
session_row = ensure_session(conn, session_date)
|
||
new_capital = float(session_row["current_capital"]) + float(pnl_amount)
|
||
conn.execute(
|
||
"UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
|
||
(round(new_capital, 4), session_date)
|
||
)
|
||
conn.commit()
|
||
return round(new_capital, 4)
|
||
|
||
|
||
def calc_hold_seconds(opened_at_str, closed_at_dt):
|
||
try:
|
||
opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S")
|
||
return int((closed_at_dt - opened_at).total_seconds())
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
def calc_hold_minutes(seconds):
|
||
if not seconds or seconds <= 0:
|
||
return 0
|
||
return max(1, int(seconds // 60))
|
||
|
||
|
||
def get_opened_at_value(row):
|
||
try:
|
||
keys = row.keys() if hasattr(row, "keys") else []
|
||
except Exception:
|
||
keys = []
|
||
if "opened_at" in keys:
|
||
value = row["opened_at"]
|
||
if value:
|
||
return value
|
||
return app_now_str()
|
||
|
||
|
||
def get_effective_trade_field(row, reviewed_key, base_key, default=None):
|
||
try:
|
||
keys = row.keys() if hasattr(row, "keys") else row.keys()
|
||
except Exception:
|
||
keys = []
|
||
if reviewed_key in keys:
|
||
v = row[reviewed_key]
|
||
if v is not None and str(v).strip() != "":
|
||
return v
|
||
if base_key in keys:
|
||
v = row[base_key]
|
||
if v is not None and str(v).strip() != "":
|
||
return v
|
||
return default
|
||
|
||
|
||
def to_effective_trade_dict(row):
|
||
item = row_to_dict(row)
|
||
from order_monitor_display_lib import snapshot_stop_loss
|
||
|
||
open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss"))
|
||
item["display_open_stop_loss"] = open_stop
|
||
item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at"))
|
||
item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at"))
|
||
item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop)
|
||
item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit"))
|
||
item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result"))
|
||
item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason"))
|
||
item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount"))
|
||
item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes"))
|
||
item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds"))
|
||
er_eff = get_effective_trade_field(row, "reviewed_entry_reason", "entry_reason", item.get("entry_reason"))
|
||
item["effective_entry_reason"] = (str(er_eff).strip() if er_eff is not None else "") or ""
|
||
try:
|
||
_keys = row.keys() if hasattr(row, "keys") else []
|
||
except Exception:
|
||
_keys = []
|
||
_reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None
|
||
has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != ""
|
||
ex_pnl = item.get("exchange_realized_pnl")
|
||
if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "":
|
||
try:
|
||
item["effective_pnl_amount"] = round(float(ex_pnl), 2)
|
||
item["display_pnl_source"] = "exchange"
|
||
ex_open = (str(item.get("exchange_opened_at") or "").strip() or None)
|
||
ex_close = (str(item.get("exchange_closed_at") or "").strip() or None)
|
||
if ex_open:
|
||
item["effective_opened_at"] = ex_open
|
||
if ex_close:
|
||
item["effective_closed_at"] = ex_close
|
||
except (TypeError, ValueError):
|
||
item["display_pnl_source"] = "local"
|
||
elif has_reviewed_pnl:
|
||
item["display_pnl_source"] = "reviewed"
|
||
else:
|
||
item["display_pnl_source"] = "local"
|
||
item["effective_result"] = normalize_result_with_pnl(
|
||
item.get("effective_result"),
|
||
item.get("effective_pnl_amount"),
|
||
)
|
||
return item
|
||
|
||
|
||
def format_price_magnitude_fallback(value):
|
||
"""无 markets 或解析失败时的价格展示兜底(按量级)。"""
|
||
try:
|
||
v = float(value)
|
||
except Exception:
|
||
return str(value)
|
||
if v == 0:
|
||
return "0"
|
||
av = abs(v)
|
||
if av >= 10000:
|
||
d = 2
|
||
elif av >= 100:
|
||
d = 3
|
||
elif av >= 1:
|
||
d = 4
|
||
elif av >= 0.01:
|
||
d = 6
|
||
elif av >= 0.0001:
|
||
d = 8
|
||
else:
|
||
d = 10
|
||
text = f"{v:.{d}f}"
|
||
return text.rstrip("0").rstrip(".") if "." in text else text
|
||
|
||
|
||
def resolve_ccxt_price_symbol(symbol):
|
||
"""将界面/库中的品种名转为 ccxt 永续合约 id(如 BTC/USDT -> BTC/USDT:USDT)。"""
|
||
s = (symbol or "").strip()
|
||
if not s:
|
||
return ""
|
||
if "/" not in s and ":" not in s:
|
||
s = f"{s.upper()}/USDT"
|
||
else:
|
||
s = s.upper()
|
||
return normalize_exchange_symbol(s)
|
||
|
||
|
||
def round_price_to_exchange(exchange_symbol, price):
|
||
"""与交易所 tick 对齐后的 float,供入库与计算;失败时退回 float(price)。"""
|
||
if price in (None, ""):
|
||
return None
|
||
try:
|
||
v = float(price)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if not exchange_symbol:
|
||
return v
|
||
try:
|
||
ensure_markets_loaded()
|
||
s = exchange.price_to_precision(exchange_symbol, v)
|
||
return float(s)
|
||
except Exception:
|
||
return v
|
||
|
||
|
||
def format_price_for_symbol(symbol, value):
|
||
"""价格展示:与交易所 price_to_precision 一致(与入库 round_price_to_exchange 对齐)。"""
|
||
if value in (None, ""):
|
||
return "-"
|
||
try:
|
||
v = float(value)
|
||
except Exception:
|
||
return str(value)
|
||
ex = resolve_ccxt_price_symbol(symbol)
|
||
if not ex:
|
||
return format_price_magnitude_fallback(v)
|
||
try:
|
||
ensure_markets_loaded()
|
||
return exchange.price_to_precision(ex, v)
|
||
except Exception:
|
||
return format_price_magnitude_fallback(v)
|
||
|
||
|
||
def format_usdt(value):
|
||
"""USDT 资金类展示:固定两位小数。"""
|
||
if value in (None, ""):
|
||
return "-"
|
||
try:
|
||
return f"{float(value):.2f}"
|
||
except (TypeError, ValueError):
|
||
return str(value)
|
||
|
||
|
||
def format_signed_usdt(value):
|
||
"""USDT 盈亏等可正可负:+1.23 / -0.50 / 0.00"""
|
||
if value in (None, ""):
|
||
return "-"
|
||
try:
|
||
v = float(value)
|
||
except (TypeError, ValueError):
|
||
return str(value)
|
||
if v == 0:
|
||
return "0.00"
|
||
sign = "+" if v > 0 else ""
|
||
return f"{sign}{v:.2f}"
|
||
|
||
|
||
def format_wechat_scalar_2dp(value):
|
||
"""企业微信推送:数值统一两位小数(与交易所 tick 无关)。"""
|
||
if value in (None, ""):
|
||
return "-"
|
||
try:
|
||
return f"{float(value):.2f}"
|
||
except (TypeError, ValueError):
|
||
return str(value)
|
||
|
||
|
||
def format_hold_minutes(minutes):
|
||
if not minutes:
|
||
return "0分钟"
|
||
total = int(minutes)
|
||
hours = total // 60
|
||
mins = total % 60
|
||
if hours:
|
||
return f"{hours}小时{mins}分钟"
|
||
return f"{mins}分钟"
|
||
|
||
|
||
def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage):
|
||
try:
|
||
trigger = float(trigger_price)
|
||
exit_p = float(exit_price)
|
||
margin = float(margin_capital)
|
||
lev = float(leverage)
|
||
if trigger <= 0:
|
||
return 0.0
|
||
if direction == "short":
|
||
pnl_ratio = (trigger - exit_p) / trigger
|
||
else:
|
||
pnl_ratio = (exit_p - trigger) / trigger
|
||
return round(margin * lev * pnl_ratio, 4)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
|
||
def calc_rr_ratio(direction, entry_price, stop_loss, take_profit):
|
||
try:
|
||
entry = float(entry_price)
|
||
sl = float(stop_loss)
|
||
tp = float(take_profit)
|
||
if entry <= 0 or sl <= 0 or tp <= 0:
|
||
return None
|
||
if direction == "short":
|
||
risk = sl - entry
|
||
reward = entry - tp
|
||
else:
|
||
risk = entry - sl
|
||
reward = tp - entry
|
||
if risk <= 0 or reward <= 0:
|
||
return None
|
||
return round(reward / risk, 4)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def calc_risk_fraction(direction, entry_price, stop_loss):
|
||
try:
|
||
entry = float(entry_price)
|
||
sl = float(stop_loss)
|
||
if entry <= 0 or sl <= 0:
|
||
return None
|
||
if direction == "short":
|
||
risk = sl - entry
|
||
else:
|
||
risk = entry - sl
|
||
if risk <= 0:
|
||
return None
|
||
return risk / entry
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage):
|
||
rf = calc_risk_fraction(direction, entry_price, stop_loss)
|
||
if rf is None:
|
||
return None
|
||
try:
|
||
notional = float(margin_capital) * float(leverage)
|
||
if notional <= 0:
|
||
return None
|
||
return round(notional * rf, 6)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def calc_actual_rr(pnl_amount, risk_amount):
|
||
try:
|
||
r = float(risk_amount or 0)
|
||
if r <= 0:
|
||
return None
|
||
return round(float(pnl_amount or 0) / r, 4)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct):
|
||
"""
|
||
按“已锁定R”计算目标止损位:
|
||
- long: entry + locked_r * (entry*risk_fraction) + offset
|
||
- short: entry - locked_r * (entry*risk_fraction) - offset
|
||
"""
|
||
try:
|
||
entry = float(entry_price)
|
||
rf = float(risk_fraction)
|
||
lr = float(locked_r)
|
||
off = float(offset_pct) / 100.0
|
||
if entry <= 0 or rf <= 0 or lr < 0:
|
||
return None
|
||
base_move = entry * rf * lr
|
||
offset_move = entry * off
|
||
if direction == "short":
|
||
return round(entry - base_move - offset_move, 8)
|
||
return round(entry + base_move + offset_move, 8)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def insert_trade_record(
|
||
conn,
|
||
symbol,
|
||
monitor_type,
|
||
direction,
|
||
trigger_price,
|
||
stop_loss,
|
||
initial_stop_loss=None,
|
||
take_profit=None,
|
||
margin_capital=None,
|
||
leverage=None,
|
||
pnl_amount=0,
|
||
hold_seconds=0,
|
||
trade_style=None,
|
||
risk_amount=None,
|
||
planned_rr=None,
|
||
actual_rr=None,
|
||
result="",
|
||
miss_reason=None,
|
||
opened_at=None,
|
||
opened_at_ms=None,
|
||
closed_at=None,
|
||
closed_at_ms=None,
|
||
exchange_trade_id=None,
|
||
key_signal_type=None,
|
||
entry_reason=None,
|
||
trend_plan_id=None,
|
||
exchange_symbol=None,
|
||
attach_exchange_stats=True,
|
||
):
|
||
hold_minutes = calc_hold_minutes(hold_seconds)
|
||
open_ts = opened_at or app_now_str()
|
||
close_ts = closed_at or app_now_str()
|
||
open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts)
|
||
close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts)
|
||
kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES)
|
||
from order_monitor_display_lib import snapshot_stop_loss
|
||
|
||
snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss)
|
||
er = (
|
||
(entry_reason or "").strip()
|
||
or entry_reason_from_key_signal(kst)
|
||
or entry_reason_for_monitor_type(monitor_type)
|
||
or ""
|
||
)
|
||
cur = conn.execute(
|
||
"INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit,
|
||
margin_capital, leverage, pnl_amount, hold_seconds,
|
||
trade_style, risk_amount, planned_rr, actual_rr, hold_minutes,
|
||
open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None,
|
||
trend_plan_id,
|
||
)
|
||
)
|
||
tid = int(cur.lastrowid or 0)
|
||
if attach_exchange_stats and tid:
|
||
ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol)
|
||
_attach_gate_trade_exchange_stats(
|
||
conn,
|
||
tid,
|
||
exchange_symbol=ex_sym,
|
||
direction=direction,
|
||
opened_at_str=open_ts,
|
||
closed_at_str=close_ts,
|
||
opened_at_ms=open_ts_ms,
|
||
closed_at_ms=close_ts_ms,
|
||
)
|
||
return tid
|
||
|
||
|
||
def calc_duration_text(open_str, close_str):
|
||
try:
|
||
fmt = "%Y-%m-%dT%H:%M"
|
||
o = datetime.strptime(open_str, fmt)
|
||
c = datetime.strptime(close_str, fmt)
|
||
delta = c - o
|
||
seconds = int(delta.total_seconds())
|
||
if seconds <= 0:
|
||
return "0分钟"
|
||
d = seconds // 86400
|
||
h = (seconds % 86400) // 3600
|
||
m = (seconds % 3600) // 60
|
||
parts = []
|
||
if d:
|
||
parts.append(f"{d}天")
|
||
if h:
|
||
parts.append(f"{h}小时")
|
||
if m or not parts:
|
||
parts.append(f"{m}分钟")
|
||
return " ".join(parts)
|
||
except Exception:
|
||
return "计算失败"
|
||
|
||
|
||
def row_to_dict(row):
|
||
return {k: row[k] for k in row.keys()}
|
||
|
||
|
||
def enrich_order_item(raw_item, current_capital):
|
||
item = dict(raw_item or {})
|
||
margin = float(item.get("margin_capital") or 0)
|
||
lev = float(item.get("leverage") or 0)
|
||
notional = item.get("notional_value")
|
||
ratio = item.get("position_ratio")
|
||
if notional is None:
|
||
notional = round(margin * lev, 2) if margin and lev else 0
|
||
if ratio is None:
|
||
ratio = round(margin / current_capital * 100, 2) if current_capital else 0
|
||
item["notional_value"] = notional
|
||
item["position_ratio"] = ratio
|
||
enrich_order_display_fields(item, calc_rr_ratio)
|
||
try:
|
||
be = item.get("breakeven_enabled")
|
||
item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1
|
||
except Exception:
|
||
item["breakeven_enabled"] = 1
|
||
return apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL)
|
||
|
||
|
||
def ensure_exchange_live_ready():
|
||
if not LIVE_TRADING_ENABLED:
|
||
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
|
||
if not (GATE_API_KEY and GATE_API_SECRET):
|
||
return False, "缺少 Gate API 密钥配置(GATE_API_KEY / GATE_API_SECRET)"
|
||
return True, ""
|
||
|
||
|
||
def order_row_monitor_type(row):
|
||
return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL)
|
||
|
||
|
||
def trade_record_monitor_type(conn, row):
|
||
return resolve_trade_record_monitor_type(
|
||
conn, row, default_manual=ORDER_MONITOR_TYPE_MANUAL
|
||
)
|
||
|
||
|
||
def order_row_key_signal_type(row):
|
||
if row is None:
|
||
return None
|
||
try:
|
||
keys = row.keys() if hasattr(row, "keys") else []
|
||
except Exception:
|
||
keys = []
|
||
if "key_signal_type" not in keys:
|
||
return None
|
||
kst = (row["key_signal_type"] or "").strip()
|
||
if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst) or is_false_breakout_key_monitor_type(kst):
|
||
return kst
|
||
return None
|
||
|
||
|
||
def exchange_private_api_configured():
|
||
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等。"""
|
||
return bool(GATE_API_KEY and GATE_API_SECRET)
|
||
|
||
|
||
def _extract_usdt_total(balance):
|
||
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
|
||
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
|
||
free_map = balance.get("free", {}) if isinstance(balance, dict) else {}
|
||
total = usdt_info.get("total")
|
||
if total is None:
|
||
total = usdt_info.get("equity")
|
||
if total is None:
|
||
total = total_map.get("USDT")
|
||
if total is None:
|
||
total = usdt_info.get("free")
|
||
if total is None:
|
||
total = free_map.get("USDT")
|
||
try:
|
||
return float(total) if total is not None else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _extract_usdt_free(balance):
|
||
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
|
||
free_map = balance.get("free", {}) if isinstance(balance, dict) else {}
|
||
free = usdt_info.get("free")
|
||
if free is None:
|
||
free = free_map.get("USDT")
|
||
try:
|
||
return float(free) if free is not None else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _parse_usdt_from_gate_unified_accounts_body(data):
|
||
"""
|
||
解析 Gate GET /unified/accounts 响应体中的 USDT(dict 或 list 形态的 balances 均支持)。
|
||
ccxt fetch_balance(unifiedAccount) 在 balances 为数组时会访问 .keys() 崩溃,故资金兜底走此解析。
|
||
"""
|
||
if not isinstance(data, dict):
|
||
return None
|
||
raw_fd = data.get("funding")
|
||
if isinstance(raw_fd, (int, float)):
|
||
return float(raw_fd)
|
||
if isinstance(raw_fd, str) and raw_fd.strip():
|
||
try:
|
||
return float(raw_fd)
|
||
except Exception:
|
||
pass
|
||
if isinstance(raw_fd, dict):
|
||
u = raw_fd.get("USDT") or raw_fd.get("usdt")
|
||
if isinstance(u, dict):
|
||
for k in ("equity", "available", "total", "amount"):
|
||
v = u.get(k)
|
||
if v is not None:
|
||
try:
|
||
return float(v)
|
||
except Exception:
|
||
pass
|
||
|
||
balances = data.get("balances")
|
||
if isinstance(balances, list):
|
||
for row in balances:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
sym = str(row.get("currency") or row.get("asset") or row.get("name") or "").upper()
|
||
if sym != "USDT":
|
||
continue
|
||
for k in ("equity", "balance", "available", "total", "amount"):
|
||
v = row.get(k)
|
||
if v is not None:
|
||
try:
|
||
return float(v)
|
||
except Exception:
|
||
pass
|
||
elif isinstance(balances, dict):
|
||
u = balances.get("USDT") or balances.get("usdt")
|
||
if isinstance(u, dict):
|
||
for k in ("equity", "available", "total", "amount"):
|
||
v = u.get(k)
|
||
if v is not None:
|
||
try:
|
||
return float(v)
|
||
except Exception:
|
||
pass
|
||
|
||
tb = data.get("total_balance")
|
||
if isinstance(tb, dict):
|
||
u = tb.get("USDT") or tb.get("usdt")
|
||
if isinstance(u, (int, float, str)):
|
||
try:
|
||
return float(u)
|
||
except Exception:
|
||
pass
|
||
if isinstance(u, dict):
|
||
for k in ("equity", "available", "amount", "total"):
|
||
val = u.get(k)
|
||
if val is not None:
|
||
try:
|
||
return float(val)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _parse_gate_spot_accounts_response_usdt(response):
|
||
"""解析 GET /spot/accounts 列表中的 USDT(与 fetch_balance spot 同源,ccxt 解析失败时可兜底)。"""
|
||
rows = None
|
||
if isinstance(response, list):
|
||
rows = response
|
||
elif isinstance(response, dict):
|
||
inner = response.get("result")
|
||
if isinstance(inner, list):
|
||
rows = inner
|
||
elif isinstance(inner, dict) and isinstance(inner.get("list"), list):
|
||
rows = inner["list"]
|
||
if not rows:
|
||
return None
|
||
for row in rows:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
if str(row.get("currency") or "").upper() != "USDT":
|
||
continue
|
||
ts = row.get("total")
|
||
if ts is not None and str(ts).strip() != "":
|
||
try:
|
||
return float(ts)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
return float(row.get("available") or 0) + float(row.get("locked") or 0)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _fetch_usdt_by_types(type_candidates):
|
||
"""统一只用 ccxt.fetch_balance;spot 必须带 marginMode=spot,否则会随 defaultMarginMode 误走 cross_margin。"""
|
||
for t in type_candidates:
|
||
try:
|
||
params = {"type": t}
|
||
if t == "spot":
|
||
params["marginMode"] = "spot"
|
||
bal = exchange.fetch_balance(params=params)
|
||
val = _extract_usdt_total(bal)
|
||
if val is not None:
|
||
return val
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _fetch_gate_funding_usdt():
|
||
"""
|
||
Gate「资金账户」:
|
||
1) fetch_balance(type=spot, marginMode=spot) — 避免 defaultMarginMode=cross 误走 cross_margin;
|
||
2) privateSpotGetAccounts — 与 1 同源,ccxt 聚合异常或解析不到 USDT 时再试原始列表;
|
||
3) privateUnifiedGetAccounts + 自解析 — 统一账户 balances 常为数组,ccxt unified fetch_balance 会崩。
|
||
"""
|
||
spot_seen_ok = False
|
||
try:
|
||
ensure_markets_loaded()
|
||
bal = exchange.fetch_balance(params={"type": "spot", "marginMode": "spot"})
|
||
spot_seen_ok = True
|
||
val = _extract_usdt_total(bal)
|
||
if val is not None:
|
||
return float(val)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
resp = exchange.privateSpotGetAccounts({})
|
||
v = _parse_gate_spot_accounts_response_usdt(resp)
|
||
if v is not None:
|
||
return float(v)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
raw = exchange.privateUnifiedGetAccounts({})
|
||
body = raw
|
||
if isinstance(body, dict) and isinstance(body.get("result"), dict):
|
||
body = body["result"]
|
||
v = _parse_usdt_from_gate_unified_accounts_body(body) if isinstance(body, dict) else None
|
||
if v is not None:
|
||
return float(v)
|
||
except Exception:
|
||
pass
|
||
|
||
if spot_seen_ok:
|
||
return 0.0
|
||
return None
|
||
|
||
|
||
def get_available_trading_usdt():
|
||
ok_live, _ = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return None
|
||
for t in ["swap", "spot"]:
|
||
try:
|
||
params = {"type": t}
|
||
if t == "spot":
|
||
params["marginMode"] = "spot"
|
||
bal = exchange.fetch_balance(params=params)
|
||
free_val = _extract_usdt_free(bal)
|
||
if free_val is not None:
|
||
return free_val
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def get_synced_leverage(exchange_symbol, direction):
|
||
ensure_markets_loaded()
|
||
try:
|
||
positions = exchange.fetch_positions([exchange_symbol])
|
||
for p in positions:
|
||
if not _position_matches_wanted_contract(exchange_symbol, p):
|
||
continue
|
||
info = p.get("info", {}) or {}
|
||
side = (p.get("side") or info.get("posSide") or "").lower()
|
||
if GATE_POS_MODE == "hedge" and side and side != direction:
|
||
continue
|
||
lev = p.get("leverage")
|
||
if lev is None or lev == 0 or str(lev) == "0":
|
||
lev = info.get("cross_leverage_limit") or info.get("leverage")
|
||
if lev:
|
||
try:
|
||
return int(float(lev))
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def friendly_exchange_error(err, available_usdt=None):
|
||
msg = str(err)
|
||
low = msg.lower()
|
||
if (
|
||
"51008" in msg
|
||
or "insufficient" in low
|
||
or "margin" in low and ("not enough" in low or "不足" in msg)
|
||
or "balance" in low and "insufficient" in low
|
||
):
|
||
tail = f"(当前交易账户可用约 {round(available_usdt, 2)}U)" if available_usdt is not None else ""
|
||
return f"交易所下单失败:保证金不足 {tail}。请降低保证金/杠杆,或先划转USDT到合约账户。"
|
||
clean = re.sub(r"\s+", " ", msg).strip()
|
||
return f"交易所下单失败:{clean}"
|
||
|
||
|
||
def get_exchange_capitals(force=False):
|
||
ok_live, _ = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return None, None
|
||
now_ts = time.time()
|
||
if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS:
|
||
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
|
||
try:
|
||
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt()
|
||
except Exception:
|
||
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
|
||
try:
|
||
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"])
|
||
except Exception:
|
||
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
|
||
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
|
||
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
|
||
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
|
||
|
||
|
||
def execute_transfer_usdt(amount, from_account, to_account):
|
||
from gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt
|
||
|
||
return _gate_execute_transfer_usdt(
|
||
exchange,
|
||
amount,
|
||
from_account,
|
||
to_account,
|
||
transfer_ccy=TRANSFER_CCY,
|
||
ensure_live_ready=ensure_exchange_live_ready,
|
||
ensure_markets_loaded=ensure_markets_loaded,
|
||
)
|
||
|
||
|
||
def get_account_usdt_total(account_type):
|
||
"""读取各账户 USDT。funding 走 _fetch_gate_funding_usdt;spot 同样 marginMode=spot,一律 ccxt。"""
|
||
raw = (account_type or "").strip().lower()
|
||
if raw == "funding":
|
||
return _fetch_gate_funding_usdt()
|
||
at = raw
|
||
try:
|
||
params = {"type": at}
|
||
if at == "spot":
|
||
params["marginMode"] = "spot"
|
||
bal = exchange.fetch_balance(params=params)
|
||
val = _extract_usdt_total(bal)
|
||
if val is not None:
|
||
return val
|
||
return 0.0 if at == "spot" else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _auto_transfer_active_count(conn):
|
||
from gate_transfer_lib import count_auto_transfer_blockers
|
||
|
||
return count_auto_transfer_blockers(conn, count_order_monitors=get_active_position_count)
|
||
|
||
|
||
def auto_transfer_once_per_day():
|
||
run_auto_transfer_once_per_day(
|
||
enabled=AUTO_TRANSFER_ENABLED,
|
||
bj_hour=AUTO_TRANSFER_BJ_HOUR,
|
||
target_amount=AUTO_TRANSFER_AMOUNT,
|
||
from_account=AUTO_TRANSFER_FROM,
|
||
to_account=AUTO_TRANSFER_TO,
|
||
funds_decimals=2,
|
||
get_db=get_db,
|
||
get_active_position_count=_auto_transfer_active_count,
|
||
get_account_usdt_total=get_account_usdt_total,
|
||
execute_transfer_usdt=execute_transfer_usdt,
|
||
send_wechat_msg=send_wechat_msg,
|
||
utc_now_dt=utc_now_dt,
|
||
app_tz=APP_TZ,
|
||
utc_calendar_date_str=utc_calendar_date_str,
|
||
app_now_str=app_now_str,
|
||
)
|
||
|
||
|
||
def trading_day_reset_allows_new_open(now):
|
||
"""是否允许在满足其它风控的前提下于当前时刻新开仓(仅「整点前禁开」守卫)。"""
|
||
if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED:
|
||
return True
|
||
return now.hour >= TRADING_DAY_RESET_HOUR
|
||
|
||
|
||
def get_active_position_count(conn):
|
||
return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0])
|
||
|
||
|
||
def clear_key_sizing_snapshot_if_flat(conn, session_date):
|
||
if get_active_position_count(conn) > 0:
|
||
return
|
||
conn.execute(
|
||
"UPDATE trading_sessions SET key_sizing_capital_snapshot = NULL, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
|
||
(session_date,),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def get_key_sizing_capital_snapshot(conn, session_date):
|
||
row = ensure_session(conn, session_date)
|
||
try:
|
||
val = row["key_sizing_capital_snapshot"]
|
||
except (KeyError, IndexError):
|
||
return None
|
||
if val is None:
|
||
return None
|
||
try:
|
||
return float(val)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def set_key_sizing_capital_snapshot(conn, session_date, capital):
|
||
ensure_session(conn, session_date)
|
||
conn.execute(
|
||
"UPDATE trading_sessions SET key_sizing_capital_snapshot = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?",
|
||
(round(float(capital), 2), session_date),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
|
||
live = float(live_capital)
|
||
active = get_active_position_count(conn)
|
||
if active <= 0:
|
||
set_key_sizing_capital_snapshot(conn, trading_day, live)
|
||
return live
|
||
if KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT:
|
||
snap = get_key_sizing_capital_snapshot(conn, trading_day)
|
||
if snap is not None and snap > 0:
|
||
return snap
|
||
return live
|
||
|
||
|
||
def precheck_risk(conn, symbol, direction):
|
||
now = app_now()
|
||
from account_risk_lib import account_risk_blocks_trading
|
||
|
||
ok_risk, risk_reason = account_risk_blocks_trading(
|
||
conn,
|
||
trading_day=get_trading_day(now),
|
||
now=now,
|
||
fmt_local_ms=ms_to_app_local_str,
|
||
)
|
||
if not ok_risk:
|
||
return False, risk_reason
|
||
if not trading_day_reset_allows_new_open(now):
|
||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||
from account_risk_lib import position_limit_reached
|
||
|
||
reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS)
|
||
if reached:
|
||
return False, f"已达最大持仓数({active_count}/{mx})"
|
||
ok_daily, daily_reason, _opens = check_daily_open_hard_limit(
|
||
conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR
|
||
)
|
||
if not ok_daily:
|
||
return False, daily_reason
|
||
if direction not in ("long", "short"):
|
||
return False, "方向必须为 long 或 short"
|
||
if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"):
|
||
expected = BTC_LEVERAGE
|
||
else:
|
||
expected = ALT_LEVERAGE
|
||
if expected <= 0:
|
||
return False, "杠杆配置异常"
|
||
return True, ""
|
||
|
||
|
||
def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price):
|
||
ensure_markets_loaded()
|
||
notional = float(margin_capital) * float(leverage)
|
||
ticker = exchange.fetch_ticker(exchange_symbol)
|
||
price = float(ticker.get("last") or fallback_price)
|
||
if price <= 0:
|
||
raise ValueError("触发价必须大于 0")
|
||
market = exchange.market(exchange_symbol)
|
||
contract_size = float(market.get("contractSize") or 1)
|
||
if market.get("contract"):
|
||
# 合约 amount 按张数/合约乘数解析;ccxt 会再做精度与符号处理
|
||
amount = notional / (price * contract_size)
|
||
else:
|
||
amount = notional / price
|
||
min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min")
|
||
if min_amount and amount < float(min_amount):
|
||
raise ValueError(f"下单数量过小,最小数量为 {min_amount}")
|
||
amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount))
|
||
if amount_precise <= 0:
|
||
raise ValueError("下单数量精度后为 0,请提高基数或降低价格")
|
||
return amount_precise, price
|
||
|
||
|
||
def _to_positive_float(value):
|
||
try:
|
||
n = float(value)
|
||
return n if n > 0 else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _extract_order_price_value(order_obj):
|
||
if not isinstance(order_obj, dict):
|
||
return None
|
||
for key in ("average", "price"):
|
||
v = _to_positive_float(order_obj.get(key))
|
||
if v is not None:
|
||
return v
|
||
cost = _to_positive_float(order_obj.get("cost"))
|
||
filled = _to_positive_float(order_obj.get("filled"))
|
||
if cost is not None and filled is not None and filled > 0:
|
||
return cost / filled
|
||
info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {}
|
||
for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"):
|
||
v = _to_positive_float(info.get(key))
|
||
if v is not None:
|
||
return v
|
||
return None
|
||
|
||
|
||
def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price):
|
||
price = _extract_order_price_value(order_resp)
|
||
if price is not None:
|
||
return round(price, 8)
|
||
order_id = (order_resp or {}).get("id")
|
||
if order_id:
|
||
try:
|
||
fetched = exchange.fetch_order(order_id, exchange_symbol)
|
||
fetched_price = _extract_order_price_value(fetched)
|
||
if fetched_price is not None:
|
||
return round(fetched_price, 8)
|
||
except Exception:
|
||
pass
|
||
fallback = _to_positive_float(fallback_price)
|
||
return round(fallback, 8) if fallback is not None else 0.0
|
||
|
||
|
||
def get_contract_size(exchange_symbol):
|
||
ensure_markets_loaded()
|
||
market = exchange.market(exchange_symbol)
|
||
return float(market.get("contractSize") or 1)
|
||
|
||
|
||
def parse_positive_float(value):
|
||
if value is None:
|
||
return None
|
||
raw = str(value).strip()
|
||
if not raw:
|
||
return None
|
||
num = float(raw)
|
||
if num <= 0:
|
||
raise ValueError("数值必须大于0")
|
||
return num
|
||
|
||
|
||
def build_gate_order_params(direction, reduce_only=False):
|
||
params = {}
|
||
if reduce_only:
|
||
params["reduceOnly"] = True
|
||
return params
|
||
|
||
|
||
def _gate_contracts_amount_for_tpsl(order, fallback_amount):
|
||
for key in ("filled", "amount"):
|
||
v = order.get(key)
|
||
try:
|
||
fv = float(v)
|
||
if fv > 0:
|
||
return fv
|
||
except Exception:
|
||
pass
|
||
return float(fallback_amount)
|
||
|
||
|
||
def _gate_clamp_tpsl_to_last_price(exchange_symbol, direction, stop_loss, take_profit, *, sl_only=False):
|
||
"""
|
||
Gate price_orders 规则:空仓止损/多仓止盈 trigger>last;空仓止盈/多仓止损 trigger<last。
|
||
计划价可能已穿过现价时,按最小间距自动微调并返回说明。
|
||
"""
|
||
ensure_markets_loaded()
|
||
last = get_price(exchange_symbol)
|
||
if last is None or float(last) <= 0:
|
||
return float(stop_loss), float(take_profit), None
|
||
last = float(last)
|
||
sl = float(stop_loss)
|
||
tp = float(take_profit)
|
||
gap = max(0.0, float(GATE_TPSL_LAST_PRICE_GAP_PCT)) / 100.0
|
||
if gap <= 0:
|
||
gap = 0.0005
|
||
notes = []
|
||
direction = (direction or "long").strip().lower()
|
||
if direction == "short":
|
||
if sl <= last:
|
||
sl = float(exchange.price_to_precision(exchange_symbol, last * (1 + gap)))
|
||
notes.append(f"止损触发价须高于现价 {last},已调整为 {sl}")
|
||
if not sl_only and tp >= last:
|
||
tp = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap)))
|
||
notes.append(f"止盈触发价须低于现价 {last},已调整为 {tp}")
|
||
else:
|
||
if sl >= last:
|
||
sl = float(exchange.price_to_precision(exchange_symbol, last * (1 - gap)))
|
||
notes.append(f"止损触发价须低于现价 {last},已调整为 {sl}")
|
||
if not sl_only and tp <= last:
|
||
tp = float(exchange.price_to_precision(exchange_symbol, last * (1 + gap)))
|
||
notes.append(f"止盈触发价须高于现价 {last},已调整为 {tp}")
|
||
return sl, tp, (";".join(notes) if notes else None)
|
||
|
||
|
||
def _gate_place_tp_sl_orders_legacy_conditional(exchange_symbol, direction, contracts_amount, stop_loss, take_profit):
|
||
"""ccxt 市价减仓条件单(两张单分别带 stopLossPrice / takeProfitPrice),与官方仓位类触发单等价逻辑不同路径。"""
|
||
ensure_markets_loaded()
|
||
close_side = "sell" if direction == "long" else "buy"
|
||
base = {"reduceOnly": True}
|
||
last_err = None
|
||
for attempt in range(8):
|
||
try:
|
||
exchange.create_order(
|
||
exchange_symbol, "market", close_side, contracts_amount, None,
|
||
dict(base, stopLossPrice=float(stop_loss)),
|
||
)
|
||
exchange.create_order(
|
||
exchange_symbol, "market", close_side, contracts_amount, None,
|
||
dict(base, takeProfitPrice=float(take_profit)),
|
||
)
|
||
return
|
||
except Exception as e:
|
||
last_err = e
|
||
time.sleep(0.2 * (attempt + 1))
|
||
raise RuntimeError(f"交易所未接受条件止盈/止损委托参数:{last_err}")
|
||
|
||
|
||
def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit):
|
||
"""
|
||
Gate 永续官方仓位类触发单:POST futures/{settle}/price_orders,
|
||
order_type=close-long-position / close-short-position,单向全平 close+size=0;双向需 auto_size。
|
||
与 App 内展示的「条件委托」一致,平仓后仍需 cancel_gate_swap_trigger_orders 避免残留。
|
||
"""
|
||
stop_loss, take_profit, _ = _gate_clamp_tpsl_to_last_price(
|
||
exchange_symbol, direction, stop_loss, take_profit
|
||
)
|
||
ensure_markets_loaded()
|
||
market = exchange.market(exchange_symbol)
|
||
if not market.get("swap"):
|
||
raise RuntimeError("仅支持永续合约 symbol")
|
||
settle = market["settleId"]
|
||
contract = market["id"]
|
||
order_type = "close-long-position" if direction == "long" else "close-short-position"
|
||
close_side = "sell" if direction == "long" else "buy"
|
||
if close_side == "sell":
|
||
sl_rule, tp_rule = 2, 1
|
||
else:
|
||
sl_rule, tp_rule = 1, 2
|
||
initial = {
|
||
"contract": contract,
|
||
"size": 0,
|
||
"price": "0",
|
||
"close": True,
|
||
"reduce_only": True,
|
||
"tif": "ioc",
|
||
"text": "api",
|
||
}
|
||
if GATE_POS_MODE == "hedge":
|
||
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
|
||
# Gate API 1018:auto_size=close_long|close_short 时 initial.close 须为 false
|
||
initial["close"] = False
|
||
sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss))
|
||
tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit))
|
||
|
||
def _payload(trigger_price, rule):
|
||
trig = {
|
||
"strategy_type": 0,
|
||
"price_type": GATE_TPSL_PRICE_TYPE,
|
||
"price": trigger_price,
|
||
"rule": rule,
|
||
}
|
||
if GATE_TPSL_TRIGGER_EXPIRATION > 0:
|
||
trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION
|
||
return {
|
||
"settle": settle,
|
||
"initial": dict(initial),
|
||
"trigger": trig,
|
||
"order_type": order_type,
|
||
}
|
||
|
||
last_err = None
|
||
for attempt in range(8):
|
||
try:
|
||
exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule))
|
||
try:
|
||
exchange.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule))
|
||
except Exception:
|
||
cancel_gate_swap_trigger_orders(exchange_symbol)
|
||
raise
|
||
return
|
||
except Exception as e:
|
||
last_err = e
|
||
time.sleep(0.2 * (attempt + 1))
|
||
raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}")
|
||
|
||
|
||
def _gate_td_mode_is_cross():
|
||
return _GATE_DEFAULT_MARGIN_MODE == "cross"
|
||
|
||
|
||
def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit):
|
||
pos_err = None
|
||
if GATE_TPSL_USE_POSITION_ORDER:
|
||
try:
|
||
_gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit)
|
||
return
|
||
except Exception as e:
|
||
pos_err = e
|
||
if _gate_td_mode_is_cross():
|
||
raise RuntimeError(
|
||
f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}"
|
||
) from e
|
||
try:
|
||
_gate_place_tp_sl_orders_legacy_conditional(
|
||
exchange_symbol, direction, contracts_amount, stop_loss, take_profit,
|
||
)
|
||
except Exception as legacy_err:
|
||
if pos_err is not None:
|
||
raise RuntimeError(
|
||
f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}"
|
||
) from legacy_err
|
||
raise
|
||
|
||
|
||
def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss):
|
||
"""Gate 永续:仅挂仓位类止损触发单(趋势回调用)。"""
|
||
stop_loss, _, _ = _gate_clamp_tpsl_to_last_price(
|
||
exchange_symbol, direction, stop_loss, stop_loss, sl_only=True
|
||
)
|
||
ensure_markets_loaded()
|
||
market = exchange.market(exchange_symbol)
|
||
if not market.get("swap"):
|
||
raise RuntimeError("仅支持永续合约 symbol")
|
||
settle = market["settleId"]
|
||
contract = market["id"]
|
||
order_type = "close-long-position" if direction == "long" else "close-short-position"
|
||
close_side = "sell" if direction == "long" else "buy"
|
||
sl_rule = 2 if close_side == "sell" else 1
|
||
initial = {
|
||
"contract": contract,
|
||
"size": 0,
|
||
"price": "0",
|
||
"close": True,
|
||
"reduce_only": True,
|
||
"tif": "ioc",
|
||
"text": "api",
|
||
}
|
||
if GATE_POS_MODE == "hedge":
|
||
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
|
||
initial["close"] = False
|
||
sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss))
|
||
|
||
def _payload(trigger_price, rule):
|
||
trig = {
|
||
"strategy_type": 0,
|
||
"price_type": GATE_TPSL_PRICE_TYPE,
|
||
"price": trigger_price,
|
||
"rule": rule,
|
||
}
|
||
if GATE_TPSL_TRIGGER_EXPIRATION > 0:
|
||
trig["expiration"] = GATE_TPSL_TRIGGER_EXPIRATION
|
||
return {
|
||
"settle": settle,
|
||
"initial": dict(initial),
|
||
"trigger": trig,
|
||
"order_type": order_type,
|
||
}
|
||
|
||
last_err = None
|
||
for attempt in range(8):
|
||
try:
|
||
exchange.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule))
|
||
return
|
||
except Exception as e:
|
||
last_err = e
|
||
time.sleep(0.2 * (attempt + 1))
|
||
raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}")
|
||
|
||
|
||
def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
|
||
try:
|
||
e = float(entry_price)
|
||
pct = float(
|
||
offset_pct
|
||
if offset_pct is not None
|
||
else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3"))
|
||
)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if e <= 0:
|
||
return None
|
||
direction = (direction or "long").strip().lower()
|
||
if direction == "short":
|
||
return e * (1.0 - pct / 100.0)
|
||
return e * (1.0 + pct / 100.0)
|
||
|
||
|
||
def ensure_markets_loaded(force=False):
|
||
global MARKETS_LOADED
|
||
if force or not MARKETS_LOADED:
|
||
exchange.load_markets(reload=force)
|
||
MARKETS_LOADED = True
|
||
|
||
|
||
def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None):
|
||
ensure_markets_loaded()
|
||
exchange.set_leverage(leverage, exchange_symbol)
|
||
side = "buy" if direction == "long" else "sell"
|
||
params = build_gate_order_params(direction, reduce_only=False)
|
||
order = exchange.create_order(exchange_symbol, "market", side, amount, None, params)
|
||
order.setdefault("tpsl_attached", False)
|
||
if stop_loss and take_profit:
|
||
try:
|
||
contracts_amt = _gate_contracts_amount_for_tpsl(order, amount)
|
||
_gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amt, stop_loss, take_profit)
|
||
order["tpsl_attached"] = True
|
||
except RuntimeError:
|
||
raise
|
||
except Exception as e:
|
||
raise RuntimeError(f"交易所未接受条件止盈/止损委托,已拒绝开仓:{str(e)}") from e
|
||
return order
|
||
|
||
|
||
def close_exchange_order(order_row):
|
||
ensure_markets_loaded()
|
||
exchange_symbol = order_row["exchange_symbol"] or normalize_exchange_symbol(order_row["symbol"])
|
||
amount = float(order_row["order_amount"] or 0)
|
||
if amount <= 0:
|
||
raise ValueError("平仓失败:缺少有效下单数量")
|
||
direction = order_row["direction"]
|
||
side = "sell" if direction == "long" else "buy"
|
||
params = build_gate_order_params(direction, reduce_only=True)
|
||
return exchange.create_order(exchange_symbol, "market", side, amount, None, params)
|
||
|
||
|
||
def _gate_swap_trigger_order_params():
|
||
"""永续条件单(止盈/止损触发委托)查询/撤销用的 ccxt 参数。"""
|
||
p = {"type": "swap", "trigger": True}
|
||
try:
|
||
exchange.load_unified_status()
|
||
if exchange.options.get("unifiedAccount"):
|
||
p["unifiedAccount"] = True
|
||
except Exception:
|
||
pass
|
||
return p
|
||
|
||
|
||
def cancel_gate_swap_trigger_orders(exchange_symbol):
|
||
"""
|
||
仓位已平时撤销该合约下剩余的永续条件委托(trigger / price_orders),避免孤儿单残留。
|
||
与 App 内「仓位附带止盈止损」不同,本系统挂的是独立触发单,平仓后交易所未必自动撤。
|
||
"""
|
||
ok, _ = ensure_exchange_live_ready()
|
||
if not ok or not exchange_symbol:
|
||
return
|
||
ensure_markets_loaded()
|
||
params = _gate_swap_trigger_order_params()
|
||
sym = exchange_symbol
|
||
try:
|
||
exchange.cancel_all_orders(sym, params)
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
pending = exchange.fetch_open_orders(sym, params=params)
|
||
except Exception:
|
||
return
|
||
for o in pending or []:
|
||
oid = o.get("id")
|
||
if oid is None:
|
||
continue
|
||
try:
|
||
exchange.cancel_order(str(oid), sym, params)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _gate_list_trigger_open_orders(exchange_symbol):
|
||
params = _gate_swap_trigger_order_params()
|
||
try:
|
||
return exchange.fetch_open_orders(exchange_symbol, params=params) or []
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _gate_order_trigger_price(order):
|
||
for key in ("stopPrice", "triggerPrice", "price"):
|
||
try:
|
||
v = float(order.get(key) or 0)
|
||
if v > 0:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
info = order.get("info") or {}
|
||
if isinstance(info, dict):
|
||
trig = info.get("trigger")
|
||
if isinstance(trig, dict):
|
||
try:
|
||
v = float(trig.get("price") or 0)
|
||
if v > 0:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
for key in ("trigger_price", "triggerPrice", "stopPrice", "price"):
|
||
try:
|
||
v = float(info.get(key) or 0)
|
||
if v > 0:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _gate_tpsl_role_from_order(order, direction):
|
||
info = order.get("info") or {}
|
||
if not isinstance(info, dict):
|
||
info = {}
|
||
ot = str(info.get("order_type") or info.get("orderType") or order.get("type") or "").lower()
|
||
if "take" in ot and "profit" in ot:
|
||
return "tp"
|
||
if "stop" in ot and "loss" in ot:
|
||
return "sl"
|
||
trig = info.get("trigger")
|
||
rule = None
|
||
if isinstance(trig, dict) and trig.get("rule") is not None:
|
||
try:
|
||
rule = int(trig["rule"])
|
||
except Exception:
|
||
rule = None
|
||
if rule is None:
|
||
try:
|
||
rule = int(info.get("rule"))
|
||
except Exception:
|
||
rule = None
|
||
if rule is not None:
|
||
if direction == "long":
|
||
return "sl" if rule == 2 else ("tp" if rule == 1 else None)
|
||
return "sl" if rule == 1 else ("tp" if rule == 2 else None)
|
||
if order.get("stopLossPrice"):
|
||
return "sl"
|
||
if order.get("takeProfitPrice"):
|
||
return "tp"
|
||
typ = str(order.get("type") or "").upper()
|
||
if "TAKE" in typ:
|
||
return "tp"
|
||
if "STOP" in typ:
|
||
return "sl"
|
||
return None
|
||
|
||
|
||
def _gate_tpsl_slot_from_order(order, exchange_symbol):
|
||
trig = _gate_order_trigger_price(order)
|
||
try:
|
||
amt = float(order.get("amount") or order.get("remaining") or 0)
|
||
except Exception:
|
||
amt = None
|
||
if amt is not None and amt <= 0:
|
||
amt = None
|
||
oid = order.get("id")
|
||
if oid is None and isinstance(order.get("info"), dict):
|
||
oid = order["info"].get("id") or order["info"].get("order_id")
|
||
disp = format_price_for_symbol(exchange_symbol, trig) if trig else "-"
|
||
return {
|
||
"order_id": str(oid) if oid is not None else "",
|
||
"channel": "gate_trigger",
|
||
"trigger_price": trig,
|
||
"trigger_display": disp,
|
||
"amount": amt,
|
||
"type": str(order.get("type") or ""),
|
||
}
|
||
|
||
|
||
def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None):
|
||
slots = {"sl": None, "tp": None}
|
||
if not exchange_symbol:
|
||
return slots
|
||
ok, _ = ensure_exchange_live_ready()
|
||
if not ok:
|
||
return slots
|
||
try:
|
||
ensure_markets_loaded()
|
||
ambiguous = []
|
||
for order in _gate_list_trigger_open_orders(exchange_symbol):
|
||
role = _gate_tpsl_role_from_order(order, direction)
|
||
slot = _gate_tpsl_slot_from_order(order, exchange_symbol)
|
||
if role in ("sl", "tp"):
|
||
if slots[role] is None:
|
||
slots[role] = slot
|
||
continue
|
||
ambiguous.append(slot)
|
||
for slot in ambiguous:
|
||
trig = slot.get("trigger_price")
|
||
if trig is None:
|
||
continue
|
||
try:
|
||
plan_sl_f = float(plan_sl) if plan_sl is not None else None
|
||
plan_tp_f = float(plan_tp) if plan_tp is not None else None
|
||
except Exception:
|
||
plan_sl_f = plan_tp_f = None
|
||
if plan_sl_f is not None and plan_tp_f is not None:
|
||
role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp"
|
||
elif plan_sl_f is not None:
|
||
role = "sl"
|
||
elif plan_tp_f is not None:
|
||
role = "tp"
|
||
else:
|
||
continue
|
||
if slots[role] is None:
|
||
slots[role] = slot
|
||
except Exception:
|
||
pass
|
||
return slots
|
||
|
||
|
||
def cancel_gate_tpsl_slot(exchange_symbol, slot):
|
||
if not slot or not exchange_symbol:
|
||
return
|
||
ensure_markets_loaded()
|
||
oid = slot.get("order_id")
|
||
if not oid:
|
||
return
|
||
params = _gate_swap_trigger_order_params()
|
||
exchange.cancel_order(str(oid), exchange_symbol, params)
|
||
|
||
|
||
def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data):
|
||
return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data)
|
||
|
||
|
||
def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):
|
||
ok, reason = ensure_exchange_live_ready()
|
||
if not ok:
|
||
raise RuntimeError(reason or "实盘未就绪")
|
||
ex_sym = resolve_monitor_exchange_symbol(order_row)
|
||
direction = order_row["direction"]
|
||
sl, tp, adjust_note = _gate_clamp_tpsl_to_last_price(
|
||
ex_sym, direction, float(stop_loss), float(take_profit)
|
||
)
|
||
cancel_gate_swap_trigger_orders(ex_sym)
|
||
contracts = get_live_position_contracts(ex_sym, direction)
|
||
if contracts is None or float(contracts) <= 0:
|
||
raise ValueError("交易所当前无该方向持仓,无法挂止盈止损")
|
||
amt = float(contracts)
|
||
if amt <= 0:
|
||
try:
|
||
amt = float(order_row["order_amount"] or 0)
|
||
except Exception:
|
||
amt = 0
|
||
if amt <= 0:
|
||
raise ValueError("无法确定平仓数量")
|
||
_gate_place_tp_sl_orders(ex_sym, direction, amt, sl, tp)
|
||
|
||
|
||
def extract_trade_price_from_order(order):
|
||
if not order:
|
||
return None
|
||
for k in ("average", "avgPrice", "price"):
|
||
try:
|
||
v = float(order.get(k) or 0)
|
||
if v > 0:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
try:
|
||
info = order.get("info") or {}
|
||
if isinstance(info, dict):
|
||
for k in ("fillPx", "avgPx", "fill_price"):
|
||
v = float(info.get(k) or 0)
|
||
if v > 0:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def is_no_position_error(err_msg):
|
||
msg = (err_msg or "").lower()
|
||
keywords = [
|
||
"no position", "position does not exist", "position not exist",
|
||
"pos size is 0", "nothing to close", "reduceonly", "51008",
|
||
"empty position", "increase_position",
|
||
]
|
||
return any(k in msg for k in keywords)
|
||
|
||
|
||
def _gate_fetch_position_rows(exchange_symbol):
|
||
"""优先拉 USDT 本位全量持仓(与页面一致),避免单合约查询在重启后返回空列表误判空仓。"""
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
return None
|
||
try:
|
||
return exchange.fetch_positions(None, {"settle": "usdt"}) or []
|
||
except Exception:
|
||
pass
|
||
if not exchange_symbol:
|
||
return None
|
||
try:
|
||
return exchange.fetch_positions([exchange_symbol]) or []
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=False):
|
||
total = 0.0
|
||
if not rows:
|
||
return total
|
||
direction = (direction or "long").strip().lower()
|
||
for p in rows:
|
||
if not _position_matches_wanted_contract(exchange_symbol, p):
|
||
continue
|
||
contracts = _position_row_effective_contracts(p)
|
||
if contracts <= 0:
|
||
continue
|
||
if (not relax_direction) and GATE_POS_MODE == "hedge":
|
||
info = p.get("info", {}) or {}
|
||
side = (p.get("side") or info.get("posSide") or "").lower()
|
||
if side and side != direction:
|
||
continue
|
||
total += contracts
|
||
return total
|
||
|
||
|
||
def get_live_position_contracts(exchange_symbol, direction):
|
||
rows = _gate_fetch_position_rows(exchange_symbol)
|
||
if rows is None:
|
||
return None
|
||
total = _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=False)
|
||
if total <= 0 and GATE_POS_MODE == "hedge":
|
||
total = _sum_live_position_contracts(rows, exchange_symbol, direction, relax_direction=True)
|
||
return total
|
||
|
||
|
||
def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False):
|
||
"""在 fetch_positions 结果中取与当前监控方向一致、张数最大的一条(与 get_live_position_contracts 过滤规则一致)。"""
|
||
if not rows:
|
||
return None
|
||
candidates = []
|
||
for p in rows:
|
||
if not _position_matches_wanted_contract(exchange_symbol, p):
|
||
continue
|
||
info = p.get("info", {}) or {}
|
||
side = (p.get("side") or info.get("posSide") or "").lower()
|
||
contracts = _position_row_effective_contracts(p)
|
||
if contracts <= 0:
|
||
continue
|
||
if (not relax_hedge) and GATE_POS_MODE == "hedge":
|
||
if side and side != (direction or "").lower():
|
||
continue
|
||
candidates.append((contracts, p))
|
||
if not candidates and (not relax_hedge) and GATE_POS_MODE == "hedge":
|
||
return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True)
|
||
if not candidates:
|
||
return None
|
||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||
return candidates[0][1]
|
||
|
||
|
||
def _coerce_float(*values):
|
||
for v in values:
|
||
if v is None or v == "":
|
||
continue
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return None
|
||
|
||
|
||
def parse_ccxt_position_metrics(position, order_leverage=None):
|
||
"""
|
||
从 ccxt 统一持仓结构解析保证金/名义/未实现盈亏(Gate 等所字段略有差异,做多键兜底)。
|
||
与 App「仓位保证金」对齐时优先用 initialMargin;缺失时再尝试 info 内字段。
|
||
"""
|
||
if not position:
|
||
return None
|
||
p = position
|
||
info = p.get("info", {}) or {}
|
||
# Gate 全仓:ccxt 的 initialMargin 常为空;collateral 来自 API 的 margin,与 App「保证金」一致
|
||
initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin"))
|
||
if initial is None or initial <= 0:
|
||
initial = _coerce_float(
|
||
info.get("margin"),
|
||
info.get("cross_margin"),
|
||
info.get("iso_margin"),
|
||
info.get("initial_margin"),
|
||
info.get("position_margin"),
|
||
info.get("initialMargin"),
|
||
)
|
||
notional = _coerce_float(p.get("notional"), p.get("notionalValue"))
|
||
if notional is None or notional <= 0:
|
||
notional = _coerce_float(info.get("value"))
|
||
if notional is not None:
|
||
notional = abs(notional)
|
||
# 全仓且 API margin 为 0 时:用名义/杠杆粗算展示(与交易所「约占用」接近)
|
||
if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage:
|
||
try:
|
||
lev = float(order_leverage)
|
||
if lev > 0:
|
||
approx = notional / lev
|
||
if approx > 0:
|
||
initial = approx
|
||
except (TypeError, ValueError):
|
||
pass
|
||
unrealized = _coerce_float(
|
||
p.get("unrealizedPnl"),
|
||
info.get("unrealised_pnl"),
|
||
info.get("unrealized_pnl"),
|
||
)
|
||
mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("mark_price"), info.get("markPrice"))
|
||
out = {}
|
||
if initial is not None and initial > 0:
|
||
out["initial_margin"] = round(initial, 2)
|
||
if notional is not None and notional > 0:
|
||
out["notional"] = round(notional, 2)
|
||
if unrealized is not None:
|
||
out["unrealized_pnl"] = round(unrealized, 2)
|
||
if mark is not None and mark > 0:
|
||
out["mark_price"] = round(mark, 8)
|
||
if out:
|
||
sym = (p.get("symbol") or "").strip()
|
||
try:
|
||
cs = float(get_contract_size(sym)) if sym else 1.0
|
||
except Exception:
|
||
cs = 1.0
|
||
from hub_position_metrics import enrich_ccxt_position_metrics_out
|
||
|
||
enrich_ccxt_position_metrics_out(p, out, contract_size=cs, funds_decimals=2)
|
||
return out or None
|
||
|
||
|
||
def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None):
|
||
ensure_markets_loaded()
|
||
if not exchange_private_api_configured() or not exchange_symbol:
|
||
return None
|
||
try:
|
||
rows = exchange.fetch_positions(None, {"settle": "usdt"}) or []
|
||
except Exception:
|
||
try:
|
||
rows = exchange.fetch_positions([exchange_symbol]) or []
|
||
except Exception:
|
||
return None
|
||
p = _select_live_position_row(rows, exchange_symbol, direction)
|
||
return parse_ccxt_position_metrics(p, order_leverage=order_leverage)
|
||
|
||
|
||
def _order_row_exchange_margin_usdt(row):
|
||
if not row:
|
||
return None
|
||
try:
|
||
keys = row.keys()
|
||
except Exception:
|
||
return None
|
||
if "exchange_margin_usdt" not in keys:
|
||
return None
|
||
v = row["exchange_margin_usdt"]
|
||
if v is None:
|
||
return None
|
||
try:
|
||
x = float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return x if x > 0 else None
|
||
|
||
|
||
def margin_capital_for_trade_record(order_row):
|
||
"""trade_records.基数:优先交易所持仓保证金快照,旧数据无快照时回退计划保证金。"""
|
||
ex = _order_row_exchange_margin_usdt(order_row)
|
||
if ex is not None:
|
||
return round(ex, 2)
|
||
if not order_row:
|
||
return None
|
||
try:
|
||
v = order_row["margin_capital"]
|
||
except (TypeError, KeyError, IndexError):
|
||
return None
|
||
if v is None:
|
||
return None
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def try_persist_exchange_margin_for_order(conn, order_id, exchange_symbol, direction, order_leverage=None, max_attempts=6, sleep_s=0.45):
|
||
"""开仓成功后持仓可见时拉取交易所保证金并写入 order_monitors(平仓后无法再取)。"""
|
||
if not conn or not order_id or not exchange_private_api_configured():
|
||
return False
|
||
direction = (direction or "long").lower()
|
||
ex_sym = (exchange_symbol or "").strip()
|
||
if not ex_sym:
|
||
return False
|
||
n = max(1, int(max_attempts))
|
||
delay = max(0.05, float(sleep_s))
|
||
for _ in range(n):
|
||
pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=order_leverage)
|
||
if pm and pm.get("initial_margin") is not None:
|
||
try:
|
||
v = float(pm["initial_margin"])
|
||
except (TypeError, ValueError):
|
||
v = 0.0
|
||
if v > 0:
|
||
conn.execute(
|
||
"UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?",
|
||
(round(v, 4), int(order_id)),
|
||
)
|
||
return True
|
||
time.sleep(delay)
|
||
return False
|
||
|
||
|
||
def opened_at_str_to_ms(opened_at_str):
|
||
if not opened_at_str:
|
||
return None
|
||
dt = parse_dt_for_trading_day(opened_at_str)
|
||
if dt is None:
|
||
return None
|
||
try:
|
||
aware = dt.replace(tzinfo=APP_TZ)
|
||
return int(aware.timestamp() * 1000)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _to_ms_with_fallback(ms_value, dt_str):
|
||
try:
|
||
if ms_value is not None and str(ms_value).strip() != "":
|
||
v = int(float(ms_value))
|
||
if v > 0:
|
||
return v
|
||
except Exception:
|
||
pass
|
||
return opened_at_str_to_ms(dt_str)
|
||
|
||
|
||
def ms_to_app_local_str(ms):
|
||
if ms is None:
|
||
return app_now_str()
|
||
try:
|
||
dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ)
|
||
return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S")
|
||
except Exception:
|
||
return app_now_str()
|
||
|
||
|
||
def classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_price):
|
||
"""根据成交价相对止盈/止损位归类;无法可靠归类时返回 None。"""
|
||
try:
|
||
tp = float(take_profit)
|
||
sl = float(stop_loss)
|
||
ex = float(exit_price)
|
||
trig = float(trigger_price)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
band = max(abs(trig) * 0.0008, abs(tp - sl) * 0.003, 1e-12)
|
||
if direction == "long":
|
||
if ex >= tp - band:
|
||
return "止盈"
|
||
if ex <= sl + band:
|
||
return "止损"
|
||
else:
|
||
if ex <= tp + band:
|
||
return "止盈"
|
||
if ex >= sl - band:
|
||
return "止损"
|
||
return None
|
||
|
||
|
||
def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None):
|
||
"""取开仓以来最近一笔减仓成交(与方向一致);失败返回 None。"""
|
||
if not (GATE_API_KEY and GATE_API_SECRET):
|
||
return None
|
||
ensure_markets_loaded()
|
||
since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
|
||
close_side = "sell" if direction == "long" else "buy"
|
||
|
||
def pick_from_trades(trades):
|
||
if not trades:
|
||
return None
|
||
candidates = []
|
||
for t in trades:
|
||
if (t.get("side") or "").lower() != close_side:
|
||
continue
|
||
info = t.get("info") or {}
|
||
if not isinstance(info, dict):
|
||
info = {}
|
||
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
|
||
if GATE_POS_MODE == "hedge":
|
||
if pos_side in ("long", "short") and pos_side != direction:
|
||
continue
|
||
ts = t.get("timestamp")
|
||
if ts is None:
|
||
continue
|
||
candidates.append(t)
|
||
if not candidates:
|
||
return None
|
||
return max(candidates, key=lambda x: x.get("timestamp") or 0)
|
||
|
||
try:
|
||
trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100)
|
||
hit = pick_from_trades(trades)
|
||
if hit is None and since_ms:
|
||
trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=100)
|
||
hit = pick_from_trades(trades)
|
||
if hit is not None:
|
||
return hit
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from gate_position_history_lib import pick_gate_position_close
|
||
|
||
pos = pick_gate_position_close(
|
||
fetch_gate_positions_close_history(),
|
||
exchange_symbol,
|
||
direction,
|
||
opened_at_ms=since_ms,
|
||
)
|
||
if pos:
|
||
return {
|
||
"price": None,
|
||
"timestamp": pos["close_ms"],
|
||
"side": close_side,
|
||
"_from_position_history": True,
|
||
"_realized_pnl": pos.get("pnl"),
|
||
"_sync_key": pos.get("sync_key"),
|
||
"_open_ms": pos.get("open_ms"),
|
||
}
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None):
|
||
"""
|
||
拉取某条历史记录对应的减仓成交(用于按 id 回填)。
|
||
返回按时间排序的成交列表。
|
||
"""
|
||
if not (GATE_API_KEY and GATE_API_SECRET):
|
||
return []
|
||
ensure_markets_loaded()
|
||
since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
|
||
close_side = "sell" if direction == "long" else "buy"
|
||
closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None
|
||
# 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况
|
||
if closed_ms is not None:
|
||
closed_ms += 6 * 60 * 60 * 1000
|
||
candidates = []
|
||
all_side_candidates = []
|
||
try:
|
||
trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200)
|
||
except Exception:
|
||
trades = []
|
||
if not trades and since_ms:
|
||
try:
|
||
trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200)
|
||
except Exception:
|
||
trades = []
|
||
for t in trades or []:
|
||
if (t.get("side") or "").lower() != close_side:
|
||
continue
|
||
ts = t.get("timestamp")
|
||
if ts is None:
|
||
continue
|
||
try:
|
||
ts = int(ts)
|
||
except Exception:
|
||
continue
|
||
if since_ms and ts < since_ms:
|
||
continue
|
||
if closed_ms and ts > closed_ms:
|
||
continue
|
||
info = t.get("info") or {}
|
||
if not isinstance(info, dict):
|
||
info = {}
|
||
pos_side = (info.get("posSide") or t.get("posSide") or "").lower()
|
||
if GATE_POS_MODE == "hedge":
|
||
if pos_side in ("long", "short") and pos_side != direction:
|
||
continue
|
||
all_side_candidates.append(t)
|
||
if since_ms and ts < since_ms:
|
||
continue
|
||
if closed_ms and ts > closed_ms:
|
||
continue
|
||
candidates.append(t)
|
||
candidates.sort(key=lambda x: x.get("timestamp") or 0)
|
||
if candidates:
|
||
return candidates
|
||
|
||
# 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败。
|
||
all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0)
|
||
if not all_side_candidates:
|
||
return []
|
||
if not closed_ms:
|
||
return all_side_candidates[-20:]
|
||
near = []
|
||
for t in all_side_candidates:
|
||
ts = t.get("timestamp")
|
||
if ts is None:
|
||
continue
|
||
try:
|
||
delta = abs(int(ts) - int(closed_ms))
|
||
except Exception:
|
||
continue
|
||
# 放宽到前后 7 天
|
||
if delta <= 7 * 24 * 60 * 60 * 1000:
|
||
near.append((delta, t))
|
||
if near:
|
||
near.sort(key=lambda x: x[0])
|
||
picked = [x[1] for x in near[:20]]
|
||
picked.sort(key=lambda x: x.get("timestamp") or 0)
|
||
return picked
|
||
return all_side_candidates[-20:]
|
||
|
||
|
||
def fetch_all_position_fills_for_record(
|
||
exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None
|
||
):
|
||
if not exchange_private_api_configured():
|
||
return []
|
||
ensure_markets_loaded()
|
||
since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
|
||
closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None
|
||
if closed_ms is not None:
|
||
closed_ms += 6 * 60 * 60 * 1000
|
||
try:
|
||
trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200)
|
||
except Exception:
|
||
trades = []
|
||
if not trades and since_ms:
|
||
try:
|
||
trades = exchange.fetch_my_trades(exchange_symbol, since=None, limit=200)
|
||
except Exception:
|
||
trades = []
|
||
return filter_position_lifecycle_fills(
|
||
trades or [],
|
||
direction,
|
||
since_ms,
|
||
closed_ms,
|
||
hedge_mode=(GATE_POS_MODE == "hedge"),
|
||
close_buffer_ms=0,
|
||
)
|
||
|
||
|
||
def _attach_gate_trade_exchange_stats(
|
||
conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None
|
||
):
|
||
if not exchange_private_api_configured():
|
||
return
|
||
open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str)
|
||
close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str)
|
||
contract_size = 1.0
|
||
try:
|
||
ensure_markets_loaded()
|
||
contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1)
|
||
except Exception:
|
||
pass
|
||
|
||
def _fetch():
|
||
return fetch_all_position_fills_for_record(
|
||
exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms
|
||
)
|
||
|
||
try:
|
||
attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def calc_weighted_exit_price(trades):
|
||
if not trades:
|
||
return None
|
||
total_amount = 0.0
|
||
weighted_sum = 0.0
|
||
for t in trades:
|
||
try:
|
||
price = float(t.get("price") or 0)
|
||
amount = float(t.get("amount") or 0)
|
||
except Exception:
|
||
continue
|
||
if price <= 0:
|
||
continue
|
||
if amount <= 0:
|
||
amount = 1.0
|
||
weighted_sum += price * amount
|
||
total_amount += amount
|
||
if total_amount <= 0:
|
||
return None
|
||
return weighted_sum / total_amount
|
||
|
||
|
||
def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None, *, prefer_manual=False):
|
||
"""
|
||
交易所已无仓、本地仍为 active 时,推断平仓类型/时间/盈亏。
|
||
返回 (result, pnl_amount, closed_at_str, miss_reason)。
|
||
"""
|
||
direction = row["direction"]
|
||
sym = row["symbol"]
|
||
trigger_price = row["trigger_price"]
|
||
stop_loss = row["stop_loss"]
|
||
take_profit = row["take_profit"]
|
||
margin_capital = row["margin_capital"] or DAILY_START_CAPITAL
|
||
leverage = row["leverage"] or infer_leverage(sym)
|
||
exchange_symbol = row["exchange_symbol"] or normalize_exchange_symbol(sym)
|
||
|
||
trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms)
|
||
exit_px = None
|
||
closed_at_str = app_now_str()
|
||
if trade:
|
||
try:
|
||
exit_px = float(trade.get("price") or 0) or None
|
||
except (TypeError, ValueError):
|
||
exit_px = None
|
||
ts = trade.get("timestamp")
|
||
if ts:
|
||
closed_at_str = ms_to_app_local_str(int(ts))
|
||
if trade.get("_from_position_history"):
|
||
pnl_hist = trade.get("_realized_pnl")
|
||
if pnl_hist is not None:
|
||
note = "中控平仓后按 Gate 平仓历史同步盈亏" if prefer_manual else "按 Gate 平仓历史同步盈亏"
|
||
res = "手动平仓" if prefer_manual else "外部平仓"
|
||
return (res, float(pnl_hist), closed_at_str, note)
|
||
|
||
if exit_px is None or exit_px <= 0:
|
||
p = get_price(sym)
|
||
if p:
|
||
guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p)
|
||
if guessed:
|
||
pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
|
||
return (
|
||
normalize_result_with_pnl(guessed, pnl),
|
||
pnl,
|
||
closed_at_str,
|
||
"未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)",
|
||
)
|
||
return (
|
||
"外部平仓",
|
||
0.0,
|
||
closed_at_str,
|
||
"检测到交易所仓位已关闭,且无法从成交记录还原平仓价",
|
||
)
|
||
|
||
result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px)
|
||
pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage)
|
||
if prefer_manual:
|
||
return (
|
||
"手动平仓",
|
||
pnl,
|
||
closed_at_str,
|
||
"中控平仓后按交易所成交记录同步",
|
||
)
|
||
if result:
|
||
return (
|
||
normalize_result_with_pnl(result, pnl),
|
||
pnl,
|
||
closed_at_str,
|
||
"按交易所成交记录同步为止盈/止损平仓",
|
||
)
|
||
return (
|
||
"外部平仓",
|
||
pnl,
|
||
closed_at_str,
|
||
"交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)",
|
||
)
|
||
|
||
|
||
def reconcile_hub_external_close(conn, symbol, direction):
|
||
"""中控市价全平后:立即同步匹配 order_monitor,并读 Gate 平仓历史。"""
|
||
if not exchange_private_api_configured():
|
||
return {"ok": False, "msg": "未配置 GATE_API_KEY / GATE_API_SECRET", "synced": 0}
|
||
from gate_position_history_lib import unified_symbol_for_match
|
||
|
||
sym_u = unified_symbol_for_match(symbol)
|
||
dir_l = (direction or "").strip().lower()
|
||
if dir_l not in ("long", "short"):
|
||
return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0}
|
||
synced = 0
|
||
rows = conn.execute(
|
||
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
|
||
).fetchall()
|
||
for r in rows:
|
||
if unified_symbol_for_match(r["symbol"]) != sym_u:
|
||
continue
|
||
if (r["direction"] or "").strip().lower() != dir_l:
|
||
continue
|
||
oid = int(r["id"])
|
||
if r["status"] == "error":
|
||
opened_at_chk = get_opened_at_value(r)
|
||
existing = conn.execute(
|
||
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1",
|
||
(r["symbol"], opened_at_chk, order_row_monitor_type(r)),
|
||
).fetchone()
|
||
if existing:
|
||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
|
||
synced += 1
|
||
continue
|
||
exchange_symbol = resolve_monitor_exchange_symbol(r)
|
||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||
if live_contracts is None:
|
||
continue
|
||
if live_contracts > 0:
|
||
time.sleep(0.6)
|
||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||
if live_contracts is None or live_contracts > 0:
|
||
continue
|
||
global _RECONCILE_FLAT_STREAK
|
||
_RECONCILE_FLAT_STREAK.pop(oid, None)
|
||
cancel_gate_swap_trigger_orders(exchange_symbol)
|
||
opened_at = get_opened_at_value(r)
|
||
opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at)
|
||
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(
|
||
r, opened_at, opened_at_ms=opened_at_ms, prefer_manual=True
|
||
)
|
||
closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now()
|
||
hold_seconds = calc_hold_seconds(opened_at, closed_at_dt)
|
||
session_date = r["session_date"] or get_trading_day(closed_at_dt)
|
||
update_session_capital(conn, session_date, pnl_amount)
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=r["symbol"],
|
||
monitor_type=trade_record_monitor_type(conn, r),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||
key_signal_type=order_row_key_signal_type(r),
|
||
direction=r["direction"],
|
||
trigger_price=r["trigger_price"],
|
||
stop_loss=r["stop_loss"],
|
||
initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"],
|
||
take_profit=r["take_profit"],
|
||
margin_capital=margin_capital_for_trade_record(r),
|
||
leverage=r["leverage"],
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trade_style=r["trade_style"],
|
||
risk_amount=r["risk_amount"],
|
||
planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]),
|
||
actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]),
|
||
result=result,
|
||
miss_reason=handoff_trade_miss_reason(miss_reason, r),
|
||
opened_at=opened_at,
|
||
closed_at=closed_at,
|
||
)
|
||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
|
||
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
|
||
synced += 1
|
||
try:
|
||
sync_trade_records_from_exchange(conn, force=True)
|
||
except Exception:
|
||
pass
|
||
return {"ok": True, "synced": synced}
|
||
|
||
|
||
def reconcile_external_closes(conn, days=None):
|
||
global _RECONCILE_FLAT_STREAK
|
||
if not exchange_private_api_configured():
|
||
return 0
|
||
if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC:
|
||
return 0
|
||
synced_count = 0
|
||
cutoff_ms = None
|
||
if days is not None:
|
||
try:
|
||
d = int(days)
|
||
if d > 0:
|
||
cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000)
|
||
except Exception:
|
||
cutoff_ms = None
|
||
rows = conn.execute(
|
||
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
|
||
).fetchall()
|
||
for r in rows:
|
||
if cutoff_ms is not None:
|
||
opened_at_v = get_opened_at_value(r)
|
||
opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v)
|
||
# 手动同步按最近 N 天过滤,避免把更早历史单误同步进来
|
||
if opened_ms is None or opened_ms < cutoff_ms:
|
||
continue
|
||
oid = int(r["id"])
|
||
if r["status"] == "error":
|
||
opened_at_chk = get_opened_at_value(r)
|
||
existing = conn.execute(
|
||
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1",
|
||
(r["symbol"], opened_at_chk, order_row_monitor_type(r)),
|
||
).fetchone()
|
||
if existing:
|
||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
|
||
synced_count += 1
|
||
continue
|
||
exchange_symbol = resolve_monitor_exchange_symbol(r)
|
||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||
if live_contracts is None:
|
||
_RECONCILE_FLAT_STREAK.pop(oid, None)
|
||
continue
|
||
if live_contracts > 0:
|
||
_RECONCILE_FLAT_STREAK.pop(oid, None)
|
||
continue
|
||
if r["status"] != "error":
|
||
streak = int(_RECONCILE_FLAT_STREAK.get(oid, 0)) + 1
|
||
_RECONCILE_FLAT_STREAK[oid] = streak
|
||
if streak < RECONCILE_FLAT_CONFIRM_POLLS:
|
||
continue
|
||
_RECONCILE_FLAT_STREAK.pop(oid, None)
|
||
print(
|
||
f"[reconcile_external_closes] {r['symbol']} id={oid} "
|
||
f"flat x{streak} polls -> sync close"
|
||
)
|
||
else:
|
||
_RECONCILE_FLAT_STREAK.pop(oid, None)
|
||
print(
|
||
f"[reconcile_external_closes] error recovery {r['symbol']} id={oid} flat -> sync close"
|
||
)
|
||
cancel_gate_swap_trigger_orders(exchange_symbol)
|
||
opened_at = get_opened_at_value(r)
|
||
opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at)
|
||
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms)
|
||
closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now()
|
||
hold_seconds = calc_hold_seconds(opened_at, closed_at_dt)
|
||
session_date = r["session_date"] or get_trading_day(closed_at_dt)
|
||
update_session_capital(conn, session_date, pnl_amount)
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=r["symbol"],
|
||
monitor_type=trade_record_monitor_type(conn, r),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||
key_signal_type=order_row_key_signal_type(r),
|
||
direction=r["direction"],
|
||
trigger_price=r["trigger_price"],
|
||
stop_loss=r["stop_loss"],
|
||
initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"],
|
||
take_profit=r["take_profit"],
|
||
margin_capital=margin_capital_for_trade_record(r),
|
||
leverage=r["leverage"],
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trade_style=r["trade_style"],
|
||
risk_amount=r["risk_amount"],
|
||
planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]),
|
||
actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]),
|
||
result=result,
|
||
miss_reason=handoff_trade_miss_reason(miss_reason, r),
|
||
opened_at=opened_at,
|
||
closed_at=closed_at,
|
||
)
|
||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
|
||
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
|
||
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
||
send_wechat_msg(
|
||
build_wechat_close_message(
|
||
symbol=r["symbol"],
|
||
direction=r["direction"],
|
||
result=f"{result}(自动同步)",
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trigger_price=r["trigger_price"],
|
||
current_price="-",
|
||
stop_loss=r["stop_loss"],
|
||
take_profit=r["take_profit"],
|
||
close_order_id="-",
|
||
extra_note=miss_reason,
|
||
)
|
||
)
|
||
else:
|
||
send_wechat_msg(
|
||
build_wechat_close_message(
|
||
symbol=r["symbol"],
|
||
direction=r["direction"],
|
||
result="外部平仓(自动同步)",
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trigger_price=r["trigger_price"],
|
||
current_price="-",
|
||
stop_loss=r["stop_loss"],
|
||
take_profit=r["take_profit"],
|
||
close_order_id="-",
|
||
extra_note=miss_reason,
|
||
)
|
||
)
|
||
synced_count += 1
|
||
return synced_count
|
||
|
||
# 获取实时价格
|
||
def get_price(symbol):
|
||
try:
|
||
ensure_markets_loaded()
|
||
return exchange.fetch_ticker(normalize_exchange_symbol(symbol))["last"]
|
||
except:
|
||
return None
|
||
|
||
# 获取5分钟K线收盘价
|
||
def get_5m_close(symbol):
|
||
try:
|
||
ensure_markets_loaded()
|
||
ohlcv = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), KLINE_TIMEFRAME, limit=1)
|
||
return ohlcv[-1][4] if ohlcv else None
|
||
except:
|
||
return None
|
||
|
||
|
||
def _safe_float(v):
|
||
try:
|
||
return float(v)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _compute_ema(values, period=55):
|
||
arr = [float(x) for x in values if x is not None]
|
||
if len(arr) < period:
|
||
return None
|
||
k = 2.0 / (period + 1.0)
|
||
ema = arr[0]
|
||
for val in arr[1:]:
|
||
ema = val * k + ema * (1 - k)
|
||
return ema
|
||
|
||
|
||
def _status_by_ema55(symbol, timeframe):
|
||
try:
|
||
bars = exchange.fetch_ohlcv(normalize_exchange_symbol(symbol), timeframe=timeframe, limit=80)
|
||
if not bars or len(bars) < 56:
|
||
return "横盘", None, None
|
||
closes = [float(x[4]) for x in bars if x and len(x) >= 5]
|
||
ema55 = _compute_ema(closes, 55)
|
||
last_close = closes[-1]
|
||
if ema55 is None or last_close <= 0:
|
||
return "横盘", last_close, ema55
|
||
diff_pct = (last_close - ema55) / ema55 * 100.0
|
||
if abs(diff_pct) < 0.1:
|
||
return "横盘", last_close, ema55
|
||
return ("多头" if diff_pct > 0 else "空头"), last_close, ema55
|
||
except Exception:
|
||
return "横盘", None, None
|
||
|
||
|
||
def _daily_volume_rank(symbol):
|
||
"""
|
||
返回(symbol_rank, total_count),按 USDT 永续 24h 成交额降序。
|
||
走 hub_volume_rank_lib 轻量 ticker API,避免 fetch_tickers() 全市场拉取。
|
||
"""
|
||
sym_norm = normalize_symbol_input(symbol)
|
||
target_base = journal_coin_from_symbol(sym_norm)
|
||
return resolve_daily_volume_rank(
|
||
target_base,
|
||
LIQUIDITY_RANK_CACHE,
|
||
now_ts=time.time(),
|
||
ttl_sec=max(30, BALANCE_REFRESH_SECONDS),
|
||
exchange=exchange,
|
||
ensure_markets_loaded=ensure_markets_loaded,
|
||
)
|
||
|
||
|
||
def _key_hard_checks(symbol, direction, upper, lower, monitor_type):
|
||
"""
|
||
关键位门控:量能、突破幅度、第二根确认、日成交量前30。
|
||
使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根。
|
||
"""
|
||
out = {"ok": False}
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or []
|
||
if len(bars) < 24:
|
||
out["reason"] = "5m K线数量不足"
|
||
return out
|
||
closed = bars[:-1] if len(bars) >= 3 else bars
|
||
min_closed = KEY_VOLUME_MA_BARS + 3
|
||
if len(closed) < min_closed:
|
||
out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足"
|
||
return out
|
||
try:
|
||
breakout = closed[KEY_CONFIRM_BREAKOUT_BAR]
|
||
confirm = closed[KEY_CONFIRM_BAR]
|
||
except IndexError:
|
||
out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置"
|
||
return out
|
||
prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR]
|
||
avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1)
|
||
vol_break = float(breakout[5])
|
||
vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False
|
||
close_b = float(breakout[4])
|
||
high_b = float(breakout[2])
|
||
low_b = float(breakout[3])
|
||
cfm_close = float(confirm[4])
|
||
edge = float(upper) if direction == "long" else float(lower)
|
||
breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower))
|
||
amp_ok, amp_pct = auto_amp_ok(
|
||
direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT
|
||
)
|
||
amp_ok = amp_ok and breakout_ok
|
||
confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower))
|
||
confirm_ok = confirm_ok_raw and breakout_ok
|
||
rank, total = _daily_volume_rank(symbol)
|
||
rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX)
|
||
swing4h_pct = 0.0
|
||
try:
|
||
seg48 = closed[-48:] if len(closed) >= 48 else closed
|
||
hh = max(float(x[2]) for x in seg48)
|
||
ll = min(float(x[3]) for x in seg48)
|
||
swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0
|
||
except Exception:
|
||
swing4h_pct = 0.0
|
||
out.update(
|
||
{
|
||
"ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]),
|
||
"vol_ok": vol_ok,
|
||
"avg20": avg20,
|
||
"vol_break": vol_break,
|
||
"amp_ok": amp_ok,
|
||
"amp_pct": amp_pct,
|
||
"breakout_ok": breakout_ok,
|
||
"breakout_close": close_b,
|
||
"confirm_ok": confirm_ok,
|
||
"confirm_close": cfm_close,
|
||
"edge_price": edge,
|
||
"rank": rank,
|
||
"rank_total": total,
|
||
"rank_ok": rank_ok,
|
||
"breakout_high": high_b,
|
||
"breakout_low": low_b,
|
||
"breakout_ts": breakout[0],
|
||
"confirm_ts": confirm[0],
|
||
"swing4h_pct": swing4h_pct,
|
||
"monitor_type": monitor_type,
|
||
"direction": direction,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def calc_price_diff_pct(current_price, target_price):
|
||
try:
|
||
if target_price is None:
|
||
return None, None
|
||
t = float(target_price)
|
||
if t == 0:
|
||
return None, None
|
||
c = float(current_price)
|
||
diff = c - t
|
||
pct = diff / t * 100
|
||
return round(diff, 6), round(pct, 4)
|
||
except Exception:
|
||
return None, None
|
||
|
||
|
||
def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason):
|
||
"""本条关键位一次性结案:写历史并从当前表删除。"""
|
||
n = int(row["notification_count"] or 0) + 1
|
||
insert_key_monitor_history(conn, row, n, last_msg, close_reason)
|
||
conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],))
|
||
|
||
|
||
def _fetch_last_closed_bar(symbol):
|
||
"""最近一根闭合 K:[ts, o, h, l, c, v] 或 None。"""
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or []
|
||
if len(bars) < 2:
|
||
return None
|
||
closed = bars[:-1]
|
||
return closed[-1] if closed else None
|
||
|
||
|
||
def _key_rs_gate_preview(symbol, upper, lower):
|
||
"""页面门控预览:阻力/支撑仅显示距上/下沿与是否已越线。"""
|
||
bar = _fetch_last_closed_bar(symbol)
|
||
if not bar:
|
||
return {"summary": "5m数据不足", "metrics": ""}
|
||
close = float(bar[4])
|
||
br = detect_rs_box_break(close, upper, lower)
|
||
if br:
|
||
return {
|
||
"summary": f"已越线:{br['break_label']}",
|
||
"metrics": f"收盘:{format_price_for_symbol(symbol, close)}",
|
||
}
|
||
return {
|
||
"summary": "待突破",
|
||
"metrics": f"收盘:{format_price_for_symbol(symbol, close)}",
|
||
}
|
||
|
||
|
||
def _process_key_rs_level_alert(conn, row):
|
||
"""关键阻力位/支撑位:5m 收盘越上沿或下沿后,按间隔推送最多 KEY_ALERT_MAX_TIMES 次。"""
|
||
sym = row["symbol"]
|
||
typ = (row["monitor_type"] or "").strip()
|
||
up, low = float(row["upper"]), float(row["lower"])
|
||
if up <= low:
|
||
return
|
||
bar = _fetch_last_closed_bar(sym)
|
||
if not bar:
|
||
return
|
||
close = float(bar[4])
|
||
ts = bar[0]
|
||
now_dt = app_now()
|
||
tick = run_rs_level_alert_tick(
|
||
row,
|
||
close,
|
||
ts,
|
||
now_dt,
|
||
default_max_notify=KEY_ALERT_MAX_TIMES,
|
||
default_interval_min=KEY_ALERT_INTERVAL_MINUTES,
|
||
)
|
||
if not tick:
|
||
return
|
||
|
||
br = tick["break_info"]
|
||
notify_index = int(tick["notify_index"])
|
||
max_n = int(tick["notify_max"])
|
||
interval = int(tick["interval_min"])
|
||
bar_ts = tick.get("bar_ts")
|
||
prior_count = int(tick.get("prior_count", notify_index - 1))
|
||
|
||
notified_at = app_now_str()
|
||
if not claim_rs_level_notify(
|
||
conn,
|
||
row["id"],
|
||
notify_index,
|
||
br["direction"],
|
||
notified_at,
|
||
bar_ts,
|
||
prior_count=prior_count,
|
||
):
|
||
return
|
||
conn.commit()
|
||
|
||
trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str()
|
||
msg = build_wechat_rs_level_message(
|
||
symbol=sym,
|
||
monitor_type=typ,
|
||
account_label=_wechat_account_label(),
|
||
trigger_time=trigger_time,
|
||
upper_txt=format_price_for_symbol(sym, up),
|
||
lower_txt=format_price_for_symbol(sym, low),
|
||
close_txt=format_price_for_symbol(sym, close),
|
||
edge_txt=format_price_for_symbol(sym, br["edge_price"]),
|
||
break_label=br["break_label"],
|
||
direction=br["direction"],
|
||
notify_index=notify_index,
|
||
notify_max=max_n,
|
||
interval_min=interval,
|
||
)
|
||
send_wechat_msg(msg)
|
||
conn.execute(
|
||
"UPDATE key_monitors SET last_alert_message=? WHERE id=?",
|
||
(msg, row["id"]),
|
||
)
|
||
conn.commit()
|
||
if notify_index >= max_n:
|
||
hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone()
|
||
if hist_row:
|
||
insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done")
|
||
conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],))
|
||
conn.commit()
|
||
|
||
|
||
def _key_hard_lines_from_checks(checks):
|
||
direction = (checks.get("direction") or "long").lower()
|
||
return [
|
||
f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)",
|
||
f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})",
|
||
format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT),
|
||
format_auto_confirm_line(
|
||
checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction
|
||
),
|
||
f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})",
|
||
]
|
||
|
||
|
||
def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks):
|
||
"""按 key_monitors 录入的方案计算计划 SL/TP。"""
|
||
mode = sl_tp_mode_from_row(row, "standard")
|
||
manual_tp = _sqlite_row_val(row, "manual_take_profit")
|
||
planned = plan_key_sl_tp(
|
||
mode,
|
||
direction,
|
||
upper,
|
||
lower,
|
||
checks,
|
||
outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
|
||
trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
|
||
manual_take_profit=manual_tp,
|
||
)
|
||
return planned, mode
|
||
|
||
|
||
def _market_open_for_key_monitor(
|
||
conn,
|
||
symbol,
|
||
direction,
|
||
exchange_symbol,
|
||
stop_loss,
|
||
take_profit,
|
||
key_signal_type=None,
|
||
breakeven_enabled=0,
|
||
time_close_enabled=0,
|
||
time_close_hours=None,
|
||
):
|
||
"""
|
||
与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。
|
||
返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict])
|
||
"""
|
||
ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO)
|
||
if not ok_src:
|
||
return False, src_msg, None
|
||
now = app_now()
|
||
ok, reason = precheck_risk(conn, symbol, direction)
|
||
if not ok:
|
||
return False, f"风控拒绝下单:{reason}", None
|
||
ok_live, reason_live = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return False, reason_live, None
|
||
|
||
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||
leverage = int(default_leverage) if default_leverage else 5
|
||
if leverage <= 0:
|
||
leverage = 5
|
||
|
||
trading_day = get_trading_day(now)
|
||
opens_today_before = conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
|
||
(trading_day,),
|
||
).fetchone()[0]
|
||
session_row = ensure_session(conn, trading_day)
|
||
_, trading_capital_live = get_exchange_capitals(force=True)
|
||
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
|
||
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
|
||
|
||
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
|
||
if trade_style not in ("trend", "swing"):
|
||
trade_style = "trend"
|
||
|
||
available_usdt = get_available_trading_usdt()
|
||
live_price = get_price(symbol)
|
||
if live_price is None:
|
||
return False, "获取交易所实时价格失败(以损定仓需要当前价)", None
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
lp_r = round_price_to_exchange(exchange_symbol, live_price)
|
||
if lp_r is not None:
|
||
live_price = lp_r
|
||
|
||
sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss))
|
||
tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit))
|
||
if sl_adj is not None:
|
||
stop_loss = float(sl_adj)
|
||
if tp_adj is not None:
|
||
take_profit = float(tp_adj)
|
||
|
||
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
|
||
if risk_fraction is None:
|
||
return False, "止损方向不合法(相对当前市价);请核对上下沿与方向", None
|
||
risk_percent = max(0.01, float(RISK_PERCENT))
|
||
risk_amount = round(capital_base * risk_percent / 100.0, 4)
|
||
notional_value = round(risk_amount / risk_fraction, 4)
|
||
margin_capital = round(notional_value / leverage, 4)
|
||
|
||
if capital_base and margin_capital > capital_base:
|
||
return False, "以损定仓后保证金超过当前交易资金", None
|
||
|
||
if available_usdt is not None:
|
||
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
|
||
if margin_capital > max_margin:
|
||
return (
|
||
False,
|
||
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
|
||
None,
|
||
)
|
||
|
||
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
|
||
|
||
try:
|
||
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
|
||
contract_size = get_contract_size(exchange_symbol)
|
||
base_amount = round(float(amount) * contract_size, 8)
|
||
order_resp = place_exchange_order(
|
||
exchange_symbol, direction, amount, leverage,
|
||
stop_loss=stop_loss, take_profit=take_profit,
|
||
)
|
||
open_order_id = order_resp.get("id", "")
|
||
tpsl_attached = bool(order_resp.get("tpsl_attached"))
|
||
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
|
||
except Exception as e:
|
||
return False, friendly_exchange_error(e, available_usdt=available_usdt), None
|
||
|
||
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
|
||
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
|
||
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
|
||
|
||
opened_at_bj = app_now_str()
|
||
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
|
||
|
||
planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
|
||
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
|
||
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
|
||
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
|
||
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
|
||
if risk_amount_final is None:
|
||
risk_amount_final = risk_amount
|
||
else:
|
||
try:
|
||
risk_amount_final = round(float(risk_amount_final), 4)
|
||
except (TypeError, ValueError):
|
||
risk_amount_final = risk_amount
|
||
|
||
if direction == "short":
|
||
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
|
||
else:
|
||
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
|
||
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
|
||
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||
tc_en, tc_h, tc_at = time_close_insert_values(
|
||
time_close_enabled, time_close_hours, opened_at_ms
|
||
)
|
||
|
||
conn.execute(
|
||
"INSERT INTO order_monitors "
|
||
"(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
|
||
"margin_capital, leverage, trade_style, risk_percent, risk_amount, "
|
||
"breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
|
||
"notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, "
|
||
"time_close_enabled, time_close_hours, time_close_at_ms) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol,
|
||
exchange_symbol,
|
||
direction,
|
||
trigger_price,
|
||
stop_loss,
|
||
stop_loss,
|
||
take_profit,
|
||
margin_capital,
|
||
leverage,
|
||
trade_style,
|
||
risk_percent,
|
||
risk_amount_final,
|
||
breakeven_rr_trigger,
|
||
breakeven_offset_pct,
|
||
breakeven_step_r,
|
||
0,
|
||
breakeven_price,
|
||
be_enabled,
|
||
notional_value,
|
||
position_ratio,
|
||
base_amount,
|
||
amount,
|
||
open_order_id,
|
||
opened_at_bj,
|
||
opened_at_ms,
|
||
trading_day,
|
||
ORDER_MONITOR_TYPE_KEY_AUTO,
|
||
stored_key_signal_type(key_signal_type),
|
||
tc_en,
|
||
tc_h,
|
||
tc_at,
|
||
),
|
||
)
|
||
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
|
||
opens_today_after = conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
|
||
(trading_day,),
|
||
).fetchone()[0]
|
||
|
||
return True, None, {
|
||
"new_order_id": new_order_id,
|
||
"open_order_id": open_order_id,
|
||
"trigger_price": trigger_price,
|
||
"planned_rr_fill": planned_rr,
|
||
"risk_amount_final": risk_amount_final,
|
||
"margin_capital": margin_capital,
|
||
"leverage": leverage,
|
||
"amount": amount,
|
||
"base_amount": base_amount,
|
||
"notional_value": notional_value,
|
||
"position_ratio": position_ratio,
|
||
"tpsl_attached": tpsl_attached,
|
||
"opens_today_before": opens_today_before,
|
||
"opens_today_after": opens_today_after,
|
||
"trading_day": trading_day,
|
||
"risk_percent": risk_percent,
|
||
"breakeven_rr_trigger": breakeven_rr_trigger,
|
||
"breakeven_price": breakeven_price,
|
||
"capital_base_at_open": capital_base,
|
||
}
|
||
|
||
|
||
def _sqlite_row_val(row, key, default=None):
|
||
try:
|
||
v = row[key]
|
||
return default if v is None else v
|
||
except (KeyError, IndexError, TypeError):
|
||
return default
|
||
|
||
|
||
def get_symbol_mark_price(symbol):
|
||
"""斐波失效判定用标记价。"""
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
try:
|
||
ensure_markets_loaded()
|
||
ticker = exchange.fetch_ticker(ex_sym)
|
||
m = _coerce_float(ticker.get("mark"), ticker.get("last"))
|
||
if m is None:
|
||
info = ticker.get("info") or {}
|
||
m = _coerce_float(info.get("mark_price"), info.get("last"))
|
||
if m is not None and m > 0:
|
||
return float(m)
|
||
except Exception:
|
||
pass
|
||
p = get_price(symbol)
|
||
return float(p) if p is not None else None
|
||
|
||
|
||
def cancel_fib_limit_order(exchange_symbol, order_id):
|
||
"""仅撤销本条斐波限价单,不用 cancel_all。"""
|
||
if not order_id:
|
||
return False
|
||
ok_live, _ = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return False
|
||
ensure_markets_loaded()
|
||
oid = str(order_id)
|
||
try:
|
||
exchange.cancel_order(oid, exchange_symbol)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for o in exchange.fetch_open_orders(exchange_symbol) or []:
|
||
if str(o.get("id")) == oid:
|
||
exchange.cancel_order(oid, exchange_symbol)
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def fib_limit_order_status(exchange_symbol, order_id):
|
||
if not order_id:
|
||
return "missing"
|
||
ensure_markets_loaded()
|
||
oid = str(order_id)
|
||
try:
|
||
o = exchange.fetch_order(oid, exchange_symbol)
|
||
st = (o.get("status") or "").lower()
|
||
if st in ("closed", "filled"):
|
||
filled = float(o.get("filled") or 0)
|
||
if filled > 0 or st == "filled":
|
||
return "filled"
|
||
if st in ("canceled", "cancelled", "expired", "rejected"):
|
||
return "canceled"
|
||
if st in ("open", "new", "partially_filled"):
|
||
return "open"
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for o in exchange.fetch_open_orders(exchange_symbol) or []:
|
||
if str(o.get("id")) == oid:
|
||
return "open"
|
||
except Exception:
|
||
pass
|
||
return "unknown"
|
||
|
||
|
||
def place_fib_limit_order(exchange_symbol, direction, amount, leverage, limit_price):
|
||
ensure_markets_loaded()
|
||
exchange.set_leverage(leverage, exchange_symbol)
|
||
side = "buy" if direction == "long" else "sell"
|
||
price = round_price_to_exchange(exchange_symbol, float(limit_price))
|
||
if price is None or price <= 0:
|
||
raise ValueError("挂单价无效")
|
||
params = build_gate_order_params(direction, reduce_only=False)
|
||
return exchange.create_order(exchange_symbol, "limit", side, amount, price, params)
|
||
|
||
|
||
def _fib_key_exists_for_symbol(conn, symbol):
|
||
ph = ",".join("?" * len(FIB_KEY_MONITOR_TYPES))
|
||
row = conn.execute(
|
||
f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({ph})",
|
||
(symbol, *tuple(FIB_KEY_MONITOR_TYPES)),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
def _fib_plan_for_row(row):
|
||
typ = (row["monitor_type"] or "").strip()
|
||
ratio = fib_ratio_from_type(typ)
|
||
if ratio is None:
|
||
return None
|
||
return calc_fib_plan(row["direction"], row["upper"], row["lower"], ratio)
|
||
|
||
|
||
def _limit_key_plan_for_row(row):
|
||
typ = (row["monitor_type"] or "").strip()
|
||
if is_fib_key_monitor_type(typ):
|
||
return _fib_plan_for_row(row)
|
||
if is_false_breakout_key_monitor_type(typ):
|
||
direction = (row["direction"] or "long").lower()
|
||
key_px = key_price_from_row(direction, row["upper"], row["lower"])
|
||
if key_px is None:
|
||
return None
|
||
return calc_false_breakout_plan(direction, key_px)
|
||
return None
|
||
|
||
|
||
def _cancel_fib_monitor_limit(row):
|
||
ex_sym = normalize_exchange_symbol(row["symbol"])
|
||
oid = _sqlite_row_val(row, "fib_limit_order_id")
|
||
if oid:
|
||
cancel_fib_limit_order(ex_sym, oid)
|
||
|
||
|
||
def _fib_has_live_position(exchange_symbol, direction):
|
||
live = get_live_position_contracts(exchange_symbol, direction)
|
||
return live is not None and float(live) > 0
|
||
|
||
|
||
def _insert_order_monitor_from_fib_fill(
|
||
conn, row, trigger_price, stop_loss, take_profit, amount, leverage, margin_capital,
|
||
notional_value, position_ratio, base_amount, exchange_order_id, tpsl_attached,
|
||
):
|
||
symbol = row["symbol"]
|
||
direction = (row["direction"] or "long").lower()
|
||
exchange_symbol = normalize_exchange_symbol(symbol)
|
||
typ = (row["monitor_type"] or "").strip()
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
|
||
if trade_style not in ("trend", "swing"):
|
||
trade_style = "trend"
|
||
risk_percent = max(0.01, float(RISK_PERCENT))
|
||
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
|
||
if risk_amount_final is None:
|
||
risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4)
|
||
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
|
||
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
|
||
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
|
||
if direction == "short":
|
||
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
|
||
else:
|
||
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
|
||
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
|
||
opened_at_bj = app_now_str()
|
||
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
|
||
tc_en, tc_h, _ = time_close_settings_from_row(row)
|
||
tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms)
|
||
conn.execute(
|
||
"INSERT INTO order_monitors "
|
||
"(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
|
||
"margin_capital, leverage, trade_style, risk_percent, risk_amount, "
|
||
"breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
|
||
"notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, "
|
||
"time_close_enabled, time_close_hours, time_close_at_ms) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol,
|
||
exchange_symbol,
|
||
direction,
|
||
trigger_price,
|
||
stop_loss,
|
||
stop_loss,
|
||
take_profit,
|
||
margin_capital,
|
||
leverage,
|
||
trade_style,
|
||
risk_percent,
|
||
risk_amount_final,
|
||
breakeven_rr_trigger,
|
||
breakeven_offset_pct,
|
||
breakeven_step_r,
|
||
0,
|
||
breakeven_price,
|
||
1 if breakeven_enabled_from_row(row, 0) else 0,
|
||
notional_value,
|
||
position_ratio,
|
||
base_amount,
|
||
amount,
|
||
exchange_order_id or "",
|
||
opened_at_bj,
|
||
opened_at_ms,
|
||
trading_day,
|
||
ORDER_MONITOR_TYPE_KEY_AUTO,
|
||
stored_key_signal_type(typ),
|
||
tc_en,
|
||
tc_h,
|
||
tc_at,
|
||
),
|
||
)
|
||
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
|
||
return new_order_id
|
||
|
||
|
||
def _finalize_fib_key_fill(conn, row):
|
||
symbol = row["symbol"]
|
||
direction = (row["direction"] or "long").lower()
|
||
typ = (row["monitor_type"] or "").strip()
|
||
kind = "假突破" if is_false_breakout_key_monitor_type(typ) else "斐波"
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
plan = _limit_key_plan_for_row(row)
|
||
if not plan:
|
||
_finalize_key_monitor_one_shot(conn, row, f"{kind}计划无效", "fib_plan_invalid")
|
||
return
|
||
entry_plan, sl_plan, tp_plan = plan
|
||
sl = float(_sqlite_row_val(row, "fib_stop_loss", sl_plan) or sl_plan)
|
||
tp = float(_sqlite_row_val(row, "fib_take_profit", tp_plan) or tp_plan)
|
||
sl_adj = round_price_to_exchange(ex_sym, sl)
|
||
tp_adj = round_price_to_exchange(ex_sym, tp)
|
||
if sl_adj is not None:
|
||
sl = float(sl_adj)
|
||
if tp_adj is not None:
|
||
tp = float(tp_adj)
|
||
amount = float(_sqlite_row_val(row, "fib_order_amount") or 0)
|
||
leverage = int(_sqlite_row_val(row, "fib_leverage") or infer_leverage(symbol) or 5)
|
||
margin_capital = float(_sqlite_row_val(row, "fib_margin_capital") or 0)
|
||
oid = _sqlite_row_val(row, "fib_limit_order_id")
|
||
entry_px = float(_sqlite_row_val(row, "fib_entry_price", entry_plan) or entry_plan)
|
||
trigger_price = entry_px
|
||
if oid:
|
||
try:
|
||
o = exchange.fetch_order(str(oid), ex_sym)
|
||
trigger_price = resolve_order_entry_price(o, ex_sym, entry_px)
|
||
except Exception:
|
||
pass
|
||
tr_adj = round_price_to_exchange(ex_sym, trigger_price)
|
||
if tr_adj is not None:
|
||
trigger_price = float(tr_adj)
|
||
if amount <= 0:
|
||
live_amt = get_live_position_contracts(ex_sym, direction)
|
||
amount = float(live_amt or 0)
|
||
if amount <= 0:
|
||
send_wechat_msg(
|
||
f"# ❌ {symbol} {kind}成交后处理失败\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 无法取得持仓/下单数量,未挂 TP/SL\n"
|
||
)
|
||
return
|
||
ok, reason = precheck_risk(conn, symbol, direction)
|
||
if not ok:
|
||
send_wechat_msg(
|
||
f"# ❌ {symbol} {kind}成交后风控拒绝\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{typ}\n"
|
||
f"- 原因:{reason}\n"
|
||
f"- 请手动处理仓位与挂单\n"
|
||
)
|
||
return
|
||
tpsl_attached = False
|
||
try:
|
||
_gate_place_tp_sl_orders(ex_sym, direction, amount, sl, tp)
|
||
tpsl_attached = True
|
||
except Exception as e:
|
||
send_wechat_msg(
|
||
f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 错误:{friendly_exchange_error(e)}\n"
|
||
f"- 请手动补挂止盈止损\n"
|
||
)
|
||
return
|
||
contract_size = get_contract_size(ex_sym)
|
||
base_amount = round(float(amount) * contract_size, 8)
|
||
notional_value = round(float(margin_capital) * leverage, 4) if margin_capital else 0
|
||
session_row = ensure_session(conn, get_trading_day(app_now()))
|
||
capital_base = float(session_row["current_capital"] or 0)
|
||
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base and margin_capital else 0
|
||
planned_rr = calc_rr_ratio(direction, trigger_price, sl, tp)
|
||
new_order_id = _insert_order_monitor_from_fib_fill(
|
||
conn, row, trigger_price, sl, tp, amount, leverage, margin_capital,
|
||
notional_value, position_ratio, base_amount, oid, tpsl_attached,
|
||
)
|
||
rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-"
|
||
close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled"
|
||
succ = (
|
||
f"# ✅ {symbol} {kind}限价成交\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n"
|
||
f"- 类型:{typ}|{_wechat_direction_text(direction)}\n"
|
||
f"- 订单 ID:**{new_order_id}**\n"
|
||
f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n"
|
||
f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n"
|
||
f"- 计划 RR:{rr_txt}:1\n"
|
||
f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n"
|
||
)
|
||
send_wechat_msg(succ)
|
||
_finalize_key_monitor_one_shot(conn, row, succ, close_reason)
|
||
|
||
|
||
def _trigger_entry_exists_for_symbol(conn, symbol):
|
||
placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
|
||
row = conn.execute(
|
||
f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})",
|
||
(symbol, *TRIGGER_ENTRY_MONITOR_TYPES),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
def _add_trigger_entry_key_monitor(
|
||
conn,
|
||
symbol,
|
||
direction_sel,
|
||
entry,
|
||
sl,
|
||
tp,
|
||
monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
|
||
breakeven_enabled=0,
|
||
time_close_enabled=0,
|
||
time_close_hours=None,
|
||
):
|
||
mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip()
|
||
if mt not in TRIGGER_ENTRY_MONITOR_TYPES:
|
||
mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE
|
||
if _trigger_entry_exists_for_symbol(conn, symbol):
|
||
return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)"
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
mark = get_symbol_mark_price(symbol)
|
||
geom_err = validate_trigger_entry_geometry(
|
||
direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt
|
||
)
|
||
if geom_err:
|
||
return False, geom_err
|
||
rr_err = validate_trigger_entry_rr(
|
||
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
|
||
)
|
||
if rr_err:
|
||
return False, rr_err
|
||
entry = float(round_price_to_exchange(ex_sym, entry) or entry)
|
||
sl = float(round_price_to_exchange(ex_sym, sl) or sl)
|
||
tp = float(round_price_to_exchange(ex_sym, tp) or tp)
|
||
geom_err = validate_trigger_entry_geometry(
|
||
direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt
|
||
)
|
||
if geom_err:
|
||
return False, geom_err
|
||
rr_err = validate_trigger_entry_rr(
|
||
direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio
|
||
)
|
||
if rr_err:
|
||
return False, rr_err
|
||
ok_live, reason_live = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return False, reason_live
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||
ok_intent, intent_msg = check_trigger_entry_intent_limit(
|
||
conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT
|
||
)
|
||
if not ok_intent:
|
||
return False, intent_msg
|
||
if is_full_margin_mode(POSITION_SIZING_MODE):
|
||
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
|
||
if not ok_flat:
|
||
return False, flat_msg
|
||
if count_pending_trigger_entries(conn, trading_day) > 0:
|
||
return False, "全仓杠杆模式下仅允许一条待触发触价监控"
|
||
session_row = ensure_session(conn, trading_day)
|
||
_, trading_capital_live = get_exchange_capitals(force=True)
|
||
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
|
||
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
|
||
available_usdt = get_available_trading_usdt()
|
||
if is_full_margin_mode(POSITION_SIZING_MODE):
|
||
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
|
||
sizing, sizing_err = compute_full_margin_sizing(
|
||
symbol=symbol,
|
||
available_usdt=available_usdt if available_usdt is not None else 0.0,
|
||
capital_base=capital_base,
|
||
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
|
||
btc_leverage=BTC_LEVERAGE,
|
||
alt_leverage=ALT_LEVERAGE,
|
||
funds_decimals=2,
|
||
)
|
||
if sizing_err:
|
||
return False, sizing_err
|
||
margin_capital = float(sizing["margin_capital"])
|
||
amount_plan = None
|
||
else:
|
||
default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol)
|
||
leverage = int(default_leverage) if default_leverage else 5
|
||
if leverage <= 0:
|
||
leverage = 5
|
||
risk_fraction = calc_risk_fraction(direction_sel, entry, sl)
|
||
if risk_fraction is None:
|
||
return False, "止损方向不合法(相对计划入场价)"
|
||
risk_percent = max(0.01, float(RISK_PERCENT))
|
||
risk_amount = round(capital_base * risk_percent / 100.0, 4)
|
||
notional_value = round(risk_amount / risk_fraction, 4)
|
||
margin_capital = round(notional_value / leverage, 4)
|
||
if capital_base and margin_capital > capital_base:
|
||
return False, "以损定仓后保证金超过当前交易资金"
|
||
if available_usdt is not None:
|
||
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
|
||
if margin_capital > max_margin:
|
||
return (
|
||
False,
|
||
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
|
||
)
|
||
try:
|
||
amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
|
||
except Exception as e:
|
||
return False, friendly_exchange_error(e, available_usdt=available_usdt)
|
||
upper_px = round_price_to_exchange(ex_sym, max(entry, tp))
|
||
lower_px = round_price_to_exchange(ex_sym, min(entry, sl))
|
||
if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px):
|
||
upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl))
|
||
if upper_px <= lower_px:
|
||
lower_px = upper_px * 0.9999
|
||
be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
|
||
conn.execute(
|
||
"INSERT INTO key_monitors "
|
||
"(symbol, monitor_type, direction, upper, lower, "
|
||
"fib_entry_price, fib_stop_loss, fib_take_profit, "
|
||
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, "
|
||
"time_close_enabled, time_close_hours, session_date) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol,
|
||
mt,
|
||
direction_sel,
|
||
float(upper_px),
|
||
float(lower_px),
|
||
entry,
|
||
sl,
|
||
tp,
|
||
float(amount_plan) if amount_plan is not None else None,
|
||
margin_capital,
|
||
leverage,
|
||
be_flag,
|
||
tc_en,
|
||
tc_h,
|
||
trading_day,
|
||
),
|
||
)
|
||
return True, None
|
||
|
||
|
||
def _market_open_for_trigger_entry(
|
||
conn,
|
||
symbol,
|
||
direction,
|
||
exchange_symbol,
|
||
entry_price,
|
||
stop_loss,
|
||
take_profit,
|
||
monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE,
|
||
breakeven_enabled=0,
|
||
time_close_enabled=0,
|
||
time_close_hours=None,
|
||
):
|
||
"""触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致。"""
|
||
ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER)
|
||
if not ok_src:
|
||
return False, src_msg, None
|
||
now = app_now()
|
||
ok, reason = precheck_risk(conn, symbol, direction)
|
||
if not ok:
|
||
return False, f"风控拒绝下单:{reason}", None
|
||
ok_live, reason_live = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return False, reason_live, None
|
||
|
||
trading_day = get_trading_day(now)
|
||
opens_today_before = count_opens_for_trading_day(conn, trading_day)
|
||
session_row = ensure_session(conn, trading_day)
|
||
_, trading_capital_live = get_exchange_capitals(force=True)
|
||
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
|
||
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
|
||
|
||
trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower()
|
||
if trade_style not in ("trend", "swing"):
|
||
trade_style = "trend"
|
||
|
||
available_usdt = get_available_trading_usdt()
|
||
live_price = get_symbol_mark_price(symbol) or get_price(symbol)
|
||
if live_price is None:
|
||
return False, "获取标记价/实时价失败", None
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
lp_r = round_price_to_exchange(exchange_symbol, live_price)
|
||
if lp_r is not None:
|
||
live_price = float(lp_r)
|
||
|
||
entry_price = float(entry_price)
|
||
sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss))
|
||
tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit))
|
||
if sl_adj is not None:
|
||
stop_loss = float(sl_adj)
|
||
if tp_adj is not None:
|
||
take_profit = float(tp_adj)
|
||
|
||
planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit)
|
||
if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
|
||
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
|
||
return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None
|
||
|
||
risk_percent = max(0.01, float(RISK_PERCENT))
|
||
if is_full_margin_mode(POSITION_SIZING_MODE):
|
||
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
|
||
if not ok_flat:
|
||
return False, flat_msg, None
|
||
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
|
||
sizing, sizing_err = compute_full_margin_sizing(
|
||
symbol=symbol,
|
||
available_usdt=available_usdt if available_usdt is not None else 0.0,
|
||
capital_base=capital_base,
|
||
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
|
||
btc_leverage=BTC_LEVERAGE,
|
||
alt_leverage=ALT_LEVERAGE,
|
||
funds_decimals=2,
|
||
)
|
||
if sizing_err:
|
||
return False, sizing_err, None
|
||
margin_capital = float(sizing["margin_capital"])
|
||
notional_value = float(sizing["notional_value"])
|
||
position_ratio = float(sizing["position_ratio"])
|
||
risk_amount = margin_capital
|
||
else:
|
||
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||
leverage = int(default_leverage) if default_leverage else 5
|
||
if leverage <= 0:
|
||
leverage = 5
|
||
risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss)
|
||
if risk_fraction is None:
|
||
return False, "止损方向不合法(相对计划入场价)", None
|
||
risk_amount = round(capital_base * risk_percent / 100.0, 4)
|
||
notional_value = round(risk_amount / risk_fraction, 4)
|
||
margin_capital = round(notional_value / leverage, 4)
|
||
if capital_base and margin_capital > capital_base:
|
||
return False, "以损定仓后保证金超过当前交易资金", None
|
||
if available_usdt is not None:
|
||
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
|
||
if margin_capital > max_margin:
|
||
return (
|
||
False,
|
||
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
|
||
None,
|
||
)
|
||
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
|
||
|
||
try:
|
||
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
|
||
contract_size = get_contract_size(exchange_symbol)
|
||
base_amount = round(float(amount) * contract_size, 8)
|
||
order_resp = place_exchange_order(
|
||
exchange_symbol, direction, amount, leverage,
|
||
stop_loss=stop_loss, take_profit=take_profit,
|
||
)
|
||
open_order_id = order_resp.get("id", "")
|
||
tpsl_attached = bool(order_resp.get("tpsl_attached"))
|
||
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
|
||
except Exception as e:
|
||
return False, friendly_exchange_error(e, available_usdt=available_usdt), None
|
||
|
||
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
|
||
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
|
||
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
|
||
|
||
opened_at_bj = app_now_str()
|
||
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
|
||
planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
|
||
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
|
||
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
|
||
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
|
||
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage)
|
||
if risk_amount_final is None:
|
||
risk_amount_final = risk_amount
|
||
else:
|
||
try:
|
||
risk_amount_final = round(float(risk_amount_final), 4)
|
||
except (TypeError, ValueError):
|
||
risk_amount_final = risk_amount
|
||
|
||
if direction == "short":
|
||
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
|
||
else:
|
||
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
|
||
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
|
||
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||
tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms)
|
||
risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
|
||
|
||
conn.execute(
|
||
"INSERT INTO order_monitors "
|
||
"(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
|
||
"margin_capital, leverage, trade_style, risk_percent, risk_amount, "
|
||
"breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
|
||
"notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, "
|
||
"time_close_enabled, time_close_hours, time_close_at_ms) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol,
|
||
exchange_symbol,
|
||
direction,
|
||
trigger_price,
|
||
stop_loss,
|
||
stop_loss,
|
||
take_profit,
|
||
margin_capital,
|
||
leverage,
|
||
trade_style,
|
||
risk_percent_db,
|
||
risk_amount_final,
|
||
breakeven_rr_trigger,
|
||
breakeven_offset_pct,
|
||
breakeven_step_r,
|
||
0,
|
||
breakeven_price,
|
||
be_enabled,
|
||
notional_value,
|
||
position_ratio,
|
||
base_amount,
|
||
amount,
|
||
open_order_id,
|
||
opened_at_bj,
|
||
opened_at_ms,
|
||
trading_day,
|
||
ORDER_MONITOR_TYPE_KEY_AUTO,
|
||
stored_key_signal_type(monitor_type),
|
||
tc_en,
|
||
tc_h,
|
||
tc_at,
|
||
),
|
||
)
|
||
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
|
||
opens_today_after = count_opens_for_trading_day(conn, trading_day)
|
||
|
||
return True, None, {
|
||
"new_order_id": new_order_id,
|
||
"open_order_id": open_order_id,
|
||
"trigger_price": trigger_price,
|
||
"planned_rr_fill": planned_rr_fill,
|
||
"risk_amount_final": risk_amount_final,
|
||
"margin_capital": margin_capital,
|
||
"leverage": leverage,
|
||
"amount": amount,
|
||
"tpsl_attached": tpsl_attached,
|
||
"opens_today_before": opens_today_before,
|
||
"opens_today_after": opens_today_after,
|
||
"trading_day": trading_day,
|
||
"stop_loss": stop_loss,
|
||
"take_profit": take_profit,
|
||
}
|
||
|
||
|
||
def _execute_trigger_entry_cross(conn, row):
|
||
"""标记价触达计划入场:先删监控行防重复触发,再市价开仓。"""
|
||
symbol = row["symbol"]
|
||
direction = (row["direction"] or "long").lower()
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
entry = float(_sqlite_row_val(row, "fib_entry_price") or 0)
|
||
sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0)
|
||
tp = float(_sqlite_row_val(row, "fib_take_profit") or 0)
|
||
be_en = breakeven_enabled_from_row(row, 0)
|
||
tc_en, tc_h, _ = time_close_settings_from_row(row)
|
||
|
||
kid = int(row["id"])
|
||
conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
|
||
conn.commit()
|
||
|
||
try:
|
||
ok, err, det = _market_open_for_trigger_entry(
|
||
conn,
|
||
symbol,
|
||
direction,
|
||
ex_sym,
|
||
entry,
|
||
sl,
|
||
tp,
|
||
monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE),
|
||
breakeven_enabled=be_en,
|
||
time_close_enabled=tc_en,
|
||
time_close_hours=tc_h,
|
||
)
|
||
except Exception as e:
|
||
fail_msg = friendly_exchange_error(e)
|
||
send_wechat_msg(
|
||
f"# ❌ {symbol} 触价开仓异常\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
|
||
f"- 原因:{fail_msg}\n"
|
||
)
|
||
insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
|
||
return False, fail_msg
|
||
|
||
if ok and det:
|
||
rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-"
|
||
msg = (
|
||
f"# ✅ {symbol} 触价开仓成交\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n"
|
||
f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n"
|
||
f"- 订单 ID:**{det.get('new_order_id')}**\n"
|
||
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
|
||
f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n"
|
||
f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n"
|
||
f"- 计划 RR:{rr_txt}:1\n"
|
||
f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED)
|
||
return True, None
|
||
fail_msg = err or "触价触发后开仓失败"
|
||
send_wechat_msg(
|
||
f"# ❌ {symbol} 触价开仓失败\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n"
|
||
f"- 原因:{fail_msg}\n"
|
||
)
|
||
insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED)
|
||
return False, fail_msg
|
||
|
||
|
||
def check_trigger_entry_key_monitors():
|
||
conn = get_db()
|
||
placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES))
|
||
rows = conn.execute(
|
||
f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})",
|
||
tuple(TRIGGER_ENTRY_MONITOR_TYPES),
|
||
).fetchall()
|
||
now_dt = app_now()
|
||
for r in rows:
|
||
symbol = r["symbol"]
|
||
direction = (r["direction"] or "long").lower()
|
||
mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip()
|
||
entry = float(_sqlite_row_val(r, "fib_entry_price") or 0)
|
||
sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0)
|
||
tp = float(_sqlite_row_val(r, "fib_take_profit") or 0)
|
||
kid = int(r["id"])
|
||
if entry <= 0 or sl <= 0 or tp <= 0:
|
||
_finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid")
|
||
continue
|
||
mark = get_symbol_mark_price(symbol)
|
||
if mark is None:
|
||
continue
|
||
prev_mark = _sqlite_row_val(r, "last_mark_price")
|
||
prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None
|
||
if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS):
|
||
exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS)
|
||
msg = (
|
||
f"# ⚠️ {symbol} 触价开仓已过期\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{mt}|{_wechat_direction_text(direction)}\n"
|
||
f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED)
|
||
continue
|
||
inv = trigger_entry_invalidate(mt, direction, mark, sl, tp)
|
||
if inv == "tp":
|
||
msg = (
|
||
f"# ⚠️ {symbol} 触价开仓失效\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE)
|
||
continue
|
||
if inv == "sl":
|
||
msg = (
|
||
f"# ⚠️ {symbol} 触价开仓失效\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
_finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE)
|
||
continue
|
||
if trigger_should_fire(mt, direction, mark, entry, prev_mark_f):
|
||
_execute_trigger_entry_cross(conn, r)
|
||
continue
|
||
conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def check_fib_key_monitors():
|
||
conn = get_db()
|
||
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
|
||
for r in rows:
|
||
typ = (r["monitor_type"] or "").strip()
|
||
if not is_limit_key_monitor_type(typ):
|
||
continue
|
||
symbol = r["symbol"]
|
||
direction = (r["direction"] or "long").lower()
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
up, low = float(r["upper"]), float(r["lower"])
|
||
oid = _sqlite_row_val(r, "fib_limit_order_id")
|
||
if is_false_breakout_key_monitor_type(typ):
|
||
now_dt = app_now()
|
||
if is_false_breakout_expired(r["created_at"], now_dt):
|
||
_cancel_fib_monitor_limit(r)
|
||
exp_txt = expires_at_text(r["created_at"])
|
||
msg = (
|
||
f"# ⚠️ {symbol} 假突破监控已过期\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{typ}|{_wechat_direction_text(direction)}\n"
|
||
f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n"
|
||
f"- 已撤销限价单\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
_finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired")
|
||
continue
|
||
mark = get_symbol_mark_price(symbol)
|
||
if mark is None:
|
||
continue
|
||
status = fib_limit_order_status(ex_sym, oid) if oid else "missing"
|
||
if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)):
|
||
_finalize_fib_key_fill(conn, r)
|
||
continue
|
||
if is_fib_key_monitor_type(typ) and status == "open":
|
||
if fib_invalidate_by_mark(direction, mark, up, low):
|
||
_cancel_fib_monitor_limit(r)
|
||
msg = (
|
||
f"# ⚠️ {symbol} 斐波监控失效\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{typ}|{_wechat_direction_text(direction)}\n"
|
||
f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
_finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate")
|
||
continue
|
||
if is_fib_key_monitor_type(typ) and status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low):
|
||
msg = (
|
||
f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 标记价触达止盈侧,本条已结案\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
_finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate")
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def _false_breakout_exists_for_symbol(conn, symbol):
|
||
row = conn.execute(
|
||
"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?",
|
||
(symbol, FALSE_BREAKOUT_MONITOR_TYPE),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
def _add_false_breakout_key_monitor(
|
||
conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,
|
||
time_close_enabled=0, time_close_hours=None,
|
||
):
|
||
if _false_breakout_exists_for_symbol(conn, symbol):
|
||
return False, f"{symbol} 已有假突破监控(同币仅允许一条)"
|
||
plan = calc_false_breakout_plan(direction_sel, key_px)
|
||
if not plan:
|
||
return False, "假突破价位无效,请核对方向与关键价位"
|
||
entry, sl, tp = plan
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
entry = round_price_to_exchange(ex_sym, entry)
|
||
sl = round_price_to_exchange(ex_sym, sl)
|
||
tp = round_price_to_exchange(ex_sym, tp)
|
||
if entry is None or sl is None or tp is None:
|
||
return False, "假突破价位经交易所精度舍入后无效"
|
||
entry, sl, tp = float(entry), float(sl), float(tp)
|
||
ok, reason = precheck_risk(conn, symbol, direction_sel)
|
||
if not ok:
|
||
return False, reason
|
||
ok_live, reason_live = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return False, reason_live
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
session_row = ensure_session(conn, trading_day)
|
||
_, trading_capital_live = get_exchange_capitals(force=True)
|
||
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
|
||
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
|
||
default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol)
|
||
leverage = int(default_leverage) if default_leverage else 5
|
||
if leverage <= 0:
|
||
leverage = 5
|
||
available_usdt = get_available_trading_usdt()
|
||
risk_fraction = calc_risk_fraction(direction_sel, entry, sl)
|
||
if risk_fraction is None:
|
||
return False, "止损方向不合法(相对挂单价);请核对方向与关键价位"
|
||
risk_percent = max(0.01, float(RISK_PERCENT))
|
||
risk_amount = round(capital_base * risk_percent / 100.0, 4)
|
||
notional_value = round(risk_amount / risk_fraction, 4)
|
||
margin_capital = round(notional_value / leverage, 4)
|
||
if capital_base and margin_capital > capital_base:
|
||
return False, "以损定仓后保证金超过当前交易资金"
|
||
if available_usdt is not None:
|
||
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
|
||
if margin_capital > max_margin:
|
||
return (
|
||
False,
|
||
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
|
||
)
|
||
try:
|
||
amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
|
||
order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry)
|
||
oid = str(order_resp.get("id") or "")
|
||
if not oid:
|
||
return False, "交易所未返回限价单 ID"
|
||
except Exception as e:
|
||
return False, friendly_exchange_error(e, available_usdt=available_usdt)
|
||
be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
|
||
conn.execute(
|
||
"INSERT INTO key_monitors "
|
||
"(symbol, monitor_type, direction, upper, lower, "
|
||
"fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, "
|
||
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px,
|
||
oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h,
|
||
),
|
||
)
|
||
return True, None
|
||
|
||
|
||
def _add_fib_key_monitor(
|
||
conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0,
|
||
time_close_enabled=0, time_close_hours=None,
|
||
):
|
||
if _fib_key_exists_for_symbol(conn, symbol):
|
||
return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)"
|
||
ratio = fib_ratio_from_type(mt)
|
||
plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio)
|
||
if not plan:
|
||
return False, "斐波上下沿无效(需上沿 H > 下沿 L)"
|
||
entry, sl, tp = plan
|
||
ex_sym = normalize_exchange_symbol(symbol)
|
||
entry = round_price_to_exchange(ex_sym, entry)
|
||
sl = round_price_to_exchange(ex_sym, sl)
|
||
tp = round_price_to_exchange(ex_sym, tp)
|
||
if entry is None or sl is None or tp is None:
|
||
return False, "斐波价位经交易所精度舍入后无效"
|
||
entry, sl, tp = float(entry), float(sl), float(tp)
|
||
planned_rr = calc_rr_ratio(direction_sel, entry, sl, tp)
|
||
if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR:
|
||
fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
|
||
return False, f"斐波计划盈亏比 {fmt_rr}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)"
|
||
ok, reason = precheck_risk(conn, symbol, direction_sel)
|
||
if not ok:
|
||
return False, reason
|
||
ok_live, reason_live = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
return False, reason_live
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
session_row = ensure_session(conn, trading_day)
|
||
_, trading_capital_live = get_exchange_capitals(force=True)
|
||
live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
|
||
capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital)
|
||
default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol)
|
||
leverage = int(default_leverage) if default_leverage else 5
|
||
if leverage <= 0:
|
||
leverage = 5
|
||
available_usdt = get_available_trading_usdt()
|
||
risk_fraction = calc_risk_fraction(direction_sel, entry, sl)
|
||
if risk_fraction is None:
|
||
return False, "止损方向不合法(相对挂单价 E);请核对上下沿与方向"
|
||
risk_percent = max(0.01, float(RISK_PERCENT))
|
||
risk_amount = round(capital_base * risk_percent / 100.0, 4)
|
||
notional_value = round(risk_amount / risk_fraction, 4)
|
||
margin_capital = round(notional_value / leverage, 4)
|
||
if capital_base and margin_capital > capital_base:
|
||
return False, "以损定仓后保证金超过当前交易资金"
|
||
if available_usdt is not None:
|
||
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4)
|
||
if margin_capital > max_margin:
|
||
return (
|
||
False,
|
||
f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U",
|
||
)
|
||
try:
|
||
amount, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry)
|
||
order_resp = place_fib_limit_order(ex_sym, direction_sel, amount, leverage, entry)
|
||
oid = str(order_resp.get("id") or "")
|
||
if not oid:
|
||
return False, "交易所未返回限价单 ID"
|
||
except Exception as e:
|
||
return False, friendly_exchange_error(e, available_usdt=available_usdt)
|
||
be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
|
||
conn.execute(
|
||
"INSERT INTO key_monitors "
|
||
"(symbol, monitor_type, direction, upper, lower, "
|
||
"fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, "
|
||
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol, mt, direction_sel, upper_px, lower_px,
|
||
oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h,
|
||
),
|
||
)
|
||
return True, None
|
||
|
||
|
||
# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒)
|
||
def check_key_monitors():
|
||
conn = get_db()
|
||
rows = conn.execute("SELECT * FROM key_monitors").fetchall()
|
||
for r in rows:
|
||
sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"]
|
||
typ = (typ_raw or "").strip()
|
||
if is_limit_key_monitor_type(typ):
|
||
continue
|
||
if typ in KEY_MONITOR_RS_TYPES:
|
||
try:
|
||
_process_key_rs_level_alert(conn, r)
|
||
except Exception as e:
|
||
print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}")
|
||
continue
|
||
|
||
direction = (r["direction"] or "long").lower()
|
||
if direction == KEY_DIRECTION_WATCH:
|
||
continue
|
||
if typ in KEY_MONITOR_AUTO_TYPES:
|
||
mark = get_symbol_mark_price(sym)
|
||
if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low):
|
||
edge = float(low) if direction == "long" else float(up)
|
||
edge_label = box_breakout_invalidate_edge_label(direction)
|
||
msg = (
|
||
f"# ⚠️ {sym} 关键位监控失效\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{typ}|{_wechat_direction_text(direction)}\n"
|
||
f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} "
|
||
f"{format_price_for_symbol(sym, edge)}(设置失效)\n"
|
||
)
|
||
send_wechat_msg(msg)
|
||
_finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break")
|
||
continue
|
||
try:
|
||
checks = _key_hard_checks(sym, direction, up, low, typ)
|
||
except Exception:
|
||
checks = {"ok": False}
|
||
if not checks.get("ok"):
|
||
continue
|
||
|
||
btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h")
|
||
coin4h_status, _, _ = _status_by_ema55(sym, "4h")
|
||
risk_tip = None
|
||
if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"):
|
||
risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损。"
|
||
|
||
key_price = float(low) if direction == "long" else float(up)
|
||
hard_lines = _key_hard_lines_from_checks(checks)
|
||
trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str()
|
||
|
||
if typ not in KEY_MONITOR_AUTO_TYPES:
|
||
continue
|
||
|
||
plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks)
|
||
if not plan_tuple:
|
||
fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)"
|
||
rr_msg = (
|
||
f"# ⚠️ {sym} 关键位自动单:计划无效\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n"
|
||
f"- 方向:**{_wechat_direction_text(direction)}**\n"
|
||
f"- 触发时间:`{trigger_time}`\n"
|
||
f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n"
|
||
f"- **{fmt_rr}**(未开仓)\n"
|
||
"---\n"
|
||
"### 硬条件\n"
|
||
+ "\n".join(f"- {x}" for x in hard_lines)
|
||
)
|
||
if risk_tip:
|
||
rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
|
||
send_wechat_msg(rr_msg)
|
||
_finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient")
|
||
continue
|
||
E, sl_raw, tp_raw, box_h = plan_tuple
|
||
exchange_symbol = normalize_exchange_symbol(sym)
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
sl_px = round_price_to_exchange(exchange_symbol, sl_raw)
|
||
tp_px = round_price_to_exchange(exchange_symbol, tp_raw)
|
||
if sl_px is not None:
|
||
sl_raw = float(sl_px)
|
||
if tp_px is not None:
|
||
tp_raw = float(tp_px)
|
||
|
||
planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw)
|
||
rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR
|
||
|
||
if not rr_ok:
|
||
fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)"
|
||
plan_line = sl_tp_plan_summary_text(
|
||
sl_tp_mode, direction, E, sl_raw, tp_raw, box_h,
|
||
outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
|
||
trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
|
||
)
|
||
rr_msg = (
|
||
f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{typ}|{plan_line}\n"
|
||
f"- 方向:**{_wechat_direction_text(direction)}**\n"
|
||
f"- 触发时间:`{trigger_time}`\n"
|
||
f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n"
|
||
f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n"
|
||
f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n"
|
||
f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n"
|
||
f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n"
|
||
"---\n"
|
||
"### 硬条件\n"
|
||
+ "\n".join(f"- {x}" for x in hard_lines)
|
||
)
|
||
if risk_tip:
|
||
rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
|
||
send_wechat_msg(rr_msg)
|
||
_finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient")
|
||
continue
|
||
|
||
key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None
|
||
be_on = breakeven_enabled_from_row(r, 0)
|
||
tc_en, tc_h, _ = time_close_settings_from_row(r)
|
||
ok_trade, trade_err, det = _market_open_for_key_monitor(
|
||
conn,
|
||
sym,
|
||
direction,
|
||
exchange_symbol,
|
||
sl_raw,
|
||
tp_raw,
|
||
key_signal_type=key_sig,
|
||
breakeven_enabled=1 if be_on else 0,
|
||
time_close_enabled=tc_en,
|
||
time_close_hours=tc_h,
|
||
)
|
||
planned_rr_txt = (
|
||
format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-"
|
||
)
|
||
if not ok_trade:
|
||
fail_msg = (
|
||
f"# ❌ {sym} 关键位自动单失败\n"
|
||
f"**账户:{_wechat_account_label()}**\n"
|
||
f"- 类型:{typ}\n"
|
||
f"- 方向:**{_wechat_direction_text(direction)}**\n"
|
||
f"- 触发时间:`{trigger_time}`\n"
|
||
f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n"
|
||
f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n"
|
||
f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n"
|
||
f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n"
|
||
f"- **失败原因:{trade_err}**\n"
|
||
"---\n"
|
||
"### 硬条件\n"
|
||
+ "\n".join(f"- {x}" for x in hard_lines)
|
||
)
|
||
if risk_tip:
|
||
fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}"
|
||
send_wechat_msg(fail_msg)
|
||
_finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed")
|
||
continue
|
||
|
||
tpsl_txt = (
|
||
"已在交易所挂条件委托(止盈、止损触发单)"
|
||
if det.get("tpsl_attached")
|
||
else "⚠️ 条件委托挂接状态异常或未挂上"
|
||
)
|
||
rr_fill = det.get("planned_rr_fill")
|
||
rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-"
|
||
|
||
succ_msg_lines = [
|
||
f"# ✅ {sym} 关键位自动开仓成功",
|
||
f"**账户:{_wechat_account_label()}**",
|
||
f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)",
|
||
f"- 页面订单 ID:**{det['new_order_id']}**",
|
||
f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`",
|
||
f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}",
|
||
f"- 方向:**{_wechat_direction_text(direction)}**",
|
||
f"- 触发时间:`{trigger_time}`",
|
||
f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)",
|
||
f"- **计划 RR(E):{planned_rr_txt}:1**",
|
||
f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**",
|
||
f"- **成交价侧计划 RR:**{rr_fill_txt}:1",
|
||
f"- 止损:{format_wechat_scalar_2dp(sl_raw)}",
|
||
f"- 止盈:{format_price_for_symbol(sym, tp_raw)}",
|
||
f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x",
|
||
f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}",
|
||
f"- **{tpsl_txt}**",
|
||
f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}",
|
||
f"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}",
|
||
]
|
||
succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines])
|
||
if risk_tip:
|
||
succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"])
|
||
succ_msg = "\n".join(succ_msg_lines)
|
||
send_wechat_msg(succ_msg)
|
||
_finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened")
|
||
|
||
if should_send_daily_open_alert(
|
||
det.get("opens_today_before", 0),
|
||
det.get("opens_today_after", 0),
|
||
DAILY_OPEN_ALERT_THRESHOLD,
|
||
):
|
||
advice = ai_short_advice(
|
||
build_daily_open_alert_prompt(
|
||
det["trading_day"],
|
||
det.get("opens_today_after", 0),
|
||
DAILY_OPEN_ALERT_THRESHOLD,
|
||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||
detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x。",
|
||
)
|
||
)
|
||
if advice:
|
||
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}")
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 止盈止损监控(已修复:严格区分多空,无默认做多)
|
||
def check_order_monitors():
|
||
conn = get_db()
|
||
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||
for r in rows:
|
||
pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"]
|
||
margin_capital = r["margin_capital"] or DAILY_START_CAPITAL
|
||
leverage = r["leverage"] or infer_leverage(sym)
|
||
trade_basis_row = row_to_dict(r)
|
||
ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym)
|
||
if _order_row_exchange_margin_usdt(r) is None and exchange_private_api_configured():
|
||
pm = get_live_position_exchange_metrics(ex_sym, direction, order_leverage=leverage)
|
||
if pm and pm.get("initial_margin") is not None:
|
||
try:
|
||
mv = float(pm["initial_margin"])
|
||
if mv > 0:
|
||
conn.execute(
|
||
"UPDATE order_monitors SET exchange_margin_usdt=? WHERE id=?",
|
||
(round(mv, 4), pid),
|
||
)
|
||
trade_basis_row["exchange_margin_usdt"] = round(mv, 4)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
session_date = r["session_date"] or get_trading_day()
|
||
p = get_price(sym)
|
||
if not p: continue
|
||
|
||
# 到达设定 R 倍后,按阶梯持续上移止损(本地风控层)
|
||
risk_amount = float(r["risk_amount"] or 0)
|
||
breakeven_armed = int(r["breakeven_armed"] or 0)
|
||
trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER)
|
||
step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0)
|
||
step_r = 1.0 if step_r <= 0 else step_r
|
||
breakeven_enabled = True
|
||
try:
|
||
if "breakeven_enabled" in r.keys():
|
||
breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0
|
||
except Exception:
|
||
breakeven_enabled = True
|
||
if breakeven_enabled and risk_amount > 0 and trigger_rr > 0:
|
||
now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
|
||
now_rr = now_pnl / risk_amount
|
||
if now_rr >= trigger_rr:
|
||
steps = int((now_rr - trigger_rr) // step_r)
|
||
locked_r = max(0.0, steps * step_r)
|
||
notional = float(margin_capital or 0) * float(leverage or 0)
|
||
risk_frac = (risk_amount / notional) if notional > 0 else None
|
||
if risk_frac and risk_frac > 0:
|
||
new_sl = calc_breakeven_stop(
|
||
direction,
|
||
trigger_price,
|
||
risk_frac,
|
||
locked_r=locked_r,
|
||
offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT),
|
||
)
|
||
if new_sl is not None:
|
||
should_move = (direction == "short" and new_sl < float(stop_loss)) or (
|
||
direction == "long" and new_sl > float(stop_loss)
|
||
)
|
||
if should_move:
|
||
was_armed = breakeven_armed
|
||
ex_sym = resolve_monitor_exchange_symbol(r)
|
||
new_sl = round_price_to_exchange(ex_sym, new_sl)
|
||
tp_ex = float(take_profit or 0)
|
||
ok_live, _live_reason = ensure_exchange_live_ready()
|
||
synced_ex = not ok_live
|
||
if ok_live and tp_ex > 0:
|
||
try:
|
||
replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex)
|
||
synced_ex = True
|
||
_clear_breakeven_exchange_warn(pid)
|
||
except Exception as e:
|
||
print(
|
||
f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}",
|
||
flush=True,
|
||
)
|
||
_send_breakeven_exchange_warn_once(
|
||
pid,
|
||
f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_exchange_error(e)}",
|
||
)
|
||
elif ok_live:
|
||
print(
|
||
f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit",
|
||
flush=True,
|
||
)
|
||
if synced_ex:
|
||
conn.execute(
|
||
"UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?",
|
||
(new_sl, new_sl, pid),
|
||
)
|
||
stop_loss = new_sl
|
||
breakeven_armed = 1
|
||
if not was_armed:
|
||
arm_txt = "保本止盈"
|
||
be_msg = build_wechat_breakeven_message(
|
||
sym,
|
||
direction,
|
||
arm_txt,
|
||
now_rr,
|
||
locked_r,
|
||
new_sl,
|
||
)
|
||
if ok_live:
|
||
be_msg += "\n- 交易所:已先撤后挂止盈止损"
|
||
send_wechat_msg(be_msg)
|
||
|
||
res = None
|
||
if should_trigger_time_close(r):
|
||
res = TIME_CLOSE_RESULT
|
||
# 做多
|
||
if not res and direction == "long":
|
||
if p >= take_profit: res = "止盈"
|
||
elif p <= stop_loss: res = "止损"
|
||
# 做空
|
||
elif not res and direction == "short":
|
||
if p <= take_profit: res = "止盈"
|
||
elif p >= stop_loss: res = "止损"
|
||
|
||
if res:
|
||
now = app_now()
|
||
opened_at = get_opened_at_value(r)
|
||
opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None)
|
||
closed_at = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
hold_seconds = calc_hold_seconds(opened_at, now)
|
||
pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
|
||
if res == "止损" and float(pnl_amount or 0) > 0:
|
||
res = normalize_result_with_pnl("止损", pnl_amount)
|
||
else:
|
||
res = normalize_result_with_pnl(res, pnl_amount)
|
||
close_order_id = ""
|
||
try:
|
||
close_resp = close_exchange_order(r)
|
||
close_order_id = close_resp.get("id", "")
|
||
# 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细。
|
||
exit_p = extract_trade_price_from_order(close_resp)
|
||
if exit_p and exit_p > 0:
|
||
pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage)
|
||
guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p)
|
||
if guessed_res:
|
||
res = normalize_result_with_pnl(guessed_res, pnl_amount)
|
||
else:
|
||
res = normalize_result_with_pnl(res, pnl_amount)
|
||
else:
|
||
ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym)
|
||
tr = fetch_latest_closing_fill(
|
||
ex_sym,
|
||
direction,
|
||
opened_at,
|
||
opened_at_ms=opened_at_ms,
|
||
)
|
||
if tr and tr.get("price"):
|
||
try:
|
||
exit_p = float(tr["price"])
|
||
pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage)
|
||
guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p)
|
||
if guessed_res:
|
||
if guessed_res == "止损" and float(pnl_amount or 0) > 0:
|
||
res = normalize_result_with_pnl("止损", pnl_amount)
|
||
else:
|
||
res = normalize_result_with_pnl(guessed_res, pnl_amount)
|
||
else:
|
||
res = normalize_result_with_pnl(res, pnl_amount)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
ts = tr.get("timestamp")
|
||
if ts:
|
||
closed_at = ms_to_app_local_str(int(ts))
|
||
hold_seconds = calc_hold_seconds(
|
||
opened_at, parse_dt_for_trading_day(closed_at) or now
|
||
)
|
||
except Exception as e:
|
||
if is_no_position_error(str(e)):
|
||
ex_sym = r["exchange_symbol"] or normalize_exchange_symbol(sym)
|
||
cancel_gate_swap_trigger_orders(ex_sym)
|
||
tr = fetch_latest_closing_fill(
|
||
ex_sym,
|
||
direction,
|
||
opened_at,
|
||
opened_at_ms=opened_at_ms,
|
||
)
|
||
if tr and tr.get("price"):
|
||
try:
|
||
exit_p = float(tr["price"])
|
||
pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage)
|
||
# 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判。
|
||
guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p)
|
||
if guessed_res:
|
||
if guessed_res == "止损" and float(pnl_amount or 0) > 0:
|
||
res = normalize_result_with_pnl("止损", pnl_amount)
|
||
else:
|
||
res = normalize_result_with_pnl(guessed_res, pnl_amount)
|
||
else:
|
||
res = normalize_result_with_pnl(res, pnl_amount)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
ts = tr.get("timestamp")
|
||
if ts:
|
||
closed_at = ms_to_app_local_str(int(ts))
|
||
hold_seconds = calc_hold_seconds(
|
||
opened_at, parse_dt_for_trading_day(closed_at) or now
|
||
)
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=sym,
|
||
monitor_type=trade_record_monitor_type(conn, r),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||
key_signal_type=order_row_key_signal_type(r),
|
||
direction=direction,
|
||
trigger_price=trigger_price,
|
||
stop_loss=stop_loss,
|
||
initial_stop_loss=r["initial_stop_loss"] or stop_loss,
|
||
take_profit=take_profit,
|
||
margin_capital=margin_capital_for_trade_record(trade_basis_row),
|
||
leverage=leverage,
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trade_style=r["trade_style"],
|
||
risk_amount=r["risk_amount"],
|
||
planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit),
|
||
actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]),
|
||
result=res,
|
||
miss_reason=handoff_trade_miss_reason(
|
||
"触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)",
|
||
r,
|
||
),
|
||
opened_at=opened_at,
|
||
closed_at=closed_at,
|
||
)
|
||
session_capital = update_session_capital(conn, session_date, pnl_amount)
|
||
send_wechat_msg(
|
||
build_wechat_close_message(
|
||
symbol=sym,
|
||
direction=direction,
|
||
result=f"{res}(交易所已先行平仓)",
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trigger_price=trigger_price,
|
||
current_price=p,
|
||
stop_loss=stop_loss,
|
||
take_profit=take_profit,
|
||
close_order_id="-",
|
||
extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉",
|
||
session_capital_fallback=session_capital,
|
||
)
|
||
)
|
||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,))
|
||
conn.commit()
|
||
continue
|
||
ex_sym_fail = r["exchange_symbol"] or normalize_exchange_symbol(sym)
|
||
cancel_gate_swap_trigger_orders(ex_sym_fail)
|
||
live_contracts = get_live_position_contracts(ex_sym_fail, direction)
|
||
if live_contracts is not None and live_contracts <= 0:
|
||
record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close(
|
||
r, opened_at, opened_at_ms=opened_at_ms
|
||
)
|
||
record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}"
|
||
monitor_status = "stopped"
|
||
else:
|
||
record_res, record_pnl, record_closed = res, pnl_amount, closed_at
|
||
record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}"
|
||
monitor_status = "error"
|
||
record_hold = calc_hold_seconds(
|
||
opened_at, parse_dt_for_trading_day(record_closed) or now
|
||
)
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=sym,
|
||
monitor_type=trade_record_monitor_type(conn, r),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||
key_signal_type=order_row_key_signal_type(r),
|
||
direction=direction,
|
||
trigger_price=trigger_price,
|
||
stop_loss=stop_loss,
|
||
initial_stop_loss=r["initial_stop_loss"] or stop_loss,
|
||
take_profit=take_profit,
|
||
margin_capital=margin_capital_for_trade_record(trade_basis_row),
|
||
leverage=leverage,
|
||
pnl_amount=record_pnl,
|
||
hold_seconds=record_hold,
|
||
trade_style=r["trade_style"],
|
||
risk_amount=r["risk_amount"],
|
||
planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit),
|
||
actual_rr=calc_actual_rr(record_pnl, r["risk_amount"]),
|
||
result=record_res,
|
||
miss_reason=handoff_trade_miss_reason(record_miss, r),
|
||
opened_at=opened_at,
|
||
closed_at=record_closed,
|
||
)
|
||
session_capital = update_session_capital(conn, session_date, record_pnl)
|
||
conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid))
|
||
conn.commit()
|
||
send_wechat_msg(
|
||
build_wechat_monitor_error_message(
|
||
symbol=sym,
|
||
direction=direction,
|
||
scene=f"触发{res}后交易所平仓失败",
|
||
error_text=str(e),
|
||
)
|
||
)
|
||
if monitor_status == "stopped":
|
||
send_wechat_msg(
|
||
build_wechat_close_message(
|
||
symbol=sym,
|
||
direction=direction,
|
||
result=f"{record_res}(已补记入交易记录)",
|
||
pnl_amount=record_pnl,
|
||
hold_seconds=record_hold,
|
||
trigger_price=trigger_price,
|
||
current_price=p,
|
||
stop_loss=stop_loss,
|
||
take_profit=take_profit,
|
||
close_order_id="-",
|
||
extra_note=record_miss,
|
||
session_capital_fallback=session_capital,
|
||
)
|
||
)
|
||
continue
|
||
cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(sym))
|
||
session_capital = update_session_capital(conn, session_date, pnl_amount)
|
||
send_wechat_msg(
|
||
build_wechat_close_message(
|
||
symbol=sym,
|
||
direction=direction,
|
||
result=res,
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trigger_price=trigger_price,
|
||
current_price=p,
|
||
stop_loss=stop_loss,
|
||
take_profit=take_profit,
|
||
close_order_id=close_order_id or "-",
|
||
session_capital_fallback=session_capital,
|
||
)
|
||
)
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=sym,
|
||
monitor_type=trade_record_monitor_type(conn, r),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||
key_signal_type=order_row_key_signal_type(r),
|
||
direction=direction,
|
||
trigger_price=trigger_price,
|
||
stop_loss=stop_loss,
|
||
initial_stop_loss=r["initial_stop_loss"] or stop_loss,
|
||
take_profit=take_profit,
|
||
margin_capital=margin_capital_for_trade_record(trade_basis_row),
|
||
leverage=leverage,
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trade_style=r["trade_style"],
|
||
risk_amount=r["risk_amount"],
|
||
planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit),
|
||
actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]),
|
||
result=res,
|
||
miss_reason=handoff_trade_miss_reason(None, r),
|
||
opened_at=opened_at,
|
||
closed_at=closed_at,
|
||
)
|
||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid))
|
||
clear_key_sizing_snapshot_if_flat(conn, get_trading_day())
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def force_close_before_reset():
|
||
if not FORCE_CLOSE_ENABLED:
|
||
return
|
||
now = app_now()
|
||
# 每天北京时间指定整点小时内执行一次性兜底清仓(默认 00:xx)
|
||
if now.hour != FORCE_CLOSE_BJ_HOUR:
|
||
return
|
||
conn = get_db()
|
||
rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||
for r in rows:
|
||
p = get_price(r["symbol"])
|
||
if not p:
|
||
continue
|
||
direction = r["direction"]
|
||
trigger_price = r["trigger_price"]
|
||
margin_capital = r["margin_capital"] or DAILY_START_CAPITAL
|
||
leverage = r["leverage"] or infer_leverage(r["symbol"])
|
||
session_date = r["session_date"] or get_trading_day(now)
|
||
opened_at = get_opened_at_value(r)
|
||
closed_at = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
hold_seconds = calc_hold_seconds(opened_at, now)
|
||
pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage)
|
||
try:
|
||
close_resp = close_exchange_order(r)
|
||
close_order_id = close_resp.get("id", "")
|
||
cancel_gate_swap_trigger_orders(r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]))
|
||
except Exception as e:
|
||
conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],))
|
||
conn.commit()
|
||
send_wechat_msg(
|
||
build_wechat_monitor_error_message(
|
||
symbol=r["symbol"],
|
||
direction=direction,
|
||
scene="强制清仓失败",
|
||
error_text=str(e),
|
||
)
|
||
)
|
||
continue
|
||
session_capital = update_session_capital(conn, session_date, pnl_amount)
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=r["symbol"],
|
||
monitor_type=trade_record_monitor_type(conn, r),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(r),
|
||
key_signal_type=order_row_key_signal_type(r),
|
||
direction=direction,
|
||
trigger_price=trigger_price,
|
||
stop_loss=r["stop_loss"],
|
||
initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"],
|
||
take_profit=r["take_profit"],
|
||
margin_capital=margin_capital_for_trade_record(r),
|
||
leverage=leverage,
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trade_style=r["trade_style"],
|
||
risk_amount=r["risk_amount"],
|
||
planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]),
|
||
actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]),
|
||
result="强制清仓",
|
||
miss_reason=handoff_trade_miss_reason(
|
||
f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓",
|
||
r,
|
||
),
|
||
opened_at=opened_at,
|
||
closed_at=closed_at,
|
||
)
|
||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"]))
|
||
send_wechat_msg(
|
||
build_wechat_close_message(
|
||
symbol=r["symbol"],
|
||
direction=direction,
|
||
result="强制清仓",
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trigger_price=trigger_price,
|
||
current_price=p,
|
||
stop_loss=r["stop_loss"],
|
||
take_profit=r["take_profit"],
|
||
close_order_id=close_order_id or "-",
|
||
extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓",
|
||
session_capital_fallback=session_capital,
|
||
)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 后台线程
|
||
def background_task():
|
||
while True:
|
||
try:
|
||
auto_transfer_once_per_day()
|
||
conn = get_db()
|
||
reconcile_external_closes(conn)
|
||
conn.commit()
|
||
conn.close()
|
||
force_close_before_reset()
|
||
check_fib_key_monitors()
|
||
check_trigger_entry_key_monitors()
|
||
_roll_cfg = app.extensions.get("strategy_roll_cfg")
|
||
if _roll_cfg:
|
||
from strategy_roll_monitor_lib import check_roll_monitors
|
||
|
||
check_roll_monitors(_roll_cfg)
|
||
check_key_monitors()
|
||
check_order_monitors()
|
||
cfg = app.extensions.get("strategy_trend_cfg")
|
||
if cfg:
|
||
from strategy_trend_register import check_trend_pullback_plans
|
||
|
||
check_trend_pullback_plans(cfg)
|
||
except:
|
||
pass
|
||
time.sleep(MONITOR_POLL_SECONDS)
|
||
|
||
|
||
# ====================== 登录路由 ======================
|
||
@app.route("/login", methods=["GET", "POST"])
|
||
def login():
|
||
if AUTH_DISABLED:
|
||
session["logged_in"] = True
|
||
return redirect("/")
|
||
if request.method == "POST":
|
||
username = request.form.get("username")
|
||
password = request.form.get("password")
|
||
if username == USERNAME and password == PASSWORD:
|
||
session["logged_in"] = True
|
||
return redirect("/")
|
||
else:
|
||
flash("账号或密码错误")
|
||
return render_template("login.html", exchange_display=EXCHANGE_DISPLAY_NAME)
|
||
|
||
@app.route("/logout")
|
||
def logout():
|
||
session.clear()
|
||
return redirect("/" if AUTH_DISABLED else "/login")
|
||
|
||
# 登录校验装饰器
|
||
def login_required(f):
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED):
|
||
return f(*args, **kwargs)
|
||
return redirect("/login")
|
||
return decorated
|
||
|
||
|
||
@app.route("/sync_positions")
|
||
@login_required
|
||
def sync_positions():
|
||
days_raw = (request.args.get("days") or "").strip()
|
||
sync_days = None
|
||
if days_raw:
|
||
try:
|
||
sync_days = max(1, min(365, int(days_raw)))
|
||
except Exception:
|
||
sync_days = None
|
||
conn = get_db()
|
||
synced = reconcile_external_closes(conn, days=sync_days)
|
||
conn.commit()
|
||
conn.close()
|
||
if sync_days is not None:
|
||
flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新")
|
||
else:
|
||
flash(f"同步完成:{synced} 笔持仓已按交易所状态更新")
|
||
return redirect("/")
|
||
|
||
|
||
@app.route("/api/sync_positions", methods=["POST"])
|
||
@login_required
|
||
def api_sync_positions():
|
||
payload = request.get_json(silent=True) or {}
|
||
days_raw = str(payload.get("days", "")).strip()
|
||
if not days_raw:
|
||
return jsonify({"ok": False, "msg": "请填写天数"}), 400
|
||
try:
|
||
days = int(days_raw)
|
||
except Exception:
|
||
return jsonify({"ok": False, "msg": "天数必须是整数"}), 400
|
||
if days < 1 or days > 365:
|
||
return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400
|
||
conn = get_db()
|
||
synced = reconcile_external_closes(conn, days=days)
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"ok": True, "days": days, "synced": int(synced)})
|
||
|
||
|
||
def _coerce_ts_ms(val):
|
||
if val is None or val == "":
|
||
return None
|
||
try:
|
||
v = float(val)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if v > 1e12:
|
||
return int(v)
|
||
if v > 1e9:
|
||
return int(v * 1000.0)
|
||
return int(v * 1000.0)
|
||
|
||
|
||
def _unified_symbol_for_match(symbol_str):
|
||
"""统一 ETH/USDT:USDT、ETH_USDT、ETH/USDT 便于与 trade_records 比对。"""
|
||
s = (symbol_str or "").strip().upper()
|
||
if not s:
|
||
return ""
|
||
if ":" in s:
|
||
s = s.split(":")[0]
|
||
if "_" in s and "/" not in s:
|
||
s = s.replace("_", "/")
|
||
if s.endswith("USDT") and "/" not in s and len(s) > 4:
|
||
s = f"{s[:-4]}/USDT"
|
||
return s
|
||
|
||
|
||
def exchange_position_sync_since_ms():
|
||
s = EXCHANGE_POSITION_SYNC_FROM_BJ
|
||
if s:
|
||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
|
||
try:
|
||
chunk = s[:ln] if len(s) >= ln else s[:10]
|
||
dt = datetime.strptime(chunk, fmt)
|
||
aware = dt.replace(tzinfo=APP_TZ)
|
||
return int(aware.timestamp() * 1000)
|
||
except Exception:
|
||
continue
|
||
dt0 = app_now() - timedelta(days=90)
|
||
try:
|
||
aware0 = datetime(dt0.year, dt0.month, dt0.day, 0, 0, 0, tzinfo=APP_TZ)
|
||
except Exception:
|
||
aware0 = datetime.now(APP_TZ)
|
||
return int(aware0.timestamp() * 1000)
|
||
|
||
|
||
def _normalize_gate_position_history_entry(p):
|
||
if not p or not isinstance(p, dict):
|
||
return None
|
||
info = p.get("info") or {}
|
||
sym = p.get("symbol") or ""
|
||
if not sym:
|
||
c_alt = str(info.get("contract") or "").strip()
|
||
if c_alt:
|
||
sym = c_alt.replace("_", "/")
|
||
side = (p.get("side") or info.get("side") or "").strip().lower()
|
||
if side not in ("long", "short"):
|
||
sz = info.get("accum_size") if info.get("accum_size") is not None else info.get("size")
|
||
try:
|
||
szf = float(sz)
|
||
if szf > 0:
|
||
side = "long"
|
||
elif szf < 0:
|
||
side = "short"
|
||
except (TypeError, ValueError):
|
||
side = ""
|
||
rp = p.get("realizedPnl")
|
||
if rp is None:
|
||
rp = info.get("pnl")
|
||
try:
|
||
rp_f = float(rp) if rp is not None and str(rp).strip() != "" else None
|
||
except (TypeError, ValueError):
|
||
rp_f = None
|
||
close_ms = _coerce_ts_ms(p.get("lastUpdateTimestamp"))
|
||
if close_ms is None:
|
||
close_ms = _coerce_ts_ms(info.get("time"))
|
||
open_ms = _coerce_ts_ms(p.get("timestamp"))
|
||
if open_ms is None:
|
||
open_ms = _coerce_ts_ms(info.get("first_open_time"))
|
||
c_raw = str(info.get("contract") or "").strip()
|
||
t_raw = info.get("time")
|
||
sync_key = f"{c_raw}|{t_raw}|{side}"
|
||
return {
|
||
"symbol_u": _unified_symbol_for_match(sym),
|
||
"side": side,
|
||
"close_ms": close_ms,
|
||
"open_ms": open_ms,
|
||
"pnl": rp_f,
|
||
"sync_key": sync_key,
|
||
}
|
||
|
||
|
||
def fetch_gate_positions_close_history():
|
||
if not exchange_private_api_configured():
|
||
return []
|
||
ensure_markets_loaded()
|
||
since_ms = exchange_position_sync_since_ms()
|
||
until_ms = int(time.time() * 1000)
|
||
out = []
|
||
offset = 0
|
||
page_limit = min(100, int(EXCHANGE_POSITION_HISTORY_LIMIT))
|
||
max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT)
|
||
|
||
def _pull(params_extra):
|
||
nonlocal offset
|
||
offset = 0
|
||
while len(out) < max_total:
|
||
params = dict(params_extra)
|
||
params["offset"] = offset
|
||
params["until"] = until_ms
|
||
try:
|
||
rows = exchange.fetch_positions_history(
|
||
None,
|
||
since=int(since_ms),
|
||
limit=page_limit,
|
||
params=params,
|
||
)
|
||
except Exception:
|
||
return False
|
||
if not rows:
|
||
break
|
||
for p in rows:
|
||
h = _normalize_gate_position_history_entry(p)
|
||
if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]:
|
||
out.append(h)
|
||
offset += len(rows)
|
||
if len(rows) < page_limit:
|
||
break
|
||
return True
|
||
|
||
if not _pull({"settle": "usdt"}):
|
||
_pull({})
|
||
return out[:max_total]
|
||
|
||
|
||
def sync_trade_records_from_exchange(conn, force=False):
|
||
"""为未同步的 trade_records 回填 Gate 平仓历史中的已实现盈亏。返回统计 dict。"""
|
||
global _LAST_EXCHANGE_PNL_SYNC_AT
|
||
stats = {"ok": False, "hist_count": 0, "matched": 0, "pending": 0, "skipped": False}
|
||
if not exchange_private_api_configured():
|
||
stats["reason"] = "未配置 GATE_API_KEY / GATE_API_SECRET"
|
||
return stats
|
||
now = time.time()
|
||
if not force and now - _LAST_EXCHANGE_PNL_SYNC_AT < 25.0:
|
||
stats["ok"] = True
|
||
stats["skipped"] = True
|
||
return stats
|
||
try:
|
||
hist = fetch_gate_positions_close_history()
|
||
except Exception as e:
|
||
stats["reason"] = str(e)
|
||
return stats
|
||
stats["hist_count"] = len(hist)
|
||
if not hist:
|
||
stats["ok"] = True
|
||
stats["reason"] = "交易所平仓历史为空(请检查 API 权限或 EXCHANGE_POSITION_SYNC_FROM_BJ)"
|
||
return stats
|
||
candidates = conn.execute(
|
||
"""
|
||
SELECT id, symbol, direction, closed_at, closed_at_ms, opened_at, opened_at_ms
|
||
FROM trade_records
|
||
WHERE (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '')
|
||
OR exchange_realized_pnl IS NULL
|
||
ORDER BY id DESC
|
||
LIMIT 200
|
||
"""
|
||
).fetchall()
|
||
stats["pending"] = len(candidates)
|
||
if not candidates:
|
||
stats["ok"] = True
|
||
_LAST_EXCHANGE_PNL_SYNC_AT = now
|
||
return stats
|
||
used = set()
|
||
matched = 0
|
||
for tr in candidates:
|
||
close_ms_trade = _to_ms_with_fallback(
|
||
tr["closed_at_ms"] if "closed_at_ms" in tr.keys() else None, tr["closed_at"]
|
||
) or opened_at_str_to_ms(tr["closed_at"])
|
||
open_ms_trade = _to_ms_with_fallback(
|
||
tr["opened_at_ms"] if "opened_at_ms" in tr.keys() else None, tr["opened_at"]
|
||
) or opened_at_str_to_ms(tr["opened_at"])
|
||
if close_ms_trade is None:
|
||
continue
|
||
best = None
|
||
best_d = None
|
||
for h in hist:
|
||
sk = h["sync_key"]
|
||
if not sk or sk in used:
|
||
continue
|
||
if h["symbol_u"] != _unified_symbol_for_match(tr["symbol"]):
|
||
continue
|
||
if h["side"] != (tr["direction"] or "long").strip().lower():
|
||
continue
|
||
cm = h["close_ms"]
|
||
if cm is None:
|
||
continue
|
||
if open_ms_trade is not None:
|
||
if cm < open_ms_trade - 15 * 60 * 1000:
|
||
continue
|
||
if cm > open_ms_trade + 15 * 86400 * 1000:
|
||
continue
|
||
else:
|
||
if abs(cm - close_ms_trade) > 3 * 86400 * 1000:
|
||
continue
|
||
d = abs(cm - close_ms_trade)
|
||
if best_d is None or d < best_d:
|
||
best_d = d
|
||
best = h
|
||
if best is None or best_d is None or best_d > 90 * 60 * 1000:
|
||
continue
|
||
sk = best["sync_key"]
|
||
if sk in used:
|
||
continue
|
||
eo = ms_to_app_local_str(best["open_ms"]) if best.get("open_ms") else None
|
||
ec = ms_to_app_local_str(best["close_ms"]) if best.get("close_ms") else None
|
||
pnl_val = best.get("pnl")
|
||
if pnl_val is None:
|
||
pnl_val = 0.0
|
||
conn.execute(
|
||
"""
|
||
UPDATE trade_records
|
||
SET exchange_realized_pnl = ?, exchange_opened_at = ?, exchange_closed_at = ?, exchange_sync_key = ?
|
||
WHERE id = ?
|
||
""",
|
||
(float(pnl_val), eo, ec, sk, int(tr["id"])),
|
||
)
|
||
used.add(sk)
|
||
matched += 1
|
||
stats["matched"] = matched
|
||
stats["ok"] = True
|
||
_LAST_EXCHANGE_PNL_SYNC_AT = now
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
return stats
|
||
|
||
|
||
# ====================== 主页面 ======================
|
||
def render_main_page(page="trade", embed_mode=None):
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
list_window = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ)
|
||
conn = get_db()
|
||
session_row = ensure_session(conn, trading_day)
|
||
local_current_capital = float(session_row["current_capital"])
|
||
from instance_embed_context_lib import (
|
||
embed_render_plan,
|
||
minimal_stats_bundle,
|
||
trade_records_summary,
|
||
)
|
||
|
||
plan = embed_render_plan(page, embed_mode)
|
||
if plan.exchange_capitals:
|
||
funding_capital, trading_capital = get_exchange_capitals()
|
||
else:
|
||
funding_capital, trading_capital = None, None
|
||
# 资金账户:仅展示交易所读取结果(含 0)。不可用 TOTAL_CAPITAL 兜底,否则会与实盘不符。
|
||
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
||
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
|
||
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
|
||
key_list = (
|
||
conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else []
|
||
)
|
||
key_history = (
|
||
conn.execute(
|
||
"SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
if plan.key_history
|
||
else []
|
||
)
|
||
stats_bundle = (
|
||
compute_stats_bundle(conn, trading_day, now)
|
||
if plan.stats_bundle
|
||
else minimal_stats_bundle(TRADING_DAY_RESET_HOUR)
|
||
)
|
||
order_list = []
|
||
if plan.orders:
|
||
raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall()
|
||
for o in raw_order_list:
|
||
order_list.append(enrich_order_item(row_to_dict(o), current_capital))
|
||
exchange_pnl_sync = {}
|
||
if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in (
|
||
"fragment",
|
||
"shell",
|
||
):
|
||
try:
|
||
exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {}
|
||
except Exception as e:
|
||
exchange_pnl_sync = {"ok": False, "reason": str(e)}
|
||
tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at")
|
||
if plan.records_rows:
|
||
raw_records = conn.execute(
|
||
f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
records = [to_effective_trade_dict(r) for r in raw_records]
|
||
total = len(records)
|
||
miss_count = sum(1 for r in records if (r.get("effective_result") or "") == "错过")
|
||
win = count_winning_trades(records)
|
||
occupied_miss_total = sum(
|
||
1
|
||
for r in records
|
||
if (r.get("effective_result") or "") == "错过"
|
||
and ("持仓占用" in str(r.get("effective_miss_reason") or ""))
|
||
)
|
||
rate = round(win / total * 100, 2) if total else 0
|
||
elif plan.records_summary:
|
||
summary = trade_records_summary(conn, start_bj, end_bj, tr_ts)
|
||
records = summary["records"]
|
||
total = summary["total"]
|
||
miss_count = summary["miss_count"]
|
||
rate = summary["rate"]
|
||
occupied_miss_total = summary["occupied_miss_total"]
|
||
else:
|
||
records = []
|
||
total = miss_count = rate = occupied_miss_total = 0
|
||
active_count = len(order_list)
|
||
from strategy_trade_labels import count_position_limit_active_monitors
|
||
|
||
position_limit_count = count_position_limit_active_monitors(conn)
|
||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||
risk_status = hub_account_risk_status(conn)
|
||
can_trade = can_trade_new_open(
|
||
time_allows=trading_day_reset_allows_new_open(now),
|
||
active_count=position_limit_count,
|
||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||
opens_today=opens_today,
|
||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||
extra_blocks=not risk_status.get("can_trade", True),
|
||
)
|
||
key_rule_ctx = key_monitor_rule_template_context(
|
||
kline_timeframe=KLINE_TIMEFRAME,
|
||
key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT,
|
||
key_volume_ma_bars=KEY_VOLUME_MA_BARS,
|
||
key_volume_ratio_min=KEY_VOLUME_RATIO_MIN,
|
||
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
|
||
key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX,
|
||
key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR,
|
||
key_confirm_bar=KEY_CONFIRM_BAR,
|
||
key_alert_max_times=KEY_ALERT_MAX_TIMES,
|
||
key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES,
|
||
key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT,
|
||
key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT,
|
||
false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS,
|
||
trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS,
|
||
)
|
||
strategy_extra = {}
|
||
if plan.strategy:
|
||
from strategy_ui import strategy_render_extras
|
||
|
||
strategy_extra = strategy_render_extras(
|
||
conn,
|
||
page,
|
||
default_risk_percent=float(RISK_PERCENT),
|
||
request_obj=request,
|
||
trend_cfg=app.extensions.get("strategy_trend_cfg"),
|
||
)
|
||
conn.close()
|
||
from instance_embed_lib import embed_context_extras
|
||
|
||
template_ctx = dict(
|
||
page=page,
|
||
key=key_list,
|
||
key_history=key_history,
|
||
stats_bundle=stats_bundle,
|
||
order=order_list,
|
||
record=records,
|
||
total=total,
|
||
miss_count=miss_count,
|
||
rate=rate,
|
||
trading_day=trading_day,
|
||
funding_usdt=funding_usdt,
|
||
daily_start_capital=DAILY_START_CAPITAL,
|
||
current_capital=current_capital,
|
||
recommended_capital=recommended_capital,
|
||
btc_leverage=BTC_LEVERAGE,
|
||
alt_leverage=ALT_LEVERAGE,
|
||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||
balance_refresh_seconds=BALANCE_REFRESH_SECONDS,
|
||
auto_transfer_enabled=AUTO_TRANSFER_ENABLED,
|
||
auto_transfer_amount=AUTO_TRANSFER_AMOUNT,
|
||
auto_transfer_from=AUTO_TRANSFER_FROM,
|
||
auto_transfer_to=AUTO_TRANSFER_TO,
|
||
auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR,
|
||
transfer_amount_fmt=format_usdt(AUTO_TRANSFER_AMOUNT),
|
||
full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
|
||
price_refresh_seconds=PRICE_REFRESH_SECONDS,
|
||
active_count=position_limit_count,
|
||
can_trade=can_trade,
|
||
opens_today=opens_today,
|
||
daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||
daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD,
|
||
focus_key_id=(key_list[0]["id"] if key_list else None),
|
||
focus_order_id=(order_list[0]["id"] if order_list else None),
|
||
data_export_version=3,
|
||
list_window=list_window,
|
||
list_window_presets={
|
||
"utc_today": PRESET_UTC_TODAY,
|
||
"utc_last24h": PRESET_UTC_LAST24H,
|
||
"utc_last7d": PRESET_UTC_LAST7D,
|
||
"custom": PRESET_CUSTOM,
|
||
},
|
||
key_alert_max_times=KEY_ALERT_MAX_TIMES,
|
||
risk_percent=RISK_PERCENT,
|
||
position_sizing_mode=POSITION_SIZING_MODE,
|
||
position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE),
|
||
open_position_button_label=(
|
||
"开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)"
|
||
),
|
||
breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER,
|
||
breakeven_offset_pct=BREAKEVEN_OFFSET_PCT,
|
||
occupied_miss_total=occupied_miss_total,
|
||
price_fmt=format_price_for_symbol,
|
||
funds_fmt=format_usdt,
|
||
usdt_fmt=format_usdt,
|
||
signed_usdt_fmt=format_signed_usdt,
|
||
entry_reason_options=list(ENTRY_REASON_OPTIONS),
|
||
entry_reason_other_value=ENTRY_REASON_OTHER,
|
||
journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES,
|
||
journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1,
|
||
journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2,
|
||
journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT,
|
||
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
|
||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||
risk_status=risk_status,
|
||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
||
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
|
||
key_rule_ctx=key_rule_ctx,
|
||
kline_timeframe=KLINE_TIMEFRAME,
|
||
exchange_pnl_sync=exchange_pnl_sync,
|
||
**strategy_extra,
|
||
**embed_context_extras("gate_bot"),
|
||
)
|
||
if embed_mode == "fragment":
|
||
return render_template("embed_page_fragment.html", **template_ctx)
|
||
if embed_mode == "shell":
|
||
return render_template("embed_shell.html", initial_tab=page, **template_ctx)
|
||
return render_template("index.html", **template_ctx)
|
||
|
||
|
||
@app.route("/api/sync_exchange_pnl")
|
||
@login_required
|
||
def api_sync_exchange_pnl():
|
||
conn = get_db()
|
||
stats = sync_trade_records_from_exchange(conn, force=True)
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
conn.close()
|
||
return jsonify(stats)
|
||
|
||
|
||
@app.route("/")
|
||
@login_required
|
||
def index():
|
||
return redirect("/trade")
|
||
|
||
|
||
@app.route("/key_monitor")
|
||
@login_required
|
||
def key_monitor_page():
|
||
return render_main_page("key_monitor")
|
||
|
||
|
||
@app.route("/trade")
|
||
@login_required
|
||
def trade_page():
|
||
return render_main_page("trade")
|
||
|
||
|
||
@app.route("/records")
|
||
@login_required
|
||
def records_page():
|
||
return render_main_page("records")
|
||
|
||
|
||
@app.route("/stats")
|
||
@login_required
|
||
def stats_page():
|
||
return render_main_page("stats")
|
||
|
||
|
||
@app.route("/api/account_snapshot")
|
||
@login_required
|
||
def api_account_snapshot():
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
conn = get_db()
|
||
session_row = ensure_session(conn, trading_day)
|
||
local_current_capital = float(session_row["current_capital"])
|
||
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
||
current_capital = round(trading_capital, 2) if trading_capital is not None else round(local_current_capital, 2)
|
||
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
|
||
from strategy_trade_labels import count_position_limit_active_monitors
|
||
|
||
position_limit_count = count_position_limit_active_monitors(conn)
|
||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||
risk_status = hub_account_risk_status(conn)
|
||
conn.close()
|
||
can_trade = can_trade_new_open(
|
||
time_allows=trading_day_reset_allows_new_open(now),
|
||
active_count=position_limit_count,
|
||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||
opens_today=opens_today,
|
||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||
extra_blocks=not risk_status.get("can_trade", True),
|
||
)
|
||
available_trading_usdt = get_available_trading_usdt()
|
||
return jsonify({
|
||
"funding_usdt": funding_usdt,
|
||
"current_capital": current_capital,
|
||
"available_trading_usdt": round(available_trading_usdt, 2) if available_trading_usdt is not None else None,
|
||
"recommended_capital": recommended_capital,
|
||
"active_count": position_limit_count,
|
||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||
"can_trade": can_trade,
|
||
"opens_today": opens_today,
|
||
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
|
||
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
|
||
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||
"trading_day": trading_day,
|
||
"risk_status": risk_status,
|
||
})
|
||
|
||
|
||
@app.route("/api/price_snapshot")
|
||
@login_required
|
||
def api_price_snapshot():
|
||
conn = get_db()
|
||
key_rows = conn.execute(
|
||
"SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors"
|
||
).fetchall()
|
||
order_rows = conn.execute(
|
||
"SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"
|
||
"time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'"
|
||
).fetchall()
|
||
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
|
||
symbol_set = set()
|
||
for r in key_rows:
|
||
symbol_set.add(r["symbol"])
|
||
for r in order_rows:
|
||
symbol_set.add(r["symbol"])
|
||
|
||
prices = {}
|
||
for s in symbol_set:
|
||
p = get_price(s)
|
||
if p is not None:
|
||
prices[s] = float(p)
|
||
|
||
all_swap_positions = []
|
||
if exchange_private_api_configured():
|
||
try:
|
||
ensure_markets_loaded()
|
||
# 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐
|
||
all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or []
|
||
except Exception:
|
||
try:
|
||
all_swap_positions = exchange.fetch_positions() or []
|
||
except Exception:
|
||
all_swap_positions = []
|
||
|
||
key_prices = []
|
||
for r in key_rows:
|
||
is_fib = is_fib_key_monitor_type(r["monitor_type"])
|
||
is_fb = is_false_breakout_key_monitor_type(r["monitor_type"])
|
||
is_te = is_trigger_entry_key_monitor_type(r["monitor_type"])
|
||
if is_fib or is_fb or is_te:
|
||
price = get_symbol_mark_price(r["symbol"])
|
||
else:
|
||
price = prices.get(r["symbol"])
|
||
if price is None:
|
||
continue
|
||
upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"])
|
||
lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"])
|
||
gate = None
|
||
gate_summary = "-"
|
||
gate_metrics = ""
|
||
fib_gate_ok = True
|
||
fb_gate_ok = True
|
||
te_gate_ok = True
|
||
box_gate_ok = True
|
||
if is_fib:
|
||
direction = (r["direction"] or "long").lower()
|
||
inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"])
|
||
fib_gate_ok = not inval
|
||
entry = _sqlite_row_val(r, "fib_entry_price")
|
||
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
|
||
gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}"
|
||
if _sqlite_row_val(r, "fib_limit_order_id"):
|
||
gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}"
|
||
elif is_fb:
|
||
entry = _sqlite_row_val(r, "fib_entry_price")
|
||
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
|
||
prev = false_breakout_gate_preview(
|
||
entry_display=entry_txt,
|
||
limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"),
|
||
created_at=_sqlite_row_val(r, "created_at"),
|
||
now=app_now(),
|
||
)
|
||
gate_summary = prev.get("summary") or "-"
|
||
gate_metrics = prev.get("metrics") or ""
|
||
fb_gate_ok = bool(prev.get("gate_ok"))
|
||
elif is_te:
|
||
direction = (r["direction"] or "long").lower()
|
||
entry = _sqlite_row_val(r, "fib_entry_price")
|
||
tp_v = _sqlite_row_val(r, "fib_take_profit")
|
||
entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-"
|
||
tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-"
|
||
sl_v = _sqlite_row_val(r, "fib_stop_loss")
|
||
inv = (
|
||
trigger_entry_invalidate(
|
||
r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0)
|
||
)
|
||
if tp_v
|
||
else None
|
||
)
|
||
prev = trigger_entry_gate_preview(
|
||
monitor_type=r["monitor_type"],
|
||
entry_display=entry_txt,
|
||
take_profit_display=tp_txt,
|
||
created_at=_sqlite_row_val(r, "created_at"),
|
||
now=app_now(),
|
||
tp_invalidated=inv == "tp",
|
||
sl_invalidated=inv == "sl",
|
||
hours=TRIGGER_ENTRY_VALIDITY_HOURS,
|
||
)
|
||
gate_summary = prev.get("summary") or "-"
|
||
gate_metrics = prev.get("metrics") or ""
|
||
te_gate_ok = bool(prev.get("gate_ok"))
|
||
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES:
|
||
try:
|
||
prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"])
|
||
gate_summary = prev.get("summary") or "-"
|
||
gate_metrics = prev.get("metrics") or ""
|
||
except Exception:
|
||
gate_summary = "-"
|
||
elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES:
|
||
direction = (r["direction"] or "long").lower()
|
||
if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]):
|
||
edge_label = box_breakout_invalidate_edge_label(direction)
|
||
gate_summary = f"反向突破{edge_label}·将撤销"
|
||
box_gate_ok = False
|
||
else:
|
||
try:
|
||
gate = _key_hard_checks(
|
||
r["symbol"],
|
||
direction,
|
||
r["upper"],
|
||
r["lower"],
|
||
r["monitor_type"],
|
||
)
|
||
except Exception:
|
||
gate = None
|
||
if gate:
|
||
rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}"
|
||
gate_summary = (
|
||
f"量:{'Y' if gate.get('vol_ok') else 'N'} "
|
||
f"破:{'Y' if gate.get('breakout_ok') else 'N'} "
|
||
f"幅:{'Y' if gate.get('amp_ok') else 'N'} "
|
||
f"二确:{'Y' if gate.get('confirm_ok') else 'N'} "
|
||
f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})"
|
||
)
|
||
if gate.get("breakout_ok"):
|
||
try:
|
||
vol_now = round(float(gate.get("vol_break") or 0), 4)
|
||
vol_avg = round(float(gate.get("avg20") or 0), 4)
|
||
amp_pct = round(float(gate.get("amp_pct") or 0), 4)
|
||
cfm_close = round(float(gate.get("confirm_close") or 0), 8)
|
||
edge = round(float(gate.get("edge_price") or 0), 8)
|
||
gate_metrics = (
|
||
f"量值:{vol_now}/{vol_avg} "
|
||
f"幅值:{amp_pct}% "
|
||
f"二确值:{cfm_close}@{edge}"
|
||
)
|
||
except Exception:
|
||
gate_metrics = ""
|
||
px_disp = format_price_for_symbol(r["symbol"], price)
|
||
try:
|
||
price_num = float(px_disp) if px_disp != "-" else float(price)
|
||
except Exception:
|
||
price_num = float(price)
|
||
key_prices.append({
|
||
"id": r["id"],
|
||
"symbol": r["symbol"],
|
||
"price": price_num,
|
||
"price_display": px_disp,
|
||
"upper_diff": upper_diff,
|
||
"upper_pct": upper_pct,
|
||
"lower_diff": lower_diff,
|
||
"lower_pct": lower_pct,
|
||
"gate_summary": gate_summary,
|
||
"gate_ok": (
|
||
fib_gate_ok if is_fib
|
||
else fb_gate_ok if is_fb
|
||
else te_gate_ok if is_te
|
||
else box_gate_ok and bool(gate and gate.get("ok"))
|
||
),
|
||
"gate_metrics": gate_metrics,
|
||
})
|
||
|
||
order_prices = []
|
||
for r in order_rows:
|
||
price = prices.get(r["symbol"])
|
||
if price is None:
|
||
continue
|
||
margin = float(r["margin_capital"] or 0)
|
||
leverage = float(r["leverage"] or 0)
|
||
entry = float(r["trigger_price"] or 0)
|
||
pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 else 0
|
||
pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0
|
||
exchange_tpsl = {"sl": None, "tp": None}
|
||
ex_sym = resolve_monitor_exchange_symbol(r)
|
||
prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"])
|
||
lev_row = r["leverage"] if "leverage" in r.keys() else None
|
||
ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None
|
||
payload = {
|
||
"id": r["id"],
|
||
"symbol": r["symbol"],
|
||
"float_pnl": round(pnl, 2),
|
||
"float_pct": pnl_pct,
|
||
"plan_margin": round(margin, 2) if margin else None,
|
||
"exchange_initial_margin": None,
|
||
"exchange_notional": None,
|
||
"exchange_mark_price": None,
|
||
"pnl_source": "plan",
|
||
}
|
||
if ex_metrics:
|
||
if ex_metrics.get("initial_margin") is not None:
|
||
payload["exchange_initial_margin"] = ex_metrics["initial_margin"]
|
||
if ex_metrics.get("notional") is not None:
|
||
payload["exchange_notional"] = ex_metrics["notional"]
|
||
if ex_metrics.get("mark_price") is not None:
|
||
payload["exchange_mark_price"] = ex_metrics["mark_price"]
|
||
if ex_metrics.get("unrealized_pnl") is not None:
|
||
payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2)
|
||
payload["pnl_source"] = "exchange"
|
||
denom = ex_metrics.get("initial_margin") or margin
|
||
payload["float_pct"] = (
|
||
round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct
|
||
)
|
||
px_for_fmt = float(price)
|
||
if ex_metrics and ex_metrics.get("mark_price") is not None:
|
||
try:
|
||
px_for_fmt = float(ex_metrics["mark_price"])
|
||
except (TypeError, ValueError):
|
||
pass
|
||
px_disp = format_price_for_symbol(r["symbol"], px_for_fmt)
|
||
try:
|
||
payload["price"] = float(px_disp) if px_disp != "-" else px_for_fmt
|
||
except Exception:
|
||
payload["price"] = px_for_fmt
|
||
payload["price_display"] = px_disp
|
||
if exchange_private_api_configured():
|
||
try:
|
||
exchange_tpsl = fetch_exchange_tpsl_slots(
|
||
ex_sym,
|
||
r["direction"],
|
||
plan_sl=r["stop_loss"],
|
||
plan_tp=r["take_profit"],
|
||
)
|
||
except Exception:
|
||
exchange_tpsl = {"sl": None, "tp": None}
|
||
payload["exchange_tpsl"] = exchange_tpsl
|
||
apply_order_price_display_fields(
|
||
payload,
|
||
direction=r["direction"],
|
||
entry_price=entry,
|
||
initial_stop_loss=r["initial_stop_loss"],
|
||
stop_loss=r["stop_loss"],
|
||
take_profit=r["take_profit"],
|
||
calc_rr_ratio_fn=calc_rr_ratio,
|
||
exchange_tpsl=exchange_tpsl,
|
||
format_price_fn=format_price_for_symbol,
|
||
symbol=r["symbol"],
|
||
)
|
||
apply_time_close_to_payload(payload, r)
|
||
payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None
|
||
open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None
|
||
payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None
|
||
new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(
|
||
r["stop_loss"], r["take_profit"], exchange_tpsl
|
||
)
|
||
if changed:
|
||
try:
|
||
conn.execute(
|
||
"UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?",
|
||
(new_sl, new_tp, int(r["id"])),
|
||
)
|
||
except Exception:
|
||
pass
|
||
order_prices.append(payload)
|
||
|
||
try:
|
||
conn.commit()
|
||
except Exception:
|
||
pass
|
||
conn.close()
|
||
|
||
from hub_position_metrics import build_position_marks_list
|
||
|
||
position_marks = build_position_marks_list(
|
||
all_swap_positions,
|
||
format_mark_display=lambda sym, px: format_price_for_symbol(sym, px),
|
||
)
|
||
|
||
return jsonify({
|
||
"updated_at": app_now_str(),
|
||
"key_prices": key_prices,
|
||
"order_prices": order_prices,
|
||
"position_marks": position_marks,
|
||
"positions_raw_count": len(all_swap_positions),
|
||
})
|
||
|
||
|
||
@app.route("/api/order/<int:order_id>/cancel_tpsl", methods=["POST"])
|
||
@login_required
|
||
def api_order_cancel_tpsl(order_id):
|
||
data = request.get_json(silent=True) or {}
|
||
role = (data.get("role") or "").strip().lower()
|
||
if role not in ("sl", "tp"):
|
||
return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400
|
||
conn = get_db()
|
||
row = conn.execute(
|
||
"SELECT * FROM order_monitors WHERE id=? AND status='active'",
|
||
(order_id,),
|
||
).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404
|
||
ok, reason = ensure_exchange_live_ready()
|
||
if not ok:
|
||
return jsonify({"ok": False, "msg": reason}), 400
|
||
ex_sym = resolve_monitor_exchange_symbol(row)
|
||
slots = fetch_exchange_tpsl_slots(
|
||
ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"]
|
||
)
|
||
slot = slots.get(role)
|
||
if not slot:
|
||
return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404
|
||
try:
|
||
cancel_gate_tpsl_slot(ex_sym, slot)
|
||
slots = fetch_exchange_tpsl_slots(
|
||
ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"]
|
||
)
|
||
return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": slots})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400
|
||
|
||
|
||
@app.route("/api/order/<int:order_id>/place_tpsl", methods=["POST"])
|
||
@login_required
|
||
def api_order_place_tpsl(order_id):
|
||
data = request.get_json(silent=True) or {}
|
||
conn = get_db()
|
||
row = conn.execute(
|
||
"SELECT * FROM order_monitors WHERE id=? AND status='active'",
|
||
(order_id,),
|
||
).fetchone()
|
||
if not row:
|
||
conn.close()
|
||
return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404
|
||
symbol = row["symbol"]
|
||
direction = row["direction"]
|
||
live_price = get_price(symbol)
|
||
if live_price is None:
|
||
conn.close()
|
||
return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400
|
||
try:
|
||
sltp_mode = (data.get("sltp_mode") or "price").strip().lower()
|
||
stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data)
|
||
except Exception as e:
|
||
conn.close()
|
||
return jsonify({"ok": False, "msg": str(e)}), 400
|
||
planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
|
||
if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR:
|
||
conn.close()
|
||
rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算"
|
||
return jsonify(
|
||
{
|
||
"ok": False,
|
||
"msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1",
|
||
}
|
||
), 400
|
||
try:
|
||
replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit)
|
||
except Exception as e:
|
||
conn.close()
|
||
return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400
|
||
conn.execute(
|
||
"UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?",
|
||
(stop_loss, take_profit, order_id),
|
||
)
|
||
conn.commit()
|
||
ex_sym = resolve_monitor_exchange_symbol(row)
|
||
slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit)
|
||
conn.close()
|
||
return jsonify(
|
||
{
|
||
"ok": True,
|
||
"msg": "已先撤后挂止盈止损",
|
||
"stop_loss": stop_loss,
|
||
"take_profit": take_profit,
|
||
"planned_rr": planned_rr,
|
||
"exchange_tpsl": slots,
|
||
}
|
||
)
|
||
|
||
|
||
@app.route("/api/symbol_liquidity_rank")
|
||
@login_required
|
||
def api_symbol_liquidity_rank():
|
||
symbol = normalize_symbol_input(request.args.get("symbol"))
|
||
if not symbol:
|
||
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
|
||
rank, total = _daily_volume_rank(symbol)
|
||
if total <= 0:
|
||
return jsonify({"ok": False, "msg": "日成交量排名读取失败"}), 502
|
||
if rank is None:
|
||
return jsonify({"ok": True, "symbol": symbol, "rank": None, "total": int(total), "in_top30": False})
|
||
return jsonify(
|
||
{
|
||
"ok": True,
|
||
"symbol": symbol,
|
||
"rank": int(rank),
|
||
"total": int(total),
|
||
"in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX),
|
||
"rank_max": KEY_DAILY_VOLUME_RANK_MAX,
|
||
}
|
||
)
|
||
|
||
|
||
@app.route("/api/order_defaults")
|
||
@login_required
|
||
def api_order_defaults():
|
||
symbol = normalize_symbol_input(request.args.get("symbol"))
|
||
direction = (request.args.get("direction") or "long").strip().lower()
|
||
if not symbol:
|
||
return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400
|
||
if direction not in ("long", "short"):
|
||
direction = "long"
|
||
exchange_symbol = normalize_exchange_symbol(symbol)
|
||
leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||
available = get_available_trading_usdt()
|
||
last_price = get_price(symbol)
|
||
return jsonify({
|
||
"ok": True,
|
||
"symbol": symbol,
|
||
"exchange_symbol": exchange_symbol,
|
||
"direction": direction,
|
||
"leverage": leverage,
|
||
"available_trading_usdt": round(available, 2) if available is not None else None,
|
||
"last_price": round(float(last_price), 8) if last_price is not None else None,
|
||
})
|
||
|
||
|
||
@app.route("/order_focus")
|
||
@login_required
|
||
def order_focus():
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
conn = get_db()
|
||
session_row = ensure_session(conn, trading_day)
|
||
local_current_capital = float(session_row["current_capital"])
|
||
_, trading_capital_live = get_exchange_capitals()
|
||
current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2)
|
||
raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall()
|
||
conn.close()
|
||
orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders]
|
||
picked_id = request.args.get("order_id", "").strip()
|
||
selected = None
|
||
if picked_id.isdigit():
|
||
selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None)
|
||
if selected is None and orders:
|
||
selected = orders[0]
|
||
return render_template(
|
||
"order_focus_v2.html",
|
||
orders=orders,
|
||
selected_order=selected,
|
||
default_timeframe=KLINE_TIMEFRAME,
|
||
price_refresh_seconds=PRICE_REFRESH_SECONDS,
|
||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||
)
|
||
|
||
|
||
@app.route("/api/order_kline")
|
||
@login_required
|
||
def api_order_kline():
|
||
order_id_raw = (request.args.get("order_id") or "").strip()
|
||
if not order_id_raw.isdigit():
|
||
return jsonify({"ok": False, "msg": "order_id 无效"}), 400
|
||
order_id = int(order_id_raw)
|
||
timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip()
|
||
allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}
|
||
if timeframe not in allowed_tfs:
|
||
timeframe = KLINE_TIMEFRAME
|
||
limit = 100
|
||
|
||
now = app_now()
|
||
trading_day = get_trading_day(now)
|
||
conn = get_db()
|
||
session_row = ensure_session(conn, trading_day)
|
||
local_current_capital = float(session_row["current_capital"])
|
||
_, trading_capital_live = get_exchange_capitals()
|
||
current_capital = round(trading_capital_live, 2) if trading_capital_live is not None else round(local_current_capital, 2)
|
||
row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404
|
||
|
||
order_item = enrich_order_item(row_to_dict(row), current_capital)
|
||
exchange_symbol = order_item.get("exchange_symbol") or normalize_exchange_symbol(order_item["symbol"])
|
||
try:
|
||
ensure_markets_loaded()
|
||
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit)
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500
|
||
|
||
candles = []
|
||
for bar in ohlcv or []:
|
||
if not bar or len(bar) < 6:
|
||
continue
|
||
ts = int(bar[0] // 1000)
|
||
candles.append({
|
||
"time": ts,
|
||
"open": float(bar[1]),
|
||
"high": float(bar[2]),
|
||
"low": float(bar[3]),
|
||
"close": float(bar[4]),
|
||
"volume": float(bar[5]),
|
||
})
|
||
|
||
from focus_chart_lib import (
|
||
build_order_kline_order_payload,
|
||
load_swap_positions_for_order_kline,
|
||
metrics_for_order_item,
|
||
)
|
||
|
||
current_price = get_price(order_item["symbol"])
|
||
positions = load_swap_positions_for_order_kline(
|
||
exchange,
|
||
private_configured=exchange_private_api_configured(),
|
||
ensure_markets_fn=ensure_markets_loaded,
|
||
)
|
||
ex_metrics = metrics_for_order_item(
|
||
order_item,
|
||
positions,
|
||
resolve_ex_sym_fn=resolve_monitor_exchange_symbol,
|
||
select_live_fn=_select_live_position_row,
|
||
parse_metrics_fn=parse_ccxt_position_metrics,
|
||
)
|
||
order_payload = build_order_kline_order_payload(
|
||
order_item,
|
||
ticker_price=current_price,
|
||
format_price_fn=format_price_for_symbol,
|
||
calc_pnl_fn=calc_pnl,
|
||
calc_rr_ratio_fn=calc_rr_ratio,
|
||
ex_metrics=ex_metrics,
|
||
)
|
||
|
||
from focus_chart_lib import kline_api_price_fields
|
||
|
||
price_fields = kline_api_price_fields(
|
||
exchange,
|
||
exchange_symbol,
|
||
candles,
|
||
ensure_markets_fn=ensure_markets_loaded,
|
||
)
|
||
|
||
return jsonify({
|
||
"ok": True,
|
||
"timeframe": timeframe,
|
||
"limit": limit,
|
||
"order": order_payload,
|
||
"candles": candles,
|
||
"updated_at": app_now_str(),
|
||
**price_fields,
|
||
})
|
||
|
||
|
||
@app.route("/key_focus")
|
||
@login_required
|
||
def key_focus():
|
||
conn = get_db()
|
||
key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall()
|
||
conn.close()
|
||
key_list = [row_to_dict(r) for r in key_rows]
|
||
|
||
key_id_raw = (request.args.get("key_id") or "").strip()
|
||
symbol_query = normalize_symbol_input(request.args.get("symbol"))
|
||
selected_key = None
|
||
if key_id_raw.isdigit():
|
||
selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None)
|
||
if selected_key is None and symbol_query:
|
||
selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None)
|
||
if selected_key is None and key_list:
|
||
selected_key = key_list[0]
|
||
default_symbol = symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT"
|
||
return render_template(
|
||
"key_focus_v2.html",
|
||
key_list=key_list,
|
||
selected_key=selected_key,
|
||
default_symbol=default_symbol,
|
||
default_timeframe=KLINE_TIMEFRAME,
|
||
default_kline_limit=200,
|
||
price_refresh_seconds=PRICE_REFRESH_SECONDS,
|
||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||
)
|
||
|
||
|
||
@app.route("/api/key_kline")
|
||
@login_required
|
||
def api_key_kline():
|
||
key_id_raw = (request.args.get("key_id") or "").strip()
|
||
symbol_input = normalize_symbol_input(request.args.get("symbol"))
|
||
timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip()
|
||
if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}:
|
||
timeframe = KLINE_TIMEFRAME
|
||
limit = normalize_kline_limit(request.args.get("limit"), default=200)
|
||
|
||
conn = get_db()
|
||
key_row = None
|
||
if key_id_raw.isdigit():
|
||
key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone()
|
||
if key_row is None and symbol_input:
|
||
key_row = conn.execute(
|
||
"SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1",
|
||
(symbol_input,),
|
||
).fetchone()
|
||
if key_row is not None:
|
||
symbol = (key_row["symbol"] or "").upper()
|
||
else:
|
||
symbol = symbol_input
|
||
conn.close()
|
||
if not symbol:
|
||
return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400
|
||
|
||
exchange_symbol = normalize_exchange_symbol(symbol)
|
||
try:
|
||
ensure_markets_loaded()
|
||
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit)
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_exchange_error(e)}"}), 500
|
||
|
||
candles = []
|
||
for bar in ohlcv or []:
|
||
if not bar or len(bar) < 6:
|
||
continue
|
||
candles.append({
|
||
"time": int(bar[0] // 1000),
|
||
"open": float(bar[1]),
|
||
"high": float(bar[2]),
|
||
"low": float(bar[3]),
|
||
"close": float(bar[4]),
|
||
"volume": float(bar[5]),
|
||
})
|
||
|
||
current_price = get_price(symbol)
|
||
key_info = None
|
||
if key_row is not None:
|
||
upper = float(key_row["upper"]) if key_row["upper"] is not None else None
|
||
lower = float(key_row["lower"]) if key_row["lower"] is not None else None
|
||
upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None)
|
||
lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None)
|
||
key_info = {
|
||
"id": key_row["id"],
|
||
"monitor_type": key_row["monitor_type"],
|
||
"direction": key_row["direction"] or "long",
|
||
"upper": upper,
|
||
"lower": lower,
|
||
"notification_count": int(key_row["notification_count"] or 0),
|
||
"upper_diff": upper_diff,
|
||
"upper_pct": upper_pct,
|
||
"lower_diff": lower_diff,
|
||
"lower_pct": lower_pct,
|
||
}
|
||
|
||
from focus_chart_lib import enrich_key_kline_response
|
||
|
||
price_display, key_info = enrich_key_kline_response(
|
||
symbol=symbol,
|
||
current_price=current_price,
|
||
key_info=key_info,
|
||
format_price_fn=format_price_for_symbol,
|
||
)
|
||
|
||
from focus_chart_lib import kline_api_price_fields
|
||
|
||
price_fields = kline_api_price_fields(
|
||
exchange,
|
||
exchange_symbol,
|
||
candles,
|
||
ensure_markets_fn=ensure_markets_loaded,
|
||
)
|
||
|
||
return jsonify({
|
||
"ok": True,
|
||
"symbol": symbol,
|
||
"timeframe": timeframe,
|
||
"limit": limit,
|
||
"current_price": round(float(current_price), 8) if current_price is not None else None,
|
||
"current_price_display": price_display,
|
||
"key_monitor": key_info,
|
||
"candles": candles,
|
||
"updated_at": app_now_str(),
|
||
**price_fields,
|
||
})
|
||
|
||
|
||
@app.route("/add_key", methods=["POST"])
|
||
@login_required
|
||
def add_key():
|
||
conn = None
|
||
try:
|
||
d = request.form
|
||
symbol = normalize_symbol_input(d.get("symbol"))
|
||
if not symbol:
|
||
flash("symbol 不能为空")
|
||
return redirect("/key_monitor")
|
||
mt = (d.get("type") or "").strip()
|
||
direction_pre = (d.get("direction") or "").strip().lower()
|
||
dup_msg = check_duplicate_submit(
|
||
session, submit_scope_add_key(symbol, mt, direction_pre or "watch")
|
||
)
|
||
if dup_msg:
|
||
flash(dup_msg)
|
||
return redirect("/key_monitor")
|
||
direction_sel = (d.get("direction") or "").strip().lower()
|
||
if mt in KEY_MONITOR_RS_TYPES:
|
||
direction_sel = KEY_DIRECTION_WATCH
|
||
mt = KEY_MONITOR_RS_TYPE
|
||
elif direction_sel not in ("long", "short"):
|
||
flash("箱体/收敛突破请选择做多或做空")
|
||
return redirect("/key_monitor")
|
||
allowed_types = (
|
||
tuple(KEY_MONITOR_AUTO_TYPES)
|
||
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
|
||
+ tuple(FIB_KEY_MONITOR_TYPES)
|
||
+ (FALSE_BREAKOUT_MONITOR_TYPE,)
|
||
+ tuple(TRIGGER_ENTRY_MONITOR_TYPES)
|
||
)
|
||
if mt not in allowed_types:
|
||
flash("监控类型无效")
|
||
return redirect("/key_monitor")
|
||
if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt):
|
||
flash(
|
||
"全仓杠杆模式下不可添加箱体/收敛突破、斐波或假突破监控;"
|
||
"可使用「回调/突破触价开仓」或阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。"
|
||
)
|
||
return redirect("/key_monitor")
|
||
skip_volume_rank = is_false_breakout_key_monitor_type(mt)
|
||
rank, total = None, None
|
||
if not skip_volume_rank:
|
||
rank, total = _daily_volume_rank(symbol)
|
||
if rank is None:
|
||
flash("日成交量排名读取失败,请稍后重试")
|
||
return redirect("/key_monitor")
|
||
if rank > KEY_DAILY_VOLUME_RANK_MAX:
|
||
flash(
|
||
f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位"
|
||
)
|
||
return redirect("/key_monitor")
|
||
conn = get_db()
|
||
if mt in KEY_MONITOR_AUTO_TYPES:
|
||
occupied = get_active_position_count(conn)
|
||
if occupied >= MAX_ACTIVE_POSITIONS:
|
||
conn.close()
|
||
conn = None
|
||
flash(
|
||
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。"
|
||
"请平仓后再试,或使用「关键支撑阻力」(仅提醒)。"
|
||
)
|
||
return redirect("/key_monitor")
|
||
ex_sym_key = normalize_exchange_symbol(symbol)
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled"))
|
||
tc_en = parse_time_close_enabled_form(d.get("time_close_enabled"))
|
||
tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
|
||
if tc_en and not tc_h:
|
||
tc_en = 0
|
||
if is_trigger_entry_key_monitor_type(mt):
|
||
if direction_sel not in ("long", "short"):
|
||
conn.close()
|
||
conn = None
|
||
flash("触价请选择做多或做空")
|
||
return redirect("/key_monitor")
|
||
try:
|
||
entry_px = float(d.get("trigger_entry") or 0)
|
||
sl_px = float(d.get("trigger_sl") or 0)
|
||
tp_px = float(d.get("trigger_tp") or 0)
|
||
except (TypeError, ValueError):
|
||
entry_px = sl_px = tp_px = 0
|
||
if entry_px <= 0 or sl_px <= 0 or tp_px <= 0:
|
||
conn.close()
|
||
conn = None
|
||
flash("触价须填写有效的入场价、止损价、止盈价")
|
||
return redirect("/key_monitor")
|
||
ok_te, err_te = _add_trigger_entry_key_monitor(
|
||
conn,
|
||
symbol,
|
||
direction_sel,
|
||
entry_px,
|
||
sl_px,
|
||
tp_px,
|
||
monitor_type=mt,
|
||
breakeven_enabled=be_flag,
|
||
time_close_enabled=tc_en,
|
||
time_close_hours=tc_h,
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
conn = None
|
||
if not ok_te:
|
||
flash(err_te or "触价开仓监控添加失败")
|
||
return redirect("/key_monitor")
|
||
trigger_hint = (
|
||
"标记价穿越入场价后立即市价开仓"
|
||
if is_breakout_trigger_entry_key_monitor_type(mt)
|
||
else "标记价回调触达入场价后下一轮询市价开仓"
|
||
)
|
||
flash(
|
||
f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})"
|
||
f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h"
|
||
f"|{trigger_hint}"
|
||
f"|移动保本:{'开' if be_flag else '关'}"
|
||
+ (f"|{time_close_label(tc_h)}" if tc_en else "")
|
||
)
|
||
return redirect("/key_monitor")
|
||
if is_false_breakout_key_monitor_type(mt):
|
||
fb_sym = normalize_false_breakout_symbol(symbol)
|
||
if not fb_sym:
|
||
conn.close()
|
||
conn = None
|
||
flash("假突破仅支持 BTC / ETH")
|
||
return redirect("/key_monitor")
|
||
symbol = fb_sym
|
||
if direction_sel not in ("long", "short"):
|
||
conn.close()
|
||
conn = None
|
||
flash("假突破请选择做多或做空")
|
||
return redirect("/key_monitor")
|
||
try:
|
||
key_px = float(d.get("key_price") or 0)
|
||
except (TypeError, ValueError):
|
||
key_px = 0
|
||
if key_px <= 0:
|
||
conn.close()
|
||
conn = None
|
||
flash("请填写关键价位(做空填高点,做多填低点)")
|
||
return redirect("/key_monitor")
|
||
ex_sym_key = normalize_exchange_symbol(symbol)
|
||
key_adj = round_price_to_exchange(ex_sym_key, key_px)
|
||
key_px = float(key_adj) if key_adj is not None else float(key_px)
|
||
try:
|
||
upper_px, lower_px = storage_bounds_from_key_price(direction_sel, key_px)
|
||
except ValueError as e:
|
||
conn.close()
|
||
conn = None
|
||
flash(str(e))
|
||
return redirect("/key_monitor")
|
||
ok_fb, err_fb = _add_false_breakout_key_monitor(
|
||
conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,
|
||
time_close_enabled=tc_en, time_close_hours=tc_h,
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
conn = None
|
||
if not ok_fb:
|
||
flash(err_fb or "假突破监控添加失败")
|
||
return redirect("/key_monitor")
|
||
flash(
|
||
f"假突破监控已添加,限价单已挂出({symbol})"
|
||
f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}"
|
||
+ (f"|{time_close_label(tc_h)}" if tc_en else "")
|
||
)
|
||
return redirect("/key_monitor")
|
||
try:
|
||
upper_raw = float(d.get("upper") or 0)
|
||
lower_raw = float(d.get("lower") or 0)
|
||
except (TypeError, ValueError):
|
||
conn.close()
|
||
conn = None
|
||
flash("上下沿须为有效数字")
|
||
return redirect("/key_monitor")
|
||
upper_px = round_price_to_exchange(ex_sym_key, upper_raw)
|
||
lower_px = round_price_to_exchange(ex_sym_key, lower_raw)
|
||
if float(upper_px) <= float(lower_px):
|
||
conn.close()
|
||
conn = None
|
||
flash("上沿必须大于下沿")
|
||
return redirect("/key_monitor")
|
||
if is_fib_key_monitor_type(mt):
|
||
ok_fib, err_fib = _add_fib_key_monitor(
|
||
conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,
|
||
time_close_enabled=tc_en, time_close_hours=tc_h,
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
conn = None
|
||
if not ok_fib:
|
||
flash(err_fib or "斐波监控添加失败")
|
||
return redirect("/key_monitor")
|
||
flash(
|
||
f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})"
|
||
f"|移动保本:{'开' if be_flag else '关'}"
|
||
+ (f"|{time_close_label(tc_h)}" if tc_en else "")
|
||
)
|
||
return redirect("/key_monitor")
|
||
sl_tp_mode = "standard"
|
||
manual_tp = None
|
||
if mt in KEY_MONITOR_AUTO_TYPES:
|
||
sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode"))
|
||
if sl_tp_mode == "trend_manual":
|
||
try:
|
||
manual_tp = float(d.get("manual_take_profit") or 0)
|
||
except (TypeError, ValueError):
|
||
manual_tp = 0
|
||
if manual_tp <= 0:
|
||
conn.close()
|
||
conn = None
|
||
flash("趋势单方案须填写有效止盈价")
|
||
return redirect("/key_monitor")
|
||
if direction_sel == "long" and manual_tp <= upper_px:
|
||
conn.close()
|
||
conn = None
|
||
flash("做多趋势单:止盈价应高于上沿(阻力)")
|
||
return redirect("/key_monitor")
|
||
if direction_sel == "short" and manual_tp >= lower_px:
|
||
conn.close()
|
||
conn = None
|
||
flash("做空趋势单:止盈价应低于下沿(支撑)")
|
||
return redirect("/key_monitor")
|
||
mtpx = round_price_to_exchange(ex_sym_key, manual_tp)
|
||
if mtpx is not None:
|
||
manual_tp = float(mtpx)
|
||
if mt in KEY_MONITOR_RS_TYPES:
|
||
conn.execute(
|
||
"INSERT INTO key_monitors "
|
||
"(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled,"
|
||
"max_notify,notify_interval_min,time_close_enabled,time_close_hours) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol,
|
||
mt,
|
||
direction_sel,
|
||
upper_px,
|
||
lower_px,
|
||
sl_tp_mode,
|
||
manual_tp,
|
||
be_flag,
|
||
KEY_ALERT_MAX_TIMES,
|
||
KEY_ALERT_INTERVAL_MINUTES,
|
||
tc_en,
|
||
tc_h,
|
||
),
|
||
)
|
||
else:
|
||
conn.execute(
|
||
"INSERT INTO key_monitors "
|
||
"(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled,"
|
||
"time_close_enabled,time_close_hours) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||
(symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
conn = None
|
||
ctr = False
|
||
try:
|
||
coin4h_status, _, _ = _status_by_ema55(symbol, "4h")
|
||
ctr = (direction_sel == "long" and coin4h_status == "空头") or (
|
||
direction_sel == "short" and coin4h_status == "多头"
|
||
)
|
||
except Exception:
|
||
pass
|
||
extra = ""
|
||
if mt in KEY_MONITOR_AUTO_TYPES:
|
||
extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}"
|
||
if tc_en:
|
||
extra += f"|{time_close_label(tc_h)}"
|
||
if mt in KEY_MONITOR_RS_TYPES:
|
||
flash(
|
||
f"添加成功({symbol} 日成交量排名 {rank}/{total})|关键支撑阻力:双向监控上/下沿,"
|
||
f"5m 收盘突破后微信提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分钟)"
|
||
)
|
||
else:
|
||
flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}){extra}")
|
||
if ctr:
|
||
flash(
|
||
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
|
||
)
|
||
return redirect("/key_monitor")
|
||
except Exception as e:
|
||
if conn is not None:
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
flash(f"添加关键位失败:{e}")
|
||
return redirect("/key_monitor")
|
||
|
||
@app.route("/add_order", methods=["POST"])
|
||
@login_required
|
||
def add_order():
|
||
d = request.form
|
||
now = app_now()
|
||
conn = get_db()
|
||
direction = d.get("direction", "long")
|
||
symbol = normalize_symbol_input(d.get("symbol"))
|
||
if not symbol:
|
||
conn.close()
|
||
flash("symbol 不能为空")
|
||
return redirect("/")
|
||
dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction))
|
||
if dup_msg:
|
||
conn.close()
|
||
flash(dup_msg)
|
||
return redirect("/trade")
|
||
ok, reason = precheck_risk(conn, symbol, direction)
|
||
if not ok:
|
||
if "已达最大持仓数" in reason:
|
||
try:
|
||
tp_raw = parse_positive_float(d.get("tp"))
|
||
sl_raw = parse_positive_float(d.get("sl"))
|
||
tgt_raw = parse_positive_float(d.get("tgt"))
|
||
except Exception:
|
||
tp_raw = sl_raw = tgt_raw = None
|
||
ex_miss = normalize_exchange_symbol(symbol)
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=symbol,
|
||
monitor_type="下单监控",
|
||
direction=direction if direction in ("long", "short") else "long",
|
||
trigger_price=round_price_to_exchange(ex_miss, tp_raw) if tp_raw else 0,
|
||
stop_loss=round_price_to_exchange(ex_miss, sl_raw) if sl_raw else 0,
|
||
take_profit=round_price_to_exchange(ex_miss, tgt_raw) if tgt_raw else 0,
|
||
result="错过",
|
||
miss_reason=f"持仓占用:{reason}",
|
||
opened_at=app_now_str(),
|
||
closed_at=app_now_str(),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
flash(f"风控拒绝下单:{reason}")
|
||
return redirect("/trade")
|
||
ok_live, reason_live = ensure_exchange_live_ready()
|
||
if not ok_live:
|
||
conn.close()
|
||
flash(f"风控拒绝下单:{reason_live}")
|
||
return redirect("/trade")
|
||
exchange_symbol = normalize_exchange_symbol(symbol)
|
||
trading_day = get_trading_day(now)
|
||
opens_today_before = conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
|
||
(trading_day,),
|
||
).fetchone()[0]
|
||
session_row = ensure_session(conn, trading_day)
|
||
_, trading_capital_live = get_exchange_capitals(force=True)
|
||
capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"])
|
||
trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower()
|
||
if trade_style not in ("trend", "swing"):
|
||
trade_style = "trend"
|
||
available_usdt = get_available_trading_usdt()
|
||
live_price = get_price(symbol)
|
||
if live_price is None:
|
||
conn.close()
|
||
flash("获取交易所实时价格失败,请稍后重试")
|
||
return redirect("/")
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
lp_r = round_price_to_exchange(exchange_symbol, live_price)
|
||
if lp_r is not None:
|
||
live_price = lp_r
|
||
sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode"))
|
||
try:
|
||
stop_loss, take_profit = resolve_open_sltp_prices(
|
||
direction, live_price, sltp_mode, d
|
||
)
|
||
except ValueError as e:
|
||
conn.close()
|
||
flash(str(e) or "止盈止损参数错误")
|
||
return redirect("/")
|
||
if stop_loss <= 0 or take_profit <= 0:
|
||
conn.close()
|
||
flash("价格参数必须大于0")
|
||
return redirect("/trade")
|
||
planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)
|
||
if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:
|
||
conn.close()
|
||
rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算"
|
||
flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1")
|
||
return redirect("/trade")
|
||
sl_adj = round_price_to_exchange(exchange_symbol, stop_loss)
|
||
tp_adj = round_price_to_exchange(exchange_symbol, take_profit)
|
||
if sl_adj is not None:
|
||
stop_loss = sl_adj
|
||
if tp_adj is not None:
|
||
take_profit = tp_adj
|
||
risk_fraction = calc_risk_fraction(direction, live_price, stop_loss)
|
||
if risk_fraction is None:
|
||
conn.close()
|
||
flash("止损方向不合法:请检查入场方向与止损价格关系")
|
||
return redirect("/")
|
||
risk_percent = max(0.01, float(RISK_PERCENT))
|
||
risk_amount = round(capital_base * risk_percent / 100.0, 2)
|
||
if is_full_margin_mode(POSITION_SIZING_MODE):
|
||
ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn))
|
||
if not ok_flat:
|
||
conn.close()
|
||
flash(flat_msg)
|
||
return redirect("/")
|
||
leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE)
|
||
sizing, sizing_err = compute_full_margin_sizing(
|
||
symbol=symbol,
|
||
available_usdt=available_usdt if available_usdt is not None else 0.0,
|
||
capital_base=capital_base,
|
||
buffer_ratio=FULL_MARGIN_BUFFER_RATIO,
|
||
btc_leverage=BTC_LEVERAGE,
|
||
alt_leverage=ALT_LEVERAGE,
|
||
funds_decimals=2,
|
||
)
|
||
if sizing_err:
|
||
conn.close()
|
||
flash(sizing_err)
|
||
return redirect("/")
|
||
margin_capital = sizing["margin_capital"]
|
||
notional_value = sizing["notional_value"]
|
||
position_ratio = sizing["position_ratio"]
|
||
else:
|
||
default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol)
|
||
try:
|
||
leverage_input = parse_positive_float(d.get("leverage"))
|
||
leverage = int(leverage_input) if leverage_input is not None else default_leverage
|
||
except Exception:
|
||
conn.close()
|
||
flash("杠杆参数格式错误")
|
||
return redirect("/")
|
||
if leverage <= 0:
|
||
conn.close()
|
||
flash("杠杆必须大于0")
|
||
return redirect("/")
|
||
notional_value = round(risk_amount / risk_fraction, 2)
|
||
margin_capital = round(notional_value / leverage, 2)
|
||
if capital_base and margin_capital > capital_base:
|
||
conn.close()
|
||
flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例")
|
||
return redirect("/")
|
||
if available_usdt is not None:
|
||
max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 2)
|
||
if margin_capital > max_margin:
|
||
conn.close()
|
||
flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U")
|
||
return redirect("/")
|
||
position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0
|
||
try:
|
||
amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price)
|
||
contract_size = get_contract_size(exchange_symbol)
|
||
base_amount = round(float(amount) * contract_size, 8)
|
||
order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit)
|
||
open_order_id = order_resp.get("id", "")
|
||
tpsl_attached = bool(order_resp.get("tpsl_attached"))
|
||
trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price)
|
||
except Exception as e:
|
||
conn.close()
|
||
flash(friendly_exchange_error(e, available_usdt=available_usdt))
|
||
return redirect("/")
|
||
|
||
trigger_price = round_price_to_exchange(exchange_symbol, trigger_price)
|
||
stop_loss = round_price_to_exchange(exchange_symbol, stop_loss)
|
||
take_profit = round_price_to_exchange(exchange_symbol, take_profit)
|
||
|
||
make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes")
|
||
opened_at_bj = app_now_str()
|
||
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
|
||
planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit)
|
||
breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER)
|
||
breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT)
|
||
breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0
|
||
risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount
|
||
risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent)
|
||
risk_display = format_risk_display_text(
|
||
POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=2
|
||
)
|
||
if direction == "short":
|
||
breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0)
|
||
else:
|
||
breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0)
|
||
breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
|
||
breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0
|
||
tc_en = parse_time_close_enabled_form(d.get("time_close_enabled"))
|
||
tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
|
||
if tc_en and not tc_h:
|
||
tc_en = 0
|
||
tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms)
|
||
conn.execute(
|
||
"INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(
|
||
symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,
|
||
margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,
|
||
breakeven_enabled,
|
||
notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day,
|
||
ORDER_MONITOR_TYPE_MANUAL,
|
||
tc_en, tc_h, tc_at,
|
||
)
|
||
)
|
||
conn.commit()
|
||
new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
|
||
try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage)
|
||
conn.commit()
|
||
opens_today_after = conn.execute(
|
||
"SELECT COUNT(*) FROM order_monitors WHERE session_date=?",
|
||
(trading_day,),
|
||
).fetchone()[0]
|
||
conn.close()
|
||
|
||
chart_name = None
|
||
chart_url = None
|
||
if make_order_chart and ORDER_CHART_ENABLED:
|
||
try:
|
||
title_prefix = f"{symbol} {direction} #{new_order_id}"
|
||
chart_name = generate_order_open_chart(
|
||
exchange_symbol,
|
||
title_prefix,
|
||
opened_at_ms=opened_at_ms,
|
||
entry_price=trigger_price,
|
||
)
|
||
if chart_name:
|
||
chart_url = f"/static/images/order_charts/{chart_name}"
|
||
except Exception:
|
||
chart_name = None
|
||
chart_url = None
|
||
|
||
if chart_name:
|
||
try:
|
||
journal_id = f"order_{new_order_id}"
|
||
coin = journal_coin_from_symbol(symbol)
|
||
open_local = (opened_at_bj or "")[:16].replace(" ", "T")
|
||
if len(open_local) < 16:
|
||
open_local = app_now().strftime("%Y-%m-%dT%H:%M")
|
||
close_local = open_local
|
||
hold_duration = calc_duration_text(open_local, close_local)
|
||
note = (
|
||
f"auto_from_open_order id={new_order_id} oid={open_order_id} "
|
||
f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}"
|
||
)
|
||
conn = get_db()
|
||
conn.execute(
|
||
"""INSERT OR REPLACE INTO journal_entries
|
||
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
|
||
expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note,
|
||
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
|
||
new_trade_while_occupied, note, image)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
journal_id,
|
||
open_local,
|
||
close_local,
|
||
hold_duration,
|
||
coin,
|
||
"multi",
|
||
"0",
|
||
"auto:open",
|
||
"待平仓",
|
||
"",
|
||
"",
|
||
"否",
|
||
"",
|
||
"",
|
||
"",
|
||
None,
|
||
None,
|
||
None,
|
||
"",
|
||
"否",
|
||
"否",
|
||
note,
|
||
chart_name,
|
||
),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
except Exception:
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
_, trading_capital_after = get_exchange_capitals(force=True)
|
||
account_base_display = (
|
||
round(float(trading_capital_after), 2)
|
||
if trading_capital_after is not None
|
||
else round(float(capital_base), 2)
|
||
)
|
||
account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip()
|
||
dir_text = "多头(long)" if direction == "long" else "空头(short)"
|
||
order_state_text = (
|
||
"已在交易所挂条件委托(止盈、止损各一张触发单)"
|
||
if tpsl_attached
|
||
else "条件委托未挂上(已拦截)"
|
||
)
|
||
rr_show = planned_rr if planned_rr is not None else "-"
|
||
try:
|
||
rr_show_fmt = f"{float(planned_rr):.2f}" if planned_rr is not None else None
|
||
except (TypeError, ValueError):
|
||
rr_show_fmt = None
|
||
rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1"
|
||
ep_wx = format_price_for_symbol(symbol, trigger_price)
|
||
sl_wx = format_wechat_scalar_2dp(stop_loss)
|
||
tp_wx = format_price_for_symbol(symbol, take_profit)
|
||
be_wx = format_price_for_symbol(symbol, breakeven_price)
|
||
style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势"
|
||
wx_lines = [
|
||
f"📈 {symbol} 开仓成功",
|
||
f"💼 交易类型:{dir_text}",
|
||
"🧾 订单基础信息",
|
||
f"🔖 交易所订单 ID:{open_order_id}",
|
||
f"📈 交易风格:{style_zh}",
|
||
f"⚠️ 单笔风控风险:{risk_display}",
|
||
"📊 仓位配置详情",
|
||
f"账户基数:{account_base_display} USDT",
|
||
f"合约杠杆:{leverage} 倍",
|
||
f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT",
|
||
f"仓位占比:{position_ratio}%",
|
||
f"合约张数:{format_wechat_scalar_2dp(amount)} 张",
|
||
f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}",
|
||
"🎯 价位 & 盈亏比",
|
||
f"开仓成交价:{ep_wx}",
|
||
f"止损价位:{sl_wx}",
|
||
f"止盈价位:{tp_wx}",
|
||
f"计划盈亏比:{rr_line}",
|
||
f"移动保本位:{breakeven_rr_trigger}R → {be_wx}",
|
||
"📌 状态统计",
|
||
f"✅ 条件委托:{order_state_text}",
|
||
format_daily_open_counter_line(
|
||
opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT
|
||
),
|
||
]
|
||
if chart_url:
|
||
wx_lines.append(f"多周期K线图:{chart_url}")
|
||
send_wechat_msg("\n".join(wx_lines))
|
||
|
||
flash_lines = [
|
||
f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount}),"
|
||
f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)",
|
||
format_daily_open_summary_short(
|
||
opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT
|
||
),
|
||
]
|
||
if chart_url:
|
||
flash_lines.append(f"已生成多周期K线图:{chart_url}")
|
||
flash(" ".join(flash_lines))
|
||
|
||
if should_send_daily_open_alert(
|
||
opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD
|
||
):
|
||
advice = ai_short_advice(
|
||
build_daily_open_alert_prompt(
|
||
trading_day,
|
||
opens_today_after,
|
||
DAILY_OPEN_ALERT_THRESHOLD,
|
||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||
detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{round(float(margin_capital), 2)}U。",
|
||
)
|
||
)
|
||
if advice:
|
||
send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}")
|
||
flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}")
|
||
return redirect("/")
|
||
|
||
@app.route("/delete_key_monitor/<int:kid>", methods=["POST"])
|
||
@login_required
|
||
def delete_key_monitor(kid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone()
|
||
if not row:
|
||
conn.close()
|
||
return jsonify({"ok": False, "error": "not_found"})
|
||
if is_limit_key_monitor_type(row["monitor_type"]):
|
||
_cancel_fib_monitor_limit(row)
|
||
insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual")
|
||
cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,))
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"ok": cur.rowcount > 0})
|
||
|
||
|
||
@app.route("/delete_key_history/<int:hid>", methods=["POST"])
|
||
@login_required
|
||
def delete_key_history(hid):
|
||
conn = get_db()
|
||
cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,))
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"ok": cur.rowcount > 0})
|
||
|
||
|
||
@app.route("/del_key/<int:id>")
|
||
@login_required
|
||
def del_key(id):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone()
|
||
if row:
|
||
if is_limit_key_monitor_type(row["monitor_type"]):
|
||
_cancel_fib_monitor_limit(row)
|
||
insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual")
|
||
conn.execute("DELETE FROM key_monitors WHERE id=?", (id,))
|
||
conn.commit()
|
||
conn.close()
|
||
resp = redirect("/")
|
||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||
resp.headers["Pragma"] = "no-cache"
|
||
return resp
|
||
|
||
|
||
def _csv_response(filename, rows, header):
|
||
buf = StringIO()
|
||
w = csv.writer(buf)
|
||
w.writerow(header)
|
||
for row in rows:
|
||
w.writerow(row)
|
||
out = "\ufeff" + buf.getvalue()
|
||
return Response(
|
||
out,
|
||
mimetype="text/csv; charset=utf-8",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||
"Cache-Control": "no-store",
|
||
},
|
||
)
|
||
|
||
|
||
def _md_response(filename, content):
|
||
return Response(
|
||
content,
|
||
mimetype="text/markdown; charset=utf-8",
|
||
headers={
|
||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||
"Cache-Control": "no-store",
|
||
},
|
||
)
|
||
|
||
|
||
@app.route("/export/trade_records")
|
||
@login_required
|
||
def export_trade_records():
|
||
win = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,"
|
||
"margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount,"
|
||
"opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason,"
|
||
"exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at "
|
||
f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? "
|
||
f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
conn.close()
|
||
head = [
|
||
"id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price",
|
||
"stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage",
|
||
"pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount",
|
||
"opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason",
|
||
"exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型",
|
||
]
|
||
data = []
|
||
for r in rows:
|
||
er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else ""
|
||
er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else ""
|
||
kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else ""
|
||
eff = er1 or er0 or entry_reason_from_key_signal(kst) or ""
|
||
snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"]
|
||
data.append((
|
||
r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"],
|
||
snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"],
|
||
r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"],
|
||
r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"],
|
||
r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None,
|
||
r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None,
|
||
r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None,
|
||
r["created_at"], eff,
|
||
))
|
||
day = app_now().strftime("%Y%m%d")
|
||
return _csv_response(f"trade_records_v3_{day}.csv", data, head)
|
||
|
||
|
||
@app.route("/export/journal_entries")
|
||
@login_required
|
||
def export_journal_entries():
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason,"
|
||
"expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues,"
|
||
"post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC"
|
||
).fetchall()
|
||
conn.close()
|
||
head = [
|
||
"id",
|
||
"open_datetime",
|
||
"close_datetime",
|
||
"hold_duration",
|
||
"coin",
|
||
"tf",
|
||
"pnl",
|
||
"entry_reason",
|
||
"exit_reason",
|
||
"expect_rr",
|
||
"real_rr",
|
||
"early_exit",
|
||
"early_exit_trigger",
|
||
"early_exit_note",
|
||
"early_exit_reason",
|
||
"mood_issues",
|
||
"post_breakeven_stare",
|
||
"new_trade_while_occupied",
|
||
"note",
|
||
"image",
|
||
"created_at",
|
||
]
|
||
data = [tuple(r[h] for h in head) for r in rows]
|
||
day = app_now().strftime("%Y%m%d")
|
||
return _csv_response(f"journal_entries_v1_{day}.csv", data, head)
|
||
|
||
|
||
@app.route("/export/key_monitors")
|
||
@login_required
|
||
def export_key_monitors():
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify,"
|
||
"notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC"
|
||
).fetchall()
|
||
conn.close()
|
||
head = [
|
||
"id",
|
||
"symbol",
|
||
"monitor_type",
|
||
"direction",
|
||
"upper",
|
||
"lower",
|
||
"notification_count",
|
||
"last_notified_at",
|
||
"max_notify",
|
||
"notify_interval_min",
|
||
"breakout_limit_pct",
|
||
"created_at",
|
||
]
|
||
data = [tuple(r[h] for h in head) for r in rows]
|
||
day = app_now().strftime("%Y%m%d")
|
||
return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head)
|
||
|
||
|
||
@app.route("/export/key_monitor_history")
|
||
@login_required
|
||
def export_key_monitor_history():
|
||
win = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at "
|
||
"FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
conn.close()
|
||
head = [
|
||
"id",
|
||
"symbol",
|
||
"monitor_type",
|
||
"direction",
|
||
"upper",
|
||
"lower",
|
||
"notification_count",
|
||
"last_alert_message",
|
||
"close_reason",
|
||
"closed_at",
|
||
]
|
||
data = [tuple(r[h] for h in head) for r in rows]
|
||
day = app_now().strftime("%Y%m%d")
|
||
return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head)
|
||
|
||
@app.route("/del_order/<int:id>")
|
||
@login_required
|
||
def del_order(id):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone()
|
||
if not row:
|
||
conn.close()
|
||
flash("订单不存在")
|
||
return redirect("/")
|
||
if row["status"] == "active":
|
||
try:
|
||
p = get_price(row["symbol"]) or float(row["trigger_price"])
|
||
opened_at = get_opened_at_value(row)
|
||
closed_at = app_now_str()
|
||
hold_seconds = calc_hold_seconds(opened_at, app_now())
|
||
pnl_amount = calc_pnl(
|
||
row["direction"],
|
||
row["trigger_price"],
|
||
p,
|
||
row["margin_capital"] or DAILY_START_CAPITAL,
|
||
row["leverage"] or infer_leverage(row["symbol"])
|
||
)
|
||
close_resp = close_exchange_order(row)
|
||
close_order_id = close_resp.get("id", "")
|
||
cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
|
||
session_date = row["session_date"] or get_trading_day()
|
||
session_capital = update_session_capital(conn, session_date, pnl_amount)
|
||
row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=row["symbol"],
|
||
monitor_type=trade_record_monitor_type(conn, row),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(row),
|
||
key_signal_type=order_row_key_signal_type(row),
|
||
direction=row["direction"],
|
||
trigger_price=row["trigger_price"],
|
||
stop_loss=row["stop_loss"],
|
||
initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"],
|
||
take_profit=row["take_profit"],
|
||
margin_capital=margin_capital_for_trade_record(row_snap),
|
||
leverage=row["leverage"],
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trade_style=row["trade_style"],
|
||
risk_amount=row["risk_amount"],
|
||
planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]),
|
||
actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]),
|
||
result="手动平仓",
|
||
miss_reason=handoff_trade_miss_reason("用户手动删除订单触发平仓", row),
|
||
opened_at=opened_at,
|
||
closed_at=closed_at,
|
||
)
|
||
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||
|
||
on_user_initiated_close(
|
||
conn,
|
||
source=CLOSE_SOURCE_USER_INSTANCE,
|
||
trade_record_id=insert_trade_record_id(conn),
|
||
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||
trading_day=session_date,
|
||
now=app_now(),
|
||
)
|
||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
|
||
try:
|
||
_rcfg = app.extensions.get("strategy_roll_cfg")
|
||
if isinstance(_rcfg, dict):
|
||
from strategy_register import roll_sync_after_external_close
|
||
|
||
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
|
||
except Exception:
|
||
pass
|
||
clear_key_sizing_snapshot_if_flat(conn, session_date)
|
||
conn.commit()
|
||
conn.close()
|
||
send_wechat_msg(
|
||
build_wechat_close_message(
|
||
symbol=row["symbol"],
|
||
direction=row["direction"],
|
||
result="手动平仓",
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trigger_price=row["trigger_price"],
|
||
current_price=p,
|
||
stop_loss=row["stop_loss"],
|
||
take_profit=row["take_profit"],
|
||
close_order_id=close_order_id or "-",
|
||
extra_note="用户在页面手动平仓",
|
||
session_capital_fallback=session_capital,
|
||
)
|
||
)
|
||
flash("已按实盘流程手动平仓")
|
||
return redirect("/trade")
|
||
except Exception as e:
|
||
if is_no_position_error(str(e)):
|
||
cancel_gate_swap_trigger_orders(row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]))
|
||
opened_at = get_opened_at_value(row)
|
||
opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at)
|
||
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms)
|
||
miss_reason = f"手动删除时无持仓:{miss_reason}"
|
||
closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now()
|
||
hold_seconds = calc_hold_seconds(opened_at, closed_at_dt)
|
||
session_date = row["session_date"] or get_trading_day(closed_at_dt)
|
||
update_session_capital(conn, session_date, pnl_amount)
|
||
row_snap = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() or row
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=row["symbol"],
|
||
monitor_type=trade_record_monitor_type(conn, row),
|
||
trend_plan_id=trend_plan_id_from_monitor_row(row),
|
||
key_signal_type=order_row_key_signal_type(row),
|
||
direction=row["direction"],
|
||
trigger_price=row["trigger_price"],
|
||
stop_loss=row["stop_loss"],
|
||
initial_stop_loss=row["initial_stop_loss"] or row["stop_loss"],
|
||
take_profit=row["take_profit"],
|
||
margin_capital=margin_capital_for_trade_record(row_snap),
|
||
leverage=row["leverage"],
|
||
pnl_amount=pnl_amount,
|
||
hold_seconds=hold_seconds,
|
||
trade_style=row["trade_style"],
|
||
risk_amount=row["risk_amount"],
|
||
planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]),
|
||
actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]),
|
||
result=result,
|
||
miss_reason=handoff_trade_miss_reason(miss_reason, row),
|
||
opened_at=opened_at,
|
||
closed_at=closed_at,
|
||
)
|
||
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||
|
||
on_user_initiated_close(
|
||
conn,
|
||
source=CLOSE_SOURCE_USER_INSTANCE,
|
||
trade_record_id=insert_trade_record_id(conn),
|
||
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||
trading_day=session_date,
|
||
now=app_now(),
|
||
)
|
||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
||
try:
|
||
_rcfg = app.extensions.get("strategy_roll_cfg")
|
||
if isinstance(_rcfg, dict):
|
||
from strategy_register import roll_sync_after_external_close
|
||
|
||
roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"])
|
||
except Exception:
|
||
pass
|
||
conn.commit()
|
||
conn.close()
|
||
flash("该仓位在交易所已不存在,已按成交记录同步结束并记账")
|
||
return redirect("/")
|
||
conn.close()
|
||
flash(f"手动平仓失败:{str(e)}")
|
||
return redirect("/")
|
||
conn.execute("DELETE FROM order_monitors WHERE id=?",(id,))
|
||
conn.commit()
|
||
conn.close()
|
||
return redirect("/")
|
||
|
||
@app.route("/add_miss", methods=["POST"])
|
||
@login_required
|
||
def add_miss():
|
||
d = request.form
|
||
direction = d.get("direction", "long")
|
||
sym_in = normalize_symbol_input(d.get("symbol"))
|
||
ex_sym = normalize_exchange_symbol(sym_in)
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
tp_px = round_price_to_exchange(ex_sym, float(d["tp"]))
|
||
sl_px = round_price_to_exchange(ex_sym, float(d["sl"]))
|
||
tgt_px = round_price_to_exchange(ex_sym, float(d["tgt"]))
|
||
except Exception:
|
||
flash("价格格式错误")
|
||
return _redirect_records()
|
||
conn = get_db()
|
||
insert_trade_record(
|
||
conn,
|
||
symbol=sym_in,
|
||
monitor_type=d["type"],
|
||
direction=direction,
|
||
trigger_price=tp_px,
|
||
stop_loss=sl_px,
|
||
take_profit=tgt_px,
|
||
result="错过",
|
||
miss_reason=d["reason"],
|
||
opened_at=app_now_str(),
|
||
closed_at=app_now_str(),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
flash("已记录错过机会")
|
||
return _redirect_records()
|
||
|
||
|
||
@app.route("/add_journal", methods=["POST"])
|
||
@login_required
|
||
def add_journal():
|
||
d = request.form
|
||
entry_reason_norm = normalize_entry_reason(d.get("entry_reason"), d.get("entry_reason_custom"))
|
||
if not entry_reason_norm:
|
||
flash("请选择开仓类型;若选「其他」请在下方填写自定义说明")
|
||
return _redirect_records()
|
||
early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger"))
|
||
early_exit_note = str(d.get("early_exit_note") or "").strip()
|
||
if not early_exit_trigger:
|
||
flash("请选择离场触发")
|
||
return _redirect_records()
|
||
if early_exit_trigger == "手动平仓" and not early_exit_note:
|
||
flash("手工平仓必须填写补充说明")
|
||
return _redirect_records()
|
||
if early_exit_trigger != "手动平仓":
|
||
early_exit_note = ""
|
||
# 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」
|
||
early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否"
|
||
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note)
|
||
exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
|
||
image_filename = None
|
||
uploaded_tmp = None
|
||
entry_id = uuid.uuid4().hex
|
||
file = request.files.get("screenshot")
|
||
if file and file.filename:
|
||
ext = os.path.splitext(file.filename)[1]
|
||
image_filename = f"{uuid.uuid4().hex}{ext}"
|
||
save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename))
|
||
file.save(save_path)
|
||
uploaded_tmp = image_filename
|
||
|
||
mood_issues = ",".join(request.form.getlist("mood_issues"))
|
||
hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", ""))
|
||
real_rr_text = (d.get("real_rr") or "").strip()
|
||
try:
|
||
risk_amount_hint = float(d.get("risk_amount_hint") or 0)
|
||
pnl_hint = float(d.get("pnl") or 0)
|
||
# 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额
|
||
if risk_amount_hint > 0:
|
||
real_rr_text = f"{(pnl_hint / risk_amount_hint):.4f}"
|
||
except Exception:
|
||
pass
|
||
|
||
want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes")
|
||
chart_msg = None
|
||
if want_exchange_chart and ORDER_CHART_ENABLED:
|
||
coin = (d.get("coin") or "").strip().upper()
|
||
symbol_guess = normalize_symbol_input(coin) or coin
|
||
exchange_symbol = normalize_exchange_symbol(symbol_guess)
|
||
title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
|
||
journal_tfs = parse_journal_chart_timeframes(
|
||
d.get("journal_chart_tf1"),
|
||
d.get("journal_chart_tf2"),
|
||
ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None,
|
||
)
|
||
journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
|
||
chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor"))
|
||
marker_payload = {
|
||
"entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
|
||
"exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
|
||
"entry_price": d.get("entry_price_hint"),
|
||
"exit_price": d.get("exit_price_hint"),
|
||
"stop_loss_price": d.get("stop_loss_hint"),
|
||
"chart_anchor": chart_anchor,
|
||
"now_ts_ms": int(app_now().timestamp() * 1000),
|
||
}
|
||
try:
|
||
chart_fname = f"journal_{entry_id}.png"
|
||
saved = generate_multi_timeframe_chart_png(
|
||
exchange_symbol,
|
||
title_prefix,
|
||
timeframes=journal_tfs,
|
||
limit=journal_limit,
|
||
out_dir=app.config["UPLOAD_FOLDER"],
|
||
filename=chart_fname,
|
||
filename_prefix="journal",
|
||
marker_payload=marker_payload,
|
||
marker_timeframes={x.strip().lower() for x in journal_tfs},
|
||
layout="vertical",
|
||
)
|
||
if saved:
|
||
image_filename = saved
|
||
chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}"
|
||
if uploaded_tmp:
|
||
try:
|
||
old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp)
|
||
if os.path.exists(old_path):
|
||
os.remove(old_path)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。"
|
||
except Exception as e:
|
||
image_filename = uploaded_tmp
|
||
chart_msg = f"自动生成K线图失败:{str(e)}"
|
||
|
||
conn = get_db()
|
||
conn.execute(
|
||
"""INSERT INTO journal_entries
|
||
(id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason,
|
||
expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note,
|
||
mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare,
|
||
new_trade_while_occupied, note, image)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
entry_id,
|
||
normalize_bj_datetime_storage(d.get("open_datetime")),
|
||
normalize_bj_datetime_storage(d.get("close_datetime")),
|
||
hold_duration,
|
||
d.get("coin"),
|
||
d.get("tf"),
|
||
d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text,
|
||
early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
|
||
None, None, None, mood_issues,
|
||
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
|
||
)
|
||
)
|
||
from account_risk_lib import on_journal_saved
|
||
|
||
on_journal_saved(
|
||
conn,
|
||
early_exit_trigger=early_exit_trigger,
|
||
early_exit_note=early_exit_note,
|
||
mood_issues_raw=mood_issues,
|
||
trading_day=get_trading_day(),
|
||
now=app_now(),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
if chart_msg:
|
||
flash(f"交易复盘记录已保存。{chart_msg}")
|
||
else:
|
||
flash("交易复盘记录已保存")
|
||
return _redirect_records()
|
||
|
||
|
||
@app.route("/api/journals")
|
||
@login_required
|
||
def api_journals():
|
||
win = _list_window_from_request()
|
||
start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ)
|
||
conn = get_db()
|
||
j_ts = sql_list_time_field("close_datetime", "created_at", "open_datetime")
|
||
rows = conn.execute(
|
||
f"SELECT * FROM journal_entries WHERE {j_ts} >= ? AND {j_ts} <= ? ORDER BY created_at DESC LIMIT 500",
|
||
(start_bj, end_bj),
|
||
).fetchall()
|
||
conn.close()
|
||
result = []
|
||
for r in rows:
|
||
item = row_to_dict(r)
|
||
item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x]
|
||
result.append(item)
|
||
return jsonify(result)
|
||
|
||
|
||
@app.route("/api/journal_prefill", methods=["POST"])
|
||
@login_required
|
||
def api_journal_prefill():
|
||
file = request.files.get("screenshot")
|
||
if not file or not file.filename:
|
||
return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400
|
||
try:
|
||
raw = file.read()
|
||
if not raw:
|
||
return jsonify({"ok": False, "msg": "截图为空"}), 400
|
||
image_b64 = base64.b64encode(raw).decode("utf-8")
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400
|
||
|
||
parsed = ai_extract_journal_from_image(image_b64)
|
||
if parsed is None:
|
||
return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500
|
||
return jsonify({"ok": True, "data": parsed})
|
||
|
||
|
||
@app.route("/delete_journal/<jid>", methods=["POST"])
|
||
@login_required
|
||
def delete_journal(jid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone()
|
||
if row and row["image"]:
|
||
img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"])
|
||
if os.path.exists(img_path):
|
||
os.remove(img_path)
|
||
conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,))
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@app.route("/api/reviews")
|
||
@login_required
|
||
def api_reviews():
|
||
win = _list_window_from_request()
|
||
start_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"])
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200",
|
||
(start_sql, end_sql),
|
||
).fetchall()
|
||
conn.close()
|
||
return jsonify([row_to_dict(r) for r in rows])
|
||
|
||
|
||
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
|
||
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
|
||
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
|
||
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js")
|
||
|
||
|
||
@app.route("/static/ai_review_render.js")
|
||
def static_ai_review_render_js():
|
||
if not os.path.isfile(_AI_REVIEW_RENDER_JS):
|
||
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
|
||
return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")
|
||
|
||
|
||
@app.route("/static/form_submit_guard.js")
|
||
def static_form_submit_guard_js():
|
||
if not os.path.isfile(_FORM_SUBMIT_GUARD_JS):
|
||
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
|
||
return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8")
|
||
|
||
|
||
@app.route("/static/manual_order_rr_preview.js")
|
||
def static_manual_order_rr_preview_js():
|
||
if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS):
|
||
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
|
||
return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8")
|
||
|
||
|
||
@app.route("/export/review_md/<rid>")
|
||
@login_required
|
||
def export_review_md(rid):
|
||
conn = get_db()
|
||
row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return Response("review not found", status=404, mimetype="text/plain; charset=utf-8")
|
||
|
||
review_type = "日复盘" if row["review_type"] == "daily" else "周复盘"
|
||
target_date = row["target_date"] or "-"
|
||
created_at = row["created_at"] or app_now_str()
|
||
content = (row["content"] or "").strip()
|
||
if not content:
|
||
content = "(无内容)"
|
||
|
||
md = (
|
||
f"# {review_type}报告\n\n"
|
||
f"- 目标日期: {target_date}\n"
|
||
f"- 生成时间: {created_at}\n"
|
||
f"- 报告ID: {row['id']}\n\n"
|
||
f"---\n\n"
|
||
f"{content}\n"
|
||
)
|
||
|
||
safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date"
|
||
safe_type = "daily" if row["review_type"] == "daily" else "weekly"
|
||
filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md"
|
||
return _md_response(filename, md)
|
||
|
||
|
||
@app.route("/export/reviews_md_bundle")
|
||
@login_required
|
||
def export_reviews_md_bundle():
|
||
review_type = (request.args.get("review_type") or "").strip().lower()
|
||
target_date = (request.args.get("target_date") or "").strip()
|
||
if review_type not in ("daily", "weekly"):
|
||
return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8")
|
||
if not target_date:
|
||
return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8")
|
||
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC",
|
||
(review_type, target_date),
|
||
).fetchall()
|
||
conn.close()
|
||
if not rows:
|
||
return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8")
|
||
|
||
title = "日复盘" if review_type == "daily" else "周复盘"
|
||
lines = [
|
||
f"# {title}汇总报告",
|
||
"",
|
||
f"- 目标日期: {target_date}",
|
||
f"- 条目数量: {len(rows)}",
|
||
f"- 导出时间: {app_now_str()}",
|
||
"",
|
||
"---",
|
||
"",
|
||
]
|
||
for idx, row in enumerate(rows, 1):
|
||
created_at = row["created_at"] or "-"
|
||
content = (row["content"] or "").strip() or "(无内容)"
|
||
lines.extend(
|
||
[
|
||
f"## 第{idx}条",
|
||
"",
|
||
f"- 报告ID: {row['id']}",
|
||
f"- 生成时间: {created_at}",
|
||
"",
|
||
content,
|
||
"",
|
||
"---",
|
||
"",
|
||
]
|
||
)
|
||
md = "\n".join(lines)
|
||
safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date"
|
||
filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md"
|
||
return _md_response(filename, md)
|
||
|
||
|
||
@app.route("/delete_review/<rid>", methods=["POST"])
|
||
@login_required
|
||
def delete_review(rid):
|
||
conn = get_db()
|
||
conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,))
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@app.route("/delete_trade_record/<int:rid>", methods=["POST"])
|
||
@login_required
|
||
def delete_trade_record(rid):
|
||
conn = get_db()
|
||
cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,))
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount})
|
||
|
||
|
||
@app.route("/api/trade_record_review_update", methods=["POST"])
|
||
@login_required
|
||
def api_trade_record_review_update():
|
||
payload = request.get_json(silent=True) or {}
|
||
rec_id = payload.get("id")
|
||
try:
|
||
rec_id = int(rec_id)
|
||
except Exception:
|
||
return jsonify({"ok": False, "msg": "记录ID无效"}), 400
|
||
|
||
reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip()
|
||
reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip()
|
||
reviewed_stop_loss_raw = payload.get("reviewed_stop_loss")
|
||
reviewed_take_profit_raw = payload.get("reviewed_take_profit")
|
||
reviewed_result = str(payload.get("reviewed_result") or "").strip()
|
||
reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip()
|
||
reviewed_pnl_raw = payload.get("reviewed_pnl_amount")
|
||
|
||
if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS:
|
||
return jsonify({"ok": False, "msg": "结果仅允许:止盈/止损/保本止盈/移动止盈/手动平仓"}), 400
|
||
|
||
try:
|
||
reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S")
|
||
reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S")
|
||
except Exception:
|
||
return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400
|
||
if reviewed_close_dt < reviewed_open_dt:
|
||
return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400
|
||
hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds())
|
||
hold_minutes = calc_hold_minutes(hold_seconds)
|
||
|
||
try:
|
||
reviewed_pnl_amount = float(reviewed_pnl_raw)
|
||
except Exception:
|
||
return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400
|
||
reviewed_stop_loss = None
|
||
if reviewed_stop_loss_raw not in (None, ""):
|
||
try:
|
||
reviewed_stop_loss = float(reviewed_stop_loss_raw)
|
||
except Exception:
|
||
return jsonify({"ok": False, "msg": "止损必须为数字"}), 400
|
||
reviewed_take_profit = None
|
||
if reviewed_take_profit_raw not in (None, ""):
|
||
try:
|
||
reviewed_take_profit = float(reviewed_take_profit_raw)
|
||
except Exception:
|
||
return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400
|
||
|
||
_MISSING_ER = object()
|
||
reviewed_entry_reason_update = _MISSING_ER
|
||
if "reviewed_entry_reason" in payload:
|
||
s = str(payload.get("reviewed_entry_reason") or "").strip()
|
||
if s and not entry_reason_valid_for_storage(s):
|
||
return jsonify({"ok": False, "msg": "开仓类型须为五种固定整句之一、自定义说明(2000字内)或留空"}), 400
|
||
reviewed_entry_reason_update = s or None
|
||
|
||
conn = get_db()
|
||
row = conn.execute("SELECT risk_amount, symbol FROM trade_records WHERE id=?", (rec_id,)).fetchone()
|
||
if not row:
|
||
conn.close()
|
||
return jsonify({"ok": False, "msg": "记录不存在"}), 404
|
||
risk_amount = row["risk_amount"]
|
||
ex_review = resolve_ccxt_price_symbol(row["symbol"])
|
||
try:
|
||
ensure_markets_loaded()
|
||
except Exception:
|
||
pass
|
||
if reviewed_stop_loss is not None:
|
||
reviewed_stop_loss = round_price_to_exchange(ex_review, reviewed_stop_loss)
|
||
if reviewed_take_profit is not None:
|
||
reviewed_take_profit = round_price_to_exchange(ex_review, reviewed_take_profit)
|
||
actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount)
|
||
base_params = [
|
||
reviewed_opened_at,
|
||
reviewed_closed_at,
|
||
reviewed_stop_loss,
|
||
reviewed_take_profit,
|
||
round(reviewed_pnl_amount, 4),
|
||
reviewed_result or None,
|
||
reviewed_miss_reason or None,
|
||
hold_seconds,
|
||
hold_minutes,
|
||
app_now_str(),
|
||
actual_rr,
|
||
]
|
||
if reviewed_entry_reason_update is not _MISSING_ER:
|
||
conn.execute(
|
||
"""UPDATE trade_records
|
||
SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?,
|
||
reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?,
|
||
reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=?
|
||
WHERE id=?""",
|
||
tuple(base_params + [reviewed_entry_reason_update, rec_id]),
|
||
)
|
||
else:
|
||
conn.execute(
|
||
"""UPDATE trade_records
|
||
SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?,
|
||
reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?,
|
||
reviewed_at=?, actual_rr=COALESCE(?, actual_rr)
|
||
WHERE id=?""",
|
||
tuple(base_params + [rec_id]),
|
||
)
|
||
if reviewed_result == "手动平仓" and reviewed_miss_reason:
|
||
from account_risk_lib import apply_manual_close_journal_cooloff
|
||
|
||
apply_manual_close_journal_cooloff(
|
||
conn,
|
||
early_exit_note=reviewed_miss_reason,
|
||
trading_day=get_trading_day(),
|
||
now=app_now(),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes})
|
||
|
||
|
||
@app.route("/manual_transfer", methods=["POST"])
|
||
@login_required
|
||
def manual_transfer():
|
||
try:
|
||
amount = float(request.form.get("amount", "0"))
|
||
except Exception:
|
||
flash("划转金额格式错误")
|
||
return redirect("/")
|
||
from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip()
|
||
to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip()
|
||
ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account)
|
||
conn = get_db()
|
||
conn.execute(
|
||
"INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)",
|
||
("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500])
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
if ok:
|
||
flash(f"手动划转成功:{amount}U {from_account}->{to_account}")
|
||
else:
|
||
flash(f"手动划转失败:{msg}")
|
||
return redirect(request.referrer or "/trade")
|
||
|
||
|
||
def _journal_ai_chart_builder(row):
|
||
return build_journal_ai_chart_path(
|
||
row,
|
||
app.config["UPLOAD_FOLDER"],
|
||
order_chart_enabled=ORDER_CHART_ENABLED,
|
||
normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)),
|
||
generate_chart_fn=generate_multi_timeframe_chart_png,
|
||
local_datetime_to_ms_fn=_local_input_datetime_to_ms,
|
||
now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000),
|
||
)
|
||
|
||
|
||
@app.route("/ai_daily_review", methods=["POST"])
|
||
@login_required
|
||
def ai_daily_review():
|
||
date = request.form.get("date", "")
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC",
|
||
(date,)
|
||
).fetchall()
|
||
conn.close()
|
||
if not rows:
|
||
return jsonify({"result": "该日无交易记录"})
|
||
|
||
text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n"
|
||
for idx, row in enumerate(rows, 1):
|
||
text += journal_row_lines_for_ai(idx, row)
|
||
text += "\n"
|
||
|
||
image_paths = collect_images_for_ai_review(
|
||
rows,
|
||
app.config["UPLOAD_FOLDER"],
|
||
build_chart_if_missing=_journal_ai_chart_builder,
|
||
)
|
||
ai_result = ai_review(text, "每日", image_paths=image_paths)
|
||
full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}"
|
||
conn = get_db()
|
||
conn.execute(
|
||
"INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)",
|
||
(uuid.uuid4().hex, "daily", date, full)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"result": full})
|
||
|
||
|
||
@app.route("/ai_weekly_review", methods=["POST"])
|
||
@login_required
|
||
def ai_weekly_review():
|
||
start_date = request.form.get("start_date", "")
|
||
end_date = request.form.get("end_date", "")
|
||
conn = get_db()
|
||
rows = conn.execute(
|
||
"SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC",
|
||
(start_date, end_date)
|
||
).fetchall()
|
||
conn.close()
|
||
if not rows:
|
||
return jsonify({"result": "该时间段无交易记录"})
|
||
|
||
text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n"
|
||
for idx, row in enumerate(rows, 1):
|
||
text += journal_row_lines_for_ai(idx, row)
|
||
text += "\n"
|
||
|
||
image_paths = collect_images_for_ai_review(
|
||
rows,
|
||
app.config["UPLOAD_FOLDER"],
|
||
build_chart_if_missing=_journal_ai_chart_builder,
|
||
)
|
||
ai_result = ai_review(text, "周度", image_paths=image_paths)
|
||
full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}"
|
||
conn = get_db()
|
||
conn.execute(
|
||
"INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)",
|
||
(uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full)
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({"result": full})
|
||
|
||
def _hub_meta_bundle():
|
||
return {
|
||
"exchange_display": EXCHANGE_DISPLAY_NAME,
|
||
"key_gate_rule_text": (
|
||
f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|"
|
||
f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|"
|
||
f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}"
|
||
),
|
||
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||
"btc_leverage": BTC_LEVERAGE,
|
||
"alt_leverage": ALT_LEVERAGE,
|
||
}
|
||
|
||
|
||
def _hub_account_bundle():
|
||
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||
funding_usdt = round(funding_capital, 2) if funding_capital is not None else None
|
||
trading_usdt = round(trading_capital, 2) if trading_capital is not None else None
|
||
available = get_available_trading_usdt()
|
||
return {
|
||
"funding_usdt": funding_usdt,
|
||
"trading_usdt": trading_usdt,
|
||
"available_trading_usdt": round(available, 2) if available is not None else None,
|
||
"trading_day": get_trading_day(app_now()),
|
||
}
|
||
|
||
|
||
def _hub_fetch_market(base=""):
|
||
from hub_market_info_lib import fetch_usdt_swap_market_info
|
||
|
||
return fetch_usdt_swap_market_info(
|
||
base_or_symbol=base,
|
||
normalize_symbol_input=normalize_symbol_input,
|
||
normalize_exchange_symbol=normalize_exchange_symbol,
|
||
ensure_markets_loaded=ensure_markets_loaded,
|
||
exchange=exchange,
|
||
exchange_id="gate_bot",
|
||
)
|
||
|
||
|
||
def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
|
||
from hub_ohlcv_lib import fetch_ohlcv_for_hub
|
||
|
||
return fetch_ohlcv_for_hub(
|
||
symbol=symbol,
|
||
timeframe=timeframe,
|
||
since_ms=since_ms,
|
||
limit=limit,
|
||
normalize_symbol_input=normalize_symbol_input,
|
||
normalize_exchange_symbol=normalize_exchange_symbol,
|
||
ensure_markets_loaded=ensure_markets_loaded,
|
||
exchange=exchange,
|
||
friendly_error=friendly_exchange_error,
|
||
)
|
||
|
||
|
||
def _hub_fetch_volume_rank(top_n=20):
|
||
from hub_volume_rank_lib import fetch_usdt_swap_volume_rank
|
||
|
||
return fetch_usdt_swap_volume_rank(
|
||
exchange=exchange,
|
||
ensure_markets_loaded=ensure_markets_loaded,
|
||
top_n=top_n,
|
||
exchange_id="gateio",
|
||
)
|
||
|
||
|
||
try:
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
_repo_root = Path(__file__).resolve().parent.parent
|
||
if str(_repo_root) not in sys.path:
|
||
sys.path.insert(0, str(_repo_root))
|
||
from hub_bridge import install_on_app
|
||
|
||
install_on_app(
|
||
app,
|
||
exchange="gate_bot",
|
||
capabilities=["order", "key"],
|
||
has_trend=True,
|
||
get_db=get_db,
|
||
row_to_dict=row_to_dict,
|
||
meta_fn=_hub_meta_bundle,
|
||
account_fn=_hub_account_bundle,
|
||
views={"add_order": add_order, "add_key": add_key},
|
||
ohlcv_fn=_hub_fetch_ohlcv,
|
||
volume_rank_fn=_hub_fetch_volume_rank,
|
||
market_fn=_hub_fetch_market,
|
||
reconcile_hub_flat_fn=reconcile_hub_external_close,
|
||
risk_status_fn=hub_account_risk_status,
|
||
user_close_fn=hub_user_initiated_close,
|
||
render_main_page_fn=render_main_page,
|
||
login_required_fn=login_required,
|
||
)
|
||
except Exception as _hub_err:
|
||
print(f"[hub_bridge] gate_bot: {_hub_err}")
|
||
|
||
|
||
@app.route("/strategy")
|
||
@login_required
|
||
def strategy_trading_page():
|
||
return render_main_page("strategy")
|
||
|
||
|
||
@app.route("/strategy/trend")
|
||
@login_required
|
||
def strategy_trend_page():
|
||
qs = request.query_string.decode()
|
||
return redirect(f"/strategy?{qs}" if qs else "/strategy")
|
||
|
||
|
||
@app.route("/strategy/roll")
|
||
@login_required
|
||
def strategy_roll_page():
|
||
return redirect("/strategy")
|
||
|
||
|
||
from strategy_register import install_strategy_trading
|
||
from strategy_trend_register import install_strategy_trend
|
||
|
||
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||
|
||
_purge_key_monitors_if_full_margin()
|
||
|
||
|
||
# 启动
|
||
if __name__ == "__main__":
|
||
threading.Thread(target=background_task, daemon=True).start()
|
||
app.run(host=HOST, port=PORT, debug=DEBUG)
|