Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#46 Add HTX exchange #47

Merged
merged 1 commit into from
Jan 31, 2024
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
1 change: 1 addition & 0 deletions .envs/.local/.producer
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ OKX_COINS=ETH-USDT ETH-BTC BTC-USDT LINK-USDT FTM-USDT
BYBIT_COINS=BTCUSDT ETHUSDT LINKUSDT
BITSTAMP_COINS=BTCUSD ETHUSD LINKUSD
MEXC_COINS=BTCUSDT ETHUSDT LINKUSDT
HTX_COINS=BTCUSDT ETHUSDT LINKUSDT
1 change: 1 addition & 0 deletions .envs/.local/.web
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ OKX=ETH,BTC,LINK,FTM
BYBIT=ETH,BTC,LINK
BITSTAMP=BTC,ETH,LINK
MEXC=BTC,ETH,LINK
HTX=BTC,ETH,LINK
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ reset-data:

redis-cli:
@docker exec -it exchange-radar-redis redis-cli

create-env-file:
@cat .envs/.local/.* > .env
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Exchange Radar currently supports the following top exchanges by reputation and
- Bybit
- Bitstamp
- MEXC
- HTX

### Build & Run
Get started effortlessly:
Expand Down
53 changes: 53 additions & 0 deletions compose/producer/htx/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
ARG PYTHON_VERSION=3.11.3-slim-bullseye

FROM python:${PYTHON_VERSION} as python

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_HOME="/opt/poetry" \
POETRY_VIRTUALENVS_IN_PROJECT=true \
POETRY_NO_INTERACTION=1 \
PYSETUP_PATH="/app" \
VENV_PATH="/app/.venv"

ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"


FROM python as python-build-stage

RUN apt-get update && apt-get install --no-install-recommends -y \
curl \
build-essential

ENV POETRY_VERSION=1.7.1
RUN curl -sSL https://install.python-poetry.org | python

WORKDIR $PYSETUP_PATH

COPY ./poetry.lock ./pyproject.toml ./
RUN poetry install --only main --no-root


FROM python as python-run-stage

COPY --from=python-build-stage $POETRY_HOME $POETRY_HOME
COPY --from=python-build-stage $PYSETUP_PATH $PYSETUP_PATH

COPY ./compose/producer/entrypoint /entrypoint
RUN sed -i 's/\r$//g' /entrypoint
RUN chmod +x /entrypoint

COPY ./compose/producer/htx/start /start
RUN sed -i 's/\r$//g' /start
RUN chmod +x /start

WORKDIR $PYSETUP_PATH

COPY . .

RUN poetry install

ENTRYPOINT ["/entrypoint"]
7 changes: 7 additions & 0 deletions compose/producer/htx/start
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash

set -o errexit
set -o pipefail
set -o nounset

exec poetry run producer ${HTX_COINS} --exchange htx
10 changes: 5 additions & 5 deletions exchange_radar/producer/serializers/bitstamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ class BitstampTradeSerializer(BaseSerializer):
def symbol_normalization(cls, v) -> str:
return "".join(v.split("_")[-1:]).upper()

@computed_field
def exchange(self) -> str:
return "Bitstamp"

@computed_field
@cached_property
def is_seller(self) -> bool:
return True if self.type == 1 else False
return self.type == 1

@computed_field
def exchange(self) -> str:
return "Bitstamp"
10 changes: 5 additions & 5 deletions exchange_radar/producer/serializers/bybit.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ class BybitTradeSerializer(BaseSerializer):
trade_time: datetime = Field(alias="T")
side: str = Field(alias="S", exclude=True)

@computed_field
def exchange(self) -> str:
return "Bybit"

@computed_field
@cached_property
def is_seller(self) -> bool:
return True if self.side == "Sell" else False
return self.side == "Sell"

@computed_field
def exchange(self) -> str:
return "Bybit"
2 changes: 1 addition & 1 deletion exchange_radar/producer/serializers/coinbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def symbol_normalization(cls, v) -> str:
@computed_field
@cached_property
def is_seller(self) -> bool:
return True if self.side == "sell" else False
return self.side == "sell"

@computed_field
def exchange(self) -> str:
Expand Down
33 changes: 33 additions & 0 deletions exchange_radar/producer/serializers/htx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from datetime import datetime
from functools import cached_property

from pydantic import Field, computed_field, condecimal, field_validator

from exchange_radar.producer.serializers.base import BaseSerializer


class HtxTradeSerializer(BaseSerializer):
symbol: str = Field(alias="channel")
price: condecimal(ge=0, decimal_places=8)
quantity: condecimal(ge=0, decimal_places=8) = Field(alias="amount")
trade_time: datetime = Field(alias="ts")
direction: str = Field(exclude=True)

def __init__(self, *args, **kwargs):
kwargs["price"] = str(kwargs["price"])
kwargs["amount"] = str(kwargs["amount"])
super().__init__(*args, **kwargs)

@field_validator("symbol") # noqa
@classmethod
def symbol_normalization(cls, v) -> str:
return "".join(v.split(".")[1]).upper()

@computed_field
@cached_property
def is_seller(self) -> bool:
return self.direction == "sell"

@computed_field
def exchange(self) -> str:
return "HTX"
2 changes: 1 addition & 1 deletion exchange_radar/producer/serializers/kraken.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def trade_time_before(cls, v) -> int:
@computed_field
@cached_property
def is_seller(self) -> bool:
return True if self.side == "s" else False
return self.side == "s"

@computed_field
def exchange(self) -> str:
Expand Down
2 changes: 1 addition & 1 deletion exchange_radar/producer/serializers/kucoin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def trade_time_before(cls, v) -> int:
@computed_field
@cached_property
def is_seller(self) -> bool:
return True if self.side == "sell" else False
return self.side == "sell"

@computed_field
def exchange(self) -> str:
Expand Down
10 changes: 5 additions & 5 deletions exchange_radar/producer/serializers/mexc.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ class MexcTradeSerializer(BaseSerializer):
trade_time: datetime = Field(alias="t")
side: int = Field(alias="S", exclude=True)

@computed_field
def exchange(self) -> str:
return "MEXC"

@computed_field
@cached_property
def is_seller(self) -> bool:
return True if self.side == 2 else False
return self.side == 2

@computed_field
def exchange(self) -> str:
return "MEXC"
10 changes: 5 additions & 5 deletions exchange_radar/producer/serializers/okx.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ class OkxTradeSerializer(BaseSerializer):
def symbol_normalization(cls, v) -> str:
return v.replace("-", "")

@computed_field
def exchange(self) -> str:
return "OKX"

@computed_field
@cached_property
def is_seller(self) -> bool:
return True if self.side == "sell" else False
return self.side == "sell"

@computed_field
def exchange(self) -> str:
return "OKX"
2 changes: 2 additions & 0 deletions exchange_radar/producer/settings/exchanges.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"okx": "OkxTradesTask",
"bitstamp": "BitstampTradesTask",
"mexc": "MexcTradesTask",
"htx": "HtxTradesTask",
}

EXCHANGES_LIST = list(EXCHANGES.keys())
Expand All @@ -24,3 +25,4 @@
OKX_COINS = env.list("OKX_COINS", delimiter=" ")
BITSTAMP_COINS = env.list("BITSTAMP_COINS", delimiter=" ")
MEXC_COINS = env.list("MEXC_COINS", delimiter=" ")
HTX_COINS = env.list("HTX_COINS", delimiter=" ")
43 changes: 43 additions & 0 deletions exchange_radar/producer/tasks/htx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import asyncio
import gzip
import json
import logging

import websockets

from exchange_radar.producer.publisher import publish
from exchange_radar.producer.serializers.htx import HtxTradeSerializer
from exchange_radar.producer.tasks.base import Task

logger = logging.getLogger(__name__)


ITER_SLEEP = 10.0


class HtxTradesTask(Task):
async def process(self, symbol_or_symbols: str | tuple):
uri = "wss://api.huobi.pro/ws"
message = {"sub": f"market.{symbol_or_symbols.lower()}.trade.detail"}

while True:
try:
async with websockets.connect(uri) as ws:
await ws.send(json.dumps(message))
while True:
try:
response = json.loads(gzip.decompress(await ws.recv()).decode("utf-8"))
if "ping" in response:
await ws.send(json.dumps({"pong": response["ping"]}))
continue
for msg in response["tick"]["data"]:
msg["channel"] = response["ch"]
data = HtxTradeSerializer(**msg)
publish(data)
except Exception as error:
logger.error(f"ERROR: {error}")
except Exception as error2:
logger.error(f"GENERAL ERROR: {error2}")
finally:
logger.error(f"Trying again in {ITER_SLEEP} seconds...")
await asyncio.sleep(ITER_SLEEP)
6 changes: 4 additions & 2 deletions exchange_radar/producer/tasks/okx.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ async def task(self, symbols: tuple[dict]):
await asyncio.gather(self.process(tuple([{"channel": "trades-all", "instId": symbol} for symbol in symbols])))

async def process(self, symbol_or_symbols: str | tuple):
url = "wss://ws.okx.com:8443/ws/v5/business"

try:

def callback(message):
Expand All @@ -46,7 +48,7 @@ def callback(message):

_symbols = list(symbol_or_symbols)

ws = WsPublicAsync(url="wss://ws.okx.com:8443/ws/v5/business")
ws = WsPublicAsync(url=url)
await self._subscribe(ws=ws, callback=callback, symbols=_symbols)

while True:
Expand All @@ -65,7 +67,7 @@ def callback(message):
pass # possibly nothing to unsubscribe

# re-subscribe
ws = WsPublicAsync(url="wss://ws.okx.com:8443/ws/v5/business")
ws = WsPublicAsync(url=url)
await self._subscribe(ws=ws, callback=callback, symbols=_symbols)

self.num_events = 0
Expand Down
36 changes: 36 additions & 0 deletions exchange_radar/producer/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from exchange_radar.producer.serializers.bitstamp import BitstampTradeSerializer
from exchange_radar.producer.serializers.bybit import BybitTradeSerializer
from exchange_radar.producer.serializers.coinbase import CoinbaseTradeSerializer
from exchange_radar.producer.serializers.htx import HtxTradeSerializer
from exchange_radar.producer.serializers.kraken import KrakenTradeSerializer
from exchange_radar.producer.serializers.kucoin import KucoinTradeSerializer
from exchange_radar.producer.serializers.mexc import MexcTradeSerializer
Expand Down Expand Up @@ -349,3 +350,38 @@ def test_serializer_mexc(mock_redis):
"exchange": "MEXC",
"is_seller": False,
}


@patch("exchange_radar.producer.models.redis")
def test_serializer_htx(mock_redis):
mock_redis.hincrbyfloat.return_value = 3.7335
mock_redis.pipeline().__enter__().execute = MagicMock(return_value=[1.0, 100.0])

msg = {
"id": 116629582655996198945167846,
"ts": 1706698561897,
"tradeId": 100267772152,
"amount": 16.72,
"price": 15.4841,
"direction": "buy",
"channel": "market.linkusdt.trade.detail",
}
payload = HtxTradeSerializer(**msg)

assert payload.model_dump() == {
"symbol": "LINKUSDT",
"price": Decimal("15.4841"),
"quantity": Decimal("16.72"),
"trade_time": datetime.datetime(2024, 1, 31, 10, 56, 1),
"total": Decimal("258.894152"),
"currency": "USDT",
"trade_symbol": "LINK",
"volume": 3.7335,
"volume_trades": (1.0, 100.0),
"number_trades": (1, 100),
"trade_time_ts": 1706698561,
"message": "2024-01-31 10:56:01 | <span class='htx'>HTX </span> | 15.48410000 USDT |"
" 16.72000000 LINK | 258.89415200 USDT",
"is_seller": False,
"exchange": "HTX",
}
4 changes: 2 additions & 2 deletions exchange_radar/producer/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ def test_get_ranking__error():
@pytest.mark.parametrize(
"coin, expected",
[
("BTC", "Binance, Coinbase, Kraken, Kucoin, OKX, Bybit, Bitstamp, MEXC"),
("ETH", "Binance, Coinbase, Kraken, Kucoin, OKX, Bybit, Bitstamp, MEXC"),
("BTC", "Binance, Coinbase, Kraken, Kucoin, OKX, Bybit, Bitstamp, MEXC, HTX"),
("ETH", "Binance, Coinbase, Kraken, Kucoin, OKX, Bybit, Bitstamp, MEXC, HTX"),
("LTO", "Binance, Kucoin"),
],
)
Expand Down
2 changes: 2 additions & 0 deletions exchange_radar/producer/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
BITSTAMP_COINS,
BYBIT_COINS,
COINBASE_COINS,
HTX_COINS,
KRAKEN_COINS,
KUCOIN_COINS,
MEXC_COINS,
Expand Down Expand Up @@ -55,6 +56,7 @@ def get_exchanges(coin: str) -> str:
(BYBIT_COINS, "Bybit"),
(BITSTAMP_COINS, "Bitstamp"),
(MEXC_COINS, "MEXC"),
(HTX_COINS, "HTX"),
]
coin_length = len(coin)
exchanges_selected = []
Expand Down
3 changes: 2 additions & 1 deletion exchange_radar/web/src/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@
OKX = env.list("OKX")
BITSTAMP = env.list("BITSTAMP")
MEXC = env.list("MEXC")
HTX = env.list("HTX")

COINS = list(set(BINANCE + COINBASE + KRAKEN + KUCOIN + OKX + BITSTAMP + MEXC))
COINS = list(set(BINANCE + COINBASE + KRAKEN + KUCOIN + OKX + BITSTAMP + MEXC + HTX))
Loading
Loading