Add hub strategy calculator page with trend and roll risk-based sizing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -16,6 +16,7 @@ DEFAULT_DISPLAY = {
|
||||
"show_nav_plan": True,
|
||||
"show_nav_archive": True,
|
||||
"show_nav_ai": True,
|
||||
"show_nav_calculator": True,
|
||||
}
|
||||
|
||||
DEFAULT_EXCHANGES = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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, ">")
|
||||
.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 '<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 () {},
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user