Skip to content

Commit 4e8e527

Browse files
v3.0.17
Signed-off-by: Dinger <[email protected]>
1 parent 57b54c5 commit 4e8e527

2 files changed

Lines changed: 232 additions & 49 deletions

File tree

backend_api_python/app/routes/quick_trade.py

Lines changed: 117 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1383,7 +1383,8 @@ def _fetch_exchange_positions_raw(
13831383
else:
13841384
inst_id = to_okx_swap_inst_id(symbol)
13851385
inst_type = "SWAP"
1386-
return client.get_positions(inst_id=inst_id, inst_type=inst_type)
1386+
raw = client.get_positions(inst_id=inst_id, inst_type=inst_type)
1387+
return _normalize_okx_positions_raw(raw)
13871388

13881389
if isinstance(client, BinanceFuturesClient):
13891390
return client.get_positions(symbol=symbol)
@@ -1462,6 +1463,9 @@ def _fetch_exchange_positions_raw(
14621463
base_amt = client.contracts_signed_to_base_qty(contract=c, contracts_signed=ct_sz)
14631464
if base_amt > 0:
14641465
q["positionAmt"] = base_amt
1466+
# Preserve direction for _parse_positions — Gate encodes short as
1467+
# negative contract size but positionAmt is always positive.
1468+
q["positionSide"] = "LONG" if ct_sz > 0 else "SHORT"
14651469
out.append(q)
14661470
logger.info("Gate filtered positions for %s: %d items, sizes=%s", c, len(out),
14671471
[(p.get("size"), p.get("positionAmt")) for p in out])
@@ -1572,6 +1576,113 @@ def get_position():
15721576
return jsonify({"code": 0, "msg": str(e)}), 500
15731577

15741578

1579+
def _normalize_okx_positions_raw(raw: Any) -> Any:
1580+
"""
1581+
OKX net-mode rows use ``posSide=net`` with a *signed* ``pos`` (negative = short).
1582+
Attach ``positionSide`` so downstream parsers never default to long when posSide
1583+
is present but not literally ``long``/``short``.
1584+
"""
1585+
if not isinstance(raw, dict):
1586+
return raw
1587+
data = raw.get("data")
1588+
if not isinstance(data, list):
1589+
return raw
1590+
out_rows = []
1591+
for item in data:
1592+
if not isinstance(item, dict):
1593+
out_rows.append(item)
1594+
continue
1595+
row = dict(item)
1596+
ps = str(row.get("posSide") or "").strip().lower()
1597+
if ps in ("long", "short"):
1598+
row.setdefault("positionSide", ps.upper())
1599+
elif ps == "net":
1600+
signed = None
1601+
for key in ("pos", "availPos", "posAmt"):
1602+
try:
1603+
v = float(row.get(key) or 0)
1604+
except (TypeError, ValueError):
1605+
continue
1606+
if abs(v) > 1e-10:
1607+
signed = v
1608+
break
1609+
if signed is not None:
1610+
row["positionSide"] = "SHORT" if signed < 0 else "LONG"
1611+
out_rows.append(row)
1612+
out = dict(raw)
1613+
out["data"] = out_rows
1614+
return out
1615+
1616+
1617+
def _extract_signed_position_qty(item: dict) -> float:
1618+
"""Return signed position qty; OKX ``pos`` must be checked before abs-only fields."""
1619+
for key in (
1620+
"pos", "positionAmt", "posAmt", "size", "currentQty", "volume",
1621+
"contracts", "total", "current_qty", "availPos",
1622+
):
1623+
try:
1624+
v = float(item.get(key) or 0)
1625+
except (TypeError, ValueError):
1626+
continue
1627+
if abs(v) > 1e-10:
1628+
return v
1629+
return 0.0
1630+
1631+
1632+
def _infer_position_side_from_row(item: dict) -> str:
1633+
"""Map heterogeneous exchange position rows to ``long`` / ``short``."""
1634+
psu = str(item.get("positionSide") or item.get("position_side") or "").strip().upper()
1635+
if psu == "SHORT":
1636+
return "short"
1637+
if psu == "LONG":
1638+
return "long"
1639+
1640+
pos_side = str(item.get("posSide") or "").strip().lower()
1641+
if pos_side in ("long", "short"):
1642+
return pos_side
1643+
1644+
# OKX 买卖模式 (net_mode): posSide=net, sign lives on pos / availPos
1645+
if pos_side == "net":
1646+
signed = _extract_signed_position_qty(item)
1647+
if signed < -1e-10:
1648+
return "short"
1649+
if signed > 1e-10:
1650+
return "long"
1651+
1652+
hold = str(item.get("holdSide") or "").strip().lower()
1653+
if hold in ("long", "short"):
1654+
return hold
1655+
1656+
try:
1657+
idx = int(item.get("positionIdx") or 0)
1658+
if idx == 1:
1659+
return "long"
1660+
if idx == 2:
1661+
return "short"
1662+
except (TypeError, ValueError):
1663+
pass
1664+
1665+
exch_side = str(item.get("side") or "").strip().lower()
1666+
if exch_side in ("sell", "s", "short"):
1667+
return "short"
1668+
if exch_side in ("buy", "b", "long"):
1669+
return "long"
1670+
1671+
direction = str(item.get("direction") or "").strip().lower()
1672+
if direction in ("sell", "short", "open_short"):
1673+
return "short"
1674+
if direction in ("buy", "long", "open_long"):
1675+
return "long"
1676+
1677+
# Signed quantity fallbacks (Binance one-way, Gate, KuCoin, …)
1678+
signed = _extract_signed_position_qty(item)
1679+
if signed < -1e-10:
1680+
return "short"
1681+
if signed > 1e-10:
1682+
return "long"
1683+
return "long"
1684+
1685+
15751686
def _parse_positions(raw: Any) -> list:
15761687
"""Best-effort parse positions from exchange response."""
15771688
result = []
@@ -1611,56 +1722,13 @@ def _parse_positions(raw: Any) -> list:
16111722
if len(parts) == 2 and parts[0] and parts[1]:
16121723
display_symbol = f"{parts[0]}/{parts[1]}"
16131724
break
1614-
# For OKX, position size can be in different fields
1615-
# SWAP: posAmt, pos
1616-
# Binance futures: positionAmt
1617-
# SPOT: bal (balance), availBal (available balance)
1618-
size = float(
1619-
item.get("positionAmt")
1620-
or item.get("posAmt")
1621-
or item.get("pos")
1622-
or item.get("total")
1623-
or item.get("currentQty")
1624-
or item.get("available")
1625-
or item.get("size")
1626-
or item.get("contracts")
1627-
or item.get("bal")
1628-
or item.get("availBal")
1629-
or item.get("volume")
1630-
or item.get("current_qty")
1631-
or 0
1632-
)
1725+
# For OKX, pos is signed in net_mode — read before abs-only aliases.
1726+
size = _extract_signed_position_qty(item)
16331727
if abs(size) < 1e-10:
16341728
continue
1635-
1636-
# Binance hedge: positionSide LONG/SHORT with positive positionAmt; one-way: BOTH + signed amt
1637-
side = "long"
1638-
psu = str(item.get("positionSide", "")).strip().upper()
1639-
if psu == "SHORT":
1640-
side = "short"
1641-
elif psu == "LONG":
1642-
side = "long"
1643-
elif item.get("posSide"):
1644-
pos_side = str(item.get("posSide", "")).strip().lower()
1645-
if pos_side in ("long", "short"):
1646-
side = pos_side
1647-
elif str(item.get("holdSide") or "").strip().lower() == "short":
1648-
side = "short"
1649-
elif str(item.get("holdSide") or "").strip().lower() == "long":
1650-
side = "long"
1651-
elif str(item.get("side") or "").strip().lower() in ("sell", "s"):
1652-
side = "short"
1653-
elif str(item.get("side") or "").strip().lower() in ("buy", "b"):
1654-
side = "long"
1655-
elif size < 0:
1656-
side = "short"
1657-
elif item.get("direction"):
1658-
dir_side = str(item.get("direction") or "").strip().lower()
1659-
if dir_side in ("buy", "long"):
1660-
side = "long"
1661-
elif dir_side in ("sell", "short"):
1662-
side = "short"
1663-
1729+
1730+
side = _infer_position_side_from_row(item)
1731+
16641732
result.append({
16651733
"symbol": display_symbol,
16661734
"side": side,
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Tests for Quick Trade position side parsing (long vs short display)."""
2+
3+
from app.routes.quick_trade import _infer_position_side_from_row, _parse_positions
4+
5+
6+
def test_binance_hedge_short():
7+
row = {"positionSide": "SHORT", "positionAmt": "109", "symbol": "APTUSDT"}
8+
assert _infer_position_side_from_row(row) == "short"
9+
10+
11+
def test_binance_one_way_short_signed_amt():
12+
row = {"positionSide": "BOTH", "positionAmt": "-109", "symbol": "APTUSDT"}
13+
assert _infer_position_side_from_row(row) == "short"
14+
15+
16+
def test_bybit_sell_side():
17+
row = {"side": "Sell", "size": "109", "symbol": "APTUSDT"}
18+
assert _infer_position_side_from_row(row) == "short"
19+
20+
21+
def test_bybit_position_idx_short():
22+
row = {"positionIdx": 2, "size": "109", "symbol": "APTUSDT"}
23+
assert _infer_position_side_from_row(row) == "short"
24+
25+
26+
def test_gate_negative_contract_size():
27+
row = {"size": -50, "positionAmt": 109.0, "symbol": "APT_USDT"}
28+
assert _infer_position_side_from_row(row) == "short"
29+
30+
31+
def test_gate_position_side_short():
32+
row = {"size": -50, "positionAmt": 109.0, "positionSide": "SHORT", "symbol": "APT_USDT"}
33+
assert _infer_position_side_from_row(row) == "short"
34+
35+
36+
def test_htx_direction_sell():
37+
row = {"volume": 10, "direction": "sell", "contract_code": "APT-USDT"}
38+
assert _infer_position_side_from_row(row) == "short"
39+
40+
41+
def test_parse_positions_gate_short_row():
42+
raw = [{"size": -50, "positionAmt": 109.0, "positionSide": "SHORT", "contract": "APT_USDT"}]
43+
out = _parse_positions(raw)
44+
assert len(out) == 1
45+
assert out[0]["side"] == "short"
46+
assert out[0]["size"] == 109.0
47+
48+
49+
def test_okx_net_mode_short_negative_pos():
50+
"""OKX 买卖模式: posSide=net + pos<0 必须识别为空仓."""
51+
row = {
52+
"instId": "APT-USDT-SWAP",
53+
"posSide": "net",
54+
"pos": "-109",
55+
"avgPx": "0.9129",
56+
"upl": "0.1526",
57+
}
58+
assert _infer_position_side_from_row(row) == "short"
59+
60+
61+
def test_okx_net_mode_long_positive_pos():
62+
row = {"posSide": "net", "pos": "50", "instId": "APT-USDT-SWAP"}
63+
assert _infer_position_side_from_row(row) == "long"
64+
65+
66+
def test_okx_long_short_mode_short():
67+
row = {"posSide": "short", "pos": "109", "instId": "APT-USDT-SWAP"}
68+
assert _infer_position_side_from_row(row) == "short"
69+
70+
71+
def test_parse_positions_okx_net_mode_wrapper():
72+
raw = {
73+
"code": "0",
74+
"data": [
75+
{
76+
"instId": "APT-USDT-SWAP",
77+
"posSide": "net",
78+
"pos": "-109",
79+
"avgPx": "0.9129",
80+
"upl": "0.1526",
81+
"markPx": "0.9115",
82+
}
83+
],
84+
}
85+
out = _parse_positions(raw)
86+
assert len(out) == 1
87+
assert out[0]["side"] == "short"
88+
assert out[0]["size"] == 109.0
89+
90+
91+
def test_normalize_okx_positions_raw_net_short():
92+
from app.routes.quick_trade import _normalize_okx_positions_raw
93+
94+
raw = {"data": [{"posSide": "net", "pos": "-109"}]}
95+
norm = _normalize_okx_positions_raw(raw)
96+
assert norm["data"][0]["positionSide"] == "SHORT"
97+
98+
99+
def test_parse_positions_bybit_list_wrapper():
100+
raw = {
101+
"result": {
102+
"list": [
103+
{
104+
"symbol": "APTUSDT",
105+
"side": "Sell",
106+
"size": "109",
107+
"entryPrice": "0.9129",
108+
"unrealisedPnl": "0.15",
109+
}
110+
]
111+
}
112+
}
113+
out = _parse_positions(raw)
114+
assert len(out) == 1
115+
assert out[0]["side"] == "short"

0 commit comments

Comments
 (0)