Skip to content

Commit c3d3f9c

Browse files
committed
fix: reject Gate stock testnet deployments before runtime
1 parent 4dd57b1 commit c3d3f9c

6 files changed

Lines changed: 199 additions & 4 deletions

File tree

‎backend_api_python/app/services/live_trading/factory.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
316316
base_url = default_gate if is_demo else (_get(exchange_config, "base_url", "baseUrl") or default_gate)
317317
if str(exchange_config.get("api_family") or "").strip().lower() == "stock":
318318
if is_demo:
319-
raise LiveTradingError("GATE_STOCK_TESTNET_NOT_SUPPORTED")
319+
raise LiveTradingError("strategyV2.gateStockTestnetUnsupported")
320320
return GateStockClient(
321321
api_key=api_key,
322322
secret_key=secret_key,

‎backend_api_python/app/services/market/product_catalog.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,22 @@ def _json_object(value: Any) -> dict[str, Any]:
6666
return {}
6767

6868

69+
def validate_product_account_environment(
70+
products: list[dict[str, Any]] | None,
71+
exchange_config: dict[str, Any],
72+
) -> None:
73+
from app.services.live_trading.factory import exchange_trading_environment
74+
75+
has_gate_stock = any(
76+
isinstance(product, dict)
77+
and str(product.get("exchange_id") or exchange_config.get("exchange_id") or "").lower() == "gate"
78+
and str(product.get("api_family") or "").lower() == "stock"
79+
for product in products or []
80+
)
81+
if has_gate_stock and exchange_trading_environment(exchange_config) != "live":
82+
raise ValueError("strategyV2.gateStockTestnetUnsupported")
83+
84+
6985
def validate_runtime_products(
7086
products: list[dict[str, Any]] | None,
7187
*,

‎backend_api_python/app/services/strategy_v2/deployment.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ def save(self, *, user_id: int, payload: dict[str, Any], strategy_id: int | None
6262
manifest.metadata(), exchange_id, execution_mode,
6363
instruments=live_instruments,
6464
)
65+
if execution_mode == "live" and any(
66+
item.get("exchange_id") == "gate" and item.get("api_family") == "stock"
67+
for item in instrument_products
68+
):
69+
from app.services.exchange_execution import resolve_exchange_config
70+
from app.services.market.product_catalog import validate_product_account_environment
71+
72+
account_config = resolve_exchange_config(
73+
{"credential_id": credential_id, "exchange_id": exchange_id},
74+
user_id=user_id,
75+
)
76+
try:
77+
validate_product_account_environment(instrument_products, account_config)
78+
except ValueError as exc:
79+
raise StrategyV2ContractError(str(exc)) from exc
6580
quote_currency = self._validate_live_quote_currency(
6681
markets=manifest.markets,
6782
instruments=live_instruments or (

‎backend_api_python/app/services/trading_executor.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,17 @@ def _preflight_live_strategy(self, strategy_id: int) -> None:
122122

123123
trading_config = _json_object(strategy.get("trading_config"))
124124
from app.services.exchange_execution import resolve_exchange_config
125-
from app.services.market.product_catalog import validate_runtime_products
125+
from app.services.market.product_catalog import (
126+
validate_product_account_environment,
127+
validate_runtime_products,
128+
)
126129

127130
exchange_config = resolve_exchange_config(
128131
_json_object(strategy.get("exchange_config")),
129132
user_id=user_id,
130133
)
131134
exchange_id = str(exchange_config.get("exchange_id") or "").strip().lower()
135+
validate_product_account_environment(trading_config.get("instrument_products"), exchange_config)
132136
validate_runtime_products(
133137
trading_config.get("instrument_products")
134138
if isinstance(trading_config.get("instrument_products"), list)
@@ -382,6 +386,9 @@ def _run_strategy_loop(self, strategy_id: int) -> None:
382386
trading_config,
383387
exchange_id=account_exchange,
384388
)
389+
from app.services.market.product_catalog import validate_product_account_environment
390+
391+
validate_product_account_environment(candidates, exchange_config)
385392

386393
frequency = program.manifest.driving_frequency
387394

@@ -520,7 +527,6 @@ def runtime_prices() -> dict[str, float]:
520527
rest_fallback=runtime_prices,
521528
)
522529
market_price_feed.start()
523-
rest_runtime_prices = runtime_prices
524530

525531
def runtime_prices() -> dict[str, float]:
526532
snapshot = market_price_feed.snapshot(
@@ -533,7 +539,7 @@ def runtime_prices() -> dict[str, float]:
533539
"age_ms": snapshot.age_ms,
534540
"connected": snapshot.connected,
535541
})
536-
return snapshot.prices or rest_runtime_prices()
542+
return snapshot.prices
537543
state_store = RuntimeStateStore(
538544
strategy_id=strategy_id,
539545
strategy_run_id=run_id,

‎backend_api_python/tests/test_exchange_equity_product_preflight.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,33 @@
66
from app.services.market import product_catalog
77

88

9+
@pytest.mark.parametrize("config", [
10+
{"environment": "testnet"}, {"environment": "demo"}, {"sandbox": True},
11+
{"use_testnet": True}, {"enableDemoTrading": "true"},
12+
])
13+
def test_gate_stock_environment_validation_preserves_account_configuration(config):
14+
account = {"exchange_id": "gate", **config}
15+
snapshot = dict(account)
16+
with pytest.raises(ValueError, match="gateStockTestnetUnsupported"):
17+
product_catalog.validate_product_account_environment(
18+
[{"exchange_id": "gate", "api_family": "stock"}], account,
19+
)
20+
assert account == snapshot
21+
22+
23+
@pytest.mark.parametrize("exchange,api_family,environment", [
24+
("gate", "stock", "live"), ("gate", "spot", "testnet"),
25+
("gate", "swap", "testnet"), ("binance", "spot", "demo"),
26+
("okx", "swap", "demo"), ("bitget", "spot", "demo"),
27+
("bybit", "swap", "demo"), ("htx", "spot", "live"),
28+
])
29+
def test_gate_stock_guard_does_not_change_other_product_environments(exchange, api_family, environment):
30+
product_catalog.validate_product_account_environment(
31+
[{"exchange_id": exchange, "api_family": api_family}],
32+
{"exchange_id": exchange, "environment": environment},
33+
)
34+
35+
936
def _manifest(exchange_id="bybit", market_type="spot"):
1037
return {
1138
"universe": {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import Mock
3+
import time
4+
5+
import pandas as pd
6+
import pytest
7+
8+
from app.services import trading_executor as runtime
9+
from app.services.live_trading.gate import GateStockClient
10+
11+
12+
SYMBOL = "Crypto:NVDA/USD@gate:spot"
13+
CODE = '''
14+
def initialize(context):
15+
g.symbol = "Crypto:NVDA/USD@gate:spot"
16+
context.set_universe([g.symbol])
17+
context.subscribe(frequency="1m", fields=["close"])
18+
context.set_warmup(50)
19+
context.set_benchmark(g.symbol)
20+
context.set_metadata(direction_mode="long_only", strategy_family="ema_crossover")
21+
22+
def handle_data(context, data):
23+
bars = data.history(g.symbol, count=500, fields=["close"])
24+
if len(bars) >= 32:
25+
fast = bars.close.ewm(span=10, adjust=False).mean()
26+
slow = bars.close.ewm(span=30, adjust=False).mean()
27+
if fast.iloc[-1] > slow.iloc[-1] and fast.iloc[-2] <= slow.iloc[-2]:
28+
order_target_percent(g.symbol, 0.95)
29+
'''
30+
31+
32+
@pytest.mark.parametrize("account_options", [{}, {"environment": "testnet"}, {"enable_demo_trading": True}])
33+
def test_gate_stock_complete_live_loop_uses_exchange_quotes(monkeypatch, account_options):
34+
product = {
35+
"symbol": "NVDA/USD", "exchange_id": "gate", "market_type": "spot",
36+
"instrument_id": "NVDA", "product_type": "direct_equity", "api_family": "stock",
37+
"underlying_market": "USStock", "underlying_symbol": "NVDA",
38+
}
39+
strategy = {
40+
"id": 1, "user_id": 1, "execution_mode": "live", "initial_capital": 1000,
41+
"trading_config": {"instrument_products": [product], "risk_tick_seconds": 0.25},
42+
"exchange_config": {"exchange_id": "gate", "api_key": "test", "secret_key": "test", **(account_options or {})},
43+
}
44+
executor = runtime.TradingExecutor()
45+
monkeypatch.setattr(executor, "_load_strategy", lambda _: strategy)
46+
monkeypatch.setattr(executor, "_load_source", lambda _: (1, CODE))
47+
monkeypatch.setattr(executor, "_load_schedule_timezone", lambda _: "UTC")
48+
monkeypatch.setattr(executor, "_positions_by_symbol", lambda *a, **kw: {})
49+
monkeypatch.setattr(executor, "_calculate_current_equity", lambda *a, **kw: 1000)
50+
submit = Mock(return_value=True)
51+
monkeypatch.setattr(executor, "_execute_strategy_v2_intent", submit)
52+
cycles = iter([True, True, True, False])
53+
monkeypatch.setattr(executor, "_is_strategy_running", lambda *a: next(cycles))
54+
heartbeats = []
55+
monkeypatch.setattr(executor, "_heartbeat", lambda *a, **kw: heartbeats.append((dict(a[3]), kw)))
56+
monkeypatch.setattr(executor, "_mark_stopped", lambda *a: None)
57+
logs = []
58+
monkeypatch.setattr(runtime, "append_strategy_log", lambda *a: logs.append(a))
59+
monkeypatch.setattr(runtime, "ensure_strategy_run", lambda **kw: SimpleNamespace(strategy_run_id=1))
60+
monkeypatch.setattr(runtime, "finish_strategy_run", lambda *a, **kw: None)
61+
store = Mock()
62+
store.load.return_value = {}
63+
monkeypatch.setattr(runtime, "RuntimeStateStore", lambda **kw: store)
64+
monkeypatch.setattr(runtime, "persist_cancellations", lambda *a: None)
65+
monkeypatch.setattr(runtime, "time", SimpleNamespace(monotonic=time.monotonic, sleep=lambda _: None, time=time.time))
66+
frame = pd.DataFrame(
67+
{"open": 200., "high": 201., "low": 199., "close": 200., "volume": 1.},
68+
index=pd.date_range("2026-09-14 14:00", periods=60, freq="min", tz="UTC"),
69+
)
70+
monkeypatch.setattr(runtime.StrategyV2BacktestService, "fetch_frequency_frames", lambda *a, **kw: ({"1m": {SYMBOL: frame}}, []))
71+
request = Mock(return_value=(200, {"data": {"bids": [{"p": "210.1"}], "asks": [{"p": "210.3"}]}}, ""))
72+
monkeypatch.setattr(GateStockClient, "_request", request)
73+
74+
executor._run_strategy_loop(1)
75+
76+
if account_options:
77+
assert executor._last_exit_reason == {1: "strategyV2.gateStockTestnetUnsupported"}
78+
assert not heartbeats
79+
request.assert_not_called()
80+
submit.assert_not_called()
81+
assert logs == [(1, "error", "strategyV2.gateStockTestnetUnsupported")]
82+
return
83+
assert executor._last_exit_reason == {}, logs
84+
assert len(heartbeats) == 4, logs
85+
assert all(prices.get(SYMBOL) == 210.2 for prices, _ in heartbeats[1:]), logs
86+
assert all(meta["status"] == "healthy" for _, meta in heartbeats[1:]), logs
87+
assert request.call_count == 3
88+
89+
90+
def test_gate_stock_testnet_rejected_before_deployment_is_saved(monkeypatch):
91+
from app.services import exchange_execution
92+
from app.services.strategy_v2 import deployment
93+
94+
monkeypatch.setattr(deployment, "get_script_source_service", lambda: SimpleNamespace(
95+
get_source=lambda *a, **kw: {"name": "Gate NVDA", "code": CODE},
96+
))
97+
monkeypatch.setattr(deployment.StrategyV2DeploymentService, "_credential_exchange", lambda *a: "gate")
98+
monkeypatch.setattr(deployment, "get_catalog_product", lambda **kw: {
99+
"instrument_id": "NVDA", "api_family": "stock", "product_type": "direct_equity",
100+
})
101+
resolve = Mock(return_value={"exchange_id": "gate", "environment": "testnet"})
102+
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", resolve)
103+
database = Mock(side_effect=AssertionError("No deployment should be persisted"))
104+
monkeypatch.setattr(deployment, "get_db_connection", database)
105+
106+
with pytest.raises(deployment.StrategyV2ContractError, match="gateStockTestnetUnsupported"):
107+
deployment.StrategyV2DeploymentService().save(user_id=7, payload={
108+
"sourceId": 1, "initialCapital": 1000, "credentialId": 23, "executionMode": "live",
109+
})
110+
111+
resolve.assert_called_once_with({"credential_id": 23, "exchange_id": "gate"}, user_id=7)
112+
database.assert_not_called()
113+
114+
115+
def test_gate_stock_testnet_rejected_before_starting_thread(monkeypatch):
116+
from app.services import exchange_execution
117+
118+
executor = runtime.TradingExecutor()
119+
strategy = {
120+
"id": 1, "user_id": 7, "execution_mode": "live", "market_type": "spot",
121+
"trading_config": {"instrument_products": [{"exchange_id": "gate", "api_family": "stock"}]},
122+
"exchange_config": {"credential_id": 23},
123+
}
124+
monkeypatch.setattr(executor, "_load_strategy", lambda _: strategy)
125+
monkeypatch.setattr(exchange_execution, "resolve_exchange_config", lambda *a, **kw: {
126+
"exchange_id": "gate", "use_testnet": True,
127+
})
128+
129+
assert executor.start_strategy(1) is False
130+
assert executor._last_start_failure == "strategyV2.gateStockTestnetUnsupported"
131+
assert not executor.running_strategies

0 commit comments

Comments
 (0)