feat: Add cross-sectional strategy support

- Add cross-sectional strategy type (single vs cross-sectional)
- Support multi-symbol portfolio management with automatic ranking
- Add portfolio size, long ratio, and rebalance frequency configuration
- Implement parallel order execution for cross-sectional strategies
- Add frontend UI for strategy type selection and configuration
- Add i18n support (Chinese and English) for cross-sectional features
- Fix decimal precision issues in exchange order quantities
- Add last_rebalance_at field to database schema
- Add comprehensive documentation and examples

Database migration required: Add last_rebalance_at column to qd_strategies_trading table
This commit is contained in:
TIANHE
2026-02-10 15:18:45 +08:00
parent a89cc9bee9
commit a51184497d
19 changed files with 1517 additions and 36 deletions
@@ -47,11 +47,51 @@ class BinanceFuturesClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
# Fallback: try to convert safely
try:
# If Decimal conversion fails, try float with limited precision
f = float(d)
if f == 0:
return "0"
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -237,12 +277,34 @@ class BinanceFuturesClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
try:
meta = fdict.get("_meta") or {}
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
if isinstance(meta, dict):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
# stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0')
if '.' in step_str:
qty_precision = len(step_str.split('.')[1])
else:
# If stepSize is 1 or larger, precision is 0
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return Decimal("0")
return q
@@ -38,11 +38,51 @@ class BinanceSpotClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
# Fallback: try to convert safely
try:
# If Decimal conversion fails, try float with limited precision
f = float(d)
if f == 0:
return "0"
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -217,12 +257,34 @@ class BinanceSpotClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
try:
meta = fdict.get("_meta") or {}
q = self._floor_to_precision(q, (meta.get("quantityPrecision") if isinstance(meta, dict) else None))
if isinstance(meta, dict):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
# stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0')
if '.' in step_str:
qty_precision = len(step_str.split('.')[1])
else:
# If stepSize is 1 or larger, precision is 0
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return Decimal("0")
return q
@@ -54,11 +54,39 @@ class BitgetMixClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match sizeStep/sizePlace precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -54,11 +54,39 @@ class BitgetSpotClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match quantityStep/quantityScale precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -61,11 +61,39 @@ class BybitClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Bybit requires quantities to match qtyStep precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -74,11 +74,39 @@ class DeepcoinClient(BaseRestClient):
return Decimal("0")
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to string with controlled precision.
Deepcoin requires quantities to match lotSz/qtyStep precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
normalized = d.normalize()
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _floor_to_step(value: Decimal, step: Decimal) -> Decimal:
@@ -52,14 +52,41 @@ class OkxClient(BaseRestClient):
self._lev_cache_ttl_sec = 60.0
@staticmethod
def _dec_str(d: Decimal) -> str:
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
"""
Convert Decimal to a non-scientific string (OKX expects plain decimal strings).
Convert Decimal to a non-scientific string with controlled precision.
OKX expects plain decimal strings matching lotSz precision.
"""
try:
return format(d, "f")
if d == 0:
return "0"
# Normalize to remove unnecessary trailing zeros
normalized = d.normalize()
# Format with max_decimals and remove trailing zeros
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
return str(d)
try:
f = float(d)
if f == 0:
return "0"
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
try:
f = float(s)
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
except Exception:
pass
return s if s else "0"
@staticmethod
def _to_dec(x: Any) -> Decimal: