Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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.
3230 lines
112 KiB
Markdown
3230 lines
112 KiB
Markdown
# 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 14–16 ✓
|
||
- §"`/meet-kevin/paper-account` page" → Tasks 21–22 ✓
|
||
- §"Broker BRACKET extension" → Task 18 ✓
|
||
- §"Filter via `strategy_id` FK" → Task 3 (seed) + Task 21 (filter) ✓
|
||
- §"Master kill-switch" → Task 7 (config) + Task 20 (flip) ✓
|
||
- §"Daily counter mechanics" → Task 9 ✓
|
||
- §"Cursor advance after XADD" → Task 10 ✓
|
||
|
||
**Placeholder scan:** none — all code blocks are concrete.
|
||
|
||
**Type consistency:** `KevinDecision.decision` uses `KevinDecisionType` enum across all tasks. `BridgeStatus` enum used consistently. `KEVIN_STRATEGY_UUID` referenced consistently from `shared/constants/kevin.py`.
|
||
|
||
---
|
||
|
||
**Spec:** [`2026-05-23-meet-kevin-paper-trading-design.md`](2026-05-23-meet-kevin-paper-trading-design.md) (commit 280f807)
|