Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions api/alembic/versions/c32fcddff2d7_add_top_gainers_losers_table.py
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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")
15 changes: 15 additions & 0 deletions api/charts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions api/charts/routes.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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}")
Empty file removed api/charts/schemas.py
Empty file.
121 changes: 121 additions & 0 deletions api/databases/crud/top_gainers_losers_series_crud.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions api/databases/tables/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading