Fix amp-stats perp PnL to exit at daily profit target.

Hit A/B via open-to-high/low; day PnL equals target when touched, otherwise settle at close.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-28 14:41:29 +08:00
parent 2ce67da8e8
commit 90be23e845
5 changed files with 222 additions and 76 deletions
+13 -9
View File
@@ -111,23 +111,26 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
### ① 所需点数达标
复用计算器「由比例推点数」:
复用计算器「由比例推点数」(按**当日开盘**推 A/B;汇总展示用样本开盘中位)
| 指标 | 规则 |
|------|------|
| A 所需点数 | 永续方向对、净利=目标 |
| A 达标 | 日 `涨跌 ≥ A点数` 的天数与占比 |
| A 达标 | 日 `开→高 ≥ A点数` 的天数与占比 |
| B 所需点数 | 期权方向对、**组合净利**=目标 |
| B 达标 | 日 `涨跌 ≤ B点数` 的天数与占比 |
| B 达标 | 日 `开→低 ≥ B点数` 的天数与占比 |
### ② 按日组合盈亏
### ② 按日组合盈亏(目标出场)
| 日向 | 组合净利 |
|------|----------|
| 上涨 `涨跌≥0` | `涨跌 − 当日权利金 − 永续开平手续费(开→收)` |
| 下跌 `涨跌<0` | `\|涨跌\|×(期权币数−1) − 当日权利金` |
日盈利目标(如 **15U**)用于出场:
汇总:合计、日均、胜率、上涨日/下跌日盈亏小计、单日最大赚亏;日表 **永期盈亏** 列。
| 情形 | 日盈亏 |
|------|--------|
| 开→高触达 A,或 开→低触达 B | **= 目标盈利**(出场) |
| 两边都触达 | 仍按目标盈利(OHLC 未知先后) |
| 均未触达 | 收盘结算:上涨 `涨跌−权利金−手续费`;下跌 `\|涨跌\|×(期权币数−1)−权利金` |
汇总:目标出场天数 / 收盘结算天数、合计、日均、胜率、上涨日/下跌日盈亏小计、单日最大赚亏;日表 **永期盈亏** 列。
改永期参数 / 周末筛选会**本地重算**(不重拉 K 线)。
---
@@ -162,3 +165,4 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD /
| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 |
| 2026-07-28 | 永期对冲对照:所需点数达标 + 按日组合盈亏 |
| 2026-07-28 | 永期入场改按日开盘;买跨/永期对照模式二选一 |
| 2026-07-28 | 永期日盈亏按目标盈利出场(开→高/低触达),未触达才收盘结算 |
+169 -46
View File
@@ -275,15 +275,16 @@ def enrich_rows_pnl(
item["take_profit_hit"] = hit
item["profit"] = round(move - prem, 4) if prem is not None else None
if hedge is not None:
item["perp_hedge_pnl"] = perp_hedge_day_pnl(
change=float(item.get("change") or 0),
open_px=float(item.get("open") or 0),
close_px=float(item.get("close") or 0),
option_leverage=float(hedge["option_leverage"]),
opt_coins=float(hedge["opt_coins"]),
)
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"]
else:
item["perp_hedge_pnl"] = None
item["perp_hedge_exit"] = None
item["perp_hedge_hit_a"] = False
item["perp_hedge_hit_b"] = False
out.append(item)
return out
@@ -336,7 +337,37 @@ def perp_hedge_day_premium(*, open_px: float, option_leverage: float, opt_coins:
return coins * (o / lev)
def perp_hedge_day_pnl(
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,
@@ -344,7 +375,7 @@ def perp_hedge_day_pnl(
option_leverage: float,
opt_coins: float,
) -> float:
"""单日组合净利(永续多1币 + 买期权);入场/权利金按当日开盘.
"""未触达目标时按收盘结算的组合净利.
上涨: change − 权利金 − 永续开平手续费
下跌: |change|×(opt_coins1) 权利金
@@ -361,20 +392,114 @@ def perp_hedge_day_pnl(
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)
# 下跌: 永续亏 chg(负), 期权内在 |chg|*coins
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 点数;
触达则日盈亏=目标盈利,否则收盘结算.汇总展示点数用样本开盘中位.
"""
from lib.hub.hub_perp_options_calc_lib import calc_perp_options_points
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 = (
@@ -392,40 +517,24 @@ def perp_hedge_stats(
move_b = None
points_err = None
if spot_ref is not None and spot_ref > 0:
points_data, points_err = calc_perp_options_points(
base="ETH",
spot=spot_ref,
capital_usdt=max(spot_ref / 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 points_data:
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
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)
item["perp_hedge_pnl"] = perp_hedge_day_pnl(
change=float(item.get("change") or 0),
open_px=float(item.get("open") or 0),
close_px=float(item.get("close") or 0),
option_leverage=float(hedge["option_leverage"]),
opt_coins=float(hedge["opt_coins"]),
)
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)
empty = {
"enabled": True,
"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),
@@ -445,6 +554,9 @@ def perp_hedge_stats(
"hit_a_ratio": None,
"hit_b_days": 0,
"hit_b_ratio": None,
"target_exit_days": 0,
"target_exit_ratio": None,
"eod_days": 0,
"pnl_total": None,
"pnl_avg": None,
"win_days": 0,
@@ -459,12 +571,10 @@ def perp_hedge_stats(
if n <= 0:
return empty
hit_a = 0
hit_b = 0
if move_a is not None and move_a > 0:
hit_a = sum(1 for r in work if float(r.get("change") or 0) >= move_a)
if move_b is not None and move_b > 0:
hit_b = sum(1 for r in work if float(r.get("change") or 0) <= -move_b)
hit_a = sum(1 for r in work if r.get("perp_hedge_hit_a"))
hit_b = sum(1 for r in work if r.get("perp_hedge_hit_b"))
target_exits = sum(1 for r in work if str(r.get("perp_hedge_exit") or "").startswith("target"))
eod_days = sum(1 for r in work if r.get("perp_hedge_exit") == "eod")
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)
@@ -476,9 +586,12 @@ def perp_hedge_stats(
empty.update(
{
"hit_a_days": hit_a,
"hit_a_ratio": round(hit_a / n, 4) if move_a else None,
"hit_a_ratio": round(hit_a / n, 4),
"hit_b_days": hit_b,
"hit_b_ratio": round(hit_b / n, 4) if move_b else None,
"hit_b_ratio": round(hit_b / n, 4),
"target_exit_days": target_exits,
"target_exit_ratio": round(target_exits / n, 4),
"eod_days": eod_days,
"pnl_total": round(sum(pnls), 4) if pnls else None,
"pnl_avg": round(statistics.fmean(pnls), 4) if pnls else None,
"win_days": win,
@@ -1043,7 +1156,7 @@ def build_export_csv(payload: dict[str, Any]) -> str:
[
"A所需点数",
ph.get("move_a"),
"A达标天",
"A达标天(开→高)",
ph.get("hit_a_days"),
"占比",
ph.get("hit_a_ratio"),
@@ -1053,12 +1166,22 @@ def build_export_csv(payload: dict[str, Any]) -> str:
[
"B所需点数(组合)",
ph.get("move_b"),
"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(
[
"组合盈亏合计",
+5 -4
View File
@@ -210,7 +210,7 @@
if (!box) return;
if (!ph) {
box.innerHTML =
'<p class="amp-empty">填写「目标 / 杠杆」后计算;入场按日开盘;对照所需点数达标与组合盈亏(永续多1币+买期权)</p>';
'<p class="amp-empty">填写「目标 / 杠杆」后计算;入场按日开盘;触达目标点数按目标盈利出场,否则收盘结算</p>';
return;
}
const err =
@@ -219,13 +219,14 @@
: "";
box.innerHTML =
`<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">入场</span><span class="amp-sum-v">按日开盘 · 推点数中位 ${esc(ph.spot)}</span></div>` +
`<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">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">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>` +
+1 -1
View File
@@ -1306,7 +1306,7 @@
</div>
</div>
<p id="amp-status" class="toolbar-meta amp-status"></p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.对照模式二选一:买跨收益=有效波动−权利金;永期对冲=永续多1币+买期权(默认1:2),入场/权利金按日开盘.周末按结算日标注/筛选.</p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.对照模式二选一:买跨收益=有效波动−权利金;永期对冲=永续多1币+买期权(默认1:2),入场按日开盘,触达目标点数按目标盈利出场否则收盘结算.周末按结算日标注/筛选.</p>
<h3 class="amp-block-title">汇总</h3>
<div id="amp-summary" class="amp-summary"></div>
<div id="amp-overlay-straddle-block">
+34 -16
View File
@@ -93,10 +93,10 @@ class AmpStatsLibTests(unittest.TestCase):
self.assertIsNone(s["perp_hedge"])
def test_perp_hedge_hit_and_pnl(self):
from lib.hub.amp_stats_lib import perp_hedge_day_pnl
from lib.hub.amp_stats_lib import perp_hedge_day_pnl, perp_hedge_day_pnl_eod
# 开盘=1800 optLev=100 → prem/coin=18; 1:2 → premium=36
# A move ≈ 52.83; B portfolio move = 51
# 开盘=1800 optLev=100 → prem=36; A≈52.83; B=51
# 触达目标出场 → 日盈亏=15, 不再按收盘涨跌算满仓
rows = [
{"open": 1800, "close": 1860, "change": 60, "up_points": 60, "down_points": 0, "amplitude": 60, "settlement_day": "2026-07-01"},
{"open": 1800, "close": 1740, "change": -60, "up_points": 0, "down_points": 60, "amplitude": 60, "settlement_day": "2026-07-02"},
@@ -114,30 +114,48 @@ class AmpStatsLibTests(unittest.TestCase):
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) # only +60
self.assertEqual(ph["hit_b_days"], 1) # only -60
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
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
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(down_pnl, 60 * (2 - 1) - 36, places=4) # 24
self.assertAlmostEqual(ph["down_pnl_total"], down_pnl, places=4)
self.assertGreater(up_pnl, 0)
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)
# 不同开盘 → 不同权利金
hi_open_pnl = perp_hedge_day_pnl(
change=-60, open_px=2000, close_px=1940, option_leverage=100, opt_coins=2
)
self.assertAlmostEqual(hi_open_pnl, 60 - 40, places=4) # prem=40
csv_text = build_export_csv(
{
"exchange": "okx",