1095 lines
43 KiB
Python
1095 lines
43 KiB
Python
"""
|
|
BTC Hourly 自动交易脚本
|
|
|
|
基于DLinear、N-BEATS、TTM-R2多模型集成预测,在Polymarket自动下单BTC小时涨跌
|
|
支持:
|
|
- 每小时自动寻找对应市场
|
|
- 实时价格刷新
|
|
- 定时下单
|
|
- 详细日志记录
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import argparse
|
|
import json
|
|
import time
|
|
import logging
|
|
from logging.handlers import RotatingFileHandler
|
|
import schedule
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, Tuple, Dict, List
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import torch
|
|
import numpy as np
|
|
import pandas as pd
|
|
import httpx
|
|
from dotenv import load_dotenv
|
|
|
|
# 添加项目路径
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from data.fetcher import BinanceDataFetcher
|
|
from data.trading_db import TradingDatabase
|
|
from api.models import PredictionModel
|
|
from models.inference import get_inference_manager
|
|
from utils.network_proxy import apply_global_proxy_from_env, get_proxy_url
|
|
from utils.send_msg import lark_notifier
|
|
|
|
load_dotenv()
|
|
apply_global_proxy_from_env()
|
|
|
|
|
|
def setup_logging(log_dir: str = "logs", log_level: int = logging.INFO) -> logging.Logger:
|
|
"""
|
|
配置日志系统
|
|
|
|
Args:
|
|
log_dir: 日志目录
|
|
log_level: 日志级别
|
|
|
|
Returns:
|
|
配置好的logger
|
|
"""
|
|
# 创建日志目录
|
|
os.makedirs(log_dir, exist_ok=True)
|
|
|
|
# 创建logger
|
|
logger = logging.getLogger("polymarket_trader")
|
|
logger.setLevel(log_level)
|
|
logger.propagate = False
|
|
|
|
# 避免重复添加handler
|
|
if logger.handlers:
|
|
return logger
|
|
|
|
# 日志格式
|
|
formatter = logging.Formatter(
|
|
"%(asctime)s | %(levelname)-8s | %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S"
|
|
)
|
|
|
|
# 文件handler (按大小轮转, 最大10MB, 保留5个备份)
|
|
log_file = os.path.join(log_dir, "polymarket_trading.log")
|
|
file_handler = RotatingFileHandler(
|
|
log_file,
|
|
maxBytes=10*1024*1024, # 10MB
|
|
backupCount=5,
|
|
encoding='utf-8'
|
|
)
|
|
file_handler.setLevel(log_level)
|
|
file_handler.setFormatter(formatter)
|
|
logger.addHandler(file_handler)
|
|
|
|
# 控制台handler
|
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
console_handler.setLevel(log_level)
|
|
console_handler.setFormatter(formatter)
|
|
logger.addHandler(console_handler)
|
|
|
|
return logger
|
|
|
|
|
|
# 初始化全局logger
|
|
logger = setup_logging()
|
|
|
|
|
|
class PolymarketBTCTrader:
|
|
"""Polymarket BTC Hourly交易器 (多模型集成版)"""
|
|
|
|
GAMMA_API = "https://gamma-api.polymarket.com"
|
|
CLOB_API = "https://clob.polymarket.com"
|
|
|
|
# 月份名称映射
|
|
MONTH_NAMES = {
|
|
1: 'january', 2: 'february', 3: 'march', 4: 'april',
|
|
5: 'may', 6: 'june', 7: 'july', 8: 'august',
|
|
9: 'september', 10: 'october', 11: 'november', 12: 'december'
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
private_key: Optional[str] = None,
|
|
max_bet: float = 1.0,
|
|
max_price: float = 0.50,
|
|
dry_run: bool = True,
|
|
):
|
|
self.private_key = private_key or os.getenv("POLYMARKET_PRIVATE_KEY")
|
|
self.max_bet = max_bet
|
|
self.max_price = max_price
|
|
self.dry_run = dry_run
|
|
|
|
# HTTP客户端
|
|
proxy = get_proxy_url()
|
|
self.http = httpx.Client(
|
|
timeout=30.0,
|
|
proxy=proxy,
|
|
headers={'Accept-Encoding': 'identity'}
|
|
)
|
|
|
|
# 初始化推理管理器
|
|
logger.info("初始化推理管理器...")
|
|
self.inference_manager = get_inference_manager()
|
|
self.inference_manager.load_all_models()
|
|
self.device = self.inference_manager.device
|
|
|
|
# 数据获取器
|
|
self.fetcher = BinanceDataFetcher()
|
|
|
|
# 数据库
|
|
self.db = TradingDatabase()
|
|
|
|
# CLOB客户端(用于下单)
|
|
self.clob_client = None
|
|
if self.private_key and not self.dry_run:
|
|
self._init_clob_client()
|
|
|
|
# 当前市场缓存
|
|
self.current_market = None
|
|
self.last_price_update = None
|
|
|
|
def _init_clob_client(self):
|
|
"""初始化CLOB交易客户端"""
|
|
try:
|
|
from py_clob_client.client import ClobClient
|
|
from py_clob_client.clob_types import ApiCreds, OpenOrderParams
|
|
|
|
funder = os.getenv("FUNDER_ADDRESS")
|
|
nonce_str = os.getenv("POLYMARKET_NONCE") # 可选,取决于你的钱包设置
|
|
nonce = int(nonce_str) if nonce_str else None
|
|
if self.private_key and funder and nonce is not None:
|
|
try:
|
|
self.clob_client = ClobClient(
|
|
host=self.CLOB_API,
|
|
key=self.private_key,
|
|
chain_id=137, # Polygon
|
|
signature_type=2,
|
|
funder=funder
|
|
)
|
|
derived_creds = self.clob_client.create_or_derive_api_creds(nonce=nonce)
|
|
self.clob_client.set_api_creds(derived_creds)
|
|
logger.info(f"✓ 私钥派生 API Creds 成功! Derived Key: {derived_creds.api_key}")
|
|
# 验证API Key是否有效
|
|
self.clob_client.get_api_keys()
|
|
# 打印账户信息
|
|
self._print_account_info()
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 环境变量API凭证鉴权失败: {e}")
|
|
self._print_account_info()
|
|
else:
|
|
self._print_account_info()
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ CLOB客户端初始化失败: {e}")
|
|
self.clob_client = None
|
|
|
|
def _print_account_info(self):
|
|
"""打印账户余额和Allowance信息"""
|
|
try:
|
|
logger.info("-" * 40)
|
|
logger.info("👤 账户信息检查:")
|
|
|
|
# 1. 获取地址
|
|
address = self.clob_client.get_address()
|
|
logger.info(f" 钱包地址: {address}")
|
|
|
|
# 2. 获取余额和Allowance
|
|
try:
|
|
from py_clob_client.clob_types import BalanceAllowanceParams, AssetType
|
|
|
|
# 检查 USDC (Collateral) 的余额
|
|
# 在Polymarket中,USDC通常作为 COLLATERAL
|
|
params = BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)
|
|
|
|
balance_allowance = self.clob_client.get_balance_allowance(params)
|
|
logger.info(f" USDC余额: ${float(balance_allowance.get('balance', 0)) / 10**6:.2f}") # USDC是6位小数
|
|
logger.info(f" 授权额度: {balance_allowance.get('allowance', 'N/A')}")
|
|
|
|
# 检查是否足够下单
|
|
balance = float(balance_allowance.get('balance', 0)) / 10**6
|
|
if balance < self.max_bet:
|
|
logger.warning(f"⚠️ 余额不足: ${balance:.2f} < 下单额 ${self.max_bet:.2f}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f" 无法获取详细余额信息: {e}")
|
|
# 备用方法
|
|
try:
|
|
logger.info(f" 尝试备用方法获取余额...")
|
|
# 有些版本的 client 不需要 params
|
|
ba = self.clob_client.get_balance_allowance()
|
|
logger.info(f" Balance Info: {ba}")
|
|
except:
|
|
pass
|
|
|
|
logger.info("-" * 40)
|
|
except Exception as e:
|
|
logger.error(f"❌ 获取账户信息失败: {e}")
|
|
|
|
def _log_to_db(self, result: Dict, market: Optional[Dict]):
|
|
"""记录交易结果到数据库"""
|
|
try:
|
|
# 确定状态
|
|
status = 'failure'
|
|
if self.dry_run:
|
|
status = 'test'
|
|
elif result.get('success'):
|
|
status = 'success'
|
|
elif result.get('error'):
|
|
status = 'failure'
|
|
elif result.get('prediction'): # 有预测但未交易(如市场未找到)
|
|
status = 'in_progress'
|
|
if result.get('error'):
|
|
status = 'failure'
|
|
|
|
log_data = {
|
|
'timestamp': result.get('timestamp'),
|
|
'market_title': result.get('market'),
|
|
'market_slug': market.get('slug') if market else result.get('market_slug'),
|
|
'model_name': 'Ensemble', # 记录为集成策略
|
|
'prediction': result.get('prediction'),
|
|
'probability': result.get('probability'),
|
|
'action': result.get('side'),
|
|
'price': result.get('price'),
|
|
'amount': result.get('amount'),
|
|
'size': result.get('size'),
|
|
'status': status,
|
|
'error_message': result.get('error'),
|
|
'order_id': result.get('order_id'),
|
|
'raw_data': result
|
|
}
|
|
self.db.log_trade(log_data)
|
|
logger.info(f"💾 交易记录已保存到数据库")
|
|
except Exception as e:
|
|
logger.error(f"❌ 保存数据库失败: {e}")
|
|
|
|
def generate_market_slug(self, target_time: Optional[datetime] = None) -> str:
|
|
"""
|
|
生成市场slug
|
|
|
|
格式: bitcoin-up-or-down-{month}-{day}-{hour}am/pm-et
|
|
例如: bitcoin-up-or-down-february-3-8am-et
|
|
"""
|
|
# 获取美东时间
|
|
et_tz = ZoneInfo('America/New_York')
|
|
|
|
if target_time is None:
|
|
now_et = datetime.now(et_tz)
|
|
target_time = now_et.replace(minute=0, second=0, microsecond=0)
|
|
|
|
month_name = self.MONTH_NAMES[target_time.month]
|
|
day = target_time.day
|
|
|
|
# 转换为12小时制
|
|
hour_24 = target_time.hour
|
|
if hour_24 == 0:
|
|
hour_12 = 12
|
|
am_pm = 'am'
|
|
elif hour_24 < 12:
|
|
hour_12 = hour_24
|
|
am_pm = 'am'
|
|
elif hour_24 == 12:
|
|
hour_12 = 12
|
|
am_pm = 'pm'
|
|
else:
|
|
hour_12 = hour_24 - 12
|
|
am_pm = 'pm'
|
|
|
|
slug = f"bitcoin-up-or-down-{month_name}-{day}-{hour_12}{am_pm}-et"
|
|
return slug
|
|
|
|
def get_market_by_slug(self, slug: str) -> Optional[Dict]:
|
|
"""通过slug获取市场信息"""
|
|
for attempt in range(3):
|
|
try:
|
|
response = self.http.get(
|
|
f"{self.GAMMA_API}/events",
|
|
params={'slug': slug}
|
|
)
|
|
response.raise_for_status()
|
|
events = response.json()
|
|
|
|
if not events:
|
|
return None
|
|
|
|
event = events[0]
|
|
markets = event.get('markets', [])
|
|
|
|
if not markets:
|
|
return None
|
|
|
|
market = markets[0]
|
|
|
|
# outcomePrices可能是JSON字符串或列表
|
|
outcome_prices_raw = market.get('outcomePrices', ['0', '0'])
|
|
if isinstance(outcome_prices_raw, str):
|
|
import json as _json
|
|
try:
|
|
outcome_prices_raw = _json.loads(outcome_prices_raw)
|
|
except:
|
|
outcome_prices_raw = ['0', '0']
|
|
|
|
# clobTokenIds也可能是JSON字符串
|
|
clob_tokens_raw = market.get('clobTokenIds', [])
|
|
if isinstance(clob_tokens_raw, str):
|
|
import json as _json
|
|
try:
|
|
clob_tokens_raw = _json.loads(clob_tokens_raw)
|
|
except:
|
|
clob_tokens_raw = []
|
|
|
|
return {
|
|
'title': event.get('title', ''),
|
|
'slug': slug,
|
|
'question': market.get('question', ''),
|
|
'condition_id': market.get('conditionId', ''),
|
|
'outcomes': market.get('outcomes', []),
|
|
'outcome_prices': [float(p) for p in outcome_prices_raw],
|
|
'tokens': clob_tokens_raw,
|
|
'end_date': event.get('endDate', ''),
|
|
'closed': market.get('closed', False),
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"尝试 {attempt+1}/3 获取市场失败: {e}")
|
|
if attempt < 2:
|
|
time.sleep(5) # 等待5秒后重试
|
|
|
|
lark_notifier.send_alert(f"Slug: {slug}","连续3次请求或解析发生异常,已放弃重试")
|
|
return None
|
|
|
|
def get_current_hourly_market(self) -> Optional[Dict]:
|
|
"""获取BTC市场"""
|
|
slug = self.generate_market_slug()
|
|
logger.info(f"搜索市场: {slug}")
|
|
|
|
market = self.get_market_by_slug(slug)
|
|
|
|
if market:
|
|
self.current_market = market
|
|
self.last_price_update = datetime.now()
|
|
logger.info(f"✓ 找到市场: {market['title']}")
|
|
logger.info(f" Up价格: ${market['outcome_prices'][0]:.3f}")
|
|
logger.info(f" Down价格: ${market['outcome_prices'][1]:.3f}")
|
|
else:
|
|
logger.warning(f"❌ 未找到市场: {slug}")
|
|
|
|
return market
|
|
|
|
def refresh_prices(self) -> Optional[Dict]:
|
|
"""刷新当前市场价格"""
|
|
if not self.current_market:
|
|
self.get_current_hourly_market()
|
|
|
|
# 使用CLOB API获取实时价格
|
|
tokens = self.current_market.get('tokens', [])
|
|
if len(tokens) >= 2:
|
|
realtime_prices = self.get_realtime_prices(tokens[0], tokens[1])
|
|
if realtime_prices:
|
|
old_prices = self.current_market['outcome_prices']
|
|
new_prices = realtime_prices
|
|
|
|
self.current_market['outcome_prices'] = new_prices
|
|
self.last_price_update = datetime.now()
|
|
|
|
# 显示价格变化
|
|
up_change = new_prices[0] - old_prices[0]
|
|
down_change = new_prices[1] - old_prices[1]
|
|
|
|
logger.info(f"📊 实时价格 [{datetime.now().strftime('%H:%M:%S')}]")
|
|
logger.info(f" Up: ${new_prices[0]:.3f} ({up_change:+.3f})")
|
|
logger.info(f" Down: ${new_prices[1]:.3f} ({down_change:+.3f})")
|
|
|
|
return self.current_market
|
|
|
|
# 备用: 使用Gamma API
|
|
slug = self.current_market['slug']
|
|
market = self.get_market_by_slug(slug)
|
|
|
|
if market:
|
|
old_prices = self.current_market['outcome_prices']
|
|
new_prices = market['outcome_prices']
|
|
|
|
self.current_market = market
|
|
self.last_price_update = datetime.now()
|
|
|
|
# 显示价格变化
|
|
up_change = new_prices[0] - old_prices[0]
|
|
down_change = new_prices[1] - old_prices[1]
|
|
|
|
logger.info(f"📊 价格更新(Gamma) [{datetime.now().strftime('%H:%M:%S')}]")
|
|
logger.info(f" Up: ${new_prices[0]:.3f} ({up_change:+.3f})")
|
|
logger.info(f" Down: ${new_prices[1]:.3f} ({down_change:+.3f})")
|
|
|
|
return market
|
|
|
|
def get_realtime_prices(self, up_token_id: str, down_token_id: str) -> Optional[list]:
|
|
"""从CLOB API获取实时买入价格"""
|
|
try:
|
|
# 使用/price端点获取买入价格
|
|
up_response = self.http.get(
|
|
f"{self.CLOB_API}/price",
|
|
params={'token_id': up_token_id, 'side': 'BUY'}
|
|
)
|
|
up_response.raise_for_status()
|
|
up_data = up_response.json()
|
|
|
|
down_response = self.http.get(
|
|
f"{self.CLOB_API}/price",
|
|
params={'token_id': down_token_id, 'side': 'BUY'}
|
|
)
|
|
down_response.raise_for_status()
|
|
down_data = down_response.json()
|
|
|
|
# 解析买入价格
|
|
up_price = float(up_data.get('price', 0.5))
|
|
down_price = float(down_data.get('price', 0.5))
|
|
|
|
return [up_price, down_price]
|
|
except Exception as e:
|
|
logger.debug(f"获取实时价格失败: {e}")
|
|
return None
|
|
|
|
def monitor_order_status(self, order_id: str, timeout_seconds: Optional[int] = None) -> str:
|
|
"""
|
|
监控订单状态直到成交或超时
|
|
timeout_seconds: 超时秒数。如果为None,默认监控直到当前小时结束前1分钟。
|
|
"""
|
|
if timeout_seconds is None:
|
|
now = datetime.now()
|
|
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
|
end_time = next_hour - timedelta(minutes=1)
|
|
timeout_seconds = int((end_time - now).total_seconds())
|
|
if timeout_seconds < 10:
|
|
timeout_seconds = 10 # 至少监控10秒
|
|
|
|
logger.info(f"🕵️ 开始监控订单状态: {order_id} (超时: {timeout_seconds}s)")
|
|
start_monitor = datetime.now()
|
|
last_status = 'UNKNOWN'
|
|
|
|
while (datetime.now() - start_monitor).total_seconds() < timeout_seconds:
|
|
try:
|
|
order_data = self.clob_client.get_order(order_id)
|
|
status = order_data.get('status')
|
|
last_status = status
|
|
|
|
if status in ['MATCHED', 'FILLED']:
|
|
logger.info(f"🎉 订单已成交! 状态: {status}")
|
|
return status
|
|
elif status == 'CANCELED':
|
|
logger.warning(f"🚫 订单已取消")
|
|
return status
|
|
elif status == 'LIVE':
|
|
logger.info(f" 订单挂单中 (LIVE)...")
|
|
time.sleep(30)
|
|
else:
|
|
logger.info(f" 订单状态: {status} (等待成交...)")
|
|
time.sleep(30) # 轮询间隔
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 查询订单状态失败: {e}")
|
|
time.sleep(30)
|
|
|
|
logger.info(f"⚠️ 监控超时,当前状态: {last_status}")
|
|
return last_status
|
|
|
|
def _log_to_db(self, result: Dict, market: Optional[Dict]):
|
|
"""记录交易结果到数据库"""
|
|
try:
|
|
# 确定状态
|
|
status = 'UNKNOWN'
|
|
|
|
if self.dry_run:
|
|
status = 'test'
|
|
else:
|
|
# 真实交易:优先使用 Poly 订单状态
|
|
# 如果没有 order_id,说明下单未成功发起,标记为 FAILED
|
|
if not result.get('order_id'):
|
|
status = 'FAILED'
|
|
elif result.get('status'):
|
|
status = result.get('status')
|
|
else:
|
|
status = 'SUBMITTED' # 有ID但无状态,暂时标记为已提交
|
|
|
|
log_data = {
|
|
'timestamp': result.get('timestamp'),
|
|
'market_title': result.get('market'),
|
|
'market_slug': market.get('slug') if market else result.get('market_slug'),
|
|
'model_name': 'Ensemble', # 记录为集成策略
|
|
'prediction': result.get('prediction'),
|
|
'probability': result.get('probability'),
|
|
'action': result.get('side'),
|
|
'price': result.get('price'),
|
|
'amount': result.get('amount'),
|
|
'size': result.get('size'),
|
|
'status': status,
|
|
'error_message': result.get('error'),
|
|
'order_id': result.get('order_id'),
|
|
'raw_data': result
|
|
}
|
|
self.db.log_trade(log_data)
|
|
logger.info(f"💾 交易记录已保存到数据库 (Status: {status})")
|
|
except Exception as e:
|
|
logger.error(f"❌ 保存数据库失败: {e}")
|
|
|
|
def execute_trade(self, prediction: int, market: Dict) -> Dict:
|
|
"""执行交易"""
|
|
result = {
|
|
'timestamp': datetime.now().isoformat(),
|
|
'market': market['title'],
|
|
'prediction': 'UP' if prediction == 1 else 'DOWN',
|
|
'success': False,
|
|
'order': None,
|
|
'error': None,
|
|
'order_id': None,
|
|
'status': None
|
|
}
|
|
|
|
if prediction == 1: # 预测涨 -> 买UP
|
|
token_id = market['tokens'][0]
|
|
price = 0.5 # 暂时固定价格
|
|
side = "UP"
|
|
else: # 预测跌 -> 买DOWN
|
|
token_id = market['tokens'][1]
|
|
price = 0.5 # 暂时固定价格
|
|
side = "DOWN"
|
|
|
|
result['side'] = side
|
|
result['price'] = price
|
|
|
|
# 检查价格限制
|
|
# if price > self.max_price:
|
|
# result['error'] = f"价格${price:.2f}超过限制${self.max_price:.2f}"
|
|
# logger.error(f"❌ {result['error']}")
|
|
# return result
|
|
|
|
# 计算数量
|
|
size = self.max_bet / price
|
|
result['size'] = size
|
|
result['amount'] = self.max_bet
|
|
|
|
logger.info("📋 交易详情:")
|
|
logger.info(f" 方向: {side}")
|
|
logger.info(f" 价格: ${price:.3f}")
|
|
logger.info(f" 金额: ${self.max_bet:.2f} USDC")
|
|
logger.info(f" 数量: {size:.2f} 股")
|
|
|
|
if self.dry_run:
|
|
logger.info("🔸 [模拟模式] 未实际下单")
|
|
result['success'] = True
|
|
result['order'] = 'DRY_RUN'
|
|
result['status'] = 'simulated'
|
|
return result
|
|
|
|
# 实际下单
|
|
if not self.clob_client:
|
|
result['error'] = "CLOB客户端未初始化"
|
|
logger.error(f"❌ {result['error']}")
|
|
return result
|
|
|
|
try:
|
|
from py_clob_client.order_builder.constants import BUY
|
|
from py_clob_client.clob_types import OrderArgs, OpenOrderParams
|
|
|
|
# 2. 检查现有挂单
|
|
order_id = None
|
|
try:
|
|
open_order_params = OpenOrderParams(asset_id=token_id)
|
|
existing_orders = self.clob_client.get_orders(open_order_params)
|
|
if existing_orders:
|
|
existing_order = existing_orders[0]
|
|
order_id = existing_order.get('orderID')
|
|
logger.warning(f"⚠️ 检测到已有挂单: {order_id},跳过下单")
|
|
result['order_id'] = order_id
|
|
result['success'] = True
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 检查挂单失败 (不影响下单): {e}")
|
|
|
|
if not order_id:
|
|
# 创建订单参数
|
|
order_args = OrderArgs(
|
|
price=price,
|
|
size=size,
|
|
side=BUY,
|
|
token_id=token_id,
|
|
)
|
|
|
|
# 3. 原子化下单 (Create & Post)
|
|
logger.info(f"正在下单... (Price: {price}, Size: {size})")
|
|
response = self.clob_client.create_and_post_order(order_args)
|
|
|
|
if response and response.get('success'):
|
|
order_id = response.get('orderID')
|
|
result['order_id'] = order_id
|
|
result['success'] = True
|
|
result['order'] = response
|
|
logger.info(f"✓ 订单已提交, Order ID: {order_id}")
|
|
else:
|
|
error_msg = response.get('errorMsg') if response else "未知错误"
|
|
result['error'] = f"下单失败: {error_msg}"
|
|
logger.error(f"❌ {result['error']}")
|
|
logger.error(f" Response: {response}")
|
|
|
|
# 4. 立即查询状态 (无论是新单还是旧单)
|
|
if order_id:
|
|
try:
|
|
order_status = self.clob_client.get_order(order_id)
|
|
status = order_status.get('status')
|
|
result['status'] = status
|
|
logger.info(f" 订单状态: {status}")
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 无法获取订单状态: {e}")
|
|
|
|
except Exception as e:
|
|
result['error'] = str(e)
|
|
logger.error(f"❌ 下单异常: {e}")
|
|
|
|
return result
|
|
|
|
def get_prediction_with_delay(self, delay_seconds: float = 2.0) -> Tuple[int, float]:
|
|
"""
|
|
延迟后获取K线并预测当前小时的涨跌 (多模型)
|
|
"""
|
|
logger.info(f"⏳ 等待{delay_seconds}秒获取最新K线...")
|
|
time.sleep(delay_seconds)
|
|
|
|
logger.info("获取最新BTC数据...")
|
|
|
|
end_date = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
|
|
|
|
df = self.fetcher.fetch_ohlcv(
|
|
symbol='BTC',
|
|
timeframe='1h',
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
use_cache=False # 不使用缓存,获取最新数据
|
|
)
|
|
|
|
# 使用倒数第二根K线(上一小时收盘)进行预测
|
|
prev_time = df['timestamp'].iloc[-2]
|
|
prev_close = df['close'].iloc[-2]
|
|
current_time = df['timestamp'].iloc[-1]
|
|
current_close = df['close'].iloc[-1]
|
|
|
|
logger.info(f" 上一K线时间: {prev_time} (用于预测)")
|
|
logger.info(f" 上一收盘价: ${prev_close:,.2f}")
|
|
logger.info(f" 当前K线时间: {current_time} (预测目标)")
|
|
logger.info(f" 当前收盘价: ${current_close:,.2f} (未收盘)")
|
|
|
|
# 回填上一小时的实际结果 (多模型)
|
|
self._update_previous_prediction(df)
|
|
|
|
# 用除最后一根外的数据进行预测
|
|
df_for_prediction = df.iloc[:-1]
|
|
|
|
# 打印模型输入的最后一根K线数据
|
|
last_input_row = df_for_prediction.iloc[-1]
|
|
logger.info(f" 📌 模型输入末根K线: {last_input_row['timestamp']} "
|
|
f"O:{last_input_row['open']:.2f} H:{last_input_row['high']:.2f} "
|
|
f"L:{last_input_row['low']:.2f} C:{last_input_row['close']:.2f} "
|
|
f"V:{last_input_row['volume']:.0f}")
|
|
|
|
# === 多模型预测 ===
|
|
all_preds = self.inference_manager.predict_all(df_for_prediction)
|
|
|
|
# 解析预测结果
|
|
final_prediction = 0
|
|
final_probability = 0.5
|
|
|
|
# 1. DLinear
|
|
if 'dlinear' in all_preds:
|
|
dl = all_preds['dlinear']
|
|
dl_dir = "📈 UP" if dl['prediction'] == 1 else "📉 DOWN"
|
|
logger.info(f" [DLinear] {dl_dir} (conf: {dl['probability']:.2%})")
|
|
|
|
# 2. N-BEATS
|
|
if 'nbeats' in all_preds:
|
|
nb = all_preds['nbeats']
|
|
nb_dir = "📈 UP" if nb['prediction'] == 1 else "📉 DOWN"
|
|
logger.info(f" [N-BEATS] {nb_dir}")
|
|
|
|
# 3. TTM-R2
|
|
if 'ttm' in all_preds:
|
|
ttm = all_preds['ttm']
|
|
ttm_dir = "📈 UP" if ttm['prediction'] == 1 else "📉 DOWN"
|
|
logger.info(f" [TTM-R2] {ttm_dir} (conf: {ttm['probability']:.2%})")
|
|
|
|
# 4. 集成结果
|
|
if 'ensemble' in all_preds and all_preds['ensemble']:
|
|
ens = all_preds['ensemble']
|
|
ens_pred = ens['prediction']
|
|
votes = ens['votes']
|
|
ens_dir = "📈 UP" if ens_pred == 1 else "📉 DOWN"
|
|
vote_str = f"{sum(votes)}/{len(votes)}"
|
|
logger.info(f" [Ensemble] {ens_dir} (Votes: {vote_str})")
|
|
|
|
# 使用集成结果作为最终决策
|
|
final_prediction = ens_pred
|
|
final_probability = 0.6 # 集成策略暂时给一个固定置信度或基于投票比例
|
|
if len(votes) > 0:
|
|
final_probability = sum(votes) / len(votes) if ens_pred == 1 else 1 - (sum(votes) / len(votes))
|
|
else:
|
|
# 降级到 DLinear
|
|
if 'dlinear' in all_preds:
|
|
final_prediction = all_preds['dlinear']['prediction']
|
|
final_probability = all_preds['dlinear']['probability']
|
|
logger.warning("⚠️ 集成预测不可用,降级为 DLinear")
|
|
|
|
# 保存所有预测结果到 hourly_model_predictions 表 (V2)
|
|
try:
|
|
et_tz = ZoneInfo('America/New_York')
|
|
current_time_utc = pd.Timestamp(current_time)
|
|
current_time_et = current_time_utc.tz_localize('UTC').tz_convert(et_tz)
|
|
|
|
prediction_data = {
|
|
"timestamp": current_time_utc.isoformat(),
|
|
"timestamp_et": current_time_et.isoformat(),
|
|
"date": current_time_et.strftime('%Y-%m-%d'),
|
|
"hour": current_time_et.hour,
|
|
"open_price": float(df['open'].iloc[-1]),
|
|
"close_price": None,
|
|
"actual_result": None,
|
|
|
|
# DLinear
|
|
"dlinear_prediction": all_preds.get('dlinear', {}).get('prediction'),
|
|
"dlinear_prob": all_preds.get('dlinear', {}).get('probability'),
|
|
"dlinear_correct": None,
|
|
|
|
# N-BEATS
|
|
"nbeats_prediction": all_preds.get('nbeats', {}).get('prediction'),
|
|
"nbeats_prob": all_preds.get('nbeats', {}).get('probability'),
|
|
"nbeats_correct": None,
|
|
|
|
# TTM-R2
|
|
"ttm_prediction": all_preds.get('ttm', {}).get('prediction'),
|
|
"ttm_prob": all_preds.get('ttm', {}).get('probability'),
|
|
"ttm_correct": None,
|
|
|
|
# Ensemble
|
|
"ensemble_prediction": all_preds.get('ensemble', {}).get('prediction'),
|
|
"ensemble_votes": all_preds.get('ensemble', {}).get('votes'),
|
|
"ensemble_correct": None,
|
|
}
|
|
|
|
PredictionModel.save_hourly_prediction(prediction_data)
|
|
logger.info(f"💾 所有模型预测记录已保存 (ET {current_time_et.strftime('%Y-%m-%d %H:%M')})")
|
|
except Exception as e:
|
|
logger.error(f"保存预测记录失败: {e}")
|
|
|
|
return final_prediction, final_probability
|
|
|
|
def _update_previous_prediction(self, df: pd.DataFrame):
|
|
"""回填上一小时预测的实际结果 (所有模型)"""
|
|
if len(df) < 2:
|
|
return
|
|
|
|
try:
|
|
et_tz = ZoneInfo('America/New_York')
|
|
prev_row = df.iloc[-2]
|
|
prev_utc = pd.Timestamp(prev_row['timestamp'])
|
|
prev_et = prev_utc.tz_localize('UTC').tz_convert(et_tz)
|
|
prev_date = prev_et.strftime('%Y-%m-%d')
|
|
prev_hour = prev_et.hour
|
|
|
|
prev_open = float(prev_row['open'])
|
|
prev_close = float(prev_row['close'])
|
|
actual_result = 1 if prev_close > prev_open else 0
|
|
|
|
# 使用 V2 更新方法 (宽表)
|
|
updated = PredictionModel.update_actual_result_v2(
|
|
date=prev_date,
|
|
hour=prev_hour,
|
|
actual_result=actual_result,
|
|
close_price=prev_close
|
|
)
|
|
|
|
if updated:
|
|
logger.info(f"📝 回填上一小时结果: ET {prev_date} {prev_hour}:00, "
|
|
f"实际{'涨' if actual_result == 1 else '跌'} (O:{prev_open:.0f} C:{prev_close:.0f})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"回填上一小时结果失败: {e}")
|
|
|
|
def run_once(self) -> Dict:
|
|
"""运行一次交易流程(不等待整点)"""
|
|
logger.info("运行单次交易...")
|
|
return self.run_at_hour_start(delay_seconds=0)
|
|
|
|
def run_at_hour_start(self, delay_seconds: float = 2.0, timeout_minutes: int = 50) -> Dict:
|
|
"""
|
|
整点时执行的交易流程
|
|
"""
|
|
logger.info("=" * 60)
|
|
logger.info(f"⏰ 整点交易触发 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
logger.info("=" * 60)
|
|
logger.info(f" 模式: {'🔸 模拟' if self.dry_run else '💰 真实交易'}")
|
|
logger.info(f" 最大下注: ${self.max_bet:.2f} USDC")
|
|
logger.info(f" 最高价格: ${self.max_price:.2f}")
|
|
logger.info(f" 等待超时: {timeout_minutes}分钟")
|
|
|
|
result = {
|
|
'timestamp': datetime.now().isoformat(),
|
|
'success': False,
|
|
'error': None,
|
|
}
|
|
|
|
# 1. 延迟后获取预测
|
|
prediction, probability = self.get_prediction_with_delay(delay_seconds)
|
|
result['prediction'] = 'UP' if prediction == 1 else 'DOWN'
|
|
result['probability'] = probability if prediction == 1 else 1 - probability
|
|
|
|
# 2. 获取初始市场
|
|
market = self.get_current_hourly_market()
|
|
if not market:
|
|
result['error'] = '未找到市场'
|
|
logger.error(f"❌ {result['error']}")
|
|
self._log_to_db(result, None)
|
|
return result
|
|
|
|
result['market'] = market['title']
|
|
result['market_slug'] = market['slug']
|
|
|
|
if market['closed']:
|
|
result['error'] = '市场已关闭'
|
|
logger.error(f"❌ {result['error']}")
|
|
self._log_to_db(result, market)
|
|
return result
|
|
|
|
# 检查是否已交易过该市场
|
|
existing_trade = self.db.get_existing_trade(market['slug'])
|
|
|
|
if existing_trade:
|
|
status = existing_trade.get('status')
|
|
order_id = existing_trade.get('order_id')
|
|
|
|
# 如果是成功状态或模拟测试,且不需要进一步同步,则直接跳过
|
|
if status in ['test', 'MATCHED', 'FILLED']:
|
|
# 但如果是 dry_run 模式,且 existing_trade 也是 test,则认为是已完成
|
|
if self.dry_run and status == 'test':
|
|
result['error'] = '该市场已存在模拟交易记录'
|
|
logger.warning(f"⚠️ {result['error']}, 跳过交易")
|
|
return result
|
|
elif not self.dry_run and status in ['MATCHED', 'FILLED']:
|
|
result['error'] = '该市场已存在成功交易记录'
|
|
logger.warning(f"⚠️ {result['error']}, 跳过交易")
|
|
return result
|
|
|
|
# 如果存在未完成的订单 (且不是 dry_run),尝试同步状态
|
|
# FAILED 和 CANCELED 可以重试
|
|
if not self.dry_run and order_id and status not in ['FAILED', 'CANCELED', 'test']:
|
|
logger.info(f"🔄 发现已有订单 ({status}), 正在同步状态... OrderID: {order_id}")
|
|
try:
|
|
# 使用较短的超时时间进行一次快速检查
|
|
new_status = self.monitor_order_status(order_id, timeout_seconds=10)
|
|
|
|
if new_status != status:
|
|
self.db.update_trade_status(existing_trade['id'], new_status)
|
|
logger.info(f"📝 数据库状态已更新: {status} -> {new_status}")
|
|
|
|
if new_status in ['MATCHED', 'FILLED']:
|
|
result['status'] = new_status
|
|
result['error'] = '该市场已存在成功交易记录 (已同步)'
|
|
return result
|
|
elif new_status == 'CANCELED':
|
|
logger.warning("⚠️ 原订单已取消,将尝试重新下单...")
|
|
# 允许后续逻辑继续执行,尝试重新下单
|
|
pass
|
|
else:
|
|
logger.info(f"⏳ 订单状态仍为 {new_status},继续等待或根据策略处理...")
|
|
# 如果还在 OPEN/LIVE,可能需要取消重下或者继续等待?
|
|
# 这里简单处理:如果还在挂单中,暂不重复下单
|
|
if new_status in ['OPEN', 'LIVE', 'pending']:
|
|
result['error'] = f'该市场已存在挂单 ({new_status})'
|
|
result['order_id'] = order_id
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 同步订单状态失败: {e}")
|
|
|
|
# 3. 循环检查价格并交易
|
|
# start_time = datetime.now()
|
|
# end_time = start_time + timedelta(minutes=timeout_minutes)
|
|
|
|
# logger.info(f"🔄 开始价格监控 (预测: {'UP' if prediction == 1 else 'DOWN'})...")
|
|
|
|
# while datetime.now() < end_time:
|
|
# 刷新价格
|
|
# market = self.refresh_prices()
|
|
# if not market:
|
|
# logger.warning("无法获取市场价格,重试中...")
|
|
# time.sleep(10)
|
|
# continue
|
|
|
|
# 检查价格
|
|
# price_idx = 0 if prediction == 1 else 1
|
|
# current_price = market['outcome_prices'][price_idx]
|
|
|
|
# logger.info(f" 当前价格: ${current_price:.3f} (目标: <= ${self.max_price:.2f})")
|
|
|
|
# if current_price <= self.max_price:
|
|
# 价格符合,执行交易
|
|
logger.info("✅ 预测完成,正在下单(限价 0.5)...")
|
|
trade_result = self.execute_trade(prediction, market)
|
|
result.update(trade_result)
|
|
|
|
# 如果下单成功且有order_id,进入状态监控
|
|
if result.get('success') and result.get('order_id'):
|
|
final_status = self.monitor_order_status(result['order_id'])
|
|
result['status'] = final_status
|
|
|
|
# 记录数据库
|
|
self._log_to_db(result, market)
|
|
|
|
logger.info("=" * 60)
|
|
return result
|
|
# else:
|
|
# # 价格过高,等待
|
|
# wait_time = 30 # 等待30秒
|
|
# elapsed = int((datetime.now() - start_time).total_seconds())
|
|
# logger.info(f"⏳ 价格过高,继续等待... (已耗时: {elapsed}s)")
|
|
# time.sleep(wait_time)
|
|
|
|
# 超时
|
|
# result['error'] = f'等待价格超时 ({timeout_minutes}分钟)'
|
|
# result['price'] = market['outcome_prices'][0] if prediction == 1 else market['outcome_prices'][1]
|
|
# result['side'] = 'UP' if prediction == 1 else 'DOWN'
|
|
# logger.error(f"❌ {result['error']}")
|
|
|
|
# self._log_to_db(result, market)
|
|
# logger.info("=" * 60)
|
|
|
|
# return result
|
|
|
|
def _show_initial_prediction(self):
|
|
"""
|
|
启动时显示当前预测并尝试执行交易 (不记录Monitor状态)
|
|
"""
|
|
logger.info("\n📊 启动时初始预测检查:")
|
|
|
|
# 计算剩余分钟数
|
|
now = datetime.now()
|
|
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
|
remaining_minutes = int((next_hour - now).total_seconds() / 60)
|
|
|
|
# 超时时间设为剩余分钟数 - 10,最少为 1 分钟
|
|
timeout_minutes = max(1, remaining_minutes - 10)
|
|
|
|
logger.info(f" 距离下一整点还有 {remaining_minutes} 分钟,设置超时为 {timeout_minutes} 分钟")
|
|
|
|
# 直接调用 run_at_hour_start,延迟为0,立即执行交易逻辑
|
|
self.run_at_hour_start(delay_seconds=0, timeout_minutes=timeout_minutes)
|
|
|
|
def wait_for_next_hour(self) -> datetime:
|
|
"""等待到下一个整点"""
|
|
now = datetime.now()
|
|
next_hour = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
|
wait_seconds = (next_hour - now).total_seconds()
|
|
|
|
logger.info(f"⏳ 等待下一个整点: {next_hour.strftime('%H:%M:%S')}")
|
|
logger.info(f" 剩余等待: {int(wait_seconds)}秒")
|
|
|
|
return next_hour
|
|
|
|
def run_scheduler(self, interval_minutes: int = 5, delay_seconds: float = 2.0):
|
|
"""定时运行交易器"""
|
|
logger.info("=" * 60)
|
|
logger.info("🤖 BTC Hourly自动交易 (集成版) 已启动")
|
|
logger.info("=" * 60)
|
|
logger.info(f" 模式: {'🔸 模拟' if self.dry_run else '💰 真实交易'}")
|
|
logger.info(f" 下单时机: 每个整点后延迟{delay_seconds}秒")
|
|
logger.info(f" 价格刷新: 每{interval_minutes}分钟")
|
|
logger.info(f" 最大下注: ${self.max_bet:.2f} USDC")
|
|
logger.info(f" 最高价格: ${self.max_price:.2f}")
|
|
logger.info(" 按 Ctrl+C 停止")
|
|
logger.info("=" * 60)
|
|
|
|
# 立即获取整点的市场
|
|
self.get_current_hourly_market()
|
|
|
|
# 启动时进行一次预测(不下单,仅显示)
|
|
self._show_initial_prediction()
|
|
|
|
# 每小时整点后执行交易
|
|
def hourly_trade():
|
|
self.run_at_hour_start(delay_seconds=delay_seconds)
|
|
|
|
schedule.every().hour.at(":00").do(lambda: time.sleep(delay_seconds) or hourly_trade())
|
|
|
|
try:
|
|
while True:
|
|
schedule.run_pending()
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
logger.info("✓ 交易器已停止")
|
|
|
|
def close(self):
|
|
"""关闭资源"""
|
|
self.http.close()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='BTC Hourly自动交易 - 基于DLinear、N-BEATS、TTM-R2集成模型')
|
|
parser.add_argument('--dry-run', action='store_true', default=True,
|
|
help='模拟模式(不实际下单)')
|
|
parser.add_argument('--real', action='store_true',
|
|
help='真实交易模式')
|
|
parser.add_argument('--max-bet', type=float, default=2.5,
|
|
help='最大下注金额(USDC)')
|
|
parser.add_argument('--max-price', type=float, default=0.5,
|
|
help='最高可接受价格')
|
|
parser.add_argument('--once', action='store_true',
|
|
help='只执行一次(不启动定时器)')
|
|
parser.add_argument('--interval', type=int, default=1,
|
|
help='价格刷新间隔(分钟)')
|
|
parser.add_argument('--check-market', action='store_true',
|
|
help='只检查当前市场信息')
|
|
|
|
args = parser.parse_args()
|
|
|
|
dry_run = not args.real
|
|
|
|
# 真实交易确认
|
|
if not dry_run:
|
|
logger.warning("⚠️ 警告: 即将进行真实交易!")
|
|
logger.warning(f" 最大下注: ${args.max_bet:.2f} USDC")
|
|
confirm = input("确认继续? (yes/no): ")
|
|
if confirm.lower() != 'yes':
|
|
logger.info("已取消")
|
|
return
|
|
|
|
trader = PolymarketBTCTrader(
|
|
max_bet=args.max_bet,
|
|
max_price=args.max_price,
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
try:
|
|
if args.check_market:
|
|
# 只检查市场
|
|
trader.get_current_hourly_market()
|
|
elif args.once:
|
|
# 执行一次
|
|
result = trader.run_once()
|
|
|
|
# 保存日志
|
|
log_file = 'trading_log.json'
|
|
logs = []
|
|
if os.path.exists(log_file):
|
|
with open(log_file, 'r') as f:
|
|
try:
|
|
logs = json.load(f)
|
|
except:
|
|
pass
|
|
logs.append(result)
|
|
with open(log_file, 'w') as f:
|
|
json.dump(logs, f, indent=2, ensure_ascii=False)
|
|
logger.info(f"结果已保存到 {log_file}")
|
|
else:
|
|
# 定时运行
|
|
trader.run_scheduler(interval_minutes=args.interval)
|
|
finally:
|
|
trader.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|