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
+14
View File
@@ -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): def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub from hub_ohlcv_lib import fetch_ohlcv_for_hub
@@ -9359,6 +9372,7 @@ try:
views={"add_order": add_order, "add_key": add_key}, views={"add_order": add_order, "add_key": add_key},
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
market_fn=_hub_fetch_market,
risk_status_fn=hub_account_risk_status, risk_status_fn=hub_account_risk_status,
user_close_fn=hub_user_initiated_close, user_close_fn=hub_user_initiated_close,
) )
+14
View File
@@ -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): def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub from hub_ohlcv_lib import fetch_ohlcv_for_hub
@@ -9303,6 +9316,7 @@ try:
views={"add_order": add_order, "add_key": add_key}, views={"add_order": add_order, "add_key": add_key},
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
market_fn=_hub_fetch_market,
reconcile_hub_flat_fn=reconcile_hub_external_close, reconcile_hub_flat_fn=reconcile_hub_external_close,
risk_status_fn=hub_account_risk_status, risk_status_fn=hub_account_risk_status,
user_close_fn=hub_user_initiated_close, user_close_fn=hub_user_initiated_close,
+14
View File
@@ -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): def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub from hub_ohlcv_lib import fetch_ohlcv_for_hub
@@ -9303,6 +9316,7 @@ try:
views={"add_order": add_order, "add_key": add_key}, views={"add_order": add_order, "add_key": add_key},
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
market_fn=_hub_fetch_market,
reconcile_hub_flat_fn=reconcile_hub_external_close, reconcile_hub_flat_fn=reconcile_hub_external_close,
risk_status_fn=hub_account_risk_status, risk_status_fn=hub_account_risk_status,
user_close_fn=hub_user_initiated_close, user_close_fn=hub_user_initiated_close,
+14
View File
@@ -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): def _hub_fetch_ohlcv(symbol, timeframe, since_ms=None, limit=500):
from hub_ohlcv_lib import fetch_ohlcv_for_hub from hub_ohlcv_lib import fetch_ohlcv_for_hub
@@ -8771,6 +8784,7 @@ try:
views={"add_order": add_order, "add_key": add_key}, views={"add_order": add_order, "add_key": add_key},
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
market_fn=_hub_fetch_market,
risk_status_fn=hub_account_risk_status, risk_status_fn=hub_account_risk_status,
user_close_fn=hub_user_initiated_close, user_close_fn=hub_user_initiated_close,
) )
+17
View File
@@ -214,6 +214,7 @@ def install_on_app(
ohlcv_fn=None, ohlcv_fn=None,
account_fn=None, account_fn=None,
volume_rank_fn=None, volume_rank_fn=None,
market_fn=None,
reconcile_hub_flat_fn=None, reconcile_hub_flat_fn=None,
risk_status_fn=None, risk_status_fn=None,
user_close_fn=None, user_close_fn=None,
@@ -229,6 +230,7 @@ def install_on_app(
"views": views, "views": views,
"ohlcv_fn": ohlcv_fn, "ohlcv_fn": ohlcv_fn,
"volume_rank_fn": volume_rank_fn, "volume_rank_fn": volume_rank_fn,
"market_fn": market_fn,
"reconcile_hub_flat_fn": reconcile_hub_flat_fn, "reconcile_hub_flat_fn": reconcile_hub_flat_fn,
"risk_status_fn": risk_status_fn, "risk_status_fn": risk_status_fn,
"user_close_fn": user_close_fn, "user_close_fn": user_close_fn,
@@ -602,6 +604,21 @@ def register_hub_routes(app):
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500 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") @app.route("/api/hub/ohlcv")
@_hub_auth_required @_hub_auth_required
def api_hub_ohlcv(): def api_hub_ohlcv():
+217 -79
View File
@@ -1,9 +1,9 @@
"""中控历史测算:趋势回调 / 滚仓,以损定仓(交易所精度张数按公式估算)。""" """中控历史测算:趋势回调 / 滚仓,以损定仓(交易所精度张数规则)。"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable, Optional, Tuple 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 ( from strategy_trend_lib import (
build_trend_preview_level_rows, build_trend_preview_level_rows,
calc_risk_fraction, calc_risk_fraction,
@@ -12,37 +12,20 @@ from strategy_trend_lib import (
) )
DEFAULT_DCA_LEGS = 5 DEFAULT_DCA_LEGS = 5
DEFAULT_CONTRACT_SIZE = 1.0
MARGIN_BUFFER = 0.95 MARGIN_BUFFER = 0.95
def _identity_amount_precise(_symbol: str, amount: float) -> Optional[float]: def _resolve_market(
try: exchange_id: str,
v = float(amount) base: str,
except (TypeError, ValueError): ) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]:
return None from hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market
if v <= 0:
return None
return round(v, 8)
market, err = get_calculator_market(exchange_id, base)
def amount_from_margin( if err or not market:
margin_capital: float, return None, None, err or "无法解析合约"
leverage: int, amount_precise = make_amount_precise_fn_from_market(market)
price: float, return market, amount_precise, None
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)
def calc_trend_calculator( def calc_trend_calculator(
@@ -56,8 +39,15 @@ def calc_trend_calculator(
add_upper: float, add_upper: float,
take_profit: float, take_profit: float,
dca_legs: int = DEFAULT_DCA_LEGS, 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]]: ) -> 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() direction = (direction or "long").strip().lower()
if direction not in ("long", "short"): if direction not in ("long", "short"):
return None, "方向须为 long 或 short" return None, "方向须为 long 或 short"
@@ -70,7 +60,7 @@ def calc_trend_calculator(
upper = float(add_upper) upper = float(add_upper)
tp = float(take_profit) tp = float(take_profit)
legs = max(1, int(dca_legs)) 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): except (TypeError, ValueError):
return None, "参数格式错误" return None, "参数格式错误"
if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0: 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: if margin_plan <= 0:
return None, "计划保证金过小" 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: if target_amt is None or target_amt <= 0:
return None, "无法计算计划张数,请检查入场价与杠杆" 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( payload, err = compute_trend_plan_core(
direction=direction, direction=direction,
@@ -103,10 +99,10 @@ def calc_trend_calculator(
leverage=lev, leverage=lev,
live_price=entry, live_price=entry,
target_order_amount=target_amt, target_order_amount=target_amt,
exchange_symbol="CALC", exchange_symbol=exchange_symbol,
dca_legs=legs, dca_legs=legs,
amount_precise=_identity_amount_precise, amount_precise=_amount_precise,
min_amount=0.0, min_amount=float(market.get("min_amount") or 0.0),
full_margin_buffer_ratio=MARGIN_BUFFER, full_margin_buffer_ratio=MARGIN_BUFFER,
) )
if err: if err:
@@ -117,11 +113,14 @@ def calc_trend_calculator(
payload["contract_size"] = cs payload["contract_size"] = cs
preview, rows = build_trend_preview_level_rows(payload) 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: if v is None:
return None return None
try: try:
return round(float(v), nd) return round(float(v), nd if nd is not None else 8)
except (TypeError, ValueError): except (TypeError, ValueError):
return v return v
@@ -130,9 +129,9 @@ def calc_trend_calculator(
table.append( table.append(
{ {
"label": row.get("label"), "label": row.get("label"),
"price": _f(row.get("price"), 8), "price": _f(row.get("price"), px_dec),
"contracts": _f(row.get("contracts"), 8), "contracts": _f(row.get("contracts"), amt_dec),
"avg_entry": _f(row.get("avg_entry"), 8), "avg_entry": _f(row.get("avg_entry"), px_dec),
"profit_u": _f(row.get("profit_u")), "profit_u": _f(row.get("profit_u")),
"risk_u": _f(row.get("risk_u")), "risk_u": _f(row.get("risk_u")),
"rr": _f(row.get("rr"), 4), "rr": _f(row.get("rr"), 4),
@@ -145,20 +144,40 @@ def calc_trend_calculator(
"risk_percent": _f(rp, 2), "risk_percent": _f(rp, 2),
"risk_budget_u": _f(preview.get("preview_risk_amount_u")), "risk_budget_u": _f(preview.get("preview_risk_amount_u")),
"leverage": lev, "leverage": lev,
"entry_price": _f(entry, 8), "entry_price": _f(entry, px_dec),
"stop_loss": _f(sl, 8), "stop_loss": _f(sl, px_dec),
"add_upper": _f(upper, 8), "add_upper": _f(upper, px_dec),
"take_profit": _f(tp, 8), "take_profit": _f(tp, px_dec),
"plan_margin_u": _f(preview.get("plan_margin_capital")), "plan_margin_u": _f(preview.get("plan_margin_capital")),
"target_contracts": _f(preview.get("target_order_amount"), 8), "target_contracts": _f(preview.get("target_order_amount"), amt_dec),
"first_contracts": _f(preview.get("first_order_amount"), 8), "first_contracts": _f(preview.get("first_order_amount"), amt_dec),
"dca_legs": int(preview.get("dca_legs") or legs), "dca_legs": int(preview.get("dca_legs") or legs),
"first_profit_u": _f(preview.get("preview_first_profit_u")), "first_profit_u": _f(preview.get("preview_first_profit_u")),
"first_rr": _f(preview.get("preview_target_rr"), 4), "first_rr": _f(preview.get("preview_target_rr"), 4),
"market": market,
"rows": table, "rows": table,
}, None }, 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: def _round(v: Any, nd: int = 4) -> Any:
if v is None: if v is None:
return None return None
@@ -182,28 +201,135 @@ def calc_initial_roll_qty(
entry_price: float, entry_price: float,
stop_loss: float, stop_loss: float,
risk_budget_usdt: float, risk_budget_usdt: float,
contract_size: float = 1.0,
) -> Tuple[Optional[float], Optional[str]]: ) -> Tuple[Optional[float], Optional[str]]:
"""首仓以损定仓:打到初始止损亏损 = 风险预算。""" """首仓以损定仓:打到初始止损亏损 = 风险预算。"""
try: try:
entry = float(entry_price) entry = float(entry_price)
sl = float(stop_loss) sl = float(stop_loss)
budget = float(risk_budget_usdt) budget = float(risk_budget_usdt)
cs = float(contract_size) if contract_size else 1.0
except (TypeError, ValueError): except (TypeError, ValueError):
return None, "参数格式错误" 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" return None, "入场价、止损与风险预算须大于 0"
direction = (direction or "long").strip().lower() direction = (direction or "long").strip().lower()
if direction == "short": if direction == "short":
per_unit = sl - entry per_unit = (sl - entry) * cs
if per_unit <= 0: if per_unit <= 0:
return None, "做空:止损价须高于首仓入场价" return None, "做空:止损价须高于首仓入场价"
else: else:
per_unit = entry - sl per_unit = (entry - sl) * cs
if per_unit <= 0: if per_unit <= 0:
return None, "做多:止损价须低于首仓入场价" return None, "做多:止损价须低于首仓入场价"
return budget / per_unit, 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( def calc_roll_calculator(
*, *,
direction: str, direction: str,
@@ -214,12 +340,21 @@ def calc_roll_calculator(
take_profit: float, take_profit: float,
add_legs: list[dict[str, float]] | None = None, add_legs: list[dict[str, float]] | None = None,
legs_done: int = 0, legs_done: int = 0,
exchange_id: str = "0",
base: str = "ETH",
) -> Tuple[Optional[dict[str, Any]], Optional[str]]: ) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
""" """
滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓。 滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓。
add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算。 add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算。
legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进)。 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() direction = (direction or "long").strip().lower()
if direction not in ("long", "short"): if direction not in ("long", "short"):
return None, "方向须为 long 或 short" return None, "方向须为 long 或 short"
@@ -261,34 +396,38 @@ def calc_roll_calculator(
return None, "做空:止盈价须低于首仓入场价" return None, "做空:止盈价须低于首仓入场价"
risk_budget = capital * (rp / 100.0) 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: if err:
return None, err return None, err
if qty is None or qty <= 0: if qty is None or qty <= 0:
return None, "无法计算首仓张数" 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 avg = entry
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
cs = contract_size
if direction == "long": if direction == "long":
first_loss = (avg - initial_sl) * qty_f first_loss = (avg - initial_sl) * qty_f * cs
first_profit = (tp - avg) * qty_f first_profit = (tp - avg) * qty_f * cs
else: else:
first_loss = (initial_sl - avg) * qty_f first_loss = (initial_sl - avg) * qty_f * cs
first_profit = (avg - tp) * qty_f first_profit = (avg - tp) * qty_f * cs
rows.append( rows.append(
{ {
"label": "首仓", "label": "首仓",
"leg_index": 0, "leg_index": 0,
"already_done": False, "already_done": False,
"entry_or_add_price": _round(entry, 8), "entry_or_add_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, 8), "stop_loss": _round(initial_sl, px_dec),
"add_contracts": _round(qty_f, 8), "add_contracts": _round(qty_f, amt_dec),
"total_contracts": _round(qty_f, 8), "total_contracts": _round(qty_f, amt_dec),
"avg_entry": _round(avg, 8), "avg_entry": _round(avg, px_dec),
"take_profit": _round(tp, 8), "take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(first_loss), "loss_at_sl_u": _round(first_loss),
"profit_at_tp_u": _round(first_profit), "profit_at_tp_u": _round(first_profit),
"rr": _money_rr(first_profit, first_loss), "rr": _money_rr(first_profit, first_loss),
@@ -300,18 +439,16 @@ def calc_roll_calculator(
for i, leg in enumerate(legs_in): for i, leg in enumerate(legs_in):
leg_no = i + 1 leg_no = i + 1
preview, err = preview_roll( preview, err = _roll_leg_preview(
direction=direction, direction=direction,
symbol="CALC",
qty_existing=current_qty, qty_existing=current_qty,
entry_existing=current_avg, entry_existing=current_avg,
initial_take_profit=tp, take_profit=tp,
add_mode="market",
new_stop_loss=leg["new_stop_loss"],
risk_percent=rp,
capital_base_usdt=capital,
add_price=leg["add_price"], 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: if err:
return None, f"滚仓第 {leg_no} 次:{err}" return None, f"滚仓第 {leg_no} 次:{err}"
@@ -327,12 +464,12 @@ def calc_roll_calculator(
"label": f"滚仓{leg_no}", "label": f"滚仓{leg_no}",
"leg_index": leg_no, "leg_index": leg_no,
"already_done": leg_no <= done, "already_done": leg_no <= done,
"entry_or_add_price": _round(preview.get("add_price"), 8), "entry_or_add_price": _round(preview.get("add_price"), px_dec),
"stop_loss": _round(preview.get("new_stop_loss"), 8), "stop_loss": _round(preview.get("new_stop_loss"), px_dec),
"add_contracts": _round(preview.get("add_amount_raw"), 8), "add_contracts": _round(preview.get("add_amount_raw"), amt_dec),
"total_contracts": _round(current_qty, 8), "total_contracts": _round(current_qty, amt_dec),
"avg_entry": _round(current_avg, 8), "avg_entry": _round(current_avg, px_dec),
"take_profit": _round(tp, 8), "take_profit": _round(tp, px_dec),
"loss_at_sl_u": _round(loss), "loss_at_sl_u": _round(loss),
"profit_at_tp_u": _round(reward), "profit_at_tp_u": _round(reward),
"rr": _money_rr(reward, loss), "rr": _money_rr(reward, loss),
@@ -345,16 +482,17 @@ def calc_roll_calculator(
"capital_usdt": _round(capital), "capital_usdt": _round(capital),
"risk_percent": _round(rp, 2), "risk_percent": _round(rp, 2),
"risk_budget_u": _round(risk_budget), "risk_budget_u": _round(risk_budget),
"entry_price": _round(entry, 8), "entry_price": _round(entry, px_dec),
"stop_loss": _round(initial_sl, 8), "stop_loss": _round(initial_sl, px_dec),
"take_profit": _round(tp, 8), "take_profit": _round(tp, px_dec),
"legs_done": done, "legs_done": done,
"roll_legs_planned": len(legs_in), "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_contracts": last.get("total_contracts"),
"final_avg_entry": last.get("avg_entry"), "final_avg_entry": last.get("avg_entry"),
"final_loss_at_sl_u": last.get("loss_at_sl_u"), "final_loss_at_sl_u": last.get("loss_at_sl_u"),
"final_profit_at_tp_u": last.get("profit_at_tp_u"), "final_profit_at_tp_u": last.get("profit_at_tp_u"),
"final_rr": last.get("rr"), "final_rr": last.get("rr"),
"market": market,
"rows": rows, "rows": rows,
}, None }, None
+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()
+81
View File
@@ -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,
}
+25 -2
View File
@@ -831,7 +831,8 @@ class TrendCalculatorBody(BaseModel):
add_upper: float = Field(gt=0) add_upper: float = Field(gt=0)
take_profit: float = Field(gt=0) take_profit: float = Field(gt=0)
dca_legs: int = Field(default=5, ge=1, le=20) 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): class RollAddLegBody(BaseModel):
@@ -848,6 +849,25 @@ class RollCalculatorBody(BaseModel):
take_profit: float = Field(gt=0) take_profit: float = Field(gt=0)
add_legs: list[RollAddLegBody] = Field(default_factory=list, max_length=3) add_legs: list[RollAddLegBody] = Field(default_factory=list, max_length=3)
legs_done: int = Field(default=0, ge=0, le=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") @app.post("/api/calculator/trend")
@@ -864,7 +884,8 @@ def api_calculator_trend(body: TrendCalculatorBody):
add_upper=body.add_upper, add_upper=body.add_upper,
take_profit=body.take_profit, take_profit=body.take_profit,
dca_legs=body.dca_legs, dca_legs=body.dca_legs,
contract_size=body.contract_size, exchange_id=body.exchange_id,
base=body.base,
) )
if err: if err:
return JSONResponse({"ok": False, "msg": err}, status_code=400) return JSONResponse({"ok": False, "msg": err}, status_code=400)
@@ -884,6 +905,8 @@ def api_calculator_roll(body: RollCalculatorBody):
take_profit=body.take_profit, take_profit=body.take_profit,
add_legs=[leg.model_dump() for leg in body.add_legs], add_legs=[leg.model_dump() for leg in body.add_legs],
legs_done=body.legs_done, legs_done=body.legs_done,
exchange_id=body.exchange_id,
base=body.base,
) )
if err: if err:
return JSONResponse({"ok": False, "msg": err}, status_code=400) return JSONResponse({"ok": False, "msg": err}, status_code=400)
+24
View File
@@ -6845,6 +6845,8 @@ body.funds-fullscreen-open {
.calc-field input, .calc-field input,
.calc-field select { .calc-field select {
width: 100%;
box-sizing: border-box;
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); color: var(--text);
@@ -6854,6 +6856,28 @@ body.funds-fullscreen-open {
font-family: var(--mono); 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 { .calc-actions {
margin-top: 12px; margin-top: 12px;
} }
+197 -22
View File
@@ -6,6 +6,8 @@
if (!page) return; if (!page) return;
let inited = false; let inited = false;
const marketCache = {};
let calculatorExchanges = [];
function $(id) { function $(id) {
return document.getElementById(id); return document.getElementById(id);
@@ -16,7 +18,7 @@
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/\"/g, "&quot;");
} }
function num(id) { function num(id) {
@@ -26,6 +28,12 @@
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
} }
function text(id) {
const el = $(id);
if (!el) return "";
return String(el.value || "").trim();
}
function fmt(v, digits) { function fmt(v, digits) {
if (v == null || v === "") return "—"; if (v == null || v === "") return "—";
const n = Number(v); const n = Number(v);
@@ -47,16 +55,160 @@
return n > 0 ? "calc-pnl-profit" : "calc-pnl-loss"; 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 '<span class="calc-market-err">' + esc(err) + "</span>";
}
if (!market) return "—";
const inst = market.exchange_name ? esc(market.exchange_name) + " · " : "";
const parts = [
inst + "<strong>" + esc(market.display_symbol || market.base || "") + "</strong> 永续",
"合约 " + 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 = '<option value="">无已启用交易所</option>';
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() { function syncTrendAddLabel() {
const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long"; const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long";
const lab = $("calc-trend-add-label"); const lab = $("calc-trend-add-label");
if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价"; if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价";
} }
function renderTrendTable(rows) { function renderTrendTable(rows, dec) {
if (!rows || !rows.length) { if (!rows || !rows.length) {
return '<p class="calc-empty">无档位数据</p>'; return '<p class="calc-empty">无档位数据</p>';
} }
const px = dec.price != null ? dec.price : 4;
const amt = dec.amount != null ? dec.amount : 4;
let html = let html =
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' + '<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
"<th>档位</th><th>触发价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利</th><th>止损金额</th><th>盈亏比</th>" + "<th>档位</th><th>触发价</th><th>张数</th><th>加仓后均价</th><th>止盈盈利</th><th>止损金额</th><th>盈亏比</th>" +
@@ -68,13 +220,13 @@
esc(r.label) + esc(r.label) +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.price, 4) + fmt(r.price, px) +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.contracts, 4) + fmt(r.contracts, amt) +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.avg_entry, 4) + fmt(r.avg_entry, px) +
"</td>" + "</td>" +
'<td class="' + '<td class="' +
pnlClass(r.profit_u) + pnlClass(r.profit_u) +
@@ -96,9 +248,13 @@
function renderTrendResult(data) { function renderTrendResult(data) {
const box = $("calc-trend-result"); const box = $("calc-trend-result");
if (!box) return; if (!box) return;
const dec = decimalsFromMarket(data);
box.classList.remove("hidden"); box.classList.remove("hidden");
box.innerHTML = box.innerHTML =
'<div class="calc-summary">' + '<div class="calc-summary">' +
"<div><span>合约</span><strong>" +
esc((data.market && data.market.display_symbol) || "—") +
"</strong></div>" +
"<div><span>计划保证金</span><strong>" + "<div><span>计划保证金</span><strong>" +
fmt(data.plan_margin_u, 2) + fmt(data.plan_margin_u, 2) +
"U</strong></div>" + "U</strong></div>" +
@@ -106,10 +262,10 @@
fmt(data.risk_budget_u, 2) + fmt(data.risk_budget_u, 2) +
"U</strong></div>" + "U</strong></div>" +
"<div><span>总张数</span><strong>" + "<div><span>总张数</span><strong>" +
fmt(data.target_contracts, 4) + fmt(data.target_contracts, dec.amount) +
"</strong></div>" + "</strong></div>" +
"<div><span>首仓张数</span><strong>" + "<div><span>首仓张数</span><strong>" +
fmt(data.first_contracts, 4) + fmt(data.first_contracts, dec.amount) +
"</strong></div>" + "</strong></div>" +
'<div><span>首仓止盈盈利</span><strong class="' + '<div><span>首仓止盈盈利</span><strong class="' +
pnlClass(data.first_profit_u) + pnlClass(data.first_profit_u) +
@@ -120,16 +276,19 @@
(data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") + (data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") +
"</strong></div>" + "</strong></div>" +
"</div>" + "</div>" +
renderTrendTable(data.rows); renderTrendTable(data.rows, dec);
} }
function renderRollResult(data) { function renderRollResult(data) {
const box = $("calc-roll-result"); const box = $("calc-roll-result");
if (!box) return; 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"); box.classList.remove("hidden");
let table = let table =
'<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' + '<div class="calc-table-wrap"><table class="calc-table"><thead><tr>' +
"<th>阶段</th><th>入场/加仓价</th><th>统一止损</th><th>本次张数</th><th>累计张数</th><th>均价</th><th>止损亏损</th><th>止盈盈利</th><th>盈亏比</th>" + "<th>阶段</th><th>入场/加仓价</th><th>统一止损</th><th>本次张数</th><th>累计张数</th><th>均价</th><th>打到止损总亏</th><th>止盈盈利</th><th>盈亏比</th>" +
"</tr></thead><tbody>"; "</tr></thead><tbody>";
(data.rows || []).forEach(function (r) { (data.rows || []).forEach(function (r) {
const tag = r.already_done ? ' <span class="calc-done-tag">已完成</span>' : ""; const tag = r.already_done ? ' <span class="calc-done-tag">已完成</span>' : "";
@@ -140,19 +299,19 @@
tag + tag +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.entry_or_add_price, 4) + fmt(r.entry_or_add_price, px) +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.stop_loss, 4) + fmt(r.stop_loss, px) +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.add_contracts, 4) + fmt(r.add_contracts, amt) +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.total_contracts, 4) + fmt(r.total_contracts, amt) +
"</td>" + "</td>" +
"<td>" + "<td>" +
fmt(r.avg_entry, 4) + fmt(r.avg_entry, px) +
"</td>" + "</td>" +
'<td class="calc-pnl-loss">' + '<td class="calc-pnl-loss">' +
fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) + fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) +
@@ -170,17 +329,20 @@
table += "</tbody></table></div>"; table += "</tbody></table></div>";
box.innerHTML = box.innerHTML =
'<div class="calc-summary">' + '<div class="calc-summary">' +
"<div><span>合约</span><strong>" +
esc((data.market && data.market.display_symbol) || "—") +
"</strong></div>" +
"<div><span>单次风险预算</span><strong>" + "<div><span>单次风险预算</span><strong>" +
fmt(data.risk_budget_u, 2) + fmt(data.risk_budget_u, 2) +
"U</strong></div>" + "U</strong></div>" +
"<div><span>首仓张数(自动)</span><strong>" + "<div><span>首仓张数(自动)</span><strong>" +
fmt(data.first_contracts, 4) + fmt(data.first_contracts, amt) +
"</strong></div>" + "</strong></div>" +
"<div><span>最终累计张数</span><strong>" + "<div><span>最终累计张数</span><strong>" +
fmt(data.final_contracts, 4) + fmt(data.final_contracts, amt) +
"</strong></div>" + "</strong></div>" +
"<div><span>最终均价</span><strong>" + "<div><span>最终均价</span><strong>" +
fmt(data.final_avg_entry, 4) + fmt(data.final_avg_entry, px) +
"</strong></div>" + "</strong></div>" +
'<div><span>最终止盈盈利</span><strong class="' + '<div><span>最终止盈盈利</span><strong class="' +
pnlClass(data.final_profit_at_tp_u) + pnlClass(data.final_profit_at_tp_u) +
@@ -209,6 +371,7 @@
} }
function rollLegRowHtml(index) { function rollLegRowHtml(index) {
const step = (marketCache["calc-roll"] && marketCache["calc-roll"].price_tick) || "any";
return ( return (
'<div class="calc-roll-leg" data-leg-index="' + '<div class="calc-roll-leg" data-leg-index="' +
index + index +
@@ -217,8 +380,12 @@
index + index +
"</div>" + "</div>" +
'<div class="calc-roll-leg-grid">' + '<div class="calc-roll-leg-grid">' +
'<label class="calc-field"><span>加仓价</span><input type="number" class="calc-roll-leg-add" min="0" step="any" required /></label>' + '<label class="calc-field"><span>加仓价</span><input type="number" class="calc-roll-leg-add" min="0" step="' +
'<label class="calc-field"><span>新统一止损</span><input type="number" class="calc-roll-leg-stop" min="0" step="any" required /></label>' + esc(step) +
'" required /></label>' +
'<label class="calc-field"><span>新统一止损</span><input type="number" class="calc-roll-leg-stop" min="0" step="' +
esc(step) +
'" required /></label>' +
"</div>" + "</div>" +
'<button type="button" class="ghost danger calc-roll-leg-remove">删除</button>' + '<button type="button" class="ghost danger calc-roll-leg-remove">删除</button>' +
"</div>" "</div>"
@@ -303,6 +470,8 @@
e.preventDefault(); e.preventDefault();
const body = { const body = {
direction: ($("calc-trend-direction") && $("calc-trend-direction").value) || "long", 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"), capital_usdt: num("calc-trend-capital"),
risk_percent: num("calc-trend-risk"), risk_percent: num("calc-trend-risk"),
leverage: num("calc-trend-leverage"), leverage: num("calc-trend-leverage"),
@@ -311,7 +480,6 @@
add_upper: num("calc-trend-add-upper"), add_upper: num("calc-trend-add-upper"),
take_profit: num("calc-trend-tp"), take_profit: num("calc-trend-tp"),
dca_legs: num("calc-trend-dca-legs") || 5, dca_legs: num("calc-trend-dca-legs") || 5,
contract_size: num("calc-trend-contract-size") || 1,
}; };
try { try {
const r = await fetch("/api/calculator/trend", { const r = await fetch("/api/calculator/trend", {
@@ -335,6 +503,8 @@
e.preventDefault(); e.preventDefault();
const body = { const body = {
direction: ($("calc-roll-direction") && $("calc-roll-direction").value) || "long", 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"), capital_usdt: num("calc-roll-capital"),
risk_percent: num("calc-roll-risk"), risk_percent: num("calc-roll-risk"),
entry_price: num("calc-roll-entry"), entry_price: num("calc-roll-entry"),
@@ -361,9 +531,10 @@
} }
} }
function bindOnce() { async function bindOnce() {
if (inited) return; if (inited) return;
inited = true; inited = true;
await loadCalculatorExchanges();
const trendForm = $("calc-trend-form"); const trendForm = $("calc-trend-form");
const rollForm = $("calc-roll-form"); const rollForm = $("calc-roll-form");
const dirSel = $("calc-trend-direction"); const dirSel = $("calc-trend-direction");
@@ -374,10 +545,14 @@
syncTrendAddLabel(); syncTrendAddLabel();
} }
bindRollLegsUI(); bindRollLegsUI();
bindMarket("calc-trend");
bindMarket("calc-roll");
} }
window.hubCalculatorPage = { window.hubCalculatorPage = {
init: bindOnce, init: function () {
bindOnce();
},
destroy: function () {}, destroy: function () {},
}; };
})(); })();
+22 -4
View File
@@ -707,6 +707,17 @@
<p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%。</p> <p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%。</p>
<form id="calc-trend-form" class="calc-form"> <form id="calc-trend-form" class="calc-form">
<div class="calc-form-grid"> <div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-trend-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-trend-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-trend-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field"> <label class="calc-field">
<span>交易资金 (U)</span> <span>交易资金 (U)</span>
<input id="calc-trend-capital" type="number" min="0.01" step="any" value="1000" required /> <input id="calc-trend-capital" type="number" min="0.01" step="any" value="1000" required />
@@ -746,10 +757,6 @@
<span>补仓档数</span> <span>补仓档数</span>
<input id="calc-trend-dca-legs" type="number" min="1" max="20" step="1" value="5" /> <input id="calc-trend-dca-legs" type="number" min="1" max="20" step="1" value="5" />
</label> </label>
<label class="calc-field">
<span>合约乘数</span>
<input id="calc-trend-contract-size" type="number" min="0.0001" step="any" value="1" title="USDT 线性合约默认 1" />
</label>
</div> </div>
<div class="calc-actions"> <div class="calc-actions">
<button type="submit" class="primary">计算</button> <button type="submit" class="primary">计算</button>
@@ -763,6 +770,17 @@
<p class="calc-hint">首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变。最多 3 次滚仓。</p> <p class="calc-hint">首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变。最多 3 次滚仓。</p>
<form id="calc-roll-form" class="calc-form"> <form id="calc-roll-form" class="calc-form">
<div class="calc-form-grid"> <div class="calc-form-grid">
<label class="calc-field">
<span>交易所</span>
<select id="calc-roll-exchange" required></select>
</label>
<label class="calc-field">
<span>币种</span>
<input id="calc-roll-base" type="text" value="ETH" placeholder="如 ETH" required autocomplete="off" />
</label>
<div class="calc-field calc-field-span2">
<div id="calc-roll-market-info" class="calc-market-info">ETH/USDT · 加载合约信息…</div>
</div>
<label class="calc-field"> <label class="calc-field">
<span>交易资金 (U)</span> <span>交易资金 (U)</span>
<input id="calc-roll-capital" type="number" min="0.01" step="any" value="1000" required /> <input id="calc-roll-capital" type="number" min="0.01" step="any" value="1000" required />
+84 -33
View File
@@ -1,13 +1,38 @@
"""hub_calculator_lib 测算逻辑。""" """hub_calculator_lib 测算逻辑。"""
import unittest
from unittest.mock import patch
from hub_calculator_lib import ( from hub_calculator_lib import (
calc_initial_roll_qty, calc_initial_roll_qty,
calc_roll_calculator, calc_roll_calculator,
calc_trend_calculator, calc_trend_calculator,
solve_add_amount_for_total_risk,
) )
MOCK_MARKET = {
"exchange_id": "0",
"exchange_key": "binance",
"exchange_name": "币安 · crypto_monitor_binance",
"exchange_label": "币安 · crypto_monitor_binance",
"base": "ETH",
"exchange_symbol": "ETH/USDT:USDT",
"display_symbol": "ETH/USDT",
"contract_size": 1.0,
"price_tick": 0.01,
"price_decimals": 2,
"amount_decimals": 3,
"min_amount": 0.001,
}
def test_trend_calculator_long_basic():
def _mock_resolve(_exchange="binance", _base="ETH"):
return MOCK_MARKET, lambda amount: round(float(amount), 3), None
class HubCalculatorLibTests(unittest.TestCase):
@patch("hub_calculator_lib._resolve_market", return_value=_mock_resolve())
def test_trend_calculator_long_basic(self, _mock):
data, err = calc_trend_calculator( data, err = calc_trend_calculator(
direction="long", direction="long",
capital_usdt=1000, capital_usdt=1000,
@@ -18,16 +43,19 @@ def test_trend_calculator_long_basic():
add_upper=110, add_upper=110,
take_profit=120, take_profit=120,
dca_legs=3, dca_legs=3,
contract_size=1, exchange_id="0",
base="ETH",
) )
assert err is None self.assertIsNone(err)
self.assertIsNotNone(data)
assert data is not None assert data is not None
assert data["risk_budget_u"] == 50.0 self.assertEqual(data["risk_budget_u"], 50.0)
assert len(data["rows"]) >= 2 self.assertGreaterEqual(len(data["rows"]), 2)
assert data["rows"][0]["label"] == "首仓" self.assertEqual(data["rows"][0]["label"], "首仓")
self.assertEqual(data["market"]["display_symbol"], "ETH/USDT")
@patch("hub_calculator_lib._resolve_market", return_value=_mock_resolve())
def test_trend_calculator_short_rejects_bad_bounds(): def test_trend_calculator_short_rejects_bad_bounds(self, _mock):
data, err = calc_trend_calculator( data, err = calc_trend_calculator(
direction="short", direction="short",
capital_usdt=1000, capital_usdt=1000,
@@ -39,11 +67,11 @@ def test_trend_calculator_short_rejects_bad_bounds():
take_profit=80, take_profit=80,
dca_legs=3, dca_legs=3,
) )
assert data is None self.assertIsNone(data)
assert err is not None self.assertIsNotNone(err)
@patch("hub_calculator_lib._resolve_market", return_value=_mock_resolve())
def test_roll_calculator_first_leg_auto(): def test_roll_calculator_first_leg_auto(self, _mock):
data, err = calc_roll_calculator( data, err = calc_roll_calculator(
direction="long", direction="long",
capital_usdt=1000, capital_usdt=1000,
@@ -54,15 +82,16 @@ def test_roll_calculator_first_leg_auto():
add_legs=[], add_legs=[],
legs_done=0, legs_done=0,
) )
assert err is None self.assertIsNone(err)
self.assertIsNotNone(data)
assert data is not None assert data is not None
assert data["first_contracts"] == 10.0 self.assertEqual(data["first_contracts"], 10.0)
assert len(data["rows"]) == 1 self.assertEqual(len(data["rows"]), 1)
assert data["rows"][0]["loss_at_sl_u"] == 50.0 self.assertEqual(data["rows"][0]["loss_at_sl_u"], 50.0)
assert data["rows"][0]["profit_at_tp_u"] == 200.0 self.assertEqual(data["rows"][0]["profit_at_tp_u"], 200.0)
@patch("hub_calculator_lib._resolve_market", return_value=_mock_resolve())
def test_roll_calculator_chain_two_legs(): def test_roll_calculator_chain_two_legs(self, _mock):
data, err = calc_roll_calculator( data, err = calc_roll_calculator(
direction="long", direction="long",
capital_usdt=1000, capital_usdt=1000,
@@ -76,16 +105,15 @@ def test_roll_calculator_chain_two_legs():
], ],
legs_done=0, legs_done=0,
) )
assert err is None self.assertIsNone(err)
self.assertIsNotNone(data)
assert data is not None assert data is not None
assert len(data["rows"]) == 3 self.assertEqual(len(data["rows"]), 3)
assert data["rows"][0]["label"] == "首仓" self.assertEqual(data["rows"][1]["label"], "滚仓1")
assert data["rows"][1]["label"] == "滚仓1" self.assertGreater(float(data["final_contracts"]), float(data["first_contracts"]))
assert data["rows"][2]["label"] == "滚仓2"
assert float(data["final_contracts"]) > float(data["first_contracts"])
@patch("hub_calculator_lib._resolve_market", return_value=_mock_resolve())
def test_roll_calculator_rejects_too_many_legs(): def test_roll_calculator_rejects_too_many_legs(self, _mock):
data, err = calc_roll_calculator( data, err = calc_roll_calculator(
direction="long", direction="long",
capital_usdt=1000, capital_usdt=1000,
@@ -101,11 +129,34 @@ def test_roll_calculator_rejects_too_many_legs():
], ],
legs_done=0, legs_done=0,
) )
assert data is None self.assertIsNone(data)
assert err is not None self.assertIsNotNone(err)
def test_initial_roll_qty(self):
qty, err = calc_initial_roll_qty("long", 100, 95, 50, 1.0)
self.assertIsNone(err)
self.assertEqual(qty, 10.0)
def test_initial_roll_qty_with_contract_size(self):
qty, err = calc_initial_roll_qty("long", 100, 95, 50, 0.1)
self.assertIsNone(err)
self.assertEqual(qty, 100.0)
def test_solve_add_with_contract_size(self):
q2, err = solve_add_amount_for_total_risk(
"long",
qty_existing=10.0,
entry_existing=100.0,
add_price=105.0,
new_stop=98.0,
risk_budget_usdt=50.0,
contract_size=1.0,
)
self.assertIsNone(err)
self.assertIsNotNone(q2)
assert q2 is not None
self.assertGreater(q2, 0)
def test_initial_roll_qty(): if __name__ == "__main__":
qty, err = calc_initial_roll_qty("long", 100, 95, 50) unittest.main()
assert err is None
assert qty == 10.0
+107
View File
@@ -0,0 +1,107 @@
"""hub_calculator_market_lib 合约解析。"""
import unittest
from unittest.mock import patch
from hub_calculator_market_lib import (
amount_decimals_from_exchange,
find_exchange,
get_calculator_market,
list_calculator_exchanges,
make_amount_precise_fn_from_market,
normalize_base_symbol,
resolve_usdt_perp_symbol,
)
class FakeExchange:
def __init__(self, markets: dict):
self.markets = markets
def market(self, symbol: str):
return self.markets[symbol]
def amount_to_precision(self, symbol: str, amount: float) -> str:
return f"{float(amount):.3f}"
class HubCalculatorMarketLibTests(unittest.TestCase):
def test_normalize_base_symbol(self):
self.assertEqual(normalize_base_symbol("eth"), "ETH")
self.assertEqual(normalize_base_symbol("ETH/USDT:USDT"), "ETH")
self.assertEqual(normalize_base_symbol("ETHUSDT"), "ETH")
def test_resolve_usdt_perp_symbol(self):
ex = FakeExchange(
{
"ETH/USDT:USDT": {
"base": "ETH",
"quote": "USDT",
"swap": True,
"active": True,
"contractSize": 1.0,
"limits": {"amount": {"min": 0.001}},
"precision": {"price": 2, "amount": 3},
}
}
)
sym, err = resolve_usdt_perp_symbol(ex, "ETH")
self.assertIsNone(err)
self.assertEqual(sym, "ETH/USDT:USDT")
def test_amount_decimals_from_exchange(self):
ex = FakeExchange({})
self.assertEqual(amount_decimals_from_exchange(ex, "ETH/USDT:USDT"), 3)
def test_make_amount_precise_fn_from_market(self):
fn = make_amount_precise_fn_from_market({"amount_decimals": 3, "min_amount": 0.001})
self.assertEqual(fn(1.23456), 1.234)
self.assertIsNone(fn(0.0001))
@patch("hub_calculator_market_lib.fetch_instance_market_sync")
def test_get_calculator_market_from_instance(self, fetch_mock):
fetch_mock.return_value = {
"ok": True,
"base": "ETH",
"exchange_symbol": "ETH/USDT:USDT",
"display_symbol": "ETH/USDT",
"contract_size": 0.01,
"price_tick": 0.01,
"price_decimals": 2,
"amount_decimals": 2,
"min_amount": 0.01,
}
ex = {
"id": "0",
"key": "binance",
"name": "币安 · crypto_monitor_binance",
"enabled": True,
"flask_url": "http://127.0.0.1:5001",
}
data, err = get_calculator_market("0", "ETH", ex=ex)
self.assertIsNone(err)
self.assertIsNotNone(data)
assert data is not None
self.assertEqual(data["exchange_id"], "0")
self.assertEqual(data["exchange_name"], "币安 · crypto_monitor_binance")
self.assertEqual(data["contract_size"], 0.01)
@patch("hub_calculator_market_lib.enabled_exchanges")
def test_list_calculator_exchanges(self, enabled_mock):
enabled_mock.return_value = [
{"id": "0", "key": "binance", "name": "币安", "enabled": True},
]
rows = list_calculator_exchanges()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["id"], "0")
def test_find_exchange_by_id(self):
with patch(
"hub_calculator_market_lib.load_settings",
return_value={"exchanges": [{"id": "2", "key": "gate", "name": "Gate"}]},
):
self.assertEqual(find_exchange("2")["key"], "gate")
if __name__ == "__main__":
unittest.main()