18 Commits

Author SHA1 Message Date
9baea3a0e2 feat(stock): 매매알람 쿨다운 중복억제 + 종목명 해석
- 쿨다운(TRADE_ALERT_COOLDOWN_HOURS 기본 6h): 같은 종목·조건 해제→재발화 오실레이션 시
  재알림 억제(set_alert_firing mark_fired=False로 firing 유지·발동시각 미갱신, suppressed 카운트).
- 종목명: 워커 firing에 name 없어도 NAS가 watchlist→portfolio→krx_master로 해석해 알림·이력에 포함.
2026-07-03 16:14:51 +09:00
80daa53558 feat(agent-office): 매매알람에 조건별 '왜 매수/매도' 한 줄 근거(💡) 추가 2026-07-03 16:14:51 +09:00
35795abb0f docs(README): 실시간 매매 알람 + WSL워커 /infra 관측 팀규칙 + Alpine tzdata 함정 반영
stock 실시간 매매알람(watchlist/trade_alert_state/history·webai 계약·1분 Windows 워커),
agent-office 매매알람 notify+/watch 봇·분산워커 관측, 주의사항에 팀규칙·tzdata,
DB 테이블 목록 최신화. (기존 하네스 엔지니어링 섹션도 함께 커밋)
2026-07-03 11:01:24 +09:00
4e47f5dd43 docs(CLAUDE.md): [팀 규칙] 모든 WSL docker 워커는 /infra 관측 필수 (node_monitor WORKER_REGISTRY 등재+heartbeat 3단계) 2026-07-03 10:48:17 +09:00
246c8d5328 feat(agent-office): node_monitor에 trade-monitor 워커 등재 + trader 링크 from을 워커명으로 수정
WSL 워커 관측 규칙 — 모든 WSL docker 워커는 /infra에서 모니터링 가능해야 함.
trade-monitor(kind=trader) 등재 → /nodes·/infra 노출. 링크 from 하드코딩('ai_trade')을
w[name]으로 고쳐 다중 trader가 각자 링크를 갖도록 함. 미배포 워커는 prev=None이라 다운 경보 없음.
2026-07-03 10:45:45 +09:00
ed17193945 feat(stock): 매매알람 exit_params에 climax 파라미터 중앙화 (climax_vol_x 3.0, climax_close_pct 0.97) 2026-07-03 10:37:57 +09:00
c4b2fffeb4 docs(CLAUDE.md): 실시간 매매 알람 엔드포인트 카탈로그 등재 (stock watchlist/webai + agent-office notify/봇명령) 2026-07-02 20:09:07 +09:00
c6540b2417 feat(agent-office): /watch /unwatch /watchlist 봇 명령 2026-07-02 20:05:59 +09:00
2bce07c367 feat(agent-office): 매매알람 텔레그램 notify(너+아내) 엔드포인트 2026-07-02 20:01:10 +09:00
2906a2ae3e feat(stock): webai report — edge diff→agent-office push→상태/이력(전송성공시만) 2026-07-02 19:56:58 +09:00
134b9e5d07 feat(stock): session 판정 + webai monitor-set 엔드포인트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-02 19:51:57 +09:00
bf84328d59 feat(stock): edge diff(신규/해제/재무장) 순수 함수 2026-07-02 19:48:45 +09:00
d8b3267b98 feat(stock): 감시대상(monitor-set) 조립 로직 2026-07-02 15:51:06 +09:00
89c52b1fb6 feat(stock): watchlist CRUD + 알람 이력 API 2026-07-02 15:45:14 +09:00
01a8aee226 fix(stock): 매매알람 이력 days 필터 포맷을 ISO로 통일 (경계일 과다포함 수정, 리뷰 Important) 2026-07-02 15:43:22 +09:00
b2c4ca0e0b feat(stock): 매매알람 DB — watchlist/alert_state/history 테이블+헬퍼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-02 15:34:53 +09:00
baa3a3075d docs(stock): 실시간 매매 알람 BE 구현 계획 (9 tasks, TDD)
watchlist/alert_state/history DB → CRUD API → monitor-set 조립 → edge diff →
webai monitor-set/report → agent-office 텔레그램(너+아내) → /watch 봇 명령 → 회귀/배포.
워커(web-ai)·탭(web-ui)은 계약(스펙 §5)만 정의해 각 세션 핸드오프.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-02 15:25:26 +09:00
4cb9dc6a7c docs(stock): 실시간 매매 알람 설계 스펙 (watchlist∪screener buy + exit+trailing sell, 1분 Windows 워커, NAS edge dedup)
브레인스토밍 확정 요구사항 6종 + 아키텍처 A(신규 Windows docker 워커). TA/조건판정은
Windows, edge 중복판정 상태는 NAS 영속(재시작 스팸 방지). cross-repo 계약(webai
monitor-set/report, agent-office notify, watchlist CRUD, heartbeat) 정의.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-02 15:19:00 +09:00
21 changed files with 2402 additions and 5 deletions

View File

@@ -246,6 +246,10 @@ docker compose up -d
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 (`days`) | | GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 (`days`) |
| GET/POST | `/api/portfolio/sell-history` | 매도 내역 조회/저장 | | GET/POST | `/api/portfolio/sell-history` | 매도 내역 조회/저장 |
| PUT/DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 수정/삭제 | | PUT/DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 수정/삭제 |
| GET/POST/DELETE | `/api/stock/watchlist` (+ `/{ticker}`) | 실시간 매수 알람 관심종목 CRUD |
| GET | `/api/stock/trade-alerts` | 매매 알람 이력 (`days`) |
| GET | `/api/webai/trade-alert/monitor-set` | (워커) 감시대상 조립 = watchlistscreener보유 + session/params (X-WebAI-Key) |
| POST | `/api/webai/trade-alert/report` | (워커) 발화집합 수신 → edge diff → 신규만 텔레그램 push (X-WebAI-Key) |
### music-lab (music-lab/) ### music-lab (music-lab/)
듀얼 프로바이더 음악 생성(Suno + MusicGen) + YouTube 영상 자동화 파이프라인 + 시장 트렌드. 듀얼 프로바이더 음악 생성(Suno + MusicGen) + YouTube 영상 자동화 파이프라인 + 시장 트렌드.
@@ -369,6 +373,7 @@ AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출
| POST/GET | `/api/agent-office/youtube/research` (+ `/status`) | YouTube 트렌드 수집 트리거/상태 | | POST/GET | `/api/agent-office/youtube/research` (+ `/status`) | YouTube 트렌드 수집 트리거/상태 |
| GET | `/api/agent-office/lotto/signals`, `/lotto/baselines` | 로또 시그널 이력·baseline | | GET | `/api/agent-office/lotto/signals`, `/lotto/baselines` | 로또 시그널 이력·baseline |
| POST | `/api/agent-office/lotto/signal-check` | 로또 시그널 평가 트리거 (light/sim/deep) | | POST | `/api/agent-office/lotto/signal-check` | 로또 시그널 평가 트리거 (light/sim/deep) |
| POST | `/api/agent-office/stock/trade-alert` | stock에서 push된 매매 알람 → 텔레그램(너+아내). 봇 명령 `/watch`·`/unwatch`·`/watchlist`로 watchlist 관리 |
### tarot-lab (tarot-lab/) ### tarot-lab (tarot-lab/)
타로 카드 해석 (Claude Sonnet, agent-office에서 2026-05-25 독립). 타로 카드 해석 (Claude Sonnet, agent-office에서 2026-05-25 독립).
@@ -487,6 +492,7 @@ Gitea Webhook 수신 → 자동 배포. HMAC SHA256 검증(`X-Gitea-Signature`
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준) - **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
- **Windows AI 서버 IP**: `192.168.45.59` (DHCP 고정 예약). Tailscale은 Synology userspace 모드라 TCP 불가 → 로컬 IP 사용 - **Windows AI 서버 IP**: `192.168.45.59` (DHCP 고정 예약). Tailscale은 Synology userspace 모드라 TCP 불가 → 로컬 IP 사용
- **렌더/생성 워커 분리**: music/video/image/insta 무거운 작업은 Windows `web-ai` 워커. NAS 코드의 `*_provider.py`/`card_renderer.py`가 DEPRECATED stub면 실 로직은 web-ai 쪽이 authoritative - **렌더/생성 워커 분리**: music/video/image/insta 무거운 작업은 Windows `web-ai` 워커. NAS 코드의 `*_provider.py`/`card_renderer.py`가 DEPRECATED stub면 실 로직은 web-ai 쪽이 authoritative
- **[팀 규칙] 모든 WSL(docker) 워커는 `/infra`에서 관측 가능해야 한다**: 새 워커 추가 시 필수 3단계 — ① 워커가 `worker:<name>:heartbeat`(EX45, ~15초) 발신 ② BE가 `agent-office/app/node_monitor.py``WORKER_REGISTRY``{name,kind,queue}` 등재 ③ → `/api/agent-office/nodes`·web-ui `/infra` 노출 + 다운/복구/dead-letter 텔레그램 경보. 미준수 = "사일런트 사망"(insta-render 2주 무관측 사고) 재발 위험. 워커 신규/변경 PR 머지 게이트. web-ai/web-ui repo CLAUDE.md에도 동일 규칙 명시 필요. 상세는 `infra_distributed_workers.md` 메모리(관측 계약 2)
- **Playwright Dockerfile**: bookworm 고정 + 수동 chromium deps, `--with-deps` 금지 (`feedback_playwright_dockerfile.md`) - **Playwright Dockerfile**: bookworm 고정 + 수동 chromium deps, `--with-deps` 금지 (`feedback_playwright_dockerfile.md`)
- **lab 네이밍**: `-lab`은 개발/연구 단계에만, 정식 서비스엔 미사용 (`feedback_lab_naming.md`) - **lab 네이밍**: `-lab`은 개발/연구 단계에만, 정식 서비스엔 미사용 (`feedback_lab_naming.md`)

View File

@@ -115,6 +115,7 @@ curl http://localhost:18500/health
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문) - **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회 - **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신) - **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
- **실시간 매매 알람** (2026-07-02): 장중(+시간외) 1분 폴링으로 매수(watchlist 스크리너 후보, TA 시그널)·매도(보유종목, exit 룰 + 트레일링 스톱) 조건 충족 시 텔레그램(본인+아내) 알람. **TA 계산은 Windows `trade-monitor` WSL2 docker 워커**, NAS는 감시대상 조립 + edge 중복판정(영속) + 발송 담당. 관심종목은 `/api/stock/watchlist` CRUD 또는 텔레그램 `/watch` 봇 명령. webai 계약: `GET /api/webai/trade-alert/monitor-set` · `POST /report`. 워커/프론트 탭은 web-ai/web-ui repo (설계: `docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md`)
**LLM provider 전환**`LLM_PROVIDER` 환경변수 **LLM provider 전환**`LLM_PROVIDER` 환경변수
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`) - `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
@@ -169,6 +170,8 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인 - **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달 - 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
- Webhook 검증 후 `chat.id` 기준 라우팅 - Webhook 검증 후 `chat.id` 기준 라우팅
- **실시간 매매 알람 수신**: `POST /api/agent-office/stock/trade-alert` (stock이 edge 판정한 알람 push) → 텔레그램 본인+아내 발송. 봇 명령 `/watch`·`/unwatch`·`/watchlist`로 관심종목 관리
- **분산 워커 관측**: `GET /api/agent-office/nodes``worker:<name>:heartbeat`를 집계 → web-ui `/infra` 시각화 + 다운/복구/dead-letter 텔레그램 경보. WSL docker 워커는 `node_monitor.WORKER_REGISTRY` 등재 필수(위 주의사항 팀 규칙)
#### 에이전트 구성 #### 에이전트 구성
@@ -283,11 +286,11 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
| DB | 소유 서비스 | 주요 테이블 | | DB | 소유 서비스 | 주요 테이블 |
|----|------------|-----------| |----|------------|-----------|
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings | | `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history | | `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history, holdings_signals, news_sentiment, **watchlist, trade_alert_state, trade_alert_history** (실시간 매매 알람) |
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls), video_projects, revenue_records, market_trends, trend_reports | | `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls), video_projects, revenue_records, market_trends, trend_reports |
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences | | `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log | | `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages | | `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages, youtube_research_jobs, lotto_signals/baselines, notified_failed_pipelines (파이프라인 실패 알림 dedup) |
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts | | `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers | | `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at | | `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
@@ -384,6 +387,52 @@ PORTFOLIO_EDIT_PASSWORD=
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수 - **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
- **LLM provider 롤백** — Claude API 장애 시 `.env``LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d` - **LLM provider 롤백** — Claude API 장애 시 `.env``LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
- **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력) - **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력)
- **[팀 규칙] 모든 WSL docker 워커는 `/infra` 관측 필수** — 새 워커는 ① `worker:<name>:heartbeat`(EX45) 발신 ② BE가 `agent-office/app/node_monitor.py``WORKER_REGISTRY`에 등재 ③ → `/api/agent-office/nodes`·web-ui `/infra` 노출 + 다운/복구/dead-letter 경보. 미준수 = 사일런트 사망 재발(insta-render 2주 사고). 워커 PR 머지 게이트
- **Alpine + tzdata 함정** — stock 컨테이너는 `python:3.12-alpine` + tzdata 미설치라 `TZ=Asia/Seoul`이 무효 → `date.today()`가 UTC. KST 날짜는 `_today_kst()`(=`utcnow()+9h`) 명시 변환 필수 (아침 스케줄 리포트 하루 밀림 방지)
---
## 하네스 엔지니어링 (Claude Code 제어)
이 레포는 Claude Code 세션의 동작을 `.claude/` 설정으로 **제어(harness engineering)** 한다. 모든 산출물은 git 추적되어 이 체크아웃의 모든 세션(co-gahusb 팀버스의 BE 역할 포함)에 공유된다.
### 제어 표면 (무엇을 통제하는가)
| 레이어 | 메커니즘 | 위치 | 역할 |
|--------|---------|------|------|
| 컨텍스트 주입 | CLAUDE.md 계층 + 서비스 메모리 | `CLAUDE.md`, `memory/service_*.md` | 항상 로딩되는 카탈로그(불변) ↔ 관련 시 recall(가변) 2계층 |
| 권한 가드 | permissions allow/deny/ask | `.claude/settings.json` | 읽기전용 명령 무프롬프트 / 시크릿·DB 차단 / push·reset 확인 |
| 행동 강제 | PreToolUse·PostToolUse·SessionStart hook | `.claude/hooks/` | CLAUDE.md 주석 규칙을 하네스가 실제 차단·환기 |
| 반복 워크플로우 | slash commands | `.claude/commands/` | `/co-inbox`, `/svc`, `/harness-audit` |
| 전문 역할 | subagents | `.claude/agents/` | `be-developer`, `evaluator` |
| 협업 버스 | MCP 서버 | `.mcp.json` | co-gahusb 팀버스(세션 간 메시지·작업·락) |
### 적용된 가드 (hook)
| hook | 이벤트 / matcher | 동작 | 근거 |
|------|-----------------|------|------|
| `pretooluse-guard.sh` | PreToolUse · `Bash\|PowerShell` | **차단** 로컬 docker 변경(`up/down/build/restart/exec…`; ps·logs·config·images는 허용) | `feedback_docker_nas` |
| 〃 | 〃 | **차단** `git commit --amend` · `git push --force`(`--force-with-lease`는 허용) | `feedback_concurrent_session_git_collision` |
| 〃 | 〃 | **차단** PowerShell `>`/`>>` 파일 리다이렉트(UTF-16 BOM; `2>$null`·`> $null`은 허용) | `feedback_powershell_redirect_encoding` |
| `posttooluse-memory.sh` | PostToolUse · `Edit\|Write` | 서비스 `db.py`/`models.py`/스케줄러/`.sql` 편집 시 `service_<name>.md` 갱신 환기(비차단) | 메모리 디스플린 |
| `session-start.sh` | SessionStart · `startup\|resume` | BE 역할 + 수신함/락 넛지 주입 | 협업 버스 프로토콜 |
차단 판단 로직은 `.claude/hooks/_guard.py`(Python). 래퍼는 파서 부재 시 **fail-open**(통과)하고, 출력은 UTF-8로 고정한다.
### slash commands
| 커맨드 | 용도 |
|--------|------|
| `/co-inbox` | co-gahusb 팀버스 BE 수신함(inbox + tasks + locks) 일괄 확인 |
| `/svc <name>` | 해당 `service_<name>.md` 메모리 + 핵심 파일 위치를 즉시 로드 |
| `/harness-audit` | 서브에이전트 fan-out으로 CLAUDE.md 카탈로그 ↔ 실제 코드 드리프트 감사 |
### 확장 / 유지보수
- **hook 경로는 이 머신 기준 절대경로**(`/c/Users/jaeoh/Desktop/workspace/web-backend/.claude/hooks/…`)다. 레포를 다른 경로로 클론하면 `settings.json`의 3개 hook command 경로를 갱신해야 한다.
- 가드 패턴 추가/수정은 `_guard.py`만 고치면 된다(설정 변경 불필요).
- hook은 새 세션에서 자동 로드된다. 진행 중 세션에 즉시 반영하려면 `/hooks` 메뉴를 열거나 재시작한다.
- 메모리 디스플린: 코드 구조가 바뀌면 **CLAUDE.md(불변 카탈로그)** 가 아니라 **`service_*.md`(가변 상세)** 를 갱신한다.
--- ---
@@ -391,3 +440,4 @@ PORTFOLIO_EDIT_PASSWORD=
- `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등) - `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등)
- `docs/` — 서비스별 기획·설계 문서 - `docs/` — 서비스별 기획·설계 문서
- `.claude/` — 하네스 설정(settings·hooks·commands·agents). 위 "하네스 엔지니어링" 섹션 참조

View File

@@ -278,3 +278,19 @@ async def trigger_signal_check(source: str = "light"):
if not agent: if not agent:
raise HTTPException(status_code=503, detail="lotto agent not registered") raise HTTPException(status_code=503, detail="lotto agent not registered")
return await agent.run_signal_check(source=source) return await agent.run_signal_check(source=source)
# --- Trade Alert Notify Endpoint ---
class TradeAlertBody(BaseModel):
alerts: List[Dict[str, Any]] = []
@app.post("/api/agent-office/stock/trade-alert")
async def stock_trade_alert(body: TradeAlertBody):
from .notifiers.telegram_trade import send_trade_alerts
from .db import add_log
res = await send_trade_alerts(body.alerts)
for a in body.alerts:
add_log("stock", f"매매알람 {a.get('kind')} {a.get('ticker')} {a.get('condition')}", "info")
return res

View File

@@ -14,8 +14,9 @@ WORKER_REGISTRY = [
{"name": "video-render", "kind": "render", "queue": "queue:video-render"}, {"name": "video-render", "kind": "render", "queue": "queue:video-render"},
{"name": "image-render", "kind": "render", "queue": "queue:image-render"}, {"name": "image-render", "kind": "render", "queue": "queue:image-render"},
{"name": "insta-render", "kind": "render", "queue": "queue:insta-render"}, {"name": "insta-render", "kind": "render", "queue": "queue:insta-render"},
{"name": "task-watcher", "kind": "watcher", "queue": None}, {"name": "task-watcher", "kind": "watcher", "queue": None},
{"name": "ai_trade", "kind": "trader", "queue": None}, {"name": "ai_trade", "kind": "trader", "queue": None},
{"name": "trade-monitor", "kind": "trader", "queue": None},
] ]
_redis = None _redis = None
@@ -92,7 +93,7 @@ async def collect_status(redis=None) -> dict:
for w in out["workers"]: for w in out["workers"]:
if w["kind"] == "trader": if w["kind"] == "trader":
out["links"].append({"from": "ai_trade", "to": "nas-stock", "type": "http-pull", out["links"].append({"from": w["name"], "to": "nas-stock", "type": "http-pull",
"status": "healthy" if w["alive"] else "down"}) "status": "healthy" if w["alive"] else "down"})
elif w["kind"] == "render": elif w["kind"] == "render":
out["links"].append({"from": "nas", "to": w["name"], "type": "redis-queue", out["links"].append({"from": "nas", "to": w["name"], "type": "redis-queue",

View File

@@ -0,0 +1,61 @@
"""매매 알람 텔레그램 포맷+전송 (본인+아내 각각)."""
import logging
from typing import Any, Dict, List
from ..telegram.messaging import send_raw
from ..config import TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID
logger = logging.getLogger("agent-office")
_KIND_LABEL = {"buy": "🟢 매수", "sell": "🔴 매도"}
_COND_LABEL = {
"buy_ma20_pullback": "지지선 되돌림", "buy_breakout": "돌파", "buy_rsi_bounce": "RSI 과매도 반등",
"sell_stop_loss": "손절", "sell_ma_break": "이평 이탈", "sell_take_profit": "익절",
"sell_climax": "급등 소진", "sell_trailing_stop": "트레일링 스톱",
}
# 조건별 "왜 이 시점에 매수/매도인가" 한 줄 근거
_COND_REASON = {
"buy_ma20_pullback": "상승추세 중 MA20 지지선 눌림목 반등 — 저가 진입 기회",
"buy_breakout": "전고점·저항 돌파 + 거래량 증가 — 추세 상승 진입 신호",
"buy_rsi_bounce": "RSI 과매도(30↓)에서 반등 — 단기 낙폭과대 되돌림",
"sell_stop_loss": "평단 대비 손절선 도달 — 추가 하락 리스크 차단",
"sell_ma_break": "주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토",
"sell_take_profit": "목표 수익 도달 — 이익 실현 구간",
"sell_climax": "거래량 급증 + 윗꼬리(고점 대비 하락 마감) — 분산·소진 의심",
"sell_trailing_stop":"보유기간 고점 대비 하락 — 수익 반납 방어(트레일링 스톱)",
}
def format_trade_alert(a: Dict[str, Any]) -> str:
kind = _KIND_LABEL.get(a["kind"], a["kind"])
cond = _COND_LABEL.get(a["condition"], a["condition"])
reason = _COND_REASON.get(a["condition"], "")
name = a.get("name") or a["ticker"]
price = a.get("price")
price_s = f"{int(price):,}" if price else "-"
lines = [f"{kind} 알람", f"<b>{name}</b> ({a['ticker']})", f"조건: {cond}"]
if reason:
lines.append(f"💡 {reason}")
lines.append(f"현재가: {price_s}")
return "\n".join(lines)
async def send_trade_alerts(alerts: List[Dict[str, Any]]) -> dict:
"""알람마다 본인+아내 chat_id 각각으로 send_raw. 실패해도 계속 진행."""
sent = 0
all_ok = True
chat_ids = [c for c in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if c]
for a in alerts:
text = format_trade_alert(a)
for cid in chat_ids:
try:
r = await send_raw(text, chat_id=cid)
except Exception as e:
logger.warning(f"[telegram_trade] send failed (chat_id={cid}): {e}")
all_ok = False
continue
if r.get("ok"):
sent += 1
else:
all_ok = False
return {"sent": sent, "ok": all_ok}

View File

@@ -111,6 +111,29 @@ async def stock_holdings_brief() -> Dict[str, Any]:
return resp.json() return resp.json()
# --- stock watchlist (실시간 매매 알람) ---
async def watchlist_add(ticker: str) -> Dict[str, Any]:
"""stock의 관심종목 추가 (POST, 이미 존재하면 멱등하게 갱신)."""
resp = await _client.post(f"{STOCK_URL}/api/stock/watchlist", json={"ticker": ticker})
resp.raise_for_status()
return resp.json()
async def watchlist_remove(ticker: str) -> Dict[str, Any]:
"""stock의 관심종목 삭제."""
resp = await _client.delete(f"{STOCK_URL}/api/stock/watchlist/{ticker}")
resp.raise_for_status()
return resp.json()
async def watchlist_list() -> Dict[str, Any]:
"""stock의 관심종목 목록 조회 → {"watchlist": [...]}."""
resp = await _client.get(f"{STOCK_URL}/api/stock/watchlist")
resp.raise_for_status()
return resp.json()
async def generate_music(payload: dict) -> Dict[str, Any]: async def generate_music(payload: dict) -> Dict[str, Any]:
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload) resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
resp.raise_for_status() resp.raise_for_status()

View File

@@ -1,6 +1,7 @@
"""텔레그램 Webhook 이벤트 처리.""" """텔레그램 Webhook 이벤트 처리."""
from typing import Optional from typing import Optional
from .. import service_proxy
from ..db import get_telegram_callback, mark_telegram_responded from ..db import get_telegram_callback, mark_telegram_responded
from .client import _enabled, api_call from .client import _enabled, api_call
@@ -23,12 +24,43 @@ async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
if message: if message:
chat = message.get("chat", {}) chat = message.get("chat", {})
print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True) print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True)
if message and message.get("text"):
if await handle_watch_command(message):
return None
if message and message.get("text") and agent_dispatcher is not None: if message and message.get("text") and agent_dispatcher is not None:
return await _handle_message(message, agent_dispatcher) return await _handle_message(message, agent_dispatcher)
return None return None
async def handle_watch_command(message: dict) -> bool:
"""/watch /unwatch /watchlist 명령을 처리해 stock watchlist API로 프록시.
처리했으면(응답 전송 포함) True, 매칭되지 않는 텍스트면 False."""
text = (message.get("text") or "").strip()
chat_id = message.get("chat", {}).get("id")
parts = text.split()
cmd = parts[0].lower() if parts else ""
if cmd == "/watch" and len(parts) >= 2:
await service_proxy.watchlist_add(parts[1])
reply = f"관심종목 추가: {parts[1]}"
elif cmd == "/unwatch" and len(parts) >= 2:
await service_proxy.watchlist_remove(parts[1])
reply = f"관심종목 삭제: {parts[1]}"
elif cmd == "/watchlist":
res = await service_proxy.watchlist_list()
items = res.get("watchlist", [])
reply = "관심종목:\n" + (
"\n".join(f"- {w.get('name') or ''} ({w['ticker']})" for w in items) or "(없음)"
)
else:
return False
await api_call("sendMessage", {"chat_id": chat_id, "text": reply})
return True
async def _handle_callback(callback_query: dict) -> Optional[dict]: async def _handle_callback(callback_query: dict) -> Optional[dict]:
"""승인/거절 및 realestate 북마크 콜백 처리.""" """승인/거절 및 realestate 북마크 콜백 처리."""
callback_id = callback_query.get("data", "") callback_id = callback_query.get("data", "")

View File

@@ -68,6 +68,18 @@ async def test_trader_http_pull_link():
link = next(l for l in st["links"] if l["from"] == "ai_trade") link = next(l for l in st["links"] if l["from"] == "ai_trade")
assert link["type"] == "http-pull" and link["status"] == "healthy" assert link["type"] == "http-pull" and link["status"] == "healthy"
@pytest.mark.asyncio
async def test_trade_monitor_registered_and_own_link():
"""WSL 워커 trade-monitor가 registry에 있어 /nodes에 노출되고, 링크 from은
ai_trade 하드코딩이 아니라 자기 이름(trade-monitor)이어야 한다 (다중 trader 구분)."""
r = FakeRedis(kv={"worker:trade-monitor:heartbeat": _hb("trade-monitor", "trader", "market_open")})
st = await node_monitor.collect_status(redis=r)
tm = next(w for w in st["workers"] if w["name"] == "trade-monitor")
assert tm["alive"] is True and tm["kind"] == "trader"
link = next(l for l in st["links"] if l["from"] == "trade-monitor")
assert link["type"] == "http-pull" and link["to"] == "nas-stock" and link["status"] == "healthy"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_paused_no_watcher_heartbeat_fallback_reason(): async def test_paused_no_watcher_heartbeat_fallback_reason():
"""paused=True인데 watcher heartbeat 없으면 paused_reason == 'trading' 폴백.""" """paused=True인데 watcher heartbeat 없으면 paused_reason == 'trading' 폴백."""

View File

@@ -0,0 +1,67 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from unittest.mock import AsyncMock, patch
@pytest.fixture(autouse=True)
def _init_db(monkeypatch):
import gc
gc.collect()
# config.DB_PATH는 첫 import 시 1회 고정되므로, 다른 테스트 파일과 조합 실행 시
# db가 이 파일의 _TMP가 아닌 다른 경로를 쓸 수 있다. db.DB_PATH를 이 파일 전용으로
# 강제해 영속 테이블의 테스트 간 누수를 결정적으로 차단.
import app.db as _db
monkeypatch.setattr(_db, "DB_PATH", _TMP)
# WAL 사이드카(-wal/-shm)까지 지워야 영속 상태가 남지 않음
for suffix in ("", "-wal", "-shm"):
p = _TMP + suffix
if os.path.exists(p):
os.remove(p)
_db.init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_send_trade_alerts_to_user_and_wife():
from app.notifiers import telegram_trade
alerts = [{"ticker": "005930", "name": "삼성전자", "kind": "buy",
"condition": "buy_breakout", "price": 71500, "detail": {}}]
with patch("app.notifiers.telegram_trade.send_raw",
new=AsyncMock(return_value={"ok": True})) as m, \
patch("app.notifiers.telegram_trade.TELEGRAM_CHAT_ID", "U"), \
patch("app.notifiers.telegram_trade.TELEGRAM_WIFE_CHAT_ID", "W"):
res = await telegram_trade.send_trade_alerts(alerts)
assert res["ok"] is True
chat_ids = {c.kwargs.get("chat_id") for c in m.await_args_list}
assert chat_ids == {"U", "W"} # 둘 다 발송
@pytest.mark.asyncio
async def test_format_trade_alert_has_direction():
from app.notifiers.telegram_trade import format_trade_alert
txt = format_trade_alert({"ticker": "005930", "name": "삼성전자", "kind": "sell",
"condition": "sell_stop_loss", "price": 60000, "detail": {}})
assert "매도" in txt and "삼성전자" in txt
def test_format_trade_alert_includes_reason_line():
"""조건별 '왜 매수/매도해야 하는지' 한 줄 이유(💡)가 메시지에 포함된다."""
from app.notifiers.telegram_trade import format_trade_alert
for cond in ("buy_breakout", "sell_stop_loss", "sell_trailing_stop"):
txt = format_trade_alert({"ticker": "005930", "name": "삼성전자", "kind": cond.split("_")[0],
"condition": cond, "price": 60000, "detail": {}})
assert "💡" in txt, f"{cond}: 이유 한 줄 누락"
# 이유 라인이 조건 라벨을 그대로 반복하지 않고 실제 설명을 담아야 함
reason_line = next(l for l in txt.split("\n") if l.startswith("💡"))
assert len(reason_line) > 6

View File

@@ -0,0 +1,93 @@
import os
import sys
import tempfile
_fd, _TMP = tempfile.mkstemp(suffix=".db")
os.close(_fd)
os.unlink(_TMP)
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from unittest.mock import AsyncMock, patch
@pytest.fixture(autouse=True)
def _init_db(monkeypatch):
import gc
gc.collect()
# config.DB_PATH는 첫 import 시 1회 고정되므로, 다른 테스트 파일과 조합 실행 시
# db가 이 파일의 _TMP가 아닌 다른 경로를 쓸 수 있다. db.DB_PATH를 이 파일 전용으로
# 강제해 영속 테이블의 테스트 간 누수를 결정적으로 차단.
import app.db as _db
monkeypatch.setattr(_db, "DB_PATH", _TMP)
for suffix in ("", "-wal", "-shm"):
p = _TMP + suffix
if os.path.exists(p):
os.remove(p)
_db.init_db()
yield
gc.collect()
@pytest.mark.asyncio
async def test_watch_command_calls_add():
from app.telegram import webhook
msg = {"chat": {"id": 1}, "text": "/watch 005930"}
with patch("app.telegram.webhook.service_proxy.watchlist_add",
new=AsyncMock(return_value={"ok": True})) as m, \
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})):
handled = await webhook.handle_watch_command(msg)
assert handled is True
m.assert_awaited_once_with("005930")
@pytest.mark.asyncio
async def test_non_watch_text_ignored():
from app.telegram import webhook
msg = {"chat": {"id": 1}, "text": "안녕"}
assert await webhook.handle_watch_command(msg) is False
@pytest.mark.asyncio
async def test_unwatch_command_calls_remove():
from app.telegram import webhook
msg = {"chat": {"id": 1}, "text": "/unwatch 005930"}
with patch("app.telegram.webhook.service_proxy.watchlist_remove",
new=AsyncMock(return_value={"ok": True})) as m, \
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})) as sent:
handled = await webhook.handle_watch_command(msg)
assert handled is True
m.assert_awaited_once_with("005930")
sent.assert_awaited_once()
@pytest.mark.asyncio
async def test_watchlist_command_calls_list_and_formats_items():
from app.telegram import webhook
msg = {"chat": {"id": 1}, "text": "/watchlist"}
items = {"watchlist": [{"ticker": "005930", "name": "삼성전자"}]}
with patch("app.telegram.webhook.service_proxy.watchlist_list",
new=AsyncMock(return_value=items)) as m, \
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})) as sent:
handled = await webhook.handle_watch_command(msg)
assert handled is True
m.assert_awaited_once_with()
text = sent.await_args.args[1]["text"]
assert "005930" in text and "삼성전자" in text
@pytest.mark.asyncio
async def test_watch_command_reaches_handle_webhook_before_slash_dispatch():
"""handle_webhook이 /watch 를 agent_dispatcher 호출 전에 가로채야 한다."""
from app.telegram import webhook
data = {"message": {"chat": {"id": 1}, "text": "/watch 005930"}}
dispatcher = AsyncMock(side_effect=AssertionError("agent_dispatcher가 호출되면 안 됨"))
with patch("app.telegram.webhook.service_proxy.watchlist_add",
new=AsyncMock(return_value={"ok": True})) as m, \
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})):
result = await webhook.handle_webhook(data, agent_dispatcher=dispatcher)
assert result is None
m.assert_awaited_once_with("005930")
dispatcher.assert_not_awaited()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
# 실시간 매매 알람 (Real-time Trade Alerts) — 설계 스펙
- 작성일: 2026-07-02
- 상태: 설계 승인됨 (사용자 리뷰 대기)
- 관련 세션: BE(web-backend, 본 스펙 주도) · AI(web-ai 워커) · FE(web-ui 탭)
## 1. 목표
장이 열려 있는 동안(**시간외 포함**) 실시간으로 주가 기준치를 분석해, 조건 충족 시 **매수/매도 알람**을 텔레그램으로 **사용자 + 아내** 둘 다에게 전송한다. 기술적 분석(TA) 계산은 **Windows PC의 docker 워커**에서 수행한다.
기존에는 이 판단들이 EOD(하루 1회)로만 돌았다:
- 매수 후보 = 스크리너(평일 16:30) · 매도/보유 advisory = holdings_intel(08:30/16:50).
이번 작업의 핵심 = **동일 판단을 장중(+시간외) 1분 주기 실시간으로 전환 + 조건 충족 즉시 알람**.
## 2. 확정된 요구사항 (사용자 결정)
| 항목 | 결정 |
|------|------|
| 매수 유니버스 | **watchlist(사용자 관리) 당일 스크리너 후보** |
| 매수 트리거 | **TA 자동 시그널**(수동 목표가 없음) |
| 매도 트리거 | **기존 exit 룰 + 트레일링 스톱** |
| 감시 주기/세션 | **1분 폴링** · 장전 시간외 08:3009:00 · 정규장 09:0015:30 · 시간외 단일가 16:0018:00 |
| 중복 방지 | **상태 전이(edge-triggered)** — 거짓→참 전이 시만 알림, 참 유지 중 무알림, 재무장 |
| watchlist 관리 | **텔레그램 봇 명령 + web-ui 탭 둘 다** |
| 수신자 | **사용자 + 아내 둘 다**(매수·매도 모두) |
| TA 연산 위치 | **Windows WSL2 docker 신규 워커** |
| 트레일링 스톱 기본값 | 보유기간 고점 대비 **10%**(파라미터화) |
| 매수 신호 | 지지선 되돌림(MA20/50) · 돌파(전고점/52주) · RSI 과매도 반등 |
## 3. 아키텍처
```
[Windows WSL2 docker] trade-monitor 워커 (web-ai · AI세션)
1분 루프 (KST 세션 게이팅)
① GET NAS /api/webai/trade-alert/monitor-set (X-WebAI-Key)
② KIS 실시간/시간외 시세 + 분봉/일봉 → TA 계산
③ 조건 평가 → 현재 발화집합 F = {(ticker, kind, condition)}
④ POST NAS /api/webai/trade-alert/report {firing: F} (X-WebAI-Key)
⑤ heartbeat: worker:trade-monitor:heartbeat (EX45, 관측 편입)
[NAS] stock (:18500 · web-backend · BE)
• watchlist·alert_state(edge dedup, 영속)·alert_history·holding high-water
• monitor-set 조립(watchlist screener 후보 보유) + 세션/휴장 게이팅
• report 수신 → edge diff(F vs 직전 발화) → 신규 edge를 agent-office로 push
│ (텔레그램 전송 성공 시에만 alert_state 갱신)
[NAS] agent-office (:18900 · web-backend · BE)
• POST /api/agent-office/stock/trade-alert → 텔레그램(너+아내)
• 봇 명령 /watch /unwatch /watchlist → stock watchlist CRUD
• 알람 activity feed 편입
[web-ui] 관심종목 탭 (FE세션) — watchlist CRUD + 알람 이력 뷰
```
**설계 원칙**
- TA/조건판정 = Windows(요구사항). **edge 중복판정 상태 = NAS 영속** → 워커 재시작해도 재알림 스팸 없음(youtube_publisher 교훈 재적용).
- 워커는 dedup 상태를 **안 가진다**. 매 사이클 "현재 발화집합 전체"만 보고 → NAS가 diff(단일 진실원천).
- 워커의 대외 채널은 **NAS stock 한 곳**(기존 ai_trade↔stock의 `X-WebAI-Key` 재사용). 텔레그램 발송은 stock→agent-office push(기존 realestate→agent-office/notify 패턴).
## 4. DB 스키마 (stock.db)
```sql
-- 매수 감시 관심종목 (사용자 관리)
CREATE TABLE IF NOT EXISTS watchlist (
ticker TEXT PRIMARY KEY,
name TEXT,
note TEXT,
params_json TEXT NOT NULL DEFAULT '{}', -- 종목별 조건 오버라이드(선택)
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- edge 중복판정 상태 (영속 — 재시작 스팸 방지의 핵심)
CREATE TABLE IF NOT EXISTS trade_alert_state (
ticker TEXT NOT NULL,
kind TEXT NOT NULL, -- 'buy' | 'sell'
condition TEXT NOT NULL, -- ex) buy_ma20_pullback, sell_trailing_stop
currently_firing INTEGER NOT NULL DEFAULT 0,
first_fired_at TEXT,
last_fired_at TEXT,
last_seen_at TEXT,
PRIMARY KEY (ticker, kind, condition)
);
-- 알람 이력
CREATE TABLE IF NOT EXISTS trade_alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL,
name TEXT,
kind TEXT NOT NULL,
condition TEXT NOT NULL,
price REAL,
detail_json TEXT,
fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_tah_fired ON trade_alert_history(fired_at DESC);
```
보유기간 고점(트레일링 스톱용) high-water는 `krx_daily_prices`(기존)에서 lookback max로 계산하거나 별도 컬럼으로 관리 — 구현 계획에서 확정(v1: 포지션 최초 관측 이후 일봉 고가 max, 없으면 최근 N일).
## 5. 계약 (Contracts) — cross-repo 잠금 대상
### 5.1 NAS stock ↔ Windows 워커 (X-WebAI-Key)
`GET /api/webai/trade-alert/monitor-set`
```json
{
"session": "pre | regular | after | closed",
"as_of": "2026-07-02T09:01:00+09:00",
"buy_targets": [{"ticker":"005930","name":"삼성전자","source":"watch|screener","params":{}}],
"sell_targets": [{"ticker":"000660","name":"SK하이닉스","avg_price":180000,"qty":10,
"holding_high":210000,"params":{}}],
"buy_params": {"rsi_oversold":30,"breakout_vol_mult":1.5,"pullback_pct":0.02},
"exit_params": {"stop_pct":0.08,"take_pct":0.25,"trailing_pct":0.10}
}
```
- `session=closed`면 워커는 KIS 호출 없이 sleep.
`POST /api/webai/trade-alert/report`
```json
{ "as_of":"2026-07-02T09:01:00+09:00",
"firing":[ {"ticker":"005930","kind":"buy","condition":"buy_ma20_pullback",
"price":71500,"detail":{"ma20":71200,"rsi":34}} ] }
```
응답: `{ "new_alerts": <int>, "cleared": <int> }`
- NAS가 `firing` vs `trade_alert_state[firing=1]` diff → 신규 edge만 텔레그램.
### 5.2 stock → agent-office (내부)
`POST /api/agent-office/stock/trade-alert`
```json
{ "alerts":[ {"ticker":"005930","name":"삼성전자","kind":"buy",
"condition":"buy_ma20_pullback","price":71500,
"detail":{...},"fired_at":"..."} ] }
```
→ agent-office가 너+아내에게 텔레그램. (realestate/notify 패턴)
### 5.3 stock watchlist CRUD (web-ui + agent-office 봇)
- `GET /api/stock/watchlist`
- `POST /api/stock/watchlist` `{ticker, note?}`
- `DELETE /api/stock/watchlist/{ticker}`
- `GET /api/stock/trade-alerts?days=N` (이력, web-ui용)
### 5.4 워커 heartbeat (관측 편입)
`worker:trade-monitor:heartbeat` EX45, 값 JSON `{name:"trade-monitor",kind:"trader",state:"market_open|market_closed|idle",ts,last_alert_at,...}`. `/api/agent-office/nodes` workers[]에 추가.
## 6. 알람 조건 (Windows 워커가 계산)
**매수** (buy_targets):
- `buy_ma20_pullback` — MA20>MA50>MA200 정렬 + 저가가 MA20/50에 `pullback_pct` 이내 접근 후 종가 반등
- `buy_breakout` — 종가 > (전 N일 고점 또는 52주 신고가) + 거래량 > `breakout_vol_mult`×20일평균
- `buy_rsi_bounce` — RSI(14)가 `rsi_oversold` 아래로 내려갔다가 **봉 시리즈 내에서** 다시 상향 돌파(최근 봉에서 30 상향 크로스). 워커는 무상태 — 매 사이클 봉 데이터로 크로스를 계산(cross-cycle 메모리 불필요)
**매도** (sell_targets):
- `sell_stop_loss` — (priceavg)/avg ≤ `stop_pct`
- `sell_ma_break` — 종가 < MA50 (심각: < MA200)
- `sell_take_profit` — (priceavg)/avg ≥ `take_pct`
- `sell_climax` — 급등 소진(holdings_intel climax 로직 이식)
- `sell_trailing_stop` — price ≤ holding_high × (1 `trailing_pct`)
## 7. 데이터 흐름 — edge dedup (NAS)
```
매 1분 report 수신 시:
F = report.firing 집합
prev = SELECT (ticker,kind,condition) FROM trade_alert_state WHERE currently_firing=1
new_edge = F prev
cleared = prev F
for e in new_edge:
ok = agent_office.send_trade_alert(e) # 텔레그램
if ok:
INSERT trade_alert_history(e)
UPSERT trade_alert_state(e, firing=1, fired/last=now)
# 실패 시 상태 미갱신 → 다음 사이클 재시도
for c in cleared:
UPDATE trade_alert_state SET firing=0 WHERE key=c # 재무장
UPDATE last_seen_at for all F
```
- 영속 `trade_alert_state` → 워커·NAS 재시작에도 재알림 스팸 없음.
- 텔레그램 실패 시 firing 미표시 → 재시도 보장(node_monitor "성공 시만 갱신" 관용).
## 8. 세션/휴장 게이팅
NAS `monitor-set.session` 필드가 KST 시각 + `holidays.json`(`is_market_open`)으로 판정:
- pre 08:3009:00 / regular 09:0015:30 / after 16:0018:00 → 그 외/휴장 = closed.
- 워커는 `closed`면 sleep. (불필요 KIS 호출·알람 차단)
## 9. 에러 처리
- 워커: KIS 실패 → 해당 사이클 skip + 다음 분 재시도, 종목별 실패 격리. heartbeat로 생사 노출.
- NAS: 워커 인증 `X-WebAI-Key`. 텔레그램 실패 → 상태 미갱신. `report`는 멱등(같은 F 재전송 무해).
- 워커 다운 시 알람 정지 → node_monitor 경보(기존 관측)로 감지.
## 10. 테스트 전략 (BE, TDD)
- watchlist CRUD (추가/중복/삭제/조회)
- monitor-set 조립 (watchlist screener 보유, 세션 게이팅, 휴장)
- **edge diff 로직**: 신규 edge만 알림 / 참 유지 무알림 / 해제 후 재발화 재알림 / 재시작 지속성(영속 상태)
- 텔레그램 전송 실패 시 상태 미갱신(재시도)
- alert_history 기록 / trade-alerts 조회
- agent-office: /watch·/unwatch·/watchlist 봇 명령 → stock CRUD, trade-alert notify → 텔레그램 포맷(너+아내)
- webai 계약 엔드포인트(monitor-set/report) 스키마·인증
## 11. 작업 분담
| repo | 세션 | 산출물 | 상태 |
|------|------|--------|------|
| **web-backend** (stock + agent-office) | **BE(본 세션)** | DB·watchlist·edge·webai 계약·텔레그램·봇 | 이번에 구현 |
| **web-ai** (`services/trade-monitor/` WSL2 docker) | AI세션 | 1분 루프·KIS·TA·조건평가·report·heartbeat | 계약 넘김 |
| **web-ui** (관심종목 탭) | FE세션 | watchlist CRUD·조건·이력 뷰 | 계약 넘김 |
- 계약(§5)은 co-gahusb로 잠근 뒤 3세션 병렬.
- 워커 재빌드는 로컬 docker(사용자): `wsl -d Ubuntu-24.04 -- docker compose up -d --build trade-monitor`.
## 12. 범위 밖 (YAGNI / 후속)
- 실주문 자동 집행(알람 전용, KIS 주문 X).
- KIS 웹소켓 실시간 틱(1분 폴링으로 충분).
- 종목별 수동 목표가(이번은 TA 자동만).
- 백테스트/성과 추적(후속 슬라이스).

View File

@@ -2,6 +2,7 @@ import sqlite3
import os import os
import hashlib import hashlib
import json import json
import datetime as dt
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from app.screener.schema import ensure_screener_schema from app.screener.schema import ensure_screener_schema
@@ -125,6 +126,42 @@ def init_db():
conn.execute("CREATE INDEX IF NOT EXISTS idx_holdings_sig_ticker " conn.execute("CREATE INDEX IF NOT EXISTS idx_holdings_sig_ticker "
"ON holdings_signals(ticker, date DESC);") "ON holdings_signals(ticker, date DESC);")
# 실시간 매매 알람: watchlist / alert_state / alert_history
conn.execute("""
CREATE TABLE IF NOT EXISTS watchlist (
ticker TEXT PRIMARY KEY,
name TEXT,
note TEXT,
params_json TEXT NOT NULL DEFAULT '{}',
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS trade_alert_state (
ticker TEXT NOT NULL,
kind TEXT NOT NULL,
condition TEXT NOT NULL,
currently_firing INTEGER NOT NULL DEFAULT 0,
first_fired_at TEXT,
last_fired_at TEXT,
last_seen_at TEXT,
PRIMARY KEY (ticker, kind, condition)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS trade_alert_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL,
name TEXT,
kind TEXT NOT NULL,
condition TEXT NOT NULL,
price REAL,
detail_json TEXT,
fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tah_fired ON trade_alert_history(fired_at DESC)")
# Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드) # Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드)
ensure_screener_schema(conn) ensure_screener_schema(conn)
@@ -379,3 +416,146 @@ def get_holdings_signal_history(ticker: str, limit: int = 30) -> list:
"SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?", "SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?",
(ticker, limit)).fetchall() (ticker, limit)).fetchall()
return [_row_to_signal(r) for r in rows] return [_row_to_signal(r) for r in rows]
# --- 실시간 매매 알람: 공통 유틸 ---
def _now_iso() -> str:
return dt.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
# --- Watchlist CRUD ---
def add_watchlist(ticker: str, name: str = None, note: str = None) -> None:
with _conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO watchlist(ticker,name,note) VALUES(?,?,?)",
(ticker, name, note),
)
# 이름/노트 갱신(이미 있으면)
conn.execute(
"UPDATE watchlist SET name=COALESCE(?,name), note=COALESCE(?,note) WHERE ticker=?",
(name, note, ticker),
)
def remove_watchlist(ticker: str) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM watchlist WHERE ticker=?", (ticker,))
return cur.rowcount > 0
def get_watchlist() -> list:
with _conn() as conn:
rows = conn.execute("SELECT * FROM watchlist ORDER BY added_at").fetchall()
return [
{"ticker": r["ticker"], "name": r["name"], "note": r["note"],
"params": json.loads(r["params_json"] or "{}"), "added_at": r["added_at"]}
for r in rows
]
# --- Trade Alert State ---
def get_alert_state_firing() -> set:
with _conn() as conn:
rows = conn.execute(
"SELECT ticker,kind,condition FROM trade_alert_state WHERE currently_firing=1"
).fetchall()
return {(r["ticker"], r["kind"], r["condition"]) for r in rows}
def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool,
at_iso: str = None, mark_fired: bool = True) -> None:
"""currently_firing 상태 갱신.
mark_fired=True(기본): 실제 알림 발송 → first/last_fired_at 갱신.
mark_fired=False: 쿨다운으로 발송 억제하되 firing 상태만 유지 → 발동시각 미갱신
(쿨다운이 계속 연장되지 않도록).
"""
now = at_iso or _now_iso()
with _conn() as conn:
if firing and mark_fired:
conn.execute(
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,first_fired_at,last_fired_at,last_seen_at)
VALUES(?,?,?,1,?,?,?)
ON CONFLICT(ticker,kind,condition) DO UPDATE SET
currently_firing=1,
first_fired_at=COALESCE(first_fired_at,excluded.first_fired_at),
last_fired_at=excluded.last_fired_at,
last_seen_at=excluded.last_seen_at""",
(ticker, kind, condition, now, now, now),
)
elif firing and not mark_fired:
conn.execute(
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,last_seen_at)
VALUES(?,?,?,1,?)
ON CONFLICT(ticker,kind,condition) DO UPDATE SET
currently_firing=1, last_seen_at=excluded.last_seen_at""",
(ticker, kind, condition, now),
)
else:
conn.execute(
"UPDATE trade_alert_state SET currently_firing=0, last_seen_at=? WHERE ticker=? AND kind=? AND condition=?",
(now, ticker, kind, condition),
)
def get_alert_last_fired_map() -> dict:
"""{(ticker,kind,condition): last_fired_at ISO} — 쿨다운 판정용."""
with _conn() as conn:
rows = conn.execute(
"SELECT ticker,kind,condition,last_fired_at FROM trade_alert_state"
).fetchall()
return {(r["ticker"], r["kind"], r["condition"]): r["last_fired_at"] for r in rows}
def get_ticker_name(ticker: str) -> Optional[str]:
"""종목명 해석 — watchlist → portfolio → krx_master 순. 없으면 None."""
with _conn() as conn:
for sql in (
"SELECT name FROM watchlist WHERE ticker=?",
"SELECT name FROM portfolio WHERE ticker=? LIMIT 1",
"SELECT name FROM krx_master WHERE ticker=?",
):
try:
row = conn.execute(sql, (ticker,)).fetchone()
except sqlite3.OperationalError:
continue # 일부 테스트 DB엔 해당 테이블 부재
if row and row["name"]:
return row["name"]
return None
def touch_alert_seen(keys: list, at_iso: str) -> None:
with _conn() as conn:
for (ticker, kind, condition) in keys:
conn.execute(
"UPDATE trade_alert_state SET last_seen_at=? WHERE ticker=? AND kind=? AND condition=?",
(at_iso, ticker, kind, condition),
)
# --- Trade Alert History ---
def add_alert_history(ticker: str, name: str, kind: str, condition: str, price, detail: dict) -> int:
with _conn() as conn:
cur = conn.execute(
"INSERT INTO trade_alert_history(ticker,name,kind,condition,price,detail_json) VALUES(?,?,?,?,?,?)",
(ticker, name, kind, condition, price, json.dumps(detail or {}, ensure_ascii=False)),
)
return cur.lastrowid
def get_alert_history(days: int = 7) -> list:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM trade_alert_history WHERE fired_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?) ORDER BY fired_at DESC",
(f"-{int(days)} days",),
).fetchall()
return [
{"id": r["id"], "ticker": r["ticker"], "name": r["name"], "kind": r["kind"],
"condition": r["condition"], "price": r["price"],
"detail": json.loads(r["detail_json"] or "{}"), "fired_at": r["fired_at"]}
for r in rows
]

View File

@@ -21,6 +21,9 @@ from .db import (
upsert_broker_cash, get_all_broker_cash, delete_broker_cash, upsert_broker_cash, get_all_broker_cash, delete_broker_cash,
upsert_asset_snapshot, get_asset_snapshots, upsert_asset_snapshot, get_asset_snapshots,
add_sell_history, get_sell_history, update_sell_history, delete_sell_history, add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
add_watchlist, remove_watchlist, get_watchlist, get_alert_history,
get_alert_state_firing, set_alert_firing, touch_alert_seen, add_alert_history,
get_alert_last_fired_map, get_ticker_name,
) )
from .scraper import fetch_market_news, fetch_major_indices from .scraper import fetch_market_news, fetch_major_indices
from .price_fetcher import get_current_prices, get_current_prices_detail from .price_fetcher import get_current_prices, get_current_prices_detail
@@ -28,6 +31,10 @@ from .ai_summarizer import summarize_news, OllamaError
from .auth import verify_webai_key from .auth import verify_webai_key
from . import webai_cache from . import webai_cache
from . import holdings_intel from . import holdings_intel
from . import trade_alerts
from .trade_alerts import (
build_monitor_set, current_session, diff_firing, DEFAULT_EXIT_PARAMS, DEFAULT_BUY_PARAMS,
)
app = FastAPI() app = FastAPI()
install_access_log(app) install_access_log(app)
@@ -506,6 +513,90 @@ def get_webai_news_sentiment(date: str | None = None):
return result return result
@app.get("/api/webai/trade-alert/monitor-set", dependencies=[Depends(verify_webai_key)])
def get_trade_alert_monitor_set():
"""web-ai(Windows 워커) 전용 — 실시간 매매 알람 감시대상 조립 (계약 §5.1).
session은 KST 시각으로 pre/regular/after 판정 후, 평일·휴장 여부(is_market_open)를
함께 게이팅해 최종 closed 여부를 결정한다.
"""
from datetime import datetime, timezone, timedelta
kst = timezone(timedelta(hours=9))
now_kst = datetime.now(kst)
session = current_session(now_kst)
if not is_market_open(now_kst.date()):
session = "closed"
from .db import _conn
conn = _conn()
try:
return build_monitor_set(conn, session, DEFAULT_EXIT_PARAMS, DEFAULT_BUY_PARAMS)
finally:
conn.close()
class TradeAlertReport(BaseModel):
as_of: str | None = None
firing: list[dict] = []
@app.post("/api/webai/trade-alert/report", dependencies=[Depends(verify_webai_key)])
def post_trade_alert_report(req: TradeAlertReport):
"""web-ai(Windows 워커) 전용 — 발화 보고 수신 (계약 §5.2).
직전 발화상태 대비 edge diff(diff_firing) 후, 신규 alert는
agent-office 전송 성공 시에만 상태(firing=True)+이력 반영한다.
전송 실패 시 상태를 채택하지 않아 다음 사이클에 동일 alert가 다시
"신규"로 잡혀 재시도된다(멱등). 해제(cleared)는 전송과 무관하게 firing=False.
"""
from datetime import datetime, timedelta
cooldown_h = float(os.getenv("TRADE_ALERT_COOLDOWN_HOURS", "6"))
now = datetime.utcnow()
prev = get_alert_state_firing()
last_fired = get_alert_last_fired_map()
d = diff_firing(req.firing, prev)
new_count = 0
suppressed = 0
for a in d["new"]:
key = (a["ticker"], a["kind"], a["condition"])
# 쿨다운: 같은 종목·조건이 최근 발동됐으면(해제→재발화 오실레이션) 재알림 억제
lf = last_fired.get(key)
if cooldown_h > 0 and _within_cooldown(now, lf, timedelta(hours=cooldown_h)):
set_alert_firing(*key, firing=True, mark_fired=False) # firing 유지, 발동시각 미갱신
suppressed += 1
continue
name = a.get("name") or get_ticker_name(a["ticker"])
alert = {**a, "name": name}
if trade_alerts.notify_agent_office([alert]):
set_alert_firing(*key, firing=True) # 발동시각 갱신(UTC)
add_alert_history(
a["ticker"], name, a["kind"], a["condition"],
a.get("price"), a.get("detail") or {},
)
new_count += 1
for ticker, kind, condition in d["cleared"]:
set_alert_firing(ticker, kind, condition, firing=False)
touch_alert_seen(d["seen"], req.as_of or "")
return {"new_alerts": new_count, "cleared": len(d["cleared"]), "suppressed": suppressed}
def _within_cooldown(now, last_iso, cooldown) -> bool:
"""last_iso(UTC ISO `%Y-%m-%dT%H:%M:%fZ`)가 now 기준 cooldown 이내면 True."""
if not last_iso:
return False
from datetime import datetime
try:
lf = datetime.strptime(last_iso, "%Y-%m-%dT%H:%M:%fZ")
except (ValueError, TypeError):
return False
return (now - lf) < cooldown
@app.post("/api/portfolio", status_code=201) @app.post("/api/portfolio", status_code=201)
def create_portfolio_item(req: PortfolioItemRequest): def create_portfolio_item(req: PortfolioItemRequest):
"""포트폴리오 종목 추가""" """포트폴리오 종목 추가"""
@@ -653,6 +744,41 @@ def remove_sell_history(record_id: int):
return {"ok": True} return {"ok": True}
# --- Watchlist & Trade Alerts API (실시간 매매 알람) ---
class WatchlistItemRequest(BaseModel):
ticker: str
name: str | None = None
note: str | None = None
@app.get("/api/stock/watchlist")
def list_watchlist():
"""관심종목 목록 조회"""
return {"watchlist": get_watchlist()}
@app.post("/api/stock/watchlist", status_code=201)
def create_watchlist_item(req: WatchlistItemRequest):
"""관심종목 추가 (이미 존재하면 name/note 갱신, 멱등)"""
add_watchlist(req.ticker, req.name, req.note)
return {"ok": True}
@app.delete("/api/stock/watchlist/{ticker}")
def delete_watchlist_item(ticker: str):
"""관심종목 삭제"""
if not remove_watchlist(ticker):
raise HTTPException(status_code=404, detail="not in watchlist")
return {"ok": True}
@app.get("/api/stock/trade-alerts")
def list_trade_alerts(days: int = 7):
"""매매 알람 이력 조회 (최근 N일)"""
return {"alerts": get_alert_history(days)}
# --- Holdings Intelligence API --- # --- Holdings Intelligence API ---
@app.get("/api/stock/holdings/intel") @app.get("/api/stock/holdings/intel")

138
stock/app/trade_alerts.py Normal file
View File

@@ -0,0 +1,138 @@
"""매매 알람 — 감시대상(monitor-set) 조립. 순수 조립 로직(HTTP/텔레그램 없음).
계약 §5.1 (docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md) —
Windows 워커가 GET /api/webai/trade-alert/monitor-set 로 받는 응답을 조립한다.
NAS는 watchlist screener 최신 성공 run 후보를 buy_targets로, 보유 종목을
sell_targets로 병합해 넘긴다. TA/조건판정은 워커 쪽 책임.
"""
import os
import httpx
from datetime import datetime, timedelta, timezone, time as _time
from typing import Optional
from app.db import get_all_portfolio, get_watchlist
_KST = timezone(timedelta(hours=9))
# KST 세션 창(시:분) — 평일+휴장 판정은 호출부에서 is_market_open으로 별도 게이팅
_SESSIONS = [
("pre", (8, 30), (9, 0)),
("regular", (9, 0), (15, 30)),
("after", (16, 0), (18, 0)),
]
def current_session(now_kst) -> str:
"""now_kst의 time만으로 pre/regular/after/closed 세션 판정 (요일·휴장 무관)."""
t = now_kst.time()
for name, (sh, sm), (eh, em) in _SESSIONS:
if _time(sh, sm) <= t < _time(eh, em):
return name
return "closed"
DEFAULT_EXIT_PARAMS = {"stop_pct": 0.08, "take_pct": 0.25, "trailing_pct": 0.10,
"climax_vol_x": 3.0, "climax_close_pct": 0.97}
DEFAULT_BUY_PARAMS = {"rsi_oversold": 30, "breakout_vol_mult": 1.5, "pullback_pct": 0.02}
def latest_screener_candidates(conn) -> list:
"""최신 성공(status='success') screener run의 후보 {ticker,name} 목록."""
row = conn.execute(
"SELECT id FROM screener_runs WHERE status='success' ORDER BY asof DESC, id DESC LIMIT 1"
).fetchone()
if not row:
return []
run_id = row[0]
rows = conn.execute(
"SELECT ticker, name FROM screener_results WHERE run_id=? ORDER BY rank", (run_id,)
).fetchall()
return [{"ticker": r[0], "name": r[1]} for r in rows]
def holding_high(conn, ticker: str, lookback_days: int = 60) -> Optional[float]:
"""보유기간 고점(트레일링 스톱용) — krx_daily_prices 최근 lookback_days 최고 high."""
row = conn.execute(
"SELECT MAX(high) FROM krx_daily_prices WHERE ticker=? "
"AND date >= date('now', ?)",
(ticker, f"-{int(lookback_days)} days"),
).fetchone()
return row[0] if row and row[0] is not None else None
def build_monitor_set(conn, session: str, exit_params: dict, buy_params: dict) -> dict:
"""계약 §5.1 monitor-set 응답 dict 조립.
buy_targets = watchlist 최신 screener 후보 (ticker 기준 중복 제거, watchlist 우선)
sell_targets = 보유 종목(portfolio) + avg_price/qty/holding_high
"""
buy: dict[str, dict] = {}
for w in get_watchlist():
buy[w["ticker"]] = {
"ticker": w["ticker"], "name": w["name"],
"source": "watch", "params": w.get("params") or {},
}
for c in latest_screener_candidates(conn):
if c["ticker"] not in buy:
buy[c["ticker"]] = {
"ticker": c["ticker"], "name": c["name"],
"source": "screener", "params": {},
}
sell_targets = []
for p in get_all_portfolio():
ticker = p["ticker"]
sell_targets.append({
"ticker": ticker,
"name": p.get("name"),
"avg_price": p.get("avg_price"),
"qty": p.get("quantity"),
"holding_high": holding_high(conn, ticker),
"params": {},
})
return {
"session": session,
"as_of": datetime.now(_KST).isoformat(),
"buy_targets": list(buy.values()),
"sell_targets": sell_targets,
"buy_params": buy_params,
"exit_params": exit_params,
}
def diff_firing(reported: list, prev: set) -> dict:
"""워커 발화집합(reported) vs 직전 발화상태(prev) edge diff.
reported 각 항목: {ticker,kind,condition,price,detail,name?}.
key = (ticker,kind,condition).
반환 {"new":[신규 alert...], "cleared":[해제 key...], "seen":[현재 key...]}.
"""
cur = {}
for a in reported:
key = (a["ticker"], a["kind"], a["condition"])
cur[key] = a
cur_keys = set(cur.keys())
new_keys = cur_keys - prev
cleared = sorted(prev - cur_keys)
return {
"new": [cur[k] for k in cur_keys if k in new_keys],
"cleared": cleared,
"seen": sorted(cur_keys),
}
def notify_agent_office(alerts: list) -> bool:
"""신규 alert들을 agent-office로 push (계약 §5.2). 전송 성공 시 True.
실패(네트워크 오류/비-200)는 False — 호출부가 상태/이력 미채택 후 다음
사이클에 동일 alert를 재시도하도록 한다(멱등, at-least-once).
"""
url = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000") + "/api/agent-office/stock/trade-alert"
try:
with httpx.Client(timeout=10) as c:
resp = c.post(url, json={"alerts": alerts})
return resp.status_code == 200
except httpx.HTTPError:
return False

View File

@@ -0,0 +1,48 @@
import os, sqlite3, tempfile, datetime as dt
import pytest
@pytest.fixture
def db(monkeypatch, tmp_path):
from app import db as _db
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
_db.init_db()
return _db
def test_watchlist_add_get_remove(db):
db.add_watchlist("005930", "삼성전자", note="관심")
db.add_watchlist("005930", "삼성전자") # 멱등
wl = db.get_watchlist()
assert [w["ticker"] for w in wl] == ["005930"]
assert wl[0]["name"] == "삼성전자"
assert db.remove_watchlist("005930") is True
assert db.get_watchlist() == []
def test_alert_state_edge_firing_and_clear(db):
key = ("005930", "buy", "buy_breakout")
assert db.get_alert_state_firing() == set()
db.set_alert_firing(*key, firing=True, at_iso="2026-07-02T00:01:00Z")
assert key in db.get_alert_state_firing()
db.set_alert_firing(*key, firing=False)
assert key not in db.get_alert_state_firing()
def test_alert_history_records_and_reads(db):
db.add_alert_history("005930", "삼성전자", "buy", "buy_breakout", 71500, {"vol": 2.1})
rows = db.get_alert_history(days=7)
assert len(rows) == 1
assert rows[0]["ticker"] == "005930" and rows[0]["kind"] == "buy"
assert rows[0]["detail"]["vol"] == 2.1
def test_alert_history_days_filter_format_consistency(db):
"""fired_at은 ISO(T/Z)로 저장 — 필터도 ISO여야 경계일 비교가 정확.
7일 경계 밖(정확히 7일 전 자정) 레코드는 제외되어야 한다. 포맷 불일치면 잘못 포함됨."""
db.add_alert_history("005930", "삼성", "buy", "buy_breakout", 71500, {}) # now
conn = sqlite3.connect(db.DB_PATH)
conn.execute(
"INSERT INTO trade_alert_history(ticker,name,kind,condition,price,detail_json,fired_at) "
"VALUES('000660','SK','sell','sell_stop_loss',60000,'{}', "
"strftime('%Y-%m-%dT%H:%M:%fZ','now','-7 days','start of day'))"
)
conn.commit(); conn.close()
tickers = [r["ticker"] for r in db.get_alert_history(days=7)]
assert "005930" in tickers
assert "000660" not in tickers

View File

@@ -0,0 +1,18 @@
def test_diff_new_and_cleared_and_rearm():
from app.trade_alerts import diff_firing
reported = [{"ticker": "005930", "kind": "buy", "condition": "buy_breakout",
"price": 71500, "detail": {}}]
# 최초: prev 비어있음 → 신규
d1 = diff_firing(reported, prev=set())
assert [a["condition"] for a in d1["new"]] == ["buy_breakout"]
assert d1["cleared"] == []
# 유지: prev에 이미 있음 → 신규 없음
prev = {("005930", "buy", "buy_breakout")}
d2 = diff_firing(reported, prev=prev)
assert d2["new"] == []
# 해제: reported 비었고 prev에 있음 → cleared
d3 = diff_firing([], prev=prev)
assert d3["cleared"] == [("005930", "buy", "buy_breakout")]
# 재무장 후 재발화: prev 다시 비면 신규
d4 = diff_firing(reported, prev=set())
assert len(d4["new"]) == 1

View File

@@ -0,0 +1,101 @@
import sqlite3
import pytest
@pytest.fixture
def conn(monkeypatch, tmp_path):
from app import db as _db
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
_db.init_db()
c = sqlite3.connect(_db.DB_PATH)
c.row_factory = sqlite3.Row
# 보유 1종목 (add_portfolio_item 실제 시그니처: broker/ticker/name/quantity/avg_price — market 파라미터 없음)
_db.add_portfolio_item(ticker="000660", name="SK하이닉스", quantity=10,
avg_price=180000, broker="kis")
# watchlist 1종목
_db.add_watchlist("005930", "삼성전자")
yield c
c.close()
def test_build_monitor_set_merges_sources(conn):
from app import trade_alerts as ta
ms = ta.build_monitor_set(conn, session="regular",
exit_params={"stop_pct": 0.08}, buy_params={"rsi_oversold": 30})
buy_tickers = {t["ticker"] for t in ms["buy_targets"]}
sell_tickers = {t["ticker"] for t in ms["sell_targets"]}
assert "005930" in buy_tickers # watchlist
assert "000660" in sell_tickers # 보유
assert ms["session"] == "regular"
assert ms["exit_params"]["stop_pct"] == 0.08
sell = next(t for t in ms["sell_targets"] if t["ticker"] == "000660")
assert sell["avg_price"] == 180000 and sell["qty"] == 10
def test_latest_screener_candidates_empty_when_no_run(conn):
from app import trade_alerts as ta
assert ta.latest_screener_candidates(conn) == []
def test_latest_screener_candidates_picks_latest_success_run(conn):
from app import trade_alerts as ta
now = "2026-07-02T09:00:00Z"
conn.execute(
"INSERT INTO screener_runs (asof, mode, status, started_at, weights_json, "
"node_params_json, gate_params_json, top_n) VALUES (?,?,?,?,?,?,?,?)",
(now, "manual", "failed", now, "{}", "{}", "{}", 20),
)
conn.execute(
"INSERT INTO screener_runs (asof, mode, status, started_at, weights_json, "
"node_params_json, gate_params_json, top_n) VALUES (?,?,?,?,?,?,?,?)",
(now, "manual", "success", now, "{}", "{}", "{}", 20),
)
run_id = conn.execute("SELECT id FROM screener_runs WHERE status='success'").fetchone()[0]
conn.execute(
"INSERT INTO screener_results (run_id, rank, ticker, name, total_score, scores_json) "
"VALUES (?,?,?,?,?,?)",
(run_id, 1, "035720", "카카오", 88.5, "{}"),
)
conn.commit()
candidates = ta.latest_screener_candidates(conn)
assert candidates == [{"ticker": "035720", "name": "카카오"}]
def test_holding_high_returns_max_high_within_lookback(conn):
from app import trade_alerts as ta
conn.execute(
"INSERT INTO krx_daily_prices (ticker, date, high) VALUES (?,?,?)",
("000660", "2026-06-01", 200000),
)
conn.execute(
"INSERT INTO krx_daily_prices (ticker, date, high) VALUES (?,?,?)",
("000660", "2026-06-20", 210000),
)
conn.commit()
assert ta.holding_high(conn, "000660", lookback_days=60) == 210000
def test_holding_high_none_when_no_price_history(conn):
from app import trade_alerts as ta
assert ta.holding_high(conn, "999999") is None
def test_build_monitor_set_dedupes_watchlist_and_screener_overlap(conn):
from app import trade_alerts as ta
now = "2026-07-02T09:00:00Z"
cur = conn.execute(
"INSERT INTO screener_runs (asof, mode, status, started_at, weights_json, "
"node_params_json, gate_params_json, top_n) VALUES (?,?,?,?,?,?,?,?)",
(now, "manual", "success", now, "{}", "{}", "{}", 20),
)
run_id = cur.lastrowid
# 스크리너 후보가 watchlist와 중복(005930)
conn.execute(
"INSERT INTO screener_results (run_id, rank, ticker, name, total_score, scores_json) "
"VALUES (?,?,?,?,?,?)",
(run_id, 1, "005930", "삼성전자", 90.0, "{}"),
)
conn.commit()
ms = ta.build_monitor_set(conn, session="regular", exit_params={}, buy_params={})
buy_tickers = [t["ticker"] for t in ms["buy_targets"]]
assert buy_tickers.count("005930") == 1

View File

@@ -0,0 +1,43 @@
import datetime as dt
import pytest
from fastapi.testclient import TestClient
def test_current_session_windows():
from app.trade_alerts import current_session
d = dt.date(2026, 7, 2)
assert current_session(dt.datetime.combine(d, dt.time(8, 40))) == "pre"
assert current_session(dt.datetime.combine(d, dt.time(10, 0))) == "regular"
assert current_session(dt.datetime.combine(d, dt.time(17, 0))) == "after"
assert current_session(dt.datetime.combine(d, dt.time(20, 0))) == "closed"
@pytest.fixture
def client(monkeypatch, tmp_path):
from app import db as _db
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
_db.init_db()
monkeypatch.setenv("WEBAI_API_KEY", "k")
from app.main import app
return TestClient(app)
def test_monitor_set_requires_auth(client):
assert client.get("/api/webai/trade-alert/monitor-set").status_code == 401
def test_monitor_set_ok(client):
r = client.get("/api/webai/trade-alert/monitor-set", headers={"X-WebAI-Key": "k"})
assert r.status_code == 200
body = r.json()
assert body["session"] in ("pre", "regular", "after", "closed")
assert "buy_targets" in body and "sell_targets" in body
assert body["exit_params"]["trailing_pct"] == 0.10
def test_monitor_set_exit_params_include_climax(client):
"""climax 파라미터 중앙화 — 워커가 하드코딩 대신 NAS exit_params에서 받아 튜닝."""
ep = client.get("/api/webai/trade-alert/monitor-set",
headers={"X-WebAI-Key": "k"}).json()["exit_params"]
assert ep["climax_vol_x"] == 3.0
assert ep["climax_close_pct"] == 0.97

View File

@@ -0,0 +1,85 @@
import pytest
from unittest.mock import patch
from fastapi.testclient import TestClient
@pytest.fixture
def client(monkeypatch, tmp_path):
from app import db as _db
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
_db.init_db()
monkeypatch.setenv("WEBAI_API_KEY", "k")
from app.main import app
return TestClient(app)
def _report(client, firing):
return client.post("/api/webai/trade-alert/report",
headers={"X-WebAI-Key": "k"},
json={"as_of": "2026-07-02T09:01:00+09:00", "firing": firing})
def test_report_new_edge_sends_and_persists(client):
firing = [{"ticker": "005930", "name": "삼성전자", "kind": "buy",
"condition": "buy_breakout", "price": 71500, "detail": {"vol": 2.0}}]
with patch("app.trade_alerts.notify_agent_office", return_value=True) as m:
r1 = _report(client, firing)
assert r1.json()["new_alerts"] == 1
assert m.called
# 2번째 동일 firing → 유지, 신규 0
with patch("app.trade_alerts.notify_agent_office", return_value=True):
r2 = _report(client, firing)
assert r2.json()["new_alerts"] == 0
# 이력 1건
assert len(client.get("/api/stock/trade-alerts?days=1").json()["alerts"]) == 1
def test_report_send_failure_does_not_persist(client):
firing = [{"ticker": "005930", "name": "삼성전자", "kind": "buy",
"condition": "buy_breakout", "price": 71500, "detail": {}}]
with patch("app.trade_alerts.notify_agent_office", return_value=False):
r = _report(client, firing)
assert r.json()["new_alerts"] == 0 # 전송 실패 → 미채택
# 다음 사이클(전송 성공) 재시도되어 알림
with patch("app.trade_alerts.notify_agent_office", return_value=True):
r2 = _report(client, firing)
assert r2.json()["new_alerts"] == 1
def test_report_cooldown_suppresses_immediate_refire(client):
"""같은 종목·조건이 해제됐다 곧바로 재발화해도 쿨다운(기본 6h) 내면 재알림 억제."""
firing = [{"ticker": "005930", "name": "삼성", "kind": "buy",
"condition": "buy_breakout", "price": 71500, "detail": {}}]
with patch("app.trade_alerts.notify_agent_office", return_value=True):
assert _report(client, firing).json()["new_alerts"] == 1 # 최초 알림
_report(client, []) # 해제
r = _report(client, firing) # 즉시 재발화 → 쿨다운 억제
assert r.json()["new_alerts"] == 0
assert r.json()["suppressed"] == 1
def test_report_refire_after_cooldown_alerts(client, monkeypatch):
"""쿨다운=0이면 해제 후 재발화 시 재알림."""
monkeypatch.setenv("TRADE_ALERT_COOLDOWN_HOURS", "0")
firing = [{"ticker": "005930", "name": "삼성", "kind": "buy",
"condition": "buy_breakout", "price": 71500, "detail": {}}]
with patch("app.trade_alerts.notify_agent_office", return_value=True):
_report(client, firing)
_report(client, [])
r = _report(client, firing)
assert r.json()["new_alerts"] == 1
def test_report_resolves_stock_name_from_watchlist(client):
"""워커 firing에 name이 없어도 NAS가 종목명을 해석해 알림에 포함한다."""
from app import db
db.add_watchlist("000660", "SK하이닉스")
firing = [{"ticker": "000660", "kind": "buy", "condition": "buy_breakout",
"price": 180000, "detail": {}}] # name 없음
with patch("app.trade_alerts.notify_agent_office", return_value=True) as m:
_report(client, firing)
sent_alert = m.call_args[0][0][0]
assert sent_alert["name"] == "SK하이닉스"
# 이력에도 종목명 기록
alerts = client.get("/api/stock/trade-alerts?days=1").json()["alerts"]
assert alerts[0]["name"] == "SK하이닉스"

View File

@@ -0,0 +1,22 @@
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(monkeypatch, tmp_path):
from app import db as _db
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
_db.init_db()
from app.main import app
return TestClient(app)
def test_watchlist_crud(client):
assert client.get("/api/stock/watchlist").json()["watchlist"] == []
r = client.post("/api/stock/watchlist", json={"ticker": "005930", "name": "삼성전자"})
assert r.status_code == 201
wl = client.get("/api/stock/watchlist").json()["watchlist"]
assert wl[0]["ticker"] == "005930"
assert client.delete("/api/stock/watchlist/005930").status_code == 200
assert client.delete("/api/stock/watchlist/005930").status_code == 404
def test_trade_alerts_history_empty(client):
assert client.get("/api/stock/trade-alerts?days=7").json()["alerts"] == []