feat(budget): 가희 증권 자산 후잉·결산 반영 (securities_balance v2)
- send_balance_to_budget.py: 본인+가희 계좌 소유자별(by_owner) 집계, schema v1→v2 - inbox_handler.py: OWNER_ASSET 맵으로 소유자별 reconcile(self→증권(효원)/gahee→증권(가희)), asset_name 기반 출력, v2 payload 검증 추가(v1 하위호환 유지) - monthly_settlement.py: 텔레그램 reconcile 라인 소유자별 라벨 - INBOX_TOPICS.md / CLAUDE.md: v2 스키마 문서화 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+30
-20
@@ -11,44 +11,53 @@ OpenClaw 에이전트 간 파일 기반 통신의 topic 카탈로그. envelope
|
|||||||
### `securities_balance`
|
### `securities_balance`
|
||||||
|
|
||||||
- **방향:** `stock` → `budget`
|
- **방향:** `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일을 허용 — 과거 운영 호환 안전망)
|
- **트리거:** 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
|
- **schema_version:** 2 (v1 하위호환 유지 — 수신자 `SUPPORTED_SCHEMA={1,2}`)
|
||||||
- **payload 스키마:**
|
- **payload 스키마 (v2):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"as_of": "2026-05-01",
|
"as_of": "2026-07-01",
|
||||||
"owner_scope": "self_only",
|
"owner_scope": "self_and_gahee",
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"label": "일반",
|
"label": "일반",
|
||||||
|
"owner": "self",
|
||||||
"account_no": "",
|
"account_no": "",
|
||||||
"deposit": 118743,
|
"deposit": 56032,
|
||||||
"eval_amount": 63177225,
|
"eval_amount": 114294705,
|
||||||
"total": 63295968,
|
"total": 114350737,
|
||||||
"position_count": 4
|
"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": {
|
"totals": {
|
||||||
"deposit": 843705,
|
"deposit": 284831,
|
||||||
"eval_amount": 114519255,
|
"eval_amount": 211551892,
|
||||||
"total": 115362960
|
"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 이전 — 분개가 후잉 잔액에 반영되어 결산이 분개 후 스냅샷을 보도록)
|
- 월간결산 cron 진입부에서 `inbox_handler.process_inbox()` 호출 (fetch_balance 이전 — 분개가 후잉 잔액에 반영되어 결산이 분개 후 스냅샷을 보도록)
|
||||||
- payload의 `totals.total` 과 후잉 자산 `증권(효원)` 의 차액을 계산:
|
- `by_owner` 의 각 소유자 total 과 후잉 자산(`self`→`증권(효원)`, `gahee`→`증권(가희)`)의 차액을 소유자별로 reconcile:
|
||||||
- 차액 > 0 → 차변 `증권(효원)` / 대변 `주식평가수익` 자동 분개
|
- 차액 > 0 → 차변 `증권(효원|가희)` / 대변 `주식평가수익` 자동 분개 (손익 계정은 소유자 공용)
|
||||||
- 차액 < 0 → 차변 `주식평가손실` / 대변 `증권(효원)` 자동 분개
|
- 차액 < 0 → 차변 `주식평가손실` / 대변 `증권(효원|가희)` 자동 분개
|
||||||
- `|차액| < 1만원` → 노이즈로 간주, 분개 skip (processed 처리)
|
- `|차액| < 1만원` → 노이즈로 간주, 분개 skip (processed 처리)
|
||||||
- `|차액| > 1억원` → 안전 가드 발동, 분개 거부 + 텔레그램 alert + `failed/` 이동
|
- `|차액| > 1억원` → 안전 가드 발동, 분개 거부 + 텔레그램 alert + `failed/` 이동
|
||||||
- 처리 후 `processed/`로 이동, `state/inbox_state.json` 의 `processed[]` 에 message_id 누적 (idempotency, 최근 1000개)
|
- 후잉에 해당 자산 항목 없으면 `skipped` (분개 안 함)
|
||||||
- 결산 메일 본문에 `## 인박스 reconcile` 섹션, 텔레그램에 한 줄 요약 추가
|
- 소유자 하나라도 rejected/journal_failed 면 `failed/` 이동·미처리. 재실행 시 이미 분개된 소유자는 fresh 조회로 차액≈0 aligned → 중복분개 없음 (reconcile는 목표값으로 맞추기라 자연 idempotent)
|
||||||
|
- 정상 처리 후 `processed/`로 이동, `state/inbox_state.json` 의 `processed[]` 에 message_id 누적 (idempotency, 최근 1000개)
|
||||||
|
- 결산 메일 본문에 `## 인박스` reconcile 섹션(소유자별 라인), 텔레그램에 한 줄 요약 추가. 메일 "자산 계정별 변동"은 후잉 자산 전수 순회라 `증권(가희)`도 자동 표시
|
||||||
- **GC / 적체 정책 (월간결산 cron 진입부에서 매월 자동 수행):**
|
- **GC / 적체 정책 (월간결산 cron 진입부에서 매월 자동 수행):**
|
||||||
- `processed/` 의 mtime 30일 초과 envelope 자동 삭제 (`gc_processed`)
|
- `processed/` 의 mtime 30일 초과 envelope 자동 삭제 (`gc_processed`)
|
||||||
- `failed/` 적체가 5건 이상이면 결산 메일·텔레그램에 ⚠️ alert 한 줄 추가 — 사람이 검토 후 수동 삭제 (자동 삭제 안 함, CLAUDE.md 원칙 준수)
|
- `failed/` 적체가 5건 이상이면 결산 메일·텔레그램에 ⚠️ alert 한 줄 추가 — 사람이 검토 후 수동 삭제 (자동 삭제 안 함, CLAUDE.md 원칙 준수)
|
||||||
@@ -57,8 +66,9 @@ OpenClaw 에이전트 간 파일 기반 통신의 topic 카탈로그. envelope
|
|||||||
- envelope 키 누락 / `to != budget` / 미등록 topic / 미지원 `schema_version`
|
- envelope 키 누락 / `to != budget` / 미등록 topic / 미지원 `schema_version`
|
||||||
- payload 키 누락 (`as_of`, `accounts`, `totals`, `owner_scope`)
|
- payload 키 누락 (`as_of`, `accounts`, `totals`, `owner_scope`)
|
||||||
- `as_of` 가 매월 1·10·20일이 아님
|
- `as_of` 가 매월 1·10·20일이 아님
|
||||||
- `totals.{deposit,eval_amount,total}` 음수 또는 100억 초과
|
- `totals`/`by_owner.*` 의 `{deposit,eval_amount,total}` 음수 또는 100억 초과
|
||||||
- `accounts[].total` 합계가 `totals.total` 과 불일치
|
- `accounts[].total` 합계가 `totals.total` 과 불일치
|
||||||
|
- `by_owner` 에 미등록 소유자 (self/gahee 외) 또는 구조 오류
|
||||||
- 차액이 1억원 초과 (분개 거부)
|
- 차액이 1억원 초과 (분개 거부)
|
||||||
- 후잉 webhook 분개 실패
|
- 후잉 webhook 분개 실패
|
||||||
- **관련 스크립트:**
|
- **관련 스크립트:**
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ BALANCE_SCRIPT = WORKSPACE / 'skills' / 'whooing-sync' / 'scripts' / 'whooing_ba
|
|||||||
TELEGRAM_ACCOUNT = 'budget'
|
TELEGRAM_ACCOUNT = 'budget'
|
||||||
|
|
||||||
KNOWN_TOPICS = {'securities_balance'}
|
KNOWN_TOPICS = {'securities_balance'}
|
||||||
SUPPORTED_SCHEMA = {1}
|
SUPPORTED_SCHEMA = {1, 2}
|
||||||
|
|
||||||
SECURITIES_ASSET_NAME = '증권(효원)'
|
SECURITIES_ASSET_NAME = '증권(효원)' # v1(단일 소유자) 경로 및 self 자산명
|
||||||
SECURITIES_GAIN_NAME = '주식평가수익'
|
SECURITIES_GAIN_NAME = '주식평가수익'
|
||||||
SECURITIES_LOSS_NAME = '주식평가손실'
|
SECURITIES_LOSS_NAME = '주식평가손실'
|
||||||
|
# 소유자 → 후잉 자산명 (v2). 손익 계정(주식평가수익/손실)은 양쪽 공용.
|
||||||
|
OWNER_ASSET = {'self': '증권(효원)', 'gahee': '증권(가희)'}
|
||||||
|
|
||||||
RECONCILE_NOISE_FLOOR = 10_000 # |차액| < 1만원: 노이즈, 분개 skip
|
RECONCILE_NOISE_FLOOR = 10_000 # |차액| < 1만원: 노이즈, 분개 skip
|
||||||
RECONCILE_HARD_CAP = 100_000_000 # |차액| > 1억원: 분개 거부 (안전 가드)
|
RECONCILE_HARD_CAP = 100_000_000 # |차액| > 1억원: 분개 거부 (안전 가드)
|
||||||
@@ -124,6 +126,17 @@ def validate_envelope(env: dict) -> None:
|
|||||||
raise ValidationError('payload 가 dict 가 아님')
|
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:
|
def validate_securities_payload(payload: dict) -> None:
|
||||||
for key in ('as_of', 'accounts', 'totals', 'owner_scope'):
|
for key in ('as_of', 'accounts', 'totals', 'owner_scope'):
|
||||||
if key not in payload:
|
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):
|
if as_of_dt.day not in (1, 10, 20):
|
||||||
raise ValidationError(f'as_of 는 매월 1·10·20일이어야 함: {as_of}')
|
raise ValidationError(f'as_of 는 매월 1·10·20일이어야 함: {as_of}')
|
||||||
|
|
||||||
totals = payload['totals']
|
_validate_amount_block(payload['totals'], '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:,}')
|
|
||||||
|
|
||||||
if not isinstance(payload['accounts'], list) or not payload['accounts']:
|
if not isinstance(payload['accounts'], list) or not payload['accounts']:
|
||||||
raise ValidationError('accounts 비어있음')
|
raise ValidationError('accounts 비어있음')
|
||||||
sum_total = sum(int(a.get('total', 0)) for a in payload['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(
|
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:
|
def fetch_whooing_balance(as_of: str | None = None) -> dict:
|
||||||
cmd = ['python3', str(BALANCE_SCRIPT), '--json']
|
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)
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> dict:
|
def _reconcile_asset(asset_name: str, owner: str, target_total: int,
|
||||||
"""차액 reconcile + 후잉 자동 분개. 반환: {action, delta, before, after, journal_ok, note, journal?}.
|
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}.
|
action ∈ {aligned, journaled, noise, skipped, rejected, journal_failed}.
|
||||||
"""
|
"""
|
||||||
payload = envelope['payload']
|
current = get_whooing_balance_for(asset_name, current_balance)
|
||||||
target_total = int(payload['totals']['total'])
|
|
||||||
current = get_whooing_balance_for(SECURITIES_ASSET_NAME, current_balance)
|
|
||||||
base = {
|
base = {
|
||||||
'topic': 'securities_balance',
|
'topic': 'securities_balance',
|
||||||
'as_of': payload.get('as_of'),
|
'owner': owner,
|
||||||
|
'asset_name': asset_name,
|
||||||
|
'as_of': as_of,
|
||||||
'target_total': target_total,
|
'target_total': target_total,
|
||||||
}
|
}
|
||||||
if current is None:
|
if current is None:
|
||||||
return {
|
return {
|
||||||
**base,
|
**base,
|
||||||
'action': 'skipped',
|
'action': 'skipped',
|
||||||
'note': f'후잉 자산에 "{SECURITIES_ASSET_NAME}" 항목 없음',
|
'note': f'후잉 자산에 "{asset_name}" 항목 없음',
|
||||||
'delta': None, 'before': None, 'after': None, 'journal_ok': None,
|
'delta': None, 'before': None, 'after': None, 'journal_ok': None,
|
||||||
}
|
}
|
||||||
delta = target_total - current
|
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,
|
'before': current, 'after': current, 'journal_ok': False,
|
||||||
'note': f'차액 {delta:+,}원 — 1억원 초과, 분개 거부 (가드)'}
|
'note': f'차액 {delta:+,}원 — 1억원 초과, 분개 거부 (가드)'}
|
||||||
|
|
||||||
entry_date = payload['as_of'].replace('-', '')
|
entry_date = as_of.replace('-', '')
|
||||||
ym = payload['as_of'][:7]
|
ym = as_of[:7]
|
||||||
if delta > 0:
|
if delta > 0:
|
||||||
item = f'{ym} 평가차익'
|
item = f'{ym} 평가차익'
|
||||||
left, right, money = SECURITIES_ASSET_NAME, SECURITIES_GAIN_NAME, delta
|
left, right, money = asset_name, SECURITIES_GAIN_NAME, delta
|
||||||
else:
|
else:
|
||||||
item = f'{ym} 평가차손'
|
item = f'{ym} 평가차손'
|
||||||
left, right, money = SECURITIES_LOSS_NAME, SECURITIES_ASSET_NAME, -delta
|
left, right, money = SECURITIES_LOSS_NAME, asset_name, -delta
|
||||||
|
|
||||||
journal = {
|
journal = {
|
||||||
'entry_date': entry_date,
|
'entry_date': entry_date,
|
||||||
@@ -245,7 +264,7 @@ def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bo
|
|||||||
'money': str(money),
|
'money': str(money),
|
||||||
'left': left,
|
'left': left,
|
||||||
'right': right,
|
'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)
|
ok, body = post_whooing(journal, dry_run=dry_run)
|
||||||
return {
|
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:
|
def move_to(src: Path, dest_dir: Path, dry_run: bool = False) -> None:
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return
|
return
|
||||||
@@ -350,24 +394,28 @@ def process_inbox(current_balance: dict | None = None, dry_run: bool = False) ->
|
|||||||
validate_securities_payload(env['payload'])
|
validate_securities_payload(env['payload'])
|
||||||
if balance is None:
|
if balance is None:
|
||||||
balance = fetch_whooing_balance()
|
balance = fetch_whooing_balance()
|
||||||
result = handle_securities_balance(env, balance, dry_run=dry_run)
|
results = handle_securities_balance(env, balance, dry_run=dry_run)
|
||||||
summary['reconcile'].setdefault(topic, []).append(result)
|
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)
|
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})
|
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
|
continue
|
||||||
|
|
||||||
# 분개 성공/aligned/noise/skipped 모두 processed 로 이동
|
# 분개 성공분은 후잉 잔액 in-memory 업데이트 (동일 run 내 정합성 위해)
|
||||||
if result.get('journal_ok') and result['action'] == 'journaled' and balance is not None:
|
if balance is not None:
|
||||||
# 후잉 잔액 in-memory 업데이트 (다음 파일 reconcile 시 정합성 위해)
|
for r in results:
|
||||||
for sec in balance.get('sections', []):
|
if r.get('journal_ok') and r['action'] == 'journaled':
|
||||||
items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
|
for sec in balance.get('sections', []):
|
||||||
for it in items:
|
items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
|
||||||
if it.get('name') == SECURITIES_ASSET_NAME:
|
for it in items:
|
||||||
it['money'] = result['after']
|
if it.get('name') == r['asset_name']:
|
||||||
|
it['money'] = r['after']
|
||||||
else:
|
else:
|
||||||
raise ValidationError(f'핸들러 없음: {topic}')
|
raise ValidationError(f'핸들러 없음: {topic}')
|
||||||
|
|
||||||
@@ -415,22 +463,23 @@ def format_summary(summary: dict) -> str:
|
|||||||
before = result.get('before')
|
before = result.get('before')
|
||||||
after = result.get('after')
|
after = result.get('after')
|
||||||
delta = result.get('delta')
|
delta = result.get('delta')
|
||||||
|
label = result.get('asset_name', '증권(효원)')
|
||||||
if action == 'aligned':
|
if action == 'aligned':
|
||||||
lines.append(f'- **증권(효원):** {before:,}원 — 레이 ground truth 와 일치')
|
lines.append(f'- **{label}:** {before:,}원 — 레이 ground truth 와 일치')
|
||||||
elif action == 'journaled':
|
elif action == 'journaled':
|
||||||
sign = '평가차익' if (delta or 0) > 0 else '평가차손'
|
sign = '평가차익' if (delta or 0) > 0 else '평가차손'
|
||||||
lines.append(
|
lines.append(
|
||||||
f'- **증권(효원):** {before:,}원 → {after:,}원 '
|
f'- **{label}:** {before:,}원 → {after:,}원 '
|
||||||
f'({sign} **{abs(delta):,}원** 자동 분개)'
|
f'({sign} **{abs(delta):,}원** 자동 분개)'
|
||||||
)
|
)
|
||||||
elif action == 'noise':
|
elif action == 'noise':
|
||||||
lines.append(f'- **증권(효원):** 차액 {delta:+,}원 (1만원 미만, 분개 skip)')
|
lines.append(f'- **{label}:** 차액 {delta:+,}원 (1만원 미만, 분개 skip)')
|
||||||
elif action == 'skipped':
|
elif action == 'skipped':
|
||||||
lines.append(f'- **증권(효원):** {result.get("note", "skip")}')
|
lines.append(f'- **{label}:** {result.get("note", "skip")}')
|
||||||
elif action == 'rejected':
|
elif action == 'rejected':
|
||||||
lines.append(f'- **증권(효원):** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)')
|
lines.append(f'- **{label}:** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)')
|
||||||
elif action == 'journal_failed':
|
elif action == 'journal_failed':
|
||||||
lines.append(f'- **증권(효원):** ⚠️ 분개 실패 — {result.get("note", "")[:120]}')
|
lines.append(f'- **{label}:** ⚠️ 분개 실패 — {result.get("note", "")[:120]}')
|
||||||
|
|
||||||
if summary['failed']:
|
if summary['failed']:
|
||||||
lines.append('')
|
lines.append('')
|
||||||
|
|||||||
@@ -238,19 +238,20 @@ def format_inbox_telegram_line(summary: dict) -> str:
|
|||||||
for r in summary.get("reconcile", {}).get("securities_balance", []):
|
for r in summary.get("reconcile", {}).get("securities_balance", []):
|
||||||
action = r.get("action")
|
action = r.get("action")
|
||||||
delta = r.get("delta") or 0
|
delta = r.get("delta") or 0
|
||||||
|
label = r.get("asset_name", "증권(효원)")
|
||||||
if action == "aligned":
|
if action == "aligned":
|
||||||
parts.append("증권 일치")
|
parts.append(f"{label} 일치")
|
||||||
elif action == "journaled":
|
elif action == "journaled":
|
||||||
sign = "차익" if delta > 0 else "차손"
|
sign = "차익" if delta > 0 else "차손"
|
||||||
parts.append(f"증권 평가{sign} {abs(delta):,}원 분개")
|
parts.append(f"{label} 평가{sign} {abs(delta):,}원 분개")
|
||||||
elif action == "noise":
|
elif action == "noise":
|
||||||
parts.append(f"증권 차액 {delta:+,}원 (노이즈)")
|
parts.append(f"{label} 차액 {delta:+,}원 (노이즈)")
|
||||||
elif action == "rejected":
|
elif action == "rejected":
|
||||||
parts.append(f"⚠️ 증권 차액 {delta:+,}원 거부")
|
parts.append(f"⚠️ {label} 차액 {delta:+,}원 거부")
|
||||||
elif action == "journal_failed":
|
elif action == "journal_failed":
|
||||||
parts.append("⚠️ 증권 분개 실패")
|
parts.append(f"⚠️ {label} 분개 실패")
|
||||||
elif action == "skipped":
|
elif action == "skipped":
|
||||||
parts.append("증권 reconcile skip")
|
parts.append(f"{label} reconcile skip")
|
||||||
# rejected/journal_failed 는 위 reconcile 라인에서 이미 표시됨 — 중복 카운트 방지
|
# rejected/journal_failed 는 위 reconcile 라인에서 이미 표시됨 — 중복 카운트 방지
|
||||||
reconcile_failed = sum(
|
reconcile_failed = sum(
|
||||||
1 for r in summary.get("reconcile", {}).get("securities_balance", [])
|
1 for r in summary.get("reconcile", {}).get("securities_balance", [])
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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로 실행. 실패 시 레이 텔레그램으로 자가 알림.
|
LLM 불필요 — launchd로 실행. 실패 시 레이 텔레그램으로 자가 알림.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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')
|
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||||
TELEGRAM_ACCOUNT = 'stock'
|
TELEGRAM_ACCOUNT = 'stock'
|
||||||
TOPIC = 'securities_balance'
|
TOPIC = 'securities_balance'
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 2
|
||||||
GAHEE_PREFIX = '가희_'
|
GAHEE_PREFIX = '가희_'
|
||||||
|
|
||||||
|
|
||||||
@@ -60,13 +61,12 @@ def send_telegram(text: str) -> bool:
|
|||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
|
||||||
def collect_owner_accounts() -> list[dict]:
|
def collect_accounts() -> list[dict]:
|
||||||
accounts = kw.list_accounts()
|
accounts = kw.list_accounts()
|
||||||
out = []
|
out = []
|
||||||
for a in accounts:
|
for a in accounts:
|
||||||
label = a['label']
|
label = a['label']
|
||||||
if label.startswith(GAHEE_PREFIX):
|
owner = 'gahee' if label.startswith(GAHEE_PREFIX) else 'self'
|
||||||
continue
|
|
||||||
balance = kw.get_balance(label)
|
balance = kw.get_balance(label)
|
||||||
positions = kw.get_positions(label)
|
positions = kw.get_positions(label)
|
||||||
eval_amount = sum(p.get('evlt_amt', 0) for p in positions)
|
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)
|
deposit = balance.get('d2_entra', 0)
|
||||||
out.append({
|
out.append({
|
||||||
'label': label,
|
'label': label,
|
||||||
|
'owner': owner,
|
||||||
'account_no': a.get('account_no', ''),
|
'account_no': a.get('account_no', ''),
|
||||||
'deposit': deposit,
|
'deposit': deposit,
|
||||||
'eval_amount': eval_amount,
|
'eval_amount': eval_amount,
|
||||||
@@ -86,6 +87,12 @@ def collect_owner_accounts() -> list[dict]:
|
|||||||
|
|
||||||
def build_message(accounts: list[dict]) -> dict:
|
def build_message(accounts: list[dict]) -> dict:
|
||||||
now = datetime.now(KST)
|
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 {
|
return {
|
||||||
'message_id': str(uuid.uuid4()),
|
'message_id': str(uuid.uuid4()),
|
||||||
'from': 'stock',
|
'from': 'stock',
|
||||||
@@ -95,8 +102,9 @@ def build_message(accounts: list[dict]) -> dict:
|
|||||||
'schema_version': SCHEMA_VERSION,
|
'schema_version': SCHEMA_VERSION,
|
||||||
'payload': {
|
'payload': {
|
||||||
'as_of': now.strftime('%Y-%m-%d'),
|
'as_of': now.strftime('%Y-%m-%d'),
|
||||||
'owner_scope': 'self_only',
|
'owner_scope': 'self_and_gahee',
|
||||||
'accounts': accounts,
|
'accounts': accounts,
|
||||||
|
'by_owner': by_owner,
|
||||||
'totals': {
|
'totals': {
|
||||||
'deposit': sum(a['deposit'] for a in accounts),
|
'deposit': sum(a['deposit'] for a in accounts),
|
||||||
'eval_amount': sum(a['eval_amount'] 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:
|
def main() -> int:
|
||||||
try:
|
try:
|
||||||
accounts = collect_owner_accounts()
|
accounts = collect_accounts()
|
||||||
if not accounts:
|
if not accounts:
|
||||||
raise RuntimeError('본인 계좌 0개 — 자격증명 또는 prefix 필터 점검 필요')
|
raise RuntimeError('계좌 0개 — 자격증명 점검 필요')
|
||||||
msg = build_message(accounts)
|
msg = build_message(accounts)
|
||||||
path = write_message(msg)
|
path = write_message(msg)
|
||||||
total = msg['payload']['totals']['total']
|
total = msg['payload']['totals']['total']
|
||||||
|
|||||||
Reference in New Issue
Block a user