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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add amendment_count and amendment_log to swap_orders."""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision: str = "20260601_0005"
down_revision: Union[str, None] = "20260530_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"swap_orders",
sa.Column(
"amendment_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"swap_orders",
sa.Column(
"amendment_log",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="[]",
),
)


def downgrade() -> None:
op.drop_column("swap_orders", "amendment_log")
op.drop_column("swap_orders", "amendment_count")
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Add trigger_price and valid_from to swap_orders for advanced order conditions."""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

revision: str = "20260601_0006"
down_revision: Union[str, None] = "20260601_0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"swap_orders",
sa.Column("trigger_price", sa.Numeric(precision=36, scale=18), nullable=True),
)
op.add_column(
"swap_orders",
sa.Column("valid_from", sa.BigInteger(), nullable=True),
)
op.create_index(
op.f("ix_swap_orders_valid_from"), "swap_orders", ["valid_from"], unique=False
)


def downgrade() -> None:
op.drop_index(op.f("ix_swap_orders_valid_from"), table_name="swap_orders")
op.drop_column("swap_orders", "valid_from")
op.drop_column("swap_orders", "trigger_price")
8 changes: 6 additions & 2 deletions backend/app/models/order.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import uuid
from sqlalchemy import Column, String, BigInteger, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import Column, String, BigInteger, Integer, Numeric, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from .base import Base, TimestampMixin


Expand All @@ -27,3 +27,7 @@ class SwapOrder(Base, TimestampMixin):
expiry = Column(BigInteger, nullable=False, index=True)
status = Column(String, nullable=False, default="open", index=True)
counterparty = Column(String, nullable=True)
amendment_count = Column(Integer, nullable=False, default=0)
amendment_log = Column(JSONB, nullable=False, default=list)
trigger_price = Column(Numeric(36, 18), nullable=True)
valid_from = Column(BigInteger, nullable=True, index=True)
81 changes: 79 additions & 2 deletions backend/app/routes/orders.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Order book endpoints: create, list, match, cancel (#26, #59)."""
"""Order book endpoints: create, list, match, cancel, amend (#26, #59, #512)."""

from datetime import datetime, timezone
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
Expand All @@ -8,7 +9,7 @@
from app.config.database import get_db
from app.config.redis import get_redis, CacheService
from app.models.order import SwapOrder
from app.schemas.order import OrderCreate, OrderResponse, OrderMatch
from app.schemas.order import OrderAmend, OrderCreate, OrderResponse, OrderMatch
from app.middleware.auth import require_api_key
from app.services.order_matching import OrderMatchingService
from app.ws.events import emit_order_event, EventType
Expand Down Expand Up @@ -46,6 +47,8 @@ async def create_order(
to_amount=data.to_amount,
min_fill_amount=data.min_fill_amount,
expiry=data.expiry,
trigger_price=data.trigger_price,
valid_from=data.valid_from,
status="open",
)
db.add(order)
Expand Down Expand Up @@ -148,6 +151,80 @@ async def match_order(
return response


@router.patch("/{order_id}/amend", response_model=OrderResponse)
async def amend_order(
order_id: str,
data: OrderAmend,
db: AsyncSession = Depends(get_db),
_=Depends(require_api_key),
):
result = await db.execute(select(SwapOrder).where(SwapOrder.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status != "open":
raise HTTPException(status_code=400, detail="Only open orders can be amended")

changes: dict = {}
if data.from_amount is not None and data.from_amount != order.from_amount:
changes["from_amount"] = {"before": int(order.from_amount), "after": data.from_amount}
order.from_amount = data.from_amount
if data.to_amount is not None and data.to_amount != order.to_amount:
changes["to_amount"] = {"before": int(order.to_amount), "after": data.to_amount}
order.to_amount = data.to_amount
if data.min_fill_amount is not None and data.min_fill_amount != order.min_fill_amount:
changes["min_fill_amount"] = {
"before": int(order.min_fill_amount) if order.min_fill_amount is not None else None,
"after": data.min_fill_amount,
}
order.min_fill_amount = data.min_fill_amount
if data.expiry is not None and data.expiry != order.expiry:
changes["expiry"] = {"before": int(order.expiry), "after": data.expiry}
order.expiry = data.expiry
if data.trigger_price is not None and data.trigger_price != order.trigger_price:
changes["trigger_price"] = {
"before": float(order.trigger_price) if order.trigger_price is not None else None,
"after": float(data.trigger_price),
}
order.trigger_price = data.trigger_price
if data.valid_from is not None and data.valid_from != order.valid_from:
new_expiry = data.expiry if data.expiry is not None else int(order.expiry)
if data.valid_from >= new_expiry:
raise HTTPException(
status_code=400, detail="valid_from must be earlier than expiry"
)
changes["valid_from"] = {
"before": int(order.valid_from) if order.valid_from is not None else None,
"after": data.valid_from,
}
order.valid_from = data.valid_from

if not changes:
raise HTTPException(status_code=400, detail="No fields changed")

entry = {
"sequence": int(order.amendment_count or 0) + 1,
"amended_at": datetime.now(timezone.utc).isoformat(),
"changes": changes,
}
if data.note:
entry["note"] = data.note

order.amendment_count = int(order.amendment_count or 0) + 1
order.amendment_log = list(order.amendment_log or []) + [entry]

await db.commit()
await db.refresh(order)

redis = get_redis()
cache = CacheService(redis)
await cache.invalidate_pattern("orders:*")

response = OrderResponse.model_validate(order)
await emit_order_event(redis, EventType.ORDER_UPDATED, response.model_dump())
return response


@router.post("/{order_id}/cancel", response_model=OrderResponse)
async def cancel_order(
order_id: str, db: AsyncSession = Depends(get_db), _=Depends(require_api_key)
Expand Down
48 changes: 47 additions & 1 deletion backend/app/schemas/order.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from decimal import Decimal
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional
from typing import Any, Optional
from datetime import datetime

from app.utils.address_validation import (
Expand All @@ -19,6 +20,8 @@ class OrderCreate(BaseModel):
to_amount: int = Field(gt=0)
min_fill_amount: Optional[int] = None
expiry: int = Field(gt=0)
trigger_price: Optional[Decimal] = Field(default=None, gt=0)
valid_from: Optional[int] = Field(default=None, gt=0)

@field_validator("from_chain", "to_chain")
@classmethod
Expand All @@ -39,6 +42,45 @@ def validate_creator_address(self):
)
return self

@model_validator(mode="after")
def validate_time_window(self):
if self.valid_from is not None and self.valid_from >= self.expiry:
raise ValueError("valid_from must be earlier than expiry")
return self


class OrderAmend(BaseModel):
from_amount: Optional[int] = Field(default=None, gt=0)
to_amount: Optional[int] = Field(default=None, gt=0)
min_fill_amount: Optional[int] = Field(default=None, gt=0)
expiry: Optional[int] = Field(default=None, gt=0)
trigger_price: Optional[Decimal] = Field(default=None, gt=0)
valid_from: Optional[int] = Field(default=None, gt=0)
note: Optional[str] = None

@model_validator(mode="after")
def at_least_one_field(self):
if all(
v is None
for v in (
self.from_amount,
self.to_amount,
self.min_fill_amount,
self.expiry,
self.trigger_price,
self.valid_from,
)
):
raise ValueError("At least one amendable field must be provided")
return self


class OrderAmendmentEntry(BaseModel):
sequence: int
amended_at: str
changes: dict[str, Any]
note: Optional[str] = None


class OrderMatch(BaseModel):
counterparty: str
Expand Down Expand Up @@ -69,6 +111,10 @@ class OrderResponse(BaseModel):
status: str
counterparty: Optional[str] = None
created_at: Optional[datetime] = None
amendment_count: int = 0
amendment_log: list[dict[str, Any]] = []
trigger_price: Optional[Decimal] = None
valid_from: Optional[int] = None

class Config:
from_attributes = True
23 changes: 21 additions & 2 deletions backend/app/services/order_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ def _is_expired(order: SwapOrder) -> bool:
return int(order.expiry) <= int(datetime.now(timezone.utc).timestamp())


def _is_not_yet_active(order: SwapOrder) -> bool:
if order.valid_from is None:
return False
return int(datetime.now(timezone.utc).timestamp()) < int(order.valid_from)


def _meets_trigger_price(order: SwapOrder, execution_price: Fraction) -> bool:
if order.trigger_price is None:
return True
return execution_price >= Fraction(order.trigger_price)


def _counterparty_label(existing: str | None, new_value: str) -> str:
if not existing or existing == new_value:
return new_value
Expand Down Expand Up @@ -72,7 +84,7 @@ class OrderMatchingService:
"""Price-time-priority matcher for reciprocal open orders."""

async def match_order(self, db: AsyncSession, order: SwapOrder) -> MatchingSummary:
if order.status not in {"open", "matched"} or _is_expired(order):
if order.status not in {"open", "matched"} or _is_expired(order) or _is_not_yet_active(order):
return MatchingSummary(
total_matches=0,
filled_amount=int(order.filled_amount or 0),
Expand Down Expand Up @@ -111,7 +123,7 @@ def candidate_price_key(candidate: SwapOrder) -> Fraction:
):
if _remaining(order) <= 0:
break
if _remaining(candidate) <= 0 or _is_expired(candidate):
if _remaining(candidate) <= 0 or _is_expired(candidate) or _is_not_yet_active(candidate):
continue
if not self._is_price_compatible(order, candidate):
continue
Expand All @@ -133,6 +145,13 @@ def candidate_price_key(candidate: SwapOrder) -> Fraction:
):
continue

taker_execution_price = Fraction(counterparty_fill, max_fill)
if not _meets_trigger_price(order, taker_execution_price):
continue
maker_execution_price = Fraction(max_fill, counterparty_fill)
if not _meets_trigger_price(candidate, maker_execution_price):
continue

order.filled_amount = int(order.filled_amount or 0) + max_fill
candidate.filled_amount = (
int(candidate.filled_amount or 0) + counterparty_fill
Expand Down
1 change: 1 addition & 0 deletions backend/app/ws/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class EventType(str, Enum):
ORDER_MATCHED = "order.matched"
ORDER_CANCELLED = "order.cancelled"
ORDER_FILLED = "order.filled"
ORDER_UPDATED = "order.updated"


def _build_event(event_type: EventType, channel: str, data: Any) -> str:
Expand Down
Loading