feat(reconcile): safely seed canonical cost basis

This commit is contained in:
Hermes Agent
2026-07-17 09:57:08 +09:00
parent 18bd8d9f0b
commit 81a853a314
25 changed files with 1689 additions and 66 deletions
+226
View File
@@ -113,6 +113,232 @@ def test_get_portfolios_network_error_raises_unavailable():
adapter.get_portfolios("d-7")
# ---------------------------------------------------------------------------
# REQ-123: cost-basis parsing (canonical + diagnostics)
# ---------------------------------------------------------------------------
def test_get_cost_basis_with_diagnostics_prefers_canonical_when_valid():
"""REQ-123 AC-1: cost_basis_per_unit가 있으면 우선 사용."""
def handler(req: httpx.Request) -> httpx.Response:
assert req.url.path.endswith("/holdings/cost-basis")
return httpx.Response(200, json={
"cost_basis_per_unit": "125000.5",
"avg_cost": 1000,
"wac": 1200,
"cost_basis": 1300,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value == 125000.5
assert source == "canonical"
assert reason is None
def test_get_cost_basis_with_diagnostics_canonical_invalid_blocks_legacy():
"""REQ-123 AC-1: canonical 존재 시 invalid면 legacy fallback 없이 실패."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"cost_basis_per_unit": 0,
"avg_cost": 1000,
"wac": 900,
"cost_basis": 1300,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "invalid"
def test_get_cost_basis_with_diagnostics_canonical_null_blocks_legacy():
"""REQ-123 QC-5: canonical null은 legacy 값이 있어도 fallback하지 않는다."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"cost_basis_per_unit": None,
"avg_cost": 1000,
"wac": 900,
"cost_basis": 1300,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "missing"
def test_get_cost_basis_with_diagnostics_canonical_non_numeric_blocks_legacy():
"""REQ-123 QC-5: canonical 비숫자 문자열은 legacy 값이 있어도 fallback하지 않는다."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"cost_basis_per_unit": "not-a-number",
"avg_cost": 1000,
"wac": 900,
"cost_basis": 1300,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "invalid"
def test_get_cost_basis_with_diagnostics_canonical_negative_blocks_legacy():
"""REQ-123 QC-5: canonical 음수는 legacy 값이 있어도 fallback하지 않는다."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"cost_basis_per_unit": -1,
"avg_cost": 1000,
"wac": 900,
"cost_basis": 1300,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "invalid"
def test_get_cost_basis_with_diagnostics_legacy_chain_fallback_order():
"""REQ-123 AC-2: canonical 미존재 시 avg_cost → wac → cost_basis."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"wac": 7000,
"avg_cost": "8200",
"cost_basis": 9000,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value == 8200.0
assert source == "legacy"
assert reason is None
def test_get_cost_basis_with_diagnostics_legacy_skips_invalid_values():
"""REQ-123 AC-2: legacy 중 invalid면 다음 legacy 키로 이동."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"avg_cost": "-1", # invalid
"wac": "not-a-number", # invalid
"cost_basis": 3000,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value == 3000.0
assert source == "legacy"
assert reason is None
def test_get_cost_basis_with_diagnostics_all_legacy_invalid_returns_last_reason():
"""REQ-123 AC-3: legacy 체인에서 모두 invalid/비정상이면 reason 반환."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={
"avg_cost": 0,
"wac": "-100",
"cost_basis": None,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "missing"
def test_get_cost_basis_with_diagnostics_legacy_invalid_is_sanitized():
"""REQ-123 QC-2: legacy parser detail never leaks through diagnostics."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"avg_cost": 0})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "invalid"
def test_get_cost_basis_with_diagnostics_missing_field_returns_missing_reason():
"""REQ-123 AC-4: cost-basis 필드가 모두 없으면 missing reason."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"portfolio_id": "pf-1"})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "missing"
def test_get_cost_basis_with_diagnostics_non_2xx_or_transport_keeps_diagnostic():
"""REQ-123 AC-4: non-2xx/transport 오류는 reason 코드로 반영."""
def transport_handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(503, text="down")
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(transport_handler))
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert value is None
assert source == "none"
assert reason == "http_status_503"
def error_handler(req: httpx.Request):
raise httpx.ConnectError("network down")
adapter2 = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(error_handler))
value2, source2, reason2 = adapter2.get_cost_basis_with_diagnostics("pf-1")
assert value2 is None
assert source2 == "none"
assert reason2 == "transport"
def test_get_cost_basis_with_diagnostics_invalid_json_and_payload_are_sanitized():
"""REQ-123 QC-2: malformed responses expose only stable reason codes."""
def invalid_json_handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=b"not-json", headers={"content-type": "application/json"})
adapter = PortfolioHubReadAdapter(
"https://pf.test", token="tok", client=_client(invalid_json_handler)
)
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert (value, source, reason) == (None, "none", "invalid_json")
def invalid_payload_handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=["not", "an", "object"])
adapter = PortfolioHubReadAdapter(
"https://pf.test", token="tok", client=_client(invalid_payload_handler)
)
value, source, reason = adapter.get_cost_basis_with_diagnostics("pf-1")
assert (value, source, reason) == (None, "none", "invalid_payload")
def test_get_cost_basis_falls_back_to_legacy_alias():
"""REQ-123 AC-4: 기존 get_cost_basis는 diagnostics에서 value만 반환."""
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"avg_cost": "7500"})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
assert adapter.get_cost_basis("pf-1") == 7500.0
# ---------------------------------------------------------------------------
# REQ-109: get_realized_pnl
# ---------------------------------------------------------------------------