Pārlūkot izejas kodu

Add strategy exposure reporting

Lukas Goldschmidt 3 nedēļas atpakaļ
vecāks
revīzija
0fb5022314

+ 2 - 0
PROJECT.md

@@ -24,6 +24,8 @@ Trader MCP runs strategies, persists strategy state, and keeps the public surfac
 - `report().fit` is the Hermes-facing fit block.
 - `set_strategy_policy()` stores `risk_posture`, `priority`, and optional metadata.
 - policy is reapplied on reconcile and instance creation.
+- quote-currency notionals are the canonical sizing unit for directional and defensive strategies.
+- directional strategies should use `trade_side` when they need long-only or short-only behavior.
 - `control_strategy()` handles `start`, `pause`, `resume`, `stop`, and `reconcile`.
 
 ## Upgrade plans

+ 4 - 0
src/trader_mcp/server.py

@@ -128,6 +128,8 @@ def list_strategies() -> dict:
                 "market_symbol": record.market_symbol,
                 "base_currency": record.base_currency,
                 "counter_currency": record.counter_currency,
+                "trade_side": (record.config or {}).get("trade_side"),
+                "trade_sides": (record.config or {}).get("trade_sides"),
                 "config": record.config or {},
                 "state": state,
                 "last_price": state.get("last_price"),
@@ -199,6 +201,8 @@ def get_strategy(
         "market_symbol": record.market_symbol,
         "base_currency": record.base_currency,
         "counter_currency": record.counter_currency,
+        "trade_side": (record.config or {}).get("trade_side"),
+        "trade_sides": (record.config or {}).get("trade_sides"),
     }
     if include_config:
         response["config"] = record.config

+ 8 - 1
src/trader_mcp/strategy_context.py

@@ -149,6 +149,7 @@ class StrategyContext:
         levels: int,
         min_notional: float,
         fee_rate: float,
+        quote_notional: float = 0.0,
         max_notional_per_order: float = 0.0,
         dust_collect: bool = False,
         order_size: float = 0.0,
@@ -157,13 +158,15 @@ class StrategyContext:
         """Return a conservative per-order amount for this venue/account.
 
         The returned amount is exchange-aware but strategy-agnostic, so other
-        strategies can reuse the same sizing rules.
+        strategies can reuse the same sizing rules. `quote_notional` is the
+        canonical quote-currency cap when a strategy wants quote-standard sizing.
         """
         if levels <= 0 or price <= 0:
             return 0.0
 
         side = str(side or "").strip().lower()
         fee_rate = max(float(fee_rate or 0.0), 0.0)
+        quote_notional = float(quote_notional or 0.0)
         max_notional_per_order = float(max_notional_per_order or 0.0)
         order_size = float(order_size or 0.0)
         min_amount = (min_notional / price) if min_notional > 0 else 0.0
@@ -173,6 +176,8 @@ class StrategyContext:
             quote_available = self._available_balance(quote) if hasattr(self, "_available_balance") else 0.0
             spendable_quote = quote_available * safety
             quote_cap = spendable_quote if max_notional_per_order <= 0 else min(spendable_quote, max_notional_per_order)
+            if quote_notional > 0:
+                quote_cap = min(quote_cap, quote_notional)
 
             if dust_collect and max_notional_per_order > 0:
                 leftover_quote = max(spendable_quote - max_notional_per_order, 0.0)
@@ -191,6 +196,8 @@ class StrategyContext:
             base = self.base_currency or (self.market_symbol or "XRP")
             base_available = self._available_balance(base) if hasattr(self, "_available_balance") else 0.0
             spendable_base = base_available * safety
+            if quote_notional > 0 and price > 0:
+                spendable_base = min(spendable_base, quote_notional / price)
             if max_notional_per_order > 0 and price > 0:
                 base_cap = max_notional_per_order / price
                 if dust_collect:

+ 3 - 0
strategies/exposure_protector.md

@@ -20,6 +20,8 @@ Defensive rebalancer that trims skew and protects exposure.
 - `rebalance_step_ratio`: how much to move per action
 - `min_rebalance_seconds`: minimum time between actions
 - `min_price_move_pct`: minimum move before a new action
+- `min_order_notional_quote`: minimum quote notional for one action
+- `max_order_notional_quote`: optional quote notional cap for one action
 
 ## Hermes policy mapping
 - `risk_posture` controls caution vs aggression
@@ -30,3 +32,4 @@ Defensive rebalancer that trims skew and protects exposure.
 - It should protect and rebalance, not decide regime.
 - 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.

+ 20 - 11
strategies/exposure_protector.py

@@ -34,14 +34,13 @@ class Strategy(Strategy):
         "trail_distance_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 1.0},
         "rebalance_target_ratio": {"type": "float", "default": 0.5, "min": 0.0, "max": 1.0},
         "rebalance_step_ratio": {"type": "float", "default": 0.15, "min": 0.0, "max": 1.0},
-        "min_order_size": {"type": "float", "default": 0.0, "min": 0.0},
-        "max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
+        "min_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
+        "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
         "order_spacing_ticks": {"type": "int", "default": 1, "min": 0, "max": 1000},
         "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
         "min_rebalance_seconds": {"type": "int", "default": 180, "min": 0, "max": 86400},
         "min_price_move_pct": {"type": "float", "default": 0.005, "min": 0.0, "max": 1.0},
         "balance_tolerance": {"type": "float", "default": 0.05, "min": 0.0, "max": 1.0},
-        "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
         "debug_orders": {"type": "bool", "default": True},
     }
     STATE_SCHEMA = {
@@ -133,7 +132,7 @@ class Strategy(Strategy):
             return float(payload.get("maker") or 0.0)
         except Exception as exc:
             self._log(f"fee lookup failed: {exc}")
-            return float(self.config.get("fee_rate", 0.0025) or 0.0)
+            return 0.0
 
     def _price(self) -> float:
         payload = self.context.get_price(self._base_symbol())
@@ -185,6 +184,14 @@ class Strategy(Strategy):
         tolerance = float(self.config.get("balance_tolerance", 0.05) or 0.05)
         drift = abs(ratio - target)
         last_error = str(self.state.get("last_error") or "")
+        repair_progress = max(0.0, 1.0 - min(drift / max(tolerance, 0.35), 1.0))
+        concerns = []
+        if drift > tolerance:
+            concerns.append(f"inventory drift {drift:.3f} still above tolerance {tolerance:.3f}")
+        if drift >= 0.35:
+            concerns.append("inventory imbalance is critical")
+        if last_error:
+            concerns.append(last_error)
         if drift >= 0.35:
             pressure = "critical"
         elif drift > tolerance:
@@ -199,6 +206,8 @@ class Strategy(Strategy):
             "rebalance_needed": drift > tolerance,
             "drift": round(drift, 6),
             "target_ratio": target,
+            "repair_progress": round(repair_progress, 6),
+            "concerns": concerns,
             "last_reason": last_error or f"base_ratio={ratio:.3f}, target={target:.3f}, drift={drift:.3f}",
         }
 
@@ -235,8 +244,8 @@ class Strategy(Strategy):
         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 = float(self.config.get("min_order_size", 0.0) or 0.0)
-        max_order = float(self.config.get("max_order_size", 0.0) or 0.0)
+        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)
@@ -256,10 +265,10 @@ class Strategy(Strategy):
             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 > 0:
-            amount = max(amount, min_order)
-        if max_order > 0:
-            amount = min(amount, max_order)
+        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)
 
     def on_tick(self, tick):
@@ -357,7 +366,7 @@ class Strategy(Strategy):
                 "confidence": None,
                 "uncertainty": None,
                 "reason": "defensive exposure protection",
-                "warnings": [],
+                "warnings": [w for w in (self._supervision().get("concerns") or []) if w],
                 "policy": dict(self.config.get("policy") or {}),
             },
             "execution": snapshot.get("execution", {}),

+ 3 - 2
strategies/grid_trader.md

@@ -19,8 +19,8 @@ Passive, structure-based liquidity strategy.
 - `grid_step_pct`: spacing between levels
 - `recenter_pct`: when to rebuild the grid
 - `volatility_timeframe`: timeframe used for adaptive sizing
-- `max_notional_per_order`: per-order cap
-- `fee_rate`: fallback fee assumption
+- `order_notional_quote`: quote-currency notional target per order
+- `max_order_notional_quote`: optional quote-currency cap per order
 
 ## Hermes policy mapping
 - `risk_posture` adjusts grid spacing, levels, and recentering
@@ -33,3 +33,4 @@ Passive, structure-based liquidity strategy.
 - `report().supervision` is descriptive, not imperative.
 - `side_capacity` and `inventory_pressure` describe the grid's current shape.
 - 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.

+ 117 - 29
strategies/grid_trader.py

@@ -39,14 +39,13 @@ class Strategy(Strategy):
         "volatility_multiplier": {"type": "float", "default": 0.5, "min": 0.0, "max": 10.0},
         "grid_step_min_pct": {"type": "float", "default": 0.005, "min": 0.0001, "max": 0.5},
         "grid_step_max_pct": {"type": "float", "default": 0.03, "min": 0.0001, "max": 1.0},
-        "order_size": {"type": "float", "default": 0.0, "min": 0.0},
+        "order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
+        "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
         "recenter_pct": {"type": "float", "default": 0.05, "min": 0.0, "max": 0.5},
         "recenter_atr_multiplier": {"type": "float", "default": 0.35, "min": 0.0, "max": 10.0},
         "recenter_min_pct": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.5},
         "recenter_max_pct": {"type": "float", "default": 0.03, "min": 0.0, "max": 0.5},
-        "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
         "trade_sides": {"type": "string", "default": "both"},
-        "max_notional_per_order": {"type": "float", "default": 0.0, "min": 0.0},
         "dust_collect": {"type": "bool", "default": False},
         "order_call_delay_ms": {"type": "int", "default": 250, "min": 0, "max": 10000},
         "debug_orders": {"type": "bool", "default": True},
@@ -164,8 +163,7 @@ class Strategy(Strategy):
             return maker, taker
         except Exception as exc:
             self._log(f"fee lookup failed: {exc}")
-            fallback = float(self.config.get("fee_rate", 0.0025) or 0.0)
-            return fallback, fallback
+            return 0.0, 0.0
 
     def _live_fee_rate(self) -> float:
         _maker, taker = self._live_fee_rates()
@@ -308,6 +306,7 @@ class Strategy(Strategy):
         last_error = str(self.state.get("last_error") or "")
         config_warning = self._config_warning()
         regime_1h = (((self.state.get("regimes") or {}).get("1h") or {}).get("trend") or {}).get("state")
+        center_price = float(self.state.get("center_price") or self.state.get("last_price") or 0.0)
         if ratio >= 0.88:
             pressure = "base_side_depleted"
         elif ratio <= 0.12:
@@ -318,6 +317,52 @@ class Strategy(Strategy):
             pressure = "quote_heavy"
         else:
             pressure = "balanced"
+        if price > 0 and center_price > 0:
+            if price > center_price:
+                market_bias = "bullish"
+                adverse_side = "sell"
+            elif price < center_price:
+                market_bias = "bearish"
+                adverse_side = "buy"
+            else:
+                market_bias = "flat"
+                adverse_side = "unknown"
+        else:
+            market_bias = "unknown"
+            adverse_side = "unknown"
+
+        open_orders = self.state.get("orders") or []
+        order_distribution = {"buy": {"count": 0, "notional_quote": 0.0}, "sell": {"count": 0, "notional_quote": 0.0}}
+        adverse_side_nearest_distance_pct = None
+        for order in open_orders:
+            if not isinstance(order, dict):
+                continue
+            side = str(order.get("side") or "").lower()
+            if side not in order_distribution:
+                continue
+            try:
+                order_price = float(order.get("price") or 0.0)
+                amount = float(order.get("amount") or order.get("amount_remaining") or 0.0)
+            except Exception:
+                continue
+            if order_price <= 0 or amount <= 0:
+                continue
+            order_distribution[side]["count"] += 1
+            order_distribution[side]["notional_quote"] += amount * order_price
+            if side == adverse_side and price > 0:
+                distance_pct = abs(order_price - price) / price * 100.0
+                if adverse_side_nearest_distance_pct is None or distance_pct < adverse_side_nearest_distance_pct:
+                    adverse_side_nearest_distance_pct = distance_pct
+
+        adverse_count = int(order_distribution.get(adverse_side, {}).get("count") or 0) if adverse_side in order_distribution else 0
+        adverse_notional = float(order_distribution.get(adverse_side, {}).get("notional_quote") or 0.0) if adverse_side in order_distribution else 0.0
+        concerns = []
+        if adverse_side in {"buy", "sell"} and adverse_count > 0:
+            concerns.append(f"{adverse_side} ladder exposed to {market_bias} drift")
+        if pressure in {"base_side_depleted", "quote_side_depleted"}:
+            concerns.append(f"inventory pressure={pressure}")
+        if config_warning:
+            concerns.append(config_warning)
         side_capacity = {
             "buy": pressure not in {"quote_side_depleted"},
             "sell": pressure not in {"base_side_depleted"},
@@ -328,6 +373,13 @@ class Strategy(Strategy):
             "inventory_pressure": pressure,
             "capacity_available": pressure == "balanced",
             "side_capacity": side_capacity,
+            "market_bias": market_bias,
+            "adverse_side": adverse_side,
+            "adverse_side_open_order_count": adverse_count,
+            "adverse_side_open_order_notional_quote": round(adverse_notional, 4),
+            "adverse_side_nearest_distance_pct": round(adverse_side_nearest_distance_pct, 4) if adverse_side_nearest_distance_pct is not None else None,
+            "open_order_distribution": order_distribution,
+            "concerns": concerns,
             "last_reason": last_error or config_warning or f"base_ratio={ratio:.3f}, trend_1h={regime_1h or 'unknown'}",
         }
 
@@ -447,16 +499,24 @@ class Strategy(Strategy):
         return {"buy", "sell"}
 
     def _suggest_amount(self, side: str, price: float, levels: int, min_notional: float) -> float:
-        return self.context.suggest_order_amount(
-            side=side,
-            price=price,
-            levels=levels,
-            min_notional=min_notional,
-            fee_rate=self._live_fee_rate(),
-            max_notional_per_order=float(self.config.get("max_notional_per_order", 0.0) or 0.0),
-            dust_collect=bool(self.config.get("dust_collect", False)),
-            order_size=float(self.config.get("order_size", 0.0) or 0.0),
-        )
+        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)
 
     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:
@@ -548,30 +608,56 @@ class Strategy(Strategy):
             min_size_buy = (min_notional / buy_price) if buy_price > 0 else 0.0
             min_size_sell = (min_notional / sell_price) if sell_price > 0 else 0.0
 
-            try:
-                if i <= buy_levels and buy_amount >= min_size_buy:
+            buy_error = None
+            sell_error = None
+            placed_any = False
+
+            if i <= buy_levels and buy_amount >= min_size_buy:
+                try:
                     buy = self.context.place_order(side="buy", order_type="limit", amount=buy_amount, price=buy_price, market=market)
                     orders.append({"side": "buy", "price": buy_price, "amount": buy_amount, "result": buy})
                     buy_id = _capture_order_id(buy)
                     if buy_id is not None:
                         order_ids.append(str(buy_id))
-                if i <= sell_levels and sell_amount >= min_size_sell:
+                    placed_any = True
+                except Exception as exc:  # best effort for first draft
+                    buy_error = str(exc)
+                    self.state["last_error"] = buy_error
+                    self._log(f"seed level {i} buy failed: {exc}")
+
+            if i <= sell_levels and sell_amount >= min_size_sell:
+                try:
                     sell = self.context.place_order(side="sell", order_type="limit", amount=sell_amount, price=sell_price, market=market)
                     orders.append({"side": "sell", "price": sell_price, "amount": sell_amount, "result": sell})
                     sell_id = _capture_order_id(sell)
                     if sell_id is not None:
                         order_ids.append(str(sell_id))
-                self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
-            except Exception as exc:  # best effort for first draft
-                self.state["last_error"] = str(exc)
-                self._log(f"seed level {i} failed: {exc}")
+                    placed_any = True
+                except Exception as exc:  # best effort for first draft
+                    sell_error = str(exc)
+                    self.state["last_error"] = sell_error
+                    self._log(f"seed level {i} sell failed: {exc}")
+
+            if placed_any:
+                if buy_error or sell_error:
+                    self._log(
+                        f"seed level {i} partial success: buy_error={buy_error or 'none'} sell_error={sell_error or 'none'}"
+                    )
+                else:
+                    self._log(f"seed level {i}: buy {buy_price} amount {buy_amount:.6g} / sell {sell_price} amount {sell_amount:.6g}")
+                delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
+                if delay > 0:
+                    time.sleep(delay)
+                self._refresh_balance_snapshot()
+            else:
+                if buy_error or sell_error:
+                    self._log(
+                        f"seed level {i} failed: buy_error={buy_error or 'none'} sell_error={sell_error or 'none'}"
+                    )
+                else:
+                    self._log(f"seed level {i} skipped: no order placed")
                 continue
 
-            delay = max(int(self.config.get("order_call_delay_ms", 250) or 0), 0) / 1000.0
-            if delay > 0:
-                time.sleep(delay)
-            self._refresh_balance_snapshot()
-
         self.state["orders"] = orders
         self.state["order_ids"] = order_ids
         self.state["last_action"] = "seeded grid"
@@ -973,6 +1059,8 @@ class Strategy(Strategy):
 
     def report(self):
         snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
+        supervision = self._supervision()
+        warnings = [w for w in [self._config_warning(), *(supervision.get("concerns") or [])] if w]
         return {
             "identity": snapshot.get("identity", {}),
             "control": snapshot.get("control", {}),
@@ -996,11 +1084,11 @@ class Strategy(Strategy):
                 "confidence": None,
                 "uncertainty": None,
                 "reason": "structure-based grid management",
-                "warnings": [w for w in [self._config_warning()] if w],
+                "warnings": warnings,
                 "policy": dict(self.config.get("policy") or {}),
             },
             "execution": snapshot.get("execution", {}),
-            "supervision": self._supervision(),
+            "supervision": supervision,
         }
 
     def render(self):

+ 8 - 7
strategies/trend_follower.md

@@ -15,19 +15,20 @@ Directional strategy for confirmed momentum.
 - the market is too chaotic for clean continuation
 
 ## Core parameters
-- `trend_timeframe`: regime timeframe to inspect
-- `trend_strength_min`: minimum strength to act
+- `trade_side`: buy, sell, or both
+- `order_notional_quote`: quote-currency notional per order
+- `max_order_notional_quote`: optional quote notional cap per order
 - `entry_offset_pct`: offset from market for entries
 - `exit_offset_pct`: offset for exits or reversals
-- `order_size`: base size for an entry
 - `cooldown_ticks`: pause between actions
 
 ## Hermes policy mapping
-- `risk_posture` adjusts strength threshold and offsets
-- `priority` adjusts urgency and cooldown
+- Hermes controls whether the side is active.
+- Hermes may set the quote notional and offsets.
 
 ## Notes
 - Hermes decides when trend following is allowed.
 - Trader maps policy to concrete order behavior.
-- The strategy reports signal, strength, and policy-derived settings.
-- `report().supervision` may signal `ready_to_yield_to_grid` only when trend pressure has cooled and inventory pressure is balanced.
+- The strategy reports side, quote notional, and policy-derived settings.
+- `trade_side` lets Hermes or the operator run a long-only, short-only, or symmetric directional instance.
+- live fee rates are used directly, so the strategy does not need a configured fee fallback.

+ 80 - 169
strategies/trend_follower.py

@@ -32,13 +32,11 @@ class Strategy(Strategy):
     }
     TICK_MINUTES = 0.5
     CONFIG_SCHEMA = {
-        "trend_timeframe": {"type": "string", "default": "1h"},
-        "trend_strength_min": {"type": "float", "default": 0.65, "min": 0.0, "max": 1.0},
+        "trade_side": {"type": "string", "default": "both"},
         "entry_offset_pct": {"type": "float", "default": 0.003, "min": 0.0, "max": 1.0},
         "exit_offset_pct": {"type": "float", "default": 0.002, "min": 0.0, "max": 1.0},
-        "order_size": {"type": "float", "default": 0.0, "min": 0.0},
-        "max_order_size": {"type": "float", "default": 0.0, "min": 0.0},
-        "fee_rate": {"type": "float", "default": 0.0025, "min": 0.0, "max": 0.05},
+        "order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
+        "max_order_notional_quote": {"type": "float", "default": 0.0, "min": 0.0},
         "cooldown_ticks": {"type": "int", "default": 2, "min": 0, "max": 1000},
         "debug_orders": {"type": "bool", "default": True},
     }
@@ -47,8 +45,7 @@ class Strategy(Strategy):
         "last_action": {"type": "string", "default": "idle"},
         "last_error": {"type": "string", "default": ""},
         "debug_log": {"type": "list", "default": []},
-        "last_signal": {"type": "string", "default": "neutral"},
-        "last_strength": {"type": "float", "default": 0.0},
+        "trade_side": {"type": "string", "default": "both"},
         "cooldown_remaining": {"type": "int", "default": 0},
         "last_order_at": {"type": "float", "default": 0.0},
         "last_order_price": {"type": "float", "default": 0.0},
@@ -62,8 +59,7 @@ class Strategy(Strategy):
             "last_action": "idle",
             "last_error": "",
             "debug_log": ["init trend follower"],
-            "last_signal": "neutral",
-            "last_strength": 0.0,
+            "trade_side": "both",
             "cooldown_remaining": 0,
             "last_order_at": 0.0,
             "last_order_price": 0.0,
@@ -85,6 +81,10 @@ class Strategy(Strategy):
     def _market_symbol(self) -> str:
         return self.context.market_symbol or f"{self._base_symbol().lower()}usd"
 
+    def _trade_side(self) -> str:
+        side = str(self.config.get("trade_side") or "both").strip().lower()
+        return side if side in {"buy", "sell", "both"} else "both"
+
     def _price(self) -> float:
         payload = self.context.get_price(self._base_symbol())
         return float(payload.get("price") or 0.0)
@@ -95,7 +95,7 @@ class Strategy(Strategy):
             return float(payload.get("maker") or payload.get("taker") or 0.0)
         except Exception as exc:
             self._log(f"fee lookup failed: {exc}")
-            return float(self.config.get("fee_rate", 0.0025) or 0.0)
+            return 0.0
 
     def _refresh_balance_snapshot(self) -> None:
         try:
@@ -121,167 +121,81 @@ class Strategy(Strategy):
             if asset == quote:
                 self.state["counter_available"] = available
 
-    def _inventory_ratio(self, price: float) -> float:
-        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:
-            return 0.5
-        return base_value / total
-
     def _supervision(self) -> dict:
-        price = float(self.state.get("last_price") or 0.0)
-        ratio = self._inventory_ratio(price if price > 0 else 1.0)
         last_error = str(self.state.get("last_error") or "")
-        strength = float(self.state.get("last_strength") or 0.0)
-        signal = str(self.state.get("last_signal") or "neutral")
-        if ratio >= 0.88:
-            pressure = "base_heavy"
-        elif ratio <= 0.12:
-            pressure = "quote_heavy"
-        elif ratio >= 0.68:
-            pressure = "base_biased"
-        elif ratio <= 0.32:
-            pressure = "quote_biased"
+        side = self._trade_side()
+        pressure = "balanced" if side in {"buy", "sell"} else "unknown"
+        entry_offset_pct = float(self.config.get("entry_offset_pct") or 0.003)
+        exit_offset_pct = float(self.config.get("exit_offset_pct") or 0.002)
+        last_order_at = float(self.state.get("last_order_at") or 0.0)
+        now_ts = datetime.now(timezone.utc).timestamp()
+        last_order_age_seconds = round(max(now_ts - last_order_at, 0.0), 3) if last_order_at > 0 else None
+        if entry_offset_pct <= 0.0015:
+            chasing_risk = "elevated"
+        elif entry_offset_pct <= 0.0035:
+            chasing_risk = "moderate"
         else:
-            pressure = "balanced"
+            chasing_risk = "low"
+        concerns = []
+        if side == "both":
+            concerns.append("generic trend instance relies on Hermes for direction")
+        if chasing_risk == "elevated":
+            concerns.append("entry offset is tight and may chase price")
         return {
             "health": "degraded" if last_error else "healthy",
             "degraded": bool(last_error),
             "inventory_pressure": pressure,
-            "capacity_available": strength >= float(self.config.get("trend_strength_min", 0.65) or 0.65),
-            "last_reason": last_error or f"signal={signal}, strength={strength:.3f}, base_ratio={ratio:.3f}",
-            "trend_strength": strength,
-            "signal": signal,
+            "capacity_available": side in {"buy", "sell"},
+            "trade_side": side,
+            "entry_offset_pct": round(entry_offset_pct, 6),
+            "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),
+            "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,
+            "concerns": concerns,
+            "last_reason": last_error or f"trade_side={side}",
         }
 
-    def _trend_snapshot(self) -> dict:
-        tf = str(self.config.get("trend_timeframe", "1h") or "1h")
-        try:
-            return self.context.get_regime(self._base_symbol(), tf)
-        except Exception as exc:
-            self._log(f"trend lookup failed: {exc}")
-            return {"error": str(exc)}
-
     def apply_policy(self):
         policy = super().apply_policy()
-        risk = str(policy.get("risk_posture") or "normal").lower()
-        priority = str(policy.get("priority") or "normal").lower()
-
-        strength_map = {"cautious": 0.8, "normal": 0.65, "assertive": 0.5}
-        entry_map = {"cautious": 0.002, "normal": 0.003, "assertive": 0.005}
-        exit_map = {"cautious": 0.0015, "normal": 0.002, "assertive": 0.003}
-        cooldown_map = {"cautious": 4, "normal": 2, "assertive": 1}
-        size_map = {"cautious": 0.5, "normal": 1.0, "assertive": 1.5}
-
-        if priority in {"low", "background"}:
-            risk = "cautious"
-        elif priority in {"high", "urgent"}:
-            risk = "assertive"
-
-        self.config["trend_strength_min"] = float(self.config.get("trend_strength_min") or strength_map.get(risk, 0.65))
-        self.config["entry_offset_pct"] = float(self.config.get("entry_offset_pct") or entry_map.get(risk, 0.003))
-        self.config["exit_offset_pct"] = float(self.config.get("exit_offset_pct") or exit_map.get(risk, 0.002))
-        self.config["cooldown_ticks"] = int(self.config.get("cooldown_ticks") or cooldown_map.get(risk, 2))
-        self.config["order_size"] = float(self.config.get("order_size") or size_map.get(risk, 1.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)
         self.state["policy_derived"] = {
-            "trend_strength_min": self.config["trend_strength_min"],
-            "entry_offset_pct": self.config["entry_offset_pct"],
-            "exit_offset_pct": self.config["exit_offset_pct"],
-            "cooldown_ticks": self.config["cooldown_ticks"],
-            "order_size": self.config["order_size"],
+            "trade_side": self._trade_side(),
+            "entry_offset_pct": float(self.config.get("entry_offset_pct") or 0.003),
+            "exit_offset_pct": float(self.config.get("exit_offset_pct") or 0.002),
+            "cooldown_ticks": int(self.config.get("cooldown_ticks") or 2),
+            "order_notional_quote": quote_notional,
+            "max_order_notional_quote": max_quote_notional,
         }
         return policy
 
-    def _trend_strength(self) -> tuple[str, float]:
-        regime = self._trend_snapshot()
-        trend = regime.get("trend") or {}
-        momentum = regime.get("momentum") or {}
-        direction = str(trend.get("state") or trend.get("direction") or "unknown")
-        strength = self._coerce_strength(trend.get("strength"))
-        if strength is None:
-            strength = self._derive_strength_from_regime(direction=direction, trend=trend, momentum=momentum, regime=regime)
-        return direction, strength
-
-    def _coerce_strength(self, value) -> float | None:
-        try:
-            if value is None:
-                return None
-            return max(0.0, min(1.0, float(value)))
-        except Exception:
-            return None
-
-    def _derive_strength_from_regime(self, *, direction: str, trend: dict, momentum: dict, regime: dict) -> float:
-        direction = str(direction or "unknown").lower()
-        score = 0.0
-
-        if direction in {"bull", "up", "long"}:
-            score += 0.45
-        elif direction in {"bear", "down", "short"}:
-            score += 0.45
-        else:
-            return 0.0
-
-        momentum_state = str(momentum.get("state") or "").lower()
-        if direction in {"bull", "up", "long"} and momentum_state == "bull":
-            score += 0.2
-        elif direction in {"bear", "down", "short"} and momentum_state == "bear":
-            score += 0.2
-
-        try:
-            rsi = float(momentum.get("rsi") or 0.0)
-        except Exception:
-            rsi = 0.0
-        if direction in {"bull", "up", "long"}:
-            if rsi >= 60:
-                score += 0.2
-            elif rsi >= 52:
-                score += 0.1
-        else:
-            if 0 < rsi <= 40:
-                score += 0.2
-            elif 0 < rsi <= 48:
-                score += 0.1
-
-        try:
-            macd_hist = float(momentum.get("macd_histogram") or 0.0)
-        except Exception:
-            macd_hist = 0.0
-        if direction in {"bull", "up", "long"} and macd_hist > 0:
-            score += 0.1
-        elif direction in {"bear", "down", "short"} and macd_hist < 0:
-            score += 0.1
-
-        try:
-            ema_fast = float(trend.get("ema_fast") or 0.0)
-            ema_slow = float(trend.get("ema_slow") or 0.0)
-        except Exception:
-            ema_fast = 0.0
-            ema_slow = 0.0
-        if direction in {"bull", "up", "long"} and ema_fast > ema_slow > 0:
-            score += 0.05
-        elif direction in {"bear", "down", "short"} and 0 < ema_fast < ema_slow:
-            score += 0.05
-
-        return max(0.0, min(1.0, round(score, 4)))
-
-    def _suggest_amount(self, price: float) -> float:
+    def _suggest_amount(self, price: float, side: str) -> float:
         min_notional = float(self.context.minimum_order_value or 0.0)
-        max_order = float(self.config.get("max_order_size", 0.0) 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"):
-            fee_rate = self._live_fee_rate()
-            return float(self.context.suggest_order_amount(
-                side="buy" if str(self.state.get("last_signal") or "").lower() in {"bull", "up", "long"} else "sell",
-                price=price,
-                levels=1,
-                min_notional=min_notional,
-                fee_rate=fee_rate,
-                max_notional_per_order=(max_order * price) if max_order > 0 else 0.0,
-                order_size=float(self.config.get("order_size", 0.0) or 0.0),
-            ) or 0.0)
-        amount = float(self.config.get("order_size", 0.0) or 0.0)
-        if max_order > 0:
-            amount = min(amount, max_order)
+            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)
 
     def on_tick(self, tick):
@@ -296,20 +210,16 @@ class Strategy(Strategy):
             self.state["last_action"] = "cooldown"
             return {"action": "cooldown", "price": price}
 
-        direction, strength = self._trend_strength()
-        self.state["last_signal"] = direction
-        self.state["last_strength"] = strength
-
-        if strength < float(self.config.get("trend_strength_min", 0.65) or 0.65):
+        side = self._trade_side()
+        if side not in {"buy", "sell"}:
             self.state["last_action"] = "hold"
-            return {"action": "hold", "price": price, "reason": "trend too weak", "strength": strength}
+            return {"action": "hold", "price": price, "reason": "trade_side must be buy or sell"}
 
-        amount = self._suggest_amount(price)
+        amount = self._suggest_amount(price, side)
         if amount <= 0:
             self.state["last_action"] = "hold"
             return {"action": "hold", "price": price, "reason": "no usable size"}
 
-        side = "buy" if direction in {"bull", "up", "long"} else "sell"
         offset = float(self.config.get("entry_offset_pct", 0.003) or 0.0)
         if side == "buy":
             order_price = round(price * (1 + offset), 8)
@@ -318,7 +228,7 @@ class Strategy(Strategy):
 
         try:
             if self.config.get("debug_orders", True):
-                self._log(f"{side} trend amount={amount:.6g} price={order_price} strength={strength:.3f}")
+                self._log(f"{side} trend amount={amount:.6g} price={order_price}")
             result = self.context.place_order(
                 side=side,
                 order_type="limit",
@@ -330,7 +240,7 @@ class Strategy(Strategy):
             self.state["last_order_at"] = datetime.now(timezone.utc).timestamp()
             self.state["last_order_price"] = order_price
             self.state["last_action"] = f"{side}_trend"
-            return {"action": side, "price": order_price, "amount": amount, "result": result, "strength": strength}
+            return {"action": side, "price": order_price, "amount": amount, "result": result}
         except Exception as exc:
             self.state["last_error"] = str(exc)
             self._log(f"trend order failed: {exc}")
@@ -347,8 +257,9 @@ class Strategy(Strategy):
             "state": {
                 "last_price": self.state.get("last_price", 0.0),
                 "last_action": self.state.get("last_action", "idle"),
-                "last_signal": self.state.get("last_signal", "neutral"),
-                "last_strength": self.state.get("last_strength", 0.0),
+                "trade_side": self._trade_side(),
+                "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),
                 "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),
@@ -357,7 +268,7 @@ class Strategy(Strategy):
                 "confidence": None,
                 "uncertainty": None,
                 "reason": "trend capture",
-                "warnings": [],
+                "warnings": [w for w in (self._supervision().get("concerns") or []) if w],
                 "policy": dict(self.config.get("policy") or {}),
             },
             "execution": snapshot.get("execution", {}),
@@ -369,8 +280,8 @@ class Strategy(Strategy):
             "widgets": [
                 {"type": "metric", "label": "market", "value": self._market_symbol()},
                 {"type": "metric", "label": "price", "value": round(float(self.state.get("last_price") or 0.0), 6)},
-                {"type": "metric", "label": "signal", "value": self.state.get("last_signal", "neutral")},
-                {"type": "metric", "label": "strength", "value": round(float(self.state.get("last_strength") or 0.0), 4)},
+                {"type": "metric", "label": "side", "value": self._trade_side()},
+                {"type": "metric", "label": "quote notional", "value": round(float(self.config.get("order_notional_quote") or 0.0), 6)},
                 {"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"},

+ 175 - 33
tests/test_strategies.py

@@ -145,6 +145,33 @@ def test_grid_supervision_reports_factual_capacity_not_handoff_commands():
     assert supervision["side_capacity"] == {"buy": True, "sell": False}
 
 
+def test_grid_supervision_exposes_adverse_side_open_orders():
+    class FakeContext:
+        account_id = "acct-1"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+        mode = "active"
+
+    strategy = GridStrategy(FakeContext(), {})
+    strategy.state.update({
+        "last_price": 1.60,
+        "center_price": 1.45,
+        "orders": [
+            {"side": "sell", "price": "1.62", "amount": "10", "status": "open"},
+            {"side": "sell", "price": "1.66", "amount": "5", "status": "open"},
+            {"side": "buy", "price": "1.38", "amount": "7", "status": "open"},
+        ],
+    })
+
+    supervision = strategy._supervision()
+    assert supervision["market_bias"] == "bullish"
+    assert supervision["adverse_side"] == "sell"
+    assert supervision["adverse_side_open_order_count"] == 2
+    assert supervision["adverse_side_open_order_notional_quote"] > 0
+    assert "sell ladder exposed" in " ".join(supervision["concerns"])
+
+
 def test_trend_and_protector_supervision_reports_facts_only():
     class FakeContext:
         account_id = "acct-1"
@@ -153,11 +180,13 @@ def test_trend_and_protector_supervision_reports_facts_only():
         counter_currency = "USD"
         mode = "active"
 
-    trend = TrendStrategy(FakeContext(), {})
-    trend.state.update({"last_price": 1.45, "last_strength": 0.42, "last_signal": "up", "base_available": 20.0, "counter_available": 20.0})
+    trend = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.0})
+    trend.state.update({"last_price": 1.45, "base_available": 20.0, "counter_available": 20.0, "last_order_at": 0.0})
     trend_supervision = trend._supervision()
-    assert trend_supervision["trend_strength"] == 0.42
-    assert trend_supervision["signal"] == "up"
+    assert trend_supervision["trade_side"] == "buy"
+    assert trend_supervision["capacity_available"] is True
+    assert trend_supervision["entry_offset_pct"] == 0.003
+    assert trend_supervision["chasing_risk"] in {"low", "moderate", "elevated"}
     assert "switch_readiness" not in trend_supervision
     assert "desired_companion" not in trend_supervision
 
@@ -165,6 +194,7 @@ def test_trend_and_protector_supervision_reports_facts_only():
     protector.state.update({"last_price": 1.45, "base_available": 40.0, "counter_available": 10.0})
     protector_supervision = protector._supervision()
     assert protector_supervision["rebalance_needed"] is True
+    assert protector_supervision["repair_progress"] <= 1.0
     assert "switch_readiness" not in protector_supervision
     assert "desired_companion" not in protector_supervision
 
@@ -221,6 +251,51 @@ def test_grid_apply_policy_keeps_explicit_grid_levels():
     assert strategy.state["policy_derived"]["grid_levels"] == 5
 
 
+def test_grid_seed_keeps_other_side_when_one_side_fails(monkeypatch):
+    class FakeContext:
+        base_currency = "XRP"
+        counter_currency = "USD"
+        market_symbol = "xrpusd"
+        minimum_order_value = 10.0
+        mode = "active"
+
+        def __init__(self):
+            self.attempts = []
+            self.buy_attempts = 0
+            self.sell_attempts = 0
+
+        def get_fee_rates(self, market):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return 10.0
+
+        def place_order(self, **kwargs):
+            self.attempts.append(kwargs)
+            if kwargs["side"] == "buy":
+                self.buy_attempts += 1
+                if self.buy_attempts == 3:
+                    raise RuntimeError("insufficient USD")
+            elif kwargs["side"] == "sell":
+                self.sell_attempts += 1
+            return {"status": "ok", "id": f"{kwargs['side']}-{len(self.attempts)}"}
+
+    ctx = FakeContext()
+    strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.0})
+    strategy.state["center_price"] = 100.0
+    monkeypatch.setattr(strategy, "_supported_levels", lambda side, center, min_notional: 5)
+    monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
+
+    strategy._place_grid(100.0)
+
+    orders = strategy.state["orders"]
+    assert ctx.buy_attempts == 5
+    assert ctx.sell_attempts == 5
+    assert len([o for o in orders if o["side"] == "buy"]) == 4
+    assert len([o for o in orders if o["side"] == "sell"]) == 5
+    assert any("partial success" in line for line in (strategy.state.get("debug_log") or [])) or strategy.state.get("last_error") == "insufficient USD"
+
+
 def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
     class FakeContext:
         base_currency = "XRP"
@@ -595,20 +670,17 @@ def test_trend_follower_uses_policy_and_reports_fit():
         def get_price(self, symbol):
             return {"price": 1.2}
 
-        def get_regime(self, symbol, timeframe="1h"):
-            return {"trend": {"state": "bull", "strength": 0.9}}
-
         def place_order(self, **kwargs):
             return {"ok": True, "order": kwargs}
 
         def get_strategy_snapshot(self):
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
-    strat = TrendStrategy(FakeContext(), {"order_size": 1.5})
+    strat = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.5})
     strat.apply_policy()
     report = strat.report()
     assert report["fit"]["risk_profile"] == "growth"
-    assert strat.state["policy_derived"]["order_size"] > 0
+    assert strat.state["policy_derived"]["order_notional_quote"] > 0
 
 
 def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
@@ -627,12 +699,6 @@ def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
         def get_price(self, symbol):
             return {"price": 1.2}
 
-        def get_regime(self, symbol, timeframe="1h"):
-            return {
-                "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
-                "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
-            }
-
         def place_order(self, **kwargs):
             self.orders.append(kwargs)
             return {"ok": True, "order": kwargs}
@@ -643,19 +709,18 @@ def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
         minimum_order_value = 10.0
 
         def suggest_order_amount(self, **kwargs):
-            return 10.0
+            return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
 
         def get_strategy_snapshot(self):
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
+    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 2.0})
     result = strat.on_tick({})
     assert result["action"] == "buy"
     assert ctx.orders[-1]["side"] == "buy"
-    assert ctx.orders[-1]["amount"] == 10.0
+    assert ctx.orders[-1]["amount"] == 2.0 / 1.2
     assert strat.state["last_action"] == "buy_trend"
-    assert strat.state["last_strength"] >= 0.65
 
 
 def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
@@ -668,6 +733,46 @@ def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
         base_currency = "XRP"
         counter_currency = "USD"
 
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 1.2}
+
+        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": 1000}, {"asset_code": "XRP", "available": 10}]}
+
+        minimum_order_value = 10.0
+
+        def suggest_order_amount(self, **kwargs):
+            return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 2.0})
+    result = strat.on_tick({})
+    assert result["action"] == "sell"
+    assert ctx.orders[-1]["side"] == "sell"
+    assert ctx.orders[-1]["amount"] == 2.0 / 1.2
+    assert strat.state["last_action"] == "sell_trend"
+
+
+def test_trend_follower_buy_only_ignores_bear_regime():
+    class FakeContext:
+        id = "s-buy-only"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+
         def __init__(self):
             self.orders = []
 
@@ -696,16 +801,59 @@ def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
+    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 2.0})
+    result = strat.on_tick({})
+    assert result["action"] == "buy"
+    assert ctx.orders[-1]["side"] == "buy"
+    assert strat.state["last_action"] == "buy_trend"
+
+
+def test_trend_follower_sell_only_ignores_bull_regime():
+    class FakeContext:
+        id = "s-sell-only"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "xrpusd"
+        base_currency = "XRP"
+        counter_currency = "USD"
+
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 1.2}
+
+        def get_regime(self, symbol, timeframe="1h"):
+            return {
+                "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
+                "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
+            }
+
+        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": 1000}, {"asset_code": "XRP", "available": 0}]}
+
+        minimum_order_value = 10.0
+
+        def suggest_order_amount(self, **kwargs):
+            return 10.0
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 2.0})
     result = strat.on_tick({})
     assert result["action"] == "sell"
     assert ctx.orders[-1]["side"] == "sell"
-    assert ctx.orders[-1]["amount"] == 6.0
     assert strat.state["last_action"] == "sell_trend"
-    assert strat.state["last_strength"] >= 0.65
 
 
-def test_trend_follower_policy_does_not_override_explicit_order_size():
+def test_trend_follower_policy_does_not_override_explicit_order_notional_quote():
     class FakeContext:
         id = "s-explicit"
         account_id = "acct-1"
@@ -718,16 +866,13 @@ def test_trend_follower_policy_does_not_override_explicit_order_size():
         def get_price(self, symbol):
             return {"price": 1.2}
 
-        def get_regime(self, symbol, timeframe="1h"):
-            return {"trend": {"state": "bull", "strength": 0.9}}
-
         def get_strategy_snapshot(self):
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
-    strat = TrendStrategy(FakeContext(), {"order_size": 10.5})
+    strat = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 10.5})
     strat.apply_policy()
-    assert strat.config["order_size"] == 10.5
-    assert strat.state["policy_derived"]["order_size"] == 10.5
+    assert strat.config["order_notional_quote"] == 10.5
+    assert strat.state["policy_derived"]["order_notional_quote"] == 10.5
 
 
 def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
@@ -748,9 +893,6 @@ def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
         def get_price(self, symbol):
             return {"price": 1.2}
 
-        def get_regime(self, symbol, timeframe="1h"):
-            return {"trend": {"state": "bull", "strength": 0.9}}
-
         def get_fee_rates(self, market_symbol=None):
             self.fee_calls.append(market_symbol)
             return {"maker": 0.0025, "taker": 0.004}
@@ -769,7 +911,7 @@ def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"order_size": 10.5, "trend_timeframe": "15m"})
+    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 10.5})
     strat.on_tick({})
     assert ctx.fee_calls == ["xrpusd"]
     assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025