feat(kevin): correct exits, realized P&L, wire exit scanner
- executor: EXIT/SELL signals close the FULL held broker position (not a target_dollars-sized fresh order) and skip when flat - executor: book realized P&L on the closing trade ((fill - avg_entry)*qty) so the dashboard P&L + win-rate populate; entries leave pnl=None - exit scanner: wired into the bridge run loop on kevin_bridge_exit_scan_cron (daily ET gate; croniter intentionally not a dependency) plus an offsetting-SELL guard so it only emits exits for currently-held tickers [ci skip] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a8b0d33bd1
commit
52b3c76482
7 changed files with 587 additions and 15 deletions
|
|
@ -32,6 +32,7 @@ from shared.schemas.trading import (
|
|||
OrderRequest,
|
||||
OrderSide,
|
||||
OrderStatus,
|
||||
PositionInfo,
|
||||
SignalDirection,
|
||||
TradeExecution,
|
||||
TradeSignal,
|
||||
|
|
@ -56,6 +57,19 @@ async def _next_market_open(broker: AlpacaBroker) -> datetime:
|
|||
return next_open.astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def _held_position(broker: AlpacaBroker, ticker: str) -> PositionInfo | None:
|
||||
"""Return the currently-held position for *ticker*, or ``None`` if flat.
|
||||
|
||||
Used to size EXIT orders off the live broker position rather than the
|
||||
signal's target_dollars.
|
||||
"""
|
||||
positions = await broker.get_positions()
|
||||
for pos in positions:
|
||||
if pos.ticker == ticker and pos.qty != 0:
|
||||
return pos
|
||||
return None
|
||||
|
||||
|
||||
def _build_order_request(
|
||||
signal: TradeSignal,
|
||||
side: OrderSide,
|
||||
|
|
@ -156,15 +170,32 @@ async def process_signal(
|
|||
return
|
||||
|
||||
# --- Step 2: calculate position size ---
|
||||
account = await broker.get_account()
|
||||
qty = risk_manager.calculate_position_size(signal, account)
|
||||
if qty <= 0:
|
||||
logger.info("Position size is zero for %s — skipping", signal.ticker)
|
||||
counters["rejections"].add(1, {"reason": "zero_position_size"})
|
||||
return
|
||||
# Entries (LONG) size from target_dollars/strength via the risk manager.
|
||||
# Exits (EXIT/SELL) close the FULL currently-held broker position — a
|
||||
# Kevin EXIT carries target_dollars, so sizing it via the risk manager
|
||||
# would open/size a fresh position instead of flattening the existing one.
|
||||
side = OrderSide.BUY if signal.direction == SignalDirection.LONG else OrderSide.SELL
|
||||
exit_avg_entry: float | None = None
|
||||
if signal.direction == SignalDirection.LONG:
|
||||
account = await broker.get_account()
|
||||
qty = risk_manager.calculate_position_size(signal, account)
|
||||
if qty <= 0:
|
||||
logger.info("Position size is zero for %s — skipping", signal.ticker)
|
||||
counters["rejections"].add(1, {"reason": "zero_position_size"})
|
||||
return
|
||||
else:
|
||||
held = await _held_position(broker, signal.ticker)
|
||||
if held is None:
|
||||
logger.info(
|
||||
"EXIT for %s but no position held — skipping (no order)",
|
||||
signal.ticker,
|
||||
)
|
||||
counters["rejections"].add(1, {"reason": "no_position_to_close"})
|
||||
return
|
||||
qty = abs(held.qty)
|
||||
exit_avg_entry = held.avg_entry
|
||||
|
||||
# --- Step 3: create order ---
|
||||
side = OrderSide.BUY if signal.direction == SignalDirection.LONG else OrderSide.SELL
|
||||
order_request = _build_order_request(signal, side, qty, risk_manager)
|
||||
|
||||
# --- Step 4: submit order ---
|
||||
|
|
@ -188,6 +219,18 @@ async def process_signal(
|
|||
timestamp=result.timestamp,
|
||||
)
|
||||
|
||||
# --- Step 5b: realized P&L on close ---
|
||||
# The closing (EXIT) trade carries the round-trip P&L; entry trades leave
|
||||
# pnl=None. avg_entry is captured from the held position BEFORE the sell.
|
||||
# Only book P&L on a fill — a rejected/pending sell has no realized result.
|
||||
realized_pnl: float | None = None
|
||||
if (
|
||||
exit_avg_entry is not None
|
||||
and result.status == OrderStatus.FILLED
|
||||
and result.filled_price is not None
|
||||
):
|
||||
realized_pnl = (result.filled_price - exit_avg_entry) * result.qty
|
||||
|
||||
# --- Step 6: persist trade to DB ---
|
||||
if db_session_factory is not None:
|
||||
try:
|
||||
|
|
@ -212,6 +255,7 @@ async def process_signal(
|
|||
signal_id=signal.signal_id,
|
||||
strategy_id=signal.strategy_id,
|
||||
status=status_map.get(result.status, TradeStatusModel.PENDING),
|
||||
pnl=realized_pnl,
|
||||
)
|
||||
session.add(db_trade)
|
||||
await session.commit()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue