Skip to content

Commit ed94cfd

Browse files
authored
Merge pull request #14 from hypurrquant/qa/2026-05-06-numeric-validation-audit
qa(audit): numeric-validation — 18 Rule #2 fixes across 5 modules
2 parents 1d0e961 + dcae870 commit ed94cfd

8 files changed

Lines changed: 551 additions & 19 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
# QA Report — Numeric Validation Audit
2+
3+
> 본 보고서는 `docs/QA_WORKFLOW.md` Section 11 의 한국어 구조화 포맷에
4+
> 따라 작성된다. 이전 사이클 (`qa/2026-05-05-v0.13.0-validation`) 의
5+
> production 결함 3건 (NaN silent-pass / NaN propagation / silent empty
6+
> book) 이 같은 audit pass 에서 발견된 것을 확인하고, **사용자 가설
7+
> "빙산의 일각"** 을 다른 모듈 전반에 검증한 follow-up.
8+
9+
## QA 결과 요약
10+
11+
- **브랜치:** `qa/2026-05-06-numeric-validation-audit` (origin push 완료)
12+
- **베이스 커밋:** `1d0e961``Merge pull request #13 from hypurrquant/qa/2026-05-05-v0.13.0-validation`
13+
- **베이스 버전:** `0.13.0`
14+
- **추가 커밋 수:** 3개
15+
16+
| # | 해시 | 제목 |
17+
|---|------|------|
18+
| 1 | `f381c0b` | `fix(validator): reject non-finite numeric inputs (Rule #2 audit)` |
19+
| 2 | `26d78d7` | `fix(lighter): reject non-finite numeric venue payloads (Rule #2 audit)` |
20+
| 3 | `ed533cb` | `fix(numeric-audit): observability + rebalance + outcome-time NaN guards` |
21+
22+
PR URL 후보:
23+
`https://github.com/hypurrquant/perp-cli/pull/new/qa/2026-05-06-numeric-validation-audit`
24+
25+
## 가설 검증 결과
26+
27+
이전 사이클 사용자 review 의 핵심 가설:
28+
29+
> "production 버그 2건 = 빙산의 일각일 가능성. 같은 treatment 안 받은
30+
> 다른 모듈에도 같은 패턴의 버그가 있을 가능성이 높음."
31+
32+
이번 audit 가 **18개의 추가 사이트** 에서 동일 패턴의 NaN silent-pass /
33+
NaN propagation 결함을 확인. 가설 강하게 입증됨.
34+
35+
| 모듈 | 결함 사이트 | Fix commit |
36+
|------|----------|-----------|
37+
| `trade-validator.ts` | 5 (markPrice / balance / orderbook px·sz / posSize / fundingRate) | `f381c0b` |
38+
| `exchanges/lighter.ts` | 9 (balance + position aggregation 9곳) | `26d78d7` |
39+
| `event-stream.ts` | 2 (liquidation distance / balance delta) | `ed533cb` |
40+
| `rebalance.ts` | 1 (planner input) | `ed533cb` |
41+
| `exchanges/hyperliquid-outcome.ts:439` | 1 (l2Book time, 이전 사이클 leftover) | `ed533cb` |
42+
43+
**이전 사이클 (3건) + 이번 사이클 (18건) = 합계 21건** 의 같은 클래스
44+
Rule #2 위반이 **2 audit pass** 에서 발견됨.
45+
46+
## 환경 / Pre-flight (Section 4)
47+
48+
- **호스트:** macOS (Darwin 25.4.0), pnpm 10, Node 20·22·24 매트릭스
49+
- **컨테이너:** `perp-qa` Docker, `~/.ows`·`~/.perp` 마운트
50+
- working tree clean, base `1d0e961` = origin/main HEAD
51+
- mainnet 거래 실행 0건 (audit 자체가 readonly + 단위 테스트 영역)
52+
53+
## 실행 내역
54+
55+
### 적용된 audit 패턴
56+
57+
이전 사이클의 `_computeUnderlying` / `_computeMidSum` /
58+
`_assertOutcomeRange` 패턴 follow-through:
59+
60+
1. **Helper 추출 + 단위 테스트** (가능한 곳) — 같은 가드 로직을 한 곳에
61+
두고 테스트로 freeze.
62+
2. **Inline 가드 + 명시 throw** (helper 추출이 어려운 곳, e.g. event
63+
stream 에서 venue 응답이 일회용으로 해석됨).
64+
3. **에러 메시지에 field name + 원본 value** 포함 — triage 시 어느 venue
65+
endpoint 의 어떤 field 가 깨졌는지 즉시 식별.
66+
67+
### 모듈별 변경
68+
69+
#### `trade-validator.ts` (`f381c0b`, 5 sites + 6 tests)
70+
71+
| Site | 이전 동작 | 변경 후 |
72+
|------|---------|---------|
73+
| `markPrice` | `<= 0` 검사가 NaN 통과시킴 | `Number.isNaN` / Infinity pre-check throw |
74+
| `balance.available` | NaN 면 "insufficient" branch 거짓 진입 + `$NaN available` 노출 | throw EXCHANGE_ERROR |
75+
| 주문북 level `px`·`sz` | NaN 누적 시 `availableLiquidity` NaN, `>= notional` 항상 false | level 단위 finite + positive 검사 throw |
76+
| reduce-only `pos.size` | NaN 면 size 비교 거짓 통과 | parseFloat 후 finite 검사 throw |
77+
| `marketInfo.fundingRate` (output) | envelope 에 `NaN` JSON 노출 | 0 substitute + warning push (output sanitization) |
78+
79+
#### `exchanges/lighter.ts` (`26d78d7`, helper + 9 sites + 9 tests)
80+
81+
`LighterAdapter._toFiniteNumber(value, fieldName, defaultValue=0)` 추출:
82+
- undefined / null → defaultValue (venue 가 빈 계정 / position 에서 omit 가능)
83+
- finite number / parseable string → 그대로
84+
- NaN / ±Infinity / 비숫자 string → throw EXCHANGE_ERROR (field name 포함)
85+
86+
Sites:
87+
- `total_asset_value`, `available_balance`, `collateral`, `position.unrealized_pnl` (balance)
88+
- `position`, `posSize`, `position_value` (positions, markPrice/leverage 계산)
89+
90+
#### `event-stream.ts` (`ed533cb`, 2 sites)
91+
92+
- liquidation distance (`mark`/`liq` NaN → critical alert silent skip)
93+
- balance delta (NaN → balance_update emit 누락)
94+
95+
둘 다 `console.warn` + 분기 가드로 변경. observability 영역이라
96+
throw 가 stream 흐름 깨므로 silent → noisy 로 격상 (warn 로그가 사용자
97+
디버깅 단서).
98+
99+
#### `rebalance.ts` (`ed533cb`, 1 site)
100+
101+
`Number(bal.equity)` 등 NaN propagation → `Promise.allSettled` 의 reject
102+
경로 유도 (어댑터 단위로 plan 에서 제외, 다른 어댑터 영향 없음).
103+
104+
#### `exchanges/hyperliquid-outcome.ts:439` (`ed533cb`, 1 site)
105+
106+
이전 사이클 v0.13.0 의 `getOrderbook` 가드 강화에서 leftover. 이제
107+
`Number(book.time ?? 0)` 도 nullish 와 NaN 분리:
108+
- nullish → 0 (legit "time 정보 없음")
109+
- non-finite → throw (corrupt)
110+
111+
## 테스트 결과
112+
113+
- **passed: 1396 / failed: 0 / added: 15** (host + container cross-validate)
114+
- **이전 (베이스 = main HEAD `1d0e961`):** 1381 / 74 files
115+
- **이후 (QA 브랜치 HEAD = `ed533cb`):** 1396 / 75 files
116+
- **신규 test files (1):** `exchanges/lighter-toFinite.test.ts`
117+
- **확장된 test files (1):** `trade-validator.test.ts` (38 → 44)
118+
- **새 helper 단위 테스트:** 9 case (`_toFiniteNumber`)
119+
- **NaN edge case 테스트 추가:** trade-validator 의 5 production fix 마다 1 case
120+
121+
## 변경된 공개 인터페이스
122+
123+
- **CLI / JSON envelope: 변경 없음.** 정상 동작 시 출력 동일.
124+
- **에러 행동 변경:** NaN/Infinity venue 응답 시 이제 명시 EXCHANGE_ERROR.
125+
- 이전: silent 0 substitution → "$0 balance" / "Insufficient liquidity: $NaN" / "no alert" 같은 false-positive 분기
126+
- 이후: throw with field name + value (envelope `error` 필드로 surface)
127+
- **Internal 신규 helpers (모두 underscore prefix, 기존 컨벤션):**
128+
- `LighterAdapter._toFiniteNumber` (public static, 단위 테스트 가능)
129+
130+
## 테스트 작성 중 발견된 production 결함
131+
132+
이전 사이클은 helper-extract pattern 으로 3건 발견. 이번 audit pass 는
133+
같은 pattern 으로 **18건의 추가 결함**. 모두 NaN propagation
134+
(silent-pass, false-positive 분기, envelope NaN 노출) 의 동일 클래스.
135+
136+
| 분류 | 건수 | 영향 |
137+
|------|------|------|
138+
| 자금 계산 (balance / position / margin) | 13 | "$0" 으로 corrupt 가림, false 충분 잔고 판정, plan 입력 NaN |
139+
| 시세 / 주문북 (markPrice / level px·sz / time) | 4 | NaN 비교 false → silent skip |
140+
| Output sanitization (envelope output) | 1 | agent JSON 파싱 깨짐 |
141+
142+
이전 사이클의 가설 — "같은 treatment 안 받은 모듈에 같은 패턴 더 있음"
143+
— 이 audit 가 18건으로 **6배 강화**된 형태로 검증.
144+
145+
## 사람 검토 필요 항목
146+
147+
1. **`Number.isNaN` vs `!Number.isFinite` 사용**`markPrice` 가드는
148+
`Number.isNaN(x) || x === Infinity || x === -Infinity` 명시. 다른
149+
사이트는 `!Number.isFinite(x)`. 동등하지만 가독성 차이. 통일
150+
권장 (`!Number.isFinite` 가 더 짧음).
151+
2. **mcp-server.ts 의 advisor numeric output sanitization** — 이번
152+
사이클 범위에서 분리. portfolio aggregation 의 `Number(snap.balance.equity)`
153+
가드 부재 영역 follow-up. envelope 영향 작지만 lighter.ts cascade
154+
덕에 부분 보호. 별도 micro-PR 가치.
155+
3. **다른 어댑터 (`pacifica.ts` / `hyperliquid.ts` / `aster.ts`) 의
156+
동일 audit**`cross-adapter-matrix` 사이클 (P1) 의 일부로
157+
처리 권장. lighter.ts 가 가장 많은 silent fallback 을 가졌지만
158+
다른 어댑터도 부분 패턴 가능.
159+
4. **event-stream / rebalance 의 단위 테스트 부재** — stream/aggregation
160+
shape 라 단위 테스트 비용 큼. integration test 인프라 (mock signer
161+
matrix 사이클) 와 함께 생기면 추가 가치.
162+
163+
## 다음 권장 액션
164+
165+
### 즉시 (이번 PR)
166+
167+
- [ ] **PR 생성**`qa/2026-05-06-numeric-validation-audit``main`.
168+
3 commits, +18 production fixes, +15 tests.
169+
170+
### Follow-up micro-PRs
171+
172+
- [ ] **mcp-server.ts numeric output sanitization** — portfolio aggregation
173+
`Number(...)` 사이트들 (`extractNumber || "<size>"` 패턴 + balance
174+
reduce). envelope NaN 노출 차단.
175+
- [ ] **`pacifica.ts` / `hyperliquid.ts` / `aster.ts` 동일 audit**
176+
이번 사이클의 cross-adapter follow-up. cross-adapter-matrix 와
177+
병합 가능.
178+
- [ ] **`Number.isFinite` 통일** — 사람 검토 #1 의 가독성 통일.
179+
- [ ] **`@vitest/coverage-v8` dep 추가 (사용자 승인 필요)** — 정량
180+
coverage. 이번 audit 가 18건 추가했는데 0% → 미커버 모듈 식별.
181+
- [ ] **`fast-check` property test (사용자 승인 필요)**`--json` numeric
182+
필드 finite 강제. 1000+ 자동 case 로 audit 패턴 영구화.
183+
184+
### 다른 사이클로 이미 분리된 항목 (이전 보고서 plan 그대로)
185+
186+
- `qa/2026-05-XX-aster-signer-regression` (P0 격상)
187+
- `commander program-builder factory` (P1)
188+
- `qa/2026-05-XX-cross-adapter-matrix` (P1, mcp-server / 다른 어댑터 audit 묶기 가능)
189+
- `qa/2026-05-XX-failure-modes` (P2)
190+
191+
## Section 3 / Section 13 — 절대 금지 항목 준수
192+
193+
| 항목 | 수행 여부 |
194+
|------|----------|
195+
| `main` 머지 / push ||
196+
| `npm publish` ||
197+
| `git tag` ||
198+
| GitHub Release 생성 ||
199+
| mainnet 실거래 ||
200+
| 의존성 메이저 업데이트 ||
201+
| 새 npm 패키지 추가 ||
202+
203+
## 부록 A — 가설 강화 데이터
204+
205+
| 사이클 | audit 통과 모듈 | 발견 production 결함 |
206+
|--------|--------------|------------------|
207+
| v0.13.0 (`qa/2026-05-05`) | `hyperliquid-outcome.ts` (helper 5개) | 3 |
208+
| Numeric audit (`qa/2026-05-06`, 본 보고서) | `trade-validator` / `lighter` / `event-stream` / `rebalance` / `outcome-time` | 18 |
209+
| **합계** | 5 영역 | **21** |
210+
211+
이 시점에서 **다음 사이클이 같은 패턴으로 더 발견할 결함의 lower bound**
212+
는 0 이 아님. 추가 audit 사이클의 가성비는 여전히 높음 — 본 보고서의
213+
follow-up plan 이 그 우선순위 정렬.
214+
215+
## 부록 B — audit 검색 명령
216+
217+
다음 사이클이 동일 패턴 적용 시 사용:
218+
219+
```bash
220+
# 1. Number()/parseFloat()/parseInt() 후 isFinite 가드 부재 사이트
221+
grep -rnE '(Number\(|parseFloat\(|parseInt\()' src --include='*.ts' \
222+
--exclude-dir=__tests__ --exclude-dir=dist \
223+
| grep -vE '(Number\.is(Finite|Integer)|\.\s*toString)'
224+
225+
# 2. `|| 0` 또는 `?? 0` 같은 numeric default fallback
226+
grep -rnE '\?\? \d|\|\| \d' src/exchanges src/strategies src/arb \
227+
--include='*.ts'
228+
```
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from "vitest";
2+
import { LighterAdapter } from "../../exchanges/lighter.js";
3+
import { PerpError } from "../../errors.js";
4+
5+
/**
6+
* Unit tests for `LighterAdapter._toFiniteNumber` — the venue-payload
7+
* coercion helper used by getBalance / getPositions to reject NaN and
8+
* Infinity instead of silently substituting 0.
9+
*
10+
* Same Rule #2 spirit as the v0.13.0 cycle's `_computeMidSum` /
11+
* `_assertOutcomeRange` helpers: nullish input is allowed (venue may
12+
* legitimately omit a field for an empty account), but corrupted
13+
* values must throw rather than masquerade as zero balance.
14+
*/
15+
16+
describe("LighterAdapter._toFiniteNumber — Rule #2 venue-payload coercion", () => {
17+
it("returns finite numbers unchanged", () => {
18+
expect(LighterAdapter._toFiniteNumber(0, "x")).toBe(0);
19+
expect(LighterAdapter._toFiniteNumber(1234.56, "x")).toBe(1234.56);
20+
expect(LighterAdapter._toFiniteNumber(-50, "x")).toBe(-50);
21+
});
22+
23+
it("parses numeric strings into finite numbers", () => {
24+
expect(LighterAdapter._toFiniteNumber("0", "x")).toBe(0);
25+
expect(LighterAdapter._toFiniteNumber("123.45", "x")).toBe(123.45);
26+
expect(LighterAdapter._toFiniteNumber("-1.5", "x")).toBe(-1.5);
27+
});
28+
29+
it("returns the default value (0) for undefined / null — venue may omit a field", () => {
30+
expect(LighterAdapter._toFiniteNumber(undefined, "x")).toBe(0);
31+
expect(LighterAdapter._toFiniteNumber(null, "x")).toBe(0);
32+
});
33+
34+
it("respects a custom default value", () => {
35+
expect(LighterAdapter._toFiniteNumber(undefined, "x", 1)).toBe(1);
36+
expect(LighterAdapter._toFiniteNumber(null, "x", -42)).toBe(-42);
37+
});
38+
39+
it("throws EXCHANGE_ERROR for NaN — silent zero substitution would mask broken accounting", () => {
40+
expect(() => LighterAdapter._toFiniteNumber(NaN, "total_asset_value")).toThrow(PerpError);
41+
try {
42+
LighterAdapter._toFiniteNumber(NaN, "total_asset_value");
43+
expect.fail("expected throw");
44+
} catch (e) {
45+
const err = e as PerpError;
46+
expect(err.structured.code).toBe("EXCHANGE_ERROR");
47+
expect(err.message).toMatch(/`total_asset_value` is not a finite number/);
48+
// Exchange tag is nested under `details` per PerpError constructor
49+
// (third-arg `details` are stripped of `remediation` and bagged into
50+
// `structured.details`). classifyError later promotes it to top-level.
51+
expect((err.structured as { details?: { exchange?: string } }).details?.exchange).toBe("lighter");
52+
}
53+
});
54+
55+
it("throws EXCHANGE_ERROR for ±Infinity", () => {
56+
expect(() => LighterAdapter._toFiniteNumber(Infinity, "x")).toThrow(/not a finite number/);
57+
expect(() => LighterAdapter._toFiniteNumber(-Infinity, "x")).toThrow(/not a finite number/);
58+
});
59+
60+
it("throws EXCHANGE_ERROR for non-numeric strings (Number(s) → NaN)", () => {
61+
expect(() => LighterAdapter._toFiniteNumber("abc", "x")).toThrow(/not a finite number/);
62+
expect(() => LighterAdapter._toFiniteNumber("12abc", "x")).toThrow(/not a finite number/);
63+
// empty string → Number("") === 0, allowed (venue may stringify zero this way)
64+
expect(LighterAdapter._toFiniteNumber("", "x")).toBe(0);
65+
});
66+
67+
it("includes the field name in the error so the failing endpoint is attributable", () => {
68+
expect(() => LighterAdapter._toFiniteNumber("xyz", "available_balance")).toThrow(/`available_balance`/);
69+
expect(() => LighterAdapter._toFiniteNumber(NaN, "position.unrealized_pnl")).toThrow(/`position\.unrealized_pnl`/);
70+
});
71+
72+
it("includes the original (stringified) value in the error message for triage", () => {
73+
expect(() => LighterAdapter._toFiniteNumber("garbled", "x")).toThrow(/"garbled"/);
74+
});
75+
});

src/__tests__/trade-validator.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,93 @@ describe("validateTrade — overall validity", () => {
722722
expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp);
723723
});
724724
});
725+
726+
// ──────────────────────────────────────────────
727+
// Rule #2 numeric guards — NaN propagation rejection
728+
// ──────────────────────────────────────────────
729+
730+
describe("validateTrade — Rule #2 numeric guards (NaN propagation rejection)", () => {
731+
// These tests document the gap previous helper-extract audits did not
732+
// cover at the validator boundary: NaN values from a malformed venue
733+
// payload would silently slip past comparison-based checks because all
734+
// NaN comparisons are false. The validator must reject them explicitly
735+
// so the caller doesn't see a false-positive "insufficient liquidity"
736+
// or "$NaN available" message.
737+
738+
it("throws EXCHANGE_ERROR when getMarkets returns non-finite markPrice", async () => {
739+
const adapter = mockAdapter({
740+
getMarkets: vi.fn().mockResolvedValue([
741+
{
742+
symbol: "BTC-PERP", markPrice: "not-a-number", indexPrice: "60000",
743+
fundingRate: "0.0001", volume24h: "1000000", openInterest: "500000", maxLeverage: 20,
744+
},
745+
]),
746+
});
747+
await expect(
748+
validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams),
749+
).rejects.toThrow(/non-finite markPrice/);
750+
});
751+
752+
it("throws EXCHANGE_ERROR when balance.available is non-finite", async () => {
753+
const adapter = mockAdapter({
754+
getBalance: vi.fn().mockResolvedValue({
755+
equity: "10000", available: undefined, marginUsed: "2000", unrealizedPnl: "0",
756+
}),
757+
});
758+
await expect(
759+
validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams),
760+
).rejects.toThrow(/non-finite balance\.available/);
761+
});
762+
763+
it("throws EXCHANGE_ERROR when an orderbook level has non-finite price", async () => {
764+
const adapter = mockAdapter({
765+
getOrderbook: vi.fn().mockResolvedValue({
766+
bids: [["59990", "1"]],
767+
asks: [["abc", "1"], ["60020", "2"]],
768+
}),
769+
});
770+
await expect(
771+
validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams),
772+
).rejects.toThrow(/orderbook level/);
773+
});
774+
775+
it("throws EXCHANGE_ERROR when an orderbook level has zero price", async () => {
776+
const adapter = mockAdapter({
777+
getOrderbook: vi.fn().mockResolvedValue({
778+
bids: [["59990", "1"]],
779+
asks: [["0", "1"]],
780+
}),
781+
});
782+
await expect(
783+
validateTrade(adapter, { symbol: "BTC", side: "buy", size: 0.1 } as TradeCheckParams),
784+
).rejects.toThrow(/non-finite or non-positive/);
785+
});
786+
787+
it("throws EXCHANGE_ERROR when reduce-only position size is non-finite", async () => {
788+
const adapter = mockAdapter({
789+
getPositions: vi.fn().mockResolvedValue([
790+
{ symbol: "BTC-PERP", side: "long", size: "garbled", markPrice: "60000", entryPrice: "60000", unrealizedPnl: "0", margin: "100" },
791+
]),
792+
});
793+
await expect(
794+
validateTrade(adapter, { symbol: "BTC", side: "sell", size: 0.5, reduceOnly: true } as TradeCheckParams),
795+
).rejects.toThrow(/non-finite position size/);
796+
});
797+
798+
it("substitutes 0 + emits warning when funding rate is non-finite (output sanitization)", async () => {
799+
const adapter = mockAdapter({
800+
getMarkets: vi.fn().mockResolvedValue([
801+
{
802+
symbol: "BTC-PERP", markPrice: "60000", indexPrice: "60000",
803+
fundingRate: "abc", volume24h: "1000000", openInterest: "500000", maxLeverage: 20,
804+
},
805+
]),
806+
});
807+
const result = await validateTrade(adapter, {
808+
symbol: "BTC", side: "buy", size: 0.1,
809+
} as TradeCheckParams);
810+
// Envelope must NOT carry NaN — agents JSON.parsing the output would break.
811+
expect(result.marketInfo?.fundingRate).toBe(0);
812+
expect(result.warnings.some((w) => w.includes("Funding rate unavailable"))).toBe(true);
813+
});
814+
});

0 commit comments

Comments
 (0)