Compare commits
6 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1210c17989 | ||
| c43660a58c | |||
|
|
7fd48c7764 | ||
| a105bb7c1a | |||
|
|
1a34a74232 | ||
| a82a167915 |
45
docs/agent-constraints.md
Normal file
45
docs/agent-constraints.md
Normal 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.
|
||||
@@ -55,8 +55,15 @@ class Settings(BaseSettings):
|
||||
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
|
||||
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)
|
||||
ENABLED_MARKETS: str = "KR"
|
||||
ENABLED_MARKETS: str = "KR,US"
|
||||
|
||||
# Backup and Disaster Recovery (optional)
|
||||
BACKUP_ENABLED: bool = True
|
||||
|
||||
79
src/main.py
79
src/main.py
@@ -63,14 +63,6 @@ def safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||
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
|
||||
SCAN_INTERVAL_SECONDS = 60 # Scan markets every 60 seconds
|
||||
MAX_CONNECTION_RETRIES = 3
|
||||
@@ -79,15 +71,6 @@ MAX_CONNECTION_RETRIES = 3
|
||||
DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
|
||||
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(
|
||||
broker: KISBroker,
|
||||
@@ -349,6 +332,7 @@ async def run_daily_session(
|
||||
criticality_assessor: CriticalityAssessor,
|
||||
telegram: TelegramClient,
|
||||
settings: Settings,
|
||||
smart_scanner: SmartVolatilityScanner | None = None,
|
||||
) -> None:
|
||||
"""Execute one daily trading session.
|
||||
|
||||
@@ -368,15 +352,21 @@ async def run_daily_session(
|
||||
|
||||
# Process each open market
|
||||
for market in open_markets:
|
||||
# Get watchlist for this market
|
||||
watchlist = WATCHLISTS.get(market.code, [])
|
||||
# Dynamic stock discovery via scanner (no static watchlists)
|
||||
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:
|
||||
logger.debug("No watchlist for market %s", market.code)
|
||||
logger.info("No scanner candidates for market %s — skipping", market.code)
|
||||
continue
|
||||
|
||||
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 = []
|
||||
for stock_code in watchlist:
|
||||
try:
|
||||
@@ -745,6 +735,9 @@ async def run(settings: Settings) -> None:
|
||||
# Track scan candidates for selection context logging
|
||||
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
|
||||
criticality_assessor = CriticalityAssessor(
|
||||
critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0%
|
||||
@@ -817,6 +810,7 @@ async def run(settings: Settings) -> None:
|
||||
criticality_assessor,
|
||||
telegram,
|
||||
settings,
|
||||
smart_scanner=smart_scanner,
|
||||
)
|
||||
except CircuitBreakerTripped:
|
||||
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)
|
||||
_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()
|
||||
last_scan = last_scan_time.get(market.code, 0.0)
|
||||
if now_timestamp - last_scan >= SCAN_INTERVAL_SECONDS:
|
||||
try:
|
||||
logger.info("Smart Scanner: Scanning %s market", market.name)
|
||||
|
||||
# Run smart scan with fallback to static universe
|
||||
fallback_universe = STOCK_UNIVERSE.get(market.code, [])
|
||||
candidates = await smart_scanner.scan(fallback_stocks=fallback_universe)
|
||||
candidates = await smart_scanner.scan()
|
||||
|
||||
if candidates:
|
||||
# Update watchlist with qualified candidates
|
||||
qualified_codes = smart_scanner.get_stock_codes(candidates)
|
||||
# Use scanner results directly as trading candidates
|
||||
active_stocks[market.code] = smart_scanner.get_stock_codes(
|
||||
candidates
|
||||
)
|
||||
|
||||
# Merge with existing watchlist (keep some continuity)
|
||||
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
|
||||
# Store candidates for selection context logging
|
||||
for candidate in candidates:
|
||||
scan_candidates[candidate.stock_code] = candidate
|
||||
|
||||
logger.info(
|
||||
"Smart Scanner: Found %d qualified candidates for %s: %s",
|
||||
"Smart Scanner: Found %d candidates for %s: %s",
|
||||
len(candidates),
|
||||
market.name,
|
||||
[f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates],
|
||||
)
|
||||
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
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
||||
|
||||
# Get watchlist for this market
|
||||
watchlist = WATCHLISTS.get(market.code, [])
|
||||
if not watchlist:
|
||||
logger.debug("No watchlist for market %s", market.code)
|
||||
# Get active stocks from scanner (dynamic, no static fallback)
|
||||
stock_codes = active_stocks.get(market.code, [])
|
||||
if not stock_codes:
|
||||
logger.debug("No active stocks for market %s", market.code)
|
||||
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
|
||||
for stock_code in watchlist:
|
||||
# Process each stock from scanner results
|
||||
for stock_code in stock_codes:
|
||||
if shutdown.is_set():
|
||||
break
|
||||
|
||||
|
||||
0
src/strategy/__init__.py
Normal file
0
src/strategy/__init__.py
Normal file
164
src/strategy/models.py
Normal file
164
src/strategy/models.py
Normal 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()
|
||||
366
tests/test_strategy_models.py
Normal file
366
tests/test_strategy_models.py
Normal 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"
|
||||
Reference in New Issue
Block a user