Compare commits

..

76 Commits

Author SHA1 Message Date
agentson
d9763def85 feat: integrate ContextScheduler into main loop (issue #89)
Some checks failed
CI / test (pull_request) Has been cancelled
Wire up periodic context rollups (weekly/monthly/quarterly/annual/legacy)
in both daily and realtime trading loops with dedup-safe scheduling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:37:30 +09:00
ab7f0444b2 Merge pull request 'feat: 플래너에 자기 시장 성적표 주입 (issue #94)' (#124) from feature/issue-94-planner-scorecard-injection into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #124
2026-02-14 23:34:09 +09:00
agentson
6b3960a3a4 feat: inject self-market scorecard into planner prompt (issue #94)
Some checks failed
CI / test (pull_request) Has been cancelled
Add build_self_market_scorecard() to read previous day's own market
performance, and include it in the Gemini planning prompt alongside
cross-market context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:27:01 +09:00
6cad8e74e1 Merge pull request 'feat: 플래너 크로스마켓 날짜 보정 + 전략 컨텍스트 (issue #88)' (#123) from feat/v2-2-4-planner-context-crossmarket into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #123
2026-02-14 23:21:12 +09:00
agentson
86c94cff62 feat: cross-market date fix and strategic context selector (issue #88)
Some checks failed
CI / test (pull_request) Has been cancelled
KR planner now reads US scorecard from previous day (timezone-aware),
and generate_playbook uses STRATEGIC context selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:20:24 +09:00
692cb61991 Merge pull request 'feat: main.py에 일일 리뷰 연결 (issue #93)' (#122) from feature/issue-93-daily-review-integration into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #122
2026-02-14 23:15:26 +09:00
agentson
392422992b feat: integrate DailyReviewer into market close flow (issue #93)
Some checks failed
CI / test (pull_request) Has been cancelled
Extract _handle_market_close() helper that runs EOD aggregation,
generates scorecard with optional AI lessons, and sends Telegram summary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:13:57 +09:00
cc637a9738 Merge pull request 'feat: Daily Reviewer - 시장별 성적표 생성 (issue #91)' (#121) from feature/issue-91-daily-reviewer into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #121
2026-02-14 23:08:05 +09:00
agentson
8c27473fed feat: DailyReviewer for market-scoped scorecards and AI lessons (issue #91)
Some checks failed
CI / test (pull_request) Has been cancelled
Generate per-market daily scorecards from decision_logs and trades,
optional Gemini-powered lessons, and store results in L6 context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:07:12 +09:00
bde54c7487 Merge pull request 'feat: Decision outcome 업데이트 (issue #92)' (#120) from feature/issue-92-decision-outcome into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #120
2026-02-14 22:41:29 +09:00
agentson
a14f944fcc feat: link decision outcomes to trades via decision_id (issue #92)
Some checks failed
CI / test (pull_request) Has been cancelled
Add decision_id column to trades table, capture log_decision() return
value, and update original BUY decision outcome on SELL execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:36:57 +09:00
56f7405baa Merge pull request 'feat: 컨텍스트 집계 스케줄러 (issue #87)' (#119) from feature/issue-87-context-scheduler into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #119
2026-02-10 04:28:42 +09:00
agentson
e3b1ecc572 feat: context aggregation scheduler (issue #87)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add ContextScheduler with run_if_due() for periodic rollups
- Weekly (Sunday), monthly (last day), quarterly, annual, legacy schedules
- Daily cleanup of expired contexts via ContextStore
- Dedup guard: each task runs at most once per day

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 04:26:51 +09:00
8acf72b22c Merge pull request 'feat: DailyScorecard 모델 정의 (issue #90)' (#118) from feature/issue-90-scorecard-model into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #118
2026-02-10 04:26:21 +09:00
agentson
c95102a0bd feat: DailyScorecard model for per-market performance review (issue #90)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add DailyScorecard dataclass with market-scoped fields
- Fields: date, market, decisions, pnl, win_rate, scenario_match_rate, lessons, cross_market_note
- Export from src/evolution/__init__.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 04:25:37 +09:00
0685d62f9c Merge pull request 'feat: EOD 집계 시장 필터 추가 (issue #86)' (#117) from feature/issue-86-eod-market-filter into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #117
2026-02-10 04:24:58 +09:00
agentson
78021d4695 feat: EOD aggregation with market filter (issue #86)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add market parameter to aggregate_daily_from_trades() for per-market L6 aggregation
- Store market-scoped keys (total_pnl_KR, win_rate_US, etc.) in L6/L5/L4 layers
- Hook aggregate_daily_from_trades() into market close detection in run()
- Update tests for market-scoped context keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 04:23:49 +09:00
3cdd10783b Merge pull request 'feat: L7 실시간 컨텍스트 시장별 기록 (issue #85)' (#116) from feature/issue-85-l7-context-write into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #116
2026-02-10 04:22:57 +09:00
agentson
c4e31be27a feat: L7 real-time context write with market-scoped keys (issue #85)
Some checks failed
CI / test (pull_request) Has been cancelled
- Add L7_REALTIME writes in trading_cycle() for volatility, price, rsi, volume_ratio
- Normalize key format to {metric}_{market}_{stock_code} across scanner and main
- Fix existing key mismatch between scanner writes and main reads
- Remove unused MarketScanner dead code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 04:21:52 +09:00
9d9ade14eb Merge pull request 'docs: add plan-implementation consistency check to code review checklist (#114)' (#115) from feature/issue-114-review-plan-consistency into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #115
2026-02-10 04:16:30 +09:00
agentson
9a8936ab34 docs: add plan-implementation consistency check to code review checklist (#114)
Some checks failed
CI / test (pull_request) Has been cancelled
리뷰 시 플랜과 구현의 일치 여부를 필수로 확인하는 규칙 추가.
- workflow.md에 Code Review Checklist 섹션 신설
- requirements-log.md에 사용자 요구사항 기록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 04:15:51 +09:00
c5831966ed Merge pull request 'fix: derive all aggregation timeframes from trade timestamp (#112)' (#113) from fix/test-failures into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #113
2026-02-10 00:42:39 +09:00
agentson
f03cc6039b fix: derive all aggregation timeframes from trade timestamp (#112)
Some checks failed
CI / test (pull_request) Has been cancelled
run_all_aggregations() previously used datetime.now(UTC) for weekly
through annual layers while using the trade date only for daily,
causing data misalignment on backfill. Now all layers consistently
use the latest trade timestamp. Also adds "Z" suffix handling for
fromisoformat() compatibility and strengthens test assertions to
verify L4-L2 layer values end-to-end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 00:40:28 +09:00
9171e54652 Merge pull request 'feat: integrate scenario engine and playbook into main trading loop (issue #84)' (#110) from feature/issue-84-main-integration into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #110
2026-02-09 23:18:24 +09:00
agentson
d64e072f06 fix: PR review — DB reload, market-local date, market-scoped scan_candidates
Some checks failed
CI / test (pull_request) Has been cancelled
Address PR #110 review findings:

1. High — Realtime mode now loads playbook from DB before calling Gemini,
   preventing duplicate API calls on process restart (4/day budget).
2. Medium — Pass market-local date (via market.timezone) to
   generate_playbook() and _empty_playbook() instead of date.today().
3. Medium — scan_candidates restructured from {stock_code: candidate}
   to {market_code: {stock_code: candidate}} to prevent KR/US symbol
   collision.

New test: test_scan_candidates_market_scoped verifies cross-market
isolation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:00:06 +09:00
agentson
b2312fbe01 fix: resolve lint issues in main.py and test_main.py
Some checks failed
CI / test (pull_request) Has been cancelled
Remove unused imports (sys, ScenarioMatch, asyncio, StockPlaybook),
fix import ordering, and split long lines for ruff compliance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:28:31 +09:00
agentson
98c4a2413c feat: integrate scenario engine and playbook into main trading loop (issue #84)
Replace brain.decide() with scenario_engine.evaluate() in trading_cycle
and brain.decide_batch() with per-stock scenario evaluation in
run_daily_session. Initialize PreMarketPlanner, ScenarioEngine, and
PlaybookStore in run(). Add pre-market playbook generation on market
open (1 Gemini call per market per day), market_data enrichment from
scanner metrics (rsi, volume_ratio), portfolio_data for global rules,
scenario match notifications, and playbook lifecycle management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:24:19 +09:00
6fba7c7ae8 Merge pull request 'feat: implement pre-market planner with Gemini integration (issue #83)' (#109) from feature/issue-83-pre-market-planner into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #109
2026-02-08 22:07:36 +09:00
agentson
be695a5d7c fix: address PR review — inject today param, remove unused imports, fix lint
Some checks failed
CI / test (pull_request) Has been cancelled
Review findings addressed:
- Finding 1 (ImportError): false positive — ContextLayer is re-exported from
  src.context.store, import works correctly at runtime
- Finding 2 (timezone): generate_playbook() and build_cross_market_context()
  now accept optional today parameter for market-local date injection
- Finding 3 (lint): removed unused imports (UTC, datetime, PlaybookStatus),
  fixed line-too-long in prompt template
- Tests simplified: replaced date patching with direct today= parameter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:57:39 +09:00
agentson
6471e66d89 fix: correct Settings field name in planner tests (KIS_ACCOUNT_NO)
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:36:42 +09:00
agentson
149039a904 feat: implement pre-market planner with Gemini integration (issue #83)
PreMarketPlanner generates DayPlaybook via single Gemini API call per market:
- Structured JSON prompt with scan candidates + strategic context
- Cross-market context (KR reads US scorecard, US reads KR scorecard)
- Robust JSON parser with markdown fence stripping
- Unknown stock filtering (only scanner candidates allowed)
- MAX_SCENARIOS_PER_STOCK enforcement
- Defensive playbook on failure (HOLD + stop-loss)
- Empty playbook when no candidates (safe, no trades)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:35:57 +09:00
815d675529 Merge pull request 'feat: add Telegram playbook notifications (issue #81)' (#108) from feature/issue-81-telegram-playbook-notify into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #108
2026-02-08 21:27:46 +09:00
agentson
e8634b93c3 feat: add Telegram playbook notifications (issue #81)
Some checks failed
CI / test (pull_request) Has been cancelled
- notify_playbook_generated(): market, stock/scenario count, token usage (MEDIUM)
- notify_scenario_matched(): stock, action, condition, confidence (HIGH)
- notify_playbook_failed(): market, reason with 200-char truncation (HIGH)
- 6 new tests: 3 format + 3 priority validations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 21:25:16 +09:00
f20736fd2a Merge pull request 'feat: add playbook persistence with DB schema and CRUD store (issue #82)' (#107) from feature/issue-82-playbook-persistence into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #107
2026-02-08 21:07:13 +09:00
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
agentson
7725e7a8de docs: update documentation for Smart Volatility Scanner
Some checks failed
CI / test (pull_request) Has been cancelled
Update project documentation to reflect new Smart Volatility Scanner feature:

## CLAUDE.md
- Add Smart Volatility Scanner section with configuration guide
- Update project structure to include analysis/ module
- Update test count (273→343 tests)

## docs/architecture.md
- Add Analysis component (VolatilityAnalyzer + SmartVolatilityScanner)
- Add new KIS API methods (fetch_market_rankings, get_daily_prices)
- Update data flow diagram to show Python-first filtering pipeline
- Add selection_context to database schema documentation
- Add Smart Scanner configuration section
- Renumber components (Brain 2→3, Risk Manager 3→4, etc.)

## docs/requirements-log.md
- Document 2026-02-06 requirement for Smart Volatility Scanner
- Explain Python-First, AI-Last pipeline rationale
- Record implementation details and benefits
- Reference issue #76 and PR #77

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 07:35:25 +09:00
agentson
f0ae25c533 feat: implement Smart Volatility Scanner with RSI/volume filters (issue #76)
Some checks failed
CI / test (pull_request) Has been cancelled
Add Python-first scanning pipeline that reduces Gemini API calls by filtering
stocks before AI analysis: KIS rankings API -> RSI/volume filter -> AI judgment.

## Implementation
- Add RSI calculation (Wilder's smoothing method) to VolatilityAnalyzer
- Add KIS API methods: fetch_market_rankings() and get_daily_prices()
- Create SmartVolatilityScanner with configurable thresholds
- Integrate scanner into main.py realtime mode
- Add selection_context logging to trades table for Evolution system

## Configuration
- RSI_OVERSOLD_THRESHOLD: 30 (configurable 0-50)
- RSI_MOMENTUM_THRESHOLD: 70 (configurable 50-100)
- VOL_MULTIPLIER: 2.0 (minimum volume ratio, configurable 1-10)
- SCANNER_TOP_N: 3 (max candidates per scan, configurable 1-10)

## Benefits
- Reduces Gemini API calls (process 1-3 qualified stocks vs 20-30 ranked)
- Python-based technical filtering before expensive AI judgment
- Tracks selection criteria (RSI, volume_ratio, signal, score) for strategy optimization
- Graceful fallback to static watchlist if ranking API fails

## Tests
- 13 new tests for SmartVolatilityScanner and RSI calculation
- All existing tests updated and passing
- Coverage maintained at 73%

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 00:48:23 +09:00
27f581f17d Merge pull request 'fix: resolve Telegram command handler errors for /status and /positions (issue #74)' (#75) from feature/issue-74-telegram-command-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #75
2026-02-05 18:56:24 +09:00
agentson
18a098d9a6 fix: resolve Telegram command handler errors for /status and /positions (issue #74)
Some checks failed
CI / test (pull_request) Has been cancelled
Fixed AttributeError exceptions in /status and /positions commands:
- Replaced invalid risk.calculate_pnl() with inline P&L calculation from balance dict
- Changed risk.circuit_breaker_threshold to risk._cb_threshold
- Replaced balance.stocks access with account summary from output2 dict
- Updated tests to match new account summary format

All 27 telegram command tests pass. Live bot testing confirms no errors.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 18:54:42 +09:00
d2b07326ed Merge pull request 'fix: remove /start command and handle @botname suffix (issue #71)' (#72) from fix/start-command-parsing into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #72
2026-02-05 17:15:14 +09:00
agentson
1c5eadc23b fix: remove /start command and handle @botname suffix
Some checks failed
CI / test (pull_request) Has been cancelled
Remove /start command as name doesn't match functionality, and fix
command parsing to handle @botname suffix for group chat compatibility.

Changes:
- Remove handle_start function and registration
- Remove /start from help command list
- Remove test_start_command_content test
- Strip @botname suffix from commands (e.g., /help@mybot → help)

Rationale:
- /start command name implies bot initialization, but it was just
  showing help text (duplicate of /help)
- Better to have one clear /help command
- @botname suffix handling needed for group chats

Test:
- 27 tests pass (1 removed, 1 added for @botname handling)
- All existing functionality preserved

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 15:59:07 +09:00
10ff718045 Merge pull request 'feat: add configuration and documentation for Telegram commands (issue #69)' (#70) from feature/issue-69-config-docs into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #70
2026-02-05 15:50:52 +09:00
agentson
0ca3fe9f5d feat: add configuration and documentation for Telegram commands (issue #69)
Some checks failed
CI / test (pull_request) Has been cancelled
Add configuration options and comprehensive documentation for the new
bidirectional command feature.

Changes:
- Add TELEGRAM_COMMANDS_ENABLED to config.py
- Add TELEGRAM_POLLING_INTERVAL to config.py
- Add extensive "Bidirectional Commands" section to README.md

Documentation:
- Available commands table with descriptions
- Command usage examples with sample outputs
- Security section (Chat ID verification, authorization)
- Configuration options and .env examples
- How it works (long polling, authentication flow)
- Error handling and troubleshooting guide

Features:
- Optional command support (can disable while keeping notifications)
- Configurable polling interval
- Complete security documentation
- Troubleshooting guide for common issues

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 15:39:02 +09:00
462f8763ab Merge pull request 'feat: implement status query commands /status and /positions (issue #67)' (#68) from feature/issue-67-status-commands into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #68
2026-02-05 15:34:16 +09:00
agentson
57a45a24cb feat: implement status query commands /status and /positions (issue #67)
Some checks failed
CI / test (pull_request) Has been cancelled
Add real-time status and portfolio monitoring via Telegram.

Changes:
- Implement /status handler (mode, markets, P&L, trading state)
- Implement /positions handler (holdings with grouping by market)
- Integrate with Broker API and RiskManager
- Add 5 comprehensive tests for status commands

Features:
- /status: Shows trading mode, enabled markets, pause state, P&L, circuit breaker
- /positions: Lists holdings grouped by market (domestic/overseas)
- Error handling: Graceful degradation on API failures
- Empty state: Handles portfolios with no positions

Integration:
- Uses broker.get_balance() for account data
- Uses risk.calculate_pnl() for P&L calculation
- Accesses pause_trading.is_set() for trading state
- Groups positions by market for better readability

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 15:29:52 +09:00
a7696568cc Merge pull request 'feat: implement trading control commands /stop and /resume (issue #65)' (#66) from feature/issue-65-trading-control into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #66
2026-02-05 15:17:35 +09:00
agentson
70701bf73a feat: implement trading control commands /stop and /resume (issue #65)
Some checks failed
CI / test (pull_request) Has been cancelled
Add pause/resume functionality for remote trading control via Telegram.

Changes:
- Add pause_trading Event to main.py
- Implement /stop handler (pause trading)
- Implement /resume handler (resume trading)
- Integrate pause logic into both daily and realtime trading loops
- Add 4 comprehensive tests for trading control

Features:
- /stop: Pauses all trading operations
- /resume: Resumes trading operations
- Idempotent: Handles repeated stop/resume gracefully
- Status feedback: Informs if already paused/active
- Works in both daily and realtime trading modes

Security:
- Commands verified by TelegramCommandHandler chat_id check
- Only authorized users can control trading

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 14:40:19 +09:00
20dbd94892 Merge pull request 'feat: implement basic commands /start and /help (issue #63)' (#64) from feature/issue-63-basic-commands into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #64
2026-02-05 13:56:51 +09:00
agentson
48a99962e3 feat: implement basic commands /start and /help (issue #63)
Some checks failed
CI / test (pull_request) Has been cancelled
Integrate TelegramCommandHandler into main.py and implement
welcome and help commands.

Changes:
- Import TelegramCommandHandler in main.py
- Initialize command handler and register /start and /help
- Start/stop command handler with proper lifecycle management
- Add tests for command content validation

Features:
- /start: Welcome message with bot introduction
- /help: Complete command reference
- Handlers respond with HTML-formatted messages
- Clean startup/shutdown integration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 13:55:52 +09:00
ee66ecc305 Merge pull request 'feat: implement TelegramCommandHandler core structure (issue #61)' (#62) from feature/issue-61-command-handler into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #62
2026-02-05 13:51:18 +09:00
agentson
065c9daaad feat: implement TelegramCommandHandler core structure (issue #61)
Some checks failed
CI / test (pull_request) Has been cancelled
Add TelegramCommandHandler class with long polling, command routing,
and security features.

Changes:
- Add TelegramCommandHandler class to telegram_client.py
- Implement long polling with getUpdates API
- Add command registration and routing mechanism
- Implement chat ID verification for security
- Add comprehensive tests (16 tests)
- Coverage: 85% for telegram_client.py

Features:
- start_polling() / stop_polling() lifecycle management
- register_command() for handler registration
- Chat ID verification to prevent unauthorized access
- Error isolation (command failures don't crash system)
- Graceful handling of API errors and timeouts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 13:47:27 +09:00
c76b9d5c15 Merge pull request 'feat: add generic send_message method to TelegramClient (issue #59)' (#60) from feature/issue-59-send-message into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #60
2026-02-05 13:40:06 +09:00
agentson
259f9d2e24 feat: add generic send_message method to TelegramClient (issue #59)
Some checks failed
CI / test (pull_request) Has been cancelled
Add send_message(text, parse_mode) method that can be used for both
notifications and command responses. Refactor _send_notification to
use the new method.

Changes:
- Add send_message() method with return value for success/failure
- Refactor _send_notification() to call send_message()
- Add comprehensive tests for send_message()
- Coverage: 93% for telegram_client.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 13:39:09 +09:00
8e715c55cd Merge pull request 'feat: 일일 거래 모드 + 요구사항 문서화 체계 (issue #57)' (#58) from feature/issue-57-daily-trading-mode into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #58
2026-02-05 09:49:26 +09:00
agentson
0057de4d12 feat: implement daily trading mode with batch decisions (issue #57)
Some checks failed
CI / test (pull_request) Has been cancelled
Add API-efficient daily trading mode for Gemini Free tier compatibility:

## Features

- **Batch Decisions**: GeminiClient.decide_batch() analyzes multiple stocks
  in a single API call using compressed JSON format
- **Daily Trading Mode**: run_daily_session() executes N sessions per day
  at configurable intervals (default: 4 sessions, 6 hours apart)
- **Mode Selection**: TRADE_MODE env var switches between daily (batch)
  and realtime (per-stock) modes
- **Requirements Log**: docs/requirements-log.md tracks user feedback
  chronologically for project evolution

## Configuration

- TRADE_MODE: "daily" (default) | "realtime"
- DAILY_SESSIONS: 1-10 (default: 4)
- SESSION_INTERVAL_HOURS: 1-24 (default: 6)

## API Efficiency

- 2 markets × 4 sessions = 8 API calls/day (within Free tier 20 calls)
- 3 markets × 4 sessions = 12 API calls/day (within Free tier 20 calls)

## Testing

- 9 new batch decision tests (all passing)
- All existing tests maintained (298 passed)

## Documentation

- docs/architecture.md: Trading Modes section with daily vs realtime
- CLAUDE.md: Requirements Management section
- docs/requirements-log.md: Initial entries for API efficiency needs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 09:28:10 +09:00
agentson
71ac59794e fix: implement comprehensive KIS API rate limiting solution
Some checks failed
CI / test (push) Has been cancelled
Root cause analysis revealed 3 critical issues causing EGW00201 errors:

1. **Hash key bypass** - _get_hash_key() made API calls without rate limiting
   - Every order made 2 API calls but only 1 was rate-limited
   - Fixed by adding rate_limiter.acquire() to _get_hash_key()

2. **Scanner concurrent burst** - scan_market() launched all stocks via asyncio.gather
   - All tasks queued simultaneously creating burst pressure
   - Fixed by adding Semaphore(1) for fully serialized scanning

3. **RPS too aggressive** - 5.0 RPS exceeded KIS API's real ~2 RPS limit
   - Lowered to 2.0 RPS (500ms interval) for maximum safety

Changes:
- src/broker/kis_api.py: Add rate limiter to _get_hash_key()
- src/analysis/scanner.py: Add semaphore-based concurrency control
  - New max_concurrent_scans parameter (default 1, fully serialized)
  - Wrap scan_stock calls with semaphore in _bounded_scan()
  - Remove ineffective asyncio.sleep(0.2) from scan_stock()
- src/config.py: Lower RATE_LIMIT_RPS from 5.0 to 2.0
- tests/test_broker.py: Add 2 tests for hash key rate limiting
- tests/test_volatility.py: Add test for scanner concurrency limit

Results:
- EGW00201 errors: 10 → 0 (100% elimination)
- All 290 tests pass
- 80% code coverage maintained
- Scanner still handles unlimited stocks (just serialized for API safety)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 01:09:34 +09:00
be04820b00 Merge pull request 'fix: properly close telegram client session to prevent resource leak (issue #52)' (#56) from feature/issue-52-aiohttp-cleanup into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #56
2026-02-05 00:46:24 +09:00
10b6e34d44 Merge pull request 'fix: add token refresh cooldown to prevent EGW00133 cascading failures (issue #54)' (#55) from feature/issue-54-token-refresh-cooldown into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #55
2026-02-05 00:46:06 +09:00
58f1106dbd Merge pull request 'feat: add rate limiting for overseas market scanning (issue #51)' (#53) from feature/issue-51-api-rate-limiting into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #53
2026-02-05 00:45:39 +09:00
cf5072cced Merge pull request 'fix: handle empty strings in price data parsing (issue #49)' (#50) from feature/issue-49-valueerror-empty-string into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #50
2026-02-05 00:45:06 +09:00
agentson
db0d966a6a fix: properly close telegram client session to prevent resource leak (issue #52)
Some checks failed
CI / test (pull_request) Has been cancelled
Adds telegram.close() to finally block to ensure aiohttp session cleanup.

Changes:
- src/main.py:553 - Add await telegram.close() in shutdown

Before:
- broker.close() called 
- telegram.close() NOT called 
- "Unclosed client session" error on shutdown

After:
- broker.close() called 
- telegram.close() called 
- Clean shutdown, no resource leak errors

Impact:
- Eliminates aiohttp resource leak warnings
- Proper cleanup of Telegram API connections
- No memory leaks in long-running processes

Related:
- KISBroker.close() already handles broker session
- OverseasBroker reuses KISBroker session (no separate close needed)
- TelegramClient has separate session that needs cleanup

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 00:40:31 +09:00
agentson
a56adcd342 fix: add token refresh cooldown to prevent EGW00133 cascading failures (issue #54)
Some checks failed
CI / test (pull_request) Has been cancelled
Prevents rapid retry attempts when token refresh hits KIS API's
1-per-minute rate limit (EGW00133: 접근토큰 발급 잠시 후 다시 시도하세요).

Changes:
- src/broker/kis_api.py:58-61 - Add cooldown tracking variables
- src/broker/kis_api.py:102-111 - Enforce 60s cooldown between refresh attempts
- tests/test_broker.py - Add cooldown behavior tests

Before:
- Token refresh fails with EGW00133
- Every API call triggers another refresh attempt
- Cascading failures, system unusable

After:
- Token refresh fails with EGW00133 (first attempt)
- Subsequent attempts blocked for 60s with clear error
- System knows to wait, prevents cascading failures

Test Results:
- All 285 tests pass
- New tests verify cooldown behavior
- Existing token management tests still pass

Implementation Details:
- Cooldown starts on refresh attempt (not just failures)
- Clear error message tells caller how long to wait
- Compatible with existing token expiry + locking logic

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 00:37:20 +09:00
agentson
eaf509a895 feat: add rate limiting for overseas market scanning (issue #51)
Some checks failed
CI / test (pull_request) Has been cancelled
Add 200ms delay between overseas API calls to prevent hitting
KIS API rate limit (EGW00201: 초당 거래건수 초과).

Changes:
- src/analysis/scanner.py:79-81 - Add asyncio.sleep(0.2) for overseas calls

Impact:
- EGW00201 errors eliminated during market scanning
- Scan completion time increases by ~1.2s for 6 stocks
- Trade-off: Slower scans vs complete market data

Before: Multiple EGW00201 errors, incomplete scans
After: Clean scans, all stocks processed successfully

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 00:34:43 +09:00
42 changed files with 8765 additions and 334 deletions

View File

@@ -45,6 +45,39 @@ Get real-time alerts for trades, circuit breakers, and system events via Telegra
**Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally. **Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally.
## Smart Volatility Scanner (Optional)
Python-first filtering pipeline that reduces Gemini API calls by pre-filtering stocks using technical indicators.
### How It Works
1. **Fetch Rankings** — KIS API volume surge rankings (top 30 stocks)
2. **Python Filter** — RSI + volume ratio calculations (no AI)
- Volume > 200% of previous day
- RSI(14) < 30 (oversold) OR RSI(14) > 70 (momentum)
3. **AI Judgment** — Only qualified candidates (1-3 stocks) sent to Gemini
### Configuration
Add to `.env` (optional, has sensible defaults):
```bash
RSI_OVERSOLD_THRESHOLD=30 # 0-50, default 30
RSI_MOMENTUM_THRESHOLD=70 # 50-100, default 70
VOL_MULTIPLIER=2.0 # Volume threshold (2.0 = 200%)
SCANNER_TOP_N=3 # Max candidates per scan
```
### Benefits
- **Reduces API costs** — Process 1-3 stocks instead of 20-30
- **Python-based filtering** — Fast technical analysis before AI
- **Evolution-ready** — Selection context logged for strategy optimization
- **Fault-tolerant** — Falls back to static watchlist on API failure
### Realtime Mode Only
Smart Scanner runs in `TRADE_MODE=realtime` only. Daily mode uses static watchlists for batch efficiency.
## Documentation ## Documentation
- **[Workflow Guide](docs/workflow.md)** — Git workflow policy and agent-based development - **[Workflow Guide](docs/workflow.md)** — Git workflow policy and agent-based development
@@ -53,6 +86,7 @@ Get real-time alerts for trades, circuit breakers, and system events via Telegra
- **[Context Tree](docs/context-tree.md)** — L1-L7 hierarchical memory system - **[Context Tree](docs/context-tree.md)** — L1-L7 hierarchical memory system
- **[Testing](docs/testing.md)** — Test structure, coverage requirements, writing tests - **[Testing](docs/testing.md)** — Test structure, coverage requirements, writing tests
- **[Agent Policies](docs/agents.md)** — Prime directives, constraints, prohibited actions - **[Agent Policies](docs/agents.md)** — Prime directives, constraints, prohibited actions
- **[Requirements Log](docs/requirements-log.md)** — User requirements and feedback tracking
## Core Principles ## Core Principles
@@ -61,10 +95,20 @@ Get real-time alerts for trades, circuit breakers, and system events via Telegra
3. **Issue-Driven Development** — All work goes through Gitea issues → feature branches → PRs 3. **Issue-Driven Development** — All work goes through Gitea issues → feature branches → PRs
4. **Agent Specialization** — Use dedicated agents for design, coding, testing, docs, review 4. **Agent Specialization** — Use dedicated agents for design, coding, testing, docs, review
## Requirements Management
User requirements and feedback are tracked in [docs/requirements-log.md](docs/requirements-log.md):
- New requirements are added chronologically with dates
- Code changes should reference related requirements
- Helps maintain project evolution aligned with user needs
- Preserves context across conversations and development cycles
## Project Structure ## Project Structure
``` ```
src/ src/
├── analysis/ # Technical analysis (RSI, volatility, smart scanner)
├── broker/ # KIS API client (domestic + overseas) ├── broker/ # KIS API client (domestic + overseas)
├── brain/ # Gemini AI decision engine ├── brain/ # Gemini AI decision engine
├── core/ # Risk manager (READ-ONLY) ├── core/ # Risk manager (READ-ONLY)
@@ -75,7 +119,7 @@ src/
├── main.py # Trading loop orchestrator ├── main.py # Trading loop orchestrator
└── config.py # Settings (from .env) └── config.py # Settings (from .env)
tests/ # 273 tests across 13 files tests/ # 343 tests across 14 files
docs/ # Extended documentation docs/ # Extended documentation
``` ```

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

@@ -2,7 +2,42 @@
## Overview ## Overview
Self-evolving AI trading agent for global stock markets via KIS (Korea Investment & Securities) API. The main loop in `src/main.py` orchestrates four components in a 60-second cycle per stock across multiple markets. Self-evolving AI trading agent for global stock markets via KIS (Korea Investment & Securities) API. The main loop in `src/main.py` orchestrates four components across multiple markets with two trading modes: daily (batch API calls) or realtime (per-stock decisions).
## Trading Modes
The system supports two trading frequency modes controlled by the `TRADE_MODE` environment variable:
### Daily Mode (default)
Optimized for Gemini Free tier API limits (20 calls/day):
- **Batch decisions**: 1 API call per market per session
- **Fixed schedule**: 4 sessions per day at 6-hour intervals (configurable)
- **API efficiency**: Processes all stocks in a market simultaneously
- **Use case**: Free tier users, cost-conscious deployments
- **Configuration**:
```bash
TRADE_MODE=daily
DAILY_SESSIONS=4 # Sessions per day (1-10)
SESSION_INTERVAL_HOURS=6 # Hours between sessions (1-24)
```
**Example**: With 2 markets (US, KR) and 4 sessions/day = 8 API calls/day (within 20 call limit)
### Realtime Mode
High-frequency trading with individual stock analysis:
- **Per-stock decisions**: 1 API call per stock per cycle
- **60-second interval**: Continuous monitoring
- **Use case**: Production deployments with Gemini paid tier
- **Configuration**:
```bash
TRADE_MODE=realtime
```
**Note**: Realtime mode requires Gemini API subscription due to high call volume.
## Core Components ## Core Components
@@ -29,7 +64,39 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
- `get_open_markets()` returns currently active markets - `get_open_markets()` returns currently active markets
- `get_next_market_open()` finds next market to open and when - `get_next_market_open()` finds next market to open and when
### 2. Brain (`src/brain/gemini_client.py`) **New API Methods** (added in v0.9.0):
- `fetch_market_rankings()` — Fetch volume surge rankings from KIS API
- `get_daily_prices()` — Fetch OHLCV history for technical analysis
### 2. Analysis (`src/analysis/`)
**VolatilityAnalyzer** (`volatility.py`) — Technical indicator calculations
- ATR (Average True Range) for volatility measurement
- RSI (Relative Strength Index) using Wilder's smoothing method
- Price change percentages across multiple timeframes
- Volume surge ratios and price-volume divergence
- Momentum scoring (0-100 scale)
- Breakout/breakdown pattern detection
**SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline
- **Step 1**: Fetch volume rankings from KIS API (top 30 stocks)
- **Step 2**: Calculate RSI and volume ratio for each stock
- **Step 3**: Apply filters:
- Volume ratio >= `VOL_MULTIPLIER` (default 2.0x previous day)
- RSI < `RSI_OVERSOLD_THRESHOLD` (30) OR RSI > `RSI_MOMENTUM_THRESHOLD` (70)
- **Step 4**: Score candidates by RSI extremity (60%) + volume surge (40%)
- **Step 5**: Return top N candidates (default 3) for AI analysis
- **Fallback**: Uses static watchlist if ranking API unavailable
- **Realtime mode only**: Daily mode uses batch processing for API efficiency
**Benefits:**
- Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates
- Fast Python-based filtering before expensive AI judgment
- Logs selection context (RSI, volume_ratio, signal, score) for Evolution system
### 3. Brain (`src/brain/gemini_client.py`)
**GeminiClient** — AI decision engine powered by Google Gemini **GeminiClient** — AI decision engine powered by Google Gemini
@@ -39,7 +106,7 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
- Falls back to safe HOLD on any parse/API error - Falls back to safe HOLD on any parse/API error
- Handles markdown-wrapped JSON, malformed responses, invalid actions - Handles markdown-wrapped JSON, malformed responses, invalid actions
### 3. Risk Manager (`src/core/risk_manager.py`) ### 4. Risk Manager (`src/core/risk_manager.py`)
**RiskManager** — Safety circuit breaker and order validation **RiskManager** — Safety circuit breaker and order validation
@@ -51,7 +118,7 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
- **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash - **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash
- Must always be enforced, cannot be disabled - Must always be enforced, cannot be disabled
### 4. Notifications (`src/notifications/telegram_client.py`) ### 5. Notifications (`src/notifications/telegram_client.py`)
**TelegramClient** — Real-time event notifications via Telegram Bot API **TelegramClient** — Real-time event notifications via Telegram Bot API
@@ -70,7 +137,7 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
**Setup:** See [src/notifications/README.md](../src/notifications/README.md) for bot creation and configuration. **Setup:** See [src/notifications/README.md](../src/notifications/README.md) for bot creation and configuration.
### 5. Evolution (`src/evolution/optimizer.py`) ### 6. Evolution (`src/evolution/optimizer.py`)
**StrategyOptimizer** — Self-improvement loop **StrategyOptimizer** — Self-improvement loop
@@ -82,9 +149,11 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
## Data Flow ## Data Flow
### Realtime Mode (with Smart Scanner)
``` ```
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ Main Loop (60s cycle per stock, per market) │ │ Main Loop (60s cycle per market)
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
@@ -97,6 +166,21 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Smart Scanner (Python-first) │
│ - Fetch volume rankings (KIS) │
│ - Get 20d price history per stock│
│ - Calculate RSI(14) + vol ratio │
│ - Filter: vol>2x AND RSI extreme │
│ - Return top 3 qualified stocks │
└──────────────────┬────────────────┘
┌──────────────────────────────────┐
│ For Each Qualified Candidate │
└──────────────────┬────────────────┘
┌──────────────────────────────────┐
│ Broker: Fetch Market Data │ │ Broker: Fetch Market Data │
│ - Domestic: orderbook + balance │ │ - Domestic: orderbook + balance │
│ - Overseas: price + balance │ │ - Overseas: price + balance │
@@ -110,7 +194,7 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Brain: Get Decision │ Brain: Get Decision (AI)
│ - Build prompt with market data │ │ - Build prompt with market data │
│ - Call Gemini API │ │ - Call Gemini API │
│ - Parse JSON response │ │ - Parse JSON response │
@@ -146,6 +230,9 @@ Self-evolving AI trading agent for global stock markets via KIS (Korea Investmen
│ - SQLite (data/trades.db) │ │ - SQLite (data/trades.db) │
│ - Track: action, confidence, │ │ - Track: action, confidence, │
│ rationale, market, exchange │ │ rationale, market, exchange │
│ - NEW: selection_context (JSON) │
│ - RSI, volume_ratio, signal │
│ - For Evolution optimization │
└───────────────────────────────────┘ └───────────────────────────────────┘
``` ```
@@ -165,11 +252,24 @@ CREATE TABLE trades (
price REAL, price REAL,
pnl REAL DEFAULT 0.0, pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR', -- KR | US_NASDAQ | JP | etc. market TEXT DEFAULT 'KR', -- KR | US_NASDAQ | JP | etc.
exchange_code TEXT DEFAULT 'KRX' -- KRX | NASD | NYSE | etc. exchange_code TEXT DEFAULT 'KRX', -- KRX | NASD | NYSE | etc.
selection_context TEXT -- JSON: {rsi, volume_ratio, signal, score}
); );
``` ```
Auto-migration: Adds `market` and `exchange_code` columns if missing for backward compatibility. **Selection Context** (new in v0.9.0): Stores scanner selection criteria as JSON:
```json
{
"rsi": 28.5,
"volume_ratio": 2.7,
"signal": "oversold",
"score": 85.2
}
```
Enables Evolution system to analyze correlation between selection criteria and trade outcomes.
Auto-migration: Adds `market`, `exchange_code`, and `selection_context` columns if missing for backward compatibility.
## Configuration ## Configuration
@@ -192,10 +292,21 @@ MAX_LOSS_PCT=3.0
MAX_ORDER_PCT=30.0 MAX_ORDER_PCT=30.0
ENABLED_MARKETS=KR,US_NASDAQ # Comma-separated market codes ENABLED_MARKETS=KR,US_NASDAQ # Comma-separated market codes
# Trading Mode (API efficiency)
TRADE_MODE=daily # daily | realtime
DAILY_SESSIONS=4 # Sessions per day (daily mode only)
SESSION_INTERVAL_HOURS=6 # Hours between sessions (daily mode only)
# Telegram Notifications (optional) # Telegram Notifications (optional)
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=123456789 TELEGRAM_CHAT_ID=123456789
TELEGRAM_ENABLED=true TELEGRAM_ENABLED=true
# Smart Scanner (optional, realtime mode only)
RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold
RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold
VOL_MULTIPLIER=2.0 # Minimum volume ratio (2.0 = 200%)
SCANNER_TOP_N=3 # Max qualified candidates per scan
``` ```
Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`. Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`.

88
docs/requirements-log.md Normal file
View File

@@ -0,0 +1,88 @@
# Requirements Log
프로젝트 진화를 위한 사용자 요구사항 기록.
이 문서는 시간순으로 사용자와의 대화에서 나온 요구사항과 피드백을 기록합니다.
새로운 요구사항이 있으면 날짜와 함께 추가하세요.
---
## 2026-02-05
### API 효율화
- Gemini API는 귀중한 자원. 종목별 개별 호출 대신 배치 호출 필요
- Free tier 한도(20 calls/day) 고려하여 일일 몇 차례 거래 모드로 전환
- 배치 API 호출로 여러 종목을 한 번에 분석
### 거래 모드
- **Daily Mode**: 하루 4회 거래 세션 (6시간 간격) - Free tier 호환
- **Realtime Mode**: 60초 간격 실시간 거래 - 유료 구독 필요
- `TRADE_MODE` 환경변수로 모드 선택
### 진화 시스템
- 사용자 대화 내용을 문서로 기록하여 향후에도 의도 반영
- 프롬프트 품질 검증은 별도 이슈로 다룰 예정
### 문서화
- 시스템 구조, 기능별 설명 등 코드 문서화 항상 신경쓸 것
- 새로운 기능 추가 시 관련 문서 업데이트 필수
---
## 2026-02-06
### Smart Volatility Scanner (Python-First, AI-Last 파이프라인)
**배경:**
- 정적 종목 리스트를 순회하는 방식은 비효율적
- KIS API 거래량 순위를 통해 시장 주도주를 자동 탐지해야 함
- Gemini API 호출 전에 Python 기반 기술적 분석으로 필터링 필요
**요구사항:**
1. KIS API 거래량 순위 API 통합 (`fetch_market_rankings`)
2. 일별 가격 히스토리 API 추가 (`get_daily_prices`)
3. RSI(14) 계산 기능 구현 (Wilder's smoothing method)
4. 필터 조건:
- 거래량 > 전일 대비 200% (VOL_MULTIPLIER)
- RSI < 30 (과매도) OR RSI > 70 (모멘텀)
5. 상위 1-3개 적격 종목만 Gemini에 전달
6. 종목 선정 배경(RSI, volume_ratio, signal, score) 데이터베이스 기록
**구현 결과:**
- `src/analysis/smart_scanner.py`: SmartVolatilityScanner 클래스
- `src/analysis/volatility.py`: calculate_rsi() 메서드 추가
- `src/broker/kis_api.py`: 2개 신규 API 메서드
- `src/db.py`: selection_context 컬럼 추가
- 설정 가능한 임계값: RSI_OVERSOLD_THRESHOLD, RSI_MOMENTUM_THRESHOLD, VOL_MULTIPLIER, SCANNER_TOP_N
**효과:**
- Gemini API 호출 20-30개 → 1-3개로 감소
- Python 기반 빠른 필터링 → 비용 절감
- 선정 기준 추적 → Evolution 시스템 최적화 가능
- API 장애 시 정적 watchlist로 자동 전환
**참고:** Realtime 모드 전용. Daily 모드는 배치 효율성을 위해 정적 watchlist 사용.
**이슈/PR:** #76, #77
---
## 2026-02-10
### 코드 리뷰 시 플랜-구현 일치 검증 규칙
**배경:**
- 코드 리뷰 시 플랜(EnterPlanMode에서 승인된 계획)과 실제 구현이 일치하는지 확인하는 절차가 없었음
- 플랜과 다른 구현이 리뷰 없이 통과될 위험
**요구사항:**
1. 모든 PR 리뷰에서 플랜-구현 일치 여부를 필수 체크
2. 플랜에 없는 변경은 정당한 사유 필요
3. 플랜 항목이 누락되면 PR 설명에 사유 기록
4. 스코프가 플랜과 일치하는지 확인
**구현 결과:**
- `docs/workflow.md`에 Code Review Checklist 섹션 추가
- Plan Consistency (필수), Safety & Constraints, Quality, Workflow 4개 카테고리
**이슈/PR:** #114

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)
@@ -73,3 +74,37 @@ task_tool(
``` ```
Use `run_in_background=True` for independent tasks that don't block subsequent work. Use `run_in_background=True` for independent tasks that don't block subsequent work.
## Code Review Checklist
**CRITICAL: Every PR review MUST verify plan-implementation consistency.**
Before approving any PR, the reviewer (human or agent) must check ALL of the following:
### 1. Plan Consistency (MANDATORY)
- [ ] **Implementation matches the approved plan** — Compare the actual code changes against the plan created during `EnterPlanMode`. Every item in the plan must be addressed.
- [ ] **No unplanned changes** — If the implementation includes changes not in the plan, they must be explicitly justified.
- [ ] **No plan items omitted** — If any planned item was skipped, the reason must be documented in the PR description.
- [ ] **Scope matches** — The PR does not exceed or fall short of the planned scope.
### 2. Safety & Constraints
- [ ] `src/core/risk_manager.py` is unchanged (READ-ONLY)
- [ ] Circuit breaker threshold not weakened (only stricter allowed)
- [ ] Fat-finger protection (30% max order) still enforced
- [ ] Confidence < 80 still forces HOLD
- [ ] No hardcoded API keys or secrets
### 3. Quality
- [ ] All new/modified code has corresponding tests
- [ ] Test coverage >= 80%
- [ ] `ruff check src/ tests/` passes (no lint errors)
- [ ] No `assert` statements removed from tests
### 4. Workflow
- [ ] PR references the Gitea issue number
- [ ] Feature branch follows naming convention (`feature/issue-N-description`)
- [ ] Commit messages are clear and descriptive

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from src.analysis.scanner import MarketScanner from src.analysis.scanner import MarketScanner
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
from src.analysis.volatility import VolatilityAnalyzer from src.analysis.volatility import VolatilityAnalyzer
__all__ = ["VolatilityAnalyzer", "MarketScanner"] __all__ = ["VolatilityAnalyzer", "MarketScanner", "SmartVolatilityScanner", "ScanCandidate"]

View File

@@ -42,6 +42,7 @@ class MarketScanner:
volatility_analyzer: VolatilityAnalyzer, volatility_analyzer: VolatilityAnalyzer,
context_store: ContextStore, context_store: ContextStore,
top_n: int = 5, top_n: int = 5,
max_concurrent_scans: int = 1,
) -> None: ) -> None:
"""Initialize the market scanner. """Initialize the market scanner.
@@ -51,12 +52,14 @@ class MarketScanner:
volatility_analyzer: Volatility analyzer instance volatility_analyzer: Volatility analyzer instance
context_store: Context store for L7 real-time data context_store: Context store for L7 real-time data
top_n: Number of top movers to return per market (default 5) top_n: Number of top movers to return per market (default 5)
max_concurrent_scans: Max concurrent stock scans (default 1, fully serialized)
""" """
self.broker = broker self.broker = broker
self.overseas_broker = overseas_broker self.overseas_broker = overseas_broker
self.analyzer = volatility_analyzer self.analyzer = volatility_analyzer
self.context_store = context_store self.context_store = context_store
self.top_n = top_n self.top_n = top_n
self._scan_semaphore = asyncio.Semaphore(max_concurrent_scans)
async def scan_stock( async def scan_stock(
self, self,
@@ -105,7 +108,7 @@ class MarketScanner:
self.context_store.set_context( self.context_store.set_context(
ContextLayer.L7_REALTIME, ContextLayer.L7_REALTIME,
timeframe, timeframe,
f"{market.code}_{stock_code}_volatility", f"volatility_{market.code}_{stock_code}",
{ {
"price": metrics.current_price, "price": metrics.current_price,
"atr": metrics.atr, "atr": metrics.atr,
@@ -139,8 +142,12 @@ class MarketScanner:
logger.info("Scanning %s market (%d stocks)", market.name, len(stock_codes)) logger.info("Scanning %s market (%d stocks)", market.name, len(stock_codes))
# Scan all stocks concurrently (with rate limiting handled by broker) # Scan stocks with bounded concurrency to prevent API rate limit burst
tasks = [self.scan_stock(code, market) for code in stock_codes] async def _bounded_scan(code: str) -> VolatilityMetrics | None:
async with self._scan_semaphore:
return await self.scan_stock(code, market)
tasks = [_bounded_scan(code) for code in stock_codes]
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
# Filter out failures and sort by momentum score # Filter out failures and sort by momentum score
@@ -172,7 +179,7 @@ class MarketScanner:
self.context_store.set_context( self.context_store.set_context(
ContextLayer.L7_REALTIME, ContextLayer.L7_REALTIME,
timeframe, timeframe,
f"{market.code}_scan_result", f"scan_result_{market.code}",
{ {
"total_scanned": len(valid_metrics), "total_scanned": len(valid_metrics),
"top_movers": [m.stock_code for m in top_movers], "top_movers": [m.stock_code for m in top_movers],

View File

@@ -0,0 +1,192 @@
"""Smart Volatility Scanner with RSI and volume filters.
Fetches market rankings from KIS API and applies technical filters
to identify high-probability trading candidates.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from src.analysis.volatility import VolatilityAnalyzer
from src.broker.kis_api import KISBroker
from src.config import Settings
logger = logging.getLogger(__name__)
@dataclass
class ScanCandidate:
"""A qualified candidate from the smart scanner."""
stock_code: str
name: str
price: float
volume: float
volume_ratio: float # Current volume / previous day volume
rsi: float
signal: str # "oversold" or "momentum"
score: float # Composite score for ranking
class SmartVolatilityScanner:
"""Scans market rankings and applies RSI/volume filters.
Flow:
1. Fetch volume rankings from KIS API
2. For each ranked stock, fetch daily prices
3. Calculate RSI and volume ratio
4. Apply filters: volume > VOL_MULTIPLIER AND (RSI < 30 OR RSI > 70)
5. Return top N qualified candidates
"""
def __init__(
self,
broker: KISBroker,
volatility_analyzer: VolatilityAnalyzer,
settings: Settings,
) -> None:
"""Initialize the smart scanner.
Args:
broker: KIS broker for API calls
volatility_analyzer: Analyzer for RSI calculation
settings: Application settings
"""
self.broker = broker
self.analyzer = volatility_analyzer
self.settings = settings
# Extract scanner settings
self.rsi_oversold = settings.RSI_OVERSOLD_THRESHOLD
self.rsi_momentum = settings.RSI_MOMENTUM_THRESHOLD
self.vol_multiplier = settings.VOL_MULTIPLIER
self.top_n = settings.SCANNER_TOP_N
async def scan(
self,
fallback_stocks: list[str] | None = None,
) -> list[ScanCandidate]:
"""Execute smart scan and return qualified candidates.
Args:
fallback_stocks: Stock codes to use if ranking API fails
Returns:
List of ScanCandidate, sorted by score, up to top_n items
"""
# Step 1: Fetch rankings
try:
rankings = await self.broker.fetch_market_rankings(
ranking_type="volume",
limit=30, # Fetch more than needed for filtering
)
logger.info("Fetched %d stocks from volume rankings", len(rankings))
except ConnectionError as exc:
logger.warning("Ranking API failed, using fallback: %s", exc)
if fallback_stocks:
# Create minimal ranking data for fallback
rankings = [
{
"stock_code": code,
"name": code,
"price": 0,
"volume": 0,
"change_rate": 0,
"volume_increase_rate": 0,
}
for code in fallback_stocks
]
else:
return []
# Step 2: Analyze each stock
candidates: list[ScanCandidate] = []
for stock in rankings:
stock_code = stock["stock_code"]
if not stock_code:
continue
try:
# Fetch daily prices for RSI calculation
daily_prices = await self.broker.get_daily_prices(stock_code, days=20)
if len(daily_prices) < 15: # Need at least 14+1 for RSI
logger.debug("Insufficient price history for %s", stock_code)
continue
# Calculate RSI
close_prices = [p["close"] for p in daily_prices]
rsi = self.analyzer.calculate_rsi(close_prices, period=14)
# Calculate volume ratio (today vs previous day avg)
if len(daily_prices) >= 2:
prev_day_volume = daily_prices[-2]["volume"]
current_volume = stock.get("volume", 0) or daily_prices[-1]["volume"]
volume_ratio = (
current_volume / prev_day_volume if prev_day_volume > 0 else 1.0
)
else:
volume_ratio = stock.get("volume_increase_rate", 0) / 100 + 1 # Fallback
# Apply filters
volume_qualified = volume_ratio >= self.vol_multiplier
rsi_oversold = rsi < self.rsi_oversold
rsi_momentum = rsi > self.rsi_momentum
if volume_qualified and (rsi_oversold or rsi_momentum):
signal = "oversold" if rsi_oversold else "momentum"
# Calculate composite score
# Higher score for: extreme RSI + high volume
rsi_extremity = abs(rsi - 50) / 50 # 0-1 scale
volume_score = min(volume_ratio / 5, 1.0) # Cap at 5x
score = (rsi_extremity * 0.6 + volume_score * 0.4) * 100
candidates.append(
ScanCandidate(
stock_code=stock_code,
name=stock.get("name", stock_code),
price=stock.get("price", daily_prices[-1]["close"]),
volume=current_volume,
volume_ratio=volume_ratio,
rsi=rsi,
signal=signal,
score=score,
)
)
logger.info(
"Qualified: %s (%s) RSI=%.1f vol=%.1fx signal=%s score=%.1f",
stock_code,
stock.get("name", ""),
rsi,
volume_ratio,
signal,
score,
)
except ConnectionError as exc:
logger.warning("Failed to analyze %s: %s", stock_code, exc)
continue
except Exception as exc:
logger.error("Unexpected error analyzing %s: %s", stock_code, exc)
continue
# Sort by score and return top N
candidates.sort(key=lambda c: c.score, reverse=True)
return candidates[: self.top_n]
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:
"""Extract stock codes from candidates for watchlist update.
Args:
candidates: List of scan candidates
Returns:
List of stock codes
"""
return [c.stock_code for c in candidates]

View File

@@ -124,6 +124,54 @@ class VolatilityAnalyzer:
return 1.0 return 1.0
return current_volume / avg_volume return current_volume / avg_volume
def calculate_rsi(
self,
close_prices: list[float],
period: int = 14,
) -> float:
"""Calculate Relative Strength Index (RSI) using Wilder's smoothing.
Args:
close_prices: List of closing prices (oldest to newest, minimum period+1 values)
period: RSI period (default 14)
Returns:
RSI value between 0 and 100, or 50.0 (neutral) if insufficient data
Examples:
>>> analyzer = VolatilityAnalyzer()
>>> prices = [100 - i * 0.5 for i in range(20)] # Downtrend
>>> rsi = analyzer.calculate_rsi(prices)
>>> assert rsi < 50 # Oversold territory
"""
if len(close_prices) < period + 1:
return 50.0 # Neutral RSI if insufficient data
# Calculate price changes
changes = [close_prices[i] - close_prices[i - 1] for i in range(1, len(close_prices))]
# Separate gains and losses
gains = [max(0.0, change) for change in changes]
losses = [max(0.0, -change) for change in changes]
# Calculate initial average gain/loss (simple average for first period)
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
# Apply Wilder's smoothing for remaining periods
for i in range(period, len(changes)):
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
# Calculate RS and RSI
if avg_loss == 0:
return 100.0 # All gains, maximum RSI
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
def calculate_pv_divergence( def calculate_pv_divergence(
self, self,
price_change: float, price_change: float,

View File

@@ -525,3 +525,233 @@ class GeminiClient:
DecisionCache instance or None if caching disabled DecisionCache instance or None if caching disabled
""" """
return self._cache return self._cache
# ------------------------------------------------------------------
# Batch Decision Making (for daily trading mode)
# ------------------------------------------------------------------
async def decide_batch(
self, stocks_data: list[dict[str, Any]]
) -> dict[str, TradeDecision]:
"""Make decisions for multiple stocks in a single API call.
This is designed for daily trading mode to minimize API usage
when working with Gemini Free tier (20 calls/day limit).
Args:
stocks_data: List of market data dictionaries, each with:
- stock_code: Stock ticker
- current_price: Current price
- market_name: Market name (optional)
- foreigner_net: Foreigner net buy/sell (optional)
Returns:
Dictionary mapping stock_code to TradeDecision
Example:
>>> stocks_data = [
... {"stock_code": "AAPL", "current_price": 185.5},
... {"stock_code": "MSFT", "current_price": 420.0},
... ]
>>> decisions = await client.decide_batch(stocks_data)
>>> decisions["AAPL"].action
'BUY'
"""
if not stocks_data:
return {}
# Build compressed batch prompt
market_name = stocks_data[0].get("market_name", "stock market")
# Format stock data as compact JSON array
compact_stocks = []
for stock in stocks_data:
compact = {
"code": stock["stock_code"],
"price": stock["current_price"],
}
if stock.get("foreigner_net", 0) != 0:
compact["frgn"] = stock["foreigner_net"]
compact_stocks.append(compact)
data_str = json.dumps(compact_stocks, ensure_ascii=False)
prompt = (
f"You are a professional {market_name} trading analyst.\n"
"Analyze the following stocks and decide whether to BUY, SELL, or HOLD each one.\n\n"
f"Stock Data: {data_str}\n\n"
"You MUST respond with ONLY a valid JSON array in this format:\n"
'[{"code": "AAPL", "action": "BUY", "confidence": 85, "rationale": "..."},\n'
' {"code": "MSFT", "action": "HOLD", "confidence": 50, "rationale": "..."}, ...]\n\n'
"Rules:\n"
"- Return one decision object per stock\n"
"- action must be exactly: BUY, SELL, or HOLD\n"
"- confidence must be 0-100\n"
"- rationale should be concise (1-2 sentences)\n"
"- Do NOT wrap JSON in markdown code blocks\n"
)
# Estimate tokens
token_count = self._optimizer.estimate_tokens(prompt)
self._total_tokens_used += token_count
logger.info(
"Requesting batch decision for %d stocks from Gemini",
len(stocks_data),
extra={"estimated_tokens": token_count},
)
try:
response = await self._client.aio.models.generate_content(
model=self._model_name,
contents=prompt,
)
raw = response.text
except Exception as exc:
logger.error("Gemini API error in batch decision: %s", exc)
# Return HOLD for all stocks on API error
return {
stock["stock_code"]: TradeDecision(
action="HOLD",
confidence=0,
rationale=f"API error: {exc}",
token_count=token_count,
cached=False,
)
for stock in stocks_data
}
# Parse batch response
return self._parse_batch_response(raw, stocks_data, token_count)
def _parse_batch_response(
self, raw: str, stocks_data: list[dict[str, Any]], token_count: int
) -> dict[str, TradeDecision]:
"""Parse batch response into a dictionary of decisions.
Args:
raw: Raw response from Gemini
stocks_data: Original stock data list
token_count: Token count for the request
Returns:
Dictionary mapping stock_code to TradeDecision
"""
if not raw or not raw.strip():
logger.warning("Empty batch response from Gemini — defaulting all to HOLD")
return {
stock["stock_code"]: TradeDecision(
action="HOLD",
confidence=0,
rationale="Empty response",
token_count=0,
cached=False,
)
for stock in stocks_data
}
# Strip markdown code fences if present
cleaned = raw.strip()
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", cleaned, re.DOTALL)
if match:
cleaned = match.group(1).strip()
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
logger.warning("Malformed JSON in batch response — defaulting all to HOLD")
return {
stock["stock_code"]: TradeDecision(
action="HOLD",
confidence=0,
rationale="Malformed JSON response",
token_count=0,
cached=False,
)
for stock in stocks_data
}
if not isinstance(data, list):
logger.warning("Batch response is not a JSON array — defaulting all to HOLD")
return {
stock["stock_code"]: TradeDecision(
action="HOLD",
confidence=0,
rationale="Invalid response format",
token_count=0,
cached=False,
)
for stock in stocks_data
}
# Build decision map
decisions: dict[str, TradeDecision] = {}
stock_codes = {stock["stock_code"] for stock in stocks_data}
for item in data:
if not isinstance(item, dict):
continue
code = item.get("code")
if not code or code not in stock_codes:
continue
# Validate required fields
if not all(k in item for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields for %s — using HOLD", code)
decisions[code] = TradeDecision(
action="HOLD",
confidence=0,
rationale="Missing required fields",
token_count=0,
cached=False,
)
continue
action = str(item["action"]).upper()
if action not in VALID_ACTIONS:
logger.warning("Invalid action '%s' for %s — forcing HOLD", action, code)
action = "HOLD"
confidence = int(item["confidence"])
rationale = str(item["rationale"])
# Enforce confidence threshold
if confidence < self._confidence_threshold:
logger.info(
"Confidence %d < threshold %d for %s — forcing HOLD",
confidence,
self._confidence_threshold,
code,
)
action = "HOLD"
decisions[code] = TradeDecision(
action=action,
confidence=confidence,
rationale=rationale,
token_count=token_count // len(stocks_data), # Split token cost
cached=False,
)
self._total_decisions += 1
# Fill in missing stocks with HOLD
for stock in stocks_data:
code = stock["stock_code"]
if code not in decisions:
logger.warning("No decision for %s in batch response — using HOLD", code)
decisions[code] = TradeDecision(
action="HOLD",
confidence=0,
rationale="Not found in batch response",
token_count=0,
cached=False,
)
logger.info(
"Batch decision completed for %d stocks",
len(decisions),
extra={"tokens": token_count},
)
return decisions

View File

@@ -56,6 +56,8 @@ class KISBroker:
self._access_token: str | None = None self._access_token: str | None = None
self._token_expires_at: float = 0.0 self._token_expires_at: float = 0.0
self._token_lock = asyncio.Lock() self._token_lock = asyncio.Lock()
self._last_refresh_attempt: float = 0.0
self._refresh_cooldown: float = 60.0 # Seconds (matches KIS 1/minute limit)
self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS) self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS)
def _get_session(self) -> aiohttp.ClientSession: def _get_session(self) -> aiohttp.ClientSession:
@@ -98,7 +100,19 @@ class KISBroker:
if self._access_token and now < self._token_expires_at: if self._access_token and now < self._token_expires_at:
return self._access_token return self._access_token
# Check cooldown period (prevents hitting EGW00133: 1/minute limit)
time_since_last_attempt = now - self._last_refresh_attempt
if time_since_last_attempt < self._refresh_cooldown:
remaining = self._refresh_cooldown - time_since_last_attempt
error_msg = (
f"Token refresh on cooldown. "
f"Retry in {remaining:.1f}s (KIS allows 1/minute)"
)
logger.warning(error_msg)
raise ConnectionError(error_msg)
logger.info("Refreshing KIS access token") logger.info("Refreshing KIS access token")
self._last_refresh_attempt = now
session = self._get_session() session = self._get_session()
url = f"{self._base_url}/oauth2/tokenP" url = f"{self._base_url}/oauth2/tokenP"
body = { body = {
@@ -124,6 +138,7 @@ class KISBroker:
async def _get_hash_key(self, body: dict[str, Any]) -> str: async def _get_hash_key(self, body: dict[str, Any]) -> str:
"""Request a hash key from KIS for POST request body signing.""" """Request a hash key from KIS for POST request body signing."""
await self._rate_limiter.acquire()
session = self._get_session() session = self._get_session()
url = f"{self._base_url}/uapi/hashkey" url = f"{self._base_url}/uapi/hashkey"
headers = { headers = {
@@ -265,3 +280,153 @@ class KISBroker:
return data return data
except (TimeoutError, aiohttp.ClientError) as exc: except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error sending order: {exc}") from exc raise ConnectionError(f"Network error sending order: {exc}") from exc
async def fetch_market_rankings(
self,
ranking_type: str = "volume",
limit: int = 30,
) -> list[dict[str, Any]]:
"""Fetch market rankings from KIS API.
Args:
ranking_type: Type of ranking ("volume" or "fluctuation")
limit: Maximum number of results to return
Returns:
List of stock data dicts with keys: stock_code, name, price, volume,
change_rate, volume_increase_rate
Raises:
ConnectionError: If API request fails
"""
await self._rate_limiter.acquire()
session = self._get_session()
# TR_ID for volume ranking
tr_id = "FHPST01710000" if ranking_type == "volume" else "FHPST01710100"
headers = await self._auth_headers(tr_id)
params = {
"FID_COND_MRKT_DIV_CODE": "J", # Stock/ETF/ETN
"FID_COND_SCR_DIV_CODE": "20001", # Volume surge
"FID_INPUT_ISCD": "0000", # All stocks
"FID_DIV_CLS_CODE": "0", # All types
"FID_BLNG_CLS_CODE": "0",
"FID_TRGT_CLS_CODE": "111111111",
"FID_TRGT_EXLS_CLS_CODE": "000000",
"FID_INPUT_PRICE_1": "0",
"FID_INPUT_PRICE_2": "0",
"FID_VOL_CNT": "0",
"FID_INPUT_DATE_1": "",
}
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"fetch_market_rankings failed ({resp.status}): {text}"
)
data = await resp.json()
# Parse response - output is a list of ranked stocks
def _safe_float(value: str | float | None, default: float = 0.0) -> float:
if value is None or value == "":
return default
try:
return float(value)
except (ValueError, TypeError):
return default
rankings = []
for item in data.get("output", [])[:limit]:
rankings.append({
"stock_code": item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
"change_rate": _safe_float(item.get("prdy_ctrt", "0")),
"volume_increase_rate": _safe_float(item.get("vol_inrt", "0")),
})
return rankings
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching rankings: {exc}") from exc
async def get_daily_prices(
self,
stock_code: str,
days: int = 20,
) -> list[dict[str, Any]]:
"""Fetch daily OHLCV price history for a stock.
Args:
stock_code: 6-digit stock code
days: Number of trading days to fetch (default 20 for RSI calculation)
Returns:
List of daily price dicts with keys: date, open, high, low, close, volume
Sorted oldest to newest
Raises:
ConnectionError: If API request fails
"""
await self._rate_limiter.acquire()
session = self._get_session()
headers = await self._auth_headers("FHKST03010100")
# Calculate date range (today and N days ago)
from datetime import datetime, timedelta
end_date = datetime.now().strftime("%Y%m%d")
start_date = (datetime.now() - timedelta(days=days + 10)).strftime("%Y%m%d")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": stock_code,
"FID_INPUT_DATE_1": start_date,
"FID_INPUT_DATE_2": end_date,
"FID_PERIOD_DIV_CODE": "D", # Daily
"FID_ORG_ADJ_PRC": "0", # Adjusted price
}
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(
f"get_daily_prices failed ({resp.status}): {text}"
)
data = await resp.json()
# Parse response
def _safe_float(value: str | float | None, default: float = 0.0) -> float:
if value is None or value == "":
return default
try:
return float(value)
except (ValueError, TypeError):
return default
prices = []
for item in data.get("output2", []):
prices.append({
"date": item.get("stck_bsop_date", ""),
"open": _safe_float(item.get("stck_oprc", "0")),
"high": _safe_float(item.get("stck_hgpr", "0")),
"low": _safe_float(item.get("stck_lwpr", "0")),
"close": _safe_float(item.get("stck_clpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
})
# Sort oldest to newest (KIS returns newest first)
prices.reverse()
return prices[:days] # Return only requested number of days
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching daily prices: {exc}") from exc

View File

@@ -33,18 +33,37 @@ class Settings(BaseSettings):
FAT_FINGER_PCT: float = Field(default=30.0, gt=0.0, le=100.0) FAT_FINGER_PCT: float = Field(default=30.0, gt=0.0, le=100.0)
CONFIDENCE_THRESHOLD: int = Field(default=80, ge=0, le=100) CONFIDENCE_THRESHOLD: int = Field(default=80, ge=0, le=100)
# Smart Scanner Configuration
RSI_OVERSOLD_THRESHOLD: int = Field(default=30, ge=0, le=50)
RSI_MOMENTUM_THRESHOLD: int = Field(default=70, ge=50, le=100)
VOL_MULTIPLIER: float = Field(default=2.0, gt=1.0, le=10.0)
SCANNER_TOP_N: int = Field(default=3, ge=1, le=10)
# Database # Database
DB_PATH: str = "data/trade_logs.db" DB_PATH: str = "data/trade_logs.db"
# Rate Limiting (requests per second for KIS API) # Rate Limiting (requests per second for KIS API)
# Reduced to 5.0 to avoid EGW00201 "초당 거래건수 초과" errors # Conservative limit to avoid EGW00201 "초당 거래건수 초과" errors.
RATE_LIMIT_RPS: float = 5.0 # KIS API real limit is ~2 RPS; 2.0 provides maximum safety.
RATE_LIMIT_RPS: float = 2.0
# Trading mode # Trading mode
MODE: str = Field(default="paper", pattern="^(paper|live)$") MODE: str = Field(default="paper", pattern="^(paper|live)$")
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
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) # 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
@@ -60,6 +79,10 @@ class Settings(BaseSettings):
TELEGRAM_CHAT_ID: str | None = None TELEGRAM_CHAT_ID: str | None = None
TELEGRAM_ENABLED: bool = True TELEGRAM_ENABLED: bool = True
# Telegram Commands (optional)
TELEGRAM_COMMANDS_ENABLED: bool = True
TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
@property @property

View File

@@ -5,6 +5,7 @@ The context tree implements Pillar 2: hierarchical memory management across
""" """
from src.context.layer import ContextLayer from src.context.layer import ContextLayer
from src.context.scheduler import ContextScheduler
from src.context.store import ContextStore from src.context.store import ContextStore
__all__ = ["ContextLayer", "ContextStore"] __all__ = ["ContextLayer", "ContextScheduler", "ContextStore"]

View File

@@ -18,52 +18,83 @@ class ContextAggregator:
self.conn = conn self.conn = conn
self.store = ContextStore(conn) self.store = ContextStore(conn)
def aggregate_daily_from_trades(self, date: str | None = None) -> None: def aggregate_daily_from_trades(
self, date: str | None = None, market: str | None = None
) -> None:
"""Aggregate L6 (daily) context from trades table. """Aggregate L6 (daily) context from trades table.
Args: Args:
date: Date in YYYY-MM-DD format. If None, uses today. date: Date in YYYY-MM-DD format. If None, uses today.
market: Market code filter (e.g., "KR", "US"). If None, aggregates all markets.
""" """
if date is None: if date is None:
date = datetime.now(UTC).date().isoformat() date = datetime.now(UTC).date().isoformat()
# Calculate daily metrics from trades if market is None:
cursor = self.conn.execute( cursor = self.conn.execute(
""" """
SELECT SELECT DISTINCT market
COUNT(*) as trade_count, FROM trades
SUM(CASE WHEN action = 'BUY' THEN 1 ELSE 0 END) as buys, WHERE DATE(timestamp) = ?
SUM(CASE WHEN action = 'SELL' THEN 1 ELSE 0 END) as sells, """,
SUM(CASE WHEN action = 'HOLD' THEN 1 ELSE 0 END) as holds, (date,),
AVG(confidence) as avg_confidence,
SUM(pnl) as total_pnl,
COUNT(DISTINCT stock_code) as unique_stocks,
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) as losses
FROM trades
WHERE DATE(timestamp) = ?
""",
(date,),
)
row = cursor.fetchone()
if row and row[0] > 0: # At least one trade
trade_count, buys, sells, holds, avg_conf, total_pnl, stocks, wins, losses = row
# Store daily metrics in L6
self.store.set_context(ContextLayer.L6_DAILY, date, "trade_count", trade_count)
self.store.set_context(ContextLayer.L6_DAILY, date, "buys", buys)
self.store.set_context(ContextLayer.L6_DAILY, date, "sells", sells)
self.store.set_context(ContextLayer.L6_DAILY, date, "holds", holds)
self.store.set_context(
ContextLayer.L6_DAILY, date, "avg_confidence", round(avg_conf, 2)
) )
self.store.set_context( markets = [row[0] for row in cursor.fetchall() if row[0]]
ContextLayer.L6_DAILY, date, "total_pnl", round(total_pnl, 2) else:
markets = [market]
for market_code in markets:
# Calculate daily metrics from trades for the market
cursor = self.conn.execute(
"""
SELECT
COUNT(*) as trade_count,
SUM(CASE WHEN action = 'BUY' THEN 1 ELSE 0 END) as buys,
SUM(CASE WHEN action = 'SELL' THEN 1 ELSE 0 END) as sells,
SUM(CASE WHEN action = 'HOLD' THEN 1 ELSE 0 END) as holds,
AVG(confidence) as avg_confidence,
SUM(pnl) as total_pnl,
COUNT(DISTINCT stock_code) as unique_stocks,
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) as losses
FROM trades
WHERE DATE(timestamp) = ? AND market = ?
""",
(date, market_code),
) )
self.store.set_context(ContextLayer.L6_DAILY, date, "unique_stocks", stocks) row = cursor.fetchone()
win_rate = round(wins / max(wins + losses, 1) * 100, 2)
self.store.set_context(ContextLayer.L6_DAILY, date, "win_rate", win_rate) if row and row[0] > 0: # At least one trade
trade_count, buys, sells, holds, avg_conf, total_pnl, stocks, wins, losses = row
key_suffix = f"_{market_code}"
# Store daily metrics in L6 with market suffix
self.store.set_context(
ContextLayer.L6_DAILY, date, f"trade_count{key_suffix}", trade_count
)
self.store.set_context(ContextLayer.L6_DAILY, date, f"buys{key_suffix}", buys)
self.store.set_context(ContextLayer.L6_DAILY, date, f"sells{key_suffix}", sells)
self.store.set_context(ContextLayer.L6_DAILY, date, f"holds{key_suffix}", holds)
self.store.set_context(
ContextLayer.L6_DAILY,
date,
f"avg_confidence{key_suffix}",
round(avg_conf, 2),
)
self.store.set_context(
ContextLayer.L6_DAILY,
date,
f"total_pnl{key_suffix}",
round(total_pnl, 2),
)
self.store.set_context(
ContextLayer.L6_DAILY, date, f"unique_stocks{key_suffix}", stocks
)
win_rate = round(wins / max(wins + losses, 1) * 100, 2)
self.store.set_context(
ContextLayer.L6_DAILY, date, f"win_rate{key_suffix}", win_rate
)
def aggregate_weekly_from_daily(self, week: str | None = None) -> None: def aggregate_weekly_from_daily(self, week: str | None = None) -> None:
"""Aggregate L5 (weekly) context from L6 (daily). """Aggregate L5 (weekly) context from L6 (daily).
@@ -92,14 +123,25 @@ class ContextAggregator:
daily_data[row[0]].append(json.loads(row[1])) daily_data[row[0]].append(json.loads(row[1]))
if daily_data: if daily_data:
# Sum all PnL values # Sum all PnL values (market-specific if suffixed)
if "total_pnl" in daily_data: if "total_pnl" in daily_data:
total_pnl = sum(daily_data["total_pnl"]) total_pnl = sum(daily_data["total_pnl"])
self.store.set_context( self.store.set_context(
ContextLayer.L5_WEEKLY, week, "weekly_pnl", round(total_pnl, 2) ContextLayer.L5_WEEKLY, week, "weekly_pnl", round(total_pnl, 2)
) )
# Average all confidence values for key, values in daily_data.items():
if key.startswith("total_pnl_"):
market_code = key.split("total_pnl_", 1)[1]
total_pnl = sum(values)
self.store.set_context(
ContextLayer.L5_WEEKLY,
week,
f"weekly_pnl_{market_code}",
round(total_pnl, 2),
)
# Average all confidence values (market-specific if suffixed)
if "avg_confidence" in daily_data: if "avg_confidence" in daily_data:
conf_values = daily_data["avg_confidence"] conf_values = daily_data["avg_confidence"]
avg_conf = sum(conf_values) / len(conf_values) avg_conf = sum(conf_values) / len(conf_values)
@@ -107,6 +149,17 @@ class ContextAggregator:
ContextLayer.L5_WEEKLY, week, "avg_confidence", round(avg_conf, 2) ContextLayer.L5_WEEKLY, week, "avg_confidence", round(avg_conf, 2)
) )
for key, values in daily_data.items():
if key.startswith("avg_confidence_"):
market_code = key.split("avg_confidence_", 1)[1]
avg_conf = sum(values) / len(values)
self.store.set_context(
ContextLayer.L5_WEEKLY,
week,
f"avg_confidence_{market_code}",
round(avg_conf, 2),
)
def aggregate_monthly_from_weekly(self, month: str | None = None) -> None: def aggregate_monthly_from_weekly(self, month: str | None = None) -> None:
"""Aggregate L4 (monthly) context from L5 (weekly). """Aggregate L4 (monthly) context from L5 (weekly).
@@ -135,8 +188,16 @@ class ContextAggregator:
if weekly_data: if weekly_data:
# Sum all weekly PnL values # Sum all weekly PnL values
total_pnl_values: list[float] = []
if "weekly_pnl" in weekly_data: if "weekly_pnl" in weekly_data:
total_pnl = sum(weekly_data["weekly_pnl"]) total_pnl_values.extend(weekly_data["weekly_pnl"])
for key, values in weekly_data.items():
if key.startswith("weekly_pnl_"):
total_pnl_values.extend(values)
if total_pnl_values:
total_pnl = sum(total_pnl_values)
self.store.set_context( self.store.set_context(
ContextLayer.L4_MONTHLY, month, "monthly_pnl", round(total_pnl, 2) ContextLayer.L4_MONTHLY, month, "monthly_pnl", round(total_pnl, 2)
) )
@@ -230,21 +291,44 @@ class ContextAggregator:
) )
def run_all_aggregations(self) -> None: def run_all_aggregations(self) -> None:
"""Run all aggregations from L7 to L1 (bottom-up).""" """Run all aggregations from L7 to L1 (bottom-up).
All timeframes are derived from the latest trade timestamp so that
past data re-aggregation produces consistent results across layers.
"""
cursor = self.conn.execute("SELECT MAX(timestamp) FROM trades")
row = cursor.fetchone()
if not row or row[0] is None:
return
ts_raw = row[0]
if ts_raw.endswith("Z"):
ts_raw = ts_raw.replace("Z", "+00:00")
latest_ts = datetime.fromisoformat(ts_raw)
trade_date = latest_ts.date()
date_str = trade_date.isoformat()
iso_year, iso_week, _ = trade_date.isocalendar()
week_str = f"{iso_year}-W{iso_week:02d}"
month_str = f"{trade_date.year}-{trade_date.month:02d}"
quarter = (trade_date.month - 1) // 3 + 1
quarter_str = f"{trade_date.year}-Q{quarter}"
year_str = str(trade_date.year)
# L7 (trades) → L6 (daily) # L7 (trades) → L6 (daily)
self.aggregate_daily_from_trades() self.aggregate_daily_from_trades(date_str)
# L6 (daily) → L5 (weekly) # L6 (daily) → L5 (weekly)
self.aggregate_weekly_from_daily() self.aggregate_weekly_from_daily(week_str)
# L5 (weekly) → L4 (monthly) # L5 (weekly) → L4 (monthly)
self.aggregate_monthly_from_weekly() self.aggregate_monthly_from_weekly(month_str)
# L4 (monthly) → L3 (quarterly) # L4 (monthly) → L3 (quarterly)
self.aggregate_quarterly_from_monthly() self.aggregate_quarterly_from_monthly(quarter_str)
# L3 (quarterly) → L2 (annual) # L3 (quarterly) → L2 (annual)
self.aggregate_annual_from_quarterly() self.aggregate_annual_from_quarterly(year_str)
# L2 (annual) → L1 (legacy) # L2 (annual) → L1 (legacy)
self.aggregate_legacy_from_annual() self.aggregate_legacy_from_annual()

135
src/context/scheduler.py Normal file
View File

@@ -0,0 +1,135 @@
"""Context aggregation scheduler for periodic rollups and cleanup."""
from __future__ import annotations
import sqlite3
from calendar import monthrange
from dataclasses import dataclass
from datetime import UTC, datetime
from src.context.aggregator import ContextAggregator
from src.context.store import ContextStore
@dataclass(frozen=True)
class ScheduleResult:
"""Represents which scheduled tasks ran."""
weekly: bool = False
monthly: bool = False
quarterly: bool = False
annual: bool = False
legacy: bool = False
cleanup: bool = False
class ContextScheduler:
"""Run periodic context aggregations and cleanup when due."""
def __init__(
self,
conn: sqlite3.Connection | None = None,
aggregator: ContextAggregator | None = None,
store: ContextStore | None = None,
) -> None:
if aggregator is None:
if conn is None:
raise ValueError("conn is required when aggregator is not provided")
aggregator = ContextAggregator(conn)
self.aggregator = aggregator
if store is None:
store = getattr(aggregator, "store", None)
if store is None:
if conn is None:
raise ValueError("conn is required when store is not provided")
store = ContextStore(conn)
self.store = store
self._last_run: dict[str, str] = {}
def run_if_due(self, now: datetime | None = None) -> ScheduleResult:
"""Run scheduled aggregations if their schedule is due.
Args:
now: Current datetime (UTC). If None, uses current time.
Returns:
ScheduleResult indicating which tasks ran.
"""
if now is None:
now = datetime.now(UTC)
today = now.date().isoformat()
result = ScheduleResult()
if self._should_run("cleanup", today):
self.store.cleanup_expired_contexts()
result = self._with(result, cleanup=True)
if self._is_sunday(now) and self._should_run("weekly", today):
week = now.strftime("%Y-W%V")
self.aggregator.aggregate_weekly_from_daily(week)
result = self._with(result, weekly=True)
if self._is_last_day_of_month(now) and self._should_run("monthly", today):
month = now.strftime("%Y-%m")
self.aggregator.aggregate_monthly_from_weekly(month)
result = self._with(result, monthly=True)
if self._is_last_day_of_quarter(now) and self._should_run("quarterly", today):
quarter = self._current_quarter(now)
self.aggregator.aggregate_quarterly_from_monthly(quarter)
result = self._with(result, quarterly=True)
if self._is_last_day_of_year(now) and self._should_run("annual", today):
year = str(now.year)
self.aggregator.aggregate_annual_from_quarterly(year)
result = self._with(result, annual=True)
# Legacy rollup runs after annual aggregation.
self.aggregator.aggregate_legacy_from_annual()
result = self._with(result, legacy=True)
return result
def _should_run(self, key: str, date_str: str) -> bool:
if self._last_run.get(key) == date_str:
return False
self._last_run[key] = date_str
return True
@staticmethod
def _is_sunday(now: datetime) -> bool:
return now.weekday() == 6
@staticmethod
def _is_last_day_of_month(now: datetime) -> bool:
last_day = monthrange(now.year, now.month)[1]
return now.day == last_day
@classmethod
def _is_last_day_of_quarter(cls, now: datetime) -> bool:
if now.month not in (3, 6, 9, 12):
return False
return cls._is_last_day_of_month(now)
@staticmethod
def _is_last_day_of_year(now: datetime) -> bool:
return now.month == 12 and now.day == 31
@staticmethod
def _current_quarter(now: datetime) -> str:
quarter = (now.month - 1) // 3 + 1
return f"{now.year}-Q{quarter}"
@staticmethod
def _with(result: ScheduleResult, **kwargs: bool) -> ScheduleResult:
return ScheduleResult(
weekly=kwargs.get("weekly", result.weekly),
monthly=kwargs.get("monthly", result.monthly),
quarterly=kwargs.get("quarterly", result.quarterly),
annual=kwargs.get("annual", result.annual),
legacy=kwargs.get("legacy", result.legacy),
cleanup=kwargs.get("cleanup", result.cleanup),
)

View File

@@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
def init_db(db_path: str) -> sqlite3.Connection: def init_db(db_path: str) -> sqlite3.Connection:
@@ -25,7 +27,8 @@ def init_db(db_path: str) -> sqlite3.Connection:
price REAL, price REAL,
pnl REAL DEFAULT 0.0, pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR', market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX' exchange_code TEXT DEFAULT 'KRX',
decision_id TEXT
) )
""" """
) )
@@ -38,6 +41,10 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute("ALTER TABLE trades ADD COLUMN market TEXT DEFAULT 'KR'") conn.execute("ALTER TABLE trades ADD COLUMN market TEXT DEFAULT 'KR'")
if "exchange_code" not in columns: if "exchange_code" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN exchange_code TEXT DEFAULT 'KRX'") conn.execute("ALTER TABLE trades ADD COLUMN exchange_code TEXT DEFAULT 'KRX'")
if "selection_context" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
if "decision_id" not in columns:
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
# Context tree tables for multi-layered memory management # Context tree tables for multi-layered memory management
conn.execute( conn.execute(
@@ -88,6 +95,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)")
@@ -118,15 +146,34 @@ def log_trade(
pnl: float = 0.0, pnl: float = 0.0,
market: str = "KR", market: str = "KR",
exchange_code: str = "KRX", exchange_code: str = "KRX",
selection_context: dict[str, any] | None = None,
decision_id: str | None = None,
) -> None: ) -> None:
"""Insert a trade record into the database.""" """Insert a trade record into the database.
Args:
conn: Database connection
stock_code: Stock code
action: Trade action (BUY/SELL/HOLD)
confidence: Confidence level (0-100)
rationale: AI decision rationale
quantity: Number of shares
price: Trade price
pnl: Profit/loss
market: Market code
exchange_code: Exchange code
selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
"""
# Serialize selection context to JSON
context_json = json.dumps(selection_context) if selection_context else None
conn.execute( conn.execute(
""" """
INSERT INTO trades ( INSERT INTO trades (
timestamp, stock_code, action, confidence, rationale, timestamp, stock_code, action, confidence, rationale,
quantity, price, pnl, market, exchange_code quantity, price, pnl, market, exchange_code, selection_context, decision_id
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
datetime.now(UTC).isoformat(), datetime.now(UTC).isoformat(),
@@ -139,6 +186,31 @@ def log_trade(
pnl, pnl,
market, market,
exchange_code, exchange_code,
context_json,
decision_id,
), ),
) )
conn.commit() conn.commit()
def get_latest_buy_trade(
conn: sqlite3.Connection, stock_code: str, market: str
) -> dict[str, Any] | None:
"""Fetch the most recent BUY trade for a stock and market."""
cursor = conn.execute(
"""
SELECT decision_id, price, quantity
FROM trades
WHERE stock_code = ?
AND market = ?
AND action = 'BUY'
AND decision_id IS NOT NULL
ORDER BY timestamp DESC
LIMIT 1
""",
(stock_code, market),
)
row = cursor.fetchone()
if not row:
return None
return {"decision_id": row[0], "price": row[1], "quantity": row[2]}

View File

@@ -1,12 +1,14 @@
"""Evolution engine for self-improving trading strategies.""" """Evolution engine for self-improving trading strategies."""
from src.evolution.ab_test import ABTester, ABTestResult, StrategyPerformance from src.evolution.ab_test import ABTester, ABTestResult, StrategyPerformance
from src.evolution.daily_review import DailyReviewer
from src.evolution.optimizer import EvolutionOptimizer from src.evolution.optimizer import EvolutionOptimizer
from src.evolution.performance_tracker import ( from src.evolution.performance_tracker import (
PerformanceDashboard, PerformanceDashboard,
PerformanceTracker, PerformanceTracker,
StrategyMetrics, StrategyMetrics,
) )
from src.evolution.scorecard import DailyScorecard
__all__ = [ __all__ = [
"EvolutionOptimizer", "EvolutionOptimizer",
@@ -16,4 +18,6 @@ __all__ = [
"PerformanceTracker", "PerformanceTracker",
"PerformanceDashboard", "PerformanceDashboard",
"StrategyMetrics", "StrategyMetrics",
"DailyScorecard",
"DailyReviewer",
] ]

View File

@@ -0,0 +1,196 @@
"""Daily review generator for market-scoped end-of-day scorecards."""
from __future__ import annotations
import json
import logging
import re
import sqlite3
from dataclasses import asdict
from src.brain.gemini_client import GeminiClient
from src.context.layer import ContextLayer
from src.context.store import ContextStore
from src.evolution.scorecard import DailyScorecard
logger = logging.getLogger(__name__)
class DailyReviewer:
"""Builds daily scorecards and optional AI-generated lessons."""
def __init__(
self,
conn: sqlite3.Connection,
context_store: ContextStore,
gemini_client: GeminiClient | None = None,
) -> None:
self._conn = conn
self._context_store = context_store
self._gemini = gemini_client
def generate_scorecard(self, date: str, market: str) -> DailyScorecard:
"""Generate a market-scoped scorecard from decision logs and trades."""
decision_rows = self._conn.execute(
"""
SELECT action, confidence, context_snapshot
FROM decision_logs
WHERE DATE(timestamp) = ? AND market = ?
""",
(date, market),
).fetchall()
total_decisions = len(decision_rows)
buys = sum(1 for row in decision_rows if row[0] == "BUY")
sells = sum(1 for row in decision_rows if row[0] == "SELL")
holds = sum(1 for row in decision_rows if row[0] == "HOLD")
avg_confidence = (
round(sum(int(row[1]) for row in decision_rows) / total_decisions, 2)
if total_decisions > 0
else 0.0
)
matched = 0
for row in decision_rows:
try:
snapshot = json.loads(row[2]) if row[2] else {}
except json.JSONDecodeError:
snapshot = {}
scenario_match = snapshot.get("scenario_match", {})
if isinstance(scenario_match, dict) and scenario_match:
matched += 1
scenario_match_rate = (
round((matched / total_decisions) * 100, 2)
if total_decisions
else 0.0
)
trade_stats = self._conn.execute(
"""
SELECT
COALESCE(SUM(pnl), 0.0),
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END),
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END)
FROM trades
WHERE DATE(timestamp) = ? AND market = ?
""",
(date, market),
).fetchone()
total_pnl = round(float(trade_stats[0] or 0.0), 2) if trade_stats else 0.0
wins = int(trade_stats[1] or 0) if trade_stats else 0
losses = int(trade_stats[2] or 0) if trade_stats else 0
win_rate = round((wins / (wins + losses)) * 100, 2) if (wins + losses) > 0 else 0.0
top_winners = [
row[0]
for row in self._conn.execute(
"""
SELECT stock_code, SUM(pnl) AS stock_pnl
FROM trades
WHERE DATE(timestamp) = ? AND market = ?
GROUP BY stock_code
HAVING stock_pnl > 0
ORDER BY stock_pnl DESC
LIMIT 3
""",
(date, market),
).fetchall()
]
top_losers = [
row[0]
for row in self._conn.execute(
"""
SELECT stock_code, SUM(pnl) AS stock_pnl
FROM trades
WHERE DATE(timestamp) = ? AND market = ?
GROUP BY stock_code
HAVING stock_pnl < 0
ORDER BY stock_pnl ASC
LIMIT 3
""",
(date, market),
).fetchall()
]
return DailyScorecard(
date=date,
market=market,
total_decisions=total_decisions,
buys=buys,
sells=sells,
holds=holds,
total_pnl=total_pnl,
win_rate=win_rate,
avg_confidence=avg_confidence,
scenario_match_rate=scenario_match_rate,
top_winners=top_winners,
top_losers=top_losers,
lessons=[],
cross_market_note="",
)
async def generate_lessons(self, scorecard: DailyScorecard) -> list[str]:
"""Generate concise lessons from scorecard metrics using Gemini."""
if self._gemini is None:
return []
prompt = (
"You are a trading performance reviewer.\n"
"Return ONLY a JSON array of 1-3 short lessons in English.\n"
f"Market: {scorecard.market}\n"
f"Date: {scorecard.date}\n"
f"Total decisions: {scorecard.total_decisions}\n"
f"Buys/Sells/Holds: {scorecard.buys}/{scorecard.sells}/{scorecard.holds}\n"
f"Total PnL: {scorecard.total_pnl}\n"
f"Win rate: {scorecard.win_rate}%\n"
f"Average confidence: {scorecard.avg_confidence}\n"
f"Scenario match rate: {scorecard.scenario_match_rate}%\n"
f"Top winners: {', '.join(scorecard.top_winners) or 'N/A'}\n"
f"Top losers: {', '.join(scorecard.top_losers) or 'N/A'}\n"
)
try:
decision = await self._gemini.decide(
{
"stock_code": "REVIEW",
"market_name": scorecard.market,
"current_price": 0,
"prompt_override": prompt,
}
)
return self._parse_lessons(decision.rationale)
except Exception as exc:
logger.warning("Failed to generate daily lessons: %s", exc)
return []
def store_scorecard_in_context(self, scorecard: DailyScorecard) -> None:
"""Store scorecard in L6 using market-scoped key."""
self._context_store.set_context(
ContextLayer.L6_DAILY,
scorecard.date,
f"scorecard_{scorecard.market}",
asdict(scorecard),
)
def _parse_lessons(self, raw_text: str) -> list[str]:
"""Parse lessons from JSON array response or fallback text."""
raw_text = raw_text.strip()
try:
parsed = json.loads(raw_text)
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()][:3]
except json.JSONDecodeError:
pass
match = re.search(r"\[.*\]", raw_text, re.DOTALL)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()][:3]
except json.JSONDecodeError:
pass
lines = [line.strip("-* \t") for line in raw_text.splitlines() if line.strip()]
return lines[:3]

View File

@@ -0,0 +1,25 @@
"""Daily scorecard model for end-of-day performance review."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class DailyScorecard:
"""Structured daily performance snapshot for a single market."""
date: str
market: str
total_decisions: int
buys: int
sells: int
holds: int
total_pnl: float
win_rate: float
avg_confidence: float
scenario_match_rate: float
top_winners: list[str] = field(default_factory=list)
top_losers: list[str] = field(default_factory=list)
lessons: list[str] = field(default_factory=list)
cross_market_note: str = ""

File diff suppressed because it is too large Load Diff

View File

@@ -200,14 +200,151 @@ telegram = TelegramClient(
) )
``` ```
## Bidirectional Commands
Control your trading bot remotely via Telegram commands. The bot not only sends notifications but also accepts commands for real-time control.
### Available Commands
| Command | Description |
|---------|-------------|
| `/start` | Welcome message with quick start guide |
| `/help` | List all available commands |
| `/status` | Current trading status (mode, markets, P&L, circuit breaker) |
| `/positions` | View current holdings grouped by market |
| `/stop` | Pause all trading operations |
| `/resume` | Resume trading operations |
### Command Examples
**Check Trading Status**
```
You: /status
Bot:
📊 Trading Status
Mode: PAPER
Markets: Korea, United States
Trading: Active
Current P&L: +2.50%
Circuit Breaker: -3.0%
```
**View Holdings**
```
You: /positions
Bot:
💼 Current Holdings
🇰🇷 Korea
• 005930: 10 shares @ 70,000
• 035420: 5 shares @ 200,000
🇺🇸 Overseas
• AAPL: 15 shares @ 175
• TSLA: 8 shares @ 245
Cash: ₩5,000,000
```
**Pause Trading**
```
You: /stop
Bot:
⏸️ Trading Paused
All trading operations have been suspended.
Use /resume to restart trading.
```
**Resume Trading**
```
You: /resume
Bot:
▶️ Trading Resumed
Trading operations have been restarted.
```
### Security
**Chat ID Verification**
- Commands are only accepted from the configured `TELEGRAM_CHAT_ID`
- Unauthorized users receive no response
- Command attempts from wrong chat IDs are logged
**Authorization Required**
- Only the bot owner (chat ID in `.env`) can control trading
- No way for unauthorized users to discover or use commands
- All command executions are logged for audit
### Configuration
Add to your `.env` file:
```bash
# Commands are enabled by default
TELEGRAM_COMMANDS_ENABLED=true
# Polling interval (seconds) - how often to check for commands
TELEGRAM_POLLING_INTERVAL=1.0
```
To disable commands but keep notifications:
```bash
TELEGRAM_COMMANDS_ENABLED=false
```
### How It Works
1. **Long Polling**: Bot checks Telegram API every second for new messages
2. **Command Parsing**: Messages starting with `/` are parsed as commands
3. **Authentication**: Chat ID is verified before executing any command
4. **Execution**: Command handler is called with current bot state
5. **Response**: Result is sent back via Telegram
### Error Handling
- Command parsing errors → "Unknown command" response
- API failures → Graceful degradation, error logged
- Invalid state → Appropriate message (e.g., "Trading is already paused")
- Trading loop isolation → Command errors never crash trading
### Troubleshooting Commands
**Commands not responding**
1. Check `TELEGRAM_COMMANDS_ENABLED=true` in `.env`
2. Verify you started conversation with `/start`
3. Check logs for command handler errors
4. Confirm chat ID matches `.env` configuration
**Wrong chat ID**
- Commands from unauthorized chats are silently ignored
- Check logs for "unauthorized chat_id" warnings
**Delayed responses**
- Polling interval is 1 second by default
- Network latency may add delay
- Check `TELEGRAM_POLLING_INTERVAL` setting
## API Reference ## API Reference
See `telegram_client.py` for full API documentation. See `telegram_client.py` for full API documentation.
Key methods: ### Notification Methods
- `notify_trade_execution()` - Trade alerts - `notify_trade_execution()` - Trade alerts
- `notify_circuit_breaker()` - Emergency stops - `notify_circuit_breaker()` - Emergency stops
- `notify_fat_finger()` - Order rejections - `notify_fat_finger()` - Order rejections
- `notify_market_open/close()` - Session tracking - `notify_market_open/close()` - Session tracking
- `notify_system_start/shutdown()` - Lifecycle events - `notify_system_start/shutdown()` - Lifecycle events
- `notify_error()` - Error alerts - `notify_error()` - Error alerts
### Command Handler
- `TelegramCommandHandler` - Bidirectional command processing
- `register_command()` - Register custom command handlers
- `start_polling()` / `stop_polling()` - Lifecycle management

View File

@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import time import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
@@ -117,26 +118,28 @@ class TelegramClient:
if self._session is not None and not self._session.closed: if self._session is not None and not self._session.closed:
await self._session.close() await self._session.close()
async def _send_notification(self, msg: NotificationMessage) -> None: async def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
""" """
Send notification to Telegram with graceful degradation. Send a generic text message to Telegram.
Args: Args:
msg: Notification message to send text: Message text to send
parse_mode: Parse mode for formatting (HTML or Markdown)
Returns:
True if message was sent successfully, False otherwise
""" """
if not self._enabled: if not self._enabled:
return return False
try: try:
await self._rate_limiter.acquire() await self._rate_limiter.acquire()
formatted_message = f"{msg.priority.emoji} {msg.message}"
url = f"{self.API_BASE.format(token=self._bot_token)}/sendMessage" url = f"{self.API_BASE.format(token=self._bot_token)}/sendMessage"
payload = { payload = {
"chat_id": self._chat_id, "chat_id": self._chat_id,
"text": formatted_message, "text": text,
"parse_mode": "HTML", "parse_mode": parse_mode,
} }
session = self._get_session() session = self._get_session()
@@ -146,15 +149,29 @@ class TelegramClient:
logger.error( logger.error(
"Telegram API error (status=%d): %s", resp.status, error_text "Telegram API error (status=%d): %s", resp.status, error_text
) )
else: return False
logger.debug("Telegram notification sent: %s", msg.message[:50]) logger.debug("Telegram message sent: %s", text[:50])
return True
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error("Telegram notification timeout") logger.error("Telegram message timeout")
return False
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
logger.error("Telegram notification failed: %s", exc) logger.error("Telegram message failed: %s", exc)
return False
except Exception as exc: except Exception as exc:
logger.error("Unexpected error sending notification: %s", exc) logger.error("Unexpected error sending message: %s", exc)
return False
async def _send_notification(self, msg: NotificationMessage) -> None:
"""
Send notification to Telegram with graceful degradation.
Args:
msg: Notification message to send
"""
formatted_message = f"{msg.priority.emoji} {msg.message}"
await self.send_message(formatted_message)
async def notify_trade_execution( async def notify_trade_execution(
self, self,
@@ -287,6 +304,77 @@ class TelegramClient:
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message) NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
) )
async def notify_playbook_generated(
self,
market: str,
stock_count: int,
scenario_count: int,
token_count: int,
) -> None:
"""
Notify that a daily playbook was generated.
Args:
market: Market code (e.g., "KR", "US")
stock_count: Number of stocks in the playbook
scenario_count: Total number of scenarios
token_count: Gemini token usage for the playbook
"""
message = (
f"<b>Playbook Generated</b>\n"
f"Market: {market}\n"
f"Stocks: {stock_count}\n"
f"Scenarios: {scenario_count}\n"
f"Tokens: {token_count}"
)
await self._send_notification(
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
)
async def notify_scenario_matched(
self,
stock_code: str,
action: str,
condition_summary: str,
confidence: float,
) -> None:
"""
Notify that a scenario matched for a stock.
Args:
stock_code: Stock ticker symbol
action: Scenario action (BUY/SELL/HOLD/REDUCE_ALL)
condition_summary: Short summary of the matched condition
confidence: Scenario confidence (0-100)
"""
message = (
f"<b>Scenario Matched</b>\n"
f"Symbol: <code>{stock_code}</code>\n"
f"Action: {action}\n"
f"Condition: {condition_summary}\n"
f"Confidence: {confidence:.0f}%"
)
await self._send_notification(
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
)
async def notify_playbook_failed(self, market: str, reason: str) -> None:
"""
Notify that playbook generation failed.
Args:
market: Market code (e.g., "KR", "US")
reason: Failure reason summary
"""
message = (
f"<b>Playbook Failed</b>\n"
f"Market: {market}\n"
f"Reason: {reason[:200]}"
)
await self._send_notification(
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
)
async def notify_system_shutdown(self, reason: str) -> None: async def notify_system_shutdown(self, reason: str) -> None:
""" """
Notify system shutdown. Notify system shutdown.
@@ -323,3 +411,172 @@ class TelegramClient:
await self._send_notification( await self._send_notification(
NotificationMessage(priority=NotificationPriority.HIGH, message=message) NotificationMessage(priority=NotificationPriority.HIGH, message=message)
) )
class TelegramCommandHandler:
"""Handles incoming Telegram commands via long polling."""
def __init__(
self, client: TelegramClient, polling_interval: float = 1.0
) -> None:
"""
Initialize command handler.
Args:
client: TelegramClient instance for sending responses
polling_interval: Polling interval in seconds
"""
self._client = client
self._polling_interval = polling_interval
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
self._last_update_id = 0
self._polling_task: asyncio.Task[None] | None = None
self._running = False
def register_command(
self, command: str, handler: Callable[[], Awaitable[None]]
) -> None:
"""
Register a command handler.
Args:
command: Command name (without leading slash, e.g., "start")
handler: Async function to handle the command
"""
self._commands[command] = handler
logger.debug("Registered command handler: /%s", command)
async def start_polling(self) -> None:
"""Start long polling for commands."""
if self._running:
logger.warning("Command handler already running")
return
if not self._client._enabled:
logger.info("Command handler disabled (TelegramClient disabled)")
return
self._running = True
self._polling_task = asyncio.create_task(self._poll_loop())
logger.info("Started Telegram command polling")
async def stop_polling(self) -> None:
"""Stop polling and cancel pending tasks."""
if not self._running:
return
self._running = False
if self._polling_task:
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
logger.info("Stopped Telegram command polling")
async def _poll_loop(self) -> None:
"""Main polling loop that fetches updates."""
while self._running:
try:
updates = await self._get_updates()
for update in updates:
await self._handle_update(update)
except asyncio.CancelledError:
break
except Exception as exc:
logger.error("Error in polling loop: %s", exc)
await asyncio.sleep(self._polling_interval)
async def _get_updates(self) -> list[dict]:
"""
Fetch updates from Telegram API.
Returns:
List of update objects
"""
try:
url = f"{self._client.API_BASE.format(token=self._client._bot_token)}/getUpdates"
payload = {
"offset": self._last_update_id + 1,
"timeout": int(self._polling_interval),
"allowed_updates": ["message"],
}
session = self._client._get_session()
async with session.post(url, json=payload) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.error(
"getUpdates API error (status=%d): %s", resp.status, error_text
)
return []
data = await resp.json()
if not data.get("ok"):
logger.error("getUpdates returned ok=false: %s", data)
return []
updates = data.get("result", [])
if updates:
self._last_update_id = updates[-1]["update_id"]
return updates
except asyncio.TimeoutError:
logger.debug("getUpdates timeout (normal)")
return []
except aiohttp.ClientError as exc:
logger.error("getUpdates failed: %s", exc)
return []
except Exception as exc:
logger.error("Unexpected error in _get_updates: %s", exc)
return []
async def _handle_update(self, update: dict) -> None:
"""
Parse and handle a single update.
Args:
update: Update object from Telegram API
"""
try:
message = update.get("message")
if not message:
return
# Verify chat_id matches configured chat
chat_id = str(message.get("chat", {}).get("id", ""))
if chat_id != self._client._chat_id:
logger.warning(
"Ignoring command from unauthorized chat_id: %s", chat_id
)
return
# Extract command text
text = message.get("text", "").strip()
if not text.startswith("/"):
return
# Parse command (remove leading slash and extract command name)
command_parts = text[1:].split()
if not command_parts:
return
# Remove @botname suffix if present (for group chats)
command_name = command_parts[0].split("@")[0]
# Execute handler
handler = self._commands.get(command_name)
if handler:
logger.info("Executing command: /%s", command_name)
await handler()
else:
logger.debug("Unknown command: /%s", command_name)
await self._client.send_message(
f"Unknown command: /{command_name}\nUse /help to see available commands."
)
except Exception as exc:
logger.error("Error handling update: %s", exc)
# Don't crash the polling loop on handler errors

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,472 @@
"""Pre-market planner — generates DayPlaybook via Gemini before market open.
One Gemini API call per market per day. Candidates come from SmartVolatilityScanner.
On failure, returns a defensive playbook (all HOLD, no trades).
"""
from __future__ import annotations
import json
import logging
from datetime import date, timedelta
from typing import Any
from src.analysis.smart_scanner import ScanCandidate
from src.brain.context_selector import ContextSelector, DecisionType
from src.brain.gemini_client import GeminiClient
from src.config import Settings
from src.context.store import ContextLayer, ContextStore
from src.strategy.models import (
CrossMarketContext,
DayPlaybook,
GlobalRule,
MarketOutlook,
ScenarioAction,
StockCondition,
StockPlaybook,
StockScenario,
)
logger = logging.getLogger(__name__)
# Mapping from string to MarketOutlook enum
_OUTLOOK_MAP: dict[str, MarketOutlook] = {
"bullish": MarketOutlook.BULLISH,
"neutral_to_bullish": MarketOutlook.NEUTRAL_TO_BULLISH,
"neutral": MarketOutlook.NEUTRAL,
"neutral_to_bearish": MarketOutlook.NEUTRAL_TO_BEARISH,
"bearish": MarketOutlook.BEARISH,
}
_ACTION_MAP: dict[str, ScenarioAction] = {
"BUY": ScenarioAction.BUY,
"SELL": ScenarioAction.SELL,
"HOLD": ScenarioAction.HOLD,
"REDUCE_ALL": ScenarioAction.REDUCE_ALL,
}
class PreMarketPlanner:
"""Generates a DayPlaybook by calling Gemini once before market open.
Flow:
1. Collect strategic context (L5-L7) + cross-market context
2. Build a structured prompt with scan candidates
3. Call Gemini for JSON scenario generation
4. Parse and validate response into DayPlaybook
5. On failure → defensive playbook (HOLD everything)
"""
def __init__(
self,
gemini_client: GeminiClient,
context_store: ContextStore,
context_selector: ContextSelector,
settings: Settings,
) -> None:
self._gemini = gemini_client
self._context_store = context_store
self._context_selector = context_selector
self._settings = settings
async def generate_playbook(
self,
market: str,
candidates: list[ScanCandidate],
today: date | None = None,
) -> DayPlaybook:
"""Generate a DayPlaybook for a market using Gemini.
Args:
market: Market code ("KR" or "US")
candidates: Stock candidates from SmartVolatilityScanner
today: Override date (defaults to date.today()). Use market-local date.
Returns:
DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
"""
if today is None:
today = date.today()
if not candidates:
logger.info("No candidates for %s — returning empty playbook", market)
return self._empty_playbook(today, market)
try:
# 1. Gather context
context_data = self._gather_context()
self_market_scorecard = self.build_self_market_scorecard(market, today)
cross_market = self.build_cross_market_context(market, today)
# 2. Build prompt
prompt = self._build_prompt(
market,
candidates,
context_data,
self_market_scorecard,
cross_market,
)
# 3. Call Gemini
market_data = {
"stock_code": "PLANNER",
"current_price": 0,
"prompt_override": prompt,
}
decision = await self._gemini.decide(market_data)
# 4. Parse response
playbook = self._parse_response(
decision.rationale, today, market, candidates, cross_market
)
playbook_with_tokens = playbook.model_copy(
update={"token_count": decision.token_count}
)
logger.info(
"Generated playbook for %s: %d stocks, %d scenarios, %d tokens",
market,
playbook_with_tokens.stock_count,
playbook_with_tokens.scenario_count,
playbook_with_tokens.token_count,
)
return playbook_with_tokens
except Exception:
logger.exception("Playbook generation failed for %s", market)
if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE:
return self._defensive_playbook(today, market, candidates)
return self._empty_playbook(today, market)
def build_cross_market_context(
self, target_market: str, today: date | None = None,
) -> CrossMarketContext | None:
"""Build cross-market context from the other market's L6 data.
KR planner → reads US scorecard from previous night.
US planner → reads KR scorecard from today.
Args:
target_market: The market being planned ("KR" or "US")
today: Override date (defaults to date.today()). Use market-local date.
"""
other_market = "US" if target_market == "KR" else "KR"
if today is None:
today = date.today()
timeframe_date = today - timedelta(days=1) if target_market == "KR" else today
timeframe = timeframe_date.isoformat()
scorecard_key = f"scorecard_{other_market}"
scorecard_data = self._context_store.get_context(
ContextLayer.L6_DAILY, timeframe, scorecard_key
)
if scorecard_data is None:
logger.debug("No cross-market scorecard found for %s", other_market)
return None
if isinstance(scorecard_data, str):
try:
scorecard_data = json.loads(scorecard_data)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(scorecard_data, dict):
return None
return CrossMarketContext(
market=other_market,
date=timeframe,
total_pnl=float(scorecard_data.get("total_pnl", 0.0)),
win_rate=float(scorecard_data.get("win_rate", 0.0)),
index_change_pct=float(scorecard_data.get("index_change_pct", 0.0)),
key_events=scorecard_data.get("key_events", []),
lessons=scorecard_data.get("lessons", []),
)
def build_self_market_scorecard(
self, market: str, today: date | None = None,
) -> dict[str, Any] | None:
"""Build previous-day scorecard for the same market."""
if today is None:
today = date.today()
timeframe = (today - timedelta(days=1)).isoformat()
scorecard_key = f"scorecard_{market}"
scorecard_data = self._context_store.get_context(
ContextLayer.L6_DAILY, timeframe, scorecard_key
)
if scorecard_data is None:
return None
if isinstance(scorecard_data, str):
try:
scorecard_data = json.loads(scorecard_data)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(scorecard_data, dict):
return None
return {
"date": timeframe,
"total_pnl": float(scorecard_data.get("total_pnl", 0.0)),
"win_rate": float(scorecard_data.get("win_rate", 0.0)),
"lessons": scorecard_data.get("lessons", []),
}
def _gather_context(self) -> dict[str, Any]:
"""Gather strategic context using ContextSelector."""
layers = self._context_selector.select_layers(
decision_type=DecisionType.STRATEGIC,
include_realtime=True,
)
return self._context_selector.get_context_data(layers, max_items_per_layer=10)
def _build_prompt(
self,
market: str,
candidates: list[ScanCandidate],
context_data: dict[str, Any],
self_market_scorecard: dict[str, Any] | None,
cross_market: CrossMarketContext | None,
) -> str:
"""Build a structured prompt for Gemini to generate scenario JSON."""
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
candidates_text = "\n".join(
f" - {c.stock_code} ({c.name}): price={c.price}, "
f"RSI={c.rsi:.1f}, volume_ratio={c.volume_ratio:.1f}, "
f"signal={c.signal}, score={c.score:.1f}"
for c in candidates
)
cross_market_text = ""
if cross_market:
cross_market_text = (
f"\n## Other Market ({cross_market.market}) Summary\n"
f"- P&L: {cross_market.total_pnl:+.2f}%\n"
f"- Win Rate: {cross_market.win_rate:.0f}%\n"
f"- Index Change: {cross_market.index_change_pct:+.2f}%\n"
)
if cross_market.lessons:
cross_market_text += f"- Lessons: {'; '.join(cross_market.lessons[:3])}\n"
self_market_text = ""
if self_market_scorecard:
self_market_text = (
f"\n## My Market Previous Day ({market})\n"
f"- Date: {self_market_scorecard['date']}\n"
f"- P&L: {self_market_scorecard['total_pnl']:+.2f}%\n"
f"- Win Rate: {self_market_scorecard['win_rate']:.0f}%\n"
)
lessons = self_market_scorecard.get("lessons", [])
if lessons:
self_market_text += f"- Lessons: {'; '.join(lessons[:3])}\n"
context_text = ""
if context_data:
context_text = "\n## Strategic Context\n"
for layer_name, layer_data in context_data.items():
if layer_data:
context_text += f"### {layer_name}\n"
for key, value in list(layer_data.items())[:5]:
context_text += f" - {key}: {value}\n"
return (
f"You are a pre-market trading strategist for the {market} market.\n"
f"Generate structured trading scenarios for today.\n\n"
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
f"{self_market_text}"
f"{cross_market_text}"
f"{context_text}\n"
f"## Instructions\n"
f"Return a JSON object with this exact structure:\n"
f'{{\n'
f' "market_outlook": "bullish|neutral_to_bullish|neutral'
f'|neutral_to_bearish|bearish",\n'
f' "global_rules": [\n'
f' {{"condition": "portfolio_pnl_pct < -2.0",'
f' "action": "REDUCE_ALL", "rationale": "..."}}\n'
f' ],\n'
f' "stocks": [\n'
f' {{\n'
f' "stock_code": "...",\n'
f' "scenarios": [\n'
f' {{\n'
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
f' "action": "BUY|SELL|HOLD",\n'
f' "confidence": 85,\n'
f' "allocation_pct": 10.0,\n'
f' "stop_loss_pct": -2.0,\n'
f' "take_profit_pct": 3.0,\n'
f' "rationale": "..."\n'
f' }}\n'
f' ]\n'
f' }}\n'
f' ]\n'
f'}}\n\n'
f"Rules:\n"
f"- Max {max_scenarios} scenarios per stock\n"
f"- Only use stocks from the candidates list\n"
f"- Confidence 0-100 (80+ for actionable trades)\n"
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
f"- Return ONLY the JSON, no markdown fences or explanation\n"
)
def _parse_response(
self,
response_text: str,
today: date,
market: str,
candidates: list[ScanCandidate],
cross_market: CrossMarketContext | None,
) -> DayPlaybook:
"""Parse Gemini's JSON response into a validated DayPlaybook."""
cleaned = self._extract_json(response_text)
data = json.loads(cleaned)
valid_codes = {c.stock_code for c in candidates}
# Parse market outlook
outlook_str = data.get("market_outlook", "neutral")
market_outlook = _OUTLOOK_MAP.get(outlook_str, MarketOutlook.NEUTRAL)
# Parse global rules
global_rules = []
for rule_data in data.get("global_rules", []):
action_str = rule_data.get("action", "HOLD")
action = _ACTION_MAP.get(action_str, ScenarioAction.HOLD)
global_rules.append(
GlobalRule(
condition=rule_data.get("condition", ""),
action=action,
rationale=rule_data.get("rationale", ""),
)
)
# Parse stock playbooks
stock_playbooks = []
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
for stock_data in data.get("stocks", []):
code = stock_data.get("stock_code", "")
if code not in valid_codes:
logger.warning("Gemini returned unknown stock %s — skipping", code)
continue
scenarios = []
for sc_data in stock_data.get("scenarios", [])[:max_scenarios]:
scenario = self._parse_scenario(sc_data)
if scenario:
scenarios.append(scenario)
if scenarios:
stock_playbooks.append(
StockPlaybook(
stock_code=code,
scenarios=scenarios,
)
)
return DayPlaybook(
date=today,
market=market,
market_outlook=market_outlook,
global_rules=global_rules,
stock_playbooks=stock_playbooks,
cross_market=cross_market,
)
def _parse_scenario(self, sc_data: dict) -> StockScenario | None:
"""Parse a single scenario from JSON data. Returns None if invalid."""
try:
cond_data = sc_data.get("condition", {})
condition = StockCondition(
rsi_below=cond_data.get("rsi_below"),
rsi_above=cond_data.get("rsi_above"),
volume_ratio_above=cond_data.get("volume_ratio_above"),
volume_ratio_below=cond_data.get("volume_ratio_below"),
price_above=cond_data.get("price_above"),
price_below=cond_data.get("price_below"),
price_change_pct_above=cond_data.get("price_change_pct_above"),
price_change_pct_below=cond_data.get("price_change_pct_below"),
)
if not condition.has_any_condition():
logger.warning("Scenario has no conditions — skipping")
return None
action_str = sc_data.get("action", "HOLD")
action = _ACTION_MAP.get(action_str, ScenarioAction.HOLD)
return StockScenario(
condition=condition,
action=action,
confidence=int(sc_data.get("confidence", 50)),
allocation_pct=float(sc_data.get("allocation_pct", 10.0)),
stop_loss_pct=float(sc_data.get("stop_loss_pct", -2.0)),
take_profit_pct=float(sc_data.get("take_profit_pct", 3.0)),
rationale=sc_data.get("rationale", ""),
)
except (ValueError, TypeError) as e:
logger.warning("Failed to parse scenario: %s", e)
return None
@staticmethod
def _extract_json(text: str) -> str:
"""Extract JSON from response, stripping markdown fences if present."""
stripped = text.strip()
if stripped.startswith("```"):
# Remove first line (```json or ```) and last line (```)
lines = stripped.split("\n")
lines = lines[1:] # Remove opening fence
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
stripped = "\n".join(lines)
return stripped.strip()
@staticmethod
def _empty_playbook(today: date, market: str) -> DayPlaybook:
"""Return an empty playbook (no stocks, no scenarios)."""
return DayPlaybook(
date=today,
market=market,
market_outlook=MarketOutlook.NEUTRAL,
stock_playbooks=[],
)
@staticmethod
def _defensive_playbook(
today: date,
market: str,
candidates: list[ScanCandidate],
) -> DayPlaybook:
"""Return a defensive playbook — HOLD everything with stop-loss ready."""
stock_playbooks = [
StockPlaybook(
stock_code=c.stock_code,
scenarios=[
StockScenario(
condition=StockCondition(price_change_pct_below=-3.0),
action=ScenarioAction.SELL,
confidence=90,
stop_loss_pct=-3.0,
rationale="Defensive stop-loss (planner failure)",
),
],
)
for c in candidates
]
return DayPlaybook(
date=today,
market=market,
market_outlook=MarketOutlook.NEUTRAL_TO_BEARISH,
default_action=ScenarioAction.HOLD,
stock_playbooks=stock_playbooks,
global_rules=[
GlobalRule(
condition="portfolio_pnl_pct < -2.0",
action=ScenarioAction.REDUCE_ALL,
rationale="Defensive: reduce on loss threshold",
),
],
)

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

@@ -152,3 +152,121 @@ class TestPromptConstruction:
assert "JSON" in prompt assert "JSON" in prompt
assert "action" in prompt assert "action" in prompt
assert "confidence" in prompt assert "confidence" in prompt
# ---------------------------------------------------------------------------
# Batch Decision Making
# ---------------------------------------------------------------------------
class TestBatchDecisionParsing:
"""Batch response parser must handle JSON arrays correctly."""
def test_parse_valid_batch_response(self, settings):
client = GeminiClient(settings)
stocks_data = [
{"stock_code": "AAPL", "current_price": 185.5},
{"stock_code": "MSFT", "current_price": 420.0},
]
raw = """[
{"code": "AAPL", "action": "BUY", "confidence": 85, "rationale": "Strong momentum"},
{"code": "MSFT", "action": "HOLD", "confidence": 50, "rationale": "Wait for earnings"}
]"""
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert len(decisions) == 2
assert decisions["AAPL"].action == "BUY"
assert decisions["AAPL"].confidence == 85
assert decisions["MSFT"].action == "HOLD"
assert decisions["MSFT"].confidence == 50
def test_parse_batch_with_markdown_wrapper(self, settings):
client = GeminiClient(settings)
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
raw = """```json
[{"code": "AAPL", "action": "BUY", "confidence": 90, "rationale": "Good"}]
```"""
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert decisions["AAPL"].action == "BUY"
assert decisions["AAPL"].confidence == 90
def test_parse_batch_empty_response_returns_hold_for_all(self, settings):
client = GeminiClient(settings)
stocks_data = [
{"stock_code": "AAPL", "current_price": 185.5},
{"stock_code": "MSFT", "current_price": 420.0},
]
decisions = client._parse_batch_response("", stocks_data, token_count=100)
assert len(decisions) == 2
assert decisions["AAPL"].action == "HOLD"
assert decisions["AAPL"].confidence == 0
assert decisions["MSFT"].action == "HOLD"
def test_parse_batch_malformed_json_returns_hold_for_all(self, settings):
client = GeminiClient(settings)
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
raw = "This is not JSON"
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert decisions["AAPL"].action == "HOLD"
assert decisions["AAPL"].confidence == 0
def test_parse_batch_not_array_returns_hold_for_all(self, settings):
client = GeminiClient(settings)
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
raw = '{"code": "AAPL", "action": "BUY", "confidence": 90, "rationale": "Good"}'
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert decisions["AAPL"].action == "HOLD"
assert decisions["AAPL"].confidence == 0
def test_parse_batch_missing_stock_gets_hold(self, settings):
client = GeminiClient(settings)
stocks_data = [
{"stock_code": "AAPL", "current_price": 185.5},
{"stock_code": "MSFT", "current_price": 420.0},
]
# Response only has AAPL, MSFT is missing
raw = '[{"code": "AAPL", "action": "BUY", "confidence": 85, "rationale": "Good"}]'
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert decisions["AAPL"].action == "BUY"
assert decisions["MSFT"].action == "HOLD"
assert decisions["MSFT"].confidence == 0
def test_parse_batch_invalid_action_becomes_hold(self, settings):
client = GeminiClient(settings)
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
raw = '[{"code": "AAPL", "action": "YOLO", "confidence": 90, "rationale": "Moon"}]'
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert decisions["AAPL"].action == "HOLD"
def test_parse_batch_low_confidence_becomes_hold(self, settings):
client = GeminiClient(settings)
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
raw = '[{"code": "AAPL", "action": "BUY", "confidence": 65, "rationale": "Weak"}]'
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert decisions["AAPL"].action == "HOLD"
assert decisions["AAPL"].confidence == 65
def test_parse_batch_missing_fields_gets_hold(self, settings):
client = GeminiClient(settings)
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
raw = '[{"code": "AAPL", "action": "BUY"}]' # Missing confidence and rationale
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
assert decisions["AAPL"].action == "HOLD"
assert decisions["AAPL"].confidence == 0

View File

@@ -89,6 +89,70 @@ class TestTokenManagement:
await broker.close() await broker.close()
@pytest.mark.asyncio
async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings):
"""Token refresh should enforce cooldown after failure (issue #54)."""
broker = KISBroker(settings)
broker._refresh_cooldown = 2.0 # Short cooldown for testing
# First refresh attempt fails with 403 (EGW00133)
mock_resp_403 = AsyncMock()
mock_resp_403.status = 403
mock_resp_403.text = AsyncMock(
return_value='{"error_code":"EGW00133","error_description":"접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)"}'
)
mock_resp_403.__aenter__ = AsyncMock(return_value=mock_resp_403)
mock_resp_403.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp_403):
# First attempt should fail with 403
with pytest.raises(ConnectionError, match="Token refresh failed"):
await broker._ensure_token()
# Second attempt within cooldown should fail with cooldown error
with pytest.raises(ConnectionError, match="Token refresh on cooldown"):
await broker._ensure_token()
await broker.close()
@pytest.mark.asyncio
async def test_token_refresh_allowed_after_cooldown(self, settings):
"""Token refresh should be allowed after cooldown period expires."""
broker = KISBroker(settings)
broker._refresh_cooldown = 0.1 # Very short cooldown for testing
# First attempt fails
mock_resp_403 = AsyncMock()
mock_resp_403.status = 403
mock_resp_403.text = AsyncMock(return_value='{"error_code":"EGW00133"}')
mock_resp_403.__aenter__ = AsyncMock(return_value=mock_resp_403)
mock_resp_403.__aexit__ = AsyncMock(return_value=False)
# Second attempt succeeds
mock_resp_200 = AsyncMock()
mock_resp_200.status = 200
mock_resp_200.json = AsyncMock(
return_value={
"access_token": "tok_after_cooldown",
"expires_in": 86400,
}
)
mock_resp_200.__aenter__ = AsyncMock(return_value=mock_resp_200)
mock_resp_200.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp_403):
with pytest.raises(ConnectionError, match="Token refresh failed"):
await broker._ensure_token()
# Wait for cooldown to expire
await asyncio.sleep(0.15)
with patch("aiohttp.ClientSession.post", return_value=mock_resp_200):
token = await broker._ensure_token()
assert token == "tok_after_cooldown"
await broker.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Network Error Handling # Network Error Handling
@@ -147,6 +211,38 @@ class TestRateLimiter:
await broker._rate_limiter.acquire() await broker._rate_limiter.acquire()
await broker.close() await broker.close()
@pytest.mark.asyncio
async def test_send_order_acquires_rate_limiter_twice(self, settings):
"""send_order must acquire rate limiter for both hash key and order call."""
broker = KISBroker(settings)
broker._access_token = "tok"
broker._token_expires_at = asyncio.get_event_loop().time() + 3600
# Mock hash key response
mock_hash_resp = AsyncMock()
mock_hash_resp.status = 200
mock_hash_resp.json = AsyncMock(return_value={"HASH": "abc123"})
mock_hash_resp.__aenter__ = AsyncMock(return_value=mock_hash_resp)
mock_hash_resp.__aexit__ = AsyncMock(return_value=False)
# Mock order response
mock_order_resp = AsyncMock()
mock_order_resp.status = 200
mock_order_resp.json = AsyncMock(return_value={"rt_cd": "0"})
mock_order_resp.__aenter__ = AsyncMock(return_value=mock_order_resp)
mock_order_resp.__aexit__ = AsyncMock(return_value=False)
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash_resp, mock_order_resp]
):
with patch.object(
broker._rate_limiter, "acquire", new_callable=AsyncMock
) as mock_acquire:
await broker.send_order("005930", "BUY", 1, 50000)
assert mock_acquire.call_count == 2
await broker.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Hash Key Generation # Hash Key Generation
@@ -176,3 +272,27 @@ class TestHashKey:
assert len(hash_key) > 0 assert len(hash_key) > 0
await broker.close() await broker.close()
@pytest.mark.asyncio
async def test_hash_key_acquires_rate_limiter(self, settings):
"""_get_hash_key must go through the rate limiter to prevent burst."""
broker = KISBroker(settings)
broker._access_token = "tok"
broker._token_expires_at = asyncio.get_event_loop().time() + 3600
body = {"CANO": "12345678", "ACNT_PRDT_CD": "01"}
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"HASH": "abc123hash"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
with patch.object(
broker._rate_limiter, "acquire", new_callable=AsyncMock
) as mock_acquire:
await broker._get_hash_key(body)
mock_acquire.assert_called_once()
await broker.close()

View File

@@ -161,7 +161,7 @@ class TestContextAggregator:
self, aggregator: ContextAggregator, db_conn: sqlite3.Connection self, aggregator: ContextAggregator, db_conn: sqlite3.Connection
) -> None: ) -> None:
"""Test aggregating daily metrics from trades.""" """Test aggregating daily metrics from trades."""
date = "2026-02-04" date = datetime.now(UTC).date().isoformat()
# Create sample trades # Create sample trades
log_trade(db_conn, "005930", "BUY", 85, "Good signal", quantity=10, price=70000, pnl=500) log_trade(db_conn, "005930", "BUY", 85, "Good signal", quantity=10, price=70000, pnl=500)
@@ -175,36 +175,44 @@ class TestContextAggregator:
db_conn.commit() db_conn.commit()
# Aggregate # Aggregate
aggregator.aggregate_daily_from_trades(date) aggregator.aggregate_daily_from_trades(date, market="KR")
# Verify L6 contexts # Verify L6 contexts
store = aggregator.store store = aggregator.store
assert store.get_context(ContextLayer.L6_DAILY, date, "trade_count") == 3 assert store.get_context(ContextLayer.L6_DAILY, date, "trade_count_KR") == 3
assert store.get_context(ContextLayer.L6_DAILY, date, "buys") == 1 assert store.get_context(ContextLayer.L6_DAILY, date, "buys_KR") == 1
assert store.get_context(ContextLayer.L6_DAILY, date, "sells") == 1 assert store.get_context(ContextLayer.L6_DAILY, date, "sells_KR") == 1
assert store.get_context(ContextLayer.L6_DAILY, date, "holds") == 1 assert store.get_context(ContextLayer.L6_DAILY, date, "holds_KR") == 1
assert store.get_context(ContextLayer.L6_DAILY, date, "total_pnl") == 2000.0 assert store.get_context(ContextLayer.L6_DAILY, date, "total_pnl_KR") == 2000.0
assert store.get_context(ContextLayer.L6_DAILY, date, "unique_stocks") == 3 assert store.get_context(ContextLayer.L6_DAILY, date, "unique_stocks_KR") == 3
# 2 wins, 0 losses # 2 wins, 0 losses
assert store.get_context(ContextLayer.L6_DAILY, date, "win_rate") == 100.0 assert store.get_context(ContextLayer.L6_DAILY, date, "win_rate_KR") == 100.0
def test_aggregate_weekly_from_daily(self, aggregator: ContextAggregator) -> None: def test_aggregate_weekly_from_daily(self, aggregator: ContextAggregator) -> None:
"""Test aggregating weekly metrics from daily.""" """Test aggregating weekly metrics from daily."""
week = "2026-W06" week = "2026-W06"
# Set daily contexts # Set daily contexts
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "total_pnl", 100.0) aggregator.store.set_context(
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-03", "total_pnl", 200.0) ContextLayer.L6_DAILY, "2026-02-02", "total_pnl_KR", 100.0
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "avg_confidence", 80.0) )
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-03", "avg_confidence", 85.0) aggregator.store.set_context(
ContextLayer.L6_DAILY, "2026-02-03", "total_pnl_KR", 200.0
)
aggregator.store.set_context(
ContextLayer.L6_DAILY, "2026-02-02", "avg_confidence_KR", 80.0
)
aggregator.store.set_context(
ContextLayer.L6_DAILY, "2026-02-03", "avg_confidence_KR", 85.0
)
# Aggregate # Aggregate
aggregator.aggregate_weekly_from_daily(week) aggregator.aggregate_weekly_from_daily(week)
# Verify L5 contexts # Verify L5 contexts
store = aggregator.store store = aggregator.store
weekly_pnl = store.get_context(ContextLayer.L5_WEEKLY, week, "weekly_pnl") weekly_pnl = store.get_context(ContextLayer.L5_WEEKLY, week, "weekly_pnl_KR")
avg_conf = store.get_context(ContextLayer.L5_WEEKLY, week, "avg_confidence") avg_conf = store.get_context(ContextLayer.L5_WEEKLY, week, "avg_confidence_KR")
assert weekly_pnl == 300.0 assert weekly_pnl == 300.0
assert avg_conf == 82.5 assert avg_conf == 82.5
@@ -214,9 +222,15 @@ class TestContextAggregator:
month = "2026-02" month = "2026-02"
# Set weekly contexts # Set weekly contexts
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W05", "weekly_pnl", 100.0) aggregator.store.set_context(
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W06", "weekly_pnl", 200.0) ContextLayer.L5_WEEKLY, "2026-W05", "weekly_pnl_KR", 100.0
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W07", "weekly_pnl", 150.0) )
aggregator.store.set_context(
ContextLayer.L5_WEEKLY, "2026-W06", "weekly_pnl_KR", 200.0
)
aggregator.store.set_context(
ContextLayer.L5_WEEKLY, "2026-W07", "weekly_pnl_KR", 150.0
)
# Aggregate # Aggregate
aggregator.aggregate_monthly_from_weekly(month) aggregator.aggregate_monthly_from_weekly(month)
@@ -285,7 +299,7 @@ class TestContextAggregator:
self, aggregator: ContextAggregator, db_conn: sqlite3.Connection self, aggregator: ContextAggregator, db_conn: sqlite3.Connection
) -> None: ) -> None:
"""Test running all aggregations from L7 to L1.""" """Test running all aggregations from L7 to L1."""
date = "2026-02-04" date = datetime.now(UTC).date().isoformat()
# Create sample trades # Create sample trades
log_trade(db_conn, "005930", "BUY", 85, "Good signal", quantity=10, price=70000, pnl=1000) log_trade(db_conn, "005930", "BUY", 85, "Good signal", quantity=10, price=70000, pnl=1000)
@@ -299,10 +313,18 @@ class TestContextAggregator:
# Verify data exists in each layer # Verify data exists in each layer
store = aggregator.store store = aggregator.store
assert store.get_context(ContextLayer.L6_DAILY, date, "total_pnl") == 1000.0 assert store.get_context(ContextLayer.L6_DAILY, date, "total_pnl_KR") == 1000.0
current_week = datetime.now(UTC).strftime("%Y-W%V") from datetime import date as date_cls
assert store.get_context(ContextLayer.L5_WEEKLY, current_week, "weekly_pnl") is not None trade_date = date_cls.fromisoformat(date)
# Further layers depend on time alignment, just verify no crashes iso_year, iso_week, _ = trade_date.isocalendar()
trade_week = f"{iso_year}-W{iso_week:02d}"
assert store.get_context(ContextLayer.L5_WEEKLY, trade_week, "weekly_pnl_KR") is not None
trade_month = f"{trade_date.year}-{trade_date.month:02d}"
trade_quarter = f"{trade_date.year}-Q{(trade_date.month - 1) // 3 + 1}"
trade_year = str(trade_date.year)
assert store.get_context(ContextLayer.L4_MONTHLY, trade_month, "monthly_pnl") == 1000.0
assert store.get_context(ContextLayer.L3_QUARTERLY, trade_quarter, "quarterly_pnl") == 1000.0
assert store.get_context(ContextLayer.L2_ANNUAL, trade_year, "annual_pnl") == 1000.0
class TestLayerMetadata: class TestLayerMetadata:

View File

@@ -0,0 +1,104 @@
"""Tests for ContextScheduler."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from src.context.scheduler import ContextScheduler
@dataclass
class StubAggregator:
"""Stub aggregator that records calls."""
weekly_calls: list[str]
monthly_calls: list[str]
quarterly_calls: list[str]
annual_calls: list[str]
legacy_calls: int
def aggregate_weekly_from_daily(self, week: str) -> None:
self.weekly_calls.append(week)
def aggregate_monthly_from_weekly(self, month: str) -> None:
self.monthly_calls.append(month)
def aggregate_quarterly_from_monthly(self, quarter: str) -> None:
self.quarterly_calls.append(quarter)
def aggregate_annual_from_quarterly(self, year: str) -> None:
self.annual_calls.append(year)
def aggregate_legacy_from_annual(self) -> None:
self.legacy_calls += 1
@dataclass
class StubStore:
"""Stub store that records cleanup calls."""
cleanup_calls: int = 0
def cleanup_expired_contexts(self) -> None:
self.cleanup_calls += 1
def make_scheduler() -> tuple[ContextScheduler, StubAggregator, StubStore]:
aggregator = StubAggregator([], [], [], [], 0)
store = StubStore()
scheduler = ContextScheduler(aggregator=aggregator, store=store)
return scheduler, aggregator, store
def test_run_if_due_weekly() -> None:
scheduler, aggregator, store = make_scheduler()
now = datetime(2026, 2, 8, 10, 0, tzinfo=UTC) # Sunday
result = scheduler.run_if_due(now)
assert result.weekly is True
assert aggregator.weekly_calls == ["2026-W06"]
assert store.cleanup_calls == 1
def test_run_if_due_monthly() -> None:
scheduler, aggregator, _store = make_scheduler()
now = datetime(2026, 2, 28, 12, 0, tzinfo=UTC) # Last day of month
result = scheduler.run_if_due(now)
assert result.monthly is True
assert aggregator.monthly_calls == ["2026-02"]
def test_run_if_due_quarterly() -> None:
scheduler, aggregator, _store = make_scheduler()
now = datetime(2026, 3, 31, 12, 0, tzinfo=UTC) # Last day of Q1
result = scheduler.run_if_due(now)
assert result.quarterly is True
assert aggregator.quarterly_calls == ["2026-Q1"]
def test_run_if_due_annual_and_legacy() -> None:
scheduler, aggregator, _store = make_scheduler()
now = datetime(2026, 12, 31, 12, 0, tzinfo=UTC)
result = scheduler.run_if_due(now)
assert result.annual is True
assert result.legacy is True
assert aggregator.annual_calls == ["2026"]
assert aggregator.legacy_calls == 1
def test_cleanup_runs_once_per_day() -> None:
scheduler, _aggregator, store = make_scheduler()
now = datetime(2026, 2, 9, 9, 0, tzinfo=UTC)
scheduler.run_if_due(now)
scheduler.run_if_due(now)
assert store.cleanup_calls == 1

383
tests/test_daily_review.py Normal file
View File

@@ -0,0 +1,383 @@
"""Tests for DailyReviewer."""
from __future__ import annotations
import json
import sqlite3
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.context.layer import ContextLayer
from src.context.store import ContextStore
from src.db import init_db, log_trade
from src.evolution.daily_review import DailyReviewer
from src.evolution.scorecard import DailyScorecard
from src.logging.decision_logger import DecisionLogger
@pytest.fixture
def db_conn() -> sqlite3.Connection:
return init_db(":memory:")
@pytest.fixture
def context_store(db_conn: sqlite3.Connection) -> ContextStore:
return ContextStore(db_conn)
def _log_decision(
logger: DecisionLogger,
*,
stock_code: str,
market: str,
action: str,
confidence: int,
scenario_match: dict[str, float] | None = None,
) -> str:
return logger.log_decision(
stock_code=stock_code,
market=market,
exchange_code="KRX" if market == "KR" else "NASDAQ",
action=action,
confidence=confidence,
rationale="test",
context_snapshot={"scenario_match": scenario_match or {}},
input_data={"stock_code": stock_code},
)
def test_generate_scorecard_market_scoped(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
logger = DecisionLogger(db_conn)
buy_id = _log_decision(
logger,
stock_code="005930",
market="KR",
action="BUY",
confidence=90,
scenario_match={"rsi": 29.0},
)
_log_decision(
logger,
stock_code="000660",
market="KR",
action="HOLD",
confidence=60,
)
_log_decision(
logger,
stock_code="AAPL",
market="US",
action="SELL",
confidence=80,
scenario_match={"volume_ratio": 2.1},
)
log_trade(
db_conn,
"005930",
"BUY",
90,
"buy",
quantity=1,
price=100.0,
pnl=10.0,
market="KR",
exchange_code="KRX",
decision_id=buy_id,
)
log_trade(
db_conn,
"000660",
"HOLD",
60,
"hold",
quantity=0,
price=0.0,
pnl=0.0,
market="KR",
exchange_code="KRX",
)
log_trade(
db_conn,
"AAPL",
"SELL",
80,
"sell",
quantity=1,
price=200.0,
pnl=-5.0,
market="US",
exchange_code="NASDAQ",
)
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
assert scorecard.market == "KR"
assert scorecard.total_decisions == 2
assert scorecard.buys == 1
assert scorecard.sells == 0
assert scorecard.holds == 1
assert scorecard.total_pnl == 10.0
assert scorecard.win_rate == 100.0
assert scorecard.avg_confidence == 75.0
assert scorecard.scenario_match_rate == 50.0
def test_generate_scorecard_top_winners_and_losers(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
logger = DecisionLogger(db_conn)
for code, pnl in [("005930", 30.0), ("000660", 10.0), ("035420", -15.0), ("051910", -5.0)]:
decision_id = _log_decision(
logger,
stock_code=code,
market="KR",
action="BUY" if pnl >= 0 else "SELL",
confidence=80,
scenario_match={"rsi": 30.0},
)
log_trade(
db_conn,
code,
"BUY" if pnl >= 0 else "SELL",
80,
"test",
quantity=1,
price=100.0,
pnl=pnl,
market="KR",
exchange_code="KRX",
decision_id=decision_id,
)
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
assert scorecard.top_winners == ["005930", "000660"]
assert scorecard.top_losers == ["035420", "051910"]
def test_generate_scorecard_empty_day(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
assert scorecard.total_decisions == 0
assert scorecard.total_pnl == 0.0
assert scorecard.win_rate == 0.0
assert scorecard.avg_confidence == 0.0
assert scorecard.scenario_match_rate == 0.0
assert scorecard.top_winners == []
assert scorecard.top_losers == []
@pytest.mark.asyncio
async def test_generate_lessons_without_gemini_returns_empty(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store, gemini_client=None)
lessons = await reviewer.generate_lessons(
DailyScorecard(
date="2026-02-14",
market="KR",
total_decisions=1,
buys=1,
sells=0,
holds=0,
total_pnl=5.0,
win_rate=100.0,
avg_confidence=90.0,
scenario_match_rate=100.0,
)
)
assert lessons == []
@pytest.mark.asyncio
async def test_generate_lessons_parses_json_array(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
mock_gemini = MagicMock()
mock_gemini.decide = AsyncMock(
return_value=SimpleNamespace(rationale='["Cut losers earlier", "Reduce midday churn"]')
)
reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini)
lessons = await reviewer.generate_lessons(
DailyScorecard(
date="2026-02-14",
market="KR",
total_decisions=3,
buys=1,
sells=1,
holds=1,
total_pnl=-2.5,
win_rate=50.0,
avg_confidence=70.0,
scenario_match_rate=66.7,
)
)
assert lessons == ["Cut losers earlier", "Reduce midday churn"]
@pytest.mark.asyncio
async def test_generate_lessons_fallback_to_lines(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
mock_gemini = MagicMock()
mock_gemini.decide = AsyncMock(
return_value=SimpleNamespace(rationale="- Keep risk tighter\n- Increase selectivity")
)
reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini)
lessons = await reviewer.generate_lessons(
DailyScorecard(
date="2026-02-14",
market="US",
total_decisions=2,
buys=1,
sells=1,
holds=0,
total_pnl=1.0,
win_rate=50.0,
avg_confidence=75.0,
scenario_match_rate=100.0,
)
)
assert lessons == ["Keep risk tighter", "Increase selectivity"]
@pytest.mark.asyncio
async def test_generate_lessons_handles_gemini_error(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
mock_gemini = MagicMock()
mock_gemini.decide = AsyncMock(side_effect=RuntimeError("boom"))
reviewer = DailyReviewer(db_conn, context_store, gemini_client=mock_gemini)
lessons = await reviewer.generate_lessons(
DailyScorecard(
date="2026-02-14",
market="US",
total_decisions=0,
buys=0,
sells=0,
holds=0,
total_pnl=0.0,
win_rate=0.0,
avg_confidence=0.0,
scenario_match_rate=0.0,
)
)
assert lessons == []
def test_store_scorecard_in_context(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
scorecard = DailyScorecard(
date="2026-02-14",
market="KR",
total_decisions=5,
buys=2,
sells=1,
holds=2,
total_pnl=15.0,
win_rate=66.67,
avg_confidence=82.0,
scenario_match_rate=80.0,
lessons=["Keep position sizing stable"],
cross_market_note="US risk-off",
)
reviewer.store_scorecard_in_context(scorecard)
stored = context_store.get_context(
ContextLayer.L6_DAILY,
"2026-02-14",
"scorecard_KR",
)
assert stored is not None
assert stored["market"] == "KR"
assert stored["total_pnl"] == 15.0
assert stored["lessons"] == ["Keep position sizing stable"]
def test_store_scorecard_key_is_market_scoped(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
kr = DailyScorecard(
date="2026-02-14",
market="KR",
total_decisions=1,
buys=1,
sells=0,
holds=0,
total_pnl=1.0,
win_rate=100.0,
avg_confidence=90.0,
scenario_match_rate=100.0,
)
us = DailyScorecard(
date="2026-02-14",
market="US",
total_decisions=1,
buys=0,
sells=1,
holds=0,
total_pnl=-1.0,
win_rate=0.0,
avg_confidence=70.0,
scenario_match_rate=100.0,
)
reviewer.store_scorecard_in_context(kr)
reviewer.store_scorecard_in_context(us)
kr_ctx = context_store.get_context(ContextLayer.L6_DAILY, "2026-02-14", "scorecard_KR")
us_ctx = context_store.get_context(ContextLayer.L6_DAILY, "2026-02-14", "scorecard_US")
assert kr_ctx["market"] == "KR"
assert us_ctx["market"] == "US"
assert kr_ctx["total_pnl"] == 1.0
assert us_ctx["total_pnl"] == -1.0
def test_generate_scorecard_handles_invalid_context_snapshot(
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
db_conn.execute(
"""
INSERT INTO decision_logs (
decision_id, timestamp, stock_code, market, exchange_code,
action, confidence, rationale, context_snapshot, input_data
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"d1",
"2026-02-14T09:00:00+00:00",
"005930",
"KR",
"KRX",
"HOLD",
50,
"test",
"{invalid_json",
json.dumps({}),
),
)
db_conn.commit()
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
assert scorecard.total_decisions == 1
assert scorecard.scenario_match_rate == 0.0

View File

@@ -1,12 +1,62 @@
"""Tests for main trading loop telegram integration.""" """Tests for main trading loop integration."""
import asyncio from datetime import UTC, date, datetime
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest import pytest
from src.context.layer import ContextLayer
from src.context.scheduler import ScheduleResult
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
from src.main import safe_float, trading_cycle from src.db import init_db, log_trade
from src.evolution.scorecard import DailyScorecard
from src.logging.decision_logger import DecisionLogger
from src.main import _handle_market_close, _run_context_scheduler, safe_float, trading_cycle
from src.strategy.models import (
DayPlaybook,
ScenarioAction,
StockCondition,
StockScenario,
)
from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch
def _make_playbook(market: str = "KR") -> DayPlaybook:
"""Create a minimal empty playbook for testing."""
return DayPlaybook(date=date(2026, 2, 8), market=market)
def _make_buy_match(stock_code: str = "005930") -> ScenarioMatch:
"""Create a ScenarioMatch that returns BUY."""
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.BUY,
confidence=85,
rationale="Test buy",
)
def _make_hold_match(stock_code: str = "005930") -> ScenarioMatch:
"""Create a ScenarioMatch that returns HOLD."""
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.HOLD,
confidence=0,
rationale="No scenario conditions met",
)
def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
"""Create a ScenarioMatch that returns SELL."""
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
action=ScenarioAction.SELL,
confidence=90,
rationale="Test sell",
)
class TestSafeFloat: class TestSafeFloat:
@@ -81,15 +131,16 @@ class TestTradingCycleTelegramIntegration:
return broker return broker
@pytest.fixture @pytest.fixture
def mock_brain(self) -> MagicMock: def mock_scenario_engine(self) -> MagicMock:
"""Create mock brain that decides to buy.""" """Create mock scenario engine that returns BUY."""
brain = MagicMock() engine = MagicMock(spec=ScenarioEngine)
decision = MagicMock() engine.evaluate = MagicMock(return_value=_make_buy_match())
decision.action = "BUY" return engine
decision.confidence = 85
decision.rationale = "Test buy" @pytest.fixture
brain.decide = AsyncMock(return_value=decision) def mock_playbook(self) -> DayPlaybook:
return brain """Create a minimal day playbook."""
return _make_playbook()
@pytest.fixture @pytest.fixture
def mock_risk(self) -> MagicMock: def mock_risk(self) -> MagicMock:
@@ -134,6 +185,7 @@ class TestTradingCycleTelegramIntegration:
telegram.notify_trade_execution = AsyncMock() telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock() telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock() telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
return telegram return telegram
@pytest.fixture @pytest.fixture
@@ -151,7 +203,8 @@ class TestTradingCycleTelegramIntegration:
self, self,
mock_broker: MagicMock, mock_broker: MagicMock,
mock_overseas_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_brain: MagicMock, mock_scenario_engine: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -165,7 +218,8 @@ class TestTradingCycleTelegramIntegration:
await trading_cycle( await trading_cycle(
broker=mock_broker, broker=mock_broker,
overseas_broker=mock_overseas_broker, overseas_broker=mock_overseas_broker,
brain=mock_brain, scenario_engine=mock_scenario_engine,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -174,6 +228,7 @@ class TestTradingCycleTelegramIntegration:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_market, market=mock_market,
stock_code="005930", stock_code="005930",
scan_candidates={},
) )
# Verify notification was sent # Verify notification was sent
@@ -189,7 +244,8 @@ class TestTradingCycleTelegramIntegration:
self, self,
mock_broker: MagicMock, mock_broker: MagicMock,
mock_overseas_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_brain: MagicMock, mock_scenario_engine: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -207,7 +263,8 @@ class TestTradingCycleTelegramIntegration:
await trading_cycle( await trading_cycle(
broker=mock_broker, broker=mock_broker,
overseas_broker=mock_overseas_broker, overseas_broker=mock_overseas_broker,
brain=mock_brain, scenario_engine=mock_scenario_engine,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -216,6 +273,7 @@ class TestTradingCycleTelegramIntegration:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_market, market=mock_market,
stock_code="005930", stock_code="005930",
scan_candidates={},
) )
# Verify notification was attempted # Verify notification was attempted
@@ -226,7 +284,8 @@ class TestTradingCycleTelegramIntegration:
self, self,
mock_broker: MagicMock, mock_broker: MagicMock,
mock_overseas_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_brain: MagicMock, mock_scenario_engine: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -248,7 +307,8 @@ class TestTradingCycleTelegramIntegration:
await trading_cycle( await trading_cycle(
broker=mock_broker, broker=mock_broker,
overseas_broker=mock_overseas_broker, overseas_broker=mock_overseas_broker,
brain=mock_brain, scenario_engine=mock_scenario_engine,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -257,6 +317,7 @@ class TestTradingCycleTelegramIntegration:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_market, market=mock_market,
stock_code="005930", stock_code="005930",
scan_candidates={},
) )
# Verify notification was sent # Verify notification was sent
@@ -272,7 +333,8 @@ class TestTradingCycleTelegramIntegration:
self, self,
mock_broker: MagicMock, mock_broker: MagicMock,
mock_overseas_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_brain: MagicMock, mock_scenario_engine: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -296,7 +358,8 @@ class TestTradingCycleTelegramIntegration:
await trading_cycle( await trading_cycle(
broker=mock_broker, broker=mock_broker,
overseas_broker=mock_overseas_broker, overseas_broker=mock_overseas_broker,
brain=mock_brain, scenario_engine=mock_scenario_engine,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -305,6 +368,7 @@ class TestTradingCycleTelegramIntegration:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_market, market=mock_market,
stock_code="005930", stock_code="005930",
scan_candidates={},
) )
# Verify notification was attempted # Verify notification was attempted
@@ -315,7 +379,8 @@ class TestTradingCycleTelegramIntegration:
self, self,
mock_broker: MagicMock, mock_broker: MagicMock,
mock_overseas_broker: MagicMock, mock_overseas_broker: MagicMock,
mock_brain: MagicMock, mock_scenario_engine: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -325,18 +390,15 @@ class TestTradingCycleTelegramIntegration:
mock_market: MagicMock, mock_market: MagicMock,
) -> None: ) -> None:
"""Test no trade notification sent when decision is HOLD.""" """Test no trade notification sent when decision is HOLD."""
# Change brain decision to HOLD # Scenario engine returns HOLD
decision = MagicMock() mock_scenario_engine.evaluate = MagicMock(return_value=_make_hold_match())
decision.action = "HOLD"
decision.confidence = 50
decision.rationale = "Insufficient signal"
mock_brain.decide = AsyncMock(return_value=decision)
with patch("src.main.log_trade"): with patch("src.main.log_trade"):
await trading_cycle( await trading_cycle(
broker=mock_broker, broker=mock_broker,
overseas_broker=mock_overseas_broker, overseas_broker=mock_overseas_broker,
brain=mock_brain, scenario_engine=mock_scenario_engine,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -345,6 +407,7 @@ class TestTradingCycleTelegramIntegration:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_market, market=mock_market,
stock_code="005930", stock_code="005930",
scan_candidates={},
) )
# Verify no trade notification sent # Verify no trade notification sent
@@ -467,15 +530,16 @@ class TestOverseasBalanceParsing:
return market return market
@pytest.fixture @pytest.fixture
def mock_brain_hold(self) -> MagicMock: def mock_scenario_engine_hold(self) -> MagicMock:
"""Create mock brain that always holds.""" """Create mock scenario engine that always returns HOLD."""
brain = MagicMock() engine = MagicMock(spec=ScenarioEngine)
decision = MagicMock() engine.evaluate = MagicMock(return_value=_make_hold_match("AAPL"))
decision.action = "HOLD" return engine
decision.confidence = 50
decision.rationale = "Testing balance parsing" @pytest.fixture
brain.decide = AsyncMock(return_value=decision) def mock_playbook(self) -> DayPlaybook:
return brain """Create a minimal playbook."""
return _make_playbook("US")
@pytest.fixture @pytest.fixture
def mock_risk(self) -> MagicMock: def mock_risk(self) -> MagicMock:
@@ -512,14 +576,17 @@ class TestOverseasBalanceParsing:
@pytest.fixture @pytest.fixture
def mock_telegram(self) -> MagicMock: def mock_telegram(self) -> MagicMock:
"""Create mock telegram client.""" """Create mock telegram client."""
return MagicMock() telegram = MagicMock()
telegram.notify_scenario_matched = AsyncMock()
return telegram
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_overseas_balance_list_format( async def test_overseas_balance_list_format(
self, self,
mock_domestic_broker: MagicMock, mock_domestic_broker: MagicMock,
mock_overseas_broker_with_list: MagicMock, mock_overseas_broker_with_list: MagicMock,
mock_brain_hold: MagicMock, mock_scenario_engine_hold: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -534,7 +601,8 @@ class TestOverseasBalanceParsing:
await trading_cycle( await trading_cycle(
broker=mock_domestic_broker, broker=mock_domestic_broker,
overseas_broker=mock_overseas_broker_with_list, overseas_broker=mock_overseas_broker_with_list,
brain=mock_brain_hold, scenario_engine=mock_scenario_engine_hold,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -543,6 +611,7 @@ class TestOverseasBalanceParsing:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_overseas_market, market=mock_overseas_market,
stock_code="AAPL", stock_code="AAPL",
scan_candidates={},
) )
# Verify balance API was called # Verify balance API was called
@@ -553,7 +622,8 @@ class TestOverseasBalanceParsing:
self, self,
mock_domestic_broker: MagicMock, mock_domestic_broker: MagicMock,
mock_overseas_broker_with_dict: MagicMock, mock_overseas_broker_with_dict: MagicMock,
mock_brain_hold: MagicMock, mock_scenario_engine_hold: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -568,7 +638,8 @@ class TestOverseasBalanceParsing:
await trading_cycle( await trading_cycle(
broker=mock_domestic_broker, broker=mock_domestic_broker,
overseas_broker=mock_overseas_broker_with_dict, overseas_broker=mock_overseas_broker_with_dict,
brain=mock_brain_hold, scenario_engine=mock_scenario_engine_hold,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -577,6 +648,7 @@ class TestOverseasBalanceParsing:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_overseas_market, market=mock_overseas_market,
stock_code="AAPL", stock_code="AAPL",
scan_candidates={},
) )
# Verify balance API was called # Verify balance API was called
@@ -587,7 +659,8 @@ class TestOverseasBalanceParsing:
self, self,
mock_domestic_broker: MagicMock, mock_domestic_broker: MagicMock,
mock_overseas_broker_with_empty: MagicMock, mock_overseas_broker_with_empty: MagicMock,
mock_brain_hold: MagicMock, mock_scenario_engine_hold: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -602,7 +675,8 @@ class TestOverseasBalanceParsing:
await trading_cycle( await trading_cycle(
broker=mock_domestic_broker, broker=mock_domestic_broker,
overseas_broker=mock_overseas_broker_with_empty, overseas_broker=mock_overseas_broker_with_empty,
brain=mock_brain_hold, scenario_engine=mock_scenario_engine_hold,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -611,6 +685,7 @@ class TestOverseasBalanceParsing:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_overseas_market, market=mock_overseas_market,
stock_code="AAPL", stock_code="AAPL",
scan_candidates={},
) )
# Verify balance API was called # Verify balance API was called
@@ -621,7 +696,8 @@ class TestOverseasBalanceParsing:
self, self,
mock_domestic_broker: MagicMock, mock_domestic_broker: MagicMock,
mock_overseas_broker_with_empty_price: MagicMock, mock_overseas_broker_with_empty_price: MagicMock,
mock_brain_hold: MagicMock, mock_scenario_engine_hold: MagicMock,
mock_playbook: DayPlaybook,
mock_risk: MagicMock, mock_risk: MagicMock,
mock_db: MagicMock, mock_db: MagicMock,
mock_decision_logger: MagicMock, mock_decision_logger: MagicMock,
@@ -636,7 +712,8 @@ class TestOverseasBalanceParsing:
await trading_cycle( await trading_cycle(
broker=mock_domestic_broker, broker=mock_domestic_broker,
overseas_broker=mock_overseas_broker_with_empty_price, overseas_broker=mock_overseas_broker_with_empty_price,
brain=mock_brain_hold, scenario_engine=mock_scenario_engine_hold,
playbook=mock_playbook,
risk=mock_risk, risk=mock_risk,
db_conn=mock_db, db_conn=mock_db,
decision_logger=mock_decision_logger, decision_logger=mock_decision_logger,
@@ -645,7 +722,587 @@ class TestOverseasBalanceParsing:
telegram=mock_telegram, telegram=mock_telegram,
market=mock_overseas_market, market=mock_overseas_market,
stock_code="AAPL", stock_code="AAPL",
scan_candidates={},
) )
# Verify price API was called # Verify price API was called
mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once() mock_overseas_broker_with_empty_price.get_overseas_price.assert_called_once()
class TestScenarioEngineIntegration:
"""Test scenario engine integration in trading_cycle."""
@pytest.fixture
def mock_broker(self) -> MagicMock:
"""Create mock broker with standard domestic data."""
broker = MagicMock()
broker.get_orderbook = AsyncMock(
return_value={
"output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100"}
}
)
broker.get_balance = AsyncMock(
return_value={
"output2": [
{
"tot_evlu_amt": "10000000",
"dnca_tot_amt": "5000000",
"pchs_amt_smtl_amt": "9500000",
}
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
return broker
@pytest.fixture
def mock_market(self) -> MagicMock:
"""Create mock KR market."""
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
return market
@pytest.fixture
def mock_telegram(self) -> MagicMock:
"""Create mock telegram with all notification methods."""
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
return telegram
@pytest.mark.asyncio
async def test_scenario_engine_called_with_enriched_market_data(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test scenario engine receives market_data enriched with scanner metrics."""
from src.analysis.smart_scanner import ScanCandidate
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
playbook = _make_playbook()
candidate = ScanCandidate(
stock_code="005930", name="Samsung", price=50000,
volume=1000000, volume_ratio=3.5, rsi=25.0,
signal="oversold", score=85.0,
)
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={"KR": {"005930": candidate}},
)
# Verify evaluate was called
engine.evaluate.assert_called_once()
call_args = engine.evaluate.call_args
market_data = call_args[0][2] # 3rd positional arg
portfolio_data = call_args[0][3] # 4th positional arg
# Scanner data should be enriched into market_data
assert market_data["rsi"] == 25.0
assert market_data["volume_ratio"] == 3.5
assert market_data["current_price"] == 50000.0
# Portfolio data should include pnl
assert "portfolio_pnl_pct" in portfolio_data
assert "total_cash" in portfolio_data
@pytest.mark.asyncio
async def test_trading_cycle_sets_l7_context_keys(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test L7 context is written with market-scoped keys."""
from src.analysis.smart_scanner import ScanCandidate
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
playbook = _make_playbook()
context_store = MagicMock(get_latest_timeframe=MagicMock(return_value=None))
candidate = ScanCandidate(
stock_code="005930", name="Samsung", price=50000,
volume=1000000, volume_ratio=3.5, rsi=25.0,
signal="oversold", score=85.0,
)
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=context_store,
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={"KR": {"005930": candidate}},
)
context_store.set_context.assert_any_call(
ContextLayer.L7_REALTIME,
ANY,
"volatility_KR_005930",
{"momentum_score": 50.0, "volume_surge": 1.0, "price_change_1m": 0.0},
)
context_store.set_context.assert_any_call(
ContextLayer.L7_REALTIME,
ANY,
"price_KR_005930",
{"current_price": 50000.0},
)
context_store.set_context.assert_any_call(
ContextLayer.L7_REALTIME,
ANY,
"rsi_KR_005930",
{"rsi": 25.0},
)
context_store.set_context.assert_any_call(
ContextLayer.L7_REALTIME,
ANY,
"volume_ratio_KR_005930",
{"volume_ratio": 3.5},
)
@pytest.mark.asyncio
async def test_scan_candidates_market_scoped(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test scan_candidates uses market-scoped lookup, ignoring other markets."""
from src.analysis.smart_scanner import ScanCandidate
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
# Candidate stored under US market — should NOT be found for KR market
us_candidate = ScanCandidate(
stock_code="005930", name="Overlap", price=100,
volume=500000, volume_ratio=5.0, rsi=15.0,
signal="oversold", score=90.0,
)
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market, # KR market
stock_code="005930",
scan_candidates={"US": {"005930": us_candidate}}, # Wrong market
)
# Should NOT have rsi/volume_ratio because candidate is under US, not KR
market_data = engine.evaluate.call_args[0][2]
assert "rsi" not in market_data
assert "volume_ratio" not in market_data
@pytest.mark.asyncio
async def test_scenario_engine_called_without_scanner_data(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test scenario engine works when stock has no scan candidate."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
playbook = _make_playbook()
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=playbook,
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={}, # No scanner data
)
# Should still work, just without rsi/volume_ratio
engine.evaluate.assert_called_once()
market_data = engine.evaluate.call_args[0][2]
assert "rsi" not in market_data
assert "volume_ratio" not in market_data
assert market_data["current_price"] == 50000.0
@pytest.mark.asyncio
async def test_scenario_matched_notification_sent(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test telegram notification sent when a scenario matches."""
# Create a match with matched_scenario (not None)
scenario = StockScenario(
condition=StockCondition(rsi_below=30),
action=ScenarioAction.BUY,
confidence=88,
rationale="RSI oversold bounce",
)
match = ScenarioMatch(
stock_code="005930",
matched_scenario=scenario,
action=ScenarioAction.BUY,
confidence=88,
rationale="RSI oversold bounce",
match_details={"rsi": 25.0},
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=match)
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
# Scenario matched notification should be sent
mock_telegram.notify_scenario_matched.assert_called_once()
call_kwargs = mock_telegram.notify_scenario_matched.call_args.kwargs
assert call_kwargs["stock_code"] == "005930"
assert call_kwargs["action"] == "BUY"
assert "rsi=25.0" in call_kwargs["condition_summary"]
@pytest.mark.asyncio
async def test_no_scenario_matched_notification_on_default_hold(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test no scenario notification when default HOLD is returned."""
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_hold_match())
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
# No scenario matched notification for default HOLD
mock_telegram.notify_scenario_matched.assert_not_called()
@pytest.mark.asyncio
async def test_decision_logger_receives_scenario_match_details(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test decision logger context includes scenario match details."""
match = ScenarioMatch(
stock_code="005930",
matched_scenario=None,
action=ScenarioAction.HOLD,
confidence=0,
rationale="No match",
match_details={"rsi": 45.0, "volume_ratio": 1.2},
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=match)
decision_logger = MagicMock()
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=decision_logger,
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
decision_logger.log_decision.assert_called_once()
call_kwargs = decision_logger.log_decision.call_args.kwargs
assert "scenario_match" in call_kwargs["context_snapshot"]
assert call_kwargs["context_snapshot"]["scenario_match"]["rsi"] == 45.0
@pytest.mark.asyncio
async def test_reduce_all_does_not_execute_order(
self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock,
) -> None:
"""Test REDUCE_ALL action does not trigger order execution."""
match = ScenarioMatch(
stock_code="005930",
matched_scenario=None,
action=ScenarioAction.REDUCE_ALL,
confidence=100,
rationale="Global rule: portfolio loss > 2%",
)
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=match)
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
scenario_engine=engine,
playbook=_make_playbook(),
risk=MagicMock(),
db_conn=MagicMock(),
decision_logger=MagicMock(),
context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=mock_telegram,
market=mock_market,
stock_code="005930",
scan_candidates={},
)
# REDUCE_ALL is not BUY or SELL — no order sent
mock_broker.send_order.assert_not_called()
mock_telegram.notify_trade_execution.assert_not_called()
@pytest.mark.asyncio
async def test_sell_updates_original_buy_decision_outcome() -> None:
"""SELL should update the original BUY decision outcome in decision_logs."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
buy_decision_id = decision_logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=85,
rationale="Initial buy",
context_snapshot={},
input_data={},
)
log_trade(
conn=db_conn,
stock_code="005930",
action="BUY",
confidence=85,
rationale="Initial buy",
quantity=1,
price=100.0,
pnl=0.0,
market="KR",
exchange_code="KRX",
decision_id=buy_decision_id,
)
broker = MagicMock()
broker.get_orderbook = AsyncMock(
return_value={"output1": {"stck_prpr": "120", "frgn_ntby_qty": "0"}}
)
broker.get_balance = AsyncMock(
return_value={
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "10000",
"pchs_amt_smtl_amt": "90000",
}
]
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
overseas_broker = MagicMock()
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_sell_match())
risk = MagicMock()
context_store = MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
)
criticality_assessor = MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
)
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
await trading_cycle(
broker=broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(),
risk=risk,
db_conn=db_conn,
decision_logger=decision_logger,
context_store=context_store,
criticality_assessor=criticality_assessor,
telegram=telegram,
market=market,
stock_code="005930",
scan_candidates={},
)
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
assert updated_buy is not None
assert updated_buy.outcome_pnl == 20.0
assert updated_buy.outcome_accuracy == 1
@pytest.mark.asyncio
async def test_handle_market_close_runs_daily_review_flow() -> None:
"""Market close should aggregate, create scorecard, lessons, and notify."""
telegram = MagicMock()
telegram.notify_market_close = AsyncMock()
telegram.send_message = AsyncMock()
context_aggregator = MagicMock()
reviewer = MagicMock()
reviewer.generate_scorecard.return_value = DailyScorecard(
date="2026-02-14",
market="KR",
total_decisions=3,
buys=1,
sells=1,
holds=1,
total_pnl=12.5,
win_rate=50.0,
avg_confidence=75.0,
scenario_match_rate=66.7,
)
reviewer.generate_lessons = AsyncMock(return_value=["Cut losers faster"])
await _handle_market_close(
market_code="KR",
market_name="Korea",
market_timezone=UTC,
telegram=telegram,
context_aggregator=context_aggregator,
daily_reviewer=reviewer,
)
telegram.notify_market_close.assert_called_once_with("Korea", 0.0)
context_aggregator.aggregate_daily_from_trades.assert_called_once()
reviewer.generate_scorecard.assert_called_once()
assert reviewer.store_scorecard_in_context.call_count == 2
reviewer.generate_lessons.assert_called_once()
telegram.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_handle_market_close_without_lessons_stores_once() -> None:
"""If no lessons are generated, scorecard should be stored once."""
telegram = MagicMock()
telegram.notify_market_close = AsyncMock()
telegram.send_message = AsyncMock()
context_aggregator = MagicMock()
reviewer = MagicMock()
reviewer.generate_scorecard.return_value = DailyScorecard(
date="2026-02-14",
market="US",
total_decisions=1,
buys=0,
sells=1,
holds=0,
total_pnl=-3.0,
win_rate=0.0,
avg_confidence=65.0,
scenario_match_rate=100.0,
)
reviewer.generate_lessons = AsyncMock(return_value=[])
await _handle_market_close(
market_code="US",
market_name="United States",
market_timezone=UTC,
telegram=telegram,
context_aggregator=context_aggregator,
daily_reviewer=reviewer,
)
assert reviewer.store_scorecard_in_context.call_count == 1
def test_run_context_scheduler_invokes_scheduler() -> None:
"""Scheduler helper should call run_if_due with provided datetime."""
scheduler = MagicMock()
scheduler.run_if_due = MagicMock(return_value=ScheduleResult(cleanup=True))
_run_context_scheduler(scheduler, now=datetime(2026, 2, 14, tzinfo=UTC))
scheduler.run_if_due.assert_called_once()

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,659 @@
"""Tests for PreMarketPlanner — Gemini prompt builder + response parser."""
from __future__ import annotations
import json
from datetime import date
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.analysis.smart_scanner import ScanCandidate
from src.brain.context_selector import DecisionType
from src.brain.gemini_client import TradeDecision
from src.config import Settings
from src.context.store import ContextLayer
from src.strategy.models import (
CrossMarketContext,
DayPlaybook,
MarketOutlook,
ScenarioAction,
)
from src.strategy.pre_market_planner import PreMarketPlanner
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _candidate(
code: str = "005930",
name: str = "Samsung",
price: float = 71000,
rsi: float = 28.5,
volume_ratio: float = 3.2,
signal: str = "oversold",
score: float = 82.0,
) -> ScanCandidate:
return ScanCandidate(
stock_code=code,
name=name,
price=price,
volume=1_500_000,
volume_ratio=volume_ratio,
rsi=rsi,
signal=signal,
score=score,
)
def _gemini_response_json(
outlook: str = "neutral_to_bullish",
stocks: list[dict] | None = None,
global_rules: list[dict] | None = None,
) -> str:
"""Build a valid Gemini JSON response."""
if stocks is None:
stocks = [
{
"stock_code": "005930",
"scenarios": [
{
"condition": {"rsi_below": 30, "volume_ratio_above": 2.5},
"action": "BUY",
"confidence": 85,
"allocation_pct": 15.0,
"stop_loss_pct": -2.0,
"take_profit_pct": 4.0,
"rationale": "Oversold bounce with high volume",
}
],
}
]
if global_rules is None:
global_rules = [
{
"condition": "portfolio_pnl_pct < -2.0",
"action": "REDUCE_ALL",
"rationale": "Near circuit breaker",
}
]
return json.dumps(
{"market_outlook": outlook, "global_rules": global_rules, "stocks": stocks}
)
def _make_planner(
gemini_response: str = "",
token_count: int = 200,
context_data: dict | None = None,
scorecard_data: dict | None = None,
scorecard_map: dict[tuple[str, str, str], dict | None] | None = None,
) -> PreMarketPlanner:
"""Create a PreMarketPlanner with mocked dependencies."""
if not gemini_response:
gemini_response = _gemini_response_json()
# Mock GeminiClient
gemini = AsyncMock()
gemini.decide = AsyncMock(
return_value=TradeDecision(
action="HOLD",
confidence=0,
rationale=gemini_response,
token_count=token_count,
)
)
# Mock ContextStore
store = MagicMock()
if scorecard_map is not None:
store.get_context = MagicMock(
side_effect=lambda layer, timeframe, key: scorecard_map.get(
(layer.value if hasattr(layer, "value") else layer, timeframe, key)
)
)
else:
store.get_context = MagicMock(return_value=scorecard_data)
# Mock ContextSelector
selector = MagicMock()
selector.select_layers = MagicMock(
return_value=[ContextLayer.L7_REALTIME, ContextLayer.L6_DAILY]
)
selector.get_context_data = MagicMock(return_value=context_data or {})
settings = Settings(
KIS_APP_KEY="test",
KIS_APP_SECRET="test",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test",
)
return PreMarketPlanner(gemini, store, selector, settings)
# ---------------------------------------------------------------------------
# generate_playbook
# ---------------------------------------------------------------------------
class TestGeneratePlaybook:
@pytest.mark.asyncio
async def test_basic_generation(self) -> None:
planner = _make_planner()
candidates = [_candidate()]
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
assert isinstance(pb, DayPlaybook)
assert pb.market == "KR"
assert pb.stock_count == 1
assert pb.scenario_count == 1
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BULLISH
assert pb.token_count == 200
@pytest.mark.asyncio
async def test_empty_candidates_returns_empty_playbook(self) -> None:
planner = _make_planner()
pb = await planner.generate_playbook("KR", [], today=date(2026, 2, 8))
assert pb.stock_count == 0
assert pb.scenario_count == 0
assert pb.market_outlook == MarketOutlook.NEUTRAL
@pytest.mark.asyncio
async def test_gemini_failure_returns_defensive(self) -> None:
planner = _make_planner()
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout"))
candidates = [_candidate()]
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
assert pb.default_action == ScenarioAction.HOLD
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH
assert pb.stock_count == 1
# Defensive playbook has stop-loss scenarios
assert pb.stock_playbooks[0].scenarios[0].action == ScenarioAction.SELL
@pytest.mark.asyncio
async def test_gemini_failure_empty_when_defensive_disabled(self) -> None:
planner = _make_planner()
planner._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE = False
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("fail"))
candidates = [_candidate()]
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
assert pb.stock_count == 0
@pytest.mark.asyncio
async def test_multiple_candidates(self) -> None:
stocks = [
{
"stock_code": "005930",
"scenarios": [
{
"condition": {"rsi_below": 30},
"action": "BUY",
"confidence": 85,
"rationale": "Oversold",
}
],
},
{
"stock_code": "AAPL",
"scenarios": [
{
"condition": {"rsi_above": 75},
"action": "SELL",
"confidence": 80,
"rationale": "Overbought",
}
],
},
]
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
candidates = [_candidate(), _candidate(code="AAPL", name="Apple")]
pb = await planner.generate_playbook("US", candidates, today=date(2026, 2, 8))
assert pb.stock_count == 2
codes = [sp.stock_code for sp in pb.stock_playbooks]
assert "005930" in codes
assert "AAPL" in codes
@pytest.mark.asyncio
async def test_unknown_stock_in_response_skipped(self) -> None:
stocks = [
{
"stock_code": "005930",
"scenarios": [
{
"condition": {"rsi_below": 30},
"action": "BUY",
"confidence": 85,
"rationale": "ok",
}
],
},
{
"stock_code": "UNKNOWN",
"scenarios": [
{
"condition": {"rsi_below": 20},
"action": "BUY",
"confidence": 90,
"rationale": "bad",
}
],
},
]
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
candidates = [_candidate()] # Only 005930
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
assert pb.stock_count == 1
assert pb.stock_playbooks[0].stock_code == "005930"
@pytest.mark.asyncio
async def test_global_rules_parsed(self) -> None:
planner = _make_planner()
candidates = [_candidate()]
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
assert len(pb.global_rules) == 1
assert pb.global_rules[0].action == ScenarioAction.REDUCE_ALL
@pytest.mark.asyncio
async def test_token_count_from_decision(self) -> None:
planner = _make_planner(token_count=450)
candidates = [_candidate()]
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
assert pb.token_count == 450
@pytest.mark.asyncio
async def test_generate_playbook_uses_strategic_context_selector(self) -> None:
planner = _make_planner()
candidates = [_candidate()]
await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
planner._context_selector.select_layers.assert_called_once_with(
decision_type=DecisionType.STRATEGIC,
include_realtime=True,
)
planner._context_selector.get_context_data.assert_called_once()
@pytest.mark.asyncio
async def test_generate_playbook_injects_self_and_cross_scorecards(self) -> None:
scorecard_map = {
(ContextLayer.L6_DAILY.value, "2026-02-07", "scorecard_KR"): {
"total_pnl": -1.0,
"win_rate": 40,
"lessons": ["Tighten entries"],
},
(ContextLayer.L6_DAILY.value, "2026-02-07", "scorecard_US"): {
"total_pnl": 1.5,
"win_rate": 62,
"index_change_pct": 0.9,
"lessons": ["Follow momentum"],
},
}
planner = _make_planner(scorecard_map=scorecard_map)
await planner.generate_playbook("KR", [_candidate()], today=date(2026, 2, 8))
call_market_data = planner._gemini.decide.call_args.args[0]
prompt = call_market_data["prompt_override"]
assert "My Market Previous Day (KR)" in prompt
assert "Other Market (US)" in prompt
# ---------------------------------------------------------------------------
# _parse_response
# ---------------------------------------------------------------------------
class TestParseResponse:
def test_parse_full_response(self) -> None:
planner = _make_planner()
response = _gemini_response_json(outlook="bearish")
candidates = [_candidate()]
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
assert pb.market_outlook == MarketOutlook.BEARISH
assert pb.stock_count == 1
assert pb.stock_playbooks[0].scenarios[0].confidence == 85
def test_parse_with_markdown_fences(self) -> None:
planner = _make_planner()
response = f"```json\n{_gemini_response_json()}\n```"
candidates = [_candidate()]
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
assert pb.stock_count == 1
def test_parse_unknown_outlook_defaults_neutral(self) -> None:
planner = _make_planner()
response = _gemini_response_json(outlook="super_bullish")
candidates = [_candidate()]
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
assert pb.market_outlook == MarketOutlook.NEUTRAL
def test_parse_scenario_with_all_condition_fields(self) -> None:
planner = _make_planner()
stocks = [
{
"stock_code": "005930",
"scenarios": [
{
"condition": {
"rsi_below": 25,
"volume_ratio_above": 3.0,
"price_change_pct_below": -2.0,
},
"action": "BUY",
"confidence": 92,
"allocation_pct": 20.0,
"stop_loss_pct": -3.0,
"take_profit_pct": 5.0,
"rationale": "Multi-condition entry",
}
],
}
]
response = _gemini_response_json(stocks=stocks)
candidates = [_candidate()]
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
sc = pb.stock_playbooks[0].scenarios[0]
assert sc.condition.rsi_below == 25
assert sc.condition.volume_ratio_above == 3.0
assert sc.condition.price_change_pct_below == -2.0
assert sc.allocation_pct == 20.0
assert sc.stop_loss_pct == -3.0
assert sc.take_profit_pct == 5.0
def test_parse_empty_condition_scenario_skipped(self) -> None:
planner = _make_planner()
stocks = [
{
"stock_code": "005930",
"scenarios": [
{
"condition": {},
"action": "BUY",
"confidence": 85,
"rationale": "No conditions",
},
{
"condition": {"rsi_below": 30},
"action": "BUY",
"confidence": 80,
"rationale": "Valid",
},
],
}
]
response = _gemini_response_json(stocks=stocks)
candidates = [_candidate()]
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
# Empty condition scenario skipped, valid one kept
assert pb.stock_count == 1
assert pb.stock_playbooks[0].scenarios[0].confidence == 80
def test_parse_max_scenarios_enforced(self) -> None:
planner = _make_planner()
# Settings default MAX_SCENARIOS_PER_STOCK = 5
scenarios = [
{
"condition": {"rsi_below": 20 + i},
"action": "BUY",
"confidence": 80 + i,
"rationale": f"Scenario {i}",
}
for i in range(8) # 8 scenarios, should be capped to 5
]
stocks = [{"stock_code": "005930", "scenarios": scenarios}]
response = _gemini_response_json(stocks=stocks)
candidates = [_candidate()]
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
assert len(pb.stock_playbooks[0].scenarios) == 5
def test_parse_invalid_json_raises(self) -> None:
planner = _make_planner()
candidates = [_candidate()]
with pytest.raises(json.JSONDecodeError):
planner._parse_response("not json at all", date(2026, 2, 8), "KR", candidates, None)
def test_parse_cross_market_preserved(self) -> None:
planner = _make_planner()
response = _gemini_response_json()
candidates = [_candidate()]
cross = CrossMarketContext(market="US", date="2026-02-07", total_pnl=1.5, win_rate=60)
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, cross)
assert pb.cross_market is not None
assert pb.cross_market.market == "US"
assert pb.cross_market.total_pnl == 1.5
# ---------------------------------------------------------------------------
# build_cross_market_context
# ---------------------------------------------------------------------------
class TestBuildCrossMarketContext:
def test_kr_reads_us_scorecard(self) -> None:
scorecard = {
"total_pnl": 2.5,
"win_rate": 65,
"index_change_pct": 0.8,
"lessons": ["Stay patient"],
}
planner = _make_planner(scorecard_data=scorecard)
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
assert ctx is not None
assert ctx.market == "US"
assert ctx.total_pnl == 2.5
assert ctx.win_rate == 65
assert "Stay patient" in ctx.lessons
# Verify it queried scorecard_US
planner._context_store.get_context.assert_called_once_with(
ContextLayer.L6_DAILY, "2026-02-07", "scorecard_US"
)
assert ctx.date == "2026-02-07"
def test_us_reads_kr_scorecard(self) -> None:
scorecard = {"total_pnl": -1.0, "win_rate": 40, "index_change_pct": -0.5}
planner = _make_planner(scorecard_data=scorecard)
ctx = planner.build_cross_market_context("US", today=date(2026, 2, 8))
assert ctx is not None
assert ctx.market == "KR"
assert ctx.total_pnl == -1.0
planner._context_store.get_context.assert_called_once_with(
ContextLayer.L6_DAILY, "2026-02-08", "scorecard_KR"
)
def test_no_scorecard_returns_none(self) -> None:
planner = _make_planner(scorecard_data=None)
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
assert ctx is None
def test_invalid_scorecard_returns_none(self) -> None:
planner = _make_planner(scorecard_data="not a dict and not json")
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
assert ctx is None
# ---------------------------------------------------------------------------
# build_self_market_scorecard
# ---------------------------------------------------------------------------
class TestBuildSelfMarketScorecard:
def test_reads_previous_day_scorecard(self) -> None:
scorecard = {"total_pnl": -1.2, "win_rate": 45, "lessons": ["Reduce overtrading"]}
planner = _make_planner(scorecard_data=scorecard)
data = planner.build_self_market_scorecard("KR", today=date(2026, 2, 8))
assert data is not None
assert data["date"] == "2026-02-07"
assert data["total_pnl"] == -1.2
assert data["win_rate"] == 45
assert "Reduce overtrading" in data["lessons"]
planner._context_store.get_context.assert_called_once_with(
ContextLayer.L6_DAILY, "2026-02-07", "scorecard_KR"
)
def test_missing_scorecard_returns_none(self) -> None:
planner = _make_planner(scorecard_data=None)
assert planner.build_self_market_scorecard("US", today=date(2026, 2, 8)) is None
# ---------------------------------------------------------------------------
# _build_prompt
# ---------------------------------------------------------------------------
class TestBuildPrompt:
def test_prompt_contains_candidates(self) -> None:
planner = _make_planner()
candidates = [_candidate(code="005930", name="Samsung")]
prompt = planner._build_prompt("KR", candidates, {}, None, None)
assert "005930" in prompt
assert "Samsung" in prompt
assert "RSI=" in prompt
assert "volume_ratio=" in prompt
def test_prompt_contains_cross_market(self) -> None:
planner = _make_planner()
cross = CrossMarketContext(
market="US", date="2026-02-07", total_pnl=1.5,
win_rate=60, index_change_pct=0.8, lessons=["Cut losses early"],
)
prompt = planner._build_prompt("KR", [_candidate()], {}, None, cross)
assert "Other Market (US)" in prompt
assert "+1.50%" in prompt
assert "Cut losses early" in prompt
def test_prompt_contains_context_data(self) -> None:
planner = _make_planner()
context = {"L6_DAILY": {"win_rate": 0.65, "total_pnl": 2.5}}
prompt = planner._build_prompt("KR", [_candidate()], context, None, None)
assert "Strategic Context" in prompt
assert "L6_DAILY" in prompt
assert "win_rate" in prompt
def test_prompt_contains_max_scenarios(self) -> None:
planner = _make_planner()
prompt = planner._build_prompt("KR", [_candidate()], {}, None, None)
assert f"Max {planner._settings.MAX_SCENARIOS_PER_STOCK} scenarios" in prompt
def test_prompt_market_name(self) -> None:
planner = _make_planner()
prompt = planner._build_prompt("US", [_candidate()], {}, None, None)
assert "US market" in prompt
def test_prompt_contains_self_market_scorecard(self) -> None:
planner = _make_planner()
self_scorecard = {
"date": "2026-02-07",
"total_pnl": -0.8,
"win_rate": 45.0,
"lessons": ["Avoid midday entries"],
}
prompt = planner._build_prompt("KR", [_candidate()], {}, self_scorecard, None)
assert "My Market Previous Day (KR)" in prompt
assert "2026-02-07" in prompt
assert "-0.80%" in prompt
assert "Avoid midday entries" in prompt
# ---------------------------------------------------------------------------
# _extract_json
# ---------------------------------------------------------------------------
class TestExtractJson:
def test_plain_json(self) -> None:
assert PreMarketPlanner._extract_json('{"a": 1}') == '{"a": 1}'
def test_with_json_fence(self) -> None:
text = '```json\n{"a": 1}\n```'
assert PreMarketPlanner._extract_json(text) == '{"a": 1}'
def test_with_plain_fence(self) -> None:
text = '```\n{"a": 1}\n```'
assert PreMarketPlanner._extract_json(text) == '{"a": 1}'
def test_with_whitespace(self) -> None:
text = ' \n {"a": 1} \n '
assert PreMarketPlanner._extract_json(text) == '{"a": 1}'
# ---------------------------------------------------------------------------
# Defensive playbook
# ---------------------------------------------------------------------------
class TestDefensivePlaybook:
def test_defensive_has_stop_loss(self) -> None:
candidates = [_candidate(code="005930"), _candidate(code="AAPL")]
pb = PreMarketPlanner._defensive_playbook(date(2026, 2, 8), "KR", candidates)
assert pb.default_action == ScenarioAction.HOLD
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH
assert pb.stock_count == 2
for sp in pb.stock_playbooks:
assert sp.scenarios[0].action == ScenarioAction.SELL
assert sp.scenarios[0].stop_loss_pct == -3.0
def test_defensive_has_global_rule(self) -> None:
pb = PreMarketPlanner._defensive_playbook(date(2026, 2, 8), "KR", [_candidate()])
assert len(pb.global_rules) == 1
assert pb.global_rules[0].action == ScenarioAction.REDUCE_ALL
def test_empty_playbook(self) -> None:
pb = PreMarketPlanner._empty_playbook(date(2026, 2, 8), "US")
assert pb.stock_count == 0
assert pb.market == "US"
assert pb.market_outlook == MarketOutlook.NEUTRAL

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)

81
tests/test_scorecard.py Normal file
View File

@@ -0,0 +1,81 @@
"""Tests for DailyScorecard model."""
from __future__ import annotations
from src.evolution.scorecard import DailyScorecard
def test_scorecard_initialization() -> None:
scorecard = DailyScorecard(
date="2026-02-08",
market="KR",
total_decisions=10,
buys=3,
sells=2,
holds=5,
total_pnl=1234.5,
win_rate=60.0,
avg_confidence=78.5,
scenario_match_rate=70.0,
top_winners=["005930", "000660"],
top_losers=["035420"],
lessons=["Avoid chasing breakouts"],
cross_market_note="US volatility spillover",
)
assert scorecard.market == "KR"
assert scorecard.total_decisions == 10
assert scorecard.total_pnl == 1234.5
assert scorecard.top_winners == ["005930", "000660"]
assert scorecard.lessons == ["Avoid chasing breakouts"]
assert scorecard.cross_market_note == "US volatility spillover"
def test_scorecard_defaults() -> None:
scorecard = DailyScorecard(
date="2026-02-08",
market="US",
total_decisions=0,
buys=0,
sells=0,
holds=0,
total_pnl=0.0,
win_rate=0.0,
avg_confidence=0.0,
scenario_match_rate=0.0,
)
assert scorecard.top_winners == []
assert scorecard.top_losers == []
assert scorecard.lessons == []
assert scorecard.cross_market_note == ""
def test_scorecard_list_isolation() -> None:
a = DailyScorecard(
date="2026-02-08",
market="KR",
total_decisions=1,
buys=1,
sells=0,
holds=0,
total_pnl=10.0,
win_rate=100.0,
avg_confidence=90.0,
scenario_match_rate=100.0,
)
b = DailyScorecard(
date="2026-02-08",
market="US",
total_decisions=1,
buys=0,
sells=1,
holds=0,
total_pnl=-5.0,
win_rate=0.0,
avg_confidence=60.0,
scenario_match_rate=50.0,
)
a.top_winners.append("005930")
assert b.top_winners == []

377
tests/test_smart_scanner.py Normal file
View File

@@ -0,0 +1,377 @@
"""Tests for SmartVolatilityScanner."""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
from src.analysis.volatility import VolatilityAnalyzer
from src.broker.kis_api import KISBroker
from src.config import Settings
@pytest.fixture
def mock_settings() -> Settings:
"""Create test settings."""
return Settings(
KIS_APP_KEY="test",
KIS_APP_SECRET="test",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test",
RSI_OVERSOLD_THRESHOLD=30,
RSI_MOMENTUM_THRESHOLD=70,
VOL_MULTIPLIER=2.0,
SCANNER_TOP_N=3,
DB_PATH=":memory:",
)
@pytest.fixture
def mock_broker(mock_settings: Settings) -> MagicMock:
"""Create mock broker."""
broker = MagicMock(spec=KISBroker)
broker._settings = mock_settings
broker.fetch_market_rankings = AsyncMock()
broker.get_daily_prices = AsyncMock()
return broker
@pytest.fixture
def scanner(mock_broker: MagicMock, mock_settings: Settings) -> SmartVolatilityScanner:
"""Create smart scanner instance."""
analyzer = VolatilityAnalyzer()
return SmartVolatilityScanner(
broker=mock_broker,
volatility_analyzer=analyzer,
settings=mock_settings,
)
class TestSmartVolatilityScanner:
"""Test suite for SmartVolatilityScanner."""
@pytest.mark.asyncio
async def test_scan_finds_oversold_candidates(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that scanner identifies oversold stocks with high volume."""
# Mock rankings
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": "005930",
"name": "Samsung",
"price": 70000,
"volume": 5000000,
"change_rate": -3.5,
"volume_increase_rate": 250,
},
]
# Mock daily prices - trending down (oversold)
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 75000 - i * 200,
"high": 75500 - i * 200,
"low": 74500 - i * 200,
"close": 75000 - i * 250, # Steady decline
"volume": 2000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan()
# Should find at least one candidate (depending on exact RSI calculation)
mock_broker.fetch_market_rankings.assert_called_once()
mock_broker.get_daily_prices.assert_called_once_with("005930", days=20)
# If qualified, should have oversold signal
if candidates:
assert candidates[0].signal in ["oversold", "momentum"]
assert candidates[0].volume_ratio >= scanner.vol_multiplier
@pytest.mark.asyncio
async def test_scan_finds_momentum_candidates(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that scanner identifies momentum stocks with high volume."""
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": "035420",
"name": "NAVER",
"price": 250000,
"volume": 3000000,
"change_rate": 5.0,
"volume_increase_rate": 300,
},
]
# Mock daily prices - trending up (momentum)
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 230000 + i * 500,
"high": 231000 + i * 500,
"low": 229000 + i * 500,
"close": 230500 + i * 500, # Steady rise
"volume": 1000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan()
mock_broker.fetch_market_rankings.assert_called_once()
@pytest.mark.asyncio
async def test_scan_filters_low_volume(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that stocks with low volume ratio are filtered out."""
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": "000660",
"name": "SK Hynix",
"price": 150000,
"volume": 500000,
"change_rate": -5.0,
"volume_increase_rate": 50, # Only 50% increase (< 200%)
},
]
# Low volume
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 150000 - i * 100,
"high": 151000 - i * 100,
"low": 149000 - i * 100,
"close": 150000 - i * 150, # Declining (would be oversold)
"volume": 1000000, # Current 500k < 2x prev day 1M
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan()
# Should be filtered out due to low volume ratio
assert len(candidates) == 0
@pytest.mark.asyncio
async def test_scan_filters_neutral_rsi(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that stocks with neutral RSI are filtered out."""
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": "051910",
"name": "LG Chem",
"price": 500000,
"volume": 3000000,
"change_rate": 0.5,
"volume_increase_rate": 300, # High volume
},
]
# Flat prices (neutral RSI ~50)
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 500000 + (i % 2) * 100, # Small oscillation
"high": 500500,
"low": 499500,
"close": 500000 + (i % 2) * 50,
"volume": 1000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan()
# Should be filtered out (RSI ~50, not < 30 or > 70)
assert len(candidates) == 0
@pytest.mark.asyncio
async def test_scan_uses_fallback_on_api_error(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test fallback to static list when ranking API fails."""
mock_broker.fetch_market_rankings.side_effect = ConnectionError("API unavailable")
# Fallback stocks should still be analyzed
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 50000 - i * 50,
"high": 51000 - i * 50,
"low": 49000 - i * 50,
"close": 50000 - i * 75, # Declining
"volume": 1000000,
})
mock_broker.get_daily_prices.return_value = prices
candidates = await scanner.scan(fallback_stocks=["005930", "000660"])
# Should not crash
assert isinstance(candidates, list)
@pytest.mark.asyncio
async def test_scan_returns_top_n_only(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that scan returns at most top_n candidates."""
# Return many stocks
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": f"00{i}000",
"name": f"Stock{i}",
"price": 10000 * i,
"volume": 5000000,
"change_rate": -10,
"volume_increase_rate": 500,
}
for i in range(1, 10)
]
# All oversold with high volume
def make_prices(code: str) -> list[dict]:
prices = []
for i in range(20):
prices.append({
"date": f"2026020{i:02d}",
"open": 10000 - i * 100,
"high": 10500 - i * 100,
"low": 9500 - i * 100,
"close": 10000 - i * 150,
"volume": 1000000,
})
return prices
mock_broker.get_daily_prices.side_effect = make_prices
candidates = await scanner.scan()
# Should respect top_n limit (3)
assert len(candidates) <= scanner.top_n
@pytest.mark.asyncio
async def test_scan_skips_insufficient_price_history(
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
) -> None:
"""Test that stocks with insufficient history are skipped."""
mock_broker.fetch_market_rankings.return_value = [
{
"stock_code": "005930",
"name": "Samsung",
"price": 70000,
"volume": 5000000,
"change_rate": -5.0,
"volume_increase_rate": 300,
},
]
# Only 5 days of data (need 15+ for RSI)
mock_broker.get_daily_prices.return_value = [
{
"date": f"2026020{i:02d}",
"open": 70000,
"high": 71000,
"low": 69000,
"close": 70000,
"volume": 2000000,
}
for i in range(5)
]
candidates = await scanner.scan()
# Should skip due to insufficient data
assert len(candidates) == 0
@pytest.mark.asyncio
async def test_get_stock_codes(
self, scanner: SmartVolatilityScanner
) -> None:
"""Test extraction of stock codes from candidates."""
candidates = [
ScanCandidate(
stock_code="005930",
name="Samsung",
price=70000,
volume=5000000,
volume_ratio=2.5,
rsi=28,
signal="oversold",
score=85.0,
),
ScanCandidate(
stock_code="035420",
name="NAVER",
price=250000,
volume=3000000,
volume_ratio=3.0,
rsi=75,
signal="momentum",
score=88.0,
),
]
codes = scanner.get_stock_codes(candidates)
assert codes == ["005930", "035420"]
class TestRSICalculation:
"""Test RSI calculation in VolatilityAnalyzer."""
def test_rsi_oversold(self) -> None:
"""Test RSI calculation for downtrending prices."""
analyzer = VolatilityAnalyzer()
# Steadily declining prices
prices = [100 - i * 0.5 for i in range(20)]
rsi = analyzer.calculate_rsi(prices, period=14)
assert rsi < 50 # Should be oversold territory
def test_rsi_overbought(self) -> None:
"""Test RSI calculation for uptrending prices."""
analyzer = VolatilityAnalyzer()
# Steadily rising prices
prices = [100 + i * 0.5 for i in range(20)]
rsi = analyzer.calculate_rsi(prices, period=14)
assert rsi > 50 # Should be overbought territory
def test_rsi_neutral(self) -> None:
"""Test RSI calculation for flat prices."""
analyzer = VolatilityAnalyzer()
# Flat prices with small oscillation
prices = [100 + (i % 2) * 0.1 for i in range(20)]
rsi = analyzer.calculate_rsi(prices, period=14)
assert 40 < rsi < 60 # Should be near neutral
def test_rsi_insufficient_data(self) -> None:
"""Test RSI returns neutral when insufficient data."""
analyzer = VolatilityAnalyzer()
prices = [100, 101, 102] # Only 3 prices, need 15+
rsi = analyzer.calculate_rsi(prices, period=14)
assert rsi == 50.0 # Default neutral
def test_rsi_all_gains(self) -> None:
"""Test RSI returns 100 when all gains (no losses)."""
analyzer = VolatilityAnalyzer()
# Monotonic increase
prices = [100 + i for i in range(20)]
rsi = analyzer.calculate_rsi(prices, period=14)
assert rsi == 100.0 # Maximum RSI

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"

View File

@@ -39,6 +39,76 @@ class TestTelegramClientInit:
class TestNotificationSending: class TestNotificationSending:
"""Test notification sending behavior.""" """Test notification sending behavior."""
@pytest.mark.asyncio
async def test_send_message_success(self) -> None:
"""send_message returns True on successful send."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
result = await client.send_message("Test message")
assert result is True
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert payload["chat_id"] == "456"
assert payload["text"] == "Test message"
assert payload["parse_mode"] == "HTML"
@pytest.mark.asyncio
async def test_send_message_disabled_client(self) -> None:
"""send_message returns False when client disabled."""
client = TelegramClient(enabled=False)
with patch("aiohttp.ClientSession.post") as mock_post:
result = await client.send_message("Test message")
assert result is False
mock_post.assert_not_called()
@pytest.mark.asyncio
async def test_send_message_api_error(self) -> None:
"""send_message returns False on API error."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 400
mock_resp.text = AsyncMock(return_value="Bad Request")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
result = await client.send_message("Test message")
assert result is False
@pytest.mark.asyncio
async def test_send_message_with_markdown(self) -> None:
"""send_message supports different parse modes."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
result = await client.send_message("*bold*", parse_mode="Markdown")
assert result is True
payload = mock_post.call_args.kwargs["json"]
assert payload["parse_mode"] == "Markdown"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_send_when_disabled(self) -> None: async def test_no_send_when_disabled(self) -> None:
"""Notifications not sent when client disabled.""" """Notifications not sent when client disabled."""
@@ -90,6 +160,83 @@ class TestNotificationSending:
assert "250.50" in payload["text"] assert "250.50" in payload["text"]
assert "92%" in payload["text"] assert "92%" in payload["text"]
@pytest.mark.asyncio
async def test_playbook_generated_format(self) -> None:
"""Playbook generated notification has expected fields."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await client.notify_playbook_generated(
market="KR",
stock_count=4,
scenario_count=12,
token_count=980,
)
payload = mock_post.call_args.kwargs["json"]
assert "Playbook Generated" in payload["text"]
assert "Market: KR" in payload["text"]
assert "Stocks: 4" in payload["text"]
assert "Scenarios: 12" in payload["text"]
assert "Tokens: 980" in payload["text"]
@pytest.mark.asyncio
async def test_scenario_matched_format(self) -> None:
"""Scenario matched notification has expected fields."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await client.notify_scenario_matched(
stock_code="AAPL",
action="BUY",
condition_summary="RSI < 30, volume_ratio > 2.0",
confidence=88.2,
)
payload = mock_post.call_args.kwargs["json"]
assert "Scenario Matched" in payload["text"]
assert "AAPL" in payload["text"]
assert "Action: BUY" in payload["text"]
assert "RSI < 30" in payload["text"]
assert "88%" in payload["text"]
@pytest.mark.asyncio
async def test_playbook_failed_format(self) -> None:
"""Playbook failed notification has expected fields."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await client.notify_playbook_failed(
market="US",
reason="Gemini timeout",
)
payload = mock_post.call_args.kwargs["json"]
assert "Playbook Failed" in payload["text"]
assert "Market: US" in payload["text"]
assert "Gemini timeout" in payload["text"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_circuit_breaker_priority(self) -> None: async def test_circuit_breaker_priority(self) -> None:
"""Circuit breaker uses CRITICAL priority.""" """Circuit breaker uses CRITICAL priority."""
@@ -239,6 +386,73 @@ class TestMessagePriorities:
payload = mock_post.call_args.kwargs["json"] payload = mock_post.call_args.kwargs["json"]
assert NotificationPriority.CRITICAL.emoji in payload["text"] assert NotificationPriority.CRITICAL.emoji in payload["text"]
@pytest.mark.asyncio
async def test_playbook_generated_priority(self) -> None:
"""Playbook generated uses MEDIUM priority emoji."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await client.notify_playbook_generated(
market="KR",
stock_count=2,
scenario_count=4,
token_count=123,
)
payload = mock_post.call_args.kwargs["json"]
assert NotificationPriority.MEDIUM.emoji in payload["text"]
@pytest.mark.asyncio
async def test_playbook_failed_priority(self) -> None:
"""Playbook failed uses HIGH priority emoji."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await client.notify_playbook_failed(
market="KR",
reason="Invalid JSON",
)
payload = mock_post.call_args.kwargs["json"]
assert NotificationPriority.HIGH.emoji in payload["text"]
@pytest.mark.asyncio
async def test_scenario_matched_priority(self) -> None:
"""Scenario matched uses HIGH priority emoji."""
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
await client.notify_scenario_matched(
stock_code="AAPL",
action="BUY",
condition_summary="RSI < 30",
confidence=80.0,
)
payload = mock_post.call_args.kwargs["json"]
assert NotificationPriority.HIGH.emoji in payload["text"]
class TestClientCleanup: class TestClientCleanup:
"""Test client cleanup behavior.""" """Test client cleanup behavior."""

View File

@@ -0,0 +1,777 @@
"""Tests for Telegram command handler."""
from unittest.mock import AsyncMock, patch
import pytest
from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler
class TestCommandHandlerInit:
"""Test command handler initialization."""
def test_init_with_client(self) -> None:
"""Handler initializes with TelegramClient."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
assert handler._client is client
assert handler._polling_interval == 1.0
assert handler._commands == {}
assert handler._running is False
def test_custom_polling_interval(self) -> None:
"""Handler accepts custom polling interval."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client, polling_interval=2.5)
assert handler._polling_interval == 2.5
class TestCommandRegistration:
"""Test command registration."""
@pytest.mark.asyncio
async def test_register_command(self) -> None:
"""Commands can be registered."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
async def test_handler() -> None:
pass
handler.register_command("test", test_handler)
assert "test" in handler._commands
assert handler._commands["test"] is test_handler
@pytest.mark.asyncio
async def test_register_multiple_commands(self) -> None:
"""Multiple commands can be registered."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
async def handler1() -> None:
pass
async def handler2() -> None:
pass
handler.register_command("start", handler1)
handler.register_command("help", handler2)
assert len(handler._commands) == 2
assert handler._commands["start"] is handler1
assert handler._commands["help"] is handler2
class TestPollingLifecycle:
"""Test polling start/stop."""
@pytest.mark.asyncio
async def test_start_polling(self) -> None:
"""Polling can be started."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
await handler.start_polling()
assert handler._running is True
assert handler._polling_task is not None
await handler.stop_polling()
@pytest.mark.asyncio
async def test_start_polling_disabled_client(self) -> None:
"""Polling not started when client disabled."""
client = TelegramClient(enabled=False)
handler = TelegramCommandHandler(client)
await handler.start_polling()
assert handler._running is False
assert handler._polling_task is None
@pytest.mark.asyncio
async def test_stop_polling(self) -> None:
"""Polling can be stopped."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
await handler.start_polling()
await handler.stop_polling()
assert handler._running is False
@pytest.mark.asyncio
async def test_double_start_ignored(self) -> None:
"""Starting already running handler is ignored."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
await handler.start_polling()
task1 = handler._polling_task
await handler.start_polling() # Second start
task2 = handler._polling_task
# Should be the same task
assert task1 is task2
await handler.stop_polling()
class TestUpdateHandling:
"""Test update parsing and handling."""
@pytest.mark.asyncio
async def test_handle_valid_command(self) -> None:
"""Valid commands are executed."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
executed = False
async def test_command() -> None:
nonlocal executed
executed = True
handler.register_command("test", test_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/test",
},
}
await handler._handle_update(update)
assert executed is True
@pytest.mark.asyncio
async def test_handle_unknown_command(self) -> None:
"""Unknown commands send help message."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/unknown",
},
}
await handler._handle_update(update)
# Should send error message
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Unknown command" in payload["text"]
assert "/unknown" in payload["text"]
@pytest.mark.asyncio
async def test_ignore_unauthorized_chat(self) -> None:
"""Commands from unauthorized chats are ignored."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
executed = False
async def test_command() -> None:
nonlocal executed
executed = True
handler.register_command("test", test_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 999}, # Wrong chat_id
"text": "/test",
},
}
await handler._handle_update(update)
assert executed is False
@pytest.mark.asyncio
async def test_ignore_non_command_text(self) -> None:
"""Non-command text is ignored."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
executed = False
async def test_command() -> None:
nonlocal executed
executed = True
handler.register_command("test", test_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "Hello, not a command",
},
}
await handler._handle_update(update)
assert executed is False
@pytest.mark.asyncio
async def test_handle_command_with_botname(self) -> None:
"""Commands with @botname suffix are handled correctly."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
executed = False
async def test_command() -> None:
nonlocal executed
executed = True
handler.register_command("start", test_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/start@mybot",
},
}
await handler._handle_update(update)
assert executed is True
@pytest.mark.asyncio
async def test_handle_update_error_isolation(self) -> None:
"""Errors in handlers don't crash the system."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
async def failing_command() -> None:
raise ValueError("Test error")
handler.register_command("fail", failing_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/fail",
},
}
# Should not raise exception
await handler._handle_update(update)
class TestTradingControlCommands:
"""Test trading control commands."""
@pytest.mark.asyncio
async def test_stop_command_pauses_trading(self) -> None:
"""Stop command clears pause event."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
# Create mock pause event
import asyncio
pause_event = asyncio.Event()
pause_event.set() # Initially active
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_stop() -> None:
"""Mock /stop handler."""
if not pause_event.is_set():
await client.send_message("⏸️ Trading is already paused")
return
pause_event.clear()
await client.send_message(
"<b>⏸️ Trading Paused</b>\n\n"
"All trading operations have been suspended.\n"
"Use /resume to restart trading."
)
handler.register_command("stop", mock_stop)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/stop",
},
}
await handler._handle_update(update)
# Verify pause event was cleared
assert not pause_event.is_set()
# Verify message was sent
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Trading Paused" in payload["text"]
@pytest.mark.asyncio
async def test_resume_command_resumes_trading(self) -> None:
"""Resume command sets pause event."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
# Create mock pause event (initially paused)
import asyncio
pause_event = asyncio.Event()
pause_event.clear() # Initially paused
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_resume() -> None:
"""Mock /resume handler."""
if pause_event.is_set():
await client.send_message("▶️ Trading is already active")
return
pause_event.set()
await client.send_message(
"<b>▶️ Trading Resumed</b>\n\n"
"Trading operations have been restarted."
)
handler.register_command("resume", mock_resume)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/resume",
},
}
await handler._handle_update(update)
# Verify pause event was set
assert pause_event.is_set()
# Verify message was sent
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Trading Resumed" in payload["text"]
@pytest.mark.asyncio
async def test_stop_when_already_paused(self) -> None:
"""Stop command when already paused sends appropriate message."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
# Create mock pause event (already paused)
import asyncio
pause_event = asyncio.Event()
pause_event.clear()
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_stop() -> None:
"""Mock /stop handler."""
if not pause_event.is_set():
await client.send_message("⏸️ Trading is already paused")
return
pause_event.clear()
handler.register_command("stop", mock_stop)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/stop",
},
}
await handler._handle_update(update)
# Verify message was sent
payload = mock_post.call_args.kwargs["json"]
assert "already paused" in payload["text"]
@pytest.mark.asyncio
async def test_resume_when_already_active(self) -> None:
"""Resume command when already active sends appropriate message."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
# Create mock pause event (already active)
import asyncio
pause_event = asyncio.Event()
pause_event.set()
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_resume() -> None:
"""Mock /resume handler."""
if pause_event.is_set():
await client.send_message("▶️ Trading is already active")
return
pause_event.set()
handler.register_command("resume", mock_resume)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/resume",
},
}
await handler._handle_update(update)
# Verify message was sent
payload = mock_post.call_args.kwargs["json"]
assert "already active" in payload["text"]
class TestStatusCommands:
"""Test status query commands."""
@pytest.mark.asyncio
async def test_status_command_shows_trading_info(self) -> None:
"""Status command displays mode, markets, and P&L."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_status() -> None:
"""Mock /status handler."""
message = (
"<b>📊 Trading Status</b>\n\n"
"<b>Mode:</b> PAPER\n"
"<b>Markets:</b> Korea, United States\n"
"<b>Trading:</b> Active\n\n"
"<b>Current P&L:</b> +2.50%\n"
"<b>Circuit Breaker:</b> -3.0%"
)
await client.send_message(message)
handler.register_command("status", mock_status)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/status",
},
}
await handler._handle_update(update)
# Verify message was sent
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Trading Status" in payload["text"]
assert "PAPER" in payload["text"]
assert "P&L" in payload["text"]
@pytest.mark.asyncio
async def test_status_command_error_handling(self) -> None:
"""Status command handles errors gracefully."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_status_error() -> None:
"""Mock /status handler with error."""
await client.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve trading status."
)
handler.register_command("status", mock_status_error)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/status",
},
}
await handler._handle_update(update)
# Should send error message
payload = mock_post.call_args.kwargs["json"]
assert "Error" in payload["text"]
@pytest.mark.asyncio
async def test_positions_command_shows_holdings(self) -> None:
"""Positions command displays account summary."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_positions() -> None:
"""Mock /positions handler."""
message = (
"<b>💼 Account Summary</b>\n\n"
"<b>Total Evaluation:</b> ₩10,500,000\n"
"<b>Available Cash:</b> ₩5,000,000\n"
"<b>Purchase Total:</b> ₩10,000,000\n"
"<b>P&L:</b> +5.00%\n\n"
"<i>Note: Individual position details require API enhancement</i>"
)
await client.send_message(message)
handler.register_command("positions", mock_positions)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/positions",
},
}
await handler._handle_update(update)
# Verify message was sent
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Account Summary" in payload["text"]
assert "Total Evaluation" in payload["text"]
assert "P&L" in payload["text"]
@pytest.mark.asyncio
async def test_positions_command_empty_holdings(self) -> None:
"""Positions command handles empty portfolio."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_positions_empty() -> None:
"""Mock /positions handler with no positions."""
message = (
"<b>💼 Account Summary</b>\n\n"
"No balance information available."
)
await client.send_message(message)
handler.register_command("positions", mock_positions_empty)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/positions",
},
}
await handler._handle_update(update)
# Verify message was sent
payload = mock_post.call_args.kwargs["json"]
assert "No balance information available" in payload["text"]
@pytest.mark.asyncio
async def test_positions_command_error_handling(self) -> None:
"""Positions command handles errors gracefully."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_positions_error() -> None:
"""Mock /positions handler with error."""
await client.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve positions."
)
handler.register_command("positions", mock_positions_error)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/positions",
},
}
await handler._handle_update(update)
# Should send error message
payload = mock_post.call_args.kwargs["json"]
assert "Error" in payload["text"]
class TestBasicCommands:
"""Test basic command implementations."""
@pytest.mark.asyncio
async def test_help_command_content(self) -> None:
"""Help command lists all available commands."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_help() -> None:
"""Mock /help handler."""
message = (
"<b>📖 Available Commands</b>\n\n"
"/help - Show available commands\n"
"/status - Trading status (mode, markets, P&L)\n"
"/positions - Current holdings\n"
"/stop - Pause trading\n"
"/resume - Resume trading"
)
await client.send_message(message)
handler.register_command("help", mock_help)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/help",
},
}
await handler._handle_update(update)
# Verify message was sent
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Available Commands" in payload["text"]
assert "/help" in payload["text"]
assert "/status" in payload["text"]
assert "/positions" in payload["text"]
assert "/stop" in payload["text"]
assert "/resume" in payload["text"]
class TestGetUpdates:
"""Test getUpdates API interaction."""
@pytest.mark.asyncio
async def test_get_updates_success(self) -> None:
"""getUpdates fetches and parses updates."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={
"ok": True,
"result": [
{"update_id": 1, "message": {"text": "/test"}},
{"update_id": 2, "message": {"text": "/help"}},
],
}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert len(updates) == 2
assert updates[0]["update_id"] == 1
assert updates[1]["update_id"] == 2
assert handler._last_update_id == 2
@pytest.mark.asyncio
async def test_get_updates_api_error(self) -> None:
"""getUpdates handles API errors gracefully."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 400
mock_resp.text = AsyncMock(return_value="Bad Request")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert updates == []
@pytest.mark.asyncio
async def test_get_updates_empty_result(self) -> None:
"""getUpdates handles empty results."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"ok": True, "result": []})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert updates == []

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import sqlite3 import sqlite3
from typing import Any from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -411,7 +412,7 @@ class TestMarketScanner:
scan_result = context_store.get_context( scan_result = context_store.get_context(
ContextLayer.L7_REALTIME, ContextLayer.L7_REALTIME,
latest_timeframe, latest_timeframe,
"KR_scan_result", "scan_result_KR",
) )
assert scan_result is not None assert scan_result is not None
assert scan_result["total_scanned"] == 3 assert scan_result["total_scanned"] == 3
@@ -531,3 +532,45 @@ class TestMarketScanner:
new_additions = [code for code in updated if code not in current_watchlist] new_additions = [code for code in updated if code not in current_watchlist]
assert len(new_additions) <= 1 assert len(new_additions) <= 1
assert len(updated) == len(current_watchlist) assert len(updated) == len(current_watchlist)
@pytest.mark.asyncio
async def test_scan_market_respects_concurrency_limit(
self,
mock_broker: KISBroker,
mock_overseas_broker: OverseasBroker,
volatility_analyzer: VolatilityAnalyzer,
context_store: ContextStore,
) -> None:
"""scan_market should limit concurrent scans to max_concurrent_scans."""
max_concurrent = 2
scanner = MarketScanner(
broker=mock_broker,
overseas_broker=mock_overseas_broker,
volatility_analyzer=volatility_analyzer,
context_store=context_store,
top_n=5,
max_concurrent_scans=max_concurrent,
)
# Track peak concurrency
active_count = 0
peak_count = 0
original_scan = scanner.scan_stock
async def tracking_scan(code: str, market: Any) -> VolatilityMetrics:
nonlocal active_count, peak_count
active_count += 1
peak_count = max(peak_count, active_count)
await asyncio.sleep(0.05) # Simulate API call duration
active_count -= 1
return VolatilityMetrics(code, 50000, 500, 1.0, 1.0, 1.0, 1.0, 10.0, 50.0)
scanner.scan_stock = tracking_scan # type: ignore[method-assign]
market = MARKETS["KR"]
stock_codes = ["001", "002", "003", "004", "005", "006"]
await scanner.scan_market(market, stock_codes)
assert peak_count <= max_concurrent