Skip to content
5 changes: 5 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ disallow_any_decorated = False
disallow_any_explicit = False
disallow_any_expr = False

[mypy-dali.plugin.input.rest.kucoin_futures]
disallow_any_decorated = False
disallow_any_explicit = False
disallow_any_expr = False

[mypy-dali.transaction_resolver]
disallow_any_unimported = False
disallow_any_expr = False
Expand Down
282 changes: 282 additions & 0 deletions src/dali/plugin/input/rest/kucoin_futures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# Copyright 2022 topcoderasdf
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Kucoin Futures REST plugin links:
# REST API: https://docs.kucoin.com/futures/#general
# Authentication: https://docs.kucoin.com/futures/#authentication
# Endpoint: https://api-futures.kucoin.com

import json
Comment thread
topcoderasdf marked this conversation as resolved.
import time
from datetime import datetime
from decimal import Decimal
Comment thread
topcoderasdf marked this conversation as resolved.
Outdated
from typing import Any, Dict, List, Optional

from ccxt import DDoSProtection, Exchange, ExchangeError, ExchangeNotAvailable, NetworkError, RateLimitExceeded, RequestTimeout, kucoinfutures

from dali.abstract_ccxt_input_plugin import AbstractCcxtInputPlugin
from dali.ccxt_pagination import AbstractPaginationDetailSet
from dali.configuration import Keyword
from dali.in_transaction import InTransaction
from dali.intra_transaction import IntraTransaction
from dali.out_transaction import OutTransaction

_AMOUNT: str = "amount"
_CURRENCY: str = "currency"
_DATA: str = "data"
_DATA_LIST: str = "dataList"
_DEPOSIT: str = "Deposit"
_END_AT: str = "endAt"
_HAS_MORE: str = "hasMore"
_OFFSET: str = "offset"
_REALISED_PNL: str = "RealisedPNL"
_START_AT: str = "startAt"
_TIME: str = "time"
_TRANSFER_IN: str = "TransferIn"
_TRANSFER_OUT: str = "TransferOut"
_ONE_DAY_IN_MS: int = 86400000
_TYPE: str = "type"
_WITHDRAWAL: str = "Withdrawal"

class InputPlugin(AbstractCcxtInputPlugin):

__EXCHANGE_NAME: str = "kucoin_futures"
__PLUGIN_NAME: str = "kucoin_futures"
__DEFAULT_THREAD_COUNT: int = 1

def __init__(
self, account_holder: str, api_key: str, api_secret: str, api_passphrase: str, native_fiat: Optional[str] = None, thread_count: Optional[int] = None
) -> None:
self.__api_key = api_key
self.__api_secret = api_secret
self.__api_passphrase = api_passphrase

super().__init__(account_holder, datetime(2019, 2, 17, 0, 0, 0, 0), native_fiat, thread_count)

def _initialize_client(self) -> kucoinfutures:
return kucoinfutures(
{
"apiKey": self.__api_key,
"secret": self.__api_secret,
"password": self.__api_passphrase,
"enableRateLimit": True,
}
)

def exchange_name(self) -> str:
return self.__EXCHANGE_NAME

def plugin_name(self) -> str:
return self.__PLUGIN_NAME

@property
def _client(self) -> kucoinfutures:
super_client: Exchange = super()._client
if not isinstance(super_client, kucoinfutures):
raise TypeError("super_client is not of type kucoinfutures")

return super_client

# using kucoin transaction history api instead. (implicit api)
def _get_process_deposits_pagination_detail_set(self) -> Optional[AbstractPaginationDetailSet]:
return None

def _get_process_withdrawals_pagination_detail_set(self) -> Optional[AbstractPaginationDetailSet]:
return None

def _get_process_trades_pagination_detail_set(self) -> Optional[AbstractPaginationDetailSet]:
return None

def _process_gains(
self,
in_transactions: List[InTransaction],
out_transactions: List[OutTransaction],
) -> None:
pass

def _fetch_data(
self,
start_at: int,
end_at: int,
) -> List[Dict[str, Any]]:

offset: Optional[int] = None
retries: int = 0
has_more: bool = True

ledger_transactions: Dict[str, Any] = {}
items: List[Dict[str, Any]] = []

while start_at <= end_at:
while has_more and retries < 4:
try:
if offset is None:
ledger_transactions = self._client.futuresPrivateGetTransactionHistory({_START_AT: start_at, _END_AT: start_at + _ONE_DAY_IN_MS})
else:
ledger_transactions = self._client.futuresPrivateGetTransactionHistory(
{_START_AT: start_at, _END_AT: start_at + _ONE_DAY_IN_MS, _OFFSET: offset}
)
data_list = ledger_transactions[_DATA][_DATA_LIST]
has_more = ledger_transactions[_DATA][_HAS_MORE]

for item in data_list:
items.append(item)
offset = item[_OFFSET]

# https://github.com/ccxt/ccxt/issues/10273
# enableRateLimit is not working for kucoin
self._client.sleep(150)

except RateLimitExceeded:
# sleep for 13 seconds
self._client.sleep(13000)
retries += 1
except (DDoSProtection, ExchangeError) as exc:
self.__logger.debug(
"Exception from server, most likely too many requests. Making another attempt after 13 second delay. Exception - %s", exc
)

self._client.sleep(13000)
retries += 1

except (ExchangeNotAvailable, NetworkError, RequestTimeout) as exc_na:
retries += 1
self.__logger.debug("Server not available. Making attempt #%s of 4 after a 13 second delay. Exception - %s", retries, exc_na)
self._client.sleep(13000)

if retries >= 4:
raise Exception("Failed to fetch ledger transactions after 4 retries")
start_at += _ONE_DAY_IN_MS
offset = None
has_more = True
retries = 0

return items

def _process_data(
self,
items: List[Dict[str, Any]],
in_transactions: List[InTransaction],
out_transactions: List[OutTransaction],
# intra_transactions: List[IntraTransaction],
Comment thread
topcoderasdf marked this conversation as resolved.
Outdated
) -> None:
for item in items:
timestamp: str = self._rp2_timestamp_from_ms_epoch(str(item[_TIME]))
transaction_type: str = item[_TYPE]
amount: str = str(item[_AMOUNT])
currency: str = item[_CURRENCY]
offset: int = item[_OFFSET]

# internal tranfer between kucoin and kucoin futures. Ignore
if transaction_type in {_TRANSFER_IN, _TRANSFER_OUT}:
continue

# To do: implement deposit/withdrawal using ccxt fetchDeposits()/fetchWithdrawals()
if transaction_type in {_DEPOSIT, _WITHDRAWAL}:
Comment thread
topcoderasdf marked this conversation as resolved.
continue

if transaction_type == _REALISED_PNL:
amount_value = Decimal(amount)
Comment thread
topcoderasdf marked this conversation as resolved.
Outdated

# no internal txn id provided by kucoin futures
# unique id manually generated
unique_id = f"realized_pnl:{self.exchange_name()}:{self.account_holder}:{timestamp}:{offset}"

if amount_value > 0:
# Gain
# Example json
# {
# "time": 1644444400000,
# "type": "RealisedPNL",
# "amount": 66.66666666,
# "fee": 0.0,
# "accountEquity": 12345.67890123,
# "status": "Completed",
# "remark": "ETHUSDTM",
# "offset": 123456,
# "currency": "USDT"
# }

# To do: need to replace transaction type from INCOME to GAIN
# GAIN currently not implemented in rp2
in_transactions.append(
InTransaction(
plugin=self.plugin_name(),
unique_id=unique_id,
raw_data=json.dumps(item),
timestamp=timestamp,
asset=currency,
exchange=self.exchange_name(),
holder=self.account_holder,
transaction_type=Keyword.INCOME.value,
spot_price=Keyword.UNKNOWN.value,
crypto_in=str(amount_value),
crypto_fee=None,
fiat_in_no_fee=None,
fiat_in_with_fee=None,
fiat_fee=None,
notes="Futures gain"
)
)

else:
# Loss
# Example json
# {
# "time": 1644444400000,
# "type": "RealisedPNL",
# "amount": -66.66666666,
# "fee": 0.0,
# "accountEquity": 12345.67890123,
# "status": "Completed",
# "remark": "ETHUSDTM",
# "offset": 123456,
# "currency": "USDT"
# }

# To do: need to replace transaction type from SELL to LOSS
# LOSS currently not implemented in rp2
out_transactions.append(
OutTransaction(
plugin=self.plugin_name(),
unique_id=unique_id,
raw_data=json.dumps(item),
timestamp=timestamp,
asset=currency,
exchange=self.exchange_name(),
holder=self.account_holder,
transaction_type=Keyword.SELL.value,
spot_price="0",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is spot price 0? This would cause a 0 fiat loss: in other words it would be a taxable event with no impact.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be wrong, but this is my thought process.

If we set spot price to 0, it would incur ((acquisition price - spot price) * quantity) loss and will be considered as taxable event. If this is not how it works, at least that is my intention and I would like to know how to code that as I am not sure how to do that.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have Kucoin so I'm just going by the example JSON above: does the amount field contain the fiat amount of the loss (in other words is -66 the fiat difference between the price at acquisition of the future contract and the price at expiration)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-66 would be the amount of USDT realized loss (it isn't fiat amount). So if you had 3000 USDT before, you would be now left with 2934 USDT after closing your future position.

If the amount is positive (e.g 66 not -66), then it would be the amount of USDT realized gain. If the currency was XBT, then it would be BTC realized gain. -66 amount with XBT currency would be, realized loss of 66 Bitcoins (66 * $ 17000 in fiat values at current price)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see, thanks for the explanation. What would happen if the loss is -66 USDT at expiration but the user never had any USDT?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

before that happens, the position will be liquidated.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just represent the differential loss as a SELL transaction of 66 USDT with the actual spot price (or UNKNOWN if the exchange doesn't provide it)? This way it would be treated as a normal loss by the tax engine.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am kind of confused as to why the spot price needs to be set to market price.

ex)

1 30 USDT bought with USD at $ 1.00 spot price
2. Bought a future contract.
3. Contract got liquidated. 30 USDT realized loss reported.

So, are you suggesting step 3 to be represented as a sell (OutTransaction) with spot price of $1.00 ? Wouldn't this generate a tax report of 0 capital loss?

My assumption was to represent the above as the following.
step 1 -> Buy InTransaction with amount 30, currency USDT, spot price $1.00
step 3-> Sell OutTransaction with amount 30, currency USDT, spot price $0.00

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I see what you mean: you want to ensure the 30 USDT are modeled as lost, not sold and so by setting the spot price to 0 you achieve that. Sorry it took me a while to get it: it looks good to me. @macanudo527, do you have any more thoughts on this? If not I think we can move on.

crypto_out_no_fee=str(-amount_value),
crypto_fee="0",
crypto_out_with_fee=str(-amount_value),
fiat_out_no_fee=None,
fiat_fee=None,
notes="Futures loss",
)
)

def _process_implicit_api(
self,
in_transactions: List[InTransaction],
out_transactions: List[OutTransaction],
intra_transactions: List[IntraTransaction],
) -> None:
in_transactions.clear()
Comment thread
topcoderasdf marked this conversation as resolved.
Outdated
out_transactions.clear()
end_at: int = int(time.time() * 1000)
Comment thread
topcoderasdf marked this conversation as resolved.
Outdated
start_at: int = self._start_time_ms

items: List[Dict[str, Any]] = self._fetch_data(start_at, end_at)
self._process_data(items, in_transactions, out_transactions)
6 changes: 6 additions & 0 deletions src/stubs/ccxt.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Exchange:
def fetch_withdrawals( # type: ignore
self, code: Optional[str] = ..., since: Optional[int] = ..., limit: Optional[int] = ..., params: Optional[Dict[str, Union[int, str, None]]] = ...
) -> Any: ...
def sleep(self, ms: int) -> None: ...

class binance(Exchange):
def __init__(self, config: Dict[str, Union[str, bool]]) -> None: ...
Expand Down Expand Up @@ -64,6 +65,11 @@ class kraken(Exchange):
def __init__(self, config: Dict[str, Union[str, bool]]) -> None: ...
options: Dict[str, str]

class kucoinfutures(Exchange):
def __init__(self, config: Dict[str, Union[str, bool]]) -> None: ...
options: Dict[str, str]
def futuresPrivateGetTransactionHistory(self, params: Dict[str, int] = ...) -> Any: ... # type: ignore

class liquid(Exchange):
def __init__(self, config: Dict[str, Union[str, bool]]) -> None: ...
options: Dict[str, str]
Expand Down