Skip to content

Commit

Permalink
#46 Add HTX exchange (#47)
Browse files Browse the repository at this point in the history
  • Loading branch information
pantunes authored Jan 31, 2024
1 parent fc7d30f commit ea47f27
Show file tree
Hide file tree
Showing 28 changed files with 261 additions and 30 deletions.
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

0 comments on commit ea47f27

Please sign in to comment.