1334 lines
66 KiB
Python
1334 lines
66 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
auto.py - BTC 5m 差价交易机器人 (趋势跟随策略)
|
||
|
||
策略核心: 赚取差价 (非持仓到结算)
|
||
跟随 BTC 趋势入场, 趋势延续时止盈 (5m 市场动量效应 > 均值回归)
|
||
|
||
入场信号 (加权评分, 正=偏UP, 负=偏DN):
|
||
1. BTC 位移 (权重 4, 核心): BTC 偏离 strike 的方向 = 趋势方向
|
||
2. BTC 加速度 (权重 3, 核心): 动量启动/衰减, 加速时跟随价值最高
|
||
3. 盘口压力失衡 (权重 2): order book bids/asks 失衡确认短期方向
|
||
4. 鲸鱼跟随 (权重 1): 大户持仓方向 = 趋势方向 (跟单, 不反向)
|
||
|
||
退出条件:
|
||
持仓盈利 > 15% -> 止盈 (趋势跟随给趋势发展空间)
|
||
持仓亏损 > 15% -> 止损 (严格止损)
|
||
持仓超过 120s -> 强制平仓 (避免结算尾部风险)
|
||
剩余时间 < 30s -> 强制平仓
|
||
|
||
前置条件: onchain_leaderboard.py 必须在运行 (提供盘口 + 鲸鱼持仓数据)
|
||
|
||
用法:
|
||
python auto.py # dry run (默认)
|
||
python auto.py --live # 实盘
|
||
python auto.py --amount 1 # 每笔 $1
|
||
python auto.py --market btc_5m # 指定市场
|
||
python auto.py --weight-btc-dev 4 --weight-btc-accel 3 # 调权重
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import time
|
||
import threading
|
||
import traceback
|
||
from datetime import datetime
|
||
|
||
# 屏蔽 SSL 警告 (代理自签证书)
|
||
import urllib3
|
||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||
|
||
# ── 复用 quick_trade 的基础设施 ──────────────────────────────────────────────
|
||
import quick_trade as qt
|
||
|
||
PROXY = qt._PROXY
|
||
PROXIES = qt._PROXIES
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 配置 (差价交易: 趋势跟随策略)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 参数分两类:
|
||
# 1. 交易参数: amount, entry_delay, exit_deadline, take_profit, stop_loss, max_price
|
||
# 2. 信号评分参数: 4 个权重 + min_score_ratio
|
||
#
|
||
# 策略目标: 赚取差价 (非持仓到结算)
|
||
# - 跟随 BTC 趋势入场, 趋势延续时止盈 (动量效应)
|
||
# - BTC 位移 + 加速度为核心信号, 盘口/鲸鱼为确认信号
|
||
# - 严格止损 + 中止盈, 高频次小利润
|
||
# - 持仓时间短 (max_hold_time), 避免结算尾部风险
|
||
#
|
||
# 权重的含义: 每个信号的最大贡献值, 不是"所需分数"
|
||
# 4 个信号权重总和 10 = 理论最大 ±10
|
||
# 入场门槛 = min_score_ratio × 权重总和 (默认 0.5 × 10 = ±5)
|
||
# 调 min_score_ratio = 改变入场严格度 (0.3=宽松, 0.7=严格)
|
||
class Config:
|
||
dry_run = True # True=模拟, False=实盘
|
||
market = "btc_5m" # 市场: btc_5m/btc_15m/btc_1h/eth_5m...
|
||
amount = 1.0 # 每笔下单金额 (USD)
|
||
entry_delay = 15 # 回合开始后等待秒数 (等盘口形成, 太早无流动性)
|
||
exit_deadline = 60 # 强制平仓倒计时 (-1=禁用, 60=剩60s平仓, 避免回合末流动性枯竭)
|
||
min_hold_time = 90 # 入场时最小剩余时间 (秒), 不够则跳过
|
||
max_hold_time = 180 # 持仓最大时长 (秒), 超过强制平仓 (趋势需要时间发展)
|
||
reentry_cooldown = 30 # 平仓后冷却秒数, 期间不再入场 (避免过度交易, 0=立即再入场)
|
||
stop_loss_grace = 5 # 入场后宽限秒数, 期间不检查止损 (避免瞬时滑点误触发)
|
||
max_spread = 0.05 # 入场前盘口价差上限 (bid-ask > 5¢ 不入场, 流动性差)
|
||
max_retries = 3 # 下单失败最大重试次数
|
||
retry_delay = 0.1 # 重试间隔基数 (秒, 指数退避)
|
||
virtual_balance = 10.0 # dry-run 初始模拟余额
|
||
take_profit_pct = 0.25 # 持仓盈利止盈阈值 (backtest TP×SL grid: 25% × 30% 最优)
|
||
stop_loss_pct = 0.30 # 持仓亏损止损阈值 (backtest: 25% TP + 30% SL 总PnL +$71.69)
|
||
max_price = 0.75 # token 价格上限 (趋势可能推高, 允许 0.75 入场)
|
||
buy_slippage = 0.02 # 买入滑点 (ask + 此值 = 成交价, 0.02=2¢, 摩擦最小化)
|
||
sell_slippage = 0.02 # 卖出滑点 (bid - 此值 = 成交价, 0.02=2¢)
|
||
|
||
# 信号权重: 每个信号的最大贡献值 (实际打分 ≤ 此值)
|
||
# 策略核心: 跟随当前 3 连胜 (🔥) 鲸鱼的动态方向, 而不是 curated 列表
|
||
weight_btc_dev = 1 # BTC 位移 (弱确认): 偏离 strike 仅作辅助
|
||
weight_btc_accel = 2 # BTC 加速度 (弱确认): 动量辅助
|
||
weight_book = 1 # 盘口压力 (弱确认): 失衡辅助
|
||
weight_whale_follow = 0 # 鲸鱼加权投票: 禁用 (curated 列表噪声大, 已替换为 streak_leader)
|
||
weight_winners = 0 # 上轮赢家方向: 已合并入 streak_leader (combined 投票)
|
||
weight_streak_leader = 4 # 连赢鲸鱼 🔥 + 上轮赢家 综合信号 (核心): 加权投票 2:1
|
||
streak_combined_threshold = 0.30 # combined 投票入场门槛 (历史回测最优)
|
||
|
||
# 入场门槛 = 这个比例 × (权重总和)
|
||
# 0.5 = 50% 满分才入场 (默认), 0.3 = 宽松, 0.7 = 严格
|
||
min_score_ratio = 0.5
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 日志
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
_LOG_LOCK = threading.Lock()
|
||
|
||
def log(msg, level="INFO"):
|
||
ts = datetime.now().strftime("%H:%M:%S")
|
||
tag = f"[{level}]"
|
||
prefix = " " if level == "DATA" else ""
|
||
with _LOG_LOCK:
|
||
print(f"{ts} {tag} {prefix}{msg}", flush=True)
|
||
|
||
|
||
def log_trade(msg, success=True):
|
||
ts = datetime.now().strftime("%H:%M:%S")
|
||
icon = "✅" if success else "❌"
|
||
with _LOG_LOCK:
|
||
print(f"{ts} {icon} {msg}", flush=True)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 回测数据记录器 (JSONL 格式, 只在 dry_run 模式写)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
_recorder = None # 延迟到 main() 初始化
|
||
|
||
|
||
class Recorder:
|
||
def __init__(self, path):
|
||
self.path = path
|
||
self.fh = open(path, "a", encoding="utf-8")
|
||
self._lock = threading.Lock()
|
||
|
||
def write(self, event_type: str, data: dict):
|
||
row = {"ts": time.time(), "type": event_type, **data}
|
||
with self._lock:
|
||
self.fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||
self.fh.flush()
|
||
|
||
def close(self):
|
||
self.fh.close()
|
||
|
||
|
||
def rec(event_type: str, **kw):
|
||
"""安全写记录 (recorder 不存在时静默)"""
|
||
global _recorder
|
||
if _recorder:
|
||
_recorder.write(event_type, kw)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 从 onchain_leaderboard 的 WebSocket 读取全部状态 (100ms 推送)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 单个 dict, 无锁读取 (GIL 保证原子引用赋值, 读取端只做 dict.get)
|
||
_ws_state = {
|
||
"leaderboard": {},
|
||
"strike": 0.0,
|
||
"round_ts": 0,
|
||
"round_end": 0,
|
||
"token_ids": {},
|
||
"up_price": 0.0, # best bid
|
||
"down_price": 0.0,
|
||
"up_ask": 0.0,
|
||
"down_ask": 0.0,
|
||
"crypto_current": 0.0,
|
||
"up_book": {"bids": [], "asks": []},
|
||
"down_book": {"bids": [], "asks": []},
|
||
"ts": 0.0, # 最后更新时间
|
||
}
|
||
|
||
def _ws_client():
|
||
"""后台线程: 连接 onchain_leaderboard WebSocket, 持续接收全部状态"""
|
||
import websocket
|
||
ws_url = f"ws://127.0.0.1:{qt.PORT}/ws"
|
||
while True:
|
||
try:
|
||
ws = websocket.create_connection(ws_url, timeout=10)
|
||
log(f"已连接排行榜 WebSocket: {ws_url}")
|
||
while True:
|
||
try:
|
||
raw = ws.recv()
|
||
if not raw:
|
||
continue
|
||
# 解析在锁外完成 (CPU 密集, 不需要持锁)
|
||
data = json.loads(raw)
|
||
# 快速赋值: 只持锁做字段拷贝
|
||
with _ws_lock:
|
||
for k in ("leaderboard", "token_ids", "up_book", "down_book"):
|
||
v = data.get(k)
|
||
if v:
|
||
_ws_state[k] = v
|
||
for k in ("strike", "round_ts", "round_end", "up_price",
|
||
"down_price", "up_ask", "down_ask", "crypto_current"):
|
||
v = data.get(k)
|
||
if v:
|
||
_ws_state[k] = v
|
||
_ws_state["ts"] = time.time()
|
||
# 同步到 qt.state 让 place_order 读到 token_ids
|
||
if _ws_state.get("token_ids"):
|
||
qt.state["token_ids"] = dict(_ws_state["token_ids"])
|
||
qt.state["strike"] = _ws_state.get("strike", 0)
|
||
qt.state["round_ts"] = _ws_state.get("round_ts", 0)
|
||
qt.state["round_end"] = _ws_state.get("round_end", 0)
|
||
except Exception:
|
||
break
|
||
ws.close()
|
||
except Exception:
|
||
pass
|
||
log("排行榜 WS 断开, 2s 后重连...", "WARN")
|
||
time.sleep(2)
|
||
|
||
|
||
_ws_lock = threading.Lock()
|
||
|
||
|
||
def ws_get(key, default=None):
|
||
"""无锁快读 (GIL 保证 dict.get 原子性)"""
|
||
return _ws_state.get(key, default)
|
||
|
||
|
||
def read_leaderboard() -> dict:
|
||
"""读取排行榜副本 (深拷贝 up/down 列表, 防止 WS 写入时 RuntimeError)"""
|
||
lb = _ws_state.get("leaderboard")
|
||
if not lb:
|
||
return {}
|
||
return {
|
||
"up": list(lb.get("up", [])),
|
||
"down": list(lb.get("down", [])),
|
||
"up_total": lb.get("up_total", 0),
|
||
"dn_total": lb.get("dn_total", 0),
|
||
"up_count": lb.get("up_count", 0),
|
||
"dn_count": lb.get("dn_count", 0),
|
||
"hedged_count": lb.get("hedged_count", 0),
|
||
"streak_up": lb.get("streak_up", 0),
|
||
"streak_dn": lb.get("streak_dn", 0),
|
||
"winners": dict(lb.get("winners", {})),
|
||
"streak4": dict(lb.get("streak4", {})),
|
||
}
|
||
|
||
|
||
def get_cached_price(side: str) -> tuple:
|
||
"""从 WS 缓存读取 best_bid / best_ask (优先用盘口数据, 再用 up_ask/down_ask 字段)"""
|
||
# 从 order book 读: 最优卖 = asks[0], 最优买 = bids[0]
|
||
# 盘口数据格式: {"p": price, "s": size}
|
||
book = _ws_state.get(f"{side}_book", {})
|
||
asks = book.get("asks", [])
|
||
bids = book.get("bids", [])
|
||
best_ask = min((a["p"] for a in asks), default=0) if asks else 0
|
||
best_bid = max((b["p"] for b in bids), default=0) if bids else 0
|
||
# 兜底: 用旧的 up_ask/down_ask 字段
|
||
if best_ask <= 0:
|
||
best_ask = _ws_state.get(f"{side}_ask", 0)
|
||
if best_bid <= 0:
|
||
best_bid = _ws_state.get(f"{side}_price", 0)
|
||
return best_bid, best_ask
|
||
|
||
|
||
def start_ws_client():
|
||
try:
|
||
import websocket
|
||
except ImportError:
|
||
log("缺少 websocket-client, 请 pip install websocket-client", "ERROR")
|
||
return False
|
||
threading.Thread(target=_ws_client, daemon=True).start()
|
||
return True
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# BTC 实时价格 (从 WS 缓存读取, 100ms 精度)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
def get_btc_price() -> float:
|
||
return _ws_state.get("crypto_current", 0.0)
|
||
|
||
|
||
# BTC 价格历史 (用于计算加速度/动量)
|
||
_btc_history = [] # [(ts, price), ...] 最近 N 个采样
|
||
_BTC_HIST_MAX = 30 # 保留最近 30 个采样 (约 30 秒, 每次 run_bot 循环采一个)
|
||
_btc_hist_lock = threading.Lock()
|
||
|
||
|
||
def sample_btc_price(btc: float):
|
||
"""在 run_bot 主循环里采样 BTC 价格, 用于计算动量/加速度"""
|
||
if btc <= 0:
|
||
return
|
||
now = time.time()
|
||
with _btc_hist_lock:
|
||
_btc_history.append((now, btc))
|
||
# 保留最近 _BTC_HIST_MAX 个 + 30 秒内的
|
||
cutoff = now - 30
|
||
while _btc_history and (_btc_history[0][0] < cutoff or len(_btc_history) > _BTC_HIST_MAX):
|
||
_btc_history.pop(0)
|
||
|
||
|
||
def get_btc_velocity() -> tuple:
|
||
"""计算 BTC 价格动量
|
||
返回 (velocity_per_sec, accel_per_sec2):
|
||
velocity = (current - past) / dt
|
||
accel = (v_now - v_past) / dt
|
||
无足够数据时返回 (0.0, 0.0)
|
||
"""
|
||
with _btc_hist_lock:
|
||
if len(_btc_history) < 4:
|
||
return 0.0, 0.0
|
||
now_ts, now_p = _btc_history[-1]
|
||
# 取 ~10s 前的价格算 velocity
|
||
past_ts = now_ts - 10
|
||
past_p = None
|
||
for ts, p in _btc_history:
|
||
if ts >= past_ts:
|
||
past_p = p
|
||
past_ts_actual = ts
|
||
break
|
||
if past_p is None:
|
||
return 0.0, 0.0
|
||
dt1 = now_ts - past_ts_actual
|
||
if dt1 <= 0:
|
||
return 0.0, 0.0
|
||
v_now = (now_p - past_p) / dt1 # $/s
|
||
# 取 ~20s 前的价格算过去的 velocity, 与现在比得到加速度
|
||
old_ts = now_ts - 20
|
||
old_p = None
|
||
for ts, p in _btc_history:
|
||
if ts >= old_ts:
|
||
old_p = p
|
||
old_ts_actual = ts
|
||
break
|
||
if old_p is None or len(_btc_history) < 6:
|
||
return v_now, 0.0
|
||
# 过去的 velocity: 用 20s 前 到 10s 前之间
|
||
mid_ts = now_ts - 10
|
||
mid_p = None
|
||
for ts, p in _btc_history:
|
||
if ts >= mid_ts:
|
||
mid_p = p
|
||
break
|
||
if mid_p is None:
|
||
return v_now, 0.0
|
||
dt2 = mid_ts - old_ts_actual
|
||
if dt2 <= 0:
|
||
return v_now, 0.0
|
||
v_past = (mid_p - old_p) / dt2
|
||
accel = (v_now - v_past) / max(now_ts - mid_ts, 0.1)
|
||
return v_now, accel
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 虚拟持仓 (dry-run 模式)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
class VirtualPortfolio:
|
||
def __init__(self, balance):
|
||
self.balance = balance
|
||
self.position = None
|
||
self.trades = []
|
||
self.total_pnl = 0.0
|
||
|
||
def buy(self, side, price, shares, amount):
|
||
if self.position:
|
||
return False, "已有持仓"
|
||
cost = shares * price
|
||
if cost > self.balance:
|
||
return False, f"余额不足 (${self.balance:.2f} < ${cost:.2f})"
|
||
self.balance -= cost
|
||
self.position = {"side": side, "shares": shares, "entry_price": price, "entry_ts": time.time()}
|
||
self.trades.append({"action": "buy", "side": side, "price": price, "shares": shares, "ts": time.time()})
|
||
return True, f"买入 {side.upper()} {shares:.1f}股 @ {price:.2f}"
|
||
|
||
def sell(self, exit_price):
|
||
if not self.position:
|
||
return False, "无持仓", 0
|
||
pos = self.position
|
||
revenue = pos["shares"] * exit_price
|
||
cost = pos["shares"] * pos["entry_price"]
|
||
pnl = revenue - cost
|
||
self.balance += revenue
|
||
self.total_pnl += pnl
|
||
self.trades.append({"action": "sell", "side": pos["side"], "price": exit_price,
|
||
"shares": pos["shares"], "pnl": pnl, "ts": time.time()})
|
||
self.position = None
|
||
return True, f"卖出 {pos['side'].upper()} {pos['shares']:.1f}股 @ {exit_price:.2f}", pnl
|
||
|
||
def status(self):
|
||
return {
|
||
"balance": round(self.balance, 2),
|
||
"position": self.position,
|
||
"total_pnl": round(self.total_pnl, 2),
|
||
"trades": len(self.trades),
|
||
}
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 下单 (带重试 + 执行验证)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
_virtual = None # 延迟到 main() 初始化 (Config.virtual_balance 可能被 CLI 覆盖)
|
||
|
||
|
||
# 实盘模式 P&L 追踪
|
||
_live_stats = {"trades": 0, "wins": 0, "losses": 0, "total_pnl": 0.0, "total_fees": 0.0}
|
||
|
||
|
||
def record_live_trade(pnl: float, fees: float = 0.0):
|
||
"""实盘: 记录单笔 P&L (从 pUSD 余额变化推算)"""
|
||
_live_stats["trades"] += 1
|
||
_live_stats["total_pnl"] += pnl
|
||
_live_stats["total_fees"] += fees
|
||
if pnl > 0:
|
||
_live_stats["wins"] += 1
|
||
elif pnl < 0:
|
||
_live_stats["losses"] += 1
|
||
|
||
|
||
def place_order(side: str, amount_usd: float, is_sell_all: bool = False) -> dict:
|
||
"""
|
||
下单统一入口 (买入 / SELL ALL)
|
||
返回 {"success": bool, "message": str, "price": float, "shares": float}
|
||
带: 滑点计算, 网络重试, 执行验证
|
||
价格来源: WS 缓存 (无 HTTP 延迟)
|
||
"""
|
||
for attempt in range(1, Config.max_retries + 1):
|
||
try:
|
||
token_id = _ws_state.get("token_ids", {}).get(side)
|
||
if not token_id:
|
||
with qt.state_lock:
|
||
token_id = qt.state["token_ids"].get(side)
|
||
if not token_id:
|
||
return {"success": False, "message": "回合未就绪, 无 token_id"}
|
||
|
||
# 从 WS 缓存读价格 (无 HTTP, 0ms 延迟)
|
||
bid, ask = get_cached_price(side)
|
||
|
||
if is_sell_all:
|
||
price = bid if bid > 0 else 0.5
|
||
order_price = round(max(price - Config.sell_slippage, 0.01), 2)
|
||
else:
|
||
price = ask if ask > 0 else bid
|
||
if price <= 0:
|
||
if attempt < Config.max_retries:
|
||
time.sleep(0.3 * attempt)
|
||
continue
|
||
return {"success": False, "message": "无可用盘口价格"}
|
||
order_price = round(min(price + Config.buy_slippage, 0.99), 2)
|
||
|
||
# ── Dry Run: 模拟成交 (与实盘对齐: 用 order_price = ask + 滑点) ──
|
||
if Config.dry_run:
|
||
if is_sell_all:
|
||
if not _virtual or not _virtual.position or _virtual.position["side"] != side:
|
||
return {"success": False, "message": "[DRY] 无持仓可卖"}
|
||
shares = _virtual.position["shares"]
|
||
ok, msg = _virtual.sell(order_price)[:2]
|
||
if ok:
|
||
pnl = _virtual.total_pnl
|
||
log_trade(f"[DRY] SELL ALL {side.upper()} {shares:.1f}股 @ {order_price:.2f} | 累计PnL: ${pnl:+.2f}")
|
||
return {"success": ok, "message": f"[DRY] {msg}", "price": order_price, "shares": shares}
|
||
else:
|
||
# shares = amount / order_price (含滑点), 余额检查也用含滑点价
|
||
shares = round(amount_usd / order_price, 2)
|
||
ok, msg = _virtual.buy(side, order_price, shares, amount_usd)
|
||
if ok:
|
||
log_trade(f"[DRY] BUY {side.upper()} {shares:.1f}股 @ {order_price:.2f} (${amount_usd})")
|
||
return {"success": ok, "message": f"[DRY] {msg}", "price": order_price, "shares": shares}
|
||
|
||
# ── 实盘下单 ──
|
||
if is_sell_all:
|
||
result = qt.execute_sell(side, "all")
|
||
else:
|
||
result = qt.execute_buy(side, amount_usd)
|
||
|
||
if result.get("success"):
|
||
# ── 执行验证: 轮询链上余额, 成交即返回 (50ms × 10 次, 共 500ms 上限) ──
|
||
# 替代原 time.sleep(0.5) + 查 1 次, 平均省 200-400ms, 最坏相同
|
||
w3 = qt.get_w3()
|
||
actual_bal = 0.0
|
||
verified = False
|
||
pre_bal = qt.get_token_balance(w3, token_id) # 下单前余额 (用于卖出验证)
|
||
for _ in range(10):
|
||
time.sleep(0.05)
|
||
actual_bal = qt.get_token_balance(w3, token_id)
|
||
if is_sell_all:
|
||
# 卖出验证: 余额相比下单前减少 (即 token 被消耗)
|
||
if actual_bal < pre_bal - 0.5:
|
||
verified = True
|
||
break
|
||
else:
|
||
# 买入验证: 余额 > 0 即成交
|
||
if actual_bal > 0:
|
||
verified = True
|
||
break
|
||
|
||
if not verified:
|
||
if not is_sell_all:
|
||
# 买入未成交: 可重试
|
||
if attempt < Config.max_retries:
|
||
log(f"买入后链上余额仍为 0, 第{attempt}次重试...", "WARN")
|
||
time.sleep(Config.retry_delay * attempt)
|
||
continue
|
||
log_trade(f"⚠️ 订单返回成功但链上余额为 0, 可能未成交", success=False)
|
||
else:
|
||
# 卖出未成交: 余额没变化, 警告但不重试 (避免重复下单把已成交的卖单当失败)
|
||
log_trade(f"⚠️ 卖出订单返回成功但链上余额未变 (pre={pre_bal:.1f} post={actual_bal:.1f}), 可能未成交", success=False)
|
||
|
||
shares = result.get("shares", amount_usd / price)
|
||
log_trade(f"{'SELL ALL' if is_sell_all else 'BUY'} {side.upper()} | {result.get('message', '')} | {result.get('elapsed_ms', 0)}ms")
|
||
return {"success": True, "message": result.get("message", ""),
|
||
"price": price, "shares": shares, "order_id": result.get("order_id", "")}
|
||
|
||
# ── 下单失败: 判断是否可重试 ──
|
||
msg = result.get("message", "")
|
||
retryable = any(kw in msg.lower() for kw in ["timeout", "network", "connection", "reset", "refused", "5xx", "502", "503"])
|
||
if retryable and attempt < Config.max_retries:
|
||
delay = Config.retry_delay * (2 ** (attempt - 1))
|
||
log(f"下单失败 (可重试): {msg} | 第{attempt}/{Config.max_retries}次, {delay:.1f}s 后重试", "WARN")
|
||
time.sleep(delay)
|
||
continue
|
||
log_trade(f"下单失败: {msg}", success=False)
|
||
return {"success": False, "message": msg}
|
||
|
||
except Exception as e:
|
||
if attempt < Config.max_retries:
|
||
delay = Config.retry_delay * (2 ** (attempt - 1))
|
||
log(f"下单异常: {e} | 第{attempt}/{Config.max_retries}次, {delay:.1f}s 后重试", "WARN")
|
||
time.sleep(delay)
|
||
continue
|
||
log_trade(f"下单异常 (放弃): {e}", success=False)
|
||
return {"success": False, "message": str(e)}
|
||
|
||
return {"success": False, "message": f"重试 {Config.max_retries} 次后仍失败"}
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 差价交易信号分析 (趋势跟随策略)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
def analyze_signals(lb: dict, btc: float, strike: float) -> dict:
|
||
"""
|
||
差价交易信号分析 (趋势跟随 + 动量确认)
|
||
|
||
核心逻辑: BTC 5m 市场短期呈动量效应, 跟随趋势入场, 趋势延续时止盈
|
||
信号 1 (核心): BTC 位移 - BTC 偏离 strike 的方向 = 趋势方向
|
||
信号 2 (核心): BTC 加速度 - 价格变化率的变化, 加速时趋势更强
|
||
信号 3 (确认): 盘口压力 - orderbook 失衡方向与趋势一致
|
||
信号 4 (确认): 鲸鱼跟随 - 大户持仓方向 = 趋势方向 (跟随, 不反向)
|
||
|
||
正分 = 偏 UP, 负分 = 偏 DOWN
|
||
返回: {"score": int, "side": "up"/"down"/None, "details": [str], "signals": dict, "min_abs": int}
|
||
"""
|
||
score = 0
|
||
details = []
|
||
signals = {}
|
||
|
||
# ── 信号 1: BTC 位移 (核心, 权重最大) ──
|
||
# BTC 偏离 strike 的方向就是趋势方向, 偏离越大趋势越强
|
||
w_dev = Config.weight_btc_dev
|
||
if btc > 0 and strike > 0:
|
||
dev = btc - strike # 正=BTC上涨, 负=BTC下跌
|
||
if abs(dev) >= 10: # 至少偏离 $10 才算有趋势 (干跑日志显示锚定偏差 $10~16 几乎必有, 提高阈值过滤噪声)
|
||
# 映射: $10->1, $20->2, $30->3, $40+->4 (权重4封顶)
|
||
s = min(w_dev, int(abs(dev) / 10))
|
||
s = s if dev > 0 else -s
|
||
score += s
|
||
direction = "UP" if s > 0 else "DN"
|
||
sign = "+" if s > 0 else ""
|
||
signals["btc_dev"] = f"{direction} {sign}{s} (BTC ${dev:+.2f} vs 锚定)"
|
||
else:
|
||
signals["btc_dev"] = f"中性 (偏离 ${dev:+.2f} < $10, 无趋势)"
|
||
else:
|
||
signals["btc_dev"] = "无 BTC/锚定数据"
|
||
|
||
# ── 信号 2: BTC 加速度 (核心, 捕捉动量启动) ──
|
||
# velocity = 10s 内 BTC 价格变化率
|
||
# accel = velocity 的变化率, 正=加速上涨, 负=加速下跌
|
||
# 加速时趋势刚开始, 跟随价值最高
|
||
w_acc = Config.weight_btc_accel
|
||
velocity, accel = get_btc_velocity()
|
||
if abs(velocity) > 0.1: # 至少 $0.1/s 才算有动量
|
||
# velocity 映射: $0.1/s=1, $0.2/s=2, $0.3/s+=3 (权重3封顶)
|
||
s_v = min(w_acc, int(abs(velocity) / 0.1))
|
||
# 加速度同方向加成 (加速时给满分, 减速时减半)
|
||
if (velocity > 0 and accel >= 0) or (velocity < 0 and accel <= 0):
|
||
s = s_v # 加速, 满分
|
||
accel_desc = "加速"
|
||
else:
|
||
s = max(1, s_v // 2) # 减速, 半分
|
||
accel_desc = "减速"
|
||
s = s if velocity > 0 else -s
|
||
score += s
|
||
direction = "UP" if s > 0 else "DN"
|
||
sign = "+" if s > 0 else ""
|
||
signals["btc_accel"] = (f"{direction} {sign}{s} (v={velocity:+.3f}/s, "
|
||
f"a={accel:+.3f}/s², {accel_desc})")
|
||
else:
|
||
signals["btc_accel"] = f"中性 (v={velocity:+.3f}/s < 0.1, 无动量)"
|
||
|
||
# ── 信号 3: 盘口压力确认 (权重中等) ──
|
||
# orderbook bids 厚 = 买盘强, 跟随买盘方向
|
||
wb = Config.weight_book
|
||
up_book = _ws_state.get("up_book") or {"bids": [], "asks": []}
|
||
dn_book = _ws_state.get("down_book") or {"bids": [], "asks": []}
|
||
|
||
up_bid_d = sum(b.get("s", 0) for b in up_book.get("bids", []))
|
||
up_ask_d = sum(a.get("s", 0) for a in up_book.get("asks", []))
|
||
dn_bid_d = sum(b.get("s", 0) for b in dn_book.get("bids", []))
|
||
dn_ask_d = sum(a.get("s", 0) for a in dn_book.get("asks", []))
|
||
|
||
if (up_bid_d + up_ask_d) > 0 and (dn_bid_d + dn_ask_d) > 0:
|
||
# 归一化压力: 正 = UP 涨, 负 = DN 涨
|
||
up_pressure = (up_bid_d - up_ask_d) / max(up_bid_d + up_ask_d, 1)
|
||
dn_pressure = (dn_bid_d - dn_ask_d) / max(dn_bid_d + dn_ask_d, 1)
|
||
# 综合压力: up 买盘强 + dn 抛压强 = UP 涨
|
||
net_pressure = up_pressure - dn_pressure
|
||
|
||
if abs(net_pressure) > 0.2:
|
||
s = min(wb, int(abs(net_pressure) * 5))
|
||
s = s if net_pressure > 0 else -s
|
||
score += s
|
||
direction = "UP" if s > 0 else "DN"
|
||
sign = "+" if s > 0 else ""
|
||
signals["book"] = (f"{direction} {sign}{s} "
|
||
f"(UP bid/ask={up_bid_d:.0f}/{up_ask_d:.0f}, "
|
||
f"DN bid/ask={dn_bid_d:.0f}/{dn_ask_d:.0f})")
|
||
else:
|
||
signals["book"] = (f"中性 (UP bid/ask={up_bid_d:.0f}/{up_ask_d:.0f}, "
|
||
f"DN bid/ask={dn_bid_d:.0f}/{dn_ask_d:.0f})")
|
||
else:
|
||
signals["book"] = "无盘口深度数据"
|
||
|
||
# ── 信号 4: 连赢鲸鱼 (🔥 streak=True) 动态信号 (核心 alpha) ──
|
||
# 只追踪当前 3 连胜的鲸鱼, 忽略 curated 列表
|
||
# 两个维度: (a) 当前集中度 (b) 跨回合翻向 (预言信号)
|
||
ws = Config.weight_streak_leader
|
||
cur_sides = {}
|
||
for e in lb.get("up", []):
|
||
if e.get("streak") and not e.get("hedged"):
|
||
cur_sides[e["addr"]] = "up"
|
||
for e in lb.get("down", []):
|
||
if e.get("streak") and not e.get("hedged"):
|
||
cur_sides[e["addr"]] = "down"
|
||
|
||
cur_up_n = sum(1 for v in cur_sides.values() if v == "up")
|
||
cur_dn_n = sum(1 for v in cur_sides.values() if v == "down")
|
||
cur_total = cur_up_n + cur_dn_n
|
||
|
||
if cur_total >= 2:
|
||
# (a) 当前集中度 (核心)
|
||
concentration = (cur_up_n - cur_dn_n) / cur_total
|
||
|
||
# (b) 上轮赢家方向 (新: 加入 combined 投票, 历史回测显示 winners_concentration 单独也有 86% 胜率)
|
||
winners_data = lb.get("winners") or {}
|
||
w_up_n = winners_data.get("up_n", 0)
|
||
w_dn_n = winners_data.get("dn_n", 0)
|
||
w_total = w_up_n + w_dn_n
|
||
if w_total >= 2:
|
||
winners_concentration = (w_up_n - w_dn_n) / w_total
|
||
else:
|
||
winners_concentration = 0.0
|
||
|
||
# (c) 集合变化: 新加 streak whales 的方向 vs 离开的
|
||
if _streak_snapshot.get("sides"):
|
||
prev_sides = _streak_snapshot["sides"]
|
||
left_addrs = set(prev_sides.keys()) - set(cur_sides.keys())
|
||
left_up = sum(1 for a in left_addrs if prev_sides.get(a) == "up")
|
||
left_dn = sum(1 for a in left_addrs if prev_sides.get(a) == "down")
|
||
new_addrs = set(cur_sides.keys()) - set(prev_sides.keys())
|
||
new_up = sum(1 for a in new_addrs if cur_sides.get(a) == "up")
|
||
new_dn = sum(1 for a in new_addrs if cur_sides.get(a) == "down")
|
||
net_set_change = (new_up - new_dn) - (left_up - left_dn)
|
||
set_change_score = 0.0
|
||
if abs(net_set_change) > 0:
|
||
set_change_score = net_set_change / max(new_up + new_dn + left_up + left_dn, 1)
|
||
else:
|
||
set_change_score = 0.0
|
||
new_up = new_dn = left_up = left_dn = 0
|
||
|
||
# 综合: 加权投票 streak(2) + winners(1), 各按样本量比例加权
|
||
# 历史回测: combined 模式 (threshold=0.15) 胜率 88.1%, avg/trade +$0.14
|
||
# 公式对齐 backtest: 2*streak_conc*share + 1*winners_conc*share
|
||
# streak 仅有时: 2*streak_conc (max 2)
|
||
# winners 仅有时: 1*winners_conc (max 1)
|
||
total_sample = cur_total + w_total
|
||
if total_sample > 0:
|
||
vote_score = (2.0 * concentration * (cur_total / total_sample)
|
||
+ 1.0 * winners_concentration * (w_total / total_sample))
|
||
else:
|
||
vote_score = 0.0
|
||
|
||
# 直接用 vote_score 作为 combined (历史回测对齐)
|
||
# backtest: combined threshold=0.30 → 91.4% 胜率, 420 trades, +$66 PnL
|
||
combined = vote_score
|
||
|
||
if abs(combined) >= Config.streak_combined_threshold:
|
||
s = min(ws, max(1, int(abs(combined) * ws * 1.4)))
|
||
s = s if combined > 0 else -s
|
||
score += s
|
||
direction = "UP" if s > 0 else "DN"
|
||
sign = "+" if s > 0 else ""
|
||
signals["streak_leader"] = (
|
||
f"{direction} {sign}{s} (🔥 {cur_up_n}U/{cur_dn_n}D, "
|
||
f"上轮赢家 {w_up_n}U/{w_dn_n}D, 综合 {combined:+.0%})"
|
||
)
|
||
else:
|
||
signals["streak_leader"] = (
|
||
f"中性 (🔥 {cur_up_n}U/{cur_dn_n}D, "
|
||
f"上轮赢家 {w_up_n}U/{w_dn_n}D, 综合 {combined:+.0%})"
|
||
)
|
||
else:
|
||
signals["streak_leader"] = f"无连赢鲸鱼 ({cur_total} 🔥)"
|
||
|
||
# ── 汇总 ──
|
||
max_score = (Config.weight_btc_dev + Config.weight_btc_accel
|
||
+ Config.weight_book + Config.weight_whale_follow + Config.weight_winners
|
||
+ Config.weight_streak_leader)
|
||
min_abs = max(1, round(max_score * Config.min_score_ratio))
|
||
side = "up" if score >= min_abs else "down" if score <= -min_abs else None
|
||
for k, v in signals.items():
|
||
details.append(f" [{k}] {v}")
|
||
|
||
return {"score": score, "side": side, "details": details, "signals": signals,
|
||
"min_abs": min_abs, "max_score": max_score,
|
||
"streak_up": cur_up_n, "streak_dn": cur_dn_n,
|
||
"streak_concentration": concentration if cur_total >= 2 else 0.0}
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 策略状态机
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
class BotState:
|
||
IDLE = "IDLE"
|
||
WAITING_ENTRY = "WAITING_ENTRY"
|
||
IN_POSITION = "IN_POSITION"
|
||
|
||
|
||
_bot_state = BotState.IDLE
|
||
_position_info = None # {"side", "entry_score", "entry_ts", "shares"}
|
||
_last_exit_ts = 0.0 # 上次平仓时间戳, 用于 reentry_cooldown 判断
|
||
|
||
# 连赢鲸鱼 (streak=True) 的跨回合分布追踪 — 用于"动态变化"信号
|
||
# 每回合开局时快照 {addr: "up"/"down"}; 信号基于"翻了 vs 上回合"
|
||
_streak_snapshot: dict = {} # {"round_ts": int, "sides": {addr: "up"/"down"}}
|
||
_streak_snapshot_round_ts: int = 0 # 已快照的回合 ts
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 获取回合信息
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
def get_round_info(mkt_key: str) -> dict:
|
||
"""从 WS 缓存读回合信息 (无 HTTP, 0ms)"""
|
||
mkt = qt.MARKETS[mkt_key]
|
||
duration = mkt["duration"]
|
||
now = int(time.time())
|
||
round_ts = now - (now % duration)
|
||
round_end = round_ts + duration
|
||
|
||
token_ids = ws_get("token_ids", {})
|
||
strike = ws_get("strike", 0)
|
||
ws_round = ws_get("round_ts", 0)
|
||
|
||
# WS 数据落后: 自己拉
|
||
if ws_round != round_ts or not token_ids:
|
||
token_ids = qt.get_round_token_ids(round_ts, mkt["slug_prefix"], mkt.get("slug_type", ""))
|
||
if strike <= 0:
|
||
strike, _ = qt.get_strike_price(round_ts, duration, mkt["symbol"], mkt["variant"])
|
||
|
||
return {"round_ts": round_ts, "round_end": round_end, "token_ids": token_ids, "strike": strike}
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 综合市场状态捕获 (entry/exit/snapshot 时调用, 用于回测)
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
def capture_market_state(round_ts: int = 0, strike: float = 0.0,
|
||
remaining: float = 0.0, side: str = "") -> dict:
|
||
"""一次性捕获所有维度, 供 entry/exit/snapshot 写入 jsonl"""
|
||
btc = get_btc_price()
|
||
velocity, accel = get_btc_velocity()
|
||
up_bid, up_ask = get_cached_price("up")
|
||
dn_bid, dn_ask = get_cached_price("down")
|
||
|
||
# 盘口深度 (bids/asks 总量)
|
||
up_book = _ws_state.get("up_book") or {"bids": [], "asks": []}
|
||
dn_book = _ws_state.get("dn_book") or {"bids": [], "asks": []}
|
||
up_bid_depth = sum(b.get("s", 0) for b in up_book.get("bids", []))
|
||
up_ask_depth = sum(a.get("s", 0) for a in up_book.get("asks", []))
|
||
dn_bid_depth = sum(b.get("s", 0) for b in dn_book.get("bids", []))
|
||
dn_ask_depth = sum(a.get("s", 0) for a in dn_book.get("asks", []))
|
||
total_depth = up_bid_depth + up_ask_depth + dn_bid_depth + dn_ask_depth
|
||
up_depth_share = (up_bid_depth + up_ask_depth) / total_depth if total_depth else 0.5
|
||
|
||
# 🔥 streak whales (核心 alpha)
|
||
lb = _ws_state.get("leaderboard") or {}
|
||
up_entries = [e for e in lb.get("up", []) if e.get("streak") and not e.get("hedged")]
|
||
dn_entries = [e for e in lb.get("down", []) if e.get("streak") and not e.get("hedged")]
|
||
streak_up_n = len(up_entries)
|
||
streak_dn_n = len(dn_entries)
|
||
streak_up_shares = sum(e.get("shares", 0) for e in up_entries)
|
||
streak_dn_shares = sum(e.get("shares", 0) for e in dn_entries)
|
||
|
||
# 上轮赢家
|
||
winners = lb.get("winners") or {}
|
||
|
||
# 整体鲸鱼分布 (非 streak)
|
||
all_whale_up = sum(e.get("shares", 0) for e in lb.get("up", []) if e.get("whale"))
|
||
all_whale_dn = sum(e.get("shares", 0) for e in lb.get("down", []) if e.get("whale"))
|
||
|
||
# round timing
|
||
now = time.time()
|
||
elapsed = (now - round_ts) if round_ts else 0
|
||
|
||
state = {
|
||
"ts": now,
|
||
"btc": round(btc, 2),
|
||
"strike": round(strike, 2),
|
||
"btc_dev": round(btc - strike, 2) if strike > 0 else 0,
|
||
"btc_dev_pct": round((btc - strike) / strike * 100, 4) if strike > 0 else 0,
|
||
"btc_velocity": round(velocity, 4),
|
||
"btc_accel": round(accel, 4),
|
||
"up_bid": up_bid, "up_ask": up_ask,
|
||
"dn_bid": dn_bid, "dn_ask": dn_ask,
|
||
"up_spread": round(up_ask - up_bid, 4) if up_ask and up_bid else 0,
|
||
"dn_spread": round(dn_ask - dn_bid, 4) if dn_ask and dn_bid else 0,
|
||
"up_bid_depth": round(up_bid_depth, 0),
|
||
"up_ask_depth": round(up_ask_depth, 0),
|
||
"dn_bid_depth": round(dn_bid_depth, 0),
|
||
"dn_ask_depth": round(dn_ask_depth, 0),
|
||
"book_imbalance": round((up_bid_depth - up_ask_depth) - (dn_bid_depth - dn_ask_depth), 1),
|
||
"up_depth_share": round(up_depth_share, 3),
|
||
"streak_up_n": streak_up_n,
|
||
"streak_dn_n": streak_dn_n,
|
||
"streak_up_shares": round(streak_up_shares, 0),
|
||
"streak_dn_shares": round(streak_dn_shares, 0),
|
||
"streak_concentration": round(
|
||
(streak_up_n - streak_dn_n) / max(streak_up_n + streak_dn_n, 1), 3),
|
||
"streak4_total": (_ws_state.get("streak4") or {}).get("total", 0),
|
||
"streak4_up": (_ws_state.get("streak4") or {}).get("up", 0),
|
||
"streak4_dn": (_ws_state.get("streak4") or {}).get("dn", 0),
|
||
"streak_up_total_field": lb.get("streak_up", 0),
|
||
"streak_dn_total_field": lb.get("streak_dn", 0),
|
||
"all_whale_up_shares": round(all_whale_up, 0),
|
||
"all_whale_dn_shares": round(all_whale_dn, 0),
|
||
"winners_total": winners.get("total", 0),
|
||
"winners_up_n": winners.get("up_n", 0),
|
||
"winners_dn_n": winners.get("dn_n", 0),
|
||
"winners_up_s": round(winners.get("up_s", 0), 0),
|
||
"winners_dn_s": round(winners.get("dn_s", 0), 0),
|
||
"winners_side": winners.get("side", ""),
|
||
"hedged_count": lb.get("hedged_count", 0),
|
||
"up_total_shares": lb.get("up_total", 0),
|
||
"dn_total_shares": lb.get("dn_total", 0),
|
||
"up_count": lb.get("up_count", 0),
|
||
"dn_count": lb.get("dn_count", 0),
|
||
"round_ts": round_ts,
|
||
"round_elapsed": round(elapsed, 1),
|
||
"round_remaining": round(remaining, 1),
|
||
"side": side,
|
||
}
|
||
return state
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 主策略循环
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
def run_bot():
|
||
global _bot_state, _position_info, _last_exit_ts, _streak_snapshot, _streak_snapshot_round_ts
|
||
|
||
mkt = qt.MARKETS[Config.market]
|
||
log(f"🤖 差价交易机器人启动 | 市场: {mkt['label']} | 模式: {'DRY RUN' if Config.dry_run else '实盘'}")
|
||
max_score = (Config.weight_btc_dev + Config.weight_btc_accel
|
||
+ Config.weight_book + Config.weight_whale_follow)
|
||
min_abs = max(1, round(max_score * Config.min_score_ratio))
|
||
log(f" 策略: 跟随 🔥 连赢鲸鱼 + 上轮赢家 综合信号 (backtest 验证) | 金额: ${Config.amount}")
|
||
log(f" 权重: BTC位移{Config.weight_btc_dev}/BTC加速{Config.weight_btc_accel}/盘口{Config.weight_book}/鲸鱼跟{Config.weight_whale_follow}/上轮赢家{Config.weight_winners}/连赢鲸鱼{Config.weight_streak_leader} | 最低入场: {min_abs}/{max_score} ({Config.min_score_ratio*100:.0f}%)")
|
||
ed = f"{Config.exit_deadline}s" if Config.exit_deadline > 0 else "禁用"
|
||
mhd = f"{Config.max_hold_time}s" if Config.max_hold_time > 0 else "禁用"
|
||
cd = f"{Config.reentry_cooldown}s" if Config.reentry_cooldown > 0 else "禁用"
|
||
log(f" 入场延迟: {Config.entry_delay}s | 持仓上限: {mhd} | 强制平仓: {ed} | 平仓冷却: {cd}")
|
||
log(f" 止盈: {Config.take_profit_pct*100:.0f}pct | 止损: {Config.stop_loss_pct*100:.0f}pct (宽限 {Config.stop_loss_grace}s)")
|
||
log(f" 价格上限: {Config.max_price:.2f} | 买入滑点: {Config.buy_slippage:.2f} | 卖出滑点: {Config.sell_slippage:.2f} | 价差上限: {Config.max_spread:.3f}")
|
||
log(f" ⚠️ 需 onchain_leaderboard.py 在运行 (提供盘口+鲸鱼持仓数据)")
|
||
|
||
if Config.dry_run:
|
||
log(f" 虚拟初始余额: ${Config.virtual_balance:.2f}")
|
||
else:
|
||
if not qt.ACCOUNTS:
|
||
log("❌ 未配置账号, 请在 .env 中设置 QUICK_PRIVATE_KEY 和 QUICK_FUNDER", "ERROR")
|
||
return
|
||
log(f" 交易账号: {qt.ACCOUNTS[0]['label']}")
|
||
|
||
last_round_ts = 0
|
||
lb_warned = False
|
||
_last_signal_log = 0
|
||
_last_pos_log = 0
|
||
_last_wait_log = 0
|
||
_last_price_skip_log = 0
|
||
|
||
while True:
|
||
try:
|
||
info = get_round_info(Config.market)
|
||
round_ts = info["round_ts"]
|
||
round_end = info["round_end"]
|
||
token_ids = info["token_ids"]
|
||
strike = info["strike"]
|
||
now = time.time()
|
||
remaining = round_end - now
|
||
btc = get_btc_price()
|
||
sample_btc_price(btc) # 采样 BTC 价格用于计算加速度
|
||
|
||
# ── 新回合检测 ──
|
||
if round_ts != last_round_ts:
|
||
if token_ids and strike > 0:
|
||
last_round_ts = round_ts
|
||
_bot_state = BotState.WAITING_ENTRY
|
||
_position_info = None
|
||
_last_exit_ts = 0.0 # 新回合重置冷却状态 (本回合还没平仓过)
|
||
lb_warned = False
|
||
rt = datetime.fromtimestamp(round_ts).strftime("%H:%M:%S")
|
||
log(f"{'─'*60}")
|
||
log(f"🔄 新回合 {rt} | 锚定: ${strike:,.2f} | BTC: ${btc:,.2f} | 剩余: {remaining:.0f}s")
|
||
else:
|
||
time.sleep(2)
|
||
continue
|
||
|
||
# ── 持仓中: 监控退出条件 (200ms 高频, 无 HTTP) ──
|
||
if _bot_state == BotState.IN_POSITION and _position_info:
|
||
pos = _position_info
|
||
side = pos["side"]
|
||
entry_price = pos["entry_price"]
|
||
|
||
# 从 WS 缓存读当前 bid (无 HTTP, 0ms)
|
||
current_bid, _ = get_cached_price(side)
|
||
|
||
# WS 数据陈旧检测 (>5s 无更新, 警告)
|
||
ws_age = time.time() - _ws_state.get("ts", 0)
|
||
if ws_age > 5 and current_bid > 0:
|
||
log(f"⚠️ WS 数据陈旧 {ws_age:.0f}s, 价格可能不准", "WARN")
|
||
|
||
# 退出条件 1: 止盈 (考虑卖出滑点, 确保实际成交价仍有盈利)
|
||
if Config.take_profit_pct > 0 and current_bid > 0 and entry_price > 0:
|
||
actual_exit = current_bid - Config.sell_slippage
|
||
pnl_pct = (actual_exit - entry_price) / entry_price
|
||
if pnl_pct >= Config.take_profit_pct:
|
||
log(f"💰 止盈 (token {entry_price:.2f} -> {current_bid:.2f}, 实际成交 {actual_exit:.2f}, {pnl_pct*100:+.0f}pct)")
|
||
_do_exit(side, "take_profit")
|
||
continue
|
||
# 退出条件 2: 止损 (实际成交价 = bid - SELL_SLIPPAGE)
|
||
# 入场后宽限期内不检查止损 (避免瞬时滑点/盘口抖动误触发)
|
||
# 注意: 止盈不受宽限期影响 (有利润就该锁)
|
||
held_seconds = time.time() - pos.get("entry_ts", time.time())
|
||
if (Config.stop_loss_pct > 0 and current_bid > 0 and entry_price > 0
|
||
and held_seconds >= Config.stop_loss_grace):
|
||
actual_exit = current_bid - Config.sell_slippage
|
||
pnl_pct = (actual_exit - entry_price) / entry_price
|
||
if pnl_pct <= -Config.stop_loss_pct:
|
||
log(f"🛑 止损 (token {entry_price:.2f} -> {current_bid:.2f}, 实际成交 {actual_exit:.2f}, {pnl_pct*100:.0f}pct, 持仓{held_seconds:.0f}s)")
|
||
_do_exit(side, "stop_loss")
|
||
continue
|
||
|
||
# 退出条件 3: 持仓超过最大时长 (防止一直拿到结算)
|
||
if Config.max_hold_time > 0:
|
||
held_seconds = time.time() - pos.get("entry_ts", time.time())
|
||
if held_seconds >= Config.max_hold_time:
|
||
log(f"⏳ 持仓超时 ({held_seconds:.0f}s >= {Config.max_hold_time}s), 强制平仓")
|
||
_do_exit(side, "max_hold_time")
|
||
continue
|
||
|
||
# 退出条件 4: 时间到
|
||
if Config.exit_deadline > 0 and remaining <= Config.exit_deadline:
|
||
log(f"⏰ 时间到, 强制平仓 | 剩余 {remaining:.0f}s")
|
||
_do_exit(side, "exit_deadline")
|
||
continue
|
||
|
||
# 持仓中追踪极值 (每 200ms, 不阻塞退出检查)
|
||
if current_bid > 0 and entry_price > 0:
|
||
cur_pnl_pct = (current_bid - entry_price) / entry_price * 100
|
||
if cur_pnl_pct > pos.get("peak_pnl_pct", float("-inf")):
|
||
pos["peak_pnl_pct"] = cur_pnl_pct
|
||
if cur_pnl_pct < pos.get("trough_pnl_pct", float("inf")):
|
||
pos["trough_pnl_pct"] = cur_pnl_pct
|
||
if current_bid > pos.get("max_bid", 0):
|
||
pos["max_bid"] = current_bid
|
||
if current_bid < pos.get("min_bid", 999) or pos.get("min_bid", 999) == 0:
|
||
pos["min_bid"] = current_bid
|
||
|
||
# 状态打印 (每 2s 一次, 不阻塞退出检查)
|
||
if int(now) >= _last_pos_log + 2:
|
||
_last_pos_log = int(now)
|
||
pnl_str = ""
|
||
if current_bid > 0 and entry_price > 0:
|
||
pnl_pct = (current_bid - entry_price) / entry_price * 100
|
||
pnl_str = f" | token {entry_price:.2f}->{current_bid:.2f} ({pnl_pct:+.0f}pct)"
|
||
log(f"📊 持仓 {side.upper()} | BTC ${btc:,.2f} vs 锚定 ${strike:,.2f} | 剩余 {remaining:.0f}s{pnl_str}", "DATA")
|
||
# 每 5 秒记录一次快照 (用于回测)
|
||
if int(now) % 5 == 0:
|
||
rec("snapshot", round_ts=round_ts, remaining=remaining, strike=strike,
|
||
btc=btc, bid=current_bid, side=side, entry_price=entry_price,
|
||
state=capture_market_state(round_ts, strike, remaining, side),
|
||
peak_pnl_pct=pos.get("peak_pnl_pct", 0.0),
|
||
trough_pnl_pct=pos.get("trough_pnl_pct", 0.0))
|
||
|
||
time.sleep(0.2) # 200ms 高频监控
|
||
continue
|
||
|
||
# ── 等待入场 (高频, 无 HTTP) ──
|
||
if _bot_state == BotState.WAITING_ENTRY:
|
||
if not token_ids or strike <= 0:
|
||
time.sleep(0.3)
|
||
continue
|
||
|
||
elapsed = now - round_ts
|
||
|
||
# 等待入场延迟 (仅回合开头生效: 若已平仓过, _last_exit_ts > 0, 不再等 entry_delay)
|
||
if _last_exit_ts == 0 and elapsed < Config.entry_delay:
|
||
if int(now) >= _last_wait_log + 5:
|
||
_last_wait_log = int(now)
|
||
log(f"⏳ 等待大户建仓 ({Config.entry_delay-elapsed:.0f}s) | BTC ${btc:,.2f}", "DATA")
|
||
time.sleep(0.3)
|
||
continue
|
||
|
||
# 平仓后冷却 (避免同回合过度交易)
|
||
if _last_exit_ts > 0:
|
||
cooldown_remaining = Config.reentry_cooldown - (now - _last_exit_ts)
|
||
if cooldown_remaining > 0:
|
||
if int(now) >= _last_wait_log + 5:
|
||
_last_wait_log = int(now)
|
||
log(f"🧊 平仓冷却中 ({cooldown_remaining:.0f}s) | BTC ${btc:,.2f}", "DATA")
|
||
time.sleep(0.3)
|
||
continue
|
||
|
||
# 剩余时间不够持有 → 跳过 (避免回合末成交, 止盈/止损无法触发)
|
||
if remaining < Config.min_hold_time:
|
||
if int(now) >= _last_wait_log + 5:
|
||
_last_wait_log = int(now)
|
||
log(f"⏭️ 剩余时间 {remaining:.0f}s < {Config.min_hold_time}s, 跳过本回合", "DATA")
|
||
if Config.exit_deadline > 0:
|
||
_bot_state = BotState.IDLE
|
||
time.sleep(2)
|
||
continue
|
||
|
||
# 读取链上情报
|
||
lb = read_leaderboard()
|
||
if not lb:
|
||
if not lb_warned:
|
||
log("⚠️ 无排行榜数据 (onchain_leaderboard 未运行?)", "WARN")
|
||
lb_warned = True
|
||
if Config.exit_deadline > 0 and remaining <= Config.exit_deadline:
|
||
log("⏭️ 无信号数据, 跳过本回合")
|
||
_bot_state = BotState.IDLE
|
||
time.sleep(2)
|
||
continue
|
||
|
||
lb_warned = False
|
||
|
||
# 快照本回合的 🔥 鲸鱼分布 (用于下一回合的"翻向"信号)
|
||
# 一次性捕获 (entry_delay 后, 此时鲸鱼已建仓)
|
||
if _streak_snapshot_round_ts != round_ts:
|
||
snap_sides = {}
|
||
for e in lb.get("up", []):
|
||
if e.get("streak") and not e.get("hedged"):
|
||
snap_sides[e["addr"]] = "up"
|
||
for e in lb.get("down", []):
|
||
if e.get("streak") and not e.get("hedged"):
|
||
snap_sides[e["addr"]] = "down"
|
||
_streak_snapshot = {"round_ts": round_ts, "sides": snap_sides}
|
||
_streak_snapshot_round_ts = round_ts
|
||
if snap_sides:
|
||
log(f"📸 🔥快照 r={datetime.fromtimestamp(round_ts).strftime('%H:%M:%S')}: "
|
||
f"{sum(1 for v in snap_sides.values() if v == 'up')}UP/"
|
||
f"{sum(1 for v in snap_sides.values() if v == 'down')}DN", "DATA")
|
||
|
||
analysis = analyze_signals(lb, btc, strike)
|
||
score = analysis["score"]
|
||
side = analysis["side"]
|
||
|
||
# 已有持仓时不再分析新信号 (避免日志刷屏)
|
||
if _position_info:
|
||
time.sleep(2)
|
||
continue
|
||
|
||
if side:
|
||
# 价格过滤: 入场价(含滑点)高于阈值则不下单
|
||
# 持续监控: 价格降下来后会自动下单
|
||
bid, ask = get_cached_price(side)
|
||
order_price = round(min(ask + Config.buy_slippage, 0.99), 2) if ask > 0 else 0
|
||
if Config.max_price < 1.0 and order_price > Config.max_price:
|
||
if int(now) >= _last_price_skip_log + 5:
|
||
_last_price_skip_log = int(now)
|
||
log(f"⏸️ 价格过高跳过: {side.upper()} 入场价{order_price:.2f} > 阈值 {Config.max_price:.2f}", "DATA")
|
||
time.sleep(2)
|
||
continue
|
||
|
||
# 盘口价差过滤: bid-ask 价差过大 = 流动性差, 入场即被套
|
||
# 双边滑点 + 大价差 = 入场就亏损, 必须过滤
|
||
if bid > 0 and ask > 0:
|
||
spread = ask - bid
|
||
if spread > Config.max_spread:
|
||
if int(now) >= _last_price_skip_log + 5:
|
||
_last_price_skip_log = int(now)
|
||
log(f"⏸️ 价差过大跳过: {side.upper()} spread={spread:.3f} > 阈值 {Config.max_spread:.3f} (bid={bid:.2f} ask={ask:.2f})", "DATA")
|
||
time.sleep(2)
|
||
continue
|
||
|
||
log(f"📡 入场信号: {side.upper()} (评分 {score:+d}/{analysis['max_score']}, 需 ±{analysis['min_abs']})")
|
||
for d in analysis["details"]:
|
||
log(d, "DATA")
|
||
|
||
result = place_order(side, Config.amount, is_sell_all=False)
|
||
if result.get("success"):
|
||
_bot_state = BotState.IN_POSITION
|
||
entry_price = result.get("price", 0)
|
||
entry_shares = result.get("shares", 0)
|
||
_position_info = {
|
||
"side": side,
|
||
"entry_price": entry_price,
|
||
"entry_score": score,
|
||
"entry_ts": now,
|
||
"shares": entry_shares,
|
||
"entry_signal": side.upper(),
|
||
"entry_signals": analysis["signals"],
|
||
"peak_pnl_pct": 0.0,
|
||
"trough_pnl_pct": 0.0,
|
||
"max_bid": entry_price,
|
||
"min_bid": entry_price,
|
||
"entry_streak_concentration": analysis.get("streak_concentration", 0.0),
|
||
}
|
||
rec("entry", round_ts=round_ts, strike=strike, btc=btc,
|
||
side=side, score=score, signals=analysis["signals"],
|
||
price=entry_price, shares=entry_shares, amount=Config.amount,
|
||
streak_up=analysis.get("streak_up", 0),
|
||
streak_dn=analysis.get("streak_dn", 0),
|
||
streak_concentration=analysis.get("streak_concentration", 0.0),
|
||
state=capture_market_state(round_ts, strike, remaining, side),
|
||
virtual_balance=round(_virtual.balance, 2) if _virtual else 0)
|
||
log(f"✅ 已入场 {side.upper()} | {entry_shares:.1f}股 @ {entry_price:.2f}")
|
||
else:
|
||
log(f"❌ 入场失败: {result.get('message', '')}", "WARN")
|
||
if Config.exit_deadline <= 0 or remaining > Config.exit_deadline + 10:
|
||
time.sleep(2)
|
||
else:
|
||
_bot_state = BotState.IDLE
|
||
else:
|
||
if int(now) >= _last_signal_log + 10:
|
||
_last_signal_log = int(now)
|
||
log(f"🔍 信号不足 (评分 {score:+d}, 需 ±{analysis['min_abs']}) | 剩余 {remaining:.0f}s", "DATA")
|
||
if Config.exit_deadline > 0 and remaining <= Config.exit_deadline:
|
||
log(f"⏭️ 信号不足, 跳过本回合 (评分 {score:+d})")
|
||
_bot_state = BotState.IDLE
|
||
|
||
time.sleep(0.5)
|
||
continue
|
||
|
||
# ── IDLE ──
|
||
time.sleep(2)
|
||
|
||
except KeyboardInterrupt:
|
||
raise
|
||
except Exception as e:
|
||
log(f"主循环异常: {e}", "ERROR")
|
||
traceback.print_exc()
|
||
time.sleep(5)
|
||
|
||
|
||
def _do_exit(side: str, reason: str = ""):
|
||
"""执行平仓 + 重置状态 (带即时重试, 无 sleep)
|
||
|
||
平仓后回到 WAITING_ENTRY (而非 IDLE), 允许同回合再次入场, 但需等 reentry_cooldown 秒
|
||
"""
|
||
global _bot_state, _position_info, _last_exit_ts
|
||
entry = _position_info or {}
|
||
for i in range(Config.max_retries + 1):
|
||
result = place_order(side, 0, is_sell_all=True)
|
||
if result.get("success"):
|
||
if i > 0:
|
||
log_trade(f"重试平仓成功 {side.upper()} (第{i}次)")
|
||
else:
|
||
log_trade(f"已平仓 {side.upper()}")
|
||
break
|
||
if i < Config.max_retries:
|
||
log_trade(f"平仓失败: {result.get('message', '')} (重试 {i+1}/{Config.max_retries})", success=False)
|
||
|
||
_bot_state = BotState.WAITING_ENTRY
|
||
_position_info = None
|
||
_last_exit_ts = time.time()
|
||
|
||
# 记录 (dry-run 回测数据)
|
||
entry_price = entry.get("entry_price", 0)
|
||
entry_shares = entry.get("shares", 0)
|
||
exit_price = result.get("price", 0) if result and result.get("success") else 0
|
||
if entry_price > 0 and entry_shares > 0 and exit_price > 0:
|
||
cost = entry_shares * entry_price
|
||
proceeds = entry_shares * exit_price
|
||
pnl = proceeds - cost
|
||
pct = (exit_price - entry_price) / entry_price * 100
|
||
rec("exit", reason=reason, entry_price=entry_price, exit_price=exit_price,
|
||
shares=entry_shares, pnl=round(pnl, 2), pnl_pct=round(pct, 1),
|
||
hold_seconds=round(time.time() - entry.get("entry_ts", time.time()), 1),
|
||
btc=get_btc_price(),
|
||
strike=ws_get("strike", 0),
|
||
peak_pnl_pct=entry.get("peak_pnl_pct", 0.0),
|
||
trough_pnl_pct=entry.get("trough_pnl_pct", 0.0),
|
||
max_bid=entry.get("max_bid", entry_price),
|
||
min_bid=entry.get("min_bid", exit_price),
|
||
state=capture_market_state(ws_get("round_ts", 0), ws_get("strike", 0), 0, side),
|
||
entry_signal=entry.get("entry_signal", ""),
|
||
entry_signals=entry.get("entry_signals", {}))
|
||
|
||
# 计算单笔 P&L (实盘)
|
||
if not Config.dry_run:
|
||
entry_price = entry.get("entry_price", 0)
|
||
entry_shares = entry.get("shares", 0)
|
||
exit_price = result.get("price", 0) if result and result.get("success") else 0
|
||
if entry_price > 0 and entry_shares > 0 and exit_price > 0:
|
||
cost = entry_shares * entry_price
|
||
proceeds = entry_shares * exit_price
|
||
pnl = proceeds - cost
|
||
record_live_trade(pnl)
|
||
pct = (exit_price - entry_price) / entry_price * 100
|
||
log_trade(f"本笔 PnL: ${pnl:+.2f} ({pct:+.1f}%) | 累计: ${_live_stats['total_pnl']:+.2f} | {_live_stats['wins']}胜{_live_stats['losses']}负")
|
||
|
||
if Config.dry_run and _virtual:
|
||
s = _virtual.status()
|
||
log(f"💼 虚拟账户 | 余额: ${s['balance']:.2f} | 累计PnL: ${s['total_pnl']:+.2f} | 交易: {s['trades']}笔")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# CLI 入口
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="BTC 5m 差价交易机器人 (趋势跟随策略)")
|
||
parser.add_argument("--live", action="store_true", help="实盘交易 (默认 dry run)")
|
||
parser.add_argument("--amount", type=float, default=None, help="每笔金额 (USD)")
|
||
parser.add_argument("--market", type=str, default="btc_5m", help="市场 (btc_5m/btc_15m/eth_5m...)")
|
||
parser.add_argument("--min-score-ratio", type=float, default=None, help="最低入场信号分占满分比例 (0.5=50pct, 范围 0~1)")
|
||
parser.add_argument("--take-profit", type=float, default=None, help="止盈: token涨幅 (0.10=10pct, -1=禁用)")
|
||
parser.add_argument("--stop-loss", type=float, default=None, help="止损: token跌幅 (0.15=15pct, -1=禁用)")
|
||
parser.add_argument("--exit-deadline", type=int, default=None, help="强制平仓倒计时 (秒, -1=禁用)")
|
||
parser.add_argument("--reentry-cooldown", type=int, default=None, help="平仓后冷却秒数 (同回合再次入场前等待, 0=立即, 默认10)")
|
||
parser.add_argument("--stop-loss-grace", type=int, default=None, help="入场后止损宽限期 (秒, 默认5, 期间不检查止损)")
|
||
parser.add_argument("--max-spread", type=float, default=None, help="入场前盘口价差上限 (0.05=5¢, 默认0.05)")
|
||
parser.add_argument("--weight-btc-dev", type=int, default=None, help="BTC 位移权重 (核心), 默认4")
|
||
parser.add_argument("--weight-btc-accel", type=int, default=None, help="BTC 加速度权重 (核心), 默认3")
|
||
parser.add_argument("--weight-book", type=int, default=None, help="盘口压力失衡权重, 默认2")
|
||
parser.add_argument("--weight-whale-follow", type=int, default=None, help="鲸鱼跟随权重 (curated 列表, 已弃用, 留作兼容), 默认0")
|
||
parser.add_argument("--weight-winners", type=int, default=None, help="上轮赢家方向权重, 默认2")
|
||
parser.add_argument("--weight-streak-leader", type=int, default=None, help="连赢鲸鱼 🔥 动态信号权重 (核心), 默认4")
|
||
parser.add_argument("--streak-threshold", type=float, default=None, help="combined 投票入场门槛 (backtest: 0.30-0.45), 默认0.30")
|
||
parser.add_argument("--max-price", type=float, default=None, help="token价格上限 (0.60=60¢, 1.0=禁用)")
|
||
parser.add_argument("--buy-slippage", type=float, default=None, help="买入滑点 (0.03=3¢)")
|
||
parser.add_argument("--sell-slippage", type=float, default=None, help="卖出滑点 (0.03=3¢)")
|
||
parser.add_argument("--balance", type=float, default=None, help="dry-run 初始余额")
|
||
args = parser.parse_args()
|
||
|
||
Config.dry_run = not args.live
|
||
if args.market is not None:
|
||
Config.market = args.market
|
||
if args.amount is not None:
|
||
Config.amount = args.amount
|
||
if args.min_score_ratio is not None:
|
||
Config.min_score_ratio = max(0.0, min(1.0, args.min_score_ratio))
|
||
if args.take_profit is not None:
|
||
Config.take_profit_pct = args.take_profit
|
||
if args.stop_loss is not None:
|
||
Config.stop_loss_pct = args.stop_loss
|
||
if args.exit_deadline is not None:
|
||
Config.exit_deadline = args.exit_deadline
|
||
if args.reentry_cooldown is not None:
|
||
Config.reentry_cooldown = args.reentry_cooldown
|
||
if args.stop_loss_grace is not None:
|
||
Config.stop_loss_grace = args.stop_loss_grace
|
||
if args.max_spread is not None:
|
||
Config.max_spread = args.max_spread
|
||
if args.weight_btc_dev is not None:
|
||
Config.weight_btc_dev = args.weight_btc_dev
|
||
if args.weight_btc_accel is not None:
|
||
Config.weight_btc_accel = args.weight_btc_accel
|
||
if args.weight_book is not None:
|
||
Config.weight_book = args.weight_book
|
||
if args.weight_whale_follow is not None:
|
||
Config.weight_whale_follow = args.weight_whale_follow
|
||
if args.weight_winners is not None:
|
||
Config.weight_winners = args.weight_winners
|
||
if args.weight_streak_leader is not None:
|
||
Config.weight_streak_leader = args.weight_streak_leader
|
||
if args.streak_threshold is not None:
|
||
Config.streak_combined_threshold = max(0.05, min(1.0, args.streak_threshold))
|
||
if args.max_price is not None:
|
||
Config.max_price = args.max_price
|
||
if args.buy_slippage is not None:
|
||
Config.buy_slippage = args.buy_slippage
|
||
if args.sell_slippage is not None:
|
||
Config.sell_slippage = args.sell_slippage
|
||
if args.balance is not None:
|
||
Config.virtual_balance = args.balance
|
||
|
||
if Config.market not in qt.MARKETS:
|
||
log(f"未知市场: {Config.market}", "ERROR")
|
||
sys.exit(1)
|
||
|
||
# 连接 onchain_leaderboard 的 WebSocket
|
||
if not start_ws_client():
|
||
log("无法启动排行榜 WS 客户端, 退出", "ERROR")
|
||
sys.exit(1)
|
||
|
||
log("等待 WS 数据就绪 (BTC 价格)...")
|
||
for _ in range(30):
|
||
if get_btc_price() > 0:
|
||
break
|
||
time.sleep(1)
|
||
else:
|
||
log("无法获取 BTC 价格, 请检查 onchain_leaderboard 是否在运行", "ERROR")
|
||
sys.exit(1)
|
||
log(f"BTC 现价: ${get_btc_price():,.2f}")
|
||
|
||
if not Config.dry_run:
|
||
try:
|
||
qt.get_clob_client()
|
||
except Exception as e:
|
||
log(f"CLOB 初始化失败: {e}", "ERROR")
|
||
sys.exit(1)
|
||
else:
|
||
global _virtual, _recorder
|
||
_virtual = VirtualPortfolio(Config.virtual_balance)
|
||
_recorder = Recorder(f"backtest_{datetime.now().strftime('%m%d_%H%M')}.jsonl")
|
||
|
||
try:
|
||
run_bot()
|
||
except KeyboardInterrupt:
|
||
log("\n👋 退出")
|
||
if Config.dry_run and _virtual:
|
||
s = _virtual.status()
|
||
log(f"最终账户: ${s['balance']:.2f} | PnL: ${s['total_pnl']:+.2f} | {s['trades']}笔交易")
|
||
elif not Config.dry_run:
|
||
log(f"实盘总结: {_live_stats['trades']}笔 | {_live_stats['wins']}胜{_live_stats['losses']}负 | 累计PnL: ${_live_stats['total_pnl']:+.2f}")
|
||
finally:
|
||
if _recorder:
|
||
_recorder.close()
|
||
log(f"📁 回测数据已保存: {_recorder.path}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|