Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-02-27 22:28:01 +08:00
parent a90f336b93
commit d92d4c53dc
91 changed files with 1773 additions and 39 deletions
+2 -1
View File
@@ -42,7 +42,8 @@ __pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
# Only ignore root-level dist/, not frontend/dist/
/dist/
build/
*.egg
.eggs/
+405 -27
View File
@@ -5,10 +5,11 @@ Allows users to place market or limit orders directly from AI analysis
or indicator analysis pages, without creating a strategy first.
Endpoints:
POST /api/quick-trade/place-order — Place a quick order
GET /api/quick-trade/balance — Get available balance
GET /api/quick-trade/position — Get current position for symbol
GET /api/quick-trade/history — Get quick trade history
POST /api/quick-trade/place-order — Place a quick order
POST /api/quick-trade/close-position — Close an existing position
GET /api/quick-trade/balance — Get available balance
GET /api/quick-trade/position — Get current position for symbol
GET /api/quick-trade/history — Get quick trade history
"""
from __future__ import annotations
@@ -32,6 +33,101 @@ quick_trade_bp = Blueprint('quick_trade', __name__)
# ────────── helpers ──────────
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.
This is a unified function that works for all exchanges.
For spot: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH)
For swap: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH), which will then be converted to contracts
Args:
client: Exchange client instance
symbol: Trading pair (e.g., "ETH/USDT")
usdt_amount: USDT amount to convert
market_type: "spot" or "swap"
limit_price: For limit orders, use this price if provided (optional)
Returns:
Base asset quantity
"""
if usdt_amount <= 0:
return usdt_amount
try:
# Try to get current price from exchange
current_price = 0.0
# For limit orders, use the provided price
if limit_price > 0:
current_price = limit_price
logger.info(f"Using limit price {limit_price} for USDT conversion")
else:
# Try to get current market price from exchange
# OKX
from app.services.live_trading.okx import OkxClient
if isinstance(client, OkxClient):
try:
from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id
inst_id = to_okx_spot_inst_id(symbol) if market_type == "spot" else to_okx_swap_inst_id(symbol)
logger.debug(f"OKX: Getting ticker for inst_id={inst_id}, symbol={symbol}, market_type={market_type}")
ticker = client.get_ticker(inst_id=inst_id)
if ticker:
current_price = float(ticker.get("last") or ticker.get("lastPx") or 0)
logger.debug(f"OKX: Got price {current_price} from ticker")
else:
logger.warning(f"OKX: get_ticker returned empty result for inst_id={inst_id}")
except AttributeError as e:
logger.error(f"OKX: get_ticker method not found: {e}")
raise
except Exception as e:
logger.error(f"OKX: Failed to get ticker: {e}")
raise
# Binance - try to get price from public API
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
try:
# Binance public ticker endpoint
base_url = getattr(client, "base_url", "")
if "binance" in base_url.lower():
import requests
if isinstance(client, BinanceFuturesClient):
ticker_url = f"{base_url}/fapi/v1/ticker/price"
else:
ticker_url = f"{base_url}/api/v3/ticker/price"
from app.services.live_trading.symbols import to_binance_futures_symbol
# Binance spot and futures use the same symbol format
sym = to_binance_futures_symbol(symbol)
resp = requests.get(ticker_url, params={"symbol": sym}, timeout=5)
if resp.status_code == 200:
data = resp.json()
if isinstance(data, dict):
current_price = float(data.get("price") or 0)
except Exception:
pass
# Other exchanges - can be added as needed
# For exchanges without price API, we'll use a fallback
if current_price > 0:
base_qty = usdt_amount / current_price
logger.info(f"Converted USDT amount {usdt_amount} to base qty {base_qty:.8f} using price {current_price} for {symbol}")
return base_qty
else:
# Can't get price - this is critical for quick trade
# Quick trade always expects USDT input, so we must convert
logger.error(f"CRITICAL: Could not get price for {symbol} on {type(client).__name__} to convert USDT amount {usdt_amount}")
logger.error(f"This will cause order to fail. Please check exchange API connectivity or symbol format.")
# Still return original amount as fallback, but log error
return usdt_amount
except Exception as e:
logger.warning(f"Failed to convert USDT amount to base qty: {e}, using original amount")
return usdt_amount
def _safe_json(v, default=None):
if v is None:
return default
@@ -138,10 +234,12 @@ def place_order():
symbol (str) — e.g. "BTC/USDT"
side (str) — "buy" or "sell"
order_type (str) — "market" or "limit" (default: market)
amount (float) — order size (USDT quote amount for market buy, or base qty)
amount (float) — USDT amount (always in USDT, will be converted to base qty)
price (float) — limit price (required for limit orders)
leverage (int) — leverage multiplier (default: 1)
market_type (str) — "swap" / "spot" (default: swap)
- leverage = 1: spot market
- leverage > 1: swap (perpetual futures) market
market_type (str) — "swap" / "spot" (optional, auto-determined by leverage if not provided)
tp_price (float) — take-profit price (optional, for record only)
sl_price (float) — stop-loss price (optional, for record only)
source (str) — "ai_radar" / "ai_analysis" / "indicator" / "manual"
@@ -154,10 +252,10 @@ def place_order():
symbol = str(body.get("symbol") or "").strip()
side = str(body.get("side") or "").strip().lower()
order_type = str(body.get("order_type") or "market").strip().lower()
amount = float(body.get("amount") or 0)
usdt_amount = float(body.get("amount") or 0) # Always USDT amount
price = float(body.get("price") or 0)
leverage = int(body.get("leverage") or 1)
market_type = str(body.get("market_type") or "swap").strip().lower()
market_type = str(body.get("market_type") or "").strip().lower()
tp_price = float(body.get("tp_price") or 0)
sl_price = float(body.get("sl_price") or 0)
source = str(body.get("source") or "manual").strip()
@@ -169,13 +267,22 @@ def place_order():
return jsonify({"code": 0, "msg": "Missing symbol"}), 400
if side not in ("buy", "sell"):
return jsonify({"code": 0, "msg": "side must be 'buy' or 'sell'"}), 400
if amount <= 0:
if usdt_amount <= 0:
return jsonify({"code": 0, "msg": "amount must be > 0"}), 400
if order_type == "limit" and price <= 0:
return jsonify({"code": 0, "msg": "price required for limit orders"}), 400
# ---- Auto-determine market_type from leverage ----
# leverage = 1 -> spot, leverage > 1 -> swap
if not market_type:
market_type = "spot" if leverage == 1 else "swap"
if market_type in ("futures", "future", "perp", "perpetual"):
market_type = "swap"
# Override: if leverage > 1, force swap; if leverage = 1, force spot
if leverage > 1:
market_type = "swap"
elif leverage == 1:
market_type = "spot"
# ---- build exchange client ----
exchange_config = _build_exchange_config(credential_id, user_id, {
@@ -187,31 +294,82 @@ def place_order():
client = _create_client(exchange_config, market_type=market_type)
# ---- Convert USDT amount to base asset quantity ----
# Quick trade always accepts USDT amount, convert to base qty for all exchanges
# For limit orders, use the provided price; for market orders, fetch current price
limit_price_for_conversion = price if order_type == "limit" and price > 0 else 0.0
base_qty = _convert_usdt_to_base_qty(client, symbol, usdt_amount, market_type, limit_price_for_conversion)
# Validate conversion: if base_qty equals usdt_amount, conversion likely failed
# For swap markets, base_qty should be much smaller than usdt_amount (e.g., 100 USDT -> 0.033 ETH)
if market_type != "spot" and base_qty == usdt_amount and usdt_amount >= 1:
logger.error(f"USDT conversion may have failed: base_qty ({base_qty}) equals usdt_amount ({usdt_amount})")
logger.error(f"This suggests the price fetch failed. Order may fail due to insufficient margin.")
# ---- set leverage (futures only) ----
if market_type != "spot" and leverage > 1:
try:
if hasattr(client, "set_leverage"):
client.set_leverage(symbol=symbol, leverage=leverage)
elif hasattr(client, "set_leverage") and callable(getattr(client, "set_leverage", None)):
client.set_leverage(symbol=symbol, lever=leverage)
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.gate import GateUsdtFuturesClient
# OKX requires inst_id instead of symbol
if isinstance(client, OkxClient):
from app.services.live_trading.symbols import to_okx_swap_inst_id
inst_id = to_okx_swap_inst_id(symbol)
client.set_leverage(inst_id=inst_id, lever=leverage)
# Gate requires contract (currency_pair) instead of symbol
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)
# Most other exchanges use symbol
else:
# Try common parameter names
try:
client.set_leverage(symbol=symbol, leverage=leverage)
except TypeError:
try:
client.set_leverage(symbol=symbol, lever=leverage)
except TypeError:
pass
except Exception as le:
logger.warning(f"set_leverage failed (non-fatal): {le}")
# ---- place order ----
client_order_id = f"qt_{int(time.time())}_{uuid.uuid4().hex[:8]}"
# Generate client_order_id: OKX clOrdId requirements: 1-32 chars, alphanumeric, underscore, hyphen only
timestamp_suffix = str(int(time.time()))[-6:] # Last 6 digits of timestamp
uuid_suffix = uuid.uuid4().hex[:8] # 8 hex chars
client_order_id = f"qt{timestamp_suffix}{uuid_suffix}" # Total: 2 + 6 + 8 = 16 chars
result = None
if order_type == "market":
result = client.place_market_order(
# Use execution.py's place_order_from_signal for market orders to ensure consistency
# Convert side to signal_type: buy -> open_long, sell -> open_short (for swap) or close_long (for spot)
from app.services.live_trading.execution import place_order_from_signal
if market_type == "spot":
# Spot: buy = open_long, sell = close_long (assuming we're closing a position)
signal_type = "open_long" if side == "buy" else "close_long"
else:
# Swap: buy = open_long, sell = open_short
signal_type = "open_long" if side == "buy" else "open_short"
result = place_order_from_signal(
client=client,
signal_type=signal_type,
symbol=symbol,
side=side.upper() if "binance" in exchange_id else side,
**_market_order_kwargs(client, symbol, amount, side, market_type, client_order_id),
amount=base_qty, # Use converted base qty
market_type=market_type,
exchange_config=exchange_config,
client_order_id=client_order_id,
)
else:
# Limit orders: use direct client call (execution.py doesn't handle limit orders)
result = client.place_limit_order(
symbol=symbol,
side=side.upper() if "binance" in exchange_id else side,
**_limit_order_kwargs(client, symbol, amount, price, side, market_type, client_order_id),
**_limit_order_kwargs(client, symbol, base_qty, price, side, market_type, client_order_id),
)
# ---- extract result ----
@@ -221,6 +379,7 @@ def place_order():
raw = getattr(result, "raw", {}) or {}
# ---- record trade ----
# Record original USDT amount, not converted base qty
trade_id = _record_quick_trade(
user_id=user_id,
credential_id=credential_id,
@@ -228,7 +387,7 @@ def place_order():
symbol=symbol,
side=side,
order_type=order_type,
amount=amount,
amount=usdt_amount, # Record original USDT amount
price=price if order_type == "limit" else avg_fill,
leverage=leverage,
market_type=market_type,
@@ -268,7 +427,7 @@ def place_order():
symbol=str(body.get("symbol") or ""),
side=str(body.get("side") or ""),
order_type=str(body.get("order_type") or "market"),
amount=float(body.get("amount") or 0),
amount=float(body.get("amount") or 0), # Original USDT amount
price=0,
leverage=int(body.get("leverage") or 1),
market_type=str(body.get("market_type") or "swap"),
@@ -299,7 +458,14 @@ def _market_order_kwargs(client, symbol, amount, side, market_type, client_order
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
return {"quantity": amount, "client_order_id": client_order_id}
if isinstance(client, OkxClient):
return {"size": amount, "client_order_id": client_order_id}
kwargs = {"market_type": market_type, "size": amount, "client_order_id": client_order_id}
# For swap market, OKX requires pos_side. Infer from side:
# buy -> long, sell -> short
# The _resolve_pos_side method will handle net_mode vs long_short_mode
if market_type and market_type.strip().lower() != "spot":
pos_side = "long" if side.lower() == "buy" else "short"
kwargs["pos_side"] = pos_side
return kwargs
if isinstance(client, BitgetMixClient):
return {"size": amount, "client_order_id": client_order_id}
if isinstance(client, BybitClient):
@@ -312,9 +478,19 @@ def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client
"""Build kwargs compatible with any exchange client's place_limit_order."""
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
from app.services.live_trading.okx import OkxClient
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
return {"quantity": amount, "price": price, "client_order_id": client_order_id}
if isinstance(client, OkxClient):
kwargs = {"market_type": market_type, "size": amount, "price": price, "client_order_id": client_order_id}
# For swap market, OKX requires pos_side. Infer from side:
# buy -> long, sell -> short
# The _resolve_pos_side method will handle net_mode vs long_short_mode
if market_type and market_type.strip().lower() != "spot":
pos_side = "long" if side.lower() == "buy" else "short"
kwargs["pos_side"] = pos_side
return kwargs
# Generic fallback
return {"size": amount, "price": price, "client_order_id": client_order_id}
@@ -444,7 +620,20 @@ def get_position():
positions = []
try:
if hasattr(client, "get_positions"):
# OKX requires inst_id instead of symbol
from app.services.live_trading.okx import OkxClient
if isinstance(client, OkxClient):
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
if market_type == "spot":
inst_id = to_okx_spot_inst_id(symbol)
inst_type = "SPOT"
else:
inst_id = to_okx_swap_inst_id(symbol)
inst_type = "SWAP"
raw = client.get_positions(inst_id=inst_id, inst_type=inst_type)
positions = _parse_positions(raw)
logger.info(f"OKX positions query: inst_id={inst_id}, inst_type={inst_type}, found {len(positions)} positions")
elif hasattr(client, "get_positions"):
raw = client.get_positions(symbol=symbol)
positions = _parse_positions(raw)
elif hasattr(client, "get_position"):
@@ -452,7 +641,9 @@ def get_position():
positions = _parse_positions(raw)
except Exception as pe:
logger.warning(f"Position fetch failed: {pe}")
logger.warning(traceback.format_exc())
logger.info(f"Returning {len(positions)} positions for symbol={symbol}, market_type={market_type}")
return jsonify({"code": 1, "msg": "success", "data": {"positions": positions}})
except Exception as e:
logger.error(f"get_position failed: {e}")
@@ -478,23 +669,210 @@ def _parse_positions(raw: Any) -> list:
for item in items:
if not isinstance(item, dict):
continue
size = float(item.get("posAmt") or item.get("pos") or item.get("size") or item.get("contracts") or 0)
# For OKX, position size can be in different fields
# SWAP: posAmt, pos
# SPOT: bal (balance), availBal (available balance)
size = float(item.get("posAmt") or item.get("pos") or item.get("size") or item.get("contracts") or
item.get("bal") or item.get("availBal") or 0)
if abs(size) < 1e-10:
continue
# For spot, side is always "long" (you own the asset)
# For swap, determine side from sign of size
side = "long"
if size < 0:
side = "short"
elif item.get("posSide"):
# OKX may have posSide field: "long" or "short"
pos_side = str(item.get("posSide", "")).strip().lower()
if pos_side in ("long", "short"):
side = pos_side
result.append({
"symbol": item.get("symbol") or item.get("instId") or "",
"side": "long" if size > 0 else "short",
"side": side,
"size": abs(size),
"entry_price": float(item.get("entryPrice") or item.get("avgCost") or item.get("avgPx") or 0),
"unrealized_pnl": float(item.get("unRealizedProfit") or item.get("upl") or item.get("unrealisedPnl") or 0),
"leverage": float(item.get("leverage") or 1),
"mark_price": float(item.get("markPrice") or item.get("markPx") or 0),
"entry_price": float(item.get("entryPrice") or item.get("avgCost") or item.get("avgPx") or item.get("avgPx") or 0),
"unrealized_pnl": float(item.get("unRealizedProfit") or item.get("upl") or item.get("unrealisedPnl") or item.get("pnl") or 0),
"leverage": float(item.get("leverage") or item.get("lever") or 1),
"mark_price": float(item.get("markPrice") or item.get("markPx") or item.get("last") or 0),
})
except Exception as e:
logger.warning(f"_parse_positions error: {e}")
return result
@quick_trade_bp.route('/close-position', methods=['POST'])
@login_required
def close_position():
"""
Close an existing position.
Body JSON:
credential_id (int) — saved exchange credential ID
symbol (str) — e.g. "BTC/USDT"
market_type (str) — "swap" / "spot" (default: swap)
size (float) — position size to close (optional, defaults to full position)
source (str) — "ai_radar" / "ai_analysis" / "indicator" / "manual"
"""
try:
user_id = g.user_id
body = request.get_json(force=True, silent=True) or {}
credential_id = int(body.get("credential_id") or 0)
symbol = str(body.get("symbol") or "").strip()
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()
# ---- validation ----
if not credential_id:
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
if not symbol:
return jsonify({"code": 0, "msg": "Missing symbol"}), 400
if market_type in ("futures", "future", "perp", "perpetual"):
market_type = "swap"
# ---- build exchange client ----
exchange_config = _build_exchange_config(credential_id, user_id, {
"market_type": market_type,
})
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
if not exchange_id:
return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400
client = _create_client(exchange_config, market_type=market_type)
# ---- get current position ----
positions = []
try:
from app.services.live_trading.okx import OkxClient
if isinstance(client, OkxClient):
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
if market_type == "spot":
inst_id = to_okx_spot_inst_id(symbol)
else:
inst_id = to_okx_swap_inst_id(symbol)
raw = client.get_positions(inst_id=inst_id)
positions = _parse_positions(raw)
elif hasattr(client, "get_positions"):
raw = client.get_positions(symbol=symbol)
positions = _parse_positions(raw)
elif hasattr(client, "get_position"):
raw = client.get_position(symbol=symbol)
positions = _parse_positions(raw)
except Exception as pe:
logger.warning(f"Position fetch failed: {pe}")
if not positions:
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
# Find matching position for this symbol
position = None
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 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
# Determine close size
actual_close_size = close_size if close_size > 0 else position_size
if actual_close_size > position_size:
actual_close_size = position_size
# ---- determine signal type based on position side ----
if market_type == "spot":
# Spot only supports long positions
if position_side != "long":
return jsonify({"code": 0, "msg": "Spot market only supports closing long positions"}), 400
signal_type = "close_long"
else:
# Swap: close_long or close_short
if position_side == "long":
signal_type = "close_long"
elif position_side == "short":
signal_type = "close_short"
else:
return jsonify({"code": 0, "msg": f"Unknown position side: {position_side}"}), 400
# ---- place close order ----
from app.services.live_trading.execution import place_order_from_signal
# Generate client_order_id
timestamp_suffix = str(int(time.time()))[-6:]
uuid_suffix = uuid.uuid4().hex[:8]
client_order_id = f"qtc{timestamp_suffix}{uuid_suffix}" # 'c' for close
result = place_order_from_signal(
client=client,
signal_type=signal_type,
symbol=symbol,
amount=actual_close_size, # Use position size directly (already in base qty)
market_type=market_type,
exchange_config=exchange_config,
client_order_id=client_order_id,
)
# ---- extract result ----
exchange_order_id = str(getattr(result, "exchange_order_id", "") or "")
filled = float(getattr(result, "filled", 0) or 0)
avg_fill = float(getattr(result, "avg_price", 0) or 0)
raw = getattr(result, "raw", {}) or {}
# ---- record trade ----
trade_id = _record_quick_trade(
user_id=user_id,
credential_id=credential_id,
exchange_id=exchange_id,
symbol=symbol,
side="sell" if position_side == "long" else "buy", # Opposite of position side
order_type="market",
amount=actual_close_size, # Record position size
price=avg_fill,
leverage=float(position.get("leverage") or 1),
market_type=market_type,
tp_price=0,
sl_price=0,
status="filled" if filled > 0 else "submitted",
exchange_order_id=exchange_order_id,
filled=filled,
avg_price=avg_fill,
error_msg="",
source=source,
raw_result=raw,
)
return jsonify({
"code": 1,
"msg": "Position closed successfully",
"data": {
"trade_id": trade_id,
"exchange_order_id": exchange_order_id,
"filled": filled,
"avg_price": avg_fill,
"closed_size": actual_close_size,
"position_side": position_side,
"status": "filled" if filled > 0 else "submitted",
},
})
except Exception as e:
logger.error(f"close_position failed: {e}")
logger.error(traceback.format_exc())
return jsonify({"code": 0, "msg": str(e)}), 500
@quick_trade_bp.route('/history', methods=['GET'])
@login_required
def get_history():
@@ -2,7 +2,7 @@
Translate a strategy signal into a direct-exchange order call.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
@@ -26,6 +26,9 @@ from app.services.live_trading.kucoin import KucoinFuturesClient
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
# Lazy import Deepcoin
DeepcoinClient = None
# Lazy import IBKR
IBKRClient = None
@@ -94,6 +97,7 @@ def place_order_from_signal(
side=side,
pos_side=pos_side,
size=qty,
market_type=mt,
td_mode=str(td_mode),
reduce_only=reduce_only,
client_order_id=client_order_id,
@@ -155,6 +159,25 @@ def place_order_from_signal(
if isinstance(client, KrakenFuturesClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
# Check for Deepcoin client (lazy import to avoid circular dependency)
global DeepcoinClient
if DeepcoinClient is None:
try:
from app.services.live_trading.deepcoin import DeepcoinClient as _DeepcoinClient
DeepcoinClient = _DeepcoinClient
except ImportError:
pass
if DeepcoinClient is not None and isinstance(client, DeepcoinClient):
return client.place_market_order(
symbol=symbol,
side=side,
qty=qty,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=client_order_id,
)
# Check for IBKR client (lazy import to avoid circular dependency)
global IBKRClient
if IBKRClient is None:
@@ -294,21 +294,79 @@ class OkxClient(BaseRestClient):
if params:
# OKX expects the query string in the signed request path. Keep key order stable.
# Convert all values to string to avoid "True"/"False" surprises.
norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()}
qs = urlencode(sorted(norm.items()), doseq=True)
# Filter out empty strings and None values (OKX doesn't like empty params)
norm = {str(k): str(v) for k, v in dict(params).items() if v is not None and str(v).strip() != ""}
if norm:
# Sort by key to ensure consistent ordering (OKX requirement)
qs = urlencode(sorted(norm.items()), doseq=True)
signed_path = f"{path}?{qs}" if qs else path
sign = self._sign(ts, method, signed_path, body_str)
# For GET requests with query params, we need to ensure the actual request URL matches the signed path
# OKX requires exact match between signed path and actual request path
if method.upper() == "GET" and qs:
# Append query string directly to path to match signature exactly
# Don't use params parameter to avoid double encoding
request_path = f"{path}?{qs}"
request_params = None
else:
request_path = path
request_params = params
code, data, text = self._request(
method,
path,
params=params,
request_path,
params=request_params,
data=body_str if body_str else None,
headers=self._headers(ts, sign),
)
if code >= 400:
raise LiveTradingError(f"OKX HTTP {code}: {text[:500]}")
# Provide more helpful error messages for common permission issues
error_msg = text[:500] if text else f"HTTP {code}"
if code == 401:
error_code = ""
if isinstance(data, dict):
error_code = str(data.get("code") or "")
if error_code == "50120" or "permission" in error_msg.lower():
raise LiveTradingError(
f"OKX API permission error (HTTP {code}, code {error_code}): {error_msg}\n"
f"Solution: Please enable 'Trade' permission for your API key in OKX account.\n"
f"Path: OKX website -> API Management -> Edit API Key -> Enable 'Trade' permission"
)
raise LiveTradingError(f"OKX HTTP {code}: {error_msg}")
if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""):
error_code = str(data.get("code") or "")
error_msg = str(data.get("msg") or data)
# Check for specific error codes in data array
data_array = data.get("data", [])
if isinstance(data_array, list) and data_array:
first_item = data_array[0] if isinstance(data_array[0], dict) else {}
s_code = str(first_item.get("sCode") or "")
s_msg = str(first_item.get("sMsg") or "")
# Error code 51008: Insufficient margin
if s_code == "51008" or "insufficient" in s_msg.lower() or "margin" in s_msg.lower():
raise LiveTradingError(
f"OKX insufficient margin error (code {s_code}): {s_msg}\n"
f"Solution: Please ensure you have sufficient USDT margin in your account to place this order."
)
# Error code 50120: Permission error
if s_code == "50120" or error_code == "50120" or "permission" in str(error_msg).lower():
raise LiveTradingError(
f"OKX API permission error (code {s_code or error_code}): {s_msg or error_msg}\n"
f"Solution: Please enable 'Trade' permission for your API key in OKX account.\n"
f"Path: OKX website -> API Management -> Edit API Key -> Enable 'Trade' permission"
)
# Fallback for permission errors
if error_code == "50120" or "permission" in str(error_msg).lower():
raise LiveTradingError(
f"OKX API permission error (code {error_code}): {error_msg}\n"
f"Solution: Please enable 'Trade' permission for your API key in OKX account.\n"
f"Path: OKX website -> API Management -> Edit API Key -> Enable 'Trade' permission"
)
raise LiveTradingError(f"OKX error: {data}")
return data if isinstance(data, dict) else {"raw": data}
@@ -316,21 +374,44 @@ class OkxClient(BaseRestClient):
code, data, _ = self._request("GET", "/api/v5/public/time")
return code == 200 and isinstance(data, dict)
def get_ticker(self, *, inst_id: str) -> Dict[str, Any]:
"""
Get ticker price for an instrument.
Endpoint: GET /api/v5/market/ticker?instId=...
"""
if not inst_id:
return {}
raw = self._public_request("GET", "/api/v5/market/ticker", params={"instId": inst_id})
data = (raw.get("data") or []) if isinstance(raw, dict) else []
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
return first if isinstance(first, dict) else {}
def get_balance(self) -> Dict[str, Any]:
"""
Private endpoint to validate credentials (best-effort).
"""
return self._signed_request("GET", "/api/v5/account/balance")
def get_positions(self, *, inst_id: str = "") -> Dict[str, Any]:
def get_positions(self, *, inst_id: str = "", inst_type: str = "SWAP") -> Dict[str, Any]:
"""
Get swap positions (best-effort).
Get positions (best-effort).
Args:
inst_id: Instrument ID (optional, for filtering)
inst_type: Instrument type - "SPOT" or "SWAP" (default: "SWAP")
Endpoint: GET /api/v5/account/positions
"""
params: Dict[str, Any] = {"instType": "SWAP"}
if inst_id:
params["instId"] = str(inst_id)
# Validate inst_type
it = str(inst_type or "SWAP").strip().upper()
if it not in ("SPOT", "SWAP", "FUTURES", "OPTION"):
it = "SWAP"
params: Dict[str, Any] = {"instType": it}
# Only add instId if it's not empty
if inst_id and str(inst_id).strip():
params["instId"] = str(inst_id).strip()
return self._signed_request("GET", "/api/v5/account/positions", params=params)
def set_leverage(self, *, inst_id: str, lever: float, mgn_mode: str = "cross", pos_side: str = "") -> bool:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1361px" height="609px" viewBox="0 0 1361 609" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
<title>Group 21</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Ant-Design-Pro-3.0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="账户密码登录-校验" transform="translate(-79.000000, -82.000000)">
<g id="Group-21" transform="translate(77.000000, 73.000000)">
<g id="Group-18" opacity="0.8" transform="translate(74.901416, 569.699158) rotate(-7.000000) translate(-74.901416, -569.699158) translate(4.901416, 525.199158)">
<ellipse id="Oval-11" fill="#CFDAE6" opacity="0.25" cx="63.5748792" cy="32.468367" rx="21.7830479" ry="21.766008"></ellipse>
<ellipse id="Oval-3" fill="#CFDAE6" opacity="0.599999964" cx="5.98746479" cy="13.8668601" rx="5.2173913" ry="5.21330997"></ellipse>
<path d="M38.1354514,88.3520215 C43.8984227,88.3520215 48.570234,83.6838647 48.570234,77.9254015 C48.570234,72.1669383 43.8984227,67.4987816 38.1354514,67.4987816 C32.3724801,67.4987816 27.7006688,72.1669383 27.7006688,77.9254015 C27.7006688,83.6838647 32.3724801,88.3520215 38.1354514,88.3520215 Z" id="Oval-3-Copy" fill="#CFDAE6" opacity="0.45"></path>
<path d="M64.2775582,33.1704963 L119.185836,16.5654915" id="Path-12" stroke="#CFDAE6" stroke-width="1.73913043" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M42.1431708,26.5002681 L7.71190162,14.5640702" id="Path-16" stroke="#E0B4B7" stroke-width="0.702678964" opacity="0.7" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
<path d="M63.9262187,33.521561 L43.6721326,69.3250951" id="Path-15" stroke="#BACAD9" stroke-width="0.702678964" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
<g id="Group-17" transform="translate(126.850922, 13.543654) rotate(30.000000) translate(-126.850922, -13.543654) translate(117.285705, 4.381889)" fill="#CFDAE6">
<ellipse id="Oval-4" opacity="0.45" cx="9.13482653" cy="9.12768076" rx="9.13482653" ry="9.12768076"></ellipse>
<path d="M18.2696531,18.2553615 C18.2696531,13.2142826 14.1798519,9.12768076 9.13482653,9.12768076 C4.08980114,9.12768076 0,13.2142826 0,18.2553615 L18.2696531,18.2553615 Z" id="Oval-4" transform="translate(9.134827, 13.691521) scale(-1, -1) translate(-9.134827, -13.691521) "></path>
</g>
</g>
<g id="Group-14" transform="translate(216.294700, 123.725600) rotate(-5.000000) translate(-216.294700, -123.725600) translate(106.294700, 35.225600)">
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.25" cx="29.1176471" cy="29.1402439" rx="29.1176471" ry="29.1402439"></ellipse>
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.3" cx="29.1176471" cy="29.1402439" rx="21.5686275" ry="21.5853659"></ellipse>
<ellipse id="Oval-2-Copy" stroke="#CFDAE6" opacity="0.4" cx="179.019608" cy="138.146341" rx="23.7254902" ry="23.7439024"></ellipse>
<ellipse id="Oval-2" fill="#BACAD9" opacity="0.5" cx="29.1176471" cy="29.1402439" rx="10.7843137" ry="10.7926829"></ellipse>
<path d="M29.1176471,39.9329268 L29.1176471,18.347561 C23.1616351,18.347561 18.3333333,23.1796097 18.3333333,29.1402439 C18.3333333,35.1008781 23.1616351,39.9329268 29.1176471,39.9329268 Z" id="Oval-2" fill="#BACAD9"></path>
<g id="Group-9" opacity="0.45" transform="translate(172.000000, 131.000000)" fill="#E6A1A6">
<ellipse id="Oval-2-Copy-2" cx="7.01960784" cy="7.14634146" rx="6.47058824" ry="6.47560976"></ellipse>
<path d="M0.549019608,13.6219512 C4.12262681,13.6219512 7.01960784,10.722722 7.01960784,7.14634146 C7.01960784,3.56996095 4.12262681,0.670731707 0.549019608,0.670731707 L0.549019608,13.6219512 Z" id="Oval-2-Copy-2" transform="translate(3.784314, 7.146341) scale(-1, 1) translate(-3.784314, -7.146341) "></path>
</g>
<ellipse id="Oval-10" fill="#CFDAE6" cx="218.382353" cy="138.685976" rx="1.61764706" ry="1.61890244"></ellipse>
<ellipse id="Oval-10-Copy-2" fill="#E0B4B7" opacity="0.35" cx="179.558824" cy="175.381098" rx="1.61764706" ry="1.61890244"></ellipse>
<ellipse id="Oval-10-Copy" fill="#E0B4B7" opacity="0.35" cx="180.098039" cy="102.530488" rx="2.15686275" ry="2.15853659"></ellipse>
<path d="M28.9985381,29.9671598 L171.151018,132.876024" id="Path-11" stroke="#CFDAE6" opacity="0.8"></path>
</g>
<g id="Group-10" opacity="0.799999952" transform="translate(1054.100635, 36.659317) rotate(-11.000000) translate(-1054.100635, -36.659317) translate(1026.600635, 4.659317)">
<ellipse id="Oval-7" stroke="#CFDAE6" stroke-width="0.941176471" cx="43.8135593" cy="32" rx="11.1864407" ry="11.2941176"></ellipse>
<g id="Group-12" transform="translate(34.596774, 23.111111)" fill="#BACAD9">
<ellipse id="Oval-7" opacity="0.45" cx="9.18534718" cy="8.88888889" rx="8.47457627" ry="8.55614973"></ellipse>
<path d="M9.18534718,17.4450386 C13.8657264,17.4450386 17.6599235,13.6143199 17.6599235,8.88888889 C17.6599235,4.16345787 13.8657264,0.332739156 9.18534718,0.332739156 L9.18534718,17.4450386 Z" id="Oval-7"></path>
</g>
<path d="M34.6597385,24.809694 L5.71666084,4.76878945" id="Path-2" stroke="#CFDAE6" stroke-width="0.941176471"></path>
<ellipse id="Oval" stroke="#CFDAE6" stroke-width="0.941176471" cx="3.26271186" cy="3.29411765" rx="3.26271186" ry="3.29411765"></ellipse>
<ellipse id="Oval-Copy" fill="#F7E1AD" cx="2.79661017" cy="61.1764706" rx="2.79661017" ry="2.82352941"></ellipse>
<path d="M34.6312443,39.2922712 L5.06366663,59.785082" id="Path-10" stroke="#CFDAE6" stroke-width="0.941176471"></path>
</g>
<g id="Group-19" opacity="0.33" transform="translate(1282.537219, 446.502867) rotate(-10.000000) translate(-1282.537219, -446.502867) translate(1142.537219, 327.502867)">
<g id="Group-17" transform="translate(141.333539, 104.502742) rotate(275.000000) translate(-141.333539, -104.502742) translate(129.333539, 92.502742)" fill="#BACAD9">
<circle id="Oval-4" opacity="0.45" cx="11.6666667" cy="11.6666667" r="11.6666667"></circle>
<path d="M23.3333333,23.3333333 C23.3333333,16.8900113 18.1099887,11.6666667 11.6666667,11.6666667 C5.22334459,11.6666667 0,16.8900113 0,23.3333333 L23.3333333,23.3333333 Z" id="Oval-4" transform="translate(11.666667, 17.500000) scale(-1, -1) translate(-11.666667, -17.500000) "></path>
</g>
<circle id="Oval-5-Copy-6" fill="#CFDAE6" cx="201.833333" cy="87.5" r="5.83333333"></circle>
<path d="M143.5,88.8126685 L155.070501,17.6038544" id="Path-17" stroke="#BACAD9" stroke-width="1.16666667"></path>
<path d="M17.5,37.3333333 L127.466252,97.6449735" id="Path-18" stroke="#BACAD9" stroke-width="1.16666667"></path>
<polyline id="Path-19" stroke="#CFDAE6" stroke-width="1.16666667" points="143.902597 120.302281 174.935455 231.571342 38.5 147.510847 126.366941 110.833333"></polyline>
<path d="M159.833333,99.7453842 L195.416667,89.25" id="Path-20" stroke="#E0B4B7" stroke-width="1.16666667" opacity="0.6"></path>
<path d="M205.333333,82.1372105 L238.719406,36.1666667" id="Path-24" stroke="#BACAD9" stroke-width="1.16666667"></path>
<path d="M266.723424,132.231988 L207.083333,90.4166667" id="Path-25" stroke="#CFDAE6" stroke-width="1.16666667"></path>
<circle id="Oval-5" fill="#C1D1E0" cx="156.916667" cy="8.75" r="8.75"></circle>
<circle id="Oval-5-Copy-3" fill="#C1D1E0" cx="39.0833333" cy="148.75" r="5.25"></circle>
<circle id="Oval-5-Copy-2" fill-opacity="0.6" fill="#D1DEED" cx="8.75" cy="33.25" r="8.75"></circle>
<circle id="Oval-5-Copy-4" fill-opacity="0.6" fill="#D1DEED" cx="243.833333" cy="30.3333333" r="5.83333333"></circle>
<circle id="Oval-5-Copy-5" fill="#E0B4B7" cx="175.583333" cy="232.75" r="5.25"></circle>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+424
View File
@@ -0,0 +1,424 @@
<!doctype html><html lang="zh-cmn-Hans"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/slogo.png"><title>QuantDinger</title><style>.first-loading-wrp {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
min-height: 420px;
height: 100vh;
background: #fff;
position: relative;
overflow: hidden;
}
.first-loading-wrp > h2 {
font-size: 32px;
margin-bottom: 40px;
color: #333;
font-weight: 600;
}
.first-loading-wrp .loading-wrp {
padding: 40px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
position: relative;
width: 100%;
max-width: 600px;
}
/* 像素风格小猫奔跑动画容器 */
.pixel-cat-container {
position: relative;
width: 100%;
height: 120px;
margin: 0 auto 30px;
overflow: hidden;
}
/* 像素小猫主体 */
.pixel-cat {
position: absolute;
left: 0;
bottom: 24px;
width: 32px;
height: 32px;
animation: catRun 0.4s steps(2) infinite, catMove 3.5s linear infinite;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
/* 所有像素元素都使用锐利边缘 */
.pixel-cat * {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
/* 猫头 - 像素方块组成 */
.cat-head {
position: absolute;
left: 2px;
top: 0;
width: 16px;
height: 14px;
background:
/* 头部主体白色 */
linear-gradient(#fff, #fff) 2px 4px / 12px 10px no-repeat,
/* 左半脸黑色斑块 */
linear-gradient(#000, #000) 2px 4px / 6px 10px no-repeat,
/* 头顶 */
linear-gradient(#fff, #fff) 4px 2px / 8px 2px no-repeat;
animation: headBob 0.4s steps(2) infinite;
}
/* 左耳 - 黑色尖耳朵 */
.cat-ear-left {
position: absolute;
left: 0px;
top: -2px;
width: 4px;
height: 6px;
background:
linear-gradient(#000, #000) 0px 4px / 4px 2px no-repeat,
linear-gradient(#000, #000) 1px 2px / 2px 2px no-repeat,
linear-gradient(#000, #000) 1px 0px / 2px 2px no-repeat;
}
/* 右耳 - 白色尖耳朵 */
.cat-ear-right {
position: absolute;
right: 0px;
top: -2px;
width: 4px;
height: 6px;
background:
linear-gradient(#fff, #fff) 0px 4px / 4px 2px no-repeat,
linear-gradient(#fff, #fff) 1px 2px / 2px 2px no-repeat,
linear-gradient(#fff, #fff) 1px 0px / 2px 2px no-repeat;
box-shadow: inset 0 0 0 1px #000;
}
.cat-ear-right::after {
content: '';
position: absolute;
width: 4px;
height: 6px;
border: 1px solid #000;
border-width: 0 1px 0 0;
box-sizing: border-box;
}
/* 左眼 - 黑底白眼(在黑色区域) */
.cat-eye-left {
position: absolute;
left: 4px;
top: 6px;
width: 4px;
height: 4px;
background: #fff;
}
.cat-eye-left::after {
content: '';
position: absolute;
left: 2px;
top: 1px;
width: 2px;
height: 2px;
background: #000;
}
/* 右眼 - 白底黑眼(在白色区域) */
.cat-eye-right {
position: absolute;
right: 2px;
top: 6px;
width: 4px;
height: 4px;
background: #fff;
border: 1px solid #000;
box-sizing: border-box;
}
.cat-eye-right::after {
content: '';
position: absolute;
left: 1px;
top: 0px;
width: 2px;
height: 2px;
background: #000;
}
/* 鼻子 - 小粉色方块 */
.cat-nose {
position: absolute;
left: 7px;
top: 10px;
width: 2px;
height: 2px;
background: #000;
}
/* 胡须 - 像素线条 */
.cat-whiskers {
position: absolute;
left: 0;
top: 10px;
width: 16px;
height: 4px;
}
.cat-whiskers::before {
content: '';
position: absolute;
left: -4px;
top: 0;
width: 4px;
height: 1px;
background: #000;
box-shadow: 0 2px 0 #000;
}
.cat-whiskers::after {
content: '';
position: absolute;
right: -4px;
top: 0;
width: 4px;
height: 1px;
background: #000;
box-shadow: 0 2px 0 #000;
}
/* 身体 - 黑白相间像素块 */
.cat-body {
position: absolute;
left: 4px;
top: 14px;
width: 14px;
height: 10px;
background:
/* 白色部分 */
linear-gradient(#fff, #fff) 6px 0 / 8px 10px no-repeat,
/* 黑色部分 */
linear-gradient(#000, #000) 0 0 / 8px 10px no-repeat;
border: 1px solid #000;
box-sizing: border-box;
}
/* 前腿 - 左 (黑色) */
.cat-leg-front-left {
position: absolute;
left: 6px;
top: 22px;
width: 3px;
height: 8px;
background: #000;
animation: legFront 0.2s steps(2) infinite;
}
/* 前腿 - 右 (白色带边框) */
.cat-leg-front-right {
position: absolute;
left: 12px;
top: 22px;
width: 3px;
height: 8px;
background: #fff;
border: 1px solid #000;
box-sizing: border-box;
animation: legFront 0.2s steps(2) infinite 0.1s;
}
/* 后腿 - 左 (白色带边框) */
.cat-leg-back-left {
position: absolute;
left: 3px;
top: 22px;
width: 3px;
height: 8px;
background: #fff;
border: 1px solid #000;
box-sizing: border-box;
animation: legBack 0.2s steps(2) infinite 0.1s;
}
/* 后腿 - 右 (黑色) */
.cat-leg-back-right {
position: absolute;
left: 15px;
top: 22px;
width: 3px;
height: 8px;
background: #000;
animation: legBack 0.2s steps(2) infinite;
}
/* 尾巴 - 长且弯曲的像素尾巴 */
.cat-tail {
position: absolute;
right: -10px;
top: 10px;
width: 12px;
height: 10px;
background:
/* 尾巴根部 */
linear-gradient(#000, #000) 0 6px / 3px 3px no-repeat,
/* 尾巴中部 */
linear-gradient(#000, #000) 3px 4px / 3px 3px no-repeat,
/* 尾巴弯曲 */
linear-gradient(#000, #000) 6px 2px / 3px 3px no-repeat,
/* 尾巴尖端 */
linear-gradient(#000, #000) 9px 0 / 3px 3px no-repeat;
animation: tailWag 0.3s steps(2) infinite alternate;
transform-origin: left bottom;
}
/* 奔跑动画 - 轻微上下跳动 */
@keyframes catRun {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
/* 移动动画 - 左右移动 */
@keyframes catMove {
0% { left: -40px; }
100% { left: calc(100% + 40px); }
}
/* 前腿动画 */
@keyframes legFront {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
/* 后腿动画 */
@keyframes legBack {
0%, 100% { transform: rotate(15deg); }
50% { transform: rotate(-15deg); }
}
/* 尾巴摆动 */
@keyframes tailWag {
0% { transform: rotate(-10deg); }
100% { transform: rotate(10deg); }
}
/* 头部轻微摆动 */
@keyframes headBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1px); }
}
/* 地面效果 - 像素风格 */
.ground {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 12px;
background: repeating-linear-gradient(
90deg,
#222 0px,
#222 6px,
#555 6px,
#555 12px
);
animation: groundMove 0.3s linear infinite;
image-rendering: pixelated;
}
@keyframes groundMove {
0% { background-position: 0 0; }
100% { background-position: 12px 0; }
}
/* 品牌文字 */
.brand-text {
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
font-weight: 600;
color: #333;
margin-top: 20px;
letter-spacing: 2px;
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.first-loading-wrp {
background: #141414;
}
.first-loading-wrp > h2 {
color: #fff;
}
.brand-text {
color: #fff;
}
.ground {
background: repeating-linear-gradient(
90deg,
#555 0px,
#555 6px,
#333 6px,
#333 12px
);
}
/* 暗色模式下白色部分改成浅灰 */
.cat-head {
background:
linear-gradient(#ddd, #ddd) 2px 4px / 12px 10px no-repeat,
linear-gradient(#000, #000) 2px 4px / 6px 10px no-repeat,
linear-gradient(#ddd, #ddd) 4px 2px / 8px 2px no-repeat;
}
.cat-body {
background:
linear-gradient(#ddd, #ddd) 6px 0 / 8px 10px no-repeat,
linear-gradient(#000, #000) 0 0 / 8px 10px no-repeat;
}
.cat-leg-front-right,
.cat-leg-back-left {
background: #ddd;
}
.cat-eye-left,
.cat-eye-right {
background: #ddd;
}
}
/* 确保像素风格在所有浏览器中正确显示 */
.pixel-cat,
.pixel-cat * {
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* 手机端适配 */
@media (max-width: 768px) {
.pixel-cat-container {
transform: scale(1.5);
}
.first-loading-wrp > h2 {
font-size: 24px;
margin-bottom: 30px;
}
.brand-text {
font-size: 20px;
}
}</style><script defer="defer" src="/js/chunk-vendors.6f65f877.js" type="module"></script><script defer="defer" src="/js/app.78319175.js" type="module"></script><link href="/css/chunk-vendors.b8cb9e53.css" rel="stylesheet"><link href="/css/app.b8761398.css" rel="stylesheet"><script defer="defer" src="/js/chunk-vendors-legacy.9d679269.js" nomodule></script><script defer="defer" src="/js/app-legacy.b9bb3b91.js" nomodule></script></head><body><noscript><strong>We're sorry but vue-antd-pro doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"><div class="first-loading-wrp"><h2>Landing</h2><div class="loading-wrp"><div class="pixel-cat-container"><div class="ground"></div><div class="pixel-cat"><div class="cat-head"><div class="cat-ear-left"></div><div class="cat-ear-right"></div><div class="cat-eye-left"></div><div class="cat-eye-right"></div><div class="cat-nose"></div><div class="cat-whiskers"></div></div><div class="cat-body"></div><div class="cat-leg-front-left"></div><div class="cat-leg-front-right"></div><div class="cat-leg-back-left"></div><div class="cat-leg-back-right"></div><div class="cat-tail"></div></div></div></div><div class="brand-text">QuantDinger</div></div></div></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
"use strict";(self["webpackChunkvue_antd_pro"]=self["webpackChunkvue_antd_pro"]||[]).push([[824],{95824:function(t,n,r){r.r(n),r.d(n,{changePassword:function(){return p},getGitHubOAuthUrl:function(){return g},getGoogleOAuthUrl:function(){return l},getSecurityConfig:function(){return o},getUserInfo:function(){return c},login:function(){return i},loginWithCode:function(){return s},logout:function(){return a},register:function(){return f},resetPassword:function(){return d},sendVerificationCode:function(){return h}});r(34782),r(27495),r(99449),r(25440),r(11392),r(42762);var u=r(75769);function e(t){var n="/api".trim(),r=t.startsWith("/")?t:"/".concat(t);if(!n)return r;var u=n.replace(/\/+$/,"");return u.endsWith("/api")&&r.startsWith("/api/")?u+r.slice(4):u+r}function o(){return(0,u.Ay)({url:"/api/auth/security-config",method:"get"})}function i(t){return(0,u.Ay)({url:"/api/auth/login",method:"post",data:t})}function a(){return(0,u.Ay)({url:"/api/auth/logout",method:"post"})}function c(){return(0,u.Ay)({url:"/api/auth/info",method:"get"})}function h(t){return(0,u.Ay)({url:"/api/auth/send-code",method:"post",data:t})}function s(t){return(0,u.Ay)({url:"/api/auth/login-code",method:"post",data:t})}function f(t){return(0,u.Ay)({url:"/api/auth/register",method:"post",data:t})}function d(t){return(0,u.Ay)({url:"/api/auth/reset-password",method:"post",data:t})}function p(t){return(0,u.Ay)({url:"/api/auth/change-password",method:"post",data:t})}function l(){return e("/api/auth/oauth/google")}function g(){return e("/api/auth/oauth/github")}}}]);
+1
View File
@@ -0,0 +1 @@
"use strict";(self["webpackChunkvue_antd_pro"]=self["webpackChunkvue_antd_pro"]||[]).push([[824],{95824:function(t,n,r){r.r(n),r.d(n,{changePassword:function(){return p},getGitHubOAuthUrl:function(){return g},getGoogleOAuthUrl:function(){return l},getSecurityConfig:function(){return o},getUserInfo:function(){return c},login:function(){return i},loginWithCode:function(){return s},logout:function(){return a},register:function(){return f},resetPassword:function(){return d},sendVerificationCode:function(){return h}});var u=r(75769);function e(t){var n="/api".trim(),r=t.startsWith("/")?t:"/".concat(t);if(!n)return r;var u=n.replace(/\/+$/,"");return u.endsWith("/api")&&r.startsWith("/api/")?u+r.slice(4):u+r}function o(){return(0,u.Ay)({url:"/api/auth/security-config",method:"get"})}function i(t){return(0,u.Ay)({url:"/api/auth/login",method:"post",data:t})}function a(){return(0,u.Ay)({url:"/api/auth/logout",method:"post"})}function c(){return(0,u.Ay)({url:"/api/auth/info",method:"get"})}function h(t){return(0,u.Ay)({url:"/api/auth/send-code",method:"post",data:t})}function s(t){return(0,u.Ay)({url:"/api/auth/login-code",method:"post",data:t})}function f(t){return(0,u.Ay)({url:"/api/auth/register",method:"post",data:t})}function d(t){return(0,u.Ay)({url:"/api/auth/reset-password",method:"post",data:t})}function p(t){return(0,u.Ay)({url:"/api/auth/change-password",method:"post",data:t})}function l(){return e("/api/auth/oauth/google")}function g(){return e("/api/auth/oauth/github")}}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
"use strict";(self["webpackChunkvue_antd_pro"]=self["webpackChunkvue_antd_pro"]||[]).push([[143],{32319:function(t,e,n){n.r(e),n.d(e,{default:function(){return l}});var u=function(){var t=this,e=t._self._c;return e("a-result",{attrs:{status:"404",title:"404","sub-title":"Sorry, the page you visited does not exist."},scopedSlots:t._u([{key:"extra",fn:function(){return[e("a-button",{attrs:{type:"primary"},on:{click:t.toHome}},[t._v(" Back Home ")])]},proxy:!0}])})},o=[],r={name:"Exception404",methods:{toHome:function(){this.$router.push({path:"/"})}}},s=r,a=n(81656),i=(0,a.A)(s,u,o,!1,null,null,null),l=i.exports}}]);
+1
View File
@@ -0,0 +1 @@
"use strict";(self["webpackChunkvue_antd_pro"]=self["webpackChunkvue_antd_pro"]||[]).push([[143],{32319:function(t,e,n){n.r(e),n.d(e,{default:function(){return l}});var u=function(){var t=this,e=t._self._c;return e("a-result",{attrs:{status:"404",title:"404","sub-title":"Sorry, the page you visited does not exist."},scopedSlots:t._u([{key:"extra",fn:function(){return[e("a-button",{attrs:{type:"primary"},on:{click:t.toHome}},[t._v(" Back Home ")])]},proxy:!0}])})},o=[],r={name:"Exception404",methods:{toHome:function(){this.$router.push({path:"/"})}}},s=r,a=n(81656),i=(0,a.A)(s,u,o,!1,null,null,null),l=i.exports}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB