数据模型设计(上):资产与账户模型定义、SQLAlchemy ORM 基础

做市库存系统,说白了就是管钱管货的系统。钱和货怎么在代码里表示?这就是数据模型要干的事。我个人习惯先把资产(Asset)和账户(Account)这两个最核心的模型敲定,后面的逻辑才能跑得起来。

一、先搞清楚:资产和账户到底是什么关系?

很多新手容易把资产和账户混为一谈。我刚开始做量化系统时也犯过这个错——以为一个账户对应一种资产就完事了。结果后来要支持多币种、多交易所,代码改得想哭。

其实很简单:资产是“什么东西”,账户是“谁在持有”

  • Asset(资产):描述一种可交易的东西。比如BTC、ETH、USDT。它本身不关心谁持有,只关心这个资产叫什么、精度多少、是币还是合约。
  • Account(账户):描述一个持有者。比如“币安主账户”、“OKX子账户”。它不关心持有什么,只关心这个账户是谁的、属于哪个交易所。

那怎么把两者关联起来?需要一个中间表——AccountAsset(账户资产)。它记录“某个账户持有多少某种资产”。这才是真正的库存数据。

核心关系总结:

Asset(资产) ←→ AccountAsset(账户资产) ←→ Account(账户)

一个资产可以被多个账户持有,一个账户可以持有多种资产。多对多关系。

嗯,这里要注意:千万别把余额字段直接塞到Account表里。那样做的话,每新增一种资产就要改表结构,运维起来会疯掉。

Asset(资产) id, symbol, name precision, asset_type Account(账户) id, name, exchange account_type, status AccountAsset(账户资产) id, account_id, asset_id available, frozen, total 1 1 N N

二、用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})>"

重要提醒availablefrozen的精度我用了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,把关系理清了,代码自然就顺了。


专注资料整理