feat: add bounded KIS account read recovery
This commit is contained in:
@@ -27,6 +27,8 @@ def test_account_split_and_mock_tr_ids():
|
||||
assert b._tr_submit("buy") == "VTTC0012U"
|
||||
assert b._tr_submit("sell") == "VTTC0011U"
|
||||
assert b.name() == "kis-mock"
|
||||
assert len(b.account_scope_hash()) == 64
|
||||
assert "12345678" not in b.account_scope_hash()
|
||||
|
||||
|
||||
def test_live_base_url_requires_allow_live():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -14,6 +15,10 @@ from trex_core.application.broker_port import (
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
)
|
||||
from trex_core.application.account_snapshot import (
|
||||
AccountSnapshotService,
|
||||
InMemoryAccountSnapshotStore,
|
||||
)
|
||||
|
||||
|
||||
class FakeBroker:
|
||||
@@ -36,6 +41,12 @@ class FakeBroker:
|
||||
def get_balance(self):
|
||||
return self.balance
|
||||
|
||||
def account_scope_hash(self):
|
||||
return "c" * 64
|
||||
|
||||
def get_available_cash(self, *, price_check_code="005930"):
|
||||
return 1_000_000
|
||||
|
||||
def get_current_price(self, symbol, market="J"):
|
||||
return Decimal("1")
|
||||
|
||||
@@ -90,3 +101,28 @@ def test_sell_cannot_reduce_below_strategy_owned_zero(tmp_path):
|
||||
side="sell",
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
|
||||
def test_account_snapshot_keeps_manual_holdings_outside_strategy_scope(tmp_path):
|
||||
ownership = StrategyOwnershipStore(tmp_path / "ownership.json")
|
||||
ownership.apply_fill(
|
||||
execution_id="buy-1",
|
||||
symbol="005930",
|
||||
side="buy",
|
||||
quantity=2,
|
||||
)
|
||||
broker = OwnershipAwareBroker(
|
||||
FakeBroker([BalanceItem("005930", 21), BalanceItem("000660", 1)]),
|
||||
ownership,
|
||||
)
|
||||
snapshots = InMemoryAccountSnapshotStore()
|
||||
service = AccountSnapshotService(
|
||||
broker=broker,
|
||||
store=snapshots,
|
||||
clock=lambda: datetime(2026, 7, 29, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert service.refresh()
|
||||
snapshot = snapshots.load("c" * 64)
|
||||
assert snapshot is not None
|
||||
assert snapshot.holdings == (BalanceItem("005930", 2),)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""PostgreSQL account snapshot persistence contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from trex_core.adapters.persistence.account_snapshot import (
|
||||
PostgreSQLAccountSnapshotStore,
|
||||
)
|
||||
from trex_core.application.account_snapshot import AccountSnapshot
|
||||
from trex_core.application.broker_port import BalanceItem
|
||||
|
||||
|
||||
def test_round_trip_keeps_independent_capture_times(pg_clean):
|
||||
store = PostgreSQLAccountSnapshotStore(pg_clean)
|
||||
holdings_at = datetime(2026, 7, 29, 0, 0, tzinfo=timezone.utc)
|
||||
cash_at = datetime(2026, 7, 28, 23, 59, tzinfo=timezone.utc)
|
||||
expected = AccountSnapshot(
|
||||
account_hash="d" * 64,
|
||||
holdings=(BalanceItem("005930", 2),),
|
||||
holdings_captured_at=holdings_at,
|
||||
available_cash=300_000,
|
||||
cash_captured_at=cash_at,
|
||||
)
|
||||
|
||||
store.save(expected)
|
||||
|
||||
assert store.load(expected.account_hash) == expected
|
||||
|
||||
|
||||
def test_refresh_guard_is_account_scoped_and_released(pg_clean):
|
||||
store = PostgreSQLAccountSnapshotStore(pg_clean)
|
||||
with store.refresh_guard("e" * 64) as first:
|
||||
assert first is True
|
||||
with store.refresh_guard("e" * 64) as second:
|
||||
assert second is False
|
||||
with store.refresh_guard("e" * 64) as after_release:
|
||||
assert after_release is True
|
||||
@@ -18,6 +18,7 @@ _EXPECTED_TABLES = {
|
||||
"intent_log",
|
||||
"ledger_outbox", # BAT-006 / REQ-019
|
||||
"snapshot_pause", # BAT-008 / REQ-032
|
||||
"broker_account_snapshot", # dcy-041 / GitHub #45
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Bounded account-read recovery (dcy-041 / GitHub #45)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.account_snapshot import (
|
||||
AccountSnapshot,
|
||||
AccountSnapshotService,
|
||||
AccountSnapshotUnavailable,
|
||||
InMemoryAccountSnapshotStore,
|
||||
)
|
||||
from trex_core.application.broker_port import BalanceItem, BrokerBalanceError
|
||||
|
||||
|
||||
class FakeAccountBroker:
|
||||
def __init__(self) -> None:
|
||||
self.account_hash = "a" * 64
|
||||
self.balance = [BalanceItem("005930", 2)]
|
||||
self.cash = 500_000
|
||||
self.balance_error: str | None = None
|
||||
self.cash_error: str | None = None
|
||||
|
||||
def account_scope_hash(self) -> str:
|
||||
return self.account_hash
|
||||
|
||||
def get_balance(self) -> list[BalanceItem]:
|
||||
if self.balance_error is not None:
|
||||
raise BrokerBalanceError(self.balance_error)
|
||||
return list(self.balance)
|
||||
|
||||
def get_available_cash(self, *, price_check_code: str = "005930") -> int:
|
||||
if self.cash_error is not None:
|
||||
raise BrokerBalanceError(self.cash_error)
|
||||
return self.cash
|
||||
|
||||
|
||||
def _wire():
|
||||
now = [datetime(2026, 7, 29, tzinfo=timezone.utc)]
|
||||
broker = FakeAccountBroker()
|
||||
store = InMemoryAccountSnapshotStore()
|
||||
service = AccountSnapshotService(
|
||||
broker=broker,
|
||||
store=store,
|
||||
buy_max_age_sec=600,
|
||||
sell_max_age_sec=3600,
|
||||
clock=lambda: now[0],
|
||||
rng=lambda: 0.5,
|
||||
)
|
||||
return broker, store, service, now
|
||||
|
||||
|
||||
def test_refresh_persists_independent_holdings_and_cash_capture_times():
|
||||
broker, store, service, now = _wire()
|
||||
assert service.refresh() is True
|
||||
first = store.load(broker.account_hash)
|
||||
assert first is not None
|
||||
|
||||
now[0] += timedelta(seconds=120)
|
||||
broker.balance = [BalanceItem("005930", 3)]
|
||||
broker.cash_error = "transport_failed"
|
||||
assert service.refresh() is False
|
||||
|
||||
second = store.load(broker.account_hash)
|
||||
assert second is not None
|
||||
assert second.holdings == (BalanceItem("005930", 3),)
|
||||
assert second.holdings_captured_at == now[0]
|
||||
assert second.available_cash == first.available_cash
|
||||
assert second.cash_captured_at == first.cash_captured_at
|
||||
|
||||
|
||||
def test_sell_falls_back_only_for_transport_failure_and_within_sell_ttl():
|
||||
broker, _, service, now = _wire()
|
||||
assert service.refresh()
|
||||
broker.balance_error = "transport_failed"
|
||||
now[0] += timedelta(seconds=601)
|
||||
|
||||
assert service.holdings_for_sell() == [BalanceItem("005930", 2)]
|
||||
assert service.buy_guard(qty=1, limit_price=100_000) == (
|
||||
"account_snapshot_unavailable"
|
||||
)
|
||||
|
||||
now[0] += timedelta(seconds=3000)
|
||||
with pytest.raises(AccountSnapshotUnavailable) as raised:
|
||||
service.holdings_for_sell()
|
||||
assert raised.value.code == "expired"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_code",
|
||||
["authentication_failed", "authorization_failed", "malformed_response",
|
||||
"broker_rejected", "ownership_mismatch"],
|
||||
)
|
||||
def test_sell_never_falls_back_for_non_transport_failures(error_code):
|
||||
broker, _, service, _ = _wire()
|
||||
assert service.refresh()
|
||||
broker.balance_error = error_code
|
||||
|
||||
with pytest.raises(AccountSnapshotUnavailable) as raised:
|
||||
service.holdings_for_sell()
|
||||
assert raised.value.code == "live_read_failed"
|
||||
|
||||
|
||||
def test_buy_guard_accounts_for_existing_pending_cash_and_never_renews_age():
|
||||
broker, store, service, now = _wire()
|
||||
assert service.refresh()
|
||||
captured = store.load(broker.account_hash).cash_captured_at
|
||||
|
||||
assert service.buy_guard(
|
||||
qty=2, limit_price=100_000, reserved_cash=250_000
|
||||
) is None
|
||||
assert service.buy_guard(
|
||||
qty=3, limit_price=100_000, reserved_cash=250_000
|
||||
) == "insufficient_snapshot_cash"
|
||||
assert store.load(broker.account_hash).cash_captured_at == captured
|
||||
|
||||
now[0] += timedelta(seconds=601)
|
||||
assert service.buy_guard(qty=1, limit_price=1) == (
|
||||
"account_snapshot_unavailable"
|
||||
)
|
||||
|
||||
|
||||
def test_current_account_hash_is_required_on_every_consume():
|
||||
broker, _, service, _ = _wire()
|
||||
assert service.refresh()
|
||||
broker.account_hash = "b" * 64
|
||||
|
||||
assert service.buy_guard(qty=1, limit_price=1) == (
|
||||
"account_snapshot_unavailable"
|
||||
)
|
||||
broker.balance_error = "transport_failed"
|
||||
with pytest.raises(AccountSnapshotUnavailable) as raised:
|
||||
service.holdings_for_sell()
|
||||
assert raised.value.code == "account_mismatch"
|
||||
|
||||
|
||||
def test_restart_transport_attempt_can_use_existing_fresh_cash_snapshot():
|
||||
broker, store, service, now = _wire()
|
||||
store.save(
|
||||
AccountSnapshot(
|
||||
account_hash=broker.account_hash,
|
||||
holdings=(BalanceItem("005930", 2),),
|
||||
holdings_captured_at=now[0],
|
||||
available_cash=500_000,
|
||||
cash_captured_at=now[0],
|
||||
)
|
||||
)
|
||||
broker.balance_error = "transport_failed"
|
||||
broker.cash_error = "transport_failed"
|
||||
|
||||
assert service.refresh() is False
|
||||
assert service.buy_guard(qty=2, limit_price=100_000) is None
|
||||
|
||||
|
||||
def test_invalid_or_missing_buy_notional_fails_closed():
|
||||
_, _, service, _ = _wire()
|
||||
assert service.refresh()
|
||||
assert service.buy_guard(qty=None, limit_price=1) == (
|
||||
"account_snapshot_price_required"
|
||||
)
|
||||
assert service.buy_guard(qty=1, limit_price=None) == (
|
||||
"account_snapshot_price_required"
|
||||
)
|
||||
@@ -99,3 +99,52 @@ def test_no_cap_admits_without_capacity_check():
|
||||
d = intents.submit_buy(BuyIntent(intent_id=f"b{i}", portfolio_id=pid))
|
||||
assert d.status == "admitted"
|
||||
assert repo.count_active(INST) == 3
|
||||
|
||||
|
||||
def test_buy_guard_rejection_is_fail_closed_and_sell_path_is_unaffected():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, {"pf-a": "SYM"})
|
||||
intents = IntentService(
|
||||
svc,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: _SYMS.get(pid),
|
||||
instance_key=INST,
|
||||
buy_guard_fn=lambda _intent: "insufficient_snapshot_cash",
|
||||
)
|
||||
|
||||
decision = intents.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id="guarded",
|
||||
portfolio_id="pf-a",
|
||||
qty=1,
|
||||
entry_price=1000,
|
||||
)
|
||||
)
|
||||
assert decision.status == "rejected"
|
||||
assert decision.reason == "insufficient_snapshot_cash"
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE
|
||||
|
||||
|
||||
def test_buy_guard_exception_rejects_without_slot_transition():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, {"pf-a": "SYM"})
|
||||
|
||||
def broken_guard(_intent):
|
||||
raise RuntimeError("unavailable")
|
||||
|
||||
intents = IntentService(
|
||||
svc,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: _SYMS.get(pid),
|
||||
instance_key=INST,
|
||||
buy_guard_fn=broken_guard,
|
||||
)
|
||||
decision = intents.submit_buy(
|
||||
BuyIntent(intent_id="broken", portfolio_id="pf-a", qty=1, entry_price=1000)
|
||||
)
|
||||
assert decision.reason == "account_snapshot_unavailable"
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
CancelOutcome,
|
||||
CancelState,
|
||||
FillStatus,
|
||||
@@ -182,6 +183,37 @@ def test_full_sell_qty_is_broker_balance():
|
||||
assert repo.fetch_slot(sid).status is SlotState.INACTIVE # full exit
|
||||
|
||||
|
||||
def test_pending_sell_uses_injected_bounded_balance_reader():
|
||||
class LiveReadFailPaper(PaperBroker):
|
||||
fail_live_balance = False
|
||||
|
||||
def get_balance(self):
|
||||
if self.fail_live_balance:
|
||||
raise AssertionError("raw balance reader must not be called")
|
||||
return super().get_balance()
|
||||
|
||||
broker = LiveReadFailPaper(fill_after_polls=0)
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents, qty=10).slot_id
|
||||
execution.run_once()
|
||||
svc.sell(sid, actor="t", reason="full")
|
||||
broker.fail_live_balance = True
|
||||
broker._fill_after = 5
|
||||
|
||||
snapshot_execution = ExecutionService(
|
||||
svc,
|
||||
repo,
|
||||
broker,
|
||||
instance_key=INST,
|
||||
balance_reader=lambda: [BalanceItem("005931", 10)],
|
||||
)
|
||||
snapshot_execution.run_once()
|
||||
|
||||
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
|
||||
assert len(sell_orders) == 1 and sell_orders[0].qty == 10
|
||||
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
|
||||
|
||||
|
||||
def test_partial_sell_qty_is_min_intent_broker():
|
||||
"""dcy-021/REQ-062 (D4): partial sell dispatches min(intent qty, broker);
|
||||
remainder stays holding (broker-authoritative qty), cost basis preserved."""
|
||||
|
||||
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.broker_port import BalanceItem
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
@@ -20,7 +18,6 @@ from trex_core.application.reconciliation import (
|
||||
reconcile_core_holdings,
|
||||
reconcile_positions,
|
||||
)
|
||||
from trex_core.application.ports import IntentStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
@@ -54,7 +51,8 @@ class InMemoryIntentStore:
|
||||
return None
|
||||
class R:
|
||||
def __init__(self, d): self.__dict__.update(d)
|
||||
r = R(rec); r.intent_id = intent_id
|
||||
r = R(rec)
|
||||
r.intent_id = intent_id
|
||||
return r
|
||||
def finalize(self, intent_id, status, slot_id, reason):
|
||||
self._db[intent_id] = {"status": status, "slot_id": slot_id, "reason": reason}
|
||||
@@ -211,7 +209,6 @@ def test_reconcile_positions_c_case_matching_no_warn():
|
||||
|
||||
# REQ-103 AC-5: C case with warn_store=None => backward compat
|
||||
def test_reconcile_positions_no_warn_store_backward_compat():
|
||||
repo = InMemorySlotRepository()
|
||||
store = _store_with([
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
|
||||
])
|
||||
@@ -458,7 +455,7 @@ def test_reconcile_core_holdings_b_case_logging(caplog):
|
||||
svc = SlotService(repo)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
|
||||
slot = _make_holding_slot(repo, svc, qty=10)
|
||||
_make_holding_slot(repo, svc, qty=10)
|
||||
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
|
||||
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
|
||||
reconcile_core_holdings(
|
||||
@@ -527,7 +524,7 @@ def test_short_term_instance_normal_no_warn():
|
||||
svc = SlotService(repo)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
|
||||
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
|
||||
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
|
||||
store = _store_with([
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
|
||||
])
|
||||
|
||||
@@ -5,8 +5,6 @@ PostgreSQL adapter is tested by DB-skipped integration tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.portfolio import InMemoryReconcileWarningStore
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.portfolio import (
|
||||
BrokerCredentialPayload,
|
||||
PortfolioHubUnavailableError,
|
||||
PortfolioItem,
|
||||
SnapshotStore,
|
||||
TradingContext,
|
||||
@@ -266,7 +263,6 @@ def test_entry_allowed_fixed_for_day_no_refetch(monkeypatch):
|
||||
def test_successful_credential_cached_for_day():
|
||||
"""REQ-115 AC-3: 성공 후 그날 내 재fetch 없음 — 캐시 유지."""
|
||||
fetch_count = {"n": 0}
|
||||
original_fetch = StubPortfolioRead.fetch_broker_credential
|
||||
|
||||
class CountingStub(StubPortfolioRead):
|
||||
def fetch_broker_credential(self):
|
||||
|
||||
@@ -14,8 +14,6 @@ from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.broker_port import BalanceItem
|
||||
from trex_core.application.portfolio import (
|
||||
InMemoryReconcileWarningStore,
|
||||
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import pytest
|
||||
|
||||
_PG_TABLES = (
|
||||
"broker_account_snapshot",
|
||||
"killswitch_exclusion",
|
||||
"snapshot_pause",
|
||||
"ledger_outbox",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Account snapshot configuration stays opt-in and bounded."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.runtime.config import AccountSnapshotConfig, DaemonConfig
|
||||
|
||||
_KEYS = (
|
||||
"TREX_ACCOUNT_SNAPSHOT_ENABLED",
|
||||
"TREX_ACCOUNT_SNAPSHOT_BUY_MAX_AGE_SEC",
|
||||
"TREX_ACCOUNT_SNAPSHOT_SELL_MAX_AGE_SEC",
|
||||
"TREX_ACCOUNT_SNAPSHOT_REFRESH_INTERVAL_SEC",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(monkeypatch):
|
||||
for key in _KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "dbname=x")
|
||||
|
||||
|
||||
def test_defaults_are_disabled_and_bounded():
|
||||
config = DaemonConfig.from_env().account_snapshot
|
||||
assert config == AccountSnapshotConfig()
|
||||
assert config.enabled is False
|
||||
|
||||
|
||||
def test_env_enables_and_tunes_account_snapshot():
|
||||
import os
|
||||
|
||||
os.environ["TREX_ACCOUNT_SNAPSHOT_ENABLED"] = "true"
|
||||
os.environ["TREX_ACCOUNT_SNAPSHOT_BUY_MAX_AGE_SEC"] = "300"
|
||||
os.environ["TREX_ACCOUNT_SNAPSHOT_SELL_MAX_AGE_SEC"] = "1800"
|
||||
os.environ["TREX_ACCOUNT_SNAPSHOT_REFRESH_INTERVAL_SEC"] = "240"
|
||||
|
||||
config = DaemonConfig.from_env().account_snapshot
|
||||
assert config.enabled is True
|
||||
assert config.buy_max_age_sec == 300
|
||||
assert config.sell_max_age_sec == 1800
|
||||
assert config.refresh_interval_sec == 240
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"buy_max_age_sec": -1},
|
||||
{"buy_max_age_sec": 601, "sell_max_age_sec": 600},
|
||||
{"sell_max_age_sec": 21_601},
|
||||
{"refresh_interval_sec": 0},
|
||||
],
|
||||
)
|
||||
def test_invalid_age_or_interval_policy_is_rejected(kwargs):
|
||||
with pytest.raises(ValueError):
|
||||
AccountSnapshotConfig(**kwargs)
|
||||
@@ -322,7 +322,7 @@ class TestReportWacPath:
|
||||
return_value=mock_adapter,
|
||||
),
|
||||
):
|
||||
rc = cli.main(["report", "--json"])
|
||||
cli.main(["report", "--json"])
|
||||
j = json.loads(capsys.readouterr().out)
|
||||
# WAC 필드는 null (당일 net 99999로 채워지지 않음)
|
||||
assert j["realized_pnl_wac"] is None
|
||||
|
||||
Reference in New Issue
Block a user