Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
145 changes: 126 additions & 19 deletions api/logics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions api/management/commands/clean_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions api/migrations/0056_add_price_limit_fields.py
Original file line number Diff line number Diff line change
@@ -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)]),
),
]
14 changes: 14 additions & 0 deletions api/migrations/0058_merge_20260206_1213.py
Original file line number Diff line number Diff line change
@@ -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 = [
]
3 changes: 3 additions & 0 deletions api/models/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class Meta:
"type",
"currency",
"amount",
"price_limit",
"auto_paused",
"has_range",
"min_amount",
"max_amount",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -501,6 +515,8 @@ class Meta:
"longitude",
"chat_last_index",
"description",
"price_limit",
"auto_paused",
"bad_request",
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -566,6 +593,8 @@ class Meta:
"bond_size",
"latitude",
"longitude",
"price_limit",
"auto_paused",
)


Expand Down Expand Up @@ -609,6 +638,7 @@ class Meta:
"longitude",
"password",
"description",
"price_limit",
)


Expand Down
9 changes: 8 additions & 1 deletion api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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})"
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading