Files
Hermes-Skills/okx-auto-position/scripts/okx_position_advisor.py
T
mike 0fd498c2c3 okx-auto-position: 平仓推送删 unrealizedPnl 字段 (改用 fillPnl/不显示)
**Bug**: 平仓推送显示 unrealizedPnl (浮盈) + 不扣手续费
**实际**: 用户从 OKX app 看真盈亏 (-1.9 vs 推送 +2.96 不一致)

**Fix**:
1. okx_position_advisor.py close_position() 用 fillPnl (OKX 真成交字段) 替换 unrealizedPnl
2. format_close_result() 删 '盈亏: X USDT' 字段
3. format_close_message() 删 '浮盈' 字段
4. 平仓推送加 '💡 实际盈亏请到 OKX app 查看 (扣手续费 + funding)' 提示

**保留**:
- format_execution_result() 开仓前预测盈利额 (TP/SL 推算的合理预估)
- record_confirmed 表的 pnl 字段 (历史归档)

预期: 用户平仓时推送不带误导性'X USDT 浮盈', 改从 OKX app 自己看真盈亏
2026-08-05 13:58:03 +08:00

839 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
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 = []
used_margin = 0.0 # 所有币种已用保证金总和
for p in positions:
c = float(p.get('contracts', 0))
if abs(c) > 0.001:
# OKX 不直接返 margin, 但返 notional / leverage = 保证金
notional = float(p.get('notional', 0)) or 0
leverage = float(p.get('leverage', 1)) or 1
margin = notional / leverage if notional > 0 else 0
# 如果 notional 拿不到, 用 contracts * ct_val * price / leverage 估算
# (position_value 字段也常用)
if margin == 0:
pv = float(p.get('positionValue', 0)) or 0
margin = pv / leverage if pv > 0 else 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,
'margin': margin,
})
used_margin += abs(margin)
# 账户总资产 = 可用 USDT + 所有持仓占用保证金
total_capital = usdt_free + used_margin
return {
'usdt_free': usdt_free,
'usdt_total': usdt_total,
'used_margin': used_margin,
'total_capital': total_capital,
'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: 单币种上限 75% × 账户总资产 - 当前同币种已用
# 用户原话: "只持仓一种币的时候, 最多加仓到账户资金的 75%"
single_coin_cap_pct = cfg('position_sizing', 'single_coin_max_pct', 0.75)
base = symbol.split('/')[0] # e.g. 'BTC' from 'BTC/USDT:USDT'
# 当前同币种已用保证金
same_coin_margin = 0.0
for p in acct_info.get('positions', []):
if p['symbol'].startswith(base):
same_coin_margin += abs(p['margin'])
# 单币种总上限 (基于账户总资产)
total_cap = acct_info.get('total_capital', acct_info['usdt_free'])
single_coin_cap = total_cap * single_coin_cap_pct
# 还可加仓 = 上限 - 已用
single_coin_avail = max(0, single_coin_cap - same_coin_margin)
# 同时考虑 free 余额 (不能超过 free)
avail_margin = min(acct_info['usdt_free'], single_coin_avail)
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. SL/TP 挂单 - OKX 专属 (长桥 SDK 不支持 algo order)
# 长桥端: 跳过此步, 靠 time_in_force=Day 让日内单自动平仓
# OKX 端: 用 conditional algo 设单腿 SL (v4.5.0)
is_okx = 'OKX' in str(type(exchange))
if not is_okx:
# 长桥: 跳过 SL, 靠日内规则自动平
results['steps'].append({'step': 'sl_only', 'status': 'skipped', 'msg': 'longbridge 不支持 algo order, 靠 time_in_force=Day 自动平仓'})
else:
# OKX: 设单腿 SL conditional algo
try:
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']
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 REAL close PnL from fills (扣手续费后的已实现盈亏)
# fetch_my_trades 返回字段: price/fee/cost/info/fillPnl (OKX 真实成交)
try:
fills = exchange.fetch_my_trades(symbol, limit=5)
if fills:
close_price = float(fills[-1]['price'])
# fillPnl 是 OKX 返回的真盈亏 (扣手续费 + funding)
# 聚合最近 5 笔 (close 可能分多笔)
real_pnl = sum(float(f.get('info', {}).get('fillPnl', 0)) for f in fills)
else:
close_price = 0
real_pnl = 0
except Exception:
close_price = 0
real_pnl = 0
results['closed'] = {
'symbol': symbol.split('/')[0] + '/USDT',
'side': side,
'contracts': contracts,
'entry': entry,
'close_price': close_price,
'pnl': real_pnl, # 用 fillPnl (真盈亏) 不用 unrealizedPnl (浮盈)
}
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:
# 2026-08-05: 不显示盈亏 (unrealizedPnl 不准, fillPnl 需要查询)
# 用户从 OKX app 自己看实际盈亏
lines.extend([
f"",
f"📊 **{c['symbol']} 平仓确认:**",
f"• 方向: {c['side']}",
f"• 数量: {c['contracts']}张",
f"• 入场价: {c['entry']}",
f"• 平仓价: **{c['close_price']}**",
f"• 已取消止盈止损",
f"",
f"💡 实际盈亏请到 OKX app 查看 (扣手续费 + funding)",
])
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()