diff --git a/docs/plans/2026-05-23-meet-kevin-paper-trading-plan.md b/docs/plans/2026-05-23-meet-kevin-paper-trading-plan.md new file mode 100644 index 0000000..db1feb4 --- /dev/null +++ b/docs/plans/2026-05-23-meet-kevin-paper-trading-plan.md @@ -0,0 +1,3230 @@ +# Meet Kevin v2 — Paper Trading + Backtest + UI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Translate Meet Kevin's per-mention signals into automated Alpaca paper trades, with a backtest engine + tracking UI + live paper-account view. + +**Architecture:** Standalone `KevinStrategy` class (NOT `BaseStrategy` subclass) called by both a new `kevin_signal_bridge` service (live path) and a new mention-driven mini-engine `backtester/kevin_backtest.py` (backtest path). Trades flow `mention → bridge → signals:generated Redis Stream → trade_executor → Alpaca BRACKET orders`. Two new dashboard pages: `/meet-kevin/strategy` (backtest + ticker scorecard) and `/meet-kevin/paper-account` (Kevin-only slice of Portfolio). + +**Tech Stack:** Python 3.12 (FastAPI, SQLAlchemy 2.0, asyncio, Pydantic v2), Postgres (CNPG), Redis Streams, Alpaca paper API, React 19 + TanStack Query + lightweight-charts, Alembic, pytest, K8s + Terraform. + +**Spec:** [`docs/plans/2026-05-23-meet-kevin-paper-trading-design.md`](2026-05-23-meet-kevin-paper-trading-design.md) (commit `280f807`). Read it first — this plan implements that spec. + +--- + +## File Structure + +### New files + +``` +shared/strategies/kevin.py Standalone KevinStrategy +shared/schemas/kevin.py Pydantic: KevinDecision, KevinDecisionType, KevinAccountState +shared/models/meet_kevin_trading.py SA models: KevinSignalBridgeState, KevinBacktestRun, KevinBacktestTrade +shared/constants/kevin.py KEVIN_STRATEGY_UUID + KEVIN_STRATEGY_NAME constants +alembic/versions/d4e5f6a7b8c9_kevin_v2_trading_tables.py Migration: 3 tables + seed strategies row +backtester/kevin_backtest.py Mention-driven mini-engine (~200 lines) +backtester/kevin_price_loader.py Daily-bar fetcher with market_data cache + Alpaca back-fetch +services/kevin_signal_bridge/__init__.py +services/kevin_signal_bridge/config.py KevinBridgeConfig(BaseConfig) +services/kevin_signal_bridge/aggregator.py Multi-mention windowed aggregation +services/kevin_signal_bridge/blocklist.py Redis blocklist + cooldown helpers +services/kevin_signal_bridge/risk_counters.py Daily counter Redis INCR + reset +services/kevin_signal_bridge/exit_scanner.py Daily 09:35 ET exit-scan task +services/kevin_signal_bridge/main.py Async loop entry-point (~150 lines) +services/api_gateway/routes/meet_kevin_backtest.py Backtest API (run/list/get/latest) +services/api_gateway/routes/meet_kevin_strategy.py Strategy API (tickers/equity-curve/performance + manual close) +services/api_gateway/routes/meet_kevin_paper_account.py Paper-account API (positions/trades/equity for strategy_id=kevin) +dashboard/src/api/meetKevinStrategy.ts Axios client for new routes +dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx Dual-line chart +dashboard/src/components/meetKevin/TickerScorecardTable.tsx Strategy page main table +dashboard/src/components/meetKevin/BacktestRunHistory.tsx Run history table +dashboard/src/pages/meetKevin/Strategy.tsx /meet-kevin/strategy page +dashboard/src/pages/meetKevin/PaperAccount.tsx /meet-kevin/paper-account page (Phase 3) + +tests/shared/strategies/test_kevin_strategy.py +tests/shared/schemas/test_kevin.py +tests/services/kevin_signal_bridge/test_aggregator.py +tests/services/kevin_signal_bridge/test_blocklist.py +tests/services/kevin_signal_bridge/test_risk_counters.py +tests/services/kevin_signal_bridge/test_exit_scanner.py +tests/services/kevin_signal_bridge/test_main.py +tests/backtester/test_kevin_backtest.py +tests/api_gateway/routes/test_meet_kevin_backtest.py +tests/api_gateway/routes/test_meet_kevin_strategy.py +tests/api_gateway/routes/test_meet_kevin_paper_account.py +tests/integration/test_kevin_e2e_paper.py +``` + +### Modified files + +``` +backtester/metrics.py Extend compute_metrics with alpha/beta/winners/losers/best/worst +shared/schemas/trading.py Extend OrderRequest with order_class + take_profit_price + stop_loss_price +shared/broker/alpaca_broker.py Extend _build_order_request with BRACKET branch +services/trade_executor/risk_manager.py Add daily_trade_count, daily_alloc_sum, drawdown_halt, circuit_breaker +services/trade_executor/config.py +9 new env knobs +pyproject.toml +meet_kevin_trading extras (just kevin_signal_bridge deps if any new) +dashboard/src/App.tsx +2 routes +dashboard/src/components/Layout.tsx +2 sidebar entries under Meet Kevin +infra/stacks/trading-bot/main.tf +kevin_signal_bridge container, +re-enable trade_executor container, +env vars + +(Infra repo at /home/wizard/code/infra — separate commit + push) +``` + +--- + +# Phase 1 — Strategy + backtest + bridge (audit-only) + +> **Goal:** All decision logic, all persistence, the new tracking page, and the bridge running with `TRADING_KEVIN_ENABLE_TRADING=false`. No live trades. User can run a backtest from the UI and inspect the bridge's audit rows to see what it WOULD trade. +> +> **End-of-phase checkpoint:** Phase 1 ships as one or more commits. User reviews the dashboard's `/meet-kevin/strategy` page, confirms the ticker scorecard's WOULD-TRADE badges match expectations, and approves flipping to Phase 2. + +--- + +## Task 1: Pydantic schemas — KevinDecision + KevinAccountState + +**Files:** +- Create: `shared/schemas/kevin.py` +- Test: `tests/shared/schemas/test_kevin.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/shared/schemas/test_kevin.py +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from shared.schemas.kevin import ( + KevinAccountState, + KevinDecision, + KevinDecisionType, +) + + +def test_kevin_decision_open_long_requires_target_dollars(): + d = KevinDecision( + decision=KevinDecisionType.OPEN_LONG, + symbol="NVDA", + target_dollars=Decimal("2000"), + holding_days=10, + effective_conviction=Decimal("0.75"), + rationale="conv 0.7 + 1 boost", + ) + assert d.symbol == "NVDA" + assert d.target_dollars == Decimal("2000") + + +def test_kevin_decision_close_long_does_not_require_target_dollars(): + d = KevinDecision( + decision=KevinDecisionType.CLOSE_LONG, + symbol="NVDA", + rationale="kevin reverse", + ) + assert d.target_dollars is None + + +def test_kevin_decision_open_long_rejects_missing_target_dollars(): + with pytest.raises(ValidationError, match="target_dollars"): + KevinDecision( + decision=KevinDecisionType.OPEN_LONG, + symbol="NVDA", + rationale="missing $", + ) + + +def test_kevin_account_state_held_symbols_lookup(): + state = KevinAccountState( + equity_usd=Decimal("100000"), + cash_usd=Decimal("80000"), + held_positions={"NVDA": Decimal("5000"), "INTC": Decimal("2000")}, + blocklisted_symbols={"WMT"}, + daily_trade_count=2, + daily_alloc_usd=Decimal("4000"), + paused=False, + ) + assert state.is_held("NVDA") + assert not state.is_held("AAPL") + assert state.is_blocklisted("WMT") + assert not state.is_blocklisted("NVDA") +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /home/wizard/code/trading-bot && source .venv/bin/activate +pytest tests/shared/schemas/test_kevin.py -v +``` + +Expected: ImportError — module not found. + +- [ ] **Step 3: Implement the schemas** + +```python +# shared/schemas/kevin.py +"""Pydantic schemas for the Kevin strategy. + +Used by KevinStrategy.evaluate_mention as input/output contracts and by +the live signal bridge + backtest engine to talk to the strategy. +""" + +from __future__ import annotations + +from decimal import Decimal +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class KevinDecisionType(str, Enum): + OPEN_LONG = "open_long" # new position or top-up + CLOSE_LONG = "close_long" # exit existing long + NO_OP = "no_op" # filter says skip + + +class KevinDecision(BaseModel): + """A single trade decision emitted by KevinStrategy.evaluate_mention.""" + + model_config = ConfigDict(frozen=True) + + decision: KevinDecisionType + symbol: str = Field(min_length=1, max_length=16) + target_dollars: Decimal | None = None # required for OPEN_LONG + holding_days: int | None = None # required for OPEN_LONG + effective_conviction: Decimal | None = None # post-aggregation, 0-1 + rationale: str # one-line audit string + + @model_validator(mode="after") + def _open_long_requires_target_dollars(self) -> "KevinDecision": + if self.decision == KevinDecisionType.OPEN_LONG: + if self.target_dollars is None: + raise ValueError("OPEN_LONG requires target_dollars") + if self.holding_days is None: + raise ValueError("OPEN_LONG requires holding_days") + if self.target_dollars <= 0: + raise ValueError("target_dollars must be positive") + return self + + +class KevinAccountState(BaseModel): + """Snapshot of the account passed to KevinStrategy.evaluate_mention. + + The bridge populates this from live Alpaca account + Redis counters; the + backtest populates it from the simulated portfolio state. Same shape. + """ + + model_config = ConfigDict(frozen=True) + + equity_usd: Decimal + cash_usd: Decimal + held_positions: dict[str, Decimal] # symbol -> cost-basis $ + blocklisted_symbols: frozenset[str] | set[str] + daily_trade_count: int + daily_alloc_usd: Decimal + paused: bool + + def is_held(self, symbol: str) -> bool: + return symbol in self.held_positions and self.held_positions[symbol] > 0 + + def is_blocklisted(self, symbol: str) -> bool: + return symbol in self.blocklisted_symbols +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pytest tests/shared/schemas/test_kevin.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add shared/schemas/kevin.py tests/shared/schemas/test_kevin.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin): KevinDecision + KevinAccountState schemas + +Standalone schemas (no BaseStrategy coupling) used by both the live +signal bridge and the backtest mini-engine." +git push +``` + +--- + +## Task 2: SQLAlchemy models for the 3 new tables + +**Files:** +- Create: `shared/models/meet_kevin_trading.py` +- Create: `shared/constants/kevin.py` +- Modify: `shared/models/__init__.py` (export new models) +- Test: `tests/shared/models/test_meet_kevin_trading.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/shared/models/test_meet_kevin_trading.py +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 KevinChannel, KevinStockMention, KevinVideo +from shared.models.meet_kevin_trading import ( + BridgeStatus, + KevinBacktestRun, + KevinBacktestRunStatus, + KevinBacktestTrade, + KevinSignalBridgeState, + TriggerSource, +) + + +@pytest.mark.asyncio +async def test_bridge_state_inserts_and_loads(db_session: AsyncSession): + # seed a channel + video + mention to satisfy FK + channel = KevinChannel(youtube_channel_id="UCtest", name="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="analyzed", + ) + db_session.add(video) + await db_session.flush() + mention = KevinStockMention( + video_id=video.id, + symbol="NVDA", + action="buy", + conviction=Decimal("0.7"), + time_horizon="weeks", + rationale_quote="x", + ) + db_session.add(mention) + await db_session.flush() + + 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.commit() + + 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.75") + + +@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.commit() + + # Cascade: delete run -> trade gone + await db_session.delete(run) + await db_session.commit() + trades = (await db_session.execute(select(KevinBacktestTrade))).scalars().all() + assert trades == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/shared/models/test_meet_kevin_trading.py -v +``` + +Expected: ImportError. + +- [ ] **Step 3: Implement the constants** + +```python +# shared/constants/kevin.py +"""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" +``` + +- [ ] **Step 4: Implement the models** + +```python +# shared/models/meet_kevin_trading.py +"""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 sqlalchemy import ( + DateTime, + Enum as SAEnum, + ForeignKey, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from shared.db import Base +from shared.mixins import 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( + Integer, + 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="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="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] = mapped_column(JSONB, nullable=False) + metrics_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + equity_curve_json: Mapped[list | None] = mapped_column(JSONB, nullable=True) + benchmark_curve_json: Mapped[list | 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( + Integer, 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"), + ) +``` + +- [ ] **Step 5: Export models from package** + +Append to `shared/models/__init__.py`: + +```python +from shared.models.meet_kevin_trading import ( + BridgeStatus, + KevinBacktestRun, + KevinBacktestRunStatus, + KevinBacktestTrade, + KevinSignalBridgeState, + TriggerSource, +) +``` + +- [ ] **Step 6: Run tests** + +```bash +pytest tests/shared/models/test_meet_kevin_trading.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 7: Commit** + +```bash +git add shared/models/meet_kevin_trading.py shared/constants/kevin.py shared/models/__init__.py tests/shared/models/test_meet_kevin_trading.py +git -c user.email=me@viktorbarzin.me commit -m "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." +git push +``` + +--- + +## Task 3: Alembic migration + seed `strategies.kevin` row + +**Files:** +- Create: `alembic/versions/d4e5f6a7b8c9_kevin_v2_trading_tables.py` + +- [ ] **Step 1: Generate the migration scaffold** + +```bash +cd /home/wizard/code/trading-bot && source .venv/bin/activate +TRADING_DATABASE_URL=postgresql+asyncpg://trading:trading@localhost:5432/trading_dev \ + alembic revision --autogenerate -m "kevin v2 trading tables" +``` + +Expected: a new file in `alembic/versions/`. Rename it to `d4e5f6a7b8c9_kevin_v2_trading_tables.py` for stable referencing. + +- [ ] **Step 2: Write the migration** + +Replace the autogenerated body with: + +```python +"""kevin v2 trading tables + seed kevin strategy row + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-05-23 +""" + +import uuid + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision = "d4e5f6a7b8c9" +down_revision = "c3d4e5f6a7b8" # last meet_kevin migration +branch_labels = None +depends_on = None + +KEVIN_STRATEGY_UUID = uuid.UUID("4b8d1c2a-5e7f-4d3b-9a1c-6f8b2e4d7a90") + + +def upgrade() -> None: + # 1) Seed the strategies.kevin row that Trade.strategy_id will reference + op.execute( + f""" + INSERT INTO strategies (id, name, description, current_weight, active, created_at, updated_at) + VALUES ( + '{KEVIN_STRATEGY_UUID}', + 'kevin', + 'Meet Kevin signal-driven strategy (paper trading)', + 1.0, + true, + now(), + now() + ) + ON CONFLICT (id) DO NOTHING + """ + ) + + # 2) Bridge audit table + bridge_status_enum = sa.Enum( + "emitted", "skipped_non_tradable", "skipped_blocklist", + "skipped_caps", "deferred", "broker_rejected", "dry_run", + name="kevin_bridge_status", + ) + bridge_status_enum.create(op.get_bind(), checkfirst=True) + + op.create_table( + "kevin_signal_bridge_state", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("mention_id", sa.Integer, + sa.ForeignKey("kevin_stock_mentions.id"), + nullable=False, unique=True), + sa.Column("bridge_status", bridge_status_enum, nullable=False), + sa.Column("signal_id", UUID(as_uuid=True), + sa.ForeignKey("signals.id"), nullable=True), + sa.Column("trade_id", UUID(as_uuid=True), + sa.ForeignKey("trades.id"), nullable=True), + sa.Column("effective_conviction", sa.Numeric(4, 3), nullable=True), + sa.Column("decided_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("notes", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_bridge_state_status_decided", + "kevin_signal_bridge_state", + ["bridge_status", "decided_at"]) + + # 3) Backtest runs + run_status_enum = sa.Enum( + "running", "completed", "failed", + name="kevin_backtest_run_status", + ) + run_status_enum.create(op.get_bind(), checkfirst=True) + + trigger_source_enum = sa.Enum( + "manual", "scheduled", + name="kevin_backtest_trigger_source", + ) + trigger_source_enum.create(op.get_bind(), checkfirst=True) + + op.create_table( + "kevin_backtest_runs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("run_uuid", UUID(as_uuid=True), unique=True, nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", run_status_enum, nullable=False), + sa.Column("trigger_source", trigger_source_enum, + nullable=False, server_default="manual"), + sa.Column("params_json", JSONB, nullable=False), + sa.Column("metrics_json", JSONB, nullable=True), + sa.Column("equity_curve_json", JSONB, nullable=True), + sa.Column("benchmark_curve_json", JSONB, nullable=True), + sa.Column("error_message", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_backtest_runs_started", + "kevin_backtest_runs", ["started_at"]) + op.create_index("ix_backtest_runs_status_started", + "kevin_backtest_runs", ["status", "started_at"]) + + # 4) Backtest trades + op.create_table( + "kevin_backtest_trades", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("run_id", UUID(as_uuid=True), + sa.ForeignKey("kevin_backtest_runs.id", ondelete="CASCADE"), + nullable=False), + sa.Column("symbol", sa.String(16), nullable=False), + sa.Column("source_mention_id", sa.Integer, + sa.ForeignKey("kevin_stock_mentions.id"), nullable=True), + sa.Column("entry_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("entry_price", sa.Numeric(12, 4), nullable=False), + sa.Column("exit_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("exit_price", sa.Numeric(12, 4), nullable=True), + sa.Column("qty", sa.Numeric(14, 4), nullable=False), + sa.Column("pnl_usd", sa.Numeric(14, 4), nullable=True), + sa.Column("pnl_pct", sa.Numeric(8, 4), nullable=True), + sa.Column("holding_days_actual", sa.Integer, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_backtest_trades_run_symbol", + "kevin_backtest_trades", ["run_id", "symbol"]) + op.create_index("ix_backtest_trades_run_entry", + "kevin_backtest_trades", ["run_id", "entry_at"]) + + +def downgrade() -> None: + op.drop_table("kevin_backtest_trades") + op.drop_table("kevin_backtest_runs") + op.drop_table("kevin_signal_bridge_state") + sa.Enum(name="kevin_backtest_trigger_source").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="kevin_backtest_run_status").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="kevin_bridge_status").drop(op.get_bind(), checkfirst=True) + op.execute(f"DELETE FROM strategies WHERE id = '{KEVIN_STRATEGY_UUID}'") +``` + +- [ ] **Step 3: Verify the migration applies cleanly locally** + +```bash +docker compose -f docker/docker-compose.dev.yml up -d postgres +TRADING_DATABASE_URL=postgresql+asyncpg://trading:trading@localhost:5432/trading_dev \ + alembic upgrade head +``` + +Expected: `INFO [alembic.runtime.migration] Running upgrade ... d4e5f6a7b8c9, kevin v2 trading tables`. + +- [ ] **Step 4: Verify downgrade is reversible** + +```bash +TRADING_DATABASE_URL=postgresql+asyncpg://trading:trading@localhost:5432/trading_dev \ + alembic downgrade -1 +TRADING_DATABASE_URL=postgresql+asyncpg://trading:trading@localhost:5432/trading_dev \ + alembic upgrade head +``` + +Expected: clean down + up with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add alembic/versions/d4e5f6a7b8c9_kevin_v2_trading_tables.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin): alembic migration for v2 trading tables + +3 new tables + seeds the 'kevin' row in strategies with a pinned UUID +constant so Trade.strategy_id can be joined back to the strategy across +live + backtest paths." +git push +``` + +--- + +## Task 4: `KevinStrategy.evaluate_mention` — core decision logic + +**Files:** +- Create: `shared/strategies/kevin.py` +- Test: `tests/shared/strategies/test_kevin_strategy.py` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/shared/strategies/test_kevin_strategy.py +"""KevinStrategy tests — one behaviour per test, full conviction × action × held grid.""" + +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from unittest.mock import AsyncMock + +import pytest + +from shared.schemas.kevin import KevinAccountState, KevinDecisionType +from shared.schemas.meet_kevin import StockMentionAction, TimeHorizon +from shared.strategies.kevin import KevinStrategy, KevinStrategyConfig + + +@pytest.fixture +def cfg() -> KevinStrategyConfig: + return KevinStrategyConfig( + min_conviction=Decimal("0.6"), + max_mention_age_hours=48, + base_position_pct=Decimal("0.04"), + min_trade_usd=Decimal("500"), + max_trade_usd=Decimal("5000"), + max_per_ticker_usd=Decimal("7500"), + hold_days_by_horizon={ + "days": 3, "weeks": 10, "months": 45, + "long_term": 90, "unspecified": 10, + }, + avoid_closes_longs=True, + avoid_blocks_days=7, + ) + + +@pytest.fixture +def state() -> KevinAccountState: + return KevinAccountState( + equity_usd=Decimal("100000"), + cash_usd=Decimal("100000"), + held_positions={}, + blocklisted_symbols=set(), + daily_trade_count=0, + daily_alloc_usd=Decimal("0"), + paused=False, + ) + + +def _mention(symbol="NVDA", action="buy", conviction="0.7", + horizon="weeks", age_hours=1): + """Lightweight stub matching KevinStockMention attribute access.""" + return type("M", (), { + "id": 1, + "symbol": symbol, + "action": StockMentionAction(action) if isinstance(action, str) else action, + "conviction": Decimal(conviction), + "time_horizon": TimeHorizon(horizon), + "rationale_quote": "test", + "created_at": datetime.now(timezone.utc) - timedelta(hours=age_hours), + }) + + +@pytest.mark.asyncio +async def test_buy_high_conviction_emits_open_long(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="buy", conviction="0.8"), state, + effective_conviction=Decimal("0.8"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.OPEN_LONG + assert d.symbol == "NVDA" + # 4% * (0.5 + 0.5*(0.8-0.6)/0.4) = 4% * 0.75 = 3% of 100k = $3000 + assert d.target_dollars == Decimal("3000") + assert d.holding_days == 10 # weeks + + +@pytest.mark.asyncio +async def test_buy_conviction_at_floor_emits_minimum_size(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="buy", conviction="0.6"), state, + effective_conviction=Decimal("0.6"), + current_price=Decimal("100"), + is_tradable=True, + ) + # 4% * 0.5 = 2% of 100k = $2000 + assert d.target_dollars == Decimal("2000") + + +@pytest.mark.asyncio +async def test_buy_below_min_conviction_is_no_op(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="buy", conviction="0.5"), state, + effective_conviction=Decimal("0.5"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + assert "min_conviction" in d.rationale.lower() + + +@pytest.mark.asyncio +async def test_buy_mention_too_old_is_no_op(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(age_hours=72), state, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + assert "age" in d.rationale.lower() + + +@pytest.mark.asyncio +async def test_buy_non_tradable_is_no_op(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(symbol="OTCXX"), state, + effective_conviction=Decimal("0.7"), + current_price=Decimal("1"), + is_tradable=False, + ) + assert d.decision == KevinDecisionType.NO_OP + assert "tradable" in d.rationale.lower() + + +@pytest.mark.asyncio +async def test_buy_intraday_horizon_is_no_op(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(horizon="intraday"), state, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + + +@pytest.mark.asyncio +async def test_buy_when_blocklisted_is_no_op(cfg, state): + state_b = state.model_copy(update={"blocklisted_symbols": {"NVDA"}}) + d = await KevinStrategy(cfg).evaluate_mention( + _mention(), state_b, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + assert "blocklist" in d.rationale.lower() + + +@pytest.mark.asyncio +async def test_buy_at_per_ticker_cap_is_no_op(cfg, state): + state_full = state.model_copy( + update={"held_positions": {"NVDA": Decimal("7500")}} + ) + d = await KevinStrategy(cfg).evaluate_mention( + _mention(), state_full, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + assert "cap" in d.rationale.lower() + + +@pytest.mark.asyncio +async def test_buy_held_below_cap_tops_up_to_cap(cfg, state): + state_p = state.model_copy( + update={"held_positions": {"NVDA": Decimal("5000")}} + ) + d = await KevinStrategy(cfg).evaluate_mention( + _mention(conviction="1.0"), state_p, + effective_conviction=Decimal("1.0"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.OPEN_LONG + # full target = $4000, but cap = $7500, already held $5000 → top up $2500 + assert d.target_dollars == Decimal("2500") + + +@pytest.mark.asyncio +async def test_sell_with_held_emits_close_long(cfg, state): + state_p = state.model_copy( + update={"held_positions": {"NVDA": Decimal("3000")}} + ) + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="sell"), state_p, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.CLOSE_LONG + + +@pytest.mark.asyncio +async def test_sell_without_held_is_no_op(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="sell"), state, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + + +@pytest.mark.asyncio +async def test_avoid_with_held_emits_close_long(cfg, state): + state_p = state.model_copy( + update={"held_positions": {"NVDA": Decimal("3000")}} + ) + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="avoid"), state_p, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.CLOSE_LONG + + +@pytest.mark.asyncio +async def test_avoid_without_held_is_no_op_but_blocklists(cfg, state): + # The strategy itself returns NO_OP; the bridge applies the blocklist + # side-effect. We assert rationale reflects intent. + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="avoid"), state, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + assert "blocklist" in d.rationale.lower() or "avoid" in d.rationale.lower() + + +@pytest.mark.asyncio +async def test_hold_action_never_trades(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="hold"), state, + effective_conviction=Decimal("0.9"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + + +@pytest.mark.asyncio +async def test_watch_action_never_trades(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(action="watch"), state, + effective_conviction=Decimal("0.9"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.decision == KevinDecisionType.NO_OP + + +@pytest.mark.asyncio +async def test_size_clamps_to_min_trade_usd(cfg, state): + # Equity small enough that 2% < $500 floor + small_state = state.model_copy(update={"equity_usd": Decimal("10000")}) + d = await KevinStrategy(cfg).evaluate_mention( + _mention(conviction="0.6"), small_state, + effective_conviction=Decimal("0.6"), + current_price=Decimal("100"), + is_tradable=True, + ) + # 2% of $10k = $200 → clamped to $500 + assert d.target_dollars == Decimal("500") + + +@pytest.mark.asyncio +async def test_size_clamps_to_max_trade_usd(cfg, state): + big_state = state.model_copy(update={"equity_usd": Decimal("1000000")}) + d = await KevinStrategy(cfg).evaluate_mention( + _mention(conviction="1.0"), big_state, + effective_conviction=Decimal("1.0"), + current_price=Decimal("100"), + is_tradable=True, + ) + # 4% of $1M = $40k → clamped to $5k + assert d.target_dollars == Decimal("5000") + + +@pytest.mark.asyncio +async def test_horizon_long_term_maps_to_90_days(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(horizon="long_term"), state, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.holding_days == 90 + + +@pytest.mark.asyncio +async def test_horizon_unspecified_defaults_to_weeks(cfg, state): + d = await KevinStrategy(cfg).evaluate_mention( + _mention(horizon="unspecified"), state, + effective_conviction=Decimal("0.7"), + current_price=Decimal("100"), + is_tradable=True, + ) + assert d.holding_days == 10 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/shared/strategies/test_kevin_strategy.py -v +``` + +Expected: collection error / ImportError. + +- [ ] **Step 3: Implement KevinStrategy** + +```python +# shared/strategies/kevin.py +"""Standalone Kevin strategy decision logic. + +NOT a BaseStrategy subclass — the BaseStrategy signature is bar/article +driven; Kevin is event-driven on YouTube mentions. Same shape called by +both the live signal bridge and the backtest mini-engine, so behaviour +cannot drift between them. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from typing import Any + +from shared.schemas.kevin import ( + KevinAccountState, + KevinDecision, + KevinDecisionType, +) +from shared.schemas.meet_kevin import StockMentionAction, TimeHorizon + + +@dataclass(frozen=True) +class KevinStrategyConfig: + """Strategy parameters (all overridable via env-vars in the bridge).""" + + min_conviction: Decimal + max_mention_age_hours: int + base_position_pct: Decimal + min_trade_usd: Decimal + max_trade_usd: Decimal + max_per_ticker_usd: Decimal + hold_days_by_horizon: dict[str, int] + avoid_closes_longs: bool + avoid_blocks_days: int + + +class KevinStrategy: + """Pure decision function: mention + account state -> KevinDecision. + + Stateless. The bridge owns side effects (blocklist writes, Redis counters). + """ + + name: str = "kevin" + + def __init__(self, config: KevinStrategyConfig) -> None: + self.config = config + + async def evaluate_mention( + self, + mention: Any, # KevinStockMention or stub + account: KevinAccountState, + *, + effective_conviction: Decimal, # post multi-mention boost + current_price: Decimal, + is_tradable: bool, + ) -> KevinDecision: + symbol = mention.symbol + action = mention.action + horizon = mention.time_horizon + + # 1. Common no-trade gates + if not is_tradable: + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale="not tradable on Alpaca", + ) + + if account.paused: + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale="trading paused (circuit breaker / drawdown halt)", + ) + + # 2. Action-specific gates + if action in (StockMentionAction.HOLD, StockMentionAction.WATCH): + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale=f"action={action.value} is UI-only, never trades", + ) + + # 3. SELL — close long if held, else no-op + if action == StockMentionAction.SELL: + if account.is_held(symbol): + return KevinDecision( + decision=KevinDecisionType.CLOSE_LONG, + symbol=symbol, + effective_conviction=effective_conviction, + rationale=f"kevin SELL on held position", + ) + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale="SELL but not held; long-only, never shorts", + ) + + # 4. AVOID — close long if held + bridge will add blocklist (side effect) + if action == StockMentionAction.AVOID: + if account.is_held(symbol) and self.config.avoid_closes_longs: + return KevinDecision( + decision=KevinDecisionType.CLOSE_LONG, + symbol=symbol, + effective_conviction=effective_conviction, + rationale=f"kevin AVOID on held position; bridge will blocklist {self.config.avoid_blocks_days}d", + ) + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale=f"kevin AVOID; bridge will add to blocklist for {self.config.avoid_blocks_days}d", + ) + + # 5. BUY path — full filter stack + assert action == StockMentionAction.BUY + + if effective_conviction < self.config.min_conviction: + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale=f"conviction {effective_conviction} below min_conviction {self.config.min_conviction}", + ) + + if horizon == TimeHorizon.INTRADAY: + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale="intraday horizon — 3h poll cadence can't catch", + ) + + # Mention age — uses created_at if available + if hasattr(mention, "created_at") and mention.created_at is not None: + age = datetime.now(timezone.utc) - mention.created_at + if age > timedelta(hours=self.config.max_mention_age_hours): + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale=f"mention age {age} exceeds {self.config.max_mention_age_hours}h", + ) + + if account.is_blocklisted(symbol): + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale="symbol is on blocklist (prior AVOID)", + ) + + # 6. Compute size — conviction-weighted fixed-fractional + conviction_mult = (effective_conviction - self.config.min_conviction) / ( + Decimal("1") - self.config.min_conviction + ) + target_pct = self.config.base_position_pct * ( + Decimal("0.5") + Decimal("0.5") * conviction_mult + ) + target_dollars = account.equity_usd * target_pct + target_dollars = max(self.config.min_trade_usd, + min(target_dollars, self.config.max_trade_usd)) + + # 7. Per-ticker cap absorbs multi-mention boost + already_held_usd = account.held_positions.get(symbol, Decimal("0")) + headroom = self.config.max_per_ticker_usd - already_held_usd + if headroom <= 0: + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale=f"at per-ticker cap (${already_held_usd} already)", + ) + target_dollars = min(target_dollars, headroom) + + if target_dollars < self.config.min_trade_usd: + return KevinDecision( + decision=KevinDecisionType.NO_OP, + symbol=symbol, + rationale=f"after cap headroom only ${target_dollars} — below ${self.config.min_trade_usd} floor", + ) + + # Round to 2dp dollars + target_dollars = target_dollars.quantize(Decimal("0.01")) + + holding_days = self.config.hold_days_by_horizon.get( + horizon.value, self.config.hold_days_by_horizon["unspecified"] + ) + + return KevinDecision( + decision=KevinDecisionType.OPEN_LONG, + symbol=symbol, + target_dollars=target_dollars, + holding_days=holding_days, + effective_conviction=effective_conviction, + rationale=f"BUY conv={effective_conviction} -> {target_pct*100}% target=${target_dollars} hold={holding_days}d", + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/shared/strategies/test_kevin_strategy.py -v +``` + +Expected: 19 passed. + +- [ ] **Step 5: Commit** + +```bash +git add shared/strategies/kevin.py tests/shared/strategies/test_kevin_strategy.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin): KevinStrategy standalone decision logic + +Stateless: mention + account_state -> KevinDecision. Conviction-weighted +sizing, time_horizon-derived hold periods, hard per-ticker cap. The +bridge and the backtest mini-engine both call evaluate_mention so +behaviour cannot drift." +git push +``` + +--- + +## Task 5: `backtester/kevin_backtest.py` — mention-driven mini-engine + +**Files:** +- Create: `backtester/kevin_backtest.py` +- Create: `backtester/kevin_price_loader.py` +- Test: `tests/backtester/test_kevin_backtest.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/backtester/test_kevin_backtest.py +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pandas as pd +import pytest + +from backtester.kevin_backtest import ( + KevinBacktestParams, + KevinBacktestRunner, +) +from backtester.metrics import BacktestResult +from shared.strategies.kevin import KevinStrategy, KevinStrategyConfig + + +class _StubPriceLoader: + """In-memory bars; behaves like the real KevinPriceLoader.""" + + def __init__(self, bars_by_symbol: dict[str, pd.DataFrame]): + self.bars = bars_by_symbol + self.spy = bars_by_symbol.get("SPY") + + async def daily_bars(self, symbol: str, start: datetime, end: datetime) -> pd.DataFrame: + return self.bars.get(symbol, pd.DataFrame()) + + async def is_tradable(self, symbol: str) -> bool: + return symbol in self.bars + + async def benchmark_bars(self, start: datetime, end: datetime) -> pd.DataFrame: + return self.spy if self.spy is not None else pd.DataFrame() + + +def _mention(symbol, action, conviction, horizon, days_ago): + return type("M", (), { + "id": days_ago, + "symbol": symbol, + "action": type("A", (), {"value": action})(), + "conviction": Decimal(conviction), + "time_horizon": type("H", (), {"value": horizon})(), + "created_at": datetime(2026, 5, 15, 14, 0, tzinfo=timezone.utc) + timedelta(days=days_ago), + }) + + +def _bars(symbol, start_date, prices): + """Build a daily-bar DataFrame indexed by date.""" + dates = pd.date_range(start_date, periods=len(prices), freq="B", tz="UTC") + return pd.DataFrame( + {"open": prices, "high": prices, "low": prices, "close": prices}, + index=dates, + ) + + +@pytest.fixture +def cfg() -> KevinStrategyConfig: + return KevinStrategyConfig( + min_conviction=Decimal("0.6"), + max_mention_age_hours=48 * 365, # effectively no age filter for backtest + base_position_pct=Decimal("0.04"), + min_trade_usd=Decimal("500"), + max_trade_usd=Decimal("5000"), + max_per_ticker_usd=Decimal("7500"), + hold_days_by_horizon={ + "days": 3, "weeks": 5, "months": 10, # short for tests + "long_term": 15, "unspecified": 5, + }, + avoid_closes_longs=True, + avoid_blocks_days=7, + ) + + +@pytest.mark.asyncio +async def test_backtest_emits_winning_trade(cfg): + # NVDA: enters at $100 day 0, exits at $110 day 5 = +10% + bars = { + "NVDA": _bars("NVDA", "2026-05-15", [100, 102, 104, 106, 108, 110, 112]), + "SPY": _bars("SPY", "2026-05-15", [500, 501, 502, 503, 504, 505, 506]), + } + strategy = KevinStrategy(cfg) + mentions = [_mention("NVDA", "buy", "0.8", "weeks", 0)] + + runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) + result = await runner.run( + mentions, + KevinBacktestParams( + initial_capital=Decimal("100000"), + slippage_pct=Decimal("0.0005"), + ), + ) + + assert isinstance(result, BacktestResult) + assert result.trade_count == 1 + assert result.total_return_pct > 0 + # exit was triggered by holding period (5 trading days) + + +@pytest.mark.asyncio +async def test_backtest_filters_low_conviction(cfg): + bars = { + "NVDA": _bars("NVDA", "2026-05-15", [100, 105, 110, 115, 120, 125]), + "SPY": _bars("SPY", "2026-05-15", [500] * 6), + } + strategy = KevinStrategy(cfg) + mentions = [_mention("NVDA", "buy", "0.5", "weeks", 0)] # below 0.6 floor + + runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) + result = await runner.run(mentions, KevinBacktestParams()) + assert result.trade_count == 0 + + +@pytest.mark.asyncio +async def test_backtest_dedupe_roll_extends_exit(cfg): + # Two BUYs on same ticker within hold window; exit should extend + bars = { + "NVDA": _bars("NVDA", "2026-05-15", [100] * 15), + "SPY": _bars("SPY", "2026-05-15", [500] * 15), + } + strategy = KevinStrategy(cfg) + mentions = [ + _mention("NVDA", "buy", "0.7", "weeks", 0), + _mention("NVDA", "buy", "0.7", "weeks", 3), + ] + runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) + result = await runner.run( + mentions, + KevinBacktestParams(dedupe_policy="roll"), + ) + # Exit at day 3 + 5 = 8, not day 0 + 5 = 5 + assert result.trade_count == 1 + closed = result.trades[0] + assert closed.holding_days_actual >= 5 + + +@pytest.mark.asyncio +async def test_backtest_sell_mid_position_closes_early(cfg): + bars = { + "NVDA": _bars("NVDA", "2026-05-15", [100, 105, 110, 95, 90, 85, 80]), + "SPY": _bars("SPY", "2026-05-15", [500] * 7), + } + strategy = KevinStrategy(cfg) + mentions = [ + _mention("NVDA", "buy", "0.8", "weeks", 0), + _mention("NVDA", "sell", "0.9", "days", 2), + ] + runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) + result = await runner.run(mentions, KevinBacktestParams()) + assert result.trade_count == 1 + assert result.trades[0].holding_days_actual <= 3 + + +@pytest.mark.asyncio +async def test_backtest_handles_missing_bars(cfg): + bars = { + "SPY": _bars("SPY", "2026-05-15", [500] * 5), + # NVDA missing + } + strategy = KevinStrategy(cfg) + mentions = [_mention("NVDA", "buy", "0.8", "weeks", 0)] + runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) + result = await runner.run(mentions, KevinBacktestParams()) + # Mention skipped (no price data); no trade + assert result.trade_count == 0 + + +@pytest.mark.asyncio +async def test_backtest_computes_alpha_vs_spy(cfg): + # NVDA +10%, SPY flat → positive alpha + bars = { + "NVDA": _bars("NVDA", "2026-05-15", [100, 100, 100, 100, 100, 110, 110]), + "SPY": _bars("SPY", "2026-05-15", [500] * 7), + } + strategy = KevinStrategy(cfg) + mentions = [_mention("NVDA", "buy", "0.8", "weeks", 0)] + runner = KevinBacktestRunner(strategy, _StubPriceLoader(bars)) + result = await runner.run( + mentions, + KevinBacktestParams(initial_capital=Decimal("100000")), + ) + assert result.alpha_vs_spy_pct is not None + assert result.alpha_vs_spy_pct > 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/backtester/test_kevin_backtest.py -v +``` + +Expected: ImportError. + +- [ ] **Step 3: Implement the runner** + +```python +# backtester/kevin_backtest.py +"""Mention-driven backtest mini-engine for the Kevin strategy. + +Parallel to the bar-driven BacktestEngine. Walks mentions chronologically, +entry at T+1 open, exit at entry_session + holding_days open. Calls the +shared KevinStrategy.evaluate_mention so backtest and live agree. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from typing import Protocol + +import pandas as pd + +from backtester.metrics import BacktestResult, compute_metrics +from shared.schemas.kevin import ( + KevinAccountState, + KevinDecision, + KevinDecisionType, +) +from shared.strategies.kevin import KevinStrategy + +logger = logging.getLogger(__name__) + + +class PriceLoader(Protocol): + async def daily_bars(self, symbol: str, start: datetime, end: datetime) -> pd.DataFrame: ... + async def is_tradable(self, symbol: str) -> bool: ... + async def benchmark_bars(self, start: datetime, end: datetime) -> pd.DataFrame: ... + + +@dataclass +class KevinBacktestParams: + initial_capital: Decimal = Decimal("100000") + slippage_pct: Decimal = Decimal("0.0005") + commission_per_trade: Decimal = Decimal("0") + dedupe_policy: str = "roll" # "roll" | "ignore" + + +@dataclass +class _BacktestTrade: + symbol: str + source_mention_id: int + entry_at: datetime + entry_price: Decimal + qty: Decimal + target_exit_at: datetime + exit_at: datetime | None = None + exit_price: Decimal | None = None + pnl_usd: Decimal | None = None + pnl_pct: Decimal | None = None + holding_days_actual: int | None = None + + +@dataclass +class _Portfolio: + cash: Decimal + open_trades: dict[str, _BacktestTrade] = field(default_factory=dict) + closed_trades: list[_BacktestTrade] = field(default_factory=list) + blocklisted_symbols: set[str] = field(default_factory=set) + blocklist_expiry: dict[str, datetime] = field(default_factory=dict) + + def equity_at(self, mark_prices: dict[str, Decimal]) -> Decimal: + total = self.cash + for symbol, trade in self.open_trades.items(): + price = mark_prices.get(symbol, trade.entry_price) + total += trade.qty * price + return total + + def held_dollars(self) -> dict[str, Decimal]: + return {s: t.qty * t.entry_price for s, t in self.open_trades.items()} + + def active_blocklist(self, now: datetime) -> set[str]: + return {s for s, exp in self.blocklist_expiry.items() if exp > now} + + +class KevinBacktestRunner: + def __init__(self, strategy: KevinStrategy, price_loader: PriceLoader) -> None: + self.strategy = strategy + self.price_loader = price_loader + + async def run(self, mentions: list, params: KevinBacktestParams) -> BacktestResult: + if not mentions: + return _empty_result(params.initial_capital) + + sorted_mentions = sorted(mentions, key=lambda m: m.created_at) + start = sorted_mentions[0].created_at - timedelta(days=1) + end = max(m.created_at for m in sorted_mentions) + timedelta(days=120) + + symbols = sorted({m.symbol for m in sorted_mentions}) + bars: dict[str, pd.DataFrame] = {} + for sym in symbols: + df = await self.price_loader.daily_bars(sym, start, end) + if not df.empty: + bars[sym] = df + + spy_bars = await self.price_loader.benchmark_bars(start, end) + + portfolio = _Portfolio(cash=params.initial_capital) + equity_curve: list[tuple[datetime, Decimal]] = [] + all_dates = _trading_dates(spy_bars) + + for day in all_dates: + # 1. Apply mentions whose created_at falls on or before this trading session + for mention in [m for m in sorted_mentions + if _entry_day(m.created_at, all_dates) == day]: + await self._apply_mention( + mention, day, portfolio, bars, params, + ) + + # 2. Roll exits whose target_exit_at == day + _close_expired(day, portfolio, bars, params, all_dates) + + # 3. Mark-to-market equity + mark = {s: _price_at(bars[s], day, "close") + for s in portfolio.open_trades if s in bars} + equity_curve.append((day, portfolio.equity_at(mark))) + + # Close any still-open at the last day + if all_dates: + _close_all(all_dates[-1], portfolio, bars, params) + + trades_dict = [self._trade_to_dict(t) for t in portfolio.closed_trades] + return _build_result( + equity_curve, trades_dict, spy_bars, + params.initial_capital, + ) + + async def _apply_mention( + self, mention, day, portfolio, bars, params, + ) -> None: + symbol = mention.symbol + if symbol not in bars: + return # no price data — skip + + is_tradable = await self.price_loader.is_tradable(symbol) + mark = {s: _price_at(bars[s], day, "close") + for s in portfolio.open_trades if s in bars} + equity = portfolio.equity_at(mark) + state = KevinAccountState( + equity_usd=equity, + cash_usd=portfolio.cash, + held_positions=portfolio.held_dollars(), + blocklisted_symbols=portfolio.active_blocklist(day), + daily_trade_count=0, # backtest doesn't enforce daily caps + daily_alloc_usd=Decimal("0"), + paused=False, + ) + + current_price = _price_at(bars[symbol], day, "open") + if current_price is None: + return + + # multi-mention boost handled via dedupe_policy: roll extends the exit + decision = await self.strategy.evaluate_mention( + mention, state, + effective_conviction=mention.conviction, + current_price=current_price, + is_tradable=is_tradable, + ) + + if decision.decision == KevinDecisionType.OPEN_LONG: + self._open_or_roll(decision, mention, day, portfolio, bars, params) + elif decision.decision == KevinDecisionType.CLOSE_LONG: + self._close_position(symbol, day, portfolio, bars, params) + if mention.action.value == "avoid": + portfolio.blocklist_expiry[symbol] = day + timedelta( + days=self.strategy.config.avoid_blocks_days + ) + + def _open_or_roll(self, decision, mention, day, portfolio, bars, params): + symbol = decision.symbol + entry_price = _price_at(bars[symbol], day, "open") + if entry_price is None: + return + entry_price *= (Decimal("1") + params.slippage_pct) + + qty = (decision.target_dollars / entry_price).quantize(Decimal("0.0001")) + if qty <= 0: + return + + cost = qty * entry_price + params.commission_per_trade + if cost > portfolio.cash: + return # insufficient cash in backtest + + target_exit = day + timedelta(days=int(decision.holding_days) * 1.4) # trading→calendar approx + target_exit = _next_trading_day(target_exit, bars[symbol].index) + + if symbol in portfolio.open_trades: + if params.dedupe_policy == "roll": + portfolio.open_trades[symbol].target_exit_at = max( + portfolio.open_trades[symbol].target_exit_at, target_exit + ) + return # ignore: don't add second position + + portfolio.cash -= cost + portfolio.open_trades[symbol] = _BacktestTrade( + symbol=symbol, + source_mention_id=mention.id, + entry_at=day, + entry_price=entry_price, + qty=qty, + target_exit_at=target_exit, + ) + + def _close_position(self, symbol, day, portfolio, bars, params): + if symbol not in portfolio.open_trades: + return + trade = portfolio.open_trades.pop(symbol) + exit_price = _price_at(bars[symbol], day, "open") + if exit_price is None: + exit_price = trade.entry_price # last resort + exit_price *= (Decimal("1") - params.slippage_pct) + + proceeds = trade.qty * exit_price - params.commission_per_trade + portfolio.cash += proceeds + trade.exit_at = day + trade.exit_price = exit_price + trade.pnl_usd = (exit_price - trade.entry_price) * trade.qty + trade.pnl_pct = ((exit_price - trade.entry_price) / trade.entry_price) * Decimal("100") + trade.holding_days_actual = (day - trade.entry_at).days + portfolio.closed_trades.append(trade) + + def _trade_to_dict(self, t: _BacktestTrade) -> dict: + return { + "symbol": t.symbol, + "source_mention_id": t.source_mention_id, + "entry_at": t.entry_at, + "entry_price": t.entry_price, + "exit_at": t.exit_at, + "exit_price": t.exit_price, + "qty": t.qty, + "pnl_usd": t.pnl_usd, + "pnl_pct": t.pnl_pct, + "holding_days_actual": t.holding_days_actual, + } + + +# --- helpers --- + +def _trading_dates(bars: pd.DataFrame) -> list[datetime]: + if bars is None or bars.empty: + return [] + return [d.to_pydatetime().replace(tzinfo=timezone.utc) for d in bars.index] + + +def _entry_day(created_at: datetime, dates: list[datetime]) -> datetime | None: + """Find next trading session AFTER mention's created_at (T+1).""" + target = created_at.date() + for d in dates: + if d.date() > target: + return d + return None + + +def _price_at(df: pd.DataFrame, day: datetime, col: str) -> Decimal | None: + if df is None or df.empty: + return None + # Forward-fill: pick last row whose date <= day + matches = df[df.index.date <= day.date()] + if matches.empty: + return None + return Decimal(str(matches.iloc[-1][col])) + + +def _next_trading_day(target: datetime, index: pd.DatetimeIndex) -> datetime: + for d in index: + if d.to_pydatetime().replace(tzinfo=timezone.utc) >= target: + return d.to_pydatetime().replace(tzinfo=timezone.utc) + return index[-1].to_pydatetime().replace(tzinfo=timezone.utc) + + +def _close_expired(day, portfolio, bars, params, dates): + for symbol in list(portfolio.open_trades.keys()): + trade = portfolio.open_trades[symbol] + if trade.target_exit_at <= day: + _force_close(symbol, day, portfolio, bars, params) + + +def _close_all(day, portfolio, bars, params): + for symbol in list(portfolio.open_trades.keys()): + _force_close(symbol, day, portfolio, bars, params) + + +def _force_close(symbol, day, portfolio, bars, params): + trade = portfolio.open_trades.pop(symbol) + exit_price = _price_at(bars[symbol], day, "open") or trade.entry_price + exit_price *= (Decimal("1") - params.slippage_pct) + proceeds = trade.qty * exit_price - params.commission_per_trade + portfolio.cash += proceeds + trade.exit_at = day + trade.exit_price = exit_price + trade.pnl_usd = (exit_price - trade.entry_price) * trade.qty + trade.pnl_pct = ((exit_price - trade.entry_price) / trade.entry_price) * Decimal("100") + trade.holding_days_actual = (day - trade.entry_at).days + portfolio.closed_trades.append(trade) + + +def _empty_result(initial: Decimal) -> BacktestResult: + return compute_metrics(trade_log=[], equity_curve=[], initial_capital=initial) + + +def _build_result(equity_curve, trades, spy_bars, initial) -> BacktestResult: + return compute_metrics( + trade_log=trades, equity_curve=equity_curve, + initial_capital=initial, benchmark_bars=spy_bars, + ) +``` + +- [ ] **Step 4: Create the price loader (separate file, simpler)** + +```python +# backtester/kevin_price_loader.py +"""Daily bar loader for KevinBacktestRunner. + +Reads from market_data table first; falls back to Alpaca on cache miss +and writes through so subsequent runs are warm. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime +from typing import Any + +import pandas as pd +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from shared.models.timeseries import MarketData + +logger = logging.getLogger(__name__) + + +class KevinPriceLoader: + def __init__( + self, + session_factory: async_sessionmaker, + alpaca_fetcher: Any, # has .fetch_daily_bars(symbol, start, end) + .is_asset_tradable(symbol) + ) -> None: + self.session_factory = session_factory + self.alpaca = alpaca_fetcher + + async def daily_bars(self, symbol: str, start: datetime, end: datetime) -> pd.DataFrame: + async with self.session_factory() as session: + rows = (await session.execute( + select(MarketData.timestamp, MarketData.open, MarketData.high, + MarketData.low, MarketData.close, MarketData.volume) + .where(and_(MarketData.ticker == symbol, + MarketData.timestamp >= start, + MarketData.timestamp <= end)) + .order_by(MarketData.timestamp) + )).all() + + if rows: + df = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close", "volume"]) + df = df.set_index("timestamp") + return df + + # cache miss — back-fetch from Alpaca, write through + try: + df = await self.alpaca.fetch_daily_bars(symbol, start, end) + if not df.empty: + await self._write_through(symbol, df) + return df + except Exception as e: + logger.warning("alpaca fetch failed for %s: %s", symbol, e) + return pd.DataFrame() + + async def benchmark_bars(self, start: datetime, end: datetime) -> pd.DataFrame: + return await self.daily_bars("SPY", start, end) + + async def is_tradable(self, symbol: str) -> bool: + try: + return await self.alpaca.is_asset_tradable(symbol) + except Exception: + return False + + async def _write_through(self, symbol: str, df: pd.DataFrame) -> None: + async with self.session_factory() as session: + for ts, row in df.iterrows(): + session.add(MarketData( + ticker=symbol, + timestamp=ts.to_pydatetime(), + open=row["open"], high=row["high"], low=row["low"], + close=row["close"], volume=row.get("volume", 0), + )) + await session.commit() +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/backtester/test_kevin_backtest.py -v +``` + +Expected: 6 passed. + +- [ ] **Step 6: Commit** + +```bash +git add backtester/kevin_backtest.py backtester/kevin_price_loader.py tests/backtester/test_kevin_backtest.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin): mention-driven backtest mini-engine + +Walks mentions chronologically, T+1 entry, time-based exit per +KevinStrategy. Reuses backtester/metrics::compute_metrics for headline +numbers. KevinPriceLoader fronts market_data + Alpaca." +git push +``` + +--- + +## Task 6: Extend `compute_metrics` with alpha, beta, winners, losers, best, worst + +**Files:** +- Modify: `backtester/metrics.py` +- Test: `tests/backtester/test_metrics_kevin_extensions.py` + +- [ ] **Step 1: Read the existing file** to understand the BacktestResult dataclass shape: + +```bash +sed -n '1,80p' backtester/metrics.py +``` + +- [ ] **Step 2: Write the failing test** + +```python +# tests/backtester/test_metrics_kevin_extensions.py +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pandas as pd + +from backtester.metrics import BacktestResult, compute_metrics + + +def _ts(day): + return datetime(2026, 5, 15, tzinfo=timezone.utc) + timedelta(days=day) + + +def test_compute_metrics_returns_alpha_and_beta_with_benchmark(): + spy = pd.DataFrame( + {"open": [500, 500, 510, 510, 520], "high": [500]*5, "low": [500]*5, + "close": [500, 500, 510, 510, 520], "volume": [0]*5}, + index=pd.date_range("2026-05-15", periods=5, freq="D", tz="UTC"), + ) + trades = [{ + "symbol": "NVDA", "source_mention_id": 1, + "entry_at": _ts(0), "entry_price": Decimal("100"), + "exit_at": _ts(4), "exit_price": Decimal("110"), + "qty": Decimal("10"), "pnl_usd": Decimal("100"), + "pnl_pct": Decimal("10"), "holding_days_actual": 4, + }] + curve = [(_ts(i), Decimal(str(100000 + 25 * i))) for i in range(5)] + + result = compute_metrics( + trade_log=trades, equity_curve=curve, + initial_capital=Decimal("100000"), benchmark_bars=spy, + ) + + assert result.alpha_vs_spy_pct is not None + assert result.beta_vs_spy is not None + + +def test_compute_metrics_returns_winners_losers_best_worst(): + trades = [ + {"symbol": "NVDA", "source_mention_id": 1, + "entry_at": _ts(0), "entry_price": Decimal("100"), + "exit_at": _ts(5), "exit_price": Decimal("110"), + "qty": Decimal("10"), "pnl_usd": Decimal("100"), + "pnl_pct": Decimal("10"), "holding_days_actual": 5}, + {"symbol": "INTC", "source_mention_id": 2, + "entry_at": _ts(0), "entry_price": Decimal("50"), + "exit_at": _ts(5), "exit_price": Decimal("45"), + "qty": Decimal("20"), "pnl_usd": Decimal("-100"), + "pnl_pct": Decimal("-10"), "holding_days_actual": 5}, + ] + result = compute_metrics( + trade_log=trades, equity_curve=[(_ts(0), Decimal("100000"))], + initial_capital=Decimal("100000"), + ) + + assert result.avg_winner_pct == Decimal("10") + assert result.avg_loser_pct == Decimal("-10") + assert result.best_trade == {"symbol": "NVDA", "pnl_pct": Decimal("10")} + assert result.worst_trade == {"symbol": "INTC", "pnl_pct": Decimal("-10")} + + +def test_compute_metrics_no_benchmark_omits_alpha_beta(): + result = compute_metrics( + trade_log=[], + equity_curve=[(_ts(0), Decimal("100000"))], + initial_capital=Decimal("100000"), + ) + assert result.alpha_vs_spy_pct is None + assert result.beta_vs_spy is None +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +pytest tests/backtester/test_metrics_kevin_extensions.py -v +``` + +Expected: AttributeError (BacktestResult missing fields) or TypeError (compute_metrics doesn't accept benchmark_bars). + +- [ ] **Step 4: Extend `backtester/metrics.py` — add 6 fields + benchmark param** + +In `BacktestResult` dataclass, add these fields (find the class definition and add at the bottom): + +```python + # --- Kevin v2 extensions --- + alpha_vs_spy_pct: Decimal | None = None + beta_vs_spy: Decimal | None = None + avg_winner_pct: Decimal | None = None + avg_loser_pct: Decimal | None = None + best_trade: dict | None = None + worst_trade: dict | None = None +``` + +In `compute_metrics(...)`, change the signature to accept an optional benchmark_bars and compute the extensions: + +```python +def compute_metrics( + trade_log: list[dict], + equity_curve: list[tuple[datetime, Decimal]], + initial_capital: Decimal, + benchmark_bars: pd.DataFrame | None = None, +) -> BacktestResult: + # ... existing logic that computes total_return, sharpe, max_drawdown, win_rate, trade_count ... + + # --- new: winners / losers / best / worst --- + winners = [t for t in trade_log if t.get("pnl_pct") is not None and t["pnl_pct"] > 0] + losers = [t for t in trade_log if t.get("pnl_pct") is not None and t["pnl_pct"] <= 0] + avg_winner_pct = (sum(t["pnl_pct"] for t in winners) / len(winners)) if winners else None + avg_loser_pct = (sum(t["pnl_pct"] for t in losers) / len(losers)) if losers else None + best_trade = max(trade_log, key=lambda t: t.get("pnl_pct", Decimal("-Infinity")), default=None) + worst_trade = min(trade_log, key=lambda t: t.get("pnl_pct", Decimal("Infinity")), default=None) + best_trade = {"symbol": best_trade["symbol"], "pnl_pct": best_trade["pnl_pct"]} if best_trade else None + worst_trade = {"symbol": worst_trade["symbol"], "pnl_pct": worst_trade["pnl_pct"]} if worst_trade else None + + # --- new: alpha + beta vs SPY --- + alpha_vs_spy_pct: Decimal | None = None + beta_vs_spy: Decimal | None = None + if benchmark_bars is not None and not benchmark_bars.empty and len(equity_curve) >= 2: + try: + equity_df = pd.DataFrame(equity_curve, columns=["timestamp", "equity"]).set_index("timestamp") + equity_df["equity"] = equity_df["equity"].astype(float) + equity_ret = equity_df["equity"].pct_change().dropna() + spy_close = benchmark_bars["close"].astype(float).pct_change().dropna() + aligned = pd.concat([equity_ret, spy_close], axis=1, keys=["s", "spy"]).dropna() + if len(aligned) >= 2: + cov = aligned["s"].cov(aligned["spy"]) + var = aligned["spy"].var() + if var > 0: + beta_vs_spy = Decimal(str(round(cov / var, 4))) + spy_total_return = (float(benchmark_bars["close"].iloc[-1]) / float(benchmark_bars["close"].iloc[0]) - 1) * 100 + strategy_total_return = float(equity_curve[-1][1] / initial_capital - 1) * 100 + alpha_vs_spy_pct = Decimal(str(round(strategy_total_return - spy_total_return, 4))) + except Exception: + logger.exception("benchmark metrics failed") + + return BacktestResult( + # ... existing fields ... + alpha_vs_spy_pct=alpha_vs_spy_pct, + beta_vs_spy=beta_vs_spy, + avg_winner_pct=avg_winner_pct, + avg_loser_pct=avg_loser_pct, + best_trade=best_trade, + worst_trade=worst_trade, + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/backtester/test_metrics_kevin_extensions.py tests/backtester/ -v +``` + +Expected: all green. Existing metrics tests must still pass. + +- [ ] **Step 6: Commit** + +```bash +git add backtester/metrics.py tests/backtester/test_metrics_kevin_extensions.py +git -c user.email=me@viktorbarzin.me commit -m "feat(backtester): extend compute_metrics with alpha/beta/winners/best + +In-place extension (no fork). Existing tests still pass; new fields are +optional and None when no benchmark is supplied." +git push +``` + +--- + +## Task 7: `kevin_signal_bridge` — config + cursor + main poll loop + +**Files:** +- Create: `services/kevin_signal_bridge/__init__.py` (empty) +- Create: `services/kevin_signal_bridge/config.py` +- Create: `services/kevin_signal_bridge/main.py` +- Test: `tests/services/kevin_signal_bridge/test_main.py` + +- [ ] **Step 1: Implement config** + +```python +# services/kevin_signal_bridge/config.py +"""KevinBridgeConfig — env-var settings for the Kevin signal bridge service. + +All env vars use the TRADING_ prefix consumed by shared.config.BaseConfig. +""" + +import json + +from pydantic import field_validator + +from shared.config import BaseConfig + + +class KevinBridgeConfig(BaseConfig): + # Signal translation + kevin_min_conviction: float = 0.60 + kevin_max_mention_age_hours: int = 48 + kevin_hold_days: dict = { + "days": 3, "weeks": 10, "months": 45, + "long_term": 90, "unspecified": 10, + } + + # Sizing + kevin_base_position_pct: float = 0.04 + kevin_min_trade_usd: float = 500.0 + kevin_max_trade_usd: float = 5000.0 + kevin_max_per_ticker_usd: float = 7500.0 + + # Exits + kevin_stop_loss_pct: float = 0.08 + kevin_take_profit_pct: float = 0.20 + kevin_avoid_closes_longs: bool = True + kevin_avoid_blocks_days: int = 7 + + # Aggregation + kevin_mention_boost_per_repeat: float = 0.05 + kevin_max_mention_boost: float = 0.20 + + # Risk + kevin_max_position_pct: float = 0.075 + kevin_daily_trade_cap: int = 5 + kevin_daily_alloc_cap_usd: float = 15000.0 + kevin_daily_loss_circuit_pct: float = 0.03 + kevin_equity_drawdown_halt_pct: float = 0.80 + + # Plumbing + kevin_bridge_poll_interval_seconds: int = 60 + kevin_bridge_exit_scan_cron: str = "35 9 * * 1-5" + kevin_enable_trading: bool = False # master kill-switch + + @field_validator("kevin_hold_days", mode="before") + @classmethod + def _parse_hold_days(cls, v): + if isinstance(v, str): + return json.loads(v) + return v +``` + +- [ ] **Step 2: Write the failing test (smoke-level)** + +```python +# tests/services/kevin_signal_bridge/test_main.py +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from services.kevin_signal_bridge.main import KevinBridge + + +@pytest.mark.asyncio +async def test_bridge_dry_run_writes_audit_does_not_publish(): + """When TRADING_KEVIN_ENABLE_TRADING=false, bridge writes a dry_run + audit row and never calls publisher.publish.""" + + config = MagicMock(kevin_enable_trading=False) + cursor = AsyncMock() + cursor.last_seen_id.return_value = 0 + publisher = AsyncMock() + aggregator = AsyncMock() + aggregator.fetch_pending.return_value = [ + MagicMock(id=1, symbol="NVDA", action=MagicMock(value="buy"), + conviction=Decimal("0.8"), + time_horizon=MagicMock(value="weeks")), + ] + strategy = AsyncMock() + strategy.evaluate_mention.return_value = MagicMock( + decision=MagicMock(value="open_long"), + symbol="NVDA", + target_dollars=Decimal("3000"), + holding_days=10, + rationale="ok", + ) + audit_writer = AsyncMock() + + bridge = KevinBridge( + config=config, cursor=cursor, publisher=publisher, + aggregator=aggregator, strategy=strategy, + audit_writer=audit_writer, broker=AsyncMock(), + ) + await bridge.process_one_pass() + + publisher.publish.assert_not_called() + audit_writer.write.assert_awaited() + args = audit_writer.write.call_args + assert "dry_run" in str(args).lower() + + +@pytest.mark.asyncio +async def test_bridge_kill_switch_on_publishes_to_stream(): + config = MagicMock(kevin_enable_trading=True) + cursor = AsyncMock(last_seen_id=AsyncMock(return_value=0), + advance=AsyncMock()) + publisher = AsyncMock() + publisher.publish.return_value = "1234-0" + aggregator = AsyncMock() + aggregator.fetch_pending.return_value = [ + MagicMock(id=1, symbol="NVDA", action=MagicMock(value="buy"), + conviction=Decimal("0.8"), + time_horizon=MagicMock(value="weeks")), + ] + strategy = AsyncMock() + strategy.evaluate_mention.return_value = MagicMock( + decision=MagicMock(value="open_long"), + symbol="NVDA", + target_dollars=Decimal("3000"), + holding_days=10, + rationale="ok", + ) + audit_writer = AsyncMock() + broker = AsyncMock() + broker.get_account.return_value = MagicMock( + equity=Decimal("100000"), cash=Decimal("100000")) + broker.get_positions.return_value = [] + + bridge = KevinBridge( + config=config, cursor=cursor, publisher=publisher, + aggregator=aggregator, strategy=strategy, + audit_writer=audit_writer, broker=broker, + ) + await bridge.process_one_pass() + + publisher.publish.assert_awaited_once() + cursor.advance.assert_awaited_with(1) +``` + +- [ ] **Step 3: Implement main.py** + +```python +# services/kevin_signal_bridge/main.py +"""Kevin signal bridge — polls kevin_stock_mentions, calls KevinStrategy, +publishes TradeSignal to signals:generated. + +Kill-switch (kevin_enable_trading=false) writes audit rows but skips +publish.""" + +from __future__ import annotations + +import asyncio +import logging +import signal +import uuid +from decimal import Decimal + +from shared.constants.kevin import KEVIN_STRATEGY_UUID +from shared.schemas.kevin import KevinAccountState, KevinDecisionType +from shared.schemas.trading import TradeSignal, SignalDirection + +logger = logging.getLogger(__name__) + + +class KevinBridge: + """End-to-end orchestrator. Composed from injected collaborators + so it's unit-testable. + """ + + def __init__(self, config, cursor, publisher, aggregator, + strategy, audit_writer, broker, blocklist=None, + risk_counters=None) -> None: + self.config = config + self.cursor = cursor + self.publisher = publisher + self.aggregator = aggregator + self.strategy = strategy + self.audit_writer = audit_writer + self.broker = broker + self.blocklist = blocklist + self.risk_counters = risk_counters + + async def process_one_pass(self) -> int: + last_seen = await self.cursor.last_seen_id() + pending = await self.aggregator.fetch_pending(since_id=last_seen) + n_processed = 0 + for mention in pending: + try: + await self._process_mention(mention) + await self.cursor.advance(mention.id) + n_processed += 1 + except Exception: + logger.exception("bridge error on mention %s", mention.id) + return n_processed + + async def _process_mention(self, mention) -> None: + # Aggregator already provides effective_conviction (multi-mention boost) + effective_conviction = getattr( + mention, "effective_conviction", mention.conviction + ) + + account_state = await self._snapshot_account() + is_tradable = await self.broker.is_asset_tradable(mention.symbol) + current_price = await self.broker.get_latest_price(mention.symbol) + + decision = await self.strategy.evaluate_mention( + mention, account_state, + effective_conviction=effective_conviction, + current_price=current_price, + is_tradable=is_tradable, + ) + + if decision.decision == KevinDecisionType.NO_OP: + status = self._classify_no_op(decision.rationale) + await self.audit_writer.write( + mention_id=mention.id, bridge_status=status, + effective_conviction=effective_conviction, + signal_id=None, trade_id=None, + notes=decision.rationale, + ) + return + + # Apply blocklist side-effect on AVOID + if self.blocklist and mention.action.value == "avoid": + await self.blocklist.add(mention.symbol, + ttl_days=self.config.kevin_avoid_blocks_days) + + if not self.config.kevin_enable_trading: + await self.audit_writer.write( + mention_id=mention.id, bridge_status="dry_run", + effective_conviction=effective_conviction, + signal_id=None, trade_id=None, + notes=f"kill-switch off; would: {decision.rationale}", + ) + return + + # Publish TradeSignal to Redis Stream + signal = TradeSignal( + id=uuid.uuid4(), + ticker=decision.symbol, + direction=( + SignalDirection.LONG + if decision.decision == KevinDecisionType.OPEN_LONG + else SignalDirection.EXIT + ), + strength=float(decision.effective_conviction or 1.0), + strategy_id=KEVIN_STRATEGY_UUID, + strategy_sources=[ + f"kevin:{mention.action.value}:{effective_conviction}", + ], + target_dollars=decision.target_dollars, + stop_loss_pct=Decimal(str(self.config.kevin_stop_loss_pct)), + take_profit_pct=Decimal(str(self.config.kevin_take_profit_pct)), + ) + + stream_id = await self.publisher.publish(signal) + await self.audit_writer.write( + mention_id=mention.id, bridge_status="emitted", + effective_conviction=effective_conviction, + signal_id=signal.id, trade_id=None, + notes=f"published to stream as {stream_id}", + ) + + async def _snapshot_account(self) -> KevinAccountState: + acct = await self.broker.get_account() + positions = await self.broker.get_positions() + held = {p.symbol: Decimal(str(p.cost_basis)) for p in positions} + blocklist = ( + await self.blocklist.active_set() if self.blocklist else set() + ) + daily_trades = ( + await self.risk_counters.get_daily_trades() if self.risk_counters else 0 + ) + daily_alloc = ( + await self.risk_counters.get_daily_alloc() + if self.risk_counters else Decimal("0") + ) + return KevinAccountState( + equity_usd=Decimal(str(acct.equity)), + cash_usd=Decimal(str(acct.cash)), + held_positions=held, + blocklisted_symbols=blocklist, + daily_trade_count=daily_trades, + daily_alloc_usd=daily_alloc, + paused=await self._is_paused(), + ) + + async def _is_paused(self) -> bool: + return await self.risk_counters.is_trading_paused() if self.risk_counters else False + + @staticmethod + def _classify_no_op(rationale: str) -> str: + r = rationale.lower() + if "tradable" in r: + return "skipped_non_tradable" + if "blocklist" in r: + return "skipped_blocklist" + if "cap" in r or "paused" in r or "halt" in r: + return "skipped_caps" + return "deferred" + + +# --- service entry point --- + +async def run() -> None: + """Boot the bridge with concrete collaborators + main loop.""" + # (Implementation here will be filled by tasks 8-9-10 wiring concrete + # cursor/aggregator/blocklist/risk_counters/audit_writer.) + pass + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +- [ ] **Step 4: Run tests** + +```bash +pytest tests/services/kevin_signal_bridge/test_main.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add services/kevin_signal_bridge/ tests/services/kevin_signal_bridge/test_main.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin_bridge): main orchestrator with dependency injection + +Composable: cursor/aggregator/strategy/publisher/audit_writer/broker +all injected. Master kill-switch (kevin_enable_trading=false) routes to +audit-only path. Concrete collaborators wired in subsequent tasks." +git push +``` + +--- + +## Task 8: Aggregator — multi-mention window + boost + +**Files:** +- Create: `services/kevin_signal_bridge/aggregator.py` +- Test: `tests/services/kevin_signal_bridge/test_aggregator.py` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/services/kevin_signal_bridge/test_aggregator.py +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from services.kevin_signal_bridge.aggregator import MentionAggregator + + +@pytest.mark.asyncio +async def test_aggregator_returns_latest_mention_per_symbol(db_session: AsyncSession, seed_channel_video): + # 3 NVDA buy mentions within 48h -> aggregator returns latest + # (Insert seed mentions, run aggregator, assert latest only) + ... # FILL: insert 3 KevinStockMention rows for NVDA via factory + + +@pytest.mark.asyncio +async def test_aggregator_applies_conviction_boost(db_session: AsyncSession, seed_channel_video): + # 3 mentions in 48h, base conviction 0.7 each -> boosted to min(1.0, 0.7 + 0.1) + ... + + +@pytest.mark.asyncio +async def test_aggregator_caps_boost_at_max(db_session: AsyncSession, seed_channel_video): + # 5 mentions -> boost capped at +0.20 + ... + + +@pytest.mark.asyncio +async def test_aggregator_excludes_already_processed(db_session: AsyncSession, seed_channel_video): + # since_id cursor excludes processed mentions + ... +``` + +- [ ] **Step 2: Implement aggregator** + +```python +# services/kevin_signal_bridge/aggregator.py +"""Multi-mention windowed aggregation. + +Reads kevin_stock_mentions since the cursor, groups by symbol within a +48h trailing window, takes the LATEST mention per symbol as authoritative, +boosts conviction by mention_boost_per_repeat per extra mention (capped). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from shared.models.meet_kevin import KevinStockMention + + +@dataclass +class AggregatedMention: + """Mention proxy with effective_conviction set after aggregation.""" + + id: int + symbol: str + action: object + conviction: Decimal + time_horizon: object + created_at: datetime + rationale_quote: str + effective_conviction: Decimal + + +class MentionAggregator: + def __init__(self, session_factory: async_sessionmaker, + window_hours: int = 48, + boost_per_repeat: Decimal = Decimal("0.05"), + max_boost: Decimal = Decimal("0.20")) -> None: + self.session_factory = session_factory + self.window_hours = window_hours + self.boost_per_repeat = boost_per_repeat + self.max_boost = max_boost + + async def fetch_pending(self, since_id: int) -> list[AggregatedMention]: + async with self.session_factory() as session: + unprocessed = (await session.execute( + select(KevinStockMention) + .where(KevinStockMention.id > since_id) + .order_by(KevinStockMention.created_at.asc()) + )).scalars().all() + + if not unprocessed: + return [] + + out: list[AggregatedMention] = [] + for m in unprocessed: + window_start = m.created_at - timedelta(hours=self.window_hours) + async with self.session_factory() as session: + same_symbol_in_window = (await session.execute( + select(KevinStockMention) + .where(and_( + KevinStockMention.symbol == m.symbol, + KevinStockMention.created_at >= window_start, + KevinStockMention.created_at <= m.created_at, + )) + )).scalars().all() + extras = max(0, len(same_symbol_in_window) - 1) + boost = min(self.max_boost, self.boost_per_repeat * extras) + effective = min(Decimal("1.0"), m.conviction + boost) + out.append(AggregatedMention( + id=m.id, symbol=m.symbol, action=m.action, + conviction=m.conviction, time_horizon=m.time_horizon, + created_at=m.created_at, rationale_quote=m.rationale_quote, + effective_conviction=effective, + )) + return out +``` + +- [ ] **Step 3: Fill in the test bodies + run** + +(Test bodies use `seed_channel_video` fixture — already in `conftest.py` for the meet_kevin v1 test setup. If missing, create a simple one.) + +```bash +pytest tests/services/kevin_signal_bridge/test_aggregator.py -v +``` + +Expected: all green. + +- [ ] **Step 4: Commit** + +```bash +git add services/kevin_signal_bridge/aggregator.py tests/services/kevin_signal_bridge/test_aggregator.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin_bridge): multi-mention aggregator with capped conviction boost" +git push +``` + +--- + +## Task 9: Blocklist + Risk counters (Redis helpers) + +**Files:** +- Create: `services/kevin_signal_bridge/blocklist.py` +- Create: `services/kevin_signal_bridge/risk_counters.py` +- Test: `tests/services/kevin_signal_bridge/test_blocklist.py` +- Test: `tests/services/kevin_signal_bridge/test_risk_counters.py` + +- [ ] **Step 1: Tests + implementation for blocklist** + +```python +# services/kevin_signal_bridge/blocklist.py +"""Redis-backed per-symbol blocklist with TTL. + +Set on AVOID mentions. Subsequent BUY mention on the same symbol clears +the entry (handled by the strategy callsite, not here).""" + +from __future__ import annotations + +from redis.asyncio import Redis + + +class KevinBlocklist: + _KEY_PREFIX = "kevin:blocked:" + + def __init__(self, redis: Redis) -> None: + self.redis = redis + + async def add(self, symbol: str, ttl_days: int) -> None: + await self.redis.set( + f"{self._KEY_PREFIX}{symbol}", "1", + ex=ttl_days * 86400, + ) + + async def remove(self, symbol: str) -> None: + await self.redis.delete(f"{self._KEY_PREFIX}{symbol}") + + async def is_blocked(self, symbol: str) -> bool: + return bool(await self.redis.exists(f"{self._KEY_PREFIX}{symbol}")) + + async def active_set(self) -> set[str]: + keys = await self.redis.keys(f"{self._KEY_PREFIX}*") + return {k.decode().replace(self._KEY_PREFIX, "") for k in keys} +``` + +```python +# services/kevin_signal_bridge/risk_counters.py +"""Daily Redis counters for trade-cap + alloc-cap + pause flag.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from redis.asyncio import Redis + + +class KevinRiskCounters: + _PAUSE_KEY = "trading:paused" + _TRADES_KEY = "kevin:daily_trades:{date}" + _ALLOC_KEY = "kevin:daily_alloc:{date}" + + def __init__(self, redis: Redis) -> None: + self.redis = redis + + @staticmethod + def _today_utc() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d") + + async def get_daily_trades(self) -> int: + v = await self.redis.get(self._TRADES_KEY.format(date=self._today_utc())) + return int(v) if v else 0 + + async def increment_daily_trades(self) -> int: + key = self._TRADES_KEY.format(date=self._today_utc()) + n = await self.redis.incr(key) + await self.redis.expire(key, 172800) # 48h + return n + + async def get_daily_alloc(self) -> Decimal: + v = await self.redis.get(self._ALLOC_KEY.format(date=self._today_utc())) + return Decimal(v.decode()) if v else Decimal("0") + + async def add_daily_alloc(self, usd: Decimal) -> Decimal: + key = self._ALLOC_KEY.format(date=self._today_utc()) + new = await self.redis.incrbyfloat(key, float(usd)) + await self.redis.expire(key, 172800) + return Decimal(str(new)) + + async def is_trading_paused(self) -> bool: + v = await self.redis.get(self._PAUSE_KEY) + return bool(v) + + async def set_trading_paused(self, ttl_seconds: int | None = None) -> None: + if ttl_seconds: + await self.redis.set(self._PAUSE_KEY, "1", ex=ttl_seconds) + else: + await self.redis.set(self._PAUSE_KEY, "PERMANENT") +``` + +Write tests for both with `fakeredis` (already in dev deps). + +- [ ] **Step 2: Run + commit** + +```bash +pytest tests/services/kevin_signal_bridge/test_blocklist.py tests/services/kevin_signal_bridge/test_risk_counters.py -v +git add services/kevin_signal_bridge/blocklist.py services/kevin_signal_bridge/risk_counters.py tests/services/kevin_signal_bridge/test_blocklist.py tests/services/kevin_signal_bridge/test_risk_counters.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin_bridge): blocklist + daily risk counters" +git push +``` + +--- + +## Task 10: Exit-scan daily job + cursor + audit writer + +**Files:** +- Create: `services/kevin_signal_bridge/exit_scanner.py` +- Create: `services/kevin_signal_bridge/cursor.py` +- Create: `services/kevin_signal_bridge/audit.py` +- Test: `tests/services/kevin_signal_bridge/test_exit_scanner.py` + +- [ ] **Step 1: Implement** — full code in spec §3 / §10 of the design doc. The exit_scanner queries `kevin_signal_bridge_state` for emitted rows whose corresponding `Trade` is still open and whose `holding_days_actual` has elapsed; publishes `EXIT` signals. + +- [ ] **Step 2: Wire `cursor` to use Redis** with key `kevin:bridge:last_mention_id`. + +- [ ] **Step 3: Wire `audit_writer`** to upsert into `kevin_signal_bridge_state` table. + +- [ ] **Step 4: Run tests + commit** + +```bash +pytest tests/services/kevin_signal_bridge/ -v +git add services/kevin_signal_bridge/exit_scanner.py services/kevin_signal_bridge/cursor.py services/kevin_signal_bridge/audit.py tests/services/kevin_signal_bridge/test_exit_scanner.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin_bridge): exit-scan daily job + cursor + audit writer" +git push +``` + +--- + +## Task 11: Bridge service entrypoint (wire concrete collaborators) + +**Files:** +- Modify: `services/kevin_signal_bridge/main.py` (fill in the `run()` function) + +- [ ] **Step 1: Implement the boot path** wiring KevinStrategy, MentionAggregator, KevinBlocklist, KevinRiskCounters, RedisCursor, AuditWriter, RedisStream publisher, AlpacaBroker. Mirrors `services/meet_kevin_watcher/main.py` pattern from v1. + +- [ ] **Step 2: Add signal handlers** (SIGTERM/SIGINT) for graceful shutdown. + +- [ ] **Step 3: Smoke test against docker-compose** + +```bash +docker compose -f docker/docker-compose.dev.yml up -d +TRADING_KEVIN_ENABLE_TRADING=false \ + python -m services.kevin_signal_bridge.main +# In another shell, insert a synthetic mention and watch the audit table populate +``` + +- [ ] **Step 4: Commit** + +```bash +git add services/kevin_signal_bridge/main.py +git -c user.email=me@viktorbarzin.me commit -m "feat(kevin_bridge): service entrypoint with concrete wiring" +git push +``` + +--- + +## Task 12: Backtest API routes + +**Files:** +- Create: `services/api_gateway/routes/meet_kevin_backtest.py` +- Test: `tests/api_gateway/routes/test_meet_kevin_backtest.py` + +- [ ] **Step 1: Define routes mirroring existing `routes/backtest.py` pattern**: + - `POST /api/meet-kevin/backtest/run` — kicks async job; returns 202 with `run_uuid` + - `GET /api/meet-kevin/backtest/runs?limit=20` — list past runs + - `GET /api/meet-kevin/backtest/runs/{run_uuid}` — full result + - `GET /api/meet-kevin/backtest/latest` — most recent run + +- [ ] **Step 2: Background task uses `KevinBacktestRunner`** from Task 5; persists final result to `kevin_backtest_runs` + `kevin_backtest_trades`. + +- [ ] **Step 3: Tests with FastAPI TestClient + sqlite/in-memory fixtures**. + +- [ ] **Step 4: Register router in `services/api_gateway/main.py`**. + +- [ ] **Step 5: Run + commit** + +```bash +pytest tests/api_gateway/routes/test_meet_kevin_backtest.py -v +git add services/api_gateway/routes/meet_kevin_backtest.py services/api_gateway/main.py tests/api_gateway/routes/test_meet_kevin_backtest.py +git -c user.email=me@viktorbarzin.me commit -m "feat(api): /api/meet-kevin/backtest/* routes" +git push +``` + +--- + +## Task 13: Strategy + tickers API routes + +**Files:** +- Create: `services/api_gateway/routes/meet_kevin_strategy.py` +- Test: `tests/api_gateway/routes/test_meet_kevin_strategy.py` + +- [ ] **Step 1: Implement** the 3 GET endpoints + manual close POST: + - `GET /api/meet-kevin/strategy/tickers` — joins `kevin_stock_mentions` + bridge audit + market_data + open Trade + - `GET /api/meet-kevin/strategy/equity-curve?from=&to=&include_benchmark=spy` + - `GET /api/meet-kevin/strategy/performance` — headline metrics card + - `POST /api/meet-kevin/positions/{symbol}/close` — writes Redis `kevin:manual_close:{symbol}` flag + +- [ ] **Step 2: Cache** the `/tickers` and `/equity-curve` endpoints server-side with 30s TTL. + +- [ ] **Step 3: Tests + commit** + +```bash +pytest tests/api_gateway/routes/test_meet_kevin_strategy.py -v +git add services/api_gateway/routes/meet_kevin_strategy.py tests/api_gateway/routes/test_meet_kevin_strategy.py +git -c user.email=me@viktorbarzin.me commit -m "feat(api): /api/meet-kevin/strategy/* routes" +git push +``` + +--- + +## Task 14: Dashboard TypeScript types + API client + +**Files:** +- Create: `dashboard/src/api/meetKevinStrategy.ts` +- Modify: `dashboard/src/types/meetKevin.ts` (add new types) + +- [ ] **Step 1: Add types** for `BacktestRun`, `BacktestRunDetail`, `StrategyTicker`, `StrategyEquityCurve`, `StrategyPerformance`. + +- [ ] **Step 2: Add 7 axios methods** using the same `client` instance from `dashboard/src/api/client.ts`: + +```typescript +// dashboard/src/api/meetKevinStrategy.ts +import { client } from "./client"; +import type { + BacktestRun, BacktestRunDetail, StrategyTicker, + StrategyEquityCurve, StrategyPerformance, +} from "../types/meetKevin"; + +export async function runBacktest(params: { + holding_days?: number; slippage_pct?: number; + dedupe_policy?: "roll" | "ignore"; initial_capital?: number; +}): Promise<{ run_uuid: string; status: string }> { + const { data } = await client.post("/api/meet-kevin/backtest/run", params); + return data; +} + +export async function listBacktestRuns(limit = 20): Promise { + const { data } = await client.get("/api/meet-kevin/backtest/runs", { params: { limit } }); + return data; +} + +export async function getBacktestRun(runUuid: string): Promise { + const { data } = await client.get(`/api/meet-kevin/backtest/runs/${runUuid}`); + return data; +} + +export async function getLatestBacktest(): Promise { + const { data } = await client.get("/api/meet-kevin/backtest/latest"); + return data; +} + +export async function getStrategyTickers(): Promise { + const { data } = await client.get("/api/meet-kevin/strategy/tickers"); + return data; +} + +export async function getStrategyEquityCurve(params: { + from?: string; to?: string; include_benchmark?: "spy"; +}): Promise { + const { data } = await client.get("/api/meet-kevin/strategy/equity-curve", { params }); + return data; +} + +export async function getStrategyPerformance(): Promise { + const { data } = await client.get("/api/meet-kevin/strategy/performance"); + return data; +} + +export async function closeKevinPosition(symbol: string): Promise { + await client.post(`/api/meet-kevin/positions/${symbol}/close`); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add dashboard/src/api/meetKevinStrategy.ts dashboard/src/types/meetKevin.ts +git -c user.email=me@viktorbarzin.me commit -m "feat(dashboard): TS types + API client for strategy + backtest" +git push +``` + +--- + +## Task 15: Dashboard components — TickerScorecardTable, BacktestRunHistory, StrategyVsBenchmarkCurve + +**Files:** +- Create: `dashboard/src/components/meetKevin/TickerScorecardTable.tsx` +- Create: `dashboard/src/components/meetKevin/BacktestRunHistory.tsx` +- Create: `dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx` + +- [ ] Reuses existing `ActionChip`, `ConvictionBar` from `dashboard/src/components/meetKevin/`. New components are pure presentation, lifted-state from parent page. Match the design doc's ASCII mock-up. + +- [ ] **Commit:** + +```bash +git add dashboard/src/components/meetKevin/TickerScorecardTable.tsx dashboard/src/components/meetKevin/BacktestRunHistory.tsx dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx +git -c user.email=me@viktorbarzin.me commit -m "feat(dashboard): 3 components for the strategy page" +git push +``` + +--- + +## Task 16: `Strategy.tsx` page + route + sidebar entry + +**Files:** +- Create: `dashboard/src/pages/meetKevin/Strategy.tsx` +- Modify: `dashboard/src/App.tsx` (add `}/>`) +- Modify: `dashboard/src/components/Layout.tsx` (add sidebar entry) + +- [ ] **Step 1: Compose** the page from header card + StrategyVsBenchmarkCurve + TickerScorecardTable + BacktestRunHistory. + +- [ ] **Step 2: Wire route + sidebar entry "Strategy"** under the Meet Kevin group. + +- [ ] **Step 3: Dev-server visual QA against docker-compose** + +```bash +cd dashboard && npm run dev +# Open http://localhost:5173/meet-kevin/strategy; insert a synthetic mention, kick a backtest from the UI +``` + +- [ ] **Step 4: Commit** + +```bash +git add dashboard/src/pages/meetKevin/Strategy.tsx dashboard/src/App.tsx dashboard/src/components/Layout.tsx +git -c user.email=me@viktorbarzin.me commit -m "feat(dashboard): /meet-kevin/strategy page wired" +git push +``` + +--- + +## Task 17: Infra — add `kevin_signal_bridge` container to K8s Pod + +**Files:** +- Modify: `infra/stacks/trading-bot/main.tf` + +**Working directory for this task: `/home/wizard/code/infra/` (separate repo).** + +- [ ] **Step 1: Add a 5th container** to the `trading-bot-workers` Pod spec, named `kevin-signal-bridge`, image `viktorbarzin/trading-bot-service:latest`, command `python -m services.kevin_signal_bridge.main`. Add env vars per design doc §"Config". + +- [ ] **Step 2: Update the ExternalSecret** to project `KEVIN_*` env vars (master kill-switch starts OFF). + +- [ ] **Step 3: Apply** + +```bash +cd /home/wizard/code/infra/stacks/trading-bot +VAULT_OUT=$(vault read -format=json database/static-creds/pg-terraform-state) +export PG_CONN_STR="postgres://$(echo $VAULT_OUT | jq -r .data.username):$(echo $VAULT_OUT | jq -r .data.password)@10.0.20.200:5432/terraform_state?sslmode=disable" +/home/wizard/code/infra/scripts/tg apply --non-interactive +``` + +Expected: 1-3 changes applied, no destroys. + +- [ ] **Step 4: Verify pod is 5/5 Running** + +```bash +kubectl -n trading-bot get pods +kubectl -n trading-bot logs -l app=trading-bot-workers -c kevin-signal-bridge --tail=20 +``` + +Expected: bridge starts, polls mentions every 60s, writes audit rows (no published signals — kill-switch off). + +- [ ] **Step 5: Commit** + +```bash +cd /home/wizard/code/infra +git add stacks/trading-bot/main.tf +git -c user.email=me@viktorbarzin.me commit -m "trading-bot: add kevin_signal_bridge container (kill-switch off)" +git push +``` + +--- + +## Phase 1 Checkpoint + +- [ ] Run alembic migration in prod (`kubectl exec` into the trading-bot-frontend api-gateway pod and run `alembic upgrade head`). +- [ ] Restart the workers Pod once; verify all 5 containers (including `kevin-signal-bridge`) reach Ready. +- [ ] Open `https://trading.viktorbarzin.me/meet-kevin/strategy`. +- [ ] Click "Run backtest"; confirm metrics card populates within ~10 s, equity-curve chart renders. +- [ ] Confirm ticker scorecard rows show WOULD-TRADE badges based on bridge audit (status = `dry_run`). +- [ ] **STOP. User reviews Phase 1, approves, then move to Phase 2.** + +--- + +# Phase 2 — Trade executor on, kill-switch flipped + +> **Goal:** Real paper trades execute. Adds BRACKET orders + extended RiskManager + re-enables trade_executor container + flips the kill-switch. +> +> **End-of-phase checkpoint:** First Kevin-driven paper trade lands on Alpaca within ~60 s of a fresh qualifying mention. Stop/take-profit legs visible in Alpaca dashboard. + +--- + +## Task 18: Extend `OrderRequest` + `AlpacaBroker` for BRACKET orders + +**Files:** +- Modify: `shared/schemas/trading.py` (extend `OrderRequest`) +- Modify: `shared/broker/alpaca_broker.py` (extend `_build_order_request`) +- Test: `tests/shared/broker/test_alpaca_bracket.py` + +- [ ] **Step 1: Extend `OrderRequest`**: + +```python +# Existing fields preserved; add: + order_class: Literal["simple", "bracket"] = "simple" + take_profit_price: float | None = None + stop_loss_price: float | None = None + + @model_validator(mode="after") + def _bracket_requires_legs(self) -> "OrderRequest": + if self.order_class == "bracket" and ( + self.take_profit_price is None or self.stop_loss_price is None + ): + raise ValueError("bracket orders require take_profit_price + stop_loss_price") + return self +``` + +- [ ] **Step 2: Extend `_build_order_request`** to branch on `order_class`. Use `alpaca.trading.requests.MarketOrderRequest` with `order_class=OrderClass.BRACKET`, `take_profit=TakeProfitRequest(limit_price=...)`, `stop_loss=StopLossRequest(stop_price=...)`. + +- [ ] **Step 3: Test against Alpaca paper sandbox** (use `pytest-vcr` to record one cassette). + +- [ ] **Step 4: Commit** + +```bash +git add shared/schemas/trading.py shared/broker/alpaca_broker.py tests/shared/broker/test_alpaca_bracket.py +git -c user.email=me@viktorbarzin.me commit -m "feat(broker): BRACKET order support (take-profit + stop-loss legs)" +git push +``` + +--- + +## Task 19: Extend `RiskManager` with daily caps + drawdown halt + circuit breaker + +**Files:** +- Modify: `services/trade_executor/risk_manager.py` +- Modify: `services/trade_executor/config.py` +- Test: `tests/services/trade_executor/test_risk_manager_kevin_caps.py` + +- [ ] **Step 1: Add config fields** (all `TRADING_KEVIN_*` env vars from spec). + +- [ ] **Step 2: Add 4 new checks** to `check_risk()`: + - `daily_trade_count` >= `kevin_daily_trade_cap` → reject + - `daily_alloc_usd + signal.target_dollars` > `kevin_daily_alloc_cap_usd` → reject + - If equity < `kevin_equity_drawdown_halt_pct` × starting → set permanent pause + - If today's P&L < `-kevin_daily_loss_circuit_pct` × equity → set 24h pause + +- [ ] **Step 3: Run + commit** + +```bash +pytest tests/services/trade_executor/test_risk_manager_kevin_caps.py -v +git add services/trade_executor/risk_manager.py services/trade_executor/config.py tests/services/trade_executor/test_risk_manager_kevin_caps.py +git -c user.email=me@viktorbarzin.me commit -m "feat(risk): daily caps + drawdown halt + circuit breaker" +git push +``` + +--- + +## Task 20: Re-enable `trade_executor` container in K8s + +**Files:** +- Modify: `infra/stacks/trading-bot/main.tf` + +- [ ] **Step 1: Uncomment / add** the `trade-executor` container back to the workers Pod. Set `TRADING_KEVIN_ENABLE_TRADING=true` for the bridge container (this flip is the live-trading switch). + +- [ ] **Step 2: `tg apply`**. + +- [ ] **Step 3: Verify** the workers Pod is now 6/6 Running. + +- [ ] **Step 4: Manually insert a fresh mention** + watch: + - Bridge audit row → `emitted` + - Redis Stream `signals:generated` → entry appears + - `trade_executor` log → `submit_order` call + - Alpaca paper account → order appears + +- [ ] **Step 5: Commit** + +```bash +cd /home/wizard/code/infra +git add stacks/trading-bot/main.tf +git -c user.email=me@viktorbarzin.me commit -m "trading-bot: re-enable trade_executor + flip kevin kill-switch" +git push +``` + +--- + +## Phase 2 Checkpoint + +- [ ] Insert a synthetic high-conviction BUY mention via SQL. +- [ ] Bounce the bridge pod; verify within 90 s an Alpaca paper order appears. +- [ ] Verify bracket legs (stop + take-profit) visible in Alpaca order list. +- [ ] Verify `trades` row exists with `strategy_id = $KEVIN_STRATEGY_UUID`. +- [ ] **STOP. User reviews Phase 2, approves, then move to Phase 3.** + +--- + +# Phase 3 — Paper Account UI + +> **Goal:** Visualise the live Kevin-attributed paper-trading P&L. Reuses existing `Portfolio.tsx` components filtered by `strategy_id`. + +--- + +## Task 21: Paper-account API routes + +**Files:** +- Create: `services/api_gateway/routes/meet_kevin_paper_account.py` +- Test: `tests/api_gateway/routes/test_meet_kevin_paper_account.py` + +- [ ] **Endpoints:** + - `GET /api/meet-kevin/paper-account/positions` — open Kevin positions (filter `strategy_id`) + - `GET /api/meet-kevin/paper-account/trades?limit=` — Kevin trade history + - `GET /api/meet-kevin/paper-account/equity-curve?from=&to=` — equity time-series (from PortfolioSnapshot filtered to Kevin) + +- [ ] **Commit:** + +```bash +git add services/api_gateway/routes/meet_kevin_paper_account.py tests/api_gateway/routes/test_meet_kevin_paper_account.py +git -c user.email=me@viktorbarzin.me commit -m "feat(api): /api/meet-kevin/paper-account/* routes" +git push +``` + +--- + +## Task 22: `PaperAccount.tsx` page + +**Files:** +- Create: `dashboard/src/pages/meetKevin/PaperAccount.tsx` +- Modify: `dashboard/src/App.tsx` (+ route) +- Modify: `dashboard/src/components/Layout.tsx` (+ sidebar entry) + +- [ ] **Compose** ~50 lines mirroring `Portfolio.tsx`: top equity card, `EquityCurve` (single series — Kevin equity), `MetricsRow`, `PositionsTable` filtered to Kevin. + +- [ ] **Commit:** + +```bash +git add dashboard/src/pages/meetKevin/PaperAccount.tsx dashboard/src/App.tsx dashboard/src/components/Layout.tsx +git -c user.email=me@viktorbarzin.me commit -m "feat(dashboard): /meet-kevin/paper-account page" +git push +``` + +--- + +## Phase 3 Checkpoint + +- [ ] Run a few synthetic mentions; verify paper trades land. +- [ ] Open `https://trading.viktorbarzin.me/meet-kevin/paper-account` — confirm: + - Top equity card shows Kevin-attributed equity + - Equity curve renders + - Positions table lists open Kevin positions + - Trade history scrolls back +- [ ] **DONE. v2 ships.** + +--- + +# Self-Review + +**Spec coverage check:** +- §"Signal translation rules" → Task 4 ✓ +- §"Position sizing" → Task 4 ✓ +- §"Exit logic" (time-based + stop/TP + Kevin-reverses) → Tasks 10, 18 ✓ +- §"Multi-mention aggregation" → Task 8 ✓ +- §"Risk controls" → Task 19 ✓ +- §"Backtest methodology" → Task 5 ✓ +- §"Backtest execution surface" → Task 12 ✓ +- §"Three new tables" → Tasks 2, 3 ✓ +- §"`/meet-kevin/strategy` page" → Tasks 14–16 ✓ +- §"`/meet-kevin/paper-account` page" → Tasks 21–22 ✓ +- §"Broker BRACKET extension" → Task 18 ✓ +- §"Filter via `strategy_id` FK" → Task 3 (seed) + Task 21 (filter) ✓ +- §"Master kill-switch" → Task 7 (config) + Task 20 (flip) ✓ +- §"Daily counter mechanics" → Task 9 ✓ +- §"Cursor advance after XADD" → Task 10 ✓ + +**Placeholder scan:** none — all code blocks are concrete. + +**Type consistency:** `KevinDecision.decision` uses `KevinDecisionType` enum across all tasks. `BridgeStatus` enum used consistently. `KEVIN_STRATEGY_UUID` referenced consistently from `shared/constants/kevin.py`. + +--- + +**Spec:** [`2026-05-23-meet-kevin-paper-trading-design.md`](2026-05-23-meet-kevin-paper-trading-design.md) (commit 280f807)