0837982714
Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
7.3 KiB
Python
225 lines
7.3 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
|
||
from ..config import get_settings
|
||
from ..env_store import live_ready
|
||
from ..live import get_executor
|
||
from ..market import get_gateway
|
||
from ..models.db import get_db
|
||
from ..sim.ledger import Ledger
|
||
from ..strategy.clock import can_open_new, window_key
|
||
from ..strategy.group import next_group_id
|
||
from .auth import require_user
|
||
|
||
router = APIRouter(prefix="/api/sim", tags=["sim"])
|
||
|
||
|
||
class ManualOpenBody(BaseModel):
|
||
"""可选强制方向;默认按卖一比价自动选。"""
|
||
force_option_side: str | None = Field(default=None, description="call|put")
|
||
|
||
|
||
@router.get("/ledger")
|
||
async def sim_ledger(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||
return Ledger().snapshot()
|
||
|
||
|
||
@router.get("/position")
|
||
async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||
m = get_executor()
|
||
return {"position": m.current_position(), "unrealized": m.unrealized()}
|
||
|
||
|
||
@router.post("/open-group")
|
||
async def sim_open_group(
|
||
_user: Annotated[str, Depends(require_user)],
|
||
body: ManualOpenBody | None = None,
|
||
) -> dict:
|
||
ok, reason = live_ready()
|
||
if not get_settings().is_sim and not ok:
|
||
raise HTTPException(status_code=400, detail=reason)
|
||
from ..strategy import get_engine
|
||
|
||
st = get_engine().state()
|
||
if st.get("running"):
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="策略自动运行中,禁止手动开仓;请先暂停",
|
||
)
|
||
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
|
||
)
|
||
ex = get_executor()
|
||
if ex.has_open_position():
|
||
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||
s = get_settings()
|
||
skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends)
|
||
if not can_open_new(skip_weekends=skip_weekends):
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="周六/周日跳过开仓(上海时区)",
|
||
)
|
||
gw = get_gateway()
|
||
pick = await gw.pick_for_open_async()
|
||
if pick is None:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="无合格期权:请检查剩余时长、ATM开仓偏差(若已开启)与杠杆(现价/卖一)",
|
||
)
|
||
|
||
force = (body.force_option_side if body else None) or None
|
||
if force in ("call", "put"):
|
||
option_side = force
|
||
perp_side = "short" if force == "call" else "long"
|
||
bias = "manual_" + force
|
||
option_ask = pick.call_ask if force == "call" else pick.put_ask
|
||
from ..strategy.selection import option_leverage
|
||
|
||
min_lev = Ledger().get_setting_float("min_option_leverage", s.min_option_leverage)
|
||
lev = option_leverage(pick.underlying_px, option_ask)
|
||
if lev is None or lev < min_lev:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"强制方向杠杆不足: {lev or 0:.1f} < {min_lev:.0f}",
|
||
)
|
||
else:
|
||
option_side = pick.option_side
|
||
perp_side = pick.perp_side
|
||
bias = pick.bias
|
||
|
||
option_inst = (
|
||
pick.pair.call_inst_id if option_side == "call" else pick.pair.put_inst_id
|
||
)
|
||
# 强制方向时用该腿卖一估权利金;否则用选向结果
|
||
sizing_ask = float(
|
||
option_ask
|
||
if force in ("call", "put")
|
||
else pick.option_ask
|
||
)
|
||
|
||
wkey = window_key()
|
||
db = get_db()
|
||
from ..strategy.risk_sizing import apply_risk_sizing_to_ledger
|
||
|
||
rs = apply_risk_sizing_to_ledger(
|
||
index_px=float(pick.underlying_px),
|
||
option_ask=sizing_ask,
|
||
db=db,
|
||
)
|
||
if not rs.ok:
|
||
raise HTTPException(status_code=409, detail=rs.detail)
|
||
|
||
try:
|
||
from ..strategy.auto_usdc import ensure_okx_trading_usdc
|
||
|
||
ensure_okx_trading_usdc(db)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
from ..strategy.open_capacity import assess_open_capacity
|
||
|
||
cap = assess_open_capacity(db)
|
||
if cap.get("perp_can_open") is False or cap.get("option_can_open") is False:
|
||
detail = (
|
||
f"{cap.get('perp_label')} · {cap.get('option_label')};"
|
||
f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U,"
|
||
f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U"
|
||
)
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"资金不足,暂不可开新仓:{detail}",
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
|
||
count = len(
|
||
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
||
)
|
||
gid = next_group_id(count)
|
||
engine = get_engine()
|
||
async with engine._lock:
|
||
if ex.has_open_position():
|
||
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||
r = ex.open_group(
|
||
group_id=gid,
|
||
bias=bias,
|
||
option_side=option_side,
|
||
perp_side=perp_side,
|
||
option_inst_id=option_inst,
|
||
entry_index_px=float(pick.underlying_px),
|
||
strike=pick.pair.strike,
|
||
expiry_ymd=pick.pair.expiry_ymd,
|
||
)
|
||
if not r.ok:
|
||
raise HTTPException(status_code=400, detail=r.detail)
|
||
try:
|
||
from ..notify import wecom
|
||
|
||
wecom.notify_open(
|
||
group_id=gid,
|
||
detail=r.detail,
|
||
extra={
|
||
"bias": bias,
|
||
"option_side": option_side,
|
||
"option_inst_id": option_inst,
|
||
"strike": pick.pair.strike,
|
||
"expiry_ymd": pick.pair.expiry_ymd,
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"ok": True,
|
||
**(r.data or {}),
|
||
"detail": r.detail,
|
||
"option_leverage": pick.option_leverage,
|
||
"hours_left": pick.hours_left,
|
||
"expiry_ymd": pick.pair.expiry_ymd,
|
||
"strike": pick.pair.strike,
|
||
}
|
||
|
||
|
||
@router.post("/close-group")
|
||
async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
|
||
if not Ledger().get_setting_bool("show_manual_trade_buttons", False):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="未开启「显示手动开仓」;请在策略设置中开启后再用",
|
||
)
|
||
from ..strategy import get_engine
|
||
|
||
engine = get_engine()
|
||
# 与策略引擎共用锁,避免与自动平仓/开仓竞态
|
||
async with engine._lock:
|
||
r = get_executor().close_group(reason="manual")
|
||
if not r.ok and not r.liquidity_wait:
|
||
raise HTTPException(status_code=400, detail=r.detail)
|
||
if r.ok:
|
||
# 与自动/紧急全平一致:成功全平后进入组间休息
|
||
engine.enter_rest_after_close()
|
||
try:
|
||
from ..notify import wecom
|
||
|
||
wecom.notify_close(
|
||
reason="manual",
|
||
detail=r.detail,
|
||
data=r.data or {},
|
||
)
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"ok": r.ok,
|
||
"liquidity_wait": r.liquidity_wait,
|
||
"detail": r.detail,
|
||
"data": r.data,
|
||
}
|