"""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 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")