# trade-monitor 워커 — 구현 설계

> **2026-07-03 · web-ai 소유.** 실시간 매매 알람 파이프라인의 Windows-side 워커.
> **권위 계약(원본 스펙)**: web-backend repo `docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md` §5(계약)·§6(조건).
> 이 문서는 그 계약을 Windows Docker 워커로 구현하기 위한 **구현 설계**(모듈 분해·조건 해석·배포)만 다룬다.

---

## 1. 역할 · 경계

`services/trade-monitor/` 는 형제 워커(`image-render`, `task-watcher`)와 동일한 관례를 따르는 **FastAPI + asyncio 루프** 워커다. WSL2 Docker(`services/docker-compose.yml`)에서 구동.

**책임**
1. 60초 루프로 NAS `monitor-set` 조회 → 세션 게이트.
2. 비-KRX(알파벳) 티커 skip.
3. KIS 실시간 현재가 + 일봉 OHLCV 조회 → TA 지표 계산.
4. 매수/매도 조건 평가 → 발화집합 F 구성.
5. `POST report`로 F 전체 전송(무상태 — dedup은 NAS 영속).
6. Redis heartbeat 발신(`worker:trade-monitor:heartbeat` EX45).
7. KIS 오류는 사이클/종목 단위 격리(다음 분 재시도).

**경계 밖(안 함)**: dedup 상태 보관, 텔레그램 전송(NAS 담당), KST 세션/휴장 캘린더 재구현(NAS가 `session` 판정), 주문 실행.

---

## 2. 모듈 분해

| 파일 | 책임 | 인터페이스(순수/부작용) |
|------|------|------|
| `main.py` | FastAPI app + lifespan. `monitor_loop` + `heartbeat_loop` 스폰, `/health` | 부작용(태스크 스폰) |
| `monitor.py` | 오케스트레이션 루프. monitor-set→게이트→종목순회→firing→report. 공유 `MonitorState` 갱신 | 부작용 |
| `nas_client.py` | `get_monitor_set()` / `post_report(as_of, firing)` — `X-WebAI-Key` + retry | 부작용(HTTP) |
| `kis_client.py` | KIS REST: `_issue_token()`(OAuth 자체 발급, 24h 캐시) + `get_quote()` + `get_daily_ohlcv()` + 0.5s throttle | 부작용(HTTP) |
| `indicators.py` | `sma`, `rsi`, `avg_volume`, `highest_high` | **순수** |
| `conditions.py` | `evaluate_buy(ctx, buy_params)` / `evaluate_sell(ctx, exit_params)` → `list[firing]` | **순수** |
| `config.py` | `Settings` — env 로드 | 순수 |

순수 모듈(`indicators`, `conditions`)에 조건 로직을 격리해 테이블 기반 단위 테스트로 검증 가능하게 한다. HTTP·시간·Redis는 경계 모듈에만.

---

## 3. 데이터 흐름 (monitor_loop, 60초)

```
매 사이클:
  ms = nas.get_monitor_set()                      # §5.1
  state.session = ms.session
  if ms.session == "closed":
      state.hb = "market_closed"; sleep; continue  # KIS 호출 0
  targets = filter_krx(ms.buy_targets, ms.sell_targets)  # 알파벳 티커 skip
  firing = []
  for t in targets:                                # 종목 단위 try/except 격리
      quote = kis.get_quote(t.ticker)              # 현재가 + 당일 누적 거래량
      daily = kis.get_daily_ohlcv(t.ticker, 250)   # MA200·52주 고점용
      ctx = build_ctx(t, quote, daily)
      if t is buy_target:  firing += evaluate_buy(ctx, ms.buy_params)
      if t is sell_target: firing += evaluate_sell(ctx, ms.exit_params)
  if firing: state.last_alert_at = now
  nas.post_report(as_of=now_kst_iso, firing=firing)  # §5.2 — 빈 배열도 전송(edge clear 위해)
  state.hb = "market_open"
  stats.jobs_done += 1
```

`heartbeat_loop`(별도 15초 태스크)이 `state`를 읽어 §5.4 페이로드 발신.

---

## 4. 조건 로직 해석 (§6)

지표는 **일봉 시계열**(최신 last, 오름차순) + **실시간 현재가**(`price`) 기준. 데이터 부족(예: MA200용 200봉 미만)이면 해당 조건은 **미발화**(graceful skip). 각 firing은 `{ticker, kind, condition, price, detail}`.

### 매수 (`buy_targets`, `buy_params={rsi_oversold, breakout_vol_mult, pullback_pct}`)

| condition | 발화 규칙(해석) | detail |
|-----------|----------------|--------|
| `buy_ma20_pullback` | `ma20>ma50>ma200`(정배열) **AND** 최근 3봉 최저가가 `ma20*(1+pullback_pct)` 이하로 접근 **AND** `price>ma20`(반등 복귀) | `ma20, ma50, ma200, recent_low` |
| `buy_breakout` | `price > 직전 20봉 최고가`(당일 제외) **AND** `today_volume > breakout_vol_mult × avg_volume(20)` | `prior_high_20, vol_mult, avg_vol_20` |
| `buy_rsi_bounce` | RSI(14) 시계열에서 `min(rsi[-3:]) < rsi_oversold` **AND** `rsi[-1] > rsi_oversold` **AND** `rsi[-1] > rsi[-2]`(반등). 사이클마다 재계산·무상태 | `rsi, rsi_prev, rsi_oversold` |

종합: 각 조건 독립 발화(신뢰도 가중합은 NAS/텔레그램 단계 책임 아님 — 워커는 조건 발화만).

### 매도 (`sell_targets`, `exit_params={stop_pct, take_pct, trailing_pct}`, target에 `avg_price, qty, holding_high`)

| condition | 발화 규칙 | detail |
|-----------|----------|--------|
| `sell_stop_loss` | `(price-avg)/avg ≤ -stop_pct` | `avg_price, pnl_pct, stop_pct` |
| `sell_take_profit` | `(price-avg)/avg ≥ take_pct` | `avg_price, pnl_pct, take_pct` |
| `sell_trailing_stop` | `price ≤ holding_high × (1-trailing_pct)` (기본 0.10) | `holding_high, trailing_pct, drawdown_pct` |
| `sell_ma_break` | `price < ma50` (추가 `price<ma200`이면 detail.severity="high") | `ma50, ma200, severity` |
| `sell_climax` | **holdings_intel 정합**: `today_volume ≥ climax_vol_x × avg_volume(20)` **AND** `price < day_high × climax_close_pct`(윗꼬리) | `vol_mult, day_high, climax_close_pct` |

`climax_vol_x`(기본 3.0)·`climax_close_pct`(기본 0.97)는 monitor-set `exit_params`에서 읽음(BE 중앙화, main ed17193). 없으면 env `TM_CLIMAX_VOL_MULT` fallback. `day_high`는 KIS quote `stck_hgpr`(당일 세션 누적 고가).

---

## 5. KIS 클라이언트 (자체 토큰)

- `_issue_token()`: `POST {base}/oauth2/tokenP {grant_type, appkey, appsecret}` → `access_token`(만료 24h). 메모리 캐시, 만료 10분 전 재발급. **ai_trade와 분리된 `TM_KIS_APP_KEY/SECRET`** 사용(같은 app_key 공유 시 토큰 상호 무효화 + EGW00201).
- `get_quote(ticker)`: `inquire-price`(FHKST01010100) → `stck_prpr`(현재가), `acml_vol`(당일 누적 거래량), `stck_oprc`(당일 시가).
- `get_daily_ohlcv(ticker, days=250)`: `inquire-daily-itemchartprice`(FHKST03010100) — ai_trade `kis_client.py` 로직 복제, 오름차순.
- throttle 0.5s(초당 2회) + `_throttle_lock` 직렬화 + 429/timeout 지수 backoff(ai_trade 패턴 재사용).

> ⚠️ **운영 함정**: ai_trade와 KIS를 동시 호출하면 전용 app_key라도 KIS 계정 전체 rate limit을 공유할 수 있음. 별도 app_key로 무효화는 회피되나, 운영 시 동시 부하 모니터링 필요(Phase 7 백로그 연계).

---

## 6. heartbeat (§5.4)

`_shared.heartbeat.heartbeat_loop(redis, "trade-monitor", "trader", stats, interval=15, ttl=45, state_fn=...)`.
- `state_fn`이 `MonitorState`를 읽어 `state ∈ {market_open, market_closed, idle}` + `extra={"last_alert_at": ...}` 반환.
- **디커플링 이유**: 루프 60초 > TTL 45초 → 인라인 발신 시 만료 갭. 15초 독립 태스크로 해소(형제 워커와 동일 구조). §5.4 필수 필드(name/kind/state/ts/last_alert_at) 충족, `jobs_done/jobs_failed`는 형제 워커처럼 superset 유지.
- 초기 상태 `idle`(첫 monitor-set 조회 전).

---

## 7. 설정 (env) — `TM_` 접두사로 ai_trade와 분리

| env | 기본값 | 용도 |
|-----|--------|------|
| `NAS_BASE_URL` | `http://192.168.45.54:18500` | stock 백엔드 |
| `WEBAI_API_KEY` | (필수) | `X-WebAI-Key` |
| `REDIS_URL` | `redis://192.168.45.54:6379` | heartbeat |
| `TM_KIS_APP_KEY` / `TM_KIS_APP_SECRET` | (필수) | KIS 자체 토큰 |
| `TM_KIS_ACCOUNT` | (필수) | KIS 계좌 |
| `TM_KIS_IS_VIRTUAL` | `0` | 실전/모의 |
| `TM_LOOP_INTERVAL` | `60` | 루프 주기(초) |
| `TM_CLIMAX_VOL_MULT` | `3.0` | sell_climax 임계 |

---

## 8. 에러 처리

- **monitor-set 실패**: 사이클 skip(report 안 함), heartbeat=`idle`, 다음 분 재시도.
- **KIS 종목 실패**: 해당 종목만 skip(로그 warning), 나머지 종목 계속.
- **report 실패**: 로그 error, 다음 사이클 신선 firing 재전송(무상태라 손실 허용).
- 루프 최상위 `try/except` — 어떤 예외도 루프를 죽이지 않음(task-watcher 패턴).

---

## 9. 테스트 전략 (pytest, 시스템 Python)

| 파일 | 검증 |
|------|------|
| `test_indicators.py` | sma/rsi/avg_volume/highest_high 수치(알려진 시계열), 데이터 부족 시 None |
| `test_conditions.py` | 8개 조건 테이블 기반(발화/미발화 경계), detail 필드 |
| `test_nas_client.py` | respx — monitor-set 파싱, report 페이로드, X-WebAI-Key 헤더, retry |
| `test_kis_client.py` | respx — 토큰 발급/캐시, quote/daily 파싱, throttle |
| `test_monitor.py` | 루프 1회(mock): closed skip, 비-KRX skip, firing 조립, last_alert_at 갱신, 종목 실패 격리 |

---

## 10. 배포

- `services/trade-monitor/Dockerfile`: task-watcher 관례 복제 — `COPY _shared /app/_shared` **필수**(빌드 컨텍스트 `.` 에서), `COPY trade-monitor/. /app/`, `PYTHONPATH=/app`, uvicorn `:8000`.
- `services/docker-compose.yml`: `trade-monitor` 서비스 추가, 포트 **18715**(image-render 18714 다음), `TZ=Asia/Seoul`, KIS/WEBAI/REDIS env, healthcheck `/health`.
- `services/.env`(비커밋): `TM_KIS_*`, `WEBAI_API_KEY` 실값. `.env.example`에 키만 기재.

---

## 11. 미해결 플래그 / 후속

1. **sell_climax** — ✅ 2026-07-03 holdings_intel 정합 완료(`price < day_high × climax_close_pct` + `exit_params` 파라미터화). BE 회신 기준.
2. **KIS 지표 필드 실검증** — quote의 `acml_vol`/`stck_oprc`, daily TR 응답 필드는 첫 운영 raw 캡처로 대조.
3. **`buy_ma20_pullback`·`buy_rsi_bounce` 해석** — "current candle series" 문구를 일봉 시계열로 해석. 첫 운영 4주 IC 검증 시 재조정 가능.
4. **KIS rate limit 공존** — ai_trade와 동시 부하. 전용 app_key로 토큰 무효화는 회피, 초당 호출 총량은 운영 모니터링.
5. **after 세션 시간외 시세** — `inquire-price`가 시간외 단일가를 반영하는지 첫 운영 대조.
