This commit is contained in:
dienakdz
2026-04-08 09:12:41 +07:00
147 changed files with 4587 additions and 639 deletions
+1
View File
@@ -11,6 +11,7 @@ WORKDIR /app
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/*.sources 2>/dev/null || true \
&& apt-get update \
&& apt-get install -y --no-install-recommends --fix-missing \
ca-certificates \
curl \
# Build deps (needed for some pyproject-based packages like ed25519-blake2b)
build-essential \
@@ -17,6 +17,15 @@ class MetaAPIKeys(type):
from app.utils.config_loader import load_addon_config
val = load_addon_config().get('tiingo', {}).get('api_key')
return val if val else os.getenv('TIINGO_API_KEY', '')
@property
def TWELVE_DATA_API_KEY(cls):
env_val = os.getenv('TWELVE_DATA_API_KEY', '').strip()
if env_val:
return env_val
from app.utils.config_loader import load_addon_config
val = load_addon_config().get('twelve_data', {}).get('api_key')
return val if val else ''
@property
def OPENROUTER_API_KEY(cls):
@@ -0,0 +1,566 @@
"""
A-share / H-share chart K-lines — multi-tier fallback.
Priority order (when TWELVE_DATA_API_KEY is configured):
ALL timeframes → Twelve Data (paid, globally stable) → Tencent daily/weekly → yfinance → AkShare
Without API key:
Daily / Weekly → Tencent fqkline (fast, no key) → yfinance → AkShare
Minute / Hour → yfinance → AkShare (Eastmoney, fragile overseas)
Tencent ``fqkline`` only reliably supports day/week/month.
yfinance supports CN (.SS/.SZ) and HK (.HK) at all common intervals.
Twelve Data (https://twelvedata.com) supports XSHG/XSHE/XHKG at all intervals.
"""
from __future__ import annotations
import os
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import pandas as pd
import requests
from app.utils.logger import get_logger
logger = get_logger(__name__)
_MAX_ATTEMPTS = 3
_BACKOFF_BASE_SEC = 1.5
_BACKOFF_CAP_SEC = 12.0
_TRANSIENT_ERR_MARKERS = (
"remote end closed connection",
"connection aborted",
"connection reset",
"timed out",
"timeout",
"max retries exceeded",
"temporarily unavailable",
"broken pipe",
"eof occurred",
"remote disconnected",
"chunkedencodingerror",
"incompleteread",
"rate",
"too many requests",
"429",
)
def _is_transient(exc: BaseException) -> bool:
return any(m in str(exc).lower() for m in _TRANSIENT_ERR_MARKERS)
_CHART_TF_ALIASES = {
"1w": "1W",
"1d": "1D",
"1h": "1H",
"4h": "4H",
"d": "1D",
"day": "1D",
"w": "1W",
"week": "1W",
"wk": "1W",
"60m": "1H",
"240m": "4H",
"1day": "1D",
"1week": "1W",
}
def normalize_chart_timeframe(timeframe: str) -> str:
t = (timeframe or "1D").strip()
if not t:
return "1D"
key = t.lower()
if key in _CHART_TF_ALIASES:
return _CHART_TF_ALIASES[key]
return t
# ---------------------------------------------------------------------------
# AkShare code converters
# ---------------------------------------------------------------------------
def ak_a_code_from_tencent(tencent_code: str) -> str:
c = (tencent_code or "").strip().lower()
if len(c) >= 8 and c[:2] in ("sh", "sz"):
return c[2:]
return c
def ak_hk_code_from_tencent(tencent_code: str) -> str:
c = (tencent_code or "").strip().upper().replace(".HK", "")
if c.startswith("HK"):
num = c[2:]
else:
num = c
if num.isdigit():
return num.zfill(5)
return num
# ---------------------------------------------------------------------------
# Twelve Data (paid, globally reliable — https://twelvedata.com)
# ---------------------------------------------------------------------------
def _get_twelve_data_api_key() -> str:
try:
from app.utils.config_loader import load_addon_config
key = load_addon_config().get("twelve_data", {}).get("api_key", "")
if key:
return key
except Exception:
pass
return (os.getenv("TWELVE_DATA_API_KEY") or "").strip()
_TD_INTERVAL_MAP = {
"1m": "1min",
"5m": "5min",
"15m": "15min",
"30m": "30min",
"1H": "1h",
"4H": "4h",
"1D": "1day",
"1W": "1week",
}
def _td_symbol_and_exchange(tencent_code: str, is_hk: bool) -> tuple[str, str]:
"""Convert Tencent code to Twelve Data (symbol, exchange)."""
c = (tencent_code or "").strip().upper()
if is_hk:
num = c.replace("HK", "")
if num.isdigit():
num = str(int(num)).zfill(4)
return num, "XHKG"
digits = c.lstrip("SHSZ")
if c.startswith("SH") or digits.startswith("6"):
return digits, "XSHG"
return digits, "XSHE"
def fetch_twelvedata_klines(
*,
is_hk: bool,
tencent_code: str,
timeframe: str,
limit: int,
before_time: Optional[int],
) -> List[Dict[str, Any]]:
"""Fetch K-lines from Twelve Data REST API. Requires TWELVE_DATA_API_KEY."""
api_key = _get_twelve_data_api_key()
if not api_key:
return []
interval = _TD_INTERVAL_MAP.get(timeframe)
if not interval:
return []
symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk)
params: Dict[str, Any] = {
"symbol": symbol,
"exchange": exchange,
"interval": interval,
"outputsize": min(int(limit), 5000),
"apikey": api_key,
"format": "JSON",
"dp": "4",
}
if before_time:
end_dt = datetime.fromtimestamp(int(before_time))
params["end_date"] = end_dt.strftime("%Y-%m-%d %H:%M:%S")
url = "https://api.twelvedata.com/time_series"
for attempt in range(_MAX_ATTEMPTS):
try:
resp = requests.get(url, params=params, timeout=20)
data = resp.json()
break
except Exception as e:
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
logger.debug(
"TwelveData transient error %s/%s tf=%s (attempt %s/%s): %s",
symbol, exchange, timeframe, attempt + 1, _MAX_ATTEMPTS, e,
)
time.sleep(delay)
continue
logger.warning("TwelveData request failed %s/%s tf=%s: %s", symbol, exchange, timeframe, e)
return []
else:
return []
if data.get("status") != "ok" or "values" not in data:
code = data.get("code", "")
msg = data.get("message", str(data))
if code == 429 or "API credits" in msg or "minute limit" in msg:
logger.warning("TwelveData rate limit for %s/%s: %s", symbol, exchange, msg)
else:
logger.warning("TwelveData error %s/%s tf=%s: %s", symbol, exchange, timeframe, msg)
return []
out: List[Dict[str, Any]] = []
for v in data["values"]:
try:
dt_str = v.get("datetime", "")
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
ts = int(datetime.strptime(dt_str, fmt).timestamp())
break
except ValueError:
continue
else:
continue
o = float(v["open"])
h = float(v["high"])
low = float(v["low"])
c = float(v["close"])
vol = float(v.get("volume") or 0)
if o == 0 and c == 0:
continue
out.append({
"time": ts,
"open": round(o, 4),
"high": round(h, 4),
"low": round(low, 4),
"close": round(c, 4),
"volume": round(vol, 2),
})
except Exception:
continue
out.sort(key=lambda x: x["time"])
logger.debug("TwelveData returned %d bars for %s/%s tf=%s", len(out), symbol, exchange, timeframe)
return out
# ---------------------------------------------------------------------------
# yfinance helpers (globally accessible — Yahoo CDN)
# ---------------------------------------------------------------------------
def yf_symbol_from_tencent(tencent_code: str, is_hk: bool) -> str:
"""Convert Tencent-style code (SH600519 / SZ000001 / HK00700) to yfinance ticker."""
c = (tencent_code or "").strip().upper()
if is_hk:
num = c.replace("HK", "")
if num.isdigit():
return str(int(num)).zfill(4) + ".HK"
return num + ".HK"
if c.startswith("SH"):
return c[2:] + ".SS"
if c.startswith("SZ"):
return c[2:] + ".SZ"
digits = c.lstrip("SHSZ")
if digits.startswith("6"):
return digits + ".SS"
return digits + ".SZ"
_YF_INTERVAL_MAP = {
"1m": "1m",
"5m": "5m",
"15m": "15m",
"30m": "30m",
"1H": "1h",
"4H": "1h",
"1D": "1d",
"1W": "1wk",
}
_YF_DAYS_MAP = {
"1m": lambda lim: min(7, max(2, (lim // 240) + 2)),
"5m": lambda lim: min(60, max(3, (lim // 48) + 3)),
"15m": lambda lim: min(60, max(3, (lim // 16) + 3)),
"30m": lambda lim: min(60, max(5, (lim // 8) + 5)),
"1H": lambda lim: min(730, max(8, (lim // 4) + 8)),
"4H": lambda lim: min(730, max(20, lim + 10)),
"1D": lambda lim: min(3650, lim + 10),
"1W": lambda lim: min(3650, lim * 7 + 30),
}
def _bars_from_yfinance_df(df: Any) -> List[Dict[str, Any]]:
"""Convert a yfinance DataFrame (with DatetimeIndex or Date/Datetime column) to bar dicts."""
if df is None or getattr(df, "empty", True):
return []
df = df.reset_index()
time_col = None
for candidate in ("Datetime", "Date", "index"):
if candidate in df.columns:
time_col = candidate
break
if time_col is None:
return []
out: List[Dict[str, Any]] = []
for _, row in df.iterrows():
try:
tv = row[time_col]
if hasattr(tv, "timestamp"):
ts = int(tv.timestamp())
else:
continue
o, h, low, c, v = (
float(row["Open"]),
float(row["High"]),
float(row["Low"]),
float(row["Close"]),
float(row["Volume"]),
)
if o == 0 and c == 0:
continue
out.append({
"time": ts,
"open": round(o, 4),
"high": round(h, 4),
"low": round(low, 4),
"close": round(c, 4),
"volume": round(v, 2),
})
except Exception:
continue
out.sort(key=lambda x: x["time"])
return out
def fetch_yfinance_klines(
*,
is_hk: bool,
tencent_code: str,
timeframe: str,
limit: int,
before_time: Optional[int],
) -> List[Dict[str, Any]]:
"""Fetch K-lines via yfinance for CN/HK stocks. Globally accessible, no API key needed."""
try:
import yfinance as yf
except ImportError:
logger.debug("yfinance not installed; skipping yfinance K-lines")
return []
interval = _YF_INTERVAL_MAP.get(timeframe)
if not interval:
return []
yf_sym = yf_symbol_from_tencent(tencent_code, is_hk)
effective_limit = limit * 4 if timeframe == "4H" else limit
days_func = _YF_DAYS_MAP.get(timeframe, lambda x: x + 10)
days = days_func(effective_limit)
end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now()
start = end - timedelta(days=days)
df: Any = None
for attempt in range(_MAX_ATTEMPTS):
try:
ticker = yf.Ticker(yf_sym)
df = ticker.history(
start=start.strftime("%Y-%m-%d"),
end=(end + timedelta(days=1)).strftime("%Y-%m-%d"),
interval=interval,
)
break
except Exception as e:
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
logger.debug(
"yfinance transient error %s tf=%s (attempt %s/%s), retry in %.1fs: %s",
yf_sym, timeframe, attempt + 1, _MAX_ATTEMPTS, delay, e,
)
time.sleep(delay)
continue
logger.warning("yfinance K-line failed %s tf=%s: %s", yf_sym, timeframe, e)
return []
bars = _bars_from_yfinance_df(df)
if timeframe == "4H" and bars:
bars = _merge_every_n_sorted_bars(bars, 4)
logger.debug("yfinance returned %d bars for %s tf=%s", len(bars), yf_sym, timeframe)
return bars
# ---------------------------------------------------------------------------
# AkShare helpers (Eastmoney — unreliable from overseas, used as last resort)
# ---------------------------------------------------------------------------
def _minute_period_str(timeframe: str) -> Optional[str]:
return {"1m": "1", "5m": "5", "15m": "15", "30m": "30", "1H": "60", "4H": "60"}.get(timeframe)
def _min_bar_window(timeframe: str, limit: int, before_time: Optional[int]) -> tuple[str, str]:
_ = (timeframe, limit)
end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now()
start = end - timedelta(days=16)
fmt = "%Y-%m-%d %H:%M:%S"
return start.strftime(fmt), end.strftime(fmt)
def _bars_from_ak_min_df(df: Any) -> List[Dict[str, Any]]:
if df is None or getattr(df, "empty", True):
return []
cols = [str(x) for x in df.columns]
time_c = "时间" if "时间" in cols else (cols[0] if len(cols) > 5 else None)
if not time_c:
return []
def _pick(name_zh: str, idx: int) -> str:
return name_zh if name_zh in cols else (cols[idx] if len(cols) > idx else "")
c_open = _pick("开盘", 1)
c_close = _pick("收盘", 2)
c_high = _pick("最高", 3)
c_low = _pick("最低", 4)
c_vol = _pick("成交量", 5)
if not all((c_open, c_close, c_high, c_low, c_vol)):
return []
out: List[Dict[str, Any]] = []
for _, row in df.iterrows():
try:
t = pd.Timestamp(row[time_c])
ts = int(t.timestamp())
o, c, h, low, v = float(row[c_open]), float(row[c_close]), float(row[c_high]), float(row[c_low]), float(row[c_vol])
out.append({
"time": ts,
"open": round(o, 4),
"high": round(h, 4),
"low": round(low, 4),
"close": round(c, 4),
"volume": round(v, 2),
})
except Exception:
continue
out.sort(key=lambda x: x["time"])
return out
def _merge_every_n_sorted_bars(bars: List[Dict[str, Any]], n: int) -> List[Dict[str, Any]]:
if n <= 1 or len(bars) < n:
return bars
out: List[Dict[str, Any]] = []
i = 0
while i + n <= len(bars):
chunk = bars[i : i + n]
out.append({
"time": chunk[0]["time"],
"open": chunk[0]["open"],
"high": max(b["high"] for b in chunk),
"low": min(b["low"] for b in chunk),
"close": chunk[-1]["close"],
"volume": round(sum(b["volume"] for b in chunk), 2),
})
i += n
return out
def fetch_akshare_minute_klines(
*,
is_hk: bool,
tencent_code: str,
timeframe: str,
limit: int,
before_time: Optional[int],
) -> List[Dict[str, Any]]:
p = _minute_period_str(timeframe)
if p is None:
return []
try:
import akshare as ak # type: ignore
except ImportError:
logger.debug("akshare not installed; skipping AkShare minute K-lines")
return []
sym = ak_hk_code_from_tencent(tencent_code) if is_hk else ak_a_code_from_tencent(tencent_code)
sd, ed = _min_bar_window(timeframe, limit, before_time)
adj = "" if p == "1" else "qfq"
df: Any = None
for attempt in range(_MAX_ATTEMPTS):
try:
if is_hk:
df = ak.stock_hk_hist_min_em(symbol=sym, period=p, adjust=adj, start_date=sd, end_date=ed)
else:
df = ak.stock_zh_a_hist_min_em(symbol=sym, start_date=sd, end_date=ed, period=p, adjust=adj)
break
except Exception as e:
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
logger.debug(
"AkShare minute transient error %s tf=%s sym=%s (attempt %s/%s): %s",
tencent_code, timeframe, sym, attempt + 1, _MAX_ATTEMPTS, e,
)
time.sleep(delay)
continue
logger.warning("AkShare minute K-line failed %s tf=%s sym=%s: %s", tencent_code, timeframe, sym, e)
return []
bars = _bars_from_ak_min_df(df)
if timeframe == "4H" and bars:
bars = _merge_every_n_sorted_bars(bars, 4)
return bars
def fetch_akshare_weekly_klines(
*,
is_hk: bool,
tencent_code: str,
limit: int,
before_time: Optional[int],
) -> List[Dict[str, Any]]:
try:
import akshare as ak # type: ignore
except ImportError:
return []
sym = ak_hk_code_from_tencent(tencent_code) if is_hk else ak_a_code_from_tencent(tencent_code)
end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now()
start = end - timedelta(days=max(int(limit or 300), 1) * 14 + 400)
start_s = start.strftime("%Y%m%d")
end_s = end.strftime("%Y%m%d")
df: Any = None
for attempt in range(_MAX_ATTEMPTS):
try:
if is_hk:
df = ak.stock_hk_hist(symbol=sym, period="weekly", start_date=start_s, end_date=end_s, adjust="qfq")
else:
df = ak.stock_zh_a_hist(symbol=sym, period="weekly", start_date=start_s, end_date=end_s, adjust="qfq")
break
except Exception as e:
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
logger.debug(
"AkShare weekly transient error sym=%s (attempt %s/%s): %s",
sym, attempt + 1, _MAX_ATTEMPTS, e,
)
time.sleep(delay)
continue
logger.warning("AkShare weekly K-line failed sym=%s: %s", sym, e)
return []
if df is None or getattr(df, "empty", True) or "日期" not in df.columns:
return []
out: List[Dict[str, Any]] = []
for _, row in df.iterrows():
try:
t = pd.Timestamp(row["日期"])
ts = int(t.timestamp())
o, c, h, low = float(row["开盘"]), float(row["收盘"]), float(row["最高"]), float(row["最低"])
v = float(row["成交量"])
out.append({
"time": ts,
"open": round(o, 4),
"high": round(h, 4),
"low": round(low, 4),
"close": round(c, 4),
"volume": round(v, 2),
})
except Exception:
continue
out.sort(key=lambda x: x["time"])
return out
@@ -0,0 +1,292 @@
"""
A-share / HK share fundamentals — multi-tier fallback.
Priority (when TWELVE_DATA_API_KEY configured):
Twelve Data /statistics + /profile → AkShare (Eastmoney, fragile overseas)
Without API key:
AkShare only (may fail from overseas servers)
Keys are aligned with MarketDataCollector expectations (pe_ratio, pb_ratio, etc.).
"""
from __future__ import annotations
import math
import time
from typing import Any, Dict, Optional
import requests
from app.data_sources.asia_stock_kline import (
_get_twelve_data_api_key,
_td_symbol_and_exchange,
ak_a_code_from_tencent,
ak_hk_code_from_tencent,
)
from app.utils.logger import get_logger
logger = get_logger(__name__)
_TD_TIMEOUT = 15
_TD_MAX_ATTEMPTS = 2
_TD_BACKOFF_SEC = 2.0
def _float_clean(x: Any) -> Optional[float]:
if x is None or x == "":
return None
try:
v = float(x)
if math.isnan(v) or math.isinf(v):
return None
return v
except (TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# Twelve Data fundamentals (globally stable, paid)
# ---------------------------------------------------------------------------
def _td_request(endpoint: str, symbol: str, exchange: str) -> Optional[Dict[str, Any]]:
"""Generic Twelve Data GET with retry."""
api_key = _get_twelve_data_api_key()
if not api_key:
return None
url = f"https://api.twelvedata.com{endpoint}"
params = {"symbol": symbol, "exchange": exchange, "apikey": api_key}
for attempt in range(_TD_MAX_ATTEMPTS):
try:
resp = requests.get(url, params=params, timeout=_TD_TIMEOUT)
data = resp.json()
if data.get("status") == "error":
code = data.get("code", "")
msg = (data.get("message") or "")[:120]
if code == 429 or "API credits" in msg or "minute limit" in msg:
logger.warning("TwelveData rate limit on %s %s/%s: %s", endpoint, symbol, exchange, msg)
else:
logger.debug("TwelveData %s error %s/%s: %s", endpoint, symbol, exchange, msg)
return None
return data
except Exception as e:
if attempt + 1 < _TD_MAX_ATTEMPTS:
time.sleep(_TD_BACKOFF_SEC)
continue
logger.warning("TwelveData %s request failed %s/%s: %s", endpoint, symbol, exchange, e)
return None
def fetch_twelvedata_fundamental(tencent_code: str, is_hk: bool) -> Dict[str, Any]:
"""Fetch PE/PB/PS/PEG/ROE/margin/market_cap/52w from Twelve Data /statistics."""
symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk)
data = _td_request("/statistics", symbol, exchange)
if not data or "statistics" not in data:
return {}
stats = data["statistics"]
result: Dict[str, Any] = {"source": "twelvedata"}
vm = stats.get("valuations_metrics") or {}
result["market_cap"] = _float_clean(vm.get("market_capitalization"))
result["pe_ratio"] = _float_clean(vm.get("trailing_pe"))
result["forward_pe"] = _float_clean(vm.get("forward_pe"))
result["pb_ratio"] = _float_clean(vm.get("price_to_book_mrq"))
result["ps_ratio"] = _float_clean(vm.get("price_to_sales_ttm"))
result["peg"] = _float_clean(vm.get("peg_ratio"))
result["enterprise_value"] = _float_clean(vm.get("enterprise_value"))
fin = stats.get("financials") or {}
result["profit_margin"] = _float_clean(fin.get("profit_margin"))
result["gross_margin"] = _float_clean(fin.get("gross_margin"))
result["operating_margin"] = _float_clean(fin.get("operating_margin"))
result["roe"] = _float_clean(fin.get("return_on_equity_ttm"))
result["roa"] = _float_clean(fin.get("return_on_assets_ttm"))
ss = stats.get("stock_statistics") or {}
result["total_shares"] = _float_clean(ss.get("shares_outstanding"))
result["float_shares"] = _float_clean(ss.get("float_shares"))
sp = stats.get("stock_price_summary") or {}
result["52w_high"] = _float_clean(sp.get("fifty_two_week_high"))
result["52w_low"] = _float_clean(sp.get("fifty_two_week_low"))
result["beta"] = _float_clean(sp.get("beta"))
div = stats.get("dividends_and_splits") or {}
result["dividend_yield"] = _float_clean(div.get("trailing_annual_dividend_yield"))
result["dividend_rate"] = _float_clean(div.get("trailing_annual_dividend_rate"))
non_null = sum(1 for v in result.values() if v is not None and v != "twelvedata")
logger.debug("TwelveData /statistics %s/%s: %d non-null fields", symbol, exchange, non_null)
return result
def fetch_twelvedata_profile(tencent_code: str, is_hk: bool) -> Dict[str, Any]:
"""Fetch company info from Twelve Data /profile."""
symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk)
data = _td_request("/profile", symbol, exchange)
if not data or not data.get("name"):
return {}
out: Dict[str, Any] = {"source": "twelvedata"}
for src, dst in (
("name", "name"),
("industry", "industry"),
("sector", "sector"),
("website", "website"),
("description", "description"),
("employees", "employees"),
("name", "full_name"),
):
v = data.get(src)
if v is not None and str(v).strip():
out[dst] = str(v).strip() if isinstance(v, str) else v
country = data.get("country")
if country:
out["country"] = country
logger.debug("TwelveData /profile %s/%s: name=%s industry=%s", symbol, exchange, out.get("name"), out.get("industry"))
return out
# ---------------------------------------------------------------------------
# AkShare fundamentals (Eastmoney — fragile overseas, used as fallback)
# ---------------------------------------------------------------------------
def _eastmoney_a_em_symbol(tencent_code: str) -> str:
c = ak_a_code_from_tencent(tencent_code)
c = (c or "").zfill(6)
if c.startswith("6"):
return "SH" + c
return "SZ" + c
def _individual_info_map(symbol_6: str) -> Dict[str, Any]:
out: Dict[str, Any] = {}
try:
import akshare as ak # type: ignore
df = ak.stock_individual_info_em(symbol=symbol_6)
except Exception as e:
logger.debug("stock_individual_info_em failed %s: %s", symbol_6, e)
return out
if df is None or getattr(df, "empty", True) or len(df.columns) < 2:
return out
kcol, vcol = df.columns[0], df.columns[1]
for _, row in df.iterrows():
try:
k = str(row[kcol]).strip()
if k:
out[k] = row[vcol]
except Exception:
continue
return out
def fetch_cn_fundamental_akshare(tencent_code: str) -> Dict[str, Any]:
"""PE/PB/PS, market cap, ROE proxy, EPS for A-share (best-effort)."""
sym6 = ak_a_code_from_tencent(tencent_code)
if not sym6:
return {}
result: Dict[str, Any] = {"source": "akshare_em"}
info = _individual_info_map(sym6)
if info:
result["market_cap"] = _float_clean(info.get("总市值"))
result["float_market_cap"] = _float_clean(info.get("流通市值"))
ind = info.get("行业")
if ind is not None and str(ind).strip():
result["industry"] = str(ind).strip()
result["total_shares"] = _float_clean(info.get("总股本"))
result["float_shares"] = _float_clean(info.get("流通股"))
em_sym = _eastmoney_a_em_symbol(tencent_code)
try:
import akshare as ak # type: ignore
vdf = ak.stock_zh_valuation_comparison_em(symbol=em_sym)
except Exception as e:
logger.debug("stock_zh_valuation_comparison_em failed %s: %s", em_sym, e)
vdf = None
if vdf is not None and not vdf.empty and "代码" in vdf.columns:
hit = vdf[vdf["代码"].astype(str).str.replace(".0", "", regex=False).str.zfill(6) == sym6.zfill(6)]
if not hit.empty:
r = hit.iloc[0]
pe = _float_clean(r.get("市盈率-TTM"))
if pe is not None:
result["pe_ratio"] = pe
pb = _float_clean(r.get("市净率-MRQ"))
if pb is not None:
result["pb_ratio"] = pb
ps = _float_clean(r.get("市销率-TTM"))
if ps is not None:
result["ps_ratio"] = ps
peg = _float_clean(r.get("PEG"))
if peg is not None:
result["peg"] = peg
return result
def fetch_hk_fundamental_akshare(tencent_code: str) -> Dict[str, Any]:
hk5 = ak_hk_code_from_tencent(tencent_code)
if not hk5:
return {}
result: Dict[str, Any] = {"source": "akshare_em"}
try:
import akshare as ak # type: ignore
df = ak.stock_hk_financial_indicator_em(symbol=hk5)
except Exception as e:
logger.debug("stock_hk_financial_indicator_em failed %s: %s", hk5, e)
return result
if df is None or df.empty:
return result
r = df.iloc[0]
result["pe_ratio"] = _float_clean(r.get("市盈率"))
result["pb_ratio"] = _float_clean(r.get("市净率"))
result["eps"] = _float_clean(r.get("基本每股收益(元)"))
result["roe"] = _float_clean(r.get("股东权益回报率(%)"))
result["profit_margin"] = _float_clean(r.get("销售净利率(%)"))
mcap = _float_clean(r.get("总市值(港元)")) or _float_clean(r.get("港股市值(港元)"))
if mcap is not None:
result["market_cap"] = mcap
result["dividend_yield"] = _float_clean(r.get("股息率TTM(%)"))
return result
def fetch_cn_company_extras(tencent_code: str) -> Dict[str, Any]:
sym6 = ak_a_code_from_tencent(tencent_code)
if not sym6:
return {}
info = _individual_info_map(sym6)
out: Dict[str, Any] = {}
if info.get("行业"):
out["industry"] = str(info["行业"]).strip()
if info.get("上市时间"):
out["ipo_date"] = str(info["上市时间"]).strip()
return out
def fetch_hk_company_extras(tencent_code: str) -> Dict[str, Any]:
hk5 = ak_hk_code_from_tencent(tencent_code)
if not hk5:
return {}
out: Dict[str, Any] = {}
try:
import akshare as ak # type: ignore
df = ak.stock_hk_company_profile_em(symbol=hk5)
except Exception as e:
logger.debug("stock_hk_company_profile_em failed %s: %s", hk5, e)
return out
if df is None or df.empty:
return out
r = df.iloc[0]
for key, col in (
("industry", "所属行业"),
("ipo_date", "公司成立日期"),
("website", "公司网址"),
("full_name", "公司名称"),
):
v = r.get(col)
if v is not None and str(v).strip():
out[key] = str(v).strip()
return out
@@ -0,0 +1,99 @@
"""
中国A股数据源 — 多层 fallback
有 TWELVE_DATA_API_KEY:
所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare
无 API Key:
分钟/小时 → yfinance → AkShare
日/周线 → 腾讯 fqkline → yfinance → AkShare
"""
from __future__ import annotations
from typing import Dict, List, Any, Optional
from app.data_sources.base import BaseDataSource
from app.data_sources.tencent import normalize_cn_code, fetch_quote, parse_quote_to_ticker, fetch_kline, tencent_kline_rows_to_dicts
from app.data_sources.asia_stock_kline import (
normalize_chart_timeframe,
fetch_twelvedata_klines,
fetch_yfinance_klines,
fetch_akshare_minute_klines,
fetch_akshare_weekly_klines,
)
from app.utils.logger import get_logger
logger = get_logger(__name__)
class CNStockDataSource(BaseDataSource):
"""A股数据源(TwelveData + Tencent + yfinance + AkShare"""
name = "CNStock/multi-source"
def get_ticker(self, symbol: str) -> Dict[str, Any]:
code = normalize_cn_code(symbol)
parts = fetch_quote(code)
if not parts:
return {"last": 0, "symbol": code}
t = parse_quote_to_ticker(parts)
return {
"last": t.get("last", 0),
"change": t.get("change", 0),
"changePercent": t.get("changePercent", 0),
"high": t.get("high", 0),
"low": t.get("low", 0),
"open": t.get("open", 0),
"previousClose": t.get("previousClose", 0),
"name": t.get("name", ""),
"symbol": code,
}
def get_kline(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int] = None,
) -> List[Dict[str, Any]]:
code = normalize_cn_code(symbol)
tf = normalize_chart_timeframe(timeframe)
lim = max(int(limit or 300), 1)
# Tier 1: Twelve Data (paid, most reliable)
rows = fetch_twelvedata_klines(
is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
)
if rows:
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
# Tier 2: Tencent for daily/weekly (fast, free)
if tf in ("1D", "1W"):
tf_map = {"1D": "day", "1W": "week"}
period = tf_map.get(tf, "day")
raw_rows = fetch_kline(code, period=period, count=lim, adj="qfq")
out = tencent_kline_rows_to_dicts(raw_rows)
if out:
return self.filter_and_limit(out, limit=lim, before_time=before_time)
# Tier 3: yfinance (works when Yahoo not rate-limited)
rows = fetch_yfinance_klines(
is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
)
if rows:
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
# Tier 4: AkShare (fragile overseas, last resort)
if tf in ("1m", "5m", "15m", "30m", "1H", "4H"):
rows = fetch_akshare_minute_klines(
is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
)
elif tf == "1W":
rows = fetch_akshare_weekly_klines(
is_hk=False, tencent_code=code, limit=lim, before_time=before_time
)
else:
rows = []
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
@@ -52,6 +52,12 @@ class DataSourceFactory:
if market == 'Crypto':
from app.data_sources.crypto import CryptoDataSource
return CryptoDataSource()
elif market == 'CNStock':
from app.data_sources.cn_stock import CNStockDataSource
return CNStockDataSource()
elif market == 'HKStock':
from app.data_sources.hk_stock import HKStockDataSource
return HKStockDataSource()
elif market == 'USStock':
from app.data_sources.us_stock import USStockDataSource
return USStockDataSource()
@@ -0,0 +1,99 @@
"""
港股/H股数据源 — 多层 fallback
有 TWELVE_DATA_API_KEY:
所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare
无 API Key:
分钟/小时 → yfinance → AkShare
日/周线 → 腾讯 fqkline → yfinance → AkShare
"""
from __future__ import annotations
from typing import Dict, List, Any, Optional
from app.data_sources.base import BaseDataSource
from app.data_sources.tencent import normalize_hk_code, fetch_quote, parse_quote_to_ticker, fetch_kline, tencent_kline_rows_to_dicts
from app.data_sources.asia_stock_kline import (
normalize_chart_timeframe,
fetch_twelvedata_klines,
fetch_yfinance_klines,
fetch_akshare_minute_klines,
fetch_akshare_weekly_klines,
)
from app.utils.logger import get_logger
logger = get_logger(__name__)
class HKStockDataSource(BaseDataSource):
"""港股/H股数据源(TwelveData + Tencent + yfinance + AkShare"""
name = "HKStock/multi-source"
def get_ticker(self, symbol: str) -> Dict[str, Any]:
code = normalize_hk_code(symbol)
parts = fetch_quote(code)
if not parts:
return {"last": 0, "symbol": code}
t = parse_quote_to_ticker(parts)
return {
"last": t.get("last", 0),
"change": t.get("change", 0),
"changePercent": t.get("changePercent", 0),
"high": t.get("high", 0),
"low": t.get("low", 0),
"open": t.get("open", 0),
"previousClose": t.get("previousClose", 0),
"name": t.get("name", ""),
"symbol": code,
}
def get_kline(
self,
symbol: str,
timeframe: str,
limit: int,
before_time: Optional[int] = None,
) -> List[Dict[str, Any]]:
code = normalize_hk_code(symbol)
tf = normalize_chart_timeframe(timeframe)
lim = max(int(limit or 300), 1)
# Tier 1: Twelve Data (paid, most reliable)
rows = fetch_twelvedata_klines(
is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
)
if rows:
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
# Tier 2: Tencent for daily/weekly (fast, free)
if tf in ("1D", "1W"):
tf_map = {"1D": "day", "1W": "week"}
period = tf_map.get(tf, "day")
raw_rows = fetch_kline(code, period=period, count=lim, adj="qfq")
out = tencent_kline_rows_to_dicts(raw_rows)
if out:
return self.filter_and_limit(out, limit=lim, before_time=before_time)
# Tier 3: yfinance (works when Yahoo not rate-limited)
rows = fetch_yfinance_klines(
is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
)
if rows:
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
# Tier 4: AkShare (fragile overseas, last resort)
if tf in ("1m", "5m", "15m", "30m", "1H", "4H"):
rows = fetch_akshare_minute_klines(
is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
)
elif tf == "1W":
rows = fetch_akshare_weekly_klines(
is_hk=True, tencent_code=code, limit=lim, before_time=before_time
)
else:
rows = []
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
@@ -57,13 +57,11 @@ class PolymarketDataSource:
markets = self._fetch_markets_from_api(category, limit * 2)
all_markets.extend(markets)
else:
# If no category is specified or "all" is specified, get data from multiple categories
categories_to_fetch = ["crypto", "politics", "economics", "sports"]
for cat in categories_to_fetch:
markets = self._fetch_markets_from_api(cat, limit // len(categories_to_fetch) + 10)
all_markets.extend(markets)
# Retrieve all events (without specifying a category to avoid duplicate requests)
markets = self._fetch_from_gamma_api(category=None, limit=100)
all_markets.extend(markets)
# Deduplication (by market_id)
# 去重(按market_id
seen = set()
unique_markets = []
for market in all_markets:
@@ -0,0 +1,238 @@
"""
Tencent market data helpers (no API key).
Provides:
- Quote: https://qt.gtimg.cn/q=sh600519 / sz000001 / hk00700
- Kline: https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=CODE,PERIOD,,,COUNT,ADJ
This is used as a stable alternative when Yahoo/yfinance gets rate-limited.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import requests
from app.data_sources.rate_limiter import get_request_headers, retry_with_backoff, get_tencent_limiter
from app.utils.logger import get_logger
logger = get_logger(__name__)
def normalize_cn_code(symbol: str) -> str:
"""
Normalize A-share symbol to Tencent code: sh600519 / sz000001.
Accepts:
- 600519 / 600519.SH / 600519.SS
- 000001 / 000001.SZ
"""
s = (symbol or "").strip().upper()
if not s:
return s
if s.endswith(".SH"):
s = s[:-3]
return f"SH{s}"
if s.endswith(".SS"):
s = s[:-3]
return f"SH{s}"
if s.endswith(".SZ"):
s = s[:-3]
return f"SZ{s}"
if s.isdigit() and len(s) == 6:
return ("SH" + s) if s.startswith("6") else ("SZ" + s)
return s
def normalize_hk_code(symbol: str) -> str:
"""
Normalize HK stock symbol to Tencent code: hk00700 (5 digits).
Accepts:
- 700 / 0700 / 00700.HK / 0700.HK
"""
s = (symbol or "").strip().upper()
if not s:
return s
if s.endswith(".HK"):
s = s[:-3]
if s.isdigit():
return "HK" + s.zfill(5)
# If user already passed HKxxxxx
if s.startswith("HK") and s[2:].isdigit():
return "HK" + s[2:].zfill(5)
return s
def _lower_code(code: str) -> str:
return (code or "").strip().lower()
@retry_with_backoff(max_attempts=3, base_delay=1.2, max_delay=8.0, exceptions=(Exception,))
def fetch_quote(code: str, timeout: int = 8) -> Optional[List[str]]:
"""
Returns the raw '~' split array from qt.gtimg.cn, or None.
"""
c = _lower_code(code)
if not c:
return None
limiter = get_tencent_limiter()
limiter.wait()
url = f"https://qt.gtimg.cn/q={c}"
resp = requests.get(url, headers=get_request_headers(referer="https://qt.gtimg.cn/"), timeout=timeout)
# Tencent quote is often GBK encoded
try:
resp.encoding = "gbk"
except Exception:
pass
text = (resp.text or "").strip()
if not text or "~" not in text:
return None
# Format: v_sh600519="1~NAME~CODE~LAST~PREV~OPEN~..."
try:
start = text.index('="') + 2
end = text.rindex('"')
payload = text[start:end]
except Exception:
return None
parts = payload.split("~")
return parts if len(parts) > 5 else None
def parse_quote_to_ticker(parts: List[str]) -> Dict[str, Any]:
"""
Best-effort conversion to a unified ticker dict.
"""
def _f(i: int, default: float = 0.0) -> float:
try:
v = parts[i]
if v is None or v == "":
return default
return float(v)
except Exception:
return default
name = (parts[1] or "").strip() if len(parts) > 1 else ""
symbol = (parts[2] or "").strip() if len(parts) > 2 else ""
last_ = _f(3, 0.0)
prev = _f(4, 0.0)
open_ = _f(5, 0.0)
change = round(last_ - prev, 4) if prev else 0.0
change_pct = round(change / prev * 100, 2) if prev else 0.0
# Indices are not fully consistent across markets; keep conservative.
high = _f(33, last_) if len(parts) > 33 else last_
low = _f(34, last_) if len(parts) > 34 else last_
return {
"symbol": symbol,
"name": name,
"last": last_,
"change": change,
"changePercent": change_pct,
"high": high,
"low": low,
"open": open_ or last_,
"previousClose": prev,
"raw": parts,
}
def parse_tencent_kline_time(ds: str) -> Optional[int]:
"""Parse Tencent fqkline first column to Unix seconds (local parse, matches prior chart behavior)."""
raw = str(ds or "").strip()
if not raw:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y/%m/%d"):
try:
return int(datetime.strptime(raw, fmt).timestamp())
except ValueError:
continue
try:
ts = int(float(raw))
if ts > 10**12:
ts = int(ts / 1000)
return ts
except Exception:
return None
def tencent_kline_rows_to_dicts(rows: List[Any]) -> List[Dict[str, Any]]:
"""Convert raw fqkline rows to chart dicts; ignores corporate-action tail objects on HK rows."""
out: List[Dict[str, Any]] = []
for r in rows:
if not isinstance(r, (list, tuple)) or len(r) < 6:
continue
ts = parse_tencent_kline_time(r[0])
if ts is None:
continue
try:
o, c, h, low, vol = float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5])
except (TypeError, ValueError):
continue
out.append(
{
"time": ts,
"open": round(o, 4),
"high": round(h, 4),
"low": round(low, 4),
"close": round(c, 4),
"volume": round(vol, 2),
}
)
return out
@retry_with_backoff(max_attempts=3, base_delay=1.2, max_delay=8.0, exceptions=(Exception,))
def fetch_kline(code: str, period: str, count: int = 300, adj: str = "qfq", timeout: int = 10) -> List[List[str]]:
"""
Fetch kline arrays from Tencent.
period examples:
- day, week, month (supported by Tencent fqkline)
Note: Minute periods (m1/m5/…) return **bad params** on this endpoint; use AkShare in ``asia_stock_kline``.
"""
c = _lower_code(code)
if not c:
return []
limiter = get_tencent_limiter()
limiter.wait()
url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
params = {"param": f"{c},{period},,,{int(count)},{adj}"}
resp = requests.get(url, headers=get_request_headers(referer="https://gu.qq.com/"), params=params, timeout=timeout)
data = resp.json() if resp.text else {}
if not isinstance(data, dict) or int(data.get("code", 0)) != 0:
return []
root = (data.get("data") or {}).get(c)
if not isinstance(root, dict):
return []
# Data key variants:
# - A-share: qfqday / qfqweek / qfqm1 ...
# - HK: day / week / m1 ...
candidates = []
if adj:
candidates.append(f"{adj}{period}")
candidates.append(period)
for key in candidates:
arr = root.get(key)
if isinstance(arr, list) and arr:
return arr
# Fallback: search any key that endswith period and is a list
for k, v in root.items():
if isinstance(v, list) and v and str(k).lower().endswith(str(period).lower()):
return v
return []
@@ -88,7 +88,11 @@ class USStockDataSource(BaseDataSource):
'previousClose': quote.get('pc', 0) # Yesterday's closing price
}
except Exception as e:
logger.warning(f"Finnhub quote failed for {symbol}: {e}")
msg = str(e).lower()
if "403" in str(e) or "don't have access" in msg or "no access" in msg:
logger.debug(f"Finnhub quote skipped (no access): {symbol}: {e}")
else:
logger.warning(f"Finnhub quote failed for {symbol}: {e}")
# Downgrade to use yfinance
try:
@@ -260,7 +264,12 @@ class USStockDataSource(BaseDataSource):
))
# logger.info(f"Finnhub returns {len(klines)} pieces of data")
except Exception as e:
logger.error(f"Finnhub fetch failed: {e}")
msg = str(e).lower()
# Free tier / plan: 403 "You don't have access to this resource" is common; avoid ERROR spam.
if "403" in str(e) or "don't have access" in msg or "no access" in msg:
logger.debug(f"Finnhub candles skipped (no access): {symbol}: {e}")
else:
logger.warning(f"Finnhub fetch failed: {e}")
return klines
+166 -3
View File
@@ -1674,6 +1674,77 @@ def _fetch_stock_opportunity_prices() -> List[Dict[str, Any]]:
return []
def _fetch_local_stock_opportunity_prices(market: str, limit: int = 15) -> List[Dict[str, Any]]:
"""
Fetch CN/HK stock prices for opportunity scanning.
For overseas servers we prefer the project's Tencent-based ticker path, which is
typically more reachable/stable than some Eastmoney/AkShare endpoints.
"""
m = str(market or "").strip()
if m not in ("CNStock", "HKStock"):
return []
fallback_symbols = {
"CNStock": [
{"symbol": "600519", "name": "贵州茅台"},
{"symbol": "000001", "name": "平安银行"},
{"symbol": "300750", "name": "宁德时代"},
{"symbol": "601318", "name": "中国平安"},
{"symbol": "600036", "name": "招商银行"},
{"symbol": "002594", "name": "比亚迪"},
{"symbol": "600276", "name": "恒瑞医药"},
{"symbol": "601899", "name": "紫金矿业"},
],
"HKStock": [
{"symbol": "00700", "name": "腾讯控股"},
{"symbol": "09988", "name": "阿里巴巴-W"},
{"symbol": "03690", "name": "美团-W"},
{"symbol": "01810", "name": "小米集团-W"},
{"symbol": "01299", "name": "友邦保险"},
{"symbol": "00939", "name": "建设银行"},
{"symbol": "02318", "name": "中国平安"},
{"symbol": "09618", "name": "京东集团-SW"},
],
}
try:
from app.data.market_symbols_seed import get_hot_symbols
from app.data_sources import DataSourceFactory
from app.services.symbol_name import resolve_symbol_name
symbols = get_hot_symbols(m, limit=max(int(limit or 15), 1)) or fallback_symbols.get(m, [])
source = DataSourceFactory.get_source(m)
result = []
for item in symbols[: max(int(limit or 15), 1)]:
try:
symbol = str(item.get("symbol") or "").strip()
if not symbol:
continue
ticker = source.get_ticker(symbol) or {}
last = _safe_float(ticker.get("last") or ticker.get("close") or ticker.get("price"))
if last <= 0:
continue
change_pct = ticker.get("changePercent")
if change_pct is None:
prev_close = _safe_float(ticker.get("previousClose"))
change_pct = ((last - prev_close) / prev_close * 100.0) if prev_close > 0 else 0.0
result.append({
"symbol": symbol,
"name": (item.get("name") or resolve_symbol_name(m, symbol) or symbol).strip(),
"price": round(last, 4),
"change": round(_safe_float(change_pct), 2),
"market": m,
})
except Exception as e:
logger.debug(f"Failed to fetch {m} opportunity price {item.get('symbol')}: {e}")
return result
except Exception as e:
logger.error(f"Failed to fetch {m} opportunity prices: {e}")
return []
def _analyze_opportunities_crypto(opportunities: list):
"""Scan crypto market for trading opportunities."""
crypto_data = _get_cached("crypto_prices")
@@ -1800,6 +1871,75 @@ def _analyze_opportunities_stocks(opportunities: list):
})
def _analyze_opportunities_local_stocks(opportunities: list, market: str):
"""Scan CN/HK stocks for trading opportunities."""
m = str(market or "").strip()
if m not in ("CNStock", "HKStock"):
return
cache_key = "cn_stock_opportunity_prices" if m == "CNStock" else "hk_stock_opportunity_prices"
stock_data = _get_cached(cache_key)
if not stock_data:
stock_data = _fetch_local_stock_opportunity_prices(m)
if stock_data:
_set_cached(cache_key, stock_data, 3600)
if not stock_data:
logger.warning(f"_analyze_opportunities_local_stocks: No {m} data available")
return
logger.debug(f"_analyze_opportunities_local_stocks: Analyzing {len(stock_data)} {m} stocks")
strong_th = 7.0 if m == "CNStock" else 6.0
medium_th = 3.0 if m == "CNStock" else 2.5
market_cn = "A股" if m == "CNStock" else "港股"
for stock in stock_data:
change = _safe_float(stock.get("change", 0))
symbol = stock.get("symbol", "")
name = stock.get("name", "")
price = _safe_float(stock.get("price", 0))
signal = None
strength = "medium"
reason = ""
impact = "neutral"
if change > strong_th:
signal = "overbought"
strength = "strong"
reason = f"{market_cn}日涨幅{change:.1f}%,短期涨幅较大,注意回调风险"
impact = "bearish"
elif change > medium_th:
signal = "bullish_momentum"
strength = "medium"
reason = f"{market_cn}日涨幅{change:.1f}%,上涨动能较强"
impact = "bullish"
elif change < -strong_th:
signal = "oversold"
strength = "strong"
reason = f"{market_cn}日跌幅{abs(change):.1f}%,可能超卖反弹"
impact = "bullish"
elif change < -medium_th:
signal = "bearish_momentum"
strength = "medium"
reason = f"{market_cn}日跌幅{abs(change):.1f}%,下跌趋势明显"
impact = "bearish"
if signal:
opportunities.append({
"symbol": symbol,
"name": name,
"price": price,
"change_24h": change,
"signal": signal,
"strength": strength,
"reason": reason,
"impact": impact,
"market": m,
"timestamp": int(time.time())
})
def _analyze_opportunities_forex(opportunities: list):
"""Scan forex pairs for trading opportunities."""
forex_data = _get_cached("forex_pairs")
@@ -1915,7 +2055,7 @@ def _analyze_opportunities_polymarket(opportunities: list):
@login_required
def trading_opportunities():
"""
Scan for trading opportunities across Crypto, US Stocks, and Forex.
Scan for trading opportunities across Crypto, US Stocks, CN/HK Stocks, and Forex.
Note: Prediction Markets are excluded as they have their own dedicated page.
Cached for 1 hour. Pass ?force=true to skip cache.
"""
@@ -1945,7 +2085,23 @@ def trading_opportunities():
except Exception as e:
logger.error(f"Failed to analyze stock opportunities: {e}", exc_info=True)
# 3) Forex
# 3) CN Stocks
try:
_analyze_opportunities_local_stocks(opportunities, "CNStock")
cn_count = len([o for o in opportunities if o.get("market") == "CNStock"])
logger.info(f"Trading opportunities: found {cn_count} CN stock opportunities")
except Exception as e:
logger.error(f"Failed to analyze CN stock opportunities: {e}", exc_info=True)
# 4) HK Stocks
try:
_analyze_opportunities_local_stocks(opportunities, "HKStock")
hk_count = len([o for o in opportunities if o.get("market") == "HKStock"])
logger.info(f"Trading opportunities: found {hk_count} HK stock opportunities")
except Exception as e:
logger.error(f"Failed to analyze HK stock opportunities: {e}", exc_info=True)
# 5) Forex
try:
_analyze_opportunities_forex(opportunities)
forex_count = len([o for o in opportunities if o.get("market") == "Forex"])
@@ -1959,7 +2115,14 @@ def trading_opportunities():
# Sort by absolute change descending
opportunities.sort(key=lambda x: abs(x.get("change_24h", 0)), reverse=True)
logger.info(f"Trading opportunities: total {len(opportunities)} opportunities found (Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})")
logger.info(
f"Trading opportunities: total {len(opportunities)} opportunities found "
f"(Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, "
f"USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, "
f"CNStock: {len([o for o in opportunities if o.get('market') == 'CNStock'])}, "
f"HKStock: {len([o for o in opportunities if o.get('market') == 'HKStock'])}, "
f"Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})"
)
_set_cached("trading_opportunities", opportunities, 3600)
+2 -1
View File
@@ -83,7 +83,8 @@ def get_public_config():
@market_bp.route('/types', methods=['GET'])
def get_market_types():
"""Return supported market types for the add-watchlist modal."""
desired_order = ['USStock', 'Crypto', 'Forex', 'Futures']
# Keep a stable UX order; add CN/HK stocks near US stocks.
desired_order = ['USStock', 'CNStock', 'HKStock', 'Crypto', 'Forex', 'Futures']
order_rank = {v: i for i, v in enumerate(desired_order)}
def _normalize_item(x):
+602 -78
View File
@@ -29,11 +29,69 @@ from app.utils.credential_crypto import decrypt_credential_blob
logger = get_logger(__name__)
import re as _re
_FRIENDLY_ERROR_PATTERNS = [
# Insufficient balance / margin
(_re.compile(r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", _re.IGNORECASE),
"quickTrade.errorHints.insufficientBalance"),
# Invalid size / quantity
(_re.compile(r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", _re.IGNORECASE),
"quickTrade.errorHints.invalidSize"),
# Invalid price
(_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE),
"quickTrade.errorHints.invalidPrice"),
# Rate limit
(_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE),
"quickTrade.errorHints.rateLimit"),
# API key / permission
(_re.compile(r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", _re.IGNORECASE),
"quickTrade.errorHints.authError"),
# Position / reduce-only conflict
(_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE),
"quickTrade.errorHints.positionConflict"),
# Network / timeout
(_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE),
"quickTrade.errorHints.networkError"),
# Exchange maintenance
(_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE),
"quickTrade.errorHints.exchangeMaintenance"),
]
def _parse_trade_error_hint(error_str: str) -> str:
"""Return a i18n key hint for common exchange trading errors, or empty string."""
s = str(error_str or "")
for pattern, hint_key in _FRIENDLY_ERROR_PATTERNS:
if pattern.search(s):
return hint_key
return ""
quick_trade_bp = Blueprint('quick_trade', __name__)
# ────────── helpers ──────────
def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool:
"""Match UI symbol (e.g. ETH/USDT) with exchange-native ids (e.g. ETH_USDT, ETH-USDT-SWAP)."""
def norm(x: str) -> str:
return (x or "").strip().upper().replace("/", "").replace("-", "").replace("_", "")
a, b = norm(user_symbol), norm(position_symbol)
if not a or not b:
return False
if a == b:
return True
for suf in ("SWAP", "PERPETUAL", "PERP"):
if b.endswith(suf) and a == b[: -len(suf)]:
return True
if a.endswith(suf) and b == a[: -len(suf)]:
return True
# Substring fallback for less standard ids (min length avoids ETH vs ETHW false positives)
return (len(a) >= 6 and a in b) or (len(b) >= 6 and b in a)
def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0) -> float:
"""
Convert USDT amount to base asset quantity for all exchanges.
@@ -116,6 +174,56 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
current_price = float(data.get("price") or 0)
except Exception:
pass
# Bybit v5 — same host as trading API; tickers/orderbook are public
from app.services.live_trading.bybit import BybitClient
if current_price <= 0 and isinstance(client, BybitClient):
try:
import requests
from app.services.live_trading.symbols import to_bybit_symbol
bu = (getattr(client, "base_url", "") or "").rstrip("/")
bsym = to_bybit_symbol(symbol).upper()
cat = "spot" if (market_type or "").strip().lower() == "spot" else "linear"
if bu and bsym:
tr = requests.get(
f"{bu}/v5/market/tickers",
params={"category": cat, "symbol": bsym},
timeout=8,
)
if tr.status_code == 200:
jd = tr.json() if tr.text else {}
lst = (((jd.get("result") or {}).get("list")) or []) if isinstance(jd, dict) else []
if lst and isinstance(lst[0], dict):
t0 = lst[0]
current_price = float(
str(
t0.get("lastPrice")
or t0.get("markPrice")
or t0.get("indexPrice")
or 0
).replace(",", "")
or 0
)
if current_price <= 0:
obr = requests.get(
f"{bu}/v5/market/orderbook",
params={"category": cat, "symbol": bsym, "limit": 25},
timeout=8,
)
if obr.status_code == 200:
od = obr.json() if obr.text else {}
res = (od.get("result") or {}) if isinstance(od, dict) else {}
bids = res.get("b") or []
asks = res.get("a") or []
bp = float(str(bids[0][0]).replace(",", "")) if bids and bids[0] else 0.0
ap = float(str(asks[0][0]).replace(",", "")) if asks and asks[0] else 0.0
if bp > 0 and ap > 0:
current_price = (bp + ap) / 2.0
else:
current_price = bp or ap
except Exception:
pass
# Other exchanges - can be added as needed
# For exchanges without price API, we'll use a fallback
@@ -273,6 +381,13 @@ def place_order():
tp_price = float(body.get("tp_price") or 0)
sl_price = float(body.get("sl_price") or 0)
source = str(body.get("source") or "manual").strip()
margin_mode = str(body.get("margin_mode") or body.get("marginMode") or "").strip().lower()
if margin_mode in ("cross", "crossed"):
margin_mode = "cross"
elif margin_mode in ("iso", "isolated"):
margin_mode = "isolated"
else:
margin_mode = ""
# ---- validation ----
if not credential_id:
@@ -286,28 +401,35 @@ def place_order():
if order_type == "limit" and price <= 0:
return jsonify({"code": 0, "msg": "price required for limit orders"}), 400
# ---- Auto-determine market_type from leverage ----
# leverage = 1 -> spot, leverage > 1 -> swap
if not market_type:
market_type = "spot" if leverage == 1 else "swap"
# ---- market_type: leverage 1 => spot API, else perpetual (swap) ----
if market_type in ("futures", "future", "perp", "perpetual"):
market_type = "swap"
# Override only when user did not explicitly choose market_type.
if leverage > 1:
market_type = "swap"
elif leverage == 1 and not str(body.get("market_type") or "").strip():
else:
market_type = "spot"
# ---- build exchange client ----
exchange_config = _build_exchange_config(credential_id, user_id, {
"market_type": market_type,
})
cfg_overrides: Dict[str, Any] = {"market_type": market_type}
if margin_mode in ("cross", "isolated"):
cfg_overrides["margin_mode"] = margin_mode
cfg_overrides["td_mode"] = margin_mode
exchange_config = _build_exchange_config(credential_id, user_id, cfg_overrides)
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
if not exchange_id:
return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400
client = _create_client(exchange_config, market_type=market_type)
# Binance USDT-M: sync isolated/cross margin mode (best-effort; may fail if open orders exist)
if market_type != "spot" and margin_mode in ("cross", "isolated"):
try:
from app.services.live_trading.binance import BinanceFuturesClient
if isinstance(client, BinanceFuturesClient):
client.set_margin_type(symbol=symbol, margin_mode=margin_mode)
except Exception as me:
logger.warning(f"Binance set_margin_type failed (non-fatal): {me}")
# ---- Convert USDT amount to base asset quantity ----
# Quick trade always accepts USDT amount, convert to base qty for all exchanges
# For limit orders, use the provided price; for market orders, fetch current price
@@ -336,7 +458,12 @@ def place_order():
elif isinstance(client, GateUsdtFuturesClient):
from app.services.live_trading.symbols import to_gate_currency_pair
contract = to_gate_currency_pair(symbol)
client.set_leverage(contract=contract, leverage=leverage)
if not client.set_leverage(contract=contract, leverage=leverage):
logger.warning(
"Gate set_leverage failed (contract=%s lev=%s); order may use exchange default leverage",
contract,
leverage,
)
# Most other exchanges use symbol
else:
# Try common parameter names
@@ -458,7 +585,12 @@ def place_order():
except Exception:
pass
return jsonify({"code": 0, "msg": str(e)}), 500
err_str = str(e)
hint = _parse_trade_error_hint(err_str)
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
if hint:
resp["error_hint"] = hint
return jsonify(resp), 500
def _market_order_kwargs(client, symbol, amount, side, market_type, client_order_id):
@@ -543,7 +675,16 @@ def get_balance():
raw = client.get_account()
balance_data = _parse_balance(raw, exchange_id, market_type)
elif hasattr(client, "get_accounts"):
raw = client.get_accounts()
from app.services.live_trading.bitget import BitgetMixClient
if isinstance(client, BitgetMixClient):
pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
raw = client.get_accounts(product_type=pt)
else:
raw = client.get_accounts()
balance_data = _parse_balance(raw, exchange_id, market_type)
elif (exchange_id or "").lower() == "bitget" and market_type == "spot" and hasattr(client, "get_assets"):
raw = client.get_assets()
balance_data = _parse_balance(raw, exchange_id, market_type)
except Exception as be:
logger.warning(f"Balance fetch failed: {be}")
@@ -558,9 +699,34 @@ def get_balance():
def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, Any]:
"""Best-effort parse balance from various exchange responses."""
result = {"available": 0, "total": 0, "currency": "USDT"}
ex0 = (exchange_id or "").strip().lower()
mt0 = (market_type or "").strip().lower()
def _num(x: Any) -> float:
try:
s = str(x).replace(",", "").strip()
if not s:
return 0.0
return float(s)
except Exception:
return 0.0
if not raw:
return result
try:
# Gate.io spot: GET /api/v4/spot/accounts returns a list
if isinstance(raw, list) and ex0 == "gate":
for item in raw:
if not isinstance(item, dict):
continue
if str(item.get("currency") or "").upper() == "USDT":
av = _num(item.get("available") or item.get("available_balance"))
lk = _num(item.get("locked") or item.get("freeze") or item.get("locked_amount"))
result["available"] = av
result["total"] = av + lk
return result
return result
if isinstance(raw, dict):
# Binance futures
if "availableBalance" in raw:
@@ -575,22 +741,77 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
result["total"] = float(b.get("free") or 0) + float(b.get("locked") or 0)
return result
return result
ex = (exchange_id or "").lower()
# Gate.io USDT perpetual: GET /api/v4/futures/usdt/accounts — flat object (values often strings)
if ex == "gate" and mt0 != "spot":
if any(k in raw for k in ("available", "total", "cross_available", "cross_margin_balance")):
av = raw.get("available") or raw.get("available_balance") or raw.get("cross_available")
tot = (
raw.get("total")
or raw.get("total_balance")
or raw.get("cross_margin_balance")
or raw.get("equity")
)
result["available"] = _num(av)
result["total"] = _num(tot) if tot is not None and str(tot).strip() != "" else result["available"]
if result["total"] <= 0 < result["available"]:
result["total"] = result["available"]
return result
# Bitget mix: { code, data: [ { marginCoin, available, accountEquity, ... } ] }
# Must run before OKX — both use data as a list; OKX fallback would zero Bitget.
if ex == "bitget" and (market_type or "").lower() != "spot":
bg_data = raw.get("data")
if isinstance(bg_data, list) and bg_data:
row = None
for item in bg_data:
if isinstance(item, dict) and str(item.get("marginCoin") or "").upper() == "USDT":
row = item
break
if row is None and isinstance(bg_data[0], dict):
row = bg_data[0]
if isinstance(row, dict):
av = (
row.get("available")
or row.get("availableBalance")
or row.get("crossedMaxAvailable")
or row.get("isolatedMaxAvailable")
or 0
)
eq = row.get("accountEquity") or row.get("usdtEquity") or row.get("equity") or av
result["available"] = float(av or 0)
result["total"] = float(eq or 0) if eq is not None else result["available"]
return result
# Bitget spot: GET /api/v2/spot/account/assets
if ex == "bitget" and (market_type or "").lower() == "spot":
bg_data = raw.get("data")
if isinstance(bg_data, list):
for b in bg_data:
if isinstance(b, dict) and str(b.get("coin") or "").upper() == "USDT":
avail = float(b.get("available") or 0)
frozen = float(b.get("frozen") or b.get("locked") or 0)
result["available"] = avail
result["total"] = avail + frozen
return result
return result
# OKX
data = raw.get("data")
if isinstance(data, list) and data:
first = data[0] if isinstance(data[0], dict) else {}
# Account balance
details = first.get("details", [])
if isinstance(details, list):
if isinstance(details, list) and details:
for d in details:
if str(d.get("ccy") or "").upper() == "USDT":
result["available"] = float(d.get("availBal") or d.get("availEq") or 0)
result["total"] = float(d.get("eq") or d.get("cashBal") or 0)
return result
# Fallback
result["available"] = float(first.get("availBal") or first.get("totalEq") or 0)
result["total"] = float(first.get("totalEq") or 0)
return result
# OKX-style single-account row (not Bitget — Bitget handled above)
if "availBal" in first or "availEq" in first or "totalEq" in first or "adjEq" in first:
result["available"] = float(
first.get("availBal") or first.get("availEq") or first.get("adjEq") or first.get("totalEq") or 0
)
result["total"] = float(first.get("totalEq") or first.get("adjEq") or 0)
return result
# Bybit
if "result" in raw:
res = raw["result"]
@@ -636,6 +857,154 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
return result
def _fetch_exchange_positions_raw(
client: Any,
exchange_config: Dict[str, Any],
*,
symbol: str,
market_type: str,
) -> Any:
"""
Fetch raw position payload for quick-trade / close-position.
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bitfinex),
or need extra args (Bitget ``product_type``, OKX ``inst_type``). Centralize here.
"""
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.bitget import BitgetMixClient
from app.services.live_trading.bybit import BybitClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.gate import GateUsdtFuturesClient
from app.services.live_trading.htx import HtxClient
from app.services.live_trading.kucoin import KucoinFuturesClient
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.symbols import (
to_bybit_symbol,
to_gate_currency_pair,
to_kucoin_futures_symbol,
to_okx_spot_inst_id,
to_okx_swap_inst_id,
)
mt = (market_type or "swap").strip().lower()
if isinstance(client, OkxClient):
if mt == "spot":
inst_id = to_okx_spot_inst_id(symbol)
inst_type = "SPOT"
else:
inst_id = to_okx_swap_inst_id(symbol)
inst_type = "SWAP"
return client.get_positions(inst_id=inst_id, inst_type=inst_type)
if isinstance(client, BinanceFuturesClient):
return client.get_positions(symbol=symbol)
if isinstance(client, BitgetMixClient):
pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
return client.get_positions(product_type=pt, symbol=symbol)
if isinstance(client, BybitClient):
# Bybit v5 requires symbol or settleCoin; query the contract directly.
raw = client.get_positions(symbol=symbol)
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
if not isinstance(lst, list):
return raw
sym_norm = to_bybit_symbol(symbol)
filtered = [p for p in lst if isinstance(p, dict) and str(p.get("symbol") or "").strip() == sym_norm]
if isinstance(raw, dict):
out = dict(raw)
res = dict((raw.get("result") or {}) if isinstance(raw.get("result"), dict) else {})
res["list"] = filtered
out["result"] = res
return out
return {"result": {"list": filtered}}
if isinstance(client, GateUsdtFuturesClient):
raw = client.get_positions()
items = raw if isinstance(raw, list) else []
c = to_gate_currency_pair(symbol)
logger.info("Gate positions: total=%d, target=%s, contracts=%s",
len(items), c,
[(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10])
filtered = [p for p in items if isinstance(p, dict) and str(p.get("contract") or "").strip() == c]
out = []
for p in filtered:
q = dict(p)
try:
ct_sz = float(q.get("size") or 0)
except Exception:
ct_sz = 0.0
if abs(ct_sz) > 1e-12:
base_amt = client.contracts_signed_to_base_qty(contract=c, contracts_signed=ct_sz)
if base_amt > 0:
q["positionAmt"] = base_amt
out.append(q)
logger.info("Gate filtered positions for %s: %d items, sizes=%s", c, len(out),
[(p.get("size"), p.get("positionAmt")) for p in out])
return out
if isinstance(client, KucoinFuturesClient):
raw = client.get_positions()
data = raw.get("data") if isinstance(raw, dict) else []
sym = to_kucoin_futures_symbol(symbol)
if not isinstance(data, list):
data = []
filtered = [p for p in data if isinstance(p, dict) and str(p.get("symbol") or "").strip() == sym]
if isinstance(raw, dict):
out = dict(raw)
out["data"] = filtered
return out
return {"data": filtered}
if isinstance(client, HtxClient):
raw = client.get_positions(symbol=symbol)
data = (raw.get("data") if isinstance(raw, dict) else None) or []
if not isinstance(data, list):
data = []
out_items = []
for p in data:
if not isinstance(p, dict):
continue
q = dict(p)
cc = str(q.get("contract_code") or "").strip()
if cc:
parts = cc.split("-", 1)
if len(parts) == 2:
q["symbol"] = f"{parts[0]}/{parts[1]}"
try:
vol = float(q.get("volume") or q.get("available") or 0)
except Exception:
vol = 0.0
if abs(vol) > 1e-12 and cc:
try:
info = client.get_contract_info(symbol=symbol or cc) or {}
cs = float(info.get("contract_size") or 1)
if cs <= 0:
cs = 1.0
q["positionAmt"] = abs(vol) * cs
except Exception:
pass
out_items.append(q)
logger.info("HTX positions for %s: %d items, sizes=%s", symbol, len(out_items),
[(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items])
return {"data": out_items}
if isinstance(client, DeepcoinClient):
return client.get_positions(symbol=symbol)
if hasattr(client, "get_positions"):
try:
return client.get_positions(symbol=symbol)
except TypeError:
return client.get_positions()
if hasattr(client, "get_position"):
return client.get_position(symbol=symbol)
return None
@quick_trade_bp.route('/position', methods=['GET'])
@login_required
def get_position():
@@ -658,25 +1027,10 @@ def get_position():
positions = []
try:
# OKX requires inst_id instead of symbol
from app.services.live_trading.okx import OkxClient
if isinstance(client, OkxClient):
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
if market_type == "spot":
inst_id = to_okx_spot_inst_id(symbol)
inst_type = "SPOT"
else:
inst_id = to_okx_swap_inst_id(symbol)
inst_type = "SWAP"
raw = client.get_positions(inst_id=inst_id, inst_type=inst_type)
positions = _parse_positions(raw)
logger.info(f"OKX positions query: inst_id={inst_id}, inst_type={inst_type}, found {len(positions)} positions")
elif hasattr(client, "get_positions"):
raw = client.get_positions(symbol=symbol)
positions = _parse_positions(raw)
elif hasattr(client, "get_position"):
raw = client.get_position(symbol=symbol)
positions = _parse_positions(raw)
raw = _fetch_exchange_positions_raw(
client, exchange_config, symbol=symbol, market_type=market_type
)
positions = _parse_positions(raw)
except Exception as pe:
logger.warning(f"Position fetch failed: {pe}")
logger.warning(traceback.format_exc())
@@ -698,33 +1052,78 @@ def _parse_positions(raw: Any) -> list:
if isinstance(raw, list):
items = raw
elif isinstance(raw, dict):
data = raw.get("data") or raw.get("result") or raw.get("positions") or []
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("list", []) if "list" in data else [data]
if isinstance(raw.get("raw"), list):
items = raw["raw"]
else:
data = raw.get("data") or raw.get("result") or raw.get("positions") or []
if isinstance(data, list):
items = data
elif isinstance(data, dict):
items = data.get("list", []) if "list" in data else [data]
else:
items = []
for item in items:
if not isinstance(item, dict):
continue
sym_raw = str(
item.get("symbol")
or item.get("instId")
or item.get("contract")
or item.get("contract_code")
or ""
).strip()
display_symbol = sym_raw
if sym_raw and "/" not in sym_raw:
for sep in ("_", "-"):
if sep in sym_raw:
parts = sym_raw.split(sep, 1)
if len(parts) == 2 and parts[0] and parts[1]:
display_symbol = f"{parts[0]}/{parts[1]}"
break
# For OKX, position size can be in different fields
# SWAP: posAmt, pos
# Binance futures: positionAmt
# SPOT: bal (balance), availBal (available balance)
size = float(item.get("posAmt") or item.get("pos") or item.get("size") or item.get("contracts") or
item.get("bal") or item.get("availBal") or item.get("volume") or 0)
size = float(
item.get("positionAmt")
or item.get("posAmt")
or item.get("pos")
or item.get("total")
or item.get("currentQty")
or item.get("available")
or item.get("size")
or item.get("contracts")
or item.get("bal")
or item.get("availBal")
or item.get("volume")
or item.get("current_qty")
or 0
)
if abs(size) < 1e-10:
continue
# For spot, side is always "long" (you own the asset)
# For swap, determine side from sign of size
# Binance hedge: positionSide LONG/SHORT with positive positionAmt; one-way: BOTH + signed amt
side = "long"
if size < 0:
psu = str(item.get("positionSide", "")).strip().upper()
if psu == "SHORT":
side = "short"
elif psu == "LONG":
side = "long"
elif item.get("posSide"):
# OKX may have posSide field: "long" or "short"
pos_side = str(item.get("posSide", "")).strip().lower()
if pos_side in ("long", "short"):
side = pos_side
elif str(item.get("holdSide") or "").strip().lower() == "short":
side = "short"
elif str(item.get("holdSide") or "").strip().lower() == "long":
side = "long"
elif str(item.get("side") or "").strip().lower() in ("sell", "s"):
side = "short"
elif str(item.get("side") or "").strip().lower() in ("buy", "b"):
side = "long"
elif size < 0:
side = "short"
elif item.get("direction"):
dir_side = str(item.get("direction") or "").strip().lower()
if dir_side in ("buy", "long"):
@@ -733,19 +1132,90 @@ def _parse_positions(raw: Any) -> list:
side = "short"
result.append({
"symbol": item.get("symbol") or item.get("instId") or "",
"symbol": display_symbol,
"side": side,
"size": abs(size),
"entry_price": float(item.get("entryPrice") or item.get("avgCost") or item.get("avgPx") or item.get("cost_open") or 0),
"unrealized_pnl": float(item.get("unRealizedProfit") or item.get("upl") or item.get("unrealisedPnl") or item.get("profit_unreal") or item.get("pnl") or 0),
"leverage": float(item.get("leverage") or item.get("lever") or 1),
"mark_price": float(item.get("markPrice") or item.get("markPx") or item.get("last_price") or item.get("last") or 0),
"entry_price": float(
item.get("entryPrice")
or item.get("entry_price")
or item.get("openPriceAvg")
or item.get("avgEntryPrice")
or item.get("avgPrice")
or item.get("avgCost")
or item.get("avgPx")
or item.get("cost_open")
or item.get("trade_avg_price")
or 0
),
"unrealized_pnl": float(
item.get("unRealizedProfit")
or item.get("unrealizedProfit")
or item.get("unrealizedPnl")
or item.get("unrealised_pnl")
or item.get("upl")
or item.get("unrealisedPnl")
or item.get("profit_unreal")
or item.get("pnl")
or 0
),
"leverage": float(item.get("leverage") or item.get("lever") or item.get("lever_rate") or item.get("cross_leverage_limit") or 1),
"mark_price": float(
item.get("markPrice")
or item.get("mark_price")
or item.get("markPx")
or item.get("last_price")
or item.get("last")
or item.get("indexPrice")
or 0
),
})
except Exception as e:
logger.warning(f"_parse_positions error: {e}")
return result
def _quick_trade_net_base_qty(
user_id: int,
credential_id: int,
symbol: str,
market_type: str,
position_side: str,
) -> float:
"""
Best-effort net base-asset qty from qd_quick_trades (filled buy sell for long, vice versa for short).
Used when user chooses to close only the portion accumulated via Quick Trade, not manual exchange orders.
Imperfect if the user also traded the same symbol elsewhere or records are incomplete.
"""
mt = (market_type or "swap").strip().lower()
ps = (position_side or "").strip().lower()
sym = str(symbol or "").strip()
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN side = 'buy' THEN filled_amount ELSE 0 END), 0) AS b,
COALESCE(SUM(CASE WHEN side = 'sell' THEN filled_amount ELSE 0 END), 0) AS s
FROM qd_quick_trades
WHERE user_id = %s AND credential_id = %s AND symbol = %s AND market_type = %s
AND status = 'filled' AND COALESCE(filled_amount, 0) > 0
""",
(int(user_id), int(credential_id), sym, mt),
)
row = cur.fetchone() or {}
cur.close()
buy_sum = float(row.get("b") or 0)
sell_sum = float(row.get("s") or 0)
if ps == "long":
net = buy_sum - sell_sum
elif ps == "short":
net = sell_sum - buy_sum
else:
net = 0.0
return max(0.0, float(net))
@quick_trade_bp.route('/close-position', methods=['POST'])
@login_required
def close_position():
@@ -757,6 +1227,8 @@ def close_position():
symbol (str) e.g. "BTC/USDT"
market_type (str) "swap" / "spot" (default: swap)
size (float) position size to close (optional, defaults to full position)
close_scope (str) "full" (default) or "system_tracked" (swap only: min(position, net from qd_quick_trades))
position_side (str) optional "long" / "short"; required when both directions exist for the same symbol
source (str) "ai_radar" / "ai_analysis" / "indicator" / "manual"
"""
try:
@@ -768,6 +1240,11 @@ def close_position():
market_type = str(body.get("market_type") or "swap").strip().lower()
close_size = float(body.get("size") or 0) # 0 means close full position
source = str(body.get("source") or "manual").strip()
close_scope_raw = str(body.get("close_scope") or body.get("closeScope") or "full").strip().lower()
if close_scope_raw in ("system", "system_tracked", "quick_trade", "app"):
close_scope = "system_tracked"
else:
close_scope = "full"
# ---- validation ----
if not credential_id:
@@ -791,49 +1268,89 @@ def close_position():
# ---- get current position ----
positions = []
try:
from app.services.live_trading.okx import OkxClient
if isinstance(client, OkxClient):
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
if market_type == "spot":
inst_id = to_okx_spot_inst_id(symbol)
else:
inst_id = to_okx_swap_inst_id(symbol)
raw = client.get_positions(inst_id=inst_id)
positions = _parse_positions(raw)
elif hasattr(client, "get_positions"):
raw = client.get_positions(symbol=symbol)
positions = _parse_positions(raw)
elif hasattr(client, "get_position"):
raw = client.get_position(symbol=symbol)
positions = _parse_positions(raw)
raw = _fetch_exchange_positions_raw(
client, exchange_config, symbol=symbol, market_type=market_type
)
positions = _parse_positions(raw)
except Exception as pe:
logger.warning(f"Position fetch failed: {pe}")
if not positions:
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
# Find matching position for this symbol
position = None
want_side = str(body.get("position_side") or body.get("close_side") or "").strip().lower()
if want_side not in ("", "long", "short"):
want_side = ""
matches: list = []
for pos in positions:
pos_symbol = pos.get("symbol", "").strip()
# Match by symbol (may need normalization)
if symbol.upper().replace("/", "") in pos_symbol.upper().replace("/", "").replace("-", ""):
position = pos
break
if not _symbols_match_quick_trade(symbol, pos_symbol):
continue
ps = str(pos.get("side") or "").strip().lower()
if want_side in ("long", "short"):
if ps == want_side:
matches.append(pos)
else:
matches.append(pos)
position = None
if len(matches) == 1:
position = matches[0]
elif len(matches) > 1:
if want_side in ("long", "short"):
position = matches[0]
else:
return jsonify(
{
"code": 0,
"msg": "该交易对同时存在多仓与空仓,请在请求中指定 position_side 为 long 或 short。",
}
), 400
if not position:
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
position_side = str(position.get("side") or "").strip().lower()
position_size = float(position.get("size") or 0)
if position_size <= 0:
return jsonify({"code": 0, "msg": "Position size is zero or invalid"}), 400
if close_scope == "system_tracked" and market_type != "swap":
return jsonify({"code": 0, "msg": "system_tracked close_scope is only supported for swap/perp"}), 400
tracked_net = 0.0
if close_scope == "system_tracked":
tracked_net = _quick_trade_net_base_qty(
user_id, credential_id, symbol, market_type, position_side=position_side
)
if tracked_net <= 0:
return jsonify(
{
"code": 0,
"msg": "No filled Quick Trade volume found for this symbol; use full close or check history.",
}
), 400
# Determine close size
actual_close_size = close_size if close_size > 0 else position_size
if close_size > 0:
actual_close_size = min(close_size, position_size)
elif close_scope == "system_tracked":
actual_close_size = min(tracked_net, position_size)
logger.info(
"close_position system_tracked: symbol=%s side=%s position=%s tracked_net=%s close=%s",
symbol,
position.get("side"),
position_size,
tracked_net,
actual_close_size,
)
else:
actual_close_size = position_size
if actual_close_size > position_size:
actual_close_size = position_size
if actual_close_size <= 0:
return jsonify({"code": 0, "msg": "Close size is zero"}), 400
# ---- determine signal type based on position side ----
if market_type == "spot":
@@ -919,6 +1436,8 @@ def close_position():
"avg_price": avg_fill,
"closed_size": actual_close_size,
"position_side": position_side,
"close_scope": close_scope,
"tracked_net_base": tracked_net if close_scope == "system_tracked" else None,
"status": "filled" if filled > 0 else "submitted",
},
})
@@ -926,7 +1445,12 @@ def close_position():
except Exception as e:
logger.error(f"close_position failed: {e}")
logger.error(traceback.format_exc())
return jsonify({"code": 0, "msg": str(e)}), 500
err_str = str(e)
hint = _parse_trade_error_hint(err_str)
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
if hint:
resp["error_hint"] = hint
return jsonify(resp), 500
@quick_trade_bp.route('/history', methods=['GET'])
@@ -399,6 +399,15 @@ CONFIG_SCHEMA = {
'link_text': 'settings.link.getToken',
'description': 'Tiingo API key for Forex/Metals data'
},
{
'key': 'TWELVE_DATA_API_KEY',
'label': 'Twelve Data API Key',
'type': 'password',
'required': False,
'link': 'https://twelvedata.com/apikey',
'link_text': 'settings.link.getApiKey',
'description': 'Twelve Data API key for CN/HK stock K-lines (free 800 credits/day)'
},
]
},
+128 -22
View File
@@ -4,6 +4,7 @@ Trading Strategy API Routes
from flask import Blueprint, request, jsonify, g
from datetime import datetime
import json
import re
import traceback
import time
@@ -14,6 +15,11 @@ from app.services.strategy_snapshot import StrategySnapshotResolver
from app import get_trading_executor
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
try:
from psycopg2.errors import UndefinedTable as PgUndefinedTable
except Exception: # pragma: no cover
PgUndefinedTable = None # type: ignore
from app.utils.auth import login_required
from app.data_sources import DataSourceFactory
@@ -661,6 +667,15 @@ def get_equity_curve():
(strategy_id,)
)
rows = cur.fetchall() or []
cur.execute(
"""
SELECT COALESCE(SUM(unrealized_pnl), 0) AS u
FROM qd_strategy_positions
WHERE strategy_id = ?
""",
(strategy_id,),
)
prow = cur.fetchone() or {}
cur.close()
equity = initial
@@ -677,7 +692,17 @@ def get_equity_curve():
ts = int(created_at)
else:
ts = int(time.time())
curve.append({'time': ts, 'equity': equity})
curve.append({'time': ts, 'equity': round(equity, 2)})
# 将未实现盈亏并入曲线末端,便于「持仓中」也能在绩效里看到浮动权益
try:
unreal = float(prow.get('u') or prow.get('U') or 0)
except Exception:
unreal = 0.0
live_equity = float(equity) + unreal
now_ts = int(time.time())
if abs(unreal) > 1e-12 or not curve:
curve.append({'time': now_ts, 'equity': round(live_equity, 2)})
return jsonify({'code': 1, 'msg': 'success', 'data': curve})
except Exception as e:
@@ -770,15 +795,16 @@ def start_strategy():
# Get strategy type
strategy_type = get_strategy_service().get_strategy_type(strategy_id)
# Update strategy status
get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id)
# Local backend: AI strategy executor was removed. Only indicator strategies are supported.
if strategy_type == 'PromptBasedStrategy':
return jsonify({'code': 0, 'msg': 'AI strategy has been removed; local edition does not support starting AI strategies', 'data': None}), 400
# Indicator strategy
# IndicatorStrategy and ScriptStrategy are executed by TradingExecutor.
if strategy_type == 'PromptBasedStrategy':
return jsonify({
'code': 0,
'msg': 'AI strategy has been removed; local edition does not support starting AI strategies',
'data': None
}), 400
get_strategy_service().update_strategy_status(strategy_id, 'running', user_id=user_id)
success = get_trading_executor().start_strategy(strategy_id)
if not success:
@@ -1241,12 +1267,65 @@ def verify_strategy_code():
@strategy_bp.route('/strategies/ai-generate', methods=['POST'])
@login_required
def ai_generate_strategy():
"""Generate strategy code using AI."""
"""Generate strategy code or suggest template parameter updates using AI."""
try:
payload = request.get_json() or {}
prompt = payload.get('prompt', '')
if not prompt.strip():
return jsonify({'code': '', 'msg': 'Prompt is empty'})
return jsonify({'code': '', 'msg': 'Prompt is empty', 'params': None})
intent = (payload.get('intent') or 'generate_code').strip()
from app.services.llm import LLMService
llm = LLMService()
api_key = llm.get_api_key()
if not api_key:
return jsonify({'code': '', 'msg': 'No LLM API key configured', 'params': None})
if intent == 'adjust_params':
template_key = payload.get('template_key') or ''
current_params = payload.get('params') or {}
code_snapshot = (payload.get('code') or '')[:8000]
system_prompt = """You tune quantitative strategy template parameters from the user's request.
Return ONLY a single JSON object: keys are parameter names (strings), values are JSON numbers or booleans.
You may return a partial object (only keys that should change) or a full object.
Do not use markdown fences, do not add explanations before or after the JSON."""
user_content = (
f"Template key: {template_key}\n"
f"Current parameters (JSON):\n{json.dumps(current_params, ensure_ascii=False)}\n\n"
f"Strategy code excerpt (context):\n{code_snapshot}\n\n"
f"User request:\n{prompt.strip()}\n\n"
"Respond with JSON only."
)
content = llm.call_llm_api(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
model=llm.get_code_generation_model(),
temperature=0.3,
use_json_mode=False
)
raw = (content or '').strip()
if raw.startswith('```'):
raw = re.sub(r'^```[a-zA-Z]*', '', raw).strip()
if raw.endswith('```'):
raw = raw[:-3].strip()
updates = None
try:
updates = json.loads(raw)
except json.JSONDecodeError:
m = re.search(r'\{[\s\S]*\}', raw)
if m:
try:
updates = json.loads(m.group(0))
except json.JSONDecodeError:
updates = None
if not isinstance(updates, dict):
return jsonify({'code': '', 'params': None, 'msg': 'AI did not return valid JSON parameters'})
return jsonify({'code': '', 'params': updates, 'msg': 'success'})
system_prompt = """You are a quantitative trading strategy code generator.
Generate Python strategy code that follows this framework:
@@ -1264,16 +1343,26 @@ Generate Python strategy code that follows this framework:
Return ONLY the Python code, no explanations."""
from app.services.llm import LLMService
llm = LLMService()
api_key = llm.get_api_key()
if not api_key:
return jsonify({'code': '', 'msg': 'No LLM API key configured'})
extra = ''
template_key = payload.get('template_key')
params = payload.get('params')
code_ctx = (payload.get('code') or '').strip()
if template_key or params is not None or code_ctx:
extra_parts = []
if template_key:
extra_parts.append(f"Current template key: {template_key}")
if isinstance(params, dict) and params:
extra_parts.append('Current template parameters (JSON):\n' + json.dumps(params, ensure_ascii=False))
if code_ctx:
extra_parts.append('Current code (may be long):\n' + code_ctx[:12000])
extra = '\n\n' + '\n\n'.join(extra_parts)
user_prompt = prompt.strip() + extra
content = llm.call_llm_api(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
{"role": "user", "content": user_prompt},
],
model=llm.get_code_generation_model(),
temperature=0.7,
@@ -1290,12 +1379,12 @@ Return ONLY the Python code, no explanations."""
content = content.strip()
if content:
return jsonify({'code': content, 'msg': 'success'})
return jsonify({'code': content, 'msg': 'success', 'params': None})
else:
return jsonify({'code': '', 'msg': 'AI generation returned empty result'})
return jsonify({'code': '', 'msg': 'AI generation returned empty result', 'params': None})
except Exception as e:
logger.error(f"ai_generate_strategy failed: {str(e)}")
return jsonify({'code': '', 'msg': str(e)})
return jsonify({'code': '', 'msg': str(e), 'params': None})
@strategy_bp.route('/strategies/performance', methods=['GET'])
@@ -1326,11 +1415,16 @@ def get_strategy_performance():
def get_strategy_logs():
"""Get strategy running logs."""
try:
user_id = g.user_id
strategy_id = request.args.get('id')
limit = int(request.args.get('limit', 200))
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Strategy ID required'})
st = get_strategy_service().get_strategy(int(strategy_id), user_id=user_id)
if not st:
return jsonify({'code': 0, 'msg': 'Strategy not found'}), 404
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
@@ -1346,10 +1440,22 @@ def get_strategy_logs():
rows = cur.fetchall() or []
cur.close()
logs = list(reversed(rows))
out = []
for r in rows or []:
if not isinstance(r, dict):
continue
rr = dict(r)
ts = rr.get('timestamp')
if ts is not None and hasattr(ts, 'isoformat'):
rr['timestamp'] = ts.isoformat()
out.append(rr)
logs = list(reversed(out))
return jsonify({'code': 1, 'msg': 'success', 'data': logs})
except Exception as e:
if 'qd_strategy_logs' in str(e) and ('does not exist' in str(e) or 'no such table' in str(e)):
if PgUndefinedTable is not None and isinstance(e, PgUndefinedTable):
return jsonify({'code': 1, 'msg': 'success', 'data': []})
el = str(e).lower()
if 'qd_strategy_logs' in el and ('does not exist' in el or 'no such table' in el):
return jsonify({'code': 1, 'msg': 'success', 'data': []})
logger.error(f"get_strategy_logs failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e)}), 500
+71 -1
View File
@@ -504,6 +504,12 @@ class BacktestService:
result['precision_info']['message'] = 'Using standard backtest because scale rules are not fully supported in MTF mode'
elif fallback_reason == 'signal_timing_not_supported_in_mtf':
result['precision_info']['message'] = 'Using standard backtest because this execution timing is not fully supported in MTF mode'
ea = result.get('executionAssumptions') or {}
ea['mtfRequested'] = bool(enable_mtf)
ea['mtfActive'] = False
if fallback_reason:
ea['mtfFallbackReason'] = fallback_reason
result['executionAssumptions'] = ea
return result
logger.info(f"Multi-timeframe backtest: strategy_tf={timeframe}, exec_tf={exec_tf}, range={start_date} ~ {end_date}")
@@ -554,6 +560,11 @@ class BacktestService:
'reason': 'data_unavailable',
'message': f'Cannot fetch {exec_tf} data, using standard backtest'
}
ea = result.get('executionAssumptions') or {}
ea['mtfRequested'] = bool(enable_mtf)
ea['mtfActive'] = False
ea['mtfFallbackReason'] = 'data_unavailable'
result['executionAssumptions'] = ea
return result
logger.info(f"Data fetched: signal_candles={len(df_signal)}, exec_candles={len(df_exec)}")
@@ -598,6 +609,14 @@ class BacktestService:
result['execution_timeframe'] = exec_tf
result['signal_candles'] = len(df_signal)
result['execution_candles'] = len(df_exec)
result['executionAssumptions'] = self._execution_assumptions(
strategy_config,
simulation_mode='mtf',
signal_timeframe=timeframe,
execution_timeframe=exec_tf,
mtf_requested=True,
mtf_active=True,
)
logger.info("Backtest result formatted successfully")
except Exception as e:
logger.error(f"Failed to format result: {str(e)}")
@@ -1487,6 +1506,11 @@ class BacktestService:
'precision': 'standard',
'message': 'Using standard strategy script backtest'
}
result['executionAssumptions'] = self._execution_assumptions(
strategy_config,
simulation_mode='standard',
signal_timeframe=timeframe,
)
return result
def run_code_strategy(
@@ -1607,7 +1631,13 @@ class BacktestService:
metrics = self._calculate_metrics(equity_curve, trades, initial_capital, timeframe, start_date, end_date, total_commission)
# 5. Format result
return self._format_result(metrics, equity_curve, trades)
result = self._format_result(metrics, equity_curve, trades)
result['executionAssumptions'] = self._execution_assumptions(
strategy_config,
simulation_mode='standard',
signal_timeframe=timeframe,
)
return result
def _fetch_kline_data(
self,
@@ -4740,6 +4770,46 @@ import pandas as pd
logger.warning(f"Sharpe ratio calculation failed: {e}")
return 0
def _execution_assumptions(
self,
strategy_config: Optional[Dict[str, Any]],
*,
simulation_mode: str,
signal_timeframe: Optional[str] = None,
execution_timeframe: Optional[str] = None,
mtf_requested: bool = False,
mtf_active: bool = False,
mtf_fallback_reason: Optional[str] = None,
) -> Dict[str, Any]:
"""
Human-facing metadata so the UI can explain how trades were timed vs chart markers.
Keys use camelCase for JSON consumers (frontend).
"""
cfg = strategy_config or {}
raw = str((cfg.get('execution') or {}).get('signalTiming') or 'next_bar_open').strip().lower()
is_next_open = raw in ('next_bar_open', 'next_open', 'nextopen', 'next')
if raw in ('bar_close', 'close', 'same_bar_close', 'current_bar_close'):
timing_key = 'same_bar_close'
elif is_next_open:
timing_key = 'next_bar_open'
else:
timing_key = raw
default_fill = 'open' if is_next_open else 'close'
payload: Dict[str, Any] = {
'signalTiming': timing_key,
'signalTimingRaw': raw,
'defaultFillPrice': default_fill,
'simulationMode': simulation_mode,
'strategyTimeframe': signal_timeframe,
'executionTimeframe': execution_timeframe,
'engineVersion': self.ENGINE_VERSION,
'mtfRequested': bool(mtf_requested),
'mtfActive': bool(mtf_active),
}
if mtf_fallback_reason:
payload['mtfFallbackReason'] = mtf_fallback_reason
return payload
def _format_result(
self,
metrics: Dict,
@@ -1830,7 +1830,35 @@ IMPORTANT:
# But we need to "reweight the available information": when some modules are missing (such as news/macro is not obtained), do not use 0 points to dilute the overall strength.
# Instead, the weights are renormalized so that technical signals can still play a leading role in their absence.
market_type = str(data.get("market") or "")
fundamental_present = (market_type == "USStock") and bool(fundamental)
def _fundamental_meaningful(fund: Dict[str, Any]) -> bool:
if not fund:
return False
for key in (
"pe_ratio",
"pb_ratio",
"ps_ratio",
"market_cap",
"roe",
"eps",
"revenue_growth",
"profit_margin",
"dividend_yield",
):
v = fund.get(key)
if v is None or v == "":
continue
try:
if isinstance(v, float) and v != v: # NaN
continue
return True
except Exception:
return True
return False
fundamental_present = (
market_type in ("USStock", "CNStock", "HKStock") and _fundamental_meaningful(fundamental)
)
sentiment_present = bool(news)
macro_present = bool(macro)
# indicators usually exist once they are successfully calculated, but they are also protected here.
@@ -2057,7 +2085,7 @@ IMPORTANT:
def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float:
"""Calculate fundamental score (-100 to +100)"""
if market != "USStock" or not fundamental:
if market not in ("USStock", "CNStock", "HKStock") or not fundamental:
return 0.0 # Non-U.S. stocks or no fundamental data, return neutral
score = 0.0
@@ -2142,7 +2170,9 @@ IMPORTANT:
# Normalization (if there are multiple factors)
if factors > 0:
score = score / factors * 100 / 4 # The maximum possible score is 20 points for each of the 4 factors = 80, normalized to 100
else:
return 50.0
return max(-100, min(100, score))
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
@@ -9,12 +9,75 @@ Notes:
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional, Tuple, Union
import requests
logger = logging.getLogger(__name__)
# Cached SSL verify setting for all live-trading REST calls (requests + SOCKS proxy).
_requests_verify_value: Optional[Union[bool, str]] = None
_ssl_verify_disabled_logged = False
# OS CA bundles (Docker / slim images: install ``ca-certificates``; corporate roots often added here too).
_SYSTEM_CA_BUNDLE_CANDIDATES: Tuple[str, ...] = (
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
"/etc/ssl/cert.pem", # Alpine, some slim images
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
)
def _get_requests_verify() -> Union[bool, str]:
"""
Resolve ``verify`` for ``requests`` when calling exchanges through proxies (e.g. PROXY_URL=socks5h://...).
- LIVE_TRADING_SSL_VERIFY=0|false|no|off: disable verification (insecure; mitm risk).
- LIVE_TRADING_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE: path to a PEM CA bundle
(needed for corporate TLS inspection or custom roots).
- Else a non-empty OS CA file if present (helps Gate/HTX/hbdm etc. in minimal images).
- Otherwise certifi's bundle when available.
"""
global _requests_verify_value, _ssl_verify_disabled_logged
if _requests_verify_value is not None:
return _requests_verify_value
flag = (os.environ.get("LIVE_TRADING_SSL_VERIFY") or "").strip().lower()
if flag in ("0", "false", "no", "off"):
if not _ssl_verify_disabled_logged:
logger.warning(
"LIVE_TRADING_SSL_VERIFY is disabled: HTTPS certificate verification is OFF for live trading "
"requests (MITM risk). Fix CA trust or set LIVE_TRADING_CA_BUNDLE instead for production."
)
_ssl_verify_disabled_logged = True
_requests_verify_value = False
return _requests_verify_value
for key in ("LIVE_TRADING_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"):
path = (os.environ.get(key) or "").strip()
if path and os.path.isfile(path):
_requests_verify_value = path
return _requests_verify_value
for path in _SYSTEM_CA_BUNDLE_CANDIDATES:
try:
if path and os.path.isfile(path) and os.path.getsize(path) >= 256:
_requests_verify_value = path
return _requests_verify_value
except OSError:
continue
try:
import certifi
_requests_verify_value = certifi.where()
except ImportError:
_requests_verify_value = True
return _requests_verify_value
@dataclass
class LiveOrderResult:
@@ -51,15 +114,26 @@ class BaseRestClient:
data: Optional[Any] = None,
) -> Tuple[int, Dict[str, Any], str]:
url = self._url(path)
resp = requests.request(
method=str(method or "GET").upper(),
url=url,
params=params or None,
json=json_body if json_body is not None else None,
data=data,
headers=headers or None,
timeout=self.timeout_sec,
)
try:
resp = requests.request(
method=str(method or "GET").upper(),
url=url,
params=params or None,
json=json_body if json_body is not None else None,
data=data,
headers=headers or None,
timeout=self.timeout_sec,
verify=_get_requests_verify(),
)
except requests.exceptions.SSLError as e:
logger.warning(
"Exchange HTTPS TLS verify failed (%s). Same setting applies to all REST exchanges (Gate, HTX/hbdm, etc.). "
"Behind PROXY_URL/SOCKS or TLS inspection: set LIVE_TRADING_CA_BUNDLE to a PEM bundle (or REQUESTS_CA_BUNDLE), "
"ensure ca-certificates in the image, or dev-only LIVE_TRADING_SSL_VERIFY=false. %s",
url,
e,
)
raise
text = resp.text or ""
parsed: Dict[str, Any] = {}
try:
@@ -40,6 +40,10 @@ class BinanceFuturesClient(BaseRestClient):
self._dual_side_cache: Optional[Tuple[float, bool]] = None
self._dual_side_cache_ttl_sec = 60.0
# serverTime - local_ms; avoids Binance -1021 when the host clock is ahead of Binance.
self._time_offset_ms: int = 0
self._time_sync_monotonic: float = 0.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -163,6 +167,26 @@ class BinanceFuturesClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _ensure_server_time(self, *, force: bool = False) -> None:
"""
Align signed request timestamps with Binance server time (GET /fapi/v1/time).
"""
now_m = time.monotonic()
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
return
try:
code, data, _ = self._request("GET", "/fapi/v1/time")
if code != 200 or not isinstance(data, dict):
return
server_ms = int(data.get("serverTime") or 0)
if server_ms <= 0:
return
local_ms = int(time.time() * 1000)
self._time_offset_ms = server_ms - local_ms
self._time_sync_monotonic = now_m
except Exception:
pass
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
@@ -179,17 +203,34 @@ class BinanceFuturesClient(BaseRestClient):
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
# Use server-accepted timestamp in ms.
p["timestamp"] = int(time.time() * 1000)
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
raise LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
raise LiveTradingError(f"Binance error: {data}")
return data if isinstance(data, dict) else {"raw": data}
self._ensure_server_time()
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
if "recvWindow" not in p:
p["recvWindow"] = 10000
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
err = LiveTradingError(f"Binance HTTP {code}: {text[:500]}")
if attempt == 0 and ("-1021" in text or "1021" in text):
self._ensure_server_time(force=True)
last_err = err
continue
raise err
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
err = LiveTradingError(f"Binance error: {data}")
if attempt == 0 and int(data.get("code") or 0) == -1021:
self._ensure_server_time(force=True)
last_err = err
continue
raise err
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("Binance signed request failed")
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
@@ -663,8 +704,6 @@ class BinanceFuturesClient(BaseRestClient):
"type": "MARKET",
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
}
if reduce_only:
params["reduceOnly"] = "true"
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
@@ -681,6 +720,10 @@ class BinanceFuturesClient(BaseRestClient):
# Unknown mode: try without positionSide first; we may retry on -4061.
params.pop("positionSide", None)
# reduceOnly after positionSide: in hedge mode, Binance returns -1106 if both are sent.
if reduce_only and not (dual_side is True and params.get("positionSide") in ("LONG", "SHORT")):
params["reduceOnly"] = "true"
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
except LiveTradingError as e:
@@ -706,6 +749,7 @@ class BinanceFuturesClient(BaseRestClient):
else:
# Likely hedge mode; retry with inferred positionSide.
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params2.pop("reduceOnly", None)
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
self._dual_side_cache = (time.time(), True)
@@ -802,8 +846,6 @@ class BinanceFuturesClient(BaseRestClient):
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
"price": self._dec_str(px_dec),
}
if reduce_only:
params["reduceOnly"] = "true"
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
@@ -816,6 +858,9 @@ class BinanceFuturesClient(BaseRestClient):
params.pop("positionSide", None)
else:
params.pop("positionSide", None)
if reduce_only and not (dual_side is True and params.get("positionSide") in ("LONG", "SHORT")):
params["reduceOnly"] = "true"
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params)
except LiveTradingError as e:
@@ -837,6 +882,7 @@ class BinanceFuturesClient(BaseRestClient):
pass
else:
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params2.pop("reduceOnly", None)
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
self._dual_side_cache = (time.time(), True)
@@ -870,12 +916,41 @@ class BinanceFuturesClient(BaseRestClient):
raise LiveTradingError("Binance cancel_order requires order_id or client_order_id")
return self._signed_request("DELETE", "/fapi/v1/order", params=params)
def get_positions(self) -> Any:
def set_margin_type(self, *, symbol: str, margin_mode: str) -> Dict[str, Any]:
"""
Return all futures positions (position risk endpoint).
Set symbol margin mode on USDT-M futures.
Endpoint: POST /fapi/v1/marginType
margin_mode: cross | crossed | isolated
"""
sym = to_binance_futures_symbol(symbol)
m = (margin_mode or "").strip().lower()
if m in ("cross", "crossed"):
mt = "CROSSED"
elif m in ("isolated", "iso"):
mt = "ISOLATED"
else:
raise LiveTradingError(f"Invalid margin_mode for Binance: {margin_mode}")
return self._signed_request("POST", "/fapi/v1/marginType", params={"symbol": sym, "marginType": mt})
def get_positions(self, *, symbol: str = "") -> Any:
"""
Futures positions (position risk). Optional ``symbol`` filters to one contract.
Endpoint: GET /fapi/v2/positionRisk
"""
return self._signed_request("GET", "/fapi/v2/positionRisk", params={})
raw = self._signed_request("GET", "/fapi/v2/positionRisk", params={})
rows: list
if isinstance(raw, list):
rows = raw
elif isinstance(raw, dict) and isinstance(raw.get("raw"), list):
rows = raw["raw"]
else:
rows = []
want = (symbol or "").strip()
if not want:
return rows
sym = to_binance_futures_symbol(want)
return [p for p in rows if isinstance(p, dict) and str(p.get("symbol") or "") == sym]
@@ -31,6 +31,9 @@ class BinanceSpotClient(BaseRestClient):
self._sym_filter_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._sym_filter_cache_ttl_sec = 300.0
self._time_offset_ms: int = 0
self._time_sync_monotonic: float = 0.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -158,6 +161,24 @@ class BinanceSpotClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _ensure_server_time(self, *, force: bool = False) -> None:
"""Align signed request timestamps with Binance (GET /api/v3/time)."""
now_m = time.monotonic()
if not force and (now_m - float(self._time_sync_monotonic or 0.0)) < 300.0:
return
try:
code, data, _ = self._request("GET", "/api/v3/time")
if code != 200 or not isinstance(data, dict):
return
server_ms = int(data.get("serverTime") or 0)
if server_ms <= 0:
return
local_ms = int(time.time() * 1000)
self._time_offset_ms = server_ms - local_ms
self._time_sync_monotonic = now_m
except Exception:
pass
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
@@ -174,16 +195,34 @@ class BinanceSpotClient(BaseRestClient):
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000)
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
raise LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
raise LiveTradingError(f"BinanceSpot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
self._ensure_server_time()
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000) + int(self._time_offset_ms)
if "recvWindow" not in p:
p["recvWindow"] = 10000
qs = urlencode(p, doseq=True)
p["signature"] = self._sign(qs)
code, data, text = self._request(method, path, params=p, headers=self._signed_headers())
if code >= 400:
err = LiveTradingError(f"BinanceSpot HTTP {code}: {text[:500]}")
if attempt == 0 and ("-1021" in text or "1021" in text):
self._ensure_server_time(force=True)
last_err = err
continue
raise err
if isinstance(data, dict) and data.get("code") and int(data.get("code")) < 0:
err = LiveTradingError(f"BinanceSpot error: {data}")
if attempt == 0 and int(data.get("code") or 0) == -1021:
self._ensure_server_time(force=True)
last_err = err
continue
raise err
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("BinanceSpot signed request failed")
def ping(self) -> bool:
"""
@@ -61,6 +61,10 @@ class BitgetMixClient(BaseRestClient):
self._lev_cache: Dict[str, Tuple[float, bool]] = {}
self._lev_cache_ttl_sec = 60.0
# posMode from GET /api/v2/mix/account/account (hedge_mode vs one_way_mode), cached per contract.
self._pos_mode_cache: Dict[str, Tuple[float, str]] = {}
self._pos_mode_cache_ttl_sec = 60.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -242,6 +246,31 @@ class BitgetMixClient(BaseRestClient):
raise LiveTradingError(f"Bitget error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _post_mix_place_order(
self,
body: Dict[str, Any],
*,
original_side: str,
reduce_only: bool,
) -> Dict[str, Any]:
"""
POST place-order; on 40774 (hedge vs one-way mismatch) retry with alternate position fields.
"""
sd = (original_side or "").lower()
try:
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
except LiveTradingError as e:
if "40774" not in str(e):
raise
b2: Dict[str, Any] = {k: v for k, v in body.items() if k not in ("side", "tradeSide", "reduceOnly")}
if "tradeSide" in body:
b2["side"] = sd
b2["reduceOnly"] = "YES" if reduce_only else "NO"
else:
b2["tradeSide"] = "close" if reduce_only else "open"
b2["side"] = ("sell" if sd == "buy" else "buy") if reduce_only else sd
return self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=b2)
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
if code >= 400:
@@ -252,6 +281,117 @@ class BitgetMixClient(BaseRestClient):
raise LiveTradingError(f"Bitget error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def get_ticker(self, *, symbol: str, **kwargs: Any) -> Dict[str, Any]:
"""
Public mix ticker (for USDT-notional -> base size conversion in quick trade).
Endpoint: GET /api/v2/mix/market/ticker
"""
sym = to_bitget_um_symbol(symbol)
pt = str(kwargs.get("product_type") or "USDT-FUTURES")
if not sym:
return {}
try:
raw = self._public_request(
"GET",
"/api/v2/mix/market/ticker",
params={"symbol": sym, "productType": pt},
)
except Exception:
return {}
data = raw.get("data") if isinstance(raw, dict) else None
if isinstance(data, list) and data:
data = data[0]
if not isinstance(data, dict):
return {}
try:
last = float(
data.get("lastPr")
or data.get("last")
or data.get("close")
or data.get("markPrice")
or data.get("indexPrice")
or 0
)
except Exception:
last = 0.0
if last <= 0:
return {}
return {"last": last, "price": last, "close": last}
def get_account_pos_mode(
self,
*,
symbol: str,
margin_coin: str = "USDT",
product_type: str = "USDT-FUTURES",
) -> str:
"""
Returns Bitget posMode for the contract account: 'hedge_mode', 'one_way_mode', or '' if unknown.
GET /api/v2/mix/account/account
"""
sym = to_bitget_um_symbol(symbol)
if not sym:
return ""
mc = (margin_coin or "USDT").strip().upper()
pt = str(product_type or "USDT-FUTURES")
key = f"{pt}:{sym}:{mc}"
now = time.time()
cached = self._pos_mode_cache.get(key)
if cached:
ts, mode = cached
if (now - float(ts or 0.0)) <= float(self._pos_mode_cache_ttl_sec or 60.0) and mode is not None:
return str(mode)
try:
resp = self._signed_request(
"GET",
"/api/v2/mix/account/account",
params={
"symbol": sym.lower(),
"productType": pt,
"marginCoin": mc.lower() or "usdt",
},
)
d = resp.get("data") if isinstance(resp, dict) else None
mode = ""
if isinstance(d, dict):
mode = str(d.get("posMode") or "").strip().lower()
self._pos_mode_cache[key] = (now, mode)
return mode
except Exception:
return ""
def _mix_order_position_fields(
self,
*,
symbol: str,
side: str,
reduce_only: bool,
margin_coin: str,
product_type: str,
) -> Dict[str, Any]:
"""
Bitget mix place-order: hedge_mode requires tradeSide open/close; one_way_mode requires reduceOnly YES/NO
and must not send tradeSide (see Bitget API doc + CCXT bitget.py).
"""
sd = (side or "").lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
pos_mode = self.get_account_pos_mode(
symbol=symbol, margin_coin=margin_coin, product_type=product_type
)
hedge = pos_mode == "hedge_mode"
if hedge:
# Mirror CCXT: hedge close flips side; hedge open keeps side + tradeSide open.
out: Dict[str, Any] = {
"tradeSide": "close" if reduce_only else "open",
"side": ("sell" if sd == "buy" else "buy") if reduce_only else sd,
}
return out
return {"side": sd, "reduceOnly": "YES" if reduce_only else "NO"}
def get_contract(self, *, symbol: str, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
"""
Fetch contract metadata (best-effort) from public endpoint.
@@ -354,13 +494,34 @@ class BitgetMixClient(BaseRestClient):
"""
return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")})
def get_positions(self, *, product_type: str = "USDT-FUTURES") -> Dict[str, Any]:
def get_positions(self, *, product_type: str = "USDT-FUTURES", symbol: str = "") -> Dict[str, Any]:
"""
Get all positions (best-effort).
Get positions (best-effort).
Endpoint: GET /api/v2/mix/position/all-position
When ``symbol`` is set (e.g. ETH/USDT), filters the response list to that contract only.
"""
return self._signed_request("GET", "/api/v2/mix/position/all-position", params={"productType": str(product_type or "USDT-FUTURES")})
resp = self._signed_request(
"GET",
"/api/v2/mix/position/all-position",
params={"productType": str(product_type or "USDT-FUTURES")},
)
want = (symbol or "").strip()
if not want:
return resp
sym_key = to_bitget_um_symbol(want).upper()
if not isinstance(resp, dict):
return resp
data = resp.get("data")
if not isinstance(data, list):
return resp
filtered = [
p for p in data
if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key
]
out = dict(resp)
out["data"] = filtered
return out
def set_leverage(
self,
@@ -444,16 +605,22 @@ class BitgetMixClient(BaseRestClient):
"productType": str(product_type or "USDT-FUTURES"),
"marginCoin": str(margin_coin or "USDT"),
"marginMode": self._normalize_margin_mode(margin_mode),
"side": sd,
"orderType": "market",
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
}
if reduce_only:
body["reduceOnly"] = "YES"
body.update(
self._mix_order_position_fields(
symbol=symbol,
side=sd,
reduce_only=reduce_only,
margin_coin=str(margin_coin or "USDT"),
product_type=str(product_type or "USDT-FUTURES"),
)
)
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
data = raw.get("data") if isinstance(raw, dict) else None
exchange_order_id = ""
if isinstance(data, dict):
@@ -498,21 +665,27 @@ class BitgetMixClient(BaseRestClient):
"productType": str(product_type or "USDT-FUTURES"),
"marginCoin": str(margin_coin or "USDT"),
"marginMode": self._normalize_margin_mode(margin_mode),
"side": sd,
"orderType": "limit",
"price": str(px),
"size": self._dec_str(sz_dec, strict_precision=sz_precision),
}
body.update(
self._mix_order_position_fields(
symbol=symbol,
side=sd,
reduce_only=reduce_only,
margin_coin=str(margin_coin or "USDT"),
product_type=str(product_type or "USDT-FUTURES"),
)
)
# Force maker behavior when requested (avoid taker fills).
if post_only:
body["force"] = "post_only"
else:
body["force"] = "gtc"
if reduce_only:
body["reduceOnly"] = "YES"
if client_order_id:
body["clientOid"] = str(client_order_id)
raw = self._signed_request("POST", "/api/v2/mix/order/place-order", json_body=body)
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
data = raw.get("data") if isinstance(raw, dict) else None
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else ""
return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
@@ -32,7 +32,7 @@ class BybitClient(BaseRestClient):
base_url: str = "https://api.bybit.com",
timeout_sec: float = 15.0,
category: str = "linear", # "linear" (USDT perpetual) or "spot"
recv_window_ms: int = 5000,
recv_window_ms: int = 12000,
broker_referer: str = "",
hedge_mode: bool = False,
):
@@ -45,11 +45,13 @@ class BybitClient(BaseRestClient):
if self.category not in ("linear", "spot"):
self.category = "linear"
try:
self.recv_window_ms = int(recv_window_ms or 5000)
self.recv_window_ms = int(recv_window_ms or 12000)
except Exception:
self.recv_window_ms = 12000
if self.recv_window_ms < 5000:
self.recv_window_ms = 5000
if self.recv_window_ms <= 0:
self.recv_window_ms = 5000
if self.recv_window_ms > 60000:
self.recv_window_ms = 60000
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Bybit api_key/secret_key")
@@ -59,6 +61,12 @@ class BybitClient(BaseRestClient):
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._inst_cache_ttl_sec = 300.0
# Bybit v5 rejects requests if local clock diverges from server (retCode 10002).
# Offset = server_ms - local_ms; signed timestamp uses local_ms + offset.
self._time_offset_ms: int = 0
self._time_offset_at: float = 0.0
self._time_sync_ttl_sec: float = 55.0
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
@@ -161,6 +169,48 @@ class BybitClient(BaseRestClient):
def _sign(self, prehash: str) -> str:
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def _parse_server_time_ms_from_market_time(raw: Dict[str, Any]) -> int:
"""Parse milliseconds from GET /v5/market/time (or similar) JSON."""
if not isinstance(raw, dict):
raise LiveTradingError("Bybit market/time: invalid response")
res = raw.get("result")
if isinstance(res, dict):
nano = res.get("timeNano")
if nano is not None and str(nano).strip() != "":
try:
return int(int(str(nano)) // 1_000_000)
except Exception:
pass
sec = res.get("timeSecond")
if sec is not None and str(sec).strip() != "":
try:
return int(float(sec) * 1000)
except Exception:
pass
t = raw.get("time")
if t is not None:
try:
return int(t)
except Exception:
pass
raise LiveTradingError("Bybit market/time: missing time fields")
def sync_server_time_offset(self, *, force: bool = False) -> None:
"""Align signing timestamp with Bybit server (public /v5/market/time)."""
now = time.time()
if (
not force
and self._time_offset_at > 0
and (now - self._time_offset_at) < float(self._time_sync_ttl_sec or 55.0)
):
return
raw = self._public_request("GET", "/v5/market/time")
srv_ms = self._parse_server_time_ms_from_market_time(raw)
local_ms = int(time.time() * 1000)
self._time_offset_ms = int(srv_ms - local_ms)
self._time_offset_at = now
def _resolve_position_idx(self, pos_side: str) -> Optional[int]:
if not self.hedge_mode:
return None
@@ -193,32 +243,55 @@ class BybitClient(BaseRestClient):
json_body: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
m = str(method or "GET").upper()
ts_ms = str(int(time.time() * 1000))
body_str = self._json_dumps(json_body) if json_body is not None else ""
qs = ""
qs_base = ""
if params:
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
qs_base = urlencode(sorted(norm.items()), doseq=True)
payload_get = qs_base
payload_post = body_str
payload = qs if m == "GET" else body_str
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
sign = self._sign(prehash)
last_err: Optional[LiveTradingError] = None
for attempt in range(2):
try:
self.sync_server_time_offset(force=(attempt > 0))
except Exception as e:
if attempt == 0:
# First attempt: still try with raw local time; second pass may recover.
pass
else:
raise LiveTradingError(f"Bybit time sync failed: {e}") from e
code, data, text = self._request(
m,
path,
params=params if (m == "GET" and params) else (params or None),
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
)
if code >= 400:
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
if isinstance(data, dict):
rc = data.get("retCode")
if rc not in (0, "0", None, ""):
raise LiveTradingError(f"Bybit error: {data}")
return data if isinstance(data, dict) else {"raw": data}
ts_ms = str(int(time.time() * 1000) + int(self._time_offset_ms or 0))
payload = payload_get if m == "GET" else payload_post
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
sign = self._sign(prehash)
code, data, text = self._request(
m,
path,
params=params if (m == "GET" and params) else (params or None),
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
)
if code >= 400:
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
if isinstance(data, dict):
rc = data.get("retCode")
try:
rc_int = int(rc) if rc is not None and str(rc).strip() != "" else 0
except Exception:
rc_int = -1
if rc_int == 10002 and attempt == 0:
last_err = LiveTradingError(f"Bybit error: {data}")
continue
if rc not in (0, "0", None, ""):
raise LiveTradingError(f"Bybit error: {data}")
return data if isinstance(data, dict) else {"raw": data}
if last_err:
raise last_err
raise LiveTradingError("Bybit signed request failed after time resync")
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
@@ -237,6 +310,109 @@ class BybitClient(BaseRestClient):
except Exception:
return False
@staticmethod
def _row_to_ticker_out(row: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(row, dict):
return {}
last_raw = row.get("lastPrice") or row.get("last") or row.get("markPrice") or row.get("indexPrice") or 0
try:
px = float(str(last_raw).replace(",", "").strip() or 0)
except Exception:
px = 0.0
if px <= 0:
return {}
out: Dict[str, Any] = dict(row)
out["last"] = px
out["price"] = px
return out
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
"""
Public market price for USDT notional -> base qty (quick_trade / execution).
Tries: ``/v5/market/tickers`` (by symbol) ``/v5/market/orderbook`` (mid)
``/v5/market/tickers`` (category-only, scan list). Some environments return an empty
ticker list for filtered queries; fallbacks avoid silent failure.
"""
sym = to_bybit_symbol(symbol)
if not sym:
return {}
cat = "spot" if (self.category or "").strip().lower() == "spot" else "linear"
sym_u = sym.upper()
# 1) Filtered tickers (preferred)
try:
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat, "symbol": sym_u})
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
if isinstance(lst, list) and lst:
out = self._row_to_ticker_out(lst[0] if isinstance(lst[0], dict) else {})
if out:
return out
except LiveTradingError:
pass
except Exception:
pass
# 2) Order book mid (bid/ask)
try:
ob = self._public_request(
"GET",
"/v5/market/orderbook",
params={"category": cat, "symbol": sym_u, "limit": 25},
)
res = (ob.get("result") or {}) if isinstance(ob, dict) else {}
bids = res.get("b") or []
asks = res.get("a") or []
bid_p = 0.0
ask_p = 0.0
if isinstance(bids, list) and bids and isinstance(bids[0], (list, tuple)) and len(bids[0]) > 0:
try:
bid_p = float(str(bids[0][0]).replace(",", ""))
except Exception:
bid_p = 0.0
if isinstance(asks, list) and asks and isinstance(asks[0], (list, tuple)) and len(asks[0]) > 0:
try:
ask_p = float(str(asks[0][0]).replace(",", ""))
except Exception:
ask_p = 0.0
mid = 0.0
if bid_p > 0 and ask_p > 0:
mid = (bid_p + ask_p) / 2.0
else:
mid = bid_p or ask_p
if mid > 0:
return {
"symbol": sym_u,
"last": mid,
"price": mid,
"bid1Price": bid_p,
"ask1Price": ask_p,
}
except LiveTradingError:
pass
except Exception:
pass
# 3) Full category ticker list, match symbol (larger payload; last resort)
try:
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat})
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
if isinstance(lst, list):
for row in lst:
if not isinstance(row, dict):
continue
if str(row.get("symbol") or "").strip().upper() != sym_u:
continue
out = self._row_to_ticker_out(row)
if out:
return out
except LiveTradingError:
pass
except Exception:
pass
return {}
def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]:
return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")})
@@ -497,10 +673,28 @@ class BybitClient(BaseRestClient):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))
def get_positions(self) -> Dict[str, Any]:
def get_positions(
self,
*,
symbol: str = "",
settle_coin: str = "",
) -> Dict[str, Any]:
"""
GET /v5/position/list Bybit v5 requires ``symbol`` OR ``settleCoin`` with ``category``.
- Pass ``symbol`` (e.g. ETH/USDT) to query one contract.
- Omit ``symbol`` and pass ``settle_coin`` (default USDT) to list all USDT-linear positions.
"""
if self.category != "linear":
raise LiveTradingError("Bybit positions are only supported for linear category in this client")
return self._signed_request("GET", "/v5/position/list", params={"category": "linear"})
params: Dict[str, Any] = {"category": "linear"}
sym = to_bybit_symbol(symbol) if (symbol or "").strip() else ""
if sym:
params["symbol"] = sym
else:
sc = (settle_coin or "USDT").strip().upper() or "USDT"
params["settleCoin"] = sc
return self._signed_request("GET", "/v5/position/list", params=params)
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
if self.category != "linear":
@@ -117,7 +117,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
category = "spot" if mt == "spot" else "linear"
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 12000)
broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020"
hedge_mode_raw = exchange_config.get("hedge_mode")
if hedge_mode_raw is None:
@@ -4,7 +4,7 @@ Gate.io (direct REST) clients:
- Futures USDT: /api/v4/futures/usdt/*
Signing (apiv4):
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + body + "\\n" + timestamp))
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + hexencode(sha512(payload)) + "\\n" + timestamp))
Headers:
- KEY: api key
- Timestamp: unix seconds
@@ -15,14 +15,42 @@ from __future__ import annotations
import hashlib
import hmac
import logging
import time
from decimal import Decimal, ROUND_DOWN
from typing import Any, Dict, Optional, Tuple
from decimal import Decimal, ROUND_DOWN, ROUND_UP
from typing import Any, Dict, Optional, Tuple, Union
from urllib.parse import urlencode
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_gate_currency_pair
logger = logging.getLogger(__name__)
def _gate_ticker_response_to_normalized(raw: Any) -> Dict[str, Any]:
"""Parse Gate spot/futures tickers API (array of one row) into a dict with float ``last`` for quick_trade."""
row: Dict[str, Any] = {}
if isinstance(raw, list) and raw and isinstance(raw[0], dict):
row = raw[0]
elif isinstance(raw, dict) and raw:
row = raw
else:
return {}
last = 0.0
for key in ("last", "mark_price", "index_price", "close", "price"):
v = row.get(key)
if v is not None and str(v).strip():
try:
last = float(str(v).replace(",", ""))
break
except Exception:
continue
out = dict(row)
out["last"] = last
out["close"] = last
out["price"] = last
return out
class _GateBase(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
@@ -34,7 +62,10 @@ class _GateBase(BaseRestClient):
raise LiveTradingError("Missing Gate api_key/secret_key")
def _sign(self, *, method: str, url: str, query_string: str, body_str: str, ts: str) -> str:
msg = f"{method.upper()}\n{url}\n{query_string}\n{body_str}\n{ts}"
# Per https://www.gate.com/docs/developers/apiv4/en/#authentication — payload slot is SHA512(body).hexdigest(),
# not the raw body (GET / no body => hash of empty string).
hashed_payload = hashlib.sha512((body_str or "").encode("utf-8")).hexdigest()
msg = f"{method.upper()}\n{url}\n{query_string}\n{hashed_payload}\n{ts}"
return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest()
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
@@ -58,7 +89,15 @@ class _GateBase(BaseRestClient):
text = f"t-{text}"
return text[:28]
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
def _signed_request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Any:
m = str(method or "GET").upper()
ts = str(int(time.time()))
body_str = self._json_dumps(json_body) if json_body is not None else ""
@@ -67,7 +106,10 @@ class _GateBase(BaseRestClient):
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
sign = self._sign(method=m, url=path, query_string=qs, body_str=body_str, ts=ts)
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign))
hdrs = dict(self._headers(ts, sign))
if extra_headers:
hdrs.update({str(k): str(v) for k, v in extra_headers.items()})
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=hdrs)
if code >= 400:
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
return data
@@ -87,6 +129,11 @@ class GateSpotClient(_GateBase):
except Exception:
return False
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
pair = to_gate_currency_pair(symbol)
raw = self._public_request("GET", "/api/v4/spot/tickers", params={"currency_pair": pair})
return _gate_ticker_response_to_normalized(raw)
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v4/spot/accounts")
@@ -204,13 +251,21 @@ class GateUsdtFuturesClient(_GateBase):
return Decimal("0")
def ping(self) -> bool:
# Gate futures REST no longer serves /api/v4/futures/usdt/time (returns 400 on fx-api / api hosts).
# Use a lightweight public list call instead.
try:
_ = self._public_request("GET", "/api/v4/futures/usdt/time")
_ = self._public_request("GET", "/api/v4/futures/usdt/contracts", params={"limit": 1})
return True
except Exception:
return False
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
contract = to_gate_currency_pair(symbol)
raw = self._public_request("GET", "/api/v4/futures/usdt/tickers", params={"contract": contract})
return _gate_ticker_response_to_normalized(raw)
def get_contract(self, *, contract: str) -> Dict[str, Any]:
"""Fetch contract metadata with ``X-Gate-Size-Decimal: 1`` to get accurate string-typed size fields."""
c = str(contract or "").strip()
if not c:
return {}
@@ -220,33 +275,122 @@ class GateUsdtFuturesClient(_GateBase):
ts, obj = cached
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
return obj
raw = self._public_request("GET", f"/api/v4/futures/usdt/contracts/{c}")
obj = raw if isinstance(raw, dict) else {}
code, data, text = self._request(
"GET", f"/api/v4/futures/usdt/contracts/{c}",
params=None, headers={"X-Gate-Size-Decimal": "1"},
json_body=None, data=None,
)
if code >= 400:
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
obj = data if isinstance(data, dict) else {}
if obj:
self._contract_cache[c] = (now, obj)
return obj
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
@staticmethod
def _decimal_places(d: Decimal) -> int:
"""Return the number of decimal places in a Decimal value."""
sign, digits, exponent = d.as_tuple()
return max(0, -int(exponent))
def _resolve_order_size(self, *, contract: str, side: str, base_size: float) -> Tuple[str, Optional[Dict[str, str]]]:
"""
Convert base-asset qty to a signed Gate ``size`` string and determine whether to use
the ``X-Gate-Size-Decimal`` header.
Per Gate announcement (2025-12-18):
- ``size`` is always in **contracts** (not base-asset units).
- With ``X-Gate-Size-Decimal: 1``, ``size`` becomes a string that supports decimals.
- A contract supports fractional ordering when ``order_size_min`` (queried with the
decimal header) contains a fractional part (e.g. ``"0.1"``).
- Precision must align with ``order_size_min`` (e.g. if min is ``"0.1"`` 1 dp).
"""
sd = (side or "").strip().lower()
sign = Decimal("1") if sd == "buy" else Decimal("-1")
req = self._to_dec(base_size)
if req <= 0:
return 0
return ("0", None)
meta: Dict[str, Any] = {}
try:
meta = self.get_contract(contract=contract) or {}
except Exception:
meta = {}
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or meta.get("contract_size") or meta.get("contractSize") or "0")
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or "0")
if qm <= 0:
# Fallback: 1 contract ~= 1 base unit (best-effort)
qm = Decimal("1")
contracts = req / qm
return int(self._floor(contracts))
order_min = self._to_dec(meta.get("order_size_min") or "1")
if order_min <= 0:
order_min = Decimal("1")
dp = self._decimal_places(order_min)
if dp > 0:
step = Decimal(10) ** (-dp)
q = contracts.quantize(step, rounding=ROUND_DOWN)
if q < order_min and contracts > 0:
q = order_min
signed_q = q * sign
s = format(signed_q, "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0",
{"X-Gate-Size-Decimal": "1"})
else:
iv = int(self._floor(contracts))
int_min = max(1, int(order_min))
if iv < int_min and contracts > 0:
iv = int_min
signed_iv = int(Decimal(iv) * sign)
return (str(signed_iv), None)
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
"""Integer contracts estimate (for internal use like position sizing display)."""
meta: Dict[str, Any] = {}
try:
meta = self.get_contract(contract=contract) or {}
except Exception:
meta = {}
qm = self._to_dec(meta.get("quanto_multiplier") or "0")
if qm <= 0:
qm = Decimal("1")
return max(1, int(self._floor(self._to_dec(base_size) / qm)))
def contracts_signed_to_base_qty(self, *, contract: str, contracts_signed: float) -> float:
"""Convert signed position size (contracts) from Gate positions API to base-asset quantity."""
try:
ct = abs(float(contracts_signed or 0.0))
except Exception:
return 0.0
if ct <= 0:
return 0.0
meta: Dict[str, Any] = {}
try:
meta = self.get_contract(contract=str(contract)) or {}
except Exception:
meta = {}
qm = self._to_dec(
meta.get("quanto_multiplier")
or meta.get("quantoMultiplier")
or meta.get("contract_size")
or meta.get("contractSize")
or "0"
)
if qm <= 0:
qm = Decimal("1")
return float(Decimal(str(ct)) * qm)
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v4/futures/usdt/accounts")
def get_positions(self) -> Any:
return self._signed_request("GET", "/api/v4/futures/usdt/positions")
return self._signed_request(
"GET", "/api/v4/futures/usdt/positions",
extra_headers={"X-Gate-Size-Decimal": "1"},
)
def set_leverage(self, *, contract: str, leverage: float) -> bool:
c = str(contract or "").strip()
@@ -258,11 +402,26 @@ class GateUsdtFuturesClient(_GateBase):
lv = 1
if lv < 1:
lv = 1
try:
_ = self._signed_request("POST", f"/api/v4/futures/usdt/positions/{c}/leverage", json_body={"leverage": str(lv)})
return True
except Exception:
return False
path = f"/api/v4/futures/usdt/positions/{c}/leverage"
lv_s = str(lv)
# Gate expects ``leverage`` / ``cross_leverage_limit`` as **query parameters**, not JSON body
# (see gateapi-python: update_position_leverage). Cross / portfolio mode: leverage=0 + cross_leverage_limit.
attempts: Tuple[Dict[str, str], ...] = (
{"leverage": lv_s},
{"leverage": "0", "cross_leverage_limit": lv_s},
{"leverage": lv_s, "cross_leverage_limit": lv_s},
)
last_err: Optional[Exception] = None
for qp in attempts:
try:
_ = self._signed_request("POST", path, params=qp, json_body=None)
return True
except LiveTradingError as e:
last_err = e
except Exception as e:
last_err = e
logger.warning("Gate set_leverage failed contract=%s leverage=%s: %s", c, lv, last_err)
return False
def place_market_order(
self,
@@ -276,18 +435,27 @@ class GateUsdtFuturesClient(_GateBase):
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
base_qty = float(size or 0.0)
if base_qty <= 0:
raise LiveTradingError("Invalid size (<= 0)")
contract = to_gate_currency_pair(symbol)
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
if csz <= 0:
raise LiveTradingError("Invalid size (converted contracts <= 0)")
signed_size = int(csz) if sd == "buy" else -int(csz)
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"}
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
if size_str in ("0", "-0", ""):
raise LiveTradingError("Invalid size (resolved contracts == 0)")
logger.info("Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s",
contract, sd, base_qty, size_str, extra_headers is not None)
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": "0", "tif": "ioc"}
if reduce_only:
body["reduce_only"] = True
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
raw = self._signed_request(
"POST",
"/api/v4/futures/usdt/orders",
json_body=body,
extra_headers=extra_headers,
)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -304,21 +472,28 @@ class GateUsdtFuturesClient(_GateBase):
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
contract = to_gate_currency_pair(symbol)
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
if csz <= 0:
raise LiveTradingError("Invalid size (converted contracts <= 0)")
base_qty = float(size or 0.0)
if base_qty <= 0:
raise LiveTradingError("Invalid size (<= 0)")
px = float(price or 0.0)
if px <= 0:
raise LiveTradingError("Invalid price")
signed_size = int(csz) if sd == "buy" else -int(csz)
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"}
contract = to_gate_currency_pair(symbol)
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
if size_str in ("0", "-0", ""):
raise LiveTradingError("Invalid size (resolved contracts == 0)")
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": str(px), "tif": "gtc"}
if reduce_only:
body["reduce_only"] = True
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
raw = self._signed_request(
"POST",
"/api/v4/futures/usdt/orders",
json_body=body,
extra_headers=extra_headers,
)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -19,9 +19,13 @@ from urllib.parse import urlencode, urlparse
import datetime
import time
import logging
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
logger = logging.getLogger(__name__)
class HtxClient(BaseRestClient):
def __init__(
@@ -53,6 +57,17 @@ class HtxClient(BaseRestClient):
self._contract_cache_ttl_sec = 300.0
self._lever_cache: Dict[str, int] = {}
@staticmethod
def _format_swap_client_order_id(client_order_id: Optional[str]) -> Optional[int]:
"""HTX swap/linear-swap client_order_id must be a pure numeric long (1~9223372036854775807)."""
if not client_order_id:
return None
digits = "".join(c for c in str(client_order_id) if c.isdigit())
if not digits:
digits = str(int(time.time() * 1000))
val = int(digits[-18:])
return val if 0 < val <= 9223372036854775807 else None
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
prefix = str(self.broker_id or "").strip()
raw = str(client_order_id or "").strip()
@@ -202,11 +217,33 @@ class HtxClient(BaseRestClient):
if self.market_type == "spot":
account_id = self._get_spot_account_id()
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
# 1) v1 cross
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 cross account_info failed: %s", e)
# 2) v3 unified (unified / multi-asset collateral accounts)
try:
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
v3_code = raw.get("code")
if v3_code is not None and int(v3_code) == 200:
data = raw.get("data")
if isinstance(data, list) and data:
logger.info("HTX v3 unified_account_info succeeded")
return raw
else:
logger.debug("HTX v3 unified_account_info returned code=%s", v3_code)
except (LiveTradingError, Exception) as e:
logger.debug("HTX v3 unified_account_info failed: %s", e)
# 3) v1 isolated
try:
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
except LiveTradingError as e:
logger.warning("HTX all balance endpoints failed, last error: %s", e)
return {"data": []}
def get_positions(self, *, symbol: str = "") -> Any:
if self.market_type == "spot":
@@ -235,11 +272,35 @@ class HtxClient(BaseRestClient):
return {"data": rows}
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
# 1) v1 cross
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 cross position_info failed: %s", e)
# 2) v1 isolated
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
except LiveTradingError as e:
logger.debug("HTX v1 isolated position_info failed: %s", e)
# 3) v3 unified - extract positions from cross_swap sub-array
try:
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
v3_code = raw.get("code")
if v3_code is not None and int(v3_code) == 200:
v3_data = raw.get("data") or []
if isinstance(v3_data, list) and v3_data:
logger.info("HTX v3 unified_account_info for positions succeeded")
return raw
except (LiveTradingError, Exception) as e:
logger.debug("HTX v3 unified_account_info (positions) failed: %s", e)
logger.warning("HTX all position endpoints failed for symbol=%s", symbol)
return {"data": []}
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
if self.market_type == "spot":
@@ -355,7 +416,7 @@ class HtxClient(BaseRestClient):
raise LiveTradingError(f"Invalid side: {side}")
offset = "close" if reduce_only else "open"
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
body: Dict[str, Any] = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
@@ -365,8 +426,18 @@ class HtxClient(BaseRestClient):
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
swap_coid = self._format_swap_client_order_id(client_order_id)
if swap_coid is not None:
body["client_order_id"] = swap_coid
cross_body = dict(body)
cross_body["margin_account"] = "USDT"
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
except LiveTradingError as e:
logger.info("HTX swap_cross_order failed, trying swap_order: %s", e)
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
@@ -412,7 +483,7 @@ class HtxClient(BaseRestClient):
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
body: Dict[str, Any] = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
@@ -423,8 +494,18 @@ class HtxClient(BaseRestClient):
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
swap_coid = self._format_swap_client_order_id(client_order_id)
if swap_coid is not None:
body["client_order_id"] = swap_coid
cross_body = dict(body)
cross_body["margin_account"] = "USDT"
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
except LiveTradingError:
pass
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
@@ -129,7 +129,7 @@ class MarketDataCollector:
}
# If fundamentals are needed, also obtain them in parallel
if market == 'USStock':
if market in ('USStock', 'CNStock', 'HKStock'):
core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental"
core_futures[executor.submit(self._get_company, market, symbol)] = "company"
elif market == 'Crypto':
@@ -616,9 +616,94 @@ class MarketDataCollector:
try:
if market == 'USStock':
return self._get_us_fundamental(symbol)
if market in ('CNStock', 'HKStock'):
return self._get_cn_hk_fundamental(market, symbol)
except Exception as e:
logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}")
return None
def _get_cn_hk_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""
CN/HK fundamentals multi-tier:
Tier 1: Twelve Data /statistics (globally stable, paid)
Tier 2: AkShare / Eastmoney (fragile overseas)
+ Tencent quote for live price fields
"""
try:
from app.data_sources.tencent import (
normalize_cn_code,
normalize_hk_code,
fetch_quote,
parse_quote_to_ticker,
)
from app.data_sources.cn_hk_fundamentals import (
fetch_twelvedata_fundamental,
fetch_cn_fundamental_akshare,
fetch_hk_fundamental_akshare,
)
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
is_hk = market == 'HKStock'
parts = fetch_quote(code)
t = parse_quote_to_ticker(parts) if parts else {}
result: Dict[str, Any] = {
"pe_ratio": None,
"pb_ratio": None,
"ps_ratio": None,
"market_cap": None,
"dividend_yield": None,
"beta": None,
"52w_high": None,
"52w_low": None,
"roe": None,
"eps": None,
"last": t.get("last"),
"previous_close": t.get("previousClose"),
"change_percent": t.get("changePercent"),
"source": "tencent_quote",
}
# Tier 1: Twelve Data
td = {}
try:
td = fetch_twelvedata_fundamental(code, is_hk)
except Exception as e:
logger.debug("TwelveData fundamental failed %s:%s: %s", market, symbol, e)
if td:
result["source"] = "tencent_quote+twelvedata"
for k, v in td.items():
if k == "source":
continue
if v is not None:
result[k] = v
# Tier 2: AkShare (fill any remaining None fields)
has_valuation = result.get("pe_ratio") is not None or result.get("pb_ratio") is not None
if not has_valuation:
try:
ak_data = fetch_cn_fundamental_akshare(code) if not is_hk else fetch_hk_fundamental_akshare(code)
except Exception as e:
logger.debug("AkShare CN/HK fundamental failed %s:%s: %s", market, symbol, e)
ak_data = {}
if ak_data:
if "twelvedata" not in result.get("source", ""):
result["source"] = "tencent_quote+akshare_em"
else:
result["source"] += "+akshare_em"
for k, v in ak_data.items():
if k == "source":
continue
if v is not None and result.get(k) is None:
result[k] = v
if not parts and not td and not has_valuation:
return None
return result
except Exception as e:
logger.debug(f"CN/HK fundamental failed: {market}:{symbol}: {e}")
return None
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""
@@ -924,11 +1009,85 @@ class MarketDataCollector:
'market_cap': profile.get('marketCapitalization'),
'website': profile.get('weburl'),
}
if market in ('CNStock', 'HKStock'):
return self._get_cn_hk_company(market, symbol)
except Exception as e:
logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}")
return None
def _get_cn_hk_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
"""
CN/HK company info multi-tier:
Tier 1: Twelve Data /profile (globally stable)
Tier 2: AkShare / Eastmoney (fragile overseas)
+ Tencent quote for Chinese name
"""
try:
from app.data_sources.tencent import (
normalize_cn_code,
normalize_hk_code,
fetch_quote,
)
from app.data_sources.cn_hk_fundamentals import (
fetch_twelvedata_profile,
fetch_cn_company_extras,
fetch_hk_company_extras,
)
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
is_hk = market == 'HKStock'
parts = fetch_quote(code)
cn_name = ""
if parts:
cn_name = (parts[1] or "").strip() if len(parts) > 1 else ""
row: Dict[str, Any] = {
"name": cn_name or code,
"country": "CN" if market == "CNStock" else "HK",
"exchange": "SSE/SZSE" if market == "CNStock" else "HKEX",
"symbol": code,
"source": "tencent_quote",
}
# Tier 1: Twelve Data /profile
td_profile = {}
try:
td_profile = fetch_twelvedata_profile(code, is_hk)
except Exception as e:
logger.debug("TwelveData profile failed %s:%s: %s", market, symbol, e)
if td_profile:
row["source"] = "tencent_quote+twelvedata"
for k in ("industry", "sector", "website", "description", "employees", "full_name"):
v = td_profile.get(k)
if v is not None:
row[k] = v
if not cn_name and td_profile.get("name"):
row["name"] = td_profile["name"]
# Tier 2: AkShare (fill remaining gaps)
if not row.get("industry"):
try:
ex = fetch_cn_company_extras(code) if not is_hk else fetch_hk_company_extras(code)
except Exception:
ex = {}
if ex:
if "twelvedata" not in row.get("source", ""):
row["source"] = "tencent_quote+akshare_em"
else:
row["source"] += "+akshare_em"
for k in ("industry", "ipo_date", "website", "full_name"):
if ex.get(k) and not row.get(k):
row[k] = ex[k]
if not parts and not td_profile and not row.get("industry"):
return None
return row
except Exception:
return None
# ==================== Macro data (reusing the global financial sector) ====================
@@ -360,8 +360,8 @@ class PendingOrderWorker:
exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(total))
elif isinstance(client, BybitClient) and market_type == "swap":
# Bybit linear positions
resp = client.get_positions()
# Bybit v5 requires symbol or settleCoin — use USDT for full linear book
resp = client.get_positions(settle_coin="USDT")
lst = (((resp.get("result") or {}).get("list")) if isinstance(resp, dict) else None) or []
if isinstance(lst, list):
for p in lst:
@@ -1234,13 +1234,14 @@ class PendingOrderWorker:
actual_pos_size = pos_amt
break
elif isinstance(client, BybitClient):
pos_resp = client.get_positions() or {}
pos_resp = client.get_positions(symbol=str(symbol or "")) or {}
pos_list = (pos_resp.get("result") or {}).get("list") or [] if isinstance(pos_resp, dict) else []
want = str(symbol or "").replace("/", "").replace("-", "").upper()
for pos in pos_list:
if not isinstance(pos, dict):
continue
pos_sym = str(pos.get("symbol") or "")
if pos_sym != str(symbol or "").replace("/", ""):
pos_sym = str(pos.get("symbol") or "").strip().upper()
if pos_sym != want:
continue
p_side = str(pos.get("side") or "").strip().lower()
if (p_side == "buy" and pos_side == "long") or (p_side == "sell" and pos_side == "short"):
@@ -87,26 +87,20 @@ class PolymarketWorker:
logger.info("Starting Polymarket data update and analysis...")
start_time = time.time()
# 1. Update market data (obtained from all major categories)
categories = ["crypto", "politics", "economics", "sports", "tech", "finance", "geopolitics", "culture", "climate", "entertainment"]
all_markets = []
# Gamma API /events has no category param — fetch ALL once, categorize locally.
all_markets = self.polymarket_source.get_trending_markets(category="all", limit=500)
logger.info(f"Fetched {len(all_markets)} markets from Gamma API (single request)")
for category in categories:
try:
markets = self.polymarket_source.get_trending_markets(category, limit=50)
all_markets.extend(markets)
logger.info(f"Fetched {len(markets)} markets from category: {category}")
except Exception as e:
logger.warning(f"Failed to fetch markets for category {category}: {e}")
# Deduplication (by market_id)
unique_markets = {}
cat_counts: Dict[str, int] = {}
for market in all_markets:
market_id = market.get('market_id')
if market_id:
unique_markets[market_id] = market
cat = market.get('category', 'other')
cat_counts[cat] = cat_counts.get(cat, 0) + 1
logger.info(f"Total unique markets: {len(unique_markets)}")
logger.info(f"Total unique markets: {len(unique_markets)}, by category: {cat_counts}")
# 2. Analyze the market in batches (analyze all markets at once, and use AI to screen opportunities)
markets_list = list(unique_markets.values())
@@ -28,6 +28,8 @@ from datetime import datetime, timezone
from email.message import EmailMessage
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
import requests
from app.utils.db import get_db_connection
@@ -78,6 +80,43 @@ def _signal_meta(signal_type: str) -> Dict[str, str]:
return {"action": action, "side": side, "type": st}
def _load_user_timezone_for_strategy(strategy_id: int) -> str:
try:
sid = int(strategy_id)
except Exception:
return ""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT COALESCE(u.timezone, '') AS tz
FROM qd_strategies_trading s
JOIN qd_users u ON u.id = s.user_id
WHERE s.id = ?
""",
(sid,),
)
row = cur.fetchone() or {}
cur.close()
return str(row.get("tz") or "").strip()
except Exception:
return ""
def _utc_ts_to_user_display(now: int, user_timezone: str) -> Tuple[str, str, str]:
"""Return (utc_iso, display_local_str, label_for_plaintext)."""
iso = datetime.fromtimestamp(int(now), tz=timezone.utc).isoformat()
utz = (user_timezone or "").strip()
if not utz:
return iso, iso, "Time (UTC)"
try:
dt = datetime.fromtimestamp(int(now), tz=timezone.utc).astimezone(ZoneInfo(utz))
return iso, dt.strftime("%Y-%m-%d %H:%M:%S"), f"Time ({utz})"
except Exception:
return iso, iso, "Time (UTC)"
def _fmt_float(value: Any, *, max_decimals: int = 10) -> str:
try:
v = float(value or 0.0)
@@ -150,6 +189,7 @@ class SignalNotifier:
targets = _safe_json(cfg.get("targets") or {})
extra = extra if isinstance(extra, dict) else {}
user_tz = _load_user_timezone_for_strategy(int(strategy_id))
payload = self._build_payload(
strategy_id=strategy_id,
strategy_name=strategy_name,
@@ -159,6 +199,7 @@ class SignalNotifier:
stake_amount=stake_amount,
direction=direction,
extra=extra,
user_timezone=user_tz,
)
rendered = self._render_messages(payload)
title = rendered.get("title") or ""
@@ -252,9 +293,10 @@ class SignalNotifier:
stake_amount: float,
direction: str,
extra: Dict[str, Any],
user_timezone: str = "",
) -> Dict[str, Any]:
now = int(time.time())
iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat()
iso, disp, tlab = _utc_ts_to_user_display(now, user_timezone)
meta = _signal_meta(signal_type)
pending_id = None
@@ -268,6 +310,8 @@ class SignalNotifier:
"version": 1,
"timestamp": now,
"timestamp_iso": iso,
"timestamp_display": disp,
"time_label": tlab,
"strategy": {
"id": int(strategy_id),
"name": str(strategy_name or ""),
@@ -310,6 +354,8 @@ class SignalNotifier:
pending_id = int(trace.get("pending_order_id") or 0) if trace.get("pending_order_id") else 0
mode = str(trace.get("mode") or "")
ts_iso = str(payload.get("timestamp_iso") or "")
ts_disp = str(payload.get("timestamp_display") or "") or ts_iso
ts_lbl = str(payload.get("time_label") or "Time")
plain_lines = [
"QuantDinger Signal",
@@ -323,8 +369,8 @@ class SignalNotifier:
plain_lines.append(f"PendingOrder: {pending_id}")
if mode:
plain_lines.append(f"Mode: {mode}")
if ts_iso:
plain_lines.append(f"Time(UTC): {ts_iso}")
if ts_disp:
plain_lines.append(f"{ts_lbl}: {ts_disp}")
# Telegram (HTML) message. Escape all dynamic values.
t_strategy = f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})"
@@ -341,8 +387,8 @@ class SignalNotifier:
telegram_lines.append(f"<b>PendingOrder</b>: <code>{pending_id}</code>")
if mode:
telegram_lines.append(f"<b>Mode</b>: <code>{html.escape(mode)}</code>")
if ts_iso:
telegram_lines.append(f"<b>Time (UTC)</b>: <code>{html.escape(ts_iso)}</code>")
if ts_disp:
telegram_lines.append(f"<b>{html.escape(ts_lbl)}</b>: <code>{html.escape(ts_disp)}</code>")
telegram_html = "\n".join([x for x in telegram_lines if x is not None])
# Email (HTML) message. Keep inline CSS for maximum compatibility.
@@ -355,7 +401,8 @@ class SignalNotifier:
stake_text=stake_s,
pending_id=pending_id or None,
mode_text=mode or "",
timestamp_iso=ts_iso or "",
timestamp_display=ts_disp or "",
time_row_label=ts_lbl or "Time",
)
return {
@@ -376,7 +423,8 @@ class SignalNotifier:
stake_text: str,
pending_id: Optional[int],
mode_text: str,
timestamp_iso: str,
timestamp_display: str,
time_row_label: str,
) -> str:
def esc(s: Any) -> str:
return html.escape(str(s or ""))
@@ -392,8 +440,8 @@ class SignalNotifier:
rows.append(("PendingOrder", str(int(pending_id))))
if mode_text:
rows.append(("Mode", mode_text))
if timestamp_iso:
rows.append(("Time (UTC)", timestamp_iso))
if timestamp_display:
rows.append((time_row_label or "Time", timestamp_display))
tr_html = "\n".join(
[
@@ -418,7 +466,7 @@ class SignalNotifier:
<div style="max-width:640px;margin:0 auto;padding:24px;">
<div style="background:#111827;color:#ffffff;padding:16px 18px;border-radius:12px 12px 0 0;">
<div style="font-size:16px;letter-spacing:0.2px;font-weight:600;">{esc(title_text)}</div>
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_iso) if timestamp_iso else ""}</div>
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_display) if timestamp_display else ""}</div>
</div>
<div style="background:#ffffff;border:1px solid #eaecef;border-top:0;border-radius:0 0 12px 12px;overflow:hidden;">
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
+36 -9
View File
@@ -489,16 +489,35 @@ class StrategyService:
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
)
msg = f"{msg} | {hint}"
hint_cn = (
"币安接口返回 -2015(密钥/IP/权限不匹配)。请逐项核对:"
"① API Key 是否勾选与当前测试一致的业务(现货选现货权限,合约选合约/U 本位权限);"
"② 若启用 IP 白名单,是否包含当前服务器出口 IP(见下方 egress_ip);"
"③ base_url 与密钥环境一致(主网密钥配 api.binance.com / fapi,模拟盘配 demo 域名与 demo Key);"
"④ 无多余空格、复制完整 Secret。"
)
if alt_ok:
hint_cn += (
f" 自动探测:同一密钥在 market_type={alt_market_type} 可通过,"
f"当前选择的 {market_type} 与密钥权限不一致的可能性很大。"
)
else:
hint_cn = ""
fail_payload = {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
}
if hint_cn:
fail_payload['hint_cn'] = hint_cn
return {
'success': False,
'message': f'Auth failed: {msg}',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
},
'data': fail_payload,
}
return {
@@ -960,12 +979,22 @@ class StrategyService:
leverage = (trading_config or {}).get('leverage') or existing.get('leverage') or 1
market_type = (trading_config or {}).get('market_type') or existing.get('market_type') or 'swap'
strategy_type = (payload.get('strategy_type') if payload.get('strategy_type') is not None
else existing.get('strategy_type')) or 'IndicatorStrategy'
strategy_mode = (payload.get('strategy_mode') if payload.get('strategy_mode') is not None
else existing.get('strategy_mode')) or 'signal'
if 'strategy_code' in payload:
strategy_code = payload.get('strategy_code') or ''
else:
strategy_code = existing.get('strategy_code') or ''
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
UPDATE qd_strategies_trading
SET strategy_name = ?,
strategy_type = ?,
strategy_mode = ?,
strategy_code = ?,
market_category = ?,
@@ -985,8 +1014,6 @@ class StrategyService:
""",
(
name,
strategy_mode,
strategy_code,
market_category,
execution_mode,
self._dump_json_or_encrypt(notification_config, encrypt=False),
@@ -0,0 +1,193 @@
"""
Python 策略脚本on_init / on_bar + ctx.buy/sell/close_position运行时
与回测逻辑对齐 TradingExecutor 实盘逐根 K 线调用
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
from app.utils.logger import get_logger
logger = get_logger(__name__)
class ScriptBar(dict):
def __getattr__(self, name: str) -> Any:
try:
return self[name]
except KeyError as exc:
raise AttributeError(name) from exc
class ScriptPosition(dict):
def __init__(self):
super().__init__()
self.clear_position()
def __getattr__(self, name: str) -> Any:
try:
return self[name]
except KeyError as exc:
raise AttributeError(name) from exc
def __bool__(self) -> bool:
return bool(self.get('side')) and float(self.get('size') or 0) > 0
def __int__(self) -> int:
return int(self.get('direction') or 0)
def __float__(self) -> float:
return float(self.get('direction') or 0)
def __eq__(self, other: Any) -> bool:
try:
return int(self) == int(other)
except Exception:
return dict.__eq__(self, other)
def __lt__(self, other: Any) -> bool:
return int(self) < int(other)
def __le__(self, other: Any) -> bool:
return int(self) <= int(other)
def __gt__(self, other: Any) -> bool:
return int(self) > int(other)
def __ge__(self, other: Any) -> bool:
return int(self) >= int(other)
def clear_position(self) -> None:
self.clear()
self.update({
'side': '',
'size': 0.0,
'entry_price': 0.0,
'direction': 0,
'amount': 0.0,
})
def open_position(self, side: str, entry_price: float, amount: float) -> None:
direction = 1 if side == 'long' else (-1 if side == 'short' else 0)
size = float(amount or 0.0)
price = float(entry_price or 0.0)
self.clear()
self.update({
'side': side,
'size': size,
'entry_price': price,
'direction': direction,
'amount': size,
})
def add_position(self, entry_price: float, amount: float) -> None:
extra = float(amount or 0.0)
if extra <= 0:
return
current_size = float(self.get('size') or 0.0)
current_price = float(self.get('entry_price') or 0.0)
next_size = current_size + extra
next_price = float(entry_price or current_price or 0.0)
if current_size > 0 and current_price > 0 and next_size > 0:
next_price = ((current_price * current_size) + (float(entry_price or current_price) * extra)) / next_size
self['size'] = next_size
self['amount'] = next_size
self['entry_price'] = next_price
class StrategyScriptContext:
"""与回测 ScriptBacktestContext 行为一致,供实盘按根推进。"""
def __init__(self, bars_df: pd.DataFrame, initial_balance: float):
self._bars_df = bars_df
self._params: Dict[str, Any] = {}
self._orders: List[Dict[str, Any]] = []
self._logs: List[str] = []
self.current_index = -1
self.position = ScriptPosition()
self.balance = float(initial_balance)
self.equity = float(initial_balance)
def param(self, name: str, default: Any = None) -> Any:
if name not in self._params:
self._params[name] = default
return self._params[name]
def bars(self, n: int = 1):
start = max(0, self.current_index - int(n) + 1)
out = []
for _, row in self._bars_df.iloc[start:self.current_index + 1].iterrows():
out.append(ScriptBar(
open=float(row.get('open') or 0),
high=float(row.get('high') or 0),
low=float(row.get('low') or 0),
close=float(row.get('close') or 0),
volume=float(row.get('volume') or 0),
timestamp=row.get('time')
))
return out
def log(self, message: Any):
self._logs.append(str(message))
def buy(self, price: Any = None, amount: Any = None):
self._orders.append({'action': 'buy', 'price': price, 'amount': amount})
def sell(self, price: Any = None, amount: Any = None):
self._orders.append({'action': 'sell', 'price': price, 'amount': amount})
def close_position(self):
self._orders.append({'action': 'close'})
def compile_strategy_script_handlers(code: str) -> Tuple[Optional[Callable], Optional[Callable]]:
"""
校验并编译策略脚本返回 (on_init, on_bar)
on_bar 不可缺省on_init 可选
"""
if not code or not str(code).strip():
raise ValueError("Strategy script is empty")
import builtins
def safe_import(name, *args, **kwargs):
allowed_modules = ['numpy', 'pandas', 'math', 'json', 'datetime', 'time']
if name in allowed_modules or name.split('.')[0] in allowed_modules:
return builtins.__import__(name, *args, **kwargs)
raise ImportError(f"Import not allowed: {name}")
safe_builtins = {k: getattr(builtins, k) for k in dir(builtins)
if not k.startswith('_') and k not in ['eval', 'exec', 'compile', 'open', 'input', 'help', 'exit', 'quit']}
safe_builtins['__import__'] = safe_import
exec_env = {
'__builtins__': safe_builtins,
'np': np,
'pd': pd,
}
from app.utils.safe_exec import validate_code_safety, safe_exec_code
is_safe, error_msg = validate_code_safety(code)
if not is_safe:
raise ValueError(f"Code contains unsafe operations: {error_msg}")
exec_result = safe_exec_code(
code=code,
exec_globals=exec_env,
exec_locals=exec_env,
timeout=60
)
if not exec_result['success']:
raise RuntimeError(f"Code execution failed: {exec_result.get('error')}")
on_init = exec_env.get('on_init')
on_bar = exec_env.get('on_bar')
if not callable(on_bar):
raise ValueError("Strategy script must define on_bar(ctx, bar)")
if on_init is not None and not callable(on_init):
on_init = None
return (on_init if callable(on_init) else None), on_bar
@@ -135,14 +135,29 @@ class StrategySnapshotResolver:
timeframe = str(override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D").strip() or "1D"
initial_capital = self._to_float(override.get("initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000))), 10000.0)
leverage = self._to_int(override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1)
commission = self._percent_to_ratio(override.get("commission", trading_config.get("commission", 0)))
slippage = self._percent_to_ratio(override.get("slippage", trading_config.get("slippage", 0)))
# Commission/slippage are backtest-only assumptions (not used by live ScriptStrategy execution).
# Script strategies created from the UI may omit these; apply sensible backtest defaults.
commission_raw = override.get("commission")
if commission_raw is None:
commission_raw = trading_config.get("commission")
slippage_raw = override.get("slippage")
if slippage_raw is None:
slippage_raw = trading_config.get("slippage")
strategy_type_early = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
strategy_mode_early = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
is_script_early = strategy_type_early == "ScriptStrategy" or strategy_mode_early == "script"
if commission_raw is None or commission_raw == "":
commission_raw = 0.05 if is_script_early else 0
if slippage_raw is None or slippage_raw == "":
slippage_raw = 0.0
commission = self._percent_to_ratio(commission_raw)
slippage = self._percent_to_ratio(slippage_raw)
trade_direction = str(trading_config.get("trade_direction") or "long").strip().lower() or "long"
enable_mtf = self._to_bool(override.get("enableMtf", market.lower() == "crypto"))
strategy_type = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
strategy_mode = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
is_script = strategy_type == "ScriptStrategy" or strategy_mode == "script"
strategy_type = strategy_type_early
strategy_mode = strategy_mode_early
is_script = is_script_early
indicator_id = indicator_config.get("indicator_id") or strategy.get("indicator_id")
indicator_name = indicator_config.get("indicator_name") or ""
@@ -21,6 +21,7 @@ import requests
from app.utils.logger import get_logger
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
from app.data_sources.tencent import normalize_cn_code, normalize_hk_code
logger = get_logger(__name__)
@@ -28,6 +29,10 @@ logger = get_logger(__name__)
def _normalize_symbol_for_market(market: str, symbol: str) -> str:
m = (market or '').strip()
s = (symbol or '').strip().upper()
if m == 'CNStock':
return normalize_cn_code(s)
if m == 'HKStock':
return normalize_hk_code(s)
return s
@@ -105,6 +110,17 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
# otherwise fall back to yfinance.
return _resolve_name_from_finnhub(s) or _resolve_name_from_yfinance(s)
# CN/HK stocks: try Tencent quote name first (no key), then yfinance best-effort.
if m in ('CNStock', 'HKStock'):
try:
from app.data_sources.tencent import fetch_quote
parts = fetch_quote(s)
if parts and len(parts) > 1 and parts[1]:
return str(parts[1]).strip()
except Exception:
pass
return _resolve_name_from_yfinance(s)
# Crypto: at least return base ticker-like display (not a "company", but better than empty)
if m == 'Crypto':
if '/' in s:
@@ -1,5 +1,9 @@
"""
Real-time trade execution services
Real-time trade execution service.
Strategy thread: Generates candlestick charts/prices, calculates signals, and writes orders to pending_orders.
Live trades are executed via PendingOrderWorker + app.services.live_trading (direct REST connection to each exchange); ccxt is not used for order placement in this module.
"""
import time
import threading
@@ -15,13 +19,18 @@ import json
from decimal import Decimal, ROUND_DOWN, ROUND_UP
import pandas as pd
import numpy as np
import ccxt
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
from app.utils.strategy_runtime_logs import append_strategy_log
from app.data_sources import DataSourceFactory
from app.services.kline import KlineService
from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller
from app.services.strategy_script_runtime import (
ScriptBar,
StrategyScriptContext,
compile_strategy_script_handlers,
)
logger = get_logger(__name__)
@@ -45,6 +54,8 @@ class TradingExecutor:
self._signal_dedup = {} # type: Dict[int, Dict[str, float]]
self._signal_dedup_lock = threading.Lock()
self.kline_service = KlineService() # K-line service (with cache)
# Throttle writes to qd_strategy_logs (heartbeat), per strategy_id -> monotonic time
self._strategy_ui_log_last_tick_ts = {} # type: Dict[int, float]
# The upper limit of single-instance threads to avoid unlimited thread creation causing can't start new thread/OOM
self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64'))
@@ -422,6 +433,7 @@ class TradingExecutor:
logger.info(f"Strategy {strategy_id} started")
self._console_print(f"[strategy:{strategy_id}] started")
append_strategy_log(strategy_id, "info", "策略执行线程已启动")
return True
except Exception as e:
@@ -460,12 +472,219 @@ class TradingExecutor:
logger.info(f"Strategy {strategy_id} stopped")
self._console_print(f"[strategy:{strategy_id}] stopped (requested)")
append_strategy_log(strategy_id, "info", "已请求停止策略(运行标志已清除)")
return True
except Exception as e:
logger.error(f"Failed to stop strategy {strategy_id}: {str(e)}")
logger.error(traceback.format_exc())
return False
def _df_to_script_exec_df(self, df: pd.DataFrame) -> pd.DataFrame:
out = df.reset_index()
c0 = out.columns[0]
if c0 != 'time':
out.rename(columns={c0: 'time'}, inplace=True)
return out
def _script_default_position_ratio(self, trading_config: Dict[str, Any]) -> float:
try:
ep = (trading_config or {}).get('entry_pct')
if ep is not None:
return float(self._to_ratio(ep, default=0.06))
except Exception:
pass
return 0.06
def _hydrate_script_ctx_from_positions(self, ctx: StrategyScriptContext, strategy_id: int, symbol: str) -> None:
ctx.position.clear_position()
pl = self._get_current_positions(strategy_id, symbol)
if not pl:
return
p = pl[0]
side = (p.get('side') or 'long').strip().lower()
if side not in ('long', 'short'):
return
size = float(p.get('size') or 0)
ep = float(p.get('entry_price') or 0)
if size > 0:
ctx.position.open_position(side, ep, size)
def _init_script_strategy_context(
self,
strategy_id: int,
df: pd.DataFrame,
trading_config: Dict[str, Any],
initial_capital: float,
) -> Tuple[StrategyScriptContext, Optional[pd.Timestamp]]:
df_exec = self._df_to_script_exec_df(df)
ctx = StrategyScriptContext(df_exec, float(initial_capital or 0))
raw = (trading_config or {}).get('script_runtime_state') or {}
params = raw.get('params') if isinstance(raw, dict) else {}
if isinstance(params, dict):
ctx._params = dict(params)
last_ts = None
ts_s = raw.get('last_closed_bar_ts') if isinstance(raw, dict) else None
if ts_s:
try:
last_ts = pd.Timestamp(ts_s)
if last_ts.tzinfo is None:
last_ts = last_ts.tz_localize('UTC')
else:
last_ts = last_ts.tz_convert('UTC')
except Exception:
last_ts = None
return ctx, last_ts
def _persist_script_runtime_state(self, strategy_id: int, closed_ts: Any, params: Dict[str, Any]) -> None:
try:
safe_params = json.loads(json.dumps(params or {}, default=str))
except Exception:
safe_params = {}
ts_str = ''
try:
if closed_ts is not None:
ts_str = pd.Timestamp(closed_ts).isoformat()
except Exception:
ts_str = ''
state = {'last_closed_bar_ts': ts_str, 'params': safe_params}
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT trading_config FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cur.fetchone()
if not row:
cur.close()
return
tc = row.get('trading_config')
if isinstance(tc, str) and tc.strip():
try:
tc = json.loads(tc)
except Exception:
tc = {}
elif not isinstance(tc, dict):
tc = {}
tc['script_runtime_state'] = state
cur.execute(
"UPDATE qd_strategies_trading SET trading_config = %s WHERE id = %s",
(json.dumps(tc, ensure_ascii=False), strategy_id),
)
db.commit()
cur.close()
except Exception as e:
logger.warning(f"Persist script runtime state failed: {e}")
def _script_orders_to_execution_signals(
self,
ctx: StrategyScriptContext,
trade_direction: str,
bar_close: float,
closed_ts: pd.Timestamp,
trading_config: Dict[str, Any],
) -> List[Dict[str, Any]]:
td = str(trade_direction or 'both').lower()
if td not in ('long', 'short', 'both'):
td = 'both'
default_ratio = self._script_default_position_ratio(trading_config)
try:
ts_i = int(closed_ts.timestamp())
except Exception:
ts_i = int(time.time())
out: List[Dict[str, Any]] = []
trig = float(bar_close or 0)
for order in list(ctx._orders or []):
action = str(order.get('action') or '').lower()
try:
order_price = float(order.get('price') or bar_close or 0)
except Exception:
order_price = trig
raw_amt = order.get('amount')
pos_ratio = default_ratio
if raw_amt is not None:
try:
v = float(raw_amt)
if v > 0:
pos_ratio = v
except Exception:
pass
if action == 'close':
if ctx.position > 0:
out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
elif ctx.position < 0:
out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
continue
if action == 'buy':
if ctx.position < 0:
out.append({'type': 'close_short', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
if td in ('long', 'both'):
if ctx.position == 0:
out.append({'type': 'open_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.open_position('long', order_price or trig, pos_ratio)
else:
out.append({'type': 'add_long', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.add_position(order_price or trig, pos_ratio)
continue
if action == 'sell':
if ctx.position > 0:
out.append({'type': 'close_long', 'trigger_price': order_price or trig, 'position_size': 0, 'timestamp': ts_i})
ctx.position.clear_position()
if td in ('short', 'both'):
if ctx.position == 0:
out.append({'type': 'open_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.open_position('short', order_price or trig, pos_ratio)
else:
out.append({'type': 'add_short', 'trigger_price': order_price or trig, 'position_size': pos_ratio, 'timestamp': ts_i})
ctx.position.add_position(order_price or trig, pos_ratio)
return out
def _script_evaluate_new_closed_bar(
self,
df: pd.DataFrame,
ctx: StrategyScriptContext,
on_bar,
trade_direction: str,
last_closed_ts: Optional[pd.Timestamp],
strategy_id: int,
symbol: str,
trading_config: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], Optional[pd.Timestamp]]:
if df is None or len(df) < 2:
return [], last_closed_ts
closed_ts = df.index[-2]
try:
if last_closed_ts is not None and closed_ts <= last_closed_ts:
return [], last_closed_ts
except Exception:
pass
df_exec = self._df_to_script_exec_df(df)
ctx._bars_df = df_exec
pos = len(df) - 2
ctx.current_index = int(pos)
row = df_exec.iloc[pos]
self._hydrate_script_ctx_from_positions(ctx, strategy_id, symbol)
ctx._orders = []
bar = ScriptBar(
open=float(row.get('open') or 0),
high=float(row.get('high') or 0),
low=float(row.get('low') or 0),
close=float(row.get('close') or 0),
volume=float(row.get('volume') or 0),
timestamp=row.get('time'),
)
try:
on_bar(ctx, bar)
except Exception as e:
logger.error(f"Strategy {strategy_id} script on_bar error: {e}")
logger.error(traceback.format_exc())
return [], last_closed_ts
bar_close = float(row.get('close') or 0)
pending = self._script_orders_to_execution_signals(ctx, trade_direction, bar_close, closed_ts, trading_config)
self._persist_script_runtime_state(strategy_id, closed_ts, ctx._params)
logger.info(f"Strategy {strategy_id} script closed bar {closed_ts} -> {len(pending)} signal(s)")
return pending, closed_ts
def _run_strategy_loop(self, strategy_id: int):
"""
@@ -484,13 +703,15 @@ class TradingExecutor:
logger.error(f"Strategy {strategy_id} not found")
return
if strategy['strategy_type'] != 'IndicatorStrategy':
logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {strategy['strategy_type']}")
stype = strategy.get('strategy_type') or ''
if stype not in ('IndicatorStrategy', 'ScriptStrategy'):
logger.error(f"Strategy {strategy_id} has unsupported strategy_type for realtime execution: {stype}")
return
is_script = stype == 'ScriptStrategy'
# Initialize policy state
trading_config = strategy['trading_config']
indicator_config = strategy['indicator_config']
indicator_config = strategy.get('indicator_config') or {}
ai_model_config = strategy.get('ai_model_config') or {}
execution_mode = (strategy.get('execution_mode') or 'signal').strip().lower()
if execution_mode not in ['signal', 'live']:
@@ -567,47 +788,78 @@ class TradingExecutor:
if isinstance(initial_capital_val, (list, tuple)):
initial_capital_val = initial_capital_val[0] if initial_capital_val else 1000
initial_capital = float(initial_capital_val)
except:
except Exception:
logger.warning(f"Strategy {strategy_id} invalid initial_capital format, reset to 1000: {strategy.get('initial_capital')}")
initial_capital = 1000.0
# Equity is automatically calculated and updated the first time a position is updated
# Get indicator code
indicator_id = indicator_config.get('indicator_id')
indicator_code = indicator_config.get('indicator_code', '')
# If the code is empty, try to get it from the database
if not indicator_code and indicator_id:
indicator_code = self._get_indicator_code_from_db(indicator_id)
if not indicator_code:
logger.error(f"Strategy {strategy_id} indicator_code is empty")
return
# Make sure indicator_code is a string (to handle JSON escaping issues)
if not isinstance(indicator_code, str):
indicator_code = str(indicator_code)
# Handle possible JSON escaping issues
if '\\n' in indicator_code and '\n' not in indicator_code:
indicator_id = None
indicator_code = ''
strategy_code = ''
on_init_script = None
on_bar_script = None
if is_script:
strategy_code = (strategy.get('strategy_code') or '').strip()
if not strategy_code:
logger.error(f"Strategy {strategy_id} strategy_code is empty")
return
if '\\n' in strategy_code and '\n' not in strategy_code:
try:
decoded = json.loads(f'"{strategy_code}"')
if isinstance(decoded, str):
strategy_code = decoded
except Exception:
strategy_code = (
strategy_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
)
try:
import json
decoded = json.loads(f'"{indicator_code}"')
if isinstance(decoded, str):
indicator_code = decoded
logger.info(f"Strategy {strategy_id} decoded escaped indicator_code")
on_init_script, on_bar_script = compile_strategy_script_handlers(strategy_code)
except Exception as e:
logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}")
indicator_code = (
indicator_code
.replace('\\n', '\n')
.replace('\\t', '\t')
.replace('\\r', '\r')
.replace('\\"', '"')
.replace("\\'", "'")
.replace('\\\\', '\\')
)
logger.error(f"Strategy {strategy_id} script compile failed: {e}")
logger.error(traceback.format_exc())
return
else:
indicator_config = strategy['indicator_config']
indicator_id = indicator_config.get('indicator_id')
indicator_code = indicator_config.get('indicator_code', '')
if not indicator_code and indicator_id:
indicator_code = self._get_indicator_code_from_db(indicator_id)
if not indicator_code:
logger.error(f"Strategy {strategy_id} indicator_code is empty")
return
if not isinstance(indicator_code, str):
indicator_code = str(indicator_code)
if '\\n' in indicator_code and '\n' not in indicator_code:
try:
decoded = json.loads(f'"{indicator_code}"')
if isinstance(decoded, str):
indicator_code = decoded
logger.info(f"Strategy {strategy_id} decoded escaped indicator_code")
except Exception as e:
logger.warning(f"Strategy {strategy_id} JSON decode failed; falling back to manual unescape: {str(e)}")
indicator_code = (
indicator_code.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r')
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
)
# Check if this is a cross-sectional strategy
cs_strategy_type = trading_config.get('cs_strategy_type', 'single')
if (not is_script) and cs_strategy_type == 'cross_sectional':
self._run_cross_sectional_strategy_loop(
strategy_id, strategy, trading_config, strategy['indicator_config'],
ai_model_config, execution_mode, notification_config,
strategy_name, market_category, market_type, leverage,
initial_capital, indicator_code, indicator_id
)
return
if is_script and cs_strategy_type == 'cross_sectional':
logger.error(f"Strategy {strategy_id} ScriptStrategy does not support cross_sectional mode")
return
# Initialize the exchange connection (no real connection is required in signal mode)
exchange = None
# ============================================
# Initialization phase: Obtain historical K-lines and calculate indicators
@@ -665,26 +917,50 @@ class TradingExecutor:
f"position={initial_position}, entry_price={initial_avg_entry_price}, highest={initial_highest}"
)
# Execute indicator code, get signals and trigger prices
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result is None:
logger.error(f"Strategy {strategy_id} indicator execution failed")
return
# Extract signals and trigger prices
pending_signals = indicator_result.get('pending_signals', []) # List of signals to be triggered
last_kline_time = indicator_result.get('last_kline_time', 0) # The time of the last K-line
script_ctx = None
last_script_closed_ts = None
if is_script:
script_ctx, last_script_closed_ts = self._init_script_strategy_context(
strategy_id, df, trading_config, initial_capital
)
if on_init_script:
self._hydrate_script_ctx_from_positions(script_ctx, strategy_id, symbol)
try:
on_init_script(script_ctx)
except Exception as e:
logger.error(f"Strategy {strategy_id} on_init error: {e}")
logger.error(traceback.format_exc())
pending_signals, last_script_closed_ts = self._script_evaluate_new_closed_bar(
df, script_ctx, on_bar_script, trade_direction,
last_script_closed_ts, strategy_id, symbol, trading_config,
)
try:
last_kline_time = int(df.index[-1].timestamp())
except Exception:
last_kline_time = int(time.time())
else:
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result is None:
logger.error(f"Strategy {strategy_id} indicator execution failed")
return
pending_signals = indicator_result.get('pending_signals', [])
last_kline_time = indicator_result.get('last_kline_time', 0)
logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}")
if pending_signals:
logger.info(f"Initial signals: {pending_signals}")
append_strategy_log(
strategy_id,
"info",
f"Live trading cycle ready {symbol} {timeframe}Signal to be processed {len(pending_signals or [])} strip",
)
# ============================================
# Main loop: unified tick cadence (default: 10s)
@@ -745,36 +1021,48 @@ class TradingExecutor:
if klines and len(klines) >= 2:
df = self._klines_to_dataframe(klines)
if len(df) > 0:
current_pos_list = self._get_current_positions(strategy_id, symbol)
initial_highest = 0.0
initial_position = 0
initial_avg_entry_price = 0.0
initial_position_count = 0
initial_last_add_price = 0.0
if current_pos_list:
pos = current_pos_list[0]
initial_highest = float(pos.get('highest_price', 0) or 0)
pos_side = pos.get('side', 'long')
initial_position = 1 if pos_side == 'long' else -1
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
initial_position_count = 1
initial_last_add_price = initial_avg_entry_price
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result:
pending_signals = indicator_result.get('pending_signals', [])
last_kline_time = indicator_result.get('last_kline_time', 0)
new_hp = indicator_result.get('new_highest_price', 0)
if is_script:
new_sig, last_script_closed_ts = self._script_evaluate_new_closed_bar(
df, script_ctx, on_bar_script, trade_direction,
last_script_closed_ts, strategy_id, symbol, trading_config,
)
pending_signals = new_sig
try:
last_kline_time = int(df.index[-1].timestamp())
except Exception:
last_kline_time = int(time.time())
last_kline_update_time = current_time
else:
current_pos_list = self._get_current_positions(strategy_id, symbol)
initial_highest = 0.0
initial_position = 0
initial_avg_entry_price = 0.0
initial_position_count = 0
initial_last_add_price = 0.0
if current_pos_list:
pos = current_pos_list[0]
initial_highest = float(pos.get('highest_price', 0) or 0)
pos_side = pos.get('side', 'long')
initial_position = 1 if pos_side == 'long' else -1
initial_avg_entry_price = float(pos.get('entry_price', 0) or 0)
initial_position_count = 1
initial_last_add_price = initial_avg_entry_price
indicator_result = self._execute_indicator_with_prices(
indicator_code, df, trading_config,
initial_highest_price=initial_highest,
initial_position=initial_position,
initial_avg_entry_price=initial_avg_entry_price,
initial_position_count=initial_position_count,
initial_last_add_price=initial_last_add_price
)
if indicator_result:
pending_signals = indicator_result.get('pending_signals', [])
last_kline_time = indicator_result.get('last_kline_time', 0)
new_hp = indicator_result.get('new_highest_price', 0)
last_kline_update_time = current_time
# Update highest_price (using latest close as an approximation of current_price)
if new_hp > 0 and current_pos_list:
@@ -788,9 +1076,9 @@ class TradingExecutor:
)
else:
# ============================================
# 3. Non-K-line update tick: update the last K-line with the current price and recalculate the indicator (unify the tick rhythm)
# 3. Non-K-line update tick: The script strategy is not recalculated here (only on_bar at the close of a new K-line).
# ============================================
if 'df' in locals() and df is not None and len(df) > 0:
if (not is_script) and 'df' in locals() and df is not None and len(df) > 0:
try:
realtime_df = df.copy()
realtime_df = self._update_dataframe_with_current_price(realtime_df, current_price, timeframe)
@@ -999,6 +1287,11 @@ class TradingExecutor:
)
if ok:
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
append_strategy_log(
strategy_id,
"signal",
f"已提交信号 {signal_type} 参考价 {float(execute_price or 0):.6f}",
)
# Notify portfolio positions linked to this symbol
try:
from app.services.portfolio_monitor import notify_strategy_signal_for_positions
@@ -1012,6 +1305,11 @@ class TradingExecutor:
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
else:
logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}")
append_strategy_log(
strategy_id,
"error",
f"信号未执行或拒单: {signal_type}",
)
# Update positions once per tick.
self._update_positions(strategy_id, symbol, current_price)
@@ -1020,17 +1318,37 @@ class TradingExecutor:
self._console_print(
f"[strategy:{strategy_id}] tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}"
)
try:
nowl = time.time()
lastl = float(self._strategy_ui_log_last_tick_ts.get(strategy_id) or 0.0)
if nowl - lastl >= 55.0:
self._strategy_ui_log_last_tick_ts[strategy_id] = nowl
append_strategy_log(
strategy_id,
"info",
f"tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}",
)
except Exception:
pass
except Exception as e:
logger.error(f"Strategy {strategy_id} loop error: {str(e)}")
logger.error(traceback.format_exc())
self._console_print(f"[strategy:{strategy_id}] loop error: {e}")
try:
append_strategy_log(strategy_id, "error", f"循环异常: {e}")
except Exception:
pass
time.sleep(5)
except Exception as e:
logger.error(f"Strategy {strategy_id} crashed: {str(e)}")
logger.error(traceback.format_exc())
self._console_print(f"[strategy:{strategy_id}] fatal error: {e}")
try:
append_strategy_log(strategy_id, "error", f"策略线程致命错误: {e}")
except Exception:
pass
finally:
# clean up
with self.lock:
@@ -1038,6 +1356,10 @@ class TradingExecutor:
del self.running_strategies[strategy_id]
self._console_print(f"[strategy:{strategy_id}] loop exited")
logger.info(f"Strategy {strategy_id} loop exited")
try:
append_strategy_log(strategy_id, "info", "策略执行循环已退出")
except Exception:
pass
def _sync_positions_with_exchange(self, strategy_id: int, exchange: Any, symbol: str, market_type: str):
"""
@@ -1056,7 +1378,7 @@ class TradingExecutor:
initial_capital, leverage, decide_interval,
execution_mode, notification_config,
indicator_config, exchange_config, trading_config, ai_model_config,
market_category
market_category, strategy_code
FROM qd_strategies_trading
WHERE id = %s
"""
@@ -1145,8 +1467,14 @@ class TradingExecutor:
market_type: str = None,
leverage: float = None,
strategy_id: int = None
) -> Optional[ccxt.Exchange]:
"""(Mock) Signal mode does not require a real exchange connection."""
) -> Any:
"""
Placeholder: No exchange SDK instance is created within the strategy thread.
Live orders are not processed through this method. Signals are written to pending orders via `_execute_exchange_order`,
and executed by the PendingOrderWorker using a direct-connect REST client under `app.services.live_trading`.
Candlestick charts/current prices are provided by data layers such as `KlineService` and `DataSourceFactory` (this layer may use ccxt to display market data, decoupled from order placement).
"""
return None
def _fetch_latest_kline(self, symbol: str, timeframe: str, limit: int = 500, market_category: str = 'Crypto') -> List[Dict[str, Any]]:
@@ -2435,14 +2763,13 @@ class TradingExecutor:
signal_ts: int = 0,
) -> Optional[Dict[str, Any]]:
"""
Convert a signal into a concrete pending order and enqueue it into DB.
将信号转为 pending_orders 队列记录本方法不直连交易所不使用 ccxt
A separate worker will poll `pending_orders` and dispatch:
- execution_mode='signal': dispatch notifications (no real trading).
- execution_mode='live': reserved for future live trading execution (not implemented).
PendingOrderWorker 轮询执行
- execution_mode='signal'仅通知/模拟路径
- execution_mode='live'通过 live_trading 包内的各交易所 REST 客户端下单 ccxt
Note: Order execution settings (order_mode, maker_wait_sec, maker_offset_bps) are now
configured via environment variables and not passed from strategy config.
行情/K 线不在此处拉取order_mode 等由环境变量配置
"""
try:
# Reference price at enqueue time: use current tick price if provided to avoid extra fetch.
@@ -116,6 +116,7 @@ def load_addon_config() -> Dict[str, Any]:
('AKSHARE_TIMEOUT', 'akshare.timeout', 'int'),
('TIINGO_API_KEY', 'tiingo.api_key', 'string'),
('TIINGO_TIMEOUT', 'tiingo.timeout', 'int'),
('TWELVE_DATA_API_KEY', 'twelve_data.api_key', 'string'),
# Search (Google CSE / Bing)
('SEARCH_PROVIDER', 'search.provider', 'string'),
@@ -0,0 +1,29 @@
"""Persist strategy runtime lines for the strategy management UI (`qd_strategy_logs`)."""
from __future__ import annotations
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
logger = get_logger(__name__)
def append_strategy_log(strategy_id: int, level: str, message: str) -> None:
"""Best-effort insert; never raises to caller."""
try:
sid = int(strategy_id)
lv = (level or "info").strip().lower()[:20]
msg = str(message or "").strip()
if not msg:
return
msg = msg[:8000]
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"INSERT INTO qd_strategy_logs (strategy_id, level, message) VALUES (?, ?, ?)",
(sid, lv, msg),
)
db.commit()
cur.close()
except Exception as e:
logger.debug("append_strategy_log skip: %s", e)
+13
View File
@@ -69,6 +69,15 @@ SMTP_USE_SSL=false
# PROXY_URL=socks5h://host.docker.internal:10808 # Docker
PROXY_URL=
# Outbound HTTPS to exchanges when using PROXY_URL (especially SOCKS). If you see
# SSLCertVerificationError / "unable to get local issuer certificate":
# - Prefer: install OS ca-certificates in the image, or point to a PEM bundle:
# LIVE_TRADING_CA_BUNDLE=/path/to/ca-bundle.pem
# (REQUESTS_CA_BUNDLE / SSL_CERT_FILE are also honored.)
# - Last resort only: LIVE_TRADING_SSL_VERIFY=false # disables TLS verify — insecure
#LIVE_TRADING_CA_BUNDLE=
#LIVE_TRADING_SSL_VERIFY=
# =========================
# Captcha / OAuth (optional)
# =========================
@@ -160,6 +169,10 @@ YFINANCE_TIMEOUT=30
TIINGO_API_KEY=
TIINGO_TIMEOUT=10
# Twelve Data (CN/HK stock K-lines — recommended for overseas servers)
# Free tier: 800 API credits/day, 8 requests/minute. https://twelvedata.com
TWELVE_DATA_API_KEY=
# AI search / news
SEARCH_PROVIDER=google
SEARCH_MAX_RESULTS=10
+38 -1
View File
@@ -349,6 +349,21 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON qd_strategy_notification
CREATE INDEX IF NOT EXISTS idx_notifications_strategy_id ON qd_strategy_notifications(strategy_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON qd_strategy_notifications(is_read);
-- =============================================================================
-- 6b. Strategy runtime logs (dashboard / API)
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategy_logs (
id SERIAL PRIMARY KEY,
strategy_id INTEGER NOT NULL REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
level VARCHAR(20) DEFAULT 'info',
message TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_strategy_logs_strategy_id ON qd_strategy_logs(strategy_id);
CREATE INDEX IF NOT EXISTS idx_strategy_logs_timestamp ON qd_strategy_logs(timestamp);
-- =============================================================================
-- 7. Indicator Codes
-- =============================================================================
@@ -647,7 +662,29 @@ INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_acti
('Futures', 'ZS', 'Soybeans', 'CBOT', 'USD', 1, 1, 94),
('Futures', 'ZW', 'Wheat', 'CBOT', 'USD', 1, 1, 93),
('Futures', 'ES', 'S&P 500 E-mini', 'CME', 'USD', 1, 1, 92),
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91)
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91),
-- A股 (CNStock)
('CNStock', '600519', '贵州茅台', 'SSE', 'CNY', 1, 1, 100),
('CNStock', '600036', '招商银行', 'SSE', 'CNY', 1, 1, 99),
('CNStock', '601318', '中国平安', 'SSE', 'CNY', 1, 1, 98),
('CNStock', '600900', '长江电力', 'SSE', 'CNY', 1, 1, 97),
('CNStock', '601899', '紫金矿业', 'SSE', 'CNY', 1, 1, 96),
('CNStock', '000858', '五粮液', 'SZSE', 'CNY', 1, 1, 95),
('CNStock', '000333', '美的集团', 'SZSE', 'CNY', 1, 1, 94),
('CNStock', '002594', '比亚迪', 'SZSE', 'CNY', 1, 1, 93),
('CNStock', '300750', '宁德时代', 'SZSE', 'CNY', 1, 1, 92),
('CNStock', '000001', '平安银行', 'SZSE', 'CNY', 1, 1, 91),
-- 港股/H股 (HKStock)
('HKStock', '00700', '腾讯控股', 'HKEX', 'HKD', 1, 1, 100),
('HKStock', '09988', '阿里巴巴-W', 'HKEX', 'HKD', 1, 1, 99),
('HKStock', '03690', '美团-W', 'HKEX', 'HKD', 1, 1, 98),
('HKStock', '01810', '小米集团-W', 'HKEX', 'HKD', 1, 1, 97),
('HKStock', '00939', '建设银行', 'HKEX', 'HKD', 1, 1, 96),
('HKStock', '01299', '友邦保险', 'HKEX', 'HKD', 1, 1, 95),
('HKStock', '02318', '中国平安', 'HKEX', 'HKD', 1, 1, 94),
('HKStock', '00388', '香港交易所', 'HKEX', 'HKD', 1, 1, 93),
('HKStock', '00883', '中国海洋石油', 'HKEX', 'HKD', 1, 1, 92),
('HKStock', '01398', '工商银行', 'HKEX', 'HKD', 1, 1, 91)
ON CONFLICT (market, symbol) DO NOTHING;
-- =============================================================================
+1
View File
@@ -5,6 +5,7 @@ yfinance>=0.2.18
ccxt>=4.0.0
pandas>=1.5.0
requests>=2.28.0
certifi>=2024.2.2
PySocks>=1.7.1
akshare>=1.12.0
pymysql>=1.0.2