Skip to content

Commit 07767f4

Browse files
HenryHenry
authored andcommitted
fix: harden position coexistence safeguards
1 parent e820a74 commit 07767f4

17 files changed

Lines changed: 897 additions & 62 deletions

backend_api_python/app/routes/strategy_position_ownership_routes.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ def get_position_ownership():
107107
if not strategy_id:
108108
return jsonify({"code": 0, "msg": "positionOwnership.missingStrategyId", "data": {"items": []}}), 400
109109
try:
110+
from app.services.live_trading.position_ownership import supports_position_coexistence
111+
110112
rows, context = _load_ownership_rows(int(strategy_id), int(g.user_id))
111113
status = "drift_blocked" if any(row.get("status") == "drift_blocked" for row in rows) else "ok"
112114
return jsonify({
@@ -117,7 +119,10 @@ def get_position_ownership():
117119
"status": status,
118120
"market_type": context["market_type"],
119121
"credential_id": context["credential_id"],
120-
"advanced_coexistence_available": context["market_type"] in {"swap", "spot"},
122+
"advanced_coexistence_available": supports_position_coexistence(
123+
context["market_type"],
124+
str(context["exchange"].get("exchange_id") or ""),
125+
),
121126
},
122127
})
123128
except LookupError:

backend_api_python/app/services/grid/engine.py

Lines changed: 290 additions & 19 deletions
Large diffs are not rendered by default.

backend_api_python/app/services/live_trading/position_ownership.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
ADVANCED_MODE = "advanced"
2727
STATUS_OK = "ok"
2828
STATUS_BLOCKED = "drift_blocked"
29+
COEXISTENCE_MARKET_TYPES = frozenset({"spot", "swap"})
30+
CRYPTO_COEXISTENCE_EXCHANGES = frozenset({
31+
"binance", "bitget", "bybit", "gate", "htx", "okx",
32+
})
2933

3034

3135
def normalize_market_type(value: str) -> str:
@@ -35,6 +39,15 @@ def normalize_market_type(value: str) -> str:
3539
return market
3640

3741

42+
def supports_position_coexistence(value: str, exchange_id: str = "") -> bool:
43+
"""Return whether account/strategy inventory can share one Crypto market leg."""
44+
market = normalize_market_type(value)
45+
if market not in COEXISTENCE_MARKET_TYPES:
46+
return False
47+
exchange = str(exchange_id or "").strip().lower()
48+
return market == "swap" or not exchange or exchange in CRYPTO_COEXISTENCE_EXCHANGES
49+
50+
3851
def normalize_side(value: str) -> str:
3952
side = str(value or "").strip().lower()
4053
return side if side in {"long", "short"} else ""
@@ -283,6 +296,10 @@ def repair_position_ownership(
283296
action_name = str(action or "").strip().lower()
284297
if action_name not in {"protect_manual", "strict_mode", "recheck"}:
285298
raise ValueError("positionOwnership.invalidRepairAction")
299+
if action_name == "protect_manual" and not supports_position_coexistence(
300+
market_type, exchange_id
301+
):
302+
raise ValueError("positionOwnership.coexistenceMarketUnsupported")
286303
existing = _fetch_reservation(
287304
user_id=user_id,
288305
credential_id=credential_id,

backend_api_python/app/services/live_trading/position_query.py

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,29 @@ def query_exchange_position_size(
5656
cfg = exchange_config if isinstance(exchange_config, dict) else {}
5757
sym = str(symbol or "").strip()
5858

59-
# Spot long close = sell base balance.
59+
# Spot ownership is the complete base inventory. Sell sizing has a
60+
# separate free/available clamp because locked balances are still owned.
6061
if mt == "spot":
6162
if side != "long":
6263
return 0.0
6364
try:
64-
from app.services.live_trading.spot_sizing import get_spot_free_base_balance
65-
66-
return max(0.0, float(get_spot_free_base_balance(client, symbol=sym) or 0.0))
65+
from app.services.live_trading.spot_sizing import get_spot_total_base_balance
66+
67+
return max(
68+
0.0,
69+
float(
70+
get_spot_total_base_balance(
71+
client,
72+
symbol=sym,
73+
strict=strict,
74+
)
75+
or 0.0
76+
),
77+
)
6778
except Exception as e:
6879
if strict:
6980
raise
70-
logger.debug("spot free balance query failed symbol=%s: %s", sym, e)
81+
logger.debug("spot total balance query failed symbol=%s: %s", sym, e)
7182
return 0.0
7283

7384
try:
@@ -317,24 +328,23 @@ def resolve_reduce_only_quantity(
317328
# In advanced coexistence mode the manual baseline is a hard floor. Even
318329
# a reduce-only strategy exit may use only quantity above that floor.
319330
if int(user_id or 0) > 0 and int(credential_id or 0) > 0:
320-
try:
321-
from app.services.live_trading.position_ownership import protected_quantity
322-
323-
protected = protected_quantity(
324-
user_id=int(user_id),
325-
credential_id=int(credential_id),
326-
market_type=market_type,
327-
symbol=symbol,
328-
side=pos_side,
329-
)
330-
available = max(0.0, float(exch_size or 0.0) - float(protected or 0.0))
331-
meta["protected_manual_qty"] = protected
332-
meta["exchange_strategy_available"] = available
333-
if amount > available:
334-
amount = available
335-
meta["capped_by"] = "protected_manual_position"
336-
except Exception as exc:
337-
meta["protected_position_check_error"] = str(exc)
331+
from app.services.live_trading.position_ownership import protected_quantity
332+
333+
# Deliberately fail closed. If the protection ledger cannot be read,
334+
# the caller must reject the exit instead of risking manual inventory.
335+
protected = protected_quantity(
336+
user_id=int(user_id),
337+
credential_id=int(credential_id),
338+
market_type=market_type,
339+
symbol=symbol,
340+
side=pos_side,
341+
)
342+
available = max(0.0, float(exch_size or 0.0) - float(protected or 0.0))
343+
meta["protected_manual_qty"] = protected
344+
meta["exchange_strategy_available"] = available
345+
if amount > available:
346+
amount = available
347+
meta["capped_by"] = "protected_manual_position"
338348

339349
meta["resolved"] = amount
340350
return amount, meta

backend_api_python/app/services/live_trading/spot_sizing.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,23 +73,32 @@ def _pick_cost_from_row(row: Dict[str, Any], *keys: str) -> float:
7373
return 0.0
7474

7575

76-
def _spot_holding(total: float, available: float, avg_cost: float = 0.0) -> Dict[str, float]:
76+
def _spot_holding(
77+
total: float,
78+
available: Optional[float],
79+
avg_cost: float = 0.0,
80+
) -> Dict[str, float]:
7781
t = max(0.0, float(total or 0.0))
78-
a = max(0.0, float(available or 0.0))
82+
# ``0`` is a valid available balance when the whole holding is locked.
83+
# Only a genuinely absent value may fall back to total.
84+
a = t if available is None else max(0.0, float(available or 0.0))
7985
if t <= 0 and a <= 0:
8086
return {"total": 0.0, "available": 0.0, "avg_cost": 0.0}
8187
if t <= 0:
8288
t = a
83-
if a <= 0:
84-
a = t
8589
return {
8690
"total": t,
8791
"available": a,
8892
"avg_cost": max(0.0, float(avg_cost or 0.0)),
8993
}
9094

9195

92-
def get_spot_base_holding(client: BaseRestClient, *, symbol: str) -> Dict[str, float]:
96+
def get_spot_base_holding(
97+
client: BaseRestClient,
98+
*,
99+
symbol: str,
100+
strict: bool = False,
101+
) -> Dict[str, float]:
93102
"""
94103
Best-effort spot base-asset holding (total + available/free).
95104
@@ -113,6 +122,8 @@ def get_spot_base_holding(client: BaseRestClient, *, symbol: str) -> Dict[str, f
113122
locked = _pick_free_from_row(b, "locked")
114123
return _spot_holding(free + locked, free)
115124
except Exception as e:
125+
if strict:
126+
raise
116127
logger.warning("spot base holding (binance): %s", e)
117128

118129
try:
@@ -137,6 +148,8 @@ def get_spot_base_holding(client: BaseRestClient, *, symbol: str) -> Dict[str, f
137148
)
138149
return _spot_holding(total, avail, avg_cost)
139150
except Exception as e:
151+
if strict:
152+
raise
140153
logger.warning("spot base holding (okx): %s", e)
141154

142155
try:
@@ -155,6 +168,8 @@ def get_spot_base_holding(client: BaseRestClient, *, symbol: str) -> Dict[str, f
155168
locked = _pick_free_from_row(row, "locked", "freeze")
156169
return _spot_holding(avail + locked, avail)
157170
except Exception as e:
171+
if strict:
172+
raise
158173
logger.warning("spot base holding (gate): %s", e)
159174

160175
try:
@@ -177,6 +192,8 @@ def get_spot_base_holding(client: BaseRestClient, *, symbol: str) -> Dict[str, f
177192
)
178193
return _spot_holding(total, avail, avg_cost)
179194
except Exception as e:
195+
if strict:
196+
raise
180197
logger.warning("spot base holding (bitget): %s", e)
181198

182199
try:
@@ -201,6 +218,8 @@ def get_spot_base_holding(client: BaseRestClient, *, symbol: str) -> Dict[str, f
201218
)
202219
return _spot_holding(total, avail, avg_cost)
203220
except Exception as e:
221+
if strict:
222+
raise
204223
logger.warning("spot base holding (bybit): %s", e)
205224

206225
try:
@@ -217,6 +236,8 @@ def get_spot_base_holding(client: BaseRestClient, *, symbol: str) -> Dict[str, f
217236
avail = _pick_free_from_row(item, "available", "balance")
218237
return _spot_holding(total, avail)
219238
except Exception as e:
239+
if strict:
240+
raise
220241
logger.warning("spot base holding (htx): %s", e)
221242

222243
return {"total": 0.0, "available": 0.0, "avg_cost": 0.0}
@@ -231,6 +252,23 @@ def get_spot_free_base_balance(client: BaseRestClient, *, symbol: str) -> float:
231252
return max(0.0, float(holding.get("available") or 0.0))
232253

233254

255+
def get_spot_total_base_balance(
256+
client: BaseRestClient,
257+
*,
258+
symbol: str,
259+
strict: bool = False,
260+
) -> float:
261+
"""Best-effort total base inventory, including exchange-locked quantity.
262+
263+
Ownership and drift checks must use the whole account inventory. Open
264+
limit orders can move quantity from ``available`` to ``locked`` without
265+
changing ownership, so using the sellable balance here would create a
266+
false negative drift.
267+
"""
268+
holding = get_spot_base_holding(client, symbol=symbol, strict=strict)
269+
return max(0.0, float(holding.get("total") or 0.0))
270+
271+
234272
def fetch_spot_last_price(client: BaseRestClient, *, symbol: str) -> float:
235273
"""Best-effort last price for USDT -> base conversion (supports Bitget ``lastPr``)."""
236274
if not hasattr(client, "get_ticker"):

backend_api_python/app/services/pending_order_worker.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
credential_id_from_exchange_config,
4646
)
4747
from app.services.live_trading.position_query import resolve_reduce_only_quantity
48+
from app.services.live_trading.position_ownership import supports_position_coexistence
4849
from app.utils.pnl import calc_notional_value
4950
from app.services.live_trading.base import LiveTradingError, is_file_descriptor_exhausted
5051
from app.services.pending_orders.fill_records import (
@@ -1788,7 +1789,8 @@ def _execute_live_order(self, *, order_id: int, order_row: Dict[str, Any], paylo
17881789
append_strategy_log(strategy_id, "error", f"Order rejected because the exchange position snapshot failed: {symbol}")
17891790
return
17901791

1791-
if not reduce_only and market_type == "swap":
1792+
ownership_enabled = supports_position_coexistence(market_type, exchange_id)
1793+
if not reduce_only and ownership_enabled:
17921794
credential_id = credential_id_from_exchange_config(exchange_config)
17931795
guard = evaluate_entry_position_guard(
17941796
client=client,
@@ -1813,7 +1815,7 @@ def _execute_live_order(self, *, order_id: int, order_row: Dict[str, Any], paylo
18131815

18141816
# Collect raw exchange interactions / intermediate states for debugging & persistence.
18151817
phases: Dict[str, Any] = {"pre_position_qty": pre_position_qty}
1816-
if not reduce_only and market_type == "swap":
1818+
if not reduce_only and ownership_enabled:
18171819
phases["position_ownership"] = phases_ownership
18181820

18191821
if not reduce_only and market_type == "swap":
@@ -1874,8 +1876,17 @@ def _execute_live_order(self, *, order_id: int, order_row: Dict[str, Any], paylo
18741876
if close_meta:
18751877
phases["close_size_resolve"] = close_meta
18761878
except Exception as e:
1879+
error = f"protected_position_check_failed:{e}"
18771880
logger.error(f"[RiskControl] Failed to resolve close quantity: {e}")
18781881
phases["close_size_resolve_error"] = str(e)
1882+
self._mark_failed(order_id=order_id, error=error)
1883+
_notify_live_best_effort(status="failed", error=error)
1884+
append_strategy_log(
1885+
strategy_id,
1886+
"error",
1887+
f"Close rejected because protected inventory could not be verified: {symbol}",
1888+
)
1889+
return
18791890

18801891
# Ensure ref price exists (used by maker pricing, fallbacks, and local DB snapshots).
18811892
if ref_price <= 0:

backend_api_python/app/services/pending_orders/entry_position_guard.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from app.services.live_trading.position_ownership import (
99
evaluate_and_record_ownership,
10+
normalize_market_type,
1011
ownership_log_message,
1112
)
1213
from app.services.live_trading.position_query import query_exchange_position_size
@@ -79,6 +80,12 @@ def evaluate_entry_position_guard(
7980
log_message=ownership_log_message(snapshot) if snapshot.should_log else "",
8081
)
8182

83+
# Spot has only the long inventory lane. Its ownership baseline and drift
84+
# rules are identical to swap, but there is no opposite exchange leg to
85+
# inspect before an entry.
86+
if normalize_market_type(market_type) == "spot":
87+
return EntryPositionGuardResult(ownership=metadata)
88+
8289
if strategy_allows_simultaneous_legs(strategy_config):
8390
return EntryPositionGuardResult(ownership=metadata)
8491

backend_api_python/app/services/trading_executor.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -999,19 +999,22 @@ def _execute_signal(self, **values: Any) -> bool:
999999
return False
10001000
action = str(values.get("signal_type") or "").strip().lower()
10011001
market_type = str(values.get("market_type") or "swap").strip().lower()
1002-
if action in {"open_long", "add_long", "open_short", "add_short"} and market_type in {
1003-
"swap", "future", "futures", "perp", "perpetual",
1004-
}:
1002+
if action in {"open_long", "add_long", "open_short", "add_short"}:
10051003
try:
10061004
from app.services.exchange_execution import resolve_exchange_config
10071005
from app.services.live_trading.leg_context import credential_id_from_exchange_config
1008-
from app.services.live_trading.position_ownership import is_position_leg_blocked
1006+
from app.services.live_trading.position_ownership import (
1007+
is_position_leg_blocked,
1008+
supports_position_coexistence,
1009+
)
10091010

10101011
resolved_exchange = resolve_exchange_config(
10111012
_json_object(strategy.get("exchange_config")),
10121013
user_id=int(strategy.get("user_id") or 0),
10131014
)
1014-
if is_position_leg_blocked(
1015+
if supports_position_coexistence(
1016+
market_type, str(resolved_exchange.get("exchange_id") or "")
1017+
) and is_position_leg_blocked(
10151018
user_id=int(strategy.get("user_id") or 0),
10161019
credential_id=int(credential_id_from_exchange_config(resolved_exchange) or 0),
10171020
market_type=market_type,

backend_api_python/tests/test_grid_credentials.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ def _create_client():
6969

7070
with patch("app.services.grid.engine.place_grid_limit_order") as place:
7171
place.return_value = MagicMock(exchange_order_id="ex1")
72-
with patch("app.services.grid.engine.GridRestingOrderRepository") as repo_cls:
72+
with patch(
73+
"app.services.grid.engine.GridEngine._grid_entry_ownership_allowed",
74+
return_value=(True, {}),
75+
), patch("app.services.grid.engine.GridRestingOrderRepository") as repo_cls:
7376
repo = repo_cls.return_value
7477
repo.has_open_for_cell.return_value = False
7578
repo.insert.return_value = 1
@@ -89,7 +92,13 @@ def _create_client():
8992
"initialPositionPct": 0,
9093
},
9194
},
92-
{"exchange_id": "okx", "api_key": "k", "secret_key": "s", "passphrase": "p"},
95+
{
96+
"exchange_id": "okx",
97+
"credential_id": 9,
98+
"api_key": "k",
99+
"secret_key": "s",
100+
"passphrase": "p",
101+
},
93102
user_id=1,
94103
initial_capital=1000,
95104
enqueue_market_fn=_enqueue,

0 commit comments

Comments
 (0)