Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-07 22:47:07 +08:00
parent baa3182eca
commit 8563e4ea53
116 changed files with 3189 additions and 356 deletions
@@ -1830,7 +1830,35 @@ IMPORTANT:
# 但要做“可用信息重加权”:当某些模块缺失(如新闻/宏观没取到),不要用0分去稀释整体强度,
# 而是重新归一化权重,让技术信号在缺失时仍可发挥主导作用。
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 一旦成功计算通常就存在,但这里也做一次保护
@@ -2057,9 +2085,9 @@ IMPORTANT:
def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float:
"""计算基本面评分 (-100 to +100)"""
if market != "USStock" or not fundamental:
return 0.0 # 非美股或无基本面数据,返回中性
if market not in ("USStock", "CNStock", "HKStock") or not fundamental:
return 50.0
score = 0.0
factors = 0
@@ -2142,7 +2170,9 @@ IMPORTANT:
# 归一化(如果有多个因素)
if factors > 0:
score = score / factors * 100 / 4 # 最大可能分数是4个因素各20分=80,归一化到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:
@@ -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 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
# ==================== 宏观数据 (复用全球金融板块) ====================
@@ -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. 更新市场数据(从所有主要分类获取)
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}")
# 去重(按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. 批量分析市场(一次性分析所有市场,由AI筛选机会)
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;">
@@ -977,12 +977,24 @@ 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 = ?,
execution_mode = ?,
notification_config = ?,
@@ -1000,6 +1012,9 @@ class StrategyService:
""",
(
name,
strategy_type,
strategy_mode,
strategy_code,
market_category,
execution_mode,
self._dump_json_or_encrypt(notification_config, encrypt=False),
@@ -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:
@@ -21,6 +21,7 @@ import numpy as np
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
@@ -52,6 +53,8 @@ class TradingExecutor:
self._signal_dedup = {} # type: Dict[int, Dict[str, float]]
self._signal_dedup_lock = threading.Lock()
self.kline_service = KlineService() # K线服务(带缓存)
# Throttle writes to qd_strategy_logs (heartbeat), per strategy_id -> monotonic time
self._strategy_ui_log_last_tick_ts = {} # type: Dict[int, float]
# 单实例线程上限,避免无限制创建线程导致 can't start new thread/OOM
self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64'))
@@ -428,6 +431,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:
@@ -466,6 +470,7 @@ 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:
@@ -933,6 +938,11 @@ class TradingExecutor:
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"实盘循环就绪 {symbol} {timeframe},待处理信号 {len(pending_signals or [])}",
)
# ============================================
# Main loop: unified tick cadence (default: 10s)
@@ -1258,6 +1268,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
@@ -1271,6 +1286,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)
@@ -1279,17 +1299,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:
# 清理
with self.lock:
@@ -1297,6 +1337,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):
"""