- scripts/okx_position_advisor.py execute_order(): * 步骤名 tp_sl → sl_only * OCO algo (双触发) → conditional algo (单腿 SL) * 不挂 tpTriggerPx, 只挂 slTriggerPx * 实测: SPCX 1.45张 short OCO 已撤, 改挂 SL @ 153.62 - SKILL.md: * v4.5.0 章节: 只设止损不设止盈 (原因: advisor 算的 TP 太远, 占用保证金) * 平仓信号自动跟单: 同方向 reduceOnly 平仓, 反方向不动, 无 Y/N * 完全自动跟单流程: 加仓/新开仓/平仓/减仓信号都自动判断执行 * 实测案例: SPCX 1.45张空 @ 149.15, 浮盈 /usr/bin/bash, 占用 45% Co-Authored-By: Claude <noreply@anthropic.com>
789 lines
28 KiB
Python
789 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
OKX Auto Position Advisor
|
||
根据余额自动推荐开仓数量+止盈止损位
|
||
|
||
Usage:
|
||
python3 okx_position_advisor.py --symbol ETH --side short --leverage 10
|
||
python3 okx_position_advisor.py --symbol BTC --side long --leverage 5
|
||
python3 okx_position_advisor.py --symbol ETH --side short # 默认10x
|
||
"""
|
||
|
||
import re
|
||
import os
|
||
import sys
|
||
import json
|
||
import argparse
|
||
import ccxt
|
||
import math
|
||
|
||
# Import cost performance module
|
||
sys.path.insert(0, os.path.dirname(__file__))
|
||
from cost_performance import calc_cost_performance, calc_min_contracts_for_profit
|
||
from config_loader import get as cfg
|
||
|
||
|
||
def load_credentials():
|
||
"""Load OKX credentials from ~/.bashrc"""
|
||
creds = {}
|
||
with open(os.path.expanduser("~/.bashrc")) as f:
|
||
for line in f:
|
||
m = re.match(r'export\s+(OKX_\w+)=(.*)', line.strip())
|
||
if m:
|
||
val = m.group(2).strip()
|
||
if val.startswith('"') and val.endswith('"'):
|
||
val = val[1:-1]
|
||
elif val.startswith("'") and val.endswith("'"):
|
||
val = val[1:-1]
|
||
creds[m.group(1)] = val
|
||
return creds
|
||
|
||
|
||
def create_exchange(creds):
|
||
"""Create ccxt OKX exchange instance with proxy"""
|
||
return ccxt.okx({
|
||
'apiKey': creds['OKX_API_KEY'],
|
||
'secret': creds['OKX_SECRET'],
|
||
'password': creds['OKX_PASSPHRASE'],
|
||
'proxies': {
|
||
'http': 'http://127.0.0.1:7890',
|
||
'https': 'http://127.0.0.1:7890',
|
||
},
|
||
'options': {'defaultType': 'swap'},
|
||
})
|
||
|
||
|
||
def get_account_info(exchange):
|
||
"""Get account balance and positions"""
|
||
balance = exchange.fetch_balance()
|
||
usdt_free = float(balance.get('USDT', {}).get('free', 0))
|
||
usdt_total = float(balance.get('USDT', {}).get('total', 0))
|
||
|
||
positions = exchange.fetch_positions()
|
||
active = []
|
||
for p in positions:
|
||
if float(p.get('contracts', 0)) > 0:
|
||
active.append({
|
||
'symbol': p['symbol'],
|
||
'side': p['side'],
|
||
'contracts': float(p['contracts']),
|
||
'entry': float(p['entryPrice']) if p.get('entryPrice') else 0,
|
||
'pnl': float(p.get('unrealizedPnl', 0)),
|
||
'liq': float(p.get('liquidationPrice', 0)) if p.get('liquidationPrice') else 0,
|
||
})
|
||
|
||
return {
|
||
'usdt_free': usdt_free,
|
||
'usdt_total': usdt_total,
|
||
'positions': active,
|
||
}
|
||
|
||
|
||
def get_instrument(exchange, inst_id):
|
||
"""Get contract specifications"""
|
||
inst = exchange.public_get_public_instruments({
|
||
'instType': 'SWAP',
|
||
'instId': inst_id,
|
||
})
|
||
spec = inst['data'][0]
|
||
return {
|
||
'ct_val': float(spec['ctVal']), # contract value in base currency
|
||
'min_sz': float(spec['minSz']), # minimum order size
|
||
'lot_sz': float(spec['lotSz']), # order step size
|
||
'ct_mult': float(spec.get('ctMult', 1)),
|
||
'inst_id': inst_id,
|
||
}
|
||
|
||
|
||
def calc_atr(exchange, symbol, timeframe='4h', periods=30):
|
||
"""Calculate Average True Range"""
|
||
try:
|
||
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=periods)
|
||
if len(ohlcv) < 5:
|
||
return None
|
||
|
||
true_ranges = []
|
||
for i in range(1, len(ohlcv)):
|
||
high = ohlcv[i][2]
|
||
low = ohlcv[i][3]
|
||
prev_close = ohlcv[i - 1][4]
|
||
tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
|
||
true_ranges.append(tr)
|
||
|
||
return sum(true_ranges) / len(true_ranges)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def calc_multi_atr(exchange, symbol):
|
||
"""多周期ATR融合: 1H×0.5 + 4H×0.3 + 1D×0.2 × 1.5
|
||
|
||
比单用4H ATR更灵敏——1H应对短期波动,4H做主心骨,1D兜底。
|
||
"""
|
||
try:
|
||
atr_1h = calc_atr(exchange, symbol, '1h', 24)
|
||
atr_4h = calc_atr(exchange, symbol, '4h', 30)
|
||
atr_1d = calc_atr(exchange, symbol, '1d', 14)
|
||
values = [v for v in [atr_1h, atr_4h, atr_1d] if v is not None]
|
||
if not values:
|
||
return None, None, None, None
|
||
if atr_1h is not None and atr_4h is not None and atr_1d is not None:
|
||
fused = (atr_1h * cfg('atr','weight_1h',0.5) + atr_4h * cfg('atr','weight_4h',0.3) + atr_1d * cfg('atr','weight_1d',0.2)) * cfg('atr','multiplier',1.5)
|
||
elif atr_4h is not None:
|
||
fused = atr_4h * cfg('atr','multiplier',1.5)
|
||
else:
|
||
fused = sum(values) / len(values) * cfg('atr','multiplier',1.5)
|
||
return fused, atr_1h, atr_4h, atr_1d
|
||
except Exception:
|
||
return None, None, None, None
|
||
|
||
|
||
def estimate_trend_strength(exchange, symbol):
|
||
"""通过EMA12-EMA26斜率估算趋势强度
|
||
|
||
Returns: ('strong_up'|'strong_down'|'ranging'|'weak_trend', slope_pct)
|
||
"""
|
||
try:
|
||
ohlcv = exchange.fetch_ohlcv(symbol, '4h', limit=30)
|
||
closes = [c[4] for c in ohlcv[-26:]]
|
||
if len(closes) < 14:
|
||
return 'weak_trend', 0
|
||
ema12 = sum(closes[-12:]) / 12
|
||
ema26 = sum(closes) / 26
|
||
slope = (ema12 - ema26) / ema26 * 100
|
||
if slope > 0.5: return 'strong_up', round(slope, 2)
|
||
if slope < -0.5: return 'strong_down', round(slope, 2)
|
||
if abs(slope) < 0.1: return 'ranging', round(slope, 2)
|
||
return 'weak_trend', round(slope, 2)
|
||
except Exception:
|
||
return 'weak_trend', 0
|
||
|
||
|
||
def _rr_by_trend():
|
||
return cfg('rr_by_trend', 'strong_up', 3.0), cfg('rr_by_trend', 'strong_down', 3.0), cfg('rr_by_trend', 'weak_trend', 2.0), cfg('rr_by_trend', 'ranging', 1.5)
|
||
|
||
RR_BY_TREND = {
|
||
'strong_up': cfg('rr_by_trend', 'strong_up', 3.0),
|
||
'strong_down': cfg('rr_by_trend', 'strong_down', 3.0),
|
||
'weak_trend': cfg('rr_by_trend', 'weak_trend', 2.0),
|
||
'ranging': cfg('rr_by_trend', 'ranging', 1.5),
|
||
}
|
||
|
||
TREND_LABEL = {
|
||
'strong_up': '强上升趋势',
|
||
'strong_down': '强下降趋势',
|
||
'weak_trend': '弱趋势',
|
||
'ranging': '震荡',
|
||
}
|
||
|
||
|
||
def recommend_position(symbol, side, leverage, exchange, acct_info):
|
||
"""Calculate recommended position size, TP, SL"""
|
||
|
||
# Get current price
|
||
ticker = exchange.fetch_ticker(symbol)
|
||
price = ticker['last']
|
||
|
||
# Get instrument specs
|
||
inst_id = symbol.replace('/', '-').replace(':USDT', '-SWAP').replace(':USD', '-SWAP')
|
||
# Handle common formats: ETH/USDT:USDT -> ETH-USDT-SWAP
|
||
parts = symbol.split('/')
|
||
base = parts[0]
|
||
inst_id = f"{base}-USDT-SWAP"
|
||
|
||
spec = get_instrument(exchange, inst_id)
|
||
ct_val = spec['ct_val']
|
||
min_sz = spec['min_sz']
|
||
lot_sz = spec['lot_sz']
|
||
|
||
# Cap leverage for safety
|
||
max_lev = cfg('position_sizing', 'max_leverage', 20)
|
||
if leverage > max_lev:
|
||
leverage = max_lev
|
||
if leverage < 1:
|
||
leverage = 1
|
||
|
||
# Position sizing: use 45% of available balance
|
||
avail_margin = acct_info['usdt_free'] * cfg('position_sizing', 'balance_utilization', 0.45)
|
||
margin_per_contract = ct_val * price / leverage
|
||
|
||
if margin_per_contract <= 0:
|
||
return {'error': 'Invalid margin calculation'}
|
||
|
||
raw_contracts = avail_margin / margin_per_contract
|
||
# Round down to lot_sz
|
||
contracts = int(raw_contracts / lot_sz) * lot_sz
|
||
contracts = max(contracts, min_sz)
|
||
|
||
if contracts < min_sz:
|
||
return {
|
||
'error': f'余额不足: 需要至少 {margin_per_contract * min_sz:.2f} USDT, 可用 {acct_info["usdt_free"]:.2f} USDT'
|
||
}
|
||
|
||
# Calculate A+E+D multi-timeframe ATR fusion (方案A)
|
||
fused_atr, atr_1h, atr_4h, atr_1d = calc_multi_atr(exchange, symbol)
|
||
|
||
if fused_atr and fused_atr > 0:
|
||
sl_distance = fused_atr # fused_atr already includes ×1.5 multiplier
|
||
else:
|
||
# Fallback: fixed percentage
|
||
sl_distance = price * cfg('atr', 'fallback_sl_pct', 0.03)
|
||
|
||
# Adaptive R:R based on trend strength (方案D)
|
||
trend, slope = estimate_trend_strength(exchange, symbol)
|
||
rr_target = RR_BY_TREND.get(trend, 2.0)
|
||
tp_distance = sl_distance * rr_target
|
||
|
||
# Calculate TP/SL prices
|
||
if side == 'sell': # Short
|
||
tp_price = price - tp_distance
|
||
sl_price = price + sl_distance
|
||
else: # Long
|
||
tp_price = price + tp_distance
|
||
sl_price = price - sl_distance
|
||
|
||
# Calculate liquidation price estimate
|
||
if side == 'sell':
|
||
liq_price = price * (1 + 1 / leverage * cfg('safety', 'liq_estimate_factor', 0.9)) # ~90% of theoretical max
|
||
else:
|
||
liq_price = price * (1 - 1 / leverage * cfg('safety', 'liq_estimate_factor', 0.9))
|
||
|
||
# Safety check: SL must be inside liquidation (20% buffer)
|
||
if side == 'sell':
|
||
# Short: SL is above entry, liq is further above
|
||
# max_sl = entry + (liq - entry) * 0.8
|
||
max_sl = price + (liq_price - price) * cfg('safety', 'liq_buffer', 0.8)
|
||
if sl_price > max_sl:
|
||
sl_price = max_sl
|
||
tp_price = price - (sl_price - price) * 2 # Maintain R:R
|
||
else:
|
||
# Long: SL is below entry, liq is further below
|
||
# min_sl = entry - (entry - liq) * 0.8
|
||
min_sl = price - (price - liq_price) * cfg('safety', 'liq_buffer', 0.8)
|
||
if sl_price < min_sl:
|
||
sl_price = min_sl
|
||
tp_price = price + (price - sl_price) * 2
|
||
|
||
# Calculate percentages
|
||
tp_pct = abs(tp_price - price) / price * 100
|
||
sl_pct = abs(sl_price - price) / price * 100
|
||
liq_pct = abs(liq_price - price) / price * 100
|
||
|
||
# Risk/reward ratio
|
||
rr = tp_pct / sl_pct if sl_pct > 0 else 0
|
||
|
||
# Total margin used
|
||
total_margin = contracts * margin_per_contract
|
||
margin_pct = total_margin / acct_info['usdt_free'] * 100
|
||
|
||
# Estimated P&L
|
||
tp_pnl = contracts * ct_val * abs(tp_price - price)
|
||
sl_pnl = contracts * ct_val * abs(sl_price - price)
|
||
|
||
# Cost-performance check (性价比检查)
|
||
fee_rate = cfg('cost_performance', 'fee_rate', 0.0005)
|
||
cost_check = calc_cost_performance(
|
||
entry_price=price,
|
||
sl_price=sl_price,
|
||
tp_price=tp_price,
|
||
contracts=contracts,
|
||
ct_val=ct_val,
|
||
leverage=leverage,
|
||
fee_rate=fee_rate
|
||
)
|
||
|
||
# If profit < 5 USDT, adjust contracts to meet minimum
|
||
min_profit = cfg('position_sizing', 'min_profit_usdt', 10)
|
||
if cost_check['profit_amount'] < min_profit:
|
||
tp_distance = abs(tp_price - price)
|
||
min_contracts = calc_min_contracts_for_profit(tp_distance, ct_val, min_profit=min_profit)
|
||
# Round up to lot_sz
|
||
min_contracts = math.ceil(min_contracts / lot_sz) * lot_sz
|
||
|
||
if min_contracts * margin_per_contract <= acct_info['usdt_free']:
|
||
contracts = min_contracts
|
||
# Recalculate P&L
|
||
tp_pnl = contracts * ct_val * abs(tp_price - price)
|
||
sl_pnl = contracts * ct_val * abs(sl_price - price)
|
||
total_margin = contracts * margin_per_contract
|
||
margin_pct = total_margin / acct_info['usdt_free'] * 100
|
||
|
||
# Recalculate cost check
|
||
cost_check = calc_cost_performance(
|
||
entry_price=price,
|
||
sl_price=sl_price,
|
||
tp_price=tp_price,
|
||
contracts=contracts,
|
||
ct_val=ct_val,
|
||
leverage=leverage,
|
||
fee_rate=fee_rate
|
||
)
|
||
|
||
return {
|
||
'symbol': f"{base}/USDT",
|
||
'side': side,
|
||
'side_cn': '做空' if side == 'sell' else '做多',
|
||
'leverage': leverage,
|
||
'price': price,
|
||
'contracts': contracts,
|
||
'base_amount': contracts * ct_val,
|
||
'margin': round(total_margin, 2),
|
||
'margin_pct': round(margin_pct, 1),
|
||
'tp_price': round(tp_price, 2),
|
||
'tp_pct': round(tp_pct, 2),
|
||
'tp_pnl': round(tp_pnl, 2),
|
||
'sl_price': round(sl_price, 2),
|
||
'sl_pct': round(sl_pct, 2),
|
||
'sl_pnl': round(sl_pnl, 2),
|
||
'rr': round(rr, 1),
|
||
'liq_price': round(liq_price, 2),
|
||
'liq_pct': round(liq_pct, 1),
|
||
'atr_fused': round(fused_atr, 2) if fused_atr else None,
|
||
'atr_1h': round(atr_1h, 2) if atr_1h else None,
|
||
'atr_4h': round(atr_4h, 2) if atr_4h else None,
|
||
'atr_1d': round(atr_1d, 2) if atr_1d else None,
|
||
'trend': trend,
|
||
'trend_label': TREND_LABEL.get(trend, ''),
|
||
'slope': slope,
|
||
'inst_id': inst_id,
|
||
'ct_val': ct_val,
|
||
'min_sz': min_sz,
|
||
'acct_free': round(acct_info['usdt_free'], 2),
|
||
'cost_check': cost_check,
|
||
'auto_execute': cost_check['auto_execute'],
|
||
}
|
||
|
||
|
||
def format_recommendation(rec):
|
||
"""Format recommendation as readable text"""
|
||
if 'error' in rec:
|
||
return f"❌ {rec['error']}"
|
||
|
||
cost_check = rec.get('cost_check', {})
|
||
rating = cost_check.get('rating', 'unknown')
|
||
rating_emoji = cost_check.get('rating_emoji', '')
|
||
rating_text = cost_check.get('rating_text', '')
|
||
auto_execute = rec.get('auto_execute', False)
|
||
|
||
# 根据性价比等级选择模板
|
||
if rating == 'high':
|
||
# 性价比高 - 自动开仓后推送
|
||
lines = [
|
||
f"✅ **{rec['symbol']} {rec['side_cn']}** 自动开仓",
|
||
f"",
|
||
f"📊 方向: {rec['side_cn']} | 杠杆: **{rec['leverage']}x**",
|
||
f"📍 入场: **{rec['price']}**",
|
||
f"🛑 止损: **{rec['sl_price']}** → 预亏 -{rec['sl_pnl']:.2f} USDT (保证金-{rec['sl_pnl']/max(rec['margin'], 0.01)*100:.0f}%)",
|
||
f"🎯 止盈: **{rec['tp_price']}** → 预盈 +{rec['tp_pnl']:.2f} USDT (保证金+{rec['tp_pnl']/max(rec['margin'], 0.01)*100:.0f}%)",
|
||
f"📐 盈亏比: **{rec['rr']}:1** ✅",
|
||
f"",
|
||
f"📦 张数: **{rec['contracts']}张** ({rec['base_amount']}个)",
|
||
f"💰 保证金: {rec['margin']} USDT ({rec['margin_pct']}%)",
|
||
f"",
|
||
f"⚖️ 盈利: {cost_check['profit_amount']} USDT | 手续费: {cost_check['fee_cost']} USDT ({cost_check['fee_pct']}%)",
|
||
]
|
||
elif rating == 'medium':
|
||
# 性价比一般 - 等确认
|
||
lines = [
|
||
f"⚠️ **{rec['symbol']} {rec['side_cn']}** 性价比一般",
|
||
f"",
|
||
f"📊 方向: {rec['side_cn']} | 杠杆: **{rec['leverage']}x**",
|
||
f"📍 入场: **{rec['price']}**",
|
||
f"🛑 止损: **{rec['sl_price']}** → 预亏 -{rec['sl_pnl']:.2f} USDT (保证金-{rec['sl_pnl']/max(rec['margin'], 0.01)*100:.0f}%)",
|
||
f"🎯 止盈: **{rec['tp_price']}** → 预盈 +{rec['tp_pnl']:.2f} USDT (保证金+{rec['tp_pnl']/max(rec['margin'], 0.01)*100:.0f}%)",
|
||
f"📐 盈亏比: **{rec['rr']}:1** ⚠️",
|
||
f"",
|
||
f"📦 张数: **{rec['contracts']}张** ({rec['base_amount']}个)",
|
||
f"💰 保证金: {rec['margin']} USDT ({rec['margin_pct']}%)",
|
||
f"",
|
||
f"⚠️ {cost_check.get('reason', '')}",
|
||
f"",
|
||
f"回复 **Y** 仍要开仓 / **N** 取消",
|
||
]
|
||
else:
|
||
# 性价比低 - 不建议
|
||
lines = [
|
||
f"❌ **{rec['symbol']} {rec['side_cn']}** 性价比低,不建议",
|
||
f"",
|
||
f"📊 方向: {rec['side_cn']} | 杠杆: **{rec['leverage']}x**",
|
||
f"📍 入场: **{rec['price']}**",
|
||
f"🛑 止损: **{rec['sl_price']}** → 预亏 -{rec['sl_pnl']:.2f} USDT (保证金-{rec['sl_pnl']/max(rec['margin'], 0.01)*100:.0f}%)",
|
||
f"🎯 止盈: **{rec['tp_price']}** → 预盈 +{rec['tp_pnl']:.2f} USDT (保证金+{rec['tp_pnl']/max(rec['margin'], 0.01)*100:.0f}%)",
|
||
f"📐 盈亏比: **{rec['rr']}:1** ❌",
|
||
f"",
|
||
f"❌ {cost_check.get('reason', '')}",
|
||
f"",
|
||
f"💡 建议:观望或等更好入场点",
|
||
]
|
||
|
||
# 添加ATR和趋势信息
|
||
if rec.get('atr_fused'):
|
||
lines.append(f"📊 多周期ATR: 融合${rec['atr_fused']} (1H=${rec.get('atr_1h','?')} 4H=${rec.get('atr_4h','?')} 1D=${rec.get('atr_1d','?')})")
|
||
if rec.get('trend_label'):
|
||
lines.append(f"🧭 趋势: {rec['trend_label']} (斜率{rec.get('slope','?')}%)")
|
||
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def execute_order(exchange, rec):
|
||
"""Execute the order after user confirmation"""
|
||
symbol = f"{rec['symbol'].split('/')[0]}/USDT:USDT"
|
||
inst_id = rec['inst_id']
|
||
side = rec['side']
|
||
contracts = rec['contracts']
|
||
leverage = rec['leverage']
|
||
|
||
results = {'steps': []}
|
||
|
||
# 1. Set leverage
|
||
try:
|
||
exchange.set_leverage(leverage, symbol)
|
||
results['steps'].append({'step': 'leverage', 'status': 'ok'})
|
||
except Exception as e:
|
||
results['steps'].append({'step': 'leverage', 'status': 'warn', 'msg': str(e)})
|
||
|
||
# 2. Place market order
|
||
try:
|
||
if side == 'sell':
|
||
order = exchange.create_market_sell_order(symbol, contracts, params={'tdMode': 'cross'})
|
||
else:
|
||
order = exchange.create_market_buy_order(symbol, contracts, params={'tdMode': 'cross'})
|
||
results['order'] = {
|
||
'id': order['id'],
|
||
'status': order['status'],
|
||
'side': side,
|
||
'amount': contracts,
|
||
}
|
||
results['steps'].append({'step': 'order', 'status': 'ok', 'order_id': order['id']})
|
||
except Exception as e:
|
||
results['steps'].append({'step': 'order', 'status': 'error', 'msg': str(e)})
|
||
return results
|
||
|
||
# 3. Wait for position update
|
||
import time
|
||
time.sleep(2)
|
||
|
||
# 4. Cancel existing algo orders for this instrument (避免多开止盈止损单)
|
||
cancelled = 0
|
||
for otype in ['oco', 'conditional']:
|
||
try:
|
||
resp = exchange.private_get_trade_orders_algo_pending({
|
||
'ordType': otype,
|
||
'instId': inst_id,
|
||
})
|
||
for algo in resp.get('data', []):
|
||
try:
|
||
exchange.private_post_trade_cancel_algos([{
|
||
'algoId': algo['algoId'],
|
||
'instId': inst_id,
|
||
}])
|
||
cancelled += 1
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
if cancelled > 0:
|
||
results['steps'].append({'step': 'cancel_old_algos', 'status': 'ok', 'cancelled': cancelled})
|
||
time.sleep(0.5) # wait for cancellation to propagate
|
||
|
||
# 5. Set SL-only via conditional algo order (v4.5.0: 不设止盈, 靠平仓信号平仓)
|
||
try:
|
||
# 用单腿 conditional algo, 只挂止损
|
||
# 多头: 价格跌破 SL 时市价平仓
|
||
# 空头: 价格涨破 SL 时市价平仓
|
||
if side == 'sell':
|
||
# Short: SL trigger above entry
|
||
algo_params = {
|
||
'instId': inst_id,
|
||
'tdMode': 'cross',
|
||
'side': 'buy', # buy to close short
|
||
'posSide': 'net',
|
||
'ordType': 'conditional',
|
||
'sz': str(contracts),
|
||
'slTriggerPx': str(rec['sl_price']),
|
||
'slOrdPx': '-1',
|
||
'slTriggerPxType': 'last',
|
||
'reduceOnly': 'true',
|
||
}
|
||
else:
|
||
# Long: SL trigger below entry
|
||
algo_params = {
|
||
'instId': inst_id,
|
||
'tdMode': 'cross',
|
||
'side': 'sell', # sell to close long
|
||
'posSide': 'net',
|
||
'ordType': 'conditional',
|
||
'sz': str(contracts),
|
||
'slTriggerPx': str(rec['sl_price']),
|
||
'slOrdPx': '-1',
|
||
'slTriggerPxType': 'last',
|
||
'reduceOnly': 'true',
|
||
}
|
||
|
||
resp = exchange.private_post_trade_order_algo(algo_params)
|
||
if resp.get('data') and resp['data'][0].get('algoId'):
|
||
algo_id = resp['data'][0]['algoId']
|
||
# v4.5.0: 只设 SL, tp 标记为 None
|
||
results['algo'] = {'id': algo_id, 'sl': rec['sl_price'], 'tp': None}
|
||
results['steps'].append({'step': 'sl_only', 'status': 'ok', 'algo_id': algo_id})
|
||
else:
|
||
results['steps'].append({'step': 'sl_only', 'status': 'warn', 'msg': str(resp)})
|
||
except Exception as e:
|
||
results['steps'].append({'step': 'sl_only', 'status': 'error', 'msg': str(e)})
|
||
|
||
# 5. Verify position
|
||
try:
|
||
positions = exchange.fetch_positions([symbol])
|
||
for p in positions:
|
||
if float(p.get('contracts', 0)) > 0:
|
||
results['position'] = {
|
||
'side': p['side'],
|
||
'contracts': float(p['contracts']),
|
||
'entry': float(p['entryPrice']) if p.get('entryPrice') else 0,
|
||
'liq': float(p.get('liquidationPrice', 0)) if p.get('liquidationPrice') else 0,
|
||
'pnl': float(p.get('unrealizedPnl', 0)),
|
||
}
|
||
except Exception:
|
||
pass
|
||
|
||
return results
|
||
|
||
|
||
def format_execution_result(results):
|
||
"""Format execution result for user"""
|
||
lines = []
|
||
for step in results.get('steps', []):
|
||
if step['step'] == 'leverage':
|
||
if step['status'] == 'ok':
|
||
lines.append("✅ 杠杆设置成功")
|
||
else:
|
||
lines.append(f"⚠️ 杠杆: {step.get('msg', '')}")
|
||
elif step['step'] == 'order':
|
||
if step['status'] == 'ok':
|
||
lines.append(f"✅ 下单成功 (ID: {step['order_id']})")
|
||
else:
|
||
lines.append(f"❌ 下单失败: {step.get('msg', '')}")
|
||
return '\n'.join(lines)
|
||
elif step['step'] == 'cancel_old_algos':
|
||
lines.append(f"🧹 已清理 {step['cancelled']} 个旧止盈止损单")
|
||
elif step['step'] == 'tp_sl':
|
||
if step['status'] == 'ok':
|
||
lines.append(f"✅ 止盈止损设置成功 (ID: {step['algo_id']})")
|
||
else:
|
||
lines.append(f"⚠️ 止盈止损: {step.get('msg', '')}")
|
||
|
||
pos = results.get('position')
|
||
if pos:
|
||
lines.extend([
|
||
"",
|
||
"📊 **持仓确认:**",
|
||
f"• 方向: {pos['side']}",
|
||
f"• 数量: {pos['contracts']}张",
|
||
f"• 入场价: **{pos['entry']}**",
|
||
f"• 清算价: {pos['liq']}",
|
||
])
|
||
algo = results.get('algo')
|
||
if algo:
|
||
lines.extend([
|
||
f"• 🎯 止盈: {algo['tp']}",
|
||
f"• 🛑 止损: {algo['sl']}",
|
||
])
|
||
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def close_position(exchange, symbol, inst_id):
|
||
"""Close all positions for a symbol and cancel algo orders"""
|
||
results = {'steps': []}
|
||
|
||
# 1. Get current position
|
||
positions = exchange.fetch_positions([symbol])
|
||
pos = None
|
||
for p in positions:
|
||
if float(p.get('contracts', 0)) > 0:
|
||
pos = p
|
||
break
|
||
|
||
if not pos:
|
||
results['steps'].append({'step': 'check', 'status': 'none', 'msg': '没有持仓'})
|
||
return results
|
||
|
||
contracts = float(pos['contracts'])
|
||
side = pos['side']
|
||
entry = float(pos['entryPrice'])
|
||
pnl = float(pos.get('unrealizedPnl', 0))
|
||
|
||
# 2. Cancel all algo orders
|
||
for otype in ['oco', 'conditional']:
|
||
try:
|
||
resp = exchange.private_get_trade_orders_algo_pending({
|
||
'ordType': otype,
|
||
'instId': inst_id,
|
||
})
|
||
for algo in resp.get('data', []):
|
||
try:
|
||
exchange.private_post_trade_cancel_algos([{
|
||
'algoId': algo['algoId'],
|
||
'instId': inst_id,
|
||
}])
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
results['steps'].append({'step': 'cancel_algos', 'status': 'ok'})
|
||
|
||
# 3. Close position with market order
|
||
try:
|
||
if side == 'short':
|
||
order = exchange.create_market_buy_order(symbol, contracts, params={
|
||
'tdMode': 'cross',
|
||
'reduceOnly': True,
|
||
})
|
||
else:
|
||
order = exchange.create_market_sell_order(symbol, contracts, params={
|
||
'tdMode': 'cross',
|
||
'reduceOnly': True,
|
||
})
|
||
results['steps'].append({'step': 'close', 'status': 'ok', 'order_id': order['id']})
|
||
except Exception as e:
|
||
results['steps'].append({'step': 'close', 'status': 'error', 'msg': str(e)})
|
||
return results
|
||
|
||
# 4. Wait and verify
|
||
import time
|
||
time.sleep(2)
|
||
|
||
# 5. Get close price from trades
|
||
try:
|
||
fills = exchange.fetch_my_trades(symbol, limit=1)
|
||
close_price = float(fills[0]['price']) if fills else 0
|
||
except Exception:
|
||
close_price = 0
|
||
|
||
results['closed'] = {
|
||
'symbol': symbol.split('/')[0] + '/USDT',
|
||
'side': side,
|
||
'contracts': contracts,
|
||
'entry': entry,
|
||
'close_price': close_price,
|
||
'pnl': pnl,
|
||
}
|
||
|
||
return results
|
||
|
||
|
||
def format_close_result(results):
|
||
"""Format close position result"""
|
||
lines = []
|
||
for step in results.get('steps', []):
|
||
if step['step'] == 'none':
|
||
return f"ℹ️ {step['msg']}"
|
||
elif step['step'] == 'close':
|
||
if step['status'] == 'ok':
|
||
lines.append("✅ 平仓成功")
|
||
else:
|
||
lines.append(f"❌ 平仓失败: {step.get('msg', '')}")
|
||
return '\n'.join(lines)
|
||
|
||
c = results.get('closed')
|
||
if c:
|
||
pnl_emoji = "🟢" if c['pnl'] >= 0 else "🔴"
|
||
lines.extend([
|
||
f"",
|
||
f"📊 **{c['symbol']} 平仓确认:**",
|
||
f"• 方向: {c['side']}",
|
||
f"• 数量: {c['contracts']}张",
|
||
f"• 入场价: {c['entry']}",
|
||
f"• 平仓价: **{c['close_price']}**",
|
||
f"• {pnl_emoji} 盈亏: **{c['pnl']:.2f} USDT**",
|
||
f"• 已取消止盈止损",
|
||
])
|
||
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description='OKX Position Advisor')
|
||
parser.add_argument('--symbol', required=True, help='Base currency: ETH, BTC, SOL...')
|
||
parser.add_argument('--side', choices=['long', 'short', 'buy', 'sell'],
|
||
help='Position direction (required for open, optional for close)')
|
||
parser.add_argument('--leverage', type=int, default=10, help='Leverage (default: 10)')
|
||
parser.add_argument('--execute', action='store_true', help='Execute order (requires prior --json output)')
|
||
parser.add_argument('--rec-json', type=str, help='Recommendation JSON to execute')
|
||
parser.add_argument('--close', action='store_true', help='Close position for symbol')
|
||
parser.add_argument('--close-all', action='store_true', help='Close all positions')
|
||
parser.add_argument('--json', action='store_true', help='Output as JSON')
|
||
args = parser.parse_args()
|
||
|
||
# Normalize side (only needed for open)
|
||
if args.side:
|
||
side = 'sell' if args.side in ('short', 'sell') else 'buy'
|
||
else:
|
||
side = None
|
||
|
||
# Load credentials and create exchange
|
||
creds = load_credentials()
|
||
exchange = create_exchange(creds)
|
||
|
||
# Build symbol
|
||
symbol = f"{args.symbol.upper()}/USDT:USDT"
|
||
inst_id = f"{args.symbol.upper()}-USDT-SWAP"
|
||
|
||
# Close mode
|
||
if args.close:
|
||
results = close_position(exchange, symbol, inst_id)
|
||
print(format_close_result(results))
|
||
return
|
||
|
||
if args.close_all:
|
||
positions = exchange.fetch_positions()
|
||
active = [p for p in positions if float(p.get('contracts', 0)) > 0]
|
||
if not active:
|
||
print("ℹ️ 没有持仓")
|
||
return
|
||
for p in active:
|
||
sym = p['symbol']
|
||
iid = sym.split('/')[0].replace(':USDT', '') + '-USDT-SWAP'
|
||
results = close_position(exchange, sym, iid)
|
||
print(format_close_result(results))
|
||
print()
|
||
return
|
||
|
||
# Open mode requires --side
|
||
if not side:
|
||
print("❌ 开仓需要指定 --side (long/short/buy/sell)")
|
||
return
|
||
|
||
# Get account info
|
||
acct_info = get_account_info(exchange)
|
||
|
||
# Calculate recommendation
|
||
rec = recommend_position(symbol, side, args.leverage, exchange, acct_info)
|
||
|
||
# Execute mode: run the order
|
||
if args.execute and args.rec_json:
|
||
rec = json.loads(args.rec_json)
|
||
results = execute_order(exchange, rec)
|
||
# Output JSON for trade_signal_handler to parse
|
||
if args.json:
|
||
print(json.dumps(results, ensure_ascii=False))
|
||
else:
|
||
print(format_execution_result(results))
|
||
return
|
||
|
||
# Auto-execute mode: if cost-performance is high, execute directly
|
||
if rec.get('auto_execute') and not args.json:
|
||
print(f"✅ 性价比高,自动开仓...")
|
||
results = execute_order(exchange, rec)
|
||
print(format_execution_result(results))
|
||
return
|
||
|
||
if args.json:
|
||
print(json.dumps(rec, indent=2, ensure_ascii=False))
|
||
else:
|
||
print(format_recommendation(rec))
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|