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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+185
-24
@@ -17,7 +17,6 @@ TDD coverage:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -69,15 +68,41 @@ class FakeCostBasisPort:
|
||||
def __init__(self, values: dict[str, float | None]):
|
||||
self._v = values
|
||||
|
||||
def get_cost_basis_with_diagnostics(self, portfolio_id: str):
|
||||
value = self._v.get(portfolio_id)
|
||||
if value is None:
|
||||
return None, "none", "missing"
|
||||
return value, "legacy", None
|
||||
|
||||
# Backward compatibility for non-diagnostic callers
|
||||
def get_cost_basis(self, portfolio_id: str) -> float | None:
|
||||
return self._v.get(portfolio_id)
|
||||
value = self._v.get(portfolio_id)
|
||||
return value
|
||||
|
||||
|
||||
class RaisingCostBasisPort:
|
||||
def get_cost_basis(self, portfolio_id: str) -> float | None:
|
||||
def get_cost_basis_with_diagnostics(self, portfolio_id: str):
|
||||
raise RuntimeError("simulated network error")
|
||||
|
||||
|
||||
class DiagnosticCostBasisPort:
|
||||
def __init__(self, result):
|
||||
self._result = result
|
||||
|
||||
def get_cost_basis_with_diagnostics(self, portfolio_id: str):
|
||||
return self._result
|
||||
|
||||
|
||||
class LegacyOnlyCostBasisPort:
|
||||
"""Compatibility seam with no diagnostic method."""
|
||||
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get_cost_basis(self, portfolio_id: str):
|
||||
return self._value
|
||||
|
||||
|
||||
def _setup(slot: Slot, broker_balances: dict[str, int],
|
||||
cost_basis_port=None, dry_run=False,
|
||||
warn_store=None):
|
||||
@@ -104,19 +129,23 @@ def _setup(slot: Slot, broker_balances: dict[str, int],
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sync_inactive_to_holding_on_cold_start():
|
||||
"""AC-1: INACTIVE slot with broker qty → INACTIVE→HOLDING, qty set, warning cleared."""
|
||||
"""AC-1: INACTIVE slot with broker qty + seeded cost-basis transitions to HOLDING."""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None)
|
||||
ws = InMemoryReconcileWarningStore()
|
||||
ws.set_warned(str(slot.slot_id), portfolio_id="pf-005930", case_code="B")
|
||||
cb_port = FakeCostBasisPort({"pf-005930": 50000.0})
|
||||
|
||||
result, repo, ws_out = _setup(slot, {"005930": 10}, warn_store=ws)
|
||||
result, repo, ws_out = _setup(slot, {"005930": 10}, cost_basis_port=cb_port, warn_store=ws)
|
||||
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed.status is SlotState.HOLDING
|
||||
assert refreshed.qty == 10.0
|
||||
assert refreshed.avg_price == 50000.0
|
||||
assert result["warning_cleared"] is True
|
||||
assert not ws_out.is_warned(str(slot.slot_id))
|
||||
assert result["skipped"] is False
|
||||
assert result["seeded_avg_price"] is True
|
||||
assert result["cost_basis_source"] == "legacy"
|
||||
assert result["before"]["status"] == "inactive"
|
||||
assert result["after"]["status"] == "holding"
|
||||
assert result["after"]["qty"] == 10.0
|
||||
@@ -130,6 +159,7 @@ def test_sync_inactive_to_holding_sets_transition_log():
|
||||
svc = SlotService(repo, INSTANCE)
|
||||
ws = InMemoryReconcileWarningStore()
|
||||
broker = FakeBroker({"005930": 5})
|
||||
cb_port = FakeCostBasisPort({"pf-005930": 50000.0})
|
||||
|
||||
sync_slot_to_broker(
|
||||
slot_id=str(slot.slot_id),
|
||||
@@ -138,6 +168,7 @@ def test_sync_inactive_to_holding_sets_transition_log():
|
||||
broker=broker,
|
||||
instance_key=INSTANCE,
|
||||
warn_store=ws,
|
||||
cost_basis_port=cb_port,
|
||||
)
|
||||
|
||||
transitions = repo._transitions
|
||||
@@ -199,20 +230,25 @@ def test_sync_seeds_avg_price_holding_with_cost_basis():
|
||||
# AC-5: graceful degrade — no cost_basis_port or port fails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sync_degrades_gracefully_no_cost_basis_port():
|
||||
"""AC-5: When cost_basis_port is None, avg_price is not seeded (just qty updated)."""
|
||||
def test_sync_inactive_requires_cost_basis_live():
|
||||
"""AC-5: INACTIVE live sync requires avg_price seed; missing cost_basis_port fails closed."""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930")
|
||||
|
||||
result, repo, _ = _setup(slot, {"005930": 10}, cost_basis_port=None)
|
||||
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed.qty == 10.0
|
||||
assert refreshed.avg_price is None # no seed
|
||||
assert result.get("seeded_avg_price") is not True
|
||||
assert refreshed.status is SlotState.INACTIVE
|
||||
assert refreshed.qty is None
|
||||
assert refreshed.avg_price is None
|
||||
assert result["skipped"] is True
|
||||
assert result["error"] == "cost_basis_unavailable"
|
||||
assert result["dry_run"] is False
|
||||
assert result["skip_reason"] == "cost_basis_unavailable"
|
||||
assert result.get("seeded_avg_price") is False
|
||||
|
||||
|
||||
def test_sync_degrades_gracefully_cost_basis_port_raises(caplog):
|
||||
"""AC-5: When cost_basis_port raises, qty is updated and WARN is logged."""
|
||||
def test_sync_inactive_cost_basis_port_raises_fail_closed(caplog):
|
||||
"""AC-5: INACTIVE live sync fails closed if cost_basis_port diagnostics raises."""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930")
|
||||
cb_port = RaisingCostBasisPort()
|
||||
|
||||
@@ -220,7 +256,11 @@ def test_sync_degrades_gracefully_cost_basis_port_raises(caplog):
|
||||
result, repo, _ = _setup(slot, {"005930": 10}, cost_basis_port=cb_port)
|
||||
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed.qty == 10.0 # qty still synced
|
||||
assert refreshed.status is SlotState.INACTIVE
|
||||
assert refreshed.qty is None
|
||||
assert result["skipped"] is True
|
||||
assert result["error"] == "cost_basis_unavailable"
|
||||
assert "cost_basis_unavailable" in result.get("skip_reason", "")
|
||||
assert "graceful degrade" in caplog.text or "cost_basis_port" in caplog.text
|
||||
|
||||
|
||||
@@ -236,10 +276,132 @@ def test_sync_degrades_gracefully_cost_basis_returns_none():
|
||||
assert refreshed.avg_price == 60000.0 # unchanged
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_wac", (0.0, -1.0, float("nan"), float("inf")))
|
||||
@pytest.mark.parametrize("dry_run", (True, False))
|
||||
def test_sync_legacy_only_invalid_wac_fails_closed_without_writes(invalid_wac, dry_run):
|
||||
"""QC-6: legacy-only invalid WAC never bypasses inactive fail-closed barrier."""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930")
|
||||
ws = InMemoryReconcileWarningStore()
|
||||
ws.set_warned(str(slot.slot_id), portfolio_id="pf-005930", case_code="B")
|
||||
|
||||
result, repo, ws_out = _setup(
|
||||
slot,
|
||||
{"005930": 10},
|
||||
cost_basis_port=LegacyOnlyCostBasisPort(invalid_wac),
|
||||
dry_run=dry_run,
|
||||
warn_store=ws,
|
||||
)
|
||||
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status is SlotState.INACTIVE
|
||||
assert refreshed.qty is None
|
||||
assert refreshed.avg_price is None
|
||||
assert ws_out.is_warned(str(slot.slot_id))
|
||||
assert result["after"] == result["before"]
|
||||
assert result["warning_cleared"] is False
|
||||
assert result["seeded_avg_price"] is False
|
||||
assert result["cost_basis_source"] == "none"
|
||||
assert result["cost_basis_reason"] == "invalid"
|
||||
assert result["skipped"] is True
|
||||
assert result["skip_reason"] == "cost_basis_unavailable"
|
||||
if dry_run:
|
||||
assert result["dry_run"] is True
|
||||
assert "error" not in result
|
||||
else:
|
||||
assert result["dry_run"] is False
|
||||
assert result["error"] == "cost_basis_unavailable"
|
||||
|
||||
|
||||
def test_sync_normalizes_diagnostic_source_aliases_and_rejects_unknown_sources():
|
||||
"""REQ-123: diagnostic seam never exposes raw provider field names."""
|
||||
for raw_source in ("avg_cost", "wac", "cost_basis"):
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None)
|
||||
result, repo, _ = _setup(
|
||||
slot,
|
||||
{"005930": 10},
|
||||
cost_basis_port=DiagnosticCostBasisPort((50000.0, raw_source, None)),
|
||||
)
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status is SlotState.HOLDING
|
||||
assert result["cost_basis_source"] == "legacy"
|
||||
assert result["cost_basis_reason"] is None
|
||||
|
||||
for raw_source in ("provider_private", 7, None):
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None)
|
||||
result, repo, _ = _setup(
|
||||
slot,
|
||||
{"005930": 10},
|
||||
cost_basis_port=DiagnosticCostBasisPort((50000.0, raw_source, "raw.detail")),
|
||||
)
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status is SlotState.INACTIVE
|
||||
assert result["cost_basis_source"] == "none"
|
||||
assert result["cost_basis_reason"] == "diagnostic_source_invalid"
|
||||
|
||||
|
||||
def test_sync_preserves_only_sanitized_adapter_failure_reasons():
|
||||
"""REQ-123: public result reasons are stable and do not expose parser detail."""
|
||||
cases = (
|
||||
(None, "canonical", "invalid", "invalid"),
|
||||
(None, "legacy", "missing", "missing"),
|
||||
(None, "none", "transport", "transport"),
|
||||
(None, "none", "http_status_503", "http_status_503"),
|
||||
(None, "none", "invalid_json", "invalid_json"),
|
||||
(None, "none", "invalid_payload", "invalid_payload"),
|
||||
)
|
||||
for value, source, reason, expected_reason in cases:
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None)
|
||||
result, repo, _ = _setup(
|
||||
slot,
|
||||
{"005930": 10},
|
||||
cost_basis_port=DiagnosticCostBasisPort((value, source, reason)),
|
||||
dry_run=True,
|
||||
)
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status is SlotState.INACTIVE
|
||||
assert result["cost_basis_source"] in {"canonical", "legacy", "none"}
|
||||
assert result["cost_basis_reason"] == expected_reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6: dry-run — no writes, shows before/after
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sync_dry_run_inactive_valid_canonical_seed_previews_without_writes():
|
||||
"""QC-5: canonical seed dry-run previews holding state without mutations."""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None)
|
||||
ws = InMemoryReconcileWarningStore()
|
||||
ws.set_warned(str(slot.slot_id), portfolio_id="pf-005930", case_code="B")
|
||||
cb_port = DiagnosticCostBasisPort((75000.0, "canonical", None))
|
||||
|
||||
result, repo, ws_out = _setup(
|
||||
slot,
|
||||
{"005930": 5},
|
||||
cost_basis_port=cb_port,
|
||||
dry_run=True,
|
||||
warn_store=ws,
|
||||
)
|
||||
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status is SlotState.INACTIVE
|
||||
assert refreshed.qty is None
|
||||
assert refreshed.avg_price is None
|
||||
assert ws_out.is_warned(str(slot.slot_id))
|
||||
assert result["dry_run"] is True
|
||||
assert result["warning_cleared"] is False
|
||||
assert result["after"]["status"] == "holding"
|
||||
assert result["after"]["qty"] == 5.0
|
||||
assert result["after"]["avg_price"] == 75000.0
|
||||
assert result["seeded_avg_price"] is True
|
||||
assert result["cost_basis_source"] == "canonical"
|
||||
assert result["cost_basis_reason"] is None
|
||||
|
||||
|
||||
def test_sync_dry_run_no_write():
|
||||
"""AC-6: dry-run does NOT modify the slot, returns before/after preview."""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None)
|
||||
@@ -254,11 +416,12 @@ def test_sync_dry_run_no_write():
|
||||
assert refreshed.qty is None
|
||||
# warning must NOT be cleared
|
||||
assert ws_out.is_warned(str(slot.slot_id))
|
||||
# result has dry_run flag and after preview
|
||||
# result has dry_run flag and no-write preview
|
||||
assert result["dry_run"] is True
|
||||
assert result["after"]["status"] == "holding"
|
||||
assert result["after"]["qty"] == 10.0
|
||||
assert result["after"]["status"] == "inactive"
|
||||
assert result["after"]["qty"] is None
|
||||
assert result["warning_cleared"] is False
|
||||
assert result["skip_reason"] == "cost_basis_unavailable"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -359,20 +522,18 @@ def test_sync_missing_slot_returns_not_found():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sync_unwarned_slot_still_syncs_qty():
|
||||
"""sync_slot_to_broker works on slots without an active warning.
|
||||
|
||||
warning_cleared=True means clear_warned() was called and succeeded
|
||||
(idempotent: even if there was no warning, clear is a no-op so True is fine).
|
||||
The important thing is that qty was updated.
|
||||
"""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None)
|
||||
"""sync_slot_to_broker works on slots without an active warning,
|
||||
but INACTIVE still requires avg_price for live write."""
|
||||
slot = _make_slot(SlotState.INACTIVE, qty=None, pid="pf-005930")
|
||||
ws = InMemoryReconcileWarningStore() # no warning set
|
||||
cb_port = FakeCostBasisPort({"pf-005930": 50000.0})
|
||||
|
||||
result, repo, _ = _setup(slot, {"005930": 7}, warn_store=ws)
|
||||
result, repo, _ = _setup(slot, {"005930": 7}, cost_basis_port=cb_port, warn_store=ws)
|
||||
|
||||
refreshed = repo.fetch_slot(slot.slot_id)
|
||||
assert refreshed.status is SlotState.HOLDING
|
||||
assert refreshed.qty == 7.0
|
||||
assert refreshed.avg_price == 50000.0
|
||||
# warning_cleared is True because clear_warned() was called successfully
|
||||
# (idempotent no-op when there was no warning)
|
||||
assert result["warning_cleared"] is True
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -405,6 +408,76 @@ def test_sync_live_requires_live_flag(capsys, monkeypatch):
|
||||
assert rc == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("as_json", [False, True])
|
||||
def test_sync_fail_closed_skipped_error_exits_nonzero_without_writes(
|
||||
monkeypatch, capsys, as_json,
|
||||
):
|
||||
"""REQ-123: a fail-closed skipped live sync is a CLI failure."""
|
||||
class _Provider:
|
||||
def get_broker_for_read(self):
|
||||
return SimpleNamespace(get_balance=lambda: []), None
|
||||
|
||||
class _WarningStore:
|
||||
def clear_warned(self, *_args):
|
||||
raise AssertionError("warning clear must not be called")
|
||||
|
||||
class _Connection:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return self
|
||||
|
||||
def execute(self, *_args):
|
||||
return None
|
||||
|
||||
def fetchone(self):
|
||||
return ("slot-1",)
|
||||
|
||||
sync_calls = []
|
||||
|
||||
def _fail_closed_sync(**kwargs):
|
||||
sync_calls.append(kwargs)
|
||||
return {
|
||||
"slot_id": "slot-1", "symbol": "005930", "portfolio_id": "PF-1",
|
||||
"before": {"status": "inactive", "qty": None, "avg_price": None},
|
||||
"after": {"status": "inactive", "qty": None, "avg_price": None},
|
||||
"warning_cleared": False, "dry_run": False, "skipped": True,
|
||||
"skip_reason": "cost_basis_unavailable", "seeded_avg_price": False,
|
||||
"cost_basis_source": "none", "cost_basis_reason": "transport",
|
||||
"error": "cost_basis_unavailable",
|
||||
}
|
||||
|
||||
psycopg = types.ModuleType("psycopg")
|
||||
setattr(psycopg, "connect", lambda *_args, **_kwargs: _Connection())
|
||||
monkeypatch.setitem(sys.modules, "psycopg", psycopg)
|
||||
monkeypatch.setattr("trex_core.runtime.broker_provider.build_broker_provider", lambda _cfg: _Provider())
|
||||
monkeypatch.setattr("trex_core.adapters.portfolio.reconcile_warning_store.PostgreSQLReconcileWarningStore", lambda *_args: _WarningStore())
|
||||
monkeypatch.setattr("trex_core.adapters.persistence.postgresql.PostgreSQLSlotRepository", lambda *_args: object())
|
||||
monkeypatch.setattr("trex_core.domain.slot.service.SlotService", lambda *_args: object())
|
||||
monkeypatch.setattr("trex_core.application.reconciliation.sync_slot_to_broker", _fail_closed_sync)
|
||||
|
||||
args = SimpleNamespace(portfolio_id="PF-1", all=False, json=as_json, dry_run=False,
|
||||
yes=True, live=False, instance=None, config=None, config_path=None)
|
||||
rc = cli._cmd_sync(args, SimpleNamespace(dsn="dsn", broker="paper", kis=None, portfolio_hub=None), lambda _: True)
|
||||
|
||||
assert rc == 1
|
||||
assert len(sync_calls) == 1
|
||||
assert sync_calls[0]["dry_run"] is False
|
||||
captured = capsys.readouterr()
|
||||
if as_json:
|
||||
result = json.loads(captured.out)["results"][0]
|
||||
assert result["error"] == "cost_basis_unavailable"
|
||||
assert result["skipped"] is True
|
||||
else:
|
||||
assert "SKIP" in captured.out
|
||||
assert "error=cost_basis_unavailable" in captured.out
|
||||
assert "CLEARED" not in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify parser tests (REQ-106 / BAT-051) — parser-level only
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -455,7 +528,6 @@ def test_verify_cmd_broker_import_does_not_raise(monkeypatch):
|
||||
Paper broker config is used (no real network needed).
|
||||
"""
|
||||
from trex_core.runtime.config import DaemonConfig
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
|
||||
cfg = DaemonConfig(
|
||||
dsn="postgresql://x/y",
|
||||
|
||||
Reference in New Issue
Block a user