From 78f18e731a1402ad7026e3e3f64379cfa686ee39 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 13 Oct 2026 09:37:14 +0800 Subject: [PATCH] =?UTF-8?q?fix=E6=A8=A1=E6=8B=9F=E7=9B=98=E6=9C=9F?= =?UTF-8?q?=E6=9D=83=E6=89=8B=E7=BB=AD=E8=B4=B9=E6=8C=89OKX=E5=B8=81?= =?UTF-8?q?=E6=9C=AC=E4=BD=8D=E5=85=AC=E5=BC=8F=E8=AE=A1=E8=B4=B9=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E7=BA=A0=E6=AD=A3=E5=A4=8D=E7=9B=98=E6=97=A7=E5=8D=95?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 费率作用在标的币数量上并以权利金7%封顶,不再误用权利金×费率。 Co-authored-by: Cursor --- lib/options/options_review_lib.py | 158 +++++++++++++++++++++++++++++- lib/sim/pricing_lib.py | 48 ++++++++- tests/test_sim_option_fee.py | 52 ++++++++++ 3 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 tests/test_sim_option_fee.py diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index 4f5669f..fd4c742 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -280,6 +280,148 @@ def hide_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] return {"ok": True, "entry": _row_to_dict(entry) if entry else None, "history_key": d.get("history_key")} +def _sum_sim_order_fees(conn: sqlite3.Connection, ord_ids: list[str]) -> float | None: + """从 sim_option_orders 汇总手续费(权利金币种,正数).""" + ids = [str(x).strip() for x in (ord_ids or []) if str(x).strip()] + if not ids: + return None + try: + conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone() + except Exception: + return None + total = 0.0 + n = 0 + for oid in ids: + try: + row = conn.execute( + "SELECT fee FROM sim_option_orders WHERE ord_id=?", + (oid,), + ).fetchone() + except Exception: + continue + if not row: + continue + fee = _safe_float(row["fee"] if hasattr(row, "keys") else row[0]) + if fee is None: + continue + total += abs(float(fee)) + n += 1 + return round(total, 8) if n else None + + +def _estimate_okx_option_fee_coin( + *, + underlying_coin: float | None, + premium_coin: float | None, +) -> float: + """按 OKX 币本位公式估算期权手续费(权利金币种).""" + try: + from lib.sim.pricing_lib import option_fee_coin + + return float( + option_fee_coin( + underlying_coin=float(underlying_coin or 0.0), + premium_coin=float(premium_coin or 0.0), + ) + ) + except Exception: + return 0.0 + + +def _fee_looks_like_legacy_premium_rate( + fee_coin: float, + *, + premium_coin: float | None, + underlying_coin: float | None, +) -> bool: + """旧模拟盘把手续费算成权利金×费率,约为正确值的 premium/underlying 倍.""" + if fee_coin <= 0: + return True + prem = abs(float(premium_coin or 0.0)) + uly = abs(float(underlying_coin or 0.0)) + if prem <= 0 or uly <= 0: + return False + # 正确量级 ~ uly * fee_rate; 错误量级 ~ prem * fee_rate + # 若 fee << uly*1e-4 且接近 prem*5e-4,视为旧算法 + try: + from lib.sim.pricing_lib import sim_fee_rate + + fr = float(sim_fee_rate()) + except Exception: + fr = 0.0005 + expect_okx = uly * fr + expect_legacy = prem * fr + if expect_okx <= 0: + return False + # 比 OKX 估算小一个数量级以上,且靠近旧公式 + if fee_coin < expect_okx * 0.25 and abs(fee_coin - expect_legacy) <= max(expect_legacy * 0.5, 1e-10): + return True + if fee_coin < expect_okx * 0.05: + return True + return False + + +def _resolve_option_fees_coin( + conn: sqlite3.Connection, + row: Any, + *, + open_fee: float, + close_fee: float, + premium_ccy: str, +) -> tuple[float, float]: + """解析期权开/平手续费(权利金币种);修正模拟盘旧算法偏小值.""" + ccy = (premium_ccy or "").strip().upper() + paid = _safe_float(row["premium_paid"] if hasattr(row, "keys") else None) + recv = _safe_float(row["premium_received"] if hasattr(row, "keys") else None) + uly = _safe_float(row["eth_amount"] if hasattr(row, "keys") else None) + if uly is None or uly <= 0: + sheets = _safe_float(row["sheets"] if hasattr(row, "keys") else None) or 0.0 + # BTC 期权约 0.01,ETH 约 0.1 + ct = 0.01 if ccy == "BTC" else 0.1 + uly = float(sheets) * ct if sheets > 0 else None + + open_oids = [ + p.strip() + for p in str(row["exchange_ord_id"] if hasattr(row, "keys") else "").split(",") + if p.strip() + ] + close_oids = [ + p.strip() + for p in str(row["close_ord_id"] if hasattr(row, "keys") else "").split(",") + if p.strip() + ] + + ofee = abs(float(open_fee or 0.0)) + cfee = abs(float(close_fee or 0.0)) + + # 优先用订单表(若订单本身仍是旧算法,下面会再纠正) + sim_open = _sum_sim_order_fees(conn, open_oids) + sim_close = _sum_sim_order_fees(conn, close_oids) + if sim_open is not None and sim_open > 0: + ofee = sim_open + if sim_close is not None and sim_close > 0: + cfee = sim_close + + if ccy in ("ETH", "BTC"): + if _fee_looks_like_legacy_premium_rate(ofee, premium_coin=paid, underlying_coin=uly): + est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=paid) + if est > 0: + ofee = est + if _fee_looks_like_legacy_premium_rate(cfee, premium_coin=recv, underlying_coin=uly): + est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=recv) + if est > 0: + cfee = est + elif cfee <= 0 and recv is not None and recv > 0: + est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=recv) + if est > 0: + cfee = est + if ofee <= 0 and paid is not None and paid > 0: + est = _estimate_okx_option_fee_coin(underlying_coin=uly, premium_coin=paid) + if est > 0: + ofee = est + + return round(ofee, 8), round(cfee, 8) + def sync_options_from_local_trades( conn: sqlite3.Connection, @@ -317,11 +459,11 @@ def sync_options_from_local_trades( rows = conn.execute( """ SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, - open_quote, close_quote, premium_paid, premium_received, realized_pnl, - realized_pnl_usdt, close_index_px, open_index_px, budget_usdt, - premium_ccy, created_at, closed_at, signal_note, status, + eth_amount, open_quote, close_quote, premium_paid, premium_received, + realized_pnl, realized_pnl_usdt, close_index_px, open_index_px, + budget_usdt, premium_ccy, created_at, closed_at, signal_note, status, trading_usdt_before, pnl_settled, initial_premium_usdt, - open_fee, close_fee + open_fee, close_fee, exchange_ord_id, close_ord_id FROM options_trades WHERE status = 'closed' ORDER BY id DESC @@ -445,6 +587,14 @@ def sync_options_from_local_trades( # 仅期权开/平手续费折 U(不含现货) ofee = abs(_safe_float(r["open_fee"]) or 0.0) cfee = abs(_safe_float(r["close_fee"]) or 0.0) + # 模拟盘旧单曾按「权利金×费率」计费偏小:按 OKX 公式/订单回填 + ofee, cfee = _resolve_option_fees_coin( + conn, + r, + open_fee=ofee, + close_fee=cfee, + premium_ccy=ccy, + ) fee_u = None if ccy in ("ETH", "BTC"): fee_parts = 0.0 diff --git a/lib/sim/pricing_lib.py b/lib/sim/pricing_lib.py index a5e2255..495ead9 100644 --- a/lib/sim/pricing_lib.py +++ b/lib/sim/pricing_lib.py @@ -1,4 +1,9 @@ -"""成交价与手续费: 滑点 = 1×fee_rate.""" +"""成交价与手续费. + +永续:滑点=1×fee_rate,手续费=名义×fee_rate. +期权(币本位):滑点仍按权利金价×fee_rate;手续费对齐 OKX= + Min(fee_rate×标的币数量, 权利金×7%). +""" from __future__ import annotations @@ -57,6 +62,30 @@ def perp_fill( return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional) +def option_fee_coin( + *, + underlying_coin: float, + premium_coin: float, + fee_rate: float | None = None, + premium_fee_cap: float = 0.07, +) -> float: + """OKX 币本位期权手续费(权利金币种). + + Min(费率 × 张数对应标的币数量, 权利金×封顶比例). + 注意:费率作用在标的币面值上,不是权利金名义上. + """ + f = sim_fee_rate(fee_rate) + qty = abs(float(underlying_coin or 0.0)) + prem = abs(float(premium_coin or 0.0)) + if qty <= 0 and prem <= 0: + return 0.0 + by_rate = qty * f + cap_ratio = abs(float(premium_fee_cap or 0.07)) + if cap_ratio <= 0 or prem <= 0: + return round(by_rate, 8) + return round(min(by_rate, prem * cap_ratio), 8) + + def option_fill( *, action: str, @@ -65,7 +94,11 @@ def option_fill( qty: float, fee_rate: float, ) -> PriceResult: - """开仓买入吃卖一; 平仓卖出吃买一. qty = sheets × ct_mult.""" + """开仓买入吃卖一; 平仓卖出吃买一. + + qty = sheets × ct_mult(标的币数量). + 滑点仍按权利金价×费率;手续费按 OKX 币本位公式(标的币×费率,权利金7%封顶). + """ f = float(fee_rate) if action == "open": base = float(ask) @@ -73,9 +106,14 @@ def option_fill( else: base = float(bid) fill = base * (1.0 - f) - notional = abs(fill * float(qty)) - fee = notional * f - slip = abs(fill - base) * float(qty) + qty_f = abs(float(qty)) + notional = abs(fill * qty_f) + fee = option_fee_coin( + underlying_coin=qty_f, + premium_coin=notional, + fee_rate=f, + ) + slip = abs(fill - base) * qty_f return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional) diff --git a/tests/test_sim_option_fee.py b/tests/test_sim_option_fee.py new file mode 100644 index 0000000..74629cb --- /dev/null +++ b/tests/test_sim_option_fee.py @@ -0,0 +1,52 @@ +"""模拟盘期权手续费:对齐 OKX 币本位公式.""" +from __future__ import annotations + +import unittest + +from lib.sim.pricing_lib import option_fee_coin, option_fill + + +class OptionFeeCoinTests(unittest.TestCase): + def test_fee_on_underlying_not_premium(self): + # 90 张 BTC×0.01=0.9 币;权利金 0.01125 + fee = option_fee_coin( + underlying_coin=0.9, + premium_coin=0.01125, + fee_rate=0.0005, + ) + # min(0.9*0.0005, 0.01125*0.07) = min(0.00045, 0.0007875) + self.assertAlmostEqual(fee, 0.00045, places=8) + + def test_premium_cap(self): + fee = option_fee_coin( + underlying_coin=1.0, + premium_coin=0.001, + fee_rate=0.0005, + ) + # min(0.0005, 0.00007) = 0.00007 + self.assertAlmostEqual(fee, 0.00007, places=8) + + def test_option_fill_uses_okx_fee(self): + pr = option_fill( + action="open", + bid=0.012, + ask=0.0125, + qty=0.9, + fee_rate=0.0005, + ) + self.assertGreater(pr.fee, 1e-6) + # 旧算法 fee≈notional*rate≈5.6e-6;新算法应为 4.5e-4 量级 + self.assertGreater(pr.fee, 1e-4) + self.assertAlmostEqual( + pr.fee, + option_fee_coin( + underlying_coin=0.9, + premium_coin=pr.notional, + fee_rate=0.0005, + ), + places=8, + ) + + +if __name__ == "__main__": + unittest.main()