from datetime import date, datetime from sqlalchemy import Date, DateTime, ForeignKey, Index, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base class UniverseMembership(Base): """One observed interval of a symbol's membership in an index universe. Append-only. A symbol that leaves and returns has two rows, so a point-in-time query for the gap between them correctly answers "not a member" — reopening the original row would erase that. **Observed, not asserted.** ``first_seen_on`` is the first date a live universe fetch actually listed the symbol, not the date it joined the index. Anything earlier is unknown, and this table never guesses: the removals list reconstructed by hand in ``scripts/data/sp500_removals.json`` stays a research artifact rather than a seed. Membership is independent of *tracking ownership* (``Ticker.tracking_source``) and of whether the symbol is scanned. A manually added symbol that happens to be an index constituent gets intervals like any other; being untracked or scan-disabled does not close one. """ __tablename__ = "universe_memberships" __table_args__ = ( Index("ix_universe_memberships_ticker_universe", "ticker_id", "universe"), # "Who was a member of U at date D" — the point-in-time query. Index("ix_universe_memberships_universe_seen", "universe", "absent_since"), ) id: Mapped[int] = mapped_column(primary_key=True) ticker_id: Mapped[int] = mapped_column( ForeignKey("tickers.id", ondelete="CASCADE"), nullable=False ) # "sp500" | "nasdaq100" | "nasdaq_all" — a symbol can belong to several. universe: Mapped[str] = mapped_column(String(20), nullable=False) # The first observation that listed this symbol. INCLUSIVE lower bound. Not # the date it joined the index — that is unknown and never guessed. first_seen_on: Mapped[date] = mapped_column(Date, nullable=False) # The first observation that found the symbol ABSENT — hence the name. It is # *not* "last seen present": the symbol was present at every observation # before this one and absent at this one, so it is an EXCLUSIVE upper bound. # NULL means still present at the most recent observation. # # member on D <=> first_seen_on <= D < absent_since (or absent_since IS NULL) # # The real departure lies somewhere in (previous observation, absent_since]; # with a daily sync that is a day, and with a missed week it is a week. The # column records when we LOOKED, which is the only thing actually known. absent_since: Mapped[date | None] = mapped_column(Date, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False ) ticker = relationship("Ticker", back_populates="universe_memberships")