8d67f3fc6c
Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
5.7 KiB
Python
172 lines
5.7 KiB
Python
"""P0 实盘 SoT:closing 状态机、紧急期期、recover Put、到期无 intrinsic。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from types import SimpleNamespace
|
||
|
||
from app.sim.matcher import BLOCKING_STATUSES
|
||
|
||
|
||
def test_closing_in_blocking_statuses() -> None:
|
||
assert "closing" in BLOCKING_STATUSES
|
||
|
||
|
||
def test_has_open_position_blocks_closing(tmp_path, monkeypatch) -> None:
|
||
monkeypatch.setenv("MODE", "SIM")
|
||
from app.models.db import Database
|
||
from app.sim.matcher import Matcher
|
||
|
||
db = Database(tmp_path / "c.db")
|
||
m = Matcher(db)
|
||
with db._lock:
|
||
db._conn.execute(
|
||
"""UPDATE positions SET group_id=?, option_inst_id=?, option2_inst_id=?,
|
||
status='closing', hedge_mode='option_option' WHERE id=1""",
|
||
("G1", "C", "P"),
|
||
)
|
||
db._conn.commit()
|
||
assert m.has_open_position() is True
|
||
db.close()
|
||
|
||
|
||
def test_recover_opening_refuses_orphan_put(monkeypatch) -> None:
|
||
monkeypatch.setenv("MODE", "LIVE")
|
||
from app.config import get_settings
|
||
|
||
get_settings.cache_clear()
|
||
import app.live.reconcile as rec
|
||
from app.live.reconcile import recover_stuck_opening
|
||
|
||
class _Ex:
|
||
def current_position(self):
|
||
return {
|
||
"status": "opening",
|
||
"group_id": "G-oo",
|
||
"option_inst_id": "ETH-CALL",
|
||
"perp_side": "oo_put:ETH-PUT",
|
||
"option_qty_eth": 1,
|
||
"option_qty_contracts": 100,
|
||
}
|
||
|
||
@property
|
||
def db(self):
|
||
return SimpleNamespace()
|
||
|
||
def _client(self):
|
||
return object()
|
||
|
||
def fake_opt(_c, inst):
|
||
if "PUT" in inst:
|
||
return 5.0
|
||
return 0.0
|
||
|
||
monkeypatch.setattr(rec, "_executor_client_and_exchange", lambda _e: (object(), "okx"))
|
||
monkeypatch.setattr(rec, "exchange_option_abs_size", fake_opt)
|
||
monkeypatch.setattr(rec, "exchange_perp_abs_size", lambda *_a, **_k: 0.0)
|
||
monkeypatch.setattr(
|
||
rec, "resolve_perp_inst_id", lambda *_a, **_k: "ETH-USDT-SWAP"
|
||
)
|
||
r = recover_stuck_opening(_Ex())
|
||
assert r is not None
|
||
assert r.ok is False
|
||
assert "Put" in (r.detail or "")
|
||
get_settings.cache_clear()
|
||
|
||
|
||
def test_expiry_fill_zero_not_intrinsic(monkeypatch, tmp_path) -> None:
|
||
monkeypatch.setenv("MODE", "LIVE")
|
||
from app.config import get_settings
|
||
|
||
get_settings.cache_clear()
|
||
from app.live.executor import OkxLiveExecutor
|
||
from app.models.db import Database
|
||
|
||
db = Database(tmp_path / "e.db")
|
||
ex = OkxLiveExecutor(db)
|
||
monkeypatch.setattr(ex, "_guard_live", lambda: None)
|
||
with db._lock:
|
||
db._conn.execute(
|
||
"""UPDATE positions SET
|
||
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
|
||
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
|
||
option_entry_px=?, status='open' WHERE id=1""",
|
||
("G-e", "short", 4.0, 2000.0, "ETH-OPT", "call", 1.0, 100.0, 20.0),
|
||
)
|
||
db._conn.execute(
|
||
"""INSERT INTO groups(group_id, status, option_inst_id, perp_inst_id, strike, open_at_ms)
|
||
VALUES (?,?,?,?,?,?)""",
|
||
("G-e", "open", "ETH-OPT", "ETH-USDT-SWAP", 1900.0, 1),
|
||
)
|
||
db._conn.commit()
|
||
|
||
class _C:
|
||
def get_ct_val(self, *_a, **_k):
|
||
return 0.01
|
||
|
||
def get_perp_pos_sz(self, *_a, **_k):
|
||
return 400.0
|
||
|
||
def place_market(self, *, inst_id, side, sz, **_k):
|
||
return SimpleNamespace(avg_px=2010.0, fee=0.1, sz=float(sz))
|
||
|
||
monkeypatch.setattr(ex, "_client", lambda: _C())
|
||
monkeypatch.setattr(
|
||
"app.live.executor.exchange_option_abs_size", lambda *_a, **_k: 2.0
|
||
)
|
||
monkeypatch.setattr(ex, "_group_strike", lambda *_a, **_k: 1900.0)
|
||
monkeypatch.setattr(ex, "_close_spot_px", lambda *_a, **_k: 1950.0)
|
||
monkeypatch.setattr(
|
||
"app.live.executor.get_session",
|
||
lambda: SimpleNamespace(snapshot=lambda: {}),
|
||
)
|
||
monkeypatch.setattr(
|
||
"app.live.executor.resolve_perp_inst_id",
|
||
lambda *_a, **_k: "ETH-USDT-SWAP",
|
||
)
|
||
monkeypatch.setattr(
|
||
"app.live.live_pnl.reconcile_closed_group_pnl",
|
||
lambda **_k: 0.0,
|
||
)
|
||
|
||
r = ex.close_group(reason="expiry", bypass_liquidity=True)
|
||
assert r.ok, r.detail
|
||
row = db.fetchone(
|
||
"SELECT fill_px, notional FROM fills WHERE group_id=? AND leg='option' AND action='close'",
|
||
("G-e",),
|
||
)
|
||
assert row is not None
|
||
assert float(row["fill_px"]) == 0.0
|
||
assert float(row["notional"] or 0) == 0.0
|
||
db.close()
|
||
get_settings.cache_clear()
|
||
|
||
|
||
def test_binance_fetch_balances_maps_usdt(monkeypatch) -> None:
|
||
from app.live.binance_trade import BinanceTradeClient
|
||
|
||
c = BinanceTradeClient.__new__(BinanceTradeClient)
|
||
|
||
def _signed(client, method, path, params=None):
|
||
if "fapi" in str(getattr(client, "base_url", "")) or path.startswith("/fapi"):
|
||
return [{"asset": "USDT", "availableBalance": "100.5"}]
|
||
if "marginAccount" in path:
|
||
return {"asset": [{"asset": "USDT", "available": "80"}]}
|
||
return []
|
||
|
||
c._signed = _signed # type: ignore
|
||
c._fapi = SimpleNamespace(base_url="https://fapi")
|
||
c._eapi = SimpleNamespace(base_url="https://eapi")
|
||
|
||
# simpler: patch by path
|
||
def signed2(_client, method, path, params=None):
|
||
if path == "/fapi/v2/balance":
|
||
return [{"asset": "USDT", "availableBalance": "100.5"}]
|
||
if path == "/eapi/v1/marginAccount":
|
||
return {"asset": [{"asset": "USDT", "available": "80"}]}
|
||
return []
|
||
|
||
c._signed = signed2 # type: ignore
|
||
bal = BinanceTradeClient.fetch_balances(c)
|
||
assert bal["trading_usdt"] == 100.5
|
||
assert bal["trading_usdc"] == 100.5 # mapped from USDT when no USDC
|