diff --git a/.github/workflows/check_no_changes_workflows.yml b/.github/workflows/check_no_changes_workflows.yml index 7a4f43fa..6ec08606 100644 --- a/.github/workflows/check_no_changes_workflows.yml +++ b/.github/workflows/check_no_changes_workflows.yml @@ -4,6 +4,7 @@ on: pull_request_target: paths: - .github/workflows/* + - pyproject.toml jobs: check-changes: diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index fc100236..74e05b1d 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -50,7 +50,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.ref }} - name: Set up python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" architecture: "x64" diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index a283fb59..f59acf49 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -46,11 +46,11 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.11 - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: key: mkdocs-material-${{ env.cache_id }} path: .cache diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 7370eeb8..1ce10796 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.11 @@ -28,6 +28,8 @@ jobs: echo "LATEST_TAG=$tag" >> $GITHUB_ENV - name: Update version in setup.py + env: + LATEST_TAG: ${{ env.LATEST_TAG }} run: sed -i "s/{{VERSION}}/${{ env.LATEST_TAG }}/g" setup.py - name: Install pypa/build diff --git a/Makefile b/Makefile index 44d25284..fa4d38e6 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ SHELL := /bin/bash - +.PHONY: setup OS := $(shell uname) all: setup-testing-environment makemigrations migrate runserver @@ -10,7 +10,7 @@ ifeq ($(OS),Darwin) # Mac OS X else ifeq ($(OS),Linux) ./setup/setup-unix.sh else - ./setup/setup-windows.sh + powershell -NoProfile -ExecutionPolicy Bypass -File ".\setup\setup-windows.ps1" endif setup-testing-environment: @@ -37,6 +37,9 @@ clean: celery: source .venv/bin/activate && watchfiles --filter python celery.__main__.main --args "-A tests.test_app worker --beat -l INFO" +shell: + source .venv/bin/activate && python tests/test_app/manage.py shell + test: source .venv/bin/activate && python tests/test_app/manage.py test -v2 --keepdb --parallel diff --git a/branding/napse_black.svg b/branding/napse_black.svg deleted file mode 100644 index 5271647d..00000000 --- a/branding/napse_black.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/branding/napse_white.svg b/branding/napse_white.svg deleted file mode 100644 index 54f41f8d..00000000 --- a/branding/napse_white.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/django_napse/api/api_urls.py b/django_napse/api/api_urls.py index b000a2e0..5b5da3fa 100644 --- a/django_napse/api/api_urls.py +++ b/django_napse/api/api_urls.py @@ -24,10 +24,11 @@ def build_main_router() -> DefaultRouter: api_dir = Path(__file__).parent api_modules_folders_names = [folder.name for folder in api_dir.iterdir() if folder.is_dir() and not folder.name.startswith("_")] for module_name in api_modules_folders_names: + # print(f"module name: {module_name}") try: module: ModuleType = import_module(f"django_napse.api.{module_name}.views") except (ImportError, ModuleNotFoundError) as error: # noqa: F841 - # print(f"Could not import module {module_name}") + # print(f"Could not import module {module_name} ({type(error)})") # print(error) continue for obj in vars(module).values(): diff --git a/django_napse/api/bots/serializers/__init__.py b/django_napse/api/bots/serializers/__init__.py index dabd5841..1ff1ccc2 100644 --- a/django_napse/api/bots/serializers/__init__.py +++ b/django_napse/api/bots/serializers/__init__.py @@ -1,5 +1,5 @@ from .architecture_serializer import ArchitectureSerializer -from .bot_serializers import BotSerializer +from .bot_serializers import BotDetailSerializer, BotSerializer from .config_serializer import ConfigSerializer from .plugin_serializer import PluginSerializer from .strategy_serializer import StrategySerializer diff --git a/django_napse/api/bots/serializers/bot_serializers.py b/django_napse/api/bots/serializers/bot_serializers.py index 33670e51..d391b9f5 100644 --- a/django_napse/api/bots/serializers/bot_serializers.py +++ b/django_napse/api/bots/serializers/bot_serializers.py @@ -1,17 +1,160 @@ from rest_framework import serializers +from rest_framework.fields import empty -from django_napse.api.bots.serializers.strategy_serializer import StrategySerializer -from django_napse.core.models import Bot +from django_napse.api.orders.serializers import OrderSerializer +from django_napse.api.wallets.serializers import WalletSerializer +from django_napse.core.models import Bot, BotHistory, ConnectionWallet, Order class BotSerializer(serializers.ModelSerializer): - strategy = StrategySerializer() + delta = serializers.SerializerMethodField(read_only=True) + space = serializers.SerializerMethodField(read_only=True) + exchange_account = serializers.SerializerMethodField(read_only=True) + fleet = serializers.CharField(source="fleet.uuid", read_only=True) class Meta: model = Bot fields = [ "name", "uuid", - "strategy", + "value", + "delta", + "fleet", + "space", + "exchange_account", ] - read_only_fields = fields + read_only_fields = [ + "uuid", + "value", + "delta", + "space", + ] + + def __init__(self, instance=None, data=empty, space=None, **kwargs): + self.space = space + super().__init__(instance=instance, data=data, **kwargs) + + def get_delta(self, instance) -> float: + """Delta on the last 30 days.""" + try: + history = BotHistory.objects.get(owner=instance) + except BotHistory.DoesNotExist: + return 0 + return history.get_delta() + + def get_space(self, instance): + if self.space is None: + return None + return self.space.uuid + + def get_exchange_account(self, instance): + if not instance.is_in_fleet and not instance.is_in_simulation: + return None + return instance.exchange_account.uuid + + +class BotDetailSerializer(serializers.ModelSerializer): + delta = serializers.SerializerMethodField(read_only=True) + space = serializers.SerializerMethodField(read_only=True) + exchange_account = serializers.CharField(source="exchange_account.uuid", read_only=True) + fleet = serializers.CharField(source="fleet.uuid", read_only=True) + + statistics = serializers.SerializerMethodField(read_only=True) + wallet = serializers.SerializerMethodField(read_only=True) + orders = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = Bot + fields = [ + "name", + "uuid", + "value", + "delta", + "statistics", + "fleet", + "space", + "exchange_account", + "wallet", + "orders", + ] + read_only_fields = [ + "uuid", + "value", + "delta", + "statistics", + "space", + "wallet", + "orders", + ] + + def __init__(self, instance=None, data=empty, space=None, **kwargs): + self.space = space + super().__init__(instance=instance, data=data, **kwargs) + + def get_delta(self, instance) -> float: + """Delta on the last 30 days.""" + try: + history = BotHistory.objects.get(owner=instance) + except BotHistory.DoesNotExist: + return 0 + return history.get_delta() + + def get_space(self, instance): + if self.space is None: + return None + return self.space.uuid + + def get_statistics(self, instance): + return instance.get_stats(space=self.space) + + def get_wallet(self, instance): + def _search_ticker(ticker: str, merged_wallet) -> int | None: + """Return the index of the currency in the list if found, None otherwise.""" + for i, currency in enumerate(merged_wallet): + if currency.get("ticker").ticker == ticker: + return i + return None + + def _update_merged_wallet(index: int, currency: str, merged_wallet) -> None: + """Update the merged wallet with the new currency.""" + if index is None: + merged_wallet.append( + { + "ticker": currency.ticker, + "amount": currency.amount, + "mbp": currency.mbp, + }, + ) + else: + merged_wallet[index]["amount"] += currency.amount + + if self.space is not None: + wallet = ConnectionWallet.objects.get( + owner__owner=self.space.wallet, + owner__bot=instance, + ) + return WalletSerializer(wallet).data + + wallets = [connection.wallet for connection in instance.connections.all()] + merged_wallet: list[dict[str, str | float]] = [] + + for wallet in wallets: + for currency in wallet.currencies.all(): + index = _search_ticker(currency.ticker, merged_wallet) + _update_merged_wallet(index, currency, merged_wallet) + + return merged_wallet + + def get_orders(self, instance): + if self.space is None: + return OrderSerializer( + Order.objects.filter(connection__bot=instance), + many=True, + ).data + return OrderSerializer( + Order.objects.filter( + connection__bot=instance, + connection__owner=self.space.wallet, + ), + many=True, + ).data diff --git a/django_napse/api/bots/views/bot_view.py b/django_napse/api/bots/views/bot_view.py index a432c89a..495a6e5c 100644 --- a/django_napse/api/bots/views/bot_view.py +++ b/django_napse/api/bots/views/bot_view.py @@ -1,33 +1,132 @@ +from django.db.models import QuerySet from rest_framework import status from rest_framework.response import Response from rest_framework_api_key.permissions import HasAPIKey -from django_napse.api.bots.serializers.bot_serializers import BotSerializer +from django_napse.api.bots.serializers.bot_serializers import BotDetailSerializer, BotSerializer from django_napse.api.custom_permissions import HasSpace from django_napse.api.custom_viewset import CustomViewSet -from django_napse.core.models import Bot +from django_napse.core.models import Bot, NapseSpace +from django_napse.utils.errors import APIError class BotView(CustomViewSet): permission_classes = [HasAPIKey, HasSpace] serializer_class = BotSerializer - def get_queryset(self): - return Bot.objects.all() + def get_queryset(self) -> list[QuerySet[Bot]] | dict[str, QuerySet[Bot]]: + """Return bot queryset. + + Can return + Free bots across all available spaces + Bots with connections without space containerization + Bots with connections with a specific space containerization + Bots with connections with space containerization + + Raises: + ValueError: Not space_containers mode is only available for master key. + ValueError: Space not found. + """ + api_key = self.get_api_key(self.request) + spaces = NapseSpace.objects.all() if api_key.is_master_key else [permission.space for permission in api_key.permissions.all()] + + # Free bots across all available spaces + if self.request.query_params.get("free", False): + # Exchange account containerization + request_space = self.get_space(self.request) + if request_space is None: + raise APIError.MissingSpace() + spaces = [space for space in spaces if space.exchange_account == request_space.exchange_account] + # Cross space free bots + return [bot for bot in Bot.objects.filter(strategy__config__space__in=spaces) if bot.is_free] + + # Bots with connections without space containerization + if not self.request.query_params.get("space_containers", True): + if not api_key.is_master_key: + error_msg: str = "Not space_containers mode is only available for master key." + raise ValueError(error_msg) + return Bot.objects.exclude(connections__isnull=True) + + # Filter by specific space + space_uuid = self.request.query_params.get("space", None) + if space_uuid is not None: + for space in spaces: + if space.uuid == space_uuid: + spaces = [space] + break + else: + error_msg: str = "Space not found." + raise ValueError(error_msg) + + # Space container mode + bots_per_space: dict[str, QuerySet[Bot]] = {} + for space in spaces: + bots_per_space[space] = space.bots.exclude(connections__isnull=True) + return bots_per_space def get_serializer_class(self, *args, **kwargs): actions: dict = { "list": BotSerializer, + "retrieve": BotDetailSerializer, } result = actions.get(self.action) return result if result else super().get_serializer_class() + def get_permissions(self): + match self.action: + case "list" | "create": + return [HasAPIKey()] + case _: + return super().get_permissions() + + def get_object(self): + uuid = self.kwargs.get("pk", None) + if uuid is None: + return super().get_object() + return Bot.objects.get(uuid=uuid) + + def _get_boolean_query_param(self, param: str) -> bool | None: + """Return None if a boolean cannot be found.""" + if isinstance(param, bool): + return param + + if not isinstance(param, str): + return None + + match param.lower(): + case "true" | "1": + return True + case "false" | "0": + return False + case _: + return None + def list(self, request): - serializer = self.get_serializer(self.get_queryset(), many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + """Return a list of bots. - def retrieve(self, request, pk=None): - return Response(status=status.HTTP_501_NOT_IMPLEMENTED) + Warning: space_containers can lead to undesirable behaviour. + """ + try: + queryset = self.get_queryset() + except ValueError as error: + return Response({"detail": str(error)}, status=status.HTTP_400_BAD_REQUEST) + + if isinstance(queryset, list): + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) - def create(self, request, *args, **kwargs): - return Response(status=status.HTTP_501_NOT_IMPLEMENTED) + if isinstance(queryset, dict): + serialized_bots: QuerySet[Bot] = [] + for space, query in queryset.items(): + serializer = self.get_serializer(query, many=True, space=space) + if serializer.data != []: + serialized_bots += serializer.data + return Response(serialized_bots, status=status.HTTP_200_OK) + + return Response(status=status.HTTP_400_BAD_REQUEST) + + def retrieve(self, request, pk=None): + instance = self.get_object() + space = self.get_space(request) + serializer = self.get_serializer(instance, space=space) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/django_napse/api/custom_viewset.py b/django_napse/api/custom_viewset.py index c24f494a..a4c76275 100644 --- a/django_napse/api/custom_viewset.py +++ b/django_napse/api/custom_viewset.py @@ -14,5 +14,8 @@ def get_api_key(self, request): except KeyError as e: raise APIError.NoAPIKey() from e - def space(self, request): - return NapseSpace.objects.get(uuid=request.query_params["space"]) + def get_space(self, request) -> NapseSpace | None: + try: + return NapseSpace.objects.get(uuid=request.query_params["space"]) + except NapseSpace.DoesNotExist: + return None diff --git a/django_napse/api/fleets/serializers/__init__.py b/django_napse/api/fleets/serializers/__init__.py index deafb8a4..cd055980 100644 --- a/django_napse/api/fleets/serializers/__init__.py +++ b/django_napse/api/fleets/serializers/__init__.py @@ -1,2 +1,2 @@ -from .cluster_serialisers import ClusterSerializer -from .fleet_serializers import FleetDetailSerializer, FleetSerializer +from .cluster_serialisers import ClusterFormatterSerializer +from .fleet_serializers import FleetDetailSerializer, FleetMoneyFlowSerializer, FleetSerializer diff --git a/django_napse/api/fleets/serializers/cluster_serialisers.py b/django_napse/api/fleets/serializers/cluster_serialisers.py index 2508c7c1..33915ceb 100644 --- a/django_napse/api/fleets/serializers/cluster_serialisers.py +++ b/django_napse/api/fleets/serializers/cluster_serialisers.py @@ -1,10 +1,10 @@ from rest_framework import serializers from django_napse.api.bots.serializers import BotSerializer -from django_napse.core.models import Cluster +from django_napse.core.models import Bot, Cluster -class ClusterSerializer(serializers.ModelSerializer): +class ClusterSerializerV1(serializers.ModelSerializer): template_bot = BotSerializer() class Meta: @@ -15,3 +15,25 @@ class Meta: "breakpoint", "autoscale", ] + + +class ClusterFormatterSerializer(serializers.Serializer): + """Format cluster dictionnary for fleet creation.""" + + template_bot = serializers.UUIDField(required=True) + share = serializers.FloatField(required=True) + breakpoint = serializers.IntegerField(required=True) + autoscale = serializers.BooleanField(required=True) + + def validate(self, data): + data = super().validate(data) + try: + bot = Bot.objects.get(uuid=data.pop("template_bot")) + except Bot.DoesNotExist: + error_msg: str = "Template bot does not exist." + raise serializers.ValidationError(error_msg) from None + data["template_bot"] = bot + return data + + def create(self, validated_data): + return super().create(validated_data) diff --git a/django_napse/api/fleets/serializers/fleet_serializers.py b/django_napse/api/fleets/serializers/fleet_serializers.py index c0739bfe..ac4ad7ec 100644 --- a/django_napse/api/fleets/serializers/fleet_serializers.py +++ b/django_napse/api/fleets/serializers/fleet_serializers.py @@ -1,47 +1,89 @@ from rest_framework import serializers -from rest_framework.fields import empty from django_napse.api.bots.serializers import BotSerializer -from django_napse.api.fleets.serializers.cluster_serialisers import ClusterSerializer -from django_napse.core.models import ConnectionWallet, Fleet +from django_napse.api.fleets.serializers.cluster_serialisers import ClusterFormatterSerializer +from django_napse.core.models import ConnectionWallet, Fleet, FleetHistory, NapseSpace, SpaceWallet class FleetSerializer(serializers.ModelSerializer): value = serializers.SerializerMethodField(read_only=True) bot_count = serializers.SerializerMethodField(read_only=True) - clusters = ClusterSerializer( + clusters = ClusterFormatterSerializer( write_only=True, many=True, required=True, ) + space = serializers.UUIDField(write_only=True, required=True) + delta = serializers.SerializerMethodField(read_only=True) + exchange_account = serializers.SerializerMethodField(read_only=True) class Meta: model = Fleet fields = [ "name", + # write-only + "clusters", + "space", # read-only "uuid", "value", "bot_count", - # write-only - "clusters", + "delta", + "exchange_account", ] read_only_fields = [ "uuid", + "exchange_account", ] - def __init__(self, instance=None, data=empty, space=None, **kwargs): + def __init__(self, instance=None, data=serializers.empty, space=None, **kwargs): self.space = space super().__init__(instance=instance, data=data, **kwargs) def get_value(self, instance): - return instance.value(space=self.space) + if self.space is None: + return instance.value + return instance.space_frame_value(space=self.space) def get_bot_count(self, instance): - return instance.bots.count() + return instance.bot_count(space=self.space) + + def get_delta(self, instance) -> float: + """Delta on the last 30 days.""" + try: + history = FleetHistory.objects.get(owner=instance) + except FleetHistory.DoesNotExist: + return 0 + return history.get_delta() + + def get_exchange_account(self, instance): + return instance.exchange_account.uuid + + def validate(self, attrs): + data = super().validate(attrs) + + try: + self.space = NapseSpace.objects.get(uuid=attrs.pop("space")) + print("get space", self.space) + except NapseSpace.DoesNotExist: + error_msg: str = "Space does not exist." + raise serializers.ValidationError(error_msg) from None + + data["exchange_account"] = self.space.exchange_account + return data + + def to_representation(self, instance): + data = super().to_representation(instance) + if self.space is not None: + data["space"] = self.space.uuid + + return data + def create(self, validated_data): + return Fleet.objects.create(**validated_data) -class FleetDetailSerializer(serializers.Serializer): + +class FleetDetailSerializer(serializers.ModelSerializer): wallet = serializers.SerializerMethodField(read_only=True) statistics = serializers.SerializerMethodField(read_only=True) bots = BotSerializer(many=True, read_only=True) @@ -55,9 +97,10 @@ class Meta: "statistics", "wallet", "bots", + "exchange_account", ] - def __init__(self, instance=None, data=empty, space=None, **kwargs): + def __init__(self, instance=None, data=serializers.empty, space=None, **kwargs): self.space = space super().__init__(instance=instance, data=data, **kwargs) @@ -65,6 +108,7 @@ def get_statistics(self, instance): return instance.get_stats() def get_wallet(self, instance): + # Method not tested, high chance of being buggy def _search_ticker(ticker: str, merged_wallet) -> int | None: """Return the index of the currency in the list if found, None otherwise.""" for i, currency in enumerate(merged_wallet): @@ -85,6 +129,8 @@ def _update_merged_wallet(index: int, currency: str, merged_wallet) -> None: else: merged_wallet[index]["amount"] += currency.amount + if self.space is None: + return None wallets = ConnectionWallet.objects.filter(owner__owner=self.space.wallet, owner__bot__in=instance.bots) merged_wallet: list[dict[str, str | float]] = [] @@ -95,6 +141,65 @@ def _update_merged_wallet(index: int, currency: str, merged_wallet) -> None: return merged_wallet + def to_representation(self, instance): + data = super().to_representation(instance) + if self.space is not None: + data["space"] = self.space.uuid + return data + def save(self, **kwargs): error_msg: str = "Impossible to update a fleet through the detail serializer." - raise ValueError(error_msg) + raise serializers.ValidationError(error_msg) + + +class FleetMoneyFlowSerializer(serializers.Serializer): + amount = serializers.FloatField(write_only=True, required=True) + ticker = serializers.CharField(write_only=True, required=True) + + def __init__(self, side, instance=None, data=serializers.empty, space=None, **kwargs): + self.side = side + self.space = space + super().__init__(instance=instance, data=data, **kwargs) + + def _invest_validate(self, attrs): + if self.space.testing: + space_wallet = self.space.wallet + try: + currency: SpaceWallet = space_wallet.currencies.get(ticker=attrs["ticker"]) + except SpaceWallet.DoesNotExist: + error_msg: str = f"{attrs['ticker']} does not exist in space ({self.space.name})." + raise serializers.ValidationError(error_msg) from None + + if currency.amount < attrs["amount"]: + error_msg: str = f"Not enough {currency.ticker} in the wallet." + raise serializers.ValidationError(error_msg) + + return attrs + + error_msg: str = "Real invest is not implemented yet." + raise NotImplementedError(error_msg) + + def _withdraw_validate(self, attrs): + if self.space.testing: + error_msg: str = "Withdraw is not implemented yet." + raise NotImplementedError(error_msg) + + error_msg: str = "Real withdraw is not implemented yet." + raise NotImplementedError(error_msg) + + def validate(self, attrs): + """Check if the wallet has enough money to invest.""" + match self.side.upper(): + case "INVEST": + return self._invest_validate(attrs) + case "WITHDRAW": + return self._withdraw_validate(attrs) + case _: + error_msg: str = "Invalid side." + raise ValueError(error_msg) + + def save(self, **kwargs): + """Make the transaction.""" + amount = self.validated_data["amount"] + ticker = self.validated_data["ticker"] + self.instance.invest(self.space, amount, ticker) diff --git a/django_napse/api/fleets/views/fleet_view.py b/django_napse/api/fleets/views/fleet_view.py index 15afa269..dfbc6bbb 100644 --- a/django_napse/api/fleets/views/fleet_view.py +++ b/django_napse/api/fleets/views/fleet_view.py @@ -1,14 +1,22 @@ from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework_api_key.permissions import HasAPIKey from django_napse.api.custom_permissions import HasSpace from django_napse.api.custom_viewset import CustomViewSet -from django_napse.api.fleets.serializers import FleetDetailSerializer, FleetSerializer +from django_napse.api.fleets.serializers import FleetDetailSerializer, FleetMoneyFlowSerializer, FleetSerializer from django_napse.core.models import Fleet, NapseSpace class FleetView(CustomViewSet): + """View of a fleet. + + Query parameters: + space: uuid of the space to filter on. + space_containers (bool): If True, list endpoint returns fleets for each space (default = True). + """ + permission_classes = [HasAPIKey, HasSpace] serializer_class = FleetSerializer @@ -16,14 +24,16 @@ def get_queryset(self): space_uuid = self.request.query_params.get("space", None) if space_uuid is None: return Fleet.objects.all() - self.space = NapseSpace.objects.get(uuid=space_uuid) - return self.space.fleets + self.get_space = NapseSpace.objects.get(uuid=space_uuid) + return self.get_space.fleets - def get_serialiser_class(self, *args, **kwargs): + def get_serializer_class(self, *args, **kwargs): actions: dict = { "list": FleetSerializer, "retrieve": FleetDetailSerializer, "create": FleetSerializer, + "invest": FleetMoneyFlowSerializer, + "withdraw": FleetMoneyFlowSerializer, } result = actions.get(self.action) return result if result else super().get_serializer_class() @@ -35,16 +45,92 @@ def get_permissions(self): case _: return super().get_permissions() + def get_object(self): + uuid = self.kwargs.get("pk", None) + if uuid is None: + return super().get_object() + return Fleet.objects.get(uuid=uuid) + + def _get_boolean_query_param(self, param: str) -> bool | None: + """Return None if a boolean cannot be found.""" + if isinstance(param, bool): + return param + + if not isinstance(param, str): + return None + + match param.lower(): + case "true" | "1": + return True + case "false" | "0": + return False + case _: + return None + def list(self, request): - serializer = self.serializer_class(self.get_queryset(), many=True, space=self.space) - return Response(serializer.data, status=status.HTTP_200_OK) + space_containers = self._get_boolean_query_param(request.query_params.get("space_containers", True)) + space_uuid = request.query_params.get("space", None) + api_key = self.get_api_key(request) + + if not space_containers and api_key.is_master_key: + # Not space_containers mode is only available for master key + serializer = self.get_serializer(self.get_queryset(), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Get spaces from API key + spaces = NapseSpace.objects.all() if api_key.is_master_key else [permission.space for permission in api_key.permissions.all()] + # Filter by specific space + if space_uuid is not None: + space = NapseSpace.objects.get(uuid=space_uuid) + if space not in spaces: + return Response(status=status.HTTP_403_FORBIDDEN) + spaces = [space] + + # Fleet list + fleets = [] + for space in spaces: + serializer = self.get_serializer(space.fleets, many=True, space=space) + if serializer.data != []: + fleets += serializer.data + return Response(fleets, status=status.HTTP_200_OK) def retrieve(self, request, pk=None): - pass + instance = self.get_object() + space = self.get_space(request) + serializer = self.get_serializer(instance, space=space) + return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, *args, **kwargs): - return Response(status=status.HTTP_501_NOT_IMPLEMENTED) - # serializer = self.serializer_class(data=request.data, space=self.space) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + fleet = serializer.save() + space = serializer.space + fleet.invest(space, 0, "USDT") + return Response(serializer.data, status=status.HTTP_201_CREATED) def delete(self): return Response(status=status.HTTP_501_NOT_IMPLEMENTED) + + @action(detail=True, methods=["post"]) + def invest(self, request, pk=None): + fleet = self.get_object() + space = self.get_space(request) + + if not space.testing: + error_msg: str = "Investing in real is not allowed yet." + return Response(error_msg, status=status.HTTP_403_FORBIDDEN) + + serializer = self.get_serializer( + data=request.data, + instance=fleet, + space=space, + side="INVEST", + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(status=status.HTTP_200_OK) + + @action(detail=True, methods=["post"]) + def withdraw(self, request, pk=None): + return Response(status=status.HTTP_501_NOT_IMPLEMENTED) diff --git a/django_napse/api/keys/views/key_view.py b/django_napse/api/keys/views/key_view.py index 8e5c19c8..458c35ca 100644 --- a/django_napse/api/keys/views/key_view.py +++ b/django_napse/api/keys/views/key_view.py @@ -86,10 +86,10 @@ def patch(self, request, pk): if "description" in request.data: key.description = request.data["description"] if "permissions" in request.data: - for permission in key.permissions.filter(space=self.space(request)): + for permission in key.permissions.filter(space=self.get_space(request)): permission.delete() for permission in request.data["permissions"]: - key.add_permission(self.space(request), permission) + key.add_permission(self.get_space(request), permission) key.save() if request.data.get("revoked", False) and not key.is_master_key: key.revoke() diff --git a/django_napse/api/orders/__init__.py b/django_napse/api/orders/__init__.py index e69de29b..60ffb38c 100644 --- a/django_napse/api/orders/__init__.py +++ b/django_napse/api/orders/__init__.py @@ -0,0 +1,2 @@ +from .serializers import * +from .views import * diff --git a/django_napse/api/orders/serializers/__init__.py b/django_napse/api/orders/serializers/__init__.py index e69de29b..e7396e11 100644 --- a/django_napse/api/orders/serializers/__init__.py +++ b/django_napse/api/orders/serializers/__init__.py @@ -0,0 +1 @@ +from .order_serializer import OrderSerializer diff --git a/django_napse/api/orders/serializers/order_serializer.py b/django_napse/api/orders/serializers/order_serializer.py new file mode 100644 index 00000000..3d3dd4ac --- /dev/null +++ b/django_napse/api/orders/serializers/order_serializer.py @@ -0,0 +1,84 @@ +from rest_framework import serializers + +from django_napse.core.models import Order +from django_napse.utils.constants import SIDES + + +class OrderSerializer(serializers.ModelSerializer): + """. + + { + "side": "BUY", + "completed": true, + "spent": { + "ticker": "BTC", + "amount": 0.1, + "price": 1, + "value": 0.1, + }, + "received": { + "ticker": "MATIC", + "amount": 995, + "price": 0.0001, + "value": 0.095, + }, + "fees": { + "ticker": "BTC", + "amount": 5, + "price": 0.0001, + "value": 0.05, + }, + "created_at": "2021-09-01T12:00:00Z", + } + """ + + spent = serializers.SerializerMethodField() + received = serializers.SerializerMethodField() + fees = serializers.SerializerMethodField() + + class Meta: + model = Order + fields = [ + "side", + "completed", + "spent", + "received", + "fees", + "created_at", + ] + read_only_fields = fields + + def get_spent(self, instance): + """Return spend informations.""" + exit_amount = instance.exit_amount_quote if instance.side == SIDES.BUY else instance.exit_amount_base + amount = instance.debited_amount - exit_amount + ticker = instance.tickers_info().get("spent_ticker") + return { + "ticker": ticker, + "amount": amount, + "price": 1, + "value": amount, + } + + def get_received(self, instance): + """Rerturn receive informations.""" + amount = instance.exit_amount_base if instance.side == SIDES.BUY else instance.exit_amount_quote + ticker = instance.tickers_info().get("received_ticker") + price = instance.price + return { + "ticker": ticker, + "amount": amount, + "price": price, + "value": amount * price, + } + + def get_fees(self, instance): + return { + "ticker": instance.fee_ticker, + "amount": instance.fees, + "value": instance.fees * instance.price, + } + + def save(self, **kwargs): + error_msg: str = "It's impossible to create ormodify an order." + raise serializers.ValidationError(error_msg) diff --git a/django_napse/api/orders/views/__init__.py b/django_napse/api/orders/views/__init__.py new file mode 100644 index 00000000..01cd66c1 --- /dev/null +++ b/django_napse/api/orders/views/__init__.py @@ -0,0 +1 @@ +from .order_view import OrderView diff --git a/django_napse/api/orders/views/order_view.py b/django_napse/api/orders/views/order_view.py new file mode 100644 index 00000000..df33db05 --- /dev/null +++ b/django_napse/api/orders/views/order_view.py @@ -0,0 +1,26 @@ +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from django_napse.api.custom_viewset import CustomViewSet +from django_napse.api.orders.serializers import OrderSerializer +from django_napse.core.models import Order + + +class OrderView(CustomViewSet): + """.""" + + # permission_classes = [HasAPIKey, HasSpace] + permission_classes = [] + serializer_class = OrderSerializer + + def get_queryset(self): + print(f"count: {Order.objects.count()}") + return Order.objects.all() + + def list(self, request): + """For test & debug purposes only.""" + if not settings.DEBUG: + return Response(status=status.HTTP_404_NOT_FOUND) + serializer = self.get_serializer(self.get_queryset(), many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/django_napse/api/spaces/serializers/space_serializers.py b/django_napse/api/spaces/serializers/space_serializers.py index 383ccdd1..e6c9cbc2 100644 --- a/django_napse/api/spaces/serializers/space_serializers.py +++ b/django_napse/api/spaces/serializers/space_serializers.py @@ -20,11 +20,13 @@ class Meta: "exchange_account", # read-only "uuid", + "testing", "value", "delta", ] read_only_fields = [ "uuid", + "testing", "value", "delta", ] @@ -70,6 +72,7 @@ class Meta: "description", # read-only "uuid", + "testing", "exchange_account", "created_at", "statistics", @@ -79,6 +82,7 @@ class Meta: ] read_only_fields = [ "uuid", + "testing", "exchange_account", "created_at", "statistics", @@ -97,3 +101,55 @@ def get_history(self, instance) -> list: return [] return loads(history.to_dataframe().to_json(orient="records")) + + +class SpaceMoneyFlowSerializer(serializers.Serializer): + amount = serializers.FloatField(write_only=True, required=True) + ticker = serializers.CharField(write_only=True, required=True) + + def __init__(self, side, instance=None, data=serializers.empty, **kwargs): + self.side = side + super().__init__(instance=instance, data=data, **kwargs) + + def _invest_validate(self, attrs): + if not self.instance.testing: + error_msg: str = "Not implemented yet." + raise NotImplementedError(error_msg) + + # Test invest + return attrs + + def _withdraw_validate(self, attrs): + if self.instance.testing: + error_msg: str = "Not implemented yet." + raise NotImplementedError(error_msg) + + # Test withdraw + return attrs + + def validate(self, attrs): + if attrs.get("amount") <= 0: + error_msg: str = "Invalid amount." + raise serializers.ValidationError(error_msg) + + if attrs.get("ticker") not in self.instance.exchange_account.get_tickers(): + error_msg: str = f"{attrs['ticker']} is not available on {self.instance.exchange_account.exchange.name} exchange." + raise serializers.ValidationError(error_msg) + + match self.side.upper(): + case "INVEST": + return self._invest_validate(attrs) + case "WITHDRAW": + return self._withdraw_validate(attrs) + case _: + error_msg: str = "Invalid side." + raise ValueError(error_msg) + + def save(self, **kwargs): + if self.side.upper() == "INVEST": + self.instance.invest( + self.validated_data.get("amount"), + self.validated_data.get("ticker"), + ) + else: + self.instance.withdraw(self.amount, self.ticker) diff --git a/django_napse/api/spaces/views/space_view.py b/django_napse/api/spaces/views/space_view.py index c34250cd..1662f7dc 100644 --- a/django_napse/api/spaces/views/space_view.py +++ b/django_napse/api/spaces/views/space_view.py @@ -1,11 +1,13 @@ from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework_api_key.permissions import HasAPIKey from django_napse.api.custom_permissions import HasFullAccessPermission, HasMasterKey, HasReadPermission from django_napse.api.custom_viewset import CustomViewSet -from django_napse.api.spaces.serializers import SpaceDetailSerializer, SpaceSerializer +from django_napse.api.spaces.serializers import SpaceDetailSerializer, SpaceMoneyFlowSerializer, SpaceSerializer from django_napse.core.models import NapseSpace +from django_napse.utils.constants import EXCHANGE_TICKERS from django_napse.utils.errors import SpaceError @@ -26,6 +28,8 @@ def get_serializer_class(self, *args, **kwargs): "create": SpaceSerializer, "update": SpaceSerializer, "partial_update": SpaceSerializer, + "invest": SpaceMoneyFlowSerializer, + "withdraw": SpaceMoneyFlowSerializer, } result = actions.get(self.action, None) return result if result else super().get_serializer_class() @@ -80,3 +84,54 @@ def delete(self, request, *args, **kwargs): except SpaceError.DeleteError: return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["GET", "POST"]) + def invest(self, request, pk=None): + """Endpoint to invest on space. + + GET: Return all {ticker: amount} which can be invested in the space. + POST: Invest in the space. + """ + space: NapseSpace = self.get_object() + if not space.testing: + return Response(status=status.HTTP_501_NOT_IMPLEMENTED) + + exchange_name: str = space.exchange_account.exchange.name + + match request.method: + case "GET": + possible_investments = [{"ticker": ticker, "amount": 1_000_000} for ticker in EXCHANGE_TICKERS.get(exchange_name)] + return Response(possible_investments, status=status.HTTP_200_OK) + + case "POST": + space = self.get_object() + if not space.testing: + error_msg: str = "Investing in real is not allowed yet." + return Response(error_msg, status=status.HTTP_403_FORBIDDEN) + serializer = self.get_serializer(data=request.data, instance=space, side="INVEST") + serializer.is_valid(raise_exception=True) + serializer.save() + + case _: + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + return Response(status=status.HTTP_200_OK) + + @action(detail=True, methods=["GET", "POST"]) + def withdraw(self, request, pk=None): + """Endpoint to withdraw on space. + + GET: Return all {ticker: amount} which can be withdrawn in the space. + POST: Withdraw from the space. + """ + space: NapseSpace = self.get_object() + if not space.testing: + return Response(status=status.HTTP_501_NOT_IMPLEMENTED) + + match request.method: + case "GET": + return Response(status=status.HTTP_200_OK) + case "POST": + return Response(status=status.HTTP_501_NOT_IMPLEMENTED) + case _: + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/django_napse/api/transactions/serializers/credit_serializer.py b/django_napse/api/transactions/serializers/credit_serializer.py index 8da55575..ea6817ce 100644 --- a/django_napse/api/transactions/serializers/credit_serializer.py +++ b/django_napse/api/transactions/serializers/credit_serializer.py @@ -1,13 +1,13 @@ from rest_framework import serializers -from django_napse.core.models import credit +from django_napse.core.models import Credit class CreditSerializer(serializers.ModelSerializer): operation_type = serializers.CharField(default="CREDIT") class Meta: - model = credit + model = Credit fields = [ "amount", "ticker", diff --git a/django_napse/api/wallets/serializers/__init__.py b/django_napse/api/wallets/serializers/__init__.py index e2438165..5a0a81c5 100644 --- a/django_napse/api/wallets/serializers/__init__.py +++ b/django_napse/api/wallets/serializers/__init__.py @@ -1 +1,2 @@ from .currency_serializer import CurrencySerializer +from .wallet_serializers import WalletSerializer diff --git a/django_napse/api/wallets/serializers/wallet_serializers.py b/django_napse/api/wallets/serializers/wallet_serializers.py index f9ea311a..3a2e13f3 100644 --- a/django_napse/api/wallets/serializers/wallet_serializers.py +++ b/django_napse/api/wallets/serializers/wallet_serializers.py @@ -35,7 +35,6 @@ def get_operations(self, instance) -> dict: transactions_data = TransactionSerializer(transactions, many=True).data credits_data = CreditSerializer(instance.credits.all().order_by("created_at"), many=True).data debits_data = DebitSerializer(instance.debits.all().order_by("created_at"), many=True).data - # return { # "credits": CreditSerializer(instance.credits.all().order_by("created_at"), many=True).data, # "debits": DebitSerializer(instance.debits.all().order_by("created_at"), many=True).data, diff --git a/django_napse/core/db_essentials.py b/django_napse/core/db_essentials.py index 9fa695dd..ef88d16d 100644 --- a/django_napse/core/db_essentials.py +++ b/django_napse/core/db_essentials.py @@ -27,8 +27,13 @@ def create_accounts(sender, **kwargs): Exchange = apps.get_model("django_napse_core", "Exchange") from django_napse.core.models.accounts.exchange import EXCHANGE_ACCOUNT_DICT - with open(settings.NAPSE_SECRETS_FILE_PATH, "r") as json_file: - secrets = json.load(json_file) + try: + with open(settings.NAPSE_SECRETS_FILE_PATH, "r") as json_file: + secrets = json.load(json_file) + except FileNotFoundError: + with open(settings.NAPSE_SECRETS_FILE_PATH, "w") as json_file: + json.dump({"Exchange Accounts": {}}, json_file) + secrets = {"Exchange Accounts": {}} created_exchange_accounts = [] with atomic(): diff --git a/django_napse/core/migrations/0001_initial.py b/django_napse/core/migrations/0001_initial.py index f59f7c1f..ba5a9504 100644 --- a/django_napse/core/migrations/0001_initial.py +++ b/django_napse/core/migrations/0001_initial.py @@ -1,15 +1,16 @@ # Generated by Django 4.2.5 on 2023-10-09 15:35 import datetime -from django.db import migrations, models +import uuid + import django.db.models.deletion +from django.db import migrations, models + import django_napse.utils.constants import django_napse.utils.findable_class -import uuid class Migration(migrations.Migration): - initial = True dependencies = [] @@ -197,9 +198,7 @@ class Migration(migrations.Migration): ( "status", models.CharField( - default=django_napse.utils.constants.MODIFICATION_STATUS[ - "PENDING" - ], + default=django_napse.utils.constants.MODIFICATION_STATUS["PENDING"], max_length=15, ), ), @@ -860,8 +859,13 @@ class Migration(migrations.Migration): "variable_last_candle_date", models.DateTimeField( default=datetime.datetime( - 1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc - ) + 1970, + 1, + 1, + 0, + 0, + tzinfo=datetime.timezone.utc, + ), ), ), ( diff --git a/django_napse/core/migrations/0008_bothistory.py b/django_napse/core/migrations/0008_bothistory.py new file mode 100644 index 00000000..c3da3d9d --- /dev/null +++ b/django_napse/core/migrations/0008_bothistory.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.7 on 2024-01-07 13:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_napse_core", "0007_historydatapoint_created_at"), + ] + + operations = [ + migrations.CreateModel( + name="BotHistory", + fields=[ + ( + "history_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="django_napse_core.history", + ), + ), + ("owner", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="history", to="django_napse_core.bot")), + ], + bases=("django_napse_core.history",), + ), + ] diff --git a/django_napse/core/migrations/0009_rename_exit_base_amount_order_exit_amount_base_and_more.py b/django_napse/core/migrations/0009_rename_exit_base_amount_order_exit_amount_base_and_more.py new file mode 100644 index 00000000..0ce22896 --- /dev/null +++ b/django_napse/core/migrations/0009_rename_exit_base_amount_order_exit_amount_base_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-11 22:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_napse_core', '0008_bothistory'), + ] + + operations = [ + migrations.RenameField( + model_name='order', + old_name='exit_base_amount', + new_name='exit_amount_base', + ), + migrations.RenameField( + model_name='order', + old_name='exit_quote_amount', + new_name='exit_amount_quote', + ), + ] diff --git a/django_napse/core/models/accounts/exchange.py b/django_napse/core/models/accounts/exchange.py index 1fcf531d..4583cc25 100644 --- a/django_napse/core/models/accounts/exchange.py +++ b/django_napse/core/models/accounts/exchange.py @@ -3,6 +3,7 @@ from django.db import models from django_napse.core.models.accounts.managers.exchange import ExchangeAccountManager +from django_napse.utils.constants import EXCHANGE_TICKERS from django_napse.utils.errors import ExchangeAccountError from django_napse.utils.findable_class import FindableClass from django_napse.utils.trading.binance_controller import BinanceController @@ -24,6 +25,10 @@ def info(self, verbose=True, beacon=""): print(string) return string + def get_tickers(self): + """Return a list of available tickers on the exchange.""" + return EXCHANGE_TICKERS.get(self.name, []) + class ExchangeAccount(models.Model, FindableClass): uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4) @@ -70,6 +75,9 @@ def exchange_controller(self): # pragma: no cover error_msg = f"exchange_controller() not implemented for {self.__class__.__name__}" raise NotImplementedError(error_msg) + def get_tickers(self): + return self.exchange.get_tickers() + class BinanceAccount(ExchangeAccount): public_key = models.CharField(max_length=200) @@ -78,6 +86,9 @@ class BinanceAccount(ExchangeAccount): class Meta: unique_together = ("public_key", "private_key") + def __str__(self): + return "BINANCE " + super().__str__() + def ping(self): request = self.exchange_controller().get_info() if "error" in request: diff --git a/django_napse/core/models/accounts/space.py b/django_napse/core/models/accounts/space.py index 264847cb..e2719aaa 100644 --- a/django_napse/core/models/accounts/space.py +++ b/django_napse/core/models/accounts/space.py @@ -5,9 +5,15 @@ from django.utils.timezone import get_default_timezone from django_napse.core.models.accounts.managers import NapseSpaceManager +from django_napse.core.models.bots.bot import Bot from django_napse.core.models.fleets.fleet import Fleet from django_napse.core.models.orders.order import Order +from django_napse.core.models.transactions.credit import Credit +from django_napse.core.models.transactions.debit import Debit +from django_napse.utils.constants import EXCHANGE_TICKERS from django_napse.utils.errors import SpaceError +from django_napse.utils.errors.exchange import ExchangeError +from django_napse.utils.errors.wallets import WalletError class NapseSpace(models.Model): @@ -90,6 +96,12 @@ def fleets(self) -> models.QuerySet: connections = self.wallet.connections.all() return Fleet.objects.filter(clusters__links__bot__connections__in=connections).distinct() + @property + def bots(self) -> models.QuerySet: + """Bots of the space.""" + connections = self.wallet.connections.all() + return Bot.objects.filter(connections__in=connections) + def get_stats(self) -> dict[str, int | float | str]: """Statistics of space.""" order_count_30 = Order.objects.filter( @@ -110,3 +122,35 @@ def delete(self) -> None: if self.value > 0: raise SpaceError.DeleteError return super().delete() + + def invest(self, amount: float, ticker: str): + """Invest in space.""" + if ticker not in EXCHANGE_TICKERS.get("BINANCE"): + error_msg: str = f"{ticker} is not available on {self.exchange_account.name} exchange." + raise ExchangeError.UnavailableTicker(error_msg) + + # Real invest + if not self.testing: + error_msg: str = "Investing for real is not available yet." + raise NotImplementedError(error_msg) + + # Testing invest + Credit.objects.create(wallet=self.wallet, amount=amount, ticker=ticker) + + def withdraw(self, amount: float, ticker: str): + """Withdraw from space.""" + if ticker not in [currency.ticker for currency in self.wallet.currencies.all()]: + error_msg: str = f"{ticker} is not on your {self.name}(space)'s wallet." + raise WalletError.UnavailableTicker(error_msg) + + # Real withdraw + if not self.testing: + error_msg: str = "Withdrawing for real is not available yet." + raise NotImplementedError(error_msg) + + # Testing withdraw + Debit.objects.create( + wallet=self.wallet, + amount=amount, + ticker=ticker, + ) diff --git a/django_napse/core/models/bots/bot.py b/django_napse/core/models/bots/bot.py index 931a5deb..894745f1 100644 --- a/django_napse/core/models/bots/bot.py +++ b/django_napse/core/models/bots/bot.py @@ -48,7 +48,18 @@ def is_in_simulation(self): @property def is_in_fleet(self): - return hasattr(self, "bot_in_cluster") + # return hasattr(self, "bot_in_cluster") + return hasattr(self, "link") + + @property + def is_templace(self): + """Is self a template bot of a cluster?""" + return hasattr(self, "cluster") + + @property + def is_free(self): + """Is self not in a simulation, not in a fleet and not a cluster's template bot?""" + return not self.is_in_simulation and not self.is_in_fleet and not self.is_templace @property def testing(self): @@ -59,6 +70,12 @@ def testing(self): error_msg = "Bot is not in simulation or fleet." raise BotError.InvalidSetting(error_msg) + @property + def fleet(self): + if self.is_in_fleet: + return self.link.cluster.fleet + return None + @property def space(self): if self.is_in_simulation: @@ -74,7 +91,7 @@ def exchange_account(self): if self.is_in_simulation: return self.simulation.space.exchange_account if self.is_in_fleet: - return self.bot_in_cluster.cluster.fleet.exchange_account + return self.link.cluster.fleet.exchange_account error_msg = "Bot is not in simulation or fleet." raise BotError.InvalidSetting(error_msg) @@ -90,6 +107,11 @@ def architecture(self): def controllers(self): return self.architecture.controllers_dict() + @property + def orders(self): + connections = self.connections.select_related("orders").all() + return [connection.orders for connection in connections] + def hibernate(self): if not self.active: error_msg = "Bot is already hibernating." @@ -135,7 +157,7 @@ def get_orders(self, data: Optional[dict] = None, no_db_data: Optional[dict] = N def _get_orders(self, data: Optional[dict] = None, no_db_data: Optional[dict] = None): return self.architecture._get_orders(data=data, no_db_data=no_db_data) - def connect_to(self, wallet): + def connect_to_wallet(self, wallet): connection = Connection.objects.create(owner=wallet, bot=self) for plugin in self._strategy.plugins.all(): plugin.connect(connection) @@ -147,3 +169,16 @@ def copy(self): name=f"Copy of {self.name}", strategy=self.strategy.copy(), ) + + def value(self, space=None): + if space is None: + return sum([connection.wallet.value_market() for connection in self.connections.all()]) + connection = Connection.objects.get(owner=space.wallet, bot=self) + return connection.wallet.value_market() + + def get_stats(self, space=None): + return { + "value": self.value(space), + "profit": 0, + "delta_30": 0, # TODO: Need history + } diff --git a/django_napse/core/models/bots/strategy.py b/django_napse/core/models/bots/strategy.py index e5ae0d11..1df3a4b0 100644 --- a/django_napse/core/models/bots/strategy.py +++ b/django_napse/core/models/bots/strategy.py @@ -13,6 +13,17 @@ class Strategy(models.Model, FindableClass): def __str__(self) -> str: # pragma: no cover return f"STRATEGY {self.pk}" + def info(self, verbose=True, beacon=""): + string = "" + string += f"{beacon}Strategy {self.pk}:\n" + string += f"{beacon}Args:\n" + string += f"{beacon}\t{self.config=}\n" + string += f"{beacon}\t{self.architecture=}\n" + + if verbose: + print(string) + return string + def give_order(self, data: dict) -> list[dict]: if self.__class__ == Strategy: error_msg = "give_order not implemented for the Strategy base class, please implement it in a subclass." diff --git a/django_napse/core/models/fleets/cluster.py b/django_napse/core/models/fleets/cluster.py index b75c17e4..9aced4b1 100644 --- a/django_napse/core/models/fleets/cluster.py +++ b/django_napse/core/models/fleets/cluster.py @@ -1,6 +1,6 @@ from django.db import models -from django_napse.core.models.connections.connection import Connection +# from django_napse.core.models.connections.connection import Connection from django_napse.core.models.fleets.link import Link from django_napse.core.models.transactions.transaction import Transaction from django_napse.utils.constants import TRANSACTION_TYPES @@ -68,7 +68,7 @@ def invest(self, space, amount, ticker): new_bot = self.template_bot.copy() Link.objects.create(bot=new_bot, cluster=self, importance=1) # connection = Connection.objects.create(bot=new_bot, owner=space.wallet) - connection = space.wallet.connect_to(new_bot) + connection = space.wallet.connect_to_bot(new_bot) Transaction.objects.create( from_wallet=space.wallet, to_wallet=connection.wallet, @@ -82,11 +82,9 @@ def invest(self, space, amount, ticker): if ticker not in bot.architecture.accepted_investment_tickers() or ticker not in bot.architecture.accepted_tickers(): error_msg = f"Bot {bot} does not accept ticker {ticker}." raise BotError.InvalidTicker(error_msg) - try: - connection = Connection.objects.get(bot=bot, owner=space.wallet) - except Connection.DoesNotExist: - # connection = Connection.objects.create(bot=bot, owner=sace.wallet) - connection = space.wallet.connect_to(bot) + + connection = space.wallet.connect_to_bot(bot) + # connection = Connection.objects.get(bot=bot, owner=space.wallet) Transaction.objects.create( from_wallet=space.wallet, to_wallet=connection.wallet, diff --git a/django_napse/core/models/fleets/fleet.py b/django_napse/core/models/fleets/fleet.py index c603847d..34426e56 100644 --- a/django_napse/core/models/fleets/fleet.py +++ b/django_napse/core/models/fleets/fleet.py @@ -8,6 +8,7 @@ from django_napse.core.models.connections.connection import Connection from django_napse.core.models.fleets.managers import FleetManager from django_napse.core.models.orders.order import Order +from django_napse.utils.errors import BotError class Fleet(models.Model): @@ -70,6 +71,7 @@ def value(self) -> float: def space_frame_value(self, space) -> float: """Sum value of all bots connected to the space.""" + # TODO: remove property to values and add the following lines to the new `value()` method fleet_connections = Connection.objects.filter(bot__in=self.bots) space_connections = space.wallet.connections.all() commun_connections = space_connections.intersection(fleet_connections) @@ -81,19 +83,43 @@ def bot_clusters(self): bot_clusters.append(Bot.objects.filter(link__cluster=cluster)) return bot_clusters + def connect_to_space(self, space): + ... + def invest(self, space, amount, ticker): connections = [] for cluster in self.clusters.all(): connections += cluster.invest(space, amount * cluster.share, ticker) return connections + def bot_count(self, space=None) -> int: + """Count number of bots in fleet, depends on space frame.""" + query_bot = self.bots.all() + if space is None: + return len(query_bot) + result = [] + for bot in query_bot: + try: + bot_space = bot.space + except BotError.NoSpace: + continue + if bot_space == self.space: + result.append(bot) + return len(result) + def get_stats(self): - order_count = Order.objects.filter( + order_count = Order.objects.filter( # noqa: F841 connection__bot__in=self.bots, created_at__gt=datetime.now(tz=get_default_timezone()) - timedelta(days=30), ).count() return { "value": self.value, - "order_count_30": order_count, - "change_30": None, # TODO: Need history + "bot_count": self.bot_count(), + "delta_30": 0, # TODO: Need history } + + def delete(self) -> None: + """Delete clusters & relative (template) bots.""" + self.bots.delete() + self.clusters.all().delete() + super().delete() diff --git a/django_napse/core/models/fleets/link.py b/django_napse/core/models/fleets/link.py index 406fbc72..3e16dce8 100644 --- a/django_napse/core/models/fleets/link.py +++ b/django_napse/core/models/fleets/link.py @@ -7,7 +7,7 @@ class Link(models.Model): importance = models.FloatField() def __str__(self): - return f"LINK: {self.bot} {self.fleet}" + return f"LINK: {self.bot=} {self.cluster=}" def info(self, verbose=True, beacon=""): string = "" diff --git a/django_napse/core/models/histories/__init__.py b/django_napse/core/models/histories/__init__.py index fef5cd7a..1d38b79c 100644 --- a/django_napse/core/models/histories/__init__.py +++ b/django_napse/core/models/histories/__init__.py @@ -1,3 +1,4 @@ +from .bot import * from .exchange_account import * from .fleet import * from .history import * diff --git a/django_napse/core/models/orders/order.py b/django_napse/core/models/orders/order.py index 459aaf60..40291b56 100644 --- a/django_napse/core/models/orders/order.py +++ b/django_napse/core/models/orders/order.py @@ -6,7 +6,7 @@ from django_napse.core.models.transactions.credit import Credit from django_napse.core.models.transactions.debit import Debit from django_napse.core.models.transactions.transaction import Transaction -from django_napse.utils.constants import MODIFICATION_STATUS, ORDER_STATUS, SIDES, TRANSACTION_TYPES +from django_napse.utils.constants import EXCHANGE_PAIRS, MODIFICATION_STATUS, ORDER_STATUS, SIDES, TRANSACTION_TYPES from django_napse.utils.errors import OrderError @@ -59,8 +59,8 @@ class Order(models.Model): debited_amount = models.FloatField(default=0) batch_share = models.FloatField(default=0) - exit_base_amount = models.FloatField(default=0) - exit_quote_amount = models.FloatField(default=0) + exit_amount_base = models.FloatField(default=0) + exit_amount_quote = models.FloatField(default=0) fees = models.FloatField(default=0) fee_ticker = models.CharField(max_length=10, blank=True) @@ -81,8 +81,8 @@ def info(self, verbose=True, beacon=""): string += f"{beacon}\t{self.asked_for_ticker=}\n" string += f"{beacon}\t{self.debited_amount=}\n" string += f"{beacon}\t{self.batch_share=}\n" - string += f"{beacon}\t{self.exit_base_amount=}\n" - string += f"{beacon}\t{self.exit_quote_amount=}\n" + string += f"{beacon}\t{self.exit_amount_base=}\n" + string += f"{beacon}\t{self.exit_amount_quote=}\n" string += f"{beacon}\t{self.fees=}\n" string += f"{beacon}\t{self.fee_ticker=}\n" string += f"{beacon}\t{self.side=}\n" @@ -251,3 +251,17 @@ def process_payout(self): ticker=self.batch.controller.quote, transaction_type=TRANSACTION_TYPES.ORDER_REFUND, ) + + def tickers_info(self) -> dict[str, str]: + """Give informations about received, spent & fee tickers.""" + spent_ticker = EXCHANGE_PAIRS[self.connection.space.exchange_account.exchange.name][self.pair]["base" if self.side == SIDES.SELL else "quote"] + received_ticker = EXCHANGE_PAIRS[self.connection.space.exchange_account.exchange.name][self.pair][ + "quote" if self.side == SIDES.SELL else "base" + ] + fee_ticker = self.fee_ticker + + return { + "spent_ticker": spent_ticker, + "received_ticker": received_ticker, + "fee_ticker": fee_ticker, + } diff --git a/django_napse/core/models/wallets/wallet.py b/django_napse/core/models/wallets/wallet.py index d63cc1ec..2d245dbb 100644 --- a/django_napse/core/models/wallets/wallet.py +++ b/django_napse/core/models/wallets/wallet.py @@ -23,7 +23,7 @@ def __str__(self): def info(self, verbose=True, beacon=""): self = self.find() string = "" - string += f"{beacon}Wallet ({self.pk=}):\n" + string += f"{beacon}Wallet ({self.pk=}):\t{type(self)}\n" string += f"{beacon}Args:\n" string += f"{beacon}\t{self.title=}\n" string += f"{beacon}\t{self.testing=}\n" @@ -170,6 +170,9 @@ def to_dict(self): 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 @@ -178,13 +181,20 @@ def space(self): def exchange_account(self): return self.space.exchange_account.find() - def connect_to(self, bot): - return Connection.objects.create(owner=self, bot=bot) + 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 @@ -200,13 +210,21 @@ def exchange_account(self): def reset(self): self.currencies.all().delete() - def connect_to(self, bot): - return Connection.objects.create(owner=self, bot=bot) + 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() @@ -215,6 +233,9 @@ def exchange_account(self): 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 diff --git a/django_napse/simulations/models/simulations/simulation_queue.py b/django_napse/simulations/models/simulations/simulation_queue.py index 082024e9..b88c42f3 100644 --- a/django_napse/simulations/models/simulations/simulation_queue.py +++ b/django_napse/simulations/models/simulations/simulation_queue.py @@ -70,7 +70,7 @@ def setup_simulation(self): amount=investment.amount, ) new_bot = self.bot.copy() - connection = new_bot.connect_to(self.space.simulation_wallet) + connection = new_bot.connect_to_wallet(self.space.simulation_wallet) for investment in self.investments.all(): connection.deposit(investment.ticker, investment.amount) no_db_data = new_bot.architecture.prepare_db_data() diff --git a/django_napse/utils/errors/exchange.py b/django_napse/utils/errors/exchange.py index 935b88c9..d0c2270b 100644 --- a/django_napse/utils/errors/exchange.py +++ b/django_napse/utils/errors/exchange.py @@ -1,6 +1,9 @@ class ExchangeError: """Base class for exchange errors.""" + class UnavailableTicker(Exception): + """Raised when a ticker is not available on the exchange.""" + class ExchangeAccountError: """Base class for exchange account errors.""" diff --git a/django_napse/utils/errors/wallets.py b/django_napse/utils/errors/wallets.py index 34407d49..f6283247 100644 --- a/django_napse/utils/errors/wallets.py +++ b/django_napse/utils/errors/wallets.py @@ -9,3 +9,6 @@ class TopUpError(Exception): class CreateError(Exception): """Raised when a wallet cannot be created.""" + + class UnavailableTicker(Exception): + """Raised when a ticker is the wallet's currencies.""" diff --git a/docs/contributing.md b/docs/contributing.md index 5c67726c..0c026c6b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -5,10 +5,11 @@ Welcome ! Thank you in advance for your contribution to Napse ! There are several ways in which you can contribute, beyond writing code. The goal of this document is to provide a high-level overview of how you can get involved. ## Questions & Feedbacks - +--- Have a question ? Want to give a feedback ? Instead of opening an issue, please use the [discussion](https://github.com/napse-invest/django-napse/discussions) section. ## Basics +--- For small changes (e.g., bug fixes, documentation improvement), feel free to submit a PR. For larger changes (e.g., new feature), please open an issue first to discuss the proposed changes. @@ -35,6 +36,7 @@ If you open a new issue for a bug report or a feature request, please read the f ## Setup development environnement +--- If you would like to go a steup further and write some code to contributing, we would love to hear from you! @@ -47,7 +49,7 @@ You will need the following tools: - [Git](https://git-scm.com/) - `make` ([make for windows](https://linuxhint.com/install-use-make-windows/)) - [Python](https://www.python.org/downloads/) `>=3.11` -- [Ruff](https://docs.astral.sh/ruff/) `>=1.3` +- [Ruff](https://docs.astral.sh/ruff/) `>=2.0` #### Clone the project from github @@ -61,6 +63,12 @@ You can commit the code from your fork through a pull request on the official re #### Build the virtual environment: +To setup the virtual environment, you can run the command: +```bash +make setup +``` +Or run manually the following script depending on your operating system: + === "Linux" ```bash @@ -97,6 +105,11 @@ At `tests/test_app/`, build a `secret.json` file (or run the `./setup_secrets.sh } ``` +You can create this file with the commande: +```bash +make setup-testing-environment +``` + !!! note We **strongly recommend** to add the `secret.json` file to your `.gitignore` file to avoid sharing your API keys. @@ -130,6 +143,7 @@ The project contains 5 parts: ## Documentation +--- In order to produce the best possible documentation, it is based on the [diataxis](https://diataxis.fr/) framework. @@ -147,6 +161,7 @@ make mkdocs The documentation should then be available locally at http://localhost:8005/. ## Code contribution +--- The code you contribute to the project must follow a standard. This standard ensures that all code is consistent, thant it has a certain quality and, above all, makes it easier for the various contributors to handle it. @@ -172,4 +187,17 @@ We strongly recommand to use a formatter to format your code. You can use both r ### Tests -⚠️ Work in progress ⚠️ \ No newline at end of file +⚠️ Work in progress ⚠️ + +Run tests: +```bash +make test +``` +Run tests with coverage: +```bash +make coverage +``` +Run tests with coverage and open the html report: +```bash +make coverage-open +``` \ No newline at end of file diff --git a/docs/graphs/models graph.drawio b/docs/graphs/django-napse-models.drawio similarity index 73% rename from docs/graphs/models graph.drawio rename to docs/graphs/django-napse-models.drawio index 665c7e88..e21fdca5 100644 --- a/docs/graphs/models graph.drawio +++ b/docs/graphs/django-napse-models.drawio @@ -1,53 +1,53 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -57,7 +57,7 @@ - + @@ -67,7 +67,7 @@ - + @@ -77,7 +77,7 @@ - + @@ -87,12 +87,12 @@ - + - + @@ -101,23 +101,23 @@ - + - + - + - + - + @@ -127,24 +127,24 @@ - + - + - + - + - + - + @@ -154,17 +154,17 @@ - + - + - + @@ -173,12 +173,12 @@ - + - + @@ -188,43 +188,51 @@ - + - + - + - + - + - + - + - + - + + + + + + + + + - + @@ -233,57 +241,57 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -292,23 +300,23 @@ - + - + - + - + - + @@ -318,73 +326,73 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -393,26 +401,26 @@ - - - + + + - - - + + + - + - + - + - + @@ -422,23 +430,23 @@ - + - + - + - + - + @@ -447,29 +455,29 @@ - + - + - + - + - + - + - + @@ -479,163 +487,163 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -645,20 +653,20 @@ - + - + - + - + @@ -668,26 +676,26 @@ - + - + - + - + - + - + @@ -697,16 +705,31 @@ - + - + + + + + + + + + + + + + + + + diff --git a/docs/graphs/django-napse-models.drawio.svg b/docs/graphs/django-napse-models.drawio.svg new file mode 100644 index 00000000..fa2183ee --- /dev/null +++ b/docs/graphs/django-napse-models.drawio.svg @@ -0,0 +1,4 @@ + + + +
Exchange account
Exchange...
Napse Space
Napse Sp...
1
1
exchange_account
exchange_account
spaces
spaces
Wallet
Wallet
Space Wallet
Space Wa...
Space Simulation Wallet
Space Simul...
Connection Wallet
Connection...
Order Wallet
Order Wa...
inherits
inherits
1
1
1
1
owner
owner
wallet
wallet
1
1
simulation wallet
simulat...
Credit
Credit
Debit
Debit
Transaction
Transaction
1
1
1
1
1
1
wallet
wallet
debits
debits
credits
credits
Currency
Currency
1
1
Connection
Connection
1
1
1
1
wallet
wallet
owner
owner
wallet
wallet
currencies
currenc...
Connection Specific Args
Connection...
1
1
connection
connection
specific_args
specific_ar...
Order
Order
1
1
1
1
wallet
wallet
owner
owner
1
1
connection
connection
Modifications
Modifications
1
1
order
order
modifications
modificatio...
Order Batch
Order Ba...
1
1
orders
orders
orders
orders
owner
owner
space wallet
or
space simulation wallet
space wallet...
connections
connections
owner
owner
Controller
Controller
1
1
order_batches
order_batches
controller
controller
1
1
controllers
controlle...
exchange_account
exchange_account
batch
batch
Fleet
Fleet
1
1
fleets
fleets
Cluster
Cluster
1
1
fleet
fleet
clusters
clusters
Link
Link
1
1
cluster
cluster
links
links
same
same
Bot
Bot
1
1
1
1
link
link
bot
bot
template
bot
template...
1
1
1
1
Strategy
Strategy
1
1
1
1
strategy
strategy
bot
bot
Plugin
Plugin
Architecture
Architecture
Config
Config
1
1
plugins
plugins
1
1
1
1
strategy
strategy
architecture
architecture
config
config
1
1
1
1
1
1
1
1
bot
bot
connections
connections
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/graphs/models graphs.svg b/docs/graphs/models graphs.svg deleted file mode 100644 index 6f15bfe0..00000000 --- a/docs/graphs/models graphs.svg +++ /dev/null @@ -1,2302 +0,0 @@ - - - - - - - - - - -
-
-
- Exchange account
-
-
-
-
Exchange... -
-
- - - - -
-
-
- Napse Space
-
-
-
Napse Sp... -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- exchange_account
-
-
-
exchange_account -
-
- - - - -
-
-
- spaces
-
-
-
spaces -
-
- - - - -
-
-
- Wallet
-
-
-
Wallet -
-
- - - - -
-
-
- Space Wallet
-
-
-
Space Wa... -
-
- - - - -
-
-
- Space Simulation Wallet
-
-
-
Space Simul... -
-
- - - - -
-
-
- Connection Wallet
-
-
-
Connection... -
-
- - - - -
-
-
- Order Wallet
-
-
-
Order Wa... -
-
- - - - - - - - - - - -
-
-
- inherits
-
-
-
inherits -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
- - - - -
-
-
- owner
-
-
-
owner -
-
- - - - -
-
-
- wallet
-
-
-
wallet -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - - -
-
-
- simulation wallet
-
-
-
simulat... -
-
- - - - -
-
-
- Credit
-
-
-
Credit -
-
- - - - -
-
-
- Debit
-
-
-
Debit -
-
- - - - -
-
-
- Transaction
-
-
-
Transaction -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - - -
-
-
- wallet
-
-
-
wallet -
-
- - - - -
-
-
- debits
-
-
-
debits -
-
- - - - -
-
-
- credits
-
-
-
credits -
-
- - - - -
-
-
- Currency
-
-
-
Currency -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- Connection
-
-
-
Connection -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
- - - - -
-
-
- wallet
-
-
-
wallet -
-
- - - - -
-
-
- owner
-
-
-
owner -
-
- - - - -
-
-
- wallet
-
-
-
wallet -
-
- - - - -
-
-
- currencies
-
-
-
currenc... -
-
- - - - -
-
-
- Connection Specific Args
-
-
-
Connection... -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- connection
-
-
-
connection -
-
- - - - -
-
-
- specific_args
-
-
-
specific_ar... -
-
- - - - -
-
-
- Order
-
-
-
Order -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
- - - - -
-
-
- wallet
-
-
-
wallet -
-
- - - - -
-
-
- owner
-
-
-
owner -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- connection
-
-
-
connection -
-
- - - - -
-
-
- Modifications
-
-
-
Modifications -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- order
-
-
-
order -
-
- - - - -
-
-
- modifications
-
-
-
modificatio... -
-
- - - - -
-
-
- Order Batch
-
-
-
Order Ba... -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- orders
-
-
-
orders -
-
- - - - -
-
-
- orders
-
-
-
orders -
-
- - - - -
-
-
- owner
-
-
-
owner -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- connections
-
-
-
connections -
-
- - - - -
-
-
- owner
-
-
-
owner -
-
- - - - -
-
-
- Controller
-
-
-
Controller -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- order_batches
-
-
-
order_batches -
-
- - - - -
-
-
- controller
-
-
-
controller -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- controllers
-
-
-
controlle... -
-
- - - - -
-
-
- exchange_account
-
-
-
exchange_account -
-
- - - - -
-
-
- batch
-
-
-
batch -
-
- - - - -
-
-
- Fleet
-
-
-
Fleet -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- fleets
-
-
-
fleets -
-
- - - - -
-
-
- Cluster
-
-
-
Cluster -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- fleet
-
-
-
fleet -
-
- - - - -
-
-
- clusters
-
-
-
clusters -
-
- - - - -
-
-
- Link
-
-
-
Link -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- cluster
-
-
-
cluster -
-
- - - - -
-
-
- links
-
-
-
links -
-
- - - - - -
-
-
- same
-
-
-
same -
-
- - - - -
-
-
- Bot
-
-
-
Bot -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
- - - - -
-
-
- link
-
-
-
link -
-
- - - - -
-
-
- bot
-
-
-
bot -
-
- - - - -
-
-
- (template) Bot
-
-
-
(template... -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
- - - - -
-
-
- Strategy
-
-
-
Strategy -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
- - - - -
-
-
- strategy
-
-
-
strategy -
-
- - - - -
-
-
- bot
-
-
-
bot -
-
- - - - -
-
-
- Plugin
-
-
-
Plugin -
-
- - - - -
-
-
- Architecture
-
-
-
Architecture -
-
- - - - -
-
-
- Config
-
-
-
Config -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
- ∞
-
-
-
-
-
- - - - -
-
-
- plugins
-
-
-
plugins -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
- - - - -
-
-
- strategy
-
-
-
strategy -
-
- - - - -
-
-
- architecture
-
-
-
architecture -
-
- - - - -
-
-
- config
-
-
-
config -
-
- - - - - -
-
-
-
- 1 -
-
-
-
-
1 -
-
- - - -
-
-
-
1
-
-
-
-
1 -
-
-
- - Text is not SVG - cannot display - -
\ No newline at end of file diff --git a/docs/plugins/griffe_doclinks.py b/docs/plugins/griffe_doclinks.py deleted file mode 100644 index 00dca481..00000000 --- a/docs/plugins/griffe_doclinks.py +++ /dev/null @@ -1,85 +0,0 @@ -import ast -import re -from functools import partial -from pathlib import Path -from typing import Tuple - -from griffe.dataclasses import Object as GriffeObject -from griffe.extensions import VisitorExtension -from pymdownx.slugs import slugify - -DOCS_PATH = Path(__file__).parent.parent -slugifier = slugify(case="lower") - - -def find_heading(content: str, slug: str, file_path: Path) -> Tuple[str, int]: - for m in re.finditer("^#+ (.+)", content, flags=re.M): - heading = m.group(1) - h_slug = slugifier(heading, "-") - if h_slug == slug: - return heading, m.end() - msg = f"heading with slug {slug!r} not found in {file_path}" - raise ValueError(msg) - - -def insert_at_top(path: str, api_link: str) -> str: - rel_file = path.rstrip("/") + ".md" - file_path = DOCS_PATH / rel_file - content = file_path.read_text() - second_heading = re.search("^#+ ", content, flags=re.M) - assert second_heading, "unable to find second heading in file" # noqa: S101 - first_section = content[: second_heading.start()] - - if f"[{api_link}]" not in first_section: - print(f'inserting API link "{api_link}" at the top of {file_path.relative_to(DOCS_PATH)}') - file_path.write_text('??? api "API Documentation"\n' f" [`{api_link}`][{api_link}]
\n\n{content}") # noqa: ISC001 - - heading = file_path.stem.replace("_", " ").title() - return f'!!! abstract "Usage Documentation"\n [{heading}](../{rel_file})\n' - - -def replace_links(m: re.Match, *, api_link: str) -> str: - path_group = m.group(1) - if "#" not in path_group: - # no heading id, put the content at the top of the page - return insert_at_top(path_group, api_link) - - usage_path, slug = path_group.split("#", 1) - rel_file = usage_path.rstrip("/") + ".md" - file_path = DOCS_PATH / rel_file - content = file_path.read_text() - heading, heading_end = find_heading(content, slug, file_path) - - next_heading = re.search("^#+ ", content[heading_end:], flags=re.M) - next_section = content[heading_end : heading_end + next_heading.start()] if next_heading else content[heading_end:] - - if f"[{api_link}]" not in next_section: - print(f'inserting API link "{api_link}" into {file_path.relative_to(DOCS_PATH)}') - file_path.write_text( - f"{content[:heading_end]}\n\n" '??? api "API Documentation"\n' f" [`{api_link}`][{api_link}]
" f"{content[heading_end:]}", # noqa: ISC001 - ) - - return f'!!! abstract "Usage Documentation"\n [{heading}](../{rel_file}#{slug})\n' - - -def update_docstring(obj: GriffeObject) -> str: - return re.sub( - r"usage[\- ]docs: ?https://docs\.pydantic\.dev/.+?/(\S+)", - partial(replace_links, api_link=obj.path), - obj.docstring.value, - flags=re.I, - ) - - -def update_docstrings_recursively(obj: GriffeObject) -> None: - if obj.docstring: - obj.docstring.value = update_docstring(obj) - for member in obj.members.values(): - if not member.is_alias: - update_docstrings_recursively(member) - - -class Extension(VisitorExtension): - def visit_module(self, node: ast.AST) -> None: - module = self.visitor.current.module - update_docstrings_recursively(module) diff --git a/docs/sources/guides/exchange_account.md b/docs/sources/guides/exchange_account.md index f36577c8..c6eb8522 100644 --- a/docs/sources/guides/exchange_account.md +++ b/docs/sources/guides/exchange_account.md @@ -21,9 +21,9 @@ binance_exchange = Exchange.objects.get(name = "BINANCE") from django_napse.core.models import ExchangeAccount new_binance_exchange_account = ExchangeAccount.objects.create( - exchange=binance_exchange, - testing=True - name="binance_test_exchange_account", - description="This is a test exchange account for Binance." - ) + exchange=binance_exchange, + testing=True + name="binance_test_exchange_account", + description="This is a test E.A." + ) ``` \ No newline at end of file diff --git a/docs/sources/quickstart.md b/docs/sources/quickstart.md index 4dea2c36..d59821eb 100644 --- a/docs/sources/quickstart.md +++ b/docs/sources/quickstart.md @@ -41,10 +41,51 @@ space = NapseSpace.objects.create( ) ``` +## Bot +--- + +Now let's build your first bot. A bot will trade on your behalf according to the strategy you have defined. +```python +from django_napse.core.models import Bot, EmptyBotConfig, Controller, SinglePairArchitecture, EmptyStrategy + +# Create the config of the bot +config = EmptyBotConfig.objects.create(space=space, settings={"empty": True}) +# The controller will discuss with the exchange's API +controller = Controller.get( + exchange_account=exchange_account, + base="BTC", + quote="USDT", + interval="1m", +) +# The architecture + architecture = SinglePairArchitecture.objects.create(constants={"controller": controller}) + strategy = EmptyStrategy.objects.create(config=config, architecture=architecture) + bot = Bot.objects.create(name="Test Bot", strategy=strategy) +``` + ## Fleet --- Then it's time to build a fleet. A fleet is a set of bot. This allows bots to be scaled up according to the amount of money they manage. ```python +from django_napse.core.models import Fleet +Fleet.objects.create( + name="Test Fleet", + exchange_account=exchange_account, + clusters=[ + { + "template_bot": bot, + "share": 0.7, + "breakpoint": 1000, + "autoscale": False, + }, + { + "template_bot": bot, + "share": 0.3, + "breakpoint": 1000, + "autoscale": True, + }, + ], +) ``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 0363c276..b96cc37f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -113,11 +113,6 @@ plugins: show_signature_annotations: true annotations_path: full line_length: 80 - # Contributors - - neoteroi.contribs: - contributors: - - email: Firenix.nex@gmail.com - image: https://avatars.githubusercontent.com/u/11559668?s=400&u=6564188698fbd519f21b7f400e522659e41a158e&v=4 markdown_extensions: diff --git a/pyproject.toml b/pyproject.toml index d2158dde..34bf411b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,8 @@ # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. # https://beta.ruff.rs/docs/rules/ -select = ["A", "B", "C", "D", "E", "F", "DTZ", "RUF", "S", "COM", "C4", "DJ", "EM", "ISC", "ICN", "PIE", "RET", "SLF", "SIM", "TID", "PD", "NPY"] -ignore = [ +lint.select = ["A", "B", "C", "D", "E", "F", "DTZ", "RUF", "S", "COM", "C4", "DJ", "EM", "ISC", "ICN", "PIE", "RET", "SLF", "SIM", "TID", "PD", "NPY"] +lint.ignore = [ "SLF001", # Private member accessed "D100", # Missing docstring in public module "D101", # Missing docstring in public class @@ -20,11 +20,11 @@ ignore = [ ] # Allow autofix for all enabled rules (when `--fix`) is provided -fixable = ["A", "B", "C", "D", "E", "F", "DTZ", "RUF", "S", "COM", "C4", "DJ", "EM", "ISC", "ICN", "PIE", "RET", "SLF", "SIM", "TID", "PD", "NPY"] -unfixable = [] +lint.fixable = ["A", "B", "C", "D", "E", "F", "DTZ", "RUF", "S", "COM", "C4", "DJ", "EM", "ISC", "ICN", "PIE", "RET", "SLF", "SIM", "TID", "PD", "NPY"] +lint.unfixable = [] # Exclude a variety of commonly ignored directories. -exclude = [ +lint.exclude = [ ".bzr", ".direnv", ".eggs", @@ -49,20 +49,20 @@ exclude = [ "migrations", ] -pydocstyle.convention ="google" +lint.pydocstyle.convention ="google" # Same as Black. line-length = 150 # Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" # Assume Python 3.11. target-version = "py311" -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "F403", "D104"] -[tool.ruff.mccabe] +[tool.ruff.lint.mccabe] # Unlike Flake8, default to a complexity level of 10. max-complexity = 10 diff --git a/requirements/core.txt b/requirements/core.txt index b8daf8aa..366ff3aa 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1,12 +1,12 @@ django==4.2.7 # https://www.djangoproject.com/ django-environ==0.11.2 # https://github.com/joke2k/django-environ django-celery-beat==2.5.0 # https://github.com/celery/django-celery-beat -drf-spectacular==0.26.5 # https://github.com/tfranzel/drf-spectacular +drf-spectacular==0.27.1 # https://github.com/tfranzel/drf-spectacular django-cors-headers==4.3.1 # https://github.com/adamchainz/django-cors-headers djangorestframework-api-key==3.0.0 psycopg2-binary==2.9.9 # https://github.com/psycopg/psycopg2 -celery==5.3.5 +celery==5.3.6 redis==5.0.1 python-binance==1.0.19 # https://github.com/sammchardy/python-binance diff --git a/requirements/development.txt b/requirements/development.txt index ecabce0b..20c6bd4f 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -2,22 +2,21 @@ -r simulations.txt tblib==3.0.0 # https://pypi.org/project/tblib/ -coverage==7.3.2 # https://github.com/nedbat/coveragepy +coverage==7.4.1 # https://github.com/nedbat/coveragepy watchfiles==0.21.0 # https://github.com/samuelcolvin/watchfiles Werkzeug==3.0.1 # https://github.com/pallets/werkzeug django-extensions==3.2.3 # https://github.com/django-extensions/django-extensions -django-debug-toolbar==4.2.0 # https://github.com/jazzband/django-debug-toolbar -ruff==0.1.8 # https://github.com/astral-sh/ruff -platformdirs==3.11.0 # https://github.com/platformdirs/platformdirs +django-debug-toolbar==4.3.0 # https://github.com/jazzband/django-debug-toolbar +ruff==0.2.1 # https://github.com/astral-sh/ruff +platformdirs==4.2.0 # https://github.com/platformdirs/platformdirs # Documentation -mkdocs-material==9.4.14 # https://github.com/squidfunk/mkdocs-material +mkdocs-material==9.5.9 # https://github.com/squidfunk/mkdocs-material pygments==2.17.2 # https://github.com/pygments/pygments mkdocs-plugin-inline-svg==0.1.0 # https://pypi.org/project/mkdocs-plugin-inline-svg mkdocs-coverage==1.0.0 # https://github.com/pawamoy/mkdocs-coverage -neoteroi-mkdocs==1.0.4 # https://github.com/Neoteroi/mkdocs-plugins -drf-spectacular==0.26.5 # https://github.com/tfranzel/drf-spectacular +neoteroi-mkdocs==1.0.5 # https://github.com/Neoteroi/mkdocs-plugins +drf-spectacular==0.27.1 # https://github.com/tfranzel/drf-spectacular mkdocstrings==0.24.0 # https://github.com/mkdocstrings/mkdocstrings -mkdocstrings-python==1.7.5 # https://github.com/mkdocstrings/python -watchfiles==0.21.0 # https://github.com/samuelcolvin/watchfiles +mkdocstrings-python==1.8.0 # https://github.com/mkdocstrings/python diff --git a/requirements/simulations.txt b/requirements/simulations.txt index b440dc2e..3105b202 100644 --- a/requirements/simulations.txt +++ b/requirements/simulations.txt @@ -1 +1 @@ -pandas==2.1.3 \ No newline at end of file +pandas==2.2.0 \ No newline at end of file diff --git a/setup/setup-osx.sh b/setup/setup-osx.sh index d742fe93..af589fef 100755 --- a/setup/setup-osx.sh +++ b/setup/setup-osx.sh @@ -1,9 +1,9 @@ brew install python@3.11 pip3 install virtualenv -pip3 install pip-tools python3 -m virtualenv .venv --python=python3.11 printf "\n===============================================\nVirtual python environment has been created.\n" source .venv/bin/activate +pip3 install pip-tools printf "Virtual python environment has been activated.\n" curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 printf "Compiling requirements... This may take a few minutes.\n" diff --git a/setup/setup-unix.sh b/setup/setup-unix.sh index 1b4d0184..aecb35ea 100755 --- a/setup/setup-unix.sh +++ b/setup/setup-unix.sh @@ -9,17 +9,13 @@ pip3 install --upgrade pip rm -rf .venv pip install virtualenv -pip install pip-tools python3 -m virtualenv .venv --python=python3.11 printf "\n===============================================\nVirtual python environment has been created.\n" source .venv/bin/activate printf "Virtual python environment has been activated.\n" curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 +pip install pip-tools printf "Compiling requirements... This may take a few minutes.\n" pip-compile ./requirements/development.txt --output-file ./full-requirements.txt --resolver=backtracking --strip-extras pip install -r ./full-requirements.txt -# deactivate -# pip uninstall pip-tools -y -# source .venv/bin/activate - printf "Done installing requirements for local .venv!\nHave fun coding!\n" diff --git a/setup/setup-windows.ps1 b/setup/setup-windows.ps1 index 28e448ed..9817493e 100644 --- a/setup/setup-windows.ps1 +++ b/setup/setup-windows.ps1 @@ -17,7 +17,7 @@ Write-Host "Virtual env is now activated " python.exe -m pip install --upgrade pip python.exe -m pip install pip-tools -pip-compile .\backend\requirements\development.txt --output-file .\full-requirements.txt --resolver=backtracking +pip-compile .\requirements\development.txt --output-file .\full-requirements.txt --resolver=backtracking python -m pip install -r .\full-requirements.txt Write-Host "Have fun with coding" \ No newline at end of file diff --git a/tests/django_tests/db/fleets/test_fleet.py b/tests/django_tests/db/fleets/test_fleet.py index f010abcd..f9d0568a 100644 --- a/tests/django_tests/db/fleets/test_fleet.py +++ b/tests/django_tests/db/fleets/test_fleet.py @@ -2,7 +2,7 @@ from django_napse.utils.model_test_case import ModelTestCase """ -python tests/test_app/manage.py test tests.django_tests.fleets.test_fleet -v2 --keepdb --parallel +python tests/test_app/manage.py test tests.django_tests.db.fleets.test_fleet -v2 --keepdb --parallel """ diff --git a/tests/django_tests/db/simulations/test_simulation.py b/tests/django_tests/db/simulations/test_simulation.py index 788024b4..6be2ecc6 100644 --- a/tests/django_tests/db/simulations/test_simulation.py +++ b/tests/django_tests/db/simulations/test_simulation.py @@ -8,7 +8,7 @@ from django_napse.utils.model_test_case import ModelTestCase """ -python tests/test_app/manage.py test tests.django_tests.simulations.test_simulation -v2 --keepdb --parallel +python tests/test_app/manage.py test tests.django_tests.db.simulations.test_simulation -v2 --keepdb --parallel """ diff --git a/tests/django_tests/db/simulations/test_simulation_queue.py b/tests/django_tests/db/simulations/test_simulation_queue.py index bc698e6b..03316923 100644 --- a/tests/django_tests/db/simulations/test_simulation_queue.py +++ b/tests/django_tests/db/simulations/test_simulation_queue.py @@ -7,7 +7,7 @@ from django_napse.utils.model_test_case import ModelTestCase """ -python tests/test_app/manage.py test tests.django_tests.simulations.test_simulation_queue -v2 --keepdb --parallel +python tests/test_app/manage.py test tests.django_tests.db.simulations.test_simulation_queue -v2 --keepdb --parallel """