feat(reconcile): safely seed canonical cost basis
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user