فهرست منبع

refactored suggest amount by codex

Lukas Goldschmidt 2 هفته پیش
والد
کامیت
94deffb3a8

+ 2 - 0
CHANGELOG.md

@@ -4,6 +4,8 @@
 - Grid trader is now stable enough for real use.
 - Orders place, replace, recenter, and resize as expected.
 - Shared execution uses real fees for sizing.
+- Shared strategy sizing now lives in `src/trader_mcp/strategy_sizing.py`.
+- Trend follower clamps the last order to the remaining `balance_target` gap instead of overshooting it.
 - Dashboard and live strategy state are aligned with the running strategy flow.
 - MCP surface remains compact while the runtime behaviour is much more complete.
 

+ 213 - 0
src/trader_mcp/strategy_sizing.py

@@ -0,0 +1,213 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+def _call_context_suggest_order_amount(context: Any, kwargs: dict[str, Any]) -> float:
+    if not hasattr(context, "suggest_order_amount"):
+        return 0.0
+
+    variants: list[dict[str, Any]] = []
+    drop_sets = (
+        (),
+        ("quote_notional",),
+        ("dust_collect",),
+        ("order_size",),
+        ("quote_notional", "dust_collect"),
+        ("quote_notional", "order_size"),
+        ("dust_collect", "order_size"),
+        ("quote_notional", "dust_collect", "order_size"),
+    )
+    for drop_keys in drop_sets:
+        variant = {key: value for key, value in kwargs.items() if key not in drop_keys}
+        if variant not in variants:
+            variants.append(variant)
+
+    last_error: TypeError | None = None
+    for variant in variants:
+        try:
+            return float(context.suggest_order_amount(**variant) or 0.0)
+        except TypeError as exc:
+            last_error = exc
+
+    if last_error is not None:
+        raise last_error
+    return 0.0
+
+
+def suggest_quote_sized_amount(
+    context: Any,
+    *,
+    side: str,
+    price: float,
+    levels: int,
+    min_notional: float,
+    fee_rate: float,
+    order_notional_quote: float = 0.0,
+    max_order_notional_quote: float = 0.0,
+    dust_collect: bool = False,
+    order_size: float = 0.0,
+) -> float:
+    side = str(side or "").strip().lower()
+    price = float(price or 0.0)
+    levels = int(levels or 0)
+    min_notional = max(float(min_notional or 0.0), 0.0)
+    fee_rate = max(float(fee_rate or 0.0), 0.0)
+    order_notional_quote = max(float(order_notional_quote or 0.0), 0.0)
+    max_order_notional_quote = max(float(max_order_notional_quote or 0.0), 0.0)
+    order_size = max(float(order_size or 0.0), 0.0)
+
+    if levels <= 0 or price <= 0 or side not in {"buy", "sell"}:
+        return 0.0
+
+    kwargs = {
+        "side": side,
+        "price": price,
+        "levels": levels,
+        "min_notional": min_notional,
+        "fee_rate": fee_rate,
+        "quote_notional": order_notional_quote,
+        "max_notional_per_order": max_order_notional_quote,
+        "dust_collect": bool(dust_collect),
+        "order_size": order_size,
+    }
+    amount = _call_context_suggest_order_amount(context, kwargs)
+    if amount > 0:
+        return amount
+
+    if order_notional_quote <= 0:
+        return 0.0
+
+    effective_quote = order_notional_quote
+    if max_order_notional_quote > 0:
+        effective_quote = min(effective_quote, max_order_notional_quote)
+
+    if side == "buy":
+        min_quote_needed = min_notional * (1 + fee_rate)
+        if min_notional > 0 and effective_quote < min_quote_needed:
+            return 0.0
+        amount = effective_quote / (price * (1 + fee_rate))
+    else:
+        amount = effective_quote / price
+    min_amount = (min_notional / price) if side == "sell" and min_notional > 0 else 0.0
+    if min_amount > 0 and amount < min_amount:
+        return 0.0
+    return max(amount, 0.0)
+
+
+def cap_amount_to_balance_target(
+    *,
+    suggested_amount: float,
+    side: str,
+    price: float,
+    fee_rate: float,
+    balance_target: float,
+    base_available: float,
+    counter_available: float,
+    min_notional: float = 0.0,
+) -> float:
+    amount = max(float(suggested_amount or 0.0), 0.0)
+    side = str(side or "").strip().lower()
+    price = float(price or 0.0)
+    fee_rate = max(float(fee_rate or 0.0), 0.0)
+    target = min(max(float(balance_target if balance_target is not None else 1.0), 0.0), 1.0)
+    base_available = max(float(base_available or 0.0), 0.0)
+    counter_available = max(float(counter_available or 0.0), 0.0)
+    min_notional = max(float(min_notional or 0.0), 0.0)
+
+    if amount <= 0 or price <= 0 or side not in {"buy", "sell"}:
+        return 0.0
+    if target >= 1.0:
+        return amount
+
+    base_value = base_available * price
+    total_value = base_value + counter_available
+    if total_value <= 0:
+        return 0.0
+
+    capped_amount = amount
+    if side == "buy":
+        remaining_quote = (target * total_value) - base_value
+        if remaining_quote <= 0:
+            return 0.0
+        target_amount = remaining_quote / (price * (1 + target * fee_rate))
+        capped_amount = min(amount, target_amount)
+    else:
+        target_base_ratio = 1.0 - target
+        remaining_quote = base_value - (target_base_ratio * total_value)
+        if remaining_quote <= 0:
+            return 0.0
+        denominator = price * max(1.0 - (target_base_ratio * fee_rate), 1e-12)
+        target_amount = remaining_quote / denominator
+        capped_amount = min(amount, target_amount)
+
+    if capped_amount <= 0:
+        return 0.0
+    if min_notional > 0 and (capped_amount * price) < min_notional:
+        return 0.0
+    return max(capped_amount, 0.0)
+
+
+def suggest_rebalance_amount(
+    *,
+    side: str,
+    price: float,
+    fee_rate: float,
+    base_available: float,
+    counter_available: float,
+    target_ratio: float,
+    step_ratio: float,
+    balance_tolerance: float,
+    min_order_notional_quote: float = 0.0,
+    max_order_notional_quote: float = 0.0,
+) -> float:
+    side = str(side or "").strip().lower()
+    price = float(price or 0.0)
+    fee_rate = max(float(fee_rate or 0.0), 0.0)
+    base_available = max(float(base_available or 0.0), 0.0)
+    counter_available = max(float(counter_available or 0.0), 0.0)
+    target_ratio = min(max(float(target_ratio or 0.0), 0.0), 1.0)
+    step_ratio = max(float(step_ratio or 0.0), 0.0)
+    balance_tolerance = max(float(balance_tolerance or 0.0), 0.0)
+    min_order_notional_quote = max(float(min_order_notional_quote or 0.0), 0.0)
+    max_order_notional_quote = max(float(max_order_notional_quote or 0.0), 0.0)
+
+    if side not in {"buy", "sell"} or price <= 0:
+        return 0.0
+
+    base_value = base_available * price
+    total_value = base_value + counter_available
+    if total_value <= 0:
+        return 0.0
+
+    current_ratio = base_value / total_value
+    drift = abs(current_ratio - target_ratio)
+    if drift <= balance_tolerance:
+        return 0.0
+
+    target_quote = total_value * min(drift, step_ratio)
+    if min_order_notional_quote > 0:
+        target_quote = max(target_quote, min_order_notional_quote)
+    if max_order_notional_quote > 0:
+        target_quote = min(target_quote, max_order_notional_quote)
+    if target_quote <= 0:
+        return 0.0
+    if min_order_notional_quote > 0 and target_quote < min_order_notional_quote:
+        return 0.0
+
+    if side == "buy":
+        max_affordable_quote = counter_available
+        if max_affordable_quote < min_order_notional_quote * (1 + fee_rate):
+            return 0.0
+        capped_quote = min(target_quote, max_affordable_quote)
+        amount = capped_quote / (price * (1 + fee_rate))
+    else:
+        max_affordable_quote = base_available * price
+        if max_affordable_quote < min_order_notional_quote:
+            return 0.0
+        capped_quote = min(target_quote, max_affordable_quote)
+        amount = capped_quote / (price * (1 + fee_rate))
+
+    if amount <= 0:
+        return 0.0
+    return max(amount, 0.0)

+ 1 - 0
strategies/exposure_protector.md

@@ -33,3 +33,4 @@ Defensive rebalancer that trims skew and protects exposure.
 - Trader derives concrete execution values from policy.
 - `report().supervision` should be interpreted as a defensive attachment signal, not as a preferred replacement for a healthy grid during persistent trend continuation unless imbalance is genuinely severe.
 - live fee rates are used directly, and quote notional is the canonical sizing unit.
+- rebalance sizing now uses the shared strategy sizing helper so fee-aware quote bounds behave consistently with the other strategies.

+ 13 - 29
strategies/exposure_protector.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from datetime import datetime, timezone
 
+from src.trader_mcp.strategy_sizing import suggest_rebalance_amount
 from src.trader_mcp.strategy_sdk import Strategy
 from src.trader_mcp.logging_utils import log_event
 
@@ -241,35 +242,18 @@ class Strategy(Strategy):
         return ""
 
     def _suggest_amount(self, side: str, price: float) -> float:
-        fee_rate = self._live_fee_rate()
-        step_ratio = float(self.config.get("rebalance_step_ratio", 0.15) or 0.0)
-        target = float(self.config.get("rebalance_target_ratio", 0.5) or 0.5)
-        min_order_quote = float(self.config.get("min_order_notional_quote") or self.config.get("min_order_size") or 0.0)
-        max_order_quote = float(self.config.get("max_order_notional_quote") or self.config.get("max_order_size") or 0.0)
-        balance_tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.0)
-        base_value = float(self.state.get("base_available") or 0.0) * price
-        counter_value = float(self.state.get("counter_available") or 0.0)
-        total = base_value + counter_value
-        if total <= 0 or price <= 0:
-            return 0.0
-        current = base_value / total
-        drift = abs(current - target)
-        if drift <= balance_tolerance:
-            return 0.0
-
-        notional = total * min(drift, step_ratio)
-        if side == "sell":
-            amount = notional / (price * (1 + fee_rate))
-            amount = min(amount, float(self.state.get("base_available") or 0.0))
-        else:
-            amount = notional / (price * (1 + fee_rate))
-            amount = min(amount, float(self.state.get("counter_available") or 0.0) / price if price > 0 else 0.0)
-
-        if min_order_quote > 0 and price > 0:
-            amount = max(amount, min_order_quote / price)
-        if max_order_quote > 0 and price > 0:
-            amount = min(amount, max_order_quote / price)
-        return max(amount, 0.0)
+        return suggest_rebalance_amount(
+            side=side,
+            price=price,
+            fee_rate=self._live_fee_rate(),
+            base_available=float(self.state.get("base_available") or 0.0),
+            counter_available=float(self.state.get("counter_available") or 0.0),
+            target_ratio=float(self.config.get("rebalance_target_ratio", 0.5) or 0.5),
+            step_ratio=float(self.config.get("rebalance_step_ratio", 0.15) or 0.0),
+            balance_tolerance=float(self.config.get("balance_tolerance", 0.05) or 0.0),
+            min_order_notional_quote=float(self.config.get("min_order_notional_quote") or self.config.get("min_order_size") or 0.0),
+            max_order_notional_quote=float(self.config.get("max_order_notional_quote") or self.config.get("max_order_size") or 0.0),
+        )
 
     def on_tick(self, tick):
         self.state["last_error"] = ""

+ 1 - 0
strategies/grid_trader.md

@@ -40,3 +40,4 @@ Passive, structure-based liquidity strategy.
 - `report()` and `render()` expose the live base step plus the effective buy/sell step split and current rebalance bias.
 - ordinary directional conditions alone should not force a rebuild or switch.
 - live fee rates are used directly, and the quote notional is the canonical sizing unit.
+- quote sizing now flows through the shared strategy sizing helper, so grid and directional strategies use the same fee-aware notional rules.

+ 13 - 18
strategies/grid_trader.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import time
 from datetime import datetime, timezone
 
+from src.trader_mcp.strategy_sizing import suggest_quote_sized_amount
 from src.trader_mcp.strategy_sdk import Strategy
 from src.trader_mcp.logging_utils import log_event
 
@@ -568,24 +569,18 @@ class Strategy(Strategy):
         return {"buy", "sell"}
 
     def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
-        quote_notional = float(self.config.get("order_notional_quote") or self.config.get("order_size") or 0.0)
-        max_quote_notional = float(self.config.get("max_order_notional_quote") or self.config.get("max_notional_per_order") or 0.0)
-        kwargs = {
-            "side": side,
-            "price": price,
-            "levels": levels,
-            "min_notional": min_notional,
-            "fee_rate": self._live_fee_rate(),
-            "quote_notional": quote_notional,
-            "max_notional_per_order": max_quote_notional,
-            "dust_collect": bool(self.config.get("dust_collect", False)),
-            "order_size": 0.0,
-        }
-        try:
-            return self.context.suggest_order_amount(**kwargs)
-        except TypeError:
-            kwargs.pop("quote_notional", None)
-            return self.context.suggest_order_amount(**kwargs)
+        return suggest_quote_sized_amount(
+            self.context,
+            side=side,
+            price=price,
+            levels=levels,
+            min_notional=min_notional,
+            fee_rate=self._live_fee_rate(),
+            order_notional_quote=float(self.config.get("order_notional_quote") or self.config.get("order_size") or 0.0),
+            max_order_notional_quote=float(self.config.get("max_order_notional_quote") or self.config.get("max_notional_per_order") or 0.0),
+            dust_collect=bool(self.config.get("dust_collect", False)),
+            order_size=0.0,
+        )
 
     def _target_levels_for_side(self, side: str, center: float, live_orders: list[dict], balance_total: float, expected_levels: int, min_notional: float) -> int:
         if expected_levels <= 0 or center <= 0 or balance_total <= 0:

+ 2 - 0
strategies/trend_follower.md

@@ -19,6 +19,7 @@ Directional strategy for confirmed momentum.
 - `balance_target`: stop once the wallet reaches the target balance for the active side
 - `order_notional_quote`: quote-currency notional per order
 - `max_order_notional_quote`: optional quote notional cap per order
+- `dust_collect`: allow the last order on the funding side to consume leftover size when it would otherwise be stranded below the venue minimum
 - `entry_offset_pct`: offset from market for entries
 - `exit_offset_pct`: offset for exits or reversals
 - `cooldown_ticks`: pause between actions
@@ -36,4 +37,5 @@ Directional strategy for confirmed momentum.
 - `balance_target=1.0` means keep trading until no more usable size remains on the funding side.
 - In `buy` mode, values below `1.0` target the base share of wallet value. Example: `0.5` stops near a 50/50 base-quote split.
 - In `sell` mode, values below `1.0` target the quote share of wallet value. Example: `0.5` stops near a 50/50 quote-base split.
+- When an order would overshoot a sub-`1.0` `balance_target`, the final order is clamped to the remaining target gap instead of sending the full configured notional.
 - live fee rates are used directly, so the strategy does not need a configured fee fallback.

+ 28 - 23
strategies/trend_follower.py

@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from datetime import datetime, timezone
 
+from src.trader_mcp.strategy_sizing import cap_amount_to_balance_target, suggest_quote_sized_amount
 from src.trader_mcp.strategy_sdk import Strategy
 from src.trader_mcp.logging_utils import log_event
 
@@ -38,6 +39,7 @@ class Strategy(Strategy):
         "exit_offset_pct": {"type": "float", "default": 0.002, "min": 0.0, "max": 1.0},
         "order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
         "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
+        "dust_collect": {"type": "bool", "default": False},
         "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
         "debug_orders": {"type": "bool", "default": True},
     }
@@ -158,6 +160,7 @@ class Strategy(Strategy):
             "exit_offset_pct": round(exit_offset_pct, 6),
             "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
             "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
+            "dust_collect": bool(self.config.get("dust_collect", False)),
             "last_order_age_seconds": last_order_age_seconds,
             "last_order_price": float(self.state.get("last_order_price") or 0.0),
             "chasing_risk": chasing_risk,
@@ -177,6 +180,7 @@ class Strategy(Strategy):
             "cooldown_ticks": int(self.config.get("cooldown_ticks") or 2),
             "order_notional_quote": quote_notional,
             "max_order_notional_quote": max_quote_notional,
+            "dust_collect": bool(self.config.get("dust_collect", False)),
         }
         return policy
 
@@ -210,29 +214,28 @@ class Strategy(Strategy):
 
     def _suggest_amount(self, price: float, side: str) -> float:
         min_notional = float(self.context.minimum_order_value or 0.0)
-        quote_notional = float(self.config.get("order_notional_quote") or 0.0)
-        max_quote_notional = float(self.config.get("max_order_notional_quote") or 0.0)
-        if hasattr(self.context, "suggest_order_amount"):
-            kwargs = {
-                "side": side,
-                "price": price,
-                "levels": 1,
-                "min_notional": min_notional,
-                "fee_rate": self._live_fee_rate(),
-                "quote_notional": quote_notional,
-                "max_notional_per_order": max_quote_notional,
-            }
-            try:
-                return float(self.context.suggest_order_amount(**kwargs) or 0.0)
-            except TypeError:
-                kwargs.pop("quote_notional", None)
-                return float(self.context.suggest_order_amount(**kwargs) or 0.0)
-        if quote_notional <= 0:
-            return 0.0
-        amount = quote_notional / price
-        if max_quote_notional > 0:
-            amount = min(amount, max_quote_notional / price)
-        return max(amount, 0.0)
+        fee_rate = self._live_fee_rate()
+        amount = suggest_quote_sized_amount(
+            self.context,
+            side=side,
+            price=price,
+            levels=1,
+            min_notional=min_notional,
+            fee_rate=fee_rate,
+            order_notional_quote=float(self.config.get("order_notional_quote") or 0.0),
+            max_order_notional_quote=float(self.config.get("max_order_notional_quote") or 0.0),
+            dust_collect=bool(self.config.get("dust_collect", False)),
+        )
+        return cap_amount_to_balance_target(
+            suggested_amount=amount,
+            side=side,
+            price=price,
+            fee_rate=fee_rate,
+            balance_target=self._balance_target(),
+            base_available=float(self.state.get("base_available") or 0.0),
+            counter_available=float(self.state.get("counter_available") or 0.0),
+            min_notional=min_notional,
+        )
 
     def on_tick(self, tick):
         self.state["last_error"] = ""
@@ -300,6 +303,7 @@ class Strategy(Strategy):
                 "balance_target": self._balance_target(),
                 "order_notional_quote": float(self.config.get("order_notional_quote") or 0.0),
                 "max_order_notional_quote": float(self.config.get("max_order_notional_quote") or 0.0),
+                "dust_collect": bool(self.config.get("dust_collect", False)),
                 "cooldown_remaining": self.state.get("cooldown_remaining", 0),
                 "base_available": self.state.get("base_available", 0.0),
                 "counter_available": self.state.get("counter_available", 0.0),
@@ -323,6 +327,7 @@ class Strategy(Strategy):
                 {"type": "metric", "label": "side", "value": self._trade_side()},
                 {"type": "metric", "label": "balance target", "value": round(self._balance_target(), 6)},
                 {"type": "metric", "label": "quote notional", "value": round(float(self.config.get("order_notional_quote") or 0.0), 6)},
+                {"type": "metric", "label": "dust collect", "value": bool(self.config.get("dust_collect", False))},
                 {"type": "metric", "label": "state", "value": self.state.get("last_action", "idle")},
                 {"type": "metric", "label": "cooldown", "value": int(self.state.get("cooldown_remaining") or 0)},
                 {"type": "text", "label": "error", "value": self.state.get("last_error", "") or "none"},

+ 230 - 1
tests/test_strategies.py

@@ -911,7 +911,236 @@ def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 10.5})
+    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 10.5, "dust_collect": True})
     strat.on_tick({})
     assert ctx.fee_calls == ["xrpusd"]
     assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025
+    assert ctx.suggest_calls[-1]["dust_collect"] is True
+
+
+def test_grid_sizing_helper_receives_quote_controls_and_dust_collect():
+    class FakeContext:
+        base_currency = "XRP"
+        counter_currency = "USD"
+        market_symbol = "xrpusd"
+        minimum_order_value = 10.0
+        mode = "active"
+
+        def __init__(self):
+            self.suggest_calls = []
+
+        def get_fee_rates(self, market_symbol=None):
+            return {"maker": 0.001, "taker": 0.004}
+
+        def suggest_order_amount(self, **kwargs):
+            self.suggest_calls.append(kwargs)
+            return 7.0
+
+    ctx = FakeContext()
+    strategy = GridStrategy(
+        ctx,
+        {
+            "grid_levels": 3,
+            "order_notional_quote": 11.0,
+            "max_order_notional_quote": 12.0,
+            "dust_collect": True,
+        },
+    )
+
+    amount = strategy._suggest_amount("buy", 1.5, 3, 10.0)
+
+    assert amount == 7.0
+    assert ctx.suggest_calls[-1]["fee_rate"] == 0.004
+    assert ctx.suggest_calls[-1]["quote_notional"] == 11.0
+    assert ctx.suggest_calls[-1]["max_notional_per_order"] == 12.0
+    assert ctx.suggest_calls[-1]["dust_collect"] is True
+    assert ctx.suggest_calls[-1]["levels"] == 3
+
+
+def test_trend_follower_buy_clamps_last_order_to_balance_target():
+    class FakeContext:
+        id = "s-buy-clamp"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        minimum_order_value = 0.5
+
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 1.0}
+
+        def get_fee_rates(self, market_symbol=None):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
+
+        def place_order(self, **kwargs):
+            self.orders.append(kwargs)
+            return {"ok": True, "order": kwargs}
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
+
+    result = strat.on_tick({})
+
+    assert result["action"] == "buy"
+    assert ctx.orders[-1]["amount"] == 1.0
+
+
+def test_trend_follower_sell_clamps_last_order_to_balance_target():
+    class FakeContext:
+        id = "s-sell-clamp"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        minimum_order_value = 0.5
+
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 1.0}
+
+        def get_fee_rates(self, market_symbol=None):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
+
+        def place_order(self, **kwargs):
+            self.orders.append(kwargs)
+            return {"ok": True, "order": kwargs}
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 3.0}, {"asset_code": "XRP", "available": 7.0}]}
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 0.5})
+
+    result = strat.on_tick({})
+
+    assert result["action"] == "sell"
+    assert ctx.orders[-1]["amount"] == 2.0
+
+
+def test_trend_follower_holds_when_balance_target_already_reached():
+    class FakeContext:
+        id = "s-target-hold"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        minimum_order_value = 0.5
+
+        def get_price(self, symbol):
+            return {"price": 1.0}
+
+        def get_fee_rates(self, market_symbol=None):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return 10.0
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 5.0}, {"asset_code": "XRP", "available": 5.0}]}
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    strat = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
+
+    result = strat.on_tick({})
+
+    assert result["action"] == "hold"
+    assert result["reason"] == "balance target reached"
+
+
+def test_trend_follower_balance_target_one_does_not_clamp_size():
+    class FakeContext:
+        id = "s-target-open"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        minimum_order_value = 0.5
+
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 1.0}
+
+        def get_fee_rates(self, market_symbol=None):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
+
+        def place_order(self, **kwargs):
+            self.orders.append(kwargs)
+            return {"ok": True, "order": kwargs}
+
+        def get_account_info(self):
+            return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 1.0})
+
+    result = strat.on_tick({})
+
+    assert result["action"] == "buy"
+    assert ctx.orders[-1]["amount"] == 3.0
+
+
+def test_exposure_protector_buy_sizing_respects_fee_when_min_order_quote_is_unaffordable():
+    class FakeContext:
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+
+        def get_fee_rates(self, market_symbol=None):
+            return {"maker": 0.1, "taker": 0.2}
+
+    strategy = ExposureStrategy(
+        FakeContext(),
+        {
+            "rebalance_target_ratio": 0.9,
+            "rebalance_step_ratio": 1.0,
+            "balance_tolerance": 0.0,
+            "min_order_notional_quote": 10.0,
+        },
+    )
+    strategy.state["base_available"] = 0.0
+    strategy.state["counter_available"] = 10.0
+
+    amount = strategy._suggest_amount("buy", 1.0)
+
+    assert amount == 0.0