Initial standalone crypto_okx with one-click deploy.
Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""本地模拟资金撮合(公开行情 + SQLite 钱包)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["install_sim_trading"]
|
||||
|
||||
|
||||
def install_sim_trading(*args, **kwargs):
|
||||
from lib.sim.register import install_sim_trading as _install
|
||||
|
||||
return _install(*args, **kwargs)
|
||||
@@ -0,0 +1,528 @@
|
||||
"""模拟撮合: 用公开行情 bid/ask 成交, 结算到本地钱包."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from lib.sim.pricing_lib import option_fill, perp_fill, sim_fee_rate
|
||||
from lib.sim.wallets_lib import InsufficientFunds, SimWallets
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]:
|
||||
t = exchange.fetch_ticker(symbol)
|
||||
last = t.get("last")
|
||||
bid = t.get("bid")
|
||||
ask = t.get("ask")
|
||||
if bid is None or float(bid) <= 0:
|
||||
bid = last
|
||||
if ask is None or float(ask) <= 0:
|
||||
ask = last
|
||||
if bid is None or ask is None:
|
||||
raise RuntimeError(f"无法获取 {symbol} 行情 bid/ask")
|
||||
return float(bid), float(ask)
|
||||
|
||||
|
||||
def _option_bid_ask(exchange_options: Any, inst_id: str) -> tuple[float, float, float]:
|
||||
"""返回 bid, ask, ct_mult."""
|
||||
ct_mult = 0.01
|
||||
bid = ask = None
|
||||
try:
|
||||
from lib.exchange.okx_options_lib import quote_option_contract
|
||||
|
||||
q = quote_option_contract(exchange_options, inst_id)
|
||||
if q.get("ok"):
|
||||
bid = q.get("bid") or q.get("mark")
|
||||
ask = q.get("ask") or q.get("book_ask") or q.get("ref_ask") or q.get("mark")
|
||||
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||
except Exception:
|
||||
pass
|
||||
if bid is None or ask is None:
|
||||
rows = exchange_options.public_get_market_ticker({"instId": inst_id}).get("data") or []
|
||||
if not rows:
|
||||
raise RuntimeError(f"无法获取期权行情 {inst_id}")
|
||||
t = rows[0]
|
||||
bid = bid or t.get("bidPx") or t.get("markPx") or t.get("last")
|
||||
ask = ask or t.get("askPx") or t.get("markPx") or t.get("last")
|
||||
if bid is None or ask is None:
|
||||
raise RuntimeError(f"期权 {inst_id} 缺少 bid/ask")
|
||||
return float(bid), float(ask), float(ct_mult)
|
||||
|
||||
|
||||
def _contract_size(exchange: Any, symbol: str) -> float:
|
||||
try:
|
||||
if hasattr(exchange, "market"):
|
||||
m = exchange.market(symbol)
|
||||
return float(m.get("contractSize") or 1)
|
||||
except Exception:
|
||||
pass
|
||||
return 1.0
|
||||
|
||||
|
||||
class SimBroker:
|
||||
def __init__(self, get_db: Callable) -> None:
|
||||
self.get_db = get_db
|
||||
self.wallets = SimWallets(get_db)
|
||||
|
||||
def balances_header(self) -> dict[str, float]:
|
||||
return self.wallets.view()
|
||||
|
||||
def list_perp_positions(self) -> list[dict[str, Any]]:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sim_perp_positions WHERE contracts > 1e-12 ORDER BY id"
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
{
|
||||
"symbol": r["symbol"],
|
||||
"direction": r["direction"],
|
||||
"contracts": float(r["contracts"]),
|
||||
"entry_px": float(r["entry_px"]),
|
||||
"leverage": int(r["leverage"] or 1),
|
||||
"margin_usdt": float(r["margin_usdt"] or 0),
|
||||
"contract_size": float(r["contract_size"] or 1),
|
||||
}
|
||||
)
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_option_positions(self) -> list[dict[str, Any]]:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sim_option_positions WHERE sheets > 1e-12 ORDER BY id"
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
{
|
||||
"inst_id": r["inst_id"],
|
||||
"side": r["side"],
|
||||
"sheets": float(r["sheets"]),
|
||||
"entry_px": float(r["entry_px"]),
|
||||
"ct_mult": float(r["ct_mult"] or 0.01),
|
||||
"premium_paid_usdc": float(r["premium_paid_usdc"] or 0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_perp_contracts(self, symbol: str, direction: str) -> float:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT contracts FROM sim_perp_positions
|
||||
WHERE symbol=? AND direction=? AND contracts > 1e-12
|
||||
""",
|
||||
(symbol, direction),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return 0.0
|
||||
return float(row["contracts"] if hasattr(row, "keys") else row[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def place_perp_market(
|
||||
self,
|
||||
exchange: Any,
|
||||
*,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
contracts: float,
|
||||
leverage: int,
|
||||
fee_rate: float | None = None,
|
||||
stop_loss: Any = None,
|
||||
take_profit: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
_ = (stop_loss, take_profit) # sim: ignore attachAlgoOrds / tpsl
|
||||
side = (direction or "").lower().strip()
|
||||
if side not in ("long", "short"):
|
||||
raise ValueError("direction 须为 long 或 short")
|
||||
qty_c = float(contracts)
|
||||
if qty_c <= 0:
|
||||
raise ValueError("张数须大于 0")
|
||||
lev = max(1, int(leverage or 1))
|
||||
fr = sim_fee_rate(fee_rate)
|
||||
bid, ask = _ticker_bid_ask(exchange, symbol)
|
||||
ct_sz = _contract_size(exchange, symbol)
|
||||
qty = qty_c * ct_sz
|
||||
pr = perp_fill(side=side, action="open", bid=bid, ask=ask, qty=qty, fee_rate=fr)
|
||||
margin = pr.notional / lev
|
||||
need = margin + pr.fee
|
||||
try:
|
||||
self.wallets.debit_trading(
|
||||
"USDT",
|
||||
need,
|
||||
kind="perp_open",
|
||||
note=f"open {side} {symbol} {qty_c}@{pr.fill_px:.4f}",
|
||||
)
|
||||
except InsufficientFunds as e:
|
||||
raise RuntimeError(str(e)) from e
|
||||
|
||||
conn = self.get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM sim_perp_positions WHERE symbol=? AND direction=?",
|
||||
(symbol, side),
|
||||
).fetchone()
|
||||
if existing and float(existing["contracts"] or 0) > 1e-12:
|
||||
old_c = float(existing["contracts"])
|
||||
old_px = float(existing["entry_px"])
|
||||
old_m = float(existing["margin_usdt"] or 0)
|
||||
new_c = old_c + qty_c
|
||||
entry = (old_px * old_c + pr.fill_px * qty_c) / new_c
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sim_perp_positions
|
||||
SET contracts=?, entry_px=?, leverage=?, margin_usdt=?, contract_size=?, updated_at=?
|
||||
WHERE symbol=? AND direction=?
|
||||
""",
|
||||
(new_c, entry, lev, old_m + margin, ct_sz, _now(), symbol, side),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sim_perp_positions(
|
||||
symbol, direction, contracts, entry_px, leverage, margin_usdt, contract_size, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(symbol, direction) DO UPDATE SET
|
||||
contracts=excluded.contracts,
|
||||
entry_px=excluded.entry_px,
|
||||
leverage=excluded.leverage,
|
||||
margin_usdt=excluded.margin_usdt,
|
||||
contract_size=excluded.contract_size,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(symbol, side, qty_c, pr.fill_px, lev, margin, ct_sz, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
oid = f"sim-perp-{uuid.uuid4().hex[:16]}"
|
||||
return {
|
||||
"id": oid,
|
||||
"symbol": symbol,
|
||||
"side": "buy" if side == "long" else "sell",
|
||||
"amount": qty_c,
|
||||
"average": pr.fill_px,
|
||||
"status": "closed",
|
||||
"info": {"sim": True, "fee": pr.fee, "margin": margin, "fill": pr.to_dict()},
|
||||
"tpsl_attached": False,
|
||||
}
|
||||
|
||||
def close_perp_market(
|
||||
self,
|
||||
exchange: Any,
|
||||
*,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
contracts: float | None = None,
|
||||
fee_rate: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
side = (direction or "").lower().strip()
|
||||
if side not in ("long", "short"):
|
||||
raise ValueError("direction 须为 long 或 short")
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sim_perp_positions WHERE symbol=? AND direction=?",
|
||||
(symbol, side),
|
||||
).fetchone()
|
||||
if not row or float(row["contracts"] or 0) <= 1e-12:
|
||||
raise ValueError("模拟永续无对应持仓")
|
||||
pos_c = float(row["contracts"])
|
||||
entry = float(row["entry_px"])
|
||||
margin_all = float(row["margin_usdt"] or 0)
|
||||
ct_sz = float(row["contract_size"] or 1)
|
||||
close_c = pos_c if contracts is None else min(pos_c, float(contracts))
|
||||
if close_c <= 0:
|
||||
raise ValueError("平仓张数无效")
|
||||
fr = sim_fee_rate(fee_rate)
|
||||
bid, ask = _ticker_bid_ask(exchange, symbol)
|
||||
qty = close_c * ct_sz
|
||||
pr = perp_fill(side=side, action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr)
|
||||
if side == "long":
|
||||
pnl = (pr.fill_px - entry) * qty
|
||||
else:
|
||||
pnl = (entry - pr.fill_px) * qty
|
||||
release = margin_all * (close_c / pos_c)
|
||||
credit = release + pnl - pr.fee
|
||||
remain = pos_c - close_c
|
||||
if remain <= 1e-12:
|
||||
conn.execute(
|
||||
"DELETE FROM sim_perp_positions WHERE symbol=? AND direction=?",
|
||||
(symbol, side),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sim_perp_positions
|
||||
SET contracts=?, margin_usdt=?, updated_at=?
|
||||
WHERE symbol=? AND direction=?
|
||||
""",
|
||||
(remain, margin_all - release, _now(), symbol, side),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if credit >= 0:
|
||||
self.wallets.credit_trading(
|
||||
"USDT",
|
||||
credit,
|
||||
kind="perp_close",
|
||||
note=f"close {side} {symbol} pnl={pnl:.4f}",
|
||||
)
|
||||
else:
|
||||
self.wallets.debit_trading(
|
||||
"USDT",
|
||||
abs(credit),
|
||||
kind="perp_close",
|
||||
note=f"close {side} {symbol} pnl={pnl:.4f}",
|
||||
)
|
||||
|
||||
oid = f"sim-perp-close-{uuid.uuid4().hex[:16]}"
|
||||
return {
|
||||
"id": oid,
|
||||
"symbol": symbol,
|
||||
"side": "sell" if side == "long" else "buy",
|
||||
"amount": close_c,
|
||||
"average": pr.fill_px,
|
||||
"status": "closed",
|
||||
"info": {
|
||||
"sim": True,
|
||||
"fee": pr.fee,
|
||||
"pnl": pnl,
|
||||
"released_margin": release,
|
||||
"fill": pr.to_dict(),
|
||||
},
|
||||
"tpsl_attached": False,
|
||||
}
|
||||
|
||||
def _store_option_order(
|
||||
self,
|
||||
*,
|
||||
ord_id: str,
|
||||
inst_id: str,
|
||||
side: str,
|
||||
sheets: float,
|
||||
avg_px: float,
|
||||
) -> None:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO sim_option_orders(
|
||||
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
|
||||
) VALUES (?,?,?,?,?,'filled',?,?)
|
||||
""",
|
||||
(ord_id, inst_id, side, float(sheets), float(avg_px), float(sheets), _now()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_option_order(self, ord_id: str) -> dict[str, Any] | None:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sim_option_orders WHERE ord_id=?",
|
||||
(ord_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"ok": True,
|
||||
"ord_id": row["ord_id"],
|
||||
"inst_id": row["inst_id"],
|
||||
"side": row["side"],
|
||||
"state": row["state"],
|
||||
"acc_fill_sz": float(row["acc_fill_sz"]),
|
||||
"avg_px": float(row["avg_px"]),
|
||||
"sim": True,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def place_option_buy(
|
||||
self,
|
||||
exchange_options: Any,
|
||||
*,
|
||||
inst_id: str,
|
||||
sheets: int,
|
||||
price: float | None = None,
|
||||
fee_rate: float | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
n = int(sheets)
|
||||
if n < 1:
|
||||
return {"ok": False, "msg": "张数至少为 1"}
|
||||
fr = sim_fee_rate(fee_rate)
|
||||
bid, ask, ct_mult = _option_bid_ask(exchange_options, inst_id)
|
||||
if price is not None and float(price) > 0:
|
||||
# 限价: 用 min(limit, ask) 作为基准卖一近似, 仍走 option_fill 滑点
|
||||
ask = min(float(ask), float(price)) if float(price) > 0 else float(ask)
|
||||
qty = n * ct_mult
|
||||
pr = option_fill(action="open", bid=bid, ask=ask, qty=qty, fee_rate=fr)
|
||||
cost = pr.notional + pr.fee
|
||||
try:
|
||||
self.wallets.debit_trading(
|
||||
"USDC",
|
||||
cost,
|
||||
kind="option_open",
|
||||
note=f"buy {inst_id} x{n}@{pr.fill_px}",
|
||||
)
|
||||
except InsufficientFunds as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
conn = self.get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM sim_option_positions WHERE inst_id=?",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if existing and float(existing["sheets"] or 0) > 1e-12:
|
||||
old_s = float(existing["sheets"])
|
||||
old_px = float(existing["entry_px"])
|
||||
old_prem = float(existing["premium_paid_usdc"] or 0)
|
||||
new_s = old_s + n
|
||||
entry = (old_px * old_s + pr.fill_px * n) / new_s
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sim_option_positions
|
||||
SET sheets=?, entry_px=?, ct_mult=?, premium_paid_usdc=?, updated_at=?
|
||||
WHERE inst_id=?
|
||||
""",
|
||||
(new_s, entry, ct_mult, old_prem + pr.notional, _now(), inst_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sim_option_positions(
|
||||
inst_id, side, sheets, entry_px, ct_mult, premium_paid_usdc, updated_at
|
||||
) VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(inst_id) DO UPDATE SET
|
||||
sheets=excluded.sheets,
|
||||
entry_px=excluded.entry_px,
|
||||
ct_mult=excluded.ct_mult,
|
||||
premium_paid_usdc=excluded.premium_paid_usdc,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(inst_id, "buy", float(n), pr.fill_px, ct_mult, pr.notional, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ord_id = f"sim-opt-{uuid.uuid4().hex[:16]}"
|
||||
self._store_option_order(
|
||||
ord_id=ord_id, inst_id=inst_id, side="buy", sheets=n, avg_px=pr.fill_px
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
|
||||
"raw": {"sim": True},
|
||||
"px": pr.fill_px,
|
||||
"ord_type": "ioc",
|
||||
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
|
||||
}
|
||||
|
||||
def sell_option_close(
|
||||
self,
|
||||
exchange_options: Any,
|
||||
*,
|
||||
inst_id: str,
|
||||
sheets: int,
|
||||
price: float | None = None,
|
||||
fee_rate: float | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
n = int(sheets)
|
||||
if n < 1:
|
||||
return {"ok": False, "msg": "张数至少为 1"}
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sim_option_positions WHERE inst_id=?",
|
||||
(inst_id,),
|
||||
).fetchone()
|
||||
if not row or float(row["sheets"] or 0) <= 1e-12:
|
||||
return {"ok": False, "msg": "模拟期权无对应持仓"}
|
||||
pos_s = float(row["sheets"])
|
||||
ct_mult = float(row["ct_mult"] or 0.01)
|
||||
close_n = min(pos_s, float(n))
|
||||
if close_n <= 0:
|
||||
return {"ok": False, "msg": "平仓张数无效"}
|
||||
fr = sim_fee_rate(fee_rate)
|
||||
bid, ask, _ = _option_bid_ask(exchange_options, inst_id)
|
||||
if price is not None and float(price) > 0:
|
||||
bid = max(float(bid), float(price)) if float(price) > 0 else float(bid)
|
||||
qty = close_n * ct_mult
|
||||
pr = option_fill(action="close", bid=bid, ask=ask, qty=qty, fee_rate=fr)
|
||||
credit = pr.notional - pr.fee
|
||||
remain = pos_s - close_n
|
||||
if remain <= 1e-12:
|
||||
conn.execute("DELETE FROM sim_option_positions WHERE inst_id=?", (inst_id,))
|
||||
else:
|
||||
prem = float(row["premium_paid_usdc"] or 0) * (remain / pos_s)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sim_option_positions
|
||||
SET sheets=?, premium_paid_usdc=?, updated_at=?
|
||||
WHERE inst_id=?
|
||||
""",
|
||||
(remain, prem, _now(), inst_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if credit > 0:
|
||||
self.wallets.credit_trading(
|
||||
"USDC",
|
||||
credit,
|
||||
kind="option_close",
|
||||
note=f"sell {inst_id} x{close_n}@{pr.fill_px}",
|
||||
)
|
||||
ord_id = f"sim-opt-close-{uuid.uuid4().hex[:16]}"
|
||||
self._store_option_order(
|
||||
ord_id=ord_id, inst_id=inst_id, side="sell", sheets=close_n, avg_px=pr.fill_px
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {"ordId": ord_id, "sCode": "0", "sMsg": "sim filled"},
|
||||
"raw": {"sim": True},
|
||||
"px": pr.fill_px,
|
||||
"ord_type": "ioc",
|
||||
"info": {"sim": True, "fee": pr.fee, "fill": pr.to_dict()},
|
||||
}
|
||||
|
||||
def option_positions_okx_rows(self) -> list[dict[str, Any]]:
|
||||
"""对齐 OKX positions 行字段, 供 format_position_row 使用."""
|
||||
rows = []
|
||||
for p in self.list_option_positions():
|
||||
rows.append(
|
||||
{
|
||||
"instId": p["inst_id"],
|
||||
"pos": str(p["sheets"]),
|
||||
"avgPx": str(p["entry_px"]),
|
||||
"markPx": str(p["entry_px"]),
|
||||
"upl": "0",
|
||||
"uplRatio": "0",
|
||||
"posSide": "long",
|
||||
"mgnMode": "isolated",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
@@ -0,0 +1,116 @@
|
||||
"""模拟资金 SQLite 表初始化与种子余额."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _env_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(key) if os.getenv(key) not in (None, "") else default)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
|
||||
def init_sim_tables(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sim_wallets (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
funding_usdt REAL NOT NULL DEFAULT 0,
|
||||
trading_usdt REAL NOT NULL DEFAULT 0,
|
||||
funding_usdc REAL NOT NULL DEFAULT 0,
|
||||
trading_usdc REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sim_perp_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
contracts REAL NOT NULL,
|
||||
entry_px REAL NOT NULL,
|
||||
leverage INTEGER NOT NULL DEFAULT 1,
|
||||
margin_usdt REAL NOT NULL DEFAULT 0,
|
||||
contract_size REAL NOT NULL DEFAULT 1,
|
||||
updated_at TEXT,
|
||||
UNIQUE(symbol, direction)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sim_option_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inst_id TEXT NOT NULL UNIQUE,
|
||||
side TEXT NOT NULL DEFAULT 'buy',
|
||||
sheets REAL NOT NULL,
|
||||
entry_px REAL NOT NULL,
|
||||
ct_mult REAL NOT NULL DEFAULT 0.01,
|
||||
premium_paid_usdc REAL NOT NULL DEFAULT 0,
|
||||
updated_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sim_ledger_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
ccy TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
balance_after REAL,
|
||||
note TEXT,
|
||||
ts TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sim_option_orders (
|
||||
ord_id TEXT PRIMARY KEY,
|
||||
inst_id TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
sheets REAL NOT NULL,
|
||||
avg_px REAL NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'filled',
|
||||
acc_fill_sz REAL NOT NULL,
|
||||
created_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
row = conn.execute("SELECT id FROM sim_wallets WHERE id=1").fetchone()
|
||||
if row is None:
|
||||
equity = max(0.0, _env_float("SIM_INITIAL_EQUITY_USDT", 10000.0))
|
||||
usdc = max(0.0, _env_float("SIM_INITIAL_USDC", 0.0))
|
||||
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sim_wallets(
|
||||
id, funding_usdt, trading_usdt, funding_usdc, trading_usdc, updated_at
|
||||
) VALUES (1, ?, 0, ?, 0, ?)
|
||||
""",
|
||||
(equity, usdc, now),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sim_ledger_entries(kind, amount, ccy, account, balance_after, note, ts)
|
||||
VALUES ('seed', ?, 'USDT', 'funding', ?, 'initial equity', ?)
|
||||
""",
|
||||
(equity, equity, now),
|
||||
)
|
||||
if usdc > 0:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sim_ledger_entries(kind, amount, ccy, account, balance_after, note, ts)
|
||||
VALUES ('seed', ?, 'USDC', 'funding', ?, 'initial usdc', ?)
|
||||
""",
|
||||
(usdc, usdc, now),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,321 @@
|
||||
"""运行时挂钩: 将 app / options cfg 在 sim 模式下切到本地撮合."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.sim.broker_lib import SimBroker
|
||||
from lib.sim.mode_lib import is_sim_mode
|
||||
from lib.sim.pricing_lib import sim_fee_rate
|
||||
|
||||
|
||||
_GET_DB: Optional[Callable] = None
|
||||
_APP_MODULE: Any = None
|
||||
|
||||
|
||||
def set_get_db(get_db: Callable) -> None:
|
||||
global _GET_DB
|
||||
_GET_DB = get_db
|
||||
|
||||
|
||||
def get_db_fn() -> Callable:
|
||||
if _GET_DB is None:
|
||||
raise RuntimeError("sim get_db 未安装")
|
||||
return _GET_DB
|
||||
|
||||
|
||||
def broker() -> SimBroker:
|
||||
return SimBroker(get_db_fn())
|
||||
|
||||
|
||||
def apply_sim_hooks(app_module: Any) -> None:
|
||||
"""包装 app 上的永续下单/余额/就绪检查; 幂等."""
|
||||
global _APP_MODULE
|
||||
_APP_MODULE = app_module
|
||||
set_get_db(app_module.get_db)
|
||||
|
||||
if getattr(app_module, "_sim_hooks_applied", False):
|
||||
return
|
||||
|
||||
_orig_ensure = app_module.ensure_okx_live_ready
|
||||
_orig_capitals = app_module.get_exchange_capitals
|
||||
_orig_avail = app_module.get_available_trading_usdt
|
||||
_orig_place = app_module.place_exchange_order
|
||||
_orig_close = app_module.close_exchange_order
|
||||
_orig_live_contracts = app_module.get_live_position_contracts
|
||||
|
||||
def ensure_okx_live_ready():
|
||||
if is_sim_mode(app_module.get_db):
|
||||
return True, "sim"
|
||||
return _orig_ensure()
|
||||
|
||||
def get_exchange_capitals(force=False):
|
||||
if is_sim_mode(app_module.get_db):
|
||||
w = broker().balances_header()
|
||||
return float(w["funding_usdt"]), float(w["trading_usdt"])
|
||||
return _orig_capitals(force=force)
|
||||
|
||||
def get_available_trading_usdt():
|
||||
if is_sim_mode(app_module.get_db):
|
||||
return float(broker().balances_header()["trading_usdt"])
|
||||
return _orig_avail()
|
||||
|
||||
def place_exchange_order(
|
||||
exchange_symbol, direction, amount, leverage, stop_loss=None, take_profit=None
|
||||
):
|
||||
if is_sim_mode(app_module.get_db):
|
||||
ex = getattr(app_module, "exchange", None)
|
||||
if ex is None:
|
||||
raise RuntimeError("sim: exchange 未就绪(公开行情)")
|
||||
ensure = getattr(app_module, "ensure_markets_loaded", None)
|
||||
if callable(ensure):
|
||||
try:
|
||||
ensure()
|
||||
except Exception:
|
||||
pass
|
||||
return broker().place_perp_market(
|
||||
ex,
|
||||
symbol=exchange_symbol,
|
||||
direction=direction,
|
||||
contracts=float(amount),
|
||||
leverage=int(leverage or 1),
|
||||
fee_rate=sim_fee_rate(),
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
)
|
||||
return _orig_place(
|
||||
exchange_symbol, direction, amount, leverage, stop_loss=stop_loss, take_profit=take_profit
|
||||
)
|
||||
|
||||
def close_exchange_order(order_row):
|
||||
if is_sim_mode(app_module.get_db):
|
||||
ex = getattr(app_module, "exchange", None)
|
||||
if ex is None:
|
||||
raise RuntimeError("sim: exchange 未就绪")
|
||||
ensure = getattr(app_module, "ensure_markets_loaded", None)
|
||||
if callable(ensure):
|
||||
try:
|
||||
ensure()
|
||||
except Exception:
|
||||
pass
|
||||
normalize = getattr(app_module, "normalize_okx_symbol", None) or getattr(
|
||||
app_module, "normalize_exchange_symbol", None
|
||||
)
|
||||
try:
|
||||
symbol = order_row["exchange_symbol"] or None
|
||||
except Exception:
|
||||
symbol = None
|
||||
if not symbol:
|
||||
try:
|
||||
symbol = order_row["symbol"]
|
||||
except Exception:
|
||||
symbol = None
|
||||
if callable(normalize):
|
||||
symbol = normalize(symbol)
|
||||
direction = order_row["direction"]
|
||||
db_amt = float(order_row["order_amount"] or 0)
|
||||
live = broker().get_perp_contracts(symbol, direction)
|
||||
amt = live if live and live > 0 else db_amt
|
||||
return broker().close_perp_market(
|
||||
ex, symbol=symbol, direction=direction, contracts=amt, fee_rate=sim_fee_rate()
|
||||
)
|
||||
return _orig_close(order_row)
|
||||
|
||||
def get_live_position_contracts(exchange_symbol, direction):
|
||||
if is_sim_mode(app_module.get_db):
|
||||
normalize = getattr(app_module, "normalize_okx_symbol", None)
|
||||
sym = exchange_symbol
|
||||
if callable(normalize):
|
||||
sym = normalize(exchange_symbol or "")
|
||||
return broker().get_perp_contracts(sym, direction)
|
||||
return _orig_live_contracts(exchange_symbol, direction)
|
||||
|
||||
app_module.ensure_okx_live_ready = ensure_okx_live_ready
|
||||
app_module.get_exchange_capitals = get_exchange_capitals
|
||||
app_module.get_available_trading_usdt = get_available_trading_usdt
|
||||
app_module.place_exchange_order = place_exchange_order
|
||||
app_module.close_exchange_order = close_exchange_order
|
||||
app_module.get_live_position_contracts = get_live_position_contracts
|
||||
app_module._sim_hooks_applied = True
|
||||
|
||||
_patch_okx_options_lib(app_module)
|
||||
|
||||
|
||||
def _patch_okx_options_lib(app_module: Any) -> None:
|
||||
"""期权余额 / 成交等待: 对 sim-* 订单与 sim 模式短路."""
|
||||
import lib.exchange.okx_options_lib as opt_lib
|
||||
|
||||
if getattr(opt_lib, "_sim_hooks_applied", False):
|
||||
return
|
||||
|
||||
_orig_header = opt_lib.options_header_balances
|
||||
_orig_wait = opt_lib.wait_option_order_full_fill
|
||||
_orig_fetch_order = opt_lib.fetch_option_order
|
||||
_orig_fetch_pos = opt_lib.fetch_option_positions
|
||||
_orig_ready = opt_lib.options_api_ready
|
||||
_orig_fetch_bal = opt_lib.fetch_options_balances
|
||||
|
||||
def options_header_balances(ex, *, force: bool = False):
|
||||
try:
|
||||
if _GET_DB is not None and is_sim_mode(_GET_DB):
|
||||
w = broker().balances_header()
|
||||
return (
|
||||
round(float(w["trading_usdc"]), 2),
|
||||
round(float(w["funding_usdc"]), 2),
|
||||
round(float(w["funding_usdt"]), 2),
|
||||
round(float(w["trading_usdt"]), 2),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _orig_header(ex, force=force)
|
||||
|
||||
def fetch_options_balances(ex, *, force: bool = False):
|
||||
try:
|
||||
if _GET_DB is not None and is_sim_mode(_GET_DB):
|
||||
w = broker().balances_header()
|
||||
return {
|
||||
"trading_usdc": float(w["trading_usdc"]),
|
||||
"funding_usdc": float(w["funding_usdc"]),
|
||||
"funding_usdt": float(w["funding_usdt"]),
|
||||
"trading_usdt": float(w["trading_usdt"]),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return _orig_fetch_bal(ex, force=force)
|
||||
|
||||
def options_api_ready(ex):
|
||||
try:
|
||||
if _GET_DB is not None and is_sim_mode(_GET_DB):
|
||||
return True, "sim"
|
||||
except Exception:
|
||||
pass
|
||||
return _orig_ready(ex)
|
||||
|
||||
def fetch_option_order(ex, *, inst_id: str, ord_id: str):
|
||||
oid = str(ord_id or "")
|
||||
if oid.startswith("sim-"):
|
||||
info = broker().get_option_order(oid)
|
||||
if info:
|
||||
return info
|
||||
return {"ok": False, "msg": "sim order not found"}
|
||||
return _orig_fetch_order(ex, inst_id=inst_id, ord_id=ord_id)
|
||||
|
||||
def wait_option_order_full_fill(
|
||||
ex,
|
||||
*,
|
||||
inst_id: str,
|
||||
ord_id: str,
|
||||
need_sheets: int,
|
||||
timeout_sec: float = 12.0,
|
||||
poll_sec: float = 0.35,
|
||||
cancel_on_timeout: bool = True,
|
||||
):
|
||||
oid = str(ord_id or "")
|
||||
if oid.startswith("sim-"):
|
||||
info = broker().get_option_order(oid)
|
||||
if not info:
|
||||
return {"ok": False, "msg": "sim order not found", "filled_sheets": 0}
|
||||
return {
|
||||
"ok": True,
|
||||
"filled_sheets": int(round(float(info.get("acc_fill_sz") or need_sheets))),
|
||||
"avg_px": info.get("avg_px"),
|
||||
"state": "filled",
|
||||
"order": info,
|
||||
}
|
||||
return _orig_wait(
|
||||
ex,
|
||||
inst_id=inst_id,
|
||||
ord_id=ord_id,
|
||||
need_sheets=need_sheets,
|
||||
timeout_sec=timeout_sec,
|
||||
poll_sec=poll_sec,
|
||||
cancel_on_timeout=cancel_on_timeout,
|
||||
)
|
||||
|
||||
def fetch_option_positions(ex):
|
||||
try:
|
||||
if _GET_DB is not None and is_sim_mode(_GET_DB):
|
||||
return broker().option_positions_okx_rows()
|
||||
except Exception:
|
||||
pass
|
||||
return _orig_fetch_pos(ex)
|
||||
|
||||
opt_lib.options_header_balances = options_header_balances
|
||||
opt_lib.fetch_options_balances = fetch_options_balances
|
||||
opt_lib.options_api_ready = options_api_ready
|
||||
opt_lib.fetch_option_order = fetch_option_order
|
||||
opt_lib.wait_option_order_full_fill = wait_option_order_full_fill
|
||||
opt_lib.fetch_option_positions = fetch_option_positions
|
||||
opt_lib._sim_hooks_applied = True
|
||||
|
||||
|
||||
def wrap_option_place_fns(get_db: Callable, live_place_limit, live_place_market):
|
||||
"""返回按模式分流的 place_option_limit/market."""
|
||||
|
||||
def place_option_limit_order(ex, **kwargs):
|
||||
if is_sim_mode(get_db):
|
||||
side = (kwargs.get("side") or "").lower()
|
||||
b = broker()
|
||||
if side == "sell" or kwargs.get("reduce_only"):
|
||||
return b.sell_option_close(ex, **kwargs)
|
||||
return b.place_option_buy(ex, **kwargs)
|
||||
return live_place_limit(ex, **kwargs)
|
||||
|
||||
def place_option_market_order(ex, **kwargs):
|
||||
if is_sim_mode(get_db):
|
||||
side = (kwargs.get("side") or "").lower()
|
||||
b = broker()
|
||||
if side == "sell" or kwargs.get("reduce_only"):
|
||||
return b.sell_option_close(ex, **kwargs)
|
||||
return b.place_option_buy(ex, **kwargs)
|
||||
return live_place_market(ex, **kwargs)
|
||||
|
||||
return place_option_limit_order, place_option_market_order
|
||||
|
||||
|
||||
def patch_options_cfg(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""就地替换 options/hedge cfg 中的下单函数为模式感知包装."""
|
||||
get_db = cfg.get("get_db")
|
||||
if not callable(get_db):
|
||||
return cfg
|
||||
set_get_db(get_db)
|
||||
live_limit = cfg.get("place_option_limit_order")
|
||||
live_market = cfg.get("place_option_market_order")
|
||||
if callable(live_limit) and callable(live_market):
|
||||
wrapped_l, wrapped_m = wrap_option_place_fns(get_db, live_limit, live_market)
|
||||
cfg["place_option_limit_order"] = wrapped_l
|
||||
cfg["place_option_market_order"] = wrapped_m
|
||||
elif callable(live_limit):
|
||||
wrapped_l, _ = wrap_option_place_fns(
|
||||
get_db,
|
||||
live_limit,
|
||||
live_limit,
|
||||
)
|
||||
cfg["place_option_limit_order"] = wrapped_l
|
||||
|
||||
live_cancel = cfg.get("cancel_option_order")
|
||||
|
||||
def cancel_option_order(ex, **kwargs):
|
||||
oid = str(kwargs.get("ord_id") or "")
|
||||
if oid.startswith("sim-") or (callable(get_db) and is_sim_mode(get_db)):
|
||||
return {"ok": True, "msg": "sim cancel noop", "sim": True}
|
||||
if callable(live_cancel):
|
||||
return live_cancel(ex, **kwargs)
|
||||
return {"ok": False, "msg": "cancel unavailable"}
|
||||
|
||||
if "cancel_option_order" in cfg:
|
||||
cfg["cancel_option_order"] = cancel_option_order
|
||||
|
||||
live_pending = cfg.get("fetch_option_pending_orders")
|
||||
|
||||
def fetch_option_pending_orders(ex, **kwargs):
|
||||
if is_sim_mode(get_db):
|
||||
return []
|
||||
if callable(live_pending):
|
||||
return live_pending(ex, **kwargs)
|
||||
return []
|
||||
|
||||
if "fetch_option_pending_orders" in cfg:
|
||||
cfg["fetch_option_pending_orders"] = fetch_option_pending_orders
|
||||
|
||||
return cfg
|
||||
@@ -0,0 +1,52 @@
|
||||
"""交易模式: sim | live, 持久化到 app_runtime_settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from lib.instance.runtime_settings_lib import runtime_get, runtime_set, with_db
|
||||
|
||||
TRADING_MODE_KEY = "trading.mode"
|
||||
MODE_SIM = "sim"
|
||||
MODE_LIVE = "live"
|
||||
VALID_MODES = (MODE_SIM, MODE_LIVE)
|
||||
|
||||
|
||||
def default_trading_mode() -> str:
|
||||
raw = (os.getenv("SIM_DEFAULT_MODE") or "").strip().lower()
|
||||
if raw in VALID_MODES:
|
||||
return raw
|
||||
return MODE_SIM
|
||||
|
||||
|
||||
def normalize_mode(mode: str | None) -> str:
|
||||
m = (mode or "").strip().lower()
|
||||
if m in VALID_MODES:
|
||||
return m
|
||||
raise ValueError("mode 须为 sim 或 live")
|
||||
|
||||
|
||||
def get_trading_mode(get_db: Callable) -> str:
|
||||
def _read(conn):
|
||||
v = runtime_get(conn, TRADING_MODE_KEY)
|
||||
if v is None or str(v).strip() == "":
|
||||
return default_trading_mode()
|
||||
m = str(v).strip().lower()
|
||||
return m if m in VALID_MODES else default_trading_mode()
|
||||
|
||||
return with_db(get_db, _read)
|
||||
|
||||
|
||||
def set_trading_mode(get_db: Callable, mode: str) -> str:
|
||||
m = normalize_mode(mode)
|
||||
|
||||
def _write(conn):
|
||||
runtime_set(conn, TRADING_MODE_KEY, m)
|
||||
return m
|
||||
|
||||
return with_db(get_db, _write)
|
||||
|
||||
|
||||
def is_sim_mode(get_db: Callable) -> bool:
|
||||
return get_trading_mode(get_db) == MODE_SIM
|
||||
@@ -0,0 +1,79 @@
|
||||
"""成交价与手续费: 滑点 = 1×fee_rate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PriceResult:
|
||||
base_px: float
|
||||
fill_px: float
|
||||
fee: float
|
||||
slip: float
|
||||
notional: float
|
||||
|
||||
def to_dict(self) -> dict[str, float]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def sim_fee_rate(override: float | None = None) -> float:
|
||||
if override is not None:
|
||||
return float(override)
|
||||
try:
|
||||
return float(os.getenv("SIM_FEE_RATE") or "0.0005")
|
||||
except (TypeError, ValueError):
|
||||
return 0.0005
|
||||
|
||||
|
||||
def perp_fill(
|
||||
*,
|
||||
side: str,
|
||||
action: str,
|
||||
bid: float,
|
||||
ask: float,
|
||||
qty: float,
|
||||
fee_rate: float,
|
||||
) -> PriceResult:
|
||||
"""
|
||||
side: long|short
|
||||
action: open|close
|
||||
开多/平空: 吃卖一 ×(1+f)
|
||||
开空/平多: 吃买一 ×(1-f)
|
||||
qty: 标的数量(合约张数 × 合约面值)
|
||||
"""
|
||||
f = float(fee_rate)
|
||||
buying = (action == "open" and side == "long") or (action == "close" and side == "short")
|
||||
if buying:
|
||||
base = float(ask)
|
||||
fill = base * (1.0 + f)
|
||||
else:
|
||||
base = float(bid)
|
||||
fill = base * (1.0 - f)
|
||||
notional = abs(fill * float(qty))
|
||||
fee = notional * f
|
||||
slip = abs(fill - base) * float(qty)
|
||||
return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional)
|
||||
|
||||
|
||||
def option_fill(
|
||||
*,
|
||||
action: str,
|
||||
bid: float,
|
||||
ask: float,
|
||||
qty: float,
|
||||
fee_rate: float,
|
||||
) -> PriceResult:
|
||||
"""开仓买入吃卖一; 平仓卖出吃买一. qty = sheets × ct_mult."""
|
||||
f = float(fee_rate)
|
||||
if action == "open":
|
||||
base = float(ask)
|
||||
fill = base * (1.0 + f)
|
||||
else:
|
||||
base = float(bid)
|
||||
fill = base * (1.0 - f)
|
||||
notional = abs(fill * float(qty))
|
||||
fee = notional * f
|
||||
slip = abs(fill - base) * float(qty)
|
||||
return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""安装模拟资金路由与模板."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
|
||||
from lib.sim.broker_lib import SimBroker
|
||||
from lib.sim.db_lib import init_sim_tables
|
||||
from lib.sim.hooks import apply_sim_hooks, patch_options_cfg, set_get_db
|
||||
from lib.sim.mode_lib import get_trading_mode, is_sim_mode, set_trading_mode
|
||||
from lib.sim.pricing_lib import sim_fee_rate
|
||||
from lib.sim.wallets_lib import SimWallets
|
||||
|
||||
|
||||
def attach_sim_templates(app: Flask, repo_root: str) -> None:
|
||||
tpl_dir = os.path.join(repo_root, "lib", "sim", "templates")
|
||||
if not os.path.isdir(tpl_dir):
|
||||
return
|
||||
existing = app.jinja_loader
|
||||
loaders = [FileSystemLoader(tpl_dir)]
|
||||
if existing is not None:
|
||||
if isinstance(existing, ChoiceLoader):
|
||||
loaders = list(existing.loaders) + loaders
|
||||
else:
|
||||
loaders.insert(0, existing)
|
||||
app.jinja_loader = ChoiceLoader(loaders)
|
||||
|
||||
|
||||
def install_sim_trading(app: Flask, repo_root: str, app_module: Any = None) -> None:
|
||||
if app_module is None:
|
||||
raise ValueError("install_sim_trading 需要 app_module")
|
||||
|
||||
attach_sim_templates(app, repo_root)
|
||||
get_db = app_module.get_db
|
||||
login_required = app_module.login_required
|
||||
set_get_db(get_db)
|
||||
|
||||
try:
|
||||
conn = get_db()
|
||||
try:
|
||||
init_sim_tables(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[sim] init tables: {e}")
|
||||
|
||||
apply_sim_hooks(app_module)
|
||||
|
||||
# 若 options / hedge 已安装, 补丁其 cfg, 并刷新永续下单引用
|
||||
for key in ("options_cfg", "hedge_plan_cfg"):
|
||||
cfg = app.extensions.get(key)
|
||||
if isinstance(cfg, dict):
|
||||
patch_options_cfg(cfg)
|
||||
for fn_name in (
|
||||
"place_exchange_order",
|
||||
"close_exchange_order",
|
||||
"get_exchange_capitals",
|
||||
"get_available_trading_usdt",
|
||||
"ensure_okx_live_ready",
|
||||
"get_live_position_contracts",
|
||||
):
|
||||
if fn_name in cfg and hasattr(app_module, fn_name):
|
||||
cfg[fn_name] = getattr(app_module, fn_name)
|
||||
|
||||
app.extensions["sim_installed"] = True
|
||||
|
||||
def _status_payload():
|
||||
mode = get_trading_mode(get_db)
|
||||
wallets = SimWallets(get_db).view() if mode == "sim" else None
|
||||
return {
|
||||
"mode": mode,
|
||||
"is_sim": mode == "sim",
|
||||
"wallets": wallets,
|
||||
"fee_rate": sim_fee_rate(),
|
||||
}
|
||||
|
||||
@app.route("/api/sim/status")
|
||||
@login_required
|
||||
def api_sim_status():
|
||||
return jsonify({"ok": True, **_status_payload()})
|
||||
|
||||
@app.route("/api/sim/mode", methods=["POST"])
|
||||
@login_required
|
||||
def api_sim_mode():
|
||||
body = request.get_json(silent=True) or {}
|
||||
mode = body.get("mode")
|
||||
try:
|
||||
saved = set_trading_mode(get_db, mode)
|
||||
except ValueError as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 400
|
||||
return jsonify({"ok": True, "mode": saved, **_status_payload()})
|
||||
|
||||
@app.route("/api/sim/reset", methods=["POST"])
|
||||
@login_required
|
||||
def api_sim_reset():
|
||||
if not is_sim_mode(get_db):
|
||||
return jsonify({"ok": False, "msg": "仅模拟模式可重置"}), 400
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
equity = float(body.get("equity_usdt") if body.get("equity_usdt") is not None else 10000)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "equity_usdt 无效"}), 400
|
||||
force = bool(body.get("force"))
|
||||
result = SimWallets(get_db).reset_equity(equity, force=force)
|
||||
if not result.get("ok"):
|
||||
return jsonify(result), 400
|
||||
return jsonify({"ok": True, **result, **_status_payload()})
|
||||
|
||||
@app.route("/api/sim/transfer", methods=["POST"])
|
||||
@login_required
|
||||
def api_sim_transfer():
|
||||
if not is_sim_mode(get_db):
|
||||
return jsonify({"ok": False, "msg": "仅模拟模式可划转"}), 400
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
amount = float(body.get("amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "amount 无效"}), 400
|
||||
result = SimWallets(get_db).transfer(
|
||||
ccy=str(body.get("ccy") or "USDT"),
|
||||
amount=amount,
|
||||
from_account=str(body.get("from") or ""),
|
||||
to_account=str(body.get("to") or ""),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return jsonify(result), 400
|
||||
return jsonify({**result, **_status_payload()})
|
||||
|
||||
@app.route("/api/sim/convert", methods=["POST"])
|
||||
@login_required
|
||||
def api_sim_convert():
|
||||
if not is_sim_mode(get_db):
|
||||
return jsonify({"ok": False, "msg": "仅模拟模式可兑换"}), 400
|
||||
body = request.get_json(silent=True) or {}
|
||||
try:
|
||||
amount = float(body.get("amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "msg": "amount 无效"}), 400
|
||||
result = SimWallets(get_db).convert(
|
||||
from_ccy=str(body.get("from_ccy") or ""),
|
||||
to_ccy=str(body.get("to_ccy") or ""),
|
||||
amount=amount,
|
||||
account=str(body.get("account") or "funding"),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
return jsonify(result), 400
|
||||
return jsonify({**result, **_status_payload()})
|
||||
|
||||
@app.route("/api/sim/positions")
|
||||
@login_required
|
||||
def api_sim_positions():
|
||||
if not is_sim_mode(get_db):
|
||||
return jsonify({"ok": True, "perp": [], "options": [], "is_sim": False})
|
||||
b = SimBroker(get_db)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"is_sim": True,
|
||||
"perp": b.list_perp_positions(),
|
||||
"options": b.list_option_positions(),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
{# 系统设置 · 模拟资金 #}
|
||||
<div class="sim-funds-panel" id="sim-funds-panel">
|
||||
<h2>模拟资金</h2>
|
||||
<p class="settings-subcard-desc">
|
||||
在本地 SQLite 钱包中模拟撮合: 用 OKX <strong>公开行情</strong> 吃买卖一结算盈亏,
|
||||
<strong>不会向交易所下真实订单</strong>.
|
||||
</p>
|
||||
<p class="muted" style="margin:0.5rem 0 1rem">
|
||||
当前模式:
|
||||
<strong id="sim-mode-label">…</strong>
|
||||
<span id="sim-mode-badge" class="sim-mode-badge" style="display:none;margin-left:0.5rem;padding:0.1rem 0.45rem;border:1px solid currentColor;font-size:0.85em">模拟</span>
|
||||
</p>
|
||||
|
||||
<div class="sim-mode-toggle" style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:1rem">
|
||||
<button type="button" class="btn" id="sim-btn-sim" onclick="simSetMode('sim')">切换到模拟资金</button>
|
||||
<button type="button" class="btn" id="sim-btn-live" onclick="simSetMode('live')">切换到实盘</button>
|
||||
</div>
|
||||
|
||||
<div id="sim-wallets-block" style="display:none">
|
||||
<h3>钱包余额</h3>
|
||||
<div class="sim-wallet-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:0.75rem;margin:0.75rem 0 1.25rem">
|
||||
<div><div class="muted">资金 USDT</div><div id="sim-bal-funding-usdt">—</div></div>
|
||||
<div><div class="muted">交易 USDT</div><div id="sim-bal-trading-usdt">—</div></div>
|
||||
<div><div class="muted">资金 USDC</div><div id="sim-bal-funding-usdc">—</div></div>
|
||||
<div><div class="muted">交易 USDC</div><div id="sim-bal-trading-usdc">—</div></div>
|
||||
</div>
|
||||
<p class="muted" id="sim-fee-rate-line">手续费率: —</p>
|
||||
|
||||
<h3>重置权益</h3>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
|
||||
<label>USDT <input type="number" id="sim-reset-equity" value="10000" min="0" step="1" style="width:8rem"></label>
|
||||
<button type="button" class="btn" onclick="simReset(false)">重置(需无仓)</button>
|
||||
<button type="button" class="btn" onclick="simReset(true)">强制重置(清仓)</button>
|
||||
</div>
|
||||
|
||||
<h3>账户划转</h3>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
|
||||
<select id="sim-xfer-ccy"><option>USDT</option><option>USDC</option></select>
|
||||
<select id="sim-xfer-from"><option value="funding">资金</option><option value="trading">交易</option></select>
|
||||
<span>→</span>
|
||||
<select id="sim-xfer-to"><option value="trading">交易</option><option value="funding">资金</option></select>
|
||||
<input type="number" id="sim-xfer-amt" min="0" step="0.01" placeholder="金额" style="width:7rem">
|
||||
<button type="button" class="btn" onclick="simTransfer()">划转</button>
|
||||
</div>
|
||||
|
||||
<h3>USDT ↔ USDC (1:1)</h3>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
|
||||
<select id="sim-conv-account"><option value="funding">资金账户</option><option value="trading">交易账户</option></select>
|
||||
<select id="sim-conv-from"><option value="USDT">USDT</option><option value="USDC">USDC</option></select>
|
||||
<span>→</span>
|
||||
<select id="sim-conv-to"><option value="USDC">USDC</option><option value="USDT">USDT</option></select>
|
||||
<input type="number" id="sim-conv-amt" min="0" step="0.01" placeholder="金额" style="width:7rem">
|
||||
<button type="button" class="btn" onclick="simConvert()">兑换</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="sim-funds-msg" class="muted" style="min-height:1.2em"></p>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function msg(t, ok) {
|
||||
var el = $("sim-funds-msg");
|
||||
if (!el) return;
|
||||
el.textContent = t || "";
|
||||
el.style.color = ok === false ? "#c0392b" : "";
|
||||
}
|
||||
function applyStatus(d) {
|
||||
if (!d) return;
|
||||
var mode = d.mode || (d.is_sim ? "sim" : "live");
|
||||
var label = $("sim-mode-label");
|
||||
if (label) label.textContent = mode === "sim" ? "模拟资金" : "实盘";
|
||||
var badge = $("sim-mode-badge");
|
||||
if (badge) badge.style.display = mode === "sim" ? "inline" : "none";
|
||||
var block = $("sim-wallets-block");
|
||||
if (block) block.style.display = mode === "sim" ? "block" : "none";
|
||||
var w = d.wallets || {};
|
||||
function setBal(id, v) {
|
||||
var el = $(id);
|
||||
if (el) el.textContent = (v == null || v === undefined) ? "—" : Number(v).toFixed(4);
|
||||
}
|
||||
setBal("sim-bal-funding-usdt", w.funding_usdt);
|
||||
setBal("sim-bal-trading-usdt", w.trading_usdt);
|
||||
setBal("sim-bal-funding-usdc", w.funding_usdc);
|
||||
setBal("sim-bal-trading-usdc", w.trading_usdc);
|
||||
var fee = $("sim-fee-rate-line");
|
||||
if (fee && d.fee_rate != null) fee.textContent = "手续费率: " + d.fee_rate;
|
||||
}
|
||||
window.simRefreshStatus = function () {
|
||||
return fetch("/api/sim/status", { credentials: "same-origin" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.ok) applyStatus(d);
|
||||
else msg((d && d.msg) || "状态加载失败", false);
|
||||
})
|
||||
.catch(function (e) { msg(String(e), false); });
|
||||
};
|
||||
window.simSetMode = function (mode) {
|
||||
msg("切换中…");
|
||||
fetch("/api/sim/mode", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: mode })
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (!d.ok) { msg(d.msg || "切换失败", false); return; }
|
||||
applyStatus(d);
|
||||
msg("已切换为 " + (mode === "sim" ? "模拟资金" : "实盘"), true);
|
||||
}).catch(function (e) { msg(String(e), false); });
|
||||
};
|
||||
window.simReset = function (force) {
|
||||
var equity = parseFloat(($("sim-reset-equity") || {}).value || "10000");
|
||||
msg("重置中…");
|
||||
fetch("/api/sim/reset", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ equity_usdt: equity, force: !!force })
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (!d.ok) { msg(d.detail || d.msg || "重置失败", false); return; }
|
||||
applyStatus(d);
|
||||
msg("已重置权益", true);
|
||||
}).catch(function (e) { msg(String(e), false); });
|
||||
};
|
||||
window.simTransfer = function () {
|
||||
var body = {
|
||||
ccy: ($("sim-xfer-ccy") || {}).value || "USDT",
|
||||
from: ($("sim-xfer-from") || {}).value || "funding",
|
||||
to: ($("sim-xfer-to") || {}).value || "trading",
|
||||
amount: parseFloat(($("sim-xfer-amt") || {}).value || "0")
|
||||
};
|
||||
fetch("/api/sim/transfer", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (!d.ok) { msg(d.detail || d.msg || "划转失败", false); return; }
|
||||
applyStatus(d);
|
||||
msg("划转成功", true);
|
||||
}).catch(function (e) { msg(String(e), false); });
|
||||
};
|
||||
window.simConvert = function () {
|
||||
var body = {
|
||||
account: ($("sim-conv-account") || {}).value || "funding",
|
||||
from_ccy: ($("sim-conv-from") || {}).value || "USDT",
|
||||
to_ccy: ($("sim-conv-to") || {}).value || "USDC",
|
||||
amount: parseFloat(($("sim-conv-amt") || {}).value || "0")
|
||||
};
|
||||
fetch("/api/sim/convert", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (!d.ok) { msg(d.detail || d.msg || "兑换失败", false); return; }
|
||||
applyStatus(d);
|
||||
msg("兑换成功", true);
|
||||
}).catch(function (e) { msg(String(e), false); });
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", window.simRefreshStatus);
|
||||
} else {
|
||||
window.simRefreshStatus();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,335 @@
|
||||
"""模拟资金钱包: funding/trading × USDT/USDC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
WALLET_KEYS = (
|
||||
"funding_usdt",
|
||||
"trading_usdt",
|
||||
"funding_usdc",
|
||||
"trading_usdc",
|
||||
)
|
||||
|
||||
_ACCT_MAP = {
|
||||
("funding", "usdt"): "funding_usdt",
|
||||
("trading", "usdt"): "trading_usdt",
|
||||
("funding", "usdc"): "funding_usdc",
|
||||
("trading", "usdc"): "trading_usdc",
|
||||
}
|
||||
|
||||
|
||||
class InsufficientFunds(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SimWallets:
|
||||
def __init__(self, get_db: Callable) -> None:
|
||||
self.get_db = get_db
|
||||
|
||||
def _now(self) -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def snapshot(self) -> dict[str, float]:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
|
||||
if row is None:
|
||||
return {k: 0.0 for k in WALLET_KEYS}
|
||||
return {k: float(row[k] or 0) for k in WALLET_KEYS}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def view(self) -> dict[str, float]:
|
||||
return self.snapshot()
|
||||
|
||||
def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float:
|
||||
v = snap or self.view()
|
||||
return round(
|
||||
float(v.get("funding_usdt") or 0)
|
||||
+ float(v.get("trading_usdt") or 0)
|
||||
+ float(v.get("funding_usdc") or 0)
|
||||
+ float(v.get("trading_usdc") or 0),
|
||||
8,
|
||||
)
|
||||
|
||||
def _write(self, snap: dict[str, float], conn=None) -> dict[str, float]:
|
||||
owns = conn is None
|
||||
if owns:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
now = self._now()
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sim_wallets SET
|
||||
funding_usdt=?, trading_usdt=?, funding_usdc=?, trading_usdc=?, updated_at=?
|
||||
WHERE id=1
|
||||
""",
|
||||
(
|
||||
float(snap["funding_usdt"]),
|
||||
float(snap["trading_usdt"]),
|
||||
float(snap["funding_usdc"]),
|
||||
float(snap["trading_usdc"]),
|
||||
now,
|
||||
),
|
||||
)
|
||||
if owns:
|
||||
conn.commit()
|
||||
return {k: float(snap[k]) for k in WALLET_KEYS}
|
||||
finally:
|
||||
if owns:
|
||||
conn.close()
|
||||
|
||||
def _ledger(
|
||||
self,
|
||||
conn,
|
||||
*,
|
||||
kind: str,
|
||||
amount: float,
|
||||
ccy: str,
|
||||
account: str,
|
||||
balance_after: float,
|
||||
note: str = "",
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sim_ledger_entries(kind, amount, ccy, account, balance_after, note, ts)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
""",
|
||||
(kind, float(amount), ccy.upper(), account, float(balance_after), note or "", self._now()),
|
||||
)
|
||||
|
||||
def debit_trading(self, ccy: str, amount: float, *, kind: str = "debit", note: str = "") -> dict[str, float]:
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
raise ValueError("扣款金额须大于 0")
|
||||
key = _ACCT_MAP.get(("trading", (ccy or "").lower()))
|
||||
if not key:
|
||||
raise ValueError("币种须为 USDT 或 USDC")
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
|
||||
snap = {k: float(row[k] or 0) for k in WALLET_KEYS}
|
||||
bal = float(snap[key])
|
||||
if amt > bal + 1e-9:
|
||||
raise InsufficientFunds(f"交易账户 {ccy.upper()} 不足(可用 {bal:.4f})")
|
||||
snap[key] = bal - amt
|
||||
self._write(snap, conn=conn)
|
||||
self._ledger(
|
||||
conn,
|
||||
kind=kind,
|
||||
amount=-amt,
|
||||
ccy=ccy,
|
||||
account="trading",
|
||||
balance_after=snap[key],
|
||||
note=note,
|
||||
)
|
||||
conn.commit()
|
||||
return snap
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def credit_trading(self, ccy: str, amount: float, *, kind: str = "credit", note: str = "") -> dict[str, float]:
|
||||
amt = float(amount)
|
||||
if amt < 0:
|
||||
raise ValueError("入账金额不能为负")
|
||||
if amt < 1e-12:
|
||||
return self.snapshot()
|
||||
key = _ACCT_MAP.get(("trading", (ccy or "").lower()))
|
||||
if not key:
|
||||
raise ValueError("币种须为 USDT 或 USDC")
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
|
||||
snap = {k: float(row[k] or 0) for k in WALLET_KEYS}
|
||||
snap[key] = float(snap[key]) + amt
|
||||
self._write(snap, conn=conn)
|
||||
self._ledger(
|
||||
conn,
|
||||
kind=kind,
|
||||
amount=amt,
|
||||
ccy=ccy,
|
||||
account="trading",
|
||||
balance_after=snap[key],
|
||||
note=note,
|
||||
)
|
||||
conn.commit()
|
||||
return snap
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
*,
|
||||
ccy: str,
|
||||
amount: float,
|
||||
from_account: str,
|
||||
to_account: str,
|
||||
) -> dict[str, Any]:
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "detail": "划转金额须大于 0"}
|
||||
ccy_l = (ccy or "USDT").strip().lower()
|
||||
fa = (from_account or "").strip().lower()
|
||||
ta = (to_account or "").strip().lower()
|
||||
if fa not in ("funding", "trading") or ta not in ("funding", "trading"):
|
||||
return {"ok": False, "detail": "账户仅支持 funding / trading"}
|
||||
if fa == ta:
|
||||
return {"ok": False, "detail": "来源与目标账户不能相同"}
|
||||
src_key = _ACCT_MAP.get((fa, ccy_l))
|
||||
dst_key = _ACCT_MAP.get((ta, ccy_l))
|
||||
if not src_key or not dst_key:
|
||||
return {"ok": False, "detail": "币种须为 USDT 或 USDC"}
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
|
||||
snap = {k: float(row[k] or 0) for k in WALLET_KEYS}
|
||||
src_bal = float(snap[src_key])
|
||||
if amt > src_bal + 1e-9:
|
||||
return {"ok": False, "detail": f"余额不足(可用 {src_bal:.4f})"}
|
||||
snap[src_key] = src_bal - amt
|
||||
snap[dst_key] = float(snap[dst_key]) + amt
|
||||
self._write(snap, conn=conn)
|
||||
self._ledger(
|
||||
conn,
|
||||
kind="transfer",
|
||||
amount=-amt,
|
||||
ccy=ccy_l,
|
||||
account=fa,
|
||||
balance_after=snap[src_key],
|
||||
note=f"to {ta}",
|
||||
)
|
||||
self._ledger(
|
||||
conn,
|
||||
kind="transfer",
|
||||
amount=amt,
|
||||
ccy=ccy_l,
|
||||
account=ta,
|
||||
balance_after=snap[dst_key],
|
||||
note=f"from {fa}",
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "transferred",
|
||||
"ccy": ccy_l.upper(),
|
||||
"amount": amt,
|
||||
"from": fa,
|
||||
"to": ta,
|
||||
"wallets": {k: float(snap[k]) for k in WALLET_KEYS},
|
||||
"total_usdt_equiv": self.total_usdt_equiv(snap),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def convert(
|
||||
self,
|
||||
*,
|
||||
from_ccy: str,
|
||||
to_ccy: str,
|
||||
amount: float,
|
||||
account: str = "funding",
|
||||
) -> dict[str, Any]:
|
||||
amt = float(amount)
|
||||
if amt <= 0:
|
||||
return {"ok": False, "detail": "数量须大于 0"}
|
||||
fa = (from_ccy or "").strip().lower()
|
||||
ta = (to_ccy or "").strip().lower()
|
||||
acct = (account or "funding").strip().lower()
|
||||
if acct not in ("funding", "trading"):
|
||||
return {"ok": False, "detail": "account 须为 funding / trading"}
|
||||
if {fa, ta} != {"usdt", "usdc"}:
|
||||
return {"ok": False, "detail": "仅支持 USDT↔USDC 1:1"}
|
||||
src_key = _ACCT_MAP[(acct, fa)]
|
||||
dst_key = _ACCT_MAP[(acct, ta)]
|
||||
conn = self.get_db()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
|
||||
snap = {k: float(row[k] or 0) for k in WALLET_KEYS}
|
||||
src = float(snap[src_key])
|
||||
if amt > src + 1e-9:
|
||||
return {"ok": False, "detail": f"{acct} {fa.upper()} 不足(可用 {src:.4f})"}
|
||||
snap[src_key] = src - amt
|
||||
snap[dst_key] = float(snap[dst_key]) + amt
|
||||
self._write(snap, conn=conn)
|
||||
self._ledger(
|
||||
conn,
|
||||
kind="convert",
|
||||
amount=-amt,
|
||||
ccy=fa,
|
||||
account=acct,
|
||||
balance_after=snap[src_key],
|
||||
note=f"to {ta}",
|
||||
)
|
||||
self._ledger(
|
||||
conn,
|
||||
kind="convert",
|
||||
amount=amt,
|
||||
ccy=ta,
|
||||
account=acct,
|
||||
balance_after=snap[dst_key],
|
||||
note=f"from {fa}",
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "converted",
|
||||
"from_ccy": fa.upper(),
|
||||
"to_ccy": ta.upper(),
|
||||
"amount": amt,
|
||||
"rate": 1.0,
|
||||
"account": acct,
|
||||
"wallets": {k: float(snap[k]) for k in WALLET_KEYS},
|
||||
"total_usdt_equiv": self.total_usdt_equiv(snap),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def has_open_positions(self) -> bool:
|
||||
conn = self.get_db()
|
||||
try:
|
||||
p = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM sim_perp_positions WHERE contracts > 1e-12"
|
||||
).fetchone()
|
||||
o = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM sim_option_positions WHERE sheets > 1e-12"
|
||||
).fetchone()
|
||||
pn = int(p["n"] if hasattr(p, "keys") else p[0])
|
||||
on = int(o["n"] if hasattr(o, "keys") else o[0])
|
||||
return pn > 0 or on > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def reset_equity(self, amount: float, *, force: bool = False) -> dict[str, Any]:
|
||||
if self.has_open_positions() and not force:
|
||||
return {"ok": False, "detail": "仍有模拟持仓,请先平仓或传 force=true"}
|
||||
amt = max(0.0, float(amount))
|
||||
conn = self.get_db()
|
||||
try:
|
||||
if force:
|
||||
conn.execute("DELETE FROM sim_perp_positions")
|
||||
conn.execute("DELETE FROM sim_option_positions")
|
||||
conn.execute("DELETE FROM sim_option_orders")
|
||||
now = self._now()
|
||||
snap = {
|
||||
"funding_usdt": amt,
|
||||
"trading_usdt": 0.0,
|
||||
"funding_usdc": 0.0,
|
||||
"trading_usdc": 0.0,
|
||||
}
|
||||
self._write(snap, conn=conn)
|
||||
self._ledger(
|
||||
conn,
|
||||
kind="reset",
|
||||
amount=amt,
|
||||
ccy="USDT",
|
||||
account="funding",
|
||||
balance_after=amt,
|
||||
note="reset equity",
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "wallets": snap, "total_usdt_equiv": amt}
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user