Skip to content
Merged
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
35 changes: 35 additions & 0 deletions api/alembic/versions/b3c4d5e6f7a8_grid_ladder_tighten_enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""grid ladder: make exchange/market_type NOT NULL

Revision ID: b3c4d5e6f7a8
Revises: a2b3c4d5e6f7
Create Date: 2026-05-20 00:00:00.000000

"""

from typing import Sequence, Union

from alembic import op


# revision identifiers, used by Alembic.
revision: str = "b3c4d5e6f7a8"
down_revision: Union[str, Sequence[str], None] = "a2b3c4d5e6f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# Backfill any rows missing the enum value before we tighten the column.
op.execute("UPDATE grid_ladder SET exchange = 'KUCOIN' WHERE exchange IS NULL")
op.execute(
"UPDATE grid_ladder SET market_type = 'FUTURES' WHERE market_type IS NULL"
)
op.alter_column("grid_ladder", "exchange", nullable=False)
op.alter_column("grid_ladder", "market_type", nullable=False)


def downgrade() -> None:
"""Downgrade schema."""
op.alter_column("grid_ladder", "exchange", nullable=True)
op.alter_column("grid_ladder", "market_type", nullable=True)
6 changes: 3 additions & 3 deletions api/databases/crud/grid_ladder_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Any, cast
from uuid import UUID

from pybinbot import GridLadderStatus, timestamp
from pybinbot import ExchangeId, GridLadderStatus, MarketType, timestamp
from sqlalchemy.orm import QueryableAttribute, selectinload
from sqlalchemy.orm.attributes import flag_modified
from sqlmodel import Session, select, desc
Expand Down Expand Up @@ -32,8 +32,8 @@ def create(
*,
symbol: str,
fiat: str,
exchange: str,
market_type: str,
exchange: ExchangeId | str,
market_type: MarketType | str,
algorithm_name: str,
range_low: float,
range_high: float,
Expand Down
8 changes: 6 additions & 2 deletions api/databases/tables/grid_ladder_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,15 @@ class GridLadderTable(SQLModel, table=True):
fiat: str = Field(default="USDC", index=True)
exchange: ExchangeId = Field(
default=ExchangeId.KUCOIN,
sa_column=Column(Enum(ExchangeId, name="grid_ladder_exchange_enum")),
sa_column=Column(
Enum(ExchangeId, name="grid_ladder_exchange_enum"), nullable=False
),
)
market_type: MarketType = Field(
default=MarketType.FUTURES,
sa_column=Column(Enum(MarketType, name="grid_ladder_market_type_enum")),
sa_column=Column(
Enum(MarketType, name="grid_ladder_market_type_enum"), nullable=False
),
)
algorithm_name: str = Field(default="fixed_grid", index=True)
status: GridLadderStatus = Field(
Expand Down
10 changes: 8 additions & 2 deletions api/grid_ladders/calculations.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ class CalculatedGridLevel:
take_profit_price: float | None


@dataclass(frozen=True)
class CalculatedGrid:
grid_step: float
levels: list[CalculatedGridLevel]


def calculate_grid_step(range_low: float, range_high: float, level_count: int) -> float:
return (range_high - range_low) / (level_count - 1)

Expand All @@ -23,7 +29,7 @@ def calculate_grid_levels(
level_count: int,
total_margin: float,
sizer: GridMarginSizer,
) -> list[CalculatedGridLevel]:
) -> CalculatedGrid:
grid_step = calculate_grid_step(range_low, range_high, level_count)
midpoint_index = level_count // 2
active_entry_level_count = level_count - 1
Expand Down Expand Up @@ -63,4 +69,4 @@ def calculate_grid_levels(
)
)

return levels
return CalculatedGrid(grid_step=grid_step, levels=levels)
4 changes: 4 additions & 0 deletions api/grid_ladders/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ def validate_range(self) -> "GridLadderCreate":
raise ValueError("breakout_low must be less than range_low")
if self.breakout_high <= self.range_high:
raise ValueError("breakout_high must be greater than range_high")
# Even level_count produces an asymmetric grid (more buys than sells, or
# vice versa), which breaks the mean-reversion assumption. Require odd.
if self.level_count % 2 == 0:
raise ValueError("level_count must be odd for a symmetric grid")
return self


Expand Down
15 changes: 5 additions & 10 deletions api/grid_ladders/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from databases.tables.symbol_table import SymbolTable
from databases.utils import get_session
from pybinbot import ExchangeId, GridLadderStatus, KucoinFutures, MarketType
from grid_ladders.calculations import calculate_grid_levels, calculate_grid_step
from grid_ladders.calculations import calculate_grid_levels
from grid_ladders.capital import evaluate_grid_capital
from grid_ladders.models import (
GridLadderCloseRequest,
Expand Down Expand Up @@ -142,7 +142,7 @@ def post_grid_ladder(
payload.market_type,
)
try:
calculated_levels = calculate_grid_levels(
calculated = calculate_grid_levels(
range_low=payload.range_low,
range_high=payload.range_high,
level_count=payload.level_count,
Expand All @@ -152,12 +152,7 @@ def post_grid_ladder(
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error

grid_step = calculate_grid_step(
payload.range_low,
payload.range_high,
payload.level_count,
)
reserved_margin = sum(level.margin_required for level in calculated_levels)
reserved_margin = sum(level.margin_required for level in calculated.levels)
ladder = grid_ladder_crud.create(
symbol=payload.symbol,
fiat=payload.fiat,
Expand All @@ -166,7 +161,7 @@ def post_grid_ladder(
algorithm_name=payload.algorithm_name,
range_low=payload.range_low,
range_high=payload.range_high,
grid_step=grid_step,
grid_step=calculated.grid_step,
level_count=payload.level_count,
total_margin=payload.total_margin,
reserved_margin=reserved_margin,
Expand All @@ -185,7 +180,7 @@ def post_grid_ladder(
"margin_required": level.margin_required,
"take_profit_price": level.take_profit_price,
}
for level in calculated_levels
for level in calculated.levels
],
)
created_ladder = grid_ladder_crud.get(ladder.id)
Expand Down
63 changes: 55 additions & 8 deletions api/tests/test_grid_ladders.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
GridLevelTable,
GridOrderTable,
)
from grid_ladders.calculations import calculate_grid_levels, calculate_grid_step
from grid_ladders.calculations import calculate_grid_levels
from grid_ladders.capital import evaluate_grid_capital
from grid_ladders.models import GridLadderCreate
from grid_ladders.routes import GridContractMeta
Expand Down Expand Up @@ -156,20 +156,32 @@ def test_reserves_only_allowed_portion_of_available_balance():
def test_calculates_fixed_grid_levels_correctly():
sizer = KucoinGridMarginRules(futures_leverage=1)

levels = calculate_grid_levels(90, 110, 5, 1000, sizer)
calculated = calculate_grid_levels(90, 110, 5, 1000, sizer)

assert calculate_grid_step(90, 110, 5) == 5
assert [level.price for level in levels] == [90, 95, 100, 105, 110]
assert [level.side for level in levels] == ["buy", "buy", "neutral", "sell", "sell"]
assert [level.take_profit_price for level in levels] == [95, 100, None, 100, 105]
assert calculated.grid_step == 5
assert [level.price for level in calculated.levels] == [90, 95, 100, 105, 110]
assert [level.side for level in calculated.levels] == [
"buy",
"buy",
"neutral",
"sell",
"sell",
]
assert [level.take_profit_price for level in calculated.levels] == [
95,
100,
None,
100,
105,
]


def test_sizes_level_contracts_using_margin_spend_interpretation():
sizer = KucoinGridMarginRules(futures_leverage=2, multiplier=1, lot_size=1)

levels = calculate_grid_levels(90, 110, 5, 1000, sizer)
calculated = calculate_grid_levels(90, 110, 5, 1000, sizer)

buy_level = levels[0]
buy_level = calculated.levels[0]
assert buy_level.contracts == 5
assert buy_level.margin_required == 225

Expand Down Expand Up @@ -336,3 +348,38 @@ def test_grid_ladder_active_unique_allows_reopen_after_close(create_test_tables)

session.add(_active_ladder("ZZYUSDC"))
session.commit()


@pytest.mark.parametrize("even_count", [4, 6, 8])
def test_grid_ladder_create_rejects_even_level_count(even_count):
data = _payload()
data["level_count"] = even_count

with pytest.raises(ValidationError, match="level_count must be odd"):
GridLadderCreate(**data)


def test_post_grid_ladder_rejects_spot_market_type(client, monkeypatch):
_patch_balance(monkeypatch, 10_000)
_patch_contract_meta(monkeypatch)

payload = _payload()
payload["market_type"] = "SPOT"

response = client.post("/grid-ladders", json=payload)

assert response.status_code == 400
assert "FUTURES" in response.json()["detail"]


def test_post_grid_ladder_rejects_non_kucoin_exchange(client, monkeypatch):
_patch_balance(monkeypatch, 10_000)
_patch_contract_meta(monkeypatch)

payload = _payload()
payload["exchange"] = "binance"

response = client.post("/grid-ladders", json=payload)

assert response.status_code == 400
assert "KuCoin" in response.json()["detail"]
Loading