Files
crypto_monitor/scripts/align_okx_to_binance.py
2026-05-23 16:29:20 +08:00

567 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")