294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""See `KucoinDepoFetcher` for documentation."""
|
|
|
|
import datetime
|
|
import logging
|
|
import time
|
|
from collections.abc import Iterator
|
|
from decimal import Decimal
|
|
|
|
import fin_defs
|
|
import kucoin.client
|
|
from fin_defs import AssetAmount
|
|
|
|
from .data import (
|
|
DepoFetcher,
|
|
DepoGroup,
|
|
DepoSingle,
|
|
DepositDetails,
|
|
DoubleRegister,
|
|
TradeOrderDetails,
|
|
WithdrawalDetails,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def parse_asset_from_ticker(ticker: str) -> fin_defs.Asset:
|
|
if ticker == 'KCS':
|
|
return fin_defs.CryptoCurrency('KCS', coingecko_id='kucoin-shares')
|
|
return fin_defs.WELL_KNOWN_SYMBOLS[ticker]
|
|
|
|
|
|
def order_from_json(order_details: dict):
|
|
(asset, base_asset) = [
|
|
parse_asset_from_ticker(a) for a in order_details['symbol'].split('-')
|
|
]
|
|
|
|
# Convert from kucoin results
|
|
if order_details['side'] == 'buy':
|
|
input_asset, output_asset = base_asset, asset
|
|
input_amount_final = Decimal(order_details['dealFunds'])
|
|
output_amount_final = Decimal(order_details['dealSize'])
|
|
else:
|
|
input_asset, output_asset = asset, base_asset
|
|
output_amount_final = Decimal(order_details['dealFunds'])
|
|
input_amount_final = Decimal(order_details['dealSize'])
|
|
|
|
fee_asset = parse_asset_from_ticker(order_details['feeCurrency'])
|
|
|
|
return TradeOrderDetails(
|
|
input=AssetAmount(input_asset, input_amount_final),
|
|
output=AssetAmount(output_asset, output_amount_final),
|
|
fee=AssetAmount(fee_asset, Decimal(order_details['fee'])),
|
|
executed_time=datetime.datetime.fromtimestamp(
|
|
order_details['createdAt'] / 1000,
|
|
tz=datetime.UTC,
|
|
),
|
|
order_id=order_details['id'],
|
|
raw_order_details=order_details,
|
|
)
|
|
|
|
|
|
class KucoinDepoFetcher(DepoFetcher):
|
|
"""`Depo` fetcher for [Kucoin](https://www.kucoin.com), the online crypto currency exchange.
|
|
|
|
Requirements for use:
|
|
- Account on [Kucoin](https://www.kucoin.com).
|
|
- Have performed Know Your Customer (KYC) for your account.
|
|
- Created API key from [settings menu](https://www.kucoin.com/account/api).
|
|
API key must have the **General Permission**. Unless you are using the
|
|
`place_market_order` functionality, the API key **should not have
|
|
any additional permissions**. Employ principle of least priviledge.
|
|
- Install [`python-kucoin`](https://python-kucoin.readthedocs.io/en/latest/) library.
|
|
|
|
Depository structure: An upper level depo split for each of the
|
|
sub-accounts (Funding, Trading, Margin, Futures...)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
kucoin_key: str,
|
|
kucoin_secret: str,
|
|
kucoin_pass: str,
|
|
allow_trades: bool = False,
|
|
):
|
|
self.assert_param('kucoin_key', str, kucoin_key)
|
|
self.assert_param('kucoin_secret', str, kucoin_secret)
|
|
self.assert_param('kucoin_pass', str, kucoin_pass)
|
|
self.allow_trades = allow_trades
|
|
self.kucoin_client = kucoin.client.Client(
|
|
kucoin_key,
|
|
kucoin_secret,
|
|
kucoin_pass,
|
|
)
|
|
|
|
def get_depo(self) -> DepoGroup:
|
|
# We would ideally get timestamp from request,
|
|
# but this is fine for now.
|
|
now = datetime.datetime.now(tz=datetime.UTC)
|
|
|
|
# Assets are spread across account types, but we would like them
|
|
# clustered into different depos.
|
|
assets_by_account_type: dict[str, dict[fin_defs.Asset, Decimal]] = {}
|
|
|
|
for account_data in self.kucoin_client.get_accounts():
|
|
asset = parse_asset_from_ticker(account_data['currency'])
|
|
balance = Decimal(account_data['balance'])
|
|
assets_for_account_type = assets_by_account_type.setdefault(
|
|
account_data['type'],
|
|
{},
|
|
)
|
|
assets_for_account_type[asset] = (
|
|
assets_for_account_type.get(asset, Decimal(0)) + balance
|
|
)
|
|
del account_data, asset, balance, assets_for_account_type
|
|
|
|
return DepoGroup(
|
|
'Kucoin',
|
|
now,
|
|
[
|
|
DepoSingle('Kucoin ' + account_type, now, assets)
|
|
for account_type, assets in assets_by_account_type.items()
|
|
],
|
|
)
|
|
|
|
def place_market_order(
|
|
self,
|
|
input_: fin_defs.AssetAmount,
|
|
output_asset: fin_defs.Asset,
|
|
) -> TradeOrderDetails:
|
|
"""Executes a market order through the Kucoin market.
|
|
|
|
Will automatically determine the market based on the input and output
|
|
assets.
|
|
|
|
Requirements:
|
|
- Fetcher must have been created with `allow_trades=True`.
|
|
- API key used with fetcher must have **Spot Trading** permissions.
|
|
- Assets must be on trading account. Assets on funding accounts or
|
|
other accounts cannot be used.
|
|
|
|
Note:
|
|
- A fee will be paid to Kucoin, with the rate determined by your VIP
|
|
level and the asset being traded.
|
|
- The full `input_amount` may not be used. Inspect the resulting
|
|
`TradeOrderDetails` to see how much of the `input_amount` have been
|
|
used.
|
|
|
|
References:
|
|
- POST Market Order: <https://www.kucoin.com/docs/rest/spot-trading/orders/place-order>
|
|
- GET Market Order by id: <https://www.kucoin.com/docs/rest/spot-trading/orders/get-order-details-by-orderid>
|
|
"""
|
|
# Check requirements
|
|
if not self.allow_trades:
|
|
msg = 'KucoinDepoFetcher.allow_trades is not enabled: Cannot make trades'
|
|
raise PermissionError(msg)
|
|
|
|
if fin_defs.USDT not in [input_.asset, output_asset]:
|
|
msg = 'Non-USDT Markets are not supported'
|
|
raise NotImplementedError(msg)
|
|
|
|
# Convert arguments to kucoin client arguments
|
|
if input_.asset == fin_defs.USDT:
|
|
symbol: str = (
|
|
f'{output_asset.raw_short_name()}-{input_.asset.raw_short_name()}'
|
|
)
|
|
side: str = 'buy'
|
|
size = None
|
|
funds = str(input_.amount)
|
|
else:
|
|
symbol = f'{input_.asset.raw_short_name()}-{output_asset.raw_short_name()}'
|
|
side = 'sell'
|
|
size = str(input_.amount)
|
|
funds = None
|
|
|
|
# Place order
|
|
logger.info('Placing order: %s', str([symbol, side, size, funds]))
|
|
response = self.kucoin_client.create_market_order(
|
|
symbol=symbol,
|
|
side=side,
|
|
size=size,
|
|
funds=funds,
|
|
)
|
|
del symbol, side, size, funds, input_
|
|
|
|
# Determine order details
|
|
return self._get_order_details(response['orderId'])
|
|
|
|
def _get_withdrawals(self) -> list[WithdrawalDetails]:
|
|
raw_details = self.kucoin_client.get_withdrawals()
|
|
|
|
withdrawals = []
|
|
for item in raw_details['items']:
|
|
withdrawn_asset = parse_asset_from_ticker(item['currency'])
|
|
withdrawn_amount = Decimal(item['amount'])
|
|
fee_asset = withdrawn_asset # Assumed
|
|
fee_amount = Decimal(item['fee'])
|
|
executed_time = datetime.datetime.fromtimestamp(
|
|
item['createdAt'] / 1000,
|
|
tz=datetime.UTC,
|
|
)
|
|
|
|
withdrawals.append(
|
|
WithdrawalDetails(
|
|
AssetAmount(withdrawn_asset, withdrawn_amount),
|
|
AssetAmount(fee_asset, fee_amount),
|
|
executed_time,
|
|
item,
|
|
),
|
|
)
|
|
del item
|
|
|
|
return withdrawals
|
|
|
|
def _get_deposits(self) -> list[DepositDetails]:
|
|
raw_details = self.kucoin_client.get_deposits()
|
|
|
|
deposits = []
|
|
for item in raw_details['items']:
|
|
deposit_asset = parse_asset_from_ticker(item['currency'])
|
|
deposit_amount = Decimal(item['amount'])
|
|
fee_asset = deposit_asset # Assumed
|
|
fee_amount = Decimal(item['fee'])
|
|
executed_time = datetime.datetime.fromtimestamp(
|
|
item['createdAt'] / 1000,
|
|
tz=datetime.UTC,
|
|
)
|
|
|
|
deposits.append(
|
|
DepositDetails(
|
|
AssetAmount(deposit_asset, deposit_amount),
|
|
AssetAmount(fee_asset, fee_amount),
|
|
executed_time,
|
|
item,
|
|
),
|
|
)
|
|
del item
|
|
|
|
return deposits
|
|
|
|
def _get_historic_spot_orders(self) -> Iterator[TradeOrderDetails]:
|
|
end_time = datetime.datetime.now(tz=datetime.UTC)
|
|
for _weeks_back in range(20):
|
|
end_time = end_time - datetime.timedelta(days=7)
|
|
timestamp = int(end_time.timestamp() * 1000)
|
|
page_index = 1
|
|
while True:
|
|
raw_details = self.kucoin_client.get_orders(end=timestamp, page=page_index)
|
|
yield from (order_from_json(item) for item in raw_details['items'])
|
|
|
|
page_index = raw_details['currentPage'] + 1
|
|
if page_index > raw_details['totalPage']:
|
|
break
|
|
del raw_details
|
|
del _weeks_back
|
|
|
|
def _get_double_registers(self) -> list[DoubleRegister]:
|
|
double_registers: list[DoubleRegister] = []
|
|
double_registers += self._get_deposits()
|
|
double_registers += self._get_withdrawals()
|
|
double_registers += self._get_historic_spot_orders()
|
|
double_registers.sort(key=lambda x: x.executed_time)
|
|
return double_registers
|
|
|
|
def _get_order_details(
|
|
self,
|
|
order_id: str,
|
|
) -> TradeOrderDetails:
|
|
"""Determine the order details for the order with the given id.
|
|
|
|
Retries the order a few times, as KuCoin might not have propagated the
|
|
order through their systems.
|
|
"""
|
|
order_details = self._get_order_with_retries(order_id, num_retries=10)
|
|
return order_from_json(order_details)
|
|
|
|
def _get_order_with_retries(
|
|
self,
|
|
order_id: str,
|
|
*,
|
|
num_retries: int,
|
|
sleep_between_tries: float = 1.0,
|
|
) -> dict:
|
|
"""Get the order details from KuCoin backend.
|
|
|
|
Retries the order a few times, as KuCoin might not have propagated the
|
|
order through their systems since it was sent.
|
|
"""
|
|
for _ in range(num_retries):
|
|
try:
|
|
return self.kucoin_client.get_order(order_id)
|
|
except kucoin.exceptions.KucoinAPIException: # noqa: PERF203
|
|
time.sleep(sleep_between_tries)
|
|
return self.kucoin_client.get_order(order_id)
|