trading/docs/plans/2026-05-23-meet-kevin-paper-trading-plan.md
Viktor Barzin c83f13625b
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
add Meet Kevin v2 implementation plan (3 phases, 22 tasks)
Maps the design doc (commit 280f807) to bite-sized tasks. Phase 1 ships
strategy + backtest + bridge in audit-only mode; Phase 2 extends
OrderRequest/AlpacaBroker for BRACKET orders, extends RiskManager, and
flips the kill-switch; Phase 3 ships the paper-account UI page.

Each task has Test → Run-and-fail → Implement → Run-and-pass → Commit
steps with concrete code in every step. Implementer can pick up any
task without prior session context.
2026-05-24 00:40:20 +00:00

3230 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<BacktestRun[]> {
const { data } = await client.get("/api/meet-kevin/backtest/runs", { params: { limit } });
return data;
}
export async function getBacktestRun(runUuid: string): Promise<BacktestRunDetail> {
const { data } = await client.get(`/api/meet-kevin/backtest/runs/${runUuid}`);
return data;
}
export async function getLatestBacktest(): Promise<BacktestRunDetail> {
const { data } = await client.get("/api/meet-kevin/backtest/latest");
return data;
}
export async function getStrategyTickers(): Promise<StrategyTicker[]> {
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<StrategyEquityCurve> {
const { data } = await client.get("/api/meet-kevin/strategy/equity-curve", { params });
return data;
}
export async function getStrategyPerformance(): Promise<StrategyPerformance> {
const { data } = await client.get("/api/meet-kevin/strategy/performance");
return data;
}
export async function closeKevinPosition(symbol: string): Promise<void> {
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 `<Route path="/meet-kevin/strategy" element={<Strategy/>}/>`)
- 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 1416 ✓
- §"`/meet-kevin/paper-account` page" → Tasks 2122 ✓
- §"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)