@@ -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 \
|
||||
|
||||
@@ -18,6 +18,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)
|
||||
@@ -49,19 +49,17 @@ class PolymarketDataSource:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 从真实API获取 - 获取多个分类的数据以确保多样性
|
||||
# 从真实API获取
|
||||
all_markets = []
|
||||
|
||||
if category and category != "all":
|
||||
# 如果指定了类别,只获取该类别的数据
|
||||
# 获取所有事件,然后按类别过滤
|
||||
markets = self._fetch_markets_from_api(category, limit * 2)
|
||||
all_markets.extend(markets)
|
||||
else:
|
||||
# 如果没有指定类别或指定了"all",获取多个分类的数据
|
||||
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)
|
||||
# 获取所有事件(不指定类别,避免重复请求)
|
||||
markets = self._fetch_from_gamma_api(category=None, limit=100)
|
||||
all_markets.extend(markets)
|
||||
|
||||
# 去重(按market_id)
|
||||
seen = set()
|
||||
|
||||
@@ -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) # 昨收价
|
||||
}
|
||||
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}")
|
||||
|
||||
# 降级使用 yfinance
|
||||
try:
|
||||
@@ -260,7 +264,12 @@ class USStockDataSource(BaseDataSource):
|
||||
))
|
||||
# logger.info(f"Finnhub 返回 {len(klines)} 条数据")
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -350,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
|
||||
@@ -472,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):
|
||||
@@ -581,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:
|
||||
@@ -599,6 +742,21 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
|
||||
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":
|
||||
@@ -709,7 +867,7 @@ def _fetch_exchange_positions_raw(
|
||||
"""
|
||||
Fetch raw position payload for quick-trade / close-position.
|
||||
|
||||
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bybit, Bitfinex),
|
||||
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
|
||||
@@ -747,7 +905,8 @@ def _fetch_exchange_positions_raw(
|
||||
return client.get_positions(product_type=pt, symbol=symbol)
|
||||
|
||||
if isinstance(client, BybitClient):
|
||||
raw = client.get_positions()
|
||||
# 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
|
||||
@@ -765,8 +924,25 @@ def _fetch_exchange_positions_raw(
|
||||
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]
|
||||
return filtered
|
||||
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()
|
||||
@@ -782,7 +958,37 @@ def _fetch_exchange_positions_raw(
|
||||
return {"data": filtered}
|
||||
|
||||
if isinstance(client, HtxClient):
|
||||
return client.get_positions(symbol=symbol)
|
||||
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)
|
||||
@@ -860,6 +1066,21 @@ def _parse_positions(raw: Any) -> list:
|
||||
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
|
||||
@@ -876,6 +1097,7 @@ def _parse_positions(raw: Any) -> list:
|
||||
or item.get("bal")
|
||||
or item.get("availBal")
|
||||
or item.get("volume")
|
||||
or item.get("current_qty")
|
||||
or 0
|
||||
)
|
||||
if abs(size) < 1e-10:
|
||||
@@ -910,11 +1132,12 @@ 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("entry_price")
|
||||
or item.get("openPriceAvg")
|
||||
or item.get("avgEntryPrice")
|
||||
or item.get("avgPrice")
|
||||
@@ -928,15 +1151,17 @@ def _parse_positions(raw: Any) -> list:
|
||||
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 1),
|
||||
"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")
|
||||
@@ -949,6 +1174,48 @@ def _parse_positions(raw: Any) -> list:
|
||||
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():
|
||||
@@ -960,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:
|
||||
@@ -971,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:
|
||||
@@ -1003,29 +1277,80 @@ def close_position():
|
||||
|
||||
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":
|
||||
@@ -1111,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",
|
||||
},
|
||||
})
|
||||
@@ -1118,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)'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -15,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
|
||||
|
||||
@@ -662,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
|
||||
@@ -678,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:
|
||||
@@ -1391,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(
|
||||
@@ -1411,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
|
||||
@@ -1830,7 +1830,35 @@ IMPORTANT:
|
||||
# 但要做“可用信息重加权”:当某些模块缺失(如新闻/宏观没取到),不要用0分去稀释整体强度,
|
||||
# 而是重新归一化权重,让技术信号在缺失时仍可发挥主导作用。
|
||||
market_type = str(data.get("market") or "")
|
||||
fundamental_present = (market_type == "USStock") and bool(fundamental)
|
||||
|
||||
def _fundamental_meaningful(fund: Dict[str, Any]) -> bool:
|
||||
if not fund:
|
||||
return False
|
||||
for key in (
|
||||
"pe_ratio",
|
||||
"pb_ratio",
|
||||
"ps_ratio",
|
||||
"market_cap",
|
||||
"roe",
|
||||
"eps",
|
||||
"revenue_growth",
|
||||
"profit_margin",
|
||||
"dividend_yield",
|
||||
):
|
||||
v = fund.get(key)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
if isinstance(v, float) and v != v: # NaN
|
||||
continue
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
fundamental_present = (
|
||||
market_type in ("USStock", "CNStock", "HKStock") and _fundamental_meaningful(fundamental)
|
||||
)
|
||||
sentiment_present = bool(news)
|
||||
macro_present = bool(macro)
|
||||
# indicators 一旦成功计算通常就存在,但这里也做一次保护
|
||||
@@ -2057,9 +2085,9 @@ IMPORTANT:
|
||||
|
||||
def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float:
|
||||
"""计算基本面评分 (-100 to +100)"""
|
||||
if market != "USStock" or not fundamental:
|
||||
return 0.0 # 非美股或无基本面数据,返回中性
|
||||
|
||||
if market not in ("USStock", "CNStock", "HKStock") or not fundamental:
|
||||
return 50.0
|
||||
|
||||
score = 0.0
|
||||
factors = 0
|
||||
|
||||
@@ -2142,7 +2170,9 @@ IMPORTANT:
|
||||
# 归一化(如果有多个因素)
|
||||
if factors > 0:
|
||||
score = score / factors * 100 / 4 # 最大可能分数是4个因素各20分=80,归一化到100
|
||||
|
||||
else:
|
||||
return 50.0
|
||||
|
||||
return max(-100, min(100, score))
|
||||
|
||||
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
|
||||
|
||||
@@ -9,12 +9,75 @@ Notes:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cached SSL verify setting for all live-trading REST calls (requests + SOCKS proxy).
|
||||
_requests_verify_value: Optional[Union[bool, str]] = None
|
||||
_ssl_verify_disabled_logged = False
|
||||
|
||||
# OS CA bundles (Docker / slim images: install ``ca-certificates``; corporate roots often added here too).
|
||||
_SYSTEM_CA_BUNDLE_CANDIDATES: Tuple[str, ...] = (
|
||||
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
||||
"/etc/ssl/cert.pem", # Alpine, some slim images
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
||||
)
|
||||
|
||||
|
||||
def _get_requests_verify() -> Union[bool, str]:
|
||||
"""
|
||||
Resolve ``verify`` for ``requests`` when calling exchanges through proxies (e.g. PROXY_URL=socks5h://...).
|
||||
|
||||
- LIVE_TRADING_SSL_VERIFY=0|false|no|off: disable verification (insecure; mitm risk).
|
||||
- LIVE_TRADING_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE: path to a PEM CA bundle
|
||||
(needed for corporate TLS inspection or custom roots).
|
||||
- Else a non-empty OS CA file if present (helps Gate/HTX/hbdm etc. in minimal images).
|
||||
- Otherwise certifi's bundle when available.
|
||||
"""
|
||||
global _requests_verify_value, _ssl_verify_disabled_logged
|
||||
if _requests_verify_value is not None:
|
||||
return _requests_verify_value
|
||||
|
||||
flag = (os.environ.get("LIVE_TRADING_SSL_VERIFY") or "").strip().lower()
|
||||
if flag in ("0", "false", "no", "off"):
|
||||
if not _ssl_verify_disabled_logged:
|
||||
logger.warning(
|
||||
"LIVE_TRADING_SSL_VERIFY is disabled: HTTPS certificate verification is OFF for live trading "
|
||||
"requests (MITM risk). Fix CA trust or set LIVE_TRADING_CA_BUNDLE instead for production."
|
||||
)
|
||||
_ssl_verify_disabled_logged = True
|
||||
_requests_verify_value = False
|
||||
return _requests_verify_value
|
||||
|
||||
for key in ("LIVE_TRADING_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"):
|
||||
path = (os.environ.get(key) or "").strip()
|
||||
if path and os.path.isfile(path):
|
||||
_requests_verify_value = path
|
||||
return _requests_verify_value
|
||||
|
||||
for path in _SYSTEM_CA_BUNDLE_CANDIDATES:
|
||||
try:
|
||||
if path and os.path.isfile(path) and os.path.getsize(path) >= 256:
|
||||
_requests_verify_value = path
|
||||
return _requests_verify_value
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
try:
|
||||
import certifi
|
||||
|
||||
_requests_verify_value = certifi.where()
|
||||
except ImportError:
|
||||
_requests_verify_value = True
|
||||
return _requests_verify_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveOrderResult:
|
||||
@@ -51,15 +114,26 @@ class BaseRestClient:
|
||||
data: Optional[Any] = None,
|
||||
) -> Tuple[int, Dict[str, Any], str]:
|
||||
url = self._url(path)
|
||||
resp = requests.request(
|
||||
method=str(method or "GET").upper(),
|
||||
url=url,
|
||||
params=params or None,
|
||||
json=json_body if json_body is not None else None,
|
||||
data=data,
|
||||
headers=headers or None,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
try:
|
||||
resp = requests.request(
|
||||
method=str(method or "GET").upper(),
|
||||
url=url,
|
||||
params=params or None,
|
||||
json=json_body if json_body is not None else None,
|
||||
data=data,
|
||||
headers=headers or None,
|
||||
timeout=self.timeout_sec,
|
||||
verify=_get_requests_verify(),
|
||||
)
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.warning(
|
||||
"Exchange HTTPS TLS verify failed (%s). Same setting applies to all REST exchanges (Gate, HTX/hbdm, etc.). "
|
||||
"Behind PROXY_URL/SOCKS or TLS inspection: set LIVE_TRADING_CA_BUNDLE to a PEM bundle (or REQUESTS_CA_BUNDLE), "
|
||||
"ensure ca-certificates in the image, or dev-only LIVE_TRADING_SSL_VERIFY=false. %s",
|
||||
url,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
text = resp.text or ""
|
||||
parsed: Dict[str, Any] = {}
|
||||
try:
|
||||
|
||||
@@ -32,7 +32,7 @@ class BybitClient(BaseRestClient):
|
||||
base_url: str = "https://api.bybit.com",
|
||||
timeout_sec: float = 15.0,
|
||||
category: str = "linear", # "linear" (USDT perpetual) or "spot"
|
||||
recv_window_ms: int = 5000,
|
||||
recv_window_ms: int = 12000,
|
||||
broker_referer: str = "",
|
||||
hedge_mode: bool = False,
|
||||
):
|
||||
@@ -45,11 +45,13 @@ class BybitClient(BaseRestClient):
|
||||
if self.category not in ("linear", "spot"):
|
||||
self.category = "linear"
|
||||
try:
|
||||
self.recv_window_ms = int(recv_window_ms or 5000)
|
||||
self.recv_window_ms = int(recv_window_ms or 12000)
|
||||
except Exception:
|
||||
self.recv_window_ms = 12000
|
||||
if self.recv_window_ms < 5000:
|
||||
self.recv_window_ms = 5000
|
||||
if self.recv_window_ms <= 0:
|
||||
self.recv_window_ms = 5000
|
||||
if self.recv_window_ms > 60000:
|
||||
self.recv_window_ms = 60000
|
||||
|
||||
if not self.api_key or not self.secret_key:
|
||||
raise LiveTradingError("Missing Bybit api_key/secret_key")
|
||||
@@ -59,6 +61,12 @@ class BybitClient(BaseRestClient):
|
||||
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._inst_cache_ttl_sec = 300.0
|
||||
|
||||
# Bybit v5 rejects requests if local clock diverges from server (retCode 10002).
|
||||
# Offset = server_ms - local_ms; signed timestamp uses local_ms + offset.
|
||||
self._time_offset_ms: int = 0
|
||||
self._time_offset_at: float = 0.0
|
||||
self._time_sync_ttl_sec: float = 55.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
@@ -161,6 +169,48 @@ class BybitClient(BaseRestClient):
|
||||
def _sign(self, prehash: str) -> str:
|
||||
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _parse_server_time_ms_from_market_time(raw: Dict[str, Any]) -> int:
|
||||
"""Parse milliseconds from GET /v5/market/time (or similar) JSON."""
|
||||
if not isinstance(raw, dict):
|
||||
raise LiveTradingError("Bybit market/time: invalid response")
|
||||
res = raw.get("result")
|
||||
if isinstance(res, dict):
|
||||
nano = res.get("timeNano")
|
||||
if nano is not None and str(nano).strip() != "":
|
||||
try:
|
||||
return int(int(str(nano)) // 1_000_000)
|
||||
except Exception:
|
||||
pass
|
||||
sec = res.get("timeSecond")
|
||||
if sec is not None and str(sec).strip() != "":
|
||||
try:
|
||||
return int(float(sec) * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
t = raw.get("time")
|
||||
if t is not None:
|
||||
try:
|
||||
return int(t)
|
||||
except Exception:
|
||||
pass
|
||||
raise LiveTradingError("Bybit market/time: missing time fields")
|
||||
|
||||
def sync_server_time_offset(self, *, force: bool = False) -> None:
|
||||
"""Align signing timestamp with Bybit server (public /v5/market/time)."""
|
||||
now = time.time()
|
||||
if (
|
||||
not force
|
||||
and self._time_offset_at > 0
|
||||
and (now - self._time_offset_at) < float(self._time_sync_ttl_sec or 55.0)
|
||||
):
|
||||
return
|
||||
raw = self._public_request("GET", "/v5/market/time")
|
||||
srv_ms = self._parse_server_time_ms_from_market_time(raw)
|
||||
local_ms = int(time.time() * 1000)
|
||||
self._time_offset_ms = int(srv_ms - local_ms)
|
||||
self._time_offset_at = now
|
||||
|
||||
def _resolve_position_idx(self, pos_side: str) -> Optional[int]:
|
||||
if not self.hedge_mode:
|
||||
return None
|
||||
@@ -193,32 +243,55 @@ class BybitClient(BaseRestClient):
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
m = str(method or "GET").upper()
|
||||
ts_ms = str(int(time.time() * 1000))
|
||||
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
qs = ""
|
||||
qs_base = ""
|
||||
if params:
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
qs_base = urlencode(sorted(norm.items()), doseq=True)
|
||||
payload_get = qs_base
|
||||
payload_post = body_str
|
||||
|
||||
payload = qs if m == "GET" else body_str
|
||||
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
|
||||
sign = self._sign(prehash)
|
||||
last_err: Optional[LiveTradingError] = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
self.sync_server_time_offset(force=(attempt > 0))
|
||||
except Exception as e:
|
||||
if attempt == 0:
|
||||
# First attempt: still try with raw local time; second pass may recover.
|
||||
pass
|
||||
else:
|
||||
raise LiveTradingError(f"Bybit time sync failed: {e}") from e
|
||||
|
||||
code, data, text = self._request(
|
||||
m,
|
||||
path,
|
||||
params=params if (m == "GET" and params) else (params or None),
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
rc = data.get("retCode")
|
||||
if rc not in (0, "0", None, ""):
|
||||
raise LiveTradingError(f"Bybit error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
ts_ms = str(int(time.time() * 1000) + int(self._time_offset_ms or 0))
|
||||
payload = payload_get if m == "GET" else payload_post
|
||||
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
|
||||
sign = self._sign(prehash)
|
||||
|
||||
code, data, text = self._request(
|
||||
m,
|
||||
path,
|
||||
params=params if (m == "GET" and params) else (params or None),
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
rc = data.get("retCode")
|
||||
try:
|
||||
rc_int = int(rc) if rc is not None and str(rc).strip() != "" else 0
|
||||
except Exception:
|
||||
rc_int = -1
|
||||
if rc_int == 10002 and attempt == 0:
|
||||
last_err = LiveTradingError(f"Bybit error: {data}")
|
||||
continue
|
||||
if rc not in (0, "0", None, ""):
|
||||
raise LiveTradingError(f"Bybit error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise LiveTradingError("Bybit signed request failed after time resync")
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
@@ -237,6 +310,109 @@ class BybitClient(BaseRestClient):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _row_to_ticker_out(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(row, dict):
|
||||
return {}
|
||||
last_raw = row.get("lastPrice") or row.get("last") or row.get("markPrice") or row.get("indexPrice") or 0
|
||||
try:
|
||||
px = float(str(last_raw).replace(",", "").strip() or 0)
|
||||
except Exception:
|
||||
px = 0.0
|
||||
if px <= 0:
|
||||
return {}
|
||||
out: Dict[str, Any] = dict(row)
|
||||
out["last"] = px
|
||||
out["price"] = px
|
||||
return out
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Public market price for USDT notional -> base qty (quick_trade / execution).
|
||||
|
||||
Tries: ``/v5/market/tickers`` (by symbol) → ``/v5/market/orderbook`` (mid) →
|
||||
``/v5/market/tickers`` (category-only, scan list). Some environments return an empty
|
||||
ticker list for filtered queries; fallbacks avoid silent failure.
|
||||
"""
|
||||
sym = to_bybit_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
cat = "spot" if (self.category or "").strip().lower() == "spot" else "linear"
|
||||
sym_u = sym.upper()
|
||||
|
||||
# 1) Filtered tickers (preferred)
|
||||
try:
|
||||
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat, "symbol": sym_u})
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if isinstance(lst, list) and lst:
|
||||
out = self._row_to_ticker_out(lst[0] if isinstance(lst[0], dict) else {})
|
||||
if out:
|
||||
return out
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Order book mid (bid/ask)
|
||||
try:
|
||||
ob = self._public_request(
|
||||
"GET",
|
||||
"/v5/market/orderbook",
|
||||
params={"category": cat, "symbol": sym_u, "limit": 25},
|
||||
)
|
||||
res = (ob.get("result") or {}) if isinstance(ob, dict) else {}
|
||||
bids = res.get("b") or []
|
||||
asks = res.get("a") or []
|
||||
bid_p = 0.0
|
||||
ask_p = 0.0
|
||||
if isinstance(bids, list) and bids and isinstance(bids[0], (list, tuple)) and len(bids[0]) > 0:
|
||||
try:
|
||||
bid_p = float(str(bids[0][0]).replace(",", ""))
|
||||
except Exception:
|
||||
bid_p = 0.0
|
||||
if isinstance(asks, list) and asks and isinstance(asks[0], (list, tuple)) and len(asks[0]) > 0:
|
||||
try:
|
||||
ask_p = float(str(asks[0][0]).replace(",", ""))
|
||||
except Exception:
|
||||
ask_p = 0.0
|
||||
mid = 0.0
|
||||
if bid_p > 0 and ask_p > 0:
|
||||
mid = (bid_p + ask_p) / 2.0
|
||||
else:
|
||||
mid = bid_p or ask_p
|
||||
if mid > 0:
|
||||
return {
|
||||
"symbol": sym_u,
|
||||
"last": mid,
|
||||
"price": mid,
|
||||
"bid1Price": bid_p,
|
||||
"ask1Price": ask_p,
|
||||
}
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) Full category ticker list, match symbol (larger payload; last resort)
|
||||
try:
|
||||
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat})
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if isinstance(lst, list):
|
||||
for row in lst:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if str(row.get("symbol") or "").strip().upper() != sym_u:
|
||||
continue
|
||||
out = self._row_to_ticker_out(row)
|
||||
if out:
|
||||
return out
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]:
|
||||
return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")})
|
||||
|
||||
@@ -497,10 +673,28 @@ class BybitClient(BaseRestClient):
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
def get_positions(self) -> Dict[str, Any]:
|
||||
def get_positions(
|
||||
self,
|
||||
*,
|
||||
symbol: str = "",
|
||||
settle_coin: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
GET /v5/position/list — Bybit v5 requires ``symbol`` OR ``settleCoin`` with ``category``.
|
||||
|
||||
- Pass ``symbol`` (e.g. ETH/USDT) to query one contract.
|
||||
- Omit ``symbol`` and pass ``settle_coin`` (default USDT) to list all USDT-linear positions.
|
||||
"""
|
||||
if self.category != "linear":
|
||||
raise LiveTradingError("Bybit positions are only supported for linear category in this client")
|
||||
return self._signed_request("GET", "/v5/position/list", params={"category": "linear"})
|
||||
params: Dict[str, Any] = {"category": "linear"}
|
||||
sym = to_bybit_symbol(symbol) if (symbol or "").strip() else ""
|
||||
if sym:
|
||||
params["symbol"] = sym
|
||||
else:
|
||||
sc = (settle_coin or "USDT").strip().upper() or "USDT"
|
||||
params["settleCoin"] = sc
|
||||
return self._signed_request("GET", "/v5/position/list", params=params)
|
||||
|
||||
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
|
||||
if self.category != "linear":
|
||||
|
||||
@@ -117,7 +117,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
|
||||
category = "spot" if mt == "spot" else "linear"
|
||||
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
|
||||
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 12000)
|
||||
broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020"
|
||||
hedge_mode_raw = exchange_config.get("hedge_mode")
|
||||
if hedge_mode_raw is None:
|
||||
|
||||
@@ -4,7 +4,7 @@ Gate.io (direct REST) clients:
|
||||
- Futures USDT: /api/v4/futures/usdt/*
|
||||
|
||||
Signing (apiv4):
|
||||
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + body + "\\n" + timestamp))
|
||||
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + hexencode(sha512(payload)) + "\\n" + timestamp))
|
||||
Headers:
|
||||
- KEY: api key
|
||||
- Timestamp: unix seconds
|
||||
@@ -15,14 +15,42 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_gate_currency_pair
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _gate_ticker_response_to_normalized(raw: Any) -> Dict[str, Any]:
|
||||
"""Parse Gate spot/futures tickers API (array of one row) into a dict with float ``last`` for quick_trade."""
|
||||
row: Dict[str, Any] = {}
|
||||
if isinstance(raw, list) and raw and isinstance(raw[0], dict):
|
||||
row = raw[0]
|
||||
elif isinstance(raw, dict) and raw:
|
||||
row = raw
|
||||
else:
|
||||
return {}
|
||||
last = 0.0
|
||||
for key in ("last", "mark_price", "index_price", "close", "price"):
|
||||
v = row.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
try:
|
||||
last = float(str(v).replace(",", ""))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
out = dict(row)
|
||||
out["last"] = last
|
||||
out["close"] = last
|
||||
out["price"] = last
|
||||
return out
|
||||
|
||||
|
||||
class _GateBase(BaseRestClient):
|
||||
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
|
||||
@@ -34,7 +62,10 @@ class _GateBase(BaseRestClient):
|
||||
raise LiveTradingError("Missing Gate api_key/secret_key")
|
||||
|
||||
def _sign(self, *, method: str, url: str, query_string: str, body_str: str, ts: str) -> str:
|
||||
msg = f"{method.upper()}\n{url}\n{query_string}\n{body_str}\n{ts}"
|
||||
# Per https://www.gate.com/docs/developers/apiv4/en/#authentication — payload slot is SHA512(body).hexdigest(),
|
||||
# not the raw body (GET / no body => hash of empty string).
|
||||
hashed_payload = hashlib.sha512((body_str or "").encode("utf-8")).hexdigest()
|
||||
msg = f"{method.upper()}\n{url}\n{query_string}\n{hashed_payload}\n{ts}"
|
||||
return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest()
|
||||
|
||||
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
|
||||
@@ -58,7 +89,15 @@ class _GateBase(BaseRestClient):
|
||||
text = f"t-{text}"
|
||||
return text[:28]
|
||||
|
||||
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
m = str(method or "GET").upper()
|
||||
ts = str(int(time.time()))
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
@@ -67,7 +106,10 @@ class _GateBase(BaseRestClient):
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
sign = self._sign(method=m, url=path, query_string=qs, body_str=body_str, ts=ts)
|
||||
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign))
|
||||
hdrs = dict(self._headers(ts, sign))
|
||||
if extra_headers:
|
||||
hdrs.update({str(k): str(v) for k, v in extra_headers.items()})
|
||||
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=hdrs)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
|
||||
return data
|
||||
@@ -87,6 +129,11 @@ class GateSpotClient(_GateBase):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
pair = to_gate_currency_pair(symbol)
|
||||
raw = self._public_request("GET", "/api/v4/spot/tickers", params={"currency_pair": pair})
|
||||
return _gate_ticker_response_to_normalized(raw)
|
||||
|
||||
def get_accounts(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/spot/accounts")
|
||||
|
||||
@@ -204,13 +251,21 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
return Decimal("0")
|
||||
|
||||
def ping(self) -> bool:
|
||||
# Gate futures REST no longer serves /api/v4/futures/usdt/time (returns 400 on fx-api / api hosts).
|
||||
# Use a lightweight public list call instead.
|
||||
try:
|
||||
_ = self._public_request("GET", "/api/v4/futures/usdt/time")
|
||||
_ = self._public_request("GET", "/api/v4/futures/usdt/contracts", params={"limit": 1})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
raw = self._public_request("GET", "/api/v4/futures/usdt/tickers", params={"contract": contract})
|
||||
return _gate_ticker_response_to_normalized(raw)
|
||||
|
||||
def get_contract(self, *, contract: str) -> Dict[str, Any]:
|
||||
"""Fetch contract metadata with ``X-Gate-Size-Decimal: 1`` to get accurate string-typed size fields."""
|
||||
c = str(contract or "").strip()
|
||||
if not c:
|
||||
return {}
|
||||
@@ -220,33 +275,122 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
raw = self._public_request("GET", f"/api/v4/futures/usdt/contracts/{c}")
|
||||
obj = raw if isinstance(raw, dict) else {}
|
||||
code, data, text = self._request(
|
||||
"GET", f"/api/v4/futures/usdt/contracts/{c}",
|
||||
params=None, headers={"X-Gate-Size-Decimal": "1"},
|
||||
json_body=None, data=None,
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
|
||||
obj = data if isinstance(data, dict) else {}
|
||||
if obj:
|
||||
self._contract_cache[c] = (now, obj)
|
||||
return obj
|
||||
|
||||
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
|
||||
@staticmethod
|
||||
def _decimal_places(d: Decimal) -> int:
|
||||
"""Return the number of decimal places in a Decimal value."""
|
||||
sign, digits, exponent = d.as_tuple()
|
||||
return max(0, -int(exponent))
|
||||
|
||||
def _resolve_order_size(self, *, contract: str, side: str, base_size: float) -> Tuple[str, Optional[Dict[str, str]]]:
|
||||
"""
|
||||
Convert base-asset qty to a signed Gate ``size`` string and determine whether to use
|
||||
the ``X-Gate-Size-Decimal`` header.
|
||||
|
||||
Per Gate announcement (2025-12-18):
|
||||
- ``size`` is always in **contracts** (not base-asset units).
|
||||
- With ``X-Gate-Size-Decimal: 1``, ``size`` becomes a string that supports decimals.
|
||||
- A contract supports fractional ordering when ``order_size_min`` (queried with the
|
||||
decimal header) contains a fractional part (e.g. ``"0.1"``).
|
||||
- Precision must align with ``order_size_min`` (e.g. if min is ``"0.1"`` → 1 dp).
|
||||
"""
|
||||
sd = (side or "").strip().lower()
|
||||
sign = Decimal("1") if sd == "buy" else Decimal("-1")
|
||||
req = self._to_dec(base_size)
|
||||
if req <= 0:
|
||||
return 0
|
||||
return ("0", None)
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=contract) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or meta.get("contract_size") or meta.get("contractSize") or "0")
|
||||
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or "0")
|
||||
if qm <= 0:
|
||||
# Fallback: 1 contract ~= 1 base unit (best-effort)
|
||||
qm = Decimal("1")
|
||||
|
||||
contracts = req / qm
|
||||
return int(self._floor(contracts))
|
||||
|
||||
order_min = self._to_dec(meta.get("order_size_min") or "1")
|
||||
if order_min <= 0:
|
||||
order_min = Decimal("1")
|
||||
dp = self._decimal_places(order_min)
|
||||
|
||||
if dp > 0:
|
||||
step = Decimal(10) ** (-dp)
|
||||
q = contracts.quantize(step, rounding=ROUND_DOWN)
|
||||
if q < order_min and contracts > 0:
|
||||
q = order_min
|
||||
signed_q = q * sign
|
||||
s = format(signed_q, "f")
|
||||
if "." in s:
|
||||
s = s.rstrip("0").rstrip(".")
|
||||
return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0",
|
||||
{"X-Gate-Size-Decimal": "1"})
|
||||
else:
|
||||
iv = int(self._floor(contracts))
|
||||
int_min = max(1, int(order_min))
|
||||
if iv < int_min and contracts > 0:
|
||||
iv = int_min
|
||||
signed_iv = int(Decimal(iv) * sign)
|
||||
return (str(signed_iv), None)
|
||||
|
||||
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
|
||||
"""Integer contracts estimate (for internal use like position sizing display)."""
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=contract) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or "0")
|
||||
if qm <= 0:
|
||||
qm = Decimal("1")
|
||||
return max(1, int(self._floor(self._to_dec(base_size) / qm)))
|
||||
|
||||
def contracts_signed_to_base_qty(self, *, contract: str, contracts_signed: float) -> float:
|
||||
"""Convert signed position size (contracts) from Gate positions API to base-asset quantity."""
|
||||
try:
|
||||
ct = abs(float(contracts_signed or 0.0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
if ct <= 0:
|
||||
return 0.0
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=str(contract)) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(
|
||||
meta.get("quanto_multiplier")
|
||||
or meta.get("quantoMultiplier")
|
||||
or meta.get("contract_size")
|
||||
or meta.get("contractSize")
|
||||
or "0"
|
||||
)
|
||||
if qm <= 0:
|
||||
qm = Decimal("1")
|
||||
return float(Decimal(str(ct)) * qm)
|
||||
|
||||
def get_accounts(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/futures/usdt/accounts")
|
||||
|
||||
def get_positions(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/futures/usdt/positions")
|
||||
return self._signed_request(
|
||||
"GET", "/api/v4/futures/usdt/positions",
|
||||
extra_headers={"X-Gate-Size-Decimal": "1"},
|
||||
)
|
||||
|
||||
def set_leverage(self, *, contract: str, leverage: float) -> bool:
|
||||
c = str(contract or "").strip()
|
||||
@@ -258,11 +402,26 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
lv = 1
|
||||
if lv < 1:
|
||||
lv = 1
|
||||
try:
|
||||
_ = self._signed_request("POST", f"/api/v4/futures/usdt/positions/{c}/leverage", json_body={"leverage": str(lv)})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
path = f"/api/v4/futures/usdt/positions/{c}/leverage"
|
||||
lv_s = str(lv)
|
||||
# Gate expects ``leverage`` / ``cross_leverage_limit`` as **query parameters**, not JSON body
|
||||
# (see gateapi-python: update_position_leverage). Cross / portfolio mode: leverage=0 + cross_leverage_limit.
|
||||
attempts: Tuple[Dict[str, str], ...] = (
|
||||
{"leverage": lv_s},
|
||||
{"leverage": "0", "cross_leverage_limit": lv_s},
|
||||
{"leverage": lv_s, "cross_leverage_limit": lv_s},
|
||||
)
|
||||
last_err: Optional[Exception] = None
|
||||
for qp in attempts:
|
||||
try:
|
||||
_ = self._signed_request("POST", path, params=qp, json_body=None)
|
||||
return True
|
||||
except LiveTradingError as e:
|
||||
last_err = e
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning("Gate set_leverage failed contract=%s leverage=%s: %s", c, lv, last_err)
|
||||
return False
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
@@ -276,18 +435,27 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
sd = (side or "").strip().lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
base_qty = float(size or 0.0)
|
||||
if base_qty <= 0:
|
||||
raise LiveTradingError("Invalid size (<= 0)")
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
|
||||
if csz <= 0:
|
||||
raise LiveTradingError("Invalid size (converted contracts <= 0)")
|
||||
signed_size = int(csz) if sd == "buy" else -int(csz)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"}
|
||||
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
|
||||
if size_str in ("0", "-0", ""):
|
||||
raise LiveTradingError("Invalid size (resolved contracts == 0)")
|
||||
logger.info("Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s",
|
||||
contract, sd, base_qty, size_str, extra_headers is not None)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": "0", "tif": "ioc"}
|
||||
if reduce_only:
|
||||
body["reduce_only"] = True
|
||||
text = self._format_text(client_order_id)
|
||||
if text:
|
||||
body["text"] = text
|
||||
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
|
||||
raw = self._signed_request(
|
||||
"POST",
|
||||
"/api/v4/futures/usdt/orders",
|
||||
json_body=body,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
|
||||
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
||||
|
||||
@@ -304,21 +472,28 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
sd = (side or "").strip().lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
|
||||
if csz <= 0:
|
||||
raise LiveTradingError("Invalid size (converted contracts <= 0)")
|
||||
base_qty = float(size or 0.0)
|
||||
if base_qty <= 0:
|
||||
raise LiveTradingError("Invalid size (<= 0)")
|
||||
px = float(price or 0.0)
|
||||
if px <= 0:
|
||||
raise LiveTradingError("Invalid price")
|
||||
signed_size = int(csz) if sd == "buy" else -int(csz)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"}
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
|
||||
if size_str in ("0", "-0", ""):
|
||||
raise LiveTradingError("Invalid size (resolved contracts == 0)")
|
||||
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": str(px), "tif": "gtc"}
|
||||
if reduce_only:
|
||||
body["reduce_only"] = True
|
||||
text = self._format_text(client_order_id)
|
||||
if text:
|
||||
body["text"] = text
|
||||
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
|
||||
raw = self._signed_request(
|
||||
"POST",
|
||||
"/api/v4/futures/usdt/orders",
|
||||
json_body=body,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
|
||||
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@ from urllib.parse import urlencode, urlparse
|
||||
import datetime
|
||||
import time
|
||||
|
||||
import logging
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HtxClient(BaseRestClient):
|
||||
def __init__(
|
||||
@@ -53,6 +57,17 @@ class HtxClient(BaseRestClient):
|
||||
self._contract_cache_ttl_sec = 300.0
|
||||
self._lever_cache: Dict[str, int] = {}
|
||||
|
||||
@staticmethod
|
||||
def _format_swap_client_order_id(client_order_id: Optional[str]) -> Optional[int]:
|
||||
"""HTX swap/linear-swap client_order_id must be a pure numeric long (1~9223372036854775807)."""
|
||||
if not client_order_id:
|
||||
return None
|
||||
digits = "".join(c for c in str(client_order_id) if c.isdigit())
|
||||
if not digits:
|
||||
digits = str(int(time.time() * 1000))
|
||||
val = int(digits[-18:])
|
||||
return val if 0 < val <= 9223372036854775807 else None
|
||||
|
||||
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
|
||||
prefix = str(self.broker_id or "").strip()
|
||||
raw = str(client_order_id or "").strip()
|
||||
@@ -202,11 +217,33 @@ class HtxClient(BaseRestClient):
|
||||
if self.market_type == "spot":
|
||||
account_id = self._get_spot_account_id()
|
||||
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
|
||||
# 1) v1 cross
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 cross account_info failed: %s", e)
|
||||
# 2) v3 unified (unified / multi-asset collateral accounts)
|
||||
try:
|
||||
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
|
||||
v3_code = raw.get("code")
|
||||
if v3_code is not None and int(v3_code) == 200:
|
||||
data = raw.get("data")
|
||||
if isinstance(data, list) and data:
|
||||
logger.info("HTX v3 unified_account_info succeeded")
|
||||
return raw
|
||||
else:
|
||||
logger.debug("HTX v3 unified_account_info returned code=%s", v3_code)
|
||||
except (LiveTradingError, Exception) as e:
|
||||
logger.debug("HTX v3 unified_account_info failed: %s", e)
|
||||
# 3) v1 isolated
|
||||
try:
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
|
||||
except LiveTradingError as e:
|
||||
logger.warning("HTX all balance endpoints failed, last error: %s", e)
|
||||
return {"data": []}
|
||||
|
||||
def get_positions(self, *, symbol: str = "") -> Any:
|
||||
if self.market_type == "spot":
|
||||
@@ -235,11 +272,35 @@ class HtxClient(BaseRestClient):
|
||||
return {"data": rows}
|
||||
|
||||
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
|
||||
# 1) v1 cross
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 cross position_info failed: %s", e)
|
||||
# 2) v1 isolated
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 isolated position_info failed: %s", e)
|
||||
# 3) v3 unified - extract positions from cross_swap sub-array
|
||||
try:
|
||||
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
|
||||
v3_code = raw.get("code")
|
||||
if v3_code is not None and int(v3_code) == 200:
|
||||
v3_data = raw.get("data") or []
|
||||
if isinstance(v3_data, list) and v3_data:
|
||||
logger.info("HTX v3 unified_account_info for positions succeeded")
|
||||
return raw
|
||||
except (LiveTradingError, Exception) as e:
|
||||
logger.debug("HTX v3 unified_account_info (positions) failed: %s", e)
|
||||
logger.warning("HTX all position endpoints failed for symbol=%s", symbol)
|
||||
return {"data": []}
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
if self.market_type == "spot":
|
||||
@@ -355,7 +416,7 @@ class HtxClient(BaseRestClient):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
offset = "close" if reduce_only else "open"
|
||||
lever_rate = int(self._lever_cache.get(contract_code) or 5)
|
||||
body = {
|
||||
body: Dict[str, Any] = {
|
||||
"contract_code": contract_code,
|
||||
"volume": volume,
|
||||
"direction": sd,
|
||||
@@ -365,8 +426,18 @@ class HtxClient(BaseRestClient):
|
||||
}
|
||||
if self.broker_id:
|
||||
body["channel_code"] = self.broker_id
|
||||
if client_order_id:
|
||||
body["client_order_id"] = str(client_order_id)[:64]
|
||||
swap_coid = self._format_swap_client_order_id(client_order_id)
|
||||
if swap_coid is not None:
|
||||
body["client_order_id"] = swap_coid
|
||||
cross_body = dict(body)
|
||||
cross_body["margin_account"] = "USDT"
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
||||
except LiveTradingError as e:
|
||||
logger.info("HTX swap_cross_order failed, trying swap_order: %s", e)
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
@@ -412,7 +483,7 @@ class HtxClient(BaseRestClient):
|
||||
contract_code = to_htx_contract_code(symbol)
|
||||
volume = self._base_to_contracts(symbol=symbol, qty=qty)
|
||||
lever_rate = int(self._lever_cache.get(contract_code) or 5)
|
||||
body = {
|
||||
body: Dict[str, Any] = {
|
||||
"contract_code": contract_code,
|
||||
"volume": volume,
|
||||
"direction": sd,
|
||||
@@ -423,8 +494,18 @@ class HtxClient(BaseRestClient):
|
||||
}
|
||||
if self.broker_id:
|
||||
body["channel_code"] = self.broker_id
|
||||
if client_order_id:
|
||||
body["client_order_id"] = str(client_order_id)[:64]
|
||||
swap_coid = self._format_swap_client_order_id(client_order_id)
|
||||
if swap_coid is not None:
|
||||
body["client_order_id"] = swap_coid
|
||||
cross_body = dict(body)
|
||||
cross_body["margin_account"] = "USDT"
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
||||
except LiveTradingError:
|
||||
pass
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
|
||||
@@ -129,7 +129,7 @@ class MarketDataCollector:
|
||||
}
|
||||
|
||||
# 如果需要基本面,也并行获取
|
||||
if market == 'USStock':
|
||||
if market in ('USStock', 'CNStock', 'HKStock'):
|
||||
core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental"
|
||||
core_futures[executor.submit(self._get_company, market, symbol)] = "company"
|
||||
elif market == 'Crypto':
|
||||
@@ -616,9 +616,94 @@ class MarketDataCollector:
|
||||
try:
|
||||
if market == 'USStock':
|
||||
return self._get_us_fundamental(symbol)
|
||||
if market in ('CNStock', 'HKStock'):
|
||||
return self._get_cn_hk_fundamental(market, symbol)
|
||||
except Exception as e:
|
||||
logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}")
|
||||
return None
|
||||
|
||||
def _get_cn_hk_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
CN/HK fundamentals — multi-tier:
|
||||
Tier 1: Twelve Data /statistics (globally stable, paid)
|
||||
Tier 2: AkShare / Eastmoney (fragile overseas)
|
||||
+ Tencent quote for live price fields
|
||||
"""
|
||||
try:
|
||||
from app.data_sources.tencent import (
|
||||
normalize_cn_code,
|
||||
normalize_hk_code,
|
||||
fetch_quote,
|
||||
parse_quote_to_ticker,
|
||||
)
|
||||
from app.data_sources.cn_hk_fundamentals import (
|
||||
fetch_twelvedata_fundamental,
|
||||
fetch_cn_fundamental_akshare,
|
||||
fetch_hk_fundamental_akshare,
|
||||
)
|
||||
|
||||
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
|
||||
is_hk = market == 'HKStock'
|
||||
|
||||
parts = fetch_quote(code)
|
||||
t = parse_quote_to_ticker(parts) if parts else {}
|
||||
result: Dict[str, Any] = {
|
||||
"pe_ratio": None,
|
||||
"pb_ratio": None,
|
||||
"ps_ratio": None,
|
||||
"market_cap": None,
|
||||
"dividend_yield": None,
|
||||
"beta": None,
|
||||
"52w_high": None,
|
||||
"52w_low": None,
|
||||
"roe": None,
|
||||
"eps": None,
|
||||
"last": t.get("last"),
|
||||
"previous_close": t.get("previousClose"),
|
||||
"change_percent": t.get("changePercent"),
|
||||
"source": "tencent_quote",
|
||||
}
|
||||
|
||||
# Tier 1: Twelve Data
|
||||
td = {}
|
||||
try:
|
||||
td = fetch_twelvedata_fundamental(code, is_hk)
|
||||
except Exception as e:
|
||||
logger.debug("TwelveData fundamental failed %s:%s: %s", market, symbol, e)
|
||||
|
||||
if td:
|
||||
result["source"] = "tencent_quote+twelvedata"
|
||||
for k, v in td.items():
|
||||
if k == "source":
|
||||
continue
|
||||
if v is not None:
|
||||
result[k] = v
|
||||
|
||||
# Tier 2: AkShare (fill any remaining None fields)
|
||||
has_valuation = result.get("pe_ratio") is not None or result.get("pb_ratio") is not None
|
||||
if not has_valuation:
|
||||
try:
|
||||
ak_data = fetch_cn_fundamental_akshare(code) if not is_hk else fetch_hk_fundamental_akshare(code)
|
||||
except Exception as e:
|
||||
logger.debug("AkShare CN/HK fundamental failed %s:%s: %s", market, symbol, e)
|
||||
ak_data = {}
|
||||
if ak_data:
|
||||
if "twelvedata" not in result.get("source", ""):
|
||||
result["source"] = "tencent_quote+akshare_em"
|
||||
else:
|
||||
result["source"] += "+akshare_em"
|
||||
for k, v in ak_data.items():
|
||||
if k == "source":
|
||||
continue
|
||||
if v is not None and result.get(k) is None:
|
||||
result[k] = v
|
||||
|
||||
if not parts and not td and not has_valuation:
|
||||
return None
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"CN/HK fundamental failed: {market}:{symbol}: {e}")
|
||||
return None
|
||||
|
||||
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -924,11 +1009,85 @@ class MarketDataCollector:
|
||||
'market_cap': profile.get('marketCapitalization'),
|
||||
'website': profile.get('weburl'),
|
||||
}
|
||||
if market in ('CNStock', 'HKStock'):
|
||||
return self._get_cn_hk_company(market, symbol)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_cn_hk_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
CN/HK company info — multi-tier:
|
||||
Tier 1: Twelve Data /profile (globally stable)
|
||||
Tier 2: AkShare / Eastmoney (fragile overseas)
|
||||
+ Tencent quote for Chinese name
|
||||
"""
|
||||
try:
|
||||
from app.data_sources.tencent import (
|
||||
normalize_cn_code,
|
||||
normalize_hk_code,
|
||||
fetch_quote,
|
||||
)
|
||||
from app.data_sources.cn_hk_fundamentals import (
|
||||
fetch_twelvedata_profile,
|
||||
fetch_cn_company_extras,
|
||||
fetch_hk_company_extras,
|
||||
)
|
||||
|
||||
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
|
||||
is_hk = market == 'HKStock'
|
||||
|
||||
parts = fetch_quote(code)
|
||||
cn_name = ""
|
||||
if parts:
|
||||
cn_name = (parts[1] or "").strip() if len(parts) > 1 else ""
|
||||
|
||||
row: Dict[str, Any] = {
|
||||
"name": cn_name or code,
|
||||
"country": "CN" if market == "CNStock" else "HK",
|
||||
"exchange": "SSE/SZSE" if market == "CNStock" else "HKEX",
|
||||
"symbol": code,
|
||||
"source": "tencent_quote",
|
||||
}
|
||||
|
||||
# Tier 1: Twelve Data /profile
|
||||
td_profile = {}
|
||||
try:
|
||||
td_profile = fetch_twelvedata_profile(code, is_hk)
|
||||
except Exception as e:
|
||||
logger.debug("TwelveData profile failed %s:%s: %s", market, symbol, e)
|
||||
|
||||
if td_profile:
|
||||
row["source"] = "tencent_quote+twelvedata"
|
||||
for k in ("industry", "sector", "website", "description", "employees", "full_name"):
|
||||
v = td_profile.get(k)
|
||||
if v is not None:
|
||||
row[k] = v
|
||||
if not cn_name and td_profile.get("name"):
|
||||
row["name"] = td_profile["name"]
|
||||
|
||||
# Tier 2: AkShare (fill remaining gaps)
|
||||
if not row.get("industry"):
|
||||
try:
|
||||
ex = fetch_cn_company_extras(code) if not is_hk else fetch_hk_company_extras(code)
|
||||
except Exception:
|
||||
ex = {}
|
||||
if ex:
|
||||
if "twelvedata" not in row.get("source", ""):
|
||||
row["source"] = "tencent_quote+akshare_em"
|
||||
else:
|
||||
row["source"] += "+akshare_em"
|
||||
for k in ("industry", "ipo_date", "website", "full_name"):
|
||||
if ex.get(k) and not row.get(k):
|
||||
row[k] = ex[k]
|
||||
|
||||
if not parts and not td_profile and not row.get("industry"):
|
||||
return None
|
||||
return row
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ==================== 宏观数据 (复用全球金融板块) ====================
|
||||
|
||||
|
||||
@@ -360,8 +360,8 @@ class PendingOrderWorker:
|
||||
exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(total))
|
||||
|
||||
elif isinstance(client, BybitClient) and market_type == "swap":
|
||||
# Bybit linear positions
|
||||
resp = client.get_positions()
|
||||
# Bybit v5 requires symbol or settleCoin — use USDT for full linear book
|
||||
resp = client.get_positions(settle_coin="USDT")
|
||||
lst = (((resp.get("result") or {}).get("list")) if isinstance(resp, dict) else None) or []
|
||||
if isinstance(lst, list):
|
||||
for p in lst:
|
||||
@@ -1234,13 +1234,14 @@ class PendingOrderWorker:
|
||||
actual_pos_size = pos_amt
|
||||
break
|
||||
elif isinstance(client, BybitClient):
|
||||
pos_resp = client.get_positions() or {}
|
||||
pos_resp = client.get_positions(symbol=str(symbol or "")) or {}
|
||||
pos_list = (pos_resp.get("result") or {}).get("list") or [] if isinstance(pos_resp, dict) else []
|
||||
want = str(symbol or "").replace("/", "").replace("-", "").upper()
|
||||
for pos in pos_list:
|
||||
if not isinstance(pos, dict):
|
||||
continue
|
||||
pos_sym = str(pos.get("symbol") or "")
|
||||
if pos_sym != str(symbol or "").replace("/", ""):
|
||||
pos_sym = str(pos.get("symbol") or "").strip().upper()
|
||||
if pos_sym != want:
|
||||
continue
|
||||
p_side = str(pos.get("side") or "").strip().lower()
|
||||
if (p_side == "buy" and pos_side == "long") or (p_side == "sell" and pos_side == "short"):
|
||||
|
||||
@@ -87,26 +87,20 @@ class PolymarketWorker:
|
||||
logger.info("Starting Polymarket data update and analysis...")
|
||||
start_time = time.time()
|
||||
|
||||
# 1. 更新市场数据(从所有主要分类获取)
|
||||
categories = ["crypto", "politics", "economics", "sports", "tech", "finance", "geopolitics", "culture", "climate", "entertainment"]
|
||||
all_markets = []
|
||||
# Gamma API /events has no category param — fetch ALL once, categorize locally.
|
||||
all_markets = self.polymarket_source.get_trending_markets(category="all", limit=500)
|
||||
logger.info(f"Fetched {len(all_markets)} markets from Gamma API (single request)")
|
||||
|
||||
for category in categories:
|
||||
try:
|
||||
markets = self.polymarket_source.get_trending_markets(category, limit=50)
|
||||
all_markets.extend(markets)
|
||||
logger.info(f"Fetched {len(markets)} markets from category: {category}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch markets for category {category}: {e}")
|
||||
|
||||
# 去重(按market_id)
|
||||
unique_markets = {}
|
||||
cat_counts: Dict[str, int] = {}
|
||||
for market in all_markets:
|
||||
market_id = market.get('market_id')
|
||||
if market_id:
|
||||
unique_markets[market_id] = market
|
||||
cat = market.get('category', 'other')
|
||||
cat_counts[cat] = cat_counts.get(cat, 0) + 1
|
||||
|
||||
logger.info(f"Total unique markets: {len(unique_markets)}")
|
||||
logger.info(f"Total unique markets: {len(unique_markets)}, by category: {cat_counts}")
|
||||
|
||||
# 2. 批量分析市场(一次性分析所有市场,由AI筛选机会)
|
||||
markets_list = list(unique_markets.values())
|
||||
|
||||
@@ -28,6 +28,8 @@ from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
@@ -78,6 +80,43 @@ def _signal_meta(signal_type: str) -> Dict[str, str]:
|
||||
return {"action": action, "side": side, "type": st}
|
||||
|
||||
|
||||
def _load_user_timezone_for_strategy(strategy_id: int) -> str:
|
||||
try:
|
||||
sid = int(strategy_id)
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(u.timezone, '') AS tz
|
||||
FROM qd_strategies_trading s
|
||||
JOIN qd_users u ON u.id = s.user_id
|
||||
WHERE s.id = ?
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
return str(row.get("tz") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _utc_ts_to_user_display(now: int, user_timezone: str) -> Tuple[str, str, str]:
|
||||
"""Return (utc_iso, display_local_str, label_for_plaintext)."""
|
||||
iso = datetime.fromtimestamp(int(now), tz=timezone.utc).isoformat()
|
||||
utz = (user_timezone or "").strip()
|
||||
if not utz:
|
||||
return iso, iso, "Time (UTC)"
|
||||
try:
|
||||
dt = datetime.fromtimestamp(int(now), tz=timezone.utc).astimezone(ZoneInfo(utz))
|
||||
return iso, dt.strftime("%Y-%m-%d %H:%M:%S"), f"Time ({utz})"
|
||||
except Exception:
|
||||
return iso, iso, "Time (UTC)"
|
||||
|
||||
|
||||
def _fmt_float(value: Any, *, max_decimals: int = 10) -> str:
|
||||
try:
|
||||
v = float(value or 0.0)
|
||||
@@ -150,6 +189,7 @@ class SignalNotifier:
|
||||
targets = _safe_json(cfg.get("targets") or {})
|
||||
extra = extra if isinstance(extra, dict) else {}
|
||||
|
||||
user_tz = _load_user_timezone_for_strategy(int(strategy_id))
|
||||
payload = self._build_payload(
|
||||
strategy_id=strategy_id,
|
||||
strategy_name=strategy_name,
|
||||
@@ -159,6 +199,7 @@ class SignalNotifier:
|
||||
stake_amount=stake_amount,
|
||||
direction=direction,
|
||||
extra=extra,
|
||||
user_timezone=user_tz,
|
||||
)
|
||||
rendered = self._render_messages(payload)
|
||||
title = rendered.get("title") or ""
|
||||
@@ -252,9 +293,10 @@ class SignalNotifier:
|
||||
stake_amount: float,
|
||||
direction: str,
|
||||
extra: Dict[str, Any],
|
||||
user_timezone: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
now = int(time.time())
|
||||
iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat()
|
||||
iso, disp, tlab = _utc_ts_to_user_display(now, user_timezone)
|
||||
meta = _signal_meta(signal_type)
|
||||
|
||||
pending_id = None
|
||||
@@ -268,6 +310,8 @@ class SignalNotifier:
|
||||
"version": 1,
|
||||
"timestamp": now,
|
||||
"timestamp_iso": iso,
|
||||
"timestamp_display": disp,
|
||||
"time_label": tlab,
|
||||
"strategy": {
|
||||
"id": int(strategy_id),
|
||||
"name": str(strategy_name or ""),
|
||||
@@ -310,6 +354,8 @@ class SignalNotifier:
|
||||
pending_id = int(trace.get("pending_order_id") or 0) if trace.get("pending_order_id") else 0
|
||||
mode = str(trace.get("mode") or "")
|
||||
ts_iso = str(payload.get("timestamp_iso") or "")
|
||||
ts_disp = str(payload.get("timestamp_display") or "") or ts_iso
|
||||
ts_lbl = str(payload.get("time_label") or "Time")
|
||||
|
||||
plain_lines = [
|
||||
"QuantDinger Signal",
|
||||
@@ -323,8 +369,8 @@ class SignalNotifier:
|
||||
plain_lines.append(f"PendingOrder: {pending_id}")
|
||||
if mode:
|
||||
plain_lines.append(f"Mode: {mode}")
|
||||
if ts_iso:
|
||||
plain_lines.append(f"Time(UTC): {ts_iso}")
|
||||
if ts_disp:
|
||||
plain_lines.append(f"{ts_lbl}: {ts_disp}")
|
||||
|
||||
# Telegram (HTML) message. Escape all dynamic values.
|
||||
t_strategy = f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})"
|
||||
@@ -341,8 +387,8 @@ class SignalNotifier:
|
||||
telegram_lines.append(f"<b>PendingOrder</b>: <code>{pending_id}</code>")
|
||||
if mode:
|
||||
telegram_lines.append(f"<b>Mode</b>: <code>{html.escape(mode)}</code>")
|
||||
if ts_iso:
|
||||
telegram_lines.append(f"<b>Time (UTC)</b>: <code>{html.escape(ts_iso)}</code>")
|
||||
if ts_disp:
|
||||
telegram_lines.append(f"<b>{html.escape(ts_lbl)}</b>: <code>{html.escape(ts_disp)}</code>")
|
||||
telegram_html = "\n".join([x for x in telegram_lines if x is not None])
|
||||
|
||||
# Email (HTML) message. Keep inline CSS for maximum compatibility.
|
||||
@@ -355,7 +401,8 @@ class SignalNotifier:
|
||||
stake_text=stake_s,
|
||||
pending_id=pending_id or None,
|
||||
mode_text=mode or "",
|
||||
timestamp_iso=ts_iso or "",
|
||||
timestamp_display=ts_disp or "",
|
||||
time_row_label=ts_lbl or "Time",
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -376,7 +423,8 @@ class SignalNotifier:
|
||||
stake_text: str,
|
||||
pending_id: Optional[int],
|
||||
mode_text: str,
|
||||
timestamp_iso: str,
|
||||
timestamp_display: str,
|
||||
time_row_label: str,
|
||||
) -> str:
|
||||
def esc(s: Any) -> str:
|
||||
return html.escape(str(s or ""))
|
||||
@@ -392,8 +440,8 @@ class SignalNotifier:
|
||||
rows.append(("PendingOrder", str(int(pending_id))))
|
||||
if mode_text:
|
||||
rows.append(("Mode", mode_text))
|
||||
if timestamp_iso:
|
||||
rows.append(("Time (UTC)", timestamp_iso))
|
||||
if timestamp_display:
|
||||
rows.append((time_row_label or "Time", timestamp_display))
|
||||
|
||||
tr_html = "\n".join(
|
||||
[
|
||||
@@ -418,7 +466,7 @@ class SignalNotifier:
|
||||
<div style="max-width:640px;margin:0 auto;padding:24px;">
|
||||
<div style="background:#111827;color:#ffffff;padding:16px 18px;border-radius:12px 12px 0 0;">
|
||||
<div style="font-size:16px;letter-spacing:0.2px;font-weight:600;">{esc(title_text)}</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_iso) if timestamp_iso else ""}</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_display) if timestamp_display else ""}</div>
|
||||
</div>
|
||||
<div style="background:#ffffff;border:1px solid #eaecef;border-top:0;border-radius:0 0 12px 12px;overflow:hidden;">
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
|
||||
|
||||
@@ -977,12 +977,24 @@ class StrategyService:
|
||||
leverage = (trading_config or {}).get('leverage') or existing.get('leverage') or 1
|
||||
market_type = (trading_config or {}).get('market_type') or existing.get('market_type') or 'swap'
|
||||
|
||||
strategy_type = (payload.get('strategy_type') if payload.get('strategy_type') is not None
|
||||
else existing.get('strategy_type')) or 'IndicatorStrategy'
|
||||
strategy_mode = (payload.get('strategy_mode') if payload.get('strategy_mode') is not None
|
||||
else existing.get('strategy_mode')) or 'signal'
|
||||
if 'strategy_code' in payload:
|
||||
strategy_code = payload.get('strategy_code') or ''
|
||||
else:
|
||||
strategy_code = existing.get('strategy_code') or ''
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategies_trading
|
||||
SET strategy_name = ?,
|
||||
strategy_type = ?,
|
||||
strategy_mode = ?,
|
||||
strategy_code = ?,
|
||||
market_category = ?,
|
||||
execution_mode = ?,
|
||||
notification_config = ?,
|
||||
@@ -1000,6 +1012,9 @@ class StrategyService:
|
||||
""",
|
||||
(
|
||||
name,
|
||||
strategy_type,
|
||||
strategy_mode,
|
||||
strategy_code,
|
||||
market_category,
|
||||
execution_mode,
|
||||
self._dump_json_or_encrypt(notification_config, encrypt=False),
|
||||
|
||||
@@ -135,14 +135,29 @@ class StrategySnapshotResolver:
|
||||
timeframe = str(override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D").strip() or "1D"
|
||||
initial_capital = self._to_float(override.get("initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000))), 10000.0)
|
||||
leverage = self._to_int(override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1)
|
||||
commission = self._percent_to_ratio(override.get("commission", trading_config.get("commission", 0)))
|
||||
slippage = self._percent_to_ratio(override.get("slippage", trading_config.get("slippage", 0)))
|
||||
# Commission/slippage are backtest-only assumptions (not used by live ScriptStrategy execution).
|
||||
# Script strategies created from the UI may omit these; apply sensible backtest defaults.
|
||||
commission_raw = override.get("commission")
|
||||
if commission_raw is None:
|
||||
commission_raw = trading_config.get("commission")
|
||||
slippage_raw = override.get("slippage")
|
||||
if slippage_raw is None:
|
||||
slippage_raw = trading_config.get("slippage")
|
||||
strategy_type_early = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
|
||||
strategy_mode_early = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
|
||||
is_script_early = strategy_type_early == "ScriptStrategy" or strategy_mode_early == "script"
|
||||
if commission_raw is None or commission_raw == "":
|
||||
commission_raw = 0.05 if is_script_early else 0
|
||||
if slippage_raw is None or slippage_raw == "":
|
||||
slippage_raw = 0.0
|
||||
commission = self._percent_to_ratio(commission_raw)
|
||||
slippage = self._percent_to_ratio(slippage_raw)
|
||||
trade_direction = str(trading_config.get("trade_direction") or "long").strip().lower() or "long"
|
||||
enable_mtf = self._to_bool(override.get("enableMtf", market.lower() == "crypto"))
|
||||
|
||||
strategy_type = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
|
||||
strategy_mode = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
|
||||
is_script = strategy_type == "ScriptStrategy" or strategy_mode == "script"
|
||||
strategy_type = strategy_type_early
|
||||
strategy_mode = strategy_mode_early
|
||||
is_script = is_script_early
|
||||
|
||||
indicator_id = indicator_config.get("indicator_id") or strategy.get("indicator_id")
|
||||
indicator_name = indicator_config.get("indicator_name") or ""
|
||||
|
||||
@@ -21,6 +21,7 @@ import requests
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
|
||||
from app.data_sources.tencent import normalize_cn_code, normalize_hk_code
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -28,6 +29,10 @@ logger = get_logger(__name__)
|
||||
def _normalize_symbol_for_market(market: str, symbol: str) -> str:
|
||||
m = (market or '').strip()
|
||||
s = (symbol or '').strip().upper()
|
||||
if m == 'CNStock':
|
||||
return normalize_cn_code(s)
|
||||
if m == 'HKStock':
|
||||
return normalize_hk_code(s)
|
||||
return s
|
||||
|
||||
|
||||
@@ -105,6 +110,17 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
|
||||
# otherwise fall back to yfinance.
|
||||
return _resolve_name_from_finnhub(s) or _resolve_name_from_yfinance(s)
|
||||
|
||||
# CN/HK stocks: try Tencent quote name first (no key), then yfinance best-effort.
|
||||
if m in ('CNStock', 'HKStock'):
|
||||
try:
|
||||
from app.data_sources.tencent import fetch_quote
|
||||
parts = fetch_quote(s)
|
||||
if parts and len(parts) > 1 and parts[1]:
|
||||
return str(parts[1]).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return _resolve_name_from_yfinance(s)
|
||||
|
||||
# Crypto: at least return base ticker-like display (not a "company", but better than empty)
|
||||
if m == 'Crypto':
|
||||
if '/' in s:
|
||||
|
||||
@@ -21,6 +21,7 @@ import numpy as np
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.strategy_runtime_logs import append_strategy_log
|
||||
from app.data_sources import DataSourceFactory
|
||||
from app.services.kline import KlineService
|
||||
from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller
|
||||
@@ -52,6 +53,8 @@ class TradingExecutor:
|
||||
self._signal_dedup = {} # type: Dict[int, Dict[str, float]]
|
||||
self._signal_dedup_lock = threading.Lock()
|
||||
self.kline_service = KlineService() # K线服务(带缓存)
|
||||
# Throttle writes to qd_strategy_logs (heartbeat), per strategy_id -> monotonic time
|
||||
self._strategy_ui_log_last_tick_ts = {} # type: Dict[int, float]
|
||||
|
||||
# 单实例线程上限,避免无限制创建线程导致 can't start new thread/OOM
|
||||
self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64'))
|
||||
@@ -428,6 +431,7 @@ class TradingExecutor:
|
||||
|
||||
logger.info(f"Strategy {strategy_id} started")
|
||||
self._console_print(f"[strategy:{strategy_id}] started")
|
||||
append_strategy_log(strategy_id, "info", "策略执行线程已启动")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -466,6 +470,7 @@ class TradingExecutor:
|
||||
|
||||
logger.info(f"Strategy {strategy_id} stopped")
|
||||
self._console_print(f"[strategy:{strategy_id}] stopped (requested)")
|
||||
append_strategy_log(strategy_id, "info", "已请求停止策略(运行标志已清除)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -933,6 +938,11 @@ class TradingExecutor:
|
||||
logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}")
|
||||
if pending_signals:
|
||||
logger.info(f"Initial signals: {pending_signals}")
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"info",
|
||||
f"实盘循环就绪 {symbol} {timeframe},待处理信号 {len(pending_signals or [])} 条",
|
||||
)
|
||||
|
||||
# ============================================
|
||||
# Main loop: unified tick cadence (default: 10s)
|
||||
@@ -1258,6 +1268,11 @@ class TradingExecutor:
|
||||
)
|
||||
if ok:
|
||||
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"signal",
|
||||
f"已提交信号 {signal_type} 参考价 {float(execute_price or 0):.6f}",
|
||||
)
|
||||
# Notify portfolio positions linked to this symbol
|
||||
try:
|
||||
from app.services.portfolio_monitor import notify_strategy_signal_for_positions
|
||||
@@ -1271,6 +1286,11 @@ class TradingExecutor:
|
||||
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
|
||||
else:
|
||||
logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}")
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"error",
|
||||
f"信号未执行或拒单: {signal_type}",
|
||||
)
|
||||
|
||||
# Update positions once per tick.
|
||||
self._update_positions(strategy_id, symbol, current_price)
|
||||
@@ -1279,17 +1299,37 @@ class TradingExecutor:
|
||||
self._console_print(
|
||||
f"[strategy:{strategy_id}] tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}"
|
||||
)
|
||||
try:
|
||||
nowl = time.time()
|
||||
lastl = float(self._strategy_ui_log_last_tick_ts.get(strategy_id) or 0.0)
|
||||
if nowl - lastl >= 55.0:
|
||||
self._strategy_ui_log_last_tick_ts[strategy_id] = nowl
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"info",
|
||||
f"tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Strategy {strategy_id} loop error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self._console_print(f"[strategy:{strategy_id}] loop error: {e}")
|
||||
try:
|
||||
append_strategy_log(strategy_id, "error", f"循环异常: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Strategy {strategy_id} crashed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self._console_print(f"[strategy:{strategy_id}] fatal error: {e}")
|
||||
try:
|
||||
append_strategy_log(strategy_id, "error", f"策略线程致命错误: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# 清理
|
||||
with self.lock:
|
||||
@@ -1297,6 +1337,10 @@ class TradingExecutor:
|
||||
del self.running_strategies[strategy_id]
|
||||
self._console_print(f"[strategy:{strategy_id}] loop exited")
|
||||
logger.info(f"Strategy {strategy_id} loop exited")
|
||||
try:
|
||||
append_strategy_log(strategy_id, "info", "策略执行循环已退出")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sync_positions_with_exchange(self, strategy_id: int, exchange: Any, symbol: str, market_type: str):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user