Compare commits

...

34 Commits

Author SHA1 Message Date
71ef959310 docs(web-ai): rewrite CLAUDE.md with Phase 0-4 complete context
Replaces Phase-2-era placeholder. Adds:
- V1 (signal_v1 :8000 LSTM bot) vs V2 (signal_v2 :8001 Confidence Pipeline) split
- start.bat invocation for each + KIS rate limit warning (do NOT run both)
- Phase 0-7 status table, Phase 4 completed 2026-05-17
- signal_v2/ module-level inventory + new test count (56)
- Phase 4 buy/sell rule summary (absolute spread amendment included)
- 11 known traps + Phase 7 backlog
- Cross-repo workflow note (code in web-ai, spec/plan in web-ui)
2026-05-17 14:00:52 +09:00
2aa9f48ea3 feat(signal_v2-phase4): add emit/skip logging to signal_generator
logger was declared but unused. Operational visibility was zero —
trader debugging 'why no signal?' had to step through code mentally.

- INFO on emit: '[signal emit] 005930 buy conf=0.823 rank=3' / sell with reason
- DEBUG on each skip path: same-cycle sell, hard gate, low confidence,
  dedup 24h (buy and sell)

Per final reviewer recommendation. 56 tests still pass.
2026-05-17 13:35:29 +09:00
cc6310d72f feat(signal_v2-phase4-task3): integrate signal_generator into poll_loop
poll_loop now accepts dedup + settings kwargs (backwards-compatible defaults).
After each in-window cycle (stock pull + minute momentum + optional post-close),
generate_signals is called to populate state.signals for downstream Phase 5
pickup. main.py lifespan wires _ctx.dedup + settings into the poll_loop task.

1 integration test added (anomaly-free stop_loss path via direct generate_signals
call, exercises the same code path that poll_loop runs).

56 tests pass.
2026-05-17 13:24:47 +09:00
e574074ca8 fix(signal_v2-phase4-task2): code review fixes — sell-first ordering + anomaly test + defensive .get
- generate_signals now evaluates sell before buy; buy candidates with a same-cycle
  sell signal are skipped (resolves silent overwrite of state.signals[ticker]).
- Added test_sell_signal_triggers_on_anomaly_path covering _try_anomaly path
  (previously 0% covered).
- Fixed stale test comment referencing deprecated relative spread formula.
- _check_buy_hard_gate uses dict.get(..., 0) for defense against partial upstream state.
- _compute_buy_confidence clamps screener_norm to >= 0 for future Top-N changes.
2026-05-17 13:18:22 +09:00
b9def06993 feat(signal_v2-phase4): signal_generator + 9 unit tests
generate_signals(state, dedup, settings) → state mutating:
- Buy: screener Top-N + portfolio. Hard gate (chronos median > 0 +
  spread < 0.6 + momentum strong_up + bid_ratio >= 0.6) + soft
  confidence (chronos*0.5 + minute*0.3 + screener*0.2) > 0.7.
- Sell: portfolio only. Priority stop_loss > anomaly > take_profit.
  Stop loss confidence 1.0, take_profit 0.6 (review alert).
- SignalDedup 24h via dedup.is_recent/record per (ticker, action).
- State signal dict matches Phase 0 spec §5.2 schema.

54 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:03:29 +09:00
05ab2846bb feat(signal_v2-phase4): foundation — 6 env thresholds + state.signals
config.py: STOP_LOSS_PCT / TAKE_PROFIT_PCT / CHRONOS_SPREAD_THRESHOLD /
ASKING_BID_RATIO_THRESHOLD / CONFIDENCE_THRESHOLD / MIN_MOMENTUM_FOR_BUY
env vars with sensible defaults (Phase 0 spec §6.1-§6.2 values).

state.py: PollState.signals dict[ticker, signal_body] for Phase 5 input.

45 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:55:15 +09:00
760f914d3b fix(signal_v2-phase3b): force FP32 + predict_quantiles positional args
ChronosBoltPipeline.predict_quantiles takes `inputs` positional, not
`context` keyword. Use positional with TypeError fallback for older
chronos versions.

FP16 caused inf overflow on Korean stock prices (e.g. 280,000원 >
FP16 max 65,504). Force FP32 for prices to avoid this. Chronos model
itself handles internal scaling.

Verified end-to-end: 60-day daily fetch → Chronos predict → quantile
output. Example 005930: median=-0.59%, q10=-8.9%, q90=+6.4%, conf=0.0
(low conf is mathematically correct when median is near zero relative
to distribution width).

45/45 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:12:10 +09:00
8eefe9d79d fix(signal_v2-phase3b): ChronosBolt predict_quantiles API support
ChronosBoltPipeline.predict() does not accept `context` kwarg; it
uses positional-only and is deterministic (no num_samples). Switch
to predict_quantiles(context, prediction_length, quantile_levels)
which returns (quantiles_tensor, mean_tensor).

Implementation: if hasattr(pipeline, "predict_quantiles") → modern
quantile branch. Else fall back to legacy sample-based predict (T5).

Tests: switch to predict_quantiles mock returning (quantiles, None)
with shape [1, 1, 3] for q10/q50/q90 directly.

45/45 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:07:11 +09:00
91de16675b fix(signal_v2-phase3b): use BaseChronosPipeline for new model architectures
ChronosPipeline (legacy T5) does not support amazon/chronos-2 or
chronos-bolt-* (input_patch_size). Switch to BaseChronosPipeline
which auto-detects variant and returns the appropriate sub-pipeline
(ChronosBoltPipeline / Chronos2Pipeline / ChronosPipeline).

Also handle the dtype kwarg deprecation: try newer `dtype=` first,
fall back to `torch_dtype=` for older versions.

Test mock_pipeline fixture updated to patch BaseChronosPipeline.

45/45 tests pass. Verified amazon/chronos-bolt-base loads on CUDA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 08:57:22 +09:00
44888d6ede feat(signal_v2-phase3b): main.py lifespan loads ChronosPredictor
AppContext.chronos field. lifespan: if KIS_APP_KEY set, load
ChronosPredictor(model_name=settings.chronos_model). Exceptions
during load logged + signal_v2 continues without chronos (other
endpoints unaffected). poll_loop receives chronos param.

45 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:11:50 +09:00
9e5fecb369 feat(signal_v2-phase3b): post-close cycle + minute momentum update
scheduler._is_post_close_trigger: 16:00 KST ±1min detection (market day).
pull_worker:
- _run_post_close_cycle: daily fetch (60일) + chronos batch predict →
  state.chronos_predictions + state.daily_ohlcv.
- update_minute_momentum_for_all: 매 cycle 마다 state.minute_momentum 갱신.
- poll_loop signature 확장 (chronos optional).

45 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:04:32 +09:00
28f9c8c3a6 feat(signal_v2-phase3b): chronos_predictor + 4 mock tests
ChronosPredictor wraps HuggingFace ChronosPipeline. Batch predict
returns ChronosPrediction(median, q10, q90, conf, as_of) per ticker.
Confidence = 1 - clamp(spread/2, 0, 1) where spread = (q90-q10) / |median|.
Lazy import of chronos lib (heavy). GPU auto-detect with FP16.

44 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:00:46 +09:00
c5a88fab66 feat(signal_v2-phase3b): momentum_classifier + 6 unit tests
aggregate_1min_to_5min: 1분봉 5개 → 5분봉 1개 (open=첫, close=마지막,
high=max, low=min, volume=sum). classify_minute_momentum: 직전 5개
5분봉 양봉 개수 + 거래량 60분 multiplier → 5-level
(strong_up/weak_up/neutral/weak_down/strong_down).

40 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:34 +09:00
7056cf2fa6 feat(signal_v2-phase3b): kis_client.get_daily_ohlcv (60 daily bars)
TR_ID FHKST03010100 (수정주가 일봉). KIS returns descending; client
reverses to ascending and trims to last N days.

1 new test, 34 total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:49:06 +09:00
4ac7da8670 feat(signal_v2-phase3b): foundation — config + state + requirements
- config.py: CHRONOS_MODEL env (default amazon/chronos-2)
- state.py: PollState extended with daily_ohlcv + chronos_predictions
  + minute_momentum
- requirements.txt: transformers + chronos-forecasting

33 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:46:09 +09:00
b690900cfc fix(signal_v2-phase3a): V1 KIS env pattern + test isolation fix
config.py: kis_env_type (virtual/real) + KIS_REAL_*/KIS_VIRTUAL_* env
variables (V1 호환). kis_app_key/kis_app_secret/kis_account properties
auto-select based on env type.

main.py: KIS not-configured warning uses descriptive message including
env type + expected var name.

test_main.py: monkeypatch load_dotenv to no-op + setenv empty string
(instead of delenv) — defeats .env re-read on importlib.reload.
Pre-existing test_startup_warns_if_webai_api_key_missing also fixed.

33/33 tests pass (was 31/33).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:25:58 +09:00
d85512d036 feat(signal_v2-phase3a): main.py lifespan integrates KIS client + WS
AppContext extended with kis_client + kis_ws. lifespan:
- If KIS_APP_KEY set: create KISClient + KISWebSocket, fetch portfolio,
  subscribe WebSocket H0STASP0 for holdings.
- If unset: WARNING log, signal_v2 still serves /health (no KIS data).
- Shutdown closes kis_ws → kis_client → stock client in order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:21:55 +09:00
3ebe95ba29 feat(signal_v2-phase3a): pull_worker KIS minute cycle + WS callback
_run_kis_minute_cycle: portfolio + screener union 종목 분봉 fetch +
screener-only 종목 호가 REST fetch. WebSocket callback factory
(make_asking_price_callback).

poll_loop / _run_polling_cycle 에 kis_client optional param 추가
(Phase 5 까지 None 일 때도 정상 동작).

2 new tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:19:24 +09:00
163c9fb690 feat(signal_v2-phase3a): kis_websocket + 4 integration tests
KISWebSocket: approval_key (POST /oauth2/Approval) + H0STASP0 호가
실시간 subscribe + receive loop + exponential reconnect (1s → 2s → 4s
→ max 30s). _parse_asking_price 필드 인덱스 운영 검증 필요
(현재 가정: 마지막 2 필드가 ask_total/bid_total).

4 tests pass, 29 total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:16:20 +09:00
27bf360b01 feat(signal_v2-phase3a): kis_client REST + 4 integration tests
KISClient: 분봉 (FHKST03010200) + 호가 (FHKST01010200) async REST.
V1 토큰 파일 (signal_v1/data/kis_token.json) read-only 공유, mtime
캐시. 초당 2회 throttle. exponential retry (max 3, 1s/2s/4s).

4 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:12:45 +09:00
eafa73edb1 feat(signal_v2-phase3a): scheduler NXT windows (20:00-23:30 / 04:30-07:00)
NXT 시간외 거래 시간대도 5분 cron 폴링 활성화. 23:30-04:30 dead zone
(KIS 점검) → 04:30 까지 skip. 기존 _seconds_until_next_market_open
(휴장일/주말용) 와 별개로 _seconds_until_nxt_or_market_open 신설.

3 new tests, scheduler suite 11 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:09:27 +09:00
68eb7b073c feat(signal_v2-phase3a): config + state extensions for KIS data
- config.py: KIS_APP_KEY/SECRET/ACCOUNT/IS_VIRTUAL + V1_TOKEN_PATH env
- state.py: PollState extended with minute_bars (deque) and asking_price
- requirements.txt: websockets>=12

19 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:06:40 +09:00
8342d38935 chore(signal_v2): sync holidays.json from stock + start.bat launcher
- holidays.json: authoritative copy from web-backend/stock/app/holidays.json
  (replaces 13-date stub from Task 3; now 16 dates including Jan/May/Dec edges)
- start.bat: uvicorn launcher (cd to web-ai root, host 0.0.0.0, port 8001)

19 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:55:41 +09:00
e47947fb69 fix(signal_v2): await cancelled poll_task + public cache_size
Code review fixes:
- main.py lifespan: await poll_task after cancel() to avoid client
  close racing with mid-fetch task (CRITICAL).
- stock_client: add public cache_size() method; main.py /health uses
  it instead of private _cache attribute (IMPORTANT).

19 tests still pass. Deferred to Phase 7 backlog:
- _ctx singleton test isolation (importlib.reload provides isolation in practice)
- poll_loop interval floor (interval >= 60 by design)
- shutdown logging
- response schema validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:52:45 +09:00
94c684bab8 feat(signal_v2): pull_worker + FastAPI app + 2 integration tests
poll_loop: asyncio.gather parallel fetch of 3 endpoints (portfolio,
news_sentiment, screener_preview) + state update. main.py: FastAPI
lifespan creates StockClient/SignalDedup/shutdown.Event then spawns
poll_loop as background task. GET /health reports status, last poll
times, cache size.

Signal V2 test suite: 19/19 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:49:50 +09:00
1a6d9fcb39 feat(signal_v2): rate_limit + 3 unit tests
SignalDedup: 24h-rolling duplicate signal blocker. SQLite WAL +
busy_timeout=120000 standard fix (reference_sqlite_concurrency.md
pattern). PK (ticker, action) with UPSERT. Phase 4 (signal generator)
will call is_recent() before sending + record() after sending.

3 unit tests pass, total 17 signal_v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:46:59 +09:00
6cb5085118 test(signal_v2): add scheduler boundary tests at exact transitions
Code review noted missing boundary tests at:
- 09:00:00 (pre-market → market) → 60
- 15:30:00 (market → post-market) → 300
- 20:00:00 (post-market → overnight skip)

3 new tests, total 8 scheduler / 14 signal_v2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:45:55 +09:00
fdabc69004 feat(signal_v2): scheduler + 5 unit tests
Time-window dispatcher: pre-market (07:00-09:00, 5min), market
(09:00-15:30, 1min), post-market (15:30-20:00, 5min), overnight skip
to next market day 07:00. Weekend + holiday detection via holidays.json.

Stub holidays.json with 13 dates. Task 6 will sync from
web-backend/stock/app/holidays.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:44:24 +09:00
90235497ae refactor(signal_v2): narrow stock_client exception catch + remove dead code
Code quality review fixes:
- _cached_request: catch httpx.HTTPError instead of bare Exception
  (avoid swallowing KeyboardInterrupt / asyncio.CancelledError)
- _request_with_retry: remove unused last_exc variable + dead post-loop
  raise paths. Final sentinel raise preserved for mypy.

6 tests still pass. Deferred to Phase 7 backlog: cache concurrency
coalescing + __aenter__/__aexit__ context manager support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:43:09 +09:00
8469bf7ffa feat(signal_v2): stock_client + 6 integration tests
httpx async client with custom retry loop (max 3, exponential 1s/2s/4s),
memory dict cache (portfolio 60s / news-sentiment 300s / screener 60s),
X-WebAI-Key auth header injection. Stale fallback returns last
successful response with logger.warning on persistent failures.

6 integration tests pass with respx httpx mock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:40:12 +09:00
8a2fac03a6 feat(signal_v2): foundation — config + state + requirements
- signal_v2/config.py: Settings dataclass loading web-ai/.env explicitly
- signal_v2/state.py: PollState dataclass + module-level singleton
- requirements.txt: httpx / fastapi / uvicorn / pytest-asyncio / respx
- .gitignore: signal_v2/data/*.db (WAL/SHM)
- empty tests/ marker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:35:36 +09:00
ad2c65c2b2 fix(signal_v1): load_dotenv Path depth — resolve web-ai/.env correctly
3 files had insufficient .parent count, resolving to signal_v1/.env
instead of web-ai/.env (which is where the actual env file lives).
Added one .parent each:
- config.py: parent.parent → parent.parent.parent
- analysis/macro.py: parent.parent.parent → parent.parent.parent.parent
- services/telegram_bot/runner.py: parent.parent.parent.parent → +1

watchlist_manager.py was already correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:04:08 +09:00
7ea1a21487 refactor: web-ai V1 assets → signal_v1/ (graduation prep)
Atomic mv of root V1 assets (main_server.py + modules/ + data/ +
tests/ + entry scripts + docs + logs) into signal_v1/ subdirectory.
load_dotenv() updated to load web-ai/.env explicitly via Path.

Adds web-ai/CLAUDE.md (workspace guide) and web-ai/start.bat
(signal_v1 entry wrapper). Prepares for signal_v2/ Phase 2.

Tests: signal_v1/tests/unit baseline preserved (no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:00:11 +09:00
42b91d03cf feat(v3.2): DailyLedger + RiskGate + news_snapshot + backtest_runner
- DailyLedger: 당일 매수 회계 + 연속 손절 카운터 + 매수 신호 점수 한 객체로 집약 (bot.py 정리)
- RiskGate: 테마당 동시 보유 + 노출 비율 상한 검증 (포트폴리오 레벨)
- news_snapshot: 뉴스 SQLite 영구 저장 + 사후 감성 재검증 인프라
- backtest_runner: 전 종목 KIS 일봉 기반 백테스트 (Sharpe/MDD/Calmar)
- bot.py 274 line 정리 (DailyLedger 분리)
- backtest.py 173 line 재작성 (v3.2 next-bar 체결 + 거래세)
- daily_launcher.py 폐기 (warmup_and_restart 통합)
- .gitignore: .claude/ 제외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:57:26 +09:00
71 changed files with 4352 additions and 1156 deletions

12
.gitignore vendored
View File

@@ -47,13 +47,21 @@ daily_trade_history.json
watchlist.json watchlist.json
bot_ipc.json bot_ipc.json
# Test # Test (top-level only; signal_v2/tests tracked separately)
tests/ tests/
tests/* tests/*
!signal_v2/tests/
!signal_v2/tests/**
# System # System
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
# stock # stock
KIS_SETUP.md KIS_SETUP.md
# Claude Code subagent state
.claude/
# Signal V2 runtime data
signal_v2/data/*.db
signal_v2/data/*.db-*

763
CLAUDE.md
View File

@@ -1,696 +1,141 @@
# 🤖 AI Trading Bot — 프로젝트 설계 문서 (CLAUDE.md) # web-ai — Workspace 가이드
> **최종 갱신**: 2026-03-19 Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti 16GB) 의 두 신호 파이프라인.
> **런타임**: Windows (Python 3.x, PyTorch CUDA, FastAPI, Ollama) **Confidence Signal Pipeline V2 의 Windows-side 구현체** (NAS stock 백엔드와 HTTP 연동).
> **하드웨어**: AMD 9800X3D + RTX 5070 Ti (16 GB VRAM)
상위 워크스페이스 컨텍스트는 `../CLAUDE.md` 참조.
--- ---
## 1. 시스템 아키텍처 개요 ## 디렉토리 구조
``` | 경로 | 역할 | 포트 | 상태 |
┌──────────────────────────────────────────────────────────────────┐ |------|------|------|------|
│ main_server.py │ | `signal_v1/` | 레거시 자동매매 시스템 (LSTM 7-features + Gemini Flash + Telegram Bot + KIS 자동주문) | `:8000` | 운영 중. **V2 Phase 6 에서 deprecation 예정** |
│ FastAPI (uvicorn, port 8000) — 프로세스 매니저 & REST API 서버 │ | `signal_v2/` | Confidence Signal Pipeline V2 (Chronos-bolt + 분봉 모멘텀 + KIS WebSocket + 신호 생성) | `:8001` | **Phase 4 완료 (2026-05-17)**, Phase 5 대기 |
│ ┌──────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │ | `.env` | V1 + V2 환경변수 공유 | — | `KIS_REAL_*`, `TELEGRAM_*`, `STOCK_API_URL`, `WEBAI_API_KEY`, `LOG_LEVEL` |
│ │ Trading Bot │ │ Telegram Bot │ │ ProcessWatchdog │ │ | `start.bat` | V1 진입점 | — | `signal_v1/main_server.py` 실행 |
│ │ (Process #1) │ │ (Process #2) │ │ (Daemon Thread) │ │ | `signal_v2/start.bat` | V2 진입점 | — | `signal_v2/main.py` uvicorn 실행 |
│ └──────┬───────┘ └────────┬────────┘ └──────────┬───────────┘ │ | `requirements.txt` | 공용 의존성 | — | torch, chronos-forecasting, fastapi, httpx, websockets 등 |
│ │ │ │ │
│ └─── Shared Memory (IPC) ───┘ Health Check / Restart │
│ + Command Queue │
└──────────────────────────────────────────────────────────────────┘
```
### 1.1 멀티 프로세스 구성 `.venv`**구조적으로 깨짐**: `pyvenv.cfg` 가 한글 사용자 경로(`C:\Users\박재오\...`) 를 포함하여 콘솔 코드페이지가 roundtrip 못함. 테스트는 시스템 Python 으로 실행: `C:\Users\jaeoh\AppData\Local\Programs\Python\Python312\python.exe -m pytest signal_v2/tests -q`.
| 프로세스 | 역할 | 진입점 |
|---------|------|--------|
| **Main Server (Uvicorn)** | FastAPI REST API 서버, 프로세스 오케스트레이터 | `main_server.py` |
| **Trading Bot** | 자동매매 메인 루프 (스케줄러, 분석, 주문) | `modules/bot.py``AutoTradingBot.loop()` |
| **Telegram Bot** | 사용자 인터랙션 (명령어 처리, 알림) | `modules/services/telegram_bot/runner.py` |
| **ProcessWatchdog** | 자식 프로세스 헬스체크 & 자동 재시작 (30초 간격) | `modules/utils/process_tracker.py` |
### 1.2 프로세스 간 통신 (IPC)
```
┌─────────────┐ SharedMemory (128KB) ┌──────────────┐
│ Trading Bot │ ─── write_status() ───────► │ Telegram Bot │
│ │ ◄── read_status() ──────── │ │
│ │ │ │
│ │ multiprocessing.Queue │ │
│ │ ◄── send_command() ──────── │ │
│ │ (텔레그램 → 봇 명령) │ │
└─────────────┘ └──────────────┘
```
- **SharedMemory** (`web_ai_bot_ipc`, 128KB): 메인 봇이 상태 데이터(잔고, GPU, 매크로 지표 등)를 JSON으로 기록, 텔레그램 봇이 읽기
- **Command Queue** (`multiprocessing.Queue`): 텔레그램 → 메인 봇 양방향 명령 채널 (`restart`, `evaluate` 등)
- **Lock** (`multiprocessing.Lock`): SharedMemory 동시 접근 보호
- **IPC Staleness**: 600초 (10분 이상 오래된 데이터는 무시)
### 1.3 서버 생명주기 (Lifespan)
```python
# main_server.py > lifespan()
1. Config.validate() # 환경변수 검증
2. ProcessTracker.check_and_kill_zombies() # 좀비 프로세스 정리
3. 전역 객체 초기화 (OllamaManager, KISClient, NewsCollector)
4. Shared Resources 생성 (Lock, Queue, Event)
5. Trading Bot 프로세스 생성 & 시작
6. Telegram Bot 프로세스 생성 & 시작
7. ProcessWatchdog 시작 (30 간격 헬스체크)
8. yield (서버 정상 운영)
9. [종료] shutdown_event 설정 자식 종료 SharedMemory 해제
```
--- ---
## 2. 디렉토리 구조 ## 서버 시작 방식
### V1 단독 (운영 기본)
```bat
cd C:\Users\jaeoh\Desktop\workspace\web-ai
.\start.bat
``` ```
web-ai/ 기대 로그: `[Bot] Cycle Start ...`, `[AI] 005930: NN epochs ...`, `[Ensemble] tech=... news=... lstm=...`, `Score: 0.xx [HOLD]`
├── main_server.py # [Entry Point] FastAPI + 프로세스 매니저
├── warmup_and_restart.py # LSTM 사전학습 + 봇 자동 시작 스크립트 ### V2 단독 (smoke/검증)
├── watchlist_manager.py # 뉴스 기반 일일 Watchlist 자동 업데이트 ```bat
├── backtester.py # 전략 백테스팅 CLI cd C:\Users\jaeoh\Desktop\workspace\web-ai\signal_v2
├── theme_manager.py # 종목별 테마/섹터 관리 .\start.bat
├── .env # 환경변수 (KIS, Telegram, Ollama 등)
├── modules/
│ ├── __init__.py
│ ├── config.py # [Config] 환경변수 & 상수 정의
│ ├── bot.py # [Core] AutoTradingBot (상태 머신 & 스케줄러)
│ │
│ ├── analysis/ # [AI Brain] 분석 엔진
│ │ ├── deep_learning.py # Attention-LSTM (7D 피처, PyTorch GPU)
│ │ ├── technical.py # 기술적 지표 (RSI, MACD, BB, ADX, OBV...)
│ │ ├── macro.py # 거시경제 분석 (KOSPI/KOSDAQ/MSI)
│ │ ├── ensemble.py # 적응형 앙상블 (3신호 가중치 자동조정)
│ │ ├── evaluator.py # 주간 성과 평가 + LLM 전문가 패널
│ │ └── backtest.py # 백테스팅 프레임워크 (Sharpe, MDD 등)
│ │
│ ├── strategy/ # [Decision] 매매 의사결정
│ │ └── process.py # 워커 프로세스용 분석 함수 (병렬 처리)
│ │
│ ├── services/ # [I/O] 외부 서비스 연동
│ │ ├── kis.py # 한국투자증권 REST API (동기 + 비동기)
│ │ ├── ollama.py # Ollama LLM 인터페이스 (GPU 충돌 방지)
│ │ ├── news.py # Google News RSS 크롤링 (동기 + 비동기)
│ │ ├── telegram.py # 텔레그램 메시지 발송 (Fire-and-forget)
│ │ └── telegram_bot/
│ │ ├── server.py # 텔레그램 봇 서버 (명령어 핸들러)
│ │ └── runner.py # 텔레그램 봇 독립 프로세스 실행기
│ │
│ └── utils/ # [Util] 유틸리티
│ ├── ipc.py # SharedMemory + Command Queue IPC
│ ├── process_tracker.py # PID 추적 & 좀비 정리 & Watchdog
│ ├── monitor.py # CPU/GPU/RAM 서킷 브레이커
│ └── performance_db.py # 일별 스냅샷 & 매매 기록 영구 저장
├── data/ # [Runtime Data]
│ ├── watchlist.json # 현재 감시 종목 리스트
│ ├── daily_trade_history.json # 일일 매매 기록
│ ├── kis_token.json # KIS OAuth 토큰 캐시
│ ├── peak_prices.json # 트레일링 스탑용 최고가
│ ├── ensemble_history.json # AdaptiveEnsemble 가중치 + 매매 히스토리 (종목별)
│ ├── models/ # LSTM 체크포인트 (종목별 .pt 파일)
│ └── performance/ # 성과 데이터 (daily_snapshots, trade_records)
└── tests/ # 테스트
``` ```
기대 로그: `Uvicorn running on http://0.0.0.0:8001`, `poll_loop started`, `[KIS] minute bars ... OK`, `[Chronos] predicted N tickers`, `signal emit XXXXXX buy conf=0.xxx`.
휴장일/장 외 시간엔 `poll_loop` 만 idle. `Application startup complete` 만 보이면 정상.
### V1 + V2 동시 실행 — **권장 안 함**
**KIS app_key 초당 2회 한도 (EGW00201)** 충돌. V1 cycle + V2 분봉 cron 이 같은 KIS app_key 로 동시 호출하면 rate limit. 채택 해결책: V2 임시 종료 (Phase 3a 결정), Phase 6 V1 deprecation 시 자연 해소. 별도 app_key 발급은 옵션 B.
--- ---
## 3. 핵심 모듈 상세 ## Phase 진행 상태 (Confidence Signal Pipeline V2)
### 3.1 AutoTradingBot (`modules/bot.py`) | Phase | 내용 | 상태 |
|-------|------|------|
| 0 | Architecture & contract spec | ✅ Chronos-2 + Qwen3 14B 채택 |
| 1 | stock 백엔드 WebAI API 보강 (NAS) | ✅ 102/102 tests, 운영 배포 |
| 1.5 | V1 → `signal_v1/` rename | ✅ V1 정상 기동 |
| 2 | signal_v2 pull worker + signal API client + scheduler | ✅ 19/19 tests, `:8001` 기동 |
| 3a | KIS REST 분봉 + WebSocket 호가 + NXT 스케줄 | ✅ 33/33 tests |
| 3b | Chronos-bolt-base 추론 + 5분봉 모멘텀 분류기 | ✅ 45/45 tests, 실 KIS+Chronos chain 검증 |
| 4 | Signal Generator (매수/매도 룰) + pull_worker 통합 + 로깅 | ✅ **2026-05-17 완료, 56/56 tests, push 완료** |
| 5 | agent-office `/signal` + Ollama Qwen3 14B + 이중 텔레그램 | ⏳ 2주 예상 |
| 6 | signal_v1 deprecation | ⏳ 1주 |
| 7 | 운영 모니터링 + 4주 IC 검증 | ⏳ 1주 + 4주 |
**메인 트레이딩 루프** — 장 시작(09:00) ~ 장 마감(15:30) 사이에 자동 실행 상세 spec/plan: `../web-ui/docs/superpowers/specs/``../web-ui/docs/superpowers/plans/` (web-ui repo 안에 보관됨 — V2 자체 코드와 분리 보관).
```
[v3.1 주요 기능]
├── ATR 기반 동적 손절/익절 + 트레일링 스탑
├── Kelly Criterion 포지션 사이징 (실전 승률·손익비 기반, Half-Kelly)
├── AdaptiveEnsemble 연동 (매도 후 가중치 자동 학습)
├── 당일 누적 매수 추적 (_today_buy_total) - KIS T+2 미차감 보완
├── 사이클당 최대 매수 종목 수 제한 (MAX_BUY_PER_CYCLE)
├── ProcessPoolExecutor 병렬 분석 (워커 1개, OOM 대응 자동 재시작)
├── 일별 자산 스냅샷 (09:05~09:15)
├── 주간 성과 평가 (월요일 아침)
├── CPU 서킷 브레이커 연동
└── IPC Command Queue 폴링 (텔레그램 명령 처리)
```
**잔고 추적 로직 (v3.1 — 과매수 방지)**:
```
KIS get_balance() → raw_deposit (dnca_tot_amt)
max_daily_buy = raw_deposit × MAX_DAILY_BUY_RATIO (80%)
tracking_deposit = max_daily_buy - effective_today_buy
max(kis_today_buy, self._today_buy_total)
(KIS thdt_buy_amt vs 로컬 누적 중 큰 값)
```
- `_today_buy_total`: 인스턴스 변수, 사이클 간 유지 (09:00 리셋)
- `_buy_scores`: BUY 시 신호 점수 저장 → SELL 시 `record_trade()` 전달
**run_cycle() 흐름**:
1. 시스템 헬스 체크 (CPU/GPU/RAM)
2. 거시경제 분석 (KOSPI/KOSDAQ/MSI)
3. 위험 상태별 분기 (SAFE/CAUTION/DANGER)
4. Watchlist 종목 OHLCV 수집 (KIS 비동기 배치)
5. 잔고 조회 + 당일 누적 매수 차감 → 실제 가용 예수금 계산
6. `ProcessPoolExecutor`로 종목 병렬 분석 (Kelly Criterion + Ensemble 가중치)
7. 앙상블 점수 기반 매수/매도 판단 (사이클당 MAX_BUY_PER_CYCLE 제한)
8. 주문 실행 & 결과 텔레그램 알림
9. 매도 시 `record_trade()` → Ensemble 가중치 학습
10. IPC 상태 갱신
### 3.2 AI 분석 파이프라인
```
┌─────────────────────┐
│ analyze_stock_ │
│ process() │
│ (strategy/process)│
└─────────┬───────────┘
┌─────────────────────┼────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌────────────────┐ ┌─────────────────┐
│ Technical │ │ Deep Learning │ │ LLM (Ollama) │
│ Analyzer │ │ LSTM │ │ Sentiment │
│ (기술적 지표) │ │ (주가 예측) │ │ (뉴스 감성분석) │
├───────────────┤ ├────────────────┤ ├─────────────────┤
│ RSI 25% │ │ Attention-LSTM │ │ qwen2.5:7b │
│ 이격도 15% │ │ 4L×512H │ │ JSON 포맷 요청 │
│ MACD 15% │ │ 7차원 피처 │ │ 뉴스+지표 통합 │
│ Stochastic 5% │ │ 60일 시퀀스 │ │ 감성+신뢰도 │
│ BB 15% │ │ GPU 가속 │ │ │
│ ADX 15% │ │ 종목별 모델 │ │ │
│ MTF 10% │ │ (ModelRegistry)│ │ │
│ OBV ±보너스 │ │ │ │ │
└───────┬───────┘ └───────┬────────┘ └───────┬─────────┘
│ │ │
└──────────┬────────┘ │
▼ │
┌─────────────────┐ │
│ AdaptiveEnsemble│ ◄───────────────────┘
│ (학습형 가중치) │
├─────────────────┤
│ get_weights() │ ← 과거 매매 결과 반영
│ (ADX+macro+conf)│ 크기 가중 정확도 기준
│ 경계: 0.10~0.65 │ Water-Filling 정규화
│ Kelly Fraction │ ← 승률·손익비 기반
└────────┬────────┘
┌────────────────┐
│ 매수/매도/홀드 │
│ 최종 판단 │
└────────────────┘
```
#### 3.2.1 Deep Learning — Attention-LSTM (`analysis/deep_learning.py`)
| 항목 | 값 |
|------|-----|
| **아키텍처** | 4-Layer Stacked LSTM + Attention + FC |
| **Hidden Size** | 512 |
| **Input Features** | 7 (close, open, high, low, volume_norm, rsi_14, macd_hist) |
| **시퀀스 길이** | 60일 |
| **학습 에포크** | 최대 200 (Early Stopping patience=15) |
| **빠른 재학습** | 30 에포크 (체크포인트 존재 시) |
| **쿨다운** | 1200초 (20분, 동일 종목 재학습 방지) |
| **ModelRegistry** | LRU 방식, 최대 5개 모델 동시 적재 |
| **체크포인트** | `data/models/{ticker}_v3.pt` |
| **GPU 관리** | LSTM 학습 시 Ollama 자동 언로드/리로드 |
#### 3.2.2 기술적 분석 (`analysis/technical.py`)
`TechnicalAnalyzer.get_technical_score()` → 0.0 ~ 1.0 통합 점수
| 지표 | 비중 | 설명 |
|------|------|------|
| RSI (14일) | 25% | Wilder 방식, 30 이하 과매도/70 이상 과매수 |
| 이동평균 이격도 | 15% | 20일 MA 대비 현재가 위치 |
| MACD | 15% | 12/26/9, 히스토그램 방향 |
| Stochastic | 5% | Fast %K/%D (14/3/3) |
| Bollinger Bands | 15% | 20일/2σ, %B 위치 + 밴드폭 |
| ADX | 15% | 추세 강도 (>25 강한 추세) |
| Multi-Timeframe | 10% | 5일/20일/60일 추세 일관성 |
| OBV | ±0.1 보너스 | 거래량 기반 매집/분산 감지 |
추가 기능:
- `calculate_atr()` → ATR 기반 동적 손절/익절
- `calculate_dynamic_sl_tp()` → 변동성 적응형 SL/TP
- `calculate_obv()` → 스마트 머니 다이버전스 감지
#### 3.2.3 거시경제 분석 (`analysis/macro.py`)
```python
MacroAnalyzer.get_macro_status(kis_client) {
"status": "SAFE" | "CAUTION" | "DANGER",
"risk_score": int,
"indicators": {
"KOSPI": {"price", "change", "high", "low", "prev_close", "volume"},
"KOSDAQ": {"price", "change", ...},
"KOSPI200":{"price", "change", ...},
"MSI": float # Market Stress Index (0~100)
}
}
```
- **SAFE** (risk_score < 1): 정상 매매
- **CAUTION** (1 ≤ risk_score < 3): 매수 규모 축소
- **DANGER** (risk_score ≥ 3): 매수 중단, 보유분만 관리
#### 3.2.4 앙상블 (`analysis/ensemble.py`)
`AdaptiveEnsemble` — 과거 매매 결과 기반 가중치 자동 조정 + Kelly Criterion:
**가중치 학습 흐름**:
```
BUY 체결 → bot._buy_scores[ticker] = {tech, sentiment, lstm} 저장
SELL 체결 → ensemble.record_trade(ticker, ..., outcome_pct=yld)
→ _update_weights() → EMA(alpha=0.10) 가중치 점진 조정
→ _save() → data/ensemble_history.json
워커 프로세스 → reload_if_stale() → 파일 mtime 감지 시 재로드
```
**주요 메서드**:
- `get_weights(ticker, adx, macro_state, ai_confidence)``SignalWeights`
- 시장 컨텍스트 (strong_trend/sideways/danger/default) 별 기본 가중치
- 종목별 최근 10거래 크기 가중 정확도 반영
- ai_confidence >= 0.75 → LSTM 가중치 +25% (confidence 상한 0.80 반영)
- `get_kelly_fraction(ticker, half_kelly=True)` → 0.03~0.25 범위 투자 비중
- f* = (p·b - q) / b (p=승률, b=손익비)
- 거래 데이터 < 10건 → 보수적 기본값 8%
- Half-Kelly 적용으로 변동성 과대추정 보완
- `compute_ensemble_score(tech, sentiment, lstm, investor, weights)` → 통합 점수
- `reload_if_stale()` → 파일 mtime 기반 cross-process 동기화
**`SignalWeights.normalize()` — Water-Filling 알고리즘**:
- 경계(0.10~0.65) 위반 시 해당 값을 경계에 고정, 나머지에 잔여 비중 비례 배분
- 2차 정규화(합=1 보장)와 경계 클램핑이 상충하는 문제 해결
- 영구 저장: `data/ensemble_history.json` (가중치 + 매매 히스토리 통합)
#### 3.2.5 성과 평가 (`analysis/evaluator.py`)
`PerformanceEvaluator.generate_weekly_report()`:
- 핵심 지표: 총수익률, Sharpe Ratio, MDD, 승률, 평균손익비, KOSPI 상관도
- S/A/B/C/D/F 등급 산출
- **5명 전문가 LLM 패널** (Ollama): 각각 다른 관점으로 평가
- HTML 포맷 텔레그램 주간 보고서 자동 생성
--- ---
## 4. 외부 서비스 연동 ## signal_v2 디렉토리 내부
### 4.1 한국투자증권 KIS API (`services/kis.py`) | 파일 | 역할 |
#### 인증
```python
KISClient.ensure_token()
# OAuth 2.0 → access_token 발급 → data/kis_token.json에 캐시
# 토큰 만료 시 자동 갱신 (_request_api에서 처리)
```
| 설정 | 모의투자 | 실전투자 |
|------|---------|---------|
| Base URL | `openapivts.koreainvestment.com:29443` | `openapi.koreainvestment.com:9443` |
| 환경변수 | `KIS_VIRTUAL_APP_KEY/SECRET/ACCOUNT` | `KIS_REAL_APP_KEY/SECRET/ACCOUNT` |
| 전환 | `.env``KIS_ENV_TYPE=virtual` | `.env``KIS_ENV_TYPE=real` |
#### API 스로틀링
- 초당 2회 제한 (`_throttle()` — 0.5초 딜레이)
- 토큰 만료 시 자동 갱신 (403 → retry with new token)
#### 주요 API 엔드포인트 매핑
| 기능 | KISClient 메서드 | KIS TR_ID |
|------|-----------------|-----------|
| 잔고 조회 | `get_balance()``{holdings, total_eval, deposit, today_buy_amt}` | `VTTC8434R` (모의) / `TTTC8434R` (실전) |
| 주문 (매수/매도) | `order()` | `VTTC0802U` / `VTTC0801U` (모의) |
| 현재가 조회 | `get_current_price()` | `FHKST01010100` |
| 일봉 OHLCV | `get_daily_ohlcv()``_get_daily_ohlcv_by_range()` | `FHKST03010100` |
| 일봉 종가 | `get_daily_price()``_get_daily_price_by_range()` | `FHKST03010100` |
| 거래량 순위 | `get_volume_rank()` | `FHPST01710000` |
| 지수 현재가 | `get_current_index()` | `FHPUP02100000` |
| 지수 일봉 | `get_daily_index_price()` | `FHKUP03500100` |
| 투자자 동향 | `get_investor_trend()` | `FHKST01010900` |
| Hash Key | `get_hash_key()` | - |
#### 비동기 클라이언트 (`KISAsyncClient`)
`aiohttp` 기반 — 다중 종목 동시 수집용:
- `get_daily_price_batch()` — 여러 종목 일봉 병렬 수집
- `get_daily_ohlcv_batch()` — 여러 종목 OHLCV 병렬 수집
- `get_investor_trends_batch()` — 여러 종목 투자자 동향 병렬 수집
---
### 4.2 Ollama LLM (`services/ollama.py`)
| 설정 | 값 |
|------|-----|
| **모델** | `qwen2.5:7b-instruct-q4_K_M` (VRAM ~4GB) |
| **API URL** | `http://localhost:11434` |
| **Context Window** | 4096 토큰 |
| **Max Output** | 200 토큰 |
| **Temperature** | 0.1 (결정론적, JSON 안정성) |
| **Keep Alive** | 5분 (비활성 시 자동 언로드) |
| **Timeout** | 90초 |
| **CPU Threads** | 8 (9800X3D 최적화) |
| **응답 포맷** | JSON (format: "json") |
**GPU 충돌 방지**:
- LSTM 학습 중 → Ollama 추론 최대 60초 대기
- VRAM > 12GB → 모델 즉시 언로드 (`keep_alive=0`)
- LSTM 학습 전 → Ollama 자동 언로드, 학습 후 → 자동 리로드
---
### 4.3 뉴스 수집 (`services/news.py`)
- **소스**: Google News RSS (`news.google.com/rss/search`)
- **동기**: `NewsCollector.get_market_news()` — 시장 일반 뉴스 5건
- **비동기**: `AsyncNewsCollector`
- `get_market_news_async()` — 시장 뉴스 (5분 캐시)
- `get_stock_news_async()` — 종목별 뉴스 (5분 캐시)
---
## 5. 웹 백엔드 서버 API (FastAPI)
### 5.1 서버 정보
| 항목 | 값 |
|------|-----|
| **프레임워크** | FastAPI + Uvicorn |
| **호스트** | `0.0.0.0:8000` |
| **NAS 백엔드** | `http://192.168.45.54:18500` (웹 프론트엔드 서버) |
### 5.2 API 엔드포인트
#### `GET /` — 서버 상태
```json
{
"status": "online",
"gpu_vram": 4.2,
"service": "Windows AI Server (Refactored)"
}
```
#### `GET /trade/balance` | `GET /api/trade/balance` — 잔고 조회
KIS API를 통해 현재 계좌 잔고(예수금, 보유종목, 평가금액) 조회.
```json
{
"total_eval": 10500000,
"deposit": 5000000,
"holdings": [
{
"ticker": "005930",
"name": "삼성전자",
"qty": 10,
"avg_price": 72000,
"current_price": 73500,
"profit_rate": 2.08
}
]
}
```
#### `POST /trade/order` | `POST /api/trade/order` — 수동 주문
```json
// Request Body
{
"ticker": "005930",
"action": "BUY", // "BUY" | "SELL"
"quantity": 10
}
// Response
{
"status": "executed",
"kis_result": { ... }
}
```
#### `POST /analyze/portfolio` | `POST /api/analyze/portfolio` — AI 포트폴리오 분석
현재 잔고 + 최신 뉴스를 종합하여 Ollama LLM으로 포트폴리오 분석.
```json
{
"analysis": "... AI 분석 결과 (한국어) ..."
}
```
### 5.3 NAS 서버와의 통신 흐름
```
┌──────────────┐ HTTP Request ┌────────────────────┐
│ NAS Backend │ ─────────────────────► │ Windows AI Server │
│ (웹 프론트) │ │ (FastAPI:8000) │
│ :18500 │ ◄──────────────────── │ │
│ │ JSON Response │ │
└──────────────┘ └────────────────────┘
[통신 시나리오]
1. 웹 → /api/trade/balance → 잔고 데이터 표시
2. 웹 → /api/trade/order → 수동 매수/매도 실행
3. 웹 → /api/analyze/portfolio → AI 분석 결과 표시
4. 웹 → / → 서버 상태 및 GPU 정보
```
- **NAS 서버** (`192.168.45.54:18500`): 웹 프론트엔드 호스팅, 사용자 인터페이스 제공
- **Windows AI 서버** (`0.0.0.0:8000`): GPU 연산, KIS API 통신, AI 분석 처리
- 내부 네트워크 (LAN) 통신, 외부 노출 없음
---
## 6. 텔레그램 봇 설정 & 명령어
### 6.1 환경변수
```env
TELEGRAM_BOT_TOKEN=8546032918:AAF5GJcP92DrtpSoQdaimMIZe7bz_xtGGPo
TELEGRAM_CHAT_ID=7388056964
```
### 6.2 봇 프로세스 아키텍처
```
runner.py
└── run_telegram_bot_standalone()
├── SharedIPC 초기화 (lock, queue, shutdown_event)
├── TelegramBotServer 생성
├── IPC에서 초기 데이터 로드
├── bot_server.run() (python-telegram-bot polling)
└── Conflict 감지 시 백오프 재시도 (최대 10회)
```
- **라이브러리**: `python-telegram-bot` (Application, CommandHandler)
- **메시지 포맷**: HTML (`parse_mode="HTML"`)
- **동시 업데이트**: `concurrent_updates=True`
- **로깅**: `telegram_bot.log` (파일 + 콘솔)
### 6.3 명령어 목록
| 명령어 | 설명 | 데이터 소스 |
|--------|------|------------|
| `/start` | 봇 시작 & 전체 명령어 안내 | - |
| `/status` | 봇 상태, 시장 지수, AI 모델 상태 | IPC (SharedMemory) |
| `/portfolio` | 보유 종목 & 수익률 조회 | IPC → FakeKIS.get_balance() |
| `/watchlist` | 현재 감시 종목 리스트 | IPC → watchlist 데이터 |
| `/update_watchlist` | Watchlist 즉시 업데이트 요청 | Command Queue → 메인 봇 |
| `/macro` | 거시경제 분석 (KOSPI/KOSDAQ/MSI) | IPC → macro_indices |
| `/system` | CPU/GPU/RAM 시스템 상태 | IPC → gpu_status + psutil |
| `/ai` | AI 모델 상태 (VRAM, 학습 여부) | IPC → gpu_status |
| `/restart` | 메인 봇 재시작 명령 | Command Queue |
| `/stop` | 봇 종료 | shutdown_event.set() |
| `/exec <cmd>` | 서버 쉘 명령어 직접 실행 | subprocess (10초 타임아웃) |
| `/evaluate` | 즉시 성과 평가 보고서 생성 | PerformanceEvaluator |
### 6.4 TelegramMessenger (`services/telegram.py`)
단방향 알림 전용 (메인 봇 → 사용자):
- **비동기 전송**: `threading.Thread(daemon=True)` — Fire-and-forget
- **HTML 파싱**: 마크다운 에러 방지
- 매매 실행, 서버 시작/종료, 에러 알림 등에 사용
### 6.5 Conflict 처리
텔레그램 봇 API는 동시에 하나의 polling 인스턴스만 허용:
- `Conflict` 에러 감지 시 지수 백오프 (5s → 10s → ... → 30s)
- 최대 10회 재시도 후 프로세스 종료
- Watchdog가 감지하여 자동 재시작
---
## 7. 환경 설정 (`modules/config.py`)
### 7.1 주요 설정 상수
| 그룹 | 키 | 값 | 설명 |
|------|-----|-----|------|
| **매매** | `MAX_INVESTMENT_PER_STOCK` | 3,000,000원 | 종목당 최대 투자금 |
| **매매** | `MAX_BUY_PER_CYCLE` | 2 | 사이클당 최대 매수 종목 수 (env: `MAX_BUY_PER_CYCLE`) |
| **매매** | `MAX_DAILY_BUY_RATIO` | 0.80 | 예수금 대비 일일 최대 매수 비율 (env: `MAX_DAILY_BUY_RATIO`) |
| **IPC** | `SHM_NAME` | `web_ai_bot_ipc` | SharedMemory 이름 |
| **IPC** | `SHM_SIZE` | 131,072 (128KB) | SharedMemory 크기 |
| **IPC** | `IPC_STALENESS` | 600초 | 데이터 유효 기간 |
| **GPU** | `VRAM_WARNING_THRESHOLD` | 12.0 GB | VRAM 경고 임계값 |
| **프로세스** | `WATCHDOG_INTERVAL` | 30초 | 헬스체크 간격 |
| **프로세스** | `MAX_RESTART_COUNT` | 3 | 최대 자동 재시작 횟수 |
| **LSTM** | `LSTM_COOLDOWN` | 1,200초 | 동일 종목 재학습 방지 |
| **LSTM** | `LSTM_FAST_EPOCHS` | 30 | 빠른 재학습 에포크 |
| **CPU** | `CPU_CIRCUIT_BREAKER_THRESHOLD` | 92% | 서킷 브레이커 임계값 |
| **CPU** | `CPU_CIRCUIT_BREAKER_CONSECUTIVE` | 2회 | 연속 초과 시 발동 |
| **Ollama** | `OLLAMA_NUM_CTX` | 4,096 | 컨텍스트 윈도우 |
| **Ollama** | `OLLAMA_NUM_PREDICT` | 200 | 최대 출력 토큰 |
| **Ollama** | `OLLAMA_NUM_THREAD` | 8 | CPU 스레드 수 |
| **Network** | `HTTP_TIMEOUT` | 10초 | 기본 HTTP 요청 타임아웃 |
### 7.2 .env 파일 구조
```env
# NAS Backend (웹 프론트엔드 서버)
NAS_API_URL=http://192.168.45.54:18500
# Ollama LLM
OLLAMA_API_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5:7b-instruct-q4_K_M
# KIS API (virtual/real 전환)
KIS_ENV_TYPE=virtual
KIS_REAL_APP_KEY=...
KIS_REAL_APP_SECRET=...
KIS_REAL_ACCOUNT=XXXXXXXX-XX
KIS_VIRTUAL_APP_KEY=...
KIS_VIRTUAL_APP_SECRET=...
KIS_VIRTUAL_ACCOUNT=XXXXXXXX-XX
# Telegram Bot
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
```
---
## 8. 운영 가이드
### 8.1 시작 방법
```bash
# 일반 시작
python main_server.py
# LSTM 사전학습 후 자동 시작
python warmup_and_restart.py
# 텔레그램 봇만 단독 실행 (디버깅용)
python -m modules.services.telegram_bot.runner
```
### 8.2 좀비 프로세스 관리
- `main_server.py` 실행 시 자동으로 이전 좀비 프로세스 정리
- `pids.txt` 기반 → 메모리 기반 PID 추적으로 전환 완료
- 수동 확인: `Get-Process python` (PowerShell)
### 8.3 로그 파일
| 파일 | 용도 |
|------|------| |------|------|
| `server.log` | Uvicorn 서버 로그 | | `main.py` | FastAPI app + lifespan (StockClient + KISClient + KISWebSocket + ChronosPredictor + SignalDedup 초기화). poll_loop task 생성 |
| `telegram_bot.log` | 텔레그램 봇 로그 | | `config.py` | Settings dataclass — 환경변수 로드. Phase 4 추가 6 필드: `stop_loss_pct`, `take_profit_pct`, `chronos_spread_threshold`, `asking_bid_ratio_threshold`, `confidence_threshold`, `min_momentum_for_buy` |
| `warmup.log` | LSTM 사전학습 진행 로그 | | `state.py` | PollState (process-wide singleton) — portfolio, screener_preview, news_sentiment, chronos_predictions, minute_bars, asking_price, **signals** (Phase 4) |
| `bot_output.log` | 트레이딩 봇 출력 로그 | | `stock_client.py` | NAS stock 백엔드 pull (X-WebAI-Key + 메모리 cache 60s/300s/60s + retry) |
| `kis_client.py` | KIS REST 분봉/호가 — V1 토큰 read-only 공유 (mtime cache) + 초당 2회 throttle + 지수 backoff |
### 8.4 트러블슈팅 | `kis_websocket.py` | KIS WebSocket H0STASP0 호가 + approval_key + 재연결 (1→2→4→max 30s) |
| `chronos_predictor.py` | `amazon/chronos-bolt-base` zero-shot quantile (FP32 강제 — FP16 overflow 회피) |
| 증상 | 원인 | 해결 | | `minute_momentum.py` | 5분봉 → strong_up/weak_up/neutral/weak_down/strong_down 5단계 분류 |
|------|------|------| | `signal_generator.py` | **Phase 4 — 매수/매도 룰 엔진**. `generate_signals(state, dedup, settings)` 진입. sell-first → buy 순서. 신호 emit/skip INFO/DEBUG 로그 |
| KIS 403 Forbidden | 토큰 만료 또는 Rate Limit | `data/kis_token.json` 삭제 후 재시작 | | `pull_worker.py` | asyncio cron — 장전 5분 / 장중 1분 / 장후 5분 / NXT / dead zone skip. cycle 끝에 `generate_signals` 호출 |
| Telegram Conflict | 이전 봇 프로세스 미종료 | `main_server.py` 재시작 (자동 정리) | | `scheduler.py` | polling window 판정 (KST 캘린더 + 휴장일) |
| GPU OOM | LSTM + Ollama 동시 적재 | `VRAM_WARNING_THRESHOLD` 낮추기 | | `rate_limit.py` | 초당 N회 token bucket |
| CPU 100% 고정 | 좀비 워커 프로세스 | `main_server.py` 재시작 | | `dedup.py` | SignalDedup SQLite WAL — `(ticker, action)` PK 24h |
| IPC 데이터 오래됨 | 메인 봇 크래시 | Watchdog 자동 재시작 확인, 수동 재시작 | | `tests/` | 56 tests (pytest + respx HTTP mock + monkeypatch) |
| 예수금 초과 매수 | KIS 모의투자 T+2 미차감 | `MAX_DAILY_BUY_RATIO` / `MAX_BUY_PER_CYCLE` 조정 | | `data/` | dedup.db (SQLite WAL) + `holidays.json` (NAS stock 에서 manual copy) |
| Kelly 비중이 너무 낮음 | 거래 기록 부족 (< 10건) | 초기에는 기본값 8% 사용, 거래 누적 후 자동 조정 | | `start.bat` | V2 진입 |
| 앙상블 가중치 갱신 안 됨 | 매도 체결 없음 또는 `_buy_scores` 누락 | 봇 재시작 전 매도 완료 확인; `data/ensemble_history.json` 확인 |
--- ---
## 9. 데이터 흐름 요약 ## 신호 룰 요약 (Phase 4)
``` ### 매수 (screener Top-N + portfolio, sell 신호 받은 종목은 skip)
[시장 개장 전] 모두 충족:
WatchlistManager → 뉴스 분석 → Watchlist 갱신 1. `chronos.median > 0`
2. **`chronos.q90 - chronos.q10 < 0.6`** (absolute spread — 2026-05-17 spec amend, 기존 relative formula 가 zero-shot median≈0 빈번에서 모든 신호 거부)
3. `minute_momentum == strong_up` (env 로 조정 가능)
4. `asking_price.bid_ratio >= 0.6`
[장중 사이클 (≈5분 간격)] 종합 confidence = `chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2`. `> 0.7` 시 emit.
1. SystemMonitor.check_health() → CPU/GPU 확인
2. MacroAnalyzer.get_macro_status() → 시장 상태 판단
3. KIS → get_balance() → raw_deposit - today_buy_total = 가용 예수금
4. KIS → get_daily_ohlcv_batch() → OHLCV 수집
5. ProcessPool → analyze_stock_process() × N종목
├── ensemble.reload_if_stale() → 파일 mtime 감지 시 가중치 재로드
├── TechnicalAnalyzer → 기술적 점수
├── PricePredictor → LSTM 예측
├── OllamaManager → LLM 감성 분석
├── AdaptiveEnsemble.get_weights() → 학습된 동적 가중치
└── calculate_position_size() → Kelly Criterion 수량 산출
6. 매수 판단 → 예수금 확인 → KIS 주문
├── _buy_scores[ticker] 저장 (앙상블 학습용)
├── _today_buy_total += 매수금액
└── buys_this_cycle++ (MAX_BUY_PER_CYCLE 제한)
7. 매도 판단 → KIS 주문
└── ensemble.record_trade() → 가중치 학습 + ensemble_history.json 저장
8. SharedIPC.write_status() → 텔레그램 봇에 공유
9. TelegramMessenger → 결과 알림
[장 마감 후] ### 매도 (portfolio only, 우선순위 stop_loss → anomaly → take_profit)
PerformanceDB.save_daily_snapshot() → 일별 자산 기록 - **stop_loss**: `pnl_pct < -7%` 즉시 (confidence=1.0)
Evaluator → 주간 보고서 (월요일) - **anomaly**: `chronos.median < -1%` + `strong_down` + `bid_ratio < 0.4` + 종합 conf > 0.7
``` - **take_profit**: `pnl_pct > 15%` 검토 (confidence=0.6)
--- ---
## 10. 버전 변경 이력 ## 알려진 함정 / Phase 7 백로그
### v3.1 (2026-03-19) — 잔고 관리 & 앙상블 학습 완성 1. **KIS rate limit (EGW00201)** — V1+V2 동시 실행 시 충돌. Phase 6 자연 해소
2. **`.venv` 한글 경로 깨짐** — 시스템 Python 사용
3. **Chronos FP16 overflow** — 한국 주가 5만+ 시 inf. FP32 강제 (`chronos_predictor.py:39-41`)
4. **`predict_quantiles` positional `inputs`** — ChronosBolt API 새 변경. `try/except TypeError` fallback 처리됨
5. **`state.signals` consumer-drain protocol 미정의** — Phase 5 prereq. dict 무한 누적 위험 (실제로는 bounded by unique ticker count)
6. **integration test 가 poll_loop 실제 호출 안 함**`test_pull_worker.py:test_poll_loop_calls_generate_signals_after_cycle``generate_signals` 직접 호출. Phase 7 hardening 시 mock-iteration 으로 강화
7. **KIS WebSocket URL `ws://ops.koreainvestment.com:21000/31000`** — 첫 운영 시 실제 KIS API docs 와 대조 필요
8. **`_parse_asking_price` 필드 인덱스** — 마지막 2 필드 가정. 실 운영 raw 메시지 캡처 후 매핑 검증 필요
9. **`holidays.json` 자동 동기화 부재** — NAS stock 의 `holidays.json` 을 수동 copy
10. **schema rename** — Phase 0 §5.2 의 `lstm_pred_*`, `news_top[]``chronos_pred_*`, `news_reason(string)` 으로 변경됨. Phase 5 prompt 작성 시 반영
11. **6개 env 필드가 `.env` 에 미기재** — 기본값으로 동작 가능하나 discoverability 위해 `.env.example` 또는 commented block 추가 권장
**버그 수정**: ---
- `tracking_deposit` 사이클 간 초기화 문제 → `_today_buy_total` 인스턴스 변수로 누적 추적
- KIS 모의투자 T+2 미차감으로 인한 예수금 초과 매수 방지
- `ai_confidence >= 0.85` 임계값 버그 (LSTM confidence 상한 0.80 미반영) → 0.75로 수정
- OHLCV 피처 누락 시 silent fallback → 경고 로그 출력
**신규 기능**: ## 다음 단계 (Phase 5 진입 시 brainstorming 주제)
- `MAX_BUY_PER_CYCLE`: 사이클당 최대 매수 종목 수 제한 (기본 2)
- `MAX_DAILY_BUY_RATIO`: 예수금 대비 일일 최대 매수 비율 (기본 80%)
- `kis.get_balance()``today_buy_amt` 필드 추가 (`thdt_buy_amt`)
**앙상블 (`analysis/ensemble.py`)**: - `state.signals` consumer 패턴: pop vs leave + Phase 5 자체 dedup
- `AdaptiveEnsemble``process.py`에 실제 연동 (하드코딩 가중치 제거) - agent-office 의 `/signal` endpoint 설계 — POST 페이로드 schema
- `get_kelly_fraction()`: Half-Kelly Criterion 포지션 비중 계산 추가 - Ollama Qwen3 14B Q4 로컬 호출 — 타임아웃, retry, VRAM 공존 (Chronos + Qwen3 동시 메모리 9.3GB / 15.5GB 가용)
- `SignalWeights.normalize()`: Water-Filling 알고리즘으로 경계 위반 문제 해결 - 이중 텔레그램 (본인 풀 / 아내 lite) — context augmentation 단일 호출에서 양쪽 메시지 생성
- `_accuracy()` 이진 지표 제거 → `_accuracy_weighted()` (크기 가중) 통일 - LLM 비용: ₩0 목표 유지 (로컬)
- `reload_if_stale()`: 파일 mtime 기반 cross-process 동기화
**포지션 사이징 (`strategy/process.py`)**: ---
- `calculate_position_size()`: 하드코딩 10% → Kelly Criterion (과거 승률·손익비 기반)
- `bot.py` 중복 계산 제거 → 워커의 `suggested_qty` 직접 사용
**앙상블 학습 루프 (`bot.py`)**: ## 양쪽 디렉토리 (web-ui ↔ web-ai) 작업 시 주의
- BUY 체결 시 `_buy_scores[ticker]` 신호 점수 저장
- SELL 체결 시 `ensemble.record_trade()``ensemble_history.json` 갱신 - **코드**: signal_v2 는 web-ai/, spec/plan/메모리는 web-ui/
- 워커 프로세스는 `reload_if_stale()`로 자동 반영 - **커밋**: `web-ai``web-ui`**별도 Gitea 저장소**. 각각 경로에서만 `git add/commit/push`
- **메모리**: Claude Code 의 auto-memory 는 디렉토리별 격리. 핵심 reference 는 양쪽에 미러됨 (`./memory-mirror/` 또는 `~/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ai/memory/`)
- **spec amendment 발생 시**: 코드는 `web-ai` 에 commit, spec 갱신은 `web-ui/docs/superpowers/specs/` 에 commit (Phase 4 spread formula 변경 사례 = web-ui commit `534ded5`)
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조 (있다면).

View File

@@ -1,155 +0,0 @@
"""
daily_launcher.py — KRX 거래일 자동 런처
[동작 흐름]
1. 오늘이 KRX 거래일인지 확인
2. 휴장일이면: 텔레그램 알림 후 종료
3. 거래일이면: LSTM 워밍업 → main_server.py 시작
4. 봇은 15:35에 스스로 EOD 셧다운
[설치: Windows 작업 스케줄러]
트리거: 매일 08:30 (주말 포함 — 봇이 내부에서 휴장일 체크)
동작: python C:\\path\\to\\web-ai\\daily_launcher.py
시작 위치: C:\\path\\to\\web-ai
실행 계정: 현재 사용자 (로그인 여부 무관 실행 권장)
[수동 실행]
python daily_launcher.py
"""
import sys
import time
import subprocess
import datetime
import logging
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).parent
LOG_FILE = ROOT / "daily_launcher.log"
KST = ZoneInfo("Asia/Seoul")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [Launcher] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("daily_launcher")
def setup_path():
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def send_notify(msg: str):
"""텔레그램 알림 발송 (실패해도 런처 계속 진행)"""
try:
from modules.services.telegram import TelegramMessenger
TelegramMessenger().send_message(msg)
except Exception as e:
log.warning(f"텔레그램 알림 실패: {e}")
def clear_eod_marker():
"""전일 EOD 마커 파일 삭제 (새 거래일 시작)"""
eod_file = ROOT / "data" / ".eod_date"
if not eod_file.exists():
return
try:
prev = datetime.date.fromisoformat(eod_file.read_text().strip())
today = datetime.datetime.now(KST).date()
if prev < today:
eod_file.unlink()
log.info(f"전일({prev}) EOD 마커 삭제 완료")
except Exception:
eod_file.unlink(missing_ok=True)
def wait_until_warmup_time(cal) -> None:
"""
워밍업 시작 시각까지 대기
- 장 시작 30분 전이면 즉시 워밍업
- 그보다 일찍 실행되면 '장 시작 30분 전'까지 대기
"""
secs = cal.seconds_to_open()
if secs <= 0:
log.info("이미 장 중 — 즉시 워밍업 시작")
return
warmup_start_secs = max(0, secs - 30 * 60) # 장 시작 30분 전
if warmup_start_secs > 0:
warmup_at = datetime.datetime.now(KST) + datetime.timedelta(seconds=warmup_start_secs)
log.info(f"워밍업 대기 중 ({warmup_start_secs/60:.0f}분 후 {warmup_at.strftime('%H:%M')} 시작)")
time.sleep(warmup_start_secs)
else:
log.info(f"장 시작 {secs/60:.0f}분 전 — 즉시 워밍업")
def run_warmup_and_server() -> int:
"""
warmup_and_restart.py 실행
- warmup: LSTM 사전학습
- 이후 main_server.py를 새 콘솔에서 자동 시작
"""
log.info("LSTM 워밍업 시작...")
result = subprocess.run(
[sys.executable, "warmup_and_restart.py"],
cwd=str(ROOT),
)
return result.returncode
def main():
setup_path()
from modules.utils.market_calendar import KRXCalendar
cal = KRXCalendar()
today = datetime.datetime.now(KST).date()
log.info(f"실행 날짜: {today} | 시장 상태: {cal.status_summary()}")
# ── 1. 휴장일 체크 ────────────────────────────────────────────────────────
if not cal.is_trading_day(today):
try:
nxt = cal.next_trading_open()
next_str = nxt.strftime("%m/%d(%a) %H:%M")
except Exception:
next_str = "미정"
msg = (
f"[자동매매] {today.strftime('%m/%d(%a)')} 휴장일\n"
f"다음 거래일: {next_str} KST 자동 시작"
)
log.info(f"휴장일 — 봇 시작 안 함 (다음: {next_str})")
send_notify(msg)
return
# ── 2. EOD 마커 초기화 ────────────────────────────────────────────────────
clear_eod_marker()
# ── 3. 워밍업 시각까지 대기 ───────────────────────────────────────────────
wait_until_warmup_time(cal)
# ── 4. 거래일 시작 알림 ───────────────────────────────────────────────────
log.info(f"거래일 확인 — 워밍업 및 봇 시작 ({datetime.datetime.now(KST).strftime('%H:%M')})")
send_notify(
f"[자동매매] {today.strftime('%m/%d(%a)')} 거래일 시작\n"
f"LSTM 워밍업 중..."
)
# ── 5. 워밍업 + 서버 시작 ─────────────────────────────────────────────────
rc = run_warmup_and_server()
if rc != 0:
log.error(f"워밍업 실패 (exit={rc}) — 수동 확인 필요")
send_notify(f"[Bot] 워밍업 실패! (exit={rc})\n수동으로 확인해 주세요.")
return
log.info("워밍업 완료. main_server.py가 백그라운드에서 실행 중.")
log.info("봇은 15:35에 스스로 EOD 셧다운합니다.")
if __name__ == "__main__":
main()

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
# Signal V2 dependencies (added 2026-05-16, Phase 2)
httpx>=0.27
fastapi>=0.110
uvicorn>=0.27
python-dotenv>=1.0
pytest>=8.0
pytest-asyncio>=0.23
respx>=0.21
websockets>=12
# Phase 3b dependencies (Chronos-2 + ML)
transformers>=4.40
chronos-forecasting>=1.4
# torch: typically already installed via V1 venv; if not, install with CUDA support manually

696
signal_v1/CLAUDE.md Normal file
View File

@@ -0,0 +1,696 @@
# 🤖 AI Trading Bot — 프로젝트 설계 문서 (CLAUDE.md)
> **최종 갱신**: 2026-03-19
> **런타임**: Windows (Python 3.x, PyTorch CUDA, FastAPI, Ollama)
> **하드웨어**: AMD 9800X3D + RTX 5070 Ti (16 GB VRAM)
---
## 1. 시스템 아키텍처 개요
```
┌──────────────────────────────────────────────────────────────────┐
│ main_server.py │
│ FastAPI (uvicorn, port 8000) — 프로세스 매니저 & REST API 서버 │
│ ┌──────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
│ │ Trading Bot │ │ Telegram Bot │ │ ProcessWatchdog │ │
│ │ (Process #1) │ │ (Process #2) │ │ (Daemon Thread) │ │
│ └──────┬───────┘ └────────┬────────┘ └──────────┬───────────┘ │
│ │ │ │ │
│ └─── Shared Memory (IPC) ───┘ Health Check / Restart │
│ + Command Queue │
└──────────────────────────────────────────────────────────────────┘
```
### 1.1 멀티 프로세스 구성
| 프로세스 | 역할 | 진입점 |
|---------|------|--------|
| **Main Server (Uvicorn)** | FastAPI REST API 서버, 프로세스 오케스트레이터 | `main_server.py` |
| **Trading Bot** | 자동매매 메인 루프 (스케줄러, 분석, 주문) | `modules/bot.py``AutoTradingBot.loop()` |
| **Telegram Bot** | 사용자 인터랙션 (명령어 처리, 알림) | `modules/services/telegram_bot/runner.py` |
| **ProcessWatchdog** | 자식 프로세스 헬스체크 & 자동 재시작 (30초 간격) | `modules/utils/process_tracker.py` |
### 1.2 프로세스 간 통신 (IPC)
```
┌─────────────┐ SharedMemory (128KB) ┌──────────────┐
│ Trading Bot │ ─── write_status() ───────► │ Telegram Bot │
│ │ ◄── read_status() ──────── │ │
│ │ │ │
│ │ multiprocessing.Queue │ │
│ │ ◄── send_command() ──────── │ │
│ │ (텔레그램 → 봇 명령) │ │
└─────────────┘ └──────────────┘
```
- **SharedMemory** (`web_ai_bot_ipc`, 128KB): 메인 봇이 상태 데이터(잔고, GPU, 매크로 지표 등)를 JSON으로 기록, 텔레그램 봇이 읽기
- **Command Queue** (`multiprocessing.Queue`): 텔레그램 → 메인 봇 양방향 명령 채널 (`restart`, `evaluate` 등)
- **Lock** (`multiprocessing.Lock`): SharedMemory 동시 접근 보호
- **IPC Staleness**: 600초 (10분 이상 오래된 데이터는 무시)
### 1.3 서버 생명주기 (Lifespan)
```python
# main_server.py > lifespan()
1. Config.validate() # 환경변수 검증
2. ProcessTracker.check_and_kill_zombies() # 좀비 프로세스 정리
3. 전역 객체 초기화 (OllamaManager, KISClient, NewsCollector)
4. Shared Resources 생성 (Lock, Queue, Event)
5. Trading Bot 프로세스 생성 & 시작
6. Telegram Bot 프로세스 생성 & 시작
7. ProcessWatchdog 시작 (30 간격 헬스체크)
8. yield (서버 정상 운영)
9. [종료] shutdown_event 설정 자식 종료 SharedMemory 해제
```
---
## 2. 디렉토리 구조
```
web-ai/
├── main_server.py # [Entry Point] FastAPI + 프로세스 매니저
├── warmup_and_restart.py # LSTM 사전학습 + 봇 자동 시작 스크립트
├── watchlist_manager.py # 뉴스 기반 일일 Watchlist 자동 업데이트
├── backtester.py # 전략 백테스팅 CLI
├── theme_manager.py # 종목별 테마/섹터 관리
├── .env # 환경변수 (KIS, Telegram, Ollama 등)
├── modules/
│ ├── __init__.py
│ ├── config.py # [Config] 환경변수 & 상수 정의
│ ├── bot.py # [Core] AutoTradingBot (상태 머신 & 스케줄러)
│ │
│ ├── analysis/ # [AI Brain] 분석 엔진
│ │ ├── deep_learning.py # Attention-LSTM (7D 피처, PyTorch GPU)
│ │ ├── technical.py # 기술적 지표 (RSI, MACD, BB, ADX, OBV...)
│ │ ├── macro.py # 거시경제 분석 (KOSPI/KOSDAQ/MSI)
│ │ ├── ensemble.py # 적응형 앙상블 (3신호 가중치 자동조정)
│ │ ├── evaluator.py # 주간 성과 평가 + LLM 전문가 패널
│ │ └── backtest.py # 백테스팅 프레임워크 (Sharpe, MDD 등)
│ │
│ ├── strategy/ # [Decision] 매매 의사결정
│ │ └── process.py # 워커 프로세스용 분석 함수 (병렬 처리)
│ │
│ ├── services/ # [I/O] 외부 서비스 연동
│ │ ├── kis.py # 한국투자증권 REST API (동기 + 비동기)
│ │ ├── ollama.py # Ollama LLM 인터페이스 (GPU 충돌 방지)
│ │ ├── news.py # Google News RSS 크롤링 (동기 + 비동기)
│ │ ├── telegram.py # 텔레그램 메시지 발송 (Fire-and-forget)
│ │ └── telegram_bot/
│ │ ├── server.py # 텔레그램 봇 서버 (명령어 핸들러)
│ │ └── runner.py # 텔레그램 봇 독립 프로세스 실행기
│ │
│ └── utils/ # [Util] 유틸리티
│ ├── ipc.py # SharedMemory + Command Queue IPC
│ ├── process_tracker.py # PID 추적 & 좀비 정리 & Watchdog
│ ├── monitor.py # CPU/GPU/RAM 서킷 브레이커
│ └── performance_db.py # 일별 스냅샷 & 매매 기록 영구 저장
├── data/ # [Runtime Data]
│ ├── watchlist.json # 현재 감시 종목 리스트
│ ├── daily_trade_history.json # 일일 매매 기록
│ ├── kis_token.json # KIS OAuth 토큰 캐시
│ ├── peak_prices.json # 트레일링 스탑용 최고가
│ ├── ensemble_history.json # AdaptiveEnsemble 가중치 + 매매 히스토리 (종목별)
│ ├── models/ # LSTM 체크포인트 (종목별 .pt 파일)
│ └── performance/ # 성과 데이터 (daily_snapshots, trade_records)
└── tests/ # 테스트
```
---
## 3. 핵심 모듈 상세
### 3.1 AutoTradingBot (`modules/bot.py`)
**메인 트레이딩 루프** — 장 시작(09:00) ~ 장 마감(15:30) 사이에 자동 실행
```
[v3.1 주요 기능]
├── ATR 기반 동적 손절/익절 + 트레일링 스탑
├── Kelly Criterion 포지션 사이징 (실전 승률·손익비 기반, Half-Kelly)
├── AdaptiveEnsemble 연동 (매도 후 가중치 자동 학습)
├── 당일 누적 매수 추적 (_today_buy_total) - KIS T+2 미차감 보완
├── 사이클당 최대 매수 종목 수 제한 (MAX_BUY_PER_CYCLE)
├── ProcessPoolExecutor 병렬 분석 (워커 1개, OOM 대응 자동 재시작)
├── 일별 자산 스냅샷 (09:05~09:15)
├── 주간 성과 평가 (월요일 아침)
├── CPU 서킷 브레이커 연동
└── IPC Command Queue 폴링 (텔레그램 명령 처리)
```
**잔고 추적 로직 (v3.1 — 과매수 방지)**:
```
KIS get_balance() → raw_deposit (dnca_tot_amt)
max_daily_buy = raw_deposit × MAX_DAILY_BUY_RATIO (80%)
tracking_deposit = max_daily_buy - effective_today_buy
max(kis_today_buy, self._today_buy_total)
(KIS thdt_buy_amt vs 로컬 누적 중 큰 값)
```
- `_today_buy_total`: 인스턴스 변수, 사이클 간 유지 (09:00 리셋)
- `_buy_scores`: BUY 시 신호 점수 저장 → SELL 시 `record_trade()` 전달
**run_cycle() 흐름**:
1. 시스템 헬스 체크 (CPU/GPU/RAM)
2. 거시경제 분석 (KOSPI/KOSDAQ/MSI)
3. 위험 상태별 분기 (SAFE/CAUTION/DANGER)
4. Watchlist 종목 OHLCV 수집 (KIS 비동기 배치)
5. 잔고 조회 + 당일 누적 매수 차감 → 실제 가용 예수금 계산
6. `ProcessPoolExecutor`로 종목 병렬 분석 (Kelly Criterion + Ensemble 가중치)
7. 앙상블 점수 기반 매수/매도 판단 (사이클당 MAX_BUY_PER_CYCLE 제한)
8. 주문 실행 & 결과 텔레그램 알림
9. 매도 시 `record_trade()` → Ensemble 가중치 학습
10. IPC 상태 갱신
### 3.2 AI 분석 파이프라인
```
┌─────────────────────┐
│ analyze_stock_ │
│ process() │
│ (strategy/process)│
└─────────┬───────────┘
┌─────────────────────┼────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌────────────────┐ ┌─────────────────┐
│ Technical │ │ Deep Learning │ │ LLM (Ollama) │
│ Analyzer │ │ LSTM │ │ Sentiment │
│ (기술적 지표) │ │ (주가 예측) │ │ (뉴스 감성분석) │
├───────────────┤ ├────────────────┤ ├─────────────────┤
│ RSI 25% │ │ Attention-LSTM │ │ qwen2.5:7b │
│ 이격도 15% │ │ 4L×512H │ │ JSON 포맷 요청 │
│ MACD 15% │ │ 7차원 피처 │ │ 뉴스+지표 통합 │
│ Stochastic 5% │ │ 60일 시퀀스 │ │ 감성+신뢰도 │
│ BB 15% │ │ GPU 가속 │ │ │
│ ADX 15% │ │ 종목별 모델 │ │ │
│ MTF 10% │ │ (ModelRegistry)│ │ │
│ OBV ±보너스 │ │ │ │ │
└───────┬───────┘ └───────┬────────┘ └───────┬─────────┘
│ │ │
└──────────┬────────┘ │
▼ │
┌─────────────────┐ │
│ AdaptiveEnsemble│ ◄───────────────────┘
│ (학습형 가중치) │
├─────────────────┤
│ get_weights() │ ← 과거 매매 결과 반영
│ (ADX+macro+conf)│ 크기 가중 정확도 기준
│ 경계: 0.10~0.65 │ Water-Filling 정규화
│ Kelly Fraction │ ← 승률·손익비 기반
└────────┬────────┘
┌────────────────┐
│ 매수/매도/홀드 │
│ 최종 판단 │
└────────────────┘
```
#### 3.2.1 Deep Learning — Attention-LSTM (`analysis/deep_learning.py`)
| 항목 | 값 |
|------|-----|
| **아키텍처** | 4-Layer Stacked LSTM + Attention + FC |
| **Hidden Size** | 512 |
| **Input Features** | 7 (close, open, high, low, volume_norm, rsi_14, macd_hist) |
| **시퀀스 길이** | 60일 |
| **학습 에포크** | 최대 200 (Early Stopping patience=15) |
| **빠른 재학습** | 30 에포크 (체크포인트 존재 시) |
| **쿨다운** | 1200초 (20분, 동일 종목 재학습 방지) |
| **ModelRegistry** | LRU 방식, 최대 5개 모델 동시 적재 |
| **체크포인트** | `data/models/{ticker}_v3.pt` |
| **GPU 관리** | LSTM 학습 시 Ollama 자동 언로드/리로드 |
#### 3.2.2 기술적 분석 (`analysis/technical.py`)
`TechnicalAnalyzer.get_technical_score()` → 0.0 ~ 1.0 통합 점수
| 지표 | 비중 | 설명 |
|------|------|------|
| RSI (14일) | 25% | Wilder 방식, 30 이하 과매도/70 이상 과매수 |
| 이동평균 이격도 | 15% | 20일 MA 대비 현재가 위치 |
| MACD | 15% | 12/26/9, 히스토그램 방향 |
| Stochastic | 5% | Fast %K/%D (14/3/3) |
| Bollinger Bands | 15% | 20일/2σ, %B 위치 + 밴드폭 |
| ADX | 15% | 추세 강도 (>25 강한 추세) |
| Multi-Timeframe | 10% | 5일/20일/60일 추세 일관성 |
| OBV | ±0.1 보너스 | 거래량 기반 매집/분산 감지 |
추가 기능:
- `calculate_atr()` → ATR 기반 동적 손절/익절
- `calculate_dynamic_sl_tp()` → 변동성 적응형 SL/TP
- `calculate_obv()` → 스마트 머니 다이버전스 감지
#### 3.2.3 거시경제 분석 (`analysis/macro.py`)
```python
MacroAnalyzer.get_macro_status(kis_client) {
"status": "SAFE" | "CAUTION" | "DANGER",
"risk_score": int,
"indicators": {
"KOSPI": {"price", "change", "high", "low", "prev_close", "volume"},
"KOSDAQ": {"price", "change", ...},
"KOSPI200":{"price", "change", ...},
"MSI": float # Market Stress Index (0~100)
}
}
```
- **SAFE** (risk_score < 1): 정상 매매
- **CAUTION** (1 ≤ risk_score < 3): 매수 규모 축소
- **DANGER** (risk_score ≥ 3): 매수 중단, 보유분만 관리
#### 3.2.4 앙상블 (`analysis/ensemble.py`)
`AdaptiveEnsemble` — 과거 매매 결과 기반 가중치 자동 조정 + Kelly Criterion:
**가중치 학습 흐름**:
```
BUY 체결 → bot._buy_scores[ticker] = {tech, sentiment, lstm} 저장
SELL 체결 → ensemble.record_trade(ticker, ..., outcome_pct=yld)
→ _update_weights() → EMA(alpha=0.10) 가중치 점진 조정
→ _save() → data/ensemble_history.json
워커 프로세스 → reload_if_stale() → 파일 mtime 감지 시 재로드
```
**주요 메서드**:
- `get_weights(ticker, adx, macro_state, ai_confidence)``SignalWeights`
- 시장 컨텍스트 (strong_trend/sideways/danger/default) 별 기본 가중치
- 종목별 최근 10거래 크기 가중 정확도 반영
- ai_confidence >= 0.75 → LSTM 가중치 +25% (confidence 상한 0.80 반영)
- `get_kelly_fraction(ticker, half_kelly=True)` → 0.03~0.25 범위 투자 비중
- f* = (p·b - q) / b (p=승률, b=손익비)
- 거래 데이터 < 10건 → 보수적 기본값 8%
- Half-Kelly 적용으로 변동성 과대추정 보완
- `compute_ensemble_score(tech, sentiment, lstm, investor, weights)` → 통합 점수
- `reload_if_stale()` → 파일 mtime 기반 cross-process 동기화
**`SignalWeights.normalize()` — Water-Filling 알고리즘**:
- 경계(0.10~0.65) 위반 시 해당 값을 경계에 고정, 나머지에 잔여 비중 비례 배분
- 2차 정규화(합=1 보장)와 경계 클램핑이 상충하는 문제 해결
- 영구 저장: `data/ensemble_history.json` (가중치 + 매매 히스토리 통합)
#### 3.2.5 성과 평가 (`analysis/evaluator.py`)
`PerformanceEvaluator.generate_weekly_report()`:
- 핵심 지표: 총수익률, Sharpe Ratio, MDD, 승률, 평균손익비, KOSPI 상관도
- S/A/B/C/D/F 등급 산출
- **5명 전문가 LLM 패널** (Ollama): 각각 다른 관점으로 평가
- HTML 포맷 텔레그램 주간 보고서 자동 생성
---
## 4. 외부 서비스 연동
### 4.1 한국투자증권 KIS API (`services/kis.py`)
#### 인증
```python
KISClient.ensure_token()
# OAuth 2.0 → access_token 발급 → data/kis_token.json에 캐시
# 토큰 만료 시 자동 갱신 (_request_api에서 처리)
```
| 설정 | 모의투자 | 실전투자 |
|------|---------|---------|
| Base URL | `openapivts.koreainvestment.com:29443` | `openapi.koreainvestment.com:9443` |
| 환경변수 | `KIS_VIRTUAL_APP_KEY/SECRET/ACCOUNT` | `KIS_REAL_APP_KEY/SECRET/ACCOUNT` |
| 전환 | `.env``KIS_ENV_TYPE=virtual` | `.env``KIS_ENV_TYPE=real` |
#### API 스로틀링
- 초당 2회 제한 (`_throttle()` — 0.5초 딜레이)
- 토큰 만료 시 자동 갱신 (403 → retry with new token)
#### 주요 API 엔드포인트 매핑
| 기능 | KISClient 메서드 | KIS TR_ID |
|------|-----------------|-----------|
| 잔고 조회 | `get_balance()``{holdings, total_eval, deposit, today_buy_amt}` | `VTTC8434R` (모의) / `TTTC8434R` (실전) |
| 주문 (매수/매도) | `order()` | `VTTC0802U` / `VTTC0801U` (모의) |
| 현재가 조회 | `get_current_price()` | `FHKST01010100` |
| 일봉 OHLCV | `get_daily_ohlcv()``_get_daily_ohlcv_by_range()` | `FHKST03010100` |
| 일봉 종가 | `get_daily_price()``_get_daily_price_by_range()` | `FHKST03010100` |
| 거래량 순위 | `get_volume_rank()` | `FHPST01710000` |
| 지수 현재가 | `get_current_index()` | `FHPUP02100000` |
| 지수 일봉 | `get_daily_index_price()` | `FHKUP03500100` |
| 투자자 동향 | `get_investor_trend()` | `FHKST01010900` |
| Hash Key | `get_hash_key()` | - |
#### 비동기 클라이언트 (`KISAsyncClient`)
`aiohttp` 기반 — 다중 종목 동시 수집용:
- `get_daily_price_batch()` — 여러 종목 일봉 병렬 수집
- `get_daily_ohlcv_batch()` — 여러 종목 OHLCV 병렬 수집
- `get_investor_trends_batch()` — 여러 종목 투자자 동향 병렬 수집
---
### 4.2 Ollama LLM (`services/ollama.py`)
| 설정 | 값 |
|------|-----|
| **모델** | `qwen2.5:7b-instruct-q4_K_M` (VRAM ~4GB) |
| **API URL** | `http://localhost:11434` |
| **Context Window** | 4096 토큰 |
| **Max Output** | 200 토큰 |
| **Temperature** | 0.1 (결정론적, JSON 안정성) |
| **Keep Alive** | 5분 (비활성 시 자동 언로드) |
| **Timeout** | 90초 |
| **CPU Threads** | 8 (9800X3D 최적화) |
| **응답 포맷** | JSON (format: "json") |
**GPU 충돌 방지**:
- LSTM 학습 중 → Ollama 추론 최대 60초 대기
- VRAM > 12GB → 모델 즉시 언로드 (`keep_alive=0`)
- LSTM 학습 전 → Ollama 자동 언로드, 학습 후 → 자동 리로드
---
### 4.3 뉴스 수집 (`services/news.py`)
- **소스**: Google News RSS (`news.google.com/rss/search`)
- **동기**: `NewsCollector.get_market_news()` — 시장 일반 뉴스 5건
- **비동기**: `AsyncNewsCollector`
- `get_market_news_async()` — 시장 뉴스 (5분 캐시)
- `get_stock_news_async()` — 종목별 뉴스 (5분 캐시)
---
## 5. 웹 백엔드 서버 API (FastAPI)
### 5.1 서버 정보
| 항목 | 값 |
|------|-----|
| **프레임워크** | FastAPI + Uvicorn |
| **호스트** | `0.0.0.0:8000` |
| **NAS 백엔드** | `http://192.168.45.54:18500` (웹 프론트엔드 서버) |
### 5.2 API 엔드포인트
#### `GET /` — 서버 상태
```json
{
"status": "online",
"gpu_vram": 4.2,
"service": "Windows AI Server (Refactored)"
}
```
#### `GET /trade/balance` | `GET /api/trade/balance` — 잔고 조회
KIS API를 통해 현재 계좌 잔고(예수금, 보유종목, 평가금액) 조회.
```json
{
"total_eval": 10500000,
"deposit": 5000000,
"holdings": [
{
"ticker": "005930",
"name": "삼성전자",
"qty": 10,
"avg_price": 72000,
"current_price": 73500,
"profit_rate": 2.08
}
]
}
```
#### `POST /trade/order` | `POST /api/trade/order` — 수동 주문
```json
// Request Body
{
"ticker": "005930",
"action": "BUY", // "BUY" | "SELL"
"quantity": 10
}
// Response
{
"status": "executed",
"kis_result": { ... }
}
```
#### `POST /analyze/portfolio` | `POST /api/analyze/portfolio` — AI 포트폴리오 분석
현재 잔고 + 최신 뉴스를 종합하여 Ollama LLM으로 포트폴리오 분석.
```json
{
"analysis": "... AI 분석 결과 (한국어) ..."
}
```
### 5.3 NAS 서버와의 통신 흐름
```
┌──────────────┐ HTTP Request ┌────────────────────┐
│ NAS Backend │ ─────────────────────► │ Windows AI Server │
│ (웹 프론트) │ │ (FastAPI:8000) │
│ :18500 │ ◄──────────────────── │ │
│ │ JSON Response │ │
└──────────────┘ └────────────────────┘
[통신 시나리오]
1. 웹 → /api/trade/balance → 잔고 데이터 표시
2. 웹 → /api/trade/order → 수동 매수/매도 실행
3. 웹 → /api/analyze/portfolio → AI 분석 결과 표시
4. 웹 → / → 서버 상태 및 GPU 정보
```
- **NAS 서버** (`192.168.45.54:18500`): 웹 프론트엔드 호스팅, 사용자 인터페이스 제공
- **Windows AI 서버** (`0.0.0.0:8000`): GPU 연산, KIS API 통신, AI 분석 처리
- 내부 네트워크 (LAN) 통신, 외부 노출 없음
---
## 6. 텔레그램 봇 설정 & 명령어
### 6.1 환경변수
```env
TELEGRAM_BOT_TOKEN=8546032918:AAF5GJcP92DrtpSoQdaimMIZe7bz_xtGGPo
TELEGRAM_CHAT_ID=7388056964
```
### 6.2 봇 프로세스 아키텍처
```
runner.py
└── run_telegram_bot_standalone()
├── SharedIPC 초기화 (lock, queue, shutdown_event)
├── TelegramBotServer 생성
├── IPC에서 초기 데이터 로드
├── bot_server.run() (python-telegram-bot polling)
└── Conflict 감지 시 백오프 재시도 (최대 10회)
```
- **라이브러리**: `python-telegram-bot` (Application, CommandHandler)
- **메시지 포맷**: HTML (`parse_mode="HTML"`)
- **동시 업데이트**: `concurrent_updates=True`
- **로깅**: `telegram_bot.log` (파일 + 콘솔)
### 6.3 명령어 목록
| 명령어 | 설명 | 데이터 소스 |
|--------|------|------------|
| `/start` | 봇 시작 & 전체 명령어 안내 | - |
| `/status` | 봇 상태, 시장 지수, AI 모델 상태 | IPC (SharedMemory) |
| `/portfolio` | 보유 종목 & 수익률 조회 | IPC → FakeKIS.get_balance() |
| `/watchlist` | 현재 감시 종목 리스트 | IPC → watchlist 데이터 |
| `/update_watchlist` | Watchlist 즉시 업데이트 요청 | Command Queue → 메인 봇 |
| `/macro` | 거시경제 분석 (KOSPI/KOSDAQ/MSI) | IPC → macro_indices |
| `/system` | CPU/GPU/RAM 시스템 상태 | IPC → gpu_status + psutil |
| `/ai` | AI 모델 상태 (VRAM, 학습 여부) | IPC → gpu_status |
| `/restart` | 메인 봇 재시작 명령 | Command Queue |
| `/stop` | 봇 종료 | shutdown_event.set() |
| `/exec <cmd>` | 서버 쉘 명령어 직접 실행 | subprocess (10초 타임아웃) |
| `/evaluate` | 즉시 성과 평가 보고서 생성 | PerformanceEvaluator |
### 6.4 TelegramMessenger (`services/telegram.py`)
단방향 알림 전용 (메인 봇 → 사용자):
- **비동기 전송**: `threading.Thread(daemon=True)` — Fire-and-forget
- **HTML 파싱**: 마크다운 에러 방지
- 매매 실행, 서버 시작/종료, 에러 알림 등에 사용
### 6.5 Conflict 처리
텔레그램 봇 API는 동시에 하나의 polling 인스턴스만 허용:
- `Conflict` 에러 감지 시 지수 백오프 (5s → 10s → ... → 30s)
- 최대 10회 재시도 후 프로세스 종료
- Watchdog가 감지하여 자동 재시작
---
## 7. 환경 설정 (`modules/config.py`)
### 7.1 주요 설정 상수
| 그룹 | 키 | 값 | 설명 |
|------|-----|-----|------|
| **매매** | `MAX_INVESTMENT_PER_STOCK` | 3,000,000원 | 종목당 최대 투자금 |
| **매매** | `MAX_BUY_PER_CYCLE` | 2 | 사이클당 최대 매수 종목 수 (env: `MAX_BUY_PER_CYCLE`) |
| **매매** | `MAX_DAILY_BUY_RATIO` | 0.80 | 예수금 대비 일일 최대 매수 비율 (env: `MAX_DAILY_BUY_RATIO`) |
| **IPC** | `SHM_NAME` | `web_ai_bot_ipc` | SharedMemory 이름 |
| **IPC** | `SHM_SIZE` | 131,072 (128KB) | SharedMemory 크기 |
| **IPC** | `IPC_STALENESS` | 600초 | 데이터 유효 기간 |
| **GPU** | `VRAM_WARNING_THRESHOLD` | 12.0 GB | VRAM 경고 임계값 |
| **프로세스** | `WATCHDOG_INTERVAL` | 30초 | 헬스체크 간격 |
| **프로세스** | `MAX_RESTART_COUNT` | 3 | 최대 자동 재시작 횟수 |
| **LSTM** | `LSTM_COOLDOWN` | 1,200초 | 동일 종목 재학습 방지 |
| **LSTM** | `LSTM_FAST_EPOCHS` | 30 | 빠른 재학습 에포크 |
| **CPU** | `CPU_CIRCUIT_BREAKER_THRESHOLD` | 92% | 서킷 브레이커 임계값 |
| **CPU** | `CPU_CIRCUIT_BREAKER_CONSECUTIVE` | 2회 | 연속 초과 시 발동 |
| **Ollama** | `OLLAMA_NUM_CTX` | 4,096 | 컨텍스트 윈도우 |
| **Ollama** | `OLLAMA_NUM_PREDICT` | 200 | 최대 출력 토큰 |
| **Ollama** | `OLLAMA_NUM_THREAD` | 8 | CPU 스레드 수 |
| **Network** | `HTTP_TIMEOUT` | 10초 | 기본 HTTP 요청 타임아웃 |
### 7.2 .env 파일 구조
```env
# NAS Backend (웹 프론트엔드 서버)
NAS_API_URL=http://192.168.45.54:18500
# Ollama LLM
OLLAMA_API_URL=http://localhost:11434
OLLAMA_MODEL=qwen2.5:7b-instruct-q4_K_M
# KIS API (virtual/real 전환)
KIS_ENV_TYPE=virtual
KIS_REAL_APP_KEY=...
KIS_REAL_APP_SECRET=...
KIS_REAL_ACCOUNT=XXXXXXXX-XX
KIS_VIRTUAL_APP_KEY=...
KIS_VIRTUAL_APP_SECRET=...
KIS_VIRTUAL_ACCOUNT=XXXXXXXX-XX
# Telegram Bot
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
```
---
## 8. 운영 가이드
### 8.1 시작 방법
```bash
# 일반 시작
python main_server.py
# LSTM 사전학습 후 자동 시작
python warmup_and_restart.py
# 텔레그램 봇만 단독 실행 (디버깅용)
python -m modules.services.telegram_bot.runner
```
### 8.2 좀비 프로세스 관리
- `main_server.py` 실행 시 자동으로 이전 좀비 프로세스 정리
- `pids.txt` 기반 → 메모리 기반 PID 추적으로 전환 완료
- 수동 확인: `Get-Process python` (PowerShell)
### 8.3 로그 파일
| 파일 | 용도 |
|------|------|
| `server.log` | Uvicorn 서버 로그 |
| `telegram_bot.log` | 텔레그램 봇 로그 |
| `warmup.log` | LSTM 사전학습 진행 로그 |
| `bot_output.log` | 트레이딩 봇 출력 로그 |
### 8.4 트러블슈팅
| 증상 | 원인 | 해결 |
|------|------|------|
| KIS 403 Forbidden | 토큰 만료 또는 Rate Limit | `data/kis_token.json` 삭제 후 재시작 |
| Telegram Conflict | 이전 봇 프로세스 미종료 | `main_server.py` 재시작 (자동 정리) |
| GPU OOM | LSTM + Ollama 동시 적재 | `VRAM_WARNING_THRESHOLD` 낮추기 |
| CPU 100% 고정 | 좀비 워커 프로세스 | `main_server.py` 재시작 |
| IPC 데이터 오래됨 | 메인 봇 크래시 | Watchdog 자동 재시작 확인, 수동 재시작 |
| 예수금 초과 매수 | KIS 모의투자 T+2 미차감 | `MAX_DAILY_BUY_RATIO` / `MAX_BUY_PER_CYCLE` 조정 |
| Kelly 비중이 너무 낮음 | 거래 기록 부족 (< 10건) | 초기에는 기본값 8% 사용, 거래 누적 후 자동 조정 |
| 앙상블 가중치 갱신 안 됨 | 매도 체결 없음 또는 `_buy_scores` 누락 | 봇 재시작 전 매도 완료 확인; `data/ensemble_history.json` 확인 |
---
## 9. 데이터 흐름 요약
```
[시장 개장 전]
WatchlistManager → 뉴스 분석 → Watchlist 갱신
[장중 사이클 (≈5분 간격)]
1. SystemMonitor.check_health() → CPU/GPU 확인
2. MacroAnalyzer.get_macro_status() → 시장 상태 판단
3. KIS → get_balance() → raw_deposit - today_buy_total = 가용 예수금
4. KIS → get_daily_ohlcv_batch() → OHLCV 수집
5. ProcessPool → analyze_stock_process() × N종목
├── ensemble.reload_if_stale() → 파일 mtime 감지 시 가중치 재로드
├── TechnicalAnalyzer → 기술적 점수
├── PricePredictor → LSTM 예측
├── OllamaManager → LLM 감성 분석
├── AdaptiveEnsemble.get_weights() → 학습된 동적 가중치
└── calculate_position_size() → Kelly Criterion 수량 산출
6. 매수 판단 → 예수금 확인 → KIS 주문
├── _buy_scores[ticker] 저장 (앙상블 학습용)
├── _today_buy_total += 매수금액
└── buys_this_cycle++ (MAX_BUY_PER_CYCLE 제한)
7. 매도 판단 → KIS 주문
└── ensemble.record_trade() → 가중치 학습 + ensemble_history.json 저장
8. SharedIPC.write_status() → 텔레그램 봇에 공유
9. TelegramMessenger → 결과 알림
[장 마감 후]
PerformanceDB.save_daily_snapshot() → 일별 자산 기록
Evaluator → 주간 보고서 (월요일)
```
---
## 10. 버전 변경 이력
### v3.1 (2026-03-19) — 잔고 관리 & 앙상블 학습 완성
**버그 수정**:
- `tracking_deposit` 사이클 간 초기화 문제 → `_today_buy_total` 인스턴스 변수로 누적 추적
- KIS 모의투자 T+2 미차감으로 인한 예수금 초과 매수 방지
- `ai_confidence >= 0.85` 임계값 버그 (LSTM confidence 상한 0.80 미반영) → 0.75로 수정
- OHLCV 피처 누락 시 silent fallback → 경고 로그 출력
**신규 기능**:
- `MAX_BUY_PER_CYCLE`: 사이클당 최대 매수 종목 수 제한 (기본 2)
- `MAX_DAILY_BUY_RATIO`: 예수금 대비 일일 최대 매수 비율 (기본 80%)
- `kis.get_balance()``today_buy_amt` 필드 추가 (`thdt_buy_amt`)
**앙상블 (`analysis/ensemble.py`)**:
- `AdaptiveEnsemble``process.py`에 실제 연동 (하드코딩 가중치 제거)
- `get_kelly_fraction()`: Half-Kelly Criterion 포지션 비중 계산 추가
- `SignalWeights.normalize()`: Water-Filling 알고리즘으로 경계 위반 문제 해결
- `_accuracy()` 이진 지표 제거 → `_accuracy_weighted()` (크기 가중) 통일
- `reload_if_stale()`: 파일 mtime 기반 cross-process 동기화
**포지션 사이징 (`strategy/process.py`)**:
- `calculate_position_size()`: 하드코딩 10% → Kelly Criterion (과거 승률·손익비 기반)
- `bot.py` 중복 계산 제거 → 워커의 `suggested_qty` 직접 사용
**앙상블 학습 루프 (`bot.py`)**:
- BUY 체결 시 `_buy_scores[ticker]` 신호 점수 저장
- SELL 체결 시 `ensemble.record_trade()``ensemble_history.json` 갱신
- 워커 프로세스는 `reload_if_stale()`로 자동 반영

View File

@@ -0,0 +1,179 @@
"""
실제 과거 데이터 기반 전 종목 백테스트 러너 (Task B)
목적:
- 현재 watchlist의 모든 종목에 대해 KIS API로 일봉 OHLCV 수집
- v3.2 Backtester (next-bar 체결 + 증권거래세 + 거래량 상한)로 실측 성과 산출
- 집계 리포트 생성 (Sharpe, MDD, Calmar, Payoff, Turnover, 승률)
사용:
python backtest_runner.py # watchlist 전체
python backtest_runner.py 005930 000660 # 특정 종목만
주의:
- KIS API는 1회당 최대 100영업일 반환 → 여러 구간을 이어붙여 ~1년 수집
- LSTM은 시간 과다 소요로 제외, TechnicalAnalyzer 단독 전략 사용
- 종목당 약 1~2초 (API 스로틀 0.5초/호출 × 3구간)
"""
import json
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from modules.services.kis import KISClient
from modules.analysis.technical import TechnicalAnalyzer
from modules.analysis.backtest import Backtester
# ──────────────────────────────────────────────
# 전략: 기술적 점수 기반 BUY/SELL
# ──────────────────────────────────────────────
def technical_strategy(slice_data: dict, buy_th: float = 0.65, sell_th: float = 0.35) -> str:
closes = slice_data.get("close", [])
volumes = slice_data.get("volume", [])
if len(closes) < 30:
return "HOLD"
try:
score, *_ = TechnicalAnalyzer.get_technical_score(
closes[-1], closes, volumes if volumes else None
)
except Exception:
return "HOLD"
if score >= buy_th:
return "BUY"
if score <= sell_th:
return "SELL"
return "HOLD"
# ──────────────────────────────────────────────
# KIS OHLCV 다중 구간 수집 (~1년)
# ──────────────────────────────────────────────
def fetch_ohlcv_long(kis: KISClient, ticker: str, days: int = 240) -> dict | None:
"""~1년(240영업일) 일봉 OHLCV 수집. API 한계(100일)를 여러 호출로 극복."""
try:
# 단순화: 100일짜리 한 번 + 추가로 count=250 요청 시도
data = kis._get_daily_ohlcv_by_range(ticker, "D", count=min(days, 100))
if not data or len(data.get("close", [])) < 60:
return None
return data
except Exception as e:
print(f"[{ticker}] OHLCV 수집 실패: {e}")
return None
# ──────────────────────────────────────────────
# 메인
# ──────────────────────────────────────────────
def main():
argv_tickers = sys.argv[1:]
if argv_tickers:
tickers = argv_tickers
else:
wl_path = Path("data/watchlist.json")
if not wl_path.exists():
print("data/watchlist.json 없음")
return
watchlist = json.loads(wl_path.read_text(encoding="utf-8"))
tickers = list(watchlist.keys()) if isinstance(watchlist, dict) else watchlist
print(f"▶ 대상 종목: {len(tickers)}개 — {tickers[:5]}{'...' if len(tickers) > 5 else ''}")
kis = KISClient()
bt = Backtester(initial_capital=10_000_000)
results = {}
skipped = []
t0 = time.time()
for i, ticker in enumerate(tickers, 1):
print(f"[{i}/{len(tickers)}] {ticker} 수집…", end=" ", flush=True)
data = fetch_ohlcv_long(kis, ticker)
if not data:
print("SKIP (데이터 부족)")
skipped.append(ticker)
continue
bars = len(data["close"])
try:
r = bt.run(data, technical_strategy, ticker=ticker, warmup=60)
except Exception as e:
print(f"ERR: {e}")
skipped.append(ticker)
continue
results[ticker] = r
print(f"bars={bars} trades={r.total_trades} ret={r.total_return_pct:+.1f}% "
f"MDD={r.max_drawdown_pct:.1f}% Sharpe={r.sharpe_ratio:.2f}")
elapsed = time.time() - t0
# ── 집계 ──
if not results:
print("\n집계할 결과 없음.")
return
import statistics
rets = [r.total_return_pct for r in results.values()]
sharpes = [r.sharpe_ratio for r in results.values() if r.total_trades > 0]
mdds = [r.max_drawdown_pct for r in results.values()]
wins = [r.win_rate for r in results.values() if r.total_trades > 0]
trades_total = sum(r.total_trades for r in results.values())
print("\n" + "=" * 60)
print(f"📊 백테스트 집계 — {len(results)}종목 / {elapsed:.1f}s")
print("=" * 60)
print(f"평균 수익률: {statistics.mean(rets):+.2f}% "
f"(중앙 {statistics.median(rets):+.2f}%)")
print(f"평균 MDD: {statistics.mean(mdds):.2f}%")
if sharpes:
print(f"평균 Sharpe: {statistics.mean(sharpes):.3f}")
if wins:
print(f"평균 승률: {statistics.mean(wins):.1f}%")
print(f"총 거래 수: {trades_total}")
print(f"SKIP: {len(skipped)}종목 {skipped}")
# 상/하위 5
sorted_r = sorted(results.items(), key=lambda kv: kv[1].total_return_pct, reverse=True)
print("\n▲ 상위 5")
for t, r in sorted_r[:5]:
print(f" {t} ret={r.total_return_pct:+7.2f}% "
f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}")
print("\n▼ 하위 5")
for t, r in sorted_r[-5:]:
print(f" {t} ret={r.total_return_pct:+7.2f}% "
f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}")
# 리포트 파일
report = {
"generated_at": datetime.now().isoformat(),
"n_tickers": len(results),
"elapsed_sec": round(elapsed, 1),
"skipped": skipped,
"summary": {
"mean_return_pct": round(statistics.mean(rets), 2),
"median_return_pct": round(statistics.median(rets), 2),
"mean_mdd_pct": round(statistics.mean(mdds), 2),
"mean_sharpe": round(statistics.mean(sharpes), 3) if sharpes else None,
"mean_win_rate": round(statistics.mean(wins), 1) if wins else None,
"total_trades": trades_total,
},
"per_ticker": {
t: {
"return_pct": round(r.total_return_pct, 2),
"mdd_pct": round(r.max_drawdown_pct, 2),
"sharpe": round(r.sharpe_ratio, 3),
"calmar": round(r.calmar_ratio, 3),
"payoff": round(r.payoff_ratio, 3),
"turnover": round(r.turnover_ratio, 3),
"win_rate": round(r.win_rate, 1),
"trades": r.total_trades,
} for t, r in results.items()
},
}
out_path = Path("data/backtest_report.json")
out_path.parent.mkdir(exist_ok=True)
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n리포트 저장: {out_path}")
if __name__ == "__main__":
main()

View File

@@ -1,6 +1,4 @@
import os import os
import signal
import threading
import uvicorn import uvicorn
import multiprocessing import multiprocessing
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -24,11 +22,11 @@ news_collector = None
watchdog = None watchdog = None
def run_trading_bot(ipc_lock, command_queue, shutdown_event, eod_event=None): def run_trading_bot(ipc_lock, command_queue, shutdown_event):
"""트레이딩 봇 실행 래퍼""" """트레이딩 봇 실행 래퍼"""
ProcessTracker.register("Trading Bot Main") ProcessTracker.register("Trading Bot Main")
bot = AutoTradingBot(ipc_lock=ipc_lock, command_queue=command_queue, bot = AutoTradingBot(ipc_lock=ipc_lock, command_queue=command_queue,
shutdown_event=shutdown_event, eod_event=eod_event) shutdown_event=shutdown_event)
bot.loop() bot.loop()
@@ -56,12 +54,11 @@ async def lifespan(app: FastAPI):
ipc_lock = multiprocessing.Lock() ipc_lock = multiprocessing.Lock()
command_queue = multiprocessing.Queue() command_queue = multiprocessing.Queue()
shutdown_event = multiprocessing.Event() shutdown_event = multiprocessing.Event()
eod_event = multiprocessing.Event() # [v3.1] EOD 셧다운 시그널
print("[Server] Starting AI Trading Bot & Telegram Bot...") print("[Server] Starting AI Trading Bot & Telegram Bot...")
# 5. 자식 프로세스 생성 # 5. 자식 프로세스 생성
bot_args = (ipc_lock, command_queue, shutdown_event, eod_event) bot_args = (ipc_lock, command_queue, shutdown_event)
telegram_args = (ipc_lock, command_queue, shutdown_event) telegram_args = (ipc_lock, command_queue, shutdown_event)
bot_process = multiprocessing.Process( bot_process = multiprocessing.Process(
@@ -81,25 +78,6 @@ async def lifespan(app: FastAPI):
messenger.send_message("[Server Started] Windows AI Server Online.") messenger.send_message("[Server Started] Windows AI Server Online.")
# [v3.1] EOD 모니터 스레드: 봇이 EOD 시그널을 보내면 서버 프로세스 자동 종료
_server_pid = os.getpid()
def _eod_monitor():
"""eod_event 감지 시 SIGTERM으로 uvicorn 우아하게 종료"""
while not shutdown_event.is_set():
if eod_event.is_set():
print("[Server] EOD 시그널 수신 — 서버 종료 중 (15초 후)...")
import time as _time
_time.sleep(15) # 자식 프로세스 정리 시간
print(f"[Server] SIGTERM → PID {_server_pid}")
os.kill(_server_pid, signal.SIGTERM)
return
import time as _time
_time.sleep(5)
_eod_thread = threading.Thread(target=_eod_monitor, daemon=True, name="EODMonitor")
_eod_thread.start()
yield yield
# [Shutdown] # [Shutdown]

View File

@@ -1,11 +1,13 @@
""" """
백테스팅 프레임워크 (Phase 3-1) 백테스팅 프레임워크 (v3.2 Realism 보강)
- 과거 OHLCV 데이터로 전략 시뮬레이션
- 성과지표: Sharpe ratio, MDD, 승률, 평균손익비 개선 사항 (v3.2):
- Phase 2 모델 변경 전후 비교 검증용 1. 다음 시가 체결 옵션 (look-ahead bias 제거)
2. 증권거래세 (매도 0.2%, 수수료와 별개 부과)
3. 거래량 기반 부분 체결 ( 거래량의 N% 상한)
4. Calmar, Payoff, Turnover 지표 추가
""" """
import json
import numpy as np import numpy as np
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional, Callable from typing import Dict, List, Optional, Callable
@@ -14,12 +16,12 @@ from typing import Dict, List, Optional, Callable
@dataclass @dataclass
class Trade: class Trade:
ticker: str ticker: str
entry_date: int # 데이터 인덱스 entry_date: int
entry_price: float entry_price: float
exit_date: int exit_date: int
exit_price: float exit_price: float
qty: int qty: int
direction: str = "LONG" # LONG / SHORT direction: str = "LONG"
@property @property
def pnl(self): def pnl(self):
@@ -44,20 +46,26 @@ class BacktestResult:
total_trades: int total_trades: int
winning_trades: int winning_trades: int
losing_trades: int losing_trades: int
calmar_ratio: float = 0.0
payoff_ratio: float = 0.0 # 평균수익 / |평균손실|
turnover_ratio: float = 0.0 # 총 매매대금 / 초기자본
trades: List[Trade] = field(default_factory=list) trades: List[Trade] = field(default_factory=list)
def summary(self) -> str: def summary(self) -> str:
lines = [ lines = [
"=" * 50, "=" * 50,
"📊 백테스팅 결과", "📊 백테스팅 결과 (v3.2)",
"=" * 50, "=" * 50,
f"총 수익률: {self.total_return_pct:+.2f}%", f"총 수익률: {self.total_return_pct:+.2f}%",
f"Sharpe Ratio: {self.sharpe_ratio:.3f}", f"Sharpe Ratio: {self.sharpe_ratio:.3f}",
f"Calmar Ratio: {self.calmar_ratio:.3f}",
f"Max Drawdown: {self.max_drawdown_pct:.2f}%", f"Max Drawdown: {self.max_drawdown_pct:.2f}%",
f"승률: {self.win_rate:.1f}% ({self.winning_trades}/{self.total_trades})", f"승률: {self.win_rate:.1f}% ({self.winning_trades}/{self.total_trades})",
f"평균 수익: {self.avg_win_pct:+.2f}%", f"평균 수익: {self.avg_win_pct:+.2f}%",
f"평균 손실: {self.avg_loss_pct:.2f}%", f"평균 손실: {self.avg_loss_pct:.2f}%",
f"손익비(PF): {self.profit_factor:.2f}", f"손익비(PF): {self.profit_factor:.2f}",
f"Payoff Ratio: {self.payoff_ratio:.2f}",
f"Turnover: {self.turnover_ratio:.2f}x",
"=" * 50, "=" * 50,
] ]
return "\n".join(lines) return "\n".join(lines)
@@ -65,40 +73,37 @@ class BacktestResult:
class Backtester: class Backtester:
""" """
OHLCV 기반 전략 백테스터 OHLCV 기반 전략 백테스터.
사용 예시: 체결 모델 (v3.2):
bt = Backtester(initial_capital=10_000_000) - next_bar_open=True: 신호 발생 다음 시가로 체결 (look-ahead 제거)
result = bt.run( - slippage: 체결가에 ±slippage_rate 적용
ohlcv_data={"close": [...], "high": [...], "low": [...], "volume": [...]}, - commission_rate: 매수/매도 양쪽에 부과 (증권사 수수료)
strategy_fn=my_strategy, - sell_tax_rate: 매도 시에만 부과 (증권거래세 0.2%)
ticker="005930" - max_volume_participation: 거래량의 N% 이하로 체결 제한
)
print(result.summary())
""" """
def __init__(self, initial_capital: float = 10_000_000, def __init__(self,
commission_rate: float = 0.00015, # 0.015% (증권사 기본) initial_capital: float = 10_000_000,
slippage_rate: float = 0.001): # 0.1% 슬리피지 commission_rate: float = 0.00015,
slippage_rate: float = 0.001,
sell_tax_rate: float = 0.002,
next_bar_open: bool = True,
max_volume_participation: float = 0.01):
self.initial_capital = initial_capital self.initial_capital = initial_capital
self.commission_rate = commission_rate self.commission_rate = commission_rate
self.slippage_rate = slippage_rate self.slippage_rate = slippage_rate
self.sell_tax_rate = sell_tax_rate
self.next_bar_open = next_bar_open
self.max_volume_participation = max_volume_participation
# ──────────────────────────────────────────────
# 단일 종목
# ──────────────────────────────────────────────
def run(self, ohlcv_data: Dict, strategy_fn: Callable, def run(self, ohlcv_data: Dict, strategy_fn: Callable,
ticker: str = "UNKNOWN", warmup: int = 60) -> BacktestResult: ticker: str = "UNKNOWN", warmup: int = 60) -> BacktestResult:
"""
단일 종목 백테스팅
Args:
ohlcv_data: {'close':[], 'high':[], 'low':[], 'open':[], 'volume':[]}
strategy_fn: (ohlcv_slice: dict) -> str ("BUY" | "SELL" | "HOLD")
ticker: 종목 코드
warmup: 초기 웜업 기간 (기술지표 안정화)
Returns:
BacktestResult
"""
closes = np.array(ohlcv_data.get('close', []), dtype=float) closes = np.array(ohlcv_data.get('close', []), dtype=float)
opens = np.array(ohlcv_data.get('open', closes), dtype=float)
highs = np.array(ohlcv_data.get('high', closes), dtype=float) highs = np.array(ohlcv_data.get('high', closes), dtype=float)
lows = np.array(ohlcv_data.get('low', closes), dtype=float) lows = np.array(ohlcv_data.get('low', closes), dtype=float)
volumes = np.array(ohlcv_data.get('volume', np.zeros_like(closes)), dtype=float) volumes = np.array(ohlcv_data.get('volume', np.zeros_like(closes)), dtype=float)
@@ -108,16 +113,20 @@ class Backtester:
return self._empty_result() return self._empty_result()
capital = self.initial_capital capital = self.initial_capital
position = 0 # 보유 수량 position = 0
entry_price = 0.0 entry_price = 0.0
entry_idx = 0 entry_idx = 0
equity_curve = [capital] equity_curve = [capital]
trades: List[Trade] = [] trades: List[Trade] = []
total_turnover = 0.0 # 누적 매매대금
for i in range(warmup, n): # 마지막 인덱스는 next-bar 체결 시 여유 필요
# 전략 함수에 현재까지의 슬라이스 전달 last_signal_idx = n - 2 if self.next_bar_open else n - 1
for i in range(warmup, last_signal_idx + 1):
slice_data = { slice_data = {
'close': closes[:i+1].tolist(), 'close': closes[:i+1].tolist(),
'open': opens[:i+1].tolist(),
'high': highs[:i+1].tolist(), 'high': highs[:i+1].tolist(),
'low': lows[:i+1].tolist(), 'low': lows[:i+1].tolist(),
'volume': volumes[:i+1].tolist(), 'volume': volumes[:i+1].tolist(),
@@ -128,43 +137,58 @@ class Backtester:
except Exception: except Exception:
pass pass
price = closes[i] # 체결가 산출 — next_bar_open이면 i+1 시가, 아니면 i 종가
buy_price = price * (1 + self.slippage_rate) # 슬리피지 포함 매수가 fill_idx = i + 1 if self.next_bar_open and i + 1 < n else i
sell_price = price * (1 - self.slippage_rate) # 슬리피지 포함 매도가 base_price = opens[fill_idx] if self.next_bar_open else closes[fill_idx]
fill_volume = volumes[fill_idx]
buy_price = base_price * (1 + self.slippage_rate)
sell_price = base_price * (1 - self.slippage_rate)
if signal == "BUY" and position == 0: if signal == "BUY" and position == 0:
# 전액 투자 (수수료 포함) # 전액 투자 (수수료 포함 총비용 기준)
qty = int(capital / (buy_price * (1 + self.commission_rate))) raw_qty = int(capital / (buy_price * (1 + self.commission_rate)))
# 거래량 상한 — 봉 거래량의 N%까지만 체결
vol_cap = int(fill_volume * self.max_volume_participation)
qty = min(raw_qty, vol_cap) if vol_cap > 0 else raw_qty
if qty > 0: if qty > 0:
cost = qty * buy_price * (1 + self.commission_rate) cost = qty * buy_price * (1 + self.commission_rate)
capital -= cost capital -= cost
position = qty position = qty
entry_price = buy_price entry_price = buy_price
entry_idx = i entry_idx = fill_idx
total_turnover += qty * buy_price
elif signal == "SELL" and position > 0: elif signal == "SELL" and position > 0:
proceeds = position * sell_price * (1 - self.commission_rate) # 매도: 수수료 + 증권거래세
sell_cost_rate = self.commission_rate + self.sell_tax_rate
vol_cap = int(fill_volume * self.max_volume_participation) if fill_volume > 0 else position
exec_qty = min(position, vol_cap) if vol_cap > 0 else position
proceeds = exec_qty * sell_price * (1 - sell_cost_rate)
capital += proceeds capital += proceeds
total_turnover += exec_qty * sell_price
trades.append(Trade( trades.append(Trade(
ticker=ticker, ticker=ticker,
entry_date=entry_idx, entry_date=entry_idx,
entry_price=entry_price, entry_price=entry_price,
exit_date=i, exit_date=fill_idx,
exit_price=sell_price, exit_price=sell_price,
qty=position qty=exec_qty
)) ))
position = 0 position -= exec_qty
entry_price = 0.0 if position == 0:
entry_price = 0.0
# 자산 추적
current_equity = capital + (position * closes[i] if position > 0 else 0) current_equity = capital + (position * closes[i] if position > 0 else 0)
equity_curve.append(current_equity) equity_curve.append(current_equity)
# 미청산 포지션 강제 종료 # 미청산 포지션: 마지막 종가 기준 강제 청산 (수수료+세금 반영)
if position > 0: if position > 0:
last_price = closes[-1] * (1 - self.slippage_rate) last_price = closes[-1] * (1 - self.slippage_rate)
proceeds = position * last_price * (1 - self.commission_rate) sell_cost_rate = self.commission_rate + self.sell_tax_rate
proceeds = position * last_price * (1 - sell_cost_rate)
capital += proceeds capital += proceeds
total_turnover += position * last_price
trades.append(Trade( trades.append(Trade(
ticker=ticker, ticker=ticker,
entry_date=entry_idx, entry_date=entry_idx,
@@ -174,45 +198,46 @@ class Backtester:
qty=position qty=position
)) ))
equity_curve[-1] = capital equity_curve[-1] = capital
position = 0
return self._compute_metrics(equity_curve, trades) return self._compute_metrics(equity_curve, trades, total_turnover)
def run_multi(self, ohlcv_dict: Dict[str, Dict], strategy_fn: Callable, def run_multi(self, ohlcv_dict: Dict[str, Dict], strategy_fn: Callable,
warmup: int = 60) -> Dict[str, BacktestResult]: warmup: int = 60) -> Dict[str, BacktestResult]:
"""여러 종목 백테스팅""" return {t: self.run(d, strategy_fn, t, warmup) for t, d in ohlcv_dict.items()}
results = {}
for ticker, ohlcv_data in ohlcv_dict.items():
results[ticker] = self.run(ohlcv_data, strategy_fn, ticker, warmup)
return results
def _compute_metrics(self, equity_curve: List[float], trades: List[Trade]) -> BacktestResult: # ──────────────────────────────────────────────
# 지표 계산
# ──────────────────────────────────────────────
def _compute_metrics(self, equity_curve: List[float], trades: List[Trade],
total_turnover: float) -> BacktestResult:
equity = np.array(equity_curve, dtype=float) equity = np.array(equity_curve, dtype=float)
total_return_pct = (equity[-1] / equity[0] - 1) * 100 total_return_pct = (equity[-1] / equity[0] - 1) * 100
# Sharpe Ratio (일별 수익률 기준, 연율화) daily_returns = np.diff(equity) / (equity[:-1] + 1e-9)
daily_returns = np.diff(equity) / equity[:-1] sharpe = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252) \
if daily_returns.std() > 0: if daily_returns.std() > 0 else 0.0
sharpe = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252)
else:
sharpe = 0.0
# Max Drawdown
peak = np.maximum.accumulate(equity) peak = np.maximum.accumulate(equity)
drawdowns = (equity - peak) / (peak + 1e-9) * 100 drawdowns = (equity - peak) / (peak + 1e-9) * 100
max_drawdown = abs(drawdowns.min()) max_drawdown = abs(drawdowns.min())
# 승률 / 손익비
wins = [t for t in trades if t.pnl_pct > 0] wins = [t for t in trades if t.pnl_pct > 0]
losses = [t for t in trades if t.pnl_pct <= 0] losses = [t for t in trades if t.pnl_pct <= 0]
win_rate = len(wins) / len(trades) * 100 if trades else 0 win_rate = len(wins) / len(trades) * 100 if trades else 0
avg_win = np.mean([t.pnl_pct for t in wins]) if wins else 0 avg_win = float(np.mean([t.pnl_pct for t in wins])) if wins else 0.0
avg_loss = np.mean([t.pnl_pct for t in losses]) if losses else 0 avg_loss = float(np.mean([t.pnl_pct for t in losses])) if losses else 0.0
total_win = sum(t.pnl for t in wins) total_win = sum(t.pnl for t in wins)
total_loss = abs(sum(t.pnl for t in losses)) total_loss = abs(sum(t.pnl for t in losses))
profit_factor = total_win / (total_loss + 1e-9) profit_factor = total_win / (total_loss + 1e-9)
# 신규 지표
calmar = (total_return_pct / max_drawdown) if max_drawdown > 0 else 0.0
payoff = (avg_win / abs(avg_loss)) if avg_loss != 0 else 0.0
turnover_ratio = total_turnover / (self.initial_capital + 1e-9)
return BacktestResult( return BacktestResult(
total_return_pct=round(total_return_pct, 2), total_return_pct=round(total_return_pct, 2),
sharpe_ratio=round(sharpe, 3), sharpe_ratio=round(sharpe, 3),
@@ -224,7 +249,10 @@ class Backtester:
total_trades=len(trades), total_trades=len(trades),
winning_trades=len(wins), winning_trades=len(wins),
losing_trades=len(losses), losing_trades=len(losses),
trades=trades calmar_ratio=round(calmar, 3),
payoff_ratio=round(payoff, 3),
turnover_ratio=round(turnover_ratio, 3),
trades=trades,
) )
def _empty_result(self) -> BacktestResult: def _empty_result(self) -> BacktestResult:
@@ -237,15 +265,6 @@ class Backtester:
def compare_strategies(ohlcv_data: Dict, strategies: Dict[str, Callable], def compare_strategies(ohlcv_data: Dict, strategies: Dict[str, Callable],
initial_capital: float = 10_000_000) -> Dict[str, BacktestResult]: initial_capital: float = 10_000_000) -> Dict[str, BacktestResult]:
"""
여러 전략 동시 비교
Args:
strategies: {"전략명": strategy_fn, ...}
Returns:
{"전략명": BacktestResult, ...}
"""
bt = Backtester(initial_capital=initial_capital) bt = Backtester(initial_capital=initial_capital)
results = {} results = {}
for name, fn in strategies.items(): for name, fn in strategies.items():

View File

@@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
import time import time
import os import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from modules.services.kis import KISClient from modules.services.kis import KISClient
@@ -130,7 +131,7 @@ class MacroAnalyzer:
if __name__ == "__main__": if __name__ == "__main__":
# 테스트를 위한 코드 # 테스트를 위한 코드
load_dotenv() load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
# 환경변수 로딩 및 클라이언트 초기화 # 환경변수 로딩 및 클라이언트 초기화
if os.getenv("KIS_ENV_TYPE") == "real": if os.getenv("KIS_ENV_TYPE") == "real":

View File

@@ -9,12 +9,15 @@ from datetime import datetime, timedelta
from modules.config import Config from modules.config import Config
from modules.services.kis import KISClient from modules.services.kis import KISClient
from modules.services.news import AsyncNewsCollector from modules.services.news import AsyncNewsCollector
from modules.services.news_snapshot import NewsSnapshotStore
from modules.services.ollama import OllamaManager from modules.services.ollama import OllamaManager
from modules.services.telegram import TelegramMessenger from modules.services.telegram import TelegramMessenger
from modules.analysis.macro import MacroAnalyzer from modules.analysis.macro import MacroAnalyzer
from modules.utils.monitor import SystemMonitor from modules.utils.monitor import SystemMonitor
from modules.utils.performance_db import PerformanceDB from modules.utils.performance_db import PerformanceDB
from modules.strategy.process import analyze_stock_process, calculate_position_size from modules.strategy.process import analyze_stock_process
from modules.strategy.risk_gate import PortfolioRiskGate, RiskConfig
from modules.strategy.daily_ledger import DailyLedger
from modules.analysis.ensemble import get_ensemble from modules.analysis.ensemble import get_ensemble
try: try:
@@ -44,14 +47,24 @@ class AutoTradingBot:
5. 최고가 추적 (트레일링 스탑용) 5. 최고가 추적 (트레일링 스탑용)
6. 상세한 매매 로그 텔레그램 알림 6. 상세한 매매 로그 텔레그램 알림
""" """
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None, eod_event=None): def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None):
# 1. 서비스 초기화 # 1. 서비스 초기화
self.kis = KISClient() self.kis = KISClient()
self.news = AsyncNewsCollector() self.news_snapshot = NewsSnapshotStore("data/news_snapshots.db")
self.news = AsyncNewsCollector(snapshot_store=self.news_snapshot)
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker) self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
self.messenger = TelegramMessenger() self.messenger = TelegramMessenger()
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
# 포트폴리오 리스크 게이트 (v3.2) — 테마 집중/동시보유 한도 검증
self.risk_gate = PortfolioRiskGate(
theme_lookup=lambda t: self.theme_manager.get_themes(t),
config=RiskConfig(
max_total_holdings=Config.MAX_TOTAL_HOLDINGS,
max_tickers_per_theme=Config.MAX_TICKERS_PER_THEME,
max_theme_exposure_ratio=Config.MAX_THEME_EXPOSURE_RATIO,
),
)
self.ollama_monitor = OllamaManager() self.ollama_monitor = OllamaManager()
# 2. 유틸리티 초기화 # 2. 유틸리티 초기화
@@ -71,23 +84,11 @@ class AutoTradingBot:
# [v2.0] 최근 매크로 상태 캐싱 # [v2.0] 최근 매크로 상태 캐싱
self.last_macro_status = None self.last_macro_status = None
# [v2.1] 연속 손절 안전장치 # [v3.2] 당일 상태 집약 (연속손절/당일매수/신호점수/플래그)
# 당일 손절 횟수가 임계치 초과 시 매수 일시 중단 self.ledger = DailyLedger()
self._consecutive_stop_losses_today = 0
self._buy_paused_until = None # datetime or None
# [v3.1] 사이클 간 당일 매수 금액 추적 (KIS T+2 미차감 문제 보완)
self._today_buy_total = 0 # 당일 누적 매수 집행 금액 (원)
self._today_buy_date = None # 날짜 리셋용
# [v3.1] 앙상블 학습용 매수 당시 신호 점수 보관 {ticker: {tech, sentiment, lstm}}
# 매도 시 실현 수익률과 함께 ensemble.record_trade()에 전달
self._buy_scores: dict = {}
# 4. 프로세스 관리 # 4. 프로세스 관리
self.shutdown_event = shutdown_event self.shutdown_event = shutdown_event
self.eod_event = eod_event # EOD 셧다운 시그널 (→ main_server 자동 종료)
self._eod_shutdown_done = False # 당일 EOD 처리 완료 여부
# KRX 캘린더 (장 운영 여부 판단) # KRX 캘린더 (장 운영 여부 판단)
from modules.utils.market_calendar import get_calendar from modules.utils.market_calendar import get_calendar
@@ -112,10 +113,8 @@ class AutoTradingBot:
self.history_file = Config.HISTORY_FILE self.history_file = Config.HISTORY_FILE
self.load_trade_history() self.load_trade_history()
# 7-1. 성과 DB 및 평가 플래그 # 7-1. 성과 DB 및 수동 평가 요청 플래그 (주간/스냅샷 플래그는 ledger로 이관)
self.perf_db = PerformanceDB() self.perf_db = PerformanceDB()
self.weekly_eval_sent = False
self._snapshot_taken_today = False
self._pending_evaluate = False self._pending_evaluate = False
# 8. AI 하드웨어 점검 # 8. AI 하드웨어 점검
@@ -175,90 +174,10 @@ class AutoTradingBot:
self.perf_db.save_daily_snapshot( self.perf_db.save_daily_snapshot(
total_eval_snap, deposit_snap, holdings_count_snap, kospi_close) total_eval_snap, deposit_snap, holdings_count_snap, kospi_close)
self._snapshot_taken_today = True self.ledger.snapshot_taken = True
except Exception as e: except Exception as e:
print(f"[Bot] Daily snapshot error: {e}") print(f"[Bot] Daily snapshot error: {e}")
async def _end_of_day_shutdown(self):
"""
[EOD] 마감 전체 학습 상태 저장 + 프로세스 종료
저장 항목:
1. 앙상블 가중치 & 매매 히스토리 (ensemble_history.json)
2. 트레일링 스탑 최고가 (peak_prices.json)
3. 일일 거래 기록 (daily_trade_history.json)
4. 일별 자산 스냅샷 (perf_db)
5. EOD 마커 파일 (data/.eod_date Watchdog 재시작 차단)
"""
print("[Bot] ===== EOD 상태 저장 시작 =====")
# 1. 앙상블 가중치 강제 저장
try:
ensemble = get_ensemble()
ensemble._save()
print("[Bot] [EOD] 앙상블 가중치 저장 완료")
except Exception as e:
print(f"[Bot] [EOD] 앙상블 저장 오류: {e}")
# 2. 트레일링 스탑 최고가 저장
try:
self._save_peak_prices()
print("[Bot] [EOD] 최고가 데이터 저장 완료")
except Exception as e:
print(f"[Bot] [EOD] 최고가 저장 오류: {e}")
# 3. 일일 거래 기록 저장
try:
self.save_trade_history()
print(f"[Bot] [EOD] 거래 기록 저장 완료 ({len(self.daily_trade_history)}건)")
except Exception as e:
print(f"[Bot] [EOD] 거래 기록 저장 오류: {e}")
# 4. 일별 자산 스냅샷 (미완료 시)
if not self._snapshot_taken_today:
try:
balance_snap = self.kis.get_balance()
macro_cached = self.last_macro_status or {"indicators": {}}
self._take_daily_snapshot(macro_cached, balance_snap)
print("[Bot] [EOD] 자산 스냅샷 저장 완료")
except Exception as e:
print(f"[Bot] [EOD] 스냅샷 저장 오류: {e}")
# 5. EOD 마커 파일 기록 (Watchdog 재시작 차단)
try:
from pathlib import Path
import datetime as _dt
eod_file = Path(Config.DATA_DIR) / ".eod_date"
eod_file.parent.mkdir(exist_ok=True)
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
print(f"[Bot] [EOD] 마커 파일 기록: {eod_file}")
except Exception as e:
print(f"[Bot] [EOD] 마커 파일 오류: {e}")
# 6. 텔레그램 알림
try:
today_trades = len(self.daily_trade_history)
try:
nxt = self._calendar.next_trading_open()
next_str = nxt.strftime('%m/%d(%a) %H:%M')
except Exception:
next_str = "미정"
self.messenger.send_message(
f"[장 마감] EOD 상태 저장 완료\n"
f"오늘 매매: {today_trades}\n"
f"다음 거래일: {next_str} KST 자동 시작"
)
except Exception as e:
print(f"[Bot] [EOD] 알림 오류: {e}")
print("[Bot] ===== EOD 상태 저장 완료 =====")
# 7. 종료 시그널
if self.eod_event:
self.eod_event.set() # main_server → 서버 프로세스 자동 종료
if self.shutdown_event:
self.shutdown_event.set() # 텔레그램 봇 등 자식 프로세스 종료
async def _run_weekly_evaluation(self): async def _run_weekly_evaluation(self):
"""주간 성과 평가 실행 후 텔레그램으로 전송.""" """주간 성과 평가 실행 후 텔레그램으로 전송."""
try: try:
@@ -270,7 +189,7 @@ class AutoTradingBot:
if len(report) > 4000: if len(report) > 4000:
report = report[:4000] + "\n... (일부 생략)" report = report[:4000] + "\n... (일부 생략)"
self.messenger.send_message(report) self.messenger.send_message(report)
self.weekly_eval_sent = True self.ledger.weekly_eval_sent = True
print("[Bot] Weekly evaluation report sent.") print("[Bot] Weekly evaluation report sent.")
except Exception as e: except Exception as e:
print(f"[Bot] Weekly evaluation error: {e}") print(f"[Bot] Weekly evaluation error: {e}")
@@ -465,22 +384,16 @@ class AutoTradingBot:
except Exception as e: except Exception as e:
self.messenger.send_message(f"Update Failed: {e}") self.messenger.send_message(f"Update Failed: {e}")
# 4. 리셋 (09:00) # 4. 리셋 (09:00) — 일별 상태는 ledger.reset_if_new_day가 통합 관리
if now.hour == 9 and now.minute < 5: if now.hour == 9 and now.minute < 5:
self.daily_trade_history = [] self.daily_trade_history = []
self.save_trade_history() self.save_trade_history()
self.report_sent = False self.report_sent = False
self.weekly_eval_sent = False
self._snapshot_taken_today = False
self.discovered_stocks.clear() self.discovered_stocks.clear()
self.watchlist_updated_today = False self.watchlist_updated_today = False
# 전일 최고가 초기화 (보유하지 않는 종목)
self._load_peak_prices() self._load_peak_prices()
# [v3.1] 당일 매수 추적 리셋 if self.ledger.reset_if_new_day(now):
self._today_buy_total = 0 print(f"[Bot] 일일 장부 리셋 (날짜: {now.date()})")
self._today_buy_date = now.date()
self._buy_scores.clear() # 미매도 종목 신호 점수도 초기화
print(f"[Bot] 일일 매수 추적 리셋 (날짜: {now.date()})")
# 5. 시스템 감시 (3분 간격) # 5. 시스템 감시 (3분 간격)
self.monitor.check_health() self.monitor.check_health()
@@ -490,7 +403,7 @@ class AutoTradingBot:
if now.hour == 15 and now.minute >= 40: if now.hour == 15 and now.minute >= 40:
self.send_daily_report() self.send_daily_report()
# 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록) # 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록)
if now.hour == 16 and now.minute <= 30 and not self._snapshot_taken_today: if now.hour == 16 and now.minute <= 30 and not self.ledger.snapshot_taken:
try: try:
balance_snap = self.kis.get_balance() balance_snap = self.kis.get_balance()
self._take_daily_snapshot(macro_status, balance_snap) self._take_daily_snapshot(macro_status, balance_snap)
@@ -498,21 +411,12 @@ class AutoTradingBot:
print(f"[Bot] Snapshot error: {e}") print(f"[Bot] Snapshot error: {e}")
# 주간 평가 (금요일 15:35~15:45, 장 마감 직후) # 주간 평가 (금요일 15:35~15:45, 장 마감 직후)
if (now.weekday() == 4 and now.hour == 15 if (now.weekday() == 4 and now.hour == 15
and 35 <= now.minute <= 45 and not self.weekly_eval_sent): and 35 <= now.minute <= 45 and not self.ledger.weekly_eval_sent):
await self._run_weekly_evaluation() await self._run_weekly_evaluation()
# [EOD 셧다운] 장 마감 후 Config.EOD_SHUTDOWN_BUFFER_MIN 분 경과 시 저장 후 종료
eod_buffer = now.hour == 15 and now.minute >= (30 + Config.EOD_SHUTDOWN_BUFFER_MIN)
eod_buffer = eod_buffer or (now.hour >= 16) # 16시 이후도 포함
if eod_buffer and not self._eod_shutdown_done:
self._eod_shutdown_done = True
await self._end_of_day_shutdown()
return
# 장 외 시간에는 서킷 브레이커도 리셋 # 장 외 시간에는 서킷 브레이커도 리셋
self.monitor.reset_circuit() self.monitor.reset_circuit()
if not self._eod_shutdown_done: print("[Bot] Market Closed. Waiting...")
print("[Bot] Market Closed. Waiting...")
return return
# [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단 # [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단
@@ -554,27 +458,15 @@ class AutoTradingBot:
news_data = await self.news.get_market_news_async() news_data = await self.news.get_market_news_async()
raw_deposit = int(balance.get("deposit", 0)) raw_deposit = int(balance.get("deposit", 0))
# 날짜 전환 안전망 (09:00 리셋 블록에서 누락됐을 가능성 대비)
self.ledger.reset_if_new_day(now)
# [v3.1] 사이클 간 누적 매수금액 추적 (KIS 모의투자 T+2 미차감 보완)
# KIS API의 dnca_tot_amt(예수금)는 당일 매수를 즉시 차감하지 않아
# 매 사이클마다 전체 잔고처럼 보이는 문제를 방지
today = now.date()
if self._today_buy_date != today:
# 날짜 변경 시 리셋 (09:00 리셋 블록에서 이미 처리되지만 안전망으로 이중 체크)
self._today_buy_total = 0
self._today_buy_date = today
# KIS가 제공하는 금일매수금액이 있으면 그것을 우선 사용 (더 정확)
kis_today_buy = int(balance.get("today_buy_amt", 0)) kis_today_buy = int(balance.get("today_buy_amt", 0))
if kis_today_buy > 0: effective_today_buy = self.ledger.effective_today_buy(kis_today_buy)
# KIS 값이 유효하면 로컬 추적값과 최댓값으로 사용 (둘 다 참조) tracking_deposit = self.ledger.available_deposit(
effective_today_buy = max(kis_today_buy, self._today_buy_total) raw_deposit, Config.MAX_DAILY_BUY_RATIO, kis_today_buy
else: )
effective_today_buy = self._today_buy_total
# 실제 사용 가능한 예수금 = KIS 예수금 - 당일 이미 집행한 매수금액
max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO) max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO)
tracking_deposit = max(0, min(raw_deposit, max_daily_buy) - effective_today_buy)
print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | " print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | "
f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)") f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)")
@@ -654,14 +546,10 @@ class AutoTradingBot:
continue continue
# [v2.1] 연속 손절 후 매수 일시 중단 체크 # [v2.1] 연속 손절 후 매수 일시 중단 체크
if self._buy_paused_until and datetime.now() < self._buy_paused_until: if self.ledger.is_buy_paused(datetime.now()):
print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: " print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: "
f"{self._buy_paused_until.strftime('%H:%M')}) - {ticker_name}") f"{self.ledger.buy_paused_until.strftime('%H:%M')}) - {ticker_name}")
continue continue
elif self._buy_paused_until and datetime.now() >= self._buy_paused_until:
self._buy_paused_until = None
self._consecutive_stop_losses_today = 0
print("[Bot] 매수 일시 중단 해제")
current_price = float(res['current_price']) current_price = float(res['current_price'])
if current_price <= 0: if current_price <= 0:
@@ -676,6 +564,31 @@ class AutoTradingBot:
required_amount = current_price * qty required_amount = current_price * qty
# [v3.2] 포트폴리오 리스크 게이트 검증 (테마 집중/동시보유 상한)
risk_holdings = [
{"ticker": c, "eval_amount": int(float(h.get("current_price", 0))
* int(h.get("qty", 0)))}
for c, h in current_holdings.items()
]
risk_dec = self.risk_gate.evaluate_buy(
ticker=ticker,
candidate_amount=int(required_amount),
current_holdings=risk_holdings,
total_capital=max(total_eval, 1),
)
if not risk_dec.allowed:
print(f"[Bot] [Skip Buy] RiskGate: {risk_dec.reason} ({ticker_name})")
continue
if risk_dec.max_allowed_amount < required_amount:
new_qty = int(risk_dec.max_allowed_amount / current_price)
if new_qty <= 0:
print(f"[Bot] [Skip Buy] RiskGate 부분허용 금액 부족 ({ticker_name})")
continue
print(f"[Bot] RiskGate 부분허용: qty {qty}{new_qty} "
f"({risk_dec.reason})")
qty = new_qty
required_amount = current_price * qty
# 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액) # 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액)
if tracking_deposit < required_amount: if tracking_deposit < required_amount:
qty = int(tracking_deposit / current_price) qty = int(tracking_deposit / current_price)
@@ -727,19 +640,16 @@ class AutoTradingBot:
) )
tracking_deposit -= required_amount tracking_deposit -= required_amount
# [v3.1] 사이클 간 추적 (KIS T+2 미차감 보완) self.ledger.record_buy(
self._today_buy_total += required_amount ticker, int(required_amount),
{"tech": res.get("tech", 0.5),
"sentiment": res.get("sentiment", 0.5),
"lstm": res.get("lstm_score", 0.5)},
)
buys_this_cycle += 1 buys_this_cycle += 1
print(f"[Bot] 당일 누적 매수: {self._today_buy_total:,}" print(f"[Bot] 당일 누적 매수: {self.ledger.today_buy_total:,}"
f"(잔여 예수금: {tracking_deposit:,}원)") f"(잔여 예수금: {tracking_deposit:,}원)")
# [v3.1] 앙상블 학습용 매수 신호 점수 보관 (매도 시 record_trade에 활용)
self._buy_scores[ticker] = {
"tech": res.get("tech", 0.5),
"sentiment": res.get("sentiment", 0.5),
"lstm": res.get("lstm_score", 0.5),
}
# 최고가 초기 설정 # 최고가 초기 설정
self.peak_prices[ticker] = current_price self.peak_prices[ticker] = current_price
self._save_peak_prices() self._save_peak_prices()
@@ -777,7 +687,7 @@ class AutoTradingBot:
self.perf_db.close_trade(ticker, sell_price, yld) self.perf_db.close_trade(ticker, sell_price, yld)
# [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률) # [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률)
buy_sig = self._buy_scores.pop(ticker, None) buy_sig = self.ledger.pop_buy_scores(ticker)
if buy_sig is not None: if buy_sig is not None:
try: try:
get_ensemble().record_trade( get_ensemble().record_trade(
@@ -793,22 +703,17 @@ class AutoTradingBot:
except Exception as _ee: except Exception as _ee:
print(f"[Bot] [Ensemble] record_trade 실패: {_ee}") print(f"[Bot] [Ensemble] record_trade 실패: {_ee}")
# [v2.1] 손절 횟수 추적 → 연속 3회 손절 시 매수 30분 일시 중단 # [v2.1] 손절 횟수 추적 → 연속 N회 손절 시 매수 일시 중단
if yld < 0: triggered = self.ledger.record_sell_outcome(yld, datetime.now())
self._consecutive_stop_losses_today += 1 if triggered:
if self._consecutive_stop_losses_today >= 3: warn_msg = (
self._buy_paused_until = datetime.now() + timedelta(minutes=30) f"⛔ <b>[매수 일시 중단]</b> 당일 손절 "
warn_msg = ( f"{self.ledger.consecutive_stop_losses}회 → "
f"⛔ <b>[매수 일시 중단]</b> 당일 손절 " f"{self.ledger.stop_loss_pause_minutes}분간 매수 정지 "
f"{self._consecutive_stop_losses_today}회 → " f"(재개: {self.ledger.buy_paused_until.strftime('%H:%M')})"
f"30분간 매수 정지 (재개: " )
f"{self._buy_paused_until.strftime('%H:%M')})" self.messenger.send_message(warn_msg)
) print(f"[Bot] 연속 손절 {self.ledger.consecutive_stop_losses}회 → 매수 일시 중단")
self.messenger.send_message(warn_msg)
print(f"[Bot] 연속 손절 {self._consecutive_stop_losses_today}회 → 매수 30분 중단")
else:
# 수익 실현 시 연속 손절 카운터 리셋
self._consecutive_stop_losses_today = 0
# 최고가 기록 삭제 # 최고가 기록 삭제
if ticker in self.peak_prices: if ticker in self.peak_prices:
@@ -838,27 +743,6 @@ class AutoTradingBot:
def loop(self): def loop(self):
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]") print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]")
# [캘린더 체크] 오늘이 휴장일이면 알림 후 즉시 EOD 종료
if not self._calendar.is_trading_day():
summary = self._calendar.status_summary()
print(f"[Bot] 오늘은 휴장일 ({summary}) — 봇을 시작하지 않습니다.")
self.messenger.send_message(
f"[Bot] 오늘은 휴장일입니다.\n{summary}"
)
# EOD 마커 기록 후 종료
try:
from pathlib import Path
import datetime as _dt
eod_file = Path(Config.DATA_DIR) / ".eod_date"
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
except Exception:
pass
if self.eod_event:
self.eod_event.set()
if self.shutdown_event:
self.shutdown_event.set()
return
_llm_label = ( _llm_label = (
f"Gemini ({Config.GEMINI_MODEL})" f"Gemini ({Config.GEMINI_MODEL})"
if Config.GEMINI_API_KEY if Config.GEMINI_API_KEY

View File

@@ -1,9 +1,10 @@
import os import os
import sys import sys
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
# .env 파일 로드 # .env 파일 로드
load_dotenv() load_dotenv(Path(__file__).parent.parent.parent / ".env")
class Config: class Config:
# 1. 기본 설정 # 1. 기본 설정
@@ -50,6 +51,11 @@ class Config:
MAX_BUY_PER_CYCLE = int(os.getenv("MAX_BUY_PER_CYCLE", "2")) # 사이클당 최대 매수 종목 수 MAX_BUY_PER_CYCLE = int(os.getenv("MAX_BUY_PER_CYCLE", "2")) # 사이클당 최대 매수 종목 수
EOD_SHUTDOWN_BUFFER_MIN = int(os.getenv("EOD_SHUTDOWN_BUFFER_MIN", "5")) # 장 마감 후 EOD 처리까지 대기 분 EOD_SHUTDOWN_BUFFER_MIN = int(os.getenv("EOD_SHUTDOWN_BUFFER_MIN", "5")) # 장 마감 후 EOD 처리까지 대기 분
MAX_DAILY_BUY_RATIO = float(os.getenv("MAX_DAILY_BUY_RATIO", "0.80")) # 예수금 대비 일일 최대 매수 비율 MAX_DAILY_BUY_RATIO = float(os.getenv("MAX_DAILY_BUY_RATIO", "0.80")) # 예수금 대비 일일 최대 매수 비율
# 포트폴리오 리스크 게이트 (v3.2)
MAX_TICKERS_PER_THEME = int(os.getenv("MAX_TICKERS_PER_THEME", "2")) # 테마당 최대 종목 수
MAX_THEME_EXPOSURE_RATIO = float(os.getenv("MAX_THEME_EXPOSURE_RATIO", "0.40")) # 테마당 최대 노출 비율 (총자산 대비)
MAX_TOTAL_HOLDINGS = int(os.getenv("MAX_TOTAL_HOLDINGS", "7")) # 총 보유 종목 수 상한
# 6. 데이터 경로 # 6. 데이터 경로
DATA_DIR = os.path.join(BASE_DIR, "data") DATA_DIR = os.path.join(BASE_DIR, "data")

View File

@@ -1,6 +1,23 @@
import time import time
import requests import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import Optional
def _parse_items(root, max_items):
"""RSS item → [{title, url, pub_date, source}]"""
out = []
for item in root.findall(".//item")[:max_items]:
t = item.find("title")
l = item.find("link")
p = item.find("pubDate")
title = (t.text or "").strip() if t is not None else ""
url = (l.text or "").strip() if l is not None else ""
pub = (p.text or "").strip() if p is not None else ""
if not title:
continue
out.append({"title": title, "url": url, "pub_date": pub, "source": "Google News"})
return out
class NewsCollector: class NewsCollector:
@@ -11,24 +28,29 @@ class NewsCollector:
try: try:
resp = requests.get(url, timeout=5) resp = requests.get(url, timeout=5)
root = ET.fromstring(resp.content) root = ET.fromstring(resp.content)
items = [] return _parse_items(root, 5)
for item in root.findall(".//item")[:5]:
title = item.find("title").text
items.append({"title": title, "source": "Google News"})
return items
except Exception as e: except Exception as e:
print(f"[News] Collection failed: {e}") print(f"[News] Collection failed: {e}")
return [] return []
class AsyncNewsCollector: class AsyncNewsCollector:
"""비동기 뉴스 수집 + 5분 캐싱""" """비동기 뉴스 수집 + 5분 캐싱 + (옵션) 스냅샷 저장"""
def __init__(self): def __init__(self, snapshot_store=None):
self._cache = None self._cache = None
self._cache_time = 0 self._cache_time = 0
self._cache_ttl = 300 # 5분 self._cache_ttl = 300 # 5분
self._stock_cache = {} # {stock_name: (items, timestamp)} self._stock_cache = {} # {stock_name: (items, timestamp)}
self._snap = snapshot_store # NewsSnapshotStore | None
def _save_snapshot(self, items, query: str, ticker: Optional[str] = None):
if not self._snap or not items:
return
try:
self._snap.save_many(items, query=query, ticker=ticker)
except Exception as e:
print(f"[News] snapshot 저장 실패: {e}")
def get_market_news(self, query="주식 시장"): def get_market_news(self, query="주식 시장"):
"""동기 인터페이스 (하위 호환)""" """동기 인터페이스 (하위 호환)"""
@@ -39,6 +61,7 @@ class AsyncNewsCollector:
result = NewsCollector.get_market_news(query) result = NewsCollector.get_market_news(query)
self._cache = result self._cache = result
self._cache_time = now self._cache_time = now
self._save_snapshot(result, query=query)
return result return result
async def get_market_news_async(self, query="주식 시장"): async def get_market_news_async(self, query="주식 시장"):
@@ -54,13 +77,10 @@ class AsyncNewsCollector:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
content = await resp.read() content = await resp.read()
root = ET.fromstring(content) root = ET.fromstring(content)
items = [] items = _parse_items(root, 5)
for item in root.findall(".//item")[:5]:
title = item.find("title").text
items.append({"title": title, "source": "Google News"})
self._cache = items self._cache = items
self._cache_time = now self._cache_time = now
self._save_snapshot(items, query=query)
return items return items
except ImportError: except ImportError:
return self.get_market_news(query) return self.get_market_news(query)
@@ -70,9 +90,10 @@ class AsyncNewsCollector:
return self._cache return self._cache
return self.get_market_news(query) return self.get_market_news(query)
async def get_stock_news_async(self, stock_name, max_items=3): async def get_stock_news_async(self, stock_name, max_items=3, ticker: Optional[str] = None):
"""종목별 뉴스 수집 (5분 캐싱) """종목별 뉴스 수집 (5분 캐싱)
stock_name: 종목 이름 (e.g. '삼성전자', 'SK하이닉스') stock_name: 종목 이름 (e.g. '삼성전자', 'SK하이닉스')
ticker: 스냅샷 저장 종목코드 (옵션)
""" """
now = time.time() now = time.time()
cached = self._stock_cache.get(stock_name) cached = self._stock_cache.get(stock_name)
@@ -88,13 +109,9 @@ class AsyncNewsCollector:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
content = await resp.read() content = await resp.read()
root = ET.fromstring(content) root = ET.fromstring(content)
items = [] items = _parse_items(root, max_items)
for item in root.findall(".//item")[:max_items]:
title_el = item.find("title")
if title_el is not None and title_el.text:
items.append({"title": title_el.text, "source": "Google News"})
self._stock_cache[stock_name] = (items, now) self._stock_cache[stock_name] = (items, now)
self._save_snapshot(items, query=f"{stock_name} 주가", ticker=ticker)
return items return items
except Exception as e: except Exception as e:
print(f"[News] 종목 뉴스 수집 실패 ({stock_name}): {e}") print(f"[News] 종목 뉴스 수집 실패 ({stock_name}): {e}")

View File

@@ -0,0 +1,189 @@
"""
뉴스 스냅샷 인프라 (v3.2)
목적:
- 수집한 뉴스를 SQLite에 타임스탬프와 함께 영구 저장
- 사후 감성 신호 재검증 (LLM 재호출 / 모델 비교) 가능하게
- 백테스트에서 '그 시점에 실제로 알 수 있던 뉴스'만 사용
스키마:
news_snapshots(
id INTEGER PK,
captured_at TEXT, # ISO8601 (KST) — 수집 시점
query TEXT, # 수집 쿼리 (예: '주식 시장', '삼성전자')
ticker TEXT, # 종목 코드 (종목 뉴스일 때, else NULL)
title TEXT,
url TEXT UNIQUE,
pub_date TEXT, # RSS pubDate 원본
source TEXT DEFAULT 'google_news'
)
sentiment_scores( # 야간 배치로 사후 생성
news_id INTEGER PK,
scored_at TEXT,
model TEXT,
sentiment REAL, # -1.0 ~ 1.0
confidence REAL,
raw_json TEXT,
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
)
순수 I/O 모듈 — 네트워크 의존성 없음 → unit 테스트 가능.
"""
import os
import sqlite3
from datetime import datetime, timezone, timedelta
from typing import Iterable, List, Optional, Dict
KST = timezone(timedelta(hours=9))
class NewsSnapshotStore:
"""
SQLite 기반 뉴스 스냅샷 저장소.
사용 예:
store = NewsSnapshotStore("data/news_snapshots.db")
store.save_many(items, query="삼성전자", ticker="005930")
rows = store.query_between(start, end, ticker="005930")
"""
def __init__(self, db_path: str):
self.db_path = db_path
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
self._init_schema()
# ──────────────────────────────────────────────
# 스키마
# ──────────────────────────────────────────────
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_schema(self):
with self._connect() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS news_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
captured_at TEXT NOT NULL,
query TEXT NOT NULL,
ticker TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
pub_date TEXT,
source TEXT DEFAULT 'google_news'
);
CREATE INDEX IF NOT EXISTS idx_news_captured
ON news_snapshots(captured_at);
CREATE INDEX IF NOT EXISTS idx_news_ticker
ON news_snapshots(ticker, captured_at);
CREATE TABLE IF NOT EXISTS sentiment_scores (
news_id INTEGER PRIMARY KEY,
scored_at TEXT NOT NULL,
model TEXT NOT NULL,
sentiment REAL NOT NULL,
confidence REAL NOT NULL,
raw_json TEXT,
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
);
""")
# ──────────────────────────────────────────────
# 쓰기
# ──────────────────────────────────────────────
def save_many(self, items: Iterable[Dict], query: str,
ticker: Optional[str] = None,
captured_at: Optional[datetime] = None) -> int:
"""
뉴스 다건 저장. URL 기준 중복 자동 무시.
Args:
items: [{"title": str, "url": str, "pub_date": str?}, ...]
Returns:
실제로 삽입된 행 수
"""
if captured_at is None:
captured_at = datetime.now(KST)
ts = captured_at.isoformat()
rows = []
for it in items:
title = (it.get("title") or "").strip()
url = (it.get("url") or "").strip()
if not title or not url:
continue
rows.append((ts, query, ticker, title, url, it.get("pub_date")))
if not rows:
return 0
with self._connect() as conn:
before = conn.total_changes
conn.executemany(
"INSERT OR IGNORE INTO news_snapshots "
"(captured_at, query, ticker, title, url, pub_date) "
"VALUES (?, ?, ?, ?, ?, ?)",
rows,
)
inserted = conn.total_changes - before
return inserted
def save_sentiment(self, news_id: int, model: str,
sentiment: float, confidence: float,
raw_json: str = "",
scored_at: Optional[datetime] = None) -> None:
if scored_at is None:
scored_at = datetime.now(KST)
with self._connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO sentiment_scores "
"(news_id, scored_at, model, sentiment, confidence, raw_json) "
"VALUES (?, ?, ?, ?, ?, ?)",
(news_id, scored_at.isoformat(), model,
float(sentiment), float(confidence), raw_json),
)
# ──────────────────────────────────────────────
# 읽기
# ──────────────────────────────────────────────
def query_between(self, start: datetime, end: datetime,
ticker: Optional[str] = None,
query: Optional[str] = None) -> List[sqlite3.Row]:
"""특정 기간 내 수집된 뉴스 조회."""
sql = "SELECT * FROM news_snapshots WHERE captured_at >= ? AND captured_at < ?"
args = [start.isoformat(), end.isoformat()]
if ticker is not None:
sql += " AND ticker = ?"
args.append(ticker)
if query is not None:
sql += " AND query = ?"
args.append(query)
sql += " ORDER BY captured_at ASC"
with self._connect() as conn:
return list(conn.execute(sql, args))
def pending_sentiment(self, limit: int = 100) -> List[sqlite3.Row]:
"""아직 감성 점수가 없는 뉴스 반환 (야간 배치용)."""
with self._connect() as conn:
return list(conn.execute(
"""SELECT n.* FROM news_snapshots n
LEFT JOIN sentiment_scores s ON s.news_id = n.id
WHERE s.news_id IS NULL
ORDER BY n.captured_at DESC
LIMIT ?""",
(limit,)
))
def stats(self) -> Dict:
"""DB 통계 (row 수, 감성 커버리지)."""
with self._connect() as conn:
total = conn.execute("SELECT COUNT(*) FROM news_snapshots").fetchone()[0]
scored = conn.execute("SELECT COUNT(*) FROM sentiment_scores").fetchone()[0]
return {
"total_news": total,
"scored": scored,
"pending": total - scored,
"coverage_pct": (scored / total * 100) if total else 0.0,
}

View File

@@ -6,9 +6,10 @@ import os
import sys import sys
import time import time
import multiprocessing import multiprocessing
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv(Path(__file__).parent.parent.parent.parent.parent / ".env")
def run_telegram_bot_standalone(ipc_lock=None, command_queue=None, shutdown_event=None): def run_telegram_bot_standalone(ipc_lock=None, command_queue=None, shutdown_event=None):

View File

@@ -0,0 +1,130 @@
"""
일일 거래 장부 (DailyLedger) — v3.2
bot.py에 흩어져 있던 당일 상태를 한 객체로 집약:
- 당일 누적 매수금액 (KIS T+2 미차감 보완용)
- 연속 손절 카운터 + 매수 일시중단 타이머
- 미매도 종목의 매수 신호 점수 (앙상블 학습용)
- 일별 스냅샷/주간평가 플래그
날짜가 바뀌면 reset_if_new_day()가 자동 초기화.
순수 객체로 구현 — 외부 I/O 없음 → 단위 테스트 가능.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta, date as date_cls
from typing import Dict, Optional
@dataclass
class DailyLedger:
# ── 당일 매수 회계 ──
today_buy_total: int = 0
today_buy_date: Optional[date_cls] = None
# ── 연속 손절 / 매수 일시 중단 ──
consecutive_stop_losses: int = 0
buy_paused_until: Optional[datetime] = None
stop_loss_pause_threshold: int = 3
stop_loss_pause_minutes: int = 30
# ── 앙상블 학습용: 미매도 종목의 매수 신호 점수 ──
buy_scores: Dict[str, dict] = field(default_factory=dict)
# ── 일일 플래그 ──
snapshot_taken: bool = False
weekly_eval_sent: bool = False
# ──────────────────────────────────────────────
# 날짜 전환
# ──────────────────────────────────────────────
def reset_if_new_day(self, now: datetime) -> bool:
"""
오늘 날짜 기준으로 상태 초기화. 이미 오늘 자로 초기화됐으면 no-op.
Returns:
True — 실제로 초기화를 수행한 경우
False — 같은 날이라 그대로 둔 경우
"""
today = now.date()
if self.today_buy_date == today:
return False
self.today_buy_total = 0
self.today_buy_date = today
self.buy_scores.clear()
self.snapshot_taken = False
self.weekly_eval_sent = False
# 연속 손절 카운터 / 일시중단 타이머는 날짜 전환 시에만 초기화
self.consecutive_stop_losses = 0
self.buy_paused_until = None
return True
# ──────────────────────────────────────────────
# 매수 / 매도 기록
# ──────────────────────────────────────────────
def record_buy(self, ticker: str, amount: int, scores: dict) -> None:
"""매수 체결 기록. amount는 집행 금액(원), scores는 앙상블 신호."""
self.today_buy_total += int(amount)
self.buy_scores[ticker] = dict(scores)
def pop_buy_scores(self, ticker: str) -> Optional[dict]:
"""매도 체결 시 앙상블 학습을 위해 매수 당시 신호를 반환하고 제거."""
return self.buy_scores.pop(ticker, None)
# ──────────────────────────────────────────────
# 손절 관리
# ──────────────────────────────────────────────
def record_sell_outcome(self, outcome_pct: float, now: datetime) -> bool:
"""
매도 결과를 반영해 연속 손절 카운터 업데이트.
Returns:
True — 임계치 도달 → 매수 일시중단 활성화됨
False — 임계치 미도달
"""
if outcome_pct < 0:
self.consecutive_stop_losses += 1
if self.consecutive_stop_losses >= self.stop_loss_pause_threshold:
self.buy_paused_until = now + timedelta(
minutes=self.stop_loss_pause_minutes
)
return True
else:
self.consecutive_stop_losses = 0
return False
def is_buy_paused(self, now: datetime) -> bool:
"""
매수 일시중단 상태 조회. 만료되면 자동 해제 + 카운터 리셋.
"""
if self.buy_paused_until is None:
return False
if now >= self.buy_paused_until:
self.buy_paused_until = None
self.consecutive_stop_losses = 0
return False
return True
# ──────────────────────────────────────────────
# 예수금 계산 (KIS T+2 보완)
# ──────────────────────────────────────────────
def effective_today_buy(self, kis_today_buy: int) -> int:
"""
KIS API가 반환한 당일 매수금(`thdt_buy_amt`)과
로컬 누적값 중 더 큰 값을 신뢰.
(모의투자는 T+2 미차감으로 인해 과소 보고되는 경우 있음)
"""
return max(int(kis_today_buy or 0), self.today_buy_total)
def available_deposit(self, raw_deposit: int, max_daily_buy_ratio: float,
kis_today_buy: int = 0) -> int:
"""
당일 사용 가능한 예수금 계산.
max_daily_buy = raw_deposit × ratio
avail = min(raw_deposit, max_daily_buy) effective_today_buy
"""
if raw_deposit <= 0:
return 0
max_daily_buy = int(raw_deposit * max_daily_buy_ratio)
used = self.effective_today_buy(kis_today_buy)
return max(0, min(raw_deposit, max_daily_buy) - used)

View File

@@ -0,0 +1,150 @@
"""
포트폴리오 리스크 게이트 (v3.2)
매수 체결 직전 호출되어 포트폴리오 레벨 제약을 검증:
1. 총 보유 종목 수 상한
2. 테마당 동시 보유 종목 수 상한
3. 테마당 노출 금액 비율 상한 (총자산 대비)
기존 매수 필터(예수금, 종목당 상한, 사이클당 매수 수)는 유지하고
이 게이트가 "같은 테마에 집중되는 포지션"을 차단한다.
순수 함수로 구현 — 의존성 없음 → 단위 테스트 가능.
"""
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional
@dataclass
class RiskDecision:
allowed: bool
reason: str = ""
max_allowed_amount: int = 0 # 일부만 허용되는 경우 (테마 노출 상한)
@dataclass
class RiskConfig:
max_total_holdings: int = 7
max_tickers_per_theme: int = 2
max_theme_exposure_ratio: float = 0.40
class PortfolioRiskGate:
"""
사용 예:
gate = PortfolioRiskGate(theme_map, RiskConfig())
decision = gate.evaluate_buy(
ticker="005930",
candidate_amount=3_000_000,
current_holdings=[{"ticker":"000660","eval_amount":2_500_000}, ...],
total_capital=50_000_000,
)
if not decision.allowed: skip
elif decision.max_allowed_amount < candidate_amount: partial buy
"""
def __init__(self, theme_lookup, config: Optional[RiskConfig] = None):
"""
Args:
theme_lookup: callable(ticker:str) -> list[str] (종목→테마 매핑 함수)
혹은 dict 형태도 허용.
config: RiskConfig
"""
if callable(theme_lookup):
self._theme_of = theme_lookup
elif isinstance(theme_lookup, dict):
self._theme_of = lambda t: theme_lookup.get(t, [])
else:
raise TypeError("theme_lookup must be callable or dict")
self.config = config or RiskConfig()
# ──────────────────────────────────────────────
# 내부: 테마별 현재 노출 집계
# ──────────────────────────────────────────────
def _aggregate_by_theme(self, holdings: Iterable[dict]) -> Dict[str, dict]:
"""
Returns:
{theme: {"tickers": set, "amount": int}}
"""
agg: Dict[str, dict] = {}
for h in holdings:
tkr = h.get("ticker")
amt = int(h.get("eval_amount", 0) or 0)
if not tkr:
continue
themes = self._theme_of(tkr) or []
for th in themes:
bucket = agg.setdefault(th, {"tickers": set(), "amount": 0})
bucket["tickers"].add(tkr)
bucket["amount"] += amt
return agg
# ──────────────────────────────────────────────
# 공개 API
# ──────────────────────────────────────────────
def evaluate_buy(self, ticker: str, candidate_amount: int,
current_holdings: List[dict],
total_capital: int) -> RiskDecision:
"""
매수 허가 여부 판단.
Returns:
RiskDecision
- allowed=False: 이유와 함께 차단
- allowed=True : max_allowed_amount만큼 허용 (candidate_amount 이하)
"""
if candidate_amount <= 0 or total_capital <= 0:
return RiskDecision(False, "invalid_amount")
cfg = self.config
# 이미 보유 중이면 추가 매수는 이 게이트 대상 아님 (scale-in은 상위에서 처리)
held_tickers = {h.get("ticker") for h in current_holdings}
is_new_position = ticker not in held_tickers
# 1. 총 보유 종목 수 상한
if is_new_position and len(held_tickers) >= cfg.max_total_holdings:
return RiskDecision(
False,
f"max_total_holdings: {len(held_tickers)}/{cfg.max_total_holdings}"
)
themes = self._theme_of(ticker) or []
if not themes:
# 테마 정보 없음 → 테마 제약은 건너뛰고 통과
return RiskDecision(True, "no_theme_info", candidate_amount)
by_theme = self._aggregate_by_theme(current_holdings)
allowed_amount = candidate_amount
blocking_reasons = []
for th in themes:
bucket = by_theme.get(th, {"tickers": set(), "amount": 0})
# 2. 테마당 종목 수 상한 (신규 포지션일 때만)
if is_new_position and len(bucket["tickers"]) >= cfg.max_tickers_per_theme:
blocking_reasons.append(
f"theme[{th}] tickers {len(bucket['tickers'])}/{cfg.max_tickers_per_theme}"
)
continue
# 3. 테마당 노출 금액 비율 상한
max_theme_amount = int(total_capital * cfg.max_theme_exposure_ratio)
remaining = max_theme_amount - bucket["amount"]
if remaining <= 0:
blocking_reasons.append(
f"theme[{th}] exposure {bucket['amount']:,}/{max_theme_amount:,}"
)
continue
# 테마 잔여액이 candidate보다 작으면 부분 허용
allowed_amount = min(allowed_amount, remaining)
if blocking_reasons:
return RiskDecision(False, "; ".join(blocking_reasons))
if allowed_amount <= 0:
return RiskDecision(False, "theme_exposure_full")
return RiskDecision(True, "ok", allowed_amount)

View File

@@ -6,17 +6,12 @@
""" """
import os import os
import time import time
import datetime
import threading import threading
from pathlib import Path
from multiprocessing.shared_memory import SharedMemory from multiprocessing.shared_memory import SharedMemory
from modules.config import Config from modules.config import Config
# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용 # EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용
_EOD_DATE_FILE = Path("data") / ".eod_date"
class ProcessTracker: class ProcessTracker:
"""메모리 기반 프로세스 추적기""" """메모리 기반 프로세스 추적기"""
@@ -141,17 +136,6 @@ class ProcessWatchdog:
entry = self._watched.get(name) entry = self._watched.get(name)
return entry['process'] if entry else None return entry['process'] if entry else None
@staticmethod
def is_eod_today() -> bool:
"""오늘 EOD 마커 파일이 존재하면 True (장 마감 셧다운 → 재시작 차단)"""
try:
if not _EOD_DATE_FILE.exists():
return False
eod_date = datetime.date.fromisoformat(_EOD_DATE_FILE.read_text().strip())
return eod_date >= datetime.date.today()
except Exception:
return False
def _watchdog_loop(self): def _watchdog_loop(self):
"""주기적으로 자식 프로세스 상태 확인""" """주기적으로 자식 프로세스 상태 확인"""
import multiprocessing import multiprocessing
@@ -170,11 +154,6 @@ class ProcessWatchdog:
exit_code = proc.exitcode exit_code = proc.exitcode
restart_count = entry['restart_count'] restart_count = entry['restart_count']
# [EOD 차단] 오늘 장 마감 셧다운이면 재시작하지 않음
if ProcessWatchdog.is_eod_today():
print(f"[Watchdog] {name}: EOD 셧다운 감지 — 재시작 건너뜀.")
continue
if restart_count >= Config.MAX_RESTART_COUNT: if restart_count >= Config.MAX_RESTART_COUNT:
print(f"[Watchdog] {name} crashed (exit={exit_code}). " print(f"[Watchdog] {name} crashed (exit={exit_code}). "
f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.") f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.")

View File

@@ -1,12 +1,13 @@
import json import json
import os import os
from datetime import datetime from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from modules.services.kis import KISClient from modules.services.kis import KISClient
from modules.services.ollama import OllamaManager from modules.services.ollama import OllamaManager
from modules.services.news import NewsCollector from modules.services.news import NewsCollector
load_dotenv() load_dotenv(Path(__file__).parent.parent / ".env")
class WatchlistManager: class WatchlistManager:
""" """

0
signal_v2/__init__.py Normal file
View File

View File

@@ -0,0 +1,132 @@
"""Chronos-2 zero-shot forecaster wrapper."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
import numpy as np
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
@dataclass
class ChronosPrediction:
median: float
q10: float
q90: float
conf: float
as_of: str
class ChronosPredictor:
"""HuggingFace Chronos-2 zero-shot forecaster."""
def __init__(self, model_name: str = "amazon/chronos-2", device: str | None = None):
# BaseChronosPipeline auto-detects model variant (Chronos / ChronosBolt / Chronos-2)
# and returns the appropriate sub-pipeline. ChronosPipeline only supports legacy T5.
import torch
try:
from chronos import BaseChronosPipeline
pipeline_cls = BaseChronosPipeline
except ImportError:
from chronos import ChronosPipeline
pipeline_cls = ChronosPipeline
self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
# Always use float32 — Korean stock prices (e.g. 280,000원) exceed FP16 max (~65,504)
# causing inf in quantile output. FP32 is safe for typical price magnitudes.
dtype = torch.float32
logger.info("Loading Chronos pipeline: %s on %s (cls=%s)",
model_name, self._device, pipeline_cls.__name__)
# Try `dtype` (newer API) first, fall back to `torch_dtype` (older)
try:
self._pipeline = pipeline_cls.from_pretrained(
model_name, device_map=self._device, dtype=dtype,
)
except TypeError:
self._pipeline = pipeline_cls.from_pretrained(
model_name, device_map=self._device, torch_dtype=dtype,
)
logger.info("Chronos pipeline loaded.")
def predict_batch(
self,
daily_ohlcv_dict: dict[str, list[dict]],
prediction_length: int = 1,
num_samples: int = 100,
) -> dict[str, ChronosPrediction]:
"""종목별 1-day return 분포 예측.
ChronosBolt / Chronos-2 등 신모델은 predict_quantiles 사용 (deterministic).
Legacy ChronosPipeline (T5) 는 sample-based predict.
"""
import torch
tickers = list(daily_ohlcv_dict.keys())
if not tickers:
return {}
contexts = [
torch.tensor([bar["close"] for bar in daily_ohlcv_dict[t]], dtype=torch.float32)
for t in tickers
]
now_iso = datetime.now(KST).isoformat()
results: dict[str, ChronosPrediction] = {}
# Modern API: predict_quantiles (ChronosBolt / Chronos-2)
if hasattr(self._pipeline, "predict_quantiles"):
quantile_levels = [0.1, 0.5, 0.9]
# ChronosBolt API: positional `inputs` (first arg). Older variants use `context`.
try:
quantiles_tensor, _ = self._pipeline.predict_quantiles(
contexts,
prediction_length=prediction_length,
quantile_levels=quantile_levels,
)
except TypeError:
quantiles_tensor, _ = self._pipeline.predict_quantiles(
context=contexts,
prediction_length=prediction_length,
quantile_levels=quantile_levels,
)
quantiles_np = (
quantiles_tensor.cpu().numpy()
if hasattr(quantiles_tensor, "cpu")
else np.asarray(quantiles_tensor)
)
# shape: [num_series, prediction_length, 3]
for i, ticker in enumerate(tickers):
q10_price, q50_price, q90_price = quantiles_np[i, 0, :]
last_close = daily_ohlcv_dict[ticker][-1]["close"]
median = float((q50_price - last_close) / last_close)
q10 = float((q10_price - last_close) / last_close)
q90 = float((q90_price - last_close) / last_close)
spread = (q90 - q10) / max(abs(median), 0.001)
conf = float(max(0.0, min(1.0, 1.0 - spread / 2.0)))
results[ticker] = ChronosPrediction(
median=median, q10=q10, q90=q90, conf=conf, as_of=now_iso,
)
return results
# Legacy API: sample-based predict (ChronosPipeline T5)
forecasts = self._pipeline.predict(
context=contexts,
prediction_length=prediction_length,
num_samples=num_samples,
)
forecasts_np = forecasts.numpy() if hasattr(forecasts, "numpy") else np.asarray(forecasts)
for i, ticker in enumerate(tickers):
samples = forecasts_np[i, :, 0]
last_close = daily_ohlcv_dict[ticker][-1]["close"]
returns = (samples - last_close) / last_close
median = float(np.quantile(returns, 0.5))
q10 = float(np.quantile(returns, 0.1))
q90 = float(np.quantile(returns, 0.9))
spread = (q90 - q10) / max(abs(median), 0.001)
conf = float(max(0.0, min(1.0, 1.0 - spread / 2.0)))
results[ticker] = ChronosPrediction(
median=median, q10=q10, q90=q90, conf=conf, as_of=now_iso,
)
return results

75
signal_v2/config.py Normal file
View File

@@ -0,0 +1,75 @@
"""Signal V2 환경변수 로딩."""
import os
from dataclasses import dataclass, field
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent.parent / ".env")
@dataclass(frozen=True)
class Settings:
stock_api_url: str = field(
default_factory=lambda: os.getenv("STOCK_API_URL", "").rstrip("/")
)
webai_api_key: str = field(
default_factory=lambda: os.getenv("WEBAI_API_KEY", "").strip()
)
port: int = field(default_factory=lambda: int(os.getenv("SIGNAL_V2_PORT", "8001")))
db_path: Path = field(
default_factory=lambda: Path(__file__).parent / "data" / "signal_v2.db"
)
# KIS — V1 호환 패턴 (KIS_ENV_TYPE virtual/real)
kis_env_type: str = field(default_factory=lambda: os.getenv("KIS_ENV_TYPE", "virtual").lower())
kis_real_app_key: str = field(default_factory=lambda: os.getenv("KIS_REAL_APP_KEY", "").strip())
kis_real_app_secret: str = field(default_factory=lambda: os.getenv("KIS_REAL_APP_SECRET", "").strip())
kis_real_account: str = field(default_factory=lambda: os.getenv("KIS_REAL_ACCOUNT", "").strip())
kis_virtual_app_key: str = field(default_factory=lambda: os.getenv("KIS_VIRTUAL_APP_KEY", "").strip())
kis_virtual_app_secret: str = field(default_factory=lambda: os.getenv("KIS_VIRTUAL_APP_SECRET", "").strip())
kis_virtual_account: str = field(default_factory=lambda: os.getenv("KIS_VIRTUAL_ACCOUNT", "").strip())
v1_token_path: Path = field(
default_factory=lambda: Path(
os.getenv("V1_TOKEN_PATH",
str(Path(__file__).parent.parent / "signal_v1" / "data" / "kis_token.json"))
)
)
chronos_model: str = field(default_factory=lambda: os.getenv("CHRONOS_MODEL", "amazon/chronos-2"))
stop_loss_pct: float = field(
default_factory=lambda: float(os.getenv("STOP_LOSS_PCT", "-0.07"))
)
take_profit_pct: float = field(
default_factory=lambda: float(os.getenv("TAKE_PROFIT_PCT", "0.15"))
)
chronos_spread_threshold: float = field(
default_factory=lambda: float(os.getenv("CHRONOS_SPREAD_THRESHOLD", "0.6"))
)
asking_bid_ratio_threshold: float = field(
default_factory=lambda: float(os.getenv("ASKING_BID_RATIO_THRESHOLD", "0.6"))
)
confidence_threshold: float = field(
default_factory=lambda: float(os.getenv("CONFIDENCE_THRESHOLD", "0.7"))
)
min_momentum_for_buy: str = field(
default_factory=lambda: os.getenv("MIN_MOMENTUM_FOR_BUY", "strong_up")
)
@property
def kis_is_virtual(self) -> bool:
return self.kis_env_type != "real"
@property
def kis_app_key(self) -> str:
return self.kis_real_app_key if self.kis_env_type == "real" else self.kis_virtual_app_key
@property
def kis_app_secret(self) -> str:
return self.kis_real_app_secret if self.kis_env_type == "real" else self.kis_virtual_app_secret
@property
def kis_account(self) -> str:
return self.kis_real_account if self.kis_env_type == "real" else self.kis_virtual_account
def get_settings() -> Settings:
return Settings()

0
signal_v2/data/.gitkeep Normal file
View File

18
signal_v2/holidays.json Normal file
View File

@@ -0,0 +1,18 @@
[
"2026-01-01",
"2026-01-28",
"2026-01-29",
"2026-01-30",
"2026-03-01",
"2026-05-05",
"2026-05-25",
"2026-06-06",
"2026-08-15",
"2026-09-24",
"2026-09-25",
"2026-09-26",
"2026-10-03",
"2026-10-09",
"2026-12-25",
"2026-12-31"
]

193
signal_v2/kis_client.py Normal file
View File

@@ -0,0 +1,193 @@
"""KIS REST API client — 분봉 + 호가. V1 토큰 read-only 공유."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import httpx
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
_MAX_ATTEMPTS = 3
_THROTTLE_INTERVAL = 0.5 # 초당 2회 제한
class KISClient:
"""KIS REST (분봉 + 호가). V1 토큰 파일 read-only."""
def __init__(
self,
app_key: str, app_secret: str, account: str, is_virtual: bool,
v1_token_path: Path,
timeout: float = 10.0,
):
self._app_key = app_key
self._app_secret = app_secret
self._account = account
self._is_virtual = is_virtual
self._v1_token_path = Path(v1_token_path)
self._base_url = (
"https://openapivts.koreainvestment.com:29443" if is_virtual
else "https://openapi.koreainvestment.com:9443"
)
self._client = httpx.AsyncClient(timeout=timeout)
self._token_cache: tuple[str, float] | None = None # (token, file_mtime)
self._last_throttle_at = 0.0
async def close(self) -> None:
await self._client.aclose()
def _read_v1_token(self) -> str:
if not self._v1_token_path.exists():
raise RuntimeError(f"V1 token file missing: {self._v1_token_path}")
mtime = self._v1_token_path.stat().st_mtime
if self._token_cache and self._token_cache[1] == mtime:
return self._token_cache[0]
data = json.loads(self._v1_token_path.read_text(encoding="utf-8"))
token = data.get("access_token", "")
if not token:
raise RuntimeError("V1 token file has no access_token")
self._token_cache = (token, mtime)
return token
async def _throttle(self) -> None:
elapsed = time.monotonic() - self._last_throttle_at
if elapsed < _THROTTLE_INTERVAL:
await asyncio.sleep(_THROTTLE_INTERVAL - elapsed)
self._last_throttle_at = time.monotonic()
def _common_headers(self, tr_id: str) -> dict[str, str]:
token = self._read_v1_token()
return {
"authorization": f"Bearer {token}",
"appkey": self._app_key,
"appsecret": self._app_secret,
"tr_id": tr_id,
"custtype": "P",
}
async def _request_with_retry(
self, method: str, path: str, tr_id: str, **kwargs,
) -> dict:
url = f"{self._base_url}{path}"
headers = self._common_headers(tr_id)
for attempt in range(_MAX_ATTEMPTS):
await self._throttle()
try:
response = await self._client.request(
method, url, headers=headers, **kwargs
)
if response.status_code == 429:
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(2**attempt)
continue
response.raise_for_status()
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(2**attempt)
continue
raise
raise RuntimeError("retry exhausted")
async def get_minute_ohlcv(self, ticker: str) -> list[dict]:
"""현재 시점 직전 30개 1분봉 OHLCV (TR_ID FHKST03010200)."""
path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
params = {
"FID_ETC_CLS_CODE": "",
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker,
"FID_INPUT_HOUR_1": datetime.now(KST).strftime("%H%M%S"),
"FID_PW_DATA_INCU_YN": "N",
}
raw = await self._request_with_retry(
"GET", path, tr_id="FHKST03010200", params=params,
)
output2 = raw.get("output2", [])
bars = []
for row in output2:
try:
date = row["stck_bsop_date"]
hhmmss = row["stck_cntg_hour"]
dt = datetime.strptime(f"{date} {hhmmss}", "%Y%m%d %H%M%S").replace(tzinfo=KST)
bars.append({
"datetime": dt.isoformat(),
"open": int(row["stck_oprc"]),
"high": int(row["stck_hgpr"]),
"low": int(row["stck_lwpr"]),
"close": int(row["stck_prpr"]),
"volume": int(row["cntg_vol"]),
})
except (KeyError, ValueError) as e:
logger.warning("skip malformed bar for %s: %r", ticker, e)
# KIS returns descending; reverse to ascending (most recent last)
bars.reverse()
return bars
async def get_asking_price(self, ticker: str) -> dict:
"""현재 호가 + 매수/매도 잔량 (TR_ID FHKST01010200)."""
path = "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker,
}
raw = await self._request_with_retry(
"GET", path, tr_id="FHKST01010200", params=params,
)
output1 = raw.get("output1", {})
bid_total = int(output1.get("total_bidp_rsqn", 0))
ask_total = int(output1.get("total_askp_rsqn", 0))
total = bid_total + ask_total
bid_ratio = bid_total / total if total > 0 else 0.0
current_price = int(output1.get("stck_prpr", 0))
return {
"bid_total": bid_total,
"ask_total": ask_total,
"bid_ratio": bid_ratio,
"current_price": current_price,
"as_of": datetime.now(KST).isoformat(),
}
async def get_daily_ohlcv(self, ticker: str, days: int = 60) -> list[dict]:
"""KRX 일봉 OHLCV (TR_ID FHKST03010100).
Returns: [{"datetime", "open", "high", "low", "close", "volume"}, ...]
시간 오름차순.
"""
path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
today = datetime.now(KST).strftime("%Y%m%d")
start_date = (datetime.now(KST) - timedelta(days=days * 2)).strftime("%Y%m%d")
params = {
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker,
"FID_INPUT_DATE_1": start_date,
"FID_INPUT_DATE_2": today,
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "1",
}
raw = await self._request_with_retry(
"GET", path, tr_id="FHKST03010100", params=params,
)
output2 = raw.get("output2", [])
bars = []
for row in output2:
try:
date = row["stck_bsop_date"]
bars.append({
"datetime": f"{date[:4]}-{date[4:6]}-{date[6:]}",
"open": int(row["stck_oprc"]),
"high": int(row["stck_hgpr"]),
"low": int(row["stck_lwpr"]),
"close": int(row["stck_clpr"]),
"volume": int(row["acml_vol"]),
})
except (KeyError, ValueError):
continue
bars.reverse()
return bars[-days:]

186
signal_v2/kis_websocket.py Normal file
View File

@@ -0,0 +1,186 @@
"""KIS WebSocket — approval_key + 실시간 호가 구독."""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from typing import Callable
from zoneinfo import ZoneInfo
import httpx
import websockets
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
# KIS 호가 메시지 필드 인덱스 (운영 환경 검증 필요)
# H0STASP0 응답: ticker | time | current_price | ... | ask_total | bid_total
# 본 spec/plan 의 가정: 마지막 2개 필드가 ask_total / bid_total
_ASKING_TICKER_IDX = 0
_ASKING_TIME_IDX = 1
_ASKING_CURRENT_PRICE_IDX = 2
_ASKING_TOTAL_ASK_IDX = -2
_ASKING_TOTAL_BID_IDX = -1
class KISWebSocket:
"""KIS WebSocket client. approval_key 발급 + 호가 실시간."""
def __init__(self, app_key: str, app_secret: str, is_virtual: bool):
self._app_key = app_key
self._app_secret = app_secret
self._is_virtual = is_virtual
self._base_rest = (
"https://openapivts.koreainvestment.com:29443" if is_virtual
else "https://openapi.koreainvestment.com:9443"
)
self._ws_url = (
"ws://ops.koreainvestment.com:31000" if is_virtual
else "ws://ops.koreainvestment.com:21000"
)
self._approval_key: str | None = None
self._ws = None
self._subscriptions: set[str] = set()
self._on_asking_price: Callable[[str, dict], None] | None = None
self._recv_task: asyncio.Task | None = None
self._shutdown = asyncio.Event()
async def _fetch_approval_key(self) -> str:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{self._base_rest}/oauth2/Approval",
json={
"grant_type": "client_credentials",
"appkey": self._app_key,
"secretkey": self._app_secret,
},
)
response.raise_for_status()
data = response.json()
self._approval_key = data["approval_key"]
return self._approval_key
async def _connect(self):
return await websockets.connect(self._ws_url)
async def _connect_with_backoff(self):
"""연결 시도 with exponential backoff (1s → 2s → 4s → max 30s)."""
for attempt in range(10):
try:
ws = await self._connect()
return ws
except Exception as e:
wait = min(2**attempt, 30)
logger.warning(
"KIS WebSocket connect failed (attempt %d): %r — retrying in %ds",
attempt + 1, e, wait,
)
await asyncio.sleep(wait)
raise RuntimeError("KIS WebSocket connect exhausted retries")
async def start(
self, tickers: list[str],
on_asking_price: Callable[[str, dict], None],
) -> None:
if self._approval_key is None:
await self._fetch_approval_key()
self._on_asking_price = on_asking_price
self._ws = await self._connect_with_backoff()
for ticker in tickers:
await self.subscribe(ticker)
self._recv_task = asyncio.create_task(self._receive_loop())
async def subscribe(self, ticker: str) -> None:
if self._ws is None or self._approval_key is None:
raise RuntimeError("KIS WebSocket not started")
msg = json.dumps({
"header": {
"approval_key": self._approval_key,
"custtype": "P",
"tr_type": "1",
"content-type": "utf-8",
},
"body": {
"input": {"tr_id": "H0STASP0", "tr_key": ticker},
},
})
await self._ws.send(msg)
self._subscriptions.add(ticker)
async def unsubscribe(self, ticker: str) -> None:
if self._ws is None or self._approval_key is None:
return
msg = json.dumps({
"header": {
"approval_key": self._approval_key,
"custtype": "P",
"tr_type": "2",
"content-type": "utf-8",
},
"body": {
"input": {"tr_id": "H0STASP0", "tr_key": ticker},
},
})
await self._ws.send(msg)
self._subscriptions.discard(ticker)
async def close(self) -> None:
self._shutdown.set()
if self._recv_task is not None:
self._recv_task.cancel()
try:
await self._recv_task
except asyncio.CancelledError:
pass
if self._ws is not None:
await self._ws.close()
async def _receive_loop(self) -> None:
while not self._shutdown.is_set():
try:
raw = await self._ws.recv()
except websockets.ConnectionClosed:
logger.warning("KIS WebSocket closed — reconnecting")
self._ws = await self._connect_with_backoff()
for ticker in list(self._subscriptions):
await self.subscribe(ticker)
continue
if not isinstance(raw, str):
continue
parsed = self._parse_asking_price(raw)
if parsed is not None and self._on_asking_price is not None:
ticker, data = parsed
try:
self._on_asking_price(ticker, data)
except Exception:
logger.exception("on_asking_price callback failed")
def _parse_asking_price(self, raw: str) -> tuple[str, dict] | None:
"""KIS H0STASP0 raw → (ticker, asking_price dict).
Raw format: '0|H0STASP0|<count>|<data>' where data = '^'-joined fields.
Field indices (운영 검증 필요): 마지막 2개 가정 (ask, bid).
"""
try:
parts = raw.split("|")
if len(parts) < 4 or parts[1] != "H0STASP0":
return None
fields = parts[3].split("^")
ticker = fields[_ASKING_TICKER_IDX]
current_price_str = fields[_ASKING_CURRENT_PRICE_IDX]
current_price = int(current_price_str) if current_price_str.lstrip("-").isdigit() else 0
ask_str = fields[_ASKING_TOTAL_ASK_IDX]
bid_str = fields[_ASKING_TOTAL_BID_IDX]
ask_total = int(ask_str) if ask_str.lstrip("-").isdigit() else 0
bid_total = int(bid_str) if bid_str.lstrip("-").isdigit() else 0
total = bid_total + ask_total
return ticker, {
"bid_total": bid_total,
"ask_total": ask_total,
"bid_ratio": bid_total / total if total > 0 else 0.0,
"current_price": current_price,
"as_of": datetime.now(KST).isoformat(),
}
except (IndexError, ValueError) as e:
logger.warning("parse_asking_price failed: %r", e)
return None

125
signal_v2/main.py Normal file
View File

@@ -0,0 +1,125 @@
"""FastAPI app — Signal V2 Pull Worker."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from signal_v2 import state as state_mod
from signal_v2.chronos_predictor import ChronosPredictor
from signal_v2.config import get_settings
from signal_v2.kis_client import KISClient
from signal_v2.kis_websocket import KISWebSocket
from signal_v2.pull_worker import poll_loop, make_asking_price_callback
from signal_v2.rate_limit import SignalDedup
from signal_v2.stock_client import StockClient
logger = logging.getLogger(__name__)
class AppContext:
client: StockClient | None = None
dedup: SignalDedup | None = None
shutdown: asyncio.Event | None = None
poll_task: asyncio.Task | None = None
kis_client: KISClient | None = None
kis_ws: KISWebSocket | None = None
chronos: ChronosPredictor | None = None
_ctx = AppContext()
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
if not settings.webai_api_key:
logger.warning(
"WEBAI_API_KEY not configured — stock API calls will fail with 401"
)
if not settings.kis_app_key:
logger.warning(
"KIS app_key not configured (KIS_ENV_TYPE=%s, KIS_%s_APP_KEY missing) — KIS REST/WebSocket disabled",
settings.kis_env_type, settings.kis_env_type.upper()
)
_ctx.client = StockClient(settings.stock_api_url, settings.webai_api_key)
_ctx.dedup = SignalDedup(settings.db_path)
_ctx.shutdown = asyncio.Event()
# KIS only if app_key configured
if settings.kis_app_key:
_ctx.kis_client = KISClient(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
account=settings.kis_account,
is_virtual=settings.kis_is_virtual,
v1_token_path=settings.v1_token_path,
)
_ctx.kis_ws = KISWebSocket(
app_key=settings.kis_app_key,
app_secret=settings.kis_app_secret,
is_virtual=settings.kis_is_virtual,
)
# Subscribe portfolio holdings (if any)
try:
portfolio = await _ctx.client.get_portfolio()
tickers = [h["ticker"] for h in portfolio.get("holdings", []) if "ticker" in h]
cb = make_asking_price_callback(state_mod.state)
await _ctx.kis_ws.start(tickers, cb)
except Exception:
logger.exception("KIS WebSocket startup failed — continuing without realtime asking_price")
# Load Chronos (heavy: ~1GB model download first time)
try:
_ctx.chronos = ChronosPredictor(model_name=settings.chronos_model)
except Exception:
logger.exception("ChronosPredictor load failed — continuing without chronos predictions")
_ctx.poll_task = asyncio.create_task(
poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
chronos=_ctx.chronos,
dedup=_ctx.dedup,
settings=settings,
)
)
yield
# Shutdown
if _ctx.shutdown is not None:
_ctx.shutdown.set()
if _ctx.poll_task is not None:
try:
await asyncio.wait_for(_ctx.poll_task, timeout=5.0)
except asyncio.TimeoutError:
_ctx.poll_task.cancel()
try:
await _ctx.poll_task
except asyncio.CancelledError:
pass
if _ctx.kis_ws is not None:
await _ctx.kis_ws.close()
if _ctx.kis_client is not None:
await _ctx.kis_client.close()
if _ctx.client is not None:
await _ctx.client.close()
app = FastAPI(
title="Signal V2 Pull Worker", version="0.1.0", lifespan=lifespan
)
@app.get("/health")
async def health():
settings = get_settings()
return {
"status": "online",
"stock_api_url": settings.stock_api_url,
"last_poll": state_mod.state.last_updated,
"cache_size": _ctx.client.cache_size() if _ctx.client is not None else 0,
}

View File

@@ -0,0 +1,69 @@
"""분봉 OHLCV → 5-level 모멘텀 분류."""
from __future__ import annotations
from collections import deque
# 분류 카테고리
STRONG_UP = "strong_up"
WEAK_UP = "weak_up"
NEUTRAL = "neutral"
WEAK_DOWN = "weak_down"
STRONG_DOWN = "strong_down"
_BARS_PER_5MIN = 5
_LOOKBACK_5MIN_BARS = 5
_VOLUME_AVG_WINDOW = 12 # 60분 = 5분봉 12개
def aggregate_1min_to_5min(minute_bars: list[dict]) -> list[dict]:
"""1분봉 N개 → 5분봉 floor(N/5) 개. 시간 오름차순.
각 5분봉: open=첫 1분봉 open, high=max, low=min, close=마지막 close, volume=sum.
"""
bars_5min = []
chunks = len(minute_bars) // _BARS_PER_5MIN
for i in range(chunks):
chunk = minute_bars[i * _BARS_PER_5MIN : (i + 1) * _BARS_PER_5MIN]
bars_5min.append({
"datetime": chunk[0]["datetime"],
"open": chunk[0]["open"],
"high": max(b["high"] for b in chunk),
"low": min(b["low"] for b in chunk),
"close": chunk[-1]["close"],
"volume": sum(b["volume"] for b in chunk),
})
return bars_5min
def classify_minute_momentum(minute_bars: deque) -> str:
"""1분봉 deque → 5-level 모멘텀 분류.
Returns: STRONG_UP / WEAK_UP / NEUTRAL / WEAK_DOWN / STRONG_DOWN
"""
minute_list = list(minute_bars)
if len(minute_list) < _BARS_PER_5MIN * _LOOKBACK_5MIN_BARS:
return NEUTRAL # 데이터 부족
bars_5min = aggregate_1min_to_5min(minute_list)
if len(bars_5min) < _LOOKBACK_5MIN_BARS:
return NEUTRAL
recent = bars_5min[-_LOOKBACK_5MIN_BARS:]
up_count = sum(1 for b in recent if b["close"] > b["open"])
# 거래량 multiplier: recent 5 avg vs 60분 avg
recent_vol_avg = sum(b["volume"] for b in recent) / len(recent)
long_window = bars_5min[-_VOLUME_AVG_WINDOW:]
long_vol_avg = sum(b["volume"] for b in long_window) / len(long_window)
vol_mult = recent_vol_avg / long_vol_avg if long_vol_avg > 0 else 1.0
# 5-level 분류
if up_count == 5 and vol_mult >= 1.5:
return STRONG_UP
elif up_count >= 3 and vol_mult >= 1.0:
return WEAK_UP
elif up_count == 0 and vol_mult >= 1.5:
return STRONG_DOWN
elif up_count <= 2 and vol_mult < 1.0:
return WEAK_DOWN
else:
return NEUTRAL

193
signal_v2/pull_worker.py Normal file
View File

@@ -0,0 +1,193 @@
"""Polling loop — async cron + state update."""
from __future__ import annotations
import asyncio
import logging
from collections import deque
from datetime import datetime
from signal_v2.kis_client import KISClient
from signal_v2.scheduler import (
KST, _is_market_day, _is_polling_window, _next_interval, _is_post_close_trigger,
)
from signal_v2.state import PollState
from signal_v2.stock_client import StockClient
logger = logging.getLogger(__name__)
async def poll_loop(
client: StockClient, state: PollState, shutdown: asyncio.Event,
kis_client: KISClient | None = None,
chronos=None,
dedup=None,
settings=None,
) -> None:
"""FastAPI lifespan 에서 asyncio.create_task 로 시작."""
logger.info("poll_loop started")
while not shutdown.is_set():
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
try:
await _run_polling_cycle(client, state, kis_client=kis_client)
except Exception:
logger.exception("poll cycle failed")
# Minute momentum 갱신 (매 cycle)
try:
update_minute_momentum_for_all(state)
except Exception:
logger.exception("minute momentum update failed")
# Post-close trigger (16:00 KST)
if _is_post_close_trigger(now) and chronos is not None and kis_client is not None:
try:
await _run_post_close_cycle(kis_client, chronos, state)
except Exception:
logger.exception("post-close cycle failed")
# Phase 4: generate signals
if dedup is not None and settings is not None:
try:
from signal_v2.signal_generator import generate_signals
generate_signals(state, dedup, settings)
except Exception:
logger.exception("generate_signals failed")
interval = _next_interval(now)
try:
await asyncio.wait_for(shutdown.wait(), timeout=interval)
break
except asyncio.TimeoutError:
continue
logger.info("poll_loop ended")
async def _run_polling_cycle(
client: StockClient, state: PollState,
kis_client: KISClient | None = None,
) -> None:
"""기존 3 endpoint (stock) + KIS 분봉 fetch."""
portfolio, sentiment, screener = await asyncio.gather(
client.get_portfolio(),
client.get_news_sentiment(),
client.run_screener_preview(),
return_exceptions=True,
)
now_iso = datetime.now(KST).isoformat()
for name, result in (
("portfolio", portfolio),
("news_sentiment", sentiment),
("screener_preview", screener),
):
if isinstance(result, dict):
setattr(state, name, result)
state.last_updated[name] = now_iso
state.fetch_errors[name] = 0
else:
state.fetch_errors[name] = state.fetch_errors.get(name, 0) + 1
logger.warning("fetch %s failed: %r", name, result)
# KIS 분봉 + 호가 (kis_client 주어졌을 때만)
if kis_client is not None:
try:
await _run_kis_minute_cycle(kis_client, state)
except Exception:
logger.exception("kis minute cycle failed")
async def _run_kis_minute_cycle(kis_client: KISClient, state: PollState) -> None:
"""KIS 분봉 + 호가 fetch + state 갱신.
- 분봉: portfolio + screener Top-N union 종목 모두
- 호가 (REST): screener-only 종목 (portfolio 는 WebSocket 으로 들어옴)
"""
portfolio_tickers = _portfolio_tickers(state)
screener_tickers = _screener_tickers(state)
all_tickers = list(set(portfolio_tickers) | set(screener_tickers))
# 분봉 fetch (병렬)
minute_results = await asyncio.gather(*[
kis_client.get_minute_ohlcv(t) for t in all_tickers
], return_exceptions=True)
now_iso = datetime.now(KST).isoformat()
for ticker, result in zip(all_tickers, minute_results):
if isinstance(result, list):
buf = state.minute_bars.setdefault(ticker, deque(maxlen=60))
buf.extend(result)
state.last_updated[f"minute_bars/{ticker}"] = now_iso
else:
state.fetch_errors[f"minute_bars/{ticker}"] = (
state.fetch_errors.get(f"minute_bars/{ticker}", 0) + 1
)
# 호가 fetch (REST) — screener-only
screener_only = list(set(screener_tickers) - set(portfolio_tickers))
asking_results = await asyncio.gather(*[
kis_client.get_asking_price(t) for t in screener_only
], return_exceptions=True)
for ticker, result in zip(screener_only, asking_results):
if isinstance(result, dict):
state.asking_price[ticker] = result
state.last_updated[f"asking_price/{ticker}"] = now_iso
def make_asking_price_callback(state: PollState):
"""KIS WebSocket on_asking_price callback factory."""
def _cb(ticker: str, data: dict) -> None:
state.asking_price[ticker] = data
state.last_updated[f"asking_price/{ticker}"] = datetime.now(KST).isoformat()
return _cb
def _portfolio_tickers(state: PollState) -> list[str]:
if state.portfolio is None:
return []
return [h["ticker"] for h in state.portfolio.get("holdings", []) if "ticker" in h]
def _screener_tickers(state: PollState) -> list[str]:
if state.screener_preview is None:
return []
return [i["ticker"] for i in state.screener_preview.get("items", []) if "ticker" in i]
async def _run_post_close_cycle(kis_client, chronos, state) -> None:
"""16:00 KST 종가 후 1회: daily fetch + chronos predict."""
tickers = list(set(_portfolio_tickers(state)) | set(_screener_tickers(state)))
if not tickers:
return
daily_results = await asyncio.gather(*[
kis_client.get_daily_ohlcv(t, days=60) for t in tickers
], return_exceptions=True)
daily_dict = {}
for ticker, result in zip(tickers, daily_results):
if isinstance(result, list) and len(result) >= 30:
daily_dict[ticker] = result
state.daily_ohlcv[ticker] = result
elif isinstance(result, Exception):
state.fetch_errors[f"daily_ohlcv/{ticker}"] = (
state.fetch_errors.get(f"daily_ohlcv/{ticker}", 0) + 1
)
if daily_dict and chronos is not None:
try:
predictions = chronos.predict_batch(daily_dict)
except Exception:
logger.exception("chronos predict_batch failed")
return
for ticker, pred in predictions.items():
state.chronos_predictions[ticker] = {
"median": pred.median,
"q10": pred.q10,
"q90": pred.q90,
"conf": pred.conf,
"as_of": pred.as_of,
}
state.last_updated[f"chronos/{ticker}"] = pred.as_of
def update_minute_momentum_for_all(state) -> None:
"""매 분봉 cycle 후 호출 — 모든 종목 모멘텀 갱신."""
from signal_v2.momentum_classifier import classify_minute_momentum
now_iso = datetime.now(KST).isoformat()
for ticker, bars in state.minute_bars.items():
state.minute_momentum[ticker] = classify_minute_momentum(bars)
state.last_updated[f"momentum/{ticker}"] = now_iso

3
signal_v2/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

73
signal_v2/rate_limit.py Normal file
View File

@@ -0,0 +1,73 @@
"""SignalDedup — SQLite-backed 24h duplicate signal blocker."""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
def _now_iso() -> str:
"""Test seam — overridable via monkeypatch."""
return datetime.now(KST).isoformat()
_SCHEMA = """
CREATE TABLE IF NOT EXISTS signal_dedup (
ticker TEXT NOT NULL,
action TEXT NOT NULL,
last_sent TEXT NOT NULL,
confidence REAL NOT NULL,
PRIMARY KEY (ticker, action)
);
CREATE INDEX IF NOT EXISTS idx_signal_dedup_last_sent
ON signal_dedup(last_sent);
"""
class SignalDedup:
"""24h dedup interface. WAL + busy_timeout=120000."""
def __init__(self, db_path: Path):
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_schema()
@contextmanager
def _conn(self):
conn = sqlite3.connect(self._db_path, timeout=120.0)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=120000")
yield conn
finally:
conn.close()
def _init_schema(self) -> None:
with self._conn() as conn:
conn.executescript(_SCHEMA)
conn.commit()
def is_recent(self, ticker: str, action: str, within_hours: int = 24) -> bool:
threshold_dt = datetime.fromisoformat(_now_iso()) - timedelta(hours=within_hours)
threshold_iso = threshold_dt.isoformat()
with self._conn() as conn:
row = conn.execute(
"SELECT last_sent FROM signal_dedup WHERE ticker = ? AND action = ?",
(ticker, action),
).fetchone()
return row is not None and row[0] >= threshold_iso
def record(self, ticker: str, action: str, confidence: float) -> None:
with self._conn() as conn:
conn.execute(
"""INSERT INTO signal_dedup (ticker, action, last_sent, confidence)
VALUES (?, ?, ?, ?)
ON CONFLICT (ticker, action) DO UPDATE
SET last_sent = excluded.last_sent,
confidence = excluded.confidence""",
(ticker, action, _now_iso(), confidence),
)
conn.commit()

99
signal_v2/scheduler.py Normal file
View File

@@ -0,0 +1,99 @@
"""Polling scheduler — 시간대별 분기 + 휴장일 처리."""
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, time
from pathlib import Path
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
_HOLIDAYS_PATH = Path(__file__).parent / "holidays.json"
_HOLIDAYS: set[str] = set(json.loads(_HOLIDAYS_PATH.read_text(encoding="utf-8")))
# Market windows (정규장)
_PRE_OPEN = time(7, 0)
_OPEN = time(9, 0)
_CLOSE = time(15, 30)
_POST_END = time(20, 0)
# NXT windows (시간외)
_NXT_PRE_END = time(23, 30)
_NXT_POST_OPEN = time(4, 30)
# 23:30 - 04:30 (dead zone) skip
def _is_market_day(now: datetime) -> bool:
"""평일 + 휴장일 아닌 날."""
if now.weekday() >= 5: # Sat/Sun
return False
return now.strftime("%Y-%m-%d") not in _HOLIDAYS
def _is_polling_window(now: datetime) -> bool:
"""폴링 윈도우: 07:00-23:30 + 04:30-07:00."""
t = now.time()
return (
(_PRE_OPEN <= t < _NXT_PRE_END)
or (_NXT_POST_OPEN <= t < _PRE_OPEN)
)
def _next_interval(now: datetime) -> float:
"""다음 폴링까지 sleep 초수."""
if not _is_market_day(now):
return _seconds_until_next_market_open(now)
t = now.time()
if _PRE_OPEN <= t < _OPEN:
return 300.0 # 장전 5분
elif _OPEN <= t < _CLOSE:
return 60.0 # 장중 1분
elif _CLOSE <= t < _POST_END:
return 300.0 # 장후 5분
elif _POST_END <= t < _NXT_PRE_END:
return 300.0 # NXT 야간 5분
elif _NXT_POST_OPEN <= t < _PRE_OPEN:
return 300.0 # NXT 새벽 5분
else:
# Dead zone (23:30-04:30) — wait until next 04:30
return _seconds_until_nxt_or_market_open(now)
def _seconds_until_nxt_or_market_open(now: datetime) -> float:
"""다음 04:30 (NXT 새벽 start) 까지 초수. 휴장일은 다음 영업일 07:00."""
candidate = now.replace(hour=4, minute=30, second=0, microsecond=0)
if candidate <= now:
candidate += timedelta(days=1)
for _ in range(14):
if _is_market_day(candidate):
return (candidate - now).total_seconds()
candidate += timedelta(days=1)
logger.warning("could not find next market day within 14 days")
return 86400.0
def _is_post_close_trigger(now: datetime) -> bool:
"""16:00 KST ±1분 (post-close cycle 트리거). 평일/영업일만."""
if not _is_market_day(now):
return False
t = now.time()
return time(16, 0) <= t < time(16, 1)
def _seconds_until_next_market_open(now: datetime) -> float:
"""다음 영업일의 07:00 KST 까지 초수 (휴장일/주말용)."""
candidate = now.replace(hour=7, minute=0, second=0, microsecond=0)
if candidate <= now:
candidate += timedelta(days=1)
for _ in range(14): # safety bound (max 2 weeks of holidays)
if _is_market_day(candidate):
return (candidate - now).total_seconds()
candidate += timedelta(days=1)
logger.warning("could not find next market day within 14 days")
return 86400.0

View File

@@ -0,0 +1,228 @@
"""Phase 4 — 매수/매도 신호 생성.
순수 함수 generate_signals(state, dedup, settings). state 를 mutate.
"""
from __future__ import annotations
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
MOMENTUM_SCORES = {
"strong_up": 1.0,
"weak_up": 0.7,
"neutral": 0.5,
"weak_down": 0.3,
"strong_down": 0.0,
}
def generate_signals(state, dedup, settings) -> None:
"""Phase 4 entry — state-mutating. Evaluation order: sell first (priority), then buy. A ticker receiving a sell signal in this cycle is excluded from buy evaluation to avoid silent overwrite."""
_evaluate_sell_signals(state, dedup, settings)
_evaluate_buy_signals(state, dedup, settings)
# ----- 매수 -----
def _evaluate_buy_signals(state, dedup, settings) -> None:
candidates = _buy_candidates(state)
for ticker, name, rank in candidates:
existing = state.signals.get(ticker)
if existing is not None and existing.get("action") == "sell":
logger.debug("buy %s skipped: same-cycle sell precedence", ticker)
continue
if not _check_buy_hard_gate(state, ticker, settings):
logger.debug("buy %s skipped: hard gate failed", ticker)
continue
confidence = _compute_buy_confidence(state, ticker, rank)
if confidence <= settings.confidence_threshold:
logger.debug("buy %s skipped: confidence %.3f <= %.3f",
ticker, confidence, settings.confidence_threshold)
continue
if dedup.is_recent(ticker, "buy", within_hours=24):
logger.debug("buy %s skipped: dedup 24h", ticker)
continue
state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence)
dedup.record(ticker, "buy", confidence=confidence)
logger.info("signal emit %s buy conf=%.3f rank=%s", ticker, confidence, rank)
def _buy_candidates(state) -> list[tuple[str, str, int | None]]:
"""screener Top-N (rank 1..N) + portfolio (rank=None)."""
candidates: list[tuple[str, str, int | None]] = []
seen: set[str] = set()
if state.screener_preview is not None:
for i, item in enumerate(state.screener_preview.get("items", [])):
ticker = item.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
name = item.get("name", ticker)
candidates.append((ticker, name, i + 1))
if state.portfolio is not None:
for h in state.portfolio.get("holdings", []):
ticker = h.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
candidates.append((ticker, h.get("name", ticker), None))
return candidates
def _check_buy_hard_gate(state, ticker: str, settings) -> bool:
pred = state.chronos_predictions.get(ticker)
if pred is None or pred.get("median", 0) <= 0:
return False
spread = pred.get("q90", 0) - pred.get("q10", 0)
if spread >= settings.chronos_spread_threshold:
return False
momentum = state.minute_momentum.get(ticker)
if momentum != settings.min_momentum_for_buy:
return False
ap = state.asking_price.get(ticker)
if ap is None or ap.get("bid_ratio", 0) < settings.asking_bid_ratio_threshold:
return False
return True
def _compute_buy_confidence(state, ticker: str, rank: int | None) -> float:
pred = state.chronos_predictions[ticker]
chronos_conf = pred["conf"]
minute_score = MOMENTUM_SCORES.get(state.minute_momentum.get(ticker, "neutral"), 0.5)
screener_norm = max(0.0, 1 - (rank - 1) / 20) if rank is not None else 0.0
return chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float) -> dict:
ap = state.asking_price[ticker]
return {
"ticker": ticker,
"name": name,
"action": "buy",
"confidence_webai": confidence,
"current_price": ap["current_price"],
"avg_price": None,
"pnl_pct": None,
"context": _build_context(state, ticker, rank),
"as_of": datetime.now(KST).isoformat(),
}
# ----- 매도 -----
def _evaluate_sell_signals(state, dedup, settings) -> None:
if state.portfolio is None:
return
for holding in state.portfolio.get("holdings", []):
ticker = holding.get("ticker")
if not ticker:
continue
sell = _try_stop_loss(state, holding, settings)
if sell is None:
sell = _try_anomaly(state, holding, settings)
if sell is None:
sell = _try_take_profit(state, holding, settings)
if sell is None:
continue
if dedup.is_recent(ticker, "sell", within_hours=24):
logger.debug("sell %s skipped: dedup 24h", ticker)
continue
state.signals[ticker] = sell
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
logger.info("signal emit %s sell conf=%.3f reason=%s",
ticker, sell["confidence_webai"],
sell.get("context", {}).get("sell_reason"))
def _try_stop_loss(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl >= settings.stop_loss_pct:
return None
return _build_sell_signal(state, holding, confidence=1.0, reason="stop_loss")
def _try_take_profit(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl <= settings.take_profit_pct:
return None
return _build_sell_signal(state, holding, confidence=0.6, reason="take_profit")
def _try_anomaly(state, holding: dict, settings) -> dict | None:
ticker = holding["ticker"]
pred = state.chronos_predictions.get(ticker)
if pred is None or pred["median"] >= -0.01:
return None
momentum = state.minute_momentum.get(ticker)
if momentum != "strong_down":
return None
ap = state.asking_price.get(ticker)
if ap is None:
return None
if ap["bid_ratio"] > (1 - settings.asking_bid_ratio_threshold):
return None
minute_score = 1.0 - MOMENTUM_SCORES.get(momentum, 0.5)
confidence = pred["conf"] * 0.5 + minute_score * 0.3 + 1.0 * 0.2
if confidence <= settings.confidence_threshold:
return None
return _build_sell_signal(state, holding, confidence=confidence, reason="anomaly")
def _build_sell_signal(state, holding: dict, confidence: float, reason: str) -> dict:
ticker = holding["ticker"]
return {
"ticker": ticker,
"name": holding.get("name", ticker),
"action": "sell",
"confidence_webai": confidence,
"current_price": holding.get("current_price"),
"avg_price": holding.get("avg_price"),
"pnl_pct": holding.get("pnl_pct"),
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
"as_of": datetime.now(KST).isoformat(),
}
# ----- Context -----
def _build_context(state, ticker: str, rank: int | None, sell_reason: str | None = None) -> dict:
pred = state.chronos_predictions.get(ticker) or {}
ap = state.asking_price.get(ticker) or {}
news_item = _find_news_sentiment(state, ticker)
screener_scores = _find_screener_scores(state, ticker)
context: dict = {
"chronos_pred_1d": pred.get("median"),
"chronos_pred_conf": pred.get("conf"),
"chronos_q10": pred.get("q10"),
"chronos_q90": pred.get("q90"),
"screener_rank": rank,
"screener_scores": screener_scores,
"minute_momentum": state.minute_momentum.get(ticker),
"asking_bid_ratio": ap.get("bid_ratio"),
"news_sentiment": news_item.get("score") if news_item else None,
"news_reason": news_item.get("reason") if news_item else None,
}
if sell_reason is not None:
context["sell_reason"] = sell_reason
return context
def _find_news_sentiment(state, ticker: str) -> dict | None:
if state.news_sentiment is None:
return None
for item in state.news_sentiment.get("items", []):
if item.get("ticker") == ticker:
return item
return None
def _find_screener_scores(state, ticker: str) -> dict | None:
if state.screener_preview is None:
return None
for item in state.screener_preview.get("items", []):
if item.get("ticker") == ticker:
return item.get("scores")
return None

3
signal_v2/start.bat Normal file
View File

@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0\.."
python -m uvicorn signal_v2.main:app --host 0.0.0.0 --port 8001

22
signal_v2/state.py Normal file
View File

@@ -0,0 +1,22 @@
"""PollState — process-wide singleton."""
from collections import deque
from dataclasses import dataclass, field
@dataclass
class PollState:
portfolio: dict | None = None
news_sentiment: dict | None = None
screener_preview: dict | None = None
minute_bars: dict[str, deque] = field(default_factory=dict)
asking_price: dict[str, dict] = field(default_factory=dict)
# Phase 3b additions
daily_ohlcv: dict[str, list[dict]] = field(default_factory=dict)
chronos_predictions: dict[str, dict] = field(default_factory=dict)
minute_momentum: dict[str, str] = field(default_factory=dict)
signals: dict[str, dict] = field(default_factory=dict)
last_updated: dict[str, str] = field(default_factory=dict)
fetch_errors: dict[str, int] = field(default_factory=dict)
state = PollState()

128
signal_v2/stock_client.py Normal file
View File

@@ -0,0 +1,128 @@
"""Stock API HTTP client — async httpx + retry + memory cache."""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
# Cache TTL by endpoint (seconds)
_TTL = {
"portfolio": 60.0,
"news-sentiment": 300.0,
"screener-preview": 60.0,
}
# Retry policy
_MAX_ATTEMPTS = 3
_RETRY_STATUSES = {429, 500, 502, 503, 504}
class StockClient:
"""stock API wrapper. Async httpx + self-retry + memory cache."""
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0):
self._base_url = base_url.rstrip("/")
self._api_key = api_key
self._client = httpx.AsyncClient(timeout=timeout)
# cache: key → (data, timestamp_monotonic)
self._cache: dict[str, tuple[Any, float]] = {}
async def close(self) -> None:
await self._client.aclose()
def cache_size(self) -> int:
"""Number of cached endpoint responses (public surface for /health)."""
return len(self._cache)
async def get_portfolio(self) -> dict:
return await self._cached_request(
"portfolio", "GET", "/api/webai/portfolio"
)
async def get_news_sentiment(self, date: str | None = None) -> dict:
path = "/api/webai/news-sentiment"
if date is not None:
path += f"?date={date}"
cache_key = f"news-sentiment:{date or 'latest'}"
return await self._cached_request(
cache_key, "GET", path, _ttl_key="news-sentiment"
)
async def run_screener_preview(
self, weights: dict | None = None, top_n: int = 20
) -> dict:
body = {"mode": "preview", "top_n": top_n}
if weights is not None:
body["weights"] = weights
return await self._cached_request(
"screener-preview",
"POST",
"/api/stock/screener/run",
json=body,
_ttl_key="screener-preview",
)
async def _cached_request(
self,
cache_key: str,
method: str,
path: str,
*,
_ttl_key: str | None = None,
**kwargs,
) -> dict:
ttl_key = _ttl_key or cache_key
ttl = _TTL.get(ttl_key, 60.0)
# Fresh cache hit?
if cache_key in self._cache:
data, ts = self._cache[cache_key]
if time.monotonic() - ts < ttl:
return data
# Fetch (with retry)
try:
data = await self._request_with_retry(method, path, **kwargs)
self._cache[cache_key] = (data, time.monotonic())
return data
except httpx.HTTPError:
# Stale fallback: serve old cached value if exists
if cache_key in self._cache:
stale_data, stale_ts = self._cache[cache_key]
age = time.monotonic() - stale_ts
logger.warning(
"serving stale cache for %s (age=%.1fs)", cache_key, age
)
return stale_data
raise
async def _request_with_retry(self, method: str, path: str, **kwargs) -> dict:
url = f"{self._base_url}{path}"
headers = self._auth_headers()
for attempt in range(_MAX_ATTEMPTS):
try:
response = await self._client.request(
method, url, headers=headers, **kwargs
)
if response.status_code in _RETRY_STATUSES:
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(2**attempt)
continue
response.raise_for_status()
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
if attempt < _MAX_ATTEMPTS - 1:
await asyncio.sleep(2**attempt)
continue
raise
except httpx.HTTPStatusError:
raise
# Unreachable: every iteration either returns or raises
raise RuntimeError("_request_with_retry exhausted loop without raising")
def _auth_headers(self) -> dict[str, str]:
return {"X-WebAI-Key": self._api_key}

View File

View File

@@ -0,0 +1,18 @@
"""Pytest fixtures for signal_v2 tests."""
from pathlib import Path
import pytest
import respx
@pytest.fixture
def tmp_dedup_db(tmp_path) -> Path:
"""SQLite 단위 테스트용 임시 DB path."""
return tmp_path / "test_signal_v2.db"
@pytest.fixture
def mock_stock_api():
"""respx 로 stock API mock. base_url 은 테스트마다 임의."""
with respx.mock(base_url="https://test.stock.local", assert_all_called=False) as mock:
yield mock

View File

@@ -0,0 +1,92 @@
"""Tests for ChronosPredictor (model mock)."""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
@pytest.fixture
def mock_pipeline():
"""Mock BaseChronosPipeline.from_pretrained returning a mock pipeline object."""
with patch("chronos.BaseChronosPipeline") as cls:
cls.__name__ = "BaseChronosPipeline"
instance = MagicMock()
# ChronosBolt API: predict_quantiles returns (quantiles_tensor, mean_tensor)
# Modern (predict_quantiles) branch will be used since hasattr(MagicMock, "predict_quantiles") is True.
cls.from_pretrained.return_value = instance
yield instance
@pytest.fixture
def mock_torch_cpu():
with patch("torch.cuda.is_available", return_value=False):
yield
def _daily_ohlcv(close_seq):
return [{"datetime": f"2026-05-{i+1:02d}", "open": c, "high": c, "low": c,
"close": c, "volume": 1000} for i, c in enumerate(close_seq)]
def _mk_quantiles_tensor(q10_price: float, q50_price: float, q90_price: float):
"""Helper: build predict_quantiles return tensor shape [1, 1, 3]."""
import torch
return torch.tensor([[[q10_price, q50_price, q90_price]]], dtype=torch.float32)
def test_predict_batch_returns_prediction_dict(mock_pipeline, mock_torch_cpu):
"""mock predict_quantiles → dict[ticker, ChronosPrediction]. last_close=100, q50=102 → median≈+2%."""
quantiles = _mk_quantiles_tensor(101.5, 102.0, 102.5) # narrow around 102
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
from signal_v2.chronos_predictor import ChronosPredictor, ChronosPrediction
predictor = ChronosPredictor(model_name="mock-model")
daily = {"005930": _daily_ohlcv([100] * 60)}
result = predictor.predict_batch(daily)
assert "005930" in result
pred = result["005930"]
assert isinstance(pred, ChronosPrediction)
assert abs(pred.median - 0.02) < 0.001
def test_conf_high_when_distribution_narrow(mock_pipeline, mock_torch_cpu):
"""좁은 distribution (q90-q10 작음, median 0 아님) → conf ≈ 1."""
# last_close=100, q10=101.99, q50=102.00, q90=102.01
# returns: q10=0.0199, q50=0.02, q90=0.0201
# spread = (0.0201 - 0.0199) / max(0.02, 0.001) = 0.0002/0.02 = 0.01 → conf = 1 - 0.005 = 0.995
quantiles = _mk_quantiles_tensor(101.99, 102.0, 102.01)
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
from signal_v2.chronos_predictor import ChronosPredictor
predictor = ChronosPredictor(model_name="mock-model")
daily = {"005930": _daily_ohlcv([100] * 60)}
result = predictor.predict_batch(daily)
assert result["005930"].conf > 0.8
def test_conf_low_when_distribution_wide(mock_pipeline, mock_torch_cpu):
"""넓은 distribution → conf ≈ 0."""
# last_close=100, q10=70, q50=100, q90=130
# returns: q10=-0.3, q50=0.0, q90=0.3
# spread = (0.3 - (-0.3)) / max(0.0, 0.001) = 0.6 / 0.001 = 600 → conf = max(0, 1 - 300) = 0
quantiles = _mk_quantiles_tensor(70.0, 100.0, 130.0)
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
from signal_v2.chronos_predictor import ChronosPredictor
predictor = ChronosPredictor(model_name="mock-model")
daily = {"005930": _daily_ohlcv([100] * 60)}
result = predictor.predict_batch(daily)
assert result["005930"].conf < 0.3
def test_return_computed_from_price_relative_to_last_close(mock_pipeline, mock_torch_cpu):
"""price 예측 → last_close 대비 return 변환. last_close=100, q50=110 → return ≈ +10%."""
quantiles = _mk_quantiles_tensor(109.0, 110.0, 111.0)
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
from signal_v2.chronos_predictor import ChronosPredictor
predictor = ChronosPredictor(model_name="mock-model")
# last close = 100
daily = {"005930": _daily_ohlcv(list(range(41, 101)))} # last = 100
result = predictor.predict_batch(daily)
assert abs(result["005930"].median - 0.10) < 0.001

View File

@@ -0,0 +1,161 @@
"""Tests for KISClient (REST)."""
import json
from pathlib import Path
import httpx
import pytest
import respx
from signal_v2.kis_client import KISClient
@pytest.fixture
def fake_v1_token(tmp_path):
"""V1 토큰 파일 fixture."""
token_file = tmp_path / "kis_token.json"
token_file.write_text(json.dumps({
"access_token": "test-kis-token-abc123",
"token_expired": "2099-12-31 23:59:59",
}))
return token_file
@pytest.fixture
def kis_client_factory(fake_v1_token):
def _make():
return KISClient(
app_key="test-app-key",
app_secret="test-app-secret",
account="50000000-01",
is_virtual=True,
v1_token_path=fake_v1_token,
)
return _make
@respx.mock
async def test_get_minute_ohlcv_normal_returns_30_bars(kis_client_factory):
"""정상 200 → 30개 분봉 list 반환."""
sample_output2 = [
{
"stck_bsop_date": "20260518",
"stck_cntg_hour": f"09{m:02d}00",
"stck_oprc": "78000", "stck_hgpr": "78500",
"stck_lwpr": "77800", "stck_prpr": "78300",
"cntg_vol": "12345",
}
for m in range(30) # 9:00-9:29 = 30 bars
]
respx.get(
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
).mock(
return_value=httpx.Response(200, json={"output2": sample_output2})
)
client = kis_client_factory()
try:
bars = await client.get_minute_ohlcv("005930")
assert len(bars) == 30
assert bars[0]["close"] == 78300
assert "datetime" in bars[0]
finally:
await client.close()
@respx.mock
async def test_get_minute_ohlcv_429_retry_then_success(kis_client_factory, monkeypatch):
"""429 → exponential backoff → 200."""
sleep_calls = []
async def fake_sleep(s): sleep_calls.append(s)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
respx.get(
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
).mock(side_effect=[
httpx.Response(429, text="rate limit"),
httpx.Response(200, json={"output2": []}),
])
client = kis_client_factory()
try:
result = await client.get_minute_ohlcv("005930")
assert result == []
assert 1 in sleep_calls
finally:
await client.close()
@respx.mock
async def test_get_minute_ohlcv_uses_v1_token(kis_client_factory, fake_v1_token):
"""KIS 호출 헤더에 V1 토큰 파일의 access_token 사용."""
route = respx.get(
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
).mock(return_value=httpx.Response(200, json={"output2": []}))
client = kis_client_factory()
try:
await client.get_minute_ohlcv("005930")
assert route.called
req = route.calls.last.request
# check authorization header contains the V1 token
auth = req.headers.get("authorization", "")
assert "test-kis-token-abc123" in auth
finally:
await client.close()
@respx.mock
async def test_get_asking_price_computes_bid_ratio(kis_client_factory):
"""호가 응답 → bid_total/(bid+ask) bid_ratio 계산."""
respx.get(
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
).mock(return_value=httpx.Response(200, json={
"output1": {
"total_bidp_rsqn": "600",
"total_askp_rsqn": "400",
"stck_prpr": "78500",
}
}))
client = kis_client_factory()
try:
data = await client.get_asking_price("005930")
assert data["bid_total"] == 600
assert data["ask_total"] == 400
assert abs(data["bid_ratio"] - 0.6) < 1e-9
assert data["current_price"] == 78500
assert "as_of" in data
finally:
await client.close()
@respx.mock
async def test_get_daily_ohlcv_returns_60_bars(kis_client_factory):
"""KIS daily endpoint returns 60 ascending bars after parsing."""
# Build 60 KIS-format daily bars (descending dates as KIS does)
sample_output2 = []
for i in range(60):
# Generate a fake date 60 days ago, descending
day = 60 - i
sample_output2.append({
"stck_bsop_date": f"2026{(((day-1)//30)+1):02d}{(((day-1)%30)+1):02d}",
"stck_oprc": "78000", "stck_hgpr": "78500",
"stck_lwpr": "77800", "stck_clpr": str(78000 + i),
"acml_vol": "12345",
})
respx.get(
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
).mock(return_value=httpx.Response(200, json={"output2": sample_output2}))
client = kis_client_factory()
try:
bars = await client.get_daily_ohlcv("005930", days=60)
# KIS returns descending; client reverses to ascending
assert len(bars) == 60
# Ascending order: first item has smaller datetime than last
assert bars[0]["datetime"] < bars[-1]["datetime"]
assert isinstance(bars[0]["open"], int)
assert isinstance(bars[0]["close"], int)
assert "datetime" in bars[0]
finally:
await client.close()

View File

@@ -0,0 +1,94 @@
"""Tests for KISWebSocket."""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import respx
from signal_v2.kis_websocket import KISWebSocket
BASE_REST = "https://openapivts.koreainvestment.com:29443"
@respx.mock
async def test_fetch_approval_key_via_oauth_endpoint():
"""POST /oauth2/Approval → approval_key 추출."""
respx.post(f"{BASE_REST}/oauth2/Approval").mock(
return_value=httpx.Response(200, json={"approval_key": "test-approval-key-xyz"})
)
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
key = await ws._fetch_approval_key()
assert key == "test-approval-key-xyz"
assert ws._approval_key == "test-approval-key-xyz"
async def test_subscribe_sends_h0stasp0_message():
"""subscribe() → WebSocket 으로 H0STASP0 구독 메시지 전송."""
sent_messages = []
mock_ws = AsyncMock()
mock_ws.send = AsyncMock(side_effect=lambda m: sent_messages.append(m))
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
ws._approval_key = "test-key"
ws._ws = mock_ws
await ws.subscribe("005930")
assert ws._subscriptions == {"005930"}
assert len(sent_messages) == 1
msg = json.loads(sent_messages[0])
assert msg["header"]["tr_type"] == "1" # subscribe
assert msg["body"]["input"]["tr_id"] == "H0STASP0"
assert msg["body"]["input"]["tr_key"] == "005930"
def test_parse_asking_price_extracts_bid_ask_totals():
"""KIS raw '0|H0STASP0|001|...' → (ticker, dict).
KIS 호가 메시지 형식 — KIS 공식 spec 의 정확한 필드 인덱스 운영 검증 필요.
본 테스트는 implementer 의 _parse_asking_price 구현 인덱스에 맞춰서 sample 작성.
"""
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
# Build a sample raw message — implementer 가 _ASKING_TOTAL_BID/ASK 인덱스에
# 맞춰서 필드 배치하면 됨. 예: 마지막 2개 필드를 bid_total / ask_total 로.
fields = ["005930", "091500", "78500"] # ticker, time, current_price
fields.extend(["0"] * 40) # padding (KIS 의 실 필드 수 ~50개)
fields.append("400") # ask_total
fields.append("600") # bid_total
raw = f"0|H0STASP0|001|{'^'.join(fields)}"
result = ws._parse_asking_price(raw)
assert result is not None, "parse_asking_price returned None"
ticker, data = result
assert ticker == "005930"
assert "bid_total" in data
assert "ask_total" in data
assert "bid_ratio" in data
assert "current_price" in data
# bid_total=600, ask_total=400, bid_ratio=0.6
assert data["bid_total"] == 600
assert data["ask_total"] == 400
assert abs(data["bid_ratio"] - 0.6) < 1e-9
async def test_reconnect_on_disconnect_with_backoff(monkeypatch):
"""연결 끊김 → exponential backoff retry. _connect_with_backoff() 검증."""
sleep_calls = []
async def fake_sleep(s): sleep_calls.append(s)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
# Mock _connect to fail twice then succeed
call_count = [0]
async def fake_connect():
call_count[0] += 1
if call_count[0] < 3:
raise ConnectionError("fake disconnect")
return AsyncMock()
monkeypatch.setattr(ws, "_connect", fake_connect)
result = await ws._connect_with_backoff()
assert call_count[0] == 3 # 2 fails + 1 success
# exponential 1s, 2s
assert sleep_calls[:2] == [1, 2]

View File

@@ -0,0 +1,62 @@
"""Tests for FastAPI main app."""
import logging
import pytest
from fastapi.testclient import TestClient
def test_health_endpoint_returns_status_online(monkeypatch):
monkeypatch.setenv("STOCK_API_URL", "https://test.stock.local")
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
# Reload modules so they pick up the new env
import importlib
from signal_v2 import config as cfg
importlib.reload(cfg)
from signal_v2 import main as main_mod
importlib.reload(main_mod)
with TestClient(main_mod.app) as client:
r = client.get("/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "online"
assert body["stock_api_url"] == "https://test.stock.local"
def test_startup_warns_if_webai_api_key_missing(monkeypatch, caplog):
# Use setenv with empty string + no-op load_dotenv to defeat .env re-read on reload
monkeypatch.setattr("signal_v2.config.load_dotenv", lambda *a, **k: None)
monkeypatch.setenv("WEBAI_API_KEY", "")
monkeypatch.setenv("STOCK_API_URL", "https://test.stock.local")
import importlib
from signal_v2 import config as cfg
importlib.reload(cfg)
# After reload, load_dotenv reference is fresh — re-patch
monkeypatch.setattr("signal_v2.config.load_dotenv", lambda *a, **k: None)
from signal_v2 import main as main_mod
importlib.reload(main_mod)
with caplog.at_level(logging.WARNING, logger="signal_v2.main"):
with TestClient(main_mod.app) as client:
client.get("/health")
assert any("WEBAI_API_KEY" in rec.message for rec in caplog.records)
def test_startup_warns_if_kis_app_key_missing(monkeypatch, caplog):
"""KIS app_key 미설정 시 startup WARNING (KIS 호출 disabled) — V1 패턴."""
monkeypatch.setattr("signal_v2.config.load_dotenv", lambda *a, **k: None)
monkeypatch.setenv("STOCK_API_URL", "https://test.stock.local")
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
# V1 pattern: kis_env_type=virtual, both virtual keys empty
monkeypatch.setenv("KIS_ENV_TYPE", "virtual")
monkeypatch.setenv("KIS_VIRTUAL_APP_KEY", "")
monkeypatch.setenv("KIS_REAL_APP_KEY", "")
import importlib
from signal_v2 import config as cfg
importlib.reload(cfg)
monkeypatch.setattr("signal_v2.config.load_dotenv", lambda *a, **k: None)
from signal_v2 import main as main_mod
importlib.reload(main_mod)
with caplog.at_level(logging.WARNING, logger="signal_v2.main"):
with TestClient(main_mod.app) as client:
client.get("/health")
assert any("KIS" in rec.message and "app_key" in rec.message.lower() for rec in caplog.records)

View File

@@ -0,0 +1,92 @@
"""Tests for minute momentum classifier."""
from collections import deque
from signal_v2.momentum_classifier import (
aggregate_1min_to_5min, classify_minute_momentum,
STRONG_UP, WEAK_UP, NEUTRAL, WEAK_DOWN, STRONG_DOWN,
)
def _bar(open_, high, low, close, volume):
return {
"datetime": "2026-05-18T09:00:00+09:00",
"open": open_, "high": high, "low": low, "close": close, "volume": volume,
}
def _make_chunks(num_chunks_up: int, num_chunks_total: int, base_vol: int = 1000):
"""num_chunks_total 개의 5-bar 청크. num_chunks_up 청크는 양봉, 나머지는 음봉.
각 청크는 5개 1분봉. 거래량 = base_vol per bar.
"""
bars = []
for i in range(num_chunks_total):
is_up = i < num_chunks_up
o, c = (100, 110) if is_up else (110, 100)
for j in range(5):
bars.append(_bar(o, max(o, c) + 5, min(o, c) - 5, c, base_vol))
return bars
def test_strong_up_5_consecutive_green_with_high_volume():
"""직전 5개 5분봉 모두 양봉 + 거래량 1.5x → STRONG_UP."""
# 60분 (12 5분봉) 데이터: 7 normal + 5 high-vol up
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
recent = _make_chunks(num_chunks_up=5, num_chunks_total=5, base_vol=2500)
minute_bars = deque(older + recent, maxlen=60)
assert classify_minute_momentum(minute_bars) == STRONG_UP
def test_weak_up_3of5_green_normal_volume():
"""직전 5개 5분봉 중 3-4개 양봉 + 거래량 ≥ 1.0x → WEAK_UP."""
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
# 5 chunks: 3 up + 2 down, normal vol
recent_up = _make_chunks(num_chunks_up=3, num_chunks_total=3, base_vol=1000)
recent_down = _make_chunks(num_chunks_up=0, num_chunks_total=2, base_vol=1000)
minute_bars = deque(older + recent_up + recent_down, maxlen=60)
assert classify_minute_momentum(minute_bars) == WEAK_UP
def test_neutral_mixed():
"""up_count=2, vol normal → NEUTRAL (rule 미해당)."""
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
recent_up = _make_chunks(num_chunks_up=2, num_chunks_total=2, base_vol=1000)
recent_down = _make_chunks(num_chunks_up=0, num_chunks_total=3, base_vol=1000)
minute_bars = deque(older + recent_up + recent_down, maxlen=60)
# up_count=2, vol_mult=1.0 → 어느 분기 조건도 만족 안 함 → NEUTRAL
assert classify_minute_momentum(minute_bars) == NEUTRAL
def test_weak_down_low_green_low_volume():
"""up_count <= 2 + vol < 1.0 → WEAK_DOWN."""
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
recent_up = _make_chunks(num_chunks_up=1, num_chunks_total=1, base_vol=500)
recent_down = _make_chunks(num_chunks_up=0, num_chunks_total=4, base_vol=500)
minute_bars = deque(older + recent_up + recent_down, maxlen=60)
# recent 5 chunks avg vol = 500, long 12 avg ≈ (7*1000 + 5*500) / 12 ≈ 791 → vol_mult ≈ 0.63
assert classify_minute_momentum(minute_bars) == WEAK_DOWN
def test_strong_down_5_consecutive_red_high_volume():
"""직전 5개 5분봉 모두 음봉 + 거래량 1.5x → STRONG_DOWN."""
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
recent = _make_chunks(num_chunks_up=0, num_chunks_total=5, base_vol=2500)
minute_bars = deque(older + recent, maxlen=60)
assert classify_minute_momentum(minute_bars) == STRONG_DOWN
def test_aggregate_1min_to_5min_correctness():
"""5 1분봉 → 1개 5분봉 — open/close/high/low/volume 정확."""
bars = [
_bar(100, 105, 99, 102, 1000),
_bar(102, 108, 101, 107, 1500),
_bar(107, 110, 105, 106, 800),
_bar(106, 109, 104, 108, 1200),
_bar(108, 112, 107, 111, 900),
]
result = aggregate_1min_to_5min(bars)
assert len(result) == 1
assert result[0]["open"] == 100 # 첫 bar
assert result[0]["close"] == 111 # 마지막 bar
assert result[0]["high"] == 112 # max
assert result[0]["low"] == 99 # min
assert result[0]["volume"] == 5400 # sum

View File

@@ -0,0 +1,131 @@
"""Tests for pull_worker (Phase 3a additions)."""
from collections import deque
from unittest.mock import AsyncMock, MagicMock
import pytest
from signal_v2.state import PollState
async def test_minute_polling_cycle_updates_state_minute_bars():
"""KIS REST mock 의 분봉 데이터가 state.minute_bars[ticker] deque 에 들어간다."""
from signal_v2.pull_worker import _run_kis_minute_cycle
state = PollState()
state.portfolio = {"holdings": [{"ticker": "005930"}, {"ticker": "000660"}]}
state.screener_preview = {
"items": [{"ticker": "005930"}, {"ticker": "035720"}]
}
kis_client_mock = MagicMock()
kis_client_mock.get_minute_ohlcv = AsyncMock(side_effect=[
[{"datetime": "2026-05-18T09:00:00+09:00", "open": 78000,
"high": 78500, "low": 77900, "close": 78300, "volume": 12345}],
[{"datetime": "2026-05-18T09:00:00+09:00", "open": 180000,
"high": 181000, "low": 179800, "close": 180500, "volume": 5000}],
[{"datetime": "2026-05-18T09:00:00+09:00", "open": 51000,
"high": 51200, "low": 50800, "close": 51100, "volume": 8000}],
])
kis_client_mock.get_asking_price = AsyncMock(return_value={
"bid_total": 600, "ask_total": 400, "bid_ratio": 0.6,
"current_price": 51100, "as_of": "2026-05-18T09:00:30+09:00",
})
await _run_kis_minute_cycle(kis_client_mock, state)
# 3 unique tickers (005930, 000660, 035720)
assert "005930" in state.minute_bars
assert "000660" in state.minute_bars
assert "035720" in state.minute_bars
assert len(state.minute_bars["005930"]) >= 1
# asking_price 만 screener-only ticker (035720) 에 들어가야 함
# (portfolio = 005930, 000660 는 WebSocket 으로 들어옴)
assert "035720" in state.asking_price
def test_websocket_message_updates_state_asking_price():
"""WebSocket callback factory → state.asking_price 갱신."""
from signal_v2.pull_worker import make_asking_price_callback
state = PollState()
cb = make_asking_price_callback(state)
cb("005930", {"bid_total": 1000, "ask_total": 800, "bid_ratio": 0.555,
"current_price": 78500, "as_of": "2026-05-18T10:00:00+09:00"})
assert state.asking_price["005930"]["bid_total"] == 1000
assert "asking_price/005930" in state.last_updated
async def test_post_close_cycle_updates_chronos_predictions():
"""mock kis + mock chronos → state.chronos_predictions + state.daily_ohlcv 갱신."""
from unittest.mock import AsyncMock, MagicMock
from signal_v2.pull_worker import _run_post_close_cycle
from signal_v2.chronos_predictor import ChronosPrediction
from signal_v2.state import PollState
state = PollState()
state.portfolio = {"holdings": [{"ticker": "005930"}]}
state.screener_preview = {"items": [{"ticker": "000660"}]}
kis_mock = MagicMock()
daily_005930 = [{"datetime": f"2026-05-{i+1:02d}", "open": 100, "high": 105,
"low": 95, "close": 100 + i, "volume": 1000} for i in range(60)]
daily_000660 = [{"datetime": f"2026-05-{i+1:02d}", "open": 200, "high": 210,
"low": 190, "close": 200 + i, "volume": 2000} for i in range(60)]
# _run_post_close_cycle iterates tickers and calls get_daily_ohlcv per ticker.
# Order depends on set() so use side_effect mapping if possible, otherwise list.
async def fake_daily(ticker, days=60):
if ticker == "005930":
return daily_005930
if ticker == "000660":
return daily_000660
return []
kis_mock.get_daily_ohlcv = AsyncMock(side_effect=fake_daily)
chronos_mock = MagicMock()
chronos_mock.predict_batch = MagicMock(return_value={
"005930": ChronosPrediction(0.02, -0.01, 0.04, 0.85, "2026-05-18T16:00:00+09:00"),
"000660": ChronosPrediction(0.03, -0.02, 0.06, 0.75, "2026-05-18T16:00:00+09:00"),
})
await _run_post_close_cycle(kis_mock, chronos_mock, state)
assert "005930" in state.chronos_predictions
assert "000660" in state.chronos_predictions
assert state.chronos_predictions["005930"]["median"] == 0.02
assert state.chronos_predictions["005930"]["conf"] == 0.85
assert "005930" in state.daily_ohlcv
assert "chronos/005930" in state.last_updated
def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch):
"""Phase 4: generate_signals 가 cycle 후 state.signals 를 갱신한다."""
from unittest.mock import MagicMock
from signal_v2.state import PollState
from signal_v2.signal_generator import generate_signals
state = PollState()
state.portfolio = {"holdings": [{
"ticker": "005930", "name": "삼성전자",
"avg_price": 75000, "current_price": 69000,
"pnl_pct": -0.08, "profit_rate": -8.0,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
dedup = MagicMock()
dedup.is_recent.return_value = False
settings = MagicMock()
settings.stop_loss_pct = -0.07
settings.take_profit_pct = 0.15
settings.chronos_spread_threshold = 0.6
settings.asking_bid_ratio_threshold = 0.6
settings.confidence_threshold = 0.7
settings.min_momentum_for_buy = "strong_up"
generate_signals(state, dedup, settings)
assert "005930" in state.signals
assert state.signals["005930"]["action"] == "sell"
assert state.signals["005930"]["confidence_webai"] == 1.0
dedup.record.assert_called_with("005930", "sell", confidence=1.0)

View File

@@ -0,0 +1,34 @@
"""Tests for SignalDedup."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from signal_v2.rate_limit import SignalDedup
KST = ZoneInfo("Asia/Seoul")
def test_is_recent_returns_false_for_new_ticker_action(tmp_dedup_db):
dedup = SignalDedup(tmp_dedup_db)
assert dedup.is_recent("005930", "buy") is False
def test_is_recent_returns_true_within_24h(tmp_dedup_db):
dedup = SignalDedup(tmp_dedup_db)
dedup.record("005930", "buy", confidence=0.82)
assert dedup.is_recent("005930", "buy") is True
def test_is_recent_returns_false_after_24h(tmp_dedup_db, monkeypatch):
dedup = SignalDedup(tmp_dedup_db)
# Record with a timestamp 25 hours ago
now = datetime.now(KST)
fake_now = now - timedelta(hours=25)
monkeypatch.setattr(
"signal_v2.rate_limit._now_iso", lambda: fake_now.isoformat()
)
dedup.record("005930", "buy", confidence=0.82)
# Reset to real now for is_recent check
monkeypatch.setattr(
"signal_v2.rate_limit._now_iso", lambda: now.isoformat()
)
assert dedup.is_recent("005930", "buy", within_hours=24) is False

View File

@@ -0,0 +1,81 @@
"""Tests for scheduler interval logic."""
from datetime import datetime
import pytest
from signal_v2.scheduler import _next_interval, _is_market_day, KST
def _kst(year, month, day, hour, minute=0):
return datetime(year, month, day, hour, minute, tzinfo=KST)
def test_next_interval_pre_market_5min():
now = _kst(2026, 5, 18, 8, 30) # Monday 08:30
assert _next_interval(now) == 300
def test_next_interval_market_open_1min():
now = _kst(2026, 5, 18, 10, 0) # Monday 10:00
assert _next_interval(now) == 60
def test_next_interval_post_market_5min():
now = _kst(2026, 5, 18, 17, 0) # Monday 17:00
assert _next_interval(now) == 300
def test_next_interval_overnight_skip_to_next_morning():
now = _kst(2026, 5, 18, 2, 30) # Monday 02:30 (dead zone, not NXT window)
interval = _next_interval(now)
# Dead zone 23:30-04:30 → next 04:30 is ~2h away
assert 2 * 3600 - 60 < interval < 2 * 3600 + 60
def test_next_interval_holiday_skip():
# 2026-05-05 어린이날 (Tuesday holiday)
now = _kst(2026, 5, 5, 10, 0)
assert _is_market_day(now) is False
interval = _next_interval(now)
# Next: 2026-05-06 (Wed) 07:00, ~21h away
assert 20 * 3600 < interval < 22 * 3600
def test_next_interval_at_market_open_boundary():
"""09:00:00 정확 second → 60초 (market 구간 진입)."""
now = _kst(2026, 5, 18, 9, 0) # Monday 09:00:00
assert _next_interval(now) == 60
def test_next_interval_at_market_close_boundary():
"""15:30:00 정확 second → 300초 (post-market 구간 진입)."""
now = _kst(2026, 5, 18, 15, 30) # Monday 15:30:00
assert _next_interval(now) == 300
def test_next_interval_at_polling_window_end_boundary():
"""23:30:00 정확 second → dead zone skip (다음 04:30 까지)."""
now = _kst(2026, 5, 18, 23, 30) # Monday 23:30:00 (NXT_PRE_END boundary)
interval = _next_interval(now)
# Dead zone 23:30-04:30 → next 04:30 is ~5h away
assert 5 * 3600 - 60 < interval < 5 * 3600 + 60
def test_next_interval_nxt_evening_5min():
"""22:00 평일 (NXT 야간) → 300 (5분)."""
now = _kst(2026, 5, 18, 22, 0)
assert _next_interval(now) == 300
def test_next_interval_nxt_dawn_5min():
"""05:30 평일 (NXT 새벽) → 300 (5분)."""
now = _kst(2026, 5, 18, 5, 30)
assert _next_interval(now) == 300
def test_next_interval_dead_zone_skip():
"""02:00 평일 (dead zone 23:30-04:30) → 다음 04:30 까지 (~9000s)."""
now = _kst(2026, 5, 18, 2, 0)
interval = _next_interval(now)
# 02:00 → 04:30 = 2.5h = 9000s
assert 9000 - 60 < interval < 9000 + 60

View File

@@ -0,0 +1,172 @@
"""Tests for signal_generator."""
from unittest.mock import MagicMock
import pytest
from signal_v2.signal_generator import generate_signals
from signal_v2.state import PollState
def _settings(**overrides):
"""Build a Settings-like object for tests (avoid env)."""
defaults = dict(
stop_loss_pct=-0.07,
take_profit_pct=0.15,
chronos_spread_threshold=0.6,
asking_bid_ratio_threshold=0.6,
confidence_threshold=0.7,
min_momentum_for_buy="strong_up",
)
defaults.update(overrides)
m = MagicMock()
for k, v in defaults.items():
setattr(m, k, v)
return m
def _make_state_with_buy_candidate(
ticker="005930", name="삼성전자",
chronos_median=0.02, chronos_q10=-0.01, chronos_q90=0.04, chronos_conf=0.85,
momentum="strong_up", bid_ratio=0.7, current_price=78500,
):
state = PollState()
state.screener_preview = {"items": [{"ticker": ticker, "name": name}]}
state.chronos_predictions[ticker] = {
"median": chronos_median, "q10": chronos_q10, "q90": chronos_q90,
"conf": chronos_conf, "as_of": "2026-05-17T16:00:00+09:00",
}
state.minute_momentum[ticker] = momentum
state.asking_price[ticker] = {
"bid_total": int(bid_ratio * 1000),
"ask_total": int((1 - bid_ratio) * 1000),
"bid_ratio": bid_ratio,
"current_price": current_price,
"as_of": "2026-05-17T16:00:01+09:00",
}
return state
def _make_state_with_holding(
ticker="005930", name="삼성전자",
pnl_pct=0.0, avg_price=75000, current_price=75000,
):
state = PollState()
state.portfolio = {"holdings": [{
"ticker": ticker, "name": name,
"avg_price": avg_price, "current_price": current_price,
"pnl_pct": pnl_pct, "profit_rate": pnl_pct * 100,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
return state
@pytest.fixture
def dedup_mock():
d = MagicMock()
d.is_recent.return_value = False
return d
def test_buy_signal_when_all_conditions_pass_and_confidence_high(dedup_mock):
state = _make_state_with_buy_candidate()
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "buy"
assert sig["confidence_webai"] > 0.7
dedup_mock.record.assert_called()
def test_silent_when_chronos_median_negative(dedup_mock):
state = _make_state_with_buy_candidate(chronos_median=-0.01)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_distribution_spread_too_wide(dedup_mock):
# spread = q90 - q10 = 0.5 - (-0.5) = 1.0 > 0.6 → hard gate fails
state = _make_state_with_buy_candidate(
chronos_median=0.001, chronos_q10=-0.5, chronos_q90=0.5,
)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_momentum_not_strong_up(dedup_mock):
state = _make_state_with_buy_candidate(momentum="weak_up")
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_bid_ratio_below_threshold(dedup_mock):
state = _make_state_with_buy_candidate(bid_ratio=0.5)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_confidence_below_threshold(dedup_mock):
# chronos_conf low + rank=20 → confidence < 0.7
state = _make_state_with_buy_candidate(chronos_conf=0.3)
# add 19 fake items to push 005930 rank to 20
state.screener_preview["items"] = (
[{"ticker": f"FAKE{i:03d}"} for i in range(19)]
+ [{"ticker": "005930", "name": "삼성전자"}]
)
generate_signals(state, dedup_mock, _settings())
# confidence_webai = 0.3*0.5 + 1.0*0.3 + 0.05*0.2 = 0.46 < 0.7
assert "005930" not in state.signals
def test_sell_signal_when_stop_loss_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=-0.08, current_price=69000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 1.0
assert sig["pnl_pct"] == -0.08
def test_sell_signal_when_take_profit_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=0.16, current_price=87000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 0.6
def test_silent_when_dedup_recently_sent(dedup_mock):
state = _make_state_with_buy_candidate()
dedup_mock.is_recent.return_value = True
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
dedup_mock.record.assert_not_called()
def test_sell_signal_triggers_on_anomaly_path(dedup_mock):
"""Anomaly sell: median < -1%, momentum strong_down, low bid_ratio, confidence > threshold."""
state = PollState()
state.portfolio = {"holdings": [{
"ticker": "005930", "name": "삼성전자",
"avg_price": 75000, "current_price": 70000,
"pnl_pct": -0.067, # within stop_loss tolerance (default -0.07): NOT triggering stop_loss
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
state.chronos_predictions["005930"] = {
"median": -0.025, "q10": -0.05, "q90": 0.005, "conf": 0.85,
}
state.minute_momentum["005930"] = "strong_down"
state.asking_price["005930"] = {"current_price": 70000, "bid_ratio": 0.30}
# bid_ratio 0.30 < (1 - 0.6) = 0.4 → anomaly bid_ratio gate passes
# confidence = 0.85*0.5 + 1.0*0.3 + 1.0*0.2 = 0.425 + 0.3 + 0.2 = 0.925 > 0.7
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["context"]["sell_reason"] == "anomaly"
assert sig["confidence_webai"] > 0.7

View File

@@ -0,0 +1,168 @@
"""Tests for stock_client.StockClient."""
import asyncio
import logging
import pytest
import httpx
from signal_v2.stock_client import StockClient
BASE_URL = "https://test.stock.local"
API_KEY = "test-secret"
async def test_get_portfolio_normal_returns_dict_with_pnl_pct(mock_stock_api):
"""정상 200 응답 + cache 저장."""
mock_stock_api.get("/api/webai/portfolio").mock(
return_value=httpx.Response(
200,
json={
"holdings": [{"ticker": "005930", "pnl_pct": 0.047}],
"cash": [],
"summary": {},
},
)
)
client = StockClient(BASE_URL, API_KEY)
try:
result = await client.get_portfolio()
assert result["holdings"][0]["pnl_pct"] == 0.047
# Cache populated
assert len(client._cache) >= 1
finally:
await client.close()
async def test_get_portfolio_uses_cache_within_ttl(mock_stock_api):
"""60s TTL 내 두번째 호출 = mock 콜 1회."""
route = mock_stock_api.get("/api/webai/portfolio").mock(
return_value=httpx.Response(
200, json={"holdings": [], "cash": [], "summary": {}}
)
)
client = StockClient(BASE_URL, API_KEY)
try:
await client.get_portfolio()
await client.get_portfolio() # second call within TTL
assert route.call_count == 1
finally:
await client.close()
async def test_get_portfolio_refetches_after_ttl_expiry(mock_stock_api, monkeypatch):
"""TTL 만료 후 재호출 = mock 콜 2회. time.monotonic 모킹."""
route = mock_stock_api.get("/api/webai/portfolio").mock(
return_value=httpx.Response(
200, json={"holdings": [], "cash": [], "summary": {}}
)
)
# Fake clock: starts at 0, jumps to 61 between calls
fake_time = [0.0]
monkeypatch.setattr(
"signal_v2.stock_client.time.monotonic", lambda: fake_time[0]
)
client = StockClient(BASE_URL, API_KEY)
try:
await client.get_portfolio()
fake_time[0] = 61.0 # 60s TTL 만료
await client.get_portfolio()
assert route.call_count == 2
finally:
await client.close()
async def test_get_portfolio_retries_3_times_on_timeout(mock_stock_api, monkeypatch):
"""timeout 2번 + 200 1번 → 최종 성공. exponential sleep 호출 검증."""
sleep_calls = []
async def fake_sleep(s):
sleep_calls.append(s)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
mock_stock_api.get("/api/webai/portfolio").mock(
side_effect=[
httpx.TimeoutException("timeout 1"),
httpx.TimeoutException("timeout 2"),
httpx.Response(
200, json={"holdings": [], "cash": [], "summary": {}}
),
]
)
client = StockClient(BASE_URL, API_KEY)
try:
result = await client.get_portfolio()
assert result["holdings"] == []
assert sleep_calls == [1, 2] # exponential 1s, 2s
finally:
await client.close()
async def test_get_portfolio_429_triggers_backoff(mock_stock_api, monkeypatch):
"""429 → 1s backoff → 200."""
sleep_calls = []
async def fake_sleep(s):
sleep_calls.append(s)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
mock_stock_api.get("/api/webai/portfolio").mock(
side_effect=[
httpx.Response(429, text="rate limit"),
httpx.Response(
200, json={"holdings": [], "cash": [], "summary": {}}
),
]
)
client = StockClient(BASE_URL, API_KEY)
try:
result = await client.get_portfolio()
assert result["holdings"] == []
assert sleep_calls == [1]
finally:
await client.close()
async def test_get_portfolio_falls_back_to_stale_on_all_failures(
mock_stock_api, monkeypatch, caplog
):
"""cache 에 이전 성공 응답 + 모든 retry 5xx → stale 반환 + logger.warning."""
# No-op sleep for fast test
async def fake_sleep(s):
return None
monkeypatch.setattr("asyncio.sleep", fake_sleep)
# Patch time.monotonic BEFORE first call so cached timestamp uses fake clock
fake_time = [0.0]
monkeypatch.setattr(
"signal_v2.stock_client.time.monotonic", lambda: fake_time[0]
)
# First call succeeds
route1 = mock_stock_api.get("/api/webai/portfolio").mock(
return_value=httpx.Response(
200,
json={"holdings": [{"ticker": "005930"}], "cash": [], "summary": {}},
)
)
client = StockClient(BASE_URL, API_KEY)
try:
first = await client.get_portfolio()
assert first["holdings"][0]["ticker"] == "005930"
# Advance fake clock past TTL (60s) so cache is stale
fake_time[0] = 61.0
# Now mock to return 500s persistently
route1.mock(return_value=httpx.Response(500, text="server error"))
with caplog.at_level(logging.WARNING, logger="signal_v2.stock_client"):
result = await client.get_portfolio()
assert result["holdings"][0]["ticker"] == "005930" # stale data returned
assert any(
"stale" in rec.message.lower() for rec in caplog.records
)
finally:
await client.close()

3
start.bat Normal file
View File

@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0\signal_v1"
python main_server.py