Preserve ledger venue provenance (#39)

* feat: preserve ledger venue provenance

* fix: satisfy ledger test lint ratchet

* fix: advance image migration inventory
This commit is contained in:
2026-09-19 19:30:32 +09:00
committed by GitHub
parent 567d662913
commit 996127f215
23 changed files with 768 additions and 35 deletions
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0039"
status: active
---
# Broker ledger venue API and persistence contract
## Criterion
Broker ledger write는 optional nullable `execution_venue`와 `order_venue`를 수용해야 한다. `execution_venue`는 broker가 확인한 actual venue인 `KRX`, `NXT` 또는 NULL이고, `order_venue`는 original routing venue인 `KRX`, `NXT`, `SOR` 또는 NULL이다. Portfolio-hub는 두 값을 추정·상호 변환하지 않고 broker ledger row에 저장하며 POST의 new/duplicate 응답과 unified `GET /v1/portfolios/{portfolio_id}/ledger` broker item에서 그대로 반환해야 한다.
## Verification
- 성공 조건: `(execution_venue=NULL, order_venue=SOR)`, KRX/NXT actual과 KRX/NXT/SOR order 조합이 POST→repository→실제 PostgreSQL→POST response→unified GET에서 동일하게 round-trip되고 OpenAPI가 두 nullable enum을 노출한다.
- 실패·경계 조건: `execution_venue=SOR`, AUTO 또는 미지원 값과 `order_venue=AUTO` 또는 미지원 값은 `422`나 DB constraint로 거부되며, order venue·시간·default로 actual venue를 채우지 않는다. 필드 생략 request는 계속 유효하고 manual/corporate-action/historical item은 NULL을 반환할 수 있다.
- 검사 방법: schema/OpenAPI·API·repository tests와 실제 PostgreSQL round-trip/check-constraint integration을 실행하고 `LedgerEntry`·request/response·unified mapper의 end-to-end field path를 검토한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0040"
status: active
---
# Idempotent venue provenance enrichment
## Criterion
동일 `(portfolio_id, order_id, execution_id)`의 broker ledger duplicate는 기존 portfolio row lock을 유지하면서 기존 ledger row를 `FOR UPDATE`로 선택해야 한다. 같은 transaction에서 기존 economic payload(`side`, `quantity`, `price`, `fee_amount`, `tax_amount`)를 먼저 비교하고 불일치면 metadata 변경 없이 `409`로 중단해야 한다. Economic payload가 같을 때 모든 incoming venue를 검사하여 저장값과 서로 다른 non-null이 하나라도 있으면 어느 field도 갱신하지 않고 `409`로 중단하며, 충돌이 없을 때만 저장값 NULL·incoming non-null인 venue들을 하나의 metadata-only update로 보강해야 한다. Incoming NULL은 기존 non-null을 유지해야 한다. 이 경로는 기존 ledger row를 재생성하거나 portfolio cash·asset quantity·snapshot과 WAC/PnL를 다시 변경하지 않아야 한다.
## Verification
- 성공 조건: execution/order venue 각각과 두 필드 동시 NULL→non-null duplicate가 같은 ledger row를 갱신하고 `200 duplicate` 최종 저장값을 반환하며 ledger row 수·cash·quantity·snapshot이 최초 `201 new` 이후 변하지 않는다. Incoming NULL replay는 기존 값을 보존한다.
- 실패·경계 조건: economic payload 불일치는 venue 검사·update 전에 `409`이고, execution 또는 order venue의 non-null 불일치는 모든 venue 검사를 완료한 뒤 update 전에 `409`이므로 어느 venue도 부분 갱신되지 않으며 회계가 변하지 않는다. 서로 다른 concurrent backfill은 portfolio·ledger row lock 아래 하나만 성공하거나 같은 값으로 수렴하고 상충 값의 last-write-wins를 허용하지 않는다.
- 검사 방법: API/repository idempotency·conflict tests와 실제 PostgreSQL row-lock/concurrency integration에서 final row·row count·portfolio balances·quantity·snapshot을 함께 검사한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0041"
status: active
---
# Venue migration and mixed-version compatibility
## Criterion
Venue provenance schema 변경은 `portfolio_ledger`에 nullable `execution_venue`와 `order_venue` 및 값 constraint만 additive하게 추가하고 기존 row를 backfill하거나 재해석하지 않아야 한다. 빈 DB bootstrap, 기존 Alembic head에서의 upgrade와 migration replay가 모두 같은 head schema에 수렴해야 하며, Hub가 새 필드를 optional로 먼저 수용한 뒤 core adapter가 전송을 시작하는 혼합 버전 순서에서 구·신 payload와 기존 ledger consumer가 함께 동작해야 한다.
## Verification
- 성공 조건: disposable PostgreSQL에서 cold bootstrap, pre-migration legacy fixture upgrade와 replay가 성공하고 기존 row의 두 venue가 NULL인 채 다른 값이 보존된다. 새 core payload와 필드를 생략한 구 payload가 모두 수용되며 manual/corporate-action, authorization, ordering/filter/cursor, joined corporate-action, WAC/PnL 회귀가 통과한다.
- 실패·경계 조건: migration이 historical venue를 KRX/NXT/SOR로 채우거나 기존 row를 삭제·변경하거나 invalid venue를 허용하면 실패한다. Hub보다 core adapter를 먼저 배포하는 순서는 지원된다고 주장하지 않는다. Downgrade는 신규 constraint와 column만 제거하고 기존 schema를 복원한다.
- 검사 방법: Alembic upgrade/downgrade/replay·`app/db/migrations.py` cold/legacy integration, PostgreSQL schema inventory와 기존 ledger API/repository 회귀를 실행하고 interface contract의 Hub-first rollout 문구를 검사한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"schema_version": 1, "schema_version": 1,
"next_line_number": 9, "next_line_number": 10,
"next_ac_number": 39 "next_ac_number": 42
} }
+59
View File
@@ -0,0 +1,59 @@
---
id: "dcy-0009"
status: confirmed
---
# Broker ledger venue provenance preservation
## Problem
`trex-core`는 broker가 확인한 실제 체결 거래소를 `LedgerTrade.venue`에, 최초 주문 라우팅 거래소를 `LedgerTrade.order_venue`에 구분하여 durable ledger outbox까지 보존한다. 그러나 `PortfolioHubLedger._payload()`가 두 값을 HTTP payload에서 누락하고, `portfolio-hub`의 broker ledger request·저장소·응답 모델에도 대응 필드가 없다. 이 때문에 core 내부에서 보존된 venue provenance가 portfolio-hub 전달 경계에서 소실된다.
기대 상태는 core의 nullable actual execution venue와 original order venue를 추정·변환 없이 portfolio-hub에 전달하고, 기존 broker ledger 행·manual ledger·corporate action·구버전 client의 동작을 깨뜨리지 않으면서 저장·조회하는 것이다.
## Evidence
- `E-001` GitHub Issue #32 `https://github.com/fourblocks2020/portfolio-hub/issues/32`는 `LedgerTrade.venue -> execution_venue`, `LedgerTrade.order_venue -> order_venue` mapping, nullable historical compatibility, duplicate metadata 보강, Hub-first rollout과 venue-only 범위를 구현 계획으로 정한다.
- `E-002` `trex-core` revision `bbdacb121c25a7539f6f9f7fe0559fc03d4542eb`의 `trex_core/application/ledger_port.py`는 `LedgerTrade.venue: Optional[str]`와 `LedgerTrade.order_venue: str`를 별도 필드로 정의한다.
- `E-003` 같은 revision의 `trex_core/adapters/persistence/postgresql.py`와 migration `024_add_ledger_order_venue.sql`은 durable outbox에 `venue`와 `order_venue`를 별도 저장·복원하지만, `trex_core/adapters/ledger/portfolio_hub.py::PortfolioHubLedger._payload()`는 두 값을 HTTP payload에 포함하지 않는다.
- `E-004` `portfolio-hub` revision `567d662913335a90d0de51b8e0e961b6b166d543`의 `app/schemas/v1.py::LedgerTradeRequest`·`LedgerTradeResponse`, `app/domain/models.py::LedgerEntry`, `app/repositories/portfolio_repository.py`와 `app/repositories/postgres.py`에는 execution/order venue 계약이 없다.
- `E-005` 현재 Alembic head의 `portfolio_ledger`에는 두 venue column이 없으며 `app/db/migrations.py`와 Alembic migration이 빈 DB와 legacy DB bootstrap을 함께 소유한다.
- `E-006` portfolio-hub active AC-0032·AC-0034·AC-0035·AC-0037은 unified ledger read, nullable source-specific facts, authorization·manual compatibility와 joined ledger 안정성을 소유하지만 broker venue의 POST validation·persistence·duplicate 보강을 명시하지 않는다. 따라서 새 observable contract는 `create` 대상이고 기존 AC는 의미를 바꾸지 않는 `satisfy` 대상이다.
- `E-007` trex-core active AC-0019는 original `order_venue`와 nullable actual execution venue의 durable 분리, `(order_venue=SOR, venue=NULL)` 보존, 무추론 원칙과 기존 `LedgerWriter` 전달 경로를 이미 소유한다. Core 변경은 기존 AC의 의미를 바꾸지 않는 satisfy 구현이다.
## Scope
- `S-001` portfolio-hub에 새 active AC를 `create`하여 broker ledger request가 nullable `execution_venue` (`KRX`, `NXT`)와 nullable `order_venue` (`KRX`, `NXT`, `SOR`)를 수용하고 다른 값과 `execution_venue=SOR`를 거부하는 계약을 소유한다.
- `S-002` `portfolio_ledger`에 nullable venue column과 allowlist constraint를 additive migration으로 추가하고 historical row를 NULL로 유지한다. 빈 DB·legacy DB migration apply/replay와 실제 PostgreSQL round-trip을 검증한다.
- `S-003` 새 active AC를 `create`하여 broker ledger insert, duplicate lookup, domain mapping, POST 응답과 unified GET 응답이 두 venue를 추정·변환 없이 저장·반환하는 계약을 소유한다.
- `S-004` 동일 `(portfolio_id, order_id, execution_id)` duplicate는 portfolio row lock으로 기존 동시성 경계를 유지하면서 기존 ledger row도 `FOR UPDATE`로 선택한다. 같은 transaction에서 기존 economic payload 비교를 먼저 수행해 불일치면 아무 metadata도 갱신하지 않고 `409`로 중단한다. Economic payload가 같을 때만 모든 venue의 non-null 충돌을 먼저 검사하고, 충돌이 없으면 NULL column만 incoming non-null 값으로 한 번에 보강한다. Incoming NULL은 기존 값을 지우지 않으며 이 보강은 ledger row를 재생성하거나 cash·quantity·snapshot 회계를 다시 적용하지 않는다.
- `S-005` 기존 active AC-0032·AC-0034·AC-0035·AC-0037을 `satisfy`하여 source-neutral ordering/filter/cursor, nullable source-specific representation, owner-only authorization, manual/corporate-action 및 WAC/PnL compatibility를 유지한다.
- `S-006` trex-core는 active AC-0019 아래 `PortfolioHubLedger._payload()`가 기존 `LedgerTrade.venue`를 `execution_venue`, 기존 `LedgerTrade.order_venue`를 `order_venue`로 전송하도록 변경한다. producer, `LedgerTrade`, outbox schema와 broker routing은 변경하지 않는다.
- `S-007` 배포 호환 경계는 portfolio-hub 선배포 후 trex-core adapter 배포이며, 구버전 core payload와 historical/manual/corporate-action row는 venue가 NULL인 상태로 계속 유효하다.
- `S-008` 검증은 portfolio-hub schema/API/repository/PostgreSQL migration과 concurrency/idempotency, trex-core exact adapter payload·outbox reload/retry, 양 저장소 교차 contract `201 new -> 200 duplicate -> GET /ledger`를 포함한다.
## Out of Scope
- `OS-001` `broker_ordered_at`, `core_observed_filled_at` 또는 다른 timestamp provenance.
- `OS-002` trex-core `LedgerTrade`, producer, **broker execution adapter**, durable outbox schema 또는 주문 routing 변경. `PortfolioHubLedger` HTTP ledger transport adapter의 두 기존 field 직렬화는 `S-006` 범위다.
- `OS-003` historical venue 추정·backfill, 특히 `order_venue`나 시간대로 actual execution venue를 추론하는 동작.
- `OS-004` ledger detail endpoint, idempotency key, broker 주문 실행·라우팅 정책 변경.
- `OS-005` production migration·deployment·runtime restart·실제 broker 주문.
## Risks and Unknowns
### Risks
- `R-001` mixed-version rollout에서 새 core가 구 Hub에 먼저 배포되면 추가 payload field가 거부되거나 유실될 수 있다. Hub를 먼저 배포하고 기존 payload omission을 허용한다.
- `R-002` duplicate metadata 보강이 기존 회계 transaction path를 재실행하면 잔액·수량·snapshot이 중복 반영될 수 있다. row lock과 단일 transaction에서 provenance-only update를 분리하고 회계 불변 회귀를 실행한다.
- `R-003` repository query의 positional mapping과 joined corporate-action projection에 column을 추가할 때 기존 mapping이 어긋날 수 있다. 명시 projection·mapping 검토와 실제 PostgreSQL mixed-row 회귀로 방지한다.
- `R-004` concurrent duplicates가 서로 다른 non-null venue를 보강하면 last-write-wins로 provenance가 변조될 수 있다. 동일 row를 잠그고 NULL-only update와 non-null conflict를 transaction 안에서 판정한다.
### Assumptions
- `A-001` Issue #32에 적힌 venue-only 구현 계획과 사용자의 현재 구현 지시가 제품 범위의 근거이며, 추가 timestamp나 broker lifecycle 변경은 승인되지 않았다.
- `A-002` trex-core active AC-0019의 기존 producer/outbox 계약은 변경 없이 유지되고, 이번 core 변경은 transport 누락을 복구하는 satisfy 작업이다.
### Open Questions
없음. Issue #32와 현재 source에서 API naming, 값 allowlist, NULL semantics, duplicate policy, migration policy, rollout 순서와 제외 범위가 모두 결정되어 있다.
+4
View File
@@ -0,0 +1,4 @@
---
id: "line-0009"
status: discovery
---
+53
View File
@@ -0,0 +1,53 @@
---
id: "req-0009"
status: approved
discovery: "dcy-0009"
criteria:
create:
- "ac-0039"
- "ac-0040"
- "ac-0041"
update: []
retire: []
satisfy:
- "ac-0032"
- "ac-0034"
- "ac-0035"
- "ac-0037"
---
# Broker ledger venue provenance preservation
## Objective
`trex-core`가 이미 durable하게 구분하는 actual execution venue와 original order-routing venue를 portfolio-hub의 broker ledger write·storage·read 경계까지 손실 없이 보존한다. Unknown actual venue와 SOR routing을 정상 조합으로 유지하고, 기존 client·historical/manual/corporate-action row·회계·조회 계약과 혼합 버전 배포의 하위호환성을 보장한다.
## Scope
- Broker ledger request에 optional nullable `execution_venue`와 `order_venue`를 추가한다.
- `execution_venue`는 `KRX`, `NXT`, NULL만 허용하고 `order_venue`는 `KRX`, `NXT`, `SOR`, NULL만 허용한다.
- `portfolio_ledger`에 두 nullable column과 같은 allowlist constraint를 additive migration으로 추가한다. 기존 row는 NULL로 유지한다.
- Broker ledger insert·domain mapping·POST response와 unified GET broker item이 저장값을 추정·변환 없이 반환한다.
- 동일 `(portfolio_id, order_id, execution_id)` duplicate는 portfolio row lock에 더해 기존 ledger row를 `FOR UPDATE`로 선택한다. 같은 transaction에서 기존 economic payload를 먼저 비교해 불일치면 metadata 변경 없이 `409`로 중단한다. Economic payload가 같을 때 모든 venue의 non-null 충돌을 먼저 검사하고, 충돌이 없을 때만 NULL→non-null venue를 하나의 metadata-only update로 보강한다. Incoming NULL은 기존 non-null을 지우지 않는다.
- Metadata-only duplicate 보강은 ledger row, portfolio cash·asset quantity·snapshot, WAC/PnL 또는 corporate-action state를 다시 적용하지 않는다.
- 기존 active AC-0032·AC-0034·AC-0035·AC-0037을 의미 변경 없이 satisfy하여 unified ordering/filter/cursor, canonical nullable representation, owner authorization, manual/corporate-action 및 joined ledger compatibility를 유지한다.
- trex-core는 active AC-0019 아래 기존 `LedgerTrade.venue`를 `execution_venue`, `LedgerTrade.order_venue`를 `order_venue`로 HTTP payload에 전달한다. Core producer·domain·outbox schema는 변경하지 않는다.
- 실제 PostgreSQL migration·round-trip·concurrent duplicate와 core adapter payload/reload, 교차 저장소 `201 new -> 200 duplicate -> GET /ledger` contract를 검증한다.
## Constraints
- `SOR`는 order routing provenance이며 actual execution venue로 저장하거나 추론하지 않는다.
- Unknown actual venue는 NULL로 보존하고 `order_venue`, 시간 또는 historical default에서 KRX/NXT를 추론하지 않는다.
- 기존 idempotency key와 기존 경제적 payload conflict 판정은 유지한다.
- 추가 request field는 optional이어야 하며 구버전 core/client payload가 계속 유효해야 한다.
- Historical, manual, corporate-action row는 두 venue가 NULL이어도 유효해야 한다.
- Hub-first, core-adapter-second 배포 순서를 문서화하되 production rollout은 수행하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 create AC-0039~AC-0041 및 satisfy 대상 active AC가 normative specification을 소유한다.
## Non-Goals
- `broker_ordered_at`, `core_observed_filled_at` 또는 다른 timestamp provenance.
- trex-core `LedgerTrade`, producer, **broker execution adapter**, outbox table/schema 또는 routing policy 변경. `PortfolioHubLedger` HTTP ledger transport adapter의 기존 venue 직렬화는 Scope에 포함한다.
- Historical venue 추정·backfill.
- 새로운 ledger detail endpoint 또는 idempotency key 변경.
- Production migration·deployment·restart·실제 broker 주문.
+23 -2
View File
@@ -27,6 +27,7 @@ from app.domain.models import CorporateAction, Portfolio, PortfolioComment
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE from app.domain.services import PORTFOLIO_NOT_FOUND_MESSAGE
from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import LedgerTradeConflictError
from app.schemas.corporate_actions import ( from app.schemas.corporate_actions import (
CorporateActionRequest, CorporateActionRequest,
CorporateActionResponse, CorporateActionResponse,
@@ -604,7 +605,13 @@ def _require_service_token(auth: AuthContext = Depends(require_trade_write)) ->
return auth return auth
def _payloads_match(existing, body: LedgerTradeRequest) -> bool: def _economic_payloads_match(existing, body: LedgerTradeRequest) -> bool:
"""Defence in depth for non-Postgres repository implementations.
The Postgres repository performs this comparison under the portfolio and
ledger row locks; this check preserves the public contract for test or
alternate repository implementations.
"""
return ( return (
existing.side == body.side existing.side == body.side
and abs(existing.quantity - float(body.quantity)) < 1e-9 and abs(existing.quantity - float(body.quantity)) < 1e-9
@@ -659,14 +666,21 @@ def record_ledger_trade_v1(
executed_at=body.executed_at, executed_at=body.executed_at,
fee_amount=float(body.fee_amount), fee_amount=float(body.fee_amount),
tax_amount=float(body.tax_amount), tax_amount=float(body.tax_amount),
execution_venue=body.execution_venue,
order_venue=body.order_venue,
) )
except LedgerTradeConflictError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Duplicate key with different payload",
) from None
except HTTPException: except HTTPException:
raise raise
finally: finally:
_close_repo(repo) _close_repo(repo)
if not created: if not created:
if not _payloads_match(trade, body): if not _economic_payloads_match(trade, body):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Duplicate key with different payload", detail="Duplicate key with different payload",
@@ -687,6 +701,8 @@ def record_ledger_trade_v1(
fee_amount=trade.fee_amount, fee_amount=trade.fee_amount,
tax_amount=trade.tax_amount, tax_amount=trade.tax_amount,
executed_at=trade.executed_at, executed_at=trade.executed_at,
execution_venue=trade.execution_venue,
order_venue=trade.order_venue,
recorded_at=trade.recorded_at, recorded_at=trade.recorded_at,
cash_balance_after=trade.cash_balance_after, cash_balance_after=trade.cash_balance_after,
asset_quantity_after=trade.asset_quantity_after, asset_quantity_after=trade.asset_quantity_after,
@@ -827,6 +843,7 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHi
total_cost_basis=event["total_cost_basis"], cash_balance=event["cash_balance"], total_cost_basis=event["total_cost_basis"], cash_balance=event["cash_balance"],
profit_buffer=event["profit_buffer"], evidence_ref=event["evidence_ref"], profit_buffer=event["profit_buffer"], evidence_ref=event["evidence_ref"],
actor_user_id=event.get("actor_user_id"), recorded_at=entry.recorded_at, actor_user_id=event.get("actor_user_id"), recorded_at=entry.recorded_at,
execution_venue=entry.execution_venue, order_venue=entry.order_venue,
) )
action = (entry.action or entry.side or "").upper() action = (entry.action or entry.side or "").upper()
if action == "DIVIDEND": if action == "DIVIDEND":
@@ -853,6 +870,8 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHi
total_value_after=entry.total_value_after, total_value_after=entry.total_value_after,
target_ledger_entry_id=entry.target_ledger_entry_id, target_ledger_entry_id=entry.target_ledger_entry_id,
confirmation_evidence=entry.confirmation_evidence, confirmation_evidence=entry.confirmation_evidence,
execution_venue=entry.execution_venue,
order_venue=entry.order_venue,
) )
return LedgerHistoryItem( return LedgerHistoryItem(
ledger_entry_id=entry.ledger_entry_id, ledger_entry_id=entry.ledger_entry_id,
@@ -875,6 +894,8 @@ def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem | DividendLedgerHi
total_value_after=entry.total_value_after, total_value_after=entry.total_value_after,
target_ledger_entry_id=entry.target_ledger_entry_id, target_ledger_entry_id=entry.target_ledger_entry_id,
confirmation_evidence=entry.confirmation_evidence, confirmation_evidence=entry.confirmation_evidence,
execution_venue=entry.execution_venue,
order_venue=entry.order_venue,
) )
@@ -0,0 +1,54 @@
"""Add nullable execution and order venue provenance to portfolio ledger.
Revision ID: 20260919_0032
Revises: 20260913_0031
"""
from alembic import op
revision = "20260919_0032"
down_revision = "20260913_0031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_venue TEXT")
op.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS order_venue TEXT")
op.execute(
"""
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_portfolio_ledger_execution_venue'
AND conrelid = 'portfolio_ledger'::regclass
) THEN
ALTER TABLE portfolio_ledger
ADD CONSTRAINT ck_portfolio_ledger_execution_venue
CHECK (execution_venue IN ('KRX', 'NXT'));
END IF;
END $$
"""
)
op.execute(
"""
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_portfolio_ledger_order_venue'
AND conrelid = 'portfolio_ledger'::regclass
) THEN
ALTER TABLE portfolio_ledger
ADD CONSTRAINT ck_portfolio_ledger_order_venue
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'));
END IF;
END $$
"""
)
def downgrade() -> None:
op.drop_constraint("ck_portfolio_ledger_order_venue", "portfolio_ledger", type_="check")
op.drop_constraint("ck_portfolio_ledger_execution_venue", "portfolio_ledger", type_="check")
op.drop_column("portfolio_ledger", "order_venue")
op.drop_column("portfolio_ledger", "execution_venue")
+3 -1
View File
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from typing import Any, Protocol, runtime_checkable from typing import Any, Literal, Protocol, runtime_checkable
class MarketCode(str, Enum): class MarketCode(str, Enum):
@@ -181,6 +181,8 @@ class LedgerEntry:
target_ledger_entry_id: int | None = None target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None confirmation_evidence: str | None = None
actor_user_id: str | None = None actor_user_id: str | None = None
execution_venue: Literal["KRX", "NXT"] | None = None
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
corporate_action: dict[str, Any] | None = None corporate_action: dict[str, Any] | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
+6
View File
@@ -9,6 +9,10 @@ from typing import Protocol
from app.domain.models import Asset, AuthToken, BrokerCredential, CorporateAction, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User from app.domain.models import Asset, AuthToken, BrokerCredential, CorporateAction, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
class LedgerTradeConflictError(ValueError):
"""A duplicate broker-ledger key disagrees with its persisted payload."""
class PortfolioRepository(Protocol): class PortfolioRepository(Protocol):
def ping(self) -> None: ... def ping(self) -> None: ...
@@ -324,6 +328,8 @@ class PortfolioRepository(Protocol):
executed_at: datetime, executed_at: datetime,
fee_amount: float = 0.0, fee_amount: float = 0.0,
tax_amount: float = 0.0, tax_amount: float = 0.0,
execution_venue: str | None = None,
order_venue: str | None = None,
) -> tuple[LedgerEntry, bool]: ... ) -> tuple[LedgerEntry, bool]: ...
def record_manual_ledger( def record_manual_ledger(
+116 -10
View File
@@ -40,6 +40,7 @@ from app.domain.models import (
TokenPrincipal, TokenPrincipal,
User, User,
) )
from app.repositories.portfolio_repository import LedgerTradeConflictError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_LEDGER_NUMERIC_SCALE = 8 _LEDGER_NUMERIC_SCALE = 8
@@ -382,7 +383,11 @@ class PostgresPortfolioRepository:
profit_buffer_after NUMERIC(20,8) NOT NULL DEFAULT 0, profit_buffer_after NUMERIC(20,8) NOT NULL DEFAULT 0,
strategy_set_id TEXT NOT NULL DEFAULT 'legacy', strategy_set_id TEXT NOT NULL DEFAULT 'legacy',
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
execution_venue TEXT NULL CONSTRAINT ck_portfolio_ledger_execution_venue
CHECK (execution_venue IN ('KRX', 'NXT')),
order_venue TEXT NULL CONSTRAINT ck_portfolio_ledger_order_venue
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'))
) )
""" """
) )
@@ -395,6 +400,33 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS target_ledger_entry_id BIGINT") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS target_ledger_entry_id BIGINT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS confirmation_evidence TEXT") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS confirmation_evidence TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS actor_user_id TEXT") cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS actor_user_id TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS execution_venue TEXT")
cur.execute("ALTER TABLE portfolio_ledger ADD COLUMN IF NOT EXISTS order_venue TEXT")
cur.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_portfolio_ledger_execution_venue'
AND conrelid = 'portfolio_ledger'::regclass
) THEN
ALTER TABLE portfolio_ledger
ADD CONSTRAINT ck_portfolio_ledger_execution_venue
CHECK (execution_venue IS NULL OR execution_venue IN ('KRX', 'NXT'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_portfolio_ledger_order_venue'
AND conrelid = 'portfolio_ledger'::regclass
) THEN
ALTER TABLE portfolio_ledger
ADD CONSTRAINT ck_portfolio_ledger_order_venue
CHECK (order_venue IS NULL OR order_venue IN ('KRX', 'NXT', 'SOR'));
END IF;
END $$;
"""
)
cur.execute( cur.execute(
""" """
DO $$ DO $$
@@ -2018,6 +2050,8 @@ class PostgresPortfolioRepository:
executed_at, executed_at,
fee_amount: float = 0.0, fee_amount: float = 0.0,
tax_amount: float = 0.0, tax_amount: float = 0.0,
execution_venue: str | None = None,
order_venue: str | None = None,
) -> tuple[LedgerEntry, bool]: ) -> tuple[LedgerEntry, bool]:
self.ensure_schema() self.ensure_schema()
side_normalized = str(side).lower() side_normalized = str(side).lower()
@@ -2078,16 +2112,70 @@ class PostgresPortfolioRepository:
executed_at, recorded_at, executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8, cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8, total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note strategy_set_id, note, source, target_ledger_entry_id,
confirmation_evidence, actor_user_id, execution_venue, order_venue
FROM portfolio_ledger FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
ORDER BY recorded_at DESC, ledger_entry_id DESC ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT 1 LIMIT 1
FOR UPDATE
""", """,
(portfolio_id, order_id, execution_id), (portfolio_id, order_id, execution_id),
) )
existing = cur.fetchone() existing = cur.fetchone()
if existing is not None: if existing is not None:
economic_payload_matches = (
existing[4] == side_normalized
and abs(float(existing[5]) - qty_float) < 1e-9
and abs(float(existing[6]) - price_float) < 1e-9
and abs(float(existing[7]) - fee_float) < 1e-9
and abs(float(existing[8]) - tax_float) < 1e-9
)
if not economic_payload_matches:
raise LedgerTradeConflictError("duplicate economic payload conflict")
existing_execution_venue = existing[21]
existing_order_venue = existing[22]
if (
existing_execution_venue is not None
and execution_venue is not None
and existing_execution_venue != execution_venue
) or (
existing_order_venue is not None
and order_venue is not None
and existing_order_venue != order_venue
):
raise LedgerTradeConflictError("duplicate venue payload conflict")
enriched_execution_venue = existing_execution_venue or execution_venue
enriched_order_venue = existing_order_venue or order_venue
if (
enriched_execution_venue != existing_execution_venue
or enriched_order_venue != existing_order_venue
):
cur.execute(
"""
UPDATE portfolio_ledger
SET execution_venue = %s, order_venue = %s
WHERE ledger_entry_id = %s AND recorded_at = %s
RETURNING
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note, source, target_ledger_entry_id,
confirmation_evidence, actor_user_id, execution_venue, order_venue
""",
(
enriched_execution_venue,
enriched_order_venue,
existing[0],
existing[10],
),
)
existing = cur.fetchone()
# Duplicate: do not insert a new row, do not mutate the portfolio. # Duplicate: do not insert a new row, do not mutate the portfolio.
# OQ-4 (b): return the CURRENT authoritative portfolio state so the # OQ-4 (b): return the CURRENT authoritative portfolio state so the
# caller always syncs to the latest balances, not the first-call snapshot. # caller always syncs to the latest balances, not the first-call snapshot.
@@ -2102,12 +2190,13 @@ class PostgresPortfolioRepository:
portfolio_id, recorded_at, ledger_entry_id, order_id, execution_id, portfolio_id, recorded_at, ledger_entry_id, order_id, execution_id,
side, price, quantity, fee_amount, tax_amount, side, price, quantity, fee_amount, tax_amount,
cash_balance_after, asset_quantity_after, total_value_after, cash_balance_after, asset_quantity_after, total_value_after,
profit_buffer_after, strategy_set_id, note, executed_at profit_buffer_after, strategy_set_id, note, executed_at,
execution_venue, order_venue
) VALUES ( ) VALUES (
%s, NOW(), DEFAULT, %s, %s, %s, NOW(), DEFAULT, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s %s, %s, %s, %s, %s, %s
) )
RETURNING RETURNING
ledger_entry_id, portfolio_id, order_id, execution_id, side, ledger_entry_id, portfolio_id, order_id, execution_id, side,
@@ -2116,7 +2205,8 @@ class PostgresPortfolioRepository:
executed_at, recorded_at, executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8, cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8, total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note strategy_set_id, note, source, target_ledger_entry_id,
confirmation_evidence, actor_user_id, execution_venue, order_venue
""", """,
( (
portfolio_id, portfolio_id,
@@ -2134,6 +2224,8 @@ class PostgresPortfolioRepository:
strategy_set_id, strategy_set_id,
"", "",
executed_at, executed_at,
execution_venue,
order_venue,
), ),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -2182,6 +2274,16 @@ class PostgresPortfolioRepository:
target_ledger_entry_id=int(row[18]) if len(row) > 18 and row[18] is not None else None, target_ledger_entry_id=int(row[18]) if len(row) > 18 and row[18] is not None else None,
confirmation_evidence=row[19] if len(row) > 19 else None, confirmation_evidence=row[19] if len(row) > 19 else None,
actor_user_id=row[20] if len(row) > 20 else None, actor_user_id=row[20] if len(row) > 20 else None,
execution_venue=(
row[21]
if len(row) > 21 and row[21] in {None, "KRX", "NXT"}
else None
),
order_venue=(
row[22]
if len(row) > 22 and row[22] in {None, "KRX", "NXT", "SOR"}
else None
),
) )
def reset(self, portfolio_id: str) -> Portfolio: def reset(self, portfolio_id: str) -> Portfolio:
@@ -2492,7 +2594,8 @@ class PostgresPortfolioRepository:
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after, fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
execution_venue, order_venue
FROM portfolio_ledger FROM portfolio_ledger
WHERE portfolio_id = %s AND ledger_entry_id = %s WHERE portfolio_id = %s AND ledger_entry_id = %s
""", """,
@@ -2591,7 +2694,8 @@ class PostgresPortfolioRepository:
RETURNING ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, RETURNING ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after, fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
execution_venue, order_venue
""", """,
( (
portfolio_id, portfolio_id,
@@ -2623,7 +2727,8 @@ class PostgresPortfolioRepository:
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price, SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after, fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id, asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id,
execution_venue, order_venue
FROM portfolio_ledger FROM portfolio_ledger
WHERE portfolio_id = %s AND source = 'manual' WHERE portfolio_id = %s AND source = 'manual'
ORDER BY recorded_at DESC, ledger_entry_id DESC ORDER BY recorded_at DESC, ledger_entry_id DESC
@@ -2661,6 +2766,7 @@ class PostgresPortfolioRepository:
ledger.asset_quantity_after, ledger.total_value_after, ledger.profit_buffer_after, ledger.asset_quantity_after, ledger.total_value_after, ledger.profit_buffer_after,
ledger.strategy_set_id, ledger.note, ledger.source, ledger.target_ledger_entry_id, ledger.strategy_set_id, ledger.note, ledger.source, ledger.target_ledger_entry_id,
ledger.confirmation_evidence, ledger.actor_user_id, ledger.confirmation_evidence, ledger.actor_user_id,
ledger.execution_venue, ledger.order_venue,
ca.corporate_action_id, ca.action_type, ca.effective_date, ca.corporate_action_id, ca.action_type, ca.effective_date,
ca.quantity_before, ca.quantity_after, ca.average_price_before, ca.quantity_before, ca.quantity_after, ca.average_price_before,
ca.average_price_after, ca.total_cost_basis, ca.cash_balance, ca.average_price_after, ca.total_cost_basis, ca.cash_balance,
@@ -2676,8 +2782,8 @@ class PostgresPortfolioRepository:
result = [] result = []
for row in cur.fetchall(): for row in cur.fetchall():
entry = self._row_to_ledger_entry(row) entry = self._row_to_ledger_entry(row)
if row[21] is not None: if row[23] is not None:
entry.corporate_action = {"corporate_action_id": str(row[21]), "action_type": row[22], "effective_date": row[23], "quantity_before": Decimal(str(row[24])), "quantity_after": Decimal(str(row[25])), "average_price_before": Decimal(str(row[26])), "average_price_after": Decimal(str(row[27])), "total_cost_basis": Decimal(str(row[28])), "cash_balance": Decimal(str(row[29])), "profit_buffer": Decimal(str(row[30])), "evidence_ref": row[31], "actor_user_id": row[32]} entry.corporate_action = {"corporate_action_id": str(row[23]), "action_type": row[24], "effective_date": row[25], "quantity_before": Decimal(str(row[26])), "quantity_after": Decimal(str(row[27])), "average_price_before": Decimal(str(row[28])), "average_price_after": Decimal(str(row[29])), "total_cost_basis": Decimal(str(row[30])), "cash_balance": Decimal(str(row[31])), "profit_buffer": Decimal(str(row[32])), "evidence_ref": row[33], "actor_user_id": row[34]}
result.append(entry) result.append(entry)
return result return result
+2
View File
@@ -37,6 +37,8 @@ class LedgerEntryResponse(BaseModel):
profit_buffer_after: float = 0.0 profit_buffer_after: float = 0.0
strategy_set_id: str = "manual" strategy_set_id: str = "manual"
note: str = "" note: str = ""
execution_venue: Literal["KRX", "NXT"] | None = None
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
class PortfolioBase(BaseModel): class PortfolioBase(BaseModel):
+12
View File
@@ -108,6 +108,8 @@ class UserInfoResponse(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class LedgerTradeRequest(BaseModel): class LedgerTradeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
order_id: str = Field(..., min_length=1) order_id: str = Field(..., min_length=1)
execution_id: str = Field(..., min_length=1) execution_id: str = Field(..., min_length=1)
side: Literal["buy", "sell"] side: Literal["buy", "sell"]
@@ -116,6 +118,8 @@ class LedgerTradeRequest(BaseModel):
fee_amount: Decimal = Field(default=Decimal(0), ge=0) fee_amount: Decimal = Field(default=Decimal(0), ge=0)
tax_amount: Decimal = Field(default=Decimal(0), ge=0) tax_amount: Decimal = Field(default=Decimal(0), ge=0)
executed_at: datetime executed_at: datetime
execution_venue: Literal["KRX", "NXT"] | None = None
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
class LedgerTradeResponse(BaseModel): class LedgerTradeResponse(BaseModel):
@@ -129,6 +133,8 @@ class LedgerTradeResponse(BaseModel):
fee_amount: float fee_amount: float
tax_amount: float tax_amount: float
executed_at: datetime executed_at: datetime
execution_venue: Literal["KRX", "NXT"] | None = None
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
recorded_at: datetime | None = None recorded_at: datetime | None = None
cash_balance_after: float = 0.0 cash_balance_after: float = 0.0
asset_quantity_after: float = 0.0 asset_quantity_after: float = 0.0
@@ -286,6 +292,8 @@ class LedgerHistoryItem(BaseModel):
total_value_after: float | None = None total_value_after: float | None = None
target_ledger_entry_id: int | None = None target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None confirmation_evidence: str | None = None
execution_venue: Literal["KRX", "NXT"] | None = None
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
class DividendLedgerHistoryItem(BaseModel): class DividendLedgerHistoryItem(BaseModel):
@@ -311,6 +319,8 @@ class DividendLedgerHistoryItem(BaseModel):
total_value_after: float | None = None total_value_after: float | None = None
target_ledger_entry_id: int | None = None target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None confirmation_evidence: str | None = None
execution_venue: Literal["KRX", "NXT"] | None = None
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
class CorporateActionLedgerHistoryItem(BaseModel): class CorporateActionLedgerHistoryItem(BaseModel):
@@ -331,6 +341,8 @@ class CorporateActionLedgerHistoryItem(BaseModel):
evidence_ref: str evidence_ref: str
actor_user_id: str | None = None actor_user_id: str | None = None
recorded_at: datetime recorded_at: datetime
execution_venue: Literal["KRX", "NXT"] | None = None
order_venue: Literal["KRX", "NXT", "SOR"] | None = None
class LedgerHistoryResponse(BaseModel): class LedgerHistoryResponse(BaseModel):
+7 -7
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stagepilot/bootstrap/baseline.yaml - Baseline Seed: .stagepilot/bootstrap/baseline.yaml
- Status: confirmed - Status: confirmed
- Owner: repository-maintainers - Owner: repository-maintainers
- Last Updated (KST): 2026-05-21 00:00 - Last Updated (KST): 2026-09-19
- Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004) - Source Discovery / Batch: bootstrap-baseline, bat-001, bat-006, bat-008, bat-010, bat-003, bat-004, bat-005, BAT-006(dcy-004), BAT-007(dcy-004), BAT-008(dcy-004), ProofLine req-0009
- Primary Runtime: mixed - Primary Runtime: mixed
## Project Summary ## Project Summary
@@ -407,11 +407,11 @@
### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011; REQ-033 column rename BAT-015) ### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/trades` (REQ-029, BAT-011; REQ-033 column rename BAT-015)
- Auth: DB service token with `trade-write` permission; `daemon_id IS NULL` → HTTP 403 fail-closed - Auth: DB service token with `trade-write` permission; `daemon_id IS NULL` → HTTP 403 fail-closed
- Request: `LedgerTradeRequest` — `order_id`, `execution_id`, `side` (buy|sell), `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at` - Request: `LedgerTradeRequest` — `order_id`, `execution_id`, `side` (buy|sell), `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`, optional `execution_venue` (`KRX|NXT|null`), optional `order_venue` (`KRX|NXT|SOR|null`). `SOR` is routing provenance only and is rejected as `execution_venue`; the service never infers actual execution venue from routing venue or time.
- Response: HTTP 201 + `LedgerTradeResponse` with `status="new"`; HTTP 200 + `status="duplicate"` for idempotent replay; HTTP 409 for conflicting payload - Response: HTTP 201 + `LedgerTradeResponse` with `status="new"`; HTTP 200 + `status="duplicate"` for idempotent replay; both responses and broker items from `GET /v1/portfolios/{portfolio_id}/ledger` return persisted `execution_venue` and `order_venue`; HTTP 409 for conflicting payload
- `LedgerTradeResponse` fields: `ledger_entry_id`, `portfolio_id` (renamed from `asset_id` by REQ-033 / dcy-005 — schema and DB column now match in meaning), `order_id`, `execution_id`, `side`, `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`, `recorded_at`, `status` - `LedgerTradeResponse` fields: `ledger_entry_id`, `portfolio_id` (renamed from `asset_id` by REQ-033 / dcy-005 — schema and DB column now match in meaning), `order_id`, `execution_id`, `side`, `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`, `execution_venue`, `order_venue`, `recorded_at`, `status`
- Error: HTTP 403 (no daemon_id on token, portfolio unassigned, daemon mismatch); HTTP 404 (non-existent portfolio); HTTP 409 (disabled portfolio or payload conflict) - Error: HTTP 403 (no daemon_id on token, portfolio unassigned, daemon mismatch); HTTP 404 (non-existent portfolio); HTTP 409 (disabled portfolio, economic-payload conflict, or conflicting non-null venue); HTTP 422 (unsupported venue or unknown request field)
- Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint - Idempotency key: `(portfolio_id, order_id, execution_id)` — reuses `uq_ledger_trades_idempotency` constraint. Equal economic payloads may atomically enrich only NULL venue metadata; incoming NULL never erases persisted metadata, conflicting non-null values return HTTP 409, and metadata enrichment never reapplies accounting.
- **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404. - **구 path `POST /v1/assets/{asset_id}/ledger/trades`**: BAT-011에서 제거됨. 호출 시 HTTP 404.
### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024) ### Endpoint: `POST /v1/portfolios/{portfolio_id}/ledger/manual` (REQ-056, BAT-024)
+2 -2
View File
@@ -25,8 +25,8 @@ COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
VERSION_REF_RE = re.compile(r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") VERSION_REF_RE = re.compile(r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$") IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)") DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
EXPECTED_MIGRATION_COUNT = 31 EXPECTED_MIGRATION_COUNT = 32
EXPECTED_ALEMBIC_HEAD = "20260913_0031" EXPECTED_ALEMBIC_HEAD = "20260919_0032"
@dataclass(frozen=True) @dataclass(frozen=True)
class Result: class Result:
+8 -4
View File
@@ -33,8 +33,8 @@ class RecordingRunner:
if command[:3] == ["docker", "run", "--rm"] and "urllib.request" in command[-1]: if command[:3] == ["docker", "run", "--rm"] and "urllib.request" in command[-1]:
enabled = "TARGET:8000" not in command[-1] and "plain" not in command[-1] enabled = "TARGET:8000" not in command[-1] and "plain" not in command[-1]
return image_release.Result('{"status":"ok","db_enabled":' + str(enabled).lower() + '}') return image_release.Result('{"status":"ok","db_enabled":' + str(enabled).lower() + '}')
if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("20260913_0031\n") if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("20260919_0032\n")
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260913_0031"}\n') if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260919_0032"}\n')
return image_release.Result() return image_release.Result()
def artifacts(tmp_path: Path): def artifacts(tmp_path: Path):
@@ -74,12 +74,16 @@ def test_project_version_must_match_release_tag(tmp_path):
def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup(): def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup():
inventory = tuple( inventory = tuple(
[f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 30)] [f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 30)]
+ ["20260820_0030_portfolio_ledger_keyset_index.py", "20260913_0031_corporate_actions.py"] + [
"20260820_0030_portfolio_ledger_keyset_index.py",
"20260913_0031_corporate_actions.py",
"20260919_0032_ledger_venue_provenance.py",
]
) )
runner = RecordingRunner(); image_release.smoke_installed_image("candidate", inventory, runner, 17) runner = RecordingRunner(); image_release.smoke_installed_image("candidate", inventory, runner, 17)
runs = [x[0] for x in runner.commands if x[0][:2] == ["docker","run"]] runs = [x[0] for x in runner.commands if x[0][:2] == ["docker","run"]]
assert len(runs) == 3 and all("--read-only" in x and "none" in x for x in runs) assert len(runs) == 3 and all("--read-only" in x and "none" in x for x in runs)
assert "migration_inventory" in runs[-1][-1] and "20260913_0031" in runs[-1][-1] assert "migration_inventory" in runs[-1][-1] and "20260919_0032" in runs[-1][-1]
assert len([x for x in runner.commands if x[0][:3] == ["docker","rm","--force"]]) == 3 assert len([x for x in runner.commands if x[0][:3] == ["docker","rm","--force"]]) == 3
def _assert_exact_startup_cleanup(commands: list[list[str]]) -> None: def _assert_exact_startup_cleanup(commands: list[list[str]]) -> None:
+3 -3
View File
@@ -54,7 +54,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
assert 'RELEASE_VERSION=v0.1.3' not in verify assert 'RELEASE_VERSION=v0.1.3' not in verify
def test_release_version_and_migration_contract_has_current_head_0031(): def test_release_version_and_migration_contract_has_current_head_0032():
project = (REPO / "pyproject.toml").read_text() project = (REPO / "pyproject.toml").read_text()
config = (REPO / "app/core/config.py").read_text() config = (REPO / "app/core/config.py").read_text()
migrations = sorted( migrations = sorted(
@@ -64,8 +64,8 @@ def test_release_version_and_migration_contract_has_current_head_0031():
) )
assert 'version = "0.3.4"' in project assert 'version = "0.3.4"' in project
assert 'app_version: str = "0.3.4"' in config assert 'app_version: str = "0.3.4"' in config
assert len(migrations) == 31 assert len(migrations) == 32
assert migrations[-1].startswith("20260913_0031_") assert migrations[-1].startswith("20260919_0032_")
def test_dockerignore_is_deny_by_default_required_inputs_only(): def test_dockerignore_is_deny_by_default_required_inputs_only():
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**" lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
@@ -34,7 +34,7 @@ from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioReposi
_LOCAL_DSN_ENV = "BAT025_LOCAL_POSTGRES_DSN" _LOCAL_DSN_ENV = "BAT025_LOCAL_POSTGRES_DSN"
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
_HEAD = "20260715_0028" _HEAD = "20260919_0032"
_PRE_BAT025 = "20260628_0027" _PRE_BAT025 = "20260628_0027"
@@ -138,6 +138,26 @@ def _metadata_columns(settings: Settings) -> dict[str, tuple[str, str, str | Non
return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()} return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}
def _venue_columns(settings: Settings) -> dict[str, tuple[str, str, str | None]]:
with psycopg.connect(
host=settings.active_db_host,
port=settings.active_db_port,
dbname=settings.active_db_name,
user=settings.active_db_user,
password=settings.active_db_password,
sslmode=settings.active_db_sslmode,
) as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'portfolio_ledger'
AND column_name IN ('execution_venue', 'order_venue')
"""
)
return {row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()}
def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str: def _seed_portfolio(repo: PostgresPortfolioRepository, suffix: str) -> str:
portfolio_id = f"bat025-{suffix}" portfolio_id = f"bat025-{suffix}"
user_id = f"bat025-user-{suffix}" user_id = f"bat025-user-{suffix}"
@@ -215,14 +235,79 @@ def test_upgrade_from_pre_0028_preserves_business_data_and_backfills_source(
"confirmation_evidence": ("text", "YES", None), "confirmation_evidence": ("text", "YES", None),
"actor_user_id": ("text", "YES", None), "actor_user_id": ("text", "YES", None),
} }
assert _venue_columns(isolated_settings) == {
"execution_venue": ("text", "YES", None),
"order_venue": ("text", "YES", None),
}
with repo._connect().cursor() as cur: with repo._connect().cursor() as cur:
cur.execute( cur.execute(
"SELECT COUNT(*), side, price::text, quantity::text, note, source " "SELECT COUNT(*), side, price::text, quantity::text, note, source, "
"FROM portfolio_ledger GROUP BY side, price, quantity, note, source" "execution_venue, order_venue FROM portfolio_ledger "
"GROUP BY side, price, quantity, note, source, execution_venue, order_venue"
) )
count, side, price, quantity, note, source = cur.fetchone() row = cur.fetchone()
assert row is not None
count, side, price, quantity, note, source, execution_venue, order_venue = row
assert count == before_count assert count == before_count
assert (side, price, quantity, note, source) == ("buy", "123.45000000", "7.00000000", "preserve-me", "broker") assert (side, price, quantity, note, source) == ("buy", "123.45000000", "7.00000000", "preserve-me", "broker")
assert (execution_venue, order_venue) == (None, None)
with pytest.raises(psycopg.errors.CheckViolation), repo._connect().cursor() as cur:
cur.execute(
"UPDATE portfolio_ledger SET execution_venue = 'SOR' WHERE portfolio_id = %s",
(portfolio_id,),
)
upgrade_to_head(isolated_settings)
assert _current_revision(isolated_settings) == _HEAD
repo.close()
def test_venue_migration_downgrade_removes_only_new_columns(
isolated_settings: Settings,
) -> None:
from alembic import command
upgrade_to_head(isolated_settings)
repo = _repo(isolated_settings)
portfolio_id = _seed_portfolio(repo, "venue-downgrade")
recorded_at = datetime(2026, 9, 19, tzinfo=UTC)
with repo._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, side, price, quantity,
cash_balance_after, asset_quantity_after, total_value_after,
strategy_set_id, note, executed_at,
execution_venue, order_venue
) VALUES (%s, %s, 'buy', 100, 1, 900, 1, 1000,
'downgrade', 'preserve-me', %s, 'NXT', 'SOR')
""",
(portfolio_id, recorded_at, recorded_at),
)
command.downgrade(get_alembic_config(isolated_settings), "20260913_0031")
assert _current_revision(isolated_settings) == "20260913_0031"
assert _venue_columns(isolated_settings) == {}
with repo._connect().cursor() as cur:
cur.execute(
"SELECT COUNT(*), MIN(note) FROM portfolio_ledger WHERE portfolio_id = %s",
(portfolio_id,),
)
assert cur.fetchone() == (1, "preserve-me")
upgrade_to_head(isolated_settings)
assert _venue_columns(isolated_settings) == {
"execution_venue": ("text", "YES", None),
"order_venue": ("text", "YES", None),
}
with repo._connect().cursor() as cur:
cur.execute(
"SELECT execution_venue, order_venue FROM portfolio_ledger WHERE portfolio_id = %s",
(portfolio_id,),
)
assert cur.fetchone() == (None, None)
repo.close() repo.close()
+2
View File
@@ -230,6 +230,8 @@ def test_ledger_trade_201_when_daemon_matches(monkeypatch) -> None:
trade.price = 50000.0 trade.price = 50000.0
trade.fee_amount = 0.0 trade.fee_amount = 0.0
trade.tax_amount = 0.0 trade.tax_amount = 0.0
trade.execution_venue = None
trade.order_venue = None
trade.executed_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc) trade.executed_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc)
trade.recorded_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc) trade.recorded_at = datetime(2026, 5, 13, 1, 0, 0, tzinfo=timezone.utc)
mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade) mock_repo, client = _setup_ledger_mocks(monkeypatch, principal, portfolio, trade)
@@ -12,10 +12,13 @@ from __future__ import annotations
import os import os
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from threading import Barrier
import pytest import pytest
from app.repositories.portfolio_repository import LedgerTradeConflictError
from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository from app.repositories.postgres import PortfolioDBConfig, PostgresPortfolioRepository
@@ -133,6 +136,182 @@ def test_duplicate_broker_write_is_idempotent(repo, seeded_portfolio):
assert cur.fetchone()[0] == 1 assert cur.fetchone()[0] == 1
def test_duplicate_venue_enrichment_is_metadata_only(repo, seeded_portfolio):
args = {
"portfolio_id": seeded_portfolio,
"order_id": "ord-venue-enrich",
"execution_id": "exec-venue-enrich",
"side": "buy",
"quantity": 5.0,
"price": 10_000.0,
"executed_at": _now(),
}
_first, created = repo.insert_ledger_trade(**args)
assert created is True
before = repo.get(seeded_portfolio)
enriched, created = repo.insert_ledger_trade(
**args,
execution_venue="NXT",
order_venue="SOR",
)
assert created is False
assert enriched.execution_venue == "NXT"
assert enriched.order_venue == "SOR"
after = repo.get(seeded_portfolio)
assert after.cash_balance == before.cash_balance
assert after.asset_quantity == before.asset_quantity
with repo._connect().cursor() as cur:
cur.execute(
"""
SELECT COUNT(*), MIN(execution_venue), MIN(order_venue)
FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
""",
(seeded_portfolio, args["order_id"], args["execution_id"]),
)
assert cur.fetchone() == (1, "NXT", "SOR")
def test_duplicate_venue_conflict_is_atomic(repo, seeded_portfolio):
args = {
"portfolio_id": seeded_portfolio,
"order_id": "ord-venue-conflict",
"execution_id": "exec-venue-conflict",
"side": "buy",
"quantity": 1.0,
"price": 10_000.0,
"executed_at": _now(),
}
repo.insert_ledger_trade(**args, execution_venue="KRX")
before = repo.get(seeded_portfolio)
with pytest.raises(LedgerTradeConflictError):
repo.insert_ledger_trade(
**args,
execution_venue="NXT",
order_venue="SOR",
)
after = repo.get(seeded_portfolio)
assert after.cash_balance == before.cash_balance
assert after.asset_quantity == before.asset_quantity
with repo._connect().cursor() as cur:
cur.execute(
"""
SELECT execution_venue, order_venue
FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
""",
(seeded_portfolio, args["order_id"], args["execution_id"]),
)
assert cur.fetchone() == ("KRX", None)
def test_duplicate_null_venues_do_not_erase_existing_values(repo, seeded_portfolio):
args = {
"portfolio_id": seeded_portfolio,
"order_id": "ord-venue-preserve",
"execution_id": "exec-venue-preserve",
"side": "buy",
"quantity": 1.0,
"price": 10_000.0,
"executed_at": _now(),
}
repo.insert_ledger_trade(
**args,
execution_venue="KRX",
order_venue="SOR",
)
duplicate, created = repo.insert_ledger_trade(**args)
assert created is False
assert duplicate.execution_venue == "KRX"
assert duplicate.order_venue == "SOR"
def test_duplicate_economic_conflict_is_rejected(repo, seeded_portfolio):
args = {
"portfolio_id": seeded_portfolio,
"order_id": "ord-economic-conflict",
"execution_id": "exec-economic-conflict",
"side": "buy",
"quantity": 1.0,
"price": 10_000.0,
"executed_at": _now(),
}
repo.insert_ledger_trade(**args)
with pytest.raises(LedgerTradeConflictError):
repo.insert_ledger_trade(**{**args, "quantity": 2.0}, order_venue="SOR")
with repo._connect().cursor() as cur:
cur.execute(
"""
SELECT quantity::float8, order_venue
FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
""",
(seeded_portfolio, args["order_id"], args["execution_id"]),
)
assert cur.fetchone() == (1.0, None)
def test_concurrent_conflicting_venue_enrichment_has_no_last_write_wins(
repo, seeded_portfolio
):
args = {
"portfolio_id": seeded_portfolio,
"order_id": "ord-venue-concurrent",
"execution_id": "exec-venue-concurrent",
"side": "buy",
"quantity": 1.0,
"price": 10_000.0,
"executed_at": _now(),
}
repo.insert_ledger_trade(**args)
barrier = Barrier(2)
def enrich(execution_venue: str):
worker_repo = PostgresPortfolioRepository(repo._config)
barrier.wait()
try:
entry, created = worker_repo.insert_ledger_trade(
portfolio_id=seeded_portfolio,
order_id="ord-venue-concurrent",
execution_id="exec-venue-concurrent",
side="buy",
quantity=1.0,
price=10_000.0,
executed_at=args["executed_at"],
execution_venue=execution_venue,
)
return ("ok", created, entry.execution_venue)
except LedgerTradeConflictError:
return ("conflict", False, None)
finally:
worker_repo.close()
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(enrich, ("KRX", "NXT")))
assert sorted(result[0] for result in results) == ["conflict", "ok"]
winner = next(result[2] for result in results if result[0] == "ok")
assert winner in {"KRX", "NXT"}
with repo._connect().cursor() as cur:
cur.execute(
"""
SELECT COUNT(*), MIN(execution_venue), MIN(order_venue)
FROM portfolio_ledger
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
""",
(seeded_portfolio, args["order_id"], args["execution_id"]),
)
assert cur.fetchone() == (1, winner, None)
def test_negative_balance_allowed(repo, seeded_portfolio): def test_negative_balance_allowed(repo, seeded_portfolio):
"""REQ-037 AC-3 + OQ-3: oversell produces negative asset_quantity without rejection.""" """REQ-037 AC-3 + OQ-3: oversell produces negative asset_quantity without rejection."""
entry, created = repo.insert_ledger_trade( entry, created = repo.insert_ledger_trade(
+91
View File
@@ -147,6 +147,97 @@ def test_ledger_trade_duplicate_same_payload() -> None:
assert resp.json()["status"] == "duplicate" assert resp.json()["status"] == "duplicate"
def test_ledger_trade_round_trips_and_forwards_venue_provenance() -> None:
settings = _make_settings()
repo = _make_service_repo(
trade=_make_trade(execution_venue="NXT", order_venue="SOR")
)
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(
_NEW_PATH,
json=_trade_payload(execution_venue="NXT", order_venue="SOR"),
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 201
assert resp.json()["execution_venue"] == "NXT"
assert resp.json()["order_venue"] == "SOR"
assert repo.insert_ledger_trade.call_args.kwargs["execution_venue"] == "NXT"
assert repo.insert_ledger_trade.call_args.kwargs["order_venue"] == "SOR"
def test_ledger_trade_allows_unknown_actual_venue_with_sor_route() -> None:
settings = _make_settings()
repo = _make_service_repo(trade=_make_trade(execution_venue=None, order_venue="SOR"))
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(
_NEW_PATH,
json=_trade_payload(execution_venue=None, order_venue="SOR"),
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 201
assert resp.json()["execution_venue"] is None
assert resp.json()["order_venue"] == "SOR"
def test_ledger_trade_rejects_sor_as_execution_venue() -> None:
settings = _make_settings()
repo = _make_service_repo()
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(
_NEW_PATH,
json=_trade_payload(execution_venue="SOR", order_venue="SOR"),
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 422
repo.insert_ledger_trade.assert_not_called()
def test_ledger_trade_rejects_unknown_request_fields() -> None:
settings = _make_settings()
repo = _make_service_repo()
with _with_repo(repo):
client = _make_client(settings)
resp = client.post(
_NEW_PATH,
json=_trade_payload(broker_ordered_at="2026-05-01T09:59:59+09:00"),
headers={"Authorization": "Bearer svc-token"},
)
assert resp.status_code == 422
repo.insert_ledger_trade.assert_not_called()
def test_ledger_trade_openapi_exposes_nullable_venue_enums() -> None:
settings = _make_settings()
repo = _make_service_repo()
with _with_repo(repo):
client = _make_client(settings)
response = client.get("/openapi.json")
assert response.status_code == 200
properties = response.json()["components"]["schemas"]["LedgerTradeRequest"][
"properties"
]
execution_options = properties["execution_venue"]["anyOf"]
order_options = properties["order_venue"]["anyOf"]
assert {tuple(option.get("enum", ())) for option in execution_options} == {
(),
("KRX", "NXT"),
}
assert {tuple(option.get("enum", ())) for option in order_options} == {
(),
("KRX", "NXT", "SOR"),
}
assert {option.get("type") for option in execution_options} == {"string", "null"}
assert {option.get("type") for option in order_options} == {"string", "null"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# AC-3: same key + different payload → 409 # AC-3: same key + different payload → 409
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1030,6 +1030,8 @@ def test_unified_ledger_includes_corporate_action_fields() -> None:
"evidence_ref": "notice://ca-7", "evidence_ref": "notice://ca-7",
"actor_user_id": _USER_ID, "actor_user_id": _USER_ID,
"recorded_at": "2026-09-02T00:00:00Z", "recorded_at": "2026-09-02T00:00:00Z",
"execution_venue": None,
"order_venue": None,
} }
@@ -1094,6 +1096,7 @@ def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
LedgerEntry( LedgerEntry(
ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker", ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker",
recorded_at=datetime.now(UTC), cash_balance_after=900, asset_quantity_after=12, recorded_at=datetime.now(UTC), cash_balance_after=900, asset_quantity_after=12,
execution_venue="NXT", order_venue="SOR",
) )
) )
first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth()) first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth())
@@ -1101,6 +1104,8 @@ def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
first_item = first.json()["items"][0] first_item = first.json()["items"][0]
assert first_item["source"] == "broker" assert first_item["source"] == "broker"
assert first_item["action"] == "BUY" assert first_item["action"] == "BUY"
assert first_item["execution_venue"] == "NXT"
assert first_item["order_venue"] == "SOR"
assert "side" not in first_item assert "side" not in first_item
assert first.json()["next_cursor"] assert first.json()["next_cursor"]
second = client.get( second = client.get(