Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-07 22:47:07 +08:00
parent baa3182eca
commit 8563e4ea53
116 changed files with 3189 additions and 356 deletions
+1
View File
@@ -11,6 +11,7 @@ WORKDIR /app
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/*.sources 2>/dev/null || true \
&& apt-get update \
&& apt-get install -y --no-install-recommends --fix-missing \
ca-certificates \
curl \
# Build deps (needed for some pyproject-based packages like ed25519-blake2b)
build-essential \
@@ -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
+166 -3
View File
@@ -1674,6 +1674,77 @@ def _fetch_stock_opportunity_prices() -> List[Dict[str, Any]]:
return []
def _fetch_local_stock_opportunity_prices(market: str, limit: int = 15) -> List[Dict[str, Any]]:
"""
Fetch CN/HK stock prices for opportunity scanning.
For overseas servers we prefer the project's Tencent-based ticker path, which is
typically more reachable/stable than some Eastmoney/AkShare endpoints.
"""
m = str(market or "").strip()
if m not in ("CNStock", "HKStock"):
return []
fallback_symbols = {
"CNStock": [
{"symbol": "600519", "name": "贵州茅台"},
{"symbol": "000001", "name": "平安银行"},
{"symbol": "300750", "name": "宁德时代"},
{"symbol": "601318", "name": "中国平安"},
{"symbol": "600036", "name": "招商银行"},
{"symbol": "002594", "name": "比亚迪"},
{"symbol": "600276", "name": "恒瑞医药"},
{"symbol": "601899", "name": "紫金矿业"},
],
"HKStock": [
{"symbol": "00700", "name": "腾讯控股"},
{"symbol": "09988", "name": "阿里巴巴-W"},
{"symbol": "03690", "name": "美团-W"},
{"symbol": "01810", "name": "小米集团-W"},
{"symbol": "01299", "name": "友邦保险"},
{"symbol": "00939", "name": "建设银行"},
{"symbol": "02318", "name": "中国平安"},
{"symbol": "09618", "name": "京东集团-SW"},
],
}
try:
from app.data.market_symbols_seed import get_hot_symbols
from app.data_sources import DataSourceFactory
from app.services.symbol_name import resolve_symbol_name
symbols = get_hot_symbols(m, limit=max(int(limit or 15), 1)) or fallback_symbols.get(m, [])
source = DataSourceFactory.get_source(m)
result = []
for item in symbols[: max(int(limit or 15), 1)]:
try:
symbol = str(item.get("symbol") or "").strip()
if not symbol:
continue
ticker = source.get_ticker(symbol) or {}
last = _safe_float(ticker.get("last") or ticker.get("close") or ticker.get("price"))
if last <= 0:
continue
change_pct = ticker.get("changePercent")
if change_pct is None:
prev_close = _safe_float(ticker.get("previousClose"))
change_pct = ((last - prev_close) / prev_close * 100.0) if prev_close > 0 else 0.0
result.append({
"symbol": symbol,
"name": (item.get("name") or resolve_symbol_name(m, symbol) or symbol).strip(),
"price": round(last, 4),
"change": round(_safe_float(change_pct), 2),
"market": m,
})
except Exception as e:
logger.debug(f"Failed to fetch {m} opportunity price {item.get('symbol')}: {e}")
return result
except Exception as e:
logger.error(f"Failed to fetch {m} opportunity prices: {e}")
return []
def _analyze_opportunities_crypto(opportunities: list):
"""Scan crypto market for trading opportunities."""
crypto_data = _get_cached("crypto_prices")
@@ -1800,6 +1871,75 @@ def _analyze_opportunities_stocks(opportunities: list):
})
def _analyze_opportunities_local_stocks(opportunities: list, market: str):
"""Scan CN/HK stocks for trading opportunities."""
m = str(market or "").strip()
if m not in ("CNStock", "HKStock"):
return
cache_key = "cn_stock_opportunity_prices" if m == "CNStock" else "hk_stock_opportunity_prices"
stock_data = _get_cached(cache_key)
if not stock_data:
stock_data = _fetch_local_stock_opportunity_prices(m)
if stock_data:
_set_cached(cache_key, stock_data, 3600)
if not stock_data:
logger.warning(f"_analyze_opportunities_local_stocks: No {m} data available")
return
logger.debug(f"_analyze_opportunities_local_stocks: Analyzing {len(stock_data)} {m} stocks")
strong_th = 7.0 if m == "CNStock" else 6.0
medium_th = 3.0 if m == "CNStock" else 2.5
market_cn = "A股" if m == "CNStock" else "港股"
for stock in stock_data:
change = _safe_float(stock.get("change", 0))
symbol = stock.get("symbol", "")
name = stock.get("name", "")
price = _safe_float(stock.get("price", 0))
signal = None
strength = "medium"
reason = ""
impact = "neutral"
if change > strong_th:
signal = "overbought"
strength = "strong"
reason = f"{market_cn}日涨幅{change:.1f}%,短期涨幅较大,注意回调风险"
impact = "bearish"
elif change > medium_th:
signal = "bullish_momentum"
strength = "medium"
reason = f"{market_cn}日涨幅{change:.1f}%,上涨动能较强"
impact = "bullish"
elif change < -strong_th:
signal = "oversold"
strength = "strong"
reason = f"{market_cn}日跌幅{abs(change):.1f}%,可能超卖反弹"
impact = "bullish"
elif change < -medium_th:
signal = "bearish_momentum"
strength = "medium"
reason = f"{market_cn}日跌幅{abs(change):.1f}%,下跌趋势明显"
impact = "bearish"
if signal:
opportunities.append({
"symbol": symbol,
"name": name,
"price": price,
"change_24h": change,
"signal": signal,
"strength": strength,
"reason": reason,
"impact": impact,
"market": m,
"timestamp": int(time.time())
})
def _analyze_opportunities_forex(opportunities: list):
"""Scan forex pairs for trading opportunities."""
forex_data = _get_cached("forex_pairs")
@@ -1915,7 +2055,7 @@ def _analyze_opportunities_polymarket(opportunities: list):
@login_required
def trading_opportunities():
"""
Scan for trading opportunities across Crypto, US Stocks, and Forex.
Scan for trading opportunities across Crypto, US Stocks, CN/HK Stocks, and Forex.
Note: Prediction Markets are excluded as they have their own dedicated page.
Cached for 1 hour. Pass ?force=true to skip cache.
"""
@@ -1945,7 +2085,23 @@ def trading_opportunities():
except Exception as e:
logger.error(f"Failed to analyze stock opportunities: {e}", exc_info=True)
# 3) Forex
# 3) CN Stocks
try:
_analyze_opportunities_local_stocks(opportunities, "CNStock")
cn_count = len([o for o in opportunities if o.get("market") == "CNStock"])
logger.info(f"Trading opportunities: found {cn_count} CN stock opportunities")
except Exception as e:
logger.error(f"Failed to analyze CN stock opportunities: {e}", exc_info=True)
# 4) HK Stocks
try:
_analyze_opportunities_local_stocks(opportunities, "HKStock")
hk_count = len([o for o in opportunities if o.get("market") == "HKStock"])
logger.info(f"Trading opportunities: found {hk_count} HK stock opportunities")
except Exception as e:
logger.error(f"Failed to analyze HK stock opportunities: {e}", exc_info=True)
# 5) Forex
try:
_analyze_opportunities_forex(opportunities)
forex_count = len([o for o in opportunities if o.get("market") == "Forex"])
@@ -1959,7 +2115,14 @@ def trading_opportunities():
# Sort by absolute change descending
opportunities.sort(key=lambda x: abs(x.get("change_24h", 0)), reverse=True)
logger.info(f"Trading opportunities: total {len(opportunities)} opportunities found (Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})")
logger.info(
f"Trading opportunities: total {len(opportunities)} opportunities found "
f"(Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, "
f"USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, "
f"CNStock: {len([o for o in opportunities if o.get('market') == 'CNStock'])}, "
f"HKStock: {len([o for o in opportunities if o.get('market') == 'HKStock'])}, "
f"Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})"
)
_set_cached("trading_opportunities", opportunities, 3600)
+2 -1
View File
@@ -83,7 +83,8 @@ def get_public_config():
@market_bp.route('/types', methods=['GET'])
def get_market_types():
"""Return supported market types for the add-watchlist modal."""
desired_order = ['USStock', 'Crypto', 'Forex', 'Futures']
# Keep a stable UX order; add CN/HK stocks near US stocks.
desired_order = ['USStock', 'CNStock', 'HKStock', 'Crypto', 'Forex', 'Futures']
order_rank = {v: i for i, v in enumerate(desired_order)}
def _normalize_item(x):
+351 -19
View File
@@ -29,11 +29,69 @@ from app.utils.credential_crypto import decrypt_credential_blob
logger = get_logger(__name__)
import re as _re
_FRIENDLY_ERROR_PATTERNS = [
# Insufficient balance / margin
(_re.compile(r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", _re.IGNORECASE),
"quickTrade.errorHints.insufficientBalance"),
# Invalid size / quantity
(_re.compile(r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", _re.IGNORECASE),
"quickTrade.errorHints.invalidSize"),
# Invalid price
(_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE),
"quickTrade.errorHints.invalidPrice"),
# Rate limit
(_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE),
"quickTrade.errorHints.rateLimit"),
# API key / permission
(_re.compile(r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", _re.IGNORECASE),
"quickTrade.errorHints.authError"),
# Position / reduce-only conflict
(_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE),
"quickTrade.errorHints.positionConflict"),
# Network / timeout
(_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE),
"quickTrade.errorHints.networkError"),
# Exchange maintenance
(_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE),
"quickTrade.errorHints.exchangeMaintenance"),
]
def _parse_trade_error_hint(error_str: str) -> str:
"""Return a i18n key hint for common exchange trading errors, or empty string."""
s = str(error_str or "")
for pattern, hint_key in _FRIENDLY_ERROR_PATTERNS:
if pattern.search(s):
return hint_key
return ""
quick_trade_bp = Blueprint('quick_trade', __name__)
# ────────── helpers ──────────
def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool:
"""Match UI symbol (e.g. ETH/USDT) with exchange-native ids (e.g. ETH_USDT, ETH-USDT-SWAP)."""
def norm(x: str) -> str:
return (x or "").strip().upper().replace("/", "").replace("-", "").replace("_", "")
a, b = norm(user_symbol), norm(position_symbol)
if not a or not b:
return False
if a == b:
return True
for suf in ("SWAP", "PERPETUAL", "PERP"):
if b.endswith(suf) and a == b[: -len(suf)]:
return True
if a.endswith(suf) and b == a[: -len(suf)]:
return True
# Substring fallback for less standard ids (min length avoids ETH vs ETHW false positives)
return (len(a) >= 6 and a in b) or (len(b) >= 6 and b in a)
def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0) -> float:
"""
Convert USDT amount to base asset quantity for all exchanges.
@@ -116,6 +174,56 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
current_price = float(data.get("price") or 0)
except Exception:
pass
# Bybit v5 — same host as trading API; tickers/orderbook are public
from app.services.live_trading.bybit import BybitClient
if current_price <= 0 and isinstance(client, BybitClient):
try:
import requests
from app.services.live_trading.symbols import to_bybit_symbol
bu = (getattr(client, "base_url", "") or "").rstrip("/")
bsym = to_bybit_symbol(symbol).upper()
cat = "spot" if (market_type or "").strip().lower() == "spot" else "linear"
if bu and bsym:
tr = requests.get(
f"{bu}/v5/market/tickers",
params={"category": cat, "symbol": bsym},
timeout=8,
)
if tr.status_code == 200:
jd = tr.json() if tr.text else {}
lst = (((jd.get("result") or {}).get("list")) or []) if isinstance(jd, dict) else []
if lst and isinstance(lst[0], dict):
t0 = lst[0]
current_price = float(
str(
t0.get("lastPrice")
or t0.get("markPrice")
or t0.get("indexPrice")
or 0
).replace(",", "")
or 0
)
if current_price <= 0:
obr = requests.get(
f"{bu}/v5/market/orderbook",
params={"category": cat, "symbol": bsym, "limit": 25},
timeout=8,
)
if obr.status_code == 200:
od = obr.json() if obr.text else {}
res = (od.get("result") or {}) if isinstance(od, dict) else {}
bids = res.get("b") or []
asks = res.get("a") or []
bp = float(str(bids[0][0]).replace(",", "")) if bids and bids[0] else 0.0
ap = float(str(asks[0][0]).replace(",", "")) if asks and asks[0] else 0.0
if bp > 0 and ap > 0:
current_price = (bp + ap) / 2.0
else:
current_price = bp or ap
except Exception:
pass
# Other exchanges - can be added as needed
# For exchanges without price API, we'll use a fallback
@@ -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)'
},
]
},
+44 -3
View File
@@ -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)
+13
View File
@@ -69,6 +69,15 @@ SMTP_USE_SSL=false
# PROXY_URL=socks5h://host.docker.internal:10808 # Docker
PROXY_URL=
# Outbound HTTPS to exchanges when using PROXY_URL (especially SOCKS). If you see
# SSLCertVerificationError / "unable to get local issuer certificate":
# - Prefer: install OS ca-certificates in the image, or point to a PEM bundle:
# LIVE_TRADING_CA_BUNDLE=/path/to/ca-bundle.pem
# (REQUESTS_CA_BUNDLE / SSL_CERT_FILE are also honored.)
# - Last resort only: LIVE_TRADING_SSL_VERIFY=false # disables TLS verify — insecure
#LIVE_TRADING_CA_BUNDLE=
#LIVE_TRADING_SSL_VERIFY=
# =========================
# Captcha / OAuth (optional)
# =========================
@@ -160,6 +169,10 @@ YFINANCE_TIMEOUT=30
TIINGO_API_KEY=
TIINGO_TIMEOUT=10
# Twelve Data (CN/HK stock K-lines — recommended for overseas servers)
# Free tier: 800 API credits/day, 8 requests/minute. https://twelvedata.com
TWELVE_DATA_API_KEY=
# AI search / news
SEARCH_PROVIDER=google
SEARCH_MAX_RESULTS=10
+38 -1
View File
@@ -349,6 +349,21 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON qd_strategy_notification
CREATE INDEX IF NOT EXISTS idx_notifications_strategy_id ON qd_strategy_notifications(strategy_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON qd_strategy_notifications(is_read);
-- =============================================================================
-- 6b. Strategy runtime logs (dashboard / API)
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategy_logs (
id SERIAL PRIMARY KEY,
strategy_id INTEGER NOT NULL REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
level VARCHAR(20) DEFAULT 'info',
message TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_strategy_logs_strategy_id ON qd_strategy_logs(strategy_id);
CREATE INDEX IF NOT EXISTS idx_strategy_logs_timestamp ON qd_strategy_logs(timestamp);
-- =============================================================================
-- 7. Indicator Codes
-- =============================================================================
@@ -647,7 +662,29 @@ INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_acti
('Futures', 'ZS', 'Soybeans', 'CBOT', 'USD', 1, 1, 94),
('Futures', 'ZW', 'Wheat', 'CBOT', 'USD', 1, 1, 93),
('Futures', 'ES', 'S&P 500 E-mini', 'CME', 'USD', 1, 1, 92),
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91)
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91),
-- A股 (CNStock)
('CNStock', '600519', '贵州茅台', 'SSE', 'CNY', 1, 1, 100),
('CNStock', '600036', '招商银行', 'SSE', 'CNY', 1, 1, 99),
('CNStock', '601318', '中国平安', 'SSE', 'CNY', 1, 1, 98),
('CNStock', '600900', '长江电力', 'SSE', 'CNY', 1, 1, 97),
('CNStock', '601899', '紫金矿业', 'SSE', 'CNY', 1, 1, 96),
('CNStock', '000858', '五粮液', 'SZSE', 'CNY', 1, 1, 95),
('CNStock', '000333', '美的集团', 'SZSE', 'CNY', 1, 1, 94),
('CNStock', '002594', '比亚迪', 'SZSE', 'CNY', 1, 1, 93),
('CNStock', '300750', '宁德时代', 'SZSE', 'CNY', 1, 1, 92),
('CNStock', '000001', '平安银行', 'SZSE', 'CNY', 1, 1, 91),
-- 港股/H股 (HKStock)
('HKStock', '00700', '腾讯控股', 'HKEX', 'HKD', 1, 1, 100),
('HKStock', '09988', '阿里巴巴-W', 'HKEX', 'HKD', 1, 1, 99),
('HKStock', '03690', '美团-W', 'HKEX', 'HKD', 1, 1, 98),
('HKStock', '01810', '小米集团-W', 'HKEX', 'HKD', 1, 1, 97),
('HKStock', '00939', '建设银行', 'HKEX', 'HKD', 1, 1, 96),
('HKStock', '01299', '友邦保险', 'HKEX', 'HKD', 1, 1, 95),
('HKStock', '02318', '中国平安', 'HKEX', 'HKD', 1, 1, 94),
('HKStock', '00388', '香港交易所', 'HKEX', 'HKD', 1, 1, 93),
('HKStock', '00883', '中国海洋石油', 'HKEX', 'HKD', 1, 1, 92),
('HKStock', '01398', '工商银行', 'HKEX', 'HKD', 1, 1, 91)
ON CONFLICT (market, symbol) DO NOTHING;
-- =============================================================================
+1
View File
@@ -5,6 +5,7 @@ yfinance>=0.2.18
ccxt>=4.0.0
pandas>=1.5.0
requests>=2.28.0
certifi>=2024.2.2
PySocks>=1.7.1
akshare>=1.12.0
pymysql>=1.0.2
+40
View File
@@ -4,6 +4,46 @@ This document records version updates, new features, bug fixes, and database mig
---
## 2026-04-07 — 数据库:`qd_market_symbols` 补充 A股 / H股热门标的
已在 **Docker** 内对运行中的 PostgreSQL 执行完毕(`INSERT 0 20`)。**新库**若使用当前仓库中的 `migrations/init.sql` 初始化,已包含同批种子数据,无需重复执行。
**在已有库上手动执行(等价 SQL,可重复执行,`ON CONFLICT DO NOTHING`):**
```sql
INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_active, is_hot, sort_order) VALUES
('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),
('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;
```
**Docker 一行示例(文件需 UTF-8):**
```bash
docker cp backend_api_python/migrations/<your>.sql quantdinger-db:/tmp/migrate.sql
docker compose exec -T postgres psql -U quantdinger -d quantdinger -f /tmp/migrate.sql
```
---
## V3.0.1 (2026-04-05) — Frontend / docs
- **前端版本**`QuantDinger-Vue-src/package.json`、页脚展示与 `frontend/VERSION` 统一为 **3.0.1**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,6 +44,14 @@ body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.real
.profile-exchange-modal--dark .exchange-account-form .exchange-api-doc-callout{background:-webkit-gradient(linear,left top,right top,from(rgba(24,144,255,.12)),to(rgba(82,196,26,.08)));background:linear-gradient(90deg,rgba(24,144,255,.12),rgba(82,196,26,.08))}
.polymarket-analysis-modal .result-section .analysis-result .probability-comparison .prob-item .prob-value.ai-prob[data-v-67e891d2]{color:#1890ff}
.polymarket-analysis-modal .result-section .analysis-result .recommendation-section .rec-card .rec-value[data-v-67e891d2]{color:#1890ff}
.comment-list .comment-form .form-header .edit-label[data-v-48b21f80]{color:#1890ff}
.comment-list .comments .comment-item.is-mine[data-v-48b21f80]{background:rgba(24,144,255,.02)}
.dark .comment-list .comments .comment-item.is-mine[data-v-48b21f80],[data-theme=dark] .comment-list .comments .comment-item.is-mine[data-v-48b21f80],body.dark .comment-list .comments .comment-item.is-mine[data-v-48b21f80]{background:rgba(24,144,255,.05)}
.indicator-community-container .market-header .header-left .page-title .anticon[data-v-f59e48f0]{color:#1890ff}
.indicator-community-container.theme-dark .admin-tabs[data-v-f59e48f0] .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active{color:#40a9ff}
.indicator-community-container.theme-dark[data-v-f59e48f0] .ant-radio-group .ant-radio-button-wrapper:hover{color:#40a9ff}
.indicator-community-container.theme-dark[data-v-f59e48f0] .ant-btn-link{color:#40a9ff}
.indicator-community-container.theme-dark[data-v-f59e48f0] .ant-modal-content .ant-list-item-meta-title a{color:#40a9ff}
.code-section .section-header .section-title[data-v-4fea1865]:before{background:#1890ff}
.code-section .section-header .section-actions[data-v-4fea1865] .ant-btn-link{color:#1890ff}
.code-section .section-header .section-actions[data-v-4fea1865] .ant-btn-link:hover{color:#40a9ff}
@@ -54,44 +62,44 @@ body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.real
.editor-footer[data-v-4fea1865] .ant-btn:not(.ant-btn-primary):hover{border-color:#40a9ff;color:#40a9ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.2);box-shadow:0 2px 8px rgba(24,144,255,.2)}
.editor-footer[data-v-4fea1865] .ant-btn.ant-btn-primary{-webkit-box-shadow:0 2px 4px rgba(24,144,255,.3);box-shadow:0 2px 4px rgba(24,144,255,.3)}
.editor-footer[data-v-4fea1865] .ant-btn.ant-btn-primary:hover{-webkit-box-shadow:0 4px 12px rgba(24,144,255,.4);box-shadow:0 4px 12px rgba(24,144,255,.4)}
.drawing-tool-btn[data-v-58f93984]:hover{color:#1890ff}
.drawing-tool-btn.active[data-v-58f93984]{background:#e6f7ff;color:#1890ff;border:1px solid #1890ff}
.indicator-btn[data-v-58f93984]:hover{color:#1890ff;border-color:#1890ff}
.indicator-btn.active[data-v-58f93984]{color:#1890ff;border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.1);box-shadow:0 0 0 2px rgba(24,144,255,.1)}
.symbol-select[data-v-6bd239a6] .ant-select-selection:hover{border-color:#1890ff}
.symbol-select[data-v-6bd239a6] .ant-select-focused .ant-select-selection{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.2);box-shadow:0 0 0 2px rgba(24,144,255,.2)}
.timeframe-item[data-v-6bd239a6]:hover{color:#1890ff}
.timeframe-item.active[data-v-6bd239a6]{color:#1890ff}
.panel-header .realtime-toggle-btn[data-v-6bd239a6]:hover,.panel-header .theme-toggle-btn[data-v-6bd239a6]:hover{color:#1890ff}
.panel-header .realtime-toggle-btn.active[data-v-6bd239a6],.panel-header .theme-toggle-btn.active[data-v-6bd239a6]{color:#1890ff;background:#e6f7ff}
.indicator-card[data-v-6bd239a6]:hover{border-color:#1890ff}
.indicator-card.active[data-v-6bd239a6]{background:#e6f7ff;border-color:#1890ff}
.indicator-card.active .card-name[data-v-6bd239a6]{color:#1890ff}
.card-action[data-v-6bd239a6]:hover{color:#1890ff}
.action-icon.edit-icon[data-v-6bd239a6]{color:#1890ff}
.action-icon.edit-icon[data-v-6bd239a6]:hover{color:#40a9ff}
.action-icon[data-v-6bd239a6]:hover{color:#1890ff}
.action-icon.publish-icon[data-v-6bd239a6]{color:#1890ff}
.action-icon.publish-icon[data-v-6bd239a6]:hover{color:#40a9ff}
.action-icon.expiry-icon[data-v-6bd239a6]{color:#1890ff}
.drawing-tool-btn[data-v-076675b8]:hover{color:#1890ff}
.drawing-tool-btn.active[data-v-076675b8]{background:#e6f7ff;color:#1890ff;border:1px solid #1890ff}
.indicator-btn[data-v-076675b8]:hover{color:#1890ff;border-color:#1890ff}
.indicator-btn.active[data-v-076675b8]{color:#1890ff;border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.1);box-shadow:0 0 0 2px rgba(24,144,255,.1)}
.symbol-select[data-v-11d8a5e2] .ant-select-selection:hover{border-color:#1890ff}
.symbol-select[data-v-11d8a5e2] .ant-select-focused .ant-select-selection{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.2);box-shadow:0 0 0 2px rgba(24,144,255,.2)}
.timeframe-item[data-v-11d8a5e2]:hover{color:#1890ff}
.timeframe-item.active[data-v-11d8a5e2]{color:#1890ff}
.panel-header .realtime-toggle-btn[data-v-11d8a5e2]:hover,.panel-header .theme-toggle-btn[data-v-11d8a5e2]:hover{color:#1890ff}
.panel-header .realtime-toggle-btn.active[data-v-11d8a5e2],.panel-header .theme-toggle-btn.active[data-v-11d8a5e2]{color:#1890ff;background:#e6f7ff}
.indicator-card[data-v-11d8a5e2]:hover{border-color:#1890ff}
.indicator-card.active[data-v-11d8a5e2]{background:#e6f7ff;border-color:#1890ff}
.indicator-card.active .card-name[data-v-11d8a5e2]{color:#1890ff}
.card-action[data-v-11d8a5e2]:hover{color:#1890ff}
.action-icon.edit-icon[data-v-11d8a5e2]{color:#1890ff}
.action-icon.edit-icon[data-v-11d8a5e2]:hover{color:#40a9ff}
.action-icon[data-v-11d8a5e2]:hover{color:#1890ff}
.action-icon.publish-icon[data-v-11d8a5e2]{color:#1890ff}
.action-icon.publish-icon[data-v-11d8a5e2]:hover{color:#40a9ff}
.action-icon.expiry-icon[data-v-11d8a5e2]{color:#1890ff}
.dark-dropdown .ant-select-dropdown-menu-item-active{background-color:#e6f7ff;color:#1890ff}
.qt-header-btn[data-v-6bd239a6]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
.qt-header-btn[data-v-6bd239a6]:focus,.qt-header-btn[data-v-6bd239a6]:hover{background:linear-gradient(135deg,#40a9ff,#9254de);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.45);box-shadow:0 4px 12px rgba(24,144,255,.45)}
.qt-floating-btn[data-v-6bd239a6]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.4);box-shadow:0 4px 16px rgba(24,144,255,.4)}
.qt-floating-btn[data-v-6bd239a6]:hover{-webkit-box-shadow:0 6px 24px rgba(24,144,255,.55);box-shadow:0 6px 24px rgba(24,144,255,.55)}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-6bd239a6] .ant-select-selection:hover,body.dark,body.realdark{border-color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-6bd239a6] .ant-select-focused .ant-select-selection,body.dark,body.realdark{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.3);box-shadow:0 0 0 2px rgba(24,144,255,.3)}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item.active[data-v-6bd239a6],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn.active[data-v-6bd239a6],.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-6bd239a6],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#40a9ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active[data-v-6bd239a6],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card[data-v-6bd239a6]:hover,body.dark,body.realdark{border-color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active .card-name[data-v-6bd239a6],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-6bd239a6],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#40a9ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-6bd239a6],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-6bd239a6]:hover,body.dark,body.realdark{color:#40a9ff}
.qt-header-btn[data-v-11d8a5e2]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
.qt-header-btn[data-v-11d8a5e2]:focus,.qt-header-btn[data-v-11d8a5e2]:hover{background:linear-gradient(135deg,#40a9ff,#9254de);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.45);box-shadow:0 4px 12px rgba(24,144,255,.45)}
.qt-floating-btn[data-v-11d8a5e2]{background:linear-gradient(135deg,#1890ff,#722ed1);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.4);box-shadow:0 4px 16px rgba(24,144,255,.4)}
.qt-floating-btn[data-v-11d8a5e2]:hover{-webkit-box-shadow:0 6px 24px rgba(24,144,255,.55);box-shadow:0 6px 24px rgba(24,144,255,.55)}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-11d8a5e2] .ant-select-selection:hover,body.dark,body.realdark{border-color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .symbol-select[data-v-11d8a5e2] .ant-select-focused .ant-select-selection,body.dark,body.realdark{border-color:#1890ff;-webkit-box-shadow:0 0 0 2px rgba(24,144,255,.3);box-shadow:0 0 0 2px rgba(24,144,255,.3)}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item[data-v-11d8a5e2]:hover,body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .timeframe-group .timeframe-item.active[data-v-11d8a5e2],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn.active[data-v-11d8a5e2],.chart-container.theme-dark .chart-right .indicators-panel .panel-header .realtime-toggle-btn[data-v-11d8a5e2]:hover,body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-11d8a5e2],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .section-label .buy-indicator-btn[data-v-11d8a5e2]:hover,body.dark,body.realdark{color:#40a9ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active[data-v-11d8a5e2],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card[data-v-11d8a5e2]:hover,body.dark,body.realdark{border-color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card.active .card-name[data-v-11d8a5e2],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-11d8a5e2],.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon[data-v-11d8a5e2]:hover,body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.edit-icon[data-v-11d8a5e2]:hover,body.dark,body.realdark{color:#40a9ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-11d8a5e2],body.dark,body.realdark{color:#1890ff}
.ant-layout.dark,.ant-layout.realdark,.ant-pro-layout.dark,.ant-pro-layout.realdark,.chart-container.theme-dark .chart-right .indicators-panel .indicator-card .action-icon.publish-icon[data-v-11d8a5e2]:hover,body.dark,body.realdark{color:#40a9ff}
.ai-markdown-content[data-v-9c4d8af2] h3{border-left:3px solid #1890ff}
.ai-markdown-content[data-v-9c4d8af2] blockquote{border-left:4px solid #91d5ff}
.backtest-history-drawer-wrap--dark .drawer-toolbar .ant-btn-primary.ant-btn-background-ghost{color:#69c0ff}
@@ -107,11 +115,11 @@ body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected,body.real
[data-v-1daf6ac6] .result-ai-markdown-content/deep/blockquote{border-left:3px solid #91d5ff}
.add-item-active[data-v-1daf6ac6]{background:#e6f7ff!important}
.theme-dark .page-header .page-title .title-icon[data-v-1daf6ac6]{color:#40a9ff!important}
.trading-records[data-v-bdf54cd6] .ant-tag[color=blue]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(24,144,255,.08));color:#1890ff;border:1px solid rgba(24,144,255,.3)}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-item:hover a{color:#1890ff}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-item.ant-pagination-item-active{background:linear-gradient(135deg,#1890ff,#40a9ff);border-color:#1890ff}
.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-next .ant-pagination-item-link:hover,.trading-records[data-v-bdf54cd6] .ant-pagination .ant-pagination-prev .ant-pagination-item-link:hover{border-color:#1890ff;color:#1890ff}
.trading-records[data-v-46c92dab] .ant-tag[color=blue]{background:linear-gradient(135deg,rgba(24,144,255,.15),rgba(24,144,255,.08));color:#1890ff;border:1px solid rgba(24,144,255,.3)}
.trading-records[data-v-46c92dab] .ant-pagination .ant-pagination-item:hover{border-color:#1890ff}
.trading-records[data-v-46c92dab] .ant-pagination .ant-pagination-item:hover a{color:#1890ff}
.trading-records[data-v-46c92dab] .ant-pagination .ant-pagination-item.ant-pagination-item-active{background:linear-gradient(135deg,#1890ff,#40a9ff);border-color:#1890ff}
.trading-records[data-v-46c92dab] .ant-pagination .ant-pagination-next .ant-pagination-item-link:hover,.trading-records[data-v-46c92dab] .ant-pagination .ant-pagination-prev .ant-pagination-item-link:hover{border-color:#1890ff;color:#1890ff}
.theme-dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover,body.dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover,body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover{border-color:#1890ff!important}
.theme-dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover a,body.dark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover a,body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-item:hover a{color:#1890ff!important}
.theme-dark .trading-records[data-v-8a68b65a] .ant-pagination-item-active,body.dark .trading-records[data-v-8a68b65a] .ant-pagination-item-active,body.realdark .trading-records[data-v-8a68b65a] .ant-pagination-item-active{background:#1890ff!important;border-color:#1890ff!important}
@@ -150,55 +158,58 @@ body.dark .trading-records ::v-deep .ant-pagination .ant-pagination-next:hover .
.theme-dark .side-tabs[data-v-35a0a386] .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#1890ff}
.theme-dark .ai-status[data-v-35a0a386]{color:#40a9ff}
.theme-dark[data-v-35a0a386] .ant-alert-info{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
.theme-dark .logs-toolbar[data-v-73589ce4] .ant-radio-group .ant-radio-button-wrapper:hover{color:#40a9ff}
.theme-dark .logs-toolbar[data-v-73589ce4] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:#1890ff;border-color:#1890ff}
.assistant-guide-bar[data-v-533cb131]{border:1px solid rgba(24,144,255,.14);background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06))}
.assistant-guide-bar .assistant-guide-eyebrow[data-v-533cb131]{background:rgba(24,144,255,.12)}
.assistant-guide-bar .assistant-guide-actions .assistant-guide-close[data-v-533cb131]:focus,.assistant-guide-bar .assistant-guide-actions .assistant-guide-close[data-v-533cb131]:hover{border-color:rgba(24,144,255,.35)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-533cb131]{background:linear-gradient(135deg,#1890ff,#40a9ff);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.35);box-shadow:0 4px 12px rgba(24,144,255,.35)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-533cb131]:hover{-webkit-box-shadow:0 6px 16px rgba(24,144,255,.45);box-shadow:0 6px 16px rgba(24,144,255,.45)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-header .group-header-left .group-icon[data-v-533cb131]{color:#1890ff}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item[data-v-533cb131]:hover{border-left-color:#1890ff;border-color:rgba(24,144,255,.22);-webkit-box-shadow:0 2px 8px rgba(24,144,255,.08);box-shadow:0 2px 8px rgba(24,144,255,.08)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item.active[data-v-533cb131]{border-color:rgba(24,144,255,.35);border-left-color:#1890ff;-webkit-box-shadow:0 2px 12px rgba(24,144,255,.12);box-shadow:0 2px 12px rgba(24,144,255,.12)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item[data-v-533cb131]:hover{border-color:rgba(24,144,255,.2);-webkit-box-shadow:0 2px 12px rgba(24,144,255,.1);box-shadow:0 2px 12px rgba(24,144,255,.1)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item.active[data-v-533cb131]{border-color:#1890ff;border-left:4px solid #1890ff;-webkit-box-shadow:0 4px 16px rgba(24,144,255,.15);box-shadow:0 4px 16px rgba(24,144,255,.15)}
.trading-assistant .strategy-detail-col .strategy-empty-detail-icon[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.12),rgba(64,169,255,.08));color:#1890ff}
.trading-assistant .strategy-detail-col .strategy-detail-panel .strategy-header-card .strategy-header .header-left .strategy-tags .tag-item .anticon[data-v-533cb131]{color:#1890ff}
.trading-assistant.theme-dark .creation-mode-toggle[data-v-533cb131]{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-list-col .group-mode-switch[data-v-533cb131] .ant-radio-button-wrapper:hover{color:#69c0ff}
.trading-assistant.theme-dark .strategy-list-col .group-mode-switch[data-v-533cb131] .ant-radio-button-wrapper-checked{background:rgba(24,144,255,.25)!important;border-color:#1890ff!important}
.trading-assistant.theme-dark .strategy-list-col .strategy-group-content .strategy-list-item[data-v-533cb131]:hover{border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-group-content .strategy-list-item.active[data-v-533cb131]{background:rgba(24,144,255,.12);border-color:rgba(24,144,255,.28);border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-item.active[data-v-533cb131]{background:rgba(24,144,255,.1);border-color:#1890ff;-webkit-box-shadow:0 4px 20px rgba(24,144,255,.2);box-shadow:0 4px 20px rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-header-card .strategy-tags .tag-item[data-v-533cb131]:hover{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-533cb131] .ant-tabs .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#1890ff}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-533cb131] .ant-tabs .ant-tabs-nav .ant-tabs-ink-bar{background:-webkit-gradient(linear,left top,right top,from(#1890ff),to(#40a9ff));background:linear-gradient(90deg,#1890ff,#40a9ff)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-empty-detail-icon[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.22),rgba(64,169,255,.1));color:#69c0ff}
.theme-dark .assistant-guide-bar .assistant-guide-eyebrow[data-v-533cb131]{color:#69c0ff}
.theme-dark .assistant-guide-bar .assistant-guide-close[data-v-533cb131]:focus,.theme-dark .assistant-guide-bar .assistant-guide-close[data-v-533cb131]:hover{color:#91d5ff}
.ai-filter-title .anticon[data-v-533cb131]{color:#1890ff}
.creation-mode-toggle[data-v-533cb131]{background:rgba(24,144,255,.04);border:1px solid rgba(24,144,255,.12)}
.simple-mode-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06));border:1px solid rgba(24,144,255,.14)}
.simple-mode-kicker[data-v-533cb131]{color:#1890ff}
.advanced-settings-shell[data-v-533cb131],.selected-indicator-card[data-v-533cb131],.simple-essentials-card[data-v-533cb131]{border:1px solid rgba(24,144,255,.12)}
.execution-step-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(82,196,26,.08),rgba(24,144,255,.08));border:1px solid rgba(24,144,255,.14)}
.execution-section-card[data-v-533cb131]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-533cb131]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-533cb131]:hover{border-color:rgba(24,144,255,.35);-webkit-box-shadow:0 8px 24px rgba(24,144,255,.08);box-shadow:0 8px 24px rgba(24,144,255,.08)}
.execution-mode-card.active[data-v-533cb131]{border-color:#1890ff;background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(24,144,255,.03));-webkit-box-shadow:0 10px 28px rgba(24,144,255,.12);box-shadow:0 10px 28px rgba(24,144,255,.12)}
.execution-mode-card.disabled[data-v-533cb131]:hover{border-color:rgba(24,144,255,.12)}
.execution-mode-card-icon.signal[data-v-533cb131]{color:#1890ff;background:rgba(24,144,255,.1)}
.execution-mode-card-check[data-v-533cb131]{color:#1890ff}
.strategy-type-selector .strategy-type-card[data-v-533cb131]:hover{border-color:#1890ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.2);box-shadow:0 2px 8px rgba(24,144,255,.2)}
.strategy-type-selector .strategy-type-card.selected[data-v-533cb131]{border-color:#1890ff;background-color:#e6f7ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
.strategy-type-selector .strategy-type-card .strategy-type-content .strategy-type-icon[data-v-533cb131]{color:#1890ff}
.theme-dark .simple-mode-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.14),rgba(114,46,209,.12))}
.theme-dark .execution-step-hero[data-v-533cb131]{background:linear-gradient(135deg,rgba(82,196,26,.14),rgba(24,144,255,.14))}
.theme-dark .execution-mode-card[data-v-533cb131]:hover{-webkit-box-shadow:0 8px 24px rgba(24,144,255,.12);box-shadow:0 8px 24px rgba(24,144,255,.12)}
.theme-dark .execution-mode-card.active[data-v-533cb131]{background:linear-gradient(135deg,rgba(24,144,255,.16),rgba(24,144,255,.08));border-color:#1890ff}
.theme-dark .simple-mode-kicker[data-v-533cb131]{color:#69c0ff}
.ip-whitelist-tip[data-v-533cb131]{background-color:#e6f7ff;border:1px solid #91d5ff;color:#1890ff}
.ip-whitelist-tip .anticon[data-v-533cb131]{color:#1890ff}
.theme-dark .logs-toolbar[data-v-5ea7be7d] .ant-radio-group .ant-radio-button-wrapper:hover{color:#40a9ff}
.theme-dark .logs-toolbar[data-v-5ea7be7d] .ant-radio-group .ant-radio-button-wrapper.ant-radio-button-wrapper-checked{background:#1890ff;border-color:#1890ff}
.assistant-guide-bar[data-v-7a5d79cc]{border:1px solid rgba(24,144,255,.14);background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06))}
.assistant-guide-bar .assistant-guide-eyebrow[data-v-7a5d79cc]{background:rgba(24,144,255,.12)}
.assistant-guide-bar .assistant-guide-actions .assistant-guide-close[data-v-7a5d79cc]:focus,.assistant-guide-bar .assistant-guide-actions .assistant-guide-close[data-v-7a5d79cc]:hover{border-color:rgba(24,144,255,.35)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-7a5d79cc]{background:linear-gradient(135deg,#1890ff,#40a9ff);-webkit-box-shadow:0 4px 12px rgba(24,144,255,.35);box-shadow:0 4px 12px rgba(24,144,255,.35)}
.trading-assistant .strategy-list-col .strategy-list-card .card-title .ant-btn-primary[data-v-7a5d79cc]:hover{-webkit-box-shadow:0 6px 16px rgba(24,144,255,.45);box-shadow:0 6px 16px rgba(24,144,255,.45)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-header .group-header-left .group-icon[data-v-7a5d79cc]{color:#1890ff}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item[data-v-7a5d79cc]:hover{border-left-color:#1890ff;border-color:rgba(24,144,255,.22);-webkit-box-shadow:0 2px 8px rgba(24,144,255,.08);box-shadow:0 2px 8px rgba(24,144,255,.08)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item.active[data-v-7a5d79cc]{border-color:rgba(24,144,255,.35);border-left-color:#1890ff;-webkit-box-shadow:0 2px 12px rgba(24,144,255,.12);box-shadow:0 2px 12px rgba(24,144,255,.12)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item[data-v-7a5d79cc]:hover{border-color:rgba(24,144,255,.2);-webkit-box-shadow:0 2px 12px rgba(24,144,255,.1);box-shadow:0 2px 12px rgba(24,144,255,.1)}
.trading-assistant .strategy-list-col .strategy-list-card .strategy-list-item.active[data-v-7a5d79cc]{border-color:#1890ff;border-left:4px solid #1890ff;-webkit-box-shadow:0 4px 16px rgba(24,144,255,.15);box-shadow:0 4px 16px rgba(24,144,255,.15)}
.trading-assistant .strategy-detail-col .strategy-empty-detail-icon[data-v-7a5d79cc]{background:linear-gradient(135deg,rgba(24,144,255,.12),rgba(64,169,255,.08));color:#1890ff}
.trading-assistant .strategy-detail-col .strategy-detail-panel .strategy-header-card .strategy-header .header-left .strategy-tags .tag-item .anticon[data-v-7a5d79cc]{color:#1890ff}
.trading-assistant.theme-dark .creation-mode-toggle[data-v-7a5d79cc]{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-header .group-icon[data-v-7a5d79cc]{color:#69c0ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item[data-v-7a5d79cc]:hover{border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-card .strategy-grouped-list .strategy-group .strategy-group-content .strategy-list-item.active[data-v-7a5d79cc]{background:rgba(24,144,255,.15);border-color:rgba(24,144,255,.35);border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .group-mode-switch[data-v-7a5d79cc] .ant-radio-button-wrapper:hover{color:#69c0ff}
.trading-assistant.theme-dark .strategy-list-col .group-mode-switch[data-v-7a5d79cc] .ant-radio-button-wrapper-checked{background:rgba(24,144,255,.25)!important;border-color:#1890ff!important}
.trading-assistant.theme-dark .strategy-list-col .strategy-group-content .strategy-list-item[data-v-7a5d79cc]:hover{border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-group-content .strategy-list-item.active[data-v-7a5d79cc]{background:rgba(24,144,255,.12);border-color:rgba(24,144,255,.28);border-left-color:#1890ff}
.trading-assistant.theme-dark .strategy-list-col .strategy-list-item.active[data-v-7a5d79cc]{background:rgba(24,144,255,.1);border-color:#1890ff;-webkit-box-shadow:0 4px 20px rgba(24,144,255,.2);box-shadow:0 4px 20px rgba(24,144,255,.2)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-header-card .strategy-tags .tag-item[data-v-7a5d79cc]:hover{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-7a5d79cc] .ant-tabs .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn{color:#1890ff}
.trading-assistant.theme-dark .strategy-detail-col .strategy-content-card[data-v-7a5d79cc] .ant-tabs .ant-tabs-nav .ant-tabs-ink-bar{background:-webkit-gradient(linear,left top,right top,from(#1890ff),to(#40a9ff));background:linear-gradient(90deg,#1890ff,#40a9ff)}
.trading-assistant.theme-dark .strategy-detail-col .strategy-empty-detail-icon[data-v-7a5d79cc]{background:linear-gradient(135deg,rgba(24,144,255,.22),rgba(64,169,255,.1));color:#69c0ff}
.theme-dark .assistant-guide-bar .assistant-guide-eyebrow[data-v-7a5d79cc]{color:#69c0ff}
.theme-dark .assistant-guide-bar .assistant-guide-close[data-v-7a5d79cc]:focus,.theme-dark .assistant-guide-bar .assistant-guide-close[data-v-7a5d79cc]:hover{color:#91d5ff}
.ai-filter-title .anticon[data-v-7a5d79cc]{color:#1890ff}
.creation-mode-toggle[data-v-7a5d79cc]{background:rgba(24,144,255,.04);border:1px solid rgba(24,144,255,.12)}
.simple-mode-hero[data-v-7a5d79cc]{background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(114,46,209,.06));border:1px solid rgba(24,144,255,.14)}
.simple-mode-kicker[data-v-7a5d79cc]{color:#1890ff}
.advanced-settings-shell[data-v-7a5d79cc],.selected-indicator-card[data-v-7a5d79cc],.simple-essentials-card[data-v-7a5d79cc]{border:1px solid rgba(24,144,255,.12)}
.execution-step-hero[data-v-7a5d79cc]{background:linear-gradient(135deg,rgba(82,196,26,.08),rgba(24,144,255,.08));border:1px solid rgba(24,144,255,.14)}
.execution-section-card[data-v-7a5d79cc]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-7a5d79cc]{border:1px solid rgba(24,144,255,.12)}
.execution-mode-card[data-v-7a5d79cc]:hover{border-color:rgba(24,144,255,.35);-webkit-box-shadow:0 8px 24px rgba(24,144,255,.08);box-shadow:0 8px 24px rgba(24,144,255,.08)}
.execution-mode-card.active[data-v-7a5d79cc]{border-color:#1890ff;background:linear-gradient(135deg,rgba(24,144,255,.08),rgba(24,144,255,.03));-webkit-box-shadow:0 10px 28px rgba(24,144,255,.12);box-shadow:0 10px 28px rgba(24,144,255,.12)}
.execution-mode-card.disabled[data-v-7a5d79cc]:hover{border-color:rgba(24,144,255,.12)}
.execution-mode-card-icon.signal[data-v-7a5d79cc]{color:#1890ff;background:rgba(24,144,255,.1)}
.execution-mode-card-check[data-v-7a5d79cc]{color:#1890ff}
.strategy-type-selector .strategy-type-card[data-v-7a5d79cc]:hover{border-color:#1890ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.2);box-shadow:0 2px 8px rgba(24,144,255,.2)}
.strategy-type-selector .strategy-type-card.selected[data-v-7a5d79cc]{border-color:#1890ff;background-color:#e6f7ff;-webkit-box-shadow:0 2px 8px rgba(24,144,255,.3);box-shadow:0 2px 8px rgba(24,144,255,.3)}
.strategy-type-selector .strategy-type-card .strategy-type-content .strategy-type-icon[data-v-7a5d79cc]{color:#1890ff}
.theme-dark .simple-mode-hero[data-v-7a5d79cc]{background:linear-gradient(135deg,rgba(24,144,255,.14),rgba(114,46,209,.12))}
.theme-dark .execution-step-hero[data-v-7a5d79cc]{background:linear-gradient(135deg,rgba(82,196,26,.14),rgba(24,144,255,.14))}
.theme-dark .execution-mode-card[data-v-7a5d79cc]:hover{-webkit-box-shadow:0 8px 24px rgba(24,144,255,.12);box-shadow:0 8px 24px rgba(24,144,255,.12)}
.theme-dark .execution-mode-card.active[data-v-7a5d79cc]{background:linear-gradient(135deg,rgba(24,144,255,.16),rgba(24,144,255,.08));border-color:#1890ff}
.theme-dark .simple-mode-kicker[data-v-7a5d79cc]{color:#69c0ff}
.ip-whitelist-tip[data-v-7a5d79cc]{background-color:#e6f7ff;border:1px solid #91d5ff;color:#1890ff}
.ip-whitelist-tip .anticon[data-v-7a5d79cc]{color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-modal-content .creation-mode-toggle{background:rgba(24,144,255,.08);border-color:rgba(24,144,255,.2)}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-steps .ant-steps-item-process .ant-steps-item-icon{background:#1890ff;border-color:#1890ff}
body.dark .strategy-form-modal.strategy-form-modal-dark .ant-steps .ant-steps-item-finish .ant-steps-item-icon{border-color:#1890ff}
@@ -219,14 +230,6 @@ body.dark .strategy-form-modal.strategy-form-modal-dark .simple-mode-kicker{colo
body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-active,body.dark .ant-select-dropdown .ant-select-dropdown-menu-item:hover{background:rgba(24,144,255,.1)}
body.dark .ant-select-dropdown .ant-select-dropdown-menu-item-selected{background:rgba(24,144,255,.15);color:#1890ff}
body.dark .ant-modal-wrap .ant-alert{background:rgba(24,144,255,.06);border-color:rgba(24,144,255,.2)}
.comment-list .comment-form .form-header .edit-label[data-v-48b21f80]{color:#1890ff}
.comment-list .comments .comment-item.is-mine[data-v-48b21f80]{background:rgba(24,144,255,.02)}
.dark .comment-list .comments .comment-item.is-mine[data-v-48b21f80],[data-theme=dark] .comment-list .comments .comment-item.is-mine[data-v-48b21f80],body.dark .comment-list .comments .comment-item.is-mine[data-v-48b21f80]{background:rgba(24,144,255,.05)}
.indicator-community-container .market-header .header-left .page-title .anticon[data-v-f59e48f0]{color:#1890ff}
.indicator-community-container.theme-dark .admin-tabs[data-v-f59e48f0] .ant-tabs-nav .ant-tabs-tab.ant-tabs-tab-active{color:#40a9ff}
.indicator-community-container.theme-dark[data-v-f59e48f0] .ant-radio-group .ant-radio-button-wrapper:hover{color:#40a9ff}
.indicator-community-container.theme-dark[data-v-f59e48f0] .ant-btn-link{color:#40a9ff}
.indicator-community-container.theme-dark[data-v-f59e48f0] .ant-modal-content .ant-list-item-meta-title a{color:#40a9ff}
.fast-analysis-report .loading-container .loading-content-pro .loading-header .loading-icon-pro[data-v-03feb1fa]{color:var(--primary-color,#1890ff)}
.fast-analysis-report .loading-container .loading-content-pro .progress-wrapper .progress-text[data-v-03feb1fa]{color:var(--primary-color,#1890ff)}
.fast-analysis-report .loading-container .loading-content-pro .current-step[data-v-03feb1fa]{background:color-mix(in srgb,var(--primary-color,#1890ff) 6%,transparent);color:var(--primary-color,#1890ff)}
@@ -259,39 +262,39 @@ body.dark .ant-modal-wrap .ant-alert{background:rgba(24,144,255,.06);border-colo
.fast-analysis-report.theme-dark .result-container .indicators-section .indicators-methodology .anticon[data-v-03feb1fa]{color:var(--primary-color,#1890ff)}
.fast-analysis-report.theme-dark .result-container .indicators-section .indicators-pro-wrap .indicators-pro-title .anticon[data-v-03feb1fa]{color:var(--primary-color,#1890ff)}
.fast-analysis-report.theme-dark .result-container .feedback-section .ant-btn[data-v-03feb1fa]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 30%,transparent);color:var(--primary-color,#1890ff)}
.left-panel .calendar-box .box-header .box-title .anticon[data-v-22f26594]{color:var(--primary-color,#1890ff)}
.right-panel .analysis-toolbar .analyze-button[data-v-22f26594]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .watchlist-panel .batch-bar .ant-btn[data-v-22f26594]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active[data-v-22f26594]{background:color-mix(in srgb,var(--primary-color,#1890ff) 8%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent)}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card-hover-actions .wl-hover-btn[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active .wl-card-hover-actions[data-v-22f26594]{background:linear-gradient(90deg,transparent 0,color-mix(in srgb,var(--primary-color,#1890ff) 6%,transparent) 30%)}
.ai-analysis-container.theme-dark .placeholder-hero .hero-badge[data-v-22f26594]{background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .placeholder-hero .hstat[data-v-22f26594]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);-webkit-box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
.ai-analysis-container.theme-dark .placeholder-hero .hstat-icon[data-v-22f26594]{background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .placeholder-hero .hero-cta .ant-btn-primary[data-v-22f26594]{-webkit-box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent)}
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-22f26594],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-22f26594]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
.ai-analysis-container.theme-dark .right-panel .analysis-toolbar .history-button[data-v-22f26594]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 45%,transparent);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .hero-cta .ant-btn[data-v-22f26594]:not(.ant-btn-primary):hover{border-color:#1890ff;color:#1890ff}
.ai-analysis-container.theme-dark .panel-header-icon[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
.ai-analysis-container.theme-dark .batch-bar .ant-btn[data-v-22f26594]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff)!important;color:var(--primary-color,#1890ff)!important}
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-22f26594],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-22f26594]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-tabs-tab-active{color:#1890ff!important}
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-tabs-ink-bar{background-color:#1890ff}
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
.ai-analysis-container.theme-dark[data-v-22f26594] .ant-alert{background:rgba(24,144,255,.06)}
.hero-bg-circle.c1[data-v-22f26594]{background:radial-gradient(circle,rgba(24,144,255,.1) 0,transparent 70%)}
.hero-bg-grid[data-v-22f26594]{background-image:linear-gradient(rgba(24,144,255,.03) 1px,transparent 0),linear-gradient(90deg,rgba(24,144,255,.03) 1px,transparent 0)}
.hero-badge[data-v-22f26594]{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08);border:1px solid rgba(24,144,255,.2)}
.hstat[data-v-22f26594]:hover{border-color:var(--primary-color,#1890ff);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.1);box-shadow:0 4px 16px rgba(24,144,255,.1)}
.hstat-icon[data-v-22f26594]{background:linear-gradient(135deg,rgba(24,144,255,.1),rgba(114,46,209,.08));color:var(--primary-color,#1890ff)}
.hero-cta .ant-btn-primary[data-v-22f26594]{-webkit-box-shadow:0 4px 14px rgba(24,144,255,.3);box-shadow:0 4px 14px rgba(24,144,255,.3)}
.panel-header-icon[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
.batch-bar .ant-btn-primary[data-v-22f26594]{-webkit-box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent);box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent)}
.wl-card.active[data-v-22f26594]{background:linear-gradient(135deg,color-mix(in srgb,var(--primary-color,#1890ff) 6%,#fff) 0,color-mix(in srgb,var(--primary-color,#1890ff) 4%,#fff) 100%);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent);-webkit-box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
.wl-card.active .wl-card-hover-actions[data-v-22f26594]{background:-webkit-gradient(linear,left top,right top,from(transparent),color-stop(30%,#e6f7ff));background:linear-gradient(90deg,transparent,#e6f7ff 30%)}
.wl-hover-btn[data-v-22f26594]:hover{color:var(--primary-color,#1890ff);background:#e6f7ff}
.task-item-actions .ant-btn.ant-btn-primary[data-v-22f26594]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.task-item-actions .ant-btn.ant-btn-primary[data-v-22f26594]:hover{-webkit-box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent)}
.left-panel .calendar-box .box-header .box-title .anticon[data-v-0fce93ef]{color:var(--primary-color,#1890ff)}
.right-panel .analysis-toolbar .analyze-button[data-v-0fce93ef]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .watchlist-panel .batch-bar .ant-btn[data-v-0fce93ef]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active[data-v-0fce93ef]{background:color-mix(in srgb,var(--primary-color,#1890ff) 8%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent)}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card-hover-actions .wl-hover-btn[data-v-0fce93ef]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
.ai-analysis-container.theme-dark .watchlist-panel .watchlist-list .wl-card.active .wl-card-hover-actions[data-v-0fce93ef]{background:linear-gradient(90deg,transparent 0,color-mix(in srgb,var(--primary-color,#1890ff) 6%,transparent) 30%)}
.ai-analysis-container.theme-dark .placeholder-hero .hero-badge[data-v-0fce93ef]{background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .placeholder-hero .hstat[data-v-0fce93ef]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);-webkit-box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);box-shadow:0 4px 16px color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent)}
.ai-analysis-container.theme-dark .placeholder-hero .hstat-icon[data-v-0fce93ef]{background:color-mix(in srgb,var(--primary-color,#1890ff) 12%,transparent);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .placeholder-hero .hero-cta .ant-btn-primary[data-v-0fce93ef]{-webkit-box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent);box-shadow:0 4px 14px color-mix(in srgb,var(--primary-color,#1890ff) 35%,transparent)}
.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip.active[data-v-0fce93ef],.ai-analysis-container.theme-dark .watchlist-bar-legacy .stock-chip[data-v-0fce93ef]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
.ai-analysis-container.theme-dark .right-panel .analysis-toolbar .history-button[data-v-0fce93ef]:hover{border-color:color-mix(in srgb,var(--primary-color,#1890ff) 45%,transparent);color:var(--primary-color,#1890ff)}
.ai-analysis-container.theme-dark .hero-cta .ant-btn[data-v-0fce93ef]:not(.ant-btn-primary):hover{border-color:#1890ff;color:#1890ff}
.ai-analysis-container.theme-dark .panel-header-icon[data-v-0fce93ef]:hover{color:var(--primary-color,#1890ff);background:color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
.ai-analysis-container.theme-dark .batch-bar .ant-btn[data-v-0fce93ef]:not(.ant-btn-primary):hover{border-color:var(--primary-color,#1890ff)!important;color:var(--primary-color,#1890ff)!important}
.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip.active[data-v-0fce93ef],.ai-analysis-container.theme-dark .watchlist-bar-compat .stock-chip[data-v-0fce93ef]:hover{border-color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
.ai-analysis-container.theme-dark[data-v-0fce93ef] .ant-tabs-tab-active{color:#1890ff!important}
.ai-analysis-container.theme-dark[data-v-0fce93ef] .ant-tabs-ink-bar{background-color:#1890ff}
.ai-analysis-container.theme-dark[data-v-0fce93ef] .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
.ai-analysis-container.theme-dark[data-v-0fce93ef] .ant-alert{background:rgba(24,144,255,.06)}
.hero-bg-circle.c1[data-v-0fce93ef]{background:radial-gradient(circle,rgba(24,144,255,.1) 0,transparent 70%)}
.hero-bg-grid[data-v-0fce93ef]{background-image:linear-gradient(rgba(24,144,255,.03) 1px,transparent 0),linear-gradient(90deg,rgba(24,144,255,.03) 1px,transparent 0)}
.hero-badge[data-v-0fce93ef]{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08);border:1px solid rgba(24,144,255,.2)}
.hstat[data-v-0fce93ef]:hover{border-color:var(--primary-color,#1890ff);-webkit-box-shadow:0 4px 16px rgba(24,144,255,.1);box-shadow:0 4px 16px rgba(24,144,255,.1)}
.hstat-icon[data-v-0fce93ef]{background:linear-gradient(135deg,rgba(24,144,255,.1),rgba(114,46,209,.08));color:var(--primary-color,#1890ff)}
.hero-cta .ant-btn-primary[data-v-0fce93ef]{-webkit-box-shadow:0 4px 14px rgba(24,144,255,.3);box-shadow:0 4px 14px rgba(24,144,255,.3)}
.panel-header-icon[data-v-0fce93ef]:hover{color:var(--primary-color,#1890ff);background:rgba(24,144,255,.08)}
.batch-bar .ant-btn-primary[data-v-0fce93ef]{-webkit-box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent);box-shadow:0 1px 2px color-mix(in srgb,var(--primary-color,#1890ff) 20%,transparent)}
.wl-card.active[data-v-0fce93ef]{background:linear-gradient(135deg,color-mix(in srgb,var(--primary-color,#1890ff) 6%,#fff) 0,color-mix(in srgb,var(--primary-color,#1890ff) 4%,#fff) 100%);border-color:color-mix(in srgb,var(--primary-color,#1890ff) 28%,transparent);-webkit-box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent);box-shadow:0 1px 4px color-mix(in srgb,var(--primary-color,#1890ff) 10%,transparent)}
.wl-card.active .wl-card-hover-actions[data-v-0fce93ef]{background:-webkit-gradient(linear,left top,right top,from(transparent),color-stop(30%,#e6f7ff));background:linear-gradient(90deg,transparent,#e6f7ff 30%)}
.wl-hover-btn[data-v-0fce93ef]:hover{color:var(--primary-color,#1890ff);background:#e6f7ff}
.task-item-actions .ant-btn.ant-btn-primary[data-v-0fce93ef]{background:var(--primary-color,#1890ff);border-color:var(--primary-color,#1890ff)}
.task-item-actions .ant-btn.ant-btn-primary[data-v-0fce93ef]:hover{-webkit-box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent);box-shadow:0 2px 6px color-mix(in srgb,var(--primary-color,#1890ff) 25%,transparent)}
.qd-dark-modal .ant-modal-footer .ant-btn-default:hover{border-color:#1890ff;color:#1890ff}
.qd-dark-modal .ant-tabs-tab-active{color:#1890ff!important}
.qd-dark-modal .ant-tag-blue{background:rgba(24,144,255,.1);border-color:rgba(24,144,255,.3);color:#1890ff}
+1 -1
View File
@@ -421,4 +421,4 @@
.brand-text {
font-size: 20px;
}
}</style><script defer="defer" src="/js/chunk-vendors.7a650f0b.js" type="module"></script><script defer="defer" src="/js/app.1b0410d5.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.9dca4aef.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.e8633248.js" nomodule></script><script defer="defer" src="/js/app-legacy.abf8ca7f.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
}</style><script defer="defer" src="/js/chunk-vendors.ac6f37ff.js" type="module"></script><script defer="defer" src="/js/app.e1e54a25.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.d31596cf.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.ad9b5cc2.js" nomodule></script><script defer="defer" src="/js/app-legacy.78cc7a1b.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More