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