Use hub exchange instances for calculator contract precision.

Load enabled instances from settings, fetch market info via /api/hub/market, and apply exchange-specific amount and price precision in trend and roll calculators.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-23 18:13:02 +08:00
parent d938bc6c59
commit 5e507d0b66
14 changed files with 1140 additions and 204 deletions
+246
View File
@@ -0,0 +1,246 @@
"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则。"""
from __future__ import annotations
import threading
import time
from typing import Any, Callable, Optional, Tuple
from urllib.parse import urlencode
import httpx
from manual_trading_hub.settings_store import enabled_exchanges, load_settings
MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
MARKET_LOCK = threading.Lock()
MARKET_TTL_SEC = 300.0
HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "12"))
def normalize_base_symbol(text: str) -> str:
s = str(text or "").upper().strip()
for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"):
if s.endswith(suf) and len(s) > len(suf):
s = s[: -len(suf)].strip("-/")
break
if "/" in s:
s = s.split("/", 1)[0].strip()
if ":" in s:
s = s.split(":", 1)[0].strip()
return s
def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]:
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"]
markets = getattr(exchange, "markets", None) or {}
for sym in candidates:
m = markets.get(sym)
if not m:
continue
if m.get("active") is False:
continue
if m.get("swap") or m.get("linear") or m.get("contract"):
return sym, None
for sym, m in markets.items():
if m.get("active") is False:
continue
if not (m.get("swap") or m.get("linear")):
continue
if (m.get("quote") or "").upper() != "USDT":
continue
if (m.get("base") or "").upper() == base_u:
return sym, None
return None, f"未找到 {base_u}/USDT 永续合约"
def _decimals_from_precision_value(value: Any) -> Optional[int]:
if value in (None, ""):
return None
try:
p = float(value)
except (TypeError, ValueError):
return None
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
return int(round(p))
if 0 < p < 1:
s = f"{p:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
return None
def _decimals_from_ccxt_str(text: str) -> int:
s = str(text or "").strip()
if not s or "." not in s:
return 0
frac = s.split(".", 1)[1]
if not frac:
return 0
return min(12, len(frac.rstrip("0") or frac))
def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int:
try:
return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("amount")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def price_decimals_from_exchange(
exchange: Any, exchange_symbol: str, price_tick: Optional[float]
) -> int:
from hub_ohlcv_lib import normalize_price_tick
tick = normalize_price_tick(price_tick)
if tick and tick > 0:
if tick >= 1:
return 0
s = f"{tick:.12f}".rstrip("0")
if "." in s:
return min(12, len(s.split(".", 1)[1]))
try:
return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234))
except Exception:
market = exchange.market(exchange_symbol)
prec = (market.get("precision") or {}).get("price")
d = _decimals_from_precision_value(prec)
return d if d is not None else 4
def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]:
dec = max(0, int(market.get("amount_decimals") or 4))
min_amt = market.get("min_amount")
def _fn(amount: float) -> Optional[float]:
try:
v = float(amount)
except (TypeError, ValueError):
return None
if v <= 0:
return None
factor = 10**dec
v = int(v * factor + 1e-12) / factor
if min_amt is not None:
try:
if v < float(min_amt):
return None
except (TypeError, ValueError):
pass
if v <= 0:
return None
return v
return _fn
def find_exchange(exchange_id: str) -> dict | None:
needle = str(exchange_id or "").strip()
if not needle:
return None
for ex in load_settings().get("exchanges") or []:
if str(ex.get("id") or "").strip() == needle:
return ex
if str(ex.get("key") or "").strip().lower() == needle.lower():
return ex
return None
def list_calculator_exchanges() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for ex in enabled_exchanges():
rows.append(
{
"id": str(ex.get("id") or ""),
"key": str(ex.get("key") or ""),
"name": str(ex.get("name") or ex.get("key") or ""),
"enabled": bool(ex.get("enabled")),
}
)
return rows
def _hub_headers() -> dict[str, str]:
import os
token = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
if token:
return {"X-Hub-Bridge-Token": token}
return {}
def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]:
base_url = (ex.get("flask_url") or "").rstrip("/")
if not base_url:
return {"ok": False, "msg": "未配置 flask_url"}
params = urlencode({"base": normalize_base_symbol(base) or base})
url = f"{base_url}/api/hub/market?{params}"
try:
with httpx.Client(timeout=HUB_FLASK_TIMEOUT) as client:
r = client.get(url, headers=_hub_headers())
if r.status_code >= 400:
try:
body = r.json()
except Exception:
body = {"ok": False, "msg": r.text or f"HTTP {r.status_code}"}
if isinstance(body, dict):
body.setdefault("ok", False)
return body
return {"ok": False, "msg": f"HTTP {r.status_code}"}
data = r.json() if r.content else {}
return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"}
except Exception as exc:
return {"ok": False, "msg": str(exc)}
def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]:
out = dict(payload)
out["exchange_id"] = str(ex.get("id") or "")
out["exchange_key"] = str(ex.get("key") or "")
out["exchange_name"] = str(ex.get("name") or ex.get("key") or "")
out["exchange_label"] = out["exchange_name"]
return out
def get_calculator_market(
exchange_id: str,
base: str,
*,
ex: dict | None = None,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""从系统设置中的交易实例拉取合约精度(与实盘一致)。"""
row = ex or find_exchange(exchange_id)
if not row:
return None, "未找到该交易所配置"
if not row.get("enabled"):
return None, f"{row.get('name') or exchange_id} 未启用"
base_u = normalize_base_symbol(base)
if not base_u:
return None, "请输入币种,如 ETH"
cache_key = f"{row.get('id')}:{base_u}"
now = time.time()
with MARKET_LOCK:
cached = MARKET_CACHE.get(cache_key)
if cached and now - cached[0] < MARKET_TTL_SEC:
return dict(cached[1]), None
remote = fetch_instance_market_sync(row, base=base_u)
if not remote.get("ok"):
return None, str(remote.get("msg") or "实例返回失败")
data = _enrich_market_from_settings(row, remote)
with MARKET_LOCK:
MARKET_CACHE[cache_key] = (now, data)
return data, None
def clear_market_cache() -> None:
with MARKET_LOCK:
MARKET_CACHE.clear()