Replace amp-stats straddle/perp overlays with move-points amplitude ratio.

Input points now drives amplitude hit share; table keeps both-side moves and amp达标.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-28 14:56:48 +08:00
parent 90be23e845
commit c81ba147cc
6 changed files with 885 additions and 1768 deletions
+18 -76
View File
@@ -3,8 +3,8 @@
中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。 中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。
> 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。 > 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。
> 永期对冲公式见 [永期对冲计算器.md](./永期对冲计算器.md)。
> **不改下单链路**;不算 IV。 > **不改下单链路**;不算 IV。
> 买跨 / 永期对冲测算请用中控 **策略计算器**,本页不再做对照盈亏。
--- ---
@@ -22,8 +22,9 @@
2. 选择 **标的** ETH / BTC;数据源固定 **OKX** 2. 选择 **标的** ETH / BTC;数据源固定 **OKX**
3. **起点整点**0023);终点固定 **16:00** 3. **起点整点**0023);终点固定 **16:00**
4. **周期**1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月) 4. **周期**1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月)
5. **计算** → 下方看汇总 + 分页日表 5. 可选填 **波动点数**(如 `50`)→ 看振幅达标占比
6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细 6. **计算** → 下方看汇总 + 振幅占比 + 分页日表
7. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细
**跨天例子** **跨天例子**
@@ -43,8 +44,8 @@
| 字段 | 算法 | | 字段 | 算法 |
|------|------| |------|------|
| 开→高 | `H O` | | 开→高 | `H O`(一边波动) |
| 开→低 | `O L` | | 开→低 | `O L`(另一边波动) |
| **振幅** | `H L`= 开→高 + 开→低) | | **振幅** | `H L`= 开→高 + 开→低) |
| 涨跌值 | `C O` | | 涨跌值 | `C O` |
@@ -58,35 +59,19 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
--- ---
## 对照模式(买跨 / 永期二选一) ## 波动点数 → 振幅占比
表单 **对照模式** 切换 表单可填 **波动点数**(如 `50`)。填写后下方 **振幅占比** 块显示
| 模式 | 表单 | 汇总块 | 日表末列 |
|------|------|--------|----------|
| 买跨双边 | 双边权利金、止盈点 | 买跨对照 | 收益 |
| 永期对冲 | 目标盈利、杠杆、比例 | 永期对冲对照 | 永期盈亏 |
同一时刻只计算 / 展示当前模式;切换后在已有日表上本地重算。
---
## 买跨对照(赌波动)
表单可填 **双边权利金(点)**,例如 `30`;旁边可填 **止盈点**(可空):
| 汇总项 | 口径 | | 汇总项 | 口径 |
|--------|------| |--------|------|
| 开→高超过权利金 | `HO > 权利金` 的天数与占比 | | 振幅≥点数 | `HL ≥ 点数` 的天数与**占比**(主指标) |
| 开→低超过权利金 | `OL > 权利金` 天数与占比 | | 开→高≥点数 | `HO ≥ 点数` 天数与占比 |
| \|涨跌\|超过权利金 | `\|CO\| > 权利金` 天数与占比 | | 开→低≥点数 | `OL ≥ 点数` 天数与占比 |
| 有效波动 | 若设止盈且 `开→高≥止盈``开→低≥止盈` → 用止盈点;否则用 `\|CO\|` | | \|涨跌\|≥点数 | `\|CO\| ≥ 点数` 天数与占比 |
| 买跨收益 | `有效波动 权利金`(日表「收益」列同口径) |
- 方向:**买跨** 日表保留 **开→高 / 开→低**(两边波动点数),并标 **振幅达标**;达标行振幅会高亮。
- 权利金越过:严格 **`>`**;止盈触达:**`≥`** 改点数 / 周末筛选会在已有日表上**本地重算**(不重拉 K 线)。
- 止盈留空 / ≤0:有效波动一律按 `|涨跌|`
- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线)
### 周末 ### 周末
@@ -95,46 +80,6 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
--- ---
## 永期对冲对照
与中控 [永期对冲计算器](./永期对冲计算器.md) 同口径:**永续做多 1 币 + 买期权**(默认比例 **1:2**),在历史振幅日表上做对照。
表单填:目标盈利、永续杠杆、期权杠杆、比例(可改)。**入场价 = 当日开盘**,不再填现价。未填齐目标/杠杆时不计算该块。
```text
单币权利金(日) = 当日开盘 / 期权杠杆
期权币数 = 1 × (期权比例 / 永续比例)
权利金总额(日) = 期权币数 × 单币权利金(日)
```
推「所需点数」时,用样本 **开盘中位数** 作入场参照(汇总里展示的权利金中位同口径)。
### ① 所需点数达标
复用计算器「由比例推点数」(按**当日开盘**推 A/B;汇总展示用样本开盘中位):
| 指标 | 规则 |
|------|------|
| A 所需点数 | 永续方向对、净利=目标 |
| A 达标 | 日 `开→高 ≥ A点数` 的天数与占比 |
| B 所需点数 | 期权方向对、**组合净利**=目标 |
| B 达标 | 日 `开→低 ≥ B点数` 的天数与占比 |
### ② 按日组合盈亏(目标出场)
日盈利目标(如 **15U**)用于出场:
| 情形 | 日盈亏 |
|------|--------|
| 开→高触达 A,或 开→低触达 B | **= 目标盈利**(出场) |
| 两边都触达 | 仍按目标盈利(OHLC 未知先后) |
| 均未触达 | 收盘结算:上涨 `涨跌−权利金−手续费`;下跌 `\|涨跌\|×(期权币数−1)−权利金` |
汇总:目标出场天数 / 收盘结算天数、合计、日均、胜率、上涨日/下跌日盈亏小计、单日最大赚亏;日表 **永期盈亏** 列。
改永期参数 / 周末筛选会**本地重算**(不重拉 K 线)。
---
## 历史 Tab ## 历史 Tab
-**保存到历史** 后出现(不会一算就自动入库) -**保存到历史** 后出现(不会一算就自动入库)
@@ -151,7 +96,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
| `manual_trading_hub/amp_stats_routes.py` | API | | `manual_trading_hub/amp_stats_routes.py` | API |
| `manual_trading_hub/amp_stats_store.py` | 历史 JSON | | `manual_trading_hub/amp_stats_store.py` | 历史 JSON |
| `manual_trading_hub/static/amp_stats.js` | 前端 | | `manual_trading_hub/static/amp_stats.js` | 前端 |
| `tests/test_amp_stats_lib.py` | 单元测试 | | `tests/test_amp_stats_lib.py` | 单 |
--- ---
@@ -160,9 +105,6 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-07-23 | 首版上线说明 | | 2026-07-23 | 首版上线说明 |
| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 | | 2026-07-23 | 买跨对照、周末筛选、止盈点 |
| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 | | 2026-07-28 | 永期对冲对照(后已移除) |
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 | | 2026-07-28 | 去掉买跨/永期;改为波动点数→振幅占比 |
| 2026-07-28 | 永期对冲对照:所需点数达标 + 按日组合盈亏 |
| 2026-07-28 | 永期入场改按日开盘;买跨/永期对照模式二选一 |
| 2026-07-28 | 永期日盈亏按目标盈利出场(开→高/低触达),未触达才收盘结算 |
+105 -577
View File
@@ -173,27 +173,14 @@ def compute_day_row(
} }
def normalize_straddle_premium(raw: Any) -> Optional[float]: def normalize_move_points(raw: Any) -> Optional[float]:
"""双边权利金(点数).空/≤0 表示不做跨式对照.""" """对照波动点数.空/≤0 表示不做点数达标对照."""
if raw is None or raw == "": if raw is None or raw == "":
return None return None
try: try:
v = float(raw) v = float(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError("双边权利金须为数字") from None raise ValueError("波动点数须为数字") from None
if v <= 0:
return None
return v
def normalize_take_profit(raw: Any) -> Optional[float]:
"""止盈点.空/≤0 表示不止盈,有效波动用 |涨跌|."""
if raw is None or raw == "":
return None
try:
v = float(raw)
except (TypeError, ValueError):
raise ValueError("止盈点须为数字") from None
if v <= 0: if v <= 0:
return None return None
return v return v
@@ -231,34 +218,13 @@ def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all")
return out return out
def effective_move_points(row: dict[str, Any], take_profit: Optional[float]) -> float: def _ensure_weekend_flags(item: dict[str, Any]) -> None:
"""触达止盈(≥)用止盈点,否则用 |涨跌|.""" if "is_weekend" in item:
abs_chg = abs(float(row.get("change") or 0)) return
if take_profit is None: if not item.get("settlement_day"):
return abs_chg item.setdefault("weekday_label", "")
tp = float(take_profit) item.setdefault("is_weekend", False)
up = float(row.get("up_points") or 0) return
down = float(row.get("down_points") or 0)
if up >= tp or down >= tp:
return tp
return abs_chg
def enrich_rows_pnl(
rows: list[dict[str, Any]],
*,
straddle_premium: Optional[float] = None,
take_profit: Optional[float] = None,
perp_hedge: Any = None,
) -> list[dict[str, Any]]:
"""为日表附加有效波动 / 是否触达止盈 / 收益(有权利金时) / 永期盈亏."""
prem = normalize_straddle_premium(straddle_premium)
tp = normalize_take_profit(take_profit)
hedge = normalize_perp_hedge_params(perp_hedge)
out: list[dict[str, Any]] = []
for r in rows or []:
item = dict(r)
if "is_weekend" not in item and item.get("settlement_day"):
try: try:
wd = date.fromisoformat(str(item["settlement_day"])).weekday() wd = date.fromisoformat(str(item["settlement_day"])).weekday()
item["weekday"] = wd item["weekday"] = wd
@@ -267,416 +233,96 @@ def enrich_rows_pnl(
except ValueError: except ValueError:
item.setdefault("weekday_label", "") item.setdefault("weekday_label", "")
item.setdefault("is_weekend", False) item.setdefault("is_weekend", False)
move = effective_move_points(item, tp)
hit = False
if tp is not None: def enrich_rows(
hit = float(item.get("up_points") or 0) >= tp or float(item.get("down_points") or 0) >= tp rows: list[dict[str, Any]],
item["effective_move"] = round(move, 4) *,
item["take_profit_hit"] = hit move_points: Any = None,
item["profit"] = round(move - prem, 4) if prem is not None else None ) -> list[dict[str, Any]]:
if hedge is not None: """为日表附加周末标注,以及相对波动点数的两边达标."""
day = perp_hedge_day_result(item, hedge) mp = normalize_move_points(move_points)
item["perp_hedge_pnl"] = day["pnl"] out: list[dict[str, Any]] = []
item["perp_hedge_exit"] = day["exit"] for r in rows or []:
item["perp_hedge_hit_a"] = day["hit_a"] item = dict(r)
item["perp_hedge_hit_b"] = day["hit_b"] _ensure_weekend_flags(item)
else: up = float(item.get("up_points") or 0)
item["perp_hedge_pnl"] = None down = float(item.get("down_points") or 0)
item["perp_hedge_exit"] = None amp = float(item.get("amplitude") or 0)
item["perp_hedge_hit_a"] = False hit_up = bool(mp is not None and up >= mp)
item["perp_hedge_hit_b"] = False hit_down = bool(mp is not None and down >= mp)
amp_hit = bool(mp is not None and amp >= mp)
item["move_points"] = mp
item["hit_up"] = hit_up
item["hit_down"] = hit_down
item["hit_either"] = hit_up or hit_down
item["hit_both"] = hit_up and hit_down
item["amp_hit"] = amp_hit
out.append(item) out.append(item)
return out return out
def normalize_perp_hedge_params(raw: Any) -> Optional[dict[str, float]]: # 兼容旧调用名
"""永期对冲对照参数.缺必填则返回 None(不做对照). def enrich_rows_pnl(rows: list[dict[str, Any]], **kwargs: Any) -> list[dict[str, Any]]:
return enrich_rows(rows, move_points=kwargs.get("move_points"))
入场价按日开盘;表单只需目标盈利/杠杆/比例.旧字段 spot 可忽略.
"""
if raw is None or raw == "":
return None
if not isinstance(raw, dict):
return None
target = _safe_float(raw.get("target_profit_u") if "target_profit_u" in raw else raw.get("target"))
p_lev = _safe_float(raw.get("perp_leverage"))
o_lev = _safe_float(raw.get("option_leverage"))
rp = _safe_float(raw.get("ratio_perp"))
ro = _safe_float(raw.get("ratio_opt"))
ct = _safe_float(raw.get("ct_mult"))
if target is None or p_lev is None or o_lev is None:
return None
if target < 0 or p_lev <= 0 or o_lev <= 0:
return None
if rp is None or rp <= 0:
rp = 1.0
if ro is None or ro <= 0:
ro = 2.0
if ct is None or ct <= 0:
ct = 0.01
opt_coins = 1.0 * (ro / rp)
return {
"target_profit_u": target,
"perp_leverage": p_lev,
"option_leverage": o_lev,
"ratio_perp": rp,
"ratio_opt": ro,
"ct_mult": ct,
"opt_coins": opt_coins,
"opt_sheets": opt_coins / ct,
}
def perp_hedge_day_premium(*, open_px: float, option_leverage: float, opt_coins: float) -> float: def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[str, Any]:
"""单日权利金总额 = 开盘 / 期权杠杆 × 期权币数.""" """波动点数达标汇总:开→高/开→低两边."""
o = float(open_px or 0) mp = float(move_points)
lev = float(option_leverage or 0) if mp <= 0:
coins = float(opt_coins or 0) raise ValueError("波动点数须 > 0")
if o <= 0 or lev <= 0 or coins < 0: work = enrich_rows(rows, move_points=mp)
return 0.0
return coins * (o / lev)
def perp_hedge_required_moves(
*,
open_px: float,
hedge: dict[str, float],
) -> tuple[Optional[float], Optional[float], Optional[str]]:
"""按当日开盘推 A/B 达目标盈利所需点数."""
from lib.hub.hub_perp_options_calc_lib import calc_perp_options_points
spot = float(open_px or 0)
if spot <= 0:
return None, None, "开盘价无效"
points_data, points_err = calc_perp_options_points(
base="ETH",
spot=spot,
capital_usdt=max(spot / hedge["perp_leverage"] * 2, 1000.0),
target_profit_u=hedge["target_profit_u"],
perp_leverage=hedge["perp_leverage"],
option_leverage=hedge["option_leverage"],
ratio_perp=hedge["ratio_perp"],
ratio_opt=hedge["ratio_opt"],
ct_mult=hedge["ct_mult"],
)
if not points_data:
return None, None, points_err
move_a = float((points_data.get("case_a") or {}).get("move_points") or 0) or None
mb = (points_data.get("case_b") or {}).get("move_points_portfolio")
move_b = float(mb) if mb is not None else None
return move_a, move_b, points_err
def perp_hedge_day_pnl_eod(
*,
change: float,
open_px: float,
close_px: float,
option_leverage: float,
opt_coins: float,
) -> float:
"""未触达目标时按收盘结算的组合净利.
上涨: change − 权利金 − 永续开平手续费
下跌: |change|×(opt_coins1) 权利金
"""
from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt
chg = float(change or 0)
prem = perp_hedge_day_premium(
open_px=open_px, option_leverage=option_leverage, opt_coins=opt_coins
)
coins = float(opt_coins or 0)
if chg >= 0:
fee = 0.0
if open_px and close_px and open_px > 0 and close_px > 0:
fee = estimate_roundtrip_fee_usdt(open_px, close_px, qty=1.0, contract_size=1.0)
return round(chg - prem - fee, 4)
return round(abs(chg) * (coins - 1.0) - prem, 4)
def perp_hedge_day_result(row: dict[str, Any], hedge: dict[str, float]) -> dict[str, Any]:
"""单日永期结果:触达目标点数则按目标盈利出场,否则收盘结算.
A: 开→高 ≥ move_a → 出场净利 = 目标盈利
B: 开→低 ≥ move_b → 出场净利 = 目标盈利
两边都触达时仍按目标盈利(路径未知,任一边出场均约为目标).
"""
open_px = float(row.get("open") or 0)
close_px = float(row.get("close") or 0)
change = float(row.get("change") or 0)
up_pts = float(row.get("up_points") or 0)
down_pts = float(row.get("down_points") or 0)
target = float(hedge["target_profit_u"])
move_a, move_b, _err = perp_hedge_required_moves(open_px=open_px, hedge=hedge)
hit_a = bool(move_a is not None and move_a > 0 and up_pts >= move_a)
hit_b = bool(move_b is not None and move_b > 0 and down_pts >= move_b)
if hit_a or hit_b:
if hit_a and hit_b:
exit_tag = "target_both"
elif hit_a:
exit_tag = "target_a"
else:
exit_tag = "target_b"
return {
"pnl": round(target, 4),
"exit": exit_tag,
"hit_a": hit_a,
"hit_b": hit_b,
"move_a": move_a,
"move_b": move_b,
}
return {
"pnl": perp_hedge_day_pnl_eod(
change=change,
open_px=open_px,
close_px=close_px,
option_leverage=float(hedge["option_leverage"]),
opt_coins=float(hedge["opt_coins"]),
),
"exit": "eod",
"hit_a": False,
"hit_b": False,
"move_a": move_a,
"move_b": move_b,
}
# 兼容旧名:默认按「目标出场」完整日结果取 pnl
def perp_hedge_day_pnl(
*,
change: float,
open_px: float,
close_px: float,
option_leverage: float,
opt_coins: float,
up_points: Optional[float] = None,
down_points: Optional[float] = None,
target_profit_u: Optional[float] = None,
perp_leverage: float = 10.0,
ratio_perp: float = 1.0,
ratio_opt: float = 2.0,
ct_mult: float = 0.01,
) -> float:
"""单日盈亏.若给了目标与开→高/低,触达则按目标出场;否则收盘结算."""
if target_profit_u is None or up_points is None or down_points is None:
return perp_hedge_day_pnl_eod(
change=change,
open_px=open_px,
close_px=close_px,
option_leverage=option_leverage,
opt_coins=opt_coins,
)
hedge = {
"target_profit_u": float(target_profit_u),
"perp_leverage": float(perp_leverage),
"option_leverage": float(option_leverage),
"ratio_perp": float(ratio_perp),
"ratio_opt": float(ratio_opt),
"ct_mult": float(ct_mult),
"opt_coins": float(opt_coins),
"opt_sheets": float(opt_coins) / float(ct_mult),
}
return float(
perp_hedge_day_result(
{
"open": open_px,
"close": close_px,
"change": change,
"up_points": up_points,
"down_points": down_points,
},
hedge,
)["pnl"]
)
def perp_hedge_stats(
rows: list[dict[str, Any]],
hedge: dict[str, float],
) -> dict[str, Any]:
"""永期对冲:所需点数达标 + 按日组合盈亏汇总.
达标看开→高/开→低是否触达当日入场推得的 A/B 点数;
触达则日盈亏=目标盈利,否则收盘结算.汇总展示点数用样本开盘中位.
"""
opens = [float(r.get("open") or 0) for r in (rows or []) if float(r.get("open") or 0) > 0]
spot_ref = statistics.median(opens) if opens else None
prem_ref = (
perp_hedge_day_premium(
open_px=spot_ref,
option_leverage=hedge["option_leverage"],
opt_coins=hedge["opt_coins"],
)
if spot_ref is not None
else None
)
prem_per_coin_ref = (spot_ref / hedge["option_leverage"]) if spot_ref is not None else None
move_a = None
move_b = None
points_err = None
if spot_ref is not None and spot_ref > 0:
move_a, move_b, points_err = perp_hedge_required_moves(open_px=spot_ref, hedge=hedge)
else:
points_err = "样本无有效开盘价,无法推所需点数"
work: list[dict[str, Any]] = []
for r in rows or []:
item = dict(r)
day = perp_hedge_day_result(item, hedge)
item["perp_hedge_pnl"] = day["pnl"]
item["perp_hedge_exit"] = day["exit"]
item["perp_hedge_hit_a"] = day["hit_a"]
item["perp_hedge_hit_b"] = day["hit_b"]
work.append(item)
n = len(work) n = len(work)
empty = { empty = {
"enabled": True, "move_points": round(mp, 4),
"entry": "open",
"exit": "target_or_eod",
"spot": None if spot_ref is None else round(spot_ref, 4),
"target_profit_u": round(hedge["target_profit_u"], 4),
"perp_leverage": round(hedge["perp_leverage"], 4),
"option_leverage": round(hedge["option_leverage"], 4),
"ratio_perp": round(hedge["ratio_perp"], 4),
"ratio_opt": round(hedge["ratio_opt"], 4),
"ratio_label": f"{hedge['ratio_perp']:g}:{hedge['ratio_opt']:g}",
"prem_per_coin": None if prem_per_coin_ref is None else round(prem_per_coin_ref, 4),
"opt_coins": round(hedge["opt_coins"], 4),
"opt_sheets": round(hedge["opt_sheets"], 4),
"premium_total": None if prem_ref is None else round(prem_ref, 4),
"move_a": None if move_a is None else round(move_a, 4),
"move_b": None if move_b is None else round(move_b, 4),
"points_error": points_err,
"sample_count": n, "sample_count": n,
"hit_a_days": 0, "up_hit_days": 0,
"hit_a_ratio": None, "up_hit_ratio": None,
"hit_b_days": 0, "down_hit_days": 0,
"hit_b_ratio": None, "down_hit_ratio": None,
"target_exit_days": 0, "either_hit_days": 0,
"target_exit_ratio": None, "either_hit_ratio": None,
"eod_days": 0, "both_hit_days": 0,
"pnl_total": None, "both_hit_ratio": None,
"pnl_avg": None, "amp_hit_days": 0,
"win_days": 0, "amp_hit_ratio": None,
"win_ratio": None, "abs_change_hit_days": 0,
"pnl_max": None, "abs_change_hit_ratio": None,
"pnl_min": None,
"up_days": 0,
"down_days": 0,
"up_pnl_total": None,
"down_pnl_total": None,
} }
if n <= 0: if n <= 0:
return empty return empty
up_hit = sum(1 for r in work if r.get("hit_up"))
hit_a = sum(1 for r in work if r.get("perp_hedge_hit_a")) down_hit = sum(1 for r in work if r.get("hit_down"))
hit_b = sum(1 for r in work if r.get("perp_hedge_hit_b")) either = sum(1 for r in work if r.get("hit_either"))
target_exits = sum(1 for r in work if str(r.get("perp_hedge_exit") or "").startswith("target")) both = sum(1 for r in work if r.get("hit_both"))
eod_days = sum(1 for r in work if r.get("perp_hedge_exit") == "eod") amp_hit = sum(1 for r in work if float(r.get("amplitude") or 0) >= mp)
abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp)
pnls = [float(r["perp_hedge_pnl"]) for r in work if r.get("perp_hedge_pnl") is not None]
win = sum(1 for p in pnls if p > 0)
up_rows = [r for r in work if float(r.get("change") or 0) >= 0]
down_rows = [r for r in work if float(r.get("change") or 0) < 0]
up_pnls = [float(r["perp_hedge_pnl"]) for r in up_rows if r.get("perp_hedge_pnl") is not None]
down_pnls = [float(r["perp_hedge_pnl"]) for r in down_rows if r.get("perp_hedge_pnl") is not None]
empty.update( empty.update(
{ {
"hit_a_days": hit_a, "up_hit_days": up_hit,
"hit_a_ratio": round(hit_a / n, 4), "up_hit_ratio": round(up_hit / n, 4),
"hit_b_days": hit_b, "down_hit_days": down_hit,
"hit_b_ratio": round(hit_b / n, 4), "down_hit_ratio": round(down_hit / n, 4),
"target_exit_days": target_exits, "either_hit_days": either,
"target_exit_ratio": round(target_exits / n, 4), "either_hit_ratio": round(either / n, 4),
"eod_days": eod_days, "both_hit_days": both,
"pnl_total": round(sum(pnls), 4) if pnls else None, "both_hit_ratio": round(both / n, 4),
"pnl_avg": round(statistics.fmean(pnls), 4) if pnls else None, "amp_hit_days": amp_hit,
"win_days": win, "amp_hit_ratio": round(amp_hit / n, 4),
"win_ratio": round(win / n, 4), "abs_change_hit_days": abs_hit,
"pnl_max": round(max(pnls), 4) if pnls else None, "abs_change_hit_ratio": round(abs_hit / n, 4),
"pnl_min": round(min(pnls), 4) if pnls else None,
"up_days": len(up_rows),
"down_days": len(down_rows),
"up_pnl_total": round(sum(up_pnls), 4) if up_pnls else None,
"down_pnl_total": round(sum(down_pnls), 4) if down_pnls else None,
} }
) )
return empty return empty
def straddle_long_stats(
rows: list[dict[str, Any]],
premium: float,
*,
take_profit: Any = None,
) -> dict[str, Any]:
"""买跨:越过权利金用严格 >;收益=有效波动−权利金(止盈≥触达用止盈点,否则|涨跌|)."""
prem = float(premium)
if prem <= 0:
raise ValueError("双边权利金须 > 0")
tp = normalize_take_profit(take_profit)
enriched = enrich_rows_pnl(rows, straddle_premium=prem, take_profit=tp)
if not enriched:
return {
"side": "long_straddle",
"premium": prem,
"take_profit": tp,
"sample_count": 0,
"up_exceed_days": 0,
"up_exceed_ratio": None,
"down_exceed_days": 0,
"down_exceed_ratio": None,
"abs_change_exceed_days": 0,
"abs_change_exceed_ratio": None,
"tp_hit_days": 0,
"tp_hit_ratio": None,
"pnl_total": None,
"pnl_avg": None,
"win_days": 0,
"win_ratio": None,
"pnl_max": None,
"pnl_min": None,
}
n = len(enriched)
up_ex = sum(1 for r in enriched if float(r["up_points"]) > prem)
down_ex = sum(1 for r in enriched if float(r["down_points"]) > prem)
abs_ex = sum(1 for r in enriched if abs(float(r["change"])) > prem)
tp_hits = sum(1 for r in enriched if r.get("take_profit_hit"))
pnls = [float(r["profit"]) for r in enriched if r.get("profit") is not None]
win = sum(1 for p in pnls if p > 0)
return {
"side": "long_straddle",
"premium": round(prem, 4),
"take_profit": round(tp, 4) if tp is not None else None,
"sample_count": n,
"up_exceed_days": up_ex,
"up_exceed_ratio": round(up_ex / n, 4),
"down_exceed_days": down_ex,
"down_exceed_ratio": round(down_ex / n, 4),
"abs_change_exceed_days": abs_ex,
"abs_change_exceed_ratio": round(abs_ex / n, 4),
"tp_hit_days": tp_hits,
"tp_hit_ratio": round(tp_hits / n, 4) if tp is not None else None,
"pnl_total": round(sum(pnls), 4),
"pnl_avg": round(statistics.fmean(pnls), 4),
"win_days": win,
"win_ratio": round(win / n, 4),
"pnl_max": round(max(pnls), 4),
"pnl_min": round(min(pnls), 4),
}
def summarize_rows( def summarize_rows(
rows: list[dict[str, Any]], rows: list[dict[str, Any]],
*, *,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
perp_hedge: Any = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
hedge = normalize_perp_hedge_params(perp_hedge) mp = normalize_move_points(move_points)
if not rows: if not rows:
out = { out = {
"sample_count": 0, "sample_count": 0,
@@ -690,14 +336,10 @@ def summarize_rows(
"avg_down_points": None, "avg_down_points": None,
"up_day_ratio": None, "up_day_ratio": None,
"down_day_ratio": None, "down_day_ratio": None,
"straddle": None, "move_points_stats": None,
"perp_hedge": None,
} }
prem = normalize_straddle_premium(straddle_premium) if mp is not None:
if prem is not None: out["move_points_stats"] = move_points_stats([], mp)
out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit)
if hedge is not None:
out["perp_hedge"] = perp_hedge_stats([], hedge)
return out return out
amps = [float(r["amplitude"]) for r in rows] amps = [float(r["amplitude"]) for r in rows]
ups = [float(r["up_points"]) for r in rows] ups = [float(r["up_points"]) for r in rows]
@@ -719,14 +361,10 @@ def summarize_rows(
"avg_down_points": round(statistics.fmean(downs), 4), "avg_down_points": round(statistics.fmean(downs), 4),
"up_day_ratio": round(up_days / n, 4), "up_day_ratio": round(up_days / n, 4),
"down_day_ratio": round(down_days / n, 4), "down_day_ratio": round(down_days / n, 4),
"straddle": None, "move_points_stats": None,
"perp_hedge": None,
} }
prem = normalize_straddle_premium(straddle_premium) if mp is not None:
if prem is not None: out["move_points_stats"] = move_points_stats(rows, mp)
out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit)
if hedge is not None:
out["perp_hedge"] = perp_hedge_stats(rows, hedge)
return out return out
@@ -930,10 +568,8 @@ def compute_amp_stats(
start_hour: int = 16, start_hour: int = 16,
period: str = "2m", period: str = "2m",
custom_days: Any = None, custom_days: Any = None,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all", weekend_filter: Any = "all",
perp_hedge: Any = None,
now: Optional[datetime] = None, now: Optional[datetime] = None,
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -941,10 +577,8 @@ def compute_amp_stats(
sh = int(start_hour) sh = int(start_hour)
if sh < 0 or sh > 23: if sh < 0 or sh > 23:
raise ValueError("起点须为 0-23 整点") raise ValueError("起点须为 0-23 整点")
prem = normalize_straddle_premium(straddle_premium) mp = normalize_move_points(move_points)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter) we_mode = normalize_weekend_filter(weekend_filter)
hedge = normalize_perp_hedge_params(perp_hedge)
sample_days = resolve_sample_days(period, custom_days) sample_days = resolve_sample_days(period, custom_days)
settlements = list_settlement_dates(sample_days=sample_days, now=now) settlements = list_settlement_dates(sample_days=sample_days, now=now)
if not settlements: if not settlements:
@@ -974,10 +608,8 @@ def compute_amp_stats(
start_hour=sh, start_hour=sh,
period=period, period=period,
sample_days=sample_days, sample_days=sample_days,
straddle_premium=prem, move_points=mp,
take_profit=tp,
weekend_filter=we_mode, weekend_filter=we_mode,
perp_hedge=hedge,
price_source=price_source, price_source=price_source,
inst_id=inst_id, inst_id=inst_id,
missing=missing, missing=missing,
@@ -991,21 +623,17 @@ def build_amp_result(
start_hour: int, start_hour: int,
period: str, period: str,
sample_days: int, sample_days: int,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all", weekend_filter: Any = "all",
perp_hedge: Any = None,
price_source: str = "", price_source: str = "",
inst_id: str = "", inst_id: str = "",
missing: Optional[list[str]] = None, missing: Optional[list[str]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
prem = normalize_straddle_premium(straddle_premium) mp = normalize_move_points(move_points)
tp = normalize_take_profit(take_profit)
we_mode = normalize_weekend_filter(weekend_filter) we_mode = normalize_weekend_filter(weekend_filter)
hedge = normalize_perp_hedge_params(perp_hedge)
filtered = filter_weekend_rows(rows_all, we_mode) filtered = filter_weekend_rows(rows_all, we_mode)
rows = enrich_rows_pnl(filtered, straddle_premium=prem, take_profit=tp, perp_hedge=hedge) rows = enrich_rows(filtered, move_points=mp)
summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp, perp_hedge=hedge) summary = summarize_rows(rows, move_points=mp)
if period == "custom" or str(period).startswith("custom:"): if period == "custom" or str(period).startswith("custom:"):
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}" period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
else: else:
@@ -1020,10 +648,8 @@ def build_amp_result(
"end_hour": END_HOUR, "end_hour": END_HOUR,
"period": period_label, "period": period_label,
"sample_days_requested": sample_days, "sample_days_requested": sample_days,
"straddle_premium": prem, "move_points": mp,
"take_profit": tp,
"weekend_filter": we_mode, "weekend_filter": we_mode,
"perp_hedge": hedge,
"timeframe": TIMEFRAME, "timeframe": TIMEFRAME,
"price_source": price_source, "price_source": price_source,
"inst_id": inst_id, "inst_id": inst_id,
@@ -1043,15 +669,13 @@ def reframe_amp_stats(
start_hour: int = 16, start_hour: int = 16,
period: str = "2m", period: str = "2m",
sample_days: int = 60, sample_days: int = 60,
straddle_premium: Any = None, move_points: Any = None,
take_profit: Any = None,
weekend_filter: Any = "all", weekend_filter: Any = "all",
perp_hedge: Any = None,
price_source: str = "", price_source: str = "",
inst_id: str = "", inst_id: str = "",
missing: Optional[list[str]] = None, missing: Optional[list[str]] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""已有日表上改周末/权利金/止盈/永期参数,不拉 K 线.""" """已有日表上改周末/波动点数,不拉 K 线."""
key = normalize_symbol(symbol) key = normalize_symbol(symbol)
return build_amp_result( return build_amp_result(
rows_all=list(rows_all or []), rows_all=list(rows_all or []),
@@ -1059,10 +683,8 @@ def reframe_amp_stats(
start_hour=int(start_hour), start_hour=int(start_hour),
period=period, period=period,
sample_days=int(sample_days or 60), sample_days=int(sample_days or 60),
straddle_premium=straddle_premium, move_points=move_points,
take_profit=take_profit,
weekend_filter=weekend_filter, weekend_filter=weekend_filter,
perp_hedge=perp_hedge,
price_source=price_source, price_source=price_source,
inst_id=inst_id, inst_id=inst_id,
missing=missing, missing=missing,
@@ -1104,104 +726,14 @@ def build_export_csv(payload: dict[str, Any]) -> str:
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")]) w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")]) w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")])
w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")]) w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
st = s.get("straddle") or {} mp = s.get("move_points_stats") or {}
if st: if mp:
w.writerow([]) w.writerow([])
w.writerow(["买跨对照·双边权利金】", st.get("premium"), "止盈点", st.get("take_profit")]) w.writerow(["波动点数·振幅占比】", mp.get("move_points")])
w.writerow(["开→高超过权利金", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")]) w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")])
w.writerow(["开→低超过权利金", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")]) w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")])
w.writerow(["|涨跌|超过权利金", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")]) w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")])
if st.get("take_profit") is not None: w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_hit_ratio")])
w.writerow(["触达止盈天数", st.get("tp_hit_days"), "占比", st.get("tp_hit_ratio")])
w.writerow(
[
"买跨点数盈亏合计",
st.get("pnl_total"),
"日均",
st.get("pnl_avg"),
"赚钱天数",
st.get("win_days"),
"胜率",
st.get("win_ratio"),
]
)
w.writerow(["单日最大赚", st.get("pnl_max"), "单日最大亏", st.get("pnl_min")])
ph = s.get("perp_hedge") or {}
if ph:
w.writerow([])
w.writerow(
[
"【永期对冲对照】",
"比例",
ph.get("ratio_label"),
"入场",
"按日开盘",
"推点数开盘中位",
ph.get("spot"),
"目标",
ph.get("target_profit_u"),
]
)
w.writerow(
[
"单币权利金(开盘中位)",
ph.get("prem_per_coin"),
"权利金总额(开盘中位)",
ph.get("premium_total"),
"期权币数",
ph.get("opt_coins"),
]
)
w.writerow(
[
"A所需点数",
ph.get("move_a"),
"A达标天(开→高)",
ph.get("hit_a_days"),
"占比",
ph.get("hit_a_ratio"),
]
)
w.writerow(
[
"B所需点数(组合)",
ph.get("move_b"),
"B达标天(开→低)",
ph.get("hit_b_days"),
"占比",
ph.get("hit_b_ratio"),
]
)
w.writerow(
[
"目标出场天",
ph.get("target_exit_days"),
"收盘结算天",
ph.get("eod_days"),
"目标盈利",
ph.get("target_profit_u"),
]
)
w.writerow(
[
"组合盈亏合计",
ph.get("pnl_total"),
"日均",
ph.get("pnl_avg"),
"胜率",
ph.get("win_ratio"),
]
)
w.writerow(
[
"上涨日盈亏",
ph.get("up_pnl_total"),
"下跌日盈亏",
ph.get("down_pnl_total"),
"最大赚/亏",
f"{ph.get('pnl_max')} / {ph.get('pnl_min')}",
]
)
w.writerow([]) w.writerow([])
w.writerow(["【日表明细】"]) w.writerow(["【日表明细】"])
w.writerow( w.writerow(
@@ -1219,10 +751,8 @@ def build_export_csv(payload: dict[str, Any]) -> str:
"开→低", "开→低",
"振幅", "振幅",
"涨跌值", "涨跌值",
"有效波动", "对照点数",
"触达止盈", "振幅达标",
"收益",
"永期盈亏",
] ]
) )
for r in payload.get("rows") or []: for r in payload.get("rows") or []:
@@ -1241,10 +771,8 @@ def build_export_csv(payload: dict[str, Any]) -> str:
r.get("down_points"), r.get("down_points"),
r.get("amplitude"), r.get("amplitude"),
r.get("change"), r.get("change"),
r.get("effective_move"), r.get("move_points") if r.get("move_points") is not None else "",
"" if r.get("take_profit_hit") else "", "" if r.get("amp_hit") else ("" if r.get("move_points") is not None else ""),
r.get("profit"),
r.get("perp_hedge_pnl"),
] ]
) )
return buf.getvalue() return buf.getvalue()
+12 -68
View File
@@ -12,33 +12,20 @@ from lib.hub.amp_stats_lib import (
build_export_csv, build_export_csv,
compute_amp_stats, compute_amp_stats,
export_filename, export_filename,
normalize_straddle_premium, normalize_move_points,
normalize_take_profit,
normalize_weekend_filter, normalize_weekend_filter,
reframe_amp_stats, reframe_amp_stats,
rows_page, rows_page,
) )
class PerpHedgeBody(BaseModel):
spot: Optional[float] = None
target_profit_u: Optional[float] = None
perp_leverage: Optional[float] = None
option_leverage: Optional[float] = None
ratio_perp: float = 1.0
ratio_opt: float = 2.0
ct_mult: float = 0.01
class ComputeBody(BaseModel): class ComputeBody(BaseModel):
symbol: str = "eth" symbol: str = "eth"
start_hour: int = 16 start_hour: int = 16
period: str = "2m" period: str = "2m"
custom_days: Optional[int] = None custom_days: Optional[int] = None
straddle_premium: Optional[float] = None move_points: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all" weekend_filter: str = "all"
perp_hedge: Optional[PerpHedgeBody] = None
page: int = 1 page: int = 1
page_size: int = 20 page_size: int = 20
@@ -48,29 +35,21 @@ class SaveBody(BaseModel):
class ReframeBody(BaseModel): class ReframeBody(BaseModel):
"""已有日表上改周末/权利金/止盈/永期参数(不拉 K 线).""" """已有日表上改周末/波动点数(不拉 K 线)."""
rows_all: list[dict[str, Any]] = Field(default_factory=list) rows_all: list[dict[str, Any]] = Field(default_factory=list)
symbol: str = "eth" symbol: str = "eth"
start_hour: int = 16 start_hour: int = 16
period: str = "2m" period: str = "2m"
sample_days: int = 60 sample_days: int = 60
straddle_premium: Optional[float] = None move_points: Optional[float] = None
take_profit: Optional[float] = None
weekend_filter: str = "all" weekend_filter: str = "all"
perp_hedge: Optional[PerpHedgeBody] = None
price_source: str = "" price_source: str = ""
inst_id: str = "" inst_id: str = ""
page: int = 1 page: int = 1
page_size: int = 20 page_size: int = 20
def _hedge_dict(body_hedge: Optional[PerpHedgeBody]) -> Optional[dict[str, Any]]:
if body_hedge is None:
return None
return body_hedge.model_dump()
def create_amp_stats_router() -> APIRouter: def create_amp_stats_router() -> APIRouter:
router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"]) router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"])
@@ -102,12 +81,7 @@ def create_amp_stats_router() -> APIRouter:
"default_weekend_filter": "all", "default_weekend_filter": "all",
"timeframe": "1H", "timeframe": "1H",
"metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)", "metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)",
"straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金", "move_points_note": "填波动点数后统计振幅≥该点数的天数占比;日表显示开→高/开→低两边点数与振幅是否达标",
"perp_hedge_note": "永期对冲:永续多1币+买期权;入场按日开盘;比例默认1:2;与买跨二选一对照",
"overlay_modes": [
{"key": "straddle", "label": "买跨双边"},
{"key": "perp", "label": "永期对冲"},
],
} }
@router.post("/compute") @router.post("/compute")
@@ -118,10 +92,8 @@ def create_amp_stats_router() -> APIRouter:
start_hour=body.start_hour, start_hour=body.start_hour,
period=body.period, period=body.period,
custom_days=body.custom_days, custom_days=body.custom_days,
straddle_premium=body.straddle_premium, move_points=body.move_points,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter, weekend_filter=body.weekend_filter,
perp_hedge=_hedge_dict(body.perp_hedge),
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -146,10 +118,8 @@ def create_amp_stats_router() -> APIRouter:
start_hour=body.start_hour, start_hour=body.start_hour,
period=body.period, period=body.period,
sample_days=body.sample_days, sample_days=body.sample_days,
straddle_premium=body.straddle_premium, move_points=body.move_points,
take_profit=body.take_profit,
weekend_filter=body.weekend_filter, weekend_filter=body.weekend_filter,
perp_hedge=_hedge_dict(body.perp_hedge),
price_source=body.price_source, price_source=body.price_source,
inst_id=body.inst_id, inst_id=body.inst_id,
) )
@@ -190,33 +160,15 @@ def create_amp_stats_router() -> APIRouter:
start_hour: int = Query(default=16), start_hour: int = Query(default=16),
period: str = Query(default="2m"), period: str = Query(default="2m"),
custom_days: Optional[int] = Query(default=None), custom_days: Optional[int] = Query(default=None),
straddle_premium: Optional[float] = Query(default=None), move_points: Optional[float] = Query(default=None),
take_profit: Optional[float] = Query(default=None),
weekend_filter: str = Query(default="all"), weekend_filter: str = Query(default="all"),
hedge_spot: Optional[float] = Query(default=None),
hedge_target: Optional[float] = Query(default=None),
hedge_perp_lev: Optional[float] = Query(default=None),
hedge_opt_lev: Optional[float] = Query(default=None),
hedge_ratio_perp: float = Query(default=1.0),
hedge_ratio_opt: float = Query(default=2.0),
hedge_ct_mult: float = Query(default=0.01),
): ):
hedge_q = {
"target_profit_u": hedge_target,
"perp_leverage": hedge_perp_lev,
"option_leverage": hedge_opt_lev,
"ratio_perp": hedge_ratio_perp,
"ratio_opt": hedge_ratio_opt,
"ct_mult": hedge_ct_mult,
}
hedge_q_ready = hedge_target is not None and hedge_perp_lev is not None and hedge_opt_lev is not None
if (history_id or "").strip(): if (history_id or "").strip():
item = get_history(history_id.strip()) item = get_history(history_id.strip())
if not item: if not item:
raise HTTPException(status_code=404, detail="历史不存在") raise HTTPException(status_code=404, detail="历史不存在")
rows_all = item.get("rows_all") or item.get("rows") or [] rows_all = item.get("rows_all") or item.get("rows") or []
item_hedge = item.get("perp_hedge") if isinstance(item.get("perp_hedge"), dict) else None use_mp = move_points if move_points is not None else item.get("move_points")
use_hedge = hedge_q if hedge_q_ready else item_hedge
try: try:
payload = reframe_amp_stats( payload = reframe_amp_stats(
rows_all=rows_all, rows_all=rows_all,
@@ -224,12 +176,8 @@ def create_amp_stats_router() -> APIRouter:
start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour), start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour),
period=str(item.get("period") or period), period=str(item.get("period") or period),
sample_days=int(item.get("sample_days_requested") or 60), sample_days=int(item.get("sample_days_requested") or 60),
straddle_premium=straddle_premium move_points=use_mp,
if straddle_premium is not None
else item.get("straddle_premium"),
take_profit=take_profit if take_profit is not None else item.get("take_profit"),
weekend_filter=weekend_filter or item.get("weekend_filter") or "all", weekend_filter=weekend_filter or item.get("weekend_filter") or "all",
perp_hedge=use_hedge,
price_source=str(item.get("price_source") or ""), price_source=str(item.get("price_source") or ""),
inst_id=str(item.get("inst_id") or ""), inst_id=str(item.get("inst_id") or ""),
missing=item.get("missing_days") or [], missing=item.get("missing_days") or [],
@@ -238,19 +186,15 @@ def create_amp_stats_router() -> APIRouter:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
else: else:
try: try:
# validate enums early
normalize_weekend_filter(weekend_filter) normalize_weekend_filter(weekend_filter)
normalize_straddle_premium(straddle_premium) normalize_move_points(move_points)
normalize_take_profit(take_profit)
payload = compute_amp_stats( payload = compute_amp_stats(
symbol=symbol, symbol=symbol,
start_hour=start_hour, start_hour=start_hour,
period=period, period=period,
custom_days=custom_days, custom_days=custom_days,
straddle_premium=straddle_premium, move_points=move_points,
take_profit=take_profit,
weekend_filter=weekend_filter, weekend_filter=weekend_filter,
perp_hedge=hedge_q if hedge_q_ready else None,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
+38 -197
View File
@@ -1,5 +1,5 @@
/** /**
* 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选. * 中控振幅统计:OKX ETH/BTC + 波动点数振幅占比 + 周末筛选.
*/ */
(function () { (function () {
const page = document.getElementById("page-amp-stats"); const page = document.getElementById("page-amp-stats");
@@ -39,32 +39,8 @@
return (n * 100).toFixed(1) + "%"; return (n * 100).toFixed(1) + "%";
} }
function overlayMode() { function readMovePoints() {
return el("amp-overlay-mode")?.value || "straddle"; const raw = (el("amp-move-points")?.value || "").trim();
}
function syncOverlayMode() {
const isPerp = overlayMode() === "perp";
page.querySelectorAll(".amp-overlay-straddle").forEach((n) => n.classList.toggle("hidden", isPerp));
page.querySelectorAll(".amp-overlay-perp").forEach((n) => n.classList.toggle("hidden", !isPerp));
el("amp-overlay-straddle-block")?.classList.toggle("hidden", isPerp);
el("amp-overlay-perp-block")?.classList.toggle("hidden", !isPerp);
const col = el("amp-col-pnl");
if (col) col.textContent = isPerp ? "永期盈亏" : "收益";
}
function readPremium() {
if (overlayMode() !== "straddle") return null;
const raw = (el("amp-straddle-premium")?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function readTakeProfit() {
if (overlayMode() !== "straddle") return null;
const raw = (el("amp-take-profit")?.value || "").trim();
if (!raw) return null; if (!raw) return null;
const n = Number(raw); const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return null; if (!Number.isFinite(n) || n <= 0) return null;
@@ -75,30 +51,6 @@
return el("amp-weekend-filter")?.value || "all"; return el("amp-weekend-filter")?.value || "all";
} }
function readNum(id) {
const raw = (el(id)?.value || "").trim();
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function readPerpHedge() {
if (overlayMode() !== "perp") return null;
const target = readNum("amp-hedge-target");
const perpLev = readNum("amp-hedge-perp-lev");
const optLev = readNum("amp-hedge-opt-lev");
if (target == null || perpLev == null || optLev == null) return null;
if (target < 0 || perpLev <= 0 || optLev <= 0) return null;
return {
target_profit_u: target,
perp_leverage: perpLev,
option_leverage: optLev,
ratio_perp: readNum("amp-hedge-ratio-perp") || 1,
ratio_opt: readNum("amp-hedge-ratio-opt") || 2,
ct_mult: 0.01,
};
}
function setStatus(msg) { function setStatus(msg) {
const s = el("amp-status"); const s = el("amp-status");
if (s) s.textContent = msg || ""; if (s) s.textContent = msg || "";
@@ -135,21 +87,13 @@
} }
} }
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "is-pos" : "is-neg";
}
function renderSummary(summary, result) { function renderSummary(summary, result) {
const box = el("amp-summary"); const box = el("amp-summary");
if (!box) return; if (!box) return;
const s = summary || {}; const s = summary || {};
syncOverlayMode();
if (!s.sample_count) { if (!s.sample_count) {
box.innerHTML = '<p class="amp-empty">暂无汇总</p>'; box.innerHTML = '<p class="amp-empty">暂无汇总</p>';
renderStraddle(null); renderMoveStats(null);
renderPerpHedge(null);
return; return;
} }
box.innerHTML = box.innerHTML =
@@ -163,76 +107,23 @@
`<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` + `<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` +
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` + `<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
`</div>`; `</div>`;
if (overlayMode() === "perp") { renderMoveStats(s.move_points_stats);
renderStraddle(null);
renderPerpHedge(s.perp_hedge);
} else {
renderPerpHedge(null);
renderStraddle(s.straddle);
}
} }
function renderStraddle(st) { function renderMoveStats(ms) {
const box = el("amp-straddle"); const box = el("amp-move-stats");
if (!box) return; if (!box) return;
if (!st) { if (!ms) {
box.innerHTML = '<p class="amp-empty">填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏</p>'; box.innerHTML = '<p class="amp-empty">填写「波动点数」后计算,可看振幅≥该点数的天数占比</p>';
return; return;
} }
const verdict =
st.pnl_total == null
? "—"
: Number(st.pnl_total) > 0
? "样本合计盈利"
: Number(st.pnl_total) < 0
? "样本合计亏损"
: "样本合计持平";
const tpLine =
st.take_profit != null
? `<div><span class="amp-sum-k">止盈点 / 触达</span><span class="amp-sum-v">${esc(st.take_profit)} · ${esc(st.tp_hit_days)} 天 · ${esc(pct(st.tp_hit_ratio))}</span></div>`
: `<div><span class="amp-sum-k">止盈点</span><span class="amp-sum-v">未设(按|涨跌|)</span></div>`;
box.innerHTML = box.innerHTML =
`<div class="amp-sum-grid">` + `<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">双边权利金</span><span class="amp-sum-v">${esc(st.premium)}</span></div>` + `<div><span class="amp-sum-k">对照点数</span><span class="amp-sum-v">${esc(ms.move_points)}</span></div>` +
tpLine + `<div><span class="amp-sum-k">振幅≥点数</span><span class="amp-sum-v">${esc(ms.amp_hit_days)} 天 · <strong>${esc(pct(ms.amp_hit_ratio))}</strong></span></div>` +
`<div><span class="amp-sum-k">开→高超过权利金</span><span class="amp-sum-v">${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}</span></div>` + `<div><span class="amp-sum-k">开→高≥点数</span><span class="amp-sum-v">${esc(ms.up_hit_days)} 天 · ${esc(pct(ms.up_hit_ratio))}</span></div>` +
`<div><span class="amp-sum-k">开→低超过权利金</span><span class="amp-sum-v">${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}</span></div>` + `<div><span class="amp-sum-k">开→低≥点数</span><span class="amp-sum-v">${esc(ms.down_hit_days)} 天 · ${esc(pct(ms.down_hit_ratio))}</span></div>` +
`<div><span class="amp-sum-k">|涨跌|超过权利金</span><span class="amp-sum-v">${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}</span></div>` + `<div><span class="amp-sum-k">|涨跌|≥点数</span><span class="amp-sum-v">${esc(ms.abs_change_hit_days)} 天 · ${esc(pct(ms.abs_change_hit_ratio))}</span></div>` +
`<div><span class="amp-sum-k">买跨盈亏合计</span><span class="amp-sum-v ${pnlClass(st.pnl_total)}">${esc(st.pnl_total)} <small>(${esc(verdict)})</small></span></div>` +
`<div><span class="amp-sum-k">日均盈亏</span><span class="amp-sum-v ${pnlClass(st.pnl_avg)}">${esc(st.pnl_avg)}</span></div>` +
`<div><span class="amp-sum-k">赚钱天数/胜率</span><span class="amp-sum-v">${esc(st.win_days)} · ${esc(pct(st.win_ratio))}</span></div>` +
`<div><span class="amp-sum-k">单日最大赚/亏</span><span class="amp-sum-v">${esc(st.pnl_max)} / ${esc(st.pnl_min)}</span></div>` +
`</div>`;
}
function renderPerpHedge(ph) {
const box = el("amp-perp-hedge");
if (!box) return;
if (!ph) {
box.innerHTML =
'<p class="amp-empty">填写「目标 / 杠杆」后计算;入场按日开盘;触达目标点数按目标盈利出场,否则收盘结算</p>';
return;
}
const err =
ph.points_error
? `<div><span class="amp-sum-k">推点数提示</span><span class="amp-sum-v">${esc(ph.points_error)}</span></div>`
: "";
box.innerHTML =
`<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">入场 / 出场</span><span class="amp-sum-v">开盘 · 目标 ${esc(ph.target_profit_u)}U 或收盘</span></div>` +
`<div><span class="amp-sum-k">比例 / 期权仓</span><span class="amp-sum-v">${esc(ph.ratio_label)} · ${esc(ph.opt_coins)} 币</span></div>` +
`<div><span class="amp-sum-k">单币/总权利金(中位)</span><span class="amp-sum-v">${esc(ph.prem_per_coin)} / ${esc(ph.premium_total)}</span></div>` +
`<div><span class="amp-sum-k">A所需点数(永续对)</span><span class="amp-sum-v">${esc(ph.move_a)}</span></div>` +
`<div><span class="amp-sum-k">A达标(开→高)</span><span class="amp-sum-v">${esc(ph.hit_a_days)} 天 · ${esc(pct(ph.hit_a_ratio))}</span></div>` +
`<div><span class="amp-sum-k">B所需点数(组合)</span><span class="amp-sum-v">${esc(ph.move_b)}</span></div>` +
`<div><span class="amp-sum-k">B达标(开→低)</span><span class="amp-sum-v">${esc(ph.hit_b_days)} 天 · ${esc(pct(ph.hit_b_ratio))}</span></div>` +
`<div><span class="amp-sum-k">目标出场 / 收盘结算</span><span class="amp-sum-v">${esc(ph.target_exit_days)} / ${esc(ph.eod_days)} 天</span></div>` +
`<div><span class="amp-sum-k">组合盈亏合计</span><span class="amp-sum-v ${pnlClass(ph.pnl_total)}">${esc(ph.pnl_total)}</span></div>` +
`<div><span class="amp-sum-k">日均 / 胜率</span><span class="amp-sum-v ${pnlClass(ph.pnl_avg)}">${esc(ph.pnl_avg)} · ${esc(pct(ph.win_ratio))}</span></div>` +
`<div><span class="amp-sum-k">上涨日盈亏</span><span class="amp-sum-v ${pnlClass(ph.up_pnl_total)}">${esc(ph.up_pnl_total)} <small>(${esc(ph.up_days)}天)</small></span></div>` +
`<div><span class="amp-sum-k">下跌日盈亏</span><span class="amp-sum-v ${pnlClass(ph.down_pnl_total)}">${esc(ph.down_pnl_total)} <small>(${esc(ph.down_days)}天)</small></span></div>` +
`<div><span class="amp-sum-k">单日最大赚/亏</span><span class="amp-sum-v">${esc(ph.pnl_max)} / ${esc(ph.pnl_min)}</span></div>` +
err +
`</div>`; `</div>`;
} }
@@ -244,24 +135,26 @@
return day; return day;
} }
function hitCell(r) {
if (r.move_points == null) return "—";
if (r.amp_hit) return '<span class="amp-pnl is-pos">是</span>';
return '<span class="amp-pnl is-neg">否</span>';
}
function renderTable(pagePayload) { function renderTable(pagePayload) {
const body = el("amp-table-body"); const body = el("amp-table-body");
const pager = el("amp-pager"); const pager = el("amp-pager");
if (!body) return; if (!body) return;
const isPerp = overlayMode() === "perp";
syncOverlayMode();
const rows = (pagePayload && pagePayload.rows) || []; const rows = (pagePayload && pagePayload.rows) || [];
if (!rows.length) { if (!rows.length) {
body.innerHTML = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>'; body.innerHTML = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>';
} else { } else {
body.innerHTML = rows body.innerHTML = rows
.map((r) => { .map((r) => {
const pnlVal = isPerp ? r.perp_hedge_pnl : r.profit;
const pnlCell =
pnlVal == null || pnlVal === ""
? "—"
: `<span class="amp-pnl ${pnlClass(pnlVal)}">${esc(pnlVal)}</span>`;
const trClass = r.is_weekend ? ' class="amp-row-weekend"' : ""; const trClass = r.is_weekend ? ' class="amp-row-weekend"' : "";
const upCls = r.hit_up ? ' class="amp-pnl is-pos"' : "";
const downCls = r.hit_down ? ' class="amp-pnl is-pos"' : "";
const ampCls = r.amp_hit ? ' class="amp-pnl is-pos"' : "";
return ( return (
`<tr${trClass}>` + `<tr${trClass}>` +
`<td>${dayLabel(r)}</td>` + `<td>${dayLabel(r)}</td>` +
@@ -270,11 +163,11 @@
`<td>${esc(r.high)}</td>` + `<td>${esc(r.high)}</td>` +
`<td>${esc(r.low)}</td>` + `<td>${esc(r.low)}</td>` +
`<td>${esc(r.close)}</td>` + `<td>${esc(r.close)}</td>` +
`<td>${esc(r.up_points)}</td>` + `<td${upCls}>${esc(r.up_points)}</td>` +
`<td>${esc(r.down_points)}</td>` + `<td${downCls}>${esc(r.down_points)}</td>` +
`<td><strong>${esc(r.amplitude)}</strong></td>` + `<td${ampCls}><strong>${esc(r.amplitude)}</strong></td>` +
`<td>${esc(r.change)}</td>` + `<td>${esc(r.change)}</td>` +
`<td>${pnlCell}</td>` + `<td>${hitCell(r)}</td>` +
`</tr>` `</tr>`
); );
}) })
@@ -308,8 +201,7 @@
async function reframe(resetPage) { async function reframe(resetPage) {
if (!lastResult) { if (!lastResult) {
renderStraddle(null); renderMoveStats(null);
renderPerpHedge(null);
return; return;
} }
if (resetPage) pageNo = 1; if (resetPage) pageNo = 1;
@@ -325,10 +217,8 @@
start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16), start_hour: lastResult.start_hour ?? Number(el("amp-start-hour")?.value || 16),
period: lastResult.period || el("amp-period")?.value || "2m", period: lastResult.period || el("amp-period")?.value || "2m",
sample_days: lastResult.sample_days_requested || 60, sample_days: lastResult.sample_days_requested || 60,
straddle_premium: readPremium(), move_points: readMovePoints(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(), weekend_filter: readWeekend(),
perp_hedge: readPerpHedge(),
price_source: lastResult.price_source || "", price_source: lastResult.price_source || "",
inst_id: lastResult.inst_id || "", inst_id: lastResult.inst_id || "",
page: pageNo, page: pageNo,
@@ -368,10 +258,8 @@
start_hour: startHour, start_hour: startHour,
period, period,
custom_days: period === "custom" ? customDays : null, custom_days: period === "custom" ? customDays : null,
straddle_premium: readPremium(), move_points: readMovePoints(),
take_profit: readTakeProfit(),
weekend_filter: readWeekend(), weekend_filter: readWeekend(),
perp_hedge: readPerpHedge(),
page: pageNo, page: pageNo,
page_size: 20, page_size: 20,
}), }),
@@ -407,17 +295,6 @@
} }
} }
function appendHedgeQuery(q) {
const h = readPerpHedge();
if (!h) return;
q.set("hedge_target", String(h.target_profit_u));
q.set("hedge_perp_lev", String(h.perp_leverage));
q.set("hedge_opt_lev", String(h.option_leverage));
q.set("hedge_ratio_perp", String(h.ratio_perp));
q.set("hedge_ratio_opt", String(h.ratio_opt));
q.set("hedge_ct_mult", String(h.ct_mult || 0.01));
}
function downloadCurrent() { function downloadCurrent() {
if (!lastResult) { if (!lastResult) {
setStatus("请先计算"); setStatus("请先计算");
@@ -427,8 +304,7 @@
const startHour = Number(el("amp-start-hour")?.value || 16); const startHour = Number(el("amp-start-hour")?.value || 16);
const period = el("amp-period")?.value || "2m"; const period = el("amp-period")?.value || "2m";
const customDays = Number(el("amp-custom-days")?.value || 60); const customDays = Number(el("amp-custom-days")?.value || 60);
const prem = readPremium(); const mp = readMovePoints();
const tp = readTakeProfit();
const q = new URLSearchParams({ const q = new URLSearchParams({
symbol, symbol,
start_hour: String(startHour), start_hour: String(startHour),
@@ -436,9 +312,7 @@
weekend_filter: readWeekend(), weekend_filter: readWeekend(),
}); });
if (period === "custom") q.set("custom_days", String(customDays)); if (period === "custom") q.set("custom_days", String(customDays));
if (prem != null) q.set("straddle_premium", String(prem)); if (mp != null) q.set("move_points", String(mp));
if (tp != null) q.set("take_profit", String(tp));
appendHedgeQuery(q);
window.location.href = "/api/amp-stats/export?" + q.toString(); window.location.href = "/api/amp-stats/export?" + q.toString();
} }
@@ -472,15 +346,12 @@
const id = card.getAttribute("data-id"); const id = card.getAttribute("data-id");
card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id)); card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id));
card.querySelector(".amp-hist-dl")?.addEventListener("click", () => { card.querySelector(".amp-hist-dl")?.addEventListener("click", () => {
const prem = readPremium(); const mp = readMovePoints();
const tp = readTakeProfit();
const q = new URLSearchParams({ const q = new URLSearchParams({
history_id: id, history_id: id,
weekend_filter: readWeekend(), weekend_filter: readWeekend(),
}); });
if (prem != null) q.set("straddle_premium", String(prem)); if (mp != null) q.set("move_points", String(mp));
if (tp != null) q.set("take_profit", String(tp));
appendHedgeQuery(q);
window.location.href = "/api/amp-stats/export?" + q.toString(); window.location.href = "/api/amp-stats/export?" + q.toString();
}); });
card.querySelector(".amp-hist-del")?.addEventListener("click", async () => { card.querySelector(".amp-hist-del")?.addEventListener("click", async () => {
@@ -502,27 +373,12 @@
if (lastResult) { if (lastResult) {
if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth"; if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth";
if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16); if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16);
if (lastResult.straddle_premium != null && el("amp-straddle-premium")) { if (lastResult.move_points != null && el("amp-move-points")) {
el("amp-straddle-premium").value = String(lastResult.straddle_premium); el("amp-move-points").value = String(lastResult.move_points);
}
if (lastResult.take_profit != null && el("amp-take-profit")) {
el("amp-take-profit").value = String(lastResult.take_profit);
} }
if (lastResult.weekend_filter && el("amp-weekend-filter")) { if (lastResult.weekend_filter && el("amp-weekend-filter")) {
el("amp-weekend-filter").value = lastResult.weekend_filter; el("amp-weekend-filter").value = lastResult.weekend_filter;
} }
const h = lastResult.perp_hedge;
if (h && typeof h === "object") {
if (el("amp-overlay-mode")) el("amp-overlay-mode").value = "perp";
if (h.target_profit_u != null && el("amp-hedge-target")) el("amp-hedge-target").value = String(h.target_profit_u);
if (h.perp_leverage != null && el("amp-hedge-perp-lev")) el("amp-hedge-perp-lev").value = String(h.perp_leverage);
if (h.option_leverage != null && el("amp-hedge-opt-lev")) el("amp-hedge-opt-lev").value = String(h.option_leverage);
if (h.ratio_perp != null && el("amp-hedge-ratio-perp")) el("amp-hedge-ratio-perp").value = String(h.ratio_perp);
if (h.ratio_opt != null && el("amp-hedge-ratio-opt")) el("amp-hedge-ratio-opt").value = String(h.ratio_opt);
} else if (lastResult.straddle_premium != null && el("amp-overlay-mode")) {
el("amp-overlay-mode").value = "straddle";
}
syncOverlayMode();
pageNo = 1; pageNo = 1;
setStatus("已载入历史 " + id); setStatus("已载入历史 " + id);
await reframe(true); await reframe(true);
@@ -543,22 +399,9 @@
el("amp-btn-compute")?.addEventListener("click", () => void compute(true)); el("amp-btn-compute")?.addEventListener("click", () => void compute(true));
el("amp-btn-save")?.addEventListener("click", () => void saveHistory()); el("amp-btn-save")?.addEventListener("click", () => void saveHistory());
el("amp-btn-download")?.addEventListener("click", downloadCurrent); el("amp-btn-download")?.addEventListener("click", downloadCurrent);
el("amp-overlay-mode")?.addEventListener("change", () => { el("amp-move-points")?.addEventListener("input", scheduleReframe);
syncOverlayMode();
void reframe(true);
});
el("amp-straddle-premium")?.addEventListener("input", scheduleReframe);
el("amp-take-profit")?.addEventListener("input", scheduleReframe);
[
"amp-hedge-target",
"amp-hedge-perp-lev",
"amp-hedge-opt-lev",
"amp-hedge-ratio-perp",
"amp-hedge-ratio-opt",
].forEach((id) => el(id)?.addEventListener("input", scheduleReframe));
el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true)); el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true));
syncCustomDays(); syncCustomDays();
syncOverlayMode();
} }
window.hubAmpStatsPage = { window.hubAmpStatsPage = {
@@ -566,9 +409,7 @@
bind(); bind();
setView("stats"); setView("stats");
setStatus(""); setStatus("");
syncOverlayMode(); renderMoveStats(null);
renderStraddle(null);
renderPerpHedge(null);
}, },
}; };
})(); })();
+6 -43
View File
@@ -1265,39 +1265,8 @@
</select> </select>
</label> </label>
<label class="amp-field"> <label class="amp-field">
<span>对照模式</span> <span>波动点数</span>
<select id="amp-overlay-mode"> <input id="amp-move-points" type="number" min="0" step="any" placeholder="如 50" />
<option value="straddle" selected>买跨双边</option>
<option value="perp">永期对冲</option>
</select>
</label>
<label class="amp-field amp-overlay-straddle">
<span>买跨·双边权利金(点)</span>
<input id="amp-straddle-premium" type="number" min="0" step="any" placeholder="如 30" />
</label>
<label class="amp-field amp-overlay-straddle">
<span>止盈点(点)</span>
<input id="amp-take-profit" type="number" min="0" step="any" placeholder="空=按涨跌" />
</label>
<label class="amp-field amp-overlay-perp hidden">
<span>永期·目标盈利(U)</span>
<input id="amp-hedge-target" type="number" min="0" step="any" value="15" />
</label>
<label class="amp-field amp-overlay-perp hidden">
<span>永期·永续杠杆</span>
<input id="amp-hedge-perp-lev" type="number" min="0.01" step="any" value="10" />
</label>
<label class="amp-field amp-overlay-perp hidden">
<span>永期·期权杠杆</span>
<input id="amp-hedge-opt-lev" type="number" min="0.01" step="any" value="100" />
</label>
<label class="amp-field amp-overlay-perp hidden">
<span>永期·永续比例</span>
<input id="amp-hedge-ratio-perp" type="number" min="0.01" step="any" value="1" />
</label>
<label class="amp-field amp-overlay-perp hidden">
<span>永期·期权比例</span>
<input id="amp-hedge-ratio-opt" type="number" min="0.01" step="any" value="2" />
</label> </label>
<div class="amp-actions"> <div class="amp-actions">
<button type="button" id="amp-btn-compute" class="primary">计算</button> <button type="button" id="amp-btn-compute" class="primary">计算</button>
@@ -1306,24 +1275,18 @@
</div> </div>
</div> </div>
<p id="amp-status" class="toolbar-meta amp-status"></p> <p id="amp-status" class="toolbar-meta amp-status"></p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.对照模式二选一:买跨收益=有效波动−权利金;永期对冲=永续多1币+买期权(默认1:2),入场按日开盘,触达目标点数按目标盈利出场否则收盘结算.周末按结算日标注/筛选.</p> <p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.填写波动点数后看振幅≥该点数的天数占比;日表显示两边波动(开→高/开→低)与振幅是否达标.周末按结算日标注/筛选.</p>
<h3 class="amp-block-title">汇总</h3> <h3 class="amp-block-title">汇总</h3>
<div id="amp-summary" class="amp-summary"></div> <div id="amp-summary" class="amp-summary"></div>
<div id="amp-overlay-straddle-block"> <h3 class="amp-block-title">振幅占比</h3>
<h3 class="amp-block-title">买跨对照</h3> <div id="amp-move-stats" class="amp-summary amp-move-stats"></div>
<div id="amp-straddle" class="amp-summary amp-straddle"></div>
</div>
<div id="amp-overlay-perp-block" class="hidden">
<h3 class="amp-block-title">永期对冲对照</h3>
<div id="amp-perp-hedge" class="amp-summary amp-perp-hedge"></div>
</div>
<h3 class="amp-block-title">日表明细</h3> <h3 class="amp-block-title">日表明细</h3>
<div class="amp-table-wrap"> <div class="amp-table-wrap">
<table class="amp-table"> <table class="amp-table">
<thead> <thead>
<tr> <tr>
<th>结算日</th><th>窗起点</th><th></th><th></th><th></th><th></th> <th>结算日</th><th>窗起点</th><th></th><th></th><th></th><th></th>
<th>开→高</th><th>开→低</th><th>振幅</th><th>涨跌</th><th id="amp-col-pnl">收益</th> <th>开→高</th><th>开→低</th><th>振幅</th><th>涨跌</th><th>振幅达标</th>
</tr> </tr>
</thead> </thead>
<tbody id="amp-table-body"> <tbody id="amp-table-body">
+24 -125
View File
@@ -89,73 +89,22 @@ class AmpStatsLibTests(unittest.TestCase):
self.assertEqual(s["max_amplitude_day"], "2026-07-02") self.assertEqual(s["max_amplitude_day"], "2026-07-02")
self.assertEqual(s["max_up_points"], 500) self.assertEqual(s["max_up_points"], 500)
self.assertEqual(s["max_down_points"], 200) self.assertEqual(s["max_down_points"], 200)
self.assertIsNone(s["straddle"]) self.assertIsNone(s["move_points_stats"])
self.assertIsNone(s["perp_hedge"])
def test_perp_hedge_hit_and_pnl(self): def test_move_points_amp_ratio(self):
from lib.hub.amp_stats_lib import perp_hedge_day_pnl, perp_hedge_day_pnl_eod
# 开盘=1800 optLev=100 → prem=36; A≈52.83; B=51
# 触达目标出场 → 日盈亏=15, 不再按收盘涨跌算满仓
rows = [ rows = [
{"open": 1800, "close": 1860, "change": 60, "up_points": 60, "down_points": 0, "amplitude": 60, "settlement_day": "2026-07-01"}, {"amplitude": 100, "up_points": 40, "down_points": 60, "change": 10, "settlement_day": "2026-07-01"},
{"open": 1800, "close": 1740, "change": -60, "up_points": 0, "down_points": 60, "amplitude": 60, "settlement_day": "2026-07-02"}, {"amplitude": 40, "up_points": 10, "down_points": 30, "change": -5, "settlement_day": "2026-07-02"},
{"open": 1800, "close": 1820, "change": 20, "up_points": 20, "down_points": 0, "amplitude": 20, "settlement_day": "2026-07-03"}, {"amplitude": 50, "up_points": 50, "down_points": 0, "change": 20, "settlement_day": "2026-07-03"},
] ]
hedge = { s = summarize_rows(rows, move_points=50)
"target_profit_u": 15, ms = s["move_points_stats"]
"perp_leverage": 10, self.assertIsNotNone(ms)
"option_leverage": 100, self.assertEqual(ms["move_points"], 50)
"ratio_perp": 1, self.assertEqual(ms["amp_hit_days"], 2) # 100, 50
"ratio_opt": 2, self.assertEqual(ms["amp_hit_ratio"], round(2 / 3, 4))
"ct_mult": 0.01, self.assertEqual(ms["up_hit_days"], 1) # 50
} self.assertEqual(ms["down_hit_days"], 1) # 60
s = summarize_rows(rows, perp_hedge=hedge)
ph = s["perp_hedge"]
self.assertIsNotNone(ph)
self.assertEqual(ph["entry"], "open")
self.assertEqual(ph["exit"], "target_or_eod")
self.assertEqual(ph["spot"], 1800.0)
self.assertEqual(ph["opt_coins"], 2.0)
self.assertEqual(ph["premium_total"], 36.0)
self.assertAlmostEqual(ph["move_b"], 51.0, places=4)
self.assertEqual(ph["hit_a_days"], 1) # up 60 >= A
self.assertEqual(ph["hit_b_days"], 1) # down 60 >= B
self.assertEqual(ph["target_exit_days"], 2)
self.assertEqual(ph["eod_days"], 1)
# 触达目标 → 15U
self.assertAlmostEqual(ph["pnl_max"], 15.0, places=4)
up_pnl = perp_hedge_day_pnl(
change=60,
open_px=1800,
close_px=1860,
option_leverage=100,
opt_coins=2,
up_points=60,
down_points=0,
target_profit_u=15,
)
down_pnl = perp_hedge_day_pnl(
change=-60,
open_px=1800,
close_px=1740,
option_leverage=100,
opt_coins=2,
up_points=0,
down_points=60,
target_profit_u=15,
)
self.assertAlmostEqual(up_pnl, 15.0, places=4)
self.assertAlmostEqual(down_pnl, 15.0, places=4)
self.assertAlmostEqual(ph["down_pnl_total"], 15.0, places=4)
# 未触达:收盘结算
eod = perp_hedge_day_pnl_eod(
change=20, open_px=1800, close_px=1820, option_leverage=100, opt_coins=2
)
self.assertLess(eod, 0)
self.assertEqual(ph["up_days"], 2)
self.assertEqual(ph["down_days"], 1)
csv_text = build_export_csv( csv_text = build_export_csv(
{ {
"exchange": "okx", "exchange": "okx",
@@ -166,43 +115,12 @@ class AmpStatsLibTests(unittest.TestCase):
"end_hour": 16, "end_hour": 16,
} }
) )
self.assertIn("永期对冲对照", csv_text) self.assertIn("振幅占比", csv_text)
self.assertIn("永期盈亏", csv_text) self.assertIn("振幅达标", csv_text)
self.assertIn("按日开盘", csv_text)
def test_long_straddle_stats(self): def test_weekend_and_reframe_move_points(self):
rows = [ from lib.hub.amp_stats_lib import enrich_rows, filter_weekend_rows, reframe_amp_stats
# |chg|=40>30 win+10; up=40>30; down=10
{"up_points": 40, "down_points": 10, "change": 40, "amplitude": 50, "settlement_day": "2026-07-01"},
# |chg|=10 lose-20; up=5; down=35>30
{"up_points": 5, "down_points": 35, "change": -10, "amplitude": 40, "settlement_day": "2026-07-02"},
# |chg|=30 not >30 lose-30; boundary
{"up_points": 30, "down_points": 30, "change": 30, "amplitude": 60, "settlement_day": "2026-07-03"},
]
s = summarize_rows(rows, straddle_premium=30)
st = s["straddle"]
self.assertEqual(st["side"], "long_straddle")
self.assertEqual(st["premium"], 30)
self.assertEqual(st["up_exceed_days"], 1) # only 40
self.assertEqual(st["down_exceed_days"], 1) # only 35
self.assertEqual(st["abs_change_exceed_days"], 1) # only 40
self.assertAlmostEqual(st["pnl_total"], 40 - 30 + 10 - 30 + 30 - 30)
self.assertEqual(st["win_days"], 1)
self.assertEqual(st["win_ratio"], round(1 / 3, 4))
csv_text = build_export_csv(
{"exchange": "okx", "symbol_label": "ETH", "summary": s, "rows": rows, "start_hour": 22, "end_hour": 16}
)
self.assertIn("买跨对照", csv_text)
self.assertIn("买跨点数盈亏合计", csv_text)
def test_take_profit_and_weekend(self):
from lib.hub.amp_stats_lib import (
enrich_rows_pnl,
filter_weekend_rows,
reframe_amp_stats,
)
# Sat 2026-07-18, Sun 2026-07-19, Mon 2026-07-20
rows = [ rows = [
{ {
"settlement_day": "2026-07-18", "settlement_day": "2026-07-18",
@@ -238,39 +156,20 @@ class AmpStatsLibTests(unittest.TestCase):
only = filter_weekend_rows(rows, "only") only = filter_weekend_rows(rows, "only")
self.assertEqual(len(only), 2) self.assertEqual(len(only), 2)
# TP=80: day1 hit → move 80; day2 no → |12|; day3 no → 8 enriched = enrich_rows(rows, move_points=80)
enriched = enrich_rows_pnl(rows, straddle_premium=10, take_profit=80) self.assertTrue(enriched[0]["amp_hit"])
self.assertTrue(enriched[0]["take_profit_hit"]) self.assertFalse(enriched[1]["amp_hit"])
self.assertEqual(enriched[0]["effective_move"], 80) self.assertTrue(enriched[2]["amp_hit"])
self.assertEqual(enriched[0]["profit"], 70)
self.assertFalse(enriched[1]["take_profit_hit"])
self.assertEqual(enriched[1]["effective_move"], 12)
self.assertEqual(enriched[1]["profit"], 2)
# TP empty → use |change|
no_tp = enrich_rows_pnl(rows[:1], straddle_premium=10, take_profit=None)
self.assertEqual(no_tp[0]["effective_move"], 5)
self.assertEqual(no_tp[0]["profit"], -5)
# TP boundary >= : up=80 counts as hit
edge = enrich_rows_pnl(
[{"up_points": 80, "down_points": 1, "change": 2, "settlement_day": "2026-07-20", "is_weekend": False}],
straddle_premium=10,
take_profit=80,
)
self.assertTrue(edge[0]["take_profit_hit"])
self.assertEqual(edge[0]["profit"], 70)
reframed = reframe_amp_stats( reframed = reframe_amp_stats(
rows_all=rows, rows_all=rows,
symbol="eth", symbol="eth",
weekend_filter="exclude", weekend_filter="exclude",
straddle_premium=10, move_points=80,
take_profit=80,
) )
self.assertEqual(reframed["summary"]["sample_count"], 1) self.assertEqual(reframed["summary"]["sample_count"], 1)
# Mon: 未触达止盈 → |8|-10 self.assertTrue(reframed["rows"][0]["amp_hit"])
self.assertEqual(reframed["rows"][0]["profit"], -2) self.assertIn("振幅占比", build_export_csv(reframed))
self.assertIn("收益", build_export_csv(reframed))
def test_fetch_switches_to_history_endpoint(self): def test_fetch_switches_to_history_endpoint(self):
"""近期接口到头后应切 history 续拉.""" """近期接口到头后应切 history 续拉."""