This commit is contained in:
dienakdz
2026-04-08 09:12:41 +07:00
147 changed files with 4587 additions and 639 deletions
+71 -1
View File
@@ -504,6 +504,12 @@ class BacktestService:
result['precision_info']['message'] = 'Using standard backtest because scale rules are not fully supported in MTF mode'
elif fallback_reason == 'signal_timing_not_supported_in_mtf':
result['precision_info']['message'] = 'Using standard backtest because this execution timing is not fully supported in MTF mode'
ea = result.get('executionAssumptions') or {}
ea['mtfRequested'] = bool(enable_mtf)
ea['mtfActive'] = False
if fallback_reason:
ea['mtfFallbackReason'] = fallback_reason
result['executionAssumptions'] = ea
return result
logger.info(f"Multi-timeframe backtest: strategy_tf={timeframe}, exec_tf={exec_tf}, range={start_date} ~ {end_date}")
@@ -554,6 +560,11 @@ class BacktestService:
'reason': 'data_unavailable',
'message': f'Cannot fetch {exec_tf} data, using standard backtest'
}
ea = result.get('executionAssumptions') or {}
ea['mtfRequested'] = bool(enable_mtf)
ea['mtfActive'] = False
ea['mtfFallbackReason'] = 'data_unavailable'
result['executionAssumptions'] = ea
return result
logger.info(f"Data fetched: signal_candles={len(df_signal)}, exec_candles={len(df_exec)}")
@@ -598,6 +609,14 @@ class BacktestService:
result['execution_timeframe'] = exec_tf
result['signal_candles'] = len(df_signal)
result['execution_candles'] = len(df_exec)
result['executionAssumptions'] = self._execution_assumptions(
strategy_config,
simulation_mode='mtf',
signal_timeframe=timeframe,
execution_timeframe=exec_tf,
mtf_requested=True,
mtf_active=True,
)
logger.info("Backtest result formatted successfully")
except Exception as e:
logger.error(f"Failed to format result: {str(e)}")
@@ -1487,6 +1506,11 @@ class BacktestService:
'precision': 'standard',
'message': 'Using standard strategy script backtest'
}
result['executionAssumptions'] = self._execution_assumptions(
strategy_config,
simulation_mode='standard',
signal_timeframe=timeframe,
)
return result
def run_code_strategy(
@@ -1607,7 +1631,13 @@ class BacktestService:
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
# 5. Format result
return self._format_result(metrics, equity_curve, trades)
result = self._format_result(metrics, equity_curve, trades)
result['executionAssumptions'] = self._execution_assumptions(
strategy_config,
simulation_mode='standard',
signal_timeframe=timeframe,
)
return result
def _fetch_kline_data(
self,
@@ -4740,6 +4770,46 @@ import pandas as pd
logger.warning(f"Sharpe ratio calculation failed: {e}")
return 0
def _execution_assumptions(
self,
strategy_config: Optional[Dict[str, Any]],
*,
simulation_mode: str,
signal_timeframe: Optional[str] = None,
execution_timeframe: Optional[str] = None,
mtf_requested: bool = False,
mtf_active: bool = False,
mtf_fallback_reason: Optional[str] = None,
) -> Dict[str, Any]:
"""
Human-facing metadata so the UI can explain how trades were timed vs chart markers.
Keys use camelCase for JSON consumers (frontend).
"""
cfg = strategy_config or {}
raw = str((cfg.get('execution') or {}).get('signalTiming') or 'next_bar_open').strip().lower()
is_next_open = raw in ('next_bar_open', 'next_open', 'nextopen', 'next')
if raw in ('bar_close', 'close', 'same_bar_close', 'current_bar_close'):
timing_key = 'same_bar_close'
elif is_next_open:
timing_key = 'next_bar_open'
else:
timing_key = raw
default_fill = 'open' if is_next_open else 'close'
payload: Dict[str, Any] = {
'signalTiming': timing_key,
'signalTimingRaw': raw,
'defaultFillPrice': default_fill,
'simulationMode': simulation_mode,
'strategyTimeframe': signal_timeframe,
'executionTimeframe': execution_timeframe,
'engineVersion': self.ENGINE_VERSION,
'mtfRequested': bool(mtf_requested),
'mtfActive': bool(mtf_active),
}
if mtf_fallback_reason:
payload['mtfFallbackReason'] = mtf_fallback_reason
return payload
def _format_result(
self,
metrics: Dict,
@@ -1830,7 +1830,35 @@ IMPORTANT:
# But we need to "reweight the available information": when some modules are missing (such as news/macro is not obtained), do not use 0 points to dilute the overall strength.
# Instead, the weights are renormalized so that technical signals can still play a leading role in their absence.
market_type = str(data.get("market") or "")
fundamental_present = (market_type == "USStock") and bool(fundamental)
def _fundamental_meaningful(fund: Dict[str, Any]) -> bool:
if not fund:
return False
for key in (
"pe_ratio",
"pb_ratio",
"ps_ratio",
"market_cap",
"roe",
"eps",
"revenue_growth",
"profit_margin",
"dividend_yield",
):
v = fund.get(key)
if v is None or v == "":
continue
try:
if isinstance(v, float) and v != v: # NaN
continue
return True
except Exception:
return True
return False
fundamental_present = (
market_type in ("USStock", "CNStock", "HKStock") and _fundamental_meaningful(fundamental)
)
sentiment_present = bool(news)
macro_present = bool(macro)
# indicators usually exist once they are successfully calculated, but they are also protected here.
@@ -2057,7 +2085,7 @@ IMPORTANT:
def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float:
"""Calculate fundamental score (-100 to +100)"""
if market != "USStock" or not fundamental:
if market not in ("USStock", "CNStock", "HKStock") or not fundamental:
return 0.0 # Non-U.S. stocks or no fundamental data, return neutral
score = 0.0
@@ -2142,7 +2170,9 @@ IMPORTANT:
# Normalization (if there are multiple factors)
if factors > 0:
score = score / factors * 100 / 4 # The maximum possible score is 20 points for each of the 4 factors = 80, normalized to 100
else:
return 50.0
return max(-100, min(100, score))
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
@@ -9,12 +9,75 @@ Notes:
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional, Tuple, Union
import requests
logger = logging.getLogger(__name__)
# Cached SSL verify setting for all live-trading REST calls (requests + SOCKS proxy).
_requests_verify_value: Optional[Union[bool, str]] = None
_ssl_verify_disabled_logged = False
# OS CA bundles (Docker / slim images: install ``ca-certificates``; corporate roots often added here too).
_SYSTEM_CA_BUNDLE_CANDIDATES: Tuple[str, ...] = (
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
"/etc/ssl/cert.pem", # Alpine, some slim images
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
)
def _get_requests_verify() -> Union[bool, str]:
"""
Resolve ``verify`` for ``requests`` when calling exchanges through proxies (e.g. PROXY_URL=socks5h://...).
- LIVE_TRADING_SSL_VERIFY=0|false|no|off: disable verification (insecure; mitm risk).
- LIVE_TRADING_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE: path to a PEM CA bundle
(needed for corporate TLS inspection or custom roots).
- Else a non-empty OS CA file if present (helps Gate/HTX/hbdm etc. in minimal images).
- Otherwise certifi's bundle when available.
"""
global _requests_verify_value, _ssl_verify_disabled_logged
if _requests_verify_value is not None:
return _requests_verify_value
flag = (os.environ.get("LIVE_TRADING_SSL_VERIFY") or "").strip().lower()
if flag in ("0", "false", "no", "off"):
if not _ssl_verify_disabled_logged:
logger.warning(
"LIVE_TRADING_SSL_VERIFY is disabled: HTTPS certificate verification is OFF for live trading "
"requests (MITM risk). Fix CA trust or set LIVE_TRADING_CA_BUNDLE instead for production."
)
_ssl_verify_disabled_logged = True
_requests_verify_value = False
return _requests_verify_value
for key in ("LIVE_TRADING_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"):
path = (os.environ.get(key) or "").strip()
if path and os.path.isfile(path):
_requests_verify_value = path
return _requests_verify_value
for path in _SYSTEM_CA_BUNDLE_CANDIDATES:
try:
if path and os.path.isfile(path) and os.path.getsize(path) >= 256:
_requests_verify_value = path
return _requests_verify_value
except OSError:
continue
try:
import certifi
_requests_verify_value = certifi.where()
except ImportError:
_requests_verify_value = True
return _requests_verify_value
@dataclass
class LiveOrderResult:
@@ -51,15 +114,26 @@ class BaseRestClient:
data: Optional[Any] = None,
) -> Tuple[int, Dict[str, Any], str]:
url = self._url(path)
resp = requests.request(
method=str(method or "GET").upper(),
url=url,
params=params or None,
json=json_body if json_body is not None else None,
data=data,
headers=headers or None,
timeout=self.timeout_sec,
)
try:
resp = requests.request(
method=str(method or "GET").upper(),
url=url,
params=params or None,
json=json_body if json_body is not None else None,
data=data,
headers=headers or None,
timeout=self.timeout_sec,
verify=_get_requests_verify(),
)
except requests.exceptions.SSLError as e:
logger.warning(
"Exchange HTTPS TLS verify failed (%s). Same setting applies to all REST exchanges (Gate, HTX/hbdm, etc.). "
"Behind PROXY_URL/SOCKS or TLS inspection: set LIVE_TRADING_CA_BUNDLE to a PEM bundle (or REQUESTS_CA_BUNDLE), "
"ensure ca-certificates in the image, or dev-only LIVE_TRADING_SSL_VERIFY=false. %s",
url,
e,
)
raise
text = resp.text or ""
parsed: Dict[str, Any] = {}
try:
@@ -40,6 +40,10 @@ class BinanceFuturesClient(BaseRestClient):
self._dual_side_cache: Optional[Tuple[float, bool]] = None
self._dual_side_cache_ttl_sec = 60.0
# serverTime - local_ms; avoids Binance -1021 when the host clock is ahead of Binance.
self._time_offset_ms: int = 0
self._time_sync_monotonic: float = 0.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -163,6 +167,26 @@ class BinanceFuturesClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _ensure_server_time(self, *, force: bool = False) -> None:
"""
Align signed request timestamps with Binance server time (GET /fapi/v1/time).
"""
now_m = time.monotonic()
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
return
try:
code, data, _ = self._request("GET", "/fapi/v1/time")
if code != 200 or not isinstance(data, dict):
return
server_ms = int(data.get("serverTime") or 0)
if server_ms <= 0:
return
local_ms = int(time.time() * 1000)
self._time_offset_ms = server_ms - local_ms
self._time_sync_monotonic = now_m
except Exception:
pass
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
@@ -179,17 +203,34 @@ class BinanceFuturesClient(BaseRestClient):
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
# Use server-accepted timestamp in ms.
p["timestamp"] = int(time.time() * 1000)
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
raise LiveTradingError(f"Binance error: {data}")
return data if isinstance(data, dict) else {"raw": data}
self._ensure_server_time()
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
if "recvWindow" not in p:
p["recvWindow"] = 10000
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
err = LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
if attempt == 0 and ("-1021" in text or "1021" in text):
self._ensure_server_time(force=True)
last_err = err
continue
raise err
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
err = LiveTradingError(f"Binance error: {data}")
if attempt == 0 and int(data.get("code") or 0) == -1021:
self._ensure_server_time(force=True)
last_err = err
continue
raise err
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("Binance signed request failed")
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
@@ -663,8 +704,6 @@ class BinanceFuturesClient(BaseRestClient):
"type": "MARKET",
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
}
if reduce_only:
params["reduceOnly"] = "true"
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
@@ -681,6 +720,10 @@ class BinanceFuturesClient(BaseRestClient):
# Unknown mode: try without positionSide first; we may retry on -4061.
params.pop("positionSide", None)
# reduceOnly after positionSide: in hedge mode, Binance returns -1106 if both are sent.
if reduce_only and not (dual_side is True and params.get("positionSide") in ("LONG", "SHORT")):
params["reduceOnly"] = "true"
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
except LiveTradingError as e:
@@ -706,6 +749,7 @@ class BinanceFuturesClient(BaseRestClient):
else:
# Likely hedge mode; retry with inferred positionSide.
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params2.pop("reduceOnly", None)
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
self._dual_side_cache = (time.time(), True)
@@ -802,8 +846,6 @@ class BinanceFuturesClient(BaseRestClient):
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
"price": self._dec_str(px_dec),
}
if reduce_only:
params["reduceOnly"] = "true"
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
@@ -816,6 +858,9 @@ class BinanceFuturesClient(BaseRestClient):
params.pop("positionSide", None)
else:
params.pop("positionSide", None)
if reduce_only and not (dual_side is True and params.get("positionSide") in ("LONG", "SHORT")):
params["reduceOnly"] = "true"
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
except LiveTradingError as e:
@@ -837,6 +882,7 @@ class BinanceFuturesClient(BaseRestClient):
pass
else:
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params2.pop("reduceOnly", None)
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
self._dual_side_cache = (time.time(), True)
@@ -870,12 +916,41 @@ class BinanceFuturesClient(BaseRestClient):
raise LiveTradingError("Binance cancel_order requires order_id or client_order_id")
return self._signed_request("DELETE", "/fapi/v1/order", params=params)
def get_positions(self) -> Any:
def set_margin_type(self, *, symbol: str, margin_mode: str) -> Dict[str, Any]:
"""
Return all futures positions (position risk endpoint).
Set symbol margin mode on USDT-M futures.
Endpoint: POST /fapi/v1/marginType
margin_mode: cross | crossed | isolated
"""
sym = to_binance_futures_symbol(symbol)
m = (margin_mode or "").strip().lower()
if m in ("cross", "crossed"):
mt = "CROSSED"
elif m in ("isolated", "iso"):
mt = "ISOLATED"
else:
raise LiveTradingError(f"Invalid margin_mode for Binance: {margin_mode}")
return self._signed_request("POST", "/fapi/v1/marginType", params={"symbol": sym, "marginType": mt})
def get_positions(self, *, symbol: str = "") -> Any:
"""
Futures positions (position risk). Optional ``symbol`` filters to one contract.
Endpoint: GET /fapi/v2/positionRisk
"""
return self._signed_request("GET", "/fapi/v2/positionRisk", params={})
raw = self._signed_request("GET", "/fapi/v2/positionRisk", params={})
rows: list
if isinstance(raw, list):
rows = raw
elif isinstance(raw, dict) and isinstance(raw.get("raw"), list):
rows = raw["raw"]
else:
rows = []
want = (symbol or "").strip()
if not want:
return rows
sym = to_binance_futures_symbol(want)
return [p for p in rows if isinstance(p, dict) and str(p.get("symbol") or "") == sym]
@@ -31,6 +31,9 @@ class BinanceSpotClient(BaseRestClient):
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._sym_filter_cache_ttl_sec = 300.0
self._time_offset_ms: int = 0
self._time_sync_monotonic: float = 0.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -158,6 +161,24 @@ class BinanceSpotClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _ensure_server_time(self, *, force: bool = False) -> None:
"""Align signed request timestamps with Binance (GET /api/v3/time)."""
now_m = time.monotonic()
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
return
try:
code, data, _ = self._request("GET", "/api/v3/time")
if code != 200 or not isinstance(data, dict):
return
server_ms = int(data.get("serverTime") or 0)
if server_ms <= 0:
return
local_ms = int(time.time() * 1000)
self._time_offset_ms = server_ms - local_ms
self._time_sync_monotonic = now_m
except Exception:
pass
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
@@ -174,16 +195,34 @@ class BinanceSpotClient(BaseRestClient):
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000)
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
raise LiveTradingError(f"BinanceSpot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
self._ensure_server_time()
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
if "recvWindow" not in p:
p["recvWindow"] = 10000
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
err = LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
if attempt == 0 and ("-1021" in text or "1021" in text):
self._ensure_server_time(force=True)
last_err = err
continue
raise err
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
err = LiveTradingError(f"BinanceSpot error: {data}")
if attempt == 0 and int(data.get("code") or 0) == -1021:
self._ensure_server_time(force=True)
last_err = err
continue
raise err
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("BinanceSpot signed request failed")
def ping(self) -> bool:
"""
@@ -61,6 +61,10 @@ class BitgetMixClient(BaseRestClient):
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
self._lev_cache_ttl_sec = 60.0
# posMode from GET /api/v2/mix/account/account (hedge_mode vs one_way_mode), cached per contract.
self._pos_mode_cache: Dict[str, Tuple[float, str]] = {}
self._pos_mode_cache_ttl_sec = 60.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -242,6 +246,31 @@ class BitgetMixClient(BaseRestClient):
raise LiveTradingError(f"Bitget error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _post_mix_place_order(
self,
body: Dict[str, Any],
*,
original_side: str,
reduce_only: bool,
) -> Dict[str, Any]:
"""
POST place-order; on 40774 (hedge vs one-way mismatch) retry with alternate position fields.
"""
sd = (original_side or "").lower()
try:
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
except LiveTradingError as e:
if "40774" not in str(e):
raise
b2: Dict[str, Any] = {k: v for k, v in body.items() if k not in ("side", "tradeSide", "reduceOnly")}
if "tradeSide" in body:
b2["side"] = sd
b2["reduceOnly"] = "YES" if reduce_only else "NO"
else:
b2["tradeSide"] = "close" if reduce_only else "open"
b2["side"] = ("sell" if sd == "buy" else "buy") if reduce_only else sd
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=b2)
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
if code >= 400:
@@ -252,6 +281,117 @@ class BitgetMixClient(BaseRestClient):
raise LiveTradingError(f"Bitget error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def get_ticker(self, *, symbol: str, **kwargs: Any) -> Dict[str, Any]:
"""
Public mix ticker (for USDT-notional -> base size conversion in quick trade).
Endpoint: GET /api/v2/mix/market/ticker
"""
sym = to_bitget_um_symbol(symbol)
pt = str(kwargs.get("product_type") or "USDT-FUTURES")
if not sym:
return {}
try:
raw = self._public_request(
"GET",
"/api/v2/mix/market/ticker",
params={"symbol": sym, "productType": pt},
)
except Exception:
return {}
data = raw.get("data") if isinstance(raw, dict) else None
if isinstance(data, list) and data:
data = data[0]
if not isinstance(data, dict):
return {}
try:
last = float(
data.get("lastPr")
or data.get("last")
or data.get("close")
or data.get("markPrice")
or data.get("indexPrice")
or 0
)
except Exception:
last = 0.0
if last <= 0:
return {}
return {"last": last, "price": last, "close": last}
def get_account_pos_mode(
self,
*,
symbol: str,
margin_coin: str = "USDT",
product_type: str = "USDT-FUTURES",
) -> str:
"""
Returns Bitget posMode for the contract account: 'hedge_mode', 'one_way_mode', or '' if unknown.
GET /api/v2/mix/account/account
"""
sym = to_bitget_um_symbol(symbol)
if not sym:
return ""
mc = (margin_coin or "USDT").strip().upper()
pt = str(product_type or "USDT-FUTURES")
key = f"{pt}:{sym}:{mc}"
now = time.time()
cached = self._pos_mode_cache.get(key)
if cached:
ts, mode = cached
if (now - float(ts or 0.0)) <= float(self._pos_mode_cache_ttl_sec or 60.0) and mode is not None:
return str(mode)
try:
resp = self._signed_request(
"GET",
"/api/v2/mix/account/account",
params={
"symbol": sym.lower(),
"productType": pt,
"marginCoin": mc.lower() or "usdt",
},
)
d = resp.get("data") if isinstance(resp, dict) else None
mode = ""
if isinstance(d, dict):
mode = str(d.get("posMode") or "").strip().lower()
self._pos_mode_cache[key] = (now, mode)
return mode
except Exception:
return ""
def _mix_order_position_fields(
self,
*,
symbol: str,
side: str,
reduce_only: bool,
margin_coin: str,
product_type: str,
) -> Dict[str, Any]:
"""
Bitget mix place-order: hedge_mode requires tradeSide open/close; one_way_mode requires reduceOnly YES/NO
and must not send tradeSide (see Bitget API doc + CCXT bitget.py).
"""
sd = (side or "").lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
pos_mode = self.get_account_pos_mode(
symbol=symbol, margin_coin=margin_coin, product_type=product_type
)
hedge = pos_mode == "hedge_mode"
if hedge:
# Mirror CCXT: hedge close flips side; hedge open keeps side + tradeSide open.
out: Dict[str, Any] = {
"tradeSide": "close" if reduce_only else "open",
"side": ("sell" if sd == "buy" else "buy") if reduce_only else sd,
}
return out
return {"side": sd, "reduceOnly": "YES" if reduce_only else "NO"}
def get_contract(self, *, symbol: str, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
"""
Fetch contract metadata (best-effort) from public endpoint.
@@ -354,13 +494,34 @@ class BitgetMixClient(BaseRestClient):
"""
return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")})
def get_positions(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
def get_positions(self, *, product_type: str = "USDT-FUTURES", symbol: str = "") -> Dict[str, Any]:
"""
Get all positions (best-effort).
Get positions (best-effort).
Endpoint: GET /api/v2/mix/position/all-position
When ``symbol`` is set (e.g. ETH/USDT), filters the response list to that contract only.
"""
return self._signed_request("GET", "/api/v2/mix/position/all-position", params={"productType": str(product_type or "USDT-FUTURES")})
resp = self._signed_request(
"GET",
"/api/v2/mix/position/all-position",
params={"productType": str(product_type or "USDT-FUTURES")},
)
want = (symbol or "").strip()
if not want:
return resp
sym_key = to_bitget_um_symbol(want).upper()
if not isinstance(resp, dict):
return resp
data = resp.get("data")
if not isinstance(data, list):
return resp
filtered = [
p for p in data
if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key
]
out = dict(resp)
out["data"] = filtered
return out
def set_leverage(
self,
@@ -444,16 +605,22 @@ class BitgetMixClient(BaseRestClient):
"productType": str(product_type or "USDT-FUTURES"),
"marginCoin": str(margin_coin or "USDT"),
"marginMode": self._normalize_margin_mode(margin_mode),
"side": sd,
"orderType": "market",
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
}
if reduce_only:
body["reduceOnly"] = "YES"
body.update(
self._mix_order_position_fields(
symbol=symbol,
side=sd,
reduce_only=reduce_only,
margin_coin=str(margin_coin or "USDT"),
product_type=str(product_type or "USDT-FUTURES"),
)
)
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
data = raw.get("data") if isinstance(raw, dict) else None
exchange_order_id = ""
if isinstance(data, dict):
@@ -498,21 +665,27 @@ class BitgetMixClient(BaseRestClient):
"productType": str(product_type or "USDT-FUTURES"),
"marginCoin": str(margin_coin or "USDT"),
"marginMode": self._normalize_margin_mode(margin_mode),
"side": sd,
"orderType": "limit",
"price": str(px),
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
}
body.update(
self._mix_order_position_fields(
symbol=symbol,
side=sd,
reduce_only=reduce_only,
margin_coin=str(margin_coin or "USDT"),
product_type=str(product_type or "USDT-FUTURES"),
)
)
# Force maker behavior when requested (avoid taker fills).
if post_only:
body["force"] = "post_only"
else:
body["force"] = "gtc"
if reduce_only:
body["reduceOnly"] = "YES"
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
data = raw.get("data") if isinstance(raw, dict) else None
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else ""
return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
@@ -32,7 +32,7 @@ class BybitClient(BaseRestClient):
base_url: str = "https://api.bybit.com",
timeout_sec: float = 15.0,
category: str = "linear", # "linear" (USDT perpetual) or "spot"
recv_window_ms: int = 5000,
recv_window_ms: int = 12000,
broker_referer: str = "",
hedge_mode: bool = False,
):
@@ -45,11 +45,13 @@ class BybitClient(BaseRestClient):
if self.category not in ("linear", "spot"):
self.category = "linear"
try:
self.recv_window_ms = int(recv_window_ms or 5000)
self.recv_window_ms = int(recv_window_ms or 12000)
except Exception:
self.recv_window_ms = 12000
if self.recv_window_ms < 5000:
self.recv_window_ms = 5000
if self.recv_window_ms <= 0:
self.recv_window_ms = 5000
if self.recv_window_ms > 60000:
self.recv_window_ms = 60000
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Bybit api_key/secret_key")
@@ -59,6 +61,12 @@ class BybitClient(BaseRestClient):
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._inst_cache_ttl_sec = 300.0
# Bybit v5 rejects requests if local clock diverges from server (retCode 10002).
# Offset = server_ms - local_ms; signed timestamp uses local_ms + offset.
self._time_offset_ms: int = 0
self._time_offset_at: float = 0.0
self._time_sync_ttl_sec: float = 55.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -161,6 +169,48 @@ class BybitClient(BaseRestClient):
def _sign(self, prehash: str) -> str:
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def _parse_server_time_ms_from_market_time(raw: Dict[str, Any]) -> int:
"""Parse milliseconds from GET /v5/market/time (or similar) JSON."""
if not isinstance(raw, dict):
raise LiveTradingError("Bybit market/time: invalid response")
res = raw.get("result")
if isinstance(res, dict):
nano = res.get("timeNano")
if nano is not None and str(nano).strip() != "":
try:
return int(int(str(nano)) // 1_000_000)
except Exception:
pass
sec = res.get("timeSecond")
if sec is not None and str(sec).strip() != "":
try:
return int(float(sec) * 1000)
except Exception:
pass
t = raw.get("time")
if t is not None:
try:
return int(t)
except Exception:
pass
raise LiveTradingError("Bybit market/time: missing time fields")
def sync_server_time_offset(self, *, force: bool = False) -> None:
"""Align signing timestamp with Bybit server (public /v5/market/time)."""
now = time.time()
if (
not force
and self._time_offset_at > 0
and (now - self._time_offset_at) < float(self._time_sync_ttl_sec or 55.0)
):
return
raw = self._public_request("GET", "/v5/market/time")
srv_ms = self._parse_server_time_ms_from_market_time(raw)
local_ms = int(time.time() * 1000)
self._time_offset_ms = int(srv_ms - local_ms)
self._time_offset_at = now
def _resolve_position_idx(self, pos_side: str) -> Optional[int]:
if not self.hedge_mode:
return None
@@ -193,32 +243,55 @@ class BybitClient(BaseRestClient):
json_body: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
m = str(method or "GET").upper()
ts_ms = str(int(time.time() * 1000))
body_str = self._json_dumps(json_body) if json_body is not None else ""
qs = ""
qs_base = ""
if params:
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
qs_base = urlencode(sorted(norm.items()), doseq=True)
payload_get = qs_base
payload_post = body_str
payload = qs if m == "GET" else body_str
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
sign = self._sign(prehash)
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
try:
self.sync_server_time_offset(force=(attempt > 0))
except Exception as e:
if attempt == 0:
# First attempt: still try with raw local time; second pass may recover.
pass
else:
raise LiveTradingError(f"Bybit time sync failed: {e}") from e
code, data, text = self._request(
m,
path,
params=params if (m == "GET" and params) else (params or None),
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
)
if code >= 400:
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
if isinstance(data, dict):
rc = data.get("retCode")
if rc not in (0, "0", None, ""):
raise LiveTradingError(f"Bybit error: {data}")
return data if isinstance(data, dict) else {"raw": data}
ts_ms = str(int(time.time() * 1000) + int(self._time_offset_ms or 0))
payload = payload_get if m == "GET" else payload_post
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
sign = self._sign(prehash)
code, data, text = self._request(
m,
path,
params=params if (m == "GET" and params) else (params or None),
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
)
if code >= 400:
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
if isinstance(data, dict):
rc = data.get("retCode")
try:
rc_int = int(rc) if rc is not None and str(rc).strip() != "" else 0
except Exception:
rc_int = -1
if rc_int == 10002 and attempt == 0:
last_err = LiveTradingError(f"Bybit error: {data}")
continue
if rc not in (0, "0", None, ""):
raise LiveTradingError(f"Bybit error: {data}")
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("Bybit signed request failed after time resync")
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
@@ -237,6 +310,109 @@ class BybitClient(BaseRestClient):
except Exception:
return False
@staticmethod
def _row_to_ticker_out(row: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(row, dict):
return {}
last_raw = row.get("lastPrice") or row.get("last") or row.get("markPrice") or row.get("indexPrice") or 0
try:
px = float(str(last_raw).replace(",", "").strip() or 0)
except Exception:
px = 0.0
if px <= 0:
return {}
out: Dict[str, Any] = dict(row)
out["last"] = px
out["price"] = px
return out
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
"""
Public market price for USDT notional -> base qty (quick_trade / execution).
Tries: ``/v5/market/tickers`` (by symbol) ``/v5/market/orderbook`` (mid)
``/v5/market/tickers`` (category-only, scan list). Some environments return an empty
ticker list for filtered queries; fallbacks avoid silent failure.
"""
sym = to_bybit_symbol(symbol)
if not sym:
return {}
cat = "spot" if (self.category or "").strip().lower() == "spot" else "linear"
sym_u = sym.upper()
# 1) Filtered tickers (preferred)
try:
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat, "symbol": sym_u})
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
if isinstance(lst, list) and lst:
out = self._row_to_ticker_out(lst[0] if isinstance(lst[0], dict) else {})
if out:
return out
except LiveTradingError:
pass
except Exception:
pass
# 2) Order book mid (bid/ask)
try:
ob = self._public_request(
"GET",
"/v5/market/orderbook",
params={"category": cat, "symbol": sym_u, "limit": 25},
)
res = (ob.get("result") or {}) if isinstance(ob, dict) else {}
bids = res.get("b") or []
asks = res.get("a") or []
bid_p = 0.0
ask_p = 0.0
if isinstance(bids, list) and bids and isinstance(bids[0], (list, tuple)) and len(bids[0]) > 0:
try:
bid_p = float(str(bids[0][0]).replace(",", ""))
except Exception:
bid_p = 0.0
if isinstance(asks, list) and asks and isinstance(asks[0], (list, tuple)) and len(asks[0]) > 0:
try:
ask_p = float(str(asks[0][0]).replace(",", ""))
except Exception:
ask_p = 0.0
mid = 0.0
if bid_p > 0 and ask_p > 0:
mid = (bid_p + ask_p) / 2.0
else:
mid = bid_p or ask_p
if mid > 0:
return {
"symbol": sym_u,
"last": mid,
"price": mid,
"bid1Price": bid_p,
"ask1Price": ask_p,
}
except LiveTradingError:
pass
except Exception:
pass
# 3) Full category ticker list, match symbol (larger payload; last resort)
try:
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat})
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
if isinstance(lst, list):
for row in lst:
if not isinstance(row, dict):
continue
if str(row.get("symbol") or "").strip().upper() != sym_u:
continue
out = self._row_to_ticker_out(row)
if out:
return out
except LiveTradingError:
pass
except Exception:
pass
return {}
def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]:
return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")})
@@ -497,10 +673,28 @@ class BybitClient(BaseRestClient):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))
def get_positions(self) -> Dict[str, Any]:
def get_positions(
self,
*,
symbol: str = "",
settle_coin: str = "",
) -> Dict[str, Any]:
"""
GET /v5/position/list Bybit v5 requires ``symbol`` OR ``settleCoin`` with ``category``.
- Pass ``symbol`` (e.g. ETH/USDT) to query one contract.
- Omit ``symbol`` and pass ``settle_coin`` (default USDT) to list all USDT-linear positions.
"""
if self.category != "linear":
raise LiveTradingError("Bybit positions are only supported for linear category in this client")
return self._signed_request("GET", "/v5/position/list", params={"category": "linear"})
params: Dict[str, Any] = {"category": "linear"}
sym = to_bybit_symbol(symbol) if (symbol or "").strip() else ""
if sym:
params["symbol"] = sym
else:
sc = (settle_coin or "USDT").strip().upper() or "USDT"
params["settleCoin"] = sc
return self._signed_request("GET", "/v5/position/list", params=params)
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
if self.category != "linear":
@@ -117,7 +117,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
category = "spot" if mt == "spot" else "linear"
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 12000)
broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020"
hedge_mode_raw = exchange_config.get("hedge_mode")
if hedge_mode_raw is None:
@@ -4,7 +4,7 @@ Gate.io (direct REST) clients:
- Futures USDT: /api/v4/futures/usdt/*
Signing (apiv4):
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + body + "\\n" + timestamp))
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + hexencode(sha512(payload)) + "\\n" + timestamp))
Headers:
- KEY: api key
- Timestamp: unix seconds
@@ -15,14 +15,42 @@ from __future__ import annotations
import hashlib
import hmac
import logging
import time
from decimal import Decimal, ROUND_DOWN
from typing import Any, Dict, Optional, Tuple
from decimal import Decimal, ROUND_DOWN, ROUND_UP
from typing import Any, Dict, Optional, Tuple, Union
from urllib.parse import urlencode
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_gate_currency_pair
logger = logging.getLogger(__name__)
def _gate_ticker_response_to_normalized(raw: Any) -> Dict[str, Any]:
"""Parse Gate spot/futures tickers API (array of one row) into a dict with float ``last`` for quick_trade."""
row: Dict[str, Any] = {}
if isinstance(raw, list) and raw and isinstance(raw[0], dict):
row = raw[0]
elif isinstance(raw, dict) and raw:
row = raw
else:
return {}
last = 0.0
for key in ("last", "mark_price", "index_price", "close", "price"):
v = row.get(key)
if v is not None and str(v).strip():
try:
last = float(str(v).replace(",", ""))
break
except Exception:
continue
out = dict(row)
out["last"] = last
out["close"] = last
out["price"] = last
return out
class _GateBase(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
@@ -34,7 +62,10 @@ class _GateBase(BaseRestClient):
raise LiveTradingError("Missing Gate api_key/secret_key")
def _sign(self, *, method: str, url: str, query_string: str, body_str: str, ts: str) -> str:
msg = f"{method.upper()}\n{url}\n{query_string}\n{body_str}\n{ts}"
# Per https://www.gate.com/docs/developers/apiv4/en/#authentication — payload slot is SHA512(body).hexdigest(),
# not the raw body (GET / no body => hash of empty string).
hashed_payload = hashlib.sha512((body_str or "").encode("utf-8")).hexdigest()
msg = f"{method.upper()}\n{url}\n{query_string}\n{hashed_payload}\n{ts}"
return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest()
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
@@ -58,7 +89,15 @@ class _GateBase(BaseRestClient):
text = f"t-{text}"
return text[:28]
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
def _signed_request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Any:
m = str(method or "GET").upper()
ts = str(int(time.time()))
body_str = self._json_dumps(json_body) if json_body is not None else ""
@@ -67,7 +106,10 @@ class _GateBase(BaseRestClient):
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
sign = self._sign(method=m, url=path, query_string=qs, body_str=body_str, ts=ts)
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign))
hdrs = dict(self._headers(ts, sign))
if extra_headers:
hdrs.update({str(k): str(v) for k, v in extra_headers.items()})
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=hdrs)
if code >= 400:
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
return data
@@ -87,6 +129,11 @@ class GateSpotClient(_GateBase):
except Exception:
return False
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
pair = to_gate_currency_pair(symbol)
raw = self._public_request("GET", "/api/v4/spot/tickers", params={"currency_pair": pair})
return _gate_ticker_response_to_normalized(raw)
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v4/spot/accounts")
@@ -204,13 +251,21 @@ class GateUsdtFuturesClient(_GateBase):
return Decimal("0")
def ping(self) -> bool:
# Gate futures REST no longer serves /api/v4/futures/usdt/time (returns 400 on fx-api / api hosts).
# Use a lightweight public list call instead.
try:
_ = self._public_request("GET", "/api/v4/futures/usdt/time")
_ = self._public_request("GET", "/api/v4/futures/usdt/contracts", params={"limit": 1})
return True
except Exception:
return False
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
contract = to_gate_currency_pair(symbol)
raw = self._public_request("GET", "/api/v4/futures/usdt/tickers", params={"contract": contract})
return _gate_ticker_response_to_normalized(raw)
def get_contract(self, *, contract: str) -> Dict[str, Any]:
"""Fetch contract metadata with ``X-Gate-Size-Decimal: 1`` to get accurate string-typed size fields."""
c = str(contract or "").strip()
if not c:
return {}
@@ -220,33 +275,122 @@ class GateUsdtFuturesClient(_GateBase):
ts, obj = cached
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
return obj
raw = self._public_request("GET", f"/api/v4/futures/usdt/contracts/{c}")
obj = raw if isinstance(raw, dict) else {}
code, data, text = self._request(
"GET", f"/api/v4/futures/usdt/contracts/{c}",
params=None, headers={"X-Gate-Size-Decimal": "1"},
json_body=None, data=None,
)
if code >= 400:
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
obj = data if isinstance(data, dict) else {}
if obj:
self._contract_cache[c] = (now, obj)
return obj
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
@staticmethod
def _decimal_places(d: Decimal) -> int:
"""Return the number of decimal places in a Decimal value."""
sign, digits, exponent = d.as_tuple()
return max(0, -int(exponent))
def _resolve_order_size(self, *, contract: str, side: str, base_size: float) -> Tuple[str, Optional[Dict[str, str]]]:
"""
Convert base-asset qty to a signed Gate ``size`` string and determine whether to use
the ``X-Gate-Size-Decimal`` header.
Per Gate announcement (2025-12-18):
- ``size`` is always in **contracts** (not base-asset units).
- With ``X-Gate-Size-Decimal: 1``, ``size`` becomes a string that supports decimals.
- A contract supports fractional ordering when ``order_size_min`` (queried with the
decimal header) contains a fractional part (e.g. ``"0.1"``).
- Precision must align with ``order_size_min`` (e.g. if min is ``"0.1"`` 1 dp).
"""
sd = (side or "").strip().lower()
sign = Decimal("1") if sd == "buy" else Decimal("-1")
req = self._to_dec(base_size)
if req <= 0:
return 0
return ("0", None)
meta: Dict[str, Any] = {}
try:
meta = self.get_contract(contract=contract) or {}
except Exception:
meta = {}
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or meta.get("contract_size") or meta.get("contractSize") or "0")
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or "0")
if qm <= 0:
# Fallback: 1 contract ~= 1 base unit (best-effort)
qm = Decimal("1")
contracts = req / qm
return int(self._floor(contracts))
order_min = self._to_dec(meta.get("order_size_min") or "1")
if order_min <= 0:
order_min = Decimal("1")
dp = self._decimal_places(order_min)
if dp > 0:
step = Decimal(10) ** (-dp)
q = contracts.quantize(step, rounding=ROUND_DOWN)
if q < order_min and contracts > 0:
q = order_min
signed_q = q * sign
s = format(signed_q, "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0",
{"X-Gate-Size-Decimal": "1"})
else:
iv = int(self._floor(contracts))
int_min = max(1, int(order_min))
if iv < int_min and contracts > 0:
iv = int_min
signed_iv = int(Decimal(iv) * sign)
return (str(signed_iv), None)
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
"""Integer contracts estimate (for internal use like position sizing display)."""
meta: Dict[str, Any] = {}
try:
meta = self.get_contract(contract=contract) or {}
except Exception:
meta = {}
qm = self._to_dec(meta.get("quanto_multiplier") or "0")
if qm <= 0:
qm = Decimal("1")
return max(1, int(self._floor(self._to_dec(base_size) / qm)))
def contracts_signed_to_base_qty(self, *, contract: str, contracts_signed: float) -> float:
"""Convert signed position size (contracts) from Gate positions API to base-asset quantity."""
try:
ct = abs(float(contracts_signed or 0.0))
except Exception:
return 0.0
if ct <= 0:
return 0.0
meta: Dict[str, Any] = {}
try:
meta = self.get_contract(contract=str(contract)) or {}
except Exception:
meta = {}
qm = self._to_dec(
meta.get("quanto_multiplier")
or meta.get("quantoMultiplier")
or meta.get("contract_size")
or meta.get("contractSize")
or "0"
)
if qm <= 0:
qm = Decimal("1")
return float(Decimal(str(ct)) * qm)
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v4/futures/usdt/accounts")
def get_positions(self) -> Any:
return self._signed_request("GET", "/api/v4/futures/usdt/positions")
return self._signed_request(
"GET", "/api/v4/futures/usdt/positions",
extra_headers={"X-Gate-Size-Decimal": "1"},
)
def set_leverage(self, *, contract: str, leverage: float) -> bool:
c = str(contract or "").strip()
@@ -258,11 +402,26 @@ class GateUsdtFuturesClient(_GateBase):
lv = 1
if lv < 1:
lv = 1
try:
_ = self._signed_request("POST", f"/api/v4/futures/usdt/positions/{c}/leverage", json_body={"leverage": str(lv)})
return True
except Exception:
return False
path = f"/api/v4/futures/usdt/positions/{c}/leverage"
lv_s = str(lv)
# Gate expects ``leverage`` / ``cross_leverage_limit`` as **query parameters**, not JSON body
# (see gateapi-python: update_position_leverage). Cross / portfolio mode: leverage=0 + cross_leverage_limit.
attempts: Tuple[Dict[str, str], ...] = (
{"leverage": lv_s},
{"leverage": "0", "cross_leverage_limit": lv_s},
{"leverage": lv_s, "cross_leverage_limit": lv_s},
)
last_err: Optional[Exception] = None
for qp in attempts:
try:
_ = self._signed_request("POST", path, params=qp, json_body=None)
return True
except LiveTradingError as e:
last_err = e
except Exception as e:
last_err = e
logger.warning("Gate set_leverage failed contract=%s leverage=%s: %s", c, lv, last_err)
return False
def place_market_order(
self,
@@ -276,18 +435,27 @@ class GateUsdtFuturesClient(_GateBase):
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
base_qty = float(size or 0.0)
if base_qty <= 0:
raise LiveTradingError("Invalid size (<= 0)")
contract = to_gate_currency_pair(symbol)
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
if csz <= 0:
raise LiveTradingError("Invalid size (converted contracts <= 0)")
signed_size = int(csz) if sd == "buy" else -int(csz)
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"}
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
if size_str in ("0", "-0", ""):
raise LiveTradingError("Invalid size (resolved contracts == 0)")
logger.info("Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s",
contract, sd, base_qty, size_str, extra_headers is not None)
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": "0", "tif": "ioc"}
if reduce_only:
body["reduce_only"] = True
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
raw = self._signed_request(
"POST",
"/api/v4/futures/usdt/orders",
json_body=body,
extra_headers=extra_headers,
)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -304,21 +472,28 @@ class GateUsdtFuturesClient(_GateBase):
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
contract = to_gate_currency_pair(symbol)
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
if csz <= 0:
raise LiveTradingError("Invalid size (converted contracts <= 0)")
base_qty = float(size or 0.0)
if base_qty <= 0:
raise LiveTradingError("Invalid size (<= 0)")
px = float(price or 0.0)
if px <= 0:
raise LiveTradingError("Invalid price")
signed_size = int(csz) if sd == "buy" else -int(csz)
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"}
contract = to_gate_currency_pair(symbol)
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
if size_str in ("0", "-0", ""):
raise LiveTradingError("Invalid size (resolved contracts == 0)")
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": str(px), "tif": "gtc"}
if reduce_only:
body["reduce_only"] = True
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
raw = self._signed_request(
"POST",
"/api/v4/futures/usdt/orders",
json_body=body,
extra_headers=extra_headers,
)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -19,9 +19,13 @@ from urllib.parse import urlencode, urlparse
import datetime
import time
import logging
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
logger = logging.getLogger(__name__)
class HtxClient(BaseRestClient):
def __init__(
@@ -53,6 +57,17 @@ class HtxClient(BaseRestClient):
self._contract_cache_ttl_sec = 300.0
self._lever_cache: Dict[str, int] = {}
@staticmethod
def _format_swap_client_order_id(client_order_id: Optional[str]) -> Optional[int]:
"""HTX swap/linear-swap client_order_id must be a pure numeric long (1~9223372036854775807)."""
if not client_order_id:
return None
digits = "".join(c for c in str(client_order_id) if c.isdigit())
if not digits:
digits = str(int(time.time() * 1000))
val = int(digits[-18:])
return val if 0 < val <= 9223372036854775807 else None
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
prefix = str(self.broker_id or "").strip()
raw = str(client_order_id or "").strip()
@@ -202,11 +217,33 @@ class HtxClient(BaseRestClient):
if self.market_type == "spot":
account_id = self._get_spot_account_id()
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
# 1) v1 cross
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 cross account_info failed: %s", e)
# 2) v3 unified (unified / multi-asset collateral accounts)
try:
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
v3_code = raw.get("code")
if v3_code is not None and int(v3_code) == 200:
data = raw.get("data")
if isinstance(data, list) and data:
logger.info("HTX v3 unified_account_info succeeded")
return raw
else:
logger.debug("HTX v3 unified_account_info returned code=%s", v3_code)
except (LiveTradingError, Exception) as e:
logger.debug("HTX v3 unified_account_info failed: %s", e)
# 3) v1 isolated
try:
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
except LiveTradingError as e:
logger.warning("HTX all balance endpoints failed, last error: %s", e)
return {"data": []}
def get_positions(self, *, symbol: str = "") -> Any:
if self.market_type == "spot":
@@ -235,11 +272,35 @@ class HtxClient(BaseRestClient):
return {"data": rows}
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
# 1) v1 cross
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 cross position_info failed: %s", e)
# 2) v1 isolated
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 isolated position_info failed: %s", e)
# 3) v3 unified - extract positions from cross_swap sub-array
try:
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
v3_code = raw.get("code")
if v3_code is not None and int(v3_code) == 200:
v3_data = raw.get("data") or []
if isinstance(v3_data, list) and v3_data:
logger.info("HTX v3 unified_account_info for positions succeeded")
return raw
except (LiveTradingError, Exception) as e:
logger.debug("HTX v3 unified_account_info (positions) failed: %s", e)
logger.warning("HTX all position endpoints failed for symbol=%s", symbol)
return {"data": []}
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
if self.market_type == "spot":
@@ -355,7 +416,7 @@ class HtxClient(BaseRestClient):
raise LiveTradingError(f"Invalid side: {side}")
offset = "close" if reduce_only else "open"
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
body: Dict[str, Any] = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
@@ -365,8 +426,18 @@ class HtxClient(BaseRestClient):
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
swap_coid = self._format_swap_client_order_id(client_order_id)
if swap_coid is not None:
body["client_order_id"] = swap_coid
cross_body = dict(body)
cross_body["margin_account"] = "USDT"
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
except LiveTradingError as e:
logger.info("HTX swap_cross_order failed, trying swap_order: %s", e)
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
@@ -412,7 +483,7 @@ class HtxClient(BaseRestClient):
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
body: Dict[str, Any] = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
@@ -423,8 +494,18 @@ class HtxClient(BaseRestClient):
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
swap_coid = self._format_swap_client_order_id(client_order_id)
if swap_coid is not None:
body["client_order_id"] = swap_coid
cross_body = dict(body)
cross_body["margin_account"] = "USDT"
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
except LiveTradingError:
pass
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
@@ -129,7 +129,7 @@ class MarketDataCollector:
}
# If fundamentals are needed, also obtain them in parallel
if market == 'USStock':
if market in ('USStock', 'CNStock', 'HKStock'):
core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental"
core_futures[executor.submit(self._get_company, market, symbol)] = "company"
elif market == 'Crypto':
@@ -616,9 +616,94 @@ class MarketDataCollector:
try:
if market == 'USStock':
return self._get_us_fundamental(symbol)
if market in ('CNStock', 'HKStock'):
return self._get_cn_hk_fundamental(market, symbol)
except Exception as e:
logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}")
return None
def _get_cn_hk_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""
CN/HK fundamentals multi-tier:
Tier 1: Twelve Data /statistics (globally stable, paid)
Tier 2: AkShare / Eastmoney (fragile overseas)
+ Tencent quote for live price fields
"""
try:
from app.data_sources.tencent import (
normalize_cn_code,
normalize_hk_code,
fetch_quote,
parse_quote_to_ticker,
)
from app.data_sources.cn_hk_fundamentals import (
fetch_twelvedata_fundamental,
fetch_cn_fundamental_akshare,
fetch_hk_fundamental_akshare,
)
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
is_hk = market == 'HKStock'
parts = fetch_quote(code)
t = parse_quote_to_ticker(parts) if parts else {}
result: Dict[str, Any] = {
"pe_ratio": None,
"pb_ratio": None,
"ps_ratio": None,
"market_cap": None,
"dividend_yield": None,
"beta": None,
"52w_high": None,
"52w_low": None,
"roe": None,
"eps": None,
"last": t.get("last"),
"previous_close": t.get("previousClose"),
"change_percent": t.get("changePercent"),
"source": "tencent_quote",
}
# Tier 1: Twelve Data
td = {}
try:
td = fetch_twelvedata_fundamental(code, is_hk)
except Exception as e:
logger.debug("TwelveData fundamental failed %s:%s: %s", market, symbol, e)
if td:
result["source"] = "tencent_quote+twelvedata"
for k, v in td.items():
if k == "source":
continue
if v is not None:
result[k] = v
# Tier 2: AkShare (fill any remaining None fields)
has_valuation = result.get("pe_ratio") is not None or result.get("pb_ratio") is not None
if not has_valuation:
try:
ak_data = fetch_cn_fundamental_akshare(code) if not is_hk else fetch_hk_fundamental_akshare(code)
except Exception as e:
logger.debug("AkShare CN/HK fundamental failed %s:%s: %s", market, symbol, e)
ak_data = {}
if ak_data:
if "twelvedata" not in result.get("source", ""):
result["source"] = "tencent_quote+akshare_em"
else:
result["source"] += "+akshare_em"
for k, v in ak_data.items():
if k == "source":
continue
if v is not None and result.get(k) is None:
result[k] = v
if not parts and not td and not has_valuation:
return None
return result
except Exception as e:
logger.debug(f"CN/HK fundamental failed: {market}:{symbol}: {e}")
return None
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
@@ -924,11 +1009,85 @@ class MarketDataCollector:
'market_cap': profile.get('marketCapitalization'),
'website': profile.get('weburl'),
}
if market in ('CNStock', 'HKStock'):
return self._get_cn_hk_company(market, symbol)
except Exception as e:
logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}")
return None
def _get_cn_hk_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""
CN/HK company info multi-tier:
Tier 1: Twelve Data /profile (globally stable)
Tier 2: AkShare / Eastmoney (fragile overseas)
+ Tencent quote for Chinese name
"""
try:
from app.data_sources.tencent import (
normalize_cn_code,
normalize_hk_code,
fetch_quote,
)
from app.data_sources.cn_hk_fundamentals import (
fetch_twelvedata_profile,
fetch_cn_company_extras,
fetch_hk_company_extras,
)
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
is_hk = market == 'HKStock'
parts = fetch_quote(code)
cn_name = ""
if parts:
cn_name = (parts[1] or "").strip() if len(parts) > 1 else ""
row: Dict[str, Any] = {
"name": cn_name or code,
"country": "CN" if market == "CNStock" else "HK",
"exchange": "SSE/SZSE" if market == "CNStock" else "HKEX",
"symbol": code,
"source": "tencent_quote",
}
# Tier 1: Twelve Data /profile
td_profile = {}
try:
td_profile = fetch_twelvedata_profile(code, is_hk)
except Exception as e:
logger.debug("TwelveData profile failed %s:%s: %s", market, symbol, e)
if td_profile:
row["source"] = "tencent_quote+twelvedata"
for k in ("industry", "sector", "website", "description", "employees", "full_name"):
v = td_profile.get(k)
if v is not None:
row[k] = v
if not cn_name and td_profile.get("name"):
row["name"] = td_profile["name"]
# Tier 2: AkShare (fill remaining gaps)
if not row.get("industry"):
try:
ex = fetch_cn_company_extras(code) if not is_hk else fetch_hk_company_extras(code)
except Exception:
ex = {}
if ex:
if "twelvedata" not in row.get("source", ""):
row["source"] = "tencent_quote+akshare_em"
else:
row["source"] += "+akshare_em"
for k in ("industry", "ipo_date", "website", "full_name"):
if ex.get(k) and not row.get(k):
row[k] = ex[k]
if not parts and not td_profile and not row.get("industry"):
return None
return row
except Exception:
return None
# ==================== Macro data (reusing the global financial sector) ====================
@@ -360,8 +360,8 @@ class PendingOrderWorker:
exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(total))
elif isinstance(client, BybitClient) and market_type == "swap":
# Bybit linear positions
resp = client.get_positions()
# Bybit v5 requires symbol or settleCoin — use USDT for full linear book
resp = client.get_positions(settle_coin="USDT")
lst = (((resp.get("result") or {}).get("list")) if isinstance(resp, dict) else None) or []
if isinstance(lst, list):
for p in lst:
@@ -1234,13 +1234,14 @@ class PendingOrderWorker:
actual_pos_size = pos_amt
break
elif isinstance(client, BybitClient):
pos_resp = client.get_positions() or {}
pos_resp = client.get_positions(symbol=str(symbol or "")) or {}
pos_list = (pos_resp.get("result") or {}).get("list") or [] if isinstance(pos_resp, dict) else []
want = str(symbol or "").replace("/", "").replace("-", "").upper()
for pos in pos_list:
if not isinstance(pos, dict):
continue
pos_sym = str(pos.get("symbol") or "")
if pos_sym != str(symbol or "").replace("/", ""):
pos_sym = str(pos.get("symbol") or "").strip().upper()
if pos_sym != want:
continue
p_side = str(pos.get("side") or "").strip().lower()
if (p_side == "buy" and pos_side == "long") or (p_side == "sell" and pos_side == "short"):
@@ -87,26 +87,20 @@ class PolymarketWorker:
logger.info("Starting Polymarket data update and analysis...")
start_time = time.time()
# 1. Update market data (obtained from all major categories)
categories = ["crypto", "politics", "economics", "sports", "tech", "finance", "geopolitics", "culture", "climate", "entertainment"]
all_markets = []
# Gamma API /events has no category param — fetch ALL once, categorize locally.
all_markets = self.polymarket_source.get_trending_markets(category="all", limit=500)
logger.info(f"Fetched {len(all_markets)} markets from Gamma API (single request)")
for category in categories:
try:
markets = self.polymarket_source.get_trending_markets(category, limit=50)
all_markets.extend(markets)
logger.info(f"Fetched {len(markets)} markets from category: {category}")
except Exception as e:
logger.warning(f"Failed to fetch markets for category {category}: {e}")
# Deduplication (by market_id)
unique_markets = {}
cat_counts: Dict[str, int] = {}
for market in all_markets:
market_id = market.get('market_id')
if market_id:
unique_markets[market_id] = market
cat = market.get('category', 'other')
cat_counts[cat] = cat_counts.get(cat, 0) + 1
logger.info(f"Total unique markets: {len(unique_markets)}")
logger.info(f"Total unique markets: {len(unique_markets)}, by category: {cat_counts}")
# 2. Analyze the market in batches (analyze all markets at once, and use AI to screen opportunities)
markets_list = list(unique_markets.values())
@@ -28,6 +28,8 @@ from datetime import datetime, timezone
from email.message import EmailMessage
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
import requests
from app.utils.db import get_db_connection
@@ -78,6 +80,43 @@ def _signal_meta(signal_type: str) -> Dict[str, str]:
return {"action": action, "side": side, "type": st}
def _load_user_timezone_for_strategy(strategy_id: int) -> str:
try:
sid = int(strategy_id)
except Exception:
return ""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT COALESCE(u.timezone, '') AS tz
FROM qd_strategies_trading s
JOIN qd_users u ON u.id = s.user_id
WHERE s.id = ?
""",
(sid,),
)
row = cur.fetchone() or {}
cur.close()
return str(row.get("tz") or "").strip()
except Exception:
return ""
def _utc_ts_to_user_display(now: int, user_timezone: str) -> Tuple[str, str, str]:
"""Return (utc_iso, display_local_str, label_for_plaintext)."""
iso = datetime.fromtimestamp(int(now), tz=timezone.utc).isoformat()
utz = (user_timezone or "").strip()
if not utz:
return iso, iso, "Time (UTC)"
try:
dt = datetime.fromtimestamp(int(now), tz=timezone.utc).astimezone(ZoneInfo(utz))
return iso, dt.strftime("%Y-%m-%d %H:%M:%S"), f"Time ({utz})"
except Exception:
return iso, iso, "Time (UTC)"
def _fmt_float(value: Any, *, max_decimals: int = 10) -> str:
try:
v = float(value or 0.0)
@@ -150,6 +189,7 @@ class SignalNotifier:
targets = _safe_json(cfg.get("targets") or {})
extra = extra if isinstance(extra, dict) else {}
user_tz = _load_user_timezone_for_strategy(int(strategy_id))
payload = self._build_payload(
strategy_id=strategy_id,
strategy_name=strategy_name,
@@ -159,6 +199,7 @@ class SignalNotifier:
stake_amount=stake_amount,
direction=direction,
extra=extra,
user_timezone=user_tz,
)
rendered = self._render_messages(payload)
title = rendered.get("title") or ""
@@ -252,9 +293,10 @@ class SignalNotifier:
stake_amount: float,
direction: str,
extra: Dict[str, Any],
user_timezone: str = "",
) -> Dict[str, Any]:
now = int(time.time())
iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat()
iso, disp, tlab = _utc_ts_to_user_display(now, user_timezone)
meta = _signal_meta(signal_type)
pending_id = None
@@ -268,6 +310,8 @@ class SignalNotifier:
"version": 1,
"timestamp": now,
"timestamp_iso": iso,
"timestamp_display": disp,
"time_label": tlab,
"strategy": {
"id": int(strategy_id),
"name": str(strategy_name or ""),
@@ -310,6 +354,8 @@ class SignalNotifier:
pending_id = int(trace.get("pending_order_id") or 0) if trace.get("pending_order_id") else 0
mode = str(trace.get("mode") or "")
ts_iso = str(payload.get("timestamp_iso") or "")
ts_disp = str(payload.get("timestamp_display") or "") or ts_iso
ts_lbl = str(payload.get("time_label") or "Time")
plain_lines = [
"QuantDinger Signal",
@@ -323,8 +369,8 @@ class SignalNotifier:
plain_lines.append(f"PendingOrder: {pending_id}")
if mode:
plain_lines.append(f"Mode: {mode}")
if ts_iso:
plain_lines.append(f"Time(UTC): {ts_iso}")
if ts_disp:
plain_lines.append(f"{ts_lbl}: {ts_disp}")
# Telegram (HTML) message. Escape all dynamic values.
t_strategy = f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})"
@@ -341,8 +387,8 @@ class SignalNotifier:
telegram_lines.append(f"<b>PendingOrder</b>: <code>{pending_id}</code>")
if mode:
telegram_lines.append(f"<b>Mode</b>: <code>{html.escape(mode)}</code>")
if ts_iso:
telegram_lines.append(f"<b>Time (UTC)</b>: <code>{html.escape(ts_iso)}</code>")
if ts_disp:
telegram_lines.append(f"<b>{html.escape(ts_lbl)}</b>: <code>{html.escape(ts_disp)}</code>")
telegram_html = "\n".join([x for x in telegram_lines if x is not None])
# Email (HTML) message. Keep inline CSS for maximum compatibility.
@@ -355,7 +401,8 @@ class SignalNotifier:
stake_text=stake_s,
pending_id=pending_id or None,
mode_text=mode or "",
timestamp_iso=ts_iso or "",
timestamp_display=ts_disp or "",
time_row_label=ts_lbl or "Time",
)
return {
@@ -376,7 +423,8 @@ class SignalNotifier:
stake_text: str,
pending_id: Optional[int],
mode_text: str,
timestamp_iso: str,
timestamp_display: str,
time_row_label: str,
) -> str:
def esc(s: Any) -> str:
return html.escape(str(s or ""))
@@ -392,8 +440,8 @@ class SignalNotifier:
rows.append(("PendingOrder", str(int(pending_id))))
if mode_text:
rows.append(("Mode", mode_text))
if timestamp_iso:
rows.append(("Time (UTC)", timestamp_iso))
if timestamp_display:
rows.append((time_row_label or "Time", timestamp_display))
tr_html = "\n".join(
[
@@ -418,7 +466,7 @@ class SignalNotifier:
<div style="max-width:640px;margin:0 auto;padding:24px;">
<div style="background:#111827;color:#ffffff;padding:16px 18px;border-radius:12px 12px 0 0;">
<div style="font-size:16px;letter-spacing:0.2px;font-weight:600;">{esc(title_text)}</div>
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_iso) if timestamp_iso else ""}</div>
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_display) if timestamp_display else ""}</div>
</div>
<div style="background:#ffffff;border:1px solid #eaecef;border-top:0;border-radius:0 0 12px 12px;overflow:hidden;">
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
+36 -9
View File
@@ -489,16 +489,35 @@ class StrategyService:
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
)
msg = f"{msg} | {hint}"
hint_cn = (
"币安接口返回 -2015(密钥/IP/权限不匹配)。请逐项核对:"
"① API Key 是否勾选与当前测试一致的业务(现货选现货权限,合约选合约/U 本位权限);"
"② 若启用 IP 白名单,是否包含当前服务器出口 IP(见下方 egress_ip);"
"③ base_url 与密钥环境一致(主网密钥配 api.binance.com / fapi,模拟盘配 demo 域名与 demo Key);"
"④ 无多余空格、复制完整 Secret。"
)
if alt_ok:
hint_cn += (
f" 自动探测:同一密钥在 market_type={alt_market_type} 可通过,"
f"当前选择的 {market_type} 与密钥权限不一致的可能性很大。"
)
else:
hint_cn = ""
fail_payload = {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
}
if hint_cn:
fail_payload['hint_cn'] = hint_cn
return {
'success': False,
'message': f'Auth failed: {msg}',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
},
'data': fail_payload,
}
return {
@@ -960,12 +979,22 @@ class StrategyService:
leverage = (trading_config or {}).get('leverage') or existing.get('leverage') or 1
market_type = (trading_config or {}).get('market_type') or existing.get('market_type') or 'swap'
strategy_type = (payload.get('strategy_type') if payload.get('strategy_type') is not None
else existing.get('strategy_type')) or 'IndicatorStrategy'
strategy_mode = (payload.get('strategy_mode') if payload.get('strategy_mode') is not None
else existing.get('strategy_mode')) or 'signal'
if 'strategy_code' in payload:
strategy_code = payload.get('strategy_code') or ''
else:
strategy_code = existing.get('strategy_code') or ''
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
UPDATE qd_strategies_trading
SET strategy_name = ?,
strategy_type = ?,
strategy_mode = ?,
strategy_code = ?,
market_category = ?,
@@ -985,8 +1014,6 @@ class StrategyService:
""",
(
name,
strategy_mode,
strategy_code,
market_category,
execution_mode,
self._dump_json_or_encrypt(notification_config, encrypt=False),
@@ -0,0 +1,193 @@
"""
Python 策略脚本on_init / on_bar + ctx.buy/sell/close_position运行时
与回测逻辑对齐 TradingExecutor 实盘逐根 K 线调用
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from app.utils.logger import get_logger
logger = get_logger(__name__)
class ScriptBar(dict):
def __getattr__(self, name: str) -> Any:
try:
return self[name]
except KeyError as exc:
raise AttributeError(name) from exc
class ScriptPosition(dict):
def __init__(self):
super().__init__()
self.clear_position()
def __getattr__(self, name: str) -> Any:
try:
return self[name]
except KeyError as exc:
raise AttributeError(name) from exc
def __bool__(self) -> bool:
return bool(self.get('side')) and float(self.get('size') or 0) > 0
def __int__(self) -> int:
return int(self.get('direction') or 0)
def __float__(self) -> float:
return float(self.get('direction') or 0)
def __eq__(self, other: Any) -> bool:
try:
return int(self) == int(other)
except Exception:
return dict.__eq__(self, other)
def __lt__(self, other: Any) -> bool:
return int(self) < int(other)
def __le__(self, other: Any) -> bool:
return int(self) <= int(other)
def __gt__(self, other: Any) -> bool:
return int(self) > int(other)
def __ge__(self, other: Any) -> bool:
return int(self) >= int(other)
def clear_position(self) -> None:
self.clear()
self.update({
'side': '',
'size': 0.0,
'entry_price': 0.0,
'direction': 0,
'amount': 0.0,
})
def open_position(self, side: str, entry_price: float, amount: float) -> None:
direction = 1 if side == 'long' else (-1 if side == 'short' else 0)
size = float(amount or 0.0)
price = float(entry_price or 0.0)
self.clear()
self.update({
'side': side,
'size': size,
'entry_price': price,
'direction': direction,
'amount': size,
})
def add_position(self, entry_price: float, amount: float) -> None:
extra = float(amount or 0.0)
if extra <= 0:
return
current_size = float(self.get('size') or 0.0)
current_price = float(self.get('entry_price') or 0.0)
next_size = current_size + extra
next_price = float(entry_price or current_price or 0.0)
if current_size > 0 and current_price > 0 and next_size > 0:
next_price = ((current_price * current_size) + (float(entry_price or current_price) * extra)) / next_size
self['size'] = next_size
self['amount'] = next_size
self['entry_price'] = next_price
class StrategyScriptContext:
"""与回测 ScriptBacktestContext 行为一致,供实盘按根推进。"""
def __init__(self, bars_df: pd.DataFrame, initial_balance: float):
self._bars_df = bars_df
self._params: Dict[str, Any] = {}
self._orders: List[Dict[str, Any]] = []
self._logs: List[str] = []
self.current_index = -1
self.position = ScriptPosition()
self.balance = float(initial_balance)
self.equity = float(initial_balance)
def param(self, name: str, default: Any = None) -> Any:
if name not in self._params:
self._params[name] = default
return self._params[name]
def bars(self, n: int = 1):
start = max(0, self.current_index - int(n) + 1)
out = []
for _, row in self._bars_df.iloc[start:self.current_index + 1].iterrows():
out.append(ScriptBar(
open=float(row.get('open') or 0),
high=float(row.get('high') or 0),
low=float(row.get('low') or 0),
close=float(row.get('close') or 0),
volume=float(row.get('volume') or 0),
timestamp=row.get('time')
))
return out
def log(self, message: Any):
self._logs.append(str(message))
def buy(self, price: Any = None, amount: Any = None):
self._orders.append({'action': 'buy', 'price': price, 'amount': amount})
def sell(self, price: Any = None, amount: Any = None):
self._orders.append({'action': 'sell', 'price': price, 'amount': amount})
def close_position(self):
self._orders.append({'action': 'close'})
def compile_strategy_script_handlers(code: str) -> Tuple[Optional[Callable], Optional[Callable]]:
"""
校验并编译策略脚本返回 (on_init, on_bar)
on_bar 不可缺省on_init 可选
"""
if not code or not str(code).strip():
raise ValueError("Strategy script is empty")
import builtins
def safe_import(name, *args, **kwargs):
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
if name in allowed_modules or name.split('.')[0] in allowed_modules:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"Import not allowed: {name}")
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'help', 'exit', 'quit']}
safe_builtins['__import__'] = safe_import
exec_env = {
'__builtins__': safe_builtins,
'np': np,
'pd': pd,
}
from app.utils.safe_exec import validate_code_safety, safe_exec_code
is_safe, error_msg = validate_code_safety(code)
if not is_safe:
raise ValueError(f"Code contains unsafe operations: {error_msg}")
exec_result = safe_exec_code(
code=code,
exec_globals=exec_env,
exec_locals=exec_env,
timeout=60
)
if not exec_result['success']:
raise RuntimeError(f"Code execution failed: {exec_result.get('error')}")
on_init = exec_env.get('on_init')
on_bar = exec_env.get('on_bar')
if not callable(on_bar):
raise ValueError("Strategy script must define on_bar(ctx, bar)")
if on_init is not None and not callable(on_init):
on_init = None
return (on_init if callable(on_init) else None), on_bar
@@ -135,14 +135,29 @@ class StrategySnapshotResolver:
timeframe = str(override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D").strip() or "1D"
initial_capital = self._to_float(override.get("initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000))), 10000.0)
leverage = self._to_int(override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1)
commission = self._percent_to_ratio(override.get("commission", trading_config.get("commission", 0)))
slippage = self._percent_to_ratio(override.get("slippage", trading_config.get("slippage", 0)))
# Commission/slippage are backtest-only assumptions (not used by live ScriptStrategy execution).
# Script strategies created from the UI may omit these; apply sensible backtest defaults.
commission_raw = override.get("commission")
if commission_raw is None:
commission_raw = trading_config.get("commission")
slippage_raw = override.get("slippage")
if slippage_raw is None:
slippage_raw = trading_config.get("slippage")
strategy_type_early = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
strategy_mode_early = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
is_script_early = strategy_type_early == "ScriptStrategy" or strategy_mode_early == "script"
if commission_raw is None or commission_raw == "":
commission_raw = 0.05 if is_script_early else 0
if slippage_raw is None or slippage_raw == "":
slippage_raw = 0.0
commission = self._percent_to_ratio(commission_raw)
slippage = self._percent_to_ratio(slippage_raw)
trade_direction = str(trading_config.get("trade_direction") or "long").strip().lower() or "long"
enable_mtf = self._to_bool(override.get("enableMtf", market.lower() == "crypto"))
strategy_type = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
strategy_mode = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
is_script = strategy_type == "ScriptStrategy" or strategy_mode == "script"
strategy_type = strategy_type_early
strategy_mode = strategy_mode_early
is_script = is_script_early
indicator_id = indicator_config.get("indicator_id") or strategy.get("indicator_id")
indicator_name = indicator_config.get("indicator_name") or ""
@@ -21,6 +21,7 @@ import requests
from app.utils.logger import get_logger
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
from app.data_sources.tencent import normalize_cn_code, normalize_hk_code
logger = get_logger(__name__)
@@ -28,6 +29,10 @@ logger = get_logger(__name__)
def _normalize_symbol_for_market(market: str, symbol: str) -> str:
m = (market or '').strip()
s = (symbol or '').strip().upper()
if m == 'CNStock':
return normalize_cn_code(s)
if m == 'HKStock':
return normalize_hk_code(s)
return s
@@ -105,6 +110,17 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
# otherwise fall back to yfinance.
return _resolve_name_from_finnhub(s) or _resolve_name_from_yfinance(s)
# CN/HK stocks: try Tencent quote name first (no key), then yfinance best-effort.
if m in ('CNStock', 'HKStock'):
try:
from app.data_sources.tencent import fetch_quote
parts = fetch_quote(s)
if parts and len(parts) > 1 and parts[1]:
return str(parts[1]).strip()
except Exception:
pass
return _resolve_name_from_yfinance(s)
# Crypto: at least return base ticker-like display (not a "company", but better than empty)
if m == 'Crypto':
if '/' in s:
@@ -1,5 +1,9 @@
"""
Real-time trade execution services
Real-time trade execution service.
Strategy thread: Generates candlestick charts/prices, calculates signals, and writes orders to pending_orders.
Live trades are executed via PendingOrderWorker + app.services.live_trading (direct REST connection to each exchange); ccxt is not used for order placement in this module.
"""
import time
import threading
@@ -15,13 +19,18 @@ import json
from decimal import Decimal, ROUND_DOWN, ROUND_UP
import pandas as pd
import numpy as np
import ccxt
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from app.utils.strategy_runtime_logs import append_strategy_log
from app.data_sources import DataSourceFactory
from app.services.kline import KlineService
from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller
from app.services.strategy_script_runtime import (
ScriptBar,
StrategyScriptContext,
compile_strategy_script_handlers,
)
logger = get_logger(__name__)
@@ -45,6 +54,8 @@ class TradingExecutor:
self._signal_dedup = {} # type: Dict[int, Dict[str, float]]
self._signal_dedup_lock = threading.Lock()
self.kline_service = KlineService() # K-line service (with cache)
# Throttle writes to qd_strategy_logs (heartbeat), per strategy_id -> monotonic time
self._strategy_ui_log_last_tick_ts = {} # type: Dict[int, float]
# The upper limit of single-instance threads to avoid unlimited thread creation causing can't start new thread/OOM
self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64'))
@@ -422,6 +433,7 @@ class TradingExecutor:
logger.info(f"Strategy {strategy_id} started")
self._console_print(f"[strategy:{strategy_id}] started")
append_strategy_log(strategy_id, "info", "策略执行线程已启动")
return True
except Exception as e:
@@ -460,12 +472,219 @@ class TradingExecutor:
logger.info(f"Strategy {strategy_id} stopped")
self._console_print(f"[strategy:{strategy_id}] stopped (requested)")
append_strategy_log(strategy_id, "info", "已请求停止策略(运行标志已清除)")
return True
except Exception as e:
logger.error(f"Failed to stop strategy {strategy_id}: {str(e)}")
logger.error(traceback.format_exc())
return False
def _df_to_script_exec_df(self, df: pd.DataFrame) -> pd.DataFrame:
out = df.reset_index()
c0 = out.columns[0]
if c0 != 'time':
out.rename(columns={c0: 'time'}, inplace=True)
return out
def _script_default_position_ratio(self, trading_config: Dict[str, Any]) -> float:
try:
ep = (trading_config or {}).get('entry_pct')
if ep is not None:
return float(self._to_ratio(ep, default=0.06))
except Exception:
pass
return 0.06
def _hydrate_script_ctx_from_positions(self, ctx: StrategyScriptContext, strategy_id: int, symbol: str) -> None:
ctx.position.clear_position()
pl = self._get_current_positions(strategy_id, symbol)
if not pl:
return
p = pl[0]
side = (p.get('side') or 'long').strip().lower()
if side not in ('long', 'short'):
return
size = float(p.get('size') or 0)
ep = float(p.get('entry_price') or 0)
if size > 0:
ctx.position.open_position(side, ep, size)
def _init_script_strategy_context(
self,
strategy_id: int,
df: pd.DataFrame,
trading_config: Dict[str, Any],
initial_capital: float,
) -> Tuple[StrategyScriptContext, Optional[pd.Timestamp]]:
df_exec = self._df_to_script_exec_df(df)
ctx = StrategyScriptContext(df_exec, float(initial_capital or 0))
raw = (trading_config or {}).get('script_runtime_state') or {}
params = raw.get('params') if isinstance(raw, dict) else {}
if isinstance(params, dict):
ctx._params = dict(params)
last_ts = None
ts_s = raw.get('last_closed_bar_ts') if isinstance(raw, dict) else None
if ts_s:
try:
last_ts = pd.Timestamp(ts_s)
if last_ts.tzinfo is None:
last_ts = last_ts.tz_localize('UTC')
else:
last_ts = last_ts.tz_convert('UTC')
except Exception:
last_ts = None
return ctx, last_ts
def _persist_script_runtime_state(self, strategy_id: int, closed_ts: Any, params: Dict[str, Any]) -> None:
try:
safe_params = json.loads(json.dumps(params or {}, default=str))
except Exception:
safe_params = {}
ts_str = ''
try:
if closed_ts is not None:
ts_str = pd.Timestamp(closed_ts).isoformat()
except Exception:
ts_str = ''
state = {'last_closed_bar_ts': ts_str, 'params': safe_params}
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT trading_config FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cur.fetchone()
if not row:
cur.close()
return
tc = row.get('trading_config')
if isinstance(tc, str) and tc.strip():
try:
tc = json.loads(tc)
except Exception:
tc = {}
elif not isinstance(tc, dict):
tc = {}
tc['script_runtime_state'] = state
cur.execute(
"UPDATE qd_strategies_trading SET trading_config = %s WHERE id = %s",
(json.dumps(tc, ensure_ascii=False), strategy_id),
)
db.commit()
cur.close()
except Exception as e:
logger.warning(f"Persist script runtime state failed: {e}")
def _script_orders_to_execution_signals(
self,
ctx: StrategyScriptContext,
trade_direction: str,
bar_close: float,
closed_ts: pd.Timestamp,
trading_config: Dict[str, Any],
) -> List[Dict[str, Any]]:
td = str(trade_direction or 'both').lower()
if td not in ('long', 'short', 'both'):
td = 'both'
default_ratio = self._script_default_position_ratio(trading_config)
try:
ts_i = int(closed_ts.timestamp())
except Exception:
ts_i = int(time.time())
out: List[Dict[str, Any]] = []
trig = float(bar_close or 0)
for order in list(ctx._orders or []):
action = str(order.get('action') or '').lower()
try:
order_price = float(order.get('price') or bar_close or 0)
except Exception:
order_price = trig
raw_amt = order.get('amount')
pos_ratio = default_ratio
if raw_amt is not None:
try:
v = float(raw_amt)
if v > 0:
pos_ratio = v
except Exception:
pass
if action == 'close':
if ctx.position > 0:
out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
elif ctx.position < 0:
out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
continue
if action == 'buy':
if ctx.position < 0:
out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
if td in ('long', 'both'):
if ctx.position == 0:
out.append({'type': 'open_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.open_position('long', order_price or trig, pos_ratio)
else:
out.append({'type': 'add_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.add_position(order_price or trig, pos_ratio)
continue
if action == 'sell':
if ctx.position > 0:
out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
if td in ('short', 'both'):
if ctx.position == 0:
out.append({'type': 'open_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.open_position('short', order_price or trig, pos_ratio)
else:
out.append({'type': 'add_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.add_position(order_price or trig, pos_ratio)
return out
def _script_evaluate_new_closed_bar(
self,
df: pd.DataFrame,
ctx: StrategyScriptContext,
on_bar,
trade_direction: str,
last_closed_ts: Optional[pd.Timestamp],
strategy_id: int,
symbol: str,
trading_config: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], Optional[pd.Timestamp]]:
if df is None or len(df) < 2:
return [], last_closed_ts
closed_ts = df.index[-2]
try:
if last_closed_ts is not None and closed_ts <= last_closed_ts:
return [], last_closed_ts
except Exception:
pass
df_exec = self._df_to_script_exec_df(df)
ctx._bars_df = df_exec
pos = len(df) - 2
ctx.current_index = int(pos)
row = df_exec.iloc[pos]
self._hydrate_script_ctx_from_positions(ctx, strategy_id, symbol)
ctx._orders = []
bar = ScriptBar(
open=float(row.get('open') or 0),
high=float(row.get('high') or 0),
low=float(row.get('low') or 0),
close=float(row.get('close') or 0),
volume=float(row.get('volume') or 0),
timestamp=row.get('time'),
)
try:
on_bar(ctx, bar)
except Exception as e:
logger.error(f"Strategy {strategy_id} script on_bar error: {e}")
logger.error(traceback.format_exc())
return [], last_closed_ts
bar_close = float(row.get('close') or 0)
pending = self._script_orders_to_execution_signals(ctx, trade_direction, bar_close, closed_ts, trading_config)
self._persist_script_runtime_state(strategy_id, closed_ts, ctx._params)
logger.info(f"Strategy {strategy_id} script closed bar {closed_ts} -> {len(pending)} signal(s)")
return pending, closed_ts
def _run_strategy_loop(self, strategy_id: int):
"""
@@ -484,13 +703,15 @@ class TradingExecutor:
logger.error(f"Strategy {strategy_id} not found")
return
if strategy['strategy_type'] != 'IndicatorStrategy':
logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {strategy['strategy_type']}")
stype = strategy.get('strategy_type') or ''
if stype not in ('IndicatorStrategy', 'ScriptStrategy'):
logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {stype}")
return
is_script = stype == 'ScriptStrategy'
# Initialize policy state
trading_config = strategy['trading_config']
indicator_config = strategy['indicator_config']
indicator_config = strategy.get('indicator_config') or {}
ai_model_config = strategy.get('ai_model_config') or {}
execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower()
if execution_mode not in ['signal', 'live']:
@@ -567,47 +788,78 @@ class TradingExecutor:
if isinstance(initial_capital_val, (list, tuple)):
initial_capital_val = initial_capital_val[0] if initial_capital_val else 1000
initial_capital = float(initial_capital_val)
except:
except Exception:
logger.warning(f"Strategy {strategy_id} invalid initial_capital format, reset to 1000: {strategy.get('initial_capital')}")
initial_capital = 1000.0
# Equity is automatically calculated and updated the first time a position is updated
# Get indicator code
indicator_id = indicator_config.get('indicator_id')
indicator_code = indicator_config.get('indicator_code', '')
# If the code is empty, try to get it from the database
if not indicator_code and indicator_id:
indicator_code = self._get_indicator_code_from_db(indicator_id)
if not indicator_code:
logger.error(f"Strategy {strategy_id} indicator_code is empty")
return
# Make sure indicator_code is a string (to handle JSON escaping issues)
if not isinstance(indicator_code, str):
indicator_code = str(indicator_code)
# Handle possible JSON escaping issues
if '\\n' in indicator_code and '\n' not in indicator_code:
indicator_id = None
indicator_code = ''
strategy_code = ''
on_init_script = None
on_bar_script = None
if is_script:
strategy_code = (strategy.get('strategy_code') or '').strip()
if not strategy_code:
logger.error(f"Strategy {strategy_id} strategy_code is empty")
return
if '\\n' in strategy_code and '\n' not in strategy_code:
try:
decoded = json.loads(f'"{strategy_code}"')
if isinstance(decoded, str):
strategy_code = decoded
except Exception:
strategy_code = (
strategy_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
)
try:
import json
decoded = json.loads(f'"{indicator_code}"')
if isinstance(decoded, str):
indicator_code = decoded
logger.info(f"Strategy {strategy_id} decoded escaped indicator_code")
on_init_script, on_bar_script = compile_strategy_script_handlers(strategy_code)
except Exception as e:
logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}")
indicator_code = (
indicator_code
.replace('\\n', '\n')
.replace('\\t', '\t')
.replace('\\r', '\r')
.replace('\\"', '"')
.replace("\\'", "'")
.replace('\\\\', '\\')
)
logger.error(f"Strategy {strategy_id} script compile failed: {e}")
logger.error(traceback.format_exc())
return
else:
indicator_config = strategy['indicator_config']
indicator_id = indicator_config.get('indicator_id')
indicator_code = indicator_config.get('indicator_code', '')
if not indicator_code and indicator_id:
indicator_code = self._get_indicator_code_from_db(indicator_id)
if not indicator_code:
logger.error(f"Strategy {strategy_id} indicator_code is empty")
return
if not isinstance(indicator_code, str):
indicator_code = str(indicator_code)
if '\\n' in indicator_code and '\n' not in indicator_code:
try:
decoded = json.loads(f'"{indicator_code}"')
if isinstance(decoded, str):
indicator_code = decoded
logger.info(f"Strategy {strategy_id} decoded escaped indicator_code")
except Exception as e:
logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}")
indicator_code = (
indicator_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
)
# Check if this is a cross-sectional strategy
cs_strategy_type = trading_config.get('cs_strategy_type', 'single')
if (not is_script) and cs_strategy_type == 'cross_sectional':
self._run_cross_sectional_strategy_loop(
strategy_id, strategy, trading_config, strategy['indicator_config'],
ai_model_config, execution_mode, notification_config,
strategy_name, market_category, market_type, leverage,
initial_capital, indicator_code, indicator_id
)
return
if is_script and cs_strategy_type == 'cross_sectional':
logger.error(f"Strategy {strategy_id} ScriptStrategy does not support cross_sectional mode")
return
# Initialize the exchange connection (no real connection is required in signal mode)
exchange = None
# ============================================
# Initialization phase: Obtain historical K-lines and calculate indicators
@@ -665,26 +917,50 @@ class TradingExecutor:
f"position={initial_position}, entry_price={initial_avg_entry_price}, highest={initial_highest}"
)
# Execute indicator code, get signals and trigger prices
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result is None:
logger.error(f"Strategy {strategy_id} indicator execution failed")
return
# Extract signals and trigger prices
pending_signals = indicator_result.get('pending_signals', []) # List of signals to be triggered
last_kline_time = indicator_result.get('last_kline_time', 0) # The time of the last K-line
script_ctx = None
last_script_closed_ts = None
if is_script:
script_ctx, last_script_closed_ts = self._init_script_strategy_context(
strategy_id, df, trading_config, initial_capital
)
if on_init_script:
self._hydrate_script_ctx_from_positions(script_ctx, strategy_id, symbol)
try:
on_init_script(script_ctx)
except Exception as e:
logger.error(f"Strategy {strategy_id} on_init error: {e}")
logger.error(traceback.format_exc())
pending_signals, last_script_closed_ts = self._script_evaluate_new_closed_bar(
df, script_ctx, on_bar_script, trade_direction,
last_script_closed_ts, strategy_id, symbol, trading_config,
)
try:
last_kline_time = int(df.index[-1].timestamp())
except Exception:
last_kline_time = int(time.time())
else:
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result is None:
logger.error(f"Strategy {strategy_id} indicator execution failed")
return
pending_signals = indicator_result.get('pending_signals', [])
last_kline_time = indicator_result.get('last_kline_time', 0)
logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}")
if pending_signals:
logger.info(f"Initial signals: {pending_signals}")
append_strategy_log(
strategy_id,
"info",
f"Live trading cycle ready {symbol} {timeframe}Signal to be processed {len(pending_signals or [])} strip",
)
# ============================================
# Main loop: unified tick cadence (default: 10s)
@@ -745,36 +1021,48 @@ class TradingExecutor:
if klines and len(klines) >= 2:
df = self._klines_to_dataframe(klines)
if len(df) > 0:
current_pos_list = self._get_current_positions(strategy_id, symbol)
initial_highest = 0.0
initial_position = 0
initial_avg_entry_price = 0.0
initial_position_count = 0
initial_last_add_price = 0.0
if current_pos_list:
pos = current_pos_list[0]
initial_highest = float(pos.get('highest_price', 0) or 0)
pos_side = pos.get('side', 'long')
initial_position = 1 if pos_side == 'long' else -1
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
initial_position_count = 1
initial_last_add_price = initial_avg_entry_price
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result:
pending_signals = indicator_result.get('pending_signals', [])
last_kline_time = indicator_result.get('last_kline_time', 0)
new_hp = indicator_result.get('new_highest_price', 0)
if is_script:
new_sig, last_script_closed_ts = self._script_evaluate_new_closed_bar(
df, script_ctx, on_bar_script, trade_direction,
last_script_closed_ts, strategy_id, symbol, trading_config,
)
pending_signals = new_sig
try:
last_kline_time = int(df.index[-1].timestamp())
except Exception:
last_kline_time = int(time.time())
last_kline_update_time = current_time
else:
current_pos_list = self._get_current_positions(strategy_id, symbol)
initial_highest = 0.0
initial_position = 0
initial_avg_entry_price = 0.0
initial_position_count = 0
initial_last_add_price = 0.0
if current_pos_list:
pos = current_pos_list[0]
initial_highest = float(pos.get('highest_price', 0) or 0)
pos_side = pos.get('side', 'long')
initial_position = 1 if pos_side == 'long' else -1
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
initial_position_count = 1
initial_last_add_price = initial_avg_entry_price
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result:
pending_signals = indicator_result.get('pending_signals', [])
last_kline_time = indicator_result.get('last_kline_time', 0)
new_hp = indicator_result.get('new_highest_price', 0)
last_kline_update_time = current_time
# Update highest_price (using latest close as an approximation of current_price)
if new_hp > 0 and current_pos_list:
@@ -788,9 +1076,9 @@ class TradingExecutor:
)
else:
# ============================================
# 3. Non-K-line update tick: update the last K-line with the current price and recalculate the indicator (unify the tick rhythm)
# 3. Non-K-line update tick: The script strategy is not recalculated here (only on_bar at the close of a new K-line).
# ============================================
if 'df' in locals() and df is not None and len(df) > 0:
if (not is_script) and 'df' in locals() and df is not None and len(df) > 0:
try:
realtime_df = df.copy()
realtime_df = self._update_dataframe_with_current_price(realtime_df, current_price, timeframe)
@@ -999,6 +1287,11 @@ class TradingExecutor:
)
if ok:
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
append_strategy_log(
strategy_id,
"signal",
f"已提交信号 {signal_type} 参考价 {float(execute_price or 0):.6f}",
)
# Notify portfolio positions linked to this symbol
try:
from app.services.portfolio_monitor import notify_strategy_signal_for_positions
@@ -1012,6 +1305,11 @@ class TradingExecutor:
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
else:
logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}")
append_strategy_log(
strategy_id,
"error",
f"信号未执行或拒单: {signal_type}",
)
# Update positions once per tick.
self._update_positions(strategy_id, symbol, current_price)
@@ -1020,17 +1318,37 @@ class TradingExecutor:
self._console_print(
f"[strategy:{strategy_id}] tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}"
)
try:
nowl = time.time()
lastl = float(self._strategy_ui_log_last_tick_ts.get(strategy_id) or 0.0)
if nowl - lastl >= 55.0:
self._strategy_ui_log_last_tick_ts[strategy_id] = nowl
append_strategy_log(
strategy_id,
"info",
f"tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}",
)
except Exception:
pass
except Exception as e:
logger.error(f"Strategy {strategy_id} loop error: {str(e)}")
logger.error(traceback.format_exc())
self._console_print(f"[strategy:{strategy_id}] loop error: {e}")
try:
append_strategy_log(strategy_id, "error", f"循环异常: {e}")
except Exception:
pass
time.sleep(5)
except Exception as e:
logger.error(f"Strategy {strategy_id} crashed: {str(e)}")
logger.error(traceback.format_exc())
self._console_print(f"[strategy:{strategy_id}] fatal error: {e}")
try:
append_strategy_log(strategy_id, "error", f"策略线程致命错误: {e}")
except Exception:
pass
finally:
# clean up
with self.lock:
@@ -1038,6 +1356,10 @@ class TradingExecutor:
del self.running_strategies[strategy_id]
self._console_print(f"[strategy:{strategy_id}] loop exited")
logger.info(f"Strategy {strategy_id} loop exited")
try:
append_strategy_log(strategy_id, "info", "策略执行循环已退出")
except Exception:
pass
def _sync_positions_with_exchange(self, strategy_id: int, exchange: Any, symbol: str, market_type: str):
"""
@@ -1056,7 +1378,7 @@ class TradingExecutor:
initial_capital, leverage, decide_interval,
execution_mode, notification_config,
indicator_config, exchange_config, trading_config, ai_model_config,
market_category
market_category, strategy_code
FROM qd_strategies_trading
WHERE id = %s
"""
@@ -1145,8 +1467,14 @@ class TradingExecutor:
market_type: str = None,
leverage: float = None,
strategy_id: int = None
) -> Optional[ccxt.Exchange]:
"""(Mock) Signal mode does not require a real exchange connection."""
) -> Any:
"""
Placeholder: No exchange SDK instance is created within the strategy thread.
Live orders are not processed through this method. Signals are written to pending orders via `_execute_exchange_order`,
and executed by the PendingOrderWorker using a direct-connect REST client under `app.services.live_trading`.
Candlestick charts/current prices are provided by data layers such as `KlineService` and `DataSourceFactory` (this layer may use ccxt to display market data, decoupled from order placement).
"""
return None
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]:
@@ -2435,14 +2763,13 @@ class TradingExecutor:
signal_ts: int = 0,
) -> Optional[Dict[str, Any]]:
"""
Convert a signal into a concrete pending order and enqueue it into DB.
将信号转为 pending_orders 队列记录本方法不直连交易所不使用 ccxt
A separate worker will poll `pending_orders` and dispatch:
- execution_mode='signal': dispatch notifications (no real trading).
- execution_mode='live': reserved for future live trading execution (not implemented).
PendingOrderWorker 轮询执行
- execution_mode='signal'仅通知/模拟路径
- execution_mode='live'通过 live_trading 包内的各交易所 REST 客户端下单 ccxt
Note: Order execution settings (order_mode, maker_wait_sec, maker_offset_bps) are now
configured via environment variables and not passed from strategy config.
行情/K 线不在此处拉取order_mode 等由环境变量配置
"""
try:
# Reference price at enqueue time: use current tick price if provided to avoid extra fetch.