From 88b7fb7a0af5099b4d8b188c1316e416c58d3cfd Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 14 Oct 2026 00:18:40 +0800 Subject: [PATCH] Initial CoinOX instance: coin-margined options + USDT hedge. Forked from crypto_monitor OKX instance (copy-only). Keeps standalone options, perp-options and options-options hedge; drops standalone perp entrypoints; forces coin margin; adds sim wallets; default port 5010. Co-authored-by: Cursor --- .env.example | 338 + .gitattributes | 3 + .gitignore | 31 + README.md | 47 + app.py | 9929 +++++++++++++++++ brand/icon.svg | 17 + brand/icons/apple-touch-icon.png | Bin 0 -> 1743 bytes brand/icons/binance/apple-touch-icon.png | Bin 0 -> 1263 bytes brand/icons/binance/favicon.ico | Bin 0 -> 150 bytes brand/icons/binance/icon-16.png | Bin 0 -> 127 bytes brand/icons/binance/icon-192.png | Bin 0 -> 1424 bytes brand/icons/binance/icon-32.png | Bin 0 -> 301 bytes brand/icons/binance/icon-48.png | Bin 0 -> 389 bytes brand/icons/binance/icon-512.png | Bin 0 -> 4991 bytes brand/icons/binance/icon.svg | 6 + brand/icons/favicon.ico | Bin 0 -> 181 bytes brand/icons/gate/apple-touch-icon.png | Bin 0 -> 2763 bytes brand/icons/gate/favicon.ico | Bin 0 -> 287 bytes brand/icons/gate/icon-16.png | Bin 0 -> 265 bytes brand/icons/gate/icon-192.png | Bin 0 -> 2990 bytes brand/icons/gate/icon-32.png | Bin 0 -> 507 bytes brand/icons/gate/icon-48.png | Bin 0 -> 747 bytes brand/icons/gate/icon-512.png | Bin 0 -> 8836 bytes brand/icons/gate/icon.svg | 6 + brand/icons/icon-16.png | Bin 0 -> 162 bytes brand/icons/icon-180.png | Bin 0 -> 3353 bytes brand/icons/icon-192.png | Bin 0 -> 1796 bytes brand/icons/icon-32.png | Bin 0 -> 497 bytes brand/icons/icon-48.png | Bin 0 -> 612 bytes brand/icons/icon-512.png | Bin 0 -> 6059 bytes brand/icons/icon.svg | 17 + brand/icons/okx/apple-touch-icon.png | Bin 0 -> 1032 bytes brand/icons/okx/favicon.ico | Bin 0 -> 133 bytes brand/icons/okx/icon-16.png | Bin 0 -> 108 bytes brand/icons/okx/icon-192.png | Bin 0 -> 1165 bytes brand/icons/okx/icon-32.png | Bin 0 -> 254 bytes brand/icons/okx/icon-48.png | Bin 0 -> 290 bytes brand/icons/okx/icon-512.png | Bin 0 -> 3920 bytes brand/icons/okx/icon.svg | 6 + brand/manifest.binance.webmanifest | 23 + brand/manifest.exchange.webmanifest | 23 + brand/manifest.gate.webmanifest | 23 + brand/manifest.okx.webmanifest | 23 + brand/manifest.webmanifest | 23 + docs/OKX单笔期权-币本位与USDT桥-开发方案.md | 279 + docs/env配置说明.md | 231 + docs/lib-structure.md | 147 + docs/position-sizing-mode.md | 73 + docs/对冲计划-以期权为主.md | 71 + docs/对冲计划-选约与虚实值.md | 76 + docs/对冲计划开发方案.md | 695 ++ docs/对冲计划策略与P0校验.md | 109 + docs/期权开平仓与监控说明.md | 104 + docs/期权方案.md | 78 + docs/期权用法.md | 107 + ecosystem.config.cjs | 25 + lib/__init__.py | 1 + lib/account_ledger/__init__.py | 1 + lib/account_ledger/account_ledger_db.py | 186 + .../account_ledger_normalize.py | 192 + lib/account_ledger/account_ledger_register.py | 208 + lib/account_ledger/account_ledger_sync.py | 252 + .../templates/account_ledger_panel.html | 72 + lib/ai/__init__.py | 1 + lib/ai/ai_client.py | 540 + lib/ai/ai_review_lib.py | 178 + lib/common/__init__.py | 1 + lib/common/auto_transfer_daily_lib.py | 130 + lib/common/flask_access_log_lib.py | 16 + lib/common/form_submit_lib.py | 51 + lib/common/history_window_lib.py | 187 + lib/common/static/account_ledger.js | 337 + lib/common/static/account_risk_badge.css | 150 + lib/common/static/account_risk_badge.js | 120 + lib/common/static/ai_review_render.js | 223 + lib/common/static/autofill_guard.js | 148 + lib/common/static/focus_chart_page.css | 221 + lib/common/static/focus_chart_page.js | 401 + lib/common/static/form_submit_guard.js | 81 + lib/common/static/hedge_plan.js | 2791 +++++ lib/common/static/instance_dashboard.js | 508 + lib/common/static/instance_embed.js | 595 + lib/common/static/instance_live.js | 111 + lib/common/static/instance_mobile_nav.js | 177 + lib/common/static/instance_page.css | 332 + lib/common/static/instance_records_mobile.js | 74 + lib/common/static/instance_settings_prefs.js | 632 ++ lib/common/static/instance_stats.js | 117 + lib/common/static/instance_theme.css | 6365 +++++++++++ lib/common/static/instance_theme.js | 570 + lib/common/static/instance_theme_early.css | 54 + lib/common/static/instance_ui.js | 456 + lib/common/static/journal_form_save.js | 175 + lib/common/static/journal_upload_slots.js | 153 + lib/common/static/key_monitor_form.js | 160 + lib/common/static/manual_order_rr_preview.js | 340 + lib/common/static/open_submit_gate.js | 55 + lib/common/static/options_expiry_countdown.js | 58 + lib/common/static/options_panel.js | 3109 ++++++ lib/common/static/options_position_cards.js | 344 + lib/common/static/options_review.js | 1484 +++ lib/common/static/options_settings.js | 312 + lib/common/static/order_entry_model.js | 214 + lib/common/static/records_review_page.js | 641 ++ lib/common/static/strategy_roll.js | 318 + lib/common/static/symbol_live_price.js | 169 + lib/common/static/time_close_ui.js | 194 + lib/common/static/trade_stats_calendar.css | 171 + lib/common/static/trade_stats_calendar.js | 314 + lib/common/wechat_notify_lib.py | 117 + lib/env/env_file_lib.py | 121 + lib/env/env_schema.py | 434 + lib/env/env_ui_manifest.py | 636 ++ lib/env/shared_env_lib.py | 237 + lib/exchange/__init__.py | 1 + lib/exchange/api_credentials_lib.py | 94 + lib/exchange/binance_ledger_lib.py | 187 + lib/exchange/gate_ccxt_lib.py | 9 + lib/exchange/gate_ledger_lib.py | 212 + lib/exchange/gate_position_history_lib.py | 66 + lib/exchange/gate_transfer_lib.py | 56 + lib/exchange/okx_ledger_lib.py | 99 + lib/exchange/okx_options_lib.py | 1877 ++++ lib/exchange/okx_orders_lib.py | 116 + lib/hedge_plan/__init__.py | 1 + lib/hedge_plan/hedge_options_exclusive_lib.py | 86 + lib/hedge_plan/hedge_plan_calc_lib.py | 782 ++ lib/hedge_plan/hedge_plan_db.py | 468 + lib/hedge_plan/hedge_plan_moneyness_lib.py | 285 + lib/hedge_plan/hedge_plan_monitor_lib.py | 1439 +++ lib/hedge_plan/hedge_plan_notify_lib.py | 216 + .../hedge_plan_option_primary_lib.py | 527 + lib/hedge_plan/hedge_plan_orders_lib.py | 1413 +++ lib/hedge_plan/hedge_plan_register.py | 1434 +++ lib/hedge_plan/hedge_plan_settle_lib.py | 217 + lib/hedge_plan/okx_trade_mode_lib.py | 103 + .../templates/hedge_plan_panel.html | 408 + lib/hub/__init__.py | 1 + lib/hub/amp_stats_lib.py | 888 ++ lib/hub/hub_auth.py | 36 + lib/hub/hub_backup_lib.py | 447 + lib/hub/hub_bridge.py | 1187 ++ lib/hub/hub_calculator_lib.py | 514 + lib/hub/hub_calculator_market_lib.py | 257 + lib/hub/hub_compare_lib.py | 400 + lib/hub/hub_divergence_scan_lib.py | 465 + lib/hub/hub_entry_plan_lib.py | 453 + lib/hub/hub_fund_history_lib.py | 470 + lib/hub/hub_help_lib.py | 53 + lib/hub/hub_host_status_lib.py | 98 + lib/hub/hub_kline_store.py | 881 ++ lib/hub/hub_macro_calendar_lib.py | 311 + lib/hub/hub_market_info_lib.py | 81 + lib/hub/hub_monitor_totals_lib.py | 136 + lib/hub/hub_ohlcv_lib.py | 692 ++ lib/hub/hub_options_archive_lib.py | 599 + lib/hub/hub_options_funds_lib.py | 219 + lib/hub/hub_order_sync_lib.py | 114 + lib/hub/hub_perp_options_calc_lib.py | 387 + lib/hub/hub_poll_wait_lib.py | 45 + lib/hub/hub_position_metrics.py | 310 + lib/hub/hub_reconcile_flat_lib.py | 95 + lib/hub/hub_sso.py | 166 + lib/hub/hub_strategy_lib.py | 542 + lib/hub/hub_symbol_archive_lib.py | 1718 +++ lib/hub/hub_symbol_lib.py | 38 + lib/hub/hub_system_logs_lib.py | 172 + lib/hub/hub_trades_lib.py | 742 ++ lib/hub/hub_volume_rank_lib.py | 595 + lib/hub/price_snapshot_lib.py | 124 + lib/instance/__init__.py | 1 + lib/instance/focus_chart_lib.py | 187 + lib/instance/instance_dashboard_cache.py | 175 + lib/instance/instance_dashboard_lib.py | 564 + lib/instance/instance_dashboard_register.py | 78 + lib/instance/instance_display_prefs_lib.py | 146 + lib/instance/instance_embed_context_lib.py | 251 + lib/instance/instance_embed_lib.py | 238 + lib/instance/instance_live_pnl_lib.py | 127 + lib/instance/instance_live_push_lib.py | 122 + lib/instance/instance_nav_lib.py | 19 + lib/instance/instance_pm2_lib.py | 74 + lib/instance/instance_settings_lib.py | 242 + lib/instance/instance_settings_register.py | 194 + lib/instance/instance_system_guide_lib.py | 69 + lib/instance/journal_chart_async_lib.py | 93 + lib/instance/journal_chart_lib.py | 452 + lib/instance/journal_form_lib.py | 54 + lib/instance/journal_images_lib.py | 208 + lib/instance/journal_upload_api_lib.py | 43 + lib/instance/records_api_register.py | 66 + lib/instance/records_list_lib.py | 72 + lib/instance/runtime_config_lib.py | 62 + lib/instance/runtime_settings_lib.py | 71 + lib/instance/templates/dashboard_panel.html | 15 + .../templates/display_prefs_panel.html | 28 + .../templates/embed_boot_scripts.html | 1630 +++ .../templates/embed_page_fragment.html | 352 + lib/instance/templates/embed_shell.html | 178 + lib/instance/templates/env_config_panel.html | 108 + .../templates/force_close_header_badge.html | 8 + .../templates/force_close_order_badge.html | 8 + lib/instance/templates/index.html | 2159 ++++ .../templates/instance_header_panel.html | 57 + .../templates/instance_header_stats.html | 53 + .../templates/instance_theme_toggle.html | 12 + lib/instance/templates/instance_top_bar.html | 4 + .../templates/instance_transfer_panel.html | 28 + lib/instance/templates/login.html | 150 + .../templates/order_entry_model_fields.html | 72 + .../templates/order_leverage_fields.html | 7 + .../templates/order_monitor_open_form.html | 77 + .../templates/password_settings_panel.html | 13 + lib/instance/templates/records_panel.html | 153 + lib/instance/templates/risk_policy_panel.html | 39 + lib/instance/templates/settings_panel.html | 77 + .../templates/system_guide_panel.html | 87 + lib/key_monitor/__init__.py | 1 + .../false_breakout_key_monitor_lib.py | 145 + lib/key_monitor/fib_key_monitor_lib.py | 140 + lib/key_monitor/key_auto_order_lib.py | 164 + .../key_monitor_full_margin_lib.py | 61 + lib/key_monitor/key_monitor_lib.py | 390 + lib/key_monitor/key_monitor_schema_lib.py | 15 + lib/key_monitor/key_sl_tp_lib.py | 139 + .../trigger_entry_key_monitor_lib.py | 324 + lib/options/__init__.py | 0 lib/options/options_close_exec_lib.py | 442 + lib/options/options_close_gate_lib.py | 334 + lib/options/options_coin_open_lib.py | 484 + lib/options/options_dashboard_lib.py | 85 + lib/options/options_db.py | 156 + lib/options/options_history_lib.py | 149 + lib/options/options_hub_lib.py | 185 + lib/options/options_margin_mode_lib.py | 315 + lib/options/options_monitor_lib.py | 483 + lib/options/options_notify_lib.py | 442 + lib/options/options_pending_lib.py | 124 + lib/options/options_position_limit_lib.py | 144 + lib/options/options_positions_lib.py | 213 + lib/options/options_pricing_lib.py | 719 ++ lib/options/options_profit_exit_lib.py | 435 + lib/options/options_register.py | 2088 ++++ lib/options/options_review_chart_async_lib.py | 137 + lib/options/options_review_db.py | 147 + lib/options/options_review_images_lib.py | 144 + lib/options/options_review_lib.py | 1199 ++ lib/options/options_review_register.py | 452 + lib/options/options_spot_bridge_lib.py | 416 + lib/options/options_stats_lib.py | 192 + lib/options/options_target_lib.py | 546 + lib/options/options_time_lib.py | 181 + lib/options/templates/options_panel.html | 383 + .../templates/options_review_panel.html | 450 + .../templates/options_settings_panel.html | 3 + .../templates/options_settings_swap.html | 16 + .../templates/options_settings_transfer.html | 24 + lib/paths.py | 33 + lib/sim/__init__.py | 11 + lib/sim/broker_lib.py | 1349 +++ lib/sim/db_lib.py | 142 + lib/sim/hooks.py | 566 + lib/sim/mode_lib.py | 88 + lib/sim/pricing_lib.py | 240 + lib/sim/register.py | 179 + lib/sim/templates/sim_funds_panel.html | 168 + lib/sim/wallets_lib.py | 397 + lib/strategy/__init__.py | 1 + lib/strategy/strategy_config.py | 232 + lib/strategy/strategy_db.py | 164 + lib/strategy/strategy_exchange_base.py | 48 + lib/strategy/strategy_exchange_binance.py | 4 + lib/strategy/strategy_exchange_gate.py | 9 + lib/strategy/strategy_exchange_okx.py | 4 + lib/strategy/strategy_records_register.py | 72 + lib/strategy/strategy_register.py | 654 ++ lib/strategy/strategy_roll_lib.py | 392 + lib/strategy/strategy_roll_monitor_lib.py | 562 + lib/strategy/strategy_roll_ui_lib.py | 434 + lib/strategy/strategy_snapshot_lib.py | 529 + lib/strategy/strategy_trade_labels.py | 192 + lib/strategy/strategy_trend_exchange.py | 97 + lib/strategy/strategy_trend_lib.py | 701 ++ lib/strategy/strategy_trend_register.py | 1972 ++++ lib/strategy/strategy_ui.py | 143 + lib/strategy/strategy_wechat_notify.py | 192 + .../templates/gate_transfer_block.html | 26 + .../templates/journal_form_fields.html | 44 + .../templates/journal_upload_slots.html | 20 + lib/strategy/templates/key_focus_v2.html | 182 + lib/strategy/templates/key_monitor_panel.html | 330 + .../templates/key_monitor_rule_tips.html | 59 + lib/strategy/templates/order_focus_v2.html | 151 + .../order_monitor_rule_tips_binance.html | 38 + .../order_monitor_rule_tips_gate.html | 38 + .../order_monitor_rule_tips_gate_bot.html | 21 + .../order_monitor_rule_tips_okx.html | 38 + .../templates/order_plan_preview_bar.html | 5 + .../templates/strategy_records_page.html | 284 + lib/strategy/templates/strategy_roll.html | 19 + .../templates/strategy_roll_docs.html | 23 + .../templates/strategy_roll_panel.html | 106 + lib/strategy/templates/strategy_subnav.html | 4 + .../templates/strategy_trading_page.html | 47 + .../templates/strategy_trend_disabled.html | 20 + .../strategy_trend_disabled_panel.html | 16 + .../templates/strategy_trend_panel.html | 207 + .../templates/symbol_live_price_snippet.html | 10 + .../templates/trade_policy_fields.html | 35 + lib/trade/__init__.py | 1 + lib/trade/account_risk_lib.py | 912 ++ lib/trade/compensating_close_lib.py | 16 + lib/trade/daily_open_limit_lib.py | 140 + lib/trade/entry_model_lib.py | 509 + lib/trade/force_close_lib.py | 366 + lib/trade/manual_sltp_lib.py | 136 + lib/trade/open_trade_gate_lib.py | 57 + lib/trade/order_monitor_display_lib.py | 452 + lib/trade/position_sizing_lib.py | 136 + lib/trade/time_close_lib.py | 150 + lib/trade/trade_exchange_stats_lib.py | 229 + lib/trade/trade_fee_lib.py | 94 + lib/trade/trade_policy_app_lib.py | 57 + lib/trade/trade_policy_lib.py | 205 + lib/trade/trade_result_lib.py | 120 + lib/trade/trade_stats_calendar_lib.py | 115 + requirements.txt | 9 + scripts/backup_data.sh | 109 + scripts/fix_breakeven_labels.py | 108 + scripts/install_backup_cron.sh | 38 + scripts/verify_okx_funding.py | 52 + static/icons/apple-touch-icon.png | Bin 0 -> 1032 bytes static/icons/favicon.ico | Bin 0 -> 133 bytes static/icons/icon-16.png | Bin 0 -> 108 bytes static/icons/icon-192.png | Bin 0 -> 1165 bytes static/icons/icon-32.png | Bin 0 -> 254 bytes static/icons/icon-512.png | Bin 0 -> 3920 bytes static/icons/icon.svg | 6 + static/icons/manifest.webmanifest | 23 + templates/key_focus.html | 1 + templates/order_focus.html | 195 + tests/test_hedge_exchange_pnl.py | 130 + tests/test_hedge_options_exclusive.py | 103 + tests/test_hedge_partial_manual.py | 45 + tests/test_hedge_plan_calc.py | 236 + tests/test_hedge_plan_end.py | 140 + tests/test_hedge_plan_history_stats.py | 186 + tests/test_hedge_plan_moneyness.py | 118 + tests/test_hedge_plan_notify_settle.py | 220 + tests/test_hedge_plan_option_primary.py | 195 + tests/test_hedge_plan_orders.py | 240 + tests/test_hedge_po_monitor_safety.py | 101 + tests/test_hedge_start_refresh.py | 125 + tests/test_okx_funding_balances.py | 39 + tests/test_okx_position_metrics.py | 38 + tests/test_okx_spot_swap.py | 60 + tests/test_okx_trade_mode.py | 95 + tests/test_options_add_premium.py | 69 + tests/test_options_btc_pnl_usdt.py | 44 + tests/test_options_budget_full.py | 98 + tests/test_options_close_gate_lib.py | 191 + tests/test_options_history_coin.py | 50 + tests/test_options_hub_lib.py | 54 + tests/test_options_margin_mode.py | 126 + tests/test_options_net_pnl_sum.py | 71 + tests/test_options_notify_lib.py | 86 + tests/test_options_pending_lib.py | 63 + tests/test_options_position_limit.py | 101 + tests/test_options_pricing.py | 464 + tests/test_options_profit_exit.py | 65 + tests/test_options_profit_exit_recycle.py | 39 + tests/test_options_review_chart_async_lib.py | 157 + tests/test_options_review_lib.py | 408 + tests/test_options_stats_lib.py | 104 + tests/test_options_sync.py | 310 + tests/test_options_target_lib.py | 169 + tests/test_options_time_lib.py | 92 + 使用说明.md | 138 + 关键位自动下单说明.md | 192 + 更新文档.md | 98 + 部署文档.md | 367 + 381 files changed, 104628 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 brand/icon.svg create mode 100644 brand/icons/apple-touch-icon.png create mode 100644 brand/icons/binance/apple-touch-icon.png create mode 100644 brand/icons/binance/favicon.ico create mode 100644 brand/icons/binance/icon-16.png create mode 100644 brand/icons/binance/icon-192.png create mode 100644 brand/icons/binance/icon-32.png create mode 100644 brand/icons/binance/icon-48.png create mode 100644 brand/icons/binance/icon-512.png create mode 100644 brand/icons/binance/icon.svg create mode 100644 brand/icons/favicon.ico create mode 100644 brand/icons/gate/apple-touch-icon.png create mode 100644 brand/icons/gate/favicon.ico create mode 100644 brand/icons/gate/icon-16.png create mode 100644 brand/icons/gate/icon-192.png create mode 100644 brand/icons/gate/icon-32.png create mode 100644 brand/icons/gate/icon-48.png create mode 100644 brand/icons/gate/icon-512.png create mode 100644 brand/icons/gate/icon.svg create mode 100644 brand/icons/icon-16.png create mode 100644 brand/icons/icon-180.png create mode 100644 brand/icons/icon-192.png create mode 100644 brand/icons/icon-32.png create mode 100644 brand/icons/icon-48.png create mode 100644 brand/icons/icon-512.png create mode 100644 brand/icons/icon.svg create mode 100644 brand/icons/okx/apple-touch-icon.png create mode 100644 brand/icons/okx/favicon.ico create mode 100644 brand/icons/okx/icon-16.png create mode 100644 brand/icons/okx/icon-192.png create mode 100644 brand/icons/okx/icon-32.png create mode 100644 brand/icons/okx/icon-48.png create mode 100644 brand/icons/okx/icon-512.png create mode 100644 brand/icons/okx/icon.svg create mode 100644 brand/manifest.binance.webmanifest create mode 100644 brand/manifest.exchange.webmanifest create mode 100644 brand/manifest.gate.webmanifest create mode 100644 brand/manifest.okx.webmanifest create mode 100644 brand/manifest.webmanifest create mode 100644 docs/OKX单笔期权-币本位与USDT桥-开发方案.md create mode 100644 docs/env配置说明.md create mode 100644 docs/lib-structure.md create mode 100644 docs/position-sizing-mode.md create mode 100644 docs/对冲计划-以期权为主.md create mode 100644 docs/对冲计划-选约与虚实值.md create mode 100644 docs/对冲计划开发方案.md create mode 100644 docs/对冲计划策略与P0校验.md create mode 100644 docs/期权开平仓与监控说明.md create mode 100644 docs/期权方案.md create mode 100644 docs/期权用法.md create mode 100644 ecosystem.config.cjs create mode 100644 lib/__init__.py create mode 100644 lib/account_ledger/__init__.py create mode 100644 lib/account_ledger/account_ledger_db.py create mode 100644 lib/account_ledger/account_ledger_normalize.py create mode 100644 lib/account_ledger/account_ledger_register.py create mode 100644 lib/account_ledger/account_ledger_sync.py create mode 100644 lib/account_ledger/templates/account_ledger_panel.html create mode 100644 lib/ai/__init__.py create mode 100644 lib/ai/ai_client.py create mode 100644 lib/ai/ai_review_lib.py create mode 100644 lib/common/__init__.py create mode 100644 lib/common/auto_transfer_daily_lib.py create mode 100644 lib/common/flask_access_log_lib.py create mode 100644 lib/common/form_submit_lib.py create mode 100644 lib/common/history_window_lib.py create mode 100644 lib/common/static/account_ledger.js create mode 100644 lib/common/static/account_risk_badge.css create mode 100644 lib/common/static/account_risk_badge.js create mode 100644 lib/common/static/ai_review_render.js create mode 100644 lib/common/static/autofill_guard.js create mode 100644 lib/common/static/focus_chart_page.css create mode 100644 lib/common/static/focus_chart_page.js create mode 100644 lib/common/static/form_submit_guard.js create mode 100644 lib/common/static/hedge_plan.js create mode 100644 lib/common/static/instance_dashboard.js create mode 100644 lib/common/static/instance_embed.js create mode 100644 lib/common/static/instance_live.js create mode 100644 lib/common/static/instance_mobile_nav.js create mode 100644 lib/common/static/instance_page.css create mode 100644 lib/common/static/instance_records_mobile.js create mode 100644 lib/common/static/instance_settings_prefs.js create mode 100644 lib/common/static/instance_stats.js create mode 100644 lib/common/static/instance_theme.css create mode 100644 lib/common/static/instance_theme.js create mode 100644 lib/common/static/instance_theme_early.css create mode 100644 lib/common/static/instance_ui.js create mode 100644 lib/common/static/journal_form_save.js create mode 100644 lib/common/static/journal_upload_slots.js create mode 100644 lib/common/static/key_monitor_form.js create mode 100644 lib/common/static/manual_order_rr_preview.js create mode 100644 lib/common/static/open_submit_gate.js create mode 100644 lib/common/static/options_expiry_countdown.js create mode 100644 lib/common/static/options_panel.js create mode 100644 lib/common/static/options_position_cards.js create mode 100644 lib/common/static/options_review.js create mode 100644 lib/common/static/options_settings.js create mode 100644 lib/common/static/order_entry_model.js create mode 100644 lib/common/static/records_review_page.js create mode 100644 lib/common/static/strategy_roll.js create mode 100644 lib/common/static/symbol_live_price.js create mode 100644 lib/common/static/time_close_ui.js create mode 100644 lib/common/static/trade_stats_calendar.css create mode 100644 lib/common/static/trade_stats_calendar.js create mode 100644 lib/common/wechat_notify_lib.py create mode 100644 lib/env/env_file_lib.py create mode 100644 lib/env/env_schema.py create mode 100644 lib/env/env_ui_manifest.py create mode 100644 lib/env/shared_env_lib.py create mode 100644 lib/exchange/__init__.py create mode 100644 lib/exchange/api_credentials_lib.py create mode 100644 lib/exchange/binance_ledger_lib.py create mode 100644 lib/exchange/gate_ccxt_lib.py create mode 100644 lib/exchange/gate_ledger_lib.py create mode 100644 lib/exchange/gate_position_history_lib.py create mode 100644 lib/exchange/gate_transfer_lib.py create mode 100644 lib/exchange/okx_ledger_lib.py create mode 100644 lib/exchange/okx_options_lib.py create mode 100644 lib/exchange/okx_orders_lib.py create mode 100644 lib/hedge_plan/__init__.py create mode 100644 lib/hedge_plan/hedge_options_exclusive_lib.py create mode 100644 lib/hedge_plan/hedge_plan_calc_lib.py create mode 100644 lib/hedge_plan/hedge_plan_db.py create mode 100644 lib/hedge_plan/hedge_plan_moneyness_lib.py create mode 100644 lib/hedge_plan/hedge_plan_monitor_lib.py create mode 100644 lib/hedge_plan/hedge_plan_notify_lib.py create mode 100644 lib/hedge_plan/hedge_plan_option_primary_lib.py create mode 100644 lib/hedge_plan/hedge_plan_orders_lib.py create mode 100644 lib/hedge_plan/hedge_plan_register.py create mode 100644 lib/hedge_plan/hedge_plan_settle_lib.py create mode 100644 lib/hedge_plan/okx_trade_mode_lib.py create mode 100644 lib/hedge_plan/templates/hedge_plan_panel.html create mode 100644 lib/hub/__init__.py create mode 100644 lib/hub/amp_stats_lib.py create mode 100644 lib/hub/hub_auth.py create mode 100644 lib/hub/hub_backup_lib.py create mode 100644 lib/hub/hub_bridge.py create mode 100644 lib/hub/hub_calculator_lib.py create mode 100644 lib/hub/hub_calculator_market_lib.py create mode 100644 lib/hub/hub_compare_lib.py create mode 100644 lib/hub/hub_divergence_scan_lib.py create mode 100644 lib/hub/hub_entry_plan_lib.py create mode 100644 lib/hub/hub_fund_history_lib.py create mode 100644 lib/hub/hub_help_lib.py create mode 100644 lib/hub/hub_host_status_lib.py create mode 100644 lib/hub/hub_kline_store.py create mode 100644 lib/hub/hub_macro_calendar_lib.py create mode 100644 lib/hub/hub_market_info_lib.py create mode 100644 lib/hub/hub_monitor_totals_lib.py create mode 100644 lib/hub/hub_ohlcv_lib.py create mode 100644 lib/hub/hub_options_archive_lib.py create mode 100644 lib/hub/hub_options_funds_lib.py create mode 100644 lib/hub/hub_order_sync_lib.py create mode 100644 lib/hub/hub_perp_options_calc_lib.py create mode 100644 lib/hub/hub_poll_wait_lib.py create mode 100644 lib/hub/hub_position_metrics.py create mode 100644 lib/hub/hub_reconcile_flat_lib.py create mode 100644 lib/hub/hub_sso.py create mode 100644 lib/hub/hub_strategy_lib.py create mode 100644 lib/hub/hub_symbol_archive_lib.py create mode 100644 lib/hub/hub_symbol_lib.py create mode 100644 lib/hub/hub_system_logs_lib.py create mode 100644 lib/hub/hub_trades_lib.py create mode 100644 lib/hub/hub_volume_rank_lib.py create mode 100644 lib/hub/price_snapshot_lib.py create mode 100644 lib/instance/__init__.py create mode 100644 lib/instance/focus_chart_lib.py create mode 100644 lib/instance/instance_dashboard_cache.py create mode 100644 lib/instance/instance_dashboard_lib.py create mode 100644 lib/instance/instance_dashboard_register.py create mode 100644 lib/instance/instance_display_prefs_lib.py create mode 100644 lib/instance/instance_embed_context_lib.py create mode 100644 lib/instance/instance_embed_lib.py create mode 100644 lib/instance/instance_live_pnl_lib.py create mode 100644 lib/instance/instance_live_push_lib.py create mode 100644 lib/instance/instance_nav_lib.py create mode 100644 lib/instance/instance_pm2_lib.py create mode 100644 lib/instance/instance_settings_lib.py create mode 100644 lib/instance/instance_settings_register.py create mode 100644 lib/instance/instance_system_guide_lib.py create mode 100644 lib/instance/journal_chart_async_lib.py create mode 100644 lib/instance/journal_chart_lib.py create mode 100644 lib/instance/journal_form_lib.py create mode 100644 lib/instance/journal_images_lib.py create mode 100644 lib/instance/journal_upload_api_lib.py create mode 100644 lib/instance/records_api_register.py create mode 100644 lib/instance/records_list_lib.py create mode 100644 lib/instance/runtime_config_lib.py create mode 100644 lib/instance/runtime_settings_lib.py create mode 100644 lib/instance/templates/dashboard_panel.html create mode 100644 lib/instance/templates/display_prefs_panel.html create mode 100644 lib/instance/templates/embed_boot_scripts.html create mode 100644 lib/instance/templates/embed_page_fragment.html create mode 100644 lib/instance/templates/embed_shell.html create mode 100644 lib/instance/templates/env_config_panel.html create mode 100644 lib/instance/templates/force_close_header_badge.html create mode 100644 lib/instance/templates/force_close_order_badge.html create mode 100644 lib/instance/templates/index.html create mode 100644 lib/instance/templates/instance_header_panel.html create mode 100644 lib/instance/templates/instance_header_stats.html create mode 100644 lib/instance/templates/instance_theme_toggle.html create mode 100644 lib/instance/templates/instance_top_bar.html create mode 100644 lib/instance/templates/instance_transfer_panel.html create mode 100644 lib/instance/templates/login.html create mode 100644 lib/instance/templates/order_entry_model_fields.html create mode 100644 lib/instance/templates/order_leverage_fields.html create mode 100644 lib/instance/templates/order_monitor_open_form.html create mode 100644 lib/instance/templates/password_settings_panel.html create mode 100644 lib/instance/templates/records_panel.html create mode 100644 lib/instance/templates/risk_policy_panel.html create mode 100644 lib/instance/templates/settings_panel.html create mode 100644 lib/instance/templates/system_guide_panel.html create mode 100644 lib/key_monitor/__init__.py create mode 100644 lib/key_monitor/false_breakout_key_monitor_lib.py create mode 100644 lib/key_monitor/fib_key_monitor_lib.py create mode 100644 lib/key_monitor/key_auto_order_lib.py create mode 100644 lib/key_monitor/key_monitor_full_margin_lib.py create mode 100644 lib/key_monitor/key_monitor_lib.py create mode 100644 lib/key_monitor/key_monitor_schema_lib.py create mode 100644 lib/key_monitor/key_sl_tp_lib.py create mode 100644 lib/key_monitor/trigger_entry_key_monitor_lib.py create mode 100644 lib/options/__init__.py create mode 100644 lib/options/options_close_exec_lib.py create mode 100644 lib/options/options_close_gate_lib.py create mode 100644 lib/options/options_coin_open_lib.py create mode 100644 lib/options/options_dashboard_lib.py create mode 100644 lib/options/options_db.py create mode 100644 lib/options/options_history_lib.py create mode 100644 lib/options/options_hub_lib.py create mode 100644 lib/options/options_margin_mode_lib.py create mode 100644 lib/options/options_monitor_lib.py create mode 100644 lib/options/options_notify_lib.py create mode 100644 lib/options/options_pending_lib.py create mode 100644 lib/options/options_position_limit_lib.py create mode 100644 lib/options/options_positions_lib.py create mode 100644 lib/options/options_pricing_lib.py create mode 100644 lib/options/options_profit_exit_lib.py create mode 100644 lib/options/options_register.py create mode 100644 lib/options/options_review_chart_async_lib.py create mode 100644 lib/options/options_review_db.py create mode 100644 lib/options/options_review_images_lib.py create mode 100644 lib/options/options_review_lib.py create mode 100644 lib/options/options_review_register.py create mode 100644 lib/options/options_spot_bridge_lib.py create mode 100644 lib/options/options_stats_lib.py create mode 100644 lib/options/options_target_lib.py create mode 100644 lib/options/options_time_lib.py create mode 100644 lib/options/templates/options_panel.html create mode 100644 lib/options/templates/options_review_panel.html create mode 100644 lib/options/templates/options_settings_panel.html create mode 100644 lib/options/templates/options_settings_swap.html create mode 100644 lib/options/templates/options_settings_transfer.html create mode 100644 lib/paths.py create mode 100644 lib/sim/__init__.py create mode 100644 lib/sim/broker_lib.py create mode 100644 lib/sim/db_lib.py create mode 100644 lib/sim/hooks.py create mode 100644 lib/sim/mode_lib.py create mode 100644 lib/sim/pricing_lib.py create mode 100644 lib/sim/register.py create mode 100644 lib/sim/templates/sim_funds_panel.html create mode 100644 lib/sim/wallets_lib.py create mode 100644 lib/strategy/__init__.py create mode 100644 lib/strategy/strategy_config.py create mode 100644 lib/strategy/strategy_db.py create mode 100644 lib/strategy/strategy_exchange_base.py create mode 100644 lib/strategy/strategy_exchange_binance.py create mode 100644 lib/strategy/strategy_exchange_gate.py create mode 100644 lib/strategy/strategy_exchange_okx.py create mode 100644 lib/strategy/strategy_records_register.py create mode 100644 lib/strategy/strategy_register.py create mode 100644 lib/strategy/strategy_roll_lib.py create mode 100644 lib/strategy/strategy_roll_monitor_lib.py create mode 100644 lib/strategy/strategy_roll_ui_lib.py create mode 100644 lib/strategy/strategy_snapshot_lib.py create mode 100644 lib/strategy/strategy_trade_labels.py create mode 100644 lib/strategy/strategy_trend_exchange.py create mode 100644 lib/strategy/strategy_trend_lib.py create mode 100644 lib/strategy/strategy_trend_register.py create mode 100644 lib/strategy/strategy_ui.py create mode 100644 lib/strategy/strategy_wechat_notify.py create mode 100644 lib/strategy/templates/gate_transfer_block.html create mode 100644 lib/strategy/templates/journal_form_fields.html create mode 100644 lib/strategy/templates/journal_upload_slots.html create mode 100644 lib/strategy/templates/key_focus_v2.html create mode 100644 lib/strategy/templates/key_monitor_panel.html create mode 100644 lib/strategy/templates/key_monitor_rule_tips.html create mode 100644 lib/strategy/templates/order_focus_v2.html create mode 100644 lib/strategy/templates/order_monitor_rule_tips_binance.html create mode 100644 lib/strategy/templates/order_monitor_rule_tips_gate.html create mode 100644 lib/strategy/templates/order_monitor_rule_tips_gate_bot.html create mode 100644 lib/strategy/templates/order_monitor_rule_tips_okx.html create mode 100644 lib/strategy/templates/order_plan_preview_bar.html create mode 100644 lib/strategy/templates/strategy_records_page.html create mode 100644 lib/strategy/templates/strategy_roll.html create mode 100644 lib/strategy/templates/strategy_roll_docs.html create mode 100644 lib/strategy/templates/strategy_roll_panel.html create mode 100644 lib/strategy/templates/strategy_subnav.html create mode 100644 lib/strategy/templates/strategy_trading_page.html create mode 100644 lib/strategy/templates/strategy_trend_disabled.html create mode 100644 lib/strategy/templates/strategy_trend_disabled_panel.html create mode 100644 lib/strategy/templates/strategy_trend_panel.html create mode 100644 lib/strategy/templates/symbol_live_price_snippet.html create mode 100644 lib/strategy/templates/trade_policy_fields.html create mode 100644 lib/trade/__init__.py create mode 100644 lib/trade/account_risk_lib.py create mode 100644 lib/trade/compensating_close_lib.py create mode 100644 lib/trade/daily_open_limit_lib.py create mode 100644 lib/trade/entry_model_lib.py create mode 100644 lib/trade/force_close_lib.py create mode 100644 lib/trade/manual_sltp_lib.py create mode 100644 lib/trade/open_trade_gate_lib.py create mode 100644 lib/trade/order_monitor_display_lib.py create mode 100644 lib/trade/position_sizing_lib.py create mode 100644 lib/trade/time_close_lib.py create mode 100644 lib/trade/trade_exchange_stats_lib.py create mode 100644 lib/trade/trade_fee_lib.py create mode 100644 lib/trade/trade_policy_app_lib.py create mode 100644 lib/trade/trade_policy_lib.py create mode 100644 lib/trade/trade_result_lib.py create mode 100644 lib/trade/trade_stats_calendar_lib.py create mode 100644 requirements.txt create mode 100644 scripts/backup_data.sh create mode 100644 scripts/fix_breakeven_labels.py create mode 100644 scripts/install_backup_cron.sh create mode 100644 scripts/verify_okx_funding.py create mode 100644 static/icons/apple-touch-icon.png create mode 100644 static/icons/favicon.ico create mode 100644 static/icons/icon-16.png create mode 100644 static/icons/icon-192.png create mode 100644 static/icons/icon-32.png create mode 100644 static/icons/icon-512.png create mode 100644 static/icons/icon.svg create mode 100644 static/icons/manifest.webmanifest create mode 100644 templates/key_focus.html create mode 100644 templates/order_focus.html create mode 100644 tests/test_hedge_exchange_pnl.py create mode 100644 tests/test_hedge_options_exclusive.py create mode 100644 tests/test_hedge_partial_manual.py create mode 100644 tests/test_hedge_plan_calc.py create mode 100644 tests/test_hedge_plan_end.py create mode 100644 tests/test_hedge_plan_history_stats.py create mode 100644 tests/test_hedge_plan_moneyness.py create mode 100644 tests/test_hedge_plan_notify_settle.py create mode 100644 tests/test_hedge_plan_option_primary.py create mode 100644 tests/test_hedge_plan_orders.py create mode 100644 tests/test_hedge_po_monitor_safety.py create mode 100644 tests/test_hedge_start_refresh.py create mode 100644 tests/test_okx_funding_balances.py create mode 100644 tests/test_okx_position_metrics.py create mode 100644 tests/test_okx_spot_swap.py create mode 100644 tests/test_okx_trade_mode.py create mode 100644 tests/test_options_add_premium.py create mode 100644 tests/test_options_btc_pnl_usdt.py create mode 100644 tests/test_options_budget_full.py create mode 100644 tests/test_options_close_gate_lib.py create mode 100644 tests/test_options_history_coin.py create mode 100644 tests/test_options_hub_lib.py create mode 100644 tests/test_options_margin_mode.py create mode 100644 tests/test_options_net_pnl_sum.py create mode 100644 tests/test_options_notify_lib.py create mode 100644 tests/test_options_pending_lib.py create mode 100644 tests/test_options_position_limit.py create mode 100644 tests/test_options_pricing.py create mode 100644 tests/test_options_profit_exit.py create mode 100644 tests/test_options_profit_exit_recycle.py create mode 100644 tests/test_options_review_chart_async_lib.py create mode 100644 tests/test_options_review_lib.py create mode 100644 tests/test_options_stats_lib.py create mode 100644 tests/test_options_sync.py create mode 100644 tests/test_options_target_lib.py create mode 100644 tests/test_options_time_lib.py create mode 100644 使用说明.md create mode 100644 关键位自动下单说明.md create mode 100644 更新文档.md create mode 100644 部署文档.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5681359 --- /dev/null +++ b/.env.example @@ -0,0 +1,338 @@ +# ============================================================================= +# 环境配置模板(可提交 Git).程序运行时只读取同目录下的 .env. +# +# 首次部署 / 新机: +# cp .env.example .env +# nano .env # 填入真实密钥,端口,代理等 +# +# 升级代码(git pull)前建议备份(.env 不在 Git 中,pull 不会覆盖): +# cp .env .env.backup.$(date +%Y%m%d) +# +# 从备份恢复: +# cp .env.backup.YYYYMMDD .env +# ============================================================================= + +APP_ENV=production +# 服务监听地址(云服务器通常用 0.0.0.0) +APP_HOST=0.0.0.0 +# 服务端口 +APP_PORT=5010 +# 是否开启调试模式(生产建议 false) +APP_DEBUG=false + +# 登录账号 +APP_USERNAME=admin +# 登录密码(请改成你自己的强密码) +APP_PASSWORD=admin123 +# 是否关闭登录校验(局域网可设 true;公网务必 false) +APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token +# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) +# APP_ALLOW_HUB_EMBED=true +# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com +# HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None +# APP_COOKIE_SECURE=true +# Flask 会话密钥(必须替换为长随机字符串) +FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET + +# 企业微信机器人 Webhook(用于行情/风控推送) +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=REPLACE_WITH_REAL_KEY + +# 数据库文件路径(相对路径会自动按项目目录解析) +DB_PATH=crypto.db +# 交易截图上传目录 +UPLOAD_DIR=static/images + +# 自动备份(scripts/backup_data.sh + cron,可选;默认即可) +# BACKUP_ROOT=/root/backups +# BACKUP_RETENTION_DAYS=30 +# BACKUP_INSTANCE=coinox + +# 训练总资金(U) +# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所 +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk +# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启) +# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向) +TRADE_DIRECTION_RESTRICT_ENABLED=false +TRADE_DIRECTION=both +# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择) +TRADE_SYMBOL_RESTRICT_ENABLED=false +TRADE_SYMBOL_WHITELIST=BTC,ETH +# 每天起始基数(U) +DAILY_START_CAPITAL=30 +# 日内回撤后基数(U) +DAILY_LOSS_CAPITAL=20 +# 日内盈利后基数(U) +DAILY_PROFIT_CAPITAL=50 +# BTC 默认杠杆倍数 +BTC_LEVERAGE=10 +# 山寨币默认杠杆倍数 +ALT_LEVERAGE=5 +# 交易日重置小时(北京时间) +TRADING_DAY_RESET_HOUR=8 +# 整点前禁止新开仓:true=启用(默认),false=关闭(仍可保留 8 点作为交易日划分) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true + +# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单) +LIVE_TRADING_ENABLED=true +# ============================================================================= +# OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示) +# 新机保持为空;填真钥后 pm2 restart --update-env(含子代理) +# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填 +# ============================================================================= +OKX_API_KEY= +OKX_API_SECRET= +OKX_API_PASSPHRASE= +# 保证金模式:cross=全仓,isolated=逐仓 +OKX_TD_MODE=cross +# 持仓模式:hedge=双向持仓,net=单向净持仓 +OKX_POS_MODE=hedge +# 仓位查询 instType(OKX) +OKX_POSITION_INST_TYPE=SWAP +# 从 OKX 历史仓位同步已实现盈亏(北京时间起点,空=近 90 天 0 点起) +# EXCHANGE_POSITION_SYNC_FROM_BJ=2026-01-01 +# 单次拉取历史仓位条数上限(OKX 每页最多 100,程序会分页) +# EXCHANGE_POSITION_HISTORY_LIMIT=200 +# 页面与浏览器标签展示的交易所名称(多环境区分时可改成例如 OKX·测试网) +EXCHANGE_DISPLAY_NAME=CoinOX +# 企业微信推送里展示的账户备注 +# OKX_ACCOUNT_LABEL= +# 顶栏是否显示 USDT 资金/交易账户(热更);false 时总资金仅计期权 USDC 侧 +OKX_SHOW_PERP_FUNDS=true + +# ============================================================================= +# 期权模块(与上方 OKX_API_* 同源;修改启用开关后须重启 PM2) +# 详见 docs/期权方案.md 与 docs/期权用法.md +# ============================================================================= +OKX_OPTIONS_ENABLED=false +# 以下 OKX_OPTIONS_API_* 已废弃,请勿再配置(仅兼容旧部署回填) +# OKX_OPTIONS_API_KEY= +# OKX_OPTIONS_API_SECRET= +# OKX_OPTIONS_API_PASSPHRASE= +OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 +# 单笔期权本位: coin(默认,币本位+USDT买币桥) | usdc(权利金USDC;对冲仍仅USDC) +OKX_OPTIONS_MARGIN_MODE=coin +OKX_OPTIONS_TRADE_BUDGET_USDC=10 +OKX_OPTIONS_BUDGET_BUFFER=0.95 +# 币本位:按交易户USDT×缓冲复利;上限开关默认关(靠人工转走) +OKX_OPTIONS_COIN_COMPOUND=true +OKX_OPTIONS_COIN_BUDGET_USDT=10 +OKX_OPTIONS_COIN_MAX_USDT_ENABLED=false +OKX_OPTIONS_COIN_MAX_USDT=50 +# 现货买入相对权利金缓冲:1.10=多买10%;也可写 0.10。按最大可开张数×权利金×缓冲买币,不全额兑换 +OKX_OPTIONS_COIN_SPOT_BUY_BUFFER=1.10 +# 全仓复利:开启时隐藏单笔预算且不可用打满;关闭后恢复单笔预算 +OKX_OPTIONS_COMPOUND_FULL_ENABLED=true +OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED=false +OKX_OPTIONS_COMPOUND_FULL_CAP_USDC=300 +# 交易模式三选一(热更):options=单独期权 / perp_options=永期对冲 / options_options=期期对冲 +# 选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权,仓位按「对冲组数上限」 +OKX_TRADE_MODE=perp_options +# 仅单独期权模式:期权同时持仓上限(笔);0=不限制;同合约加仓不占新笔数;热更 +OKX_OPTIONS_MAX_ACTIVE_POSITIONS=0 +OKX_OPTIONS_DEFAULT_UNDERLY=ETH +# 期权链仅显示卖一深度≥1张的合约(估算卖一/无深度不显示);false 则显示全部 +OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED=true +OKX_OPTIONS_MAX_DTE_DAYS=2 +OKX_OPTIONS_CHAIN_MAX_DTE_DAYS=14 +OKX_OPTIONS_ITM_MAX_DIST_USD=30 +OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0 +OKX_OPTIONS_POLL_SECONDS=15 +OKX_OPTIONS_TD_MODE=isolated +OKX_OPTIONS_ALLOW_MARKET_CLOSE=false +# 目标平仓门控(目标触达后自动平才校验;比较口径均为 USDT 估值) +OKX_OPTIONS_CLOSE_GATE_MODE=premium +# 全局倍数可选;留空则按本位用下方 COIN/USDC +OKX_OPTIONS_CLOSE_RECYCLE_MULT= +OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05 +OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC=2 +OKX_OPTIONS_CLOSE_NET_PNL_MIN_U=0 +OKX_OPTIONS_CLOSE_HOLD_SECONDS=120 +# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 +OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 + +# ============================================================================= +# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md) +# ============================================================================= +# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读) +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_SHOW_PERP_OPTIONS=true +HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true +HEDGE_PLAN_LIVE_ORDER=false +# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换) +HEDGE_PLAN_OPTION_PRIMARY=true +HEDGE_PLAN_OPEN_ORDER=options_first +HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true +HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false +HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +# 方案C:期期页面显示「平仓模式」(到期平/全平);关则固定到期平.默认开启,页面默认选全平 +HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true +# 期期「做多/做空」拆分口径:budget=按权利金预算(默认);sheets=先算同张数总张数(2n)再按比例拆 +HEDGE_PLAN_OO_BIAS_SPLIT_BY=budget +# 期期「做多/做空」主腿占比(0~1,默认 0.7=7:3);做多主腿=Call,做空主腿=Put +HEDGE_PLAN_OO_BIAS_RATIO=0.7 +# 对冲与单独期权互斥(默认 true):有对冲计划不可单独开期权;有单独期权不可启动对冲;false=可同时开 +HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE=true +# 半腿失败改手动补开(默认 true):不自动平已成腿,计划挂 partial,页面补开;开启时下方自动平强制无效 +HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL=true +# 对冲组数上限(默认 1;opening/active/partial 计入);仅永期/期期模式生效;热更 +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +# 半腿失败自动平期权;若 MANUAL_COMPLETE_ON_PARTIAL=true 则运行时强制无效(建议一并写成 false) +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=false + +# ============================================================================= +# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2) +# ============================================================================= +# 默认 false = 关闭所有关键位程序自动单(箱体/收敛/斐波/假突破/触价) +# +# POSITION_SIZING_MODE=risk(以损定仓) +# false → 不执行任何关键位自动单;支撑/阻力提醒,人工下单,顺势加仓不受影响 +# true → 允许关键位全套自动(含触价) +# +# POSITION_SIZING_MODE=full_margin(全仓杠杆,须无仓切换) +# false → 不执行触价自动单 +# true → 仅回调/突破触价可程序自动开仓;箱体/斐波等仍禁止 +# +# 顺势加仓,趋势回调不受本开关控制;全仓模式下策略自动仍禁止. +KEY_AUTO_ORDER_ENABLED=false + +# ============================================================================= +# 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) +# ============================================================================= +# 【周期】门控 K 线周期,如 5m,15m;仅影响关键位硬条件,不改变顶栏分区 +KLINE_TIMEFRAME=5m +# OKX 遗留:突破过滤百分比(与 KEY_BREAKOUT_AMP_* 并存,程序仍读取) +KEY_BREAKOUT_LIMIT_PCT=1.5 +# 【确认K】闭合 K 序列中的棒偏移:突破棒默认 -2(倒数第2根),确认棒默认 -1(倒数第1根) +KEY_CONFIRM_BREAKOUT_BAR=-2 +KEY_CONFIRM_BAR=-1 +# 【量能】突破棒成交量 > 前 N 根均量 × 倍数(默认 N=20,倍数=1.3 即放大 30%) +KEY_VOLUME_MA_BARS=20 +KEY_VOLUME_RATIO_MIN=1.3 +# 【箱体/收敛】突破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 +# 【关键位自动开仓盈亏比】按确认K收盘 E 计算,严格大于该值才市价开仓(如 1.5 表示须 >1.5:1) +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 + +# ============================================================================= +# 交易执行 / 人工风控(页面「实盘下单」) +# ============================================================================= +# 【最大同时持仓】active 订单数达到该值后禁止人工与关键位自动再加仓(默认 1=单仓) +MAX_ACTIVE_POSITIONS=1 +# 【人工下单最低盈亏比】按当前价与 SL/TP 计算,低于该值前后端均拒绝(默认 1.4,即须 >=1.4:1) +MANUAL_MIN_PLANNED_RR=1.4 +# 【关键位连开计仓】true=已有持仓时关键位自动单仍按「无仓时」资金快照算保证金基数 +KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT=true +# 【单日开仓 AI 提醒】本交易日开仓达到该次数时推送企业微信 AI 克制提醒(不拦单) +DAILY_OPEN_ALERT_THRESHOLD=5 +# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 +DAILY_OPEN_HARD_LIMIT=0 + +# ============================================================================= +# 账户冷静期 / 日冻结风控(手动平仓,外部平仓,复盘情绪标签) +# 详见 docs/account-risk-cooldown.md +# ============================================================================= +RISK_CONTROL_ENABLED=true +RISK_COOLING_HOURS_MANUAL=4 +RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用 +RISK_DAILY_LOSS_LIMIT=2 +RISK_MOOD_ISSUES_DAILY_FREEZE=true + +# 资金与仓位刷新周期(秒) +BALANCE_REFRESH_SECONDS=60 +# 前端价格快照轮询(秒) +PRICE_REFRESH_SECONDS=5 +# 后台监控轮询周期(秒) +MONITOR_POLL_SECONDS=3 +# 移动保本同步交易所止盈止损的最小间隔(秒),避免频繁撤挂叠单 +BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC=60 +# 重启后多少秒内不做「外部平仓」同步(避免 API 未就绪误判) +RECONCILE_STARTUP_GRACE_SEC=90 +# 连续多少次轮询确认交易所空仓后,才记为外部平仓(默认 3 次 ≈ 9 秒) +RECONCILE_FLAT_CONFIRM_POLLS=3 +# 使用可用资金时的缓冲比例(如0.98代表用98%) +FULL_MARGIN_BUFFER_RATIO=0.98 + +# ============================================================================= +# 自动划转(页顶「将 swap 补足到 XU」;与 DAILY_START_CAPITAL 独立,需一致时请设为相同值) +# ============================================================================= +AUTO_TRANSFER_ENABLED=false +# 交易账户(swap)目标余额 U:每日 8 点(北京)自动划入或划出至 funding;持仓中不划转 +AUTO_TRANSFER_AMOUNT=30 +AUTO_TRANSFER_FROM=funding +AUTO_TRANSFER_TO=swap +TRANSFER_CCY=USDT +# 北京时间该整点小时内尝试;账簿按 UTC 自然日去重 +AUTO_TRANSFER_BJ_HOUR=8 +# 强制清仓整点(北京时间,默认 0=凌晨00点) +FORCE_CLOSE_BJ_HOUR=0 +# 是否启用强制清仓(默认关闭,true 才会在整点执行) +FORCE_CLOSE_ENABLED=false +# 强制清仓执行窗口(分钟,默认 5;该窗口内禁止开仓) +FORCE_CLOSE_GRACE_MINUTES=5 + +# 推送与AI超时(秒) +WECHAT_TIMEOUT_SECONDS=10 +AI_TIMEOUT_SECONDS=120 + +# AI 复盘服务地址(本机 Ollama 默认地址) +AI_PROVIDER=openai +OPENAI_API_BASE=https://op.bz121.com/v1 +OPENAI_API_KEY=你的密钥 +OPENAI_MODEL=gemma4:e4b +OLLAMA_API=http://127.0.0.1:11434/api/generate +AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest + +# OKX 代理(可选,仅本地开发网络受限时用;云服务器部署请留空,直连 OKX 即可) +# 1) 先在本机建立隧道(示例): +# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes +# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 +# +# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: +# OKX_HTTP_PROXY=http://127.0.0.1:3128 +# OKX_HTTPS_PROXY=http://127.0.0.1:3128 + +# 开仓多周期K线图(可选) +# ORDER_CHART_ENABLED=true +# ORDER_CHART_TFS=4h,1h,15m,5m +# ORDER_CHART_LIMIT=100 +# ORDER_CHART_DIR=static/images/order_charts +# 详见上文 DAILY_OPEN_ALERT_THRESHOLD / DAILY_OPEN_HARD_LIMIT;说明文档 docs/daily-open-limit.md +# 以损定仓(按交易账户资金的百分比) +# RISK_PERCENT=2 +# 移动保本触发(达到多少R触发)与偏移(百分比) +# BREAKEVEN_RR_TRIGGER=1.0 +# 移动保本阶梯(每多少R继续上移一次,默认1R) +# BREAKEVEN_STEP_R=1.0 +# BREAKEVEN_OFFSET_PCT=0.02 +# 开单风格默认值:trend / swing +# DEFAULT_TRADE_STYLE=trend + +APP_TIMEZONE=Asia/Shanghai +# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED + +# --- CoinOX 模拟资金 --- +# sim=本地模拟资金撮合; live=实盘账户 +SIM_DEFAULT_MODE=sim +SIM_INITIAL_EQUITY_USDT=10000 +SIM_INITIAL_USDC=0 +SIM_FEE_RATE=0.0005 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..77dda83 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Auto LF for shell scripts (avoid CRLF breaking bash on Linux) +*.sh text eol=lf +deploy/**/*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9da156 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ + +# Env & secrets +.env +*.pem +*.key + +# Runtime data +*.db +*.db-journal +data/ +static/images/ +!static/images/.gitkeep +static/images/order_charts/ +backups/ + +# OS / IDE +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp + +# Logs / PM2 +*.log +.pm2/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f7b2ea --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# CoinOX(币本对冲) + +OKX **币本位期权** + **U 本位永续对冲** 独立实例。 + +远程仓库: + +## 产品范围 + +| 保留 | 说明 | +|------|------| +| 单独期权 | 仅币本位(USDT→买币→开期权) | +| 永期对冲 | 币本位期权腿 + USDT 永续腿 | +| 期期对冲 | 双腿币本位期权 | +| 模拟资金 | 默认 `sim`,本地钱包撮合 | + +| 不提供 | 说明 | +|--------|------| +| 单独永续下单 | `/trade` 已重定向 | +| 关键位自动开仓 | `/key_monitor` 已重定向 | +| 策略趋势/滚仓 | 未安装 | +| USDC 期权 | 已固定币本位 | + +源仓 `crypto_monitor` **只读复制**,不回改源仓。中控二期再做。 + +## 快速开始 + +```bash +cd /path/to/CoinOX # 或 E:\仓库\okx对冲 +python -m venv .venv +# Windows: .venv\Scripts\activate +pip install -r requirements.txt +copy .env.example .env # 编辑密钥;默认 SIM_DEFAULT_MODE=sim +python app.py +``` + +默认端口见 `.env` 的 `APP_PORT`(模板为 `5010`,避免与旧 OKX 实例冲突)。 + +## 环境要点 + +- `OKX_OPTIONS_MARGIN_MODE=coin`(固定) +- `OKX_TRADE_MODE=options|perp_options|options_options` +- `SIM_DEFAULT_MODE=sim` / `live` +- `SIM_INITIAL_EQUITY_USDT=10000` + +## 风险 + +实盘须自行确认 API 权限与资金;默认模拟盘,切 `live` 前请核对。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..214949d --- /dev/null +++ b/app.py @@ -0,0 +1,9929 @@ +from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, Response, send_file +import sqlite3 +import csv +from io import StringIO +import time +import threading +import requests +import os +import re +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +try: + from zoneinfo import ZoneInfo +except ImportError: + ZoneInfo = None # type: ignore +from functools import wraps +import uuid +import ccxt +from werkzeug.utils import secure_filename + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + Image = None # type: ignore + ImageDraw = None # type: ignore + ImageFont = None # type: ignore + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = BASE_DIR # CoinOX 独立仓: lib/ 与 app.py 同级 +import sys + +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +from lib.paths import common_static_dir +from lib.exchange.api_credentials_lib import ( + is_exchange_auth_error, + load_markets_public_fallback, + normalize_api_credential, +) +from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice +from lib.ai.ai_review_lib import ( + build_journal_ai_chart_path, + collect_images_for_ai_review, + journal_row_lines_for_ai, +) +from lib.common.form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order +from lib.key_monitor.fib_key_monitor_lib import ( + FIB_KEY_MONITOR_TYPES, + backfill_missing_key_signal_types, + calc_fib_plan, + entry_reason_from_key_signal, + fib_invalidate_by_mark, + fib_ratio_from_type, + is_fib_key_monitor_type, + key_signal_type_for_trade_record, + stored_key_signal_type, +) +from lib.key_monitor.false_breakout_key_monitor_lib import ( + FALSE_BREAKOUT_MONITOR_TYPE, + FALSE_BREAKOUT_VALIDITY_HOURS, + calc_false_breakout_plan, + expires_at_text, + false_breakout_gate_preview, + is_false_breakout_expired, + is_false_breakout_key_monitor_type, + is_limit_key_monitor_type, + key_price_from_row, + normalize_false_breakout_symbol, + storage_bounds_from_key_price, +) +from lib.strategy.strategy_trade_labels import ( + JOURNAL_ORDER_TYPE_OPTIONS, + apply_order_monitor_source_labels, + entry_reason_for_monitor_type, + handoff_trade_miss_reason, + normalize_journal_order_type, + order_monitor_source_type, + trade_record_monitor_type as resolve_trade_record_monitor_type, + trend_plan_id_from_monitor_row, +) +from lib.instance.journal_form_lib import normalize_journal_direction, normalize_journal_entry_reason +from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders +from lib.instance.journal_images_lib import ( + collect_journal_slot_images, + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + normalize_journal_draft_id, + primary_journal_image, +) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + JOURNAL_CHART_TF_CHOICES, + compose_chart_panels, + marker_points_for_timeframe, + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + JOURNAL_CHART_DEFAULT_ANCHOR, + price_levels_from_marker_payload, + render_candles_subplot, + trade_review_fetch_window, + trim_rows_for_trade_review, +) +from lib.key_monitor.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 lib.trade.time_close_lib import ( + TIME_CLOSE_RESULT, + apply_time_close_to_payload, + ensure_time_close_schema, + parse_time_close_enabled_form, + parse_time_close_hours_form, + should_trigger_time_close, + time_close_insert_values, + time_close_label, + time_close_settings_from_row, +) +from lib.trade.force_close_lib import ( + apply_force_close_display_result, + apply_force_close_to_payload, + enrich_orders_force_close, + force_close_template_context, +) +from lib.trade.manual_sltp_lib import ( + normalize_open_sltp_mode, + resolve_entrust_sltp_prices, + resolve_open_sltp_prices, +) +from lib.key_monitor.key_monitor_schema_lib import ensure_key_monitor_schema +from lib.key_monitor.trigger_entry_key_monitor_lib import ( + BREAKOUT_TRIGGER_ENTRY_MONITOR_TYPE, + CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED, + TRIGGER_ENTRY_CLOSE_EXPIRED, + TRIGGER_ENTRY_CLOSE_FILLED, + TRIGGER_ENTRY_CLOSE_SL_INVALIDATE, + TRIGGER_ENTRY_CLOSE_TP_INVALIDATE, + TRIGGER_ENTRY_MONITOR_TYPE, + TRIGGER_ENTRY_MONITOR_TYPES, + TRIGGER_ENTRY_VALIDITY_HOURS, + check_trigger_entry_intent_limit, + count_pending_trigger_entries, + acquire_trigger_entry_exec_lock, + is_trigger_entry_in_flight_row, + release_trigger_entry_exec_lock, + is_breakout_trigger_entry_key_monitor_type, + is_trigger_entry_expired, + is_trigger_entry_key_monitor_type, + trigger_entry_expires_at_text, + trigger_entry_gate_preview, + trigger_entry_invalidate, + trigger_should_fire, + validate_trigger_entry_geometry, + validate_trigger_entry_rr, +) +from lib.trade.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 lib.trade.trade_policy_lib import load_trade_policy +from lib.trade.entry_model_lib import ( + build_intraday_entry_reason_options, + build_journal_entry_reason_options, + enrich_entry_model_display, + hub_meta_entry_context, + migrate_entry_model_columns, + order_entry_template_context, + open_position_button_label, + parse_manual_order_style_fields, + resolve_effective_trade_entry_reason, + format_entry_type_display, + resolve_trade_record_entry_reason, + trend_manual_entry_reason_count, +) +from lib.trade.trade_policy_app_lib import ( + check_direction_policy, + check_open_policy, + check_symbol_policy, + default_symbol_for_policy, + trade_policy_template_context, +) +from lib.key_monitor.key_auto_order_lib import ( + check_monitor_type_add_allowed, + effective_entry_reason_options, + effective_stats_segment_defs, + load_key_auto_order_enabled, +) +from lib.key_monitor.key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) +from lib.common.auto_transfer_daily_lib import run_auto_transfer_once_per_day +from lib.key_monitor.key_monitor_lib import ( + KEY_DIRECTION_WATCH, + KEY_MONITOR_ALERT_ONLY_TYPES, + KEY_MONITOR_AUTO_TYPES, + KEY_MONITOR_RS_TYPE, + KEY_MONITOR_RS_TYPES, + auto_amp_ok, + auto_confirm_ok, + box_breakout_invalidate_by_mark, + box_breakout_invalidate_edge_label, + claim_rs_level_notify, + detect_rs_box_break, + format_auto_amp_line, + format_auto_confirm_line, + key_monitor_rule_template_context, + notify_interval_elapsed, + resolve_rs_break_for_alert, + rs_break_from_direction, + run_rs_level_alert_tick, +) +from lib.trade.order_monitor_display_lib import ( + apply_order_price_display_fields, + enrich_order_display_fields, + order_monitor_tpsl_needs_sync, + stale_breakeven_armed, +) +from lib.common.wechat_notify_lib import build_wechat_rs_level_message, send_wechat_webhook +from lib.hub.hub_auth import request_allowed as hub_request_allowed +from lib.instance.instance_nav_lib import request_is_hub_soft_nav +from lib.hub.hub_volume_rank_lib import resolve_daily_volume_rank +from lib.common.history_window_lib import ( + PRESET_ALL, + PRESET_CUSTOM, + PRESET_DEFAULT, + PRESET_UTC_LAST24H, + PRESET_UTC_LAST3M, + PRESET_UTC_LAST6M, + PRESET_UTC_LAST7D, + PRESET_UTC_THIS_MONTH, + PRESET_UTC_TODAY, + list_window_redirect_query, + normalize_bj_datetime_storage, + resolve_list_window, + resolve_window, + sql_list_time_field, + utc_window_to_bj_sql_strings, + utc_window_to_utc_sql_strings, +) +from lib.trade.trade_result_lib import ( + classify_exit_by_levels, + count_winning_trades, + filter_trade_records_excluding_miss, + normalize_result_with_pnl, +) +from lib.trade.trade_exchange_stats_lib import attach_exchange_stats_to_trade, filter_position_lifecycle_fills + + +def load_env_file(path): + if not os.path.exists(path): + return + raw_bytes = open(path, "rb").read() + text = "" + for enc in ("utf-8-sig", "utf-16", "utf-16-le", "utf-16-be"): + try: + text = raw_bytes.decode(enc) + break + except Exception: + continue + if not text: + text = raw_bytes.decode("utf-8", errors="ignore") + text = text.replace("\x00", "") + for line in text.splitlines(): + raw = line.strip() + if not raw or raw.startswith("#") or "=" not in raw: + continue + key, value = raw.split("=", 1) + clean_key = key.strip().lstrip("\ufeff") + if not clean_key.replace("_", "").isalnum(): + continue + clean_value = value.strip().strip('"').strip("'") + os.environ[clean_key] = clean_value + +load_env_file(os.path.join(BASE_DIR, ".env")) + + +def resolve_path(path_value): + if os.path.isabs(path_value): + return path_value + return os.path.join(BASE_DIR, path_value) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "coinox_2026_secret_key") +from lib.instance.instance_embed_lib import attach_embed_templates, redirect_to_embed_shell_if_enabled + +attach_embed_templates(app, _REPO_ROOT) + +# ====================== 登录配置 ====================== +USERNAME = os.getenv("APP_USERNAME", "dekun") +PASSWORD = os.getenv("APP_PASSWORD", "Woaini88@") +AUTH_DISABLED = os.getenv("APP_AUTH_DISABLED", "false").lower() in ("1", "true", "yes", "on") + +# 企业微信机器人Webhook +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me") +SYSTEM_TYPE = "CRYPTO" +HOST = os.getenv("APP_HOST", "0.0.0.0") +PORT = int(os.getenv("APP_PORT", "5000")) +DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true" +DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db")) + +# 训练参数(可由 .env 覆盖) +TOTAL_CAPITAL = float(os.getenv("TOTAL_CAPITAL", "100")) +DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30")) +DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20")) +DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50")) +BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10")) +ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5")) +# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8) +TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8")) +TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv( + "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true" +).lower() in ("1", "true", "yes", "on") +RUNTIME_KEY_OPEN_GUARD = "trading_day_reset_open_guard_enabled" +APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Asia/Shanghai") + + +def _resolve_app_tz(): + if ZoneInfo is not None: + try: + return ZoneInfo((APP_TIMEZONE or "Asia/Shanghai").strip()) + except Exception: + pass + return timezone(timedelta(hours=8)) + + +APP_TZ = _resolve_app_tz() +LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true" + + +def _promote_legacy_options_api_keys() -> None: + """1B: OKX_API_* 为空时,用废弃的 OKX_OPTIONS_API_* 回填到进程环境.""" + if (os.getenv("OKX_API_KEY") or "").strip(): + return + legacy_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip() + legacy_secret = (os.getenv("OKX_OPTIONS_API_SECRET") or "").strip() + legacy_pass = (os.getenv("OKX_OPTIONS_API_PASSPHRASE") or "").strip() + if not (legacy_key and legacy_secret and legacy_pass): + return + os.environ["OKX_API_KEY"] = legacy_key + os.environ["OKX_API_SECRET"] = legacy_secret + os.environ["OKX_API_PASSPHRASE"] = legacy_pass + + +_promote_legacy_options_api_keys() +OKX_API_KEY = normalize_api_credential(os.getenv("OKX_API_KEY")) +OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET")) +OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE")) +OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on") +OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10")) +OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() +OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross") +OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") +EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "CoinOX").strip() or "CoinOX" +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")) +AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" +AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30")) +AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding") +AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap") +FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true" +FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) +# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日(与 OKX 日界一致便于对账) +AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +POSITION_SIZING_MODE = load_position_sizing_mode() +KEY_AUTO_ORDER_ENABLED = load_key_auto_order_enabled() +TRADE_POLICY = load_trade_policy() +WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) +AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) +MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) +RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90")) +RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3"))) +_APP_STARTED_AT = time.time() +_RECONCILE_FLAT_STREAK = {} +BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max( + 15, int(os.getenv("BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC", "60")) +) +_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {} +KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m") +FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO", "0.98")) +TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT" +OKX_POSITION_INST_TYPE = os.getenv("OKX_POSITION_INST_TYPE", "SWAP") +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 +UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images")) +ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true" +ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()] +ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100")) +ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts")) +from lib.trade.daily_open_limit_lib import ( + build_daily_open_alert_prompt, + can_trade_new_open, + check_daily_open_hard_limit, + count_opens_for_trading_day, + format_daily_open_counter_line, + format_daily_open_summary_short, + load_daily_open_limits_from_env, + should_send_daily_open_alert, +) + +DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env() +RISK_PERCENT = float(os.getenv("RISK_PERCENT", "2")) +BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) +BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) +BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) +ORDER_MONITOR_TYPE_MANUAL = "下单监控" +ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" +# KEY_MONITOR_AUTO_TYPES / KEY_MONITOR_ALERT_ONLY_TYPES:见 key_monitor_lib +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")) +KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX", "30"))) + +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_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() in ( + "1", + "true", + "yes", + "on", +) +DEFAULT_TRADE_STYLE = (os.getenv("DEFAULT_TRADE_STYLE", "trend") or "trend").strip().lower() + +OKX_SOCKS_PROXY = (os.getenv("OKX_SOCKS_PROXY") or "").strip() +OKX_HTTP_PROXY = (os.getenv("OKX_HTTP_PROXY") or "").strip() +OKX_HTTPS_PROXY = (os.getenv("OKX_HTTPS_PROXY") or "").strip() + + +def build_okx_ccxt_proxies(): + """ + 为 ccxt 配置代理(常用于:本地网络对 OKX TLS/SNI 不稳定,通过 SSH 动态转发 SOCKS5 出口). + + 推荐: + - 本机:ssh -N -D 127.0.0.1:1080 user@vps + - .env:OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 + + 说明: + - socks5h 让代理端解析域名(避免本机 DNS/策略差异);若你明确要本机解析可用 socks5:// + """ + socks = OKX_SOCKS_PROXY.strip() + http = OKX_HTTP_PROXY.strip() + https = OKX_HTTPS_PROXY.strip() or http + if socks: + return {"http": socks, "https": socks} + if http or https: + return {"http": http, "https": https} + return None + + +OKX_CCXT_PROXIES = build_okx_ccxt_proxies() + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(ORDER_CHART_DIR, exist_ok=True) +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER + +# 同一套 OKX_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同) +exchange = ccxt.okx({ + "enableRateLimit": True, + "options": {"defaultType": "swap"}, # OKX 用 swap 表示永续 +}) +if OKX_CCXT_PROXIES: + exchange.proxies = OKX_CCXT_PROXIES +if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: + exchange.apiKey = OKX_API_KEY + exchange.secret = OKX_API_SECRET + exchange.password = OKX_API_PASSPHRASE + +exchange_options = ccxt.okx( + { + "enableRateLimit": True, + "options": {"defaultType": "option"}, + } +) +if OKX_CCXT_PROXIES: + exchange_options.proxies = OKX_CCXT_PROXIES +if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: + exchange_options.apiKey = OKX_API_KEY + exchange_options.secret = OKX_API_SECRET + exchange_options.password = OKX_API_PASSPHRASE + +MARKETS_LOADED = False +EXCHANGE_AUTH_DISABLED_MSG = "" +ACCOUNT_BALANCE_CACHE = { + "updated_at": 0.0, + "funding_usdt": None, + "trading_usdt": None +} +LIQUIDITY_RANK_CACHE = { + "updated_at": 0.0, + "version": 0, + "ranks": {}, + "total": 0, +} + +# 企业微信推送 +def send_wechat_msg(content): + send_wechat_webhook( + WECHAT_WEBHOOK, content, timeout=WECHAT_TIMEOUT_SECONDS + ) + + +_BREAKEVEN_EXCHANGE_WARNED_IDS = set() + + +def _send_breakeven_exchange_warn_once(order_id, message): + """移动保本同步交易所失败:同一笔监控单只推送一次,避免轮询刷屏.""" + oid = int(order_id) + if oid in _BREAKEVEN_EXCHANGE_WARNED_IDS: + return + _BREAKEVEN_EXCHANGE_WARNED_IDS.add(oid) + send_wechat_msg(message) + + +def _clear_breakeven_exchange_warn(order_id): + _BREAKEVEN_EXCHANGE_WARNED_IDS.discard(int(order_id)) + + +def _wechat_account_label(): + return (os.getenv("OKX_ACCOUNT_LABEL") or "okx实盘子账户").strip() + + +def _wechat_direction_text(direction): + d = (direction or "").lower() + return "多头(long)" if d == "long" else "空头(short)" + + +def _wechat_trading_capital_text(fallback=None): + try: + _, trading_capital = get_exchange_capitals(force=True) + except Exception: + trading_capital = None + if trading_capital is not None: + return f"{round(float(trading_capital), 2)}U" + if fallback is not None: + try: + return f"{round(float(fallback), 2)}U" + except Exception: + pass + return "-" + + +def format_wechat_scalar_2dp(value): + """企业微信推送:数值统一两位小数(与交易所 tick 无关).""" + if value in (None, ""): + return "-" + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return str(value) + + +def build_wechat_close_message( + symbol, + direction, + result, + pnl_amount, + hold_seconds=None, + trigger_price=None, + current_price=None, + stop_loss=None, + take_profit=None, + close_order_id=None, + extra_note=None, + session_capital_fallback=None, +): + hold_txt = format_hold_minutes(calc_hold_minutes(hold_seconds)) if hold_seconds is not None else "-" + ep = format_price_for_symbol(symbol, trigger_price) + cp = format_price_for_symbol(symbol, current_price) + tp = format_price_for_symbol(symbol, take_profit) + sl = format_wechat_scalar_2dp(stop_loss) + cap_txt = _wechat_trading_capital_text(session_capital_fallback) + try: + if pnl_amount is not None: + pv = float(pnl_amount) + pnl_disp = f"{'+' if pv > 0 else ''}{round(pv, 2)} U" + else: + pnl_disp = "-" + except (TypeError, ValueError): + pnl_disp = "-" + + lines = [ + f"📉 {symbol} 平仓完成", + f"💼 账户:{_wechat_account_label()}", + "", + "🧾 平仓概要", + f"🔖 平仓单号:{close_order_id or '-'}", + f"📌 方向:{_wechat_direction_text(direction)}", + f"📌 平仓结果:{result or '-'}", + f"💰 本单盈亏:{pnl_disp}", + f"⏱ 持仓时长:{hold_txt}", + f"💵 交易账户资金:{cap_txt}", + "", + "🎯 价位(计划)", + f"开仓成交价:{ep}", + f"离场参考价:{cp}", + f"止盈价位:{tp}", + f"止损价位:{sl}", + ] + if extra_note: + lines.extend(["", "📎 备注", extra_note]) + return "\n".join(lines) + + +def build_wechat_breakeven_message(symbol, direction, arm_txt, now_rr, locked_r, new_sl): + return "\n".join( + [ + f"# 🛡️ {symbol} 保护位更新", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 移动保本/止盈", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 类型:**{arm_txt}**", + f"- 当前RR:`{round(float(now_rr), 2)}R`", + f"- 锁定RR:`{round(float(locked_r), 2)}R`", + f"- 新保护位:`{format_wechat_scalar_2dp(new_sl)}`", + ] + ) + + +def build_wechat_monitor_error_message(symbol, direction, scene, error_text): + return "\n".join( + [ + f"# ⚠️ {symbol} 下单监控异常", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 异常信息", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 场景:{scene}", + f"- 错误:{str(error_text)}", + ] + ) + + +def build_wechat_key_monitor_message( + symbol, + direction, + monitor_type, + trigger_time, + key_price, + confirm_close, + hard_lines, + btc8h_status, + coin4h_status, + swing4h_pct, + op_lines, + risk_tip=None, +): + lines = [ + f"# 🎯 {symbol} 关键位确认推送", + f"**账户:{_wechat_account_label()}**", + "", + "---", + "", + "### 交易对 / 触发时间", + f"- 交易对:**{symbol}**", + f"- 触发时间:`{trigger_time}`", + "", + "### 方向与确认K", + f"- 方向:**{_wechat_direction_text(direction)}**", + "- 确认K:第二根5m收盘完成", + "", + "### 关键价位", + f"- 类型:**{monitor_type}**", + f"- 箱体关键位:`{key_price}`", + f"- 第二根确认收盘价:`{confirm_close}`", + "", + "### 硬条件校验结果", + ] + lines.extend([f"- {x}" for x in hard_lines]) + lines.extend( + [ + "", + "### 市场状态说明", + f"- BTC 8h 状态:**{btc8h_status}**", + f"- 本币 4h(EMA55) 状态:**{coin4h_status}**", + f"- 4h震荡幅度(5m近48根):`{round(float(swing4h_pct), 3)}%`", + "", + "### 操作提示", + ] + ) + lines.extend([f"- {x}" for x in op_lines]) + if risk_tip: + lines.extend(["", f"### 逆势风险提醒", f"- {risk_tip}"]) + return "\n".join(lines) + + +def _read_image_base64(image_path): + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception: + return None + + +def _extract_json_object(text): + if not text: + return None + clean = text.strip() + if clean.startswith("```"): + clean = clean.replace("```json", "").replace("```", "").strip() + try: + return json.loads(clean) + except Exception: + pass + match = re.search(r"\{[\s\S]*\}", clean) + if not match: + return None + try: + return json.loads(match.group(0)) + except Exception: + return None + + +def _load_font(size): + if not ImageFont: + return None + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "C:\\Windows\\Fonts\\msyh.ttc", + "C:\\Windows\\Fonts\\arial.ttf", + ] + for path in candidates: + if path and os.path.exists(path): + try: + return ImageFont.truetype(path, size) + except Exception: + continue + try: + return ImageFont.load_default() + except Exception: + return None + + +def _ohlcv_to_rows(ohlcv): + rows = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + try: + rows.append( + { + "ts": int(bar[0]), + "o": float(bar[1]), + "h": float(bar[2]), + "l": float(bar[3]), + "c": float(bar[4]), + "v": float(bar[5]), + } + ) + except Exception: + continue + return rows + + +def _local_input_datetime_to_ms(dt_text): + raw = str(dt_text or "").strip() + if not raw: + return None + raw = raw.replace("T", " ") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + dt = datetime.strptime(raw, fmt) + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + continue + return None + + +def _marker_tag_label(tag): + t = str(tag or "").strip().upper() + if t == "ENTRY": + return "开仓" + if t == "EXIT": + return "平仓" + return str(tag or "") + + +def _pick_marker_point(rows, target_ts_ms, target_price=None): + if not rows or target_ts_ms is None: + return None, None + idx = min(range(len(rows)), key=lambda i: abs(int(rows[i]["ts"]) - int(target_ts_ms))) + if target_price is not None: + try: + p = float(target_price) + if p > 0: + return idx, p + except Exception: + pass + return idx, float(rows[idx]["c"]) + + +def _render_candles_subplot(rows, title, width, height, bg_rgb=(255, 255, 255), marker_points=None): + if not Image or not ImageDraw: + raise RuntimeError("缺少依赖:Pillow(pip install Pillow)") + img = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(img) + font = _load_font(14) + small = _load_font(12) + + pad_l, pad_r, pad_t, pad_b = 46, 12, 26, 28 + plot_w = max(10, width - pad_l - pad_r) + plot_h = max(10, height - pad_t - pad_b) + + header_bg = (245, 247, 250) + draw.rectangle((0, 0, width, pad_t), fill=header_bg) + if font: + draw.text((10, 6), title, fill=(25, 35, 60), font=font) + else: + draw.text((10, 6), title, fill=(25, 35, 60)) + + if not rows: + if small: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120), font=small) + else: + draw.text((pad_l, pad_t + 10), "无K线数据", fill=(90, 100, 120)) + return img + + lo = min(r["l"] for r in rows) + hi = max(r["h"] for r in rows) + if hi <= lo: + hi = lo + 1e-12 + + n = len(rows) + marker_by_idx = {} + for mp in marker_points or []: + try: + idx = int(mp.get("idx")) + except Exception: + continue + if idx < 0 or idx >= n: + continue + marker_by_idx.setdefault(idx, []).append(mp) + + x0 = pad_l + for i, r in enumerate(rows): + x1 = pad_l + int((i + 1) * plot_w / n) + x_mid = (x0 + x1) // 2 + wick_x = x_mid + y_high = pad_t + int((hi - r["h"]) / (hi - lo) * plot_h) + y_low = pad_t + int((hi - r["l"]) / (hi - lo) * plot_h) + y_open = pad_t + int((hi - r["o"]) / (hi - lo) * plot_h) + y_close = pad_t + int((hi - r["c"]) / (hi - lo) * plot_h) + top = min(y_open, y_close) + bot = max(y_open, y_close) + up = r["c"] >= r["o"] + wick_color = (120, 120, 120) + edge_color = (20, 20, 20) + draw.line((wick_x, y_high, wick_x, y_low), fill=wick_color) + body_w = max(1, (x1 - x0) - 2) + left = x0 + 1 + if bot - top < 2: + mid = (top + bot) // 2 + draw.rectangle((left, mid, left + body_w, mid + 1), fill=edge_color) + else: + if up: + draw.rectangle((left, top, left + body_w, bot), fill=(255, 255, 255), outline=edge_color, width=1) + else: + draw.rectangle((left, top, left + body_w, bot), fill=edge_color, outline=edge_color, width=1) + for j, mp in enumerate(marker_by_idx.get(i, [])): + tag = str(mp.get("tag") or "") + label = _marker_tag_label(tag) + m_price = float(mp.get("price") or r["c"]) + y_m = pad_t + int((hi - m_price) / (hi - lo) * plot_h) + y_m = max(pad_t + 4, min(pad_t + plot_h - 4, y_m)) + x_off = (j - (len(marker_by_idx[i]) - 1) / 2.0) * 14 + x_draw = int(x_mid + x_off) + if tag == "ENTRY": + m_color = (0, 195, 95) + tri = [(x_draw, y_m - 20), (x_draw - 9, y_m - 4), (x_draw + 9, y_m - 4)] + text_y = y_m - 36 + else: + m_color = (235, 65, 65) + tri = [(x_draw, y_m + 20), (x_draw - 9, y_m + 4), (x_draw + 9, y_m + 4)] + text_y = y_m + 12 + draw.ellipse((x_draw - 5, y_m - 5, x_draw + 5, y_m + 5), fill=m_color, outline=(255, 255, 255), width=1) + draw.polygon(tri, fill=m_color) + draw.line((x_draw, y_m, x_draw, y_m - 16 if tag == "ENTRY" else y_m + 16), fill=m_color, width=3) + if font: + draw.text((x_draw + 8, text_y), label, fill=m_color, font=font) + else: + draw.text((x_draw + 8, text_y), label, fill=m_color) + x0 = x1 + + if len(marker_points or []) >= 2: + try: + entry = next((m for m in marker_points if m.get("tag") == "ENTRY"), None) + exitp = next((m for m in marker_points if m.get("tag") == "EXIT"), None) + if entry is not None and exitp is not None: + ex_i, ex_p = int(entry["idx"]), float(entry["price"]) + xx_i, xx_p = int(exitp["idx"]), float(exitp["price"]) + x_ex = pad_l + int((ex_i + 0.5) * plot_w / n) + x_xx = pad_l + int((xx_i + 0.5) * plot_w / n) + y_ex = pad_t + int((hi - ex_p) / (hi - lo) * plot_h) + y_xx = pad_t + int((hi - xx_p) / (hi - lo) * plot_h) + draw.line((x_ex, y_ex, x_xx, y_xx), fill=(35, 135, 255), width=3) + except Exception: + pass + + # 极简风格:不画网格与坐标轴,仅保留右下角轻量区间信息 + if small: + draw.text((width - 210, height - 22), f"L={lo:.6g} H={hi:.6g}", fill=(120, 125, 135), font=small) + return img + + +def _timeframe_period_ms(tf): + s = (tf or "").strip().lower() + if s.endswith("m"): + try: + return int(s[:-1]) * 60 * 1000 + except ValueError: + pass + if s.endswith("h"): + try: + return int(s[:-1]) * 3600 * 1000 + except ValueError: + pass + if s.endswith("d"): + try: + return int(s[:-1]) * 86400 * 1000 + except ValueError: + pass + return 300000 + + +def _ohlcv_dict_rows_to_lists(rows, lim): + if not rows: + return [] + pick = rows[-lim:] if len(rows) >= lim else rows + return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] + + +def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): + lim = max(2, int(limit or ORDER_CHART_LIMIT)) + try: + if not end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim) + else: + period = _timeframe_period_ms(timeframe) + since = int(end_ts_ms) - period * (lim + 10) + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20 + ) + except Exception: + return [] + rows = _ohlcv_to_rows(ohlcv) + if not rows: + return [] + if not end_ts_ms: + return _ohlcv_dict_rows_to_lists(rows, lim) + filtered = [r for r in rows if int(r["ts"]) <= int(end_ts_ms)] + if len(filtered) >= 2: + return _ohlcv_dict_rows_to_lists(filtered, lim) + return _ohlcv_dict_rows_to_lists(rows, lim) + + +def generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + out_dir=None, + filename=None, + filename_prefix="chart", + marker_payload=None, + marker_timeframes=None, + layout="grid", +): + if not ORDER_CHART_ENABLED: + return None + if not Image: + return None + requested = list(timeframes or ORDER_CHART_TFS) + limit = limit or ORDER_CHART_LIMIT + if layout == "vertical": + timeframes = requested[:2] if requested else [JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF2] + else: + preferred_layout = ["5m", "15m", "1h", "4h"] + requested_set = set(requested or []) + ordered = [tf for tf in preferred_layout if tf in requested_set] + for tf in requested: + if tf not in ordered: + ordered.append(tf) + timeframes = ordered[:4] if ordered else preferred_layout + + ensure_markets_loaded() + panels = [] + cell_w, cell_h = 980, 520 + end_ts_ms = None + if marker_payload: + try: + end_ts_ms = int(marker_payload.get("exit_ts_ms") or marker_payload.get("entry_ts_ms") or 0) or None + except (TypeError, ValueError): + end_ts_ms = None + default_marker_tfs = {str(t).strip().lower() for t in timeframes} + price_levels = price_levels_from_marker_payload(marker_payload) + for tf in timeframes: + rows = [] + try: + if layout == "vertical" and marker_payload: + win = trade_review_fetch_window( + marker_payload.get("entry_ts_ms"), + marker_payload.get("exit_ts_ms"), + tf, + limit, + anchor=marker_payload.get("chart_anchor"), + now_ms=marker_payload.get("now_ts_ms"), + ) + if win: + ohlcv = exchange.fetch_ohlcv( + exchange_symbol, + timeframe=tf, + since=max(0, int(win["since_ms"])), + limit=int(win["fetch_limit"]), + ) + rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) + if not rows: + ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) + if not ohlcv and end_ts_ms: + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) + rows = _ohlcv_to_rows(ohlcv)[-limit:] + except Exception: + rows = [] + title = f"{title_prefix} | {tf} x{len(rows)}" + tf_key = str(tf).strip().lower() + if marker_payload: + if marker_timeframes: + marker_tfs = {str(x).strip().lower() for x in marker_timeframes if str(x).strip()} + else: + marker_tfs = default_marker_tfs + else: + marker_tfs = set() + points = ( + marker_points_for_timeframe(rows, marker_payload) + if marker_payload and tf_key in marker_tfs + else [] + ) + panels.append( + render_candles_subplot( + rows, + title, + width=cell_w, + height=cell_h, + bg_rgb=(255, 255, 255), + marker_points=points, + price_levels=price_levels, + ) + ) + + if not panels: + return None + + out = compose_chart_panels(panels, layout=layout, cell_w=cell_w, cell_h=cell_h, gap=10) + if out is None: + return None + + target_dir = out_dir or ORDER_CHART_DIR + os.makedirs(target_dir, exist_ok=True) + fname = filename or f"{filename_prefix}_{uuid.uuid4().hex}.png" + out_path = os.path.join(target_dir, fname) + out.save(out_path, format="PNG") + return fname + + +def generate_order_open_chart( + exchange_symbol, + title_prefix, + timeframes=None, + limit=None, + opened_at_ms=None, + entry_price=None, +): + marker_payload = None + if opened_at_ms: + marker_payload = { + "entry_ts_ms": opened_at_ms, + "exit_ts_ms": None, + "entry_price": entry_price, + "exit_price": None, + } + marker_tfs = ( + {x.strip().lower() for x in (timeframes or ORDER_CHART_TFS) if x and str(x).strip()} + or {"5m", "15m", "1h", "4h"} + ) + return generate_multi_timeframe_chart_png( + exchange_symbol, + title_prefix, + timeframes=timeframes, + limit=limit, + out_dir=ORDER_CHART_DIR, + filename=None, + filename_prefix="order", + marker_payload=marker_payload, + marker_timeframes=marker_tfs, + ) + + +def journal_coin_from_symbol(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym.split("/")[0].strip() + if "-" in sym: + return sym.split("-")[0].strip() + if sym.endswith("USDT"): + return sym[:-4].strip() + return sym + + +EARLY_EXIT_TRIGGERS = ( + "", + "止盈", + "保本止盈", + "移动止盈", + TIME_CLOSE_RESULT, + "强制清仓", + "手动平仓", + "止损", + "其他", +) + +# 趋势户:复盘开仓类型仅 entry model;策略/风格项已拆至下单类型 +ENTRY_REASON_OPTIONS = build_journal_entry_reason_options() + +STATS_SEGMENT_DEFS = ( + ("all", "全部交易", {"segment": "all"}), + ("manual", "下单监控", {"segment": "manual"}), + ("key_box", "关键位箱体突破", {"segment": "key_box"}), + ("key_conv", "关键位收敛结构", {"segment": "key_conv"}), + ("key_fib618", "关键位斐波0.618", {"segment": "key_fib618"}), + ("key_fib786", "关键位斐波0.786", {"segment": "key_fib786"}), + ("key_false_breakout", "关键位假突破", {"segment": "key_false_breakout"}), + ("key_trigger", "关键位触价开仓", {"segment": "key_trigger"}), +) +def normalize_entry_reason(raw, custom_text=None): + del custom_text + return normalize_journal_entry_reason(raw, ENTRY_REASON_OPTIONS, allow_legacy=True) + + +def normalize_early_exit_trigger(raw): + v = str(raw or "").strip() + return v if v in EARLY_EXIT_TRIGGERS else "" + + +def compose_early_exit_reason_saved(trigger, note): + """Readable single-line string stored in early_exit_reason for legacy consumers.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t and n: + return f"{t}|{n}" + return t or n + + +def journal_exit_reason_stored(trigger, note): + """exit_reason 列与表单「一处」对齐:非手工=触发类型;手工=离场说明全文.""" + t = normalize_early_exit_trigger(trigger) + n = str(note or "").strip() + if t == "手动平仓": + return n + return t + + +# 初始化数据库(支持多空方向) +def init_db(): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # 关键位监控 + c.execute('''CREATE TABLE IF NOT EXISTS key_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", upper REAL, lower REAL, + notification_count INTEGER DEFAULT 0, last_notified_at TEXT, + max_notify INTEGER DEFAULT 3, notify_interval_min INTEGER DEFAULT 5, + breakout_limit_pct REAL DEFAULT 1.5, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # 订单监控(核心:加 direction 方向字段) + c.execute('''CREATE TABLE IF NOT EXISTS order_monitors + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, direction TEXT DEFAULT "long", + exchange_symbol TEXT, + trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL DEFAULT 30, leverage INTEGER DEFAULT 5, + trade_style TEXT DEFAULT "trend", + risk_percent REAL, risk_amount REAL, + breakeven_rr_trigger REAL, breakeven_offset_pct REAL, breakeven_step_r REAL, + breakeven_armed INTEGER DEFAULT 0, breakeven_price REAL, + notional_value REAL, position_ratio REAL, base_amount REAL, + order_amount REAL, exchange_order_id TEXT, exchange_close_order_id TEXT, + opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, opened_at_ms INTEGER, session_date TEXT, + status TEXT DEFAULT "active")''') + + # 交易记录(必须存多空) + c.execute('''CREATE TABLE IF NOT EXISTS trade_records + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, + direction TEXT DEFAULT "long", trigger_price REAL, stop_loss REAL, initial_stop_loss REAL, take_profit REAL, + margin_capital REAL, leverage INTEGER, pnl_amount REAL DEFAULT 0, hold_seconds INTEGER DEFAULT 0, + trade_style TEXT DEFAULT "trend", risk_amount REAL, planned_rr REAL, actual_rr REAL, + hold_minutes INTEGER DEFAULT 0, opened_at TEXT, opened_at_ms INTEGER, closed_at TEXT, closed_at_ms INTEGER, + result TEXT, miss_reason TEXT, exchange_trade_id TEXT, + reviewed_opened_at TEXT, reviewed_closed_at TEXT, reviewed_stop_loss REAL, reviewed_take_profit REAL, reviewed_pnl_amount REAL, + reviewed_result TEXT, reviewed_miss_reason TEXT, reviewed_hold_seconds INTEGER, reviewed_hold_minutes INTEGER, + reviewed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS trading_sessions + (session_date TEXT PRIMARY KEY, start_capital REAL, current_capital REAL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS journal_entries + (id TEXT PRIMARY KEY, open_datetime TEXT, close_datetime TEXT, hold_duration TEXT, + coin TEXT, tf TEXT, pnl TEXT, entry_reason TEXT, exit_reason TEXT, + expect_rr TEXT, real_rr TEXT, early_exit TEXT, early_exit_reason TEXT, + early_exit_trigger TEXT, early_exit_note TEXT, + mood_score INTEGER, mood_ai_score INTEGER, mood_ai_comment TEXT, mood_issues TEXT, post_breakeven_stare TEXT, + new_trade_while_occupied TEXT, note TEXT, image TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS ai_reviews + (id TEXT PRIMARY KEY, review_type TEXT, target_date TEXT, content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + c.execute('''CREATE TABLE IF NOT EXISTS transfer_logs + (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, + amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""" + ) + c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') + c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique + ON transfer_logs(transfer_type, transfer_day) + WHERE transfer_type = 'auto_daily' ''') + + # 给旧表加 direction 字段(兼容老数据,不报错) + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_symbol TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN margin_capital REAL DEFAULT 30") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN leverage INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_percent REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_rr_trigger REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_offset_pct REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_step_r REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_armed INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_price REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN notional_value REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN position_ratio REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN base_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN order_amount REAL") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN exchange_close_order_id TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN session_date TEXT") + except: pass + try: + c.execute("UPDATE order_monitors SET opened_at = datetime('now') WHERE opened_at IS NULL OR opened_at = ''") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN margin_capital REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN leverage INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN pnl_amount REAL DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_seconds INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN hold_minutes INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN trade_style TEXT DEFAULT 'trend'") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN risk_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN planned_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN actual_rr REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN initial_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN exchange_trade_id TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN opened_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN closed_at_ms INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_opened_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_closed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_stop_loss REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_take_profit REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_pnl_amount REAL") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_result TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_miss_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_seconds INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_hold_minutes INTEGER") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_at TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN entry_reason TEXT") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN reviewed_entry_reason TEXT") + except: pass + for ddl in ( + "ALTER TABLE trade_records ADD COLUMN exchange_realized_pnl REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_opened_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_closed_at TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_sync_key TEXT", + "ALTER TABLE trade_records ADD COLUMN exchange_turnover_usdt REAL", + "ALTER TABLE trade_records ADD COLUMN exchange_commission_usdt REAL", + ): + try: + c.execute(ddl) + except Exception: + pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_score INTEGER") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN mood_ai_comment TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_trigger TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN order_type TEXT") + except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN direction TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notification_count INTEGER DEFAULT 0") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN last_notified_at TEXT") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN max_notify INTEGER DEFAULT 3") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN notify_interval_min INTEGER DEFAULT 5") + except: pass + try: + c.execute("ALTER TABLE key_monitors ADD COLUMN breakout_limit_pct REAL DEFAULT 1.5") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN key_signal_type TEXT") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN monitor_type TEXT DEFAULT '下单监控'") + except: pass + try: + c.execute("ALTER TABLE order_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 1") + except: pass + try: + c.execute("ALTER TABLE trade_records ADD COLUMN key_signal_type TEXT") + except: pass + for ddl in ( + "ALTER TABLE key_monitors ADD COLUMN fib_limit_order_id TEXT", + "ALTER TABLE key_monitors ADD COLUMN fib_entry_price REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_stop_loss REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_order_amount REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_margin_capital REAL", + "ALTER TABLE key_monitors ADD COLUMN fib_leverage INTEGER", + "ALTER TABLE key_monitors ADD COLUMN sl_tp_mode TEXT DEFAULT 'standard'", + "ALTER TABLE key_monitors ADD COLUMN manual_take_profit REAL", + "ALTER TABLE key_monitors ADD COLUMN breakeven_enabled INTEGER DEFAULT 0", + "ALTER TABLE key_monitors ADD COLUMN last_rs_bar_ts INTEGER", + "ALTER TABLE key_monitors ADD COLUMN session_date TEXT", + ): + try: + c.execute(ddl) + except Exception: + pass + ensure_time_close_schema(c) + ensure_key_monitor_schema(c) + try: + c.execute("ALTER TABLE trading_sessions ADD COLUMN key_sizing_capital_snapshot REAL") + except Exception: + pass + + c.execute( + """CREATE TABLE IF NOT EXISTS key_monitor_history + (id INTEGER PRIMARY KEY AUTOINCREMENT, symbol TEXT, monitor_type TEXT, direction TEXT, + upper REAL, lower REAL, notification_count INTEGER, last_alert_message TEXT, + close_reason TEXT, closed_at TEXT)""" + ) + + from lib.strategy.strategy_db import init_strategy_tables +from lib.options.options_db import init_options_tables +from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables +from lib.sim.db_lib import init_sim_tables + +init_strategy_tables(conn) +init_options_tables(conn) +init_hedge_plan_tables(conn) +init_sim_tables(conn) + from lib.trade.account_risk_lib import ensure_account_risk_schema + + ensure_account_risk_schema(conn) + migrate_entry_model_columns(conn) + backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) + conn.commit() + conn.close() + +init_db() + + +def _purge_key_monitors_if_full_margin(): + if not is_full_margin_mode(POSITION_SIZING_MODE): + return + conn = get_db() + try: + purge_disallowed_key_monitors( + conn, + sizing_mode=POSITION_SIZING_MODE, + select_rows=lambda c: c.execute("SELECT * FROM key_monitors").fetchall(), + cancel_fib_limit=_cancel_fib_monitor_limit, + delete_monitor=lambda c, kid: c.execute("DELETE FROM key_monitors WHERE id=?", (kid,)), + send_wechat=send_wechat_msg, + ) + conn.commit() + except Exception as e: + print(f"[full_margin] purge key monitors: {e}", flush=True) + finally: + conn.close() + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def hub_account_risk_status(conn): + from lib.trade.account_risk_lib import ( + apply_position_limit_risk, + compute_account_risk_status, + enrich_risk_status_countdown, + ensure_account_risk_schema, + ) + + ensure_account_risk_schema(conn) + now = app_now() + st = compute_account_risk_status( + conn, + trading_day=get_trading_day(), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + return apply_position_limit_risk( + st, + count_position_limit_active_monitors(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) + + +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + +def app_now(): + """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较).""" + return datetime.now(APP_TZ).replace(tzinfo=None) + + +def app_now_str(): + return app_now().strftime("%Y-%m-%d %H:%M:%S") + + +def utc_now_dt(): + """当前时刻(UTC,aware).""" + return datetime.now(timezone.utc) + + +def utc_calendar_date_str(): + """UTC 自然日 YYYY-MM-DD(用于自动划转去重等与交易所日界对齐的计算).""" + return utc_now_dt().strftime("%Y-%m-%d") + + +def get_trading_day(now=None): + """交易日字符串:本地时钟下若小时 < TRADING_DAY_RESET_HOUR 则归属「上一日历日」.""" + now = now or app_now() + if getattr(now, "tzinfo", None): + now = now.astimezone(APP_TZ).replace(tzinfo=None) + if now.hour < TRADING_DAY_RESET_HOUR: + return (now - timedelta(days=1)).strftime("%Y-%m-%d") + return now.strftime("%Y-%m-%d") + + +TRADE_COMPLETED_RESULTS = ( + "止盈", + "止损", + "保本止盈", + "移动止盈", + "手动平仓", + "强制清仓", + "外部平仓", + TIME_CLOSE_RESULT, +) + +REVIEW_RESULT_OPTIONS = ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓", TIME_CLOSE_RESULT) + + +def parse_dt_for_trading_day(s): + if not s: + return None + s = str(s).strip().replace("Z", "").replace("T", " ") + if not s: + return None + for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:ln], fmt) + except ValueError: + continue + return None + + +def insert_key_monitor_history(conn, row, notification_count, last_msg, close_reason): + conn.execute( + """INSERT INTO key_monitor_history + (symbol, monitor_type, direction, upper, lower, notification_count, last_alert_message, close_reason, closed_at) + VALUES (?,?,?,?,?,?,?,?,?)""", + ( + row["symbol"], + row["monitor_type"], + row["direction"] or "long", + row["upper"], + row["lower"], + int(notification_count or 0), + (last_msg or "")[:800] if last_msg else None, + close_reason, + app_now_str(), + ), + ) + + +def _session_week_bounds(trading_day_str): + end = datetime.strptime(trading_day_str, "%Y-%m-%d").date() + start = end - timedelta(days=6) + return start.strftime("%Y-%m-%d"), trading_day_str + + +def _calendar_month_bounds(local_dt): + y, m = local_dt.year, local_dt.month + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end_d = datetime(y, 12, 31).date() + else: + end_d = (datetime(y, m + 1, 1) - timedelta(days=1)).date() + return start, end_d.strftime("%Y-%m-%d") + + +def _count_opens_between(conn, start_td, end_td): + return _count_opens_for_segment(conn, start_td, end_td, "all") + + +def _list_window_from_request(): + return resolve_list_window(request.args, session, default_preset=PRESET_DEFAULT) + + +def _redirect_records(): + qs = list_window_redirect_query(session) + return redirect(f"/records?{qs}" if qs else "/records") + + +def _pnl_row_matches_segment(row, segment_key): + try: + mt = (row["monitor_type"] or "").strip() + kst = (row["key_signal_type"] or "").strip() + except Exception: + return False + if segment_key == "all": + return True + if segment_key == "manual": + return mt == ORDER_MONITOR_TYPE_MANUAL and not kst + if segment_key == "key_box": + return kst == "箱体突破" + if segment_key == "key_conv": + return kst == "收敛突破" + if segment_key == "key_fib618": + return kst == "斐波回调0.618" + if segment_key == "key_fib786": + return kst == "斐波回调0.786" + if segment_key == "key_false_breakout": + return kst == FALSE_BREAKOUT_MONITOR_TYPE + if segment_key == "key_trigger": + return kst in TRIGGER_ENTRY_MONITOR_TYPES + return False + + +def _count_opens_for_segment(conn, start_td, end_td, segment_key): + if segment_key == "manual": + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + "AND (monitor_type IS NULL OR monitor_type=? OR TRIM(monitor_type)='') " + "AND (key_signal_type IS NULL OR TRIM(key_signal_type)='')", + (start_td, end_td, ORDER_MONITOR_TYPE_MANUAL), + ).fetchone()[0] + kst_map = { + "key_box": "箱体突破", + "key_conv": "收敛突破", + "key_fib618": "斐波回调0.618", + "key_fib786": "斐波回调0.786", + "key_false_breakout": FALSE_BREAKOUT_MONITOR_TYPE, + "key_trigger": None, # 见 _count_opens_for_segment 多类型 + } + if segment_key == "key_trigger": + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + return conn.execute( + f"SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? " + f"AND key_signal_type IN ({placeholders})", + (start_td, end_td, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone()[0] + kst = kst_map.get(segment_key) + if kst: + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ? AND key_signal_type=?", + (start_td, end_td, kst), + ).fetchone()[0] + return conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date >= ? AND session_date <= ?", + (start_td, end_td), + ).fetchone()[0] + + +def _load_completed_trade_pnls(conn): + q = """SELECT pnl_amount, reviewed_pnl_amount, closed_at, reviewed_closed_at, created_at, opened_at, + result, reviewed_result, monitor_type, key_signal_type + FROM trade_records + ORDER BY COALESCE(closed_at, created_at, opened_at) ASC, id ASC""" + rows = conn.execute(q).fetchall() + out = [] + for r in rows: + effective_result = (r["reviewed_result"] or r["result"] or "").strip() + if effective_result not in TRADE_COMPLETED_RESULTS: + continue + try: + p = float(r["reviewed_pnl_amount"] if r["reviewed_pnl_amount"] is not None else (r["pnl_amount"] or 0)) + except (TypeError, ValueError): + p = 0.0 + t = parse_dt_for_trading_day(r["reviewed_closed_at"]) or parse_dt_for_trading_day(r["closed_at"]) or parse_dt_for_trading_day(r["created_at"]) + td = get_trading_day(t) if t else None + out.append((p, t, td, r)) + return out + + +def _compute_period_metrics(trades): + """trades: list of (pnl, close_dt, close_trading_day)""" + trades = [(p, t, td) for p, t, td in trades if t is not None] + trades.sort(key=lambda x: x[1]) + closed = len(trades) + wins = sum(1 for p, _, _ in trades if p > 0) + losses = sum(1 for p, _, _ in trades if p < 0) + net = round(sum(p for p, _, _ in trades), 4) + 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 + 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 + 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) + streak = 0 + for p, _, _ in reversed(trades): + if p < 0: + streak += 1 + else: + break + daily = {} + for p, _, td in trades: + if td: + daily[td] = daily.get(td, 0.0) + p + max_loss_streak_days = 0 + worst_day = None + worst_day_pnl = None + if daily: + sorted_days = sorted(daily.keys()) + run = 0 + for d in sorted_days: + if daily[d] < 0: + run += 1 + max_loss_streak_days = max(max_loss_streak_days, run) + else: + run = 0 + worst_day = min(daily.keys(), key=lambda x: daily[x]) + worst_day_pnl = round(daily[worst_day], 4) + win_rate_pct = round(wins / (wins + losses) * 100, 2) if (wins + losses) else None + return { + "closed_count": closed, + "win_count": wins, + "loss_count": losses, + "win_rate_pct": win_rate_pct, + "net_pnl_u": net, + "loss_sum_u": loss_sum_u, + "max_single_loss": max_single_loss, + "max_single_profit": max_single_profit, + "max_drawdown_u": max_dd, + "consecutive_losses": streak, + "max_loss_streak_days": max_loss_streak_days, + "worst_day": worst_day, + "worst_day_pnl": worst_day_pnl, + "opens_count": 0, + "range_label": "", + } + + +def _bounds_for_month_key(ym): + """ym: YYYY-MM → 该自然月首末日(北京日历).""" + y, m = [int(x) for x in str(ym).split("-", 1)] + start = f"{y:04d}-{m:02d}-01" + if m == 12: + end = f"{y:04d}-12-31" + else: + end = (datetime(y, m + 1, 1) - timedelta(days=1)).date().strftime("%Y-%m-%d") + return start, end + + +def _build_monthly_stats_rows(conn, all_tr, seg_key): + """按北京交易日所在自然月聚合;新月在前.""" + by_month = {} + for p, t, td in all_tr: + if not td or len(str(td)) < 7: + continue + mk = str(td)[:7] + by_month.setdefault(mk, []).append((p, t, td)) + rows = [] + for mk in sorted(by_month.keys(), reverse=True): + metrics = _compute_period_metrics(by_month[mk]) + ms, me = _bounds_for_month_key(mk) + metrics["opens_count"] = _count_opens_for_segment(conn, ms, me, seg_key) + metrics["range_label"] = f"{ms} ~ {me}" + metrics["month_key"] = mk + rows.append(metrics) + return rows + + +def compute_stats_bundle(conn, trading_day, now_dt=None): + """日 / 周 / 月 / 全部 统计:平仓按北京时间交易日(默认 8:00 切日)计入.""" + now_dt = now_dt or app_now() + pnls = _load_completed_trade_pnls(conn) + total_opens_all = conn.execute("SELECT COUNT(*) FROM order_monitors").fetchone()[0] + w_start, w_end = _session_week_bounds(trading_day) + m_start, m_end = _calendar_month_bounds(now_dt) + + def 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] + all_tr = [(p, t, td) for p, t, td, _r in seg_rows if t] + dm = _compute_period_metrics(day_tr) + wm = _compute_period_metrics(week_tr) + mm = _compute_period_metrics(month_tr) + am = _compute_period_metrics(all_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) + am["opens_count"] = _count_opens_for_segment(conn, "1970-01-01", "9999-12-31", 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}(北京自然月)" + tds = [td for _, _, td in all_tr if td] + if tds: + am["range_label"] = f"全部历史 {min(tds)} ~ {max(tds)}(北京交易日)" + else: + am["range_label"] = "全部历史(暂无平仓)" + am["monthly_rows"] = _build_monthly_stats_rows(conn, all_tr, seg_key) + return dm, wm, mm, am + + segments = [] + seg_defs = effective_stats_segment_defs( + STATS_SEGMENT_DEFS, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + for seg_key, seg_title, _meta in seg_defs: + dm, wm, mm, am = slice_metrics(seg_key) + segments.append( + {"key": seg_key, "title": seg_title, "day": dm, "week": wm, "month": mm, "all": am} + ) + + dm, wm, mm, am = slice_metrics("all") + + return { + "trading_day": trading_day, + "total_opens_all": total_opens_all, + "day": dm, + "week": wm, + "month": mm, + "all": am, + "segments": segments, + "stats_reset_hour": TRADING_DAY_RESET_HOUR, + } + + +def infer_leverage(symbol): + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return BTC_LEVERAGE + return ALT_LEVERAGE + + +def normalize_okx_symbol(symbol): + sym = symbol.strip().upper() + if ":" in sym: + return sym + if "/" in sym: + base, quote = sym.split("/", 1) + quote_clean = quote.split(":")[0] + return f"{base}/{quote_clean}:{quote_clean}" + return sym + + +def resolve_monitor_exchange_symbol(row): + raw = "" + try: + if row["exchange_symbol"]: + raw = str(row["exchange_symbol"]).strip() + except (KeyError, IndexError, TypeError): + raw = "" + if not raw: + try: + raw = str(row["symbol"] or "").strip() + except (KeyError, IndexError, TypeError): + raw = "" + return normalize_okx_symbol(raw) if raw else "" + + +def round_price_to_exchange(exchange_symbol, 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() + return float(exchange.price_to_precision(exchange_symbol, v)) + except Exception: + return v + + +def normalize_symbol_input(symbol): + sym = (symbol or "").strip().upper() + if not sym: + return "" + if "/" in sym: + return sym + if ":" in sym: + sym = sym.split(":")[0] + return f"{sym}/USDT" + + +def validate_trade_policy_open(symbol, direction): + return check_open_policy( + TRADE_POLICY, symbol, direction, normalize_symbol_input + ) + + +def normalize_kline_limit(limit_raw, default=200): + try: + n = int(limit_raw) + except Exception: + return default + return 200 if n >= 200 else 100 + + +def get_recommended_capital(current_capital): + if current_capital <= DAILY_LOSS_CAPITAL: + return DAILY_LOSS_CAPITAL + if current_capital >= DAILY_PROFIT_CAPITAL: + return DAILY_PROFIT_CAPITAL + return DAILY_START_CAPITAL + + +def ensure_session(conn, session_date): + row = conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + if row: + return row + conn.execute( + "INSERT INTO trading_sessions (session_date, start_capital, current_capital) VALUES (?,?,?)", + (session_date, DAILY_START_CAPITAL, DAILY_START_CAPITAL) + ) + conn.commit() + return conn.execute( + "SELECT * FROM trading_sessions WHERE session_date = ?", + (session_date,) + ).fetchone() + + +def update_session_capital(conn, session_date, pnl_amount): + session_row = ensure_session(conn, session_date) + new_capital = float(session_row["current_capital"]) + float(pnl_amount) + conn.execute( + "UPDATE trading_sessions SET current_capital = ?, updated_at = CURRENT_TIMESTAMP WHERE session_date = ?", + (round(new_capital, 4), session_date) + ) + conn.commit() + return round(new_capital, 4) + + +def calc_hold_seconds(opened_at_str, closed_at_dt): + try: + opened_at = datetime.strptime(opened_at_str, "%Y-%m-%d %H:%M:%S") + return int((closed_at_dt - opened_at).total_seconds()) + except Exception: + return 0 + + +def calc_hold_minutes(seconds): + if not seconds or seconds <= 0: + return 0 + return max(1, int(seconds // 60)) + + +def get_opened_at_value(row): + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "opened_at" in keys: + value = row["opened_at"] + if value: + return value + return app_now_str() + + +def get_effective_trade_field(row, reviewed_key, base_key, default=None): + try: + keys = row.keys() if hasattr(row, "keys") else row.keys() + except Exception: + keys = [] + if reviewed_key in keys: + v = row[reviewed_key] + if v is not None and str(v).strip() != "": + return v + if base_key in keys: + v = row[base_key] + if v is not None and str(v).strip() != "": + return v + return default + + +def to_effective_trade_dict(row): + item = row_to_dict(row) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + open_stop = snapshot_stop_loss(item.get("initial_stop_loss"), item.get("stop_loss")) + item["display_open_stop_loss"] = open_stop + item["effective_opened_at"] = get_effective_trade_field(row, "reviewed_opened_at", "opened_at", item.get("opened_at")) + item["effective_closed_at"] = get_effective_trade_field(row, "reviewed_closed_at", "closed_at", item.get("closed_at")) + item["effective_stop_loss"] = get_effective_trade_field(row, "reviewed_stop_loss", "stop_loss", open_stop) + item["effective_take_profit"] = get_effective_trade_field(row, "reviewed_take_profit", "take_profit", item.get("take_profit")) + item["effective_result"] = get_effective_trade_field(row, "reviewed_result", "result", item.get("result")) + item["effective_miss_reason"] = get_effective_trade_field(row, "reviewed_miss_reason", "miss_reason", item.get("miss_reason")) + item["effective_pnl_amount"] = get_effective_trade_field(row, "reviewed_pnl_amount", "pnl_amount", item.get("pnl_amount")) + item["effective_hold_minutes"] = get_effective_trade_field(row, "reviewed_hold_minutes", "hold_minutes", item.get("hold_minutes")) + item["effective_hold_seconds"] = get_effective_trade_field(row, "reviewed_hold_seconds", "hold_seconds", item.get("hold_seconds")) + try: + _er_keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _er_keys = [] + reviewed_er = row["reviewed_entry_reason"] if "reviewed_entry_reason" in _er_keys else None + item["effective_entry_reason"] = resolve_effective_trade_entry_reason( + reviewed_entry_reason=reviewed_er, + entry_reason=item.get("entry_reason"), + entry_model=item.get("entry_model"), + key_signal_type=(item.get("key_signal_type") or "").strip() or None, + monitor_type=item.get("monitor_type"), + trade_style=item.get("trade_style"), + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + try: + _keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + _keys = [] + _reviewed_pnl_raw = row["reviewed_pnl_amount"] if "reviewed_pnl_amount" in _keys else None + has_reviewed_pnl = _reviewed_pnl_raw is not None and str(_reviewed_pnl_raw).strip() != "" + ex_pnl = item.get("exchange_realized_pnl") + if not has_reviewed_pnl and ex_pnl is not None and str(ex_pnl).strip() != "": + try: + item["effective_pnl_amount"] = round(float(ex_pnl), FUNDS_DECIMALS) + item["display_pnl_source"] = "exchange" + ex_open = (str(item.get("exchange_opened_at") or "").strip() or None) + ex_close = (str(item.get("exchange_closed_at") or "").strip() or None) + if ex_open: + item["effective_opened_at"] = ex_open + if ex_close: + item["effective_closed_at"] = ex_close + except (TypeError, ValueError): + item["display_pnl_source"] = "local" + elif has_reviewed_pnl: + item["display_pnl_source"] = "reviewed" + else: + item["display_pnl_source"] = "local" + item["effective_result"] = normalize_result_with_pnl( + item.get("effective_result"), + item.get("effective_pnl_amount"), + ) + item["effective_result"] = apply_force_close_display_result( + item.get("effective_result"), + item.get("effective_closed_at"), + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + return item + + +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) + if v == 0: + return "0" + try: + ex_sym = normalize_okx_symbol(str(symbol or "").strip()) if symbol else "" + if ex_sym: + ensure_markets_loaded() + return str(exchange.price_to_precision(ex_sym, v)) + except Exception: + pass + av = abs(v) + # 无法加载市场或无该合约时:按价格量级回退(尽量不阻断页面) + if av >= 10000: + d = 2 + elif av >= 100: + d = 3 + elif av >= 1: + d = 4 + elif av >= 0.01: + d = 6 + elif av >= 0.0001: + d = 8 + else: + d = 10 + text = f"{v:.{d}f}" + return text.rstrip("0").rstrip(".") if "." in text else text + + +FUNDS_DECIMALS = 2 + + +def format_funds_u(value): + if value in (None, ""): + return "-" + try: + return f"{float(value):.{FUNDS_DECIMALS}f}" + except (TypeError, ValueError): + return str(value) + + +def format_hold_minutes(minutes): + if not minutes: + return "0分钟" + total = int(minutes) + hours = total // 60 + mins = total % 60 + if hours: + return f"{hours}小时{mins}分钟" + return f"{mins}分钟" + + +def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage): + """估算净盈亏(USDT):价差毛利 − 双边 taker 费(默认各 0.05%).""" + try: + trigger = float(trigger_price) + exit_p = float(exit_price) + margin = float(margin_capital) + lev = float(leverage) + if trigger <= 0: + return 0.0 + if direction == "short": + pnl_ratio = (trigger - exit_p) / trigger + else: + pnl_ratio = (exit_p - trigger) / trigger + notional = margin * lev + gross = notional * pnl_ratio + try: + from lib.trade.trade_fee_lib import net_pnl_after_fee + + net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional) + return float(net) if net is not None else round(gross, 4) + except Exception: + return round(gross, 4) + except Exception: + return 0.0 + + +def calc_rr_ratio(direction, entry_price, stop_loss, take_profit): + """ + 计划盈亏比 = 盈利空间 / 亏损空间(展示为 X:1,即 reward:risk). + 做多:止损须低于入场,止盈须高于入场;做空相反. + """ + try: + entry = float(entry_price) + sl = float(stop_loss) + tp = float(take_profit) + if entry <= 0 or sl <= 0 or tp <= 0: + return None + if direction == "short": + risk = sl - entry + reward = entry - tp + else: + risk = entry - sl + reward = tp - entry + if risk <= 0 or reward <= 0: + return None + return round(reward / risk, 4) + except Exception: + return None + + +def active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit): + """展示/校验用:优先当前 stop_loss(委托改价后),否则回落 initial_stop_loss.""" + sl = stop_loss if stop_loss not in (None, "") else initial_stop_loss + return sl, take_profit + + +def calc_planned_rr_ratio(direction, entry_price, stop_loss, initial_stop_loss, take_profit): + sl, tp = active_sl_tp_for_rr(stop_loss, initial_stop_loss, take_profit) + return calc_rr_ratio(direction, entry_price, sl, tp) + + +def calc_risk_fraction(direction, entry_price, stop_loss): + try: + entry = float(entry_price) + sl = float(stop_loss) + if entry <= 0 or sl <= 0: + return None + if direction == "short": + risk = sl - entry + else: + risk = entry - sl + if risk <= 0: + return None + return risk / entry + except Exception: + return None + + +def calc_risk_amount_from_plan(direction, entry_price, stop_loss, margin_capital, leverage): + rf = calc_risk_fraction(direction, entry_price, stop_loss) + if rf is None: + return None + try: + notional = float(margin_capital) * float(leverage) + if notional <= 0: + return None + return round(notional * rf, 6) + except Exception: + return None + + +def calc_actual_rr(pnl_amount, risk_amount): + try: + r = float(risk_amount or 0) + if r <= 0: + return None + return round(float(pnl_amount or 0) / r, 2) + except Exception: + return None + + +def calc_breakeven_stop(direction, entry_price, risk_fraction, locked_r, offset_pct): + """ + 按“已锁定R”计算目标止损位: + - long: entry + locked_r * (entry*risk_fraction) + offset + - short: entry - locked_r * (entry*risk_fraction) - offset + """ + try: + entry = float(entry_price) + rf = float(risk_fraction) + lr = float(locked_r) + off = float(offset_pct) / 100.0 + if entry <= 0 or rf <= 0 or lr < 0: + return None + base_move = entry * rf * lr + offset_move = entry * off + if direction == "short": + return round(entry - base_move - offset_move, 8) + return round(entry + base_move + offset_move, 8) + except Exception: + return None + + +def insert_trade_record( + conn, + symbol, + monitor_type, + direction, + trigger_price, + stop_loss, + initial_stop_loss=None, + take_profit=None, + margin_capital=None, + leverage=None, + pnl_amount=0, + hold_seconds=0, + trade_style=None, + risk_amount=None, + planned_rr=None, + actual_rr=None, + result="", + miss_reason=None, + opened_at=None, + opened_at_ms=None, + closed_at=None, + closed_at_ms=None, + exchange_trade_id=None, + key_signal_type=None, + entry_reason=None, + entry_model=None, + trend_plan_id=None, + exchange_symbol=None, + attach_exchange_stats=True, +): + hold_minutes = calc_hold_minutes(hold_seconds) + open_ts = opened_at or app_now_str() + close_ts = closed_at or app_now_str() + open_ts_ms = _to_ms_with_fallback(opened_at_ms, open_ts) + close_ts_ms = _to_ms_with_fallback(closed_at_ms, close_ts) + kst = key_signal_type_for_trade_record(key_signal_type, KEY_MONITOR_AUTO_TYPES) + from lib.trade.order_monitor_display_lib import snapshot_stop_loss + + snap_sl = snapshot_stop_loss(initial_stop_loss, stop_loss) + er = resolve_trade_record_entry_reason( + entry_reason=entry_reason, + entry_model=entry_model, + key_signal_type=kst, + monitor_type=monitor_type, + trade_style=trade_style, + entry_reason_from_key_signal=entry_reason_from_key_signal, + entry_reason_for_monitor_type=entry_reason_for_monitor_type, + ) + cur = conn.execute( + "INSERT INTO trade_records (symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,pnl_amount,hold_seconds,trade_style,risk_amount,planned_rr,actual_rr,hold_minutes,opened_at,opened_at_ms,closed_at,closed_at_ms,result,miss_reason,exchange_trade_id,entry_reason,trend_plan_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, monitor_type, kst, direction, trigger_price, snap_sl, snap_sl, take_profit, + margin_capital, leverage, pnl_amount, hold_seconds, + trade_style, risk_amount, planned_rr, actual_rr, hold_minutes, + open_ts, open_ts_ms, close_ts, close_ts_ms, result, miss_reason, exchange_trade_id, er or None, + trend_plan_id, + ) + ) + tid = int(cur.lastrowid or 0) + if attach_exchange_stats and tid: + ex_sym = (exchange_symbol or "").strip() or normalize_exchange_symbol(symbol) + _attach_okx_trade_exchange_stats( + conn, + tid, + exchange_symbol=ex_sym, + direction=direction, + opened_at_str=open_ts, + closed_at_str=close_ts, + opened_at_ms=open_ts_ms, + closed_at_ms=close_ts_ms, + ) + # 中控只拉 /api/trade_records,平仓当下也尝试回填交易所盈亏(内部 25s 节流) + try: + sync_trade_records_from_exchange(conn, force=False) + except Exception: + pass + try: + from lib.trade.account_risk_lib import on_closed_trade_pnl + + close_dt = parse_dt_for_trading_day(close_ts) + on_closed_trade_pnl( + conn, + pnl_amount=pnl_amount, + trading_day=get_trading_day(close_dt), + ) + except Exception: + pass + return tid + + +def calc_duration_text(open_str, close_str): + try: + fmt = "%Y-%m-%dT%H:%M" + o = datetime.strptime(open_str, fmt) + c = datetime.strptime(close_str, fmt) + delta = c - o + seconds = int(delta.total_seconds()) + if seconds <= 0: + return "0分钟" + d = seconds // 86400 + h = (seconds % 86400) // 3600 + m = (seconds % 3600) // 60 + parts = [] + if d: + parts.append(f"{d}天") + if h: + parts.append(f"{h}小时") + if m or not parts: + parts.append(f"{m}分钟") + return " ".join(parts) + except Exception: + return "计算失败" + + +def row_to_dict(row): + return {k: row[k] for k in row.keys()} + + +def enrich_order_item(raw_item, current_capital): + item = dict(raw_item or {}) + margin = float(item.get("margin_capital") or 0) + lev = float(item.get("leverage") or 0) + notional = item.get("notional_value") + ratio = item.get("position_ratio") + if notional is None: + notional = round(margin * lev, 4) if margin and lev else 0 + if ratio is None: + ratio = round(margin / current_capital * 100, 2) if current_capital else 0 + item["notional_value"] = notional + item["position_ratio"] = ratio + enrich_order_display_fields(item, calc_rr_ratio) + enrich_entry_model_display(item) + try: + be = item.get("breakeven_enabled") + item["breakeven_enabled"] = 0 if be is not None and int(be) == 0 else 1 + except Exception: + item["breakeven_enabled"] = 1 + return apply_order_monitor_source_labels(item, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def ensure_okx_live_ready(): + if EXCHANGE_AUTH_DISABLED_MSG: + return False, EXCHANGE_AUTH_DISABLED_MSG + if not LIVE_TRADING_ENABLED: + return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return False, "缺少 OKX API 密钥配置" + return True, "" + + +def order_row_monitor_type(row): + return order_monitor_source_type(row, default_manual=ORDER_MONITOR_TYPE_MANUAL) + + +def trade_record_monitor_type(conn, row): + return resolve_trade_record_monitor_type( + conn, row, default_manual=ORDER_MONITOR_TYPE_MANUAL + ) + + +def order_row_key_signal_type(row): + if row is None: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + keys = [] + if "key_signal_type" not in keys: + return None + kst = (row["key_signal_type"] or "").strip() + if kst in KEY_MONITOR_AUTO_TYPES or is_fib_key_monitor_type(kst) or is_false_breakout_key_monitor_type(kst): + return kst + return None + + +def _disable_private_api_after_auth_error(exc): + global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE + from lib.exchange.api_credentials_lib import strip_ccxt_credentials + + strip_ccxt_credentials(exchange) + try: + strip_ccxt_credentials(exchange_options) + except Exception: + pass + OKX_API_KEY = "" + OKX_API_SECRET = "" + OKX_API_PASSPHRASE = "" + EXCHANGE_AUTH_DISABLED_MSG = ( + f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}" + ) + + +def _extract_usdt_total(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + total_map = balance.get("total", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + total = usdt_info.get("total") + if total is None: + total = total_map.get("USDT") + if total is None: + total = usdt_info.get("free") + if total is None: + total = free_map.get("USDT") + try: + return float(total) if total is not None else None + except Exception: + return None + + +def _extract_usdt_free(balance): + usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {} + free_map = balance.get("free", {}) if isinstance(balance, dict) else {} + free = usdt_info.get("free") + if free is None: + free = free_map.get("USDT") + try: + return float(free) if free is not None else None + except Exception: + return None + + +def _fetch_usdt_by_types(type_candidates): + for t in type_candidates: + try: + bal = exchange.fetch_balance(params={"type": t}) + val = _extract_usdt_total(bal) + if val is not None: + return val + except Exception: + continue + return None + + +def get_available_trading_usdt(): + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return None + for t in ["swap", "trading", "spot"]: + try: + bal = exchange.fetch_balance(params={"type": t}) + free_val = _extract_usdt_free(bal) + if free_val is not None: + return free_val + except Exception: + continue + return None + + +def get_synced_leverage(exchange_symbol, direction): + ensure_markets_loaded() + # 1) 优先读取交易所杠杆配置 + try: + if hasattr(exchange, "fetch_leverage"): + lev = exchange.fetch_leverage(exchange_symbol, params={"mgnMode": OKX_TD_MODE}) + long_lev = lev.get("longLeverage") or lev.get("long") + short_lev = lev.get("shortLeverage") or lev.get("short") + base_lev = lev.get("leverage") + if direction == "long" and long_lev: + return int(float(long_lev)) + if direction == "short" and short_lev: + return int(float(short_lev)) + if base_lev: + return int(float(base_lev)) + except Exception: + pass + # 2) 从当前仓位里兜底读取 + try: + positions = exchange.fetch_positions([exchange_symbol], params={"instType": "SWAP"}) + for p in positions: + if p.get("symbol") != exchange_symbol: + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge" and side and side != direction: + continue + lev = p.get("leverage") or info.get("lever") + if lev: + return int(float(lev)) + except Exception: + pass + return None + + +def friendly_okx_error(err, available_usdt=None): + msg = str(err) + if "51008" in msg or "Insufficient USDT margin" in msg: + tail = f"(当前交易账户可用约 {round(available_usdt, 4)}U)" if available_usdt is not None else "" + return f"交易所下单失败:保证金不足 {tail}.请降低保证金/杠杆,或先划转USDT到交易账户." + clean = re.sub(r"\s+", " ", msg).strip() + return f"交易所下单失败:{clean}" + + +friendly_exchange_error = friendly_okx_error + + +def invalidate_account_balance_cache() -> None: + ACCOUNT_BALANCE_CACHE["updated_at"] = 0 + + +def get_exchange_capitals(force=False): + ok_live, _ = ensure_okx_live_ready() + if not ok_live: + return None, None + now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + try: + funding = _fetch_usdt_by_types(["funding"]) + trading = _fetch_usdt_by_types(["swap", "trading", "spot"]) + ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding + ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading + ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts + except Exception as e: + if is_exchange_auth_error(e): + _disable_private_api_after_auth_error(e) + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] + + +def execute_transfer_usdt(amount, from_account, to_account): + if amount <= 0: + return False, "划转金额必须大于0", None + ok_live, reason = ensure_okx_live_ready() + if not ok_live: + return False, reason, None + try: + resp = exchange.transfer(TRANSFER_CCY, float(amount), from_account, to_account) + return True, "划转成功", resp + except Exception as e: + return False, str(e), None + + +def get_account_usdt_total(account_type): + try: + bal = exchange.fetch_balance(params={"type": account_type}) + return _extract_usdt_total(bal) + except Exception: + return None + + +def auto_transfer_once_per_day(): + run_auto_transfer_once_per_day( + enabled=AUTO_TRANSFER_ENABLED, + bj_hour=AUTO_TRANSFER_BJ_HOUR, + target_amount=AUTO_TRANSFER_AMOUNT, + from_account=AUTO_TRANSFER_FROM, + to_account=AUTO_TRANSFER_TO, + funds_decimals=FUNDS_DECIMALS, + get_db=get_db, + get_active_position_count=get_active_position_count, + get_account_usdt_total=get_account_usdt_total, + execute_transfer_usdt=execute_transfer_usdt, + send_wechat_msg=send_wechat_msg, + utc_now_dt=utc_now_dt, + app_tz=APP_TZ, + utc_calendar_date_str=utc_calendar_date_str, + app_now_str=app_now_str, + ) + + +def get_trading_day_reset_open_guard_enabled(conn=None): + """True=启用整点限制(默认 8:00 前禁止新开仓/登记监控).""" + owns = conn is None + if owns: + conn = get_db() + try: + row = conn.execute( + "SELECT value FROM app_runtime_settings WHERE key=?", + (RUNTIME_KEY_OPEN_GUARD,), + ).fetchone() + if row is not None: + return str(row[0]).lower() in ("1", "true", "yes", "on") + except Exception: + pass + finally: + if owns: + conn.close() + return TRADING_DAY_RESET_OPEN_GUARD_ENABLED + + +def set_trading_day_reset_open_guard_enabled(enabled: bool, conn=None): + owns = conn is None + if owns: + conn = get_db() + try: + conn.execute( + "INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (RUNTIME_KEY_OPEN_GUARD, "1" if enabled else "0", app_now_str()), + ) + if owns: + conn.commit() + finally: + if owns: + conn.close() + + +def trading_day_reset_allows_new_open(now, conn=None): + if not get_trading_day_reset_open_guard_enabled(conn): + return True + return now.hour >= TRADING_DAY_RESET_HOUR + + +def precheck_risk(conn, symbol, direction): + now = app_now() + from lib.trade.account_risk_lib import account_risk_blocks_trading + from lib.trade.force_close_lib import force_close_blocks_new_open + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=get_trading_day(now), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + if not ok_risk: + return False, risk_reason + fc_block, fc_note = force_close_blocks_new_open( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ) + if fc_block: + return False, fc_note or "强制清仓窗口内暂不可开仓" + if not trading_day_reset_allows_new_open(now): + return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" + from lib.trade.account_risk_lib import position_limit_reached + + reached, active_count, mx = position_limit_reached(conn, max_active_positions=MAX_ACTIVE_POSITIONS) + if reached: + return False, f"已达最大持仓数({active_count}/{mx})" + ok_daily, daily_reason, _opens = check_daily_open_hard_limit( + conn, get_trading_day(now), DAILY_OPEN_HARD_LIMIT, TRADING_DAY_RESET_HOUR + ) + if not ok_daily: + return False, daily_reason + if direction not in ("long", "short"): + return False, "方向必须为 long 或 short" + if symbol.upper().startswith("BTC") or symbol.upper().startswith("ETH"): + expected = BTC_LEVERAGE + else: + expected = ALT_LEVERAGE + if expected <= 0: + return False, "杠杆配置异常" + return True, "" + + +def prepare_order_amount(exchange_symbol, margin_capital, leverage, fallback_price): + ensure_markets_loaded() + notional = float(margin_capital) * float(leverage) + ticker = exchange.fetch_ticker(exchange_symbol) + price = float(ticker.get("last") or fallback_price) + if price <= 0: + raise ValueError("触发价必须大于 0") + market = exchange.market(exchange_symbol) + contract_size = float(market.get("contractSize") or 1) + if market.get("contract"): + # OKX 永续 amount 是“张数”,需要按合约面值换算 + amount = notional / (price * contract_size) + else: + amount = notional / price + min_amount = (market.get("limits", {}).get("amount", {}) or {}).get("min") + if min_amount and amount < float(min_amount): + raise ValueError(f"下单数量过小,最小数量为 {min_amount}") + amount_precise = float(exchange.amount_to_precision(exchange_symbol, amount)) + if amount_precise <= 0: + raise ValueError("下单数量精度后为 0,请提高基数或降低价格") + return amount_precise, price + + +def _to_positive_float(value): + try: + n = float(value) + return n if n > 0 else None + except Exception: + return None + + +def _extract_order_price_value(order_obj): + if not isinstance(order_obj, dict): + return None + for key in ("average", "price"): + v = _to_positive_float(order_obj.get(key)) + if v is not None: + return v + cost = _to_positive_float(order_obj.get("cost")) + filled = _to_positive_float(order_obj.get("filled")) + if cost is not None and filled is not None and filled > 0: + return cost / filled + info = order_obj.get("info") if isinstance(order_obj.get("info"), dict) else {} + for key in ("avgPx", "fillPx", "avgPrice", "fillPrice", "px"): + v = _to_positive_float(info.get(key)) + if v is not None: + return v + return None + + +def resolve_order_entry_price(order_resp, exchange_symbol, fallback_price): + price = _extract_order_price_value(order_resp) + if price is not None: + return round(price, 8) + order_id = (order_resp or {}).get("id") + if order_id: + try: + fetched = exchange.fetch_order(order_id, exchange_symbol) + fetched_price = _extract_order_price_value(fetched) + if fetched_price is not None: + return round(fetched_price, 8) + except Exception: + pass + fallback = _to_positive_float(fallback_price) + return round(fallback, 8) if fallback is not None else 0.0 + + +def get_contract_size(exchange_symbol): + try: + ensure_markets_loaded() + market = exchange.market(normalize_okx_symbol(exchange_symbol)) + return float(market.get("contractSize") or 1) + except Exception: + return 1.0 + + +def parse_positive_float(value): + if value is None: + return None + raw = str(value).strip() + if not raw: + return None + num = float(raw) + if num <= 0: + raise ValueError("数值必须大于0") + return num + + +def build_okx_order_params(direction, reduce_only=False): + params = {"tdMode": OKX_TD_MODE} + if OKX_POS_MODE == "hedge": + params["posSide"] = "long" if direction == "long" else "short" + if reduce_only: + params["reduceOnly"] = True + return params + + +def ensure_markets_loaded(force=False): + global MARKETS_LOADED + if force or not MARKETS_LOADED: + try: + exchange.load_markets(reload=force) + except Exception as e: + if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)): + _disable_private_api_after_auth_error(e) + load_markets_public_fallback(exchange, reload=True) + else: + raise + MARKETS_LOADED = True + + +def _okx_algo_trigger_price_str(exchange_symbol, price): + """OKX attachAlgoOrds 触发价须为按合约 tick 格式化的十进制字符串;直接用 str(float) 低价币会得到科学计数法(如 8.5e-06),会报 tpTriggerPx/slTriggerPx 参数错误.""" + ensure_markets_loaded() + return exchange.price_to_precision(exchange_symbol, float(price)) + + +def place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None): + ensure_markets_loaded() + exchange.set_leverage(leverage, exchange_symbol) + side = "buy" if direction == "long" else "sell" + params = build_okx_order_params(direction, reduce_only=False) + if stop_loss and take_profit: + params["attachAlgoOrds"] = [{ + "tpTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, take_profit), + "tpOrdPx": "-1", + "slTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "slOrdPx": "-1" + }] + try: + order = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + order["tpsl_attached"] = bool(stop_loss and take_profit) + return order + except Exception as e: + if stop_loss and take_profit: + raise RuntimeError(f"交易所未接受止盈止损挂单参数,已拒绝开仓:{str(e)}") + raise + + +def close_exchange_order(order_row): + """ + 市价全平.数量优先取交易所当前持仓张数,避免仅用入库 order_amount 导致平不干净. + """ + ensure_markets_loaded() + exchange_symbol = order_row["exchange_symbol"] or normalize_okx_symbol(order_row["symbol"]) + direction = order_row["direction"] + db_amt = float(order_row["order_amount"] or 0) + side = "sell" if direction == "long" else "buy" + last_resp = None + for _ in range(3): + live = get_live_position_contracts(exchange_symbol, direction) + if live is not None and live > 0: + raw_amt = live + else: + raw_amt = db_amt + if raw_amt <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:缺少有效下单数量") + try: + amount = float(exchange.amount_to_precision(exchange_symbol, raw_amt)) + except Exception: + amount = float(raw_amt) + if amount <= 0: + if last_resp is not None: + return last_resp + raise ValueError("平仓失败:数量经精度舍入后为 0") + params = build_okx_order_params(direction, reduce_only=True) + last_resp = exchange.create_order(exchange_symbol, "market", side, amount, None, params) + live_after = get_live_position_contracts(exchange_symbol, direction) + if live_after is None or live_after <= 0: + return last_resp + return last_resp + + +def cancel_okx_swap_open_orders(exchange_symbol): + ok, _ = ensure_okx_live_ready() + if not ok or not exchange_symbol: + return + ensure_markets_loaded() + try: + cancel_okx_all_open_orders(exchange, exchange_symbol) + except Exception: + pass + + +def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_profit): + """ + 为已有持仓挂条件止盈/止损(一笔 OCO 算法单). + 勿带 reduceOnly,勿分两笔 reduce-only 市价单,否则 OKX/ccxt 可能当成立即全平. + """ + ensure_markets_loaded() + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(amount))) + if amt <= 0: + raise RuntimeError("止盈止损:可平数量经精度舍入后为 0") + base = build_okx_order_params(direction, reduce_only=False) + sl_px = _okx_algo_trigger_price_str(exchange_symbol, stop_loss) + tp_px = _okx_algo_trigger_price_str(exchange_symbol, take_profit) + order_params = { + **base, + "stopLossPrice": float(sl_px), + "takeProfitPrice": float(tp_px), + "tpOrdPx": "-1", + "slOrdPx": "-1", + } + if OKX_POS_MODE == "hedge": + ps = "long" if direction == "long" else "short" + order_params["positionSide"] = ps + last_err = None + for attempt in range(6): + try: + exchange.create_order(exchange_symbol, "oco", close_side, amt, None, order_params) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止盈/止损条件单:{last_err}") + + + +def exchange_private_api_configured(): + if EXCHANGE_AUTH_DISABLED_MSG: + return False + return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE) + + +def _position_row_effective_contracts(p): + """张数:OKX 以 info.pos 为准,再兜底 ccxt contracts 等(与 Binance/Gate 多字段一致).""" + from lib.hub.hub_position_metrics import normalize_contracts_qty + + if not p: + return 0.0 + info = p.get("info", {}) or {} + for val in (info.get("pos"), p.get("contracts"), info.get("positionAmt"), info.get("size")): + if val is None or val == "": + continue + try: + x = abs(float(val)) + if x > 0: + return normalize_contracts_qty(x) + except (TypeError, ValueError): + continue + return 0.0 + + +def _position_matches_wanted_contract(exchange_symbol, position): + if not position: + return False + sym = position.get("symbol") + if sym == exchange_symbol: + return True + try: + if normalize_okx_symbol(sym or "") == normalize_okx_symbol(exchange_symbol or ""): + return True + except Exception: + pass + info = position.get("info") or {} + inst = (info.get("instId") or "").strip().upper() + if not inst: + return False + try: + ensure_markets_loaded() + want = exchange.market(exchange_symbol) + mid = (want.get("id") or "").strip().upper() + if mid and inst == mid: + return True + base = (want.get("base") or "").strip().upper() + quote = (want.get("quote") or "").strip().upper() + if base and quote and inst == f"{base}-{quote}-SWAP": + return True + except Exception: + pass + return False + + +def _okx_position_direction(position): + info = position.get("info") or {} + side = (position.get("side") or info.get("posSide") or "").strip().lower() + if side in ("long", "short"): + return side + try: + raw = float(info.get("pos") or position.get("contracts") or 0) + except (TypeError, ValueError): + raw = 0.0 + if raw > 0: + return "long" + if raw < 0: + return "short" + return "" + + +def _fetch_okx_swap_position_rows(): + """OKX 单合约 fetch_positions([sym]) 常返回空;与 /api/prices 一致拉全量 SWAP 再本地匹配.""" + ensure_markets_loaded() + rows = None + for fetcher in ( + lambda: exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}), + lambda: exchange.fetch_positions(), + ): + try: + rows = fetcher() or [] + break + except Exception: + continue + if rows is None: + return None + return rows + + +def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False): + exchange_symbol = normalize_okx_symbol(exchange_symbol or "") + if not rows: + return None + candidates = [] + for p in rows: + if not _position_matches_wanted_contract(exchange_symbol, p): + continue + info = p.get("info", {}) or {} + side = (p.get("side") or info.get("posSide") or "").lower() + contracts = _position_row_effective_contracts(p) + if contracts <= 0: + continue + want_dir = (direction or "").lower() + if OKX_POS_MODE == "net" or side == "net": + pos_dir = _okx_position_direction(p) + if pos_dir and pos_dir != want_dir: + continue + elif (not relax_hedge) and OKX_POS_MODE == "hedge": + if side and side != want_dir: + continue + candidates.append((contracts, p)) + if not candidates and (not relax_hedge) and OKX_POS_MODE == "hedge": + return _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=True) + if not candidates: + return None + candidates.sort(key=lambda x: x[0], reverse=True) + return candidates[0][1] + + +def parse_ccxt_position_metrics(position, order_leverage=None): + if not position: + return None + p = position + info = p.get("info", {}) or {} + initial = _coerce_float(p.get("collateral"), p.get("initialMargin"), p.get("margin")) + if initial is None or initial <= 0: + initial = _coerce_float( + info.get("margin"), + info.get("imr"), + info.get("initial_margin"), + ) + notional = _coerce_float(p.get("notional"), p.get("notionalValue")) + if notional is None or notional <= 0: + notional = _coerce_float(info.get("notionalUsd"), info.get("notional")) + if notional is not None: + notional = abs(notional) + if (initial is None or initial <= 0) and notional and notional > 0 and order_leverage: + try: + lev = float(order_leverage) + if lev > 0: + approx = notional / lev + if approx > 0: + initial = approx + except (TypeError, ValueError): + pass + unrealized = _coerce_float_signed( + p.get("unrealizedPnl"), + info.get("upl"), + info.get("uplLast"), + info.get("unrealized_pnl"), + info.get("unrealisedPnl"), + ) + mark = _coerce_float(p.get("markPrice"), p.get("mark_price"), info.get("markPx")) + out = {} + if initial is not None and initial > 0: + out["initial_margin"] = round(initial, FUNDS_DECIMALS) + if notional is not None and notional > 0: + out["notional"] = round(notional, FUNDS_DECIMALS) + if unrealized is not None: + out["unrealized_pnl"] = round(unrealized, FUNDS_DECIMALS) + if mark is not None and mark > 0: + out["mark_price"] = round(mark, 8) + if out: + sym = (p.get("symbol") or "").strip() + try: + cs = float(get_contract_size(sym)) if sym else 1.0 + except Exception: + cs = 1.0 + from lib.hub.hub_position_metrics import enrich_ccxt_position_metrics_out + + enrich_ccxt_position_metrics_out( + p, out, contract_size=cs, funds_decimals=FUNDS_DECIMALS + ) + return out or None + + +def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data): + return resolve_entrust_sltp_prices(direction, live_price, sltp_mode, data) + + +def _okx_tpsl_slot_build(exchange_symbol, order_id, trigger_price, order_type=""): + if trigger_price is None or order_id is None: + return None + sym = exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", "") + return { + "order_id": str(order_id), + "trigger_price": float(trigger_price), + "trigger_display": format_price_for_symbol(sym, trigger_price), + "type": str(order_type or ""), + } + + +def _okx_tpsl_slots_from_order(order, exchange_symbol): + """从单笔 OKX 订单解析 SL/TP(算法单常同时带 slTriggerPx 与 tpTriggerPx).""" + if not isinstance(order, dict): + return None, None + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + oid = order.get("id") or info.get("algoId") or info.get("ordId") + if oid is None: + return None, None + ord_type = str(order.get("type") or info.get("ordType") or "") + sl_px = _coerce_float( + order.get("stopLossPrice"), + info.get("slTriggerPx"), + info.get("slOrdPx"), + ) + tp_px = _coerce_float( + order.get("takeProfitPrice"), + info.get("tpTriggerPx"), + info.get("tpOrdPx"), + ) + sl_slot = _okx_tpsl_slot_build(exchange_symbol, oid, sl_px, ord_type) if sl_px is not None else None + tp_slot = _okx_tpsl_slot_build(exchange_symbol, oid, tp_px, ord_type) if tp_px is not None else None + if sl_slot or tp_slot: + return sl_slot, tp_slot + trig = _coerce_float( + info.get("triggerPx"), + order.get("triggerPrice"), + order.get("stopPrice"), + ) + if trig is None: + return None, None + one = _okx_tpsl_slot_build(exchange_symbol, oid, trig, ord_type) + return one, None + + +def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None): + slots = {"sl": None, "tp": None} + if not exchange_symbol: + return slots + ok, _ = ensure_okx_live_ready() + if not ok: + return slots + try: + ensure_markets_loaded() + plan_sl_f = plan_tp_f = None + try: + if plan_sl is not None: + plan_sl_f = float(plan_sl) + if plan_tp is not None: + plan_tp_f = float(plan_tp) + except Exception: + plan_sl_f = plan_tp_f = None + + def assign_role(trig, slot): + if trig is None or slot is None: + return + if plan_sl_f is not None and plan_tp_f is not None: + role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp" + elif plan_sl_f is not None: + role = "sl" + elif plan_tp_f is not None: + role = "tp" + else: + return + if slots[role] is None: + slots[role] = slot + + for order in fetch_okx_all_open_orders(exchange, exchange_symbol): + sl_slot, tp_slot = _okx_tpsl_slots_from_order(order, exchange_symbol) + if sl_slot and slots["sl"] is None: + slots["sl"] = sl_slot + if tp_slot and slots["tp"] is None: + slots["tp"] = tp_slot + if sl_slot or tp_slot: + continue + info = order.get("info") or {} + oid = order.get("id") or info.get("algoId") + trig = _coerce_float(info.get("triggerPx"), order.get("triggerPrice")) + if oid is None or trig is None: + continue + slot = _okx_tpsl_slot_build( + exchange_symbol, + oid, + trig, + str(order.get("type") or info.get("ordType") or ""), + ) + assign_role(trig, slot) + except Exception: + pass + return slots + + +def cancel_okx_tpsl_slot(exchange_symbol, slot): + if not slot or not exchange_symbol: + return + oid = slot.get("order_id") + if not oid: + return + ensure_markets_loaded() + cancel_id = str(oid).split(":", 1)[0] + try: + exchange.cancel_order(cancel_id, exchange_symbol, {"stop": True}) + except Exception: + exchange.cancel_order(str(oid), exchange_symbol, {"stop": True}) + + +def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit): + """先撤该合约挂单/条件单,再按新价重挂 TP/SL.""" + ok, reason = ensure_okx_live_ready() + if not ok: + raise RuntimeError(reason or "实盘未就绪") + ex_sym = resolve_monitor_exchange_symbol(order_row) + direction = order_row["direction"] + cancelled = cancel_okx_all_open_orders(exchange, ex_sym) + if cancelled > 0: + time.sleep(0.12) + pos_amt = get_live_position_contracts(ex_sym, direction) + if pos_amt is None or float(pos_amt) <= 0: + try: + pos_amt = float(order_row["order_amount"] or 0) + except (TypeError, ValueError): + pos_amt = 0 + if float(pos_amt or 0) <= 0: + raise ValueError("交易所当前无该方向持仓,无法挂止盈止损") + _okx_place_tp_sl_orders(ex_sym, direction, float(pos_amt), float(stop_loss), float(take_profit)) + + +def _okx_place_stop_loss_only(exchange_symbol, direction, stop_loss): + """OKX 永续:仅挂止损(趋势回调),止盈由程序监控. + + 须用 stopLossPrice 挂条件单;勿用 reduce-only 市价单 + params['stopLoss'], + 后者会当成立即市价平仓(开仓后约 1 秒内全平). + """ + ensure_markets_loaded() + pos_amt = get_live_position_contracts(exchange_symbol, direction) + if pos_amt is None or float(pos_amt) <= 0: + raise RuntimeError("交易所当前无持仓,无法挂止损") + cancel_okx_swap_open_orders(exchange_symbol) + close_side = "sell" if direction == "long" else "buy" + amt = float(exchange.amount_to_precision(exchange_symbol, float(pos_amt))) + if amt <= 0: + raise RuntimeError("止损:可平数量经精度舍入后为 0") + base = build_okx_order_params(direction, reduce_only=True) + sl_px = float(stop_loss) + last_err = None + for attempt in range(6): + try: + exchange.create_order( + exchange_symbol, + "market", + close_side, + amt, + None, + {**base, "stopLossPrice": sl_px}, + ) + return + except Exception as e: + last_err = e + cancel_okx_swap_open_orders(exchange_symbol) + time.sleep(0.2 * (attempt + 1)) + raise RuntimeError(f"OKX 未接受止损条件单:{last_err}") + + +def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None): + try: + e = float(entry_price) + pct = float( + offset_pct + if offset_pct is not None + else float(os.getenv("TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT", "0.3")) + ) + except (TypeError, ValueError): + return None + if e <= 0: + return None + direction = (direction or "long").strip().lower() + if direction == "short": + return e * (1.0 - pct / 100.0) + return e * (1.0 + pct / 100.0) + + +def extract_trade_price_from_order(order): + if not order: + return None + for k in ("average", "avgPrice", "price"): + try: + v = float(order.get(k) or 0) + if v > 0: + return v + except Exception: + pass + try: + info = order.get("info") or {} + if isinstance(info, dict): + for k in ("fillPx", "avgPx", "fill_price"): + v = float(info.get(k) or 0) + if v > 0: + return v + except Exception: + pass + return None + + +def is_no_position_error(err_msg): + msg = (err_msg or "").lower() + keywords = [ + "no position", "position does not exist", "position not exist", + "pos size is 0", "nothing to close", "reduceonly", "51008", + "empty position", "increase_position", + ] + return any(k in msg for k in keywords) + + +def get_live_position_contracts(exchange_symbol, direction): + ex_sym = normalize_okx_symbol(exchange_symbol or "") + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, ex_sym, direction) + if not prow: + return 0.0 + return _position_row_effective_contracts(prow) + + +def get_live_position_exchange_metrics(exchange_symbol, direction, order_leverage=None): + """趋势回调/下单监控:从交易所持仓读标记价与未实现盈亏.""" + if not exchange_private_api_configured() or not exchange_symbol: + return None + rows = _fetch_okx_swap_position_rows() + if rows is None: + return None + prow = _select_live_position_row(rows, exchange_symbol, direction) + return parse_ccxt_position_metrics(prow, order_leverage=order_leverage) + + +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: + return None + try: + aware = dt.replace(tzinfo=APP_TZ) + return int(aware.timestamp() * 1000) + except Exception: + return None + + +def _to_ms_with_fallback(ms_value, dt_str): + try: + if ms_value is not None and str(ms_value).strip() != "": + v = int(float(ms_value)) + if v > 0: + return v + except Exception: + pass + return opened_at_str_to_ms(dt_str) + + +def ms_to_app_local_str(ms): + if ms is None: + return app_now_str() + try: + dt = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc).astimezone(APP_TZ) + return dt.replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return app_now_str() + + +def fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=None): + """取开仓以来最近一笔减仓成交(与方向一致);失败返回 None.""" + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return None + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + + def pick_from_trades(trades, min_ts=None): + if not trades: + return None + candidates = [] + for t in trades: + if (t.get("side") or "").lower() != close_side: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts_i = int(ts) + except (TypeError, ValueError): + continue + if min_ts and ts_i < int(min_ts): + continue + candidates.append(t) + if not candidates: + return None + return max(candidates, key=lambda x: x.get("timestamp") or 0) + + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=100) + return pick_from_trades(trades, since_ms) + except Exception: + return None + + +def fetch_closing_fills_for_record(exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None): + """ + 拉取某条历史记录对应的减仓成交(用于按 id 回填). + 返回按时间排序的成交列表. + """ + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_side = "sell" if direction == "long" else "buy" + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + # 历史记录回填给一点缓冲,兼容成交落在记录时间附近的情况 + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + candidates = [] + all_side_candidates = [] + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + for t in trades or []: + if (t.get("side") or "").lower() != close_side: + continue + ts = t.get("timestamp") + if ts is None: + continue + try: + ts = int(ts) + except Exception: + continue + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + info = t.get("info") or {} + if not isinstance(info, dict): + info = {} + pos_side = (info.get("posSide") or t.get("posSide") or "").lower() + if OKX_POS_MODE == "hedge": + if pos_side in ("long", "short") and pos_side != direction: + continue + all_side_candidates.append(t) + if since_ms and ts < since_ms: + continue + if closed_ms and ts > closed_ms: + continue + candidates.append(t) + candidates.sort(key=lambda x: x.get("timestamp") or 0) + if candidates: + return candidates + + # 严格窗口为空时,降级为“按平仓时间就近匹配”,降低时区/时间误差导致的回填失败. + all_side_candidates.sort(key=lambda x: x.get("timestamp") or 0) + if not all_side_candidates: + return [] + if not closed_ms: + return all_side_candidates[-20:] + near = [] + for t in all_side_candidates: + ts = t.get("timestamp") + if ts is None: + continue + try: + delta = abs(int(ts) - int(closed_ms)) + except Exception: + continue + # 放宽到前后 7 天 + if delta <= 7 * 24 * 60 * 60 * 1000: + near.append((delta, t)) + if near: + near.sort(key=lambda x: x[0]) + picked = [x[1] for x in near[:20]] + picked.sort(key=lambda x: x.get("timestamp") or 0) + return picked + return all_side_candidates[-20:] + + +def fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str=None, opened_at_ms=None, closed_at_ms=None +): + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return [] + ensure_markets_loaded() + since_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + closed_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) if (closed_at_str or closed_at_ms is not None) else None + if closed_ms is not None: + closed_ms += 6 * 60 * 60 * 1000 + try: + trades = exchange.fetch_my_trades(exchange_symbol, since=since_ms, limit=200) + except Exception: + trades = [] + return filter_position_lifecycle_fills( + trades or [], + direction, + since_ms, + closed_ms, + hedge_mode=(OKX_POS_MODE == "hedge"), + close_buffer_ms=0, + ) + + +def _attach_okx_trade_exchange_stats( + conn, trade_id, *, exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=None, closed_at_ms=None +): + if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE): + return + open_ms = _to_ms_with_fallback(opened_at_ms, opened_at_str) + close_ms = _to_ms_with_fallback(closed_at_ms, closed_at_str) + contract_size = 1.0 + try: + ensure_markets_loaded() + contract_size = float(exchange.market(exchange_symbol).get("contractSize") or 1) + except Exception: + pass + + def _fetch(): + return fetch_all_position_fills_for_record( + exchange_symbol, direction, opened_at_str, closed_at_str, opened_at_ms=open_ms, closed_at_ms=close_ms + ) + + try: + attach_exchange_stats_to_trade(conn, trade_id, fetch_fills=_fetch, contract_size=contract_size) + except Exception: + pass + + +def calc_weighted_exit_price(trades): + if not trades: + return None + total_amount = 0.0 + weighted_sum = 0.0 + for t in trades: + try: + price = float(t.get("price") or 0) + amount = float(t.get("amount") or 0) + except Exception: + continue + if price <= 0: + continue + if amount <= 0: + amount = 1.0 + weighted_sum += price * amount + total_amount += amount + if total_amount <= 0: + return None + return weighted_sum / total_amount + + +def resolve_synced_flat_close(row, opened_at_str, opened_at_ms=None): + """ + 交易所已无仓,本地仍为 active 时,推断平仓类型/时间/盈亏. + 返回 (result, pnl_amount, closed_at_str, miss_reason). + """ + direction = row["direction"] + sym = row["symbol"] + trigger_price = row["trigger_price"] + stop_loss = row["stop_loss"] + take_profit = row["take_profit"] + margin_capital = row["margin_capital"] or DAILY_START_CAPITAL + leverage = row["leverage"] or infer_leverage(sym) + exchange_symbol = row["exchange_symbol"] or normalize_okx_symbol(sym) + + open_ms = _to_ms_with_fallback( + row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at_str + ) + trade = fetch_latest_closing_fill(exchange_symbol, direction, opened_at_str, opened_at_ms=opened_at_ms) + exit_px = None + closed_at_str = app_now_str() + if trade: + try: + exit_px = float(trade.get("price") or 0) or None + except (TypeError, ValueError): + exit_px = None + ts = trade.get("timestamp") + if ts: + try: + ts_i = int(ts) + except (TypeError, ValueError): + ts_i = None + if ts_i is not None and open_ms and ts_i < int(open_ms): + exit_px = None + elif ts_i is not None: + closed_at_str = ms_to_app_local_str(ts_i) + + if exit_px is None or exit_px <= 0: + p = get_price(sym) + if p: + guessed = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, p) + if guessed: + pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + return ( + normalize_result_with_pnl(guessed, pnl), + pnl, + closed_at_str, + "未能拉取成交明细,按当前市价与止盈/止损位近似归类(建议核对交易所账单)", + ) + return ( + "外部平仓", + 0.0, + closed_at_str, + "检测到交易所仓位已关闭,且无法从成交记录还原平仓价", + ) + + result = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_px) + pnl = calc_pnl(direction, trigger_price, exit_px, margin_capital, leverage) + if result: + return ( + normalize_result_with_pnl(result, pnl), + pnl, + closed_at_str, + "按交易所成交记录同步为止盈/止损平仓", + ) + return ( + "外部平仓", + pnl, + closed_at_str, + "交易所已平仓,成交价不在计划止盈/止损带内(可能为手动或其他类型平仓)", + ) + + +def _finalize_hub_flat_monitor_okx(conn, r, *, result, pnl_amount, closed_at, miss_reason): + opened_at = get_opened_at_value(r) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio( + r["direction"], + r["trigger_price"], + r["initial_stop_loss"] or r["stop_loss"], + r["take_profit"], + ), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + + +def reconcile_hub_external_close(conn, symbol, direction): + from lib.hub.hub_reconcile_flat_lib import reconcile_hub_external_close_impl + from lib.hub.hub_symbol_lib import symbols_match + + global _RECONCILE_FLAT_STREAK + + return reconcile_hub_external_close_impl( + conn, + symbol, + direction, + exchange_configured=exchange_private_api_configured, + not_configured_msg="未配置 OKX_API_KEY / OKX_API_SECRET", + symbols_match=symbols_match, + get_opened_at_value=get_opened_at_value, + resolve_monitor_exchange_symbol=resolve_monitor_exchange_symbol, + get_live_position_contracts=get_live_position_contracts, + cancel_conditional_orders=cancel_okx_swap_open_orders, + resolve_synced_flat_close=resolve_synced_flat_close, + finalize_stopped_monitor=_finalize_hub_flat_monitor_okx, + sync_trade_records=sync_trade_records_from_exchange, + reconcile_flat_streak=_RECONCILE_FLAT_STREAK, + to_ms_with_fallback=_to_ms_with_fallback, + prefer_manual_resolve=False, + order_row_monitor_type=order_row_monitor_type, + ) + + +def reconcile_external_closes(conn, days=None): + global _RECONCILE_FLAT_STREAK + if not exchange_private_api_configured(): + return 0 + if time.time() - _APP_STARTED_AT < RECONCILE_STARTUP_GRACE_SEC: + return 0 + synced_count = 0 + cutoff_ms = None + if days is not None: + try: + d = int(days) + if d > 0: + cutoff_ms = int((app_now() - timedelta(days=d)).timestamp() * 1000) + except Exception: + cutoff_ms = None + rows = conn.execute( + "SELECT * FROM order_monitors WHERE status IN ('active', 'error')" + ).fetchall() + for r in rows: + if cutoff_ms is not None: + opened_at_v = get_opened_at_value(r) + opened_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at_v) + # 手动同步按最近 N 天过滤,避免把更早历史单误同步进来 + if opened_ms is None or opened_ms < cutoff_ms: + continue + oid = int(r["id"]) + if r["status"] == "error": + opened_at_chk = get_opened_at_value(r) + existing = conn.execute( + "SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1", + (r["symbol"], opened_at_chk, order_row_monitor_type(r)), + ).fetchone() + if existing: + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,)) + synced_count += 1 + continue + exchange_symbol = r["exchange_symbol"] or normalize_okx_symbol(r["symbol"]) + 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" + ) + opened_at = get_opened_at_value(r) + opened_at_ms = _to_ms_with_fallback(r["opened_at_ms"] if "opened_at_ms" in r.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(r, opened_at, opened_at_ms=opened_at_ms) + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = r["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=r["direction"], + trigger_price=r["trigger_price"], + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=r["margin_capital"], + leverage=r["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(r["direction"], r["trigger_price"], r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) + if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result=f"{result}(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + else: + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=r["direction"], + result="外部平仓(自动同步)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=r["trigger_price"], + current_price="-", + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id="-", + extra_note=miss_reason, + ) + ) + synced_count += 1 + return synced_count + + +def _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-SWAP 便于与 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: + parts = s.split("-") + if len(parts) >= 2 and parts[-1] in ("SWAP", "FUTURES", "FUTURE"): + s = f"{parts[0]}/{parts[1]}" + else: + s = s.replace("-", "/") + 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_okx_position_history_entry(p): + if not p or not isinstance(p, dict): + return None + info = p.get("info") or {} + if not isinstance(info, dict): + info = {} + sym = p.get("symbol") or "" + if not sym: + inst = str(info.get("instId") or "").strip() + if inst: + try: + ensure_markets_loaded() + sym = exchange.market(inst).get("symbol") or "" + except Exception: + parts = inst.split("-") + if len(parts) >= 2: + sym = f"{parts[0]}/{parts[1]}" + side = (p.get("side") or info.get("direction") or info.get("posSide") or "").strip().lower() + if side not in ("long", "short"): + try: + pos_val = float(info.get("pos") or 0) + if pos_val > 0: + side = "long" + elif pos_val < 0: + side = "short" + except (TypeError, ValueError): + side = "" + rp = p.get("realizedPnl") + if rp is None: + rp = info.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("uTime")) + open_ms = _coerce_ts_ms(p.get("timestamp")) + if open_ms is None: + open_ms = _coerce_ts_ms(info.get("cTime")) + pos_id = str(info.get("posId") or "").strip() + inst_id = str(info.get("instId") or "").strip() + u_raw = info.get("uTime") + sync_key = pos_id or f"{inst_id}|{u_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_okx_positions_close_history(): + if not exchange_private_api_configured(): + return [] + ensure_markets_loaded() + since_ms = exchange_position_sync_since_ms() + out = [] + page_limit = 100 + max_total = int(EXCHANGE_POSITION_HISTORY_LIMIT) + before = None + while len(out) < max_total: + params = {"instType": OKX_POSITION_INST_TYPE} + if before is not None: + params["before"] = str(before) + try: + rows = exchange.fetch_positions_history( + None, + since=int(since_ms), + limit=page_limit, + params=params, + ) + except Exception: + break + if not rows: + break + batch_min_u = None + for p in rows: + h = _normalize_okx_position_history_entry(p) + if h and h["close_ms"] and h["side"] in ("long", "short") and h["symbol_u"]: + out.append(h) + info = p.get("info") or {} + u = _coerce_ts_ms(info.get("uTime")) or _coerce_ts_ms(p.get("lastUpdateTimestamp")) + if u and (batch_min_u is None or u < batch_min_u): + batch_min_u = u + if len(rows) < page_limit or batch_min_u is None: + break + if before is not None and batch_min_u >= before: + break + before = batch_min_u + return out[:max_total] + + +def sync_trade_records_from_exchange(conn, force=False): + """为未同步的 trade_records 回填 OKX 历史仓位中的已实现盈亏.返回统计 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"] = "未配置 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE" + 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_okx_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 + # 仍有未匹配且历史非空:缩短节流,避免平仓后历史稍晚入库时卡在「估」 + if matched < stats["pending"] and hist: + _LAST_EXCHANGE_PNL_SYNC_AT = now - 15.0 + else: + _LAST_EXCHANGE_PNL_SYNC_AT = now + try: + conn.commit() + except Exception: + pass + return stats + + +# 获取实时价格 +def get_price(symbol): + try: + ensure_markets_loaded() + return exchange.fetch_ticker(normalize_okx_symbol(symbol))["last"] + except: + return None + +# 获取5分钟K线收盘价 +def get_5m_close(symbol): + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(normalize_okx_symbol(symbol), KLINE_TIMEFRAME, limit=1) + return ohlcv[-1][4] if ohlcv else None + except: + return None + + +def _safe_float(v): + try: + return float(v) + except Exception: + return None + + +def _compute_ema(values, period=55): + arr = [float(x) for x in values if x is not None] + if len(arr) < period: + return None + k = 2.0 / (period + 1.0) + ema = arr[0] + for val in arr[1:]: + ema = val * k + ema * (1 - k) + return ema + + +def _status_by_ema55(symbol, timeframe): + try: + bars = exchange.fetch_ohlcv(normalize_okx_symbol(symbol), timeframe=timeframe, limit=80) + if not bars or len(bars) < 56: + return "横盘", None, None + closes = [float(x[4]) for x in bars if x and len(x) >= 5] + ema55 = _compute_ema(closes, 55) + last_close = closes[-1] + if ema55 is None or last_close <= 0: + return "横盘", last_close, ema55 + diff_pct = (last_close - ema55) / ema55 * 100.0 + if abs(diff_pct) < 0.1: + return "横盘", last_close, ema55 + return ("多头" if diff_pct > 0 else "空头"), last_close, ema55 + except Exception: + return "横盘", None, None + + +def _daily_volume_rank(symbol): + """ + 返回(symbol_rank, total_count):OKX USDT 永续 24h 成交额(USDT) 在全市场币种中的排名. + """ + sym_norm = normalize_symbol_input(symbol) + target_base = journal_coin_from_symbol(sym_norm) + return resolve_daily_volume_rank( + target_base, + LIQUIDITY_RANK_CACHE, + now_ts=time.time(), + ttl_sec=max(30, BALANCE_REFRESH_SECONDS), + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + ) + + +def _key_hard_checks(symbol, direction, upper, lower, monitor_type): + """ + 关键位门控:量能,突破幅度,第二根确认,日成交量前30. + 使用最近闭合K:breakout=倒数第2根,confirm=倒数第1根. + """ + out = {"ok": False} + ex_sym = normalize_okx_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=80) or [] + if len(bars) < 24: + out["reason"] = "5m K线数量不足" + return out + closed = bars[:-1] if len(bars) >= 3 else bars + min_closed = KEY_VOLUME_MA_BARS + 3 + if len(closed) < min_closed: + out["reason"] = f"{KLINE_TIMEFRAME} 闭合K线不足" + return out + try: + breakout = closed[KEY_CONFIRM_BREAKOUT_BAR] + confirm = closed[KEY_CONFIRM_BAR] + except IndexError: + out["reason"] = "确认K索引超出范围,请检查 KEY_CONFIRM_* 配置" + return out + prev_vol = closed[KEY_CONFIRM_BREAKOUT_BAR - KEY_VOLUME_MA_BARS : KEY_CONFIRM_BREAKOUT_BAR] + avg20 = sum(float(x[5]) for x in prev_vol) / max(len(prev_vol), 1) + vol_break = float(breakout[5]) + vol_ok = vol_break > avg20 * KEY_VOLUME_RATIO_MIN if avg20 > 0 else False + close_b = float(breakout[4]) + high_b = float(breakout[2]) + low_b = float(breakout[3]) + cfm_close = float(confirm[4]) + edge = float(upper) if direction == "long" else float(lower) + breakout_ok = (close_b > float(upper)) if direction == "long" else (close_b < float(lower)) + amp_ok, amp_pct = auto_amp_ok( + direction, close_b, float(upper), float(lower), KEY_BREAKOUT_AMP_MIN_PCT + ) + amp_ok = amp_ok and breakout_ok + confirm_ok_raw = auto_confirm_ok(direction, cfm_close, float(upper), float(lower)) + confirm_ok = confirm_ok_raw and breakout_ok + rank, total = _daily_volume_rank(symbol) + rank_ok = (rank is not None) and (rank <= KEY_DAILY_VOLUME_RANK_MAX) + swing4h_pct = 0.0 + try: + seg48 = closed[-48:] if len(closed) >= 48 else closed + hh = max(float(x[2]) for x in seg48) + ll = min(float(x[3]) for x in seg48) + swing4h_pct = ((hh - ll) / ll * 100.0) if ll > 0 else 0.0 + except Exception: + swing4h_pct = 0.0 + out.update( + { + "ok": all([vol_ok, amp_ok, breakout_ok, confirm_ok, rank_ok]), + "vol_ok": vol_ok, + "avg20": avg20, + "vol_break": vol_break, + "amp_ok": amp_ok, + "amp_pct": amp_pct, + "breakout_ok": breakout_ok, + "breakout_close": close_b, + "confirm_ok": confirm_ok, + "confirm_close": cfm_close, + "edge_price": edge, + "rank": rank, + "rank_total": total, + "rank_ok": rank_ok, + "breakout_high": high_b, + "breakout_low": low_b, + "breakout_ts": breakout[0], + "confirm_ts": confirm[0], + "swing4h_pct": swing4h_pct, + "monitor_type": monitor_type, + "direction": direction, + } + ) + return out + + +def _key_plan_sl_tp_for_row(row, direction, upper, lower, checks): + mode = sl_tp_mode_from_row(row, "standard") + manual_tp = _sqlite_row_val(row, "manual_take_profit") + return 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, + ), mode + + +def calc_price_diff_pct(current_price, target_price): + try: + if target_price is None: + return None, None + t = float(target_price) + if t == 0: + return None, None + c = float(current_price) + diff = c - t + pct = diff / t * 100 + return round(diff, 6), round(pct, 4) + except Exception: + return None, None + + + +def _coerce_float(*values): + """取第一个可解析且 > 0 的数(用于价格,保证金等).""" + for v in values: + if v is None: + continue + try: + f = float(v) + if f > 0: + return f + except (TypeError, ValueError): + continue + return None + + +def _coerce_float_signed(*values): + """取第一个有限浮点数(含 0 与负数),用于未实现盈亏等.""" + for v in values: + if v is None or v == "": + continue + try: + f = float(v) + if math.isfinite(f): + return f + except (TypeError, ValueError): + continue + return None + + +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_active_position_count(conn): + return int(conn.execute("SELECT COUNT(*) FROM order_monitors WHERE status='active'").fetchone()[0]) + + +def get_key_sizing_capital_snapshot(conn, session_date): + row = conn.execute( + "SELECT key_sizing_capital_snapshot FROM trading_sessions WHERE session_date=?", + (session_date,), + ).fetchone() + if not row: + return None + try: + v = row["key_sizing_capital_snapshot"] + return float(v) if v is not None else None + except (TypeError, ValueError, KeyError): + 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), 4), 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 _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): + n = int(row["notification_count"] or 0) + 1 + insert_key_monitor_history(conn, row, n, last_msg, close_reason) + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + + +def _fetch_last_closed_bar(symbol): + ex_sym = normalize_okx_symbol(symbol) + bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] + if len(bars) < 2: + return None + closed = bars[:-1] + return closed[-1] if closed else None + + +def _key_rs_gate_preview(symbol, upper, lower): + bar = _fetch_last_closed_bar(symbol) + if not bar: + return {"summary": "5m数据不足", "metrics": ""} + close = float(bar[4]) + br = detect_rs_box_break(close, upper, lower) + if br: + return { + "summary": f"已越线:{br['break_label']}", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + return { + "summary": "待突破", + "metrics": f"收盘:{format_price_for_symbol(symbol, close)}", + } + + +def _process_key_rs_level_alert(conn, row): + sym = row["symbol"] + typ = (row["monitor_type"] or "").strip() + up, low = float(row["upper"]), float(row["lower"]) + if up <= low: + return + bar = _fetch_last_closed_bar(sym) + if not bar: + return + close = float(bar[4]) + ts = bar[0] + now_dt = app_now() + tick = run_rs_level_alert_tick( + row, + close, + ts, + now_dt, + default_max_notify=KEY_ALERT_MAX_TIMES, + default_interval_min=KEY_ALERT_INTERVAL_MINUTES, + ) + if not tick: + return + + br = tick["break_info"] + notify_index = int(tick["notify_index"]) + max_n = int(tick["notify_max"]) + interval = int(tick["interval_min"]) + bar_ts = tick.get("bar_ts") + prior_count = int(tick.get("prior_count", notify_index - 1)) + + notified_at = app_now_str() + if not claim_rs_level_notify( + conn, + row["id"], + notify_index, + br["direction"], + notified_at, + bar_ts, + prior_count=prior_count, + ): + return + conn.commit() + + trigger_time = ms_to_app_local_str(int(ts)) if ts else app_now_str() + msg = build_wechat_rs_level_message( + symbol=sym, + monitor_type=typ, + account_label=_wechat_account_label(), + trigger_time=trigger_time, + upper_txt=format_price_for_symbol(sym, up), + lower_txt=format_price_for_symbol(sym, low), + close_txt=format_price_for_symbol(sym, close), + edge_txt=format_price_for_symbol(sym, br["edge_price"]), + break_label=br["break_label"], + direction=br["direction"], + notify_index=notify_index, + notify_max=max_n, + interval_min=interval, + ) + send_wechat_msg(msg) + conn.execute( + "UPDATE key_monitors SET last_alert_message=? WHERE id=?", + (msg, row["id"]), + ) + conn.commit() + if notify_index >= max_n: + hist_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (row["id"],)).fetchone() + if hist_row: + insert_key_monitor_history(conn, hist_row, notify_index, msg, "key_level_alert_done") + conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) + conn.commit() + + +def _key_hard_lines_from_checks(checks): + direction = (checks.get("direction") or "long").lower() + return [ + f"量能:{'通过' if checks['vol_ok'] else '不通过'}(突破K量 {round(checks['vol_break'], 4)} / 前20均量 {round(checks['avg20'], 4)},阈值1.3x)", + f"突破价位:{'通过' if checks['breakout_ok'] else '不通过'}(突破K收盘 {round(float(checks['breakout_close']), 8)},关键位 {checks['edge_price']})", + format_auto_amp_line(checks["amp_ok"], checks["amp_pct"], KEY_BREAKOUT_AMP_MIN_PCT), + format_auto_confirm_line( + checks["confirm_ok"], checks["confirm_close"], checks["edge_price"], direction + ), + f"日成交量排名:{'通过' if checks['rank_ok'] else '不通过'}({checks['rank']}/{checks['rank_total']},要求前{KEY_DAILY_VOLUME_RANK_MAX})", + ] + + +def get_symbol_mark_price(symbol): + """斐波失效判定用标记价.""" + ex_sym = normalize_okx_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("markPx"), info.get("last")) + if m is not None: + 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): + if not order_id: + return False + ok_live, _ = ensure_okx_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", "live"): + return "open" + except Exception: + pass + try: + for o in fetch_okx_all_open_orders(exchange, exchange_symbol): + 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, + stop_loss=None, + take_profit=None, +): + 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_okx_order_params(direction, reduce_only=False) + if stop_loss and take_profit: + params["attachAlgoOrds"] = [ + { + "tpTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, take_profit), + "tpOrdPx": "-1", + "slTriggerPx": _okx_algo_trigger_price_str(exchange_symbol, stop_loss), + "slOrdPx": "-1", + } + ] + 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_okx_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, +): + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + exchange_symbol = normalize_okx_symbol(symbol) + typ = (row["monitor_type"] or "").strip() + now = app_now() + trading_day = get_trading_day(now) + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = round(float(margin_capital) * risk_percent / 100.0, 4) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + tc_en, tc_h, _ = time_close_settings_from_row(row) + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + 1 if breakeven_enabled_from_row(row, 0) else 0, + notional_value, + position_ratio, + base_amount, + amount, + exchange_order_id or "", + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(typ), + tc_en, + tc_h, + tc_at, + ), + ) + return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + + +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_okx_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: + msg = ( + f"# ❌ {symbol} {kind}成交后处理失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 无法取得持仓/下单数量,未挂 TP/SL\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_fill_no_amount") + return + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + msg = ( + f"# ❌ {symbol} {kind}成交后风控拒绝\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 原因:{reason}\n" + f"- 请手动处理仓位与挂单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_risk_rejected") + return + tpsl_attached = False + try: + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp) + if slots.get("sl") and slots.get("tp"): + tpsl_attached = True + else: + _okx_place_tp_sl_orders(ex_sym, direction, amount, sl, tp) + slots2 = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=sl, plan_tp=tp) + tpsl_attached = bool(slots2.get("sl") and slots2.get("tp")) + except Exception as e: + msg = ( + f"# ❌ {symbol} {kind}成交后挂 TP/SL 失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 错误:{friendly_okx_error(e)}\n" + f"- 请手动补挂止盈止损\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, row, msg, "fib_tpsl_failed") + 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, + ) + rr_txt = format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + close_reason = "false_breakout_filled" if is_false_breakout_key_monitor_type(typ) else "fib_filled" + succ = ( + f"# ✅ {symbol} {kind}限价成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(限价 @ E)\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{new_order_id}**\n" + f"- 成交价:{format_price_for_symbol(symbol, trigger_price)}\n" + f"- 止损:{format_wechat_scalar_2dp(sl)}|止盈:{format_price_for_symbol(symbol, tp)}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if tpsl_attached else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(succ) + _finalize_key_monitor_one_shot(conn, row, succ, close_reason) + + +def _trigger_entry_exists_for_symbol(conn, symbol): + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + row = conn.execute( + f"SELECT id FROM key_monitors WHERE symbol=? AND monitor_type IN ({placeholders})", + (symbol, *TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchone() + return row is not None + + +def _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry, + sl, + tp, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + mt = (monitor_type or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + if mt not in TRIGGER_ENTRY_MONITOR_TYPES: + mt = CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE + if _trigger_entry_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有触价开仓监控(同币仅允许一条)" + ex_sym = normalize_exchange_symbol(symbol) + mark = get_symbol_mark_price(symbol) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + entry = float(round_price_to_exchange(ex_sym, entry) or entry) + sl = float(round_price_to_exchange(ex_sym, sl) or sl) + tp = float(round_price_to_exchange(ex_sym, tp) or tp) + geom_err = validate_trigger_entry_geometry( + direction_sel, entry, sl, tp, mark_at_add=mark, monitor_type=mt + ) + if geom_err: + return False, geom_err + rr_err = validate_trigger_entry_rr( + direction_sel, entry, sl, tp, KEY_AUTO_MIN_PLANNED_RR, calc_rr_ratio + ) + if rr_err: + return False, rr_err + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live + now = app_now() + trading_day = get_trading_day(now) + opens_today = count_opens_for_trading_day(conn, trading_day) + ok_intent, intent_msg = check_trigger_entry_intent_limit( + conn, trading_day, opens_today, DAILY_OPEN_HARD_LIMIT + ) + if not ok_intent: + return False, intent_msg + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg + if count_pending_trigger_entries(conn, trading_day) > 0: + return False, "全仓杠杆模式下仅允许一条待触发触价监控" + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + available_usdt = get_available_trading_usdt() + if is_full_margin_mode(POSITION_SIZING_MODE): + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err + margin_capital = float(sizing["margin_capital"]) + amount_plan = None + else: + default_leverage = get_synced_leverage(ex_sym, direction_sel) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction_sel, entry, sl) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)" + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金" + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + ) + try: + amount_plan, _ = prepare_order_amount(ex_sym, margin_capital, leverage, entry) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt) + upper_px = round_price_to_exchange(ex_sym, max(entry, tp)) + lower_px = round_price_to_exchange(ex_sym, min(entry, sl)) + if upper_px is None or lower_px is None or float(upper_px) <= float(lower_px): + upper_px, lower_px = float(max(entry, tp, sl)), float(min(entry, tp, sl)) + if upper_px <= lower_px: + lower_px = upper_px * 0.9999 + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, " + "time_close_enabled, time_close_hours, session_date) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + float(upper_px), + float(lower_px), + entry, + sl, + tp, + float(amount_plan) if amount_plan is not None else None, + margin_capital, + leverage, + be_flag, + tc_en, + tc_h, + trading_day, + ), + ) + return True, None + + +def _market_open_for_trigger_entry( + conn, + symbol, + direction, + exchange_symbol, + entry_price, + stop_loss, + take_profit, + monitor_type=CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """触价触发后市价开仓,计仓规则与实盘下单/关键位 RR 门槛一致.""" + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_TRIGGER) + if not ok_src: + return False, src_msg, None + now = app_now() + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + return False, f"风控拒绝下单:{reason}", None + ok_live, reason_live = ensure_exchange_live_ready() + if not ok_live: + return False, reason_live, None + + trading_day = get_trading_day(now) + opens_today_before = count_opens_for_trading_day(conn, trading_day) + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + live_capital = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + capital_base = resolve_capital_base_for_key_open(conn, trading_day, live_capital) + + trade_style = (DEFAULT_TRADE_STYLE or "trend").strip().lower() + if trade_style not in ("trend", "swing"): + trade_style = "trend" + + available_usdt = get_available_trading_usdt() + live_price = get_symbol_mark_price(symbol) or get_price(symbol) + if live_price is None: + return False, "获取标记价/实时价失败", None + try: + ensure_markets_loaded() + except Exception: + pass + lp_r = round_price_to_exchange(exchange_symbol, live_price) + if lp_r is not None: + live_price = float(lp_r) + + entry_price = float(entry_price) + sl_adj = round_price_to_exchange(exchange_symbol, float(stop_loss)) + tp_adj = round_price_to_exchange(exchange_symbol, float(take_profit)) + if sl_adj is not None: + stop_loss = float(sl_adj) + if tp_adj is not None: + take_profit = float(tp_adj) + + planned_rr = calc_rr_ratio(direction, entry_price, stop_loss, take_profit) + if planned_rr is None or planned_rr <= KEY_AUTO_MIN_PLANNED_RR: + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return False, f"计划盈亏比 {rr_txt}:1 未达要求(>{KEY_AUTO_MIN_PLANNED_RR}:1)", None + + risk_percent = max(0.01, float(RISK_PERCENT)) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + return False, flat_msg, None + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + return False, sizing_err, None + margin_capital = float(sizing["margin_capital"]) + notional_value = float(sizing["notional_value"]) + position_ratio = float(sizing["position_ratio"]) + risk_amount = margin_capital + else: + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + leverage = int(default_leverage) if default_leverage else 5 + if leverage <= 0: + leverage = 5 + risk_fraction = calc_risk_fraction(direction, entry_price, stop_loss) + if risk_fraction is None: + return False, "止损方向不合法(相对计划入场价)", None + risk_amount = round(capital_base * risk_percent / 100.0, 4) + notional_value = round(risk_amount / risk_fraction, 4) + margin_capital = round(notional_value / leverage, 4) + if capital_base and margin_capital > capital_base: + return False, "以损定仓后保证金超过当前交易资金", None + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) + if margin_capital > max_margin: + return ( + False, + f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U", + None, + ) + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order( + exchange_symbol, direction, amount, leverage, + stop_loss=stop_loss, take_profit=take_profit, + ) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + return False, friendly_exchange_error(e, available_usdt=available_usdt), None + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr_fill = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = risk_amount + else: + try: + risk_amount_final = round(float(risk_amount_final), 4) + except (TypeError, ValueError): + risk_amount_final = risk_amount + + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, tc_at = time_close_insert_values(time_close_enabled, time_close_hours, opened_at_ms) + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent_db, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(monitor_type), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + try_persist_exchange_margin_for_order(conn, new_order_id, exchange_symbol, direction, order_leverage=leverage) + opens_today_after = count_opens_for_trading_day(conn, trading_day) + + return True, None, { + "new_order_id": new_order_id, + "open_order_id": open_order_id, + "trigger_price": trigger_price, + "planned_rr_fill": planned_rr_fill, + "risk_amount_final": risk_amount_final, + "margin_capital": margin_capital, + "leverage": leverage, + "amount": amount, + "tpsl_attached": tpsl_attached, + "opens_today_before": opens_today_before, + "opens_today_after": opens_today_after, + "trading_day": trading_day, + "stop_loss": stop_loss, + "take_profit": take_profit, + } + + +def _execute_trigger_entry_cross(conn, row): + """标记价触达计划入场:加锁防重复触发,成交成功后再删监控行.""" + symbol = row["symbol"] + direction = (row["direction"] or "long").lower() + ex_sym = normalize_exchange_symbol(symbol) + entry = float(_sqlite_row_val(row, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(row, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(row, "fib_take_profit") or 0) + be_en = breakeven_enabled_from_row(row, 0) + tc_en, tc_h, _ = time_close_settings_from_row(row) + + kid = int(row["id"]) + if not acquire_trigger_entry_exec_lock(conn, kid): + return False, "触价开仓进行中" + conn.commit() + + try: + ok, err, det = _market_open_for_trigger_entry( + conn, + symbol, + direction, + ex_sym, + entry, + sl, + tp, + monitor_type=(row["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE), + breakeven_enabled=be_en, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + except Exception as e: + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = friendly_exchange_error(e) + send_wechat_msg( + f"# ❌ {symbol} 触价开仓异常\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + if ok and det: + conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + rr_txt = format_wechat_scalar_2dp(det.get("planned_rr_fill")) if det.get("planned_rr_fill") is not None else "-" + msg = ( + f"# ✅ {symbol} 触价开仓成交\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 来源:{ORDER_MONITOR_TYPE_KEY_AUTO}(程序触价 @ E)\n" + f"- 类型:{TRIGGER_ENTRY_MONITOR_TYPE}|{_wechat_direction_text(direction)}\n" + f"- 订单 ID:**{det.get('new_order_id')}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 成交价:{format_price_for_symbol(symbol, det.get('trigger_price'))}\n" + f"- 止损:{format_wechat_scalar_2dp(det.get('stop_loss'))}|止盈:{format_price_for_symbol(symbol, det.get('take_profit'))}\n" + f"- 计划 RR:{rr_txt}:1\n" + f"- {'已挂交易所 TP/SL' if det.get('tpsl_attached') else 'TP/SL 未挂上'}\n" + ) + send_wechat_msg(msg) + insert_key_monitor_history(conn, row, 0, msg, TRIGGER_ENTRY_CLOSE_FILLED) + return True, None + release_trigger_entry_exec_lock(conn, kid) + conn.commit() + fail_msg = err or "触价触发后开仓失败" + send_wechat_msg( + f"# ❌ {symbol} 触价开仓失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 计划入场:{format_price_for_symbol(symbol, entry)}\n" + f"- 原因:{fail_msg}\n" + ) + insert_key_monitor_history(conn, row, 0, fail_msg, TRIGGER_ENTRY_CLOSE_EXCHANGE_FAILED) + return False, fail_msg + + +def check_trigger_entry_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + conn = get_db() + placeholders = ",".join("?" * len(TRIGGER_ENTRY_MONITOR_TYPES)) + rows = conn.execute( + f"SELECT * FROM key_monitors WHERE monitor_type IN ({placeholders})", + tuple(TRIGGER_ENTRY_MONITOR_TYPES), + ).fetchall() + now_dt = app_now() + for r in rows: + symbol = r["symbol"] + direction = (r["direction"] or "long").lower() + mt = (r["monitor_type"] or CALLBACK_TRIGGER_ENTRY_MONITOR_TYPE).strip() + entry = float(_sqlite_row_val(r, "fib_entry_price") or 0) + sl = float(_sqlite_row_val(r, "fib_stop_loss") or 0) + tp = float(_sqlite_row_val(r, "fib_take_profit") or 0) + kid = int(r["id"]) + if is_trigger_entry_in_flight_row(r): + continue + if entry <= 0 or sl <= 0 or tp <= 0: + _finalize_key_monitor_one_shot(conn, r, "触价计划价位无效", "fib_plan_invalid") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + prev_mark = _sqlite_row_val(r, "last_mark_price") + prev_mark_f = float(prev_mark) if prev_mark not in (None, "") else None + if is_trigger_entry_expired(r["created_at"], now_dt, hours=TRIGGER_ENTRY_VALIDITY_HOURS): + exp_txt = trigger_entry_expires_at_text(r["created_at"], hours=TRIGGER_ENTRY_VALIDITY_HOURS) + msg = ( + f"# ⚠️ {symbol} 触价开仓已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h(应于 {exp_txt} 前触发)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_EXPIRED) + continue + inv = trigger_entry_invalidate(mt, direction, mark, sl, tp) + if inv == "tp": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_TP_INVALIDATE) + continue + if inv == "sl": + msg = ( + f"# ⚠️ {symbol} 触价开仓失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{mt}|标记价 {format_price_for_symbol(symbol, mark)} 已触达止损侧(未突破)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, TRIGGER_ENTRY_CLOSE_SL_INVALIDATE) + continue + if trigger_should_fire(mt, direction, mark, entry, prev_mark_f): + _execute_trigger_entry_cross(conn, r) + continue + conn.execute("UPDATE key_monitors SET last_mark_price=? WHERE id=?", (float(mark), kid)) + conn.commit() + conn.close() + + +def check_fib_key_monitors(): + if not KEY_AUTO_ORDER_ENABLED: + return + 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_okx_symbol(symbol) + up, low = float(r["upper"]), float(r["lower"]) + oid = _sqlite_row_val(r, "fib_limit_order_id") + if is_false_breakout_key_monitor_type(typ): + now_dt = app_now() + if is_false_breakout_expired(r["created_at"], now_dt): + _cancel_fib_monitor_limit(r) + exp_txt = expires_at_text(r["created_at"]) + msg = ( + f"# ⚠️ {symbol} 假突破监控已过期\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h(应于 {exp_txt} 前成交)\n" + f"- 已撤销限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "false_breakout_expired") + continue + mark = get_symbol_mark_price(symbol) + if mark is None: + continue + status = fib_limit_order_status(ex_sym, oid) if oid else "missing" + if status == "filled" or (status != "open" and _fib_has_live_position(ex_sym, direction)): + _finalize_fib_key_fill(conn, r) + continue + if is_fib_key_monitor_type(typ) and status == "open": + if fib_invalidate_by_mark(direction, mark, up, low): + _cancel_fib_monitor_limit(r) + msg = ( + f"# ⚠️ {symbol} 斐波监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(symbol, mark)} 已触达止盈侧(未成交),已撤限价单\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + continue + if is_fib_key_monitor_type(typ) and status in ("canceled", "missing", "unknown") and fib_invalidate_by_mark(direction, mark, up, low): + msg = ( + f"# ⚠️ {symbol} 斐波监控失效(限价已不在挂单)\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 标记价触达止盈侧,本条已结案\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "fib_invalidate") + conn.commit() + conn.close() + + +def _false_breakout_exists_for_symbol(conn, symbol): + row = conn.execute( + "SELECT id FROM key_monitors WHERE symbol=? AND monitor_type=?", + (symbol, FALSE_BREAKOUT_MONITOR_TYPE), + ).fetchone() + return row is not None + + +def _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + if _false_breakout_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有假突破监控(同币仅允许一条)" + plan = calc_false_breakout_plan(direction_sel, key_px) + if not plan: + return False, "假突破价位无效,请核对方向与关键价位" + entry, sl, tp = plan + ex_sym = normalize_okx_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_okx_error(e, available_usdt=available_usdt) + be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None) + conn.execute( + "INSERT INTO key_monitors " + "(symbol, monitor_type, direction, upper, lower, " + "fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, " + "fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px, + oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h, + ), + ) + return True, None + + +def _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0, + time_close_enabled=0, time_close_hours=None, +): + if _fib_key_exists_for_symbol(conn, symbol): + return False, f"{symbol} 已有斐波监控(同币仅允许一条 0.618/0.786)" + ratio = fib_ratio_from_type(mt) + plan = calc_fib_plan(direction_sel, upper_px, lower_px, ratio) + if not plan: + return False, "斐波上下沿无效(需上沿 H > 下沿 L)" + entry, sl, tp = plan + ex_sym = normalize_okx_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_okx_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, stop_loss=sl, take_profit=tp + ) + oid = str(order_resp.get("id") or "") + if not oid: + return False, "交易所未返回限价单 ID" + except Exception as e: + return False, friendly_okx_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 + + +def _market_open_for_key_monitor( + conn, + symbol, + direction, + exchange_symbol, + stop_loss, + take_profit, + key_signal_type=None, + breakeven_enabled=0, + time_close_enabled=0, + time_close_hours=None, +): + """ + 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(OKX 永续). + 返回 (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_okx_error(e, available_usdt=available_usdt), None + + trigger_price = round_price_to_exchange(exchange_symbol, trigger_price) + stop_loss = round_price_to_exchange(exchange_symbol, stop_loss) + take_profit = round_price_to_exchange(exchange_symbol, take_profit) + + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) + if risk_amount_final is None: + risk_amount_final = risk_amount + else: + try: + risk_amount_final = round(float(risk_amount_final), 4) + except (TypeError, ValueError): + risk_amount_final = risk_amount + + if direction == "short": + breakeven_raw = float(trigger_price) * (1 - breakeven_offset_pct / 100.0) + else: + breakeven_raw = float(trigger_price) * (1 + breakeven_offset_pct / 100.0) + breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw) + be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0 + tc_en, tc_h, tc_at = time_close_insert_values( + time_close_enabled, time_close_hours, opened_at_ms + ) + + conn.execute( + "INSERT INTO order_monitors " + "(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, " + "margin_capital, leverage, trade_style, risk_percent, risk_amount, " + "breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, " + "notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type, " + "time_close_enabled, time_close_hours, time_close_at_ms) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + exchange_symbol, + direction, + trigger_price, + stop_loss, + stop_loss, + take_profit, + margin_capital, + leverage, + trade_style, + risk_percent, + risk_amount_final, + breakeven_rr_trigger, + breakeven_offset_pct, + breakeven_step_r, + 0, + breakeven_price, + be_enabled, + notional_value, + position_ratio, + base_amount, + amount, + open_order_id, + opened_at_bj, + opened_at_ms, + trading_day, + ORDER_MONITOR_TYPE_KEY_AUTO, + stored_key_signal_type(key_signal_type), + tc_en, + tc_h, + tc_at, + ), + ) + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + 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 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 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 + + +# 关键位监控(箱体/收敛可自动开仓;阻力/支撑为双向 5m 收盘突破 + 三次提醒) +def check_key_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM key_monitors").fetchall() + for r in rows: + sym, typ_raw, up, low = r["symbol"], r["monitor_type"], r["upper"], r["lower"] + typ = (typ_raw or "").strip() + if is_limit_key_monitor_type(typ): + continue + if typ in KEY_MONITOR_RS_TYPES: + try: + _process_key_rs_level_alert(conn, r) + except Exception as e: + print(f"[key_rs_level_alert] {sym} id={r['id']}: {e}") + continue + + if not KEY_AUTO_ORDER_ENABLED: + continue + + direction = (r["direction"] or "long").lower() + if direction == KEY_DIRECTION_WATCH: + continue + if typ in KEY_MONITOR_AUTO_TYPES: + mark = get_symbol_mark_price(sym) + if mark is not None and box_breakout_invalidate_by_mark(direction, mark, up, low): + edge = float(low) if direction == "long" else float(up) + edge_label = box_breakout_invalidate_edge_label(direction) + msg = ( + f"# ⚠️ {sym} 关键位监控失效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{_wechat_direction_text(direction)}\n" + f"- 标记价 {format_price_for_symbol(sym, mark)} 已突破反向{edge_label} " + f"{format_price_for_symbol(sym, edge)}(设置失效)\n" + ) + send_wechat_msg(msg) + _finalize_key_monitor_one_shot(conn, r, msg, "box_opposite_break") + continue + try: + checks = _key_hard_checks(sym, direction, up, low, typ) + except Exception: + checks = {"ok": False} + if not checks.get("ok"): + continue + + btc8h_status, _, _ = _status_by_ema55("BTC/USDT", "8h") + coin4h_status, _, _ = _status_by_ema55(sym, "4h") + risk_tip = None + if (direction == "long" and coin4h_status == "空头") or (direction == "short" and coin4h_status == "多头"): + risk_tip = "当前信号与本币4h(EMA55)主趋势逆势,建议降低仓位并严格执行止损." + + key_price = float(low) if direction == "long" else float(up) + hard_lines = _key_hard_lines_from_checks(checks) + trigger_time = ms_to_app_local_str(int(checks["confirm_ts"])) if checks.get("confirm_ts") else app_now_str() + + if typ not in KEY_MONITOR_AUTO_TYPES: + continue + + plan_tuple, sl_tp_mode = _key_plan_sl_tp_for_row(r, direction, up, low, checks) + if not plan_tuple: + fmt_rr = "无法计算(止损/止盈与确认价几何关系无效)" + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划无效\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, checks.get('confirm_close'))}`\n" + f"- **{fmt_rr}**(未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + E, sl_raw, tp_raw, box_h = plan_tuple + exchange_symbol = normalize_okx_symbol(sym) + try: + ensure_markets_loaded() + except Exception: + pass + sl_px = round_price_to_exchange(exchange_symbol, sl_raw) + tp_px = round_price_to_exchange(exchange_symbol, tp_raw) + if sl_px is not None: + sl_raw = float(sl_px) + if tp_px is not None: + tp_raw = float(tp_px) + + planned_rr = calc_rr_ratio(direction, E, sl_raw, tp_raw) + rr_ok = planned_rr is not None and planned_rr > KEY_AUTO_MIN_PLANNED_RR + + if not rr_ok: + fmt_rr = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算(止损/止盈与确认价几何关系无效)" + plan_line = sl_tp_plan_summary_text( + sl_tp_mode, direction, E, sl_raw, tp_raw, box_h, + outside_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + trend_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + ) + rr_msg = ( + f"# ⚠️ {sym} 关键位自动单:计划 RR 未达标\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}|{plan_line}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 箱体高 H:`{format_price_for_symbol(sym, box_h)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按确认收盘 E):{fmt_rr} : 1**(要求 **>{KEY_AUTO_MIN_PLANNED_RR}:1**,未开仓)\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + rr_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(rr_msg) + _finalize_key_monitor_one_shot(conn, r, rr_msg, "rr_insufficient") + continue + + key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None + be_on = breakeven_enabled_from_row(r, 0) + tc_en, tc_h, _ = time_close_settings_from_row(r) + ok_trade, trade_err, det = _market_open_for_key_monitor( + conn, + sym, + direction, + exchange_symbol, + sl_raw, + tp_raw, + key_signal_type=key_sig, + breakeven_enabled=1 if be_on else 0, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + planned_rr_txt = ( + format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else "-" + ) + if not ok_trade: + fail_msg = ( + f"# ❌ {sym} 关键位自动单失败\n" + f"**账户:{_wechat_account_label()}**\n" + f"- 类型:{typ}\n" + f"- 方向:**{_wechat_direction_text(direction)}**\n" + f"- 触发时间:`{trigger_time}`\n" + f"- 确认K收盘(E):`{format_price_for_symbol(sym, E)}`\n" + f"- 计划止损:`{format_wechat_scalar_2dp(sl_raw)}`\n" + f"- 计划止盈:`{format_price_for_symbol(sym, tp_raw)}`\n" + f"- **计划 RR(按 E):{planned_rr_txt} : 1**(已通过 RR 阈值)\n" + f"- **失败原因:{trade_err}**\n" + "---\n" + "### 硬条件\n" + + "\n".join(f"- {x}" for x in hard_lines) + ) + if risk_tip: + fail_msg += f"\n---\n### 逆势风险提示\n- {risk_tip}" + send_wechat_msg(fail_msg) + _finalize_key_monitor_one_shot(conn, r, fail_msg, "exchange_failed") + continue + + tpsl_txt = ( + "已在交易所挂止盈/止损触发单(OKX 条件单)" + if det.get("tpsl_attached") + else "⚠️ 条件单挂接状态异常或未挂上" + ) + rr_fill = det.get("planned_rr_fill") + rr_fill_txt = format_wechat_scalar_2dp(rr_fill) if rr_fill is not None else "-" + + succ_msg_lines = [ + f"# ✅ {sym} 关键位自动开仓成功", + f"**账户:{_wechat_account_label()}**", + f"- **来源:**{ORDER_MONITOR_TYPE_KEY_AUTO}(市价)", + f"- 页面订单 ID:**{det['new_order_id']}**", + f"- 交易所订单 ID:`{det.get('open_order_id') or '-'}`", + f"- 类型:{typ}|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_on else '关'}", + f"- 方向:**{_wechat_direction_text(direction)}**", + f"- 触发时间:`{trigger_time}`", + f"- 确认K收盘(E):{format_price_for_symbol(sym, E)}(RR 阈值按此计价)", + f"- **计划 RR(E):{planned_rr_txt}:1**", + f"- 开仓成交价:**{format_price_for_symbol(sym, det['trigger_price'])}**", + f"- **成交价侧计划 RR:**{rr_fill_txt}:1", + f"- 止损:{format_wechat_scalar_2dp(sl_raw)}", + f"- 止盈:{format_price_for_symbol(sym, tp_raw)}", + f"- 风险:{det.get('risk_percent')}%≈{format_wechat_scalar_2dp(det.get('risk_amount_final'))}U|基数 {format_wechat_scalar_2dp(det.get('margin_capital'))}U|杠杆 {det.get('leverage')}x", + f"- 名义 {format_wechat_scalar_2dp(det.get('notional_value'))}U|张数 {format_wechat_scalar_2dp(det.get('amount'))}|折算标的 {det.get('base_amount')}", + f"- **{tpsl_txt}**", + f"- 保本触发:{det.get('breakeven_rr_trigger')}R→{format_price_for_symbol(sym, det.get('breakeven_price'))}", + f"- {format_daily_open_summary_short(det.get('opens_today_after'), DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT)}", + ] + succ_msg_lines.extend(["---", "### 硬条件"] + [f"- {x}" for x in hard_lines]) + if risk_tip: + succ_msg_lines.extend(["---", "### 逆势风险提示", f"- {risk_tip}"]) + succ_msg = "\n".join(succ_msg_lines) + send_wechat_msg(succ_msg) + _finalize_key_monitor_one_shot(conn, r, succ_msg, "auto_opened") + + if should_send_daily_open_alert( + det.get("opens_today_before", 0), + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + det["trading_day"], + det.get("opens_today_after", 0), + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔来源为关键位自动单:{sym} {direction},杠杆{det['leverage']}x.", + ) + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {det['opens_today_after']}\n{advice[:800]}") + conn.commit() + conn.close() + +# 止盈止损监控(已修复:严格区分多空,无默认做多) +def check_order_monitors(): + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + pid, sym, direction, trigger_price, stop_loss, take_profit = r["id"], r["symbol"], r["direction"], r["trigger_price"], r["stop_loss"], r["take_profit"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(sym) + session_date = r["session_date"] or get_trading_day() + p = get_price(sym) + if not p: continue + + # 到达设定 R 倍后,按阶梯持续上移止损(本地风控层) + risk_amount = float(r["risk_amount"] or 0) + breakeven_armed = int(r["breakeven_armed"] or 0) + if stale_breakeven_armed(direction, trigger_price, stop_loss, breakeven_armed): + conn.execute( + "UPDATE order_monitors SET breakeven_armed=0, breakeven_price=NULL WHERE id=?", + (pid,), + ) + breakeven_armed = 0 + trigger_rr = float(r["breakeven_rr_trigger"] or BREAKEVEN_RR_TRIGGER) + step_r = float(r["breakeven_step_r"] or BREAKEVEN_STEP_R or 1.0) + step_r = 1.0 if step_r <= 0 else step_r + breakeven_enabled = True + try: + if "breakeven_enabled" in r.keys(): + breakeven_enabled = int(r["breakeven_enabled"] or 0) != 0 + except Exception: + breakeven_enabled = True + if breakeven_enabled and risk_amount > 0 and trigger_rr > 0: + now_pnl = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + now_rr = now_pnl / risk_amount + if now_rr >= trigger_rr: + steps = int((now_rr - trigger_rr) // step_r) + locked_r = max(0.0, steps * step_r) + notional = float(margin_capital or 0) * float(leverage or 0) + risk_frac = (risk_amount / notional) if notional > 0 else None + if risk_frac and risk_frac > 0: + new_sl = calc_breakeven_stop( + direction, + trigger_price, + risk_frac, + locked_r=locked_r, + offset_pct=float(r["breakeven_offset_pct"] or BREAKEVEN_OFFSET_PCT), + ) + if new_sl is not None: + should_move = (direction == "short" and new_sl < float(stop_loss)) or ( + direction == "long" and new_sl > float(stop_loss) + ) + if should_move: + was_armed = breakeven_armed + ex_sym = resolve_monitor_exchange_symbol(r) + new_sl = round_price_to_exchange(ex_sym, new_sl) + tp_ex = float(take_profit or 0) + ok_live, _live_reason = ensure_okx_live_ready() + synced_ex = False + last_ex_sync = float(_BREAKEVEN_LAST_EX_SYNC.get(pid, 0)) + interval_ok = ( + time.time() - last_ex_sync + ) >= BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC + if ok_live and tp_ex > 0 and interval_ok: + try: + replace_active_monitor_tpsl_on_exchange(r, new_sl, tp_ex) + synced_ex = True + _BREAKEVEN_LAST_EX_SYNC[pid] = time.time() + _clear_breakeven_exchange_warn(pid) + except Exception as e: + print( + f"[breakeven] exchange tpsl replace failed order={pid} {sym}: {e}", + flush=True, + ) + _send_breakeven_exchange_warn_once( + pid, + f"⚠️ {sym} 移动保本止损未同步交易所:{friendly_okx_error(e)}", + ) + elif ok_live: + print( + f"[breakeven] skip exchange order={pid} {sym}: invalid take_profit", + flush=True, + ) + if synced_ex: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, breakeven_armed=1, breakeven_price=? WHERE id=?", + (new_sl, new_sl, pid), + ) + stop_loss = new_sl + breakeven_armed = 1 + if not was_armed: + arm_txt = "保本止盈" + be_msg = build_wechat_breakeven_message( + sym, + direction, + arm_txt, + now_rr, + locked_r, + new_sl, + ) + if ok_live: + be_msg += "\n- 交易所:已先撤后挂止盈止损" + send_wechat_msg(be_msg) + + res = None + if should_trigger_time_close(r): + res = TIME_CLOSE_RESULT + # 做多 + if not res and direction == "long": + if p >= take_profit: res = "止盈" + elif p <= stop_loss: res = "止损" + # 做空 + elif not res and direction == "short": + if p <= take_profit: res = "止盈" + elif p >= stop_loss: res = "止损" + + if res: + now = app_now() + opened_at = get_opened_at_value(r) + opened_at_ms = (r["opened_at_ms"] if "opened_at_ms" in r.keys() else None) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + if res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + close_order_id = "" + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + # 平仓入库优先使用交易所返回成交价;拿不到再回退拉成交明细. + exit_p = extract_trade_price_from_order(close_resp) + if exit_p and exit_p > 0: + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + else: + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + except Exception as e: + if is_no_position_error(str(e)): + ex_sym = r["exchange_symbol"] or normalize_okx_symbol(sym) + tr = fetch_latest_closing_fill( + ex_sym, + direction, + opened_at, + opened_at_ms=opened_at_ms, + ) + if tr and tr.get("price"): + try: + exit_p = float(tr["price"]) + pnl_amount = calc_pnl(direction, trigger_price, exit_p, margin_capital, leverage) + # 交易所已返回真实成交价时,以真实成交结果为准,避免本地轮询竞态导致误判. + guessed_res = classify_exit_by_levels(direction, trigger_price, stop_loss, take_profit, exit_p) + if guessed_res: + if guessed_res == "止损" and float(pnl_amount or 0) > 0: + res = normalize_result_with_pnl("止损", pnl_amount) + else: + res = normalize_result_with_pnl(guessed_res, pnl_amount) + else: + res = normalize_result_with_pnl(res, pnl_amount) + except (TypeError, ValueError): + pass + ts = tr.get("timestamp") + if ts: + closed_at = ms_to_app_local_str(int(ts)) + hold_seconds = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(closed_at) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason=handoff_trade_miss_reason( + "触发价已触达,仓位已由交易所止盈/止损或其他方式平掉(本地补记)", + r, + ), + opened_at=opened_at, + closed_at=closed_at, + ) + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{res}(交易所已先行平仓)", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note="本地补记:仓位由交易所止盈/止损或其他方式先行平掉", + session_capital_fallback=session_capital, + ) + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (pid,)) + conn.commit() + continue + ex_sym_fail = r["exchange_symbol"] or normalize_okx_symbol(sym) + live_contracts = get_live_position_contracts(ex_sym_fail, direction) + if live_contracts is not None and live_contracts <= 0: + record_res, record_pnl, record_closed, sync_miss = resolve_synced_flat_close( + r, opened_at, opened_at_ms=opened_at_ms + ) + record_miss = f"{sync_miss};本地触发{res}时平仓API失败:{e}" + monitor_status = "stopped" + else: + record_res, record_pnl, record_closed = res, pnl_amount, closed_at + record_miss = f"触发{res}后交易所平仓失败(请核对交易所仓位):{e}" + monitor_status = "error" + record_hold = calc_hold_seconds( + opened_at, parse_dt_for_trading_day(record_closed) or now + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=record_pnl, + hold_seconds=record_hold, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(record_pnl, r["risk_amount"]), + result=record_res, + miss_reason=handoff_trade_miss_reason(record_miss, r), + opened_at=opened_at, + closed_at=record_closed, + ) + session_capital = update_session_capital(conn, session_date, record_pnl) + conn.execute("UPDATE order_monitors SET status=? WHERE id=?", (monitor_status, pid)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=sym, + direction=direction, + scene=f"触发{res}后交易所平仓失败", + error_text=str(e), + ) + ) + if monitor_status == "stopped": + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=f"{record_res}(已补记入交易记录)", + pnl_amount=record_pnl, + hold_seconds=record_hold, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id="-", + extra_note=record_miss, + session_capital_fallback=session_capital, + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + send_wechat_msg( + build_wechat_close_message( + symbol=sym, + direction=direction, + result=res, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=stop_loss, + take_profit=take_profit, + close_order_id=close_order_id or "-", + session_capital_fallback=session_capital, + ) + ) + insert_trade_record( + conn, + symbol=sym, + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=stop_loss, + initial_stop_loss=r["initial_stop_loss"] or stop_loss, + take_profit=take_profit, + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or stop_loss, take_profit), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result=res, + miss_reason=handoff_trade_miss_reason(None, r), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, pid)) + conn.commit() + conn.close() + + +def force_close_before_reset(): + if not FORCE_CLOSE_ENABLED: + return + now = app_now() + # 每天北京时间指定整点起 FORCE_CLOSE_GRACE_MINUTES 分钟内执行兜底清仓 + from lib.trade.force_close_lib import is_force_close_executing + + if not is_force_close_executing(FORCE_CLOSE_BJ_HOUR, now_ms=int(now.timestamp() * 1000)): + return + conn = get_db() + rows = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for r in rows: + p = get_price(r["symbol"]) + if not p: + continue + direction = r["direction"] + trigger_price = r["trigger_price"] + margin_capital = r["margin_capital"] or DAILY_START_CAPITAL + leverage = r["leverage"] or infer_leverage(r["symbol"]) + session_date = r["session_date"] or get_trading_day(now) + opened_at = get_opened_at_value(r) + closed_at = now.strftime("%Y-%m-%d %H:%M:%S") + hold_seconds = calc_hold_seconds(opened_at, now) + pnl_amount = calc_pnl(direction, trigger_price, p, margin_capital, leverage) + try: + close_resp = close_exchange_order(r) + close_order_id = close_resp.get("id", "") + except Exception as e: + conn.execute("UPDATE order_monitors SET status='error' WHERE id=?", (r["id"],)) + conn.commit() + send_wechat_msg( + build_wechat_monitor_error_message( + symbol=r["symbol"], + direction=direction, + scene="强制清仓失败", + error_text=str(e), + ) + ) + continue + session_capital = update_session_capital(conn, session_date, pnl_amount) + insert_trade_record( + conn, + symbol=r["symbol"], + monitor_type=trade_record_monitor_type(conn, r), + trend_plan_id=trend_plan_id_from_monitor_row(r), + key_signal_type=order_row_key_signal_type(r), + direction=direction, + trigger_price=trigger_price, + stop_loss=r["stop_loss"], + initial_stop_loss=r["initial_stop_loss"] or r["stop_loss"], + take_profit=r["take_profit"], + margin_capital=margin_capital, + leverage=leverage, + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=r["trade_style"], + entry_model=(r["entry_model"] if "entry_model" in r.keys() else None), + risk_amount=r["risk_amount"], + planned_rr=calc_rr_ratio(direction, trigger_price, r["initial_stop_loss"] or r["stop_loss"], r["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, r["risk_amount"]), + result="强制清仓", + miss_reason=handoff_trade_miss_reason( + f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + r, + ), + opened_at=opened_at, + closed_at=closed_at, + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, r["id"])) + send_wechat_msg( + build_wechat_close_message( + symbol=r["symbol"], + direction=direction, + result="强制清仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=trigger_price, + current_price=p, + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + close_order_id=close_order_id or "-", + extra_note=f"北京时间 {FORCE_CLOSE_BJ_HOUR}:00 整点风控清仓", + session_capital_fallback=session_capital, + ) + ) + conn.commit() + conn.close() + +# 后台线程 +def background_task(): + while True: + try: + auto_transfer_once_per_day() + conn = get_db() + force_close_before_reset() + reconcile_external_closes(conn) + conn.commit() + conn.close() + check_fib_key_monitors() + check_trigger_entry_key_monitors() + _roll_cfg = app.extensions.get("strategy_roll_cfg") + if _roll_cfg: + from lib.strategy.strategy_roll_monitor_lib import check_roll_monitors + + check_roll_monitors(_roll_cfg) + check_key_monitors() + check_order_monitors() + cfg = app.extensions.get("strategy_trend_cfg") + if cfg: + from lib.strategy.strategy_trend_register import check_trend_pullback_plans + + check_trend_pullback_plans(cfg) + except Exception as e: + print(f"[monitor_loop] {e}", flush=True) + time.sleep(MONITOR_POLL_SECONDS) + + +# ====================== 登录路由 ====================== +@app.route("/login", methods=["GET", "POST"]) +def login(): + if AUTH_DISABLED: + session["logged_in"] = True + return redirect("/") + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + if username == USERNAME and password == PASSWORD: + session["logged_in"] = True + return redirect("/") + else: + flash("账号或密码错误") + return render_template( + "login.html", + exchange_display=EXCHANGE_DISPLAY_NAME, + pwa_app_name="OKX 交易系统", + ) + +@app.route("/logout") +def logout(): + session.clear() + return redirect("/" if AUTH_DISABLED else "/login") + +# 登录校验装饰器 +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): + return f(*args, **kwargs) + return redirect("/login") + return decorated + + +@app.route("/sync_positions") +@login_required +def sync_positions(): + days_raw = (request.args.get("days") or "").strip() + sync_days = None + if days_raw: + try: + sync_days = max(1, min(365, int(days_raw))) + except Exception: + sync_days = None + conn = get_db() + synced = reconcile_external_closes(conn, days=sync_days) + conn.commit() + conn.close() + if sync_days is not None: + flash(f"同步完成:最近 {sync_days} 天内 {synced} 笔持仓已按交易所状态更新") + else: + flash(f"同步完成:{synced} 笔持仓已按交易所状态更新") + return redirect("/") + + +@app.route("/api/sync_positions", methods=["POST"]) +@login_required +def api_sync_positions(): + payload = request.get_json(silent=True) or {} + days_raw = str(payload.get("days", "")).strip() + if not days_raw: + return jsonify({"ok": False, "msg": "请填写天数"}), 400 + try: + days = int(days_raw) + except Exception: + return jsonify({"ok": False, "msg": "天数必须是整数"}), 400 + if days < 1 or days > 365: + return jsonify({"ok": False, "msg": "天数范围 1-365"}), 400 + conn = get_db() + synced = reconcile_external_closes(conn, days=days) + conn.commit() + conn.close() + return jsonify({"ok": True, "days": days, "synced": int(synced)}) + + +# ====================== 主页面 ====================== +def render_main_page(page="trade", embed_mode=None): + now = app_now() + trading_day = get_trading_day(now) + list_window = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(list_window["start_utc"], list_window["end_utc"], APP_TZ) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + from lib.instance.instance_embed_context_lib import ( + embed_render_plan, + minimal_stats_bundle, + profit_loss_ratio_from_trades, + show_perp_funds_enabled, + total_funds_usdt, + trade_records_summary, + ) + + plan = embed_render_plan(page, embed_mode) + if plan.exchange_capitals: + funding_capital, trading_capital = get_exchange_capitals() + else: + funding_capital, trading_capital = None, None + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + if ( + OKX_OPTIONS_ENABLED + and exchange_options.apiKey + and embed_mode != "fragment" + ): + try: + from lib.exchange.okx_options_lib import options_header_balance_pack + + _op = options_header_balance_pack(exchange_options) + options_trading_usdc = _op.get("trading_usdc") + options_funding_usdc = _op.get("funding_usdc") + options_funding_usdt = _op.get("funding_usdt") + options_trading_usdt = _op.get("trading_usdt") + options_funding_eth = _op.get("funding_eth") + options_trading_eth = _op.get("trading_eth") + options_trading_btc = _op.get("trading_btc") + options_margin_mode = _op.get("options_margin_mode") or "coin" + options_underly = _op.get("options_underly") or "ETH" + except Exception: + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + recommended_capital = get_recommended_capital(current_capital) + key_list = ( + conn.execute("SELECT * FROM key_monitors").fetchall() if plan.key_list else [] + ) + key_history = ( + conn.execute( + "SELECT * FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id DESC LIMIT 500", + (start_bj, end_bj), + ).fetchall() + if plan.key_history + else [] + ) + stats_bundle = ( + compute_stats_bundle(conn, trading_day, now) + if plan.stats_bundle + else minimal_stats_bundle(TRADING_DAY_RESET_HOUR) + ) + order_list = [] + if plan.orders: + raw_order_list = conn.execute("SELECT * FROM order_monitors WHERE status='active'").fetchall() + for o in raw_order_list: + order_list.append(enrich_order_item(row_to_dict(o), current_capital)) + enrich_orders_force_close( + order_list, + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ) + exchange_pnl_sync = {} + if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( + "fragment", + "shell", + ): + try: + exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} + except Exception as e: + exchange_pnl_sync = {"ok": False, "reason": str(e)} + tr_ts = sql_list_time_field("closed_at", "created_at", "opened_at") + if plan.records_rows: + raw_records = conn.execute( + f"SELECT * FROM trade_records WHERE {tr_ts} >= ? AND {tr_ts} <= ? ORDER BY id DESC LIMIT 1000", + (start_bj, end_bj), + ).fetchall() + records = filter_trade_records_excluding_miss( + [to_effective_trade_dict(r) for r in raw_records] + ) + total = len(records) + win = count_winning_trades(records) + rate = round(win / total * 100, 2) if total else 0 + profit_loss_ratio = profit_loss_ratio_from_trades(records) + elif plan.records_summary: + summary = trade_records_summary(conn, start_bj, end_bj, tr_ts) + records = summary["records"] + total = summary["total"] + rate = summary["rate"] + profit_loss_ratio = summary.get("profit_loss_ratio") + else: + records = [] + total = rate = 0 + profit_loss_ratio = None + active_count = len(order_list) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( + time_allows=trading_day_reset_allows_new_open(now, conn), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, + ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] + key_rule_ctx = {} + if page in ("key_monitor", "trade") or page in ( + "strategy", + "strategy_trend", + "strategy_roll", + "strategy_records", + ): + key_rule_ctx = key_monitor_rule_template_context( + kline_timeframe=KLINE_TIMEFRAME, + key_breakout_amp_min_pct=KEY_BREAKOUT_AMP_MIN_PCT, + key_volume_ma_bars=KEY_VOLUME_MA_BARS, + key_volume_ratio_min=KEY_VOLUME_RATIO_MIN, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + key_daily_volume_rank_max=KEY_DAILY_VOLUME_RANK_MAX, + key_confirm_breakout_bar=KEY_CONFIRM_BREAKOUT_BAR, + key_confirm_bar=KEY_CONFIRM_BAR, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + key_alert_interval_minutes=KEY_ALERT_INTERVAL_MINUTES, + key_stop_outside_breakout_pct=KEY_STOP_OUTSIDE_BREAKOUT_PCT, + key_trend_stop_outside_pct=KEY_TREND_STOP_OUTSIDE_PCT, + false_breakout_validity_hours=FALSE_BREAKOUT_VALIDITY_HOURS, + trigger_entry_validity_hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + strategy_extra = {} + if plan.strategy: + from lib.strategy.strategy_ui import strategy_render_extras + + strategy_extra = strategy_render_extras( + conn, + page, + default_risk_percent=float(RISK_PERCENT), + request_obj=request, + trend_cfg=app.extensions.get("strategy_trend_cfg"), + ) + conn.close() + from lib.instance.instance_embed_lib import embed_context_extras + from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context + + _display_ctx = display_prefs_template_context(get_db) + from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode + + _okx_trade_mode = get_okx_trade_mode() + _hedge_mode_on = _okx_trade_mode in ("perp_options", "options_options") + _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") + template_ctx = dict( + page=page, + key=key_list, + key_history=key_history, + stats_bundle=stats_bundle, + order=order_list, + record=records, + total=total, + rate=rate, + profit_loss_ratio=profit_loss_ratio, + total_funds=total_funds_usdt( + funding_usdt if _show_perp_funds else None, + current_capital if _show_perp_funds else None, + options_trading_usdc, + options_funding_usdc, + None, + None, + ), + options_funding_usdc=options_funding_usdc, + options_funding_usdt=options_funding_usdt, + options_trading_usdc=options_trading_usdc, + options_trading_usdt=options_trading_usdt, + options_funding_eth=options_funding_eth, + options_trading_eth=options_trading_eth, + options_trading_btc=options_trading_btc, + options_margin_mode=options_margin_mode, + options_underly=options_underly, + trading_day=trading_day, + daily_start_capital=DAILY_START_CAPITAL, + current_capital=current_capital, + recommended_capital=recommended_capital, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + reset_hour=TRADING_DAY_RESET_HOUR, + open_guard_enabled=open_guard_enabled, + open_guard_blocks_now=open_guard_blocks_now, + balance_refresh_seconds=BALANCE_REFRESH_SECONDS, + auto_transfer_enabled=AUTO_TRANSFER_ENABLED, + auto_transfer_amount=AUTO_TRANSFER_AMOUNT, + auto_transfer_from=AUTO_TRANSFER_FROM, + auto_transfer_to=AUTO_TRANSFER_TO, + auto_transfer_bj_hour=AUTO_TRANSFER_BJ_HOUR, + full_margin_buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + active_count=position_limit_count, + can_trade=can_trade, + open_block_note=open_block_note, + opens_today=opens_today, + daily_open_hard_limit=DAILY_OPEN_HARD_LIMIT, + daily_open_alert_threshold=DAILY_OPEN_ALERT_THRESHOLD, + focus_key_id=(key_list[0]["id"] if key_list else None), + focus_order_id=(order_list[0]["id"] if order_list else None), + data_export_version=3, + list_window=list_window, + list_window_presets={ + "utc_this_month": PRESET_UTC_THIS_MONTH, + "utc_last3m": PRESET_UTC_LAST3M, + "utc_last6m": PRESET_UTC_LAST6M, + "all": PRESET_ALL, + "utc_today": PRESET_UTC_TODAY, + "utc_last24h": PRESET_UTC_LAST24H, + "utc_last7d": PRESET_UTC_LAST7D, + "custom": PRESET_CUSTOM, + }, + key_alert_max_times=KEY_ALERT_MAX_TIMES, + risk_percent=RISK_PERCENT, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + trade_policy=trade_policy_template_context(TRADE_POLICY), + **order_entry_template_context(TRADE_POLICY), + open_position_button_label=open_position_button_label(TRADE_POLICY, POSITION_SIZING_MODE), + breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, + breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, + price_fmt=format_price_for_symbol, + entry_reason_options=list( + effective_entry_reason_options( + ENTRY_REASON_OPTIONS, + POSITION_SIZING_MODE, + KEY_AUTO_ORDER_ENABLED, + trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY), + ) + ), + order_type_options=list(JOURNAL_ORDER_TYPE_OPTIONS), + key_auto_order_enabled=KEY_AUTO_ORDER_ENABLED, + journal_chart_tf_choices=JOURNAL_CHART_TF_CHOICES, + journal_chart_default_tf1=JOURNAL_CHART_DEFAULT_TF1, + journal_chart_default_tf2=JOURNAL_CHART_DEFAULT_TF2, + journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, + journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, + key_rule_ctx=key_rule_ctx, + funds_fmt=format_funds_u, + # options_funding_label / trading_account_label 由 embed_context_extras 注入,勿重复写进 dict + exchange_display=EXCHANGE_DISPLAY_NAME, + options_enabled=OKX_OPTIONS_ENABLED, + show_perp_funds=_show_perp_funds or (options_margin_mode == "coin"), + options_nav_visible=True, + okx_trade_mode=_okx_trade_mode, + options_open_allowed=_okx_trade_mode == "options", + hedge_plan_enabled=_hedge_mode_on, + hedge_plan_nav_visible=_hedge_mode_on, + hedge_plan_show_perp_options=_okx_trade_mode == "perp_options", + hedge_plan_show_options_options=_okx_trade_mode == "options_options", + hedge_plan_oo_close_mode_enabled=os.getenv("HEDGE_PLAN_OO_CLOSE_MODE_ENABLED", "true").lower() + in ("1", "true", "yes", "on"), + hedge_plan_option_primary=os.getenv("HEDGE_PLAN_OPTION_PRIMARY", "true").lower() + in ("1", "true", "yes", "on"), + hedge_plan_budget_buffer=float(os.getenv("HEDGE_PLAN_BUDGET_BUFFER") or "0.95"), + options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC, + options_budget_buffer=float(os.getenv("OKX_OPTIONS_BUDGET_BUFFER") or "0.95"), + options_compound_full_enabled=os.getenv( + "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true" + ).lower() + in ("1", "true", "yes", "on"), + options_compound_full_cap_enabled=os.getenv( + "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false" + ).lower() + in ("1", "true", "yes", "on"), + options_compound_full_cap_usdc=float( + os.getenv("OKX_OPTIONS_COMPOUND_FULL_CAP_USDC") or "300" + ), + options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY, + options_chain_ask_liq_filter=os.getenv( + "OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", "true" + ).lower() + in ("1", "true", "yes", "on"), + risk_status=risk_status, + max_active_positions=MAX_ACTIVE_POSITIONS, + manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, + key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, + kline_timeframe=KLINE_TIMEFRAME, + funding_usdt=funding_usdt, + exchange_pnl_sync=exchange_pnl_sync, + **strategy_extra, + **embed_context_extras("okx"), + **_display_ctx, + **settings_page_context( + page, + display=_display_ctx["display"], + instance_base_dir=BASE_DIR, + exchange_key="okx", + exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, + trade_policy=TRADE_POLICY, + data_export_version=3, + open_guard_enabled=open_guard_enabled, + ), + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(app_now().timestamp() * 1000), + ), + ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template("embed_shell.html", initial_tab=page, **template_ctx) + return render_template("index.html", **template_ctx) + + +@app.route("/api/sync_exchange_pnl") +@login_required +def api_sync_exchange_pnl(): + conn = get_db() + stats = sync_trade_records_from_exchange(conn, force=True) + try: + conn.commit() + except Exception: + pass + conn.close() + return jsonify(stats) + + +@app.route("/") +@login_required +def index(): + return redirect("/trade") + + +@app.route("/key_monitor") +@login_required +def key_monitor_page(): + # CoinOX 去掉单独永续关键位开仓入口 + flash("CoinOX 不提供关键位自动开仓,请使用期权或对冲计划") + return redirect("/hedge-plan") + + +@app.route("/trade") +@login_required +def trade_page(): + # CoinOX 去掉单独永续下单入口 + flash("CoinOX 不提供单独永续下单,请使用期权或对冲计划") + return redirect("/hedge-plan") + + +@app.route("/records") +@login_required +def records_page(): + redir = redirect_to_embed_shell_if_enabled("records") + if redir is not None: + return redir + return render_main_page("records") + + +@app.route("/stats") +@login_required +def stats_page(): + redir = redirect_to_embed_shell_if_enabled("stats") + if redir is not None: + return redir + return render_main_page("stats") + + +@app.route("/dashboard") +@login_required +def dashboard_page(): + redir = redirect_to_embed_shell_if_enabled("dashboard") + if redir is not None: + return redir + return render_main_page("dashboard") + + +@app.route("/risk_policy") +@login_required +def risk_policy_page(): + redir = redirect_to_embed_shell_if_enabled("risk_policy") + if redir is not None: + return redir + return render_main_page("risk_policy") + + +@app.route("/system_guide") +@login_required +def system_guide_page(): + redir = redirect_to_embed_shell_if_enabled("system_guide") + if redir is not None: + return redir + return render_main_page("system_guide") + + +@app.route("/env_config") +@login_required +def env_config_page(): + redir = redirect_to_embed_shell_if_enabled("env_config") + if redir is not None: + return redir + return render_main_page("env_config") + + +@app.route("/settings") +@login_required +def settings_page(): + redir = redirect_to_embed_shell_if_enabled("settings") + if redir is not None: + return redir + return render_main_page("settings") + + +@app.route("/options") +@login_required +def options_main_page(): + redir = redirect_to_embed_shell_if_enabled("options") + if redir is not None: + return redir + return render_main_page("options") + + +@app.route("/api/account_snapshot") +@login_required +def api_account_snapshot(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + force_refresh = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes") + funding_capital, trading_capital = get_exchange_capitals(force=force_refresh) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS) + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + options_index_px = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.exchange.okx_options_lib import fetch_index_price, options_header_balance_pack + + _op = options_header_balance_pack(exchange_options, force=force_refresh) + options_trading_usdc = _op.get("trading_usdc") + options_funding_usdc = _op.get("funding_usdc") + options_funding_usdt = _op.get("funding_usdt") + options_trading_usdt = _op.get("trading_usdt") + options_funding_eth = _op.get("funding_eth") + options_trading_eth = _op.get("trading_eth") + options_trading_btc = _op.get("trading_btc") + options_margin_mode = _op.get("options_margin_mode") or "coin" + options_underly = _op.get("options_underly") or "ETH" + options_index_px = fetch_index_price(exchange_options, options_underly) + except Exception: + options_trading_usdc = None + options_funding_usdc = None + options_funding_usdt = None + options_trading_usdt = None + options_funding_eth = None + options_trading_eth = None + options_trading_btc = None + options_margin_mode = "coin" + options_underly = "ETH" + options_index_px = None + recommended_capital = get_recommended_capital(current_capital) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) + active_pnl_rows = conn.execute( + "SELECT exchange_symbol, symbol, direction FROM order_monitors WHERE status='active'" + ).fetchall() + from lib.instance.instance_embed_context_lib import ( + header_trade_stats_for_window, + show_perp_funds_enabled, + total_funds_usdt, + ) + + header_trade_stats = header_trade_stats_for_window(conn, _list_window_from_request(), APP_TZ) + conn.close() + open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR + from lib.trade.open_trade_gate_lib import resolve_manual_open_gate + + _open_gate = resolve_manual_open_gate( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + risk_status=risk_status, + force_close_enabled=FORCE_CLOSE_ENABLED, + force_close_bj_hour=FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + reset_hour=TRADING_DAY_RESET_HOUR, + ) + can_trade = _open_gate["can_trade"] + open_block_note = _open_gate["open_block_note"] + available_trading_usdt = get_available_trading_usdt() + + unrealized_pnl = None + if exchange_private_api_configured(): + from lib.instance.instance_live_pnl_lib import resolve_instance_unrealized_pnl + + def _okx_positions(): + ensure_markets_loaded() + try: + return exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] + except Exception: + return exchange.fetch_positions() or [] + + try: + unrealized_pnl = resolve_instance_unrealized_pnl( + _okx_positions, + active_pnl_rows, + get_live_position_exchange_metrics, + ) + except Exception: + unrealized_pnl = None + options_unrealized_pnl = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.instance.instance_live_pnl_lib import merge_unrealized_pnl_components + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + # 与持仓卡「净盈亏」同口径(买一回收−权利金),不用交易所标记价 upl + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + # 币本位期权盈亏单位为币,禁止与永续 U 混加(且 merge 只保留 2 位会把 0.0019 抹成 0) + if options_margin_mode != "coin": + unrealized_pnl = merge_unrealized_pnl_components(unrealized_pnl, options_unrealized_pnl) + except Exception: + options_unrealized_pnl = None + _show_perp_funds = show_perp_funds_enabled(exchange_key="okx") or (options_margin_mode == "coin") + return jsonify({ + "funding_usdt": funding_usdt, + "current_capital": current_capital, + "show_perp_funds": _show_perp_funds, + "options_funding_usdc": options_funding_usdc, + "options_funding_usdt": options_funding_usdt, + "options_trading_usdc": options_trading_usdc, + "options_trading_usdt": options_trading_usdt, + "options_funding_eth": options_funding_eth, + "options_trading_eth": options_trading_eth, + "options_trading_btc": options_trading_btc, + "options_margin_mode": options_margin_mode, + "options_underly": options_underly, + "options_index_px": options_index_px, + "total_funds": total_funds_usdt( + funding_usdt if _show_perp_funds else None, + current_capital if _show_perp_funds else None, + options_trading_usdc, + options_funding_usdc, + None, + None, + ), + "available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None, + "unrealized_pnl": unrealized_pnl, + "options_unrealized_pnl": options_unrealized_pnl, + "recommended_capital": recommended_capital, + "active_count": position_limit_count, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "can_trade": can_trade, + "open_block_note": open_block_note, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, + "open_guard_enabled": open_guard_enabled, + "open_guard_blocks_now": open_guard_blocks_now, + "reset_hour": TRADING_DAY_RESET_HOUR, + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "trading_day": trading_day, + "total": header_trade_stats["total"], + "rate": header_trade_stats["rate"], + "profit_loss_ratio": header_trade_stats.get("profit_loss_ratio"), + "risk_status": risk_status, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + now_ms=int(now.timestamp() * 1000), + ), + }) + + +@app.route("/api/settings/open_guard", methods=["POST"]) +@login_required +def api_settings_open_guard(): + data = request.get_json(silent=True) or {} + raw = data.get("enabled") + if raw is None: + raw = request.form.get("enabled") + if raw is None: + return jsonify({"ok": False, "msg": "缺少 enabled 参数"}), 400 + enabled = str(raw).lower() in ("1", "true", "yes", "on") + set_trading_day_reset_open_guard_enabled(enabled) + now = app_now() + conn = get_db() + trading_day = get_trading_day(now) + from lib.strategy.strategy_trade_labels import count_position_limit_active_monitors + + position_limit_count = count_position_limit_active_monitors(conn) + guard_on = get_trading_day_reset_open_guard_enabled(conn) + opens_today = count_opens_for_trading_day(conn, trading_day) + conn.close() + can_trade = can_trade_new_open( + time_allows=trading_day_reset_allows_new_open(now), + active_count=position_limit_count, + max_active_positions=MAX_ACTIVE_POSITIONS, + opens_today=opens_today, + hard_limit=DAILY_OPEN_HARD_LIMIT, + ) + return jsonify( + { + "ok": True, + "open_guard_enabled": guard_on, + "can_trade": can_trade, + "opens_today": opens_today, + "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, + "reset_hour": TRADING_DAY_RESET_HOUR, + } + ) + + +@app.route("/api/price_snapshot") +@login_required +def api_price_snapshot(): + conn = get_db() + key_rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,fib_entry_price,fib_stop_loss,fib_take_profit,fib_limit_order_id,created_at FROM key_monitors" + ).fetchall() + order_rows = conn.execute( + "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,order_amount," + "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status='active'" + ).fetchall() + + try: + ensure_markets_loaded() + except Exception: + pass + + symbol_set = set() + for r in key_rows: + symbol_set.add(r["symbol"]) + for r in order_rows: + symbol_set.add(r["symbol"]) + + prices = {} + for s in symbol_set: + p = get_price(s) + if p is not None: + prices[s] = float(p) + + all_swap_positions = [] + if exchange_private_api_configured(): + try: + ensure_markets_loaded() + # 显式 USDT 本位;不传 symbols 拉全量,再在本地按合约对齐 + all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or [] + except Exception: + try: + all_swap_positions = exchange.fetch_positions() or [] + except Exception: + all_swap_positions = [] + + key_prices = [] + for r in key_rows: + is_fib = is_fib_key_monitor_type(r["monitor_type"]) + is_fb = is_false_breakout_key_monitor_type(r["monitor_type"]) + is_te = is_trigger_entry_key_monitor_type(r["monitor_type"]) + if is_fib or is_fb or is_te: + price = get_symbol_mark_price(r["symbol"]) + else: + price = prices.get(r["symbol"]) + if price is None: + continue + upper_diff, upper_pct = calc_price_diff_pct(price, r["upper"]) + lower_diff, lower_pct = calc_price_diff_pct(price, r["lower"]) + gate = None + gate_summary = "-" + gate_metrics = "" + fib_gate_ok = True + fb_gate_ok = True + te_gate_ok = True + box_gate_ok = True + if is_fib: + direction = (r["direction"] or "long").lower() + inval = fib_invalidate_by_mark(direction, price, r["upper"], r["lower"]) + fib_gate_ok = not inval + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + gate_summary = f"斐波 挂E={entry_txt} {'标记价将失效' if inval else '等待成交'}" + if _sqlite_row_val(r, "fib_limit_order_id"): + gate_metrics = f"限价单:{_sqlite_row_val(r, 'fib_limit_order_id')}" + elif is_fb: + entry = _sqlite_row_val(r, "fib_entry_price") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + prev = false_breakout_gate_preview( + entry_display=entry_txt, + limit_order_id=_sqlite_row_val(r, "fib_limit_order_id"), + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + fb_gate_ok = bool(prev.get("gate_ok")) + elif is_te: + direction = (r["direction"] or "long").lower() + entry = _sqlite_row_val(r, "fib_entry_price") + tp_v = _sqlite_row_val(r, "fib_take_profit") + entry_txt = format_price_for_symbol(r["symbol"], entry) if entry else "-" + tp_txt = format_price_for_symbol(r["symbol"], tp_v) if tp_v else "-" + sl_v = _sqlite_row_val(r, "fib_stop_loss") + inv = ( + trigger_entry_invalidate( + r["monitor_type"], direction, price, float(sl_v or 0), float(tp_v or 0) + ) + if tp_v + else None + ) + prev = trigger_entry_gate_preview( + monitor_type=r["monitor_type"], + entry_display=entry_txt, + take_profit_display=tp_txt, + created_at=_sqlite_row_val(r, "created_at"), + now=app_now(), + tp_invalidated=inv == "tp", + sl_invalidated=inv == "sl", + hours=TRIGGER_ENTRY_VALIDITY_HOURS, + ) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + te_gate_ok = bool(prev.get("gate_ok")) + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_RS_TYPES: + try: + prev = _key_rs_gate_preview(r["symbol"], r["upper"], r["lower"]) + gate_summary = prev.get("summary") or "-" + gate_metrics = prev.get("metrics") or "" + except Exception: + gate_summary = "-" + elif (r["monitor_type"] or "").strip() in KEY_MONITOR_AUTO_TYPES: + direction = (r["direction"] or "long").lower() + if box_breakout_invalidate_by_mark(direction, price, r["upper"], r["lower"]): + edge_label = box_breakout_invalidate_edge_label(direction) + gate_summary = f"反向突破{edge_label}·将撤销" + box_gate_ok = False + else: + try: + gate = _key_hard_checks( + r["symbol"], + direction, + r["upper"], + r["lower"], + r["monitor_type"], + ) + except Exception: + gate = None + if gate: + rank_seg = "ERR" if int(gate.get("rank_total") or 0) <= 0 else f"{gate.get('rank')}/{gate.get('rank_total')}" + gate_summary = ( + f"量:{'Y' if gate.get('vol_ok') else 'N'} " + f"破:{'Y' if gate.get('breakout_ok') else 'N'} " + f"幅:{'Y' if gate.get('amp_ok') else 'N'} " + f"二确:{'Y' if gate.get('confirm_ok') else 'N'} " + f"排:{'Y' if gate.get('rank_ok') else 'N'}({rank_seg})" + ) + if gate.get("breakout_ok"): + try: + vol_now = round(float(gate.get("vol_break") or 0), 4) + vol_avg = round(float(gate.get("avg20") or 0), 4) + amp_pct = round(float(gate.get("amp_pct") or 0), 4) + cfm_close = round(float(gate.get("confirm_close") or 0), 8) + edge = round(float(gate.get("edge_price") or 0), 8) + gate_metrics = ( + f"量值:{vol_now}/{vol_avg} " + f"幅值:{amp_pct}% " + f"二确值:{cfm_close}@{edge}" + ) + except Exception: + gate_metrics = "" + px_disp = format_price_for_symbol(r["symbol"], price) + try: + price_num = float(px_disp) if px_disp != "-" else float(price) + except Exception: + price_num = float(price) + key_prices.append({ + "id": r["id"], + "symbol": r["symbol"], + "price": price_num, + "price_display": px_disp, + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + "gate_summary": gate_summary, + "gate_ok": ( + fib_gate_ok if is_fib + else fb_gate_ok if is_fb + else te_gate_ok if is_te + else box_gate_ok and bool(gate and gate.get("ok")) + ), + "gate_metrics": gate_metrics, + }) + + order_prices = [] + from lib.hub.price_snapshot_lib import resolve_order_snapshot_price + + for r in order_rows: + margin = float(r["margin_capital"] or 0) + leverage = float(r["leverage"] or 0) + entry = float(r["trigger_price"] or 0) + exchange_tpsl = {"sl": None, "tp": None} + ex_sym = resolve_monitor_exchange_symbol(r) + prow = _select_live_position_row(all_swap_positions, ex_sym, r["direction"]) + lev_row = r["leverage"] if "leverage" in r.keys() else None + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=lev_row) if prow else None + price = resolve_order_snapshot_price( + r["symbol"], + prices, + position_row=prow, + order_leverage=lev_row, + parse_position_metrics_fn=parse_ccxt_position_metrics, + get_mark_price_fn=get_symbol_mark_price, + fallback_entry=entry if entry > 0 else None, + ) + pnl = calc_pnl(r["direction"], entry, price, margin, leverage) if entry > 0 and price else 0 + pnl_pct = round((pnl / margin * 100), 4) if margin > 0 else 0 + payload = { + "id": r["id"], + "symbol": r["symbol"], + "float_pnl": round(pnl, 2), + "float_pct": pnl_pct, + "plan_margin": round(margin, 2) if margin else None, + "order_amount": float(r["order_amount"]) if r["order_amount"] not in (None, "") else None, + "exchange_initial_margin": None, + "exchange_notional": None, + "exchange_mark_price": None, + "pnl_source": "plan", + } + if ex_metrics: + if ex_metrics.get("initial_margin") is not None: + payload["exchange_initial_margin"] = ex_metrics["initial_margin"] + if ex_metrics.get("notional") is not None: + payload["exchange_notional"] = ex_metrics["notional"] + if ex_metrics.get("mark_price") is not None: + payload["exchange_mark_price"] = ex_metrics["mark_price"] + if ex_metrics.get("unrealized_pnl") is not None: + payload["float_pnl"] = round(float(ex_metrics["unrealized_pnl"]), 2) + payload["pnl_source"] = "exchange" + denom = ex_metrics.get("initial_margin") or margin + payload["float_pct"] = ( + round((payload["float_pnl"] / float(denom)) * 100, 4) if denom and float(denom) > 0 else pnl_pct + ) + px_for_fmt = None + if price is not None: + try: + px_for_fmt = float(price) + except (TypeError, ValueError): + px_for_fmt = None + 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 + if px_for_fmt is not None: + 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 + else: + payload["price"] = None + payload["price_display"] = "-" + if exchange_private_api_configured(): + try: + exchange_tpsl = fetch_exchange_tpsl_slots( + ex_sym, + r["direction"], + plan_sl=r["stop_loss"], + plan_tp=r["take_profit"], + ) + except Exception: + exchange_tpsl = {"sl": None, "tp": None} + payload["exchange_tpsl"] = exchange_tpsl + avg_entry = None + if ex_metrics and ex_metrics.get("entry_price") is not None: + avg_entry = ex_metrics["entry_price"] + elif prow: + from lib.hub.hub_position_metrics import parse_position_entry_price + + avg_entry = parse_position_entry_price(prow) + apply_order_price_display_fields( + payload, + direction=r["direction"], + entry_price=entry, + initial_stop_loss=r["initial_stop_loss"], + stop_loss=r["stop_loss"], + take_profit=r["take_profit"], + calc_rr_ratio_fn=calc_rr_ratio, + exchange_tpsl=exchange_tpsl, + format_price_fn=format_price_for_symbol, + symbol=r["symbol"], + margin_capital=margin, + leverage=leverage, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contracts=abs(_position_row_effective_contracts(prow)) if prow else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=ex_metrics.get("mark_price") if ex_metrics else price, + avg_entry_price=avg_entry, + funds_decimals=FUNDS_DECIMALS, + ) + apply_time_close_to_payload(payload, r) + apply_force_close_to_payload( + payload, + enabled=FORCE_CLOSE_ENABLED, + bj_hour=FORCE_CLOSE_BJ_HOUR, + ) + payload["opened_at"] = r["opened_at"] if "opened_at" in r.keys() else None + open_ms = r["opened_at_ms"] if "opened_at_ms" in r.keys() else None + payload["opened_at_ms"] = int(open_ms) if open_ms not in (None, "") else None + new_sl, new_tp, changed = order_monitor_tpsl_needs_sync( + r["stop_loss"], r["take_profit"], exchange_tpsl + ) + if changed: + try: + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (new_sl, new_tp, int(r["id"])), + ) + except Exception: + pass + order_prices.append(payload) + + try: + conn.commit() + except Exception: + pass + conn.close() + + from lib.hub.hub_position_metrics import build_position_marks_list + + position_marks = build_position_marks_list( + all_swap_positions, + format_mark_display=lambda sym, px: format_price_for_symbol(sym, px), + ) + + options_unrealized_pnl = None + options_index_px = None + options_margin_mode = None + options_underly = None + if OKX_OPTIONS_ENABLED and exchange_options.apiKey: + try: + from lib.options.options_positions_lib import sum_options_net_pnl_usdc + from lib.options.options_margin_mode_lib import normalize_options_margin_mode + from lib.exchange.okx_options_lib import fetch_index_price + + opt_cfg = app.extensions.get("options_cfg") + if opt_cfg: + options_unrealized_pnl = sum_options_net_pnl_usdc(opt_cfg, exchange_options) + else: + from lib.exchange.okx_options_lib import fetch_options_unrealized_pnl_usdc + + options_unrealized_pnl = fetch_options_unrealized_pnl_usdc(exchange_options) + options_margin_mode = normalize_options_margin_mode() + options_underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH" + options_index_px = fetch_index_price(exchange_options, options_underly) + except Exception: + options_unrealized_pnl = None + + return jsonify({ + "updated_at": app_now_str(), + "key_prices": key_prices, + "order_prices": order_prices, + "position_marks": position_marks, + "positions_raw_count": len(all_swap_positions), + "options_unrealized_pnl": options_unrealized_pnl, + "options_index_px": options_index_px, + "options_margin_mode": options_margin_mode, + "options_underly": options_underly, + **force_close_template_context( + FORCE_CLOSE_ENABLED, + FORCE_CLOSE_BJ_HOUR, + ), + }) + + +@app.route("/api/symbol_liquidity_rank") +@login_required +def api_symbol_liquidity_rank(): + symbol = normalize_symbol_input(request.args.get("symbol")) + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + rank, total = _daily_volume_rank(symbol) + base = journal_coin_from_symbol(symbol) + vol_24h = (LIQUIDITY_RANK_CACHE.get("volumes") or {}).get(base) + if total <= 0: + return jsonify({"ok": False, "msg": "24h成交额排名读取失败"}), 502 + if rank is None: + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": None, + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": False, + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + in_top = bool(rank <= KEY_DAILY_VOLUME_RANK_MAX) + return jsonify( + { + "ok": True, + "symbol": symbol, + "rank": int(rank), + "total": int(total), + "vol_usdt_24h": vol_24h, + "in_top30": in_top, + "in_top": in_top, + "rank_max": KEY_DAILY_VOLUME_RANK_MAX, + } + ) + + +@app.route("/api/order_defaults") +@login_required +def api_order_defaults(): + symbol = normalize_symbol_input(request.args.get("symbol")) + direction = (request.args.get("direction") or "long").strip().lower() + if not symbol: + return jsonify({"ok": False, "msg": "symbol 不能为空"}), 400 + if direction not in ("long", "short"): + direction = "long" + exchange_symbol = normalize_okx_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, + "last_price": round(float(last_price), 8) if last_price is not None else None, + "price": round(float(last_price), 8) if last_price is not None else None, + }) + + +@app.route("/order_focus") +@login_required +def order_focus(): + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + raw_orders = conn.execute("SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC").fetchall() + conn.close() + orders = [enrich_order_item(row_to_dict(r), current_capital) for r in raw_orders] + picked_id = request.args.get("order_id", "").strip() + selected = None + if picked_id.isdigit(): + selected = next((o for o in orders if int(o["id"]) == int(picked_id)), None) + if selected is None and orders: + selected = orders[0] + return render_template( + "order_focus_v2.html", + orders=orders, + selected_order=selected, + default_timeframe=KLINE_TIMEFRAME, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + ) + + +@app.route("/api/order_kline") +@login_required +def api_order_kline(): + order_id_raw = (request.args.get("order_id") or "").strip() + if not order_id_raw.isdigit(): + return jsonify({"ok": False, "msg": "order_id 无效"}), 400 + order_id = int(order_id_raw) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + allowed_tfs = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"} + if timeframe not in allowed_tfs: + timeframe = KLINE_TIMEFRAME + limit = 100 + + now = app_now() + trading_day = get_trading_day(now) + conn = get_db() + session_row = ensure_session(conn, trading_day) + local_current_capital = float(session_row["current_capital"]) + _, trading_capital_live = get_exchange_capitals() + current_capital = round(trading_capital_live, 4) if trading_capital_live is not None else round(local_current_capital, 4) + row = conn.execute("SELECT * FROM order_monitors WHERE id=? AND status='active'", (order_id,)).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "订单不存在或已结束"}), 404 + + order_item = enrich_order_item(row_to_dict(row), current_capital) + exchange_symbol = order_item.get("exchange_symbol") or normalize_okx_symbol(order_item["symbol"]) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + ts = int(bar[0] // 1000) + candles.append({ + "time": ts, + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + from lib.instance.focus_chart_lib import ( + build_order_kline_order_payload, + load_swap_positions_for_order_kline, + metrics_for_order_item, + ) + + current_price = get_price(order_item["symbol"]) + positions = load_swap_positions_for_order_kline( + exchange, + private_configured=exchange_private_api_configured(), + ensure_markets_fn=ensure_markets_loaded, + ) + ex_metrics = metrics_for_order_item( + order_item, + positions, + resolve_ex_sym_fn=resolve_monitor_exchange_symbol, + select_live_fn=_select_live_position_row, + parse_metrics_fn=parse_ccxt_position_metrics, + ) + order_payload = build_order_kline_order_payload( + order_item, + ticker_price=current_price, + format_price_fn=format_price_for_symbol, + calc_pnl_fn=calc_pnl, + calc_rr_ratio_fn=calc_rr_ratio, + ex_metrics=ex_metrics, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "timeframe": timeframe, + "limit": limit, + "order": order_payload, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/key_focus") +@login_required +def key_focus(): + conn = get_db() + key_rows = conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall() + conn.close() + key_list = [row_to_dict(r) for r in key_rows] + + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_query = normalize_symbol_input(request.args.get("symbol")) + selected_key = None + if key_id_raw.isdigit(): + selected_key = next((k for k in key_list if int(k["id"]) == int(key_id_raw)), None) + if selected_key is None and symbol_query: + selected_key = next((k for k in key_list if (k.get("symbol") or "").upper() == symbol_query), None) + if selected_key is None and key_list: + selected_key = key_list[0] + default_symbol = default_symbol_for_policy( + TRADE_POLICY, + symbol_query or ((selected_key or {}).get("symbol")) or "BTC/USDT", + ) + return render_template( + "key_focus_v2.html", + key_list=key_list, + selected_key=selected_key, + default_symbol=default_symbol, + default_timeframe=KLINE_TIMEFRAME, + default_kline_limit=200, + price_refresh_seconds=PRICE_REFRESH_SECONDS, + exchange_display=EXCHANGE_DISPLAY_NAME, + trade_policy=trade_policy_template_context(TRADE_POLICY), + ) + + +@app.route("/api/key_kline") +@login_required +def api_key_kline(): + key_id_raw = (request.args.get("key_id") or "").strip() + symbol_input = normalize_symbol_input(request.args.get("symbol")) + timeframe = (request.args.get("timeframe") or KLINE_TIMEFRAME).strip() + if timeframe not in {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"}: + timeframe = KLINE_TIMEFRAME + limit = normalize_kline_limit(request.args.get("limit"), default=200) + + conn = get_db() + key_row = None + if key_id_raw.isdigit(): + key_row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (int(key_id_raw),)).fetchone() + if key_row is None and symbol_input: + key_row = conn.execute( + "SELECT * FROM key_monitors WHERE upper(symbol)=? ORDER BY id DESC LIMIT 1", + (symbol_input,), + ).fetchone() + if key_row is not None: + symbol = (key_row["symbol"] or "").upper() + else: + symbol = symbol_input + conn.close() + if not symbol: + return jsonify({"ok": False, "msg": "请先输入币种或选择关键位"}), 400 + + exchange_symbol = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=limit) + except Exception as e: + return jsonify({"ok": False, "msg": f"K线加载失败:{friendly_okx_error(e)}"}), 500 + + candles = [] + for bar in ohlcv or []: + if not bar or len(bar) < 6: + continue + candles.append({ + "time": int(bar[0] // 1000), + "open": float(bar[1]), + "high": float(bar[2]), + "low": float(bar[3]), + "close": float(bar[4]), + "volume": float(bar[5]), + }) + + current_price = get_price(symbol) + key_info = None + if key_row is not None: + upper = float(key_row["upper"]) if key_row["upper"] is not None else None + lower = float(key_row["lower"]) if key_row["lower"] is not None else None + upper_diff, upper_pct = calc_price_diff_pct(current_price, upper) if current_price else (None, None) + lower_diff, lower_pct = calc_price_diff_pct(current_price, lower) if current_price else (None, None) + key_info = { + "id": key_row["id"], + "monitor_type": key_row["monitor_type"], + "direction": key_row["direction"] or "long", + "upper": upper, + "lower": lower, + "notification_count": int(key_row["notification_count"] or 0), + "upper_diff": upper_diff, + "upper_pct": upper_pct, + "lower_diff": lower_diff, + "lower_pct": lower_pct, + } + + from lib.instance.focus_chart_lib import enrich_key_kline_response + + price_display, key_info = enrich_key_kline_response( + symbol=symbol, + current_price=current_price, + key_info=key_info, + format_price_fn=format_price_for_symbol, + ) + + from lib.instance.focus_chart_lib import kline_api_price_fields + + price_fields = kline_api_price_fields( + exchange, + exchange_symbol, + candles, + ensure_markets_fn=ensure_markets_loaded, + ) + + return jsonify({ + "ok": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "current_price": round(float(current_price), 8) if current_price is not None else None, + "current_price_display": price_display, + "key_monitor": key_info, + "candles": candles, + "updated_at": app_now_str(), + **price_fields, + }) + + +@app.route("/api/order//cancel_tpsl", methods=["POST"]) +@login_required +def api_order_cancel_tpsl(order_id): + from lib.trade.trade_policy_lib import is_intraday_trading_profile + + if is_intraday_trading_profile(TRADE_POLICY): + return jsonify({"ok": False, "msg": "日内纪律账户禁止撤销交易所止盈止损"}), 403 + data = request.get_json(silent=True) or {} + role = (data.get("role") or "").strip().lower() + if role not in ("sl", "tp"): + return jsonify({"ok": False, "msg": "role 须为 sl 或 tp"}), 400 + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + conn.close() + if not row: + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + ok, reason = ensure_okx_live_ready() + if not ok: + return jsonify({"ok": False, "msg": reason}), 400 + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"]) + slot = slots.get(role) + if not slot: + return jsonify({"ok": False, "msg": f"交易所未找到{'止损' if role == 'sl' else '止盈'}委托"}), 404 + try: + cancel_okx_tpsl_slot(ex_sym, slot) + return jsonify({"ok": True, "msg": "已撤单", "exchange_tpsl": fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])}) + except Exception as e: + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + + +@app.route("/api/order//place_tpsl", methods=["POST"]) +@login_required +def api_order_place_tpsl(order_id): + data = request.get_json(silent=True) or {} + conn = get_db() + row = conn.execute( + "SELECT * FROM order_monitors WHERE id=? AND status='active'", + (order_id,), + ).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "持仓不存在或已结束"}), 404 + symbol = row["symbol"] + direction = row["direction"] + live_price = get_price(symbol) + if live_price is None: + conn.close() + return jsonify({"ok": False, "msg": "获取交易所实时价格失败"}), 400 + try: + sltp_mode = (data.get("sltp_mode") or "price").strip().lower() + stop_loss, take_profit = _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": str(e)}), 400 + planned_rr = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr is None or planned_rr < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr:.4f}" if planned_rr is not None else "无法计算" + return jsonify( + { + "ok": False, + "msg": f"计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1", + } + ), 400 + try: + replace_active_monitor_tpsl_on_exchange(row, stop_loss, take_profit) + except Exception as e: + conn.close() + return jsonify({"ok": False, "msg": friendly_exchange_error(e)}), 400 + conn.execute( + "UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=?", + (stop_loss, take_profit, order_id), + ) + conn.commit() + ex_sym = resolve_monitor_exchange_symbol(row) + slots = fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit) + prow = None + ex_metrics = None + if exchange_private_api_configured(): + try: + rows = exchange.fetch_positions([ex_sym]) or exchange.fetch_positions() or [] + prow = _select_live_position_row(rows, ex_sym, direction) + if prow: + ex_metrics = parse_ccxt_position_metrics(prow, order_leverage=row["leverage"]) + except Exception: + pass + from lib.trade.order_monitor_display_lib import enrich_active_monitor_tpsl_json + + ex_sym = resolve_monitor_exchange_symbol(row) + display_extra = enrich_active_monitor_tpsl_json( + row, + stop_loss, + take_profit, + slots, + position_row=prow, + exchange_notional=ex_metrics.get("notional") if ex_metrics else None, + contract_size=float(get_contract_size(ex_sym)) if ex_sym else 1.0, + mark_price=live_price, + calc_rr_ratio_fn=calc_rr_ratio, + format_price_fn=format_price_for_symbol, + symbol=symbol, + funds_decimals=FUNDS_DECIMALS, + ) + conn.close() + return jsonify( + { + "ok": True, + "msg": "已先撤后挂止盈止损", + "stop_loss": stop_loss, + "take_profit": take_profit, + "planned_rr": planned_rr, + "exchange_tpsl": slots, + **display_extra, + } + ) + +@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("/key_monitor") + ok_sym, sym_msg = check_symbol_policy( + TRADE_POLICY, symbol, normalize_symbol_input + ) + if not ok_sym: + flash(sym_msg) + return redirect("/key_monitor") + mt = (d.get("type") or "").strip() + direction_sel = (d.get("direction") or "").strip().lower() + dup_msg = check_duplicate_submit( + session, submit_scope_add_key(symbol, mt, direction_sel or "watch") + ) + if dup_msg: + flash(dup_msg) + return redirect("/key_monitor") + if mt in KEY_MONITOR_RS_TYPES: + direction_sel = KEY_DIRECTION_WATCH + mt = KEY_MONITOR_RS_TYPE + elif direction_sel not in ("long", "short"): + flash("箱体/收敛突破请选择做多或做空") + return redirect("/key_monitor") + ok_dir, dir_msg = check_direction_policy(TRADE_POLICY, direction_sel) + if not ok_dir: + flash(dir_msg) + return redirect("/key_monitor") + allowed_types = ( + tuple(KEY_MONITOR_AUTO_TYPES) + + tuple(KEY_MONITOR_ALERT_ONLY_TYPES) + + tuple(FIB_KEY_MONITOR_TYPES) + + (FALSE_BREAKOUT_MONITOR_TYPE,) + + tuple(TRIGGER_ENTRY_MONITOR_TYPES) + ) + if mt not in allowed_types: + flash("监控类型无效") + return redirect("/key_monitor") + ok_mt, mt_msg = check_monitor_type_add_allowed( + mt, POSITION_SIZING_MODE, KEY_AUTO_ORDER_ENABLED + ) + if not ok_mt: + flash(mt_msg) + 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() + flash( + f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」." + "请平仓后再试,或使用「关键支撑阻力」(仅提醒)." + ) + return redirect("/key_monitor") + ex_sym_key = normalize_okx_symbol(symbol) + try: + ensure_markets_loaded() + except Exception: + pass + be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + if is_trigger_entry_key_monitor_type(mt): + if direction_sel not in ("long", "short"): + conn.close() + flash("触价请选择做多或做空") + return redirect("/key_monitor") + try: + entry_px = float(d.get("trigger_entry") or 0) + sl_px = float(d.get("trigger_sl") or 0) + tp_px = float(d.get("trigger_tp") or 0) + except (TypeError, ValueError): + entry_px = sl_px = tp_px = 0 + if entry_px <= 0 or sl_px <= 0 or tp_px <= 0: + conn.close() + flash("触价须填写有效的入场价,止损价,止盈价") + return redirect("/key_monitor") + ok_te, err_te = _add_trigger_entry_key_monitor( + conn, + symbol, + direction_sel, + entry_px, + sl_px, + tp_px, + monitor_type=mt, + breakeven_enabled=be_flag, + time_close_enabled=tc_en, + time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_te: + flash(err_te or "触价开仓监控添加失败") + return redirect("/key_monitor") + trigger_hint = ( + "标记价穿越入场价后立即市价开仓" + if is_breakout_trigger_entry_key_monitor_type(mt) + else "标记价回调触达入场价后下一轮询市价开仓" + ) + flash( + f"{mt}已添加({symbol} 日成交量排名 {rank}/{total})" + f"|有效期 {TRIGGER_ENTRY_VALIDITY_HOURS}h" + f"|{trigger_hint}" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + if is_false_breakout_key_monitor_type(mt): + fb_sym = normalize_false_breakout_symbol(symbol) + if not fb_sym: + conn.close() + flash("假突破仅支持 BTC / ETH") + return redirect("/key_monitor") + symbol = fb_sym + if direction_sel not in ("long", "short"): + conn.close() + 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() + flash("请填写关键价位(做空填高点,做多填低点)") + return redirect("/key_monitor") + ex_sym_key = normalize_okx_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() + flash(str(e)) + return redirect("/key_monitor") + ok_fb, err_fb = _add_false_breakout_key_monitor( + conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_fb: + flash(err_fb or "假突破监控添加失败") + return redirect("/key_monitor") + flash( + f"假突破监控已添加,限价单已挂出({symbol})" + f"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + uh = round_price_to_exchange(ex_sym_key, float(d["upper"])) + lw = round_price_to_exchange(ex_sym_key, float(d["lower"])) + upper_px = float(uh) if uh is not None else float(d["upper"]) + lower_px = float(lw) if lw is not None else float(d["lower"]) + if upper_px <= lower_px: + conn.close() + flash("上沿必须大于下沿") + return redirect("/key_monitor") + if is_fib_key_monitor_type(mt): + ok_fib, err_fib = _add_fib_key_monitor( + conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, + time_close_enabled=tc_en, time_close_hours=tc_h, + ) + conn.commit() + conn.close() + if not ok_fib: + flash(err_fib or "斐波监控添加失败") + return redirect("/key_monitor") + flash( + f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total})" + f"|移动保本:{'开' if be_flag else '关'}" + + (f"|{time_close_label(tc_h)}" if tc_en else "") + ) + return redirect("/key_monitor") + sl_tp_mode = "standard" + manual_tp = None + if mt in KEY_MONITOR_AUTO_TYPES: + sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode")) + if sl_tp_mode == "trend_manual": + try: + manual_tp = float(d.get("manual_take_profit") or 0) + except (TypeError, ValueError): + manual_tp = 0 + if manual_tp <= 0: + conn.close() + flash("趋势单方案须填写有效止盈价") + return redirect("/key_monitor") + if direction_sel == "long" and manual_tp <= upper_px: + conn.close() + flash("做多趋势单:止盈价应高于上沿(阻力)") + return redirect("/key_monitor") + if direction_sel == "short" and manual_tp >= lower_px: + conn.close() + flash("做空趋势单:止盈价应低于下沿(支撑)") + return redirect("/key_monitor") + mtpx = round_price_to_exchange(ex_sym_key, manual_tp) + if mtpx is not None: + manual_tp = float(mtpx) + if mt in KEY_MONITOR_RS_TYPES: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "max_notify,notify_interval_min,time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, + mt, + direction_sel, + upper_px, + lower_px, + sl_tp_mode, + manual_tp, + be_flag, + KEY_ALERT_MAX_TIMES, + KEY_ALERT_INTERVAL_MINUTES, + tc_en, + tc_h, + ), + ) + else: + conn.execute( + "INSERT INTO key_monitors " + "(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled," + "time_close_enabled,time_close_hours) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag, tc_en, tc_h), + ) + conn.commit() + conn.close() + extra = "" + if mt in KEY_MONITOR_AUTO_TYPES: + extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}" + if tc_en: + extra += f"|{time_close_label(tc_h)}" + 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 + 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 and mt in KEY_MONITOR_AUTO_TYPES: + flash( + "⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位." + ) + return redirect("/key_monitor") + +@app.route("/add_order", methods=["POST"]) +@login_required +def add_order(): + d = request.form + now = app_now() + conn = get_db() + direction = d.get("direction", "long") + symbol = normalize_symbol_input(d.get("symbol")) + if not symbol: + conn.close() + flash("symbol 不能为空") + return redirect("/trade") + ok_pol, pol_msg = validate_trade_policy_open(symbol, direction) + if not ok_pol: + conn.close() + flash(f"账户限制:{pol_msg}") + return redirect("/trade") + dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction)) + if dup_msg: + conn.close() + flash(dup_msg) + return redirect("/trade") + ok, reason = precheck_risk(conn, symbol, direction) + if not ok: + conn.close() + flash(f"风控拒绝下单:{reason}") + return redirect("/trade") + ok_live, reason_live = ensure_okx_live_ready() + if not ok_live: + conn.close() + flash(f"风控拒绝下单:{reason_live}") + return redirect("/trade") + exchange_symbol = normalize_okx_symbol(symbol) + trading_day = get_trading_day(now) + opens_today_before = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + session_row = ensure_session(conn, trading_day) + _, trading_capital_live = get_exchange_capitals(force=True) + capital_base = float(trading_capital_live) if trading_capital_live is not None else float(session_row["current_capital"]) + trade_style, entry_model, style_err = parse_manual_order_style_fields( + TRADE_POLICY, d, default_trade_style=DEFAULT_TRADE_STYLE or "trend" + ) + if style_err: + conn.close() + flash(style_err) + return redirect("/trade") + available_usdt = get_available_trading_usdt() + live_price = get_price(symbol) + if live_price is None: + conn.close() + flash("获取交易所实时价格失败,请稍后重试") + return redirect("/trade") + sltp_mode = normalize_open_sltp_mode(d.get("sltp_mode")) + try: + stop_loss, take_profit = resolve_open_sltp_prices( + direction, live_price, sltp_mode, d + ) + except ValueError as e: + conn.close() + flash(str(e) or "止盈止损参数错误") + return redirect("/trade") + if stop_loss <= 0 or take_profit <= 0: + conn.close() + flash("价格参数必须大于0") + return redirect("/trade") + planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR: + conn.close() + rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1") + return redirect("/trade") + risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) + if risk_fraction is None: + conn.close() + flash("止损方向不合法:请检查入场方向与止损价格关系") + return redirect("/trade") + risk_percent = max(0.01, float(RISK_PERCENT)) + risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: + conn.close() + flash(flat_msg) + return redirect("/trade") + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=FUNDS_DECIMALS, + ) + if sizing_err: + conn.close() + flash(sizing_err) + return redirect("/trade") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/trade") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/trade") + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/trade") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U") + return redirect("/trade") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + try: + amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) + contract_size = get_contract_size(exchange_symbol) + base_amount = round(float(amount) * contract_size, 8) + order_resp = place_exchange_order(exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit) + open_order_id = order_resp.get("id", "") + tpsl_attached = bool(order_resp.get("tpsl_attached")) + trigger_price = resolve_order_entry_price(order_resp, exchange_symbol, quote_price) + except Exception as e: + conn.close() + flash(friendly_okx_error(e, available_usdt=available_usdt)) + return redirect("/trade") + + make_order_chart = d.get("order_chart", "").lower() in ("1", "true", "on", "yes") + opened_at_bj = app_now_str() + opened_at_ms = _to_ms_with_fallback(None, opened_at_bj) + planned_rr = calc_rr_ratio(direction, trigger_price, stop_loss, take_profit) + breakeven_rr_trigger = float(BREAKEVEN_RR_TRIGGER) + breakeven_offset_pct = float(BREAKEVEN_OFFSET_PCT) + breakeven_step_r = float(BREAKEVEN_STEP_R) if float(BREAKEVEN_STEP_R) > 0 else 1.0 + risk_amount_final = calc_risk_amount_from_plan(direction, trigger_price, stop_loss, margin_capital, leverage) or risk_amount + risk_percent_db = risk_percent_for_storage(POSITION_SIZING_MODE, risk_percent) + risk_display = format_risk_display_text( + POSITION_SIZING_MODE, risk_percent, risk_amount_final, decimals=FUNDS_DECIMALS + ) + if direction == "short": + breakeven_price = round(float(trigger_price) * (1 - breakeven_offset_pct / 100.0), 8) + else: + breakeven_price = round(float(trigger_price) * (1 + breakeven_offset_pct / 100.0), 8) + breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0 + tc_en = parse_time_close_enabled_form(d.get("time_close_enabled")) + tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None + if tc_en and not tc_h: + tc_en = 0 + tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms) + conn.execute( + "INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, entry_model, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ( + symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit, + margin_capital, leverage, trade_style, entry_model, 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, "下单监控", + tc_en, tc_h, tc_at, + ) + ) + conn.commit() + new_order_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + opens_today_after = conn.execute( + "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", + (trading_day,), + ).fetchone()[0] + conn.close() + + chart_name = None + chart_url = None + if make_order_chart and ORDER_CHART_ENABLED: + try: + title_prefix = f"{symbol} {direction} #{new_order_id}" + chart_name = generate_order_open_chart( + exchange_symbol, + title_prefix, + opened_at_ms=opened_at_ms, + entry_price=trigger_price, + ) + if chart_name: + chart_url = f"/static/images/order_charts/{chart_name}" + except Exception: + chart_name = None + chart_url = None + + if chart_name: + try: + journal_id = f"order_{new_order_id}" + coin = journal_coin_from_symbol(symbol) + open_local = (opened_at_bj or "")[:16].replace(" ", "T") + if len(open_local) < 16: + open_local = app_now().strftime("%Y-%m-%dT%H:%M") + close_local = open_local + hold_duration = calc_duration_text(open_local, close_local) + note = ( + f"auto_from_open_order id={new_order_id} oid={open_order_id} " + f"chart={chart_name} tfs={','.join(ORDER_CHART_TFS)} limit={ORDER_CHART_LIMIT}" + ) + conn = get_db() + conn.execute( + """INSERT OR REPLACE INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + journal_id, + open_local, + close_local, + hold_duration, + coin, + "multi", + "0", + "auto:open", + "待平仓", + "", + "", + "否", + "", + "", + "", + None, + None, + None, + "", + "否", + "否", + note, + chart_name, + ), + ) + conn.commit() + conn.close() + except Exception: + try: + conn.close() + except Exception: + pass + + _, trading_capital_after = get_exchange_capitals(force=True) + account_base_display = ( + round(float(trading_capital_after), 2) + if trading_capital_after is not None + else round(float(capital_base), 2) + ) + dir_text = "多头(long)" if direction == "long" else "空头(short)" + order_state_text = ( + "已在交易所挂条件委托(止盈,止损各一张触发单)" + if tpsl_attached + else "条件委托未挂上(已拦截)" + ) + rr_show = planned_rr if planned_rr is not None else "-" + try: + rr_show_fmt = f"{float(planned_rr):.2f}" if planned_rr is not None else None + except (TypeError, ValueError): + rr_show_fmt = None + rr_line = f"RR {rr_show_fmt} : 1" if rr_show_fmt is not None else f"RR {rr_show} : 1" + ep_wx = format_price_for_symbol(symbol, trigger_price) + sl_wx = format_wechat_scalar_2dp(stop_loss) + tp_wx = format_price_for_symbol(symbol, take_profit) + be_wx = format_price_for_symbol(symbol, breakeven_price) + style_zh = "Swing 波段" if trade_style == "swing" else "Trend 趋势" + wx_lines = [ + f"📈 {symbol} 开仓成功", + f"💼 交易类型:{dir_text}", + "🧾 订单基础信息", + f"🔖 交易所订单 ID:{open_order_id}", + f"📈 交易风格:{style_zh}", + f"⚠️ 单笔风控风险:{risk_display}", + "📊 仓位配置详情", + f"账户基数:{account_base_display} USDT", + f"合约杠杆:{leverage} 倍", + f"名义仓位:{format_wechat_scalar_2dp(notional_value)} USDT", + f"仓位占比:{position_ratio}%", + f"合约张数:{format_wechat_scalar_2dp(amount)} 张", + f"折算标的:{base_amount} {journal_coin_from_symbol(symbol)}", + "🎯 价位 & 盈亏比", + f"开仓成交价:{ep_wx}", + f"止损价位:{sl_wx}", + f"止盈价位:{tp_wx}", + f"计划盈亏比:{rr_line}", + f"移动保本位:{breakeven_rr_trigger}R → {be_wx}", + "📌 状态统计", + f"✅ 条件委托:{order_state_text}", + format_daily_open_counter_line( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + wx_lines.append(f"多周期K线图:{chart_url}") + send_wechat_msg("\n".join(wx_lines)) + + flash_lines = [ + f"实盘开单成功:风格 {trade_style};风险 {risk_display};基数 {round(float(margin_capital), 2)}U,杠杆 {leverage}x,名义仓位 {format_wechat_scalar_2dp(notional_value)}U,仓位占比 {position_ratio}%,合约张数 {format_wechat_scalar_2dp(amount)}(折算标的 {base_amount})," + f"计划RR {format_wechat_scalar_2dp(planned_rr) if planned_rr is not None else '-'};已在交易所挂条件止盈/止损委托(非仓位绑定型)", + format_daily_open_summary_short( + opens_today_after, DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT + ), + ] + if chart_url: + flash_lines.append(f"已生成多周期K线图:{chart_url}") + flash(" ".join(flash_lines)) + + if should_send_daily_open_alert( + opens_today_before, opens_today_after, DAILY_OPEN_ALERT_THRESHOLD + ): + advice = ai_short_advice( + build_daily_open_alert_prompt( + trading_day, + opens_today_after, + DAILY_OPEN_ALERT_THRESHOLD, + hard_limit=DAILY_OPEN_HARD_LIMIT, + detail_line=f"最新一笔:{symbol} {direction},杠杆{leverage}x,基数{margin_capital}U.", + ) + ) + if advice: + send_wechat_msg(f"【AI提醒】今日开仓次数已达 {opens_today_after}\n{advice[:800]}") + flash(f"【AI提醒】今日开仓次数已达 {opens_today_after}:{advice[:300]}") + return redirect("/trade") + +@app.route("/delete_key_monitor/", methods=["POST"]) +@login_required +def delete_key_monitor(kid): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (kid,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "error": "not_found"}) + if is_limit_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + cur = conn.execute("DELETE FROM key_monitors WHERE id=?", (kid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/delete_key_history/", methods=["POST"]) +@login_required +def delete_key_history(hid): + conn = get_db() + cur = conn.execute("DELETE FROM key_monitor_history WHERE id=?", (hid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0}) + + +@app.route("/del_key/") +@login_required +def del_key(id): + conn = get_db() + row = conn.execute("SELECT * FROM key_monitors WHERE id=?", (id,)).fetchone() + if row: + if is_limit_key_monitor_type((row["monitor_type"] or "").strip()): + _cancel_fib_monitor_limit(row) + insert_key_monitor_history(conn, row, int(row["notification_count"] or 0), None, "manual") + conn.execute("DELETE FROM key_monitors WHERE id=?", (id,)) + conn.commit() + conn.close() + resp = redirect("/") + resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + return resp + + +def _csv_response(filename, rows, header): + buf = StringIO() + w = csv.writer(buf) + w.writerow(header) + for row in rows: + w.writerow(row) + out = "\ufeff" + buf.getvalue() + return Response( + out, + mimetype="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +def _md_response(filename, content): + return Response( + content, + mimetype="text/markdown; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "no-store", + }, + ) + + +@app.route("/export/trade_records") +@login_required +def export_trade_records(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,key_signal_type,direction,trigger_price,stop_loss,initial_stop_loss,take_profit," + "margin_capital,leverage,pnl_amount,hold_seconds,hold_minutes,planned_rr,actual_rr,risk_amount," + "opened_at,closed_at,result,miss_reason,entry_reason,reviewed_entry_reason," + "exchange_realized_pnl,exchange_opened_at,exchange_closed_at,created_at " + f"FROM trade_records WHERE {sql_list_time_field('closed_at', 'created_at', 'opened_at')} >= ? " + f"AND {sql_list_time_field('closed_at', 'created_at', 'opened_at')} <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", "symbol", "monitor_type", "key_signal_type", "direction", "trigger_price", + "stop_loss_open_snapshot", "initial_stop_loss", "take_profit", "margin_capital", "leverage", + "pnl_amount", "hold_seconds", "hold_minutes", "planned_rr", "actual_rr", "risk_amount", + "opened_at", "closed_at", "result", "miss_reason", "entry_reason", "reviewed_entry_reason", + "exchange_realized_pnl", "exchange_opened_at", "exchange_closed_at", "created_at", "开仓类型", + ] + data = [] + for r in rows: + er0 = (r["entry_reason"] or "").strip() if r["entry_reason"] else "" + er1 = (r["reviewed_entry_reason"] or "").strip() if r["reviewed_entry_reason"] else "" + kst = (r["key_signal_type"] or "").strip() if "key_signal_type" in r.keys() else "" + eff = format_entry_type_display( + er1 or er0 or entry_reason_from_key_signal(kst) or "", + entry_model=r["entry_model"] if "entry_model" in r.keys() else None, + trade_style=r["trade_style"] if "trade_style" in r.keys() else None, + ) + snap = r["initial_stop_loss"] if r["initial_stop_loss"] not in (None, "") else r["stop_loss"] + data.append(( + r["id"], r["symbol"], r["monitor_type"], kst, r["direction"], r["trigger_price"], + snap, r["initial_stop_loss"], r["take_profit"], r["margin_capital"], r["leverage"], + r["pnl_amount"], r["hold_seconds"], r["hold_minutes"], r["planned_rr"], r["actual_rr"], r["risk_amount"], + r["opened_at"], r["closed_at"], r["result"], r["miss_reason"], r["entry_reason"], r["reviewed_entry_reason"], + r["exchange_realized_pnl"] if "exchange_realized_pnl" in r.keys() else None, + r["exchange_opened_at"] if "exchange_opened_at" in r.keys() else None, + r["exchange_closed_at"] if "exchange_closed_at" in r.keys() else None, + r["created_at"], eff, + )) + day = app_now().strftime("%Y%m%d") + return _csv_response(f"trade_records_v3_{day}.csv", data, head) + + +@app.route("/export/journal_entries") +@login_required +def export_journal_entries(): + conn = get_db() + rows = conn.execute( + "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," + "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," + "post_breakeven_stare,new_trade_while_occupied,note,image,images_json,created_at FROM journal_entries ORDER BY created_at ASC" + ).fetchall() + conn.close() + head = [ + "id", + "open_datetime", + "close_datetime", + "hold_duration", + "coin", + "tf", + "pnl", + "entry_reason", + "exit_reason", + "expect_rr", + "real_rr", + "early_exit", + "early_exit_trigger", + "early_exit_note", + "early_exit_reason", + "mood_issues", + "post_breakeven_stare", + "new_trade_while_occupied", + "note", + "image", + "images_json", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"journal_entries_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitors") +@login_required +def export_key_monitors(): + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_notified_at,max_notify," + "notify_interval_min,breakout_limit_pct,created_at FROM key_monitors ORDER BY id ASC" + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_notified_at", + "max_notify", + "notify_interval_min", + "breakout_limit_pct", + "created_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitors_active_v1_{day}.csv", data, head) + + +@app.route("/export/key_monitor_history") +@login_required +def export_key_monitor_history(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + rows = conn.execute( + "SELECT id,symbol,monitor_type,direction,upper,lower,notification_count,last_alert_message,close_reason,closed_at " + "FROM key_monitor_history WHERE closed_at >= ? AND closed_at <= ? ORDER BY id ASC", + (start_bj, end_bj), + ).fetchall() + conn.close() + head = [ + "id", + "symbol", + "monitor_type", + "direction", + "upper", + "lower", + "notification_count", + "last_alert_message", + "close_reason", + "closed_at", + ] + data = [tuple(r[h] for h in head) for r in rows] + day = app_now().strftime("%Y%m%d") + return _csv_response(f"key_monitor_history_v1_{day}.csv", data, head) + +@app.route("/del_order/") +@login_required +def del_order(id): + conn = get_db() + row = conn.execute("SELECT * FROM order_monitors WHERE id=?", (id,)).fetchone() + if not row: + conn.close() + flash("订单不存在") + return redirect("/") + if row["status"] == "active": + try: + p = get_price(row["symbol"]) or float(row["trigger_price"]) + opened_at = get_opened_at_value(row) + closed_at = app_now_str() + hold_seconds = calc_hold_seconds(opened_at, app_now()) + pnl_amount = calc_pnl( + row["direction"], + row["trigger_price"], + p, + row["margin_capital"] or DAILY_START_CAPITAL, + row["leverage"] or infer_leverage(row["symbol"]) + ) + close_resp = close_exchange_order(row) + close_order_id = close_resp.get("id", "") + session_date = row["session_date"] or get_trading_day() + session_capital = update_session_capital(conn, session_date, pnl_amount) + 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"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result="手动平仓", + miss_reason=handoff_trade_miss_reason("用户手动删除订单触发平仓", row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + conn.commit() + conn.close() + send_wechat_msg( + build_wechat_close_message( + symbol=row["symbol"], + direction=row["direction"], + result="手动平仓", + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trigger_price=row["trigger_price"], + current_price=p, + stop_loss=row["stop_loss"], + take_profit=row["take_profit"], + close_order_id=close_order_id or "-", + extra_note="用户在页面手动平仓", + session_capital_fallback=session_capital, + ) + ) + flash("已按实盘流程手动平仓") + return redirect("/") + except Exception as e: + if is_no_position_error(str(e)): + opened_at = get_opened_at_value(row) + opened_at_ms = _to_ms_with_fallback(row["opened_at_ms"] if "opened_at_ms" in row.keys() else None, opened_at) + result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(row, opened_at, opened_at_ms=opened_at_ms) + miss_reason = f"手动删除时无持仓:{miss_reason}" + closed_at_dt = parse_dt_for_trading_day(closed_at) or app_now() + hold_seconds = calc_hold_seconds(opened_at, closed_at_dt) + session_date = row["session_date"] or get_trading_day(closed_at_dt) + update_session_capital(conn, session_date, pnl_amount) + 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"], + leverage=row["leverage"], + pnl_amount=pnl_amount, + hold_seconds=hold_seconds, + trade_style=row["trade_style"], + entry_model=(row["entry_model"] if "entry_model" in row.keys() else None), + risk_amount=row["risk_amount"], + planned_rr=calc_rr_ratio(row["direction"], row["trigger_price"], row["initial_stop_loss"] or row["stop_loss"], row["take_profit"]), + actual_rr=calc_actual_rr(pnl_amount, row["risk_amount"]), + result=result, + miss_reason=handoff_trade_miss_reason(miss_reason, row), + opened_at=opened_at, + closed_at=closed_at, + ) + from lib.trade.account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(None, closed_at), + trading_day=session_date, + now=app_now(), + ) + conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,)) + try: + _rcfg = app.extensions.get("strategy_roll_cfg") + if isinstance(_rcfg, dict): + from lib.strategy.strategy_register import roll_sync_after_external_close + + roll_sync_after_external_close(_rcfg, conn, row["symbol"], row["direction"]) + except Exception: + pass + conn.commit() + conn.close() + flash("该仓位在交易所已不存在,已按成交记录同步结束并记账") + return redirect("/") + conn.close() + flash(f"手动平仓失败:{str(e)}") + return redirect("/") + conn.execute("DELETE FROM order_monitors WHERE id=?",(id,)) + conn.commit() + conn.close() + return redirect("/") + + +@app.route("/add_journal", methods=["POST"]) +@login_required +def add_journal(): + from lib.instance.journal_chart_async_lib import journal_ajax_or_flash_error + + d = request.form + order_type_norm = normalize_journal_order_type(d.get("order_type")) + if not order_type_norm: + return journal_ajax_or_flash_error(request, "请选择下单类型", redirect_fn=_redirect_records) + direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint")) + if not direction_norm: + return journal_ajax_or_flash_error(request, "请选择方向", redirect_fn=_redirect_records) + entry_reason_norm = normalize_journal_entry_reason( + d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False + ) + if not entry_reason_norm: + return journal_ajax_or_flash_error(request, "请选择开仓类型", redirect_fn=_redirect_records) + early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger")) + early_exit_note = str(d.get("early_exit_note") or "").strip() + if not early_exit_trigger: + return journal_ajax_or_flash_error(request, "请选择离场触发", redirect_fn=_redirect_records) + if early_exit_trigger == "手动平仓" and not early_exit_note: + return journal_ajax_or_flash_error( + request, "手工平仓必须填写补充说明", redirect_fn=_redirect_records + ) + if early_exit_trigger != "手动平仓": + early_exit_note = "" + # 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」 + early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" + early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) + exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) + + mood_issues = ",".join(request.form.getlist("mood_issues")) + hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) + real_rr_text = (d.get("real_rr") or "").strip() + try: + risk_amount_hint = float(d.get("risk_amount_hint") or 0) + pnl_hint = float(d.get("pnl") or 0) + # 口径统一:实际RR = 实际盈亏 / 以损定仓对应的初始风险金额 + if risk_amount_hint > 0: + real_rr_text = f"{(pnl_hint / risk_amount_hint):.2f}" + except Exception: + pass + + want_exchange_chart = ( + not has_manual_uploads + and ORDER_CHART_ENABLED + and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + ) + chart_job = None + if want_exchange_chart: + coin = (d.get("coin") or "").strip().upper() + symbol_guess = normalize_symbol_input(coin) or coin + exchange_symbol = normalize_okx_symbol(symbol_guess) + journal_tfs = parse_journal_chart_timeframes( + d.get("journal_chart_tf1"), + d.get("journal_chart_tf2"), + ORDER_CHART_TFS[:2] if ORDER_CHART_TFS else None, + ) + journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT) + chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor")) + chart_job = { + "exchange_symbol": exchange_symbol, + "title_prefix": f"{symbol_guess} journal {entry_id[:8]}", + "journal_tfs": journal_tfs, + "journal_limit": journal_limit, + "marker_payload": { + "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")), + "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")), + "entry_price": d.get("entry_price_hint"), + "exit_price": d.get("exit_price_hint"), + "stop_loss_price": d.get("stop_loss_hint"), + "chart_anchor": chart_anchor, + "now_ts_ms": int(app_now().timestamp() * 1000), + }, + } + + conn = get_db() + conn.execute( + """INSERT INTO journal_entries + (id, open_datetime, close_datetime, hold_duration, coin, tf, direction, pnl, order_type, entry_reason, exit_reason, + expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, + mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, + new_trade_while_occupied, note, image, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + entry_id, + normalize_bj_datetime_storage(d.get("open_datetime")), + normalize_bj_datetime_storage(d.get("close_datetime")), + hold_duration, + d.get("coin"), + d.get("tf"), + direction_norm, + d.get("pnl"), order_type_norm, entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, + early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, + None, None, None, mood_issues, + d.get("post_breakeven_stare"), None, d.get("note"), image_filename, + images_json_str, + ) + ) + from lib.trade.account_risk_lib import on_journal_saved + + on_journal_saved( + conn, + early_exit_trigger=early_exit_trigger, + early_exit_note=early_exit_note, + mood_issues_raw=mood_issues, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + if chart_job: + from lib.instance.journal_chart_async_lib import schedule_journal_exchange_chart + + schedule_journal_exchange_chart( + entry_id=entry_id, + exchange_symbol=chart_job["exchange_symbol"], + title_prefix=chart_job["title_prefix"], + journal_tfs=chart_job["journal_tfs"], + journal_limit=chart_job["journal_limit"], + marker_payload=chart_job["marker_payload"], + upload_folder=app.config["UPLOAD_FOLDER"], + generate_chart_fn=generate_multi_timeframe_chart_png, + get_db_fn=get_db, + ) + msg = ( + "交易复盘记录已保存.K 线图后台生成中" + f"({'/'.join(chart_job['journal_tfs'])} 各{chart_job['journal_limit']}根),稍后刷新列表可见." + ) + else: + msg = "交易复盘记录已保存" + from lib.instance.journal_chart_async_lib import request_wants_journal_ajax + + if request_wants_journal_ajax(request): + return jsonify( + { + "ok": True, + "id": entry_id, + "msg": msg, + "chart_pending": bool(chart_job), + } + ) + flash(msg) + return _redirect_records() + + +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + +from lib.instance.records_api_register import register_trade_records_api + +register_trade_records_api( + app, + login_required=login_required, + get_db=get_db, + list_window_from_request=_list_window_from_request, + utc_window_to_bj_sql_strings=utc_window_to_bj_sql_strings, + sql_list_time_field=sql_list_time_field, + to_effective_trade_dict=to_effective_trade_dict, + filter_trade_records_excluding_miss=filter_trade_records_excluding_miss, + app_tz=APP_TZ, + format_price_fn=format_price_for_symbol, + sync_exchange_pnl_fn=lambda conn: sync_trade_records_from_exchange(conn, force=False), +) + + +def _dashboard_fetch_options_positions(): + if not OKX_OPTIONS_ENABLED: + return [] + cfg = app.extensions.get("options_cfg") + if not isinstance(cfg, dict) or not cfg.get("enabled"): + return [] + try: + from lib.options.options_dashboard_lib import fetch_light_option_positions_for_dashboard + + return fetch_light_option_positions_for_dashboard(cfg) + except Exception: + return [] + + +def _dashboard_enrich_orders(items): + from lib.instance.instance_dashboard_lib import enrich_order_items_with_marks + + return enrich_order_items_with_marks( + items, get_price=get_price, get_contract_size=get_contract_size + ) + + +from lib.hedge_plan.okx_trade_mode_lib import hedge_module_enabled +from lib.instance.instance_dashboard_register import register_instance_dashboard_routes + +register_instance_dashboard_routes( + app, + login_required=login_required, + get_db=get_db, + fetch_options_positions=_dashboard_fetch_options_positions, + enrich_orders=_dashboard_enrich_orders, + hedge_enabled=hedge_module_enabled, +) + +from lib.account_ledger.account_ledger_register import install_account_ledger + +install_account_ledger(app, _REPO_ROOT, app_module=sys.modules[__name__], exchange_key="okx") + + +@app.route("/api/journals") +@login_required +def api_journals(): + win = _list_window_from_request() + start_bj, end_bj = utc_window_to_bj_sql_strings(win["start_utc"], win["end_utc"], APP_TZ) + conn = get_db() + 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", + (start_bj, end_bj), + ).fetchall() + conn.close() + result = [] + for r in rows: + item = enrich_journal_api_item(row_to_dict(r)) + item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] + result.append(item) + return jsonify(result) + + +@app.route("/delete_journal/", methods=["POST"]) +@login_required +def delete_journal(jid): + conn = get_db() + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass + conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/api/reviews") +@login_required +def api_reviews(): + win = _list_window_from_request() + start_sql, end_sql = utc_window_to_utc_sql_strings(win["start_utc"], win["end_utc"]) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT 200", + (start_sql, end_sql), + ).fetchall() + conn.close() + return jsonify([row_to_dict(r) for r in rows]) + + +_REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR)) +_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js") +_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js") +_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js") +_OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") +_OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") +_OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") +_HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.js") + + +@app.route("/static/ai_review_render.js") +def static_ai_review_render_js(): + if not os.path.isfile(_AI_REVIEW_RENDER_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/form_submit_guard.js") +def static_form_submit_guard_js(): + if not os.path.isfile(_FORM_SUBMIT_GUARD_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/manual_order_rr_preview.js") +def static_manual_order_rr_preview_js(): + if not os.path.isfile(_MANUAL_ORDER_RR_PREVIEW_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_panel.js") +def static_options_panel_js(): + if not os.path.isfile(_OPTIONS_PANEL_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_expiry_countdown.js") +def static_options_expiry_countdown_js(): + if not os.path.isfile(_OPTIONS_EXPIRY_COUNTDOWN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_EXPIRY_COUNTDOWN_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/options_settings.js") +def static_options_settings_js(): + if not os.path.isfile(_OPTIONS_SETTINGS_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_OPTIONS_SETTINGS_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/static/hedge_plan.js") +def static_hedge_plan_js(): + if not os.path.isfile(_HEDGE_PLAN_JS): + return Response("not found", status=404, mimetype="text/plain; charset=utf-8") + return send_file(_HEDGE_PLAN_JS, mimetype="application/javascript; charset=utf-8") + + +@app.route("/export/review_md/") +@login_required +def export_review_md(rid): + conn = get_db() + row = conn.execute("SELECT * FROM ai_reviews WHERE id=?", (rid,)).fetchone() + conn.close() + if not row: + return Response("review not found", status=404, mimetype="text/plain; charset=utf-8") + + review_type = "日复盘" if row["review_type"] == "daily" else "周复盘" + target_date = row["target_date"] or "-" + created_at = row["created_at"] or app_now_str() + content = (row["content"] or "").strip() + if not content: + content = "(无内容)" + + md = ( + f"# {review_type}报告\n\n" + f"- 目标日期: {target_date}\n" + f"- 生成时间: {created_at}\n" + f"- 报告ID: {row['id']}\n\n" + f"---\n\n" + f"{content}\n" + ) + + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + safe_type = "daily" if row["review_type"] == "daily" else "weekly" + filename = f"ai_review_{safe_type}_{safe_target}_{row['id'][:8]}.md" + return _md_response(filename, md) + + +@app.route("/export/reviews_md_bundle") +@login_required +def export_reviews_md_bundle(): + review_type = (request.args.get("review_type") or "").strip().lower() + target_date = (request.args.get("target_date") or "").strip() + if review_type not in ("daily", "weekly"): + return Response("invalid review_type", status=400, mimetype="text/plain; charset=utf-8") + if not target_date: + return Response("target_date required", status=400, mimetype="text/plain; charset=utf-8") + + conn = get_db() + rows = conn.execute( + "SELECT * FROM ai_reviews WHERE review_type=? AND target_date=? ORDER BY created_at ASC, id ASC", + (review_type, target_date), + ).fetchall() + conn.close() + if not rows: + return Response("no reviews found", status=404, mimetype="text/plain; charset=utf-8") + + title = "日复盘" if review_type == "daily" else "周复盘" + lines = [ + f"# {title}汇总报告", + "", + f"- 目标日期: {target_date}", + f"- 条目数量: {len(rows)}", + f"- 导出时间: {app_now_str()}", + "", + "---", + "", + ] + for idx, row in enumerate(rows, 1): + created_at = row["created_at"] or "-" + content = (row["content"] or "").strip() or "(无内容)" + lines.extend( + [ + f"## 第{idx}条", + "", + f"- 报告ID: {row['id']}", + f"- 生成时间: {created_at}", + "", + content, + "", + "---", + "", + ] + ) + md = "\n".join(lines) + safe_target = re.sub(r"[^0-9A-Za-z_-]+", "-", str(target_date)).strip("-") or "unknown-date" + filename = f"ai_reviews_{review_type}_bundle_{safe_target}.md" + return _md_response(filename, md) + + +@app.route("/delete_review/", methods=["POST"]) +@login_required +def delete_review(rid): + conn = get_db() + conn.execute("DELETE FROM ai_reviews WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}) + + +@app.route("/delete_trade_record/", methods=["POST"]) +@login_required +def delete_trade_record(rid): + conn = get_db() + cur = conn.execute("DELETE FROM trade_records WHERE id=?", (rid,)) + conn.commit() + conn.close() + return jsonify({"ok": cur.rowcount > 0, "deleted": cur.rowcount}) + + +@app.route("/api/trade_record_review_update", methods=["POST"]) +@login_required +def api_trade_record_review_update(): + payload = request.get_json(silent=True) or {} + rec_id = payload.get("id") + try: + rec_id = int(rec_id) + except Exception: + return jsonify({"ok": False, "msg": "记录ID无效"}), 400 + + reviewed_opened_at = str(payload.get("reviewed_opened_at") or "").strip() + reviewed_closed_at = str(payload.get("reviewed_closed_at") or "").strip() + reviewed_stop_loss_raw = payload.get("reviewed_stop_loss") + reviewed_take_profit_raw = payload.get("reviewed_take_profit") + reviewed_result = str(payload.get("reviewed_result") or "").strip() + reviewed_miss_reason = str(payload.get("reviewed_miss_reason") or "").strip() + reviewed_pnl_raw = payload.get("reviewed_pnl_amount") + + if reviewed_result and reviewed_result not in REVIEW_RESULT_OPTIONS: + return jsonify({"ok": False, "msg": "结果仅允许:" + "/".join(REVIEW_RESULT_OPTIONS)}), 400 + + try: + reviewed_open_dt = datetime.strptime(reviewed_opened_at[:19], "%Y-%m-%d %H:%M:%S") + reviewed_close_dt = datetime.strptime(reviewed_closed_at[:19], "%Y-%m-%d %H:%M:%S") + except Exception: + return jsonify({"ok": False, "msg": "开仓/平仓时间格式错误,需为 YYYY-MM-DD HH:MM:SS"}), 400 + if reviewed_close_dt < reviewed_open_dt: + return jsonify({"ok": False, "msg": "平仓时间不能早于开仓时间"}), 400 + hold_seconds = int((reviewed_close_dt - reviewed_open_dt).total_seconds()) + hold_minutes = calc_hold_minutes(hold_seconds) + + try: + reviewed_pnl_amount = float(reviewed_pnl_raw) + except Exception: + return jsonify({"ok": False, "msg": "盈亏必须为数字"}), 400 + reviewed_stop_loss = None + if reviewed_stop_loss_raw not in (None, ""): + try: + reviewed_stop_loss = float(reviewed_stop_loss_raw) + except Exception: + return jsonify({"ok": False, "msg": "止损必须为数字"}), 400 + reviewed_take_profit = None + if reviewed_take_profit_raw not in (None, ""): + try: + reviewed_take_profit = float(reviewed_take_profit_raw) + except Exception: + return jsonify({"ok": False, "msg": "止盈必须为数字"}), 400 + + _MISSING_ER = object() + reviewed_entry_reason_update = _MISSING_ER + if "reviewed_entry_reason" in payload: + s = str(payload.get("reviewed_entry_reason") or "").strip() + norm = normalize_entry_reason(s) if s else None + if s and not norm: + return jsonify({"ok": False, "msg": "开仓类型须为下拉选项之一或留空"}), 400 + reviewed_entry_reason_update = norm + + conn = get_db() + row = conn.execute("SELECT risk_amount FROM trade_records WHERE id=?", (rec_id,)).fetchone() + if not row: + conn.close() + return jsonify({"ok": False, "msg": "记录不存在"}), 404 + risk_amount = row["risk_amount"] + actual_rr = calc_actual_rr(reviewed_pnl_amount, risk_amount) + base_params = [ + reviewed_opened_at, + reviewed_closed_at, + reviewed_stop_loss, + reviewed_take_profit, + round(reviewed_pnl_amount, 4), + reviewed_result or None, + reviewed_miss_reason or None, + hold_seconds, + hold_minutes, + app_now_str(), + actual_rr, + ] + if reviewed_entry_reason_update is not _MISSING_ER: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr), reviewed_entry_reason=? + WHERE id=?""", + tuple(base_params + [reviewed_entry_reason_update, rec_id]), + ) + else: + conn.execute( + """UPDATE trade_records + SET reviewed_opened_at=?, reviewed_closed_at=?, reviewed_stop_loss=?, reviewed_take_profit=?, reviewed_pnl_amount=?, + reviewed_result=?, reviewed_miss_reason=?, reviewed_hold_seconds=?, reviewed_hold_minutes=?, + reviewed_at=?, actual_rr=COALESCE(?, actual_rr) + WHERE id=?""", + tuple(base_params + [rec_id]), + ) + if reviewed_result == "手动平仓" and reviewed_miss_reason: + from lib.trade.account_risk_lib import apply_manual_close_journal_cooloff + + apply_manual_close_journal_cooloff( + conn, + early_exit_note=reviewed_miss_reason, + trading_day=get_trading_day(), + now=app_now(), + ) + conn.commit() + conn.close() + return jsonify({"ok": True, "id": rec_id, "actual_rr": actual_rr, "hold_minutes": hold_minutes}) + + +@app.route("/manual_transfer", methods=["POST"]) +@login_required +def manual_transfer(): + try: + amount = float(request.form.get("amount", "0")) + except Exception: + flash("划转金额格式错误") + return redirect("/settings?settings_tab=transfer") + from_account = (request.form.get("from_account") or AUTO_TRANSFER_FROM).strip() + to_account = (request.form.get("to_account") or AUTO_TRANSFER_TO).strip() + ok, msg, _ = execute_transfer_usdt(amount, from_account, to_account) + conn = get_db() + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("manual", get_trading_day(), amount, from_account, to_account, "success" if ok else "failed", msg[:500]) + ) + conn.commit() + conn.close() + if ok: + invalidate_account_balance_cache() + try: + from lib.instance.instance_live_push_lib import notify_instance_balance_changed + + notify_instance_balance_changed() + except Exception: + pass + flash(f"手动划转成功:{amount}U {from_account}->{to_account}") + else: + flash(f"手动划转失败:{msg}") + return redirect("/settings?settings_tab=transfer") + + +def _journal_ai_chart_builder(row): + return build_journal_ai_chart_path( + row, + app.config["UPLOAD_FOLDER"], + order_chart_enabled=ORDER_CHART_ENABLED, + normalize_exchange_symbol_fn=lambda c: normalize_exchange_symbol(normalize_symbol_input(c)), + generate_chart_fn=generate_multi_timeframe_chart_png, + local_datetime_to_ms_fn=_local_input_datetime_to_ms, + now_ts_ms_fn=lambda: int(app_now().timestamp() * 1000), + ) + + +@app.route("/ai_daily_review", methods=["POST"]) +@login_required +def ai_daily_review(): + date = request.form.get("date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime, 1, 10)=? ORDER BY open_datetime ASC", + (date,) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该日无交易记录"}) + + text = f"【每日交易记录】{date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + ai_result = ai_review(text, "每日", image_paths=image_paths) + full = f"【AI日复盘 {date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "daily", date, full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + + +@app.route("/ai_weekly_review", methods=["POST"]) +@login_required +def ai_weekly_review(): + start_date = request.form.get("start_date", "") + end_date = request.form.get("end_date", "") + conn = get_db() + rows = conn.execute( + "SELECT * FROM journal_entries WHERE substr(open_datetime,1,10) >= ? AND substr(open_datetime,1,10) <= ? ORDER BY open_datetime ASC", + (start_date, end_date) + ).fetchall() + conn.close() + if not rows: + return jsonify({"result": "该时间段无交易记录"}) + + text = f"【周交易记录】{start_date}~{end_date}\n总笔数:{len(rows)}\n\n" + for idx, row in enumerate(rows, 1): + text += journal_row_lines_for_ai(idx, row) + text += "\n" + + image_paths = collect_images_for_ai_review( + rows, + app.config["UPLOAD_FOLDER"], + build_chart_if_missing=_journal_ai_chart_builder, + ) + ai_result = ai_review(text, "周度", image_paths=image_paths) + full = f"【AI周复盘 {start_date}~{end_date}】\n{ai_result}\n\n原始记录:\n{text}" + conn = get_db() + conn.execute( + "INSERT INTO ai_reviews (id, review_type, target_date, content) VALUES (?,?,?,?)", + (uuid.uuid4().hex, "weekly", f"{start_date}~{end_date}", full) + ) + conn.commit() + conn.close() + return jsonify({"result": full}) + +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "key_gate_rule_text": ( + f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|" + f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%" + ), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + "trade_policy": trade_policy_template_context(TRADE_POLICY), + **hub_meta_entry_context(TRADE_POLICY), + "options_enabled": OKX_OPTIONS_ENABLED, + } + + +def _hub_account_bundle(): + funding_capital, trading_capital = get_exchange_capitals(force=True) + funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None + trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None + available = get_available_trading_usdt() + return { + "funding_usdt": funding_usdt, + "trading_usdt": trading_usdt, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, + "trading_day": get_trading_day(app_now()), + } + + +def _hub_fetch_market(base=""): + from lib.hub.hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_okx_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="okx", + ) + + +def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): + from lib.hub.hub_ohlcv_lib import fetch_ohlcv_for_hub + + return fetch_ohlcv_for_hub( + symbol=symbol, + timeframe=timeframe, + since_ms=since_ms, + limit=limit, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_okx_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + friendly_error=friendly_okx_error, + ) + + +def _hub_fetch_volume_rank(top_n=20): + from lib.hub.hub_volume_rank_lib import fetch_usdt_swap_volume_rank + + return fetch_usdt_swap_volume_rank( + exchange=exchange, + ensure_markets_loaded=ensure_markets_loaded, + top_n=top_n, + exchange_id="okx", + ) + + +try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + from lib.hub.hub_bridge import install_on_app + + install_on_app( + app, + exchange="okx", + capabilities=["order", "key"], + has_trend=True, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + account_fn=_hub_account_bundle, + views={"add_order": add_order, "add_key": add_key}, + ohlcv_fn=_hub_fetch_ohlcv, + volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, + reconcile_hub_flat_fn=reconcile_hub_external_close, + risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, + ) +except Exception as _hub_err: + print(f"[hub_bridge] okx: {_hub_err}") + +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="okx", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] okx: {_settings_err}") + + +@app.route("/strategy") +@login_required +def strategy_trading_page(): + return render_main_page("strategy") + + +@app.route("/strategy/trend") +@login_required +def strategy_trend_page(): + qs = request.query_string.decode() + return redirect(f"/strategy?{qs}" if qs else "/strategy") + + +@app.route("/strategy/roll") +@login_required +def strategy_roll_page(): + return redirect("/strategy") + + +# CoinOX: 不做单独永续策略; 永续仅作对冲腿 +normalize_exchange_symbol = normalize_okx_symbol +ensure_exchange_live_ready = ensure_okx_live_ready + +from lib.sim.register import install_sim_trading + +install_sim_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.options.options_register import install_options_trading + +install_options_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.options.options_review_register import install_options_review + +install_options_review(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +from lib.hedge_plan.hedge_plan_register import install_hedge_plan + +install_hedge_plan(app, _REPO_ROOT, app_module=sys.modules[__name__]) + +try: + from lib.sim.hooks import patch_options_cfg + + _opt_cfg = app.extensions.get("options_cfg") + if isinstance(_opt_cfg, dict): + patch_options_cfg(_opt_cfg) +except Exception as _sim_patch_err: + print(f"[sim] patch options_cfg: {_sim_patch_err}", flush=True) + +_purge_key_monitors_if_full_margin() + + +# 启动 +if __name__ == "__main__": + from lib.common.flask_access_log_lib import silence_werkzeug_access_log + + silence_werkzeug_access_log() + threading.Thread(target=background_task, daemon=True).start() + app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) diff --git a/brand/icon.svg b/brand/icon.svg new file mode 100644 index 0000000..2277788 --- /dev/null +++ b/brand/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/brand/icons/apple-touch-icon.png b/brand/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bd835ad75be5d5fe23ae62a783a6517351447cc8 GIT binary patch literal 1743 zcmV;=1~B=FP)Dt9Bo_>JoDjT838Zw_MJ}Oa(}i!K?M?J9dJA25pXLU#=%!@R?hENIv?P#C z2(d_Lpv2RKzhqkx$3>Cipf9O=MzcFEokC-8CaJqong&5g8>_>m>itmw&#k$8j9v zn8Y|H@o#)J_0xLk=T*bsyZZU**Hw#SF8|-vk7;)N^4n9jIOgVkZuhuVEg$RE&Q(Me zC-Er`&OUC(i-&hR_irbK(BnA1_Vt7q9f~pkcDH-ns+N!S&HKFgn3K45@zq}Zo}@x2 z5_)pwj?5OC7;VLveV>Q_SM4PB&t(21S>#A8bUpPoF;c~yYK@Dq&%bK_#pg+7PMD$J z)aUtm9FzFx>+cu-{_ftPXHJFi@xzl8w#FdB^sUXBX_aWOi2=|<$T zS)<5N246h=;l}s=KKe=IKp8SG`o7^LWG8d?QtK3A!!oZYwN4=hDD(Q2TBi^YIS_`E zhz!F?fZ-$zjkodbO25B)lf`fndg9>eZ!1aNzGJ9Uh@NC_TB+A>f2VU~Cqxb+&xs#* z<4MOdr+2|{5}9W*IeK*E4X$(XVkg%zL8Os+a`Y(D?mn7!tk|+0nR}5rbP7?P%sPd! zZNovSMl*+1nRN;wW{4cvp2{q8kU0z|fy{=JRBAFCPC~JjIm1pNk)yM%0-DZFG-+jr zos-C&!>g&ex^a z&A9}(jlp1-<=P5<{sEoBtYuzSz3&eA-B`msbo4$Zn3K%tZ>+e;;Upq^TQ?B{BFE6y zq{*yPSj^5zUdY@U`s=WMSu|jZ1xf_fd!(-d_bG2Vgj9 zgSk2e)@*^db%Ws~B15MT5ILYSiyR=f$N`bVnly9@#SRqIDV*LiwQ>eAa~|5xNf0?i z4u~A8GHliegiayE4V^*|TjYSqp;ANSATmS_Y$YE*ywNFywdZ6)zkm0-5OFaHXrtN(Y+zux@qkty7p{ z0UbJp8;u;K4qd^VhLh$$ITSgt+=njWM&}j!@XIhoK^oWx1oij&xnaZKXDv&S>! zuIGMU&Ap=QrJq+VjyeClYH`f++r9Xh*|)pdF;{Q*YR4=-zWHNNt$55hj$<6h5%Dsz zd5@J(ad29FPdC>LV0W5#hDpW=4Xm9Jz%@5b|s8$Mg; zrYCo})y9Rm+J$&pnWqa}CPIG}uVz;4<_}VSfA(F7O^$gv$BAwy`g&vk&b`)uYdm?( zdkHSZ6TpoNy{^>AlfGn*O>W;zY$8WV+Zb%(&Z7MDI~b`{Y!VkGG_i?Xq|gK>FaQ7m l00000000000000;$^TX&O~@Q4VfX+5002ovPDHLkV1iCZRK)-Q literal 0 HcmV?d00001 diff --git a/brand/icons/binance/apple-touch-icon.png b/brand/icons/binance/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..702fc963fe61d02f30af5751fb0fd68a2a97ccda GIT binary patch literal 1263 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6VA+M}bzdHdUZGpF0 zMVq$dG}>;oT9wlw>as{xBUCELa?^zelKge=Z|*;TeHrJECq;jA)MC{=C#fKzCCpdz z|LRx#e*Exo>aDQ)dvVnZ7}@@KEO2D2{=Pu5t@rs z?P^Qj*S|OK-*w;UndYP8%lqFw-KoA>@Y&1n|Dx`g27He`oFkO(|L)R?-)j@(6wmuU zI5}v2jaF>01iz{N{*ve5FF)(Wj?25)UM>8x%h2{> z!K}hNm;1fHy)?bnxA5EC^=~fEo_AroU;4^pQ{;ToRefI`+#c=8+xK!xNssxDzYe92 z`xCOCPMDhf@^;CkOPBl3`erPb&MLf>2o!T>iUL6gZy+qUo8sz_p3o-sV#i4y(+h>! zY*!qPzdaMcEL+aZ0pu^21Tjt6fz0P1(eD>nvKBm^$Kb?Ua_4c*ipIWbb7r7GoJo_^ z3yTz>f_82oH^7m%L`DrL>&ybwSh0Z3ijNm)Zek-)kxBsbT^9F~{h~lS)n+VYv&u7a z1{yYt5vbDI7^vzl*zin{LuAha4Z33q616n}YTlvV0CIzcbk>5!_s%v*z0d%;sa+Ci zrGay8!G+El3+)VEJn5eIr(g6%^TM{D56oLVS0u7ov6-H6IKH4w@`c0|17_J~sk8;j z0f%`?c(P_R_BrwzUogb2XiG6rk)049NF@_cWkdqdI9{M}4;q2Sxfn3NEw*g9Hqp+K zGXrRd6iBfN5yoP%+j_a!1xVC@qo;heeDKy^{P(x+b>mj;H)s!<;do8vP0)(&hPwaN z?^mtt6t>>-=0Y+s2mJPy`x<3$Vp@=QcSiBm9H(gOD~FB;yk063e5+*L$EIrmQpP7% z74F(7^>EXLmEyp6u@~Y;XG|F=P6hv)OB;W3QL4?%wtHqrq>{ zJ9q#5``@ZpS+%5gtwivd=gU4v&6vICwBftU%l9#!vWSmss(jm}VP(GbxaZ%iw-Te? z=WEU1RW>~#uGmVPxmNSlPEnROe;wj>B~3XLBl5*`#rd`J4f*Lp^Q$thNfj4f@D8;8 z`J(J^XTVy+aK^nXUq!x!m!HdOE_>&ab+R|!^UPHTeb-vP?YHX9?wc+bUuqc6TC4ji zc*oVZ@0R|Nnz6e1pI+A6%|I75ut`7B`#5=WRL#ay`}fUuOigl@VU^06pzO%Zw*k4J eQSsEeX@Ah9L}sz|nL1!W#^CAd=d#Wzp$P!*QCSiI literal 0 HcmV?d00001 diff --git a/brand/icons/binance/favicon.ico b/brand/icons/binance/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..26172c886aee18b4821cc653df440b12335439d7 GIT binary patch literal 150 zcmZQzU<5(|0R|vYU}yl6VnEy(;OEZECB+3~@_Kr>1OaJ~3IPr_ASwU(e^?1^mSiGq#G=ih$(E>TsAtgYZLF;5 poRGu#eSO8%M=UCqvlCO87+O-b%vlf5*8>{K;OXk;vd$@?2>_c=B)$j+fwTU zBOnBVQnZMoAOi)|P6@WCsGtrpV=@LS-yl+4nMg~I>+br;694v+``wf0dGfwb?s9M5 z4sHn1nqmzAAclpqBhaTBy9t)45&e8C04$=y*vwtW`m_Vz0~tyrna40xVRW=R~_9upMX%Kuf9cB`S?=*JvsmWfXW2V&3OD} zB^IkbK(x)2WmWEh#qpDQg`=eGVV3|IOSHyYx=0ldmR++;p^PF!t(#g0w8?&x^EAd# zv$W<5X3}1y>{{gf?2)M%r*F0iOH78wcd-|_1BI#La>u*{hcVTiVTJha_u+b1fsC?= zsVRmtf*o5}(a-mu9hcqA#2?>2D~{p(Y;uRUoX}5iI_xUF-(ITFKUsFE^g^X30=cA* z_pm4kaLzyeI+C@=Mfw8z{L8Fdg`nOiam_v#$tx+G_@+a{sc;~g0-u@SUS8n3{UgV2 z@#*v83Sx&saA@P1xH=)eM`1yc8#}zE8H9|~a=N{`q&tysJRwiq>YiM{svhq&r9X3F zJg%s@YgbajUi~LFo!m=WC9Jjg(VnMD{{bampLtu zaV7=9j^?7y5^MRBirMCiSFUsW%{_?QIy1pFk!=03`7L#}s}xi2fs;!hh0jFFF~MpFFI7Z16ScN*Sw~ z5^U=%(z09r^{UEJ#4J(M)lqt*@wT7!fx7OAa7pCT_AUBltr#AeL*EBa zIOEiG`lv>X-1C35sQ)k#scUszJs}nNTc$QH+;C7=5Vo{1XtH?4dw$>opQ;X!Zm*M) z3XXPWBqOL3p3H6`8{IFK_d~ zFMFkZ_w8$CfA4iylN+v|(pF7-={T+>(#JHOl4_PZy~a!Q`a#OhI}4YXh+8IT!ReHU s?TdOfizYe(4$@iyo_Xx!Ll8Y+T@hSmr0zNq@e9XlVH_^IffXnH4_+2_f&c&j literal 0 HcmV?d00001 diff --git a/brand/icons/binance/icon-32.png b/brand/icons/binance/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..c706b7d2b95e6395ab2deb0c14234c2807487863 GIT binary patch literal 301 zcmV+|0n+}7P)(VEg%&lELw|t0IhM zKmFSmOyh_^m3N(|$`m!N{MG#z5y!cG@7sM-n4NTep{!ld+9OxiFuFBOnq}F$fF+D4 zWxdT&FAvX5w~!6YmXmGH`Yo4BiDZ*nIrW59P@QpcH^-Esb}da5qk{fw}F8?0~c6n?U0%AfytEFVdQ&MBb@0Qj-0cK`qY literal 0 HcmV?d00001 diff --git a/brand/icons/binance/icon-512.png b/brand/icons/binance/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..6f48a2ba51f295be65e5451357c89b025067db51 GIT binary patch literal 4991 zcmb_gX;@R|w*K}`LIz|IK~#p|K%oMX*0BmCibDaFPz0=?iB@~Opa$hsQ3HD;QfzAr zM-iND75Y!|`F*p>$ms{{}v%W9+ zM!bB96K?_!A>B7$t!r>7|90z#o&ukt-Xw2fMh4WWrw*C2ZUb**doFyJ~^d4Uv zFPp#n;cNGuk*jjBGlRL4{Tr`~PA)zbQPwq7ctwwLdwx6k%hCJ0zgwShm}dNoU!vqK z)2<)MwhmOeBw?+0g@on9PrqT<~oG#Ao!FmX13lv7UO5e?wv|R0x4GJ0EXFUk)jnBnHWc8=aJM8U+JX6 z0U~fo#spfVnjmF?PMUogm!{8rQ=rjgwF-_jN+RwI8)Fl3F?yIQ`HgYvFk=z;vEqDA z2s4_*T|MDZ%qc=FzbWRt2*s#Cj3@fkXSywHyXIpxI*Sy>rgzAazIt%TzN?COy^z}nEJF_yl`N912O5RGY z7#(%CwQ1QKD+4Q1m=9y9Wu_`GYs__ZrTeCLwsv2#XVW`t_slA@X$OSnXZD)+B%m{A zdXi^y2!7UI`+<3wXFYp)b$cr-LbYUK<_6!?O3svG@|ZzJ)Yu+pAe=vEcb;PVw0MZT z6Bm5dLfAU0ZL(6A{uK_X`isTi(xk^~-e`6`9IL%IN!8;0Gcyof*kM&3uk|pCWQ(L- zqjKEDSGeiT1~u`W=6P~Om+ftjnt#|x%=l90QuJMkQBphc$78>DHQ20;t9Blg-f72W zJgPFrI`jTxAEBV_Tk4^_wLVo;Kb21I!V5{pwy_Fh!=B2%5;yTCY`>c|CdQgUY*+v4 zAfDSk;YfRZndQ>CD#l_Bulhrzzjl|18D@>!x=6a&chjpX=HfN1YOXEq+3TvyHkM!& zH*;Xw&q*FRaWT{}gYczF(|kgHe->$AM^mxf&=6a!rSDKOergNri97jx`B01bl0QKe zi!4`mK8mLHokOyETdkpF_cOk<{}Fk1$FXK}UqTQzxf}<)#b{~i8hrYW(JY)cr!q_G zLGeUMI$QM_e=}C#_@LzA(N)~)-qF|pm54ptYul;o-kgp4f=c7(E1`zVcFAuS7}#?z zGb;u!`my{BUBqjDVLe~l>Ly6wJ(z}_D#KS|nLVEqI+yfi+>JUP7_-|ES8FT6fc|7C zmX?uvKbbkc8Xd3k?#t{{e(8ZoeKghf)RvHsFKKm9;i`sG^=Cb+V$hl(ERFaw$gd0? ze^g&QXkiIOUW>H%+?s}`k{;y6#%s2RIuEqQHoCNJl1PNj>FZL@GMz{0ct)UVM!K&; zpMZjp7xAp6S177$N4`!g(4Bt?SAS^2+Jf-QIL@QS-<=j8V;i(!ZDB6yj}k@7c9e+L z%t0G9d~I7Fa>l2k{AU)WlK_&rKZ|PHUUmuPdXT3MC;PfsA9u4m-=t8F+4$}!5VrWT zk2&4}wK-+6aSw8Jw>4y#(O>1Gy&z&qTk32z{4+>fdj`K;jL!{5OS&#q53^Og!fnnJ zkE|omIdGhAbP>Fqig=>OLr>Io1Zhpgu6T;D){Y07r$UhgAh^R+w8Tec+GuZvy0TrZy*Y4RcymHF;;@c9U&1G$PikSf`=JJMSqh2-YN=0X?n~Z zBeQcg*VM3A0})an_hH+OZ9>uQtS~{3H#8;hZwoze^1>Q(E+AmDD`5Hl&zorcTMz2S z7=-*g5-5LD955m&3n4LJ{_i(3UOv1l16C$-YmjgO$hq2=goC)?u;mcgl1<@+KLqU8 zPnEcVFV|gdBe1OISUWuH!F&03fn7iJwQvMKa4i~=5OvnQ=iv{=*?EaaBZihJi5(bh zw)-&{671^8Bs;BBvRyf2{YVh$@0AH7%J>oG!V#rm#fWA6h%#nGIpw|Odu1H0+)Hd6 zHx*ML(qJ^Q-H~f{IRk{IjBr|a;td{%{9dH+A$#4+xfl;|X0|wrk?2Ux77|Ab?hPt z96u0Ax4ArpKJOm`(6jlzrg(h6Cp`X`e{v8$g$xAWaN1|+ z0Nh!V}q31jnTKce5X-vTP!$B?hB>!4FfFICc@1cf>XtL*P9DVS-NiqOV z<^h76Z#}JalbTB*w%K&|pSuD@txk82Ij#WQU379F-Tq@S03V`}Nmnf$}GyoIV4_;`unBJX)$d%N*B8Q;u?429%3| za{b1YKsg&Iy(1$(gFtD@aT9(3${&ETp-H>~-@n@(E!|TuoG}WR_$+j=1m?v^56Quk zh72D$ObE3;pg+Y$1LGmM&7IeB!w5xRx@EzGfu3+^?7^ey87Jft8umeRN~p#QVBc`3 zx7??vZs{5rrM_^wgvEw!K3OGDs`dUADY2R$$Rvsz)bhya2;nbZaA#m3JQ83B(zl(+ z(g4SJu78S514UfApq|t6)aU}PR8iyJKNSk<2L`W3g3~EQ=_3XoasyH%n(1HwY+nsn zV9pK}skVh@De2h2=@Jc0py0o_p)HL@&PZ%u^;zf?jtDZY%4KwHkV1fl9*?u!*)6w> zOo$CK{i??TUzCH#RT<&(P#PA{pTgEaeu7tAAwg*9DSXGBPsr#BLN&!EFC(<#w%D_!+lszP_Gy(v=~>OV7iwb*;+xJ)Mtqk+?d!vMG+fQ>3w59`fP z=-$GH`V}UxDw@&BF##B%_fa#0QvgVhMvJNtfb`_7Z>Uc+c^#%1og7yKBMqD#EC=8| z0BTgOW34y&0NmRUO&Vr*(W!VR;(OKhx$RK7`I*0m0>WS)LOPiorZ#GB8IwcU2mgN zmJZq}U1-WGAE`HNo(~AQmEI29n?p;7A%;FdGxyD1f|5np4zCJ3(3?wwz$9oT35XVSYTl2)phg@Tx9&7*!So} zeJ$-}UJCQLLGRAe&TcL&Qf~`AQhQGuU~9S-*8|KiY(^fe zAkoT99s!3D-F=8{?b@mI!mD49U;>W4dFN;Yj^pP#sJZLd%XY)yzmFK2-W!g!8$!2e z3+N;bg_hXKp)5`)k#@n;xXGyF@-2?Q3Kw z!>PRq?g{^Uw41qDERKrSo9umxT&k{ILsO+-ut~ z5g=bYv+!%on%^r=AtCyGw!v?!G!kx>QgGLnh)&LNowLKJOIx<#@Y(ZZld;w;V=2O_JfPZ&s~tp)Z7oZC zFM+(3bB2Q|Mw^p@Ek?Of#?mVKP&A^h>0}xSR(&P!(Q$TqpsUmtNt}N>T#yb3#aqHx z+C>CmsJ&hdm4NPx%UgLXd0GBQd&Hnl1;fC{?Icbf zscBw+l~Q-0Q!gRrWlirqTw*Qvt=OPr=c5C}A3I)^B?Re&cb?h`gHV!uPC=|g@OfsK z_0l8cXK^B8GM+yvP18XXH3{TDbLuLlO8wENSNk*{%W8*{P;DqSQ$34Q*t?CdQ=-4G zMn6Ab6x}Hqe{#YLbSmVX@+tHDiNm$8n>71a($+Jmd`OiYHeOXxRXG@urpL+A-R3l6 zNk>mfp4x*fPsDwb7^%mUA(-FR+v*uZ2TI}^pe7q*v_EbTEJEY{?73DcOw)a0`-dZC z2*qoM8tRD8#rJz{4?S=Gftq$1lqbIU@5c_VRvwk^uNc$^>v_48P`|4=Bj;L&zB!M$ zz!Ez=<^^^T?O0kPRqUDly{jTG$rLf{-h3$ZT^uSR?>_SsZ#RFwjvSjyY;jKe^Qx7r zlSH=`V3ob4%Qth&HZ#h6DlW`K$19s9yIWz%<+wyh{}&<>{a6mW>`&#>ekuh z8;EK7M@G!uyOvivz67lb!DcE)J8VoyQ}%gSZ^WbUER#_jpw>*?-2pEY6jfg3#(iKQE31%kK(v=^-o+We!vj1*M^853M?!i`ySh1aUZ1;Q^_R4O91w|L}yw$%4B zPCi2Dx{QT#M^o}N7CMKaJrYbb8o8Qpj71J1 ze^_V(avcgpfef-~2_~9|m=Qb_jrqNeL*h|rz6<)RUyOI2vf#53@2cG+Y3is&%NLd} HknZ^p4T+qf literal 0 HcmV?d00001 diff --git a/brand/icons/binance/icon.svg b/brand/icons/binance/icon.svg new file mode 100644 index 0000000..70a7d51 --- /dev/null +++ b/brand/icons/binance/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/brand/icons/favicon.ico b/brand/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0af9b9cd2bc073bd672a187a9416a65aa4a32271 GIT binary patch literal 181 zcmZQzU<5(|0R|vYV3-di#eldoz|WnRONtA~%N|U?)(15+aoMQh2(?;1O!B_EKQMo zZ1{Zvhxrlq;joT?fKZy1=_RMzW6SyeiIVfc*eG2bByyt!du|*n(RmFFAl01oDLWDP ze0`{4L~8lr!SjO+YDwb_+P>Qlk1I7~HN5#aQ0{Rp&_+`JdS3=+Jc#o6EwdDWgA4e-jtArRmC_O;6 zacuDM_tg=XyMOGi>XD?*IQLb!vNl#)P9nQF@5`_;TD8J2FrCD7Ziz~kHG9(7cF8;U z*qzEG)$R$$nW62&gRiK^Uy@Gadh0+>Cp;|!O>MowA$P8<6qV_fDB+qq3C_Q>M8NwA zA1t^WUL5erfVmTT#TI=KBKoFf0V0Hycc^5>xhaxcMv(IKG)j}Op((_@PB3Ens!!#I zn_Ux*9%u*+V)>y;ecq|1xYk<6Q<&iGVLrE-c#4R{pQ>tzXWFy<2;L%#wMgmu%ekJY zgGy_CX$DKzmEbMVMP`Drj2tpNVOhm4GqDdQZF__5@=fcB-h*U$*hRTe-G2pf+pP;A zrb}AwZL5b?L!YQO41X07Hz(^=_Shqw$I#8id0M`oExu`G`}WaE@uklFe^xzreH;x-@l@4oLDY@n8Np-4 zJdHIV-PRin5%aHp_Tw)g|L`&j0IGSKYJVjV)`c zlsm6rW={L^#O@)?P{55gacc1Kre5*||9Z{>G|861+W;|wCsjrfoO=Bxe6{!^A)iajYe#;^XD1-IOI(tT6U&lX6uL2sXn} zOh&s6DMWk%u&y`myt`ny|7B@1$Oydfdgl;Ku&LEC-{_7Fww05w(6X&-qX;h~e7hB} zN1yaslhEuYR%@r=>bQyyL9=2UcyHxnoANY>#nBmk)U>NmQ=`c!K6gK?aH3QbOx$I9 zh^;W7yy8591%?xBu*{)LkE5+`%b)-iAmNAGMH`6IcwCn4K;zpCMdEuiA0N}I`f#0;FJ2eGE9sPXySnWp+@iXCdIgzwaLRdZZ9dRe_ zxwYm`mtxIU`-siL0|p51^}`sK;O}+P>#EMMA0}twgq9tBK#`sukleY*SY>1OpAU(% z_tIa^<3S>Si5jzgP%>d?4CnUn+UzWx)l?+;LWi@=pBxzhyvW=k1*ci6moKdx7jiLMU6O47V^s^ zq9T{~!3*PY>?iehZ8P7XlA=xEI-eS$R$;$+h$#XQiv$(U{>RNlJTwVy8ClVTGT4QIpAi$h|Vp*?3wZWMiZZARa%d?e2R z6Gd~S&;r)me&u;~=4fAvH^WKh8Ua&}8QjxooyNOn+I-*52o|YwS>4eG*DV>0YA!qM zHNLA6m3l-R!RJ2Xx3mr8De%m=^CAGaKO6T)-H}>A<`bEv&OAB|e-Au}irP{?t^3v<=0-aZSYdc}| z3^c%9wJv9TkTRo&8KM<+%=n+6l}!#y@kbpkohq5T=`4v^|5(HL$!szBT&Id-Ndz`Jd?AsPBF;<==F_) z_Ij>@w}$Dsm_YG9aXE3h|G@w7-^zdA|K^wN+5C-fh2$@6SBAzoTr(1yhvo3_ zi{fa!SoKksqhE(2HIS-hfbt#*lzVo8LOC6?u^QH&Uz{4@)46n3Us@^168Q$D$G1Wo zTVwYOBRY}{GIJIGb#0fbPo3!zC+IG zu~^hj8FI7NiB)O!Luxo5eiGG)Qrt|wUBpT#!iYJu?_Xx+ zP86lJs2Pc5n@C5zK$`d-&tRGzldC&M(FjL?-+_eZjcBQqEB^avdn2f2&qz2-6z)jp%;^`SnXn literal 0 HcmV?d00001 diff --git a/brand/icons/gate/favicon.ico b/brand/icons/gate/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..dcb291e921358ce942e80e1528bfdae7da61c4b3 GIT binary patch literal 287 zcmZQzU<5(|0R|vYVBlnAU=RbcIs^RNdAX#xfJ|Ob50@Yy4N@V%!3HGdKmShzQWrd3 z978JRyq$QKi?LATX#M|8CzGzTDMA|qlQgBZ^%W``Zbn8abL%&l2i$8=->@xw+M)|7 zi)&2GGHP;u1h1KU*YWsG37PWkzZ+i*eYy<4si!z_KTYjg(4W<0kssCfgPz{=gb*XMLAQw$Y@$XFyJcQneXUI*;s!20ge^2t;XE63zcoxF z0oqu=#&CJ>_LF>ixQnKZ87!h6uI%5yWUnJMCQQ92S}pV9^4Ag2M@Z&p!Si}YwR8I8 z5@QBuxE?#)60&I_pOJ2_{rL(@II173b{J2+_U|RFxdR-RdGRuO-haj$dowU+ip^Jy P00000NkvXXu0mjfeROi7 literal 0 HcmV?d00001 diff --git a/brand/icons/gate/icon-192.png b/brand/icons/gate/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..9039264533cb1dbf32fdbdaf908c9eec004609a1 GIT binary patch literal 2990 zcmai0c{CJ!7an7VF$jYsl|76#LS>n;WeY=$kSHX3DZ62ky&}sbAxmQHgODbMYRJ~; zwPjzvndmi^X0lASZ{Gjk^L_W6dw=Ji`_Dbky}#$VX((HBe&|Uk007{(v@msGzg_=0 zgo_;=W&*hZ0NyZ5QzOU7skNe*Cyq4nl=$VrporTO;C6^ZyK+gY8OYv938#pIDF>yG zy9TvXp=-|B_yq>I21%sy1j$aFuS&CN*@CtBgF`+`kkgay3YYshk54zv+>4lOe>H+= zEeO0fTND!hHD61M!W59E6SOvV4CnXa<|BTb;$e63Z`RLWQ#cnmTau+_f&d{+Lay(67OeMjFVWMr9fW8>F@HvC#Z3FNtXt@$jbV84H!BeOT zi#65|ggoI<;eOib0o?b->p{)Wvuu1`>~Be+6&t9HY+Zf!Z zUTqEXAl#&!2LQc zA7~62donbKaAf9Vht>rgnZbgpg2O-#DF6Tn_}?K{@}}{^mWY?qdB0rxi0yNOXgvMf zC-2OYDrB!Ff3Kz`dnMR&O_%ncwKK1Ksb~E2h)4Lsoo%Iw@6|Kox7+tJVb&l;;j)`% zKZ>h13zeWnG1axnkw*<_X5M6(roRK;Q21G+2tCQu4%;mextOS$(CK-F9*CfRZRu8e z+?Kvsr>5KZg6TcpKD@AKhkt#U^AN6i)$5I9oO`~QuJtip8WQUu+$7o&no*{!^2|wM ztFz|igC_rV*XorqF?bYZq(5l#(+L7Z&3?UD8=aOC>_q3zV`c9j=|)x-)$U4mR(^k) zXQ5+a3Em`*9sMv%$HbRuc!B)3~^mEknDzvnFey0xbhk4Nm!E1?4yjylFv&9Q@vNoWOOTyQir9 zOPP4TK2wL&g7>_Jf84uwt1$40IO$$PhWk-W*x{};b3p7b7oS(t_>6`II;`v&dgVP! zB~{21E6>=sgvQ@{`D;~EJ#f5n6|K}(7lkCv2id_{Omo~JGXU&vC}bD{dz1>`yoS0F z-x$R}Y(3A8FmxC-sT%F5!9_7tnJoil_fMFDP~CYK6Du-wqHV%wZRMTy<=e?53)Oap z_{Af?b9;Qtc1w5pIG}|Gq;nIs?iYtEs70BP5lOc%)lmC=2aj*I*e4+n8QrD;t=dI? zs7GY-B;P@SFRfL<0OQgA)TTcu@O%x>R~9f`0>19%)%0lT{JPUNt*-M!+u*#8;(0IQ z4W~Ot3jNw^xaOFK!XETRqFZiEh$Lq^paNJJJYKXPR-}N5Z&-bz8~(-M``P7Bk3vtn z;Q|6#9!(p#y}on*!2kVl^s=a-Z-RZuD|g~h+s^VC>l)0Wy13#vU{3I*4m$k@Lf2bY zr+>`?b^(NcR&wRk%~lOejINOh77Fd(%^3uJX9=;BzeT(JrmTkrOA-<^qwgFd3j4np z%owqAV}goHm^O=Y$UT)R4s;wpj<4Mp=$dJ@O*s3N1D;~~^hyIoT(Pl03?4nCZ-+oL zob42uzqxwypu=|HREfD6c(3y@p^|J3n-z=H7t%=DLU{Qg{Bo`uj00|o^|}qjV>h6j zKv14z51zj%WmH%TgjXpXm(`3qak;S=41l0qIrvD(TbX)kCDKSR525!8cKCA^h@Ydn z4VYMwDSu~4?%gd73>9^!C$V1Ptw^Ng*U@Tit2$RNoxArd@Zq!;zT=`@kD$Zs#U5Dv z5I=V^7Zv#f1WW;lI)V>bAei!6tZrYma`_|yIRnf^eCwzjD*yVj z2KCU$?=}KRR(El8(d+STn|X)?}(=syoz`s=6t9#pkxzrB$K_Eicc2ijHhNMRWrOq1o) z0?Kjo{0?6}+(|y0>9A%n!5(%!T|y5xJt|jO<;2FRUVY(0eQo5G@n_MKUdiwWf7onl z(7EbD(x)EdNI(W(&2iYNN7Gx)k|H!8t%|tlU(F(G@hAphH3O3l8OfTIAQeQE?uPyL z*czXdsON&*M%;BPACdMl{Z?tM8eJvUszQ3`kqijXcMo^{Fn7l8l4!>^Z?}8fsh=pu zt4bWkkJl?1KvoDlV)m~E+Ye=`<%JpRBNHz5?_*v&?9?q*bU+qVC&k+EURQJc*RPea z`Ck4N!`;Ut#=L6JL*vj&0f=0TiYT!uYD3`J)R!I?(`lSz&f4448|kl~EC2m^l}ewa zV5N|0)!jAII7}GvP4yu3Hfw06^dtvR?qdNl7jr+4 z{oB(A^-LC{t`fZ=lbnW627)GE&rB0>E<~I4&etFb4&cnmHNEQJUaPpczz#Nw+n_yU_nT1tIL2R=_dGF@(27p!I*u_;MxEN2tc1elEtu_ z8ty3|oJdwQUSOta0|;CvAZzBXDz4IsPOBpxhq$*u!fsNDdU6nAPbU#;44WI+Dqrh8 zuBa*q3fif}1AmXD*n_)jCgV#ENDbUK9XTLV^o;_*>~UG(3%O27UrRG#sp7Tab%Ch^ z=`29>01u)Sfk8l;r|azG4Ocm`No|FjOsj-4=x{O4Kz>CWAcvo$Zqyz8RA)Wb-pivp zr2c1u(*nuZo88&@oHLKsz2jX>VLteKhH@pAgg!$30f$g@-ib7me1R`4HeHrEQ&E$8 z25*8fR%*yL5M~IPigF?9W1RM4U5lGwQXV{i{L=(W8L;`cv|pMm1F;f89>=bqnxKX@ ziZ9)&XulKU2{$tA_MT9Tq-vc_qHjm$?|1Te${2;nJlmwHnwCx%-cTSl0AE;nItWR< zgNeK+ZJ0Cm)R5A!-y#orfA%XZa)%OV&Wt=$tg-xxH3UVT>FB0V)$;*lHtBJbgb{*m zW=dlzT-gXgQLI05DesQ~D46Rq2u7*~Be}BAa0n(OvhVAEF>L3q`efNSN#$d`=09ai MGh5SoW6y;D0*D)RfB*mh literal 0 HcmV?d00001 diff --git a/brand/icons/gate/icon-32.png b/brand/icons/gate/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..4add43fd969e40679d39b2c1e605c3ed3403e72a GIT binary patch literal 507 zcmVr|+F8lJiY;}0gyr;~Hxzo? zq4zkUv3G$ObfB>WGNCYD0|%hd=b}uM%YwjLbebU>OgnQR79&!g0HpC1qsIDQGC`+OYSG~R(8Yk6hH{l3%j7PBmh7@4I|<7Rn!LruHZf^gq}8J&hB#L)q+oe ztrY-3pW-#^gknXjX#P44>(dZN`g8B;r9EU0FVx9EHWK~#bvl~!g!9{w9A3NykJZ0| zY~%^qn+J&YZjdH(@kgyjeE|UQvbql{QyBa%2%Rk;Mm=<{F=!`;f2IcH%X_5U8_>Ha zVBDMmv6w)mH01LLGDp{tPi|{x$Au=u(~6ZYC^@_k5~Q+D_?^M<#46-fyP@*TYR82q xFXx7YcNkH6d&$aXB>y`Tk(x7M9d-OV-T}~is?3~NyBq)j002ovPDHLkV1g)9+iU;; literal 0 HcmV?d00001 diff --git a/brand/icons/gate/icon-48.png b/brand/icons/gate/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..1e109573827a5948fc3b15ef2ddf09f57bdf4253 GIT binary patch literal 747 zcmVYJy;>;sTL`1XDswna+1qQ109Uv_M@S(N zOtEQW7WuW_RVaO#zfD##*=H*GY!StzEJM>ryB0L3UXTxiGKVE#tHEvfiqus@=sXP8 zTL-e!1tRd!V+p9A<{&?fKzTU<=sKMOg!9K?yVL=2Y;Lh}SAug_f@^Pt@_Gu%zym;5 z=}IBt+)>!B`0~i-typ^_Z2k_qBEZz_fUU=uxmP1!APv8OHZ==?fN80P*y97U&jSG9 zyN*B_8UZAk4gvhB1At&mrHn<9ym}wde&@Lwj$*|>2*>bcsN*w`L+^o`Udt2UP$(s+M zJpVvJ1QZo|eAS4AT>ugJ@~gz!x5dmgyMIslRbu7U1lWceqaIv50p;!IOreUOypI=G|}%`oSIAYowG@1XJ&U(A@^Mt%1%6=nG4b?gk+SIt>vm zwXom2@aOTd1hnaSx_Tk`@hCt5@!IKv?SwKGg>?G~wAn@anmPH=DAYI8;LjWc+gcB% zx(dWDKwDUX`tB>_XX8*KGjzAp+V>xj2139<$oiqN1Xuzr0qYQu_9GKbfs9vaCS2iu zEPH#w#7RMalquD4HPhTplV7(xjVzxOCi$Y-eB@002ovPDHLkV1lEtVJH9q literal 0 HcmV?d00001 diff --git a/brand/icons/gate/icon-512.png b/brand/icons/gate/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7c41ef91ede771e7f299e094e58f0e2e52b6c0ee GIT binary patch literal 8836 zcmb_?cT|&2)AuF;f`AmMN{fPmdec#)CMv}WBHSV(1OXL6z?&{D!AeI2D^d(y1f)wx z9svPq0wN%U-XZi5Aj!A!dA{@h{XXZ-IXPGM+TGci+1Z)j%qH}bvC(d>eOv&5-Dl6} zUj_gPe<>G2SK1FMIOA{tE{<5wZ`E zNYdz4_DHVGg-fSiO^{w3Y0Uju(CoS|=W@CAL30I#VqCrimz)rq10jrhw4aUV(P3O2 zzbc>42{WcZk=^X%Q0(|nAysqx{?BG4%Gad2sy&FXO|Paea6%^juRjF`RoDjaG#6Q3 zOaT|-BAt`WmDqrgANYa;xO_A}7i}8mo|liaQysiY~Zmpjvuzy z5RVZ5ZrGB9c!EdyhP6JRh=L+G4LEZI&CgaR1j4Yyxg1_#tPci(B&e4Ly*DI*oCGLG zXu&>^+pYzSbHDHVOtC|Ymw$gkla8!w(3b-U4COIC23@Wz!HGl?NAreJn!DLf5y7(x z)^B7mKL(7aZXVqtDr49!0Q6M@7Yx(OGld5$!!sp}1($%e}ynqlhw0eN7%uG>P|JfcvGmxB1Ld`o}bzI*U zx1LKhhKtUOlAT7--<&RebLao&&v{0OIWNM@=VC@Zc_2xnUgX13`(h|eeQT>AX8R+6 zz%94`8r6F&<#^jiCHo{MZ$HL#M>~5d`eljL#d$70Wr1K@i`>(eyOH;K5TT<yq6sZSEy;UXEFpZnBunq&Mu_cM7$pChfb zz+EJ{OO(6Bk1mL;6T+1WG4ro62DYnmtnPx&8)m41_`196)E|kw(;Hbtec(v3VQ4=O zLX_vrC=YolpQr4Zo>atZp9+=p96 zA3*2rk;spe z#LkW5cP1!ZvuvLBq8I^iC}@aKSrO>N06AF26a8z!ibP(VkucL`sz``XJN94zFL&&k z9H!NXts;l}(xsgNi=Bl@^e4LUwButNPJW20NT>XhKlr(f^dwr1nBRJ z>htczAGHR+Tahiohnn=0RO|YhqXY4pXh7(!D|rd#uW^b|s2AAB{{lrvA4eXegc(AU6D&AZMEu;+pzu9wT?cnAvU|>|6M1VD{{rgp z)uJEZMz9mlR&7gD*vxhiKi+VNxoQ2pMpWbmv4gIC-Xlr0AY$7!(T+v-;Di&qlTrNI zB}*n^;G1h_DdrM*-BCesArRZ*d6%vx1LgNV%mIH*b}*wW{3+!0&s%-ly-W>HhQ{Jkr;?zBkn3&^`Y{0SXzSwq9PZFEv_A< z`Y^|YeSMf9p_g6EMSP@b7U}up6iyQO(EqrTqOCm`pmqDaEgSJyUp)bM_L?E@<9RYs z9d_Yol)?1(pEXfL0D$K;@H*nfi+(0ZN33kg5_yRmSmGV%od6E@vJt088=0pDq%kY1 zlKfKWZiJYdE)fGd${v#|Yv2Irk1^m8joGn?4%yL@dfn&+w1B;i%n^RLVgvS1_n!!Y zfrDWn!2tbNE(nQ4075G7vP)f|_ zpc;+TTAJ!OUJ>GbxA!sXw!w|I)dMqm^E=Et@5Rn$i3wOSu+JqoP2%D7vHTaES2up= z86}2pRYeqrx&c#HeT|XNW;q+m38ZjNH;~ zAyqqCc2g`xBA+HjIjT!uf( =p(^uUkel!cPrmgq51Fe{n0z`b3V9pVyFS&coh1 zpJCgYo;etR?{+O$a^ikz$YUYt4CmGD-`1x^HX72pMU7PLov0IUXHcd=UHPnUr^E+C zdMVXt;Yqz|RZzY%+}`o|TLdS3oNACJjqz*vS68S7Ji^Ao(H19qpI0 zvS>zf-`K@PIC^p>*|O88sUjtanxYagW0DdyCBwna2BuTbT?tv)yP7h!V_~EkSnE6Q zXf*syvy<6QkSfM2M!jzjz`o5s&Z3FguFs|t;Lg#h$HglA^0jD7Vki7uc?gS7O{*r2 zQAi}vKVO|?#|UO8v4weQ${z)x{-~kZb#G)ZYk+1 zHQ%o8wj~}0XDDwN)!{=@_4hq^46X8P>!<0z-k9q6CW&o5l{W02ZGgbU5#LfnA(a{w zGzvY8dA{!xgSOlS-cg6mEFQJbIwycp^7h%yj8kbJ*z4LlWgVRsV;DiWmmeepT9-qsN!(Pe|q|i&*ODG=7h) zW>Tdp6dnK|=sQNX2(50fIoh13-EZrl$@YB!yTzDP5D0BlJ_(4{e;+m=PYnsDjq$07 zNG7j+kV%=U?Bapo<&$%C{JQndhJ5unufOc&4PnR0`x$G8WBga&0da zZHMN}qNQB@<2+wfBDak*ru^Xd1b;iB=tO4>4iHtU#kLpsR*5D!$0jLSwRCQWYQ%E^ z3X=GXQOIB}wVg@d@Y+dAE4!h*?|f23Ls(De)%-(mYnXFSzJ$Dwape75h9ha)Ig9^n znvkiYfoieF^Su=Y1ykMf-`|NmMr}mZG5n4GSsW73O{nN`9ijQ_Pvj^?#~gL_B*_3V zq@Ga8KPT#dRA-4*PnZBt4ty4CsUC`5v=HF(n z)|e?8E`RDX)p2r-gIgG8RGmC_d?2fGyF1e+&|kgxu6uzD0LVRld2tPFi3}|tpMzjc;=?U9gMiCaOX&QcQ1TUVz zNq=KsHh?+MmqTcg_{YecZrJU5RYHdRS#vgA`uu{ux744N_hQ#K zF9}7z6V#;;T!f}tn-))qaG7dSQ^m?RkI3nb90uP!_UMgt$R~K~r(^9G$_?_DBib7} z>d7tFmzgP;d(@PC+D?nzbQig=;Fof?HL+J(vS|$=hlL*MN|~6hX!ZU=>AdA%g(|*Z zUfC~=kwYe%y(HB>_DBy(#Sd+jWh(e$;-x=jNK6z)`7mSv&gSnnO%<-f+YT#*k&x40 zo^4YpCAN2rqFh${cB^a#=Y9QVk?Tyq^T=CjyU^wq9!8{ZDGm+YnUz!B^TKVBr&Tsb zTI@Huu~C^nwf&!ow~8?`k{XBc_%~(P^QwWE<_w9&aA?N&yH-UrjDC<~bNpz-vH7$F zd`N#J)w}0vZ?3h_LjtYXkvGa=*5gH`q8I|Wd#|()slfxLrCLf{tR#drjAfU*e|P4V z3i-l^Ps}pL&3t|+)0aaQMIyio33@}d;$8xMq4kYj_bjTZ2Q=^iv z7w_l5uH^~*t7k_t#88a`yPo6dD$)G4jaZ*DwdD@|yakz_q=JR<_OH8h+E8^VwD0A= zrNSo*#qBnw^O_7rhYzARSp$2Ih>&*VR@IhGpD5ByKPasdMuq=X z#CXb}`lyv1b4@M5*8a#;3OEtgSmY*EU3SZ?Tl%V00*r{3ZMMdY^B1QT^rJ!)sQHi%Iux^C7|g zD?vR1GvtY&g(o4`2EY7jvk&f({w<Fsb`uD2B6QC z$%kjOv=NFa4jKq4yzsg~%K@$?X@$0I>{?tt3IV*Y)4Dy$w4v*znjf&ATZ0|HOj2!} z_%ZV+Q1Mrvhtuo=v%j6vHfY0yhLUxp)r9c0bk;;fiyt-@%-<0%eZbZ%Pd8_hw?Cs0 zoZwjoFK+#3%v$-l9CB|)B>()!{wFhyYOwrG(>O8Y*G{66Vc@bo2T~GDS2|i>*LLdPe4>*SSL-lR^6Eixksax-l1{M&u3+bMy+C;NILY1J2 zUh!RYyH<$jq37vE{IU;Y?T{@OoJ(xt1KAqsHJqOu)G(~&opg$i4~nkHo)x-v(F4O$ z?}sbV4Qg{;boYgt(lOS~y0%VaoAH8nk3Y)E$q)Fu9G_dJ_Wu`?W2q4GsR(p7&0&vb zd$_#ppY7G|k-i!;+iv35VtS%h9Y6uCjwM;X+IoUZv**3{XR(|ZchO|u@7K|YZ?Z}X z8QDD)yg)q-ohAyR`mqPj9(ddOJ-TOdRk5Wlp$Va8KO8Tr6?_s6yj8*P)vF({*uESr z7y25S#pV#W*Z8k2a^R~d?7wXwV{)st(^r155z&&T_v4RQl=jy>?V>-ItTYjtoS(o! zrRrB?ATD1CTGv4%p)utkhD*>gPV45_(=*4A|K?bOH*JVr{(EBnL=29lpA5gTsw~2j zW`b_tADS63Mx-(Jo>L;c*G0eqq*e+$bTf%TqQjEq(CWa#0&-_3_YCt6Q6^<+M^}Oe z>fJlb#^bhrO3dO@c@ff-5_#Mf@=|o(qvuKH;0kUwDaCX2y{XoW0WT-i+CVw5Q2PFL zYMPdTdSn1quHPU3WiRm^e--NHsxRVRMy2K5s=}R zh-!L#h@T9KTrKN|4fGb;ST={E)uuY@RULQgCC7%gKZl7}U{c`Ibr!!aGsx@rJ-n+Q zp(%oaNq*<`zw|EieaXzTW33x%QA1tMB-0&l>6y&DFw&=F4DB&J1EbWrJYUl_>lIhI z!w}zTKB@6^N!K%>t)-Btv2JZ{oYRMmD2;6))is3MvK3VAlh84qokjjGSD)pUZob0l zJE&rH3YYad+ul@XlwIW>jonml^@Q9ZxUWwXYtyhTzi%I@S$sWRvsQT~ozIiLDc8k8 zq7o8?>3Bc9&<1&6*Dk2PEbe>l1(|fevm1*cQw!2`d|U#p=f7ARV!z~!;wK6m(1n0z&0=7&|HMbwZ=+G4kW3?!M{R+Ysbm~YMSpP_g1Qo24Zd7^sc1U*Pb5*h+TH#X8Qu>Ln212m2g#yyY2M4 zMZK@7R)snLJ|0+Ni05A#do0izGGxMq7))IrHiO8O5ZSQO!vRD7FJsQlrn(S*Wcj2o zV?I}`dYW81{}}oWWgAY2;39@hk_6A<>xrJplj-lp81vK=zt#kfho3n49W58qx9wJG zuM9jUv>tZefEmZ0_;z|t7#2y~PfY0gOfb0X*DI>4`tz_4U7W*ydNkW^m9RbKs8iNJ z#K6qx*JDzFyY*c?W&!r&ZcMyWuR_>GH`;acylecOYbWINuR_!zh2$`@n>HgyZ7J~S zx5KmUkldTB?_I8b`qhp+G=aOy(0#lv5}sJEk2at%wR&58e(>IGcMTb72`ZoHyeNJm z7=YNI#X{d{* zSwDI?tjDx!#ln<6lG7>S-U#uXL)QL#blv=Ai<lP z@m|mshi&d&NcRDxb?}4~Nwuul2SE(9lU6 z*<=G&Imzmic_$iXI3GJh`X8W4CBwlXOtY}jN_vVXJpEdW$t*Oh6}TTC*jd0z)@EVF ziXJTNJqwLgtk`x4I`vY;$Pp!dS+#V-JB%-;?=t*g|HE)|D@`KwE>n7D5OTzRl|dC?*z3G>d=pq?Lr@T zqj&K6Ao_scxtO@~0x#_JvxLs3eNKzNWSCITmPObUO5TC54q7|khV+>&ql-S>2V7(6fsmT6U4IQ_qQ zz$)*5vE-+Ec){f|Lyq^GyZufQNapr9-_I^S38u%fOATK&ki_4;Xfwbrq_YE({Q!NE&Q6p} zTs7T9loO5)>!2S!ph0dg{pPP`6!XIJ5?NXRQb?jdl-~BIav7iwo^$~1h}c+bgLaX zIMa^bWEU)*A0hbHasqAZB`*Xh2&7Wl@Zi_TD7jhU`6#>x@L}fswMPgXNa9A6x^mAz zygxO_bSPnJ-$5kv5)}clf*=X5Eg*kM@?+vF7-?QqCE)pwD7?U&u2din(tRw!>-qaH z>l#ugc!@ahETM$C!RT`!(|!7PYItjT=ea*s34ZKxd4zcVc@#Tztj~i9uTQ!Y!C0%t z{UabbFq>gziAr84ry-Ih7hW!luw4@c&lmxOer~2#K0OchNst>^>O)oH1gRFArB%EY z+oIr5z{%mO24p{&0whu0j`6(Sri_A)+>#bYGsGA>Vn485nWC5WvO8KVQ@S^5cyaT; z&EZW1alV*Y%6H;6?!LD?3&T$k(iqZ@9#=r{Swuvz@ND@Wlag7Z_9E z5`wgj%ay`GY@te8-^y8W`^tNqnD)GN zN#~ruuY?gwf75+#qA`&V3m0M$_Fl*my8j@+P_%a7m&EaJiRCVxls00&~Fpe%Y6?vy7GbJ4{WZN44pG`d}wf$I@v%FDQq6nrNw3%1PDFqAUqYNz|*|Y0ciFk+8%CUoBJbyxZ z@8EcbRd@|}_9L#gt>_CIB6&cHB2J+eEWIOWpozI;PWgq0rR%p&<`gj-x9VQ+0|hpl zjaH4_&FVxJKh~U%>s~JhxOpCcj$Mxl{_KH9HW|5_PY)BPpJx$66uJ;&x+w9ArQ@fF zf88CaC^R{lR@M{^Fl?zMc-)0y=TJn!)y-0$-7WpqjQjG8GXK4ouJFz}njyz7q9RH= z8oHqwTp?tK(;F&Y9)|pvEwV)vpKQpzAUF*#yQ)Zp+qr+IxcH`Ta)UB%pk3}i+@^O* zo*xKWF8NGo#Z6eyxWFk~x(KK7pKU{g~Da4xqK{ieDB5 z<%(=jhQ$qG0s_;xA14O?Jy4Ga-|lmYDe!Qz)#m1IP`;A#Sc2_FAQ8_RAFzI(s^w)v zj(`<951~{j0FLKpJ9JnABxH++SJo*3P6WL9EPhl9QMmb5nbUGjuOCd0{NXATtH5NIG4!_uLl8F4InCm + + + + G + diff --git a/brand/icons/icon-16.png b/brand/icons/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..b3a4ee1ff49f1a22c8a11bf51e8b9d9cf1b3c226 GIT binary patch literal 162 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`nVv3=Ar-fh6C_x_uzF3u|JPkS z`N;BvPd@qVXe;MgSn#oWms3v#?~lNUANSQfnVtX0I%{;Lu>pZv&aA?n9I?i2j3;LG zpV{5G=tEmBbC~nY3CteaE5%kXlitS56K`jeuA)7~oe^l&MOjtf@*^*|0WD?lboFyt I=akR{0I5MZBme*a literal 0 HcmV?d00001 diff --git a/brand/icons/icon-180.png b/brand/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..b6b498f87a73b27b62bb5a9c6d38e24677550792 GIT binary patch literal 3353 zcmZu!dpr~B8{dU3j45-sF3SmJY}tm8xs>FPj{B`vjx~3($z{&wmLpMg%0e?LlH$}R z*IepIvSo4$x%I12xsoAC<-XxN)4ib3+2X(HxM0$0?Z4@=!1}Pg^MsLgo~7P>%|S)lx)!Yg1CcTZF)Qj^0WH7BR_)pMz7iE# z)U_9^@Tz)144zE-0$4`y30@gFk_%9CkO^Q3uzHYB7%>WLhv{3TJ%w=v16N%gNRZ3G z)~e@CctoEQ2K)?g#_LS;dl3Ja@yQI#i2vN)5EA6`em7$D82#eWc8uCCuEMSUQ+!P4;jzP%9HTt7QMnql{=aCE|7*ufPwSv@3**#5B zjUgwxTn>fEiAO7hkdDec#}*491@DBHz^MB-NTWC!(cRN4Feo0me>rMx!97{fYO>warko_{HjAiXAD0g)3v$L5djgShFc~EXYP&Q9 zTzr|F6v|0>hbYU;ojNdkd0hK!^4z}>)q+K6|E zZu|xg146MU=x}#ZmeV}{$D|*ZrA|aYH`Dmj-i#a|{4g{mg}T#jpE*OR6VVuD10h0e zv7e(!W*)usOq$g`mLQP1^WA`d`UCbKr*r2?-Z&6-SYnO9qSg$`=Wk6Kk z)AO3)i(e&l`n24&Eqt_7eOBoLHC56!<*kh#xm>drt;=hqrr=jk_NMV}(gq2UAwByZ zbPy9!&t?!OB&a*Jr_oQqdqxRC4}Y*>;W4-44tC{q(egr}pUzkNUw2tS(zbFHyxd&U z3NtobBO+XwW4c%OVhSsbMuN^Sry6h?-$fRFFpZv!r0Ab}W>Mx3665^TXzXT;EHxiG zbX1gD7v=F09%E2!LuOq&oJwMUytDxez)OjiV+8acX6jXCMcYiRd3P++>X(-WFAam8 zvFmpfWfquo!)ZC4Zwt(p2l1^YGY67SwCjKqBw8s${zkX)Z&U%JnVHtTlVZc10M(}F z5z88JIpz|0Qq7;6`lzWH2wi*PWJ-|KJ4=9%aBC`W@ik2jtqi_#1ZWz-`WmQ~f| ztXlu~q1}P(6r~FFhQlnJ_qXB+$f1~EkSAxHAT0R9yir8sU8#M4#hBy*4!0z8Pi{OU zO5%z=%V$e1`jl5ST4QIOqQ)S0N;ji5+lm&Ol|=`RU>&y19IwAsgeL4gbI;AO_-|7< zdC{v-8F5n`ey=z*s105%F%{aCFTS?;)=TYOk;Sa*(qCt4ik2KxhexdkUWK_Fhd&*E zkBBwP<(j2`nnJ6ZCs1k$dn+zfwT2m~JLYRziVUV32H%WrdZRY}8p^(pLNdPeGF1cj zJm1_kKD!bfkdSDF^PwO%*jjvo6x!+3nO&dGj2eWN}ewDW?Fsr4~#nLdN{EgykbQX2i$x-JIKpk-Q!B6PYa;N z-WnSh;_BLNFpoaf14+N5djd03WE@+}TS(k*_;m`+sZu>gKa+OaA{46VU@d-cx9deg zdx_B0j%`kP%r^hLw4Eimi)z5T@o#yUFVsSo2yYR6&turc1^A`N6eV>LCrmJQ(PBJp5DMu=gZN_>}TBhLBU1RPc`Fe}< zPE%W*xAr-0HOsLC?R94|I_On*Coc|M-P>W6UJ>f%`F$bJEb!E@&M`O4N|xkg5}J`P z()TzNWWWkPn{%zB>Ece`ICRH*;xDCWhnlQcll92M$Y0`XHt?lc3Kw2lF=^l3u!Y$N zyIkOwBK)FIzRO{6QW-im*Eh4h&^A>>jHEog+9FRbY#2{I8zHtlgBgS&Vt?_WRfMy( z6YIA2Rw;%Vr?4wkv4u+qvs~s+I2;dkPM8YPgWCD4-aL2u*=ln`L+Ai<^jjl0UYoOd z#hwm#XNRiL5n>;OgvkZ9iZ_g#k+1G-=JZ#lNqElWxon~5*tWA{buVTY>6OWl;`R+L z!F9%nRJOHrTf!aMFK%Kn#I)nu(|iw5HkNu|>aD>!!`WM}gbyLZmNN!rT+Nt zpu>HTKewg{9K!OThMm)Le>PO)+%YD#Nl;Q~rxR#1I+CDsr$N+*(4 zc1htRg#Zwk@k; z?e9AX@f>=S+PZFSH~_i9j^Q39%?TKqhSuPhQRam6GtQg&0J7zgiu!mrQ^rf})-&10 zpred}cM9Yi@W!drSDEpP>$hJ1nujhz9DR7G5?)^YYHQ49xIlb5IB39dDyHZ|_SZd5 z`x8f^MaID=TO1PObCZ)6YJOGjM^^qmCV$z)tHgy>jZ1~sBIlko*2L_neK>7#CP?W? z+;r6G(ND9~`dM`v9ZDI$t74M$$K~;f_0NaHWnYBX&RN9Oe2JK!JFew%dh|a(^?VnR zrBAIN1&F$^R&7oH8C~2R`*rGpUn$ZuwJ1<=c)BF$xldPbl`r+VcPb%)5y;guas+&4^D>JK-X(H`GxP)EV{+vD5= zU+&R1^8H!p!XF5=#U`G0(^EWml^&6jdms$0`Tsi&yV~25dY$|8MEnb3ctu(1R_Wb> zKMA!ZhgQa7Q{_4yNV^gUg{cOe#cuhiEQT{7(`wQR9>S3$i#3U0o~s#IRR}YYB7MrG zwX6Fm8AEh=ApniY@lnLJ!K6q@B0asR$pQKzr2WAIHA>3R3M#8_*B4EC5fROcTm*>w!YR=t`;&Te2pyNnmM2 z+i>kgvbeUe*jE#8rQNku|A_`U2FU>?q*Fm$s553AU_dl|_TAk`2;_^jh+ry;l4Gt- z@q=;U91wL2$!4MOCIwVSjRegG2!vcEp^sk?OET2VJjdQH`-~eK0YukZ5&W`1Hlh0cQLSC#IFW zkfz6c`fR2W#D!zv8jBN{_50F#asx;K^Pk!yTf+!SAR+ivAy%K?9D*raN(C%3Z!neA zxYfU~1nCE-X2*e?^~CZ{eN3-!%34vEmK^ayTxIg9fY%X~J21txwW}Xsolv9sYeoCw zyFUixx#`Z=H94Qp`+&|jbsXCrKl)LoGdeHOs5)2YR{P9ci|=3NwhQu|T}By>r%>@d zA7%4|&nNA=fiNowil?BDaTvIV_3I$Vu@qljR^fuJ5PXtO)i=_Ui_R_B)KFhVZ4=x68<>< literal 0 HcmV?d00001 diff --git a/brand/icons/icon-192.png b/brand/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..92351e1a28fcd565bb47991ca538d9a016c72174 GIT binary patch literal 1796 zcmV+f2mAPmP)9px;r2^jv{Uhf+4#syoBH;3va;WCUO_Kg{-sJ+<*%=;v(4x%uW&z+=w`X z5+OL{V!nFXp8l#jRi{q<{?ITz)jiWy{XFNq=c#k5A^-pY000000000000000006+y zsD|`m_io&-ePzSo{kzu{0}wniH$+AB7_%8F!pE46P!T@HY=jB%W6X_UGUt8!*V}p; z2QiJqn8soJ7vIfoUEJS#>Ce@QZLz_&YRC62{~Nn~mZtl#{BP{K55qYQRn+k*_D>$y zQ2rWPtuCNn>r4q@}+*-HbLSs zfYJwTltT3|N}zS_B!09p9%!={=qv^$ezct;W@Uio17Gz&(iowwUSQ$?W6Vstb1{&g zW+tfyzf~Mhfvvun2?)eSvozP~r;RdbqSeQkdjn$t7z30LXF&|wivcdL!w4VgjnH%) zpag;vNDPYH2fnFh5=_+p81pzl3<@zQU<{xPf*6$WF)kx0fuIBugTic3We~)mT78H? zqrLgu=MNx9{fI#^>KJrJ_-M*EPk#R5cmF*2RSb%8ihtGbLkx=2#ovwz#Gn|k_}epq z7!(5)e|zPSF#sS2MTkMo1_dK1fdDZmx@Z5%vv?a1w|dWRC&k2|$WZ*JY-)mLXN1%1 zXQtK1Ho~9hZ9I&d@p~fii$RgG_}BFW?TJ5z+5;$qN*8}xv5P@50P!C_xIgFybY?*e zb`gJS;TN{GqSg7_^~nRopQnCY8K@mYMPI+FGAJ zAO;6T7?k)S25o7uJ5=#+ zbI-3`UQhG`zf0udgZnE@P(rJ}DC+t9fR!zHF=(UGM#-ZD%J2pm=Z};oApR(HJwO`$ z6cK}7c8wk=e=;h8YL^6oyF}*5wwE~B_VozY1q@jUMOj75lr)m8!28G$64laxV5|u&HAf!rv z+9-cIjX_hL$%7a)8w0F!B@QktNbsrq|2Za3=Jq(4I=Ofg4RBOy?~20F|-JR0#OPn zS_riW38}PVoEAERW6sWNzBYX)+l~o-Z5@UUss5cUXs3GWZ zp57KC35Z1F`n-BUm?{t1T~F?hl@16|Gv{_96!}rzTY1GRD>T>y;A(x})=rTHoiN>Q zk3?z5GM_G;nBRBVL&qR?;Q_7Z1!r<&>?c=fN>5khY0|Ovz~IgM=MheOLFrQfy$A=W z=Q5TX+M$`ZLM1VEfkNxkPeEGrLR7A&eog4B2?;i(uIHrMNWjsgO*dag5}3p83JJ(c50E@_dH_65RgGZLDbmD(F_ nchi*o{f_9Td%`is_;q{&otMphfAQRk00000NkvXXu0mjfZ(rJi literal 0 HcmV?d00001 diff --git a/brand/icons/icon-48.png b/brand/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..219285a38ace73b288e61fa53a477655bacd71e2 GIT binary patch literal 612 zcmV-q0-ODbP)p=8rtcb`Tyn{$dxNst~5nNUnrKVRJN(Jq!R8Z0NajEm zY(Bw(jJUJKV13-*t|1ZMJl}T{L8xSyK)1EUb(l$(d6CF#aWX0yCI(CMgKi5ak1uQg z*#&w^cPpFU{aKSt$ yAfksG&R(TVIoR+Va_s&IP1euIa^=eP&-DjMHh(8?#&Ns=0000OfzD8W|Is(?6vOgSimbpmS( z)+)rIMWq6Q3M!CD#94_WC_{+IAP5OEBxE>wJM_N$-XC!DLvr@c*=w!6hHrg){}i+| zz=}DZ2>@2#1}<6wfTKqo7^diFApW%mz&`fdMGIENzwc?fd)%pJ(5z);UC0*43k;X& ztgPaaC6k<8HcB>bfG?v{j#TWgduQU_LjV2HWX2@o>xU<@>O&dlgvIQF5|5U?`1a<8 zIL+RWH^cMIKe^8oB$n@LKNB9ZCnNyr`fq=v*&imdUv)=UtWIW%!zisJuKgg83v9S- z2C#>~Hx}7R_j<|+U`}!2Ql>-Wc zy`Zr#-)Y6RGVSRSrOdNu+XnsZ5{+uNTD5+>VQNv)!J%y}nt5BgODi;U{x~^4o~pd5 zU(+0Bu}ycp?$Wi&f!ec|t{sd2B-pzQo3H#<{w!zT^nX}iKk}#G#o|_xE=2U_ z?w8lwIpz7$B$80B7q6es_;u-ze2sWLZ&mimlNEPiqZR;tGrF+pqX;)s=2Nz`K$56>ZqcE-A|~f-YZS^n6Q^kom8rU4HKj zC9w@|v;R2x>+!t`zso0si7RqD|1b^f(eB9)gf z7&~A89jQ%G*O{3BVL0yt#90pRGLL?v|Mx+gxH&ra0N}pM0;c_YZk^8elET@cR~%JFb@e_4#AP zaP@ig=e)El`Kxam?Y__J8BVk(bYG^E`GYm@47Ed5V_J9u>d6FI-MdQdl~IAM9eKIA zIz@a(Y35LsiSYhe8Kw7BG`lyOp7K97b|}qK!2P{Vv%w*G{&wuBEz2^6oSV?{DcEGH zb{)2*n6MO(bF}h;O2eRM^A%hyoNkDDa@a+zGz%?tZ%#V7@lRIqBQIUuT63>EaWXV} zBjD{gW62bxfPMvZwsi@Hzwx!3MOY;FE2+E)JO0UCpG41N!V2Uz(j^SnfH2)LXZ-74 zir2K5L{q+;J!6U65OZfBxaqT*RR1h>PtDv3Fxy*Kpid#MtLU#^yYO5682!_%#!)cs zAerzaC-~qrvM+}!a1)TT-LN;odBY>h3gFGCyTQ=D2A`?J{Og&;)!!_F&?yIzY0nw#t#)Fj*y^WZ zH31us##NPTIMcx~r|M6I`7MnZ8@Yl?#1l5qi}B)o2C9Dr6ysTcvTT`Tt~=a2~sw@d{xP#r~{k&71)hk=ll;jEuF+_=OB zOT+1*H{#xrb+K-6V!QjpBdIV`1eX}Vq(Nmk1Tmm<7$^4wQDhGj6uZttXAe$3SO8K3 z9sGc-a)O47wIOIyv7RT&-&`p~wQ{x0b^| zEGBklK|&P>x&O~`@y@o;A!5J^E^OWpEqyE)NW_TcAgr~3YaeEv^FtdcmxXlD88DOr zQmuQ#6#O8tM)kGyNh-tpKk81PATlr*mri{bE{kNNkdWn3hJXk#HN56s$m0fG=p2x9 zxgZfe;?-_vf%jNks#Yo{(>k>{xe>YY2NyzQ4;vr%=lTL)t>WNUW&w@-p1F{L{!CeD zjIJw)mCMHC11VSw>FYY5Ay#s}rr9EZ%`(LX5-@-e!`3pK2A`JAq#*~BeMf|@I5{8@ zc`*WKFfbyN;tXhCDCppM7*#5Yk%`!n1-8VZFdqwo0QNbNy-^sB4J6S3STM;KkXUgp z3wnn~!x?NOUZE54EzcDkgHKha&1p?;?it?8S9DnD-13WrQ%XS0*R%QR)+TqHOm^YE zQbuob;5W4i^y{IRMl1gkP-_n`)6#Gw1bZtQw!!!r6Gq`C;lo9C_F=>>4rC~nBTSd`|vMyGCF`n5Xn;a=_=!u z>T{7=RA76@l9L!8eVP;x`c*5eRHIkd*_dGm+qhz79jY%EaR9KXgbK(iMH$Ceck6ND z=)NJrS#>UwC6dKeezq~IVUn}A*r8TGIFdYUq&wa+NDjL_)m8!;DAG;C1_OF`g{Smr zs#70;9ggITlm}XofDsXYYw%c%)ho;_1s@LeM^t}DI$L0S8&sQbS-R)W& zEn3~J!pX`BVx^~UFcuU3R_)3(LsH@~Vjd0sreK7NQQAnFTM8=Ydz-Q)Rg=eymn7d8 zc#_bvlLaAqji4S6qCvU{)uF5Hh^*ZhU%cOyY3iZN=pOX*Vxc0!B%E2|u_&I%6UcLZ ze_Ks>S)h$t31$(-nv}>!yp-rm%8_XXoZQV|Q|Y4S=`aw+@H=Omec+X-U+u+E;8~{f zY-jJ3^7=eu-Q;NaV?(iG4()=aA)Bh2Q>d{LBqR87ENqD9`jJ73i@M!5GIp;#p;5Qb?I{Yqj(u^@Pqp^5z zn(-}B94qU5kS$;BKxry~m20*dDRZC}S_@S1=8*0^VtL-%;8CtL4-~ln)uZeljIiLw zo;8H^j7dcsvT0Bh#F=<87)CcZ(mt9^YOe;md= zKHq|H;qMVECx7qpP!|Pmz(vxz5SYd<5GwKl|FdO@IrA1kESiiLZ(=C$@~ z2048rSegWx-emCk!~lW8eDW#q$YvZrTL zE_kJZL>6S8NOdYu>oZR`JTqzgV%R~iZZRSU_pVYlC^9!m=P}@bqt#Dx-lj`o`n9RZ zzSHwGmA5rUd&W^qEbR)=I~oK7(T6^IYK;p}hY+#?Q}RVdJ7z6_Eg#J`*dCCJJ_KEx z$5)p=Cj~ph$pfVAK^9-_IlDd$?P(+lKV=wl1lsPYf}!WU4vA{wgbzKLydAMpa~e{R z=w;557UwgTww_q*AEZd=<1+D6_{ysTiOrEzmcCJwyQ66Wb6XlMY+>V?=vtPXWs0_ z7v0FX$sLPAB=41GrW~r*B8HXawijtxK%_`}5vpU!2zEkyq7RN=(OB=-TY+RUX+ z?I=7w-Z>K>@ZfJDxTDep5jGMx&m6`&^*HIvj(_Mt8Oi0M=F0PDMgp9Gkq>V)MypL2 z5bm8@Dt0(M~f%Z z$iyl{ZqNwf2m?-;<7DJQOp5qSBGhRg8mIkq4mV8hR-iBW8e5W&uNpl|P}ALlzfY9Z7kH;6*DP z=paNn#k?B;!VdI1!mhV%0gC+-~sh;tP)f!y#PK74T@pxP1k`?@me{PNuJG!u*%PZLi?S#=(y z9dxKU=LJS|_coZ5-rxv$;d?*5=e%&%nfn@exs@?4TQ+?XazbEqQrA$7zrfTKVfuBdv!67#yZf{ zvD)m^N>s-@cxsA?zjuMZ`LB`tez;T$X+Vz35G7X3vx5F+zPybEzlBrTgQkQV>dRzv zG#$|zqxVkqd=_XI!5W2P3Ql;T`k8Oj@AeRrq&W-+5-(Ee?pQM0TWi0idG=Dvl)|>Rv=woP0M9 zgWSQ|Il3KKs3|V(my9L8tOidyUjE7b!5hI#J2K(puJg#)B8jNn#bX(G&A+3@P@x5^TU~=aah}VA)8uhO0NHO6&+KyW(eNB>IkUP zEGxx}wHdB7DF1?#o;7JH=7N6ab}W?MN%@ZjPi@oCPo!mt;ZrbJTVZ~;>Fo9^A~uB+ zOCBs_Vz5NDP+};#>LN;cfsSNFCg^7D9T(2L?0+!->*cLCF7>^C&e5$&a#AD>y|{B%^&`I^RI=s?ygV#uld9bHQbk56?9v^MCcB^rKtU*GlcW$H)u2KAT7#J(qW+asBia51{3Y3B^} z%LV(=wnuL1+7T8pw0F-ns_S7z!<-uzdQQKufBSH2aKPJ!!pl}Wjy)Z7vEg0eWzW~@ zXO$K2@rPre*U?L7GkhpJ>`QKU&aJZrM+UPiI@UT}TR$tDeQo`&D~g7_&#v3?G42Z= zXe;wE6cy@Kb@}&Cs5azSOuBK+;neoJdp~-XzP%r{u{-WgyR`E}9`d8i0{%Vb`3+wH^?5r!piz`b$8`^ST zHWpqNym@dT_QsJ1$E=-tZf`bu zSJvGO4p`e*IId&)t)zxIbs7C#x#StFB}%ugI`MqjGw1s+8(RIJOxyFmaI1d#t-hPv z5-NYYp+9LAEt71F7S-M!oz*MSOr?A-^Sg2%z%JgDI@P1}o7=MI9j)2>?2N3e=k#S+ z+zqD~X;MUfoh;t1X5NXMF$>~{mmbx0#nBLup%e7>)`>x3peeP3zf6Ix3x=TGpeg>p z(NWM`{r3om{XpL3+yvWcpt)p+E^E;Oob&*Gr`DDR3qafw04y3 + + + + + + + + + + + + + + + + diff --git a/brand/icons/okx/apple-touch-icon.png b/brand/icons/okx/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..82d1e827cf2c4b8d333a1d29784266a766e37647 GIT binary patch literal 1032 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6U_S5Z;uunK>+RjMezzS&8ZOQZ zWR({4i2L~OeOOqS_EcV-1=`_yTz#@O_Hy$#yRMq`S z@^EU_>C^9@Yh*BT|M79;j=a&%A9du>y=Wn0+t~XWDOIzsebg}g|MP3Gq$uxUfqVDf z&+fO{CiB_mQKG~li@sBlCBEy|P_iB4k|c|N4CPiLIV{pHJ~y z8qd=8GbV8QKM$9U{OYxJf+s8&%{vsg*NoX&EETUPR^puC@+ma)O?B1# z%df7u)F~@v{pHEi@5M2nT|-{8e`Z^^R@yr@boPa7d|6*6dfhcTFvV=H_Z>T>r+twp zrdZs)q5EQKZThdRNnW$heV#D=TJQTe=Z}W<{cINcYN=k-`EpP6h3m_Szg>bEcGWwO-eTlZ=@7F_?cYerc{;C$t0y49cMUb}H99@#5@;qc?j z$1k0Kf9dsp_FYf!D)((oI+MK5Z0`52ou|?*m(~8dB+{Gvf7aDL z%jyf!i_1O<^8Njum32Hu<%MqG`NRKSO*JlE6l*o{)~7@I>1U2M-IMydb;rv8(+}ra z?mZz}d~rd3@2{XcEr)A0_Pd-p+War?icIjz8LL@za}?X<-p>hneDKo$ueoYk!qG=! nS%HG(l3Nb2^1!kp!;>%K|BuV=P`Ug3DM-xI)z4*}Q$iB}Zh`2i literal 0 HcmV?d00001 diff --git a/brand/icons/okx/favicon.ico b/brand/icons/okx/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..2dbb0dd42e244ecc71618eb3a3f87170687db0d1 GIT binary patch literal 133 zcmZQzU<5(|0R|vYV8{oOVnEy(;OEZECB+3~@_Kr>1OaJ~3IPr_ASwU(eJaFHz|E=l}oz_H9XSZaiOortE6y3}TMx2t1;#;kuK1Urj}y@g6ru=2W1* W9CiV*AhR$ai^0>?&t;ucLK6UhW*@Qu literal 0 HcmV?d00001 diff --git a/brand/icons/okx/icon-16.png b/brand/icons/okx/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..5ea0f5422de454851d400bdebed62f036540ae4d GIT binary patch literal 108 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`#-1*YAr-fh6C_xf`6UWH|NQ^| z-@Yx$&5h@)&y-yaNz4%)fk(78Tz7KstEo6=F)Jm5g@GZ5U7$1SjyzC1gQu&X%Q~lo FCIIYGAKL%` literal 0 HcmV?d00001 diff --git a/brand/icons/okx/icon-192.png b/brand/icons/okx/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..55ed1ded3764e19bbf59c00dc7b3f300b4cfb7e5 GIT binary patch literal 1165 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;U6z?u&p8bva+8uKQkIZWS|EG%U`SsuzzwepI7W`;Wk**Rj;4kGC`>+iSbNsB~M-TbFS=dE}Bdorab>G zzO>n!6mHG<_1xmmVS_t+VrD)1l%ccr{r)@BQvbf`&Gb{&DD7AHo!hsANpLbff z&5v65?~T-ft+g{k8$#Uw-+%Xi^F5BuTmiQ)+D9`aA9YGN`{4PG>06>1y!G1q>|gwF z?rPXM^F-lKKMCI@C{j#y$q6jPf7Px7lxRt6#sk zeqz5<%sl>%10SB>`|e+=`EPms{CWG=|NY+nU%Ed7IZGI-^6gu<-_H_ zjEZ-@Pn~xudj2EE9>ExEEslr#)_sbJemgy{Jg!I8l;+I1g2Rxn5t5h7+utR6AS>a6_kZbC)=b<7ADGKFNbu}sU`c$y e2rmm5c07~+&fH+;SYrPcBoiN>WUyZnp2vmaUU>2E)Gtv>Pth+k0uv z^KpmW4M>F77y*rVPQ6IDX*;nAkcg8oB8m*e7$-(J6JC1lQt`R9T=~KhSx)_zkWMBw zkyD(MLv;gd9^D*Heqi~0?LJ@zsv8JeJt^fc407*qoM6N<$ Ef=J$CB>(^b literal 0 HcmV?d00001 diff --git a/brand/icons/okx/icon-48.png b/brand/icons/okx/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..16a80dc8e8f0f579ee60b9089090a6955bcdd4c3 GIT binary patch literal 290 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtm!2+;Ar-gY&OFH1WFX+OU3#&& zr;6>#|Mk-Iq&z+>V*h$&iQ1AaTVhw7yVF>Ca>IsTC61;Ax=WMvE#lX`zqIV#W8YNv z2?qPvT#n9|-xhbI;&RzR0j(o@OxJA{V>CEc)A&Qd!o)1-+4Ff%yY=<&=15fqe%5pG zNU8MKe>K=*vY{NbP|I@?fu3}o8<(Vfb0*ClS{gn%7fTq z3_0>Qx5QpLH+xfh_S%;}ttx*9PK&s6eHzO(o2A<>Kl!pGsDAyr%6RLwN&MzH;gLZL lpZ$N{l6F2lMSvxA9lz+GFy*KlbFYDH@O1TaS?83{1OO8sdI10c literal 0 HcmV?d00001 diff --git a/brand/icons/okx/icon-512.png b/brand/icons/okx/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..e526b77e35add58dcf74c1f3b74e4f3d5574b522 GIT binary patch literal 3920 zcmcgvc~nzp7QgRhA(F%d>i_~u)K;-z!G+B&&lR0&rD|PJNNSJD$nLHz`Op?=Rd8CD zQ;ZtkXyLqN064wGfy1U>eY+Y>00O5)^JcHu^`Ps~t+d9~j>PS^Ml-*1 z5$&#`DA$8thov~-rwxIq_DB$?a%WQbGi=FPX6jg?&UAp>u2rl zx$7=rsXfHyiIcpUz@CYGgy8%N{zZUP&EVh|IwKIVnUFFIISYZUs1R_l(IVg_!Ds|N zeOMLXq}oo1yFKlGUus|&+zK($-wQ<{_+?wefl7B$o6U!E2P~2i2|O7j41EZO6b9)> zvPqpCsm3RJkjAUTnH+HZRTS^@9}Z_9}CwPgOwL0ojMgZGmLgmBXeHD@-fNaRdyXX<2S3 zCN7={-ooh{h51)1S~ZHPhN1-($EfmlEWG}hf(lCfzSKI!fu@A|A-a3Yvt-Yr1DPpA zu0!k;OQ(xvdEx1h=j>Ur^Ms`@16OS3^f?c>v23T>%)m=fckpL-Z2Fo89%@H6enhoP z(v&j)zT^|@Mg94g&kkfRo3zTh_C*1!crr81#Cr?Fv4eX<+=*xSPu4epymqpwv)?_{`EpdB=-bZA_x=sO;~7 z*B;x2Jmh#KjyjgdfA%q##(QhE@qB9_H`rXe_hN=@jK~cum~1(|(zaEhFJ8|`tx045 zl;*Qs=BKrKr)HIN+LGSYPpF(lO%JT69vAq0LT;&`KbCKA9CWx%O{( z_5$*ePOROixaes-@(JA$sY8>F=w{emMt2>15ggL$q(@ISl zdv2y{1hLV~#q%rfIW*Kbl5F+$O;)}P&2R|*VLIJoeTn*xJ9}z$b}jB$M8?GlF%D-n zP`qViZ^B(kjU%8$hrAvA8J*`y$22Swh8F+qJuEC@2pQp!_bNUOpT@u=)4;Pb}c*FGn~O==d)|0gTZZ2 ze@vGkSQUqqIEy^9irOkZg`q@E)BKj@^`JGpg#&b7dAGb$x(jJ6^nv( z{friN3mi7hMQ}lTl;oh6_+EF2n)yzHxfc1s=bmFehG1=nY5@TNg)DN!&Xpk)BY^#^ ze-RzNQwB5 z%r-kIBTskmf(XJpuZuYV!a+X(B3d029xB5FW?&#JE42@pmNdM99_^6>0@@D%L9ghM z|2vyM6}_SQuR#zU$JA4I9Jw59e|8`iee9f&%{7JW;}4?z-x1#&;9$hp&F5EugDi+> zoc~{D1_IMH9Sl76@o8_&*EyxasiN5>k4EyV=s#p28X&zV3}Z6C9-vYf_L9*;@BOBT z9FX9zUDH40I|${?$tQk~<8?hD+VJ;@{x+jO^YkaR3O>8DoY^RKW=oj#=k>=ffAic1 zS$h4(%DeBwTSS$`xh3v|74P-u&)DwAL0?!+)(RB|u4V|*=$gCH@c&!5)EgN>-P=G+ z?PU__J55|vrJTu2K(W^(w6$S)0F*Uv?da;+m%W(#5XG~IHQ?p-E5O!K=h zp9*2zXG1~!Jz0}Uacn$2TXwYa!nqJ!e@}6eVv^k_XU}*C8k$0FZP-}YAxKyY=(}87 zsF90mE5%QFH+6OwIy=Nq$IZrsiESL`9x9~T5#+U5)Rbn*_Ys6Mq+C#UsMx4+A}V6w zM~qF1b*m^`u9-;x;{~fcQK;455jJJyTTIr`yBQ)5)T}YrTl4dCZ4uL{BCJIhhDu`c z8k;$c*jAmyy!chC0#d(i8w33yUFfKCTMLF*U=hPJ$lBE)=dtO7iBmfjh!-cQFYw#ys3Tjfefq^H`7N$ zYI!==6m35o1Eqj!^|uxl*ov?K%{jsWV(WA}@2ajuXItG(+V=C}Ra8v5f?8}lcjz_YvT>BAsIV)QOsW;Hga(5!uQ5^1A|&BkfN zV9z>Z&>3&zKeijVXmJo8-z1#_;axrDg(^=?Aa#r(H=c(rlxAkRm&XRHqR;8ZZ3s50 zCD+m?Jar~@PEb3ahLC@X%N`AlJ_aq@bjVYf_K4HvmJ!)U^d`NJW$d*0x1FBWN{q&WIq* z&5)Zg^r+?A1Qz0Wbg(M|jCRf#J1MQ+tx^K1=(G|KYTfS1Bjw`W{LClYWiKifX7KRE zmxYbx2|uHD^?liFEfY|wb>H9@eil$Y`|UGlb!clwR_~597{{wSgYKfcsR5V%l77%sk*|53vfxm@C zY;}-&Lh&z4rGtnKa|cvT2azjy99(_xnl-}Bl%jxVemE0rXeVh4#Ay*SAA>0=MR7El zjsT-$m$^az=D2EC3r;XWYS_ODGyjC^j0gEB3Q$EleT|w18I^v}!UkuH9~d?=b-N=N zzX-iShyz;|fRp|cQT>Om5XqqLa2Rr??{F-bLtoaMfP<0hAQ-^rJ|N&FGGQSF17Y!Ju>b%7 literal 0 HcmV?d00001 diff --git a/brand/icons/okx/icon.svg b/brand/icons/okx/icon.svg new file mode 100644 index 0000000..b7eaa46 --- /dev/null +++ b/brand/icons/okx/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/brand/manifest.binance.webmanifest b/brand/manifest.binance.webmanifest new file mode 100644 index 0000000..eddc20d --- /dev/null +++ b/brand/manifest.binance.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Binance 交易系统", + "short_name": "Binance 交易系统", + "description": "Binance 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#F0B90B", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.exchange.webmanifest b/brand/manifest.exchange.webmanifest new file mode 100644 index 0000000..7464c0f --- /dev/null +++ b/brand/manifest.exchange.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "交易系统", + "short_name": "交易系统", + "description": "加密货币永续交易监控与复盘(请使用各所独立 manifest)", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#0b0d14", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.gate.webmanifest b/brand/manifest.gate.webmanifest new file mode 100644 index 0000000..6dbd346 --- /dev/null +++ b/brand/manifest.gate.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Gate 交易系统", + "short_name": "Gate 交易系统", + "description": "Gate 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#17E6A1", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.okx.webmanifest b/brand/manifest.okx.webmanifest new file mode 100644 index 0000000..bad9fb4 --- /dev/null +++ b/brand/manifest.okx.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "OKX 交易系统", + "short_name": "OKX 交易系统", + "description": "OKX 永续交易监控与复盘", + "start_url": "/", + "display": "standalone", + "background_color": "#0b0d14", + "theme_color": "#FFFFFF", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/brand/manifest.webmanifest b/brand/manifest.webmanifest new file mode 100644 index 0000000..f63dbd9 --- /dev/null +++ b/brand/manifest.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "复盘系统中控", + "short_name": "中控", + "description": "三所交易监控与行情中控", + "start_url": "/monitor", + "display": "standalone", + "background_color": "#0b0e18", + "theme_color": "#0b0e18", + "icons": [ + { + "src": "__ICON_PREFIX__/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "__ICON_PREFIX__/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/docs/OKX单笔期权-币本位与USDT桥-开发方案.md b/docs/OKX单笔期权-币本位与USDT桥-开发方案.md new file mode 100644 index 0000000..4e26650 --- /dev/null +++ b/docs/OKX单笔期权-币本位与USDT桥-开发方案.md @@ -0,0 +1,279 @@ +# OKX 单笔期权 · 币本位模式(USDT 桥 + 复利)— 开发方案 + +> 状态:**已实现首版**(按本文落地;改需求先改本文). +> 范围:**`crypto_monitor_okx` 单笔期权开平** + **中控对 OKX 期权只读字段**(能识别币本位);对冲计划(永期/期期)**不接币本位**. +> **硬约束:本次不改 Gate**(不改 `crypto_monitor_gate/`、不改 Gate 专用模板/静态/测试;共享 `lib` 若动刀不得改变 Gate 启动与交易行为). +> 相关:[期权方案.md](./期权方案.md) · [期权用法.md](./期权用法.md) · [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) · [position-sizing-mode.md](./position-sizing-mode.md) · [更新文档.md](./更新文档.md) + +--- + +## 1. 背景与动机 + +当前单笔期权仅支持 **USDⓈ 本位**(权利金 **USDC**):人工 USDT→USDC 兑换/划转后,按 `OKX_OPTIONS_TRADE_BUDGET_USDC` 卖一开 / 买一平. + +实盘观察:**部分到期与行权附近,币本位期权流动性往往好于 USDC 期权**,更利于「只锁卖一 / 买一」的成交质量. + +币本位权利金用 **ETH/BTC** 支付,操作者仍习惯用 **USDT** 思考本金与复利.因此需要一条自动资金桥,并支持交易账户 USDT 滚仓放大. + +--- + +## 2. 目标(首版) + +1. **env 切换**单笔期权模式:`usdc`(现状) ↔ `coin`(币本位 + USDT↔ETH/BTC 桥). +2. **币本位开仓**:按交易账户 USDT 预算 **先买满现货** → 再用币 **尽量开满** 期权(不按权利金精算买币数量). +3. **币本位平仓**:期权卖出成功后,**自动现货市价**把剩余标的币卖回 USDT. +4. **USDT 全仓复利**:每轮预算默认 = 交易账户 USDT × 缓冲(0.95);赚留在交易户则下一轮自动变大;减规模靠 **人工转走**. +5. **可选单笔上限**:开关默认 **关闭**;开启后 `min(账户×0.95, N U)`. +6. **有未平单笔期权或桥流程半成品时,拒绝切换模式**. +7. **对冲计划**继续只走 USDC 路径;币本位模式下对冲开仓保持不可用或明确提示未支持. +8. **中控不代下期权单**,但监控/快照/持仓卡片等 **只读字段须能识别币本位**(见 §7.5). +9. **不涉及 Gate** 任何业务改动. + +--- + +## 3. 不做(首版外) + +- 对冲计划(永期/期期)币本位腿或双模式混开 +- 盘中按单笔切换本位(必须 env + 重启/无仓校验) +- 按权利金精确计算后再买现货(明确不做;见 §5) +- 自动把资金账户 USDT 划入交易账户(首版只读 **交易账户** 可用 USDT;不足则提示人工划转) +- 市价平期权(继续沿用现有「买一限价、禁市价平」纪律,除非另改总则) +- 多笔并行单笔期权仓(维持「一次一仓」) +- **中控代下 / 中控内嵌开平仓按钮**触发币本位或 USDC 期权下单(开平仍只在 OKX 实例页) +- **任何 Gate 相关改动**(含为「顺便统一」去动 Gate 模板或共享路径上的 Gate 分支) + +--- + +## 4. 模式开关与互斥 + +### 4.1 env(草案) + +| 变量 | 含义 | 默认 | +|------|------|------| +| `OKX_OPTIONS_MARGIN_MODE` | `usdc` \| `coin` | `coin` | +| `OKX_OPTIONS_TRADE_BUDGET_USDC` | USDC 模式单笔权利金预算上限(现有) | `10` | +| `OKX_OPTIONS_BUDGET_BUFFER` | 预算缓冲(现有,币本位复利亦用) | `0.95` | +| `OKX_OPTIONS_COIN_COMPOUND` | 币本位是否按交易户 USDT 复利 | `true`(建议默认开) | +| `OKX_OPTIONS_COIN_BUDGET_USDT` | 复利关闭时的固定 USDT 预算;或作展示参考 | `10` | +| `OKX_OPTIONS_COIN_MAX_USDT_ENABLED` | 单笔不超过 N U 开关 | `false`(**默认关**) | +| `OKX_OPTIONS_COIN_MAX_USDT` | 上限 N(仅开关开启时生效) | 如 `50`(可改) | +| `OKX_OPTIONS_COIN_SPOT_BUY_BUFFER` | 现货买入相对权利金倍数(也可写 `0.10`=+10%) | `1.10` | + +开仓买币:**先按预算估最大可开张数 → 买币 USDT ≈ 张数×卖一权利金×现货缓冲**,不全额把预算换成币。 + +说明: + +- **主路径(复利开 + 上限关)**:`budget_usdt = trading_usdt_available × OKX_OPTIONS_BUDGET_BUFFER`. +- **上限开**:`budget_usdt = min(上式, OKX_OPTIONS_COIN_MAX_USDT)`. +- **复利关**:`budget_usdt = OKX_OPTIONS_COIN_BUDGET_USDT × buffer`(或直接固定值,实现时二选一写死一种,避免歧义;推荐 `固定值 × buffer` 与现 USDC 习惯一致). + +### 4.2 切换门禁 + +| 条件 | 行为 | +|------|------| +| 本地/交易所存在未平 **单笔期权** 持仓 | **拒绝**切换 `usdc`↔`coin` | +| 存在未完成桥状态(已买币未开期权、已平期权未卖回 USDT 等) | **拒绝**切换 | +| 对冲计划运行中 | **不阻断**单笔模式切换,但币本位下对冲仍不可开新币本位腿;UI 标明对冲仅 USDC | +| 无仓且无半成品 | 允许改 env 并重启后生效 | + +启动或保存配置时若检测到「模式与当前持仓族不一致」,应拒绝进入交易或强制只读提示,避免按错误货币计价. + +--- + +## 5. 币本位资金桥与开平流水 + +### 5.1 开仓(先买满,再开满) + +``` +1. 读取交易账户 USDT 可用 +2. 计算 budget_usdt(§4.1) +3. 现货市价:用约 budget_usdt 买入标的币(ETH 或 BTC,与所选期权一致) +4. 用账户中可用于权利金的标的币,按卖一限价尽量开满币本位期权 + - 受:最小张数、卖一深度、单笔一仓规则约束 + - 不要求「币数量精确等于权利金」;允许开满后仍残留部分币 +5. 本地记录本轮:模式=coin、budget_usdt、买入币数量/成本、期权成交、桥状态=holding +``` + +### 5.2 平仓(先平期权,再卖回 USDT) + +``` +1. 按现有纪律买一限价卖出期权(可分批深度) +2. 期权仓清零(或本轮目标完成)后: + 现货市价卖出账户内「本桥残留 + 平仓回收」相关标的币 → USDT +3. 桥状态=closed;交易账户 USDT 更新 → 下一轮自动按新余额复利 +``` + +### 5.3 失败回滚(必须) + +| 失败点 | 处理 | +|--------|------| +| 现货买入失败 | 不开期权;报错 | +| 现货买入成功、期权开仓失败/无卖一 | **自动市价卖回 USDT**;桥状态回滚;告警 | +| 期权平仓成功、现货卖回失败 | 持仓显示/告警 **「待卖回 USDT」**;提供仅重试卖币接口;拒绝新开仓直至清理 | +| 半成品状态下进程重启 | 启动扫描未完成桥,提示或自动尝试卖回 | + +--- + +## 6. 复利与「人工转走」 + +### 6.1 口径 + +- **加仓/放大**:利润留在 **交易账户 USDT**,下一轮 `×0.95` 自动变大(例:10U 一轮后约 20U → 下一轮约 19U 预算). +- **缩小**:运营者 **人工** 将 USDT 转出交易账户(划转到资金账户/提现/他用);系统不自动「复位到 10U」. +- **单笔上限开关**(`OKX_OPTIONS_COIN_MAX_USDT_ENABLED`): + - **默认关闭** → 纯靠人工转走控规模. + - **开启** → `min(账户×0.95, N)`,防止单笔过大. + +### 6.2 与永续「全仓」的关系 + +思想同类(吃可用 × 缓冲),但资产不同: + +- 永续全仓:USDT 保证金 × 杠杆 → 合约名义 +- 币本位单笔:USDT × 缓冲 → 现货币 → 期权权利金 + +**不要**复用 `POSITION_SIZING_MODE=full_margin` 直接驱动期权;用 §4.1 独立开关,避免永续模式与期权桥耦合. + +### 6.3 一次一仓 + +复利放大后必须坚持:**同时仅一个单笔期权仓**.新开前检查无持仓、无「待卖回」半成品. + +--- + +## 7. 产品与 UI + +### 7.1 模式可见性 + +- 顶栏或期权设置页展示当前:`单笔期权模式: USDC / 币本位`. +- 币本位时展示:交易户 USDT、本轮预估预算(`×0.95` 与是否触达 N 上限)、桥状态. +- USDC 模式保持现有 USDC 余额与预算展示. + +### 7.2 开仓按钮文案(示例) + +- 币本位:`买币并开仓(预算 ≈ xx USDT)` +- 确认框写明:将市价买 ETH/BTC → 限价买期权;失败会尝试卖回 USDT. + +### 7.3 对冲 + +- 币本位模式下:对冲计划入口保持「仅 USDC / 未支持币本位」禁用或只读测算. +- 不在此模式自动把对冲预算改成 USDT 桥. + +### 7.4 复盘字段(建议) + +单笔 round-trip 尽量可拆: + +- 期权腿盈亏(币或折合 USDT) +- 桥兑换盈亏(买币成本 vs 卖币回收) +- 合计 USDT 变化(对复利最有意义) + +首版若难拆细,至少记录:**开仓前 USDT、平仓卖币后 USDT、差值**. + +### 7.5 中控只读(要做)与不下单(不做) + +中控保持现有分工:**监控只读 + 点「期权」进 OKX 实例操作**;本方案**不**在中控增加开平仓/买币桥按钮. + +只读侧须能区分并展示币本位,避免仍按「一律 USDC 权利金」误读.实例上报快照/期权字段建议至少包含: + +| 字段(名可调) | 含义 | +|--------------|------| +| `options_margin_mode` | `usdc` \| `coin` | +| 持仓行可辨本位 | 合约族/结算币/标签,卡片上能看出「币本位」或「USDC」 | +| 币本位时预算口径 | 可选:交易户 USDT、本轮 `×0.95` 预估预算、是否触达 N 上限 | +| 桥状态(若有半成品) | 如 `holding` / `pending_sell_spot`(待卖回 USDT),中控只展示与告警,不代执行 | + +展示落点(与现网对齐即可,不新开中控交易页): + +- OKX 账户监控里的期权区块 / 期权持仓卡片 +- 推给教练等用的监控快照文案(若已注入期权行,须带本位标记,避免 AI/人工当成 USDC) + +**不做:**中控代下单、中控触发买 ETH/卖 ETH、中控改 env 切模式. + +--- + +## 8. 技术要点 + +### 8.1 合约与报价 + +- USDC 模式:继续 `ETH-USD_UM` / `BTC-USD_UM` 等现有路径. +- 币本位模式:走 OKX **币本位期权**合约族(实现时以 OKX/ccxt 实际 `instId`/settle 为准,写入适配层,勿与 UM 混用同一计价假设). +- 权利金与张数换算按币本位规则单独实现;复用「卖一开、买一平、深度校验」状态机,不复用 USDC 金额公式硬套. + +### 8.2 模块建议 + +| 块 | 职责 | +|----|------| +| 模式读取 + 门禁 | env、有仓拒切、启动一致性 | +| `options_spot_bridge_lib`(名可调) | USDT↔币 市价买卖、回滚、待卖回重试 | +| 开平编排 | 买满 → 开满 → 平 → 卖回 状态机 | +| 定价/张数 | 币本位分支 | +| UI/API | 预算预览、确认、半成品提示 | +| 中控只读 | 消费实例快照中的 `options_margin_mode` 等字段;卡片/文案可识别币本位;**无下单 API** | +| Gate | **不纳入**;禁止为本次需求修改 Gate 树 | + +现货下单可与现有账户兑换/划转能力并列,但 **桥必须可自动、可回滚**,与「人工 USDT→USDC」不同. + +共享 `lib/options*` / 快照序列化若调整:仅扩展 OKX 期权载荷;Binance/Gate 账户快照路径保持原样. + +### 8.3 权限与账户 + +- API 需具备:交易账户现货市价、期权开平. +- 预算只认 **交易账户 USDT**;资金账户有钱但交易户不足 → 明确提示先划转(首版不自动划). + +### 8.4 测试 + +- 预算计算:复利开/关、上限开/关、余额边界. +- 状态机:开仓失败回滚卖币;平仓后卖币失败 → 待卖回 → 重试成功. +- 门禁:有仓切换拒绝;一次一仓. +- 回归: `margin_mode=usdc` 时行为与现网一致;对冲仍仅 USDC. +- 中控只读:快照含本位字段时卡片/文案可区分 `usdc`/`coin`. +- Gate:本次 diff **不应出现** `crypto_monitor_gate/` 业务文件变更. + +--- + +## 9. 验收标准 + +1. `usdc` 模式:单笔期权行为与现网一致. +2. `coin` 模式:一轮开平后交易户 USDT 变化符合「买币→期权→卖币」;无异常残留币(或残留时必有待卖回告警). +3. 复利:人为把交易户从约 10U 做到约 20U 后,下一轮预览预算约为 `20×0.95`(上限关闭时). +4. 上限开关默认关;开启后预算不超过 N. +5. 有持仓或半成品时切换模式被拒绝. +6. 币本位下对冲不能误开币本位腿. +7. 开仓失败自动卖回 USDT,不留下无主现货. +8. 中控:**无**期权下单入口新增;监控/快照/持仓只读能看出当前为币本位或 USDC. +9. Gate:无相关代码改动;Gate 实例行为与改前一致. + +--- + +## 10. 实现顺序建议 + +1. 模式 env + 有仓/半成品门禁 + OKX 实例 UI 展示当前模式 +2. 现货桥(买/卖/回滚/待卖回) + 单测 +3. 币本位合约适配 + 卖一开/买一平接入编排 +4. 复利预算预览与开仓确认 +5. 上限开关 +6. 快照字段上报 + **中控只读识别币本位**(卡片/文案;不下单) +7. 文档:`期权用法.md` 增补币本位章节;`更新文档.md` 记一笔 + +--- + +## 11. 决策摘要(已拍板) + +| 决策 | 结论 | +|------|------| +| 对冲 | 暂不接币本位 | +| 单笔模式 | env:`usdc` ↔ `coin` | +| 有持仓切换 | **拒绝** | +| 买币方式 | **先买满预算 USDT 对应的币,再开满期权**(不按权利金精算) | +| 复利 | 交易账户 USDT × 0.95;人工转走控规模 | +| 单笔不超过 N U | **独立开关,默认关闭** | +| 中控 | **不下单**;只读字段/快照**能识别币本位** | +| Gate | **本次不改** | +| 动机 | 币本位流动性往往优于 USDC,利于成交 | + +--- + +## 12. 风险与说明 + +- 现货双边手续费与滑点会吃掉部分「名义预算」;小资金下占比更明显. +- 持仓期间若账户内残留标的币,平仓卖回时含现货汇率盈亏,需与期权腿区分看待. +- 流动性优势随到期、行权、标的变化,不保证每一张合约都厚于 USDC;开仓仍以当场卖一深度为准. +- 本方案不改变「符合机会才做、不符合就等」的交易纪律;仅改单笔期权的资金路径与合约族. diff --git a/docs/env配置说明.md b/docs/env配置说明.md new file mode 100644 index 0000000..8fa4002 --- /dev/null +++ b/docs/env配置说明.md @@ -0,0 +1,231 @@ +# env 配置页说明 + +本文档描述各交易实例 Web 端 **「env 配置」** 页展示项,含义,生效方式,以及与系统设置,中控密钥的分工. + +> **不在本页展示的配置**(服务端口,数据库路径,关键位门控,轮询间隔等)仍保存在实例目录 `.env` 中,需 SSH 编辑或部署脚本维护,见文末「隐藏项」. + +--- + +## 1. 设计原则 + +| 原则 | 说明 | +|------|------| +| **只展示运营相关项** | 不暴露全量 `.env`,避免误改基础设施 | +| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 | +| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 | +| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 | +| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` | +| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 | +| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 | + +--- + +## 2. 密钥分工(自动生成,本页不可见) + +部署时由脚本统一生成并写入对应 `.env`(已有值则跳过,避免覆盖生产环境). + +| 类型 | 环境变量 | 写入位置 | 用途 | +|------|----------|----------|------| +| **中控通信密钥** | `HUB_BRIDGE_TOKEN` | 中控 `manual_trading_hub/.env` + 三实例 `.env`(**相同值**) | 中控调用实例 API(`X-Hub-Token`),iframe SSO 签发与校验 | +| **登录会话密钥** | `FLASK_SECRET_KEY` | 三实例 `.env`(**三所相同**) | Flask Session 签名;与网页登录态相关,与中控密钥 **分离** | +| **中控会话密钥** | `HUB_SESSION_SECRET` | 中控 `.env` | 中控登录 Cookie 签名 | +| **登录账号** | `APP_USERNAME` / `APP_PASSWORD` | 三实例 `.env`(建议三所统一) | 直链实例 `/login` 使用;**仅在系统设置中修改** | +| **中控登录** | `HUB_USERNAME` / `HUB_PASSWORD` | 中控 `.env` | 中控网页登录;**仅在中控系统设置改密** | + +说明: + +- **长期密钥一次生成,不轮换**:`setup_env.sh` 末尾调用 `scripts/bootstrap_deploy_secrets.py`;已有非占位值不会被覆盖. +- **SSO 链接不变**:仍为中控每次签发,默认 2 小时有效,单次使用(`HUB_SSO_TTL_SEC`),与长期 `HUB_BRIDGE_TOKEN` 分离. +- 经中控 iframe / SSO 打开实例时,可免输实例密码;直链 IP/域名仍走 `/login`. +- 首次部署可生成随机强密码;用户日后在 **系统设置** 改密,不经过本页. + +--- + +## 3. 页面布局(三列卡片) + +| 列 1 | 列 2 | 列 3 | +|------|------|------| +| 交易所与实盘 | 企业微信 | 交易执行 | +| 交易风控 | 账户冷静期 | 自动划转 | +| 当日资金 | 期权账户(仅 OKX) | | + +> **AI 复盘**(OpenAI / Ollama)已移至中控 **系统设置 → AI 配置**,保存后强制同步三所 `.env`.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本所无期权」. + +--- + +## 4. 各卡片字段(中文展示名) + +### 4.1 交易所与实盘 + +| 中文名 | 说明 | 重启 | +|--------|------|------| +| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 | +| 保证金模式 | 全仓 / 逐仓 | 需重启 | +| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 | +| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 | +| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 | +| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 | + +**交易所 API Key / Secret / Passphrase 不在本页**:请 SSH 编辑各所 `crypto_monitor_*/.env`,新机部署后应为空;配好真钥后重启对应 Flask 与子代理(`pm2 restart … --update-env`).占位符或错误密钥会导致鉴权失败,Gate 上反复请求还可能封 IP. + +**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥. + +--- + +### 4.2 企业微信 + +| 中文名 | 说明 | +|--------|------| +| 机器人 Webhook | 行情,风控,提醒推送地址 | +| 推送超时(秒) | 可选,默认 10 | + +--- + +### 4.3 AI 复盘(已移至中控) + +AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT_SECONDS`)**不再在本页展示**. + +请在中控 **系统设置 → AI 配置** 修改;保存后写入中控 `.env` 并 **强制同步** 至 OKX / Binance / Gate 三实例.详见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +--- + +### 4.4 交易执行 + +| 中文名 | 说明 | +|--------|------| +| 计仓模式 | 以损定仓 / 全仓杠杆 | +| 以损定仓风险% | 单笔风险占资金比例 | +| 全仓资金缓冲比例 | 全仓模式下可用资金折扣 | +| BTC 默认杠杆 | | +| 山寨默认杠杆 | | +| 方向限制开关 | | +| 允许方向 | 多 / 空 / 双向 | +| 币种白名单开关 | | +| 白名单币种 | 逗号分隔 | +| 交易日切点(北京时间) | 默认 8 点 | +| 切点前禁止新开仓 | | +| 最大同时持仓 | | +| 人工最低盈亏比 | | +| 强制清仓开关 | `FORCE_CLOSE_ENABLED`;开启后在指定北京整点小时内,市价平掉本地 active 监控仓 | +| 强制清仓整点(北京) | `FORCE_CLOSE_BJ_HOUR`(0–23);例 `8` 表示 08:00~08:59;仅扫监控仓,不含交易所裸仓 | + +--- + +### 4.5 交易风控(日内开仓) + +| 中文名 | 说明 | +|--------|------| +| 单日开仓提醒阈值 | 达到次数后 AI 克制提醒(不拦单) | +| 单日开仓硬上限 | 0 表示不启用;达到后禁止新开仓 | + +详见 [daily-open-limit.md](./daily-open-limit.md). + +--- + +### 4.6 账户冷静期 + +| 中文名 | 说明 | +|--------|------| +| 冷静期总开关 | | +| 手动平仓冷静(小时) | | +| 复盘情绪冷静(小时) | | +| 日手动平仓次数上限 | | +| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 | +| 情绪标签日冻结 | | + +详见 [account-risk-cooldown.md](./account-risk-cooldown.md). + +--- + +### 4.7 自动划转 + +| 中文名 | 说明 | +|--------|------| +| 启用自动划转 | | +| 目标余额(U) | 交易账户目标 USDT | +| 划出账户 | funding / swap | +| 划入账户 | swap / funding | +| 执行整点(北京时间) | | +| 划转币种 | 默认 USDT | + +详见 [auto-transfer-daily.md](./auto-transfer-daily.md). + +--- + +### 4.8 当日资金 + +| 中文名 | 说明 | +|--------|------| +| 日起始基数(U) | | +| 回撤后基数(U) | | +| 盈利后基数(U) | | + +与自动划转目标余额相互独立;若需一致请手动对齐. + +--- + +### 4.9 期权账户(仅 OKX) + +| 中文名 | 说明 | +|--------|------| +| 启用期权模块 | 与永续共用 `OKX_API_*`;不再单独配置期权密钥 | +| 期权账户备注 | | +| 单笔预算(USDC) | | +| 预算缓冲比例 | | +| 默认标的 | 如 ETH | +| 最大到期天数 | 等常用策略参数 | + +高级参数与完整说明见 [期权方案.md](./期权方案.md),[期权用法.md](./期权用法.md),[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)(线上 `/options/guide`). + +--- + +## 5. 操作说明 + +1. 修改后点 **保存**:即时生效项立即应用;需重启项写入 `.env` 但未重启进程. +2. 含需重启项时点 **保存并重启**:写 `.env` 后 PM2 重启当前实例. +3. **重新加载**:从磁盘重新读取 `.env` 刷新表单(放弃未保存修改). +4. 敏感项(API,密钥)显示为掩码;**留空提交表示不修改原值**. + +--- + +## 6. 隐藏项(本页不展示) + +以下仍存在于 `.env`,仅供运维或 SSH 修改: + +- 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG` +- 数据:`DB_PATH`,`UPLOAD_DIR` +- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空) +- 关键位门控:全部 `KEY_*`,`KLINE_*` +- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*` +- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY` 等 +- 备份:`BACKUP_*` +- 中控嵌入细节:`APP_ALLOW_HUB_EMBED`,`HUB_EMBED_*`,`APP_COOKIE_SECURE` +- 登录相关:`APP_AUTH_DISABLED`,`APP_USERNAME`,`APP_PASSWORD`,`FLASK_SECRET_KEY`,`HUB_BRIDGE_TOKEN` + +后续若需要可增加「高级模式」折叠区,默认关闭. + +--- + +## 7. 与系统设置的关系 + +| 能力 | env 配置 | 系统设置 | 中控系统设置 | +|------|----------|----------|--------------| +| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 | +| 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ | +| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) | +| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 | +| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ | +| 数据导出 | ❌ | ✅ 数据导出 | ❌ | +| 期权兑换/划转 UI | ❌ | ✅(OKX,可开关) | ❌ | + +系统设置说明见 [系统设置说明.md](./系统设置说明.md);中控 AI 与部署密钥见 [中控AI与密钥配置.md](./中控AI与密钥配置.md). + +--- + +## 8. 实现备注(开发用) + +- 白名单分组:`lib/env/env_ui_manifest.py`(按 `exchange_key` 过滤) +- 中文标签:`ENV_UI_LABELS` 映射,模板只渲染 `label` / `note` +- 全量校验仍基于 `.env.example`;POST 仅接受 manifest 内 key diff --git a/docs/lib-structure.md b/docs/lib-structure.md new file mode 100644 index 0000000..4b772bd --- /dev/null +++ b/docs/lib-structure.md @@ -0,0 +1,147 @@ +# lib/ 共用模块结构 + +三所实例与中控共用的 Python 库,模板与静态资源统一放在仓库根目录的 **`lib/`** 下.部署单元(`crypto_monitor_*`,`manual_trading_hub`)仍保持独立目录与 PM2 配置不变. + +**重构前快照 Git 标签**:`pre-lib-modularization`(可用 `git checkout pre-lib-modularization` 查看旧布局). +**移除 gate_bot 前快照 Git 标签**:`pre-remove-gate-bot`. + +--- + +## 顶层目录 + +``` +crypto_monitor/ +├── crypto_monitor_binance/ # 三所:各自 app + .env + PM2 +├── crypto_monitor_gate/ +├── crypto_monitor_okx/ +├── manual_trading_hub/ # 中控 + 子代理 agent +│ +├── lib/ # 共用模块(本说明) +│ ├── strategy/ +│ ├── key_monitor/ +│ ├── trade/ +│ ├── hub/ +│ ├── ai/ +│ ├── instance/ +│ ├── exchange/ +│ ├── common/ +│ └── paths.py +│ +├── brand/ # 各所共用图标 +├── docs/ +├── deploy/ +├── scripts/ +├── tests/ +├── requirements.txt +└── README.md +``` + +--- + +## lib/ 子包说明 + +| 子包 | 职责 | 主要模块 | +|------|------|----------| +| **`lib/strategy/`** | 策略交易(顺势加仓,趋势回调,快照与记录) | `strategy_register.py`,`strategy_trend_register.py`,`strategy_db.py`,`strategy_roll_*`,`strategy_trend_*` | +| **`lib/strategy/templates/`** | 策略页 Jinja 模板(原 `strategy_templates/`) | `strategy_trading_page.html`,`strategy_roll_panel.html` 等 | +| **`lib/key_monitor/`** | 关键位监控,斐波,假突破,止盈止损方案 | `key_monitor_lib.py`,`fib_key_monitor_lib.py`,`key_sl_tp_lib.py` 等 | +| **`lib/trade/`** | 下单监控展示,计仓,账户风控,手动 SL/TP | `order_monitor_display_lib.py`,`position_sizing_lib.py`,`account_risk_lib.py` 等 | +| **`lib/hub/`** | 中控 API,K 线,归档,计仓器,SSO/Bridge | `hub_bridge.py`,`hub_kline_store.py`,`hub_trades_lib.py` 等 | +| **`lib/ai/`** | AI 复盘与文本生成 | `ai_client.py`,`ai_review_lib.py` | +| **`lib/instance/`** | 中控 iframe 嵌入,导航,复盘图表 | `instance_embed_lib.py`,`focus_chart_lib.py`,`journal_chart_lib.py` | +| **`lib/instance/templates/`** | 嵌入页片段(原 `embed_templates/`) | `embed_page_fragment.html` | +| **`lib/exchange/`** | 特定交易所工具 | `gate_transfer_lib.py`,`okx_orders_lib.py` 等 | +| **`lib/common/`** | 跨功能小工具 | `form_submit_lib.py`,`wechat_notify_lib.py` 等 | +| **`lib/common/static/`** | 三所与中控共用的 JS/CSS(原根目录 `static/`) | `instance_theme.js`,`strategy_roll.js` 等 | + +> **说明**:`hub_*` 命名表示「中控侧能力或行情聚合」,但部分模块(如 `hub_volume_rank_lib`,`hub_market_info_lib`)三所 `app.py` 也会调用,并非中控独占. + +--- + +## 路径辅助函数 + +`lib/paths.py` 集中维护资源目录,避免硬编码: + +```python +from lib.paths import strategy_templates_dir, embed_templates_dir, common_static_dir + +strategy_templates_dir() # .../lib/strategy/templates +embed_templates_dir() # .../lib/instance/templates +common_static_dir() # .../lib/common/static +``` + +可选传入 `repo_root`(字符串或 `Path`),默认使用 `lib/` 的上级目录即仓库根. + +--- + +## Python 导入约定 + +各部署目录在启动时将 **仓库根** 加入 `sys.path`(与重构前相同): + +```python +_REPO_ROOT = os.path.dirname(BASE_DIR) # 或 Path(__file__).resolve().parent.parent +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +``` + +之后使用 **`lib.<子包>.<模块>`** 形式导入,例如: + +```python +from lib.strategy.strategy_db import init_strategy_tables +from lib.key_monitor.key_monitor_lib import check_key_monitors +from lib.hub.hub_bridge import install_on_app +from lib.ai.ai_client import ai_review +``` + +策略注册仍在各所 `app.py` 末尾: + +```python +from lib.strategy.strategy_register import install_strategy_trading +from lib.strategy.strategy_trend_register import install_strategy_trend + +install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) +install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) +``` + +--- + +## 静态资源与 URL + +- 三所页面仍通过 **`/static/...`** 访问共用脚本;`hub_bridge.install_instance_theme_static` 从 `lib/common/static/` 提供部分根级静态路由. +- 各所目录下 **`static/`**(图标,上传图片等)仍为实例私有,未迁入 `lib/`. +- 中控 `manual_trading_hub/hub.py` 通过 `_REPO_ROOT / "lib" / "common" / "static"` 挂载与三所共用的 badge,复盘 JS 等. + +--- + +## 测试 + +在仓库根执行(需将根目录置于 Python 路径,或从根目录运行): + +```bash +cd /opt/crypto_monitor +python -m unittest discover -s tests -p "test_*.py" +``` + +测试文件内统一 `from lib.<子包>.<模块> import ...`.使用 `@patch` 时目标写完整模块路径,例如 `lib.hub.hub_calculator_lib._resolve_market`. + +--- + +## 迁移脚本 + +一次性迁移由 `scripts/migrate_to_lib.py` 完成(移动文件 + 批量改写 import).**不要在已迁移后的仓库上重复执行**. + +--- + +## 后续可选整理 + +- 三所 `app.py` 体量接近,可逐步抽取公共 `exchange_app` 基座(改动面大,单独规划). +- `manual_trading_hub/okx_orders_lib.py` 为 agent 本地副本,可与 `lib/exchange/okx_orders_lib.py` 合并去重. +- 可引入 `pyproject.toml` + `pip install -e .`,替代 `sys.path.insert`(长期维护更规范). + +--- + +## 相关文档 + +- [README.md](../README.md) — 总览与部署 +- [策略交易说明.md](../策略交易说明.md) +- [manual_trading_hub/使用说明.md](../manual_trading_hub/使用说明.md) diff --git a/docs/position-sizing-mode.md b/docs/position-sizing-mode.md new file mode 100644 index 0000000..22a964f --- /dev/null +++ b/docs/position-sizing-mode.md @@ -0,0 +1,73 @@ +# 计仓模式(三所统一) + +## 配置 + +在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**): + +```env +# risk(默认)= 以损定仓 +# full_margin = 全仓杠杆(合约可用保证金 × 比例) +POSITION_SIZING_MODE=risk +FULL_MARGIN_BUFFER_RATIO=0.98 + +# 关键位程序自动单(默认 false,详见各所 关键位自动下单说明.md) +KEY_AUTO_ORDER_ENABLED=false +``` + +切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓). + +## 模式说明 + +| 模式 | 保证金计算 | 杠杆 | 允许入口 | +|------|------------|------|----------| +| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工,关键位自动(须 `KEY_AUTO_ORDER_ENABLED=true`),趋势回调,顺势加仓 | +| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **实盘人工下单**,**关键位触价**(须 `KEY_AUTO_ORDER_ENABLED=true`);阻力/支撑仅提醒 | + +全仓模式下: + +- **`KEY_AUTO_ORDER_ENABLED=false`(默认)** 时,触价程序自动单也不执行. +- **`KEY_AUTO_ORDER_ENABLED=true`** 时,仅触价可程序自动开仓;箱体/斐波等仍禁止. +- 仍校验 **计划盈亏比**(实盘用 `MANUAL_MIN_PLANNED_RR`;触价开仓用 `KEY_AUTO_MIN_PLANNED_RR`). +- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定. +- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准. +- 已存在的 **箱体突破 / 收敛突破 / 斐波 / 假突破** 监控:进程启动时**自动撤销**并企业微信通知. + +## 不允许(全仓模式) + +- 关键位:箱体突破,收敛突破,斐波,假突破(添加时拒绝;已存在则启动时撤销). +- 趋势回调,顺势加仓(策略入口返回明确错误). + +**允许(须 `KEY_AUTO_ORDER_ENABLED=true`):** 关键位 **回调触价开仓** / **突破触价开仓**(程序盯价,触达/穿越计划入场后市价成交,无交易所挂单;全仓下仅允许一条待触发). + +## `KEY_AUTO_ORDER_ENABLED`(三所统一,默认 `false`) + +| 计仓 | 开关 | 效果 | +|------|------|------| +| `risk` | `false` | 关闭全部关键位程序自动单(含触价);顺势加仓不受影响 | +| `risk` | `true` | 关键位全套自动(旧行为) | +| `full_margin` | `false` | 关闭触价自动 | +| `full_margin` | `true` | 仅触价自动 | + +详见各实例目录 `关键位自动下单说明.md`. + +## 用脚本更新三所 `.env` + +详见 **[env-sync-scripts.md](./env-sync-scripts.md)**.常用命令: + +```bash +git pull + +# 仅补全计仓相关项(缺省 risk,缓冲 0.98) +python scripts/sync_four_exchange_position_sizing_env.py + +# 无仓后切换全仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin + +# 无仓后切回以损定仓 +python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk + +# 计仓 + 划转一并补全 +python scripts/sync_four_exchange_env.py + +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate +``` diff --git a/docs/对冲计划-以期权为主.md b/docs/对冲计划-以期权为主.md new file mode 100644 index 0000000..562e13f --- /dev/null +++ b/docs/对冲计划-以期权为主.md @@ -0,0 +1,71 @@ +# 对冲计划 · 永期「以期权为主」 + +> 实现日:2026-08-09 · 在现有永期**保险模式**上增加子模式,不新增 `OKX_TRADE_MODE`。 +> 模式由 env `HEDGE_PLAN_OPTION_PRIMARY` 切换(默认 `true`),页面标题前显示标识,不可在页内切换。 + +## 1. 模式对照 + +| | 保险模式(`OPTION_PRIMARY=false`) | 以期权为主(`true`) | +|--|------------------|-------------------| +| UI 做多 | 永续多 + 买 Put | 买 Call + 永续空 | +| UI 做空 | 永续空 + 买 Call | 买 Put + 永续多 | +| 左卡 | 开仓价 / 张数 / TP / SL | 资金与杠杆 / 选约条件 / 出场条件 三组 | +| 右卡 | 上永续行情 · 下期权链 | 同上;仅展示间隔+类型+杠杆达标候选 | +| 选约 | 仅实值/平值 | 类型下拉(默认虚值)+间隔+杠杆门槛 | +| 开仓 | 受 `HEDGE_PLAN_OPEN_ORDER` | **策略启动=盯盘**(status=`watching`),达标后才先期权后市价永续(**不挂**交易所 TP/SL) | +| 出场 | 交易所 TP/SL | 相对 K 的点数目标分叉 | + +## 2. 左卡默认 + +| 字段 | 默认 | +|------|------| +| 权利金 | 用户填(USDC 预算) | +| 永续杠杆 | 100 | +| 期权杠杆 | 实/平 100;虚 200 | +| 期权:永续比例 | 实/平 2;虚 4 | +| 到期时间(最短 h) | 36 | +| 期权间隔(点) | 15 | +| 期权/永续目标位 | 相对 K 点数,须 **>0** | + +## 3. 定仓 + +``` +usable = 权利金 × 0.95 +eth_qty = floor2(usable / ask) # ETH 名义,两位小数 +sheets = floor(eth_qty / ct_mult) # 整张 +perp_eth = eth_qty / 比例 +contracts = perp_eth / contract_size +``` + +启动前再拉卖一重算;卖一深度不足则缩量。 + +## 4. 出场 + +触达任一目标位(做多 `index ≥ K+N`,做空 `index ≤ K−N`)后立即执行: + +| 触达 | 规则 | +|------|------| +| **期权目标** | 验买一流动性 + **扣费净利 > 0** → 先平期权再平永续 | +| **永续目标** | 市价平永续;期权 `hold_to_expiry` 至到期结算 | + +净利:平仓/卖出手续费**按买入费率**估算(`HEDGE_PLAN_FEE_RATE` / `OKX_TAKER_FEE`,默认 0.0005)。 + +若期权目标因买一/净利未过、但永续目标已触达 → 改走永续目标。 +期权已平永续失败 → `opt_target_perp_pending` 下轮只补平永续。 +两腿仍开但期权到期 → 结算期权并平永续,避免裸奔。 + +## 5. 代码落点 + +| 文件 | 作用 | +|------|------| +| `lib/hedge_plan/hedge_plan_option_primary_lib.py` | 定仓/方向/目标/净利/校验 | +| `hedge_plan_orders_lib.py` | 路径、开平永续、启动前定仓刷新 | +| `hedge_plan_monitor_lib.py` | `_tick_po_option_primary*` | +| `hedge_plan_register.py` / `hedge_plan_db.py` | preview/start/persist;`options-chain?option_primary&min_hours&strike_interval` | +| `hedge_plan.js` + `hedge_plan_panel.html` | env 模式标识、左三组参数、右上永续/右下期权 | + +## 6. 测试 + +```bash +python -m unittest tests.test_hedge_plan_option_primary -v +``` diff --git a/docs/对冲计划-选约与虚实值.md b/docs/对冲计划-选约与虚实值.md new file mode 100644 index 0000000..7ffdcf9 --- /dev/null +++ b/docs/对冲计划-选约与虚实值.md @@ -0,0 +1,76 @@ +# 对冲计划 · 选约与虚实值 + +> 实现日:2026-08-05 · 吸收 `eth_hedge_sim`(比特骆驼)选约几何;退出仍用本仓 TP/SL/S*,**不**移植仿真「净盈亏 15U 离场」。 + +## 1. 冻结规则 + +| 计划类型 | 允许虚实值 | 禁止 | 推荐模板 | +|----------|------------|------|----------| +| **永期保险** `perp_options`(开关关) | 实值、平值 | **虚值** | 距指数最近的实值/平值(做多 Put / 做空 Call) | +| **永期以期权为主** `option_primary=1` | 实值、平值、**虚值** | —(间隔+杠杆门) | 做多 Call+永续空 / 做空 Put+永续多;详见 `docs/对冲计划-以期权为主.md` | +| **期期** `options_options` | 平值、虚值 | **实值** | 平值跨式(ATM C+P);双虚值(OTM C+P) | + +口径与 `lib/options/options_pricing_lib.option_moneyness` 一致:ATM 带 = `max(指数×0.2%, 2U)`。 + +永期几何兜底(与仿真一致): + +- Call 实值/平值:`K ≤ S` +- Put 实值/平值:`K ≥ S` + +## 2. 代码落点 + +| 层 | 文件 | 作用 | +|----|------|------| +| 选约/校验库 | `lib/hedge_plan/hedge_plan_moneyness_lib.py` | `is_itm_or_atm` / `is_atm_or_otm` / `pick_*` / `recommend_oo_legs` / `validate_*` | +| 启动门禁 | `hedge_plan_orders_lib.validate_start_body` | 测算外再拦一遍;防绕过 UI 直 POST | +| 测算 | `hedge_plan_register._preview_po/_preview_oo` | 预览同样拒绝违规腿 | +| UI | `hedge_plan.js` + `hedge_plan_panel.html` | 筛选锁定、推荐按钮、选用前校验 | +| Env | `env_ui_manifest` 永期分组 | `HEDGE_PLAN_ITM_MAX_DIST_USD` / `MIN_OPTION_HOURS` / `MIN_OPTION_LEVERAGE` | + +## 3. Env + +| 键 | 默认 | 说明 | +|----|------|------| +| `HEDGE_PLAN_ITM_MAX_DIST_USD` | 空→沿用 `OKX_OPTIONS_ITM_MAX_DIST_USD`(常 30) | 永期过深实值上限;0=不限 | +| `HEDGE_PLAN_MIN_OPTION_HOURS` | 8 | 仅当请求带 `hours_to_expiry` 时生效 | +| `HEDGE_PLAN_MIN_OPTION_LEVERAGE` | 0 | `指数/卖一`;0=关闭 | + +## 4. 可用性审计 + +| 项 | 结论 | +|----|------| +| 默认筛选 | 永期默认「实值/平值」;期期默认「平/虚」—减少误选 | +| 推荐一键 | 永期「推荐」;期期「推荐跨式 / 推荐双虚」—降低手选成本 | +| 文案 | 规则说明与 alert 明确禁虚(永期)/禁实(期期) | +| 服务端一致 | UI 过滤可绕过时,preview/start 仍会 400 | +| 兼容旧 API | 未传 `strike` 时从 `inst_id` 解析;未传 `index_px` 时永期用 `entry`、期期用上下破中点 | +| 以期权为主 | 见 `docs/对冲计划-以期权为主.md`:点数目标+扣费净利出场(非仿真 15U 固定);保险模式仍不接仿真净盈亏离场 | + +**已知局限:** + +- 链上 `moneyness` 依赖刷新时指数;剧烈跳动后需「刷新链」再选。 +- `MIN_OPTION_HOURS` 需前端/调用方传入 `hours_to_expiry` 才校验(当前链行未必带该字段)。 +- 期期「推荐跨式」优先 ATM,若无 ATM 会回退到最近允许档(含 OTM)。 + +## 5. 安全性审计 + +| 风险 | 控制 | +|------|------| +| 客户端改包选虚值永期保险 | `validate_start_body` + preview 服务端拒绝 | +| 客户端选实值期期腿 | 同上 | +| 过深实值权利金过贵 / 杠杆过低 | `ITM_MAX_DIST` + 可选 `MIN_OPTION_LEVERAGE` | +| 误开实盘 | 既有 `HEDGE_PLAN_LIVE_ORDER` ∩ `LIVE_TRADING_ENABLED` ∩ 全仓(永期)门禁不变 | +| 保险模式平仓 | 不变:交易所 TP/SL | +| 以期权为主平仓 | 独立监控分支;不改保险模式路径 | + +## 6. 测试 + +```bash +python -m unittest tests.test_hedge_plan_moneyness tests.test_hedge_plan_orders -v +``` + +覆盖:虚实值几何、永期拒 OTM、期期拒 ITM、`validate_start_body` 集成。 + +## 7. 与开发方案对齐 + +更新 `docs/对冲计划开发方案.md` §3.2 / §4.1 选约约束,与本文件一致。 diff --git a/docs/对冲计划开发方案.md b/docs/对冲计划开发方案.md new file mode 100644 index 0000000..3b0df28 --- /dev/null +++ b/docs/对冲计划开发方案.md @@ -0,0 +1,695 @@ +# OKX 对冲计划 — 开发方案 + +> 状态:**方案冻结**(实现前对照本页;改需求先改本文). +> 范围:**仅 `crypto_monitor_okx` 实例**;中控不做对冲开平. +> 相关策略说明:[期权对冲方案分析.md](./期权对冲方案分析.md) + +--- + +## 1. 目标与命名 + +在 OKX 实例增加独立模块 **「对冲计划」**,把「行情选腿 → 情景测算 →(条件满足时)自动开仓 → 规则退出 → 独立复盘统计」串成一套可状态跟踪的计划. + +| 产品名 | 英文键 | 含义 | +|--------|--------|------| +| **永期对冲** | `perp_options` | 同账户永续 + 买方期权 | +| **期期对冲** | `options_options` | 同账户内两条买方期权腿 | + +页面/导航展示用中文名;API/DB 用英文键. + +**不做(本方案外):** + +- 卖方期权、组合单原子成交 +- 中控代下单 +- 跨所对冲 +- 替代现有关键位/策略/期权页(可共存,但同标的限制见 §9) + +--- + +## 2. 计仓模式门禁 + +沿用 `[docs/position-sizing-mode.md](./position-sizing-mode.md)`: + +| `POSITION_SIZING_MODE` | 永期对冲 | 期期对冲 | +|------------------------|----------|----------| +| `risk`(以损定仓 / **非全仓**) | **仅测算**(左右行情 + 情景表);**禁止开仓/启动计划** | **允许开仓**(测算 + 启动计划) | +| `full_margin`(全仓杠杆) | **允许开仓** | **允许开仓** | + +说明: + +- **永期**依赖永续全仓名义与保证金节奏,故开仓仅限全仓. +- **期期**不占永续保证金,非全仓也允许开期权腿;非全仓下 UI 隐藏/禁用「启动永期」,仍可做永期**只读测算**. +- 切模式后若存在 `active` 永期计划,禁止切到 `risk`,或强制要求先结束计划(实现时二选一并写死校验). + +额外硬门: + +- 须 `OKX_OPTIONS_ENABLED=true` 且期权 API 可用. +- 实际开仓须实例允许实盘(`LIVE_ORDER` 等与永续/期权现有开关一致),并对冲计划自有开关见 §10. + +--- + +## 3. 永期对冲 — 仓位与选期权 + +### 3.1 永续开仓量(全仓) + +与现网全仓逻辑一致,对冲计划固定用于 **BTC / ETH**: + +``` +可用保证金 = 合约账户可用 USDT +占用保证金 = 可用 × FULL_MARGIN_BUFFER_RATIO(默认 0.98) +杠杆 = BTC_LEVERAGE / ETH 对应杠杆(默认 10x) +名义 ≈ 占用保证金 × 杠杆 +张数 = amount_to_precision(名义 / 价格 / 合约面值) +``` + +页面左侧展示:**建议张数、名义、止损亏损额、止盈盈利额**(用户填止盈价/止损价后按该仓位即时算). + +用户流程: + +1. 选标的 ETH/BTC、方向(多/空). +2. 系统按全仓 ×0.98×10x 算出永续张数与到止盈/止损的 U 盈亏. +3. **再据此挑选右侧期权**(保费、张数、行权价),使「止损时保险腿」与「止盈保费损耗」可接受. +4. 通过情景表确认后启动计划. + +### 3.2 期权腿选取 + +- 行情自动拉 OKX 期权链(复用 `build_option_chain`). +- **报价形态:列表式**;多仓默认筛 **Put**,空仓默认筛 **Call**. +- **虚实值(冻结):**仅允许 **实值或平值**,**禁止虚值**(保险腿须有内在价值或贴近平值).详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md). +- 页面默认筛「实值/平值」,提供「推荐」取距指数最近档;服务端 `validate_start_body` / preview 二次校验. +- 权利金默认按 **卖一 ask** 估算;开仓限价买入. + +### 3.3 左右布局 + +``` +左:永续列表行情(标记/买卖一) + 方向/建议张数/开仓价/止盈/止损 +右:期权列表(可「选用」一条腿) +下:情景测算 → [保存草稿] [启动计划] +``` + +--- + +## 4. 期期对冲 — 仓位与选腿 + +### 4.1 报价与选腿 + +- **T 型报价链**(复用期权页 T 型样式/数据结构). +- 用户选 **腿 A + 腿 B**(通常 Call + Put,或主方向 + 尾部). +- **虚实值(冻结):**两腿仅允许 **平值或虚值**,**禁止实值**;推荐模板:平值跨式 / 双虚值.详见 [对冲计划-选约与虚实值.md](./对冲计划-选约与虚实值.md). +- 预算:`B = min(交易户 USDC × 对冲缓冲 HEDGE_PLAN_BUDGET_BUFFER, 单笔预算)`(默认 buffer=0.95;与期权页 buffer 独立). +- 自动张数(选齐两腿后写入,可手改): + - **同张数**(默认):最大 `n` 使 `n×(cost_A+cost_B) ≤ B`,两腿均填 `n` + - **做多 / 做空**:须一 Call 一 Put;主:次默认 **7:3**(`HEDGE_PLAN_OO_BIAS_RATIO`,可改) + - 做多:主腿=Call;做空:主腿=Put + - 拆分口径 `HEDGE_PLAN_OO_BIAS_SPLIT_BY`:`budget`(默认,按权利金预算拆) / `sheets`(先按同张数得每腿 `n`,总张数 `2n` 再按比例拆到 Call/Put) + - 另受各自卖一深度上限约束 + - 已移除页面「均分」;后端仍兼容旧 `split_budget` 入参(预算对半) + +### 4.2 目标价 + +用户填 **预判价格 S\*** (「价格能到的位置」): + +- 系统标明在 S\* 时哪条腿为 **盈利方**、哪条为 **亏损方**. +- 到达规则见 §5.2. + +### 4.3 左右布局 + +``` +左:指数价 + 到期日 + 预算 + 目标价 S* +右:T 型链,依次选用两腿 +下:情景测算 → [保存草稿] [启动计划] +``` + +非全仓模式下期期布局同上(无永续区). + +--- + +## 5. 退出规则(冻结) + +### 5.1 永期对冲 + +| 事件 | 永续 | 期权 | 计划是否结束 | 设计意图 | +|------|------|------|--------------|----------| +| **永续止盈触发** | 交易所 TP 平仓 | **不强制平**(保险腿可自生自灭/人工) | **算结束** | 对冲计划以永续兑现目标收口 | +| **永续止损触发** | 交易所 SL 平仓 | **必须强制平仓** | **算结束** | 保护机制 | +| 期权单独到期 | — | 结算 | 若计划已因止盈结束则只更新腿快照,不再改计划合计 | | +| 人工结束计划 | 可选平永续 | 按选项 | **算结束** | | + +**计划结束时盈亏口径(写入 `realized_pnl_*`,推送与统计共用):** + +| 结束原因 | 公式(≈U,1:1) | 字段落库 | +|----------|--------------|----------| +| **止盈** `perp_tp` | **永续止盈已实现盈利 − 期权已付权利金** | `realized_pnl_perp` = 止盈盈利;`realized_pnl_options` = **−premium_total**(按权利金全额计成本,不论期权是否仍持仓);`realized_pnl_total` = 上两式之和 | +| **止损** `perp_sl` | **期权平仓盈利 − 永续止损亏损额** | `realized_pnl_options` = 期权强制平后已实现;`realized_pnl_perp` = 永续止损已实现(为负或记亏损额);`realized_pnl_total` = 期权盈利 − \|永续亏损\|(即有符号相加) | + +说明: + +- 止盈时期权**物理上可不平**,但 **计划账** 已按「保费打掉」收口,后续期权 thrift/到期盈亏 **不再回写计划合计**(可在腿上另记备注/浮盈,不进 `realized_pnl_total`). +- 止损时期权必须先强平再结账,用真实平仓盈亏,不是只扣权利金. + +永续侧 TP/SL:沿用实例 **交易所条件单**,监控识别成交后触发计划结束逻辑 + 微信推送. + +### 5.2 期期对冲 + +| 事件 | 盈利方 | 另一腿(残腿) | 计划是否结束 | +|------|--------|--------------|--------------| +| **标的价到达目标 + 平仓模式=到期平** | **自动平仓** | **不平**,持有至到期(`hold_expiry`) | 平盈利腿后仍 `active`;残腿到期后结账 | +| **标的价到达目标 + 平仓模式=全平**(默认) | **自动平仓** | **随即买一清残腿**(无 2×门控,失败则每轮重试) | 两腿都平完后 `closed` | +| **到期且整体无盈利** | — | 到期结算 | **算结束**;合计记 **总亏损** | +| **到期时组合合计仍盈利** | — | 到期结算 | **算结束**;按实际结算盈亏入账 | + +- 界面「平仓模式」仅控制**盈利腿已平之后**另一腿的处理;须 `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true`(默认开)才显示,页面默认选 **全平**. +- 关闭方案C开关时行为固定为 **到期平**. +- 判定「整体无盈利」:到期(或计划收口)时 `realized_pnl_total ≤ 0`(含双腿权利金全损). +- 盈利方判定:触达上破/下破时按浮盈较大一侧平仓;皆亏则等到期. + +### 5.2.1 期权腿实盘平仓执行(与期权页共用) + +对冲计划凡**必须物理平掉期权腿**时(如永期止损联动、期期平盈利腿),执行口径与独立期权模块一致: + +| 规则 | 说明 | +|------|------| +| 禁市价 | 代码硬关闭,无市价兜底 | +| 只锁买一 | 本轮 `min(仓位, 买一深度)` × 买一限价;`reduceOnly` | +| 分批 | 买一不够则剩余下一轮再平再锁新买一 | +| 有效流动性 | 残档买一禁止按买盘平 | +| 2× 门控 | 目标位/自动类路径首次需可回收≥2×权利金并持续 hold;手动买一平只验流动性 | +| 平仓挂单 TTL | 卖出限价超 `OKX_OPTIONS_PENDING_TTL_SECONDS`(默认 10 分钟)自动撤;UI「委托」可见 | + +完整说明(可单独打开):**[期权开平仓与监控说明.md](./期权开平仓与监控说明.md)** · 线上 `/options/guide`. + +--- + +### 5.3 企业微信推送(起止必发) + +| 时机 | 是否必发 | 内容要点 | +|------|----------|----------| +| **计划开始**(开仓成功 → `active`) | **必发** | 类型(永期/期期)、标的方向、关键价位、张数/保费、计划 id | +| **计划结束** | **必发** | 结束原因、`realized_pnl_total`、分项(永续/期权)、是否止盈/止损/到期亏损 | +| 半腿失败 / 强平失败 | 必发告警 | 便于人工介入 | +| 目标价平掉盈利腿(期期中间态) | 建议发 | 注明亏损腿仍持有 | + +结束推送触发点与「算结束」一致:永期止盈、永期止损、期期到期收口(含无盈利总亏)、人工结束等. + +--- + +## 6. 情景测算(开仓前必显) + +### 6.1 永期 + +| 情景 | 含义 | +|------|------| +| 止盈 | 永续到 TP 的盈利 − 期权保费(期权按不强制平时的损耗估算) | +| 宽止损 | 永续到 SL 的亏损 + 期权平仓估值(强制平,用 mark/买一估算) | +| 到期横盘 | 永续≈0 + 期权权利金全损 | +| 价格扫描 | index ± 若干档合计 | + +核心输出:**宽止损合计亏损**(人工评估是否开仓). + +### 6.2 期期 + +| 情景 | 含义 | +|------|------| +| 到达 S\* | 盈利腿兑现估值 − 已付总保费中亏损腿残留 | +| 到期横盘 | 双腿权利金近似全损 | +| 到期大涨/大跌 | 结算内在价值 | + +--- + +## 7. 数据来源(行情自动) + +| 侧 | 来源 | +|----|------| +| 永续行情/规格 | OKX 账户 ccxt:ticker + 现有 `/api/hub/market` 规格逻辑 | +| 期权链 | `build_option_chain` / `/api/options/chain`(本实例直连,无需中控代理) | +| 指数价 | 期权 `index_px`,左右对齐 | + +报价刷新:页面手动刷新 + 计划编辑态可选 10~30s 自动刷新. + +--- + +## 8. 自动开仓编排 + +### 8.1 永期(仅全仓) + +建议默认顺序:**先期权、后永续**(期权失败成本低;永续失败则提示处理刚开的期权). + +``` +校验 full_margin + LIVE + 无冲突计划 +→ 期权限价买入(卖一) +→ 永续全仓张数市价开仓 + 挂交易所 TP/SL +→ 写 hedge_plans / legs,状态 active +``` + +部分失败补偿(最小集): + +| 情况 | 动作 | +|------|------| +| 期权成、永续败 | 告警;建议自动平期权(可配置)或转人工 | +| 永续成、期权败 | 告警;可选撤永续或重试期权;计划标 `partial` | + +### 8.2 期期(全仓与非全仓均可开) + +``` +校验 LIVE + 期权资金 +→ 腿1 限价买 +→ 腿2 限价买 +→ active;记录 target_price S* +``` + +两腿间勿留长时间单腿敞口;第二腿失败则标 `partial` 并告警. + +--- + +## 9. 状态机与冲突 + +``` +draft → opening → active → closing → closed + ↘ partial / failed +draft → cancelled +``` + +冲突规则: + +- **同标的同时至多 1 条 active 永期计划**(全局可 `MAX_ACTIVE_HEDGE_PLANS`). +- 永期 active 时与全仓「单仓」一致:**不与额外永续仓并存**(启动前校验无其它持仓,或本计划即为该仓). +- 期权页对手动平「计划绑定 inst」应提示归属对冲计划. + +--- + +## 10. 历史记录、统计与复盘(独立) + +**入口:** OKX 顶栏 **「对冲计划」** 页内三个 Tab(不单开顶栏项): + +| Tab | 路由建议 | 内容 | +|-----|----------|------| +| **计划** | `/hedge-plan` | 新建 / 草稿 / 进行中 | +| **历史** | `/hedge-plan?tab=history` | 已结束与取消的计划列表 + 详情复盘 | +| **统计** | `/hedge-plan?tab=stats` | 独立统计看板 | + +**不得**并入普通「交易记录与复盘」`/records`、全站「统计分析」`/stats`、策略交易记录. +腿可可选关联 `options_trades` / 监控 id,但 **计划合计盈亏与胜率只读本模块表**. + +币种展示约定:永续腿 USDT、期权腿 USDC;合计列标注 **「≈U(1:1)」**,不做实时汇率换算. + +--- + +### 10.1 数据落库 + +库文件:OKX 实例 `crypto.db`(与其它表同库). + +#### `hedge_plans`(一条计划) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_type` | TEXT | `perp_options` 永期 / `options_options` 期期 | +| `status` | TEXT | draft / opening / active / closing / closed / partial / failed / cancelled | +| `underlying` | TEXT | BTC / ETH | +| `direction` | TEXT | 永期:long/short;期期可空 | +| `entry_mark` | REAL | 开仓参考价(标记/指数快照) | +| `tp` | REAL | 永期止盈价;可空 | +| `sl` | REAL | 永期止损价;可空 | +| `target_price` | REAL | 期期目标价 S\*;可空 | +| `sizing_mode_at_open` | TEXT | 开仓时 `risk`/`full_margin` 快照 | +| `perp_size` | REAL | 永期张数/币量快照;期期空 | +| `margin` | REAL | 占用保证金快照 | +| `leverage` | REAL | 杠杆快照 | +| `premium_total` | REAL | 期权已付权利金合计(USDC) | +| `realized_pnl_perp` | REAL | 永续已实现(USDT);止盈为正,止损为负 | +| `realized_pnl_options` | REAL | 期权账:止盈场景记 **−权利金**;止损场景记 **强平真实盈亏** | +| `realized_pnl_total` | REAL | 见 §5.1 / §5.2 公式;统计与微信共用此值 | +| `stats_bucket` | TEXT | 可选冗余:`tp` / `sl` / `oo_expiry_loss` / `oo_target` / `other` 便于统计筛选 | +| `close_reason` | TEXT | 见下表枚举 | +| `wechat_start_sent` | INTEGER | 开仓推送是否已发 | +| `wechat_end_sent` | INTEGER | 结束推送是否已发 | +| `note` | TEXT | 人工复盘短评 | +| `created_at` | TEXT | | +| `opened_at` | TEXT | 首次腿成交时间 | +| `closed_at` | TEXT | **计划结束时间**(止盈/止损/到期收口等) | +| `preview_json` | TEXT | 开仓前情景测算快照(可选) | + +**`close_reason` 枚举** + +| 值 | 含义 | 是否算计划结束 | 合计口径 | +|----|------|----------------|----------| +| `perp_tp` | 永续止盈 | **是**(立刻 closed) | **止盈盈利 − 权利金** | +| `perp_sl` | 永续止损 + 期权强制平 | **是** | **期权盈利 − 永续亏损** | +| `target_win_leg` | 期期已平盈利腿(中间态可暂不 closed) | 腿未齐前可不结束 | 待亏损腿到期后定合计 | +| `oo_expiry_loss` | 期期到期且合计无盈利 | **是** | **总亏损**(settled ≤ 0,常 ≈ −保费) | +| `oo_expiry_win` | 期期到期合计仍盈利 | **是** | 实际到期合计 | +| `expiry` | 其它到期收口 | **是** | 实际结算 | +| `manual` | 人工结束 | **是** | 按当时已实现 | +| `partial_fail` | 半腿失败收尾 | **是** | 按补偿结果 | +| `cancelled` | 未真正开仓取消 | 是(无盈亏) | 0 | + +说明:止盈结束时期权腿可标 `hold_to_expiry`/`orphaned_after_tp`,**计划已 closed**,后续期权盈亏不回写 `realized_pnl_total`. + +#### `hedge_plan_legs`(一条腿) + +| 字段 | 类型建议 | 说明 | +|------|----------|------| +| `id` | INTEGER PK | | +| `plan_id` | INTEGER FK | | +| `leg_role` | TEXT | `perp` / `option_hedge` / `option_a` / `option_b` | +| `symbol` 或 `inst_id` | TEXT | 永续符号或期权合约 id | +| `opt_type` | TEXT | C/P;永续空 | +| `strike` | REAL | 期权行权价 | +| `side` | TEXT | long/short 或 buy | +| `size` | REAL | 张数或币量 | +| `avg_open` | REAL | 开仓均价/权利金单价 | +| `premium` | REAL | 该腿已付权利金(期权) | +| `status` | TEXT | open / closed / hold_to_expiry | +| `linked_monitor_id` | INTEGER | 可选,永续监控 | +| `options_trade_id` | INTEGER | 可选,期权成交表 | +| `realized_pnl` | REAL | 该腿已实现 | +| `close_reason` | TEXT | 腿级原因 | +| `opened_at` / `closed_at` | TEXT | | + +--- + +### 10.2 历史列表(列定义) + +筛选:**类型**(全部/永期/期期)、**状态**、**标的**、**日期**(按 `opened_at` 或 `closed_at`). + +| 列 | 来源 | 展示 | +|----|------|------| +| ID | id | `#12` | +| 类型 | plan_type | 永期对冲 / 期期对冲 | +| 标的 | underlying + direction | 如 `ETH 多` / `ETH 双买` | +| 状态 | status | 中文标签 | +| 开仓时间 | opened_at | | +| 结束时间 | closed_at | 进行中显示 — | +| 平仓原因 | close_reason | 中文(止盈离场/止损联动平/目标价平盈利腿/到期…) | +| 保费 | premium_total | `x.xx USDC` | +| 合计盈亏 | realized_pnl_total | 着色 +/- ,单位 ≈U | +| 腿摘要 | legs | 如 `永续✓ · Put持仓` / `Call已平 · Put到期` | +| 操作 | | 详情 | + +行操作:**详情**(主)、可选「补写短评」. + +--- + +### 10.3 计划详情 / 复盘页(字段) + +从历史点进去的详情页 = **主复盘面**,分块如下. + +#### A. 计划摘要 + +| 项 | 字段 | +|----|------| +| 类型 / 标的 / 方向 | plan_type, underlying, direction | +| 状态 / 平仓原因 | status, close_reason | +| 时间线 | created_at → opened_at → closed_at | +| 开仓时计仓 | sizing_mode_at_open, margin, leverage, perp_size | +| 关键价位 | entry_mark, tp, sl(永期), target_price(期期) | +| 盈亏 | realized_pnl_perp / realized_pnl_options / realized_pnl_total | +| 开仓情景快照 | preview_json 折叠展示(止盈合计/宽止损合计等) | + +#### B. 腿明细表 + +| 列 | 说明 | +|----|------| +| 角色 | 永续 / 保险期权 / 期期腿A/B | +| 合约 | symbol / inst_id | +| 数量 | size | +| 开仓价/保费 | avg_open, premium | +| 状态 | open / closed / 持有至到期 | +| 盈亏 | realized_pnl | +| 平仓原因 | close_reason | +| 关联 | 链到期权成交或监控(有则显示) | + +#### C. 复盘短评(必做入口) + +| 项 | 说明 | +|----|------| +| `note` | 多行文本,可空;保存 `PATCH /api/hedge-plan//note` | +| 提示文案 | 建议写:开仓理由、结果是否符合情景测算、下次调整 | + +**不做(本期):** 复盘截图上传、填入「交易记录与复盘」表单、纳入 AI 日/周复盘. +**可后置:** 以计划摘要生成 AI 点评(独立按钮,不写进 trade_records). + +#### D. 终态示例文案(便于复盘理解) + +| 场景 | 详情页状态说明 | 合计 | +|------|----------------|------| +| 永期止盈 | 「计划已结束(止盈);期权腿可不强平,账上已扣全部权利金」 | 止盈盈利 − 权利金 | +| 永期止损 + 期权已强平 | 「计划已结束(止损保护:期权已联动平仓)」 | 期权盈利 − 永续亏损 | +| 期期到期无盈利 | 「计划已结束(到期无盈利)」 | 总亏损(计入统计) | +| 期期达 S\* 后亏损腿仍持有 | 「盈利腿已平;待亏损腿到期后结账」 | 暂不入 closed 统计,或单独「收尾中」 | + +--- + +### 10.4 统计页(独立看板) + +**筛选:** 日期区间、类型(全部/永期/期期)、标的、结束桶(`tp`/`sl`/`oo_expiry_loss`/…). + +**聚合规则:** 仅 `status=closed`;胜场 = `realized_pnl_total > 0`. + +#### 永期口径(冻结) + +| 统计桶 | `close_reason` | 单笔盈亏公式 | 汇总 | +|--------|----------------|--------------|------| +| **止盈统计** | `perp_tp` | **止盈盈利 − 期权权利金** | sum / 笔数 / 胜率 | +| **止损统计** | `perp_sl` | **期权盈利 − 永续亏损** | sum / 笔数 /「保护后净亏」均值 | + +实现校验示例: + +``` +# 止盈 +realized_pnl_total = pnl_perp_tp - premium_total + +# 止损(亏损额取绝对值) +realized_pnl_total = pnl_option_close - abs(pnl_perp_sl) +# 等价有符号: pnl_option_close + pnl_perp_sl(后者为负) +``` + +#### 期期口径(冻结) + +| 统计桶 | 条件 | 单笔盈亏 | +|--------|------|----------| +| **到期无盈利** | 到期收口且合计 ≤ 0 | **总亏损**写入 `realized_pnl_total`(负值),计入区间净亏与「到期亏损」汇总 | +| 目标价路径 | 盈利腿已平 + 亏损腿到期后 | 两腿 realized 之和 | +| 到期仍盈利 | 合计 > 0 | 实际结算合计 | + +#### 总览卡片 + +| 指标 | 计算 | +|------|------| +| 计划笔数 | count(closed) | +| 胜率 | 胜场 / 笔数 | +| 区间净盈亏 | sum(realized_pnl_total) | +| 止盈桶净盈亏 | sum where stats_bucket=tp | +| 止损桶净盈亏 | sum where stats_bucket=sl | +| 期期到期亏损合计 | sum where oo_expiry_loss(绝对值或带符号合计) | +| 总保费支出 | sum(premium_total) | +| 平均持仓时长 | avg(closed_at − opened_at) | + +#### 分类型卡片 + +永期、期期各一套:笔数、胜率、净盈亏、平均保费;永期再拆 **止盈桶 / 止损桶**. + +#### 退出结构 + +| 指标 | 过滤 | +|------|------| +| 止盈结束笔数 | `perp_tp` | +| 止损结束笔数 | `perp_sl` | +| 期期到期无盈利笔数 | `oo_expiry_loss` | +| 目标价路径完结 | 含 `target_win_leg` 后收尾 | +| 半腿/失败 | `partial_fail` / failed | + +#### 简易表(可选) + +最近 N 条已结束计划迷你列表,点击跳详情. + +**导出(P5 可选):** CSV. + +--- + +### 10.5 与现有页面关系 + +| 现有页 | 关系 | +|--------|------| +| 交易记录与复盘 | **不写入**;永续腿若系统仍落 `trade_records`,可标记来源「对冲计划#id」,但人工复盘以对冲详情为准 | +| 统计分析 | **不合并**对冲净盈亏到全站数字(避免重复或口径混乱) | +| 期权页成交/持仓 | 腿 `options_trade_id` 可跳转对照;期权页仍可看单腿 | +| 中控 | V1 不聚合;V2 可选只读摘要 | + +--- + +### 10.6 API(历史 / 统计 / 复盘) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/hedge-plan/list` | 进行中+草稿;`status` 过滤 | +| GET | `/api/hedge-plan/history` | 已结束列表;类型/日期/标的 | +| GET | `/api/hedge-plan/` | 详情 = 计划 + legs + note + preview | +| PATCH | `/api/hedge-plan//note` | 保存复盘短评 | +| GET | `/api/hedge-plan/stats` | query:`from`,`to`,`plan_type`,`underlying` → 总览+分类型+退出结构 | + +--- + +## 11. 监控线程 + +在 `crypto_okx` 同进程内新增 **`hedge_plan_monitor_loop`**(可与 `options_monitor_loop` 并列): + +| 职责 | | +|------|--| +| 永期 | 侦测永续 TP/SL → 按 §5.1 结束计划并结账;SL 时强制平期权 | +| 期期 | 侦测价触 S\* → 平盈利腿;到期无盈利 → 结束并记总亏损 | +| 微信 | **开始必推、结束必推**(§5.3);半腿/强平失败告警 | +| 幂等 | `wechat_start_sent` / `wechat_end_sent` 防重复推送 | + +--- + +## 12. 配置项与前端 env 页 + +对冲相关开关 **一律在 OKX 实例「env 配置」页维护**,不要求 SSH 改 `.env`. +实现对齐现有白名单模式(`lib/env/env_ui_manifest.py` 的「期权账户」分组). + +### 12.1 前端分组 + +在 env 配置页新增独立卡片,标题 **「对冲计划」**: + +- **仅 OKX** 展示(Binance/Gate 不出现) +- 放在 **「期权账户」下方**(依赖期权模块) +- 卡片说明文案建议: + - 永期开仓还要求「交易执行」里计仓模式为 **全仓**(`POSITION_SIZING_MODE=full_margin`) + - 真实下单还与「交易所与实盘」→ `LIVE_TRADING_ENABLED`、本卡片 `HEDGE_PLAN_LIVE_ORDER` 同时开启 + - `HEDGE_PLAN_ENABLED` 关闭时隐藏顶栏「对冲计划」并拒绝启动计划 + +### 12.2 本分组字段(前端可配) + +| 变量 | 前端标签 | 默认 | 控件 | 热更新 | 说明 | +|------|----------|------|------|--------|------| +| `HEDGE_PLAN_ENABLED` | 启用对冲计划 | false | bool | 热更优先 | 总开关:导航 + API | +| `HEDGE_PLAN_SHOW_PERP_OPTIONS` | 显示永期对冲 | true | bool | 热更 | 关则隐藏永期 Tab,不可测算/开仓 | +| `HEDGE_PLAN_SHOW_OPTIONS_OPTIONS` | 显示期期对冲 | true | bool | 热更 | 关则隐藏期期 Tab,不可测算/开仓 | +| `HEDGE_PLAN_LIVE_ORDER` | 允许对冲真实下单 | false | bool | 热更 | 关则只测算/草稿 | +| `HEDGE_PLAN_OPEN_ORDER` | 永期开仓顺序 | options_first | select:`options_first`/`perp_first` | 热更 | 默认先期权后永续 | +| `HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS` | 永期止损后强制平期权 | true | bool | 热更 | **保护机制,默认 true** | +| `HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS` | 永期止盈后强制平期权 | false | bool | 热更 | **默认 false,保险腿不平** | +| `HEDGE_PLAN_OO_CLOSE_WINNER_ONLY` | 期期只平盈利腿 | true | bool | 热更 | 达目标价只平盈利方 | +| `HEDGE_PLAN_OO_CLOSE_MODE_ENABLED` | 期期平仓模式(方案C) | true | bool | 热更 | 开:页面可选到期平/全平;关:固定到期平 | +| `MAX_ACTIVE_HEDGE_PLANS` | 最大同时活跃计划数 | 1 | number | 热更 | 建议保持 1 | +| `HEDGE_PLAN_MONITOR_POLL_SECONDS` | 对冲监控轮询(秒) | 15 | number | 热更 | 侦测 TP/SL/目标价 | +| `HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION` | 半腿失败时自动平期权 | true | bool | 热更 | 期权成、永续败时的补偿 | + +**不放入本分组、沿用已有卡片:** + +| 已有位置 | 键 | 对冲用途 | +|----------|-----|----------| +| 交易执行 | `POSITION_SIZING_MODE` | 全仓才允许永期开仓 | +| 交易执行 | `FULL_MARGIN_BUFFER_RATIO` | 默认 0.98 | +| 交易执行 | `BTC_LEVERAGE` | BTC/ETH 档(默认 10) | +| 交易所与实盘 | `LIVE_TRADING_ENABLED` | 总实盘门 | +| 期权账户 | `OKX_OPTIONS_*` | 期权 API、预算、标的 | + +### 12.3 `.env.example` 片段(实现时写入 OKX) + +```env +# --- 对冲计划(仅 OKX;前端 env「对冲计划」) --- +HEDGE_PLAN_ENABLED=false +HEDGE_PLAN_SHOW_PERP_OPTIONS=true +HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true +HEDGE_PLAN_LIVE_ORDER=false +HEDGE_PLAN_OPEN_ORDER=options_first +HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS=true +HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS=false +HEDGE_PLAN_OO_CLOSE_WINNER_ONLY=true +HEDGE_PLAN_OO_CLOSE_MODE_ENABLED=true +MAX_ACTIVE_HEDGE_PLANS=1 +HEDGE_PLAN_MONITOR_POLL_SECONDS=15 +HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION=true +``` + +### 12.4 编码触点 + +| 文件 | 改动 | +|------|------| +| `lib/env/env_ui_manifest.py` | `_HEDGE_PLAN_SECTION`,`exchanges={"okx"}`,接入 `ui_sections_for_exchange` | +| `crypto_monitor_okx/.env.example` | 增加上表键与分组注释 | +| `lib/env/env_schema.py` | 纳入 `HOT_RELOAD_EXACT`(及必要时重启列表) | +| `docs/env配置说明.md` | 上线时补「对冲计划」小节 | + +推荐分期:**P0 先做 env 白名单 + 开关可读**,页面按 `HEDGE_PLAN_ENABLED` 显隐导航. + +--- + +## 13. 前端与路由(实现要点) + +- 导航:OKX 实例顶栏 **「对冲计划」**(display prefs 可加 `show_nav_hedge_plan`). +- 路由建议:`/hedge-plan`(主)、`/hedge-plan/history`、`/hedge-plan/stats`(或单页 Tab). +- 模板/静态:`lib/options` 旁新增 `lib/hedge_plan/`(或 `lib/hedge/`),复用: + - 期权链 CSS/T 型渲染思路(`options_panel.js` / `options-strike-table--t`) + - `compute_full_margin_sizing` / `position_sizing_lib` + - 永续下单 + TPSL、期权限价开平 API + +API 草图: + +- `GET /api/hedge-plan/market` — 永续行情 + 全仓试算 +- `GET /api/hedge-plan/options-chain` — 期权链 +- `POST /api/hedge-plan/preview` — 情景测算 +- `GET /api/hedge-plan/list` / `history` / `` / `stats` +- `PATCH /api/hedge-plan//note` — 复盘短评 +- `POST /api/hedge-plan` — 保存草稿/启动 +- `POST /api/hedge-plan//cancel|close` + +非全仓请求启动永期 → `400` 明确:「永期对冲开仓仅全仓模式可用」. + +--- + +## 14. 分期实施 + +| 阶段 | 交付 | 验收 | +|------|------|------| +| **P0** | env「对冲计划」分组 + 页框 + 行情 + 永期列表/期期 T + 情景测算 + 门禁 | env 可改开关;非全仓无法启动永期;全仓可看建议张数 | +| **P1** | 表结构 + 草稿/列表 | DB 可查 | +| **P2** | 永期自动开仓(全仓) | 双腿成交入计划 | +| **P3** | 监控:止损强制平期权;止盈不平期权 | 用例测 TP/SL 分支 | +| **P4** | 期期开仓 + 目标价只平盈利腿 | 达价仅平一侧 | +| **P5** | 历史列表列 + 详情复盘(note) + 统计看板 + 微信 | 与 /records /stats 隔离;止损/止盈文案可区分 | + +建议顺序严格;P3 规则错误会误平保险腿,上线前用 dry-run / paper flags. + +--- + +## 15. 与现有文档关系 + +| 文档 | 关系 | +|------|------| +| [期权对冲方案分析.md](./期权对冲方案分析.md) | 策略观念;本模块是其「计划化 + 自动执行」实现 | +| [对冲计划策略与P0校验.md](./对冲计划策略与P0校验.md) | P0 交付与口径校验 | +| [期权方案.md](./期权方案.md) / [期权用法.md](./期权用法.md) | 期权 API、仅买方、限价规则必须遵守 | +| [期权开平仓与监控说明.md](./期权开平仓与监控说明.md) | 买一平仓、门控、监控与风险;线上 `/options/guide` | +| [position-sizing-mode.md](./position-sizing-mode.md) | 全仓公式与缓冲 0.98 | + +本模块上线后,可在《期权对冲方案分析》末尾增加「系统对冲计划」链接指向本文. + +--- + +## 16. 已拍板规则摘要(校验清单) + +- [x] 放在 **OKX 实例**,名 **永期对冲 / 期期对冲** +- [x] 永期止盈 → **期权不强制平**,但 **计划算结束**;统计 = **止盈盈利 − 权利金** +- [x] 永期止损 → **期权必须强制平**,计划结束;统计 = **期权盈利 − 永续亏损** +- [x] 期期按目标价 → **只平盈利方**;到期无盈利 → **算结束并统计总亏损** +- [x] 对冲计划 **开始与结束均企业微信推送** +- [x] **独立历史 + 独立统计 + 计划详情复盘**(短评 note;不进普通交易复盘) +- [x] 永期开仓 **仅全仓**;非全仓永期只算账 +- [x] 非全仓允许 **期期**开仓;全仓永期+期期均可 +- [x] 永期张数:**ETH/BTC 10x 全仓 × 0.98**,先算盈亏再选期权 +- [x] 行情自动;永期期权 **列表式**;期期 **T 型** +- [x] 对冲开关在前端 **env 配置 →「对冲计划」** 维护(仅 OKX);计仓/杠杆/期权 API 复用已有分组 +- [x] 期权腿实盘平仓:**禁市价、只锁买一、分批;流动性/2×门控见开平仓说明** + +--- + +## 17. 免责与边界 + +- 双账户非原子成交,存在半腿风险. +- 止盈结束账上按权利金全额计成本;期权若仍持仓,后续行情 **不再改计划合计**,属设计意图. +- 止盈后期权可能继续损耗直至到期,与「计划已结束」并存. +- 买方权利金可能全部损失;期期到期无盈利记总亏损,属设计意图. +- 本文不构成投资建议. diff --git a/docs/对冲计划策略与P0校验.md b/docs/对冲计划策略与P0校验.md new file mode 100644 index 0000000..3506fe7 --- /dev/null +++ b/docs/对冲计划策略与P0校验.md @@ -0,0 +1,109 @@ +# 对冲计划策略说明与 P0 校验 + +> 配套实现方案:[对冲计划开发方案.md](./对冲计划开发方案.md) +> 本文记录 **策略口径** 与 **P0 代码校验**,确认可继续 P1+. + +--- + +## 1. 策略摘要 + +| 类型 | 账户 | 作用 | +|------|------|------| +| **永期对冲** | 同账户永续 + 期权买方 | 全仓做方向,期权买保险 | +| **期期对冲** | 同账户双买方期权 | 目标价兑现盈利腿,亏损腿到期 | + +### 永期结束与统计 + +| 事件 | 期权处理 | 计划 | 统计公式 | +|------|----------|------|----------| +| 止盈 | 不强平 | **结束** | **止盈盈利 − 权利金** | +| 止损 | **强制平** | **结束** | **期权盈利 − 永续亏损**(有符号相加) | + +### 期期结束与统计 + +| 事件 | 处理 | 统计 | +|------|------|------| +| 达目标价 | 只平盈利腿 | 待亏损腿到期后结账 | +| 到期无盈利 | **结束** | **总亏损** | + +起止均企业微信推送(P2+ 监控落地后再接). + +### 门禁 + +- 永期开仓:**仅** `POSITION_SIZING_MODE=full_margin` +- 非全仓:永期可测算不可开;期期 P0 起可测算(开仓后续版本) +- env:OKX「对冲计划」分组;须 `HEDGE_PLAN_ENABLED=true` 才显示导航 + +--- + +## 2. P0 已交付 + +| 项 | 状态 | +|----|------| +| env 白名单「对冲计划」9 字段 | 有 | +| OKX `.env.example` 字段 | 有 | +| `/hedge-plan` 页(永期列表 / 期期 T) | 有 | +| `/api/hedge-plan/market|options-chain|preview|gates` | 有 | +| 全仓建议张数(可用×0.98×10x) | 有 | +| 情景测算止盈/止损口径 | 有 | +| 启动开仓 | **禁用**(明示 P0) | +| 历史/统计/监控/微信 | **未做**(P1–P5) | + +关键文件: + +- `lib/hedge_plan/hedge_plan_calc_lib.py` +- `lib/hedge_plan/hedge_plan_register.py` +- `lib/hedge_plan/templates/hedge_plan_panel.html` +- `lib/common/static/hedge_plan.js` +- `tests/test_hedge_plan_calc.py` + +--- + +## 3. 校验清单(可行性) + +### 计算口径 + +``` +止盈: perp_pnl(tp) - premium +止损: option_expiry_pnl(spot=sl) + perp_pnl(sl) +期期到期无盈利: expiry_flat_total <= 0 → 记总亏损 +``` + +单测覆盖:`tests/test_hedge_plan_calc.py`. + +### 门禁 + +- `risk` + 永期 → `can_start=false`,文案含「全仓」 +- `HEDGE_PLAN_ENABLED=false` → 导航隐藏(服务端 template 读 env) + +### 行情 + +- 永续:`exchange.fetch_ticker` + `get_available_trading_usdt` + `compute_full_margin_sizing` +- 期权:`build_option_chain`(与期权页同源) + +### 已知边界(非 P0 bug) + +1. 建议张数未强制 `amount_to_precision`(开仓阶段再对齐交易所精度). +2. 止盈账扣全额权利金,与期权是否仍持仓无关(策略如此). +3. 热更新 `HEDGE_PLAN_ENABLED` 后需刷新页面才显隐导航. +4. 嵌入壳 Tab 已注册 `hedge_plan`;中控能力勾选若需显式「对冲」可后续加. + +--- + +## 4. 服务器启用步骤 + +```bash +# env 配置页 → 对冲计划 → HEDGE_PLAN_ENABLED=true → 保存 +# 或服务器: +cd /opt/crypto_monitor/crypto_monitor_okx +# 确保 .env 含 HEDGE_PLAN_* 字段后 +pm2 restart crypto_okx --update-env +``` + +验收: + +1. 顶栏出现「对冲计划」 +2. 永期可见建议张数(全仓时) +3. 点「计算」得到止盈/止损合计 +4. 「启动计划」禁用 +5. env 页可见「对冲计划」分组 diff --git a/docs/期权开平仓与监控说明.md b/docs/期权开平仓与监控说明.md new file mode 100644 index 0000000..b6129e7 --- /dev/null +++ b/docs/期权开平仓与监控说明.md @@ -0,0 +1,104 @@ +# OKX 期权 — 开平仓与监控说明 + +> 独立页查看(登录后):`/options/guide` +> 对冲计划侧同步见 [对冲计划开发方案.md](./对冲计划开发方案.md) §期权腿开平仓. + +--- + +## 1. 开仓方式 + +| 项 | 规则 | +|----|------| +| 方向 | **仅买方**(Call / Put 限价买入) | +| 价格 | **卖一 ask** 限价;无卖一时可用标记估算展示,实下单仍以可挂限价为准 | +| 张数 | 1 张 = 0.01 ETH/BTC;可按预算打满或指定数量 | +| 资金 | 交易账户 **USDC**(或 USDG);不自动兑划 | +| 入口 | OKX 实例 **期权** 页列表 / T 型;对冲计划可带期权腿开仓 | + +开仓后写入 `options_trades`(open),并在持仓卡展示权利金、买盘深度、按买一可回收等. + +**同合约加仓**:每次买入再插一条 open 记录;展示权利金 / 平仓门控 / 翻倍提醒按同合约 **SUM(premium_paid)** 汇总,不再只取最新一笔. + +--- + +## 2. 平仓方式 + +### 2.1 统一规则(手动 / 目标自动共用执行核) + +1. **禁止市价平仓**(代码硬关闭,忽略 `OKX_OPTIONS_ALLOW_MARKET_CLOSE`). +2. **只锁买一**:本轮张数 = `min(持仓, 买一深度)`,限价 = 校验通过当刻的买一价. +3. **不吃买二及以下**;买一不够则只平本轮能吃掉的部分,**剩余仓位保留**,下次再平再锁新的买一. +4. 全程 `reduceOnly` 限价卖. +5. **平仓限价挂单超时自动撤**:卖出/平仓委托未成交超过默认 **10 分钟**(`OKX_OPTIONS_PENDING_TTL_SECONDS`,默认 600)由监控自动撤销,并可微信通知;UI「委托」面板实时展示挂单与剩余自动撤倒计时. + +示例:持仓 300、买一深度 200 → 本轮只平 200;剩 100 等下次「买一平仓」或目标位再次触发. + +### 2.2 手动「买一平仓」 + +- 入口:持仓卡按钮. +- **只校验有效流动性**(买一非残档、有深度). +- **不卡**「回收 ≥ 2×权利金」门控(用户主动平仓). + +### 2.3 目标位自动平仓 + +- 设置目标指数后由监控轮询;Call 指数 ≥ 目标 / Put 指数 ≤ 目标触发. +- 触发后走同一买一执行核. +- **额外门控**(不是独立自动平):仅当目标已触达时才检查;买一可回收 ≥ **权利金 × 2**,且连续约 **120 秒**(env:`OKX_OPTIONS_CLOSE_RECYCLE_MULT` / `OKX_OPTIONS_CLOSE_HOLD_SECONDS`).**到 2× 本身不会自动平仓**. +- 首次通过后,同仓**续批**只再验流动性,不再重跑 2 分钟计时. +- 无有效买一或门控未就绪 → 本轮不挂单,等下一轮;已有未成交卖平单则等成交,不撤了重挂. + +### 2.4 翻倍出场(可选) + +- 开仓勾选或持仓卡开启;倍数默认 **1**(盈利金额 = 初始权利金). +- 触发条件:买一可回收 ≥ 权利金 × (1 + 倍数);达标后走买一限价平,**不再**额外卡「回收≥2×」门控(倍数本身已是出场条件). +- 可随时关闭;与目标位监控并行,谁先达标谁平. +- 与「翻倍提醒」独立:提醒只推微信,翻倍出场会真正挂平仓单. + +--- + +## 3. 监控逻辑 + +| 监控 | 行为 | +|------|------| +| 持仓 / 买盘预览 | 轮询刷新;净盈亏按**本轮买一可回收 − 权利金** | +| 残档买一 | 买一 ≪ 标记/内在价值(默认 < 30%) → 禁止按买盘平,UI 显示无效 | +| 未成交委托 | 期权下单区右侧「委托」列表展示开/平仓限价单,可手动撤销;页面轮询刷新 | +| 平仓挂单超时 | 卖出平仓限价超 TTL 未成交 → 自动撤单(默认 10 分钟) | +| 目标位 | 独立监控表;触发后买一平;推送企业微信(防重复) | +| 翻倍出场 | 开仓/持仓可开关;自选倍数(默认1);1倍=盈利等于权利金(可回收≥2×权利金)达标后买一限价平;可随时关闭;与目标位并行 | +| 翻倍提醒 | 未实现口径达权利金 × `OKX_OPTIONS_PROFIT_ALERT_RATIO` 时推送一次(仅提醒,不平仓) | +| 到期 | 无系统止损;到期交割/保险腿自灭(对冲计划另有退出规则) | + +--- + +## 4. 平仓校验(门控) + +| 门控 | 手动买一平 | 目标自动平 | 翻倍出场 | 说明 | +|------|------------|------------|----------|------| +| 有效流动性 | ✅ 必验 | ✅ 必验 | ✅ 必验 | 残档买一 / 无买一 → 拒平 | +| 回收 ≥ 2× 权利金 + 持续 hold | ❌ | ✅ 首次 | ❌(倍数即条件) | 目标平仓专用门控 | +| 回收 ≥ 权利金×(1+倍数) | ❌ | ❌ | ✅ 触发条件 | 1倍 ⇒ 回收≥2×权利金 | +| 锁定买一价 | ✅ | ✅ | ✅ | 下单价 = 通过校验时的买一 | +| 市价兜底 | ❌ | ❌ | ❌ | 永不市价 | + +--- + +## 5. 风险点 + +1. **流动性不足**:只平买一深度,大仓位可能多次才能平完;若买一突然撤档,限价可能挂着 — 超 TTL 会自动撤,之后需再次点平或等目标触发. +2. **开仓挂单**:买入委托不在超时自动撤范围(仅平仓卖单);可在「委托」面板手动撤销. +3. **残档假买一**:若未拦住残档会严重贱卖 — 系统用标记/内在价值比例拦截,但不等于保证最优成交价. +4. **权利金沉没**:手动可在未达 2× 时平仓,可能主动止损或提前锁利不及预期. +5. **无市价强平**:盘口真空时系统**不会**市价砸盘,仓位可能留到到期. +6. **目标位只看指数**:触达后仍受买一/2×门控约束,可能「到价却平不掉」. +7. **对冲计划腿**:期权腿退出规则见对冲方案;独立期权页平仓勿与计划状态脱节. +8. **期期自动张数**:对冲计划页为「同张数 / 做多 / 做空」(已无均分);做多/做空按 Call·Put 比例拆,口径与比例见 env `HEDGE_PLAN_OO_BIAS_SPLIT_BY`、`HEDGE_PLAN_OO_BIAS_RATIO`. + +--- + +## 6. 相关文档 + +- [期权用法.md](./期权用法.md) — 资金兑划与页面操作 +- [期权方案.md](./期权方案.md) — env 与架构 +- [对冲计划开发方案.md](./对冲计划开发方案.md) — 永期/期期与期权腿 +- [系统说明.md](./系统说明.md) — 实例操作与门禁总手册 diff --git a/docs/期权方案.md b/docs/期权方案.md new file mode 100644 index 0000000..8c4f278 --- /dev/null +++ b/docs/期权方案.md @@ -0,0 +1,78 @@ +# OKX 期权模块 — 技术方案 + +> 适用范围:`crypto_monitor_okx` 实例;永续与期权共用同一套 `OKX_API_*`,不新增 PM2 进程. + +## 1. 目标 + +在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力: + +- 永续/关键位与期权:**同一账户 API**(`OKX_API_*`) +- 两个 ccxt 客户端:`exchange`(defaultType=swap)与 `exchange_options`(defaultType=option),身份相同 +- 顶栏资金:**资金账户/交易账户=USDT**;**期权资金/期权交易=USDC** +- 支持 **手动 USDT→USDC 兑换** 与 **账户内划转**(无主↔子划转) +- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC) + +## 2. 交易规则(硬约束) + +| 规则 | 说明 | +|------|------| +| 仅买方 | 开仓 `buy`,平仓 `sell`;禁止卖方开仓 | +| 产品 | `BTC-USD_UM` / `ETH-USD_UM`(线性,USDC/USDG 结算) | +| 到期 | 仅展示 ≤2 日到期合约(可配置 `OKX_OPTIONS_MAX_DTE_DAYS`) | +| 虚实 | 仅 **轻度实值**(`OKX_OPTIONS_ITM_ONLY`) | +| 合约规格 | **1 张 = 0.01 ETH/BTC**(`ctMult=0.01`,以接口为准) | +| 报价单位 | 盘口 ask/bid = **每 1 ETH/BTC** 的 USD 价 | +| 权利金 | `总权利金 = 报价 × ETH数量`;`张数 = ETH数量 / 0.01` | +| 单笔预算 | `≤ OKX_OPTIONS_TRADE_BUDGET_USDC`(默认 10),算张数 × `OKX_OPTIONS_BUDGET_BUFFER`(默认 0.95) | +| 开仓 | 限价买单,价格 = 卖一 | +| 平仓 | 限价卖单,价格 = 买一(市价需显式开启且二次确认) | +| 监控 | 浮盈 / 已付权利金 ≥ 100% → 企业微信推送一次 | + +## 3. 架构 + +``` +crypto_okx(单 PM2) +├── exchange (swap) ← OKX_API_* +└── exchange_options (option)← 同一套 OKX_API_* + +lib/options/ +├── okx_options_lib.py # 封装于 lib/exchange/ +├── options_pricing_lib.py +├── options_db.py +├── options_monitor_lib.py +└── options_register.py # 路由 + 监控线程 +``` + +**说明:** `defaultType` 分离避免错路由;密钥身份唯一.旧 `OKX_OPTIONS_API_*` 已废弃. + +## 4. 资金与兑换 + +### 4.1 展示(实例顶栏) + +- **资金账户 / 交易账户**:USDT(永续侧) +- **期权资金账户 / 期权交易账户**:USDC +- 总资金:USDT + USDC(1:1),同账户 USDT 不重复累加期权侧 USDT + +### 4.2 兑换与划转 + +- 系统设置「币种兑换」:资金账户内 USDT ↔ USDC +- 「期权划转」:同账户 funding ↔ trading(USDC/USDT) +- **已移除**主↔子账户划转 + +## 5. 环境变量(要点) + +```bash +OKX_API_KEY= +OKX_API_SECRET= +OKX_API_PASSPHRASE= +OKX_OPTIONS_ENABLED=true +OKX_OPTIONS_TRADE_BUDGET_USDC=10 +OKX_OPTIONS_ACCOUNT_LABEL=账户·期权 +``` + +详见 [env配置说明.md](./env配置说明.md) 与 `.env.example`. + +## 6. 相关文档 + +- [期权用法.md](./期权用法.md) +- [对冲计划开发方案.md](./对冲计划开发方案.md) diff --git a/docs/期权用法.md b/docs/期权用法.md new file mode 100644 index 0000000..d682679 --- /dev/null +++ b/docs/期权用法.md @@ -0,0 +1,107 @@ +# OKX 期权 — 使用说明 + +## 1. 前置条件 + +1. OKX 账户已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`. +2. 在 `crypto_monitor_okx/.env` 配置 **唯一账户 API**(永续与期权共用): + +```bash +OKX_API_KEY=你的账户Key +OKX_API_SECRET=... +OKX_API_PASSPHRASE=... +OKX_OPTIONS_ENABLED=true +``` + +3. 重启实例:`pm2 restart crypto_okx --update-env` + +> 旧 `OKX_OPTIONS_API_*` 已废弃.若仅残留 OPTIONS 键而 `OKX_API_*` 为空,启动会自动回填. + +## 2. 资金准备 + +期权权利金使用 **USDC**,不能直接用 USDT 买入. + +### 推荐步骤 + +1. 查看顶栏: + - **资金账户 / 交易账户**:USDT + - **期权资金账户 / 期权交易账户**:USDC +2. **币种兑换**(资金账户内) + - 从 USDT 兑换为 USDC + - 先点 **询价**,确认预估获得量后点 **确认兑换** +3. **账户内划转** + - 从:资金账户 → 到:交易账户 + - 币种:USDC + - 将兑换得到的 USDC 划到交易账户 +4. 确认 **期权交易账户 USDC** 足够支付本笔权利金 + +系统 **不会** 自动兑换或划转,避免误动资金. + +## 3. 下单流程 + +1. 顶栏进入 **期权** +2. 选择 **ETH** 或 **BTC** +3. 选择 **到期日**(默认仅 1~2 日) +4. 选择 **看涨 Call** 或 **看跌 Put** +5. 在行权价列表中选 **轻度实值** 合约 +6. 查看: + - **卖一价**(每 1 ETH/BTC 的报价) + - **张数 / ETH 数量** + - **预估权利金**(USDC) +7. 选择 **按预算打满**(默认 10U×0.95)或 **指定 ETH 数量** +8. 点击 **限价买入**(价格 = 卖一) + +### 张数说明 + +- **1 张 = 0.01 ETH**(或 0.01 BTC)— 与 OKX App「合约价值」一致 +- 盘口报价是 **每 1 ETH** 的价格 + 例:报价 15.6,买 0.5 ETH(50 张)→ 权利金 ≈ 15.6 × 0.5 = **7.8 USDC** + +## 4. 持仓与平仓 + +持仓表字段对齐 OKX:合约,张数,开仓均价,标记价,净盈亏,收益率,到期等. + +**买一平仓:** + +1. 在持仓卡点击 **买一平仓** +2. 系统重读盘口并校验有效买一(非残档) +3. 本轮只按买一深度限价卖出;买一不够则剩余下次再平 +4. **市价平仓已禁用**(代码硬关闭) + +目标位自动平另需「可回收 ≥ 2×权利金并持续约 2 分钟」;细则见独立说明: + +- 仓库文档:[期权开平仓与监控说明.md](./期权开平仓与监控说明.md) +- 线上(登录后):`/options/guide` + +## 5. 微信提醒 + +需已配置 `WECHAT_WEBHOOK`. + +| 场景 | 标题 | 说明 | +|------|------|------| +| **开仓** | 【OKX期权·开仓】 | 下单成功并写入本地后必发(幂等) | +| **平仓** | 【OKX期权·平仓】 | 手动全平 / 目标位全平 / 到期或交易所平仓同步后必发(幂等) | +| 浮盈翻倍 | 【OKX期权·翻倍提醒】 | 未实现盈亏 ≥ 已付权利金约 100%,同一笔只提醒一次 | +| 挂单超时撤销 | 【OKX期权·挂单超时撤销】 | 平仓挂单超时被系统撤销 | + +## 6. 与永续 / 对冲计划的关系 + +| | 永续 | 期权 | +|--|------|------| +| API | `OKX_API_*`(同一套) | `OKX_API_*`(同一套) | +| 页面 | 实盘下单 / 关键位 | 期权 · 对冲计划 | +| 顶栏 | USDT 资金户+交易户 | USDC 期权资金+期权交易 | + +**期期对冲张数**(对冲计划页,与单独开期权共用预算算法): + +| 模式 | 说明 | +|------|------| +| 同张数(默认) | 两腿同 `n`,总权利金 ≤ 预算 | +| 按比例 | 主腿/次腿按 `HEDGE_PLAN_OO_BIAS_*` | + +## 7. 常见问题 + +**Q:以前的期权专用密钥还要配吗?** +- 不需要.统一写到 `OKX_API_*`. + +**Q:还能主↔子划转吗?** +- 已移除.只保留同账户内划转与币种兑换. diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..91fe5cc --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,25 @@ +/** + * CoinOX PM2 进程定义 (Ubuntu / Linux). + * + * 启动: pm2 start ecosystem.config.cjs + */ +const path = require("path"); + +const ROOT = __dirname; +const PY = path.join(ROOT, ".venv", "bin", "python"); + +module.exports = { + apps: [ + { + name: "coinox", + cwd: ROOT, + script: path.join(ROOT, "app.py"), + interpreter: PY, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: "800M", + env: { PYTHONPATH: ROOT }, + }, + ], +}; diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..54e157b --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +"""crypto_monitor shared libraries.""" diff --git a/lib/account_ledger/__init__.py b/lib/account_ledger/__init__.py new file mode 100644 index 0000000..226daa3 --- /dev/null +++ b/lib/account_ledger/__init__.py @@ -0,0 +1 @@ +"""实例账户流水(交易所资金/交易账户账单).""" diff --git a/lib/account_ledger/account_ledger_db.py b/lib/account_ledger/account_ledger_db.py new file mode 100644 index 0000000..6d029a5 --- /dev/null +++ b/lib/account_ledger/account_ledger_db.py @@ -0,0 +1,186 @@ +"""账户流水 SQLite 缓存.""" +from __future__ import annotations + +import time +from typing import Any, Optional + +from lib.account_ledger.account_ledger_normalize import PAGE_SIZE, VALID_ACCOUNTS + + +def ensure_account_ledger_tables(conn) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS account_ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + ccy TEXT NOT NULL, + amount REAL NOT NULL, + balance_after REAL, + kind TEXT, + raw_type TEXT, + symbol TEXT, + ref_id TEXT NOT NULL, + ts_ms INTEGER NOT NULL, + note TEXT, + synced_at REAL, + UNIQUE(account, ref_id, ccy, ts_ms) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_account_ledger_acc_ts " + "ON account_ledger_entries(account, ts_ms DESC)" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS account_ledger_meta ( + key TEXT PRIMARY KEY, + value TEXT + ) + """ + ) + conn.commit() + + +def meta_get(conn, key: str, default: str = "") -> str: + row = conn.execute( + "SELECT value FROM account_ledger_meta WHERE key=?", (key,) + ).fetchone() + if not row: + return default + try: + return str(row[0] if not hasattr(row, "keys") else row["value"]) + except Exception: + return default + + +def meta_set(conn, key: str, value: str) -> None: + conn.execute( + "INSERT INTO account_ledger_meta(key, value) VALUES(?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, str(value)), + ) + + +def upsert_entries(conn, rows: list[dict[str, Any]]) -> int: + if not rows: + return 0 + now = time.time() + n = 0 + for r in rows: + try: + conn.execute( + """ + INSERT INTO account_ledger_entries( + account, ccy, amount, balance_after, kind, raw_type, + symbol, ref_id, ts_ms, note, synced_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(account, ref_id, ccy, ts_ms) DO UPDATE SET + amount=excluded.amount, + balance_after=excluded.balance_after, + kind=excluded.kind, + raw_type=excluded.raw_type, + symbol=excluded.symbol, + note=excluded.note, + synced_at=excluded.synced_at + """, + ( + r["account"], + r["ccy"], + float(r["amount"]), + r.get("balance_after"), + r.get("kind") or "other", + r.get("raw_type") or "", + r.get("symbol") or "", + r["ref_id"], + int(r["ts_ms"]), + r.get("note") or "", + now, + ), + ) + n += 1 + except Exception: + continue + conn.commit() + return n + + +def query_entries( + conn, + *, + account: str, + start_ms: int, + end_ms: int, + page: int = 1, + page_size: int = PAGE_SIZE, + currencies: Optional[list[str]] = None, +) -> dict[str, Any]: + acc = (account or "").strip().lower() + if acc not in VALID_ACCOUNTS: + return {"items": [], "total": 0, "page": 1, "page_size": page_size, "pages": 0} + page = max(1, int(page or 1)) + page_size = max(1, min(50, int(page_size or PAGE_SIZE))) + start_ms = int(start_ms) + end_ms = int(end_ms) + params: list[Any] = [acc, start_ms, end_ms] + ccy_sql = "" + if currencies: + ccy_list = [c.strip().upper() for c in currencies if c and str(c).strip()] + if ccy_list: + placeholders = ",".join("?" for _ in ccy_list) + ccy_sql = f" AND ccy IN ({placeholders})" + params.extend(ccy_list) + total = conn.execute( + f"SELECT COUNT(*) FROM account_ledger_entries " + f"WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql}", + params, + ).fetchone()[0] + total = int(total or 0) + pages = (total + page_size - 1) // page_size if total else 0 + if pages and page > pages: + page = pages + offset = (page - 1) * page_size + rows = conn.execute( + f""" + SELECT account, ccy, amount, balance_after, kind, raw_type, symbol, + ref_id, ts_ms, note + FROM account_ledger_entries + WHERE account=? AND ts_ms>=? AND ts_ms<=?{ccy_sql} + ORDER BY ts_ms DESC, id DESC + LIMIT ? OFFSET ? + """, + params + [page_size, offset], + ).fetchall() + items = [] + for r in rows: + if hasattr(r, "keys"): + d = {k: r[k] for k in r.keys()} + else: + d = { + "account": r[0], + "ccy": r[1], + "amount": r[2], + "balance_after": r[3], + "kind": r[4], + "raw_type": r[5], + "symbol": r[6], + "ref_id": r[7], + "ts_ms": r[8], + "note": r[9], + } + from lib.account_ledger.account_ledger_normalize import kind_label_zh + + d["kind_label"] = kind_label_zh(d.get("kind") or "") + items.append(d) + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "pages": pages, + } + + +def prune_older_than(conn, min_ts_ms: int) -> None: + conn.execute("DELETE FROM account_ledger_entries WHERE ts_ms < ?", (int(min_ts_ms),)) + conn.commit() diff --git a/lib/account_ledger/account_ledger_normalize.py b/lib/account_ledger/account_ledger_normalize.py new file mode 100644 index 0000000..d08372c --- /dev/null +++ b/lib/account_ledger/account_ledger_normalize.py @@ -0,0 +1,192 @@ +"""账户流水:交易所原始记录 → 统一行模型.""" +from __future__ import annotations + +from typing import Any, Optional + + +ACCOUNT_FUNDING = "funding" +ACCOUNT_TRADING = "trading" +VALID_ACCOUNTS = frozenset({ACCOUNT_FUNDING, ACCOUNT_TRADING}) + +PAGE_SIZE = 10 + + +def _safe_float(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _safe_int(v: Any) -> Optional[int]: + if v is None or v == "": + return None + try: + n = float(v) + if n > 1e12: + return int(n) + if n > 1e9: + return int(n) + return int(n) + except (TypeError, ValueError): + return None + + +def _ts_ms(v: Any) -> Optional[int]: + if v is None or v == "": + return None + try: + n = float(v) + except (TypeError, ValueError): + return None + if n > 1e12: + return int(n) + if n > 1e10: + return int(n) + return int(n * 1000.0) + + +def kind_from_raw(raw_type: str, amount: Optional[float] = None) -> str: + t = (raw_type or "").strip().lower() + if not t: + return "other" + if "deposit" in t or t in ("1", "funding_deposit"): + return "deposit" + if "withdraw" in t or "withdrawal" in t: + return "withdraw" + if "transfer" in t or "dnw" in t or t in ("2", "18", "19"): + if amount is not None and amount < 0: + return "transfer_out" + if amount is not None and amount > 0: + return "transfer_in" + return "transfer" + if "funding" in t and "fee" in t: + return "funding_fee" + if t in ("funding_fee", "fundingfee", "8"): + return "funding_fee" + if "commission" in t or "fee" in t or t in ("commission", "5", "fee"): + return "commission" + if "realiz" in t or "pnl" in t or t in ("realized_pnl", "realizedpnl", "3"): + return "realized_pnl" + if "liqui" in t: + return "liquidate" + return "other" + + +def kind_label_zh(kind: str) -> str: + return { + "deposit": "充值", + "withdraw": "提现", + "transfer": "划转", + "transfer_in": "划入", + "transfer_out": "划出", + "realized_pnl": "已实现盈亏", + "funding_fee": "资金费", + "commission": "手续费", + "liquidate": "强平", + "other": "其他", + }.get((kind or "").strip().lower(), "其他") + + +def make_ref_id(*parts: Any) -> str: + bits = [] + for p in parts: + if p is None: + continue + s = str(p).strip() + if s: + bits.append(s) + return "|".join(bits) if bits else "" + + +def normalize_row( + *, + account: str, + ccy: str, + amount: Any, + ts_ms: Any, + ref_id: str, + raw_type: str = "", + balance_after: Any = None, + symbol: str = "", + note: str = "", + kind: str = "", +) -> Optional[dict[str, Any]]: + acc = (account or "").strip().lower() + if acc not in VALID_ACCOUNTS: + return None + ccy_u = (ccy or "").strip().upper() + if not ccy_u: + return None + amt = _safe_float(amount) + if amt is None: + return None + ts = _ts_ms(ts_ms) + if ts is None or ts <= 0: + return None + rid = (ref_id or "").strip() or make_ref_id(acc, ccy_u, ts, amt, raw_type) + k = (kind or "").strip().lower() or kind_from_raw(raw_type, amt) + bal = _safe_float(balance_after) + return { + "account": acc, + "ccy": ccy_u, + "amount": amt, + "balance_after": bal, + "kind": k, + "kind_label": kind_label_zh(k), + "raw_type": (raw_type or "").strip()[:120], + "symbol": (symbol or "").strip()[:80], + "ref_id": rid[:200], + "ts_ms": int(ts), + "note": (note or "").strip()[:240], + } + + +def from_ccxt_ledger_entry(entry: dict[str, Any], *, account: str) -> Optional[dict[str, Any]]: + if not isinstance(entry, dict): + return None + info = entry.get("info") if isinstance(entry.get("info"), dict) else {} + amount = entry.get("amount") + if amount is None: + amount = entry.get("change") + if amount is None: + amount = info.get("balChg") or info.get("change") or info.get("income") or info.get("amount") + ts = entry.get("timestamp") or entry.get("datetime") + if ts is None: + ts = info.get("time") or info.get("uTime") or info.get("ts") or info.get("create_time") or info.get("createDate") + ccy = entry.get("currency") or info.get("ccy") or info.get("asset") or info.get("currency") or "USDT" + raw_type = ( + entry.get("type") + or entry.get("status") + or info.get("type") + or info.get("incomeType") + or info.get("change_type") + or info.get("subType") + or "" + ) + if isinstance(raw_type, (int, float)): + raw_type = str(raw_type) + balance_after = entry.get("balance") or info.get("bal") or info.get("balance") + symbol = entry.get("symbol") or info.get("instId") or info.get("symbol") or info.get("contract") or "" + ref = ( + entry.get("id") + or info.get("billId") + or info.get("tranId") + or info.get("id") + or info.get("trade_id") + or "" + ) + note = entry.get("description") or info.get("info") or info.get("text") or "" + return normalize_row( + account=account, + ccy=str(ccy), + amount=amount, + ts_ms=ts, + ref_id=str(ref) if ref != "" else make_ref_id(account, ccy, ts, amount, raw_type), + raw_type=str(raw_type), + balance_after=balance_after, + symbol=str(symbol or ""), + note=str(note or ""), + ) diff --git a/lib/account_ledger/account_ledger_register.py b/lib/account_ledger/account_ledger_register.py new file mode 100644 index 0000000..997d336 --- /dev/null +++ b/lib/account_ledger/account_ledger_register.py @@ -0,0 +1,208 @@ +"""三所统一:账户流水路由 + 后台同步安装.""" +from __future__ import annotations + +import os +from typing import Any, Callable + +from flask import Flask, Response, jsonify, request, session, stream_with_context +from jinja2 import ChoiceLoader, FileSystemLoader + +from lib.account_ledger.account_ledger_db import ensure_account_ledger_tables, query_entries +from lib.account_ledger.account_ledger_normalize import ( + ACCOUNT_FUNDING, + ACCOUNT_TRADING, + PAGE_SIZE, + VALID_ACCOUNTS, +) +from lib.account_ledger.account_ledger_sync import account_ledger_store +from lib.common.history_window_lib import resolve_list_window + + +def attach_account_ledger_templates(app: Flask, repo_root: str) -> None: + tpl_dir = os.path.join(repo_root, "lib", "account_ledger", "templates") + if not os.path.isdir(tpl_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(tpl_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def _build_fetch_fn(exchange_key: str, app_module: Any) -> Callable: + ex_key = (exchange_key or "").strip().lower() + exchange = getattr(app_module, "exchange", None) + ensure_markets = getattr(app_module, "ensure_markets_loaded", None) + + def _fetch(*, start_ms: int, end_ms: int): + if exchange is None: + return [], ["exchange missing"] + if ex_key == "okx": + from lib.exchange.okx_ledger_lib import fetch_okx_account_ledger + + return fetch_okx_account_ledger( + exchange, + start_ms=start_ms, + end_ms=end_ms, + ensure_markets=ensure_markets, + ) + if ex_key == "binance": + from lib.exchange.binance_ledger_lib import fetch_binance_account_ledger + + return fetch_binance_account_ledger( + exchange, + start_ms=start_ms, + end_ms=end_ms, + ensure_markets=ensure_markets, + ) + from lib.exchange.gate_ledger_lib import fetch_gate_account_ledger + + return fetch_gate_account_ledger( + exchange, + start_ms=start_ms, + end_ms=end_ms, + ensure_markets=ensure_markets, + ) + + return _fetch + + +def _currencies_for_exchange(exchange_key: str) -> list[str]: + if (exchange_key or "").strip().lower() == "okx": + return ["USDT", "USDC"] + return ["USDT"] + + +def install_account_ledger( + app: Flask, + repo_root: str, + app_module: Any, + *, + exchange_key: str = "", +) -> None: + ex = (exchange_key or "").strip().lower() + if not ex: + mod_name = getattr(app_module, "__name__", "") or "" + if "okx" in mod_name.lower(): + ex = "okx" + elif "binance" in mod_name.lower(): + ex = "binance" + else: + ex = "gate" + exchange_key = ex + + attach_account_ledger_templates(app, repo_root) + get_db = app_module.get_db + login_required = app_module.login_required + + # 初始化表 + try: + conn = get_db() + try: + ensure_account_ledger_tables(conn) + finally: + conn.close() + except Exception: + pass + + account_ledger_store.configure( + get_db=get_db, + fetch_fn=_build_fetch_fn(exchange_key, app_module), + exchange_key=str(exchange_key), + ) + account_ledger_store.start() + app.extensions["account_ledger_exchange"] = str(exchange_key).lower() + + def _list_window(): + resolve = getattr(app_module, "_list_window_from_request", None) + if callable(resolve): + return resolve() + return resolve_list_window(request.args, session) + + @app.route("/api/account_ledger") + @login_required + def api_account_ledger(): + account = (request.args.get("account") or ACCOUNT_FUNDING).strip().lower() + if account not in VALID_ACCOUNTS: + account = ACCOUNT_FUNDING + try: + page = int(request.args.get("page") or 1) + except Exception: + page = 1 + win = _list_window() + start_ms = int(win.get("start_ms") or 0) + end_ms = int(win.get("end_ms") or 0) + ccys = _currencies_for_exchange(app.extensions.get("account_ledger_exchange") or "") + conn = get_db() + try: + ensure_account_ledger_tables(conn) + data = query_entries( + conn, + account=account, + start_ms=start_ms, + end_ms=end_ms, + page=page, + page_size=PAGE_SIZE, + currencies=ccys, + ) + finally: + conn.close() + st = account_ledger_store.status_dict() + return jsonify( + { + "ok": True, + "account": account, + "window": { + "preset": win.get("preset"), + "label": win.get("label"), + "start_ms": start_ms, + "end_ms": end_ms, + }, + "currencies": ccys, + **data, + **st, + } + ) + + @app.route("/api/account_ledger/stream") + @login_required + def api_account_ledger_stream(): + return Response( + stream_with_context(account_ledger_store.iter_sse()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @app.route("/api/account_ledger/refresh", methods=["POST"]) + @login_required + def api_account_ledger_refresh(): + win = _list_window() + body = request.get_json(silent=True) or {} + start_ms = body.get("start_ms", win.get("start_ms")) + end_ms = body.get("end_ms", win.get("end_ms")) + try: + start_i = int(start_ms) if start_ms is not None else None + end_i = int(end_ms) if end_ms is not None else None + except Exception: + start_i, end_i = None, None + result = account_ledger_store.sync_once( + reason="manual", start_ms=start_i, end_ms=end_i + ) + return jsonify(result) + + @app.route("/account_ledger") + @login_required + def account_ledger_page(): + from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled + + redir = redirect_to_embed_shell_if_enabled("account_ledger") + if redir is not None: + return redir + return app_module.render_main_page("account_ledger") diff --git a/lib/account_ledger/account_ledger_sync.py b/lib/account_ledger/account_ledger_sync.py new file mode 100644 index 0000000..75a1f75 --- /dev/null +++ b/lib/account_ledger/account_ledger_sync.py @@ -0,0 +1,252 @@ +"""账户流水:后台定时拉取交易所 + SSE 版本推送.""" +from __future__ import annotations + +import json +import os +import queue +import threading +import time +from collections.abc import Callable, Iterator +from datetime import datetime, timezone +from typing import Any, Optional + +from lib.account_ledger.account_ledger_db import ( + ensure_account_ledger_tables, + meta_get, + meta_set, + prune_older_than, + upsert_entries, +) + +ACCOUNT_LEDGER_POLL_SEC = float(os.getenv("ACCOUNT_LEDGER_POLL_SEC", "120")) +ACCOUNT_LEDGER_LOOKBACK_DAYS = int(os.getenv("ACCOUNT_LEDGER_LOOKBACK_DAYS", "90")) +ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC = float(os.getenv("ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC", "25")) + + +class AccountLedgerStore: + def __init__(self) -> None: + self._lock = threading.Lock() + self.version = 0 + self._subscribers: list[queue.Queue[str | None]] = [] + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._syncing = False + self._get_db: Optional[Callable] = None + self._fetch_fn: Optional[Callable[..., tuple[list[dict[str, Any]], list[str]]]] = None + self._exchange_key = "" + self.last_sync_at: Optional[float] = None + self.last_error: str = "" + self.last_upserted: int = 0 + self._last_manual_at: float = 0.0 + self._manual_cooldown_sec = float(os.getenv("ACCOUNT_LEDGER_MANUAL_COOLDOWN_SEC", "30")) + + def configure( + self, + *, + get_db: Callable, + fetch_fn: Callable[..., tuple[list[dict[str, Any]], list[str]]], + exchange_key: str, + ) -> None: + self._get_db = get_db + self._fetch_fn = fetch_fn + self._exchange_key = (exchange_key or "").strip().lower() + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + if not self._get_db or not self._fetch_fn: + return + self._stop.clear() + self._thread = threading.Thread( + target=self._loop, daemon=True, name=f"account-ledger-{self._exchange_key or 'x'}" + ) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._broadcast(close=True) + + def lookback_bounds_ms(self, start_ms: Optional[int] = None, end_ms: Optional[int] = None) -> tuple[int, int]: + now = datetime.now(timezone.utc) + end = int(end_ms) if end_ms is not None else int(now.timestamp() * 1000) + floor = int(end - ACCOUNT_LEDGER_LOOKBACK_DAYS * 86400 * 1000) + if start_ms is not None: + start = max(int(start_ms), floor) + else: + start = floor + if start > end: + start, end = end, start + return start, end + + def sync_once( + self, + *, + reason: str = "poll", + start_ms: Optional[int] = None, + end_ms: Optional[int] = None, + ) -> dict[str, Any]: + if not self._get_db or not self._fetch_fn: + return {"ok": False, "msg": "未配置"} + with self._lock: + if self._syncing: + return {"ok": True, "busy": True, "ledger_version": self.version} + if reason == "manual": + gap = time.time() - self._last_manual_at + if gap < self._manual_cooldown_sec: + wait = int(self._manual_cooldown_sec - gap) + 1 + return { + "ok": False, + "msg": f"同步过于频繁,请 {wait}s 后再试", + "ledger_version": self.version, + } + self._syncing = True + try: + start, end = self.lookback_bounds_ms(start_ms, end_ms) + rows, errors = self._fetch_fn(start_ms=start, end_ms=end) + conn = self._get_db() + try: + ensure_account_ledger_tables(conn) + n = upsert_entries(conn, rows or []) + # 保留略宽于 lookback 的缓存 + prune_ms = int( + (datetime.now(timezone.utc).timestamp() - (ACCOUNT_LEDGER_LOOKBACK_DAYS + 7) * 86400) + * 1000 + ) + prune_older_than(conn, prune_ms) + self.last_sync_at = time.time() + self.last_upserted = n + self.last_error = "; ".join(errors[:3]) if errors else "" + meta_set(conn, "last_sync_at", str(self.last_sync_at)) + meta_set(conn, "last_error", self.last_error) + meta_set(conn, "last_upserted", str(n)) + conn.commit() + finally: + try: + conn.close() + except Exception: + pass + if reason == "manual": + self._last_manual_at = time.time() + ver = self.bump(reason) + return { + "ok": True, + "ledger_version": ver, + "upserted": n, + "errors": errors, + "start_ms": start, + "end_ms": end, + } + except Exception as e: + self.last_error = str(e) + try: + conn = self._get_db() + try: + ensure_account_ledger_tables(conn) + meta_set(conn, "last_error", self.last_error) + conn.commit() + finally: + conn.close() + except Exception: + pass + return {"ok": False, "msg": str(e), "ledger_version": self.version} + finally: + with self._lock: + self._syncing = False + + def bump(self, reason: str = "poll") -> int: + with self._lock: + self.version += 1 + ver = self.version + payload = json.dumps( + {"ledger_version": ver, "reason": reason, "exchange": self._exchange_key}, + ensure_ascii=False, + ) + self._broadcast(payload) + return ver + + def status_dict(self) -> dict[str, Any]: + last_at = self.last_sync_at + if last_at is None and self._get_db: + try: + conn = self._get_db() + try: + ensure_account_ledger_tables(conn) + raw = meta_get(conn, "last_sync_at", "") + if raw: + last_at = float(raw) + self.last_error = meta_get(conn, "last_error", self.last_error) + finally: + conn.close() + except Exception: + pass + return { + "ledger_version": self.version, + "poll_sec": ACCOUNT_LEDGER_POLL_SEC, + "lookback_days": ACCOUNT_LEDGER_LOOKBACK_DAYS, + "last_sync_at": last_at, + "last_error": self.last_error, + "last_upserted": self.last_upserted, + "exchange": self._exchange_key, + } + + def _loop(self) -> None: + # 启动后稍等再拉,避免和启动高峰撞车 + if self._stop.wait(3): + return + while not self._stop.is_set(): + try: + self.sync_once(reason="poll") + except Exception: + pass + if self._stop.wait(ACCOUNT_LEDGER_POLL_SEC): + break + + def _broadcast(self, event: str | None = None, *, close: bool = False) -> None: + with self._lock: + subs = list(self._subscribers) + dead: list[queue.Queue[str | None]] = [] + for q in subs: + try: + q.put_nowait(None if close else event) + except Exception: + dead.append(q) + if dead: + with self._lock: + for q in dead: + if q in self._subscribers: + self._subscribers.remove(q) + + def _subscribe(self) -> queue.Queue[str | None]: + q: queue.Queue[str | None] = queue.Queue(maxsize=16) + with self._lock: + self._subscribers.append(q) + return q + + def _unsubscribe(self, q: queue.Queue[str | None]) -> None: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + def iter_sse(self) -> Iterator[str]: + q = self._subscribe() + try: + yield f"event: ledger\ndata: {json.dumps({'ledger_version': self.version, 'reason': 'hello'}, ensure_ascii=False)}\n\n" + last_hb = time.time() + while not self._stop.is_set(): + try: + item = q.get(timeout=1.0) + except queue.Empty: + item = "timeout" + if item is None: + break + if item != "timeout": + yield f"event: ledger\ndata: {item}\n\n" + last_hb = time.time() + elif time.time() - last_hb >= ACCOUNT_LEDGER_SSE_HEARTBEAT_SEC: + yield ": heartbeat\n\n" + last_hb = time.time() + finally: + self._unsubscribe(q) + + +account_ledger_store = AccountLedgerStore() diff --git a/lib/account_ledger/templates/account_ledger_panel.html b/lib/account_ledger/templates/account_ledger_panel.html new file mode 100644 index 0000000..3a5fba0 --- /dev/null +++ b/lib/account_ledger/templates/account_ledger_panel.html @@ -0,0 +1,72 @@ +{# 账户流水:资金/交易 Tab · 交易所账单 · SSE #} + + diff --git a/lib/ai/__init__.py b/lib/ai/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/ai/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/ai/ai_client.py b/lib/ai/ai_client.py new file mode 100644 index 0000000..454bffd --- /dev/null +++ b/lib/ai/ai_client.py @@ -0,0 +1,540 @@ +"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一. + +配置从 os.environ 惰性读取:各实例 app.py 在 import 本模块后才 load_env_file(.env), +若在 import 时缓存变量会导致 OPENAI_API_KEY 始终为空. +""" +from __future__ import annotations + +import base64 +import os +import re +from typing import List, Optional, Sequence, Tuple + +import requests + + +def _env_str(name: str, default: str = "") -> str: + v = os.getenv(name) + if v is None: + return default + return str(v).strip() + + +def _ai_timeout_seconds(*, image_count: int = 0, chat: bool = False) -> int: + if chat: + try: + return max(30, int(_env_str("CHAT_AI_TIMEOUT_SECONDS", "300") or "300")) + except ValueError: + return 300 + if image_count > 0: + try: + return max(30, int(_env_str("AI_REVIEW_TIMEOUT_SECONDS", "300") or "300")) + except ValueError: + return 300 + try: + return max(10, int(_env_str("AI_TIMEOUT_SECONDS", "120") or "120")) + except ValueError: + return 120 + + +def _ai_provider() -> str: + return (_env_str("AI_PROVIDER", "openai") or "openai").lower() + + +def _openai_api_base() -> str: + base = _env_str("OPENAI_API_BASE", "https://op.bz121.com/v1") or "https://op.bz121.com/v1" + return base.rstrip("/") + + +def _openai_api_key() -> str: + return _env_str("OPENAI_API_KEY") or _env_str("AI_API_KEY") + + +def _openai_model() -> str: + return _env_str("OPENAI_MODEL", "gemma4:e4b") or "gemma4:e4b" + + +def _ollama_api() -> str: + return _env_str("OLLAMA_API", "http://127.0.0.1:11434/api/generate") or "http://127.0.0.1:11434/api/generate" + + +def _ollama_model() -> str: + return _env_str("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest") or "huihui_ai/deepseek-r1-abliterated:latest" + + +def _use_openai() -> bool: + return _ai_provider() in ("openai", "openai_compatible", "gateway") + + +def _image_mime_for_path(path: str) -> str: + ext = os.path.splitext(str(path or ""))[1].lower() + if ext == ".png": + return "image/png" + if ext in (".jpg", ".jpeg"): + return "image/jpeg" + if ext == ".webp": + return "image/webp" + if ext == ".gif": + return "image/gif" + return "image/jpeg" + + +def _read_image_base64(image_path: str) -> Optional[tuple]: + try: + with open(image_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + return b64, _image_mime_for_path(image_path) + except Exception: + return None + + +def _collect_images( + image_paths: Optional[Sequence[str]] = None, + images_b64: Optional[Sequence[str]] = None, +) -> List[tuple]: + out: List[tuple] = [] + for p in image_paths or []: + item = _read_image_base64(p) + if item: + out.append(item) + for b in images_b64 or []: + if b: + out.append((str(b), "image/jpeg")) + return out + + +def _openai_chat_url() -> str: + base = _openai_api_base() + if base.endswith("/chat/completions"): + return base + return f"{base}/chat/completions" + + +def _openai_message_text(msg: dict) -> str: + content = msg.get("content") + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(str(part.get("text") or "")) + content = "".join(parts) + text = str(content or "").strip() + if text: + return text + # 部分网关/模型把正文放在 reasoning_content;gemma 系则常写在 reasoning + for key in ("reasoning_content", "reasoning"): + alt = str(msg.get(key) or "").strip() + if not alt: + continue + # 英文链式思考不算可交付正文,留给上层按 finish=length 重试 + low = alt[:80].lower() + if low.startswith("here's a thinking process") or low.startswith("here is a thinking process"): + continue + if low.startswith("thinking process") or "analyze the request" in low: + continue + return alt + return "" + + +def _apply_max_tokens(body: dict, max_tokens: int | None, *, chat: bool = False) -> None: + if max_tokens is not None and max_tokens > 0: + mt = int(max_tokens) + body["max_tokens"] = mt + # 部分 OpenAI 兼容网关对 max_tokens + max_completion_tokens 双写不友好 + if chat: + body["max_completion_tokens"] = mt + + +def _openai_chat_completion( + messages: list[dict], + *, + temperature: float, + max_tokens: int | None = None, + image_count: int = 0, + chat: bool = False, +) -> Tuple[str, str]: + api_key = _openai_api_key() + if not api_key: + return "AI 调用失败:未配置 OPENAI_API_KEY(请在当前实例目录 .env 中设置,修改后需重启服务)", "error" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + body: dict = { + "model": _openai_model(), + "messages": messages, + "temperature": temperature, + "stream": False, + } + _apply_max_tokens(body, max_tokens, chat=chat) + r = requests.post( + _openai_chat_url(), + headers=headers, + json=body, + timeout=_ai_timeout_seconds(image_count=image_count, chat=chat), + ) + r.raise_for_status() + data = r.json() + choices = data.get("choices") or [] + if not choices: + return "AI 生成失败:响应无 choices", "error" + choice = choices[0] or {} + msg = choice.get("message") or {} + text = _openai_message_text(msg) + finish = str(choice.get("finish_reason") or "") + + # gemma 等会先把 token 花在 reasoning 上:过小 max_tokens 时 content 为空且 finish=length + if not text: + retry_body = dict(body) + retry_body.pop("max_completion_tokens", None) + cur = int(retry_body.get("max_tokens") or 0) + retry_body["max_tokens"] = max(cur, 4096 if chat else 8192) + r2 = requests.post( + _openai_chat_url(), + headers=headers, + json=retry_body, + timeout=_ai_timeout_seconds(image_count=image_count, chat=chat), + ) + r2.raise_for_status() + data2 = r2.json() + choices2 = data2.get("choices") or [] + if choices2: + choice2 = choices2[0] or {} + msg2 = choice2.get("message") or {} + text2 = _openai_message_text(msg2) + finish2 = str(choice2.get("finish_reason") or finish) + if text2: + return text2, finish2 + finish = finish2 or finish + if not text: + return f"AI 生成失败:空内容(finish={finish or '?'})", finish or "error" + return text, finish + + +def _generate_openai( + prompt: str, + images: List[tuple], + temperature: float, + *, + max_tokens: int | None = None, +) -> str: + if images: + content: List[dict] = [{"type": "text", "text": prompt}] + for b64, mime in images: + content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + } + ) + messages = [{"role": "user", "content": content}] + else: + messages = [{"role": "user", "content": prompt}] + text, _reason = _openai_chat_completion( + messages, + temperature=temperature, + max_tokens=max_tokens, + image_count=len(images), + ) + return text + + +def _generate_ollama( + prompt: str, + images: List[tuple], + temperature: float, + *, + max_tokens: int | None = None, + chat: bool = False, +) -> Tuple[str, str]: + options: dict = {"temperature": temperature} + if max_tokens is not None and max_tokens > 0: + options["num_predict"] = int(max_tokens) + payload = { + "model": _ollama_model(), + "prompt": prompt, + "stream": False, + "options": options, + } + if images: + payload["images"] = [b64 for b64, _mime in images] + r = requests.post( + _ollama_api(), + json=payload, + timeout=_ai_timeout_seconds(image_count=len(images), chat=chat), + ) + r.raise_for_status() + data = r.json() + text = (data.get("response") or "").strip() or "AI 生成失败" + return text, str(data.get("done_reason") or "") + + +def ai_generate( + prompt: str, + *, + image_paths: Optional[Sequence[str]] = None, + images_b64: Optional[Sequence[str]] = None, + temperature: float = 0.2, + max_tokens: int | None = None, +) -> str: + """统一文本生成;失败时返回以「AI 调用失败」开头的说明.""" + images = _collect_images(image_paths, images_b64) + try: + if _use_openai(): + out = _generate_openai(prompt, images, temperature, max_tokens=max_tokens) + else: + out, _reason = _generate_ollama(prompt, images, temperature, max_tokens=max_tokens) + # 附图导致空正文时,降级为纯文本再试一次(复盘仍可用) + if ( + images + and isinstance(out, str) + and (out.startswith("AI 生成失败:空内容") or out.startswith("AI 调用失败")) + ): + if _use_openai(): + return _generate_openai(prompt, [], temperature, max_tokens=max_tokens or 8192) + text, _reason = _generate_ollama(prompt, [], temperature, max_tokens=max_tokens or 8192) + return text + return out + except requests.HTTPError as e: + detail = "" + try: + detail = (e.response.text or "")[:500] + except Exception: + pass + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}" + except Exception as e: + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov}):{str(e)}" + + +_CHAT_CONTINUE_USER = ( + "你上一条回复在中途截断了.请从断点处继续写完,不要重复已写内容," + "保持同一语气;编号列表每条单独一行." +) +_CHAT_END_CHARS = ".!?.!?\"」』))>】" +_INCOMPLETE_TAIL_RE = re.compile( + r"(不会|不能|没有|会不会|是不是|够不够|能不能|要不要|如何|怎么|什么|哪里|多少|对吗|怎么样|" + r"这个\.\.\.|这个…|\.\.\.\d+\.|\d+\.)$" +) + + +def _looks_truncated(text: str) -> bool: + t = (text or "").rstrip() + if len(t) < 16: + return False + if t[-1] in _CHAT_END_CHARS: + return False + if _INCOMPLETE_TAIL_RE.search(t): + return True + if t.endswith("…") or t.endswith("..."): + return True + if re.search(r"\d+\.\s*$", t): + return True + return t[-1] not in ",,,;;::\n" + + +def _should_continue(reason: str, full_text: str) -> bool: + if reason in ("length", "max_tokens", "model_length"): + return True + return _looks_truncated(full_text) + + +def _chat_continue_message(full_text: str) -> str: + tail = full_text[-500:] if len(full_text) > 500 else full_text + return ( + f"{_CHAT_CONTINUE_USER}\n\n" + f"已写到最后这几句:\n「{tail}」\n\n" + f"请从断点接着写完.不要重复前文;最后一句话必须以句号,问号或感叹号结束." + ) + + +def _chat_continue_system(system: str) -> str: + return ( + f"{system.strip()}\n\n" + "【续写模式】只输出断点后的剩余内容,不要重复前文;" + "列表每条单独一行;必须以句号,问号或感叹号收尾." + ) + + +def ai_generate_chat( + *, + system: str, + user: str, + temperature: float = 0.5, + images_b64: Optional[Sequence[str]] = None, + max_tokens: int = 8192, + max_continuations: int = 4, +) -> str: + """聊天专用:system/user 分消息;输出触顶时轻量续写(不重复巨型上下文).""" + images = _collect_images(None, images_b64) + max_rounds = max(1, int(max_continuations) + 1) + try: + if _use_openai(): + if images: + user_content: List[dict] | str = [{"type": "text", "text": user.strip()}] + for b64, mime in images: + user_content.append( + { + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + } + ) + else: + user_content = user.strip() + base_user_msg = {"role": "user", "content": user_content} + messages: list[dict] = [ + {"role": "system", "content": system.strip()}, + base_user_msg, + ] + + parts: list[str] = [] + for attempt in range(max_rounds): + chunk, reason = _openai_chat_completion( + messages, + temperature=temperature, + max_tokens=max_tokens, + image_count=len(images) if attempt == 0 else 0, + chat=True, + ) + if chunk.startswith("AI 调用失败") or chunk.startswith("AI 生成失败"): + return chunk if not parts else "".join(parts).strip() + parts.append(chunk) + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: + break + messages = [ + {"role": "system", "content": _chat_continue_system(system)}, + {"role": "assistant", "content": full}, + {"role": "user", "content": _chat_continue_message(full)}, + ] + return "".join(parts).strip() or "AI 生成失败:空内容" + + prompt = f"{system.strip()}\n\n---\n\n{user.strip()}" + parts: list[str] = [] + for attempt in range(max_rounds): + if parts: + full = "".join(parts) + current_prompt = ( + f"{_chat_continue_system(system)}\n\n" + f"【你已写道】\n{full}\n\n{_chat_continue_message(full)}" + ) + else: + current_prompt = prompt + chunk, reason = _generate_ollama( + current_prompt, + images if not parts else [], + temperature, + max_tokens=max_tokens, + chat=True, + ) + if chunk.startswith("AI 生成失败") and not parts: + return chunk + if chunk.startswith("AI 生成失败"): + break + parts.append(chunk) + full = "".join(parts) + if not _should_continue(reason, full) or attempt >= max_rounds - 1: + break + return "".join(parts).strip() or "AI 生成失败:空内容" + except requests.HTTPError as e: + detail = "" + try: + detail = (e.response.text or "")[:500] + except Exception: + pass + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}" + except Exception as e: + prov = "OpenAI" if _use_openai() else "Ollama" + return f"AI 调用失败({prov}):{str(e)}" + + +def ai_review(trades_text: str, period_title: str, image_paths=None) -> str: + # 附图过多时网关易超时/空回复;保留前几张即可支撑第5节 + raw_paths = [p for p in (image_paths or []) if p] + try: + max_imgs = max(0, int(_env_str("AI_REVIEW_MAX_IMAGES", "4") or "4")) + except ValueError: + max_imgs = 4 + capped_paths = raw_paths[:max_imgs] if max_imgs else [] + n_img = len(capped_paths) + n_skipped = max(0, len(raw_paths) - n_img) + period_label = "周" if "周" in str(period_title) else "日" + attach_note = ( + f"ℹ️ 【系统说明:已向模型附带 {n_img} 张复盘附图(自动K线或上传截图)" + + (f",另跳过 {n_skipped} 张以控制体积" if n_skipped else "") + + ",请结合附图分析第5节.】\n\n" + if n_img + else "ℹ️ 【系统说明:本次未附带复盘附图,第5节请写明「无附图,无法看图」;保存复盘记录时可勾选「自动生成K线图」.】\n\n" + ) + prompt = f""" +你是一位专业交易教练.下面是用户的{period_title}交易记录,请做简洁,可执行的复盘(中文). + +【硬性规则 — 必须遵守】 +- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损,是否扛单,亏损是否扩大,图上具体结构/进出场点位等记录里**没有**的信息. +- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价,是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」. +- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等. +- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据. +- 禁止用语:人身攻击,夸张定性(如「致命伤」「灾难」);语气克制,对事不对人. +- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事. + +【输出格式 — Markdown,必须严格遵守】 +- 第一行:**交易复盘报告({period_label}度)** +- 五个大节标题必须**完全一致**(含 emoji,不要用其它编号或改名): + **1. 📊 总体盈亏结构** + **2. 🧠 心态与执行** + **3. 🏷️ 行为标签** + **4. ✅ 改进建议** + **5. 📈 图表分析** +- 每节正文用 `- **子项名**:内容` 列表;第4节改进建议用有序列表 `1. 2. 3.` +- 第1节至少包含:**笔数/盈亏**,**风险回报比**,**总结** +- 第2节至少包含:**得分**(1–10),**依据**(对应记录字段) +- 第5节至少包含:**趋势确认**,**执行路径**(记录不足则写明) +- 语气简洁,少形容词;不要输出代码块,不要表格 + +交易记录: +{trades_text} +""".strip() + try: + review_max = max(1024, int(_env_str("AI_REVIEW_MAX_TOKENS", "8192") or "8192")) + except ValueError: + review_max = 8192 + return attach_note + ai_generate( + prompt, + image_paths=capped_paths, + temperature=0.2, + max_tokens=review_max, + ) + + +def ai_short_advice(prompt_text: str) -> str: + prompt = f""" +你是交易风控助理.请用中文给出**最多 3 条**提醒,要求: +- 每条不超过 25 个字 +- 语气克制,具体,可执行 +- 不要输出 Markdown,不要编号前缀以外的废话 + +场景: +{prompt_text} +""".strip() + return ai_generate(prompt, temperature=0.2) + + +def ai_provider_label() -> str: + if _use_openai(): + return f"OpenAI 兼容 · {_openai_model()} @ {_openai_api_base()}" + return f"Ollama · {_ollama_model()}" + + +def ai_config_status() -> dict: + """调试用:当前进程内读到的 AI 配置(不含密钥明文).""" + key = _openai_api_key() + return { + "provider": _ai_provider(), + "openai_base": _openai_api_base(), + "openai_model": _openai_model(), + "openai_key_configured": bool(key), + "ollama_api": _ollama_api(), + "ollama_model": _ollama_model(), + } diff --git a/lib/ai/ai_review_lib.py b/lib/ai/ai_review_lib.py new file mode 100644 index 0000000..c81f443 --- /dev/null +++ b/lib/ai/ai_review_lib.py @@ -0,0 +1,178 @@ +"""AI 日复盘 / 周复盘:附图收集与 journal 文本格式化(三所共用).""" +from __future__ import annotations + +import os +import uuid +from typing import Any, Callable, List, Mapping, Optional, Sequence + +from lib.instance.journal_chart_lib import ( + JOURNAL_CHART_ANCHOR_CLOSE, + JOURNAL_CHART_DEFAULT_LIMIT, + JOURNAL_CHART_DEFAULT_TF1, + JOURNAL_CHART_DEFAULT_TF2, + normalize_chart_timeframe, +) +from lib.instance.journal_images_lib import journal_image_paths + + +def _journal_nz(v: Any, default: str = "无") -> str: + if v is None: + return default + s = str(v).strip() + return s if s else default + + +def _row_get(row: Any, key: str, default: Any = None) -> Any: + """兼容 dict 与 sqlite3.Row(Row 无 .get 方法).""" + if row is None: + return default + getter = getattr(row, "get", None) + if callable(getter): + return getter(key, default) + try: + keys = row.keys() if hasattr(row, "keys") else () + if key in keys: + return row[key] + except Exception: + pass + try: + return row[key] + except (KeyError, TypeError, IndexError): + return default + + +def journal_row_lines_for_ai( + idx: int, + row: Any, + *, + include_hold_duration: bool = True, +) -> str: + """把 journal 字段拼成给 AI 的文本;三所日复盘/周复盘共用.""" + lines = [ + ( + f"{idx}. {_journal_nz(_row_get(row, 'coin'))} {_journal_nz(_row_get(row, 'tf'))} " + f"| 盈亏:{_journal_nz(_row_get(row, 'pnl'))}U " + f"| 实际RR:{_journal_nz(_row_get(row, 'real_rr'))} " + f"| 预期RR:{_journal_nz(_row_get(row, 'expect_rr'))}" + ), + f" 开仓逻辑:{_journal_nz(_row_get(row, 'entry_reason'))}", + f" 平仓/离场(交易员自述):{_journal_nz(_row_get(row, 'exit_reason'))}", + ] + if include_hold_duration: + lines.append(f" 持仓时长:{_journal_nz(_row_get(row, 'hold_duration'))}") + ee_bits = [ + _journal_nz(_row_get(row, "early_exit")), + _journal_nz(_row_get(row, "early_exit_reason")), + _journal_nz(_row_get(row, "early_exit_trigger")), + _journal_nz(_row_get(row, "early_exit_note")), + ] + if any(x != "无" for x in ee_bits): + lines.append( + " 提前离场记录:" + f"{ee_bits[0]} | 原因:{ee_bits[1]} | 触发:{ee_bits[2]} | 备注:{ee_bits[3]}" + ) + mood_bits = f"心态标签:{_journal_nz(_row_get(row, 'mood_issues'))}" + mood_score = _row_get(row, "mood_score") + if mood_score is not None: + mood_bits += f" | 自评心态分:{mood_score}" + lines.append(f" {mood_bits}") + if _journal_nz(_row_get(row, "post_breakeven_stare")) != "无": + lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}") + if _journal_nz(_row_get(row, "note")) != "无": + lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}") + return "\n".join(lines) + "\n" + + +def collect_images_for_ai_review( + rows: Sequence, + upload_folder: str, + *, + build_chart_if_missing: Optional[Callable] = None, +) -> List[str]: + """ + 收集传给视觉模型的本地图片路径. + - 优先 journal_entries.images_json / image 已存附图(含多周期手动上传); + - 若无附图且提供 build_chart_if_missing,则临时生成 K 线图. + """ + paths: List[str] = [] + seen = set() + upload_folder = os.path.abspath(upload_folder or "") + for row in rows or []: + row_paths = journal_image_paths(row, upload_folder) + if row_paths: + for candidate in row_paths: + if candidate not in seen: + seen.add(candidate) + paths.append(candidate) + continue + if build_chart_if_missing: + try: + candidate = build_chart_if_missing(row) + except Exception: + candidate = None + if not candidate: + continue + candidate = os.path.abspath(candidate) + if os.path.isfile(candidate) and candidate not in seen: + seen.add(candidate) + paths.append(candidate) + return paths + + +def build_journal_ai_chart_path( + row, + upload_folder: str, + *, + order_chart_enabled: bool, + normalize_exchange_symbol_fn: Callable[[str], str], + generate_chart_fn: Callable, + local_datetime_to_ms_fn: Callable[[str], Optional[int]], + now_ts_ms_fn: Callable[[], int], +) -> Optional[str]: + """无已存附图时,按复盘记录开平仓时间临时生成 K 线图路径.""" + if not order_chart_enabled: + return None + try: + keys = row.keys() if hasattr(row, "keys") else [] + except Exception: + return None + coin = (row["coin"] if "coin" in keys else "") or "" + coin = str(coin).strip() + if not coin: + return None + try: + symbol = normalize_exchange_symbol_fn(coin) + except Exception: + return None + open_dt = row["open_datetime"] if "open_datetime" in keys else "" + close_dt = row["close_datetime"] if "close_datetime" in keys else "" + entry_ms = local_datetime_to_ms_fn(open_dt) + exit_ms = local_datetime_to_ms_fn(close_dt) + if not entry_ms: + return None + row_tf = row["tf"] if "tf" in keys else "" + tf1 = normalize_chart_timeframe(row_tf) or JOURNAL_CHART_DEFAULT_TF1 + tf2 = JOURNAL_CHART_DEFAULT_TF2 if tf1 != JOURNAL_CHART_DEFAULT_TF2 else "1h" + row_id = str(row["id"] if "id" in keys else "")[:8] or uuid.uuid4().hex[:8] + marker = { + "entry_ts_ms": entry_ms, + "exit_ts_ms": exit_ms, + "chart_anchor": JOURNAL_CHART_ANCHOR_CLOSE, + "now_ts_ms": int(now_ts_ms_fn()), + } + fname = f"ai_rev_{row_id}_{uuid.uuid4().hex[:6]}.png" + saved = generate_chart_fn( + symbol, + f"AI复盘 {coin}", + timeframes=[tf1, tf2], + limit=JOURNAL_CHART_DEFAULT_LIMIT, + out_dir=upload_folder, + filename=fname, + marker_payload=marker, + marker_timeframes={tf1, tf2}, + layout="vertical", + ) + if not saved: + return None + path = os.path.join(upload_folder, saved) + return path if os.path.isfile(path) else None diff --git a/lib/common/__init__.py b/lib/common/__init__.py new file mode 100644 index 0000000..ab164b5 --- /dev/null +++ b/lib/common/__init__.py @@ -0,0 +1 @@ +"""Shared library package.""" diff --git a/lib/common/auto_transfer_daily_lib.py b/lib/common/auto_transfer_daily_lib.py new file mode 100644 index 0000000..aaab5bf --- /dev/null +++ b/lib/common/auto_transfer_daily_lib.py @@ -0,0 +1,130 @@ +""" +每日自动划转:北京时间指定整点小时内,将交易账户(AUTO_TRANSFER_TO)余额调整至目标额. + +- 交易账户 < 目标:从资金账户划入差额 +- 交易账户 > 目标:将多余划回资金账户 +- 有 active 持仓:不划转,写账簿并企业微信说明 +""" +from __future__ import annotations + +from typing import Any, Callable + + +def run_auto_transfer_once_per_day( + *, + enabled: bool, + bj_hour: int, + target_amount: float, + from_account: str, + to_account: str, + funds_decimals: int, + get_db: Callable[[], Any], + get_active_position_count: Callable[[Any], int], + get_account_usdt_total: Callable[[str], float | None], + execute_transfer_usdt: Callable[[float, str, str], tuple[bool, str, Any]], + send_wechat_msg: Callable[[str], None], + utc_now_dt: Callable[[], Any], + app_tz: Any, + utc_calendar_date_str: Callable[[], str], + app_now_str: Callable[[], str], + min_transfer: float = 0.01, +) -> None: + if not enabled: + return + utc_dt = utc_now_dt() + bj = utc_dt.astimezone(app_tz) + if bj.hour != bj_hour: + return + + transfer_day = utc_calendar_date_str() + conn = get_db() + exists = conn.execute( + "SELECT id FROM transfer_logs WHERE transfer_type=? AND transfer_day=?", + ("auto_daily", transfer_day), + ).fetchone() + if exists: + conn.close() + return + + def _log( + amount: float, + fr: str, + to: str, + status: str, + message: str, + *, + commit_close: bool = True, + ) -> None: + conn.execute( + "INSERT INTO transfer_logs (transfer_type, transfer_day, amount, from_account, to_account, status, message) VALUES (?,?,?,?,?,?,?)", + ("auto_daily", transfer_day, amount, fr, to, status, message[:500]), + ) + conn.commit() + if commit_close: + conn.close() + + active = get_active_position_count(conn) + if active > 0: + msg = f"持仓中({active}笔),本次资金无划转" + _log(0, from_account, to_account, "skipped", msg) + send_wechat_msg( + f"自动划转:{msg}\n" + f"目标:{to_account} 调整至 {round(float(target_amount), funds_decimals)}U\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + target = round(float(target_amount), funds_decimals) + trade_bal = get_account_usdt_total(to_account) + if trade_bal is None: + _log( + 0, + from_account, + to_account, + "failed", + f"读取{to_account}账户USDT失败", + ) + return + + trade = round(float(trade_bal), funds_decimals) + diff = round(target - trade, funds_decimals) + + if abs(diff) < min_transfer: + _log( + 0, + from_account, + to_account, + "skipped", + f"{to_account}账户已为{trade}U(目标{target}U)", + ) + return + + if diff > 0: + fr, to, amount = from_account, to_account, diff + action = "划入" + else: + fr, to, amount = to_account, from_account, round(abs(diff), funds_decimals) + action = "划出" + + from_bal = get_account_usdt_total(fr) + if from_bal is not None and round(float(from_bal), funds_decimals) < amount: + cur = round(float(from_bal), funds_decimals) + _log(amount, fr, to, "failed", f"{fr}账户USDT不足,需{amount}U,当前{cur}U") + send_wechat_msg( + f"自动划转失败:{fr}余额不足,需{amount}U,当前{cur}U({action}至{to_account}目标{target}U)\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + return + + ok, msg, _ = execute_transfer_usdt(amount, fr, to) + _log(amount, fr, to, "success" if ok else "failed", msg) + if ok: + send_wechat_msg( + f"自动划转成功:{to_account} {trade}U→目标{target}U,{action}{amount}U {fr}->{to}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) + else: + send_wechat_msg( + f"自动划转失败:计划{action}{amount}U {fr}->{to}(目标{target}U)\n原因:{msg}\n" + f"账簿日(UTC):{transfer_day}|触发时刻(北京):{app_now_str()}" + ) diff --git a/lib/common/flask_access_log_lib.py b/lib/common/flask_access_log_lib.py new file mode 100644 index 0000000..68e7403 --- /dev/null +++ b/lib/common/flask_access_log_lib.py @@ -0,0 +1,16 @@ +"""关闭 Flask/Werkzeug 开发服务器 access log 刷屏(避免灌满 PM2 error 日志).""" +from __future__ import annotations + +import logging + + +def silence_werkzeug_access_log() -> None: + """仅抑制 request access 行;WARNING/ERROR 仍可读.""" + log = logging.getLogger("werkzeug") + log.setLevel(logging.WARNING) + # 部分环境会挂 StreamHandler 到 stderr;抬高阈值即可 + for h in list(log.handlers): + try: + h.setLevel(logging.WARNING) + except Exception: + pass diff --git a/lib/common/form_submit_lib.py b/lib/common/form_submit_lib.py new file mode 100644 index 0000000..687fccf --- /dev/null +++ b/lib/common/form_submit_lib.py @@ -0,0 +1,51 @@ +"""防重复提交:Flask session 短窗口去重(下单 / 关键位等).""" +from __future__ import annotations + +import time +from typing import Any, Optional + + +DEFAULT_SUBMIT_GUARD_TTL = 90.0 + + +def _prune_locks(locks: dict, now: float) -> dict: + return {k: float(v) for k, v in (locks or {}).items() if float(v) > now} + + +def check_duplicate_submit( + session: Any, + scope: str, + *, + ttl: float = DEFAULT_SUBMIT_GUARD_TTL, +) -> Optional[str]: + """ + 同一 scope 在 ttl 秒内仅允许通过一次. + 返回提示文案表示应拒绝;返回 None 表示可继续处理. + """ + scope = (scope or "").strip() + if not scope: + return None + now = time.time() + locks = _prune_locks(session.get("_form_submit_guard") or {}, now) + if scope in locks: + return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)" + locks[scope] = now + float(ttl) + session["_form_submit_guard"] = locks + try: + session.modified = True + except Exception: + pass + return None + + +def submit_scope_add_order(symbol: str, direction: str) -> str: + sym = (symbol or "").strip().upper() + d = (direction or "").strip().lower() + return f"add_order:{sym}:{d}" + + +def submit_scope_add_key(symbol: str, monitor_type: str, direction: str) -> str: + sym = (symbol or "").strip().upper() + mt = (monitor_type or "").strip() + d = (direction or "").strip().lower() or "watch" + return f"add_key:{sym}:{mt}:{d}" diff --git a/lib/common/history_window_lib.py b/lib/common/history_window_lib.py new file mode 100644 index 0000000..760f13a --- /dev/null +++ b/lib/common/history_window_lib.py @@ -0,0 +1,187 @@ +"""列表/导出用 UTC 时间窗(Gate / Binance 主站共用).""" + +from datetime import datetime, timedelta, timezone + +PRESET_UTC_TODAY = "utc_today" +PRESET_UTC_LAST24H = "utc_last24h" +PRESET_UTC_LAST7D = "utc_last7d" +PRESET_UTC_THIS_MONTH = "utc_this_month" +PRESET_UTC_LAST3M = "utc_last3m" +PRESET_UTC_LAST6M = "utc_last6m" +PRESET_ALL = "all" +PRESET_CUSTOM = "custom" +PRESET_DEFAULT = PRESET_UTC_THIS_MONTH + + +def utc_now(): + return datetime.now(timezone.utc) + + +def utc_today_bounds(now=None): + now = now or utc_now() + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + return start, now + + +def resolve_window(query_mapping, default_preset=PRESET_DEFAULT): + """ + 从 ?win_preset= & from_utc= & to_utc= 解析窗口. + 返回 dict: preset, start_utc, end_utc, label, start_ms, end_ms + """ + preset = (query_mapping.get("win_preset") or default_preset or PRESET_DEFAULT).strip().lower() + now = utc_now() + + if preset == PRESET_UTC_LAST24H: + start = now - timedelta(hours=24) + end = now + label = "近24小时(UTC)" + elif preset == PRESET_UTC_LAST7D: + start = now - timedelta(days=7) + end = now + label = "近7天(UTC)" + elif preset == PRESET_UTC_THIS_MONTH: + start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end = now + label = f"本月 {start.strftime('%Y-%m')}" + elif preset == PRESET_UTC_LAST3M: + start = now - timedelta(days=90) + end = now + label = "近3月" + elif preset == PRESET_UTC_LAST6M: + start = now - timedelta(days=180) + end = now + label = "近6月" + elif preset == PRESET_ALL: + start = datetime(2000, 1, 1, tzinfo=timezone.utc) + end = now + label = "全部" + elif preset == PRESET_CUSTOM: + start = _parse_utc_input(query_mapping.get("from_utc")) or utc_today_bounds(now)[0] + end = _parse_utc_input(query_mapping.get("to_utc")) or now + if end < start: + start, end = end, start + label = f"{start.strftime('%Y-%m-%d %H:%M')} ~ {end.strftime('%Y-%m-%d %H:%M')} UTC" + elif preset == PRESET_UTC_TODAY: + start, end = utc_today_bounds(now) + label = f"UTC当日 {start.strftime('%Y-%m-%d')}" + else: + return resolve_window( + {**(query_mapping or {}), "win_preset": default_preset}, + default_preset=default_preset, + ) + + return { + "preset": preset, + "start_utc": start, + "end_utc": end, + "label": label, + "start_ms": int(start.timestamp() * 1000), + "end_ms": int(end.timestamp() * 1000), + } + + +def _parse_utc_input(raw): + s = (raw or "").strip().replace("T", " ").replace("Z", "").strip() + if not s: + return None + for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + dt = datetime.strptime(s[:n], fmt) + return dt.replace(tzinfo=timezone.utc) + except Exception: + continue + return None + + +def utc_window_to_bj_sql_strings(start_utc, end_utc, app_tz): + """DB 存北京时间字符串时,用于 SQLite 字符串范围比较.""" + start_bj = start_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S") + end_bj = end_utc.astimezone(app_tz).strftime("%Y-%m-%d %H:%M:%S") + return start_bj, end_bj + + +def utc_window_to_utc_sql_strings(start_utc, end_utc): + """SQLite CURRENT_TIMESTAMP 写入 UTC 时,用于 created_at 范围比较.""" + return ( + start_utc.strftime("%Y-%m-%d %H:%M:%S"), + end_utc.strftime("%Y-%m-%d %H:%M:%S"), + ) + + +def normalize_bj_datetime_storage(raw): + """表单 datetime-local(含 T)入库前统一为 YYYY-MM-DD HH:MM:SS(北京时间).""" + s = (raw or "").strip().replace("T", " ").replace("Z", "").strip() + if not s: + return "" + for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)): + try: + return datetime.strptime(s[:n], fmt).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + continue + return s + + +def sql_list_time_field(*columns): + """ + SQLite 列表时间窗比较表达式. + journal_entries 的 open/close 可能含 'T',直接与 bounds(空格格式)比会误判为超出上界. + 单列时不用 COALESCE(SQLite 要求 COALESCE 至少 2 个参数). + """ + cols = [c for c in columns if c] + if not cols: + raise ValueError("sql_list_time_field requires at least one column") + if len(cols) == 1: + return f"REPLACE({cols[0]}, 'T', ' ')" + return f"REPLACE(COALESCE({', '.join(cols)}), 'T', ' ')" + + +SESSION_KEY_LIST_WIN = "list_win_filter" + + +def query_mapping_from_session(session_store): + """从 Flask session 恢复 win_preset / from_utc / to_utc.""" + if not session_store: + return {} + block = session_store.get(SESSION_KEY_LIST_WIN) + if not isinstance(block, dict): + return {} + preset = (block.get("preset") or "").strip() + if not preset: + return {} + return { + "win_preset": preset, + "from_utc": (block.get("from_utc") or "").strip(), + "to_utc": (block.get("to_utc") or "").strip(), + } + + +def resolve_list_window(query_mapping, session_store=None, default_preset=PRESET_DEFAULT): + """ + URL 带 win_preset 时解析并写入 session;无参数时用 session 中上次「应用」的预设. + """ + qm = query_mapping or {} + preset_in_q = (qm.get("win_preset") or "").strip() + if preset_in_q: + win = resolve_window(qm, default_preset=default_preset) + if session_store is not None: + session_store[SESSION_KEY_LIST_WIN] = { + "preset": win["preset"], + "from_utc": (qm.get("from_utc") or "").strip(), + "to_utc": (qm.get("to_utc") or "").strip(), + } + return win + stored = query_mapping_from_session(session_store) + if stored.get("win_preset"): + return resolve_window(stored, default_preset=default_preset) + return resolve_window(qm, default_preset=default_preset) + + +def list_window_redirect_query(session_store): + """复盘/表单 POST 后重定向时附带列表筛选 query.""" + from urllib.parse import urlencode + + stored = query_mapping_from_session(session_store) + if not stored.get("win_preset"): + return "" + params = {k: v for k, v in stored.items() if v} + return urlencode(params) diff --git a/lib/common/static/account_ledger.js b/lib/common/static/account_ledger.js new file mode 100644 index 0000000..a7ce569 --- /dev/null +++ b/lib/common/static/account_ledger.js @@ -0,0 +1,337 @@ +/** + * 账户流水:资金/交易 Tab · 分页 10 · SSE 自动刷新 · 时间窗跟随顶栏预设. + */ +(function (global) { + const PAGE_SIZE = 10; + let account = "funding"; + let page = 1; + let pages = 0; + let localVersion = 0; + let es = null; + let reconnectTimer = null; + let loading = false; + let booted = false; + + function root() { + const active = document.querySelector('.embed-tab-pane.is-active-pane [data-account-ledger="1"]'); + if (active) return active; + return document.getElementById("account-ledger-root"); + } + + function $(id) { + const r = root(); + return (r && r.querySelector("#" + id)) || document.getElementById(id); + } + + function escapeHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function listWindowQs() { + if (typeof global.listWindowQueryString === "function") { + const q = global.listWindowQueryString(); + return q ? (q.charAt(0) === "?" ? q.slice(1) : q) : ""; + } + try { + return new URLSearchParams(location.search).toString(); + } catch (_) { + return ""; + } + } + + function fmtBj(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return "—"; + try { + const d = new Date(n); + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(d); + const get = (t) => (parts.find((p) => p.type === t) || {}).value || ""; + return ( + get("year") + + "-" + + get("month") + + "-" + + get("day") + + " " + + get("hour") + + ":" + + get("minute") + + ":" + + get("second") + ); + } catch (_) { + return "—"; + } + } + + function fmtAmt(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + const cls = n > 0 ? "account-ledger-amt-pos" : n < 0 ? "account-ledger-amt-neg" : ""; + const sign = n > 0 ? "+" : ""; + return '' + sign + n.toFixed(6).replace(/\.?0+$/, "") + ""; + } + + function fmtBal(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return n.toFixed(6).replace(/\.?0+$/, ""); + } + + function setStatus(msg, isErr) { + const el = $("account-ledger-status"); + if (!el) return; + el.textContent = msg || ""; + el.style.color = isErr ? "#f07178" : ""; + } + + function setSyncLabel(data) { + const el = $("account-ledger-sync"); + if (!el) return; + const ts = data && data.last_sync_at; + if (!ts) { + el.textContent = "尚未同步"; + return; + } + el.textContent = "同步 " + fmtBj(Number(ts) * 1000); + } + + function renderRows(items) { + const tbody = $("account-ledger-tbody"); + if (!tbody) return; + if (!items || !items.length) { + tbody.innerHTML = '当前时间窗暂无流水'; + return; + } + tbody.innerHTML = items + .map(function (it) { + const note = [it.symbol, it.note, it.raw_type].filter(Boolean).join(" · "); + return ( + "" + + "" + + escapeHtml(fmtBj(it.ts_ms)) + + "" + + "" + + escapeHtml(it.ccy || "") + + "" + + "" + + escapeHtml(it.kind_label || it.kind || "") + + "" + + "" + + fmtAmt(it.amount) + + "" + + "" + + escapeHtml(fmtBal(it.balance_after)) + + "" + + "" + + escapeHtml(note || "—") + + "" + + "" + ); + }) + .join(""); + } + + function renderPager(data) { + pages = Number(data.pages || 0); + page = Number(data.page || 1); + const info = $("account-ledger-page-info"); + const prev = $("account-ledger-prev"); + const next = $("account-ledger-next"); + if (info) { + info.textContent = + "第 " + page + " / " + (pages || 1) + " 页 · 共 " + (data.total || 0) + " 条 · 每页 " + PAGE_SIZE; + } + if (prev) prev.disabled = page <= 1; + if (next) next.disabled = !pages || page >= pages; + } + + async function loadList(opts) { + const r = root(); + if (!r) return; + if (loading) return; + loading = true; + const force = opts && opts.force; + try { + if (!force) setStatus("加载中…"); + const qs = new URLSearchParams(listWindowQs()); + qs.set("account", account); + qs.set("page", String(page)); + const res = await fetch("/api/account_ledger?" + qs.toString(), { + credentials: "same-origin", + }); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok || data.ok === false) { + throw new Error(data.msg || res.statusText || "加载失败"); + } + if (data.ledger_version != null) localVersion = Number(data.ledger_version) || localVersion; + renderRows(data.items || []); + renderPager(data); + setSyncLabel(data); + const winLabel = (data.window && data.window.label) || ""; + const err = data.last_error ? " · 同步提示: " + data.last_error : ""; + setStatus( + (winLabel ? "时间窗 " + winLabel + " · " : "") + + (account === "trading" ? "交易账户" : "资金账户") + + err, + !!data.last_error + ); + } catch (e) { + setStatus(e.message || String(e), true); + } finally { + loading = false; + } + } + + async function refreshNow() { + setStatus("正在从交易所同步…"); + try { + const res = await fetch("/api/account_ledger/refresh", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const data = await res.json().catch(function () { + return {}; + }); + if (!res.ok || data.ok === false) { + throw new Error(data.msg || "同步失败"); + } + await loadList({ force: true }); + } catch (e) { + setStatus(e.message || String(e), true); + } + } + + function bindUi() { + const r = root(); + if (!r || r.getAttribute("data-ledger-bound") === "1") return; + r.setAttribute("data-ledger-bound", "1"); + r.querySelectorAll(".account-ledger-tab").forEach(function (btn) { + btn.addEventListener("click", function () { + const acc = btn.getAttribute("data-ledger-account") || "funding"; + if (acc === account) return; + account = acc; + page = 1; + r.querySelectorAll(".account-ledger-tab").forEach(function (b) { + const on = b.getAttribute("data-ledger-account") === account; + b.classList.toggle("active", on); + b.setAttribute("aria-selected", on ? "true" : "false"); + }); + loadList(); + }); + }); + const prev = $("account-ledger-prev"); + const next = $("account-ledger-next"); + const ref = $("account-ledger-refresh"); + if (prev) + prev.addEventListener("click", function () { + if (page > 1) { + page -= 1; + loadList(); + } + }); + if (next) + next.addEventListener("click", function () { + if (!pages || page < pages) { + page += 1; + loadList(); + } + }); + if (ref) ref.addEventListener("click", refreshNow); + } + + function connectSse() { + if (es) { + try { + es.close(); + } catch (_) {} + es = null; + } + if (typeof EventSource === "undefined") return; + try { + es = new EventSource("/api/account_ledger/stream"); + es.addEventListener("ledger", function (ev) { + let data = {}; + try { + data = JSON.parse(ev.data || "{}"); + } catch (_) {} + const ver = Number(data.ledger_version || 0); + if (ver && ver !== localVersion) { + localVersion = ver; + loadList({ force: true }); + } + }); + es.onerror = function () { + try { + es.close(); + } catch (_) {} + es = null; + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(connectSse, 5000); + }; + } catch (_) {} + } + + function boot() { + const r = root(); + if (!r) return; + bindUi(); + if (!booted) { + booted = true; + connectSse(); + } + loadList(); + } + + function onTabActivated(tab) { + if (tab !== "account_ledger") return; + boot(); + } + + global.AccountLedgerPage = { + boot: boot, + onTabActivated: onTabActivated, + reload: function () { + page = 1; + loadList(); + }, + }; + + document.addEventListener("DOMContentLoaded", function () { + const page = + (document.body && document.body.getAttribute("data-page")) || + (document.body && document.body.getAttribute("data-initial-tab")) || + ""; + if (page === "account_ledger" || root()) { + // embed 延后到 tab 激活;独立页直接 boot + if (!document.body || document.body.getAttribute("data-embed-shell") !== "1") { + boot(); + } else if (page === "account_ledger") { + boot(); + } + } + }); + + document.addEventListener("instance-embed-tab-activated", function (ev) { + const tab = ev && ev.detail && ev.detail.tab; + onTabActivated(tab); + }); +})(window); diff --git a/lib/common/static/account_risk_badge.css b/lib/common/static/account_risk_badge.css new file mode 100644 index 0000000..bd47181 --- /dev/null +++ b/lib/common/static/account_risk_badge.css @@ -0,0 +1,150 @@ +/* 账户风控状态徽章 — 三所实例 + 中控共用;兼容 data-theme light/dark */ + +:root, +html[data-theme="dark"] { + --risk-normal-fg: #9cf0c4; + --risk-normal-bg: rgba(36, 140, 96, 0.16); + --risk-normal-border: rgba(72, 190, 130, 0.42); + --risk-normal-glow: rgba(72, 190, 130, 0.35); + + --risk-1h-fg: #ffd27a; + --risk-1h-bg: rgba(210, 150, 40, 0.16); + --risk-1h-border: rgba(230, 170, 60, 0.45); + --risk-1h-glow: rgba(230, 170, 60, 0.32); + + --risk-4h-fg: #ffab8a; + --risk-4h-bg: rgba(210, 90, 55, 0.16); + --risk-4h-border: rgba(230, 110, 70, 0.48); + --risk-4h-glow: rgba(230, 110, 70, 0.34); + + --risk-daily-fg: #ff9ec4; + --risk-daily-bg: rgba(190, 55, 100, 0.18); + --risk-daily-border: rgba(210, 75, 120, 0.5); + --risk-daily-glow: rgba(210, 75, 120, 0.36); + + --risk-position-fg: #8ec8ff; + --risk-position-bg: rgba(55, 120, 210, 0.18); + --risk-position-border: rgba(75, 145, 230, 0.48); + --risk-position-glow: rgba(75, 145, 230, 0.34); + + --risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28); +} + +html[data-theme="light"] { + --risk-normal-fg: #056b44; + --risk-normal-bg: rgba(10, 143, 92, 0.14); + --risk-normal-border: rgba(8, 122, 80, 0.38); + --risk-normal-glow: rgba(10, 143, 92, 0.22); + + --risk-1h-fg: #8a5a00; + --risk-1h-bg: rgba(200, 140, 20, 0.14); + --risk-1h-border: rgba(170, 115, 10, 0.38); + --risk-1h-glow: rgba(200, 140, 20, 0.2); + + --risk-4h-fg: #a83812; + --risk-4h-bg: rgba(210, 85, 35, 0.12); + --risk-4h-border: rgba(180, 65, 25, 0.36); + --risk-4h-glow: rgba(210, 85, 35, 0.2); + + --risk-daily-fg: #9a1248; + --risk-daily-bg: rgba(180, 35, 80, 0.1); + --risk-daily-border: rgba(155, 28, 68, 0.34); + --risk-daily-glow: rgba(180, 35, 80, 0.18); + + --risk-position-fg: #0b5cab; + --risk-position-bg: rgba(20, 100, 190, 0.12); + --risk-position-border: rgba(15, 85, 165, 0.36); + --risk-position-glow: rgba(20, 100, 190, 0.2); + + --risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1); +} + +.risk-status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1.15; + padding: 5px 12px 5px 10px; + border-radius: 999px; + border: 1px solid var(--risk-border, transparent); + background: var(--risk-bg, transparent); + color: var(--risk-fg, inherit); + box-shadow: var(--risk-badge-shadow); + white-space: nowrap; + vertical-align: middle; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +/* 中控 iframe 内切页:避免徽章过渡动画造成 header 闪动 */ +html[data-hub-linked="1"] .header-row .risk-status-badge { + transition: none; +} + +.risk-status-badge::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; + box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 30%, transparent), + 0 0 8px var(--risk-glow, currentColor); + opacity: 0.92; +} + +.risk-status-normal { + --risk-fg: var(--risk-normal-fg); + --risk-bg: var(--risk-normal-bg); + --risk-border: var(--risk-normal-border); + --risk-glow: var(--risk-normal-glow); +} + +.risk-status-freeze_1h { + --risk-fg: var(--risk-1h-fg); + --risk-bg: var(--risk-1h-bg); + --risk-border: var(--risk-1h-border); + --risk-glow: var(--risk-1h-glow); +} + +.risk-status-freeze_4h { + --risk-fg: var(--risk-4h-fg); + --risk-bg: var(--risk-4h-bg); + --risk-border: var(--risk-4h-border); + --risk-glow: var(--risk-4h-glow); +} + +.risk-status-freeze_daily { + --risk-fg: var(--risk-daily-fg); + --risk-bg: var(--risk-daily-bg); + --risk-border: var(--risk-daily-border); + --risk-glow: var(--risk-daily-glow); +} + +.risk-status-freeze_position { + --risk-fg: var(--risk-position-fg); + --risk-bg: var(--risk-position-bg); + --risk-border: var(--risk-position-border); + --risk-glow: var(--risk-position-glow); +} + +/* 实例页:与交易所标签并排 */ +.header-row .risk-status-badge { + min-height: 28px; +} + +/* 中控卡片标题内 */ +.card-title .risk-status-badge, +.hub-tile-name .risk-status-badge { + font-size: 0.7rem; + padding: 3px 10px 3px 8px; + vertical-align: middle; +} + +.card-title .risk-status-badge::before, +.hub-tile-name .risk-status-badge::before { + width: 6px; + height: 6px; +} diff --git a/lib/common/static/account_risk_badge.js b/lib/common/static/account_risk_badge.js new file mode 100644 index 0000000..68fe7dd --- /dev/null +++ b/lib/common/static/account_risk_badge.js @@ -0,0 +1,120 @@ +/** + * 账户风控徽章倒计时 — 三所实例 + 中控共用. + */ +(function (global) { + "use strict"; + + function formatRemaining(totalSec) { + const sec = Math.max(0, Math.floor(Number(totalSec) || 0)); + if (sec <= 0) return ""; + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`; + if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`; + return `${s}s`; + } + + function baseLabel(riskStatus, el) { + if (riskStatus && riskStatus.status_label) return String(riskStatus.status_label); + if (el && el.dataset && el.dataset.statusLabel) return String(el.dataset.statusLabel); + return "正常"; + } + + function resolveFreezeUntilMs(riskStatus) { + if (!riskStatus) return null; + const sec = Number(riskStatus.freeze_remaining_sec); + if (Number.isFinite(sec) && sec > 0) { + return Date.now() + sec * 1000; + } + const until = Number(riskStatus.freeze_until_ms); + return Number.isFinite(until) && until > 0 ? until : null; + } + + function badgeText(riskStatus) { + const label = baseLabel(riskStatus, null); + const until = resolveFreezeUntilMs(riskStatus); + if (!until || until <= Date.now()) return label; + const cd = formatRemaining((until - Date.now()) / 1000); + return cd ? `${label} · ${cd}` : label; + } + + function setNormalBadge(el) { + el.className = "risk-status-badge risk-status-normal"; + el.dataset.statusLabel = "正常"; + el.textContent = "正常"; + el.title = ""; + if (el.dataset) delete el.dataset.freezeUntilMs; + } + + function refreshElement(el) { + if (!el) return; + const label = baseLabel(null, el); + const until = Number(el.dataset && el.dataset.freezeUntilMs); + if (!Number.isFinite(until) || until <= Date.now()) { + if (el.dataset && el.dataset.freezeUntilMs) { + setNormalBadge(el); + } else { + el.textContent = label; + } + return; + } + const cd = formatRemaining((until - Date.now()) / 1000); + el.textContent = cd ? `${label} · ${cd}` : label; + } + + function applyToElement(el, riskStatus) { + if (!el || !riskStatus) return; + const st = riskStatus.status || "normal"; + el.className = "risk-status-badge risk-status-" + st; + el.dataset.statusLabel = baseLabel(riskStatus, el); + const until = resolveFreezeUntilMs(riskStatus); + if (until) { + el.dataset.freezeUntilMs = String(until); + } else if (el.dataset) { + delete el.dataset.freezeUntilMs; + } + el.textContent = badgeText(riskStatus); + el.title = riskStatus.reason || ""; + } + + function formatBadgeHtml(riskStatus, esc) { + if (!riskStatus || typeof riskStatus !== "object") return ""; + const safe = typeof esc === "function" ? esc : (s) => String(s); + const st = riskStatus.status || "normal"; + const label = safe(riskStatus.status_label || "正常"); + const title = safe(riskStatus.reason || ""); + const text = safe(badgeText(riskStatus)); + const until = resolveFreezeUntilMs(riskStatus); + const untilAttr = + until != null + ? ` data-freeze-until-ms="${safe(String(Math.floor(until)))}"` + : ""; + return ( + `${text}` + ); + } + + function tickAll(root) { + const scope = root || document; + scope.querySelectorAll(".risk-status-badge[data-freeze-until-ms]").forEach(refreshElement); + } + + let timer = null; + function startTicker() { + if (timer) return; + tickAll(); + timer = setInterval(() => tickAll(), 1000); + } + + global.AccountRiskBadge = { + formatRemaining, + badgeText, + refreshElement, + applyToElement, + formatBadgeHtml, + tickAll, + startTicker, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/ai_review_render.js b/lib/common/static/ai_review_render.js new file mode 100644 index 0000000..8330aba --- /dev/null +++ b/lib/common/static/ai_review_render.js @@ -0,0 +1,223 @@ +/** + * AI 日复盘 / 周复盘:Markdown 子集渲染 + 五节大标题图标兜底 + */ +(function (global) { + "use strict"; + + var SECTION_FIXES = [ + { re: /^\*\*1\.\s*(?!📊)总体盈亏结构\*\*/m, rep: "**1. 📊 总体盈亏结构**" }, + { re: /^\*\*2\.\s*(?!🧠)心态与执行\*\*/m, rep: "**2. 🧠 心态与执行**" }, + { re: /^\*\*3\.\s*(?!🏷️)行为标签\*\*/m, rep: "**3. 🏷️ 行为标签**" }, + { re: /^\*\*4\.\s*(?!✅)改进建议\*\*/m, rep: "**4. ✅ 改进建议**" }, + { re: /^\*\*5\.\s*(?!📈)图表(?:分析)?\*\*/m, rep: "**5. 📈 图表分析**" }, + { re: /^1\.\s*(?!📊)总体盈亏结构/m, rep: "**1. 📊 总体盈亏结构**" }, + { re: /^2\.\s*(?!🧠)心态与执行/m, rep: "**2. 🧠 心态与执行**" }, + { re: /^3\.\s*(?!🏷️)行为标签/m, rep: "**3. 🏷️ 行为标签**" }, + { re: /^4\.\s*(?!✅)改进建议/m, rep: "**4. ✅ 改进建议**" }, + { re: /^5\.\s*(?!📈)图表/m, rep: "**5. 📈 图表分析**" }, + ]; + + function escapeHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function parseInline(raw) { + var s = escapeHtml(raw); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/`([^`]+)`/g, "$1"); + return s; + } + + function enhanceReviewHeadings(text) { + var out = String(text || ""); + SECTION_FIXES.forEach(function (item) { + out = out.replace(item.re, item.rep); + }); + if (/^【系统说明/m.test(out) && !/^ℹ️/m.test(out)) { + out = out.replace(/^【系统说明/gm, "ℹ️ 【系统说明"); + } + if (/^原始记录:/m.test(out) && !/^📎/m.test(out)) { + out = out.replace(/^原始记录:/gm, "📎 **原始记录**"); + } + return out; + } + + function isNumberedListLine(trimmed) { + if (!trimmed) return false; + if (/^\d+\.\s+/.test(trimmed)) return true; + if (/^\*\*\d+\.\s*.+\*\*$/.test(trimmed)) return true; + return false; + } + + /** 编号列表项之间的空行不拆段,避免每条都从 1 重新开始 */ + function preprocessListBlanks(text) { + var lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); + var out = []; + for (var i = 0; i < lines.length; i++) { + var trimmed = lines[i].trim(); + if (!trimmed) { + var prevTrim = out.length ? String(out[out.length - 1]).trim() : ""; + var nextTrim = ""; + for (var j = i + 1; j < lines.length; j++) { + var t = lines[j].trim(); + if (t) { + nextTrim = t; + break; + } + } + if (isNumberedListLine(prevTrim) && isNumberedListLine(nextTrim)) { + continue; + } + } + out.push(lines[i]); + } + return out.join("\n"); + } + + function renderMarkdown(text) { + var src = enhanceReviewHeadings(preprocessListBlanks(text)); + var lines = src.replace(/\r\n/g, "\n").split("\n"); + var html = []; + var inUl = false; + var inOl = false; + + function closeLists() { + if (inUl) { + html.push(""); + inUl = false; + } + if (inOl) { + html.push(""); + inOl = false; + } + } + + lines.forEach(function (line) { + var trimmed = line.trim(); + if (!trimmed) { + closeLists(); + return; + } + var hm = trimmed.match(/^(#{1,3})\s+(.+)$/); + if (hm) { + closeLists(); + var level = hm[1].length + 1; + if (level > 4) level = 4; + html.push("" + parseInline(hm[2]) + ""); + return; + } + var ulm = trimmed.match(/^[-*]\s+(.+)$/); + if (ulm) { + if (!inUl) { + closeLists(); + html.push("