Skip to content

Writedown & NAV

작업 진행 중

이 페이지는 v3.0 모델을 반영한다. collateral_type enum은 collateral_description(TEXT)과 선택값 collateral_ratio(NUMERIC)로 대체됐다. NAV 처리는 항상 Aset 오라클 Lambda가 한다(v3-15). nav_data_source 차원은 제거됐다.

실물자산 가치 변화가 NAV에 어떻게 반영되는지를 다룬다. 상각 메커니즘, NAV 갱신 플로우, 타임락 강제, 포트폴리오 표시 지침을 포함한다. 상환 지급액 계산은 상환 참조.

Writedown과 Wind-Down: 서로 다른 실패 양상

둘 다 손실이 얽혀 있어 자주 혼동되지만, 적용 시나리오가 다르고 정산 시점에는 같은 NAV 메커니즘을 쓴다(v3-12).

WritedownWind-Down
무엇이 실패했나기초자산의 가치가 떨어짐파트너 자체가 60일 이상 무응답
발동 조건NAV 갱신(DPD 급증, 부실)어드민 proposeWindDown() + 30일 타임락
풀 lifecycleACTIVE(계속됨) 또는 에스컬레이션되면 IMPAIREDWIND_DOWN(종착)
되돌릴 수 있나가능. NAV가 회복될 수 있다불가. 단방향이다
빈도풀이 사는 동안 여러 번한 번(종착)
신규 입금허용(낮아진 NAV로)차단
투자자 조치없음(수동적인 가치 변화)requestRedemption → funded settlement → claim; 현재 소스에는 claimRedemptionFallback이 없다. 7일 폴백을 통한 지급을 보장하지 않는다. 별도로 남아 있는 회차 정산과 대리 청구도 실제 투입 자금에 의존한다.
NAV 계산(total_deposited − uncoveredLoss) / totalSupply. reserve 항이 없다(R8). 버퍼가 손실을 흡수하는 방식원금 항 주석 참조현재 소스의 executeWindDown()navPerToken을 재계산하지 않는다. 오라클 NAV를 유지하며 실제 지급에는 별도 자금 투입과 상환 절차가 필요하다.

현재 소스의 executeWindDown()navPerToken을 재계산하지 않는다. 오라클 NAV를 유지하며 실제 지급에는 별도 자금 투입과 상환 절차가 필요하다. 현재 소스에는 claimRedemptionFallback이 없다. 7일 폴백을 통한 지급을 보장하지 않는다. 별도로 남아 있는 회차 정산과 대리 청구도 실제 투입 자금에 의존한다.

시나리오가 다르면 대응도 달라야 하는 이유: writedown은 파트너가 협조하는 상태에서의 자산 손실을 다룬다(NAV 갱신 + 표준 상환). wind-down은 파트너 부재를 다룬다(NAV 갱신이 불가능하므로 온체인에서 회수 가능한 것을 기준으로 NAV를 강제한다).

R10: wind-down 분모는 분자와 함께 고쳐야 했다

이 절의 리저브 잔고 산식은 구형 구현의 이력이다. 외부 리저브 구현에는 적용하지 않는다. 현재 소스의 executeWindDown()navPerToken을 재계산하지 않는다. 오라클 NAV를 유지하며 실제 지급에는 별도 자금 투입과 상환 절차가 필요하다.

v3-100(= D-1)은 wind-down 분자를 고치려고 redemptionCommitted, 즉 상환이 정산됐지만 아직 청구하지 않은 투자자에게 이미 약속된 USD를 빼기로 했다. 의도는 맞았다. 그 현금은 모두의 것이 아니라 이름이 정해진 청구권자의 것이다. 그런데 분모를 함께 옮기지 않았고, totalSupply가 여전히 그 지급의 뒷받침이 되는 에스크로된 LP를 세고 있었다. 그들의 USD는 분자에서 빠졌는데 토큰은 분모에 남아 있었으니, 남은 홀더 전원이 그만큼 덜 받았다.

이제 양쪽이 다 고쳐졌고, 분자 수정은 v3-100이 내보낸 것과 정반대였다.

현재    navPerToken = distributable / (totalSupply − settledUnclaimedLp)
        distributable = reserveBalance + totalEpochTopUp

수정 전  navPerToken = (distributable − redemptionCommitted) / totalSupply

redemptionCommitted는 더 이상 빼지 않는다. 정산 시점에 _reserveFilledGross가 이미 유동성 버킷 셋에서 총액을 차감해 redemptionCommitted라는 서로 겹치지 않는 버킷에 옮겨 두기 때문에, 다시 빼면 같은 돈을 두 번 제거하게 된다. 두 오류는 상쇄되지 않고 누적됐다. 하나는 분자를 줄이고 다른 하나는 분모를 부풀렸다. 그래서 둘을 한 번에 고쳐야 했다.

settledUnclaimedLp는 새 온체인 카운터다. 정산 시 +(체결분이 redemptionCommitted로 예약될 때), **claim이나 소각 시 **다. 요청 시점이 아니라 정산 시점에 증가한다.

⚠️ R10은 정산된 미청구 LP만 제외한다. 대기 중인 LP는 절대 아니다

이 부분을 과하게 적용하기 쉽고, 그러면 회차 모델의 pro-rata 보장이 깨진다.

에스크로된 LPredemptionCommitted에 있나분모에 있나이유
정산됨(체결됨), 미청구있다. USD가 이미 예약됐다제외(R10)USD가 분자에서 빠졌으니 토큰도 분모에서 빠져야 한다. 대칭이다.
미체결·이월(대기 중)없다남는다그들의 USD도 분자에 있으므로 다른 홀더처럼 pro-rata로 참여한다. 이미 대칭이다.

대기 중인 LP를 빼면 회차 상환 §5를 위반한다. 거기서는 이월된 요청자가 계속 pro-rata로 참여한다고 명시하고 있다.

수익 분모와도 혼동하지 말 것. 그쪽은 풀 보유 LP를 제외하는데, v3-131 (3) 이전과 은 같지만 더 이상 같은 규칙이 아니다. v3-104 안 A에서는 에스크로된 LP가 아무것도 못 벌었고, 지금은 벌되 기록이 에스크로 경계로 미뤄진다(previewEscrowAccrual이 그 미뤄진 부분이다). 분모가 셋이고 산식도 셋이다. 용어집 → 분모 참조.

반영 완료. dev에 처음 배포된 것은 2026-08-04다(팩토리 0xE1E2E974…DA90 → 풀 구현체 0x27D9948F…f968, 커밋 0abe168). 그 구현체에 settledUnclaimedLp() getter가 있다(셀렉터 915d0aa1). ⚠️ 그 팩토리는 이후 2026-08-06 라운드(0x09614f0B…Ed0d)로 대체됐고 그쪽도 R10을 담고 있다. 풀은 여기 산문에 인용된 주소가 아니라 apps/contract/sepolia.md의 현재 팩토리에서 만들 것. ⚠️ 신규 풀만 해당된다. 풀은 생성 시 구현체가 고정되는 Clones라, 새 팩토리로 만든 풀은 현재 줄로 가격을 매기고 그 이전 풀은 전부 수정 전 줄로 매기며 거기서는 getter가 revert한다. 구체적으로 수정 전은 미청구 유동성 $50을 들고 있는 풀에 max(0, 50 − 100) = 0을 반환했다. 남은 홀더를 0원으로 가격 매긴 것이다. v3-109 참조.

🔴 2026-08-27 결정(v3-152, v3-155) executeWindDown이 NAV를 다시 계산하지 않게 된다(아직 반영 안 됨. 컨트랙트는 여전히 다시 계산한다).

이유는 reserve가 풀 밖으로 나가기 때문이다. reserve가 외부 EOA에 있게 되면 풀 자신의 잔액은 나눌 만한 의미 있는 숫자가 아니게 된다. 정직한 수치는 장부 가치이고, 실제 지급을 좌우하는 것은 다시 계산한 가격이 아니라 자금이 들어오느냐다. 그래서 오라클이 마지막으로 설정한 NAV가 청산 가격이 된다.

⚠️ 위의 모든 내용은 그것을 위해 쓰인 풀들에 대해서는 계속 참이다. 풀은 생성 시 구현체가 고정되는 Clones라, 이미 배포된 풀은 자기 구현체의 동작을 영구히 유지한다. settledUnclaimedLp와 R10이 그 풀들을 정확히 서술한다. 이 변경은 그 라운드 이후 배포된 풀에 적용된다.

손실 보호 계층

손실은 이 순서로 흡수된다(R8, v3-109). reserve는 이 목록에 없다.

담보 → 버퍼 → Junior → Mezzanine → Senior → NAV
계층무엇인가파라미터 / 산식비고
0 · 담보플랫폼에 닿기 전의 오프체인 회수collateral_description(텍스트) + collateral_ratio(100이면 완전 담보, 150이면 150% 초과담보)파트너가 기초 대출에 대해 담보를 처분한다. Aset은 회수분을 반영한 뒤에야 NAV로 손실을 인식한다. 필드 →
1 · 버퍼(first-loss, R6)운용사 자신의 자기자본bufferCap = total_deposited × buffer_rate_bps / 10000현재 모든 풀에서 buffer_rate_bps = 0이라 이 항이 사라지고 첫 실현 손실이 곧바로 투자자에게 닿는다. 이를 설정하는 컬럼이 셋이고 아래에 있다
2 · 트랜치그룹으로 묶인 풀만(v3-14)Junior → Mezzanine → Senior NAV 워터폴Junior가 자기 자본 전부를 흡수해야 Mezzanine에 닿는다. 단독 풀(tranche_group_id가 NULL)은 이 계층을 건너뛴다. "first-loss = Junior"라는 말이 가리키는 것이 이것이다. 상세 →
3 · NAV 가격투자자에게 보이는 가격(total_deposited − uncoveredLoss) / totalSupply (R9)$1.00에서 아래로만 움직이고 상한이 $1.00이다(프리미엄 없음). 전손은 $0이 아니라 1e-6으로 표시된다. 이유. 신규 투자자는 언제나 현재의 공정 가격에 산다

reserve는 손실 계층이 아니다(R8). 투자자 입금에서 떼어낸 것이고(각 입금의 reserve_bps가 Pool 컨트랙트에 남는다) 따라서 NAV 분모가 가격 매기는 투자자 청구권 안에 이미 들어 있다. 손실에서 또 상계하면 투자자 본인 돈으로 두 번 크레딧을 주는 셈이다. 이 페이지의 이전 판은 이를 "1계층: first-loss"라고 불렀는데 그게 이중 계상이었다. reserve는 상환 유동성과 wind-down 하한, 그 이상도 이하도 아니다.풀 모델 · 개요

버퍼는 상태가 없고, 답이 아니라 설정이다. uncoveredLoss는 실행할 때마다 누계값에서 다시 계산되므로 bufferCap은 상수 임계값이다. buffer_cap이나 buffer_balance 컬럼은 없고 필요하지도 않다. 파트너 계약 조건은 아직 미정이지만 기본값이 지금의 계산을 정확히 재현하므로, 요율이 합의되면 산식을 다시 쓰는 게 아니라 UPDATE 한 번이면 된다. 잔액을 두면 왜 이중 계상이 되는가 →

버퍼의 세 컬럼 (0118)

요율만으로는 아무것도 정해지지 않는다. equity_buffer_rule은 어떤 산식에도 자리가 없는 채로 투자자에게 산문으로 약속하고 있었다("운용사가 NPL을 N%까지 흡수합니다"). R6가 이를 계산 가능하게 만들었다.

컬럼무엇을 정하나
buffer_rate_bps0~10000. 현재 모든 풀에서 0계층의 크기. 0이면 이 항이 사라진다
buffer_directionFIRST_LOSS(기본) / EXCESS누가 손실을 지는가. 한도 5%, 손실 8%일 때 FIRST_LOSS면 투자자가 3%를, EXCESS면 5%를 진다. 같은 숫자에서 정반대의 책임이 나오므로 추정으로 정할 수 없었다
buffer_basisGROSS(기본) / NETGROSS는 파트너가 실현 손실 총액을 보고하고 Aset이 한도를 뺀다. NET은 이미 파트너 흡수분을 뺀 값이라 한도를 0으로 강제한다. 이걸 거꾸로 하면 버퍼를 두 번 빼게 되고, R8이 reserve에서 없앤 것과 같은 이중 계상이다

기준은 total_deposited(풀 규모)다. 구조화 신용의 관행이고, 버퍼를 그것이 상쇄하는 손실과 같은 축에 둔다. NPL의 몇 퍼센트로 잡지 않는 것은 의도적이다. R5.5에 따라 연체만으로는 NAV가 절대 움직이지 않으므로, NPL에 비례하는 완충은 존재하지 않는 연결을 암시하게 된다.

모든 단독 풀에 적용된다(0122). 파트너 피드가 있는 풀에만 적용되던 것이 아니고, 이제 제약은 트랜치 풀에만 걸린다(buffer_not_on_tranche_pools). 트랜치 풀의 NAV는 워터폴 엔진에서 나오기 때문이다. 산문은 설정을 따른다. equity_buffer_rule은 요율이 설정돼 있어야 입력할 수 있으므로(0121), "Manager first-loss commitment"라는 투자자 문구가 존재하지 않는 계층을 설명할 수 없다.

FX는 NAV 입력이 아니다 (R7)

자산이 현지 통화로 표시되는 풀(operating_currency ≠ USD)에서 NAV는 그 통화 기준의 자산 성과만 잰다. 자산은 그대로인데 IDR이 4% 절하된 달에도 NAV는 변하지 않는다. 통화 변동은 상환 시점에 실현되고, 그때 지급액이 그 시점 환율로 환산된다.

기억해 둘 결과가 둘 있다. 환율 변동은 24시간 타임락을 절대 유발하지 않는다. NAV가 움직이지 않았으니 예고할 것이 없기 때문이다. 그리고 비USD 풀의 $1.00 NAV는 현재 USD 가치에 대한 주장이 아니므로, 이를 보여 주는 화면은 옆에 참고 USD 환산을 함께 보여 줘야 한다. pools.fx_rate는 오직 그 표시용 환산을 위한 것이다. → 풀 모델 → operating_currency

NAV < $1.00일 때의 결과: 신규 투자는 계속 열려 있고(하락 타임락 중에는 예고 NAV로 가격이 매겨진다. R2·R3), 기존 수익은 명목 입금액 기준으로 계속 쌓이며, 상환은 같은 예외 규칙을 적용해 tokens × nav_at_request로 지급된다. 하락은 24시간을 기다리고 상승은 즉시 적용된다.

NPL과 누적 손실(대출 생애주기)

연체 데이터에는 뜻이 다른 임계값이 있고, 이를 뭉개는 것이 가장 흔한 데이터 반입 오류다. 대출 하나가 순서대로 이 둘을 지난다.

  • npl_threshold_days(펀드가 보고, 기본 90): 대출이 이제 **부실(NPL)**이 됐지만 장부에 남아 있다. 여전히 자산이고 회수 중이며, DPD 버킷에 남아 미상환 원금에 포함된다. 선행적이고 미실현인 리스크 신호다.
  • write_off_policy(펀드 재량, 동남아 P2P는 보통 180 DPD 안팎): 펀드가 포기하고 대출을 장부에서 제거한다. 이때에야 미상환과 NPL에서 빠지고 실현 손실(cumulative_loss)이 되며, 그것이 NAV에 반영된다.
DPD:  0 ─────── 30 — 60 — 90 ─────────── 180 ──────►
      정상       └ 연체 구간 ┘      │              │
                                    │◄─── NPL ───►│
                              npl_threshold     write_off
                              (부실)             (실현 손실)

      ├──────── 아직 장부에 있음 ──────────────────┤├─ 장부에서 제거 ─
      └──────── outstanding_principal ────────────┘└─ 누적 손실 ─────
  • NPL 잔액 = lower_days ≥ npl_threshold_days인 구간의 노출 중 아직 상각되지 않은 것(장부에 남아 있는 것)이다. npl_ratio = NPL 잔액 ÷ total_outstanding_principal로 들어가고, NAV와는 별개인 선행 리스크 배지 신호다(04-pool-models).
  • 누적 손실(cumulative_loss) = 상각된 원금이다. NAV 산식의 상각 항에 들어가는 실현 손실이다. 대손충당금이 아니고 NPL 수치도 아니다. 배지에서는 표시 전용이다.
  • 어느 시점에도 둘이 겹치지 않는다. 1달러는 장부에 있거나(NPL일 수는 있다) 상각됐거나 둘 중 하나다.

⚠️ TODO(N-1): 코드가 이 문단과 정반대로 하고 있고, 수정은 대기 중이다. apps/infra/src/infra/report/sources/joob/observe.ts가 파트너의 totalNpl(장부에 남아 있는 연체 잔액)을 CUMULATIVE_LOSS 지표로 넣고 있어서, 지금 NAV는 잃은 돈이 아니라 늦은 돈을 기준으로 가격이 매겨지고 있다. 합의된 방향은 파트너 API에 회수·상각 필드를 요청한 뒤 이 페이지를 코드에 맞추는 것이다. 정확한 표현은 그 요청 결과에 달려 있어서 여기 일부러 적지 않았다.

OJK 스케줄과의 관계 · Joob의 확정 정책 (2026-07-24)

펀드가 NPL을 선언하는 것과 같은 DPD에 상각하면 둘이 합쳐진다(장부상 NPL이 0에 가까워진다). OJK의 단계적 스케줄이 그런 대응 중 하나다. 대출이 아직 장부에 NPL로 있는 동안 부분 손실(5 / 15 / 50%)을 인식하고 181일 이상에서 100%(전액 상각)에 도달한다. OJK 밖의 펀드, 즉 대부분의 동남아 P2P는 각자 write_off_policy를 정한다. Aset은 충당률을 강요하지 않고 펀드가 보고하는 것을 받아들인다. v3-72 참조.

Joob의 실제 정책: DPD 90에 NPL, 약 180 DPD에 상각, 현금 수취 기준 펀드 가치. 2026-07-24 Joob 통화에서 FJO 펀드에 대해 오래 열려 있던 항목들이 확정됐다.

  • DPD 90에 NPL, 약 6개월(≈180 DPD)에 상각. 대출은 90 DPD에 부실로 분류되지만 장부에 남는다. DPD-90 구간에 남아 미상환 원금에 포함되므로 npl_ratio에 들어간다. 실제 상각(장부에서 제거해 실현 cumulative_loss로 넘기는 것. Joob의 별도 "상각됨 / 회수 중" 항목)은 대략 **6개월(약 180 DPD)**에 일어난다. 그래서 npl_threshold_days = 90write_off_policy ≈ 180합쳐지지 않는다. 실제로 90~180 DPD의 장부상 NPL 구간이 존재하고, 선행 NPL 리스크 배지 신호가 정상적으로 작동한다(예: 2026-07-24 스냅샷의 NPL 약 10.6% → HIGH).
  • current_fund_value는 현금 수취 기준이고 Aset은 그대로 미러링한다. 실제로 수취한 이자만 세고 승인됐지만 미수취인(미수) 이자는 세지 않는다. 승인에서 수취까지의 시차가 1~2일 정도라 NAV 대비 무시할 만하므로, Aset은 별도 accrued_income 항을 더하지 않고 현금 기준 fund_value를 그대로 받아들인다. 그 요청은 철회했다(컬럼은 휴면 상태). Joob은 DPD와 마찬가지로 펀드 가치도 매일 09:00 EOD 갱신으로 옮기고 있다.

Joob 풀 설정 → 펀드 가치 기준v3-90 참조.

DPD 자동 상각: 보류됐고 구현되지 않았다

🚧 구현되지 않았고 아래 내용은 아무것도 배선되지 않았다. Joob의 대출별 데이터에 막혀 있다. guest API는 집계 DPD 구간만 노출한다(펀드 라우트에서 30/60/90, 마스터 라우트에서 30/60과 /risk/nonperforming이며 거기서 구간 90은 없어졌다. Guest API 표면 참조). 대출별 원금이 없으므로 DPD→OJK→updateNAV 스케줄러는 api-stack.ts에서 명시적으로 보류돼 있다. 현재 report.scheduler.fetch는 구간을 표시용 관측값으로 기록만 하고, OJK 스케줄은 SQL 주석으로만 존재하며, loan_writeoffs에는 라이터가 없고, JOOB_DPD_AUTO NAV 생산자도 없다.

⚠️ 빠진 것은 DPD→OJK 대출별 파이프라인이지 NAV 자동 유도가 아니다. 가격 sweep은 이미 돌고 있다. runPricingSweep(apps/infra/src/infra/nav/pricing/index.ts)이 report.scheduler.derive에서 호출돼 파트너가 보고한 누적 손실로 NAV를 유도한다. 이는 OPEN 제안을 열고, 사람이 각각을 승인하거나 덮어쓰거나 기각한다. "사람이 관여한다"는 점은 애초에 쟁점이 아니었다. 자동화된 것은 숫자를 유도하는 것이다. POST /nav-changes로 NAV를 손으로 입력하는 것은 산식이 표현할 수 없는 정정·재작성 경로이지 유일한 경로가 아니다. → 두 경로, 하나의 산식

보류된 설계: 대출별 데이터가 생기면 목표로 삼을 것

external_provider = JOOB인 풀(그리고 앞으로 대출별 데이터를 노출할 파트너)에 대해, 매일 도는 joob-dpd-sync Lambda를 계획했다. 이 Lambda는 다음을 수행한다.

  1. 파트너 API(예: Joob eNote 엔드포인트)로 대출별 DPD 데이터를 가져온다
  2. 대출마다 상각 스케줄을 적용한다
  3. 대출 단위 상각을 풀 단위 NAV 조정으로 집계한다
  4. Pool.updateNAV(newNav, 0)를 호출한다. 표준 24시간 타임락이 적용되고, R8에 따라 reserveConsumed = 0이다

초기 상각 스케줄: OJK 표준 (POJK No.40/2019)

인도네시아 규제 표준이다. Joob이 OJK 아래서 운영하므로 내부 충당도 여기에 맞을 가능성이 높다.

DPD 구간OJK 분류상각률
1~90DPK(요주의)5%
91~120Kurang Lancar(고정 이하)15%
121~180Diragukan(회수 의문)50%
181 이상Macet(추정 손실)100%

Joob 확인(2026-07-24): 90에 NPL, 약 180 DPD에 상각

Joob은 DPD 90에 부실로 분류하고(장부에 남는다) 약 6개월(≈180 DPD)에 상각한다. 둘이 분리돼 있으므로 실현 손실 전에 OJK식 90~180 연체 구간이 존재한다. 그 구간 동안 Joob이 5/15/50/100 단계로 부분 충당을 하는지, 아니면 상각(약 180) 전까지 장부 가치를 그대로 두는지는 확인되지 않았다. 위 스케줄은 보류된 설계 템플릿으로 남겨 둔다. DPD→NAV 자동 파이프라인이 보류됐고 NAV가 수동이므로 어느 쪽이든 배선돼 있지 않다. 다른 파트너(LINE BK 등)는 각자의 스케줄을 정할 것이고, 아마 풀별 writeoff_schedule 차원으로 승격될 것이다(v3-13.1, 보류). v3-90 참조.

감사 추적

자동 상각마다 다음을 기록한다.

  • 영향받은 대출마다 loan_writeoffs 행(loan_id, dpd_bucket, rate_applied, amount, snapshot_date)
  • 풀 단위 nav_history 행(source = JOOB_DPD_AUTO, 사유에 집계 건수 포함)
  • 어드민이 24시간 타임락 동안 대기 중인 NAV 갱신을 검토할 수 있고 수동 덮어쓰기도 가능

모든 풀이 하나의 처리 경로를 쓴다.

파트너 데이터 소스(API 또는 어드민 입력)
  ↓ 원천 입력: 자산 가치, DPD, 상환, NPL, 담보 상태
Aset 오라클 Lambda
  ↓ 적용: 상각 스케줄(v3-13 OJK), 트랜치 워터폴(v3-14), Aset 산식
PlatformPool.updateNAV(new_nav)
  ↓ 하락은 24시간 타임락
nav_per_token 갱신

NAV는 항상 Aset이 처리한다(v3-15). 파트너가 자기 NAV 숫자를 제시해도 Aset이 받아들여 검증하고 판단한다. "순수 미러링" 모드는 없다.

파트너원천 데이터Aset 처리
JoobeNote API(대출별 DPD, RNI, NPL)OJK 상각 스케줄 → NAV 집계
LINE BK(계획)LFC 리스크 대시보드(그들의 NAV 제안)타당성 확인 + 트랜치 워터폴 → 최종 NAV
Aset 직접 풀내부 포트폴리오 데이터Aset 자체 평가 산식

NAV는 어떻게 계산되나

정본 산식은 하나다. 이 산식은 절대값 기준이라, 이전 NAV에 이어 붙이지 않고 실행할 때마다 누적 손실에서 다시 계산한다. 그래서 상각이 반복돼도 표류하지 않고 bufferCap이 상수 임계값으로 작동한다. 기호 이름은 nav-formula.ts와 일대일로 맞춰 두어서 여기 있는 것은 거기서 grep할 수 있다. total_deposited가 유일한 예외이고 컬럼이 아니라 라벨이다(이유).

🔴 2026-08-27 결정(v3-153) 아직 반영 안 됨. 아래 NAV 산식과는 다른 엔진인 이자 발생에서는 원금이 납입 자본(들어온 돈에서 돌려준 돈을 뺀 값)이지 배포된 엔진이 쓰는 LP 토큰 수가 아니다. 그리고 발생분은 NAV를 따라가지 않는다. 이자는 상각된 가치가 아니라 원금에 붙는다(이유와 IFRS 9 §5.4.1(b) 근거).

lossRatio      = cumulative_loss ÷ total_subscribed   ← 둘 다 같은 파트너 보고서에서 (R7)
cumulativeLoss = lossRatio × total_deposited          ← 이제 풀의 통화 기준

bufferCap      = total_deposited × buffer_rate_bps / 10000   ← basis = NET이면 0으로 강제
uncoveredLoss  = FIRST_LOSS ? max(0, cumulativeLoss − bufferCap)
                            : min(max(0, cumulativeLoss), bufferCap)
                                                      ← reserve가 아니라 버퍼다 (R8)

# 그룹 풀만: Junior → Mezzanine → Senior가 다음으로 uncoveredLoss를 소진 (v3-14)

rawNav         = (total_deposited − uncoveredLoss) / totalSupply   ← 온체인 LP 공급량 (R9)
suggestedNav   = clamp(rawNav, 1e-6, 1.0)
escalate       = rawNav <= 0        ← proposeImpairment로 보내고, 절대 자동 적용하지 않음

pool.updateNAV(suggestedNav, 0)     ← R8에 따라 reserveConsumed는 항상 0

FIRST_LOSS에서는 bufferCap까지의 손실이 NAV를 깎지 않고, EXCESS에서는 반대로 투자자가 그것을 지고 운용사는 초과분만 진다. 호출 후 navPerToken = suggestedNav가 되거나, 하락이라면 24시간 타임락 대기에 들어간다. 상각은 reserveBalance건드리지 않는다.

**total_subscribed**는 파트너가 보고하는 펀드 자신의 누적 약정 원금이고(external_pool_data_snapshots.total_subscribed, 0054), 우리 풀의 입금액이 아니다. 첫 줄의 요점이 그것이다. 두 입력이 같은 보고서에서 같은 통화로 오므로 통화가 약분되고 lossRatio가 무차원이 된다. total_subscribed가 없거나 0 이하인 스냅샷은 비율을 만들지 못하므로 sweep이 추측하지 않고 그 풀을 건너뛴다.

자동(A4)수동
진입report.scheduler.derive(runPricingSweep 실행) → 어드민 승인·덮어쓰기·기각POST /nav-changes
입력파트너 보고서의 cumulative_losscumulative_loss(권장) 또는 직접 입력한 new_nav. 둘을 동시에 주지는 못한다
범위단독 풀(트랜치 NAV는 POST /tranche-writedown이 담당)동일
산식computeNavFromPoolLoss같은 computeNavFromPoolLoss

조합 하나는 의도적이다. sweep이 유일한 호출자이던 동안 버퍼는 단독 풀 13개 중 1개에만 적용됐고, 나머지 12개에서는 그 사실을 알려 주는 게 아무것도 없었다. new_nav 형태는 산식이 표현할 수 없는 정정과 재작성을 위해 존재하고, 유도된 형태는 nav_history.loss_amountloss_as_of를 기록하므로 NAV 하락이 자기 원인을 밝힐 수 있다.

손실 형태에는 가드가 셋 있고, 각각 이미 일어난 사고에 대응한다. cumulative_loss > total_deposited는 거부한다(총 입금보다 큰 누적 손실은 데이터 오류이거나 전손이고, 손으로 입력한 R7 통화 혼동도 여기서 잡힌다). 계산된 전손은 거부하고 proposeImpairment로 보낸다(D3). 그리고 어드민 미리보기는 브라우저에 산식을 한 벌 더 두는 대신 서버 자신의 dry_run이다(산식을 돌리고 온체인 simulate까지 한다).

구현 상태: 완료. 분모까지 포함해 두 경로 다 반영됐다. reserve 항이 사라졌고(R8), 버퍼는 실제 설정이 됐으며(R6), 분모는 온체인 LP 공급량이다(R9). usableTotalSupplynull을 반환하고 호출자는 net_principal로 폴백하는 대신 풀을 건너뛴다. 그 폴백이 정확히 R9 이전의 버그였다. → 14-decisions A4 · v3-109

updateNAV의 두 번째 인자는 남아 있지만 반드시 0이어야 한다 (R8)

배포된 시그니처는 여전히 updateNAV(uint256 newNav, uint256 reserveConsumed)다. v3-16이 reserve 소진을 NAV 쓰기와 atomic하게 만들었고, R8은 ABI를 바꾸지 않는다. 바뀐 것은 값이다.

호출자 관습이 아니라 체인 제약이다. 모든 백엔드 경로가 0을 보내고(apps/infra/src/infra/nav/apply-proposal.ts:120, propose 경로는 apps/infra/src/infra/nav/apply-plane.ts:18,25. 둘 다 NO_RESERVE_CONSUMED 상수를 쓴다. lambda/nav-changes.post.propose.ts는 29줄짜리 라우트 껍데기이고 로직은 apps/infra/src/usecases/nav/propose-change.ts에 있으며 ctx.navApply로 체인에 닿는다. 옛 min(liveReserveBalance, newLossDelta) 식은 없어졌다), NavLib.updateNAV0이 아닌 값에 InvalidAmount로 revert한다(NavLib.sol:109-111). 그래서 그럴듯한 숫자를 넘겨서 옛 모델을 되살릴 수 없다. 이 가드는 상승·하락 분기보다 앞에 있다. 차감을 넘길 수 있었던 쪽이 하락 경로였기 때문이다. → 08a-contract-reference

reserve를 줄이는 것은 정확히 둘뿐이고 둘 다 손실이 아니다.

  • 상환 지급. 투자자가 상환하면 USDC가 실제로 빠져나가고 reserveBalance가 지급액만큼 줄어든다. 모자라면 PENDING_RESERVE가 되고 파트너가 fundRedemption()으로 보충한다.
  • Wind-down 배분. executeWindDown이 reserve와 회수한 자금으로 종착 pro-rata 이탈 가격을 매긴다(v3-100).

조기 상환 패널티(PRINCIPAL_BASED / FLAT_FEE / YIELD_BASED, 즉시와 회차 모두)는 풀의 fund_wallet으로 이전되며(v3-85) reserveBalance로 가지 않는다. 그래서 풀 밖으로 나가고 reserve에도 NAV에도 영향이 없다. ✅ 반영 완료(v3-84/v3-85). 배포된 RedemptionLib이 조기 상환 패널티를 fund_wallet으로 보낸다. 14-decisions v3-85 참조.

reserve 상태 전이 매트릭스는 v3-16 참조(그 문서의 손실 흡수 틀은 R8로 대체됐지만 상태 전이 자체는 여전히 유효하다).

#단계무슨 일이 일어나나
1Aset이 처리한다 🟡 Oracle오라클이 파트너 원천 데이터(또는 어드민 입력)를 받아 정본 산식을 돌리고(버퍼 흡수, 해당되면 트랜치 워터폴) PlatformPool.updateNAV(newNav, 0)를 호출한다. nav_history 행이 PENDING으로 생긴다
224시간 타임락(하락만) 🟢 Systemeffective_at = now() + 24h가 되고 투자자와 파트너에게 통지되며 상태는 PENDING이다. 상승은 즉시 적용된다. 환율에 따른 변동은 애초에 NAV 변동이 아니므로 타임락에 걸리지 않는다(R7)
3효력 발생 🟢 Systempools.nav_per_token이 갱신되고 nav_historyAPPLIED가 된다. 신규 입금은 새 NAV로 가격이 매겨진다. 예고 이전에 한 요청은 앞서 잡힌 nav_at_request를 유지하고, 예고 기간 안에 한 요청은 이미 여기 가격으로 매겨졌다(아래 R2·R3). 투자는 계속 열려 있고 신규 투자자는 갱신된 공정 가격을 낸다

데이터 소스가 죽어 있으면 🔴 Admin NAV는 마지막으로 알려진 값에 머문다. 심각한 경우 어드민이 풀을 pause()할 수 있고(is_paused = true), 그러면 신규 입금이 막히고 상환은 열려 있다.

⚠️ R2·R3: 타임락 기간의 모든 것은 예고된 NAV로 가격이 매겨진다

대기 중인 하락은 적용되기 전 24시간 동안 공개돼 있다. 그 기간에 새 활동을 옛 더 높은 NAV로 가격 매기면 공짜 옵션을 주는 셈이다. 신규 투자자는 내일 $0.98이 온다는 걸 알면서 $1.00에 살 수 있고, 나가는 투자자는 $1.00을 확정하고 손실을 남은 사람들에게 떠넘길 수 있다. 회차 풀은 정산 시점에 가격을 매기므로 여기서 자유롭다. 즉 이건 즉시 풀 문제다.

결정된 내용(R2·R3, v3-109. v3-111로 재투자까지 확장): 하락이 대기 중인 동안 자본을 움직이는 세 경로 모두 pools.nav_per_token이 아니라 예고된 새 NAV로 가격을 매긴다. 규칙 하나, 가격 하나, 경로별 예외 없음이다.

경로가격 기준
입금effectiveNav. ✅ 2026-08-04 배포
상환 요청effectiveNav. ✅ 2026-08-04 배포
재투자effectiveNav. ✅ 2026-08-06 배포(v3-111)
시점가격 기준
제안이 예고되기 제출옛 NAV(변경 없음. 이미 찍힌 스냅샷은 그대로다)
24시간 기간 제출예고된 새 NAV
적용된 제출새 NAV(이제 그것이 pools.nav_per_token이다)

이는 이 페이지의 이전 서술을 대체한다. 예전에는 투자가 현재 가격으로 "계속 열려 있고" 대기 중인 요청은 스냅샷을 유지한다고 적었는데, 둘째와 셋째 행에는 맞지만 가운데 행에는 틀렸다.

세 경로 모두 배포됐다. 다만 두 라운드에 걸쳐 배포됐고 풀은 태어날 때의 구현체를 따른다. 입금과 상환은 2026-08-04 구현체에서 PoolCommonLib.effectiveNav를 읽는다(입금은 PlatformPool.sol:756, 상환 요청은 RedemptionLib.sol:279:327, 커밋 0abe168). 재투자는 2026-08-06 구현체에서 합류했다(YieldLib.sol:299, v3-111, test_ReinvestPricesAtAnnouncedNavDuringDecrease가 이를 고정한다). v3-109 참조.

⚠️ 어느 풀이 올바르게 가격을 매기는지는 생성 시점에 달려 있다

풀은 생성 시 구현체가 고정되고 업그레이드할 수 없는 Clones다. 그래서 이건 시간이 지나면 따라잡는 롤아웃이 아니다. 풀은 동작을 바꾸지 않는다. 마이그레이션은 없고, 풀이 더 새로운 규칙으로 가격을 매기는 유일한 방법은 더 새로운 팩토리에서 만들어지는 것이다.

풀이 만들어진 팩토리입금상환 요청재투자
2026-08-06 팩토리(0x09614f0B…Ed0d), 현재✅ 예고 NAV✅ 예고 NAV✅ 예고 NAV
2026-08-04 팩토리(0xE1E2E974…DA90)✅ 예고 NAV✅ 예고 NAV⚠️ 낡은(더 높은) NAV → 같은 블록의 입금보다 LP를 민팅
이전의 모든 것(부트스트랩 풀 #0 포함)⚠️ 낡음⚠️ 낡음⚠️ 낡음

라운드별 배포 주소는 apps/contract/sepolia.md에 있다. ⚠️ 백엔드의 FACTORY_ADDRESS_84532가 현재 팩토리를 가리켜야 한다. 아니면 새 풀이 또 옛 구현체로 만들어진다.

온체인 NavLib._checkNavBound가 모든 updateNAV에 대해(상승과 하락 경로 모두) 서킷 브레이커와 양방향 편차 상한을 강제하고, RedemptionLib.executeEpoch가 회차 정산 시점에 NAV staleness를 추가로 강제한다. 백엔드는 simulateUpdateNav로 NAV 제안을 사전 점검하고(라이브 온체인 상태를 상대로 하는 viem simulateContract / eth_call 드라이런), 커스텀 오류(CircuitBreakerActive / NavDeviationExceeded / InsufficientReserve 등)를 깔끔한 4xx로 디코딩한다. 그래서 브레이커가 걸렸거나 상한을 넘는 변동이 가스를 낭비하거나 DB를 어긋나게 하지 않는다. 편차 상한과 staleness 임계값에는 온체인 getter가 없고(setter만 있다) 그래서 simulate가 기준이다. DB에 미러링하지 않는 것은 의도적이다.

투자자 UI는 없고, 그것이 설계다. 브레이커, 편차 상한, staleness는 운영 가드레일이다. 브레이커 리셋이나 상한 설정은 PAUSER·ADMIN 멀티시그 조작이고, 투자자가 할 수 있는 상태가 없다. 그래서 투자자 앱은 서킷 브레이커나 NAV staleness 표시를 하지 않는다. 정산 시점의 staleness는 회차 스케줄러의 epoch_circuit_breaker / epoch_execute_failed 알림(운영용)이 처리하지 투자자 화면이 처리하지 않는다. 투자자는 아래의 상각 배너와 NAV 이력으로 NAV 변화를 본다. 그게 의미 있고 행동으로 이어지는 신호다.

구현 노트와 근거

위 산식이 왜 그 모양인지를 다룬다. 페이지를 쓰는 데는 필요 없다. 이미 한 번 대가를 치른 결함을 나중의 변경이 다시 들여오지 못하게 하려고 남긴다.

R7: 통화 교차는 비율이고, 건강한 풀이 승인 한 번으로 0이 될 뻔했다

파트너는 자기 통화로 보고하고 풀 장부는 USD다. 배포된 경로는 하나에서 다른 하나를 그냥 뺐다. 1,022,311,789 EFIDR에서 573,206 USD를 빼면 −1782가 나오고, 그것이 하한으로 클램프되면서 escalate_flag가 설정됐다. dev가 만들어 낸 유일한 NAV 제안이 대출이 전부 정상인 풀에 대한 전손 상각이었다.

교차는 비율이고 환율이 아닌 것이 의도적이다. 두 입력이 같은 보고서에서 오므로 통화가 약분되고 결과가 무차원이 된다. 즉 어떤 환율 변동도 NAV에 닿을 수 없고, 그것이 R7이 요구하는 바다. pools.fx_rate로 환산하면 똑같이 그럴듯한 숫자를 만들면서 FX를 곧바로 가격 안에 집어넣게 된다. fx_rate는 표시용이다.

R6: 줄어드는 bufferBalance가 왜 이중 계상인가

이 산식의 이전 형태는 잔액을 들고 다녔다. bufferBalance = bufferCap − bufferAbsorbed를 두고 uncovered = loss − bufferBalance를 계산했다. 이는 흡수분을 두 번 뺀다. loss = cap = 100일 때 정답은 0인데 uncovered로 100을 보고한다. NAV 계산은 절대값 기준이라 매번 누계값에서 다시 계산하므로, 버퍼에는 상태도, buffer_cap 컬럼도, buffer_balance 컬럼도 필요 없다. 회귀 테스트가 잘못된 형태를 막는다. bufferAbsorbed = min(loss, cap)은 계층이 얼마나 소진됐는지 운영자에게 보여 주는 표시 값으로만 남는다.

왜 답이 아니라 설정으로 내보냈나. 파트너 계약 조건을 기다리는 것은 12시간마다 도는 산식에 대해 끝이 없는 기다림이다. 기다림이 막아 주는 것은 추측인데, 컬럼은 추측하지 않는다. FIRST_LOSS가 기본값인 이유는 그 값이 0일 때 현재 동작을 그대로 유지하기 때문이다. 요율 0의 EXCESS는 투자자가 아무것도 흡수하지 않는다는 뜻이 된다.

한동안 풀 하나에만 적용된 적이 있다. 제안 sweep이 유일한 독자이던 동안에는 나머지 12개 중 어디에 버퍼를 걸어도 저장만 되고 적용되지 않았을 것이라, 0119가 그 이유로 이를 금지했다. "아무도 이걸 읽지 않는다"에 대한 답으로는 옳았고, 읽는 것이 생기자 틀린 답이 됐다. POST /nav-changes가 이제 누적 손실을 받아 같은 산식을 돌리므로 0122가 제약을 트랜치 풀에만 걸리도록 좁혔다.

R9: 왜 원금이 아니라 totalSupply인가 (2026-08-04 반영)

NAV는 손실에 움직여야 하고 그 외에는 움직이면 안 된다. 원금으로 나누면 양방향으로 그게 깨졌고, 그중 상환 사례가 돈이 든 쪽이다.

0.90으로 상각된 $1,000 풀에서의 사건÷ 원금(옛 방식)÷ LP 공급량(R9)
500 토큰을 $450에 상환0.81820.9000
그다음 $900 입금0.93100.9000
그다음 $50 추가 상각0.89660.8667

나가는 투자자의 상각분이 남은 사람들에게 두 번째로 부과되고, 다음 입금이 그것을 일부 환급해 줬다. 두 사건 다 자산과는 아무 상관이 없었다.

수정의 근거가 되는 항등식은 이렇다. p = (V − L)/S일 때 T 토큰의 상환은 VV − T·p로, SS − T로 만들므로 가격이 p(S − T)/(S − T) = p로 다시 계산된다. A만큼의 입금은 A/p 토큰을 민팅하고 같은 방식으로 상쇄된다.

🔴 이는 분자가 상환을 뺀 순액이라는 데 의존한다. complete_redemption_atomictvl = tvl − payout을 수행하고, 그것이 상쇄를 성립시킨다. v3-109 결정문은 이 항을 total_deposited라 부르며 "늘기만 하는" 수치라고 설명하는데, 컬럼은 그렇게 동작하지 않는다. 이를 누계로 만들려는 선의의 변경은 나간 투자자의 원금을 분자에 남긴 채 토큰만 분모에서 빼서 상환이 일어날 때마다 NAV를 부풀린다.

공급량이 없는 것과 0인 것은 다르다. pools.lp_total_supply는 인덱서가 풀을 미러링하기 전까지 NULL이고, usableTotalSupply()는 NULL, 0, 음수를 모두 거부한다. 호출자는 원금으로 대체하지 않고 풀을 건너뛴다. 그 대체가 정확히 옛 분모이고 화면에서는 아무것도 드러나지 않기 때문이다. dev에는 현재 그 상태의 풀이 넷 있고, 그중 하나는 공급량 0에 $7.1m을 들고 있다(미러 오류가 아니라 온체인에서 확인됐다).

⚠️ R9는 원장 어긋남도 눈에 보이는 가격으로 바꾼다. 옛 형태는 데이터가 아무리 불일치해도 손실이 0이면 모든 풀에 정확히 1.0을 반환했으므로 아무것도 드러낼 수 없었다. dev의 한 풀은 19 토큰에 원금 $2.30을 들고 있어 이제 $0.121로 가격이 매겨진다. 그게 그 행들의 정직한 장부 가치이고, 불일치는 원래부터 거기 있었다. pool_position_stats.lp_supply는 DB 포지션의 합이므로 온체인 미러 대신 쓰면 안 된다.

clamp(…, 1e-6, 1.0)은 하한 메커니즘이 아니다

하한은 정책이 아니라 표현 한계로 읽어야 한다. 독립적인 가드가 넷 있고 단일한 "하한"은 없다.

가드위치하는 일
newNav == 0 → InvalidNav컨트랙트 updateNAV0을 바로 거부한다. $1.00을 넘는 값도 클램프한다
CHECK (nav_per_token > 0)DB저장 시점에 0을 거부한다
NAV_FLOOR = 0.000001tranche.post.writedown.ts에만소진된 트랜치의 계산된 NAV에 하한을 건다. nav-changes.post.propose에는 이런 상수가 없다
settleNav == 0 → InvalidNav회차 정산오라클 손상 대비 버그 가드다(v3-100). 상각과는 무관하다

UI나 문구에 반영해야 할 결과: "NAV 0"은 어디에서도 표현할 수 없다. 전손은 1e-6이고, "사실상 0"이라고 말하는 것은 숫자가 아니라 IMPAIRED로의 lifecycle 전이다. 그 전이는 자체 7일 온체인 타임락을 거친다(v3-106).

⚠️ 잠재적 충돌: navDeviationCapBps > 0이면 전손(1.0 → 1e-6)은 어떤 상한도 넘으므로 _checkNavBound가 revert하고, 상한을 풀기 전까지 상각을 적용할 수 없다. 지금은 문제가 아니다. setNavDeviationCap을 호출하는 것이 없고 pools.nav_deviation_cap_bps도 쓰이지 않는다. 다만 상한을 켜려면 먼저 상한 해제 → 상각 → 복구라는 명시적 단계가 필요하다.

NAV 제안을 둘러싼 거버넌스 (D1 · D2 · D3 / T2)

v3-109의 결정 셋이 누가 NAV를 움직일 수 있는지, 전손 뒤에 무엇이 따라야 하는지를 조인다.

무엇을 정하나이유
D1: 수동 경로도 통제한다POST /nav-changesPOST /tranche-writedownADMIN / SUPER_ADMIN이다. OPERATOR는 제거됐다A4의 승인·덮어쓰기는 이미 ADMIN 전용이었으므로, 손으로 입력하는 경로를 OPERATOR에게 열어 두면 약한 쪽이 실효 통제가 된다. 제안 계층을 건너뛰고 싶은 사람은 그냥 다른 엔드포인트를 썼다
D2: 제안을 기각할 수 있고 스스로 만료된다사유를 기록하는 명시적 기각과 48시간 TTL을 둬서 손대지 않은 제안이 만료되게 한다어드민이 동의하지 않는 제안은 갈 데가 없었다. 승인하거나, 덮어쓰거나, OPEN인 채로 영원히 두어 다음 sweep을 막는 것뿐이었다(자료 게이트가 두 번째 열린 제안을 거부한다). 기각은 삭제가 아니라 감사되는 행위다. 나중에 검토하는 사람에게 필요한 것이 그 사유다
D3 / T2: 전손은 온체인 IMPAIRED로 올라가야 한다rawNav ≤ 0이면 proposeImpairment7일 타임락executeImpairment로 풀을 전이시킨다(v3-106)escalate_flag만으로는 강제되는 결과가 없는 경고였다. 소진된 풀이 1e-6 NAV로 ACTIVE에 앉아 입금을 계속 받을 수 있었다. T2: 타임락이 한때 여기서 논의된 2인 승인을 대체한다. 서명 둘은 동시적이고 비공개인데, 공개된 7일은 투자자와 파트너와 우리 검토자가 이의를 제기할 수 있게 한다. 그리고 이탈권과도 잘 맞물린다(입금은 막히고 상환은 열려 있다)

계산 예시

손실 이벤트가 발생하면(예: 차주 부실) NAV가 그만큼 낮아진다. Aset 오라클 Lambda가 실행하는 순서대로 보자.

단계별 예시

상황: Joob EWA Fund 풀의 총 입금이 $1,000,000이다. 한 차주가 $120,000 대출을 상환하지 못했다.

1단계: 운용사의 equity 버퍼를 먼저 확인한다

흡수하는 값은 bufferCap, 즉 운용사의 first-loss 자본이다(R6). 풀 reserve가 아니다.

  • 왜 reserve가 아닌가. reserve는 claim NAV가 가격 매기는 청구권 안에 이미 들어 있는 투자자 돈이라, 손실에서 상계하면 투자자에게 두 번 크레딧을 주게 된다(R8).
  • 예시 계산. 확정된 버퍼가 FIRST_LOSS 방식으로 total_deposited의 10%라고 하자. 그러면 bufferCap = $100,000이다. 누적 손실 $120,000 중 $100,000을 덮으므로 남는 미충당 손실은 $20,000이다.
  • 한도는 임계값이지 줄어드는 잔액이 아니다. uncoveredLoss = max(0, cumulativeLoss − bufferCap)을 매번 누계값에서 다시 계산하므로, 보고된 손실이 그 사이 커진 풀은 같은 한도에 더 큰 미충당 수치를 보인다.
  • ⚠️ 런칭 시점에 이 단계는 아무것도 흡수하지 않는다. 모든 풀에서 buffer_rate_bps가 0이라 bufferCap = 0이고, 첫 실현 손실이 곧바로 투자자에게 닿는다. 앞에 있는 계층은 담보뿐이다.

2단계: (해당된다면) Junior 트랜치를 확인한다 트랜치 그룹에 속한 풀(v3-14)은 남은 미충당 손실을 Junior 풀이 NAV 워터폴로 다음에 흡수한다(그 NAV가 먼저 떨어진다). 단독 풀(tranche_group_id가 NULL)은 이 단계를 건너뛴다. 완전히 소진된 트랜치만 IMPAIRED로 올라가고, 일부만 흡수한 트랜치는 NAV < 1.0인 채 ACTIVE로 남는다(v3-106). 그룹 경로는 POST /tranche-writedown이고 ADMIN / SUPER_ADMIN이다(v3-106. 그것이 반영되기 전까지는 OPERATOR도 받는다). A4의 승인·덮어쓰기가 아니라 풀별 직접 updateNAV 쓰기로 적용된다. 상세는 04-pool-models → 손실 워터폴 참조.

3단계: 새 NAV를 계산한다 버퍼와 (있다면) Junior를 지나고 남은 미충당 손실이 nav_per_token을 줄인다.

새 NAV = 현재 NAV × (1 − 미충당 손실 ÷ 총 입금)

새 NAV = $1.00 × (1 − $20,000 ÷ $1,000,000) = $1.00 × 0.98 = $0.98

이 곱셈 연쇄는 이해를 위한 모델이지 구현이 아니다. "2% 상각"을 사람이 손으로 헤아리는 방식이라 남겨 뒀고, ÷ 총 입금은 풀에서 차지하는 몫이라는 직관이지 실제 분모가 아니다. 정본 산식은 NAV를 절대값으로 계산한다. (total_deposited − uncoveredLoss) / totalSupply = ($1,000,000 − $20,000) / 1,000,000 토큰 = $0.98이다. 여기서 둘이 일치하는 것은 NAV가 정확히 $1.00이고 아무도 상환하지 않았기 때문이다. NAV가 움직였거나 LP가 소각된 뒤에는 갈라지고, totalSupply 분모를 쓰는 절대값 형태가 정본이다(R9).

이미 상각된 풀을 손으로 계산할 때는 곱셈에 현재 NAV를 쓴다. 상각은 누적이다. 1단계에서 빼는 것은 버퍼이지 reserve가 절대 아니다(R8).

4단계: 24시간 타임락을 적용한다 NAV 하락은 효력 발생 전에 24시간 대기가 필요하다. NAV 상승은 즉시 적용된다.

  • 하락 폭이 2%이므로 대기 중 NAV 갱신으로 제출된다
  • 24시간 타임락 동안 이해관계자가 검토하거나 대비할 수 있다
  • NAV 상한은 $1.00이다

5단계: 기존 투자자에게 미치는 영향 NAV $1.00에 $10,000을 예치한 투자자는 10,000 토큰을 갖고 있다. 새 토큰 가치는 10,000 × $0.98 = $9,800이다. 토큰 가치로 $200(2%)을 잃었지만, 수익은 여전히 $10,000 전액에 대해 쌓인다.

6단계: 신규 투자자는 공정하게 진입한다 NAV $0.98에 $10,000을 예치하는 신규 투자자는 tokens_minted = $10,000 / $0.98 = 10,204 토큰을 받아, 기존 투자자보다 비례적으로 더 많은 토큰을 갖는다. 공정한 진입이다.

v3-15에 따라 산식은 항상 Aset이 돌린다. 파트너는 원천 입력(자산 가치, DPD, NPL)을 주고 Aset이 판단한다. Aset 처리를 건너뛰는 "파트너 보고 NAV" 경로는 없다.

포트폴리오 NAV 표시

풀의 NAV가 $1.00 아래면 포트폴리오 페이지가 상각을 투명하게 보여 준다. 다만 불안을 부추기는 디자인은 쓰지 않는다. 투자자는 전문적이고, 정보를 숨기는 것이 보여 주는 것보다 신뢰를 더 빨리 갉아먹는다.

이렇게 한다

  • 이렇게 표시한다: "투자금: $1,000 → 현재 가치: $880 (NAV: $0.88)"
  • 중립적인 색을 쓴다(회색·파란색 정보 카드)
  • 해당 풀의 NAV 이력 타임라인으로 연결한다
  • 실제로 존재하는 NAV 거버넌스 경계인 $1.00 상한을 보여 준다(프리미엄 가격이 없으므로 신규 투자자가 비싸게 사는 일이 없다). "$0 하한"이라고 쓰지 말 것. 하한은 없고 0은 표현조차 안 된다. 전손은 1e-6으로 표시되며, "사실상 0"을 전달하는 것은 숫자가 아니라 IMPAIRED로의 lifecycle 전이다(상세)
  • 요율이 확정된 경우 버퍼 커버 상태를 포함한다. 그것이 손실과 NAV 사이에 서 있는 계층이다(R6). reserve를 손실 커버로 제시하지 말 것. 그건 상환 유동성이다(R8)
  • 하락을 그 원인에 연결한다. NAV 하락은 그것을 만든 상각으로 링크돼야지 설명 없는 숫자로 나타나면 안 된다

이렇게 하지 않는다

  • 빨간 경고 배너나 경보 아이콘
  • 맥락 없이 "손실", "부실", "위험" 같은 단어
  • 클릭이나 펼치기 뒤로 상각을 숨기는 것
  • 거버넌스·커버 정보 없이 NAV 변화만 보여 주는 것

→ 지급액 계산과 NAV 시나리오 예시는 상환 참조.