1
0

Compare commits

...

3 Commits

Author SHA1 Message Date
dfeb3a3294
Added KucoinDepoFetcher.place_market_order
All checks were successful
Test Python / Test (push) Successful in 26s
2024-07-20 21:55:46 +02:00
15386f0b4d
Improved type annotations 2024-07-20 20:21:50 +02:00
1903d55038
Added kraken and kucoin tests 2024-07-20 20:19:52 +02:00
8 changed files with 275 additions and 15 deletions

View File

@ -25,6 +25,8 @@ __all__ = [
'defi_kucoin', 'defi_kucoin',
'defi_partisia_blockchain', 'defi_partisia_blockchain',
'investbank_nordnet', 'investbank_nordnet',
'__version__',
] ]
from . import defi_kraken, defi_kucoin, defi_partisia_blockchain, investbank_nordnet from . import defi_kraken, defi_kucoin, defi_partisia_blockchain, investbank_nordnet
from ._version import __version__

View File

@ -10,7 +10,7 @@ from fin_defs import Asset
@enforce_typing.enforce_types @enforce_typing.enforce_types
@dataclasses.dataclass @dataclasses.dataclass(frozen=True)
class Depo(abc.ABC): class Depo(abc.ABC):
"""A depository tracking some amount of assets. """A depository tracking some amount of assets.
@ -38,7 +38,7 @@ class Depo(abc.ABC):
@enforce_typing.enforce_types @enforce_typing.enforce_types
@dataclasses.dataclass @dataclasses.dataclass(frozen=True)
class DepoSingle(Depo): class DepoSingle(Depo):
"""Base level of depository.""" """Base level of depository."""
@ -52,7 +52,7 @@ class DepoSingle(Depo):
@enforce_typing.enforce_types @enforce_typing.enforce_types
@dataclasses.dataclass @dataclasses.dataclass(frozen=True)
class DepoGroup(Depo): class DepoGroup(Depo):
"""Nested depository.""" """Nested depository."""
@ -92,3 +92,25 @@ class DepoFetcher(abc.ABC):
msg = f'{self} expected {param_name} parameter of type {param_type}, but got: {param_value}' msg = f'{self} expected {param_name} parameter of type {param_type}, but got: {param_value}'
raise TypeError(msg) raise TypeError(msg)
return param_value return param_value
@enforce_typing.enforce_types
@dataclasses.dataclass(frozen=True)
class TradeOrderDetails:
"""Information about an executed trade.
Includes both well-structured data about the order, and unstructured data
from the backend. The unstructured data might be needed for tax purposes.
"""
input_asset: Asset
input_amount: Decimal
output_asset: Asset
output_amount: Decimal
fee_asset: Asset
fee_amount: Decimal
order_id: object
raw_order_details: object

View File

@ -7,7 +7,7 @@ from decimal import Decimal
import fin_defs import fin_defs
import kucoin.client import kucoin.client
from .data import DepoFetcher, DepoGroup, DepoSingle from .data import DepoFetcher, DepoGroup, DepoSingle, TradeOrderDetails
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,7 +19,8 @@ class KucoinDepoFetcher(DepoFetcher):
- Account on [Kucoin](https://www.kucoin.com). - Account on [Kucoin](https://www.kucoin.com).
- Have performed Know Your Customer (KYC) for your account. - Have performed Know Your Customer (KYC) for your account.
- Created API key from [settings menu](https://www.kucoin.com/account/api). - Created API key from [settings menu](https://www.kucoin.com/account/api).
API key must have the **General Permission**, and **should not have 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. any additional permissions**. Employ principle of least priviledge.
- Install [`python-kucoin`](https://python-kucoin.readthedocs.io/en/latest/) library. - Install [`python-kucoin`](https://python-kucoin.readthedocs.io/en/latest/) library.
@ -27,11 +28,18 @@ class KucoinDepoFetcher(DepoFetcher):
sub-accounts (Funding, Trading, Margin, Futures...) sub-accounts (Funding, Trading, Margin, Futures...)
""" """
def __init__(self, kucoin_key: str, kucoin_secret: str, kucoin_pass: str): 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_key', str, kucoin_key)
self.assert_param('kucoin_secret', str, kucoin_secret) self.assert_param('kucoin_secret', str, kucoin_secret)
self.assert_param('kucoin_pass', str, kucoin_pass) self.assert_param('kucoin_pass', str, kucoin_pass)
self.client = kucoin.client.Client( self.allow_trades = allow_trades
self.kucoin_client = kucoin.client.Client(
kucoin_key, kucoin_key,
kucoin_secret, kucoin_secret,
kucoin_pass, kucoin_pass,
@ -46,7 +54,7 @@ class KucoinDepoFetcher(DepoFetcher):
# clustered into different depos. # clustered into different depos.
assets_by_account_type: dict[str, dict[fin_defs.Asset, Decimal]] = {} assets_by_account_type: dict[str, dict[fin_defs.Asset, Decimal]] = {}
for account_data in self.client.get_accounts(): for account_data in self.kucoin_client.get_accounts():
asset = fin_defs.WELL_KNOWN_SYMBOLS[account_data['currency']] asset = fin_defs.WELL_KNOWN_SYMBOLS[account_data['currency']]
balance = Decimal(account_data['balance']) balance = Decimal(account_data['balance'])
assets_for_account_type = assets_by_account_type.setdefault( assets_for_account_type = assets_by_account_type.setdefault(
@ -66,3 +74,81 @@ class KucoinDepoFetcher(DepoFetcher):
for account_type, assets in assets_by_account_type.items() for account_type, assets in assets_by_account_type.items()
], ],
) )
def place_market_order(
self,
input_asset: fin_defs.Asset,
input_amount: Decimal,
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 WIP
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.
"""
# Check requirements
if not self.allow_trades:
msg = 'KucoinDepoFetcher.allow_trades is not enabled: Cannot make trades'
raise PermissionError(msg)
assert fin_defs.USDT in [input_asset, output_asset], 'USDT markets only for now'
# Convert arguments to kucoin client arguments
if input_asset == fin_defs.USDT:
symbol: str = f'{output_asset}-{input_asset}'
side: str = 'buy'
size = None
funds = str(input_amount)
else:
symbol = f'{input_asset}-{output_asset}'
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_amount
# Determine order details
order_id = response['orderId']
order_details = self.kucoin_client.get_order(order_id)
# Convert from kucoin results
if input_asset == fin_defs.USDT:
input_amount_final = Decimal(order_details['dealFunds'])
output_amount_final = Decimal(order_details['dealSize'])
else:
output_amount_final = Decimal(order_details['dealFunds'])
input_amount_final = Decimal(order_details['dealSize'])
return TradeOrderDetails(
input_asset=input_asset,
input_amount=input_amount_final,
output_asset=output_asset,
output_amount=output_amount_final,
fee_asset=fin_defs.WELL_KNOWN_SYMBOLS[order_details['feeCurrency']],
fee_amount=Decimal(order_details['fee']),
order_id=order_id,
raw_order_details=order_details,
)

View File

@ -7,6 +7,7 @@ import json
import logging import logging
from collections.abc import Mapping from collections.abc import Mapping
from decimal import Decimal from decimal import Decimal
from typing import Any
import fin_defs import fin_defs
import requests import requests
@ -55,9 +56,9 @@ class PbcClient:
def get_json( def get_json(
self, self,
url: str, url: str,
data: Mapping[str, str] = frozendict(), data: Mapping[str, Any] = frozendict(),
method='POST', method='POST',
) -> tuple[dict, datetime.datetime]: ) -> tuple[Any, datetime.datetime]:
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
@ -80,10 +81,10 @@ class PbcClient:
raise Exception(msg) raise Exception(msg)
return (json_data, date) return (json_data, date)
def determine_coins(self) -> list[dict[str, str]]: def determine_coins(self) -> list[dict[str, Any]]:
data: dict = {'path': []} data: dict = {'path': []}
url = URL_ACCOUNT_PLUGIN_GLOBAL.format( url: str = URL_ACCOUNT_PLUGIN_GLOBAL.format(
hostname=HOSTNAME, hostname=HOSTNAME,
shard='', shard='',
) )

View File

@ -11,7 +11,9 @@ I am grateful for his pioneering work.
import datetime import datetime
import logging import logging
from collections.abc import Mapping
from decimal import Decimal from decimal import Decimal
from typing import Any
import fin_defs import fin_defs
import requests import requests
@ -41,7 +43,7 @@ def asset_from_instrument_json(json) -> fin_defs.Asset | None:
return fin_defs.Stock(symbol, fin_defs.EXCHANGES_BY_IDS[exchange_id]) return fin_defs.Stock(symbol, fin_defs.EXCHANGES_BY_IDS[exchange_id])
EMPTY_DICT: dict[str, str | int] = frozendict() EMPTY_DICT: Mapping[str, str | int] = frozendict()
class NordnetDepoFetcher(DepoFetcher): class NordnetDepoFetcher(DepoFetcher):
@ -66,7 +68,7 @@ class NordnetDepoFetcher(DepoFetcher):
self.password: str = self.assert_param('password', str, password) self.password: str = self.assert_param('password', str, password)
self.is_logged_in = False self.is_logged_in = False
def _get_json(self, url: str, params: dict[str, str | int] = EMPTY_DICT) -> dict: def _get_json(self, url: str, params: Mapping[str, str | int] = EMPTY_DICT) -> Any:
if not url.startswith(API_ROOT): if not url.startswith(API_ROOT):
msg = f'Given url must be located below API ROOT: {url}' msg = f'Given url must be located below API ROOT: {url}'
raise ValueError(msg) raise ValueError(msg)
@ -80,7 +82,7 @@ class NordnetDepoFetcher(DepoFetcher):
response.raise_for_status() response.raise_for_status()
return json return json
def login(self): def login(self) -> None:
"""Performs authentication with the login server if not already logged """Performs authentication with the login server if not already logged
in. Does not need to be manually called; most methods that require this in. Does not need to be manually called; most methods that require this
information will do it themselves. information will do it themselves.

View File

@ -1,6 +1,20 @@
"""Secret loading for tests.
Add these secrets to an [appropriate secret loader
location](https://gitfub.space/Jmaa/secret_loader). Missing some secrets will
result in skipped tests.
"""
from secret_loader import SecretLoader from secret_loader import SecretLoader
load_secret = SecretLoader().load load_secret = SecretLoader().load
NORDNET_USERNAME = load_secret('NORDNET_USERNAME') NORDNET_USERNAME = load_secret('NORDNET_USERNAME')
NORDNET_PASSWORD = load_secret('NORDNET_PASSWORD') NORDNET_PASSWORD = load_secret('NORDNET_PASSWORD')
KRAKEN_KEY = load_secret('KRAKEN_KEY')
KRAKEN_SECRET = load_secret('KRAKEN_SECRET')
KUCOIN_KEY = load_secret('KUCOIN_KEY')
KUCOIN_SECRET = load_secret('KUCOIN_SECRET')
KUCOIN_PASS = load_secret('KUCOIN_PASS')

23
test/test_kraken.py Normal file
View File

@ -0,0 +1,23 @@
import pytest
import requests
from fin_depo import defi_kraken
from . import secrets
needs_secrets = pytest.mark.skipif(
not secrets.KRAKEN_KEY,
reason='Secret kraken_USERNAME required',
)
@needs_secrets
def test_get_depo():
session = requests.Session()
fetcher = defi_kraken.KrakenDepoFetcher(
secrets.KRAKEN_KEY,
secrets.KRAKEN_SECRET,
)
depo = fetcher.get_depo()
assert depo is not None

110
test/test_kucoin.py Normal file
View File

@ -0,0 +1,110 @@
from decimal import Decimal
import fin_defs
import pytest
from fin_depo import defi_kucoin
from . import secrets
TEST_MARKET_ORDERS = False
needs_secrets = pytest.mark.skipif(
not secrets.KUCOIN_KEY,
reason='Secret kucoin_USERNAME required',
)
defi_kucoin.logger.setLevel('INFO')
@needs_secrets
def test_get_depo():
"""Can inspect kucoin depository."""
fetcher = defi_kucoin.KucoinDepoFetcher(
secrets.KUCOIN_KEY,
secrets.KUCOIN_SECRET,
secrets.KUCOIN_PASS,
)
depo = fetcher.get_depo()
assert depo is not None
@needs_secrets
def test_place_market_order_requires_allow_trades():
"""Client fails if allow_trades is not enabled when doing market orders."""
fetcher = defi_kucoin.KucoinDepoFetcher(
secrets.KUCOIN_KEY,
secrets.KUCOIN_SECRET,
secrets.KUCOIN_PASS,
allow_trades=False,
)
with pytest.raises(PermissionError) as m:
fetcher.place_market_order(fin_defs.MPC, Decimal(1), fin_defs.USDT)
assert 'KucoinDepoFetcher.allow_trades is not enabled: Cannot make trades' in str(m)
@needs_secrets
def test_place_buy_side_order():
"""Client can place buy side market orders."""
fetcher = defi_kucoin.KucoinDepoFetcher(
secrets.KUCOIN_KEY,
secrets.KUCOIN_SECRET,
secrets.KUCOIN_PASS,
allow_trades=TEST_MARKET_ORDERS,
)
input_amount = Decimal('0.1')
order_details = fetcher.place_market_order(
fin_defs.USDT, input_amount, fin_defs.MPC,
)
assert order_details is not None
assert order_details.order_id is not None
assert order_details.raw_order_details is not None
assert order_details.input_asset == fin_defs.USDT
assert order_details.output_asset == fin_defs.MPC
assert order_details.input_amount <= input_amount
assert order_details.output_amount >= 0
assert order_details.input_amount != order_details.output_amount
assert order_details.fee_asset == fin_defs.USDT
assert order_details.fee_amount <= Decimal('0.0002')
@needs_secrets
def test_place_sell_side_order():
"""Client can place sell side market orders."""
fetcher = defi_kucoin.KucoinDepoFetcher(
secrets.KUCOIN_KEY,
secrets.KUCOIN_SECRET,
secrets.KUCOIN_PASS,
allow_trades=TEST_MARKET_ORDERS,
)
input_amount = Decimal('1')
order_details = fetcher.place_market_order(
fin_defs.MPC, input_amount, fin_defs.USDT,
)
assert order_details is not None
assert order_details.order_id is not None
assert order_details.raw_order_details is not None
assert order_details.input_asset == fin_defs.MPC
assert order_details.output_asset == fin_defs.USDT
assert order_details.input_amount <= input_amount
assert order_details.output_amount >= 0
assert order_details.input_amount != order_details.output_amount
assert order_details.fee_asset == fin_defs.USDT
assert order_details.fee_amount is not None