feat(kevin): KevinStrategy standalone decision logic
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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.
This commit is contained in:
parent
c4e92b580e
commit
7dcce5ea0e
3 changed files with 517 additions and 0 deletions
213
shared/strategies/kevin.py
Normal file
213
shared/strategies/kevin.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""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 TickerAction, 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,
|
||||
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 (TickerAction.HOLD, TickerAction.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 == TickerAction.SELL:
|
||||
if account.is_held(symbol):
|
||||
return KevinDecision(
|
||||
decision=KevinDecisionType.CLOSE_LONG,
|
||||
symbol=symbol,
|
||||
effective_conviction=effective_conviction,
|
||||
rationale="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 == TickerAction.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 "
|
||||
f"{self.config.avoid_blocks_days}d"
|
||||
),
|
||||
)
|
||||
return KevinDecision(
|
||||
decision=KevinDecisionType.NO_OP,
|
||||
symbol=symbol,
|
||||
rationale=(
|
||||
f"kevin AVOID; bridge will add to blocklist for "
|
||||
f"{self.config.avoid_blocks_days}d"
|
||||
),
|
||||
)
|
||||
|
||||
# 5. BUY path — full filter stack
|
||||
assert action == TickerAction.BUY
|
||||
|
||||
if effective_conviction < self.config.min_conviction:
|
||||
return KevinDecision(
|
||||
decision=KevinDecisionType.NO_OP,
|
||||
symbol=symbol,
|
||||
rationale=(
|
||||
f"conviction {effective_conviction} below "
|
||||
f"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 "
|
||||
f"{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} — "
|
||||
f"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} -> "
|
||||
f"{target_pct * 100}% target=${target_dollars} hold={holding_days}d"
|
||||
),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue