Skip to content
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
File renamed without changes.
151 changes: 118 additions & 33 deletions api/account/controller.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
from account.schemas import BalanceSchema, KucoinBalance
from databases.crud.balances_crud import BalancesCrud
from exchange_apis.binance.assets import Assets
from pybinbot import ExchangeId, round_numbers, KucoinApi, KucoinFutures, BinbotErrors
from pybinbot import ExchangeId, round_numbers, KucoinApi, KucoinFutures
from pybinbot.shared.enums import KucoinKlineIntervals
from databases.utils import get_session
from sqlmodel import Session
from exchange_apis.kucoin.deals.base import KucoinBaseBalance
from kucoin_universal_sdk.generate.account.account import (
GetSpotLedgerItems,
)
from typing import Dict
from enum import Enum
from tools.config import Config


class ConsolidatedAccounts:
PAGINATION_PAGE_SIZE = 500
BANK_TRANSFER_CURRENCIES = frozenset({"EUR", "GBP"})
HISTORICAL_RATE_INTERVAL = KucoinKlineIntervals.ONE_HOUR.value

def __init__(self, session: Session = None):
if not session:
self.session = get_session()
Expand All @@ -32,49 +40,114 @@ def __init__(self, session: Session = None):
self.autotrade_settings = self.binance_assets.autotrade_settings
self.balances_crud = BalancesCrud(session=self.session)
self.fiat = self.autotrade_settings.fiat
self._historical_rate_cache: dict[tuple[str, int], float] = {}

@staticmethod
def _deposit_amount(entry) -> float:
if isinstance(entry, dict):
amount = entry.get("amount") or entry.get("size") or 0
def _extract_entry_timestamp(entry: GetSpotLedgerItems) -> int | None:
return int(entry.created_at) if entry.created_at is not None else None

def _get_historical_ticker_price(
self, symbol: str, timestamp_ms: int | None
) -> float:
if timestamp_ms is None:
return float(self.kucoin_api.get_ticker_price(symbol))

interval_ms = KucoinKlineIntervals.get_interval_ms(
self.HISTORICAL_RATE_INTERVAL
)
candle_open_ms = timestamp_ms - (timestamp_ms % interval_ms)
cache_key = (symbol, candle_open_ms)
cached = self._historical_rate_cache.get(cache_key)
if cached is not None:
return cached

klines = self.kucoin_api.get_ui_klines(
symbol=symbol,
interval=self.HISTORICAL_RATE_INTERVAL,
limit=1,
start_time=candle_open_ms,
end_time=candle_open_ms + interval_ms,
)

if klines:
price = float(klines[-1][4])
else:
amount = getattr(entry, "amount", None)
if amount is None:
amount = getattr(entry, "size", 0)
price = float(self.kucoin_api.get_ticker_price(symbol))

self._historical_rate_cache[cache_key] = price
return price

try:
return float(amount)
except (TypeError, ValueError):
def _convert_amount_to_fiat(
self, currency: str | None, amount: float, timestamp_ms: int | None = None
) -> float:
if amount <= 0 or not currency:
return 0.0

def get_total_deposit(self) -> float:
if self.autotrade_settings.exchange_id != ExchangeId.KUCOIN:
raise NotImplementedError(
"Total deposit aggregation is only implemented for KuCoin."
if currency == self.fiat:
return amount

if currency in ["EUR", "GBP", "AUD", "JPY"]:
rate = self._get_historical_ticker_price(
f"{self.fiat}-{currency}",
timestamp_ms=timestamp_ms,
)
return amount / float(rate)

try:
deposits = self.kucoin_futures_api.get_deposit_history()
except BinbotErrors:
return 0.0
rate = self._get_historical_ticker_price(
f"{currency}-{self.fiat}",
timestamp_ms=timestamp_ms,
)
return amount * float(rate)

if not deposits:
return 0.0
def _sum_deposit_entries(
self,
entries: list[GetSpotLedgerItems],
) -> float:
total = 0.0

if isinstance(deposits, dict):
candidates = (
deposits.get("items")
or deposits.get("data")
or deposits.get("deposits")
or []
for entry in entries:
timestamp_ms = self._extract_entry_timestamp(entry)
total += self._convert_amount_to_fiat(
entry.currency,
float(entry.amount or 0),
timestamp_ms=timestamp_ms,
)
else:
candidates = deposits

if isinstance(candidates, dict):
candidates = candidates.get("items") or []
return total

def _get_bank_transfer_entries(self) -> list[GetSpotLedgerItems]:
page = 1
bank_transfers: list[GetSpotLedgerItems] = []

while True:
response = self.kucoin_api.get_spot_ledger(
current_page=page,
page_size=self.PAGINATION_PAGE_SIZE,
)

bank_transfers.extend(
entry
for entry in (response.items or [])
if entry.currency in self.BANK_TRANSFER_CURRENCIES
and str(entry.account_type or "").upper() == "MAIN"
and str(entry.biz_type or "").lower() == "fiat deposit"
)

if page >= max(int(response.total_page or 1), 1):
break

page += 1

return bank_transfers

def get_total_deposit(self) -> float:
if self.autotrade_settings.exchange_id != ExchangeId.KUCOIN:
raise NotImplementedError(
"Total deposit aggregation is only implemented for KuCoin."
)

return sum(self._deposit_amount(deposit) for deposit in candidates)
bank_transfers = self._get_bank_transfer_entries()
return self._sum_deposit_entries(bank_transfers)

def get_balance(self) -> BalanceSchema:
"""
Expand Down Expand Up @@ -193,8 +266,20 @@ def get_kucoin_balances_by_type(self) -> KucoinBalance:
fiat_available += float(value["balance"])
# we don't want to convert USDC, TUSD or USDT to itself
if key != self.fiat:
rate = self.kucoin_api.get_ticker_price(f"{key}-{self.fiat}")
estimated_total_fiat += float(value["balance"]) * float(rate)
if key in ["EUR", "GBP", "AUD", "JPY"]:
rate = self.kucoin_api.get_ticker_price(
f"{self.fiat}-{key}"
)
estimated_total_fiat += float(value["balance"]) / float(
rate
)
else:
rate = self.kucoin_api.get_ticker_price(
f"{key}-{self.fiat}"
)
estimated_total_fiat += float(value["balance"]) * float(
rate
)
else:
estimated_total_fiat += float(value["balance"])

Expand Down
2 changes: 1 addition & 1 deletion api/exchange_apis/kucoin/futures/futures_deal.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def _is_reversal_possible(
if estimated_available_buffer < (min_contract_step * per_contract_buffer):
return float(current_contracts)

return max(float(current_contracts), float(minimum_flip_contracts))
return float(minimum_flip_contracts)

def estimate_reversal_possible_for_new_bot(self) -> bool:
"""
Expand Down
38 changes: 23 additions & 15 deletions api/exchange_apis/kucoin/futures/position_deal.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,15 @@ def reverse_position(self) -> BotModel:

current_contracts = abs(float(current_position.current_qty))

# set fixed to ensure flip
flip_contracts = 1
flip_contracts = self._is_reversal_possible(
current_position.mark_price, current_contracts
)

if flip_contracts < current_contracts:
self.active_bot.add_log(
"Insufficient balance to reverse position after hitting stop loss, closing position with stop loss order."
)
self.execute_stop_loss()

# Construct new bot
new_bot = BotBase(
Expand Down Expand Up @@ -621,23 +628,24 @@ def reverse_position(self) -> BotModel:
f"Second order succeeded - filled_qty: {filled_qty}, filled_price: {filled_price}, timestamp: {timestamp}"
)
second_order_model = OrderModel(
order_type=str(
getattr(second_order_details, "type", None)
or second_order.order_type
order_type=(
second_order_details.type.value
if second_order_details.type
else second_order.order_type
),
time_in_force=str(
getattr(second_order_details, "time_in_force", None)
or second_order.time_in_force
time_in_force=(
second_order_details.time_in_force
if second_order_details.time_in_force
else second_order.time_in_force
),
timestamp=timestamp or int(second_order.timestamp),
order_id=str(second_order.order_id),
order_side=str(
getattr(second_order_details, "side", None)
or second_order.order_side
),
pair=str(
getattr(second_order_details, "symbol", None) or second_order.pair
order_side=(
second_order_details.side.value
if second_order_details.side
else second_order.order_side
),
pair=str(second_order_details.symbol or second_order.pair),
qty=float(filled_qty or second_order.qty or 0),
status=second_order.status,
price=float(filled_price or second_order.price or 0),
Expand Down Expand Up @@ -675,7 +683,7 @@ def reverse_position(self) -> BotModel:
self.controller.save(reversed_bot)
self.active_bot = reversed_bot
if reversed_bot.status == Status.active:
self.update_parameters()
self.active_bot = self.update_parameters()

return self.active_bot

Expand Down
2 changes: 1 addition & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dependencies = [
"pydantic-settings>=2.10.1",
"confluent-kafka>=2.11.1",
"aiokafka>=0.12.0",
"pybinbot>=1.2.0",
"pybinbot>=1.3.2",
]

[project.urls]
Expand Down
48 changes: 48 additions & 0 deletions api/tests/test_account_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from types import SimpleNamespace
from unittest.mock import MagicMock

from account.controller import ConsolidatedAccounts
from databases.tables.autotrade_table import AutotradeTable
from pybinbot import ExchangeId


def test_get_total_deposit_includes_paginated_crypto_and_bank_transfer_entries():
controller = ConsolidatedAccounts.__new__(ConsolidatedAccounts)
controller.fiat = "USDT"
controller.autotrade_settings = AutotradeTable(exchange_id=ExchangeId.KUCOIN)
controller._historical_rate_cache = {}

ledger_page = SimpleNamespace(
total_page=1,
items=[
SimpleNamespace(
currency="EUR",
amount="100",
account_type="MAIN",
biz_type="Fiat Deposit",
direction="in",
created_at=1_700_000_000_000,
),
SimpleNamespace(
currency="EUR",
amount="50",
account_type="TRADE",
biz_type="Transfer in",
direction="in",
created_at=1_700_000_000_000,
),
],
)
controller.kucoin_api = MagicMock()
controller.kucoin_api.get_spot_ledger.return_value = ledger_page
controller.kucoin_api.get_ui_klines.return_value = [
[0, "0", "0", "0", "0.8", "0", 0, "0"]
]

assert controller.get_total_deposit() == 125.0

controller.kucoin_api.get_spot_ledger.assert_called_once_with(
current_page=1,
page_size=ConsolidatedAccounts.PAGINATION_PAGE_SIZE,
)
controller.kucoin_api.get_ticker_price.assert_not_called()
Loading
Loading