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:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """);
|
.replace(/\"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 () {},
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -1,111 +1,162 @@
|
|||||||
"""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 = {
|
||||||
def test_trend_calculator_long_basic():
|
"exchange_id": "0",
|
||||||
data, err = calc_trend_calculator(
|
"exchange_key": "binance",
|
||||||
direction="long",
|
"exchange_name": "币安 · crypto_monitor_binance",
|
||||||
capital_usdt=1000,
|
"exchange_label": "币安 · crypto_monitor_binance",
|
||||||
risk_percent=5,
|
"base": "ETH",
|
||||||
leverage=5,
|
"exchange_symbol": "ETH/USDT:USDT",
|
||||||
entry_price=100,
|
"display_symbol": "ETH/USDT",
|
||||||
stop_loss=95,
|
"contract_size": 1.0,
|
||||||
add_upper=110,
|
"price_tick": 0.01,
|
||||||
take_profit=120,
|
"price_decimals": 2,
|
||||||
dca_legs=3,
|
"amount_decimals": 3,
|
||||||
contract_size=1,
|
"min_amount": 0.001,
|
||||||
)
|
}
|
||||||
assert err is None
|
|
||||||
assert data is not None
|
|
||||||
assert data["risk_budget_u"] == 50.0
|
|
||||||
assert len(data["rows"]) >= 2
|
|
||||||
assert data["rows"][0]["label"] == "首仓"
|
|
||||||
|
|
||||||
|
|
||||||
def test_trend_calculator_short_rejects_bad_bounds():
|
def _mock_resolve(_exchange="binance", _base="ETH"):
|
||||||
data, err = calc_trend_calculator(
|
return MOCK_MARKET, lambda amount: round(float(amount), 3), None
|
||||||
direction="short",
|
|
||||||
capital_usdt=1000,
|
|
||||||
risk_percent=5,
|
|
||||||
leverage=5,
|
|
||||||
entry_price=100,
|
|
||||||
stop_loss=90,
|
|
||||||
add_upper=110,
|
|
||||||
take_profit=80,
|
|
||||||
dca_legs=3,
|
|
||||||
)
|
|
||||||
assert data is None
|
|
||||||
assert err is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_roll_calculator_first_leg_auto():
|
class HubCalculatorLibTests(unittest.TestCase):
|
||||||
data, err = calc_roll_calculator(
|
@patch("hub_calculator_lib._resolve_market", return_value=_mock_resolve())
|
||||||
direction="long",
|
def test_trend_calculator_long_basic(self, _mock):
|
||||||
capital_usdt=1000,
|
data, err = calc_trend_calculator(
|
||||||
risk_percent=5,
|
direction="long",
|
||||||
entry_price=100,
|
capital_usdt=1000,
|
||||||
stop_loss=95,
|
risk_percent=5,
|
||||||
take_profit=120,
|
leverage=5,
|
||||||
add_legs=[],
|
entry_price=100,
|
||||||
legs_done=0,
|
stop_loss=95,
|
||||||
)
|
add_upper=110,
|
||||||
assert err is None
|
take_profit=120,
|
||||||
assert data is not None
|
dca_legs=3,
|
||||||
assert data["first_contracts"] == 10.0
|
exchange_id="0",
|
||||||
assert len(data["rows"]) == 1
|
base="ETH",
|
||||||
assert data["rows"][0]["loss_at_sl_u"] == 50.0
|
)
|
||||||
assert data["rows"][0]["profit_at_tp_u"] == 200.0
|
self.assertIsNone(err)
|
||||||
|
self.assertIsNotNone(data)
|
||||||
|
assert data is not None
|
||||||
|
self.assertEqual(data["risk_budget_u"], 50.0)
|
||||||
|
self.assertGreaterEqual(len(data["rows"]), 2)
|
||||||
|
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(self, _mock):
|
||||||
|
data, err = calc_trend_calculator(
|
||||||
|
direction="short",
|
||||||
|
capital_usdt=1000,
|
||||||
|
risk_percent=5,
|
||||||
|
leverage=5,
|
||||||
|
entry_price=100,
|
||||||
|
stop_loss=90,
|
||||||
|
add_upper=110,
|
||||||
|
take_profit=80,
|
||||||
|
dca_legs=3,
|
||||||
|
)
|
||||||
|
self.assertIsNone(data)
|
||||||
|
self.assertIsNotNone(err)
|
||||||
|
|
||||||
|
@patch("hub_calculator_lib._resolve_market", return_value=_mock_resolve())
|
||||||
|
def test_roll_calculator_first_leg_auto(self, _mock):
|
||||||
|
data, err = calc_roll_calculator(
|
||||||
|
direction="long",
|
||||||
|
capital_usdt=1000,
|
||||||
|
risk_percent=5,
|
||||||
|
entry_price=100,
|
||||||
|
stop_loss=95,
|
||||||
|
take_profit=120,
|
||||||
|
add_legs=[],
|
||||||
|
legs_done=0,
|
||||||
|
)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
self.assertIsNotNone(data)
|
||||||
|
assert data is not None
|
||||||
|
self.assertEqual(data["first_contracts"], 10.0)
|
||||||
|
self.assertEqual(len(data["rows"]), 1)
|
||||||
|
self.assertEqual(data["rows"][0]["loss_at_sl_u"], 50.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(self, _mock):
|
||||||
|
data, err = calc_roll_calculator(
|
||||||
|
direction="long",
|
||||||
|
capital_usdt=1000,
|
||||||
|
risk_percent=5,
|
||||||
|
entry_price=100,
|
||||||
|
stop_loss=95,
|
||||||
|
take_profit=120,
|
||||||
|
add_legs=[
|
||||||
|
{"add_price": 105, "new_stop_loss": 98},
|
||||||
|
{"add_price": 108, "new_stop_loss": 101},
|
||||||
|
],
|
||||||
|
legs_done=0,
|
||||||
|
)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
self.assertIsNotNone(data)
|
||||||
|
assert data is not None
|
||||||
|
self.assertEqual(len(data["rows"]), 3)
|
||||||
|
self.assertEqual(data["rows"][1]["label"], "滚仓1")
|
||||||
|
self.assertGreater(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(self, _mock):
|
||||||
|
data, err = calc_roll_calculator(
|
||||||
|
direction="long",
|
||||||
|
capital_usdt=1000,
|
||||||
|
risk_percent=5,
|
||||||
|
entry_price=100,
|
||||||
|
stop_loss=95,
|
||||||
|
take_profit=120,
|
||||||
|
add_legs=[
|
||||||
|
{"add_price": 105, "new_stop_loss": 98},
|
||||||
|
{"add_price": 108, "new_stop_loss": 101},
|
||||||
|
{"add_price": 110, "new_stop_loss": 103},
|
||||||
|
{"add_price": 112, "new_stop_loss": 105},
|
||||||
|
],
|
||||||
|
legs_done=0,
|
||||||
|
)
|
||||||
|
self.assertIsNone(data)
|
||||||
|
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_roll_calculator_chain_two_legs():
|
if __name__ == "__main__":
|
||||||
data, err = calc_roll_calculator(
|
unittest.main()
|
||||||
direction="long",
|
|
||||||
capital_usdt=1000,
|
|
||||||
risk_percent=5,
|
|
||||||
entry_price=100,
|
|
||||||
stop_loss=95,
|
|
||||||
take_profit=120,
|
|
||||||
add_legs=[
|
|
||||||
{"add_price": 105, "new_stop_loss": 98},
|
|
||||||
{"add_price": 108, "new_stop_loss": 101},
|
|
||||||
],
|
|
||||||
legs_done=0,
|
|
||||||
)
|
|
||||||
assert err is None
|
|
||||||
assert data is not None
|
|
||||||
assert len(data["rows"]) == 3
|
|
||||||
assert data["rows"][0]["label"] == "首仓"
|
|
||||||
assert data["rows"][1]["label"] == "滚仓1"
|
|
||||||
assert data["rows"][2]["label"] == "滚仓2"
|
|
||||||
assert float(data["final_contracts"]) > float(data["first_contracts"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_roll_calculator_rejects_too_many_legs():
|
|
||||||
data, err = calc_roll_calculator(
|
|
||||||
direction="long",
|
|
||||||
capital_usdt=1000,
|
|
||||||
risk_percent=5,
|
|
||||||
entry_price=100,
|
|
||||||
stop_loss=95,
|
|
||||||
take_profit=120,
|
|
||||||
add_legs=[
|
|
||||||
{"add_price": 105, "new_stop_loss": 98},
|
|
||||||
{"add_price": 108, "new_stop_loss": 101},
|
|
||||||
{"add_price": 110, "new_stop_loss": 103},
|
|
||||||
{"add_price": 112, "new_stop_loss": 105},
|
|
||||||
],
|
|
||||||
legs_done=0,
|
|
||||||
)
|
|
||||||
assert data is None
|
|
||||||
assert err is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_initial_roll_qty():
|
|
||||||
qty, err = calc_initial_roll_qty("long", 100, 95, 50)
|
|
||||||
assert err is None
|
|
||||||
assert qty == 10.0
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user