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:
Viktor Barzin 2026-06-04 22:13:30 +00:00
parent a8b0d33bd1
commit 52b3c76482
7 changed files with 587 additions and 15 deletions

View file

@ -7,8 +7,6 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
pytestmark = pytest.mark.integration
from services.kevin_signal_bridge.exit_scanner import ExitScanner
from shared.constants.kevin import KEVIN_STRATEGY_UUID
from shared.models.meet_kevin import (
@ -23,6 +21,9 @@ from shared.models.meet_kevin import (
)
from shared.models.meet_kevin_trading import BridgeStatus, KevinSignalBridgeState
from shared.models.trading import Trade, TradeSide, TradeStatus
from shared.schemas.trading import PositionInfo
pytestmark = pytest.mark.integration
def _factory(session: AsyncSession):
@ -39,6 +40,25 @@ def _factory(session: AsyncSession):
return factory
def _broker_holding(*tickers: str):
"""Mock broker reporting the given tickers as currently held."""
broker = AsyncMock()
broker.get_positions = AsyncMock(
return_value=[
PositionInfo(
ticker=t,
qty=10.0,
avg_entry=100.0,
current_price=110.0,
unrealized_pnl=100.0,
market_value=1100.0,
)
for t in tickers
]
)
return broker
async def _seed_video_and_mention(session: AsyncSession) -> int:
channel = KevinChannel(youtube_channel_id="UCex", title="t")
session.add(channel)
@ -111,7 +131,10 @@ async def test_exit_scanner_emits_on_elapsed_hold(db_session: AsyncSession):
config = MagicMock(kevin_hold_days={"unspecified": 10})
scanner = ExitScanner(
session_factory=_factory(db_session), publisher=publisher, config=config
session_factory=_factory(db_session),
publisher=publisher,
config=config,
broker=_broker_holding("NVDA"),
)
emitted = await scanner.scan_and_emit_exits()
assert emitted == 1
@ -149,7 +172,10 @@ async def test_exit_scanner_does_not_emit_when_hold_not_elapsed(
config = MagicMock(kevin_hold_days={"unspecified": 10})
scanner = ExitScanner(
session_factory=_factory(db_session), publisher=publisher, config=config
session_factory=_factory(db_session),
publisher=publisher,
config=config,
broker=_broker_holding("NVDA"),
)
emitted = await scanner.scan_and_emit_exits()
assert emitted == 0

View file

@ -0,0 +1,135 @@
"""Unit tests for ExitScanner's offsetting-SELL (held-at-broker) guard and
the no-op-with-zero-positions behaviour.
These are pure unit tests (no DB fixture) the session factory and broker
are mocked so they run in the default ``not integration`` suite.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from services.kevin_signal_bridge.exit_scanner import ExitScanner
from shared.constants.kevin import KEVIN_STRATEGY_UUID
from shared.schemas.trading import PositionInfo
def _trade(ticker: str) -> SimpleNamespace:
return SimpleNamespace(
id=uuid.uuid4(),
ticker=ticker,
strategy_id=KEVIN_STRATEGY_UUID,
)
def _audit(days_ago: int, hold_days: int = 10) -> SimpleNamespace:
return SimpleNamespace(
decided_at=datetime.now(timezone.utc) - timedelta(days=days_ago),
notes=f"BUY hold={hold_days}d",
)
def _session_factory(trades: list, audit: SimpleNamespace | None):
"""Fake session factory whose ``execute`` returns *trades* on the first
call (the open-trades query) and *audit* on every subsequent per-trade
audit lookup."""
class _Result:
def __init__(self, payload):
self._payload = payload
def scalars(self):
return self
def all(self):
return self._payload
def one_or_none(self):
return self._payload
class _Session:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def execute(self, stmt):
# Route by which table the SELECT targets — the open-trades query
# hits `trades`, the per-trade audit lookup hits the bridge-state
# table. Robust against separate `async with` blocks.
if "trades" in str(stmt).lower():
return _Result(trades)
return _Result(audit)
def factory():
return _Session()
return factory
def _position(ticker: str, qty: float = 10.0) -> PositionInfo:
return PositionInfo(
ticker=ticker,
qty=qty,
avg_entry=100.0,
current_price=110.0,
unrealized_pnl=100.0,
market_value=qty * 110.0,
)
def _broker(positions: list[PositionInfo]):
broker = AsyncMock()
broker.get_positions = AsyncMock(return_value=positions)
return broker
@pytest.mark.asyncio
async def test_emits_exit_for_held_ticker_past_hold():
"""Hold elapsed AND still held at broker → emit one EXIT."""
scanner = ExitScanner(
session_factory=_session_factory([_trade("NVDA")], _audit(days_ago=30)),
publisher=AsyncMock(),
config=MagicMock(kevin_hold_days={"unspecified": 10}),
broker=_broker([_position("NVDA")]),
)
emitted = await scanner.scan_and_emit_exits()
assert emitted == 1
scanner.publisher.publish.assert_awaited_once()
@pytest.mark.asyncio
async def test_no_emit_when_ticker_no_longer_held():
"""Offsetting guard: hold elapsed but the position is already closed
(broker holds nothing for that ticker) emit nothing."""
scanner = ExitScanner(
session_factory=_session_factory([_trade("NVDA")], _audit(days_ago=30)),
publisher=AsyncMock(),
config=MagicMock(kevin_hold_days={"unspecified": 10}),
broker=_broker([]), # nothing held
)
emitted = await scanner.scan_and_emit_exits()
assert emitted == 0
scanner.publisher.publish.assert_not_called()
@pytest.mark.asyncio
async def test_noop_with_zero_open_positions():
"""Zero positions at the broker → safe no-op regardless of open trades."""
scanner = ExitScanner(
session_factory=_session_factory(
[_trade("NVDA"), _trade("AAPL")], _audit(days_ago=30)
),
publisher=AsyncMock(),
config=MagicMock(kevin_hold_days={"unspecified": 10}),
broker=_broker([]),
)
emitted = await scanner.scan_and_emit_exits()
assert emitted == 0
scanner.publisher.publish.assert_not_called()

View file

@ -1,9 +1,13 @@
"""Smoke-level orchestrator tests for the Kevin signal bridge."""
from datetime import date, datetime
from decimal import Decimal
from unittest.mock import AsyncMock, MagicMock
from zoneinfo import ZoneInfo
from services.kevin_signal_bridge.main import KevinBridge
from services.kevin_signal_bridge.main import KevinBridge, should_run_exit_scan
_ET = ZoneInfo("America/New_York")
async def test_bridge_dry_run_writes_audit_does_not_publish():
@ -232,3 +236,44 @@ async def test_bridge_advances_cursor_only_after_publish():
# cursor.advance must NOT have been called for mention 1
cursor.advance.assert_not_called()
# ---------------------------------------------------------------------------
# Exit-scan cron gate (croniter unavailable → simple daily ET-weekday gate)
# ---------------------------------------------------------------------------
_CRON = "35 9 * * 1-5" # 09:35 ET, Mon-Fri
def test_exit_scan_gate_fires_at_or_after_time_on_weekday():
"""Wed 09:36 ET, not yet run today → fire."""
now = datetime(2026, 6, 3, 9, 36, tzinfo=_ET) # Wednesday
assert should_run_exit_scan(_CRON, now, last_run_date=None) is True
def test_exit_scan_gate_does_not_fire_before_time():
"""Wed 09:34 ET (one minute early) → do not fire."""
now = datetime(2026, 6, 3, 9, 34, tzinfo=_ET)
assert should_run_exit_scan(_CRON, now, last_run_date=None) is False
def test_exit_scan_gate_does_not_fire_on_weekend():
"""Saturday 10:00 ET → do not fire (cron DOW is 1-5)."""
now = datetime(2026, 6, 6, 10, 0, tzinfo=_ET) # Saturday
assert should_run_exit_scan(_CRON, now, last_run_date=None) is False
def test_exit_scan_gate_runs_once_per_day():
"""Already ran today → do not fire again the same ET day."""
now = datetime(2026, 6, 3, 11, 0, tzinfo=_ET) # Wednesday, well past 09:35
assert (
should_run_exit_scan(_CRON, now, last_run_date=date(2026, 6, 3)) is False
)
def test_exit_scan_gate_fires_next_day_after_prior_run():
"""Ran yesterday → fire again today once past the cron time."""
now = datetime(2026, 6, 4, 9, 40, tzinfo=_ET) # Thursday
assert (
should_run_exit_scan(_CRON, now, last_run_date=date(2026, 6, 3)) is True
)

View file

@ -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)