v4.5.5: 单币种上限 75% × 账户总资产 (用户原话 2026-07-13)

- get_account_info 加 used_margin / total_capital (free + 所有币种占用)
- recommend_position 用 single_coin_cap_pct=0.75 (config 可调)
- 算每个币种当前已用保证金, 加仓时还可用 = cap - 已用
- 新开 / 加仓都遵循 75% 上限 (有区别: 新开 = 75% cap, 加仓 = cap - 已用)
- 之前 balance_utilization=0.45 (算 free 的 45%) 被替换

测试 (SKHYNIX 持仓 0.216 张 0.58):
- 加仓 SKHYNIX: 0.64 (75% × 08.26 cap - 0.58 已用)
- 新开 BTC: 7.42 (75% × 08.26 cap)
- 新开 ETH: 7.74 (同上)
This commit is contained in:
2026-07-15 07:38:28 +08:00
parent 8c03e22074
commit 4d02a9d895
7 changed files with 85 additions and 46 deletions
@@ -61,8 +61,19 @@ def get_account_info(exchange):
positions = exchange.fetch_positions()
active = []
used_margin = 0.0 # 所有币种已用保证金总和
for p in positions:
if float(p.get('contracts', 0)) > 0:
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'],
@@ -70,11 +81,18 @@ def get_account_info(exchange):
'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,
}
@@ -203,8 +221,25 @@ def recommend_position(symbol, side, leverage, exchange, acct_info):
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)
# 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:
@@ -486,50 +521,54 @@ def execute_order(exchange, rec):
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',
}
# 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']
# 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)})
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: