From 253d353206dc9188d841f0e003fe1646bc8fdf72 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 23 Jun 2026 17:35:06 +0800 Subject: [PATCH] Add hub strategy calculator page with trend and roll risk-based sizing. Co-authored-by: Cursor --- hub_calculator_lib.py | 226 +++++++++++++++++++++ manual_trading_hub/hub.py | 68 +++++++ manual_trading_hub/settings_store.py | 1 + manual_trading_hub/static/app.css | 143 ++++++++++++++ manual_trading_hub/static/app.js | 16 ++ manual_trading_hub/static/calculator.js | 249 ++++++++++++++++++++++++ manual_trading_hub/static/index.html | 126 +++++++++++- tests/test_hub_calculator_lib.py | 60 ++++++ 8 files changed, 887 insertions(+), 2 deletions(-) create mode 100644 hub_calculator_lib.py create mode 100644 manual_trading_hub/static/calculator.js create mode 100644 tests/test_hub_calculator_lib.py diff --git a/hub_calculator_lib.py b/hub_calculator_lib.py new file mode 100644 index 0000000..699fe7a --- /dev/null +++ b/hub_calculator_lib.py @@ -0,0 +1,226 @@ +"""中控历史测算:趋势回调 / 滚仓,以损定仓(无交易所精度,张数按公式估算)。""" +from __future__ import annotations + +from typing import Any, Callable, Optional, Tuple + +from strategy_roll_lib import preview_roll +from strategy_trend_lib import ( + build_trend_preview_level_rows, + calc_risk_fraction, + compute_trend_plan_core, + validate_trend_bounds, +) + +DEFAULT_DCA_LEGS = 5 +DEFAULT_CONTRACT_SIZE = 1.0 +MARGIN_BUFFER = 0.95 + + +def _identity_amount_precise(_symbol: str, amount: float) -> Optional[float]: + try: + v = float(amount) + except (TypeError, ValueError): + return None + if v <= 0: + return None + return round(v, 8) + + +def amount_from_margin( + margin_capital: float, + leverage: int, + price: float, + contract_size: float = DEFAULT_CONTRACT_SIZE, +) -> Optional[float]: + try: + margin = float(margin_capital) + lev = int(leverage) + px = float(price) + cs = float(contract_size) if contract_size else DEFAULT_CONTRACT_SIZE + except (TypeError, ValueError): + return None + if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0: + return None + notional = margin * lev + return notional / (px * cs) + + +def calc_trend_calculator( + *, + direction: str, + capital_usdt: float, + risk_percent: float, + leverage: int, + entry_price: float, + stop_loss: float, + add_upper: float, + take_profit: float, + dca_legs: int = DEFAULT_DCA_LEGS, + contract_size: float = DEFAULT_CONTRACT_SIZE, +) -> Tuple[Optional[dict[str, Any]], Optional[str]]: + 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) + lev = int(leverage) + entry = float(entry_price) + sl = float(stop_loss) + upper = float(add_upper) + tp = float(take_profit) + legs = max(1, int(dca_legs)) + cs = float(contract_size) if contract_size else DEFAULT_CONTRACT_SIZE + except (TypeError, ValueError): + return None, "参数格式错误" + if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0: + return None, "资金、风险、杠杆与价格须大于 0" + + bound_err = validate_trend_bounds(direction, sl, upper) + if bound_err: + return None, bound_err + + rf = calc_risk_fraction(direction, upper, sl) + if rf is None or rf <= 0: + return None, "止损与补仓区间边界组合无法计算风险比例" + + risk_budget = capital * (rp / 100.0) + notional = risk_budget / rf + margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER) + if margin_plan <= 0: + return None, "计划保证金过小" + + target_amt = amount_from_margin(margin_plan, lev, entry, cs) + if target_amt is None or target_amt <= 0: + return None, "无法计算计划张数,请检查入场价与杠杆" + + payload, err = compute_trend_plan_core( + direction=direction, + stop_loss=sl, + add_upper=upper, + risk_percent=rp, + snapshot_usdt=capital, + leverage=lev, + live_price=entry, + target_order_amount=target_amt, + exchange_symbol="CALC", + dca_legs=legs, + amount_precise=_identity_amount_precise, + min_amount=0.0, + full_margin_buffer_ratio=MARGIN_BUFFER, + ) + if err: + return None, err + + payload["take_profit"] = tp + payload["leverage"] = lev + payload["contract_size"] = cs + preview, rows = build_trend_preview_level_rows(payload) + + 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 + + table = [] + for row in rows: + table.append( + { + "label": row.get("label"), + "price": _f(row.get("price"), 8), + "contracts": _f(row.get("contracts"), 8), + "avg_entry": _f(row.get("avg_entry"), 8), + "profit_u": _f(row.get("profit_u")), + "risk_u": _f(row.get("risk_u")), + "rr": _f(row.get("rr"), 4), + } + ) + + return { + "direction": direction, + "capital_usdt": _f(capital), + "risk_percent": _f(rp, 2), + "risk_budget_u": _f(preview.get("preview_risk_amount_u")), + "leverage": lev, + "entry_price": _f(entry, 8), + "stop_loss": _f(sl, 8), + "add_upper": _f(upper, 8), + "take_profit": _f(tp, 8), + "plan_margin_u": _f(preview.get("plan_margin_capital")), + "target_contracts": _f(preview.get("target_order_amount"), 8), + "first_contracts": _f(preview.get("first_order_amount"), 8), + "dca_legs": int(preview.get("dca_legs") or legs), + "first_profit_u": _f(preview.get("preview_first_profit_u")), + "first_rr": _f(preview.get("preview_target_rr"), 4), + "rows": table, + }, None + + +def calc_roll_calculator( + *, + direction: str, + capital_usdt: float, + risk_percent: float, + qty_existing: float, + entry_existing: float, + take_profit: float, + add_price: float, + new_stop_loss: float, + 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, + ) + if err: + return None, err + if not preview: + 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 + + 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 + + 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"), + }, None diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 1d6933a..891ee24 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -702,6 +702,7 @@ def root_redirect(): @app.get("/monitor") @app.get("/plan") +@app.get("/calculator") @app.get("/market") @app.get("/archive") @app.get("/dashboard") @@ -793,6 +794,7 @@ class SettingsDisplayBody(BaseModel): show_nav_plan: bool = True show_nav_archive: bool = True show_nav_ai: bool = True + show_nav_calculator: bool = True class SettingsBody(BaseModel): @@ -819,6 +821,72 @@ def api_save_settings(body: SettingsBody): return {"ok": True, "settings": load_settings()} +class TrendCalculatorBody(BaseModel): + direction: str = "long" + capital_usdt: float = Field(gt=0) + risk_percent: float = Field(gt=0, le=100) + leverage: int = Field(ge=1, le=125) + entry_price: float = Field(gt=0) + stop_loss: float = Field(gt=0) + add_upper: float = Field(gt=0) + take_profit: float = Field(gt=0) + dca_legs: int = Field(default=5, ge=1, le=20) + contract_size: float = Field(default=1.0, gt=0) + + +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) + 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) + + +@app.post("/api/calculator/trend") +def api_calculator_trend(body: TrendCalculatorBody): + from hub_calculator_lib import calc_trend_calculator + + data, err = calc_trend_calculator( + direction=body.direction, + capital_usdt=body.capital_usdt, + risk_percent=body.risk_percent, + leverage=body.leverage, + entry_price=body.entry_price, + stop_loss=body.stop_loss, + add_upper=body.add_upper, + take_profit=body.take_profit, + dca_legs=body.dca_legs, + contract_size=body.contract_size, + ) + if err: + return JSONResponse({"ok": False, "msg": err}, status_code=400) + return {"ok": True, "data": data} + + +@app.post("/api/calculator/roll") +def api_calculator_roll(body: RollCalculatorBody): + from hub_calculator_lib import calc_roll_calculator + + data, err = calc_roll_calculator( + direction=body.direction, + capital_usdt=body.capital_usdt, + risk_percent=body.risk_percent, + qty_existing=body.qty_existing, + entry_existing=body.entry_existing, + take_profit=body.take_profit, + add_price=body.add_price, + new_stop_loss=body.new_stop_loss, + legs_done=body.legs_done, + ) + if err: + return JSONResponse({"ok": False, "msg": err}, status_code=400) + return {"ok": True, "data": data} + + def _find_exchange_by_key(exchange_key: str) -> dict | None: key = (exchange_key or "").strip().lower() if not key: diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index 1ee77d2..35faa56 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -16,6 +16,7 @@ DEFAULT_DISPLAY = { "show_nav_plan": True, "show_nav_archive": True, "show_nav_ai": True, + "show_nav_calculator": True, } DEFAULT_EXCHANGES = [ diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 1cf6115..2d45531 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -6804,3 +6804,146 @@ body.funds-fullscreen-open { } } +/* ── 策略计算器 ── */ +.calc-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: start; +} + +.calc-card { + padding: 16px 18px; +} + +.calc-card h2 { + margin: 0 0 8px; + font-size: 1rem; + color: var(--text); +} + +.calc-hint { + margin: 0 0 14px; + font-size: 0.78rem; + color: var(--muted); + line-height: 1.5; +} + +.calc-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; +} + +.calc-field { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 0.78rem; + color: var(--muted); +} + +.calc-field input, +.calc-field select { + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 8px 10px; + font-size: 0.82rem; + font-family: var(--mono); +} + +.calc-actions { + margin-top: 12px; +} + +.calc-result { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border-soft); +} + +.calc-result.hidden { + display: none !important; +} + +.calc-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px 12px; + margin-bottom: 12px; +} + +.calc-summary div { + background: var(--bg-elevated); + border: 1px solid var(--border-soft); + border-radius: 8px; + padding: 8px 10px; +} + +.calc-summary span { + display: block; + font-size: 0.72rem; + color: var(--muted); + margin-bottom: 4px; +} + +.calc-summary strong { + font-family: var(--mono); + font-size: 0.86rem; + color: var(--text); +} + +.calc-pnl-profit { + color: var(--green) !important; +} + +.calc-pnl-loss { + color: var(--red) !important; +} + +.calc-table-wrap { + overflow: auto; +} + +.calc-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.calc-table th, +.calc-table td { + padding: 7px 8px; + border-bottom: 1px solid var(--border-soft); + text-align: left; + white-space: nowrap; +} + +.calc-table th { + color: var(--muted); + font-weight: 600; +} + +.calc-error { + color: var(--red); + font-size: 0.82rem; + margin: 0; +} + +.calc-empty { + color: var(--muted); + font-size: 0.82rem; + margin: 0; +} + +@media (max-width: 960px) { + .calc-layout { + grid-template-columns: 1fr; + } + .calc-form-grid { + grid-template-columns: 1fr; + } +} + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 6b16a43..8e7d608 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -33,6 +33,10 @@ return displayPref("show_nav_ai", true); } + function showNavCalculatorPref() { + return displayPref("show_nav_calculator", true); + } + function syncNavVisibility(data) { const d = (data && data.display) || {}; const navFunds = document.getElementById("nav-funds"); @@ -40,11 +44,13 @@ const navPlan = document.getElementById("nav-plan"); const navArchive = document.getElementById("nav-archive"); const navAi = document.getElementById("nav-ai"); + const navCalc = document.getElementById("nav-calculator"); if (navFunds) navFunds.classList.toggle("nav-hidden", d.show_nav_funds === false); if (navDash) navDash.classList.toggle("nav-hidden", d.show_nav_dashboard === false); if (navPlan) navPlan.classList.toggle("nav-hidden", d.show_nav_plan === false); if (navArchive) navArchive.classList.toggle("nav-hidden", d.show_nav_archive === false); if (navAi) navAi.classList.toggle("nav-hidden", d.show_nav_ai === false); + if (navCalc) navCalc.classList.toggle("nav-hidden", d.show_nav_calculator === false); } function pageNavAllowed(page) { @@ -53,6 +59,7 @@ if (page === "plan") return showNavPlanPref(); if (page === "archive") return showNavArchivePref(); if (page === "ai") return showNavAiPref(); + if (page === "calculator") return showNavCalculatorPref(); return true; } @@ -64,12 +71,14 @@ const planCb = document.getElementById("pref-show-nav-plan"); const archiveCb = document.getElementById("pref-show-nav-archive"); const aiCb = document.getElementById("pref-show-nav-ai"); + const calcCb = document.getElementById("pref-show-nav-calculator"); if (pnlCb) pnlCb.checked = d.show_account_pnl !== false; if (fundsCb) fundsCb.checked = d.show_nav_funds !== false; if (dashCb) dashCb.checked = d.show_nav_dashboard !== false; if (planCb) planCb.checked = d.show_nav_plan !== false; if (archiveCb) archiveCb.checked = d.show_nav_archive !== false; if (aiCb) aiCb.checked = d.show_nav_ai !== false; + if (calcCb) calcCb.checked = d.show_nav_calculator !== false; syncNavVisibility(data); } @@ -1035,6 +1044,7 @@ if (p.includes("dashboard")) return "dashboard"; if (p.includes("funds")) return "funds"; if (p.includes("plan")) return "plan"; + if (p.includes("calculator")) return "calculator"; if (p.includes("market")) return "market"; if (p.includes("/ai")) return "ai"; return "monitor"; @@ -1046,6 +1056,7 @@ if (page === "dashboard") return "page-dashboard"; if (page === "funds") return "page-funds"; if (page === "plan") return "page-plan"; + if (page === "calculator") return "page-calculator"; if (page === "market") return "page-market"; if (page === "ai") return "page-ai"; return "page-monitor"; @@ -1091,6 +1102,9 @@ } else if (window.hubPlanPage && window.hubPlanPage.destroy) { window.hubPlanPage.destroy(); } + if (page === "calculator" && window.hubCalculatorPage) { + window.hubCalculatorPage.init(); + } if (page === "funds" && window.hubFundsPage) { window.hubFundsPage.init(); } else if (window.hubFundsPage && window.hubFundsPage.destroy) { @@ -3770,6 +3784,7 @@ const planCb = document.getElementById("pref-show-nav-plan"); const archiveCb = document.getElementById("pref-show-nav-archive"); const aiCb = document.getElementById("pref-show-nav-ai"); + const calcCb = document.getElementById("pref-show-nav-calculator"); return { version: 1, display: { @@ -3779,6 +3794,7 @@ show_nav_plan: planCb ? !!planCb.checked : true, show_nav_archive: archiveCb ? !!archiveCb.checked : true, show_nav_ai: aiCb ? !!aiCb.checked : true, + show_nav_calculator: calcCb ? !!calcCb.checked : true, }, exchanges: rows.map((card) => { const caps = []; diff --git a/manual_trading_hub/static/calculator.js b/manual_trading_hub/static/calculator.js new file mode 100644 index 0000000..0e9c05e --- /dev/null +++ b/manual_trading_hub/static/calculator.js @@ -0,0 +1,249 @@ +/** + * 中控策略计算器:趋势回调 / 滚仓历史测算 + */ +(function () { + const page = document.getElementById("page-calculator"); + if (!page) return; + + let inited = false; + + function $(id) { + return document.getElementById(id); + } + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function num(id) { + const el = $(id); + if (!el) return null; + const n = Number(el.value); + return Number.isFinite(n) ? n : null; + } + + function fmt(v, digits) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return esc(v); + if (digits != null) return n.toFixed(digits); + return String(n); + } + + function fmtU(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return (n >= 0 ? "+" : "") + n.toFixed(2) + "U"; + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "calc-pnl-profit" : "calc-pnl-loss"; + } + + function syncTrendAddLabel() { + const dir = ($("calc-trend-direction") && $("calc-trend-direction").value) || "long"; + const lab = $("calc-trend-add-label"); + if (lab) lab.textContent = dir === "short" ? "补仓下沿价" : "补仓上沿价"; + } + + function renderTrendTable(rows) { + if (!rows || !rows.length) { + return '

无档位数据

'; + } + let html = + '
' + + "" + + ""; + rows.forEach(function (r) { + html += + "" + + "" + + "" + + "" + + "" + + '" + + "" + + "" + + ""; + }); + html += "
档位触发价张数加仓后均价止盈盈利止损金额盈亏比
" + + esc(r.label) + + "" + + fmt(r.price, 4) + + "" + + fmt(r.contracts, 4) + + "" + + fmt(r.avg_entry, 4) + + "' + + fmtU(r.profit_u) + + "" + + fmtU(r.risk_u) + + "" + + (r.rr != null ? fmt(r.rr, 2) + ":1" : "—") + + "
"; + return html; + } + + function renderTrendResult(data) { + const box = $("calc-trend-result"); + if (!box) return; + box.classList.remove("hidden"); + box.innerHTML = + '
' + + "
计划保证金" + + fmt(data.plan_margin_u, 2) + + "U
" + + "
止损预算" + + fmt(data.risk_budget_u, 2) + + "U
" + + "
总张数" + + fmt(data.target_contracts, 4) + + "
" + + "
首仓张数" + + fmt(data.first_contracts, 4) + + "
" + + '
首仓止盈盈利' + + fmtU(data.first_profit_u) + + "
" + + "
首仓盈亏比" + + (data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") + + "
" + + "
" + + renderTrendTable(data.rows); + } + + function renderRollResult(data) { + const box = $("calc-roll-result"); + if (!box) return; + box.classList.remove("hidden"); + box.innerHTML = + '
' + + "
风险预算" + + fmt(data.risk_budget_u, 2) + + "U
" + + "
本次加仓张数" + + fmt(data.add_contracts, 4) + + "
" + + "
合并后张数" + + fmt(data.qty_after, 4) + + "
" + + "
合并后均价" + + fmt(data.avg_entry_after, 4) + + "
" + + "
打到新止损亏损" + + fmtU(-Math.abs(Number(data.loss_at_sl_u) || 0)) + + "
" + + '
到达首仓止盈盈利' + + fmtU(data.profit_at_tp_u) + + "
" + + "
金额盈亏比" + + (data.rr != null ? fmt(data.rr, 2) + ":1" : "—") + + "
" + + "
下一滚仓序号第 " + + esc(data.leg_index_next) + + " 次
" + + "
"; + } + + function showErr(boxId, msg) { + const box = $(boxId); + if (!box) return; + box.classList.remove("hidden"); + box.innerHTML = '

' + esc(msg || "计算失败") + "

"; + } + + async function submitTrend(e) { + e.preventDefault(); + const body = { + direction: ($("calc-trend-direction") && $("calc-trend-direction").value) || "long", + capital_usdt: num("calc-trend-capital"), + risk_percent: num("calc-trend-risk"), + leverage: num("calc-trend-leverage"), + entry_price: num("calc-trend-entry"), + stop_loss: num("calc-trend-sl"), + add_upper: num("calc-trend-add-upper"), + take_profit: num("calc-trend-tp"), + dca_legs: num("calc-trend-dca-legs") || 5, + contract_size: num("calc-trend-contract-size") || 1, + }; + try { + const r = await fetch("/api/calculator/trend", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (!j.ok) { + showErr("calc-trend-result", j.msg || "计算失败"); + return; + } + renderTrendResult(j.data); + } catch (err) { + showErr("calc-trend-result", String(err)); + } + } + + async function submitRoll(e) { + e.preventDefault(); + const body = { + 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"), + take_profit: num("calc-roll-tp"), + add_price: num("calc-roll-add-price"), + new_stop_loss: num("calc-roll-sl"), + legs_done: num("calc-roll-legs-done") || 0, + }; + try { + const r = await fetch("/api/calculator/roll", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (!j.ok) { + showErr("calc-roll-result", j.msg || "计算失败"); + return; + } + renderRollResult(j.data); + } catch (err) { + showErr("calc-roll-result", String(err)); + } + } + + function bindOnce() { + if (inited) return; + inited = true; + const trendForm = $("calc-trend-form"); + const rollForm = $("calc-roll-form"); + const dirSel = $("calc-trend-direction"); + if (trendForm) trendForm.addEventListener("submit", submitTrend); + if (rollForm) rollForm.addEventListener("submit", submitRoll); + if (dirSel) { + dirSel.addEventListener("change", syncTrendAddLabel); + syncTrendAddLabel(); + } + } + + window.hubCalculatorPage = { + init: bindOnce, + destroy: function () {}, + }; +})(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 3523e61..f1632ca 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -51,6 +51,7 @@ 开仓计划 监控区 行情区 + 计算器 内照明心 数据看板 AI 教练 @@ -695,6 +696,122 @@ + +