@@ -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+
15751686def _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 ,
0 commit comments