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

@ -8,7 +8,6 @@ from __future__ import annotations
import logging
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from redis.asyncio import Redis
@ -16,7 +15,7 @@ from redis.asyncio import Redis
from services.trade_executor.config import TradeExecutorConfig
from shared.broker.base import BaseBroker
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__)
@ -185,14 +184,22 @@ class RiskManager:
) -> float:
"""Calculate the number of shares to buy/sell.
Uses fixed-fractional sizing: ``equity * max_position_pct``
gives the maximum dollar value per position, then scales by
signal strength.
Two sizing modes:
* **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
----------
signal:
The trade signal (includes current price via strength).
The trade signal carrying the price and (optionally) sizing.
account:
Current account info (equity, buying power).
@ -201,26 +208,34 @@ class RiskManager:
float
Number of shares (whole shares).
"""
if signal.strength <= 0 or account.equity <= 0:
if account.equity <= 0:
return 0.0
position_value = account.equity * self.config.max_position_pct
position_value *= signal.strength
# 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:
price = self._resolve_price(signal)
if price <= 0:
logger.warning("No current price for %s, cannot size position", signal.ticker)
return 0.0
qty = position_value / current_price
return max(int(qty), 0)
if signal.target_dollars is not None and signal.target_dollars > 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