Given a YouTube video ID or URL, runs the same caption-extraction +
LLM-analysis pipeline the watcher uses in prod, then prints the
extracted tickers + actions sorted by action priority and conviction
descending. No DB writes, no Redis publish — strictly observational.
Two entry points:
- scripts/analyze_kevin_video.py — pure Python; needs yt-dlp +
Anthropic OAuth token in env. Local laptop yt-dlp tends to hit
"Sign in to confirm you're not a bot" rate-limits; running inside
a cluster pod avoids this.
- scripts/kevin-analyze.sh — wrapper that finds the running
trading-bot-workers pod and execs the Python script in the
meet-kevin-watcher container. Easiest invocation.
Example:
$ ./scripts/kevin-analyze.sh poUJIZRmFew
=== Meet Kevin analysis — poUJIZRmFew ===
Market outlook: bullish
TICKERS (12):
SYMBOL ACTION CONV HORIZON RATIONALE
APPF buy 85.0% long_term Apploven's looking fantastic ...
SOX buy 80.0% months Semiconductor ETF momentum ...
...
Two changes that ship together so a single CI run lands both:
1) SlackNotifier — support bot-token + channel transport
- Previous version only supported a pinned webhook URL.
- New mode uses chat.postMessage with bot_token + channel.
- Channel can be changed via env var without rotating webhooks.
- bot-token transport wins when both are set.
- Fail-soft: ok=false (e.g. channel_not_found if the user
hasn't created #trading-bot yet) is logged + skipped, not
raised.
- 5 new tests (10 total): bot-token wins, channel_not_found
swallowed, headers/payload shape verified.
2) Image tags — switch from :${CI_PIPELINE_NUMBER} → :0.1.${N}
- 3-part semver so Keel patch policy (cluster-wide default
in inject-keel-annotations) is bounded to patch bumps
within 0.1.x. Prior 1-part tags (:53) were technically
parseable as major-only, which Keel patch wouldn't bump
but could still resolve oddly under digest tracking.
- Memory id=1935 documents Keel patch ≠ bulletproof for
non-semver; semver tags are the safer mode.
- update-deployment + verify-deploy steps updated to match.
- :latest still pushed for cache-from + bootstrap.
SlackNotifier posts a short message to a Slack incoming webhook on:
- trade-executor submits an order (filled or pending)
- RiskManager rejects a signal (except outside_market_hours, which
spams every poll when the bot tries to trade after-hours)
Key properties:
- No-op when slack_webhook_url is empty (fail-soft default).
- HTTP errors are swallowed — a Slack outage MUST NOT crash the
consumer loop; the trade already happened on Alpaca.
- Kevin-strategy signals tagged "Meet Kevin" in the message so I can
tell which strategy fired.
Wiring:
- TradeExecutorConfig.slack_webhook_url + TRADING_SLACK_WEBHOOK_URL
env var, sourced from Vault secret/trading-bot/slack_webhook_url
via existing ExternalSecret.
- SlackNotifier passed to process_signal; both rejection + post-trade
paths call it.
Tests: 7 new (no-op when disabled, post calls webhook with correct
text, Kevin strategy tag, swallows HTTP errors, suppresses noisy
rejections).
End-to-end Phase 2 verification surfaced a FK violation: the bridge
publishes a TradeSignal to the Redis stream and writes
kevin_signal_bridge_state with signal_id, but signal_id has a FK to
the signals table — which was never populated for Kevin-emitted
signals (only the news+sentiment path wrote there).
AuditWriter.persist_signal() inserts the TradeSignal into the
signals table idempotently (on_conflict_do_nothing on the UUID PK)
before the bridge publishes to Redis. Bridge calls it as a new step
right before the XADD, so:
1. Signal row exists in signals table
2. XADD to signals:generated
3. Audit row with signal_id FK now resolves
Verified live: mention #84 (synthetic NVDA buy, conviction 0.85)
emitted a signal, trade-executor consumed and correctly rejected
with outside_market_hours (market was closed at the time).
Previously the backtest API instantiated a _NoAlpaca stub that
returned an empty DataFrame for every fetch_daily_bars call, so the
mention-driven engine had no price data to mark trades against and
every backtest reported total_return=0 / trade_count=0.
Replace with _AlpacaHistoricalFetcher which:
- Uses StockHistoricalDataClient (alpaca-py) with Day timeframe
- Reads creds from TRADING_ALPACA_API_KEY/SECRET_KEY env vars (already
injected via trading-bot-secrets ESO)
- DataFeed.IEX (free tier — same as services/market_data uses)
- Lazy-instantiates the SDK clients on first use to avoid import cost
in the api-gateway hot path
- Returns indexed DataFrame matching KevinPriceLoader's expected shape
([open, high, low, close, volume], timestamp index)
- Returns empty DataFrame on Alpaca failure (loader has its own
cache-miss fallback that no-ops gracefully)
is_asset_tradable also wired to the real Alpaca TradingClient so the
backtest doesn't trade non-tradable tickers.
Pipeline #47 was the first green build since the infra was simplified
on 2026-05-22 (commit 66ca8b9e in infra), which removed news-fetcher,
sentiment-analyzer, trade-executor and added meet-kevin-watcher; a
later commit added kevin-signal-bridge for Phase 1. The .woodpecker.yml
patch list was never updated, so update-deployment added 3 stale
containers to the running pod (news-fetcher, sentiment-analyzer,
trade-executor) — sentiment + trade-executor crash on port 8000 since
news-fetcher binds it first.
Update the strategic-merge patch to match infra exactly:
- signal-generator
- learning-engine
- market-data
- meet-kevin-watcher (was unmanaged → image stuck on :latest)
- kevin-signal-bridge (was unmanaged → image stuck on :latest)
Strategic merge doesn't remove containers not in the patch, so the 3
stale containers in the live deployment will be cleaned up by a
follow-up terragrunt apply on infra/stacks/trading-bot.
Pipeline #46 surfaced two pre-existing CI bugs once fakeredis was
installed and tests could collect:
1. test_models.py:389 asserted "DISCOVERED" in status_col.type.enums,
but the model defines KevinVideoStatus with values_callable so
.enums returns the lowercase string values, not member names.
Asserting "discovered" instead.
2. Four test files use the db_session fixture which requires a real
Postgres on localhost:5432. CI has no Postgres, so 10 tests failed
with Connect call failed (errno 111). These genuinely need a DB —
mirroring tests/integration/* which already use
@pytest.mark.integration. Adding module-level
pytestmark = pytest.mark.integration to:
- tests/shared/models/test_meet_kevin_trading.py
- tests/services/kevin_signal_bridge/test_aggregator.py
- tests/services/kevin_signal_bridge/test_audit.py
- tests/services/kevin_signal_bridge/test_exit_scanner.py
CI runs with -m "not integration" so they're now deselected.
Local pytest still picks them up by default (no marker filter).
CI test step in pipelines #41/#43/#44 (commits db103df, 06ede26,
552f5a1) failed during collection with ModuleNotFoundError:
fakeredis on test_blocklist.py, test_cursor.py, test_risk_counters.py.
The bridge tests use fakeredis.aioredis to mock Redis but the dep
wasn't pinned in pyproject. Locally it was installed manually, so
67 kevin tests pass via .venv but CI never installed it.
Unblocks the trading-bot-service rebuild that should ship the
PositionInfo.ticker fix the bridge pod is crash-looping on, and
also unblocks the dashboard rebuild (last pushed 2026-02-25, so
none of the Meet Kevin UI is live yet).
PositionInfo schema in shared/schemas/trading.py has ticker (not symbol)
and no cost_basis field. Compute cost basis as qty * avg_entry.
Production logs showed AttributeError on every mention process.
Wires the dependency-injected KevinBridge to concrete Redis cursor +
DB session factory + AlpacaBroker (or stub when creds missing). Includes
TradeSignalPublisher (Pydantic -> dict for the redis stream) and
SIGINT/SIGTERM graceful shutdown. Adds is_asset_tradable + get_latest_price
to AlpacaBroker so the bridge can query asset metadata.
Composable: cursor/aggregator/strategy/publisher/audit_writer/broker
all injected. Master kill-switch (kevin_enable_trading=false) routes to
audit-only path. Cursor advances ONLY after XADD succeeds (race fix).
Concrete collaborators wired in subsequent tasks.
Also extends TradeSignal + SignalDirection.EXIT with the optional
fields Kevin paths need (strategy_id, target_dollars, stop_loss_pct,
take_profit_pct).
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.
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.
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.
Strategy.tsx composes 6-up metrics header, StrategyVsBenchmarkCurve
equity chart, TickerScorecardTable and BacktestRunHistory. Selecting a
backtest run replaces the chart with that run's equity curve. Run
Backtest button fires POST and polls for completion.
App.tsx: +route /meet-kevin/strategy.
Layout.tsx: +sidebar entry 'MK Strategy' under Meet Kevin group.
TickerScorecardTable: bridge-status badges (WOULD-TRADE/HOLDING/etc),
conviction bar, unrealised P&L, manual-close button.
BacktestRunHistory: sortable run list with return/sharpe/alpha columns,
row click selects a run for detail view.
StrategyVsBenchmarkCurve: dual lightweight-charts line (strategy blue,
SPY dashed grey) with legend.
BacktestRun, BacktestRunDetail, StrategyTicker, StrategyEquityCurve,
StrategyPerformance types added to meetKevin.ts. New meetKevinStrategy.ts
with 8 axios methods covering the backtest run/list/get/latest and
strategy tickers/equity-curve/performance/close endpoints.
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.
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)
First production run hit Anthropic's per-account rate_limit_error (429) trying
to burn through 16 backfill videos in seconds. The SDK's built-in retry can't
recover because the rate limit window resets slower than the 3 retry attempts.
Added meet_kevin_inter_video_sleep_seconds (default 30s) to PipelineDeps and
main's _process_pending_videos loop. 16 backfill videos now take ~8 min (16 * 30s
sleeps + ~30s per LLM call) instead of bursting into the rate limit.
sentiment-analyzer service is disabled in the K8s revival (removed from
the workers Pod spec). Its torch+transformers dependency adds ~2GB to
the image with no runtime benefit. Tests still install it via the
.[dev] extras for the test step.
Image size: ~3GB -> ~1GB.
Previous refactor (89f01ad) moved to OpenRouter because no sk-ant-api-* key
was found in Vault. Turns out claude-agent-service-spare-{1,2} hold
sk-ant-oat01-* OAuth tokens (108 chars, scope user:inference, 1-year TTL,
minted via 'claude setup-token' — see memory id=832).
These tokens work with the Anthropic SDK via the auth_token= constructor
argument (routes to Authorization: Bearer ... instead of x-api-key: ...).
They consume the Enterprise Claude subscription quota rather than
per-call billing, so the OpenRouter zero-credit problem goes away.
- llm_analyzer.py: revert OpenAI client to AsyncAnthropic; tool-use API
+ cache_control restored
- config.py: openrouter_api_key -> anthropic_oauth_token; model slug
reverted from anthropic/claude-sonnet-4.5 -> claude-sonnet-4-5
- main.py: AsyncOpenAI -> AsyncAnthropic(auth_token=...), drop OpenRouter
attribution headers
- pyproject: openai>=1.50 -> anthropic>=0.40 in meet_kevin extras
- tests: mocks ported back to messages.create + tool_use blocks
yt-dlp requires:
- ffmpeg for sub-conversion (--convert-subs srt) and any format mux
- A JS runtime for YouTube player decryption (deno or node)
Without these, every caption extraction attempt in the meet-kevin-watcher
container fails with 'ffmpeg not found' + 'No supported JavaScript runtime
could be found'. Adding both to the runtime stage of Dockerfile.service.
Size impact: ~50 MB.
User's Vault has openrouter_api_key but no direct sk-ant-* Anthropic key.
OpenRouter passes through Claude Sonnet 4.6 (~3% markup over Anthropic
list pricing) and matches the existing gpt_mini_endpoint pattern used
by recruiter-responder.
- Replace anthropic.AsyncAnthropic with openai.AsyncOpenAI + base_url
- Convert Anthropic tool-use API to OpenAI function-calling
- System prompt unchanged (analyst instructions are model-agnostic)
- Drop cache_control (not in OpenAI API); revisit later if cost matters
- Model slug: anthropic/claude-sonnet-4.5 (OpenRouter's current Claude tier)
- Pricing: $3.10/M input, $15.50/M output (OpenRouter pass-through)
- Config field anthropic_api_key -> openrouter_api_key
- pyproject extras: anthropic>=0.40 -> openai>=1.50
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three issues caught during end-to-end manual QA against docker-compose:
1. SAEnum field columns serialized to Python enum NAMES ('DISCOVERED')
but the DB enum had VALUES ('discovered'). Added `values_callable`
to all 5 SAEnum() declarations in shared/models/meet_kevin.py so they
emit values, matching the migration's enum literals.
2. /dashboard's "last 7 days" / "last 14 days" filters used
`func.cast("7 days", type_=None)` which produced NullType DDL.
Replaced with `text("now() - interval '7 days'")`.
3. /dashboard's outlook trend query repeated `func.date_trunc("day", col)`
in SELECT, GROUP BY and ORDER BY — Postgres treats each as a separate
parameterized expression. Hoisted into a single `day_trunc` variable
so all three clauses reference the same SQL fragment.
All 11 /api/meet-kevin/* endpoints now return valid JSON against a
docker-compose Postgres seeded with one analyzed video + NVDA mention.