diff --git a/api/alembic/versions/c32fcddff2d7_add_top_gainers_losers_table.py b/api/alembic/versions/c32fcddff2d7_add_top_gainers_losers_table.py new file mode 100644 index 000000000..2c1927fe9 --- /dev/null +++ b/api/alembic/versions/c32fcddff2d7_add_top_gainers_losers_table.py @@ -0,0 +1,60 @@ +"""add top gainers losers table + +Revision ID: c32fcddff2d7 +Revises: c5d6e7f8a9b0 +Create Date: 2026-08-17 00:58:23.280464 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c32fcddff2d7" +down_revision: Union[str, Sequence[str], None] = "c5d6e7f8a9b0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "top_gainers_losers", + sa.Column( + "id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False + ), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("side", sa.String(length=8), nullable=False), + sa.Column("rank", sa.Integer(), nullable=False), + sa.Column("symbol", sa.String(length=64), nullable=False), + sa.Column("price_change_percent", sa.Float(), nullable=False), + sa.UniqueConstraint( + "recorded_at", + "side", + "rank", + name="uq_top_gainers_losers_recorded_at_side_rank", + ), + ) + op.create_index( + "ix_top_gainers_losers_recorded_at", + "top_gainers_losers", + ["recorded_at"], + unique=False, + ) + op.create_index( + "ix_top_gainers_losers_side", "top_gainers_losers", ["side"], unique=False + ) + op.create_index( + "ix_top_gainers_losers_symbol", "top_gainers_losers", ["symbol"], unique=False + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_top_gainers_losers_symbol", table_name="top_gainers_losers") + op.drop_index("ix_top_gainers_losers_side", table_name="top_gainers_losers") + op.drop_index("ix_top_gainers_losers_recorded_at", table_name="top_gainers_losers") + op.drop_table("top_gainers_losers") diff --git a/api/alembic/versions/d4e5f6a7b8c9_rename_top_gainers_losers_to_series.py b/api/alembic/versions/d4e5f6a7b8c9_rename_top_gainers_losers_to_series.py new file mode 100644 index 000000000..7c43ea314 --- /dev/null +++ b/api/alembic/versions/d4e5f6a7b8c9_rename_top_gainers_losers_to_series.py @@ -0,0 +1,78 @@ +"""rename top_gainers_losers table to top_gainers_losers_series + +Revision ID: d4e5f6a7b8c9 +Revises: c32fcddff2d7 +Create Date: 2026-08-17 01:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "d4e5f6a7b8c9" +down_revision: Union[str, Sequence[str], None] = "c32fcddff2d7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.rename_table("top_gainers_losers", "top_gainers_losers_series") + op.execute( + "ALTER TABLE top_gainers_losers_series " + "RENAME CONSTRAINT top_gainers_losers_pkey TO top_gainers_losers_series_pkey" + ) + op.execute( + "ALTER SEQUENCE top_gainers_losers_id_seq " + "RENAME TO top_gainers_losers_series_id_seq" + ) + op.execute( + "ALTER INDEX ix_top_gainers_losers_recorded_at " + "RENAME TO ix_top_gainers_losers_series_recorded_at" + ) + op.execute( + "ALTER INDEX ix_top_gainers_losers_side " + "RENAME TO ix_top_gainers_losers_series_side" + ) + op.execute( + "ALTER INDEX ix_top_gainers_losers_symbol " + "RENAME TO ix_top_gainers_losers_series_symbol" + ) + op.execute( + "ALTER TABLE top_gainers_losers_series " + "RENAME CONSTRAINT uq_top_gainers_losers_recorded_at_side_rank " + "TO uq_top_gainers_losers_series_recorded_at_side_rank" + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.execute( + "ALTER TABLE top_gainers_losers_series " + "RENAME CONSTRAINT uq_top_gainers_losers_series_recorded_at_side_rank " + "TO uq_top_gainers_losers_recorded_at_side_rank" + ) + op.execute( + "ALTER INDEX ix_top_gainers_losers_series_symbol " + "RENAME TO ix_top_gainers_losers_symbol" + ) + op.execute( + "ALTER INDEX ix_top_gainers_losers_series_side " + "RENAME TO ix_top_gainers_losers_side" + ) + op.execute( + "ALTER INDEX ix_top_gainers_losers_series_recorded_at " + "RENAME TO ix_top_gainers_losers_recorded_at" + ) + op.execute( + "ALTER SEQUENCE top_gainers_losers_series_id_seq " + "RENAME TO top_gainers_losers_id_seq" + ) + op.execute( + "ALTER TABLE top_gainers_losers_series " + "RENAME CONSTRAINT top_gainers_losers_series_pkey TO top_gainers_losers_pkey" + ) + op.rename_table("top_gainers_losers_series", "top_gainers_losers") diff --git a/api/charts/models.py b/api/charts/models.py index 58c5be60e..a8faaee48 100644 --- a/api/charts/models.py +++ b/api/charts/models.py @@ -92,6 +92,21 @@ class CandlestickResponse(StandardResponse): data: list[CandlestickData] +class TopMoverEntry(BaseModel): + symbol: str + price_change_percent: float + + +class GainersLosersSnapshot(BaseModel): + recorded_at: datetime.datetime + top_gainers: list[TopMoverEntry] + top_losers: list[TopMoverEntry] + + +class GainersLosersSeriesResponse(StandardResponse): + data: list[GainersLosersSnapshot] + + class MarketBreadthSample(BaseModel): """ Ingest payload for one market-breadth sample before it is mapped to the diff --git a/api/charts/routes.py b/api/charts/routes.py index 199be2cdc..c0b957739 100644 --- a/api/charts/routes.py +++ b/api/charts/routes.py @@ -1,11 +1,15 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session +from api.databases.crud.top_gainers_losers_series_crud import ( + TopGainersLosersSeriesCrud, +) from api.databases.utils import get_session from api.tools.handle_error import ( json_response, json_response_error, ) from api.charts.controllers import MarketDominationController +from api.charts.models import GainersLosersSeriesResponse from pybinbot import MarketBreadthSeriesResponse charts_blueprint = APIRouter() @@ -35,3 +39,28 @@ def get_market_breadth(size: int = 14, session: Session = Depends(get_session)): except Exception as error: return json_response_error(f"Failed to retrieve market breadth data: {error}") + + +@charts_blueprint.get( + "/gainers-losers-series", + tags=["charts"], + summary="Daily top-10 gainers/losers snapshots (no auth, freely accessible)", + response_model=GainersLosersSeriesResponse, +) +def get_gainers_losers_series(limit: int = 7, session: Session = Depends(get_session)): + try: + data = TopGainersLosersSeriesCrud(session=session).query_series(limit=limit) + if not data: + raise HTTPException(404, detail="No gainers/losers data found") + + return json_response( + { + "data": data, + "message": "Successfully retrieved gainers/losers series.", + "error": 0, + } + ) + except HTTPException: + raise + except Exception as error: + return json_response_error(f"Failed to retrieve gainers/losers series: {error}") diff --git a/api/charts/schemas.py b/api/charts/schemas.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/api/databases/crud/top_gainers_losers_series_crud.py b/api/databases/crud/top_gainers_losers_series_crud.py new file mode 100644 index 000000000..f0bbc01ee --- /dev/null +++ b/api/databases/crud/top_gainers_losers_series_crud.py @@ -0,0 +1,121 @@ +from datetime import datetime, timezone +from typing import Any + +from pybinbot import BinanceApi +from sqlmodel import Session, col, select + +from api.databases.crud.autotrade_crud import AutotradeCrud +from api.databases.tables.top_gainers_losers_series_table import ( + TopGainersLosersSeriesTable, +) +from api.databases.utils import get_db_session +from api.tools.config import Config + + +class TopGainersLosersSeriesCrud: + """ + CRUD operations for `top_gainers_losers_series` — daily snapshots of the + biggest 24h Binance spot movers, kept to spot patterns for new strategies. + """ + + def __init__(self, session: Session | None = None): + self._external_session = session + + def ingest(self, top: int = 10) -> list[TopGainersLosersSeriesTable]: + """ + Pull the current 24h ticker ranking from Binance and persist the + top N gainers and top N losers as one row each. Called once a day + by the cron. Exchange clients are built here rather than in + __init__ so the read path (query_series, used by the public, + unauthenticated GET endpoint) never needs Binance credentials. + """ + config = Config() + binance_api = BinanceApi(key=config.binance_key, secret=config.binance_secret) + fiat = AutotradeCrud(session=self._external_session).get_fiat() + + ticker_data = binance_api.ticker_24() + ranked = sorted( + (item for item in ticker_data if item["symbol"].endswith(fiat)), + key=lambda item: float(item["priceChangePercent"]), + reverse=True, + ) + recorded_at = datetime.now(timezone.utc) + + rows = [ + TopGainersLosersSeriesTable( + recorded_at=recorded_at, + side="gainer", + rank=rank, + symbol=item["symbol"], + price_change_percent=float(item["priceChangePercent"]), + ) + for rank, item in enumerate(ranked[:top], start=1) + ] + rows += [ + TopGainersLosersSeriesTable( + recorded_at=recorded_at, + side="loser", + rank=rank, + symbol=item["symbol"], + price_change_percent=float(item["priceChangePercent"]), + ) + for rank, item in enumerate(reversed(ranked[-top:]), start=1) + ] + + with get_db_session(self._external_session) as session: + session.add_all(rows) + session.flush() + created = [TopGainersLosersSeriesTable(**row.model_dump()) for row in rows] + if self._external_session is not None: + session.commit() + return created + + def query_series(self, limit: int = 7) -> list[dict[str, Any]]: + """ + Return up to `limit` most recent daily snapshots, newest first, each + with its top gainers and top losers ordered by rank. + """ + with get_db_session(self._external_session) as session: + distinct_timestamps = session.exec( + select(TopGainersLosersSeriesTable.recorded_at) + .distinct() + .order_by(col(TopGainersLosersSeriesTable.recorded_at).desc()) + .limit(limit) + ).all() + if not distinct_timestamps: + return [] + + rows = session.exec( + select(TopGainersLosersSeriesTable) + .where( + col(TopGainersLosersSeriesTable.recorded_at).in_( + distinct_timestamps + ) + ) + .order_by( + col(TopGainersLosersSeriesTable.recorded_at).desc(), + col(TopGainersLosersSeriesTable.side), + col(TopGainersLosersSeriesTable.rank), + ) + ).all() + + snapshots: dict[datetime, dict[str, Any]] = {} + for row in rows: + snapshot = snapshots.setdefault( + row.recorded_at, + { + "recorded_at": row.recorded_at, + "top_gainers": [], + "top_losers": [], + }, + ) + entry = { + "symbol": row.symbol, + "price_change_percent": row.price_change_percent, + } + if row.side == "gainer": + snapshot["top_gainers"].append(entry) + else: + snapshot["top_losers"].append(entry) + + return sorted(snapshots.values(), key=lambda s: s["recorded_at"], reverse=True) diff --git a/api/databases/tables/__init__.py b/api/databases/tables/__init__.py index 8d329ce45..782408fe7 100644 --- a/api/databases/tables/__init__.py +++ b/api/databases/tables/__init__.py @@ -11,4 +11,5 @@ from .market_breadth_table import * # noqa from .signals_table import * # noqa from .grid_ladder_table import * # noqa +from .top_gainers_losers_series_table import * # noqa from api.databases.web3_candidates import * # noqa diff --git a/api/databases/tables/checkout.py b/api/databases/tables/checkout.py deleted file mode 100644 index 8bc855af5..000000000 --- a/api/databases/tables/checkout.py +++ /dev/null @@ -1,107 +0,0 @@ -# mypy: ignore-errors -import os -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, relationship -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Float, DateTime -from sqlalchemy.sql import func - -engine = create_engine( - f"postgresql://{os.environ['POSTGRES_USER']}:{os.environ['POSTGRES_PASSWORD']}@{os.environ['POSTGRES_HOSTNAME']}/{os.environ['POSTGRES_DB']}" -) -session = sessionmaker(bind=engine, autocommit=False, autoflush=False) -Base = declarative_base() - - -class BaseModel(Base): - """ - Base class to handle database model defaults - this avoids repetition of common fields - """ - - __abstract__ = True - - id = Column( - Integer, - primary_key=True, - nullable=False, - comment="Primary key for the resource", - ) - created_at = Column( - DateTime, - server_default=func.now(), - comment="Records the time the record was created", - ) - updated_at = Column( - DateTime, - server_default=func.now(), - onupdate=func.now(), - comment="Records the time the record was last updated", - ) - - -class User(BaseModel): - __tablename__ = "users" - - email = Column( - String, - unique=True, - index=True, - nullable=False, - comment="Email address of the user, used for login", - ) - hashed_password = Column(String) - is_active = Column(Boolean, default=True) - first_name = Column(String) - last_name = Column(String) - phone_number = Column(Integer) - address = Column(String) - - -class Pricing(BaseModel): - __tablename__ = "pricing" - - name = Column(String) - description = Column(String) - default_price = Column(Float) - currency = Column(String) - product_type = Column(String) - discount = Column(Float) - - -class Orders(BaseModel): - __tablename__ = "orders" - - status = Column(String) - posted_at = Column(Float) - transacted_at = Column(Float) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - users = relationship("Users", back_populates="orders", lazy="joined") - - -class Billing(BaseModel): - __tablename__ = "billing" - - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - amount = Column(Float) - currency = Column(String) - payment_method = Column(String) - address = Column(String) - city = Column(String) - country = Column(String) - postal_code = Column(Integer) - vat_number = Column(String) - - users = relationship("Users", back_populates="billing", lazy="joined") - orders = relationship("Orders", back_populates="billing", lazy="joined") - - -class CheckoutDetails(BaseModel): - __tablename__ = "checkout_details" - - price = Column(Integer) - transacted_at = Column(Float) - order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) - pricing_id = Column(Integer, ForeignKey("product.id"), nullable=False) - billing_id = Column(Integer, ForeignKey("billing.id"), nullable=False) - orders = relationship("Orders", back_populates="order_details", lazy="joined") diff --git a/api/databases/tables/top_gainers_losers_series_table.py b/api/databases/tables/top_gainers_losers_series_table.py new file mode 100644 index 000000000..54acfedd1 --- /dev/null +++ b/api/databases/tables/top_gainers_losers_series_table.py @@ -0,0 +1,32 @@ +from datetime import datetime +from sqlalchemy import Column, DateTime, UniqueConstraint +from sqlmodel import SQLModel, Field + + +class TopGainersLosersSeriesTable(SQLModel, table=True): + """ + One row per (recorded_at, side, rank) — a symbol's 24h price change + percent captured in a daily top-10 gainers/losers snapshot, so patterns + across days can be queried directly in SQL for future strategy analysis. + """ + + __tablename__ = "top_gainers_losers_series" + __table_args__ = ( + UniqueConstraint( + "recorded_at", + "side", + "rank", + name="uq_top_gainers_losers_series_recorded_at_side_rank", + ), + ) + + id: int | None = Field(default=None, primary_key=True) + recorded_at: datetime = Field( + sa_column=Column(DateTime(timezone=True), nullable=False, index=True) + ) + side: str = Field( + nullable=False, max_length=8, index=True, description="gainer|loser" + ) + rank: int = Field(nullable=False, description="1 = biggest gain/loss") + symbol: str = Field(nullable=False, max_length=64, index=True) + price_change_percent: float = Field(nullable=False) diff --git a/api/tests/test_symbols_isolated/conftest.py b/api/tests/test_symbols_isolated/conftest.py index 05d7b24e7..ac8af0e44 100644 --- a/api/tests/test_symbols_isolated/conftest.py +++ b/api/tests/test_symbols_isolated/conftest.py @@ -36,7 +36,12 @@ def create_symbol_test_tables(): _test_engine_symbols = test_engine SQLModel.metadata.create_all(test_engine) - # Override get_session to use this isolated test database + # Override get_session to use this isolated test database, keeping the + # parent conftest's override so it can be restored afterwards instead of + # leaving app.dependency_overrides empty for every test file that runs + # after this one. + original_get_session_override = app.dependency_overrides.get(get_session) + @contextmanager def get_test_session_manager(): with Session(test_engine) as session: @@ -94,5 +99,8 @@ def mock_independent_session(): # Clean up patcher1.stop() - app.dependency_overrides.clear() + if original_get_session_override is not None: + app.dependency_overrides[get_session] = original_get_session_override + else: + app.dependency_overrides.pop(get_session, None) SQLModel.metadata.drop_all(test_engine) diff --git a/api/tests/test_top_gainers_losers_series_crud.py b/api/tests/test_top_gainers_losers_series_crud.py new file mode 100644 index 000000000..6acbc9655 --- /dev/null +++ b/api/tests/test_top_gainers_losers_series_crud.py @@ -0,0 +1,182 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import pytest +from sqlmodel import Session, delete, select + +from api.databases.crud.top_gainers_losers_series_crud import TopGainersLosersSeriesCrud +from api.databases.tables.top_gainers_losers_series_table import ( + TopGainersLosersSeriesTable, +) + +_engine = None + + +def _make_session() -> Session: + assert _engine is not None + return Session(_engine, expire_on_commit=False) + + +def _ticker(symbol: str, price_change_percent: float) -> dict: + return { + "symbol": symbol, + "priceChangePercent": str(price_change_percent), + } + + +def _ingest_with_fake_ticker( + session: Session, ticker_rows: list[dict], top: int = 10 +) -> list[TopGainersLosersSeriesTable]: + with patch( + "api.databases.crud.top_gainers_losers_series_crud.BinanceApi" + ) as binance_api_cls: + binance_api_cls.return_value = MagicMock(ticker_24=lambda: ticker_rows) + return TopGainersLosersSeriesCrud(session=session).ingest(top=top) + + +@pytest.fixture(autouse=True) +def _clean_top_gainers_losers_series(test_engine): + global _engine + _engine = test_engine + with _make_session() as session: + session.execute(delete(TopGainersLosersSeriesTable)) + session.commit() + yield + with _make_session() as session: + session.execute(delete(TopGainersLosersSeriesTable)) + session.commit() + + +def test_ingest_ranks_gainers_and_losers_by_percent_change(): + session = _make_session() + ticker_rows = [ + _ticker("AUSDC", 25.0), + _ticker("BUSDC", -12.0), + _ticker("CUSDC", 5.0), + _ticker("DUSDC", -3.0), + _ticker("EUSDC", 1.0), + _ticker("FBTC", 99.0), # different quote asset, must be excluded + ] + + rows = _ingest_with_fake_ticker(session, ticker_rows, top=2) + + assert len(rows) == 4 + gainers = sorted((r for r in rows if r.side == "gainer"), key=lambda r: r.rank) + losers = sorted((r for r in rows if r.side == "loser"), key=lambda r: r.rank) + + assert [(g.rank, g.symbol, g.price_change_percent) for g in gainers] == [ + (1, "AUSDC", 25.0), + (2, "CUSDC", 5.0), + ] + assert [ + (loser.rank, loser.symbol, loser.price_change_percent) for loser in losers + ] == [ + (1, "BUSDC", -12.0), + (2, "DUSDC", -3.0), + ] + + +def test_ingest_persists_rows(): + session = _make_session() + ticker_rows = [_ticker("AUSDC", 10.0), _ticker("BUSDC", -10.0)] + + _ingest_with_fake_ticker(session, ticker_rows, top=1) + + persisted = session.exec(select(TopGainersLosersSeriesTable)).all() + assert len(persisted) == 2 + assert {p.symbol for p in persisted} == {"AUSDC", "BUSDC"} + + +def test_query_series_groups_by_snapshot_newest_first(): + session = _make_session() + older = datetime(2026, 8, 10, 9, 0, tzinfo=timezone.utc) + newer = older + timedelta(days=1) + + session.add_all( + [ + TopGainersLosersSeriesTable( + recorded_at=older, + side="gainer", + rank=1, + symbol="AUSDC", + price_change_percent=10.0, + ), + TopGainersLosersSeriesTable( + recorded_at=older, + side="loser", + rank=1, + symbol="BUSDC", + price_change_percent=-10.0, + ), + TopGainersLosersSeriesTable( + recorded_at=newer, + side="gainer", + rank=1, + symbol="CUSDC", + price_change_percent=20.0, + ), + TopGainersLosersSeriesTable( + recorded_at=newer, + side="loser", + rank=1, + symbol="DUSDC", + price_change_percent=-20.0, + ), + ] + ) + session.commit() + + result = TopGainersLosersSeriesCrud(session=session).query_series(limit=7) + + assert len(result) == 2 + # SQLite (test DB) drops tzinfo on round-trip, unlike Postgres. + assert result[0]["recorded_at"] == newer.replace(tzinfo=None) + assert result[0]["top_gainers"] == [ + {"symbol": "CUSDC", "price_change_percent": 20.0} + ] + assert result[0]["top_losers"] == [ + {"symbol": "DUSDC", "price_change_percent": -20.0} + ] + assert result[1]["recorded_at"] == older.replace(tzinfo=None) + + +def test_query_series_returns_empty_list_when_no_data(): + session = _make_session() + assert TopGainersLosersSeriesCrud(session=session).query_series() == [] + + +def test_get_gainers_losers_series_endpoint(client): + session = _make_session() + recorded_at = datetime(2026, 8, 10, 9, 0, tzinfo=timezone.utc) + session.add_all( + [ + TopGainersLosersSeriesTable( + recorded_at=recorded_at, + side="gainer", + rank=1, + symbol="AUSDC", + price_change_percent=10.0, + ), + TopGainersLosersSeriesTable( + recorded_at=recorded_at, + side="loser", + rank=1, + symbol="BUSDC", + price_change_percent=-10.0, + ), + ] + ) + session.commit() + + response = client.get("/charts/gainers-losers-series") + + assert response.status_code == 200, response.text + body = response.json()["data"] + assert len(body) == 1 + assert body[0]["top_gainers"] == [{"symbol": "AUSDC", "price_change_percent": 10.0}] + assert body[0]["top_losers"] == [{"symbol": "BUSDC", "price_change_percent": -10.0}] + + +def test_get_gainers_losers_series_endpoint_404_when_empty(client): + response = client.get("/charts/gainers-losers-series") + assert response.status_code == 404 diff --git a/cronjobs/main.py b/cronjobs/main.py index acdf9ffa2..f8349503a 100644 --- a/cronjobs/main.py +++ b/cronjobs/main.py @@ -2,6 +2,9 @@ from api.account.controller import ConsolidatedAccounts from api.exchange_apis.binance.assets import Assets from api.databases.crud.signals_crud import SignalsCrud +from api.databases.crud.top_gainers_losers_series_crud import ( + TopGainersLosersSeriesCrud, +) from api.databases.symbols_etl import SymbolDataEtl from api.charts.controllers import MarketDominationController from api.databases.utils import independent_session @@ -19,6 +22,9 @@ def main(): market_domination = MarketDominationController() symbols_crud = SymbolDataEtl() signals_crud = SignalsCrud() + top_gainers_losers_series_crud = TopGainersLosersSeriesCrud( + session=independent_session() + ) web3_candidates_ingester = IngestWeb3Candidates(session=independent_session()) autotrade_settings = assets.autotrade_settings @@ -85,6 +91,14 @@ def main(): minutes=15, id="ingest_market_breadth", ) + scheduler.add_job( + func=top_gainers_losers_series_crud.ingest, + trigger="cron", + timezone="UTC", + hour=9, + minute=0, + id="ingest_top_gainers_losers", + ) scheduler.start()