feat(anchoring): 가격구간을 자릿수 계단식 사다리(46칸)로 재설계 (TODO 1)

균일 3,000원 × 33,334칸 → 자릿수 사다리 46칸. 폭 = 구간 상한의 10%
(선행 자릿수 밴드): [0, 1,000) 통일 1칸 + 자릿수(1천~1억, 5개)당 9칸
— "1천 원대·2천 원대 … 9천만 원대". 1억 초과는 마지막 인덱스(45) 클램프.

- 고가 구간의 무의미한 3,000원 해상도 제거 → 칸당 표본 밀도 대폭 개선
  (희소 칸 이월 문제 구조적 완화), 정적 테이블 2MB → 3KB
- 사다리 단일 소스 = constants.UPPER_BOUNDS(생성식), json 은 기동 시 대조 검증
- calc_bracket_index: // 3000 → bisect_right (좌폐우개 경계 규약 동일 유지)
- 조정 이력 0건 시점 적용 — 인덱스 재매핑/마이그레이션 없음. DB 스키마 무변경
- 골든 테스트 재작성(경계·자릿수 진입·클램프) + 사다리 형태 검증 추가 — 16 passed
- 문서 5종·인수인계·README·스키마 주석 동기화, TODO 과제 1 완료 처리

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-02 20:08:23 +09:00
parent 84786134c2
commit e1940c69aa
16 changed files with 177 additions and 33451 deletions

View File

@ -1,6 +1,6 @@
# anchoring — 앵커링 값 자동 조정 배치 (자립 모듈)
회사 × 협력사유형(1유통/2제조/3총판) × 가격구간(3,000원, 33,334칸)별 앵커링 값(‰)을
회사 × 협력사유형(1유통/2제조/3총판) × 가격구간(자릿수 계단식 사다리, 46칸 — 예: 3만 원대)별 앵커링 값(‰)을
**격주 토 00:00 KST** 배치로 협상 성공률에 따라 자동 조정한다.
`schedules/anchoring` 아래에서 **완전 독립**으로 동작 — backend 코드를 import 하지 않는다.

View File

@ -1,55 +1,18 @@
# TODO — 앵커링 모듈 후속 과제
> 두 과제 모두 **정책 재확정 + 스펙(v1.3) 개정 사안**이다. 현재 규범(`docs/개발용.md` §12)은
> 남은 과제는 **정책 재확정 + 스펙(v1.3) 개정 사안**이다. 현재 규범(`docs/개발용.md` §12)은
> 정적 테이블 변경·파라미터 조정을 봉인하고 있으므로, 착수 전 정책 확정 → 문서 개정 → 구현 순서를 지킨다.
> 두 과제 다 **운영 데이터(조정 이력)가 쌓이기 전에 확정하는 것이 가장 저렴하다** — negodata 적용 전이 적기.
> **운영 데이터(조정 이력)가 쌓이기 전에 확정하는 것이 가장 저렴하다** — negodata 적용 전이 적기.
---
## 1. 정적 기본 테이블의 가격구간을 계단식으로 재설계
## ~~1. 정적 기본 테이블의 가격구간을 계단식으로 재설계~~ ✅ 완료 (2026-07-02)
### 배경
현재 `resources/anchoring_base.json` 은 0원~1억을 **균일 3,000원 폭 33,334칸**으로 나눈다.
저가 구간에서는 3,000원 구분이 유효하지만, 고가 구간(예: 9,900만 원대)에서 3,000원 차이는
협상 관점에서 의미가 없다. 결과적으로:
- 고가 칸은 표본이 거의 안 모여 **영원히 이월**만 반복(희소 칸 문제를 구조적으로 가중)
- 33,334칸 중 실질 사용 칸이 극소수 — 테이블·캐시·인덱스가 불필요하게 큼
### 방향 (예시 — 정책 확정 필요)
가격대별로 구간 폭을 넓히는 계단식. 예:
| 가격대 | 구간 폭(예시) |
|---|---|
| ~ 100만 | 3,000원 |
| 100만 ~ 1,000만 | 30,000원 |
| 1,000만 ~ 1억 | 300,000원 |
| 1억 초과 | 마지막 구간 편입(현행 유지) |
정확한 경계·폭은 실거래 목표가 분포를 보고 정책으로 확정한다.
### 착수 시 변경 범위 (전부 이 모듈 안 + 문서)
- [ ] `resources/anchoring_base.json` — 구간표 재생성(행 수 대폭 감소)
- [ ] `service.calc_bracket_index` — `// 3000` 단순 나눗셈 → 계단식 경계 탐색(이분 탐색 등)으로 교체
- [ ] `base_table._validate` — "upper_bound == idx×3000" 검증을 새 규약으로 교체
- [ ] `constants` — PRICE_BRACKET_UNIT/BRACKET_INDEX_MAX 등 상수 재정의
- [ ] 골든 테스트 `tests/test_core.py` §11.2 — 경계 케이스 재작성
- [ ] 문서 — `docs/개발용.md` §2·§4.1, `docs/기획용.md` §2, `docs/워크플로우.md` 칸 설명
- [ ] negodata 이식본(reader) 재전달 — `docs/인수인계.md` 갱신
### ⚠️ 이력 호환성 (핵심 리스크)
`anchoring.rate_adjustments.price_bracket_index` 와 Redis 키의 인덱스 의미가 바뀐다.
**이미 조정 이력이 쌓인 뒤에 개편하면** 구 인덱스 ↔ 신 인덱스 해석이 섞이므로:
- 이력이 쌓이기 전(negodata 적용 전)에 개편하거나,
- 이후라면 구간표에 버전을 부여(`bracket_schema_version` 컬럼 또는 신규 테이블 세대 구분)하고
기존 이력의 재매핑/동결 전략을 함께 설계해야 한다.
---
**자릿수 계단식 사다리(46칸)로 확정·구현 완료.** 폭 = 구간 상한의 10%(선행 자릿수 밴드):
[0, 1,000) 통일 1칸 + 자릿수(1천~1억, 5개)당 9칸 — "1천 원대·2천 원대 … 9천만 원대".
1억 초과는 마지막 인덱스(45) 클램프. 사다리 단일 소스 = `constants.UPPER_BOUNDS`,
산식 = `bisect_right`. 조정 이력 0건 시점에 적용해 마이그레이션 없음.
상세: `docs/개발용.md` §2·§4.1.
## 2. `anchoring_records` — 회사별 앵커링 값 전체 조회 테이블
@ -69,10 +32,9 @@
하며, append-only 보호 대상(§12)에는 포함하지 않는다
- [ ] **쓰기 주체·시점**: 배치가 조정 트랜잭션에 함께 기록할지(정합 우선), 커밋 후
best-effort 로 기록할지(조정 경로 무손대 우선 — 재구축 가능하므로 후자도 충분)
- [ ] **무조정 칸 표현**: 시작값(10‰) 그대로인 칸을 행으로 둘지(회사 온보딩 시 33,334행
선생성 — 구 설계의 JSONB 문제 재발 주의), 조회 시 정적 테이블과 합성할지
- [ ] 과제 1(구간 개편)과 **순서 조율** — records 스키마에 bracket 인덱스가 들어가므로
구간 개편 후에 만드는 것이 이중 작업을 피한다
- [ ] **무조정 칸 표현**: 시작값(10‰) 그대로인 칸을 행으로 둘지(회사 온보딩 시 46행 선생성 —
사다리 개편으로 부담 미미), 조회 시 정적 테이블과 합성할지
- [x] ~~과제 1(구간 개편)과 순서 조율~~ — 구간 개편 완료(46칸 사다리), records 는 새 인덱스 기준으로 설계하면 됨
### 참고

View File

@ -32,7 +32,7 @@
| 역할 분담 | **`schedules/anchoring` 자립 모듈** = 정적 테이블·rate 조회(reader)·조정 배치·Redis 규약·DDL 소유, 독립 컨테이너로 자체 스케줄 실행 / **negodata** = 세션 생성 시 reader 로 rate 조회 → 앵커가·rate 박제 (인수인계, §9.1) / **backend** = 협상 채팅(박제값 소비 + 마지막 제시가 기록, anchoring 모듈 **무의존**) (§9.2) / **agent** = **변경 없음**(앵커 비노출 — 정보 비대칭 전략) |
| 소유·수정 범위 | 직접 수정 가능 = `backend`·`frontend`·`schedules`(우리 모듈). `negodata`·`agent`는 인수인계 문서로 전달 → 담당 개발자가 적용 |
| 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`src/anchoring/resources/anchoring_base.json`, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 |
| 가격 상한 | 정적 테이블 상한 = **정확히 1억 원**. `target_price > 1억`은 전부 **마지막 인덱스(idx 33334)** 로 클램프 |
| 가격구간 | **자릿수 계단식 사다리(46칸)** — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = **정확히 1억**, `target_price > 1억`은 전부 **마지막 인덱스(45)** 로 클램프 |
| 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 |
| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`target_anchoring_price`, `anchor_rate_permille`, `last_offered_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 |
| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offered_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 |
@ -51,30 +51,42 @@
서비스 시작 시 메모리에 로드되는 불변 리스트. **DB에 저장하지 않으며, 런타임에 절대 수정하지 않는다** (MUST NOT).
파일: `src/anchoring/resources/anchoring_base.json` (리포에 커밋, 33,334행). 키는 프로젝트 컨벤션대로 snake_case.
파일: `src/anchoring/resources/anchoring_base.json` (리포에 커밋, **46행**). 키는 프로젝트 컨벤션대로 snake_case. 구간은 **자릿수 계단식 사다리**:
| 구간 | 폭 | 칸 수 |
|---|---|---|
| 0 ~ 1,000 | (한 칸으로 통일) | 1 |
| 1,000 ~ 1만 | 1,000원 | 9 |
| 1만 ~ 10만 | 1만 | 9 |
| 10만 ~ 100만 | 10만 | 9 |
| 100만 ~ 1,000만 | 100만 | 9 |
| 1,000만 ~ 1억 | 1,000만 | 9 |
| **합계** | 폭 = 자릿수 시작값(구간 상한의 10%) | **46** |
```json
[
{ "idx": 1, "upper_bound": 3000, "anchoring_value": 0.01 },
{ "idx": 2, "upper_bound": 6000, "anchoring_value": 0.01 },
{ "idx": 1, "upper_bound": 1000, "anchoring_value": 0.01 },
{ "idx": 2, "upper_bound": 2000, "anchoring_value": 0.01 },
...
{ "idx": 33333, "upper_bound": 99999000, "anchoring_value": 0.01 },
{ "idx": 33334, "upper_bound": 100000000, "anchoring_value": 0.01 }
{ "idx": 10, "upper_bound": 10000, "anchoring_value": 0.01 },
{ "idx": 11, "upper_bound": 20000, "anchoring_value": 0.01 },
...
{ "idx": 46, "upper_bound": 100000000, "anchoring_value": 0.01 }
]
```
> 마지막 행(idx 33334)만 `upper_bound`가 `idx × 3000`(= 100,002,000)이 아니라 **정확히 100,000,000(1억)** 이다. 1억 초과 가격은 전부 이 마지막 인덱스로 클램프된다(§2.1).
> 각 칸은 사람이 부르는 가격대와 일치한다 — idx 13 = "3만 원대"([30,000, 40,000)). 1억 초과 가격은 전부 마지막 인덱스로 클램프된다(§2.1). 사다리의 단일 소스는 `constants.UPPER_BOUNDS`(생성식)이며, json 은 기동 시 이와 대조 검증된다.
### 2.1 매핑 규약 (MUST)
| 항목 | 규약 |
|---|---|
| 구간 범위 | `idx` k의 구간 = **`[upper_bound − 3000, upper_bound)`** 좌폐우개 |
| 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 3,000원 → idx 2 |
| 내부 인덱스 변환 | `bracket_index = idx − 1` = `target_price // 3000` (0-기반). DB·Redis·코드 내부는 `bracket_index` 사용 |
| 상한 클램프 | `target_price ≥ 99,999,000` → 전부 최상위 구간(idx 33334, `bracket_index` 33333). **1억 초과도 예외 없이 마지막 인덱스** |
| 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) |
| 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) |
| 내부 인덱스 변환 | `bracket_index = idx − 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `bracket_index` 사용 |
| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `bracket_index` 45). **1억 초과도 예외 없이 마지막 인덱스** |
| 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 `anchoring_value` 천분율 변환 정수: `int(anchoring_value * 1000)`. 현재 전 구간 10‰ |
| 기동 검증 | 로드 시 33,334행·idx 연속(1..33334)·`upper_bound == min(idx*3000, 100_000_000)`·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) |
| 기동 검증 | 로드 시 46행·idx 연속(1..46)·`upper_bound == constants.UPPER_BOUNDS[i]`(사다리 대조)·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) |
- 시작값은 **정적 테이블에서만** 읽는다. 코드에 `0.01`/`10` 하드코딩 **MUST NOT** (테이블이 유일한 소스).
- `anchoring_value`는 회사 무관 공통. 회사별 차이는 **조정 이력의 누적**에서만 발생한다.
@ -102,9 +114,11 @@ DELTA_PERMILLE = {
SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면 스킵·이월)
PRICE_BRACKET_UNIT = 3_000 # 가격구간 폭 (원)
# 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드)
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
BRACKET_INDEX_MAX = 33_333 # 0-기반 구간 인덱스 상한 (총 33,334칸)
UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개
BRACKET_COUNT = 46
BRACKET_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
@ -146,10 +160,10 @@ class AnchoringSampleType(Enum):
칸 = **`(company_id, supplier_type, bracket_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.
```
bracket_index = min(target_price // 3_000, 33_333)
bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
```
- `bracket_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 1억 이상(≥ 99,999,000)은 전부 마지막 인덱스 33333.
- `bracket_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45.
- 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨).
- 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**.
- 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
@ -299,7 +313,7 @@ CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..33333 (앱 보장)
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
@ -414,7 +428,7 @@ CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
```
세션(상품 × 공급사) 생성 시마다:
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
bracket_index = min(target_price // 3000, 33333)
bracket_index = calc_bracket_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식
2. rate 조회 (모듈의 reader 이식):
supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12)
@ -452,18 +466,21 @@ CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
```python
# src/anchoring/service.py (순수 함수만 — DB/Redis 접근 없음)
from bisect import bisect_right
from anchoring.constants import (
ANCHOR_RATE_MIN, ANCHOR_RATE_MAX, DELTA_PERMILLE,
SAMPLE_THRESHOLD, PRICE_BRACKET_UNIT, BRACKET_INDEX_MAX,
SAMPLE_THRESHOLD, UPPER_BOUNDS, BRACKET_INDEX_MAX,
AnchoringSampleType,
)
from anchoring.base_table import get_base_rate_permille # 정적 테이블 조회 (§2)
def calc_bracket_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 1억 이상은 마지막 인덱스로 클램프.
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프.
정적 테이블 idx = 반환값 + 1"""
return min(target_price // PRICE_BRACKET_UNIT, BRACKET_INDEX_MAX)
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
@ -559,16 +576,20 @@ LIMIT 1
### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)
| target_price | bracket_index | 정적 테이블 idx | upper_bound |
| target_price | bracket_index | 정적 테이블 idx | 칸 |
|---|---|---|---|
| 0 | 0 | 1 | 3,000 |
| 2,999 | 0 | 1 | 3,000 |
| 3,000 | 1 (경계는 상위 구간) | 2 | 6,000 |
| 99,999,000 | 33,333 (마지막 구간 진입) | 33,334 | 100,000,000 |
| 100,000,000 | 33,333 | 33,334 | 100,000,000 |
| 150,000,000 | 33,333 (**1억 초과 → 마지막 인덱스 클램프**) | 33,334 | 100,000,000 |
| 0 | 0 | 1 | [0, 1,000) 통일 칸 |
| 999 | 0 | 1 | [0, 1,000) |
| 1,000 | 1 (경계는 상위 구간) | 2 | 1천 원대 |
| 9,999 | 9 | 10 | 9천 원대 |
| 10,000 | 10 | 11 | 1만 원대 |
| 30,000 | 12 | 13 | 3만 원대 |
| 150,000 | 19 | 20 | 10만 원대 |
| 99,999,999 | 45 (마지막 구간) | 46 | 9천만 원대 |
| 100,000,000 | 45 | 46 | 마지막 칸 |
| 150,000,000 | 45 (**1억 초과 → 마지막 인덱스 클램프**) | 46 | 마지막 칸 |
정적 테이블 검증: 33,334행 · idx 1..33334 연속 · `upper_bound == min(idx*3000, 100_000_000)` · 마지막 행만 100,000,000.
정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000.
### 11.3 누적 전량 평가 (유통 코드1, δ=20, rate_before=10)
@ -638,7 +659,7 @@ clamp·격리 케이스:
- **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
- **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
- **sessions 판정 입력 컬럼(`target_anchoring_price`, `anchor_rate_permille`)의 사후 수정, `last_offered_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `anchoring_adjustment_id` 단 하나.
- **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 구간 3,000원, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
- **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
- **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
- **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립.
- **float 산술** — 앵커링가·rate 계산에 부동소수점 사용 금지 (`round(target*0.99)` 패턴 금지).

View File

@ -47,7 +47,7 @@ NegoWiz는 여러 회사가 함께 쓰는 플랫폼이므로, 시스템은 협
| 기준 | 내용 |
|---|---|
| **회사** | 플랫폼을 쓰는 각 고객사. 회사끼리는 값도 협상 기록도 완전히 분리 |
| **가격구간** | 목표가를 3,000원 단위로 나눈 구간 (0원 ~ 1억 원, 총 33,334개). **1억 원을 넘는 목표가는 전부 마지막 구간(1억 원 구간)으로 편입** |
| **가격구간** | 목표가를 자릿수 단위 사다리로 나눈 구간 — 1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대 (0~1억 원, 총 46개). **1억 원을 넘는 목표가는 전부 마지막 구간으로 편입** |
| **협력사 유형** | 유통 / 총판 / 제조 |
즉 "A사의 유통 3만 원대"와 "B사의 유통 3만 원대"는 **서로 다른 칸**이고, 각자 자기만의 앵커링 값과 협상 기록을 가집니다. A사의 협상 결과가 B사의 값에 영향을 주는 일은 없습니다. 새 회사가 플랫폼에 들어오면 모든 칸이 기준표의 시작값(1%)에서 출발합니다.
@ -210,7 +210,7 @@ NegoWiz는 여러 회사가 함께 쓰는 플랫폼이므로, 시스템은 협
| **앵커링가(기준가)** | 목표가 × (1 − 앵커링 값), 소수점 버림. 협력사에게 표시하지 않는 내부 합의 기준선 |
| **기준표** | 가격구간별 시작값이 담긴 불변 표. 서비스에 내장되며 절대 변경되지 않음 |
| **칸** | 회사 × 가격구간 × 협력사 유형 조합. 값이 관리되는 최소 단위 |
| **가격구간** | 목표가를 3,000원 단위로 나눈 구간 (0~1억 원). 1억 원 초과는 마지막 구간으로 편입 |
| **가격구간** | 목표가를 자릿수 단위 사다리(1천 원대 … 9천만 원대, 46칸)로 나눈 구간 (0~1억 원). 1억 원 초과는 마지막 구간으로 편입 |
| **재협상** | 협력사 1곳과 1:1로 진행하는 협상. 이 시스템의 학습(표본 수집·값 조정) 대상 |
| **가격 흔적** | 협력사가 협상에서 가격을 한 번이라도 써낸 기록. 가격 흔적이 있는 협상만 채점 대상 |
| **성공** | 정상 종료 협상에서 협력사 투찰가 ≤ 기준가(앵커링가) |

View File

@ -94,7 +94,7 @@ PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 배치 즉시 1회
기동 직후 로그에 아래 3줄이 순서대로 보이면 정상입니다:
```
[main] 정적 기본 테이블 로드·검증 완료 (33,334칸)
[main] 정적 기본 테이블 로드·검증 완료 (46칸 사다리)
[scheduler] 등록 — 매주 토 00:00 Asia/Seoul (격주 게이트는 잡 내부)
[main] 스케줄러 상주 시작 — 다음 실행 예정: 2026-07-04 00:00:00+09:00
```

View File

@ -20,7 +20,7 @@
| **조정 장부** (`anchoring.rate_adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
| **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 |
여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대(3천 원 단위, 0원~1억, 총 33,334개)** 조합입니다. 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다.
여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대** 조합입니다. 가격대는 자릿수 단위 사다리(1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대, 총 46칸)로 나뉘고, 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다.
---
@ -28,7 +28,7 @@
### ① 서버가 켜질 때
기준표(33,334개 가격구간 × 시작값 1%)를 메모리에 올리고, 표가 손상됐으면 아예 서버를 켜지 않습니다. 잘못된 가격으로 협상하는 것보다 안 켜지는 게 낫다는 안전장치입니다.
기준표(46개 가격구간 × 시작값 1%)를 메모리에 올리고, 표가 손상됐으면 아예 서버를 켜지 않습니다. 잘못된 가격으로 협상하는 것보다 안 켜지는 게 낫다는 안전장치입니다.
### ② 견적(협상 건)이 만들어질 때 — "기준선을 정한다"

View File

@ -47,7 +47,7 @@ else:
# ① 칸 해석
# company_id = items.company_id (해당 상품의 소유 회사)
# supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3)
# bracket = min(tp // 3000, 33333)
# bracket = calc_bracket_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식
# ② rate 조회 — 전달받은 reader 모듈 사용
rate = await get_anchor_rate(company_id, supplier_type, bracket)
# 내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰)

View File

@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..33333 (앱 보장)
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)

View File

@ -6,7 +6,7 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증
import json
from pathlib import Path
from anchoring.constants import BRACKET_COUNT, PRICE_BRACKET_UNIT, PRICE_MAX
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
@ -20,7 +20,7 @@ class BaseTableError(RuntimeError):
def _validate(rows: list) -> list[int]:
"""행 검증 후 천분율 정수 리스트로 변환. 실패 시 BaseTableError.
규약(§2.1): 33,334행 · idx 1..33334 연속 · upper_bound == min(idx*3000, 1억) · 값 0.01~0.20.
규약(§2.1): 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20.
"""
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT:
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}")
@ -31,8 +31,8 @@ def _validate(rows: list) -> list[int]:
av = row.get("anchoring_value")
if idx != i + 1:
raise BaseTableError(f"idx 불연속: 위치 {i} 의 idx={idx} (기대 {i + 1})")
if ub != min(idx * PRICE_BRACKET_UNIT, PRICE_MAX):
raise BaseTableError(f"upper_bound 불일치: idx={idx} upper_bound={ub}")
if ub != UPPER_BOUNDS[i]:
raise BaseTableError(f"upper_bound 사다리 불일치: idx={idx} upper_bound={ub} (기대 {UPPER_BOUNDS[i]})")
if not isinstance(av, (int, float)) or av != av or not (0.01 <= av <= 0.20):
raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}")
rates.append(int(round(av * 1000)))

View File

@ -21,11 +21,23 @@ DELTA_PERMILLE = {
SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면 스킵·이월)
# ── 가격구간 ──────────────────────────────────────────────
PRICE_BRACKET_UNIT = 3_000 # 가격구간 폭 (원)
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
BRACKET_INDEX_MAX = 33_333 # 0-기반 구간 인덱스 상한 (총 33,334칸)
BRACKET_COUNT = 33_334
# ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ──
# 예: 1,000~1만 은 1,000원 폭(1천 원대·2천 원대…), 1만~10만 은 1만 폭(1만 원대·2만 원대…).
# 최하단(0~1,000원)은 한 칸으로 통일. 1억 초과는 마지막 칸으로 클램프.
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억)
_DECADE_STARTS = (1_000, 10_000, 100_000, 1_000_000, 10_000_000)
def _build_upper_bounds() -> tuple:
bounds = [1_000] # idx 0: [0, 1,000) 통일 칸
for start in _DECADE_STARTS: # 각 자릿수: 폭 = start (상한의 10%)
bounds.extend(start + start * i for i in range(1, 10))
return tuple(bounds) # 마지막 = 100,000,000
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
BRACKET_COUNT = len(UPPER_BOUNDS) # 46
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45
# ── 배치 ──────────────────────────────────────────────────
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에

View File

@ -13,6 +13,7 @@ import sys
from anchoring.base_table import load_base_table
from anchoring.batch import run_evaluation_batch
from anchoring.config import load_config
from anchoring.constants import BRACKET_COUNT
from anchoring.db import dispose_engine, init_engine
from anchoring.log import LOG, configure
from anchoring.redis_client import close_redis, init_redis
@ -24,7 +25,7 @@ async def _run(once: bool) -> None:
configure(cfg.log_level)
load_base_table() # 검증 실패 시 BaseTableError → 기동 중단
LOG.info("[main] 정적 기본 테이블 로드·검증 완료 (33,334칸)")
LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({BRACKET_COUNT}칸 사다리)")
init_engine(cfg)
init_redis(cfg.redis)

View File

@ -18,7 +18,7 @@ class RateAdjustment(BASE):
id = Column(BigInteger, primary_key=True, autoincrement=True)
company_id = Column(UUID(as_uuid=True), nullable=False)
supplier_type = Column(SmallInteger, nullable=False) # 1유통/2제조/3총판
price_bracket_index = Column(Integer, nullable=False) # 0..33333
price_bracket_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리)
nego_count = Column(Integer, nullable=False) # 유효 표본 수 n
success_count = Column(Integer, nullable=False)
anchor_rate_before = Column(SmallInteger, nullable=False) # ‰

File diff suppressed because it is too large Load Diff

View File

@ -2,22 +2,26 @@
모든 산술은 정수(천분율 ‰). float 금지(§12) — 성공률 비교도 정수 비교로 수행한다.
"""
from bisect import bisect_right
from anchoring.base_table import get_base_rate_permille
from anchoring.constants import (
ANCHOR_RATE_MAX,
ANCHOR_RATE_MIN,
BRACKET_INDEX_MAX,
DELTA_PERMILLE,
PRICE_BRACKET_UNIT,
SAMPLE_THRESHOLD,
UPPER_BOUNDS,
AnchoringSampleType,
)
def calc_bracket_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 1억 이상은 마지막 인덱스로 클램프.
정적 테이블 idx = 반환값 + 1"""
return min(target_price // PRICE_BRACKET_UNIT, BRACKET_INDEX_MAX)
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸.
1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
def calc_anchor_price(target_price: int, rate_permille: int) -> int:

View File

@ -15,8 +15,8 @@ from conftest import requires_db
pytestmark = requires_db
# 시드 기본값: target 30,000 / rate 10‰ / anchor 29,700 → bracket = 30000//3000 = 10
BRACKET = 10
# 시드 기본값: target 30,000 / rate 10‰ / anchor 29,700 → bracket 12 ("3만 원대" 칸)
BRACKET = 12
SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS
FAIL_BID = 29_999 # > anchor → BID_FAIL

View File

@ -5,7 +5,7 @@
import pytest
from anchoring.base_table import BaseTableError, _validate, get_base_rate_permille, load_base_table
from anchoring.constants import BRACKET_COUNT, AnchoringSampleType
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS, AnchoringSampleType
from anchoring.service import (
calc_anchor_price,
calc_bracket_index,
@ -27,27 +27,41 @@ def test_calc_anchor_price_floor():
assert calc_anchor_price(0, 10) == 0
# ── §11.2 구간 인덱스 (상한 클램프 포함) ──────────────────
# ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ──
def test_bracket_index():
assert calc_bracket_index(0) == 0
assert calc_bracket_index(2_999) == 0
assert calc_bracket_index(3_000) == 1 # 경계는 상위 구간
assert calc_bracket_index(99_999_000) == 33_333 # 마지막 구간 진입
assert calc_bracket_index(100_000_000) == 33_333
assert calc_bracket_index(150_000_000) == 33_333 # 1억 초과 → 마지막 인덱스 클램프
assert calc_bracket_index(0) == 0 # [0, 1,000) 통일 칸
assert calc_bracket_index(999) == 0
assert calc_bracket_index(1_000) == 1 # 경계는 상위 구간
assert calc_bracket_index(1_999) == 1 # 1천 원대
assert calc_bracket_index(9_999) == 9 # 9천 원대
assert calc_bracket_index(10_000) == 10 # 1만 원대 진입
assert calc_bracket_index(30_000) == 12 # 3만 원대
assert calc_bracket_index(99_999) == 18 # 9만 원대
assert calc_bracket_index(150_000) == 19 # 10만 원대
assert calc_bracket_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입
assert calc_bracket_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸
assert calc_bracket_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프
def test_ladder_shape():
"""사다리 자체 검증: 1 + 자릿수(5)×9 = 46칸, 단조 증가, 마지막 1억."""
assert BRACKET_COUNT == 46
assert UPPER_BOUNDS[0] == 1_000 and UPPER_BOUNDS[-1] == 100_000_000
assert list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS))
assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계
# ── §2 정적 테이블 로드·검증 ─────────────────────────────
def test_base_table_load_and_values():
load_base_table()
assert get_base_rate_permille(0) == 10
assert get_base_rate_permille(33_333) == 10
assert get_base_rate_permille(45) == 10
def _rows(n: int = BRACKET_COUNT):
def _rows():
return [
{"idx": k, "upper_bound": min(k * 3000, 100_000_000), "anchoring_value": 0.01}
for k in range(1, n + 1)
{"idx": i + 1, "upper_bound": ub, "anchoring_value": 0.01}
for i, ub in enumerate(UPPER_BOUNDS)
]
@ -64,7 +78,7 @@ def test_base_table_validate_rejects_bad():
with pytest.raises(BaseTableError):
_validate(rows)
rows = _rows()
rows[-1]["upper_bound"] = 100_002_000 # 마지막 행은 정확히 1억이어야 함
rows[-1]["upper_bound"] = 100_002_000 # 사다리 불일치(마지막은 정확히 1억)
with pytest.raises(BaseTableError):
_validate(rows)
rows = _rows()