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
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
|
@ -17,6 +18,7 @@ from services.trade_executor.config import TradeExecutorConfig
|
|||
from services.trade_executor.main import process_signal
|
||||
from services.trade_executor.risk_manager import RiskManager
|
||||
from shared.constants.kevin import KEVIN_STRATEGY_UUID
|
||||
from shared.models.trading import TradeSide as TradeSideModel
|
||||
from shared.schemas.trading import (
|
||||
AccountInfo,
|
||||
OrderResult,
|
||||
|
|
@ -557,7 +559,9 @@ class TestExecutorBracketOrders:
|
|||
"""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))
|
||||
# EXIT now requires a held position to size from.
|
||||
held = _make_position(ticker="NVDA", market_value=2000.0)
|
||||
broker = _mock_broker(positions=[held], account=_make_account(100_000))
|
||||
publisher = AsyncMock()
|
||||
publisher.publish = AsyncMock(return_value=b"1-0")
|
||||
counters = {
|
||||
|
|
@ -586,6 +590,235 @@ class TestExecutorBracketOrders:
|
|||
assert order_arg.stop_loss_price is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor flow — EXIT sizing from the held broker position
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _held_position(ticker: str, qty: float, avg_entry: float) -> PositionInfo:
|
||||
return PositionInfo(
|
||||
ticker=ticker,
|
||||
qty=qty,
|
||||
avg_entry=avg_entry,
|
||||
current_price=avg_entry,
|
||||
unrealized_pnl=0.0,
|
||||
market_value=qty * avg_entry,
|
||||
)
|
||||
|
||||
|
||||
def _exit_filled_broker(
|
||||
positions: list[PositionInfo], fill_price: float, fill_qty: float
|
||||
):
|
||||
"""Broker whose submit_order returns a FILLED SELL at fill_price/fill_qty."""
|
||||
broker = AsyncMock()
|
||||
broker.get_positions = AsyncMock(return_value=positions)
|
||||
broker.get_account = AsyncMock(return_value=_make_account(100_000))
|
||||
broker.submit_order = AsyncMock(
|
||||
return_value=OrderResult(
|
||||
order_id="ord-exit",
|
||||
ticker=positions[0].ticker if positions else "NVDA",
|
||||
side=OrderSide.SELL,
|
||||
qty=fill_qty,
|
||||
filled_price=fill_price,
|
||||
status=OrderStatus.FILLED,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
return broker
|
||||
|
||||
|
||||
class TestExecutorExitSizing:
|
||||
"""EXIT signals must be sized from the currently-held broker position,
|
||||
NOT from the signal's target_dollars (which would open/size a fresh
|
||||
position)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_sells_full_held_qty(self):
|
||||
"""A Kevin EXIT carrying target_dollars=$2000 (=20 sh @ $100) on a
|
||||
position of 37 held shares must SELL 37 — the full held qty."""
|
||||
config = _make_config()
|
||||
held = _held_position("NVDA", qty=37.0, avg_entry=90.0)
|
||||
broker = _exit_filled_broker([held], fill_price=110.0, fill_qty=37.0)
|
||||
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"),
|
||||
)
|
||||
|
||||
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.side == OrderSide.SELL
|
||||
assert order_arg.qty == 37.0 # held qty, NOT 20 (=target_dollars/price)
|
||||
assert order_arg.order_class == "simple"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_with_no_held_position_submits_nothing(self):
|
||||
"""EXIT for a ticker with no held position → no order, skip logged,
|
||||
rejection counted (never a zero/garbage sell)."""
|
||||
config = _make_config()
|
||||
# Holds a DIFFERENT ticker — nothing for NVDA.
|
||||
broker = _exit_filled_broker(
|
||||
[_held_position("AAPL", qty=10.0, avg_entry=150.0)],
|
||||
fill_price=110.0,
|
||||
fill_qty=0.0,
|
||||
)
|
||||
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"),
|
||||
)
|
||||
|
||||
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
|
||||
await process_signal(
|
||||
signal, RiskManager(config, broker), broker, publisher, counters
|
||||
)
|
||||
|
||||
broker.submit_order.assert_not_called()
|
||||
publisher.publish.assert_not_called()
|
||||
counters["rejections"].add.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_sizing_path_unchanged(self):
|
||||
"""LONG entries keep the risk_manager.calculate_position_size path —
|
||||
target_dollars=$2000 @ $100 → 20 shares (not driven by held qty)."""
|
||||
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"),
|
||||
)
|
||||
|
||||
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.side == OrderSide.BUY
|
||||
assert order_arg.qty == 20.0 # target_dollars / current_price
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor flow — realized P&L on close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExecutorRealizedPnl:
|
||||
"""When an EXIT fill closes a long, the persisted Trade row carries the
|
||||
round-trip realized P&L; ENTRY trades leave pnl=None."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exit_writes_realized_pnl(self):
|
||||
"""SELL 10 @ 110 against avg_entry 90 → pnl = (110-90)*10 = 200."""
|
||||
config = _make_config()
|
||||
held = _held_position("NVDA", qty=10.0, avg_entry=90.0)
|
||||
broker = _exit_filled_broker([held], fill_price=110.0, fill_qty=10.0)
|
||||
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"),
|
||||
)
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
db_factory = _make_mock_db_session_factory(mock_session)
|
||||
|
||||
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
|
||||
await process_signal(
|
||||
signal,
|
||||
RiskManager(config, broker),
|
||||
broker,
|
||||
publisher,
|
||||
counters,
|
||||
db_factory,
|
||||
)
|
||||
|
||||
trade_obj = mock_session.add.call_args[0][0]
|
||||
assert trade_obj.side == TradeSideModel.SELL
|
||||
assert trade_obj.pnl == 200.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_trade_has_null_pnl(self):
|
||||
"""An ENTRY (LONG) trade is persisted with pnl=None."""
|
||||
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"),
|
||||
)
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
db_factory = _make_mock_db_session_factory(mock_session)
|
||||
|
||||
with patch.object(RiskManager, "check_risk", return_value=(True, "approved")):
|
||||
await process_signal(
|
||||
signal,
|
||||
RiskManager(config, broker),
|
||||
broker,
|
||||
publisher,
|
||||
counters,
|
||||
db_factory,
|
||||
)
|
||||
|
||||
trade_obj = mock_session.add.call_args[0][0]
|
||||
assert trade_obj.side == TradeSideModel.BUY
|
||||
assert trade_obj.pnl is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor flow — rejected signal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -734,8 +967,6 @@ class TestExecutorDBPersistence:
|
|||
"""signal_id from TradeSignal should appear in the published TradeExecution."""
|
||||
signal = _make_signal(ticker="AAPL", strength=0.8, current_price=150.0)
|
||||
assert signal.signal_id is not None
|
||||
# Verify signal_id is a UUID
|
||||
from uuid import UUID
|
||||
assert isinstance(signal.signal_id, UUID)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue