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:
dekun
2026-08-13 20:00:59 +08:00
commit a1abe159fa
238 changed files with 69214 additions and 0 deletions
+11
View File
@@ -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)
+528
View File
@@ -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
+116
View File
@@ -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()
+321
View File
@@ -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
+52
View File
@@ -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
+79
View File
@@ -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)
+166
View File
@@ -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(),
}
)
+167
View File
@@ -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>
+335
View File
@@ -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()