- 상태: Draft
- 작성일: 2026-05-15
- 대상 버전: v0.5.0
- 작성자: Jong Hyun
- 선행: AGENTS-PATCH-005-portfolio.md (v0.1 단일 계좌 기반)
v0.4까지 portfolio 모듈은 단일 계좌 가정으로 출시됐다. 실 사용 E2E에서 다음 한계가 드러남:
- 다중 계좌 보유(한투, KB, ISA 등 동시 사용)가 한국 개인 투자자 표준 형태인데 도구가 한 계좌만 노출.
ls_holdings_add의미가 "현재 상태 set"인지 "추가 매수"인지 모호. ON CONFLICT가 무조건 replace라 "5주 더 샀어"가 의도와 어긋남.- 액면분할/무상증자 같은 corporate action 발생 시 cost basis가 자동 보정되지 않아
current_value,pnl계산 결과가 가짜 손실로 표시됨.
이 패치는 위 3가지를 v0.5에서 한꺼번에 정리한다. 실 brokerage 동기화는 하지 않으므로 로컬 노트패드 정확성 수준에 맞춰 설계한다.
| # | 결정 | 비고 |
|---|---|---|
| 1 | placeholder seed 제거. 계좌 0개 상태 허용 | empty state는 도구 응답에서 RequiresAccount 에러 envelope로 안내 |
| 2 | account 식별은 nickname + account_number 양쪽 수용, 둘 다 오면 account_number 우선 | 사용자는 대부분 nickname 멘탈모델 ("주식1", "ISA") |
| 3 | broker는 free text, ls_broker_rename(from, to)로 일괄 갱신 |
같은 broker_name의 모든 계좌 nickname 머지 시 충돌 검사 |
| 4 | is_default 컬럼 유지, 정확히 0 또는 1개. 0개 상태도 허용 |
마지막 계좌 삭제 시 default도 자연스럽게 해제 |
| 5 | Ambiguity 정책: 읽기는 폴백, 쓰기는 1개=auto+echo / 2개+=error | 표 2.1 참조. soft 정책 + 응답 명시성으로 안전망 |
| 6 | ls_holdings_list 응답은 항상 grouped |
accounts:[…] + total_summary. 단일 계좌는 길이 1 |
| 7 | ls_account_remove는 2단계 confirm |
보유종목 있으면 RequiresConfirmation 에러, cascade=true로 재호출 |
| 8 | holdings 도구를 3개로 분리 | _set (replace), _buy (incremental merge), _sell (incremental subtract) |
| 9 | 분할/증자 도구 3개 | _split, _reverse_split, _bonus. account 미지정 시 해당 종목 보유한 모든 계좌 일괄 적용 |
| 10 | 의심 가격 차이 경고 | list 응답에 |current_price/avg_price - 1| > 5면 분할 가능성 warning 부착 |
| 11 | 모든 쓰기 응답에 applied_to 에코 |
1개 폴백/일괄 적용 시 어느 계좌(들)에 들어갔는지 응답에 명시. soft ambiguity의 안전망 |
| 12 | default 자동 승계 | default 계좌 삭제 시 가장 오래된 다른 계좌(id ASC)가 자동 default로. 불변량: 계좌 ≥1이면 default 정확히 1개 |
| 13 | _set(quantity=0) 거부 |
ValidationError + "remove 도구를 사용하세요" 안내. _sell은 new_qty=0이면 auto-remove |
| 도구 | 0개 계좌 | 1개 | 2개+ |
|---|---|---|---|
ls_holdings_list, ls_accounts_list |
빈 응답 | auto | grouped (default 우선) |
ls_holdings_set / _buy |
error RequiresAccount |
auto + echo | error AmbiguousAccount |
ls_holdings_sell / _remove |
error RequiresAccount |
auto + echo | error AmbiguousAccount |
ls_holdings_split / _reverse_split / _bonus |
n/a | auto + echo | account 미지정 시 해당 종목 보유한 모든 계좌에 일괄 + echo |
모든 쓰기 도구는 1개 폴백/일괄 적용 시 응답에 applied_to로 어느 계좌(들)에 적용됐는지 echo (§4.4 참조). 2개+ 상태에서 account 미지정 → AmbiguousAccount error. v0.4의 _update는 v0.5에서 _set/_buy/_sell이 대체.
추가 검증 에러:
_set(quantity=0)→ValidationError("0주는 remove 도구 사용")._sell이 new_qty=0이면 auto-remove._sell(quantity > current)→InsufficientQuantityerror._reverse_split의 ratio가 quantity를 나누어 떨어뜨리지 못하면 →ValidationError.
{
"error": "AmbiguousAccount",
"message": "Holding '005930' exists in 2 accounts. Specify the account.",
"candidates": [
{ "account_number": "12345-01", "nickname": "한투", "broker": "한국투자" },
{ "account_number": "67890-22", "nickname": "ISA", "broker": "한국투자" }
]
}AccountNotFound, RequiresAccount, RequiresConfirmation, InsufficientQuantity, ValidationError도 동일 envelope (candidates 또는 도메인별 hint 필드 포함). candidates는 항상 현재 계좌 목록 동봉 → LLM이 정정 호출에 사용.
InsufficientQuantity 예시:
{
"error": "InsufficientQuantity",
"message": "Cannot sell 15 shares of '005930'; current quantity is 12.",
"current_quantity": 12,
"requested_quantity": 15,
"applied_to": { "account_number": "12345-01", "nickname": "한투" }
}-- Seed 단일 placeholder 행 제거. 기존 'UNSET' 행이 있으면 보유종목 없을 때만 제거.
DELETE FROM accounts
WHERE account_no = 'UNSET'
AND nickname = '기본 계좌'
AND id NOT IN (SELECT account_id FROM holdings);
-- nickname UNIQUE 제약. 기존 데이터는 단일 placeholder뿐이라 충돌 없음.
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_nickname ON accounts(nickname);기존 v1 스키마(accounts.id/account_no/nickname/broker/is_default/created_at, holdings.account_id FK)로 충분. account_no는 이미 UNIQUE.
- 정확히 0 또는 1개 row가
is_default = 1(애플리케이션 레벨에서 보장) - 계좌 ≥1개면 default 정확히 1개라는 불변량 유지 — default 계좌 삭제 시 id ASC로 가장 오래된 다른 계좌가 자동 default 승계
- 마지막 계좌 삭제 시에만 default는 0개가 됨
- 계좌 추가 시 (a) 기존 default가 없으면 자동 default (b)
set_default=true로 명시하면 기존 default 해제
| 도구 | 인자 | 동작 |
|---|---|---|
ls_accounts_list |
— | 모든 계좌 + 보유종목 카운트 + default 표시. 0개면 빈 배열 |
ls_account_upsert |
account_number, nickname, broker, set_default?=false |
account_number 기준 upsert. nickname 충돌 시 에러 |
ls_account_set_default |
account (nickname 또는 account_number) |
is_default 토글. 트랜잭션 |
ls_account_remove |
account, confirm?=false |
보유종목 0개면 즉시 삭제. 1+이면 RequiresConfirmation 에러 + 카운트/평가금액. confirm=true면 cascade. 삭제 대상이 default였고 다른 계좌가 남으면 id ASC로 자동 승계 |
ls_broker_rename |
from, to |
accounts.broker = to WHERE broker = from. UNIQUE 위반(머지 대상 nickname 충돌) 시 에러 |
기존 ls_account_get은 유지(default 단건 조회). 기존 ls_account_set은 제거(개명 X — 의미가 바뀌어서 silent break보다 깨끗한 제거).
| 도구 | 인자 | 동작 |
|---|---|---|
ls_holdings_list |
account? |
grouped 응답. account 필터 시 accounts 길이 1 |
ls_holdings_set |
shcode, quantity, avg_price, note?, account? |
replace. 기존 row 있으면 덮어씀. quantity=0 → ValidationError |
ls_holdings_buy |
shcode, quantity, price, account? |
merge. new_qty = old+qty, new_avg = (old.qty×old.avg + qty×price)/new_qty. 새 행이면 set과 동일 |
ls_holdings_sell |
shcode, quantity, account? |
subtract. new_qty = old-qty. avg 유지. new_qty==0이면 row 자동 삭제. quantity > old면 InsufficientQuantity |
ls_holdings_remove |
shcode, account? |
행 완전 삭제 |
ls_holdings_split |
shcode, ratio (int ≥ 2), account? |
qty ×= ratio, avg /= ratio. account 미지정 시 보유 모든 계좌 |
ls_holdings_reverse_split |
shcode, ratio (int ≥ 2), account? |
qty /= ratio, avg ×= ratio. 나눠 떨어지지 않으면 error (정수 보유 가정) |
ls_holdings_bonus |
shcode, ratio (double > 0), account? |
qty ×= (1 + ratio), avg /= (1 + ratio). 무상증자 0.1 → 10% 증가 |
기존 ls_holdings_add / _update는 제거. 의미 충돌 방지.
- 신설:
ls_watchlist_group_rename(old_name, new_name) - 나머지 v0.4 도구 그대로 유지
ls_watchlist_add재호출 시 ON CONFLICT(group_id, symbol) DO UPDATE notes로 메모 갱신 가능 — v0.5에서 도구 추가 없이 검증으로 확인
ls_holdings_list:
{
"accounts": [
{
"account_number": "12345-01",
"nickname": "한투",
"broker": "한국투자",
"is_default": true,
"holdings": [
{
"shcode": "005930",
"name": "삼성전자",
"quantity": 12,
"avg_price": 71000,
"note": null,
"quote": { "price": 75000, "change": 1000, "change_pct": 1.35, "...": "..." },
"market_value": 900000,
"cost_basis": 852000,
"pnl": 48000,
"pnl_pct": 5.63,
"warning": null
}
],
"summary": {
"cost_basis": 852000,
"market_value": 900000,
"pnl": 48000,
"pnl_pct": 5.63
}
}
],
"total_summary": {
"cost_basis": 852000,
"market_value": 900000,
"pnl": 48000,
"pnl_pct": 5.63
},
"quote_error": null
}기존 current_value → market_value, total_cost → cost_basis 개명. summary 필드명 통일.
warning 예시: "분할/무상증자 가능성 (현재가 / 평단 비율 5배 이상)". 분할 도구 안내 문자열은 응답 텍스트가 아닌 별도 필드로 분리해 LLM이 자동 라우팅 가능.
_set / _buy / _sell / _remove (단일 계좌 적용):
{
"shcode": "005930",
"name": "삼성전자",
"quantity": 12,
"avg_price": 71000,
"applied_to": { "account_number": "12345-01", "nickname": "한투" }
}_split / _reverse_split / _bonus (다중 계좌 일괄):
{
"shcode": "005930",
"ratio": 50,
"applied_to": [
{ "account_number": "12345-01", "nickname": "한투", "before": { "quantity": 10, "avg_price": 2500000 }, "after": { "quantity": 500, "avg_price": 50000 } },
{ "account_number": "67890-22", "nickname": "ISA", "before": { "quantity": 4, "avg_price": 2520000 }, "after": { "quantity": 200, "avg_price": 50400 } }
]
}applied_to는 모든 쓰기 도구 응답에 반드시 포함된다. soft ambiguity 정책의 안전망 — 사용자가 계좌 미지정으로 호출해도 어디에 적용됐는지 응답에서 즉시 확인 가능.
- 호스트(MCP 클라이언트): 도구 이름이 다수 변경. v0.4→v0.5는 dev preview 구간이라 break 허용.
- 데이터: placeholder seed 자동 제거. 기존 보유종목 데이터는 그대로 유지 (account_id FK 변경 없음).
- 공식 nuget: v0.5.0 minor bump. README에 마이그레이션 가이드 추가.
- A1~A7 전부 (계좌 CRUD + default + broker rename)
- H1/H2 add/buy → 신설 도구 분리
- H3 multi-account 같은 종목 별개 row
- H4/H5 ambiguity 정책
- H6 account 필터
- H7 grouped 응답
- W8 그룹 이름 변경
- 분할/증자 3종
- H8 계좌 간 이동 (
ls_holdings_transfer) - W2 그룹 순서 변경
- W4/S3 sector × 보유종목 교차 (krx_sector enrichment via t1102 선행)
- S2 KOSPI 업종 코드 추적
- 거래 이벤트 로그 (snapshot 외에 actions 테이블)
- 실 brokerage 계좌 동기화
- nickname rename 후 LLM이 옛 이름 사용:
AccountNotFound에러 envelope에candidates동봉으로 자동 정정 유도. 모든 not-found 에러 envelope이 동일 shape. - liability:
ls_holdings_set이 의도치 않게 호출되면 기존 매수 이력이 덮어써짐. tool description에 "현재 총량으로 교체 (추가매수는 _buy 사용)" 명시. - 분할 시 정수 나누어떨어짐:
_reverse_splitratio가 quantity를 정수로 나누지 못하면ValidationError. 예외 처리 필요. _sell과매도: quantity > current면InsufficientQuantity. 부분 매도 후 정확히 0이 되는 케이스만 auto-remove.- soft ambiguity의 오작동 리스크: 1계좌 자동 폴백이 사용자 의도와 어긋날 가능성.
applied_toecho가 안전망. 호스트 측에서 응답을 검사하도록 description에 안내. - default 0개 상태: portfolio_list는 empty 응답, holdings_set은 RequiresAccount. 모든 진입점에서 일관 처리.
- default 자동 승계 순서: id ASC (가장 오래된 계좌). 결정론적이라 테스트 안정성과 사용자 예측 가능성 모두 확보. created_at 동률 시 id로 tiebreak.
- WAL 파일 동시성: 기존 v0.4 패턴 유지. WAL은 init 시 1회만.
SqlitePortfolioRepository: 계좌 0개 상태, default 토글, nickname UNIQUE 충돌, ambiguity (같은 symbol 다중 계좌), cascade with/without holdings, broker rename + 머지 충돌, split/bonus 정확성 (정수 나누어떨어짐 포함)PortfolioService: ambiguity 에러 envelope, 1계좌 폴백, grouped 응답 shape, buy/sell 누적 정확성, split 전체 계좌 일괄
기존 24 케이스 + 신규:
- 계좌 0개 상태 → holdings_list 빈 응답, holdings_set → RequiresAccount
- ls_account_upsert × 2 → ls_accounts_list 2개, 첫 계좌 자동 default
- ls_account_set_default 전환
- ls_holdings_buy → 1회: set과 동일, 2회: weighted avg 확인, 응답
applied_toecho - ls_holdings_sell → quantity 감소, 0이면 자동 삭제, quantity > current면 InsufficientQuantity
- ls_holdings_set(quantity=0) → ValidationError ("remove 사용")
- 같은 symbol 두 계좌 등록 → list grouped 응답 확인
- _sell/_remove on ambiguous symbol → AmbiguousAccount + candidates
- _set/_buy 1계좌 폴백 → 응답
applied_to에 어느 계좌인지 확인 - ls_holdings_split(005930, 50) → quantity ×50, avg /50, 모든 계좌,
applied_to배열에 before/after - ls_holdings_reverse_split 나누어떨어지지 않는 케이스 → ValidationError
- ls_account_remove(보유종목 있음, confirm=false) → RequiresConfirmation
- ls_account_remove(default 계좌, confirm=true, 다른 계좌 존재) → cascade + id ASC 자동 승계
- ls_account_remove(마지막 계좌) → 정상 삭제, default 0개 상태 진입
- ls_broker_rename → 머지 충돌 시 에러
- ls_watchlist_group_rename → 정상 동작 + 충돌 시 에러
E:\MCP_E2E에서 실 LS 자격으로 호스트 경유:
- 사용자 발화 "한투에 LG전자 64주 평단 115,801, KB ISA에도 LG전자 10주 평단 98,450" → 정확히 2계좌 분리 등록되는지
- "삼성전자 평단 갱신해줘" → 두 계좌에 있으면 AmbiguousAccount 에러, 모델이 "어느 계좌?" 되묻는지
- "삼성전자 10:1 분할했대" → ls_holdings_split(005930, 10) 라우팅 + 두 계좌 모두 갱신
- 스키마 v2 마이그레이션 (
SqlitePortfolioRepository) - 에러 envelope 통일 —
PortfolioException도입 또는RequiresXxx/AmbiguousXxxtyped exceptions - 모델 재구조화 —
AccountInfo+=IsDefault,AccountHoldings,HoldingListResultv2 - 레포지토리 확장 —
ListAccountsAsync,GetAccountByIdentifierAsync,UpsertAccountAsync,RemoveAccountAsync,SetDefaultByIdentifierAsync,RenameBrokerAsync,ListAllHoldingsAsync,BuyHoldingAsync,SellHoldingAsync,ApplyCorporateActionAsync - 서비스 — ambiguity 정책 구현, grouped 응답 빌더, warning 부착
- 도구 — 추가/개명/삭제. description에 USE WHEN / AVOID WHEN 유지
- 단위 테스트 + smoke
- README 다중 계좌 안내 추가
- NuGet 버전 bump (Mcp 0.5.0)
_buy의 가격이 현재가보다 비합리적으로 멀면 (예: 3배 이상) confirmation 요구? → v0.5는 단순 동작. v0.6에서 sanity check 검토.ls_watchlist_group_rename호출 시 default 그룹 이름도 변경 가능? → 가능. 영향 없음.ls_holdings_split의 ratio가1이면 noop? error? → noop + 안내 메시지.- 마이그레이션 v2가 placeholder 삭제 후 기존 holdings.account_id FK 깨지지 않는지? → 보유종목 있으면 placeholder 보존하는 SQL이라 안전.