45 Commits

Author SHA1 Message Date
a020c52a30 fix(agent-office): node_monitor _beat_age가 epoch ts 허용 → naver-fetch 영구 down 오탐 해소
매물알림 스펙 §4.4는 fetcher heartbeat ts를 epoch 정수로 정의했는데
_beat_age가 ISO 문자열만 파싱해 int.replace AttributeError → age=None
→ naver-fetch가 heartbeat 정상 발신 중에도 /nodes에서 영구 alive=false.
isinstance(int|float) 분기로 epoch·ISO 둘 다 지원 (신규 테스트 2건).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jBG2fz48w1bMfaQo8YC6M
2026-07-11 15:43:55 +09:00
b86fb874f0 docs(CLAUDE.md): realestate /listings/rematch 엔드포인트 등재 2026-07-10 14:20:06 +09:00
f676116336 fix(realestate): 네이버 매물 dong_code를 dong명에서 유도(cortarNo 부재)→ market_deals 매칭·판정 복구
네이버 article에 cortarNo가 없는 경우 listing.dong_code가 None으로 저장되어
get_market_deals_for가 시세를 못 찾아 모든 매물이 '보류'(판정 불가) 상태였다.
dong명은 항상 정확히 태깅되므로 lawd_code(dong)으로 5자리 시군구코드를 유도해
market_deals와 매칭시킨다.

- internal_router.listings_ingest: dong_code 없으면 batch.dong에서 lawd_code로 보정
- db.upsert_listing: ON CONFLICT UPDATE SET에 dong_code 추가(재유입 시 기존 None행 갱신)
- listing_matcher.run_listing_matching: get_market_deals_for 호출 전 dong_code 없으면
  listing.dong에서 즉시 유도(기존 저장된 None행도 재실행만으로 판정 복구)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-10 14:11:51 +09:00
4097c95286 perf(realestate): run_listing_matching 단일 conn+배치 + POST /listings/rematch(MOLIT없이 즉시 재판정)
- get_market_deals_for에 optional conn 파라미터 추가(재사용 시 open/close 안 함, 기존 단건 호출부는 불변)
- bulk_upsert_listing_matches 추가(listing_matches 다건 upsert, 단일 connection)
- run_listing_matching이 listings 조회+매물별 market_deals 조회+최종 upsert를 단일 _conn()으로 처리
  (기존: 269건 매물마다 개별 _conn() 3회 → 병목)
- POST /api/realestate/listings/rematch: MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정
  (알림 미발송, 즉시 피드백용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-10 13:56:45 +09:00
b471d2c455 perf(realestate): market_deals 배치 upsert(단일 conn)로 collect 속도 개선 2026-07-10 13:50:35 +09:00
8e28ce9ae5 fix(realestate): listings-ingest async(BackgroundTask)+매칭/알림 타이밍 로그 + notify 홍수방지(cap+baseline) 2026-07-10 11:11:36 +09:00
28418b9f5d docs: naver 워커 internal 엔드포인트 카탈로그(§5/§9) + 스펙 matched 문구 정정 2026-07-10 00:55:23 +09:00
1fff511752 fix(realestate): compose INTERNAL_API_KEY(배포 401 방지) + 매칭·알림 공유락(중복 텔레그램 방지)
- docker-compose.yml realestate-lab environment에 INTERNAL_API_KEY/NAVER_PAGE_LIMIT 누락 추가
  (verify_internal_key 항상 401 → 워커 계약 전체 마비 방지, sibling image/video/music/insta-lab과 동형)
- pipeline_lock.py 신설: cron _run_listing_pipeline과 워커 listings_ingest가
  run_listing_matching()+notify_new_listings() 임계구역을 공유 threading.Lock으로 직렬화
  (동시 실행 시 동일 매물 중복 텔레그램 발송 방지). 느린 collect는 락 밖 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-10 00:50:55 +09:00
04081bef6a refactor(realestate): collect_listings MOLIT 전용화(네이버는 워커 ingest로 이전)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 00:36:53 +09:00
54654af815 feat(agent-office): node_monitor에 naver-fetch(fetcher) 등재 + http-pull 링크
WORKER_REGISTRY에 naver-fetch(kind=fetcher, queue=None) 추가하고
collect_status()에 fetcher kind 분기(from=워커명, to=nas-realestate,
type=http-pull)를 추가. /nodes 관측·팀 규칙(모든 워커는 heartbeat+
registry 등재)에 맞춘 naver-fetch 워커 관측 준비.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-10 00:34:47 +09:00
13e3e558af feat(realestate): naver 워커 listings-ingest(파싱→upsert→매칭→알림) 2026-07-10 00:31:42 +09:00
f931c496d8 feat(realestate): 내부 인증 + naver 워커 targets 엔드포인트 + nginx internal 블록
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-10 00:27:23 +09:00
008111eff8 feat(realestate): 네이버 cortarNo 10자리 법정동 매핑(NAVER_CORTAR) 2026-07-10 00:23:54 +09:00
dfda38bd8e docs(realestate): naver 워커 BE 구현 plan 2026-07-10 00:22:11 +09:00
e09e11be7b docs(realestate): naver-fetch 워커 설계 스펙(가정 IP 네이버 fetch→NAS ingest, trade-monitor 동형)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-09 23:59:51 +09:00
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
49 changed files with 4967 additions and 14 deletions

View File

@@ -114,6 +114,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| `/api/internal/music/` | `music-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) | | `/api/internal/music/` | `music-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
| `/api/internal/video/` | `video-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) | | `/api/internal/video/` | `video-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
| `/api/internal/image/` | `image-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) | | `/api/internal/image/` | `image-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
| `/api/internal/realestate/` | `realestate-lab:8000` | naver-fetch 워커 targets 조회 + 매물 ingest (IP 화이트리스트 + `X-Internal-Key`) |
| `/api/webai/` | `stock:8000` | Windows AI 서버 프록시 (rate-limited 60r/m) | | `/api/webai/` | `stock:8000` | Windows AI 서버 프록시 (rate-limited 60r/m) |
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook | | `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
| `/ext/feargreed` | CNN API | 공포탐욕지수 외부 프록시 | | `/ext/feargreed` | CNN API | 공포탐욕지수 외부 프록시 |
@@ -246,6 +247,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 +333,13 @@ 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`(국토부 실거래[합법, **collect은 MOLIT 전용**]), `listing_matcher.py`(안전마진·적정성), `finance_rules.py`+`finance_rules_config.py`(전세대출·LTV·DSR·토허), `lawd_codes.py`(5자리 시군구+10자리 법정동 `NAVER_CORTAR`), `internal_router.py`+`auth.py`(naver-fetch 워커 계약), `pipeline_lock.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 ⚪보류
- ⚠️ **네이버 호가는 NAS가 직접 안 긁음**(datacenter IP 차단) → **Windows `naver-fetch` 워커**(가정 IP, web-ai)가 `GET /api/internal/realestate/targets`로 대상 받고 네이버 fetch → `POST /listings-ingest`로 push. NAS `collect_listings`는 MOLIT 실거래 baseline만. 관측: node_monitor `fetcher` kind → `/infra`
- 📌 상세(DB 스키마·스케줄러·매칭 모델·notifier 멱등·매물 4테이블·판정 경계·env): **`service_realestate.md`**
| 메서드 | 경로 | 설명 | | 메서드 | 경로 | 설명 |
|--------|------|------| |--------|------|------|
@@ -345,6 +353,15 @@ 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/listings/rematch` | MOLIT 재수집 없이 기존 매물 즉시 재판정 (~2초, criteria 변경 후) → `{total,passed,judged}` |
| POST | `/api/realestate/safety-check` | 단건 안전마진/적정성 판정 (실거래 median 대비) |
| POST | `/api/realestate/budget` | 전세/매수 예산·규제 산정 (전세대출·LTV·DSR·토허) |
| GET | `/api/internal/realestate/targets` | (naver-fetch 워커) 대상 동+10자리 cortarNo+거래유형 (X-Internal-Key) |
| POST | `/api/internal/realestate/listings-ingest` | (naver-fetch 워커) 네이버 raw 매물 push→파싱·upsert·매칭·알림 (X-Internal-Key) |
### agent-office (agent-office/) ### agent-office (agent-office/)
AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇. AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇.
@@ -361,7 +378,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 +387,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 +506,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,8 @@ 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},
{"name": "naver-fetch", "kind": "fetcher", "queue": None},
] ]
_redis = None _redis = None
@@ -26,9 +28,13 @@ def _get_redis():
return _redis return _redis
def _beat_age(ts_str, now): def _beat_age(ts, now):
"""ts는 ISO-8601 문자열 또는 epoch 숫자(매물알림 스펙 §4.4) 둘 다 허용."""
try: try:
beat = dt.datetime.fromisoformat(ts_str.replace("Z", "+00:00")) if isinstance(ts, (int, float)):
beat = dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc)
else:
beat = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
return max(0, int((now - beat).total_seconds())) return max(0, int((now - beat).total_seconds()))
except Exception: except Exception:
return None return None
@@ -92,11 +98,14 @@ 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",
"status": _render_link_status(w)}) "status": _render_link_status(w)})
elif w["kind"] == "fetcher":
out["links"].append({"from": w["name"], "to": "nas-realestate", "type": "http-pull",
"status": "healthy" if w["alive"] else "down"})
if out["paused"] and not out["paused_reason"]: if out["paused"] and not out["paused_reason"]:
out["paused_reason"] = "trading" out["paused_reason"] = "trading"
return out return out

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' 폴백."""
@@ -194,3 +206,43 @@ async def test_transition_send_failure_retries_next_cycle(monkeypatch):
result2 = await nm.check_and_alert(status=dead) result2 = await nm.check_and_alert(status=dead)
assert any("다운" in t for t in result2) assert any("다운" in t for t in result2)
assert nm._node_state.get("music-render") is False # 이제 갱신 assert nm._node_state.get("music-render") is False # 이제 갱신
# ── naver-fetch(fetcher) 등재 ──────────────────────────────────────────────
def test_naver_fetch_registered():
from app.node_monitor import WORKER_REGISTRY
entry = next((w for w in WORKER_REGISTRY if w["name"] == "naver-fetch"), None)
assert entry is not None
assert entry["kind"] == "fetcher" and entry["queue"] is None
@pytest.mark.asyncio
async def test_naver_fetch_http_pull_link():
"""naver-fetch heartbeat 존재 시 links에 from=naver-fetch·to=nas-realestate·type=http-pull."""
r = FakeRedis(kv={"worker:naver-fetch:heartbeat": _hb("naver-fetch", "fetcher", "idle")})
st = await node_monitor.collect_status(redis=r)
link = next(l for l in st["links"] if l["from"] == "naver-fetch")
assert link["type"] == "http-pull" and link["to"] == "nas-realestate" and link["status"] == "healthy"
# ── ts epoch 포맷 지원 (매물알림 스펙 §4.4: naver-fetch는 ts를 epoch 정수로 발신) ──
@pytest.mark.asyncio
async def test_epoch_ts_fresh_is_alive():
"""ts가 epoch 정수(스펙 §4.4)여도 신선하면 alive=True."""
epoch_now = int(dt.datetime.now(dt.timezone.utc).timestamp())
r = FakeRedis(kv={"worker:naver-fetch:heartbeat": _hb("naver-fetch", "fetcher", "idle", ts=epoch_now)})
st = await node_monitor.collect_status(redis=r)
nf = next(w for w in st["workers"] if w["name"] == "naver-fetch")
assert nf["alive"] is True and nf["last_beat_age_s"] is not None
@pytest.mark.asyncio
async def test_epoch_ts_stale_is_dead():
"""epoch ts가 90s 초과 과거면 staleness 판정으로 alive=False."""
epoch_old = int(dt.datetime.now(dt.timezone.utc).timestamp()) - 300
r = FakeRedis(kv={"worker:naver-fetch:heartbeat": _hb("naver-fetch", "fetcher", "idle", ts=epoch_old)})
st = await node_monitor.collect_status(redis=r)
nf = next(w for w in st["workers"] if w["name"] == "naver-fetch")
assert nf["alive"] is False and nf["last_beat_age_s"] >= 300

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()

View File

@@ -206,6 +206,8 @@ services:
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-} - DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000} - AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
- NAVER_PAGE_LIMIT=${NAVER_PAGE_LIMIT:-2}
- PYTHONPATH=/app:/shared - PYTHONPATH=/app:/shared
volumes: volumes:
- ${RUNTIME_PATH}/data/realestate:/app/data - ${RUNTIME_PATH}/data/realestate:/app/data

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

@@ -119,6 +119,23 @@ server {
proxy_connect_timeout 10s; proxy_connect_timeout 10s;
} }
# naver-fetch 워커 → NAS realestate-lab internal webhook (targets/listings-ingest)
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
# Layer 3: X-Internal-Key (FastAPI dependency)
location /api/internal/realestate/ {
allow 192.168.45.0/24;
allow 100.64.0.0/10;
allow 127.0.0.1;
deny all;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Internal-Key $http_x_internal_key;
proxy_pass http://realestate-lab:8000/api/internal/realestate/;
}
# realestate API # realestate API
location /api/realestate/ { location /api/realestate/ {
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@@ -0,0 +1,11 @@
"""Windows naver-fetch 워커 → NAS realestate-lab 내부 엔드포인트 인증."""
import os
from fastapi import Header, HTTPException
def verify_internal_key(x_internal_key: str = Header(...)):
expected = os.getenv("INTERNAL_API_KEY")
if not expected:
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
if x_internal_key != expected:
raise HTTPException(401, "Invalid X-Internal-Key")

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,268 @@ 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,
dong_code=excluded.dong_code
""", 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 bulk_upsert_market_deals(rows: List[Dict[str, Any]]) -> int:
"""market_deals 다건 INSERT OR IGNORE (단일 connection). area=None은 skip(무용/bloat 방지).
저장시도 건수 반환. 기존 upsert_market_deal과 동일 dedup(ux_market_deals_dedup 인덱스)."""
cols = ("house_type", "dong_code", "dong", "complex_name", "area", "deal_type",
"deposit", "monthly_rent", "sale_amount", "deal_ym", "floor", "source")
payload = []
for data in rows:
if data.get("area") is None:
continue
d = {c: data.get(c) for c in cols}
d["source"] = data.get("source") or "molit"
payload.append(tuple(d[c] for c in cols))
if not payload:
return 0
with _conn() as conn:
conn.executemany(
f"INSERT OR IGNORE INTO market_deals ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})",
payload,
)
return len(payload)
def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6, conn=None) -> List[Dict[str, Any]]:
"""conn을 넘기면 재사용(open/close 안 함, 호출부가 소유) — run_listing_matching 단일 conn 배치용.
conn 없으면 기존대로 자체 _conn() 열고 닫음(safety-check 등 단건 호출 경로 불변)."""
own = conn is None
c = conn or _conn()
try:
def _query(cn):
rows = c.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
finally:
if own:
c.close()
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 bulk_upsert_listing_matches(recs: List[Dict[str, Any]], conn=None) -> None:
"""listing_matches 다건 upsert(단일 connection). conn 주면 재사용(호출부 with가 commit) —
run_listing_matching 269건×3conn 병목 개선(Task 2). ON CONFLICT 절은 upsert_listing_match와 동일
(notified_at 미포함=보존, 재알림 방지)."""
if not recs:
return
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")
own = conn is None
c = conn or _conn()
try:
for data in recs:
d = {col: data.get(col) for col 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)
c.execute(f"""
INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+col for col 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)
if own:
c.commit()
finally:
if own:
c.close()
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,81 @@
"""naver-fetch 워커 내부 계약: targets 조회 + listings ingest."""
import os
import time
import logging
from typing import List, Dict, Any
from fastapi import APIRouter, Depends, BackgroundTasks
from pydantic import BaseModel
from .auth import verify_internal_key
from .db import get_listing_criteria, upsert_listing
from .lawd_codes import naver_cortar, lawd_code
from .listing_collector import _parse_naver_article
from .listing_matcher import run_listing_matching
from .notifier import notify_new_listings
from .pipeline_lock import match_notify_lock
logger = logging.getLogger("realestate-lab")
router = APIRouter()
NAVER_PAGE_LIMIT = int(os.getenv("NAVER_PAGE_LIMIT", "2"))
@router.get("/api/internal/realestate/targets", dependencies=[Depends(verify_internal_key)])
def listing_targets():
crit = get_listing_criteria()
dongs = []
for d in crit.get("dongs", []):
code = naver_cortar(d)
if not code:
logger.warning("naver cortarNo 매핑 없음 — 대상 제외: %s", d)
continue
dongs.append({"dong": d, "cortar_no": code})
return {"dongs": dongs, "deal_types": crit.get("deal_types", []),
"page_limit": NAVER_PAGE_LIMIT}
class _Batch(BaseModel):
dong: str
articles: List[Dict[str, Any]] = []
class ListingsIngest(BaseModel):
fetched_at: str | None = None
batches: List[_Batch] = []
@router.post("/api/internal/realestate/listings-ingest",
dependencies=[Depends(verify_internal_key)])
def listings_ingest(body: ListingsIngest, background_tasks: BackgroundTasks):
received = new = 0
for batch in body.batches:
for raw in batch.articles:
try:
d = _parse_naver_article(raw)
if not d.get("article_no"):
continue
d["dong"] = batch.dong
# 네이버 article에 cortarNo가 없는 경우가 실사용에서 확인됨 → dong명에서
# 5자리 시군구코드(lawd_code)를 유도(market_deals는 이 5자리를 쓴다).
d["dong_code"] = d.get("dong_code") or lawd_code(batch.dong)
_, is_new = upsert_listing(d)
received += 1
if is_new:
new += 1
except Exception as e:
logger.warning("네이버 ingest 파싱 실패: %s", e)
if received:
background_tasks.add_task(_match_and_notify)
return {"received": received, "new": new, "queued": bool(received)}
def _match_and_notify():
"""ingest 후처리(백그라운드): 매칭+알림을 공유 락으로 직렬화. 타이밍 로그로 병목 관측."""
with match_notify_lock:
t0 = time.time()
run_listing_matching()
t1 = time.time()
noti = notify_new_listings()
t2 = time.time()
sent = (noti or {}).get("sent") if isinstance(noti, dict) else noti
logger.info("ingest 후처리: match=%.1fs notify=%.1fs sent=%s", t1 - t0, t2 - t1, sent)

View File

@@ -0,0 +1,40 @@
"""대상 동명 → 법정동코드(시군구 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())
# 네이버부동산 cortarNo = 10자리 법정동코드 (LAWD_CODES 5자리 시군구와 별개).
# 출처: 행정표준코드관리시스템 법정동코드. 대상 동 추가 시 여기에.
NAVER_CORTAR = {
"신대방동": "1159010100",
"대방동": "1159010200",
"상도동": "1159010600",
"봉천동": "1162010100",
"대림동": "1156013600",
"신길동": "1156013200",
}
def naver_cortar(dong: str) -> str | None:
return NAVER_CORTAR.get((dong or "").strip())

View File

@@ -0,0 +1,181 @@
"""매물 수집: 국토부 실거래가(합법, 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,
bulk_upsert_market_deals, 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=<첫에러>]' 관측용."""
all_deals = []
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
all_deals.append(d)
except Exception as e:
logger.warning("MOLIT 파싱 실패: %s", e)
time.sleep(0.3)
saved = bulk_upsert_market_deals(all_deals)
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:
"""MOLIT 실거래(합법 baseline)만 수집. 네이버 호가는 naver-fetch 워커가
/api/internal/realestate/listings-ingest로 push (spec 2026-07-09-naver-listing-worker)."""
molit_diag = None
try:
_, molit_diag = _fetch_molit_all()
except Exception as e:
molit_diag = f"exc={type(e).__name__}:{str(e)[:80]}"
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
diag = f"molit[{molit_diag}] naver[worker]"
save_listing_collect_log(0, 0, None, error=diag)
return {"new_count": 0, "total_count": 0, "naver_ok": None, "diag": diag}

View File

@@ -0,0 +1,128 @@
"""매물 조건 매칭 + 임차 안전마진(전세가율)·매매 적정성(호가율) 판정."""
import logging
import statistics
from .db import (get_listing_criteria, get_market_deals_for, bulk_upsert_listing_matches,
_conn)
from . import finance_rules
from .lawd_codes import lawd_code
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 대칭).
listings 조회 + 매물별 market_deals 조회 + 최종 upsert를 단일 connection으로 처리
(Task 2 성능개선: 이전엔 매물마다 get_market_deals_for/upsert_listing_match가 각각
자체 _conn()을 열어 269건×~3conn이 발생했다)."""
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)
with _conn() as conn:
listings = [dict(r) for r in conn.execute(
"SELECT * FROM listings ORDER BY created_at DESC LIMIT 1000"
).fetchall()]
recs = []
for l in listings:
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)
# 네이버 cortarNo 부재 등으로 기존 저장된 listing.dong_code가 None일 수 있음 —
# dong명에서 5자리 lawd_code를 유도해 즉시 판정(보류 고착 방지).
dc = l.get("dong_code") or lawd_code(l.get("dong"))
deals = get_market_deals_for(dc, l.get("complex_name"),
l.get("area_exclusive"), "전세" if is_rent else "매매",
conn=conn)
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})
recs.append(rec)
bulk_upsert_listing_matches(recs, conn=conn)
logger.info("매물 매칭 완료(%d건)", len(recs))
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,22 @@ 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 .pipeline_lock import match_notify_lock
from . import finance_rules
from .finance_rules_config import DISCLAIMER
from .models import (
AnnouncementCreate, AnnouncementUpdate, ProfileUpdate,
ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest,
)
from .internal_router import router as internal_router
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 +67,32 @@ 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()
with match_notify_lock:
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
@@ -70,6 +101,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
install_access_log(app) install_access_log(app)
app.include_router(internal_router)
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",") _cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
app.add_middleware( app.add_middleware(
@@ -217,3 +249,78 @@ 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/listings/rematch")
def api_listings_rematch():
"""MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정(즉시 피드백용, 알림 미발송)."""
with match_notify_lock:
run_listing_matching()
matches = get_listing_matches()
passed = sum(1 for m in matches if m.get("passed"))
judged = sum(1 for m in matches if m.get("passed")
and (m.get("safety_tier") or m.get("valuation_tier")) not in (None, "보류"))
return {"total": len(matches), "passed": passed, "judged": judged}
@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,12 +3,15 @@ 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")
AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000") AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
NOTIFY_TIMEOUT_SECONDS = int(os.getenv("REALESTATE_NOTIFY_TIMEOUT", "15")) NOTIFY_TIMEOUT_SECONDS = int(os.getenv("REALESTATE_NOTIFY_TIMEOUT", "15"))
LISTING_NOTIFY_MAX = int(os.getenv("LISTING_NOTIFY_MAX", "8"))
LISTING_NOTIFY_TIMEOUT = int(os.getenv("LISTING_NOTIFY_TIMEOUT", "60"))
def notify_new_matches() -> dict: def notify_new_matches() -> dict:
@@ -44,3 +47,34 @@ 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 설정 시 그 이상만.
콜드스타트로 backlog가 쌓이면 한 사이클 최대 LISTING_NOTIFY_MAX건만 알림 발송하고
나머지는 조용히 notified_at 마킹(baseline)해 스팸·재전송 루프를 막는다."""
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}
to_send = listings[:LISTING_NOTIFY_MAX]
to_baseline = listings[LISTING_NOTIFY_MAX:]
if to_baseline: # 콜드스타트 backlog: 조용히 baseline 마킹(알림 X)
mark_listings_notified([m["id"] for m in to_baseline])
logger.info("매물 알림 baseline(무알림) 마킹: %d", len(to_baseline))
url = f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify-listing"
try:
resp = requests.post(url, json={"listings": to_send}, timeout=LISTING_NOTIFY_TIMEOUT)
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), "baselined": len(to_baseline)}
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

@@ -0,0 +1,5 @@
"""매물 매칭+알림 임계구역 직렬화 락 — cron 파이프라인과 워커 ingest가 공유해
run_listing_matching()+notify_new_listings() 동시 실행(중복 텔레그램)을 방지한다."""
import threading
match_notify_lock = threading.Lock()

View File

@@ -0,0 +1,467 @@
# 네이버 매물 fetch 워커 — BE 구현 Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** naver-fetch 워커가 네이버 호가 매물을 push할 수 있도록 realestate-lab에 내부 계약(targets 조회 + listings ingest), 관측 등재, cortarNo 매핑을 구축한다. 워커(web-ai)·FE(/infra)는 별도 세션.
**Architecture:** 워커는 `GET /api/internal/realestate/targets`로 대상 동+10자리 cortarNo를 받고, 네이버 raw 매물을 `POST /api/internal/realestate/listings-ingest`로 push. NAS는 기존 `_parse_naver_article`로 파싱→`upsert_listing``run_listing_matching``notify_new_listings`. 인증은 render 워커와 동형(X-Internal-Key + nginx IP화이트리스트). 관측은 heartbeat + node_monitor `fetcher` kind.
**Tech Stack:** Python 3.12, FastAPI(APIRouter+Depends), SQLite, pytest. 외부 호출 테스트는 전부 mock.
**스펙:** `realestate-lab/docs/superpowers/specs/2026-07-09-naver-listing-worker-design.md`
## Global Constraints
- **기존 매물/청약 파이프라인 불변** — 신규 내부 라우터·매핑·등재만 추가. 안전마진 판정 로직 불변.
- **인증 패턴 준수**: `verify_internal_key(x_internal_key: str = Header(...))`, env **`INTERNAL_API_KEY`**, 실패 시 `HTTPException(401, "Invalid X-Internal-Key")`. (스펙의 `REALESTATE_INTERNAL_KEY` 대신 기존 서비스 관례 `INTERNAL_API_KEY` 사용 — image/video/insta-lab 일관성.)
- **파싱은 한 곳**: 네이버 article 파싱은 기존 `listing_collector._parse_naver_article` 재사용(중복 구현 금지).
- **멱등**: `listings.article_no` UNIQUE upsert + `listing_matches.notified_at` → 재push해도 재알림 없음.
- **테스트**: `cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest tests/<file> -q` (main import은 `_shared` 때문에 PYTHONPATH에 web-backend 루트 필요). agent-office 테스트는 `cd agent-office && python -m pytest`.
- **cortarNo는 10자리 법정동코드** (기존 `LAWD_CODES` 5자리 시군구는 MOLIT 전용 유지).
- **nginx/deploy는 locked 리소스**: nginx 변경·push는 `nginx-conf`·`nas-deploy` 락 획득 후(Task 6).
## File Structure
| 파일 | 신규/수정 | 책임 |
|---|---|---|
| `realestate-lab/app/lawd_codes.py` | Modify | `NAVER_CORTAR`(동→10자리) + `naver_cortar()` 추가 |
| `realestate-lab/app/auth.py` | Create | `verify_internal_key` (X-Internal-Key) |
| `realestate-lab/app/internal_router.py` | Create | `GET targets` + `POST listings-ingest` |
| `realestate-lab/app/main.py` | Modify | `include_router(internal_router)` |
| `nginx/default.conf` | Modify | `/api/internal/realestate/` 블록 |
| `realestate-lab/app/listing_collector.py` | Modify | `collect_listings` MOLIT 전용화(naver deprecate) |
| `agent-office/app/node_monitor.py` | Modify | `WORKER_REGISTRY` fetcher 등재 + link 분기 |
---
### Task 1: NAVER_CORTAR 매핑 (10자리 법정동코드)
**Files:**
- Modify: `realestate-lab/app/lawd_codes.py`
- Test: `realestate-lab/tests/test_lawd_codes.py` (기존 파일에 append)
**Interfaces:**
- Produces: `NAVER_CORTAR: dict[str,str]`, `naver_cortar(dong: str) -> str | None`
- [ ] **Step 1: 실패 테스트 append**`tests/test_lawd_codes.py`
```python
def test_naver_cortar_10digit():
from app.lawd_codes import naver_cortar, NAVER_CORTAR
assert naver_cortar("신대방동") == "1159010100"
assert naver_cortar("봉천동") == "1162010100"
assert naver_cortar("신길동") == "1156013200"
assert naver_cortar("없는동") is None
# 6개 초기 대상 동 전부 10자리
for d in ["신대방동","대방동","상도동","봉천동","대림동","신길동"]:
assert len(NAVER_CORTAR[d]) == 10
```
- [ ] **Step 2: 실패 확인**`cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest tests/test_lawd_codes.py -q` → FAIL(ImportError naver_cortar)
- [ ] **Step 3: 구현**`app/lawd_codes.py` 하단에 추가
```python
# 네이버부동산 cortarNo = 10자리 법정동코드 (LAWD_CODES 5자리 시군구와 별개).
# 출처: 행정표준코드관리시스템 법정동코드. 대상 동 추가 시 여기에.
NAVER_CORTAR = {
"신대방동": "1159010100",
"대방동": "1159010200",
"상도동": "1159010600",
"봉천동": "1162010100",
"대림동": "1156013600",
"신길동": "1156013200",
}
def naver_cortar(dong: str) -> str | None:
return NAVER_CORTAR.get((dong or "").strip())
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/lawd_codes.py realestate-lab/tests/test_lawd_codes.py
git commit -m "feat(realestate): 네이버 cortarNo 10자리 법정동 매핑(NAVER_CORTAR)"
```
---
### Task 2: 내부 인증 + targets 엔드포인트 + 라우터 배선
**Files:**
- Create: `realestate-lab/app/auth.py`, `realestate-lab/app/internal_router.py`
- Modify: `realestate-lab/app/main.py`
- Test: `realestate-lab/tests/test_internal_targets.py`
**Interfaces:**
- Consumes: Task1 `naver_cortar`; 기존 `db.get_listing_criteria`.
- Produces: `verify_internal_key` dependency; `GET /api/internal/realestate/targets``{"dongs":[{"dong","cortar_no"}],"deal_types":[...],"page_limit":int}`; `router` (APIRouter, main에 include).
- [ ] **Step 1: 실패 테스트**`tests/test_internal_targets.py`
```python
import os
from fastapi.testclient import TestClient
def _client(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
import importlib
from app import auth as _a
importlib.reload(_a) # env 반영
from app.main import app
return TestClient(app)
def test_targets_requires_key(monkeypatch):
c = _client(monkeypatch)
assert c.get("/api/internal/realestate/targets").status_code == 401 # 헤더 없음
assert c.get("/api/internal/realestate/targets",
headers={"X-Internal-Key": "WRONG"}).status_code == 401
def test_targets_returns_dongs_with_cortar(monkeypatch):
c = _client(monkeypatch)
r = c.get("/api/internal/realestate/targets", headers={"X-Internal-Key": "SECRET"})
assert r.status_code == 200
body = r.json()
dongs = {d["dong"]: d["cortar_no"] for d in body["dongs"]}
assert dongs.get("신대방동") == "1159010100" # criteria seed 6동 + cortar join
assert "전세" in body["deal_types"]
assert isinstance(body["page_limit"], int)
```
- [ ] **Step 2: 실패 확인**`PYTHONPATH=... python -m pytest tests/test_internal_targets.py -q` → FAIL(404/ImportError)
- [ ] **Step 3: 구현**
`app/auth.py`:
```python
"""Windows naver-fetch 워커 → NAS realestate-lab 내부 엔드포인트 인증."""
import os
from fastapi import Header, HTTPException
def verify_internal_key(x_internal_key: str = Header(...)):
expected = os.getenv("INTERNAL_API_KEY")
if not expected:
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
if x_internal_key != expected:
raise HTTPException(401, "Invalid X-Internal-Key")
```
`app/internal_router.py`:
```python
"""naver-fetch 워커 내부 계약: targets 조회 + listings ingest."""
import os
import logging
from fastapi import APIRouter, Depends
from .auth import verify_internal_key
from .db import get_listing_criteria
from .lawd_codes import naver_cortar
logger = logging.getLogger("realestate-lab")
router = APIRouter()
NAVER_PAGE_LIMIT = int(os.getenv("NAVER_PAGE_LIMIT", "2"))
@router.get("/api/internal/realestate/targets", dependencies=[Depends(verify_internal_key)])
def listing_targets():
crit = get_listing_criteria()
dongs = []
for d in crit.get("dongs", []):
code = naver_cortar(d)
if not code:
logger.warning("naver cortarNo 매핑 없음 — 대상 제외: %s", d)
continue
dongs.append({"dong": d, "cortar_no": code})
return {"dongs": dongs, "deal_types": crit.get("deal_types", []),
"page_limit": NAVER_PAGE_LIMIT}
```
`app/main.py`: import 블록에 추가(기존 `from .models import (...)` 아래)
```python
from .internal_router import router as internal_router
```
그리고 `app = FastAPI(lifespan=lifespan)` + `install_access_log(app)` **직후**에:
```python
app.include_router(internal_router)
```
`nginx/default.conf`: `/api/realestate/` location 블록 **앞**에 추가(더 구체적 prefix 우선):
```nginx
location /api/internal/realestate/ {
allow 192.168.45.0/24;
allow 100.64.0.0/10;
allow 127.0.0.1;
deny all;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Internal-Key $http_x_internal_key;
proxy_pass http://realestate-lab:8000/api/internal/realestate/;
}
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS (nginx는 로컬 테스트 무관, 배포 시 검증)
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/auth.py realestate-lab/app/internal_router.py realestate-lab/app/main.py nginx/default.conf realestate-lab/tests/test_internal_targets.py
git commit -m "feat(realestate): 내부 인증 + naver 워커 targets 엔드포인트 + nginx internal 블록"
```
---
### Task 3: listings-ingest 엔드포인트
**Files:**
- Modify: `realestate-lab/app/internal_router.py`
- Test: `realestate-lab/tests/test_internal_ingest.py`
**Interfaces:**
- Consumes: 기존 `db.upsert_listing`, `listing_collector._parse_naver_article`, `listing_matcher.run_listing_matching`, `notifier.notify_new_listings`.
- Produces: `POST /api/internal/realestate/listings-ingest` body `{fetched_at:str, batches:[{dong:str, articles:[dict]}]}``{"received":int,"new":int,"matched":int}`.
- [ ] **Step 1: 실패 테스트**`tests/test_internal_ingest.py`
```python
import os, importlib
from unittest.mock import patch
from fastapi.testclient import TestClient
def _client(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
from app import auth as _a
importlib.reload(_a)
from app.main import app
return TestClient(app)
_ARTICLE = {"articleNo": "N100", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
"area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
"buildingName": "OO", "cortarNo": "1159010100"}
def test_ingest_auth(monkeypatch):
c = _client(monkeypatch)
assert c.post("/api/internal/realestate/listings-ingest", json={"batches": []}).status_code == 401
def test_ingest_upserts_and_matches(monkeypatch):
c = _client(monkeypatch)
with patch("app.internal_router.notify_new_listings", return_value={"sent": 1, "sent_ids": [1]}) as noti:
r = c.post("/api/internal/realestate/listings-ingest",
headers={"X-Internal-Key": "SECRET"},
json={"fetched_at": "2026-07-09T00:00:00Z",
"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]})
assert r.status_code == 200
body = r.json()
assert body["received"] == 1 and body["new"] == 1 and body["matched"] == 1
assert noti.call_count == 1
# 저장 확인
from app import db
ls = db.get_listings()
assert any(l["article_no"] == "N100" and l["dong"] == "신대방동" for l in ls)
def test_ingest_idempotent(monkeypatch):
c = _client(monkeypatch)
payload = {"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]}
with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}):
c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
r2 = c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
assert r2.json()["new"] == 0 # 재push → 신규 0
```
- [ ] **Step 2: 실패 확인**`PYTHONPATH=... python -m pytest tests/test_internal_ingest.py -q` → FAIL(404)
- [ ] **Step 3: 구현**`app/internal_router.py`에 추가
상단 import 확장:
```python
from pydantic import BaseModel
from typing import List, Dict, Any
from .db import get_listing_criteria, upsert_listing
from .listing_collector import _parse_naver_article
from .listing_matcher import run_listing_matching
from .notifier import notify_new_listings
```
(기존 `from .db import get_listing_criteria`는 위 확장 라인으로 합치거나 중복 없이 정리)
모델 + 핸들러:
```python
class _Batch(BaseModel):
dong: str
articles: List[Dict[str, Any]] = []
class ListingsIngest(BaseModel):
fetched_at: str | None = None
batches: List[_Batch] = []
@router.post("/api/internal/realestate/listings-ingest",
dependencies=[Depends(verify_internal_key)])
def listings_ingest(body: ListingsIngest):
received = new = 0
for batch in body.batches:
for raw in batch.articles:
try:
d = _parse_naver_article(raw)
if not d.get("article_no"):
continue
d["dong"] = batch.dong
_, is_new = upsert_listing(d)
received += 1
if is_new:
new += 1
except Exception as e:
logger.warning("네이버 ingest 파싱 실패: %s", e)
matched = 0
if received:
run_listing_matching()
noti = notify_new_listings()
matched = int(noti.get("sent", 0)) if isinstance(noti, dict) else 0
return {"received": received, "new": new, "matched": matched}
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/internal_router.py realestate-lab/tests/test_internal_ingest.py
git commit -m "feat(realestate): naver 워커 listings-ingest(파싱→upsert→매칭→알림)"
```
---
### Task 4: node_monitor fetcher 등재
**Files:**
- Modify: `agent-office/app/node_monitor.py`
- Test: `agent-office/tests/test_node_monitor.py` (기존 파일에 append; 없으면 `test_node_monitor_fetcher.py` 생성)
**Interfaces:**
- Produces: `WORKER_REGISTRY``{"name":"naver-fetch","kind":"fetcher","queue":None}`; `collect_status()`가 fetcher에 대해 `{from:"naver-fetch", to:"nas-realestate", type:"http-pull", status:...}` link 생성.
- [ ] **Step 1: 실패 테스트** — 등재 여부(트리비얼) + 링크 분기
```python
def test_naver_fetch_registered():
from app.node_monitor import WORKER_REGISTRY
entry = next((w for w in WORKER_REGISTRY if w["name"] == "naver-fetch"), None)
assert entry is not None
assert entry["kind"] == "fetcher" and entry["queue"] is None
```
> collect_status의 fetcher-link는 기존 `test_node_monitor.py`의 redis-mock 픽스처(trader/render 링크 검증부)를 그대로 따라, "naver-fetch heartbeat 존재 시 links에 from=naver-fetch·type=http-pull이 있다"를 추가 검증하라. 픽스처가 없으면 이 트리비얼 등재 테스트만으로 충분.
- [ ] **Step 2: 실패 확인**`cd agent-office && python -m pytest tests/test_node_monitor.py -q` (또는 신규 파일) → FAIL
- [ ] **Step 3: 구현**`agent-office/app/node_monitor.py`
`WORKER_REGISTRY` 리스트에 추가:
```python
{"name": "naver-fetch", "kind": "fetcher", "queue": None},
```
`collect_status()`의 kind 분기(`if w["kind"] == "trader": ... elif w["kind"] == "render": ...`)에 추가:
```python
elif w["kind"] == "fetcher":
out["links"].append({"from": w["name"], "to": "nas-realestate", "type": "http-pull",
"status": "healthy" if w["alive"] else "down"})
```
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS + 회귀 `cd agent-office && python -m pytest -q`
- [ ] **Step 5: 커밋**
```bash
git add agent-office/app/node_monitor.py agent-office/tests/test_node_monitor.py
git commit -m "feat(agent-office): node_monitor에 naver-fetch(fetcher) 등재 + http-pull 링크"
```
---
### Task 5: collect_listings MOLIT 전용화 (naver deprecate)
**Files:**
- Modify: `realestate-lab/app/listing_collector.py`
- Test: `realestate-lab/tests/test_listing_collector.py` (기존 수정)
**Interfaces:**
- `collect_listings()``{"new_count":0,"total_count":0,"naver_ok":None,"diag":str}` (naver는 워커가 담당하므로 NAS는 MOLIT만). `_fetch_naver_all`/`_parse_naver_article`/`_won_to_manwon`**존치**(ingest가 `_parse_naver_article` 사용).
- [ ] **Step 1: 실패 테스트** — 기존 `test_collect_naver_block_sets_naver_ok_false`를 대체
```python
def test_collect_is_molit_only(monkeypatch):
"""collect_listings는 MOLIT만 수행(naver fetch 미호출). naver는 워커 담당."""
from app import listing_collector as lc
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (5, "calls=1 saved=5"))
called = {"naver": False}
def naver_spy():
called["naver"] = True
return (0, 0)
monkeypatch.setattr(lc, "_fetch_naver_all", naver_spy)
r = lc.collect_listings()
assert called["naver"] is False # naver 미호출
assert "molit" in r["diag"]
```
- [ ] **Step 2: 실패 확인**`PYTHONPATH=... python -m pytest tests/test_listing_collector.py -q` → FAIL(naver 호출됨)
- [ ] **Step 3: 구현**`collect_listings` 교체
```python
def collect_listings() -> dict:
"""MOLIT 실거래(합법 baseline)만 수집. 네이버 호가는 naver-fetch 워커가
/api/internal/realestate/listings-ingest로 push (spec 2026-07-09-naver-listing-worker)."""
molit_diag = None
try:
_, molit_diag = _fetch_molit_all()
except Exception as e:
molit_diag = f"exc={type(e).__name__}:{str(e)[:80]}"
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
diag = f"molit[{molit_diag}] naver[worker]"
save_listing_collect_log(0, 0, None, error=diag)
return {"new_count": 0, "total_count": 0, "naver_ok": None, "diag": diag}
```
> `save_listing_collect_log`의 naver_ok 인자는 `1 if naver_ok else 0` 처리이므로 None→0 저장(무해). `_fetch_naver_all`/`_parse_naver_article`는 삭제하지 말 것(ingest 재사용).
> 기존 `test_collect_writes_molit_diag_to_log`(계측 테스트)가 `_fetch_naver_all`을 `lambda: (0,0)`로 mock하는데 이제 미호출이므로 여전히 통과(무해). `test_molit_call_returns_diag_on_http_error`·`_parse_*`도 무관.
- [ ] **Step 4: 통과 확인**`PYTHONPATH=... python -m pytest tests/test_listing_collector.py -q` → PASS (신규 + 기존 계측/파서 테스트)
- [ ] **Step 5: 커밋**
```bash
git add realestate-lab/app/listing_collector.py realestate-lab/tests/test_listing_collector.py
git commit -m "refactor(realestate): collect_listings MOLIT 전용화(네이버는 워커 ingest로 이전)"
```
---
### Task 6: 회귀 + 카탈로그/메모리 + 배포 + 계약 핸드오프
**Files:** `CLAUDE.md`, 메모리 `service_realestate.md`/`infra_distributed_workers.md`
- [ ] **Step 1: 전체 회귀**`cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest -q` (0 fail) + `cd agent-office && python -m pytest -q` (0 fail)
- [ ] **Step 2: CLAUDE.md** — §5 nginx 표에 `/api/internal/realestate/`(IP화이트리스트) 추가, §9 realestate 표에 `GET /api/internal/realestate/targets`·`POST /listings-ingest` 추가
- [ ] **Step 3: 메모리**`service_realestate.md`(내부 계약·NAVER_CORTAR·collect MOLIT전용화), `infra_distributed_workers.md`(naver-fetch fetcher kind 등재, /api/internal/realestate/ 계약)
- [ ] **Step 4: 계약 잠금·핸드오프** — co-gahusb `acquire_lock("nginx-conf","BE")`+`acquire_lock("nas-deploy","BE")` → 커밋 push(자동배포). AI(web-ai)에 §4 계약(targets/ingest 스키마·heartbeat kind=fetcher·INTERNAL_API_KEY)·병렬 spike 요청, FE(web-ui)에 /infra fetcher 노드 요청 `post_message`. 배포 후 `GET /api/internal/realestate/targets`(X-Internal-Key) 200 검증 → 락 해제.
```bash
git add CLAUDE.md
git commit -m "docs(CLAUDE.md): naver 워커 internal 엔드포인트 + nginx 등재"
git push origin main
```
---
## Self-Review
**Spec coverage:** §4.1 targets→T2, §4.2 ingest→T3, §4.3 cortarNo→T1, §4.4 heartbeat(계약, 워커측)→T4 등재로 관측, §5 관측→T4, §6 인증(nginx+X-Internal-Key)→T2, §7 멱등/에러→T3(try/except·article_no UNIQUE), §8 테스트→각 Task, §3 _fetch_naver_all deprecate→T5, §10 핸드오프→T6. ✓
- 스펙 대비 조정: env명 `INTERNAL_API_KEY`(기존 관례, 스펙의 REALESTATE_INTERNAL_KEY 대체) — Global Constraints에 명시. matched=notify sent 수로 구체화(스펙 {received,new,matched} 유지).
**Placeholder scan:** 각 Step 실제 코드/명령. T4의 collect_status fetcher-link 상세 테스트만 "기존 test_node_monitor 픽스처 따라"로 위임(기존 파일 참조, 트리비얼 등재 테스트는 완전). ✓
**Type consistency:** `naver_cortar`(T1)→internal_router(T2) 사용 일치. `upsert_listing``(dict,is_new)`(기존)·`_parse_naver_article`(기존 반환 dict)·`notify_new_listings``{sent,...}`(기존)→ingest 소비 일치. targets 반환 `{dongs:[{dong,cortar_no}],deal_types,page_limit}`=스펙 §4.1. ingest 반환 `{received,new,matched}`=스펙 §4.2. WORKER_REGISTRY 항목 형식(T4)=기존 구조 일치. ✓
**범위:** BE 단일 세션(realestate T1-3,5 + agent-office T4 + 통합 T6). 워커(web-ai)·FE(web-ui)는 별도. 단일 실행 계획 적정(6 tasks).

View File

@@ -0,0 +1,144 @@
# 네이버 매물 fetch 워커 (naver-fetch) — 설계
> 상태: **설계 승인됨(2026-07-09) → 구현 계획(writing-plans) 대기**
> 관련: [[2026-07-09-매물알림-안전마진-design]](매물 파이프라인 본체), `infra_distributed_workers` 메모리(워커↔NAS 계약·관측)
## 1. 배경 / 목표
매물 알림 파이프라인(2026-07-09 배포)에서 **국토부 실거래(합법 baseline)는 정상**이나, **네이버 호가 매물 수집은 NAS(datacenter IP)에서 `ReadTimeout/429`로 차단**되어 자동 매물 유입이 막혀 있다. → 네이버 fetch를 **Windows AI 머신(192.168.45.59, 가정 IP)**의 신규 워커에서 수행해 NAS로 push한다. 기존 render/trade-monitor 워커와 **동형 패턴**.
**성공 기준**: 워커가 대상 동의 네이버 호가 매물을 주기적으로 fetch → NAS가 upsert→매칭→(안전마진 판정 포함)→신규 매물 텔레그램 알림. 워커는 `/infra`에서 관측 가능. 네이버가 일시 차단돼도 실거래·안전마진·청약 무중단.
## 2. 비목표 (Out of Scope)
- 네이버 인증 토큰 획득 전략(SPA Bearer JWT 등) — **web-ai 워커의 구현·병렬 spike**로 확인(BE 계약과 독립).
- 단지(complex)별 심층 크롤·과거 backfill — MVP는 동(법정동)별 article list만.
- 안전마진/적정성 판정 로직 — 이미 존재·불변(`listing_matcher`).
- `market_deals` 수집 성능 개선(행별 개별 `_conn` upsert가 대량시 느림) — 별개 후속(관측됨, §9 참조).
## 3. 아키텍처 (3-repo 분담)
```
[web-ai: naver-fetch 워커] (Windows 192.168.45.59, WSL2 docker, 가정 IP)
자체 스케줄 루프(2~3h, 낮 시간):
1. GET NAS /api/internal/realestate/targets (X-Internal-Key)
→ {dongs:[{dong, cortar_no}], deal_types, page_limit}
2. 네이버 new.land.naver.com/api/articles?cortarNo=<10자리>&... 동별 fetch
(인증 토큰 확보는 워커 책임 — 병렬 spike)
3. POST NAS /api/internal/realestate/listings-ingest (X-Internal-Key)
{fetched_at, batches:[{dong, articles:[<raw 네이버 article>...]}]}
4. worker:naver-fetch:heartbeat (EX45, kind=fetcher, state=idle/fetching)
[web-backend: NAS realestate-lab] (BE 담당)
- GET /api/internal/realestate/targets → criteria.dongs + cortar_no 매핑 + criteria.deal_types
- POST /api/internal/realestate/listings-ingest
→ 각 article _parse_naver_article(기존·검증) + dong 태깅 → upsert_listing
→ run_listing_matching() + notify_new_listings() (기존 재사용)
→ {received, new, matched}
- node_monitor.WORKER_REGISTRY += naver-fetch(kind=fetcher)
- nginx: /api/internal/realestate/ → realestate-lab:8000 (IP 화이트리스트)
- collect_listings의 _fetch_naver_all deprecated (MOLIT 실거래 cron은 유지)
[web-ui: /infra] (FE 담당, 소규모)
- fetcher kind 노드 시각(statusVisual 색·라벨)
```
**설계 원칙**: 워커는 **무상태 얇은 프록시**("네이버 IP차단 우회 fetch"만). 파싱·dedup·매칭·알림·상태는 전부 NAS. 파싱은 NAS의 검증된 `_parse_naver_article` 한 곳에서만 유지(네이버 포맷 변경 시 BE만 수정).
## 4. 계약 (BE 소유 — cross-repo lock)
### 4.1 `GET /api/internal/realestate/targets` (X-Internal-Key)
워커가 "무엇을 긁을지" pull.
```json
{
"dongs": [
{"dong": "신대방동", "cortar_no": "1159010100"},
{"dong": "봉천동", "cortar_no": "1162010100"}
],
"deal_types": ["전세", "반전세", "매매"],
"page_limit": 2
}
```
- `dongs`: `listing_criteria.dongs` 각각에 대해 `NAVER_CORTAR[dong]`(10자리 법정동코드) 조인. 매핑 없는 동은 제외(+경고 로그).
- `deal_types`: `listing_criteria.deal_types`.
- `page_limit`: 동당 fetch 페이지 수 상한(기본 2). 상수/env.
- 인증 실패(키 불일치/누락) → 401.
### 4.2 `POST /api/internal/realestate/listings-ingest` (X-Internal-Key)
워커가 네이버 raw 매물 push.
```json
{
"fetched_at": "2026-07-09T05:44:23Z",
"batches": [
{"dong": "신대방동", "articles": [ { /* articleList item raw */ }, ... ]},
{"dong": "봉천동", "articles": [ ... ]}
]
}
```
- 각 batch의 각 article → `_parse_naver_article(article)``d["dong"] = batch.dong``upsert_listing(d)`. article별 try/except(불량 1건 skip).
- 전체 upsert 후 `run_listing_matching()` + `notify_new_listings()` 호출(신규 매물만 알림, 멱등).
- 응답: `{"received": N, "new": M, "matched": K}` (received=처리 article 수, new=신규 upsert 수, **matched=이번 호출에서 신규 텔레그램 발송 건수(`notify_new_listings().sent`)** — 누적 통과 매칭 수가 아님).
- 인증 실패 → 401. `batches` 비면 no-op `{received:0,new:0,matched:0}`.
- ⚠️ MVP는 인라인 처리(매물 수 수백 규모). 대량 시 upsert만 동기+매칭/알림 BackgroundTask로 분리 여지(§9).
### 4.3 cortarNo 매핑 (BE)
`app/lawd_codes.py``NAVER_CORTAR: dict[str,str]`(동명→**10자리 법정동코드**) 추가. 현재 `LAWD_CODES`(5자리 시군구)는 MOLIT 전용으로 유지 — 네이버는 10자리 필요(기존 `_fetch_naver_all`이 5자리를 넘기던 버그를 이 매핑으로 해소). 초기 대상 6동:
| 동 | 구 | 10자리(구현 시 행정표준코드로 검증) |
|---|---|---|
| 신대방동 | 동작구 | 1159010100 |
| 대방동 | 동작구 | 1159010200 |
| 상도동 | 동작구 | 1159010600 |
| 봉천동 | 관악구 | 1162010100 |
| 대림동 | 영등포구 | 1156013600 |
| 신길동 | 영등포구 | 1156013200 |
> 위 코드는 구현 시 행정표준코드관리시스템으로 최종 검증(법정동 통폐합 반영).
### 4.4 heartbeat (워커→Redis, 관측 계약)
`worker:naver-fetch:heartbeat` EX45, 값 JSON:
```json
{"name":"naver-fetch","kind":"fetcher","state":"idle|fetching","ts":<epoch>,
"last_fetch_at":<epoch|null>,"listings_pushed":<int>,"errors":<int>}
```
- `kind="fetcher"`는 신규(기존 render/watcher/trader에 추가). state 의미: idle=대기, fetching=수집중.
## 5. 관측 (팀 규칙 3단계 — `infra_distributed_workers`)
1. **워커 heartbeat** (§4.4) — web-ai.
2. **BE 등재**: `agent-office/app/node_monitor.py``WORKER_REGISTRY += {"name":"naver-fetch","kind":"fetcher","queue":None}`. `collect_status()``fetcher` kind를 처리(heartbeat 유무→alive/dead, link type=`http-pull`(ai_trade/trade-monitor와 동일, redis-queue 아님)). 다운/복구 텔레그램 경보는 기존 1분 cron 재사용(`_notified` 패턴).
3. **FE**: `/infra` `statusVisual``fetcher` 라벨·색 + http-pull 링크. (web-ui, 소규모)
## 6. 인증 / 보안
- **nginx**: 신규 location `/api/internal/realestate/``realestate-lab:8000`, **IP 화이트리스트**(기존 render 워커 화이트리스트에 쓰는 Windows 워커 IP 재사용). (nginx-conf 변경 → 배포)
- **앱 레벨**: realestate-lab이 `X-Internal-Key` 검증(env `INTERNAL_API_KEY`). 다른 서비스 `internal_router` 패턴 준용. GET targets·POST ingest 둘 다 검증.
- 워커는 `.env`로 키 주입(커밋 금지).
## 7. 에러처리 / 멱등성
- 워커: 네이버 차단/토큰만료 → 사이클 skip, 다음 폴링 재시도. heartbeat는 계속 발신(생사 가시성). errors 카운트 증가.
- NAS ingest: article별 try/except(1건 실패 격리). 멱등 — `listings.article_no` UNIQUE upsert + `listing_matches.notified_at` → 같은 매물 재push해도 재알림 없음.
- NAS `collect_listings`: `_fetch_naver_all` 미호출로 변경(MOLIT만). `naver_ok`/naver diag는 collect_log에서 의미 상실 → MOLIT 전용 로그로 정리(컬럼은 nullable 유지, 하위호환).
## 8. 테스트 (BE)
- `test_internal_targets`: criteria seed 후 GET → dongs에 cortar_no 조인, deal_types 반환. 매핑 없는 동 제외.
- `test_internal_ingest`: raw 네이버 article batch POST → upsert_listing 호출 + `run_listing_matching`/`notify_new_listings` 호출(mock) + `{received,new,matched}`. 멱등(재POST시 new=0).
- `test_internal_auth`: X-Internal-Key 없거나 틀리면 401.
- `test_naver_cortar_mapping`: `NAVER_CORTAR` 10자리·대상 6동.
- 워커 테스트는 web-ai(별도).
## 9. 후속 / 알려진 개선점 (비차단)
- `market_deals`/`listings` upsert가 행별 개별 `_conn()` 커밋 → 대량 시 Celeron에서 느림(관측: 매매 승인 후 collect 수 분). 배치 트랜잭션(단일 `_conn` executemany)로 개선 여지.
- 네이버 인증 토큰 만료·갱신 자동화(워커, web-ai).
- page_limit·수집 주기 튜닝(네이버 rate-limit 관측 후).
## 10. 의존성 / 분담 요약
| repo | 작업 | 세션 |
|---|---|---|
| web-backend | targets/ingest 엔드포인트, X-Internal-Key, NAVER_CORTAR 매핑, node_monitor fetcher 등재, nginx `/api/internal/realestate/`, `_fetch_naver_all` deprecate | **BE(이 세션)** |
| web-ai | naver-fetch 워커(폴링+네이버 fetch+토큰+push+heartbeat) + Dockerfile `_shared` 포함 확인 | AI (병렬 spike 포함) |
| web-ui | `/infra` fetcher 노드 시각 | FE (소규모) |
계약(§4)은 co-gahusb로 잠그고 3세션 병렬. 계약 변경 시 web-ai+web-backend(+web-ui) 동시 수정.

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,81 @@
import os, importlib
from unittest.mock import patch
from fastapi.testclient import TestClient
def _client(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
from app import auth as _a
importlib.reload(_a)
from app.main import app
return TestClient(app)
_ARTICLE = {"articleNo": "N100", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
"area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
"buildingName": "OO", "cortarNo": "1159010100"}
def test_ingest_auth(monkeypatch):
c = _client(monkeypatch)
# 헤더 없음 — FastAPI Header(...)는 dependency 실행 전 자체 검증하므로 422.
# test_internal_targets.py의 동일 패턴과 일치(같은 서비스 선례).
assert c.post("/api/internal/realestate/listings-ingest", json={"batches": []}).status_code in (401, 422)
assert c.post("/api/internal/realestate/listings-ingest",
headers={"X-Internal-Key": "WRONG"}, json={"batches": []}).status_code == 401
def test_ingest_upserts_and_matches(monkeypatch):
c = _client(monkeypatch)
# TestClient는 BackgroundTask를 응답 반환 전 동기 실행하므로 patch된 notify는
# c.post()가 리턴하는 시점에 이미 호출되어 있다.
with patch("app.internal_router.notify_new_listings", return_value={"sent": 1, "sent_ids": [1]}) as noti:
r = c.post("/api/internal/realestate/listings-ingest",
headers={"X-Internal-Key": "SECRET"},
json={"fetched_at": "2026-07-09T00:00:00Z",
"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]})
assert r.status_code == 200
body = r.json()
assert body["received"] == 1 and body["new"] == 1 and body["queued"] is True
assert noti.call_count == 1
# 저장 확인
from app import db
ls = db.get_listings()
assert any(l["article_no"] == "N100" and l["dong"] == "신대방동" for l in ls)
def test_ingest_idempotent(monkeypatch):
c = _client(monkeypatch)
payload = {"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]}
with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}):
c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
r2 = c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
assert r2.json()["new"] == 0 # 재push → 신규 0
def test_ingest_holds_match_notify_lock(monkeypatch):
from app import internal_router as ir
from app.pipeline_lock import match_notify_lock
c = _client(monkeypatch)
holds = {}
def fake_notify():
holds["locked"] = match_notify_lock.locked()
return {"sent": 0}
monkeypatch.setattr(ir, "notify_new_listings", fake_notify)
c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"},
json={"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]})
assert holds["locked"] is True # 알림 시점에 락 보유(임계구역 직렬화 증명)
# ── 근본원인 fix: 네이버 article에 cortarNo 없으면 dong_code=None → 시세 매칭 불가 ──
_ARTICLE_NO_CORTAR = {"articleNo": "N200", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
"area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
"buildingName": "OO"} # cortarNo 없음 — 실제 네이버 응답 케이스
def test_ingest_derives_dong_code_from_dong_when_cortarno_missing(monkeypatch):
c = _client(monkeypatch)
with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}):
r = c.post("/api/internal/realestate/listings-ingest",
headers={"X-Internal-Key": "SECRET"},
json={"batches": [{"dong": "신길동", "articles": [_ARTICLE_NO_CORTAR]}]})
assert r.status_code == 200
from app import db
ls = db.get_listings()
saved = next(l for l in ls if l["article_no"] == "N200")
assert saved["dong_code"] == "11560" # 신길동 → 영등포구 5자리 lawd_code로 유도

View File

@@ -0,0 +1,28 @@
import os
from fastapi.testclient import TestClient
def _client(monkeypatch):
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
import importlib
from app import auth as _a
importlib.reload(_a) # env 반영
from app.main import app
return TestClient(app)
def test_targets_requires_key(monkeypatch):
c = _client(monkeypatch)
# 헤더 없음 — FastAPI Header(...)는 dependency 실행 전 자체 검증하므로 422.
# image-lab/tests/test_internal_router.py의 동일 패턴과 일치(sibling 서비스 선례).
assert c.get("/api/internal/realestate/targets").status_code in (401, 422)
assert c.get("/api/internal/realestate/targets",
headers={"X-Internal-Key": "WRONG"}).status_code == 401
def test_targets_returns_dongs_with_cortar(monkeypatch):
c = _client(monkeypatch)
r = c.get("/api/internal/realestate/targets", headers={"X-Internal-Key": "SECRET"})
assert r.status_code == 200
body = r.json()
dongs = {d["dong"]: d["cortar_no"] for d in body["dongs"]}
assert dongs.get("신대방동") == "1159010100" # criteria seed 6동 + cortar join
assert "전세" in body["deal_types"]
assert isinstance(body["page_limit"], int)

View File

@@ -0,0 +1,17 @@
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
def test_naver_cortar_10digit():
from app.lawd_codes import naver_cortar, NAVER_CORTAR
assert naver_cortar("신대방동") == "1159010100"
assert naver_cortar("봉천동") == "1162010100"
assert naver_cortar("신길동") == "1156013200"
assert naver_cortar("없는동") is None
# 6개 초기 대상 동 전부 10자리
for d in ["신대방동","대방동","상도동","봉천동","대림동","신길동"]:
assert len(NAVER_CORTAR[d]) == 10

View File

@@ -0,0 +1,58 @@
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
def test_listings_rematch_endpoint():
"""MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정 — 200 + 요약 카운트."""
from app import db
for i, v in enumerate((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": f"2026{i+1:02d}", "floor": "5", "source": "molit"})
db.upsert_listing({"article_no": "RM1", "source": "naver", "deal_type": "전세",
"deposit": 28000, "area_exclusive": 42.0, "complex_name": "OO",
"dong": "신대방동", "dong_code": "11590"})
db.update_listing_criteria({"dongs": ["신대방동"], "deal_types": ["전세"], "max_deposit": 32000,
"min_area": 40.0, "house_types": ["아파트"]})
r = _client().post("/api/realestate/listings/rematch")
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["passed"] == 1
assert body["judged"] == 1
matches = db.get_listing_matches()
assert matches[0]["safety_tier"] == "안전"

View File

@@ -0,0 +1,57 @@
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_is_molit_only(monkeypatch):
"""collect_listings는 MOLIT만 수행(naver fetch 미호출). naver는 워커 담당."""
from app import listing_collector as lc
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (5, "calls=1 saved=5"))
called = {"naver": False}
def naver_spy():
called["naver"] = True
return (0, 0)
monkeypatch.setattr(lc, "_fetch_naver_all", naver_spy)
r = lc.collect_listings()
assert called["naver"] is False # naver 미호출
assert "molit" in r["diag"]
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,166 @@
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_bulk_upsert_market_deals(monkeypatch):
from app import db
rows = [
{"house_type":"아파트","dong_code":"11590","dong":"신대방동","complex_name":"OO","area":42.0,
"deal_type":"전세","deposit":43000,"sale_amount":None,"deal_ym":"202606","floor":"5"},
{"house_type":"아파트","dong_code":"11590","dong":"신대방동","complex_name":"OO","area":42.0,
"deal_type":"전세","deposit":43000,"sale_amount":None,"deal_ym":"202606","floor":"5"}, # dup
{"house_type":"아파트","dong_code":"11590","dong":None,"complex_name":None,"area":None,
"deal_type":"매매","sale_amount":90000,"deal_ym":"202606","floor":"3"}, # area None → skip
]
n = db.bulk_upsert_market_deals(rows)
assert n == 2 # area=None 1건 skip
deals = db.get_market_deals_for("11590","OO",42.0,"전세",months=120)
assert len(deals) == 1 # dup dedup → 1건
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"]
# ── Task 2: run_listing_matching 단일 conn 성능개선 ──────────────────────────
def test_get_market_deals_for_reuses_passed_conn():
"""conn 인자를 넘기면 그 connection을 재사용하고, 넘기지 않았을 때와 동일한
결과를 반환해야 한다(1차 complex 스코프 → 표본 부족 시 광역 폴백 로직 불변)."""
from app import db
base = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
"sale_amount": None, "floor": "5", "source": "molit"}
db.upsert_market_deal({**base, "deposit": 40000, "deal_ym": "202601"})
db.upsert_market_deal({**base, "deposit": 41000, "deal_ym": "202602"})
db.upsert_market_deal({**base, "deposit": 42000, "deal_ym": "202603"})
without_conn = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120)
with db._conn() as shared:
with_conn = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120, conn=shared)
# conn을 넘겼으니 함수가 스스로 닫지 않아야 함 — 같은 conn으로 후속 쿼리 가능해야 한다.
still_open = shared.execute("SELECT COUNT(*) FROM market_deals").fetchone()[0]
assert len(with_conn) == len(without_conn) == 3
assert still_open == 3
def test_bulk_upsert_listing_matches_multi_insert_and_update():
"""bulk_upsert_listing_matches가 여러 건을 한 번에 upsert하고, 재호출 시
기존 upsert_listing_match와 동일하게 갱신(notified_at 보존)해야 한다."""
from app import db
l1, _ = db.upsert_listing({"article_no": "B1", "source": "naver", "deal_type": "전세",
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
l2, _ = db.upsert_listing({"article_no": "B2", "source": "naver", "deal_type": "매매",
"sale_price": 85000, "area_exclusive": 59.0, "dong": "상도동"})
db.bulk_upsert_listing_matches([
{"listing_id": l1["id"], "category": "임차", "passed": 1, "match_score": 100,
"safety_tier": "안전", "sample_size": 5, "reasons": ["ok"], "is_new": 1},
{"listing_id": l2["id"], "category": "매매", "passed": 0, "match_score": 0,
"valuation_tier": "고가", "sample_size": 4, "reasons": ["매수가능 상한 초과"], "is_new": 1},
])
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
assert len(matches) == 2
assert matches[l1["id"]]["safety_tier"] == "안전" and matches[l1["id"]]["passed"] == 1
assert matches[l2["id"]]["valuation_tier"] == "고가" and matches[l2["id"]]["passed"] == 0
# notified_at 마킹 후 재호출해도 보존돼야 함(재알림 방지)
match_id = next(m["id"] for m in db.get_listing_matches() if m["listing_id"] == l1["id"])
db.mark_listings_notified([match_id])
db.bulk_upsert_listing_matches([
{"listing_id": l1["id"], "category": "임차", "passed": 1, "match_score": 95,
"safety_tier": "주의", "sample_size": 6, "reasons": ["ok"], "is_new": 1},
])
updated = {m["listing_id"]: m for m in db.get_listing_matches()}
assert updated[l1["id"]]["safety_tier"] == "주의" # 갱신됨
assert updated[l1["id"]]["notified_at"] is not None # 보존됨
def test_bulk_upsert_listing_matches_empty_noop():
"""빈 리스트는 아무 것도 하지 않는다(가드)."""
from app import db
db.bulk_upsert_listing_matches([])
assert db.get_listing_matches() == []

View File

@@ -0,0 +1,111 @@
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
# ── Task 2: run_listing_matching 단일 conn 통합 테스트 ───────────────────────
def test_run_listing_matching_end_to_end_safe_rent_and_overpriced_sale():
"""listings+market_deals+criteria를 시드하고 run_listing_matching()을 돌려
listing_matches가 실제로 생성되고 tier/passed가 기대대로인지 확인한다
(단일 connection 리팩터 후에도 판정 로직이 보존됐는지 검증하는 회귀 테스트)."""
from app import db
from app.listing_matcher import run_listing_matching
# criteria: 신대방동 전세/매매, 보증금 상한 32000, 최소면적 40
db.update_listing_criteria({
"dongs": ["신대방동"], "deal_types": ["전세", "매매"],
"max_deposit": 32000, "min_area": 40.0,
"house_types": ["아파트"], "equity": 20000, "annual_income": 6000,
})
# 전세 실거래 시세(median ~42000) — 안전 매물(28000, ratio 0.67)
for i, v in enumerate((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": f"2026{i+1:02d}", "floor": "5", "source": "molit"})
l_rent, _ = db.upsert_listing({"article_no": "R1", "source": "naver", "deal_type": "전세",
"deposit": 28000, "area_exclusive": 42.0,
"complex_name": "OO", "dong": "신대방동", "dong_code": "11590"})
# 매매 실거래 시세(median ~87500) — 고가 매물(95000, ratio 1.09)
for i, v in enumerate((80000, 88000, 90000, 87000)):
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
"complex_name": "PP", "area": 59.0, "deal_type": "매매",
"deposit": None, "sale_amount": v,
"deal_ym": f"2026{i+1:02d}", "floor": "10", "source": "molit"})
l_sale, _ = db.upsert_listing({"article_no": "R2", "source": "naver", "deal_type": "매매",
"sale_price": 95000, "area_exclusive": 59.0,
"complex_name": "PP", "dong": "신대방동", "dong_code": "11590"})
run_listing_matching()
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
assert len(matches) == 2
rent_m = matches[l_rent["id"]]
assert rent_m["passed"] == 1
assert rent_m["safety_tier"] == "안전"
assert rent_m["sample_size"] == 3
sale_m = matches[l_sale["id"]]
assert sale_m["valuation_tier"] == "고가"
assert sale_m["sample_size"] == 4
# ── 근본원인 fix: 기존 저장된 listing.dong_code=None(cortarNo 부재) 행도 dong명에서
# lawd_code를 유도해 즉시 판정돼야 한다(보류 고착 방지) ──
def test_run_listing_matching_derives_dong_code_when_none():
from app import db
from app.listing_matcher import run_listing_matching
db.update_listing_criteria({
"dongs": ["신길동"], "deal_types": ["매매"],
"min_area": 40.0, "house_types": ["아파트"],
"equity": 20000, "annual_income": 6000,
})
# 신길동(영등포구=11560) 매매 실거래 시세 3건
for i, v in enumerate((80000, 88000, 90000)):
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11560", "dong": "신길동",
"complex_name": "QQ", "area": 59.0, "deal_type": "매매",
"deposit": None, "sale_amount": v,
"deal_ym": f"2026{i+1:02d}", "floor": "8", "source": "molit"})
# 매물은 dong_code=None으로 저장(네이버 cortarNo 부재 시나리오)
l_sale, _ = db.upsert_listing({"article_no": "R3", "source": "naver", "deal_type": "매매",
"sale_price": 84000, "area_exclusive": 59.0,
"complex_name": "QQ", "dong": "신길동", "dong_code": None})
run_listing_matching()
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
m = matches[l_sale["id"]]
assert m["sample_size"] == 3
assert m["valuation_tier"] != "보류"

View File

@@ -0,0 +1,51 @@
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 # 미마킹→재시도
def test_notify_caps_and_baselines(monkeypatch):
from app import notifier, db
from unittest.mock import MagicMock
for i in range(10):
row, _ = db.upsert_listing({"article_no": f"C{i}", "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})
monkeypatch.setattr(notifier, "LISTING_NOTIFY_MAX", 8)
captured = {}
fake = MagicMock(); fake.raise_for_status.return_value = None
def fake_post(url, json=None, timeout=None):
captured["n"] = len(json["listings"])
fake.json.return_value = {"sent": len(json["listings"]),
"sent_ids": [m["id"] for m in json["listings"]]}
return fake
monkeypatch.setattr(notifier.requests, "post", fake_post)
notifier.notify_new_listings()
assert captured["n"] == 8 # 8건만 텔레그램 전송
assert len(db.get_unnotified_listing_matches()) == 0 # 나머지 2건 baseline 마킹

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"] == []