diff --git a/hub_calculator_lib.py b/hub_calculator_lib.py index 699fe7a..9c4c1fe 100644 --- a/hub_calculator_lib.py +++ b/hub_calculator_lib.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any, Callable, Optional, Tuple -from strategy_roll_lib import preview_roll +from strategy_roll_lib import max_roll_legs, preview_roll from strategy_trend_lib import ( build_trend_preview_level_rows, calc_risk_fraction, @@ -159,68 +159,202 @@ def calc_trend_calculator( }, None +def _round(v: Any, nd: int = 4) -> Any: + if v is None: + return None + try: + return round(float(v), nd) + except (TypeError, ValueError): + return v + + +def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]: + try: + if risk_u is None or float(risk_u) <= 0 or profit_u is None: + return None + return round(float(profit_u) / float(risk_u), 4) + except (TypeError, ValueError): + return None + + +def calc_initial_roll_qty( + direction: str, + entry_price: float, + stop_loss: float, + risk_budget_usdt: float, +) -> Tuple[Optional[float], Optional[str]]: + """首仓以损定仓:打到初始止损亏损 = 风险预算。""" + try: + entry = float(entry_price) + sl = float(stop_loss) + budget = float(risk_budget_usdt) + except (TypeError, ValueError): + return None, "参数格式错误" + if entry <= 0 or sl <= 0 or budget <= 0: + return None, "入场价、止损与风险预算须大于 0" + direction = (direction or "long").strip().lower() + if direction == "short": + per_unit = sl - entry + if per_unit <= 0: + return None, "做空:止损价须高于首仓入场价" + else: + per_unit = entry - sl + if per_unit <= 0: + return None, "做多:止损价须低于首仓入场价" + return budget / per_unit, None + + def calc_roll_calculator( *, direction: str, capital_usdt: float, risk_percent: float, - qty_existing: float, - entry_existing: float, + entry_price: float, + stop_loss: float, take_profit: float, - add_price: float, - new_stop_loss: float, + add_legs: list[dict[str, float]] | None = None, legs_done: int = 0, ) -> Tuple[Optional[dict[str, Any]], Optional[str]]: - preview, err = preview_roll( - direction=direction, - symbol="CALC", - qty_existing=qty_existing, - entry_existing=entry_existing, - initial_take_profit=take_profit, - add_mode="market", - new_stop_loss=new_stop_loss, - risk_percent=risk_percent, - capital_base_usdt=capital_usdt, - add_price=add_price, - legs_done=legs_done, - ) + """ + 滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓。 + add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算。 + legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进)。 + """ + direction = (direction or "long").strip().lower() + if direction not in ("long", "short"): + return None, "方向须为 long 或 short" + try: + capital = float(capital_usdt) + rp = float(risk_percent) + entry = float(entry_price) + initial_sl = float(stop_loss) + tp = float(take_profit) + done = max(0, int(legs_done)) + except (TypeError, ValueError): + return None, "参数格式错误" + if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0: + return None, "资金、风险与价格须大于 0" + if done > max_roll_legs(direction): + return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)} 次" + + legs_in: list[dict[str, float]] = [] + for raw in add_legs or []: + if not isinstance(raw, dict): + continue + try: + ap = float(raw.get("add_price")) + nsl = float(raw.get("new_stop_loss")) + except (TypeError, ValueError): + return None, "加仓价与新止损须为有效数字" + if ap <= 0 or nsl <= 0: + return None, "加仓价与新止损须大于 0" + legs_in.append({"add_price": ap, "new_stop_loss": nsl}) + + if done + len(legs_in) > max_roll_legs(direction): + return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓" + + if direction == "long": + if tp <= entry: + return None, "做多:止盈价须高于首仓入场价" + else: + if tp >= entry: + return None, "做空:止盈价须低于首仓入场价" + + risk_budget = capital * (rp / 100.0) + qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget) if err: return None, err - if not preview: - return None, "计算失败" + if qty is None or qty <= 0: + return None, "无法计算首仓张数" - def _f(v: Any, nd: int = 4) -> Any: - if v is None: - return None - try: - return round(float(v), nd) - except (TypeError, ValueError): - return v + qty_f = float(qty) + avg = entry + rows: list[dict[str, Any]] = [] - rr = None - loss = preview.get("loss_at_sl_usdt") - reward = preview.get("reward_at_tp_usdt") - try: - if loss and float(loss) > 0 and reward is not None: - rr = round(float(reward) / float(loss), 4) - except (TypeError, ValueError): - rr = None + if direction == "long": + first_loss = (avg - initial_sl) * qty_f + first_profit = (tp - avg) * qty_f + else: + first_loss = (initial_sl - avg) * qty_f + first_profit = (avg - tp) * qty_f + rows.append( + { + "label": "首仓", + "leg_index": 0, + "already_done": False, + "entry_or_add_price": _round(entry, 8), + "stop_loss": _round(initial_sl, 8), + "add_contracts": _round(qty_f, 8), + "total_contracts": _round(qty_f, 8), + "avg_entry": _round(avg, 8), + "take_profit": _round(tp, 8), + "loss_at_sl_u": _round(first_loss), + "profit_at_tp_u": _round(first_profit), + "rr": _money_rr(first_profit, first_loss), + } + ) + + current_qty = qty_f + current_avg = avg + + for i, leg in enumerate(legs_in): + leg_no = i + 1 + preview, err = preview_roll( + direction=direction, + symbol="CALC", + qty_existing=current_qty, + entry_existing=current_avg, + initial_take_profit=tp, + add_mode="market", + new_stop_loss=leg["new_stop_loss"], + risk_percent=rp, + capital_base_usdt=capital, + add_price=leg["add_price"], + legs_done=i, + ) + if err: + return None, f"滚仓第 {leg_no} 次:{err}" + if not preview: + return None, f"滚仓第 {leg_no} 次计算失败" + + current_qty = float(preview["qty_after"]) + current_avg = float(preview["avg_entry_after"]) + loss = preview.get("loss_at_sl_usdt") + reward = preview.get("reward_at_tp_usdt") + rows.append( + { + "label": f"滚仓{leg_no}", + "leg_index": leg_no, + "already_done": leg_no <= done, + "entry_or_add_price": _round(preview.get("add_price"), 8), + "stop_loss": _round(preview.get("new_stop_loss"), 8), + "add_contracts": _round(preview.get("add_amount_raw"), 8), + "total_contracts": _round(current_qty, 8), + "avg_entry": _round(current_avg, 8), + "take_profit": _round(tp, 8), + "loss_at_sl_u": _round(loss), + "profit_at_tp_u": _round(reward), + "rr": _money_rr(reward, loss), + } + ) + + last = rows[-1] return { - "direction": preview.get("direction"), - "capital_usdt": _f(capital_usdt), - "risk_percent": _f(risk_percent, 2), - "risk_budget_u": _f(preview.get("risk_budget_usdt")), - "qty_existing": _f(qty_existing, 8), - "entry_existing": _f(entry_existing, 8), - "take_profit": _f(take_profit, 8), - "add_price": _f(preview.get("add_price"), 8), - "new_stop_loss": _f(new_stop_loss, 8), - "add_contracts": _f(preview.get("add_amount_raw"), 8), - "qty_after": _f(preview.get("qty_after"), 8), - "avg_entry_after": _f(preview.get("avg_entry_after"), 8), - "loss_at_sl_u": _f(loss), - "profit_at_tp_u": _f(reward), - "rr": rr, - "leg_index_next": preview.get("leg_index_next"), + "direction": direction, + "capital_usdt": _round(capital), + "risk_percent": _round(rp, 2), + "risk_budget_u": _round(risk_budget), + "entry_price": _round(entry, 8), + "stop_loss": _round(initial_sl, 8), + "take_profit": _round(tp, 8), + "legs_done": done, + "roll_legs_planned": len(legs_in), + "first_contracts": _round(qty_f, 8), + "final_contracts": last.get("total_contracts"), + "final_avg_entry": last.get("avg_entry"), + "final_loss_at_sl_u": last.get("loss_at_sl_u"), + "final_profit_at_tp_u": last.get("profit_at_tp_u"), + "final_rr": last.get("rr"), + "rows": rows, }, None diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 891ee24..91ad56c 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -834,16 +834,20 @@ class TrendCalculatorBody(BaseModel): contract_size: float = Field(default=1.0, gt=0) +class RollAddLegBody(BaseModel): + add_price: float = Field(gt=0) + new_stop_loss: float = Field(gt=0) + + class RollCalculatorBody(BaseModel): direction: str = "long" capital_usdt: float = Field(gt=0) risk_percent: float = Field(gt=0, le=100) - qty_existing: float = Field(gt=0) - entry_existing: float = Field(gt=0) + entry_price: float = Field(gt=0) + stop_loss: float = Field(gt=0) take_profit: float = Field(gt=0) - add_price: float = Field(gt=0) - new_stop_loss: float = Field(gt=0) - legs_done: int = Field(default=0, ge=0, le=10) + add_legs: list[RollAddLegBody] = Field(default_factory=list, max_length=3) + legs_done: int = Field(default=0, ge=0, le=3) @app.post("/api/calculator/trend") @@ -875,11 +879,10 @@ def api_calculator_roll(body: RollCalculatorBody): direction=body.direction, capital_usdt=body.capital_usdt, risk_percent=body.risk_percent, - qty_existing=body.qty_existing, - entry_existing=body.entry_existing, + entry_price=body.entry_price, + stop_loss=body.stop_loss, take_profit=body.take_profit, - add_price=body.add_price, - new_stop_loss=body.new_stop_loss, + add_legs=[leg.model_dump() for leg in body.add_legs], legs_done=body.legs_done, ) if err: diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 2d45531..3adf9bb 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -6938,6 +6938,57 @@ body.funds-fullscreen-open { margin: 0; } +.calc-roll-legs-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 14px 0 8px; + font-size: 0.82rem; + color: var(--text); +} + +.calc-roll-legs-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.calc-roll-leg { + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 10px 12px; + background: var(--bg-elevated); +} + +.calc-roll-leg-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); + margin-bottom: 8px; +} + +.calc-roll-leg-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.calc-roll-leg-remove { + margin-top: 8px; + font-size: 0.78rem; +} + +.calc-done-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.68rem; + color: var(--muted); + border: 1px solid var(--border-soft); +} + @media (max-width: 960px) { .calc-layout { grid-template-columns: 1fr; diff --git a/manual_trading_hub/static/calculator.js b/manual_trading_hub/static/calculator.js index 0e9c05e..fe9acc6 100644 --- a/manual_trading_hub/static/calculator.js +++ b/manual_trading_hub/static/calculator.js @@ -127,35 +127,169 @@ const box = $("calc-roll-result"); if (!box) return; box.classList.remove("hidden"); + let table = + '
| 阶段 | 入场/加仓价 | 统一止损 | 本次张数 | 累计张数 | 均价 | 止损亏损 | 止盈盈利 | 盈亏比 | " + + "
|---|---|---|---|---|---|---|---|---|
| " + + esc(r.label) + + tag + + " | " + + "" + + fmt(r.entry_or_add_price, 4) + + " | " + + "" + + fmt(r.stop_loss, 4) + + " | " + + "" + + fmt(r.add_contracts, 4) + + " | " + + "" + + fmt(r.total_contracts, 4) + + " | " + + "" + + fmt(r.avg_entry, 4) + + " | " + + '' + + fmtU(-Math.abs(Number(r.loss_at_sl_u) || 0)) + + " | " + + '' + + fmtU(r.profit_at_tp_u) + + " | " + + "" + + (r.rr != null ? fmt(r.rr, 2) + ":1" : "—") + + " | " + + "
逻辑与实例滚仓一致:合并持仓打到新止损 ≈ 账户风险;止盈锁定首仓;加仓价手动输入。
+首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变。最多 3 次滚仓。