diff --git a/api/charts/controllers.py b/api/charts/controllers.py index b5c24493c..fc5fed2b9 100644 --- a/api/charts/controllers.py +++ b/api/charts/controllers.py @@ -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, ) @@ -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 @@ -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] diff --git a/api/exchange_apis/coingecko.py b/api/exchange_apis/coingecko.py index 8bb4c18a7..596d5dba7 100644 --- a/api/exchange_apis/coingecko.py +++ b/api/exchange_apis/coingecko.py @@ -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()] @@ -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 = [] @@ -37,7 +37,7 @@ 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: @@ -45,3 +45,10 @@ def get_coins_in_category(self, category_id: str) -> list: 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"] diff --git a/api/tests/test_charts.py b/api/tests/test_charts.py index ed1b5c26b..214101079 100644 --- a/api/tests/test_charts.py +++ b/api/tests/test_charts.py @@ -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 diff --git a/api/tests/test_market_domination_controller.py b/api/tests/test_market_domination_controller.py index 0f498b7e5..3238cfd02 100644 --- a/api/tests/test_market_domination_controller.py +++ b/api/tests/test_market_domination_controller.py @@ -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):