Răsfoiți Sursa

order size fix, dumb trader

Lukas Goldschmidt 2 săptămâni în urmă
părinte
comite
30b9724b76

+ 72 - 0
AGENTS.md

@@ -0,0 +1,72 @@
+# AGENTS.md
+
+## Role
+TRADER-MCP is a strategy runtime.
+
+It executes multiple independent trading strategies and converts their outputs into execution intents for exec-mcp.
+
+---
+
+## Architecture
+
+TRADER-MCP is structured in three layers:
+
+1. Strategy Layer
+   - runs pluggable strategies (grid, mean reversion, etc.)
+   - produces trade intents
+
+2. Runtime Layer
+   - manages lifecycle of strategies (start/stop/configure)
+   - isolates strategies from each other
+
+3. Execution Layer
+   - translates intents into exec-mcp commands
+   - ensures format correctness
+
+---
+
+## Control Authority
+
+- hermes-mcp controls:
+  - which strategies are active
+  - strategy parameters
+  - global risk posture
+
+- trader-mcp must obey all hermes control signals
+- trader-mcp must NOT override or reinterpret them
+
+---
+
+## Core Invariants
+
+- strategies are independent units
+- no strategy may access others’ internal state
+- trader-mcp does not define market stance
+- execution is derived only from strategy outputs + Hermes constraints
+
+---
+
+## Boundary Rules
+
+- NO strategy selection logic (belongs to Hermes)
+- NO direct exchange access
+- NO market-data-driven decision making outside strategies
+- NO cross-strategy coupling
+
+---
+
+## Failure Model
+
+- strategy failures must be isolated
+- system must continue running unaffected strategies
+- invalid outputs must be rejected, not corrected silently
+
+---
+
+## Interface Stability
+
+Changes affecting:
+- Hermes control signals
+- execution intent format
+
+are breaking and must be coordinated across MCPs.

+ 25 - 1
src/trader_mcp/strategy_sizing.py

@@ -73,6 +73,12 @@ def suggest_quote_sized_amount(
     }
     amount = _call_context_suggest_order_amount(context, kwargs)
     if amount > 0:
+        if side == "buy":
+            if min_notional > 0 and (amount * price * (1 + fee_rate)) < min_notional:
+                return 0.0
+        else:
+            if min_notional > 0 and (amount * price) < min_notional:
+                return 0.0
         return amount
 
     if order_notional_quote <= 0:
@@ -117,6 +123,24 @@ def cap_amount_to_balance_target(
 
     if amount <= 0 or price <= 0 or side not in {"buy", "sell"}:
         return 0.0
+
+    def meets_min_notional(candidate: float) -> bool:
+        if min_notional <= 0:
+            return True
+        if side == "buy":
+            return (candidate * price * (1 + fee_rate)) >= min_notional
+        return (candidate * price) >= min_notional
+
+    if side == "buy":
+        max_amount = counter_available / (price * (1 + fee_rate)) if counter_available > 0 else 0.0
+    else:
+        max_amount = base_available
+
+    amount = min(amount, max(max_amount, 0.0))
+    if amount <= 0:
+        return 0.0
+    if not meets_min_notional(amount):
+        return 0.0
     if target >= 1.0:
         return amount
 
@@ -143,7 +167,7 @@ def cap_amount_to_balance_target(
 
     if capped_amount <= 0:
         return 0.0
-    if min_notional > 0 and (capped_amount * price) < min_notional:
+    if not meets_min_notional(capped_amount):
         return 0.0
     return max(capped_amount, 0.0)
 

+ 1 - 1
strategies/exposure_protector.py

@@ -28,7 +28,7 @@ class Strategy(Strategy):
         "requires_rebalance_before_start": False,
         "requires_rebalance_before_stop": False,
         "safe_when_unbalanced": True,
-        "can_run_with": ["grid_trader", "trend_follower"],
+        "can_run_with": ["grid_trader", "dumb_trader"],
     }
     TICK_MINUTES = 0.2
     CONFIG_SCHEMA = {

+ 7 - 53
strategies/trend_follower.md

@@ -1,70 +1,24 @@
 # Trend Follower
 
-Directional strategy for confirmed momentum.
+Legacy alias for `dumb_trader`.
 
-## Best Used When
-- trend is strong and persistent
-- structure supports continuation
-- liquidity is normal
-- Hermes wants to follow momentum instead of fading it
-
-## Avoid When
-- price is range-bound
-- trend strength is weak or noisy
-- event risk is high
-- the market is too chaotic for clean continuation
-
-## How It Works
-- The strategy acts on the configured side, not on an internal trend model.
-- `trade_side` controls whether it can buy, sell, or accept either side. `both` is symmetric and depends on Hermes or the operator for direction.
-- `balance_target` is a wallet-allocation target, not a position-size target.
-- For `buy`, sub-`1.0` values stop the strategy once the base share of wallet value reaches the target.
-- For `sell`, sub-`1.0` values stop the strategy once the quote share reaches the target.
-- When an order would overshoot a sub-`1.0` target, the last order is clamped to the remaining gap.
-- `entry_offset_pct` sets the limit price offset for new entries.
-- `cooldown_ticks` pauses the next few ticks after a successful order.
-
-## Parameters
-- `trade_side`: Allowed side selection. `buy` and `sell` make it one-sided; `both` is symmetric and relies on Hermes for direction.
-- `balance_target`: Allocation target between `0.0` and `1.0`. `1.0` means keep trading until the funding side is exhausted. Lower values stop once the wallet reaches the requested mix.
-- `entry_offset_pct`: Limit-price offset used for new entries. Buy orders are placed above the last price, sell orders below it.
-- `exit_offset_pct`: Reserved for exit or reversal pricing in policy and supervision output. It is part of the contract even though the current entry path only uses `entry_offset_pct`.
-- `order_notional_quote`: Target quote notional per order.
-- `max_order_notional_quote`: Optional hard cap on quote notional per order.
-- `dust_collect`: Lets the shared sizing helper consume leftover size more aggressively when a venue minimum would otherwise strand a small remainder.
-- `cooldown_ticks`: Number of ticks to wait after a successful order before the strategy can act again.
-- `debug_orders`: Enables order-placement debug logging.
-
-## Hermes Policy Mapping
-- Hermes controls whether the side is active.
-- Hermes may set `balance_target` to define how far the wallet should rotate.
-- Hermes may set the quote notional and offsets.
-- `apply_policy()` currently records the derived values but does not rewrite the config the way the grid and exposure strategies do.
+This filename is kept for compatibility while the side-only trader is being
+renamed. The active behavior lives in [`dumb_trader.md`](./dumb_trader.md).
 
 ## Notes
-- Hermes decides when trend following is allowed.
-- Trader maps policy to concrete order behavior.
-- 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.
-- `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.
+- The runtime currently treats this strategy as a side-only executor.
+- It does not use trend data.
+- `trade_side` controls whether it can buy, sell, or hold when set to `both`.
 
 ## Useful Example
 ```json
 {
   "trade_side": "buy",
-  "balance_target": 0.8,
-  "entry_offset_pct": 0.0035,
-  "exit_offset_pct": 0.002,
   "order_notional_quote": 25,
+  "entry_offset_pct": 0.0035,
   "max_order_notional_quote": 40,
   "dust_collect": true,
   "cooldown_ticks": 3,
   "debug_orders": false
 }
 ```
-
-This is a practical long-only accumulation setup. It keeps buying until the wallet is roughly 80 percent base by value, uses a modest entry offset, and avoids oversized last orders.

+ 1 - 336
strategies/trend_follower.py

@@ -1,336 +1 @@
-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
-
-
-class Strategy(Strategy):
-    LABEL = "Trend Follower"
-    STRATEGY_PROFILE = {
-        "expects": {
-            "trend": "strong",
-            "volatility": "moderate",
-            "event_risk": "low",
-            "liquidity": "normal",
-        },
-        "avoids": {
-            "trend": "range",
-            "volatility": "chaotic",
-            "event_risk": "high",
-            "liquidity": "thin",
-        },
-        "risk_profile": "growth",
-        "capabilities": ["directional_continuation", "momentum_following", "inventory_accumulation"],
-        "role": "primary",
-        "inventory_behavior": "accumulative_long",
-        "requires_rebalance_before_start": False,
-        "requires_rebalance_before_stop": False,
-        "safe_when_unbalanced": True,
-        "can_run_with": ["exposure_protector"],
-    }
-    TICK_MINUTES = 0.5
-    CONFIG_SCHEMA = {
-        "trade_side": {"type": "string", "default": "both"},
-        "balance_target": {"type": "float", "default": 1.0, "min": 0.0, "max": 1.0},
-        "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_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},
-    }
-    STATE_SCHEMA = {
-        "last_price": {"type": "float", "default": 0.0},
-        "last_action": {"type": "string", "default": "idle"},
-        "last_error": {"type": "string", "default": ""},
-        "debug_log": {"type": "list", "default": []},
-        "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},
-        "base_available": {"type": "float", "default": 0.0},
-        "counter_available": {"type": "float", "default": 0.0},
-    }
-
-    def init(self):
-        return {
-            "last_price": 0.0,
-            "last_action": "idle",
-            "last_error": "",
-            "debug_log": ["init trend follower"],
-            "trade_side": "both",
-            "cooldown_remaining": 0,
-            "last_order_at": 0.0,
-            "last_order_price": 0.0,
-            "base_available": 0.0,
-            "counter_available": 0.0,
-        }
-
-    def _log(self, message: str) -> None:
-        state = getattr(self, "state", {}) or {}
-        log = list(state.get("debug_log") or [])
-        log.append(message)
-        state["debug_log"] = log[-12:]
-        self.state = state
-        log_event("trend", message)
-
-    def _base_symbol(self) -> str:
-        return (self.context.base_currency or self.context.market_symbol or "XRP").split("/")[0].upper()
-
-    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)
-
-    def _live_fee_rate(self) -> float:
-        try:
-            payload = self.context.get_fee_rates(self._market_symbol())
-            return float(payload.get("maker") or payload.get("taker") or 0.0)
-        except Exception as exc:
-            self._log(f"fee lookup failed: {exc}")
-            return 0.0
-
-    def _refresh_balance_snapshot(self) -> None:
-        try:
-            info = self.context.get_account_info()
-        except Exception as exc:
-            self._log(f"balance refresh failed: {exc}")
-            return
-        balances = info.get("balances") if isinstance(info, dict) else []
-        if not isinstance(balances, list):
-            return
-        base = self._base_symbol()
-        quote = str(self.context.counter_currency or "USD").upper()
-        for balance in balances:
-            if not isinstance(balance, dict):
-                continue
-            asset = str(balance.get("asset_code") or "").upper()
-            try:
-                available = float(balance.get("available") if balance.get("available") is not None else balance.get("total") or 0.0)
-            except Exception:
-                continue
-            if asset == base:
-                self.state["base_available"] = available
-            if asset == quote:
-                self.state["counter_available"] = available
-
-    def _supervision(self) -> dict:
-        last_error = str(self.state.get("last_error") or "")
-        side = self._trade_side()
-        pressure = "balanced" if side in {"buy", "sell"} else "unknown"
-        balance_target = self._balance_target()
-        base_ratio = self._account_value_ratio(float(self.state.get("last_price") or 0.0))
-        quote_ratio = max(0.0, 1.0 - base_ratio)
-        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:
-            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": side in {"buy", "sell"},
-            "trade_side": side,
-            "balance_target": round(balance_target, 6),
-            "base_ratio": round(base_ratio, 6),
-            "quote_ratio": round(quote_ratio, 6),
-            "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),
-            "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,
-            "concerns": concerns,
-            "last_reason": last_error or f"trade_side={side}",
-        }
-
-    def apply_policy(self):
-        policy = super().apply_policy()
-        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"] = {
-            "trade_side": self._trade_side(),
-            "balance_target": self._balance_target(),
-            "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,
-            "dust_collect": bool(self.config.get("dust_collect", False)),
-        }
-        return policy
-
-    def _balance_target(self) -> float:
-        try:
-            target = float(self.config.get("balance_target") if self.config.get("balance_target") is not None else 1.0)
-        except Exception:
-            return 1.0
-        return min(max(target, 0.0), 1.0)
-
-    def _account_value_ratio(self, price: float) -> float:
-        if price <= 0:
-            return 0.5
-        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 _balance_target_reached(self, side: str, price: float) -> bool:
-        target = self._balance_target()
-        if target >= 1.0:
-            return False
-        base_ratio = self._account_value_ratio(price)
-        if side == "buy":
-            return base_ratio >= target
-        if side == "sell":
-            return (1.0 - base_ratio) >= target
-        return False
-
-    def _suggest_amount(self, price: float, side: str) -> float:
-        min_notional = float(self.context.minimum_order_value or 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"] = ""
-        self._log(f"tick alive price={self.state.get('last_price') or 0.0}")
-        self._refresh_balance_snapshot()
-        price = self._price()
-        self.state["last_price"] = price
-
-        if int(self.state.get("cooldown_remaining") or 0) > 0:
-            self.state["cooldown_remaining"] = int(self.state.get("cooldown_remaining") or 0) - 1
-            self.state["last_action"] = "cooldown"
-            return {"action": "cooldown", "price": price}
-
-        side = self._trade_side()
-        if side not in {"buy", "sell"}:
-            self.state["last_action"] = "hold"
-            return {"action": "hold", "price": price, "reason": "trade_side must be buy or sell"}
-        if self._balance_target_reached(side, price):
-            self.state["last_action"] = "target_reached"
-            return {"action": "hold", "price": price, "reason": "balance target reached"}
-
-        amount = self._suggest_amount(price, side)
-        if amount <= 0:
-            self.state["last_action"] = "hold"
-            return {"action": "hold", "price": price, "reason": "no usable size"}
-
-        offset = float(self.config.get("entry_offset_pct", 0.003) or 0.0)
-        if side == "buy":
-            order_price = round(price * (1 + offset), 8)
-        else:
-            order_price = round(price * (1 - offset), 8)
-
-        try:
-            if self.config.get("debug_orders", True):
-                self._log(f"{side} trend amount={amount:.6g} price={order_price}")
-            result = self.context.place_order(
-                side=side,
-                order_type="limit",
-                amount=amount,
-                price=order_price,
-                market=self._market_symbol(),
-            )
-            self.state["cooldown_remaining"] = int(self.config.get("cooldown_ticks", 2) or 2)
-            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}
-        except Exception as exc:
-            self.state["last_error"] = str(exc)
-            self._log(f"trend order failed: {exc}")
-            self.state["last_action"] = "error"
-            return {"action": "error", "price": price, "error": str(exc)}
-
-    def report(self):
-        snapshot = self.context.get_strategy_snapshot() if hasattr(self.context, "get_strategy_snapshot") else {}
-        return {
-            "identity": snapshot.get("identity", {}),
-            "control": snapshot.get("control", {}),
-            "fit": dict(getattr(self, "STRATEGY_PROFILE", {}) or {}),
-            "position": snapshot.get("position", {}),
-            "state": {
-                "last_price": self.state.get("last_price", 0.0),
-                "last_action": self.state.get("last_action", "idle"),
-                "trade_side": self._trade_side(),
-                "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),
-            },
-            "assessment": {
-                "confidence": None,
-                "uncertainty": None,
-                "reason": "trend capture",
-                "warnings": [w for w in (self._supervision().get("concerns") or []) if w],
-                "policy": dict(self.config.get("policy") or {}),
-            },
-            "execution": snapshot.get("execution", {}),
-            "supervision": self._supervision(),
-        }
-
-    def render(self):
-        return {
-            "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": "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"},
-                {"type": "log", "label": "debug log", "lines": self.state.get("debug_log") or []},
-            ]
-        }
+from strategies.dumb_trader import Strategy

+ 110 - 36
tests/test_strategies.py

@@ -8,10 +8,11 @@ from fastapi.testclient import TestClient
 from src.trader_mcp import strategy_registry, strategy_store
 from src.trader_mcp.server import app
 from src.trader_mcp.strategy_context import StrategyContext
+from src.trader_mcp.strategy_sizing import cap_amount_to_balance_target
 from src.trader_mcp.strategy_sdk import Strategy as BaseStrategy
 from strategies.exposure_protector import Strategy as ExposureStrategy
+from strategies.dumb_trader import Strategy as DumbStrategy
 from strategies.grid_trader import Strategy as GridStrategy
-from strategies.trend_follower import Strategy as TrendStrategy
 
 
 STRATEGY_CODE = '''
@@ -172,7 +173,7 @@ def test_grid_supervision_exposes_adverse_side_open_orders():
     assert "sell ladder exposed" in " ".join(supervision["concerns"])
 
 
-def test_trend_and_protector_supervision_reports_facts_only():
+def test_dumb_trader_and_protector_supervision_reports_facts_only():
     class FakeContext:
         account_id = "acct-1"
         market_symbol = "xrpusd"
@@ -180,7 +181,7 @@ def test_trend_and_protector_supervision_reports_facts_only():
         counter_currency = "USD"
         mode = "active"
 
-    trend = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.0})
+    trend = DumbStrategy(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["trade_side"] == "buy"
@@ -657,7 +658,7 @@ def test_base_strategy_report_uses_context_snapshot():
     assert report["position"]["open_orders"][0]["id"] == "o1"
 
 
-def test_trend_follower_uses_policy_and_reports_fit():
+def test_dumb_trader_uses_policy_and_reports_fit():
     class FakeContext:
         id = "s-2"
         account_id = "acct-2"
@@ -676,14 +677,14 @@ def test_trend_follower_uses_policy_and_reports_fit():
         def get_strategy_snapshot(self):
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
-    strat = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.5})
+    strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.5})
     strat.apply_policy()
     report = strat.report()
-    assert report["fit"]["risk_profile"] == "growth"
+    assert report["fit"]["risk_profile"] == "neutral"
     assert strat.state["policy_derived"]["order_notional_quote"] > 0
 
 
-def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
+def test_dumb_trader_buys_on_configured_side_without_regime_input():
     class FakeContext:
         id = "s-bull"
         account_id = "acct-1"
@@ -715,15 +716,15 @@ def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 2.0})
+    strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
     result = strat.on_tick({})
     assert result["action"] == "buy"
     assert ctx.orders[-1]["side"] == "buy"
-    assert ctx.orders[-1]["amount"] == 2.0 / 1.2
-    assert strat.state["last_action"] == "buy_trend"
+    assert ctx.orders[-1]["amount"] == 20.0 / 1.2
+    assert strat.state["last_action"] == "buy_dumb"
 
 
-def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
+def test_dumb_trader_sells_on_configured_side_without_regime_input():
     class FakeContext:
         id = "s-bear"
         account_id = "acct-1"
@@ -755,15 +756,15 @@ def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 2.0})
+    strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.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"
+    assert ctx.orders[-1]["amount"] == 20.0 / 1.2
+    assert strat.state["last_action"] == "sell_dumb"
 
 
-def test_trend_follower_buy_only_ignores_bear_regime():
+def test_dumb_trader_buy_only_ignores_bear_regime():
     class FakeContext:
         id = "s-buy-only"
         account_id = "acct-1"
@@ -795,20 +796,20 @@ def test_trend_follower_buy_only_ignores_bear_regime():
         minimum_order_value = 10.0
 
         def suggest_order_amount(self, **kwargs):
-            return 6.0
+            return 10.0
 
         def get_strategy_snapshot(self):
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 2.0})
+    strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
     result = strat.on_tick({})
     assert result["action"] == "buy"
     assert ctx.orders[-1]["side"] == "buy"
-    assert strat.state["last_action"] == "buy_trend"
+    assert strat.state["last_action"] == "buy_dumb"
 
 
-def test_trend_follower_sell_only_ignores_bull_regime():
+def test_dumb_trader_sell_only_ignores_bull_regime():
     class FakeContext:
         id = "s-sell-only"
         account_id = "acct-1"
@@ -846,14 +847,14 @@ def test_trend_follower_sell_only_ignores_bull_regime():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 2.0})
+    strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.0})
     result = strat.on_tick({})
     assert result["action"] == "sell"
     assert ctx.orders[-1]["side"] == "sell"
-    assert strat.state["last_action"] == "sell_trend"
+    assert strat.state["last_action"] == "sell_dumb"
 
 
-def test_trend_follower_policy_does_not_override_explicit_order_notional_quote():
+def test_dumb_trader_policy_does_not_override_explicit_order_notional_quote():
     class FakeContext:
         id = "s-explicit"
         account_id = "acct-1"
@@ -869,13 +870,13 @@ def test_trend_follower_policy_does_not_override_explicit_order_notional_quote()
         def get_strategy_snapshot(self):
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
-    strat = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 10.5})
+    strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 10.5})
     strat.apply_policy()
     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():
+def test_dumb_trader_passes_live_fee_rate_into_sizing_helper():
     class FakeContext:
         id = "s-fee"
         account_id = "acct-1"
@@ -911,7 +912,7 @@ 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, "dust_collect": True})
+    strat = DumbStrategy(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
@@ -957,7 +958,7 @@ def test_grid_sizing_helper_receives_quote_controls_and_dust_collect():
     assert ctx.suggest_calls[-1]["levels"] == 3
 
 
-def test_trend_follower_buy_clamps_last_order_to_balance_target():
+def test_dumb_trader_buy_uses_requested_notional_even_with_balance_target_configured():
     class FakeContext:
         id = "s-buy-clamp"
         account_id = "acct-1"
@@ -991,15 +992,15 @@ def test_trend_follower_buy_clamps_last_order_to_balance_target():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
+    strat = DumbStrategy(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
+    assert ctx.orders[-1]["amount"] == 3.0
 
 
-def test_trend_follower_sell_clamps_last_order_to_balance_target():
+def test_dumb_trader_sell_uses_requested_notional_even_with_balance_target_configured():
     class FakeContext:
         id = "s-sell-clamp"
         account_id = "acct-1"
@@ -1033,15 +1034,58 @@ def test_trend_follower_sell_clamps_last_order_to_balance_target():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 0.5})
+    strat = DumbStrategy(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
+    assert ctx.orders[-1]["amount"] == 5.0
 
 
-def test_trend_follower_holds_when_balance_target_already_reached():
+def test_dumb_trader_sell_holds_sub_minimum_order():
+    class FakeContext:
+        id = "s-sell-min"
+        account_id = "acct-1"
+        client_id = "cid-1"
+        mode = "active"
+        market_symbol = "solusd"
+        base_currency = "SOL"
+        counter_currency = "USD"
+        minimum_order_value = 1.0
+
+        def __init__(self):
+            self.orders = []
+
+        def get_price(self, symbol):
+            return {"price": 86.20062}
+
+        def get_fee_rates(self, market_symbol=None):
+            return {"maker": 0.0, "taker": 0.0}
+
+        def suggest_order_amount(self, **kwargs):
+            return 0.001
+
+        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.0}, {"asset_code": "SOL", "available": 0.00447}]}
+
+        def get_strategy_snapshot(self):
+            return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
+
+    ctx = FakeContext()
+    strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 1.0})
+
+    result = strat.on_tick({})
+
+    assert result["action"] == "hold"
+    assert result["reason"] == "no usable size"
+    assert ctx.orders == []
+
+
+def test_dumb_trader_holds_when_trade_side_is_symmetrical():
     class FakeContext:
         id = "s-target-hold"
         account_id = "acct-1"
@@ -1067,15 +1111,45 @@ def test_trend_follower_holds_when_balance_target_already_reached():
         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})
+    strat = DumbStrategy(FakeContext(), {"trade_side": "both", "order_notional_quote": 3.0, "balance_target": 0.5})
 
     result = strat.on_tick({})
 
     assert result["action"] == "hold"
-    assert result["reason"] == "balance target reached"
+    assert result["reason"] == "trade_side must be buy or sell"
+
+
+def test_cap_amount_to_balance_target_caps_sell_to_live_base():
+    amount = cap_amount_to_balance_target(
+        suggested_amount=0.127226,
+        side="sell",
+        price=86.20062,
+        fee_rate=0.0,
+        balance_target=1.0,
+        base_available=0.00447,
+        counter_available=0.0,
+        min_notional=0.0,
+    )
+
+    assert amount == 0.00447
+
+
+def test_cap_amount_to_balance_target_rejects_sell_below_min_notional():
+    amount = cap_amount_to_balance_target(
+        suggested_amount=0.127226,
+        side="sell",
+        price=86.20062,
+        fee_rate=0.0,
+        balance_target=1.0,
+        base_available=0.00447,
+        counter_available=0.0,
+        min_notional=1.0,
+    )
+
+    assert amount == 0.0
 
 
-def test_trend_follower_balance_target_one_does_not_clamp_size():
+def test_dumb_trader_ignores_balance_target_and_keeps_size():
     class FakeContext:
         id = "s-target-open"
         account_id = "acct-1"
@@ -1109,7 +1183,7 @@ def test_trend_follower_balance_target_one_does_not_clamp_size():
             return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
 
     ctx = FakeContext()
-    strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 1.0})
+    strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
 
     result = strat.on_tick({})