From f7bac11694f41795f80aa0402f18414e48bbf93f Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 4 Jun 2026 08:24:35 +0800 Subject: [PATCH] feat: add full-margin position sizing mode across four exchanges Env POSITION_SIZING_MODE switches risk vs full-margin (available*buffer, BTC/ETH 10x). Blocks trend/roll/key auto opens in full margin, purges breakout/fib monitors with WeChat notice, keeps RR check and initial SL snapshot for records. Co-authored-by: Cursor --- README.md | 2 + crypto_monitor_binance/.env.example | 2 + crypto_monitor_binance/app.py | 127 ++++++++--- crypto_monitor_binance/templates/index.html | 12 +- crypto_monitor_gate/.env.example | 2 + crypto_monitor_gate/app.py | 126 ++++++++--- crypto_monitor_gate/templates/index.html | 12 +- crypto_monitor_gate_bot/.env.example | 2 + crypto_monitor_gate_bot/app.py | 122 ++++++++--- crypto_monitor_gate_bot/templates/index.html | 10 +- crypto_monitor_okx/.env.example | 2 + crypto_monitor_okx/app.py | 126 ++++++++--- crypto_monitor_okx/templates/index.html | 12 +- docs/position-sizing-mode.md | 41 ++++ key_monitor_full_margin_lib.py | 58 ++++++ position_sizing_lib.py | 99 +++++++++ scripts/patch_position_sizing_to_exchanges.py | 197 ++++++++++++++++++ strategy_config.py | 1 + strategy_register.py | 11 + strategy_trend_register.py | 9 + 20 files changed, 866 insertions(+), 107 deletions(-) create mode 100644 docs/position-sizing-mode.md create mode 100644 key_monitor_full_margin_lib.py create mode 100644 position_sizing_lib.py create mode 100644 scripts/patch_position_sizing_to_exchanges.py diff --git a/README.md b/README.md index f791759..7b27276 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ cd crypto_monitor 会为各子项目创建 `.venv`、安装依赖、从 `.env.example` 生成 `.env`(不覆盖已有)。详见 **[deploy/README.md](./deploy/README.md)**。 +计仓模式(以损定仓 / 全仓杠杆,四所统一):见 **[docs/position-sizing-mode.md](./docs/position-sizing-mode.md)**。 + --- ## 一、仓库目录一览 diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index 886cbae..2e12178 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -50,6 +50,8 @@ UPLOAD_DIR=static/images # TOTAL_CAPITAL=100 # 页顶「资金账户」默认仅 Binance Funding 钱包;若 USDT 主要在现货,可改为 true 合并 Spot # BINANCE_FUNDING_INCLUDE_SPOT=false +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk # 每天起始基数(U) DAILY_START_CAPITAL=30 # 日内回撤后基数(U) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 828eac0..7654aa6 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -81,6 +81,23 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_MANUAL, + OPEN_SOURCE_ROLL, + OPEN_SOURCE_TREND, + assert_open_source_allowed, + compute_full_margin_sizing, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, +) +from key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) from key_monitor_lib import ( KEY_DIRECTION_WATCH, KEY_MONITOR_ALERT_ONLY_TYPES, @@ -243,6 +260,8 @@ FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) # 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +# 计仓模式:risk=以损定仓(默认);full_margin=合约可用保证金×比例全仓杠杆(仅 env 切换,须无仓) +POSITION_SIZING_MODE = load_position_sizing_mode() WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) @@ -1445,6 +1464,27 @@ def init_db(): 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 @@ -4544,6 +4584,9 @@ def _market_open_for_key_monitor( 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入(Binance U 本位)。 返回 (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: @@ -6065,6 +6108,11 @@ def render_main_page(page="trade"): }, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + open_position_button_label=( + "开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)" + ), breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, @@ -6726,6 +6774,12 @@ def add_key(): if mt not in allowed_types: flash("监控类型无效") return redirect("/key_monitor") + if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): + flash( + "全仓杠杆模式下不可添加箱体/收敛突破或斐波监控;" + "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + ) + return redirect("/key_monitor") rank, total = _daily_volume_rank(symbol) if rank is None: flash("日成交量排名读取失败,请稍后重试") @@ -6896,19 +6950,6 @@ def add_order(): flash(f"风控拒绝下单:{reason_live}") return redirect("/") exchange_symbol = normalize_exchange_symbol(symbol) - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - try: - leverage_input = parse_positive_float(d.get("leverage")) - leverage = int(leverage_input) if leverage_input is not None else default_leverage - except Exception: - conn.close() - flash("杠杆参数格式错误") - return redirect("/") - if leverage <= 0: - conn.close() - flash("杠杆必须大于0") - return redirect("/") - trading_day = get_trading_day(now) opens_today_before = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", @@ -6972,19 +7013,55 @@ def add_order(): return redirect("/") risk_percent = max(0.01, float(RISK_PERCENT)) risk_amount = round(capital_base * risk_percent / 100.0, FUNDS_DECIMALS) - 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("/") - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) - if margin_capital > max_margin: + 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(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U") + flash(flat_msg) return redirect("/") - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + 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("/") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + notional_value = round(risk_amount / risk_fraction, FUNDS_DECIMALS) + margin_capital = round(notional_value / leverage, FUNDS_DECIMALS) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), FUNDS_DECIMALS) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, FUNDS_DECIMALS)}U,当前最多建议 {max_margin}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 try: amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) contract_size = get_contract_size(exchange_symbol) @@ -8169,6 +8246,8 @@ from strategy_trend_register import install_strategy_trend install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) +_purge_key_monitors_if_full_margin() + # 启动 if __name__ == "__main__": diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index ed1f5a4..ff8e5bf 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -417,7 +417,13 @@ 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
- 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) @@ -449,7 +455,9 @@ + {% if position_sizing_mode != 'full_margin' %} + {% endif %} @@ -461,7 +469,7 @@ - +
diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index e75689c..1d920d9 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -48,6 +48,8 @@ UPLOAD_DIR=static/images # 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 # TOTAL_CAPITAL=100 +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk # 每天起始基数(U) DAILY_START_CAPITAL=30 # 日内回撤后基数(U) diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 316b4bf..e76da55 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -82,6 +82,21 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, +) +from key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) from key_monitor_lib import ( KEY_DIRECTION_WATCH, KEY_MONITOR_ALERT_ONLY_TYPES, @@ -240,6 +255,7 @@ FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) # 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +POSITION_SIZING_MODE = load_position_sizing_mode() WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) @@ -1442,6 +1458,27 @@ def init_db(): 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 @@ -4369,6 +4406,9 @@ def _market_open_for_key_monitor( 与手动「实盘下单」对齐的市价开仓与 order_monitors 写入。 返回 (ok: bool, err_msg: Optional[str], detail: Optional[dict]) """ + ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) + if not ok_src: + return False, src_msg, None now = app_now() ok, reason = precheck_risk(conn, symbol, direction) if not ok: @@ -6024,6 +6064,11 @@ def render_main_page(page="trade"): }, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + open_position_button_label=( + "开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)" + ), breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, @@ -6732,6 +6777,12 @@ def add_key(): if mt not in allowed_types: flash("监控类型无效") return redirect("/key_monitor") + if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): + flash( + "全仓杠杆模式下不可添加箱体/收敛突破或斐波监控;" + "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + ) + return redirect("/key_monitor") rank, total = _daily_volume_rank(symbol) if rank is None: flash("日成交量排名读取失败,请稍后重试") @@ -6930,19 +6981,6 @@ def add_order(): flash(f"风控拒绝下单:{reason_live}") return redirect("/trade") exchange_symbol = normalize_exchange_symbol(symbol) - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - try: - leverage_input = parse_positive_float(d.get("leverage")) - leverage = int(leverage_input) if leverage_input is not None else default_leverage - except Exception: - conn.close() - flash("杠杆参数格式错误") - return redirect("/") - if leverage <= 0: - conn.close() - flash("杠杆必须大于0") - return redirect("/") - trading_day = get_trading_day(now) opens_today_before = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", @@ -7018,20 +7056,56 @@ def add_order(): flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/") 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: - conn.close() - flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") - return redirect("/") - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: + risk_amount = round(capital_base * risk_percent / 100.0, 2) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: conn.close() - flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U") + flash(flat_msg) return redirect("/") - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + conn.close() + flash(sizing_err) + return redirect("/") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + notional_value = round(risk_amount / risk_fraction, 2) + margin_capital = round(notional_value / leverage, 2) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 2) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {round(max_margin, 2)}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 try: amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) contract_size = get_contract_size(exchange_symbol) @@ -8213,6 +8287,8 @@ from strategy_trend_register import install_strategy_trend install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) +_purge_key_monitors_if_full_margin() + # 启动 if __name__ == "__main__": diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index ed1f5a4..ff8e5bf 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -417,7 +417,13 @@ 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
- 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) @@ -449,7 +455,9 @@ + {% if position_sizing_mode != 'full_margin' %} + {% endif %} @@ -461,7 +469,7 @@ - +
diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example index b6f7f1c..505af1a 100644 --- a/crypto_monitor_gate_bot/.env.example +++ b/crypto_monitor_gate_bot/.env.example @@ -48,6 +48,8 @@ UPLOAD_DIR=static/images # 已废弃:资金账户仅显示交易所 funding 余额,不再读取此变量 # TOTAL_CAPITAL=100 +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk # 每天起始基数(U) DAILY_START_CAPITAL=30 # 日内回撤后基数(U) diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 28e9656..67ddb65 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -36,6 +36,19 @@ if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) from ai_client import ai_generate, ai_review, ai_short_advice from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review +from position_sizing_lib import ( + assert_open_source_allowed, + compute_full_margin_sizing, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, +) +from key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order from order_monitor_display_lib import ( apply_order_price_display_fields, @@ -198,6 +211,7 @@ FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0")) # 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日便于对账 AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8")) +POSITION_SIZING_MODE = load_position_sizing_mode() WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10")) AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120")) MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3")) @@ -1480,6 +1494,27 @@ def init_db(): 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=lambda _row: None, + 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 @@ -5533,6 +5568,11 @@ def render_main_page(page="trade"): data_export_version=3, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + open_position_button_label=( + "开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)" + ), breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, @@ -6128,19 +6168,6 @@ def add_order(): flash(f"风控拒绝下单:{reason_live}") return redirect("/") exchange_symbol = normalize_exchange_symbol(symbol) - default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) - try: - leverage_input = parse_positive_float(d.get("leverage")) - leverage = int(leverage_input) if leverage_input is not None else default_leverage - except Exception: - conn.close() - flash("杠杆参数格式错误") - return redirect("/") - if leverage <= 0: - conn.close() - flash("杠杆必须大于0") - return redirect("/") - trading_day = get_trading_day(now) opens_today_before = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", @@ -6191,26 +6218,69 @@ def add_order(): conn.close() flash("价格参数必须大于0") return redirect("/") + _min_rr = float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")) + planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit) + if planned_rr_manual is None or planned_rr_manual < _min_rr: + conn.close() + rr_txt = f"{planned_rr_manual:.4f}" if planned_rr_manual is not None else "无法计算" + flash(f"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {_min_rr}:1") + return redirect("/") risk_fraction = calc_risk_fraction(direction, live_price, stop_loss) if risk_fraction is None: conn.close() flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/") risk_percent = max(0.01, float(RISK_PERCENT)) - risk_amount = round(capital_base * risk_percent / 100.0, 4) - notional_value = round(risk_amount / risk_fraction, 4) - margin_capital = round(notional_value / leverage, 4) - if capital_base and margin_capital > capital_base: - conn.close() - flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") - return redirect("/") - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: + risk_amount = round(capital_base * risk_percent / 100.0, 2) + if is_full_margin_mode(POSITION_SIZING_MODE): + ok_flat, flat_msg = full_margin_requires_flat_position(get_active_position_count(conn)) + if not ok_flat: conn.close() - flash(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + flash(flat_msg) return redirect("/") - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + leverage = leverage_for_full_margin(symbol, BTC_LEVERAGE, ALT_LEVERAGE) + sizing, sizing_err = compute_full_margin_sizing( + symbol=symbol, + available_usdt=available_usdt if available_usdt is not None else 0.0, + capital_base=capital_base, + buffer_ratio=FULL_MARGIN_BUFFER_RATIO, + btc_leverage=BTC_LEVERAGE, + alt_leverage=ALT_LEVERAGE, + funds_decimals=2, + ) + if sizing_err: + conn.close() + flash(sizing_err) + return redirect("/") + margin_capital = sizing["margin_capital"] + notional_value = sizing["notional_value"] + position_ratio = sizing["position_ratio"] + else: + default_leverage = get_synced_leverage(exchange_symbol, direction) or infer_leverage(symbol) + try: + leverage_input = parse_positive_float(d.get("leverage")) + leverage = int(leverage_input) if leverage_input is not None else default_leverage + except Exception: + conn.close() + flash("杠杆参数格式错误") + return redirect("/") + if leverage <= 0: + conn.close() + flash("杠杆必须大于0") + return redirect("/") + notional_value = round(risk_amount / risk_fraction, 2) + margin_capital = round(notional_value / leverage, 2) + if capital_base and margin_capital > capital_base: + conn.close() + flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") + return redirect("/") + if available_usdt is not None: + max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 2) + if margin_capital > max_margin: + conn.close() + flash(f"保证金不足:交易账户可用约 {round(available_usdt, 2)}U,当前最多建议 {max_margin}U") + return redirect("/") + position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 try: amount, quote_price = prepare_order_amount(exchange_symbol, margin_capital, leverage, live_price) contract_size = get_contract_size(exchange_symbol) @@ -7676,6 +7746,8 @@ install_strategy_trading( trend_enabled=True, ) +_purge_key_monitors_if_full_margin() + # 启动 if __name__ == "__main__": diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index 77e3d14..b527529 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -302,7 +302,13 @@ 按风险比例自动计算仓位
- 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ money_fmt(auto_transfer_amount) }}U,来自 {{ auto_transfer_from }}) @@ -346,7 +352,7 @@ - +

实时持仓

diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index a9f6697..dbbfbe3 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -48,6 +48,8 @@ UPLOAD_DIR=static/images # 训练总资金(U) # TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所 +# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启) +POSITION_SIZING_MODE=risk # 每天起始基数(U) DAILY_START_CAPITAL=30 # 日内回撤后基数(U) diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index e1ffa90..05b0b7a 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -82,6 +82,21 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, +) +from key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) from key_monitor_lib import ( KEY_DIRECTION_WATCH, KEY_MONITOR_ALERT_ONLY_TYPES, @@ -213,6 +228,7 @@ 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() 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")) @@ -1389,6 +1405,27 @@ def init_db(): 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 @@ -4639,6 +4676,9 @@ def _market_open_for_key_monitor( 与手动「实盘下单」对齐的市价开仓与 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: @@ -5669,6 +5709,11 @@ def render_main_page(page="trade"): }, key_alert_max_times=KEY_ALERT_MAX_TIMES, risk_percent=RISK_PERCENT, + position_sizing_mode=POSITION_SIZING_MODE, + position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE), + open_position_button_label=( + "开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)" + ), breakeven_rr_trigger=BREAKEVEN_RR_TRIGGER, breakeven_offset_pct=BREAKEVEN_OFFSET_PCT, occupied_miss_total=occupied_miss_total, @@ -6403,6 +6448,12 @@ def add_key(): if mt not in allowed_types: flash("监控类型无效") return redirect("/key_monitor") + if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): + flash( + "全仓杠杆模式下不可添加箱体/收敛突破或斐波监控;" + "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + ) + return redirect("/key_monitor") rank, total = _daily_volume_rank(symbol) if rank is None: flash("日成交量排名读取失败,请稍后重试") @@ -6573,19 +6624,6 @@ def add_order(): flash(f"风控拒绝下单:{reason_live}") return redirect("/trade") exchange_symbol = normalize_okx_symbol(symbol) - 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") - trading_day = get_trading_day(now) opens_today_before = conn.execute( "SELECT COUNT(*) FROM order_monitors WHERE session_date=?", @@ -6648,20 +6686,56 @@ def add_order(): flash("止损方向不合法:请检查入场方向与止损价格关系") return redirect("/trade") 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: - conn.close() - flash("以损定仓后保证金超过当前交易资金,请放宽止损或降低风险比例") - return redirect("/trade") - if available_usdt is not None: - max_margin = round(max(available_usdt * FULL_MARGIN_BUFFER_RATIO, 0), 4) - if margin_capital > max_margin: + 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(f"保证金不足:交易账户可用约 {round(available_usdt,4)}U,当前最多建议 {max_margin}U") + flash(flat_msg) return redirect("/trade") - position_ratio = round(margin_capital / capital_base * 100, 2) if capital_base else 0 + 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) @@ -7825,6 +7899,8 @@ from strategy_trend_register import install_strategy_trend install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__]) install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__]) +_purge_key_monitors_if_full_margin() + # 启动 if __name__ == "__main__": diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index 8e6f0c2..766c748 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -426,7 +426,13 @@ 人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1
- 以损定仓:风险 {{ risk_percent }}% |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% + 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}%
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天北京时间 {{ auto_transfer_bj_hour }}:00起该整点小时内尝试;账簿按 UTC 自然日去重;界面时间为北京;将 {{ auto_transfer_to }} 补足到 {{ auto_transfer_amount }}U,来自 {{ auto_transfer_from }}) @@ -458,7 +464,9 @@ + {% if position_sizing_mode != 'full_margin' %} + {% endif %} @@ -470,7 +478,7 @@ - +
diff --git a/docs/position-sizing-mode.md b/docs/position-sizing-mode.md new file mode 100644 index 0000000..683e0d9 --- /dev/null +++ b/docs/position-sizing-mode.md @@ -0,0 +1,41 @@ +# 计仓模式(四所统一) + +## 配置 + +在各实例 `.env` 中设置(**仅能通过 env 切换,修改后须重启进程**): + +```env +# risk(默认)= 以损定仓 +# full_margin = 全仓杠杆(合约可用保证金 × 比例) +POSITION_SIZING_MODE=risk +FULL_MARGIN_BUFFER_RATIO=0.98 +``` + +切换为全仓杠杆前:**交易所须无持仓**(`MAX_ACTIVE_POSITIONS` 默认 1,全仓模式会强制单仓)。 + +## 模式说明 + +| 模式 | 保证金计算 | 杠杆 | 允许入口 | +|------|------------|------|----------| +| `risk` | `RISK_PERCENT` × 交易资金,按止损距离反推 | 表单可选 / 同步交易所 | 实盘人工、关键位自动、趋势回调、顺势加仓 | +| `full_margin` | **合约账户可用 USDT × `FULL_MARGIN_BUFFER_RATIO`**(保留 2 位小数) | BTC/ETH **10x**,其它 **5x**(与 `BTC_LEVERAGE`/`ALT_LEVERAGE` 一致) | **仅** 实盘人工下单;阻力/支撑仅提醒 | + +全仓模式下: + +- 仍校验 **计划盈亏比**(`MANUAL_MIN_PLANNED_RR`)。 +- 下单张数由 `prepare_order_amount` + 交易所 `amount_to_precision` 决定。 +- `order_monitors.initial_stop_loss` 仍记录**开仓时**止损快照;交易记录复盘以该快照为准。 +- 已存在的 **箱体突破 / 收敛突破 / 斐波** 监控:进程启动时**自动撤销**并企业微信通知。 + +## 不允许(全仓模式) + +- 关键位:箱体突破、收敛突破、斐波自动单(添加时拒绝;已存在则启动时撤销)。 +- 趋势回调、顺势加仓(策略入口返回明确错误)。 + +## 部署 + +```bash +git pull +# 四所 .env 增加 POSITION_SIZING_MODE=risk 或 full_margin +pm2 restart crypto-monitor-binance crypto-monitor-okx crypto-monitor-gate crypto-monitor-gate-bot +``` diff --git a/key_monitor_full_margin_lib.py b/key_monitor_full_margin_lib.py new file mode 100644 index 0000000..29e0f62 --- /dev/null +++ b/key_monitor_full_margin_lib.py @@ -0,0 +1,58 @@ +""" +全仓杠杆模式下:撤销已添加的箱体/收敛/斐波关键位监控并微信说明。 +""" +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional + +from fib_key_monitor_lib import FIB_KEY_MONITOR_TYPES, is_fib_key_monitor_type +from key_monitor_lib import KEY_MONITOR_AUTO_TYPES +from position_sizing_lib import is_full_margin_mode, mode_label_zh + + +def monitor_type_disallowed_in_full_margin(monitor_type: str) -> bool: + mt = (monitor_type or "").strip() + if mt in KEY_MONITOR_AUTO_TYPES: + return True + return is_fib_key_monitor_type(mt) + + +def purge_disallowed_key_monitors( + conn: Any, + *, + sizing_mode: str, + select_rows: Callable[[Any], Iterable[Any]], + cancel_fib_limit: Callable[[Any], None], + delete_monitor: Callable[[Any, int], None], + send_wechat: Callable[[str], None], + row_symbol: Callable[[Any], str] = lambda r: str(r["symbol"] or ""), + row_monitor_type: Callable[[Any], str] = lambda r: str(r["monitor_type"] or ""), + row_id: Callable[[Any], int] = lambda r: int(r["id"]), +) -> int: + if not is_full_margin_mode(sizing_mode): + return 0 + removed = [] + for row in select_rows(conn): + mt = row_monitor_type(row) + if not monitor_type_disallowed_in_full_margin(mt): + continue + sym = row_symbol(row) + kid = row_id(row) + if is_fib_key_monitor_type(mt): + try: + cancel_fib_limit(row) + except Exception: + pass + delete_monitor(conn, kid) + removed.append((sym, mt, kid)) + if removed: + lines = [f"· {s} {t} (#{i})" for s, t, i in removed[:12]] + if len(removed) > 12: + lines.append(f"… 共 {len(removed)} 条") + send_wechat( + "# ⚠️ 全仓杠杆模式:已自动撤销关键位监控\n" + f"计仓模式:{mode_label_zh(sizing_mode)}(仅 env 可切换,须无仓)\n" + "已撤销:箱体突破 / 收敛突破 / 斐波回调监控(不可与全仓杠杆并存)\n" + + "\n".join(lines) + ) + return len(removed) diff --git a/position_sizing_lib.py b/position_sizing_lib.py new file mode 100644 index 0000000..854f759 --- /dev/null +++ b/position_sizing_lib.py @@ -0,0 +1,99 @@ +""" +四所共用:计仓模式 risk(以损定仓)| full_margin(全仓杠杆)。 +仅 env POSITION_SIZING_MODE 切换;须无持仓(由部署流程保证)。 +""" +from __future__ import annotations + +import os +from typing import Any, Optional, Tuple + +MODE_RISK = "risk" +MODE_FULL_MARGIN = "full_margin" +VALID_MODES = frozenset({MODE_RISK, MODE_FULL_MARGIN}) + +OPEN_SOURCE_MANUAL = "manual" +OPEN_SOURCE_KEY_AUTO = "key_auto" +OPEN_SOURCE_KEY_FIB = "key_fib" +OPEN_SOURCE_TREND = "trend" +OPEN_SOURCE_ROLL = "roll" + +FULL_MARGIN_BLOCKED_SOURCES = frozenset( + {OPEN_SOURCE_KEY_AUTO, OPEN_SOURCE_KEY_FIB, OPEN_SOURCE_TREND, OPEN_SOURCE_ROLL} +) + + +def normalize_position_sizing_mode(raw: Optional[str]) -> str: + v = (raw or MODE_RISK).strip().lower() + if v in ("full", "full_margin", "fullmargin", "全仓", "全仓杠杆"): + return MODE_FULL_MARGIN + return MODE_RISK if v in ("risk", "r", "以损定仓", "") else MODE_RISK + + +def load_position_sizing_mode(env: Optional[dict] = None) -> str: + e = env if env is not None else os.environ + return normalize_position_sizing_mode(e.get("POSITION_SIZING_MODE")) + + +def is_full_margin_mode(mode: str) -> bool: + return normalize_position_sizing_mode(mode) == MODE_FULL_MARGIN + + +def mode_label_zh(mode: str) -> str: + return "全仓杠杆" if is_full_margin_mode(mode) else "以损定仓" + + +def leverage_for_full_margin(symbol: str, btc_leverage: int, alt_leverage: int) -> int: + sym = (symbol or "").strip().upper() + if sym.startswith("BTC") or sym.startswith("ETH"): + return max(1, int(btc_leverage or 10)) + return max(1, int(alt_leverage or 5)) + + +def round_funds(value: float, decimals: int = 2) -> float: + return round(float(value), int(decimals)) + + +def assert_open_source_allowed(mode: str, source: str) -> Tuple[bool, str]: + if not is_full_margin_mode(mode): + return True, "" + src = (source or "").strip().lower() + if src in FULL_MARGIN_BLOCKED_SOURCES: + return False, ( + "当前为全仓杠杆模式(POSITION_SIZING_MODE=full_margin)," + "不允许关键位突破/斐波自动开仓、趋势回调与顺势加仓;" + "仅支持实盘人工下单与阻力/支撑提醒。" + ) + return True, "" + + +def full_margin_requires_flat_position(active_count: int) -> Tuple[bool, str]: + if active_count > 0: + return False, "全仓杠杆模式仅允许单仓且无其它持仓,请先平仓后再开仓" + return True, "" + + +def compute_full_margin_sizing( + *, + symbol: str, + available_usdt: float, + capital_base: float, + buffer_ratio: float, + btc_leverage: int, + alt_leverage: int, + funds_decimals: int = 2, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + if available_usdt is None or float(available_usdt) <= 0: + return None, "全仓杠杆:无法读取合约账户可用保证金" + lev = leverage_for_full_margin(symbol, btc_leverage, alt_leverage) + margin = round_funds(float(available_usdt) * float(buffer_ratio), funds_decimals) + if margin <= 0: + return None, "全仓杠杆:可用保证金不足" + notional = round_funds(margin * lev, funds_decimals) + ratio = round(margin / float(capital_base) * 100, 2) if capital_base else 0.0 + return { + "margin_capital": margin, + "leverage": lev, + "notional_value": notional, + "position_ratio": ratio, + "mode": MODE_FULL_MARGIN, + }, None diff --git a/scripts/patch_position_sizing_to_exchanges.py b/scripts/patch_position_sizing_to_exchanges.py new file mode 100644 index 0000000..e765e47 --- /dev/null +++ b/scripts/patch_position_sizing_to_exchanges.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""一次性:为 okx/gate/gate_bot 注入与 binance 一致的计仓模式补丁(已 patch 过则跳过)。""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +IMPORT_BLOCK = '''from position_sizing_lib import ( + OPEN_SOURCE_KEY_AUTO, + OPEN_SOURCE_MANUAL, + assert_open_source_allowed, + compute_full_margin_sizing, + full_margin_requires_flat_position, + is_full_margin_mode, + leverage_for_full_margin, + load_position_sizing_mode, + mode_label_zh, +) +from key_monitor_full_margin_lib import ( + monitor_type_disallowed_in_full_margin, + purge_disallowed_key_monitors, +) +''' + +ENV_LINE = ( + "# 计仓模式:risk=以损定仓(默认);full_margin=合约可用×比例全仓杠杆(仅 env 切换,须无仓)\n" + "POSITION_SIZING_MODE = load_position_sizing_mode()\n" +) + +PURGE_FN = ''' + +def _purge_key_monitors_if_full_margin(): + if not is_full_margin_mode(POSITION_SIZING_MODE): + return + conn = get_db() + try: + cancel = globals().get("_cancel_fib_monitor_limit") + if not callable(cancel): + cancel = lambda _row: None + 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, + 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() + + +''' + +MARKET_OPEN_GUARD = ''' ok_src, src_msg = assert_open_source_allowed(POSITION_SIZING_MODE, OPEN_SOURCE_KEY_AUTO) + if not ok_src: + return False, src_msg, None +''' + +ADD_KEY_GUARD = ''' if is_full_margin_mode(POSITION_SIZING_MODE) and monitor_type_disallowed_in_full_margin(mt): + flash( + "全仓杠杆模式下不可添加箱体/收敛突破或斐波监控;" + "请改用阻力/支撑(仅提醒),或切换 POSITION_SIZING_MODE=risk 并重启(须无持仓)。" + ) + return redirect("/key_monitor") +''' + +TEMPLATE_RULE = '''
+ 计仓模式:{{ position_sizing_mode_label }}(仅 .env POSITION_SIZING_MODE,须无仓后重启) + {% if position_sizing_mode == 'full_margin' %} + |全仓:合约可用×{{ full_margin_buffer_ratio }},BTC/ETH {{ btc_leverage }}x、其它 {{ alt_leverage }}x,单仓;张数按交易所精度 + {% else %} + |以损定仓:风险 {{ risk_percent }}% + {% endif %} + |移动保本:下单可勾选关闭;开启时 {{ breakeven_rr_trigger }}R 触发(每 1R 阶梯上移),偏移 {{ breakeven_offset_pct }}% +
''' + +APPS = [ + ("crypto_monitor_okx", 4, "_market_open_for_key_monitor", True), + ("crypto_monitor_gate", 2, "_market_open_for_key_monitor", True), + ("crypto_monitor_gate_bot", 4, None, False), +] + + +def patch_app(app_dir: str, funds_dec: int, market_fn: str | None, has_fib: bool): + path = ROOT / app_dir / "app.py" + text = path.read_text(encoding="utf-8") + if "POSITION_SIZING_MODE" in text: + print(f"SKIP {app_dir}/app.py (already patched)") + return + if "from position_sizing_lib import" not in text: + anchor = "from key_monitor_lib import (" + if anchor not in text: + anchor = "from form_submit_lib import" + text = text.replace( + anchor, + IMPORT_BLOCK + "\n" + anchor, + 1, + ) + else: + text = text.replace(anchor, IMPORT_BLOCK + anchor, 1) + if "POSITION_SIZING_MODE = load_position_sizing_mode()" not in text: + text = text.replace( + "AUTO_TRANSFER_BJ_HOUR = int(os.getenv(\"AUTO_TRANSFER_BJ_HOUR\", \"8\"))\n", + "AUTO_TRANSFER_BJ_HOUR = int(os.getenv(\"AUTO_TRANSFER_BJ_HOUR\", \"8\"))\n" + ENV_LINE, + 1, + ) + if "_purge_key_monitors_if_full_margin" not in text: + text = text.replace("init_db()\n\n\ndef get_db():", "init_db()" + PURGE_FN + "\ndef get_db():", 1) + text = text.replace( + "install_strategy_trend(app,", + "_purge_key_monitors_if_full_margin()\n\ninstall_strategy_trend(app,", + 1, + ) + if market_fn and MARKET_OPEN_GUARD.strip() not in text: + text = text.replace( + f"def {market_fn}(\n", + f"def {market_fn}(\n", + 1, + ) + text = text.replace( + ' """\n 与手动', + MARKET_OPEN_GUARD + ' """\n 与手动', + 1, + ) + # fallback: after docstring closing + if MARKET_OPEN_GUARD.strip() not in text: + pat = rf"(def {market_fn}\([^)]+\):\s*\n\s*\"\"\"[^\"\"]*\"\"\"\s*\n)" + text = re.sub(pat, r"\1" + MARKET_OPEN_GUARD, text, count=1) + if has_fib and ADD_KEY_GUARD.strip() not in text: + text = text.replace( + ' if mt not in allowed_types:', + ADD_KEY_GUARD + ' if mt not in allowed_types:', + 1, + ) if "if mt not in allowed_types:" in text else text.replace( + ' rank, total = _daily_volume_rank(symbol)', + ADD_KEY_GUARD + ' rank, total = _daily_volume_rank(symbol)', + 1, + ) + # render_template risk_percent= add template vars + if "position_sizing_mode=POSITION_SIZING_MODE" not in text: + text = text.replace( + "risk_percent=RISK_PERCENT,\n", + "risk_percent=RISK_PERCENT,\n" + " position_sizing_mode=POSITION_SIZING_MODE,\n" + " position_sizing_mode_label=mode_label_zh(POSITION_SIZING_MODE),\n" + " open_position_button_label=(\n" + ' "开仓(全仓杠杆)" if is_full_margin_mode(POSITION_SIZING_MODE) else "开仓(以损定仓)"\n' + " ),\n", + 1, + ) + path.write_text(text, encoding="utf-8") + print(f"DONE {app_dir}/app.py (partial — verify add_order block manually if needed)") + + +def patch_template(app_dir: str): + tpl = ROOT / app_dir / "templates" / "index.html" + if not tpl.exists(): + return + text = tpl.read_text(encoding="utf-8") + if "position_sizing_mode_label" in text: + print(f"SKIP {tpl}") + return + old = re.search( + r'
\s*以损定仓:风险 \{\{ risk_percent \}\}%.*?
', + text, + re.S, + ) + if old: + text = text[: old.start()] + TEMPLATE_RULE + text[old.end() :] + text = text.replace( + '', + '', + ) + text = text.replace( + '', + '{% if position_sizing_mode != \'full_margin\' %}\n' + ' \n' + ' {% endif %}', + 1, + ) + tpl.write_text(text, encoding="utf-8") + print(f"DONE {tpl}") + + +def main(): + for app_dir, funds, mfn, fib in APPS: + patch_app(app_dir, funds, mfn, fib) + patch_template(app_dir) + + +if __name__ == "__main__": + main() diff --git a/strategy_config.py b/strategy_config.py index 1c67a7d..b749059 100644 --- a/strategy_config.py +++ b/strategy_config.py @@ -194,6 +194,7 @@ def build_strategy_config( "趋势回调(自动补仓)请在 Gate 趋势机器人实例使用:/strategy/trend" ) return { + "app_module": m, "exchange_display": getattr(m, "EXCHANGE_DISPLAY_NAME", ""), "trend_enabled": trend_enabled, "trend_disabled_note": note, diff --git a/strategy_register.py b/strategy_register.py index 345886b..dff416f 100644 --- a/strategy_register.py +++ b/strategy_register.py @@ -103,6 +103,17 @@ def _count_active_trends(conn, cfg: dict) -> int: def _roll_preview_response(cfg: dict, data: dict, json_mode: bool = False) -> dict: + m = cfg.get("app_module") + if m is not None: + try: + from position_sizing_lib import OPEN_SOURCE_ROLL, assert_open_source_allowed + + mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" + ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_ROLL) + if not ok_src: + return {"ok": False, "msg": src_msg} + except Exception: + pass get_db = cfg["get_db"] symbol = cfg["normalize_symbol_input"](data.get("symbol") or "") if not symbol: diff --git a/strategy_trend_register.py b/strategy_trend_register.py index 9ecd368..569280a 100644 --- a/strategy_trend_register.py +++ b/strategy_trend_register.py @@ -106,6 +106,15 @@ def _row(cfg, row) -> dict: def precheck_trend_start(cfg: dict, conn) -> tuple[bool, str]: m = _m(cfg) + mode = getattr(m, "POSITION_SIZING_MODE", None) or "risk" + try: + from position_sizing_lib import OPEN_SOURCE_TREND, assert_open_source_allowed + + ok_src, src_msg = assert_open_source_allowed(mode, OPEN_SOURCE_TREND) + if not ok_src: + return False, src_msg + except Exception: + pass now = m.app_now() if not m.trading_day_reset_allows_new_open(now): return False, f"北京时间 {cfg['reset_hour']}:00 前不允许持仓"