From 280f807236e3815b06ed6ef4a9544b738de5f27c Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 23 May 2026 10:04:04 +0000 Subject: [PATCH] add Meet Kevin v2 paper-trading + backtest + UI design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesizes work of two parallel architect agents (strategy + paper-trading rules / backtest + UI surface) and the subsequent challenger review. Resolves 11 issues the challenger raised: - KevinStrategy is standalone, not BaseStrategy subclass (signature mismatch — BaseStrategy.evaluate is bar-driven, Kevin is event-driven) - backtester/kevin_backtest.py as parallel mention-driven mini-engine, not a fake adapter onto BacktestEngine - AlpacaBroker BRACKET support specified (OrderRequest schema + broker _build_order_request extensions) - Filtering paper-account trades via strategy_id FK (the actual field; Trade.strategy_name doesn't exist) — migration seeds a 'kevin' row - Cursor advance race fixed (XADD success → cursor advance) - Daily counter mechanics specified (Redis INCR + audit dedupe) - kevin_signal_bridge_state table added to data model (3 new tables now) - All PKs UUID for consistency with Trade/Position - StrategyVsBenchmarkCurve.tsx promoted from contingent to definitely-new - 'avoid' policy split into AVOID_CLOSES_LONGS + AVOID_BLOCKS_DAYS knobs - Phasing collapsed A+B into Phase 1 (ticker scorecard needs bridge audit rows to render WOULD-TRADE badges) --- ...6-05-23-meet-kevin-paper-trading-design.md | 633 ++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 docs/plans/2026-05-23-meet-kevin-paper-trading-design.md diff --git a/docs/plans/2026-05-23-meet-kevin-paper-trading-design.md b/docs/plans/2026-05-23-meet-kevin-paper-trading-design.md new file mode 100644 index 0000000..736d21b --- /dev/null +++ b/docs/plans/2026-05-23-meet-kevin-paper-trading-design.md @@ -0,0 +1,633 @@ +# Meet Kevin v2 — Paper Trading + Backtest + Strategy UI — Design + +> Synthesises the work of two parallel architect agents: +> - `kevin-strategy-design.md` (strategy + paper-trading rules) — Architect A +> - `kevin-backtest-ui-design.md` (backtest + UI surface) — Architect B + +## Goal + +Translate Meet Kevin's per-mention signals into automated Alpaca paper trades, surface the strategy's performance against a benchmark in a dedicated UI, and prove it works through a backtest before any real money would be considered. + +## Premise + +A new `kevin_stock_mentions` row is the primary stimulus. Everything else is plumbing. **Long-only on Alpaca paper for v1.** + +## Scope summary + +| Area | Owner | New / Existing | +|---|---|---| +| Strategy logic (signal → trade) | `shared/strategies/kevin.py` (NEW) | `BaseStrategy` extension | +| Mention → signal bridging | `services/kevin_signal_bridge/` (NEW) | New service container | +| Trade execution | `services/trade_executor/` | EXISTING — re-enable, no behavior change | +| Risk controls | `services/trade_executor/risk_manager.py` | EXISTING — extend in place | +| Backtest engine | `backtester/` | EXISTING — add `KevinMentionAdapter` | +| Metrics | `backtester/metrics.py` | EXISTING — extend `compute_metrics` | +| Backtest API + persistence | `services/api_gateway/routes/meet_kevin_backtest.py` (NEW) + 2 tables | New | +| Strategy UI page | `dashboard/src/pages/meetKevin/Strategy.tsx` (NEW) | New | +| Paper account UI page | `dashboard/src/pages/meetKevin/PaperAccount.tsx` (NEW) | ~50-line clone of `Portfolio.tsx` | + +Total new files: ~12. Total modified files: ~6. Estimated implementer time: 6–8 hours across a subagent team. + +--- + +## Architecture + +``` +Postgres +┌──────────────────────┐ +│ kevin_stock_mentions │ ← (v1 watcher writes; unchanged) +└──────────┬───────────┘ + │ poll every 60s + ▼ +┌──────────────────────────────────────────────────────────┐ +│ services/kevin_signal_bridge/ NEW │ +│ - cursor in Redis (kevin:bridge:last_mention_id) │ +│ - applies signal filter (action, conviction, age) │ +│ - aggregates multi-mention boost │ +│ - checks Redis blocklist + per-day caps │ +│ - calls KevinStrategy.evaluate(mention, snapshot) │ +│ - daily exit-scan job @ 09:35 ET (time-based exits) │ +│ - watches trades:executed for fill confirmations │ +│ - master kill-switch: TRADING_KEVIN_ENABLE_TRADING=off │ +└────────────────┬─────────────────────────────────────────┘ + │ publishes TradeSignal with + │ strategy_sources=["kevin::"] + ▼ + Redis Stream signals:generated + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ services/trade_executor/ EXISTING │ +│ - RiskManager.check_risk (extended with new caps) │ +│ - AlpacaBroker.submit_order (BRACKET: stop+take-profit)│ +│ - Trade persistence with strategy_name="kevin" │ +│ - publishes trades:executed │ +└────────────────┬─────────────────────────────────────────┘ + │ + ▼ + Alpaca Paper (long-only, BRACKET orders) + │ + ▼ + Redis Stream trades:executed + │ + ▼ + portfolio_sync (existing) — equity curve, positions + +Parallel observability path +┌──────────────────────────────────────────────────────────┐ +│ services/api_gateway/routes/meet_kevin_backtest.py NEW │ +│ - POST /backtest/run → async job, polls Redis status │ +│ - calls backtester/engine.BacktestEngine with │ +│ KevinMentionAdapter(KevinStrategy) │ +│ - persists final run to kevin_backtest_runs │ +│ + per-trade detail in kevin_backtest_trades │ +│ - meet_kevin_watcher gains a daily post-poll cron │ +│ stage that triggers the scheduled run │ +└──────────────────────────────────────────────────────────┘ +``` + +### Key architectural rule + +**One source of truth for strategy behavior: `shared/strategies/kevin.py::KevinStrategy`.** + +`KevinStrategy` is a **stand-alone class**, NOT a `BaseStrategy` subclass — the existing `BaseStrategy.evaluate(ticker, market: MarketSnapshot, sentiment)` signature is bar/article-driven and would force the Kevin code to fabricate fake `MarketSnapshot` objects. Instead `KevinStrategy` exposes: + +```python +async def evaluate_mention( + self, + mention: KevinStockMention, + account_state: KevinAccountState, +) -> KevinDecision | None +``` + +Both the live `kevin_signal_bridge` AND the backtest engine call this method directly. The unification guarantee comes from "one class, one method, called by both" — not from an inheritance decoration. This applies to: + +- Action filtering (which actions trigger trades) +- Conviction floor +- Position sizing (conviction-weighted) +- Holding period (time_horizon-derived) +- Multi-mention aggregation (latest-wins + conviction boost) +- Long-only treatment (sell/avoid semantics) + +The backtest *parameterises* slippage + commission + initial capital (per-run knobs) but **never overrides** strategy logic. + +--- + +## Signal translation rules + +A `kevin_stock_mentions` row produces (or cancels) exactly one `TradeSignal` when **all** of: + +| Condition | Threshold | Rationale | +|---|---|---| +| `action` ∈ {buy, sell, avoid} | Mandatory | hold/watch never trade — UI-only | +| `conviction` | `>= 0.60` | Kevin makes many low-conviction asides | +| `time_horizon` | not `intraday` | 3h poll cadence can't catch intraday | +| Age of mention | `<= 48h` since `created_at` | Past 48h, thesis is stale; backtest handles older | +| Symbol tradable on Alpaca | `broker.get_asset(symbol).tradable == True` | Skip OTC / foreign / crypto | +| `asset.class_ == 'us_equity'` | Hardcoded | v1: no crypto, no options | + +**`time_horizon` → target hold period** (used for time-based exit): + +| `time_horizon` | Trading days held | +|---|---| +| `days` | 3 | +| `weeks` | 10 | +| `months` | 45 | +| `long_term` | 90 (re-enter via fresh mention if Kevin re-affirms) | +| `unspecified` | 10 (default to weeks — Kevin's modal horizon) | +| `intraday` | filtered out | + +**Long-only direction mapping:** + +| Action | Currently held? | Behavior | +|---|---|---| +| `buy` | no | Open at sized target | +| `buy` | yes, below per-ticker cap | Top up to cap | +| `buy` | yes, at cap | No-op | +| `sell` | yes | Emit market SELL for full position | +| `sell` | no | No-op (never short in v1) | +| `avoid` | yes | Emit market SELL (controlled by `AVOID_CLOSES_LONGS=true`) + add Redis blocklist `kevin:blocked:{symbol}` with TTL `AVOID_BLOCKS_DAYS=7` | +| `avoid` | no | Add to blocklist; no trade | + +**Note on `avoid` policy:** the close-action and the blocklist are split into two env knobs (`TRADING_KEVIN_AVOID_CLOSES_LONGS`, `TRADING_KEVIN_AVOID_BLOCKS_DAYS`). A subsequent `buy` mention on the same ticker **clears** the blocklist early — "latest mention wins" applies symmetrically. +| `hold` | — | Never trade | +| `watch` | — | Never trade | + +--- + +## Position sizing + +Conviction-weighted fixed-fractional: + +``` +base_pct = 0.04 # 4% of equity for max-conviction +conviction_mult = (conviction - 0.6) / 0.4 # 0.6 → 0.0; 1.0 → 1.0 +target_pct = base_pct * (0.5 + 0.5 * conviction_mult) +target_dollars = clamp(account.equity * target_pct, MIN_USD, MAX_USD) +qty = floor(target_dollars / current_price) +``` + +| Conviction | `target_pct` | $ for $100k equity | +|---|---|---| +| 0.60 | 2.0% | $2,000 | +| 0.80 | 3.0% | $3,000 | +| 1.00 | 4.0% | $4,000 | + +**Hard caps:** +- Per-trade floor: $500 +- Per-trade ceiling: $5,000 (5% of $100k starting equity) +- Per-ticker total cost basis ceiling: $7,500 (absorbs multi-mention boost) + +Rejected Kelly sizing (no historical edge data yet). Revisit after backtest reports per-conviction-bucket hit rates. + +--- + +## Exit logic + +Closes on **first** of: + +1. **Time-based:** `holding_days` for that mention's `time_horizon` (table above). Daily cron in `kevin_signal_bridge` at 09:35 ET scans open Kevin trades, emits SELL. +2. **Stop-loss:** -8% from entry. Implemented as Alpaca BRACKET order (`order_class=BRACKET, stop_loss=...`). **Requires extending the broker abstraction** — see §"Broker extension" below. +3. **Take-profit:** +20% from entry. Implemented as Alpaca BRACKET order's `take_profit` leg. +4. **Kevin reverses:** new `sell` or `avoid` mention on the same ticker → cancel bracket legs + market SELL. +5. **Manual close:** `POST /api/meet-kevin/positions/{symbol}/close` (sets a `kevin:manual_close:{symbol}` Redis flag the bridge reads). + +**Not implemented (deliberate YAGNI):** trailing stops, partial scaling out, end-of-week rebalance. + +### Broker extension (required for BRACKET orders) + +`AlpacaBroker._build_order_request` currently emits only `MarketOrderRequest`/`LimitOrderRequest`/`StopOrderRequest` (no BRACKET). `OrderRequest` schema in `shared/schemas/trading.py` lacks `take_profit_price` / `stop_loss_price` / `order_class` fields. + +Required changes: +- Extend `OrderRequest` with optional `take_profit_price: Decimal | None`, `stop_loss_price: Decimal | None`, `order_class: Literal["simple","bracket"] = "simple"`. +- `_build_order_request` branches on `order_class`: when `bracket`, returns `MarketOrderRequest(order_class=OrderClass.BRACKET, take_profit=TakeProfitRequest(limit_price=...), stop_loss=StopLossRequest(stop_price=...))`. +- Cancellation: `broker.cancel_order` already exists; for Kevin-reverses we cancel the parent bracket which auto-cancels the legs. + +--- + +## Multi-mention aggregation + +Kevin mentions NVDA `buy` three times in 48 hours: + +- **Latest mention wins** the action + time_horizon (most recent thesis is canonical). +- **Conviction boost:** `effective_conviction = min(1.0, latest.conviction + 0.05 * (extra_mentions_in_48h))`, capped at +0.20 (i.e. 4 extra mentions). +- **One position per ticker per active window.** No 3x position size. +- If the latest is `sell`/`avoid`, earlier `buy`s are dead — exit immediately. + +Aggregation is done in `kevin_signal_bridge` (windowed SQL over `kevin_stock_mentions`); `KevinStrategy` itself stays stateless and per-mention. + +--- + +## Risk controls + +Extends `services/trade_executor/risk_manager.py::RiskManager.check_risk`. **All hard caps, all blocking, all configurable:** + +| Control | Default | Notes | +|---|---|---| +| Max position size per ticker | 7.5% of equity | Per-ticker cap absorbs multi-mention boost | +| Max total long exposure | 60% of equity | Existing `max_total_exposure_pct` | +| Max concurrent positions | 15 | Existing `max_positions` (lowered from 20) | +| Max NEW trades / day | 5 | New Redis counter `kevin:daily_trades:{YYYYMMDD}` (atomic `INCR`) | +| Max NEW $ allocated / day | $15,000 | New Redis sum counter `kevin:daily_alloc:{YYYYMMDD}` (atomic `INCRBY`) | + +**Daily counter mechanics:** +- Owner: `kevin_signal_bridge` writes the counters **after** `trades:executed` confirms a fill (consumes the stream). +- Reset: keys have `EXPIRE 172800` (48h) so they self-clean. `RiskManager.check_risk` reads `kevin:daily_trades:{YYYYMMDD using UTC date}` for today. +- Idempotency: counter increment is keyed off `Trade.id`, deduplicated by an `INSERT … ON CONFLICT DO NOTHING` audit row in `kevin_signal_bridge_state`. +| Daily P&L circuit breaker | -3% of equity | Sets `trading:paused=1` until next market open | +| Account drawdown halt | Equity < 80% of starting | Sets `trading:paused=PERMANENT` until manually cleared | +| Per-ticker cooldown after exit | 60 min | Bumped from 30 | +| Sector concentration | NOT enforced v1 | Alpaca asset metadata lacks sector — YAGNI | + +The circuit breaker + drawdown halt are new periodic checks running inside `kevin_signal_bridge` (writing `trading:paused` which `RiskManager.check_risk` already honours). + +--- + +## Backtest engine + +### Methodology + +| Knob | Default | Rationale | +|---|---|---| +| Mention window | `[channel_first_polled_at, run_end)` | Use everything we have | +| Entry fill | next trading session OPEN (T+1) | Avoids look-ahead bias | +| Exit fill | OPEN of `entry_session + holding_days_for_that_mention` | `holding_days` is per-mention from strategy spec | +| Price source | Alpaca daily bars (IEX feed) via `market_data` cache, lazy back-fetch | Same wrapper as existing `routes/backtest.py::_fetch_alpaca_bars` | +| Slippage | 5 bps symmetric | `slippage_pct = 0.0005` | +| Commission | $0 (Alpaca paper) | | +| Initial capital | $100,000 | Match Alpaca paper default | +| Benchmark | SPY buy-and-hold over `[first_mention_at, run_end]` window | | +| Walk-forward vs single-pass | Single-pass | <30 days of data — walk-forward statistically meaningless | +| Dedupe policy | `roll` (latest mention extends exit) | Mirrors live `KevinStrategy` aggregation | + +### Backtest implementation: separate mention-driven engine + +The existing `BacktestEngine` in `backtester/engine.py` is **bar-driven** (iterates daily bars, evaluates all strategies per tick). The Kevin backtest is **event-driven** (mention → T+1 open entry → time-based exit). Rather than fabricate per-bar evaluations or graft a mention loop onto a bar engine, write a parallel mini-engine: + +``` +backtester/kevin_backtest.py ~150 lines + - takes (KevinStrategy, list[KevinStockMention], price_loader, params) + - walks mentions chronologically + - calls strategy.evaluate_mention() per mention to get KevinDecision + - simulates fills at T+1 open (entry) and exit_date open (exit) + - tracks portfolio state, equity curve, trade log + - emits BacktestResult (reusing existing dataclass) + - reuses compute_metrics() for headline numbers +``` + +Keep `BacktestEngine` untouched — the 9 existing strategies still backtest there. `compute_metrics` is the shared utility both engines call. + +### Metrics + +`backtester/metrics.py::compute_metrics` extended **in place** (no fork) with these additional fields: + +| Field | Formula | +|---|---| +| `total_return_pct` | `(final - initial) / initial * 100` | +| `annualized_return_pct` | `(final/initial)^(365.25/days) - 1) * 100` | +| `sharpe_ratio` | `mean(daily_ret) / stddev(daily_ret) * sqrt(252)`, rf=0 | +| `max_drawdown_pct` | `max((peak - trough) / peak)` over equity curve | +| `win_rate_pct` | `wins / closed_trades * 100` | +| `avg_winner_pct` | `mean(pnl_pct where pnl > 0)` | +| `avg_loser_pct` | `mean(pnl_pct where pnl <= 0)` | +| `best_trade` / `worst_trade` | `{symbol, pnl_pct}` | +| `alpha_vs_spy_pct` | `total_return_pct - spy_return_pct` | +| `beta_vs_spy` | `cov(daily_ret, spy_daily_ret) / var(spy_daily_ret)` | + +### Execution surface + +- **On-demand:** `POST /api/meet-kevin/backtest/run` → 202 `{run_uuid, status: running}`. Async via `asyncio.Task`; Redis stores `kevin:backtest:status:{uuid}` with 24h TTL for the dashboard's 2s poll. +- **Scheduled:** the `meet_kevin_watcher` service gains a post-analysis cron stage that calls the same internal `run_backtest_for_kevin_strategy()` helper. This produces the canonical "latest" run the UI defaults to. +- **Persistence:** final result lives in `kevin_backtest_runs` + `kevin_backtest_trades` (Postgres). Redis is only the in-flight surface. + +### Schema additions (1 Alembic migration) + +Three new tables (all `uuid` PKs for consistency with `Trade` / `Position` / `Signal`): + +```sql +kevin_signal_bridge_state + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + mention_id bigint UNIQUE NOT NULL REFERENCES kevin_stock_mentions(id), + bridge_status enum('emitted','skipped_non_tradable','skipped_blocklist', + 'skipped_caps','deferred','broker_rejected','dry_run') NOT NULL, + signal_id uuid REFERENCES signals(id), -- nullable; null on skip + trade_id uuid REFERENCES trades(id), -- nullable; null until fill + effective_conviction numeric(4,3), -- after multi-mention boost + decided_at timestamptz NOT NULL DEFAULT now(), + notes text, + INDEX (mention_id), + INDEX (bridge_status, decided_at DESC); + +kevin_backtest_runs + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_uuid uuid UNIQUE NOT NULL, + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, + status enum('running','completed','failed') NOT NULL, + trigger_source enum('manual','scheduled') NOT NULL DEFAULT 'manual', + params_json jsonb NOT NULL, + metrics_json jsonb, + equity_curve_json jsonb, + benchmark_curve_json jsonb, + error_message text, + INDEX (started_at DESC), + INDEX (status, started_at DESC); + +kevin_backtest_trades + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + run_id uuid NOT NULL REFERENCES kevin_backtest_runs(id) ON DELETE CASCADE, + symbol varchar(16) NOT NULL, + source_mention_id bigint REFERENCES kevin_stock_mentions(id), + entry_at timestamptz NOT NULL, + entry_price numeric(12,4) NOT NULL, + exit_at timestamptz, + exit_price numeric(12,4), + qty numeric(14,4) NOT NULL, + pnl_usd numeric(14,4), + pnl_pct numeric(8,4), + holding_days_actual int, + INDEX (run_id, symbol), + INDEX (run_id, entry_at); +``` + +**Not reusing the existing `trades` table** — those are real fills; backtest trades are hypothetical history; mixing them breaks consumer assumptions. + +--- + +## UI surface + +Two new pages under the existing `/meet-kevin/*` group. + +### Filtering paper-account trades to "kevin" — strategy_id FK + +`Trade` has `strategy_id` (UUID FK to `strategies` table) NOT `strategy_name`. To filter paper-account UI: + +1. Migration **seeds** a row in `strategies`: `('kevin', 'Meet Kevin Strategy', current_weight=1.0, active=true)` — known UUID generated and pinned as a constant in code. +2. `kevin_signal_bridge` sets `strategy_id` to this UUID on every emitted `TradeSignal`. +3. `trade_executor` already passes `signal.strategy_id` through to `Trade.strategy_id` during persistence. +4. The paper-account API filters: `WHERE trades.strategy_id = $kevin_strategy_uuid`. + +### `/meet-kevin/strategy` — backtest + tracking (PRIMARY) + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Meet Kevin / Strategy [Run backtest ▾] [Refresh] │ +├──────────────────┬──────────────────┬──────────────────┬──────────────────────┤ +│ Strategy Equity │ Total P&L │ vs SPY (alpha) │ Win rate / # trades │ +│ $103,420 +3.4% │ +$3,420 (+3.4%) │ +1.8% │ 64.3% · 14 trades │ +└──────────────────┴──────────────────┴──────────────────┴──────────────────────┘ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Equity curve — Strategy (blue) · SPY benchmark (gray) │ +│ [recharts/lightweight-charts dual-line] │ +│ [1W] [2W] [1M] [ALL] │ +└───────────────────────────────────────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Ticker scorecard [filter: ____] │ +├──────┬────────┬──────┬─────────────┬─────────┬─────────┬─────────┬───────────┤ +│ Sym │ Action │ Conv │ Entry date │ Live $ │ Unreal. │ Realized│ Status │ +│ NVDA │ BUY │ 0.85 │ 2026-05-15 │ $182.40 │ +$420 │ — │ HOLDING │ +│ INTC │ BUY │ 0.70 │ 2026-05-14 │ $24.10 │ — │ +$85 │ CLOSED │ +│ AMD │ HOLD │ 0.40 │ — │ $145.20 │ — │ — │ WATCH ONLY│ +│ ... │ │ │ │ │ │ │ │ +└──────┴────────┴──────┴─────────────┴─────────┴─────────┴─────────┴───────────┘ + ▲ click row → drill-down panel below (per-mention list + per-trade P&L + price chart with mention markers) + +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Backtest run history (last 5) │ +│ [table: run_uuid_short, started_at, holding_days, return, sharpe, status] │ +└───────────────────────────────────────────────────────────────────────────────┘ +``` + +**Live prices** come from the `market_data` table (Alpaca-cached daily closes), never from the dashboard's direct Alpaca calls. The API gateway is the single source. + +**Empty state** (mention count < 5 or span < 2 trading days): show "Insufficient mention history" but keep the ticker table visible. + +### `/meet-kevin/paper-account` — real paper-trading P&L + +A separate page, ~50 lines, near-clone of `Portfolio.tsx` with the data filtered to `Trade.strategy_name = "kevin"`. Reuses `EquityCurve`, `MetricsRow`, `PositionsTable` as-is. + +Justification for two pages, not one: `/strategy` is "what if" (backtest history + signal universe); `/paper-account` is "what actually happened" (real fills, real positions). Overloading muddles the lenses. + +### Sidebar nav + +Adds two entries under the existing "Meet Kevin" group: +- Strategy (after Stocks) +- Paper Account + +### Dual-line chart component (NEW, not contingent) + +`EquityCurve.tsx` renders exactly one `LineSeries`. The strategy-vs-SPY chart needs two series with a legend. **Definitely create a new component** `dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx` (~80 lines, lightweight-charts) — don't extend `EquityCurve` in place (it's used by other pages). + +--- + +## API surface + +All under existing `/api/meet-kevin/*` prefix. New router file `routes/meet_kevin_backtest.py` (keeps `meet_kevin.py` from ballooning). + +``` +POST /api/meet-kevin/backtest/run → 202 {run_uuid, status} +GET /api/meet-kevin/backtest/runs?limit=20 → run list (headline metrics) +GET /api/meet-kevin/backtest/runs/{run_uuid} → full result +GET /api/meet-kevin/backtest/latest → latest scheduled (or most recent) + +GET /api/meet-kevin/strategy/tickers → scorecard table data +GET /api/meet-kevin/strategy/equity-curve?from=&to=&include_benchmark=spy +GET /api/meet-kevin/strategy/performance → headline metrics + +POST /api/meet-kevin/positions/{symbol}/close → manual close (sets Redis flag) +``` + +Frontend `dashboard/src/api/meetKevin.ts` gains 7 methods using the same axios `client`. + +--- + +## Code locations + +``` +NEW + shared/strategies/kevin.py ~120 lines (KevinStrategy) + services/kevin_signal_bridge/__init__.py + services/kevin_signal_bridge/config.py + services/kevin_signal_bridge/main.py ~250 lines (async loop + exit-scan cron) + services/kevin_signal_bridge/aggregator.py multi-mention window logic + services/api_gateway/routes/meet_kevin_backtest.py backtest routes + backtester/adapters/kevin.py KevinMentionAdapter(BaseStrategy) + shared/market_data/alpaca_fetch.py extracted from existing routes/backtest.py + shared/models/trading.py +KevinTrade audit-link table + shared/models/meet_kevin.py +KevinBacktestRun, KevinBacktestTrade models + alembic/versions/_kevin_backtest_tables.py migration + dashboard/src/pages/meetKevin/Strategy.tsx + dashboard/src/pages/meetKevin/PaperAccount.tsx ~50 lines + dashboard/src/components/meetKevin/TickerScorecardTable.tsx + dashboard/src/components/meetKevin/BacktestRunHistory.tsx + dashboard/src/components/meetKevin/StrategyVsBenchmarkCurve.tsx if EquityCurve extension is messy + +MODIFIED + services/trade_executor/risk_manager.py +daily_trade_count, +daily_alloc_sum, +drawdown halt + services/trade_executor/config.py +new env knobs + shared/strategies/__init__.py export KevinStrategy + backtester/metrics.py extend compute_metrics with alpha, beta, winners, losers + dashboard/src/App.tsx +2 routes + dashboard/src/components/Layout.tsx +2 sidebar entries + dashboard/src/api/meetKevin.ts +7 methods + +INFRA (separately owned commit) + infra/stacks/trading-bot/main.tf +kevin_signal_bridge + trade_executor containers +``` + +--- + +## Config (env vars, all `TRADING_` prefix) + +``` +# Signal translation +TRADING_KEVIN_MIN_CONVICTION = 0.60 +TRADING_KEVIN_MAX_MENTION_AGE_HOURS = 48 +TRADING_KEVIN_HOLD_DAYS = '{"days":3,"weeks":10,"months":45,"long_term":90,"unspecified":10}' + +# Sizing +TRADING_KEVIN_BASE_POSITION_PCT = 0.04 +TRADING_KEVIN_MIN_TRADE_USD = 500 +TRADING_KEVIN_MAX_TRADE_USD = 5000 +TRADING_KEVIN_MAX_PER_TICKER_USD = 7500 + +# Exits +TRADING_KEVIN_STOP_LOSS_PCT = 0.08 +TRADING_KEVIN_TAKE_PROFIT_PCT = 0.20 +TRADING_KEVIN_AVOID_BLOCKLIST_DAYS = 7 + +# Aggregation +TRADING_KEVIN_MENTION_BOOST_PER_REPEAT = 0.05 +TRADING_KEVIN_MAX_MENTION_BOOST = 0.20 + +# Risk +TRADING_KEVIN_MAX_POSITION_PCT = 0.075 +TRADING_KEVIN_DAILY_TRADE_CAP = 5 +TRADING_KEVIN_DAILY_ALLOC_CAP_USD = 15000 +TRADING_KEVIN_DAILY_LOSS_CIRCUIT_PCT = 0.03 +TRADING_KEVIN_EQUITY_DRAWDOWN_HALT_PCT = 0.80 + +# Backtest +TRADING_KEVIN_BACKTEST_SLIPPAGE_PCT = 0.0005 +TRADING_KEVIN_BACKTEST_INITIAL_CAPITAL = 100000 +TRADING_KEVIN_BACKTEST_DAILY_CRON = "30 16 * * 1-5" # 4:30pm ET post-close + +# Bridge plumbing +TRADING_KEVIN_BRIDGE_POLL_INTERVAL_SECONDS = 60 +TRADING_KEVIN_BRIDGE_EXIT_SCAN_CRON = "35 9 * * 1-5" # 9:35 ET market open +TRADING_KEVIN_ENABLE_TRADING = "false" # MASTER KILL-SWITCH — default OFF +``` + +**Master kill-switch: `TRADING_KEVIN_ENABLE_TRADING=false` by default.** Bridge writes audit rows but never publishes to `signals:generated`. Flip to `true` only after the backtest looks sane. + +--- + +## Edge cases + +| Case | Handling | +|---|---| +| Symbol not tradable on Alpaca | `kevin_signal_bridge_state.bridge_status='skipped_non_tradable'` | +| Crypto / OTC / pink sheet | Filtered by `asset.class_ == 'us_equity'` | +| Conviction = 0 | Filtered by 0.60 floor | +| `time_horizon='unspecified'` | Default to 10 trading days | +| Mention created after market close | Re-queued with `bridge_status='deferred'`; next morning's poll picks up if still <48h | +| Alpaca 5xx | `submit_order` returns `OrderStatus.REJECTED`; bridge logs to `kevin_signal_bridge_state` | +| Partial fill | BRACKET order's `filled_avg_price` is used; stop/TP legs attach to filled qty | +| Same mention processed twice | Redis cursor + `INSERT…ON CONFLICT DO NOTHING` audit | +| Kevin reverses while stop-loss order is open | Cancel bracket legs via `broker.cancel_order`, then market SELL | +| Backtest: ticker has no `market_data` rows | Lazy back-fetch from Alpaca for `[entry-1d, end+1d]`; if Alpaca returns nothing, mark trade `skipped` and exclude from denominators | +| Backtest: <5 mentions OR <2 trading-day span | API returns 400 `insufficient_history` | +| Backtest: weekend/holiday mention | Entry shifts to next trading session open | +| Backtest: SPY price gap | Forward-fill from previous trading day | +| Backtest >60s | Status remains `running`; dashboard keeps polling. Watchdog kills at 5min, marks `failed` | + +--- + +## Testing strategy + +Mirrors v1's bar (404 tests, 1 behavior per test, no internal mocking of own code): + +``` +tests/strategies/test_kevin_strategy.py ~25 tests + - per-action signal generation (buy/sell/hold/watch/avoid × held/not-held) + - conviction threshold filtering + - mention age filtering + - time_horizon → holding_days mapping + - non-tradable symbol skip + - blocklist hit short-circuits LONG +tests/services/kevin_signal_bridge/test_main.py ~15 tests + - cursor advances, dedupe via ON CONFLICT + - multi-mention aggregation (latest wins, conviction boost capped) + - daily cap counter increments + resets at UTC midnight + - master kill-switch suppresses publish + - exit-scan emits SELL for elapsed holds +tests/services/kevin_signal_bridge/test_aggregator.py ~10 tests +tests/backtester/test_kevin_adapter.py ~15 tests + - happy path: 3 mentions → 3 backtest trades with correct entry/exit + - dedupe roll: 2nd buy on held ticker extends exit + - SPY benchmark math (alpha/beta) + - extended metrics correctness (winners, losers, best, worst) +tests/api_gateway/routes/test_meet_kevin_backtest.py ~8 tests + - POST /backtest/run returns 202 with run_uuid + - GET /backtest/runs/{uuid} returns full result + - GET /strategy/tickers shapes correctly + - insufficient_history 400 +tests/integration/test_kevin_e2e_paper.py [@integration] ~3 tests + - full pipeline against docker-compose Postgres + Redis, mocked Alpaca + +Manual QA + - dashboard /meet-kevin/strategy renders backtest + ticker table + - dashboard /meet-kevin/paper-account renders Kevin-only positions +``` + +Target: ~76 new unit + integration tests. + +--- + +## Phasing (3 phases, staged) + +Adjusted after challenger review — Phase A was leaking live-state into the ticker scorecard (which expects HOLDING positions). Merged A+B into Phase 1. + +1. **Phase 1 — Strategy + backtest + bridge (audit-only).** Land `KevinStrategy` (standalone), `backtester/kevin_backtest.py`, all 3 new tables, backtest API routes, `kevin_signal_bridge` service. Bridge runs with `TRADING_KEVIN_ENABLE_TRADING=false` — writes `kevin_signal_bridge_state` audit rows showing what it WOULD trade, but never publishes to `signals:generated`. `/meet-kevin/strategy` page shows backtest history + a ticker scorecard with "WOULD-TRADE" status badges (no live positions yet). User reviews the bridge's decisions against the backtest before flipping the switch. +2. **Phase 2 — Trade executor re-enabled, kill-switch ON.** Add `trade_executor` container back to the K8s Pod. Extend `OrderRequest`/`AlpacaBroker` for BRACKET orders. Seed the `kevin` row in `strategies`. Flip `TRADING_KEVIN_ENABLE_TRADING=true`. Live paper trades begin. Ticker scorecard now shows real HOLDING/CLOSED rows. +3. **Phase 3 — Paper-account UI.** Land `/meet-kevin/paper-account` page (Kevin-strategy-only slice of `Portfolio.tsx` filtered by `strategy_id`). + +Each phase is independently deployable. User reviews after each. + +--- + +## Out of scope (intentional) + +- Real money (live brokerage account) +- Short selling +- Options +- Crypto +- Multi-account allocation +- Per-mention attribution analytics ("high-conviction-only" filter view) +- CSV export of trades +- Strategy parameter sweeps in the UI (grid search via `scripts/` for now) +- "What-if" UI for tweaking conviction thresholds — the live strategy owns that +- Trailing stops +- End-of-week rebalances +- Sector concentration limits + +--- + +## Success criteria + +- Backtest runs against the current 63+ mentions and produces a coherent equity curve + metrics card +- The strategy page renders the ticker scorecard with live prices, action chips, and conviction bars +- After flipping the kill-switch, at least one paper trade executes via Alpaca within 60s of a new Kevin mention that passes the filters +- `/meet-kevin/paper-account` shows the Kevin-attributed equity curve diverging from the unified `/portfolio` view +- All hard-cap risk controls fire correctly in tests (verified by integration test that synthesises a 5-mention burst) + +--- + +## Open questions for the user + +1. **Phasing:** ship Phase 1 → 2 → 3 with review checkpoints, or hand all three to one big subagent push? +2. **Initial paper capital:** $100k Alpaca default — or override to a smaller number ($10k?) for less abstract dollar signs? +3. **`avoid` policy split:** the design splits into `AVOID_CLOSES_LONGS` (default `true`) + `AVOID_BLOCKS_DAYS` (default `7`). Subsequent `buy` clears the blocklist. Acceptable? +4. **`/meet-kevin/paper-account` vs reusing `/portfolio`:** two pages (Kevin slice) or one with a Kevin filter? + +Defaults if no override: stage all 3 phases, $100k, split-knob avoid, two pages.