Files
stock/backend/app/models.py
T
2026-09-29 22:48:41 +08:00

451 lines
24 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_basic,2000 年起)
__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.SH;symbol 为 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_indicator(doc 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) # 研发投入(元)
# ---- income(doc 33,report_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) # 归母净利润(元)
# ---- balancesheet(report_type=1)----
total_assets: Mapped[float | None] = mapped_column(Float) # 总资产(元)
total_hldr_eqy: Mapped[float | None] = mapped_column(Float) # 归母股东权益(元)
# ---- cashflow(report_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_date,K 线标记与统计只用实施行。
"""
__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_factor,Tushare 原始值;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 HoldingItem(Base):
"""持仓股(手动标记进「持仓」分类的股票)。"""
__tablename__ = "holding_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_holding_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 SignalPlan(Base):
"""买卖点实验室方案(一股一方案:手动标注/条件扫描的买卖点集合与指标条件)。
conditions_json 存 IndicatorCondition[].model_dump_json()(结构化条件可直接
推送到 /api/backtest/event 做全市场事件回测);点集在 signal_points 子表。
"""
__tablename__ = "signal_plans"
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)
name: Mapped[str] = mapped_column(String(64))
note: Mapped[str | None] = mapped_column(Text)
conditions_json: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
Index("ix_signal_plans_user_code", "user_id", "ts_code"),
)
class SignalPoint(Base):
"""方案内的买卖点(manual=K线图手动点击标注;scan=条件扫描命中后勾选保留)。"""
__tablename__ = "signal_points"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
plan_id: Mapped[int] = mapped_column(Integer, ForeignKey("signal_plans.id", ondelete="CASCADE"), index=True)
kind: Mapped[str] = mapped_column(String(4)) # buy | sell
trade_date: Mapped[date] = mapped_column(Date)
source: Mapped[str] = mapped_column(String(6)) # manual | scan
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint("plan_id", "kind", "trade_date", name="uq_signal_point_plan_kind_date"),
)
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")