@@ -11,6 +11,7 @@ WORKDIR /app
|
||||
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/*.sources 2>/dev/null || true \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends --fix-missing \
|
||||
ca-certificates \
|
||||
curl \
|
||||
# Build deps (needed for some pyproject-based packages like ed25519-blake2b)
|
||||
build-essential \
|
||||
|
||||
@@ -18,6 +18,15 @@ class MetaAPIKeys(type):
|
||||
from app.utils.config_loader import load_addon_config
|
||||
val = load_addon_config().get('tiingo', {}).get('api_key')
|
||||
return val if val else os.getenv('TIINGO_API_KEY', '')
|
||||
|
||||
@property
|
||||
def TWELVE_DATA_API_KEY(cls):
|
||||
env_val = os.getenv('TWELVE_DATA_API_KEY', '').strip()
|
||||
if env_val:
|
||||
return env_val
|
||||
from app.utils.config_loader import load_addon_config
|
||||
val = load_addon_config().get('twelve_data', {}).get('api_key')
|
||||
return val if val else ''
|
||||
|
||||
@property
|
||||
def OPENROUTER_API_KEY(cls):
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
A-share / H-share chart K-lines — multi-tier fallback.
|
||||
|
||||
Priority order (when TWELVE_DATA_API_KEY is configured):
|
||||
ALL timeframes → Twelve Data (paid, globally stable) → Tencent daily/weekly → yfinance → AkShare
|
||||
|
||||
Without API key:
|
||||
Daily / Weekly → Tencent fqkline (fast, no key) → yfinance → AkShare
|
||||
Minute / Hour → yfinance → AkShare (Eastmoney, fragile overseas)
|
||||
|
||||
Tencent ``fqkline`` only reliably supports day/week/month.
|
||||
yfinance supports CN (.SS/.SZ) and HK (.HK) at all common intervals.
|
||||
Twelve Data (https://twelvedata.com) supports XSHG/XSHE/XHKG at all intervals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_MAX_ATTEMPTS = 3
|
||||
_BACKOFF_BASE_SEC = 1.5
|
||||
_BACKOFF_CAP_SEC = 12.0
|
||||
|
||||
_TRANSIENT_ERR_MARKERS = (
|
||||
"remote end closed connection",
|
||||
"connection aborted",
|
||||
"connection reset",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"max retries exceeded",
|
||||
"temporarily unavailable",
|
||||
"broken pipe",
|
||||
"eof occurred",
|
||||
"remote disconnected",
|
||||
"chunkedencodingerror",
|
||||
"incompleteread",
|
||||
"rate",
|
||||
"too many requests",
|
||||
"429",
|
||||
)
|
||||
|
||||
|
||||
def _is_transient(exc: BaseException) -> bool:
|
||||
return any(m in str(exc).lower() for m in _TRANSIENT_ERR_MARKERS)
|
||||
|
||||
|
||||
_CHART_TF_ALIASES = {
|
||||
"1w": "1W",
|
||||
"1d": "1D",
|
||||
"1h": "1H",
|
||||
"4h": "4H",
|
||||
"d": "1D",
|
||||
"day": "1D",
|
||||
"w": "1W",
|
||||
"week": "1W",
|
||||
"wk": "1W",
|
||||
"60m": "1H",
|
||||
"240m": "4H",
|
||||
"1day": "1D",
|
||||
"1week": "1W",
|
||||
}
|
||||
|
||||
|
||||
def normalize_chart_timeframe(timeframe: str) -> str:
|
||||
t = (timeframe or "1D").strip()
|
||||
if not t:
|
||||
return "1D"
|
||||
key = t.lower()
|
||||
if key in _CHART_TF_ALIASES:
|
||||
return _CHART_TF_ALIASES[key]
|
||||
return t
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AkShare code converters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ak_a_code_from_tencent(tencent_code: str) -> str:
|
||||
c = (tencent_code or "").strip().lower()
|
||||
if len(c) >= 8 and c[:2] in ("sh", "sz"):
|
||||
return c[2:]
|
||||
return c
|
||||
|
||||
|
||||
def ak_hk_code_from_tencent(tencent_code: str) -> str:
|
||||
c = (tencent_code or "").strip().upper().replace(".HK", "")
|
||||
if c.startswith("HK"):
|
||||
num = c[2:]
|
||||
else:
|
||||
num = c
|
||||
if num.isdigit():
|
||||
return num.zfill(5)
|
||||
return num
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Twelve Data (paid, globally reliable — https://twelvedata.com)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_twelve_data_api_key() -> str:
|
||||
try:
|
||||
from app.utils.config_loader import load_addon_config
|
||||
key = load_addon_config().get("twelve_data", {}).get("api_key", "")
|
||||
if key:
|
||||
return key
|
||||
except Exception:
|
||||
pass
|
||||
return (os.getenv("TWELVE_DATA_API_KEY") or "").strip()
|
||||
|
||||
|
||||
_TD_INTERVAL_MAP = {
|
||||
"1m": "1min",
|
||||
"5m": "5min",
|
||||
"15m": "15min",
|
||||
"30m": "30min",
|
||||
"1H": "1h",
|
||||
"4H": "4h",
|
||||
"1D": "1day",
|
||||
"1W": "1week",
|
||||
}
|
||||
|
||||
|
||||
def _td_symbol_and_exchange(tencent_code: str, is_hk: bool) -> tuple[str, str]:
|
||||
"""Convert Tencent code to Twelve Data (symbol, exchange)."""
|
||||
c = (tencent_code or "").strip().upper()
|
||||
if is_hk:
|
||||
num = c.replace("HK", "")
|
||||
if num.isdigit():
|
||||
num = str(int(num)).zfill(4)
|
||||
return num, "XHKG"
|
||||
digits = c.lstrip("SHSZ")
|
||||
if c.startswith("SH") or digits.startswith("6"):
|
||||
return digits, "XSHG"
|
||||
return digits, "XSHE"
|
||||
|
||||
|
||||
def fetch_twelvedata_klines(
|
||||
*,
|
||||
is_hk: bool,
|
||||
tencent_code: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch K-lines from Twelve Data REST API. Requires TWELVE_DATA_API_KEY."""
|
||||
api_key = _get_twelve_data_api_key()
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
interval = _TD_INTERVAL_MAP.get(timeframe)
|
||||
if not interval:
|
||||
return []
|
||||
|
||||
symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk)
|
||||
params: Dict[str, Any] = {
|
||||
"symbol": symbol,
|
||||
"exchange": exchange,
|
||||
"interval": interval,
|
||||
"outputsize": min(int(limit), 5000),
|
||||
"apikey": api_key,
|
||||
"format": "JSON",
|
||||
"dp": "4",
|
||||
}
|
||||
if before_time:
|
||||
end_dt = datetime.fromtimestamp(int(before_time))
|
||||
params["end_date"] = end_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
url = "https://api.twelvedata.com/time_series"
|
||||
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=20)
|
||||
data = resp.json()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
|
||||
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
|
||||
logger.debug(
|
||||
"TwelveData transient error %s/%s tf=%s (attempt %s/%s): %s",
|
||||
symbol, exchange, timeframe, attempt + 1, _MAX_ATTEMPTS, e,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
logger.warning("TwelveData request failed %s/%s tf=%s: %s", symbol, exchange, timeframe, e)
|
||||
return []
|
||||
else:
|
||||
return []
|
||||
|
||||
if data.get("status") != "ok" or "values" not in data:
|
||||
code = data.get("code", "")
|
||||
msg = data.get("message", str(data))
|
||||
if code == 429 or "API credits" in msg or "minute limit" in msg:
|
||||
logger.warning("TwelveData rate limit for %s/%s: %s", symbol, exchange, msg)
|
||||
else:
|
||||
logger.warning("TwelveData error %s/%s tf=%s: %s", symbol, exchange, timeframe, msg)
|
||||
return []
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for v in data["values"]:
|
||||
try:
|
||||
dt_str = v.get("datetime", "")
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
ts = int(datetime.strptime(dt_str, fmt).timestamp())
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
o = float(v["open"])
|
||||
h = float(v["high"])
|
||||
low = float(v["low"])
|
||||
c = float(v["close"])
|
||||
vol = float(v.get("volume") or 0)
|
||||
if o == 0 and c == 0:
|
||||
continue
|
||||
out.append({
|
||||
"time": ts,
|
||||
"open": round(o, 4),
|
||||
"high": round(h, 4),
|
||||
"low": round(low, 4),
|
||||
"close": round(c, 4),
|
||||
"volume": round(vol, 2),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
out.sort(key=lambda x: x["time"])
|
||||
logger.debug("TwelveData returned %d bars for %s/%s tf=%s", len(out), symbol, exchange, timeframe)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# yfinance helpers (globally accessible — Yahoo CDN)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def yf_symbol_from_tencent(tencent_code: str, is_hk: bool) -> str:
|
||||
"""Convert Tencent-style code (SH600519 / SZ000001 / HK00700) to yfinance ticker."""
|
||||
c = (tencent_code or "").strip().upper()
|
||||
if is_hk:
|
||||
num = c.replace("HK", "")
|
||||
if num.isdigit():
|
||||
return str(int(num)).zfill(4) + ".HK"
|
||||
return num + ".HK"
|
||||
if c.startswith("SH"):
|
||||
return c[2:] + ".SS"
|
||||
if c.startswith("SZ"):
|
||||
return c[2:] + ".SZ"
|
||||
digits = c.lstrip("SHSZ")
|
||||
if digits.startswith("6"):
|
||||
return digits + ".SS"
|
||||
return digits + ".SZ"
|
||||
|
||||
|
||||
_YF_INTERVAL_MAP = {
|
||||
"1m": "1m",
|
||||
"5m": "5m",
|
||||
"15m": "15m",
|
||||
"30m": "30m",
|
||||
"1H": "1h",
|
||||
"4H": "1h",
|
||||
"1D": "1d",
|
||||
"1W": "1wk",
|
||||
}
|
||||
|
||||
_YF_DAYS_MAP = {
|
||||
"1m": lambda lim: min(7, max(2, (lim // 240) + 2)),
|
||||
"5m": lambda lim: min(60, max(3, (lim // 48) + 3)),
|
||||
"15m": lambda lim: min(60, max(3, (lim // 16) + 3)),
|
||||
"30m": lambda lim: min(60, max(5, (lim // 8) + 5)),
|
||||
"1H": lambda lim: min(730, max(8, (lim // 4) + 8)),
|
||||
"4H": lambda lim: min(730, max(20, lim + 10)),
|
||||
"1D": lambda lim: min(3650, lim + 10),
|
||||
"1W": lambda lim: min(3650, lim * 7 + 30),
|
||||
}
|
||||
|
||||
|
||||
def _bars_from_yfinance_df(df: Any) -> List[Dict[str, Any]]:
|
||||
"""Convert a yfinance DataFrame (with DatetimeIndex or Date/Datetime column) to bar dicts."""
|
||||
if df is None or getattr(df, "empty", True):
|
||||
return []
|
||||
df = df.reset_index()
|
||||
time_col = None
|
||||
for candidate in ("Datetime", "Date", "index"):
|
||||
if candidate in df.columns:
|
||||
time_col = candidate
|
||||
break
|
||||
if time_col is None:
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
tv = row[time_col]
|
||||
if hasattr(tv, "timestamp"):
|
||||
ts = int(tv.timestamp())
|
||||
else:
|
||||
continue
|
||||
o, h, low, c, v = (
|
||||
float(row["Open"]),
|
||||
float(row["High"]),
|
||||
float(row["Low"]),
|
||||
float(row["Close"]),
|
||||
float(row["Volume"]),
|
||||
)
|
||||
if o == 0 and c == 0:
|
||||
continue
|
||||
out.append({
|
||||
"time": ts,
|
||||
"open": round(o, 4),
|
||||
"high": round(h, 4),
|
||||
"low": round(low, 4),
|
||||
"close": round(c, 4),
|
||||
"volume": round(v, 2),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
out.sort(key=lambda x: x["time"])
|
||||
return out
|
||||
|
||||
|
||||
def fetch_yfinance_klines(
|
||||
*,
|
||||
is_hk: bool,
|
||||
tencent_code: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch K-lines via yfinance for CN/HK stocks. Globally accessible, no API key needed."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
except ImportError:
|
||||
logger.debug("yfinance not installed; skipping yfinance K-lines")
|
||||
return []
|
||||
|
||||
interval = _YF_INTERVAL_MAP.get(timeframe)
|
||||
if not interval:
|
||||
return []
|
||||
|
||||
yf_sym = yf_symbol_from_tencent(tencent_code, is_hk)
|
||||
effective_limit = limit * 4 if timeframe == "4H" else limit
|
||||
days_func = _YF_DAYS_MAP.get(timeframe, lambda x: x + 10)
|
||||
days = days_func(effective_limit)
|
||||
|
||||
end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now()
|
||||
start = end - timedelta(days=days)
|
||||
|
||||
df: Any = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
ticker = yf.Ticker(yf_sym)
|
||||
df = ticker.history(
|
||||
start=start.strftime("%Y-%m-%d"),
|
||||
end=(end + timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
interval=interval,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
|
||||
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
|
||||
logger.debug(
|
||||
"yfinance transient error %s tf=%s (attempt %s/%s), retry in %.1fs: %s",
|
||||
yf_sym, timeframe, attempt + 1, _MAX_ATTEMPTS, delay, e,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
logger.warning("yfinance K-line failed %s tf=%s: %s", yf_sym, timeframe, e)
|
||||
return []
|
||||
|
||||
bars = _bars_from_yfinance_df(df)
|
||||
if timeframe == "4H" and bars:
|
||||
bars = _merge_every_n_sorted_bars(bars, 4)
|
||||
logger.debug("yfinance returned %d bars for %s tf=%s", len(bars), yf_sym, timeframe)
|
||||
return bars
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AkShare helpers (Eastmoney — unreliable from overseas, used as last resort)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _minute_period_str(timeframe: str) -> Optional[str]:
|
||||
return {"1m": "1", "5m": "5", "15m": "15", "30m": "30", "1H": "60", "4H": "60"}.get(timeframe)
|
||||
|
||||
|
||||
def _min_bar_window(timeframe: str, limit: int, before_time: Optional[int]) -> tuple[str, str]:
|
||||
_ = (timeframe, limit)
|
||||
end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now()
|
||||
start = end - timedelta(days=16)
|
||||
fmt = "%Y-%m-%d %H:%M:%S"
|
||||
return start.strftime(fmt), end.strftime(fmt)
|
||||
|
||||
|
||||
def _bars_from_ak_min_df(df: Any) -> List[Dict[str, Any]]:
|
||||
if df is None or getattr(df, "empty", True):
|
||||
return []
|
||||
cols = [str(x) for x in df.columns]
|
||||
time_c = "时间" if "时间" in cols else (cols[0] if len(cols) > 5 else None)
|
||||
if not time_c:
|
||||
return []
|
||||
|
||||
def _pick(name_zh: str, idx: int) -> str:
|
||||
return name_zh if name_zh in cols else (cols[idx] if len(cols) > idx else "")
|
||||
|
||||
c_open = _pick("开盘", 1)
|
||||
c_close = _pick("收盘", 2)
|
||||
c_high = _pick("最高", 3)
|
||||
c_low = _pick("最低", 4)
|
||||
c_vol = _pick("成交量", 5)
|
||||
if not all((c_open, c_close, c_high, c_low, c_vol)):
|
||||
return []
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
t = pd.Timestamp(row[time_c])
|
||||
ts = int(t.timestamp())
|
||||
o, c, h, low, v = float(row[c_open]), float(row[c_close]), float(row[c_high]), float(row[c_low]), float(row[c_vol])
|
||||
out.append({
|
||||
"time": ts,
|
||||
"open": round(o, 4),
|
||||
"high": round(h, 4),
|
||||
"low": round(low, 4),
|
||||
"close": round(c, 4),
|
||||
"volume": round(v, 2),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
out.sort(key=lambda x: x["time"])
|
||||
return out
|
||||
|
||||
|
||||
def _merge_every_n_sorted_bars(bars: List[Dict[str, Any]], n: int) -> List[Dict[str, Any]]:
|
||||
if n <= 1 or len(bars) < n:
|
||||
return bars
|
||||
out: List[Dict[str, Any]] = []
|
||||
i = 0
|
||||
while i + n <= len(bars):
|
||||
chunk = bars[i : i + n]
|
||||
out.append({
|
||||
"time": chunk[0]["time"],
|
||||
"open": chunk[0]["open"],
|
||||
"high": max(b["high"] for b in chunk),
|
||||
"low": min(b["low"] for b in chunk),
|
||||
"close": chunk[-1]["close"],
|
||||
"volume": round(sum(b["volume"] for b in chunk), 2),
|
||||
})
|
||||
i += n
|
||||
return out
|
||||
|
||||
|
||||
def fetch_akshare_minute_klines(
|
||||
*,
|
||||
is_hk: bool,
|
||||
tencent_code: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int],
|
||||
) -> List[Dict[str, Any]]:
|
||||
p = _minute_period_str(timeframe)
|
||||
if p is None:
|
||||
return []
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
except ImportError:
|
||||
logger.debug("akshare not installed; skipping AkShare minute K-lines")
|
||||
return []
|
||||
|
||||
sym = ak_hk_code_from_tencent(tencent_code) if is_hk else ak_a_code_from_tencent(tencent_code)
|
||||
sd, ed = _min_bar_window(timeframe, limit, before_time)
|
||||
adj = "" if p == "1" else "qfq"
|
||||
|
||||
df: Any = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
if is_hk:
|
||||
df = ak.stock_hk_hist_min_em(symbol=sym, period=p, adjust=adj, start_date=sd, end_date=ed)
|
||||
else:
|
||||
df = ak.stock_zh_a_hist_min_em(symbol=sym, start_date=sd, end_date=ed, period=p, adjust=adj)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
|
||||
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
|
||||
logger.debug(
|
||||
"AkShare minute transient error %s tf=%s sym=%s (attempt %s/%s): %s",
|
||||
tencent_code, timeframe, sym, attempt + 1, _MAX_ATTEMPTS, e,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
logger.warning("AkShare minute K-line failed %s tf=%s sym=%s: %s", tencent_code, timeframe, sym, e)
|
||||
return []
|
||||
|
||||
bars = _bars_from_ak_min_df(df)
|
||||
if timeframe == "4H" and bars:
|
||||
bars = _merge_every_n_sorted_bars(bars, 4)
|
||||
return bars
|
||||
|
||||
|
||||
def fetch_akshare_weekly_klines(
|
||||
*,
|
||||
is_hk: bool,
|
||||
tencent_code: str,
|
||||
limit: int,
|
||||
before_time: Optional[int],
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
sym = ak_hk_code_from_tencent(tencent_code) if is_hk else ak_a_code_from_tencent(tencent_code)
|
||||
end = datetime.fromtimestamp(int(before_time)) if before_time else datetime.now()
|
||||
start = end - timedelta(days=max(int(limit or 300), 1) * 14 + 400)
|
||||
start_s = start.strftime("%Y%m%d")
|
||||
end_s = end.strftime("%Y%m%d")
|
||||
|
||||
df: Any = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
if is_hk:
|
||||
df = ak.stock_hk_hist(symbol=sym, period="weekly", start_date=start_s, end_date=end_s, adjust="qfq")
|
||||
else:
|
||||
df = ak.stock_zh_a_hist(symbol=sym, period="weekly", start_date=start_s, end_date=end_s, adjust="qfq")
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt + 1 < _MAX_ATTEMPTS and _is_transient(e):
|
||||
delay = min(_BACKOFF_CAP_SEC, _BACKOFF_BASE_SEC * (2 ** attempt))
|
||||
logger.debug(
|
||||
"AkShare weekly transient error sym=%s (attempt %s/%s): %s",
|
||||
sym, attempt + 1, _MAX_ATTEMPTS, e,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
logger.warning("AkShare weekly K-line failed sym=%s: %s", sym, e)
|
||||
return []
|
||||
|
||||
if df is None or getattr(df, "empty", True) or "日期" not in df.columns:
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
t = pd.Timestamp(row["日期"])
|
||||
ts = int(t.timestamp())
|
||||
o, c, h, low = float(row["开盘"]), float(row["收盘"]), float(row["最高"]), float(row["最低"])
|
||||
v = float(row["成交量"])
|
||||
out.append({
|
||||
"time": ts,
|
||||
"open": round(o, 4),
|
||||
"high": round(h, 4),
|
||||
"low": round(low, 4),
|
||||
"close": round(c, 4),
|
||||
"volume": round(v, 2),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
out.sort(key=lambda x: x["time"])
|
||||
return out
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
A-share / HK share fundamentals — multi-tier fallback.
|
||||
|
||||
Priority (when TWELVE_DATA_API_KEY configured):
|
||||
Twelve Data /statistics + /profile → AkShare (Eastmoney, fragile overseas)
|
||||
|
||||
Without API key:
|
||||
AkShare only (may fail from overseas servers)
|
||||
|
||||
Keys are aligned with MarketDataCollector expectations (pe_ratio, pb_ratio, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app.data_sources.asia_stock_kline import (
|
||||
_get_twelve_data_api_key,
|
||||
_td_symbol_and_exchange,
|
||||
ak_a_code_from_tencent,
|
||||
ak_hk_code_from_tencent,
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_TD_TIMEOUT = 15
|
||||
_TD_MAX_ATTEMPTS = 2
|
||||
_TD_BACKOFF_SEC = 2.0
|
||||
|
||||
|
||||
def _float_clean(x: Any) -> Optional[float]:
|
||||
if x is None or x == "":
|
||||
return None
|
||||
try:
|
||||
v = float(x)
|
||||
if math.isnan(v) or math.isinf(v):
|
||||
return None
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Twelve Data fundamentals (globally stable, paid)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _td_request(endpoint: str, symbol: str, exchange: str) -> Optional[Dict[str, Any]]:
|
||||
"""Generic Twelve Data GET with retry."""
|
||||
api_key = _get_twelve_data_api_key()
|
||||
if not api_key:
|
||||
return None
|
||||
url = f"https://api.twelvedata.com{endpoint}"
|
||||
params = {"symbol": symbol, "exchange": exchange, "apikey": api_key}
|
||||
for attempt in range(_TD_MAX_ATTEMPTS):
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=_TD_TIMEOUT)
|
||||
data = resp.json()
|
||||
if data.get("status") == "error":
|
||||
code = data.get("code", "")
|
||||
msg = (data.get("message") or "")[:120]
|
||||
if code == 429 or "API credits" in msg or "minute limit" in msg:
|
||||
logger.warning("TwelveData rate limit on %s %s/%s: %s", endpoint, symbol, exchange, msg)
|
||||
else:
|
||||
logger.debug("TwelveData %s error %s/%s: %s", endpoint, symbol, exchange, msg)
|
||||
return None
|
||||
return data
|
||||
except Exception as e:
|
||||
if attempt + 1 < _TD_MAX_ATTEMPTS:
|
||||
time.sleep(_TD_BACKOFF_SEC)
|
||||
continue
|
||||
logger.warning("TwelveData %s request failed %s/%s: %s", endpoint, symbol, exchange, e)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_twelvedata_fundamental(tencent_code: str, is_hk: bool) -> Dict[str, Any]:
|
||||
"""Fetch PE/PB/PS/PEG/ROE/margin/market_cap/52w from Twelve Data /statistics."""
|
||||
symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk)
|
||||
data = _td_request("/statistics", symbol, exchange)
|
||||
if not data or "statistics" not in data:
|
||||
return {}
|
||||
|
||||
stats = data["statistics"]
|
||||
result: Dict[str, Any] = {"source": "twelvedata"}
|
||||
|
||||
vm = stats.get("valuations_metrics") or {}
|
||||
result["market_cap"] = _float_clean(vm.get("market_capitalization"))
|
||||
result["pe_ratio"] = _float_clean(vm.get("trailing_pe"))
|
||||
result["forward_pe"] = _float_clean(vm.get("forward_pe"))
|
||||
result["pb_ratio"] = _float_clean(vm.get("price_to_book_mrq"))
|
||||
result["ps_ratio"] = _float_clean(vm.get("price_to_sales_ttm"))
|
||||
result["peg"] = _float_clean(vm.get("peg_ratio"))
|
||||
result["enterprise_value"] = _float_clean(vm.get("enterprise_value"))
|
||||
|
||||
fin = stats.get("financials") or {}
|
||||
result["profit_margin"] = _float_clean(fin.get("profit_margin"))
|
||||
result["gross_margin"] = _float_clean(fin.get("gross_margin"))
|
||||
result["operating_margin"] = _float_clean(fin.get("operating_margin"))
|
||||
result["roe"] = _float_clean(fin.get("return_on_equity_ttm"))
|
||||
result["roa"] = _float_clean(fin.get("return_on_assets_ttm"))
|
||||
|
||||
ss = stats.get("stock_statistics") or {}
|
||||
result["total_shares"] = _float_clean(ss.get("shares_outstanding"))
|
||||
result["float_shares"] = _float_clean(ss.get("float_shares"))
|
||||
|
||||
sp = stats.get("stock_price_summary") or {}
|
||||
result["52w_high"] = _float_clean(sp.get("fifty_two_week_high"))
|
||||
result["52w_low"] = _float_clean(sp.get("fifty_two_week_low"))
|
||||
result["beta"] = _float_clean(sp.get("beta"))
|
||||
|
||||
div = stats.get("dividends_and_splits") or {}
|
||||
result["dividend_yield"] = _float_clean(div.get("trailing_annual_dividend_yield"))
|
||||
result["dividend_rate"] = _float_clean(div.get("trailing_annual_dividend_rate"))
|
||||
|
||||
non_null = sum(1 for v in result.values() if v is not None and v != "twelvedata")
|
||||
logger.debug("TwelveData /statistics %s/%s: %d non-null fields", symbol, exchange, non_null)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_twelvedata_profile(tencent_code: str, is_hk: bool) -> Dict[str, Any]:
|
||||
"""Fetch company info from Twelve Data /profile."""
|
||||
symbol, exchange = _td_symbol_and_exchange(tencent_code, is_hk)
|
||||
data = _td_request("/profile", symbol, exchange)
|
||||
if not data or not data.get("name"):
|
||||
return {}
|
||||
|
||||
out: Dict[str, Any] = {"source": "twelvedata"}
|
||||
for src, dst in (
|
||||
("name", "name"),
|
||||
("industry", "industry"),
|
||||
("sector", "sector"),
|
||||
("website", "website"),
|
||||
("description", "description"),
|
||||
("employees", "employees"),
|
||||
("name", "full_name"),
|
||||
):
|
||||
v = data.get(src)
|
||||
if v is not None and str(v).strip():
|
||||
out[dst] = str(v).strip() if isinstance(v, str) else v
|
||||
|
||||
country = data.get("country")
|
||||
if country:
|
||||
out["country"] = country
|
||||
|
||||
logger.debug("TwelveData /profile %s/%s: name=%s industry=%s", symbol, exchange, out.get("name"), out.get("industry"))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AkShare fundamentals (Eastmoney — fragile overseas, used as fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _eastmoney_a_em_symbol(tencent_code: str) -> str:
|
||||
c = ak_a_code_from_tencent(tencent_code)
|
||||
c = (c or "").zfill(6)
|
||||
if c.startswith("6"):
|
||||
return "SH" + c
|
||||
return "SZ" + c
|
||||
|
||||
|
||||
def _individual_info_map(symbol_6: str) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
df = ak.stock_individual_info_em(symbol=symbol_6)
|
||||
except Exception as e:
|
||||
logger.debug("stock_individual_info_em failed %s: %s", symbol_6, e)
|
||||
return out
|
||||
if df is None or getattr(df, "empty", True) or len(df.columns) < 2:
|
||||
return out
|
||||
kcol, vcol = df.columns[0], df.columns[1]
|
||||
for _, row in df.iterrows():
|
||||
try:
|
||||
k = str(row[kcol]).strip()
|
||||
if k:
|
||||
out[k] = row[vcol]
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def fetch_cn_fundamental_akshare(tencent_code: str) -> Dict[str, Any]:
|
||||
"""PE/PB/PS, market cap, ROE proxy, EPS for A-share (best-effort)."""
|
||||
sym6 = ak_a_code_from_tencent(tencent_code)
|
||||
if not sym6:
|
||||
return {}
|
||||
result: Dict[str, Any] = {"source": "akshare_em"}
|
||||
info = _individual_info_map(sym6)
|
||||
if info:
|
||||
result["market_cap"] = _float_clean(info.get("总市值"))
|
||||
result["float_market_cap"] = _float_clean(info.get("流通市值"))
|
||||
ind = info.get("行业")
|
||||
if ind is not None and str(ind).strip():
|
||||
result["industry"] = str(ind).strip()
|
||||
result["total_shares"] = _float_clean(info.get("总股本"))
|
||||
result["float_shares"] = _float_clean(info.get("流通股"))
|
||||
|
||||
em_sym = _eastmoney_a_em_symbol(tencent_code)
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
vdf = ak.stock_zh_valuation_comparison_em(symbol=em_sym)
|
||||
except Exception as e:
|
||||
logger.debug("stock_zh_valuation_comparison_em failed %s: %s", em_sym, e)
|
||||
vdf = None
|
||||
|
||||
if vdf is not None and not vdf.empty and "代码" in vdf.columns:
|
||||
hit = vdf[vdf["代码"].astype(str).str.replace(".0", "", regex=False).str.zfill(6) == sym6.zfill(6)]
|
||||
if not hit.empty:
|
||||
r = hit.iloc[0]
|
||||
pe = _float_clean(r.get("市盈率-TTM"))
|
||||
if pe is not None:
|
||||
result["pe_ratio"] = pe
|
||||
pb = _float_clean(r.get("市净率-MRQ"))
|
||||
if pb is not None:
|
||||
result["pb_ratio"] = pb
|
||||
ps = _float_clean(r.get("市销率-TTM"))
|
||||
if ps is not None:
|
||||
result["ps_ratio"] = ps
|
||||
peg = _float_clean(r.get("PEG"))
|
||||
if peg is not None:
|
||||
result["peg"] = peg
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def fetch_hk_fundamental_akshare(tencent_code: str) -> Dict[str, Any]:
|
||||
hk5 = ak_hk_code_from_tencent(tencent_code)
|
||||
if not hk5:
|
||||
return {}
|
||||
result: Dict[str, Any] = {"source": "akshare_em"}
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
df = ak.stock_hk_financial_indicator_em(symbol=hk5)
|
||||
except Exception as e:
|
||||
logger.debug("stock_hk_financial_indicator_em failed %s: %s", hk5, e)
|
||||
return result
|
||||
if df is None or df.empty:
|
||||
return result
|
||||
r = df.iloc[0]
|
||||
result["pe_ratio"] = _float_clean(r.get("市盈率"))
|
||||
result["pb_ratio"] = _float_clean(r.get("市净率"))
|
||||
result["eps"] = _float_clean(r.get("基本每股收益(元)"))
|
||||
result["roe"] = _float_clean(r.get("股东权益回报率(%)"))
|
||||
result["profit_margin"] = _float_clean(r.get("销售净利率(%)"))
|
||||
mcap = _float_clean(r.get("总市值(港元)")) or _float_clean(r.get("港股市值(港元)"))
|
||||
if mcap is not None:
|
||||
result["market_cap"] = mcap
|
||||
result["dividend_yield"] = _float_clean(r.get("股息率TTM(%)"))
|
||||
return result
|
||||
|
||||
|
||||
def fetch_cn_company_extras(tencent_code: str) -> Dict[str, Any]:
|
||||
sym6 = ak_a_code_from_tencent(tencent_code)
|
||||
if not sym6:
|
||||
return {}
|
||||
info = _individual_info_map(sym6)
|
||||
out: Dict[str, Any] = {}
|
||||
if info.get("行业"):
|
||||
out["industry"] = str(info["行业"]).strip()
|
||||
if info.get("上市时间"):
|
||||
out["ipo_date"] = str(info["上市时间"]).strip()
|
||||
return out
|
||||
|
||||
|
||||
def fetch_hk_company_extras(tencent_code: str) -> Dict[str, Any]:
|
||||
hk5 = ak_hk_code_from_tencent(tencent_code)
|
||||
if not hk5:
|
||||
return {}
|
||||
out: Dict[str, Any] = {}
|
||||
try:
|
||||
import akshare as ak # type: ignore
|
||||
df = ak.stock_hk_company_profile_em(symbol=hk5)
|
||||
except Exception as e:
|
||||
logger.debug("stock_hk_company_profile_em failed %s: %s", hk5, e)
|
||||
return out
|
||||
if df is None or df.empty:
|
||||
return out
|
||||
r = df.iloc[0]
|
||||
for key, col in (
|
||||
("industry", "所属行业"),
|
||||
("ipo_date", "公司成立日期"),
|
||||
("website", "公司网址"),
|
||||
("full_name", "公司名称"),
|
||||
):
|
||||
v = r.get(col)
|
||||
if v is not None and str(v).strip():
|
||||
out[key] = str(v).strip()
|
||||
return out
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
中国A股数据源 — 多层 fallback
|
||||
|
||||
有 TWELVE_DATA_API_KEY:
|
||||
所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare
|
||||
|
||||
无 API Key:
|
||||
分钟/小时 → yfinance → AkShare
|
||||
日/周线 → 腾讯 fqkline → yfinance → AkShare
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from app.data_sources.base import BaseDataSource
|
||||
from app.data_sources.tencent import normalize_cn_code, fetch_quote, parse_quote_to_ticker, fetch_kline, tencent_kline_rows_to_dicts
|
||||
from app.data_sources.asia_stock_kline import (
|
||||
normalize_chart_timeframe,
|
||||
fetch_twelvedata_klines,
|
||||
fetch_yfinance_klines,
|
||||
fetch_akshare_minute_klines,
|
||||
fetch_akshare_weekly_klines,
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CNStockDataSource(BaseDataSource):
|
||||
"""A股数据源(TwelveData + Tencent + yfinance + AkShare)"""
|
||||
|
||||
name = "CNStock/multi-source"
|
||||
|
||||
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
code = normalize_cn_code(symbol)
|
||||
parts = fetch_quote(code)
|
||||
if not parts:
|
||||
return {"last": 0, "symbol": code}
|
||||
t = parse_quote_to_ticker(parts)
|
||||
return {
|
||||
"last": t.get("last", 0),
|
||||
"change": t.get("change", 0),
|
||||
"changePercent": t.get("changePercent", 0),
|
||||
"high": t.get("high", 0),
|
||||
"low": t.get("low", 0),
|
||||
"open": t.get("open", 0),
|
||||
"previousClose": t.get("previousClose", 0),
|
||||
"name": t.get("name", ""),
|
||||
"symbol": code,
|
||||
}
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
code = normalize_cn_code(symbol)
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
lim = max(int(limit or 300), 1)
|
||||
|
||||
# Tier 1: Twelve Data (paid, most reliable)
|
||||
rows = fetch_twelvedata_klines(
|
||||
is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
|
||||
)
|
||||
if rows:
|
||||
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
|
||||
|
||||
# Tier 2: Tencent for daily/weekly (fast, free)
|
||||
if tf in ("1D", "1W"):
|
||||
tf_map = {"1D": "day", "1W": "week"}
|
||||
period = tf_map.get(tf, "day")
|
||||
raw_rows = fetch_kline(code, period=period, count=lim, adj="qfq")
|
||||
out = tencent_kline_rows_to_dicts(raw_rows)
|
||||
if out:
|
||||
return self.filter_and_limit(out, limit=lim, before_time=before_time)
|
||||
|
||||
# Tier 3: yfinance (works when Yahoo not rate-limited)
|
||||
rows = fetch_yfinance_klines(
|
||||
is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
|
||||
)
|
||||
if rows:
|
||||
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
|
||||
|
||||
# Tier 4: AkShare (fragile overseas, last resort)
|
||||
if tf in ("1m", "5m", "15m", "30m", "1H", "4H"):
|
||||
rows = fetch_akshare_minute_klines(
|
||||
is_hk=False, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
|
||||
)
|
||||
elif tf == "1W":
|
||||
rows = fetch_akshare_weekly_klines(
|
||||
is_hk=False, tencent_code=code, limit=lim, before_time=before_time
|
||||
)
|
||||
else:
|
||||
rows = []
|
||||
|
||||
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
|
||||
@@ -52,6 +52,12 @@ class DataSourceFactory:
|
||||
if market == 'Crypto':
|
||||
from app.data_sources.crypto import CryptoDataSource
|
||||
return CryptoDataSource()
|
||||
elif market == 'CNStock':
|
||||
from app.data_sources.cn_stock import CNStockDataSource
|
||||
return CNStockDataSource()
|
||||
elif market == 'HKStock':
|
||||
from app.data_sources.hk_stock import HKStockDataSource
|
||||
return HKStockDataSource()
|
||||
elif market == 'USStock':
|
||||
from app.data_sources.us_stock import USStockDataSource
|
||||
return USStockDataSource()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
港股/H股数据源 — 多层 fallback
|
||||
|
||||
有 TWELVE_DATA_API_KEY:
|
||||
所有周期 → Twelve Data(主) → 腾讯日/周线 → yfinance → AkShare
|
||||
|
||||
无 API Key:
|
||||
分钟/小时 → yfinance → AkShare
|
||||
日/周线 → 腾讯 fqkline → yfinance → AkShare
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from app.data_sources.base import BaseDataSource
|
||||
from app.data_sources.tencent import normalize_hk_code, fetch_quote, parse_quote_to_ticker, fetch_kline, tencent_kline_rows_to_dicts
|
||||
from app.data_sources.asia_stock_kline import (
|
||||
normalize_chart_timeframe,
|
||||
fetch_twelvedata_klines,
|
||||
fetch_yfinance_klines,
|
||||
fetch_akshare_minute_klines,
|
||||
fetch_akshare_weekly_klines,
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HKStockDataSource(BaseDataSource):
|
||||
"""港股/H股数据源(TwelveData + Tencent + yfinance + AkShare)"""
|
||||
|
||||
name = "HKStock/multi-source"
|
||||
|
||||
def get_ticker(self, symbol: str) -> Dict[str, Any]:
|
||||
code = normalize_hk_code(symbol)
|
||||
parts = fetch_quote(code)
|
||||
if not parts:
|
||||
return {"last": 0, "symbol": code}
|
||||
t = parse_quote_to_ticker(parts)
|
||||
return {
|
||||
"last": t.get("last", 0),
|
||||
"change": t.get("change", 0),
|
||||
"changePercent": t.get("changePercent", 0),
|
||||
"high": t.get("high", 0),
|
||||
"low": t.get("low", 0),
|
||||
"open": t.get("open", 0),
|
||||
"previousClose": t.get("previousClose", 0),
|
||||
"name": t.get("name", ""),
|
||||
"symbol": code,
|
||||
}
|
||||
|
||||
def get_kline(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
before_time: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
code = normalize_hk_code(symbol)
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
lim = max(int(limit or 300), 1)
|
||||
|
||||
# Tier 1: Twelve Data (paid, most reliable)
|
||||
rows = fetch_twelvedata_klines(
|
||||
is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
|
||||
)
|
||||
if rows:
|
||||
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
|
||||
|
||||
# Tier 2: Tencent for daily/weekly (fast, free)
|
||||
if tf in ("1D", "1W"):
|
||||
tf_map = {"1D": "day", "1W": "week"}
|
||||
period = tf_map.get(tf, "day")
|
||||
raw_rows = fetch_kline(code, period=period, count=lim, adj="qfq")
|
||||
out = tencent_kline_rows_to_dicts(raw_rows)
|
||||
if out:
|
||||
return self.filter_and_limit(out, limit=lim, before_time=before_time)
|
||||
|
||||
# Tier 3: yfinance (works when Yahoo not rate-limited)
|
||||
rows = fetch_yfinance_klines(
|
||||
is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
|
||||
)
|
||||
if rows:
|
||||
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
|
||||
|
||||
# Tier 4: AkShare (fragile overseas, last resort)
|
||||
if tf in ("1m", "5m", "15m", "30m", "1H", "4H"):
|
||||
rows = fetch_akshare_minute_klines(
|
||||
is_hk=True, tencent_code=code, timeframe=tf, limit=lim, before_time=before_time
|
||||
)
|
||||
elif tf == "1W":
|
||||
rows = fetch_akshare_weekly_klines(
|
||||
is_hk=True, tencent_code=code, limit=lim, before_time=before_time
|
||||
)
|
||||
else:
|
||||
rows = []
|
||||
|
||||
return self.filter_and_limit(rows, limit=lim, before_time=before_time)
|
||||
@@ -49,19 +49,17 @@ class PolymarketDataSource:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 从真实API获取 - 获取多个分类的数据以确保多样性
|
||||
# 从真实API获取
|
||||
all_markets = []
|
||||
|
||||
if category and category != "all":
|
||||
# 如果指定了类别,只获取该类别的数据
|
||||
# 获取所有事件,然后按类别过滤
|
||||
markets = self._fetch_markets_from_api(category, limit * 2)
|
||||
all_markets.extend(markets)
|
||||
else:
|
||||
# 如果没有指定类别或指定了"all",获取多个分类的数据
|
||||
categories_to_fetch = ["crypto", "politics", "economics", "sports"]
|
||||
for cat in categories_to_fetch:
|
||||
markets = self._fetch_markets_from_api(cat, limit // len(categories_to_fetch) + 10)
|
||||
all_markets.extend(markets)
|
||||
# 获取所有事件(不指定类别,避免重复请求)
|
||||
markets = self._fetch_from_gamma_api(category=None, limit=100)
|
||||
all_markets.extend(markets)
|
||||
|
||||
# 去重(按market_id)
|
||||
seen = set()
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Tencent market data helpers (no API key).
|
||||
|
||||
Provides:
|
||||
- Quote: https://qt.gtimg.cn/q=sh600519 / sz000001 / hk00700
|
||||
- Kline: https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=CODE,PERIOD,,,COUNT,ADJ
|
||||
|
||||
This is used as a stable alternative when Yahoo/yfinance gets rate-limited.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from app.data_sources.rate_limiter import get_request_headers, retry_with_backoff, get_tencent_limiter
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def normalize_cn_code(symbol: str) -> str:
|
||||
"""
|
||||
Normalize A-share symbol to Tencent code: sh600519 / sz000001.
|
||||
Accepts:
|
||||
- 600519 / 600519.SH / 600519.SS
|
||||
- 000001 / 000001.SZ
|
||||
"""
|
||||
s = (symbol or "").strip().upper()
|
||||
if not s:
|
||||
return s
|
||||
if s.endswith(".SH"):
|
||||
s = s[:-3]
|
||||
return f"SH{s}"
|
||||
if s.endswith(".SS"):
|
||||
s = s[:-3]
|
||||
return f"SH{s}"
|
||||
if s.endswith(".SZ"):
|
||||
s = s[:-3]
|
||||
return f"SZ{s}"
|
||||
|
||||
if s.isdigit() and len(s) == 6:
|
||||
return ("SH" + s) if s.startswith("6") else ("SZ" + s)
|
||||
|
||||
return s
|
||||
|
||||
|
||||
def normalize_hk_code(symbol: str) -> str:
|
||||
"""
|
||||
Normalize HK stock symbol to Tencent code: hk00700 (5 digits).
|
||||
Accepts:
|
||||
- 700 / 0700 / 00700.HK / 0700.HK
|
||||
"""
|
||||
s = (symbol or "").strip().upper()
|
||||
if not s:
|
||||
return s
|
||||
if s.endswith(".HK"):
|
||||
s = s[:-3]
|
||||
if s.isdigit():
|
||||
return "HK" + s.zfill(5)
|
||||
# If user already passed HKxxxxx
|
||||
if s.startswith("HK") and s[2:].isdigit():
|
||||
return "HK" + s[2:].zfill(5)
|
||||
return s
|
||||
|
||||
|
||||
def _lower_code(code: str) -> str:
|
||||
return (code or "").strip().lower()
|
||||
|
||||
|
||||
@retry_with_backoff(max_attempts=3, base_delay=1.2, max_delay=8.0, exceptions=(Exception,))
|
||||
def fetch_quote(code: str, timeout: int = 8) -> Optional[List[str]]:
|
||||
"""
|
||||
Returns the raw '~' split array from qt.gtimg.cn, or None.
|
||||
"""
|
||||
c = _lower_code(code)
|
||||
if not c:
|
||||
return None
|
||||
|
||||
limiter = get_tencent_limiter()
|
||||
limiter.wait()
|
||||
url = f"https://qt.gtimg.cn/q={c}"
|
||||
resp = requests.get(url, headers=get_request_headers(referer="https://qt.gtimg.cn/"), timeout=timeout)
|
||||
# Tencent quote is often GBK encoded
|
||||
try:
|
||||
resp.encoding = "gbk"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
text = (resp.text or "").strip()
|
||||
if not text or "~" not in text:
|
||||
return None
|
||||
|
||||
# Format: v_sh600519="1~NAME~CODE~LAST~PREV~OPEN~..."
|
||||
try:
|
||||
start = text.index('="') + 2
|
||||
end = text.rindex('"')
|
||||
payload = text[start:end]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
parts = payload.split("~")
|
||||
return parts if len(parts) > 5 else None
|
||||
|
||||
|
||||
def parse_quote_to_ticker(parts: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Best-effort conversion to a unified ticker dict.
|
||||
"""
|
||||
def _f(i: int, default: float = 0.0) -> float:
|
||||
try:
|
||||
v = parts[i]
|
||||
if v is None or v == "":
|
||||
return default
|
||||
return float(v)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
name = (parts[1] or "").strip() if len(parts) > 1 else ""
|
||||
symbol = (parts[2] or "").strip() if len(parts) > 2 else ""
|
||||
last_ = _f(3, 0.0)
|
||||
prev = _f(4, 0.0)
|
||||
open_ = _f(5, 0.0)
|
||||
|
||||
change = round(last_ - prev, 4) if prev else 0.0
|
||||
change_pct = round(change / prev * 100, 2) if prev else 0.0
|
||||
|
||||
# Indices are not fully consistent across markets; keep conservative.
|
||||
high = _f(33, last_) if len(parts) > 33 else last_
|
||||
low = _f(34, last_) if len(parts) > 34 else last_
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"name": name,
|
||||
"last": last_,
|
||||
"change": change,
|
||||
"changePercent": change_pct,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"open": open_ or last_,
|
||||
"previousClose": prev,
|
||||
"raw": parts,
|
||||
}
|
||||
|
||||
|
||||
def parse_tencent_kline_time(ds: str) -> Optional[int]:
|
||||
"""Parse Tencent fqkline first column to Unix seconds (local parse, matches prior chart behavior)."""
|
||||
raw = str(ds or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
return int(datetime.strptime(raw, fmt).timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
ts = int(float(raw))
|
||||
if ts > 10**12:
|
||||
ts = int(ts / 1000)
|
||||
return ts
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def tencent_kline_rows_to_dicts(rows: List[Any]) -> List[Dict[str, Any]]:
|
||||
"""Convert raw fqkline rows to chart dicts; ignores corporate-action tail objects on HK rows."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, (list, tuple)) or len(r) < 6:
|
||||
continue
|
||||
ts = parse_tencent_kline_time(r[0])
|
||||
if ts is None:
|
||||
continue
|
||||
try:
|
||||
o, c, h, low, vol = float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"time": ts,
|
||||
"open": round(o, 4),
|
||||
"high": round(h, 4),
|
||||
"low": round(low, 4),
|
||||
"close": round(c, 4),
|
||||
"volume": round(vol, 2),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@retry_with_backoff(max_attempts=3, base_delay=1.2, max_delay=8.0, exceptions=(Exception,))
|
||||
def fetch_kline(code: str, period: str, count: int = 300, adj: str = "qfq", timeout: int = 10) -> List[List[str]]:
|
||||
"""
|
||||
Fetch kline arrays from Tencent.
|
||||
|
||||
period examples:
|
||||
- day, week, month (supported by Tencent fqkline)
|
||||
|
||||
Note: Minute periods (m1/m5/…) return **bad params** on this endpoint; use AkShare in ``asia_stock_kline``.
|
||||
"""
|
||||
c = _lower_code(code)
|
||||
if not c:
|
||||
return []
|
||||
|
||||
limiter = get_tencent_limiter()
|
||||
limiter.wait()
|
||||
|
||||
url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
|
||||
params = {"param": f"{c},{period},,,{int(count)},{adj}"}
|
||||
resp = requests.get(url, headers=get_request_headers(referer="https://gu.qq.com/"), params=params, timeout=timeout)
|
||||
data = resp.json() if resp.text else {}
|
||||
if not isinstance(data, dict) or int(data.get("code", 0)) != 0:
|
||||
return []
|
||||
root = (data.get("data") or {}).get(c)
|
||||
if not isinstance(root, dict):
|
||||
return []
|
||||
|
||||
# Data key variants:
|
||||
# - A-share: qfqday / qfqweek / qfqm1 ...
|
||||
# - HK: day / week / m1 ...
|
||||
candidates = []
|
||||
if adj:
|
||||
candidates.append(f"{adj}{period}")
|
||||
candidates.append(period)
|
||||
|
||||
for key in candidates:
|
||||
arr = root.get(key)
|
||||
if isinstance(arr, list) and arr:
|
||||
return arr
|
||||
|
||||
# Fallback: search any key that endswith period and is a list
|
||||
for k, v in root.items():
|
||||
if isinstance(v, list) and v and str(k).lower().endswith(str(period).lower()):
|
||||
return v
|
||||
return []
|
||||
|
||||
@@ -88,7 +88,11 @@ class USStockDataSource(BaseDataSource):
|
||||
'previousClose': quote.get('pc', 0) # 昨收价
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Finnhub quote failed for {symbol}: {e}")
|
||||
msg = str(e).lower()
|
||||
if "403" in str(e) or "don't have access" in msg or "no access" in msg:
|
||||
logger.debug(f"Finnhub quote skipped (no access): {symbol}: {e}")
|
||||
else:
|
||||
logger.warning(f"Finnhub quote failed for {symbol}: {e}")
|
||||
|
||||
# 降级使用 yfinance
|
||||
try:
|
||||
@@ -260,7 +264,12 @@ class USStockDataSource(BaseDataSource):
|
||||
))
|
||||
# logger.info(f"Finnhub 返回 {len(klines)} 条数据")
|
||||
except Exception as e:
|
||||
logger.error(f"Finnhub fetch failed: {e}")
|
||||
msg = str(e).lower()
|
||||
# Free tier / plan: 403 "You don't have access to this resource" is common; avoid ERROR spam.
|
||||
if "403" in str(e) or "don't have access" in msg or "no access" in msg:
|
||||
logger.debug(f"Finnhub candles skipped (no access): {symbol}: {e}")
|
||||
else:
|
||||
logger.warning(f"Finnhub fetch failed: {e}")
|
||||
|
||||
return klines
|
||||
|
||||
|
||||
@@ -1674,6 +1674,77 @@ def _fetch_stock_opportunity_prices() -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def _fetch_local_stock_opportunity_prices(market: str, limit: int = 15) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch CN/HK stock prices for opportunity scanning.
|
||||
|
||||
For overseas servers we prefer the project's Tencent-based ticker path, which is
|
||||
typically more reachable/stable than some Eastmoney/AkShare endpoints.
|
||||
"""
|
||||
m = str(market or "").strip()
|
||||
if m not in ("CNStock", "HKStock"):
|
||||
return []
|
||||
|
||||
fallback_symbols = {
|
||||
"CNStock": [
|
||||
{"symbol": "600519", "name": "贵州茅台"},
|
||||
{"symbol": "000001", "name": "平安银行"},
|
||||
{"symbol": "300750", "name": "宁德时代"},
|
||||
{"symbol": "601318", "name": "中国平安"},
|
||||
{"symbol": "600036", "name": "招商银行"},
|
||||
{"symbol": "002594", "name": "比亚迪"},
|
||||
{"symbol": "600276", "name": "恒瑞医药"},
|
||||
{"symbol": "601899", "name": "紫金矿业"},
|
||||
],
|
||||
"HKStock": [
|
||||
{"symbol": "00700", "name": "腾讯控股"},
|
||||
{"symbol": "09988", "name": "阿里巴巴-W"},
|
||||
{"symbol": "03690", "name": "美团-W"},
|
||||
{"symbol": "01810", "name": "小米集团-W"},
|
||||
{"symbol": "01299", "name": "友邦保险"},
|
||||
{"symbol": "00939", "name": "建设银行"},
|
||||
{"symbol": "02318", "name": "中国平安"},
|
||||
{"symbol": "09618", "name": "京东集团-SW"},
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
from app.data.market_symbols_seed import get_hot_symbols
|
||||
from app.data_sources import DataSourceFactory
|
||||
from app.services.symbol_name import resolve_symbol_name
|
||||
|
||||
symbols = get_hot_symbols(m, limit=max(int(limit or 15), 1)) or fallback_symbols.get(m, [])
|
||||
source = DataSourceFactory.get_source(m)
|
||||
|
||||
result = []
|
||||
for item in symbols[: max(int(limit or 15), 1)]:
|
||||
try:
|
||||
symbol = str(item.get("symbol") or "").strip()
|
||||
if not symbol:
|
||||
continue
|
||||
ticker = source.get_ticker(symbol) or {}
|
||||
last = _safe_float(ticker.get("last") or ticker.get("close") or ticker.get("price"))
|
||||
if last <= 0:
|
||||
continue
|
||||
change_pct = ticker.get("changePercent")
|
||||
if change_pct is None:
|
||||
prev_close = _safe_float(ticker.get("previousClose"))
|
||||
change_pct = ((last - prev_close) / prev_close * 100.0) if prev_close > 0 else 0.0
|
||||
result.append({
|
||||
"symbol": symbol,
|
||||
"name": (item.get("name") or resolve_symbol_name(m, symbol) or symbol).strip(),
|
||||
"price": round(last, 4),
|
||||
"change": round(_safe_float(change_pct), 2),
|
||||
"market": m,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch {m} opportunity price {item.get('symbol')}: {e}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch {m} opportunity prices: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _analyze_opportunities_crypto(opportunities: list):
|
||||
"""Scan crypto market for trading opportunities."""
|
||||
crypto_data = _get_cached("crypto_prices")
|
||||
@@ -1800,6 +1871,75 @@ def _analyze_opportunities_stocks(opportunities: list):
|
||||
})
|
||||
|
||||
|
||||
def _analyze_opportunities_local_stocks(opportunities: list, market: str):
|
||||
"""Scan CN/HK stocks for trading opportunities."""
|
||||
m = str(market or "").strip()
|
||||
if m not in ("CNStock", "HKStock"):
|
||||
return
|
||||
|
||||
cache_key = "cn_stock_opportunity_prices" if m == "CNStock" else "hk_stock_opportunity_prices"
|
||||
stock_data = _get_cached(cache_key)
|
||||
if not stock_data:
|
||||
stock_data = _fetch_local_stock_opportunity_prices(m)
|
||||
if stock_data:
|
||||
_set_cached(cache_key, stock_data, 3600)
|
||||
|
||||
if not stock_data:
|
||||
logger.warning(f"_analyze_opportunities_local_stocks: No {m} data available")
|
||||
return
|
||||
|
||||
logger.debug(f"_analyze_opportunities_local_stocks: Analyzing {len(stock_data)} {m} stocks")
|
||||
strong_th = 7.0 if m == "CNStock" else 6.0
|
||||
medium_th = 3.0 if m == "CNStock" else 2.5
|
||||
market_cn = "A股" if m == "CNStock" else "港股"
|
||||
|
||||
for stock in stock_data:
|
||||
change = _safe_float(stock.get("change", 0))
|
||||
symbol = stock.get("symbol", "")
|
||||
name = stock.get("name", "")
|
||||
price = _safe_float(stock.get("price", 0))
|
||||
|
||||
signal = None
|
||||
strength = "medium"
|
||||
reason = ""
|
||||
impact = "neutral"
|
||||
|
||||
if change > strong_th:
|
||||
signal = "overbought"
|
||||
strength = "strong"
|
||||
reason = f"{market_cn}日涨幅{change:.1f}%,短期涨幅较大,注意回调风险"
|
||||
impact = "bearish"
|
||||
elif change > medium_th:
|
||||
signal = "bullish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"{market_cn}日涨幅{change:.1f}%,上涨动能较强"
|
||||
impact = "bullish"
|
||||
elif change < -strong_th:
|
||||
signal = "oversold"
|
||||
strength = "strong"
|
||||
reason = f"{market_cn}日跌幅{abs(change):.1f}%,可能超卖反弹"
|
||||
impact = "bullish"
|
||||
elif change < -medium_th:
|
||||
signal = "bearish_momentum"
|
||||
strength = "medium"
|
||||
reason = f"{market_cn}日跌幅{abs(change):.1f}%,下跌趋势明显"
|
||||
impact = "bearish"
|
||||
|
||||
if signal:
|
||||
opportunities.append({
|
||||
"symbol": symbol,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"change_24h": change,
|
||||
"signal": signal,
|
||||
"strength": strength,
|
||||
"reason": reason,
|
||||
"impact": impact,
|
||||
"market": m,
|
||||
"timestamp": int(time.time())
|
||||
})
|
||||
|
||||
|
||||
def _analyze_opportunities_forex(opportunities: list):
|
||||
"""Scan forex pairs for trading opportunities."""
|
||||
forex_data = _get_cached("forex_pairs")
|
||||
@@ -1915,7 +2055,7 @@ def _analyze_opportunities_polymarket(opportunities: list):
|
||||
@login_required
|
||||
def trading_opportunities():
|
||||
"""
|
||||
Scan for trading opportunities across Crypto, US Stocks, and Forex.
|
||||
Scan for trading opportunities across Crypto, US Stocks, CN/HK Stocks, and Forex.
|
||||
Note: Prediction Markets are excluded as they have their own dedicated page.
|
||||
Cached for 1 hour. Pass ?force=true to skip cache.
|
||||
"""
|
||||
@@ -1945,7 +2085,23 @@ def trading_opportunities():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze stock opportunities: {e}", exc_info=True)
|
||||
|
||||
# 3) Forex
|
||||
# 3) CN Stocks
|
||||
try:
|
||||
_analyze_opportunities_local_stocks(opportunities, "CNStock")
|
||||
cn_count = len([o for o in opportunities if o.get("market") == "CNStock"])
|
||||
logger.info(f"Trading opportunities: found {cn_count} CN stock opportunities")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze CN stock opportunities: {e}", exc_info=True)
|
||||
|
||||
# 4) HK Stocks
|
||||
try:
|
||||
_analyze_opportunities_local_stocks(opportunities, "HKStock")
|
||||
hk_count = len([o for o in opportunities if o.get("market") == "HKStock"])
|
||||
logger.info(f"Trading opportunities: found {hk_count} HK stock opportunities")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze HK stock opportunities: {e}", exc_info=True)
|
||||
|
||||
# 5) Forex
|
||||
try:
|
||||
_analyze_opportunities_forex(opportunities)
|
||||
forex_count = len([o for o in opportunities if o.get("market") == "Forex"])
|
||||
@@ -1959,7 +2115,14 @@ def trading_opportunities():
|
||||
# Sort by absolute change descending
|
||||
opportunities.sort(key=lambda x: abs(x.get("change_24h", 0)), reverse=True)
|
||||
|
||||
logger.info(f"Trading opportunities: total {len(opportunities)} opportunities found (Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})")
|
||||
logger.info(
|
||||
f"Trading opportunities: total {len(opportunities)} opportunities found "
|
||||
f"(Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, "
|
||||
f"USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, "
|
||||
f"CNStock: {len([o for o in opportunities if o.get('market') == 'CNStock'])}, "
|
||||
f"HKStock: {len([o for o in opportunities if o.get('market') == 'HKStock'])}, "
|
||||
f"Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})"
|
||||
)
|
||||
|
||||
_set_cached("trading_opportunities", opportunities, 3600)
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ def get_public_config():
|
||||
@market_bp.route('/types', methods=['GET'])
|
||||
def get_market_types():
|
||||
"""Return supported market types for the add-watchlist modal."""
|
||||
desired_order = ['USStock', 'Crypto', 'Forex', 'Futures']
|
||||
# Keep a stable UX order; add CN/HK stocks near US stocks.
|
||||
desired_order = ['USStock', 'CNStock', 'HKStock', 'Crypto', 'Forex', 'Futures']
|
||||
order_rank = {v: i for i, v in enumerate(desired_order)}
|
||||
|
||||
def _normalize_item(x):
|
||||
|
||||
@@ -29,11 +29,69 @@ from app.utils.credential_crypto import decrypt_credential_blob
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
import re as _re
|
||||
|
||||
_FRIENDLY_ERROR_PATTERNS = [
|
||||
# Insufficient balance / margin
|
||||
(_re.compile(r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.insufficientBalance"),
|
||||
# Invalid size / quantity
|
||||
(_re.compile(r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.invalidSize"),
|
||||
# Invalid price
|
||||
(_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.invalidPrice"),
|
||||
# Rate limit
|
||||
(_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.rateLimit"),
|
||||
# API key / permission
|
||||
(_re.compile(r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.authError"),
|
||||
# Position / reduce-only conflict
|
||||
(_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.positionConflict"),
|
||||
# Network / timeout
|
||||
(_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.networkError"),
|
||||
# Exchange maintenance
|
||||
(_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE),
|
||||
"quickTrade.errorHints.exchangeMaintenance"),
|
||||
]
|
||||
|
||||
|
||||
def _parse_trade_error_hint(error_str: str) -> str:
|
||||
"""Return a i18n key hint for common exchange trading errors, or empty string."""
|
||||
s = str(error_str or "")
|
||||
for pattern, hint_key in _FRIENDLY_ERROR_PATTERNS:
|
||||
if pattern.search(s):
|
||||
return hint_key
|
||||
return ""
|
||||
|
||||
quick_trade_bp = Blueprint('quick_trade', __name__)
|
||||
|
||||
|
||||
# ────────── helpers ──────────
|
||||
|
||||
def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool:
|
||||
"""Match UI symbol (e.g. ETH/USDT) with exchange-native ids (e.g. ETH_USDT, ETH-USDT-SWAP)."""
|
||||
|
||||
def norm(x: str) -> str:
|
||||
return (x or "").strip().upper().replace("/", "").replace("-", "").replace("_", "")
|
||||
|
||||
a, b = norm(user_symbol), norm(position_symbol)
|
||||
if not a or not b:
|
||||
return False
|
||||
if a == b:
|
||||
return True
|
||||
for suf in ("SWAP", "PERPETUAL", "PERP"):
|
||||
if b.endswith(suf) and a == b[: -len(suf)]:
|
||||
return True
|
||||
if a.endswith(suf) and b == a[: -len(suf)]:
|
||||
return True
|
||||
# Substring fallback for less standard ids (min length avoids ETH vs ETHW false positives)
|
||||
return (len(a) >= 6 and a in b) or (len(b) >= 6 and b in a)
|
||||
|
||||
|
||||
def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0) -> float:
|
||||
"""
|
||||
Convert USDT amount to base asset quantity for all exchanges.
|
||||
@@ -116,6 +174,56 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
|
||||
current_price = float(data.get("price") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bybit v5 — same host as trading API; tickers/orderbook are public
|
||||
from app.services.live_trading.bybit import BybitClient
|
||||
if current_price <= 0 and isinstance(client, BybitClient):
|
||||
try:
|
||||
import requests
|
||||
from app.services.live_trading.symbols import to_bybit_symbol
|
||||
|
||||
bu = (getattr(client, "base_url", "") or "").rstrip("/")
|
||||
bsym = to_bybit_symbol(symbol).upper()
|
||||
cat = "spot" if (market_type or "").strip().lower() == "spot" else "linear"
|
||||
if bu and bsym:
|
||||
tr = requests.get(
|
||||
f"{bu}/v5/market/tickers",
|
||||
params={"category": cat, "symbol": bsym},
|
||||
timeout=8,
|
||||
)
|
||||
if tr.status_code == 200:
|
||||
jd = tr.json() if tr.text else {}
|
||||
lst = (((jd.get("result") or {}).get("list")) or []) if isinstance(jd, dict) else []
|
||||
if lst and isinstance(lst[0], dict):
|
||||
t0 = lst[0]
|
||||
current_price = float(
|
||||
str(
|
||||
t0.get("lastPrice")
|
||||
or t0.get("markPrice")
|
||||
or t0.get("indexPrice")
|
||||
or 0
|
||||
).replace(",", "")
|
||||
or 0
|
||||
)
|
||||
if current_price <= 0:
|
||||
obr = requests.get(
|
||||
f"{bu}/v5/market/orderbook",
|
||||
params={"category": cat, "symbol": bsym, "limit": 25},
|
||||
timeout=8,
|
||||
)
|
||||
if obr.status_code == 200:
|
||||
od = obr.json() if obr.text else {}
|
||||
res = (od.get("result") or {}) if isinstance(od, dict) else {}
|
||||
bids = res.get("b") or []
|
||||
asks = res.get("a") or []
|
||||
bp = float(str(bids[0][0]).replace(",", "")) if bids and bids[0] else 0.0
|
||||
ap = float(str(asks[0][0]).replace(",", "")) if asks and asks[0] else 0.0
|
||||
if bp > 0 and ap > 0:
|
||||
current_price = (bp + ap) / 2.0
|
||||
else:
|
||||
current_price = bp or ap
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Other exchanges - can be added as needed
|
||||
# For exchanges without price API, we'll use a fallback
|
||||
@@ -350,7 +458,12 @@ def place_order():
|
||||
elif isinstance(client, GateUsdtFuturesClient):
|
||||
from app.services.live_trading.symbols import to_gate_currency_pair
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
client.set_leverage(contract=contract, leverage=leverage)
|
||||
if not client.set_leverage(contract=contract, leverage=leverage):
|
||||
logger.warning(
|
||||
"Gate set_leverage failed (contract=%s lev=%s); order may use exchange default leverage",
|
||||
contract,
|
||||
leverage,
|
||||
)
|
||||
# Most other exchanges use symbol
|
||||
else:
|
||||
# Try common parameter names
|
||||
@@ -472,7 +585,12 @@ def place_order():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||||
err_str = str(e)
|
||||
hint = _parse_trade_error_hint(err_str)
|
||||
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
|
||||
if hint:
|
||||
resp["error_hint"] = hint
|
||||
return jsonify(resp), 500
|
||||
|
||||
|
||||
def _market_order_kwargs(client, symbol, amount, side, market_type, client_order_id):
|
||||
@@ -581,9 +699,34 @@ def get_balance():
|
||||
def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, Any]:
|
||||
"""Best-effort parse balance from various exchange responses."""
|
||||
result = {"available": 0, "total": 0, "currency": "USDT"}
|
||||
ex0 = (exchange_id or "").strip().lower()
|
||||
mt0 = (market_type or "").strip().lower()
|
||||
|
||||
def _num(x: Any) -> float:
|
||||
try:
|
||||
s = str(x).replace(",", "").strip()
|
||||
if not s:
|
||||
return 0.0
|
||||
return float(s)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
if not raw:
|
||||
return result
|
||||
try:
|
||||
# Gate.io spot: GET /api/v4/spot/accounts returns a list
|
||||
if isinstance(raw, list) and ex0 == "gate":
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("currency") or "").upper() == "USDT":
|
||||
av = _num(item.get("available") or item.get("available_balance"))
|
||||
lk = _num(item.get("locked") or item.get("freeze") or item.get("locked_amount"))
|
||||
result["available"] = av
|
||||
result["total"] = av + lk
|
||||
return result
|
||||
return result
|
||||
|
||||
if isinstance(raw, dict):
|
||||
# Binance futures
|
||||
if "availableBalance" in raw:
|
||||
@@ -599,6 +742,21 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
|
||||
return result
|
||||
return result
|
||||
ex = (exchange_id or "").lower()
|
||||
# Gate.io USDT perpetual: GET /api/v4/futures/usdt/accounts — flat object (values often strings)
|
||||
if ex == "gate" and mt0 != "spot":
|
||||
if any(k in raw for k in ("available", "total", "cross_available", "cross_margin_balance")):
|
||||
av = raw.get("available") or raw.get("available_balance") or raw.get("cross_available")
|
||||
tot = (
|
||||
raw.get("total")
|
||||
or raw.get("total_balance")
|
||||
or raw.get("cross_margin_balance")
|
||||
or raw.get("equity")
|
||||
)
|
||||
result["available"] = _num(av)
|
||||
result["total"] = _num(tot) if tot is not None and str(tot).strip() != "" else result["available"]
|
||||
if result["total"] <= 0 < result["available"]:
|
||||
result["total"] = result["available"]
|
||||
return result
|
||||
# Bitget mix: { code, data: [ { marginCoin, available, accountEquity, ... } ] }
|
||||
# Must run before OKX — both use data as a list; OKX fallback would zero Bitget.
|
||||
if ex == "bitget" and (market_type or "").lower() != "spot":
|
||||
@@ -709,7 +867,7 @@ def _fetch_exchange_positions_raw(
|
||||
"""
|
||||
Fetch raw position payload for quick-trade / close-position.
|
||||
|
||||
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bybit, Bitfinex),
|
||||
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bitfinex),
|
||||
or need extra args (Bitget ``product_type``, OKX ``inst_type``). Centralize here.
|
||||
"""
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
@@ -747,7 +905,8 @@ def _fetch_exchange_positions_raw(
|
||||
return client.get_positions(product_type=pt, symbol=symbol)
|
||||
|
||||
if isinstance(client, BybitClient):
|
||||
raw = client.get_positions()
|
||||
# Bybit v5 requires symbol or settleCoin; query the contract directly.
|
||||
raw = client.get_positions(symbol=symbol)
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if not isinstance(lst, list):
|
||||
return raw
|
||||
@@ -765,8 +924,25 @@ def _fetch_exchange_positions_raw(
|
||||
raw = client.get_positions()
|
||||
items = raw if isinstance(raw, list) else []
|
||||
c = to_gate_currency_pair(symbol)
|
||||
logger.info("Gate positions: total=%d, target=%s, contracts=%s",
|
||||
len(items), c,
|
||||
[(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10])
|
||||
filtered = [p for p in items if isinstance(p, dict) and str(p.get("contract") or "").strip() == c]
|
||||
return filtered
|
||||
out = []
|
||||
for p in filtered:
|
||||
q = dict(p)
|
||||
try:
|
||||
ct_sz = float(q.get("size") or 0)
|
||||
except Exception:
|
||||
ct_sz = 0.0
|
||||
if abs(ct_sz) > 1e-12:
|
||||
base_amt = client.contracts_signed_to_base_qty(contract=c, contracts_signed=ct_sz)
|
||||
if base_amt > 0:
|
||||
q["positionAmt"] = base_amt
|
||||
out.append(q)
|
||||
logger.info("Gate filtered positions for %s: %d items, sizes=%s", c, len(out),
|
||||
[(p.get("size"), p.get("positionAmt")) for p in out])
|
||||
return out
|
||||
|
||||
if isinstance(client, KucoinFuturesClient):
|
||||
raw = client.get_positions()
|
||||
@@ -782,7 +958,37 @@ def _fetch_exchange_positions_raw(
|
||||
return {"data": filtered}
|
||||
|
||||
if isinstance(client, HtxClient):
|
||||
return client.get_positions(symbol=symbol)
|
||||
raw = client.get_positions(symbol=symbol)
|
||||
data = (raw.get("data") if isinstance(raw, dict) else None) or []
|
||||
if not isinstance(data, list):
|
||||
data = []
|
||||
out_items = []
|
||||
for p in data:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
q = dict(p)
|
||||
cc = str(q.get("contract_code") or "").strip()
|
||||
if cc:
|
||||
parts = cc.split("-", 1)
|
||||
if len(parts) == 2:
|
||||
q["symbol"] = f"{parts[0]}/{parts[1]}"
|
||||
try:
|
||||
vol = float(q.get("volume") or q.get("available") or 0)
|
||||
except Exception:
|
||||
vol = 0.0
|
||||
if abs(vol) > 1e-12 and cc:
|
||||
try:
|
||||
info = client.get_contract_info(symbol=symbol or cc) or {}
|
||||
cs = float(info.get("contract_size") or 1)
|
||||
if cs <= 0:
|
||||
cs = 1.0
|
||||
q["positionAmt"] = abs(vol) * cs
|
||||
except Exception:
|
||||
pass
|
||||
out_items.append(q)
|
||||
logger.info("HTX positions for %s: %d items, sizes=%s", symbol, len(out_items),
|
||||
[(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items])
|
||||
return {"data": out_items}
|
||||
|
||||
if isinstance(client, DeepcoinClient):
|
||||
return client.get_positions(symbol=symbol)
|
||||
@@ -860,6 +1066,21 @@ def _parse_positions(raw: Any) -> list:
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
sym_raw = str(
|
||||
item.get("symbol")
|
||||
or item.get("instId")
|
||||
or item.get("contract")
|
||||
or item.get("contract_code")
|
||||
or ""
|
||||
).strip()
|
||||
display_symbol = sym_raw
|
||||
if sym_raw and "/" not in sym_raw:
|
||||
for sep in ("_", "-"):
|
||||
if sep in sym_raw:
|
||||
parts = sym_raw.split(sep, 1)
|
||||
if len(parts) == 2 and parts[0] and parts[1]:
|
||||
display_symbol = f"{parts[0]}/{parts[1]}"
|
||||
break
|
||||
# For OKX, position size can be in different fields
|
||||
# SWAP: posAmt, pos
|
||||
# Binance futures: positionAmt
|
||||
@@ -876,6 +1097,7 @@ def _parse_positions(raw: Any) -> list:
|
||||
or item.get("bal")
|
||||
or item.get("availBal")
|
||||
or item.get("volume")
|
||||
or item.get("current_qty")
|
||||
or 0
|
||||
)
|
||||
if abs(size) < 1e-10:
|
||||
@@ -910,11 +1132,12 @@ def _parse_positions(raw: Any) -> list:
|
||||
side = "short"
|
||||
|
||||
result.append({
|
||||
"symbol": item.get("symbol") or item.get("instId") or "",
|
||||
"symbol": display_symbol,
|
||||
"side": side,
|
||||
"size": abs(size),
|
||||
"entry_price": float(
|
||||
item.get("entryPrice")
|
||||
or item.get("entry_price")
|
||||
or item.get("openPriceAvg")
|
||||
or item.get("avgEntryPrice")
|
||||
or item.get("avgPrice")
|
||||
@@ -928,15 +1151,17 @@ def _parse_positions(raw: Any) -> list:
|
||||
item.get("unRealizedProfit")
|
||||
or item.get("unrealizedProfit")
|
||||
or item.get("unrealizedPnl")
|
||||
or item.get("unrealised_pnl")
|
||||
or item.get("upl")
|
||||
or item.get("unrealisedPnl")
|
||||
or item.get("profit_unreal")
|
||||
or item.get("pnl")
|
||||
or 0
|
||||
),
|
||||
"leverage": float(item.get("leverage") or item.get("lever") or 1),
|
||||
"leverage": float(item.get("leverage") or item.get("lever") or item.get("lever_rate") or item.get("cross_leverage_limit") or 1),
|
||||
"mark_price": float(
|
||||
item.get("markPrice")
|
||||
or item.get("mark_price")
|
||||
or item.get("markPx")
|
||||
or item.get("last_price")
|
||||
or item.get("last")
|
||||
@@ -949,6 +1174,48 @@ def _parse_positions(raw: Any) -> list:
|
||||
return result
|
||||
|
||||
|
||||
def _quick_trade_net_base_qty(
|
||||
user_id: int,
|
||||
credential_id: int,
|
||||
symbol: str,
|
||||
market_type: str,
|
||||
position_side: str,
|
||||
) -> float:
|
||||
"""
|
||||
Best-effort net base-asset qty from qd_quick_trades (filled buy − sell for long, vice versa for short).
|
||||
|
||||
Used when user chooses to close only the portion accumulated via Quick Trade, not manual exchange orders.
|
||||
Imperfect if the user also traded the same symbol elsewhere or records are incomplete.
|
||||
"""
|
||||
mt = (market_type or "swap").strip().lower()
|
||||
ps = (position_side or "").strip().lower()
|
||||
sym = str(symbol or "").strip()
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN side = 'buy' THEN filled_amount ELSE 0 END), 0) AS b,
|
||||
COALESCE(SUM(CASE WHEN side = 'sell' THEN filled_amount ELSE 0 END), 0) AS s
|
||||
FROM qd_quick_trades
|
||||
WHERE user_id = %s AND credential_id = %s AND symbol = %s AND market_type = %s
|
||||
AND status = 'filled' AND COALESCE(filled_amount, 0) > 0
|
||||
""",
|
||||
(int(user_id), int(credential_id), sym, mt),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
buy_sum = float(row.get("b") or 0)
|
||||
sell_sum = float(row.get("s") or 0)
|
||||
if ps == "long":
|
||||
net = buy_sum - sell_sum
|
||||
elif ps == "short":
|
||||
net = sell_sum - buy_sum
|
||||
else:
|
||||
net = 0.0
|
||||
return max(0.0, float(net))
|
||||
|
||||
|
||||
@quick_trade_bp.route('/close-position', methods=['POST'])
|
||||
@login_required
|
||||
def close_position():
|
||||
@@ -960,6 +1227,8 @@ def close_position():
|
||||
symbol (str) — e.g. "BTC/USDT"
|
||||
market_type (str) — "swap" / "spot" (default: swap)
|
||||
size (float) — position size to close (optional, defaults to full position)
|
||||
close_scope (str) — "full" (default) or "system_tracked" (swap only: min(position, net from qd_quick_trades))
|
||||
position_side (str) — optional "long" / "short"; required when both directions exist for the same symbol
|
||||
source (str) — "ai_radar" / "ai_analysis" / "indicator" / "manual"
|
||||
"""
|
||||
try:
|
||||
@@ -971,6 +1240,11 @@ def close_position():
|
||||
market_type = str(body.get("market_type") or "swap").strip().lower()
|
||||
close_size = float(body.get("size") or 0) # 0 means close full position
|
||||
source = str(body.get("source") or "manual").strip()
|
||||
close_scope_raw = str(body.get("close_scope") or body.get("closeScope") or "full").strip().lower()
|
||||
if close_scope_raw in ("system", "system_tracked", "quick_trade", "app"):
|
||||
close_scope = "system_tracked"
|
||||
else:
|
||||
close_scope = "full"
|
||||
|
||||
# ---- validation ----
|
||||
if not credential_id:
|
||||
@@ -1003,29 +1277,80 @@ def close_position():
|
||||
|
||||
if not positions:
|
||||
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
|
||||
|
||||
# Find matching position for this symbol
|
||||
position = None
|
||||
|
||||
want_side = str(body.get("position_side") or body.get("close_side") or "").strip().lower()
|
||||
if want_side not in ("", "long", "short"):
|
||||
want_side = ""
|
||||
|
||||
matches: list = []
|
||||
for pos in positions:
|
||||
pos_symbol = pos.get("symbol", "").strip()
|
||||
# Match by symbol (may need normalization)
|
||||
if symbol.upper().replace("/", "") in pos_symbol.upper().replace("/", "").replace("-", ""):
|
||||
position = pos
|
||||
break
|
||||
|
||||
if not _symbols_match_quick_trade(symbol, pos_symbol):
|
||||
continue
|
||||
ps = str(pos.get("side") or "").strip().lower()
|
||||
if want_side in ("long", "short"):
|
||||
if ps == want_side:
|
||||
matches.append(pos)
|
||||
else:
|
||||
matches.append(pos)
|
||||
|
||||
position = None
|
||||
if len(matches) == 1:
|
||||
position = matches[0]
|
||||
elif len(matches) > 1:
|
||||
if want_side in ("long", "short"):
|
||||
position = matches[0]
|
||||
else:
|
||||
return jsonify(
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "该交易对同时存在多仓与空仓,请在请求中指定 position_side 为 long 或 short。",
|
||||
}
|
||||
), 400
|
||||
if not position:
|
||||
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
|
||||
|
||||
|
||||
position_side = str(position.get("side") or "").strip().lower()
|
||||
position_size = float(position.get("size") or 0)
|
||||
|
||||
if position_size <= 0:
|
||||
return jsonify({"code": 0, "msg": "Position size is zero or invalid"}), 400
|
||||
|
||||
if close_scope == "system_tracked" and market_type != "swap":
|
||||
return jsonify({"code": 0, "msg": "system_tracked close_scope is only supported for swap/perp"}), 400
|
||||
|
||||
tracked_net = 0.0
|
||||
if close_scope == "system_tracked":
|
||||
tracked_net = _quick_trade_net_base_qty(
|
||||
user_id, credential_id, symbol, market_type, position_side=position_side
|
||||
)
|
||||
if tracked_net <= 0:
|
||||
return jsonify(
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "No filled Quick Trade volume found for this symbol; use full close or check history.",
|
||||
}
|
||||
), 400
|
||||
|
||||
# Determine close size
|
||||
actual_close_size = close_size if close_size > 0 else position_size
|
||||
if close_size > 0:
|
||||
actual_close_size = min(close_size, position_size)
|
||||
elif close_scope == "system_tracked":
|
||||
actual_close_size = min(tracked_net, position_size)
|
||||
logger.info(
|
||||
"close_position system_tracked: symbol=%s side=%s position=%s tracked_net=%s close=%s",
|
||||
symbol,
|
||||
position.get("side"),
|
||||
position_size,
|
||||
tracked_net,
|
||||
actual_close_size,
|
||||
)
|
||||
else:
|
||||
actual_close_size = position_size
|
||||
if actual_close_size > position_size:
|
||||
actual_close_size = position_size
|
||||
if actual_close_size <= 0:
|
||||
return jsonify({"code": 0, "msg": "Close size is zero"}), 400
|
||||
|
||||
# ---- determine signal type based on position side ----
|
||||
if market_type == "spot":
|
||||
@@ -1111,6 +1436,8 @@ def close_position():
|
||||
"avg_price": avg_fill,
|
||||
"closed_size": actual_close_size,
|
||||
"position_side": position_side,
|
||||
"close_scope": close_scope,
|
||||
"tracked_net_base": tracked_net if close_scope == "system_tracked" else None,
|
||||
"status": "filled" if filled > 0 else "submitted",
|
||||
},
|
||||
})
|
||||
@@ -1118,7 +1445,12 @@ def close_position():
|
||||
except Exception as e:
|
||||
logger.error(f"close_position failed: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||||
err_str = str(e)
|
||||
hint = _parse_trade_error_hint(err_str)
|
||||
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
|
||||
if hint:
|
||||
resp["error_hint"] = hint
|
||||
return jsonify(resp), 500
|
||||
|
||||
|
||||
@quick_trade_bp.route('/history', methods=['GET'])
|
||||
|
||||
@@ -399,6 +399,15 @@ CONFIG_SCHEMA = {
|
||||
'link_text': 'settings.link.getToken',
|
||||
'description': 'Tiingo API key for Forex/Metals data'
|
||||
},
|
||||
{
|
||||
'key': 'TWELVE_DATA_API_KEY',
|
||||
'label': 'Twelve Data API Key',
|
||||
'type': 'password',
|
||||
'required': False,
|
||||
'link': 'https://twelvedata.com/apikey',
|
||||
'link_text': 'settings.link.getApiKey',
|
||||
'description': 'Twelve Data API key for CN/HK stock K-lines (free 800 credits/day)'
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ from app.services.strategy_snapshot import StrategySnapshotResolver
|
||||
from app import get_trading_executor
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
|
||||
try:
|
||||
from psycopg2.errors import UndefinedTable as PgUndefinedTable
|
||||
except Exception: # pragma: no cover
|
||||
PgUndefinedTable = None # type: ignore
|
||||
from app.utils.auth import login_required
|
||||
from app.data_sources import DataSourceFactory
|
||||
|
||||
@@ -662,6 +667,15 @@ def get_equity_curve():
|
||||
(strategy_id,)
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(unrealized_pnl), 0) AS u
|
||||
FROM qd_strategy_positions
|
||||
WHERE strategy_id = ?
|
||||
""",
|
||||
(strategy_id,),
|
||||
)
|
||||
prow = cur.fetchone() or {}
|
||||
cur.close()
|
||||
|
||||
equity = initial
|
||||
@@ -678,7 +692,17 @@ def get_equity_curve():
|
||||
ts = int(created_at)
|
||||
else:
|
||||
ts = int(time.time())
|
||||
curve.append({'time': ts, 'equity': equity})
|
||||
curve.append({'time': ts, 'equity': round(equity, 2)})
|
||||
|
||||
# 将未实现盈亏并入曲线末端,便于「持仓中」也能在绩效里看到浮动权益
|
||||
try:
|
||||
unreal = float(prow.get('u') or prow.get('U') or 0)
|
||||
except Exception:
|
||||
unreal = 0.0
|
||||
live_equity = float(equity) + unreal
|
||||
now_ts = int(time.time())
|
||||
if abs(unreal) > 1e-12 or not curve:
|
||||
curve.append({'time': now_ts, 'equity': round(live_equity, 2)})
|
||||
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': curve})
|
||||
except Exception as e:
|
||||
@@ -1391,11 +1415,16 @@ def get_strategy_performance():
|
||||
def get_strategy_logs():
|
||||
"""Get strategy running logs."""
|
||||
try:
|
||||
user_id = g.user_id
|
||||
strategy_id = request.args.get('id')
|
||||
limit = int(request.args.get('limit', 200))
|
||||
if not strategy_id:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy ID required'})
|
||||
|
||||
st = get_strategy_service().get_strategy(int(strategy_id), user_id=user_id)
|
||||
if not st:
|
||||
return jsonify({'code': 0, 'msg': 'Strategy not found'}), 404
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
@@ -1411,10 +1440,22 @@ def get_strategy_logs():
|
||||
rows = cur.fetchall() or []
|
||||
cur.close()
|
||||
|
||||
logs = list(reversed(rows))
|
||||
out = []
|
||||
for r in rows or []:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
rr = dict(r)
|
||||
ts = rr.get('timestamp')
|
||||
if ts is not None and hasattr(ts, 'isoformat'):
|
||||
rr['timestamp'] = ts.isoformat()
|
||||
out.append(rr)
|
||||
logs = list(reversed(out))
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': logs})
|
||||
except Exception as e:
|
||||
if 'qd_strategy_logs' in str(e) and ('does not exist' in str(e) or 'no such table' in str(e)):
|
||||
if PgUndefinedTable is not None and isinstance(e, PgUndefinedTable):
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': []})
|
||||
el = str(e).lower()
|
||||
if 'qd_strategy_logs' in el and ('does not exist' in el or 'no such table' in el):
|
||||
return jsonify({'code': 1, 'msg': 'success', 'data': []})
|
||||
logger.error(f"get_strategy_logs failed: {str(e)}")
|
||||
return jsonify({'code': 0, 'msg': str(e)}), 500
|
||||
@@ -1830,7 +1830,35 @@ IMPORTANT:
|
||||
# 但要做“可用信息重加权”:当某些模块缺失(如新闻/宏观没取到),不要用0分去稀释整体强度,
|
||||
# 而是重新归一化权重,让技术信号在缺失时仍可发挥主导作用。
|
||||
market_type = str(data.get("market") or "")
|
||||
fundamental_present = (market_type == "USStock") and bool(fundamental)
|
||||
|
||||
def _fundamental_meaningful(fund: Dict[str, Any]) -> bool:
|
||||
if not fund:
|
||||
return False
|
||||
for key in (
|
||||
"pe_ratio",
|
||||
"pb_ratio",
|
||||
"ps_ratio",
|
||||
"market_cap",
|
||||
"roe",
|
||||
"eps",
|
||||
"revenue_growth",
|
||||
"profit_margin",
|
||||
"dividend_yield",
|
||||
):
|
||||
v = fund.get(key)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
if isinstance(v, float) and v != v: # NaN
|
||||
continue
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
fundamental_present = (
|
||||
market_type in ("USStock", "CNStock", "HKStock") and _fundamental_meaningful(fundamental)
|
||||
)
|
||||
sentiment_present = bool(news)
|
||||
macro_present = bool(macro)
|
||||
# indicators 一旦成功计算通常就存在,但这里也做一次保护
|
||||
@@ -2057,9 +2085,9 @@ IMPORTANT:
|
||||
|
||||
def _calculate_fundamental_score(self, fundamental: Dict, market: str) -> float:
|
||||
"""计算基本面评分 (-100 to +100)"""
|
||||
if market != "USStock" or not fundamental:
|
||||
return 0.0 # 非美股或无基本面数据,返回中性
|
||||
|
||||
if market not in ("USStock", "CNStock", "HKStock") or not fundamental:
|
||||
return 50.0
|
||||
|
||||
score = 0.0
|
||||
factors = 0
|
||||
|
||||
@@ -2142,7 +2170,9 @@ IMPORTANT:
|
||||
# 归一化(如果有多个因素)
|
||||
if factors > 0:
|
||||
score = score / factors * 100 / 4 # 最大可能分数是4个因素各20分=80,归一化到100
|
||||
|
||||
else:
|
||||
return 50.0
|
||||
|
||||
return max(-100, min(100, score))
|
||||
|
||||
def _calculate_sentiment_score(self, news: List[Dict]) -> float:
|
||||
|
||||
@@ -9,12 +9,75 @@ Notes:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cached SSL verify setting for all live-trading REST calls (requests + SOCKS proxy).
|
||||
_requests_verify_value: Optional[Union[bool, str]] = None
|
||||
_ssl_verify_disabled_logged = False
|
||||
|
||||
# OS CA bundles (Docker / slim images: install ``ca-certificates``; corporate roots often added here too).
|
||||
_SYSTEM_CA_BUNDLE_CANDIDATES: Tuple[str, ...] = (
|
||||
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
||||
"/etc/ssl/cert.pem", # Alpine, some slim images
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/Fedora
|
||||
)
|
||||
|
||||
|
||||
def _get_requests_verify() -> Union[bool, str]:
|
||||
"""
|
||||
Resolve ``verify`` for ``requests`` when calling exchanges through proxies (e.g. PROXY_URL=socks5h://...).
|
||||
|
||||
- LIVE_TRADING_SSL_VERIFY=0|false|no|off: disable verification (insecure; mitm risk).
|
||||
- LIVE_TRADING_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE: path to a PEM CA bundle
|
||||
(needed for corporate TLS inspection or custom roots).
|
||||
- Else a non-empty OS CA file if present (helps Gate/HTX/hbdm etc. in minimal images).
|
||||
- Otherwise certifi's bundle when available.
|
||||
"""
|
||||
global _requests_verify_value, _ssl_verify_disabled_logged
|
||||
if _requests_verify_value is not None:
|
||||
return _requests_verify_value
|
||||
|
||||
flag = (os.environ.get("LIVE_TRADING_SSL_VERIFY") or "").strip().lower()
|
||||
if flag in ("0", "false", "no", "off"):
|
||||
if not _ssl_verify_disabled_logged:
|
||||
logger.warning(
|
||||
"LIVE_TRADING_SSL_VERIFY is disabled: HTTPS certificate verification is OFF for live trading "
|
||||
"requests (MITM risk). Fix CA trust or set LIVE_TRADING_CA_BUNDLE instead for production."
|
||||
)
|
||||
_ssl_verify_disabled_logged = True
|
||||
_requests_verify_value = False
|
||||
return _requests_verify_value
|
||||
|
||||
for key in ("LIVE_TRADING_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE", "CURL_CA_BUNDLE"):
|
||||
path = (os.environ.get(key) or "").strip()
|
||||
if path and os.path.isfile(path):
|
||||
_requests_verify_value = path
|
||||
return _requests_verify_value
|
||||
|
||||
for path in _SYSTEM_CA_BUNDLE_CANDIDATES:
|
||||
try:
|
||||
if path and os.path.isfile(path) and os.path.getsize(path) >= 256:
|
||||
_requests_verify_value = path
|
||||
return _requests_verify_value
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
try:
|
||||
import certifi
|
||||
|
||||
_requests_verify_value = certifi.where()
|
||||
except ImportError:
|
||||
_requests_verify_value = True
|
||||
return _requests_verify_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveOrderResult:
|
||||
@@ -51,15 +114,26 @@ class BaseRestClient:
|
||||
data: Optional[Any] = None,
|
||||
) -> Tuple[int, Dict[str, Any], str]:
|
||||
url = self._url(path)
|
||||
resp = requests.request(
|
||||
method=str(method or "GET").upper(),
|
||||
url=url,
|
||||
params=params or None,
|
||||
json=json_body if json_body is not None else None,
|
||||
data=data,
|
||||
headers=headers or None,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
try:
|
||||
resp = requests.request(
|
||||
method=str(method or "GET").upper(),
|
||||
url=url,
|
||||
params=params or None,
|
||||
json=json_body if json_body is not None else None,
|
||||
data=data,
|
||||
headers=headers or None,
|
||||
timeout=self.timeout_sec,
|
||||
verify=_get_requests_verify(),
|
||||
)
|
||||
except requests.exceptions.SSLError as e:
|
||||
logger.warning(
|
||||
"Exchange HTTPS TLS verify failed (%s). Same setting applies to all REST exchanges (Gate, HTX/hbdm, etc.). "
|
||||
"Behind PROXY_URL/SOCKS or TLS inspection: set LIVE_TRADING_CA_BUNDLE to a PEM bundle (or REQUESTS_CA_BUNDLE), "
|
||||
"ensure ca-certificates in the image, or dev-only LIVE_TRADING_SSL_VERIFY=false. %s",
|
||||
url,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
text = resp.text or ""
|
||||
parsed: Dict[str, Any] = {}
|
||||
try:
|
||||
|
||||
@@ -32,7 +32,7 @@ class BybitClient(BaseRestClient):
|
||||
base_url: str = "https://api.bybit.com",
|
||||
timeout_sec: float = 15.0,
|
||||
category: str = "linear", # "linear" (USDT perpetual) or "spot"
|
||||
recv_window_ms: int = 5000,
|
||||
recv_window_ms: int = 12000,
|
||||
broker_referer: str = "",
|
||||
hedge_mode: bool = False,
|
||||
):
|
||||
@@ -45,11 +45,13 @@ class BybitClient(BaseRestClient):
|
||||
if self.category not in ("linear", "spot"):
|
||||
self.category = "linear"
|
||||
try:
|
||||
self.recv_window_ms = int(recv_window_ms or 5000)
|
||||
self.recv_window_ms = int(recv_window_ms or 12000)
|
||||
except Exception:
|
||||
self.recv_window_ms = 12000
|
||||
if self.recv_window_ms < 5000:
|
||||
self.recv_window_ms = 5000
|
||||
if self.recv_window_ms <= 0:
|
||||
self.recv_window_ms = 5000
|
||||
if self.recv_window_ms > 60000:
|
||||
self.recv_window_ms = 60000
|
||||
|
||||
if not self.api_key or not self.secret_key:
|
||||
raise LiveTradingError("Missing Bybit api_key/secret_key")
|
||||
@@ -59,6 +61,12 @@ class BybitClient(BaseRestClient):
|
||||
self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
self._inst_cache_ttl_sec = 300.0
|
||||
|
||||
# Bybit v5 rejects requests if local clock diverges from server (retCode 10002).
|
||||
# Offset = server_ms - local_ms; signed timestamp uses local_ms + offset.
|
||||
self._time_offset_ms: int = 0
|
||||
self._time_offset_at: float = 0.0
|
||||
self._time_sync_ttl_sec: float = 55.0
|
||||
|
||||
@staticmethod
|
||||
def _to_dec(x: Any) -> Decimal:
|
||||
try:
|
||||
@@ -161,6 +169,48 @@ class BybitClient(BaseRestClient):
|
||||
def _sign(self, prehash: str) -> str:
|
||||
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _parse_server_time_ms_from_market_time(raw: Dict[str, Any]) -> int:
|
||||
"""Parse milliseconds from GET /v5/market/time (or similar) JSON."""
|
||||
if not isinstance(raw, dict):
|
||||
raise LiveTradingError("Bybit market/time: invalid response")
|
||||
res = raw.get("result")
|
||||
if isinstance(res, dict):
|
||||
nano = res.get("timeNano")
|
||||
if nano is not None and str(nano).strip() != "":
|
||||
try:
|
||||
return int(int(str(nano)) // 1_000_000)
|
||||
except Exception:
|
||||
pass
|
||||
sec = res.get("timeSecond")
|
||||
if sec is not None and str(sec).strip() != "":
|
||||
try:
|
||||
return int(float(sec) * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
t = raw.get("time")
|
||||
if t is not None:
|
||||
try:
|
||||
return int(t)
|
||||
except Exception:
|
||||
pass
|
||||
raise LiveTradingError("Bybit market/time: missing time fields")
|
||||
|
||||
def sync_server_time_offset(self, *, force: bool = False) -> None:
|
||||
"""Align signing timestamp with Bybit server (public /v5/market/time)."""
|
||||
now = time.time()
|
||||
if (
|
||||
not force
|
||||
and self._time_offset_at > 0
|
||||
and (now - self._time_offset_at) < float(self._time_sync_ttl_sec or 55.0)
|
||||
):
|
||||
return
|
||||
raw = self._public_request("GET", "/v5/market/time")
|
||||
srv_ms = self._parse_server_time_ms_from_market_time(raw)
|
||||
local_ms = int(time.time() * 1000)
|
||||
self._time_offset_ms = int(srv_ms - local_ms)
|
||||
self._time_offset_at = now
|
||||
|
||||
def _resolve_position_idx(self, pos_side: str) -> Optional[int]:
|
||||
if not self.hedge_mode:
|
||||
return None
|
||||
@@ -193,32 +243,55 @@ class BybitClient(BaseRestClient):
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
m = str(method or "GET").upper()
|
||||
ts_ms = str(int(time.time() * 1000))
|
||||
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
qs = ""
|
||||
qs_base = ""
|
||||
if params:
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
qs_base = urlencode(sorted(norm.items()), doseq=True)
|
||||
payload_get = qs_base
|
||||
payload_post = body_str
|
||||
|
||||
payload = qs if m == "GET" else body_str
|
||||
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
|
||||
sign = self._sign(prehash)
|
||||
last_err: Optional[LiveTradingError] = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
self.sync_server_time_offset(force=(attempt > 0))
|
||||
except Exception as e:
|
||||
if attempt == 0:
|
||||
# First attempt: still try with raw local time; second pass may recover.
|
||||
pass
|
||||
else:
|
||||
raise LiveTradingError(f"Bybit time sync failed: {e}") from e
|
||||
|
||||
code, data, text = self._request(
|
||||
m,
|
||||
path,
|
||||
params=params if (m == "GET" and params) else (params or None),
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
rc = data.get("retCode")
|
||||
if rc not in (0, "0", None, ""):
|
||||
raise LiveTradingError(f"Bybit error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
ts_ms = str(int(time.time() * 1000) + int(self._time_offset_ms or 0))
|
||||
payload = payload_get if m == "GET" else payload_post
|
||||
prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}"
|
||||
sign = self._sign(prehash)
|
||||
|
||||
code, data, text = self._request(
|
||||
m,
|
||||
path,
|
||||
params=params if (m == "GET" and params) else (params or None),
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}")
|
||||
if isinstance(data, dict):
|
||||
rc = data.get("retCode")
|
||||
try:
|
||||
rc_int = int(rc) if rc is not None and str(rc).strip() != "" else 0
|
||||
except Exception:
|
||||
rc_int = -1
|
||||
if rc_int == 10002 and attempt == 0:
|
||||
last_err = LiveTradingError(f"Bybit error: {data}")
|
||||
continue
|
||||
if rc not in (0, "0", None, ""):
|
||||
raise LiveTradingError(f"Bybit error: {data}")
|
||||
return data if isinstance(data, dict) else {"raw": data}
|
||||
|
||||
if last_err:
|
||||
raise last_err
|
||||
raise LiveTradingError("Bybit signed request failed after time resync")
|
||||
|
||||
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
||||
@@ -237,6 +310,109 @@ class BybitClient(BaseRestClient):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _row_to_ticker_out(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(row, dict):
|
||||
return {}
|
||||
last_raw = row.get("lastPrice") or row.get("last") or row.get("markPrice") or row.get("indexPrice") or 0
|
||||
try:
|
||||
px = float(str(last_raw).replace(",", "").strip() or 0)
|
||||
except Exception:
|
||||
px = 0.0
|
||||
if px <= 0:
|
||||
return {}
|
||||
out: Dict[str, Any] = dict(row)
|
||||
out["last"] = px
|
||||
out["price"] = px
|
||||
return out
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Public market price for USDT notional -> base qty (quick_trade / execution).
|
||||
|
||||
Tries: ``/v5/market/tickers`` (by symbol) → ``/v5/market/orderbook`` (mid) →
|
||||
``/v5/market/tickers`` (category-only, scan list). Some environments return an empty
|
||||
ticker list for filtered queries; fallbacks avoid silent failure.
|
||||
"""
|
||||
sym = to_bybit_symbol(symbol)
|
||||
if not sym:
|
||||
return {}
|
||||
cat = "spot" if (self.category or "").strip().lower() == "spot" else "linear"
|
||||
sym_u = sym.upper()
|
||||
|
||||
# 1) Filtered tickers (preferred)
|
||||
try:
|
||||
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat, "symbol": sym_u})
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if isinstance(lst, list) and lst:
|
||||
out = self._row_to_ticker_out(lst[0] if isinstance(lst[0], dict) else {})
|
||||
if out:
|
||||
return out
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Order book mid (bid/ask)
|
||||
try:
|
||||
ob = self._public_request(
|
||||
"GET",
|
||||
"/v5/market/orderbook",
|
||||
params={"category": cat, "symbol": sym_u, "limit": 25},
|
||||
)
|
||||
res = (ob.get("result") or {}) if isinstance(ob, dict) else {}
|
||||
bids = res.get("b") or []
|
||||
asks = res.get("a") or []
|
||||
bid_p = 0.0
|
||||
ask_p = 0.0
|
||||
if isinstance(bids, list) and bids and isinstance(bids[0], (list, tuple)) and len(bids[0]) > 0:
|
||||
try:
|
||||
bid_p = float(str(bids[0][0]).replace(",", ""))
|
||||
except Exception:
|
||||
bid_p = 0.0
|
||||
if isinstance(asks, list) and asks and isinstance(asks[0], (list, tuple)) and len(asks[0]) > 0:
|
||||
try:
|
||||
ask_p = float(str(asks[0][0]).replace(",", ""))
|
||||
except Exception:
|
||||
ask_p = 0.0
|
||||
mid = 0.0
|
||||
if bid_p > 0 and ask_p > 0:
|
||||
mid = (bid_p + ask_p) / 2.0
|
||||
else:
|
||||
mid = bid_p or ask_p
|
||||
if mid > 0:
|
||||
return {
|
||||
"symbol": sym_u,
|
||||
"last": mid,
|
||||
"price": mid,
|
||||
"bid1Price": bid_p,
|
||||
"ask1Price": ask_p,
|
||||
}
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) Full category ticker list, match symbol (larger payload; last resort)
|
||||
try:
|
||||
raw = self._public_request("GET", "/v5/market/tickers", params={"category": cat})
|
||||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||||
if isinstance(lst, list):
|
||||
for row in lst:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if str(row.get("symbol") or "").strip().upper() != sym_u:
|
||||
continue
|
||||
out = self._row_to_ticker_out(row)
|
||||
if out:
|
||||
return out
|
||||
except LiveTradingError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]:
|
||||
return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")})
|
||||
|
||||
@@ -497,10 +673,28 @@ class BybitClient(BaseRestClient):
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
|
||||
time.sleep(float(poll_interval_sec or 0.5))
|
||||
|
||||
def get_positions(self) -> Dict[str, Any]:
|
||||
def get_positions(
|
||||
self,
|
||||
*,
|
||||
symbol: str = "",
|
||||
settle_coin: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
GET /v5/position/list — Bybit v5 requires ``symbol`` OR ``settleCoin`` with ``category``.
|
||||
|
||||
- Pass ``symbol`` (e.g. ETH/USDT) to query one contract.
|
||||
- Omit ``symbol`` and pass ``settle_coin`` (default USDT) to list all USDT-linear positions.
|
||||
"""
|
||||
if self.category != "linear":
|
||||
raise LiveTradingError("Bybit positions are only supported for linear category in this client")
|
||||
return self._signed_request("GET", "/v5/position/list", params={"category": "linear"})
|
||||
params: Dict[str, Any] = {"category": "linear"}
|
||||
sym = to_bybit_symbol(symbol) if (symbol or "").strip() else ""
|
||||
if sym:
|
||||
params["symbol"] = sym
|
||||
else:
|
||||
sc = (settle_coin or "USDT").strip().upper() or "USDT"
|
||||
params["settleCoin"] = sc
|
||||
return self._signed_request("GET", "/v5/position/list", params=params)
|
||||
|
||||
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
|
||||
if self.category != "linear":
|
||||
|
||||
@@ -117,7 +117,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
|
||||
category = "spot" if mt == "spot" else "linear"
|
||||
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
|
||||
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 12000)
|
||||
broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020"
|
||||
hedge_mode_raw = exchange_config.get("hedge_mode")
|
||||
if hedge_mode_raw is None:
|
||||
|
||||
@@ -4,7 +4,7 @@ Gate.io (direct REST) clients:
|
||||
- Futures USDT: /api/v4/futures/usdt/*
|
||||
|
||||
Signing (apiv4):
|
||||
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + body + "\\n" + timestamp))
|
||||
SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + hexencode(sha512(payload)) + "\\n" + timestamp))
|
||||
Headers:
|
||||
- KEY: api key
|
||||
- Timestamp: unix seconds
|
||||
@@ -15,14 +15,42 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_gate_currency_pair
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _gate_ticker_response_to_normalized(raw: Any) -> Dict[str, Any]:
|
||||
"""Parse Gate spot/futures tickers API (array of one row) into a dict with float ``last`` for quick_trade."""
|
||||
row: Dict[str, Any] = {}
|
||||
if isinstance(raw, list) and raw and isinstance(raw[0], dict):
|
||||
row = raw[0]
|
||||
elif isinstance(raw, dict) and raw:
|
||||
row = raw
|
||||
else:
|
||||
return {}
|
||||
last = 0.0
|
||||
for key in ("last", "mark_price", "index_price", "close", "price"):
|
||||
v = row.get(key)
|
||||
if v is not None and str(v).strip():
|
||||
try:
|
||||
last = float(str(v).replace(",", ""))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
out = dict(row)
|
||||
out["last"] = last
|
||||
out["close"] = last
|
||||
out["price"] = last
|
||||
return out
|
||||
|
||||
|
||||
class _GateBase(BaseRestClient):
|
||||
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
|
||||
@@ -34,7 +62,10 @@ class _GateBase(BaseRestClient):
|
||||
raise LiveTradingError("Missing Gate api_key/secret_key")
|
||||
|
||||
def _sign(self, *, method: str, url: str, query_string: str, body_str: str, ts: str) -> str:
|
||||
msg = f"{method.upper()}\n{url}\n{query_string}\n{body_str}\n{ts}"
|
||||
# Per https://www.gate.com/docs/developers/apiv4/en/#authentication — payload slot is SHA512(body).hexdigest(),
|
||||
# not the raw body (GET / no body => hash of empty string).
|
||||
hashed_payload = hashlib.sha512((body_str or "").encode("utf-8")).hexdigest()
|
||||
msg = f"{method.upper()}\n{url}\n{query_string}\n{hashed_payload}\n{ts}"
|
||||
return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest()
|
||||
|
||||
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
|
||||
@@ -58,7 +89,15 @@ class _GateBase(BaseRestClient):
|
||||
text = f"t-{text}"
|
||||
return text[:28]
|
||||
|
||||
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
m = str(method or "GET").upper()
|
||||
ts = str(int(time.time()))
|
||||
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
||||
@@ -67,7 +106,10 @@ class _GateBase(BaseRestClient):
|
||||
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
|
||||
qs = urlencode(sorted(norm.items()), doseq=True)
|
||||
sign = self._sign(method=m, url=path, query_string=qs, body_str=body_str, ts=ts)
|
||||
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign))
|
||||
hdrs = dict(self._headers(ts, sign))
|
||||
if extra_headers:
|
||||
hdrs.update({str(k): str(v) for k, v in extra_headers.items()})
|
||||
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=hdrs)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
|
||||
return data
|
||||
@@ -87,6 +129,11 @@ class GateSpotClient(_GateBase):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
pair = to_gate_currency_pair(symbol)
|
||||
raw = self._public_request("GET", "/api/v4/spot/tickers", params={"currency_pair": pair})
|
||||
return _gate_ticker_response_to_normalized(raw)
|
||||
|
||||
def get_accounts(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/spot/accounts")
|
||||
|
||||
@@ -204,13 +251,21 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
return Decimal("0")
|
||||
|
||||
def ping(self) -> bool:
|
||||
# Gate futures REST no longer serves /api/v4/futures/usdt/time (returns 400 on fx-api / api hosts).
|
||||
# Use a lightweight public list call instead.
|
||||
try:
|
||||
_ = self._public_request("GET", "/api/v4/futures/usdt/time")
|
||||
_ = self._public_request("GET", "/api/v4/futures/usdt/contracts", params={"limit": 1})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
raw = self._public_request("GET", "/api/v4/futures/usdt/tickers", params={"contract": contract})
|
||||
return _gate_ticker_response_to_normalized(raw)
|
||||
|
||||
def get_contract(self, *, contract: str) -> Dict[str, Any]:
|
||||
"""Fetch contract metadata with ``X-Gate-Size-Decimal: 1`` to get accurate string-typed size fields."""
|
||||
c = str(contract or "").strip()
|
||||
if not c:
|
||||
return {}
|
||||
@@ -220,33 +275,122 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
ts, obj = cached
|
||||
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
|
||||
return obj
|
||||
raw = self._public_request("GET", f"/api/v4/futures/usdt/contracts/{c}")
|
||||
obj = raw if isinstance(raw, dict) else {}
|
||||
code, data, text = self._request(
|
||||
"GET", f"/api/v4/futures/usdt/contracts/{c}",
|
||||
params=None, headers={"X-Gate-Size-Decimal": "1"},
|
||||
json_body=None, data=None,
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
|
||||
obj = data if isinstance(data, dict) else {}
|
||||
if obj:
|
||||
self._contract_cache[c] = (now, obj)
|
||||
return obj
|
||||
|
||||
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
|
||||
@staticmethod
|
||||
def _decimal_places(d: Decimal) -> int:
|
||||
"""Return the number of decimal places in a Decimal value."""
|
||||
sign, digits, exponent = d.as_tuple()
|
||||
return max(0, -int(exponent))
|
||||
|
||||
def _resolve_order_size(self, *, contract: str, side: str, base_size: float) -> Tuple[str, Optional[Dict[str, str]]]:
|
||||
"""
|
||||
Convert base-asset qty to a signed Gate ``size`` string and determine whether to use
|
||||
the ``X-Gate-Size-Decimal`` header.
|
||||
|
||||
Per Gate announcement (2025-12-18):
|
||||
- ``size`` is always in **contracts** (not base-asset units).
|
||||
- With ``X-Gate-Size-Decimal: 1``, ``size`` becomes a string that supports decimals.
|
||||
- A contract supports fractional ordering when ``order_size_min`` (queried with the
|
||||
decimal header) contains a fractional part (e.g. ``"0.1"``).
|
||||
- Precision must align with ``order_size_min`` (e.g. if min is ``"0.1"`` → 1 dp).
|
||||
"""
|
||||
sd = (side or "").strip().lower()
|
||||
sign = Decimal("1") if sd == "buy" else Decimal("-1")
|
||||
req = self._to_dec(base_size)
|
||||
if req <= 0:
|
||||
return 0
|
||||
return ("0", None)
|
||||
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=contract) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or meta.get("contract_size") or meta.get("contractSize") or "0")
|
||||
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or "0")
|
||||
if qm <= 0:
|
||||
# Fallback: 1 contract ~= 1 base unit (best-effort)
|
||||
qm = Decimal("1")
|
||||
|
||||
contracts = req / qm
|
||||
return int(self._floor(contracts))
|
||||
|
||||
order_min = self._to_dec(meta.get("order_size_min") or "1")
|
||||
if order_min <= 0:
|
||||
order_min = Decimal("1")
|
||||
dp = self._decimal_places(order_min)
|
||||
|
||||
if dp > 0:
|
||||
step = Decimal(10) ** (-dp)
|
||||
q = contracts.quantize(step, rounding=ROUND_DOWN)
|
||||
if q < order_min and contracts > 0:
|
||||
q = order_min
|
||||
signed_q = q * sign
|
||||
s = format(signed_q, "f")
|
||||
if "." in s:
|
||||
s = s.rstrip("0").rstrip(".")
|
||||
return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0",
|
||||
{"X-Gate-Size-Decimal": "1"})
|
||||
else:
|
||||
iv = int(self._floor(contracts))
|
||||
int_min = max(1, int(order_min))
|
||||
if iv < int_min and contracts > 0:
|
||||
iv = int_min
|
||||
signed_iv = int(Decimal(iv) * sign)
|
||||
return (str(signed_iv), None)
|
||||
|
||||
def _base_to_contracts(self, *, contract: str, base_size: float) -> int:
|
||||
"""Integer contracts estimate (for internal use like position sizing display)."""
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=contract) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(meta.get("quanto_multiplier") or "0")
|
||||
if qm <= 0:
|
||||
qm = Decimal("1")
|
||||
return max(1, int(self._floor(self._to_dec(base_size) / qm)))
|
||||
|
||||
def contracts_signed_to_base_qty(self, *, contract: str, contracts_signed: float) -> float:
|
||||
"""Convert signed position size (contracts) from Gate positions API to base-asset quantity."""
|
||||
try:
|
||||
ct = abs(float(contracts_signed or 0.0))
|
||||
except Exception:
|
||||
return 0.0
|
||||
if ct <= 0:
|
||||
return 0.0
|
||||
meta: Dict[str, Any] = {}
|
||||
try:
|
||||
meta = self.get_contract(contract=str(contract)) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
qm = self._to_dec(
|
||||
meta.get("quanto_multiplier")
|
||||
or meta.get("quantoMultiplier")
|
||||
or meta.get("contract_size")
|
||||
or meta.get("contractSize")
|
||||
or "0"
|
||||
)
|
||||
if qm <= 0:
|
||||
qm = Decimal("1")
|
||||
return float(Decimal(str(ct)) * qm)
|
||||
|
||||
def get_accounts(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/futures/usdt/accounts")
|
||||
|
||||
def get_positions(self) -> Any:
|
||||
return self._signed_request("GET", "/api/v4/futures/usdt/positions")
|
||||
return self._signed_request(
|
||||
"GET", "/api/v4/futures/usdt/positions",
|
||||
extra_headers={"X-Gate-Size-Decimal": "1"},
|
||||
)
|
||||
|
||||
def set_leverage(self, *, contract: str, leverage: float) -> bool:
|
||||
c = str(contract or "").strip()
|
||||
@@ -258,11 +402,26 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
lv = 1
|
||||
if lv < 1:
|
||||
lv = 1
|
||||
try:
|
||||
_ = self._signed_request("POST", f"/api/v4/futures/usdt/positions/{c}/leverage", json_body={"leverage": str(lv)})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
path = f"/api/v4/futures/usdt/positions/{c}/leverage"
|
||||
lv_s = str(lv)
|
||||
# Gate expects ``leverage`` / ``cross_leverage_limit`` as **query parameters**, not JSON body
|
||||
# (see gateapi-python: update_position_leverage). Cross / portfolio mode: leverage=0 + cross_leverage_limit.
|
||||
attempts: Tuple[Dict[str, str], ...] = (
|
||||
{"leverage": lv_s},
|
||||
{"leverage": "0", "cross_leverage_limit": lv_s},
|
||||
{"leverage": lv_s, "cross_leverage_limit": lv_s},
|
||||
)
|
||||
last_err: Optional[Exception] = None
|
||||
for qp in attempts:
|
||||
try:
|
||||
_ = self._signed_request("POST", path, params=qp, json_body=None)
|
||||
return True
|
||||
except LiveTradingError as e:
|
||||
last_err = e
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning("Gate set_leverage failed contract=%s leverage=%s: %s", c, lv, last_err)
|
||||
return False
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
@@ -276,18 +435,27 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
sd = (side or "").strip().lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
base_qty = float(size or 0.0)
|
||||
if base_qty <= 0:
|
||||
raise LiveTradingError("Invalid size (<= 0)")
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
|
||||
if csz <= 0:
|
||||
raise LiveTradingError("Invalid size (converted contracts <= 0)")
|
||||
signed_size = int(csz) if sd == "buy" else -int(csz)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"}
|
||||
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
|
||||
if size_str in ("0", "-0", ""):
|
||||
raise LiveTradingError("Invalid size (resolved contracts == 0)")
|
||||
logger.info("Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s",
|
||||
contract, sd, base_qty, size_str, extra_headers is not None)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": "0", "tif": "ioc"}
|
||||
if reduce_only:
|
||||
body["reduce_only"] = True
|
||||
text = self._format_text(client_order_id)
|
||||
if text:
|
||||
body["text"] = text
|
||||
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
|
||||
raw = self._signed_request(
|
||||
"POST",
|
||||
"/api/v4/futures/usdt/orders",
|
||||
json_body=body,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
|
||||
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
||||
|
||||
@@ -304,21 +472,28 @@ class GateUsdtFuturesClient(_GateBase):
|
||||
sd = (side or "").strip().lower()
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0))
|
||||
if csz <= 0:
|
||||
raise LiveTradingError("Invalid size (converted contracts <= 0)")
|
||||
base_qty = float(size or 0.0)
|
||||
if base_qty <= 0:
|
||||
raise LiveTradingError("Invalid size (<= 0)")
|
||||
px = float(price or 0.0)
|
||||
if px <= 0:
|
||||
raise LiveTradingError("Invalid price")
|
||||
signed_size = int(csz) if sd == "buy" else -int(csz)
|
||||
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"}
|
||||
contract = to_gate_currency_pair(symbol)
|
||||
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
|
||||
if size_str in ("0", "-0", ""):
|
||||
raise LiveTradingError("Invalid size (resolved contracts == 0)")
|
||||
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": str(px), "tif": "gtc"}
|
||||
if reduce_only:
|
||||
body["reduce_only"] = True
|
||||
text = self._format_text(client_order_id)
|
||||
if text:
|
||||
body["text"] = text
|
||||
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
|
||||
raw = self._signed_request(
|
||||
"POST",
|
||||
"/api/v4/futures/usdt/orders",
|
||||
json_body=body,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
|
||||
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@ from urllib.parse import urlencode, urlparse
|
||||
import datetime
|
||||
import time
|
||||
|
||||
import logging
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
||||
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HtxClient(BaseRestClient):
|
||||
def __init__(
|
||||
@@ -53,6 +57,17 @@ class HtxClient(BaseRestClient):
|
||||
self._contract_cache_ttl_sec = 300.0
|
||||
self._lever_cache: Dict[str, int] = {}
|
||||
|
||||
@staticmethod
|
||||
def _format_swap_client_order_id(client_order_id: Optional[str]) -> Optional[int]:
|
||||
"""HTX swap/linear-swap client_order_id must be a pure numeric long (1~9223372036854775807)."""
|
||||
if not client_order_id:
|
||||
return None
|
||||
digits = "".join(c for c in str(client_order_id) if c.isdigit())
|
||||
if not digits:
|
||||
digits = str(int(time.time() * 1000))
|
||||
val = int(digits[-18:])
|
||||
return val if 0 < val <= 9223372036854775807 else None
|
||||
|
||||
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
|
||||
prefix = str(self.broker_id or "").strip()
|
||||
raw = str(client_order_id or "").strip()
|
||||
@@ -202,11 +217,33 @@ class HtxClient(BaseRestClient):
|
||||
if self.market_type == "spot":
|
||||
account_id = self._get_spot_account_id()
|
||||
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
|
||||
# 1) v1 cross
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 cross account_info failed: %s", e)
|
||||
# 2) v3 unified (unified / multi-asset collateral accounts)
|
||||
try:
|
||||
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
|
||||
v3_code = raw.get("code")
|
||||
if v3_code is not None and int(v3_code) == 200:
|
||||
data = raw.get("data")
|
||||
if isinstance(data, list) and data:
|
||||
logger.info("HTX v3 unified_account_info succeeded")
|
||||
return raw
|
||||
else:
|
||||
logger.debug("HTX v3 unified_account_info returned code=%s", v3_code)
|
||||
except (LiveTradingError, Exception) as e:
|
||||
logger.debug("HTX v3 unified_account_info failed: %s", e)
|
||||
# 3) v1 isolated
|
||||
try:
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
|
||||
except LiveTradingError as e:
|
||||
logger.warning("HTX all balance endpoints failed, last error: %s", e)
|
||||
return {"data": []}
|
||||
|
||||
def get_positions(self, *, symbol: str = "") -> Any:
|
||||
if self.market_type == "spot":
|
||||
@@ -235,11 +272,35 @@ class HtxClient(BaseRestClient):
|
||||
return {"data": rows}
|
||||
|
||||
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
|
||||
# 1) v1 cross
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 cross position_info failed: %s", e)
|
||||
# 2) v1 isolated
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
|
||||
data = raw.get("data")
|
||||
if data:
|
||||
return raw
|
||||
except LiveTradingError as e:
|
||||
logger.debug("HTX v1 isolated position_info failed: %s", e)
|
||||
# 3) v3 unified - extract positions from cross_swap sub-array
|
||||
try:
|
||||
raw = self._swap_private_request("GET", "/linear-swap-api/v3/unified_account_info")
|
||||
v3_code = raw.get("code")
|
||||
if v3_code is not None and int(v3_code) == 200:
|
||||
v3_data = raw.get("data") or []
|
||||
if isinstance(v3_data, list) and v3_data:
|
||||
logger.info("HTX v3 unified_account_info for positions succeeded")
|
||||
return raw
|
||||
except (LiveTradingError, Exception) as e:
|
||||
logger.debug("HTX v3 unified_account_info (positions) failed: %s", e)
|
||||
logger.warning("HTX all position endpoints failed for symbol=%s", symbol)
|
||||
return {"data": []}
|
||||
|
||||
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
|
||||
if self.market_type == "spot":
|
||||
@@ -355,7 +416,7 @@ class HtxClient(BaseRestClient):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
offset = "close" if reduce_only else "open"
|
||||
lever_rate = int(self._lever_cache.get(contract_code) or 5)
|
||||
body = {
|
||||
body: Dict[str, Any] = {
|
||||
"contract_code": contract_code,
|
||||
"volume": volume,
|
||||
"direction": sd,
|
||||
@@ -365,8 +426,18 @@ class HtxClient(BaseRestClient):
|
||||
}
|
||||
if self.broker_id:
|
||||
body["channel_code"] = self.broker_id
|
||||
if client_order_id:
|
||||
body["client_order_id"] = str(client_order_id)[:64]
|
||||
swap_coid = self._format_swap_client_order_id(client_order_id)
|
||||
if swap_coid is not None:
|
||||
body["client_order_id"] = swap_coid
|
||||
cross_body = dict(body)
|
||||
cross_body["margin_account"] = "USDT"
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
||||
except LiveTradingError as e:
|
||||
logger.info("HTX swap_cross_order failed, trying swap_order: %s", e)
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
@@ -412,7 +483,7 @@ class HtxClient(BaseRestClient):
|
||||
contract_code = to_htx_contract_code(symbol)
|
||||
volume = self._base_to_contracts(symbol=symbol, qty=qty)
|
||||
lever_rate = int(self._lever_cache.get(contract_code) or 5)
|
||||
body = {
|
||||
body: Dict[str, Any] = {
|
||||
"contract_code": contract_code,
|
||||
"volume": volume,
|
||||
"direction": sd,
|
||||
@@ -423,8 +494,18 @@ class HtxClient(BaseRestClient):
|
||||
}
|
||||
if self.broker_id:
|
||||
body["channel_code"] = self.broker_id
|
||||
if client_order_id:
|
||||
body["client_order_id"] = str(client_order_id)[:64]
|
||||
swap_coid = self._format_swap_client_order_id(client_order_id)
|
||||
if swap_coid is not None:
|
||||
body["client_order_id"] = swap_coid
|
||||
cross_body = dict(body)
|
||||
cross_body["margin_account"] = "USDT"
|
||||
try:
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_order", json_body=cross_body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
||||
except LiveTradingError:
|
||||
pass
|
||||
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
|
||||
data = raw.get("data") or {}
|
||||
oid = str(data.get("order_id_str") or data.get("order_id") or "")
|
||||
|
||||
@@ -129,7 +129,7 @@ class MarketDataCollector:
|
||||
}
|
||||
|
||||
# 如果需要基本面,也并行获取
|
||||
if market == 'USStock':
|
||||
if market in ('USStock', 'CNStock', 'HKStock'):
|
||||
core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental"
|
||||
core_futures[executor.submit(self._get_company, market, symbol)] = "company"
|
||||
elif market == 'Crypto':
|
||||
@@ -616,9 +616,94 @@ class MarketDataCollector:
|
||||
try:
|
||||
if market == 'USStock':
|
||||
return self._get_us_fundamental(symbol)
|
||||
if market in ('CNStock', 'HKStock'):
|
||||
return self._get_cn_hk_fundamental(market, symbol)
|
||||
except Exception as e:
|
||||
logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}")
|
||||
return None
|
||||
|
||||
def _get_cn_hk_fundamental(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
CN/HK fundamentals — multi-tier:
|
||||
Tier 1: Twelve Data /statistics (globally stable, paid)
|
||||
Tier 2: AkShare / Eastmoney (fragile overseas)
|
||||
+ Tencent quote for live price fields
|
||||
"""
|
||||
try:
|
||||
from app.data_sources.tencent import (
|
||||
normalize_cn_code,
|
||||
normalize_hk_code,
|
||||
fetch_quote,
|
||||
parse_quote_to_ticker,
|
||||
)
|
||||
from app.data_sources.cn_hk_fundamentals import (
|
||||
fetch_twelvedata_fundamental,
|
||||
fetch_cn_fundamental_akshare,
|
||||
fetch_hk_fundamental_akshare,
|
||||
)
|
||||
|
||||
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
|
||||
is_hk = market == 'HKStock'
|
||||
|
||||
parts = fetch_quote(code)
|
||||
t = parse_quote_to_ticker(parts) if parts else {}
|
||||
result: Dict[str, Any] = {
|
||||
"pe_ratio": None,
|
||||
"pb_ratio": None,
|
||||
"ps_ratio": None,
|
||||
"market_cap": None,
|
||||
"dividend_yield": None,
|
||||
"beta": None,
|
||||
"52w_high": None,
|
||||
"52w_low": None,
|
||||
"roe": None,
|
||||
"eps": None,
|
||||
"last": t.get("last"),
|
||||
"previous_close": t.get("previousClose"),
|
||||
"change_percent": t.get("changePercent"),
|
||||
"source": "tencent_quote",
|
||||
}
|
||||
|
||||
# Tier 1: Twelve Data
|
||||
td = {}
|
||||
try:
|
||||
td = fetch_twelvedata_fundamental(code, is_hk)
|
||||
except Exception as e:
|
||||
logger.debug("TwelveData fundamental failed %s:%s: %s", market, symbol, e)
|
||||
|
||||
if td:
|
||||
result["source"] = "tencent_quote+twelvedata"
|
||||
for k, v in td.items():
|
||||
if k == "source":
|
||||
continue
|
||||
if v is not None:
|
||||
result[k] = v
|
||||
|
||||
# Tier 2: AkShare (fill any remaining None fields)
|
||||
has_valuation = result.get("pe_ratio") is not None or result.get("pb_ratio") is not None
|
||||
if not has_valuation:
|
||||
try:
|
||||
ak_data = fetch_cn_fundamental_akshare(code) if not is_hk else fetch_hk_fundamental_akshare(code)
|
||||
except Exception as e:
|
||||
logger.debug("AkShare CN/HK fundamental failed %s:%s: %s", market, symbol, e)
|
||||
ak_data = {}
|
||||
if ak_data:
|
||||
if "twelvedata" not in result.get("source", ""):
|
||||
result["source"] = "tencent_quote+akshare_em"
|
||||
else:
|
||||
result["source"] += "+akshare_em"
|
||||
for k, v in ak_data.items():
|
||||
if k == "source":
|
||||
continue
|
||||
if v is not None and result.get(k) is None:
|
||||
result[k] = v
|
||||
|
||||
if not parts and not td and not has_valuation:
|
||||
return None
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.debug(f"CN/HK fundamental failed: {market}:{symbol}: {e}")
|
||||
return None
|
||||
|
||||
def _get_us_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -924,11 +1009,85 @@ class MarketDataCollector:
|
||||
'market_cap': profile.get('marketCapitalization'),
|
||||
'website': profile.get('weburl'),
|
||||
}
|
||||
if market in ('CNStock', 'HKStock'):
|
||||
return self._get_cn_hk_company(market, symbol)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _get_cn_hk_company(self, market: str, symbol: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
CN/HK company info — multi-tier:
|
||||
Tier 1: Twelve Data /profile (globally stable)
|
||||
Tier 2: AkShare / Eastmoney (fragile overseas)
|
||||
+ Tencent quote for Chinese name
|
||||
"""
|
||||
try:
|
||||
from app.data_sources.tencent import (
|
||||
normalize_cn_code,
|
||||
normalize_hk_code,
|
||||
fetch_quote,
|
||||
)
|
||||
from app.data_sources.cn_hk_fundamentals import (
|
||||
fetch_twelvedata_profile,
|
||||
fetch_cn_company_extras,
|
||||
fetch_hk_company_extras,
|
||||
)
|
||||
|
||||
code = normalize_cn_code(symbol) if market == 'CNStock' else normalize_hk_code(symbol)
|
||||
is_hk = market == 'HKStock'
|
||||
|
||||
parts = fetch_quote(code)
|
||||
cn_name = ""
|
||||
if parts:
|
||||
cn_name = (parts[1] or "").strip() if len(parts) > 1 else ""
|
||||
|
||||
row: Dict[str, Any] = {
|
||||
"name": cn_name or code,
|
||||
"country": "CN" if market == "CNStock" else "HK",
|
||||
"exchange": "SSE/SZSE" if market == "CNStock" else "HKEX",
|
||||
"symbol": code,
|
||||
"source": "tencent_quote",
|
||||
}
|
||||
|
||||
# Tier 1: Twelve Data /profile
|
||||
td_profile = {}
|
||||
try:
|
||||
td_profile = fetch_twelvedata_profile(code, is_hk)
|
||||
except Exception as e:
|
||||
logger.debug("TwelveData profile failed %s:%s: %s", market, symbol, e)
|
||||
|
||||
if td_profile:
|
||||
row["source"] = "tencent_quote+twelvedata"
|
||||
for k in ("industry", "sector", "website", "description", "employees", "full_name"):
|
||||
v = td_profile.get(k)
|
||||
if v is not None:
|
||||
row[k] = v
|
||||
if not cn_name and td_profile.get("name"):
|
||||
row["name"] = td_profile["name"]
|
||||
|
||||
# Tier 2: AkShare (fill remaining gaps)
|
||||
if not row.get("industry"):
|
||||
try:
|
||||
ex = fetch_cn_company_extras(code) if not is_hk else fetch_hk_company_extras(code)
|
||||
except Exception:
|
||||
ex = {}
|
||||
if ex:
|
||||
if "twelvedata" not in row.get("source", ""):
|
||||
row["source"] = "tencent_quote+akshare_em"
|
||||
else:
|
||||
row["source"] += "+akshare_em"
|
||||
for k in ("industry", "ipo_date", "website", "full_name"):
|
||||
if ex.get(k) and not row.get(k):
|
||||
row[k] = ex[k]
|
||||
|
||||
if not parts and not td_profile and not row.get("industry"):
|
||||
return None
|
||||
return row
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ==================== 宏观数据 (复用全球金融板块) ====================
|
||||
|
||||
|
||||
@@ -360,8 +360,8 @@ class PendingOrderWorker:
|
||||
exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(total))
|
||||
|
||||
elif isinstance(client, BybitClient) and market_type == "swap":
|
||||
# Bybit linear positions
|
||||
resp = client.get_positions()
|
||||
# Bybit v5 requires symbol or settleCoin — use USDT for full linear book
|
||||
resp = client.get_positions(settle_coin="USDT")
|
||||
lst = (((resp.get("result") or {}).get("list")) if isinstance(resp, dict) else None) or []
|
||||
if isinstance(lst, list):
|
||||
for p in lst:
|
||||
@@ -1234,13 +1234,14 @@ class PendingOrderWorker:
|
||||
actual_pos_size = pos_amt
|
||||
break
|
||||
elif isinstance(client, BybitClient):
|
||||
pos_resp = client.get_positions() or {}
|
||||
pos_resp = client.get_positions(symbol=str(symbol or "")) or {}
|
||||
pos_list = (pos_resp.get("result") or {}).get("list") or [] if isinstance(pos_resp, dict) else []
|
||||
want = str(symbol or "").replace("/", "").replace("-", "").upper()
|
||||
for pos in pos_list:
|
||||
if not isinstance(pos, dict):
|
||||
continue
|
||||
pos_sym = str(pos.get("symbol") or "")
|
||||
if pos_sym != str(symbol or "").replace("/", ""):
|
||||
pos_sym = str(pos.get("symbol") or "").strip().upper()
|
||||
if pos_sym != want:
|
||||
continue
|
||||
p_side = str(pos.get("side") or "").strip().lower()
|
||||
if (p_side == "buy" and pos_side == "long") or (p_side == "sell" and pos_side == "short"):
|
||||
|
||||
@@ -87,26 +87,20 @@ class PolymarketWorker:
|
||||
logger.info("Starting Polymarket data update and analysis...")
|
||||
start_time = time.time()
|
||||
|
||||
# 1. 更新市场数据(从所有主要分类获取)
|
||||
categories = ["crypto", "politics", "economics", "sports", "tech", "finance", "geopolitics", "culture", "climate", "entertainment"]
|
||||
all_markets = []
|
||||
# Gamma API /events has no category param — fetch ALL once, categorize locally.
|
||||
all_markets = self.polymarket_source.get_trending_markets(category="all", limit=500)
|
||||
logger.info(f"Fetched {len(all_markets)} markets from Gamma API (single request)")
|
||||
|
||||
for category in categories:
|
||||
try:
|
||||
markets = self.polymarket_source.get_trending_markets(category, limit=50)
|
||||
all_markets.extend(markets)
|
||||
logger.info(f"Fetched {len(markets)} markets from category: {category}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch markets for category {category}: {e}")
|
||||
|
||||
# 去重(按market_id)
|
||||
unique_markets = {}
|
||||
cat_counts: Dict[str, int] = {}
|
||||
for market in all_markets:
|
||||
market_id = market.get('market_id')
|
||||
if market_id:
|
||||
unique_markets[market_id] = market
|
||||
cat = market.get('category', 'other')
|
||||
cat_counts[cat] = cat_counts.get(cat, 0) + 1
|
||||
|
||||
logger.info(f"Total unique markets: {len(unique_markets)}")
|
||||
logger.info(f"Total unique markets: {len(unique_markets)}, by category: {cat_counts}")
|
||||
|
||||
# 2. 批量分析市场(一次性分析所有市场,由AI筛选机会)
|
||||
markets_list = list(unique_markets.values())
|
||||
|
||||
@@ -28,6 +28,8 @@ from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
@@ -78,6 +80,43 @@ def _signal_meta(signal_type: str) -> Dict[str, str]:
|
||||
return {"action": action, "side": side, "type": st}
|
||||
|
||||
|
||||
def _load_user_timezone_for_strategy(strategy_id: int) -> str:
|
||||
try:
|
||||
sid = int(strategy_id)
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(u.timezone, '') AS tz
|
||||
FROM qd_strategies_trading s
|
||||
JOIN qd_users u ON u.id = s.user_id
|
||||
WHERE s.id = ?
|
||||
""",
|
||||
(sid,),
|
||||
)
|
||||
row = cur.fetchone() or {}
|
||||
cur.close()
|
||||
return str(row.get("tz") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _utc_ts_to_user_display(now: int, user_timezone: str) -> Tuple[str, str, str]:
|
||||
"""Return (utc_iso, display_local_str, label_for_plaintext)."""
|
||||
iso = datetime.fromtimestamp(int(now), tz=timezone.utc).isoformat()
|
||||
utz = (user_timezone or "").strip()
|
||||
if not utz:
|
||||
return iso, iso, "Time (UTC)"
|
||||
try:
|
||||
dt = datetime.fromtimestamp(int(now), tz=timezone.utc).astimezone(ZoneInfo(utz))
|
||||
return iso, dt.strftime("%Y-%m-%d %H:%M:%S"), f"Time ({utz})"
|
||||
except Exception:
|
||||
return iso, iso, "Time (UTC)"
|
||||
|
||||
|
||||
def _fmt_float(value: Any, *, max_decimals: int = 10) -> str:
|
||||
try:
|
||||
v = float(value or 0.0)
|
||||
@@ -150,6 +189,7 @@ class SignalNotifier:
|
||||
targets = _safe_json(cfg.get("targets") or {})
|
||||
extra = extra if isinstance(extra, dict) else {}
|
||||
|
||||
user_tz = _load_user_timezone_for_strategy(int(strategy_id))
|
||||
payload = self._build_payload(
|
||||
strategy_id=strategy_id,
|
||||
strategy_name=strategy_name,
|
||||
@@ -159,6 +199,7 @@ class SignalNotifier:
|
||||
stake_amount=stake_amount,
|
||||
direction=direction,
|
||||
extra=extra,
|
||||
user_timezone=user_tz,
|
||||
)
|
||||
rendered = self._render_messages(payload)
|
||||
title = rendered.get("title") or ""
|
||||
@@ -252,9 +293,10 @@ class SignalNotifier:
|
||||
stake_amount: float,
|
||||
direction: str,
|
||||
extra: Dict[str, Any],
|
||||
user_timezone: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
now = int(time.time())
|
||||
iso = datetime.fromtimestamp(now, tz=timezone.utc).isoformat()
|
||||
iso, disp, tlab = _utc_ts_to_user_display(now, user_timezone)
|
||||
meta = _signal_meta(signal_type)
|
||||
|
||||
pending_id = None
|
||||
@@ -268,6 +310,8 @@ class SignalNotifier:
|
||||
"version": 1,
|
||||
"timestamp": now,
|
||||
"timestamp_iso": iso,
|
||||
"timestamp_display": disp,
|
||||
"time_label": tlab,
|
||||
"strategy": {
|
||||
"id": int(strategy_id),
|
||||
"name": str(strategy_name or ""),
|
||||
@@ -310,6 +354,8 @@ class SignalNotifier:
|
||||
pending_id = int(trace.get("pending_order_id") or 0) if trace.get("pending_order_id") else 0
|
||||
mode = str(trace.get("mode") or "")
|
||||
ts_iso = str(payload.get("timestamp_iso") or "")
|
||||
ts_disp = str(payload.get("timestamp_display") or "") or ts_iso
|
||||
ts_lbl = str(payload.get("time_label") or "Time")
|
||||
|
||||
plain_lines = [
|
||||
"QuantDinger Signal",
|
||||
@@ -323,8 +369,8 @@ class SignalNotifier:
|
||||
plain_lines.append(f"PendingOrder: {pending_id}")
|
||||
if mode:
|
||||
plain_lines.append(f"Mode: {mode}")
|
||||
if ts_iso:
|
||||
plain_lines.append(f"Time(UTC): {ts_iso}")
|
||||
if ts_disp:
|
||||
plain_lines.append(f"{ts_lbl}: {ts_disp}")
|
||||
|
||||
# Telegram (HTML) message. Escape all dynamic values.
|
||||
t_strategy = f"{strategy.get('name') or ''} (#{int(strategy.get('id') or 0)})"
|
||||
@@ -341,8 +387,8 @@ class SignalNotifier:
|
||||
telegram_lines.append(f"<b>PendingOrder</b>: <code>{pending_id}</code>")
|
||||
if mode:
|
||||
telegram_lines.append(f"<b>Mode</b>: <code>{html.escape(mode)}</code>")
|
||||
if ts_iso:
|
||||
telegram_lines.append(f"<b>Time (UTC)</b>: <code>{html.escape(ts_iso)}</code>")
|
||||
if ts_disp:
|
||||
telegram_lines.append(f"<b>{html.escape(ts_lbl)}</b>: <code>{html.escape(ts_disp)}</code>")
|
||||
telegram_html = "\n".join([x for x in telegram_lines if x is not None])
|
||||
|
||||
# Email (HTML) message. Keep inline CSS for maximum compatibility.
|
||||
@@ -355,7 +401,8 @@ class SignalNotifier:
|
||||
stake_text=stake_s,
|
||||
pending_id=pending_id or None,
|
||||
mode_text=mode or "",
|
||||
timestamp_iso=ts_iso or "",
|
||||
timestamp_display=ts_disp or "",
|
||||
time_row_label=ts_lbl or "Time",
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -376,7 +423,8 @@ class SignalNotifier:
|
||||
stake_text: str,
|
||||
pending_id: Optional[int],
|
||||
mode_text: str,
|
||||
timestamp_iso: str,
|
||||
timestamp_display: str,
|
||||
time_row_label: str,
|
||||
) -> str:
|
||||
def esc(s: Any) -> str:
|
||||
return html.escape(str(s or ""))
|
||||
@@ -392,8 +440,8 @@ class SignalNotifier:
|
||||
rows.append(("PendingOrder", str(int(pending_id))))
|
||||
if mode_text:
|
||||
rows.append(("Mode", mode_text))
|
||||
if timestamp_iso:
|
||||
rows.append(("Time (UTC)", timestamp_iso))
|
||||
if timestamp_display:
|
||||
rows.append((time_row_label or "Time", timestamp_display))
|
||||
|
||||
tr_html = "\n".join(
|
||||
[
|
||||
@@ -418,7 +466,7 @@ class SignalNotifier:
|
||||
<div style="max-width:640px;margin:0 auto;padding:24px;">
|
||||
<div style="background:#111827;color:#ffffff;padding:16px 18px;border-radius:12px 12px 0 0;">
|
||||
<div style="font-size:16px;letter-spacing:0.2px;font-weight:600;">{esc(title_text)}</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_iso) if timestamp_iso else ""}</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:#d1d5db;">{esc(timestamp_display) if timestamp_display else ""}</div>
|
||||
</div>
|
||||
<div style="background:#ffffff;border:1px solid #eaecef;border-top:0;border-radius:0 0 12px 12px;overflow:hidden;">
|
||||
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
|
||||
|
||||
@@ -977,12 +977,24 @@ class StrategyService:
|
||||
leverage = (trading_config or {}).get('leverage') or existing.get('leverage') or 1
|
||||
market_type = (trading_config or {}).get('market_type') or existing.get('market_type') or 'swap'
|
||||
|
||||
strategy_type = (payload.get('strategy_type') if payload.get('strategy_type') is not None
|
||||
else existing.get('strategy_type')) or 'IndicatorStrategy'
|
||||
strategy_mode = (payload.get('strategy_mode') if payload.get('strategy_mode') is not None
|
||||
else existing.get('strategy_mode')) or 'signal'
|
||||
if 'strategy_code' in payload:
|
||||
strategy_code = payload.get('strategy_code') or ''
|
||||
else:
|
||||
strategy_code = existing.get('strategy_code') or ''
|
||||
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE qd_strategies_trading
|
||||
SET strategy_name = ?,
|
||||
strategy_type = ?,
|
||||
strategy_mode = ?,
|
||||
strategy_code = ?,
|
||||
market_category = ?,
|
||||
execution_mode = ?,
|
||||
notification_config = ?,
|
||||
@@ -1000,6 +1012,9 @@ class StrategyService:
|
||||
""",
|
||||
(
|
||||
name,
|
||||
strategy_type,
|
||||
strategy_mode,
|
||||
strategy_code,
|
||||
market_category,
|
||||
execution_mode,
|
||||
self._dump_json_or_encrypt(notification_config, encrypt=False),
|
||||
|
||||
@@ -135,14 +135,29 @@ class StrategySnapshotResolver:
|
||||
timeframe = str(override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D").strip() or "1D"
|
||||
initial_capital = self._to_float(override.get("initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000))), 10000.0)
|
||||
leverage = self._to_int(override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1)
|
||||
commission = self._percent_to_ratio(override.get("commission", trading_config.get("commission", 0)))
|
||||
slippage = self._percent_to_ratio(override.get("slippage", trading_config.get("slippage", 0)))
|
||||
# Commission/slippage are backtest-only assumptions (not used by live ScriptStrategy execution).
|
||||
# Script strategies created from the UI may omit these; apply sensible backtest defaults.
|
||||
commission_raw = override.get("commission")
|
||||
if commission_raw is None:
|
||||
commission_raw = trading_config.get("commission")
|
||||
slippage_raw = override.get("slippage")
|
||||
if slippage_raw is None:
|
||||
slippage_raw = trading_config.get("slippage")
|
||||
strategy_type_early = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
|
||||
strategy_mode_early = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
|
||||
is_script_early = strategy_type_early == "ScriptStrategy" or strategy_mode_early == "script"
|
||||
if commission_raw is None or commission_raw == "":
|
||||
commission_raw = 0.05 if is_script_early else 0
|
||||
if slippage_raw is None or slippage_raw == "":
|
||||
slippage_raw = 0.0
|
||||
commission = self._percent_to_ratio(commission_raw)
|
||||
slippage = self._percent_to_ratio(slippage_raw)
|
||||
trade_direction = str(trading_config.get("trade_direction") or "long").strip().lower() or "long"
|
||||
enable_mtf = self._to_bool(override.get("enableMtf", market.lower() == "crypto"))
|
||||
|
||||
strategy_type = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
|
||||
strategy_mode = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
|
||||
is_script = strategy_type == "ScriptStrategy" or strategy_mode == "script"
|
||||
strategy_type = strategy_type_early
|
||||
strategy_mode = strategy_mode_early
|
||||
is_script = is_script_early
|
||||
|
||||
indicator_id = indicator_config.get("indicator_id") or strategy.get("indicator_id")
|
||||
indicator_name = indicator_config.get("indicator_name") or ""
|
||||
|
||||
@@ -21,6 +21,7 @@ import requests
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
|
||||
from app.data_sources.tencent import normalize_cn_code, normalize_hk_code
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -28,6 +29,10 @@ logger = get_logger(__name__)
|
||||
def _normalize_symbol_for_market(market: str, symbol: str) -> str:
|
||||
m = (market or '').strip()
|
||||
s = (symbol or '').strip().upper()
|
||||
if m == 'CNStock':
|
||||
return normalize_cn_code(s)
|
||||
if m == 'HKStock':
|
||||
return normalize_hk_code(s)
|
||||
return s
|
||||
|
||||
|
||||
@@ -105,6 +110,17 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
|
||||
# otherwise fall back to yfinance.
|
||||
return _resolve_name_from_finnhub(s) or _resolve_name_from_yfinance(s)
|
||||
|
||||
# CN/HK stocks: try Tencent quote name first (no key), then yfinance best-effort.
|
||||
if m in ('CNStock', 'HKStock'):
|
||||
try:
|
||||
from app.data_sources.tencent import fetch_quote
|
||||
parts = fetch_quote(s)
|
||||
if parts and len(parts) > 1 and parts[1]:
|
||||
return str(parts[1]).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return _resolve_name_from_yfinance(s)
|
||||
|
||||
# Crypto: at least return base ticker-like display (not a "company", but better than empty)
|
||||
if m == 'Crypto':
|
||||
if '/' in s:
|
||||
|
||||
@@ -21,6 +21,7 @@ import numpy as np
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.strategy_runtime_logs import append_strategy_log
|
||||
from app.data_sources import DataSourceFactory
|
||||
from app.services.kline import KlineService
|
||||
from app.services.indicator_params import IndicatorParamsParser, IndicatorCaller
|
||||
@@ -52,6 +53,8 @@ class TradingExecutor:
|
||||
self._signal_dedup = {} # type: Dict[int, Dict[str, float]]
|
||||
self._signal_dedup_lock = threading.Lock()
|
||||
self.kline_service = KlineService() # K线服务(带缓存)
|
||||
# Throttle writes to qd_strategy_logs (heartbeat), per strategy_id -> monotonic time
|
||||
self._strategy_ui_log_last_tick_ts = {} # type: Dict[int, float]
|
||||
|
||||
# 单实例线程上限,避免无限制创建线程导致 can't start new thread/OOM
|
||||
self.max_threads = int(os.getenv('STRATEGY_MAX_THREADS', '64'))
|
||||
@@ -428,6 +431,7 @@ class TradingExecutor:
|
||||
|
||||
logger.info(f"Strategy {strategy_id} started")
|
||||
self._console_print(f"[strategy:{strategy_id}] started")
|
||||
append_strategy_log(strategy_id, "info", "策略执行线程已启动")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -466,6 +470,7 @@ class TradingExecutor:
|
||||
|
||||
logger.info(f"Strategy {strategy_id} stopped")
|
||||
self._console_print(f"[strategy:{strategy_id}] stopped (requested)")
|
||||
append_strategy_log(strategy_id, "info", "已请求停止策略(运行标志已清除)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -933,6 +938,11 @@ class TradingExecutor:
|
||||
logger.info(f"Strategy {strategy_id} initialized; pending_signals={len(pending_signals)}")
|
||||
if pending_signals:
|
||||
logger.info(f"Initial signals: {pending_signals}")
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"info",
|
||||
f"实盘循环就绪 {symbol} {timeframe},待处理信号 {len(pending_signals or [])} 条",
|
||||
)
|
||||
|
||||
# ============================================
|
||||
# Main loop: unified tick cadence (default: 10s)
|
||||
@@ -1258,6 +1268,11 @@ class TradingExecutor:
|
||||
)
|
||||
if ok:
|
||||
logger.info(f"Strategy {strategy_id} signal executed: {signal_type} @ {execute_price}")
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"signal",
|
||||
f"已提交信号 {signal_type} 参考价 {float(execute_price or 0):.6f}",
|
||||
)
|
||||
# Notify portfolio positions linked to this symbol
|
||||
try:
|
||||
from app.services.portfolio_monitor import notify_strategy_signal_for_positions
|
||||
@@ -1271,6 +1286,11 @@ class TradingExecutor:
|
||||
logger.warning(f"Strategy signal linkage notification failed: {link_e}")
|
||||
else:
|
||||
logger.warning(f"Strategy {strategy_id} signal rejected/failed: {signal_type}")
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"error",
|
||||
f"信号未执行或拒单: {signal_type}",
|
||||
)
|
||||
|
||||
# Update positions once per tick.
|
||||
self._update_positions(strategy_id, symbol, current_price)
|
||||
@@ -1279,17 +1299,37 @@ class TradingExecutor:
|
||||
self._console_print(
|
||||
f"[strategy:{strategy_id}] tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}"
|
||||
)
|
||||
try:
|
||||
nowl = time.time()
|
||||
lastl = float(self._strategy_ui_log_last_tick_ts.get(strategy_id) or 0.0)
|
||||
if nowl - lastl >= 55.0:
|
||||
self._strategy_ui_log_last_tick_ts[strategy_id] = nowl
|
||||
append_strategy_log(
|
||||
strategy_id,
|
||||
"info",
|
||||
f"tick price={float(current_price or 0.0):.8f} pending_signals={len(pending_signals or [])}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Strategy {strategy_id} loop error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self._console_print(f"[strategy:{strategy_id}] loop error: {e}")
|
||||
try:
|
||||
append_strategy_log(strategy_id, "error", f"循环异常: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Strategy {strategy_id} crashed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self._console_print(f"[strategy:{strategy_id}] fatal error: {e}")
|
||||
try:
|
||||
append_strategy_log(strategy_id, "error", f"策略线程致命错误: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# 清理
|
||||
with self.lock:
|
||||
@@ -1297,6 +1337,10 @@ class TradingExecutor:
|
||||
del self.running_strategies[strategy_id]
|
||||
self._console_print(f"[strategy:{strategy_id}] loop exited")
|
||||
logger.info(f"Strategy {strategy_id} loop exited")
|
||||
try:
|
||||
append_strategy_log(strategy_id, "info", "策略执行循环已退出")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sync_positions_with_exchange(self, strategy_id: int, exchange: Any, symbol: str, market_type: str):
|
||||
"""
|
||||
|
||||
@@ -116,6 +116,7 @@ def load_addon_config() -> Dict[str, Any]:
|
||||
('AKSHARE_TIMEOUT', 'akshare.timeout', 'int'),
|
||||
('TIINGO_API_KEY', 'tiingo.api_key', 'string'),
|
||||
('TIINGO_TIMEOUT', 'tiingo.timeout', 'int'),
|
||||
('TWELVE_DATA_API_KEY', 'twelve_data.api_key', 'string'),
|
||||
|
||||
# Search (Google CSE / Bing)
|
||||
('SEARCH_PROVIDER', 'search.provider', 'string'),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Persist strategy runtime lines for the strategy management UI (`qd_strategy_logs`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def append_strategy_log(strategy_id: int, level: str, message: str) -> None:
|
||||
"""Best-effort insert; never raises to caller."""
|
||||
try:
|
||||
sid = int(strategy_id)
|
||||
lv = (level or "info").strip().lower()[:20]
|
||||
msg = str(message or "").strip()
|
||||
if not msg:
|
||||
return
|
||||
msg = msg[:8000]
|
||||
with get_db_connection() as db:
|
||||
cur = db.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO qd_strategy_logs (strategy_id, level, message) VALUES (?, ?, ?)",
|
||||
(sid, lv, msg),
|
||||
)
|
||||
db.commit()
|
||||
cur.close()
|
||||
except Exception as e:
|
||||
logger.debug("append_strategy_log skip: %s", e)
|
||||
@@ -69,6 +69,15 @@ SMTP_USE_SSL=false
|
||||
# PROXY_URL=socks5h://host.docker.internal:10808 # Docker
|
||||
PROXY_URL=
|
||||
|
||||
# Outbound HTTPS to exchanges when using PROXY_URL (especially SOCKS). If you see
|
||||
# SSLCertVerificationError / "unable to get local issuer certificate":
|
||||
# - Prefer: install OS ca-certificates in the image, or point to a PEM bundle:
|
||||
# LIVE_TRADING_CA_BUNDLE=/path/to/ca-bundle.pem
|
||||
# (REQUESTS_CA_BUNDLE / SSL_CERT_FILE are also honored.)
|
||||
# - Last resort only: LIVE_TRADING_SSL_VERIFY=false # disables TLS verify — insecure
|
||||
#LIVE_TRADING_CA_BUNDLE=
|
||||
#LIVE_TRADING_SSL_VERIFY=
|
||||
|
||||
# =========================
|
||||
# Captcha / OAuth (optional)
|
||||
# =========================
|
||||
@@ -160,6 +169,10 @@ YFINANCE_TIMEOUT=30
|
||||
TIINGO_API_KEY=
|
||||
TIINGO_TIMEOUT=10
|
||||
|
||||
# Twelve Data (CN/HK stock K-lines — recommended for overseas servers)
|
||||
# Free tier: 800 API credits/day, 8 requests/minute. https://twelvedata.com
|
||||
TWELVE_DATA_API_KEY=
|
||||
|
||||
# AI search / news
|
||||
SEARCH_PROVIDER=google
|
||||
SEARCH_MAX_RESULTS=10
|
||||
|
||||
@@ -349,6 +349,21 @@ CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON qd_strategy_notification
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_strategy_id ON qd_strategy_notifications(strategy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON qd_strategy_notifications(is_read);
|
||||
|
||||
-- =============================================================================
|
||||
-- 6b. Strategy runtime logs (dashboard / API)
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qd_strategy_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
strategy_id INTEGER NOT NULL REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
|
||||
level VARCHAR(20) DEFAULT 'info',
|
||||
message TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_strategy_logs_strategy_id ON qd_strategy_logs(strategy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_strategy_logs_timestamp ON qd_strategy_logs(timestamp);
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. Indicator Codes
|
||||
-- =============================================================================
|
||||
@@ -647,7 +662,29 @@ INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_acti
|
||||
('Futures', 'ZS', 'Soybeans', 'CBOT', 'USD', 1, 1, 94),
|
||||
('Futures', 'ZW', 'Wheat', 'CBOT', 'USD', 1, 1, 93),
|
||||
('Futures', 'ES', 'S&P 500 E-mini', 'CME', 'USD', 1, 1, 92),
|
||||
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91)
|
||||
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91),
|
||||
-- A股 (CNStock)
|
||||
('CNStock', '600519', '贵州茅台', 'SSE', 'CNY', 1, 1, 100),
|
||||
('CNStock', '600036', '招商银行', 'SSE', 'CNY', 1, 1, 99),
|
||||
('CNStock', '601318', '中国平安', 'SSE', 'CNY', 1, 1, 98),
|
||||
('CNStock', '600900', '长江电力', 'SSE', 'CNY', 1, 1, 97),
|
||||
('CNStock', '601899', '紫金矿业', 'SSE', 'CNY', 1, 1, 96),
|
||||
('CNStock', '000858', '五粮液', 'SZSE', 'CNY', 1, 1, 95),
|
||||
('CNStock', '000333', '美的集团', 'SZSE', 'CNY', 1, 1, 94),
|
||||
('CNStock', '002594', '比亚迪', 'SZSE', 'CNY', 1, 1, 93),
|
||||
('CNStock', '300750', '宁德时代', 'SZSE', 'CNY', 1, 1, 92),
|
||||
('CNStock', '000001', '平安银行', 'SZSE', 'CNY', 1, 1, 91),
|
||||
-- 港股/H股 (HKStock)
|
||||
('HKStock', '00700', '腾讯控股', 'HKEX', 'HKD', 1, 1, 100),
|
||||
('HKStock', '09988', '阿里巴巴-W', 'HKEX', 'HKD', 1, 1, 99),
|
||||
('HKStock', '03690', '美团-W', 'HKEX', 'HKD', 1, 1, 98),
|
||||
('HKStock', '01810', '小米集团-W', 'HKEX', 'HKD', 1, 1, 97),
|
||||
('HKStock', '00939', '建设银行', 'HKEX', 'HKD', 1, 1, 96),
|
||||
('HKStock', '01299', '友邦保险', 'HKEX', 'HKD', 1, 1, 95),
|
||||
('HKStock', '02318', '中国平安', 'HKEX', 'HKD', 1, 1, 94),
|
||||
('HKStock', '00388', '香港交易所', 'HKEX', 'HKD', 1, 1, 93),
|
||||
('HKStock', '00883', '中国海洋石油', 'HKEX', 'HKD', 1, 1, 92),
|
||||
('HKStock', '01398', '工商银行', 'HKEX', 'HKD', 1, 1, 91)
|
||||
ON CONFLICT (market, symbol) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
@@ -5,6 +5,7 @@ yfinance>=0.2.18
|
||||
ccxt>=4.0.0
|
||||
pandas>=1.5.0
|
||||
requests>=2.28.0
|
||||
certifi>=2024.2.2
|
||||
PySocks>=1.7.1
|
||||
akshare>=1.12.0
|
||||
pymysql>=1.0.2
|
||||
|
||||
@@ -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**。
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+135
-132
@@ -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}
|
||||
Vendored
+1
-1
@@ -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>
|
||||
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+18
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-18
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
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
-1
File diff suppressed because one or more lines are too long
+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
+1
File diff suppressed because one or more lines are too long
-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
+1
File diff suppressed because one or more lines are too long
-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
+1
File diff suppressed because one or more lines are too long
-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
+1
File diff suppressed because one or more lines are too long
-1
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
Reference in New Issue
Block a user