feat(kevin-exec): size from target_dollars, propagate price, bracket entries

Kevin signals never placed orders: the executor sized only from
sentiment_context["current_price"] (None for Kevin) so qty=0, and orders
were always built SIMPLE (stop/take pcts ignored).

- TradeSignal gains `current_price`; the bridge now sets it on publish
- risk_manager honors `target_dollars` directly (no strength re-scale) and
  resolves price from current_price then sentiment_context
- executor builds BRACKET orders for LONG entries carrying stop/take pcts;
  EXIT/SELL signals stay SIMPLE (the bridge sets pcts even on exits)

[ci skip]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-04 21:56:59 +00:00
parent 7d1d4464c9
commit 14407d37dc
6 changed files with 302 additions and 26 deletions

View file

@ -129,6 +129,7 @@ class KevinBridge:
target_dollars=decision.target_dollars, target_dollars=decision.target_dollars,
stop_loss_pct=Decimal(str(self.config.kevin_stop_loss_pct)), stop_loss_pct=Decimal(str(self.config.kevin_stop_loss_pct)),
take_profit_pct=Decimal(str(self.config.kevin_take_profit_pct)), take_profit_pct=Decimal(str(self.config.kevin_take_profit_pct)),
current_price=current_price,
) )
# Persist to signals table FIRST so downstream FK # Persist to signals table FIRST so downstream FK

View file

@ -56,6 +56,38 @@ async def _next_market_open(broker: AlpacaBroker) -> datetime:
return next_open.astimezone(timezone.utc) return next_open.astimezone(timezone.utc)
def _build_order_request(
signal: TradeSignal,
side: OrderSide,
qty: float,
risk_manager: RiskManager,
) -> OrderRequest:
"""Build a BRACKET order for LONG entries that carry both stop/take
percentages; otherwise a SIMPLE order.
The bridge stamps stop/take pcts even on EXIT signals, so the
LONG-direction guard is what keeps exits SIMPLE.
"""
is_entry = signal.direction == SignalDirection.LONG
if (
is_entry
and signal.stop_loss_pct is not None
and signal.take_profit_pct is not None
):
entry = risk_manager._resolve_price(signal)
stop_loss_price = round(entry * (1 - float(signal.stop_loss_pct)), 2)
take_profit_price = round(entry * (1 + float(signal.take_profit_pct)), 2)
return OrderRequest(
ticker=signal.ticker,
side=side,
qty=float(qty),
order_class="bracket",
take_profit_price=take_profit_price,
stop_loss_price=stop_loss_price,
)
return OrderRequest(ticker=signal.ticker, side=side, qty=float(qty))
async def process_signal( async def process_signal(
signal: TradeSignal, signal: TradeSignal,
risk_manager: RiskManager, risk_manager: RiskManager,
@ -133,11 +165,7 @@ async def process_signal(
# --- Step 3: create order --- # --- Step 3: create order ---
side = OrderSide.BUY if signal.direction == SignalDirection.LONG else OrderSide.SELL side = OrderSide.BUY if signal.direction == SignalDirection.LONG else OrderSide.SELL
order_request = OrderRequest( order_request = _build_order_request(signal, side, qty, risk_manager)
ticker=signal.ticker,
side=side,
qty=float(qty),
)
# --- Step 4: submit order --- # --- Step 4: submit order ---
start = time.monotonic() start = time.monotonic()

View file

@ -8,7 +8,6 @@ from __future__ import annotations
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from redis.asyncio import Redis from redis.asyncio import Redis
@ -16,7 +15,7 @@ from redis.asyncio import Redis
from services.trade_executor.config import TradeExecutorConfig from services.trade_executor.config import TradeExecutorConfig
from shared.broker.base import BaseBroker from shared.broker.base import BaseBroker
from shared.constants.kevin import KEVIN_STRATEGY_UUID from shared.constants.kevin import KEVIN_STRATEGY_UUID
from shared.schemas.trading import AccountInfo, PositionInfo, SignalDirection, TradeSignal from shared.schemas.trading import AccountInfo, TradeSignal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -185,14 +184,22 @@ class RiskManager:
) -> float: ) -> float:
"""Calculate the number of shares to buy/sell. """Calculate the number of shares to buy/sell.
Uses fixed-fractional sizing: ``equity * max_position_pct`` Two sizing modes:
gives the maximum dollar value per position, then scales by
signal strength. * **Pre-computed (Kevin)** when ``signal.target_dollars`` is set,
honor it directly as the position notional. The bridge has already
applied conviction-weighting and per-ticker headroom, so we do NOT
re-scale by strength.
* **Fixed-fractional (legacy)** ``equity * max_position_pct`` gives
the max dollar value per position, then scales by signal strength.
Price is resolved from ``signal.current_price`` first, falling back to
``signal.sentiment_context["current_price"]``.
Parameters Parameters
---------- ----------
signal: signal:
The trade signal (includes current price via strength). The trade signal carrying the price and (optionally) sizing.
account: account:
Current account info (equity, buying power). Current account info (equity, buying power).
@ -201,26 +208,34 @@ class RiskManager:
float float
Number of shares (whole shares). Number of shares (whole shares).
""" """
if signal.strength <= 0 or account.equity <= 0: if account.equity <= 0:
return 0.0 return 0.0
position_value = account.equity * self.config.max_position_pct price = self._resolve_price(signal)
position_value *= signal.strength if price <= 0:
# Need a price to compute qty — use the signal's embedded price
# or fall back to getting it from the snapshot. For simplicity
# the executor will pass the current price through the signal's
# sentiment_context or fetch it directly.
current_price = 0.0
if signal.sentiment_context and "current_price" in signal.sentiment_context:
current_price = float(signal.sentiment_context["current_price"])
if current_price <= 0:
logger.warning("No current price for %s, cannot size position", signal.ticker) logger.warning("No current price for %s, cannot size position", signal.ticker)
return 0.0 return 0.0
qty = position_value / current_price if signal.target_dollars is not None and signal.target_dollars > 0:
return max(int(qty), 0) position_value = float(signal.target_dollars)
else:
if signal.strength <= 0:
return 0.0
position_value = account.equity * self.config.max_position_pct * signal.strength
return max(int(position_value / price), 0)
@staticmethod
def _resolve_price(signal: TradeSignal) -> float:
"""Resolve the sizing/entry price: prefer the dedicated
``current_price`` field, fall back to ``sentiment_context``."""
if signal.current_price is not None and signal.current_price > 0:
return float(signal.current_price)
if signal.sentiment_context and "current_price" in signal.sentiment_context:
ctx_price = float(signal.sentiment_context["current_price"])
if ctx_price > 0:
return ctx_price
return 0.0
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal helpers # Internal helpers

View file

@ -125,6 +125,7 @@ class TradeSignal(BaseModel):
target_dollars: Decimal | None = None target_dollars: Decimal | None = None
stop_loss_pct: Decimal | None = None stop_loss_pct: Decimal | None = None
take_profit_pct: Decimal | None = None take_profit_pct: Decimal | None = None
current_price: Decimal | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View file

@ -118,6 +118,65 @@ async def test_bridge_kill_switch_on_publishes_to_stream():
cursor.advance.assert_awaited_with(1) cursor.advance.assert_awaited_with(1)
async def test_bridge_attaches_current_price_to_signal():
"""The bridge must propagate broker.get_latest_price onto the published
TradeSignal so the executor can size the position."""
config = MagicMock(
kevin_enable_trading=True,
kevin_stop_loss_pct=0.08,
kevin_take_profit_pct=0.20,
kevin_avoid_blocks_days=7,
)
cursor = AsyncMock()
cursor.last_seen_id.return_value = 0
publisher = AsyncMock()
publisher.publish.return_value = "1234-0"
aggregator = AsyncMock()
aggregator.fetch_pending.return_value = [
MagicMock(
id=1,
symbol="NVDA",
action=MagicMock(value="buy"),
conviction=Decimal("0.8"),
effective_conviction=Decimal("0.8"),
time_horizon=MagicMock(value="weeks"),
),
]
strategy = AsyncMock()
from shared.schemas.kevin import KevinDecisionType
strategy.evaluate_mention.return_value = MagicMock(
decision=KevinDecisionType.OPEN_LONG,
symbol="NVDA",
target_dollars=Decimal("3000"),
holding_days=10,
effective_conviction=Decimal("0.8"),
rationale="ok",
)
audit_writer = AsyncMock()
broker = AsyncMock()
broker.is_asset_tradable.return_value = True
broker.get_latest_price.return_value = Decimal("123.45")
broker.get_account.return_value = MagicMock(
equity=Decimal("100000"), cash=Decimal("100000")
)
broker.get_positions.return_value = []
bridge = KevinBridge(
config=config,
cursor=cursor,
publisher=publisher,
aggregator=aggregator,
strategy=strategy,
audit_writer=audit_writer,
broker=broker,
)
await bridge.process_one_pass()
publisher.publish.assert_awaited_once()
published_signal = publisher.publish.call_args[0][0]
assert published_signal.current_price == Decimal("123.45")
async def test_bridge_advances_cursor_only_after_publish(): async def test_bridge_advances_cursor_only_after_publish():
"""Race-condition guard: cursor must NOT advance if publish raises.""" """Race-condition guard: cursor must NOT advance if publish raises."""
config = MagicMock( config = MagicMock(

View file

@ -7,6 +7,7 @@ position sizing) and the end-to-end executor flow with a mocked broker.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -64,6 +65,31 @@ def _make_signal(
) )
def _make_kevin_signal(
ticker: str = "NVDA",
direction: SignalDirection = SignalDirection.LONG,
strength: float = 0.8,
current_price: Decimal | None = Decimal("100"),
target_dollars: Decimal | None = Decimal("2000"),
stop_loss_pct: Decimal | None = Decimal("0.08"),
take_profit_pct: Decimal | None = Decimal("0.20"),
) -> TradeSignal:
"""A Kevin-style signal: price on the new ``current_price`` field,
pre-computed ``target_dollars``, and stop/take percentages but NO
``sentiment_context``."""
return TradeSignal(
ticker=ticker,
direction=direction,
strength=strength,
strategy_sources=["kevin:buy:0.8"],
current_price=current_price,
target_dollars=target_dollars,
stop_loss_pct=stop_loss_pct,
take_profit_pct=take_profit_pct,
timestamp=datetime.now(timezone.utc),
)
def _make_account(equity: float = 100_000.0) -> AccountInfo: def _make_account(equity: float = 100_000.0) -> AccountInfo:
return AccountInfo( return AccountInfo(
equity=equity, equity=equity,
@ -373,6 +399,77 @@ class TestPositionSizingRespectsMaxPct:
assert qty == 0 assert qty == 0
# ---------------------------------------------------------------------------
# Position sizing — Kevin path (target_dollars + current_price field)
# ---------------------------------------------------------------------------
class TestPositionSizingHonorsTargetDollars:
"""When the signal carries ``target_dollars`` (Kevin's pre-computed
sizing), use it directly and ignore signal strength."""
def test_target_dollars_drives_qty(self):
config = _make_config(max_position_pct=0.05)
broker = _mock_broker()
rm = RiskManager(config, broker)
signal = _make_kevin_signal(
target_dollars=Decimal("2000"), current_price=Decimal("100")
)
account = _make_account(equity=100_000)
qty = rm.calculate_position_size(signal, account)
# 2000 / 100 = 20 shares — NOT scaled by strength/max_position_pct.
assert qty == 20
def test_target_dollars_ignores_strength(self):
"""qty must be identical regardless of strength when target_dollars is set."""
config = _make_config(max_position_pct=0.05)
broker = _mock_broker()
rm = RiskManager(config, broker)
account = _make_account(equity=100_000)
low = rm.calculate_position_size(
_make_kevin_signal(
strength=0.1, target_dollars=Decimal("2000"), current_price=Decimal("100")
),
account,
)
high = rm.calculate_position_size(
_make_kevin_signal(
strength=1.0, target_dollars=Decimal("2000"), current_price=Decimal("100")
),
account,
)
assert low == high == 20
class TestPositionSizingReadsCurrentPriceField:
"""Sizing must read the new ``current_price`` field when
``sentiment_context`` is absent (the legacy price source)."""
def test_current_price_field_used_when_no_sentiment_context(self):
config = _make_config(max_position_pct=0.05)
broker = _mock_broker()
rm = RiskManager(config, broker)
# Legacy fixed-fractional path (no target_dollars) but price lives
# on the new field, not in sentiment_context.
signal = _make_kevin_signal(
strength=1.0,
current_price=Decimal("100"),
target_dollars=None,
stop_loss_pct=None,
take_profit_pct=None,
)
assert signal.sentiment_context is None
account = _make_account(equity=100_000)
qty = rm.calculate_position_size(signal, account)
# 100k * 0.05 * 1.0 = 5000 / 100 = 50 shares.
assert qty == 50
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Executor flow — approved signal # Executor flow — approved signal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -411,6 +508,81 @@ class TestExecutorFlowApproved:
counters["trades_executed"].add.assert_called_once_with(1) counters["trades_executed"].add.assert_called_once_with(1)
# ---------------------------------------------------------------------------
# Executor flow — bracket vs simple order construction
# ---------------------------------------------------------------------------
class TestExecutorBracketOrders:
"""LONG entries with both stop/take pcts become BRACKET orders;
EXIT signals (or signals missing a pct) stay SIMPLE."""
@pytest.mark.asyncio
async def test_long_entry_with_pcts_builds_bracket(self):
config = _make_config()
broker = _mock_broker(positions=[], account=_make_account(100_000))
publisher = AsyncMock()
publisher.publish = AsyncMock(return_value=b"1-0")
counters = {
"trades_executed": MagicMock(),
"rejections": MagicMock(),
"fill_latency": MagicMock(),
}
signal = _make_kevin_signal(
ticker="NVDA",
direction=SignalDirection.LONG,
current_price=Decimal("100"),
target_dollars=Decimal("2000"),
stop_loss_pct=Decimal("0.08"),
take_profit_pct=Decimal("0.20"),
)
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
await process_signal(signal, RiskManager(config, broker), broker, publisher, counters)
broker.submit_order.assert_called_once()
order_arg = broker.submit_order.call_args[0][0]
assert order_arg.order_class == "bracket"
assert order_arg.side == OrderSide.BUY
# entry=100 → stop=100*(1-0.08)=92.0, take=100*(1+0.20)=120.0
assert order_arg.stop_loss_price == 92.0
assert order_arg.take_profit_price == 120.0
@pytest.mark.asyncio
async def test_exit_signal_with_pcts_stays_simple(self):
"""The bridge stamps stop/take pcts even on EXIT signals; the
direction guard must keep the resulting SELL order SIMPLE."""
config = _make_config()
broker = _mock_broker(positions=[], account=_make_account(100_000))
publisher = AsyncMock()
publisher.publish = AsyncMock(return_value=b"1-0")
counters = {
"trades_executed": MagicMock(),
"rejections": MagicMock(),
"fill_latency": MagicMock(),
}
signal = _make_kevin_signal(
ticker="NVDA",
direction=SignalDirection.EXIT,
current_price=Decimal("100"),
target_dollars=Decimal("2000"),
stop_loss_pct=Decimal("0.08"),
take_profit_pct=Decimal("0.20"),
)
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
await process_signal(signal, RiskManager(config, broker), broker, publisher, counters)
broker.submit_order.assert_called_once()
order_arg = broker.submit_order.call_args[0][0]
assert order_arg.order_class == "simple"
assert order_arg.side == OrderSide.SELL
assert order_arg.take_profit_price is None
assert order_arg.stop_loss_price is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Executor flow — rejected signal # Executor flow — rejected signal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------