Merge branch 'main' of https://github.com/brokermr810/QuantDinger
This commit is contained in:
@@ -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;">
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user