@@ -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
|
||||
Reference in New Issue
Block a user