Skip to content
Open
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
36 changes: 17 additions & 19 deletions api/charts/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from databases.crud.symbols_crud import SymbolsCrud
from databases.tables.market_breadth_table import MarketBreadthTable
from databases.utils import independent_session
from exchange_apis.coingecko import CoinGecko
from kucoin_universal_sdk.generate.spot.market.model_get_symbol_resp import (
GetSymbolResp,
)
Expand Down Expand Up @@ -38,6 +39,7 @@ def __init__(self, session: Session | None = None) -> None:
secret=self.config.kucoin_secret,
passphrase=self.config.kucoin_passphrase,
)
self.coingecko_api = CoinGecko()

def _normalize_market_breadth_ticker(
self, item: GetSymbolResp, fallback_timestamp: datetime | None = None
Expand Down Expand Up @@ -231,33 +233,29 @@ def _format_ts(ts):

def gainers_losers(self) -> tuple[Iterable[Any], Iterable[Any]]:
"""
Get market top gainers of the day
Get KuCoin Futures USDT perpetual top gainers and losers of the day.

ATTENTION - This is a very heavy weight operation
ticker_24() retrieves all tokens
CoinGecko is used for discovery/ranking because KuCoin Futures all-tickers
does not include per-contract 24h percentage change.
"""
fiat = self.autotrade_db.get_fiat()
ticker_data = self.binance_api.ticker_24()
tickers = [
item
for item in self.coingecko_api.get_kucoin_futures_tickers()
if item["target"] == "USDT"
and item["contract_type"] == "perpetual"
and item["expired_at"] is None
and item["h24_percentage_change"] is not None
]

gainers = sorted(
[
item
for item in ticker_data
if float(item["priceChangePercent"]) > 0
and item["symbol"].endswith(fiat)
],
key=lambda x: float(x["priceChangePercent"]),
[item for item in tickers if item["h24_percentage_change"] > 0],
key=lambda item: item["h24_percentage_change"],
reverse=True,
)

losers = sorted(
[
item
for item in ticker_data
if float(item["priceChangePercent"]) < 0
and item["symbol"].endswith(fiat)
],
key=lambda x: float(x["priceChangePercent"]),
[item for item in tickers if item["h24_percentage_change"] < 0],
key=lambda item: item["h24_percentage_change"],
)

return gainers[:10], losers[:10]
13 changes: 10 additions & 3 deletions api/exchange_apis/coingecko.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def __init__(self):

def get_all_categories(self) -> list:
url = f"{self.base_url}/coins/categories"
r = requests.get(url)
r = requests.get(url, timeout=10)
r.raise_for_status()
return [cat["id"] for cat in r.json()]

Expand All @@ -24,7 +24,7 @@ def get_coins_in_category(self, category_id: str) -> list:
"per_page": str(250),
"page": str(1),
}
r = requests.get(url, params=params)
r = requests.get(url, params=params, timeout=10)
r.raise_for_status()
page = 1
all_coins = []
Expand All @@ -37,11 +37,18 @@ def get_coins_in_category(self, category_id: str) -> list:
"per_page": str(250),
"page": str(page),
}
r = requests.get(url, params=params)
r = requests.get(url, params=params, timeout=10)
r.raise_for_status()
data = r.json()
if not data:
break
all_coins.extend(data)
page += 1
return all_coins

def get_kucoin_futures_tickers(self) -> list[dict]:
url = f"{self.base_url}/derivatives/exchanges/kumex"
params = {"include_tickers": "unexpired"}
r = requests.get(url, params=params, timeout=10)
r.raise_for_status()
return r.json()["tickers"]
60 changes: 53 additions & 7 deletions api/tests/test_charts.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
from fastapi.testclient import TestClient
from pytest import mark

from exchange_apis.coingecko import CoinGecko
from main import app

client = TestClient(app)


@mark.vcr("cassettes/test_top_gainers.yaml")
def test_top_gainers():
def _mock_kucoin_futures_tickers():
return [
{
"symbol": "BTCUSDTM",
"base": "BTC",
"target": "USDT",
"contract_type": "perpetual",
"last": 65000.0,
"h24_percentage_change": 12.0,
"open_interest_usd": 5000000.0,
"h24_volume": 9000000.0,
"funding_rate": 0.02,
"last_traded": 1710000000,
"expired_at": None,
},
{
"symbol": "SOLUSDTM",
"base": "SOL",
"target": "USDT",
"contract_type": "perpetual",
"last": 150.0,
"h24_percentage_change": -5.0,
"open_interest_usd": 700000.0,
"h24_volume": 600000.0,
"funding_rate": -0.01,
"last_traded": 1710000000,
"expired_at": None,
},
]


def test_top_gainers(monkeypatch):
monkeypatch.setattr(
CoinGecko,
"get_kucoin_futures_tickers",
lambda _self: _mock_kucoin_futures_tickers(),
)

response = client.get("/charts/top-gainers")

assert response.status_code == 200
data = response.json()
assert "data" in data
assert data["data"][0]["symbol"] == "BTCUSDTM"
assert data["data"][0]["h24_percentage_change"] == 12.0


def test_top_losers(monkeypatch):
monkeypatch.setattr(
CoinGecko,
"get_kucoin_futures_tickers",
lambda _self: _mock_kucoin_futures_tickers(),
)

@mark.vcr("cassettes/test_top_losers.yaml")
def test_top_losers():
response = client.get("/charts/top-losers")

assert response.status_code == 200
data = response.json()
assert "data" in data
assert data["data"][0]["symbol"] == "SOLUSDTM"
assert data["data"][0]["h24_percentage_change"] == -5.0
92 changes: 92 additions & 0 deletions api/tests/test_market_domination_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,98 @@ def payload_factory():
assert len(rows) == 1


def test_gainers_losers_uses_coingecko_kucoin_futures_usdt_perpetuals():
session = _make_session()
controller = _make_controller(ExchangeId.KUCOIN, session, fiat="USDT")
controller.coingecko_api = SimpleNamespace( # type: ignore[assignment]
get_kucoin_futures_tickers=lambda: [
{
"symbol": "ETHUSDTM",
"base": "ETH",
"target": "USDT",
"contract_type": "perpetual",
"last": 3000.0,
"h24_percentage_change": 8.0,
"open_interest_usd": 1000000.0,
"h24_volume": 2000000.0,
"funding_rate": 0.01,
"last_traded": 1710000000,
"expired_at": None,
},
{
"symbol": "BTCUSDTM",
"base": "BTC",
"target": "USDT",
"contract_type": "perpetual",
"last": 65000.0,
"h24_percentage_change": 12.0,
"open_interest_usd": 5000000.0,
"h24_volume": 9000000.0,
"funding_rate": 0.02,
"last_traded": 1710000000,
"expired_at": None,
},
{
"symbol": "SOLUSDTM",
"base": "SOL",
"target": "USDT",
"contract_type": "perpetual",
"last": 150.0,
"h24_percentage_change": -5.0,
"open_interest_usd": 700000.0,
"h24_volume": 600000.0,
"funding_rate": -0.01,
"last_traded": 1710000000,
"expired_at": None,
},
{
"symbol": "XBTUSDM",
"base": "XBT",
"target": "USD",
"contract_type": "perpetual",
"last": 65000.0,
"h24_percentage_change": 50.0,
"open_interest_usd": 100000.0,
"h24_volume": 100000.0,
"funding_rate": 0.0,
"last_traded": 1710000000,
"expired_at": None,
},
{
"symbol": "ADAUSDT-20240628",
"base": "ADA",
"target": "USDT",
"contract_type": "futures",
"last": 0.5,
"h24_percentage_change": 40.0,
"open_interest_usd": 100000.0,
"h24_volume": 100000.0,
"funding_rate": 0.0,
"last_traded": 1710000000,
"expired_at": None,
},
{
"symbol": "OLDUSDTM",
"base": "OLD",
"target": "USDT",
"contract_type": "perpetual",
"last": 1.0,
"h24_percentage_change": 30.0,
"open_interest_usd": 100000.0,
"h24_volume": 100000.0,
"funding_rate": 0.0,
"last_traded": 1710000000,
"expired_at": 1710000000,
},
]
)

gainers, losers = controller.gainers_losers()

assert [item["symbol"] for item in gainers] == ["BTCUSDTM", "ETHUSDTM"]
assert [item["symbol"] for item in losers] == ["SOLUSDTM"]


def _seed_rows(session: Session, source: str, count: int):
base = datetime(2026, 4, 1, 0, 0, 0, tzinfo=timezone.utc)
for i in range(count):
Expand Down