数据模型设计(上):资产与账户模型定义、SQLAlchemy ORM 基础
做市库存系统,说白了就是管钱管货的系统。钱和货怎么在代码里表示?这就是数据模型要干的事。我个人习惯先把资产(Asset)和账户(Account)这两个最核心的模型敲定,后面的逻辑才能跑得起来。
一、先搞清楚:资产和账户到底是什么关系?
很多新手容易把资产和账户混为一谈。我刚开始做量化系统时也犯过这个错——以为一个账户对应一种资产就完事了。结果后来要支持多币种、多交易所,代码改得想哭。
其实很简单:资产是“什么东西”,账户是“谁在持有”。
- Asset(资产):描述一种可交易的东西。比如BTC、ETH、USDT。它本身不关心谁持有,只关心这个资产叫什么、精度多少、是币还是合约。
- Account(账户):描述一个持有者。比如“币安主账户”、“OKX子账户”。它不关心持有什么,只关心这个账户是谁的、属于哪个交易所。
那怎么把两者关联起来?需要一个中间表——AccountAsset(账户资产)。它记录“某个账户持有多少某种资产”。这才是真正的库存数据。
核心关系总结:
Asset(资产) ←→ AccountAsset(账户资产) ←→ Account(账户)
一个资产可以被多个账户持有,一个账户可以持有多种资产。多对多关系。
嗯,这里要注意:千万别把余额字段直接塞到Account表里。那样做的话,每新增一种资产就要改表结构,运维起来会疯掉。
二、用SQLAlchemy定义Asset模型
我个人偏好用SQLAlchemy的声明式映射(Declarative Base)。代码清晰,也好维护。先看Asset模型:
from sqlalchemy import Column, Integer, String, Numeric, Enum
from sqlalchemy.ext.declarative import declarative_base
import enum
Base = declarative_base()
class AssetType(enum.Enum):
CRYPTO = "crypto" # 加密货币
FIAT = "fiat" # 法币
TOKEN = "token" # 代币
FUTURES = "futures" # 合约
class Asset(Base):
__tablename__ = "assets"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(20), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
precision = Column(Integer, nullable=False, default=8)
asset_type = Column(Enum(AssetType), nullable=False, default=AssetType.CRYPTO)
min_trade_amount = Column(Numeric(20, 8), nullable=False, default=0.00000001)
def __repr__(self):
return f"<Asset(symbol='{self.symbol}', type='{self.asset_type.value}')>"
这里有几个设计细节,我提一下:
- symbol加唯一索引:查询时按symbol查是最高频的操作。不加索引的话,数据量一上来就等着慢查询吧。
- precision用Integer:别用Decimal存精度。精度是个整数,比如BTC是8位,USDT是2位。用Integer存,计算时再配合10的幂次转换。
- asset_type用Enum:硬编码字符串容易出错。用枚举类型,代码可读性高,数据库里也存得规范。
避坑指南:我曾经在一个项目里把precision设计成Numeric类型,结果每次查出来还要转成int,烦不胜烦。后来统一改成Integer,清爽多了。
三、Account模型定义
账户模型相对简单,但有几个字段值得注意:
class Account(Base):
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
exchange = Column(String(50), nullable=False, index=True)
account_type = Column(String(20), nullable=False, default="spot")
status = Column(String(10), nullable=False, default="active")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<Account(name='{self.name}', exchange='{self.exchange}')>"
你想想看,为什么exchange要加索引?因为做市系统里,按交易所查询账户是家常便饭。比如“查一下币安所有账户的BTC余额”,没有索引的话,全表扫描就来了。
另外,account_type我用了String而不是Enum。为什么?因为不同交易所的账户类型五花八门——spot、margin、futures、options……用Enum的话,每新增一种类型就要改代码。用String灵活些,配合校验逻辑就好。
四、关联表:AccountAsset
这才是库存系统的核心表。它把资产和账户关联起来,同时记录余额:
from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
class AccountAsset(Base):
__tablename__ = "account_assets"
id = Column(Integer, primary_key=True, autoincrement=True)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False)
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=False)
available = Column(Numeric(30, 8), nullable=False, default=0)
frozen = Column(Numeric(30, 8), nullable=False, default=0)
total = Column(Numeric(30, 8), nullable=False, default=0)
# 关系映射
account = relationship("Account", backref="assets")
asset = relationship("Asset", backref="accounts")
# 唯一约束:一个账户下不能有重复的资产记录
__table_args__ = (
UniqueConstraint("account_id", "asset_id", name="uq_account_asset"),
)
def __repr__(self):
return f"<AccountAsset(account_id={self.account_id}, asset_id={self.asset_id})>"
重要提醒:available和frozen的精度我用了30位整数、8位小数。为什么这么大?因为做市系统里,有些交易所的余额可能非常大(比如USDT总量几十亿),而且精度要求高。30位整数够用了,别省这点空间。
这里有个关键设计:唯一约束。同一个账户下,每种资产只能有一条记录。如果没有这个约束,程序bug可能导致同一个账户出现两条BTC记录,余额对账时你会怀疑人生。
五、ORM基础操作示例
模型定义好了,怎么用?我简单演示几个高频操作:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("sqlite:///market_making.db")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# 1. 创建资产
btc = Asset(symbol="BTC", name="Bitcoin", precision=8, asset_type=AssetType.CRYPTO)
usdt = Asset(symbol="USDT", name="Tether", precision=2, asset_type=AssetType.CRYPTO)
session.add_all([btc, usdt])
session.commit()
# 2. 创建账户
binance_main = Account(name="Binance Main", exchange="binance", account_type="spot")
session.add(binance_main)
session.commit()
# 3. 记录账户资产
binance_btc = AccountAsset(
account_id=binance_main.id,
asset_id=btc.id,
available=10.5,
frozen=0.5,
total=11.0
)
session.add(binance_btc)
session.commit()
# 4. 查询:币安账户有多少BTC?
result = session.query(AccountAsset).join(Account).join(Asset).filter(
Account.exchange == "binance",
Asset.symbol == "BTC"
).first()
print(f"可用: {result.available}, 冻结: {result.frozen}")
这段代码看着简单,但我在项目里见过太多人踩坑了。比如:
- 忘了
session.commit(),数据没写进去还查不到 - 直接用
total = available + frozen存冗余字段,结果更新时忘了同步 - 查询时没用join,而是先查账户再查资产,N+1问题就来了
个人经验:我建议把total字段保留,但通过数据库触发器或应用层逻辑保证它始终等于available + frozen。这样查询余额时不用每次都计算,性能好很多。
六、模型设计的几个原则
最后,分享几个我做数据模型时一直遵循的原则:
| 原则 | 说明 | 我的踩坑经历 |
|---|---|---|
| 单一职责 | 一个模型只描述一个业务实体 | 曾经把账户和资产混在一个表里,后来拆表花了三天 |
| 适度冗余 | 高频查询的字段可以冗余存储 | total字段就是典型,省去了每次计算的开销 |
| 索引先行 | 按查询模式提前设计索引 | 没加索引的表,数据到10万行就卡得不行 |
| 约束兜底 | 数据库层面的约束不能省 | 唯一约束救过我一次,防止了重复数据写入 |
嗯,数据模型设计这块,说白了就是“想清楚再动手”。Asset和Account这两个模型看似简单,但它们是整个库存系统的地基。地基没打好,后面盖多高的楼都悬。
我个人习惯是先画ER图,再写代码。别急着上手写ORM,把关系理清了,代码自然就顺了。