|
|
@@ -547,6 +547,49 @@ def test_grid_plan_truncates_outer_levels_without_skipping_inner_levels(monkeypa
|
|
|
assert plan["sell_skipped"] == []
|
|
|
|
|
|
|
|
|
+def test_grid_plan_sizes_each_level_against_its_own_price(monkeypatch):
|
|
|
+ class FakeContext:
|
|
|
+ base_currency = "XRP"
|
|
|
+ counter_currency = "USD"
|
|
|
+ market_symbol = "xrpusd"
|
|
|
+ minimum_order_value = 10.0
|
|
|
+ mode = "active"
|
|
|
+
|
|
|
+ def get_fee_rates(self, market):
|
|
|
+ return {"maker": 0.0, "taker": 0.004}
|
|
|
+
|
|
|
+ def suggest_order_amount(self, **kwargs):
|
|
|
+ quote_notional = float(kwargs.get("quote_notional") or 0.0)
|
|
|
+ max_notional_per_order = float(kwargs.get("max_notional_per_order") or 0.0)
|
|
|
+ price = float(kwargs.get("price") or 0.0)
|
|
|
+ if price <= 0:
|
|
|
+ return 0.0
|
|
|
+ target_quote = quote_notional if quote_notional > 0 else max_notional_per_order
|
|
|
+ if max_notional_per_order > 0:
|
|
|
+ target_quote = min(target_quote, max_notional_per_order)
|
|
|
+ return target_quote / price
|
|
|
+
|
|
|
+ strategy = GridStrategy(
|
|
|
+ FakeContext(),
|
|
|
+ {
|
|
|
+ "grid_levels": 5,
|
|
|
+ "grid_step_pct": 0.0062,
|
|
|
+ "grid_step_min_pct": 0.006125,
|
|
|
+ "order_notional_quote": 10.25,
|
|
|
+ "max_order_notional_quote": 12.0,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ plan = strategy._plan_grid(1.3642, base_total=100.0, quote_total=100.0)
|
|
|
+
|
|
|
+ assert plan["counts"]["buy"] == 5
|
|
|
+ assert plan["counts"]["sell"] == 5
|
|
|
+ assert [order["level"] for order in plan["buy_orders"]] == [1, 2, 3, 4, 5]
|
|
|
+ assert [order["level"] for order in plan["sell_orders"]] == [1, 2, 3, 4, 5]
|
|
|
+ assert plan["buy_orders"][0]["amount"] != plan["buy_orders"][-1]["amount"]
|
|
|
+ assert plan["sell_orders"][0]["amount"] != plan["sell_orders"][-1]["amount"]
|
|
|
+
|
|
|
+
|
|
|
def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
|
|
|
class FakeContext:
|
|
|
base_currency = "XRP"
|
|
|
@@ -617,6 +660,93 @@ def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
|
|
|
assert ctx.cancelled_all == 0
|
|
|
|
|
|
|
|
|
+def test_grid_shape_check_rebuilds_when_live_counts_are_below_funded_max(monkeypatch):
|
|
|
+ class FakeContext:
|
|
|
+ base_currency = "XRP"
|
|
|
+ counter_currency = "USD"
|
|
|
+ market_symbol = "xrpusd"
|
|
|
+ minimum_order_value = 10.0
|
|
|
+ mode = "active"
|
|
|
+
|
|
|
+ def __init__(self):
|
|
|
+ self.cancelled_all = 0
|
|
|
+
|
|
|
+ def get_fee_rates(self, market):
|
|
|
+ return {"maker": 0.0, "taker": 0.004}
|
|
|
+
|
|
|
+ def get_account_info(self):
|
|
|
+ return {
|
|
|
+ "balances": [
|
|
|
+ {"asset_code": "USD", "available": 100.0},
|
|
|
+ {"asset_code": "XRP", "available": 100.0},
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ def get_price(self, symbol):
|
|
|
+ return {"price": 1.3642}
|
|
|
+
|
|
|
+ def get_regime(self, symbol, timeframe="1h"):
|
|
|
+ return {"volatility": {"atr_percent": 0.0}, "trend": {"state": "flat"}}
|
|
|
+
|
|
|
+ def suggest_order_amount(self, **kwargs):
|
|
|
+ quote_notional = float(kwargs.get("quote_notional") or 0.0)
|
|
|
+ max_notional_per_order = float(kwargs.get("max_notional_per_order") or 0.0)
|
|
|
+ price = float(kwargs.get("price") or 0.0)
|
|
|
+ if price <= 0:
|
|
|
+ return 0.0
|
|
|
+ target_quote = quote_notional if quote_notional > 0 else max_notional_per_order
|
|
|
+ if max_notional_per_order > 0:
|
|
|
+ target_quote = min(target_quote, max_notional_per_order)
|
|
|
+ return target_quote / price
|
|
|
+
|
|
|
+ def get_open_orders(self):
|
|
|
+ return [
|
|
|
+ {"side": "buy", "price": 1.3557, "amount": 7.6, "id": "b1"},
|
|
|
+ {"side": "buy", "price": 1.3469, "amount": 7.6, "id": "b2"},
|
|
|
+ {"side": "buy", "price": 1.3381, "amount": 7.6, "id": "b3"},
|
|
|
+ {"side": "sell", "price": 1.3727, "amount": 7.5, "id": "s1"},
|
|
|
+ {"side": "sell", "price": 1.3816, "amount": 7.5, "id": "s2"},
|
|
|
+ {"side": "sell", "price": 1.3904, "amount": 7.5, "id": "s3"},
|
|
|
+ ]
|
|
|
+
|
|
|
+ ctx = FakeContext()
|
|
|
+ strategy = GridStrategy(
|
|
|
+ ctx,
|
|
|
+ {
|
|
|
+ "grid_levels": 5,
|
|
|
+ "grid_step_pct": 0.0062,
|
|
|
+ "grid_step_min_pct": 0.006125,
|
|
|
+ "order_notional_quote": 10.25,
|
|
|
+ "max_order_notional_quote": 12.0,
|
|
|
+ "order_call_delay_ms": 0,
|
|
|
+ "fee_rate": 0.004,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ strategy.state["center_price"] = 1.3642
|
|
|
+ strategy.state["seeded"] = True
|
|
|
+ strategy.state["orders"] = ctx.get_open_orders()
|
|
|
+ strategy.state["order_ids"] = ["b1", "b2", "b3", "s1", "s2", "s3"]
|
|
|
+
|
|
|
+ monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
|
|
|
+ monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
|
|
|
+ monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
|
|
|
+ monkeypatch.setattr(strategy, "_price", lambda: 1.3642)
|
|
|
+ monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
|
|
|
+
|
|
|
+ called = {"rebuild": 0}
|
|
|
+
|
|
|
+ def fake_rebuild(*_args, **_kwargs):
|
|
|
+ called["rebuild"] += 1
|
|
|
+
|
|
|
+ monkeypatch.setattr(strategy, "_recenter_and_rebuild_from_price", fake_rebuild)
|
|
|
+ monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: ctx.get_open_orders())
|
|
|
+
|
|
|
+ result = strategy.on_tick({})
|
|
|
+
|
|
|
+ assert result["action"] == "reseed"
|
|
|
+ assert called["rebuild"] == 1
|
|
|
+
|
|
|
+
|
|
|
def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
|
|
|
class FakeContext:
|
|
|
base_currency = "XRP"
|