Initialize crypto_monitor_user (user edition) from monitor codebase.
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot: align crypto_monitor_okx with binance/gate patterns (OKX_* prefixes)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OKX = ROOT / "crypto_monitor_okx"
|
||||
BIN = ROOT / "crypto_monitor_binance"
|
||||
GATE = ROOT / "crypto_monitor_gate"
|
||||
|
||||
|
||||
def patch_app():
|
||||
app_path = OKX / "app.py"
|
||||
text = app_path.read_text(encoding="utf-8")
|
||||
|
||||
if "EXCHANGE_DISPLAY_NAME" not in text.split("OKX_POS_MODE")[0]:
|
||||
text = text.replace(
|
||||
'OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")\n',
|
||||
'OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")\n'
|
||||
'EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"\n',
|
||||
)
|
||||
|
||||
if "TRADING_DAY_RESET_OPEN_GUARD_ENABLED" not in text:
|
||||
text = text.replace(
|
||||
"TRADING_DAY_RESET_HOUR = int(os.getenv(\"TRADING_DAY_RESET_HOUR\", \"8\"))\nAPP_TIMEZONE",
|
||||
'TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))\n'
|
||||
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv(\n"
|
||||
' "TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true"\n'
|
||||
').lower() in ("1", "true", "yes", "on")\n'
|
||||
"APP_TIMEZONE",
|
||||
)
|
||||
|
||||
extra_env = """
|
||||
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"))
|
||||
"""
|
||||
if "MANUAL_MIN_PLANNED_RR = float" not in text:
|
||||
text = text.replace(
|
||||
"KEY_DAILY_VOLUME_RANK_MAX = int(os.getenv(\"KEY_DAILY_VOLUME_RANK_MAX\", \"30\"))\n",
|
||||
"KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv(\"KEY_DAILY_VOLUME_RANK_MAX\", \"30\")))\n"
|
||||
+ extra_env,
|
||||
)
|
||||
|
||||
if "def format_funds_u" not in text:
|
||||
text = text.replace(
|
||||
"def format_hold_minutes(minutes):",
|
||||
'''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 "def trading_day_reset_allows_new_open" not in text:
|
||||
text = text.replace(
|
||||
"def precheck_risk(conn, symbol, direction):",
|
||||
'''def trading_day_reset_allows_new_open(now):
|
||||
if not TRADING_DAY_RESET_OPEN_GUARD_ENABLED:
|
||||
return True
|
||||
return now.hour >= TRADING_DAY_RESET_HOUR
|
||||
|
||||
|
||||
def precheck_risk(conn, symbol, direction):''',
|
||||
)
|
||||
|
||||
text = re.sub(
|
||||
r"def precheck_risk\(conn, symbol, direction\):.*?return True, \"\"",
|
||||
'''def precheck_risk(conn, symbol, direction):
|
||||
now = app_now()
|
||||
if not trading_day_reset_allows_new_open(now):
|
||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||
active_count = get_active_position_count(conn)
|
||||
if active_count >= MAX_ACTIVE_POSITIONS:
|
||||
return False, f"已达最大持仓数({active_count}/{MAX_ACTIVE_POSITIONS})"
|
||||
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, ""''',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# _key_hard_checks from gate
|
||||
gate_text = (GATE / "app.py").read_text(encoding="utf-8")
|
||||
m = re.search(r"def _key_hard_checks\(symbol.*?return out\n", gate_text, re.DOTALL)
|
||||
if m:
|
||||
kh = m.group(0).replace("normalize_exchange_symbol", "normalize_okx_symbol")
|
||||
text = re.sub(r"def _key_hard_checks\(symbol.*?return out\n", kh, text, count=1, flags=re.DOTALL)
|
||||
|
||||
if "def exchange_private_api_configured" not in text:
|
||||
insert = '''
|
||||
def exchange_private_api_configured():
|
||||
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
|
||||
|
||||
|
||||
def _position_row_effective_contracts(p):
|
||||
info = p.get("info", {}) or {}
|
||||
contracts = p.get("contracts")
|
||||
if contracts is None:
|
||||
raw_pos = info.get("pos")
|
||||
try:
|
||||
contracts = abs(float(raw_pos)) if raw_pos is not None else 0.0
|
||||
except Exception:
|
||||
contracts = 0.0
|
||||
try:
|
||||
return float(contracts)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _position_matches_wanted_contract(exchange_symbol, position):
|
||||
if not position:
|
||||
return False
|
||||
sym = position.get("symbol")
|
||||
return sym == exchange_symbol
|
||||
|
||||
|
||||
def _select_live_position_row(rows, exchange_symbol, direction, relax_hedge=False):
|
||||
if not rows:
|
||||
return None
|
||||
candidates = []
|
||||
for p in rows:
|
||||
if not _position_matches_wanted_contract(exchange_symbol, p):
|
||||
continue
|
||||
info = p.get("info", {}) or {}
|
||||
side = (p.get("side") or info.get("posSide") or "").lower()
|
||||
contracts = _position_row_effective_contracts(p)
|
||||
if contracts <= 0:
|
||||
continue
|
||||
if (not relax_hedge) and OKX_POS_MODE == "hedge":
|
||||
if side and side != (direction or "").lower():
|
||||
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(
|
||||
p.get("unrealizedPnl"),
|
||||
info.get("upl"),
|
||||
info.get("unrealized_pnl"),
|
||||
)
|
||||
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)
|
||||
return out or None
|
||||
|
||||
|
||||
def _resolve_tpsl_prices_for_manual(direction, live_price, sltp_mode, data):
|
||||
sltp_mode = (sltp_mode or "price").strip().lower()
|
||||
if sltp_mode == "pct":
|
||||
sl_pct = float(data.get("sl_pct") or 0)
|
||||
tp_pct = float(data.get("tp_pct") or 0)
|
||||
if sl_pct <= 0 or tp_pct <= 0:
|
||||
raise ValueError("百分比止盈止损须为正数")
|
||||
sl_ratio = sl_pct / 100.0
|
||||
tp_ratio = tp_pct / 100.0
|
||||
entry = float(live_price)
|
||||
if direction == "short":
|
||||
stop_loss = entry * (1 + sl_ratio)
|
||||
take_profit = entry * (1 - tp_ratio)
|
||||
else:
|
||||
stop_loss = entry * (1 - sl_ratio)
|
||||
take_profit = entry * (1 + tp_ratio)
|
||||
else:
|
||||
stop_loss = float(data.get("sl") or data.get("stop_loss") or 0)
|
||||
take_profit = float(data.get("tp") or data.get("take_profit") or data.get("tgt") or 0)
|
||||
if stop_loss <= 0 or take_profit <= 0:
|
||||
raise ValueError("止盈止损价格须大于 0")
|
||||
return stop_loss, take_profit
|
||||
|
||||
|
||||
def _okx_tpsl_slot_from_order(order, exchange_symbol):
|
||||
info = order.get("info") or {}
|
||||
oid = order.get("id") or info.get("algoId") or info.get("ordId")
|
||||
trig = _coerce_float(
|
||||
info.get("slTriggerPx"),
|
||||
info.get("tpTriggerPx"),
|
||||
order.get("stopLossPrice"),
|
||||
order.get("takeProfitPrice"),
|
||||
)
|
||||
if trig is None:
|
||||
return None
|
||||
return {
|
||||
"order_id": str(oid) if oid is not None else None,
|
||||
"trigger_price": float(trig),
|
||||
"trigger_display": format_price_for_symbol(
|
||||
exchange_symbol.replace(":USDT", "").replace("/USDT:USDT", ""),
|
||||
trig,
|
||||
),
|
||||
"type": str(order.get("type") or info.get("ordType") or ""),
|
||||
}
|
||||
|
||||
|
||||
def fetch_exchange_tpsl_slots(exchange_symbol, direction, plan_sl=None, plan_tp=None):
|
||||
slots = {"sl": None, "tp": None}
|
||||
if not exchange_symbol:
|
||||
return slots
|
||||
ok, _ = ensure_okx_live_ready()
|
||||
if not ok:
|
||||
return slots
|
||||
try:
|
||||
ensure_markets_loaded()
|
||||
ambiguous = []
|
||||
for order in exchange.fetch_open_orders(exchange_symbol) or []:
|
||||
slot = _okx_tpsl_slot_from_order(order, exchange_symbol)
|
||||
if not slot or not slot.get("order_id"):
|
||||
continue
|
||||
trig = slot.get("trigger_price")
|
||||
if plan_sl is not None and plan_tp is not None:
|
||||
try:
|
||||
role = "sl" if abs(trig - float(plan_sl)) <= abs(trig - float(plan_tp)) else "tp"
|
||||
except Exception:
|
||||
role = None
|
||||
elif plan_sl is not None:
|
||||
role = "sl"
|
||||
elif plan_tp is not None:
|
||||
role = "tp"
|
||||
else:
|
||||
ambiguous.append(slot)
|
||||
continue
|
||||
if role in ("sl", "tp") and slots[role] is None:
|
||||
slots[role] = slot
|
||||
for slot in ambiguous:
|
||||
trig = slot.get("trigger_price")
|
||||
if trig is None:
|
||||
continue
|
||||
try:
|
||||
plan_sl_f = float(plan_sl) if plan_sl is not None else None
|
||||
plan_tp_f = float(plan_tp) if plan_tp is not None else None
|
||||
except Exception:
|
||||
plan_sl_f = plan_tp_f = None
|
||||
if plan_sl_f is not None and plan_tp_f is not None:
|
||||
role = "sl" if abs(trig - plan_sl_f) <= abs(trig - plan_tp_f) else "tp"
|
||||
elif plan_sl_f is not None:
|
||||
role = "sl"
|
||||
elif plan_tp_f is not None:
|
||||
role = "tp"
|
||||
else:
|
||||
continue
|
||||
if slots[role] is None:
|
||||
slots[role] = slot
|
||||
except Exception:
|
||||
pass
|
||||
return slots
|
||||
|
||||
|
||||
def cancel_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()
|
||||
exchange.cancel_order(str(oid), exchange_symbol)
|
||||
|
||||
|
||||
'''
|
||||
text = text.replace(
|
||||
"def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):",
|
||||
insert + "def replace_active_monitor_tpsl_on_exchange(order_row, stop_loss, take_profit):",
|
||||
)
|
||||
|
||||
# render_main_page funding + template vars (gate style)
|
||||
text = text.replace(
|
||||
" funding_capital, trading_capital = get_exchange_capitals()\n"
|
||||
" total_capital = round(funding_capital, 4) if funding_capital is not None else TOTAL_CAPITAL\n"
|
||||
" current_capital = round(trading_capital, 4) if trading_capital is not None else round(local_current_capital, 4)\n",
|
||||
" funding_capital, trading_capital = get_exchange_capitals()\n"
|
||||
" funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None\n"
|
||||
" current_capital = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else round(local_current_capital, FUNDS_DECIMALS)\n",
|
||||
)
|
||||
text = text.replace(
|
||||
" can_trade = now.hour >= TRADING_DAY_RESET_HOUR and active_count == 0\n"
|
||||
" key_gate_rule_text = (\n"
|
||||
' f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|"\n',
|
||||
" can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS\n"
|
||||
" key_gate_rule_text = (\n"
|
||||
' f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR},确认棒偏移 {KEY_CONFIRM_BAR}|"\n'
|
||||
' f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|"\n',
|
||||
)
|
||||
text = text.replace(
|
||||
' f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)"\n',
|
||||
' f"箱体/收敛可选 SL/TP 方案(标准 / 箱体1R·止盈1.5H / 趋势单+自填止盈)|移动保本默认关|"\n'
|
||||
' f"斐波:限价 @ E(SL/TP 为 H/L),可选移动保本|趋势止损外侧 {KEY_TREND_STOP_OUTSIDE_PCT}%"\n',
|
||||
)
|
||||
text = text.replace(" total_capital=total_capital,\n", "")
|
||||
text = text.replace(
|
||||
" key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,\n **strategy_extra,",
|
||||
" funds_fmt=format_funds_u,\n"
|
||||
" exchange_display=EXCHANGE_DISPLAY_NAME,\n"
|
||||
" max_active_positions=MAX_ACTIVE_POSITIONS,\n"
|
||||
" manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,\n"
|
||||
" key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,\n"
|
||||
" kline_timeframe=KLINE_TIMEFRAME,\n"
|
||||
" funding_usdt=funding_usdt,\n"
|
||||
" **strategy_extra,",
|
||||
)
|
||||
|
||||
if '@app.route("/key_monitor")' not in text:
|
||||
text = text.replace(
|
||||
'@app.route("/trade")\n@login_required\ndef trade_page():',
|
||||
'@app.route("/key_monitor")\n@login_required\ndef key_monitor_page():\n'
|
||||
' return render_main_page("key_monitor")\n\n\n'
|
||||
'@app.route("/trade")\n@login_required\ndef trade_page():',
|
||||
)
|
||||
|
||||
# account_snapshot
|
||||
text = re.sub(
|
||||
r"@app\.route\(\"/api/account_snapshot\"\).*?return jsonify\(\{[^}]+\}\)",
|
||||
'''@app.route("/api/account_snapshot")
|
||||
@login_required
|
||||
def api_account_snapshot():
|
||||
now = app_now()
|
||||
trading_day = get_trading_day(now)
|
||||
conn = get_db()
|
||||
session_row = ensure_session(conn, trading_day)
|
||||
local_current_capital = float(session_row["current_capital"])
|
||||
funding_capital, trading_capital = get_exchange_capitals(force=True)
|
||||
funding_usdt = round(funding_capital, 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)
|
||||
recommended_capital = get_recommended_capital(current_capital)
|
||||
active_count = get_active_position_count(conn)
|
||||
conn.close()
|
||||
can_trade = trading_day_reset_allows_new_open(now) and active_count < MAX_ACTIVE_POSITIONS
|
||||
available_trading_usdt = get_available_trading_usdt()
|
||||
return jsonify({
|
||||
"funding_usdt": funding_usdt,
|
||||
"current_capital": current_capital,
|
||||
"available_trading_usdt": round(available_trading_usdt, FUNDS_DECIMALS) if available_trading_usdt is not None else None,
|
||||
"recommended_capital": recommended_capital,
|
||||
"active_count": active_count,
|
||||
"max_active_positions": MAX_ACTIVE_POSITIONS,
|
||||
"can_trade": can_trade,
|
||||
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||
"trading_day": trading_day,
|
||||
})''',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# api_price_snapshot from gate (OKX positions)
|
||||
gate_ps = re.search(
|
||||
r'@app\.route\("/api/price_snapshot"\).*?return jsonify\(\{[^}]+\}\)',
|
||||
gate_text,
|
||||
re.DOTALL,
|
||||
)
|
||||
if gate_ps:
|
||||
ps = gate_ps.group(0)
|
||||
ps = ps.replace("exchange_private_api_configured()", "exchange_private_api_configured()")
|
||||
ps = ps.replace(
|
||||
'all_swap_positions = exchange.fetch_positions(None, {"settle": "usdt"}) or []',
|
||||
'all_swap_positions = exchange.fetch_positions(None, {"instType": OKX_POSITION_INST_TYPE}) or []',
|
||||
)
|
||||
ps = ps.replace("fetch_exchange_tpsl_slots(", "fetch_exchange_tpsl_slots(")
|
||||
ps = ps.replace("cancel_gate_tpsl_slot", "cancel_okx_tpsl_slot")
|
||||
ps = ps.replace("ensure_exchange_live_ready", "ensure_okx_live_ready")
|
||||
text = re.sub(
|
||||
r'@app\.route\("/api/price_snapshot"\).*?return jsonify\(\{[^}]+\}\)',
|
||||
ps,
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# cancel/place tpsl routes
|
||||
if 'api_order_cancel_tpsl' not in text:
|
||||
bin_text = (BIN / "app.py").read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r'@app\.route\("/api/order/<int:order_id>/cancel_tpsl".*?exchange_tpsl": slots,\s*\}\s*\)',
|
||||
bin_text,
|
||||
re.DOTALL,
|
||||
)
|
||||
if m:
|
||||
block = m.group(0)
|
||||
block = block.replace("ensure_exchange_live_ready", "ensure_okx_live_ready")
|
||||
block = block.replace("cancel_binance_tpsl_slot", "cancel_okx_tpsl_slot")
|
||||
block = block.replace(
|
||||
'fetch_exchange_tpsl_slots(ex_sym, row["direction"])',
|
||||
'fetch_exchange_tpsl_slots(ex_sym, row["direction"], plan_sl=row["stop_loss"], plan_tp=row["take_profit"])',
|
||||
)
|
||||
block = block.replace(
|
||||
'fetch_exchange_tpsl_slots(ex_sym, direction)',
|
||||
'fetch_exchange_tpsl_slots(ex_sym, direction, plan_sl=stop_loss, plan_tp=take_profit)',
|
||||
)
|
||||
text = text.replace(
|
||||
'@app.route("/add_key", methods=["POST"])',
|
||||
block + '\n\n@app.route("/add_key", methods=["POST"])',
|
||||
)
|
||||
|
||||
# add_order RR + redirects
|
||||
if "planned_rr_manual" not in text:
|
||||
text = text.replace(
|
||||
" if stop_loss <= 0 or take_profit <= 0:\n"
|
||||
" conn.close()\n"
|
||||
" flash(\"价格参数必须大于0\")\n"
|
||||
" return redirect(\"/\")\n"
|
||||
" risk_fraction = calc_risk_fraction",
|
||||
" if stop_loss <= 0 or take_profit <= 0:\n"
|
||||
" conn.close()\n"
|
||||
" flash(\"价格参数必须大于0\")\n"
|
||||
" return redirect(\"/trade\")\n"
|
||||
" planned_rr_manual = calc_rr_ratio(direction, live_price, stop_loss, take_profit)\n"
|
||||
" if planned_rr_manual is None or planned_rr_manual < MANUAL_MIN_PLANNED_RR:\n"
|
||||
" conn.close()\n"
|
||||
" rr_txt = f\"{planned_rr_manual:.4f}\" if planned_rr_manual is not None else \"无法计算\"\n"
|
||||
" flash(f\"风控拒绝下单:计划盈亏比 {rr_txt}:1 低于最低要求 {MANUAL_MIN_PLANNED_RR}:1\")\n"
|
||||
" return redirect(\"/trade\")\n"
|
||||
" risk_fraction = calc_risk_fraction",
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
'if get_active_position_count(conn) > 0:\n'
|
||||
' conn.close()\n'
|
||||
' flash("当前已有持仓:无法添加「箱体突破 / 收敛突破」(请先平仓或使用阻力/支撑/斐波类型)")',
|
||||
'occupied = get_active_position_count(conn)\n'
|
||||
' if occupied >= MAX_ACTIVE_POSITIONS:\n'
|
||||
' conn.close()\n'
|
||||
' flash(\n'
|
||||
' f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」."\n'
|
||||
' "请先平仓或使用阻力/支撑/斐波类型"\n'
|
||||
' )',
|
||||
)
|
||||
|
||||
# add_key → /key_monitor (success paths in add_key only)
|
||||
text = text.replace(
|
||||
'def add_key():\n d = request.form\n symbol = normalize_symbol_input(d.get("symbol"))\n if not symbol:\n flash("symbol 不能为空")\n return redirect("/")',
|
||||
'def add_key():\n d = request.form\n symbol = normalize_symbol_input(d.get("symbol"))\n if not symbol:\n flash("symbol 不能为空")\n return redirect("/key_monitor")',
|
||||
)
|
||||
text = re.sub(
|
||||
r'(def add_key\(\):.*?)(return redirect\("/"\))',
|
||||
lambda m: m.group(1) + 'return redirect("/key_monitor")',
|
||||
text,
|
||||
count=0,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
'if "一次只能持有一个仓位" in reason:',
|
||||
'if "已达最大持仓数" in reason or "一次只能持有一个仓位" in reason:',
|
||||
)
|
||||
|
||||
app_path.write_text(text, encoding="utf-8")
|
||||
print("patched", app_path)
|
||||
|
||||
|
||||
def copy_templates():
|
||||
src = BIN / "templates" / "index.html"
|
||||
dst = OKX / "templates" / "index.html"
|
||||
shutil.copy2(src, dst)
|
||||
print("copied", dst)
|
||||
|
||||
|
||||
def copy_env_example():
|
||||
bin_env = (BIN / ".env.example").read_text(encoding="utf-8")
|
||||
okx_path = OKX / ".env.example"
|
||||
okx = okx_path.read_text(encoding="utf-8")
|
||||
# inject binance-style blocks if missing
|
||||
for marker, block in [
|
||||
(
|
||||
"TRADING_DAY_RESET_OPEN_GUARD",
|
||||
"\nTRADING_DAY_RESET_OPEN_GUARD_ENABLED=true\n",
|
||||
),
|
||||
("MAX_ACTIVE_POSITIONS", "\nMAX_ACTIVE_POSITIONS=1\nMANUAL_MIN_PLANNED_RR=1.4\n"),
|
||||
("KEY_CONFIRM_BREAKOUT_BAR", "\nKEY_CONFIRM_BREAKOUT_BAR=-2\nKEY_CONFIRM_BAR=-1\nKEY_VOLUME_MA_BARS=20\nKEY_VOLUME_RATIO_MIN=1.3\nKEY_BREAKOUT_AMP_MIN_PCT=0.03\nKEY_BREAKOUT_AMP_MAX_PCT=0.5\n"),
|
||||
("EXCHANGE_DISPLAY_NAME", "\nEXCHANGE_DISPLAY_NAME=OKX\nOKX_ACCOUNT_LABEL=\n"),
|
||||
("BACKUP_ROOT", "\nBACKUP_ROOT=/root/backups\nBACKUP_RETENTION_DAYS=30\nBACKUP_INSTANCE=crypto_monitor_okx\n"),
|
||||
]:
|
||||
if marker not in okx:
|
||||
okx += block
|
||||
if "TOTAL_CAPITAL=100" in okx and "# TOTAL_CAPITAL" not in okx:
|
||||
okx = okx.replace("TOTAL_CAPITAL=100", "# TOTAL_CAPITAL=100 # 已弃用,资金展示读交易所")
|
||||
okx_path.write_text(okx, encoding="utf-8")
|
||||
print("updated .env.example")
|
||||
|
||||
|
||||
def copy_scripts_docs():
|
||||
for name in ("backup_data.sh", "install_backup_cron.sh"):
|
||||
s = BIN / "scripts" / name
|
||||
d = OKX / "scripts" / name
|
||||
if s.is_file():
|
||||
d.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = s.read_text(encoding="utf-8").replace("crypto_monitor_binance", "crypto_monitor_okx")
|
||||
content = content.replace("BINANCE", "OKX")
|
||||
d.write_text(content, encoding="utf-8")
|
||||
v = BIN / "scripts" / "verify_binance_funding.py"
|
||||
if v.is_file():
|
||||
t = v.read_text(encoding="utf-8")
|
||||
t = t.replace("binance", "okx").replace("BINANCE", "OKX").replace("verify_binance", "verify_okx")
|
||||
(OKX / "scripts" / "verify_okx_funding.py").write_text(t, encoding="utf-8")
|
||||
doc = BIN / "关键位自动下单说明.md"
|
||||
if doc.is_file() and not (OKX / "关键位自动下单说明.md").exists():
|
||||
shutil.copy2(doc, OKX / "关键位自动下单说明.md")
|
||||
eco = OKX / "ecosystem.config.cjs"
|
||||
if eco.is_file():
|
||||
t = eco.read_text(encoding="utf-8").replace("GATE_SOCKS_PROXY", "OKX_SOCKS_PROXY")
|
||||
eco.write_text(t, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
copy_templates()
|
||||
patch_app()
|
||||
copy_env_example()
|
||||
copy_scripts_docs()
|
||||
print("done")
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env python3
|
||||
"""对 binance/okx 应用与 gate 相同的时间平仓代码替换."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FILES = [
|
||||
ROOT / "crypto_monitor_binance" / "app.py",
|
||||
ROOT / "crypto_monitor_okx" / "app.py",
|
||||
]
|
||||
|
||||
REPLACEMENTS: list[tuple[str, str]] = [
|
||||
(
|
||||
"def _market_open_for_key_monitor(\n conn,\n symbol,\n direction,\n exchange_symbol,\n stop_loss,\n take_profit,\n key_signal_type=None,\n breakeven_enabled=0,\n):",
|
||||
"def _market_open_for_key_monitor(\n conn,\n symbol,\n direction,\n exchange_symbol,\n stop_loss,\n take_profit,\n key_signal_type=None,\n breakeven_enabled=0,\n time_close_enabled=0,\n time_close_hours=None,\n):",
|
||||
),
|
||||
(
|
||||
"def _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,\n):",
|
||||
"def _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=0,\n time_close_enabled=0, time_close_hours=None,\n):",
|
||||
),
|
||||
(
|
||||
"def _add_fib_key_monitor(conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0):",
|
||||
"def _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=0,\n time_close_enabled=0, time_close_hours=None,\n):",
|
||||
),
|
||||
(
|
||||
" key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None\n be_on = breakeven_enabled_from_row(r, 0)\n ok_trade, trade_err, det = _market_open_for_key_monitor(\n conn,\n sym,\n direction,\n exchange_symbol,\n sl_raw,\n tp_raw,\n key_signal_type=key_sig,\n breakeven_enabled=1 if be_on else 0,\n )",
|
||||
" key_sig = typ if typ in KEY_MONITOR_AUTO_TYPES else None\n be_on = breakeven_enabled_from_row(r, 0)\n tc_en, tc_h, _ = time_close_settings_from_row(r)\n ok_trade, trade_err, det = _market_open_for_key_monitor(\n conn,\n sym,\n direction,\n exchange_symbol,\n sl_raw,\n tp_raw,\n key_signal_type=key_sig,\n breakeven_enabled=1 if be_on else 0,\n time_close_enabled=tc_en,\n time_close_hours=tc_h,\n )",
|
||||
),
|
||||
(
|
||||
" res = None\n # 做多\n if direction == \"long\":\n if p >= take_profit: res = \"止盈\"\n elif p <= stop_loss: res = \"止损\"\n # 做空\n elif direction == \"short\":\n if p <= take_profit: res = \"止盈\"\n elif p >= stop_loss: res = \"止损\"",
|
||||
" res = None\n if should_trigger_time_close(r):\n res = TIME_CLOSE_RESULT\n # 做多\n if not res and direction == \"long\":\n if p >= take_profit: res = \"止盈\"\n elif p <= stop_loss: res = \"止损\"\n # 做空\n elif not res and direction == \"short\":\n if p <= take_profit: res = \"止盈\"\n elif p >= stop_loss: res = \"止损\"",
|
||||
),
|
||||
(
|
||||
' "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage FROM order_monitors WHERE status=\'active\'"',
|
||||
' "SELECT id,symbol,exchange_symbol,direction,trigger_price,stop_loss,initial_stop_loss,take_profit,margin_capital,leverage,"\n "time_close_enabled,time_close_hours,time_close_at_ms,opened_at_ms FROM order_monitors WHERE status=\'active\'"',
|
||||
),
|
||||
(
|
||||
" apply_order_price_display_fields(\n payload,\n direction=r[\"direction\"],\n entry_price=entry,\n initial_stop_loss=r[\"initial_stop_loss\"],\n stop_loss=r[\"stop_loss\"],\n take_profit=r[\"take_profit\"],\n calc_rr_ratio_fn=calc_rr_ratio,\n exchange_tpsl=exchange_tpsl,\n format_price_fn=format_price_for_symbol,\n symbol=r[\"symbol\"],\n )\n new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(",
|
||||
" apply_order_price_display_fields(\n payload,\n direction=r[\"direction\"],\n entry_price=entry,\n initial_stop_loss=r[\"initial_stop_loss\"],\n stop_loss=r[\"stop_loss\"],\n take_profit=r[\"take_profit\"],\n calc_rr_ratio_fn=calc_rr_ratio,\n exchange_tpsl=exchange_tpsl,\n format_price_fn=format_price_for_symbol,\n symbol=r[\"symbol\"],\n )\n apply_time_close_to_payload(payload, r)\n new_sl, new_tp, changed = order_monitor_tpsl_needs_sync(",
|
||||
),
|
||||
(
|
||||
" be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n if is_false_breakout_key_monitor_type(mt):",
|
||||
" be_flag = parse_breakeven_enabled_form(d.get(\"breakeven_enabled\"))\n tc_en = parse_time_close_enabled_form(d.get(\"time_close_enabled\"))\n tc_h = parse_time_close_hours_form(d.get(\"time_close_hours\")) if tc_en else None\n if tc_en and not tc_h:\n tc_en = 0\n if is_false_breakout_key_monitor_type(mt):",
|
||||
),
|
||||
(
|
||||
" ok_fb, err_fb = _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,\n )",
|
||||
" ok_fb, err_fb = _add_false_breakout_key_monitor(\n conn, symbol, direction_sel, upper_px, lower_px, key_px, breakeven_enabled=be_flag,\n time_close_enabled=tc_en, time_close_hours=tc_h,\n )",
|
||||
),
|
||||
(
|
||||
" f\"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}\"\n )",
|
||||
" f\"|有效期 {FALSE_BREAKOUT_VALIDITY_HOURS}h|移动保本:{'开' if be_flag else '关'}\"\n + (f\"|{time_close_label(tc_h)}\" if tc_en else \"\")\n )",
|
||||
),
|
||||
(
|
||||
" ok_fib, err_fib = _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,\n )",
|
||||
" ok_fib, err_fib = _add_fib_key_monitor(\n conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,\n time_close_enabled=tc_en, time_close_hours=tc_h,\n )",
|
||||
),
|
||||
(
|
||||
" f\"|移动保本:{'开' if be_flag else '关'}\"\n )\n return redirect(\"/key_monitor\")",
|
||||
" f\"|移动保本:{'开' if be_flag else '关'}\"\n + (f\"|{time_close_label(tc_h)}\" if tc_en else \"\")\n )\n return redirect(\"/key_monitor\")",
|
||||
),
|
||||
(
|
||||
" if mt in KEY_MONITOR_AUTO_TYPES:\n extra = f\"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}\"",
|
||||
" if mt in KEY_MONITOR_AUTO_TYPES:\n extra = f\"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'开' if be_flag else '关'}\"\n if tc_en:\n extra += f\"|{time_close_label(tc_h)}\"",
|
||||
),
|
||||
]
|
||||
|
||||
MARKET_OPEN_OLD = """ breakeven_price = round_price_to_exchange(exchange_symbol, breakeven_raw)
|
||||
be_enabled = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors "
|
||||
"(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
|
||||
"margin_capital, leverage, trade_style, risk_percent, risk_amount, "
|
||||
"breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
|
||||
"notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
symbol,
|
||||
exchange_symbol,
|
||||
direction,
|
||||
trigger_price,
|
||||
stop_loss,
|
||||
stop_loss,
|
||||
take_profit,
|
||||
margin_capital,
|
||||
leverage,
|
||||
trade_style,
|
||||
risk_percent,
|
||||
risk_amount_final,
|
||||
breakeven_rr_trigger,
|
||||
breakeven_offset_pct,
|
||||
breakeven_step_r,
|
||||
0,
|
||||
breakeven_price,
|
||||
be_enabled,
|
||||
notional_value,
|
||||
position_ratio,
|
||||
base_amount,
|
||||
amount,
|
||||
open_order_id,
|
||||
opened_at_bj,
|
||||
opened_at_ms,
|
||||
trading_day,
|
||||
ORDER_MONITOR_TYPE_KEY_AUTO,
|
||||
stored_key_signal_type(key_signal_type),
|
||||
),
|
||||
)"""
|
||||
|
||||
MARKET_OPEN_NEW = """ 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,
|
||||
),
|
||||
)"""
|
||||
|
||||
FIB_INSERT_OLD = """ opened_at_bj = app_now_str()
|
||||
opened_at_ms = _to_ms_with_fallback(None, opened_at_bj)
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors "
|
||||
"(symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, "
|
||||
"margin_capital, leverage, trade_style, risk_percent, risk_amount, "
|
||||
"breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, "
|
||||
"notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, key_signal_type) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
symbol,
|
||||
exchange_symbol,
|
||||
direction,
|
||||
trigger_price,
|
||||
stop_loss,
|
||||
stop_loss,
|
||||
take_profit,
|
||||
margin_capital,
|
||||
leverage,
|
||||
trade_style,
|
||||
risk_percent,
|
||||
risk_amount_final,
|
||||
breakeven_rr_trigger,
|
||||
breakeven_offset_pct,
|
||||
breakeven_step_r,
|
||||
0,
|
||||
breakeven_price,
|
||||
1 if breakeven_enabled_from_row(row, 0) else 0,
|
||||
notional_value,
|
||||
position_ratio,
|
||||
base_amount,
|
||||
amount,
|
||||
exchange_order_id or "",
|
||||
opened_at_bj,
|
||||
opened_at_ms,
|
||||
trading_day,
|
||||
ORDER_MONITOR_TYPE_KEY_AUTO,
|
||||
stored_key_signal_type(typ),
|
||||
),
|
||||
)"""
|
||||
|
||||
FIB_INSERT_NEW = """ 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,
|
||||
),
|
||||
)"""
|
||||
|
||||
KEY_FB_OLD = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||||
conn.execute(
|
||||
"INSERT INTO key_monitors "
|
||||
"(symbol, monitor_type, direction, upper, lower, "
|
||||
"fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, "
|
||||
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
symbol, FALSE_BREAKOUT_MONITOR_TYPE, direction_sel, upper_px, lower_px,
|
||||
oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag,
|
||||
),
|
||||
)"""
|
||||
|
||||
KEY_FB_NEW = """ 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,
|
||||
),
|
||||
)"""
|
||||
|
||||
KEY_FIB_OLD = """ 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,
|
||||
),
|
||||
)"""
|
||||
|
||||
KEY_FIB_NEW = """ be_flag = 1 if int(breakeven_enabled or 0) != 0 else 0
|
||||
tc_en, tc_h, _ = time_close_insert_values(time_close_enabled, time_close_hours, None)
|
||||
conn.execute(
|
||||
"INSERT INTO key_monitors "
|
||||
"(symbol, monitor_type, direction, upper, lower, "
|
||||
"fib_limit_order_id, fib_entry_price, fib_stop_loss, fib_take_profit, "
|
||||
"fib_order_amount, fib_margin_capital, fib_leverage, breakeven_enabled, time_close_enabled, time_close_hours) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
symbol, mt, direction_sel, upper_px, lower_px,
|
||||
oid, entry, sl, tp, float(amount), margin_capital, leverage, be_flag, tc_en, tc_h,
|
||||
),
|
||||
)"""
|
||||
|
||||
ADD_KEY_RS_OLD = """ conn.execute(
|
||||
"INSERT INTO key_monitors "
|
||||
"(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled,"
|
||||
"max_notify,notify_interval_min) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
symbol,
|
||||
mt,
|
||||
direction_sel,
|
||||
upper_px,
|
||||
lower_px,
|
||||
sl_tp_mode,
|
||||
manual_tp,
|
||||
be_flag,
|
||||
KEY_ALERT_MAX_TIMES,
|
||||
KEY_ALERT_INTERVAL_MINUTES,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO key_monitors "
|
||||
"(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag),
|
||||
)"""
|
||||
|
||||
ADD_KEY_RS_NEW = """ 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),
|
||||
)"""
|
||||
|
||||
ADD_ORDER_OLD = """ breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,
|
||||
margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,
|
||||
breakeven_enabled,
|
||||
notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day,
|
||||
ORDER_MONITOR_TYPE_MANUAL,
|
||||
)
|
||||
)"""
|
||||
|
||||
ADD_ORDER_NEW = """ breakeven_enabled = 1 if (d.get("breakeven_enabled") or "").strip() in ("1", "true", "on", "yes") else 0
|
||||
tc_en = parse_time_close_enabled_form(d.get("time_close_enabled"))
|
||||
tc_h = parse_time_close_hours_form(d.get("time_close_hours")) if tc_en else None
|
||||
if tc_en and not tc_h:
|
||||
tc_en = 0
|
||||
tc_en, tc_h, tc_at = time_close_insert_values(tc_en, tc_h, opened_at_ms)
|
||||
conn.execute(
|
||||
"INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,
|
||||
margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,
|
||||
breakeven_enabled,
|
||||
notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day,
|
||||
ORDER_MONITOR_TYPE_MANUAL,
|
||||
tc_en, tc_h, tc_at,
|
||||
)
|
||||
)"""
|
||||
|
||||
BIG_BLOCKS = [
|
||||
(MARKET_OPEN_OLD, MARKET_OPEN_NEW),
|
||||
(FIB_INSERT_OLD, FIB_INSERT_NEW),
|
||||
(KEY_FB_OLD, KEY_FB_NEW),
|
||||
(KEY_FIB_OLD, KEY_FIB_NEW),
|
||||
(ADD_KEY_RS_OLD, ADD_KEY_RS_NEW),
|
||||
(ADD_ORDER_OLD, ADD_ORDER_NEW),
|
||||
]
|
||||
|
||||
|
||||
def patch(path: Path) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for old, new in REPLACEMENTS + BIG_BLOCKS:
|
||||
if old in text:
|
||||
text = text.replace(old, new, 1)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
print("done", path.name)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for f in FILES:
|
||||
patch(f)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
"""补录缺失的趋势回调策略结束快照(strategy_trade_snapshots).
|
||||
|
||||
适用:gate 等在计划结束(止盈/止损/手动)时因 strategy_trend_cfg 未注册而漏写快照的历史数据.
|
||||
保本移交路径通常已有快照,本脚本默认跳过「已有任意快照」的计划.
|
||||
|
||||
用法(在仓库根目录,Linux 请用 python3):
|
||||
python3 scripts/backfill_trend_strategy_snapshots.py \\
|
||||
--db crypto_monitor_gate/crypto.db --dry-run
|
||||
python3 scripts/backfill_trend_strategy_snapshots.py \\
|
||||
--db crypto_monitor_gate/crypto.db --apply
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from lib.strategy.strategy_snapshot_lib import ( # noqa: E402
|
||||
STRATEGY_TREND,
|
||||
init_strategy_snapshot_table,
|
||||
save_trend_plan_snapshot,
|
||||
)
|
||||
|
||||
PLAN_STATUS_LABEL = {
|
||||
"stopped_sl": "止损",
|
||||
"stopped_tp": "止盈",
|
||||
"stopped_manual": "手动平仓",
|
||||
"stopped_handoff": "保本移交",
|
||||
}
|
||||
|
||||
TRADE_RESULT_LABEL = {
|
||||
"止损": "止损",
|
||||
"止盈": "止盈",
|
||||
"手动平仓": "手动平仓",
|
||||
"移动止盈": "止盈",
|
||||
"保本止盈": "止盈",
|
||||
"强制清仓": "手动平仓",
|
||||
}
|
||||
|
||||
|
||||
def _row_dict(row) -> dict:
|
||||
if row is None:
|
||||
return {}
|
||||
try:
|
||||
return dict(row)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def infer_exit_price(
|
||||
direction: str,
|
||||
entry: float | None,
|
||||
margin: float | None,
|
||||
leverage: float | None,
|
||||
pnl: float | None,
|
||||
) -> float | None:
|
||||
"""由本地 calc_pnl 口径反推平仓价(供补录快照 exit_price)."""
|
||||
try:
|
||||
trigger = float(entry)
|
||||
margin_f = float(margin)
|
||||
lev = float(leverage)
|
||||
pnl_f = float(pnl)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if trigger <= 0 or margin_f <= 0 or lev <= 0:
|
||||
return None
|
||||
notional = margin_f * lev
|
||||
if notional <= 0:
|
||||
return None
|
||||
ratio = pnl_f / notional
|
||||
if (direction or "long").strip().lower() == "short":
|
||||
return round(trigger * (1.0 - ratio), 10)
|
||||
return round(trigger * (1.0 + ratio), 10)
|
||||
|
||||
|
||||
def resolve_result_label(plan: dict, trade: dict | None) -> str:
|
||||
status = (plan.get("status") or "").strip()
|
||||
if status in PLAN_STATUS_LABEL:
|
||||
return PLAN_STATUS_LABEL[status]
|
||||
if trade:
|
||||
res = (trade.get("result") or "").strip()
|
||||
if res in TRADE_RESULT_LABEL:
|
||||
return TRADE_RESULT_LABEL[res]
|
||||
if res:
|
||||
return res
|
||||
msg = (plan.get("message") or "").strip()
|
||||
if msg:
|
||||
return msg[:32]
|
||||
return "结束"
|
||||
|
||||
|
||||
def find_missing_plans(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
plan_id: int | None = None,
|
||||
since: str | None = None,
|
||||
) -> list[dict]:
|
||||
sql = """
|
||||
SELECT p.*
|
||||
FROM trend_pullback_plans p
|
||||
WHERE TRIM(COALESCE(p.status, '')) != 'active'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM strategy_trade_snapshots s
|
||||
WHERE s.strategy_type = ? AND s.source_id = p.id
|
||||
)
|
||||
"""
|
||||
params: list[object] = [STRATEGY_TREND]
|
||||
if plan_id is not None:
|
||||
sql += " AND p.id = ?"
|
||||
params.append(int(plan_id))
|
||||
if since:
|
||||
sql += " AND COALESCE(p.opened_at, '') >= ?"
|
||||
params.append(since.strip())
|
||||
sql += " ORDER BY p.id ASC"
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [_row_dict(r) for r in rows]
|
||||
|
||||
|
||||
def fetch_trade_for_plan(conn: sqlite3.Connection, plan_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM trade_records
|
||||
WHERE trend_plan_id = ?
|
||||
ORDER BY COALESCE(closed_at_ms, 0) DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(int(plan_id),),
|
||||
).fetchone()
|
||||
return _row_dict(row) if row else None
|
||||
|
||||
|
||||
def backfill_one(conn: sqlite3.Connection, plan: dict, *, dry_run: bool) -> dict:
|
||||
plan_id = int(plan["id"])
|
||||
trade = fetch_trade_for_plan(conn, plan_id)
|
||||
result_label = resolve_result_label(plan, trade)
|
||||
pnl_amount = None
|
||||
closed_at = None
|
||||
exit_price = None
|
||||
entry = plan.get("avg_entry_price") or plan.get("live_price_ref")
|
||||
margin = plan.get("plan_margin_capital")
|
||||
leverage = plan.get("leverage")
|
||||
|
||||
if trade:
|
||||
pnl_amount = trade.get("pnl_amount")
|
||||
closed_at = trade.get("closed_at")
|
||||
entry = trade.get("trigger_price") or entry
|
||||
margin = trade.get("margin_capital") or margin
|
||||
leverage = trade.get("leverage") or leverage
|
||||
exit_price = infer_exit_price(
|
||||
plan.get("direction") or trade.get("direction") or "long",
|
||||
entry,
|
||||
margin,
|
||||
leverage,
|
||||
pnl_amount,
|
||||
)
|
||||
|
||||
info = {
|
||||
"plan_id": plan_id,
|
||||
"symbol": plan.get("symbol"),
|
||||
"status": plan.get("status"),
|
||||
"result_label": result_label,
|
||||
"closed_at": closed_at,
|
||||
"pnl_amount": pnl_amount,
|
||||
"exit_price": exit_price,
|
||||
"legs_done": plan.get("legs_done"),
|
||||
"dca_legs": plan.get("dca_legs"),
|
||||
"has_trade": bool(trade),
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
return info
|
||||
|
||||
save_trend_plan_snapshot(
|
||||
{},
|
||||
conn,
|
||||
plan,
|
||||
result_label=result_label,
|
||||
exit_price=exit_price,
|
||||
pnl_amount=float(pnl_amount) if pnl_amount is not None else None,
|
||||
closed_at=closed_at,
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Backfill missing trend_pullback strategy_trade_snapshots rows."
|
||||
)
|
||||
parser.add_argument("--db", required=True, help="Path to instance sqlite db")
|
||||
parser.add_argument("--plan-id", type=int, help="Only backfill this trend plan id")
|
||||
parser.add_argument(
|
||||
"--since",
|
||||
help="Only plans with opened_at >= YYYY-MM-DD (optional)",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview only (default)")
|
||||
parser.add_argument("--apply", action="store_true", help="Write snapshots")
|
||||
args = parser.parse_args()
|
||||
if not args.dry_run and not args.apply:
|
||||
args.dry_run = True
|
||||
|
||||
db_path = Path(args.db).expanduser().resolve()
|
||||
if not db_path.is_file():
|
||||
print(f"[ERR] DB not found: {db_path}")
|
||||
return 1
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_strategy_snapshot_table(conn)
|
||||
|
||||
missing = find_missing_plans(
|
||||
conn, plan_id=args.plan_id, since=args.since
|
||||
)
|
||||
if not missing:
|
||||
print("[INFO] No closed trend plans missing strategy snapshots.")
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
print(f"[INFO] Found {len(missing)} plan(s) without strategy snapshot.")
|
||||
applied = 0
|
||||
for plan in missing:
|
||||
info = backfill_one(conn, plan, dry_run=not args.apply)
|
||||
trade_hint = "有交易记录" if info["has_trade"] else "无交易记录"
|
||||
print(
|
||||
f" - plan #{info['plan_id']} {info['symbol']} "
|
||||
f"status={info['status']} → {info['result_label']} "
|
||||
f"closed={info['closed_at'] or '—'} pnl={info['pnl_amount']} "
|
||||
f"补仓 {info['legs_done']}/{info['dca_legs']} ({trade_hint})"
|
||||
)
|
||||
applied += 1
|
||||
|
||||
if args.apply:
|
||||
conn.commit()
|
||||
print(f"[OK] Backfilled {applied} snapshot(s).")
|
||||
else:
|
||||
print("[DRY-RUN] No changes written. Re-run with --apply to commit.")
|
||||
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""补录缺失的趋势回调 trade_records(策略快照已有,交易记录漏写).
|
||||
|
||||
典型原因:gate insert_trade_record 曾不接受 entry_reason,_finalize_plan 写快照后插入失败.
|
||||
|
||||
用法:
|
||||
python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate/crypto.db --dry-run
|
||||
python scripts/backfill_trend_trade_records.py --db crypto_monitor_gate/crypto.db --apply
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from lib.strategy.strategy_snapshot_lib import STRATEGY_TREND # noqa: E402
|
||||
from lib.strategy.strategy_trade_labels import ENTRY_REASON_TREND_PULLBACK, MONITOR_TYPE_TREND_PULLBACK # noqa: E402
|
||||
|
||||
STATUS_TO_RESULT = {
|
||||
"stopped_sl": "止损",
|
||||
"stopped_tp": "止盈",
|
||||
"stopped_manual": "手动平仓",
|
||||
}
|
||||
|
||||
|
||||
def _row_dict(row) -> dict:
|
||||
if row is None:
|
||||
return {}
|
||||
try:
|
||||
return dict(row)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _hold_minutes(hold_seconds: int) -> int:
|
||||
try:
|
||||
return max(0, int(round(float(hold_seconds) / 60.0)))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def backfill_one(conn: sqlite3.Connection, snap: dict, *, apply: bool) -> dict:
|
||||
plan_id = int(snap.get("source_id") or 0)
|
||||
if plan_id <= 0:
|
||||
return {"plan_id": plan_id, "skipped": True, "reason": "invalid source_id"}
|
||||
exists = conn.execute(
|
||||
"SELECT id FROM trade_records WHERE trend_plan_id=? LIMIT 1", (plan_id,)
|
||||
).fetchone()
|
||||
if exists:
|
||||
return {"plan_id": plan_id, "skipped": True, "reason": "trade_exists"}
|
||||
|
||||
try:
|
||||
payload = json.loads(snap.get("snapshot_json") or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
plan = conn.execute(
|
||||
"SELECT * FROM trend_pullback_plans WHERE id=?", (plan_id,)
|
||||
).fetchone()
|
||||
plan_d = _row_dict(plan)
|
||||
|
||||
symbol = snap.get("symbol") or plan_d.get("symbol") or payload.get("symbol")
|
||||
direction = snap.get("direction") or plan_d.get("direction") or payload.get("direction") or "long"
|
||||
result = (snap.get("result_label") or "").strip() or STATUS_TO_RESULT.get(
|
||||
plan_d.get("status") or "", "手动平仓"
|
||||
)
|
||||
opened_at = snap.get("opened_at") or plan_d.get("opened_at")
|
||||
closed_at = snap.get("closed_at")
|
||||
pnl_amount = snap.get("pnl_amount")
|
||||
if pnl_amount is None:
|
||||
pnl_amount = payload.get("pnl_amount")
|
||||
|
||||
trigger_price = payload.get("avg_entry_price") or plan_d.get("avg_entry_price")
|
||||
stop_loss = payload.get("stop_loss") or plan_d.get("stop_loss")
|
||||
take_profit = payload.get("take_profit") or plan_d.get("take_profit")
|
||||
margin_capital = payload.get("plan_margin_capital") or plan_d.get("plan_margin_capital")
|
||||
leverage = payload.get("leverage") or plan_d.get("leverage")
|
||||
|
||||
opened_ms = plan_d.get("opened_at_ms")
|
||||
closed_ms = None
|
||||
|
||||
hold_seconds = 0
|
||||
if opened_at and closed_at:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
fmt = "%Y-%m-%d %H:%M:%S"
|
||||
o = datetime.strptime(str(opened_at).strip()[:19], fmt)
|
||||
c = datetime.strptime(str(closed_at).strip()[:19], fmt)
|
||||
hold_seconds = max(0, int((c - o).total_seconds()))
|
||||
except Exception:
|
||||
hold_seconds = 0
|
||||
|
||||
row = {
|
||||
"symbol": symbol,
|
||||
"monitor_type": MONITOR_TYPE_TREND_PULLBACK,
|
||||
"direction": direction,
|
||||
"trigger_price": trigger_price,
|
||||
"stop_loss": stop_loss,
|
||||
"initial_stop_loss": plan_d.get("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": "trend_pullback",
|
||||
"result": result,
|
||||
"opened_at": opened_at,
|
||||
"opened_at_ms": opened_ms,
|
||||
"closed_at": closed_at,
|
||||
"closed_at_ms": closed_ms,
|
||||
"entry_reason": ENTRY_REASON_TREND_PULLBACK,
|
||||
"trend_plan_id": plan_id,
|
||||
}
|
||||
|
||||
if not apply:
|
||||
return {"plan_id": plan_id, "dry_run": True, "row": row}
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO trade_records (
|
||||
symbol, monitor_type, direction, trigger_price, stop_loss, initial_stop_loss,
|
||||
take_profit, margin_capital, leverage, pnl_amount, hold_seconds, trade_style,
|
||||
hold_minutes, opened_at, opened_at_ms, closed_at, closed_at_ms, result,
|
||||
entry_reason, trend_plan_id
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
row["symbol"],
|
||||
row["monitor_type"],
|
||||
row["direction"],
|
||||
row["trigger_price"],
|
||||
row["stop_loss"],
|
||||
row["initial_stop_loss"],
|
||||
row["take_profit"],
|
||||
row["margin_capital"],
|
||||
row["leverage"],
|
||||
row["pnl_amount"],
|
||||
row["hold_seconds"],
|
||||
row["trade_style"],
|
||||
_hold_minutes(hold_seconds),
|
||||
row["opened_at"],
|
||||
row["opened_at_ms"],
|
||||
row["closed_at"],
|
||||
row["closed_at_ms"],
|
||||
row["result"],
|
||||
row["entry_reason"],
|
||||
row["trend_plan_id"],
|
||||
),
|
||||
)
|
||||
return {"plan_id": plan_id, "inserted": True}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--db", required=True, help="实例 sqlite 路径")
|
||||
ap.add_argument("--apply", action="store_true", help="写入数据库(默认 dry-run)")
|
||||
args = ap.parse_args()
|
||||
db_path = Path(args.db)
|
||||
if not db_path.is_file():
|
||||
print(f"数据库不存在: {db_path}")
|
||||
return 1
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
snaps = conn.execute(
|
||||
"""SELECT * FROM strategy_trade_snapshots
|
||||
WHERE strategy_type=? ORDER BY id DESC""",
|
||||
(STRATEGY_TREND,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for s in snaps:
|
||||
r = backfill_one(conn, _row_dict(s), apply=args.apply)
|
||||
out.append(r)
|
||||
print(r)
|
||||
if args.apply:
|
||||
conn.commit()
|
||||
conn.close()
|
||||
inserted = sum(1 for x in out if x.get("inserted"))
|
||||
print(f"done: inserted={inserted} total_snapshots={len(snaps)} apply={args.apply}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""首次部署:自动生成中控通信密钥,登录会话密钥,并写入初始登录账号.
|
||||
|
||||
- HUB_BRIDGE_TOKEN:中控 + 三实例(相同,仅空/占位时写入,不覆盖已有)
|
||||
- FLASK_SECRET_KEY:三实例(相同)
|
||||
- HUB_SESSION_SECRET:仅中控
|
||||
- APP_USERNAME=admin,APP_PASSWORD=admin123:实例(仅空时)
|
||||
- HUB_USERNAME=admin,HUB_PASSWORD=admin123:中控(仅空时)
|
||||
|
||||
已有非空且非占位符的值不会被覆盖(长期密钥一次生成,不轮换).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
|
||||
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
|
||||
|
||||
INSTANCE_DIRS = (
|
||||
("okx", os.path.join(_REPO, "crypto_monitor_okx")),
|
||||
("binance", os.path.join(_REPO, "crypto_monitor_binance")),
|
||||
("gate", os.path.join(_REPO, "crypto_monitor_gate")),
|
||||
)
|
||||
HUB_DIR = os.path.join(_REPO, "manual_trading_hub")
|
||||
|
||||
FLASK_PLACEHOLDERS = frozenset(
|
||||
{"", "CHANGE_TO_LONG_RANDOM_SECRET", "crypto_monitor_2026_secret_key"}
|
||||
)
|
||||
HUB_PLACEHOLDERS = frozenset({"", "your-long-random-token"})
|
||||
SESSION_PLACEHOLDERS = frozenset({"", "another-long-random-string", "hub-dev-insecure"})
|
||||
|
||||
|
||||
def _env_path(base: str) -> str:
|
||||
return os.path.join(base, ".env")
|
||||
|
||||
|
||||
def _should_set(current: str | None, placeholders: frozenset[str]) -> bool:
|
||||
val = (current or "").strip()
|
||||
return val in placeholders
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Bootstrap deploy secrets")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印将写入的项,不改文件")
|
||||
args = parser.parse_args()
|
||||
|
||||
hub_token = secrets.token_urlsafe(32)
|
||||
flask_secret = secrets.token_urlsafe(48)
|
||||
session_secret = secrets.token_urlsafe(48)
|
||||
planned: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
hub_env = _env_path(HUB_DIR)
|
||||
if os.path.isfile(hub_env):
|
||||
hub_lines = read_env_lines(hub_env)
|
||||
hub_updates: dict[str, str] = {}
|
||||
if _should_set(env_get(hub_lines, "HUB_BRIDGE_TOKEN"), HUB_PLACEHOLDERS):
|
||||
hub_updates["HUB_BRIDGE_TOKEN"] = hub_token
|
||||
if _should_set(env_get(hub_lines, "HUB_SESSION_SECRET"), SESSION_PLACEHOLDERS):
|
||||
hub_updates["HUB_SESSION_SECRET"] = session_secret
|
||||
if not (env_get(hub_lines, "HUB_USERNAME") or "").strip():
|
||||
hub_updates["HUB_USERNAME"] = "admin"
|
||||
if _should_set(env_get(hub_lines, "HUB_PASSWORD"), frozenset({""})):
|
||||
hub_updates["HUB_PASSWORD"] = "admin123"
|
||||
if hub_updates:
|
||||
planned.append((hub_env, hub_updates))
|
||||
|
||||
for _name, inst_dir in INSTANCE_DIRS:
|
||||
path = _env_path(inst_dir)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
lines = read_env_lines(path)
|
||||
updates: dict[str, str] = {}
|
||||
if _should_set(env_get(lines, "HUB_BRIDGE_TOKEN"), HUB_PLACEHOLDERS):
|
||||
updates["HUB_BRIDGE_TOKEN"] = hub_token
|
||||
if _should_set(env_get(lines, "FLASK_SECRET_KEY"), FLASK_PLACEHOLDERS):
|
||||
updates["FLASK_SECRET_KEY"] = flask_secret
|
||||
if not (env_get(lines, "APP_USERNAME") or "").strip():
|
||||
updates["APP_USERNAME"] = "admin"
|
||||
if _should_set(env_get(lines, "APP_PASSWORD"), frozenset({""})):
|
||||
updates["APP_PASSWORD"] = "admin123"
|
||||
if updates:
|
||||
planned.append((path, updates))
|
||||
|
||||
if not planned:
|
||||
print("无需写入:密钥与登录项均已配置.")
|
||||
return 0
|
||||
|
||||
for path, updates in planned:
|
||||
rel = os.path.relpath(path, _REPO)
|
||||
keys = ", ".join(sorted(updates.keys()))
|
||||
if args.dry_run:
|
||||
print(f"[dry-run] {rel}: {keys}")
|
||||
continue
|
||||
apply_env_updates(path, updates)
|
||||
print(f"已写入 {rel}: {keys}")
|
||||
|
||||
if not args.dry_run:
|
||||
print("完成.初始登录:admin / admin123(若本次写入了密码项).")
|
||||
print("请 pm2 restart 中控与三实例使密钥生效.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Build embed_page_fragment.html from lib/instance/templates/index.html."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "lib" / "instance" / "templates" / "index.html"
|
||||
OUT = ROOT / "lib" / "instance" / "templates" / "embed_page_fragment.html"
|
||||
|
||||
GRID_START = " <div class=\"grid\">"
|
||||
|
||||
|
||||
def _find_line(lines: list[str], predicate, *, start: int = 0) -> int:
|
||||
for idx in range(start, len(lines)):
|
||||
if predicate(lines[idx]):
|
||||
return idx
|
||||
raise SystemExit("marker not found")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lines = SRC.read_text(encoding="utf-8").splitlines()
|
||||
macro_start = _find_line(lines, lambda l: "macro period_stats_pane" in l)
|
||||
macro_end = _find_line(lines, lambda l: l.strip() == "{% endmacro %}", start=macro_start)
|
||||
macro_body = lines[macro_start : macro_end + 1]
|
||||
|
||||
grid_start = _find_line(lines, lambda l: l == GRID_START)
|
||||
panel_start = _find_line(
|
||||
lines, lambda l: l.strip() == "{% if page == 'env_config' %}"
|
||||
)
|
||||
stats_card_line = _find_line(lines, lambda l: 'id="stats-card"' in l)
|
||||
stats_start = stats_card_line
|
||||
while stats_start > 0 and lines[stats_start].strip() != "{% if page == 'stats' %}":
|
||||
stats_start -= 1
|
||||
if lines[stats_start].strip() != "{% if page == 'stats' %}":
|
||||
raise SystemExit("stats if-block not found")
|
||||
stats_end = _find_line(lines, lambda l: l.strip() == "{% endif %}", start=stats_start + 1)
|
||||
|
||||
grid_block = lines[grid_start + 1 : panel_start]
|
||||
while grid_block and not grid_block[-1].strip():
|
||||
grid_block.pop()
|
||||
if grid_block and grid_block[-1].strip() == "</div>":
|
||||
grid_block.pop()
|
||||
|
||||
panel_block = lines[panel_start:stats_start]
|
||||
stats_block = lines[stats_start : stats_end + 1]
|
||||
|
||||
out_lines = [
|
||||
"{# Hub iframe tab fragment — shared via embed_templates #}",
|
||||
*macro_body,
|
||||
'<div class="grid">',
|
||||
*grid_block,
|
||||
"</div>",
|
||||
*panel_block,
|
||||
*stats_block,
|
||||
]
|
||||
text = "\n".join(out_lines).rstrip() + "\n"
|
||||
if "order_rule_tips_tpl" not in text:
|
||||
text = text.replace(
|
||||
"{% include 'order_monitor_rule_tips_binance.html' %}",
|
||||
"{% include order_rule_tips_tpl %}",
|
||||
)
|
||||
OUT.write_text(text, encoding="utf-8")
|
||||
print("wrote", OUT, "lines", len(out_lines))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""从 binance index.html 生成三所共用的 lib/instance/templates/index.html."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "lib" / "instance" / "templates" / "index.html"
|
||||
OUT = ROOT / "lib" / "instance" / "templates" / "index.html"
|
||||
|
||||
TRANSFER_BLOCK = """ <details class="tip-collapse transfer-rule-collapse">
|
||||
<summary class="tip-collapse-summary">划转规则说明</summary>
|
||||
<div class="tip-collapse-body rule-tip">
|
||||
划转:自动划转 {{ '开启' if auto_transfer_enabled else '关闭' }}(每天<strong>北京时间 {{ auto_transfer_bj_hour }}:00</strong>起该整点小时内尝试;账簿按 <strong>UTC 自然日</strong>去重;将 {{ auto_transfer_to }} 调整至 {{ auto_transfer_amount }}U:不足从 {{ auto_transfer_from }} 划入,超出划回 {{ auto_transfer_from }};<strong>持仓中不划转</strong>并微信通知)
|
||||
</div>
|
||||
</details>
|
||||
<form action="/manual_transfer" method="post" class="form-row">
|
||||
<input name="amount" type="number" min="0.01" step="0.01" placeholder="手动划转金额U" required>
|
||||
<select name="from_account">
|
||||
<option value="funding" {% if auto_transfer_from == 'funding' %}selected{% endif %}>from: funding</option>
|
||||
<option value="swap" {% if auto_transfer_from == 'swap' %}selected{% endif %}>from: swap</option>
|
||||
<option value="spot" {% if auto_transfer_from == 'spot' %}selected{% endif %}>from: spot</option>
|
||||
</select>
|
||||
<select name="to_account">
|
||||
<option value="swap" {% if auto_transfer_to == 'swap' %}selected{% endif %}>to: swap</option>
|
||||
<option value="funding" {% if auto_transfer_to == 'funding' %}selected{% endif %}>to: funding</option>
|
||||
<option value="spot" {% if auto_transfer_to == 'spot' %}selected{% endif %}>to: spot</option>
|
||||
</select>
|
||||
<button type="submit">手动划转</button>
|
||||
</form>
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
|
||||
# 外链 CSS 替代内联 style
|
||||
text = re.sub(
|
||||
r" <style>.*?</style>\n",
|
||||
' <link rel="stylesheet" href="/static/instance_page.css?v=1">\n',
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# 顶栏:划转 + 可选 open guard
|
||||
text = text.replace(
|
||||
' <div class="rule-tip">实时价格更新:<span id="price-last-updated">--</span>(北京时间 UTC+8)</div>\n',
|
||||
" {% include 'instance_top_bar.html' %}\n",
|
||||
)
|
||||
|
||||
# 规则条动态 include
|
||||
text = text.replace(
|
||||
"{% include 'order_monitor_rule_tips_binance.html' %}",
|
||||
"{% include order_rule_tips_tpl %}",
|
||||
)
|
||||
|
||||
# 下单面板内划转块移除(已上移到顶栏)
|
||||
if TRANSFER_BLOCK in text:
|
||||
text = text.replace(TRANSFER_BLOCK, "", 1)
|
||||
|
||||
# 孤儿仓恢复 banner
|
||||
orphan_block = """ {% if not order and orphan_live_positions %}
|
||||
{% set o = orphan_live_positions[0] %}
|
||||
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:block;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8">
|
||||
<strong>检测到交易所仍有持仓,本地无对应监控单</strong>
|
||||
<span style="margin-left:8px">{{ o.exchange_symbol or o.symbol }} · {{ '多' if o.direction == 'long' else '空' }}</span>
|
||||
<form action="/recover_orphan_order" method="post" style="display:inline;margin-left:12px">
|
||||
<input type="hidden" name="symbol" value="{{ o.symbol }}">
|
||||
<input type="hidden" name="direction" value="{{ o.direction }}">
|
||||
<button type="submit" style="padding:4px 10px;font-size:.82rem">恢复监控</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="orphan-position-recover" class="orphan-recover-banner" style="display:none;margin-bottom:10px;padding:10px 12px;background:#2a2210;border:1px solid #6b5420;border-radius:6px;font-size:.9rem;color:#e8d5a8"></div>
|
||||
{% endif %}"""
|
||||
wrapped = "{% if ui_orphan_recovery_enabled %}\n" + orphan_block + "\n {% endif %}"
|
||||
text = text.replace(orphan_block, wrapped, 1)
|
||||
|
||||
# refreshAccountSnapshot:采用 OKX 版 open_guard 逻辑
|
||||
old_can_trade = """ let canTradeText = "可开仓";
|
||||
if (!data.can_trade) {
|
||||
const parts = [];
|
||||
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||
parts.push(data.risk_status.reason);
|
||||
}
|
||||
const ac = Number(data.active_count || 0);
|
||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
||||
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
||||
const opens = Number(data.opens_today);
|
||||
if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
|
||||
if (!parts.length) parts.push(`未到北京时间 {{ reset_hour }}:00`);
|
||||
else parts.push(`或未到北京时间 {{ reset_hour }}:00`);
|
||||
canTradeText = `不可开仓(${parts.join(";")})`;
|
||||
}"""
|
||||
new_can_trade = """ let canTradeText = "可开仓";
|
||||
if(!data.can_trade){
|
||||
const parts = [];
|
||||
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||
parts.push(data.risk_status.reason);
|
||||
}
|
||||
if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
|
||||
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
|
||||
const opens = Number(data.opens_today);
|
||||
if (hard > 0 && !Number.isNaN(opens) && opens >= hard) parts.push(`本交易日开仓 ${opens}/${hard} 已达上限`);
|
||||
if(data.open_guard_blocks_now) parts.push(`未到北京时间 ${data.reset_hour||{{ reset_hour }}}:00`);
|
||||
canTradeText = parts.length ? `不可开仓(${parts.join(";")})` : "不可开仓";
|
||||
}"""
|
||||
text = text.replace(old_can_trade, new_can_trade, 1)
|
||||
|
||||
guard_sync = """ const allowEl = document.getElementById("allow-open-before-reset");
|
||||
const guardStatus = document.getElementById("open-guard-status");
|
||||
const resetH = data.reset_hour != null ? data.reset_hour : {{ reset_hour }};
|
||||
if(allowEl && typeof data.open_guard_enabled !== "undefined"){
|
||||
allowEl.checked = !data.open_guard_enabled;
|
||||
}
|
||||
if(guardStatus && typeof data.open_guard_enabled !== "undefined"){
|
||||
guardStatus.innerText = data.open_guard_enabled
|
||||
? `已限制:${resetH}:00 前不可开仓`
|
||||
: `已放开:${resetH}:00 前允许开仓`;
|
||||
}"""
|
||||
insert_after = """ if(tip){
|
||||
tip.innerText = `规则:最多 ${data.max_active_positions || {{ max_active_positions }}} 仓;BTC {{ btc_leverage }}x / 山寨 {{ alt_leverage }}x;${openCntTxt ? openCntTxt + ";" : ""}${canTradeText}${avail};人工开仓盈亏比不得低于 {{ manual_min_planned_rr }}:1`;
|
||||
}
|
||||
}).catch(()=>{});"""
|
||||
if guard_sync not in text:
|
||||
text = text.replace(
|
||||
insert_after,
|
||||
insert_after.replace(" }).catch(()=>{});", guard_sync + "\n }).catch(()=>{});"),
|
||||
1,
|
||||
)
|
||||
|
||||
open_guard_js = """
|
||||
const allowOpenBeforeResetEl = document.getElementById("allow-open-before-reset");
|
||||
if(allowOpenBeforeResetEl){
|
||||
allowOpenBeforeResetEl.addEventListener("change", function(){
|
||||
const allow = !!this.checked;
|
||||
fetch("/api/settings/open_guard", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({enabled: !allow}),
|
||||
}).then(r=>r.json()).then(data=>{
|
||||
if(!data.ok){ alert(data.msg || "保存失败"); return; }
|
||||
refreshAccountSnapshot();
|
||||
}).catch(()=>alert("保存失败"));
|
||||
});
|
||||
}
|
||||
"""
|
||||
marker = "const orderSymbolEl = document.getElementById(\"order-symbol\");"
|
||||
if "allowOpenBeforeResetEl" not in text:
|
||||
text = text.replace(marker, "{% if ui_open_guard_enabled %}" + open_guard_js + "{% endif %}\n" + marker, 1)
|
||||
|
||||
orphan_fn_guard = "{% if ui_orphan_recovery_enabled %}\n renderOrphanRecoverBanner(data.orphan_live_positions);\n {% endif %}"
|
||||
text = text.replace(
|
||||
" renderOrphanRecoverBanner(data.orphan_live_positions);",
|
||||
orphan_fn_guard,
|
||||
)
|
||||
|
||||
header = "{# 三所共用 standalone 主页 — 由 scripts/build_unified_index.py 生成,勿手改三所副本 #}\n"
|
||||
if not text.startswith("{# 三所共用"):
|
||||
text = header + text
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(text, encoding="utf-8")
|
||||
print("wrote", OUT, "lines", len(text.splitlines()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""清空中控 K 线 SQLite 缓存(hub_kline.db),便于清库后全量重拉.
|
||||
|
||||
用法(Linux 云服务器,在仓库根目录):
|
||||
python3 scripts/clear_hub_kline_db.py --dry-run
|
||||
python3 scripts/clear_hub_kline_db.py --apply
|
||||
python3 scripts/clear_hub_kline_db.py --apply --exchange binance --symbol BTC/USDT --timeframe 15m
|
||||
|
||||
默认库路径:环境变量 HUB_KLINE_DB_PATH,或 manual_trading_hub/data/hub_kline.db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.hub.hub_kline_store import ( # noqa: E402
|
||||
clear_all_bars,
|
||||
clear_series_bars,
|
||||
default_db_path,
|
||||
init_db,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Clear manual-trading-hub K-line SQLite cache.")
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.getenv("HUB_KLINE_DB_PATH", "").strip() or str(default_db_path()),
|
||||
help="hub_kline.db path",
|
||||
)
|
||||
parser.add_argument("--exchange", default="", help="exchange_key, e.g. binance")
|
||||
parser.add_argument("--symbol", default="", help="symbol, e.g. BTC/USDT")
|
||||
parser.add_argument("--timeframe", default="", help="optional timeframe, e.g. 15m")
|
||||
parser.add_argument("--dry-run", action="store_true", help="count only")
|
||||
parser.add_argument("--apply", action="store_true", help="execute delete")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.is_file():
|
||||
print(f"DB not found: {db_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
init_db(db_path)
|
||||
ex = (args.exchange or "").strip().lower()
|
||||
sym = (args.symbol or "").strip().upper()
|
||||
tf = (args.timeframe or "").strip().lower() or None
|
||||
|
||||
if args.dry_run and not args.apply:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
if ex and sym:
|
||||
if tf:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM ohlcv_bars WHERE exchange_key=? AND symbol=? AND timeframe=?",
|
||||
(ex, sym, tf),
|
||||
).fetchone()[0]
|
||||
print(f"would delete series rows: {n} ({ex} {sym} {tf})")
|
||||
else:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM ohlcv_bars WHERE exchange_key=? AND symbol=?",
|
||||
(ex, sym),
|
||||
).fetchone()[0]
|
||||
print(f"would delete symbol rows: {n} ({ex} {sym} all tf)")
|
||||
else:
|
||||
n = conn.execute("SELECT COUNT(*) FROM ohlcv_bars").fetchone()[0]
|
||||
print(f"would delete all ohlcv_bars rows: {n}")
|
||||
finally:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
if not args.apply:
|
||||
print("Specify --apply to delete (or --dry-run to preview).", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if ex and sym:
|
||||
removed = clear_series_bars(ex, sym, tf, db_path)
|
||||
scope = f"{ex} {sym}" + (f" {tf}" if tf else " (all timeframes)")
|
||||
print(f"cleared {removed} rows for {scope}")
|
||||
else:
|
||||
removed = clear_all_bars(db_path)
|
||||
print(f"cleared all {removed} ohlcv_bars rows from {db_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""清理 strategy_trade_snapshots 重复行(同计划 + 同结果仅保留 id 最大的一条).
|
||||
|
||||
用法(在实例目录,如 crypto_monitor_gate):
|
||||
python ../scripts/dedupe_strategy_snapshots.py
|
||||
python ../scripts/dedupe_strategy_snapshots.py --db crypto.db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.strategy.strategy_snapshot_lib import dedupe_strategy_snapshots, init_strategy_snapshot_table # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Dedupe strategy_trade_snapshots rows.")
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.getenv("DB_PATH", "crypto.db"),
|
||||
help="SQLite database path (default: DB_PATH or crypto.db)",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Count only, do not delete")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.is_file():
|
||||
print(f"DB not found: {db_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_strategy_snapshot_table(conn)
|
||||
before = conn.execute("SELECT COUNT(*) AS c FROM strategy_trade_snapshots").fetchone()["c"]
|
||||
dup_groups = conn.execute(
|
||||
"""SELECT strategy_type, source_id, result_label, COUNT(*) AS n
|
||||
FROM strategy_trade_snapshots
|
||||
GROUP BY strategy_type, source_id, result_label
|
||||
HAVING n > 1
|
||||
ORDER BY n DESC"""
|
||||
).fetchall()
|
||||
extra = sum(int(r["n"]) - 1 for r in dup_groups)
|
||||
print(f"snapshots total={before}, duplicate rows to remove={extra}, groups={len(dup_groups)}")
|
||||
for r in dup_groups[:20]:
|
||||
print(
|
||||
f" {r['strategy_type']} plan={r['source_id']} "
|
||||
f"{r['result_label']} x{r['n']}"
|
||||
)
|
||||
if args.dry_run:
|
||||
conn.close()
|
||||
return 0
|
||||
removed = dedupe_strategy_snapshots(conn)
|
||||
conn.commit()
|
||||
after = conn.execute("SELECT COUNT(*) AS c FROM strategy_trade_snapshots").fetchone()["c"]
|
||||
conn.close()
|
||||
print(f"removed={removed}, remaining={after}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diagnose page render errors (run inside instance dir with venv)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
|
||||
def main() -> int:
|
||||
inst = sys.argv[1] if len(sys.argv) > 1 else "crypto_monitor_binance"
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(os.path.join(root, inst))
|
||||
sys.path.insert(0, os.getcwd())
|
||||
from app import app # noqa: WPS433
|
||||
|
||||
paths = ["/trade", "/key_monitor", "/strategy", "/login"]
|
||||
with app.test_client() as client:
|
||||
with client.session_transaction() as sess:
|
||||
sess["logged_in"] = True
|
||||
for path in paths:
|
||||
try:
|
||||
resp = client.get(path)
|
||||
print(f"{inst} {path} -> {resp.status_code}")
|
||||
if resp.status_code >= 400:
|
||||
body = resp.get_data(as_text=True)
|
||||
print(body[:3000])
|
||||
except Exception:
|
||||
print(f"{inst} {path} -> EXCEPTION")
|
||||
traceback.print_exc()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
"""One-off: extract instance_page.css / instance_page_boot.js from gate index.html."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
src = ROOT / "crypto_monitor_gate" / "templates" / "index.html"
|
||||
text = src.read_text(encoding="utf-8")
|
||||
|
||||
m = re.search(r"<style>(.*?)</style>", text, re.S)
|
||||
if m:
|
||||
(ROOT / "lib" / "common" / "static" / "instance_page.css").write_text(m.group(1).strip() + "\n", encoding="utf-8")
|
||||
|
||||
marker = '<script src="/static/manual_order_rr_preview.js?v=3"></script>'
|
||||
if marker in text:
|
||||
part = text.split(marker, 1)[1]
|
||||
m2 = re.search(r"<script>(.*?)</script>\s*</body>", part, re.S)
|
||||
if m2:
|
||||
boot = m2.group(1).strip()
|
||||
boot = boot.replace(
|
||||
"setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});",
|
||||
"setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));",
|
||||
)
|
||||
boot = boot.replace(
|
||||
"setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});",
|
||||
"setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));",
|
||||
)
|
||||
(ROOT / "lib" / "common" / "static" / "instance_page_boot.js").write_text(boot + "\n", encoding="utf-8")
|
||||
|
||||
part2 = text.split(marker, 1)[1]
|
||||
m3 = re.search(r"<script>(.*?)</script>\s*</body>", part2, re.S)
|
||||
if m3:
|
||||
boot_tpl = m3.group(1).strip()
|
||||
boot_tpl = boot_tpl.replace(
|
||||
"setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});",
|
||||
"setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));",
|
||||
)
|
||||
boot_tpl = boot_tpl.replace(
|
||||
"setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});",
|
||||
"setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));",
|
||||
)
|
||||
embed_dir = ROOT / "lib" / "instance" / "templates"
|
||||
embed_dir.mkdir(exist_ok=True)
|
||||
(embed_dir / "embed_boot_scripts.html").write_text(
|
||||
"<script>\n" + boot_tpl + "\n</script>\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print("done")
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""修正趋势保本移交后 monitor_type 仍为「下单监控」的历史数据."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from lib.strategy.strategy_trade_labels import MONITOR_TYPE_TREND_PULLBACK
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Fix trend handoff order/trade monitor_type labels.")
|
||||
parser.add_argument("--db", required=True, help="Path to instance sqlite db")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview only")
|
||||
parser.add_argument("--apply", action="store_true", help="Apply updates")
|
||||
args = parser.parse_args()
|
||||
if not args.dry_run and not args.apply:
|
||||
args.dry_run = True
|
||||
|
||||
db_path = Path(args.db).expanduser().resolve()
|
||||
if not db_path.is_file():
|
||||
print(f"[ERR] DB not found: {db_path}")
|
||||
return 1
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS c FROM order_monitors
|
||||
WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0
|
||||
AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控')
|
||||
"""
|
||||
)
|
||||
om_n = int(cur.fetchone()["c"])
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS c FROM trade_records
|
||||
WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0
|
||||
AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控')
|
||||
"""
|
||||
)
|
||||
tr_n = int(cur.fetchone()["c"])
|
||||
print(f"[INFO] order_monitors to fix: {om_n}")
|
||||
print(f"[INFO] trade_records to fix: {tr_n}")
|
||||
|
||||
if args.dry_run:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE order_monitors
|
||||
SET monitor_type=?
|
||||
WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0
|
||||
AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控')
|
||||
""",
|
||||
(MONITOR_TYPE_TREND_PULLBACK,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE trade_records
|
||||
SET monitor_type=?
|
||||
WHERE trend_plan_id IS NOT NULL AND trend_plan_id > 0
|
||||
AND (monitor_type IS NULL OR TRIM(monitor_type) = '' OR monitor_type = '下单监控')
|
||||
""",
|
||||
(MONITOR_TYPE_TREND_PULLBACK,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[OK] Applied.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成品牌 PNG/ICO(Pillow),供 Chrome 快捷方式与 manifest 使用.
|
||||
|
||||
中控用通用监控图标;三所各自用交易所标识色+字标.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT = os.path.join(REPO, "brand", "icons")
|
||||
|
||||
BG = (12, 16, 25, 255)
|
||||
PANEL = (20, 27, 45, 255)
|
||||
CYAN = (34, 211, 238, 255)
|
||||
GREEN = (52, 211, 153, 255)
|
||||
RED = (248, 113, 113, 255)
|
||||
|
||||
EXCHANGES = {
|
||||
"binance": {
|
||||
"label": "B",
|
||||
"accent": (240, 185, 11, 255),
|
||||
"panel": (26, 22, 10, 255),
|
||||
"svg_fill": "#F0B90B",
|
||||
},
|
||||
"okx": {
|
||||
"label": "OKX",
|
||||
"accent": (255, 255, 255, 255),
|
||||
"panel": (18, 18, 18, 255),
|
||||
"svg_fill": "#FFFFFF",
|
||||
},
|
||||
"gate": {
|
||||
"label": "G",
|
||||
"accent": (23, 230, 161, 255),
|
||||
"panel": (10, 28, 24, 255),
|
||||
"svg_fill": "#17E6A1",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _lerp(c1: tuple[int, ...], c2: tuple[int, ...], t: float) -> tuple[int, int, int, int]:
|
||||
t = max(0.0, min(1.0, t))
|
||||
return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(4)) # type: ignore
|
||||
|
||||
|
||||
def _rounded_rect(draw, box, radius: int, fill) -> None:
|
||||
draw.rounded_rectangle(box, radius=radius, fill=fill)
|
||||
|
||||
|
||||
def _font(size: int):
|
||||
from PIL import ImageFont
|
||||
|
||||
candidates = [
|
||||
os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", "arialbd.ttf"),
|
||||
os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts", "segoeuib.ttf"),
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if path and os.path.isfile(path):
|
||||
try:
|
||||
return ImageFont.truetype(path, size=size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def render_icon(size: int):
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
m = max(6, size // 12)
|
||||
r = max(8, size // 6)
|
||||
_rounded_rect(draw, (m, m, size - m, size - m), r, BG)
|
||||
inner = m + max(2, size // 28)
|
||||
_rounded_rect(draw, (inner, inner, size - inner, size - inner), max(6, r - 4), PANEL)
|
||||
|
||||
border = max(2, size // 42)
|
||||
for i in range(border):
|
||||
t0 = i / max(1, border - 1)
|
||||
for x in range(inner, size - inner):
|
||||
t = (x - inner) / max(1, size - 2 * inner)
|
||||
col = _lerp(CYAN, GREEN, (t + t0) * 0.5)
|
||||
draw.point((x, inner + i), fill=col)
|
||||
draw.point((x, size - inner - 1 - i), fill=col)
|
||||
for y in range(inner, size - inner):
|
||||
t = (y - inner) / max(1, size - 2 * inner)
|
||||
col = _lerp(CYAN, GREEN, (t + t0) * 0.5)
|
||||
draw.point((inner + i, y), fill=col)
|
||||
draw.point((size - inner - 1 - i, y), fill=col)
|
||||
|
||||
def sx(v: float) -> int:
|
||||
return int(v * size / 512)
|
||||
|
||||
def sy(v: float) -> int:
|
||||
return int(v * size / 512)
|
||||
|
||||
pts = [(120, 320), (200, 248), (280, 272), (392, 168)]
|
||||
scaled = [(sx(x), sy(y)) for x, y in pts]
|
||||
draw.line(scaled, fill=CYAN, width=max(2, size // 26), joint="curve")
|
||||
ex, ey = scaled[-1]
|
||||
draw.ellipse(
|
||||
(ex - size // 28, ey - size // 28, ex + size // 28, ey + size // 28),
|
||||
fill=GREEN,
|
||||
)
|
||||
|
||||
def candle(cx, top, bottom, body_top, body_bottom, color):
|
||||
w = max(1, size // 64)
|
||||
bh = max(2, size // 32)
|
||||
draw.line((cx, top, cx, bottom), fill=color, width=w)
|
||||
draw.rounded_rectangle(
|
||||
(cx - bh, body_top, cx + bh, body_bottom),
|
||||
radius=max(1, bh // 3),
|
||||
fill=color,
|
||||
)
|
||||
|
||||
candle(sx(182), sy(248), sy(340), sy(268), sy(332), RED)
|
||||
candle(sx(282), sy(200), sy(340), sy(220), sy(316), GREEN)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def render_exchange_icon(size: int, key: str):
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
cfg = EXCHANGES[key]
|
||||
accent = cfg["accent"]
|
||||
panel = cfg["panel"]
|
||||
label = cfg["label"]
|
||||
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
m = max(6, size // 12)
|
||||
r = max(8, size // 6)
|
||||
_rounded_rect(draw, (m, m, size - m, size - m), r, BG)
|
||||
inner = m + max(2, size // 28)
|
||||
_rounded_rect(draw, (inner, inner, size - inner, size - inner), max(6, r - 4), panel)
|
||||
|
||||
if size >= 32:
|
||||
border = max(1, size // 48)
|
||||
for i in range(border):
|
||||
x0 = inner + i
|
||||
y0 = inner + i
|
||||
x1 = size - inner - 1 - i
|
||||
y1 = size - inner - 1 - i
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
break
|
||||
draw.rounded_rectangle(
|
||||
(x0, y0, x1, y1),
|
||||
radius=max(2, r - 4 - i),
|
||||
outline=accent,
|
||||
)
|
||||
|
||||
if key == "binance":
|
||||
# 币安菱形标识
|
||||
cx = cy = size // 2
|
||||
s = max(3, int(size * 0.22))
|
||||
diamond = [(cx, cy - s), (cx + s, cy), (cx, cy + s), (cx - s, cy)]
|
||||
draw.polygon(diamond, fill=accent)
|
||||
s2 = max(1, int(s * 0.42))
|
||||
if s2 < s:
|
||||
inner_d = [(cx, cy - s2), (cx + s2, cy), (cx, cy + s2), (cx - s2, cy)]
|
||||
draw.polygon(inner_d, fill=panel)
|
||||
elif key == "okx":
|
||||
# OKX 四格方块风格(右下留空)
|
||||
gap = max(1, size // 48)
|
||||
cell = max(2, int(size * 0.16))
|
||||
cx = cy = size // 2
|
||||
coords = [
|
||||
(cx - cell - gap // 2, cy - cell - gap // 2),
|
||||
(cx + gap // 2, cy - cell - gap // 2),
|
||||
(cx - cell - gap // 2, cy + gap // 2),
|
||||
]
|
||||
for x0, y0 in coords:
|
||||
draw.rectangle((x0, y0, x0 + cell, y0 + cell), fill=accent)
|
||||
else:
|
||||
# Gate: 大字 G
|
||||
font_size = max(10, int(size * 0.42))
|
||||
font = _font(font_size)
|
||||
bbox = draw.textbbox((0, 0), label, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
x = (size - tw) // 2 - bbox[0]
|
||||
y = (size - th) // 2 - bbox[1] - max(0, size // 64)
|
||||
draw.text((x, y), label, font=font, fill=accent)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def write_exchange_svg(key: str, dest_dir: str) -> None:
|
||||
cfg = EXCHANGES[key]
|
||||
fill = cfg["svg_fill"]
|
||||
if key == "binance":
|
||||
mark = (
|
||||
f'<polygon points="256,150 362,256 256,362 150,256" fill="{fill}"/>'
|
||||
f'<polygon points="256,210 302,256 256,302 210,256" fill="#1a160a"/>'
|
||||
)
|
||||
elif key == "okx":
|
||||
mark = (
|
||||
f'<rect x="168" y="168" width="72" height="72" rx="10" fill="{fill}"/>'
|
||||
f'<rect x="272" y="168" width="72" height="72" rx="10" fill="{fill}"/>'
|
||||
f'<rect x="168" y="272" width="72" height="72" rx="10" fill="{fill}"/>'
|
||||
)
|
||||
else:
|
||||
mark = (
|
||||
f'<text x="256" y="310" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" '
|
||||
f'font-size="220" font-weight="700" fill="{fill}">G</text>'
|
||||
)
|
||||
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="108" fill="#0c1019"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="#141b2d"/>
|
||||
<rect x="36" y="36" width="440" height="440" rx="88" fill="none" stroke="{fill}" stroke-width="12"/>
|
||||
{mark}
|
||||
</svg>
|
||||
"""
|
||||
with open(os.path.join(dest_dir, "icon.svg"), "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(svg)
|
||||
|
||||
|
||||
def _save_set(out_dir: str, render_fn) -> None:
|
||||
from PIL import Image
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
sizes = [16, 32, 48, 180, 192, 512]
|
||||
images: dict[int, Image.Image] = {}
|
||||
for sz in sizes:
|
||||
im = render_fn(sz)
|
||||
images[sz] = im
|
||||
name = "apple-touch-icon.png" if sz == 180 else f"icon-{sz}.png"
|
||||
im.save(os.path.join(out_dir, name), format="PNG", optimize=True)
|
||||
|
||||
ico_sizes = [16, 32, 48]
|
||||
ico_imgs = [images[s] for s in ico_sizes]
|
||||
ico_imgs[0].save(
|
||||
os.path.join(out_dir, "favicon.ico"),
|
||||
format="ICO",
|
||||
sizes=[(s, s) for s in ico_sizes],
|
||||
append_images=ico_imgs[1:],
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
shutil.copy2(os.path.join(REPO, "brand", "icon.svg"), os.path.join(OUT, "icon.svg"))
|
||||
_save_set(OUT, render_icon)
|
||||
print(f"DONE hub {OUT}")
|
||||
|
||||
for key in EXCHANGES:
|
||||
dest = os.path.join(OUT, key)
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
write_exchange_svg(key, dest)
|
||||
_save_set(dest, lambda sz, k=key: render_exchange_icon(sz, k))
|
||||
print(f"DONE {key} {dest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot: move root shared modules into lib/ and rewrite imports."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
PACKAGE_FILES: dict[str, list[str]] = {
|
||||
"strategy": [
|
||||
"strategy_config.py",
|
||||
"strategy_db.py",
|
||||
"strategy_exchange_base.py",
|
||||
"strategy_exchange_binance.py",
|
||||
"strategy_exchange_gate.py",
|
||||
"strategy_exchange_okx.py",
|
||||
"strategy_records_register.py",
|
||||
"strategy_register.py",
|
||||
"strategy_roll_lib.py",
|
||||
"strategy_roll_monitor_lib.py",
|
||||
"strategy_roll_ui_lib.py",
|
||||
"strategy_snapshot_lib.py",
|
||||
"strategy_trade_labels.py",
|
||||
"strategy_trend_exchange.py",
|
||||
"strategy_trend_lib.py",
|
||||
"strategy_trend_register.py",
|
||||
"strategy_ui.py",
|
||||
"strategy_wechat_notify.py",
|
||||
],
|
||||
"key_monitor": [
|
||||
"key_monitor_full_margin_lib.py",
|
||||
"key_monitor_lib.py",
|
||||
"key_monitor_schema_lib.py",
|
||||
"key_sl_tp_lib.py",
|
||||
"fib_key_monitor_lib.py",
|
||||
"false_breakout_key_monitor_lib.py",
|
||||
"trigger_entry_key_monitor_lib.py",
|
||||
],
|
||||
"trade": [
|
||||
"trade_result_lib.py",
|
||||
"trade_exchange_stats_lib.py",
|
||||
"trade_stats_calendar_lib.py",
|
||||
"order_monitor_display_lib.py",
|
||||
"position_sizing_lib.py",
|
||||
"account_risk_lib.py",
|
||||
"manual_sltp_lib.py",
|
||||
"time_close_lib.py",
|
||||
"daily_open_limit_lib.py",
|
||||
],
|
||||
"hub": [
|
||||
"hub_auth.py",
|
||||
"hub_bridge.py",
|
||||
"hub_calculator_lib.py",
|
||||
"hub_calculator_market_lib.py",
|
||||
"hub_entry_plan_lib.py",
|
||||
"hub_fund_history_lib.py",
|
||||
"hub_host_status_lib.py",
|
||||
"hub_kline_store.py",
|
||||
"hub_macro_calendar_lib.py",
|
||||
"hub_market_info_lib.py",
|
||||
"hub_ohlcv_lib.py",
|
||||
"hub_position_metrics.py",
|
||||
"hub_sso.py",
|
||||
"hub_symbol_archive_lib.py",
|
||||
"hub_trades_lib.py",
|
||||
"hub_volume_rank_lib.py",
|
||||
],
|
||||
"ai": [
|
||||
"ai_client.py",
|
||||
"ai_review_lib.py",
|
||||
],
|
||||
"instance": [
|
||||
"instance_embed_context_lib.py",
|
||||
"instance_embed_lib.py",
|
||||
"instance_nav_lib.py",
|
||||
"focus_chart_lib.py",
|
||||
"journal_chart_lib.py",
|
||||
],
|
||||
"exchange": [
|
||||
"gate_transfer_lib.py",
|
||||
"gate_position_history_lib.py",
|
||||
"okx_orders_lib.py",
|
||||
],
|
||||
"common": [
|
||||
"form_submit_lib.py",
|
||||
"history_window_lib.py",
|
||||
"wechat_notify_lib.py",
|
||||
"auto_transfer_daily_lib.py",
|
||||
],
|
||||
}
|
||||
|
||||
DIR_MOVES: list[tuple[str, str]] = [
|
||||
("strategy_templates", "lib/strategy/templates"),
|
||||
("embed_templates", "lib/instance/templates"),
|
||||
("static", "lib/common/static"),
|
||||
]
|
||||
|
||||
MODULE_TO_LIB: dict[str, str] = {}
|
||||
for pkg, files in PACKAGE_FILES.items():
|
||||
for fname in files:
|
||||
MODULE_TO_LIB[fname[:-3]] = f"lib.{pkg}.{fname[:-3]}"
|
||||
|
||||
IMPORT_FROM_RE = re.compile(
|
||||
r"^(\s*)from\s+(" + "|".join(re.escape(m) for m in sorted(MODULE_TO_LIB, key=len, reverse=True)) + r")\s+import\s+",
|
||||
re.MULTILINE,
|
||||
)
|
||||
IMPORT_BARE_RE = re.compile(
|
||||
r"^(\s*)import\s+(" + "|".join(re.escape(m) for m in sorted(MODULE_TO_LIB, key=len, reverse=True)) + r")(\s|$)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def git_mv(src: Path, dst: Path) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not src.exists():
|
||||
if dst.exists():
|
||||
return
|
||||
raise FileNotFoundError(src)
|
||||
subprocess.run(["git", "mv", str(src), str(dst)], cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def move_files() -> None:
|
||||
(ROOT / "lib").mkdir(exist_ok=True)
|
||||
for pkg in PACKAGE_FILES:
|
||||
(ROOT / "lib" / pkg).mkdir(parents=True, exist_ok=True)
|
||||
init = ROOT / "lib" / pkg / "__init__.py"
|
||||
if not init.exists():
|
||||
init.write_text('"""Shared library package."""\n', encoding="utf-8")
|
||||
|
||||
lib_init = ROOT / "lib" / "__init__.py"
|
||||
if not lib_init.exists():
|
||||
lib_init.write_text('"""crypto_monitor shared libraries."""\n', encoding="utf-8")
|
||||
|
||||
paths_py = ROOT / "lib" / "paths.py"
|
||||
if not paths_py.exists():
|
||||
paths_py.write_text(
|
||||
'''"""Repository path helpers for lib/ assets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
LIB_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = LIB_DIR.parent
|
||||
|
||||
|
||||
def strategy_templates_dir(repo_root: str | Path | None = None) -> str:
|
||||
root = Path(repo_root) if repo_root is not None else REPO_ROOT
|
||||
return str(root / "lib" / "strategy" / "templates")
|
||||
|
||||
|
||||
def embed_templates_dir(repo_root: str | Path | None = None) -> str:
|
||||
root = Path(repo_root) if repo_root is not None else REPO_ROOT
|
||||
return str(root / "lib" / "instance" / "templates")
|
||||
|
||||
|
||||
def common_static_dir(repo_root: str | Path | None = None) -> str:
|
||||
root = Path(repo_root) if repo_root is not None else REPO_ROOT
|
||||
return str(root / "lib" / "common" / "static")
|
||||
''',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for pkg, files in PACKAGE_FILES.items():
|
||||
for fname in files:
|
||||
git_mv(ROOT / fname, ROOT / "lib" / pkg / fname)
|
||||
|
||||
for src_rel, dst_rel in DIR_MOVES:
|
||||
git_mv(ROOT / src_rel, ROOT / dst_rel)
|
||||
|
||||
|
||||
def rewrite_imports_in_text(text: str) -> str:
|
||||
def from_repl(m: re.Match) -> str:
|
||||
mod = m.group(2)
|
||||
return f"{m.group(1)}from {MODULE_TO_LIB[mod]} import "
|
||||
|
||||
def bare_repl(m: re.Match) -> str:
|
||||
mod = m.group(2)
|
||||
return f"{m.group(1)}import {MODULE_TO_LIB[mod]}{m.group(3)}"
|
||||
|
||||
text = IMPORT_FROM_RE.sub(from_repl, text)
|
||||
text = IMPORT_BARE_RE.sub(bare_repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def patch_path_literals(text: str) -> str:
|
||||
replacements = [
|
||||
('os.path.join(repo_root, "strategy_templates")', 'strategy_templates_dir(repo_root)'),
|
||||
('os.path.join(repo_root, "embed_templates")', 'embed_templates_dir(repo_root)'),
|
||||
('os.path.join(os.path.dirname(BASE_DIR), "static")', 'common_static_dir(os.path.dirname(BASE_DIR))'),
|
||||
('_REPO_ROOT / "static"', '_REPO_ROOT / "lib" / "common" / "static"'),
|
||||
('ROOT / "strategy_templates"', 'ROOT / "lib" / "strategy" / "templates"'),
|
||||
('ROOT / "embed_templates"', 'ROOT / "lib" / "instance" / "templates"'),
|
||||
('ROOT / "static"', 'ROOT / "lib" / "common" / "static"'),
|
||||
]
|
||||
for old, new in replacements:
|
||||
text = text.replace(old, new)
|
||||
return text
|
||||
|
||||
|
||||
def ensure_paths_import(text: str, filepath: Path) -> str:
|
||||
needs = []
|
||||
if "strategy_templates_dir(" in text and "from lib.paths import" not in text:
|
||||
needs.append("strategy_templates_dir")
|
||||
if "embed_templates_dir(" in text and "from lib.paths import" not in text:
|
||||
needs.append("embed_templates_dir")
|
||||
if "common_static_dir(" in text and "from lib.paths import" not in text:
|
||||
needs.append("common_static_dir")
|
||||
if not needs:
|
||||
return text
|
||||
imp = f"from lib.paths import {', '.join(sorted(set(needs)))}\n"
|
||||
if text.startswith('"""') or text.startswith("'''"):
|
||||
end = text.find('"""', 3) if text.startswith('"""') else text.find("'''", 3)
|
||||
if end != -1:
|
||||
end += 3
|
||||
return text[:end] + "\n\n" + imp + text[end + 1 :]
|
||||
if text.startswith("from __future__"):
|
||||
lines = text.splitlines(keepends=True)
|
||||
i = 0
|
||||
while i < len(lines) and (
|
||||
lines[i].startswith("from __future__") or lines[i].strip() == ""
|
||||
):
|
||||
i += 1
|
||||
return "".join(lines[:i]) + imp + "".join(lines[i:])
|
||||
return imp + text
|
||||
|
||||
|
||||
def rewrite_all_py_files() -> None:
|
||||
skip = {ROOT / "scripts" / "migrate_to_lib.py"}
|
||||
for path in ROOT.rglob("*.py"):
|
||||
if path in skip or ".venv" in path.parts or "__pycache__" in path.parts:
|
||||
continue
|
||||
original = path.read_text(encoding="utf-8")
|
||||
updated = rewrite_imports_in_text(original)
|
||||
updated = patch_path_literals(updated)
|
||||
updated = ensure_paths_import(updated, path)
|
||||
if updated != original:
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
move_files()
|
||||
rewrite_all_py_files()
|
||||
print("Migration complete.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将全角/易混淆标点规范为半角 ASCII(注释, 文档, 配置模板).
|
||||
|
||||
不转换弯引号 “ ” ‘ ’,避免破坏 Python/JS 字符串字面量.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
SKIP_DIRS = frozenset({
|
||||
".git",
|
||||
".venv",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".cursor",
|
||||
"agent-transcripts",
|
||||
})
|
||||
|
||||
SCAN_SUFFIXES = frozenset({
|
||||
".py",
|
||||
".js",
|
||||
".html",
|
||||
".md",
|
||||
".sh",
|
||||
".css",
|
||||
".json",
|
||||
".cjs",
|
||||
".txt",
|
||||
".yml",
|
||||
".yaml",
|
||||
".example",
|
||||
})
|
||||
|
||||
AMBIGUOUS_CHARS = frozenset(
|
||||
"\uff08\uff09\uff1a\uff0c\uff1b\uff1f\uff01\u3002\u3001\u00a0"
|
||||
)
|
||||
|
||||
TRANSLATION = str.maketrans(
|
||||
{
|
||||
"\uff08": "(",
|
||||
"\uff09": ")",
|
||||
"\uff1a": ":",
|
||||
"\uff0c": ",",
|
||||
"\uff1b": ";",
|
||||
"\uff1f": "?",
|
||||
"\uff01": "!",
|
||||
"\u3002": ".",
|
||||
"\u3001": ",",
|
||||
"\u00a0": " ",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def should_scan(path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
if any(part in SKIP_DIRS for part in path.parts):
|
||||
return False
|
||||
if path.name == ".env.example" or path.name.endswith(".env.example"):
|
||||
return True
|
||||
return path.suffix in SCAN_SUFFIXES
|
||||
|
||||
|
||||
def normalize_text(text: str) -> tuple[str, int]:
|
||||
count = sum(1 for ch in text if ch in AMBIGUOUS_CHARS)
|
||||
if not count:
|
||||
return text, 0
|
||||
return text.translate(TRANSLATION), count
|
||||
|
||||
|
||||
def read_text_strip_bom(path: Path) -> tuple[str, bool]:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
if raw.startswith("\ufeff"):
|
||||
return raw.lstrip("\ufeff"), True
|
||||
return raw, False
|
||||
|
||||
|
||||
def iter_targets(root: Path) -> list[Path]:
|
||||
return sorted(p for p in root.rglob("*") if should_scan(p))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Normalize ambiguous Unicode punctuation")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--root", default=str(REPO))
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.root)
|
||||
files_changed = 0
|
||||
chars_changed = 0
|
||||
|
||||
for path in iter_targets(root):
|
||||
try:
|
||||
original, had_bom = read_text_strip_bom(path)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
normalized, n = normalize_text(original)
|
||||
if not n and not had_bom:
|
||||
continue
|
||||
rel = path.relative_to(root)
|
||||
if args.dry_run:
|
||||
print(f"[dry-run] {rel}: {n} chars")
|
||||
else:
|
||||
# 保持原换行风格, 仅替换标点
|
||||
path.write_text(normalized, encoding="utf-8", newline="")
|
||||
files_changed += 1
|
||||
chars_changed += n
|
||||
|
||||
print(f"done: {files_changed} files, {chars_changed} replacements")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
一次性备份:三所 .env + 中控 .env / hub_settings.json(不含图片,不含数据库).
|
||||
|
||||
用途:删除 gate,清库,全新计划启动前,在仓库根目录执行一次即可:
|
||||
|
||||
python scripts/one_shot_backup_config_before_cleanup.py
|
||||
|
||||
输出目录默认:backups/one-shot-YYYYMMDD-HHMMSS/config/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
CONFIG_SOURCES: list[tuple[str, Path]] = [
|
||||
("crypto_monitor_binance.env", REPO_ROOT / "crypto_monitor_binance" / ".env"),
|
||||
("crypto_monitor_okx.env", REPO_ROOT / "crypto_monitor_okx" / ".env"),
|
||||
("crypto_monitor_gate.env", REPO_ROOT / "crypto_monitor_gate" / ".env"),
|
||||
("manual_trading_hub.env", REPO_ROOT / "manual_trading_hub" / ".env"),
|
||||
("hub_settings.json", REPO_ROOT / "manual_trading_hub" / "hub_settings.json"),
|
||||
]
|
||||
|
||||
ENV_BACKUP_GLOBS = (
|
||||
REPO_ROOT / "crypto_monitor_binance",
|
||||
REPO_ROOT / "crypto_monitor_okx",
|
||||
REPO_ROOT / "crypto_monitor_gate",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out_dir = REPO_ROOT / "backups" / f"one-shot-{stamp}" / "config"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied: list[str] = []
|
||||
missing: list[str] = []
|
||||
|
||||
for dest_name, src in CONFIG_SOURCES:
|
||||
if src.is_file():
|
||||
shutil.copy2(src, out_dir / dest_name)
|
||||
copied.append(dest_name)
|
||||
else:
|
||||
missing.append(str(src.relative_to(REPO_ROOT)))
|
||||
|
||||
for inst_dir in ENV_BACKUP_GLOBS:
|
||||
for src in sorted(inst_dir.glob(".env.backup.*")):
|
||||
dest_name = f"{inst_dir.name}.{src.name}"
|
||||
shutil.copy2(src, out_dir / dest_name)
|
||||
copied.append(dest_name)
|
||||
|
||||
manifest = out_dir.parent / "manifest.txt"
|
||||
lines = [
|
||||
f"created_at={stamp}",
|
||||
f"repo={REPO_ROOT}",
|
||||
"",
|
||||
"copied:",
|
||||
*[f" - {name}" for name in copied],
|
||||
"",
|
||||
"missing (skipped):",
|
||||
*[f" - {p}" for p in missing],
|
||||
"",
|
||||
"not included: crypto.db, hub *.db, static/images, gate",
|
||||
]
|
||||
manifest.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
print(f"Backup written to: {out_dir}")
|
||||
if copied:
|
||||
print("Copied:", ", ".join(copied))
|
||||
if missing:
|
||||
print("Missing (ok if fresh install):", ", ".join(missing))
|
||||
print(f"Manifest: {manifest}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch binance/okx/gate app.py for entry_model support."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
IMPORT_BLOCK = """from lib.trade.entry_model_lib import (
|
||||
build_intraday_entry_reason_options,
|
||||
build_trend_div_entry_reason_options,
|
||||
enrich_entry_model_display,
|
||||
migrate_entry_model_columns,
|
||||
order_entry_template_context,
|
||||
parse_manual_order_style_fields,
|
||||
resolve_trade_record_entry_reason,
|
||||
trend_manual_entry_reason_count,
|
||||
)
|
||||
"""
|
||||
|
||||
KEY_IMPORT = "from lib.key_monitor.key_auto_order_lib import (\n check_monitor_type_add_allowed,\n effective_entry_reason_options,\n effective_stats_segment_defs,\n load_key_auto_order_enabled,"
|
||||
|
||||
KEY_IMPORT_WITH_KEY_OPTS = "from lib.key_monitor.key_auto_order_lib import (\n KEY_ENTRY_REASON_OPTIONS,\n check_monitor_type_add_allowed,\n effective_entry_reason_options,\n effective_stats_segment_defs,\n load_key_auto_order_enabled,"
|
||||
|
||||
|
||||
def patch_file(path: str, exchange: str) -> bool:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
orig = text
|
||||
|
||||
if "from lib.trade.entry_model_lib import" not in text:
|
||||
text = text.replace(
|
||||
"from lib.trade.trade_policy_app_lib import (",
|
||||
IMPORT_BLOCK + "from lib.trade.trade_policy_app_lib import (",
|
||||
1,
|
||||
)
|
||||
|
||||
if exchange == "gate":
|
||||
old_er = '''# 与用户约定的固定开仓类型
|
||||
ENTRY_REASON_OPTIONS = (
|
||||
"趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低",
|
||||
"趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高",
|
||||
"趋势多头:小分歧低吸入场(左侧),确认条件:二次探底",
|
||||
"趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶",
|
||||
"波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20",
|
||||
"关键位箱体突破",
|
||||
"关键位收敛突破",
|
||||
"关键位斐波0.618",
|
||||
"关键位斐波0.786",
|
||||
"关键位假突破",
|
||||
"关键位回调触价开仓",
|
||||
"关键位突破触价开仓",
|
||||
) + STRATEGY_ENTRY_REASON_OPTIONS'''
|
||||
new_er = """# 日内户:长句开仓类型 + 关键位 + 策略(大分歧 A/B/小分歧 仅趋势户)
|
||||
ENTRY_REASON_OPTIONS = build_intraday_entry_reason_options(
|
||||
KEY_ENTRY_REASON_OPTIONS,
|
||||
STRATEGY_ENTRY_REASON_OPTIONS,
|
||||
)"""
|
||||
text = text.replace(old_er, new_er)
|
||||
if "KEY_ENTRY_REASON_OPTIONS," not in text.split("load_key_auto_order_enabled")[0]:
|
||||
text = text.replace(KEY_IMPORT, KEY_IMPORT_WITH_KEY_OPTS, 1)
|
||||
else:
|
||||
old_er = '''# 与用户约定的固定开仓类型(仅做这几类单子)
|
||||
ENTRY_REASON_OPTIONS = (
|
||||
"趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低",
|
||||
"趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高",
|
||||
"趋势多头:小分歧低吸入场(左侧),确认条件:二次探底",
|
||||
"趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶",
|
||||
"波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20",
|
||||
"关键位箱体突破",
|
||||
"关键位收敛突破",
|
||||
"关键位斐波0.618",
|
||||
"关键位斐波0.786",
|
||||
"关键位假突破",
|
||||
"关键位回调触价开仓",
|
||||
"关键位突破触价开仓",
|
||||
) + STRATEGY_ENTRY_REASON_OPTIONS'''
|
||||
new_er = """# 趋势户:大分歧A/B/小分歧 + 策略(关键位本实例关闭)
|
||||
ENTRY_REASON_OPTIONS = build_trend_div_entry_reason_options(STRATEGY_ENTRY_REASON_OPTIONS)"""
|
||||
text = text.replace(old_er, new_er)
|
||||
|
||||
if "migrate_entry_model_columns(conn)" not in text:
|
||||
text = text.replace(
|
||||
" conn.commit()\n conn.close()\n\n\ndef get_db",
|
||||
" migrate_entry_model_columns(conn)\n conn.commit()\n conn.close()\n\n\ndef get_db",
|
||||
1,
|
||||
)
|
||||
|
||||
text = re.sub(
|
||||
r" er = \(\n \(entry_reason or \"\"\)\.strip\(\)\n or entry_reason_from_key_signal\(kst\)\n or entry_reason_for_monitor_type\(monitor_type\)\n or \"\"\n \)",
|
||||
""" er = resolve_trade_record_entry_reason(
|
||||
entry_reason=entry_reason,
|
||||
entry_model=entry_model,
|
||||
key_signal_type=kst,
|
||||
monitor_type=monitor_type,
|
||||
entry_reason_from_key_signal=entry_reason_from_key_signal,
|
||||
entry_reason_for_monitor_type=entry_reason_for_monitor_type,
|
||||
)""",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
|
||||
if "entry_model=None," not in text:
|
||||
text = text.replace(
|
||||
" entry_reason=None,\n trend_plan_id=None,",
|
||||
" entry_reason=None,\n entry_model=None,\n trend_plan_id=None,",
|
||||
1,
|
||||
)
|
||||
|
||||
if "enrich_entry_model_display(item)" not in text:
|
||||
text = text.replace(
|
||||
" enrich_order_display_fields(item, calc_rr_ratio)\n try:",
|
||||
" enrich_order_display_fields(item, calc_rr_ratio)\n enrich_entry_model_display(item)\n try:",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
""" trade_style = (d.get("trade_style") or DEFAULT_TRADE_STYLE or "trend").strip().lower()
|
||||
if trade_style not in ("trend", "swing"):
|
||||
trade_style = "trend"
|
||||
available_usdt = get_available_trading_usdt()""",
|
||||
""" 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()""",
|
||||
1,
|
||||
)
|
||||
|
||||
old_insert = (
|
||||
'"INSERT INTO order_monitors (symbol, exchange_symbol, direction, trigger_price, stop_loss, initial_stop_loss, take_profit, margin_capital, leverage, trade_style, risk_percent, risk_amount, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, breakeven_armed, breakeven_price, breakeven_enabled, notional_value, position_ratio, base_amount, order_amount, exchange_order_id, opened_at, opened_at_ms, session_date, monitor_type, time_close_enabled, time_close_hours, time_close_at_ms) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",\n'
|
||||
" (\n"
|
||||
" symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,\n"
|
||||
" margin_capital, leverage, trade_style, risk_percent_db, risk_amount_final, breakeven_rr_trigger, breakeven_offset_pct, breakeven_step_r, 0, breakeven_price,\n"
|
||||
" breakeven_enabled,\n"
|
||||
" notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day,\n"
|
||||
" ORDER_MONITOR_TYPE_MANUAL,\n"
|
||||
" tc_en, tc_h, tc_at,\n"
|
||||
" )"
|
||||
)
|
||||
new_insert = (
|
||||
'"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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",\n'
|
||||
" (\n"
|
||||
" symbol, exchange_symbol, direction, trigger_price, stop_loss, stop_loss, take_profit,\n"
|
||||
" 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,\n"
|
||||
" breakeven_enabled,\n"
|
||||
" notional_value, position_ratio, base_amount, amount, open_order_id, opened_at_bj, opened_at_ms, trading_day,\n"
|
||||
" ORDER_MONITOR_TYPE_MANUAL,\n"
|
||||
" tc_en, tc_h, tc_at,\n"
|
||||
" )"
|
||||
)
|
||||
text = text.replace(old_insert, new_insert)
|
||||
|
||||
text = text.replace(
|
||||
""" effective_entry_reason_options(
|
||||
ENTRY_REASON_OPTIONS,
|
||||
POSITION_SIZING_MODE,
|
||||
KEY_AUTO_ORDER_ENABLED,
|
||||
)""",
|
||||
""" effective_entry_reason_options(
|
||||
ENTRY_REASON_OPTIONS,
|
||||
POSITION_SIZING_MODE,
|
||||
KEY_AUTO_ORDER_ENABLED,
|
||||
trend_manual_count=trend_manual_entry_reason_count(TRADE_POLICY),
|
||||
)""",
|
||||
1,
|
||||
)
|
||||
|
||||
if "**order_entry_template_context(TRADE_POLICY)," not in text:
|
||||
text = text.replace(
|
||||
" trade_policy=trade_policy_template_context(TRADE_POLICY),",
|
||||
" trade_policy=trade_policy_template_context(TRADE_POLICY),\n **order_entry_template_context(TRADE_POLICY),",
|
||||
1,
|
||||
)
|
||||
|
||||
# insert_trade_record from order row: add entry_model
|
||||
text = re.sub(
|
||||
r"(insert_trade_record\(\n\s+conn,\n(?:[^\n]+\n)+?\s+trade_style=r\[\"trade_style\"\],\n)",
|
||||
r"\1 entry_model=(r[\"entry_model\"] if \"entry_model\" in r.keys() else None),\n",
|
||||
text,
|
||||
)
|
||||
|
||||
if text != orig:
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(text)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
for ex in ("binance", "okx", "gate"):
|
||||
path = os.path.join(REPO, f"crypto_monitor_{ex}", "app.py")
|
||||
changed = patch_file(path, ex)
|
||||
print(f"{ex}: {'patched' if changed else 'no change'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为四所 templates 注入 instance_theme 脚本/样式与切换按钮."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EXCHANGES = ("crypto_monitor_binance", "crypto_monitor_okx", "crypto_monitor_gate")
|
||||
FILES = ("index.html", "login.html", "key_focus_v2.html", "order_focus_v2.html")
|
||||
|
||||
SCRIPT_TAG = ' <script src="/static/instance_theme.js?v=4"></script>\n'
|
||||
CSS_LINK = ' <link rel="stylesheet" href="/static/instance_theme.css?v=4">\n'
|
||||
|
||||
THEME_TOGGLE = """ <div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
|
||||
INDEX_HEADER_OLD = """ <div class="header">
|
||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||
</div>"""
|
||||
|
||||
INDEX_HEADER_NEW = """ <div class="header">
|
||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||
<div class="header-row">
|
||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||
""" + THEME_TOGGLE + """ </div>
|
||||
</div>"""
|
||||
|
||||
|
||||
def patch_file(path: Path) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
orig = text
|
||||
if 'data-theme="dark"' not in text:
|
||||
text = text.replace('<html lang="zh-CN">', '<html lang="zh-CN" data-theme="dark">', 1)
|
||||
if "/static/instance_theme.js" not in text:
|
||||
text = text.replace(
|
||||
"<meta charset=\"UTF-8\">",
|
||||
"<meta charset=\"UTF-8\">\n" + SCRIPT_TAG.strip() + "\n",
|
||||
1,
|
||||
)
|
||||
if "/static/instance_theme.css" not in text:
|
||||
text = text.replace("</style>", "</style>\n" + CSS_LINK, 1)
|
||||
if path.name == "index.html" and INDEX_HEADER_OLD in text and "instance-theme-toggle" not in text:
|
||||
text = text.replace(INDEX_HEADER_OLD, INDEX_HEADER_NEW)
|
||||
if path.name == "login.html" and "instance-theme-toggle" not in text:
|
||||
text = text.replace(
|
||||
"<body>",
|
||||
'<div class="login-theme-bar">\n' + THEME_TOGGLE + "</div>\n<body>",
|
||||
1,
|
||||
)
|
||||
if path.name == "key_focus_v2.html" and "instance-theme-toggle" not in text:
|
||||
marker = '<div class="row" style="justify-content:space-between">'
|
||||
if marker in text:
|
||||
text = text.replace(
|
||||
marker,
|
||||
marker + "\n " + THEME_TOGGLE.replace("\n", "\n "),
|
||||
1,
|
||||
)
|
||||
if path.name == "order_focus_v2.html" and "instance-theme-toggle" not in text:
|
||||
marker = '<div class="row" style="justify-content:space-between">'
|
||||
if marker in text:
|
||||
text = text.replace(
|
||||
marker,
|
||||
marker + "\n " + THEME_TOGGLE.replace("\n", "\n "),
|
||||
1,
|
||||
)
|
||||
if text != orig:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
n = 0
|
||||
for ex in EXCHANGES:
|
||||
for fn in FILES:
|
||||
p = ROOT / ex / "templates" / fn
|
||||
if p.is_file() and patch_file(p):
|
||||
print("patched", p.relative_to(ROOT))
|
||||
n += 1
|
||||
print("done", n, "files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""一次性:为 okx/gate 注入与 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 lib.key_monitor.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 = ''' <div class="rule-tip">
|
||||
计仓模式:<strong>{{ position_sizing_mode_label }}</strong>(仅 .env <code>POSITION_SIZING_MODE</code>,须无仓后重启)
|
||||
{% 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 }}%
|
||||
</div>'''
|
||||
|
||||
APPS = [
|
||||
("crypto_monitor_okx", 4, "_market_open_for_key_monitor", True),
|
||||
("crypto_monitor_gate", 2, "_market_open_for_key_monitor", True),
|
||||
]
|
||||
|
||||
|
||||
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'<div class="rule-tip">\s*以损定仓:风险 \{\{ risk_percent \}\}%.*?</div>',
|
||||
text,
|
||||
re.S,
|
||||
)
|
||||
if old:
|
||||
text = text[: old.start()] + TEMPLATE_RULE + text[old.end() :]
|
||||
text = text.replace(
|
||||
'<button type="submit">开仓(以损定仓)</button>',
|
||||
'<button type="submit">{{ open_position_button_label }}</button>',
|
||||
)
|
||||
text = text.replace(
|
||||
'<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">',
|
||||
'{% if position_sizing_mode != \'full_margin\' %}\n'
|
||||
' <input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">\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()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将 brand/icons 同步到中控与各所 static/icons(Chrome 快捷方式 / 标签页图标).
|
||||
|
||||
用法(仓库根目录):
|
||||
python scripts/generate_brand_icons.py
|
||||
python scripts/sync_brand_icons.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC = os.path.join(REPO, "brand", "icons")
|
||||
|
||||
HUB_DEST = os.path.join(REPO, "manual_trading_hub", "static", "icons")
|
||||
EXCHANGES = (
|
||||
("crypto_monitor_binance", "binance", "manifest.binance.webmanifest"),
|
||||
("crypto_monitor_okx", "okx", "manifest.okx.webmanifest"),
|
||||
("crypto_monitor_gate", "gate", "manifest.gate.webmanifest"),
|
||||
)
|
||||
|
||||
FILES = (
|
||||
"icon.svg",
|
||||
"favicon.ico",
|
||||
"icon-16.png",
|
||||
"icon-32.png",
|
||||
"icon-192.png",
|
||||
"icon-512.png",
|
||||
"apple-touch-icon.png",
|
||||
)
|
||||
|
||||
|
||||
def sync_dir(src_dir: str, dest: str, url_prefix: str, manifest_template: str) -> str:
|
||||
if not os.path.isdir(src_dir):
|
||||
return f"SKIP {dest}: 缺少 {src_dir},请先运行 python scripts/generate_brand_icons.py"
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
for name in FILES:
|
||||
src = os.path.join(src_dir, name)
|
||||
if not os.path.isfile(src):
|
||||
return f"SKIP {dest}: 缺少 {src}"
|
||||
shutil.copy2(src, os.path.join(dest, name))
|
||||
manifest_src = os.path.join(REPO, "brand", manifest_template)
|
||||
if os.path.isfile(manifest_src):
|
||||
with open(manifest_src, encoding="utf-8") as f:
|
||||
text = f.read().replace("__ICON_PREFIX__", url_prefix)
|
||||
with open(
|
||||
os.path.join(dest, "manifest.webmanifest"),
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
) as f:
|
||||
f.write(text)
|
||||
return f"DONE {dest}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(sync_dir(SRC, HUB_DEST, "/assets/icons", "manifest.webmanifest"))
|
||||
for folder, key, manifest in EXCHANGES:
|
||||
dest = os.path.join(REPO, folder, "static", "icons")
|
||||
src_dir = os.path.join(SRC, key)
|
||||
print(sync_dir(src_dir, dest, "/static/icons", manifest))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/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",
|
||||
}
|
||||
|
||||
# 仅当某实例 .env 缺少 FORCE_CLOSE_* 时补默认:
|
||||
# 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:
|
||||
"""仅在 FORCE_CLOSE_* 缺失时补默认值;已有手调值绝不覆盖."""
|
||||
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)
|
||||
added_keys: list[str] = []
|
||||
for key, val in values.items():
|
||||
cur = _env_get(lines, key)
|
||||
if cur is None:
|
||||
lines = _upsert(lines, key, val)
|
||||
added_keys.append(key)
|
||||
if not added_keys:
|
||||
print(f"ok (force-close unchanged): {dir_name}")
|
||||
continue
|
||||
any_changed = True
|
||||
print(f"force-close fill-missing: {dir_name}")
|
||||
for key in added_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="仅补全缺失的 FORCE_CLOSE_* 默认值(不覆盖手调)",
|
||||
)
|
||||
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()
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
三所 .env 一次性同步:计仓模式 + 自动划转(调用子脚本,不覆盖已有自定义值).
|
||||
|
||||
用法(仓库根目录):
|
||||
python scripts/sync_four_exchange_env.py
|
||||
python scripts/sync_four_exchange_env.py --dry-run
|
||||
python scripts/sync_four_exchange_env.py --set-transfer-amount 50 --enable-auto-transfer
|
||||
|
||||
子脚本可单独运行:
|
||||
python scripts/sync_four_exchange_position_sizing_env.py
|
||||
python scripts/sync_four_exchange_transfer_env.py
|
||||
|
||||
完整说明见 docs/env-sync-scripts.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
def _run(script: str, extra: list[str]) -> int:
|
||||
cmd = [PY, str(REPO / "scripts" / script)] + extra
|
||||
print(f"\n>>> {' '.join(cmd)}")
|
||||
return subprocess.call(cmd, cwd=str(REPO))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="三所 .env 统一同步(计仓 + 划转)")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--set-mode", choices=("risk", "full_margin"), metavar="MODE")
|
||||
ap.add_argument("--set-transfer-amount", metavar="U")
|
||||
ap.add_argument("--enable-auto-transfer", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
dry = ["--dry-run"] if args.dry_run else []
|
||||
code = 0
|
||||
|
||||
ps_args = list(dry)
|
||||
if args.set_mode:
|
||||
ps_args.extend(["--set-mode", args.set_mode])
|
||||
code |= _run("sync_four_exchange_position_sizing_env.py", ps_args)
|
||||
|
||||
tr_args = list(dry)
|
||||
if args.set_transfer_amount:
|
||||
tr_args.extend(["--set-amount", args.set_transfer_amount])
|
||||
if args.enable_auto_transfer:
|
||||
tr_args.append("--enable-auto-transfer")
|
||||
code |= _run("sync_four_exchange_transfer_env.py", tr_args)
|
||||
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将计仓模式相关项写入三所实例 .env(已存在则保留原值,缺失则追加默认值).
|
||||
|
||||
用法(仓库根目录):
|
||||
python scripts/sync_four_exchange_position_sizing_env.py
|
||||
python scripts/sync_four_exchange_position_sizing_env.py --dry-run
|
||||
python scripts/sync_four_exchange_position_sizing_env.py --set-mode risk
|
||||
python scripts/sync_four_exchange_position_sizing_env.py --set-mode full_margin
|
||||
|
||||
切换 POSITION_SIZING_MODE 须在交易所无持仓后执行,并 pm2 restart 对应实例.
|
||||
不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制).
|
||||
|
||||
完整说明见 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__)))
|
||||
|
||||
INSTANCES = (
|
||||
"crypto_monitor_binance",
|
||||
"crypto_monitor_okx",
|
||||
"crypto_monitor_gate",
|
||||
)
|
||||
|
||||
COMMENT_POSITION_SIZING = (
|
||||
"# 计仓:risk=以损定仓(默认);full_margin=合约可用×FULL_MARGIN_BUFFER_RATIO 全仓杠杆(须无仓后重启)"
|
||||
)
|
||||
COMMENT_BUFFER = "# 使用可用资金时的缓冲比例(如0.98代表用98%)"
|
||||
|
||||
DEFAULT_MODE = "risk"
|
||||
DEFAULT_BUFFER = "0.98"
|
||||
VALID_MODES = frozenset({"risk", "full_margin"})
|
||||
|
||||
|
||||
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 = []
|
||||
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 _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]:
|
||||
pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=")
|
||||
for i, line in enumerate(lines):
|
||||
if pat.match(line):
|
||||
return lines[:i] + insert + lines[i:]
|
||||
if lines and lines[-1].strip():
|
||||
return lines + [""] + insert
|
||||
return lines + insert
|
||||
|
||||
|
||||
def _ensure_position_sizing(lines: list[str], *, force_mode: str | None) -> list[str]:
|
||||
if force_mode is not None:
|
||||
if COMMENT_POSITION_SIZING not in lines and not _env_get(lines, "POSITION_SIZING_MODE"):
|
||||
lines = _insert_before(lines, "DAILY_START_CAPITAL", [COMMENT_POSITION_SIZING])
|
||||
return _upsert(lines, "POSITION_SIZING_MODE", force_mode)
|
||||
|
||||
cur = _env_get(lines, "POSITION_SIZING_MODE")
|
||||
if cur is not None:
|
||||
norm = cur.strip().lower()
|
||||
if norm in VALID_MODES and norm != cur:
|
||||
return _upsert(lines, "POSITION_SIZING_MODE", norm)
|
||||
if norm not in VALID_MODES:
|
||||
return _upsert(lines, "POSITION_SIZING_MODE", DEFAULT_MODE)
|
||||
return lines
|
||||
|
||||
block = [COMMENT_POSITION_SIZING, f"POSITION_SIZING_MODE={DEFAULT_MODE}"]
|
||||
return _insert_before(lines, "DAILY_START_CAPITAL", block)
|
||||
|
||||
|
||||
def _ensure_buffer_ratio(lines: list[str], *, force_buffer: str | None) -> list[str]:
|
||||
if force_buffer is not None:
|
||||
if COMMENT_BUFFER not in lines and _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is None:
|
||||
lines = _insert_before(lines, "BALANCE_REFRESH_SECONDS", [COMMENT_BUFFER])
|
||||
return _upsert(lines, "FULL_MARGIN_BUFFER_RATIO", force_buffer)
|
||||
|
||||
if _env_get(lines, "FULL_MARGIN_BUFFER_RATIO") is not None:
|
||||
return lines
|
||||
|
||||
block = [COMMENT_BUFFER, f"FULL_MARGIN_BUFFER_RATIO={DEFAULT_BUFFER}"]
|
||||
return _insert_before(lines, "BALANCE_REFRESH_SECONDS", block)
|
||||
|
||||
|
||||
def sync_one(
|
||||
dir_name: str,
|
||||
dry_run: bool,
|
||||
*,
|
||||
set_mode: str | None,
|
||||
set_buffer: str | None,
|
||||
) -> str:
|
||||
env_path = os.path.join(REPO, dir_name, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)"
|
||||
old_lines = _parse_env(env_path)
|
||||
new_lines = _ensure_buffer_ratio(
|
||||
_ensure_position_sizing(list(old_lines), force_mode=set_mode),
|
||||
force_buffer=set_buffer,
|
||||
)
|
||||
mode = _env_get(new_lines, "POSITION_SIZING_MODE") or DEFAULT_MODE
|
||||
buf = _env_get(new_lines, "FULL_MARGIN_BUFFER_RATIO") or DEFAULT_BUFFER
|
||||
if new_lines == old_lines:
|
||||
return f"OK {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}"
|
||||
if dry_run:
|
||||
return (
|
||||
f"DRY {dir_name}: 将写入 POSITION_SIZING_MODE={mode} "
|
||||
f"FULL_MARGIN_BUFFER_RATIO={buf}"
|
||||
)
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("\n".join(new_lines))
|
||||
if new_lines and new_lines[-1].strip():
|
||||
f.write("\n")
|
||||
return f"DONE {dir_name}: POSITION_SIZING_MODE={mode} FULL_MARGIN_BUFFER_RATIO={buf}"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="三所 .env 计仓模式项同步")
|
||||
ap.add_argument("--dry-run", action="store_true", help="仅打印将做的变更")
|
||||
ap.add_argument(
|
||||
"--set-mode",
|
||||
choices=sorted(VALID_MODES),
|
||||
metavar="MODE",
|
||||
help="强制三所 POSITION_SIZING_MODE(须无仓后重启)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--set-buffer",
|
||||
metavar="RATIO",
|
||||
help=f"强制三所 FULL_MARGIN_BUFFER_RATIO(缺省追加为 {DEFAULT_BUFFER})",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
if args.set_mode:
|
||||
print(
|
||||
f"注意:将 POSITION_SIZING_MODE 设为 {args.set_mode},"
|
||||
"请确认交易所无持仓后再 restart."
|
||||
)
|
||||
for name in INSTANCES:
|
||||
print(
|
||||
sync_one(
|
||||
name,
|
||||
args.dry_run,
|
||||
set_mode=args.set_mode,
|
||||
set_buffer=args.set_buffer,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将每日自动划转相关项写入三所实例 .env(已有值保留,缺失则追加;可选强制改金额/开关).
|
||||
|
||||
用法(仓库根目录):
|
||||
python scripts/sync_four_exchange_transfer_env.py
|
||||
python scripts/sync_four_exchange_transfer_env.py --dry-run
|
||||
python scripts/sync_four_exchange_transfer_env.py --set-amount 50
|
||||
python scripts/sync_four_exchange_transfer_env.py --enable-auto-transfer
|
||||
|
||||
不修改 API 密钥与其它自定义项;若 .env 不存在则跳过(请先从 .env.example 复制).
|
||||
|
||||
完整说明见 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__)))
|
||||
|
||||
INSTANCES = (
|
||||
"crypto_monitor_binance",
|
||||
"crypto_monitor_okx",
|
||||
"crypto_monitor_gate",
|
||||
)
|
||||
|
||||
COMMENT_BLOCK = (
|
||||
"# 自动划转:北京时间 AUTO_TRANSFER_BJ_HOUR 点将 swap 调整至 AUTO_TRANSFER_AMOUNT;"
|
||||
"不足 funding→swap,超出 swap→funding;持仓中不划转"
|
||||
)
|
||||
|
||||
DEFAULTS = {
|
||||
"AUTO_TRANSFER_ENABLED": "false",
|
||||
"AUTO_TRANSFER_FROM": "funding",
|
||||
"AUTO_TRANSFER_TO": "swap",
|
||||
"TRANSFER_CCY": "USDT",
|
||||
"AUTO_TRANSFER_BJ_HOUR": "8",
|
||||
}
|
||||
|
||||
DEFAULT_AMOUNT = "50"
|
||||
|
||||
BINANCE_ONLY = {
|
||||
"BINANCE_FUNDING_INCLUDE_SPOT": "false",
|
||||
}
|
||||
|
||||
|
||||
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 = []
|
||||
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 _insert_before(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]:
|
||||
pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=")
|
||||
for i, line in enumerate(lines):
|
||||
if pat.match(line):
|
||||
return lines[:i] + insert + lines[i:]
|
||||
if lines and lines[-1].strip():
|
||||
return lines + [""] + insert
|
||||
return lines + insert
|
||||
|
||||
|
||||
def _resolve_default_amount(lines: list[str]) -> str:
|
||||
amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT")
|
||||
if amount is not None:
|
||||
return amount
|
||||
daily = _env_get(lines, "DAILY_START_CAPITAL")
|
||||
if daily is not None:
|
||||
return daily
|
||||
return DEFAULT_AMOUNT
|
||||
|
||||
|
||||
def _ensure_key(
|
||||
lines: list[str],
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
force: bool,
|
||||
) -> list[str]:
|
||||
if force or _env_get(lines, key) is None:
|
||||
return _upsert(lines, key, value)
|
||||
return lines
|
||||
|
||||
|
||||
def _ensure_transfer_block(
|
||||
lines: list[str],
|
||||
extra: dict[str, str],
|
||||
*,
|
||||
force_amount: str | None,
|
||||
force_enabled: str | None,
|
||||
) -> list[str]:
|
||||
amount = force_amount if force_amount is not None else _resolve_default_amount(lines)
|
||||
had_amount = _env_get(lines, "AUTO_TRANSFER_AMOUNT") is not None
|
||||
|
||||
if not had_amount and COMMENT_BLOCK not in lines:
|
||||
lines = _insert_before(
|
||||
lines,
|
||||
"AUTO_TRANSFER_ENABLED",
|
||||
[COMMENT_BLOCK],
|
||||
)
|
||||
if _env_get(lines, "AUTO_TRANSFER_ENABLED") is None:
|
||||
lines = _insert_before(
|
||||
lines,
|
||||
"BALANCE_REFRESH_SECONDS",
|
||||
[COMMENT_BLOCK],
|
||||
)
|
||||
|
||||
lines = _ensure_key(
|
||||
lines,
|
||||
"AUTO_TRANSFER_AMOUNT",
|
||||
amount,
|
||||
force=force_amount is not None,
|
||||
)
|
||||
for k, v in DEFAULTS.items():
|
||||
if k == "AUTO_TRANSFER_ENABLED" and force_enabled is not None:
|
||||
lines = _upsert(lines, k, force_enabled)
|
||||
else:
|
||||
lines = _ensure_key(lines, k, v, force=False)
|
||||
for k, v in extra.items():
|
||||
lines = _ensure_key(lines, k, v, force=False)
|
||||
return lines
|
||||
|
||||
|
||||
def sync_one(
|
||||
dir_name: str,
|
||||
dry_run: bool,
|
||||
*,
|
||||
set_amount: str | None,
|
||||
enable_auto: bool | None,
|
||||
) -> str:
|
||||
env_path = os.path.join(REPO, dir_name, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return f"SKIP {dir_name}: 无 .env(请 cp .env.example .env)"
|
||||
old_lines = _parse_env(env_path)
|
||||
extra = dict(BINANCE_ONLY) if dir_name == "crypto_monitor_binance" else {}
|
||||
force_enabled = "true" if enable_auto is True else None
|
||||
new_lines = _ensure_transfer_block(
|
||||
old_lines,
|
||||
extra,
|
||||
force_amount=set_amount,
|
||||
force_enabled=force_enabled,
|
||||
)
|
||||
enabled = _env_get(new_lines, "AUTO_TRANSFER_ENABLED") or DEFAULTS["AUTO_TRANSFER_ENABLED"]
|
||||
amt = _env_get(new_lines, "AUTO_TRANSFER_AMOUNT") or DEFAULT_AMOUNT
|
||||
if new_lines == old_lines:
|
||||
return f"OK {dir_name}: ENABLED={enabled} AMOUNT={amt}"
|
||||
if dry_run:
|
||||
return f"DRY {dir_name}: 将更新 ENABLED={enabled} AMOUNT={amt}"
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("\n".join(new_lines))
|
||||
if new_lines and new_lines[-1].strip():
|
||||
f.write("\n")
|
||||
return f"DONE {dir_name}: ENABLED={enabled} AMOUNT={amt}"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="三所 .env 自动划转项同步")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument(
|
||||
"--set-amount",
|
||||
metavar="U",
|
||||
help=f"强制三所 AUTO_TRANSFER_AMOUNT(缺省补全默认 {DEFAULT_AMOUNT})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--enable-auto-transfer",
|
||||
action="store_true",
|
||||
help="强制三所 AUTO_TRANSFER_ENABLED=true",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
for name in INSTANCES:
|
||||
print(
|
||||
sync_one(
|
||||
name,
|
||||
args.dry_run,
|
||||
set_amount=args.set_amount,
|
||||
enable_auto=True if args.enable_auto_transfer else None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将账户方向 / 币种白名单 env 写入三所 .env(缺失则追加,已存在则 --set 时覆盖).
|
||||
|
||||
用法(仓库根目录):
|
||||
python scripts/sync_trade_policy_env.py
|
||||
python scripts/sync_trade_policy_env.py --dry-run
|
||||
python scripts/sync_trade_policy_env.py --apply-account-profiles
|
||||
python scripts/sync_trade_policy_env.py --set-direction binance long_only
|
||||
|
||||
--apply-account-profiles:币安=仅多,Gate=BTC/ETH 白名单,OKX=默认不限制.
|
||||
修改后须 pm2 restart 对应实例.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
INSTANCES = (
|
||||
"crypto_monitor_binance",
|
||||
"crypto_monitor_okx",
|
||||
"crypto_monitor_gate",
|
||||
)
|
||||
|
||||
COMMENT_BLOCK = [
|
||||
"# 方向限制(默认 false=双向均可;true 时按 TRADE_DIRECTION 限制,修改后须重启)",
|
||||
"# TRADE_DIRECTION=long_only | short_only | both(或 多/空/双向)",
|
||||
"# 币种白名单(默认 false=全币种可手输;true 时关键位/下单/策略仅下拉选择)",
|
||||
]
|
||||
|
||||
DEFAULTS = {
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "false",
|
||||
"TRADE_DIRECTION": "both",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "false",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
}
|
||||
|
||||
ACCOUNT_PROFILES = {
|
||||
"crypto_monitor_binance": {
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "true",
|
||||
"TRADE_DIRECTION": "long_only",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "false",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
},
|
||||
"crypto_monitor_gate": {
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "false",
|
||||
"TRADE_DIRECTION": "both",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
},
|
||||
"crypto_monitor_okx": {
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "false",
|
||||
"TRADE_DIRECTION": "both",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "false",
|
||||
"TRADE_SYMBOL_WHITELIST": "BTC,ETH",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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 _insert_after(lines: list[str], anchor_key: str, insert: list[str]) -> list[str]:
|
||||
pat = re.compile(r"^\s*" + re.escape(anchor_key) + r"\s*=")
|
||||
for i, line in enumerate(lines):
|
||||
if pat.match(line):
|
||||
return lines[: i + 1] + insert + lines[i + 1 :]
|
||||
if lines and lines[-1].strip():
|
||||
return lines + [""] + insert
|
||||
return lines + insert
|
||||
|
||||
|
||||
def sync_one(
|
||||
dir_name: str,
|
||||
values: dict[str, 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)
|
||||
changed = False
|
||||
for key, val in values.items():
|
||||
cur = _env_get(lines, key)
|
||||
if cur is None:
|
||||
if key == "TRADE_DIRECTION_RESTRICT_ENABLED" and _env_get(
|
||||
lines, "TRADE_DIRECTION"
|
||||
) is None:
|
||||
if COMMENT_BLOCK[0] not in "\n".join(lines):
|
||||
lines = _insert_after(lines, "POSITION_SIZING_MODE", COMMENT_BLOCK)
|
||||
lines = _upsert(lines, key, val)
|
||||
changed = True
|
||||
elif force or cur != val:
|
||||
lines = _upsert(lines, key, val)
|
||||
changed = True
|
||||
if not changed:
|
||||
print(f"ok (unchanged): {dir_name}")
|
||||
return False
|
||||
text = "\n".join(lines).rstrip() + "\n"
|
||||
print(f"update: {dir_name}")
|
||||
for k, v in values.items():
|
||||
print(f" {k}={v}")
|
||||
if not dry_run:
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(text)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="同步三所 trade policy env")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument(
|
||||
"--apply-account-profiles",
|
||||
action="store_true",
|
||||
help="币安仅多,Gate BTC/ETH,OKX 默认",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--defaults-only",
|
||||
action="store_true",
|
||||
help="三所均写入默认(不限制)",
|
||||
)
|
||||
ap.add_argument("--force", action="store_true", help="覆盖已有值")
|
||||
ap.add_argument("--set-direction", nargs=2, metavar=("INSTANCE", "MODE"))
|
||||
ap.add_argument("--set-symbol-whitelist", nargs=2, metavar=("INSTANCE", "SYMS"))
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.set_direction:
|
||||
inst, mode = args.set_direction
|
||||
if inst not in INSTANCES:
|
||||
raise SystemExit(f"unknown instance: {inst}")
|
||||
sync_one(
|
||||
inst,
|
||||
{
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED": "true",
|
||||
"TRADE_DIRECTION": mode,
|
||||
},
|
||||
dry_run=args.dry_run,
|
||||
force=True,
|
||||
)
|
||||
return
|
||||
|
||||
if args.set_symbol_whitelist:
|
||||
inst, syms = args.set_symbol_whitelist
|
||||
if inst not in INSTANCES:
|
||||
raise SystemExit(f"unknown instance: {inst}")
|
||||
sync_one(
|
||||
inst,
|
||||
{
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED": "true",
|
||||
"TRADE_SYMBOL_WHITELIST": syms,
|
||||
},
|
||||
dry_run=args.dry_run,
|
||||
force=True,
|
||||
)
|
||||
return
|
||||
|
||||
profiles = (
|
||||
{k: dict(DEFAULTS) for k in INSTANCES}
|
||||
if args.defaults_only
|
||||
else dict(ACCOUNT_PROFILES)
|
||||
if args.apply_account_profiles
|
||||
else {k: dict(DEFAULTS) for k in INSTANCES}
|
||||
)
|
||||
|
||||
if not args.apply_account_profiles and not args.defaults_only:
|
||||
ap.print_help()
|
||||
print("\n提示:部署常用 --apply-account-profiles")
|
||||
return
|
||||
|
||||
any_changed = False
|
||||
for inst in INSTANCES:
|
||||
if sync_one(inst, profiles[inst], dry_run=args.dry_run, force=args.force):
|
||||
any_changed = True
|
||||
if args.dry_run and any_changed:
|
||||
print("(dry-run, 未写入)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""验证中控 embed-auth 与 login 返回 session_token."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("HUB_PASSWORD", "test-pass")
|
||||
os.environ.setdefault("HUB_USERNAME", "admin")
|
||||
os.environ["HUB_ALLOW_PUBLIC"] = "true"
|
||||
|
||||
import hub as hub_mod # noqa: E402
|
||||
|
||||
client = TestClient(hub_mod.app)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
r = client.post("/api/auth/login", json={"username": "admin", "password": "test-pass"})
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data.get("ok") is True, data
|
||||
token = data.get("session_token")
|
||||
assert token, "login 应返回 session_token"
|
||||
|
||||
r2 = client.get(f"/embed-auth?token={token}&next=/monitor", follow_redirects=False)
|
||||
assert r2.status_code in (302, 307), r2.status_code
|
||||
assert r2.headers.get("location", "").endswith("/monitor")
|
||||
assert hub_mod.SESSION_COOKIE in r2.headers.get("set-cookie", "")
|
||||
|
||||
r3 = client.get("/monitor", cookies={hub_mod.SESSION_COOKIE: token})
|
||||
assert r3.status_code == 200, r3.status_code
|
||||
|
||||
csp = client.get("/login").headers.get("content-security-policy", "")
|
||||
assert "frame-ancestors" in csp, csp
|
||||
|
||||
print("OK: embed-auth sets session cookie; login returns session_token")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,62 @@
|
||||
"""验证 OKX 趋势回调止损挂单:须为 stopLossPrice 条件单,不得为立即市价平仓."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "crypto_monitor_okx"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
captured: list[dict] = []
|
||||
|
||||
def fake_create_order(symbol, order_type, side, amount, price, params):
|
||||
captured.append(
|
||||
{
|
||||
"symbol": symbol,
|
||||
"type": order_type,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"params": dict(params or {}),
|
||||
}
|
||||
)
|
||||
return {"id": "test-order", "average": 1.358}
|
||||
|
||||
mock_exchange = MagicMock()
|
||||
mock_exchange.create_order = fake_create_order
|
||||
mock_exchange.amount_to_precision = lambda sym, amt: amt
|
||||
mock_exchange.market = lambda sym: {"contractSize": 1, "limits": {"amount": {"min": 0.01}}}
|
||||
mock_exchange.load_markets = MagicMock()
|
||||
mock_exchange.price_to_precision = lambda sym, px: str(px)
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"LIVE_TRADING_ENABLED": "true", "OKX_API_KEY": "k", "OKX_API_SECRET": "s", "OKX_API_PASSPHRASE": "p"},
|
||||
clear=False,
|
||||
):
|
||||
import app as okx_app
|
||||
|
||||
okx_app.exchange = mock_exchange
|
||||
okx_app.MARKETS_LOADED = True
|
||||
|
||||
with patch.object(okx_app, "ensure_okx_live_ready", return_value=(True, "")), patch.object(
|
||||
okx_app, "get_live_position_contracts", return_value=12.0
|
||||
), patch.object(okx_app, "cancel_okx_swap_open_orders"):
|
||||
okx_app._okx_place_stop_loss_only("XRP/USDT:USDT", "long", 1.1)
|
||||
|
||||
assert len(captured) == 1, f"expected 1 create_order call, got {len(captured)}"
|
||||
call = captured[0]
|
||||
params = call["params"]
|
||||
assert call["side"] == "sell", call
|
||||
assert params.get("reduceOnly") is True, params
|
||||
assert "stopLossPrice" in params, f"missing stopLossPrice: {params}"
|
||||
assert params["stopLossPrice"] == 1.1, params
|
||||
assert "stopLoss" not in params, f"nested stopLoss causes immediate close: {params}"
|
||||
print("OK: _okx_place_stop_loss_only uses stopLossPrice conditional attach, not immediate close")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user