Initial CoinOX instance: coin-margined options + USDT hedge.

Forked from crypto_monitor OKX instance (copy-only). Keeps standalone options, perp-options and options-options hedge; drops standalone perp entrypoints; forces coin margin; adds sim wallets; default port 5010.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 00:18:40 +08:00
commit 88b7fb7a0a
381 changed files with 104628 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)
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
"""模拟资金 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 _ensure_sim_wallet_coin_columns(conn: sqlite3.Connection) -> None:
"""旧库补齐币本位 ETH/BTC 列."""
cols = {
str(r[1])
for r in conn.execute("PRAGMA table_info(sim_wallets)").fetchall()
}
for col in ("funding_eth", "trading_eth", "funding_btc", "trading_btc"):
if col not in cols:
conn.execute(
f"ALTER TABLE sim_wallets ADD COLUMN {col} REAL NOT NULL DEFAULT 0"
)
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,
funding_eth REAL NOT NULL DEFAULT 0,
trading_eth REAL NOT NULL DEFAULT 0,
funding_btc REAL NOT NULL DEFAULT 0,
trading_btc REAL NOT NULL DEFAULT 0,
updated_at TEXT
)
"""
)
_ensure_sim_wallet_coin_columns(conn)
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
)
"""
)
try:
conn.execute("ALTER TABLE sim_option_orders ADD COLUMN fee REAL DEFAULT 0")
except Exception:
pass
try:
conn.execute("ALTER TABLE sim_option_orders ADD COLUMN fee_ccy TEXT")
except Exception:
pass
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()
+566
View File
@@ -0,0 +1,566 @@
"""运行时挂钩: 将 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_live_contracts = getattr(app_module, "get_live_position_contracts", None)
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 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)
if callable(_orig_live_contracts):
return _orig_live_contracts(exchange_symbol, direction)
return 0.0
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.get_live_position_contracts = get_live_position_contracts
_orig_xfer = getattr(app_module, "execute_transfer_usdt", None)
if callable(_orig_xfer):
def execute_transfer_usdt(amount, from_account, to_account):
if is_sim_mode(app_module.get_db):
from lib.sim.wallets_lib import SimWallets, normalize_sim_account
try:
result = SimWallets(app_module.get_db).transfer(
ccy="USDT",
amount=float(amount),
from_account=normalize_sim_account(from_account),
to_account=normalize_sim_account(to_account),
)
except Exception as e:
return False, str(e), None
if not result.get("ok"):
return False, result.get("detail") or result.get("msg") or "划转失败", None
try:
from lib.instance.instance_live_push_lib import notify_instance_balance_changed
notify_instance_balance_changed()
except Exception:
pass
return True, "sim 划转成功", result
return _orig_xfer(amount, from_account, to_account)
app_module.execute_transfer_usdt = execute_transfer_usdt
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
_orig_transfer = opt_lib.transfer_ccy
_orig_swap = opt_lib.spot_market_swap_usdt_usdc
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.get("trading_usdc") or 0), 2),
round(float(w.get("funding_usdc") or 0), 2),
round(float(w.get("funding_usdt") or 0), 2),
round(float(w.get("trading_usdt") or 0), 2),
)
except Exception:
pass
return _orig_header(ex, force=force)
def fetch_options_balances(
ex, *, force: bool = False, scope: str = "main", sub_acct: str = ""
):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
w = broker().balances_header()
fu = float(w.get("funding_usdt") or 0)
fc = float(w.get("funding_usdc") or 0)
tu = float(w.get("trading_usdt") or 0)
tc = float(w.get("trading_usdc") or 0)
fe = float(w.get("funding_eth") or 0)
te = float(w.get("trading_eth") or 0)
fb = float(w.get("funding_btc") or 0)
tb = float(w.get("trading_btc") or 0)
return {
"scope": "main",
"funding_usdt": fu,
"funding_usdc": fc,
"trading_usdt": tu,
"trading_usdc": tc,
"funding_eth": fe,
"trading_eth": te,
"funding_btc": fb,
"trading_btc": tb,
"funding_usdt_avail": fu,
"funding_usdc_avail": fc,
"trading_usdt_avail": tu,
"trading_usdc_avail": tc,
"funding_eth_avail": fe,
"trading_eth_avail": te,
"funding_btc_avail": fb,
"trading_btc_avail": tb,
}
except Exception:
pass
return _orig_fetch_bal(ex, force=force, scope=scope, sub_acct=sub_acct)
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 transfer_ccy(ex, ccy, amount, from_acct, to_acct):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
from lib.sim.wallets_lib import SimWallets, normalize_sim_account
result = SimWallets(_GET_DB).transfer(
ccy=str(ccy or "USDC"),
amount=float(amount),
from_account=normalize_sim_account(from_acct),
to_account=normalize_sim_account(to_acct),
)
if not result.get("ok"):
return {
"ok": False,
"msg": result.get("detail") or result.get("msg") or "划转失败",
}
try:
from lib.instance.instance_live_push_lib import notify_instance_balance_changed
notify_instance_balance_changed()
except Exception:
pass
return {"ok": True, "msg": "sim 划转成功", "sim": True, **result}
except Exception as e:
return {"ok": False, "msg": str(e)}
return _orig_transfer(ex, ccy, amount, from_acct, to_acct)
def spot_market_swap_usdt_usdc(ex, *, direction: str = "usdt_to_usdc", amount: float = 0):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
# 对齐实盘: 交易账户 + USDC/USDT 公开买卖一(含手续费滑点)
pub = _sim_public_exchange(ex)
if pub is None and _APP_MODULE is not None:
pub = getattr(_APP_MODULE, "exchange_options", None)
if pub is None:
return {"ok": False, "msg": "sim: 无公开行情 exchange"}
result = broker().convert_usdt_usdc(
pub,
direction=direction,
amount=float(amount),
account="trading",
)
if not result.get("ok"):
return {
"ok": False,
"msg": result.get("detail") or result.get("msg") or "兑换失败",
}
try:
from lib.instance.instance_live_push_lib import notify_instance_balance_changed
notify_instance_balance_changed()
except Exception:
pass
px = result.get("fill_px")
msg = f"sim 兑换成功 @ {px:.6f}" if px else "sim 兑换成功"
return {"ok": True, "msg": msg, "sim": True, **result}
except Exception as e:
return {"ok": False, "msg": str(e)}
return _orig_swap(ex, direction=direction, amount=amount)
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(ex)
except Exception:
pass
return _orig_fetch_pos(ex)
_orig_fetch_hist = getattr(opt_lib, "fetch_option_position_history", None)
_orig_fetch_all_hist = getattr(opt_lib, "fetch_all_option_positions_history", None)
def fetch_option_position_history(ex, inst_id, limit=50):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
return broker().option_positions_history_okx_rows(
ex, inst_id=inst_id, limit=limit
)
except Exception:
pass
if callable(_orig_fetch_hist):
return _orig_fetch_hist(ex, inst_id, limit=limit)
return []
def fetch_all_option_positions_history(ex, *, limit=200):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
return broker().option_positions_history_okx_rows(ex, limit=limit)
except Exception:
pass
if callable(_orig_fetch_all_hist):
return _orig_fetch_all_hist(ex, limit=limit)
return []
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.transfer_ccy = transfer_ccy
opt_lib.spot_market_swap_usdt_usdc = spot_market_swap_usdt_usdc
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.fetch_option_position_history = fetch_option_position_history
opt_lib.fetch_all_option_positions_history = fetch_all_option_positions_history
opt_lib._sim_hooks_applied = True
_patch_spot_bridge_lib()
def _sim_public_exchange(ex: Any = None) -> Any:
"""模拟盘行情优先用无密钥/公开实例,避免 options 私钥 50111 污染 public 请求."""
if _APP_MODULE is not None:
pub = getattr(_APP_MODULE, "exchange", None)
if pub is not None:
return pub
return ex
def _patch_spot_bridge_lib() -> None:
"""币本位现货桥:模拟盘走本地 USDT↔ETH/BTC,勿打实盘 private_post_trade_order."""
import lib.options.options_spot_bridge_lib as bridge_lib
if getattr(bridge_lib, "_sim_hooks_applied", False):
return
_orig_buy = bridge_lib.spot_market_buy_coin_with_usdt
_orig_sell = bridge_lib.spot_market_sell_coin_to_usdt
_orig_avail = bridge_lib.fetch_trading_coin_available
def fetch_trading_coin_available(ex, ccy: str):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
ccy_u = (ccy or "").strip().upper()
if ccy_u not in ("ETH", "BTC"):
return None
w = broker().balances_header()
return float(w.get(f"trading_{ccy_u.lower()}") or 0)
except Exception:
pass
return _orig_avail(ex, ccy)
def spot_market_buy_coin_with_usdt(ex, *, underlying: str, usdt_amount: float):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
pub = _sim_public_exchange(ex)
if pub is None:
return {"ok": False, "msg": "sim: 无公开行情 exchange"}
result = broker().convert_usdt_coin(
pub,
underlying=underlying,
direction="usdt_to_coin",
amount=float(usdt_amount),
account="trading",
)
if not result.get("ok"):
return {
"ok": False,
"msg": result.get("detail") or result.get("msg") or "买币失败",
}
try:
from lib.instance.instance_live_push_lib import notify_instance_balance_changed
notify_instance_balance_changed()
except Exception:
pass
return {
"ok": True,
"inst_id": result.get("inst_id") or "",
"ord_id": f"sim-spot-buy-{result.get('fill_px') or 0}",
"sim": True,
"coin_bought": result.get("coin_bought"),
"data": {"sCode": "0", "sMsg": "sim filled"},
**{k: result[k] for k in ("fill_px", "usdt_spent", "underlying") if k in result},
}
except Exception as e:
return {"ok": False, "msg": str(e)}
return _orig_buy(ex, underlying=underlying, usdt_amount=usdt_amount)
def spot_market_sell_coin_to_usdt(ex, *, underlying: str, coin_amount: float | None = None):
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
coin = (underlying or "ETH").strip().upper() or "ETH"
avail = fetch_trading_coin_available(ex, coin)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {coin}"}
from lib.options.options_spot_bridge_lib import is_sellable_spot_coin, spot_min_sell_sz
amt = float(avail)
if coin_amount is not None:
try:
lim = float(coin_amount)
except (TypeError, ValueError):
lim = 0.0
if lim > 0:
amt = min(amt, lim)
if not is_sellable_spot_coin(ex, coin, amt):
min_sz = spot_min_sell_sz(ex, coin)
return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"}
# 平仓卖回(coin_amount=None)一次卖光;限量卖才留粉尘余量
if coin_amount is None:
sell_sz = float(amt)
else:
sell_sz = max(0.0, float(amt) * 0.999)
if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz):
return {"ok": False, "msg": f"{coin} 可卖数量过小"}
pub = _sim_public_exchange(ex)
if pub is None:
return {"ok": False, "msg": "sim: 无公开行情 exchange"}
result = broker().convert_usdt_coin(
pub,
underlying=coin,
direction="coin_to_usdt",
amount=float(sell_sz),
account="trading",
)
if not result.get("ok"):
return {
"ok": False,
"msg": result.get("detail") or result.get("msg") or "卖币失败",
}
try:
from lib.instance.instance_live_push_lib import notify_instance_balance_changed
notify_instance_balance_changed()
except Exception:
pass
recovered = result.get("usdt_recovered")
if recovered is None and result.get("fill_px") is not None:
try:
recovered = round(float(sell_sz) * float(result.get("fill_px")), 8)
except (TypeError, ValueError):
recovered = None
return {
"ok": True,
"inst_id": result.get("inst_id") or "",
"ord_id": f"sim-spot-sell-{result.get('fill_px') or 0}",
"coin_sold": float(sell_sz),
"fill_px": result.get("fill_px"),
"sim": True,
"usdt_recovered": recovered,
"data": {"sCode": "0", "sMsg": "sim filled"},
}
except Exception as e:
return {"ok": False, "msg": str(e)}
return _orig_sell(ex, underlying=underlying, coin_amount=coin_amount)
bridge_lib.fetch_trading_coin_available = fetch_trading_coin_available
bridge_lib.spot_market_buy_coin_with_usdt = spot_market_buy_coin_with_usdt
bridge_lib.spot_market_sell_coin_to_usdt = spot_market_sell_coin_to_usdt
bridge_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)
# 与 apply_sim_hooks 对齐: cfg 里可能仍是 import 时的旧引用
import lib.exchange.okx_options_lib as opt_lib
for key in (
"transfer_ccy",
"spot_market_swap_usdt_usdc",
"options_api_ready",
"fetch_options_balances",
"fetch_option_positions",
"fetch_option_order",
"wait_option_order_full_fill",
"fetch_option_position_history",
"fetch_all_option_positions_history",
):
if key in cfg and hasattr(opt_lib, key):
cfg[key] = getattr(opt_lib, key)
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, inst_id=None, **kwargs):
# 调用方多为 (ex, inst_id) 位置参数, 不可只收 **kwargs
if is_sim_mode(get_db):
return []
if callable(live_pending):
return live_pending(ex, inst_id)
return []
if "fetch_option_pending_orders" in cfg:
cfg["fetch_option_pending_orders"] = fetch_option_pending_orders
return cfg
+88
View File
@@ -0,0 +1,88 @@
"""交易模式: 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 peek_persisted_trading_mode(db_path: str | None = None) -> str | None:
"""只读 SQLite 中的 trading.mode(供 env 配置页展示当前生效值)."""
import sqlite3
path = (db_path or os.getenv("DB_PATH") or "crypto.db").strip()
if not path:
return None
if not os.path.isabs(path):
path = os.path.abspath(path)
if not os.path.isfile(path):
return None
try:
conn = sqlite3.connect(path, timeout=2)
try:
row = conn.execute(
"SELECT value FROM app_runtime_settings WHERE key=?",
(TRADING_MODE_KEY,),
).fetchone()
finally:
conn.close()
except Exception:
return None
if not row:
return None
m = str(row[0] or "").strip().lower()
return m if m in VALID_MODES else None
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
def exchange_mode_label(*, is_sim: bool | None = None, get_db: Callable | None = None, exchange_key: str = "okx") -> str:
"""顶栏交易所展示: okx_sim / okx_live."""
ex = (exchange_key or "okx").strip().lower() or "okx"
if is_sim is None and callable(get_db):
is_sim = is_sim_mode(get_db)
return f"{ex}_sim" if bool(is_sim) else f"{ex}_live"
+240
View File
@@ -0,0 +1,240 @@
"""成交价与手续费.
永续:滑点=1×fee_rate,手续费=名义×fee_rate.
期权(币本位):滑点仍按权利金价×fee_rate;手续费对齐 OKX=
Min(fee_rate×标的币数量, 权利金×7%).
"""
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_fee_coin(
*,
underlying_coin: float,
premium_coin: float,
fee_rate: float | None = None,
premium_fee_cap: float = 0.07,
) -> float:
"""OKX 币本位期权手续费(权利金币种).
Min(费率 × 张数对应标的币数量, 权利金×封顶比例).
注意:费率作用在标的币面值上,不是权利金名义上.
"""
f = sim_fee_rate(fee_rate)
qty = abs(float(underlying_coin or 0.0))
prem = abs(float(premium_coin or 0.0))
if qty <= 0 and prem <= 0:
return 0.0
by_rate = qty * f
cap_ratio = abs(float(premium_fee_cap or 0.07))
if cap_ratio <= 0 or prem <= 0:
return round(by_rate, 8)
return round(min(by_rate, prem * cap_ratio), 8)
def option_fill(
*,
action: str,
bid: float,
ask: float,
qty: float,
fee_rate: float,
) -> PriceResult:
"""开仓买入吃卖一; 平仓卖出吃买一.
qty = sheets × ct_mult(标的币数量).
滑点仍按权利金价×费率;手续费按 OKX 币本位公式(标的币×费率,权利金7%封顶).
"""
f = float(fee_rate)
if action == "open":
base = float(ask)
fill = base * (1.0 + f)
else:
base = float(bid)
fill = base * (1.0 - f)
qty_f = abs(float(qty))
notional = abs(fill * qty_f)
fee = option_fee_coin(
underlying_coin=qty_f,
premium_coin=notional,
fee_rate=f,
)
slip = abs(fill - base) * qty_f
return PriceResult(base_px=base, fill_px=fill, fee=fee, slip=slip, notional=notional)
@dataclass(slots=True)
class SpotConvertResult:
"""USDC/USDT 现货兑换: price = USDT per USDC."""
direction: str
from_ccy: str
to_ccy: str
from_amount: float
to_amount: float
base_px: float
fill_px: float
fee: float
def spot_usdc_usdt_fill(
*,
direction: str,
amount: float,
bid: float,
ask: float,
fee_rate: float,
) -> SpotConvertResult:
"""
对齐实盘 USDC-USDT 现货市价:
- usdt_to_usdc: 用 USDT 买 USDC, 吃卖一 ×(1+f)
- usdc_to_usdt: 卖 USDC 换 USDT, 吃买一 ×(1-f)
amount 为付出币种数量.
"""
f = float(fee_rate)
amt = float(amount)
d = (direction or "").strip().lower()
if d == "usdt_to_usdc":
base = float(ask)
fill = base * (1.0 + f)
if fill <= 0:
raise ValueError("无效卖一价")
to_amt = amt / fill
fee = amt * f
return SpotConvertResult(
direction=d,
from_ccy="USDT",
to_ccy="USDC",
from_amount=amt,
to_amount=to_amt,
base_px=base,
fill_px=fill,
fee=fee,
)
if d == "usdc_to_usdt":
base = float(bid)
fill = base * (1.0 - f)
if fill <= 0:
raise ValueError("无效买一价")
to_amt = amt * fill
fee = to_amt * f
return SpotConvertResult(
direction=d,
from_ccy="USDC",
to_ccy="USDT",
from_amount=amt,
to_amount=to_amt,
base_px=base,
fill_px=fill,
fee=fee,
)
raise ValueError("direction 须为 usdt_to_usdc 或 usdc_to_usdt")
def spot_coin_usdt_fill(
*,
direction: str,
amount: float,
bid: float,
ask: float,
fee_rate: float,
coin: str = "ETH",
) -> SpotConvertResult:
"""
对齐实盘 ETH-USDT / BTC-USDT 现货市价:
- usdt_to_coin: 用 USDT 买币, 吃卖一 ×(1+f); amount=USDT
- coin_to_usdt: 卖币换 USDT, 吃买一 ×(1-f); amount=币数量
"""
f = float(fee_rate)
amt = float(amount)
ccy = (coin or "ETH").strip().upper() or "ETH"
d = (direction or "").strip().lower()
if d in ("usdt_to_coin", "usdt_to_eth", "usdt_to_btc"):
base = float(ask)
fill = base * (1.0 + f)
if fill <= 0:
raise ValueError("无效卖一价")
to_amt = amt / fill
fee = amt * f
return SpotConvertResult(
direction="usdt_to_coin",
from_ccy="USDT",
to_ccy=ccy,
from_amount=amt,
to_amount=to_amt,
base_px=base,
fill_px=fill,
fee=fee,
)
if d in ("coin_to_usdt", "eth_to_usdt", "btc_to_usdt"):
base = float(bid)
fill = base * (1.0 - f)
if fill <= 0:
raise ValueError("无效买一价")
to_amt = amt * fill
fee = to_amt * f
return SpotConvertResult(
direction="coin_to_usdt",
from_ccy=ccy,
to_ccy="USDT",
from_amount=amt,
to_amount=to_amt,
base_px=base,
fill_px=fill,
fee=fee,
)
raise ValueError("direction 须为 usdt_to_coin 或 coin_to_usdt")
+179
View File
@@ -0,0 +1,179 @@
"""安装模拟资金路由与模板."""
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",):
cfg = app.extensions.get(key)
if isinstance(cfg, dict):
patch_options_cfg(cfg)
for fn_name in (
"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
try:
from app import invalidate_account_balance_cache
invalidate_account_balance_cache()
except Exception:
pass
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
from_ccy = str(body.get("from_ccy") or "USDT").strip().upper()
to_ccy = str(body.get("to_ccy") or "USDC").strip().upper()
if {from_ccy, to_ccy} != {"USDT", "USDC"}:
return jsonify({"ok": False, "msg": "仅支持 USDT↔USDC"}), 400
direction = "usdt_to_usdc" if from_ccy == "USDT" else "usdc_to_usdt"
account = str(body.get("account") or "trading")
ex = getattr(app_module, "exchange", None) or getattr(app_module, "exchange_options", None)
if ex is None:
return jsonify({"ok": False, "msg": "无公开行情 exchange"}), 500
try:
result = SimBroker(get_db).convert_usdt_usdc(
ex, direction=direction, amount=amount, account=account
)
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 400
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(),
}
)
+168
View File
@@ -0,0 +1,168 @@
{# 系统设置 · 模拟资金 #}
<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(交易账户 · USDC/USDT 市价)</h3>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
<select id="sim-conv-account"><option value="trading">交易账户</option><option value="funding">资金账户</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 || "trading",
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);
var px = d.fill_px != null ? (" @" + Number(d.fill_px).toFixed(6)) : "";
msg("兑换成功" + px, true);
}).catch(function (e) { msg(String(e), false); });
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", window.simRefreshStatus);
} else {
window.simRefreshStatus();
}
})();
</script>
+397
View File
@@ -0,0 +1,397 @@
"""模拟资金钱包: funding/trading × USDT/USDC/ETH/BTC."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable
WALLET_KEYS = (
"funding_usdt",
"trading_usdt",
"funding_usdc",
"trading_usdc",
"funding_eth",
"trading_eth",
"funding_btc",
"trading_btc",
)
_ACCT_MAP = {
("funding", "usdt"): "funding_usdt",
("trading", "usdt"): "trading_usdt",
("funding", "usdc"): "funding_usdc",
("trading", "usdc"): "trading_usdc",
("funding", "eth"): "funding_eth",
("trading", "eth"): "trading_eth",
("funding", "btc"): "funding_btc",
("trading", "btc"): "trading_btc",
}
def normalize_sim_account(account: str | None) -> str:
"""统一账户别名: swap/unified → trading; spot → funding."""
a = (account or "").strip().lower()
if a in ("trading", "swap", "unified", "18"):
return "trading"
if a in ("funding", "spot", "6"):
return "funding"
return a
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 _row_to_snap(self, row: Any) -> dict[str, float]:
if row is None:
return {k: 0.0 for k in WALLET_KEYS}
keys = set(row.keys()) if hasattr(row, "keys") else set()
out: dict[str, float] = {}
for k in WALLET_KEYS:
if keys and k not in keys:
out[k] = 0.0
else:
try:
out[k] = float(row[k] or 0)
except (KeyError, IndexError, TypeError, ValueError):
out[k] = 0.0
return out
def snapshot(self) -> dict[str, float]:
conn = self.get_db()
try:
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
return self._row_to_snap(row)
finally:
conn.close()
def view(self) -> dict[str, float]:
return self.snapshot()
def total_usdt_equiv(self, snap: dict[str, float] | None = None) -> float:
"""稳定币合计(不含 ETH/BTC 折算)."""
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()
full = {k: float(snap.get(k) or 0) for k in WALLET_KEYS}
conn.execute(
"""
UPDATE sim_wallets SET
funding_usdt=?, trading_usdt=?, funding_usdc=?, trading_usdc=?,
funding_eth=?, trading_eth=?, funding_btc=?, trading_btc=?,
updated_at=?
WHERE id=1
""",
(
full["funding_usdt"],
full["trading_usdt"],
full["funding_usdc"],
full["trading_usdc"],
full["funding_eth"],
full["trading_eth"],
full["funding_btc"],
full["trading_btc"],
now,
),
)
if owns:
conn.commit()
return full
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/ETH/BTC")
conn = self.get_db()
try:
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
snap = self._row_to_snap(row)
bal = float(snap[key])
if amt > bal + 1e-9:
raise InsufficientFunds(f"交易账户 {ccy.upper()} 不足(可用 {bal:.8f})")
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/ETH/BTC")
conn = self.get_db()
try:
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
snap = self._row_to_snap(row)
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 = normalize_sim_account(from_account)
ta = normalize_sim_account(to_account)
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/ETH/BTC"}
conn = self.get_db()
try:
row = conn.execute("SELECT * FROM sim_wallets WHERE id=1").fetchone()
snap = self._row_to_snap(row)
src_bal = float(snap[src_key])
if amt > src_bal + 1e-9:
return {"ok": False, "detail": f"余额不足(可用 {src_bal:.8f})"}
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 = "trading",
to_amount: float | None = None,
rate: float | None = None,
fee: float | None = None,
note: str | None = None,
) -> dict[str, Any]:
"""USDT↔USDC / USDT↔ETH / USDT↔BTC 兑换. to_amount 未给时按 rate(USDT per coin) 换算."""
amt = float(amount)
if amt <= 0:
return {"ok": False, "detail": "数量须大于 0"}
fa = (from_ccy or "").strip().lower()
ta = (to_ccy or "").strip().lower()
acct = normalize_sim_account(account) or "trading"
if acct not in ("funding", "trading"):
return {"ok": False, "detail": "account 须为 funding / trading"}
pair = {fa, ta}
if pair not in ({"usdt", "usdc"}, {"usdt", "eth"}, {"usdt", "btc"}):
return {"ok": False, "detail": "仅支持 USDT↔USDC/ETH/BTC"}
if to_amount is not None:
got = float(to_amount)
elif rate is not None and float(rate) > 0:
r = float(rate)
# rate = USDT per 1 coin(USDC/ETH/BTC)
got = (amt / r) if fa == "usdt" else (amt * r)
else:
got = amt
if got <= 0:
return {"ok": False, "detail": "兑换所得须大于 0"}
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 = self._row_to_snap(row)
src = float(snap[src_key])
if amt > src + 1e-9:
return {"ok": False, "detail": f"{acct} {fa.upper()} 不足(可用 {src:.8f})"}
snap[src_key] = src - amt
snap[dst_key] = float(snap[dst_key]) + got
self._write(snap, conn=conn)
note_s = note or f"to {ta} @{rate if rate is not None else '1:1'}"
self._ledger(
conn,
kind="convert",
amount=-amt,
ccy=fa,
account=acct,
balance_after=snap[src_key],
note=note_s,
)
self._ledger(
conn,
kind="convert",
amount=got,
ccy=ta,
account=acct,
balance_after=snap[dst_key],
note=f"from {fa}",
)
conn.commit()
eff_rate = (amt / got) if fa == "usdt" and got > 0 else ((got / amt) if amt > 0 else None)
return {
"ok": True,
"detail": "converted",
"from_ccy": fa.upper(),
"to_ccy": ta.upper(),
"amount": amt,
"to_amount": got,
"rate": float(rate) if rate is not None else eff_rate,
"fee": float(fee or 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")
snap = {
"funding_usdt": amt,
"trading_usdt": 0.0,
"funding_usdc": 0.0,
"trading_usdc": 0.0,
"funding_eth": 0.0,
"trading_eth": 0.0,
"funding_btc": 0.0,
"trading_btc": 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()