#!/usr/bin/env python3 """ 将三所共用的交易/关键位/轮询 env 写入币安,OKX 的 .env(缺失则追加,不覆盖已有值). 以 Gate .env.example 为基准;Gate 自身也可运行以补缺失项. 用法(仓库根目录): python scripts/sync_common_trading_env.py python scripts/sync_common_trading_env.py --dry-run python scripts/sync_common_trading_env.py --instances crypto_monitor_okx 修改后须 pm2 restart 对应实例.说明见 docs/env-sync-scripts.md """ from __future__ import annotations import argparse import os import re REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_INSTANCES = ( "crypto_monitor_binance", "crypto_monitor_okx", ) # 与 crypto_monitor_gate/.env.example 对齐(不含 GATE_* / 各所 API 密钥) SHARED_DEFAULTS: dict[str, str] = { "TRADING_DAY_RESET_OPEN_GUARD_ENABLED": "true", "KEY_CONFIRM_BREAKOUT_BAR": "-2", "KEY_CONFIRM_BAR": "-1", "KEY_VOLUME_MA_BARS": "20", "KEY_VOLUME_RATIO_MIN": "1.3", "KEY_BREAKOUT_AMP_MIN_PCT": "0.03", "KEY_BREAKOUT_AMP_MAX_PCT": "0.5", "KEY_ALERT_MAX_TIMES": "3", "KEY_ALERT_INTERVAL_MINUTES": "5", "KEY_DAILY_VOLUME_RANK_MAX": "30", "KEY_AUTO_MIN_PLANNED_RR": "1.5", "KEY_STOP_OUTSIDE_BREAKOUT_PCT": "0.5", "KEY_TREND_STOP_OUTSIDE_PCT": "1", "MAX_ACTIVE_POSITIONS": "1", "MANUAL_MIN_PLANNED_RR": "1.4", "KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT": "true", "DAILY_OPEN_ALERT_THRESHOLD": "5", "DAILY_OPEN_HARD_LIMIT": "0", "BALANCE_REFRESH_SECONDS": "60", "PRICE_REFRESH_SECONDS": "5", "MONITOR_POLL_SECONDS": "3", "RECONCILE_STARTUP_GRACE_SEC": "90", "RECONCILE_FLAT_CONFIRM_POLLS": "3", "FULL_MARGIN_BUFFER_RATIO": "0.98", "WECHAT_TIMEOUT_SECONDS": "10", "AI_TIMEOUT_SECONDS": "120", } # 仅 Gate 启用 0 点强制清仓;币安/OKX 须保持关闭 FORCE_CLOSE_POLICY: dict[str, dict[str, str]] = { "crypto_monitor_gate": { "FORCE_CLOSE_ENABLED": "true", "FORCE_CLOSE_BJ_HOUR": "0", }, "crypto_monitor_binance": { "FORCE_CLOSE_ENABLED": "false", "FORCE_CLOSE_BJ_HOUR": "0", }, "crypto_monitor_okx": { "FORCE_CLOSE_ENABLED": "false", "FORCE_CLOSE_BJ_HOUR": "0", }, } def _parse_env(path: str) -> list[str]: if not os.path.isfile(path): return [] with open(path, "r", encoding="utf-8", errors="ignore") as f: return f.read().replace("\r\n", "\n").replace("\r", "\n").splitlines() def _env_get(lines: list[str], key: str) -> str | None: pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=\s*(.*)\s*$") for line in lines: m = pat.match(line) if m: return m.group(1).strip().strip('"').strip("'") return None def _upsert(lines: list[str], key: str, value: str) -> list[str]: pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=") out: list[str] = [] replaced = False for line in lines: if pat.match(line): if not replaced: out.append(f"{key}={value}") replaced = True continue out.append(line) if not replaced: if out and out[-1].strip(): out.append("") out.append(f"{key}={value}") return out def sync_one(dir_name: str, *, dry_run: bool, force: bool) -> bool: path = os.path.join(REPO, dir_name, ".env") if not os.path.isfile(path): print(f"skip (no .env): {dir_name}") return False lines = _parse_env(path) added: list[str] = [] for key, val in SHARED_DEFAULTS.items(): cur = _env_get(lines, key) if cur is None or (force and cur != val): lines = _upsert(lines, key, val) added.append(key) if not added: print(f"ok (unchanged): {dir_name}") return False print(f"update: {dir_name}") for key in added: print(f" + {key}={SHARED_DEFAULTS[key]}") if not dry_run: text = "\n".join(lines).rstrip() + "\n" with open(path, "w", encoding="utf-8", newline="\n") as f: f.write(text) return True def apply_force_close_policy(*, dry_run: bool) -> bool: """Gate 开启强制清仓;币安/OKX 强制关闭(覆盖已有值).""" any_changed = False for dir_name, values in FORCE_CLOSE_POLICY.items(): path = os.path.join(REPO, dir_name, ".env") if not os.path.isfile(path): print(f"skip (no .env): {dir_name}") continue lines = _parse_env(path) changed_keys: list[str] = [] for key, val in values.items(): cur = _env_get(lines, key) if cur != val: lines = _upsert(lines, key, val) changed_keys.append(key) if not changed_keys: print(f"ok (force-close policy): {dir_name}") continue any_changed = True print(f"force-close policy: {dir_name}") for key in changed_keys: print(f" = {key}={values[key]}") if not dry_run: text = "\n".join(lines).rstrip() + "\n" with open(path, "w", encoding="utf-8", newline="\n") as f: f.write(text) return any_changed def main() -> None: ap = argparse.ArgumentParser(description="同步币安/OKX 共用 trading env(缺失项追加)") ap.add_argument("--dry-run", action="store_true") ap.add_argument("--force", action="store_true", help="覆盖已有值(慎用)") ap.add_argument( "--apply-force-close-policy", action="store_true", help="Gate 开启 0 点强制清仓,币安/OKX 强制关闭", ) ap.add_argument( "--instances", nargs="+", metavar="DIR", help="默认 crypto_monitor_binance crypto_monitor_okx", ) args = ap.parse_args() instances = tuple(args.instances) if args.instances else DEFAULT_INSTANCES any_changed = False for inst in instances: if sync_one(inst, dry_run=args.dry_run, force=args.force): any_changed = True if args.apply_force_close_policy: if apply_force_close_policy(dry_run=args.dry_run): any_changed = True if args.dry_run and any_changed: print("(dry-run, 未写入)") if __name__ == "__main__": main()