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 = + '
' + + "" + + ""; + (data.rows || []).forEach(function (r) { + const tag = r.already_done ? ' 已完成' : ""; + table += + "" + + "" + + "" + + "" + + "" + + "" + + "" + + '" + + '" + + "" + + ""; + }); + 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" : "—") + + "
"; box.innerHTML = '
' + - "
风险预算" + + "
单次风险预算" + fmt(data.risk_budget_u, 2) + "U
" + - "
本次加仓张数" + - fmt(data.add_contracts, 4) + + "
首仓张数(自动)" + + fmt(data.first_contracts, 4) + "
" + - "
合并后张数" + - fmt(data.qty_after, 4) + + "
最终累计张数" + + fmt(data.final_contracts, 4) + "
" + - "
合并后均价" + - fmt(data.avg_entry_after, 4) + + "
最终均价" + + fmt(data.final_avg_entry, 4) + "
" + - "
打到新止损亏损" + - fmtU(-Math.abs(Number(data.loss_at_sl_u) || 0)) + - "
" + - '
到达首仓止盈盈利' + - fmtU(data.profit_at_tp_u) + + fmtU(data.final_profit_at_tp_u) + "
" + - "
金额盈亏比" + - (data.rr != null ? fmt(data.rr, 2) + ":1" : "—") + + "
最终盈亏比" + + (data.final_rr != null ? fmt(data.final_rr, 2) + ":1" : "—") + "
" + - "
下一滚仓序号第 " + - esc(data.leg_index_next) + - " 次
" + - "
"; + "
" + + table; + } + + const MAX_ROLL_LEGS = 3; + let rollLegCount = 0; + + function maxRollLegsAllowed() { + const done = num("calc-roll-legs-done") || 0; + return Math.max(0, MAX_ROLL_LEGS - done); + } + + function syncRollAddBtn() { + const btn = $("calc-roll-add-leg"); + if (!btn) return; + btn.disabled = rollLegCount >= maxRollLegsAllowed(); + } + + function rollLegRowHtml(index) { + return ( + '
' + + '
滚仓 ' + + index + + "
" + + '
' + + '' + + '' + + "
" + + '' + + "
" + ); + } + + function renumberRollLegs() { + const list = $("calc-roll-legs-list"); + if (!list) return; + const rows = list.querySelectorAll(".calc-roll-leg"); + rollLegCount = rows.length; + rows.forEach(function (row, i) { + row.setAttribute("data-leg-index", String(i + 1)); + const title = row.querySelector(".calc-roll-leg-title"); + if (title) title.textContent = "滚仓 " + (i + 1); + }); + syncRollAddBtn(); + } + + function addRollLegRow() { + if (rollLegCount >= maxRollLegsAllowed()) return; + const list = $("calc-roll-legs-list"); + if (!list) return; + list.insertAdjacentHTML("beforeend", rollLegRowHtml(rollLegCount + 1)); + rollLegCount += 1; + syncRollAddBtn(); + } + + function collectRollLegs() { + const legs = []; + document.querySelectorAll(".calc-roll-leg").forEach(function (row) { + const addEl = row.querySelector(".calc-roll-leg-add"); + const stopEl = row.querySelector(".calc-roll-leg-stop"); + const ap = addEl && addEl.value !== "" ? Number(addEl.value) : null; + const sl = stopEl && stopEl.value !== "" ? Number(stopEl.value) : null; + if (ap == null || sl == null || !Number.isFinite(ap) || !Number.isFinite(sl)) return; + legs.push({ add_price: ap, new_stop_loss: sl }); + }); + return legs; + } + + function bindRollLegsUI() { + const addBtn = $("calc-roll-add-leg"); + const list = $("calc-roll-legs-list"); + const doneInput = $("calc-roll-legs-done"); + if (addBtn && !addBtn._bound) { + addBtn._bound = true; + addBtn.addEventListener("click", addRollLegRow); + } + if (list && !list._bound) { + list._bound = true; + list.addEventListener("click", function (e) { + const btn = e.target.closest(".calc-roll-leg-remove"); + if (!btn) return; + const row = btn.closest(".calc-roll-leg"); + if (row) row.remove(); + renumberRollLegs(); + }); + } + if (doneInput && !doneInput._bound) { + doneInput._bound = true; + doneInput.addEventListener("change", function () { + while (rollLegCount > maxRollLegsAllowed()) { + const rows = list && list.querySelectorAll(".calc-roll-leg"); + if (rows && rows.length) rows[rows.length - 1].remove(); + rollLegCount = list ? list.querySelectorAll(".calc-roll-leg").length : 0; + } + syncRollAddBtn(); + }); + } + syncRollAddBtn(); } function showErr(boxId, msg) { @@ -203,11 +337,10 @@ direction: ($("calc-roll-direction") && $("calc-roll-direction").value) || "long", capital_usdt: num("calc-roll-capital"), risk_percent: num("calc-roll-risk"), - qty_existing: num("calc-roll-qty"), - entry_existing: num("calc-roll-entry"), + entry_price: num("calc-roll-entry"), + stop_loss: num("calc-roll-sl"), take_profit: num("calc-roll-tp"), - add_price: num("calc-roll-add-price"), - new_stop_loss: num("calc-roll-sl"), + add_legs: collectRollLegs(), legs_done: num("calc-roll-legs-done") || 0, }; try { @@ -240,6 +373,7 @@ dirSel.addEventListener("change", syncTrendAddLabel); syncTrendAddLabel(); } + bindRollLegsUI(); } window.hubCalculatorPage = { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index f1632ca..1e6678f 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -760,7 +760,7 @@

滚仓计算器

-

逻辑与实例滚仓一致:合并持仓打到新止损 ≈ 账户风险;止盈锁定首仓;加仓价手动输入。

+

首仓按「单次风险」以损定仓;每次滚仓后合并持仓打到新止损 ≈ 单次风险;止盈锁定首仓价不变。最多 3 次滚仓。

- - - +
+
+ 滚仓加仓(最多 3 次) + +
+
@@ -944,7 +941,7 @@ - + diff --git a/tests/test_hub_calculator_lib.py b/tests/test_hub_calculator_lib.py index c47743a..45159a5 100644 --- a/tests/test_hub_calculator_lib.py +++ b/tests/test_hub_calculator_lib.py @@ -1,6 +1,10 @@ """hub_calculator_lib 测算逻辑。""" -from hub_calculator_lib import calc_roll_calculator, calc_trend_calculator +from hub_calculator_lib import ( + calc_initial_roll_qty, + calc_roll_calculator, + calc_trend_calculator, +) def test_trend_calculator_long_basic(): @@ -21,8 +25,6 @@ def test_trend_calculator_long_basic(): assert data["risk_budget_u"] == 50.0 assert len(data["rows"]) >= 2 assert data["rows"][0]["label"] == "首仓" - assert data["first_profit_u"] is not None - assert data["first_profit_u"] > 0 def test_trend_calculator_short_rejects_bad_bounds(): @@ -41,20 +43,69 @@ def test_trend_calculator_short_rejects_bad_bounds(): assert err is not None -def test_roll_calculator_long(): +def test_roll_calculator_first_leg_auto(): data, err = calc_roll_calculator( direction="long", capital_usdt=1000, risk_percent=5, - qty_existing=10, - entry_existing=100, + entry_price=100, + stop_loss=95, take_profit=120, - add_price=105, - new_stop_loss=98, + add_legs=[], legs_done=0, ) assert err is None assert data is not None - assert data["add_contracts"] > 0 - assert data["qty_after"] > 10 - assert data["profit_at_tp_u"] is not None + assert data["first_contracts"] == 10.0 + assert len(data["rows"]) == 1 + assert data["rows"][0]["loss_at_sl_u"] == 50.0 + assert data["rows"][0]["profit_at_tp_u"] == 200.0 + + +def test_roll_calculator_chain_two_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}, + ], + 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