Add hub strategy calculator page with trend and roll risk-based sizing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-23 17:35:06 +08:00
parent 1ba0014fff
commit 253d353206
8 changed files with 887 additions and 2 deletions
+226
View File
@@ -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
+68
View File
@@ -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:
+1
View File
@@ -16,6 +16,7 @@ DEFAULT_DISPLAY = {
"show_nav_plan": True,
"show_nav_archive": True,
"show_nav_ai": True,
"show_nav_calculator": True,
}
DEFAULT_EXCHANGES = [
+143
View File
@@ -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;
}
}
+16
View File
@@ -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 = [];
+249
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 '<p class="calc-empty">无档位数据</p>';
}
let html =
'<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>" +
"</tr></thead><tbody>";
rows.forEach(function (r) {
html +=
"<tr>" +
"<td>" +
esc(r.label) +
"</td>" +
"<td>" +
fmt(r.price, 4) +
"</td>" +
"<td>" +
fmt(r.contracts, 4) +
"</td>" +
"<td>" +
fmt(r.avg_entry, 4) +
"</td>" +
'<td class="' +
pnlClass(r.profit_u) +
'">' +
fmtU(r.profit_u) +
"</td>" +
"<td>" +
fmtU(r.risk_u) +
"</td>" +
"<td>" +
(r.rr != null ? fmt(r.rr, 2) + ":1" : "—") +
"</td>" +
"</tr>";
});
html += "</tbody></table></div>";
return html;
}
function renderTrendResult(data) {
const box = $("calc-trend-result");
if (!box) return;
box.classList.remove("hidden");
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>计划保证金</span><strong>" +
fmt(data.plan_margin_u, 2) +
"U</strong></div>" +
"<div><span>止损预算</span><strong>" +
fmt(data.risk_budget_u, 2) +
"U</strong></div>" +
"<div><span>总张数</span><strong>" +
fmt(data.target_contracts, 4) +
"</strong></div>" +
"<div><span>首仓张数</span><strong>" +
fmt(data.first_contracts, 4) +
"</strong></div>" +
'<div><span>首仓止盈盈利</span><strong class="' +
pnlClass(data.first_profit_u) +
'">' +
fmtU(data.first_profit_u) +
"</strong></div>" +
"<div><span>首仓盈亏比</span><strong>" +
(data.first_rr != null ? fmt(data.first_rr, 2) + ":1" : "—") +
"</strong></div>" +
"</div>" +
renderTrendTable(data.rows);
}
function renderRollResult(data) {
const box = $("calc-roll-result");
if (!box) return;
box.classList.remove("hidden");
box.innerHTML =
'<div class="calc-summary">' +
"<div><span>风险预算</span><strong>" +
fmt(data.risk_budget_u, 2) +
"U</strong></div>" +
"<div><span>本次加仓张数</span><strong>" +
fmt(data.add_contracts, 4) +
"</strong></div>" +
"<div><span>合并后张数</span><strong>" +
fmt(data.qty_after, 4) +
"</strong></div>" +
"<div><span>合并后均价</span><strong>" +
fmt(data.avg_entry_after, 4) +
"</strong></div>" +
"<div><span>打到新止损亏损</span><strong class=\"calc-pnl-loss\">" +
fmtU(-Math.abs(Number(data.loss_at_sl_u) || 0)) +
"</strong></div>" +
'<div><span>到达首仓止盈盈利</span><strong class="' +
pnlClass(data.profit_at_tp_u) +
'">' +
fmtU(data.profit_at_tp_u) +
"</strong></div>" +
"<div><span>金额盈亏比</span><strong>" +
(data.rr != null ? fmt(data.rr, 2) + ":1" : "—") +
"</strong></div>" +
"<div><span>下一滚仓序号</span><strong>第 " +
esc(data.leg_index_next) +
" 次</strong></div>" +
"</div>";
}
function showErr(boxId, msg) {
const box = $(boxId);
if (!box) return;
box.classList.remove("hidden");
box.innerHTML = '<p class="calc-error">' + esc(msg || "计算失败") + "</p>";
}
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 () {},
};
})();
+124 -2
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260614-macro-panel-padding" />
<link rel="stylesheet" href="/assets/app.css?v=20260614-calculator" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=3" />
<script src="/assets/account_risk_badge.js?v=3"></script>
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
@@ -51,6 +51,7 @@
<a href="/plan" id="nav-plan">开仓计划</a>
<a href="/monitor" id="nav-monitor">监控区</a>
<a href="/market" id="nav-market">行情区</a>
<a href="/calculator" id="nav-calculator">计算器</a>
<a href="/archive" id="nav-archive">内照明心</a>
<a href="/dashboard" id="nav-dashboard">数据看板</a>
<a href="/ai" id="nav-ai">AI 教练</a>
@@ -695,6 +696,122 @@
</div>
</div>
<div id="page-calculator" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">CAL</span> 策略计算器</h1>
<p class="page-desc">历史行情测算 · 以损定仓 · 价格均为手动输入</p>
</div>
<div class="calc-layout">
<section class="calc-card card">
<h2>趋势回调计算器</h2>
<p class="calc-hint">逻辑与实例策略页一致:首仓 50% + 补仓网格;止损金额 = 资金 × 风险%。</p>
<form id="calc-trend-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-trend-capital" type="number" min="0.01" step="any" value="1000" required />
</label>
<label class="calc-field">
<span>风险 %</span>
<input id="calc-trend-risk" type="number" min="0.1" step="0.1" value="5" required />
</label>
<label class="calc-field">
<span>杠杆</span>
<input id="calc-trend-leverage" type="number" min="1" step="1" value="5" required />
</label>
<label class="calc-field">
<span>方向</span>
<select id="calc-trend-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</label>
<label class="calc-field">
<span>首仓入场价</span>
<input id="calc-trend-entry" type="number" min="0" step="any" placeholder="手动输入" required />
</label>
<label class="calc-field">
<span>止损价</span>
<input id="calc-trend-sl" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span id="calc-trend-add-label">补仓上沿价</span>
<input id="calc-trend-add-upper" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>止盈价</span>
<input id="calc-trend-tp" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>补仓档数</span>
<input id="calc-trend-dca-legs" type="number" min="1" max="20" step="1" value="5" />
</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 class="calc-actions">
<button type="submit" class="primary">计算</button>
</div>
</form>
<div id="calc-trend-result" class="calc-result hidden"></div>
</section>
<section class="calc-card card">
<h2>滚仓计算器</h2>
<p class="calc-hint">逻辑与实例滚仓一致:合并持仓打到新止损 ≈ 账户风险;止盈锁定首仓;加仓价手动输入。</p>
<form id="calc-roll-form" class="calc-form">
<div class="calc-form-grid">
<label class="calc-field">
<span>交易资金 (U)</span>
<input id="calc-roll-capital" type="number" min="0.01" step="any" value="1000" required />
</label>
<label class="calc-field">
<span>总风险 %</span>
<input id="calc-roll-risk" type="number" min="0.1" step="0.1" value="5" required />
</label>
<label class="calc-field">
<span>方向</span>
<select id="calc-roll-direction">
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</label>
<label class="calc-field">
<span>现有张数</span>
<input id="calc-roll-qty" type="number" min="0.0001" step="any" required />
</label>
<label class="calc-field">
<span>现有均价</span>
<input id="calc-roll-entry" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>首仓止盈价</span>
<input id="calc-roll-tp" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>加仓价</span>
<input id="calc-roll-add-price" type="number" min="0" step="any" placeholder="手动输入" required />
</label>
<label class="calc-field">
<span>新统一止损</span>
<input id="calc-roll-sl" type="number" min="0" step="any" required />
</label>
<label class="calc-field">
<span>已完成滚仓次数</span>
<input id="calc-roll-legs-done" type="number" min="0" max="3" step="1" value="0" />
</label>
</div>
<div class="calc-actions">
<button type="submit" class="primary">计算</button>
</div>
</form>
<div id="calc-roll-result" class="calc-result hidden"></div>
</section>
</div>
</div>
<div id="page-settings" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">CFG</span> 系统设置</h1>
@@ -735,6 +852,10 @@
<input type="checkbox" id="pref-show-nav-ai" checked />
顶栏显示「AI 教练」
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="pref-show-nav-calculator" checked />
顶栏显示「计算器」
</label>
<p class="settings-display-hint">保存至 hub_settings.json,换浏览器同样生效。关闭导航后对应页面将不可从顶栏进入,直接访问 URL 会跳回监控区。</p>
</div>
<div class="settings-macro-panel card">
@@ -823,11 +944,12 @@
<script src="/assets/chart_draw.js?v=20260609-market-day-split"></script>
<script src="/assets/chart.js?v=20260609-prev-day-lines"></script>
<script src="/assets/plan.js?v=20260614-entry-plan-scheme"></script>
<script src="/assets/calculator.js?v=1"></script>
<script src="/assets/archive.js?v=20260612-archive-ai-chat"></script>
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=2"></script>
<script src="/assets/app.js?v=20260614-nav-feature-toggles"></script>
<script src="/assets/app.js?v=20260614-calculator"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
"""hub_calculator_lib 测算逻辑。"""
from hub_calculator_lib import calc_roll_calculator, calc_trend_calculator
def test_trend_calculator_long_basic():
data, err = calc_trend_calculator(
direction="long",
capital_usdt=1000,
risk_percent=5,
leverage=5,
entry_price=100,
stop_loss=95,
add_upper=110,
take_profit=120,
dca_legs=3,
contract_size=1,
)
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"] == "首仓"
assert data["first_profit_u"] is not None
assert data["first_profit_u"] > 0
def test_trend_calculator_short_rejects_bad_bounds():
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,
)
assert data is None
assert err is not None
def test_roll_calculator_long():
data, err = calc_roll_calculator(
direction="long",
capital_usdt=1000,
risk_percent=5,
qty_existing=10,
entry_existing=100,
take_profit=120,
add_price=105,
new_stop_loss=98,
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