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)
34 KiB
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 Akevin-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:<action>:<conviction>"]
▼
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:
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:
- Time-based:
holding_daysfor that mention'stime_horizon(table above). Daily cron inkevin_signal_bridgeat 09:35 ET scans open Kevin trades, emits SELL. - Stop-loss: -8% from entry. Implemented as Alpaca BRACKET order (
order_class=BRACKET, stop_loss=...). Requires extending the broker abstraction — see §"Broker extension" below. - Take-profit: +20% from entry. Implemented as Alpaca BRACKET order's
take_profitleg. - Kevin reverses: new
selloravoidmention on the same ticker → cancel bracket legs + market SELL. - Manual close:
POST /api/meet-kevin/positions/{symbol}/close(sets akevin: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
OrderRequestwith optionaltake_profit_price: Decimal | None,stop_loss_price: Decimal | None,order_class: Literal["simple","bracket"] = "simple". _build_order_requestbranches onorder_class: whenbracket, returnsMarketOrderRequest(order_class=OrderClass.BRACKET, take_profit=TakeProfitRequest(limit_price=...), stop_loss=StopLossRequest(stop_price=...)).- Cancellation:
broker.cancel_orderalready 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, earlierbuys 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_bridgewrites the counters aftertrades:executedconfirms a fill (consumes the stream). - Reset: keys have
EXPIRE 172800(48h) so they self-clean.RiskManager.check_riskreadskevin:daily_trades:{YYYYMMDD using UTC date}for today. - Idempotency: counter increment is keyed off
Trade.id, deduplicated by anINSERT … ON CONFLICT DO NOTHINGaudit row inkevin_signal_bridge_state. | Daily P&L circuit breaker | -3% of equity | Setstrading:paused=1until next market open | | Account drawdown halt | Equity < 80% of starting | Setstrading:paused=PERMANENTuntil 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 viaasyncio.Task; Redis storeskevin:backtest:status:{uuid}with 24h TTL for the dashboard's 2s poll. - Scheduled: the
meet_kevin_watcherservice gains a post-analysis cron stage that calls the same internalrun_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):
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:
- 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. kevin_signal_bridgesetsstrategy_idto this UUID on every emittedTradeSignal.trade_executoralready passessignal.strategy_idthrough toTrade.strategy_idduring persistence.- 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/<ts>_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.
- Phase 1 — Strategy + backtest + bridge (audit-only). Land
KevinStrategy(standalone),backtester/kevin_backtest.py, all 3 new tables, backtest API routes,kevin_signal_bridgeservice. Bridge runs withTRADING_KEVIN_ENABLE_TRADING=false— writeskevin_signal_bridge_stateaudit rows showing what it WOULD trade, but never publishes tosignals:generated./meet-kevin/strategypage 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. - Phase 2 — Trade executor re-enabled, kill-switch ON. Add
trade_executorcontainer back to the K8s Pod. ExtendOrderRequest/AlpacaBrokerfor BRACKET orders. Seed thekevinrow instrategies. FlipTRADING_KEVIN_ENABLE_TRADING=true. Live paper trades begin. Ticker scorecard now shows real HOLDING/CLOSED rows. - Phase 3 — Paper-account UI. Land
/meet-kevin/paper-accountpage (Kevin-strategy-only slice ofPortfolio.tsxfiltered bystrategy_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-accountshows the Kevin-attributed equity curve diverging from the unified/portfolioview- All hard-cap risk controls fire correctly in tests (verified by integration test that synthesises a 5-mention burst)
Open questions for the user
- Phasing: ship Phase 1 → 2 → 3 with review checkpoints, or hand all three to one big subagent push?
- Initial paper capital: $100k Alpaca default — or override to a smaller number ($10k?) for less abstract dollar signs?
avoidpolicy split: the design splits intoAVOID_CLOSES_LONGS(defaulttrue) +AVOID_BLOCKS_DAYS(default7). Subsequentbuyclears the blocklist. Acceptable?/meet-kevin/paper-accountvs 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.