Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-07 22:47:07 +08:00
parent baa3182eca
commit 8563e4ea53
116 changed files with 3189 additions and 356 deletions
+166 -3
View File
@@ -1674,6 +1674,77 @@ def _fetch_stock_opportunity_prices() -> List[Dict[str, Any]]:
return []
def _fetch_local_stock_opportunity_prices(market: str, limit: int = 15) -> List[Dict[str, Any]]:
"""
Fetch CN/HK stock prices for opportunity scanning.
For overseas servers we prefer the project's Tencent-based ticker path, which is
typically more reachable/stable than some Eastmoney/AkShare endpoints.
"""
m = str(market or "").strip()
if m not in ("CNStock", "HKStock"):
return []
fallback_symbols = {
"CNStock": [
{"symbol": "600519", "name": "贵州茅台"},
{"symbol": "000001", "name": "平安银行"},
{"symbol": "300750", "name": "宁德时代"},
{"symbol": "601318", "name": "中国平安"},
{"symbol": "600036", "name": "招商银行"},
{"symbol": "002594", "name": "比亚迪"},
{"symbol": "600276", "name": "恒瑞医药"},
{"symbol": "601899", "name": "紫金矿业"},
],
"HKStock": [
{"symbol": "00700", "name": "腾讯控股"},
{"symbol": "09988", "name": "阿里巴巴-W"},
{"symbol": "03690", "name": "美团-W"},
{"symbol": "01810", "name": "小米集团-W"},
{"symbol": "01299", "name": "友邦保险"},
{"symbol": "00939", "name": "建设银行"},
{"symbol": "02318", "name": "中国平安"},
{"symbol": "09618", "name": "京东集团-SW"},
],
}
try:
from app.data.market_symbols_seed import get_hot_symbols
from app.data_sources import DataSourceFactory
from app.services.symbol_name import resolve_symbol_name
symbols = get_hot_symbols(m, limit=max(int(limit or 15), 1)) or fallback_symbols.get(m, [])
source = DataSourceFactory.get_source(m)
result = []
for item in symbols[: max(int(limit or 15), 1)]:
try:
symbol = str(item.get("symbol") or "").strip()
if not symbol:
continue
ticker = source.get_ticker(symbol) or {}
last = _safe_float(ticker.get("last") or ticker.get("close") or ticker.get("price"))
if last <= 0:
continue
change_pct = ticker.get("changePercent")
if change_pct is None:
prev_close = _safe_float(ticker.get("previousClose"))
change_pct = ((last - prev_close) / prev_close * 100.0) if prev_close > 0 else 0.0
result.append({
"symbol": symbol,
"name": (item.get("name") or resolve_symbol_name(m, symbol) or symbol).strip(),
"price": round(last, 4),
"change": round(_safe_float(change_pct), 2),
"market": m,
})
except Exception as e:
logger.debug(f"Failed to fetch {m} opportunity price {item.get('symbol')}: {e}")
return result
except Exception as e:
logger.error(f"Failed to fetch {m} opportunity prices: {e}")
return []
def _analyze_opportunities_crypto(opportunities: list):
"""Scan crypto market for trading opportunities."""
crypto_data = _get_cached("crypto_prices")
@@ -1800,6 +1871,75 @@ def _analyze_opportunities_stocks(opportunities: list):
})
def _analyze_opportunities_local_stocks(opportunities: list, market: str):
"""Scan CN/HK stocks for trading opportunities."""
m = str(market or "").strip()
if m not in ("CNStock", "HKStock"):
return
cache_key = "cn_stock_opportunity_prices" if m == "CNStock" else "hk_stock_opportunity_prices"
stock_data = _get_cached(cache_key)
if not stock_data:
stock_data = _fetch_local_stock_opportunity_prices(m)
if stock_data:
_set_cached(cache_key, stock_data, 3600)
if not stock_data:
logger.warning(f"_analyze_opportunities_local_stocks: No {m} data available")
return
logger.debug(f"_analyze_opportunities_local_stocks: Analyzing {len(stock_data)} {m} stocks")
strong_th = 7.0 if m == "CNStock" else 6.0
medium_th = 3.0 if m == "CNStock" else 2.5
market_cn = "A股" if m == "CNStock" else "港股"
for stock in stock_data:
change = _safe_float(stock.get("change", 0))
symbol = stock.get("symbol", "")
name = stock.get("name", "")
price = _safe_float(stock.get("price", 0))
signal = None
strength = "medium"
reason = ""
impact = "neutral"
if change > strong_th:
signal = "overbought"
strength = "strong"
reason = f"{market_cn}日涨幅{change:.1f}%,短期涨幅较大,注意回调风险"
impact = "bearish"
elif change > medium_th:
signal = "bullish_momentum"
strength = "medium"
reason = f"{market_cn}日涨幅{change:.1f}%,上涨动能较强"
impact = "bullish"
elif change < -strong_th:
signal = "oversold"
strength = "strong"
reason = f"{market_cn}日跌幅{abs(change):.1f}%,可能超卖反弹"
impact = "bullish"
elif change < -medium_th:
signal = "bearish_momentum"
strength = "medium"
reason = f"{market_cn}日跌幅{abs(change):.1f}%,下跌趋势明显"
impact = "bearish"
if signal:
opportunities.append({
"symbol": symbol,
"name": name,
"price": price,
"change_24h": change,
"signal": signal,
"strength": strength,
"reason": reason,
"impact": impact,
"market": m,
"timestamp": int(time.time())
})
def _analyze_opportunities_forex(opportunities: list):
"""Scan forex pairs for trading opportunities."""
forex_data = _get_cached("forex_pairs")
@@ -1915,7 +2055,7 @@ def _analyze_opportunities_polymarket(opportunities: list):
@login_required
def trading_opportunities():
"""
Scan for trading opportunities across Crypto, US Stocks, and Forex.
Scan for trading opportunities across Crypto, US Stocks, CN/HK Stocks, and Forex.
Note: Prediction Markets are excluded as they have their own dedicated page.
Cached for 1 hour. Pass ?force=true to skip cache.
"""
@@ -1945,7 +2085,23 @@ def trading_opportunities():
except Exception as e:
logger.error(f"Failed to analyze stock opportunities: {e}", exc_info=True)
# 3) Forex
# 3) CN Stocks
try:
_analyze_opportunities_local_stocks(opportunities, "CNStock")
cn_count = len([o for o in opportunities if o.get("market") == "CNStock"])
logger.info(f"Trading opportunities: found {cn_count} CN stock opportunities")
except Exception as e:
logger.error(f"Failed to analyze CN stock opportunities: {e}", exc_info=True)
# 4) HK Stocks
try:
_analyze_opportunities_local_stocks(opportunities, "HKStock")
hk_count = len([o for o in opportunities if o.get("market") == "HKStock"])
logger.info(f"Trading opportunities: found {hk_count} HK stock opportunities")
except Exception as e:
logger.error(f"Failed to analyze HK stock opportunities: {e}", exc_info=True)
# 5) Forex
try:
_analyze_opportunities_forex(opportunities)
forex_count = len([o for o in opportunities if o.get("market") == "Forex"])
@@ -1959,7 +2115,14 @@ def trading_opportunities():
# Sort by absolute change descending
opportunities.sort(key=lambda x: abs(x.get("change_24h", 0)), reverse=True)
logger.info(f"Trading opportunities: total {len(opportunities)} opportunities found (Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})")
logger.info(
f"Trading opportunities: total {len(opportunities)} opportunities found "
f"(Crypto: {len([o for o in opportunities if o.get('market') == 'Crypto'])}, "
f"USStock: {len([o for o in opportunities if o.get('market') == 'USStock'])}, "
f"CNStock: {len([o for o in opportunities if o.get('market') == 'CNStock'])}, "
f"HKStock: {len([o for o in opportunities if o.get('market') == 'HKStock'])}, "
f"Forex: {len([o for o in opportunities if o.get('market') == 'Forex'])})"
)
_set_cached("trading_opportunities", opportunities, 3600)
+2 -1
View File
@@ -83,7 +83,8 @@ def get_public_config():
@market_bp.route('/types', methods=['GET'])
def get_market_types():
"""Return supported market types for the add-watchlist modal."""
desired_order = ['USStock', 'Crypto', 'Forex', 'Futures']
# Keep a stable UX order; add CN/HK stocks near US stocks.
desired_order = ['USStock', 'CNStock', 'HKStock', 'Crypto', 'Forex', 'Futures']
order_rank = {v: i for i, v in enumerate(desired_order)}
def _normalize_item(x):
+351 -19
View File
@@ -29,11 +29,69 @@ from app.utils.credential_crypto import decrypt_credential_blob
logger = get_logger(__name__)
import re as _re
_FRIENDLY_ERROR_PATTERNS = [
# Insufficient balance / margin
(_re.compile(r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", _re.IGNORECASE),
"quickTrade.errorHints.insufficientBalance"),
# Invalid size / quantity
(_re.compile(r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", _re.IGNORECASE),
"quickTrade.errorHints.invalidSize"),
# Invalid price
(_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE),
"quickTrade.errorHints.invalidPrice"),
# Rate limit
(_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE),
"quickTrade.errorHints.rateLimit"),
# API key / permission
(_re.compile(r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", _re.IGNORECASE),
"quickTrade.errorHints.authError"),
# Position / reduce-only conflict
(_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE),
"quickTrade.errorHints.positionConflict"),
# Network / timeout
(_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE),
"quickTrade.errorHints.networkError"),
# Exchange maintenance
(_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE),
"quickTrade.errorHints.exchangeMaintenance"),
]
def _parse_trade_error_hint(error_str: str) -> str:
"""Return a i18n key hint for common exchange trading errors, or empty string."""
s = str(error_str or "")
for pattern, hint_key in _FRIENDLY_ERROR_PATTERNS:
if pattern.search(s):
return hint_key
return ""
quick_trade_bp = Blueprint('quick_trade', __name__)
# ────────── helpers ──────────
def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool:
"""Match UI symbol (e.g. ETH/USDT) with exchange-native ids (e.g. ETH_USDT, ETH-USDT-SWAP)."""
def norm(x: str) -> str:
return (x or "").strip().upper().replace("/", "").replace("-", "").replace("_", "")
a, b = norm(user_symbol), norm(position_symbol)
if not a or not b:
return False
if a == b:
return True
for suf in ("SWAP", "PERPETUAL", "PERP"):
if b.endswith(suf) and a == b[: -len(suf)]:
return True
if a.endswith(suf) and b == a[: -len(suf)]:
return True
# Substring fallback for less standard ids (min length avoids ETH vs ETHW false positives)
return (len(a) >= 6 and a in b) or (len(b) >= 6 and b in a)
def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0) -> float:
"""
Convert USDT amount to base asset quantity for all exchanges.
@@ -116,6 +174,56 @@ def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_ty
current_price = float(data.get("price") or 0)
except Exception:
pass
# Bybit v5 — same host as trading API; tickers/orderbook are public
from app.services.live_trading.bybit import BybitClient
if current_price <= 0 and isinstance(client, BybitClient):
try:
import requests
from app.services.live_trading.symbols import to_bybit_symbol
bu = (getattr(client, "base_url", "") or "").rstrip("/")
bsym = to_bybit_symbol(symbol).upper()
cat = "spot" if (market_type or "").strip().lower() == "spot" else "linear"
if bu and bsym:
tr = requests.get(
f"{bu}/v5/market/tickers",
params={"category": cat, "symbol": bsym},
timeout=8,
)
if tr.status_code == 200:
jd = tr.json() if tr.text else {}
lst = (((jd.get("result") or {}).get("list")) or []) if isinstance(jd, dict) else []
if lst and isinstance(lst[0], dict):
t0 = lst[0]
current_price = float(
str(
t0.get("lastPrice")
or t0.get("markPrice")
or t0.get("indexPrice")
or 0
).replace(",", "")
or 0
)
if current_price <= 0:
obr = requests.get(
f"{bu}/v5/market/orderbook",
params={"category": cat, "symbol": bsym, "limit": 25},
timeout=8,
)
if obr.status_code == 200:
od = obr.json() if obr.text else {}
res = (od.get("result") or {}) if isinstance(od, dict) else {}
bids = res.get("b") or []
asks = res.get("a") or []
bp = float(str(bids[0][0]).replace(",", "")) if bids and bids[0] else 0.0
ap = float(str(asks[0][0]).replace(",", "")) if asks and asks[0] else 0.0
if bp > 0 and ap > 0:
current_price = (bp + ap) / 2.0
else:
current_price = bp or ap
except Exception:
pass
# Other exchanges - can be added as needed
# For exchanges without price API, we'll use a fallback
@@ -350,7 +458,12 @@ def place_order():
elif isinstance(client, GateUsdtFuturesClient):
from app.services.live_trading.symbols import to_gate_currency_pair
contract = to_gate_currency_pair(symbol)
client.set_leverage(contract=contract, leverage=leverage)
if not client.set_leverage(contract=contract, leverage=leverage):
logger.warning(
"Gate set_leverage failed (contract=%s lev=%s); order may use exchange default leverage",
contract,
leverage,
)
# Most other exchanges use symbol
else:
# Try common parameter names
@@ -472,7 +585,12 @@ def place_order():
except Exception:
pass
return jsonify({"code": 0, "msg": str(e)}), 500
err_str = str(e)
hint = _parse_trade_error_hint(err_str)
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
if hint:
resp["error_hint"] = hint
return jsonify(resp), 500
def _market_order_kwargs(client, symbol, amount, side, market_type, client_order_id):
@@ -581,9 +699,34 @@ def get_balance():
def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, Any]:
"""Best-effort parse balance from various exchange responses."""
result = {"available": 0, "total": 0, "currency": "USDT"}
ex0 = (exchange_id or "").strip().lower()
mt0 = (market_type or "").strip().lower()
def _num(x: Any) -> float:
try:
s = str(x).replace(",", "").strip()
if not s:
return 0.0
return float(s)
except Exception:
return 0.0
if not raw:
return result
try:
# Gate.io spot: GET /api/v4/spot/accounts returns a list
if isinstance(raw, list) and ex0 == "gate":
for item in raw:
if not isinstance(item, dict):
continue
if str(item.get("currency") or "").upper() == "USDT":
av = _num(item.get("available") or item.get("available_balance"))
lk = _num(item.get("locked") or item.get("freeze") or item.get("locked_amount"))
result["available"] = av
result["total"] = av + lk
return result
return result
if isinstance(raw, dict):
# Binance futures
if "availableBalance" in raw:
@@ -599,6 +742,21 @@ def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, An
return result
return result
ex = (exchange_id or "").lower()
# Gate.io USDT perpetual: GET /api/v4/futures/usdt/accounts — flat object (values often strings)
if ex == "gate" and mt0 != "spot":
if any(k in raw for k in ("available", "total", "cross_available", "cross_margin_balance")):
av = raw.get("available") or raw.get("available_balance") or raw.get("cross_available")
tot = (
raw.get("total")
or raw.get("total_balance")
or raw.get("cross_margin_balance")
or raw.get("equity")
)
result["available"] = _num(av)
result["total"] = _num(tot) if tot is not None and str(tot).strip() != "" else result["available"]
if result["total"] <= 0 < result["available"]:
result["total"] = result["available"]
return result
# Bitget mix: { code, data: [ { marginCoin, available, accountEquity, ... } ] }
# Must run before OKX — both use data as a list; OKX fallback would zero Bitget.
if ex == "bitget" and (market_type or "").lower() != "spot":
@@ -709,7 +867,7 @@ def _fetch_exchange_positions_raw(
"""
Fetch raw position payload for quick-trade / close-position.
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bybit, Bitfinex),
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bitfinex),
or need extra args (Bitget ``product_type``, OKX ``inst_type``). Centralize here.
"""
from app.services.live_trading.binance import BinanceFuturesClient
@@ -747,7 +905,8 @@ def _fetch_exchange_positions_raw(
return client.get_positions(product_type=pt, symbol=symbol)
if isinstance(client, BybitClient):
raw = client.get_positions()
# Bybit v5 requires symbol or settleCoin; query the contract directly.
raw = client.get_positions(symbol=symbol)
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
if not isinstance(lst, list):
return raw
@@ -765,8 +924,25 @@ def _fetch_exchange_positions_raw(
raw = client.get_positions()
items = raw if isinstance(raw, list) else []
c = to_gate_currency_pair(symbol)
logger.info("Gate positions: total=%d, target=%s, contracts=%s",
len(items), c,
[(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10])
filtered = [p for p in items if isinstance(p, dict) and str(p.get("contract") or "").strip() == c]
return filtered
out = []
for p in filtered:
q = dict(p)
try:
ct_sz = float(q.get("size") or 0)
except Exception:
ct_sz = 0.0
if abs(ct_sz) > 1e-12:
base_amt = client.contracts_signed_to_base_qty(contract=c, contracts_signed=ct_sz)
if base_amt > 0:
q["positionAmt"] = base_amt
out.append(q)
logger.info("Gate filtered positions for %s: %d items, sizes=%s", c, len(out),
[(p.get("size"), p.get("positionAmt")) for p in out])
return out
if isinstance(client, KucoinFuturesClient):
raw = client.get_positions()
@@ -782,7 +958,37 @@ def _fetch_exchange_positions_raw(
return {"data": filtered}
if isinstance(client, HtxClient):
return client.get_positions(symbol=symbol)
raw = client.get_positions(symbol=symbol)
data = (raw.get("data") if isinstance(raw, dict) else None) or []
if not isinstance(data, list):
data = []
out_items = []
for p in data:
if not isinstance(p, dict):
continue
q = dict(p)
cc = str(q.get("contract_code") or "").strip()
if cc:
parts = cc.split("-", 1)
if len(parts) == 2:
q["symbol"] = f"{parts[0]}/{parts[1]}"
try:
vol = float(q.get("volume") or q.get("available") or 0)
except Exception:
vol = 0.0
if abs(vol) > 1e-12 and cc:
try:
info = client.get_contract_info(symbol=symbol or cc) or {}
cs = float(info.get("contract_size") or 1)
if cs <= 0:
cs = 1.0
q["positionAmt"] = abs(vol) * cs
except Exception:
pass
out_items.append(q)
logger.info("HTX positions for %s: %d items, sizes=%s", symbol, len(out_items),
[(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items])
return {"data": out_items}
if isinstance(client, DeepcoinClient):
return client.get_positions(symbol=symbol)
@@ -860,6 +1066,21 @@ def _parse_positions(raw: Any) -> list:
for item in items:
if not isinstance(item, dict):
continue
sym_raw = str(
item.get("symbol")
or item.get("instId")
or item.get("contract")
or item.get("contract_code")
or ""
).strip()
display_symbol = sym_raw
if sym_raw and "/" not in sym_raw:
for sep in ("_", "-"):
if sep in sym_raw:
parts = sym_raw.split(sep, 1)
if len(parts) == 2 and parts[0] and parts[1]:
display_symbol = f"{parts[0]}/{parts[1]}"
break
# For OKX, position size can be in different fields
# SWAP: posAmt, pos
# Binance futures: positionAmt
@@ -876,6 +1097,7 @@ def _parse_positions(raw: Any) -> list:
or item.get("bal")
or item.get("availBal")
or item.get("volume")
or item.get("current_qty")
or 0
)
if abs(size) < 1e-10:
@@ -910,11 +1132,12 @@ def _parse_positions(raw: Any) -> list:
side = "short"
result.append({
"symbol": item.get("symbol") or item.get("instId") or "",
"symbol": display_symbol,
"side": side,
"size": abs(size),
"entry_price": float(
item.get("entryPrice")
or item.get("entry_price")
or item.get("openPriceAvg")
or item.get("avgEntryPrice")
or item.get("avgPrice")
@@ -928,15 +1151,17 @@ def _parse_positions(raw: Any) -> list:
item.get("unRealizedProfit")
or item.get("unrealizedProfit")
or item.get("unrealizedPnl")
or item.get("unrealised_pnl")
or item.get("upl")
or item.get("unrealisedPnl")
or item.get("profit_unreal")
or item.get("pnl")
or 0
),
"leverage": float(item.get("leverage") or item.get("lever") or 1),
"leverage": float(item.get("leverage") or item.get("lever") or item.get("lever_rate") or item.get("cross_leverage_limit") or 1),
"mark_price": float(
item.get("markPrice")
or item.get("mark_price")
or item.get("markPx")
or item.get("last_price")
or item.get("last")
@@ -949,6 +1174,48 @@ def _parse_positions(raw: Any) -> list:
return result
def _quick_trade_net_base_qty(
user_id: int,
credential_id: int,
symbol: str,
market_type: str,
position_side: str,
) -> float:
"""
Best-effort net base-asset qty from qd_quick_trades (filled buy sell for long, vice versa for short).
Used when user chooses to close only the portion accumulated via Quick Trade, not manual exchange orders.
Imperfect if the user also traded the same symbol elsewhere or records are incomplete.
"""
mt = (market_type or "swap").strip().lower()
ps = (position_side or "").strip().lower()
sym = str(symbol or "").strip()
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"""
SELECT
COALESCE(SUM(CASE WHEN side = 'buy' THEN filled_amount ELSE 0 END), 0) AS b,
COALESCE(SUM(CASE WHEN side = 'sell' THEN filled_amount ELSE 0 END), 0) AS s
FROM qd_quick_trades
WHERE user_id = %s AND credential_id = %s AND symbol = %s AND market_type = %s
AND status = 'filled' AND COALESCE(filled_amount, 0) > 0
""",
(int(user_id), int(credential_id), sym, mt),
)
row = cur.fetchone() or {}
cur.close()
buy_sum = float(row.get("b") or 0)
sell_sum = float(row.get("s") or 0)
if ps == "long":
net = buy_sum - sell_sum
elif ps == "short":
net = sell_sum - buy_sum
else:
net = 0.0
return max(0.0, float(net))
@quick_trade_bp.route('/close-position', methods=['POST'])
@login_required
def close_position():
@@ -960,6 +1227,8 @@ def close_position():
symbol (str) e.g. "BTC/USDT"
market_type (str) "swap" / "spot" (default: swap)
size (float) position size to close (optional, defaults to full position)
close_scope (str) "full" (default) or "system_tracked" (swap only: min(position, net from qd_quick_trades))
position_side (str) optional "long" / "short"; required when both directions exist for the same symbol
source (str) "ai_radar" / "ai_analysis" / "indicator" / "manual"
"""
try:
@@ -971,6 +1240,11 @@ def close_position():
market_type = str(body.get("market_type") or "swap").strip().lower()
close_size = float(body.get("size") or 0) # 0 means close full position
source = str(body.get("source") or "manual").strip()
close_scope_raw = str(body.get("close_scope") or body.get("closeScope") or "full").strip().lower()
if close_scope_raw in ("system", "system_tracked", "quick_trade", "app"):
close_scope = "system_tracked"
else:
close_scope = "full"
# ---- validation ----
if not credential_id:
@@ -1003,29 +1277,80 @@ def close_position():
if not positions:
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
# Find matching position for this symbol
position = None
want_side = str(body.get("position_side") or body.get("close_side") or "").strip().lower()
if want_side not in ("", "long", "short"):
want_side = ""
matches: list = []
for pos in positions:
pos_symbol = pos.get("symbol", "").strip()
# Match by symbol (may need normalization)
if symbol.upper().replace("/", "") in pos_symbol.upper().replace("/", "").replace("-", ""):
position = pos
break
if not _symbols_match_quick_trade(symbol, pos_symbol):
continue
ps = str(pos.get("side") or "").strip().lower()
if want_side in ("long", "short"):
if ps == want_side:
matches.append(pos)
else:
matches.append(pos)
position = None
if len(matches) == 1:
position = matches[0]
elif len(matches) > 1:
if want_side in ("long", "short"):
position = matches[0]
else:
return jsonify(
{
"code": 0,
"msg": "该交易对同时存在多仓与空仓,请在请求中指定 position_side 为 long 或 short。",
}
), 400
if not position:
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
position_side = str(position.get("side") or "").strip().lower()
position_size = float(position.get("size") or 0)
if position_size <= 0:
return jsonify({"code": 0, "msg": "Position size is zero or invalid"}), 400
if close_scope == "system_tracked" and market_type != "swap":
return jsonify({"code": 0, "msg": "system_tracked close_scope is only supported for swap/perp"}), 400
tracked_net = 0.0
if close_scope == "system_tracked":
tracked_net = _quick_trade_net_base_qty(
user_id, credential_id, symbol, market_type, position_side=position_side
)
if tracked_net <= 0:
return jsonify(
{
"code": 0,
"msg": "No filled Quick Trade volume found for this symbol; use full close or check history.",
}
), 400
# Determine close size
actual_close_size = close_size if close_size > 0 else position_size
if close_size > 0:
actual_close_size = min(close_size, position_size)
elif close_scope == "system_tracked":
actual_close_size = min(tracked_net, position_size)
logger.info(
"close_position system_tracked: symbol=%s side=%s position=%s tracked_net=%s close=%s",
symbol,
position.get("side"),
position_size,
tracked_net,
actual_close_size,
)
else:
actual_close_size = position_size
if actual_close_size > position_size:
actual_close_size = position_size
if actual_close_size <= 0:
return jsonify({"code": 0, "msg": "Close size is zero"}), 400
# ---- determine signal type based on position side ----
if market_type == "spot":
@@ -1111,6 +1436,8 @@ def close_position():
"avg_price": avg_fill,
"closed_size": actual_close_size,
"position_side": position_side,
"close_scope": close_scope,
"tracked_net_base": tracked_net if close_scope == "system_tracked" else None,
"status": "filled" if filled > 0 else "submitted",
},
})
@@ -1118,7 +1445,12 @@ def close_position():
except Exception as e:
logger.error(f"close_position failed: {e}")
logger.error(traceback.format_exc())
return jsonify({"code": 0, "msg": str(e)}), 500
err_str = str(e)
hint = _parse_trade_error_hint(err_str)
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
if hint:
resp["error_hint"] = hint
return jsonify(resp), 500
@quick_trade_bp.route('/history', methods=['GET'])
@@ -399,6 +399,15 @@ CONFIG_SCHEMA = {
'link_text': 'settings.link.getToken',
'description': 'Tiingo API key for Forex/Metals data'
},
{
'key': 'TWELVE_DATA_API_KEY',
'label': 'Twelve Data API Key',
'type': 'password',
'required': False,
'link': 'https://twelvedata.com/apikey',
'link_text': 'settings.link.getApiKey',
'description': 'Twelve Data API key for CN/HK stock K-lines (free 800 credits/day)'
},
]
},
+44 -3
View File
@@ -15,6 +15,11 @@ from app.services.strategy_snapshot import StrategySnapshotResolver
from app import get_trading_executor
from app.utils.logger import get_logger
from app.utils.db import get_db_connection
try:
from psycopg2.errors import UndefinedTable as PgUndefinedTable
except Exception: # pragma: no cover
PgUndefinedTable = None # type: ignore
from app.utils.auth import login_required
from app.data_sources import DataSourceFactory
@@ -662,6 +667,15 @@ def get_equity_curve():
(strategy_id,)
)
rows = cur.fetchall() or []
cur.execute(
"""
SELECT COALESCE(SUM(unrealized_pnl), 0) AS u
FROM qd_strategy_positions
WHERE strategy_id = ?
""",
(strategy_id,),
)
prow = cur.fetchone() or {}
cur.close()
equity = initial
@@ -678,7 +692,17 @@ def get_equity_curve():
ts = int(created_at)
else:
ts = int(time.time())
curve.append({'time': ts, 'equity': equity})
curve.append({'time': ts, 'equity': round(equity, 2)})
# 将未实现盈亏并入曲线末端,便于「持仓中」也能在绩效里看到浮动权益
try:
unreal = float(prow.get('u') or prow.get('U') or 0)
except Exception:
unreal = 0.0
live_equity = float(equity) + unreal
now_ts = int(time.time())
if abs(unreal) > 1e-12 or not curve:
curve.append({'time': now_ts, 'equity': round(live_equity, 2)})
return jsonify({'code': 1, 'msg': 'success', 'data': curve})
except Exception as e:
@@ -1391,11 +1415,16 @@ def get_strategy_performance():
def get_strategy_logs():
"""Get strategy running logs."""
try:
user_id = g.user_id
strategy_id = request.args.get('id')
limit = int(request.args.get('limit', 200))
if not strategy_id:
return jsonify({'code': 0, 'msg': 'Strategy ID required'})
st = get_strategy_service().get_strategy(int(strategy_id), user_id=user_id)
if not st:
return jsonify({'code': 0, 'msg': 'Strategy not found'}), 404
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
@@ -1411,10 +1440,22 @@ def get_strategy_logs():
rows = cur.fetchall() or []
cur.close()
logs = list(reversed(rows))
out = []
for r in rows or []:
if not isinstance(r, dict):
continue
rr = dict(r)
ts = rr.get('timestamp')
if ts is not None and hasattr(ts, 'isoformat'):
rr['timestamp'] = ts.isoformat()
out.append(rr)
logs = list(reversed(out))
return jsonify({'code': 1, 'msg': 'success', 'data': logs})
except Exception as e:
if 'qd_strategy_logs' in str(e) and ('does not exist' in str(e) or 'no such table' in str(e)):
if PgUndefinedTable is not None and isinstance(e, PgUndefinedTable):
return jsonify({'code': 1, 'msg': 'success', 'data': []})
el = str(e).lower()
if 'qd_strategy_logs' in el and ('does not exist' in el or 'no such table' in el):
return jsonify({'code': 1, 'msg': 'success', 'data': []})
logger.error(f"get_strategy_logs failed: {str(e)}")
return jsonify({'code': 0, 'msg': str(e)}), 500