From 4d40536da7ce330c110dae16d7bc0e4153b457a6 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 24 May 2026 00:49:52 +0000 Subject: [PATCH] feat(kevin): SA models for bridge audit + backtest persistence 3 tables (kevin_signal_bridge_state, kevin_backtest_runs, kevin_backtest_trades) all UUID-keyed for consistency with Trade/Position. KEVIN_STRATEGY_UUID constant pinned for FK joins from Trade.strategy_id. --- shared/constants/__init__.py | 0 shared/constants/kevin.py | 11 ++ shared/models/__init__.py | 15 ++ shared/models/meet_kevin_trading.py | 171 ++++++++++++++++++ tests/conftest.py | 49 +++++ tests/shared/models/__init__.py | 0 .../shared/models/test_meet_kevin_trading.py | 121 +++++++++++++ 7 files changed, 367 insertions(+) create mode 100644 shared/constants/__init__.py create mode 100644 shared/constants/kevin.py create mode 100644 shared/models/meet_kevin_trading.py create mode 100644 tests/conftest.py create mode 100644 tests/shared/models/__init__.py create mode 100644 tests/shared/models/test_meet_kevin_trading.py diff --git a/shared/constants/__init__.py b/shared/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shared/constants/kevin.py b/shared/constants/kevin.py new file mode 100644 index 0000000..a0c314e --- /dev/null +++ b/shared/constants/kevin.py @@ -0,0 +1,11 @@ +"""Constants shared across the Kevin strategy code. + +KEVIN_STRATEGY_UUID is the fixed UUID for the seeded `strategies.kevin` row. +Pinning the UUID at code level lets the live bridge, backtest, API filters, +and dashboard all reference the same identity without a runtime lookup. +""" + +import uuid + +KEVIN_STRATEGY_UUID: uuid.UUID = uuid.UUID("4b8d1c2a-5e7f-4d3b-9a1c-6f8b2e4d7a90") +KEVIN_STRATEGY_NAME: str = "kevin" diff --git a/shared/models/__init__.py b/shared/models/__init__.py index 5a3cbfe..e8259c4 100644 --- a/shared/models/__init__.py +++ b/shared/models/__init__.py @@ -23,6 +23,14 @@ from shared.models.meet_kevin import ( KevinAnalysis, KevinStockMention, ) +from shared.models.meet_kevin_trading import ( + BridgeStatus, + KevinBacktestRun, + KevinBacktestRunStatus, + KevinBacktestTrade, + KevinSignalBridgeState, + TriggerSource, +) __all__ = [ "Base", @@ -57,4 +65,11 @@ __all__ = [ "KevinTranscript", "KevinAnalysis", "KevinStockMention", + # Meet Kevin trading + "BridgeStatus", + "KevinBacktestRun", + "KevinBacktestRunStatus", + "KevinBacktestTrade", + "KevinSignalBridgeState", + "TriggerSource", ] diff --git a/shared/models/meet_kevin_trading.py b/shared/models/meet_kevin_trading.py new file mode 100644 index 0000000..3fecd14 --- /dev/null +++ b/shared/models/meet_kevin_trading.py @@ -0,0 +1,171 @@ +"""Tables added in v2 for paper-trading + backtest persistence. + + - KevinSignalBridgeState audit trail: one row per processed mention + - KevinBacktestRun one row per backtest invocation + - KevinBacktestTrade per-trade detail within a run +""" + +from __future__ import annotations + +import enum +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import ( + BigInteger, + DateTime, + Enum as SAEnum, + ForeignKey, + Index, + Integer, + Numeric, + String, + Text, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shared.models.base import Base, TimestampMixin + + +class BridgeStatus(str, enum.Enum): + EMITTED = "emitted" + SKIPPED_NON_TRADABLE = "skipped_non_tradable" + SKIPPED_BLOCKLIST = "skipped_blocklist" + SKIPPED_CAPS = "skipped_caps" + DEFERRED = "deferred" + BROKER_REJECTED = "broker_rejected" + DRY_RUN = "dry_run" # kill-switch off + + +class KevinBacktestRunStatus(str, enum.Enum): + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class TriggerSource(str, enum.Enum): + MANUAL = "manual" + SCHEDULED = "scheduled" + + +class KevinSignalBridgeState(TimestampMixin, Base): + __tablename__ = "kevin_signal_bridge_state" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + mention_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("kevin_stock_mentions.id"), + nullable=False, + unique=True, + ) + bridge_status: Mapped[BridgeStatus] = mapped_column( + SAEnum( + BridgeStatus, + name="kevin_bridge_status", + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + ) + signal_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("signals.id"), nullable=True + ) + trade_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("trades.id"), nullable=True + ) + effective_conviction: Mapped[Decimal | None] = mapped_column( + Numeric(4, 3), nullable=True + ) + decided_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_bridge_state_status_decided", "bridge_status", "decided_at"), + ) + + +class KevinBacktestRun(TimestampMixin, Base): + __tablename__ = "kevin_backtest_runs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + run_uuid: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), unique=True, nullable=False, default=uuid.uuid4 + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + finished_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + status: Mapped[KevinBacktestRunStatus] = mapped_column( + SAEnum( + KevinBacktestRunStatus, + name="kevin_backtest_run_status", + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + ) + trigger_source: Mapped[TriggerSource] = mapped_column( + SAEnum( + TriggerSource, + name="kevin_backtest_trigger_source", + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + default=TriggerSource.MANUAL, + ) + params_json: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + metrics_json: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + equity_curve_json: Mapped[list[Any] | None] = mapped_column(JSONB, nullable=True) + benchmark_curve_json: Mapped[list[Any] | None] = mapped_column(JSONB, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + trades: Mapped[list["KevinBacktestTrade"]] = relationship( + back_populates="run", cascade="all, delete-orphan" + ) + + __table_args__ = ( + Index("ix_backtest_runs_started", "started_at"), + Index("ix_backtest_runs_status_started", "status", "started_at"), + ) + + +class KevinBacktestTrade(TimestampMixin, Base): + __tablename__ = "kevin_backtest_trades" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + run_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("kevin_backtest_runs.id", ondelete="CASCADE"), + nullable=False, + ) + symbol: Mapped[str] = mapped_column(String(16), nullable=False) + source_mention_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("kevin_stock_mentions.id"), nullable=True + ) + entry_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + entry_price: Mapped[Decimal] = mapped_column(Numeric(12, 4), nullable=False) + exit_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + exit_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 4)) + qty: Mapped[Decimal] = mapped_column(Numeric(14, 4), nullable=False) + pnl_usd: Mapped[Decimal | None] = mapped_column(Numeric(14, 4)) + pnl_pct: Mapped[Decimal | None] = mapped_column(Numeric(8, 4)) + holding_days_actual: Mapped[int | None] = mapped_column(Integer) + + run: Mapped["KevinBacktestRun"] = relationship(back_populates="trades") + + __table_args__ = ( + Index("ix_backtest_trades_run_symbol", "run_id", "symbol"), + Index("ix_backtest_trades_run_entry", "run_id", "entry_at"), + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5189fd8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +"""Shared pytest fixtures for the trading-bot test suite. + +Provides: +- `db_session`: an `AsyncSession` against the dev Postgres (transaction-scoped, + rolled back between tests). +""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator + +import pytest_asyncio +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + + +def _dev_db_url() -> str: + return os.environ.get( + "TRADING_TEST_DATABASE_URL", + "postgresql+asyncpg://trading:trading@localhost:5432/trading_dev", + ) + + +@pytest_asyncio.fixture +async def db_session() -> AsyncIterator[AsyncSession]: + """Per-test session wrapped in a transaction that is rolled back at teardown.""" + engine = create_async_engine(_dev_db_url(), echo=False) + try: + connection = await engine.connect() + transaction = await connection.begin() + session_factory = async_sessionmaker( + bind=connection, + class_=AsyncSession, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + ) + session = session_factory() + try: + yield session + finally: + await session.close() + await transaction.rollback() + await connection.close() + finally: + await engine.dispose() diff --git a/tests/shared/models/__init__.py b/tests/shared/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/shared/models/test_meet_kevin_trading.py b/tests/shared/models/test_meet_kevin_trading.py new file mode 100644 index 0000000..0f18854 --- /dev/null +++ b/tests/shared/models/test_meet_kevin_trading.py @@ -0,0 +1,121 @@ +"""Tests for the kevin trading SA models (audit + backtest).""" + +import uuid +from datetime import datetime, timezone +from decimal import Decimal + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.models.meet_kevin import ( + KevinAnalysis, + KevinChannel, + KevinMarketOutlook, + KevinStockMention, + KevinTickerAction, + KevinTimeHorizon, + KevinVideo, + KevinVideoStatus, +) +from shared.models.meet_kevin_trading import ( + BridgeStatus, + KevinBacktestRun, + KevinBacktestRunStatus, + KevinBacktestTrade, + KevinSignalBridgeState, + TriggerSource, +) + + +async def _seed_mention(db_session: AsyncSession) -> KevinStockMention: + channel = KevinChannel(youtube_channel_id="UCtest", title="t") + db_session.add(channel) + await db_session.flush() + video = KevinVideo( + channel_id=channel.id, + youtube_video_id="v1", + title="t", + published_at=datetime.now(timezone.utc), + status=KevinVideoStatus.ANALYZED, + ) + db_session.add(video) + await db_session.flush() + analysis = KevinAnalysis( + video_id=video.id, + model="claude-sonnet", + prompt_version="v1", + market_outlook_direction=KevinMarketOutlook.NEUTRAL, + market_outlook_reasoning="x", + summary="x", + prompt_tokens=10, + completion_tokens=10, + cost_usd=Decimal("0.01"), + ) + db_session.add(analysis) + await db_session.flush() + mention = KevinStockMention( + video_id=video.id, + analysis_id=analysis.id, + symbol="NVDA", + action=KevinTickerAction.BUY, + conviction=Decimal("0.7"), + time_horizon=KevinTimeHorizon.WEEKS, + rationale_quote="x", + ) + db_session.add(mention) + await db_session.flush() + return mention + + +@pytest.mark.asyncio +async def test_bridge_state_inserts_and_loads(db_session: AsyncSession): + mention = await _seed_mention(db_session) + + state = KevinSignalBridgeState( + mention_id=mention.id, + bridge_status=BridgeStatus.EMITTED, + effective_conviction=Decimal("0.75"), + notes="boosted +0.05", + ) + db_session.add(state) + await db_session.flush() + + row = ( + await db_session.execute( + select(KevinSignalBridgeState).where( + KevinSignalBridgeState.mention_id == mention.id + ) + ) + ).scalar_one() + assert row.bridge_status == BridgeStatus.EMITTED + assert row.effective_conviction == Decimal("0.750") + + +@pytest.mark.asyncio +async def test_backtest_run_and_trade_cascade(db_session: AsyncSession): + run = KevinBacktestRun( + run_uuid=uuid.uuid4(), + status=KevinBacktestRunStatus.COMPLETED, + trigger_source=TriggerSource.MANUAL, + params_json={"holding_days": 10}, + metrics_json={"total_return_pct": 3.4}, + ) + db_session.add(run) + await db_session.flush() + + trade = KevinBacktestTrade( + run_id=run.id, + symbol="NVDA", + entry_at=datetime.now(timezone.utc), + entry_price=Decimal("180.00"), + qty=Decimal("11"), + ) + db_session.add(trade) + await db_session.flush() + + # Cascade: delete run -> trade gone + await db_session.delete(run) + await db_session.flush() + trades = (await db_session.execute(select(KevinBacktestTrade))).scalars().all() + assert trades == []