mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 15:22:56 +08:00
710 lines
25 KiB
Python
710 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
import pytest
|
|
|
|
from app.services.live_trading.account_positions import reconcile_strategy_vs_account
|
|
from app.services.pending_orders.live_order_support import FillAccumulator, signal_to_side_pos_reduce
|
|
from app.services.live_trading import account_risk, records
|
|
from app.services import strategy_live_guard
|
|
from app.services.pending_order_worker import _strategy_allows_simultaneous_legs
|
|
from app.services.trading_executor import TradingExecutor
|
|
|
|
|
|
def _strategy(
|
|
strategy_id: int,
|
|
side: str,
|
|
*,
|
|
status: str = "running",
|
|
direction_mode: str = "",
|
|
) -> dict:
|
|
trading_config = {
|
|
"symbol": "BTC/USDT",
|
|
"market_type": "swap",
|
|
"position_side": side,
|
|
"leverage": 5,
|
|
}
|
|
if direction_mode:
|
|
trading_config["direction_mode"] = direction_mode
|
|
return {
|
|
"id": strategy_id,
|
|
"user_id": 7,
|
|
"status": status,
|
|
"execution_mode": "live",
|
|
"market_type": "swap",
|
|
"symbol": "BTC/USDT",
|
|
"position_side": side,
|
|
"initial_capital": 1_000.0,
|
|
"leverage": 5,
|
|
"exchange_config": {"exchange_id": "okx", "credential_id": 17},
|
|
"direction_mode": direction_mode,
|
|
"trading_config": trading_config,
|
|
}
|
|
|
|
|
|
def _neutral_grid_strategy(strategy_id: int = 20) -> dict:
|
|
row = _strategy(strategy_id, "neutral")
|
|
row["trading_config"].update({
|
|
"bot_type": "grid",
|
|
"bot_params": {
|
|
"gridDirection": "neutral",
|
|
"lowerPrice": 0.98,
|
|
"upperPrice": 1.02,
|
|
"dynamicAnchor": True,
|
|
},
|
|
})
|
|
return row
|
|
|
|
|
|
def test_position_reader_returns_existing_leg_size(monkeypatch):
|
|
monkeypatch.setattr(records, "_fetch_position_fuzzy", lambda *_args: ({"size": "1.25"}, "BTC/USDT"))
|
|
assert records.fetch_position_size_for_side(3, "BTCUSDT", "long") == pytest.approx(1.25)
|
|
|
|
|
|
def test_position_reader_returns_zero_for_missing_or_invalid_leg(monkeypatch):
|
|
monkeypatch.setattr(records, "_fetch_position_fuzzy", lambda *_args: (None, ""))
|
|
assert records.fetch_position_size_for_side(3, "BTCUSDT", "long") == 0.0
|
|
monkeypatch.setattr(records, "_fetch_position_fuzzy", lambda *_args: ({"size": "invalid"}, "BTC/USDT"))
|
|
assert records.fetch_position_size_for_side(3, "BTCUSDT", "long") == 0.0
|
|
|
|
|
|
class _Cursor:
|
|
def __init__(self, rows=None, one=None):
|
|
self.rows = list(rows or [])
|
|
self.one = one or {}
|
|
|
|
def execute(self, *_args, **_kwargs):
|
|
return None
|
|
|
|
def fetchall(self):
|
|
return list(self.rows)
|
|
|
|
def fetchone(self):
|
|
return dict(self.one)
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
|
|
class _Db:
|
|
def __init__(self, cursor):
|
|
self._cursor = cursor
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def cursor(self):
|
|
return self._cursor
|
|
|
|
def commit(self):
|
|
return None
|
|
|
|
|
|
def test_live_lock_allows_opposite_leg_and_rejects_same_leg(monkeypatch):
|
|
target = _strategy(10, "long")
|
|
others = {
|
|
11: _strategy(11, "short"),
|
|
12: _strategy(12, "long"),
|
|
}
|
|
keys = {
|
|
10: (7, 17, "okx", "swap", "BTC/USDT", "long"),
|
|
11: (7, 17, "okx", "swap", "BTC/USDT", "short"),
|
|
12: (7, 17, "okx", "swap", "BTC/USDT", "long"),
|
|
}
|
|
|
|
class _Service:
|
|
@staticmethod
|
|
def get_strategy(strategy_id, user_id=None):
|
|
return others.get(strategy_id)
|
|
|
|
monkeypatch.setattr(strategy_live_guard, "strategy_live_lock_key", lambda row, _uid: keys[int(row["id"])])
|
|
monkeypatch.setattr(strategy_live_guard, "get_strategy_service", lambda: _Service())
|
|
|
|
monkeypatch.setattr(strategy_live_guard, "get_db_connection", lambda: _Db(_Cursor([{"id": 11}])))
|
|
assert strategy_live_guard.find_live_strategy_conflict(target, 7) is None
|
|
|
|
monkeypatch.setattr(strategy_live_guard, "get_db_connection", lambda: _Db(_Cursor([{"id": 11}, {"id": 12}])))
|
|
conflict = strategy_live_guard.find_live_strategy_conflict(target, 7)
|
|
assert conflict["strategy_id"] == 12
|
|
assert conflict["position_side"] == "long"
|
|
|
|
|
|
def test_swap_preflight_fails_closed_when_position_mode_is_unknown(monkeypatch):
|
|
from app.services.grid import exchange_requirements
|
|
from app.services.live_trading import factory
|
|
from app.services import exchange_execution
|
|
|
|
executor = TradingExecutor()
|
|
monkeypatch.setattr(executor, "_load_strategy", lambda _sid: _strategy(20, "long"))
|
|
monkeypatch.setattr(strategy_live_guard, "find_live_strategy_conflict", lambda *_args: None)
|
|
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", lambda *_args, **_kwargs: {"exchange_id": "okx"})
|
|
monkeypatch.setattr(factory, "create_client", lambda *_args, **_kwargs: object())
|
|
monkeypatch.setattr(
|
|
exchange_requirements,
|
|
"detect_hedge_position_mode",
|
|
lambda *_args, **_kwargs: (None, "OKX"),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="strategyV2.hedgeModeUnknown"):
|
|
executor._preflight_live_strategy(20)
|
|
|
|
|
|
def test_swap_preflight_accepts_one_way_strategy_on_net_account(monkeypatch):
|
|
from app.services.grid import exchange_requirements
|
|
from app.services.live_trading import factory
|
|
from app.services import exchange_execution
|
|
|
|
executor = TradingExecutor()
|
|
monkeypatch.setattr(
|
|
executor,
|
|
"_load_strategy",
|
|
lambda _sid: _strategy(20, "", direction_mode="one_way"),
|
|
)
|
|
conflict_calls = []
|
|
monkeypatch.setattr(
|
|
strategy_live_guard,
|
|
"find_live_strategy_conflict",
|
|
lambda *_args, **kwargs: conflict_calls.append(kwargs) or None,
|
|
)
|
|
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", lambda *_args, **_kwargs: {"exchange_id": "okx"})
|
|
monkeypatch.setattr(factory, "create_client", lambda *_args, **_kwargs: object())
|
|
monkeypatch.setattr(
|
|
exchange_requirements,
|
|
"detect_hedge_position_mode",
|
|
lambda *_args, **_kwargs: (False, "okx_net_mode"),
|
|
)
|
|
|
|
executor._preflight_live_strategy(20)
|
|
assert conflict_calls == [{"allow_opposite_leg": False}]
|
|
|
|
|
|
def test_swap_preflight_rejects_one_way_strategy_on_hedge_account(monkeypatch):
|
|
from app.services.grid import exchange_requirements
|
|
from app.services.live_trading import factory
|
|
from app.services import exchange_execution
|
|
|
|
executor = TradingExecutor()
|
|
monkeypatch.setattr(
|
|
executor,
|
|
"_load_strategy",
|
|
lambda _sid: _strategy(20, "", direction_mode="one_way"),
|
|
)
|
|
monkeypatch.setattr(strategy_live_guard, "find_live_strategy_conflict", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", lambda *_args, **_kwargs: {"exchange_id": "okx"})
|
|
monkeypatch.setattr(factory, "create_client", lambda *_args, **_kwargs: object())
|
|
monkeypatch.setattr(
|
|
exchange_requirements,
|
|
"detect_hedge_position_mode",
|
|
lambda *_args, **_kwargs: (True, "okx_long_short_mode"),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="strategyV2.oneWayPositionModeRequired"):
|
|
executor._preflight_live_strategy(20)
|
|
|
|
|
|
def test_swap_preflight_accepts_confirmed_hedge_mode(monkeypatch):
|
|
from app.services.grid import exchange_requirements
|
|
from app.services.live_trading import factory
|
|
from app.services import exchange_execution
|
|
|
|
executor = TradingExecutor()
|
|
monkeypatch.setattr(executor, "_load_strategy", lambda _sid: _strategy(20, "short"))
|
|
conflict_calls = []
|
|
monkeypatch.setattr(
|
|
strategy_live_guard,
|
|
"find_live_strategy_conflict",
|
|
lambda *_args, **kwargs: conflict_calls.append(kwargs) or None,
|
|
)
|
|
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", lambda *_args, **_kwargs: {"exchange_id": "okx"})
|
|
monkeypatch.setattr(factory, "create_client", lambda *_args, **_kwargs: object())
|
|
monkeypatch.setattr(
|
|
exchange_requirements,
|
|
"detect_hedge_position_mode",
|
|
lambda *_args, **_kwargs: (True, "OKX"),
|
|
)
|
|
executor._preflight_live_strategy(20)
|
|
assert conflict_calls == [{"allow_opposite_leg": True}]
|
|
|
|
|
|
def test_neutral_grid_preflight_requires_confirmed_hedge_mode(monkeypatch):
|
|
from app.services.grid import exchange_requirements
|
|
from app.services.live_trading import factory
|
|
from app.services import exchange_execution
|
|
|
|
executor = TradingExecutor()
|
|
monkeypatch.setattr(executor, "_load_strategy", lambda _sid: _neutral_grid_strategy())
|
|
monkeypatch.setattr(strategy_live_guard, "find_live_strategy_conflict", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", lambda *_args, **_kwargs: {"exchange_id": "okx"})
|
|
monkeypatch.setattr(factory, "create_client", lambda *_args, **_kwargs: object())
|
|
monkeypatch.setattr(
|
|
exchange_requirements,
|
|
"detect_hedge_position_mode",
|
|
lambda *_args, **_kwargs: (False, "okx_net_mode"),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="strategyV2.dualDirectionHedgeModeRequired"):
|
|
executor._preflight_live_strategy(20)
|
|
|
|
|
|
def test_direction_capability_rejects_incompatible_signal():
|
|
strategy = _strategy(21, "long")
|
|
|
|
strategy_live_guard.validate_strategy_signal_direction(strategy, "open_long")
|
|
strategy_live_guard.validate_strategy_signal_direction(strategy, "close_long")
|
|
strategy_live_guard.validate_strategy_signal_direction(strategy, "close_short")
|
|
with pytest.raises(RuntimeError, match="strategyV2.directionModeViolation:long_only:short"):
|
|
strategy_live_guard.validate_strategy_signal_direction(strategy, "open_short")
|
|
|
|
|
|
def test_pending_executor_allows_opposite_leg_only_for_dual_direction_strategy():
|
|
assert _strategy_allows_simultaneous_legs(_strategy(22, "neutral")) is True
|
|
assert _strategy_allows_simultaneous_legs(_strategy(23, "long")) is False
|
|
assert _strategy_allows_simultaneous_legs(_strategy(24, "short")) is False
|
|
|
|
|
|
def test_live_position_snapshot_keeps_both_owned_legs(monkeypatch):
|
|
executor = TradingExecutor()
|
|
strategy = _strategy(25, "neutral")
|
|
candidates = [{
|
|
"key": "Crypto:BTC/USDT@okx:swap",
|
|
"symbol": "BTC/USDT",
|
|
}]
|
|
monkeypatch.setattr(
|
|
executor,
|
|
"_get_current_positions",
|
|
lambda *_args: [
|
|
{
|
|
"symbol": "BTC/USDT",
|
|
"side": "long",
|
|
"size": 1.25,
|
|
"entry_price": 100,
|
|
"current_price": 101,
|
|
},
|
|
{
|
|
"symbol": "BTC/USDT",
|
|
"side": "short",
|
|
"size": 2.5,
|
|
"entry_price": 102,
|
|
"current_price": 101,
|
|
},
|
|
],
|
|
)
|
|
|
|
snapshot = executor._positions_by_symbol(
|
|
25,
|
|
candidates,
|
|
strategy=strategy,
|
|
)
|
|
|
|
assert set(snapshot) == {
|
|
"Crypto:BTC/USDT@okx:swap::long",
|
|
"Crypto:BTC/USDT@okx:swap::short",
|
|
}
|
|
assert snapshot["Crypto:BTC/USDT@okx:swap::long"]["amount"] == pytest.approx(1.25)
|
|
assert snapshot["Crypto:BTC/USDT@okx:swap::short"]["amount"] == pytest.approx(2.5)
|
|
|
|
|
|
def test_live_direction_guard_logs_warning_without_failing_runtime(monkeypatch):
|
|
import app.services.trading_executor as trading_executor_module
|
|
|
|
executor = TradingExecutor()
|
|
logs = []
|
|
monkeypatch.setattr(executor, "_load_strategy", lambda _sid: _strategy(21, "long"))
|
|
monkeypatch.setattr(
|
|
trading_executor_module,
|
|
"append_strategy_log",
|
|
lambda *args: logs.append(args),
|
|
)
|
|
|
|
submitted = executor._execute_signal(
|
|
strategy_id=21,
|
|
execution_mode="live",
|
|
signal_type="open_short",
|
|
)
|
|
|
|
assert submitted is False
|
|
assert len(logs) == 1
|
|
assert logs[0][0:2] == (21, "warning")
|
|
assert "strategyV2.directionModeViolation:long_only:short" in logs[0][2]
|
|
|
|
|
|
def test_dual_direction_strategy_conflicts_with_either_owned_leg(monkeypatch):
|
|
target = _strategy(30, "neutral")
|
|
other = _strategy(31, "long")
|
|
keys = {
|
|
30: (7, 17, "okx", "swap", "BTC/USDT", "neutral"),
|
|
31: (7, 17, "okx", "swap", "BTC/USDT", "long"),
|
|
}
|
|
|
|
class _Service:
|
|
@staticmethod
|
|
def get_strategy(strategy_id, user_id=None):
|
|
return other if strategy_id == 31 else None
|
|
|
|
monkeypatch.setattr(strategy_live_guard, "strategy_live_lock_key", lambda row, _uid: keys[int(row["id"])])
|
|
monkeypatch.setattr(strategy_live_guard, "get_strategy_service", lambda: _Service())
|
|
monkeypatch.setattr(strategy_live_guard, "get_db_connection", lambda: _Db(_Cursor([{"id": 31}])))
|
|
|
|
conflict = strategy_live_guard.find_live_strategy_conflict(target, 7)
|
|
assert conflict["strategy_id"] == 31
|
|
|
|
|
|
def test_neutral_grid_preflight_owns_both_legs(monkeypatch):
|
|
from app.services.grid import exchange_requirements
|
|
from app.services.live_trading import factory
|
|
from app.services import exchange_execution
|
|
|
|
executor = TradingExecutor()
|
|
monkeypatch.setattr(executor, "_load_strategy", lambda _sid: _neutral_grid_strategy())
|
|
conflict_calls = []
|
|
monkeypatch.setattr(
|
|
strategy_live_guard,
|
|
"find_live_strategy_conflict",
|
|
lambda *_args, **kwargs: conflict_calls.append(kwargs) or None,
|
|
)
|
|
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", lambda *_args, **_kwargs: {"exchange_id": "okx"})
|
|
monkeypatch.setattr(factory, "create_client", lambda *_args, **_kwargs: object())
|
|
monkeypatch.setattr(
|
|
exchange_requirements,
|
|
"detect_hedge_position_mode",
|
|
lambda *_args, **_kwargs: (True, "okx_long_short_mode"),
|
|
)
|
|
|
|
executor._preflight_live_strategy(20)
|
|
assert conflict_calls == [{"allow_opposite_leg": False}]
|
|
|
|
|
|
def test_dynamic_grid_anchor_materializes_absolute_live_bounds():
|
|
source = _neutral_grid_strategy()["trading_config"]
|
|
runtime = TradingExecutor._materialize_grid_anchor(source, 50_000.0)
|
|
|
|
assert runtime["bot_params"]["lowerPrice"] == pytest.approx(49_000.0)
|
|
assert runtime["bot_params"]["upperPrice"] == pytest.approx(51_000.0)
|
|
assert runtime["bot_params"]["dynamicAnchor"] is False
|
|
assert runtime["bot_params"]["_dynamicAnchorPrice"] == pytest.approx(50_000.0)
|
|
assert runtime["bot_params"]["_dynamicAnchorSource"] == "live_price"
|
|
assert source["bot_params"]["lowerPrice"] == 0.98
|
|
|
|
|
|
def test_dynamic_grid_anchor_prefers_persisted_runtime_anchor():
|
|
source = _neutral_grid_strategy()["trading_config"]
|
|
source["script_runtime_state"] = {
|
|
"grid_resting": {"dynamic_anchor_price": 48_000.0}
|
|
}
|
|
|
|
runtime = TradingExecutor._materialize_grid_anchor(source, 50_000.0)
|
|
|
|
assert runtime["bot_params"]["lowerPrice"] == pytest.approx(47_040.0)
|
|
assert runtime["bot_params"]["upperPrice"] == pytest.approx(48_960.0)
|
|
assert runtime["bot_params"]["_dynamicAnchorPrice"] == pytest.approx(48_000.0)
|
|
assert runtime["bot_params"]["_dynamicAnchorSource"] == "runtime_state"
|
|
|
|
|
|
def test_dynamic_grid_anchor_recovers_from_existing_cell_ladder():
|
|
source = _neutral_grid_strategy()["trading_config"]
|
|
|
|
runtime = TradingExecutor._materialize_grid_anchor(
|
|
source,
|
|
50_000.0,
|
|
persisted_grid_bounds=(47_040.0, 48_960.0, 9),
|
|
)
|
|
|
|
assert runtime["bot_params"]["lowerPrice"] == pytest.approx(47_040.0)
|
|
assert runtime["bot_params"]["upperPrice"] == pytest.approx(48_960.0)
|
|
assert runtime["bot_params"]["_dynamicAnchorPrice"] == pytest.approx(48_000.0)
|
|
assert runtime["bot_params"]["_dynamicAnchorSource"] == "persisted_cells"
|
|
|
|
|
|
def test_restart_recovery_repeats_live_preflight(monkeypatch):
|
|
import app.services.trading_executor as trading_executor_module
|
|
|
|
started = []
|
|
|
|
class _Thread:
|
|
def __init__(self, *args, **kwargs):
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
|
|
def start(self):
|
|
started.append(self.kwargs.get("name"))
|
|
|
|
def is_alive(self):
|
|
return True
|
|
|
|
monkeypatch.setattr(trading_executor_module.threading, "Thread", _Thread)
|
|
monkeypatch.setattr(trading_executor_module, "append_strategy_log", lambda *_args, **_kwargs: None)
|
|
|
|
checked = []
|
|
first = TradingExecutor()
|
|
monkeypatch.setattr(first, "_preflight_live_strategy", lambda sid: checked.append(("first", sid)))
|
|
assert first.start_strategy(30) is True
|
|
|
|
restarted = TradingExecutor()
|
|
monkeypatch.setattr(restarted, "_preflight_live_strategy", lambda sid: checked.append(("restart", sid)))
|
|
assert restarted.start_strategy(30) is True
|
|
assert checked == [("first", 30), ("restart", 30)]
|
|
assert started == ["strategy-30", "strategy-30"]
|
|
|
|
|
|
def test_reconciliation_allocates_long_and_short_to_separate_strategies():
|
|
result = reconcile_strategy_vs_account(
|
|
[{"strategy_id": 1, "symbol": "BTC/USDT", "side": "long", "size": 1.0}],
|
|
[
|
|
{"symbol": "BTC/USDT", "side": "long", "size": 1.0},
|
|
{"symbol": "BTC/USDT", "side": "short", "size": 2.0},
|
|
],
|
|
allocated_rows=[
|
|
{"strategy_id": 1, "symbol": "BTC/USDT", "side": "long", "size": 1.0},
|
|
{"strategy_id": 2, "symbol": "BTC/USDT", "side": "short", "size": 2.0},
|
|
],
|
|
)
|
|
assert result["status"] == "ok"
|
|
assert result["notes"] == []
|
|
assert result["strategy_allocations"] == [{
|
|
"symbol": "BTC/USDT",
|
|
"side": "long",
|
|
"strategy_size": 1.0,
|
|
"allocated_size": 1.0,
|
|
"account_size": 1.0,
|
|
"allocation_share": 1.0,
|
|
}]
|
|
|
|
|
|
def test_reconciliation_accepts_user_surplus_without_blaming_other_leg():
|
|
result = reconcile_strategy_vs_account(
|
|
[{"symbol": "BTC/USDT", "side": "long", "size": 1.0}],
|
|
[
|
|
{"symbol": "BTC/USDT", "side": "long", "size": 1.4},
|
|
{"symbol": "BTC/USDT", "side": "short", "size": 2.0},
|
|
],
|
|
allocated_rows=[
|
|
{"strategy_id": 1, "symbol": "BTC/USDT", "side": "long", "size": 1.0},
|
|
{"strategy_id": 2, "symbol": "BTC/USDT", "side": "short", "size": 2.0},
|
|
],
|
|
)
|
|
assert result["status"] == "ok"
|
|
assert result["notes"] == []
|
|
|
|
|
|
def _risk_row(strategy_id: int, side: str, size: float, price: float = 100.0) -> dict:
|
|
row = _strategy(strategy_id, side)
|
|
row.update({
|
|
"strategy_id": strategy_id,
|
|
"strategy_market_type": "swap",
|
|
"credential_id": 17,
|
|
"symbol": "BTC/USDT",
|
|
"symbol_canonical": "BTC/USDT",
|
|
"side": side,
|
|
"size": size,
|
|
"entry_price": price,
|
|
"current_price": price,
|
|
})
|
|
return row
|
|
|
|
|
|
def test_account_risk_uses_gross_exposure_instead_of_net_exposure(monkeypatch):
|
|
monkeypatch.setattr(account_risk, "_load_account_rows", lambda **_kwargs: [
|
|
_risk_row(1, "long", 5.0),
|
|
_risk_row(2, "short", 5.0),
|
|
])
|
|
snapshot = account_risk.account_risk_snapshot(
|
|
user_id=7,
|
|
credential_id=17,
|
|
market_type="swap",
|
|
strategy_id=1,
|
|
limits={"max_gross_notional": 900.0},
|
|
)
|
|
assert snapshot["net_notional"] == pytest.approx(0.0)
|
|
assert snapshot["gross_notional"] == pytest.approx(1_000.0)
|
|
assert snapshot["allowed"] is False
|
|
assert "accountRisk.grossNotionalExceeded" in snapshot["violations"]
|
|
|
|
|
|
def test_account_risk_covers_margin_fee_funding_and_symbol_budgets(monkeypatch):
|
|
rows = [_risk_row(1, "long", 5.0), _risk_row(2, "short", 5.0)]
|
|
for row in rows:
|
|
row["trading_config"]["account_risk"] = {
|
|
"fee_rate": 0.01,
|
|
"funding_rate_estimate": 0.005,
|
|
}
|
|
monkeypatch.setattr(account_risk, "_load_account_rows", lambda **_kwargs: rows)
|
|
snapshot = account_risk.account_risk_snapshot(
|
|
user_id=7,
|
|
credential_id=17,
|
|
market_type="swap",
|
|
strategy_id=1,
|
|
proposed_symbol="BTC/USDT",
|
|
proposed_side="long",
|
|
proposed_quantity=1.0,
|
|
proposed_price=100.0,
|
|
proposed_leverage=2.0,
|
|
limits={
|
|
"max_margin_estimate": 240.0,
|
|
"max_round_trip_fee": 20.0,
|
|
"max_funding_per_interval": 5.0,
|
|
"max_symbol_gross_notional": 1_050.0,
|
|
},
|
|
)
|
|
assert snapshot["allowed"] is False
|
|
assert set(snapshot["violations"]) >= {
|
|
"accountRisk.marginEstimateExceeded",
|
|
"accountRisk.feeBudgetExceeded",
|
|
"accountRisk.fundingBudgetExceeded",
|
|
"accountRisk.symbolGrossNotionalExceeded",
|
|
}
|
|
|
|
|
|
def test_account_risk_fails_closed_when_a_position_cannot_be_valued(monkeypatch):
|
|
row = _risk_row(1, "long", 5.0, price=0.0)
|
|
monkeypatch.setattr(account_risk, "_load_account_rows", lambda **_kwargs: [row])
|
|
snapshot = account_risk.account_risk_snapshot(
|
|
user_id=7,
|
|
credential_id=17,
|
|
market_type="swap",
|
|
strategy_id=1,
|
|
proposed_symbol="BTC/USDT",
|
|
proposed_side="long",
|
|
proposed_quantity=1.0,
|
|
proposed_price=0.0,
|
|
)
|
|
assert snapshot["allowed"] is False
|
|
assert set(snapshot["violations"]) >= {
|
|
"accountRisk.positionPriceMissing",
|
|
"accountRisk.proposedPriceMissing",
|
|
}
|
|
|
|
|
|
class _StopCursor:
|
|
def __init__(self):
|
|
self.query = ""
|
|
|
|
def execute(self, query, *_args):
|
|
self.query = str(query)
|
|
|
|
def fetchall(self):
|
|
return [
|
|
{"symbol": "BTC/USDT", "side": "long", "size": 1.0, "current_price": 100.0, "market_type": "swap", "strategy_run_id": 901},
|
|
{"symbol": "ETH/USDT", "side": "short", "size": 2.0, "current_price": 50.0, "market_type": "swap", "strategy_run_id": 901},
|
|
]
|
|
|
|
def fetchone(self):
|
|
return {"id": 901}
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
|
|
def test_stop_policy_distinguishes_pause_only_from_pause_and_close(monkeypatch):
|
|
executor = TradingExecutor()
|
|
strategy = _strategy(40, "long")
|
|
monkeypatch.setattr(executor, "_load_strategy", lambda _sid: strategy)
|
|
monkeypatch.setattr(executor, "stop_strategy", lambda _sid: True)
|
|
|
|
submitted = []
|
|
executor.order_gateway.submit = lambda request: submitted.append(request) or len(submitted)
|
|
|
|
pause_only = executor.stop_strategy_with_policy(40, close_positions=False)
|
|
assert pause_only["success"] is True
|
|
assert pause_only["close_orders_queued"] == 0
|
|
assert submitted == []
|
|
|
|
import app.services.trading_executor as trading_executor_module
|
|
|
|
monkeypatch.setattr(trading_executor_module, "get_db_connection", lambda: _Db(_StopCursor()))
|
|
close_result = executor.stop_strategy_with_policy(40, close_positions=True)
|
|
assert close_result["success"] is True
|
|
assert close_result["close_orders_queued"] == 2
|
|
assert [(item.symbol, item.action, item.quantity) for item in submitted] == [
|
|
("BTC/USDT", "close_long", 1.0),
|
|
("ETH/USDT", "close_short", 2.0),
|
|
]
|
|
|
|
|
|
def test_signal_stop_and_close_settles_virtual_positions_without_live_orders(monkeypatch):
|
|
import app.services.trading_executor as trading_executor_module
|
|
import app.services.virtual_trading as virtual_trading_module
|
|
|
|
executor = TradingExecutor()
|
|
strategy = _strategy(41, "long")
|
|
strategy["execution_mode"] = "signal"
|
|
monkeypatch.setattr(executor, "_load_strategy", lambda _sid: strategy)
|
|
monkeypatch.setattr(executor, "stop_strategy", lambda _sid: True)
|
|
monkeypatch.setattr(trading_executor_module, "get_db_connection", lambda: _Db(_StopCursor()))
|
|
|
|
submitted = []
|
|
settled = []
|
|
executor.order_gateway.submit = lambda request: submitted.append(request) or len(submitted)
|
|
monkeypatch.setattr(
|
|
virtual_trading_module,
|
|
"settle_virtual_pending_order",
|
|
lambda pending_id: settled.append(pending_id) or {"status": "filled"},
|
|
)
|
|
|
|
result = executor.stop_strategy_with_policy(41, close_positions=True)
|
|
|
|
assert result["success"] is True
|
|
assert result["close_orders_queued"] == 2
|
|
assert result["close_orders_completed"] == 2
|
|
assert settled == [1, 2]
|
|
assert all(item.execution_mode == "signal" for item in submitted)
|
|
assert [(item.action, item.quantity) for item in submitted] == [
|
|
("close_long", 1.0),
|
|
("close_short", 2.0),
|
|
]
|
|
|
|
|
|
def test_stop_strategy_cancels_resting_grid_orders_even_without_live_thread(monkeypatch):
|
|
import app.services.grid.runner as grid_runner_module
|
|
import app.services.trading_executor as trading_executor_module
|
|
|
|
cancelled = []
|
|
monkeypatch.setattr(
|
|
grid_runner_module,
|
|
"shutdown_grid_for_strategy",
|
|
lambda strategy_id: cancelled.append(int(strategy_id)),
|
|
)
|
|
monkeypatch.setattr(
|
|
trading_executor_module,
|
|
"append_strategy_log",
|
|
lambda *_args, **_kwargs: None,
|
|
)
|
|
|
|
executor = TradingExecutor()
|
|
|
|
assert executor.stop_strategy(77, persist_status=False) is True
|
|
assert cancelled == [77]
|
|
|
|
|
|
@dataclass
|
|
class _HedgeBook:
|
|
long: float = 0.0
|
|
short: float = 0.0
|
|
fills: dict[str, FillAccumulator] = field(default_factory=dict)
|
|
|
|
def fill(self, signal: str, quantity: float, price: float) -> None:
|
|
_side, pos_side, reduce_only = signal_to_side_pos_reduce(signal)
|
|
acc = self.fills.setdefault(signal, FillAccumulator())
|
|
acc.apply_fill(quantity, price)
|
|
current = getattr(self, pos_side)
|
|
setattr(self, pos_side, max(0.0, current - quantity) if reduce_only else current + quantity)
|
|
|
|
|
|
def test_open_close_stop_and_partial_fills_keep_hedge_legs_independent():
|
|
book = _HedgeBook()
|
|
book.fill("open_long", 1.0, 100.0)
|
|
book.fill("open_short", 2.0, 101.0)
|
|
book.fill("close_long", 0.4, 102.0)
|
|
book.fill("close_long", 0.6, 103.0)
|
|
assert book.long == pytest.approx(0.0)
|
|
assert book.short == pytest.approx(2.0)
|
|
assert book.fills["close_long"].total_base == pytest.approx(1.0)
|
|
assert book.fills["close_long"].avg_price() == pytest.approx(102.6)
|
|
|
|
book.fill("close_short_stop", 2.0, 99.0)
|
|
assert book.long == pytest.approx(0.0)
|
|
assert book.short == pytest.approx(0.0)
|