Files
stock/backend/app/models.py
2026-09-09 11:35:02 +08:00

399 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ORM 模型。
Candle 表设计与 TimescaleDB hypertable 完全兼容:将来在目标 PG 库执行
SELECT create_hypertable('candles', 'ts');
即可升级为时序表 + Continuous Aggregates 多周期预聚合,无需改表结构。
智能选股直接读 candles 不复权底座market_daily 已退役);
daily_snapshot 存每日指标快照(估值/市值,选股过滤用)。
"""
from datetime import date, datetime
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
def _utcnow() -> datetime:
# naive UTC统一存储避免时区带来的麻烦
from datetime import timezone
return datetime.now(timezone.utc).replace(tzinfo=None)
class Candle(Base):
__tablename__ = "candles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
symbol: Mapped[str] = mapped_column(String(16), index=True)
timeframe: Mapped[str] = mapped_column(String(4), default="1d", index=True)
ts: Mapped[datetime] = mapped_column(DateTime, index=True) # bar 开始时间
open: Mapped[float] = mapped_column(Float)
high: Mapped[float] = mapped_column(Float)
low: Mapped[float] = mapped_column(Float)
close: Mapped[float] = mapped_column(Float)
volume: Mapped[float] = mapped_column(Float)
amount: Mapped[float | None] = mapped_column(Float) # 成交额TDX 原生 float32
turnover: Mapped[float | None] = mapped_column(Float) # 换手率 %daily_basic2000 年起)
__table_args__ = (
UniqueConstraint("symbol", "timeframe", "ts", name="uq_candle_sym_tf_ts"),
)
class BacktestRun(Base):
"""回测运行注册表(可复现/可审计/可回归对比的基础)。
完整版应记录 策略版本 + 参数快照 + 数据快照(复权/数据源/库版本)+ 环境指纹 + 结果指纹。
MVP 先落关键字段,结构就位。
"""
__tablename__ = "backtest_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
symbol: Mapped[str] = mapped_column(String(16))
strategy: Mapped[str] = mapped_column(String(64))
timeframe: Mapped[str] = mapped_column(String(4), default="1d")
params_json: Mapped[str] = mapped_column(String, default="{}")
initial_cash: Mapped[float] = mapped_column(Float, default=100000.0)
total_return: Mapped[float] = mapped_column(Float, default=0.0)
max_drawdown: Mapped[float] = mapped_column(Float, default=0.0)
sharpe: Mapped[float] = mapped_column(Float, default=0.0)
num_trades: Mapped[int] = mapped_column(Integer, default=0)
class StockBasic(Base):
"""A股股票列表stock_basic 快照;选股展示名称、排除 ST/退市/北交所的依据)。"""
__tablename__ = "stock_basic"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts_code: Mapped[str] = mapped_column(String(12), unique=True, index=True) # 000001.SZ
symbol: Mapped[str] = mapped_column(String(10), index=True) # 000001
name: Mapped[str] = mapped_column(String(32))
area: Mapped[str | None] = mapped_column(String(32))
industry: Mapped[str | None] = mapped_column(String(32))
market: Mapped[str | None] = mapped_column(String(32)) # 主板/创业板/科创板/北交所
exchange: Mapped[str] = mapped_column(String(8)) # SSE/SZSE/BSE
list_status: Mapped[str] = mapped_column(String(2), index=True) # L上市 D退市 P暂停
list_date: Mapped[str] = mapped_column(String(8), default="")
delist_date: Mapped[str | None] = mapped_column(String(8))
class EtfBasic(Base):
"""场内 ETF 列表东财全市场快照ETF 同步任务维护,行情另走 candles 底座)。
ts_code 如 510300.SHsymbol 为 6 位纯数字,与 candles.symbol 同口径。
代码区间与股票不冲突:沪 51/56/58 开头、深 159 开头。
"""
__tablename__ = "etf_basic"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts_code: Mapped[str] = mapped_column(String(12), unique=True, index=True)
symbol: Mapped[str] = mapped_column(String(10), index=True) # 510300
name: Mapped[str] = mapped_column(String(64))
exchange: Mapped[str] = mapped_column(String(8)) # SH/SZ
list_date: Mapped[str | None] = mapped_column(String(8)) # YYYYMMDD首根K线日回填
total_mv: Mapped[float | None] = mapped_column(Float) # 总市值(元,东财快照)
circ_mv: Mapped[float | None] = mapped_column(Float) # 流通市值(元)
turnover_rate: Mapped[float | None] = mapped_column(Float) # 换手率 %
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
class StockCompany(Base):
"""上市公司基本信息tushare stock_company详情页按需单查懒加载 + 30 天新鲜度)。
行存在但 com_name 为 NULL = 负缓存墓碑tushare 无此股,避免无数据代码反复穿透)。
"""
__tablename__ = "stock_company"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts_code: Mapped[str] = mapped_column(String(12), unique=True, index=True) # 000001.SZ
com_name: Mapped[str | None] = mapped_column(String(255)) # 公司全称
com_id: Mapped[str | None] = mapped_column(String(32)) # 统一社会信用代码
chairman: Mapped[str | None] = mapped_column(String(128)) # 法人代表
manager: Mapped[str | None] = mapped_column(String(128)) # 总经理
secretary: Mapped[str | None] = mapped_column(String(128)) # 董秘
reg_capital: Mapped[float | None] = mapped_column(Float) # 注册资本(万元)
setup_date: Mapped[str | None] = mapped_column(String(8)) # 注册日期 YYYYMMDD
province: Mapped[str | None] = mapped_column(String(32))
city: Mapped[str | None] = mapped_column(String(32))
introduction: Mapped[str | None] = mapped_column(Text) # 公司介绍
website: Mapped[str | None] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255))
office: Mapped[str | None] = mapped_column(String(255)) # 办公地址
employees: Mapped[int | None] = mapped_column(Integer) # 员工人数
main_business: Mapped[str | None] = mapped_column(Text) # 主要业务及产品
business_scope: Mapped[str | None] = mapped_column(Text) # 经营范围
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
class StockFinancial(Base):
"""个股财务数据宽表fina_indicator 指标 + 三大报表关键绝对值,按报告期一行)。
详情页懒加载,四源合并 upsert金额单位元Tushare 原始),
比率/同比为百分数。fina_indicator 列无 report_type 概念;
三大报表列只取 report_type=1合并报表
"""
__tablename__ = "stock_financial"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True) # 000001.SZ
end_date: Mapped[str] = mapped_column(String(8)) # 报告期 YYYYMMDD
ann_date: Mapped[str | None] = mapped_column(String(8)) # 公告日 YYYYMMDD
# ---- fina_indicatordoc 79----
eps: Mapped[float | None] = mapped_column(Float) # 基本每股收益(元)
bps: Mapped[float | None] = mapped_column(Float) # 每股净资产(元)
ocfps: Mapped[float | None] = mapped_column(Float) # 每股经营现金流净额(元)
roe: Mapped[float | None] = mapped_column(Float) # 净资产收益率 %
roe_dt: Mapped[float | None] = mapped_column(Float) # 扣非净资产收益率 %
grossprofit_margin: Mapped[float | None] = mapped_column(Float) # 销售毛利率 %
netprofit_margin: Mapped[float | None] = mapped_column(Float) # 销售净利率 %
debt_to_assets: Mapped[float | None] = mapped_column(Float) # 资产负债率 %
or_yoy: Mapped[float | None] = mapped_column(Float) # 营业收入同比 %
netprofit_yoy: Mapped[float | None] = mapped_column(Float) # 归母净利润同比 %
dt_netprofit_yoy: Mapped[float | None] = mapped_column(Float) # 扣非净利润同比 %
profit_dedt: Mapped[float | None] = mapped_column(Float) # 扣非净利润(元)
rd_exp: Mapped[float | None] = mapped_column(Float) # 研发投入(元)
# ---- incomedoc 33report_type=1----
total_revenue: Mapped[float | None] = mapped_column(Float) # 营业总收入(元)
operate_profit: Mapped[float | None] = mapped_column(Float) # 营业利润(元)
n_income_attr_p: Mapped[float | None] = mapped_column(Float) # 归母净利润(元)
# ---- balancesheetreport_type=1----
total_assets: Mapped[float | None] = mapped_column(Float) # 总资产(元)
total_hldr_eqy: Mapped[float | None] = mapped_column(Float) # 归母股东权益(元)
# ---- cashflowreport_type=1----
n_cashflow_act: Mapped[float | None] = mapped_column(Float) # 经营现金流净额(元)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
UniqueConstraint("ts_code", "end_date", name="uq_stock_financial_code_end"),
)
class StockDividend(Base):
"""个股分红送股tushare dividend 全历史;每 code 同步时全量替换,无唯一业务键)。
预案行div_proc != 实施)无 ex_dateK 线标记与统计只用实施行。
"""
__tablename__ = "stock_dividend"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True) # 000001.SZ
end_date: Mapped[str | None] = mapped_column(String(8)) # 分红年度 YYYYMMDD
ann_date: Mapped[str | None] = mapped_column(String(8)) # 预案公告日
div_proc: Mapped[str | None] = mapped_column(String(16)) # 实施进度(预案/实施)
stk_div: Mapped[float | None] = mapped_column(Float) # 每股送转
stk_bo_rate: Mapped[float | None] = mapped_column(Float) # 每股送股比例
stk_co_rate: Mapped[float | None] = mapped_column(Float) # 每股转增比例
cash_div: Mapped[float | None] = mapped_column(Float) # 每股分红(税后,元)
cash_div_tax: Mapped[float | None] = mapped_column(Float) # 每股分红(税前,元)
base_share: Mapped[float | None] = mapped_column(Float) # 基准股本(万股)
record_date: Mapped[str | None] = mapped_column(String(8)) # 股权登记日
ex_date: Mapped[str | None] = mapped_column(String(8)) # 除权除息日K线标记锚点
pay_date: Mapped[str | None] = mapped_column(String(8)) # 派息日
div_listdate: Mapped[str | None] = mapped_column(String(8)) # 红股上市日
imp_ann_date: Mapped[str | None] = mapped_column(String(8)) # 实施公告日
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
class StockSyncState(Base):
"""多行懒加载管道的同步状态(新鲜度门控 + 无数据墓碑)。
多行表无法像 stock_company 那样用「行存在但业务列 NULL」当墓碑
用本表记录每个 (ts_code, kind) 的同步时间与是否有数据。
kind: finance | dividend。
"""
__tablename__ = "stock_sync_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
kind: Mapped[str] = mapped_column(String(16))
last_synced_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
has_data: Mapped[bool] = mapped_column(Boolean, default=False)
__table_args__ = (
UniqueConstraint("ts_code", "kind", name="uq_stock_sync_code_kind"),
)
class StockReference(Base):
"""个股参考数据tushare 参考数据版块按需懒加载,一行 = 一股一分类的 JSON 快照)。
rows_json 存清洗后的 tushare 原始行数组(单位沿用各接口原始口径,见 reference.py 注册表);
行存在但 rows_json 为 NULL = 负缓存墓碑(该股该分类确认无数据)。
kind 白名单见 app/data/reference.py 的 REFERENCE_KINDS。
"""
__tablename__ = "stock_reference"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True) # 000001.SZ
kind: Mapped[str] = mapped_column(String(24)) # top10_holders | ...
rows_json: Mapped[str | None] = mapped_column(Text) # JSON 数组NULL=墓碑
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
UniqueConstraint("ts_code", "kind", name="uq_stock_reference_code_kind"),
)
# market_daily全市场未复权日线已于选股改读 candles 后退役:
# ORM 模型已删,物理表暂留库中作冷备,确认无用后可手动 DROP TABLE market_daily。
class DailySnapshot(Base):
"""每日指标快照daily_basic。total_mv/circ_mv 单位万元Tushare 原始API 层换算亿元。"""
__tablename__ = "daily_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trade_date: Mapped[datetime] = mapped_column(DateTime, index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
close: Mapped[float | None] = mapped_column(Float)
turnover_rate: Mapped[float | None] = mapped_column(Float) # 换手率 %
turnover_rate_f: Mapped[float | None] = mapped_column(Float) # 自由流通换手率 %
volume_ratio: Mapped[float | None] = mapped_column(Float) # 量比
pe: Mapped[float | None] = mapped_column(Float)
pe_ttm: Mapped[float | None] = mapped_column(Float)
pb: Mapped[float | None] = mapped_column(Float)
total_mv: Mapped[float | None] = mapped_column(Float) # 总市值(万元)
circ_mv: Mapped[float | None] = mapped_column(Float) # 流通市值(万元)
__table_args__ = (
UniqueConstraint("ts_code", "trade_date", name="uq_snap_code_date"),
)
class AdjFactor(Base):
"""复权因子adj_factorTushare 原始值qfq/hfq 本地换算的底座)。
与 candles(不复权日线) 按 ts_code+trade_date 关联:
前复权 qfq = 不复权价 × f(t) / f(latest);后复权 hfq = 不复权价 × f(t)。
"""
__tablename__ = "adj_factor"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trade_date: Mapped[datetime] = mapped_column(DateTime, index=True)
ts_code: Mapped[str] = mapped_column(String(12))
adj_factor: Mapped[float] = mapped_column(Float)
__table_args__ = (
UniqueConstraint("ts_code", "trade_date", name="uq_adj_code_date"),
# 覆盖索引:因子查询只取 (trade_date, adj_factor) 两列时走 Index Only Scan
# 免堆访问adj_factor 堆碎片化严重,见 alembic/versions/20260902_01
Index("ix_adj_code_date_cover", "ts_code", "trade_date", postgresql_include=["adj_factor"]),
)
class UserPreference(Base):
"""用户偏好键值对(配色/复权口径/MA 周期/副图布局等value 存 JSON 字符串)。"""
__tablename__ = "user_preferences"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
key: Mapped[str] = mapped_column(String(64))
value_json: Mapped[str] = mapped_column(Text, default="null")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "key", name="uq_user_pref_key"),
)
class WatchlistItem(Base):
"""自选股(星标置顶)。"""
__tablename__ = "watchlist_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint("user_id", "ts_code", name="uq_watch_user_code"),
)
class UserTrade(Base):
"""交割单导入的实盘成交流水K线买卖点的数据源价格为券商成交原始价、不复权"""
__tablename__ = "user_trades"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
code: Mapped[str] = mapped_column(String(10)) # 6 位纯数字
name: Mapped[str | None] = mapped_column(String(32))
trade_date: Mapped[date] = mapped_column(Date, index=True) # 成交日期
direction: Mapped[str] = mapped_column(String(4)) # buy | sell
price: Mapped[float | None] = mapped_column(Float) # 成交价
qty: Mapped[float] = mapped_column(Float) # 股数
amount: Mapped[float | None] = mapped_column(Float) # 成交金额(元)
fee: Mapped[float] = mapped_column(Float, default=0.0) # 手续费合计(元)
raw_json: Mapped[str | None] = mapped_column(Text) # 原始行(审计/排错)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
__table_args__ = (
# 重复上传同一份交割单幂等price 可空导致 PG 对 NULL 不去重,导入时另有 Python 侧兜底)
UniqueConstraint("user_id", "trade_date", "ts_code", "direction", "price", "qty", name="uq_user_trade_dedup"),
)
class ScreenerQuery(Base):
"""自然语言选股提问历史(文本 + 解析出的条件,便于一键重跑)。"""
__tablename__ = "screener_queries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
text: Mapped[str] = mapped_column(String(500))
conditions_json: Mapped[str | None] = mapped_column(Text)
hit_count: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, index=True)
class TradeCalendar(Base):
"""交易日历缓存trade_cal 拉取一次宽范围后本地维护,低积分 token 限频 1 次/小时)。"""
__tablename__ = "trade_calendar"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trade_date: Mapped[str] = mapped_column(String(8), unique=True, index=True) # YYYYMMDD
class User(Base):
"""后台登录用户。系统不提供注册接口,只能通过服务器命令创建或改密。"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
password_algo: Mapped[str] = mapped_column(String(16), default="argon2id")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
password_changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
sessions: Mapped[list["AuthSession"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class AuthSession(Base):
"""服务端会话。数据库只保存随机 Token 的 SHA-256 摘要。"""
__tablename__ = "auth_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
ip_address: Mapped[str | None] = mapped_column(String(45))
user_agent: Mapped[str | None] = mapped_column(String(512))
user: Mapped[User] = relationship(back_populates="sessions")