From a659172fd04815d512282235166ed1aa25b2e240 Mon Sep 17 00:00:00 2001 From: Tom JEANNESSON Date: Thu, 29 Feb 2024 23:17:27 +0100 Subject: [PATCH] feat(histories): added WalletHistories --- django_napse/core/models/bots/architecture.py | 15 +- django_napse/core/models/bots/bot.py | 7 +- .../implementations/turbo_dca/strategy.py | 13 +- django_napse/core/models/bots/plugins/sbv.py | 17 +- .../core/models/connections/connection.py | 7 +- django_napse/core/models/histories/bot.py | 12 + django_napse/core/models/histories/history.py | 103 ++++++++- .../models/histories/managers/__init__.py | 1 + .../histories/managers/history_data_point.py | 25 ++ .../managers/history_data_point_field.py | 21 ++ django_napse/core/models/histories/wallet.py | 33 ++- django_napse/core/models/orders/order.py | 8 +- .../transactions/managers/transaction.py | 32 ++- .../core/models/transactions/transaction.py | 18 +- django_napse/core/models/wallets/__init__.py | 4 + .../core/models/wallets/connection_wallet.py | 28 +++ django_napse/core/models/wallets/currency.py | 37 ++- .../core/models/wallets/order_wallet.py | 22 ++ .../models/wallets/space_simulation_wallet.py | 47 ++++ .../core/models/wallets/space_wallet.py | 45 ++++ django_napse/core/models/wallets/wallet.py | 213 ++++++++++-------- django_napse/core/tasks/__init__.py | 2 +- .../tasks/{base_tasks.py => base_task.py} | 0 django_napse/core/tasks/candle_collector.py | 2 +- django_napse/core/tasks/controller_update.py | 2 +- .../core/tasks/order_process_executor.py | 2 +- .../models/simulations/simulation_queue.py | 32 ++- django_napse/utils/constants.py | 46 ++-- django_napse/utils/usefull_functions.py | 4 +- pyproject.toml | 5 +- requirements/core.txt | 1 + .../django_tests/db/histories/test_history.py | 22 +- ...ecial_history.py => test_space_history.py} | 4 +- .../db/histories/test_wallet_history.py | 25 ++ tests/django_tests/db/wallets/test_wallet.py | 19 +- 35 files changed, 682 insertions(+), 192 deletions(-) create mode 100644 django_napse/core/models/histories/managers/__init__.py create mode 100644 django_napse/core/models/histories/managers/history_data_point.py create mode 100644 django_napse/core/models/histories/managers/history_data_point_field.py create mode 100644 django_napse/core/models/wallets/connection_wallet.py create mode 100644 django_napse/core/models/wallets/order_wallet.py create mode 100644 django_napse/core/models/wallets/space_simulation_wallet.py create mode 100644 django_napse/core/models/wallets/space_wallet.py rename django_napse/core/tasks/{base_tasks.py => base_task.py} (100%) rename tests/django_tests/db/histories/{test_special_history.py => test_space_history.py} (85%) create mode 100644 tests/django_tests/db/histories/test_wallet_history.py diff --git a/django_napse/core/models/bots/architecture.py b/django_napse/core/models/bots/architecture.py index d5be15b8..b6ae0885 100644 --- a/django_napse/core/models/bots/architecture.py +++ b/django_napse/core/models/bots/architecture.py @@ -3,6 +3,7 @@ from django.db import models from django_napse.core.models.bots.managers import ArchitectureManager +from django_napse.core.models.wallets.currency import CurrencyPydantic from django_napse.utils.constants import ORDER_LEEWAY_PERCENTAGE, PLUGIN_CATEGORIES, SIDES from django_napse.utils.errors.orders import OrderError from django_napse.utils.findable_class import FindableClass @@ -131,12 +132,14 @@ def _get_orders(self, data: dict, no_db_data: Optional[dict] = None) -> list[dic error_msg = f"Order on {order['pair']} has a side of {order['side']} but an amount of 0." raise OrderError.ProcessError(error_msg) for ticker, amount in required_amount.items(): - if amount > no_db_data["connection_data"][connection]["wallet"]["currencies"].get(ticker, {"amount": 0})["amount"] / ( - 1 + (ORDER_LEEWAY_PERCENTAGE + 1) / 100 - ): - available = no_db_data["connection_data"][connection]["wallet"]["currencies"].get(ticker, {"amount": 0})["amount"] / ( - 1 + (ORDER_LEEWAY_PERCENTAGE + 1) / 100 - ) + if amount > no_db_data["connection_data"][connection]["wallet"].currencies.get( + ticker, + CurrencyPydantic(ticker=ticker, amount=0, mbp=0), + ).amount / (1 + (ORDER_LEEWAY_PERCENTAGE + 1) / 100): + available = no_db_data["connection_data"][connection]["wallet"].currencies.get( + ticker, + CurrencyPydantic(ticker=ticker, amount=0, mbp=0), + ).amount / (1 + (ORDER_LEEWAY_PERCENTAGE + 1) / 100) for order in [_order for _order in orders if _order["asked_for_ticker"] == ticker]: order["asked_for_amount"] *= available / amount all_orders += orders diff --git a/django_napse/core/models/bots/bot.py b/django_napse/core/models/bots/bot.py index 4a3ac86c..d89d40f4 100644 --- a/django_napse/core/models/bots/bot.py +++ b/django_napse/core/models/bots/bot.py @@ -1,16 +1,19 @@ from __future__ import annotations import uuid -from typing import Optional +from typing import TYPE_CHECKING, Optional from django.db import models from django_napse.core.models.connections.connection import Connection from django_napse.core.models.modifications import ArchitectureModification, ConnectionModification, StrategyModification from django_napse.core.models.orders.order import Order, OrderBatch -from django_napse.core.models.wallets.wallet import SpaceSimulationWallet, SpaceWallet from django_napse.utils.errors import BotError +if TYPE_CHECKING: + from django_napse.core.models.wallets.space_simulation_wallet import SpaceSimulationWallet + from django_napse.core.models.wallets.space_wallet import SpaceWallet + class Bot(models.Model): uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4) diff --git a/django_napse/core/models/bots/implementations/turbo_dca/strategy.py b/django_napse/core/models/bots/implementations/turbo_dca/strategy.py index 10cda16b..a935d614 100644 --- a/django_napse/core/models/bots/implementations/turbo_dca/strategy.py +++ b/django_napse/core/models/bots/implementations/turbo_dca/strategy.py @@ -6,6 +6,7 @@ from django_napse.core.models.bots.implementations.turbo_dca.config import TurboDCABotConfig from django_napse.core.models.bots.plugins import LBOPlugin, MBPPlugin, SBVPlugin from django_napse.core.models.bots.strategy import Strategy +from django_napse.core.models.wallets.currency import CurrencyPydantic from django_napse.utils.constants import SIDES @@ -46,8 +47,16 @@ def give_order(self, data: dict) -> list[dict]: mbp = data["connection_data"][data["connection"]]["connection_specific_args"]["mbp"].get_value() lbo = data["connection_data"][data["connection"]]["connection_specific_args"]["lbo"].get_value() sbv = data["connection_data"][data["connection"]]["connection_specific_args"]["sbv"].get_value() - available_base = data["connection_data"][data["connection"]]["wallet"]["currencies"].get(controller.base, {"amount": 0})["amount"] - available_quote = data["connection_data"][data["connection"]]["wallet"]["currencies"].get(controller.quote, {"amount": 0})["amount"] + available_base = ( + data["connection_data"][data["connection"]]["wallet"] + .currencies.get(controller.base, CurrencyPydantic(ticker=controller.base, amount=0, mbp=0)) + .amount + ) + available_quote = ( + data["connection_data"][data["connection"]]["wallet"] + .currencies.get(controller.quote, CurrencyPydantic(ticker=controller.quote, amount=0, mbp=0)) + .amount + ) mbp = mbp if mbp is not None else math.inf sbv = sbv if sbv is not None else available_quote current_price = data["candles"][controller]["latest"]["close"] diff --git a/django_napse/core/models/bots/plugins/sbv.py b/django_napse/core/models/bots/plugins/sbv.py index 9b35b657..56ce0475 100644 --- a/django_napse/core/models/bots/plugins/sbv.py +++ b/django_napse/core/models/bots/plugins/sbv.py @@ -1,5 +1,6 @@ from django_napse.core.models.bots.plugin import Plugin from django_napse.core.models.connections.connection import ConnectionSpecificArgs +from django_napse.core.models.wallets.currency import CurrencyPydantic from django_napse.utils.constants import PLUGIN_CATEGORIES, SIDES @@ -10,12 +11,16 @@ def plugin_category(cls): def _apply(self, data: dict) -> dict: order = data["order"] - current_base_amout = data["connection_data"][data["connection"]]["wallet"]["currencies"].get(order["controller"].base, {"amount": 0})[ - "amount" - ] - current_quote_amout = data["connection_data"][data["connection"]]["wallet"]["currencies"].get(order["controller"].quote, {"amount": 0})[ - "amount" - ] + current_base_amout = ( + data["connection_data"][data["connection"]]["wallet"] + .currencies.get(order["controller"].base, CurrencyPydantic(ticker=order["controller"].base, amount=0, mbp=0)) + .amount + ) + current_quote_amout = ( + data["connection_data"][data["connection"]]["wallet"] + .currencies.get(order["controller"].quote, CurrencyPydantic(ticker=order["controller"].quote, amount=0, mbp=0)) + .amount + ) if data["connection_data"][data["connection"]]["connection_specific_args"]["sbv"].get_value() is None or order["side"] == SIDES.SELL: order["ConnectionModifications"] += [ { diff --git a/django_napse/core/models/connections/connection.py b/django_napse/core/models/connections/connection.py index 39acb7ec..72298894 100644 --- a/django_napse/core/models/connections/connection.py +++ b/django_napse/core/models/connections/connection.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from django.db import models from django_napse.core.models.connections.managers import ConnectionManager @@ -5,6 +7,9 @@ from django_napse.utils.constants import TRANSACTION_TYPES from django_napse.utils.usefull_functions import process_value_from_type +if TYPE_CHECKING: + from django_napse.core.models.accounts.space import NapseSpace + class Connection(models.Model): """Link between a bot & a wallet.""" @@ -53,7 +58,7 @@ def testing(self) -> bool: return self.space.testing @property - def space(self) -> "NapseSpace": # noqa: F821 + def space(self) -> "NapseSpace": """Return the space of the connection.""" return self.owner.find().space diff --git a/django_napse/core/models/histories/bot.py b/django_napse/core/models/histories/bot.py index f7bd1d83..70101cdf 100644 --- a/django_napse/core/models/histories/bot.py +++ b/django_napse/core/models/histories/bot.py @@ -4,4 +4,16 @@ class BotHistory(History): + """A History for a Bot. + + Use it to track the evolution of a bot over time. + + This tracks the following fields: + TODO + """ + owner = models.OneToOneField("Bot", on_delete=models.CASCADE, related_name="history") + + def generate_data_points(self) -> None: + """Create a new data point for the bot.""" + return diff --git a/django_napse/core/models/histories/history.py b/django_napse/core/models/histories/history.py index 582d2614..f442d148 100644 --- a/django_napse/core/models/histories/history.py +++ b/django_napse/core/models/histories/history.py @@ -5,19 +5,39 @@ from django.db import models from django.utils.timezone import get_default_timezone -from django_napse.utils.constants import HISTORY_DATAPOINT_FIELDS +from django_napse.core.models.histories.managers.history_data_point import HistoryDataPointManager +from django_napse.core.models.histories.managers.history_data_point_field import HistoryDataPointFieldManager +from django_napse.utils.constants import HISTORY_DATAPOINT_FIELDS, HISTORY_DATAPOINT_FIELDS_WILDCARDS from django_napse.utils.errors import HistoryError from django_napse.utils.findable_class import FindableClass from django_napse.utils.usefull_functions import process_value_from_type class History(FindableClass, models.Model): + """A History is a collection of data points. + + Use it to track the evolution of a value over time. + + Create a child class to get started. + + The child class should have a ForeignKey to the model you want to track (called `owner`). + """ + uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4) def __str__(self) -> str: return f"HISTORY {self.uuid}" - def info(self, verbose=True, beacon=""): + def info(self, beacon: str = "", *, verbose: bool = True) -> str: + """Return a string with the model information. + + Args: + beacon (str, optional): The prefix for each line. Defaults to "". + verbose (bool, optional): Whether to print the string. Defaults to True. + + Returns: + str: The string with the history information. + """ string = "" string += f"{beacon}History {self.pk}:\n" string += f"{beacon}\t{self.uuid=}\n" @@ -28,23 +48,25 @@ def info(self, verbose=True, beacon=""): print(string) return string - def to_dataframe(self): + def to_dataframe(self) -> pd.DataFrame: + """Return a DataFrame containing the data points.""" all_data_points = self.data_points.all() return pd.DataFrame([data_point.to_dict() for data_point in all_data_points]) @property - def owner(self): + def owner(self) -> models.Model: + """Return the owner of the history.""" return self.find().owner @classmethod - def get_or_create(cls, owner): + def get_or_create(cls, owner: models.Model) -> "History": + """Return the history of the owner if it exists, else create it.""" if hasattr(owner, "history"): return owner.history return cls.objects.create(owner=owner) def delta(self, days: int = 30) -> float: """Return the value delta between today and {days} days ago.""" - # TODO: TEST IT date = datetime.now(tz=get_default_timezone()) - timedelta(days=days) data_points = self.data_points.filter(created_at__date=date.date()) @@ -59,32 +81,93 @@ def delta(self, days: int = 30) -> float: return data_points + def generate_data_point(self) -> "HistoryDataPoint": + """Create a new data point. + + This method should be implemented in the child class. + """ + error_msg = "You must implement the generate_data_point method in your child class." + raise NotImplementedError(error_msg) + class HistoryDataPoint(models.Model): + """A HistoryDataPoint is a collection of fields.""" + history = models.ForeignKey(History, on_delete=models.CASCADE, related_name="data_points") created_at = models.DateTimeField(auto_now_add=True) + objects = HistoryDataPointManager() + def __str__(self) -> str: # pragma: no cover return f"HISTORY DATA POINT {self.pk} {self.history.uuid}" - def to_dict(self): + def to_dict(self) -> dict: + """Return a dictionary containing the fields.""" return {field.key: field.get_value() for field in self.fields.all()} + def info(self, beacon: str = "", *, verbose: bool = True) -> str: + """Return a string with the model information. + + Args: + beacon (str, optional): The prefix for each line. Defaults to "". + verbose (bool, optional): Whether to print the string. Defaults to True. + + Returns: + str: The string with the history information. + """ + string = "" + string += f"{beacon}HistoryDataPoint {self.pk}:\n" + string += f"{beacon}Fields:\n" + for field in self.fields.all(): + string += field.info(beacon=beacon + "\t", verbose=False) + if verbose: + print(string) + return string + class HistoryDataPointField(models.Model): + """A HistoryDataPointField is a key-value pair with a target type.""" + history_data_point = models.ForeignKey(HistoryDataPoint, on_delete=models.CASCADE, related_name="fields") key = models.CharField(max_length=255) value = models.CharField(max_length=255) target_type = models.CharField(max_length=255) + objects = HistoryDataPointFieldManager() + def __str__(self) -> str: # pragma: no cover return f"HISTORY DATA POINT FIELD {self.pk} {self.history_data_point.pk}" - def save(self, *args, **kwargs): - if self.key not in HISTORY_DATAPOINT_FIELDS: + def save(self, *args, **kwargs): # noqa + is_wildcard = False + for wildcard in HISTORY_DATAPOINT_FIELDS_WILDCARDS: + if self.key.startswith(wildcard): + is_wildcard = True + break + if self.key not in HISTORY_DATAPOINT_FIELDS and not is_wildcard: error_msg = f"Invalid key {self.key} for HistoryDataPointField" raise HistoryError.InvalidDataPointFieldKey(error_msg) return super().save(*args, **kwargs) - def get_value(self): + def info(self, beacon: str = "", *, verbose: bool = True) -> str: + """Return a string with the model information. + + Args: + beacon (str, optional): The prefix for each line. Defaults to "". + verbose (bool, optional): Whether to print the string. Defaults to True. + + Returns: + str: The string with the history information. + """ + string = "" + string += f"{beacon}HistoryDataPointField {self.pk}:\n" + string += f"{beacon}\t{self.key=}\n" + string += f"{beacon}\t{self.value=}\n" + string += f"{beacon}\t{self.target_type=}\n" + if verbose: + print(string) + return string + + def get_value(self) -> any: + """Return the value as the target type.""" return process_value_from_type(value=self.value, target_type=self.target_type) diff --git a/django_napse/core/models/histories/managers/__init__.py b/django_napse/core/models/histories/managers/__init__.py new file mode 100644 index 00000000..9a5c9562 --- /dev/null +++ b/django_napse/core/models/histories/managers/__init__.py @@ -0,0 +1 @@ +from .history_data_point import HistoryDataPointManager diff --git a/django_napse/core/models/histories/managers/history_data_point.py b/django_napse/core/models/histories/managers/history_data_point.py new file mode 100644 index 00000000..5b56e205 --- /dev/null +++ b/django_napse/core/models/histories/managers/history_data_point.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING, Optional + +from django.apps import apps +from django.db import models + +if TYPE_CHECKING: + from django_napse.core.models.histories.history import History, HistoryDataPoint + + +class HistoryDataPointManager(models.Manager): + """The manager for the HistoryDataPoint model.""" + + def create( + self, + history: "History", + points: Optional[dict] = None, + ) -> "HistoryDataPoint": + """Create a new data point for the history.""" + HistoryDataPointField = apps.get_model("django_napse_core", "HistoryDataPointField") + points = points or {} + data_point = self.model(history=history) + data_point.save() + for key, value in points.items(): + HistoryDataPointField.objects.create(history_data_point=data_point, key=key, value=value) + return data_point diff --git a/django_napse/core/models/histories/managers/history_data_point_field.py b/django_napse/core/models/histories/managers/history_data_point_field.py new file mode 100644 index 00000000..afb51181 --- /dev/null +++ b/django_napse/core/models/histories/managers/history_data_point_field.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING + +from django.db import models + +if TYPE_CHECKING: + from django_napse.core.models.histories.history import HistoryDataPoint, HistoryDataPointField + + +class HistoryDataPointFieldManager(models.Manager): + """The manager for the HistoryDataPointField model.""" + + def create( + self, + history_data_point: "HistoryDataPoint", + key: str, + value: str, + ) -> "HistoryDataPointField": + """Create a new data point field for a data point.""" + data_point_field = self.model(history_data_point=history_data_point, key=key, value=str(value), target_type=type(value).__name__) + data_point_field.save() + return data_point_field diff --git a/django_napse/core/models/histories/wallet.py b/django_napse/core/models/histories/wallet.py index 9b5cfcb8..53f3a196 100644 --- a/django_napse/core/models/histories/wallet.py +++ b/django_napse/core/models/histories/wallet.py @@ -1,7 +1,36 @@ +from typing import TYPE_CHECKING + from django.db import models -from django_napse.core.models.histories.history import History +from django_napse.core.models.histories.history import History, HistoryDataPoint +from django_napse.utils.constants import HISTORY_DATAPOINT_FIELDS, HISTORY_DATAPOINT_FIELDS_WILDCARDS + +if TYPE_CHECKING: + from django_napse.core.models.wallets.wallet import Wallet class WalletHistory(History): - owner = models.OneToOneField("Wallet", on_delete=models.CASCADE, related_name="history") + """A History for a Wallet. + + Use it to track the evolution of a bot over time. + + This tracks the following fields: + - `value`: The value of the wallet in USD. + - `amount_ticker`: For each ticker, the amount of the ticker in the wallet. + """ + + owner: "Wallet" = models.OneToOneField("Wallet", on_delete=models.CASCADE, related_name="history") + + def generate_data_point(self) -> "HistoryDataPoint": + """Create a new data point for the bot.""" + wallet = self.owner.to_dict() + points = { + HISTORY_DATAPOINT_FIELDS.WALLET_VALUE: self.owner.value_market(), + } + for currency in wallet.currencies.values(): + points[HISTORY_DATAPOINT_FIELDS_WILDCARDS.AMOUNT + currency.ticker] = currency.amount + + return HistoryDataPoint.objects.create( + history=self, + points=points, + ) diff --git a/django_napse/core/models/orders/order.py b/django_napse/core/models/orders/order.py index 40291b56..c52dad27 100644 --- a/django_napse/core/models/orders/order.py +++ b/django_napse/core/models/orders/order.py @@ -68,7 +68,7 @@ class Order(models.Model): objects = OrderManager() - def __str__(self): + def __str__(self) -> str: return f"ORDER: {self.pk=}" def info(self, verbose=True, beacon=""): @@ -156,8 +156,8 @@ def _calculate_batch_share(self, total: float): def passed(self, batch: Optional[OrderBatch] = None): batch = batch or self.batch - if (self.side == SIDES.BUY and (batch.status == ORDER_STATUS.PASSED or batch.status == ORDER_STATUS.ONLY_BUY_PASSED)) or ( - self.side == SIDES.SELL and (batch.status == ORDER_STATUS.PASSED or batch.status == ORDER_STATUS.ONLY_SELL_PASSED) + if (self.side == SIDES.BUY and (batch.status in (ORDER_STATUS.PASSED, ORDER_STATUS.ONLY_BUY_PASSED))) or ( + self.side == SIDES.SELL and (batch.status in (ORDER_STATUS.PASSED, ORDER_STATUS.ONLY_SELL_PASSED)) ): return True return False @@ -186,7 +186,7 @@ def apply_modifications(self): modifications=[modification.find() for modification in self.modifications.all()], strategy=self.connection.bot.strategy.find(), architecture=self.connection.bot.architecture.find(), - currencies=self.connection.wallet.to_dict()["currencies"], + currencies=self.connection.wallet.to_dict().currencies, ) for modification in modifications: modification.save() diff --git a/django_napse/core/models/transactions/managers/transaction.py b/django_napse/core/models/transactions/managers/transaction.py index 84628173..28b0f4ff 100644 --- a/django_napse/core/models/transactions/managers/transaction.py +++ b/django_napse/core/models/transactions/managers/transaction.py @@ -1,14 +1,40 @@ +from typing import TYPE_CHECKING + from django.db import models from django.db.transaction import atomic +from django_napse.core.models.histories.wallet import WalletHistory from django_napse.utils.constants import TRANSACTION_TYPES from django_napse.utils.errors import TransactionError +if TYPE_CHECKING: + from django_napse.core.models.transactions.transaction import Transaction + from django_napse.core.models.wallets.wallet import Wallet + class TransactionManager(models.Manager): + """The manager for the Transaction model.""" + @atomic() - def create(self, from_wallet, to_wallet, amount, ticker, transaction_type): - """Create a Transaction object and update the wallets accordingly.""" + def create(self, from_wallet: "Wallet", to_wallet: "Wallet", amount: float, ticker: str, transaction_type: str) -> "Transaction": + """Create a Transaction object and update the wallets accordingly. + + Args: + from_wallet (Wallet): The wallet to take the money from. + to_wallet (Wallet): The wallet to send the money to. + amount (float): The amount of money to transfer. + ticker (str): The ticker of the currency to transfer. + transaction_type (str): The type of transaction. + + Raises: + TransactionError.DifferentAccountError: If the wallets are on different exchange_accounts. + TransactionError.SameWalletError: If the wallets are the same. + TransactionError.TestingError: If the wallets are not both testing or both not testing. + TransactionError.InvalidTransactionError: If the transaction type is not in TRANSACTION_TYPES. + + Returns: + Transaction: The created transaction. + """ if amount == 0: return None transaction = self.model( @@ -37,4 +63,6 @@ def create(self, from_wallet, to_wallet, amount, ticker, transaction_type): from_wallet.spend(amount, ticker, force=True) to_wallet.top_up(amount, ticker, mbp=from_wallet.currencies.get(ticker=ticker).mbp, force=True) transaction.save() + WalletHistory.get_or_create(from_wallet).generate_data_point() + WalletHistory.get_or_create(to_wallet).generate_data_point() return transaction diff --git a/django_napse/core/models/transactions/transaction.py b/django_napse/core/models/transactions/transaction.py index dac05635..77f8833e 100644 --- a/django_napse/core/models/transactions/transaction.py +++ b/django_napse/core/models/transactions/transaction.py @@ -1,22 +1,34 @@ from django.db import models from django_napse.core.models.transactions.managers import TransactionManager +from django_napse.utils.constants import TRANSACTION_TYPES class Transaction(models.Model): + """A Transaction is a transfer of value between two wallets.""" + from_wallet = models.ForeignKey("Wallet", on_delete=models.CASCADE, related_name="transactions_from") to_wallet = models.ForeignKey("Wallet", on_delete=models.CASCADE, related_name="transactions_to") amount = models.FloatField() ticker = models.CharField(max_length=10) - transaction_type = models.CharField(max_length=20, default="TRANSFER") + transaction_type = models.CharField(max_length=20, default=TRANSACTION_TYPES.TRANSFER) created_at = models.DateTimeField(auto_now_add=True) objects = TransactionManager() - def __str__(self): + def __str__(self) -> str: return f"TRANSACTION: {self.from_wallet.pk} -> {self.to_wallet.pk} ({self.amount=} - {self.ticker=})" - def info(self, verbose=True, beacon=""): + def info(self, beacon: str = "", *, verbose: bool = True) -> str: + """Return a string with the model information. + + Args: + beacon (str, optional): The prefix for each line. Defaults to "". + verbose (bool, optional): Whether to print the string. Defaults to True. + + Returns: + str: The string with the history information. + """ string = "" string += f"{beacon}Transaction {self.pk=}\n" string += f"{beacon}Args:\n" diff --git a/django_napse/core/models/wallets/__init__.py b/django_napse/core/models/wallets/__init__.py index c6dfc529..9744278b 100644 --- a/django_napse/core/models/wallets/__init__.py +++ b/django_napse/core/models/wallets/__init__.py @@ -1,2 +1,6 @@ +from .connection_wallet import * from .currency import * +from .order_wallet import * +from .space_simulation_wallet import * +from .space_wallet import * from .wallet import * diff --git a/django_napse/core/models/wallets/connection_wallet.py b/django_napse/core/models/wallets/connection_wallet.py new file mode 100644 index 00000000..1254e1df --- /dev/null +++ b/django_napse/core/models/wallets/connection_wallet.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING + +from django.db import models + +from django_napse.core.models.wallets.wallet import Wallet + +if TYPE_CHECKING: + from django_napse.core.models.accounts.exchange import ExchangeAccount + from django_napse.core.models.accounts.space import NapseSpace + + +class ConnectionWallet(Wallet): + """A Wallet owned by a Connection.""" + + owner = models.OneToOneField("Connection", on_delete=models.CASCADE, related_name="wallet") + + def __str__(self) -> str: # pragma: no cover + return f"WALLET: {self.pk=}. OWNER: {self.owner=}" + + @property + def space(self) -> "NapseSpace": + """Return the space that owns the wallet.""" + return self.owner.space + + @property + def exchange_account(self) -> "ExchangeAccount": + """Return the exchange account that contains the wallet.""" + return self.space.exchange_account.find() diff --git a/django_napse/core/models/wallets/currency.py b/django_napse/core/models/wallets/currency.py index 1b6731a0..6860f3fa 100644 --- a/django_napse/core/models/wallets/currency.py +++ b/django_napse/core/models/wallets/currency.py @@ -1,19 +1,44 @@ +from typing import TYPE_CHECKING + from django.db import models +from pydantic import BaseModel + +if TYPE_CHECKING: + from django_napse.core.models.wallets.wallet import Wallet + + +class CurrencyPydantic(BaseModel): + """A Pydantic model for the Currency class.""" + + ticker: str + amount: float + mbp: float class Currency(models.Model): + """A Currency contains the amount of a ticker in a wallet, as well as the Mean Buy Price (MBP).""" + wallet = models.ForeignKey("Wallet", on_delete=models.CASCADE, related_name="currencies") mbp = models.FloatField() ticker = models.CharField(max_length=10) amount = models.FloatField(default=0) - class Meta: + class Meta: # noqa unique_together = ("wallet", "ticker") - def __str__(self): # pragma: no cover + def __str__(self) -> str: # pragma: no cover return f"CURRENCY {self.pk}" - def info(self, verbose=True, beacon=""): + def info(self, beacon: str = "", *, verbose: bool = True) -> str: + """Return a string with the history information. + + Args: + beacon (str, optional): The prefix for each line. Defaults to "". + verbose (bool, optional): Whether to print the string. Defaults to True. + + Returns: + str: The string with the history information. + """ string = "" string += f"{beacon}Currency ({self.pk=}):\n" string += f"{beacon}Args:\n" @@ -27,10 +52,12 @@ def info(self, verbose=True, beacon=""): return string @property - def testing(self): + def testing(self) -> bool: + """Return the testing status of the wallet.""" return self.wallet.testing - def copy(self, owner): + def copy(self, owner: "Wallet") -> "Currency": + """Return a copy of the currency with a new owner.""" return Currency.objects.create( wallet=owner, mbp=self.mbp, diff --git a/django_napse/core/models/wallets/order_wallet.py b/django_napse/core/models/wallets/order_wallet.py new file mode 100644 index 00000000..c461c0aa --- /dev/null +++ b/django_napse/core/models/wallets/order_wallet.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING + +from django.db import models + +from django_napse.core.models.wallets.wallet import Wallet + +if TYPE_CHECKING: + from django_napse.core.models.accounts.exchange import ExchangeAccount + + +class OrderWallet(Wallet): + """A Wallet owned by an Order.""" + + owner = models.OneToOneField("Order", on_delete=models.CASCADE, related_name="wallet") + + def __str__(self) -> str: # pragma: no cover + return f"WALLET: {self.pk=}. OWNER: {self.owner=}" + + @property + def exchange_account(self) -> "ExchangeAccount": + """Return the exchange account that contains the wallet.""" + return self.owner.exchange_account.find() diff --git a/django_napse/core/models/wallets/space_simulation_wallet.py b/django_napse/core/models/wallets/space_simulation_wallet.py new file mode 100644 index 00000000..d37ed921 --- /dev/null +++ b/django_napse/core/models/wallets/space_simulation_wallet.py @@ -0,0 +1,47 @@ +from typing import TYPE_CHECKING + +from django.db import models + +from django_napse.core.models.connections.connection import Connection +from django_napse.core.models.wallets.wallet import Wallet + +if TYPE_CHECKING: + from django_napse.core.models.accounts.exchange import ExchangeAccount + from django_napse.core.models.accounts.space import NapseSpace + from django_napse.core.models.bots.bot import Bot + + +class SpaceSimulationWallet(Wallet): + """A Wallet owned by a Space for simulation purposes.""" + + owner = models.OneToOneField("NapseSpace", on_delete=models.CASCADE, related_name="simulation_wallet") + + def __str__(self) -> str: # pragma: no cover + return f"WALLET: {self.pk=}. OWNER: {self.owner=}" + + @property + def testing(self) -> bool: + """Return whether the wallet is in testing mode.""" + return True + + @property + def space(self) -> "NapseSpace": + """Return the space that owns the wallet.""" + return self.owner + + @property + def exchange_account(self) -> "ExchangeAccount": + """Return the exchange account that contains the wallet.""" + return self.space.exchange_account.find() + + def reset(self) -> None: + """Delete all currencies in the wallet.""" + self.currencies.all().delete() + + def connect_to_bot(self, bot: "Bot") -> "Connection": + """Get or create connection to bot.""" + try: + connection = self.connections.get(owner=self, bot=bot) + except Connection.DoesNotExist: + connection = Connection.objects.create(owner=self, bot=bot) + return connection diff --git a/django_napse/core/models/wallets/space_wallet.py b/django_napse/core/models/wallets/space_wallet.py new file mode 100644 index 00000000..5b8f144d --- /dev/null +++ b/django_napse/core/models/wallets/space_wallet.py @@ -0,0 +1,45 @@ +from typing import TYPE_CHECKING + +from django.db import models + +from django_napse.core.models.connections.connection import Connection +from django_napse.core.models.wallets.wallet import Wallet + +if TYPE_CHECKING: + from django_napse.core.models.accounts.exchange import ExchangeAccount + from django_napse.core.models.accounts.space import NapseSpace + from django_napse.core.models.bots.bot import Bot + + +class SpaceWallet(Wallet): + """A Wallet owned by a Space.""" + + owner = models.OneToOneField("NapseSpace", on_delete=models.CASCADE, related_name="wallet") + + def __str__(self) -> str: # pragma: no cover + return f"WALLET: {self.pk=}. OWNER: {self.owner=}" + + @property + def space(self) -> "NapseSpace": + """Return the space that owns the wallet.""" + return self.owner + + @property + def exchange_account(self) -> "ExchangeAccount": + """Return the exchange account that contains the wallet.""" + return self.space.exchange_account.find() + + def connect_to_bot(self, bot: "Bot") -> "Connection": + """Get or create connection to bot. + + Args: + bot (Bot): The bot to connect to. + + Returns: + Connection: The connection to the bot. + """ + try: + connection = self.connections.get(owner=self, bot=bot) + except Connection.DoesNotExist: + connection = Connection.objects.create(owner=self, bot=bot) + return connection diff --git a/django_napse/core/models/wallets/wallet.py b/django_napse/core/models/wallets/wallet.py index 2d245dbb..acb41108 100644 --- a/django_napse/core/models/wallets/wallet.py +++ b/django_napse/core/models/wallets/wallet.py @@ -1,26 +1,53 @@ import time +from datetime import datetime +from typing import TYPE_CHECKING from django.db import models +from pydantic import BaseModel from django_napse.core.models.bots.controller import Controller -from django_napse.core.models.connections.connection import Connection -from django_napse.core.models.wallets.currency import Currency +from django_napse.core.models.wallets.currency import Currency, CurrencyPydantic from django_napse.core.models.wallets.managers import WalletManager from django_napse.utils.errors import WalletError from django_napse.utils.findable_class import FindableClass +if TYPE_CHECKING: + from django_napse.core.models.accounts.exchange import ExchangeAccount + from django_napse.core.models.accounts.space import NapseSpace + + +class WalletPydantic(BaseModel): + """A Pydantic model for the Wallet class.""" + + title: str + testing: bool + locked: bool + created_at: datetime + currencies: dict[str, CurrencyPydantic] + class Wallet(models.Model, FindableClass): + """A Wallet is a collection of currencies.""" + title = models.CharField(max_length=255, default="Wallet") locked = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) objects = WalletManager() - def __str__(self): + def __str__(self) -> str: # pragma: no cover return f"WALLET: {self.pk=}" - def info(self, verbose=True, beacon=""): + def info(self, beacon: str = "", *, verbose: bool = True) -> str: + """Return a string with the history information. + + Args: + beacon (str, optional): The prefix for each line. Defaults to "". + verbose (bool, optional): Whether to print the string. Defaults to True. + + Returns: + str: The string with the history information. + """ self = self.find() string = "" string += f"{beacon}Wallet ({self.pk=}):\t{type(self)}\n" @@ -39,20 +66,38 @@ def info(self, verbose=True, beacon=""): return string @property - def testing(self): + def testing(self) -> bool: + """Return whether the wallet is in testing mode.""" return self.owner.testing @property - def space(self): # pragma: no cover + def space(self) -> "NapseSpace": # pragma: no cover + """Return the space that owns the wallet.""" error_msg = f"space() not implemented by default. Please implement it in {self.__class__}." raise NotImplementedError(error_msg) @property - def exchange_account(self): # pragma: no cover + def exchange_account(self) -> "ExchangeAccount": # pragma: no cover + """Return the exchange account that contains the wallet.""" error_msg = "exchange_account() not implemented by default. Please implement in a subclass of Wallet." raise NotImplementedError(error_msg) - def spend(self, amount: float, ticker: str, recv: int = 3, **kwargs) -> None: + def spend(self, amount: float, ticker: str, recv: int = 3, **kwargs: dict) -> None: + """Spend an amount of a currency from the wallet. + + Args: + amount (float): The amount to spend. + ticker (str): The ticker of the currency to spend. + recv (int, optional): The time to wait before failing the transaction. Defaults to 3. + kwargs (dict): Additional arguments. + + Raises: + WalletError.SpendError: If you try to spend money from the wallet without using the Transactions class. + ValueError: If the amount is negative. + TimeoutError: If the wallet is locked for too long. + WalletError.SpendError: If the currency does not exist in the wallet. + WalletError.SpendError: If there is not enough money in the wallet. + """ if not kwargs.get("force", False): error_msg = "DANGEROUS: You should not use this method outside of select circumstances. Use Transactions instead." raise WalletError.SpendError(error_msg) @@ -89,7 +134,21 @@ def spend(self, amount: float, ticker: str, recv: int = 3, **kwargs) -> None: self.locked = False self.save() - def top_up(self, amount: float, ticker: str, mbp: float | None = None, recv: int = 3, **kwargs) -> None: + def top_up(self, amount: float, ticker: str, mbp: float | None = None, recv: int = 3, **kwargs: dict) -> None: + """Top up the wallet with an amount of a currency. + + Args: + amount (float): The amount to top up. + ticker (str): The ticker of the currency to top up. + mbp (float, optional): The price of the currency. Defaults to None. + recv (int, optional): The time to wait before failing the transaction. Defaults to 3. + kwargs (dict): Additional arguments. + + Raises: + WalletError.TopUpError: If you try to top up the wallet without using the Transactions class. + ValueError: If the amount is negative. + TimeoutError: If the wallet is locked for too long. + """ if not kwargs.get("force", False): error_msg = "DANGEROUS: You should not use this method outside of select circumstances. Use Transactions instead." raise WalletError.TopUpError(error_msg) @@ -121,6 +180,15 @@ def top_up(self, amount: float, ticker: str, mbp: float | None = None, recv: int self.save() def has_funds(self, amount: float, ticker: str) -> bool: + """Check if the wallet has enough funds. + + Args: + amount (float): The amount the wallet should have. + ticker (str): The ticker of the currency. + + Returns: + bool: Whether the wallet has enough funds. + """ try: currency = self.currencies.get(ticker=ticker) except Currency.DoesNotExist: @@ -128,6 +196,14 @@ def has_funds(self, amount: float, ticker: str) -> bool: return currency.amount >= amount def get_amount(self, ticker: str) -> float: + """Return the amount of a currency in the wallet. + + Args: + ticker (str): The ticker of the currency. + + Returns: + float: The amount of the currency in the wallet. + """ try: curr = self.currencies.get(ticker=ticker) except Currency.DoesNotExist: @@ -135,6 +211,11 @@ def get_amount(self, ticker: str) -> float: return curr.amount def value_mbp(self) -> float: + """Return the value of the wallet in USD. + + Returns: + float: The value of the wallet in USD. + """ value = 0 for currency in self.currencies.all(): if currency.amount == 0: @@ -143,6 +224,11 @@ def value_mbp(self) -> float: return value def value_market(self) -> float: + """Return the value of the wallet in USD. + + Returns: + float: The value of the wallet in USD. + """ value = 0 for currency in self.currencies.all(): if currency.amount == 0: @@ -150,96 +236,25 @@ def value_market(self) -> float: value += currency.amount * Controller.get_asset_price(exchange_account=self.exchange_account, base=currency.ticker) return value - def to_dict(self): + def to_dict(self) -> WalletPydantic: + """Return a dictionary representation of the wallet. + + Returns: + dict: The dictionary representation of the wallet. + """ currencies = self.currencies.all() - return { - "title": self.title, - "testing": self.testing, - "locked": self.locked, - "created_at": self.created_at, - "currencies": { - currency.ticker: { - "amount": currency.amount, - "mbp": currency.mbp, - } + + return WalletPydantic( + title=self.title, + testing=self.testing, + locked=self.locked, + created_at=self.created_at, + currencies={ + currency.ticker: CurrencyPydantic( + ticker=currency.ticker, + amount=currency.amount, + mbp=currency.mbp, + ) for currency in currencies }, - } - - -class SpaceWallet(Wallet): - owner = models.OneToOneField("NapseSpace", on_delete=models.CASCADE, related_name="wallet") - - def __str__(self): - return f"WALLET: {self.pk=}\nOWNER: {self.owner=}" - - @property - def space(self): - return self.owner - - @property - def exchange_account(self): - return self.space.exchange_account.find() - - def connect_to_bot(self, bot): - try: - connection = self.connections.get(owner=self, bot=bot) - except Connection.DoesNotExist: - connection = Connection.objects.create(owner=self, bot=bot) - return connection - - -class SpaceSimulationWallet(Wallet): - owner = models.OneToOneField("NapseSpace", on_delete=models.CASCADE, related_name="simulation_wallet") - - def __str__(self): - return f"WALLET: {self.pk=}\nOWNER: {self.owner=}" - - @property - def testing(self): - return True - - @property - def space(self): - return self.owner - - @property - def exchange_account(self): - return self.space.exchange_account.find() - - def reset(self): - self.currencies.all().delete() - - def connect_to_bot(self, bot): - """Get or create connection to bot.""" - try: - connection = self.connections.get(owner=self, bot=bot) - except Connection.DoesNotExist: - connection = Connection.objects.create(owner=self, bot=bot) - return connection - - -class OrderWallet(Wallet): - owner = models.OneToOneField("Order", on_delete=models.CASCADE, related_name="wallet") - - def __str__(self): - return f"WALLET: {self.pk=}\nOWNER: {self.owner=}" - - @property - def exchange_account(self): - return self.owner.exchange_account.find() - - -class ConnectionWallet(Wallet): - owner = models.OneToOneField("Connection", on_delete=models.CASCADE, related_name="wallet") - - def __str__(self): - return f"WALLET: {self.pk=}\nOWNER: {self.owner=}" - - @property - def space(self): - return self.owner.space - - @property - def exchange_account(self): - return self.space.exchange_account.find() + ) diff --git a/django_napse/core/tasks/__init__.py b/django_napse/core/tasks/__init__.py index 8c2deb3d..54ca2350 100644 --- a/django_napse/core/tasks/__init__.py +++ b/django_napse/core/tasks/__init__.py @@ -1,4 +1,4 @@ -from .base_tasks import BaseTask +from .base_task import BaseTask from .candle_collector import CandleCollectorTask from .controller_update import ControllerUpdateTask from .order_process_executor import OrderProcessExecutorTask diff --git a/django_napse/core/tasks/base_tasks.py b/django_napse/core/tasks/base_task.py similarity index 100% rename from django_napse/core/tasks/base_tasks.py rename to django_napse/core/tasks/base_task.py diff --git a/django_napse/core/tasks/candle_collector.py b/django_napse/core/tasks/candle_collector.py index 9f96f111..9b3779eb 100644 --- a/django_napse/core/tasks/candle_collector.py +++ b/django_napse/core/tasks/candle_collector.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django_napse.core.models.bots.controller import Controller -from django_napse.core.tasks.base_tasks import BaseTask +from django_napse.core.tasks.base_task import BaseTask class CandleCollectorTask(BaseTask): diff --git a/django_napse/core/tasks/controller_update.py b/django_napse/core/tasks/controller_update.py index fa3f0827..704b23fa 100644 --- a/django_napse/core/tasks/controller_update.py +++ b/django_napse/core/tasks/controller_update.py @@ -1,5 +1,5 @@ from django_napse.core.models import Controller -from django_napse.core.tasks.base_tasks import BaseTask +from django_napse.core.tasks.base_task import BaseTask class ControllerUpdateTask(BaseTask): diff --git a/django_napse/core/tasks/order_process_executor.py b/django_napse/core/tasks/order_process_executor.py index 779e30fe..7f43e20b 100644 --- a/django_napse/core/tasks/order_process_executor.py +++ b/django_napse/core/tasks/order_process_executor.py @@ -1,5 +1,5 @@ # from django_napse.core.models import Order -from django_napse.core.tasks.base_tasks import BaseTask +from django_napse.core.tasks.base_task import BaseTask class OrderProcessExecutorTask(BaseTask): diff --git a/django_napse/simulations/models/simulations/simulation_queue.py b/django_napse/simulations/models/simulations/simulation_queue.py index 6e1bc09d..d1849019 100644 --- a/django_napse/simulations/models/simulations/simulation_queue.py +++ b/django_napse/simulations/models/simulations/simulation_queue.py @@ -11,6 +11,7 @@ from django_napse.core.models.modifications import ArchitectureModification, ConnectionModification, StrategyModification from django_napse.core.models.orders.order import Order, OrderBatch from django_napse.core.models.transactions.credit import Credit +from django_napse.core.models.wallets.currency import CurrencyPydantic from django_napse.simulations.models.datasets.dataset import Candle, DataSet from django_napse.simulations.models.simulations.managers import SimulationQueueManager from django_napse.utils.constants import EXCHANGE_INTERVALS, ORDER_LEEWAY_PERCENTAGE, ORDER_STATUS, SIDES, SIMULATION_STATUS @@ -93,7 +94,7 @@ def preparation(self, bot, no_db_data): min_interval = interval break - currencies = next(iter(no_db_data["connection_data"].values()))["wallet"]["currencies"] + currencies = next(iter(no_db_data["connection_data"].values()))["wallet"].currencies exchange_controllers = {controller: controller.exchange_controller for controller in bot.controllers.values()} @@ -179,18 +180,25 @@ def append_data( ): current_amounts = {} for controller in candle_data: - current_amounts[f"{controller.base}_amount"] = currencies.get(controller.base, {"amount": 0})["amount"] - current_amounts[f"{controller.quote}_amount"] = currencies.get(controller.quote, {"amount": 0})["amount"] + current_amounts[f"{controller.base}_amount"] = currencies.get( + controller.base, + CurrencyPydantic(ticker=controller.base, amount=0, mbp=0), + ).amount + + current_amounts[f"{controller.quote}_amount"] = currencies.get( + controller.quote, + CurrencyPydantic(ticker=controller.quote, amount=0, mbp=0), + ).amount wallet_value = 0 for ticker, currency in currencies.items(): - amount = currency["amount"] + amount = currency.amount price = 1 if ticker == "USDT" else current_prices[f"{ticker}_price"] wallet_value += amount * price wallet_value_before = 0 for ticker, currency in currencies_before.items(): - amount = currency["amount"] + amount = currency.amount price = 1 if ticker == "USDT" else current_prices[f"{ticker}_price"] wallet_value_before += amount * price @@ -232,7 +240,7 @@ def quick_simulation(self, bot, no_db_data, verbose=True): for order in orders: debited_amount = order["asked_for_amount"] * (1 + ORDER_LEEWAY_PERCENTAGE / 100) if debited_amount > 0: - currencies[order["asked_for_ticker"]]["amount"] -= debited_amount + currencies[order["asked_for_ticker"]].amount -= debited_amount order["debited_amount"] = debited_amount controller = order["controller"] @@ -281,10 +289,10 @@ def quick_simulation(self, bot, no_db_data, verbose=True): currencies=currencies, ) - currencies[controller.base] = currencies.get(controller.base, {"amount": 0, "mbp": 0}) - currencies[controller.quote] = currencies.get(controller.quote, {"amount": 0, "mbp": 0}) - currencies[controller.base]["amount"] += order.exit_amount_base - currencies[controller.quote]["amount"] += order.exit_amount_quote + currencies[controller.base] = currencies.get(controller.base, CurrencyPydantic(ticker=controller.base, amount=0, mbp=0)) + currencies[controller.quote] = currencies.get(controller.quote, CurrencyPydantic(ticker=controller.quote, amount=0, mbp=0)) + currencies[controller.base].amount += order.exit_amount_base + currencies[controller.quote].amount += order.exit_amount_quote all_orders += orders @@ -344,7 +352,7 @@ def irl_simulation(self, bot, no_db_data, verbose=True): amounts = [] tickers = [] extras = {csa.key: [] for csa in next(iter(no_db_data["connection_data"].values()))["connection_specific_args"].values()} - currencies = bot.connections.all()[0].wallet.to_dict()["currencies"] + currencies = bot.connections.all()[0].wallet.to_dict().currencies for date, candle_data in data.items(): currencies_before = deepcopy(currencies) processed_data, current_prices = self.process_candle_data( @@ -375,7 +383,7 @@ def irl_simulation(self, bot, no_db_data, verbose=True): order.process_payout() all_orders += orders - currencies = bot.connections.all()[0].wallet.to_dict()["currencies"] + currencies = bot.connections.all()[0].wallet.to_dict().currencies self.append_data( connection_specific_args=bot.connections.all()[0].to_dict()["connection_specific_args"], candle_data=candle_data, diff --git a/django_napse/utils/constants.py b/django_napse/utils/constants.py index a006a6f3..3c9eeba8 100644 --- a/django_napse/utils/constants.py +++ b/django_napse/utils/constants.py @@ -1,22 +1,23 @@ from enum import EnumMeta, StrEnum +from typing import Iterator class CustomEnumMeta(EnumMeta): """Custom EnumMeta class to allow string comparison for Enums.""" - def __contains__(cls, obj): + def __contains__(self, obj: object) -> bool: """Check if obj is a str in Enum's value or if it's an Enum in Enum's members.""" if isinstance(obj, str): - return any(obj == item for item in cls) + return any(obj == item for item in self) return super().__contains__(obj) - def __iter__(cls): + def __iter__(self) -> Iterator: """Allow to iterate over the Enum's values.""" - return (cls._member_map_[name].value for name in cls._member_names_) + return (self._member_map_[name].value for name in self._member_names_) - def __str__(cls) -> str: + def __str__(self) -> str: """Return the Enum's value.""" - return f"{[cls._member_map_[name].value for name in cls._member_names_]}" + return f"{[self._member_map_[name].value for name in self._member_names_]}" class EXCHANGES(StrEnum, metaclass=CustomEnumMeta): @@ -25,7 +26,7 @@ class EXCHANGES(StrEnum, metaclass=CustomEnumMeta): BINANCE = "BINANCE" -class TRANSACTION_TYPES(StrEnum, metaclass=CustomEnumMeta): +class TRANSACTION_TYPES(StrEnum, metaclass=CustomEnumMeta): # noqa: N801, D101 TRANSFER = "TRANSFER" CONNECTION_DEPOSIT = "CONNECTION_DEPOSIT" CONNECTION_WITHDRAW = "CONNECTION_WITHDRAW" @@ -35,7 +36,7 @@ class TRANSACTION_TYPES(StrEnum, metaclass=CustomEnumMeta): FLEET_REBALANCE = "FLEET_REBALANCE" -class ORDER_STATUS(StrEnum, metaclass=CustomEnumMeta): +class ORDER_STATUS(StrEnum, metaclass=CustomEnumMeta): # noqa: N801, D101 PENDING = "PENDING" READY = "READY" PASSED = "PASSED" @@ -44,36 +45,36 @@ class ORDER_STATUS(StrEnum, metaclass=CustomEnumMeta): FAILED = "FAILED" -class SIDES(StrEnum, metaclass=CustomEnumMeta): +class SIDES(StrEnum, metaclass=CustomEnumMeta): # noqa: D101 BUY = "BUY" SELL = "SELL" KEEP = "KEEP" -class DOWNLOAD_STATUS(StrEnum, metaclass=CustomEnumMeta): +class DOWNLOAD_STATUS(StrEnum, metaclass=CustomEnumMeta): # noqa: N801, D101 IDLE = "IDLE" DOWNLOADING = "DOWNLOADING" -class SIMULATION_STATUS(StrEnum, metaclass=CustomEnumMeta): +class SIMULATION_STATUS(StrEnum, metaclass=CustomEnumMeta): # noqa: N801, D101 IDLE = "IDLE" RUNNING = "RUNNING" -class MODIFICATION_STATUS(StrEnum, metaclass=CustomEnumMeta): +class MODIFICATION_STATUS(StrEnum, metaclass=CustomEnumMeta): # noqa: N801, D101 PENDING = "PENDING" APPLIED = "APPLIED" REJECTED = "REJECTED" -class PLUGIN_CATEGORIES(StrEnum, metaclass=CustomEnumMeta): +class PLUGIN_CATEGORIES(StrEnum, metaclass=CustomEnumMeta): # noqa: N801 """The category for a plugin.""" PRE_ORDER = "PRE_ORDER" POST_ORDER = "POST_ORDER" -class PERMISSION_TYPES(StrEnum, metaclass=CustomEnumMeta): +class PERMISSION_TYPES(StrEnum, metaclass=CustomEnumMeta): # noqa: N801 """The permission type for a key.""" ADMIN = "ADMIN" @@ -81,9 +82,10 @@ class PERMISSION_TYPES(StrEnum, metaclass=CustomEnumMeta): READ_ONLY = "READ_ONLY" -class HISTORY_DATAPOINT_FIELDS(StrEnum, metaclass=CustomEnumMeta): +class HISTORY_DATAPOINT_FIELDS(StrEnum, metaclass=CustomEnumMeta): # noqa: N801 """The different fields for a history data point.""" + WALLET_VALUE = "WALLET_VALUE" AMOUNT = "AMOUNT" ASSET = "ASSET" PRICE = "PRICE" @@ -92,6 +94,20 @@ class HISTORY_DATAPOINT_FIELDS(StrEnum, metaclass=CustomEnumMeta): VALUE = "VALUE" +class HISTORY_DATAPOINT_FIELDS_WILDCARDS(StrEnum, metaclass=CustomEnumMeta): # noqa: N801 + """The different fields for a history data point.""" + + AMOUNT = "AMOUNT_" + + +for wildcard in HISTORY_DATAPOINT_FIELDS_WILDCARDS: + duplicate = False + for field in HISTORY_DATAPOINT_FIELDS: + if field.startswith(wildcard): + duplicate = True + error_msg = f"Duplicate field {field} for wildcard {wildcard}" + raise ValueError(error_msg) + ORDER_LEEWAY_PERCENTAGE = 10 DEFAULT_TAX = { diff --git a/django_napse/utils/usefull_functions.py b/django_napse/utils/usefull_functions.py index 89fd6a11..2255457c 100644 --- a/django_napse/utils/usefull_functions.py +++ b/django_napse/utils/usefull_functions.py @@ -5,10 +5,12 @@ def calculate_mbp(value: str, current_value: float, order, currencies: dict) -> float: + from django_napse.core.models.wallets.currency import CurrencyPydantic + ticker, price = value.split("|") price = float(price) - current_amount = currencies.get(ticker, {"amount": 0})["amount"] + current_amount = currencies.get(ticker, CurrencyPydantic(ticker=ticker, amount=0, mbp=0)).amount current_value = current_value if current_value is not None else 0 received_quote = order.debited_amount - order.exit_amount_quote return (current_amount * current_value + received_quote) / (received_quote / price + current_amount) diff --git a/pyproject.toml b/pyproject.toml index d45e7bd0..4b265cd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ lint.select = ["F", "E", "W", "C90", "I", "N", "D", "YTT", "ANN", "S", "BLE", "FBT", "B", "A", "C", "C4", "DTZ","T10","DJ", "EM", "FA", "ISC", "ICN", "G", "INP", "PIE", "PYI", "Q", "RSE", "RET", "SLF", "SLOT", "SIM", "TID", "TCH", "INT", - "ARG", "PTH", "TD", "FIX", "ERA", "PD", "PL", "TRY", "FLY", "NPY", "AIR", "PERF", "LOG", "RUF", + "ARG", "PTH", "TD", "FIX", "ERA", "PD", "PL", "TRY", "FLY", "NPY", "AIR", "PERF", "LOG", "RUF", "COM" ] lint.ignore = [ # Ruff formatter recommendations @@ -19,7 +19,8 @@ lint.ignore = [ "COM819", "ISC001", "ISC002", - # Depreciated + "PLR0913", # Less than 5 arguments + # Deprecated "ANN101", "ANN102", # Other diff --git a/requirements/core.txt b/requirements/core.txt index f9fa2da0..541623e1 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -10,3 +10,4 @@ celery==5.3.6 redis==5.0.2 python-binance==1.0.19 # https://github.com/sammchardy/python-binance +pydantic==2.6.2 # https://github.com/pydantic/pydantic \ No newline at end of file diff --git a/tests/django_tests/db/histories/test_history.py b/tests/django_tests/db/histories/test_history.py index 5e56b990..1f65e23e 100644 --- a/tests/django_tests/db/histories/test_history.py +++ b/tests/django_tests/db/histories/test_history.py @@ -17,11 +17,11 @@ def simple_create(self): def test_data_points(self): info = [ [ - {"key": HISTORY_DATAPOINT_FIELDS.AMOUNT, "value": "1", "target_type": "float"}, - {"key": HISTORY_DATAPOINT_FIELDS.ASSET, "value": "BTC", "target_type": "str"}, - {"key": HISTORY_DATAPOINT_FIELDS.PRICE, "value": "123", "target_type": "float"}, - {"key": HISTORY_DATAPOINT_FIELDS.MBP, "value": "100", "target_type": "float"}, - {"key": HISTORY_DATAPOINT_FIELDS.LBO, "value": "7", "target_type": "float"}, + {"key": HISTORY_DATAPOINT_FIELDS.AMOUNT, "value": 1}, + {"key": HISTORY_DATAPOINT_FIELDS.ASSET, "value": "BTC"}, + {"key": HISTORY_DATAPOINT_FIELDS.PRICE, "value": 123.0}, + {"key": HISTORY_DATAPOINT_FIELDS.MBP, "value": 100}, + {"key": HISTORY_DATAPOINT_FIELDS.LBO, "value": 7}, ] for _ in range(100) ] @@ -31,13 +31,23 @@ def test_data_points(self): for field_info in data_point_info: HistoryDataPointField.objects.create(history_data_point=data_point, **field_info) self.assertEqual(history.data_points.count(), len(info)) + target_types = { + HISTORY_DATAPOINT_FIELDS.AMOUNT: "int", + HISTORY_DATAPOINT_FIELDS.ASSET: "str", + HISTORY_DATAPOINT_FIELDS.PRICE: "float", + HISTORY_DATAPOINT_FIELDS.MBP: "int", + HISTORY_DATAPOINT_FIELDS.LBO: "int", + } + for data_point in history.data_points.all(): + for field in data_point.fields.all(): + self.assertEqual(field.target_type, target_types[field.key]) def test_invalid_data_point(self): history = self.simple_create() data_point = HistoryDataPoint.objects.create(history=history) with self.assertRaises(HistoryError.InvalidDataPointFieldKey): - HistoryDataPointField.objects.create(history_data_point=data_point, key="INVALID", value="1", target_type="float") + HistoryDataPointField.objects.create(history_data_point=data_point, key="INVALID", value=1) class HistoryBINANCETestCase(HistoryTestCase, ModelTestCase): diff --git a/tests/django_tests/db/histories/test_special_history.py b/tests/django_tests/db/histories/test_space_history.py similarity index 85% rename from tests/django_tests/db/histories/test_special_history.py rename to tests/django_tests/db/histories/test_space_history.py index 6a802857..48276a1c 100644 --- a/tests/django_tests/db/histories/test_special_history.py +++ b/tests/django_tests/db/histories/test_space_history.py @@ -6,7 +6,7 @@ """ -class SpecialHistoryTestCase: +class SpaceHistoryTestCase: model = SpaceHistory def simple_create(self): @@ -21,5 +21,5 @@ def test_get_or_create(self): self.assertEqual(history1, history2) -class SpecialHistoryBINANCETestCase(SpecialHistoryTestCase, ModelTestCase): +class SpaceHistoryBINANCETestCase(SpaceHistoryTestCase, ModelTestCase): exchange = "BINANCE" diff --git a/tests/django_tests/db/histories/test_wallet_history.py b/tests/django_tests/db/histories/test_wallet_history.py new file mode 100644 index 00000000..05f33e0f --- /dev/null +++ b/tests/django_tests/db/histories/test_wallet_history.py @@ -0,0 +1,25 @@ +from django_napse.core.models.histories.wallet import WalletHistory +from django_napse.core.models.wallets.currency import Currency +from django_napse.utils.model_test_case import ModelTestCase + +""" +python tests/test_app/manage.py test tests.django_tests.db.histories.test_wallet_history -v2 --keepdb --parallel +""" + + +class WalletHistoryTestCase: + model = WalletHistory + + def simple_create(self): + wallet = self.space.wallet + Currency.objects.create(wallet=wallet, ticker="BTC", amount=1, mbp=20000) + Currency.objects.create(wallet=wallet, ticker="ETH", amount=0, mbp=1000) + return WalletHistory.objects.create(owner=wallet) + + def test_generate_data_points(self): + history = self.simple_create() + history.generate_data_point() + + +class WalletHistoryBINANCETestCase(WalletHistoryTestCase, ModelTestCase): + exchange = "BINANCE" diff --git a/tests/django_tests/db/wallets/test_wallet.py b/tests/django_tests/db/wallets/test_wallet.py index a4d9bb74..a850025d 100644 --- a/tests/django_tests/db/wallets/test_wallet.py +++ b/tests/django_tests/db/wallets/test_wallet.py @@ -1,7 +1,7 @@ from django.db.utils import IntegrityError from django.test import TestCase -from django_napse.core.models import Currency, SpaceWallet, Wallet +from django_napse.core.models import Currency, CurrencyPydantic, SpaceWallet, Wallet, WalletPydantic from django_napse.utils.errors import WalletError from django_napse.utils.model_test_case import ModelTestCase @@ -117,13 +117,16 @@ def test_to_dict(self): Currency.objects.create(wallet=wallet, ticker="USDT", amount=1, mbp=1) self.assertEqual( wallet.to_dict(), - { - "title": "Wallet for space Test Space", - "testing": True, - "locked": False, - "created_at": wallet.created_at, - "currencies": {"BTC": {"amount": 1.0, "mbp": 20000.0}, "USDT": {"amount": 1.0, "mbp": 1.0}}, - }, + WalletPydantic( + title="Wallet for space Test Space", + testing=True, + locked=False, + created_at=wallet.created_at, + currencies={ + "BTC": CurrencyPydantic(ticker="BTC", amount=1.0, mbp=20000.0), + "USDT": CurrencyPydantic(ticker="USDT", amount=1.0, mbp=1.0), + }, + ), )