diff --git a/CLAUDE.md b/CLAUDE.md index e107b12..101ae56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: **기업정보 모달 (4탭)** — 종목 행 `기업정보` 버튼 → `/api/stock_info?code`(키움 ka10001 + 네이버 분기영업이익 + `fnguide_client` 펀더멘털·컨센서스 + `wisereport_client` 리비전·서프라이즈·최근리포트) fetch → 단일 종목은 4탭 렌더: **요약**(현재가·목표주가·리비전·증가율·서프라이즈 + 최신 리포트 1건) / **기업정보·가치**(PER·PBR·EPS·BPS·ROE·영업이익·52주·유통비율·외국인) / **성장성·컨센서스**(매출/EPS 증가율·목표주가·투자의견·추정PER〔현재PER 병기〕·목표가 리비전·서프라이즈) / **투자리포트**(최근 증권사 리포트 목록, 목표가 ▲상향/▼하향). 탭은 모달 전용 클래스(`.info-tabs/.info-tabbtn/.info-tabpanel` + `data-info-tab`/`data-info-panel`)와 독립 핸들러로 자산탭 `.sub-tab`과 격리(전역 restoreSubs 충돌 회피). 항목별 ⓘ 탭 팝업 설명(`INFO_SPECS`/`INFO_DESC`, document 위임 `.info-label`). 비교 모드(여러 종목)는 탭 없이 기존 비교표 유지. ETF 등 데이터 없으면 해당 탭 "데이터 없어요". **메모 모달 (`note-modal`)**(2026-06-26) — 보유·관심·감시종목 행 모두에 `📝 메모` 버튼. 클릭 → 팝업 textarea(최대 1000자, 여러 줄)에 기존 메모 prefill → [저장]은 `/notes/set` POST(submit 핸들러가 `note-form`을 fetch로 가로채 **현재 탭 유지** — 네이티브 폼이면 303→`/` 리다이렉트로 자산정보 탭으로 튐) 후 패널 새로고침, [삭제]는 버튼 왼쪽 위 작은 **확인 팝업(예/아니오)** 후 빈 텍스트 submit으로 항목 제거(오삭제 방지, `note-confirm`). 메모 있으면 버튼이 `📝 메모 ●` 노란 강조(`has-note`)+hover 툴팁. 저장소 `state/behive_stock_notes.json`(`{by_code, by_name}`, code 우선→name fallback, `stock_tags`와 동일한 fcntl lock·atomic write). **종목 단위 공용**(계좌 분리 X). 백엔드 `_load/_get/_set_stock_note`·`_note_button_html`·`_render_note_modal`, 태그 모달 패턴 복제. -- `send_balance_to_budget.py` — 매월 1일 04:30 launchd. 본인 계좌(가희 제외)별 잔액·예수금·평가액을 집계해 `agents/budget/inbox/incoming/`에 envelope(`topic: securities_balance`)로 떨어뜨린다. 골디 월간 결산(05:00) 입력. LLM 미경유, 실패 시 레이 텔레그램으로 자가 알림. +- `send_balance_to_budget.py` — 매월 1일 04:30 launchd. 본인+가희 계좌를 소유자별(self/gahee)로 잔액·예수금·평가액 집계해 `agents/budget/inbox/incoming/`에 envelope(`topic: securities_balance`, schema v2 `by_owner`)로 떨어뜨린다. 골디가 self→`증권(효원)`·gahee→`증권(가희)`로 reconcile 분개. 골디 월간 결산(05:00) 입력. LLM 미경유, 실패 시 레이 텔레그램으로 자가 알림. - `trade_journal.py` — 종목별 매매 기록 누적. 키움 REST에 기간 거래내역 API 부재 → 평일 21:00 launchd(NXT 야간 마감 후)로 ka10170 4계좌 호출해 `state/trade_journal.jsonl`에 적재. `(date, account)` 단위 idempotent, 휴장일/주말 self-skip(`--force`로 우회). 적재 시작일 2026-05-13 이전 보유분은 `seed` 명령(1회)으로 현재 평단가×(보유-당일매수+당일매도) 단일 행으로 압축 적재됨(`seed=true` 플래그·`*` 마커). CLI: `collect [--date YYYYMMDD]` / `seed` / `show ` / `query [--from --to --account --code]` - `market_indicators_sync.py` — 시장 단위 ADR·투자자별 매매 누적. 네이버 m.stock `/api/index/{KOSPI|KOSDAQ}/integration` 한 콜로 dealTrendInfo·upDownStockInfo 수집 → `state/market_indicators_history.jsonl`. `(date, market)` 단위 idempotent, 휴장/주말 self-skip(`--force`). 평일 21:00 stock.trade-journal launchd에 통합 발화 (별도 plist 없음). KRX 정보데이터시스템은 응답 패턴 변경으로 백필 보류 — 매일 누적만 시작. behive_web 자산정보 탭의 시장정보 sub-tab이 sparkline 데이터원으로 사용 - `sim/` (스크립트 아닌 **별도 패키지** — `agents/stock/workspace/sim/`) — **자동매매 시뮬레이션(페이퍼 트레이딩) 엔진**. 실주문 `orders/`와 완전 분리, 가상자본 1억으로만 동작(`INITIAL_CAPITAL`, 2026-06-10 1천만→1억). 구성: `config.py`(파라미터)·`indicators.py`(SMA/RSI/ATR/고저/거래량 순수함수)·`universe.py`(**누적 관찰목록** `state/sim/watchlist.json` — 비하이브 워치+관심+본인보유를 매 스캔 **자동 편입**하되 **자동 삭제 X**(수동만). `sync_watchlist`/`add_manual`/`remove`)·`data.py`(시세 batch·일봉 **캐시 우선**·수급 60m캐시·애널〔fnguide/wisereport 12h 캐시, **캐시 미스 때만 0.4s 페이싱**으로 연속 크롤링 차단 회피〕)·`signals.py`(진입/청산 규칙 + 손절·목표 계산 + dashboard용 체크리스트)·`portfolio.py`(가상 체결·비용·손익·MDD)·`engine.py`(스캔 오케스트레이션 — `_gather(extra_codes)`로 **보유 종목은 관찰목록에서 빠져도 청산 추적**)·`benchmark.py`(**기준지수 대비 알파** — 네이버 m.stock 지수 일별종가를 `state/sim/index_history.json`에 누적〔KOSPI/KOSDAQ, pageSize≤50〕, 구간 등락률·초과수익 계산. 스캔마다 `update_today` 1회 갱신, 실패 무시)·`__main__.py`(CLI)·`sim_web.py`(별도 대시보드 서버 18792). 전략: 방향=애널 약한게이트+가점, 타이밍=수급·기술. **ETF/ETN은 자동매수 제외**(2026-06-09 — 방향성 엔진인 애널 컨센서스가 ETF엔 None이라 전략 부정합. `universe.is_etf()`가 운용사 프리픽스(KODEX/TIGER/ACE…)로 판별, engine 미보유 매수 분기 직전에 `ETF — 자동매매 제외` SKIP. **보유 ETF는 청산까지 추적**(매수 분기 전 처리라 무관)). 매수=거래량+전고점 돌파 즉시 / 평소 눌림목(max(20일선, 전일종가−ATR)) 지정가. **시장 국면 필터(6단계)**: `market_regime_map()`이 상승비율(폭)+지수추세(방향, 20일선)+당일등락(강도)을 조합해 `engine.REGIME` 6단계로 분류 — 🔥강세·📈반등(상승비율↑ & 지수 20일선 아래=약세탈출)·🌤완만상승 / ➖혼조·📉약세·❄️급락. **앞 3개=매수허용, 뒤 3개=보류**(`market_ok_map`이 `REGIME[g][2]`로 파생). 상승비율은 **항상 네이버 실시간**(`_live_breadth` 60s 캐시, 실패 시 EOD). 약세장이어도 **돌파 진입은 허용, 눌림목만 보류**(soft gate). 시장탭에 국면 배지+설명 표시(매수판단과 동일 소스). `code_market_map()`이 종목→KOSPI/KOSDAQ 매핑. (2026-06-09 강세/약세 2단계→6단계 국면으로 세분화) **2026-06-09 4종 추가**: ⓐ **리스크 기반 사이징**(`signals.target_value` — 수량=자산×`RISK_PER_TRADE_PCT` ÷ 손절폭, 균등비중 자산/`MAX_POSITIONS` 상한 캡, 0=끔. 변동성 큰 종목 자동 축소·종목당 리스크 균등화. 엔진·백테스트 공용) ⓑ **RSI 필터**(`indicators.rsi` 활성화 — 돌파 시 `RSI_OVERBOUGHT`↑ 과열 추격 보류 / 눌림목 시 `RSI_OVERSOLD`↓ 낙하 중 보류, 데이터 없으면 통과) ⓒ **시장 국면 6단계**(`market_regime_map`→`engine.REGIME`, 위 시장 국면 필터 참조. `_index_trend_ok`·`_index_daily_change` 보조. ⚠️2026-06-09 강세/약세 2단계가 84% 반등일을 약세로 오판해 6단계 국면으로 세분화) ⓓ **시간손절**(`MAX_HOLD_DAYS` 경과 & 미진전〔트레일링 전+진입가 이하〕 시 청산해 자본 회전, `evaluate_holding(held_days)` — 엔진=달력일·백테스트=`entry_date` 달력일). ⓖ **스캔 메모 4종**(2026-06-10): 변이 수가 늘어도 스캔이 선형 폭증하지 않게 — 호가(`data._BOOK_MEMO`, 체결 공정성 겸용)·일봉(`engine._SCAN_CANDLES`)·지표(`engine._IND_MEMO`, (code,지표파라미터) 키)·수급/애널(`data._FLOW_MEMO_SCAN`/`_ANL_MEMO_SCAN`)을 스캔 1회 동안 공유, `_gather`가 매 스캔 리셋. 핵심은 애널 캐시 stale 시 0.4s 페이싱이 변이 수만큼 반복되던 것 — 108계좌 판단 38s→2s. ⓕ **2026-06-10 코드리뷰 3종**: 재진입 쿨다운(전량청산 종목 **당일 재매수 금지** — `Portfolio.last_exit`/`exited_today`, backtest 동일 규칙. 휩쏘 churn 방지)·장중 거래량 환산(`_session_elapsed_frac` 0.15~1.0을 `ind['vol_time_frac']`로 주입 → signals가 돌파 거래량·회전율을 하루치로 환산 비교, '(환산)' 표기. 이전엔 오전 돌파가 구조적으로 불가했음. backtest는 1.0=무영향)·일별 성과이력(`state/sim/equity_history.json` — 매 스캔 당일 항목 덮어써 EOD값 보존, `{date:[{id,ph(파라미터해시),equity,ret}]}`. 변이 id 재사용돼도 ph로 식별. 국면별 사후분석용) ⓔ **추세 질 보강**(2026-06-09 `trend_ok` 게이트 강화: 기존 현재가>20일선+정배열(5>20)에 더해 ①**20일선 우상향 기울기**〔`sma_long_slope_up`, `TREND_SLOPE_DAYS`전 대비〕 ②**중기 정배열 5>`SMA_MID`(60)** — 둘 다 종목데이터라 **백테스트 반영**, 데이터 부족 시 통과. ③**상대강도**〔`rel_strength`=종목−지수 `REL_STRENGTH_DAYS`(20)일 수익률, engine이 주입하는 **라이브 전용**, 백테스트 None=중립, 체크표시만〕). **분할매수·추격매수**: 목표비중을 `ENTRY_TRANCHES`(기본 3)로 나눠 1/3씩 진입, 직전 진입가+ATR×`ADD_ATR_MULT` 돌파 & 추세 지속 시 다음 트랜치 추가매수(평단 가중평균, 손절 위로만 래칫)·트레일링/일부익절 시작 후엔 추가 종료. 매도=손절(max(진입−ATR×2, 10일저점))·**분할매도**(목표 도달 시 `SCALE_OUT_FRAC`(기본 50%) 익절 후 잔량 트레일링(고점−ATR×1.5))·추세수급 이탈 시 전량 청산. 부분매도는 closed/win 미카운트(전량 청산 시만 카운트). 결정론적(LLM 미경유). **기준지수 대비(알파)**: 가상계좌/백테스트 수익률을 같은 기간 KOSPI·KOSDAQ '매수후보유' 대비로 평가(수익률만으론 지수 못 이기면 무의미하므로). 라이브는 계좌 시작일~오늘, 백테스트는 윈도우 구간 대비. KPI '코스피/코스닥 대비(알파)' + 백테스트·비교 탭 알파 행. 경과기간 0(시작 당일)·캐시 부재 시 None('데이터 없음'). 상태 `state/sim/{portfolio.json,trades.jsonl,last_scan.json,index_history.json}`. CLI: `python3 -m sim {scan [--force]|status|report|reset --yes|sweep [frac]|backtest|backfill [--pages N] [--force]|backfill-index [--pages N]|variants {seed N|list|reset|clear}}` (cwd=workspace). launchd `stock.sim-scan`(15분)·`stock.sim-web`(상시). diff --git a/INBOX_TOPICS.md b/INBOX_TOPICS.md index af30175..61896e6 100644 --- a/INBOX_TOPICS.md +++ b/INBOX_TOPICS.md @@ -11,44 +11,53 @@ OpenClaw 에이전트 간 파일 기반 통신의 topic 카탈로그. envelope ### `securities_balance` - **방향:** `stock` → `budget` -- **목적:** 본인 증권 계좌별 잔액(평가액·예수금·총자산)을 골디에게 전달, 월간 결산 입력으로 사용 +- **목적:** 본인+가희 증권 계좌별 잔액(평가액·예수금·총자산)을 소유자별로 골디에게 전달, 월간 결산 입력으로 사용 - **트리거:** macOS launchd `ai.openclaw.stock.send-balance` — 매월 1일 04:30 KST. 골디 결산 cron(05:00) 30분 전. (`inbox_handler.py`의 `as_of` 가드는 1·10·20일을 허용 — 과거 운영 호환 안전망) -- **schema_version:** 1 -- **payload 스키마:** +- **schema_version:** 2 (v1 하위호환 유지 — 수신자 `SUPPORTED_SCHEMA={1,2}`) +- **payload 스키마 (v2):** ```json { - "as_of": "2026-05-01", - "owner_scope": "self_only", + "as_of": "2026-07-01", + "owner_scope": "self_and_gahee", "accounts": [ { "label": "일반", + "owner": "self", "account_no": "", - "deposit": 118743, - "eval_amount": 63177225, - "total": 63295968, - "position_count": 4 + "deposit": 56032, + "eval_amount": 114294705, + "total": 114350737, + "position_count": 7 }, - { "label": "ISA", ... } + { "label": "ISA", "owner": "self", ... }, + { "label": "가희_일반", "owner": "gahee", ... }, + { "label": "가희_ISA", "owner": "gahee", ... } ], + "by_owner": { + "self": { "deposit": 146362, "eval_amount": 188964561, "total": 189110923 }, + "gahee": { "deposit": 138469, "eval_amount": 22587331, "total": 22725800 } + }, "totals": { - "deposit": 843705, - "eval_amount": 114519255, - "total": 115362960 + "deposit": 284831, + "eval_amount": 211551892, + "total": 211836723 } } ``` -- **owner_scope:** 현재 `self_only`만 발행 (가희 계좌 제외 — `가희_` prefix 라벨 자동 필터). 향후 가희 포함 버전 필요해지면 `with_gahee` 등 새 값 도입 +- **owner_scope:** v2는 `self_and_gahee` — 본인·가희 모두 발행. `accounts[].owner` 는 `self`|`gahee` (`가희_` prefix 라벨이 gahee). 소유자 집계는 `by_owner`. `totals` 는 참고용 grand total. (v1은 `self_only`·`by_owner` 없음, 단일 소유자로 처리) - **수신자(골디) 처리 동작:** - 월간결산 cron 진입부에서 `inbox_handler.process_inbox()` 호출 (fetch_balance 이전 — 분개가 후잉 잔액에 반영되어 결산이 분개 후 스냅샷을 보도록) - - payload의 `totals.total` 과 후잉 자산 `증권(효원)` 의 차액을 계산: - - 차액 > 0 → 차변 `증권(효원)` / 대변 `주식평가수익` 자동 분개 - - 차액 < 0 → 차변 `주식평가손실` / 대변 `증권(효원)` 자동 분개 + - `by_owner` 의 각 소유자 total 과 후잉 자산(`self`→`증권(효원)`, `gahee`→`증권(가희)`)의 차액을 소유자별로 reconcile: + - 차액 > 0 → 차변 `증권(효원|가희)` / 대변 `주식평가수익` 자동 분개 (손익 계정은 소유자 공용) + - 차액 < 0 → 차변 `주식평가손실` / 대변 `증권(효원|가희)` 자동 분개 - `|차액| < 1만원` → 노이즈로 간주, 분개 skip (processed 처리) - `|차액| > 1억원` → 안전 가드 발동, 분개 거부 + 텔레그램 alert + `failed/` 이동 - - 처리 후 `processed/`로 이동, `state/inbox_state.json` 의 `processed[]` 에 message_id 누적 (idempotency, 최근 1000개) - - 결산 메일 본문에 `## 인박스 reconcile` 섹션, 텔레그램에 한 줄 요약 추가 + - 후잉에 해당 자산 항목 없으면 `skipped` (분개 안 함) + - 소유자 하나라도 rejected/journal_failed 면 `failed/` 이동·미처리. 재실행 시 이미 분개된 소유자는 fresh 조회로 차액≈0 aligned → 중복분개 없음 (reconcile는 목표값으로 맞추기라 자연 idempotent) + - 정상 처리 후 `processed/`로 이동, `state/inbox_state.json` 의 `processed[]` 에 message_id 누적 (idempotency, 최근 1000개) + - 결산 메일 본문에 `## 인박스` reconcile 섹션(소유자별 라인), 텔레그램에 한 줄 요약 추가. 메일 "자산 계정별 변동"은 후잉 자산 전수 순회라 `증권(가희)`도 자동 표시 - **GC / 적체 정책 (월간결산 cron 진입부에서 매월 자동 수행):** - `processed/` 의 mtime 30일 초과 envelope 자동 삭제 (`gc_processed`) - `failed/` 적체가 5건 이상이면 결산 메일·텔레그램에 ⚠️ alert 한 줄 추가 — 사람이 검토 후 수동 삭제 (자동 삭제 안 함, CLAUDE.md 원칙 준수) @@ -57,8 +66,9 @@ OpenClaw 에이전트 간 파일 기반 통신의 topic 카탈로그. envelope - envelope 키 누락 / `to != budget` / 미등록 topic / 미지원 `schema_version` - payload 키 누락 (`as_of`, `accounts`, `totals`, `owner_scope`) - `as_of` 가 매월 1·10·20일이 아님 - - `totals.{deposit,eval_amount,total}` 음수 또는 100억 초과 + - `totals`/`by_owner.*` 의 `{deposit,eval_amount,total}` 음수 또는 100억 초과 - `accounts[].total` 합계가 `totals.total` 과 불일치 + - `by_owner` 에 미등록 소유자 (self/gahee 외) 또는 구조 오류 - 차액이 1억원 초과 (분개 거부) - 후잉 webhook 분개 실패 - **관련 스크립트:** diff --git a/agents/budget/workspace/skills/monthly-settlement/scripts/inbox_handler.py b/agents/budget/workspace/skills/monthly-settlement/scripts/inbox_handler.py index 3bdde7a..35fcea8 100644 --- a/agents/budget/workspace/skills/monthly-settlement/scripts/inbox_handler.py +++ b/agents/budget/workspace/skills/monthly-settlement/scripts/inbox_handler.py @@ -43,11 +43,13 @@ BALANCE_SCRIPT = WORKSPACE / 'skills' / 'whooing-sync' / 'scripts' / 'whooing_ba TELEGRAM_ACCOUNT = 'budget' KNOWN_TOPICS = {'securities_balance'} -SUPPORTED_SCHEMA = {1} +SUPPORTED_SCHEMA = {1, 2} -SECURITIES_ASSET_NAME = '증권(효원)' +SECURITIES_ASSET_NAME = '증권(효원)' # v1(단일 소유자) 경로 및 self 자산명 SECURITIES_GAIN_NAME = '주식평가수익' SECURITIES_LOSS_NAME = '주식평가손실' +# 소유자 → 후잉 자산명 (v2). 손익 계정(주식평가수익/손실)은 양쪽 공용. +OWNER_ASSET = {'self': '증권(효원)', 'gahee': '증권(가희)'} RECONCILE_NOISE_FLOOR = 10_000 # |차액| < 1만원: 노이즈, 분개 skip RECONCILE_HARD_CAP = 100_000_000 # |차액| > 1억원: 분개 거부 (안전 가드) @@ -124,6 +126,17 @@ def validate_envelope(env: dict) -> None: raise ValidationError('payload 가 dict 가 아님') +def _validate_amount_block(block: dict, where: str) -> None: + for key in ('deposit', 'eval_amount', 'total'): + if key not in block: + raise ValidationError(f'{where}.{key} 누락') + v = block[key] + if not isinstance(v, int) or v < 0: + raise ValidationError(f'{where}.{key} 비정상: {v!r}') + if v > PAYLOAD_AMOUNT_CAP: + raise ValidationError(f'{where}.{key} 100억 초과 (가드): {v:,}') + + def validate_securities_payload(payload: dict) -> None: for key in ('as_of', 'accounts', 'totals', 'owner_scope'): if key not in payload: @@ -137,24 +150,28 @@ def validate_securities_payload(payload: dict) -> None: if as_of_dt.day not in (1, 10, 20): raise ValidationError(f'as_of 는 매월 1·10·20일이어야 함: {as_of}') - totals = payload['totals'] - for key in ('deposit', 'eval_amount', 'total'): - if key not in totals: - raise ValidationError(f'totals.{key} 누락') - v = totals[key] - if not isinstance(v, int) or v < 0: - raise ValidationError(f'totals.{key} 비정상: {v!r}') - if v > PAYLOAD_AMOUNT_CAP: - raise ValidationError(f'totals.{key} 100억 초과 (가드): {v:,}') + _validate_amount_block(payload['totals'], 'totals') if not isinstance(payload['accounts'], list) or not payload['accounts']: raise ValidationError('accounts 비어있음') sum_total = sum(int(a.get('total', 0)) for a in payload['accounts']) - if sum_total != totals['total']: + if sum_total != payload['totals']['total']: raise ValidationError( - f'accounts 합계 불일치: {sum_total:,} vs totals.total={totals["total"]:,}' + f'accounts 합계 불일치: {sum_total:,} vs totals.total={payload["totals"]["total"]:,}' ) + # v2: 소유자별 집계(by_owner) 검증. 미등록 소유자·구조 오류 차단. + by_owner = payload.get('by_owner') + if by_owner is not None: + if not isinstance(by_owner, dict) or not by_owner: + raise ValidationError('by_owner 비어있음') + for owner, block in by_owner.items(): + if owner not in OWNER_ASSET: + raise ValidationError(f'by_owner 미등록 소유자: {owner}') + if not isinstance(block, dict): + raise ValidationError(f'by_owner.{owner} 가 dict 가 아님') + _validate_amount_block(block, f'by_owner.{owner}') + def fetch_whooing_balance(as_of: str | None = None) -> dict: cmd = ['python3', str(BALANCE_SCRIPT), '--json'] @@ -197,23 +214,25 @@ def post_whooing(payload: dict, dry_run: bool = False) -> tuple[bool, str]: return False, str(e) -def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> dict: - """차액 reconcile + 후잉 자동 분개. 반환: {action, delta, before, after, journal_ok, note, journal?}. +def _reconcile_asset(asset_name: str, owner: str, target_total: int, + as_of: str, message_id: str, current_balance: dict, + dry_run: bool = False) -> dict: + """자산 하나를 목표총액으로 reconcile + 후잉 자동 분개. 반환 result dict. action ∈ {aligned, journaled, noise, skipped, rejected, journal_failed}. """ - payload = envelope['payload'] - target_total = int(payload['totals']['total']) - current = get_whooing_balance_for(SECURITIES_ASSET_NAME, current_balance) + current = get_whooing_balance_for(asset_name, current_balance) base = { 'topic': 'securities_balance', - 'as_of': payload.get('as_of'), + 'owner': owner, + 'asset_name': asset_name, + 'as_of': as_of, 'target_total': target_total, } if current is None: return { **base, 'action': 'skipped', - 'note': f'후잉 자산에 "{SECURITIES_ASSET_NAME}" 항목 없음', + 'note': f'후잉 자산에 "{asset_name}" 항목 없음', 'delta': None, 'before': None, 'after': None, 'journal_ok': None, } delta = target_total - current @@ -230,14 +249,14 @@ def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bo 'before': current, 'after': current, 'journal_ok': False, 'note': f'차액 {delta:+,}원 — 1억원 초과, 분개 거부 (가드)'} - entry_date = payload['as_of'].replace('-', '') - ym = payload['as_of'][:7] + entry_date = as_of.replace('-', '') + ym = as_of[:7] if delta > 0: item = f'{ym} 평가차익' - left, right, money = SECURITIES_ASSET_NAME, SECURITIES_GAIN_NAME, delta + left, right, money = asset_name, SECURITIES_GAIN_NAME, delta else: item = f'{ym} 평가차손' - left, right, money = SECURITIES_LOSS_NAME, SECURITIES_ASSET_NAME, -delta + left, right, money = SECURITIES_LOSS_NAME, asset_name, -delta journal = { 'entry_date': entry_date, @@ -245,7 +264,7 @@ def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bo 'money': str(money), 'left': left, 'right': right, - 'memo': f'레이 inbox reconcile (msg={envelope["message_id"][:8]})', + 'memo': f'레이 inbox reconcile (msg={message_id[:8]})', } ok, body = post_whooing(journal, dry_run=dry_run) return { @@ -260,6 +279,31 @@ def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bo } +def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> list[dict]: + """소유자별 reconcile. v2(by_owner)는 소유자마다, v1은 totals.total→증권(효원) 단건. + 반환: result dict 리스트. + """ + payload = envelope['payload'] + as_of = payload['as_of'] + msg_id = envelope['message_id'] + by_owner = payload.get('by_owner') + + if by_owner: + results = [] + for owner, block in sorted(by_owner.items()): # 'gahee' < 'self' — 결정적 순서 + results.append(_reconcile_asset( + OWNER_ASSET[owner], owner, int(block['total']), + as_of, msg_id, current_balance, dry_run=dry_run, + )) + return results + + # v1 하위호환: 단일 소유자(self) → 증권(효원) + return [_reconcile_asset( + SECURITIES_ASSET_NAME, 'self', int(payload['totals']['total']), + as_of, msg_id, current_balance, dry_run=dry_run, + )] + + def move_to(src: Path, dest_dir: Path, dry_run: bool = False) -> None: if dry_run: return @@ -350,24 +394,28 @@ def process_inbox(current_balance: dict | None = None, dry_run: bool = False) -> validate_securities_payload(env['payload']) if balance is None: balance = fetch_whooing_balance() - result = handle_securities_balance(env, balance, dry_run=dry_run) - summary['reconcile'].setdefault(topic, []).append(result) + results = handle_securities_balance(env, balance, dry_run=dry_run) + summary['reconcile'].setdefault(topic, []).extend(results) - if result['action'] in ('rejected', 'journal_failed'): + # 소유자 하나라도 rejected/journal_failed 면 failed 로. + # 재실행 시 이미 분개된 소유자는 fresh 조회로 delta≈0 aligned → 중복분개 없음. + bad = [r for r in results if r['action'] in ('rejected', 'journal_failed')] + if bad: move_to(fpath, FAILED_DIR, dry_run=dry_run) - reason = result['note'] + reason = '; '.join(f'{r["asset_name"]}: {r["note"]}' for r in bad) summary['failed'].append({'file': fpath.name, 'reason': reason, 'message_id': msg_id}) - send_telegram(f'⚠️ 골디 inbox: 분개 {result["action"]}\n{fpath.name}\n{reason}') + send_telegram(f'⚠️ 골디 inbox: 분개 {"/".join(r["action"] for r in bad)}\n{fpath.name}\n{reason}') continue - # 분개 성공/aligned/noise/skipped 모두 processed 로 이동 - if result.get('journal_ok') and result['action'] == 'journaled' and balance is not None: - # 후잉 잔액 in-memory 업데이트 (다음 파일 reconcile 시 정합성 위해) - for sec in balance.get('sections', []): - items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or [] - for it in items: - if it.get('name') == SECURITIES_ASSET_NAME: - it['money'] = result['after'] + # 분개 성공분은 후잉 잔액 in-memory 업데이트 (동일 run 내 정합성 위해) + if balance is not None: + for r in results: + if r.get('journal_ok') and r['action'] == 'journaled': + for sec in balance.get('sections', []): + items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or [] + for it in items: + if it.get('name') == r['asset_name']: + it['money'] = r['after'] else: raise ValidationError(f'핸들러 없음: {topic}') @@ -415,22 +463,23 @@ def format_summary(summary: dict) -> str: before = result.get('before') after = result.get('after') delta = result.get('delta') + label = result.get('asset_name', '증권(효원)') if action == 'aligned': - lines.append(f'- **증권(효원):** {before:,}원 — 레이 ground truth 와 일치') + lines.append(f'- **{label}:** {before:,}원 — 레이 ground truth 와 일치') elif action == 'journaled': sign = '평가차익' if (delta or 0) > 0 else '평가차손' lines.append( - f'- **증권(효원):** {before:,}원 → {after:,}원 ' + f'- **{label}:** {before:,}원 → {after:,}원 ' f'({sign} **{abs(delta):,}원** 자동 분개)' ) elif action == 'noise': - lines.append(f'- **증권(효원):** 차액 {delta:+,}원 (1만원 미만, 분개 skip)') + lines.append(f'- **{label}:** 차액 {delta:+,}원 (1만원 미만, 분개 skip)') elif action == 'skipped': - lines.append(f'- **증권(효원):** {result.get("note", "skip")}') + lines.append(f'- **{label}:** {result.get("note", "skip")}') elif action == 'rejected': - lines.append(f'- **증권(효원):** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)') + lines.append(f'- **{label}:** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)') elif action == 'journal_failed': - lines.append(f'- **증권(효원):** ⚠️ 분개 실패 — {result.get("note", "")[:120]}') + lines.append(f'- **{label}:** ⚠️ 분개 실패 — {result.get("note", "")[:120]}') if summary['failed']: lines.append('') diff --git a/agents/budget/workspace/skills/monthly-settlement/scripts/monthly_settlement.py b/agents/budget/workspace/skills/monthly-settlement/scripts/monthly_settlement.py index 49ac34d..21bc04d 100755 --- a/agents/budget/workspace/skills/monthly-settlement/scripts/monthly_settlement.py +++ b/agents/budget/workspace/skills/monthly-settlement/scripts/monthly_settlement.py @@ -238,19 +238,20 @@ def format_inbox_telegram_line(summary: dict) -> str: for r in summary.get("reconcile", {}).get("securities_balance", []): action = r.get("action") delta = r.get("delta") or 0 + label = r.get("asset_name", "증권(효원)") if action == "aligned": - parts.append("증권 일치") + parts.append(f"{label} 일치") elif action == "journaled": sign = "차익" if delta > 0 else "차손" - parts.append(f"증권 평가{sign} {abs(delta):,}원 분개") + parts.append(f"{label} 평가{sign} {abs(delta):,}원 분개") elif action == "noise": - parts.append(f"증권 차액 {delta:+,}원 (노이즈)") + parts.append(f"{label} 차액 {delta:+,}원 (노이즈)") elif action == "rejected": - parts.append(f"⚠️ 증권 차액 {delta:+,}원 거부") + parts.append(f"⚠️ {label} 차액 {delta:+,}원 거부") elif action == "journal_failed": - parts.append("⚠️ 증권 분개 실패") + parts.append(f"⚠️ {label} 분개 실패") elif action == "skipped": - parts.append("증권 reconcile skip") + parts.append(f"{label} reconcile skip") # rejected/journal_failed 는 위 reconcile 라인에서 이미 표시됨 — 중복 카운트 방지 reconcile_failed = sum( 1 for r in summary.get("reconcile", {}).get("securities_balance", []) diff --git a/agents/stock/workspace/scripts/send_balance_to_budget.py b/agents/stock/workspace/scripts/send_balance_to_budget.py index bb3db84..9e9e473 100755 --- a/agents/stock/workspace/scripts/send_balance_to_budget.py +++ b/agents/stock/workspace/scripts/send_balance_to_budget.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -"""매월 1일 04:30 — 본인 계좌 잔액을 골디 inbox에 envelope로 떨어뜨린다. +"""매월 1일 04:30 — 본인+가희 계좌 잔액을 골디 inbox에 envelope로 떨어뜨린다. -가희 계좌는 제외 (label prefix '가희_'). 본인 계좌별 평가액(kt00018)·예수금(kt00001)·총자산을 집계. +소유자별(본인 self / 가희 gahee, label prefix '가희_'로 판별)로 평가액(kt00018)·예수금(kt00001)· +총자산을 집계해 payload.by_owner에 담는다. 골디는 self→증권(효원), gahee→증권(가희)로 reconcile. LLM 불필요 — launchd로 실행. 실패 시 레이 텔레그램으로 자가 알림. """ from __future__ import annotations @@ -26,7 +27,7 @@ INBOX_DIR = Path('/Users/snowoyh/.openclaw/agents/budget/inbox/incoming') CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json') TELEGRAM_ACCOUNT = 'stock' TOPIC = 'securities_balance' -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 GAHEE_PREFIX = '가희_' @@ -60,13 +61,12 @@ def send_telegram(text: str) -> bool: return ok -def collect_owner_accounts() -> list[dict]: +def collect_accounts() -> list[dict]: accounts = kw.list_accounts() out = [] for a in accounts: label = a['label'] - if label.startswith(GAHEE_PREFIX): - continue + owner = 'gahee' if label.startswith(GAHEE_PREFIX) else 'self' balance = kw.get_balance(label) positions = kw.get_positions(label) eval_amount = sum(p.get('evlt_amt', 0) for p in positions) @@ -75,6 +75,7 @@ def collect_owner_accounts() -> list[dict]: deposit = balance.get('d2_entra', 0) out.append({ 'label': label, + 'owner': owner, 'account_no': a.get('account_no', ''), 'deposit': deposit, 'eval_amount': eval_amount, @@ -86,6 +87,12 @@ def collect_owner_accounts() -> list[dict]: def build_message(accounts: list[dict]) -> dict: now = datetime.now(KST) + by_owner: dict[str, dict] = {} + for a in accounts: + o = by_owner.setdefault(a['owner'], {'deposit': 0, 'eval_amount': 0, 'total': 0}) + o['deposit'] += a['deposit'] + o['eval_amount'] += a['eval_amount'] + o['total'] += a['total'] return { 'message_id': str(uuid.uuid4()), 'from': 'stock', @@ -95,8 +102,9 @@ def build_message(accounts: list[dict]) -> dict: 'schema_version': SCHEMA_VERSION, 'payload': { 'as_of': now.strftime('%Y-%m-%d'), - 'owner_scope': 'self_only', + 'owner_scope': 'self_and_gahee', 'accounts': accounts, + 'by_owner': by_owner, 'totals': { 'deposit': sum(a['deposit'] for a in accounts), 'eval_amount': sum(a['eval_amount'] for a in accounts), @@ -117,9 +125,9 @@ def write_message(msg: dict) -> Path: def main() -> int: try: - accounts = collect_owner_accounts() + accounts = collect_accounts() if not accounts: - raise RuntimeError('본인 계좌 0개 — 자격증명 또는 prefix 필터 점검 필요') + raise RuntimeError('계좌 0개 — 자격증명 점검 필요') msg = build_message(accounts) path = write_message(msg) total = msg['payload']['totals']['total']