30 Commits

Author SHA1 Message Date
cdc309150e feat(realestate): 매물 수집 MOLIT/Naver 진단 계측(collect_log.error) — silent-failure 관측
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 17:09:59 +09:00
a7be8f76bf docs(CLAUDE.md): realestate 매물 알림+안전마진 엔드포인트 + agent-office notify-listing 등재
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 14:08:14 +09:00
95fadaa8ef fix(realestate): 시세 표본 광역폴백(M1)+area-NULL upsert skip(M3)+matches JSON파싱(M4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 14:03:54 +09:00
c6b969443f feat(agent-office): 매물 알림 텔레그램 notify-listing(너+아내)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 13:50:04 +09:00
b4fb3998fe feat(realestate): 매물 라우트 6종+safety-check/budget+3h cron
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 13:43:34 +09:00
4847626424 feat(realestate): notify_new_listings — 매물 알림 agent-office push 2026-07-09 13:39:19 +09:00
08ac800910 feat(realestate): 매물 수집(국토부 실거래[합법]+네이버 호가[폴백])
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 13:36:20 +09:00
d752675e9d feat(realestate): 매물 매칭+안전마진(전세가율)/매매 적정성(호가율) 판정
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 13:33:11 +09:00
c0a50f4ee6 feat(realestate): 매물 DB 4테이블(listings/market_deals/matches/criteria)+헬퍼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 13:29:36 +09:00
cd15504f86 feat(realestate): 재무·규제 규칙(전세/매수 예산·토허·LTV·DSR config) 2026-07-09 13:24:00 +09:00
e8998a4098 feat(realestate): 매물 파이프라인 법정동코드(LAWD_CD) 매핑 2026-07-09 13:20:59 +09:00
b8229c0ffa feat(stock): /api/stock/trade-alerts 응답에 조건별 근거(reason) 문자열 추가
FE가 detail(객체)을 그대로 렌더해 React #31 크래시 → 안전한 텍스트 필드 제공.
condition_reason(조건→한줄근거), agent-office telegram_trade._COND_REASON과 동일 유지.
2026-07-09 12:47:34 +09:00
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
40 changed files with 3743 additions and 12 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 영상 자동화 파이프라인 + 시장 트렌드.
@@ -328,10 +332,12 @@ docker compose up -d
| POST | `/api/internal/insta/update` | Windows 워커 결과 webhook | | POST | `/api/internal/insta/update` | Windows 워커 결과 webhook |
### realestate-lab (realestate-lab/) ### realestate-lab (realestate-lab/)
공공데이터포털 청약 분양정보 수집 + 자치구 5티어 매칭 + agent-office push 알림. 공공데이터포털 청약 분양정보 수집 + 자치구 5티어 매칭 + agent-office push 알림. **+ 매물 알림(전월세·매매) + 안전마진/매수 적정성 판정 + 재무·규제 예산 산정**(2026-07-09 추가).
- 핵심 파일: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py` - 핵심 파일(청약): `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
- 핵심 파일(매물): `listing_collector.py`(국토부 실거래[합법]+네이버 호가[폴백]), `listing_matcher.py`(안전마진·적정성), `finance_rules.py`+`finance_rules_config.py`(전세대출·LTV·DSR·토허), `lawd_codes.py`(법정동코드)
- 매칭 100점: 지역35 / 주택유형10 / 면적15 / 가격15 / 자격25 - 매칭 100점: 지역35 / 주택유형10 / 면적15 / 가격15 / 자격25
- 📌 상세(DB 스키마·스케줄러 4단계·매칭 모델·notifier 멱등 흐름·env): **`service_realestate.md`** - 매물 판정: 임차 전세가율 ≤0.70🟢/≤0.80🟡/>0.80🔴, 매매 호가율 ≤0.97🟢/≤1.05🟡/>1.05🔴, 실거래 표본<3 ⚪보류. 네이버 차단돼도 실거래·안전마진·청약 무중단(`naver_ok`)
- 📌 상세(DB 스키마·스케줄러·매칭 모델·notifier 멱등·매물 4테이블·판정 경계·env): **`service_realestate.md`**
| 메서드 | 경로 | 설명 | | 메서드 | 경로 | 설명 |
|--------|------|------| |--------|------|------|
@@ -345,6 +351,12 @@ docker compose up -d
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 | | POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 | | PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 |
| GET | `/api/realestate/dashboard` | 요약 (진행중·신규매칭·일정) | | GET | `/api/realestate/dashboard` | 요약 (진행중·신규매칭·일정) |
| GET | `/api/realestate/listings` | 매물 목록 (dong/deal_type/tier/matched_only/page/size) |
| POST/GET | `/api/realestate/listings/collect` (+ `/collect/status`) | 매물 수동 수집(collect→match→notify)/상태 |
| GET/PUT | `/api/realestate/listings/criteria` | 매물 조건(동·거래유형·보증금상한·자기자금·연소득 등) 조회/수정 |
| GET | `/api/realestate/listings/matches` | 매물 매칭+판정 결과 |
| POST | `/api/realestate/safety-check` | 단건 안전마진/적정성 판정 (실거래 median 대비) |
| POST | `/api/realestate/budget` | 전세/매수 예산·규제 산정 (전세대출·LTV·DSR·토허) |
### agent-office (agent-office/) ### agent-office (agent-office/)
AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇. AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇.
@@ -361,7 +373,8 @@ AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출
| POST | `/api/agent-office/command` | 에이전트 명령 전송 | | POST | `/api/agent-office/command` | 에이전트 명령 전송 |
| POST | `/api/agent-office/approve` | 작업 승인/거부 | | POST | `/api/agent-office/approve` | 작업 승인/거부 |
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook (realestate_bookmark_* 콜백 포함) | | POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook (realestate_bookmark_* 콜백 포함) |
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 | | POST | `/api/agent-office/realestate/notify` | realestate-lab 청약 매칭 push 수신 → 텔레그램 |
| POST | `/api/agent-office/realestate/notify-listing` | realestate-lab 매물 알림 push 수신 → 텔레그램(너+아내, 안전마진/적정성 렌더) |
| GET | `/api/agent-office/states` | 전체 에이전트 상태 | | GET | `/api/agent-office/states` | 전체 에이전트 상태 |
| GET | `/api/agent-office/nodes` | 분산 워커(NAS↔Windows) 관측 — heartbeat 생사+큐깊이+dead-letter 집계 (web-ui `/infra` Three.js 시각화 소비). 상세 → `infra_distributed_workers.md` | | GET | `/api/agent-office/nodes` | 분산 워커(NAS↔Windows) 관측 — heartbeat 생사+큐깊이+dead-letter 집계 (web-ui `/infra` Three.js 시각화 소비). 상세 → `infra_distributed_workers.md` |
| GET | `/api/agent-office/activity` | 전 에이전트 통합 활동 피드 (tasks+logs UNION). 필터 `agent_id`/`type`(task\|log)/`status`/`days` + `limit`/`offset` | | GET | `/api/agent-office/activity` | 전 에이전트 통합 활동 피드 (tasks+logs UNION). 필터 `agent_id`/`type`(task\|log)/`status`/`days` + `limit`/`offset` |
@@ -369,6 +382,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 +501,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

@@ -227,6 +227,24 @@ async def realestate_notify(body: RealestateNotifyBody):
return await agent.on_new_matches(body.matches) return await agent.on_new_matches(body.matches)
# --- Realestate Listing Notify Endpoint (매물 알림, 청약 notify와 별도) ---
class ListingNotifyBody(BaseModel):
listings: List[Dict[str, Any]] = []
@app.post("/api/agent-office/realestate/notify-listing")
async def realestate_notify_listing(body: ListingNotifyBody):
from .notifiers.telegram_realestate_listing import send_listing_alerts
from .db import add_log
res = await send_listing_alerts(body.listings)
for a in body.listings:
add_log("realestate",
f"매물알림 {a.get('deal_type')} {a.get('complex_name')} "
f"{a.get('safety_tier') or a.get('valuation_tier')}", "info")
return res
# --- YouTube Research Agent Endpoints --- # --- YouTube Research Agent Endpoints ---
class YouTubeResearchBody(BaseModel): class YouTubeResearchBody(BaseModel):
@@ -278,3 +296,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

@@ -16,6 +16,7 @@ WORKER_REGISTRY = [
{"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,83 @@
"""매물 알림 텔레그램 포맷+전송 (본인+아내 각각). realestate-lab notify_new_listings 수신.
telegram_trade.py(매매알람)와 대칭 구조: send_raw 저수준 전송 + chat_id 리스트 순회.
"""
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")
_TIER_EMOJI = {
"안전": "🟢 안전", "주의": "🟡 주의", "위험": "🔴 위험", "보류": "⚪ 보류(표본부족)",
"저평가": "🟢 저평가", "시세": "🟡 시세 수준", "고가": "🔴 고가",
}
def _manwon(v) -> str:
if not v:
return "-"
return f"{v / 10000:.1f}" if v >= 10000 else f"{v:,}"
def format_listing_alert(a: Dict[str, Any]) -> str:
cat = a.get("category") or ("매매" if a.get("deal_type") == "매매" else "임차")
if cat == "임차":
price = _manwon(a.get("deposit"))
tier = _TIER_EMOJI.get(a.get("safety_tier"), a.get("safety_tier") or "")
ratio = a.get("jeonse_ratio")
if ratio is not None:
judge = (f"{tier} (전세가율 {int(ratio * 100)}% · 시세 {_manwon(a.get('market_median'))}, "
f"실거래 {a.get('sample_size', 0)}건)")
else:
judge = f"{tier} (실거래 표본 부족 — 수동 확인)"
warn = "⚠️ 등기부 선순위 근저당 수동 확인 필수(인터넷등기소)"
else:
price = _manwon(a.get("sale_price"))
tier = _TIER_EMOJI.get(a.get("valuation_tier"), a.get("valuation_tier") or "")
ratio = a.get("price_ratio")
budget = " · 예산 내 ✅" if a.get("budget_ok") else ""
if ratio is not None:
judge = (f"{tier} (호가율 {int(ratio * 100)}% · 실거래 median {_manwon(a.get('market_median'))}, "
f"{a.get('sample_size', 0)}건){budget}")
else:
judge = f"{tier} (실거래 표본 부족 — 수동 확인)"
flags = a.get("regulation_flags") or []
warn = "⚠️ " + (" · ".join(flags) if flags else "비토허") + " · 등기부 선순위 수동 확인 필수"
area = f"전용 {a.get('area_exclusive')}" if a.get("area_exclusive") else ""
return (
f"🏠 [{a.get('deal_type') or ''}] {a.get('complex_name') or ''} · {price} · {area} · {a.get('floor') or ''}\n"
f"{judge}\n"
f"📍 {a.get('dong') or ''} · {warn}\n"
f"🔗 {a.get('url') or ''}"
)
async def send_listing_alerts(listings: List[Dict[str, Any]]) -> dict:
"""매물마다 본인+아내 chat_id 각각으로 send_raw. 1건 이상 성공하면 delivered→sent_ids에 id 수집.
실패해도 나머지 계속 진행(per-send try/except)."""
sent = 0
sent_ids: List[Any] = []
all_ok = True
chat_ids = [c for c in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if c]
for a in listings:
text = format_listing_alert(a)
delivered = False
for cid in chat_ids:
try:
r = await send_raw(text, chat_id=cid)
except Exception as e:
logger.warning(f"[telegram_realestate_listing] send failed (chat_id={cid}): {e}")
all_ok = False
continue
if r.get("ok"):
sent += 1
delivered = True
else:
all_ok = False
if delivered and a.get("id") is not None:
sent_ids.append(a["id"])
return {"sent": sent, "sent_ids": sent_ids, "ok": all_ok}

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,78 @@
"""매물 알림 텔레그램 렌더+전송 테스트. 기존 test_trade_alert_notify.py 패턴(async mock + chat_id patch) 준수."""
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_format_listing_rent_and_sale():
from app.notifiers.telegram_realestate_listing import format_listing_alert
rent = format_listing_alert({"id": 1, "category": "임차", "deal_type": "전세",
"complex_name": "OO", "deposit": 29000, "area_exclusive": 42.0, "floor": "5/15",
"safety_tier": "안전", "jeonse_ratio": 0.68, "market_median": 43000, "sample_size": 7,
"dong": "신대방동", "url": "http://x", "regulation_flags": []})
assert "전세" in rent and "안전" in rent and "OO" in rent and "68" in rent
sale = format_listing_alert({"id": 2, "category": "매매", "deal_type": "매매",
"complex_name": "PP", "sale_price": 89000, "area_exclusive": 59.0, "floor": "12/20",
"valuation_tier": "시세", "price_ratio": 1.01, "market_median": 88000, "sample_size": 7,
"dong": "상도동", "url": "http://y", "budget_ok": 1, "regulation_flags": []})
assert "매매" in sale and "시세" in sale
@pytest.mark.asyncio
async def test_format_listing_pending_sample_and_regulation_flags():
"""표본<3(보류)·토허 등 regulation_flags 경고 라인이 포함되는지."""
from app.notifiers.telegram_realestate_listing import format_listing_alert
pending = format_listing_alert({"id": 3, "category": "임차", "deal_type": "전세",
"complex_name": "QQ", "deposit": 28000, "area_exclusive": 40.0, "floor": "3/10",
"safety_tier": "보류", "jeonse_ratio": None, "market_median": None, "sample_size": 1,
"dong": "봉천동", "url": "http://z", "regulation_flags": []})
assert "보류" in pending
toheo = format_listing_alert({"id": 4, "category": "매매", "deal_type": "매매",
"complex_name": "RR", "sale_price": 150000, "area_exclusive": 84.0, "floor": "7/15",
"valuation_tier": "고가", "price_ratio": 1.1, "market_median": 136000, "sample_size": 5,
"dong": "대치동", "url": "http://w", "budget_ok": 0, "regulation_flags": ["토허"]})
assert "토허" in toheo
@pytest.mark.asyncio
async def test_send_listing_alerts_returns_sent_ids():
from app.notifiers import telegram_realestate_listing as t
items = [{"id": 5, "category": "임차", "deal_type": "전세", "complex_name": "OO",
"deposit": 29000, "area_exclusive": 42.0, "safety_tier": "안전",
"jeonse_ratio": 0.68, "market_median": 43000, "sample_size": 7,
"dong": "신대방동", "url": "http://x", "regulation_flags": []}]
with patch("app.notifiers.telegram_realestate_listing.send_raw",
new=AsyncMock(return_value={"ok": True})) as m, \
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_CHAT_ID", "U"), \
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_WIFE_CHAT_ID", "W"):
res = await t.send_listing_alerts(items)
assert res["sent_ids"] == [5] and res["ok"] is True
assert res["sent"] == 2 # 너+아내 둘 다
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_send_listing_alerts_partial_failure_not_marked_sent():
"""한쪽 chat_id 전송이 예외를 던져도 나머지는 계속 발송되고, 최소 1건 성공이면 sent_ids에 포함."""
from app.notifiers import telegram_realestate_listing as t
items = [{"id": 6, "category": "매매", "deal_type": "매매", "complex_name": "PP",
"sale_price": 89000, "area_exclusive": 59.0, "valuation_tier": "시세",
"price_ratio": 1.01, "market_median": 88000, "sample_size": 7,
"dong": "상도동", "url": "http://y", "budget_ok": 1, "regulation_flags": []}]
async def _flaky(text, chat_id=None, **kw):
if chat_id == "U":
raise RuntimeError("network error")
return {"ok": True}
with patch("app.notifiers.telegram_realestate_listing.send_raw", side_effect=_flaky), \
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_CHAT_ID", "U"), \
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_WIFE_CHAT_ID", "W"):
res = await t.send_listing_alerts(items)
assert res["sent_ids"] == [6]
assert res["ok"] is False # 일부 실패했으므로 all_ok=False

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

@@ -10,6 +10,9 @@ logger = logging.getLogger("realestate-lab")
DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db") DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
# listing_matcher.MIN_SAMPLE와 동일 값 유지 — 시세 표본이 이 미만이면 광역 폴백.
_MARKET_SAMPLE_MIN = 3
def _conn(): def _conn():
c = sqlite3.connect(DB_PATH, timeout=120.0) c = sqlite3.connect(DB_PATH, timeout=120.0)
@@ -177,6 +180,72 @@ def init_db():
); );
""") """)
# ── listings (매물 파이프라인) ──────────────────────────────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS listings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_no TEXT UNIQUE, source TEXT NOT NULL, deal_type TEXT NOT NULL,
deposit INTEGER, monthly_rent INTEGER, sale_price INTEGER,
area_exclusive REAL, floor TEXT, complex_name TEXT, address TEXT,
dong TEXT, dong_code TEXT, url TEXT, posted_at TEXT, raw_json TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
notified_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS market_deals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
house_type TEXT, dong_code TEXT, dong TEXT, complex_name TEXT, area REAL,
deal_type TEXT, deposit INTEGER, monthly_rent INTEGER, sale_amount INTEGER,
deal_ym TEXT, floor TEXT, source TEXT DEFAULT 'molit',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
# 플랜 원안은 table-level UNIQUE(...)였으나 SQLite는 UNIQUE 제약에서 NULL을 서로 다른 값으로
# 취급해 deposit/sale_amount(둘 중 하나는 항상 NULL)가 있는 행에서 dedup이 깨진다.
# (표현식은 table-level UNIQUE에 사용 불가 → CREATE UNIQUE INDEX + COALESCE로 대체.
# 실제 컬럼값은 그대로 NULL 유지 — downstream get_market_deals_for/listing_matcher 영향 없음.)
conn.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS ux_market_deals_dedup ON market_deals(
dong_code, COALESCE(complex_name,''), area, deal_ym, deal_type,
COALESCE(deposit,-1), COALESCE(sale_amount,-1), COALESCE(floor,'')
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS listing_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
listing_id INTEGER UNIQUE REFERENCES listings(id) ON DELETE CASCADE,
category TEXT, passed INTEGER, match_score INTEGER,
jeonse_ratio REAL, safety_tier TEXT, price_ratio REAL, valuation_tier TEXT,
market_median INTEGER, sample_size INTEGER, budget_ok INTEGER,
regulation_flags TEXT, reasons TEXT, is_new INTEGER,
notified_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS listing_criteria (
id INTEGER PRIMARY KEY DEFAULT 1,
dongs TEXT NOT NULL DEFAULT '[]', deal_types TEXT NOT NULL DEFAULT '[]',
max_deposit INTEGER, max_sale_price INTEGER, min_area REAL,
house_types TEXT NOT NULL DEFAULT '[]', min_safety_tier TEXT,
notify_enabled INTEGER NOT NULL DEFAULT 1,
equity INTEGER, annual_income INTEGER,
is_homeless INTEGER NOT NULL DEFAULT 1, is_householder INTEGER NOT NULL DEFAULT 0,
is_first_home INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS listing_collect_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
new_count INTEGER, total_count INTEGER, naver_ok INTEGER,
error TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_md_lookup ON market_deals(dong_code, deal_type, area, deal_ym);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_listing_dong ON listings(dong, deal_type);")
# ── 상태 자동 계산 ─────────────────────────────────────────────────────────── # ── 상태 자동 계산 ───────────────────────────────────────────────────────────
@@ -841,3 +910,205 @@ def get_dashboard() -> Dict[str, Any]:
"upcoming_schedules": schedules, "upcoming_schedules": schedules,
"bookmarked": bookmarked_items, "bookmarked": bookmarked_items,
} }
# ── 매물 파이프라인 (listings/market_deals/listing_matches/listing_criteria) ──
_LISTING_CRITERIA_SEED = {
"dongs": ["신대방동", "대방동", "상도동", "봉천동", "대림동", "신길동"],
"deal_types": ["전세", "반전세", "매매"],
"max_deposit": 32000, "max_sale_price": None, "min_area": 40.0,
"house_types": ["아파트", "오피스텔", "빌라"], "min_safety_tier": None,
"notify_enabled": 1, "equity": None, "annual_income": None,
"is_homeless": 1, "is_householder": 0, "is_first_home": 0,
}
_LC_JSON_COLS = ("dongs", "deal_types", "house_types")
def get_listing_criteria() -> Dict[str, Any]:
with _conn() as conn:
row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
if row is None:
conn.execute(
"""INSERT INTO listing_criteria
(id,dongs,deal_types,max_deposit,max_sale_price,min_area,house_types,
min_safety_tier,notify_enabled,equity,annual_income,is_homeless,is_householder,is_first_home)
VALUES (1,:dongs,:deal_types,:max_deposit,:max_sale_price,:min_area,:house_types,
:min_safety_tier,:notify_enabled,:equity,:annual_income,:is_homeless,:is_householder,:is_first_home)""",
{**_LISTING_CRITERIA_SEED,
"dongs": json.dumps(_LISTING_CRITERIA_SEED["dongs"], ensure_ascii=False),
"deal_types": json.dumps(_LISTING_CRITERIA_SEED["deal_types"], ensure_ascii=False),
"house_types": json.dumps(_LISTING_CRITERIA_SEED["house_types"], ensure_ascii=False)},
)
row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
d = dict(row)
for c in _LC_JSON_COLS:
d[c] = json.loads(d[c] or "[]")
return d
def update_listing_criteria(fields: Dict[str, Any]) -> None:
if not fields:
return
sets, vals = [], []
for k, v in fields.items():
if v is None and k not in ("max_deposit", "max_sale_price", "equity", "annual_income", "min_safety_tier"):
continue
sets.append(f"{k}=?")
vals.append(json.dumps(v, ensure_ascii=False) if k in _LC_JSON_COLS else v)
if not sets:
return
sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
with _conn() as conn:
conn.execute("INSERT OR IGNORE INTO listing_criteria(id) VALUES(1)")
conn.execute(f"UPDATE listing_criteria SET {','.join(sets)} WHERE id=1", vals)
def upsert_listing(data: Dict[str, Any]) -> tuple:
cols = ("article_no", "source", "deal_type", "deposit", "monthly_rent", "sale_price",
"area_exclusive", "floor", "complex_name", "address", "dong", "dong_code",
"url", "posted_at", "raw_json")
d = {c: data.get(c) for c in cols}
with _conn() as conn:
exists = conn.execute("SELECT id FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
is_new = exists is None
conn.execute(f"""
INSERT INTO listings ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
ON CONFLICT(article_no) DO UPDATE SET
deposit=excluded.deposit, monthly_rent=excluded.monthly_rent, sale_price=excluded.sale_price,
floor=excluded.floor, complex_name=excluded.complex_name, address=excluded.address,
url=excluded.url, posted_at=excluded.posted_at, raw_json=excluded.raw_json
""", d)
row = conn.execute("SELECT * FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
return dict(row), is_new
def upsert_market_deal(data: Dict[str, Any]) -> None:
if data.get("area") is None:
# area 없는 실거래는 area BETWEEN 조회에서 절대 매칭 안 돼 median 계산에 무용하고,
# dedup 인덱스도 못 걸려(area가 NOT NULL 컬럼이 아니라 조건 매칭 불가) 무한 중복을 유발한다.
return
cols = ("house_type", "dong_code", "dong", "complex_name", "area", "deal_type",
"deposit", "monthly_rent", "sale_amount", "deal_ym", "floor", "source")
d = {c: data.get(c) for c in cols}
d.setdefault("source", "molit")
with _conn() as conn:
conn.execute(f"""INSERT OR IGNORE INTO market_deals ({','.join(cols)})
VALUES ({','.join(':'+c for c in cols)})""", d)
def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6) -> List[Dict[str, Any]]:
with _conn() as conn:
def _query(cn):
rows = conn.execute(
"""SELECT * FROM market_deals
WHERE dong_code=? AND deal_type=?
AND (complex_name=? OR ? IS NULL OR complex_name IS NULL)
AND area BETWEEN ? AND ?
AND deal_ym >= strftime('%Y%m', 'now', ?)""",
(dong_code, deal_type, cn, cn,
(area or 0) - 5, (area or 0) + 5, f"-{int(months)} months"),
).fetchall()
return [dict(r) for r in rows]
deals = _query(complex_name)
if len(deals) < _MARKET_SAMPLE_MIN and complex_name:
# 단지 표본이 부족하면 complex 제약을 풀고 같은 동/거래유형/면적±5/최근성으로
# 광역 재조회한다. 표본을 늘리기만 하므로 안전(잘못된 판정 유발 X).
deals = _query(None)
return deals
def upsert_listing_match(data: Dict[str, Any]) -> None:
cols = ("listing_id", "category", "passed", "match_score", "jeonse_ratio", "safety_tier",
"price_ratio", "valuation_tier", "market_median", "sample_size", "budget_ok",
"regulation_flags", "reasons", "is_new")
d = {c: data.get(c) for c in cols}
d["regulation_flags"] = json.dumps(data.get("regulation_flags") or [], ensure_ascii=False)
d["reasons"] = json.dumps(data.get("reasons") or [], ensure_ascii=False)
with _conn() as conn:
conn.execute(f"""
INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
ON CONFLICT(listing_id) DO UPDATE SET
passed=excluded.passed, match_score=excluded.match_score,
jeonse_ratio=excluded.jeonse_ratio, safety_tier=excluded.safety_tier,
price_ratio=excluded.price_ratio, valuation_tier=excluded.valuation_tier,
market_median=excluded.market_median, sample_size=excluded.sample_size,
budget_ok=excluded.budget_ok, regulation_flags=excluded.regulation_flags,
reasons=excluded.reasons, category=excluded.category
""", d)
def get_listings(dong=None, deal_type=None, tier=None, matched_only=False, limit=50, offset=0) -> List[Dict[str, Any]]:
sql = ("SELECT l.*, m.safety_tier, m.valuation_tier, m.category, m.match_score, m.passed "
"FROM listings l LEFT JOIN listing_matches m ON m.listing_id=l.id WHERE 1=1")
p = []
if dong: sql += " AND l.dong=?"; p.append(dong)
if deal_type: sql += " AND l.deal_type=?"; p.append(deal_type)
if tier: sql += " AND (m.safety_tier=? OR m.valuation_tier=?)"; p += [tier, tier]
if matched_only: sql += " AND m.passed=1"
sql += " ORDER BY l.created_at DESC LIMIT ? OFFSET ?"; p += [limit, offset]
with _conn() as conn:
return [dict(r) for r in conn.execute(sql, p).fetchall()]
def get_listing_matches() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.sale_price, l.area_exclusive, l.url "
"FROM listing_matches m JOIN listings l ON l.id=m.listing_id ORDER BY m.created_at DESC"
).fetchall()
out = []
for r in rows:
d = dict(r)
d["regulation_flags"] = json.loads(d.get("regulation_flags") or "[]")
d["reasons"] = json.loads(d.get("reasons") or "[]")
out.append(d)
return out
_TIER_ORDER = {"위험": 0, "고가": 0, "주의": 1, "시세": 1, "안전": 2, "저평가": 2, "보류": -1}
def get_unnotified_listing_matches(min_tier=None) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.monthly_rent, "
"l.sale_price, l.area_exclusive, l.floor, l.url "
"FROM listing_matches m JOIN listings l ON l.id=m.listing_id "
"WHERE m.passed=1 AND m.notified_at IS NULL"
).fetchall()
out = []
for r in rows:
d = dict(r)
d["regulation_flags"] = json.loads(d.get("regulation_flags") or "[]")
d["reasons"] = json.loads(d.get("reasons") or "[]")
out.append(d)
if min_tier:
floor = _TIER_ORDER.get(min_tier, -1)
out = [d for d in out if _TIER_ORDER.get(d.get("safety_tier") or d.get("valuation_tier"), -1) >= floor]
return out
def mark_listings_notified(match_ids: List[int]) -> None:
if not match_ids:
return
with _conn() as conn:
conn.executemany(
"UPDATE listing_matches SET notified_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
[(i,) for i in match_ids],
)
def save_listing_collect_log(new_count, total_count, naver_ok, error=None) -> None:
with _conn() as conn:
conn.execute(
"INSERT INTO listing_collect_log (new_count,total_count,naver_ok,error) VALUES (?,?,?,?)",
(new_count, total_count, 1 if naver_ok else 0, error),
)
def get_last_listing_collect_log() -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT * FROM listing_collect_log ORDER BY id DESC LIMIT 1").fetchone()
return dict(row) if row else None

View File

@@ -0,0 +1,67 @@
"""전세/매수 예산·규제 판정 (순수 함수, 외부 호출 없음)."""
from . import finance_rules_config as cfg
def region_regulation(dong: str) -> dict:
d = (dong or "").strip()
is_toheo = d in cfg.TOHEO_DONGS
is_regulated = d in cfg.REGULATED_DONGS or is_toheo
notes = []
if is_toheo:
notes.append("토지거래허가구역 — 매수 시 실거주 의무(2년)+허가 필요, 갭투자 금지")
if is_regulated:
notes.append("규제지역 — LTV 강화")
notes.append("토허/규제 여부는 서울시 최신 고시로 확인 필요")
return {"is_toheo": is_toheo, "is_regulated": is_regulated, "notes": " · ".join(notes)}
def _dsr_max_loan(annual_income: int) -> int:
"""DSR 한도 내 대출 원금 근사(만원). 원리금균등, 가정 금리·만기."""
if not annual_income or annual_income <= 0:
return 0
annual_pay_cap = annual_income * cfg.DSR_LIMIT_PCT / 100.0 # 연 상환 가능액
r = cfg.ASSUMED_RATE_PCT / 100.0 / 12
n = cfg.ASSUMED_TERM_YEARS * 12
monthly_cap = annual_pay_cap / 12.0
if r == 0:
return int(monthly_cap * n)
# PV = PMT * (1-(1+r)^-n)/r
pv = monthly_cap * (1 - (1 + r) ** (-n)) / r
return int(pv)
def estimate_jeonse_budget(equity: int, annual_income: int, jeonse_loan_ratio: float = None) -> dict:
equity = equity or 0
ratio = jeonse_loan_ratio if jeonse_loan_ratio is not None else cfg.JEONSE_LOAN_RATIO
# 전세대출 한도 = min(보증기관 절대상한, DSR 한도). 보증금 비례는 매물별로 matcher에서 재확인.
loan_limit = min(cfg.JEONSE_LOAN_CAP, _dsr_max_loan(annual_income) or cfg.JEONSE_LOAN_CAP)
max_deposit = equity + loan_limit
return {
"loan_limit": loan_limit,
"max_deposit": max_deposit,
"notes": f"전세대출 한도≈{loan_limit}만(보증기관·DSR 근사, 보증금 대비 {int(ratio*100)}% 이내). {cfg.DISCLAIMER}",
}
def estimate_purchase_budget(equity: int, annual_income: int, region_flags: dict,
is_first_home: bool, is_homeless: bool) -> dict:
equity = equity or 0
reg = "regulated" if (region_flags or {}).get("is_regulated") else "normal"
first = "first" if is_first_home else "normal"
ltv_pct = cfg.LTV_TABLE[(reg, first)]
# 대출가능액 = min(수도권 한도, DSR 한도). LTV는 매매가에 따라 matcher에서 상한 재확인.
dsr_cap = _dsr_max_loan(annual_income)
loan_cap = min(cfg.SUDOGWON_MORTGAGE_CAP, dsr_cap) if dsr_cap else cfg.SUDOGWON_MORTGAGE_CAP
max_price = equity + loan_cap
flags = []
if (region_flags or {}).get("is_toheo"):
flags.append("토허")
if (region_flags or {}).get("is_regulated"):
flags.append("규제지역")
return {
"ltv_pct": ltv_pct,
"loan_cap": loan_cap,
"max_price": max_price,
"dsr_note": f"DSR {cfg.DSR_LIMIT_PCT}% 한도 근사(금리{cfg.ASSUMED_RATE_PCT}{cfg.ASSUMED_TERM_YEARS}년)",
"regulation_flags": flags,
}

View File

@@ -0,0 +1,35 @@
"""정책 파라미터 (외부 호출 없음). ⚠️ 정부 정책 수시 변동 — 계약·대출 전 은행/서울시 고시로 최종 확인.
기준: 2025 6·27 대책(수도권 주담대 6억 한도) 등. 실제 집행 전 최신값으로 갱신."""
# 수도권 주택담보대출 총액 한도 (만원). 6·27 대책: 6억.
SUDOGWON_MORTGAGE_CAP = 60000
# LTV(%) — (규제지역 여부, 생애최초 여부) → 한도. 무주택 기준.
LTV_TABLE = {
("regulated", "first"): 70,
("regulated", "normal"): 50,
("normal", "first"): 80,
("normal", "normal"): 70,
}
# 스트레스 DSR 한도(%) — 연소득 대비 연원리금.
DSR_LIMIT_PCT = 40
# 대출 산정용 가정 금리(연%)·만기(년) — 원리금균등 근사.
ASSUMED_RATE_PCT = 4.5
ASSUMED_TERM_YEARS = 30
# 전세대출: 보증기관 보증비율(보증금 대비) 상한 근사.
JEONSE_LOAN_RATIO = 0.80
# 전세대출 절대 상한(만원) — 보증기관 한도 근사.
JEONSE_LOAN_CAP = 44000
# 토지거래허가구역(구·동 단위, 서울시 고시 기준, 수동 갱신). 소수만.
TOHEO_DONGS = {
"대치동", "삼성동", "청담동", "잠실동", # 강남·송파 국제교류복합지구권
"압구정동", "여의도동", "목동", "성수동", # 재건축 토허
"이촌동", "한남동", # 용산
}
# 규제지역(투기과열/조정) — 매수 LTV 강화 대상.
REGULATED_DONGS = set(TOHEO_DONGS) | {"반포동", "서초동"}
DISCLAIMER = "정책(LTV·DSR·수도권 한도·토허)은 수시 변동 — 계약·대출 전 은행/중개사/서울시 고시로 최종 확인."

View File

@@ -0,0 +1,24 @@
"""대상 동명 → 법정동코드(시군구 5자리, LAWD_CD). 국토부 실거래가 API 파라미터용.
동작구/관악구/영등포구 중심(스펙 초기 대상 동). 신규 동 추가 시 여기에.
출처: 행정표준코드관리시스템 시군구 코드."""
# 시군구(구) 단위 5자리 — 실거래가 API는 LAWD_CD=시군구코드를 받는다.
_DISTRICT_CODE = {
"동작구": "11590",
"관악구": "11620",
"영등포구": "11560",
"서초구": "11650",
"강남구": "11680",
}
# 동 → 소속 구
_DONG_TO_DISTRICT = {
"신대방동": "동작구", "대방동": "동작구", "상도동": "동작구", "노량진동": "동작구", "사당동": "동작구",
"봉천동": "관악구", "신림동": "관악구",
"신길동": "영등포구", "대림동": "영등포구", "영등포동": "영등포구",
}
LAWD_CODES = {dong: _DISTRICT_CODE[d] for dong, d in _DONG_TO_DISTRICT.items() if d in _DISTRICT_CODE}
def lawd_code(dong: str) -> str | None:
return LAWD_CODES.get((dong or "").strip())

View File

@@ -0,0 +1,187 @@
"""매물 수집: 국토부 실거래가(합법, market_deals) + 네이버부동산 호가(회색·폴백, listings)."""
import os
import re
import time
import logging
import requests
from datetime import date
from .lawd_codes import LAWD_CODES
from .db import (get_listing_criteria, upsert_listing, upsert_market_deal,
save_listing_collect_log)
logger = logging.getLogger("realestate-lab")
MOLIT_KEY = os.getenv("DATA_GO_KR_API_KEY", "")
NAVER_BASE = "https://new.land.naver.com/api"
# MOLIT operation ID (스펙 §3, 라이브 확인 필요). house_type → (rent_op, trade_op)
MOLIT_OPS = {
"아파트": ("getRTMSDataSvcAptRent", "getRTMSDataSvcAptTradeDev"),
"오피스텔": ("getRTMSDataSvcOffiRent", "getRTMSDataSvcOffiTrade"),
"연립다세대": ("getRTMSDataSvcRHRent", "getRTMSDataSvcRHTrade"),
}
_HOUSE_TYPE_ALIAS = {"빌라": "연립다세대"}
def _won_to_manwon(s) -> int | None:
"""'43,000' 또는 '2억 9,000' → 만원 정수."""
if s is None:
return None
t = str(s).replace(",", "").strip()
if not t:
return None
m = re.match(r"(?:(\d+)억)?\s*(\d+)?", t)
if m and (m.group(1) or m.group(2)):
eok = int(m.group(1)) if m.group(1) else 0
man = int(m.group(2)) if m.group(2) else 0
return eok * 10000 + man
try:
return int(float(t))
except ValueError:
return None
def _parse_molit_rent(raw: dict, house_type: str) -> dict:
deposit = _won_to_manwon(raw.get("deposit"))
rent = _won_to_manwon(raw.get("monthlyRent"))
return {"house_type": house_type, "complex_name": (raw.get("aptNm") or raw.get("offiNm") or "").strip(),
"area": float(raw.get("excluUseAr") or 0) or None, "deal_type": "전세" if not rent else "월세",
"deposit": deposit, "monthly_rent": rent, "sale_amount": None,
"deal_ym": f"{raw.get('dealYear','')}{str(raw.get('dealMonth','')).zfill(2)}",
"floor": str(raw.get("floor") or ""), "source": "molit"}
def _parse_molit_trade(raw: dict, house_type: str) -> dict:
return {"house_type": house_type, "complex_name": (raw.get("aptNm") or raw.get("offiNm") or "").strip(),
"area": float(raw.get("excluUseAr") or 0) or None, "deal_type": "매매",
"deposit": None, "monthly_rent": None,
"sale_amount": _won_to_manwon(raw.get("dealAmount")),
"deal_ym": f"{raw.get('dealYear','')}{str(raw.get('dealMonth','')).zfill(2)}",
"floor": str(raw.get("floor") or ""), "source": "molit"}
def _parse_naver_article(raw: dict) -> dict:
dt = raw.get("tradeTypeName") or ""
prc = _won_to_manwon(raw.get("dealOrWarrantPrc"))
rent = _won_to_manwon(raw.get("rentPrc"))
is_sale = dt == "매매"
return {"article_no": str(raw.get("articleNo") or ""), "source": "naver", "deal_type": dt,
"deposit": None if is_sale else prc, "monthly_rent": rent,
"sale_price": prc if is_sale else None,
"area_exclusive": float(raw.get("area2") or raw.get("areaName") or 0) or None,
"floor": raw.get("floorInfo"), "complex_name": raw.get("articleName") or raw.get("buildingName"),
"dong_code": (raw.get("cortarNo") or "")[:5] or None,
"url": f"https://new.land.naver.com/houses?articleNo={raw.get('articleNo')}",
"raw_json": None}
def _molit_call(op: str, lawd: str, ym: str):
"""국토부 실거래 단건 호출 (재시도). Returns (items, diag).
diag=None on success, else 짧은 진단 문자열(http=401 / json_err / req_err=...) — 관측용."""
if not MOLIT_KEY:
return [], "no_key"
url = f"https://apis.data.go.kr/1613000/RTMSDataSvc{op[len('getRTMSDataSvc'):]}/{op}"
last_diag = None
for attempt in range(3):
try:
resp = requests.get(url, params={"serviceKey": MOLIT_KEY, "LAWD_CD": lawd,
"DEAL_YMD": ym, "numOfRows": 200, "_type": "json"}, timeout=30)
resp.raise_for_status()
body = resp.json()
items = (((body.get("response") or {}).get("body") or {}).get("items") or {}).get("item") or []
return (items if isinstance(items, list) else [items]), None
except requests.HTTPError as e:
code = getattr(getattr(e, "response", None), "status_code", "?")
last_diag = f"http={code}"
except requests.RequestException as e:
last_diag = f"req_err={type(e).__name__}"
except ValueError:
# 200이지만 JSON 아님(XML 오류 응답 등) — 재시도 무의미
last_diag = "json_err"
break
if attempt < 2:
time.sleep(2 ** attempt)
logger.error("MOLIT %s 실패: %s", op, last_diag)
return [], last_diag
def _fetch_molit_all():
"""대상 동 시군구코드 × 유형 × 최근 2개월 전월세·매매 실거래 수집→market_deals.
Returns (saved_count, diag). diag = 'calls=N saved=M [err=<첫에러>]' 관측용."""
saved = 0
calls = 0
first_err = None
lawds = set(LAWD_CODES.values())
today = date.today()
yms = [today.strftime("%Y%m"),
(today.replace(day=1) - __import__("datetime").timedelta(days=1)).strftime("%Y%m")]
for lawd in lawds:
for htype, (rent_op, trade_op) in MOLIT_OPS.items():
for ym in yms:
for op, parser in ((rent_op, _parse_molit_rent), (trade_op, _parse_molit_trade)):
items, diag = _molit_call(op, lawd, ym)
calls += 1
if diag and first_err is None:
first_err = diag
for raw in items:
try:
d = parser(raw, htype)
d["dong_code"] = lawd
d["dong"] = (raw.get("umdNm") or "").strip() or None
upsert_market_deal(d)
saved += 1
except Exception as e:
logger.warning("MOLIT 파싱 실패: %s", e)
time.sleep(0.3)
diag = f"calls={calls} saved={saved}" + (f" err={first_err}" if first_err else "")
return saved, diag
def _fetch_naver_all() -> tuple:
"""대상 동 네이버 호가 매물 수집→listings. (new_count, total_count) 반환. 예외는 상위로 전파."""
new_count = total = 0
crit = get_listing_criteria()
for dong in crit.get("dongs", []):
code = LAWD_CODES.get(dong)
if not code:
continue
# 저빈도: 동당 1회, UA/Referer 현실값. 응답 articleList 순회.
resp = requests.get(f"{NAVER_BASE}/articles",
params={"cortarNo": code, "order": "dateDesc", "page": 1},
headers={"User-Agent": "Mozilla/5.0", "Referer": "https://new.land.naver.com/"},
timeout=20)
resp.raise_for_status()
for raw in (resp.json().get("articleList") or []):
try:
d = _parse_naver_article(raw)
if not d["article_no"]:
continue
d["dong"] = dong
_, is_new = upsert_listing(d)
total += 1
if is_new:
new_count += 1
except Exception as e:
logger.warning("네이버 파싱 실패: %s", e)
time.sleep(1.5)
return new_count, total
def collect_listings() -> dict:
naver_ok = True
new_count = total = 0
molit_diag = naver_diag = None
try:
_, molit_diag = _fetch_molit_all() # 실거래(합법)는 항상 우선 — 안전마진 baseline
except Exception as e:
molit_diag = f"exc={type(e).__name__}:{str(e)[:80]}"
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
try:
new_count, total = _fetch_naver_all()
except requests.RequestException as e:
naver_ok = False
naver_diag = f"{type(e).__name__}:{str(e)[:80]}"
logger.warning("네이버 매물수집 skip(차단/오류) — 안전마진·실거래는 유지: %s", e)
diag = f"molit[{molit_diag}] naver[{naver_diag or 'ok'}]"
save_listing_collect_log(new_count, total, naver_ok, error=diag)
return {"new_count": new_count, "total_count": total, "naver_ok": naver_ok, "diag": diag}

View File

@@ -0,0 +1,114 @@
"""매물 조건 매칭 + 임차 안전마진(전세가율)·매매 적정성(호가율) 판정."""
import logging
import statistics
from .db import (get_listing_criteria, get_market_deals_for, upsert_listing_match,
get_listings)
from . import finance_rules
logger = logging.getLogger("realestate-lab")
# 경계 상수(조정 가능)
JEONSE_SAFE, JEONSE_WARN = 0.70, 0.80
VALUATION_LOW, VALUATION_HIGH = 0.97, 1.05
MIN_SAMPLE = 3
DISCLAIMER = "등기부 선순위 근저당은 자동 확인 불가 — 계약 전 인터넷등기소 열람 필수."
def compute_safety(deposit, deal_type, deals) -> dict:
vals = [d["deposit"] for d in deals if d.get("deposit")]
if len(vals) < MIN_SAMPLE:
return {"jeonse_ratio": None, "tier": "보류", "median": None, "sample": len(vals)}
median = statistics.median(vals)
ratio = (deposit or 0) / median if median else None
if ratio is None:
tier = "보류"
elif ratio <= JEONSE_SAFE:
tier = "안전"
elif ratio <= JEONSE_WARN:
tier = "주의"
else:
tier = "위험"
return {"jeonse_ratio": round(ratio, 3) if ratio else None,
"tier": tier, "median": int(median), "sample": len(vals)}
def compute_valuation(sale_price, deals) -> dict:
vals = [d["sale_amount"] for d in deals if d.get("sale_amount")]
if len(vals) < MIN_SAMPLE:
return {"price_ratio": None, "tier": "보류", "median": None, "sample": len(vals)}
median = statistics.median(vals)
ratio = (sale_price or 0) / median if median else None
if ratio is None:
tier = "보류"
elif ratio <= VALUATION_LOW:
tier = "저평가"
elif ratio <= VALUATION_HIGH:
tier = "시세"
else:
tier = "고가"
return {"price_ratio": round(ratio, 3) if ratio else None,
"tier": tier, "median": int(median), "sample": len(vals)}
def match_listing(criteria, listing, budget) -> dict:
reasons, passed = [], True
if criteria.get("dongs") and listing.get("dong") not in criteria["dongs"]:
passed = False; reasons.append("동 불일치")
if criteria.get("deal_types") and listing.get("deal_type") not in criteria["deal_types"]:
passed = False; reasons.append("거래유형 불일치")
if criteria.get("min_area") and (listing.get("area_exclusive") or 0) < criteria["min_area"]:
passed = False; reasons.append("면적 미달")
if listing.get("deal_type") == "매매":
cap = criteria.get("max_sale_price") or (budget or {}).get("max_price")
if cap and (listing.get("sale_price") or 0) > cap:
passed = False; reasons.append("매수가능 상한 초과")
else: # 임차
cap = criteria.get("max_deposit") or (budget or {}).get("max_deposit")
if cap and (listing.get("deposit") or 0) > cap:
passed = False; reasons.append("보증금 상한 초과")
score = 100 if passed else 0
if passed:
reasons.append("조건 통과")
return {"passed": passed, "score": score, "reasons": reasons}
def run_listing_matching() -> None:
"""미평가 포함 전체 매물 재평가 → listing_matches upsert (청약 run_matching 대칭)."""
criteria = get_listing_criteria()
equity = criteria.get("equity") or 0
income = criteria.get("annual_income") or 0
jeonse_budget = finance_rules.estimate_jeonse_budget(equity, income)
for l in get_listings(limit=1000):
is_rent = l["deal_type"] != "매매"
reg = finance_rules.region_regulation(l.get("dong"))
if is_rent:
budget = jeonse_budget
else:
budget = finance_rules.estimate_purchase_budget(
equity, income, reg, criteria.get("is_first_home"), criteria.get("is_homeless"))
m = match_listing(criteria, l, budget)
deals = get_market_deals_for(l.get("dong_code"), l.get("complex_name"),
l.get("area_exclusive"), "전세" if is_rent else "매매")
rec = {"listing_id": l["id"], "category": "임차" if is_rent else "매매",
"passed": 1 if m["passed"] else 0, "match_score": m["score"],
"sample_size": 0, "reasons": m["reasons"], "is_new": 1,
"regulation_flags": reg_flags(reg)}
if is_rent:
s = compute_safety(l.get("deposit"), l["deal_type"], deals)
rec.update({"jeonse_ratio": s["jeonse_ratio"], "safety_tier": s["tier"],
"market_median": s["median"], "sample_size": s["sample"]})
else:
v = compute_valuation(l.get("sale_price"), deals)
rec.update({"price_ratio": v["price_ratio"], "valuation_tier": v["tier"],
"market_median": v["median"], "sample_size": v["sample"],
"budget_ok": 1 if (l.get("sale_price") or 0) <= (budget.get("max_price") or 0) else 0})
upsert_listing_match(rec)
logger.info("매물 매칭 완료")
def reg_flags(reg: dict) -> list:
f = []
if reg.get("is_toheo"): f.append("토허")
if reg.get("is_regulated"): f.append("규제지역")
return f

View File

@@ -15,11 +15,20 @@ from .db import (
get_profile, upsert_profile, get_matches, mark_match_read, get_profile, upsert_profile, get_matches, mark_match_read,
get_last_collect_log, get_dashboard, get_last_collect_log, get_dashboard,
delete_old_completed_announcements, delete_old_completed_announcements,
get_listing_criteria, update_listing_criteria, get_listings, get_listing_matches,
get_last_listing_collect_log, get_market_deals_for,
) )
from .collector import collect_all from .collector import collect_all
from .matcher import run_matching from .matcher import run_matching
from .notifier import notify_new_matches from .notifier import notify_new_matches, notify_new_listings
from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate from .listing_collector import collect_listings
from .listing_matcher import run_listing_matching, compute_safety, compute_valuation
from . import finance_rules
from .finance_rules_config import DISCLAIMER
from .models import (
AnnouncementCreate, AnnouncementUpdate, ProfileUpdate,
ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("realestate-lab") logger = logging.getLogger("realestate-lab")
@@ -56,12 +65,29 @@ def scheduled_status_update():
logger.info("상태 갱신 + 재매칭 완료") logger.info("상태 갱신 + 재매칭 완료")
_listing_collect_lock = threading.Lock()
def _run_listing_pipeline():
if not _listing_collect_lock.acquire(blocking=False):
logger.info("매물 수집 이미 진행 중 — 건너뜀"); return
try:
collect_listings(); run_listing_matching(); notify_new_listings()
finally:
_listing_collect_lock.release()
def scheduled_listing_collect():
logger.info("매물 스케줄 수집 시작"); _run_listing_pipeline()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db() init_db()
# 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A) # 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A)
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect") scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update") scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
scheduler.add_job(scheduled_listing_collect, "cron", hour="8,11,14,17,20", minute=40, id="listing_collect")
scheduler.start() scheduler.start()
logger.info("realestate-lab 시작") logger.info("realestate-lab 시작")
yield yield
@@ -217,3 +243,66 @@ def api_match_read(match_id: int):
@app.get("/api/realestate/dashboard") @app.get("/api/realestate/dashboard")
def api_dashboard(): def api_dashboard():
return get_dashboard() return get_dashboard()
# ── 매물 API ─────────────────────────────────────────────────────────────────
@app.get("/api/realestate/listings")
def api_listings(dong: str = None, deal_type: str = None, tier: str = None,
matched_only: bool = False, page: int = 1, size: int = 50):
return {"listings": get_listings(dong, deal_type, tier, matched_only, size, (page - 1) * size)}
@app.post("/api/realestate/listings/collect")
def api_listings_collect(background_tasks: BackgroundTasks):
background_tasks.add_task(_run_listing_pipeline)
return {"ok": True, "message": "매물 수집 시작됨"}
@app.get("/api/realestate/listings/collect/status")
def api_listings_collect_status():
return get_last_listing_collect_log() or {"status": "never_run"}
@app.get("/api/realestate/listings/criteria")
def api_get_listing_criteria():
return get_listing_criteria()
@app.put("/api/realestate/listings/criteria")
def api_put_listing_criteria(req: ListingCriteriaUpdate):
update_listing_criteria(req.model_dump(exclude_none=True))
return get_listing_criteria()
@app.get("/api/realestate/listings/matches")
def api_listing_matches():
return {"matches": get_listing_matches()}
@app.post("/api/realestate/safety-check")
def api_safety_check(req: SafetyCheckRequest):
deals = get_market_deals_for(req.dong_code, req.complex_name, req.area,
"전세" if req.deal_type != "매매" else "매매")
reg = finance_rules.region_regulation(req.dong) # dong명(없으면 토허 판정 생략)
if req.deal_type == "매매":
res = compute_valuation(req.amount, deals)
ratio_key = "price_ratio"
else:
res = compute_safety(req.amount, req.deal_type, deals)
ratio_key = "jeonse_ratio"
return {"median": res["median"], "ratio": res.get(ratio_key), "tier": res["tier"],
"sample": res["sample"], "is_toheo": reg["is_toheo"],
"disclaimer": "등기부 선순위·토허 허가·실거주 의무는 계약 전 수동 확인 필수. " + DISCLAIMER}
@app.post("/api/realestate/budget")
def api_budget(req: BudgetRequest):
reg = finance_rules.region_regulation(req.target_dong)
return {
"jeonse": finance_rules.estimate_jeonse_budget(req.equity, req.annual_income),
"purchase": finance_rules.estimate_purchase_budget(req.equity, req.annual_income, reg,
req.is_first_home, req.is_homeless),
"region": reg,
"disclaimer": DISCLAIMER,
}

View File

@@ -84,3 +84,38 @@ class ProfileUpdate(BaseModel):
preferred_districts: Optional[Dict[str, List[str]]] = None preferred_districts: Optional[Dict[str, List[str]]] = None
min_match_score: Optional[int] = Field(default=None, ge=0, le=100) min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
notify_enabled: Optional[bool] = None notify_enabled: Optional[bool] = None
class ListingCriteriaUpdate(BaseModel):
dongs: Optional[List[str]] = None
deal_types: Optional[List[str]] = None
max_deposit: Optional[int] = None
max_sale_price: Optional[int] = None
min_area: Optional[float] = None
house_types: Optional[List[str]] = None
min_safety_tier: Optional[str] = None
notify_enabled: Optional[bool] = None
equity: Optional[int] = None
annual_income: Optional[int] = None
is_homeless: Optional[bool] = None
is_householder: Optional[bool] = None
is_first_home: Optional[bool] = None
class SafetyCheckRequest(BaseModel):
complex_name: Optional[str] = None
address: Optional[str] = None
dong: Optional[str] = None
dong_code: Optional[str] = None
area: float
deal_type: str
amount: int
class BudgetRequest(BaseModel):
equity: int
annual_income: int
is_homeless: bool = True
is_householder: bool = False
is_first_home: bool = False
target_dong: Optional[str] = None

View File

@@ -3,7 +3,8 @@ import os
import logging import logging
import requests import requests
from .db import get_profile, get_unnotified_matches, mark_matches_notified from .db import (get_profile, get_unnotified_matches, mark_matches_notified,
get_listing_criteria, get_unnotified_listing_matches, mark_listings_notified)
logger = logging.getLogger("realestate-lab") logger = logging.getLogger("realestate-lab")
@@ -44,3 +45,27 @@ def notify_new_matches() -> dict:
mark_matches_notified(sent_ids) mark_matches_notified(sent_ids)
logger.info("알림 송신: %d", len(sent_ids)) logger.info("알림 송신: %d", len(sent_ids))
return body return body
def notify_new_listings() -> dict:
"""임계 통과 미알림 매물을 agent-office로 push (notify_new_matches 대칭).
passed 매물은 전 안전등급 알림(위험도 회피 목적 유용). criteria.min_safety_tier 설정 시 그 이상만."""
crit = get_listing_criteria()
if not crit.get("notify_enabled"):
return {"sent": 0, "skipped": "notify_disabled"}
listings = get_unnotified_listing_matches(min_tier=crit.get("min_safety_tier"))
if not listings:
return {"sent": 0}
url = f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify-listing"
try:
resp = requests.post(url, json={"listings": listings}, timeout=NOTIFY_TIMEOUT_SECONDS)
resp.raise_for_status()
body = resp.json()
except requests.RequestException as e:
logger.error("agent-office 매물 push 실패: %s", e)
return {"sent": 0, "error": str(e)}
sent_ids = body.get("sent_ids") or []
if sent_ids:
mark_listings_notified(sent_ids)
logger.info("매물 알림 송신: %d", len(sent_ids))
return body

View File

@@ -16,6 +16,11 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 테이블 목록 — init_db가 생성하는 모든 테이블 # 테이블 목록 — init_db가 생성하는 모든 테이블
_USER_TABLES = ( _USER_TABLES = (
"listing_matches", # FK CASCADE 대비 자식 테이블 먼저
"listings",
"market_deals",
"listing_criteria",
"listing_collect_log",
"match_results", # FK CASCADE 대비 자식 테이블 먼저 "match_results", # FK CASCADE 대비 자식 테이블 먼저
"announcement_models", "announcement_models",
"announcements", "announcements",

View File

@@ -0,0 +1,21 @@
def test_region_regulation_toheo_and_normal():
from app.finance_rules import region_regulation
assert region_regulation("대치동")["is_toheo"] is True # 강남 토허
r = region_regulation("신대방동")
assert r["is_toheo"] is False and "확인" in r["notes"]
def test_jeonse_budget_equity_plus_loan():
from app.finance_rules import estimate_jeonse_budget
b = estimate_jeonse_budget(equity=15000, annual_income=6000) # 자기자금 1.5억
assert b["max_deposit"] == b["loan_limit"] + 15000
assert b["loan_limit"] > 0
def test_purchase_budget_seoul_cap_applies():
from app.finance_rules import region_regulation, estimate_purchase_budget
flags = region_regulation("상도동")
b = estimate_purchase_budget(equity=30000, annual_income=8000,
region_flags=flags, is_first_home=False, is_homeless=True)
# 수도권 주담대 한도(6·27 대책 6억=60000만원) 캡이 대출가능액에 반영
assert b["loan_cap"] <= 60000
assert b["max_price"] == b["loan_cap"] + 30000
assert 0 < b["ltv_pct"] <= 100

View File

@@ -0,0 +1,6 @@
def test_lawd_code_maps_target_dongs():
from app.lawd_codes import lawd_code
assert lawd_code("신대방동") == "11590" # 동작구
assert lawd_code("봉천동") == "11620" # 관악구
assert lawd_code("신길동") == "11560" # 영등포구
assert lawd_code("없는동") is None

View File

@@ -0,0 +1,33 @@
from fastapi.testclient import TestClient
def _client():
from app.main import app
return TestClient(app)
def test_criteria_get_default_and_put():
c = _client()
assert c.get("/api/realestate/listings/criteria").json()["max_deposit"] == 32000
c.put("/api/realestate/listings/criteria", json={"max_deposit": 30000})
assert c.get("/api/realestate/listings/criteria").json()["max_deposit"] == 30000
def test_budget_endpoint():
r = _client().post("/api/realestate/budget", json={"equity": 30000, "annual_income": 8000,
"is_homeless": True, "is_householder": True, "is_first_home": False, "target_dong": "상도동"})
b = r.json()
assert "jeonse" in b and "purchase" in b and "region" in b
assert b["purchase"]["max_price"] == b["purchase"]["loan_cap"] + 30000
def test_safety_check_rent():
from app import db
for v in (40000, 42000, 44000):
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
"deposit": v, "sale_amount": None, "deal_ym": "202606", "floor": "5"})
r = _client().post("/api/realestate/safety-check",
json={"complex_name": "OO", "area": 42.0, "deal_type": "전세",
"amount": 28000, "dong_code": "11590"})
j = r.json()
assert j["tier"] == "안전" and "disclaimer" in j
def test_listings_collect_status():
assert _client().get("/api/realestate/listings/collect/status").status_code == 200

View File

@@ -0,0 +1,55 @@
from unittest.mock import patch, MagicMock
def test_parse_molit_rent_fields():
from app.listing_collector import _parse_molit_rent
raw = {"aptNm": "OO아파트", "excluUseAr": "42.5", "deposit": "43,000",
"monthlyRent": "0", "dealYear": "2026", "dealMonth": "5", "floor": "5"}
d = _parse_molit_rent(raw, "아파트")
assert d["complex_name"] == "OO아파트" and d["area"] == 42.5
assert d["deposit"] == 43000 and d["deal_type"] == "전세" and d["deal_ym"] == "202605"
def test_parse_naver_article_fields():
from app.listing_collector import _parse_naver_article
raw = {"articleNo": "A123", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
"areaName": "42", "area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
"buildingName": "OO", "cortarNo": "1159010100"}
d = _parse_naver_article(raw)
assert d["article_no"] == "A123" and d["deal_type"] == "전세" and d["deposit"] == 29000
assert d["source"] == "naver"
def test_collect_naver_block_sets_naver_ok_false(monkeypatch):
"""네이버 403 → 매물수집 skip + naver_ok=False, 실거래는 계속."""
from app import listing_collector as lc
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (2, "calls=1 saved=2"))
def boom(*a, **k):
import requests
raise requests.RequestException("403")
monkeypatch.setattr(lc, "_fetch_naver_all", boom)
r = lc.collect_listings()
assert r["naver_ok"] is False # 안전마진(실거래)은 유지
def test_molit_call_returns_diag_on_http_error(monkeypatch):
"""MOLIT 401 등 HTTP 오류 → ([], diag) 반환, diag에 상태코드 포함(관측)."""
from app import listing_collector as lc
import requests
monkeypatch.setattr(lc, "MOLIT_KEY", "TESTKEY")
monkeypatch.setattr(lc.time, "sleep", lambda *a: None) # 재시도 대기 skip
resp = MagicMock()
resp.status_code = 401
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
monkeypatch.setattr(lc.requests, "get", lambda *a, **k: resp)
items, diag = lc._molit_call("getRTMSDataSvcAptTradeDev", "11590", "202606")
assert items == []
assert diag and "401" in diag
def test_collect_writes_molit_diag_to_log(monkeypatch):
"""collect_listings가 MOLIT/Naver 진단을 collect_log.error에 기록(silent-failure 관측)."""
from app import listing_collector as lc
from app import db
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (0, "calls=36 saved=0 err=http=401"))
monkeypatch.setattr(lc, "_fetch_naver_all", lambda: (0, 0))
lc.collect_listings()
log = db.get_last_listing_collect_log()
assert log is not None
assert "http=401" in (log["error"] or "")
assert "molit" in (log["error"] or "")

View File

@@ -0,0 +1,90 @@
def test_listing_criteria_seed_and_update():
from app import db
c = db.get_listing_criteria()
assert c["id"] == 1 and "신대방동" in c["dongs"] and c["max_deposit"] == 32000
db.update_listing_criteria({"max_deposit": 30000, "equity": 15000})
assert db.get_listing_criteria()["max_deposit"] == 30000
assert db.get_listing_criteria()["equity"] == 15000
def test_upsert_listing_idempotent_and_is_new():
from app import db
row = {"article_no": "A1", "source": "naver", "deal_type": "전세", "deposit": 29000,
"area_exclusive": 42.0, "complex_name": "OO", "dong": "신대방동", "dong_code": "11590"}
_, is_new = db.upsert_listing(row)
assert is_new is True
_, is_new2 = db.upsert_listing({**row, "deposit": 28000})
assert is_new2 is False
ls = db.get_listings()
assert len(ls) == 1 and ls[0]["deposit"] == 28000
def test_market_deals_dedup_and_query():
from app import db
base = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
"deposit": 43000, "sale_amount": None, "deal_ym": "202605", "floor": "5", "source": "molit"}
db.upsert_market_deal(base)
db.upsert_market_deal(base) # 동일 → dedup
deals = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120)
assert len(deals) == 1
def test_unnotified_listing_match_and_mark():
from app import db
lid, _ = db.upsert_listing({"article_no": "A2", "source": "naver", "deal_type": "전세",
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
db.upsert_listing_match({"listing_id": lid[0] if isinstance(lid, tuple) else lid["id"],
"category": "임차", "passed": 1, "match_score": 90,
"safety_tier": "안전", "sample_size": 7, "reasons": ["ok"], "is_new": 1})
un = db.get_unnotified_listing_matches()
assert len(un) == 1
db.mark_listings_notified([un[0]["id"]])
assert db.get_unnotified_listing_matches() == []
# ── 최종 리뷰 fix (M1/M3/M4) ─────────────────────────────────────────────────
def test_market_deals_broad_fallback_when_complex_sample_thin():
"""complex_name 지정 시 표본이 MIN_SAMPLE(3) 미만이면 complex 제약을 풀고
같은 dong_code/deal_type/area±5/recency 조건으로 광역 재조회해야 한다."""
from app import db
base = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
"area": 42.0, "deal_type": "전세", "sale_amount": None, "floor": "5", "source": "molit"}
# complex "A" 단독 1건 (< MIN_SAMPLE=3)
db.upsert_market_deal({**base, "complex_name": "A", "deposit": 30000, "deal_ym": "202601"})
# complex "B" 3건 (dedup 인덱스 회피 위해 deal_ym 다르게)
db.upsert_market_deal({**base, "complex_name": "B", "deposit": 31000, "deal_ym": "202602"})
db.upsert_market_deal({**base, "complex_name": "B", "deposit": 32000, "deal_ym": "202603"})
db.upsert_market_deal({**base, "complex_name": "B", "deposit": 33000, "deal_ym": "202604"})
deals = db.get_market_deals_for("11590", "A", 42.0, "전세", months=120)
assert len(deals) == 4 # A 1건 + B 3건 광역 폴백
def test_upsert_market_deal_area_null_skipped():
"""area=None인 실거래는 median 계산에 무용 + dedup 인덱스도 못 걸려 무한중복
유발하므로 저장 자체를 skip해야 한다."""
from app import db
d = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
"complex_name": "OO", "area": None, "deal_type": "전세",
"deposit": 43000, "sale_amount": None, "deal_ym": "202605", "floor": "5", "source": "molit"}
db.upsert_market_deal(d)
db.upsert_market_deal(d) # 재호출해도 여전히 저장 안 됨(무한중복 방지 확인)
with db._conn() as conn:
cnt = conn.execute("SELECT COUNT(*) FROM market_deals").fetchone()[0]
assert cnt == 0
def test_get_listing_matches_json_parsed():
"""get_listing_matches()도 get_unnotified_listing_matches()와 동일하게
regulation_flags/reasons를 list로 파싱해 반환해야 한다."""
from app import db
lid, _ = db.upsert_listing({"article_no": "A3", "source": "naver", "deal_type": "전세",
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
db.upsert_listing_match({"listing_id": lid["id"], "category": "임차", "passed": 1,
"match_score": 90, "safety_tier": "안전", "sample_size": 7,
"regulation_flags": ["토지거래허가구역"], "reasons": ["ok"], "is_new": 1})
matches = db.get_listing_matches()
assert len(matches) == 1
assert isinstance(matches[0]["regulation_flags"], list)
assert matches[0]["regulation_flags"] == ["토지거래허가구역"]
assert isinstance(matches[0]["reasons"], list)
assert matches[0]["reasons"] == ["ok"]

View File

@@ -0,0 +1,29 @@
import statistics
def _deals(vals, key="deposit", dt="전세"):
return [{key: v, "deal_type": dt} for v in vals]
def test_compute_safety_tiers():
from app.listing_matcher import compute_safety
deals = _deals([40000, 42000, 44000, 43000]) # median ~42500
assert compute_safety(28000, "전세", deals)["tier"] == "안전" # 0.66
assert compute_safety(32000, "전세", deals)["tier"] == "주의" # 0.75
assert compute_safety(38000, "전세", deals)["tier"] == "위험" # 0.89
assert compute_safety(28000, "전세", _deals([40000, 41000]))["tier"] == "보류" # 표본<3
def test_compute_valuation_tiers():
from app.listing_matcher import compute_valuation
deals = _deals([80000, 88000, 90000, 87000], key="sale_amount", dt="매매") # median ~87500
assert compute_valuation(83000, deals)["tier"] == "저평가" # 0.95
assert compute_valuation(89000, deals)["tier"] == "시세" # 1.02
assert compute_valuation(95000, deals)["tier"] == "고가" # 1.09
def test_match_listing_rent_deposit_cap():
from app.listing_matcher import match_listing
crit = {"dongs": ["신대방동"], "deal_types": ["전세"], "max_deposit": 32000,
"min_area": 40.0, "house_types": ["아파트"]}
ok = match_listing(crit, {"deal_type": "전세", "deposit": 29000, "area_exclusive": 42.0,
"dong": "신대방동"}, budget={"max_deposit": 45000})
assert ok["passed"] is True
over = match_listing(crit, {"deal_type": "전세", "deposit": 40000, "area_exclusive": 42.0,
"dong": "신대방동"}, budget={"max_deposit": 45000})
assert over["passed"] is False

View File

@@ -0,0 +1,28 @@
from unittest.mock import patch, MagicMock
def _seed_passed_match():
from app import db
row, _ = db.upsert_listing({"article_no": "N1", "source": "naver", "deal_type": "전세",
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
db.upsert_listing_match({"listing_id": row["id"], "category": "임차", "passed": 1,
"match_score": 100, "safety_tier": "안전", "sample_size": 7,
"reasons": ["ok"], "is_new": 1})
return db.get_unnotified_listing_matches()[0]["id"]
def test_notify_listings_pushes_and_marks():
from app import notifier, db
mid = _seed_passed_match()
fake = MagicMock(); fake.json.return_value = {"sent": 1, "sent_ids": [mid]}; fake.raise_for_status.return_value = None
with patch.object(notifier.requests, "post", return_value=fake) as post:
res = notifier.notify_new_listings()
assert post.call_count == 1
assert db.get_unnotified_listing_matches() == [] # 마킹됨
def test_notify_listings_no_mark_on_failure():
from app import notifier, db
import requests as rq
mid = _seed_passed_match()
with patch.object(notifier.requests, "post", side_effect=rq.RequestException("down")):
notifier.notify_new_listings()
assert len(db.get_unnotified_listing_matches()) == 1 # 미마킹→재시도

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,44 @@ 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일). 조건별 근거(reason) 문자열 포함 — FE가 detail 객체 대신 렌더."""
alerts = get_alert_history(days)
for a in alerts:
a["reason"] = trade_alerts.condition_reason(a.get("condition", ""))
return {"alerts": alerts}
# --- Holdings Intelligence API --- # --- Holdings Intelligence API ---
@app.get("/api/stock/holdings/intel") @app.get("/api/stock/holdings/intel")

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

@@ -0,0 +1,156 @@
"""매매 알람 — 감시대상(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}
# 조건별 "왜 매수/매도인가" 한 줄 근거 — agent-office `telegram_trade._COND_REASON`과 동일 유지(sync).
# API(/api/stock/trade-alerts)가 이 문자열을 반환해 FE가 detail(객체) 대신 안전하게 렌더한다.
_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 condition_reason(condition: str) -> str:
"""조건 키 → 사람이 읽는 근거 한 줄. 미지의 조건이면 조건 키 그대로."""
return _COND_REASON.get(condition, condition)
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,96 @@
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_trade_alerts_history_includes_reason_string(client):
"""이력 API가 조건별 사람이 읽는 근거(reason) 문자열을 함께 반환한다(FE가 detail 객체 대신 렌더)."""
firing = [{"ticker": "005930", "name": "삼성", "kind": "sell",
"condition": "sell_ma_break", "price": 60000,
"detail": {"ma50": 1, "ma200": 2, "severity": "high"}}]
with patch("app.trade_alerts.notify_agent_office", return_value=True):
_report(client, firing)
alerts = client.get("/api/stock/trade-alerts?days=1").json()["alerts"]
assert isinstance(alerts[0]["reason"], str) and len(alerts[0]["reason"]) > 3
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"] == []