diff --git a/api/errors.py b/api/errors.py index efaaae614..e7215f096 100644 --- a/api/errors.py +++ b/api/errors.py @@ -59,6 +59,9 @@ 1051: "More than 5000 market ticks have been found. Please, narrow the date range", 1052: "Robot has no finished order", 1053: "Wrong hex pubkey", + 1054: "For sell orders, price limit must be below current exchange rate", + 1055: "For buy orders, price limit must be above current exchange rate", + 1056: "Cannot unpause: current exchange rate ({exchange_rate:.2f}) exceeds your limit ({price_limit})", # 2000 - Bad statement 2000: "The statement and chat logs are longer than 50,000 characters", 2001: "The statement is too short. Make sure to be thorough.", diff --git a/api/logics.py b/api/logics.py index 4dee2a8d1..7313d1cb4 100644 --- a/api/logics.py +++ b/api/logics.py @@ -164,6 +164,85 @@ def user_activity_status(last_seen): else: return "Inactive" + + + @classmethod + def check_price_limit(cls, order): + """ + Checks if a public order should be auto-paused based on price limit. + + For SELL orders (maker is selling BTC): pause if price falls BELOW the limit + For BUY orders (maker is buying BTC): pause if price rises ABOVE the limit + + Returns: (should_pause: bool, current_price: float) + """ + if order.price_limit is None: + return False, None + + if order.status != Order.Status.PUB: + return False, None + order.currency.refresh_from_db() + exchange_rate = order.currency.exchange_rate + price_limit = float(order.price_limit) + + # For SELL orders: maker doesn't want to sell if price drops too low + if order.type == Order.Types.SELL: + should_pause = exchange_rate < price_limit + # For BUY orders: maker doesn't want to buy if price rises too high + else: # Order.Types.BUY + should_pause = exchange_rate > price_limit + + return should_pause, exchange_rate + + + @classmethod + def auto_pause_order_by_price(cls, order): + """ + Auto-pauses an order if the price limit is exceeded. + Returns True if order was paused, False otherwise. + """ + should_pause, exchange_rate = cls.check_price_limit(order) + + if should_pause and order.status == Order.Status.PUB: + order.update_status(Order.Status.PAU) + order.auto_paused = True + order.save(update_fields=["auto_paused"]) + order.log( + f"Order auto-paused due to price limit. Exchange rate: {exchange_rate}, Limit: {order.price_limit}" + ) + # Send nostr event for paused order + nostr_send_order_event.delay(order_id=order.id) + + return True + + return False + + + @classmethod + def validate_price_limit(cls, order): + """ + Validates the price limit for an order. + The limit must make sense for the order type: + - For SELL: limit should be below current price (don't sell below this) + - For BUY: limit should be above current price (don't buy above this) + """ + if order.price_limit is None: + return True, None + + exchange_rate = float(order.currency.exchange_rate) + price_limit = float(order.price_limit) + + if order.type == Order.Types.SELL: + # For sell orders, limit should be a lower bound (below current price) + if price_limit >= exchange_rate: + return False, new_error(1054) + else: # BUY order + # For buy orders, limit should be an upper bound (above current price) + if price_limit <= exchange_rate: + return False,new_error(1055) + + return True, None + @classmethod def take(cls, order, user, amount=None): is_penalized, time_out = cls.is_penalized(user) @@ -1742,29 +1821,57 @@ def undo_confirm_fiat_sent(cls, order, user): return True, None def pause_unpause_public_order(order, user): + from api.tasks import nostr_send_order_event + if not order.maker == user: return False, new_error(1032) - else: - if order.status == Order.Status.PUB: - order.update_status(Order.Status.PAU) - order.log( - f"Robot({user.robot.id},{user.username}) paused the public order" - ) - nostr_send_order_event.delay(order_id=order.id) - elif order.status == Order.Status.PAU: - order.update_status(Order.Status.PUB) - order.log( - f"Robot({user.robot.id},{user.username}) made public the paused order" - ) + if order.status == Order.Status.PUB: + # Pause the order + order.update_status(Order.Status.PAU) + order.auto_paused = False # Manual pause + order.save(update_fields=["auto_paused"]) + order.log( + f"Robot({user.robot.id},{user.username}) paused the public order" + ) + nostr_send_order_event.delay(order_id=order.id) - nostr_send_order_event.delay(order_id=order.id) - else: - order.log( - f"Robot({user.robot.id},{user.username}) tried to pause/unpause an order that was not public or paused", - level="WARN", - ) - return False, new_error(1033) + elif order.status == Order.Status.PAU: + # Check if price limit would be exceeded before allowing unpause + if order.price_limit is not None: + should_pause, exchange_rate = Logics.check_price_limit(order) + # Temporarily set status to PUB to check price limit + original_status = order.status + order.status = Order.Status.PUB + should_pause, exchange_rate = Logics.check_price_limit(order) + order.status = original_status # Restore original status + + if should_pause: + order.log( + f"Robot({user.robot.id},{user.username}) tried to unpause but price limit still exceeded. " + f"Current exchange_rate: {exchange_rate}, Limit: {order.price_limit}", + level="WARN", + ) + return False,new_error(1056 , { + "exchange_rate": exchange_rate, + "price_limit": order.price_limit, + }) + + # Unpause the order + order.update_status(Order.Status.PUB) + order.auto_paused = False + order.save(update_fields=["auto_paused"]) + order.log( + f"Robot({user.robot.id},{user.username}) made public the paused order" + ) + nostr_send_order_event.delay(order_id=order.id) + + else: + order.log( + f"Robot({user.robot.id},{user.username}) tried to pause/unpause an order that was not public or paused", + level="WARN", + ) + return False, new_error(1033) return True, None diff --git a/api/management/commands/clean_orders.py b/api/management/commands/clean_orders.py index 8caf4bf7d..9f256ab72 100644 --- a/api/management/commands/clean_orders.py +++ b/api/management/commands/clean_orders.py @@ -111,12 +111,43 @@ def clean_orders(self): self.stdout.write(str(timezone.now())) self.stdout.write(str(debug)) + def check_price_limits(self): + """ + Checks all public orders with price limits and auto-pauses + them if the limit is exceeded. + """ + + queryset = Order.objects.filter( + status=Order.Status.PUB, + price_limit__isnull=False + ) + + debug = { + "num_orders_checked": len(queryset), + "auto_paused_orders": [] + } + + for order in queryset: + try: + if Logics.auto_pause_order_by_price(order): + debug["auto_paused_orders"].append({ + "order_id": order.id, + "price_limit": str(order.price_limit) + }) + except Exception as e: + self.stdout.write(f"Error checking price limit for order {order.id}: {e}") + + if len(debug["auto_paused_orders"]) > 0: + self.stdout.write(str(timezone.now())) + self.stdout.write(f"Price limit checks: {debug}") + def handle(self, *args, **options): """Never mind database locked error, keep going, print them out. Not an issue with PostgresQL""" try: while True: self.clean_orders() + self.check_price_limits() time.sleep(5) except Exception as e: diff --git a/api/migrations/0056_add_price_limit_fields.py b/api/migrations/0056_add_price_limit_fields.py new file mode 100644 index 000000000..5dc419c3f --- /dev/null +++ b/api/migrations/0056_add_price_limit_fields.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.15 on 2026-01-23 13:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0055_order_description'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='auto_paused', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='order', + name='price_limit', + field=models.DecimalField(blank=True, decimal_places=8, default=None, max_digits=18, null=True), + ), + migrations.AlterField( + model_name='order', + name='escrow_duration', + field=models.PositiveBigIntegerField(default=10799, validators=[django.core.validators.MinValueValidator(1800), django.core.validators.MaxValueValidator(36000)]), + ), + ] diff --git a/api/migrations/0058_merge_20260206_1213.py b/api/migrations/0058_merge_20260206_1213.py new file mode 100644 index 000000000..aebf40630 --- /dev/null +++ b/api/migrations/0058_merge_20260206_1213.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.15 on 2026-02-06 12:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0056_add_price_limit_fields'), + ('api', '0057_robot_webhook_enabled_alter_order_escrow_duration'), + ] + + operations = [ + ] diff --git a/api/models/order.py b/api/models/order.py index 612486fdd..e5af955c9 100644 --- a/api/models/order.py +++ b/api/models/order.py @@ -84,6 +84,9 @@ class ExpiryReasons(models.IntegerChoices): payment_method = models.CharField( max_length=70, null=False, default="not specified", blank=True ) + price_limit = models.DecimalField(max_digits=18, decimal_places=8, null=True, blank=True, default=None) + auto_paused = models.BooleanField(default=False, null=False) + # order pricing method. A explicit amount of sats, or a relative premium above/below market. is_explicit = models.BooleanField(default=False, null=False) # marked to market diff --git a/api/serializers.py b/api/serializers.py index 220d7bb5b..6de049691 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -76,6 +76,8 @@ class Meta: "type", "currency", "amount", + "price_limit", + "auto_paused", "has_range", "min_amount", "max_amount", @@ -219,6 +221,18 @@ class OrderDetailSerializer(serializers.ModelSerializer): "- **'Inactive'** (seen more than 10 min ago)\n\n" "Note: When you make a request to this route, your own status get's updated and can be seen by your counterparty", ) + + price_limit = serializers.DecimalField( + max_digits=18, + decimal_places=8, + required=False, + allow_null=True, + help_text="Price limit for auto-pause. For sellers: lower bound (pause if price falls below). For buyers: upper bound (pause if price rises above).", + ) + auto_paused = serializers.BooleanField( + required=False, + help_text="True if the order was automatically paused due to price limit", + ) taker_status = serializers.CharField( required=False, help_text="Status of the maker:\n" @@ -501,6 +515,8 @@ class Meta: "longitude", "chat_last_index", "description", + "price_limit", + "auto_paused", "bad_request", ) @@ -539,6 +555,17 @@ class OrderPublicSerializer(serializers.ModelSerializer): help_text="The amount of sats to be traded at the present moment (not including the fees)", required=False, ) + price_limit = serializers.DecimalField( + max_digits=18, + decimal_places=8, + required=False, + allow_null=True, + help_text="Price limit for auto-pause. For sellers: lower bound (pause if price falls below). For buyers: upper bound (pause if price rises above).", + ) + auto_paused = serializers.BooleanField( + default=False, + help_text="Whether the order is automatically paused or not." + ) class Meta: model = Order @@ -566,6 +593,8 @@ class Meta: "bond_size", "latitude", "longitude", + "price_limit", + "auto_paused", ) @@ -609,6 +638,7 @@ class Meta: "longitude", "password", "description", + "price_limit", ) diff --git a/api/views.py b/api/views.py index 6b1719b2d..a956df366 100644 --- a/api/views.py +++ b/api/views.py @@ -126,6 +126,7 @@ def post(self, request): longitude = serializer.data.get("longitude") password = serializer.data.get("password") description = serializer.data.get("description") + price_limit = serializer.data.get("price_limit") # Optional params if public_duration is None: @@ -174,6 +175,7 @@ def post(self, request): longitude=longitude, password=password, description=description, + price_limit=price_limit, ) order.last_satoshis = order.t0_satoshis = Logics.satoshis_now(order) @@ -186,6 +188,10 @@ def post(self, request): if not valid: return Response(context, status.HTTP_400_BAD_REQUEST) + if price_limit is not None: + valid, context = Logics.validate_price_limit(order) + if not valid: + return Response(context, status.HTTP_400_BAD_REQUEST) order.save() order.log( f"Order({order.id},{order}) created by Robot({request.user.robot.id},{request.user})" @@ -255,7 +261,8 @@ def get(self, request, format=None): data["maker_hash_id"] = str(order.maker.robot.hash_id) data["maker_nostr_pubkey"] = str(order.maker.robot.nostr_pubkey) data["description"] = order.description - + data["price_limit"] = str(order.price_limit) if order.price_limit else None + data["auto_paused"] = order.auto_paused # Add activity status of participants based on last_seen data["maker_status"] = Logics.user_activity_status(order.maker.last_login) if order.taker is not None: diff --git a/docs/assets/schemas/api-latest.yaml b/docs/assets/schemas/api-latest.yaml index e61b5ace6..de27ad933 100644 --- a/docs/assets/schemas/api-latest.yaml +++ b/docs/assets/schemas/api-latest.yaml @@ -1337,6 +1337,13 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ nullable: true + price_limit: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + auto_paused: + type: boolean has_range: type: boolean min_amount: @@ -1479,6 +1486,11 @@ components: type: string nullable: true maxLength: 240 + price_limit: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true required: - currency - type @@ -1800,6 +1812,16 @@ components: type: string nullable: true description: Order description + price_limit: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ + nullable: true + description: 'Price limit for auto-pause. For sellers: lower bound (pause + if price falls below). For buyers: upper bound (pause if price rises above).' + auto_paused: + type: boolean + description: True if the order was automatically paused due to price limit bad_request: type: string description: Error message when order is in a terminated state (e.g. cancelled) @@ -1832,6 +1854,12 @@ components: format: decimal pattern: ^-?\d{0,10}(?:\.\d{0,8})?$ nullable: true + price_limit: + type: number + format: decimal + nullable: true + auto_paused: + type: boolean has_range: type: boolean min_amount: diff --git a/tests/test_trade_pipeline.py b/tests/test_trade_pipeline.py index 724ee49df..cf9eb95fb 100644 --- a/tests/test_trade_pipeline.py +++ b/tests/test_trade_pipeline.py @@ -4,7 +4,7 @@ from decouple import config from django.contrib.auth.models import User from django.urls import reverse - +from api.logics import Logics from api.models import Currency, Order from api.tasks import cache_market from django.utils import timezone @@ -15,7 +15,7 @@ from tests.utils.node import add_invoice, set_up_regtest_network from tests.utils.pgp import sign_message from tests.utils.trade import Trade, maker_form_buy_with_range - +from tests.utils.price import PriceUtil from api.admin import OrderAdmin @@ -2108,3 +2108,63 @@ def test_robot_creation_with_missing_nostr_pubkey(self): data = response.json() self.assertIn("error_code", data) self.assertEqual(data["error_code"], 7000) + def test_low_price_limit_in_buy_order(self): + """ + Tests low price limit in buy order + """ + price_limit_maker = maker_form_buy_with_range.copy() + price = PriceUtil(price_limit_maker["currency"]) + current_price = price.get_rate() + price_limit = current_price - 999 + price_limit_maker["price_limit"] = price_limit + trade = Trade(self.client, price_limit_maker) + self.assertEqual(trade.response.status_code, 400) + data = trade.response.json() + self.assertEqual(data["error_code"], 1055) + self.assertEqual( + data["bad_request"], + "For buy orders, price limit must be above current exchange rate", + ) + + def test_high_price_limit_in_sell_order(self): + """ + Tests high price limit in sell order + """ + price_limit_maker = maker_form_buy_with_range.copy() + price = PriceUtil(price_limit_maker["currency"]) + current_price = price.get_rate() + price_limit_maker["type"] = Order.Types.SELL + price_limit = current_price + 999 + price_limit_maker["price_limit"] = price_limit + trade = Trade(self.client, price_limit_maker) + data = trade.response.json() + self.assertEqual(trade.response.status_code, 400) + self.assertEqual(data["error_code"], 1054) + self.assertEqual( + data["bad_request"], + "For sell orders, price limit must be below current exchange rate", + ) + + def test_price_limit_auto_pause(self): + """ + Tests price limit auto pause + """ + price_limit_maker = maker_form_buy_with_range.copy() + price = PriceUtil(price_limit_maker["currency"]) + current_price = price.get_rate() + price_limit_maker["price_limit"] = current_price + 999 + trade = Trade(self.client, price_limit_maker) + trade.publish_order() + self.assertEqual(trade.response.status_code, 200) + data = trade.response.json() + self.assertEqual(data["status_message"], Order.Status(Order.Status.PUB).label) + + # Raise the price + price.set_rate(current_price + 1000) + should_pause = Logics.auto_pause_order_by_price(order=Order.objects.get(id=trade.order_id)) + self.assertTrue(should_pause) + + trade.get_order() + data = trade.response.json() + self.assertEqual(data["status_message"], Order.Status(Order.Status.PAU).label) + self.assertTrue(data["auto_paused"]) diff --git a/tests/utils/price.py b/tests/utils/price.py new file mode 100644 index 000000000..9b0ed0878 --- /dev/null +++ b/tests/utils/price.py @@ -0,0 +1,46 @@ +from decimal import Decimal +from django.utils import timezone + +from api.models import Currency + + +class PriceUtil: + """ + Utility for manipulating exchange rates in tests. + + - Reads current exchange rate + - Updates exchange rate + - Restores original rate + """ + + def __init__(self, currency_code=1): + self.currency_code = currency_code + self.currency = Currency.objects.get(id=currency_code) + + # Save original state (important!) + self.original_rate = self.currency.exchange_rate + self.original_timestamp = self.currency.timestamp + + # ------------------------- + # Exchange rate operations + # ------------------------- + + def get_rate(self) -> Decimal: + """Return current exchange rate""" + self.currency.refresh_from_db() + return self.currency.exchange_rate + + def set_rate(self, rate) -> Decimal: + """Set exchange rate to an exact value""" + self.currency.exchange_rate = Decimal(rate) + self.currency.timestamp = timezone.now() + self.currency.save(update_fields=["exchange_rate", "timestamp"]) + return self.currency.exchange_rate + + def restore(self) -> Decimal: + """Restore exchange rate to original value""" + self.currency.exchange_rate = self.original_rate + self.currency.timestamp = timezone.now() + self.currency.save(update_fields=["exchange_rate", "timestamp"]) + return self.currency.exchange_rate +