Compare commits

..

14 Commits

Author SHA1 Message Date
agentson
7f2f96a819 feat: add playbook persistence with DB schema and CRUD store (issue #82)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add playbooks table to src/db.py with UNIQUE(date, market) constraint
- PlaybookStore: save/load/delete, status management, match_count tracking,
  list_recent with market filter, stats without full deserialization
- DayPlaybook JSON serialization via Pydantic model_dump_json/model_validate_json
- 23 tests, 100% coverage on playbook_store.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:00:04 +09:00
aaa74894dd Merge pull request 'feat: implement local scenario engine for playbook execution (issue #80)' (#102) from feature/issue-80-scenario-engine into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #102
2026-02-08 20:47:34 +09:00
agentson
e711d6702a fix: deduplicate missing-key warnings and normalize match_details
Some checks failed
CI / test (pull_request) Has been cancelled
Addresses second round of PR #102 review:
- _warn_missing_key(): logs each missing key only once per engine instance
  to prevent log spam in high-frequency trading loops
- _build_match_details(): uses _safe_float() normalized values instead of
  raw market_data to ensure consistent float types in logging/analysis
- Test: verify warning fires exactly once across repeated calls
- Test: verify match_details contains normalized float values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 20:41:20 +09:00
agentson
d2fc829380 fix: add safe type casting and missing-key warnings in ScenarioEngine
Some checks failed
CI / test (pull_request) Has been cancelled
Addresses PR #102 review findings:
- _safe_float() prevents TypeError from str/Decimal/invalid market_data values
- Warning logs when condition references a key missing from market_data
- 5 new tests: string, percent string, Decimal, mixed invalid types, log check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:23:54 +09:00
de27b1af10 Merge pull request 'Require rebase after creating feature branch' (#106) from feature/issue-105-branch-rebase into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #106
2026-02-08 16:04:57 +09:00
agentson
7370220497 Require rebase after creating feature branch
Some checks failed
CI / test (pull_request) Has been cancelled
2026-02-08 16:03:41 +09:00
b01dacf328 Merge pull request 'docs: add persistent agent constraints document (issue #100)' (#103) from feature/issue-100-agent-constraints into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #103
2026-02-08 15:12:19 +09:00
agentson
1210c17989 docs: add persistent agent constraints document (issue #100)
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 15:10:49 +09:00
agentson
9599b188e8 feat: implement local scenario engine for playbook execution (issue #80)
Some checks failed
CI / test (pull_request) Has been cancelled
ScenarioEngine evaluates pre-defined playbook scenarios against real-time
market data with sub-100ms execution (zero API calls). Supports condition
AND-matching, global portfolio rules, and first-match-wins priority.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 02:23:53 +09:00
c43660a58c Merge pull request 'feat: add strategy/playbook Pydantic models (issue #79)' (#99) from feature/issue-79-strategy-models into main
Some checks failed
CI / test (push) Has been cancelled
2026-02-08 02:19:48 +09:00
agentson
7fd48c7764 feat: add strategy/playbook Pydantic models (issue #79)
Some checks failed
CI / test (pull_request) Has been cancelled
Define data contracts for the proactive strategy system:
- StockCondition: AND-combined condition fields (RSI, volume, price)
- StockScenario: condition-action rules with stop loss/take profit
- StockPlaybook: per-stock scenario collection
- GlobalRule: portfolio-level rules (e.g. REDUCE_ALL on loss limit)
- DayPlaybook: complete daily playbook per market with validation
- CrossMarketContext: cross-market awareness (KR↔US)
- ScenarioAction, MarketOutlook, PlaybookStatus enums

33 tests covering validation, serialization, edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 02:06:16 +09:00
a105bb7c1a Merge pull request 'feat: add pre-market planner config and remove static watchlists (issue #78)' (#98) from feature/issue-78-config-watchlist-removal into main
Some checks failed
CI / test (push) Has been cancelled
2026-02-08 02:04:23 +09:00
agentson
1a34a74232 feat: add pre-market planner config and remove static watchlists (issue #78)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add pre-market planner settings: PRE_MARKET_MINUTES, MAX_SCENARIOS_PER_STOCK,
  PLANNER_TIMEOUT_SECONDS, DEFENSIVE_PLAYBOOK_ON_FAILURE, RESCAN_INTERVAL_SECONDS
- Change ENABLED_MARKETS default from KR to KR,US
- Remove static WATCHLISTS and STOCK_UNIVERSE dictionaries from main.py
- Replace watchlist-based trading with dynamic scanner-only stock discovery
- SmartVolatilityScanner is now the sole source of trading candidates
- Add active_stocks dict for scanner-discovered stocks per market
- Add smart_scanner parameter to run_daily_session()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 01:58:09 +09:00
a82a167915 Merge pull request 'feat: implement Smart Volatility Scanner (issue #76)' (#77) from feature/issue-76-smart-volatility-scanner into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #77
2026-02-06 07:43:54 +09:00
12 changed files with 1824 additions and 46 deletions

45
docs/agent-constraints.md Normal file
View File

@@ -0,0 +1,45 @@
# Agent Constraints
This document records **persistent behavioral constraints** for agents working on this repository.
It is distinct from `docs/requirements-log.md`, which records **project/product requirements**.
## Scope
- Applies to all AI agents and automation that modify this repo.
- Supplements (does not replace) `docs/agents.md` and `docs/workflow.md`.
## Persistent Rules
1. **Workflow enforcement**
- Follow `docs/workflow.md` for all changes.
- Create a Gitea issue before any code or documentation change.
- Work on a feature branch `feature/issue-{N}-{short-description}` and open a PR.
- Never commit directly to `main`.
2. **Document-first routing**
- When performing work, consult relevant `docs/` files *before* making changes.
- Route decisions to the documented policy whenever applicable.
- If guidance conflicts, prefer the stricter/safety-first rule and note it in the PR.
3. **Docs with code**
- Any code change must be accompanied by relevant documentation updates.
- If no doc update is needed, state the reason explicitly in the PR.
4. **Session-persistent user constraints**
- If the user requests that a behavior should persist across sessions, record it here
(or in a dedicated policy doc) and reference it when working.
- Keep entries short and concrete, with dates.
## Change Control
- Changes to this file follow the same workflow as code changes.
- Keep the history chronological and minimize rewording of existing entries.
## History
### 2026-02-08
- Always enforce Gitea workflow: issue -> feature branch -> PR before changes.
- When work requires guidance, consult the relevant `docs/` policies first.
- Any code change must be accompanied by relevant documentation updates.
- Persist user constraints across sessions by recording them in this document.

View File

@@ -6,6 +6,7 @@
1. **Create Gitea Issue First** — All features, bug fixes, and policy changes require a Gitea issue before any code is written 1. **Create Gitea Issue First** — All features, bug fixes, and policy changes require a Gitea issue before any code is written
2. **Create Feature Branch** — Branch from `main` using format `feature/issue-{N}-{short-description}` 2. **Create Feature Branch** — Branch from `main` using format `feature/issue-{N}-{short-description}`
- After creating the branch, run `git pull origin main` and rebase to ensure the branch is up to date
3. **Implement Changes** — Write code, tests, and documentation on the feature branch 3. **Implement Changes** — Write code, tests, and documentation on the feature branch
4. **Create Pull Request** — Submit PR to `main` branch referencing the issue number 4. **Create Pull Request** — Submit PR to `main` branch referencing the issue number
5. **Review & Merge** — After approval, merge via PR (squash or merge commit) 5. **Review & Merge** — After approval, merge via PR (squash or merge commit)

View File

@@ -55,8 +55,15 @@ class Settings(BaseSettings):
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10) DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
SESSION_INTERVAL_HOURS: int = Field(default=6, ge=1, le=24) SESSION_INTERVAL_HOURS: int = Field(default=6, ge=1, le=24)
# Pre-Market Planner
PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120)
MAX_SCENARIOS_PER_STOCK: int = Field(default=5, ge=1, le=10)
PLANNER_TIMEOUT_SECONDS: int = Field(default=60, ge=10, le=300)
DEFENSIVE_PLAYBOOK_ON_FAILURE: bool = True
RESCAN_INTERVAL_SECONDS: int = Field(default=300, ge=60, le=900)
# Market selection (comma-separated market codes) # Market selection (comma-separated market codes)
ENABLED_MARKETS: str = "KR" ENABLED_MARKETS: str = "KR,US"
# Backup and Disaster Recovery (optional) # Backup and Disaster Recovery (optional)
BACKUP_ENABLED: bool = True BACKUP_ENABLED: bool = True

View File

@@ -91,6 +91,27 @@ def init_db(db_path: str) -> sqlite3.Connection:
""" """
) )
# Playbook storage for pre-market strategy persistence
conn.execute(
"""
CREATE TABLE IF NOT EXISTS playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
market TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
playbook_json TEXT NOT NULL,
generated_at TEXT NOT NULL,
token_count INTEGER DEFAULT 0,
scenario_count INTEGER DEFAULT 0,
match_count INTEGER DEFAULT 0,
UNIQUE(date, market)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_playbooks_date ON playbooks(date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_playbooks_market ON playbooks(market)")
# Create indices for efficient context queries # Create indices for efficient context queries
conn.execute("CREATE INDEX IF NOT EXISTS idx_contexts_layer ON contexts(layer)") conn.execute("CREATE INDEX IF NOT EXISTS idx_contexts_layer ON contexts(layer)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_contexts_timeframe ON contexts(timeframe)") conn.execute("CREATE INDEX IF NOT EXISTS idx_contexts_timeframe ON contexts(timeframe)")

View File

@@ -63,14 +63,6 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float:
return default return default
# Target stock codes to monitor per market
WATCHLISTS = {
"KR": ["005930", "000660", "035420"], # Samsung, SK Hynix, NAVER
"US_NASDAQ": ["AAPL", "MSFT", "GOOGL"], # Example US stocks
"US_NYSE": ["JPM", "BAC"], # Example NYSE stocks
"JP": ["7203", "6758"], # Toyota, Sony
}
TRADE_INTERVAL_SECONDS = 60 TRADE_INTERVAL_SECONDS = 60
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
MAX_CONNECTION_RETRIES = 3 MAX_CONNECTION_RETRIES = 3
@@ -79,15 +71,6 @@ MAX_CONNECTION_RETRIES = 3
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
# Full stock universe per market (for scanning)
# In production, this would be loaded from a database or API
STOCK_UNIVERSE = {
"KR": ["005930", "000660", "035420", "051910", "005380", "005490"],
"US_NASDAQ": ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "TSLA"],
"US_NYSE": ["JPM", "BAC", "XOM", "JNJ", "V"],
"JP": ["7203", "6758", "9984", "6861"],
}
async def trading_cycle( async def trading_cycle(
broker: KISBroker, broker: KISBroker,
@@ -349,6 +332,7 @@ async def run_daily_session(
criticality_assessor: CriticalityAssessor, criticality_assessor: CriticalityAssessor,
telegram: TelegramClient, telegram: TelegramClient,
settings: Settings, settings: Settings,
smart_scanner: SmartVolatilityScanner | None = None,
) -> None: ) -> None:
"""Execute one daily trading session. """Execute one daily trading session.
@@ -368,15 +352,21 @@ async def run_daily_session(
# Process each open market # Process each open market
for market in open_markets: for market in open_markets:
# Get watchlist for this market # Dynamic stock discovery via scanner (no static watchlists)
watchlist = WATCHLISTS.get(market.code, []) try:
candidates = await smart_scanner.scan()
watchlist = [c.stock_code for c in candidates] if candidates else []
except Exception as exc:
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
watchlist = []
if not watchlist: if not watchlist:
logger.debug("No watchlist for market %s", market.code) logger.info("No scanner candidates for market %s — skipping", market.code)
continue continue
logger.info("Processing market: %s (%d stocks)", market.name, len(watchlist)) logger.info("Processing market: %s (%d stocks)", market.name, len(watchlist))
# Collect market data for all stocks in the watchlist # Collect market data for all stocks from scanner
stocks_data = [] stocks_data = []
for stock_code in watchlist: for stock_code in watchlist:
try: try:
@@ -745,6 +735,9 @@ async def run(settings: Settings) -> None:
# Track scan candidates for selection context logging # Track scan candidates for selection context logging
scan_candidates: dict[str, ScanCandidate] = {} # stock_code -> candidate scan_candidates: dict[str, ScanCandidate] = {} # stock_code -> candidate
# Active stocks per market (dynamically discovered by scanner)
active_stocks: dict[str, list[str]] = {} # market_code -> [stock_codes]
# Initialize latency control system # Initialize latency control system
criticality_assessor = CriticalityAssessor( criticality_assessor = CriticalityAssessor(
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0% critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
@@ -817,6 +810,7 @@ async def run(settings: Settings) -> None:
criticality_assessor, criticality_assessor,
telegram, telegram,
settings, settings,
smart_scanner=smart_scanner,
) )
except CircuitBreakerTripped: except CircuitBreakerTripped:
logger.critical("Circuit breaker tripped — shutting down") logger.critical("Circuit breaker tripped — shutting down")
@@ -890,57 +884,52 @@ async def run(settings: Settings) -> None:
logger.warning("Market open notification failed: %s", exc) logger.warning("Market open notification failed: %s", exc)
_market_states[market.code] = True _market_states[market.code] = True
# Smart Scanner: Python-first filtering (RSI + volume) before AI # Smart Scanner: dynamic stock discovery (no static watchlists)
now_timestamp = asyncio.get_event_loop().time() now_timestamp = asyncio.get_event_loop().time()
last_scan = last_scan_time.get(market.code, 0.0) last_scan = last_scan_time.get(market.code, 0.0)
if now_timestamp - last_scan >= SCAN_INTERVAL_SECONDS: if now_timestamp - last_scan >= SCAN_INTERVAL_SECONDS:
try: try:
logger.info("Smart Scanner: Scanning %s market", market.name) logger.info("Smart Scanner: Scanning %s market", market.name)
# Run smart scan with fallback to static universe candidates = await smart_scanner.scan()
fallback_universe = STOCK_UNIVERSE.get(market.code, [])
candidates = await smart_scanner.scan(fallback_stocks=fallback_universe)
if candidates: if candidates:
# Update watchlist with qualified candidates # Use scanner results directly as trading candidates
qualified_codes = smart_scanner.get_stock_codes(candidates) active_stocks[market.code] = smart_scanner.get_stock_codes(
candidates
)
# Merge with existing watchlist (keep some continuity) # Store candidates for selection context logging
current_watchlist = WATCHLISTS.get(market.code, [])
# Keep up to 2 from existing, add new qualified
merged = qualified_codes + [
c for c in current_watchlist if c not in qualified_codes
][:2]
WATCHLISTS[market.code] = merged[:5] # Cap at 5
# Store candidates for later selection context logging
for candidate in candidates: for candidate in candidates:
scan_candidates[candidate.stock_code] = candidate scan_candidates[candidate.stock_code] = candidate
logger.info( logger.info(
"Smart Scanner: Found %d qualified candidates for %s: %s", "Smart Scanner: Found %d candidates for %s: %s",
len(candidates), len(candidates),
market.name, market.name,
[f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates], [f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates],
) )
else: else:
logger.info("Smart Scanner: No qualified candidates for %s", market.name) logger.info(
"Smart Scanner: No candidates for %s — no trades", market.name
)
active_stocks[market.code] = []
last_scan_time[market.code] = now_timestamp last_scan_time[market.code] = now_timestamp
except Exception as exc: except Exception as exc:
logger.error("Smart Scanner failed for %s: %s", market.name, exc) logger.error("Smart Scanner failed for %s: %s", market.name, exc)
# Get watchlist for this market # Get active stocks from scanner (dynamic, no static fallback)
watchlist = WATCHLISTS.get(market.code, []) stock_codes = active_stocks.get(market.code, [])
if not watchlist: if not stock_codes:
logger.debug("No watchlist for market %s", market.code) logger.debug("No active stocks for market %s", market.code)
continue continue
logger.info("Processing market: %s (%d stocks)", market.name, len(watchlist)) logger.info("Processing market: %s (%d stocks)", market.name, len(stock_codes))
# Process each stock in the watchlist # Process each stock from scanner results
for stock_code in watchlist: for stock_code in stock_codes:
if shutdown.is_set(): if shutdown.is_set():
break break

0
src/strategy/__init__.py Normal file
View File

164
src/strategy/models.py Normal file
View File

@@ -0,0 +1,164 @@
"""Pydantic models for pre-market scenario planning.
Defines the data contracts for the proactive strategy system:
- AI generates DayPlaybook before market open (structured JSON scenarios)
- Local ScenarioEngine matches conditions during market hours (no API calls)
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class ScenarioAction(str, Enum):
"""Actions that can be taken by scenarios."""
BUY = "BUY"
SELL = "SELL"
HOLD = "HOLD"
REDUCE_ALL = "REDUCE_ALL"
class MarketOutlook(str, Enum):
"""AI's assessment of market direction."""
BULLISH = "bullish"
NEUTRAL_TO_BULLISH = "neutral_to_bullish"
NEUTRAL = "neutral"
NEUTRAL_TO_BEARISH = "neutral_to_bearish"
BEARISH = "bearish"
class PlaybookStatus(str, Enum):
"""Lifecycle status of a playbook."""
PENDING = "pending"
READY = "ready"
FAILED = "failed"
EXPIRED = "expired"
class StockCondition(BaseModel):
"""Condition fields for scenario matching (all optional, AND-combined).
The ScenarioEngine evaluates all non-None fields as AND conditions.
A condition matches only if ALL specified fields are satisfied.
"""
rsi_below: float | None = None
rsi_above: float | None = None
volume_ratio_above: float | None = None
volume_ratio_below: float | None = None
price_above: float | None = None
price_below: float | None = None
price_change_pct_above: float | None = None
price_change_pct_below: float | None = None
def has_any_condition(self) -> bool:
"""Check if at least one condition field is set."""
return any(
v is not None
for v in (
self.rsi_below,
self.rsi_above,
self.volume_ratio_above,
self.volume_ratio_below,
self.price_above,
self.price_below,
self.price_change_pct_above,
self.price_change_pct_below,
)
)
class StockScenario(BaseModel):
"""A single condition-action rule for one stock."""
condition: StockCondition
action: ScenarioAction
confidence: int = Field(ge=0, le=100)
allocation_pct: float = Field(ge=0, le=100, default=10.0)
stop_loss_pct: float = Field(le=0, default=-2.0)
take_profit_pct: float = Field(ge=0, default=3.0)
rationale: str = ""
class StockPlaybook(BaseModel):
"""All scenarios for a single stock (ordered by priority)."""
stock_code: str
stock_name: str = ""
scenarios: list[StockScenario] = Field(min_length=1)
class GlobalRule(BaseModel):
"""Portfolio-level rule (checked before stock-level scenarios)."""
condition: str # e.g. "portfolio_pnl_pct < -2.0"
action: ScenarioAction
rationale: str = ""
class CrossMarketContext(BaseModel):
"""Summary of another market's state for cross-market awareness."""
market: str # e.g. "US" or "KR"
date: str
total_pnl: float = 0.0
win_rate: float = 0.0
index_change_pct: float = 0.0 # e.g. KOSPI or S&P500 change
key_events: list[str] = Field(default_factory=list)
lessons: list[str] = Field(default_factory=list)
class DayPlaybook(BaseModel):
"""Complete playbook for a single trading day in a single market.
Generated by PreMarketPlanner (1 Gemini call per market per day).
Consumed by ScenarioEngine during market hours (0 API calls).
"""
date: date
market: str # "KR" or "US"
market_outlook: MarketOutlook = MarketOutlook.NEUTRAL
generated_at: str = "" # ISO timestamp
gemini_model: str = ""
token_count: int = 0
global_rules: list[GlobalRule] = Field(default_factory=list)
stock_playbooks: list[StockPlaybook] = Field(default_factory=list)
default_action: ScenarioAction = ScenarioAction.HOLD
context_summary: dict = Field(default_factory=dict)
cross_market: CrossMarketContext | None = None
@field_validator("stock_playbooks")
@classmethod
def validate_unique_stocks(cls, v: list[StockPlaybook]) -> list[StockPlaybook]:
codes = [pb.stock_code for pb in v]
if len(codes) != len(set(codes)):
raise ValueError("Duplicate stock codes in playbook")
return v
def get_stock_playbook(self, stock_code: str) -> StockPlaybook | None:
"""Find the playbook for a specific stock."""
for pb in self.stock_playbooks:
if pb.stock_code == stock_code:
return pb
return None
@property
def scenario_count(self) -> int:
"""Total number of scenarios across all stocks."""
return sum(len(pb.scenarios) for pb in self.stock_playbooks)
@property
def stock_count(self) -> int:
"""Number of stocks with scenarios."""
return len(self.stock_playbooks)
def model_post_init(self, __context: object) -> None:
"""Set generated_at if not provided."""
if not self.generated_at:
self.generated_at = datetime.now(UTC).isoformat()

View File

@@ -0,0 +1,184 @@
"""Playbook persistence layer — CRUD for DayPlaybook in SQLite.
Stores and retrieves market-specific daily playbooks with JSON serialization.
Designed for the pre-market strategy system (one playbook per market per day).
"""
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import date
from src.strategy.models import DayPlaybook, PlaybookStatus
logger = logging.getLogger(__name__)
class PlaybookStore:
"""CRUD operations for DayPlaybook persistence."""
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def save(self, playbook: DayPlaybook) -> int:
"""Save or replace a playbook for a given date+market.
Uses INSERT OR REPLACE to enforce UNIQUE(date, market).
Returns:
The row id of the inserted/replaced record.
"""
playbook_json = playbook.model_dump_json()
cursor = self._conn.execute(
"""
INSERT OR REPLACE INTO playbooks
(date, market, status, playbook_json, generated_at,
token_count, scenario_count, match_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
playbook.date.isoformat(),
playbook.market,
PlaybookStatus.READY.value,
playbook_json,
playbook.generated_at,
playbook.token_count,
playbook.scenario_count,
0,
),
)
self._conn.commit()
row_id = cursor.lastrowid or 0
logger.info(
"Saved playbook for %s/%s (%d stocks, %d scenarios)",
playbook.date, playbook.market,
playbook.stock_count, playbook.scenario_count,
)
return row_id
def load(self, target_date: date, market: str) -> DayPlaybook | None:
"""Load a playbook for a specific date and market.
Returns:
DayPlaybook if found, None otherwise.
"""
row = self._conn.execute(
"SELECT playbook_json FROM playbooks WHERE date = ? AND market = ?",
(target_date.isoformat(), market),
).fetchone()
if row is None:
return None
return DayPlaybook.model_validate_json(row[0])
def get_status(self, target_date: date, market: str) -> PlaybookStatus | None:
"""Get the status of a playbook without deserializing the full JSON."""
row = self._conn.execute(
"SELECT status FROM playbooks WHERE date = ? AND market = ?",
(target_date.isoformat(), market),
).fetchone()
if row is None:
return None
return PlaybookStatus(row[0])
def update_status(self, target_date: date, market: str, status: PlaybookStatus) -> bool:
"""Update the status of a playbook.
Returns:
True if a row was updated, False if not found.
"""
cursor = self._conn.execute(
"UPDATE playbooks SET status = ? WHERE date = ? AND market = ?",
(status.value, target_date.isoformat(), market),
)
self._conn.commit()
return cursor.rowcount > 0
def increment_match_count(self, target_date: date, market: str) -> bool:
"""Increment the match_count for tracking scenario hits during the day.
Returns:
True if a row was updated, False if not found.
"""
cursor = self._conn.execute(
"UPDATE playbooks SET match_count = match_count + 1 WHERE date = ? AND market = ?",
(target_date.isoformat(), market),
)
self._conn.commit()
return cursor.rowcount > 0
def get_stats(self, target_date: date, market: str) -> dict | None:
"""Get playbook stats without full deserialization.
Returns:
Dict with status, token_count, scenario_count, match_count, or None.
"""
row = self._conn.execute(
"""
SELECT status, token_count, scenario_count, match_count, generated_at
FROM playbooks WHERE date = ? AND market = ?
""",
(target_date.isoformat(), market),
).fetchone()
if row is None:
return None
return {
"status": row[0],
"token_count": row[1],
"scenario_count": row[2],
"match_count": row[3],
"generated_at": row[4],
}
def list_recent(self, market: str | None = None, limit: int = 7) -> list[dict]:
"""List recent playbooks with summary info.
Args:
market: Filter by market code. None for all markets.
limit: Max number of results.
Returns:
List of dicts with date, market, status, scenario_count, match_count.
"""
if market is not None:
rows = self._conn.execute(
"""
SELECT date, market, status, scenario_count, match_count
FROM playbooks WHERE market = ?
ORDER BY date DESC LIMIT ?
""",
(market, limit),
).fetchall()
else:
rows = self._conn.execute(
"""
SELECT date, market, status, scenario_count, match_count
FROM playbooks
ORDER BY date DESC LIMIT ?
""",
(limit,),
).fetchall()
return [
{
"date": row[0],
"market": row[1],
"status": row[2],
"scenario_count": row[3],
"match_count": row[4],
}
for row in rows
]
def delete(self, target_date: date, market: str) -> bool:
"""Delete a playbook.
Returns:
True if a row was deleted, False if not found.
"""
cursor = self._conn.execute(
"DELETE FROM playbooks WHERE date = ? AND market = ?",
(target_date.isoformat(), market),
)
self._conn.commit()
return cursor.rowcount > 0

View File

@@ -0,0 +1,270 @@
"""Local scenario engine for playbook execution.
Matches real-time market conditions against pre-defined scenarios
without any API calls. Designed for sub-100ms execution.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from src.strategy.models import (
DayPlaybook,
GlobalRule,
ScenarioAction,
StockCondition,
StockScenario,
)
logger = logging.getLogger(__name__)
@dataclass
class ScenarioMatch:
"""Result of matching market conditions against scenarios."""
stock_code: str
matched_scenario: StockScenario | None
action: ScenarioAction
confidence: int
rationale: str
global_rule_triggered: GlobalRule | None = None
match_details: dict[str, Any] = field(default_factory=dict)
class ScenarioEngine:
"""Evaluates playbook scenarios against real-time market data.
No API calls — pure Python condition matching.
Expected market_data keys: "rsi", "volume_ratio", "current_price", "price_change_pct".
Callers must normalize data source keys to match this contract.
"""
def __init__(self) -> None:
self._warned_keys: set[str] = set()
@staticmethod
def _safe_float(value: Any) -> float | None:
"""Safely cast a value to float. Returns None on failure."""
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
return None
def _warn_missing_key(self, key: str) -> None:
"""Log a missing-key warning once per key per engine instance."""
if key not in self._warned_keys:
self._warned_keys.add(key)
logger.warning("Condition requires '%s' but key missing from market_data", key)
def evaluate(
self,
playbook: DayPlaybook,
stock_code: str,
market_data: dict[str, Any],
portfolio_data: dict[str, Any],
) -> ScenarioMatch:
"""Match market conditions to scenarios and return a decision.
Algorithm:
1. Check global rules first (portfolio-level circuit breakers)
2. Find the StockPlaybook for the given stock_code
3. Iterate scenarios in order (first match wins)
4. If no match, return playbook.default_action (HOLD)
Args:
playbook: Today's DayPlaybook for this market
stock_code: Stock ticker to evaluate
market_data: Real-time market data (price, rsi, volume_ratio, etc.)
portfolio_data: Portfolio state (pnl_pct, total_cash, etc.)
Returns:
ScenarioMatch with the decision
"""
# 1. Check global rules
triggered_rule = self.check_global_rules(playbook, portfolio_data)
if triggered_rule is not None:
logger.info(
"Global rule triggered for %s: %s -> %s",
stock_code,
triggered_rule.condition,
triggered_rule.action.value,
)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=triggered_rule.action,
confidence=100,
rationale=f"Global rule: {triggered_rule.rationale or triggered_rule.condition}",
global_rule_triggered=triggered_rule,
)
# 2. Find stock playbook
stock_pb = playbook.get_stock_playbook(stock_code)
if stock_pb is None:
logger.debug("No playbook for %s — defaulting to %s", stock_code, playbook.default_action)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=playbook.default_action,
confidence=0,
rationale=f"No scenarios defined for {stock_code}",
)
# 3. Iterate scenarios (first match wins)
for scenario in stock_pb.scenarios:
if self.evaluate_condition(scenario.condition, market_data):
logger.info(
"Scenario matched for %s: %s (confidence=%d)",
stock_code,
scenario.action.value,
scenario.confidence,
)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=scenario,
action=scenario.action,
confidence=scenario.confidence,
rationale=scenario.rationale,
match_details=self._build_match_details(scenario.condition, market_data),
)
# 4. No match — default action
logger.debug("No scenario matched for %s — defaulting to %s", stock_code, playbook.default_action)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=playbook.default_action,
confidence=0,
rationale="No scenario conditions met — holding position",
)
def check_global_rules(
self,
playbook: DayPlaybook,
portfolio_data: dict[str, Any],
) -> GlobalRule | None:
"""Check portfolio-level rules. Returns first triggered rule or None."""
for rule in playbook.global_rules:
if self._evaluate_global_condition(rule.condition, portfolio_data):
return rule
return None
def evaluate_condition(
self,
condition: StockCondition,
market_data: dict[str, Any],
) -> bool:
"""Evaluate all non-None fields in condition as AND.
Returns True only if ALL specified conditions are met.
Empty condition (no fields set) returns False for safety.
"""
if not condition.has_any_condition():
return False
checks: list[bool] = []
rsi = self._safe_float(market_data.get("rsi"))
if condition.rsi_below is not None or condition.rsi_above is not None:
if "rsi" not in market_data:
self._warn_missing_key("rsi")
if condition.rsi_below is not None:
checks.append(rsi is not None and rsi < condition.rsi_below)
if condition.rsi_above is not None:
checks.append(rsi is not None and rsi > condition.rsi_above)
volume_ratio = self._safe_float(market_data.get("volume_ratio"))
if condition.volume_ratio_above is not None or condition.volume_ratio_below is not None:
if "volume_ratio" not in market_data:
self._warn_missing_key("volume_ratio")
if condition.volume_ratio_above is not None:
checks.append(volume_ratio is not None and volume_ratio > condition.volume_ratio_above)
if condition.volume_ratio_below is not None:
checks.append(volume_ratio is not None and volume_ratio < condition.volume_ratio_below)
price = self._safe_float(market_data.get("current_price"))
if condition.price_above is not None or condition.price_below is not None:
if "current_price" not in market_data:
self._warn_missing_key("current_price")
if condition.price_above is not None:
checks.append(price is not None and price > condition.price_above)
if condition.price_below is not None:
checks.append(price is not None and price < condition.price_below)
price_change_pct = self._safe_float(market_data.get("price_change_pct"))
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
if "price_change_pct" not in market_data:
self._warn_missing_key("price_change_pct")
if condition.price_change_pct_above is not None:
checks.append(price_change_pct is not None and price_change_pct > condition.price_change_pct_above)
if condition.price_change_pct_below is not None:
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
return len(checks) > 0 and all(checks)
def _evaluate_global_condition(
self,
condition_str: str,
portfolio_data: dict[str, Any],
) -> bool:
"""Evaluate a simple global condition string against portfolio data.
Supports: "field < value", "field > value", "field <= value", "field >= value"
"""
parts = condition_str.strip().split()
if len(parts) != 3:
logger.warning("Invalid global condition format: %s", condition_str)
return False
field_name, operator, value_str = parts
try:
threshold = float(value_str)
except ValueError:
logger.warning("Invalid threshold in condition: %s", condition_str)
return False
actual = portfolio_data.get(field_name)
if actual is None:
return False
try:
actual_val = float(actual)
except (ValueError, TypeError):
return False
if operator == "<":
return actual_val < threshold
elif operator == ">":
return actual_val > threshold
elif operator == "<=":
return actual_val <= threshold
elif operator == ">=":
return actual_val >= threshold
else:
logger.warning("Unknown operator in condition: %s", operator)
return False
def _build_match_details(
self,
condition: StockCondition,
market_data: dict[str, Any],
) -> dict[str, Any]:
"""Build a summary of which conditions matched and their normalized values."""
details: dict[str, Any] = {}
if condition.rsi_below is not None or condition.rsi_above is not None:
details["rsi"] = self._safe_float(market_data.get("rsi"))
if condition.volume_ratio_above is not None or condition.volume_ratio_below is not None:
details["volume_ratio"] = self._safe_float(market_data.get("volume_ratio"))
if condition.price_above is not None or condition.price_below is not None:
details["current_price"] = self._safe_float(market_data.get("current_price"))
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
return details

View File

@@ -0,0 +1,289 @@
"""Tests for playbook persistence (PlaybookStore + DB schema)."""
from __future__ import annotations
from datetime import date
import pytest
from src.db import init_db
from src.strategy.models import (
DayPlaybook,
GlobalRule,
MarketOutlook,
PlaybookStatus,
ScenarioAction,
StockCondition,
StockPlaybook,
StockScenario,
)
from src.strategy.playbook_store import PlaybookStore
@pytest.fixture
def conn():
"""Create an in-memory DB with schema."""
connection = init_db(":memory:")
yield connection
connection.close()
@pytest.fixture
def store(conn) -> PlaybookStore:
return PlaybookStore(conn)
def _make_playbook(
target_date: date = date(2026, 2, 8),
market: str = "KR",
outlook: MarketOutlook = MarketOutlook.NEUTRAL,
stock_codes: list[str] | None = None,
) -> DayPlaybook:
"""Create a test playbook with sensible defaults."""
if stock_codes is None:
stock_codes = ["005930"]
return DayPlaybook(
date=target_date,
market=market,
market_outlook=outlook,
token_count=150,
stock_playbooks=[
StockPlaybook(
stock_code=code,
scenarios=[
StockScenario(
condition=StockCondition(rsi_below=30.0),
action=ScenarioAction.BUY,
confidence=85,
rationale=f"Oversold bounce for {code}",
),
],
)
for code in stock_codes
],
global_rules=[
GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
rationale="Near circuit breaker",
),
],
)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
class TestSchema:
def test_playbooks_table_exists(self, conn) -> None:
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='playbooks'"
).fetchone()
assert row is not None
def test_unique_constraint(self, store: PlaybookStore) -> None:
pb = _make_playbook()
store.save(pb)
# Saving again for same date+market should replace, not error
pb2 = _make_playbook(stock_codes=["005930", "000660"])
store.save(pb2)
loaded = store.load(date(2026, 2, 8), "KR")
assert loaded is not None
assert loaded.stock_count == 2
# ---------------------------------------------------------------------------
# Save / Load
# ---------------------------------------------------------------------------
class TestSaveLoad:
def test_save_and_load(self, store: PlaybookStore) -> None:
pb = _make_playbook()
row_id = store.save(pb)
assert row_id > 0
loaded = store.load(date(2026, 2, 8), "KR")
assert loaded is not None
assert loaded.date == date(2026, 2, 8)
assert loaded.market == "KR"
assert loaded.stock_count == 1
assert loaded.scenario_count == 1
def test_load_not_found(self, store: PlaybookStore) -> None:
result = store.load(date(2026, 1, 1), "KR")
assert result is None
def test_save_preserves_all_fields(self, store: PlaybookStore) -> None:
pb = _make_playbook(
outlook=MarketOutlook.BULLISH,
stock_codes=["005930", "AAPL"],
)
store.save(pb)
loaded = store.load(date(2026, 2, 8), "KR")
assert loaded is not None
assert loaded.market_outlook == MarketOutlook.BULLISH
assert loaded.stock_count == 2
assert loaded.global_rules[0].action == ScenarioAction.REDUCE_ALL
assert loaded.token_count == 150
def test_save_different_markets(self, store: PlaybookStore) -> None:
kr = _make_playbook(market="KR")
us = _make_playbook(market="US", stock_codes=["AAPL"])
store.save(kr)
store.save(us)
kr_loaded = store.load(date(2026, 2, 8), "KR")
us_loaded = store.load(date(2026, 2, 8), "US")
assert kr_loaded is not None
assert us_loaded is not None
assert kr_loaded.market == "KR"
assert us_loaded.market == "US"
assert kr_loaded.stock_playbooks[0].stock_code == "005930"
assert us_loaded.stock_playbooks[0].stock_code == "AAPL"
def test_save_different_dates(self, store: PlaybookStore) -> None:
d1 = _make_playbook(target_date=date(2026, 2, 7))
d2 = _make_playbook(target_date=date(2026, 2, 8))
store.save(d1)
store.save(d2)
assert store.load(date(2026, 2, 7), "KR") is not None
assert store.load(date(2026, 2, 8), "KR") is not None
def test_replace_updates_data(self, store: PlaybookStore) -> None:
pb1 = _make_playbook(outlook=MarketOutlook.BEARISH)
store.save(pb1)
pb2 = _make_playbook(outlook=MarketOutlook.BULLISH)
store.save(pb2)
loaded = store.load(date(2026, 2, 8), "KR")
assert loaded is not None
assert loaded.market_outlook == MarketOutlook.BULLISH
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
class TestStatus:
def test_get_status(self, store: PlaybookStore) -> None:
store.save(_make_playbook())
status = store.get_status(date(2026, 2, 8), "KR")
assert status == PlaybookStatus.READY
def test_get_status_not_found(self, store: PlaybookStore) -> None:
assert store.get_status(date(2026, 1, 1), "KR") is None
def test_update_status(self, store: PlaybookStore) -> None:
store.save(_make_playbook())
updated = store.update_status(date(2026, 2, 8), "KR", PlaybookStatus.EXPIRED)
assert updated is True
status = store.get_status(date(2026, 2, 8), "KR")
assert status == PlaybookStatus.EXPIRED
def test_update_status_not_found(self, store: PlaybookStore) -> None:
updated = store.update_status(date(2026, 1, 1), "KR", PlaybookStatus.FAILED)
assert updated is False
# ---------------------------------------------------------------------------
# Match count
# ---------------------------------------------------------------------------
class TestMatchCount:
def test_increment_match_count(self, store: PlaybookStore) -> None:
store.save(_make_playbook())
store.increment_match_count(date(2026, 2, 8), "KR")
store.increment_match_count(date(2026, 2, 8), "KR")
stats = store.get_stats(date(2026, 2, 8), "KR")
assert stats is not None
assert stats["match_count"] == 2
def test_increment_not_found(self, store: PlaybookStore) -> None:
result = store.increment_match_count(date(2026, 1, 1), "KR")
assert result is False
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
class TestStats:
def test_get_stats(self, store: PlaybookStore) -> None:
store.save(_make_playbook())
stats = store.get_stats(date(2026, 2, 8), "KR")
assert stats is not None
assert stats["status"] == "ready"
assert stats["token_count"] == 150
assert stats["scenario_count"] == 1
assert stats["match_count"] == 0
assert stats["generated_at"] != ""
def test_get_stats_not_found(self, store: PlaybookStore) -> None:
assert store.get_stats(date(2026, 1, 1), "KR") is None
# ---------------------------------------------------------------------------
# List recent
# ---------------------------------------------------------------------------
class TestListRecent:
def test_list_recent(self, store: PlaybookStore) -> None:
for day in range(5, 10):
store.save(_make_playbook(target_date=date(2026, 2, day)))
results = store.list_recent(market="KR", limit=3)
assert len(results) == 3
# Most recent first
assert results[0]["date"] == "2026-02-09"
assert results[2]["date"] == "2026-02-07"
def test_list_recent_all_markets(self, store: PlaybookStore) -> None:
store.save(_make_playbook(market="KR"))
store.save(_make_playbook(market="US", stock_codes=["AAPL"]))
results = store.list_recent(market=None, limit=10)
assert len(results) == 2
def test_list_recent_empty(self, store: PlaybookStore) -> None:
results = store.list_recent(market="KR")
assert results == []
def test_list_recent_filter_by_market(self, store: PlaybookStore) -> None:
store.save(_make_playbook(market="KR"))
store.save(_make_playbook(market="US", stock_codes=["AAPL"]))
kr_only = store.list_recent(market="KR")
assert len(kr_only) == 1
assert kr_only[0]["market"] == "KR"
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
class TestDelete:
def test_delete(self, store: PlaybookStore) -> None:
store.save(_make_playbook())
deleted = store.delete(date(2026, 2, 8), "KR")
assert deleted is True
assert store.load(date(2026, 2, 8), "KR") is None
def test_delete_not_found(self, store: PlaybookStore) -> None:
deleted = store.delete(date(2026, 1, 1), "KR")
assert deleted is False
def test_delete_one_market_keeps_other(self, store: PlaybookStore) -> None:
store.save(_make_playbook(market="KR"))
store.save(_make_playbook(market="US", stock_codes=["AAPL"]))
store.delete(date(2026, 2, 8), "KR")
assert store.load(date(2026, 2, 8), "KR") is None
assert store.load(date(2026, 2, 8), "US") is not None

View File

@@ -0,0 +1,442 @@
"""Tests for the local scenario engine."""
from __future__ import annotations
from datetime import date
import pytest
from src.strategy.models import (
DayPlaybook,
GlobalRule,
ScenarioAction,
StockCondition,
StockPlaybook,
StockScenario,
)
from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch
@pytest.fixture
def engine() -> ScenarioEngine:
return ScenarioEngine()
def _scenario(
rsi_below: float | None = None,
rsi_above: float | None = None,
volume_ratio_above: float | None = None,
action: ScenarioAction = ScenarioAction.BUY,
confidence: int = 85,
**kwargs,
) -> StockScenario:
return StockScenario(
condition=StockCondition(
rsi_below=rsi_below,
rsi_above=rsi_above,
volume_ratio_above=volume_ratio_above,
**kwargs,
),
action=action,
confidence=confidence,
rationale=f"Test scenario: {action.value}",
)
def _playbook(
stock_code: str = "005930",
scenarios: list[StockScenario] | None = None,
global_rules: list[GlobalRule] | None = None,
default_action: ScenarioAction = ScenarioAction.HOLD,
) -> DayPlaybook:
if scenarios is None:
scenarios = [_scenario(rsi_below=30.0)]
return DayPlaybook(
date=date(2026, 2, 7),
market="KR",
stock_playbooks=[StockPlaybook(stock_code=stock_code, scenarios=scenarios)],
global_rules=global_rules or [],
default_action=default_action,
)
# ---------------------------------------------------------------------------
# evaluate_condition
# ---------------------------------------------------------------------------
class TestEvaluateCondition:
def test_rsi_below_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(rsi_below=30.0)
assert engine.evaluate_condition(cond, {"rsi": 25.0})
def test_rsi_below_no_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(rsi_below=30.0)
assert not engine.evaluate_condition(cond, {"rsi": 35.0})
def test_rsi_above_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(rsi_above=70.0)
assert engine.evaluate_condition(cond, {"rsi": 75.0})
def test_rsi_above_no_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(rsi_above=70.0)
assert not engine.evaluate_condition(cond, {"rsi": 65.0})
def test_volume_ratio_above_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(volume_ratio_above=3.0)
assert engine.evaluate_condition(cond, {"volume_ratio": 4.5})
def test_volume_ratio_below_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(volume_ratio_below=1.0)
assert engine.evaluate_condition(cond, {"volume_ratio": 0.5})
def test_price_above_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(price_above=50000)
assert engine.evaluate_condition(cond, {"current_price": 55000})
def test_price_below_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(price_below=50000)
assert engine.evaluate_condition(cond, {"current_price": 45000})
def test_price_change_pct_above_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(price_change_pct_above=2.0)
assert engine.evaluate_condition(cond, {"price_change_pct": 3.5})
def test_price_change_pct_below_match(self, engine: ScenarioEngine) -> None:
cond = StockCondition(price_change_pct_below=-3.0)
assert engine.evaluate_condition(cond, {"price_change_pct": -4.0})
def test_multiple_conditions_and_logic(self, engine: ScenarioEngine) -> None:
cond = StockCondition(rsi_below=30.0, volume_ratio_above=3.0)
# Both met
assert engine.evaluate_condition(cond, {"rsi": 25.0, "volume_ratio": 4.0})
# Only RSI met
assert not engine.evaluate_condition(cond, {"rsi": 25.0, "volume_ratio": 2.0})
# Only volume met
assert not engine.evaluate_condition(cond, {"rsi": 35.0, "volume_ratio": 4.0})
# Neither met
assert not engine.evaluate_condition(cond, {"rsi": 35.0, "volume_ratio": 2.0})
def test_empty_condition_returns_false(self, engine: ScenarioEngine) -> None:
cond = StockCondition()
assert not engine.evaluate_condition(cond, {"rsi": 25.0})
def test_missing_data_returns_false(self, engine: ScenarioEngine) -> None:
cond = StockCondition(rsi_below=30.0)
assert not engine.evaluate_condition(cond, {})
def test_none_data_returns_false(self, engine: ScenarioEngine) -> None:
cond = StockCondition(rsi_below=30.0)
assert not engine.evaluate_condition(cond, {"rsi": None})
def test_boundary_value_not_matched(self, engine: ScenarioEngine) -> None:
"""rsi_below=30 should NOT match rsi=30 (strict less than)."""
cond = StockCondition(rsi_below=30.0)
assert not engine.evaluate_condition(cond, {"rsi": 30.0})
def test_boundary_value_above_not_matched(self, engine: ScenarioEngine) -> None:
"""rsi_above=70 should NOT match rsi=70 (strict greater than)."""
cond = StockCondition(rsi_above=70.0)
assert not engine.evaluate_condition(cond, {"rsi": 70.0})
def test_string_value_no_exception(self, engine: ScenarioEngine) -> None:
"""String numeric value should not raise TypeError."""
cond = StockCondition(rsi_below=30.0)
# "25" can be cast to float → should match
assert engine.evaluate_condition(cond, {"rsi": "25"})
# "35" → should not match
assert not engine.evaluate_condition(cond, {"rsi": "35"})
def test_percent_string_returns_false(self, engine: ScenarioEngine) -> None:
"""Percent string like '30%' cannot be cast to float → False, no exception."""
cond = StockCondition(rsi_below=30.0)
assert not engine.evaluate_condition(cond, {"rsi": "30%"})
def test_decimal_value_no_exception(self, engine: ScenarioEngine) -> None:
"""Decimal values should be safely handled."""
from decimal import Decimal
cond = StockCondition(rsi_below=30.0)
assert engine.evaluate_condition(cond, {"rsi": Decimal("25.0")})
def test_mixed_invalid_types_no_exception(self, engine: ScenarioEngine) -> None:
"""Various invalid types should not raise exceptions."""
cond = StockCondition(
rsi_below=30.0, volume_ratio_above=2.0,
price_above=100, price_change_pct_below=-1.0,
)
data = {
"rsi": [25], # list
"volume_ratio": "bad", # non-numeric string
"current_price": {}, # dict
"price_change_pct": object(), # arbitrary object
}
# Should return False (invalid types → None → False), never raise
assert not engine.evaluate_condition(cond, data)
def test_missing_key_logs_warning_once(self, caplog) -> None:
"""Missing key warning should fire only once per key per engine instance."""
import logging
eng = ScenarioEngine()
cond = StockCondition(rsi_below=30.0)
with caplog.at_level(logging.WARNING):
eng.evaluate_condition(cond, {})
eng.evaluate_condition(cond, {})
eng.evaluate_condition(cond, {})
# Warning should appear exactly once despite 3 calls
assert caplog.text.count("'rsi' but key missing") == 1
# ---------------------------------------------------------------------------
# check_global_rules
# ---------------------------------------------------------------------------
class TestCheckGlobalRules:
def test_no_rules(self, engine: ScenarioEngine) -> None:
pb = _playbook(global_rules=[])
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -1.0})
assert result is None
def test_rule_triggered(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
rationale="Near circuit breaker",
),
]
)
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -2.5})
assert result is not None
assert result.action == ScenarioAction.REDUCE_ALL
def test_rule_not_triggered(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
),
]
)
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -1.0})
assert result is None
def test_first_rule_wins(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(condition="portfolio_pnl_pct < -2.0", action=ScenarioAction.REDUCE_ALL),
GlobalRule(condition="portfolio_pnl_pct < -1.0", action=ScenarioAction.HOLD),
]
)
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -2.5})
assert result is not None
assert result.action == ScenarioAction.REDUCE_ALL
def test_greater_than_operator(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(condition="volatility_index > 30", action=ScenarioAction.HOLD),
]
)
result = engine.check_global_rules(pb, {"volatility_index": 35})
assert result is not None
def test_missing_field_not_triggered(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(condition="unknown_field < -2.0", action=ScenarioAction.REDUCE_ALL),
]
)
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -5.0})
assert result is None
def test_invalid_condition_format(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(condition="bad format", action=ScenarioAction.HOLD),
]
)
result = engine.check_global_rules(pb, {})
assert result is None
def test_le_operator(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(condition="portfolio_pnl_pct <= -2.0", action=ScenarioAction.REDUCE_ALL),
]
)
assert engine.check_global_rules(pb, {"portfolio_pnl_pct": -2.0}) is not None
assert engine.check_global_rules(pb, {"portfolio_pnl_pct": -1.9}) is None
def test_ge_operator(self, engine: ScenarioEngine) -> None:
pb = _playbook(
global_rules=[
GlobalRule(condition="volatility >= 80.0", action=ScenarioAction.HOLD),
]
)
assert engine.check_global_rules(pb, {"volatility": 80.0}) is not None
assert engine.check_global_rules(pb, {"volatility": 79.9}) is None
# ---------------------------------------------------------------------------
# evaluate (full pipeline)
# ---------------------------------------------------------------------------
class TestEvaluate:
def test_scenario_match(self, engine: ScenarioEngine) -> None:
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
assert result.action == ScenarioAction.BUY
assert result.confidence == 85
assert result.matched_scenario is not None
def test_no_scenario_match_returns_default(self, engine: ScenarioEngine) -> None:
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
result = engine.evaluate(pb, "005930", {"rsi": 50.0}, {})
assert result.action == ScenarioAction.HOLD
assert result.confidence == 0
assert result.matched_scenario is None
def test_stock_not_in_playbook(self, engine: ScenarioEngine) -> None:
pb = _playbook(stock_code="005930")
result = engine.evaluate(pb, "AAPL", {"rsi": 25.0}, {})
assert result.action == ScenarioAction.HOLD
assert result.confidence == 0
def test_global_rule_takes_priority(self, engine: ScenarioEngine) -> None:
pb = _playbook(
scenarios=[_scenario(rsi_below=30.0)],
global_rules=[
GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
rationale="Loss limit",
),
],
)
result = engine.evaluate(
pb,
"005930",
{"rsi": 25.0}, # Would match scenario
{"portfolio_pnl_pct": -2.5}, # But global rule triggers first
)
assert result.action == ScenarioAction.REDUCE_ALL
assert result.global_rule_triggered is not None
assert result.matched_scenario is None
def test_first_scenario_wins(self, engine: ScenarioEngine) -> None:
pb = _playbook(
scenarios=[
_scenario(rsi_below=30.0, action=ScenarioAction.BUY, confidence=90),
_scenario(rsi_below=25.0, action=ScenarioAction.BUY, confidence=95),
]
)
result = engine.evaluate(pb, "005930", {"rsi": 20.0}, {})
# Both match, but first wins
assert result.confidence == 90
def test_sell_scenario(self, engine: ScenarioEngine) -> None:
pb = _playbook(
scenarios=[
_scenario(rsi_above=75.0, action=ScenarioAction.SELL, confidence=80),
]
)
result = engine.evaluate(pb, "005930", {"rsi": 80.0}, {})
assert result.action == ScenarioAction.SELL
def test_empty_playbook(self, engine: ScenarioEngine) -> None:
pb = DayPlaybook(date=date(2026, 2, 7), market="KR", stock_playbooks=[])
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
assert result.action == ScenarioAction.HOLD
def test_match_details_populated(self, engine: ScenarioEngine) -> None:
pb = _playbook(scenarios=[_scenario(rsi_below=30.0, volume_ratio_above=2.0)])
result = engine.evaluate(
pb, "005930", {"rsi": 25.0, "volume_ratio": 3.0}, {}
)
assert result.match_details.get("rsi") == 25.0
assert result.match_details.get("volume_ratio") == 3.0
def test_custom_default_action(self, engine: ScenarioEngine) -> None:
pb = _playbook(
scenarios=[_scenario(rsi_below=10.0)], # Very unlikely to match
default_action=ScenarioAction.SELL,
)
result = engine.evaluate(pb, "005930", {"rsi": 50.0}, {})
assert result.action == ScenarioAction.SELL
def test_multiple_stocks_in_playbook(self, engine: ScenarioEngine) -> None:
pb = DayPlaybook(
date=date(2026, 2, 7),
market="US",
stock_playbooks=[
StockPlaybook(
stock_code="AAPL",
scenarios=[_scenario(rsi_below=25.0, confidence=90)],
),
StockPlaybook(
stock_code="MSFT",
scenarios=[_scenario(rsi_above=75.0, action=ScenarioAction.SELL, confidence=80)],
),
],
)
aapl = engine.evaluate(pb, "AAPL", {"rsi": 20.0}, {})
assert aapl.action == ScenarioAction.BUY
assert aapl.confidence == 90
msft = engine.evaluate(pb, "MSFT", {"rsi": 80.0}, {})
assert msft.action == ScenarioAction.SELL
def test_complex_multi_condition(self, engine: ScenarioEngine) -> None:
pb = _playbook(
scenarios=[
_scenario(
rsi_below=30.0,
volume_ratio_above=3.0,
price_change_pct_below=-2.0,
confidence=95,
),
]
)
# All conditions met
result = engine.evaluate(
pb,
"005930",
{"rsi": 22.0, "volume_ratio": 4.0, "price_change_pct": -3.0},
{},
)
assert result.action == ScenarioAction.BUY
assert result.confidence == 95
# One condition not met
result2 = engine.evaluate(
pb,
"005930",
{"rsi": 22.0, "volume_ratio": 4.0, "price_change_pct": -1.0},
{},
)
assert result2.action == ScenarioAction.HOLD
def test_scenario_match_returns_rationale(self, engine: ScenarioEngine) -> None:
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
assert result.rationale != ""
def test_result_stock_code(self, engine: ScenarioEngine) -> None:
pb = _playbook()
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
assert result.stock_code == "005930"
def test_match_details_normalized(self, engine: ScenarioEngine) -> None:
"""match_details should contain _safe_float normalized values, not raw."""
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
# Pass string value — should be normalized to float in match_details
result = engine.evaluate(pb, "005930", {"rsi": "25.0"}, {})
assert result.action == ScenarioAction.BUY
assert result.match_details["rsi"] == 25.0
assert isinstance(result.match_details["rsi"], float)

View File

@@ -0,0 +1,366 @@
"""Tests for strategy/playbook Pydantic models."""
from __future__ import annotations
from datetime import date
import pytest
from pydantic import ValidationError
from src.strategy.models import (
CrossMarketContext,
DayPlaybook,
GlobalRule,
MarketOutlook,
PlaybookStatus,
ScenarioAction,
StockCondition,
StockPlaybook,
StockScenario,
)
# ---------------------------------------------------------------------------
# StockCondition
# ---------------------------------------------------------------------------
class TestStockCondition:
def test_empty_condition(self) -> None:
cond = StockCondition()
assert not cond.has_any_condition()
def test_single_field(self) -> None:
cond = StockCondition(rsi_below=30.0)
assert cond.has_any_condition()
def test_multiple_fields(self) -> None:
cond = StockCondition(rsi_below=25.0, volume_ratio_above=3.0)
assert cond.has_any_condition()
def test_all_fields(self) -> None:
cond = StockCondition(
rsi_below=30,
rsi_above=10,
volume_ratio_above=2.0,
volume_ratio_below=10.0,
price_above=1000,
price_below=50000,
price_change_pct_above=-5.0,
price_change_pct_below=5.0,
)
assert cond.has_any_condition()
# ---------------------------------------------------------------------------
# StockScenario
# ---------------------------------------------------------------------------
class TestStockScenario:
def test_valid_scenario(self) -> None:
s = StockScenario(
condition=StockCondition(rsi_below=25.0),
action=ScenarioAction.BUY,
confidence=85,
allocation_pct=15.0,
stop_loss_pct=-2.0,
take_profit_pct=3.0,
rationale="Oversold bounce expected",
)
assert s.action == ScenarioAction.BUY
assert s.confidence == 85
def test_confidence_too_high(self) -> None:
with pytest.raises(ValidationError):
StockScenario(
condition=StockCondition(),
action=ScenarioAction.BUY,
confidence=101,
)
def test_confidence_too_low(self) -> None:
with pytest.raises(ValidationError):
StockScenario(
condition=StockCondition(),
action=ScenarioAction.BUY,
confidence=-1,
)
def test_allocation_too_high(self) -> None:
with pytest.raises(ValidationError):
StockScenario(
condition=StockCondition(),
action=ScenarioAction.BUY,
confidence=80,
allocation_pct=101.0,
)
def test_stop_loss_must_be_negative(self) -> None:
with pytest.raises(ValidationError):
StockScenario(
condition=StockCondition(),
action=ScenarioAction.BUY,
confidence=80,
stop_loss_pct=1.0,
)
def test_take_profit_must_be_positive(self) -> None:
with pytest.raises(ValidationError):
StockScenario(
condition=StockCondition(),
action=ScenarioAction.BUY,
confidence=80,
take_profit_pct=-1.0,
)
def test_defaults(self) -> None:
s = StockScenario(
condition=StockCondition(),
action=ScenarioAction.HOLD,
confidence=50,
)
assert s.allocation_pct == 10.0
assert s.stop_loss_pct == -2.0
assert s.take_profit_pct == 3.0
assert s.rationale == ""
# ---------------------------------------------------------------------------
# StockPlaybook
# ---------------------------------------------------------------------------
class TestStockPlaybook:
def test_valid_playbook(self) -> None:
pb = StockPlaybook(
stock_code="005930",
stock_name="Samsung Electronics",
scenarios=[
StockScenario(
condition=StockCondition(rsi_below=25.0),
action=ScenarioAction.BUY,
confidence=85,
),
],
)
assert pb.stock_code == "005930"
assert len(pb.scenarios) == 1
def test_empty_scenarios_rejected(self) -> None:
with pytest.raises(ValidationError):
StockPlaybook(
stock_code="005930",
scenarios=[],
)
def test_multiple_scenarios(self) -> None:
pb = StockPlaybook(
stock_code="AAPL",
scenarios=[
StockScenario(
condition=StockCondition(rsi_below=25.0),
action=ScenarioAction.BUY,
confidence=85,
),
StockScenario(
condition=StockCondition(rsi_above=75.0),
action=ScenarioAction.SELL,
confidence=80,
),
],
)
assert len(pb.scenarios) == 2
# ---------------------------------------------------------------------------
# GlobalRule
# ---------------------------------------------------------------------------
class TestGlobalRule:
def test_valid_rule(self) -> None:
rule = GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
rationale="Risk limit approaching",
)
assert rule.action == ScenarioAction.REDUCE_ALL
def test_hold_rule(self) -> None:
rule = GlobalRule(
condition="volatility_index > 30",
action=ScenarioAction.HOLD,
)
assert rule.rationale == ""
# ---------------------------------------------------------------------------
# CrossMarketContext
# ---------------------------------------------------------------------------
class TestCrossMarketContext:
def test_valid_context(self) -> None:
ctx = CrossMarketContext(
market="US",
date="2026-02-07",
total_pnl=-1.5,
win_rate=40.0,
index_change_pct=-2.3,
key_events=["Fed rate decision"],
lessons=["Avoid tech sector on rate hike days"],
)
assert ctx.market == "US"
assert len(ctx.key_events) == 1
def test_defaults(self) -> None:
ctx = CrossMarketContext(market="KR", date="2026-02-07")
assert ctx.total_pnl == 0.0
assert ctx.key_events == []
assert ctx.lessons == []
# ---------------------------------------------------------------------------
# DayPlaybook
# ---------------------------------------------------------------------------
def _make_scenario(rsi_below: float = 25.0) -> StockScenario:
return StockScenario(
condition=StockCondition(rsi_below=rsi_below),
action=ScenarioAction.BUY,
confidence=85,
)
def _make_playbook(**kwargs) -> DayPlaybook:
defaults = {
"date": date(2026, 2, 7),
"market": "KR",
"stock_playbooks": [
StockPlaybook(stock_code="005930", scenarios=[_make_scenario()]),
],
}
defaults.update(kwargs)
return DayPlaybook(**defaults)
class TestDayPlaybook:
def test_valid_playbook(self) -> None:
pb = _make_playbook()
assert pb.market == "KR"
assert pb.date == date(2026, 2, 7)
assert pb.default_action == ScenarioAction.HOLD
assert pb.scenario_count == 1
assert pb.stock_count == 1
def test_generated_at_auto_set(self) -> None:
pb = _make_playbook()
assert pb.generated_at != ""
def test_explicit_generated_at(self) -> None:
pb = _make_playbook(generated_at="2026-02-07T08:30:00")
assert pb.generated_at == "2026-02-07T08:30:00"
def test_duplicate_stocks_rejected(self) -> None:
with pytest.raises(ValidationError):
DayPlaybook(
date=date(2026, 2, 7),
market="KR",
stock_playbooks=[
StockPlaybook(stock_code="005930", scenarios=[_make_scenario()]),
StockPlaybook(stock_code="005930", scenarios=[_make_scenario(30)]),
],
)
def test_empty_stock_playbooks_allowed(self) -> None:
pb = DayPlaybook(
date=date(2026, 2, 7),
market="KR",
stock_playbooks=[],
)
assert pb.stock_count == 0
assert pb.scenario_count == 0
def test_get_stock_playbook_found(self) -> None:
pb = _make_playbook()
result = pb.get_stock_playbook("005930")
assert result is not None
assert result.stock_code == "005930"
def test_get_stock_playbook_not_found(self) -> None:
pb = _make_playbook()
result = pb.get_stock_playbook("AAPL")
assert result is None
def test_with_global_rules(self) -> None:
pb = _make_playbook(
global_rules=[
GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
),
],
)
assert len(pb.global_rules) == 1
def test_with_cross_market_context(self) -> None:
ctx = CrossMarketContext(market="US", date="2026-02-07", total_pnl=-1.5)
pb = _make_playbook(cross_market=ctx)
assert pb.cross_market is not None
assert pb.cross_market.market == "US"
def test_market_outlook(self) -> None:
pb = _make_playbook(market_outlook=MarketOutlook.BEARISH)
assert pb.market_outlook == MarketOutlook.BEARISH
def test_multiple_stocks_multiple_scenarios(self) -> None:
pb = DayPlaybook(
date=date(2026, 2, 7),
market="US",
stock_playbooks=[
StockPlaybook(
stock_code="AAPL",
scenarios=[_make_scenario(), _make_scenario(30)],
),
StockPlaybook(
stock_code="MSFT",
scenarios=[_make_scenario()],
),
],
)
assert pb.stock_count == 2
assert pb.scenario_count == 3
def test_serialization_roundtrip(self) -> None:
pb = _make_playbook(
market_outlook=MarketOutlook.BULLISH,
cross_market=CrossMarketContext(market="US", date="2026-02-07"),
)
json_str = pb.model_dump_json()
restored = DayPlaybook.model_validate_json(json_str)
assert restored.market == pb.market
assert restored.date == pb.date
assert restored.scenario_count == pb.scenario_count
assert restored.cross_market is not None
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class TestEnums:
def test_scenario_action_values(self) -> None:
assert ScenarioAction.BUY.value == "BUY"
assert ScenarioAction.SELL.value == "SELL"
assert ScenarioAction.HOLD.value == "HOLD"
assert ScenarioAction.REDUCE_ALL.value == "REDUCE_ALL"
def test_market_outlook_values(self) -> None:
assert len(MarketOutlook) == 5
def test_playbook_status_values(self) -> None:
assert PlaybookStatus.READY.value == "ready"
assert PlaybookStatus.EXPIRED.value == "expired"