diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index da8c002..ea81721 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -9311,6 +9311,19 @@ def _hub_account_bundle(): } +def _hub_fetch_market(base=""): + from hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_exchange_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="binance", + ) + + def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): from hub_ohlcv_lib import fetch_ohlcv_for_hub @@ -9359,6 +9372,7 @@ try: views={"add_order": add_order, "add_key": add_key}, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, ) diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index f4763e4..c962632 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -9255,6 +9255,19 @@ def _hub_account_bundle(): } +def _hub_fetch_market(base=""): + from hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_exchange_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="gate", + ) + + def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): from hub_ohlcv_lib import fetch_ohlcv_for_hub @@ -9303,6 +9316,7 @@ try: views={"add_order": add_order, "add_key": add_key}, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, reconcile_hub_flat_fn=reconcile_hub_external_close, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 6162fa5..b698473 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -9255,6 +9255,19 @@ def _hub_account_bundle(): } +def _hub_fetch_market(base=""): + from hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_exchange_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="gate_bot", + ) + + def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): from hub_ohlcv_lib import fetch_ohlcv_for_hub @@ -9303,6 +9316,7 @@ try: views={"add_order": add_order, "add_key": add_key}, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, reconcile_hub_flat_fn=reconcile_hub_external_close, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 39918a6..966fb62 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -8723,6 +8723,19 @@ def _hub_account_bundle(): } +def _hub_fetch_market(base=""): + from hub_market_info_lib import fetch_usdt_swap_market_info + + return fetch_usdt_swap_market_info( + base_or_symbol=base, + normalize_symbol_input=normalize_symbol_input, + normalize_exchange_symbol=normalize_okx_symbol, + ensure_markets_loaded=ensure_markets_loaded, + exchange=exchange, + exchange_id="okx", + ) + + def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500): from hub_ohlcv_lib import fetch_ohlcv_for_hub @@ -8771,6 +8784,7 @@ try: views={"add_order": add_order, "add_key": add_key}, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, + market_fn=_hub_fetch_market, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, ) diff --git a/hub_bridge.py b/hub_bridge.py index 6c7b395..0616c79 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -214,6 +214,7 @@ def install_on_app( ohlcv_fn=None, account_fn=None, volume_rank_fn=None, + market_fn=None, reconcile_hub_flat_fn=None, risk_status_fn=None, user_close_fn=None, @@ -229,6 +230,7 @@ def install_on_app( "views": views, "ohlcv_fn": ohlcv_fn, "volume_rank_fn": volume_rank_fn, + "market_fn": market_fn, "reconcile_hub_flat_fn": reconcile_hub_flat_fn, "risk_status_fn": risk_status_fn, "user_close_fn": user_close_fn, @@ -602,6 +604,21 @@ def register_hub_routes(app): except Exception as e: return jsonify({"ok": False, "msg": str(e)}), 500 + @app.route("/api/hub/market") + @_hub_auth_required + def api_hub_market(): + fn = _ctx().get("market_fn") + if not callable(fn): + return jsonify({"ok": False, "msg": "该实例未配置合约信息接口"}), 501 + base = (request.args.get("base") or request.args.get("symbol") or "").strip() + try: + result = fn(base=base) + if isinstance(result, dict): + return jsonify(result) + return jsonify({"ok": False, "msg": "合约信息返回格式无效"}), 500 + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + @app.route("/api/hub/ohlcv") @_hub_auth_required def api_hub_ohlcv(): diff --git a/hub_calculator_lib.py b/hub_calculator_lib.py index 9c4c1fe..b4eed26 100644 --- a/hub_calculator_lib.py +++ b/hub_calculator_lib.py @@ -1,9 +1,9 @@ -"""中控历史测算:趋势回调 / 滚仓,以损定仓(无交易所精度,张数按公式估算)。""" +"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)。""" from __future__ import annotations from typing import Any, Callable, Optional, Tuple -from strategy_roll_lib import max_roll_legs, preview_roll +from strategy_roll_lib import max_roll_legs from strategy_trend_lib import ( build_trend_preview_level_rows, calc_risk_fraction, @@ -12,37 +12,20 @@ from strategy_trend_lib import ( ) DEFAULT_DCA_LEGS = 5 -DEFAULT_CONTRACT_SIZE = 1.0 MARGIN_BUFFER = 0.95 -def _identity_amount_precise(_symbol: str, amount: float) -> Optional[float]: - try: - v = float(amount) - except (TypeError, ValueError): - return None - if v <= 0: - return None - return round(v, 8) +def _resolve_market( + exchange_id: str, + base: str, +) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]: + from hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market - -def amount_from_margin( - margin_capital: float, - leverage: int, - price: float, - contract_size: float = DEFAULT_CONTRACT_SIZE, -) -> Optional[float]: - try: - margin = float(margin_capital) - lev = int(leverage) - px = float(price) - cs = float(contract_size) if contract_size else DEFAULT_CONTRACT_SIZE - except (TypeError, ValueError): - return None - if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0: - return None - notional = margin * lev - return notional / (px * cs) + market, err = get_calculator_market(exchange_id, base) + if err or not market: + return None, None, err or "无法解析合约" + amount_precise = make_amount_precise_fn_from_market(market) + return market, amount_precise, None def calc_trend_calculator( @@ -56,8 +39,15 @@ def calc_trend_calculator( add_upper: float, take_profit: float, dca_legs: int = DEFAULT_DCA_LEGS, - contract_size: float = DEFAULT_CONTRACT_SIZE, + exchange_id: str = "0", + base: str = "ETH", ) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + market, amount_precise, merr = _resolve_market(exchange_id, base) + if merr or not market or not amount_precise: + return None, merr or "无法解析合约" + contract_size = float(market.get("contract_size") or 1.0) + exchange_symbol = market["exchange_symbol"] + direction = (direction or "long").strip().lower() if direction not in ("long", "short"): return None, "方向须为 long 或 short" @@ -70,7 +60,7 @@ def calc_trend_calculator( upper = float(add_upper) tp = float(take_profit) legs = max(1, int(dca_legs)) - cs = float(contract_size) if contract_size else DEFAULT_CONTRACT_SIZE + cs = float(contract_size) if contract_size else 1.0 except (TypeError, ValueError): return None, "参数格式错误" if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0: @@ -90,9 +80,15 @@ def calc_trend_calculator( if margin_plan <= 0: return None, "计划保证金过小" - target_amt = amount_from_margin(margin_plan, lev, entry, cs) + target_amt = _amount_from_margin(margin_plan, lev, entry, cs) if target_amt is None or target_amt <= 0: return None, "无法计算计划张数,请检查入场价与杠杆" + target_amt = amount_precise(target_amt) + if target_amt is None or target_amt <= 0: + return None, "计划张数低于交易所最小精度" + + def _amount_precise(_symbol: str, amount: float) -> Optional[float]: + return amount_precise(amount) payload, err = compute_trend_plan_core( direction=direction, @@ -103,10 +99,10 @@ def calc_trend_calculator( leverage=lev, live_price=entry, target_order_amount=target_amt, - exchange_symbol="CALC", + exchange_symbol=exchange_symbol, dca_legs=legs, - amount_precise=_identity_amount_precise, - min_amount=0.0, + amount_precise=_amount_precise, + min_amount=float(market.get("min_amount") or 0.0), full_margin_buffer_ratio=MARGIN_BUFFER, ) if err: @@ -117,11 +113,14 @@ def calc_trend_calculator( payload["contract_size"] = cs preview, rows = build_trend_preview_level_rows(payload) - def _f(v: Any, nd: int = 4) -> Any: + px_dec = int(market.get("price_decimals") or 4) + amt_dec = int(market.get("amount_decimals") or 4) + + def _f(v: Any, nd: int | None = None) -> Any: if v is None: return None try: - return round(float(v), nd) + return round(float(v), nd if nd is not None else 8) except (TypeError, ValueError): return v @@ -130,9 +129,9 @@ def calc_trend_calculator( table.append( { "label": row.get("label"), - "price": _f(row.get("price"), 8), - "contracts": _f(row.get("contracts"), 8), - "avg_entry": _f(row.get("avg_entry"), 8), + "price": _f(row.get("price"), px_dec), + "contracts": _f(row.get("contracts"), amt_dec), + "avg_entry": _f(row.get("avg_entry"), px_dec), "profit_u": _f(row.get("profit_u")), "risk_u": _f(row.get("risk_u")), "rr": _f(row.get("rr"), 4), @@ -145,20 +144,40 @@ def calc_trend_calculator( "risk_percent": _f(rp, 2), "risk_budget_u": _f(preview.get("preview_risk_amount_u")), "leverage": lev, - "entry_price": _f(entry, 8), - "stop_loss": _f(sl, 8), - "add_upper": _f(upper, 8), - "take_profit": _f(tp, 8), + "entry_price": _f(entry, px_dec), + "stop_loss": _f(sl, px_dec), + "add_upper": _f(upper, px_dec), + "take_profit": _f(tp, px_dec), "plan_margin_u": _f(preview.get("plan_margin_capital")), - "target_contracts": _f(preview.get("target_order_amount"), 8), - "first_contracts": _f(preview.get("first_order_amount"), 8), + "target_contracts": _f(preview.get("target_order_amount"), amt_dec), + "first_contracts": _f(preview.get("first_order_amount"), amt_dec), "dca_legs": int(preview.get("dca_legs") or legs), "first_profit_u": _f(preview.get("preview_first_profit_u")), "first_rr": _f(preview.get("preview_target_rr"), 4), + "market": market, "rows": table, }, None +def _amount_from_margin( + margin_capital: float, + leverage: int, + price: float, + contract_size: float, +) -> Optional[float]: + try: + margin = float(margin_capital) + lev = int(leverage) + px = float(price) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None + if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0: + return None + notional = margin * lev + return notional / (px * cs) + + def _round(v: Any, nd: int = 4) -> Any: if v is None: return None @@ -182,28 +201,135 @@ def calc_initial_roll_qty( entry_price: float, stop_loss: float, risk_budget_usdt: float, + contract_size: float = 1.0, ) -> Tuple[Optional[float], Optional[str]]: """首仓以损定仓:打到初始止损亏损 = 风险预算。""" try: entry = float(entry_price) sl = float(stop_loss) budget = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 except (TypeError, ValueError): return None, "参数格式错误" - if entry <= 0 or sl <= 0 or budget <= 0: + if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0: return None, "入场价、止损与风险预算须大于 0" direction = (direction or "long").strip().lower() if direction == "short": - per_unit = sl - entry + per_unit = (sl - entry) * cs if per_unit <= 0: return None, "做空:止损价须高于首仓入场价" else: - per_unit = entry - sl + per_unit = (entry - sl) * cs if per_unit <= 0: return None, "做多:止损价须低于首仓入场价" return budget / per_unit, None +def solve_add_amount_for_total_risk( + direction: str, + qty_existing: float, + entry_existing: float, + add_price: float, + new_stop: float, + risk_budget_usdt: float, + contract_size: float = 1.0, +) -> Tuple[Optional[float], Optional[str]]: + """合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数。""" + try: + q1 = float(qty_existing) + e1 = float(entry_existing) + e2 = float(add_price) + sl = float(new_stop) + b = float(risk_budget_usdt) + cs = float(contract_size) if contract_size else 1.0 + except (TypeError, ValueError): + return None, "参数格式错误" + if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0: + return None, "持仓或风险预算无效" + direction = (direction or "long").strip().lower() + if direction == "short": + denom = sl - e2 + numer = b / cs - q1 * (sl - e1) + if denom <= 0: + return None, "做空:新止损须高于限价加仓价" + else: + denom = e2 - sl + numer = b / cs - q1 * (e1 - sl) + if denom <= 0: + return None, "做多:新止损须低于限价/市价加仓价" + q2 = numer / denom + if q2 <= 0: + return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)" + return q2, None + + +def _roll_leg_preview( + *, + direction: str, + qty_existing: float, + entry_existing: float, + take_profit: float, + add_price: float, + new_stop_loss: float, + risk_budget: float, + contract_size: float, + amount_precise: Callable[[float], Optional[float]], +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + direction = (direction or "long").strip().lower() + try: + tp = float(take_profit) + sl = float(new_stop_loss) + entry_add = float(add_price) + e1 = float(entry_existing) + except (TypeError, ValueError): + return None, "止损/止盈格式错误" + if sl <= 0 or tp <= 0 or entry_add <= 0: + return None, "止损与首仓止盈须大于0" + if direction == "long": + if sl >= entry_add: + return None, "做多:新止损须低于加仓价" + if tp <= e1: + return None, "做多:首仓止盈须高于当前持仓均价参考" + else: + if sl <= entry_add: + return None, "做空:新止损须高于加仓价" + if tp >= e1: + return None, "做空:首仓止盈须低于当前持仓均价参考" + + q2_raw, err = solve_add_amount_for_total_risk( + direction, + qty_existing, + entry_existing, + entry_add, + sl, + risk_budget, + contract_size, + ) + if err: + return None, err + q2 = amount_precise(float(q2_raw)) + if q2 is None or q2 <= 0: + return None, "加仓张数低于交易所最小精度" + new_qty = float(qty_existing) + float(q2) + new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty + cs = float(contract_size) if contract_size else 1.0 + if direction == "long": + loss_at_sl = (new_avg - sl) * new_qty * cs + reward_at_tp = (tp - new_avg) * new_qty * cs + else: + loss_at_sl = (sl - new_avg) * new_qty * cs + reward_at_tp = (new_avg - tp) * new_qty * cs + return { + "add_amount_raw": q2, + "qty_after": new_qty, + "avg_entry_after": new_avg, + "add_price": entry_add, + "new_stop_loss": sl, + "loss_at_sl_usdt": loss_at_sl, + "reward_at_tp_usdt": reward_at_tp, + }, None + + def calc_roll_calculator( *, direction: str, @@ -214,12 +340,21 @@ def calc_roll_calculator( take_profit: float, add_legs: list[dict[str, float]] | None = None, legs_done: int = 0, + exchange_id: str = "0", + base: str = "ETH", ) -> Tuple[Optional[dict[str, Any]], Optional[str]]: """ 滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓。 add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算。 legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进)。 """ + market, amount_precise, merr = _resolve_market(exchange_id, base) + if merr or not market or not amount_precise: + return None, merr or "无法解析合约" + contract_size = float(market.get("contract_size") or 1.0) + px_dec = int(market.get("price_decimals") or 4) + amt_dec = int(market.get("amount_decimals") or 4) + direction = (direction or "long").strip().lower() if direction not in ("long", "short"): return None, "方向须为 long 或 short" @@ -261,34 +396,38 @@ def calc_roll_calculator( return None, "做空:止盈价须低于首仓入场价" risk_budget = capital * (rp / 100.0) - qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget) + qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size) if err: return None, err if qty is None or qty <= 0: return None, "无法计算首仓张数" + qty_p = amount_precise(float(qty)) + if qty_p is None or qty_p <= 0: + return None, "首仓张数低于交易所最小精度" - qty_f = float(qty) + qty_f = float(qty_p) avg = entry rows: list[dict[str, Any]] = [] + cs = contract_size if direction == "long": - first_loss = (avg - initial_sl) * qty_f - first_profit = (tp - avg) * qty_f + first_loss = (avg - initial_sl) * qty_f * cs + first_profit = (tp - avg) * qty_f * cs else: - first_loss = (initial_sl - avg) * qty_f - first_profit = (avg - tp) * qty_f + first_loss = (initial_sl - avg) * qty_f * cs + first_profit = (avg - tp) * qty_f * cs rows.append( { "label": "首仓", "leg_index": 0, "already_done": False, - "entry_or_add_price": _round(entry, 8), - "stop_loss": _round(initial_sl, 8), - "add_contracts": _round(qty_f, 8), - "total_contracts": _round(qty_f, 8), - "avg_entry": _round(avg, 8), - "take_profit": _round(tp, 8), + "entry_or_add_price": _round(entry, px_dec), + "stop_loss": _round(initial_sl, px_dec), + "add_contracts": _round(qty_f, amt_dec), + "total_contracts": _round(qty_f, amt_dec), + "avg_entry": _round(avg, px_dec), + "take_profit": _round(tp, px_dec), "loss_at_sl_u": _round(first_loss), "profit_at_tp_u": _round(first_profit), "rr": _money_rr(first_profit, first_loss), @@ -300,18 +439,16 @@ def calc_roll_calculator( for i, leg in enumerate(legs_in): leg_no = i + 1 - preview, err = preview_roll( + preview, err = _roll_leg_preview( direction=direction, - symbol="CALC", qty_existing=current_qty, entry_existing=current_avg, - initial_take_profit=tp, - add_mode="market", - new_stop_loss=leg["new_stop_loss"], - risk_percent=rp, - capital_base_usdt=capital, + take_profit=tp, add_price=leg["add_price"], - legs_done=i, + new_stop_loss=leg["new_stop_loss"], + risk_budget=risk_budget, + contract_size=cs, + amount_precise=amount_precise, ) if err: return None, f"滚仓第 {leg_no} 次:{err}" @@ -327,12 +464,12 @@ def calc_roll_calculator( "label": f"滚仓{leg_no}", "leg_index": leg_no, "already_done": leg_no <= done, - "entry_or_add_price": _round(preview.get("add_price"), 8), - "stop_loss": _round(preview.get("new_stop_loss"), 8), - "add_contracts": _round(preview.get("add_amount_raw"), 8), - "total_contracts": _round(current_qty, 8), - "avg_entry": _round(current_avg, 8), - "take_profit": _round(tp, 8), + "entry_or_add_price": _round(preview.get("add_price"), px_dec), + "stop_loss": _round(preview.get("new_stop_loss"), px_dec), + "add_contracts": _round(preview.get("add_amount_raw"), amt_dec), + "total_contracts": _round(current_qty, amt_dec), + "avg_entry": _round(current_avg, px_dec), + "take_profit": _round(tp, px_dec), "loss_at_sl_u": _round(loss), "profit_at_tp_u": _round(reward), "rr": _money_rr(reward, loss), @@ -345,16 +482,17 @@ def calc_roll_calculator( "capital_usdt": _round(capital), "risk_percent": _round(rp, 2), "risk_budget_u": _round(risk_budget), - "entry_price": _round(entry, 8), - "stop_loss": _round(initial_sl, 8), - "take_profit": _round(tp, 8), + "entry_price": _round(entry, px_dec), + "stop_loss": _round(initial_sl, px_dec), + "take_profit": _round(tp, px_dec), "legs_done": done, "roll_legs_planned": len(legs_in), - "first_contracts": _round(qty_f, 8), + "first_contracts": _round(qty_f, amt_dec), "final_contracts": last.get("total_contracts"), "final_avg_entry": last.get("avg_entry"), "final_loss_at_sl_u": last.get("loss_at_sl_u"), "final_profit_at_tp_u": last.get("profit_at_tp_u"), "final_rr": last.get("rr"), + "market": market, "rows": rows, }, None diff --git a/hub_calculator_market_lib.py b/hub_calculator_market_lib.py new file mode 100644 index 0000000..d268c21 --- /dev/null +++ b/hub_calculator_market_lib.py @@ -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() diff --git a/hub_market_info_lib.py b/hub_market_info_lib.py new file mode 100644 index 0000000..6b76d7c --- /dev/null +++ b/hub_market_info_lib.py @@ -0,0 +1,81 @@ +"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致)。""" + +from __future__ import annotations + +from typing import Any, Callable, Optional, Tuple + +from hub_calculator_market_lib import ( + amount_decimals_from_exchange, + normalize_base_symbol, + price_decimals_from_exchange, + resolve_usdt_perp_symbol, +) +from hub_ohlcv_lib import normalize_price_tick, price_tick_from_market + + +def fetch_usdt_swap_market_info( + *, + base_or_symbol: str, + normalize_symbol_input: Callable[[str], str], + normalize_exchange_symbol: Callable[[str], str], + ensure_markets_loaded: Callable[[], None], + exchange: Any, + exchange_id: str = "", +) -> dict[str, Any]: + """供各实例 /api/hub/market 调用。""" + raw = str(base_or_symbol or "").strip() + if not raw: + return {"ok": False, "msg": "请输入币种,如 ETH"} + + try: + ensure_markets_loaded() + except Exception as exc: + return {"ok": False, "msg": f"加载市场失败: {exc}"} + + base_u = normalize_base_symbol(raw) + hub_sym = normalize_symbol_input(raw if base_u else raw) + try: + ex_sym = normalize_exchange_symbol(hub_sym) + except Exception: + ex_sym = hub_sym + + sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym) + if err and ex_sym: + markets = getattr(exchange, "markets", None) or {} + if ex_sym in markets: + sym = ex_sym + err = None + if err or not sym: + return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"} + + market = exchange.market(sym) + try: + contract_size = float(market.get("contractSize") or 1.0) + except (TypeError, ValueError): + contract_size = 1.0 + if contract_size <= 0: + contract_size = 1.0 + + price_tick = normalize_price_tick(price_tick_from_market(exchange, sym)) + amt_dec = amount_decimals_from_exchange(exchange, sym) + px_dec = price_decimals_from_exchange(exchange, sym, price_tick) + min_amount = None + try: + min_amount = float((market.get("limits") or {}).get("amount", {}).get("min")) + except (TypeError, ValueError): + min_amount = None + + base_out = (market.get("base") or base_u or "").upper() or base_u + return { + "ok": True, + "exchange": (exchange_id or "").strip().lower(), + "base": base_out, + "exchange_symbol": sym, + "display_symbol": f"{base_out}/USDT" if base_out else sym, + "contract_size": contract_size, + "price_tick": price_tick, + "price_decimals": px_dec, + "amount_decimals": amt_dec, + "min_amount": min_amount, + } + diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 91ad56c..3f68cbb 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -831,7 +831,8 @@ class TrendCalculatorBody(BaseModel): add_upper: float = Field(gt=0) take_profit: float = Field(gt=0) dca_legs: int = Field(default=5, ge=1, le=20) - contract_size: float = Field(default=1.0, gt=0) + exchange_id: str = "0" + base: str = "ETH" class RollAddLegBody(BaseModel): @@ -848,6 +849,25 @@ class RollCalculatorBody(BaseModel): take_profit: float = Field(gt=0) add_legs: list[RollAddLegBody] = Field(default_factory=list, max_length=3) legs_done: int = Field(default=0, ge=0, le=3) + exchange_id: str = "0" + base: str = "ETH" + + +@app.get("/api/calculator/exchanges") +def api_calculator_exchanges(): + from hub_calculator_market_lib import list_calculator_exchanges + + return {"ok": True, "data": list_calculator_exchanges()} + + +@app.get("/api/calculator/market") +def api_calculator_market(exchange_id: str = "0", base: str = "ETH"): + from hub_calculator_market_lib import get_calculator_market + + data, err = get_calculator_market(exchange_id, base) + if err: + return JSONResponse({"ok": False, "msg": err}, status_code=400) + return {"ok": True, "data": data} @app.post("/api/calculator/trend") @@ -864,7 +884,8 @@ def api_calculator_trend(body: TrendCalculatorBody): add_upper=body.add_upper, take_profit=body.take_profit, dca_legs=body.dca_legs, - contract_size=body.contract_size, + exchange_id=body.exchange_id, + base=body.base, ) if err: return JSONResponse({"ok": False, "msg": err}, status_code=400) @@ -884,6 +905,8 @@ def api_calculator_roll(body: RollCalculatorBody): take_profit=body.take_profit, add_legs=[leg.model_dump() for leg in body.add_legs], legs_done=body.legs_done, + exchange_id=body.exchange_id, + base=body.base, ) if err: return JSONResponse({"ok": False, "msg": err}, status_code=400) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 3adf9bb..b74bd8b 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -6845,6 +6845,8 @@ body.funds-fullscreen-open { .calc-field input, .calc-field select { + width: 100%; + box-sizing: border-box; background: var(--bg-elevated); border: 1px solid var(--border); color: var(--text); @@ -6854,6 +6856,28 @@ body.funds-fullscreen-open { font-family: var(--mono); } +.calc-field-span2 { + grid-column: 1 / -1; +} + +.calc-market-info { + padding: 0.55rem 0.55rem 0.55rem 0.75rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.82rem; + line-height: 1.45; + color: var(--muted, #9aa4b2); +} + +.calc-market-info strong { + color: var(--text, #e8ecf1); +} + +.calc-market-err { + color: #f87171; +} + .calc-actions { margin-top: 12px; } diff --git a/manual_trading_hub/static/calculator.js b/manual_trading_hub/static/calculator.js index fe9acc6..05c1484 100644 --- a/manual_trading_hub/static/calculator.js +++ b/manual_trading_hub/static/calculator.js @@ -6,6 +6,8 @@ if (!page) return; let inited = false; + const marketCache = {}; + let calculatorExchanges = []; function $(id) { return document.getElementById(id); @@ -16,7 +18,7 @@ .replace(/&/g, "&") .replace(//g, ">") - .replace(/"/g, """); + .replace(/\"/g, """); } function num(id) { @@ -26,6 +28,12 @@ return Number.isFinite(n) ? n : null; } + function text(id) { + const el = $(id); + if (!el) return ""; + return String(el.value || "").trim(); + } + function fmt(v, digits) { if (v == null || v === "") return "—"; const n = Number(v); @@ -47,16 +55,160 @@ return n > 0 ? "calc-pnl-profit" : "calc-pnl-loss"; } + function decimalsFromMarket(data) { + if (!data || !data.market) return { price: 4, amount: 4 }; + return { + price: Number(data.market.price_decimals), + amount: Number(data.market.amount_decimals), + }; + } + + function fmtMarketInfo(market, err) { + if (err) { + return '' + esc(err) + ""; + } + if (!market) return "—"; + const inst = market.exchange_name ? esc(market.exchange_name) + " · " : ""; + const parts = [ + inst + "" + esc(market.display_symbol || market.base || "") + " 永续", + "合约 " + esc(market.exchange_symbol || ""), + "乘数 " + fmt(market.contract_size, 8), + "价格精度 " + fmt(market.price_tick != null ? market.price_tick : Math.pow(10, -(market.price_decimals || 0))), + "张数精度 " + fmt(Math.pow(10, -(market.amount_decimals || 0))), + ]; + if (market.min_amount != null) { + parts.push("最小张数 " + fmt(market.min_amount, market.amount_decimals)); + } + return parts.join(" · "); + } + + function applyMarketSteps(prefix, market) { + const pxStep = + market && market.price_tick != null && Number(market.price_tick) > 0 + ? String(market.price_tick) + : market && market.price_decimals != null + ? String(Math.pow(10, -Number(market.price_decimals))) + : "any"; + const amtStep = + market && market.amount_decimals != null + ? String(Math.pow(10, -Number(market.amount_decimals))) + : "any"; + page.querySelectorAll("#" + prefix + "-form input[type='number']").forEach(function (el) { + if (el.classList.contains("calc-roll-leg-add") || el.classList.contains("calc-roll-leg-stop")) { + el.step = pxStep; + return; + } + if (el.id === prefix + "-capital" || el.id === prefix + "-risk" || el.id === prefix + "-leverage") { + return; + } + if (el.id === prefix + "-dca-legs" || el.id === prefix + "-legs-done") { + return; + } + el.step = pxStep; + }); + page.querySelectorAll(".calc-roll-leg-add, .calc-roll-leg-stop").forEach(function (el) { + el.step = pxStep; + }); + void amtStep; + } + + async function refreshMarket(prefix) { + const exchangeEl = $(prefix + "-exchange"); + const baseEl = $(prefix + "-base"); + const infoEl = $(prefix + "-market-info"); + if (!exchangeEl || !baseEl || !infoEl) return null; + const exchangeId = exchangeEl.value || (calculatorExchanges[0] && calculatorExchanges[0].id) || "0"; + const base = text(prefix + "-base") || "ETH"; + const cacheKey = exchangeId + ":" + base.toUpperCase(); + infoEl.innerHTML = "加载合约信息…"; + try { + const r = await fetch( + "/api/calculator/market?exchange_id=" + + encodeURIComponent(exchangeId) + + "&base=" + + encodeURIComponent(base), + { credentials: "same-origin" } + ); + const j = await r.json(); + if (!j.ok) { + infoEl.innerHTML = fmtMarketInfo(null, j.msg || "加载失败"); + marketCache[prefix] = null; + return null; + } + marketCache[prefix] = j.data; + marketCache[cacheKey] = j.data; + infoEl.innerHTML = fmtMarketInfo(j.data, null); + applyMarketSteps(prefix, j.data); + return j.data; + } catch (err) { + infoEl.innerHTML = fmtMarketInfo(null, String(err)); + marketCache[prefix] = null; + return null; + } + } + + function fillExchangeSelect(selectEl, selectedId) { + if (!selectEl) return; + selectEl.innerHTML = ""; + if (!calculatorExchanges.length) { + selectEl.innerHTML = ''; + return; + } + calculatorExchanges.forEach(function (ex) { + const opt = document.createElement("option"); + opt.value = String(ex.id); + opt.textContent = ex.name || ex.key || ex.id; + selectEl.appendChild(opt); + }); + const want = selectedId != null ? String(selectedId) : String(calculatorExchanges[0].id); + if ([].some.call(selectEl.options, function (o) { return o.value === want; })) { + selectEl.value = want; + } + } + + async function loadCalculatorExchanges() { + try { + const r = await fetch("/api/calculator/exchanges", { credentials: "same-origin" }); + const j = await r.json(); + calculatorExchanges = (j.ok && j.data) || []; + } catch (_err) { + calculatorExchanges = []; + } + fillExchangeSelect($("calc-trend-exchange")); + fillExchangeSelect($("calc-roll-exchange")); + } + + function bindMarket(prefix) { + const exchangeEl = $(prefix + "-exchange"); + const baseEl = $(prefix + "-base"); + if (!exchangeEl || !baseEl) return; + const run = function () { + void refreshMarket(prefix); + }; + if (!exchangeEl._calcMarketBound) { + exchangeEl._calcMarketBound = true; + exchangeEl.addEventListener("change", run); + } + if (!baseEl._calcMarketBound) { + baseEl._calcMarketBound = true; + baseEl.addEventListener("change", run); + baseEl.addEventListener("blur", run); + } + run(); + } + function syncTrendAddLabel() { const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long"; const lab = $("calc-trend-add-label"); if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价"; } - function renderTrendTable(rows) { + function renderTrendTable(rows, dec) { if (!rows || !rows.length) { return '
无档位数据
'; } + const px = dec.price != null ? dec.price : 4; + const amt = dec.amount != null ? dec.amount : 4; let html = '| 档位 | 触发价 | 张数 | 加仓后均价 | 止盈盈利 | 止损金额 | 盈亏比 | " + @@ -68,13 +220,13 @@ esc(r.label) + "" + "" + - fmt(r.price, 4) + + fmt(r.price, px) + " | " + "" + - fmt(r.contracts, 4) + + fmt(r.contracts, amt) + " | " + "" + - fmt(r.avg_entry, 4) + + fmt(r.avg_entry, px) + " | " + '合约" +
+ esc((data.market && data.market.display_symbol) || "—") +
+ "" +
" 计划保证金" +
fmt(data.plan_margin_u, 2) +
"U " +
@@ -106,10 +262,10 @@
fmt(data.risk_budget_u, 2) +
"U" +
"总张数" +
- fmt(data.target_contracts, 4) +
+ fmt(data.target_contracts, dec.amount) +
" " +
"首仓张数" +
- fmt(data.first_contracts, 4) +
+ fmt(data.first_contracts, dec.amount) +
" " +
'首仓止盈盈利 " +
"" +
- renderTrendTable(data.rows);
+ renderTrendTable(data.rows, dec);
}
function renderRollResult(data) {
const box = $("calc-roll-result");
if (!box) return;
+ const dec = decimalsFromMarket(data);
+ const px = dec.price != null ? dec.price : 4;
+ const amt = dec.amount != null ? dec.amount : 4;
box.classList.remove("hidden");
let table =
'
' +
+ " 合约" +
+ esc((data.market && data.market.display_symbol) || "—") +
+ " " +
"单次风险预算" +
fmt(data.risk_budget_u, 2) +
"U " +
"首仓张数(自动)" +
- fmt(data.first_contracts, 4) +
+ fmt(data.first_contracts, amt) +
" " +
"最终累计张数" +
- fmt(data.final_contracts, 4) +
+ fmt(data.final_contracts, amt) +
" " +
"最终均价" +
- fmt(data.final_avg_entry, 4) +
+ fmt(data.final_avg_entry, px) +
" " +
'最终止盈盈利" +
' "
@@ -303,6 +470,8 @@
e.preventDefault();
const body = {
direction: ($("calc-trend-direction") && $("calc-trend-direction").value) || "long",
+ exchange_id: ($("calc-trend-exchange") && $("calc-trend-exchange").value) || "0",
+ base: text("calc-trend-base") || "ETH",
capital_usdt: num("calc-trend-capital"),
risk_percent: num("calc-trend-risk"),
leverage: num("calc-trend-leverage"),
@@ -311,7 +480,6 @@
add_upper: num("calc-trend-add-upper"),
take_profit: num("calc-trend-tp"),
dca_legs: num("calc-trend-dca-legs") || 5,
- contract_size: num("calc-trend-contract-size") || 1,
};
try {
const r = await fetch("/api/calculator/trend", {
@@ -335,6 +503,8 @@
e.preventDefault();
const body = {
direction: ($("calc-roll-direction") && $("calc-roll-direction").value) || "long",
+ exchange_id: ($("calc-roll-exchange") && $("calc-roll-exchange").value) || "0",
+ base: text("calc-roll-base") || "ETH",
capital_usdt: num("calc-roll-capital"),
risk_percent: num("calc-roll-risk"),
entry_price: num("calc-roll-entry"),
@@ -361,9 +531,10 @@
}
}
- function bindOnce() {
+ async function bindOnce() {
if (inited) return;
inited = true;
+ await loadCalculatorExchanges();
const trendForm = $("calc-trend-form");
const rollForm = $("calc-roll-form");
const dirSel = $("calc-trend-direction");
@@ -374,10 +545,14 @@
syncTrendAddLabel();
}
bindRollLegsUI();
+ bindMarket("calc-trend");
+ bindMarket("calc-roll");
}
window.hubCalculatorPage = {
- init: bindOnce,
+ init: function () {
+ bindOnce();
+ },
destroy: function () {},
};
})();
diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html
index 1e6678f..e9e3bea 100644
--- a/manual_trading_hub/static/index.html
+++ b/manual_trading_hub/static/index.html
@@ -707,6 +707,17 @@
' +
- '' +
- '' +
+ '' +
+ '' +
" " +
'' +
"逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%。 |
|---|