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:
parent
7d1d4464c9
commit
14407d37dc
6 changed files with 302 additions and 26 deletions
|
|
@ -129,6 +129,7 @@ class KevinBridge:
|
|||
target_dollars=decision.target_dollars,
|
||||
stop_loss_pct=Decimal(str(self.config.kevin_stop_loss_pct)),
|
||||
take_profit_pct=Decimal(str(self.config.kevin_take_profit_pct)),
|
||||
current_price=current_price,
|
||||
)
|
||||
|
||||
# Persist to signals table FIRST so downstream FK
|
||||
|
|
|
|||
|
|
@ -56,6 +56,38 @@ async def _next_market_open(broker: AlpacaBroker) -> datetime:
|
|||
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(
|
||||
signal: TradeSignal,
|
||||
risk_manager: RiskManager,
|
||||
|
|
@ -133,11 +165,7 @@ async def process_signal(
|
|||
|
||||
# --- Step 3: create order ---
|
||||
side = OrderSide.BUY if signal.direction == SignalDirection.LONG else OrderSide.SELL
|
||||
order_request = OrderRequest(
|
||||
ticker=signal.ticker,
|
||||
side=side,
|
||||
qty=float(qty),
|
||||
)
|
||||
order_request = _build_order_request(signal, side, qty, risk_manager)
|
||||
|
||||
# --- Step 4: submit order ---
|
||||
start = time.monotonic()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue