模拟盘统计改为现货买币/卖回的真实 USDT 净盈亏,对齐钱包。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,9 +59,21 @@ def _sell_excess_bridge_coin(
|
||||
sell_amt = min(float(avail), excess_cap)
|
||||
if not _spot_bridge.is_sellable_spot_coin(ex, underlying, sell_amt):
|
||||
return None
|
||||
return _spot_bridge.spot_market_sell_coin_to_usdt(
|
||||
sell = _spot_bridge.spot_market_sell_coin_to_usdt(
|
||||
ex, underlying=underlying, coin_amount=sell_amt
|
||||
)
|
||||
if sell and sell.get("ok"):
|
||||
try:
|
||||
recovered = float(sell.get("usdt_recovered") or 0)
|
||||
except (TypeError, ValueError):
|
||||
recovered = 0.0
|
||||
if recovered > 0:
|
||||
try:
|
||||
_spot_bridge.add_bridge_usdt_recovered(conn, int(bridge_id), recovered)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return sell
|
||||
|
||||
|
||||
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
|
||||
@@ -650,12 +662,27 @@ def maybe_sell_spot_after_close(
|
||||
pass
|
||||
parts = inst_id.split("-")
|
||||
underlying = (parts[0] if parts else "ETH").upper()
|
||||
return _spot_bridge.sell_residual_after_option_flat(
|
||||
result = _spot_bridge.sell_residual_after_option_flat(
|
||||
cfg["get_db"],
|
||||
ex,
|
||||
underlying=underlying,
|
||||
inst_id=inst_id,
|
||||
)
|
||||
# 模拟盘:卖回后按桥上真实 USDT 净额回写已实现盈亏
|
||||
try:
|
||||
from lib.sim.mode_lib import is_sim_mode
|
||||
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
|
||||
|
||||
if is_sim_mode(cfg["get_db"]):
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
sync_sim_cash_realized_pnl_usdt(conn, ex)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def sweep_spot_after_options_flat(
|
||||
|
||||
@@ -151,6 +151,59 @@ def insert_bridge(
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def add_bridge_usdt_recovered(
|
||||
conn: sqlite3.Connection,
|
||||
bridge_id: int,
|
||||
delta_usdt: float | None,
|
||||
) -> float | None:
|
||||
"""累加桥上卖币收回的 USDT(开仓多余卖回 + 平仓后卖回)."""
|
||||
ensure_bridge_table(conn)
|
||||
try:
|
||||
d = float(delta_usdt or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if d <= 0 or bridge_id is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT usdt_recovered FROM options_spot_bridge WHERE id=?",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
prev = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[0]) or 0.0
|
||||
new_v = round(float(prev) + d, 8)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_spot_bridge
|
||||
SET usdt_recovered = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(new_v, int(bridge_id)),
|
||||
)
|
||||
return new_v
|
||||
|
||||
|
||||
def cash_pnl_usdt_from_bridge(conn: sqlite3.Connection, bridge_id: int | None) -> float | None:
|
||||
"""现金盈亏 = 卖回 USDT 合计 − 开仓买币花费(budget_usdt)."""
|
||||
if bridge_id is None:
|
||||
return None
|
||||
ensure_bridge_table(conn)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT budget_usdt, usdt_recovered
|
||||
FROM options_spot_bridge WHERE id = ?
|
||||
""",
|
||||
(int(bridge_id),),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
spent = _safe_float(row["budget_usdt"] if hasattr(row, "keys") else row[0])
|
||||
recovered = _safe_float(row["usdt_recovered"] if hasattr(row, "keys") else row[1])
|
||||
if spent is None or recovered is None:
|
||||
return None
|
||||
return round(float(recovered) - float(spent), 4)
|
||||
|
||||
|
||||
def update_bridge(
|
||||
conn: sqlite3.Connection,
|
||||
bridge_id: int,
|
||||
@@ -540,6 +593,9 @@ def sell_residual_after_option_flat(
|
||||
"bridge_id": bid,
|
||||
"bridge_status": BRIDGE_PENDING_SELL,
|
||||
}
|
||||
recovered = _safe_float(sell.get("usdt_recovered"))
|
||||
if recovered is not None and recovered > 0:
|
||||
add_bridge_usdt_recovered(c, bid, recovered)
|
||||
update_bridge(
|
||||
c,
|
||||
bid,
|
||||
|
||||
@@ -465,9 +465,139 @@ def repair_double_counted_fee_pnl(conn, ex: Any = None) -> int:
|
||||
return recompute_closed_realized_pnl_usdt(conn, ex)
|
||||
|
||||
|
||||
def _sim_net_usdt_converts_for_trade(
|
||||
conn,
|
||||
*,
|
||||
underlying: str,
|
||||
start_ts: str,
|
||||
end_ts: str,
|
||||
) -> float | None:
|
||||
"""从 sim_ledger 汇总该标的现货兑换的 USDT 净变动(买币为负、卖回为正)."""
|
||||
coin = (underlying or "").strip().upper()
|
||||
if coin not in ("ETH", "BTC") or not start_ts or not end_ts:
|
||||
return None
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT amount, note FROM sim_ledger_entries
|
||||
WHERE kind = 'convert'
|
||||
AND UPPER(ccy) = 'USDT'
|
||||
AND account = 'trading'
|
||||
AND ts >= ?
|
||||
AND ts <= ?
|
||||
""",
|
||||
(str(start_ts), str(end_ts)),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return None
|
||||
total = 0.0
|
||||
n = 0
|
||||
for row in rows or []:
|
||||
note = str(_row_get(row, "note") or "").upper()
|
||||
amt = _safe_float(_row_get(row, "amount"))
|
||||
if amt is None:
|
||||
continue
|
||||
related = (
|
||||
f"{coin}/USDT" in note
|
||||
or f"{coin}-USDT" in note
|
||||
or note.strip() == f"FROM {coin}"
|
||||
)
|
||||
if not related:
|
||||
continue
|
||||
total += float(amt)
|
||||
n += 1
|
||||
if n < 1:
|
||||
return None
|
||||
return round(total, 4)
|
||||
|
||||
|
||||
def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
|
||||
"""模拟盘:用现货买币/卖回的真实 USDT 净额覆盖冻结盈亏(对齐钱包)."""
|
||||
del ex # 现金口径不依赖实时指数
|
||||
init_options_tables(conn)
|
||||
try:
|
||||
from lib.options.options_spot_bridge_lib import (
|
||||
cash_pnl_usdt_from_bridge,
|
||||
ensure_bridge_table,
|
||||
)
|
||||
|
||||
ensure_bridge_table(conn)
|
||||
except Exception:
|
||||
return 0
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, bridge_id, underlying, budget_usdt, premium_ccy,
|
||||
created_at, closed_at, realized_pnl_usdt
|
||||
FROM options_trades
|
||||
WHERE status = 'closed'
|
||||
ORDER BY id DESC
|
||||
LIMIT 300
|
||||
"""
|
||||
).fetchall()
|
||||
updated = 0
|
||||
for row in rows:
|
||||
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper()
|
||||
underly = str(_row_get(row, "underlying") or "").strip().upper()
|
||||
if ccy not in ("ETH", "BTC") and underly not in ("ETH", "BTC"):
|
||||
continue
|
||||
underly = underly or ccy
|
||||
cash = None
|
||||
bid = _row_get(row, "bridge_id")
|
||||
try:
|
||||
if bid is not None:
|
||||
cash = cash_pnl_usdt_from_bridge(conn, int(bid))
|
||||
except Exception:
|
||||
cash = None
|
||||
if cash is None:
|
||||
# 旧桥未记 usdt_recovered:用流水回补(开仓前1分钟~平仓后30分钟)
|
||||
start = str(_row_get(row, "created_at") or "")
|
||||
end = str(_row_get(row, "closed_at") or "")
|
||||
if start and end:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def _shift(ts: str, minutes: int) -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(ts).strip().replace(" ", "T", 1))
|
||||
return (dt + timedelta(minutes=minutes)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return ts
|
||||
|
||||
cash = _sim_net_usdt_converts_for_trade(
|
||||
conn,
|
||||
underlying=underly,
|
||||
start_ts=_shift(start, -2),
|
||||
end_ts=_shift(end, 30),
|
||||
)
|
||||
if cash is None:
|
||||
continue
|
||||
old = _safe_float(_row_get(row, "realized_pnl_usdt"))
|
||||
if old is not None and abs(float(old) - float(cash)) < 1e-6:
|
||||
continue
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
SET realized_pnl_usdt = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(float(cash), int(row["id"])),
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]:
|
||||
"""本地已平(模拟盘口径) + 当前 open."""
|
||||
init_options_tables(conn)
|
||||
try:
|
||||
from lib.sim.mode_lib import is_sim_mode
|
||||
|
||||
# get_db 不可用时仍尝试现金同步(conn 已打开)
|
||||
sync_sim_cash_realized_pnl_usdt(conn, ex)
|
||||
except Exception:
|
||||
try:
|
||||
sync_sim_cash_realized_pnl_usdt(conn, ex)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
freeze_missing_realized_pnl_usdt(conn, ex)
|
||||
except Exception:
|
||||
|
||||
@@ -73,6 +73,103 @@ class SettleClosedPnlTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(float(s["realized_pnl_usdt"]), 27.0, places=4)
|
||||
|
||||
|
||||
class SimCashPnlStatsTests(unittest.TestCase):
|
||||
def test_sim_cash_from_bridge_overrides_index_pnl(self):
|
||||
import sqlite3
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_spot_bridge_lib import ensure_bridge_table
|
||||
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
ensure_bridge_table(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_spot_bridge(
|
||||
id, underlying, status, budget_usdt, usdt_recovered
|
||||
) VALUES (1, 'ETH', 'closed', 1000.0, 1952.96)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades(
|
||||
inst_id, underlying, opt_type, sheets, eth_amount,
|
||||
premium_paid, premium_received, realized_pnl, premium_ccy,
|
||||
status, bridge_id, budget_usdt, realized_pnl_usdt,
|
||||
created_at, closed_at
|
||||
) VALUES (
|
||||
'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01,
|
||||
0.01, 0.02, 0.01, 'ETH',
|
||||
'closed', 1, 1000.0, 884.54,
|
||||
'2026-10-06 10:00:00', '2026-10-08 14:00:00'
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
n = sync_sim_cash_realized_pnl_usdt(conn, None)
|
||||
self.assertEqual(n, 1)
|
||||
row = conn.execute(
|
||||
"SELECT realized_pnl_usdt FROM options_trades WHERE id=1"
|
||||
).fetchone()
|
||||
self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 952.96, places=2)
|
||||
conn.close()
|
||||
|
||||
def test_sim_cash_from_ledger_when_bridge_missing_recovered(self):
|
||||
import sqlite3
|
||||
|
||||
from lib.options.options_db import init_options_tables
|
||||
from lib.options.options_spot_bridge_lib import ensure_bridge_table
|
||||
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
|
||||
from lib.sim.db_lib import init_sim_tables
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
init_options_tables(conn)
|
||||
ensure_bridge_table(conn)
|
||||
init_sim_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_spot_bridge(
|
||||
id, underlying, status, budget_usdt, usdt_recovered
|
||||
) VALUES (1, 'ETH', 'closed', 1000.0, NULL)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades(
|
||||
inst_id, underlying, opt_type, sheets, eth_amount,
|
||||
premium_paid, premium_received, realized_pnl, premium_ccy,
|
||||
status, bridge_id, budget_usdt, realized_pnl_usdt,
|
||||
created_at, closed_at
|
||||
) VALUES (
|
||||
'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01,
|
||||
0.01, 0.02, 0.01, 'ETH',
|
||||
'closed', 1, 1000.0, 884.54,
|
||||
'2026-10-06 10:00:00', '2026-10-08 14:00:00'
|
||||
)
|
||||
"""
|
||||
)
|
||||
# 买币 -1000U, 卖回 +1952.96U
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sim_ledger_entries(kind, amount, ccy, account, note, ts)
|
||||
VALUES
|
||||
('convert', -1000.0, 'USDT', 'trading', 'ETH/USDT mkt 2500', '2026-10-06 10:00:05'),
|
||||
('convert', 1952.96, 'USDT', 'trading', 'from eth', '2026-10-08 14:00:10')
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
n = sync_sim_cash_realized_pnl_usdt(conn, None)
|
||||
self.assertEqual(n, 1)
|
||||
row = conn.execute(
|
||||
"SELECT realized_pnl_usdt FROM options_trades WHERE id=1"
|
||||
).fetchone()
|
||||
self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 952.96, places=2)
|
||||
conn.close()
|
||||
|
||||
|
||||
class ExchangeFreezeStatsTests(unittest.TestCase):
|
||||
def test_freeze_uses_exchange_coin_pnl(self):
|
||||
import sqlite3
|
||||
|
||||
Reference in New Issue
Block a user