From b6d343a9514dc9696b683f8edae75a0938dcddbf Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 9 Jun 2026 18:19:50 +0800 Subject: [PATCH] sync gate_bot with gate as identical copy instance Align gate_bot app, templates, and env template with gate while keeping bot identity (port 5002, hub key gate_bot). Co-authored-by: Cursor --- crypto_monitor_gate_bot/.env.example | 70 +- crypto_monitor_gate_bot/README.md | 90 + crypto_monitor_gate_bot/app.py | 4651 +++++++++-------- crypto_monitor_gate_bot/templates/index.html | 716 ++- .../templates/order_focus.html | 10 +- crypto_monitor_gate_bot/使用说明.md | 145 + crypto_monitor_gate_bot/关键位自动下单说明.md | 142 + crypto_monitor_gate_bot/更新文档.md | 148 + manual_trading_hub/settings_store.py | 2 +- 9 files changed, 3471 insertions(+), 2503 deletions(-) create mode 100644 crypto_monitor_gate_bot/README.md create mode 100644 crypto_monitor_gate_bot/使用说明.md create mode 100644 crypto_monitor_gate_bot/关键位自动下单说明.md create mode 100644 crypto_monitor_gate_bot/更新文档.md diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example index e02b152..e76fc2d 100644 --- a/crypto_monitor_gate_bot/.env.example +++ b/crypto_monitor_gate_bot/.env.example @@ -62,9 +62,8 @@ BTC_LEVERAGE=10 ALT_LEVERAGE=5 # 交易日重置小时(北京时间) TRADING_DAY_RESET_HOUR=8 -# Gate 平仓历史:同步「趋势回调」交易记录与交易所已实现盈亏(北京日期 00:00 起,与 APP_TIMEZONE 一致);留空则从近 90 天拉取 -# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-05-14 -# EXCHANGE_POSITION_HISTORY_LIMIT=200 +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true # 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单) LIVE_TRADING_ENABLED=true @@ -82,31 +81,64 @@ GATE_TPSL_USE_POSITION_ORDER=true GATE_TPSL_TRIGGER_EXPIRATION=604800 # 触发参考价:0=最新成交 1=标记价 2=指数价(非法值按 0) GATE_TPSL_PRICE_TYPE=0 +# 仓位类 TP/SL 相对现价的最小间距(%),避免 Gate 1026「触发价须高于/低于现价」 +GATE_TPSL_LAST_PRICE_GAP_PCT=0.05 # 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 Gate·模拟) # EXCHANGE_DISPLAY_NAME=Gate.io # ============================================================================= -# 交易执行 / 开仓限制(与 crypto_monitor_gate 主站一致) +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) # ============================================================================= -# 【最大同时持仓】active 下单监控数达到该值后禁止再开仓(默认 1=单仓) +# 【周期】门控 K 线周期,如 5m、15m +KLINE_TIMEFRAME=5m +# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2,确认棒默认 -1 +KEY_CONFIRM_BREAKOUT_BAR=-2 +KEY_CONFIRM_BAR=-1 +# 【量能】突破棒成交量 > 前 N 根均量 × 倍数 +KEY_VOLUME_MA_BARS=20 +KEY_VOLUME_RATIO_MIN=1.3 +# 【突破K实体幅度】占开盘价百分比区间 +# 【箱体/收敛】突破K收盘越过关键位下限%;无上限(过猛由计划RR过滤) +KEY_BREAKOUT_AMP_MIN_PCT=0.03 +KEY_BREAKOUT_AMP_MAX_PCT=0.5 +# 【阻力/支撑】突破后微信提醒 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 +# 【日成交量排名】品种须在该排名前 N 名 +KEY_DAILY_VOLUME_RANK_MAX=30 +# 【关键位自动开仓盈亏比】严格大于该值才市价开仓 +KEY_AUTO_MIN_PLANNED_RR=1.5 +# 止损:突破 K 极值向外缓冲的百分比(默认 0.5 即 0.5%) +KEY_STOP_OUTSIDE_BREAKOUT_PCT=0.5 +# 趋势单方案:止损在突破 K 极值外侧的百分比(默认 1 即 1%) +KEY_TREND_STOP_OUTSIDE_PCT=1 +KEY_ALERT_MAX_TIMES=3 +KEY_ALERT_INTERVAL_MINUTES=5 + +# ============================================================================= +# 交易执行 / 人工风控(页面「实盘下单」) +# ============================================================================= +# 【最大同时持仓】默认 1=单仓 MAX_ACTIVE_POSITIONS=1 +# 【人工下单最低盈亏比】低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) +MANUAL_MIN_PLANNED_RR=1.4 +# 【关键位连开计仓】已有持仓时按无仓时资金快照算基数 +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true # 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) DAILY_OPEN_ALERT_THRESHOLD=5 # 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 DAILY_OPEN_HARD_LIMIT=0 -# 整点前禁止新开仓:true=启用(默认),false=关闭(交易日划分仍用 TRADING_DAY_RESET_HOUR) -# TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true - -# 关键位监控:5m收线突破过滤参数 -KLINE_TIMEFRAME=5m -KEY_BREAKOUT_LIMIT_PCT=1.5 -KEY_ALERT_MAX_TIMES=3 -KEY_ALERT_INTERVAL_MINUTES=5 # 资金与仓位刷新周期(秒) BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 # 后台监控轮询周期(秒) MONITOR_POLL_SECONDS=3 +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) +RECONCILE_STARTUP_GRACE_SEC=90 +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +RECONCILE_FLAT_CONFIRM_POLLS=3 # 使用可用资金时的缓冲比例(如0.98代表用98%) FULL_MARGIN_BUFFER_RATIO=0.98 @@ -129,13 +161,12 @@ FORCE_CLOSE_ENABLED=false # 推送与AI超时(秒) WECHAT_TIMEOUT_SECONDS=10 AI_TIMEOUT_SECONDS=120 -AI_REVIEW_TIMEOUT_SECONDS=300 +# AI 提供方:openai(默认)| ollama AI_PROVIDER=openai OPENAI_API_BASE=https://op.bz121.com/v1 OPENAI_API_KEY=你的密钥 -# 须与网关「模型分布」里已启用节点的 Model ID 完全一致(示例为 4070s 节点) -OPENAI_MODEL=huihui_ai/gemma-4-abliterated:e4b +OPENAI_MODEL=gemma4:e4b OLLAMA_API=http://127.0.0.1:11434/api/generate AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest @@ -165,12 +196,5 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest # 开单风格默认值:trend / swing # DEFAULT_TRADE_STYLE=trend -# 趋势回调策略(可选,见 趋势回调策略说明.md) -# TREND_PULLBACK_DCA_LEGS=5 -# TREND_PULLBACK_PREVIEW_TTL_SECONDS=120 -# 趋势回调手动保本:相对持仓均价的默认偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100) -# TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT=0.3 -# TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT=5 - APP_TIMEZONE=Asia/Shanghai # TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED diff --git a/crypto_monitor_gate_bot/README.md b/crypto_monitor_gate_bot/README.md new file mode 100644 index 0000000..f87d643 --- /dev/null +++ b/crypto_monitor_gate_bot/README.md @@ -0,0 +1,90 @@ +# crypto_monitor_gate + +基于 **Flask** 的加密货币 **下单监控 / 关键位监控 / 交易复盘** 小系统,行情与实盘接口统一走 **Gate.io USDT 永续**,通过 **ccxt** 访问。 + +## 文档导航 + +| 文档 | 说明 | +|------|------| +| **[使用说明.md](./使用说明.md)** | 日常怎么用:登录、关键位四类、手工开仓、单仓与微信等 | +| **[关键位自动下单说明.md](./关键位自动下单说明.md)** | 关键位自动开仓的 RR、止盈止损、结案原因与 `.env` | +| **[部署文档.md](./部署文档.md)** | Ubuntu、PM2、**SSH SOCKS** 访问 Gate API 等 | + +另:**Binance U 本位** 对等实现见同级的 **`crypto_monitor_binance`** 仓库。 + +--- + +## 功能概要 + +- **关键位监控**:5m 收线硬条件、企业微信推送;**箱体 / 收敛** 在 RR 达标时可 **自动市价开仓**(见专门文档);**阻力 / 支撑** 仅单次提醒结案 +- **下单监控**:本地风控(含移动保本)、止盈/止损触达后轮询尝试平仓并记账 +- **实盘(可选)**:`LIVE_TRADING_ENABLED=true` 且配置 **`GATE_API_KEY` / `GATE_API_SECRET`** 时,支持开仓、挂单 TP/SL、余额与划转(权限依账户而定) +- **止盈止损(Gate)**:市价成交后经 **`_gate_place_tp_sl_orders`** 挂单;优先 **仓位类 `price_orders`**(受 `GATE_TPSL_USE_POSITION_ORDER`、`GATE_TPSL_PRICE_TYPE`、`GATE_POS_MODE` 等影响) + +--- + +## 环境要求 + +- Python 3.10+(建议) +- 依赖:`flask`、`requests`、`ccxt`、`werkzeug`、`PySocks`(经 SOCKS 代理时);`Pillow`(K 线导出等可选用) + +安装示例: + +```bash +cd /opt/crypto_monitor/crypto_monitor_gate +source .venv/bin/activate +pip install -r ../requirements.txt +``` + +## 配置(`.env.example` → `.env`) + +- **`.env.example`**:模板(可提交 Git);首次:`cp .env.example .env` 后编辑。 +- **`.env`**:本机真实配置(勿提交);`git pull` 不覆盖;升级前建议备份(见《部署文档》§5.2)。 + +项目启动时加载**仓库根目录**下的 `.env`。常用项: + +| 变量 | 说明 | +|------|------| +| `GATE_API_KEY` / `GATE_API_SECRET` | Gate API(需合约与对应权限) | +| `LIVE_TRADING_ENABLED` | `true` 允许真实下单;`false` 仅本地与推送逻辑 | +| `GATE_MARGIN_MODE` / `GATE_POS_MODE` | 保证金与持仓模式 | +| `GATE_TPSL_USE_POSITION_ORDER` / `GATE_TPSL_PRICE_TYPE` 等 | 条件止盈止损行为 | +| `GATE_SOCKS_PROXY` | 可选;直连不稳时 SSH 动态转发(详见部署文档) | +| `APP_PASSWORD` / `FLASK_SECRET_KEY` | Web 登录与 Session | +| `WECHAT_WEBHOOK` | 企业微信机器人 | +| `EXCHANGE_DISPLAY_NAME` / `GATE_ACCOUNT_LABEL` | 页面与推送展示的账户文案 | + +其余见 **`.env.example` 内注释** 或 **`app.py` 顶部默认值**。 + +## 运行 + +生产使用 **PM2**(`ecosystem.config.cjs`)。调试: + +```bash +source .venv/bin/activate && python app.py +``` + +见 [docs/ubuntu-server.md](../docs/ubuntu-server.md)。 + +端口由 **`APP_PORT`** 控制(未设置默认 **5000**)。浏览器登录 **`/login`**,口令为 **`APP_PASSWORD`**。 + +## 部署(Linux / PM2 / SSH SOCKS) + +见 **[部署文档.md](./部署文档.md)**。 + +## 自检脚本 + +```bash +python scripts/verify_gate_funding.py +``` + +用于核对密钥前缀(不落 Secret)、资金/合约可读性等(需网络与权限)。 + +## 数据与脚本 + +- 默认 SQLite:由 **`DB_PATH`** 指定(常见为项目下 `crypto.db`) +- `scripts/fix_breakeven_labels.py`:修正「止损」但盈亏为正的记录标签(参见部署文档说明) + +## 风险与合规 + +实盘有亏损风险。请确认 API 权限、IP 白名单、杠杆与保证金模式与 **Gate.io** 后台一致,并遵守当地法律法规与交易所用户协议。 diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 8252784..94f7398 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -40,36 +40,38 @@ from ai_review_lib import ( collect_images_for_ai_review, journal_row_lines_for_ai, ) -from manual_sltp_lib import ( - normalize_open_sltp_mode, - resolve_entrust_sltp_prices, - resolve_open_sltp_prices, -) -from position_sizing_lib import ( - 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 form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order -from order_monitor_display_lib import ( - apply_order_live_price_display, - apply_order_price_display_fields, - enrich_order_display_fields, - order_monitor_tpsl_needs_sync, - stop_is_profit_protecting, - tpsl_slot_trigger_price, - tpsl_update_passes_rr_gate, +from fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + KEY_ENTRY_REASON_BY_SIGNAL, + 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, + 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, @@ -87,14 +89,61 @@ from journal_chart_lib import ( trade_review_fetch_window, trim_rows_for_trade_review, ) -from hub_auth import request_allowed as hub_request_allowed -from strategy_trade_labels import ( - STRATEGY_ENTRY_REASON_OPTIONS, - apply_order_monitor_source_labels, - handoff_trade_miss_reason, - trade_record_monitor_type as resolve_trade_record_monitor_type, - trend_plan_id_from_monitor_row, +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 manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) +from position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + 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_TYPES, + auto_amp_ok, + auto_confirm_ok, + claim_rs_level_notify, + detect_rs_box_break, + format_auto_amp_line, + format_auto_confirm_line, + 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 history_window_lib import ( PRESET_CUSTOM, PRESET_UTC_LAST24H, @@ -146,17 +195,6 @@ def resolve_path(path_value): app = Flask(__name__) app.secret_key = os.getenv("FLASK_SECRET_KEY", "crypto_monitor_2026_secret_key") - -def trend_add_zone_label(direction): - """趋势回调:做多=补仓上沿,做空=补仓下沿(库字段仍为 add_upper)。""" - return "补仓上沿" if (direction or "long").strip().lower() == "long" else "补仓下沿" - - -@app.context_processor -def _inject_trend_ui_helpers(): - return {"trend_add_zone_label": trend_add_zone_label} - - # ====================== 登录配置 ====================== USERNAME = os.getenv("APP_USERNAME", "dekun") PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") @@ -176,8 +214,6 @@ 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")) -# 与 Gate 主站一致:最大同时 active 下单监控数(默认 1=单仓) -MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))) # 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) # false 时关闭「整点前禁止新开仓」守卫(交易日划分仍用 TRADING_DAY_RESET_HOUR) @@ -185,11 +221,6 @@ 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") -# 交易所「平仓历史」同步:自北京日期 00:00 起(与 APP_TIMEZONE 一致);空则取最近 90 天 -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_POSITION_HISTORY_SYNC_AT = 0.0 def _resolve_app_tz(): @@ -213,6 +244,8 @@ 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" @@ -220,7 +253,26 @@ 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_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.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") @@ -233,18 +285,11 @@ 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")) -# 趋势回调:补仓触发档位数(平分剩余 50% 计划仓位) -TREND_PULLBACK_DCA_LEGS = max(1, int(os.getenv("TREND_PULLBACK_DCA_LEGS", "5"))) -# 预览有效期(秒);超时须重新「生成预览」 -TREND_PULLBACK_PREVIEW_TTL_SECONDS = max(10, int(os.getenv("TREND_PULLBACK_PREVIEW_TTL_SECONDS", "120"))) -# 确认执行时:当前可用余额与预览快照相对偏差超过该百分比则拒绝(避免余额被划走后仍按旧计划满仓) -TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT = float(os.getenv("TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT", "5")) -# 趋势回调:手动保本默认相对均价偏移(%);多=均价×(1+pct/100),空=均价×(1-pct/100) -TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT = float( - os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3") -) -MONITOR_TYPE_TREND = "趋势回调" +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")) @@ -265,7 +310,6 @@ from daily_open_limit_lib import ( DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env() RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) -MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) 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")) @@ -330,8 +374,6 @@ LIQUIDITY_RANK_CACHE = { # 企业微信推送 def send_wechat_msg(content): - from wechat_notify_lib import send_wechat_webhook - send_wechat_webhook( WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS ) @@ -368,10 +410,10 @@ def _wechat_trading_capital_text(fallback=None): except Exception: trading_capital = None if trading_capital is not None: - return f"{round(float(trading_capital), 4)}U" + return f"{round(float(trading_capital), 2)}U" if fallback is not None: try: - return f"{round(float(fallback), 4)}U" + return f"{round(float(fallback), 2)}U" except Exception: pass return "-" @@ -395,12 +437,12 @@ def build_wechat_close_message( 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_price_for_symbol(symbol, stop_loss) + 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, 4)} U" + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" else: pnl_disp = "-" except (TypeError, ValueError): @@ -430,7 +472,7 @@ def build_wechat_close_message( def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): - sl_fmt = format_price_for_symbol(symbol, new_sl) + sl_fmt = format_wechat_scalar_2dp(new_sl) return "\n".join( [ f"# 🛡️ {symbol} 保护位更新", @@ -610,58 +652,6 @@ def _marker_tag_label(tag): return str(tag or "") -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 _pick_marker_point(rows, target_ts_ms, target_price=None): if not rows or target_ts_ms is None: return None, None @@ -789,6 +779,58 @@ def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), 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, @@ -945,6 +987,7 @@ def journal_coin_from_symbol(symbol): EARLY_EXIT_TRIGGERS = ( "", + "止盈", "保本止盈", "移动止盈", "手动平仓", @@ -952,15 +995,28 @@ EARLY_EXIT_TRIGGERS = ( "其他", ) -# 与用户约定的固定开仓类型(仅做这几类单子) +# 与用户约定的固定开仓类型 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"}), ) # 复盘表单「其他」选项的 value(非入库值;自定义文本走 entry_reason_custom) ENTRY_REASON_OTHER = "__OTHER__" @@ -975,7 +1031,7 @@ def normalize_entry_reason(raw, custom_text=None): def entry_reason_valid_for_storage(s): - """允许固定开仓类型选项、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" + """允许五种固定整句、或自定义短文本(不含未解析的 __OTHER__ 占位)。""" t = str(s or "").strip() if not t: return True @@ -1023,7 +1079,6 @@ def ai_extract_journal_from_image(image_b64): - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 - - 趋势回调 6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):保本止盈、移动止盈、手动平仓、止损、其他。 7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 @@ -1105,6 +1160,7 @@ def init_db(): 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")''') @@ -1218,6 +1274,21 @@ def init_db(): 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 @@ -1305,26 +1376,6 @@ def init_db(): try: c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") except: pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN trend_plan_id INTEGER") - except Exception: - pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL") - except Exception: - pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT") - except Exception: - pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT") - except Exception: - pass - try: - c.execute("ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT") - except Exception: - pass try: c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") except: pass @@ -1355,6 +1406,42 @@ def init_db(): 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", + ): + try: + c.execute(ddl) + except Exception: + pass + 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", + ): + try: + c.execute(ddl) + except Exception: + pass c.execute( """CREATE TABLE IF NOT EXISTS key_monitor_history @@ -1363,120 +1450,6 @@ def init_db(): close_reason TEXT, closed_at TEXT)""" ) - c.execute( - """CREATE TABLE IF NOT EXISTS trend_pullback_plans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - status TEXT DEFAULT 'active', - symbol TEXT NOT NULL, - exchange_symbol TEXT, - direction TEXT NOT NULL DEFAULT 'long', - leverage INTEGER NOT NULL, - stop_loss REAL NOT NULL, - add_upper REAL NOT NULL, - take_profit REAL NOT NULL, - risk_percent REAL DEFAULT 5, - snapshot_available_usdt REAL, - snapshot_at TEXT, - plan_margin_capital REAL, - target_order_amount REAL, - first_order_amount REAL, - remainder_total REAL, - dca_legs INTEGER DEFAULT 5, - per_leg_amount REAL, - grid_prices_json TEXT, - leg_amounts_json TEXT, - legs_done INTEGER DEFAULT 0, - first_order_done INTEGER DEFAULT 0, - last_mark_price REAL, - avg_entry_price REAL, - order_amount_open REAL, - opened_at TEXT, - opened_at_ms INTEGER, - session_date TEXT, - message TEXT - )""" - ) - - try: - c.execute("ALTER TABLE trend_pullback_plans ADD COLUMN leg_amounts_json TEXT") - except Exception: - pass - for ddl in ( - "ALTER TABLE trend_pullback_plans ADD COLUMN initial_stop_loss REAL", - "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied INTEGER DEFAULT 0", - "ALTER TABLE trend_pullback_plans ADD COLUMN breakeven_applied_at TEXT", - ): - try: - c.execute(ddl) - except Exception: - pass - - c.execute( - """CREATE TABLE IF NOT EXISTS trend_pullback_previews ( - id TEXT PRIMARY KEY, - symbol TEXT NOT NULL, - exchange_symbol TEXT NOT NULL, - direction TEXT NOT NULL, - leverage INTEGER NOT NULL, - stop_loss REAL NOT NULL, - add_upper REAL NOT NULL, - take_profit REAL NOT NULL, - risk_percent REAL NOT NULL, - snapshot_available_usdt REAL NOT NULL, - snapshot_at TEXT, - live_price_ref REAL, - plan_margin_capital REAL, - target_order_amount REAL, - first_order_amount REAL, - remainder_total REAL, - dca_legs INTEGER, - per_leg_amount REAL, - grid_prices_json TEXT, - leg_amounts_json TEXT, - expires_at_ms INTEGER NOT NULL, - created_at TEXT - )""" - ) - - c.execute( - """CREATE TABLE IF NOT EXISTS trend_pullback_preview_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - preview_id TEXT NOT NULL UNIQUE, - symbol TEXT NOT NULL, - exchange_symbol TEXT NOT NULL, - direction TEXT NOT NULL, - leverage INTEGER NOT NULL, - stop_loss REAL NOT NULL, - add_upper REAL NOT NULL, - take_profit REAL NOT NULL, - risk_percent REAL NOT NULL, - snapshot_available_usdt REAL NOT NULL, - snapshot_at TEXT, - live_price_ref REAL, - plan_margin_capital REAL, - target_order_amount REAL, - first_order_amount REAL, - remainder_total REAL, - dca_legs INTEGER, - per_leg_amount REAL, - grid_prices_json TEXT, - leg_amounts_json TEXT, - expires_at_ms INTEGER NOT NULL, - preview_created_at TEXT, - outcome TEXT DEFAULT 'open', - executed_plan_id INTEGER - )""" - ) - for ddl in ( - "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN preview_created_at TEXT", - "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN outcome TEXT DEFAULT 'open'", - "ALTER TABLE trend_pullback_preview_snapshots ADD COLUMN executed_plan_id INTEGER", - ): - try: - c.execute(ddl) - except Exception: - pass - from strategy_db import init_strategy_tables init_strategy_tables(conn) @@ -1495,7 +1468,7 @@ def _purge_key_monitors_if_full_margin(): conn, sizing_mode=POSITION_SIZING_MODE, select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), - cancel_fib_limit=lambda _row: None, + 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, ) @@ -1610,46 +1583,83 @@ def _count_opens_between(conn, start_td, end_td): ).fetchone()[0] -def _count_trend_plan_opens_between(conn, start_td, end_td): - """趋势回调:按计划在库里的 session_date(开仓所属北京交易日)计数。""" +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 + 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, + } + 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 trend_pullback_plans WHERE session_date IS NOT NULL AND TRIM(session_date) != '' " - "AND session_date >= ? AND session_date <= ?", + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", (start_td, end_td), ).fetchone()[0] -def _load_completed_trade_pnls(conn, monitor_type: str): - """已平仓实盘记录:按 monitor_type 过滤;趋势回调优先用交易所同步盈亏。""" - q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, - result, reviewed_result, exchange_realized_pnl +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 - WHERE monitor_type = ? ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" - rows = conn.execute(q, (monitor_type,)).fetchall() + 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: - if monitor_type == MONITOR_TYPE_TREND: - ex = None - try: - ex = r["exchange_realized_pnl"] - except (KeyError, IndexError, TypeError): - ex = None - if ex is not None and str(ex).strip() != "": - p = float(ex) - else: - p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) - else: - p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + 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)) + out.append((p, t, td, r)) return out @@ -1660,19 +1670,19 @@ def _compute_period_metrics(trades): 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), 4) + 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), 4) if loss_sum_raw < 0 else 0.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), 4) if neg_pnls else None - max_single_profit = round(max(pos_pnls), 4) if pos_pnls else None + 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, 4) + max_dd = round(max_dd, 2) streak = 0 for p, _, _ in reversed(trades): if p < 0: @@ -1696,7 +1706,7 @@ def _compute_period_metrics(trades): else: run = 0 worst_day = min(daily.keys(), key=lambda x: daily[x]) - worst_day_pnl = round(daily[worst_day], 4) + 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, @@ -1718,56 +1728,50 @@ def _compute_period_metrics(trades): def compute_stats_bundle(conn, trading_day, now_dt=None): - """日 / 周 / 月 统计:平仓按平仓时间所在交易日计入;下单监控与趋势回调分列。""" + """日 / 周 / 月 统计:平仓按北京时间交易日(默认 8:00 切日)计入。""" now_dt = now_dt or app_now() - pnls_order = _load_completed_trade_pnls(conn, "下单监控") - pnls_trend = _load_completed_trade_pnls(conn, MONITOR_TYPE_TREND) - total_opens_order = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] - total_opens_trend = conn.execute("SELECT COUNT(*) FROM trend_pullback_plans").fetchone()[0] + 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): - _p, _t, td = tr - return td and w_start <= td <= w_end + return tr[2] and w_start <= tr[2] <= w_end def in_month(tr): - _p, _t, td = tr - return td and m_start <= td <= m_end + return tr[2] and m_start <= tr[2] <= m_end - day_range = f"北京时间交易日 {trading_day}" - week_range = f"{w_start} ~ {w_end}(北京日期,近7天窗口)" - month_range = f"{m_start} ~ {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 - day_o = [tr for tr in pnls_order if tr[2] == trading_day] - day_t = [tr for tr in pnls_trend if tr[2] == trading_day] - dm_o = _compute_period_metrics(day_o) - dm_t = _compute_period_metrics(day_t) - dm_o["opens_count"] = _count_opens_between(conn, trading_day, trading_day) - dm_t["opens_count"] = _count_trend_plan_opens_between(conn, trading_day, trading_day) + 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}) - week_o = [tr for tr in pnls_order if in_week(tr)] - week_t = [tr for tr in pnls_trend if in_week(tr)] - wm_o = _compute_period_metrics(week_o) - wm_t = _compute_period_metrics(week_t) - wm_o["opens_count"] = _count_opens_between(conn, w_start, w_end) - wm_t["opens_count"] = _count_trend_plan_opens_between(conn, w_start, w_end) - - month_o = [tr for tr in pnls_order if in_month(tr)] - month_t = [tr for tr in pnls_trend if in_month(tr)] - mm_o = _compute_period_metrics(month_o) - mm_t = _compute_period_metrics(month_t) - mm_o["opens_count"] = _count_opens_between(conn, m_start, m_end) - mm_t["opens_count"] = _count_trend_plan_opens_between(conn, m_start, m_end) + dm, wm, mm = slice_metrics("all") return { "trading_day": trading_day, - "total_opens_order": total_opens_order, - "total_opens_trend": total_opens_trend, - "total_opens_all": int(total_opens_order) + int(total_opens_trend), - "day": {"range_label": day_range, "order": dm_o, "trend": dm_t}, - "week": {"range_label": week_range, "order": wm_o, "trend": wm_t}, - "month": {"range_label": month_range, "order": mm_o, "trend": mm_t}, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, } @@ -1805,24 +1809,6 @@ def resolve_monitor_exchange_symbol(row): return normalize_exchange_symbol(raw) if raw else "" -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 _position_contract_symbol_match(position_symbol, wanted_exchange_symbol): if not position_symbol or not wanted_exchange_symbol: return False @@ -1982,73 +1968,40 @@ def to_effective_trade_dict(row): 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 "" - mt = (item.get("monitor_type") or "").strip() + 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") - ex_open = item.get("exchange_opened_at") - ex_close = item.get("exchange_closed_at") - if mt == MONITOR_TYPE_TREND and ex_pnl is not None and str(ex_pnl).strip() != "": + if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": try: - item["display_pnl_amount"] = float(ex_pnl) + 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_amount"] = float(item.get("effective_pnl_amount") or 0) - item["display_pnl_source"] = "exchange" - eo = (str(ex_open).strip() if ex_open else "") or item.get("effective_opened_at") or "" - ec = (str(ex_close).strip() if ex_close else "") or item.get("effective_closed_at") or "" - item["display_opened_at"] = eo[:16] if eo else "-" - item["display_closed_at"] = ec[:16] if ec else "-" + item["display_pnl_source"] = "local" + elif has_reviewed_pnl: + item["display_pnl_source"] = "reviewed" else: - try: - item["display_pnl_amount"] = float(item.get("effective_pnl_amount") or 0) - except (TypeError, ValueError): - item["display_pnl_amount"] = 0.0 item["display_pnl_source"] = "local" - eo = item.get("effective_opened_at") or "" - ec = item.get("effective_closed_at") or "" - item["display_opened_at"] = (eo[:16] if eo else "-") - item["display_closed_at"] = (ec[:16] if ec else "-") return item -def format_money_usdt(value): - """资金类展示:固定两位小数(USDT)。""" - if value is None or value == "": - return "—" - try: - return f"{round(float(value), 2):.2f}" - except (TypeError, ValueError): - return "—" - - -def _exchange_unified_symbol_for_format(symbol_str): - if not symbol_str: - return None - s = str(symbol_str).strip() - if not s: - return None - try: - if ":" in s or "/" in s: - return normalize_exchange_symbol(s) - return normalize_exchange_symbol(f"{s}/USDT") - except Exception: - return None - - -def format_price_for_symbol(symbol, value): - if value in (None, ""): - return "-" +def format_price_magnitude_fallback(value): + """无 markets 或解析失败时的价格展示兜底(按量级)。""" try: v = float(value) except Exception: return str(value) if v == 0: return "0" - sym = _exchange_unified_symbol_for_format(symbol) - if sym and exchange_private_api_configured(): - try: - ensure_markets_loaded() - return str(exchange.price_to_precision(sym, v)) - except Exception: - pass av = abs(v) if av >= 10000: d = 2 @@ -2066,68 +2019,86 @@ def format_price_for_symbol(symbol, value): return text.rstrip("0").rstrip(".") if "." in text else text -def format_amount_for_symbol(symbol, value): - """合约张数等:尽量与交易所 amount 精度一致。""" +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) - sym = _exchange_unified_symbol_for_format(symbol) - if sym and exchange_private_api_configured(): - try: - ensure_markets_loaded() - return str(exchange.amount_to_precision(sym, v)) - except Exception: - pass - text = f"{v:.8f}" - return text.rstrip("0").rstrip(".") if "." in text else text + 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 insert_trend_preview_snapshot(conn, preview_id, created, exp_ms, pl): - """生成预览成功后归档一条快照(与 trend_pullback_previews 同参)。""" - conn.execute( - """INSERT INTO trend_pullback_preview_snapshots ( - preview_id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, - snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, - dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,preview_created_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - preview_id, - pl["symbol"], - pl["exchange_symbol"], - pl["direction"], - pl["leverage"], - pl["stop_loss"], - pl["add_upper"], - pl["take_profit"], - pl["risk_percent"], - pl["snapshot_available_usdt"], - pl["snapshot_at"], - pl["live_price_ref"], - pl["plan_margin_capital"], - pl["target_order_amount"], - pl["first_order_amount"], - pl["remainder_total"], - pl["dca_legs"], - pl["per_leg_amount"], - pl["grid_prices_json"], - pl["leg_amounts_json"], - exp_ms, - created, - ), - ) +def format_usdt(value): + """USDT 资金类展示:固定两位小数。""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) -def preview_snapshot_outcome_label(outcome): - o = (outcome or "").strip().lower() - return { - "open": "待确认", - "executed": "已执行", - "cancelled": "已取消", - "expired": "已过期", - }.get(o, outcome or "-") +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): @@ -2277,6 +2248,7 @@ def insert_trade_record( closed_at=None, closed_at_ms=None, exchange_trade_id=None, + key_signal_type=None, entry_reason=None, trend_plan_id=None, ): @@ -2285,17 +2257,23 @@ def insert_trade_record( 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) - er = (entry_reason or "").strip() or None + 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 "" + ) conn.execute( - "INSERT INTO trade_records (symbol,monitor_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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "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, direction, trigger_price, snap_sl, snap_sl, take_profit, + 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, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, trend_plan_id, ) ) @@ -2336,7 +2314,7 @@ def enrich_order_item(raw_item, current_capital): notional = item.get("notional_value") ratio = item.get("position_ratio") if notional is None: - notional = round(margin * lev, 4) if margin and lev else 0 + 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 @@ -2347,7 +2325,7 @@ def enrich_order_item(raw_item, current_capital): 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="下单监控") + return apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) def ensure_exchange_live_ready(): @@ -2358,8 +2336,29 @@ def ensure_exchange_live_ready(): 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="下单监控") + 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(): @@ -2609,7 +2608,7 @@ def friendly_exchange_error(err, available_usdt=None): 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, 4)}U)" if available_usdt is not None else "" + 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}" @@ -2702,10 +2701,55 @@ def trading_day_reset_allows_new_open(now): def get_active_position_count(conn): - """与 Gate 主站一致:仅统计 order_monitors.status=active。""" 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() if not trading_day_reset_allows_new_open(now): @@ -2718,11 +2762,6 @@ def precheck_risk(conn, symbol, direction): ) if not ok_daily: return False, daily_reason - trend_n = conn.execute( - "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" - ).fetchone()[0] - if trend_n > 0: - return False, "已存在运行中的趋势回调计划,请先结束该计划" if direction not in ("long", "short"): return False, "方向必须为 long 或 short" if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): @@ -2734,152 +2773,6 @@ def precheck_risk(conn, symbol, direction): return True, "" -def precheck_trend_pullback_start(conn): - """趋势回调启动前:不与机器人下单监控达持仓上限并存。""" - now = app_now() - if not trading_day_reset_allows_new_open(now): - return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" - 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 - active_count = get_active_position_count(conn) - if active_count >= MAX_ACTIVE_POSITIONS: - return False, ( - f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})," - "请先结束「机器人下单监控」中的持仓,再启动趋势回调" - ) - trend_n = conn.execute( - "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" - ).fetchone()[0] - if trend_n > 0: - return False, "已存在运行中的趋势回调计划" - return True, "" - - -def _trend_cleanup_stale_previews(conn): - ms = int(time.time() * 1000) - stale = conn.execute("SELECT id FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)).fetchall() - for row in stale: - try: - conn.execute( - "UPDATE trend_pullback_preview_snapshots SET outcome='expired' WHERE preview_id=? AND outcome='open'", - (row["id"],), - ) - except Exception: - pass - conn.execute("DELETE FROM trend_pullback_previews WHERE expires_at_ms < ?", (ms,)) - - -def parse_and_compute_trend_pullback_plan(form_dict): - """ - 解析表单并计算趋势回调预览参数(不写库、不下单)。 - 成功返回 (payload, None);失败返回 (None, 错误文案)。 - """ - d = form_dict or {} - symbol = normalize_symbol_input(d.get("symbol")) - if not symbol: - return None, "symbol 不能为空" - direction = (d.get("direction") or "long").strip().lower() - if direction not in ("long", "short"): - return None, "方向错误" - try: - stop_loss = float(d.get("sl")) - add_upper = float(d.get("add_upper")) - take_profit = float(d.get("take_profit")) - risk_percent = float(d.get("risk_percent") or "5") - except Exception: - return None, "价格或风险比例格式错误" - try: - lev_raw = parse_positive_float(d.get("leverage")) - leverage = int(lev_raw) if lev_raw is not None else infer_leverage(symbol) - except Exception: - return None, "杠杆格式错误" - if leverage <= 0 or risk_percent <= 0: - return None, "杠杆与风险比例必须大于0" - from strategy_trend_lib import validate_trend_bounds - - bound_err = validate_trend_bounds(direction, stop_loss, add_upper) - if bound_err: - return None, bound_err - snap = get_available_trading_usdt() - if snap is None or snap <= 0: - return None, "无法读取合约账户 USDT 可用余额,请检查 API 与账户类型" - live_price = get_price(symbol) - if live_price is None: - return None, "获取实时价格失败" - exchange_symbol = normalize_exchange_symbol(symbol) - rf = calc_risk_fraction(direction, add_upper, stop_loss) - if rf is None or rf <= 0: - return None, "止损与补仓区间边界组合无法计算风险比例" - risk_budget = float(snap) * (risk_percent / 100.0) - notional = risk_budget / rf - margin_plan = notional / float(leverage) - margin_plan = min(margin_plan, float(snap) * FULL_MARGIN_BUFFER_RATIO) - if margin_plan <= 0: - return None, "计划保证金过小" - try: - target_amt, _ = prepare_order_amount(exchange_symbol, margin_plan, leverage, live_price) - except Exception as e: - return None, str(e) - first_amt = _safe_amount_to_precision(exchange_symbol, target_amt * 0.5) - if first_amt is None or first_amt <= 0: - return None, "首仓张数过小(低于交易所最小张数),请提高风险比例或杠杆" - remainder_total = _safe_amount_to_precision( - exchange_symbol, max(0.0, float(target_amt) - float(first_amt)) - ) - if remainder_total is None: - remainder_total = 0.0 - from strategy_trend_lib import build_grid_prices, build_leg_amounts_json - - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - min_amt = float((market.get("limits", {}).get("amount", {}) or {}).get("min") or 0) - n_legs, leg_json, per_ref = build_leg_amounts_json( - exchange_symbol, - remainder_total, - TREND_PULLBACK_DCA_LEGS, - _safe_amount_to_precision, - min_amt, - ) - if n_legs <= 0: - return None, "剩余计划张数不足以拆出补仓档(低于交易所最小张数),请提高风险比例、放宽止损与补仓区间间距,或减少补仓档数" - grid = build_grid_prices(direction, stop_loss, add_upper, n_legs) - if len(grid) != n_legs: - return None, "补仓网格生成失败" - opened_at = app_now_str() - try: - leg_list = json.loads(leg_json) - except Exception: - leg_list = [] - payload = { - "symbol": symbol, - "exchange_symbol": exchange_symbol, - "direction": direction, - "leverage": leverage, - "stop_loss": stop_loss, - "add_upper": add_upper, - "take_profit": take_profit, - "risk_percent": risk_percent, - "snapshot_available_usdt": float(snap), - "snapshot_at": opened_at, - "live_price_ref": float(live_price), - "plan_margin_capital": float(margin_plan), - "target_order_amount": float(target_amt), - "first_order_amount": float(first_amt), - "remainder_total": float(remainder_total), - "dca_legs": int(n_legs), - "per_leg_amount": float(per_ref), - "grid_prices_json": json.dumps(grid), - "leg_amounts_json": leg_json, - "grid": grid, - "leg_amounts": leg_list, - "contract_size": float(market.get("contractSize") or 1), - } - return payload, None - - def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): ensure_markets_loaded() notional = float(margin_capital) * float(leverage) @@ -2984,6 +2877,40 @@ def _gate_contracts_amount_for_tpsl(order, fallback_amount): 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: + 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() @@ -3013,6 +2940,9 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s 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"): @@ -3073,8 +3003,39 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s 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 永续:仅挂仓位类止损触发单(全平),止盈由程序监控市价平仓。""" + """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"): @@ -3095,7 +3056,7 @@ def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): } if GATE_POS_MODE == "hedge": initial["auto_size"] = "close_long" if direction == "long" else "close_short" - initial["close"] = False # 与 _gate_place_tp_sl_orders_position_price_orders 相同,Gate 要求 + initial["close"] = False sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) def _payload(trigger_price, rule): @@ -3125,32 +3086,22 @@ def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): 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 +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): try: - _gate_place_tp_sl_orders_legacy_conditional( - exchange_symbol, direction, contracts_amount, stop_loss, take_profit, + 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 Exception as legacy_err: - if pos_err is not None: - raise RuntimeError( - f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}" - ) from legacy_err - raise + 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): @@ -3381,57 +3332,19 @@ def cancel_gate_tpsl_slot(exchange_symbol, slot): exchange.cancel_order(str(oid), exchange_symbol, params) -def _resolve_tpsl_prices_for_manual( - direction, live_price, sltp_mode, data, *, fallback_sl=None, fallback_tp=None -): - return resolve_entrust_sltp_prices( - direction, - live_price, - sltp_mode, - data, - fallback_sl=fallback_sl, - fallback_tp=fallback_tp, - ) - - -def cancel_all_open_orders_for_symbol(exchange_symbol): - """策略结束时:尽量撤掉该合约下条件单与普通挂单。""" - cancel_gate_swap_trigger_orders(exchange_symbol) - if not exchange_symbol: - return - ensure_markets_loaded() - plain_params = {"type": "swap"} - try: - exchange.load_unified_status() - if exchange.options.get("unifiedAccount"): - plain_params["unifiedAccount"] = True - except Exception: - pass - try: - exchange.cancel_all_orders(exchange_symbol, plain_params) - except Exception: - pass - try: - pending = exchange.fetch_open_orders(exchange_symbol, params=plain_params) - except Exception: - return - for o in pending or []: - oid = o.get("id") - if oid is None: - continue - try: - exchange.cancel_order(str(oid), exchange_symbol, plain_params) - except Exception: - pass +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): - """移动保本/手动改价:先撤该合约 TP/SL 条件单,再按新价重挂。""" 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: @@ -3444,7 +3357,7 @@ def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): amt = 0 if amt <= 0: raise ValueError("无法确定平仓数量") - _gate_place_tp_sl_orders(ex_sym, direction, amt, float(stop_loss), float(take_profit)) + _gate_place_tp_sl_orders(ex_sym, direction, amt, sl, tp) def extract_trade_price_from_order(order): @@ -3479,38 +3392,54 @@ def is_no_position_error(err_msg): return any(k in msg for k in keywords) -def get_live_position_contracts(exchange_symbol, direction): - ensure_markets_loaded() +def _gate_fetch_position_rows(exchange_symbol): + """优先拉 USDT 本位全量持仓(与页面一致),避免单合约查询在重启后返回空列表误判空仓。""" try: - rows = exchange.fetch_positions([exchange_symbol]) + 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 - info = p.get("info", {}) or {} - side = (p.get("side") or info.get("posSide") or "").lower() - contracts = p.get("contracts") - if contracts is None: - raw_pos = info.get("pos") or info.get("size") - try: - contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0 - except Exception: - contracts = 0.0 - try: - contracts = float(contracts) - except Exception: - contracts = 0.0 + contracts = _position_row_effective_contracts(p) if contracts <= 0: continue - if GATE_POS_MODE == "hedge": + 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: @@ -3590,11 +3519,11 @@ def parse_ccxt_position_metrics(position, order_leverage=None): 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, 4) + out["initial_margin"] = round(initial, 2) if notional is not None and notional > 0: - out["notional"] = round(notional, 4) + out["notional"] = round(notional, 2) if unrealized is not None: - out["unrealized_pnl"] = round(unrealized, 6) + out["unrealized_pnl"] = round(unrealized, 2) if mark is not None and mark > 0: out["mark_price"] = round(mark, 8) if out: @@ -3609,7 +3538,7 @@ def parse_ccxt_position_metrics(position, order_leverage=None): return out or None -def get_live_position_exchange_metrics(exchange_symbol, direction): +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 @@ -3621,367 +3550,79 @@ def get_live_position_exchange_metrics(exchange_symbol, direction): except Exception: return None p = _select_live_position_row(rows, exchange_symbol, direction) - return parse_ccxt_position_metrics(p) + return parse_ccxt_position_metrics(p, order_leverage=order_leverage) -def _fetch_all_swap_positions_live(): - if not exchange_private_api_configured(): - return [] - ensure_markets_loaded() - try: - return exchange.fetch_positions(None, {"settle": "usdt"}) or [] - except Exception: - try: - return exchange.fetch_positions() or [] - except Exception: - return [] - - -def _active_monitor_position_keys(active_orders): - covered = set() - for o in active_orders or []: - sym = (o.get("symbol") or "").strip() - ex = (o.get("exchange_symbol") or normalize_exchange_symbol(sym)).strip() - direction = (o.get("direction") or "long").lower() - for s in (ex, sym, _unified_symbol_for_match(ex), _unified_symbol_for_match(sym)): - if s: - covered.add((s, direction)) - return covered - - -def _active_trend_plan_position_keys(conn): - """运行中的趋势回调计划已开仓时,持仓由计划表管理而非 order_monitors。""" - covered = set() - if conn is None: - return covered - try: - rows = conn.execute( - "SELECT symbol, exchange_symbol, direction FROM trend_pullback_plans " - "WHERE status='active' AND COALESCE(first_order_done, 0) != 0" - ).fetchall() - except Exception: - return covered - for r in rows: - sym = (r["symbol"] or "").strip() - ex = (r["exchange_symbol"] or normalize_exchange_symbol(sym)).strip() - direction = (r["direction"] or "long").lower() - for s in (ex, sym, _unified_symbol_for_match(ex), _unified_symbol_for_match(sym)): - if s: - covered.add((s, direction)) - return covered - - -def _strategy_managed_position_keys(active_orders, conn=None): - covered = _active_monitor_position_keys(active_orders) - covered |= _active_trend_plan_position_keys(conn) - return covered - - -def collect_orphan_exchange_positions(active_orders, conn=None): - """交易所有持仓但未匹配本地策略/监控(order_monitors 或运行中趋势计划)。""" - from hub_position_metrics import ( - parse_position_mark_price, - position_contracts, - position_side_from_ccxt, - ) - - rows = _fetch_all_swap_positions_live() - if not rows: - return [] - covered = _strategy_managed_position_keys(active_orders, conn) - orphans = [] - seen = set() - for p in rows: - contracts = position_contracts(p) - if abs(contracts) < 1e-12: - continue - ex_sym = (p.get("symbol") or "").strip() - if not ex_sym: - continue - direction = position_side_from_ccxt(p, contracts) - match_keys = ( - (ex_sym, direction), - (_unified_symbol_for_match(ex_sym), direction), - ) - if any(k in covered for k in match_keys): - continue - dedupe = (ex_sym, direction) - if dedupe in seen: - continue - seen.add(dedupe) - metrics = parse_ccxt_position_metrics(p) or {} - info = p.get("info") or {} - entry = _coerce_float( - p.get("entryPrice"), - p.get("entry_price"), - info.get("entry_price"), - info.get("avgEntryPrice"), - ) - mark = parse_position_mark_price(p) or metrics.get("mark_price") - sym = normalize_symbol_input(ex_sym.split(":")[0] if ":" in ex_sym else ex_sym) - orphans.append( - { - "symbol": sym, - "exchange_symbol": ex_sym, - "direction": direction, - "contracts": round(abs(float(contracts)), 6), - "entry_price": entry, - "mark_price": mark, - "unrealized_pnl": metrics.get("unrealized_pnl"), - "initial_margin": metrics.get("initial_margin"), - } - ) - return orphans - - -def _unified_symbol_for_match(symbol_str): - """统一 BTC/USDT:USDT 与 BTC/USDT 便于与 trade_records.symbol 比对。""" - x = (symbol_str or "").strip().upper() - if ":" in x: - x = x.split(":")[0] - return x - - -def exchange_position_sync_since_ms(): - """Gate fetch_positions_history 的 since(毫秒,含当日 0 点)。""" - 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 _coerce_ts_ms(val): - if val is None or val == "": +def _order_row_exchange_margin_usdt(row): + if not row: return None try: - v = float(val) + 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 - if v > 1e12: - return int(v) - if v > 1e10: - return int(v) - return int(v * 1000.0) + return x if x > 0 else None -def _normalize_gate_position_history_entry(p): - if not p or not isinstance(p, dict): +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 - info = p.get("info") or {} - sym = p.get("symbol") or "" - side = (p.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 + v = order_row["margin_capital"] + except (TypeError, KeyError, IndexError): + return None + if v is None: + return None + try: + return float(v) 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, - } + return None -def fetch_gate_positions_close_history(): - if not exchange_private_api_configured(): - return [] - ensure_markets_loaded() - since_ms = exchange_position_sync_since_ms() - try: - rows = exchange.fetch_positions_history( - None, - since=int(since_ms), - limit=int(EXCHANGE_POSITION_HISTORY_LIMIT), - params={"settle": "usdt"}, - ) - except Exception: - try: - rows = exchange.fetch_positions_history( - None, - since=int(since_ms), - limit=int(EXCHANGE_POSITION_HISTORY_LIMIT), - params={}, - ) - except Exception: - return [] - out = [] - for p in rows or []: - 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) - return out - - -def sync_trend_trade_records_from_exchange(conn): - global _LAST_POSITION_HISTORY_SYNC_AT - if not exchange_private_api_configured(): - return - now = time.time() - if now - _LAST_POSITION_HISTORY_SYNC_AT < 25.0: - return - try: - hist = fetch_gate_positions_close_history() - except Exception: - return - if not hist: - _LAST_POSITION_HISTORY_SYNC_AT = now - return - candidates = conn.execute( - """ - SELECT id, symbol, direction, closed_at, opened_at, trend_plan_id, exchange_sync_key - FROM trade_records - WHERE monitor_type = ? AND (exchange_sync_key IS NULL OR TRIM(exchange_sync_key) = '') - ORDER BY id DESC - LIMIT 120 - """, - (MONITOR_TYPE_TREND,), - ).fetchall() - if not candidates: - _LAST_POSITION_HISTORY_SYNC_AT = now - return - used = set() - for tr in candidates: - tid = None - if "trend_plan_id" in tr.keys() and tr["trend_plan_id"]: +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: - tid = int(tr["trend_plan_id"]) + v = float(pm["initial_margin"]) except (TypeError, ValueError): - tid = None - plan_open_ms = None - if tid: - prow = conn.execute("SELECT opened_at FROM trend_pullback_plans WHERE id=?", (tid,)).fetchone() - if prow and prow["opened_at"]: - plan_open_ms = opened_at_str_to_ms(prow["opened_at"]) - close_ms_trade = opened_at_str_to_ms(tr["closed_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 plan_open_ms is not None: - if cm < plan_open_ms - 15 * 60 * 1000: - continue - if cm > plan_open_ms + 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 > 25 * 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) - _LAST_POSITION_HISTORY_SYNC_AT = now - conn.commit() - - -def trend_plan_history_status_label(status): - s = (status or "").strip().lower() - return { - "stopped_tp": "止盈结束", - "stopped_sl": "止损结束", - "stopped_manual": "手动结束", - }.get(s, status or "-") - - -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 calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): - """趋势回调手动保本:默认开仓均价 + offset_pct%(多上移、空下移)。""" - try: - e = float(entry_price) - pct = float( - offset_pct - if offset_pct is not None - else TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT - ) - 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) + 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 - try: - dt = datetime.strptime(str(opened_at_str).strip()[:19], "%Y-%m-%d %H:%M:%S") - except ValueError: + dt = parse_dt_for_trading_day(opened_at_str) + if dt is None: return None try: aware = dt.replace(tzinfo=APP_TZ) @@ -4300,14 +3941,14 @@ def reconcile_hub_external_close(conn, symbol, direction): 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), + "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 = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + exchange_symbol = resolve_monitor_exchange_symbol(r) live_contracts = get_live_position_contracts(exchange_symbol, r["direction"]) if live_contracts is None: continue @@ -4316,6 +3957,8 @@ def reconcile_hub_external_close(conn, symbol, direction): 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) @@ -4331,12 +3974,13 @@ def reconcile_hub_external_close(conn, symbol, direction): 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=r["margin_capital"], + margin_capital=margin_capital_for_trade_record(r), leverage=r["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -4350,15 +3994,21 @@ def reconcile_hub_external_close(conn, symbol, direction): 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_trend_trade_records_from_exchange(conn) + 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: @@ -4382,19 +4032,36 @@ def reconcile_external_closes(conn, days=None): 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), + "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 = r["exchange_symbol"] or normalize_exchange_symbol(r["symbol"]) + 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) @@ -4408,12 +4075,13 @@ def reconcile_external_closes(conn, days=None): 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=r["margin_capital"], + margin_capital=margin_capital_for_trade_record(r), leverage=r["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -4427,6 +4095,7 @@ def reconcile_external_closes(conn, days=None): 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( @@ -4470,24 +4139,6 @@ def get_price(symbol): except: return None - -def get_symbol_mark_price(symbol): - """趋势补仓/标记价展示:优先 ticker.mark,与页面浮盈亏口径一致。""" - 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 - # 获取5分钟K线收盘价 def get_5m_close(symbol): try: @@ -4611,31 +4262,34 @@ def _key_hard_checks(symbol, direction, upper, lower, monitor_type): out["reason"] = "5m K线数量不足" return out closed = bars[:-1] if len(bars) >= 3 else bars - if len(closed) < 23: - out["reason"] = "闭合K线不足" + min_closed = KEY_VOLUME_MA_BARS + 3 + if len(closed) < min_closed: + out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" return out - breakout = closed[-2] - confirm = closed[-1] - prev20 = closed[-22:-2] - avg20 = sum(float(x[5]) for x in prev20) / max(len(prev20), 1) + 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 * 1.3 if avg20 > 0 else False - open_b = float(breakout[1]) + 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]) - amp_pct = abs(close_b - open_b) / open_b * 100 if open_b > 0 else 0 - amp_ok = (amp_pct > 0.03) and (amp_pct < 0.5) cfm_close = float(confirm[4]) - # 区间极值点严格以前端录入 upper/lower 为准:做多看上沿,做空看下沿 edge = float(upper) if direction == "long" else float(lower) breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) - confirm_ok_raw = (cfm_close > edge) if direction == "long" else (cfm_close < edge) - # 口径收紧:未发生有效突破时,不标记幅度/二确通过,避免出现“还没到位却显示Y” + 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 <= 30) + 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 @@ -4687,168 +4341,864 @@ def calc_price_diff_pct(current_price, target_price): return None, None -def can_notify_key_monitor(row, now_dt): - max_notify = int(row["max_notify"] or KEY_ALERT_MAX_TIMES) - if int(row["notification_count"] or 0) >= max_notify: - return False - last_at = row["last_notified_at"] - if not last_at: - return True - try: - last_dt = datetime.strptime(last_at, "%Y-%m-%d %H:%M:%S") - except Exception: - return True - interval_min = int(row["notify_interval_min"] or KEY_ALERT_INTERVAL_MINUTES) - return (now_dt - last_dt).total_seconds() >= interval_min * 60 +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 breakout_too_far(p, edge_price, limit_pct): - try: - if edge_price is None or float(edge_price) <= 0: - return False - diff_pct = abs(float(p) - float(edge_price)) / float(edge_price) * 100 - return diff_pct > float(limit_pct) - except Exception: - return False - - -def _trend_build_grid_prices(direction, sl, upper, n_legs): - """在 (止损, 补仓区间远侧边界 add_upper) 开区间内生成 n_legs 个补仓触发价(不含端点)。""" - sl, upper = float(sl), float(upper) - out = [] - if n_legs <= 0: - return out - if direction == "long": - if upper <= sl: - return out - span = upper - sl - for i in range(1, n_legs + 1): - t = i / float(n_legs + 1) - out.append(sl + t * span) - out.sort(reverse=True) - else: - if sl <= upper: - return out - span = sl - upper - for i in range(1, n_legs + 1): - t = i / float(n_legs + 1) - out.append(upper + t * span) - out.sort() - return [round(p, 10) for p in out] - - -def _safe_amount_to_precision(exchange_symbol, raw_amount): - """amount_to_precision 在低于最小步长时会抛 InvalidOrder;返回 None 表示不可用。""" - try: - if raw_amount is None: - return None - x = float(raw_amount) - if x <= 0: - return None - return float(exchange.amount_to_precision(exchange_symbol, x)) - except Exception: +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 _trend_pick_dca_legs_and_per_leg(exchange_symbol, remainder_total, want_legs): - """按交易所最小张数约束,自动减少档位数。""" - ensure_markets_loaded() - market = exchange.market(exchange_symbol) - min_amt = (market.get("limits", {}).get("amount", {}) or {}).get("min") - min_amt = float(min_amt) if min_amt is not None else 0.0 - legs = max(1, int(want_legs)) - rem = float(remainder_total) - while legs >= 1: - per = rem / legs - per_p = _safe_amount_to_precision(exchange_symbol, per) - if per_p is None or per_p <= 0: - legs -= 1 - continue - if min_amt and per_p + 1e-12 < min_amt: - legs -= 1 - continue - return legs, per_p - one = _safe_amount_to_precision(exchange_symbol, rem) - if one is None or one <= 0: - return 0, 0.0 - return 1, one +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 _trend_build_leg_amounts_json(exchange_symbol, remainder_total, want_legs): - """将剩余计划张数拆成若干补仓市价单张数(JSON 列表),并返回有效档位数。""" - rem = _safe_amount_to_precision(exchange_symbol, float(remainder_total)) - if rem is None or rem <= 0: - return 0, "[]", 0.0 - n, _ = _trend_pick_dca_legs_and_per_leg(exchange_symbol, rem, want_legs) - if n <= 0: - return 0, "[]", 0.0 - if n <= 1: - one = _safe_amount_to_precision(exchange_symbol, rem) - if one is None or one <= 0: - return 0, "[]", 0.0 - return 1, json.dumps([one]), one - unit = _safe_amount_to_precision(exchange_symbol, rem / n) - if unit is None or unit <= 0: - one = _safe_amount_to_precision(exchange_symbol, rem) - if one is None or one <= 0: - return 0, "[]", 0.0 - return 1, json.dumps([one]), one - parts = [] - acc = 0.0 - for _ in range(n - 1): - parts.append(unit) - acc += unit - last = _safe_amount_to_precision(exchange_symbol, max(0.0, rem - acc)) - if last is None or last <= 0: - one = _safe_amount_to_precision(exchange_symbol, rem) - if one is None or one <= 0: - return 0, "[]", 0.0 - return 1, json.dumps([one]), one - parts.append(last) - return n, json.dumps(parts), unit - - -def _trend_market_add_contracts(exchange_symbol, direction, contracts, leverage): - exchange.set_leverage(int(leverage), exchange_symbol) - side = "buy" if direction == "long" else "sell" - params = build_gate_order_params(direction, reduce_only=False) - return exchange.create_order(exchange_symbol, "market", side, float(contracts), None, params) - - -def _trend_refresh_stop_only(exchange_symbol, direction, stop_loss): - cancel_gate_swap_trigger_orders(exchange_symbol) - _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss) - - -def _trend_weighted_avg(old_avg, old_amt, fill_px, add_amt): - try: - oa, aa = float(old_amt), float(add_amt) - if oa <= 0: - return float(fill_px) - return (float(old_avg) * oa + float(fill_px) * aa) / (oa + aa) - except Exception: - return float(fill_px or 0) - - -def check_trend_pullback_plans(): - """轮询趋势回调:共用 strategy_trend_register(补仓触达 + 空仓连续确认)。""" - from strategy_trend_register import build_trend_config, check_trend_pullback_plans as _check - - cfg = app.extensions.get("strategy_trend_cfg") or build_trend_config( - sys.modules[__name__] +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, ) - _check(cfg) + 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, +): + """ + 与手动「实盘下单」对齐的市价开仓与 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 + + 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) " + "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), + ), + ) + 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) + 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) " + "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), + ), + ) + 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 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, +): + 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 + 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) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + ) + return True, None + + +def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0): + 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 + 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) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, mt, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, + ), + ) + 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, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + 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() - now_dt = app_now() - if not can_notify_key_monitor(r, now_dt): + if direction == KEY_DIRECTION_WATCH: continue try: checks = _key_hard_checks(sym, direction, up, low, typ) @@ -4856,72 +5206,173 @@ def check_key_monitors(): 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)主趋势逆势,建议降低仓位并严格执行止损。" - box_h = abs(float(up) - float(low)) if up is not None and low is not None else 0.0 - c_close = float(checks.get("confirm_close") or 0) - b_high = float(checks.get("breakout_high") or 0) - b_low = float(checks.get("breakout_low") or 0) + key_price = float(low) if direction == "long" else float(up) - if direction == "long": - tp1 = c_close + box_h - tp2 = c_close + box_h * 1.5 - sl1 = b_low * (1 - 0.002) if b_low > 0 else None - sl2 = key_price * (1 - 0.002) if key_price > 0 else None - else: - tp1 = c_close - box_h - tp2 = c_close - box_h * 1.5 - sl1 = b_high * (1 + 0.002) if b_high > 0 else None - sl2 = key_price * (1 + 0.002) if key_price > 0 else None - hard_lines = [ - 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']})", - f"突破K幅度:{'通过' if checks['amp_ok'] else '不通过'}({round(checks['amp_pct'], 4)}%,要求0.03%~0.5%)", - f"第二根确认:{'通过' if checks['confirm_ok'] else '不通过'}(确认收盘 {checks['confirm_close']},关键位 {checks['edge_price']})", - f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前30)", - ] - op_lines = [ - f"方案A:止盈=箱体1.0倍({round(tp1, 8) if tp1 else '-' }),止损=突破K极值外0.2%({round(sl1, 8) if sl1 else '-' })", - f"方案B:止盈=箱体1.5倍({round(tp2, 8) if tp2 else '-' }),止损=箱体关键位外0.2%({round(sl2, 8) if sl2 else '-' })", - ] + 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() - msg = build_wechat_key_monitor_message( - symbol=sym, - direction=direction, - monitor_type=typ, - trigger_time=trigger_time, - key_price=key_price, - confirm_close=checks["confirm_close"], - hard_lines=hard_lines, - btc8h_status=btc8h_status, - coin4h_status=coin4h_status, - swing4h_pct=checks.get("swing4h_pct") or 0.0, - op_lines=op_lines, - risk_tip=risk_tip, + + 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) + 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, ) - send_wechat_msg(msg) - new_count = int(r["notification_count"] or 0) + 1 - max_n = int(r["max_notify"] or KEY_ALERT_MAX_TIMES) - conn.execute( - "UPDATE key_monitors SET notification_count = ?, last_notified_at = ? WHERE id = ?", - (new_count, app_now_str(), r["id"]), + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" ) - if new_count >= max_n: - insert_key_monitor_history(conn, r, new_count, msg, "alerts_complete") - conn.execute("DELETE FROM key_monitors WHERE id = ?", (r["id"],)) - send_wechat_msg( - "\n".join( - [ - f"# 🧾 {r['symbol']} 关键位监控结束", - "", - f"- 原因:已满 {max_n} 次提醒", - "- 状态:已自动结束并记入历史", - ] + 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() @@ -4933,6 +5384,21 @@ def check_order_monitors(): 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 @@ -5114,12 +5580,13 @@ def check_order_monitors(): 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, + margin_capital=margin_capital_for_trade_record(trade_basis_row), leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -5176,12 +5643,13 @@ def check_order_monitors(): 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, + margin_capital=margin_capital_for_trade_record(trade_basis_row), leverage=leverage, pnl_amount=record_pnl, hold_seconds=record_hold, @@ -5245,12 +5713,13 @@ def check_order_monitors(): 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, + margin_capital=margin_capital_for_trade_record(trade_basis_row), leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -5264,6 +5733,7 @@ def check_order_monitors(): 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() @@ -5312,12 +5782,13 @@ def force_close_before_reset(): 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, + margin_capital=margin_capital_for_trade_record(r), leverage=leverage, pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -5363,34 +5834,24 @@ def background_task(): conn.commit() conn.close() force_close_before_reset() - check_trend_pullback_plans() + check_fib_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() - except Exception as e: - print(f"[background_task] error: {e}", flush=True) + 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) -_BG_MONITORS_LOCK = threading.Lock() -_BG_MONITORS_STARTED = False - - -def _ensure_background_monitors_started(): - global _BG_MONITORS_STARTED - with _BG_MONITORS_LOCK: - if _BG_MONITORS_STARTED: - return - _BG_MONITORS_STARTED = True - threading.Thread( - target=background_task, daemon=True, name="gate-bot-monitors" - ).start() - print("[startup] background monitor thread started", flush=True) - - # ====================== 登录路由 ====================== @app.route("/login", methods=["GET", "POST"]) def login(): @@ -5463,6 +5924,245 @@ def api_sync_positions(): 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"): now = app_now() @@ -5476,27 +6176,28 @@ def render_main_page(page="trade"): # 资金账户:仅展示交易所读取结果(含 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(get_recommended_capital(current_capital), 2) + recommended_capital = round(float(get_recommended_capital(current_capital)), 2) key_list = conn.execute("SELECT * FROM key_monitors").fetchall() - key_history = conn.execute("SELECT * FROM key_monitor_history ORDER BY id DESC LIMIT 80").fetchall() + 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() stats_bundle = compute_stats_bundle(conn, trading_day, now) raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() order_list = [] for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) - if page in ("trade", "records"): + exchange_pnl_sync = {} + if exchange_private_api_configured(): try: - sync_trend_trade_records_from_exchange(conn) - except Exception: - pass - if page == "records": - raw_records = conn.execute( - f"SELECT * 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 DESC LIMIT 2000", - (start_bj, end_bj), - ).fetchall() - else: - raw_records = conn.execute("SELECT * FROM trade_records ORDER BY id DESC LIMIT 500").fetchall() + 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") + 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 "") == "错过") @@ -5508,25 +6209,7 @@ def render_main_page(page="trade"): and ("持仓占用" in str(r.get("effective_miss_reason") or "")) ) rate = round(win/total*100,2) if total else 0 - active_count = get_active_position_count(conn) - trend_active = conn.execute( - "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" - ).fetchone()[0] - preview_snapshots = [] - if page == "records": - try: - snap_ts = sql_list_time_field("preview_created_at", "snapshot_at") - snap_rows = conn.execute( - f"SELECT * FROM trend_pullback_preview_snapshots WHERE {snap_ts} >= ? " - f"AND {snap_ts} <= ? ORDER BY id DESC LIMIT 500", - (start_bj, end_bj), - ).fetchall() - for sr in snap_rows: - sd = row_to_dict(sr) - sd["outcome_label"] = preview_snapshot_outcome_label(sd.get("outcome")) - preview_snapshots.append(sd) - except Exception as e: - print(f"[records] trend_pullback_preview_snapshots: {e}") + active_count = len(order_list) opens_today = count_opens_for_trading_day(conn, trading_day) can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), @@ -5534,7 +6217,13 @@ def render_main_page(page="trade"): max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=int(trend_active or 0) != 0, + ) + key_gate_rule_text = ( + f"【箱体/收敛】{KLINE_TIMEFRAME} 两根闭合K|突破越过关键位 > {KEY_BREAKOUT_AMP_MIN_PCT}%|" + f"确认K收于箱外|量能>前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"RR>{KEY_AUTO_MIN_PLANNED_RR}|日成交前{KEY_DAILY_VOLUME_RANK_MAX}|" + f"【假突破·BTC/ETH】做空填高点/做多填低点,外侧 0.1% 挂限价,止损 0.5%,RR 1.5,有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|" + f"【阻力/支撑】填上/下沿,5m 收盘突破任一侧即提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不选方向、不自动开仓" ) strategy_extra = {} if page in ("strategy", "strategy_trend", "strategy_roll", "strategy_records"): @@ -5544,22 +6233,9 @@ def render_main_page(page="trade"): conn, page, default_risk_percent=float(RISK_PERCENT), - count_active_trends=lambda c, ta=trend_active: int(ta or 0), request_obj=request, trend_cfg=app.extensions.get("strategy_trend_cfg"), ) - orphan_positions: list = [] - if page == "trade": - try: - orphan_positions = collect_orphan_exchange_positions(order_list, conn) - except Exception as exc: - print(f"[render_main_page] orphan positions: {exc}") - key_gate_rule_text = ( - f"【箱体/收敛】{KLINE_TIMEFRAME} 两根闭合K|突破越过关键位 0.03%~0.5%|" - f"确认K收于箱外|量能>前20均量×1.3|日成交前30|" - f"偏离关键位 > {KEY_BREAKOUT_LIMIT_PCT}% 不提醒|" - f"【阻力/支撑】收盘突破任一侧即提醒 {KEY_ALERT_MAX_TIMES} 次(间隔 {KEY_ALERT_INTERVAL_MINUTES} 分),不自动开仓" - ) conn.close() return render_template( "index.html", @@ -5568,7 +6244,6 @@ def render_main_page(page="trade"): key_history=key_history, stats_bundle=stats_bundle, order=order_list, - orphan_positions=orphan_positions, record=records, total=total, miss_count=miss_count, @@ -5587,19 +6262,17 @@ def render_main_page(page="trade"): auto_transfer_from=AUTO_TRANSFER_FROM, auto_transfer_to=AUTO_TRANSFER_TO, auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, - transfer_amount_fmt=format_money_usdt(AUTO_TRANSFER_AMOUNT), + transfer_amount_fmt=format_usdt(AUTO_TRANSFER_AMOUNT), full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, price_refresh_seconds=PRICE_REFRESH_SECONDS, active_count=active_count, - max_active_positions=MAX_ACTIVE_POSITIONS, - manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, can_trade=can_trade, opens_today=opens_today, daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, - live_trading_enabled=LIVE_TRADING_ENABLED, - preview_snapshots=preview_snapshots, - exchange_sync_from_label=(EXCHANGE_POSITION_SYNC_FROM_BJ or "最近90天"), + 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, @@ -5607,9 +6280,6 @@ def render_main_page(page="trade"): "utc_last7d": PRESET_UTC_LAST7D, "custom": PRESET_CUSTOM, }, - 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, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, position_sizing_mode=POSITION_SIZING_MODE, @@ -5621,8 +6291,9 @@ def render_main_page(page="trade"): breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, price_fmt=format_price_for_symbol, - amt_fmt=format_amount_for_symbol, - money_fmt=format_money_usdt, + 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, @@ -5631,11 +6302,29 @@ def render_main_page(page="trade"): journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, exchange_display=EXCHANGE_DISPLAY_NAME, + 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_gate_rule_text=key_gate_rule_text, + kline_timeframe=KLINE_TIMEFRAME, + exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, ) +@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(): @@ -5666,26 +6355,6 @@ def stats_page(): return render_main_page("stats") -@app.route("/plan_history") -@login_required -def plan_history_page(): - qs = list_window_redirect_query(session) - return redirect(f"/records?{qs}" if qs else "/records") - - -@app.route("/api/preview_snapshot/") -@login_required -def api_preview_snapshot(sid): - conn = get_db() - row = conn.execute("SELECT * FROM trend_pullback_preview_snapshots WHERE id=?", (sid,)).fetchone() - conn.close() - if not row: - return jsonify({"ok": False, "msg": "not_found"}), 404 - d = row_to_dict(row) - d["outcome_label"] = preview_snapshot_outcome_label(d.get("outcome")) - return jsonify({"ok": True, "snapshot": d}) - - @app.route("/api/account_snapshot") @login_required def api_account_snapshot(): @@ -5697,11 +6366,8 @@ def api_account_snapshot(): 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(get_recommended_capital(current_capital), 2) + recommended_capital = round(float(get_recommended_capital(current_capital)), 2) active_count = get_active_position_count(conn) - trend_active = conn.execute( - "SELECT COUNT(*) FROM trend_pullback_plans WHERE status='active'" - ).fetchone()[0] opens_today = count_opens_for_trading_day(conn, trading_day) conn.close() can_trade = can_trade_new_open( @@ -5710,7 +6376,6 @@ def api_account_snapshot(): max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, - extra_blocks=int(trend_active or 0) != 0, ) available_trading_usdt = get_available_trading_usdt() return jsonify({ @@ -5733,11 +6398,18 @@ def api_account_snapshot(): @login_required def api_price_snapshot(): conn = get_db() - key_rows = conn.execute("SELECT id,symbol,monitor_type,direction,upper,lower FROM key_monitors").fetchall() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_limit_order_id 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 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"]) @@ -5764,18 +6436,46 @@ def api_price_snapshot(): key_prices = [] for r in key_rows: - price = prices.get(r["symbol"]) + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + if is_fib: + 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 - try: - gate = _key_hard_checks(r["symbol"], (r["direction"] or "long").lower(), r["upper"], r["lower"], r["monitor_type"]) - except Exception: - gate = None gate_summary = "-" gate_metrics = "" + fib_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 (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 = "-" + else: + try: + gate = _key_hard_checks( + r["symbol"], + (r["direction"] or "long").lower(), + 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 = ( @@ -5799,16 +6499,22 @@ def api_price_snapshot(): ) 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": round(price, 6), + "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": bool(gate and gate.get("ok")), + "gate_ok": fib_gate_ok if is_fib else bool(gate and gate.get("ok")), "gate_metrics": gate_metrics, }) @@ -5830,10 +6536,9 @@ def api_price_snapshot(): payload = { "id": r["id"], "symbol": r["symbol"], - "price": round(price, 6), - "float_pnl": round(pnl, 6), + "float_pnl": round(pnl, 2), "float_pct": pnl_pct, - "plan_margin": round(margin, 4) if margin else None, + "plan_margin": round(margin, 2) if margin else None, "exchange_initial_margin": None, "exchange_notional": None, "exchange_mark_price": None, @@ -5847,19 +6552,24 @@ def api_price_snapshot(): 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"]), 6) + 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 ) - apply_order_live_price_display( - payload, - r["symbol"], - price, - payload.get("exchange_mark_price"), - format_price_for_symbol, - ) + 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( @@ -5973,32 +6683,20 @@ def api_order_place_tpsl(order_id): 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, - fallback_sl=row["stop_loss"], - fallback_tp=row["take_profit"], - ) + 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 - entry_price = float(row["trigger_price"] or live_price or 0) - rr_ok, rr_err = tpsl_update_passes_rr_gate( - direction, - entry_price, - stop_loss, - take_profit, - MANUAL_MIN_PLANNED_RR, - calc_rr_ratio, - ) - if not rr_ok: + 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() - return jsonify({"ok": False, "msg": rr_err}), 400 - planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) - if stop_is_profit_protecting(direction, entry_price, stop_loss): - planned_rr = None + 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: @@ -6041,81 +6739,12 @@ def api_symbol_liquidity_rank(): "symbol": symbol, "rank": int(rank), "total": int(total), - "in_top30": bool(rank <= 30), + "in_top30": bool(rank <= KEY_DAILY_VOLUME_RANK_MAX), + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, } ) -@app.route("/api/order/relink_orphan", methods=["POST"]) -@login_required -def api_order_relink_orphan(): - """交易所有仓但本地无 active 监控时,恢复最近一条已停止的同向监控记录。""" - data = request.get_json(silent=True) or {} - symbol = normalize_symbol_input(data.get("symbol")) - direction = (data.get("direction") or "long").strip().lower() - if not symbol: - return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 - if direction not in ("long", "short"): - direction = "long" - ok, reason = ensure_exchange_live_ready() - if not ok: - return jsonify({"ok": False, "msg": reason}), 400 - exchange_symbol = normalize_exchange_symbol(symbol) - contracts = get_live_position_contracts(exchange_symbol, direction) - if contracts is None or float(contracts) <= 0: - return jsonify({"ok": False, "msg": "交易所当前无该方向持仓,无法恢复监控"}), 400 - conn = get_db() - active = conn.execute( - "SELECT id FROM order_monitors WHERE status='active' AND symbol=? AND direction=? LIMIT 1", - (symbol, direction), - ).fetchone() - if active: - conn.close() - return jsonify({"ok": True, "msg": "已有运行中的监控", "order_id": int(active["id"])}) - trend_plan = conn.execute( - "SELECT id FROM trend_pullback_plans WHERE status='active' AND symbol=? AND direction=? " - "AND COALESCE(first_order_done, 0) != 0 LIMIT 1", - (symbol, direction), - ).fetchone() - if trend_plan: - conn.close() - return jsonify( - { - "ok": False, - "msg": f"该持仓由趋势回调计划 #{int(trend_plan['id'])} 管理,请在策略页操作", - } - ), 400 - row = conn.execute( - """ - SELECT * FROM order_monitors - WHERE symbol=? AND direction=? AND status IN ('stopped', 'error') - ORDER BY id DESC LIMIT 1 - """, - (symbol, direction), - ).fetchone() - if not row: - conn.close() - return jsonify( - { - "ok": False, - "msg": "未找到可恢复的历史监控记录,请在中控核对持仓或联系管理员", - } - ), 404 - opened_at = get_opened_at_value(row) - purged = conn.execute( - "DELETE FROM trade_records WHERE symbol=? AND direction=? AND opened_at=? AND result LIKE ?", - (symbol, direction, opened_at, "%外部平仓%"), - ).rowcount - conn.execute("UPDATE order_monitors SET status='active' WHERE id=?", (int(row["id"]),)) - conn.commit() - oid = int(row["id"]) - conn.close() - msg = "已恢复本地监控" - if purged: - msg += f"(已清除 {purged} 条误记的外部平仓记录)" - return jsonify({"ok": True, "msg": msg, "order_id": oid, "purged_trade_records": purged}) - - @app.route("/api/order_defaults") @login_required def api_order_defaults(): @@ -6128,13 +6757,15 @@ def api_order_defaults(): 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, 4) if available is not None else None + "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, }) @@ -6147,7 +6778,7 @@ def order_focus(): 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, 4) if trading_capital_live is not None else round(local_current_capital, 4) + 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] @@ -6186,7 +6817,7 @@ def api_order_kline(): 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, 4) if trading_capital_live is not None else round(local_current_capital, 4) + 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: @@ -6394,33 +7025,234 @@ def api_key_kline(): @app.route("/add_key", methods=["POST"]) @login_required def add_key(): - d = request.form - symbol = normalize_symbol_input(d.get("symbol")) - if not symbol: - flash("symbol 不能为空") - return redirect("/") - mt = (d.get("type") or "").strip() - direction_pre = (d.get("direction") or "long").strip().lower() - dup_msg = check_duplicate_submit( - session, submit_scope_add_key(symbol, mt, direction_pre) - ) - if dup_msg: - flash(dup_msg) - return redirect("/") - rank, total = _daily_volume_rank(symbol) - if rank is None: - flash("日成交量排名读取失败,请稍后重试") - return redirect("/") - if rank > 30: - flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前30,已拒绝添加关键位") - return redirect("/") - conn = get_db() - conn.execute("INSERT INTO key_monitors (symbol,monitor_type,direction,upper,lower) VALUES (?,?,?,?,?)", - (symbol, d["type"], d.get("direction", "long"), d["upper"], d["lower"])) - conn.commit() - conn.close() - flash(f"添加成功({symbol} 日成交量排名 {rank}/{total})") - return redirect("/key_monitor") + 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 + 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,) + ) + 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")) + 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, + ) + 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 '关'}" + ) + 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, + ) + 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 '关'}" + ) + 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) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) " + "VALUES (?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag), + ) + 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 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 @@ -6448,14 +7280,19 @@ def add_order(): 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=tp_raw or 0, - stop_loss=sl_raw or 0, - take_profit=tgt_raw or 0, + 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(), @@ -6464,12 +7301,12 @@ def add_order(): conn.commit() conn.close() flash(f"风控拒绝下单:{reason}") - return redirect("/") + return redirect("/trade") ok_live, reason_live = ensure_exchange_live_ready() if not ok_live: conn.close() flash(f"风控拒绝下单:{reason_live}") - return redirect("/") + return redirect("/trade") exchange_symbol = normalize_exchange_symbol(symbol) trading_day = get_trading_day(now) opens_today_before = conn.execute( @@ -6488,6 +7325,13 @@ def add_order(): 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( @@ -6500,13 +7344,19 @@ def add_order(): if stop_loss <= 0 or take_profit <= 0: conn.close() flash("价格参数必须大于0") - return redirect("/") + 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("/") + 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() @@ -6560,7 +7410,7 @@ def add_order(): 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,当前最多建议 {max_margin}U") + 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: @@ -6576,6 +7426,10 @@ def add_order(): 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) @@ -6589,21 +7443,25 @@ def add_order(): POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=2 ) if direction == "short": - breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) else: - breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + 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 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) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + "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) 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 + notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day, + ORDER_MONITOR_TYPE_MANUAL, ) ) 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,), @@ -6684,9 +7542,9 @@ def add_order(): _, trading_capital_after = get_exchange_capitals(force=True) account_base_display = ( - round(float(trading_capital_after), 4) + round(float(trading_capital_after), 2) if trading_capital_after is not None - else round(float(capital_base), 4) + else round(float(capital_base), 2) ) account_name = (os.getenv("GATE_ACCOUNT_LABEL") or "gate实盘账户").strip() dir_text = "多头(long)" if direction == "long" else "空头(short)" @@ -6697,12 +7555,12 @@ def add_order(): ) rr_show = planned_rr if planned_rr is not None else "-" try: - rr_show_fmt = round(float(planned_rr), 4) if planned_rr is not None else None + 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_price_for_symbol(symbol, stop_loss) + 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 趋势" @@ -6716,9 +7574,9 @@ def add_order(): "📊 仓位配置详情", f"账户基数:{account_base_display} USDT", f"合约杠杆:{leverage} 倍", - f"名义仓位:{notional_value} USDT", + f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", f"仓位占比:{position_ratio}%", - f"合约张数:{amount} 张", + f"合约张数:{format_wechat_scalar_2dp(amount)} 张", f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", "🎯 价位 & 盈亏比", f"开仓成交价:{ep_wx}", @@ -6737,8 +7595,8 @@ def add_order(): send_wechat_msg("\n".join(wx_lines)) flash_lines = [ - f"机器人开单成功:风格 {trade_style};风险 {risk_display};基数 {margin_capital}U,杠杆 {leverage}x,名义仓位 {notional_value}U,仓位占比 {position_ratio}%,合约张数 {amount}(折算标的 {base_amount})," - f"计划RR {planned_rr if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + 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 ), @@ -6756,7 +7614,7 @@ def add_order(): opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, hard_limit=DAILY_OPEN_HARD_LIMIT, - detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U。", + detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{round(float(margin_capital), 2)}U。", ) ) if advice: @@ -6764,374 +7622,6 @@ def add_order(): flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") return redirect("/") - -@app.route("/preview_trend_pullback", methods=["POST"]) -@login_required -def preview_trend_pullback(): - conn = get_db() - _trend_cleanup_stale_previews(conn) - okp, reasonp = precheck_trend_pullback_start(conn) - if not okp: - conn.close() - flash(reasonp) - return redirect(url_for("strategy_trading_page")) - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - conn.close() - flash(reason_live) - return redirect(url_for("strategy_trading_page")) - payload, err = parse_and_compute_trend_pullback_plan(request.form) - if err: - conn.close() - flash(err) - return redirect(url_for("strategy_trading_page")) - pid = str(uuid.uuid4()) - exp_ms = int(time.time() * 1000) + int(TREND_PULLBACK_PREVIEW_TTL_SECONDS) * 1000 - created = app_now_str() - conn.execute( - """INSERT INTO trend_pullback_previews ( - id,symbol,exchange_symbol,direction,leverage,stop_loss,add_upper,take_profit,risk_percent, - snapshot_available_usdt,snapshot_at,live_price_ref,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, - dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,expires_at_ms,created_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - pid, - payload["symbol"], - payload["exchange_symbol"], - payload["direction"], - payload["leverage"], - payload["stop_loss"], - payload["add_upper"], - payload["take_profit"], - payload["risk_percent"], - payload["snapshot_available_usdt"], - payload["snapshot_at"], - payload["live_price_ref"], - payload["plan_margin_capital"], - payload["target_order_amount"], - payload["first_order_amount"], - payload["remainder_total"], - payload["dca_legs"], - payload["per_leg_amount"], - payload["grid_prices_json"], - payload["leg_amounts_json"], - exp_ms, - created, - ), - ) - insert_trend_preview_snapshot(conn, pid, created, exp_ms, payload) - conn.commit() - conn.close() - flash(f"预览已生成,有效期 {TREND_PULLBACK_PREVIEW_TTL_SECONDS} 秒,请核对后点击「确认执行」。") - return redirect(url_for("strategy_trend_page", preview_id=pid)) - - -@app.route("/execute_trend_pullback", methods=["POST"]) -@login_required -def execute_trend_pullback(): - pid = (request.form.get("preview_id") or "").strip() - if not pid: - flash("缺少预览 ID") - return redirect(url_for("strategy_trading_page")) - conn = get_db() - _trend_cleanup_stale_previews(conn) - pr = conn.execute("SELECT * FROM trend_pullback_previews WHERE id=?", (pid,)).fetchone() - now_ms = int(time.time() * 1000) - if not pr or int(pr["expires_at_ms"] or 0) < now_ms: - conn.close() - flash("预览已过期或不存在,请重新生成预览") - return redirect(url_for("strategy_trading_page")) - okp, reasonp = precheck_trend_pullback_start(conn) - if not okp: - conn.close() - flash(reasonp) - return redirect(url_for("strategy_trend_page", preview_id=pid)) - ok_live, reason_live = ensure_exchange_live_ready() - if not ok_live: - conn.close() - flash(reason_live) - return redirect(url_for("strategy_trend_page", preview_id=pid)) - snap_prev = float(pr["snapshot_available_usdt"] or 0) - snap_now = get_available_trading_usdt() - if snap_now is None or snap_now <= 0: - conn.close() - flash("无法读取当前合约可用余额,请稍后重试") - return redirect(url_for("strategy_trend_page", preview_id=pid)) - drift_pct = abs(float(snap_now) - snap_prev) / max(snap_prev, 1e-9) * 100.0 - if drift_pct > float(TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT): - conn.close() - flash( - f"当前可用余额与预览快照偏差 {drift_pct:.2f}%,超过允许 {TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT}% ,请重新生成预览" - ) - return redirect(url_for("strategy_trading_page")) - symbol = pr["symbol"] - exchange_symbol = pr["exchange_symbol"] - direction = pr["direction"] or "long" - leverage = int(pr["leverage"] or 1) - stop_loss = float(pr["stop_loss"]) - add_upper = float(pr["add_upper"]) - take_profit = float(pr["take_profit"]) - risk_percent = float(pr["risk_percent"] or 5) - snap = float(snap_now) - margin_plan = float(pr["plan_margin_capital"] or 0) - target_amt = float(pr["target_order_amount"] or 0) - first_amt = float(pr["first_order_amount"] or 0) - remainder_total = float(pr["remainder_total"] or 0) - n_legs = int(pr["dca_legs"] or 0) - per_ref = float(pr["per_leg_amount"] or 0) - grid_json = pr["grid_prices_json"] or "[]" - leg_json = pr["leg_amounts_json"] or "[]" - live_price = get_price(symbol) - if live_price is None: - conn.close() - flash("获取实时价格失败") - return redirect(url_for("strategy_trend_page", preview_id=pid)) - try: - o1 = place_exchange_order(exchange_symbol, direction, first_amt, leverage, stop_loss=None, take_profit=None) - fill1 = resolve_order_entry_price(o1, exchange_symbol, live_price) - _trend_refresh_stop_only(exchange_symbol, direction, stop_loss) - except Exception as e: - conn.close() - flash(friendly_exchange_error(e, available_usdt=snap_now)) - return redirect(url_for("strategy_trend_page", preview_id=pid)) - now = app_now() - trading_day = get_trading_day(now) - opened_at = app_now_str() - opened_ms = _to_ms_with_fallback(None, opened_at) - from strategy_trend_lib import append_leg_fill_price_json - - fills_json = append_leg_fill_price_json(None, fill1) - cur = conn.execute( - """INSERT INTO trend_pullback_plans ( - status,symbol,exchange_symbol,direction,leverage,stop_loss,initial_stop_loss,add_upper,take_profit,risk_percent, - snapshot_available_usdt,snapshot_at,plan_margin_capital,target_order_amount,first_order_amount,remainder_total, - dca_legs,per_leg_amount,grid_prices_json,leg_amounts_json,legs_done,first_order_done,last_mark_price,avg_entry_price,order_amount_open,opened_at,opened_at_ms,session_date,message,leg_fill_prices_json - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", - ( - "active", - symbol, - exchange_symbol, - direction, - leverage, - stop_loss, - stop_loss, - add_upper, - take_profit, - risk_percent, - snap, - opened_at, - margin_plan, - target_amt, - first_amt, - remainder_total, - n_legs, - per_ref, - grid_json, - leg_json, - 0, - 1, - float(live_price), - fill1, - first_amt, - opened_at, - opened_ms, - trading_day, - f"预览ID:{pid[:8]}…", - fills_json, - ), - ) - new_plan_id = int(cur.lastrowid) - conn.execute( - "UPDATE trend_pullback_preview_snapshots SET outcome='executed', executed_plan_id=? WHERE preview_id=?", - (new_plan_id, pid), - ) - conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) - conn.commit() - try: - from strategy_trend_register import build_trend_config - from strategy_wechat_notify import notify_trend_plan_started - - _tcfg = build_trend_config(sys.modules[__name__]) - notify_trend_plan_started( - _tcfg, - plan_id=new_plan_id, - symbol=symbol, - direction=direction, - leverage=leverage, - stop_loss=stop_loss, - take_profit=take_profit, - add_upper=add_upper, - risk_percent=risk_percent, - dca_legs=n_legs, - first_order_amount=first_amt, - avg_entry=fill1, - snapshot_usdt=snap, - ) - except Exception: - pass - conn.close() - flash( - f"趋势回调已执行:可用余额(执行时){round(snap, 2)}U;计划保证金约 {round(margin_plan, 2)}U;" - f"总张数约 {target_amt},首仓 {first_amt},补仓 {n_legs} 档;已挂交易所止损,止盈由程序监控。" - ) - return redirect(url_for("strategy_trend_page")) - - -@app.route("/cancel_trend_pullback_preview", methods=["POST"]) -@login_required -def cancel_trend_pullback_preview(): - pid = (request.form.get("preview_id") or "").strip() - conn = get_db() - if pid: - conn.execute( - "UPDATE trend_pullback_preview_snapshots SET outcome='cancelled' WHERE preview_id=? AND outcome='open'", - (pid,), - ) - conn.execute("DELETE FROM trend_pullback_previews WHERE id=?", (pid,)) - conn.commit() - conn.close() - flash("已取消预览") - return redirect(url_for("strategy_trend_page")) - - -@app.route("/trend_pullback_breakeven/", methods=["POST"]) -@login_required -def trend_pullback_breakeven(pid): - offset_raw = (request.form.get("breakeven_offset_pct") or "").strip() - offset_pct = None - if offset_raw: - try: - offset_pct = float(offset_raw) - if offset_pct < 0: - raise ValueError - except ValueError: - flash("保本偏移% 格式无效") - return redirect(url_for("strategy_trading_page")) - conn = get_db() - row = conn.execute( - "SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,) - ).fetchone() - if not row: - conn.close() - flash("未找到运行中的趋势回调计划") - return redirect(url_for("strategy_trading_page")) - from strategy_trend_register import apply_manual_breakeven, build_trend_config - - cfg = build_trend_config(sys.modules[__name__]) - ok, err = apply_manual_breakeven(cfg, conn, row, offset_pct=offset_pct) - conn.commit() - conn.close() - flash( - "已保本:趋势计划已结束,持仓已移交下单监控并挂止盈止损;平仓后将写入交易记录" - if ok - else (err or "保本移交失败") - ) - return redirect(url_for("strategy_trend_page")) - - -@app.route("/stop_trend_pullback/") -@login_required -def stop_trend_pullback(pid): - conn = get_db() - row = conn.execute("SELECT * FROM trend_pullback_plans WHERE id=? AND status='active'", (pid,)).fetchone() - if not row: - from strategy_trend_register import ( - _ensure_trend_plan_trade_record, - _trend_plan_trade_exists, - build_trend_config, - ) - - cfg = app.extensions.get("strategy_trend_cfg") or build_trend_config( - sys.modules[__name__] - ) - stopped = conn.execute( - "SELECT id FROM trend_pullback_plans WHERE id=? " - "AND status IN ('stopped_sl','stopped_tp','stopped_manual')", - (pid,), - ).fetchone() - if stopped and not _trend_plan_trade_exists(conn, pid): - try: - if _ensure_trend_plan_trade_record(cfg, conn, pid, prefer_label="手动平仓"): - conn.close() - flash("计划已结束,已补录缺失的交易记录") - return redirect(url_for("strategy_trend_page")) - except Exception as e: - conn.close() - flash(f"补录交易记录失败:{e}") - return redirect(url_for("strategy_trend_page")) - conn.close() - flash("未找到运行中的趋势回调计划") - return redirect("/trade") - ex_sym = row["exchange_symbol"] or normalize_exchange_symbol(row["symbol"]) - direction = row["direction"] or "long" - lev = int(row["leverage"] or 1) - px = get_price(row["symbol"]) - exit_p = float(px) if px is not None else 0.0 - ok_live, _ = ensure_exchange_live_ready() - if ok_live: - pos = get_live_position_contracts(ex_sym, direction) - if pos is not None and pos > 0: - try: - exchange.set_leverage(lev, ex_sym) - side = "sell" if direction == "long" else "buy" - params = build_gate_order_params(direction, reduce_only=True) - close_resp = exchange.create_order(ex_sym, "market", side, float(pos), None, params) - ep = extract_trade_price_from_order(close_resp) - if ep: - exit_p = float(ep) - except Exception as e: - if not is_no_position_error(str(e)): - conn.close() - flash(f"平仓失败:{e}") - return redirect("/trade") - try: - cancel_all_open_orders_for_symbol(ex_sym) - except Exception: - pass - try: - from strategy_trend_register import _finalize_plan, build_trend_config - - cfg = app.extensions.get("strategy_trend_cfg") or build_trend_config( - sys.modules[__name__] - ) - _finalize_plan(cfg, conn, row, "手动平仓", exit_p) - except Exception as e: - conn.execute( - "UPDATE trend_pullback_plans SET status='stopped_manual', message=? " - "WHERE id=? AND status='active'", - (f"结束异常:{e}", pid), - ) - conn.commit() - conn.close() - flash(f"计划已结束但记账可能不完整:{e}") - return redirect(url_for("strategy_trend_page")) - conn.close() - flash("已结束趋势回调计划(市价平仓、撤单)") - return redirect(url_for("strategy_trend_page")) - - -@app.route("/delete_trend_plan_history/", methods=["POST"]) -@login_required -def delete_trend_plan_history(pid): - conn = get_db() - row = conn.execute("SELECT id, status FROM trend_pullback_plans WHERE id=?", (pid,)).fetchone() - if not row: - conn.close() - flash("计划不存在") - return redirect(request.referrer or url_for("records_page")) - if (row["status"] or "").strip() == "active": - conn.close() - flash("运行中的计划请使用「结束计划」,不可从历史中删除") - return redirect(request.referrer or url_for("records_page")) - conn.execute("DELETE FROM trade_records WHERE trend_plan_id=?", (pid,)) - conn.execute("DELETE FROM trend_pullback_preview_snapshots WHERE executed_plan_id=?", (pid,)) - conn.execute("DELETE FROM trend_pullback_plans WHERE id=?", (pid,)) - conn.commit() - conn.close() - flash("已删除该计划历史及关联趋势交易记录(若有)") - return redirect(request.referrer or url_for("records_page")) - - @app.route("/delete_key_monitor/", methods=["POST"]) @login_required def delete_key_monitor(kid): @@ -7140,6 +7630,8 @@ def delete_key_monitor(kid): 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() @@ -7163,6 +7655,8 @@ 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() @@ -7208,50 +7702,41 @@ def export_trade_records(): 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,trigger_price,stop_loss,take_profit,margin_capital,leverage," - "pnl_amount,hold_seconds,hold_minutes,opened_at,closed_at,result,miss_reason," - "entry_reason,reviewed_entry_reason,created_at,trend_plan_id,exchange_realized_pnl," - "exchange_opened_at,exchange_closed_at,exchange_sync_key FROM trade_records " - f"WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " + "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_base = [ - "id", - "symbol", - "monitor_type", - "direction", - "trigger_price", - "stop_loss", - "take_profit", - "margin_capital", - "leverage", - "pnl_amount", - "hold_seconds", - "hold_minutes", - "opened_at", - "closed_at", - "result", - "miss_reason", - "entry_reason", - "reviewed_entry_reason", - "created_at", - "trend_plan_id", - "exchange_realized_pnl", - "exchange_opened_at", - "exchange_closed_at", - "exchange_sync_key", + 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", "开仓类型", ] - head = head_base + ["开仓类型"] 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 "" - eff = er1 or er0 - data.append(tuple(r[h] for h in head_base) + (eff,)) + 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_v2_{day}.csv", data, head) + return _csv_response(f"trade_records_v3_{day}.csv", data, head) @app.route("/export/journal_entries") @@ -7323,10 +7808,13 @@ def export_key_monitors(): @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 ORDER BY id ASC" + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), ).fetchall() conn.close() head = [ @@ -7372,17 +7860,19 @@ def del_order(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=row["margin_capital"], + margin_capital=margin_capital_for_trade_record(row_snap), leverage=row["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -7396,6 +7886,7 @@ def del_order(id): closed_at=closed_at, ) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + clear_key_sizing_snapshot_if_flat(conn, session_date) conn.commit() conn.close() send_wechat_msg( @@ -7415,7 +7906,7 @@ def del_order(id): ) ) flash("已按实盘流程手动平仓") - return redirect("/") + 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"])) @@ -7427,16 +7918,19 @@ def del_order(id): 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=row["margin_capital"], + margin_capital=margin_capital_for_trade_record(row_snap), leverage=row["leverage"], pnl_amount=pnl_amount, hold_seconds=hold_seconds, @@ -7467,15 +7961,28 @@ def del_order(id): 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=d["symbol"], + symbol=sym_in, monitor_type=d["type"], direction=direction, - trigger_price=d["tp"], - stop_loss=d["sl"], - take_profit=d["tgt"], + trigger_price=tp_px, + stop_loss=sl_px, + take_profit=tgt_px, result="错过", miss_reason=d["reason"], opened_at=app_now_str(), @@ -7621,9 +8128,9 @@ 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 {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} >= ? " - f"AND {sql_list_time_field('close_datetime', 'created_at', 'open_datetime')} <= ? ORDER BY created_at DESC LIMIT 500", + 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() @@ -7863,11 +8370,20 @@ def api_trade_record_review_update(): reviewed_entry_reason_update = s or None conn = get_db() - row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + 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, @@ -7946,40 +8462,35 @@ def _journal_ai_chart_builder(row): @login_required def ai_daily_review(): date = request.form.get("date", "") - try: - 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": "该日无交易记录"}) + 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" + 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, - ) - print(f"[ai_daily_review] date={date} rows={len(rows)} images={len(image_paths)}") - 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}) - except Exception as e: - print(f"[ai_daily_review] date={date} failed: {e}") - return jsonify({"ok": False, "result": f"生成失败:{e}"}), 500 + 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"]) @@ -7987,50 +8498,48 @@ def ai_daily_review(): def ai_weekly_review(): start_date = request.form.get("start_date", "") end_date = request.form.get("end_date", "") - try: - 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": "该时间段无交易记录"}) + 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" + 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, - ) - print(f"[ai_weekly_review] range={start_date}~{end_date} rows={len(rows)} images={len(image_paths)}") - 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}) - except Exception as e: - print(f"[ai_weekly_review] range={start_date}~{end_date} failed: {e}") - return jsonify({"ok": False, "result": f"生成失败:{e}"}), 500 + 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, - "trend_pullback_preview_ttl": TREND_PULLBACK_PREVIEW_TTL_SECONDS, - "trend_manual_breakeven_offset_pct": TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT, - "trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS, - "trend_preview_max_drift_pct": TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT, + "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(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))), + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, } @@ -8086,29 +8595,17 @@ try: install_on_app( app, exchange="gate_bot", - capabilities=["order", "trend"], + 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, - "preview_trend_pullback": preview_trend_pullback, - "execute_trend_pullback": execute_trend_pullback, - "stop_trend_pullback": stop_trend_pullback, - "trend_pullback_breakeven": trend_pullback_breakeven, - }, + views={"add_order": add_order, "add_key": add_key}, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, reconcile_hub_flat_fn=reconcile_hub_external_close, ) - from strategy_trend_register import build_trend_config, patch_trend_hub_enrich - - _hub_trend_cfg = build_trend_config(sys.modules[__name__]) - app.extensions["strategy_trend_cfg"] = _hub_trend_cfg - patch_trend_hub_enrich(app, _hub_trend_cfg) except Exception as _hub_err: print(f"[hub_bridge] gate_bot: {_hub_err}") @@ -8126,35 +8623,6 @@ def strategy_trend_page(): return redirect(f"/strategy?{qs}" if qs else "/strategy") -@app.route("/api/trend_poll_status") -@login_required -def api_trend_poll_status(): - from strategy_trend_register import ( - build_trend_config, - get_trend_poll_state, - summarize_trend_dca_probe, - ) - - cfg = app.extensions.get("strategy_trend_cfg") or build_trend_config( - sys.modules[__name__] - ) - conn = get_db() - probes = [] - for r in conn.execute( - "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" - ).fetchall(): - probes.append(summarize_trend_dca_probe(cfg, r)) - conn.close() - return jsonify( - { - "poll": get_trend_poll_state(), - "probes": probes, - "live_trading_enabled": LIVE_TRADING_ENABLED, - "monitor_poll_seconds": MONITOR_POLL_SECONDS, - } - ) - - @app.route("/strategy/roll") @login_required def strategy_roll_page(): @@ -8162,20 +8630,15 @@ def strategy_roll_page(): from strategy_register import install_strategy_trading -from strategy_trend_register import build_trend_config +from strategy_trend_register import install_strategy_trend -install_strategy_trading( - app, - _REPO_ROOT, - app_module=sys.modules[__name__], - trend_enabled=True, -) -app.extensions["strategy_trend_cfg"] = build_trend_config(sys.modules[__name__]) +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() -_ensure_background_monitors_started() # 启动 if __name__ == "__main__": + threading.Thread(target=background_task, daemon=True).start() app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 29e3f9d..934860a 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -12,7 +12,7 @@ - {{ exchange_display }} · 加密货币 | 机器人交易监控 + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 -{% macro period_metrics_cells(s) %} +{% macro period_stats(title, s) %} +
+

{{ title }}

+
{{ s.range_label }}
+
开单次数
{{ s.opens_count }}
平仓笔数
{{ s.closed_count }}
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
-
净盈亏(U)
{{ money_fmt(s.net_pnl_u) }}
-
亏损额合计(U)
{{ money_fmt(s.loss_sum_u) }}
-
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ money_fmt(s.max_single_loss) }}{% else %}-{% endif %}
-
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ money_fmt(s.max_single_profit) }}{% else %}-{% endif %}
-
最大回撤(U)
{{ money_fmt(s.max_drawdown_u) }}
+
净盈亏(U)
{{ funds_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ funds_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ funds_fmt(s.max_drawdown_u) }}
当前连续亏损笔数
{{ s.consecutive_losses }}
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
-
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ money_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
-{% endmacro %} -{% macro period_stats_dual(title, pair) %} -
-

{{ title }}

-
{{ pair.range_label }}
-
-
-
机器人下单监控
-
{{ period_metrics_cells(pair.order) }}
-
-
-
趋势回调策略
-
{{ period_metrics_cells(pair.trend) }}
-
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
{% endmacro %}
-

加密货币|Gate 机器人交易监控

+

加密货币|交易监控 + AI复盘一体化

{{ exchange_display }}
@@ -324,7 +277,7 @@
{% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} - - {% if page == 'records' %}
列表筛选(UTC,默认当日):{{ list_window.label }}
- {% endif %} -
- 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列及交易所对齐字段): + 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出): 交易记录 + 复盘记录 关键位(当前) 关键位历史
@@ -364,9 +314,9 @@
总交易
{{ total }}
错过次数
{{ miss_count }}
胜率
{{ rate }}%
-
资金账户(USDT)
{% if funding_usdt is not none %}{{ money_fmt(funding_usdt) }}U{% else %}—{% endif %}
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}
交易日
{{ trading_day }}
-
当日资金(交易账户)
{{ money_fmt(current_capital) }}U
+
当日资金(交易账户)
{{ funds_fmt(current_capital) }}U
{% include 'gate_transfer_block.html' %} @@ -377,14 +327,14 @@
-

机器人下单监控(单仓)

+

实盘下单监控

{% if focus_order_id %} 放大查看K线(100根) {% else %} 暂无持仓可放大 {% endif %}
- {% include 'order_monitor_rule_tips_gate_bot.html' %} + {% include 'order_monitor_rule_tips_gate.html' %}
@@ -568,24 +493,25 @@
- + {% for r in record %} + {% set pnl_val = (r.pnl_amount or 0)|float %} - + {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} {% set tp_show = r.effective_take_profit or r.take_profit %} - + - - - {% set pnl_val = (r.display_pnl_amount or 0)|float %} - + + + {% set pnl_val = (r.effective_pnl_amount or 0)|float %} +
品种类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓(展示)平仓(展示)盈亏U(展示)结果操作
品种类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %} {{ '做多' if r.direction == 'long' else '做空' }} {{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }} {{ price_fmt(r.symbol, tp_show) }}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ money_fmt(r.margin_capital) }}{% else %}-{% endif %}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %} {{ r.leverage or '-' }} {{ r.effective_hold_minutes or 0 }}{{ r.display_opened_at }}{{ r.display_closed_at }}{{ money_fmt(r.display_pnl_amount) }}{% if r.monitor_type == '趋势回调' and r.display_pnl_source == 'local' %}{% elif r.monitor_type == '趋势回调' and r.display_pnl_source == 'exchange' %}{% endif %}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ funds_fmt(r.effective_pnl_amount or 0) }}{% if r.display_pnl_source == 'exchange' %}{% elif r.display_pnl_source != 'reviewed' %}{% endif %} {% set effective_result = r.effective_result %} {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} @@ -600,10 +526,11 @@ onclick='fillJournalFromTrade({{ { "symbol": r.symbol, "monitor_type": r.monitor_type, + "key_signal_type": r.key_signal_type or "", "direction": r.direction, "trigger_price": r.trigger_price, - "stop_loss": stop_show, - "take_profit": tp_show, + "stop_loss": r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss, + "take_profit": r.effective_take_profit or r.take_profit, "opened_at": r.effective_opened_at, "closed_at": r.effective_closed_at, "pnl_amount": r.effective_pnl_amount, @@ -619,8 +546,8 @@ "id": r.id, "opened_at": r.effective_opened_at, "closed_at": r.effective_closed_at, - "stop_loss": stop_show, - "take_profit": tp_show, + "stop_loss": r.effective_stop_loss or r.initial_stop_loss or r.stop_loss, + "take_profit": r.effective_take_profit or r.take_profit, "pnl_amount": r.effective_pnl_amount, "result": r.effective_result, "miss_reason": r.effective_miss_reason, @@ -671,7 +598,7 @@ - {% for er in entry_reason_options %} @@ -778,33 +705,8 @@ - -
-

预览快照(自本版本起留存)

-
每次「生成预览」自动归档;取消、过期或执行后仍可点开查看当时参数。执行后状态为「已执行」并带关联计划 ID。
- {% if preview_snapshots and preview_snapshots|length > 0 %} -
- - - {% for s in preview_snapshots %} - - - - - - - - - - - {% endfor %} -
ID时间品种方向杠杆状态快照余额U操作
{{ s.id }}{{ (s.preview_created_at or '-')[:16] }}{{ s.symbol }}{{ '多' if s.direction == 'long' else '空' }}{{ s.leverage }}x{{ s.outcome_label }}{% if s.executed_plan_id %} #{{ s.executed_plan_id }}{% endif %}{{ money_fmt(s.snapshot_available_usdt) }}
- {% else %} -
暂无预览快照(新版本生成预览后将出现在此)
- {% endif %} -
- + {% endif %} @@ -819,14 +721,26 @@
持仓占用导致错过(累计)
{{ occupied_miss_total }}
- 已平仓记录按平仓时间归入北京时间交易日;胜率按盈笔数/(盈+亏)。各策略分列统计。
- 历史总开仓(累计):下单监控 {{ stats_bundle.total_opens_order }} 次 - | 趋势回调 {{ stats_bundle.total_opens_trend }} 次 - (合计 {{ stats_bundle.total_opens_all }} 次) + 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): + {{ stats_bundle.total_opens_all }}
- {{ period_stats_dual("日统计", stats_bundle.day) }} - {{ period_stats_dual("周统计", stats_bundle.week) }} - {{ period_stats_dual("月统计", stats_bundle.month) }} +
+ +
+ {% for seg in stats_bundle.segments %} + + {% endfor %} {% endif %} @@ -900,6 +814,7 @@ function forceCloseDetailModal(){ const modal = document.getElementById("detailModal"); if(modal){ modal.style.display = "none"; modal.classList.remove("fullscreen"); } } +function closeDetailModal(e){if(e.target && e.target.id==="detailModal"){forceCloseDetailModal();}} function expandDetailToFullscreen(){ setDetailModalFullscreen(true); } function toggleReviewCardFullscreen(){ const card = document.getElementById("review-card"); @@ -954,42 +869,6 @@ function openAiInlineResultFullscreen(title, elementId){ setDetailModalFullscreen(true); document.getElementById("detailModal").style.display = "flex"; } -function fmtU2(n){ - if(n === null || n === undefined || n === "") return "-"; - const x = Number(n); - if(Number.isNaN(x)) return String(n); - return x.toFixed(2); -} -function openPreviewSnapshotDetail(id){ - fetch(`/api/preview_snapshot/${id}`).then(r=>r.json()).then(data=>{ - if(!data.ok){ alert((data && data.msg) || "加载失败"); return; } - const s = data.snapshot; - const lines = [ - `预览ID:${s.preview_id || "-"}`, - `归档状态:${s.outcome_label || "-"}`, - `关联计划ID:${s.executed_plan_id != null ? s.executed_plan_id : "-"}`, - "", - `${s.symbol || "-"} ${s.direction === "long" ? "做多" : "做空"} ${s.leverage || "-"}x`, - `可用快照U:${fmtU2(s.snapshot_available_usdt)}`, - `参考价:${s.live_price_ref != null ? s.live_price_ref : "-"}`, - `计划保证金≈U:${fmtU2(s.plan_margin_capital)}`, - `总张数:${s.target_order_amount != null ? s.target_order_amount : "-"}`, - `首仓/补仓余:${s.first_order_amount != null ? s.first_order_amount : "-"} / ${s.remainder_total != null ? s.remainder_total : "-"}`, - `补仓档数:${s.dca_legs != null ? s.dca_legs : "-"}`, - `止损 / ${s.direction === "long" ? "补仓上沿" : "补仓下沿"} / 止盈:${s.stop_loss} / ${s.add_upper} / ${s.take_profit}`, - `风险%:${s.risk_percent != null ? s.risk_percent : "-"}`, - `网格价 JSON:${s.grid_prices_json || "[]"}`, - `分档张数 JSON:${s.leg_amounts_json || "[]"}`, - `创建时间:${s.preview_created_at || "-"}`, - `预览过期(ms):${s.expires_at_ms != null ? s.expires_at_ms : "-"}`, - ].join("\n"); - document.getElementById("detailTitle").innerText = `预览快照 #${id}`; - document.getElementById("detailBody").innerText = lines; - document.getElementById("detailImage").style.display = "none"; - document.getElementById("detailModal").style.display = "flex"; - }).catch(()=>{ alert("网络错误"); }); -} -function closeDetailModal(e){if(e.target && e.target.id==="detailModal"){forceCloseDetailModal();}} const journalCache = {}; const reviewCache = {}; @@ -1072,7 +951,7 @@ function editTradeRecordReview(t){ const result = prompt("结果(止盈/止损/保本止盈/移动止盈/手动平仓)", String(t.result || "")); if(result === null) return; const note = prompt("备注(可空)", String(t.miss_reason || "")) ?? ""; - const entryHint = "开仓类型:固定选项整句、或自定义说明(2000字内;与复盘表单一致;留空=本次不改该项)"; + const entryHint = "开仓类型:五种固定整句、或自定义说明(2000字内;与复盘表单一致;留空=本次不改该项)"; const entryIn = prompt(entryHint, String(t.effective_entry_reason || "")); if(entryIn === null) return; const payload = { @@ -1125,6 +1004,47 @@ function deleteKeyHistory(id){ .catch(()=>{ window.location.href = `${window.location.pathname}?_ts=${Date.now()}`; }); } +function listWindowQueryString(){ + const presetEl = document.getElementById("win-preset-select"); + const preset = (presetEl && presetEl.value) || new URLSearchParams(window.location.search).get("win_preset") || "utc_today"; + const q = new URLSearchParams(window.location.search); + q.set("win_preset", preset); + if(preset === "custom"){ + const fromEl = document.getElementById("win-from-utc"); + const toEl = document.getElementById("win-to-utc"); + if(fromEl && fromEl.value) q.set("from_utc", fromEl.value.replace("T", " ") + ":00"); + else q.delete("from_utc"); + if(toEl && toEl.value) q.set("to_utc", toEl.value.replace("T", " ") + ":00"); + else q.delete("to_utc"); + } else { + q.delete("from_utc"); + q.delete("to_utc"); + } + return q.toString(); +} + +function toggleListWindowCustom(){ + const preset = document.getElementById("win-preset-select"); + const box = document.getElementById("win-custom-range"); + if(!preset || !box) return; + box.style.display = preset.value === "custom" ? "" : "none"; +} + +function applyListWindow(){ + const qs = listWindowQueryString(); + const path = window.location.pathname || "/trade"; + window.location.href = qs ? (path + "?" + qs) : path; +} + +function attachListWindowToExports(){ + const qs = listWindowQueryString(); + if(!qs) return; + document.querySelectorAll('.export-bar a[href^="/export/trade_records"], .export-bar a[href^="/export/key_monitor_history"]').forEach(a=>{ + const base = a.getAttribute("href").split("?")[0]; + a.setAttribute("href", base + "?" + qs); + }); +} + function loadJournals(){ const qs = listWindowQueryString(); fetch("/api/journals" + (qs ? "?" + qs : "")).then(r=>r.json()).then(data=>{ @@ -1283,7 +1203,14 @@ function setJournalField(name, value){ el.value = String(value); } -const EARLY_EXIT_TRIGGERS = new Set(["保本止盈","移动止盈","手动平仓","止损","其他"]); +const EARLY_EXIT_TRIGGERS = new Set(["止盈","保本止盈","移动止盈","手动平仓","止损","其他"]); +const KEY_ENTRY_REASON_BY_SIGNAL = { + "箱体突破": "关键位箱体突破", + "收敛突破": "关键位收敛突破", + "斐波回调0.618": "关键位斐波0.618", + "斐波回调0.786": "关键位斐波0.786", + "假突破": "关键位假突破" +}; function splitLegacyEarlyExitReason(raw){ const s = String(raw || "").trim(); @@ -1394,17 +1321,24 @@ function fillJournalFromTrade(t){ if(dirHint){ dirHint.value = t.direction || "long"; } setJournalField("early_exit_trigger", ""); setJournalField("early_exit_note", ""); - setJournalField("entry_reason", ""); + const kst = String(t.key_signal_type || "").trim(); + const mt = String(t.monitor_type || "").trim(); + if(mt === "趋势回调" && JOURNAL_ENTRY_REASON_OPTIONS.includes("趋势回调")){ + setJournalField("entry_reason", "趋势回调"); + } else if(mt === "顺势加仓" && JOURNAL_ENTRY_REASON_OPTIONS.includes("顺势加仓")){ + setJournalField("entry_reason", "顺势加仓"); + } else { + const erFromKey = KEY_ENTRY_REASON_BY_SIGNAL[kst] || ""; + if(erFromKey && JOURNAL_ENTRY_REASON_OPTIONS.includes(erFromKey)){ + setJournalField("entry_reason", erFromKey); + } else { + setJournalField("entry_reason", ""); + } + } setJournalField("entry_reason_custom", ""); syncJournalEntryReasonOtherUi(); - if(String(t.monitor_type || "").trim() === "趋势回调" && JOURNAL_ENTRY_REASON_OPTIONS.includes("趋势回调")){ - setJournalField("entry_reason", "趋势回调"); - } else if(String(t.monitor_type || "").trim() === "顺势加仓" && JOURNAL_ENTRY_REASON_OPTIONS.includes("顺势加仓")){ - setJournalField("entry_reason", "顺势加仓"); - syncJournalEntryReasonOtherUi(); - } const er = String(t.result || "").trim(); - const exitTrigMap = { 保本止盈: "保本止盈", 移动止盈: "移动止盈", 手动平仓: "手动平仓", 止损: "止损" }; + const exitTrigMap = { 止盈: "止盈", 保本止盈: "保本止盈", 移动止盈: "移动止盈", 手动平仓: "手动平仓", 止损: "止损" }; if(exitTrigMap[er]) setJournalField("early_exit_trigger", exitTrigMap[er]); const note = `来自交易记录自动填充:${t.symbol || "-"} ${t.direction || "-"} | 入场:${entryPx || "-"} 止损:${slPx || "-"} 止盈:${tpPx || "-"} | 类型:${t.monitor_type || "-"}`; setJournalField("note", note); @@ -1487,6 +1421,29 @@ function recomputeJournalRealRr(){ } +function switchStatsSegment(){ + const sel = document.getElementById("stats-segment-select"); + if(!sel) return; + const key = sel.value; + document.querySelectorAll(".stats-segment-panel").forEach(p=>{ + p.style.display = p.getAttribute("data-stats-segment") === key ? "block" : "none"; + }); + const q = new URLSearchParams(window.location.search); + q.set("stats_segment", key); + const qs = q.toString(); + history.replaceState(null, "", qs ? (window.location.pathname + "?" + qs) : window.location.pathname); +} + +function initStatsSegmentFromUrl(){ + const sel = document.getElementById("stats-segment-select"); + if(!sel) return; + const key = new URLSearchParams(window.location.search).get("stats_segment"); + if(key && sel.querySelector('option[value="' + key.replace(/"/g, "") + '"]')){ + sel.value = key; + } + switchStatsSegment(); +} + function toggleStatsCard(){ const card = document.getElementById("stats-card"); const btn = document.getElementById("stats-toggle-btn"); @@ -1495,46 +1452,9 @@ function toggleStatsCard(){ btn.innerText = collapsed ? "展开" : "折叠"; } -function listWindowQueryString(){ - const presetEl = document.getElementById("win-preset-select"); - const preset = (presetEl && presetEl.value) || new URLSearchParams(window.location.search).get("win_preset") || "utc_today"; - const q = new URLSearchParams(window.location.search); - q.set("win_preset", preset); - if(preset === "custom"){ - const fromEl = document.getElementById("win-from-utc"); - const toEl = document.getElementById("win-to-utc"); - if(fromEl && fromEl.value) q.set("from_utc", fromEl.value.replace("T", " ") + ":00"); - else q.delete("from_utc"); - if(toEl && toEl.value) q.set("to_utc", toEl.value.replace("T", " ") + ":00"); - else q.delete("to_utc"); - } else { - q.delete("from_utc"); - q.delete("to_utc"); - } - return q.toString(); -} -function toggleListWindowCustom(){ - const preset = document.getElementById("win-preset-select"); - const box = document.getElementById("win-custom-range"); - if(!preset || !box) return; - box.style.display = preset.value === "custom" ? "" : "none"; -} -function applyListWindow(){ - const qs = listWindowQueryString(); - const path = window.location.pathname || "/records"; - window.location.href = qs ? (path + "?" + qs) : path; -} -function attachListWindowToExports(){ - const qs = listWindowQueryString(); - if(!qs) return; - document.querySelectorAll('.export-bar a[href^="/export/trade_records"]').forEach(a=>{ - const base = a.getAttribute("href").split("?")[0]; - a.setAttribute("href", base + "?" + qs); - }); -} - attachListWindowToExports(); toggleListWindowCustom(); +initStatsSegmentFromUrl(); if(document.getElementById("journal-list")) loadJournals(); if(document.getElementById("review-list")) loadReviews(); const reviewToggle = document.getElementById("review-mode-toggle"); @@ -1635,6 +1555,12 @@ if(keyForm){ alert("请先输入交易对"); return; } + const typeVal = (keyForm.querySelector('[name="type"]') || {}).value || ""; + if(typeVal === "假突破"){ + if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…"); + else keyForm.submit(); + return; + } if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…"); fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`) .then(r=>r.json().then(d=>({status:r.status, data:d}))) @@ -1644,8 +1570,9 @@ if(keyForm){ if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm); return; } + const rankMax = data.rank_max || 30; if(!data.in_top30){ - alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前30,已拦截。`); + alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`); if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm); return; } @@ -1664,8 +1591,7 @@ setTimeout(() => { if(document.getElementById("review-list")) loadReviews(); }, 300); -let latestAvailableUsdt = null; -const lastPriceMap = {}; + const MANUAL_MIN_PLANNED_RR = {{ manual_min_planned_rr }}; const MANUAL_FIXED_RR_DEFAULT = 1.5; const FIXED_RR_LS_KEY = "manualFixedRr"; @@ -1730,22 +1656,7 @@ function rejectManualOrderRr(rr){ alert(`计划盈亏比 ${rr === null ? '无效' : rr.toFixed(2)}:1 低于最低要求 ${MANUAL_MIN_PLANNED_RR}:1,已阻止人工下单。`); return true; } -function stopIsProfitProtecting(direction, entry, sl){ - const e = Number(entry), s = Number(sl); - if(!Number.isFinite(e) || !Number.isFinite(s)) return false; - return (direction || "long") === "short" ? s < e : s > e; -} -function entryPriceFromOrderCard(card){ - if(!card) return null; - const raw = card.getAttribute("data-entry"); - if(raw === null || raw === "") return null; - const e = Number(raw); - return Number.isFinite(e) ? e : null; -} -function tpslRrCheckPasses(direction, entry, sl, tp){ - if(stopIsProfitProtecting(direction, entry, sl)) return true; - return !rejectManualOrderRr(calcClientRr(direction, entry, sl, tp)); -} + let tpslEntrustMonitorId = null; function formatExTpslLine(role, slot){ const label = role === 'sl' ? '止损' : '止盈'; @@ -1765,26 +1676,6 @@ function paintExchangeTpslRow(orderId, tpsl){ if(slBtn) slBtn.disabled = !(data.sl && data.sl.order_id); if(tpBtn) tpBtn.disabled = !(data.tp && data.tp.order_id); } -function paintPlanTpslDisplay(orderId, snap){ - if(!snap) return; - const card = document.getElementById(`order-row-${orderId}`); - const slEl = document.getElementById(`order-plan-sl-${orderId}`); - const tpEl = document.getElementById(`order-plan-tp-${orderId}`); - const rrEl = document.getElementById(`order-rr-${orderId}`); - const slRaw = snap.stop_loss_raw != null && snap.stop_loss_raw !== "" ? snap.stop_loss_raw : snap.stop_loss; - const tpRaw = snap.take_profit_raw != null && snap.take_profit_raw !== "" ? snap.take_profit_raw : snap.take_profit; - const slDisp = snap.stop_loss_display || (slRaw != null && slRaw !== "" ? formatPriceForInput(slRaw) : null); - const tpDisp = snap.take_profit_display || (tpRaw != null && tpRaw !== "" ? formatPriceForInput(tpRaw) : null); - if(slEl) slEl.innerText = slDisp || "—"; - if(tpEl) tpEl.innerText = tpDisp || "—"; - if(card){ - if(slRaw != null && slRaw !== "") card.setAttribute('data-plan-sl', formatPriceForInput(slRaw)); - else if(slDisp) card.setAttribute('data-plan-sl', slDisp); - if(tpRaw != null && tpRaw !== "") card.setAttribute('data-plan-tp', formatPriceForInput(tpRaw)); - else if(tpDisp) card.setAttribute('data-plan-tp', tpDisp); - } - if(rrEl && typeof snap.rr_ratio !== "undefined") rrEl.innerText = formatRrRatio(snap.rr_ratio); -} function toggleTpslModalMode(){ const mode = (document.getElementById('tpsl-modal-mode')||{}).value || 'price'; const pct = mode === 'pct'; @@ -1830,32 +1721,9 @@ function submitTpslEntrust(){ alert(data.msg || '已提交'); closeTpslEntrustModal(); if(data.exchange_tpsl) paintExchangeTpslRow(orderId, data.exchange_tpsl); - paintPlanTpslDisplay(orderId, { - stop_loss_raw: data.stop_loss, - take_profit_raw: data.take_profit, - stop_loss_display: data.stop_loss != null ? formatPriceForInput(data.stop_loss) : null, - take_profit_display: data.take_profit != null ? formatPriceForInput(data.take_profit) : null, - rr_ratio: data.planned_rr, - }); refreshPriceSnapshotConditional(); }).catch(()=>alert('委托请求失败')); } -function relinkOrphanPosition(symbol, direction){ - if(!confirm(`恢复 ${symbol} ${direction} 的本地监控?(接回最近一条已停止记录)`)) return; - fetch("/api/order/relink_orphan", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ symbol, direction }), - }) - .then(r=>r.json()) - .then(data=>{ - if(!data.ok){ alert(data.msg || "恢复失败"); return; } - alert(data.msg || "已恢复"); - location.reload(); - }) - .catch(()=>alert("恢复请求失败")); -} - function cancelExchangeTpsl(orderId, role){ const label = role === 'sl' ? '止损' : '止盈'; if(!confirm(`确认撤销交易所${label}委托?(不会平仓)`)) return; @@ -1866,6 +1734,7 @@ function cancelExchangeTpsl(orderId, role){ else refreshPriceSnapshotConditional(); }).catch(()=>alert('撤单请求失败')); } + function allowManualOrderSubmit(form){ form.dataset.rrOk = "1"; if(window.FormSubmitGuard){ @@ -1878,12 +1747,16 @@ function allowManualOrderSubmit(form){ form.submit(); } -function formatSigned(v, digits=4){ +let latestAvailableUsdt = null; +const lastPriceMap = {}; + +function formatSigned(v, digits=2){ if(v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-"; const n = Number(v); const sign = n > 0 ? "+" : ""; return `${sign}${n.toFixed(digits)}`; } + function formatRrRatio(rr){ if(rr === null || typeof rr === "undefined") return "-:1"; const n = Number(rr); @@ -1892,6 +1765,30 @@ function formatRrRatio(rr){ return `${body}:1`; } +function paintBreakevenBadge(orderId, secured){ + const wrap = document.getElementById(`order-be-wrap-${orderId}`); + if(!wrap) return; + wrap.style.display = secured ? "inline-flex" : "none"; +} +function paintPlanTpslDisplay(orderId, snap){ + if(!snap) return; + const card = document.getElementById(`order-row-${orderId}`); + const slEl = document.getElementById(`order-plan-sl-${orderId}`); + const tpEl = document.getElementById(`order-plan-tp-${orderId}`); + const slRaw = snap.stop_loss_raw != null && snap.stop_loss_raw !== "" ? snap.stop_loss_raw : snap.stop_loss; + const tpRaw = snap.take_profit_raw != null && snap.take_profit_raw !== "" ? snap.take_profit_raw : snap.take_profit; + const slDisp = snap.stop_loss_display || (slRaw != null && slRaw !== "" ? formatPriceForInput(slRaw) : null); + const tpDisp = snap.take_profit_display || (tpRaw != null && tpRaw !== "" ? formatPriceForInput(tpRaw) : null); + if(slEl) slEl.innerText = slDisp || "—"; + if(tpEl) tpEl.innerText = tpDisp || "—"; + if(card){ + if(slRaw != null && slRaw !== "") card.setAttribute("data-plan-sl", formatPriceForInput(slRaw)); + else if(slDisp) card.setAttribute("data-plan-sl", slDisp); + if(tpRaw != null && tpRaw !== "") card.setAttribute("data-plan-tp", formatPriceForInput(tpRaw)); + else if(tpDisp) card.setAttribute("data-plan-tp", tpDisp); + } +} + function paintPriceTrend(el, key, value){ if(!el) return; const prev = lastPriceMap[key]; @@ -1906,78 +1803,80 @@ function paintPriceTrend(el, key, value){ lastPriceMap[key] = value; } -function paintBreakevenBadge(orderId, secured){ - const wrap = document.getElementById(`order-be-wrap-${orderId}`); - if(!wrap) return; - wrap.style.display = secured ? "inline-flex" : "none"; -} - -function refreshPriceSnapshotConditional(){ - const page = document.body.getAttribute("data-page") || ""; +function refreshPriceSnapshot(){ fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ const updatedEl = document.getElementById("price-last-updated"); - if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; - if(page === "key_monitor"){ - (data.key_prices || []).forEach(k=>{ - const pEl = document.getElementById(`key-price-${k.id}`); - if(pEl){ - pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); - paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); - } - const upEl = document.getElementById(`key-up-diff-${k.id}`); - if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`; - const lowEl = document.getElementById(`key-low-diff-${k.id}`); - if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`; - const gateEl = document.getElementById(`key-gate-${k.id}`); - if(gateEl){ - gateEl.innerText = k.gate_summary || "-"; - gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; - } - const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`); - if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || ""; - if(typeof paintKeyMonitorSummary === "function") paintKeyMonitorSummary(k.id, k); - }); + if(data.updated_at && updatedEl){ + updatedEl.innerText = data.updated_at; } - if(page === "trade"){ - (data.order_prices || []).forEach(o=>{ - const pEl = document.getElementById(`order-price-${o.id}`); - if(pEl){ - const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })(); - let disp = ""; - if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display; - else if(o.price_display) disp = o.price_display; - else { - const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); - disp = Number.isFinite(px) ? String(px) : "-"; - } - pEl.innerText = disp; - const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price); - paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px); + (data.key_prices || []).forEach(k=>{ + const pEl = document.getElementById(`key-price-${k.id}`); + if(pEl){ + pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); + paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); + } + const upEl = document.getElementById(`key-up-diff-${k.id}`); + if(upEl){ + upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`; + } + const lowEl = document.getElementById(`key-low-diff-${k.id}`); + if(lowEl){ + lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`; + } + const gateEl = document.getElementById(`key-gate-${k.id}`); + if(gateEl){ + gateEl.innerText = k.gate_summary || "-"; + gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; + } + const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`); + if(gateMetricEl){ + gateMetricEl.innerText = k.gate_metrics || ""; + } + }); + (data.order_prices || []).forEach(o=>{ + const pEl = document.getElementById(`order-price-${o.id}`); + if(pEl){ + const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })(); + let disp = ""; + if(hasMark && o.exchange_mark_price_display){ + disp = o.exchange_mark_price_display; + } else if(o.price_display){ + disp = o.price_display; + } else { + const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); + disp = Number.isFinite(px) ? px.toFixed(6) : "-"; } - const exM = document.getElementById(`order-ex-margin-${o.id}`); - if(exM){ - const mv = o.exchange_initial_margin; - const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv); - if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`; - else { - const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; - exM.innerText = (prc === 0) ? "无仓数据" : "-"; - } + pEl.innerText = disp; + const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price); + paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px); + } + const exM = document.getElementById(`order-ex-margin-${o.id}`); + if(exM){ + const mv = o.exchange_initial_margin; + const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv); + if(!Number.isNaN(mn)){ + exM.innerText = `${mn.toFixed(2)}U`; + } else { + const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; + exM.innerText = (prc === 0) ? "无仓数据" : "-"; } - const pnlEl = document.getElementById(`order-pnl-${o.id}`); - if(pnlEl){ - pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`; - pnlEl.classList.remove("price-up","price-down","price-flat","pnl-profit","pnl-loss","pnl-neutral"); - const fp = Number(o.float_pnl); - if(fp > 0) pnlEl.classList.add("pnl-profit"); - else if(fp < 0) pnlEl.classList.add("pnl-loss"); - else pnlEl.classList.add("pnl-neutral"); - } - paintBreakevenBadge(o.id, o.sl_breakeven_secured); - paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); - paintPlanTpslDisplay(o.id, o); - }); - } + } + const pnlEl = document.getElementById(`order-pnl-${o.id}`); + if(pnlEl){ + pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`; + pnlEl.classList.remove("price-up","price-down","price-flat"); + if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up"); + else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down"); + else pnlEl.classList.add("price-flat"); + } + const rrEl = document.getElementById(`order-rr-${o.id}`); + if(rrEl){ + rrEl.innerText = formatRrRatio(o.rr_ratio); + } + paintBreakevenBadge(o.id, o.sl_breakeven_secured); + if(o.exchange_tpsl) paintExchangeTpslRow(o.id, o.exchange_tpsl); + paintPlanTpslDisplay(o.id, o); + }); }).catch(()=>{}); } @@ -2032,8 +1931,8 @@ function refreshAccountSnapshot(){ const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); const opens = Number(data.opens_today); if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`); - parts.push("有趋势回调计划"); - parts.push(`或未到北京时间 {{ reset_hour }}:00`); + if (!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`); + else parts.push(`或未到北京时间 {{ reset_hour }}:00`); canTradeText = `不可开仓(${parts.join(";")})`; } const opensToday = Number(data.opens_today); @@ -2044,9 +1943,8 @@ function refreshAccountSnapshot(){ : ""; const tip = document.getElementById("order-rule-tip"); const avail = (latestAvailableUsdt !== null && !Number.isNaN(latestAvailableUsdt)) ? `;交易账户可用约${latestAvailableUsdt.toFixed(2)}U` : ""; - const minRr = data.manual_min_planned_rr != null ? data.manual_min_planned_rr : MANUAL_MIN_PLANNED_RR; if(tip){ - tip.innerText = `规则:最大同时持仓 ${data.max_active_positions || {{ max_active_positions }}}(当前 active ${data.active_count||0});与「趋势回调」计划互斥;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${openCntTxt ? openCntTxt + ";" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 ${minRr}:1`; + tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${openCntTxt ? openCntTxt + ";" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1`; } }).catch(()=>{}); } @@ -2109,6 +2007,7 @@ if(_journalFormEl){ if(_jErSel) _jErSel.addEventListener("change", syncJournalEntryReasonOtherUi); syncJournalEntryReasonOtherUi(); } + const addOrderForm = document.getElementById("add-order-form"); if(addOrderForm){ addOrderForm.addEventListener("submit", function(ev){ @@ -2182,6 +2081,63 @@ if(addOrderForm){ refreshOrderDefaults(); refreshPriceSnapshotConditional(); setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }}); +function refreshPriceSnapshotConditional(){ + const page = document.body.getAttribute("data-page") || ""; + fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{ + const updatedEl = document.getElementById("price-last-updated"); + if(data.updated_at && updatedEl) updatedEl.innerText = data.updated_at; + if(page === "key_monitor"){ + (data.key_prices || []).forEach(k=>{ + const pEl = document.getElementById(`key-price-${k.id}`); + if(pEl){ pEl.innerText = k.price_display || (Number.isFinite(Number(k.price)) ? Number(k.price).toFixed(6) : "-"); paintPriceTrend(pEl, `k-${k.id}`, Number(k.price)); } + const upEl = document.getElementById(`key-up-diff-${k.id}`); + if(upEl) upEl.innerText = `${formatSigned(k.upper_diff, 4)} (${formatSigned(k.upper_pct, 2)}%)`; + const lowEl = document.getElementById(`key-low-diff-${k.id}`); + if(lowEl) lowEl.innerText = `${formatSigned(k.lower_diff, 4)} (${formatSigned(k.lower_pct, 2)}%)`; + const gateEl = document.getElementById(`key-gate-${k.id}`); + if(gateEl){ gateEl.innerText = k.gate_summary || "-"; gateEl.style.color = k.gate_ok ? "#4cd97f" : "#ff8f8f"; } + const gateMetricEl = document.getElementById(`key-gate-metrics-${k.id}`); + if(gateMetricEl) gateMetricEl.innerText = k.gate_metrics || ""; + if(typeof paintKeyMonitorSummary === "function") paintKeyMonitorSummary(k.id, k); + }); + } + if(page === "trade"){ + (data.order_prices || []).forEach(o=>{ + const pEl = document.getElementById(`order-price-${o.id}`); + if(pEl){ + const hasMark = (()=>{ const x = o.exchange_mark_price; if(x===null||x===undefined||x==="")return false; const n=Number(x); return !Number.isNaN(n); })(); + let disp = ""; + if(hasMark && o.exchange_mark_price_display) disp = o.exchange_mark_price_display; + else if(o.price_display) disp = o.price_display; + else { const px = hasMark ? Number(o.exchange_mark_price) : Number(o.price); disp = Number.isFinite(px) ? px.toFixed(6) : "-"; } + pEl.innerText = disp; + const pxNum = hasMark ? Number(o.exchange_mark_price) : Number(o.price); + paintPriceTrend(pEl, `o-${o.id}`, Number.isFinite(pxNum) ? pxNum : px); + } + const exM = document.getElementById(`order-ex-margin-${o.id}`); + if(exM){ + const mv = o.exchange_initial_margin; + const mn = (mv === null || mv === undefined || mv === "") ? NaN : Number(mv); + if(!Number.isNaN(mn)) exM.innerText = `${mn.toFixed(2)}U`; + else { const prc = (typeof data.positions_raw_count === "number") ? data.positions_raw_count : null; exM.innerText = (prc === 0) ? "无仓数据" : "-"; } + } + const pnlEl = document.getElementById(`order-pnl-${o.id}`); + if(pnlEl){ + pnlEl.innerText = `${formatSigned(o.float_pnl, 2)}U (${formatSigned(o.float_pct, 2)}%)`; + pnlEl.classList.remove("price-up","price-down","price-flat"); + if(Number(o.float_pnl) > 0) pnlEl.classList.add("price-up"); + else if(Number(o.float_pnl) < 0) pnlEl.classList.add("price-down"); + else pnlEl.classList.add("price-flat"); + } + const rrEl = document.getElementById(`order-rr-${o.id}`); + if(rrEl) rrEl.innerText = formatRrRatio(o.rr_ratio); + paintBreakevenBadge(o.id, o.sl_breakeven_secured); + paintExchangeTpslRow(o.id, o.exchange_tpsl || {}); + paintPlanTpslDisplay(o.id, o); + }); + } + }).catch(()=>{}); +} setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); diff --git a/crypto_monitor_gate_bot/templates/order_focus.html b/crypto_monitor_gate_bot/templates/order_focus.html index 0811e93..c0992d4 100644 --- a/crypto_monitor_gate_bot/templates/order_focus.html +++ b/crypto_monitor_gate_bot/templates/order_focus.html @@ -140,13 +140,13 @@ function addLine(price, title, color){ function paintOrder(order){ document.getElementById("m-symbol").innerText = order.symbol || "-"; document.getElementById("m-direction").innerText = (order.direction === "short") ? "做空" : "做多"; - document.getElementById("m-entry").innerText = fmt(order.trigger_price, 8); - document.getElementById("m-sl").innerText = fmt(order.stop_loss, 8); - document.getElementById("m-tp").innerText = fmt(order.take_profit, 8); + document.getElementById("m-entry").innerText = order.trigger_price_display || fmt(order.trigger_price, 8); + document.getElementById("m-sl").innerText = order.stop_loss_display || fmt(order.stop_loss, 8); + document.getElementById("m-tp").innerText = order.take_profit_display || fmt(order.take_profit, 8); document.getElementById("m-rr").innerText = (order.rr_ratio === null || typeof order.rr_ratio === "undefined") ? "-" : `1:${Number(order.rr_ratio).toFixed(2)}`; - document.getElementById("m-price").innerText = fmt(order.current_price, 8); + document.getElementById("m-price").innerText = order.current_price_display || fmt(order.current_price, 8); const pnlEl = document.getElementById("m-pnl"); - pnlEl.innerText = `${fmt(order.float_pnl, 4)}U (${fmt(order.float_pct, 2)}%)`; + pnlEl.innerText = `${fmt(order.float_pnl, 2)}U (${fmt(order.float_pct, 2)}%)`; pnlEl.style.color = Number(order.float_pnl || 0) > 0 ? "#4cd97f" : (Number(order.float_pnl || 0) < 0 ? "#ff6666" : "#d6deff"); } diff --git a/crypto_monitor_gate_bot/使用说明.md b/crypto_monitor_gate_bot/使用说明.md new file mode 100644 index 0000000..7153139 --- /dev/null +++ b/crypto_monitor_gate_bot/使用说明.md @@ -0,0 +1,145 @@ +# 使用说明 + +**本文件对应仓库:`crypto_monitor_gate`(Gate.io USDT 永续)。** +功能、界面与 **Binance U 本位版**(目录 `crypto_monitor_binance`)基本一致,差异主要在 **`.env` 里交易所密钥与部分参数名**(`GATE_*` / `BINANCE_*`),文末有对照。 + +**更细的部署(SSH 代理、PM2、依赖安装)** 见同目录 **`部署文档.md`**。 +**关键位自动开仓的规则、RR、结案原因** 见 **`关键位自动下单说明.md`**。 + +--- + +## 1. 它能做什么 + +面向个人盘面的 **Web 控制台**,主要能力包括: + +| 模块 | 说明 | +|------|------| +| **关键位监控** | 录入上/下沿与类型,按 **5m 收线** 做硬条件过滤;符合条件后 **企业微信** 提醒,部分类型可 **自动市价开仓**(见第 4 节与专门文档)。 | +| **实盘下单监控** | 手工填止损/止盈,**以损定仓** 市价开单,挂上条件止盈止损,并在页面跟踪浮盈亏、保本逻辑等。 | +| **交易记录 / 复盘** | 平仓结果、盈亏、错过的单等归档与导出;可选 **AI 复盘**(见 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md))。 | +| **策略交易** | 顶栏 `/strategy`:趋势回调 + 顺势加仓双栏;见 [策略交易说明.md](../策略交易说明.md)。 | + +后台按 **`MONITOR_POLL_SECONDS`**(默认几秒)轮询行情与监控逻辑。**切勿**在未理解规则时同时运行两套程序共用一个实盘账户。 + +--- + +## 2. 运行前必须配置(`.env`) + +首次在本目录执行 **`cp .env.example .env`**,再编辑 `.env`(`.env` 勿提交 Git;`git pull` 不会改你的 `.env`,升级前建议 `cp .env .env.backup.$(date +%Y%m%d)`)。 + +至少检查以下项(具体键名以 **`.env.example`** 为准): + +| 类别 | 说明 | +|------|------| +| **登录网页** | `APP_PASSWORD`:打开站点后的登录口令。`FLASK_SECRET_KEY`:Session 密钥,请勿使用默认值。 | +| **企业微信** | `WECHAT_WEBHOOK`:告警与关键位推送机器人的 Webhook。 | +| **是否真下单** | `LIVE_TRADING_ENABLED=false`:**不会**向交易所发送开仓指令(适合测试流程)。改为 `true` 且密钥正确才会实盘。 | +| **交易所 API** | **本仓库:** `GATE_API_KEY`、`GATE_API_SECRET`;合约相关见 `GATE_MARGIN_MODE`、`GATE_POS_MODE`、`GATE_TPSL_*` 等。**勿**把 `.env` 提交到 Git。 | +| **关键位 RR / 止损外扩** | `KEY_AUTO_MIN_PLANNED_RR`、`KEY_STOP_OUTSIDE_BREAKOUT_PCT`(详见 `关键位自动下单说明.md`)。 | +| **AI 复盘** | `AI_PROVIDER=openai`(默认)或 `ollama`;变量见 `.env.example` 与 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)。 | + +网络不稳定时可为 Gate 配置 **`GATE_SOCKS_PROXY`** 等(见 **`部署文档.md`**)。 + +--- + +## 3. 如何启动与登录 + +1. 按 **`部署文档.md`** 建好虚拟环境、安装依赖(如 `flask`、`requests`、`ccxt`、按需 `Pillow`、`PySocks` 等),配置好 `.env`。 +2. 启动 Flask 应用(本仓库可用 **`ecosystem.config.cjs`** 交给 PM2,或本地 `python app.py` / `flask run`,以你当前脚本为准)。 +3. 浏览器访问站点,打开 **`/login`**,使用 **`.env` 里的 `APP_PASSWORD`** 登录。 + +登录后顶栏:**关键位监控** | **实盘下单** | **策略交易**(`/strategy`)| **策略交易记录**(`/strategy/records`)| **交易记录与复盘** | **统计分析**。 + +--- + +## 4. 关键位监控(顶栏「关键位监控」→ `/key_monitor`) + +### 4.1 添加一条关键位 + +1. **币种**:如 `BTC` 或 `BTC/USDT`(会规范成内部符号)。 +2. **类型**(必选其一): + + | 类型 | 行为摘要 | + |------|----------| + | **箱体突破** | 通过门控且计划 RR 达标 → **自动市价开仓**(需 `LIVE_TRADING_ENABLED=true` 且无其他持仓占位)。结案后本条从列表消失并记入历史。 | + | **收敛突破** | 同上(自动开仓类)。 | + | **关键阻力位** | **不自动开仓**;触发后 **发 1 次微信**,然后本条 **结案进历史**。 | + | **关键支撑位** | 同上(仅提醒)。 | + +3. **方向**:做多 / 做空(必选)。 +4. **上沿 / 下沿**:必填;保存时会按交易所 **价格精度** 取整。 + +**限制:** +活跃持仓数达到 **`MAX_ACTIVE_POSITIONS`**(默认 1)时,**不允许**再添加「**箱体突破** / **收敛突破**」;仍可添加「**关键阻力位 / 支撑位**」。 +若 **4h EMA55** 与你的方向逆势,页面会 **额外 Flash 提示**,**不阻挡**提交。 + +### 4.2 触发后会发生什么(简版) + +- **箱体 / 收敛**:门控通过后计算计划 SL/TP 与 RR;不达标则 **微信说明 + `rr_insufficient` 结案**;达标则尝试 **市价开仓**,成功 **`auto_opened`**,失败 **`exchange_failed`**——均 **不重试同一关键位**。 +- **阻力 / 支撑**:仅 **单次推送** → **`key_level_alert_only`** 结案。 + +详细公式、结案字段、与企业微信文案口径见 **`关键位自动下单说明.md`**。 + +### 4.3 列表与历史 + +- 当前条目可 **删除**(会按规则记入历史的情形见页面说明)。 +- **关键位历史**:已结案记录;可配合导出链接(若有)做备份。 + +--- + +## 5. 实盘下单(顶栏「实盘下单」→ `/trade`) + +用于 **自己点按钮** 开单: + +- 持仓上限由 **`MAX_ACTIVE_POSITIONS`** 控制(默认 1,与关键位自动单共用)。 +- **人工开仓**时计划盈亏比不得低于 **`MANUAL_MIN_PLANNED_RR`**(默认 1.4:1),否则页面弹窗且后端拒绝。 +- 填写币种、方向、杠杆(可选)、止损/止盈(价格或百分比按表单说明)。 +- 勾选是否启用 **移动保本** 等行为以 `.env`/页面默认值为准。 + +平仓通过页面 **平仓**(或等价入口),会从交易所市价处理并更新记录。**删除/误操作可能造成真实盈亏**,请先确认环境与方向。 + +开仓成功后持仓卡片上会显示 **「来源」**:手工单一般为 **下单监控**;来自关键位自动单的为 **关键位监控**。 + +--- + +## 6. 企业微信会看到什么 + +- 关键位:按类型与结案结果推送(RR 不足、下单失败、自动开仓成功、仅阻力支撑提醒等),**每条关键位结案路径原则上一条主推送**(详见 `关键位自动下单说明.md`)。 +- 手工开仓、平仓、部分异常也会在规则满足时推送(以代码与配置为准)。 + +若未配置 **`WECHAT_WEBHOOK`** 或网络失败,可能只是看不到推送,不代表逻辑未执行;要紧操作请以 **交易所端持仓与挂单** 为准核对。 + +--- + +## 7. 强烈建议的风险与运维习惯 + +1. **先用 `LIVE_TRADING_ENABLED=false`** 验证页面、录入、推送,再开小资金开实盘。 +2. **API 权限**:仅开所需合约权限;勿泄露密钥;定期轮换。 +3. **单进程控盘**:同一账户避免本程序与其他机器人 **重复开仓**。 +4. **自动备份**:服务器上执行 `bash scripts/install_backup_cron.sh`(每天北京时间 0:00 → `/root/backups`,保留 30 天);升级前也可 `bash scripts/backup_data.sh` 手动跑一次。 +5. **升级代码后**:启动时会跑 **数据库迁移**(如新列 `order_monitors.monitor_type`);首次启动关注一下日志或无报错页面。 + +--- + +## 8. 常见问题(简要) + +| 现象 | 可自查 | +|------|--------| +| 关键位永远不触发 | 5m 门控是否全通过(页面门控摘要)、币种日成交量是否在规则内、`KLINE_TIMEFRAME`。 | +| 有信号但不自动开仓 | `LIVE_TRADING_ENABLED`、`KEY_AUTO_MIN_PLANNED_RR`、计划 RR、是否已有持仓、API/余额报错(微信或日志)。 | +| 加不了箱体/收敛 | 是否已有活跃持仓;先平仓或改用「阻力/支撑位」仅提醒。 | +| 推送收不到 | `WECHAT_WEBHOOK`、企业微信机器人配额与网络。 | + +--- + +## 9. Binance 版(`crypto_monitor_binance`)差异速查 + +| 项目 | Gate 本仓库 | Binance 版 | +|------|-------------|------------| +| API 变量 | `GATE_API_KEY`、`GATE_API_SECRET`、`GATE_*` | `BINANCE_API_KEY`、`BINANCE_API_SECRET`、`BINANCE_*` | +| 实盘开关 | `LIVE_TRADING_ENABLED`(通用) | 同上 | +| 止盈止损挂载路径 | `_gate_place_tp_sl_orders` 与 `GATE_TPSL_*` | `_binance_place_tp_sl_orders`(U 本位条件单) | +| 资金显示舍入 | 以本仓库为准 | 与 **`FUNDS_DECIMALS`** 等一致 | +| 专门文档 | **`关键位自动下单说明.md`**(各仓库有一份,开头标明交易所) | 同左 | + +操作流程(登录、关键位四类、手工单、单仓)**两份程序一致**:换目录、换 `.env` 即可对照使用。 diff --git a/crypto_monitor_gate_bot/关键位自动下单说明.md b/crypto_monitor_gate_bot/关键位自动下单说明.md new file mode 100644 index 0000000..50dfb3d --- /dev/null +++ b/crypto_monitor_gate_bot/关键位自动下单说明.md @@ -0,0 +1,142 @@ +# 关键位监控说明(自动开仓 + 人工盯盘) + +**适用:`crypto_monitor_gate`(Gate U 本位永续)** +Binance / OKX 见各自目录下同名文档;共享逻辑在仓库根目录 `key_monitor_lib.py`。 + +本文档与 `.env`、`check_key_monitors`、`add_key`、`_key_hard_checks`、`_process_key_rs_level_alert` 一致。 + +--- + +## 一、监控类型总览 + +| 录入类型 | 录入时选方向 | 自动市价开仓 | 触发与结案 | +|----------|--------------|--------------|------------| +| **箱体突破** | **必选** 多/空 | **是**(门控 + RR) | 条件满足 → 开仓或 `rr_insufficient` / `exchange_failed` → **一次性删除** | +| **收敛突破** | **必选** 多/空 | **是**(同上) | 同上 | +| **关键阻力位** | **不选**(`direction=watch`) | **否** | 5m 收盘突破上/下沿 → 微信 **3 次** → `key_level_alert_done` | +| **关键支撑位** | **不选** | **否** | 同上(与阻力位**相同规则**:填上沿+下沿,程序双向监控) | +| 斐波回调 0.618 / 0.786 | 必选 | 限价挂单逻辑 | 见斐波说明(**不在下文展开**) | + +**添加时(所有类型):** 品种须 **日成交量排名前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30)**;上沿 **>** 下沿。 + +--- + +## 二、关键阻力位 / 关键支撑位(人工盯盘) + +### 2.1 录入 + +- 填写 **上沿 `upper`** 与 **下沿 `lower`**(程序同时监控两侧,**无法预先判定**做多还是做空)。 +- 页面 **不显示、不要求** 方向;库中 `direction` 初始为 `watch`,**首次突破后** 写入 `long`(向上突破上沿)或 `short`(向下突破下沿)。 + +### 2.2 触发(极简) + +- 周期:**`KLINE_TIMEFRAME`(默认 5m)最近一根已闭合 K** 的 **收盘价**(非影线)。 +- **向上突破上沿:** `收盘 > upper` → 推断方向 **多 / 向上**,本次监控任务开始按节奏提醒。 +- **向下突破下沿:** `收盘 < lower` → 推断方向 **空 / 向下**,本次任务同样开始提醒。 +- **任一侧突破即结束本条监控周期**(不会在突破后再等待另一侧;上沿、下沿谁先满足用谁,同根 K 仅可能满足一侧)。 + +**不参与:** 量能、二确 K、越过幅度下限、日成交排名(运行时)、计划 RR、自动开仓。 + +### 2.3 微信提醒次数 + +| 配置 | 默认 | 含义 | +|------|------|------| +| `KEY_ALERT_MAX_TIMES` | `3` | 突破后最多推送 3 次 | +| `KEY_ALERT_INTERVAL_MINUTES` | `5` | 相邻两次推送至少间隔 5 分钟 | + +- 第 1 次:首次检测到突破的当次轮询(若已闭合 5m 满足条件)。 +- 第 2、3 次:仅按间隔推送(**不要求**价格仍在箱外)。 +- 第 3 次推送后:写入 `key_monitor_history`,`close_reason=**key_level_alert_done**`,从 `key_monitors` **删除**。 + +### 2.4 与箱体/收敛的区别 + +| 项目 | 阻力/支撑 | 箱体/收敛 | +|------|-----------|-----------| +| 方向 | 程序推断 | 人工选择 | +| K 线根数 | 1 根闭合 5m | 2 根(突破 K + 确认 K) | +| 提醒次数 | 3 次后结案 | 自动单:触发后 1 次业务推送并结案 | + +--- + +## 三、箱体突破 / 收敛突破(自动开仓) + +### 3.1 K 线结构(默认索引) + +| 角色 | 环境变量 | 默认 | 含义 | +|------|----------|------|------| +| 突破 K | `KEY_CONFIRM_BREAKOUT_BAR` | `-2` | 倒数第 2 根闭合 K | +| 确认 K | `KEY_CONFIRM_BAR` | `-1` | 倒数第 1 根闭合 K | + +### 3.2 硬门控(须全部通过) + +1. **有效突破(收盘越界)** + - 多:`突破 K 收盘 > upper` + - 空:`突破 K 收盘 < lower` + +2. **突破越过幅度(仅下限)** + - 多:`(突破 K 收盘 − upper) / upper × 100 > KEY_BREAKOUT_AMP_MIN_PCT`(默认 **0.03%**) + - 空:`(lower − 突破 K 收盘) / lower × 100 >` 同上 + - **无上限**;突破过猛由 **计划 RR** 过滤。 + - **不再**使用 K 线实体占开盘价比例;`KEY_BREAKOUT_AMP_MAX_PCT` **已不参与门控**。 + +3. **确认 K 不进箱体** + - 多:确认 K 收盘 **`> upper`**(不得在 `[lower, upper]` 内) + - 空:确认 K 收盘 **`< lower`** + +4. **量能:** 突破 K 成交量 > 前 `KEY_VOLUME_MA_BARS`(默认 20)根均量 × `KEY_VOLUME_RATIO_MIN`(默认 1.3) + +5. **日成交量排名:** 运行时仍须前 `KEY_DAILY_VOLUME_RANK_MAX`(默认 30) + +6. **计划 RR(最后经济门控):** 按确认 K 收盘 **E** 计算 SL/TP 后,`RR` **严格大于** `KEY_AUTO_MIN_PLANNED_RR`(默认 1.5)才市价开仓 + +### 3.3 止损 / 止盈(确认 K 收盘为 E) + +箱体高 **H = |upper − lower|**。止损锚在 **突破 K 极值** 外侧: + +| 方向 | 止损(标准/趋势方案) | +|------|------------------------| +| 多 | 突破 K **最低价** × (1 − `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | +| 空 | 突破 K **最高价** × (1 + `KEY_STOP_OUTSIDE_BREAKOUT_PCT`%) | + +止盈方案见下表(与改版前一致): + +| 方案 | `sl_tp_mode` | 多:SL / TP | 空:SL / TP | +|------|--------------|-------------|-------------| +| 标准突破 | `standard` | 突破 K 低外侧% / **E+H** | 突破 K 高外侧% / **E−H** | +| 箱体 1R·止盈 1.5H | `box_1p5` | **E−H** / **E+1.5×H** | **E+H** / **E−1.5×H** | +| 趋势单·自填止盈 | `trend_manual` | 突破 K 低 × (1−`KEY_TREND_STOP_OUTSIDE_PCT`%) / **录入止盈** | 突破 K 高外侧% / **录入止盈** | + +### 3.4 一次性结案(`close_reason`) + +| `close_reason` | 含义 | +|----------------|------| +| `rr_insufficient` | 门控通过但 RR 不达标或 SL/TP 几何无效 | +| `exchange_failed` | RR 达标但实盘/交易所等原因未开仓 | +| `auto_opened` | RR 达标且市价开仓成功 | +| `key_level_alert_done` | 阻力/支撑 **3 次提醒** 完成 | + +--- + +## 四、环境与参数(`.env` 摘要) + +| 变量 | 箱体/收敛 | 阻力/支撑 | +|------|-----------|-----------| +| `KEY_BREAKOUT_AMP_MIN_PCT` | 突破越过下限(默认 0.03) | 不用 | +| `KEY_BREAKOUT_AMP_MAX_PCT` | **已废弃门控** | 不用 | +| `KEY_VOLUME_*` / `KEY_CONFIRM_*` | 用 | 不用 | +| `KEY_AUTO_MIN_PLANNED_RR` | 用 | 不用 | +| `KEY_ALERT_MAX_TIMES` / `KEY_ALERT_INTERVAL_MINUTES` | 不用 | 用(默认 3 次 / 5 分钟) | +| `KEY_DAILY_VOLUME_RANK_MAX` | 添加时 + 运行时 | **仅添加时** | + +--- + +## 五、相关代码 + +| 说明 | 位置 | +|------|------| +| 共享判定 | `key_monitor_lib.py` | +| 主循环 | `check_key_monitors` | +| 自动门控 | `_key_hard_checks` | +| 阻力/支撑提醒 | `_process_key_rs_level_alert` | +| 录入 | `add_key` | +| 开仓 | `_market_open_for_key_monitor` | diff --git a/crypto_monitor_gate_bot/更新文档.md b/crypto_monitor_gate_bot/更新文档.md new file mode 100644 index 0000000..880a159 --- /dev/null +++ b/crypto_monitor_gate_bot/更新文档.md @@ -0,0 +1,148 @@ +# 界面与风控更新说明(Gate 实例) + +## 顶栏导航(4 项) + +| 顺序 | 名称 | 路由 | 说明 | +|------|------|------|------| +| 1 | 关键位监控 | `/key_monitor` | 关键位添加、实时门控、历史 | +| 2 | 实盘下单 | `/trade` | 人工开仓、划转、实时持仓(**默认首页** `/` → `/trade`) | +| 3 | 交易记录与复盘 | `/records` | 交易记录、复盘表单、AI 历史(受顶栏 UTC 时间窗筛选) | +| 4 | 统计分析 | `/stats` | 按北京时间交易日切日 + 分品类统计块 | + +## 关键位监控页 + +- 标题去掉「5m」;规则条从 `.env` 读取(周期、确认K、量能、自动开仓盈亏比、日成交量排名)。 +- 左列:活跃关键位,**pos-card** 样式展示现价/距上沿/距下沿/门控。 +- 右列:关键位历史(失效/结案),与左列等高滚动;**受顶栏 UTC 列表时间窗筛选**(默认 UTC 当日)。 +- 监控类型新增:**斐波回调0.618**、**斐波回调0.786**(与 Binance 主站同一套规则,计算逻辑见仓库根目录 `fib_key_monitor_lib.py`)。 + +### 斐波关键位监控(方案 A:交易所限价) + +| 项 | 说明 | +|----|------| +| 同币互斥 | 每个币种只能有一条斐波监控(0.618 与 0.786 不可并存) | +| 上下沿 | 上沿 **H**、下沿 **L**(须 H > L) | +| 挂单价 E | **做多** `E = H − ratio × (H − L)`(自 H 向下回撤);**做空** `E = L + ratio × (H − L)`(自 L 向上反弹) | +| 做多 | 限价 @ E,止损 L,止盈 H | +| 做空 | 限价 @ E,止损 H,止盈 L | +| 添加后 | **立即**在 Gate 挂限价单;卡片显示 **挂E**、限价单 ID | +| 失效 | 以**标记价**判断:做多且标记价 ≥ H、做空且标记价 ≤ L,且限价**未成交** → 撤销该限价单并结案(不写历史开仓) | +| 成交后 | 按仓位挂交易所 TP/SL → 写入 **实盘下单监控**(`monitor_type=关键位监控`,`key_signal_type=斐波回调0.618/0.786`)→ 从关键位列表移除 | +| 撤单 | 仅撤本条斐波的 `fib_limit_order_id`,**不会** `cancel_all`,避免误伤其他委托 | +| 盈亏比 | 计划 RR 须 > `KEY_AUTO_MIN_PLANNED_RR`(与箱体/收敛一致);0.618 理论约 1.6:1,0.786 约 3.7:1 | +| 日成交量 | 与箱体/收敛相同,须在前 `KEY_DAILY_VOLUME_RANK_MAX` 名内方可添加 | + +后台轮询:`check_fib_key_monitors()`(标记价失效 / 成交检测);箱体/收敛仍走 `check_key_monitors()`,互不干扰。 + +手动删除关键位时,若斐波限价尚未成交,会先撤交易所限价再删库记录。 + +### 箱体 / 收敛自动开仓(来源标注) + +- 自动开仓写入 `order_monitors.key_signal_type`:`箱体突破` 或 `收敛突破`。 +- 持仓卡片、交易记录列表会显示「来源 · 信号类型」。 + +## 列表时间窗(UTC,全站顶栏) + +共用模块:仓库根目录 `history_window_lib.py`(Gate / Binance 主站一致)。 + +| 项 | 说明 | +|----|------| +| 默认 | **UTC 当日**(`win_preset=utc_today`,从 UTC 0:00 至当前时刻) | +| 可选 | 近 24 小时、近 7 天、自定义起止(UTC,`datetime-local`) | +| 作用范围 | 关键位历史、交易记录列表、复盘记录 API、AI 历史 API、导出「交易记录」「关键位历史」 | +| 与统计的关系 | **仅影响列表/导出**;**统计分析页仍按北京时间 `TRADING_DAY_RESET_HOUR`(默认 8:00)切交易日** | +| 库内时间 | DB 存北京时间字符串;后端用 `utc_window_to_bj_sql_strings()` 换算后再 SQL 比较 | +| 切换方式 | 顶栏「列表筛选(UTC)」→ 选预设 → **应用**(保留当前路由,如 `/records?win_preset=…`) | + +查询参数示例: + +- `?win_preset=utc_today` +- `?win_preset=utc_last24h` / `utc_last7d` +- `?win_preset=custom&from_utc=2026-05-18 00:00:00&to_utc=2026-05-19 12:00:00` + +## 交易记录与复盘 + +- 平仓记录可同步交易所已实现盈亏(Gate 仓位历史等);列表盈亏列优先显示交易所数据,标注 **所** / **估**。 +- 记录页提供 **立即同步**(`POST /api/sync_exchange_pnl`),用于补全或刷新 `exchange_realized_pnl` 等字段。 +- 未做人工复盘时,展示以交易所盈亏为准(有同步数据时)。 +- **列表默认只显示当前 UTC 时间窗内**的记录(见上节);导出 CSV 同步该时间窗。 +- 表头 **「止损(开仓)」**:展示开仓快照 `initial_stop_loss`(无则回退 `stop_loss`);核对/复盘仍可用有效止损字段。 +- 平仓写入 `trade_records` 时:`stop_loss` 与 `initial_stop_loss` 均写入**开仓时止损快照**;`key_signal_type` 保留箱体/收敛/斐波来源(`fib_key_monitor_lib.key_signal_type_for_trade_record`)。 +- **开仓类型**(`entry_reason`):机器单平仓入库时,若未手填,按 `key_signal_type` 自动映射(见下表);列表/导出「开仓类型」列 = 复盘核对值优先,否则入库值,否则按信号映射。 + +| `key_signal_type` | 自动写入的 `entry_reason` | +|-------------------|---------------------------| +| 箱体突破 | 关键位箱体突破 | +| 收敛突破 | 关键位收敛突破 | +| 斐波回调0.618 | 关键位斐波0.618 | +| 斐波回调0.786 | 关键位斐波0.786 | + +- 复盘表单 **开仓类型** 下拉新增上述四条固定文案(与趋势/波段类并列)。 +- 复盘 **离场触发** 新增 **「止盈」**;从交易记录「填入复盘」时,若结果为「止盈/保本止盈/移动止盈/止损/手动平仓」会自动选中对应触发项,并按 `key_signal_type` 预填开仓类型。 +- 勾选「保存时自动生成多周期 K 线图」时:以 **平仓时间** 为锚点,各周期向前约 `ORDER_CHART_LIMIT`(默认 100)根 K 线(`_fetch_ohlcv_ending_at`),不再固定拉「最近 100 根」。 +- `/api/journals`、`/api/reviews` 支持同一时间窗 query,与列表一致。 + +### 导出(交易记录 v3) + +- 文件名:`trade_records_v3_YYYYMMDD.csv` +- 相对 v2 增加:`key_signal_type`、`initial_stop_loss`(及开仓快照列)、`planned_rr`、`actual_rr`、`risk_amount`、交易所盈亏与时间字段等;末列「开仓类型」为有效展示文案。 +- 「关键位历史」导出同样受 UTC 时间窗限制。 + +## 实盘下单页 + +- 左列:实盘下单监控(表单、划转、规则)。 +- 右列:实时持仓(独立模块)。 +- **人工开仓门控**:计划盈亏比 < `MANUAL_MIN_PLANNED_RR`(默认 **1.4**)时前端弹窗 + 后端拒绝。 +- **移动保本**(勾选启用):监控轮询达到触发 RR 后,止损阶梯上移时**同步交易所**——调用与页面「挂止盈止损」相同的 **先撤后挂**(`replace_active_monitor_tpsl_on_exchange`:撤该合约全部 TP/SL 条件单 → 按新止损 + 原止盈重挂)。仅交易所成功后才写库;失败发企业微信告警,本地止损不变。未配置实盘 API 时仍只更新本地(与旧行为一致)。 + +## 统计分析页(`/stats`) + +| 项 | 说明 | +|----|------| +| 切日 | **北京时间**;交易日边界 = 每日 `TRADING_DAY_RESET_HOUR:00`(`.env` 默认 **8**) | +| 品类下拉 | 页顶 **「统计品类」** 下拉切换(默认「全部交易」):全部交易、下单监控、关键位箱体突破、关键位收敛结构、关键位斐波0.618、关键位斐波0.786;一次只显示所选品类的日/周/月 | +| URL | 切换后写入 `stats_segment=`(如 `all`、`manual`、`key_box`、`key_conv`、`key_fib618`、`key_fib786`),刷新 `/stats` 可保持选项 | +| 每块指标 | 日 / 周 / 月:开单次数、平仓笔数、胜率、净盈亏、回撤、连续亏损等(与原口径一致) | +| 开单次数 | 人工块:`monitor_type=下单监控` 且无 `key_signal_type`;关键位块:按 `order_monitors.key_signal_type` 计数 | +| 不受 UTC 窗影响 | 统计始终基于库内全部已平仓记录,按北京交易日归类,**不**随顶栏 UTC 列表窗切换 | + +## 持仓与计仓 + +- `MAX_ACTIVE_POSITIONS` 默认 **1**(可在 `.env` 调大)。 +- 关键位自动开仓:在已有持仓时,若 `KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true`,按**首笔开仓前**交易账户资金快照计仓(`trading_sessions.key_sizing_capital_snapshot`)。 + +## 配置 + +详见 `.env.example` 中「关键位门控」「交易执行 / 人工风控」注释段。Gate 专用项(`GATE_*`、止盈止损触发等)保持原有段落不变。 + +## 自动备份(服务器) + +- 脚本:`scripts/backup_data.sh`(`crypto.db` + `static/images`) +- 定时:`scripts/install_backup_cron.sh` → 每天 **北京时间 0:00**,目录 **`/root/backups/<实例名>/YYYY-MM-DD/`**,保留 **30** 天 +- 详见 `部署文档.md` 第 5.4 节(自动备份) + +## 数据库(启动时自动迁移) + +`key_monitors` 新增斐波字段(示例):`fib_limit_order_id`、`fib_entry_price`、`fib_stop_loss`、`fib_take_profit`、`fib_order_amount`、`fib_margin_capital`、`fib_leverage`。 + +`trade_records` / `order_monitors` 新增或沿用:`key_signal_type`、`exchange_realized_pnl`、`exchange_opened_at`、`exchange_closed_at`、`exchange_sync_key`、`entry_reason`、`reviewed_entry_reason`、`initial_stop_loss`。 + +**历史数据**:本次**不做**旧记录的批量回填(`entry_reason` / `initial_stop_loss` / `key_signal_type` 等);仅**新产生**的平仓与复盘按新逻辑写入。旧行展示可回退已有字段。 + +## 涉及文件(便于排查) + +| 路径 | 说明 | +|------|------| +| `history_window_lib.py` | UTC 时间窗解析与转北京时间 SQL 字符串 | +| `fib_key_monitor_lib.py` | 斐波计算、`KEY_ENTRY_REASON_BY_SIGNAL`、`entry_reason_from_key_signal` | +| `crypto_monitor_gate/app.py` | 列表筛选、统计分块、导出 v3、复盘 K 线锚点、入库逻辑 | +| `crypto_monitor_gate/templates/index.html` | 顶栏时间窗、统计分块 UI、止损(开仓)列、复盘预填 | + +## 升级步骤 + +1. `git pull` 后对比 `.env.example`,把新增变量合并进本地 `.env`。 +2. 在 VPS 上为 Binance / Gate / Gate Bot **各执行一次** `bash scripts/install_backup_cron.sh`(若尚未安装)。 +3. 重启 Gate 实例服务(如 `pm2 restart crypto_gate`);首次启动会自动 `ALTER TABLE` 缺列(斐波、交易所盈亏、`entry_reason` 等)。 +4. 浏览器强刷(Ctrl+F5)避免旧版 `index.html` 缓存。 +5. 打开任意页确认顶栏出现 **「列表筛选(UTC)」**;`/stats` 可见分品类统计与「北京 8:00 切日」说明。 +6. 建议在测试币上先添加一条斐波监控,确认:限价已挂出、标记价失效会撤单、成交后出现持仓监控且 TP/SL 已挂上;平仓后交易记录止损(开仓)与开仓类型是否正确。 diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index 1bd8502..e34434b 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -48,7 +48,7 @@ DEFAULT_EXCHANGES = [ "flask_url": "http://127.0.0.1:5002", "review_url": "http://127.0.0.1:5002/records", "enabled": True, - "capabilities": ["trend"], + "capabilities": ["key", "trend"], }, ]