65 Commits
Author SHA1 Message Date
Hermes Agent 16257e65a6 chore(release): restore Dockerfile and bump version to 0.5.1 2026-08-30 16:42:13 +09:00
Hermes Agent 4b11e1eeb3 chore(release): bump version to 0.5.0 2026-08-29 22:46:08 +09:00
Hermes Agent 3854aabd0d fix strategy state persistence corrections 2026-08-29 16:11:02 +09:00
Hermes Agent 4699de61cc feat(strategy-state): persist instance-scoped slot maps 2026-08-29 15:11:43 +09:00
Hermes Agent 3f94c19162 docs(proofline): approve line 0009 specification 2026-08-29 14:59:37 +09:00
Hermes Agent 7af6bdcf93 docs(proofline): add strategy state lifecycle discovery 2026-08-29 09:46:47 +09:00
Hermes Agent ddf7d6710b retire GHCR image publication 2026-08-28 21:52:51 +09:00
genichin dd2ae33ee1 Merge pull request #76 from fourblocks2020/fix/v0.4.0-uv-lock-20260826
fix(release): refresh uv lock for v0.4.0
2026-08-26 22:46:57 +09:00
Jinhwan Ko 2363d970c2 fix(release): refresh uv lock for v0.4.0 2026-08-26 13:39:44 +00:00
Hermes Agent 80cc6c0d73 chore(release): bump version to 0.4.0
Publish verified container image / verify (push) Canceled after 0s
Publish verified container image / publish (push) Canceled after 0s
2026-08-26 22:23:11 +09:00
genichin 81624931cf Merge pull request #75 from fourblocks2020/copilot/feat-alerts-notify-reconcile-warned
feat(alerts): emit first `reconcile_warned` intent rejection alarm from `IntentService`
2026-08-26 14:53:25 +09:00
copilot-swe-agent[bot]andgenichin ac3f27241a docs(proofline): restore line-0008 canonical approved artifacts verbatim
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 05:29:56 +00:00
copilot-swe-agent[bot]andgenichin 75406af970 test: make context-failure regressions deterministic and simplify alarm log handling
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 05:07:35 +00:00
copilot-swe-agent[bot]andgenichin 1ca9e60c98 fix(intents): isolate alarm context lookup and sanitize alarm failure logs
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 05:02:19 +00:00
copilot-swe-agent[bot]andgenichin 5dd3d57f86 test: harden reconcile warning alarm coverage and daemon wiring assertion
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 04:21:17 +00:00
copilot-swe-agent[bot]andgenichin a9e88f9770 feat(intents): emit warn alarm on first reconcile_warned rejection
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 04:19:14 +00:00
copilot-swe-agent[bot] 98c38327b2 Initial plan 2026-08-26 04:16:07 +00:00
genichin 2e314232e3 Merge pull request #73 from fourblocks2020/copilot/feat-alerts-publish-reconciliation-quantity-mismat
feat(alerts): publish B/C reconciliation quantity mismatches to trex.alerts with durable deduplication
2026-08-26 10:03:24 +09:00
copilot-swe-agent[bot]andgenichin 21f8fdd4b6 fix(ac-0024): injective C identity via SHA-256 length-prefix digest + PostgreSQL migration/round-trip tests
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 00:38:02 +00:00
copilot-swe-agent[bot]andgenichin c246fca206 fix(ac-0024): add type annotation for notification_store in SnapshotScheduler
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 00:22:15 +00:00
copilot-swe-agent[bot]andgenichin f1864a6aa8 fix(ac-0024): address 4 blockers: NUL byte in C identity, instance_key isolation, clear_warned gate, production wiring
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-26 00:20:09 +00:00
copilot-swe-agent[bot]andgenichin 153a563f6d feat(alerts): publish reconciliation quantity mismatches to trex-notifier (req-0007/ac-0024)
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-25 14:50:29 +00:00
copilot-swe-agent[bot] baae02d2af Initial plan 2026-08-25 14:33:03 +00:00
Hermes Agent 8d0942fb30 docs(proofline): approve issue 64 alert specification 2026-08-25 23:27:41 +09:00
genichin 24a64b55d1 Merge pull request #72 from fourblocks2020/copilot/bug-fix-portfolio-hub-quantity-discrepancy
AC-0023: Block C-case quantity mismatch slots from strategy RPC; fail-closed on warn-store failure
2026-08-25 22:54:17 +09:00
copilot-swe-agent[bot]andgenichin f46c2efd8b AC-0023: fix Ruff F401/F841 in test_slots_handler_quantity_mismatch.py
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-25 13:40:22 +00:00
copilot-swe-agent[bot]andgenichin 49eb20bd51 AC-0023: fail-closed intent warn-store; C-case-only verify clear; clear_failed on partial error
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-25 13:27:03 +00:00
copilot-swe-agent[bot]andgenichin 1179bdfe23 AC-0023: filter C-case quantity mismatch slots from strategy RPC, fail-closed on warn_store failure, symbol-aggregate verify clear
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-25 13:17:06 +00:00
copilot-swe-agent[bot] d9205fe151 Initial plan 2026-08-25 13:06:45 +00:00
genichin d18f098b27 Merge pull request #71 from fourblocks2020/copilot/bug-send-broker-authoritative-holding-quantity
slots RPC: make holding `qty` broker-authoritative with fail-closed availability semantics
2026-08-25 21:34:58 +09:00
copilot-swe-agent[bot]andgenichin d4a968ee1a fix: normalize snapshot read failures to unavailable in slots RPC path
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-25 12:25:39 +00:00
copilot-swe-agent[bot]andgenichin f394f1560d chore: finalize broker qty metadata consistency
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 14:19:26 +00:00
copilot-swe-agent[bot]andgenichin 30bcbe1d12 feat: use broker-authoritative qty in slots RPC holdings
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 14:15:43 +00:00
copilot-swe-agent[bot] 832dcb5fbb Initial plan 2026-08-24 14:08:01 +00:00
genichin 131d694ce4 Merge pull request #70 from fourblocks2020/copilot/feat-alerts-persist-unresolved-skipped-fills 2026-08-24 22:54:07 +09:00
copilot-swe-agent[bot]andgenichin 169f222042 fix: tighten ledger skip CLI auditing
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 13:42:47 +00:00
copilot-swe-agent[bot]andgenichin 48dfc3e76f fix: harden skipped ledger resolution paths
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 13:38:04 +00:00
copilot-swe-agent[bot]andgenichin b88b21b772 fix: harden ledger skip invariant
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 13:24:15 +00:00
copilot-swe-agent[bot]andgenichin 76cde2edd0 feat: persist skipped ledger fills
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 13:23:06 +00:00
copilot-swe-agent[bot] df0258bc36 Initial plan 2026-08-24 13:10:39 +00:00
genichin 231e8e2eec Merge pull request #69 from fourblocks2020/copilot/feat-cli-add-ledger-backfill-command
Add evidence-backed `ledger backfill` operator command
2026-08-24 20:09:05 +09:00
copilot-swe-agent[bot]andgenichin 8f74c6b67d chore: address review feedback
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 10:27:49 +00:00
copilot-swe-agent[bot]andgenichin 3e74d35299 test: validate ledger backfill flow
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 10:25:35 +00:00
copilot-swe-agent[bot]andgenichin e2c4bbba34 feat: add ledger backfill command
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-08-24 10:24:05 +00:00
copilot-swe-agent[bot] f124e9509c Initial plan 2026-08-24 10:16:22 +00:00
Hermes Agent fbfe026a7d docs(proofline): revise SOR unknown venue ledger AC 2026-08-24 10:27:31 +09:00
Hermes Agent 9da8c27e52 fix(ledger): retain SOR fills with unknown venue 2026-08-24 09:47:21 +09:00
Hermes Agent f8ad591f0d docs: formalize issue 61 proofline specification
Publish verified container image / verify (push) Canceled after 0s
Publish verified container image / publish (push) Canceled after 0s
2026-08-19 13:21:16 +09:00
Hermes Agent 43f6fea7ac test: cover null venue SOR normalization 2026-08-19 13:16:14 +09:00
Hermes Agent 64e4e32f9d docs: align venue defaults with SOR contract 2026-08-19 13:12:59 +09:00
Hermes Agent 4975d7166f docs: formalize issue 60 proofline specification 2026-08-19 13:05:02 +09:00
Hermes Agent 359ea098ff fix: enforce actual venue persistence contract 2026-08-19 12:53:56 +09:00
Hermes Agent 7366cadf03 fix: preserve SOR execution venue separately 2026-08-19 12:48:05 +09:00
Hermes Agent 15aeae6930 fix: converge broker failures without retry 2026-08-19 11:29:26 +09:00
Hermes Agent be98965f2c fix(ci): use canonical migration inventory size 2026-08-15 16:22:46 +09:00
Hermes Agent 7dadc3dd2d ci(release): verify published image selectors 2026-08-15 16:20:57 +09:00
Hermes Agent 03e50dbc26 merge: integrate issue 57 image publication
Publish verified container image / verify (push) Canceled after 0s
Publish verified container image / publish (push) Canceled after 0s
2026-08-14 17:11:23 +09:00
Hermes Agent 5c9c5ca0ca docs(proofline): capture issue 57 specification 2026-08-14 17:04:19 +09:00
Hermes Agent e3fa8243d9 fix(release): complete publication evidence contract 2026-08-14 16:34:43 +09:00
Hermes Agent cb8fcee226 feat(release): require versioned locked publication 2026-08-14 16:30:16 +09:00
Hermes Agent 1a2b500722 docs(proofline): specify issue 58 completion evidence 2026-08-14 15:58:39 +09:00
Hermes Agent 843df1d6f1 fix(execution): fail closed on unresolved orders 2026-08-14 15:29:31 +09:00
Hermes Agent 147873cb5d feat(execution): reconcile timed-out orders safely 2026-08-14 15:15:52 +09:00
Hermes Agent 482b00e54d docs(proofline): confirm order timeout reconciliation discovery 2026-08-14 14:46:27 +09:00
Hermes Agent 59e75c369f docs(proofline): draft order timeout reconciliation discovery 2026-08-14 14:46:06 +09:00
136 changed files with 9482 additions and 2618 deletions
-130
View File
@@ -1,130 +0,0 @@
name: Publish verified container image
on:
push:
branches:
- main
tags:
- "v*"
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out exact source commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install repository checks
run: |
python -m pip install "uv==0.11.14"
uv sync --extra dev --locked
- name: Run repository lint
run: uv run ruff check .
- name: Run repository tests
run: uv run pytest
- name: Create excluded synthetic canary
run: |
python - <<'PY'
from pathlib import Path
Path(".env.ci-canary").write_text("TREX_SECRET_CANARY_DO_NOT_SHIP\n", encoding="ascii")
PY
- name: Build source image once
run: |
docker build \
--provenance=false \
--build-arg "SOURCE_COMMIT=${GITHUB_SHA}" \
--tag "trex-core-ci:${GITHUB_SHA}" \
.
- name: Smoke built image
run: python scripts/image_release.py smoke --image "trex-core-ci:${GITHUB_SHA}"
- name: Record tested image ID
run: |
python scripts/image_release.py record-image-id \
--image "trex-core-ci:${GITHUB_SHA}" \
--output trex-core-image.id
- name: Export tested image
run: docker save --output trex-core-image.tar "trex-core-ci:${GITHUB_SHA}"
- name: Scan all canary surfaces
run: |
set +e
python scripts/image_release.py scan \
--image "trex-core-ci:${GITHUB_SHA}" \
--archive trex-core-image.tar \
>canary-scan.stdout 2>canary-scan.stderr
scan_status=$?
set -e
python scripts/image_release.py assert-clean-output \
canary-scan.stdout canary-scan.stderr
exit "${scan_status}"
- name: Record tested archive checksum
run: |
python scripts/image_release.py checksum \
--archive trex-core-image.tar \
--output trex-core-image.tar.sha256
- name: Upload immutable tested image artifact
uses: actions/upload-artifact@v4
with:
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
overwrite: false
retention-days: 7
path: |
trex-core-image.tar
trex-core-image.tar.sha256
trex-core-image.id
scripts/image_release.py
publish:
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.publish.outputs.image }}
digest: ${{ steps.publish.outputs.digest }}
source_commit: ${{ steps.publish.outputs.source_commit }}
steps:
- name: Download tested image artifact
uses: actions/download-artifact@v4
with:
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
path: tested-image
- name: Verify, load, and publish without rebuilding
id: publish
env:
GITHUB_ACTOR: ${{ github.actor }}
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python tested-image/scripts/image_release.py publish \
--archive tested-image/trex-core-image.tar \
--checksum tested-image/trex-core-image.tar.sha256 \
--image-id tested-image/trex-core-image.id \
--local-image "trex-core-ci:${GITHUB_SHA}" \
--commit "${GITHUB_SHA}" \
--ref "${GITHUB_REF}"
+6 -6
View File
@@ -1,16 +1,16 @@
---
id: "ac-0004"
status: active
status: retired
---
# 검증 성공 이미지에 한정된 GHCR 게시
# strict version-tag 검증 성공 이미지에 한정된 GHCR 게시
## Criterion
`main` 또는 `v*` tag push의 정확한 source commit에 대해 repository가 정의한 검증 gate, image build, built-container smoke와 tested-image artifact 생성·전달·checksum·load 검증이 모두 성공한 경우에만 GitHub Actions가 registry login/push 단계에 진입해야 한다. 이 push 전 gate 중 하나라도 실패하면 registry write 없이 fail-closed 해야 한다. push 이후에만 반환되는 digest가 누락되거나 형식이 잘못되면 job은 nonzero로 종료하고 성공 output·summary를 남기지 않아야 하며, 이미 발생한 registry write를 발생하지 않은 것으로 보고해서는 안 된다.
GitHub Actions publication workflow는 strict `vX.Y.Z` tag push만 후보로 받아야 한다. 각 숫자 구성요소는 `0` 또는 leading zero 없는 non-negative decimal integer여야 하며 branch, non-push event, malformed tag, prerelease/build suffix를 거부해야 한다. workflow는 event·ref·40자리 lowercase `${GITHUB_SHA}`를 검증하고 exact source checkout 뒤 실제 `HEAD` 일치를 재검증한 다음, pinned `uv`와 repository `uv.lock`을 사용하는 full Ruff·full pytest, single image build, built-container smoke, tested image ID, archive checksum, canary scan과 immutable artifact handoff가 모두 성공한 경우에만 publish job이 registry credential과 `packages: write`를 사용해야 한다. 이 push 전 gate 중 하나라도 실패하면 registry login/push 없이 fail-closed 해야 한다. push 이후 digest 누락·형식 오류 또는 tag 간 digest 불일치는 nonzero로 종료하고 성공 output·summary를 남기지 않아야 하며, 이미 발생한 registry write를 발생하지 않은 것으로 보고해서는 안 된다. 일반 `main`/branch push는 이 workflow에서 verification이나 publication을 실행하지 않으며 별도 비게시 CI workflow는 이 criterion의 요구사항이 아니다.
## Verification
- 성공 조건: synthetic workflow fixture에서 모든 push 전 gate가 성공한 경우에만 publish job의 login/push가 실행 가능하고, push가 반환한 유효한 digest가 확인된 경우에만 성공 output과 summary가 생성된다.
- 실패·경계 조건: repository 검증, build, smoke, artifact 생성·upload·download, checksum 비교 또는 load 실패는 login/push 전 nonzero로 종료된다. push 후 digest 누락·형식 오류는 job nonzero와 성공 output·summary 부재로 이어진다.
- 검사 방법: workflow parser 기반 구조 테스트와 publication helper의 recording-runner 단위 테스트로 push 전/후 실패 시점을 분리해 검증하고, `publish.needs` 및 `packages: write`의 publish-job 한정을 정적 검사한다. 실제 최초 GHCR 실행은 별도 승인된 `main` 반영 이후 integration/release evidence로 확인한다.
- 성공 조건: synthetic strict tag-push fixture에서 exact checkout 재검증, full repository verification, single build, smoke, tested-artifact 생성·전달·checksum·load 검증을 모두 통과해야 publish job의 login/push가 실행 가능하고, 두 push가 같은 유효 digest를 반환한 뒤에만 성공 evidence가 생성된다.
- 실패·경계 조건: non-push, branch, malformed/leading-zero/prerelease/build tag, invalid source commit, checkout `HEAD` mismatch, repository 검증·build·smoke·artifact·checksum·load 실패는 registry credential/write 전에 nonzero로 종료된다. post-push digest 오류·불일치는 nonzero와 성공 evidence 부재로 이어진다.
- 검사 방법: `tests/release/test_image_contracts.py`, `tests/release/test_image_release.py`의 workflow parser·recording-runner tests로 trigger, guard 순서, exact checkout, `publish.needs`, publish-job-only `packages: write`, registry write 전후 실패 경계를 검증하고 전체 Ruff·pytest를 실행한다. 실제 tag push·GHCR write는 별도 승인된 publication evidence로 확인한다.
+2 -2
View File
@@ -1,6 +1,6 @@
---
id: "ac-0005"
status: active
status: retired
---
# 검증 이미지와 게시 이미지의 동일성
@@ -13,4 +13,4 @@ verify job은 한 번 빌드하여 built-container smoke를 통과한 `trex-core
- 성공 조건: smoke가 성공한 local image를 export한 archive의 SHA-256과 publish job이 download한 archive의 SHA-256이 일치하고, checksum 확인 뒤 load된 image가 GHCR tag의 유일한 원본이 된다.
- 실패·경계 조건: archive 또는 checksum 누락, archive byte 변조, checksum 불일치, load 실패, publish job의 checkout·build 또는 별도 source-based image 생성은 registry login/push 전 nonzero로 종료된다.
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
+6 -6
View File
@@ -1,16 +1,16 @@
---
id: "ac-0006"
status: active
status: retired
---
# stable SHA tag와 immutable digest 추적성
# requester version tag·source tag와 immutable digest 추적성
## Criterion
모든 성공 게시 이미지는 전체 40자리 lowercase hexadecimal source commit에서 결정적으로 파생된 앞 12자리 lowercase SHA tag와 전체 commit 값을 담은 `org.opencontainers.image.revision` label을 가져야 한다. SHA tag는 source를 찾기 위한 stable selector이며 배포·rollback의 불변 정본은 registry가 반환한 `sha256` digest다. `v*` tag event에서는 같은 loaded image에 해당 Git tag를 추가하지만 이동 가능한 `latest` tag는 게시하지 않아야 하며, workflow output 또는 summary는 GHCR image reference, 64-hex digest와 전체 source commit을 함께 기록해야 한다.
모든 성공 게시 이미지는 전체 40자리 lowercase hexadecimal source commit에서 결정적으로 파생된 앞 12자리 lowercase SHA tag, requester가 선택한 strict `vX.Y.Z` version tag와 전체 commit 값을 담은 `org.opencontainers.image.revision` label을 가져야 한다. version tag는 container publication authority이며 `pyproject.toml` package version과 독립적으로 취급한다. source tag와 version tag는 별도 build 없이 동일하게 검증·load된 image에 부여하고 registry가 반환한 동일 `sha256` digest를 가리켜야 한다. 이동 가능한 `latest` tag는 게시하지 않아야 하며, 성공 output·summary는 immutable GHCR image reference, 64-hex digest, 전체 source commit과 selected version tag를 함께 기록해야 한다. source tag는 source를 찾기 위한 stable selector이고 배포·rollback의 불변 정본은 registry digest다. 이미 게시된 source tag, version tag와 digest는 이 publication flow가 삭제·이동·재지정하지 않아야 한다. operator documentation은 Git version tag가 container publication authority이고 package metadata version과 독립임을 설명하며, version 선택과 tag 생성·push가 구현·검증과 분리된 requester-approved external write임을 명시해야 한다.
## Verification
- 성공 조건: `main` event는 source commit 앞 12자리 SHA tag를, `v*` event는 같은 loaded image의 SHA tag와 release tag를 생성한다. OCI revision과 summary/output의 image reference·`sha256` digest·전체 source commit이 해당 실행 입력 및 push 결과와 일치한다.
- 실패·경계 조건: source commit 형식 오류, SHA tag 파생 불일치, 빈/형식 오류 digest, source commit 불일치, release tag용 별도 build, `latest` tag 생성 또는 OCI revision 누락은 성공 output·summary를 만들 수 없다. digest 오류가 push 이후 발견되면 job은 nonzero로 종료한다.
- 검사 방법: synthetic `main`/`v*` event와 유효·무효 commit/digest를 publication helper에 입력하는 단위 테스트, Docker image label inspection, workflow의 tag/summary 정적 검사 및 `latest` 생성 금지 회귀 검사를 실행한다.
- 성공 조건: strict `vX.Y.Z` tag event는 같은 loaded image에 `:<source-commit-first-12>`와 `:vX.Y.Z`를 만들고 두 push가 동일한 digest를 반환한다. OCI revision과 output·summary의 immutable image reference, digest, source commit, version tag가 event와 push 결과에 정확히 일치하며 package version 값은 tag 생성·검증에 사용되지 않는다. 운영 문서는 version authority·package version 독립성·별도 승인 tag push 경계를 모두 설명하고 기존 published identity를 보존한다.
- 실패·경계 조건: source commit 또는 version tag 형식 오류, SHA tag 파생 불일치, version/package version 일치 강제, tag별 별도 build, `latest` 생성, OCI revision 불일치, digest 누락·형식 오류·tag 간 불일치가 있으면 성공 evidence를 생성할 수 없다. 기존 published tag·digest의 삭제·이동·재지정 또는 operator documentation의 version authority·별도 승인 경계 누락도 FAIL이다. push 이후 오류는 job nonzero로 유지한다.
- 검사 방법: `scripts/image_release.py`의 trigger/tag/publish helper 단위 테스트와 workflow contract test로 deterministic tag, same-loaded-image, no rebuild, same digest, no-`latest`와 output·summary exactness를 검증하고 Docker image label을 inspect한다. `docs/operations.md` contract test로 Git tag authority, package version 독립성과 requester-approved tag creation/push 경계를 검사하고, workflow·helper가 기존 published registry identity를 삭제·이동·재지정하는 command를 포함하지 않는지 정적 검사한다. 실제 registry의 두 tag·digest와 기존 identity 보존은 별도 승인된 publication run에서 immutable evidence로 확인한다.
+2 -2
View File
@@ -1,6 +1,6 @@
---
id: "ac-0007"
status: active
status: retired
---
# built-container package 및 entrypoint smoke
@@ -13,4 +13,4 @@ GHCR 게시 후보는 source tree가 아니라 실제로 빌드된 image 안에
- 성공 조건: network가 차단되고 운영 config·credential이 없는 최소권한 container에서 기존 네 probe와 installed migration inventory probe가 각각 정해진 timeout 안에 exit code `0`으로 끝나며 container가 정리된다. 현재 회귀 기준선에는 canonical SQL 파일 20개가 있으나, 영구 성공 조건은 고정 수량이 아니라 해당 source commit의 non-empty exact inventory parity이다.
- 실패·경계 조건: package import 실패, entrypoint 누락, installed migration inventory의 empty·누락·추가·filename mismatch, nonzero exit, timeout, 외부 연결 시도 또는 cleanup 실패는 smoke nonzero로 이어지고 image publish를 차단한다. `trex-core-reset` probe에는 `--yes` 또는 config argument를 전달하지 않는다.
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
+5 -5
View File
@@ -3,14 +3,14 @@ id: "ac-0008"
status: active
---
# 운영 설정과 자격증명의 이미지 비포함
# locked runtime build input과 운영 설정·자격증명의 이미지 비포함
## Criterion
`trex-core` Dockerfile은 package 설치에 필요한 `pyproject.toml`과 `trex_core/`만 application source로 copy하고 repository 전체를 copy하지 않아야 한다. `.dockerignore`는 최소한 `.git`, `.env`, `.env.*`, `config/`와 `.proofline/`을 build context에서 제외해야 한다. image filesystem·history·inspect metadata와 export archive에는 운영 per-instance TOML, `.env`, credential, token, private key, 실제 connection string 또는 synthetic canary marker가 없어야 한다. daemon의 필수 config와 secret은 runtime에 read-only mount, secret 또는 environment로만 주입하고, 문서는 digest pin과 이전 digest rollback을 안내하며 `trex-core-reset`을 자동 rollback 절차에 포함하지 않아야 한다.
`trex-core` Dockerfile은 runtime dependency 설치에 필요한 pinned `uv` tool, `pyproject.toml`, repository-owned `uv.lock`과 allowlisted `trex_core/` package source만 application build input으로 copy하고 repository 전체를 copy하지 않아야 한다. runtime environment는 lockfile을 실제 소비하는 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 명령으로 생성해야 한다. `.dockerignore`는 최소한 `.git`, `.env`, `.env.*`, `config/`와 `.proofline/`을 build context에서 제외해야 한다. image filesystem·history·inspect metadata와 export archive에는 운영 per-instance TOML, `.env`, credential, token, private key, 실제 connection string 또는 synthetic canary marker가 없어야 한다. 필수 config와 secret은 runtime에 read-only mount, secret 또는 environment로만 주입하고, 문서는 digest pin과 이전 digest rollback을 안내하며 `trex-core-reset`을 자동 rollback 절차에 포함하지 않아야 한다.
## Verification
- 성공 조건: 임시 build context의 제외 대상 `.env.ci-canary`에 고정 marker `TREX_SECRET_CANARY_DO_NOT_SHIP`을 둔 상태로 이미지를 빌드해도 marker가 image filesystem, `docker history --no-trunc`, `docker image inspect` 결과와 exported image archive 어디에서도 발견되지 않는다. Dockerfile에는 `COPY .`가 없고 runtime 문서는 외부 주입과 digest rollback 경계를 설명한다.
- 실패·경계 조건: 필수 ignore path 누락, repository 전체 copy, synthetic canary 또는 금지 파일·값이 검사 surface에 존재하면 FAIL이다. helper의 locally captured stdout/stderr에 canary가 노출되거나 rollback 안내가 DB reset·자동 data mutation을 실행하도록 제시해도 FAIL이다.
- 검사 방법: Dockerfile·`.dockerignore` contract test, synthetic canary가 있는 임시 context의 실제 image build, filesystem/history/inspect/export archive scan, helper의 local output 검사와 runtime documentation contract test를 실행한다. 실제 credential이나 실제 운영 connection string은 fixture·명령·evidence에 사용하지 않는다.
- 성공 조건: Dockerfile의 copy/install allowlist에 pinned `uv`, `pyproject.toml`, `uv.lock`, package source만 존재하고 locked runtime sync가 성공한다. 제외 대상 `.env.ci-canary`에 고정 marker `TREX_SECRET_CANARY_DO_NOT_SHIP`을 둔 build에서도 marker가 filesystem, `docker history --no-trunc`, `docker image inspect`와 exported archive 어디에도 없으며 runtime 문서는 외부 주입과 digest rollback 경계를 설명한다.
- 실패·경계 조건: missing/mismatched lockfile, unlocked `pip install .` 등 lock 우회, 필수 ignore 누락, broad `COPY .`, synthetic canary 또는 금지 파일·값의 검사 surface 노출은 FAIL이다. helper stdout/stderr에 canary가 노출되거나 rollback 안내가 DB reset·자동 data mutation을 포함해도 FAIL이다.
- 검사 방법: `tests/release/test_image_contracts.py`의 Dockerfile·`.dockerignore`·documentation contract tests, 의도적 `pyproject.toml`/`uv.lock` mismatch build, synthetic canary가 있는 실제 image build, filesystem/history/inspect/export archive scan과 helper local-output 검사를 실행한다. 실제 credential이나 운영 connection string은 fixture·명령·evidence에 사용하지 않는다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0009"
status: active
---
# 주문 timeout 기본값과 입력 검증
## Criterion
매수·매도 intent가 `timeout_sec`를 생략하거나 `null`로 전달하면 인스턴스의 `default_order_timeout_sec`를 사용하고, 인스턴스 기본값도 없으면 hard fallback `3600`초를 effective timeout으로 저장해야 한다. 전략 timeout과 인스턴스 기본값은 bool·문자열·실수를 포함한 비정수 값을 허용하지 않는 strict integer `1..3600` 범위여야 한다. 전략 입력이 이 계약을 위반하면 clamp 또는 주문 제출 없이 `bad_request`로 거부하고, 인스턴스 설정이 위반하면 daemon이 주문 실행 전에 설정 오류로 시작에 실패해야 한다. 경계값 `1`과 `3600`은 매수·매도 모두 허용해야 한다.
## Verification
- 성공 조건: 매수·매도에서 생략과 `null`이 instance override 또는 `3600`으로 저장되고, explicit integer `1`, 중간값과 `3600`이 그대로 승인된다.
- 실패·경계 조건: bool, 문자열, 실수, `0`, 음수와 `3601` 이상은 묵시적 변환·clamp·slot admission·broker submission 없이 거부되며, 잘못된 daemon 설정은 startup config error가 된다.
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`의 direct/wire/config 경계 테스트와 `tests/application/test_execution_policy_handler.py`, `tests/application/test_execution_policy.py`의 매수·매도 회귀를 실행한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0010"
status: active
---
# 원주문 timeout 기준점과 주문일 영속성
## Criterion
주문 dispatch 시 effective `timeout_sec`, immutable UTC `order_submitted_at`과 그 시각에서 산출한 KST `order_date`를 슬롯에 원자적으로 영속화해야 한다. 일반 `updated_at` 변경, polling, cancel bookkeeping과 broker order ID resolution은 이 anchor를 변경하지 않아야 하며 terminal 전이에서만 정리해야 한다. daemon 재시작과 KST 날짜 rollover 뒤에도 저장된 제출 시각으로 동일 deadline을 계산하고 저장된 원주문 날짜로 KIS 주문을 조회해야 한다. migration 이전 legacy pending row의 원주문 metadata가 없으면 `created_at`·`updated_at` 또는 현재 날짜로 추정하지 않고 fail-closed unresolved로 남겨야 하며, in-memory와 PostgreSQL 저장소는 같은 column·CAS·보존 동작을 제공해야 한다.
## Verification
- 성공 조건: dispatch, cancel outcome, `replace_dispatch`, 반복 poll과 repository 재생성 뒤에도 anchor가 동일하고, 다음 KST 날짜의 poll도 원주문 날짜를 조회한다. terminal 전이에서는 anchor와 cancel metadata가 계약대로 정리된다.
- 실패·경계 조건: migration이 legacy row에 날짜를 backfill하거나 metadata 없는 일반 주문이 현재 날짜 KIS 조회·terminal 전이·cancel·ledger 기록을 수행하면 FAIL이다. mutable timestamp가 deadline 또는 조회일을 이동시켜도 FAIL이다.
- 검사 방법: migration SQL discovery, `tests/application/test_order_timeout_reconciliation.py`의 migration·restart·rollover·legacy adversarial tests, `tests/adapters/persistence/test_postgresql_slot_repository_parity.py`와 PostgreSQL integration test를 실행한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0011"
status: active
---
# timeout 취소와 terminal 주문상태 권위
## Criterion
주문의 effective timeout 도달은 슬롯을 강제 terminal state로 전환하는 조건이 아니라 취소/reconciliation을 시작하는 조건이어야 한다. 실행 루프는 취소 전에 원주문 날짜로 최신 주문 상태를 조회하고, 이미 전량체결·전량취소·terminal reject가 확인되면 해당 terminal 경로를 우선 적용해야 한다. cancel API의 `REQUESTED`, `REJECTED`, `UNKNOWN`은 모두 nonterminal이며, 슬롯의 최종 상태와 체결수량은 KIS 주문조회 terminal evidence 및 broker 잔고에 의해서만 확정해야 한다. 주문 row 없음은 bounded visibility grace 또는 그 이후 unresolved로 구분하되 어느 경우에도 row 부재만으로 terminal 상태를 추론하지 않아야 한다.
## Verification
- 성공 조건: timeout 이전에는 cancel이 없고, timeout 이후 nonterminal 주문에만 cancel을 요청한다. cancel과 전량체결 경합에서는 체결 경로가 우선하며 cancel 응답만으로 슬롯이 해제되지 않는다.
- 실패·경계 조건: 로컬 timeout, cancel `REQUESTED/REJECTED/UNKNOWN`, HTTP transport 결과, KIS message code 또는 주문 row 부재가 단독으로 슬롯 terminal 전이·보유량 0·ledger 체결을 만들면 FAIL이다.
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`, `tests/application/test_execution_cancel.py`, `tests/adapters/broker/test_kis_cancel.py`와 KIS submission recovery virtual integration을 실행하여 poll-before-cancel, original-date authority, visibility grace와 full-fill race를 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0012"
status: active
---
# 미확정 취소의 fail-closed 재조정과 경보
## Criterion
cancel `REQUESTED`, `REJECTED`, `UNKNOWN` 또는 visibility grace 이후 terminal 미확정 상태에서는 슬롯을 pending/reconciliation으로 유지하고 daemon 재시작 뒤에도 authoritative polling을 계속해야 한다. `REQUESTED`는 blind retry하지 않아야 하며, `REJECTED` 또는 `UNKNOWN` 재시도는 KIS 조회가 주문을 여전히 open으로 확인한 경우에만 CAS 보호된 최대 시도 횟수와 bounded exponential backoff 안에서 허용해야 한다. `REJECTED`·`UNKNOWN`이 terminal 미확정이거나, `REQUESTED` 후 authoritative poll 결과가 `unknown` 또는 `order_not_visible_unresolved`이면 CRITICAL alarm으로 알려야 한다. 동일 프로세스의 같은 slot·state·attempt alarm은 중복 폭주를 막고 새 프로세스에서는 재발행할 수 있어야 한다. alarm은 allowlist metadata만 포함하고 raw KIS payload, exception·credential·token·connection string을 포함하지 않아야 한다.
## Verification
- 성공 조건: rejected/unknown이 pending을 유지하고 CRITICAL alarm을 발행하며, open-confirmed evidence와 backoff·attempt 조건을 모두 만족한 경우에만 한정 재시도가 일어난다. requested 후 `unknown` 또는 `order_not_visible_unresolved` 반복 poll의 alarm은 프로세스 내 1회로 dedupe되고 restart 모사에서는 재발행된다. requested 상태에서 주문이 open으로 확인된 경우에는 pending과 no-retry를 유지하되 unresolved alarm을 요구하지 않는다.
- 실패·경계 조건: open evidence 없는 accepted/missing/unknown에서 추가 cancel이 발생하거나, requested를 재시도하거나, 최대 시도·CAS를 우회하거나, alarm에 raw payload·민감 fixture가 포함되거나, 미확정 주문의 슬롯이 재사용 가능 상태가 되면 FAIL이다.
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`의 requested dedupe/restart 및 rejected/unknown open-confirmed bounded retry tests와 alarm emitter contract를 실행하고 rendered log·alarm fields를 점검한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0013"
status: active
---
# 장마감 자동취소와 체결 경합의 슬롯·원장 정합성
## Criterion
KIS 주문조회가 장마감 자동취소의 terminal evidence를 제공하면 무체결 초기 매수는 `PENDING_BUY → INACTIVE`, 무체결 증액 매수는 broker 기존 보유량 기준 `PENDING_BUY → HOLDING`으로 전환해야 한다. 매수 부분체결 후 잔량취소는 체결분을 deterministic execution identity로 ledger에 exactly-once 기록하고 broker 잔고 기준으로 `HOLDING`을 확정해야 한다. 매도 무체결 또는 부분체결 후 잔량취소는 broker 잔고에 따라 `HOLDING` 또는 기존 terminal sell 경계를 적용해야 하며, 취소 flag와 전량체결이 경합하면 전량체결 경로를 우선해야 한다. 반복 polling·outbox 재처리·daemon 재시작은 슬롯수량이나 ledger 체결을 중복시키지 않아야 한다.
## Verification
- 성공 조건: 초기/증액 매수 무체결, 매수·매도 부분체결, 전량체결 race가 각각 정해진 슬롯 상태·broker-authoritative 수량·ledger 결과를 만들고 동일 execution의 반복 처리에도 ledger item은 하나다.
- 실패·경계 조건: 부분체결을 전량취소로 소실하거나, 전량체결을 취소로 rollback하거나, 로컬 추정수량을 broker 잔고보다 우선하거나, 동일 체결을 중복 ledger 기록하거나, terminal evidence 없이 상태를 확정하면 FAIL이다.
- 검사 방법: `tests/application/test_execution_cancel.py`, `tests/application/test_execution_ledger.py`, `tests/adapters/broker/test_kis_cancel.py`와 `tests/application/test_order_timeout_reconciliation.py`를 실행하여 zero/partial/full race, broker balance authority와 deterministic ledger idempotency를 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0014"
status: active
---
# repository-owned lockfile 기반 runtime dependency identity
## Criterion
repository verification environment와 published container의 runtime environment는 같은 revision의 repository-owned `uv.lock` dependency resolution을 사용해야 한다. version-tag verification은 pinned `uv`로 `uv sync --extra dev --locked`를 수행해야 하고, Docker runtime stage는 같은 `pyproject.toml`과 `uv.lock`을 입력으로 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 runtime-only locked installation을 수행해야 한다. lockfile이 없거나 project metadata와 불일치하거나 install command가 lock resolution을 우회하면 image build와 publication은 fail-closed 해야 한다. Git publication tag와 Python package metadata version의 독립성은 이 lock identity를 변경하지 않는다.
## Verification
- 성공 조건: 동일 source revision의 `pyproject.toml`과 `uv.lock`으로 dev verification sync와 runtime-only image sync가 모두 성공하며 built image에서 `trex_core` import, `trex-core-daemon`, `trex-core`, `trex-core-reset` CLI와 canonical SQL migration inventory가 network 없이 동작한다.
- 실패·경계 조건: missing lockfile, project metadata/lock mismatch, `--locked` 누락, runtime dependency를 새로 resolve하는 install, dev dependency의 runtime 포함 또는 editable install은 build/publication 전에 FAIL이다. tag version과 package version 불일치 자체는 실패 조건이 아니다.
- 검사 방법: workflow·Dockerfile contract tests로 pinned `uv`와 두 locked sync command를 검사하고, 정상 lockfile의 실제 Docker build·built-container smoke와 의도적으로 불일치시킨 `pyproject.toml`/`uv.lock` build failure를 실행한다. release 집중 테스트와 full repository regression을 함께 실행한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0015"
status: active
---
# 명시적 sell 미수락·미체결 상태 수렴
## Criterion
KIS submission 또는 authoritative order lookup이 sell 주문의 미수락·미체결을 명시적으로 확인하면 core는 실패 evidence를 기록하고 해당 dispatch를 정리하며 새 broker attempt를 생성하지 않고 slot을 broker-authoritative 보유수량의 `holding`으로 수렴시켜야 한다. 이 경로는 로컬 추정수량으로 보유량을 변경하거나 strategy에 broker retry 판단을 요구해서는 안 된다.
## Verification
- 성공 조건: `pending_sell`에서 명시적 rejection 또는 lookup-confirmed absence가 발생하면 기존 보유수량을 유지한 `holding`으로 전이하고 dispatch metadata가 정리되며 broker submit 호출은 한 번뿐이다.
- 실패·경계 조건: 체결 가능성이 남은 unknown outcome을 rejection으로 취급하거나, 수량을 0으로 만들거나, 자동 새 주문을 제출하거나, broker-authoritative 수량과 다른 값으로 수렴하면 FAIL이다.
- 검사 방법: `tests/application/test_execution_policy.py`, `tests/application/test_order_timeout_reconciliation.py`와 관련 repository transition tests에서 상태·수량·dispatch·broker call count를 단언하고 전체 회귀를 실행한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0016"
status: active
---
# 결과 불명확 submission의 중복 제출 방지와 reconciliation
## Criterion
주문 submission timeout, 연결 단절, 응답 누락 또는 응답 해석 실패로 broker acceptance 여부가 불명확하면 core는 original dispatch identity와 결과 불명확 sentinel을 durable하게 보존하고 새 broker order를 제출하지 않아야 한다. daemon 재시작과 반복 tick에서도 original order date·client identity를 사용한 broker lookup과 reconciliation을 먼저 수행하고 authoritative terminal evidence가 생기기 전에는 slot을 재사용 가능한 상태로 전환하지 않아야 한다.
## Verification
- 성공 조건: submit 결과 불명확 후 동일 slot의 반복 tick과 restart 모사에서 broker submit call count가 증가하지 않고 lookup/reconciliation이 실행되며 durable dispatch metadata가 유지된다.
- 실패·경계 조건: timeout 자체로 terminal reject·holding·inactive를 추론하거나, sentinel을 지우거나, 새 broker attempt를 만들거나, 현재 날짜·wall-clock 값으로 original order identity를 대체하면 FAIL이다.
- 검사 방법: `tests/application/test_order_timeout_reconciliation.py`의 timeout, restart, order visibility와 lookup fixture에서 call ordering·dispatch persistence·상태를 검증한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0017"
status: active
---
# strategy·TVR과 broker failure ownership 분리
## Criterion
KIS failure, raw broker error, broker attempt identity, retryability, retry schedule과 retry budget은 `trex-core` 내부 execution/reconciliation 경계를 벗어나 strategy 또는 TVR wire에 노출되어서는 안 된다. core도 broker failure를 retryable/non-retryable로 분류하여 자동 새 주문 attempt를 생성해서는 안 되며 strategy/TVR은 core가 수렴한 slot snapshot만 소비해야 한다. 이후 strategy가 독립적으로 생성한 별도 intent는 core의 자동 retry와 구분한다.
## Verification
- 성공 조건: sell response와 strategy-facing schema에 retryability·KIS error·attempt field가 없고 explicit reject 및 unknown outcome에서 자동 resubmit이 발생하지 않는다.
- 실패·경계 조건: raw KIS payload나 retryable boolean이 strategy/TVR wire에 나타나거나, core가 retry schedule·budget을 산출하거나, 동일 intent에서 새 broker attempt를 자동 생성하면 FAIL이다.
- 검사 방법: `tests/application/test_sell_retryable_flag.py`, messaging handler tests, execution rejection·unknown tests와 schema/document contract 검색을 실행한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0018"
status: active
---
# 주문 venue 기본값·명시값 보존과 AUTO 제거
## Criterion
intent venue가 누락되거나 null이면 core는 original order venue를 `SOR`로 정규화해야 하고 explicit `KRX`, `NXT`, `SOR`는 submit·ack·slot·restart·cancel 전 과정에서 변경 없이 보존해야 한다. `AUTO`는 admission, broker request와 persistence에서 거부해야 하며 기존 persisted AUTO는 migration으로 SOR로 전환해야 한다. core는 wall-clock time이나 session 추정으로 order venue를 KRX/NXT로 재라우팅해서는 안 된다.
## Verification
- 성공 조건: 누락/null→SOR, explicit KRX/NXT/SOR identity 보존, SOR 부분체결 후 잔량 cancel과 restart가 original SOR를 사용하는 흐름이 통과한다.
- 실패·경계 조건: AUTO acceptance·저장, 시간 기반 venue 변환, ack actual venue로 slot order venue 덮어쓰기 또는 restart/cancel에서 original venue 손실은 FAIL이다.
- 검사 방법: `tests/adapters/broker/test_venue.py`, `tests/application/test_venue_intent_flow.py`, migration/drift tests와 KIS request capture를 실행하여 wire·slot·SQL constraint를 단언한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0019"
status: active
---
# 원주문 venue와 실제 체결 venue의 durable 분리
## Criterion
core는 original `order_venue`와 broker-authoritative actual `execution_venue`를 서로 다른 의미로 보존해야 한다. `order_venue`는 KRX/NXT/SOR이며 submit, ack, slot, restart와 cancel에 사용한다. actual venue는 KRX/NXT 또는 미확정 NULL이며 KIS의 검증된 `ORD_EXG_GB`와 명시 exchange-id allowlist에서만 결정하여 fill, ledger와 outbox에 기록한다. 완료된 SOR fill의 actual venue가 확인되지 않으면 core는 `venue=NULL`, `order_venue=SOR`로 정상 ledger outbox work item을 enqueue해야 한다. `SOR`, 누락, 미지원 코드 또는 wall-clock time으로 actual venue를 추론해서는 안 된다. historical outbox SOR는 `order_venue=SOR`, actual NULL로 변환하고 migration replay는 신규 정상 조합을 오염시키지 않아야 한다.
## Verification
- 성공 조건: SOR order의 actual KRX/NXT fill은 `(order_venue=SOR, venue=KRX|NXT)`로 기록되고, actual venue가 확인되지 않은 완료 SOR fill은 `(order_venue=SOR, venue=NULL)`인 durable outbox 1건을 생성해 기존 execution-ID 멱등 `LedgerWriter` 경로로 기록된다. cancel/restart는 SOR를 유지하며, historical SOR는 `(SOR,NULL)`이고 정상 `(order=KRX, actual=NXT)`는 migration 반복 후 보존된다.
- 실패·경계 조건: actual SOR 저장, unknown code를 KRX/NXT로 추론, actual venue로 slot order venue 덮어쓰기, actual venue가 NULL인 완료 SOR fill의 outbox 미생성·원장 유실, AUTO·NULL order 저장 또는 migration replay 오염은 FAIL이다.
- 검사 방법: KIS parser tests와 KIS daily-ccld SOR fixture의 execution→outbox→`LedgerWriter` 회귀·replay idempotency, `tests/adapters/persistence/test_postgresql_ledger_outbox.py`, `tests/adapters/persistence/test_migration_and_drift.py`를 실행한다. 실제 PostgreSQL에서 historical conversion, replay, round-trip과 check violation을 검증하고 전체 회귀·Ruff 및 독립 read-only QC를 수행한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0020"
status: active
---
# 완료됐지만 원장 기록 불가인 fill의 durable skip 감사 기록
## Criterion
core는 완료된 broker fill이 정상 `ledger_outbox` 경로로 들어갈 수 없을 때 WARN 로그만 남기고 끝내서는 안 된다. genuinely non-recordable 사례(예: `portfolio_id` 누락, `execution_id` 누락, 지원하지 않는 actual execution venue)마다 durable `ledger_skip` 감사 레코드 1건을 멱등하게 유지해야 하며, 이 레코드는 최소한 daemon instance, slot ID, portfolio ID, symbol, order ID, execution ID(있으면), side, qty, price, execution time, `order_venue`, `execution_venue` 또는 명시적 unknown 상태, machine-readable `skip_reason`, 생성/갱신/해결 timestamp, resolution state를 포함해야 한다. 이 경로는 broker·slot·portfolio 수량을 자동 보정하거나 mutate하지 않아야 하며, retryable ledger delivery failure는 계속 `ledger_outbox`와 `LedgerWriter` 재시도 의미론을 사용해야 한다.
## Verification
- 성공 조건: completed fill이 `missing_portfolio_id`, `missing_execution_id`, `unsupported_execution_venue` 등으로 정상 원장 기록이 불가능하면 slot transition 이후에도 `ledger_skip`에 동일 fill 식별자의 unresolved row가 남고, 같은 fill을 다시 관찰해도 duplicate row가 생기지 않는다. 운영자는 instance/date/portfolio/resolution state 조건으로 unresolved row를 조회할 수 있다.
- 실패·경계 조건: completed-but-unrecordable fill이 ephemeral log에만 남음, skip row가 retryable delivery failure row를 대체함, skip 처리 중 slot/broker/portfolio 수량이 암묵적으로 바뀜, unknown execution venue alone(SOR actual NULL)는 정상 outbox 경로 대신 skip으로 빠짐은 FAIL이다.
- 검사 방법: execution ledger unit tests, PostgreSQL `ledger_skip` store round-trip/filter/resolve tests, CLI skipped-ledger query tests, targeted Ruff/pytest와 ProofLine validate를 수행한다.
+16
View File
@@ -0,0 +1,16 @@
---
id: "ac-0021"
status: active
---
# skipped-ledger 알림과 해결 이벤트
## Criterion
unresolved `ledger_skip` 레코드는 best-effort·non-blocking 방식으로 deduplicated `trex.alerts` 이벤트를 발행해야 하며, notifier 부재나 transport 오류가 fill lifecycle을 막아서는 안 된다. 동일 unresolved event는 중복 알림 없이 1회만 알리고, 이후 정상 ledger delivery가 성공하거나 operator가 명시적으로 reconcile/resolve 하면 해당 `ledger_skip` 레코드는 resolved 상태로 전이되고 distinct resolved alert event를 발행해야 한다.
## Verification
- 성공 조건: 새로운 unresolved skip 1건은 `trex.alerts`에 `ledger_skip_unresolved` alarm 1건을 만들고, manual backfill 또는 operator resolve 후에는 동일 skip에 대해 `ledger_skip_resolved` alarm이 별도로 발행된다. 알림 publish 실패는 swallow되며 durable state 전이는 유지된다.
- 실패·경계 조건: unresolved skip마다 중복 알림이 반복 발행됨, resolved 전이가 알림 없이 묵살됨, alert publish failure가 fill/backfill/resolve 경로를 abort시킴은 FAIL이다.
- 검사 방법: in-memory alarm transport tests, CLI ledger backfill resolution tests, skipped-ledger resolve command tests, PostgreSQL resolution-state verification을 실행한다.
+19
View File
@@ -0,0 +1,19 @@
---
id: "ac-0022"
status: active
---
# strategy slots RPC broker-authoritative holding quantity
## Criterion
`trex.slots.req.<instance>`의 **전략용 slots 응답에 포함되는** `holding` 슬롯(AC-0023에 따라 C-case 수량 불일치로 제외된 slot 제외)의 `qty`는 요청한 instance에 결합된 KIS account broker holdings(`hldg_qty`)를 authoritative source로 사용해야 한다. `daily_slot.qty`와 portfolio-hub 집계값은 각각 `slot_quantity`, `asset_quantity` 진단 필드로 분리 유지한다. broker 수량을 확보하지 못했거나 freshness 허용시간을 초과하면 core/hub mirror fallback 없이 `qty=null`, `qty_source="unavailable"`, `broker_quantity_available=false`, `broker_quantity_captured_at=null`을 반환해 전략이 fail-closed하도록 해야 한다. confirmed broker zero는 `qty=0`, `qty_source="broker"`로 unavailable과 구분되어야 한다. 이 read path는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다.
**Scope note:** AC-0023에 따라 C-case(`case_code=C`) 수량 불일치로 전략용 slots RPC에서 제외된 slot에는 이 AC의 broker-authoritative `qty` 응답 형식을 적용하지 않는다. AC-0023 제외가 AC-0022 broker-qty 형식보다 우선한다.
## Verification
- 성공 조건: mismatch fixture(`broker=440`, `slot=435`, `hub=435`)에서 holding row가 `qty=440`, `slot_quantity=435`, `asset_quantity=435`, `qty_source="broker"`를 반환한다. broker zero fixture는 `qty=0`으로 유지된다.
- 실패·경계 조건: broker unavailable/stale fixture에서 `qty`가 slot/hub 값으로 대체되면 FAIL이다. 이 경우 `qty=null`과 명시적 unavailable metadata를 반환해야 한다.
- 격리 조건: 다중 instance 테스트에서 각 `trex.slots.req.<instance>`는 해당 instance account snapshot만 참조하며 타 instance broker/snapshot/slot 데이터가 섞이지 않아야 한다.
- 비침습 조건: slots RPC 호출 전후로 slot 상태 전이, broker mutation, ledger write side effect가 없어야 한다.
+25
View File
@@ -0,0 +1,25 @@
---
id: "ac-0023"
status: active
---
# C-case quantity mismatch — strategy RPC exclusion and intent blocking
## Criterion
KIS 계좌 잔고(`hldg_qty`)와 같은 instance가 관리하는 동일 symbol의 portfolio-hub `asset_quantity` 합계가 일치하지 않으면(`case_code=C` reconcile-warning 활성), 해당 symbol의 모든 `holding` slot을 `trex.slots.req.<instance>` 응답에서 제외해야 한다. 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 응답에는 `excluded_slots.reconcile_quantity_mismatch` 필드로 제외된 slot 수를 포함한다. warn-store를 읽지 못하면 경고 유무를 모르는 상태로 slot 목록을 반환하지 않고 `degraded=true` / `reason="warn_store_unavailable"`으로 응답해야 한다(fail-closed). 신규 buy/sell intent는 durable warning을 재확인하여 `reconcile_warned` 사유로 거부한다. C-case 경고 해제는 symbol 단위 합계가 다시 일치한 경우에만 수행하며, 해당 symbol에 걸린 모든 C-case 경고를 함께 해제한다. 이 경로는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다.
## Verification
- 성공 조건: KIS 440주, hub portfolio A 200주 + B 240주(합계 440) → A·B 모두 반환되고 `excluded_slots.reconcile_quantity_mismatch=0`.
- 불일치 조건: KIS 440주, hub A 200주 + B 235주(합계 435) → A·B 모두 제외되고 `excluded_slots.reconcile_quantity_mismatch=2`. 다른 symbol 정상 slot은 같은 응답에 포함된다.
- 단일 portfolio 불일치: hub 단일 portfolio qty != KIS → 해당 symbol 전체 slot 제외.
- fail-closed (slots RPC): warn-store가 `list_c_case_warned()` 중 예외를 throw하면 `slots=[], degraded=true, reason="warn_store_unavailable"` 반환.
- fail-closed (intent): intent 처리 시 `warn_store.is_warned()` 호출이 실패하면 `reconcile_warning_unavailable` 사유로 거부해야 한다 — fail-open 허용 불가.
- intent race: 마지막 slots RPC 이후 경고가 설정된 경우에도 intent 처리 시점의 durable warning을 재확인해 `reconcile_warned`로 거부.
- 해제: symbol 합계가 다시 일치하면 해당 symbol의 모든 **C-case 경고만** 함께 해제. 같은 symbol의 B-case 등 다른 case 경고는 보존해야 한다.
- 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다.
- 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패).
- 시간이 지났다는 이유만으로 자동 해제하지 않음.
- 비침습 조건: slots RPC 호출 전후로 slot 상태 전이, broker mutation, ledger write side effect가 없어야 한다.
- AC-0022 우선순위: C-case로 제외된 slot에는 broker-authoritative `qty` 응답 형식(AC-0022)을 적용하지 않는다.
+18
View File
@@ -0,0 +1,18 @@
---
id: "ac-0024"
status: active
---
# Reconciliation 수량 불일치 탐지·변경·해소 알림
## Criterion
`trex-core`는 B-case(core slot quantity와 broker quantity 불일치)를 stable slot별로, C-case(같은 instance·symbol의 portfolio-hub `asset_quantity` 합계와 broker quantity 불일치)를 `instance_key + symbol`별로 하나의 durable notification lifecycle로 추적해야 한다. 새 mismatch와 동일 identity의 case별 비교 quantity tuple 변경은 sanitized `trex.alerts` detected/changed event를 각각 한 번 발행하고, 성공한 publish fingerprint를 process restart 뒤에도 보존하여 unchanged scheduler tick을 억제해야 한다. B-case는 operator `sync`, C-case는 operator `verify`의 authoritative durable warning clear에서만 resolved event를 한 번 발행해야 하며 scheduler 관찰만으로 recovery를 발행해서는 안 된다. Event는 lifecycle state, `instance_key`, `symbol`, `case_code`, broker quantity와 case별 비교 quantity를 포함하고 account number/hash, credential, token, DSN, raw payload와 exception 본문을 포함해서는 안 된다. Publish 성공 뒤에만 fingerprint를 전진시키고 publish 또는 notification-state persistence 실패는 reconciliation, warning set/clear, sync/verify, scheduler와 slot lifecycle을 중단하거나 바꾸지 않아야 한다. Resolved publish 실패 시 notification state가 writable이면 pending transition을 durable하게 보존하여 후속 retry opportunity에서 재발행한다. Notification-state persistence 자체가 실패하면 authoritative warning clear를 유지하고 event 유실 가능성을 sanitized log로 남긴다.
## Verification
- 성공 조건: 새로운 B-case slot mismatch는 B detected event 한 건, 새로운 C-case symbol aggregate mismatch는 portfolio 수와 무관하게 C detected event 한 건을 만든다. 동일 fingerprint의 반복 tick은 추가 event를 만들지 않고 quantity tuple 변경은 changed event 한 건을 만든다. process restart 모사 뒤 unchanged mismatch는 억제되며, B `sync`와 C aggregate `verify` clear는 각 identity의 resolved event 한 건만 만든다.
- publish 실패 조건: transport failure에서는 detected/changed/resolved fingerprint가 전진하지 않아야 한다. detected/changed는 다음 동일 detection에서 재시도하고, resolved는 notification state가 writable한 경우 warning clear를 유지하면서 pending transition을 durable하게 보존해 후속 retry opportunity에서 재시도해야 한다. Notification-state persistence 실패도 business path를 중단하지 않아야 하며 resolved event 유실 가능성을 credential·DSN·raw exception 없는 log로 남겨야 한다.
- 경계 조건: C-case 동일 symbol의 여러 portfolio/slot이 중복 event를 만들거나, scheduler의 단순 quantity 일치가 durable warning clear·resolved event를 만들거나, B slot 간 fingerprint가 섞이거나, restart 뒤 unchanged event가 재발행되거나, publish 전 fingerprint 기록으로 event가 유실되면 FAIL이다.
- 보안·비변경 조건: 모든 lifecycle event의 allowlist fields와 rendered payload에 account number/hash, credential, token, DSN, raw KIS/portfolio-hub payload 및 exception 본문이 없어야 한다. 알림 전후 broker call mutation, portfolio-hub write, slot lifecycle/quantity mutation과 기존 AC-0023 blocking/clear semantics 변화가 없어야 한다.
- 검사 방법: in-memory alarm transport와 restart 가능한 notification-state fixture로 B/C 최초 탐지, C multi-portfolio aggregation, unchanged suppression, quantity change, restart, publish failure retry, B sync resolution, C verify resolution, duplicate clear와 sensitive-field absence를 검증한다. focused reconciliation·warning·alarm tests, persistence migration/round-trip tests와 전체 repository regression 및 Ruff를 실행한다.
+22
View File
@@ -0,0 +1,22 @@
---
id: "ac-0025"
status: active
---
# 재조정 경고에 따른 전략 주문 요청 최초 거부 알림
## Criterion
`trex-core`는 해당 slot에 해제되지 않은 재조정 경고가 남아 있어 `ReconcileWarningStore.is_warned(slot_id)`가 참을 반환하고, 그 때문에 외부 전략의 새로운 매수 또는 매도 요청을 `status="rejected"`, `reason="reconcile_warned"`로 거부한 경우, 그 결과를 `IntentStore.finalize()`에 저장한 뒤 해당 `intent_id`에 대한 경고를 한 건 발행해야 한다. 경고는 `trex.alerts`로 보내며 `severity="warn"`, `code="strategy_intent_rejected"`를 사용한다. 같은 `intent_id`가 다시 들어오거나 최초 요청을 처리하는 도중 중복 요청이 들어오더라도 경고를 다시 발행해서는 안 된다. 반면, 같은 slot을 대상으로 하더라도 `intent_id`가 다르면 각각 별개의 최초 거부로 보고 경고를 발행해야 한다.
경고에는 `instance_key`, `intent_id`, `intent_type`, `reason="reconcile_warned"`, `broker_submitted=false`를 반드시 포함한다. `slot_id`, `portfolio_id`, `symbol`, `strategy_id`는 신뢰할 수 있는 값이 확인된 경우에만 포함하며, 알 수 없는 값을 추정하거나 원본 요청에서 그대로 복사해서는 안 된다. 매수 요청의 `strategy_id`와 매도 요청의 `sell_strategy_id`는 경고에서 공통 필드인 `strategy_id`로 표현한다.
정상 승인된 요청, `reconcile_warned` 이외의 사유로 거부된 요청, 최종 결과 저장에 실패한 요청, 그리고 safety-sell·kill-switch·watchdog 같은 내부 안전 경로에서는 이 경고를 발행하지 않는다. 경고 발행기가 없거나 부가 정보를 조회하지 못하거나 메시지 전송이 실패하더라도 이미 결정된 거부 결과, RPC 응답, 재조정 경고, slot 상태 및 broker·ledger·portfolio-hub 동작을 중단하거나 변경해서는 안 된다. 경고와 이 처리 경로의 로그에는 계좌 식별정보, credential, token, password, DSN, 원본 요청, 전략의 `extra`, 외부 시스템의 원본 payload 또는 예외 원문을 포함해서는 안 된다.
## Verification
- 정상 동작: 재조정 경고가 설정된 slot에 새로운 매수 요청과 매도 요청을 각각 제출하면, 최종 거부 결과가 저장된 뒤 `strategy_intent_rejected` 경고가 `warn` 등급으로 요청마다 한 건씩 발행된다. 각 경고에는 필수 필드와 `broker_submitted=false`가 들어 있다. 매수의 `strategy_id`와 매도의 `sell_strategy_id`는 값이 있을 때 경고의 `strategy_id`로 나타나며, `slot_id`, `portfolio_id`, `symbol`은 저장소에서 확인된 값만 포함된다.
- 중복 및 제외 조건: 같은 `intent_id`를 같은 프로세스에서 반복하거나 기존 영속 intent 저장소로 서비스를 다시 구성한 뒤 반복해도 경고는 추가로 발행되지 않고 저장된 기존 결과가 그대로 반환된다. 재조정 경고가 설정된 같은 slot에 서로 다른 `intent_id` 두 건을 제출하면 경고는 두 건 발행된다. 최초 요청을 처리하는 도중 들어온 중복 요청과 `duplicate_in_flight`, 정상 승인, `reconcile_warning_unavailable`, `capacity_full`, 입력 검증 실패 등 `reconcile_warned`가 아닌 결과는 경고를 만들지 않는다.
- 처리 순서 및 장애 조건: 호출 순서를 기록하는 테스트용 저장소와 경고 발행기를 사용해 `IntentStore.finalize()`가 경고 발행보다 먼저 완료되는지 확인한다. 최종 결과 저장이 실패하면 경고를 발행하지 않는다. 경고 발행기가 없거나 실패를 반환하거나 전송 계층에 장애가 발생해도 원래의 `reconcile_warned` 거부 결과와 RPC 응답 본문은 유지된다. 이 과정에서 broker 주문 제출·취소, slot 상태 전이, 재조정 경고 설정·해제, ledger 기록 또는 portfolio-hub 쓰기가 추가로 발생하지 않는다.
- 보안 및 호환성: 원본 요청과 `extra`에 credential·token·account·DSN·exception 식별용 값을 넣더라도 경고 제목, 필드, 직렬화된 전송 내용과 출력 로그에 해당 값이나 금지된 key가 나타나지 않는다. 부가 정보를 읽지 못하거나 값이 없으면 해당 선택 필드만 생략하고 필수 필드와 거부 결과는 유지한다. 운영 daemon의 `IntentService`에는 기존 공용 `AlarmEmitter`가 연결되며, 경고 발행기 없이 `IntentService`를 직접 생성하는 기존 사용 방식도 계속 동작한다.
- 검사 방법: `tests/application/test_reconcile_warning_blocking.py` 또는 별도의 intent 경고 테스트에 매수·매도, 같은 `intent_id`와 다른 `intent_id`, 재시작을 모사할 수 있는 intent 저장소, 처리 순서, 발행 실패, 경고 필드 허용 목록과 부작용 없음 조건을 추가한다. 이어서 `tests/application/test_messaging_intent_consumer.py`, `tests/application/test_alarm_emitter.py`, daemon 연결 테스트, AC-0023·AC-0024 관련 회귀 테스트, 전체 저장소 테스트, Ruff, ProofLine validation과 `git diff --check`를 실행한다.
+18
View File
@@ -0,0 +1,18 @@
---
id: "ac-0026"
status: active
---
# Slot occupancy와 독립된 strategy state lifecycle
## Criterion
`trex-core`는 슬롯의 strategy-state map과 각 entry를 slot occupancy lifecycle과 독립적으로 보존해야 한다. Slot이 어떤 경로로든 `INACTIVE`가 되어도 일반 lifecycle transition은 strategy state를 삭제하지 않아야 하며, inactive 상태와 이후 재점유 상태에서 저장된 entry와 version을 다시 읽을 수 있어야 한다. 재점유 뒤 같은 entry에 성공한 write는 close 전 version에 이어서 증가해야 한다. Strategy-state persistence schema, application model, store protocol, read filtering, `strategy_state.set` request/response 및 state CAS에는 `occupancy_seq`나 `expected_occupancy_seq`가 없어야 하며, occupancy 변경만으로 state를 숨기거나 write를 `occupancy_changed`로 거부해서는 안 된다. Slot 자체의 `occupancy_seq`, active-slot write gate, opaque data size limit, slot transition의 나머지 원자성과 별도 파괴적 전체 DB reset은 변경하지 않는다.
## Verification
- 보존 조건: active slot의 instance entry를 version 1 이상으로 저장한 뒤 전략 매도, safety-sell, forced-close 및 operator close를 포함한 각 `→ INACTIVE` 경로를 실행해도 저장소 entry와 version이 삭제되지 않는다. Inactive slot 조회와 이후 재점유 조회에서 같은 data·version이 보이고, 재점유 뒤 기존 version을 `expected_version`으로 사용한 성공 write가 version을 1 증가시킨다.
- 격리 조건: 한 slot의 lifecycle transition이 해당 slot map의 일부 또는 전체 entry를 삭제하거나 다른 slot의 state를 변경하면 FAIL이다. Close-delete hook 제거 전후로 slot update, transition log, broker·ledger side effect와 transition failure 원자성이 기존 계약에서 달라지지 않아야 한다.
- Occupancy 분리 조건: strategy-state migration/schema, dataclass, in-memory/PostgreSQL store contract, read path와 write RPC payload/response에 `occupancy_seq` 또는 `expected_occupancy_seq`가 남거나, occupancy mismatch read filtering 또는 `occupancy_changed` reject path가 실행되면 FAIL이다. Slot domain의 자체 `occupancy_seq`와 증가 규칙은 그대로 유지되어야 한다.
- 경계 조건: inactive slot에 대한 신규 state write는 계속 `slot_inactive`로 거부되고 기존 state는 변경되지 않는다. Size limit 초과 write도 `state_too_large`로 거부되며 보존 중인 entry를 손상시키지 않는다. 명시적 전체 DB reset은 기존 범위의 데이터를 계속 제거할 수 있다.
- 검사 방법: InMemory와 PostgreSQL fixtures에서 close 경로별 보존, inactive read, 재점유, version 연속성, write gate와 size limit parity를 검증한다. 기존 slot transition 회귀 테스트, persistence migration/round-trip 테스트, production daemon wiring, 전체 repository test와 정적 검사를 실행한다.
+19
View File
@@ -0,0 +1,19 @@
---
id: "ac-0027"
status: active
---
# Strategy-instance별 state identity와 version 격리
## Criterion
`trex-core`는 각 slot의 strategy state를 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 하나의 slot-scoped map으로 저장해야 한다. `strategy_instance_id`는 entry의 저장·조회·CAS key이며 entry에 별도의 `strategy_id` metadata를 저장해서는 안 된다. `strategy_state.set`은 `{slot_id, strategy_instance_id, expected_version, data}`를 받아 required non-empty string instance ID, active slot, opaque data size limit와 해당 entry의 version만 검증해야 한다. Entry가 없을 때 `expected_version=0`인 성공 write는 version 1 entry를 만들고, 이후 성공 write는 그 entry의 version만 1 증가시켜야 한다. 같은 slot의 서로 다른 instance entry와 version은 서로 격리되어야 하며, 서로 다른 entry에 대한 동시 성공 write는 어느 entry도 유실하지 않고 모두 보존해야 한다. 기존 state에 대한 `strategy_id → strategy_instance_id` mapping·backfill·귀속 정책과 core-side instance registry를 도입해서는 안 된다.
## Verification
- 저장 형태: 한 slot에 instance A와 B를 저장하면 persistence round-trip 결과가 A·B key 아래 각각 `{version, data, updated_at}`를 보존하고 entry 내부에 `strategy_id`, `occupancy_seq` 또는 다른 identity metadata를 포함하지 않는다. Inner `data`는 입력한 opaque string과 byte 단위로 동일해야 한다.
- Version 조건: 새 A에 `expected_version=0` write는 A version 1을 만들고 후속 성공 write는 A만 증가시킨다. B의 version과 data는 변하지 않는다. 존재하는 A에 `expected_version=0` 또는 stale version을 보내면 `version_conflict`로 거부하고 A·B 어느 entry도 변경하지 않는다.
- 동시성 조건: 같은 slot의 A와 B에 동시에 유효한 write를 수행하면 둘 다 성공하고 두 entry의 새 data와 version이 모두 남아야 한다. 같은 entry에 경쟁하는 write는 version CAS에 따라 최대 한 건만 해당 expected version에서 성공하며 lost update가 없어야 한다.
- Identity 조건: 같은 전략 종류의 서로 다른 `strategy_instance_id`는 독립 entry를 사용하고 다른 ID의 state를 상속하지 않는다. 재시작 뒤 같은 ID를 사용하는 owner는 기존 entry를 이어 사용한다. 누락·비문자열·빈 ID write는 non-retryable `bad_request`로 거부되어 map을 만들거나 변경하지 않는다.
- 비변경 조건: active-slot gate와 data size limit를 우회하지 않으며 `slot_inactive`·`state_too_large`·`bad_request:*` 거부가 다른 entry를 변경하지 않는다. `correlation_id` 재사용만으로 write가 deduplicate되어서는 안 된다.
- 검사 방법: InMemory/PostgreSQL 공통 contract suite, 실제 PostgreSQL row-lock 또는 동등한 원자적 update fixture, migration 및 restart round-trip에서 create/update/conflict, same-type instance 격리, A·B 동시 write, same-entry 경쟁, malformed ID와 다른 entry 무변경을 검증한다.
+19
View File
@@ -0,0 +1,19 @@
---
id: "ac-0028"
status: active
---
# 전략·operator slots RPC의 strategy state 가시성
## Criterion
`trex-core`의 `slots.req`는 전략 요청과 operator 조회의 strategy-state 가시성을 구분해야 한다. `spy=false`인 전략용 요청은 required non-empty string `strategy_instance_id`를 받아 각 반환 slot에서 `data[request.strategy_instance_id]`와 정확히 일치하는 entry 하나만 `strategy_state: {version, data, updated_at}` 단일 object로 제공해야 한다. 요청 ID에 해당하는 entry가 없으면 `strategy_state` field를 생략하고 다른 instance의 ID·state 또는 전체 map을 대신 반환해서는 안 된다. 누락·비문자열·빈 ID는 RPC-level error나 새 envelope 없이 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`인 non-retryable response를 반환해야 한다. `spy=true`인 operator 요청은 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지하며, 각 반환 slot의 `strategy_state`에 전체 `strategy_instance_id → {version, data, updated_at}` map을 제공해야 한다. Operator 요청에 `strategy_instance_id`가 포함되어도 map을 특정 entry로 제한해서는 안 된다.
## Verification
- 전략 조회: 같은 slot map에 A와 B가 있을 때 `spy=false, strategy_instance_id=A` 요청은 `strategy_state`에 A의 `{version, data, updated_at}`만 반환하고 B의 ID·data와 outer map을 serialized payload 어디에도 포함하지 않는다. B 요청은 B만 반환하며 존재하지 않는 C 요청은 slot row의 `strategy_state`를 생략한다.
- 잘못된 전략 요청: 누락, `null`, boolean, number, collection 및 빈 문자열 ID 각각에서 exact 기존 envelope인 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`을 반환한다. Slot, state 또는 다른 instance ID가 노출되거나 watchdog이 갱신되면 FAIL이다.
- Operator 조회: `strategy_instance_id` 없이 `spy=true`로 기존 `universe`와 `slots` CLI가 성공하고 filter 결과와 watchdog 비갱신을 유지한다. A·B state가 있는 slot row의 `strategy_state`는 A와 B를 각각 `{version, data, updated_at}` entry로 포함한 전체 map이어야 한다. 같은 operator 요청에 A ID를 추가해도 B가 제거되지 않아야 한다.
- 기존 계약 조합: AC-0023에 따라 제외된 slot은 strategy-state 가시성 형식을 적용하기 전에 계속 제외되고 warn-store failure는 기존 fail-closed 결과를 유지한다. 포함되는 holding slot의 broker-authoritative quantity와 unavailable metadata는 AC-0022를 유지하며 state 선택 때문에 바뀌지 않는다.
- Wire 경계: 전략 응답의 단일 `strategy_state` object 안에는 `strategy_instance_id` key나 collection을 두지 않는다. Operator map의 key만 instance ID를 나타내며 각 value에는 identity metadata를 중복 저장하지 않는다. 별도 operator/diagnostic API, core-side instance registry 또는 caller 인증을 새로 요구하지 않는다.
- 검사 방법: slots handler contract tests에서 전략 A/B/absent/malformed 요청과 operator no-ID/ID-present 요청을 serialized response 기준으로 검증한다. Operator CLI, watchdog, reconciliation exclusion, broker quantity, InMemory/PostgreSQL read parity와 production daemon handler wiring 회귀 테스트를 함께 실행한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"next_line_number": 3,
"next_ac_number": 9
"next_line_number": 10,
"next_ac_number": 29
}
+79
View File
@@ -0,0 +1,79 @@
---
id: "dcy-0003"
status: confirmed
---
# 주문 기본 timeout 상한과 장마감 자동취소·부분체결 reconciliation
## Problem
`trex-core`는 전략의 주문 intent가 `timeout_sec`를 생략하면 인스턴스의 `default_order_timeout_sec`를 사용하지만, 인스턴스 설정도 생략된 경우 최종 timeout이 `None`이 되어 주문을 무기한 기다릴 수 있다. 전략 payload와 인스턴스 설정에도 3,600초 상한이 없어, 현재 계약만으로는 모든 매수·매도 주문이 늦어도 60분 안에 취소 또는 취소 후 reconciliation 절차를 시작한다는 안전 경계를 보장하지 못한다.
timeout 후 KIS 취소 요청이 장마감 등으로 거부되더라도 취소 API의 즉시 응답은 최종 주문 상태가 아니다. KIS는 장마감 시 당일 미체결 잔량을 자동 취소할 수 있고 그 전에 일부 또는 전량이 체결될 수 있으므로, `trex-core`는 후속 주문조회에서 전량체결, 무체결 자동취소, 부분체결 후 잔량취소를 구분하여 슬롯과 ledger를 확정해야 한다.
현재 실행 루프와 KIS adapter는 같은 날 주문조회가 `cncl_yn="Y"`를 반환하면 무체결 취소와 부분체결 후 취소를 처리한다. 그러나 KIS 주문조회는 polling 시점의 오늘(KST)을 조회일로 사용하고 주문번호 row를 찾지 못하면 `accepted`로 간주한다. 장마감 결과를 확인하기 전에 날짜가 넘어가거나 daemon이 재시작되면 전날 주문을 다시 찾지 못해 슬롯이 `PENDING_BUY` 또는 `PENDING_SELL`에 고착될 수 있다.
이 Line은 주문 제출 후 최대 3,600초 안에 취소/reconciliation을 시작하는 실행 timeout과, KIS의 authoritative 주문조회로 체결·취소를 확인한 뒤에만 슬롯을 terminal business state로 전환하는 최종 상태 확정을 분리한다. KIS 상태가 불명확할 때 로컬 timeout만으로 슬롯을 임의 해제해서는 안 된다.
## Evidence
- GitHub issue #58 `https://github.com/fourblocks2020/trex-core/issues/58`은 이 문제와 현재 코드 근거를 기록한 Discovery 제안이다. 요청자는 2026-08-14에 OQ 선택을 완료한 뒤 문서 갱신과 Discovery 승인을 명시적으로 지시했다.
- issue #58의 live 본문에서 요청자는 `OQ-001 A`를 선택했다. hard fallback은 3,600초이며 인스턴스 설정은 `1..3600` 범위에서 더 짧게 설정할 수 있어야 한다.
- 같은 본문에서 `OQ-002 A`를 선택했다. 전략 payload의 `timeout_sec <= 0` 또는 `timeout_sec > 3600`은 보정하지 않고 주문 제출 전에 명시적으로 거부한다.
- 같은 본문에서 `OQ-003 A`를 선택했다. KIS terminal evidence가 확보될 때까지 슬롯을 fail-closed pending/reconciliation 상태로 유지하고 CRITICAL alarm을 발생시키며, 원주문 날짜 조회와 재시작 복구를 계속하고 로컬에서 보유량 0을 추론하지 않는다.
- 같은 본문에서 `OQ-004 B`를 선택했다. 기본 timeout과 3,600초 상한은 매수·매도 주문에 공통 적용한다.
- 조사 기준 source revision은 `90b72863a2258f8b8ed4f48a6c84dba32c2f74fd`이며 조사 시 `main`과 `origin/main`은 동기화되어 있었다.
- `trex_core/application/intents.py`의 `IntentService`는 `default_order_timeout_sec: int | None = None`을 허용하고, intent와 config에 값이 모두 없으면 슬롯에 `None`을 저장한다.
- `trex_core/application/messaging_handlers.py`는 매수·매도 payload의 `timeout_sec`를 `_opt_int()`로 파싱하지만 `1..3600` 범위 계약을 적용하지 않는다.
- `trex_core/runtime/config.py`의 `default_order_timeout_sec` 검증은 값이 존재할 때 양수인지만 확인하며 3,600초 상한과 설정 부재 시 hard fallback을 강제하지 않는다.
- `trex_core/application/execution.py::_try_timeout_cancel()`은 timeout 도달 시 `BrokerPort.cancel_order()`를 호출하고 즉시 결과를 영속화한다. `REQUESTED`, `REJECTED`, `UNKNOWN`은 최종 주문 상태가 아니며 이후 `_poll()`의 `poll_fill()` 결과가 terminal authority다. 다만 `slot.cancel_state`가 설정된 뒤에는 추가 취소를 요청하지 않는다.
- `trex_core/adapters/broker/kis.py::cancel_order()`는 KIS `rt_cd != "0"`을 `CancelState.REJECTED`로 매핑하고 `msg_cd`를 reason으로 보존한다. transport·5xx·malformed response는 `UNKNOWN`으로 구분한다.
- `KISBroker.poll_fill()`은 polling 당시의 KST 날짜로 `inquire-daily-ccld`를 호출한다. 해당 주문 row가 없으면 `order_not_visible_in_fill_inquiry` reason과 함께 `accepted`를 반환하며 원주문 날짜를 영속화하거나 전날 주문을 조회하는 경로는 없다.
- `KISBroker._fill_status_from_row()`는 `cncl_yn="Y"`이고 `tot_ccld_qty=0`이면 `cancelled`, `0 < tot_ccld_qty < ord_qty`이면 `cancelled=True`인 `partially_filled`, 전량 체결이면 `filled`로 분류한다.
- `ExecutionService._on_partial_cancel()`은 매수 부분체결분의 deterministic `execution_id`를 사용해 ledger 기록을 만들고 broker 잔고를 authority로 수량을 정한 뒤 `PENDING_BUY → HOLDING`으로 전환한다. 무체결 취소는 초기 매수라면 `PENDING_BUY → INACTIVE`, 증액 매수라면 기존 broker 보유량 기준 `PENDING_BUY → HOLDING`으로 복구한다. 매도도 부분체결·취소 결과를 broker 잔고에 맞춰 처리한다.
- 종료된 issue #21은 무기한 pending 문제와 timeout 취소 기능의 선행 이력이고, 종료된 issue #9는 KIS `cncl_yn` 외부취소 감지의 선행 이력이다. 열린 issue #45는 장 시작 KIS REST timeout과 account snapshot fallback을 다루므로 주문별 timeout·장마감 자동취소 확정과는 범위가 다르다.
- 기준 revision에서 `uv run --extra dev pytest tests/application/test_execution_policy.py tests/application/test_execution_policy_handler.py tests/application/test_execution_cancel.py tests/adapters/broker/test_kis_cancel.py -q -o 'addopts='`를 실행한 결과는 `53 passed in 0.10s`였다. 이는 기존 timeout 취소와 같은 날 외부취소·부분체결 회귀가 통과함을 보여줄 뿐, hard fallback, 3,600초 상한, 장마감 취소 API 오류 또는 날짜 rollover 복구가 이미 구현됐다는 증거는 아니다.
## Scope
- AC admission은 기존 active AC를 변경하지 않고 새로운 장기 동작을 정의하는 `create`다. 현재 active AC-0001~AC-0008은 KIS 잔고 페이지네이션 및 GHCR 게시 경계를 소유하며 주문 timeout·취소 reconciliation을 소유하지 않는다.
- 매수·매도 intent가 `timeout_sec`를 생략하거나 명시적으로 `null`을 전달하면 hard fallback `3600`초를 적용한다. 인스턴스 `default_order_timeout_sec`는 `1..3600`초 범위에서 hard fallback보다 짧거나 같은 기본값을 제공할 수 있으며 설정 자체가 없어도 timeout을 비활성화하지 않는다.
- 전략 payload의 `timeout_sec`는 `1..3600`초만 허용한다. `timeout_sec <= 0`, `timeout_sec > 3600` 또는 정수로 해석할 수 없는 값은 clamp하지 않고 주문 제출 전에 `bad_request` 계열 reason으로 거부한다.
- 인스턴스 `default_order_timeout_sec`가 허용 범위를 벗어나면 daemon 시작 시 설정 오류로 실패하며 주문 실행을 시작하지 않는다.
- effective timeout과 immutable 주문 제출 시각 또는 원주문 날짜를 영속화하여 daemon 재시작 후에도 같은 deadline과 주문일을 사용한다. 일반적인 `updated_at` 변경으로 timeout 기준점이나 주문 조회일이 이동해서는 안 된다.
- timeout 도달은 슬롯의 강제 종료가 아니라 취소/reconciliation 시작 조건이다. 실행 루프는 외부 주문의 최신 상태를 먼저 조회하고 non-terminal인 경우에만 취소를 요청한다.
- 취소 API의 `REQUESTED`, `REJECTED`, `UNKNOWN`은 final cancellation이 아니다. terminal 주문 상태는 원주문 날짜를 사용하는 KIS 주문조회로만 확정한다.
- 취소 응답이 `REJECTED` 또는 `UNKNOWN`이고 terminal 상태를 즉시 확보하지 못하면 슬롯을 fail-closed pending/reconciliation 상태로 유지한다. 주문조회와 재시작 복구를 계속하고 CRITICAL alarm을 발행하며 로컬에서 취소, 미체결 또는 보유량 0을 추론하지 않는다.
- `UNKNOWN` 또는 retryable 취소 결과의 재시도는 주문조회를 먼저 수행하고 broker가 주문을 여전히 open으로 확인한 경우에만 bounded backoff로 허용한다. 응답 유실 가능성이 있는 취소를 blind retry해서는 안 된다.
- 날짜 rollover와 daemon 재시작 후에도 영속화된 원주문 날짜로 KIS 주문 상태를 조회한다. 주문 row 미가시성은 제출 직후의 bounded visibility grace와 그 이후의 unresolved 상태로 구분하며, row가 없다는 사실만으로 terminal 상태를 추론하지 않는다.
- KIS 장마감 자동취소 결과는 다음과 같이 처리한다.
- 무체결 초기 매수 취소: `PENDING_BUY → INACTIVE`
- 무체결 증액 매수 취소: broker 기존 보유량 기준 `PENDING_BUY → HOLDING`
- 매수 부분체결 후 잔량취소: 체결분 ledger exactly-once 기록, broker 잔고 기준 `PENDING_BUY → HOLDING`
- 매도 무체결 또는 부분체결 후 잔량취소: broker 잔고 기준으로 `HOLDING` 또는 기존 terminal sell 경계를 적용
- 취소와 전량체결 경합: 전량체결을 우선하여 정상 체결 경로 적용
- 취소 응답 유실, 취소 API 거부, 장마감 자동취소, 전량·부분·무체결, 날짜 rollover, daemon 재시작, 주문 row 미가시성과 ledger 멱등성을 repository-local synthetic KIS fixture와 application 통합 테스트로 검증한다.
- account, token, credential, connection string과 raw KIS payload 등 민감한 값은 로그·오류·fixture·evidence에 포함하지 않는다.
## Out of Scope
- KIS 거래소 자체의 당일주문 유효기간 또는 장마감 자동취소 정책 변경
- strategy daemon의 매수·매도 조건, 가격 결정, slippage 또는 주문수량 정책 변경
- portfolio-hub API·ledger schema 또는 다른 TREX 서비스의 wire/HTTP 계약 변경
- 운영계좌를 이용한 실제 주문·취소 실행, 운영 데이터 수정 또는 기존 pending 슬롯의 사후 자동 정리
- KIS account snapshot fallback과 장 시작 REST 장애 복구(issue #45 범위)
- terminal broker evidence 없이 별도 유예시간만으로 슬롯을 강제 `INACTIVE` 또는 재사용 가능 상태로 전환하는 정책
- timeout 범위 밖 값을 clamp하여 주문을 계속하는 호환 동작
- 배포, release, 운영환경 설정 변경 또는 delivery/lifecycle 완료 주장
## Risks and Unknowns
### Risks
- timeout 시점에 슬롯을 즉시 해제하면 취소 요청과 실제 체결이 경합하거나 KIS 응답이 유실된 경우 실제 보유량과 슬롯이 어긋날 수 있다. 요청자가 선택한 fail-closed 정책에 따라 terminal broker evidence 전에는 슬롯을 재사용 가능하게 만들지 않고 CRITICAL alarm과 reconciliation을 유지한다.
- 원주문 날짜가 아닌 현재 날짜로 조회하면 장마감 직전 주문이나 재시작된 주문을 다음 날 찾지 못해 무기한 pending이 재발할 수 있다. immutable 주문 제출 시각 또는 원주문 날짜를 영속화하고 조회 기준으로 사용한다.
- 주문조회 row 없음은 제출 직후 eventual consistency와 오래된 주문 누락을 모두 포함할 수 있다. bounded visibility grace 이후에도 row 없음만으로 취소·미체결을 추론하지 않고 unresolved로 유지한다.
- `UNKNOWN` 취소를 맹목적으로 재시도하면 최초 취소가 실제 접수됐지만 응답만 유실된 경우 중복 요청이 발생할 수 있다. 재시도 전 authoritative 조회를 우선하고 open 주문이 확인된 경우에만 bounded backoff를 적용한다.
- 부분체결 후 자동취소를 여러 번 관찰하면 ledger가 중복 기록될 수 있다. deterministic `execution_id`와 기존 outbox/ledger 멱등 경계를 보존하고 반복 polling·재시작 fixture로 검증한다.
- KIS의 실제 장마감 error `msg_cd`와 자동취소 row shape가 synthetic fixture와 다를 수 있다. 특정 message code를 terminal authority로 고정하지 않고 주문조회 결과를 정본으로 사용하며, code-specific retry 분류가 필요하면 민감정보를 제거한 별도 운영 evidence로 검증한다.
- 매수뿐 아니라 매도에도 공통 timeout을 적용하면 기존에 timeout을 생략하던 매도 전략의 동작이 바뀐다. 이 변경은 요청자가 `OQ-004 B`로 선택한 승인 범위이며, 양쪽 side의 기존 명시 timeout과 생략 경로를 독립 회귀 테스트로 보호한다.
@@ -0,0 +1,53 @@
# 구현 완료 evidence
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다.
- Source URL: https://github.com/fourblocks2020/trex-core/issues/58#issuecomment-5290331952
- Comment ID: `5290331952`
- Author: `genichin`
- Created at: `2026-08-14T06:41:15Z`
- Updated at: `2026-08-14T06:41:15Z`
- Original body SHA-256: `10ea8235709eda917a9e64a6434a4f01ac9cd630ea965ce48d6134f0a4e0c22e`
- Captured from GitHub API: `2026-08-14`
## Original Comment Body
<!-- BEGIN ORIGINAL COMMENT BODY -->
## 구현 완료
승인된 #58 범위의 주문 timeout·장마감 자동취소·부분체결 reconciliation 구현과 독립 검증을 완료했습니다.
### 구현 내용
- 매수·매도 주문 timeout 생략 또는 `null` 시 3,600초 fallback 적용
- 전략 및 인스턴스 timeout을 strict integer `1..3600`으로 검증하고 잘못된 값은 주문 제출 전에 거부
- 원주문 제출 시각·KST 주문일·effective timeout을 일반 `updated_at`과 분리하여 영속화하고 재시작·날짜 rollover 후에도 보존
- 취소 `REQUESTED`/`REJECTED`/`UNKNOWN`을 nonterminal로 유지하고 KIS 주문조회를 terminal authority로 사용
- legacy 주문 metadata 미확인 시 현재 날짜를 추정하지 않고 poll·terminal 전이·cancel·ledger 처리를 차단하는 fail-closed 경로 구현
- authoritative open evidence가 있는 `REJECTED`/`UNKNOWN`에만 bounded backoff retry를 허용하고 blind retry 차단
- 무체결·부분체결·전량체결 경합, ledger exactly-once, alarm dedupe 및 restart 재발행 회귀 테스트 보강
- PostgreSQL migration 및 in-memory/PostgreSQL persistence parity, 데이터 모델·interface contract 갱신
### 검증 결과
집중·전체 테스트와 독립 read-only QC를 모두 통과했으며, 신규 차단 finding은 없습니다.
- 최종 HEAD: `843df1d6f1a642282039a57d0de4ca85204d6bee`
- 팀장 focused suite: `114 passed, 1 skipped`
- 독립 QC focused suite: `148 passed, 1 skipped`
- 전체 suite: `1,040 passed, 51 skipped`
- Ruff: `All checks passed!`
- `proofline validate`: exit `0`
- `git diff --check` 및 commit/worktree immutability 확인: 통과
- 독립 QC: **PASS**, `mutation=false`, 신규 finding 없음
- legacy adversarial probe: broker poll·cancel·slot transition·ledger 기록 모두 0, pending 유지 및 sanitized CRITICAL alarm 확인
### 비차단 후속 권고
- `TREX_POSTGRESQL_TEST_DSN`이 준비되면 PostgreSQL migration/repository integration 41건을 실제 DB에서 재실행
- `TREX_RABBITMQ_TEST_URL`이 준비되면 RabbitMQ integration 9건을 재실행
- 안전한 KIS sandbox credential이 준비되면 sandbox integration 1건을 재실행
> 현재 상태는 **격리 브랜치의 로컬 구현·검증·커밋 완료**입니다. push·PR·main 반영·배포·릴리스 완료를 의미하지 않습니다.
<!-- END ORIGINAL COMMENT BODY -->
@@ -0,0 +1,70 @@
# 구현 진행 상황 evidence
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다.
- Source URL: https://github.com/fourblocks2020/trex-core/issues/58#issuecomment-5290005799
- Comment ID: `5290005799`
- Author: `genichin`
- Created at: `2026-08-14T05:55:12Z`
- Updated at: `2026-08-14T06:38:38Z`
- Original body SHA-256: `471c6b3b5a70a1632e6e1d4076014a2cf967ffb45bba8ff3bffe122fee39e037`
- Captured from GitHub API: `2026-08-14`
## Original Comment Body
<!-- BEGIN ORIGINAL COMMENT BODY -->
## 구현 진행 상황
**상태:** 🟢 구현·검증 완료
### 구현 계획 및 작업 목록
- [x] **1. 구현 기준선과 영향 범위 확인**
- 승인된 #58 본문을 변경 범위의 기준으로 고정
- timeout 계산, 주문 제출정보 영속화, KIS 주문조회, 취소상태, alarm 및 migration 경로 확인
- [x] **2. 기본 timeout 및 입력 검증 구현**
- timeout 생략 또는 `null`이면 매수·매도 모두 3,600초 적용
- 전략 payload와 인스턴스 설정은 `1..3600`초만 허용
- 범위 밖 값은 clamp하지 않고 주문 제출 전에 명시적으로 거부
- [x] **3. 원주문 시각·날짜 영속화와 재시작 복구 구현**
- timeout 기준점과 KIS 주문 조회일을 일반 `updated_at`과 분리
- 날짜가 넘어가거나 daemon이 재시작돼도 원주문 날짜로 상태 조회
- [x] **4. 장마감 취소 실패 후 fail-closed reconciliation 구현**
- 취소 API의 `REQUESTED/REJECTED/UNKNOWN`을 terminal 상태로 오인하지 않음
- terminal broker evidence 전까지 슬롯 재사용 금지
- 불명확 상태에서 CRITICAL alarm 발생
- 주문조회로 open 상태를 확인한 경우에만 bounded retry를 허용하고 blind retry 금지
- [x] **5. 자동취소·부분체결·경합 처리 회귀 보강**
- 무체결 자동취소
- 부분체결 후 잔량취소와 ledger exactly-once
- 취소와 전량체결 경합
- 날짜 rollover 및 daemon 재시작
- 주문 row 미가시성
- [x] **6. 검증 및 독립 리뷰**
- 관련 집중 테스트
- 전체 테스트와 lint/정적 검사
- ProofLine repository validation
- 구현자와 분리된 독립 코드 리뷰
### 진행 원칙
- 체크박스는 해당 작업이 실제 완료되고 관련 검증이 통과한 뒤에만 갱신합니다.
- 진행 기록은 이 댓글을 계속 수정하여 누적합니다.
- 승인된 구현 계획의 제품 의미나 범위를 변경해야 하면 구현을 중지하고 사용자 승인을 요청합니다.
- 실제 주문, 운영계좌 변경, 배포, release, main push 또는 PR 생성은 이번 작업에 포함하지 않습니다.
### 진행 기록
- 구현 시작: #58의 확정된 Discovery 범위와 OQ 결정 4건을 기준선으로 채택했습니다.
- 기준선/영향 분석 완료: 기본값 `None`, `updated_at` 기반 timeout, KIS 현재일 조회, cancel-state retry 차단 및 신규 persistence migration 필요성을 확인했습니다.
- 격리 작업공간 생성: `feat/issue-58-order-timeout-reconciliation` (`482b00e` 기준).
- 변경 전 기준선 검증 완료: 전체 테스트 `995 passed, 51 skipped`, Ruff/ProofLine/diff check 통과.
- 1차 구현 인스턴스가 핵심 코드·migration·synthetic test를 작성하고 focused suite `95 passed`를 확인했습니다. 전체 검증·커밋 전 실행 한도에 도달하여 동일 worktree에서 후속 인스턴스로 인계했습니다.
- 후속 검수·보완 완료: strict integer 입력, legacy migration fail-closed, 원주문일 rollover, cancel alarm 중복 억제 및 open-evidence 기반 bounded retry를 반영했고 focused suite `141 passed`를 확인했습니다.
- 회귀 보강 완료: wire/config 경계, migration, 재시작, KIS rollover/row 미가시성, 자동취소 및 기존 partial/full-fill·ledger 경로를 포함한 focused suite `154 passed`.
- 구현 commit 생성: `147873cb5d5280a99872aeca0e9b44942b41b3df`. 팀장 재검증에서 핵심 focused `60 passed`, 전체 `1,038 passed, 51 skipped`, Ruff/ProofLine/diff/show check 통과. 환경 의존 integration 51건은 당시 설정 부재로 skip됐습니다. 1차 독립 QC는 FAIL로 반려됐습니다: legacy row의 현재 날짜 fallback으로 인한 terminal 오인 위험과 `REQUESTED` 후 terminal 미확정 CRITICAL 누락을 보완 중입니다. 최종 검증은 미완료입니다.
- QC 보완 commit `843df1d6f1a642282039a57d0de4ca85204d6bee` 생성: legacy metadata 없는 주문의 현재일 조회·terminal 오인을 차단하고, `REQUESTED` 후 terminal unresolved CRITICAL/dedupe를 추가했습니다. post-fix 팀장 검증은 focused `114 passed, 1 skipped`, 전체 `1,040 passed, 51 skipped`, Ruff/ProofLine/diff/show check 통과. 독립 재-QC 진행 중입니다.
- 독립 재-QC 최종 **PASS** (`mutation=false`): focused `148 passed, 1 skipped`, 전체 `1,040 passed, 51 skipped`, Ruff/ProofLine/diff check 통과. legacy adversarial probe에서 broker poll/cancel/transition/ledger가 모두 0이고 pending 및 sanitized CRITICAL alarm을 확인했습니다. skip은 PostgreSQL 41건(DSN 없음), RabbitMQ 9건(URL 없음), KIS sandbox 1건(credential 없음)입니다. 신규 finding은 없습니다.
- 최종 HEAD `843df1d6f1a642282039a57d0de4ca85204d6bee`, worktree clean. push/PR/main 반영/실제 주문/배포는 수행하지 않았습니다.
<!-- END ORIGINAL COMMENT BODY -->
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0003"
---
+57
View File
@@ -0,0 +1,57 @@
---
id: "req-0003"
status: approved
discovery: "dcy-0003"
criteria:
create:
- "ac-0009"
- "ac-0010"
- "ac-0011"
- "ac-0012"
- "ac-0013"
update: []
retire: []
satisfy: []
---
# 주문 기본 timeout 상한과 장마감 자동취소·부분체결 reconciliation
## Objective
모든 매수·매도 주문에 최대 3,600초의 실행 timeout을 적용하고, timeout 이후에도 KIS의 authoritative 주문 상태가 확인될 때까지 슬롯을 fail-closed로 보호한다. 원주문 시각·주문일을 영속화하여 날짜 rollover와 daemon 재시작 뒤에도 동일 주문을 재조정하며, 장마감 자동취소와 무체결·부분체결·전량체결 경합을 슬롯 및 ledger에 멱등하게 반영한다.
## Scope
- 매수·매도 intent의 timeout 생략 또는 `null`, 인스턴스 기본값 override, 전략·설정 입력 경계와 주문 제출 전 거부를 정의한다.
- effective timeout, immutable 주문 제출 시각과 KST 원주문 날짜를 슬롯 저장소에 영속화하고 in-memory/PostgreSQL 구현 및 migration의 동작을 일치시킨다.
- timeout을 슬롯 terminal 전이가 아닌 취소/reconciliation 시작 조건으로 사용하고, 원주문 날짜를 사용하는 KIS 주문조회를 terminal authority로 유지한다.
- 취소 `REQUESTED`, `REJECTED`, `UNKNOWN`과 주문 row 미가시성을 nonterminal로 처리하며, open 주문 evidence가 있을 때만 제한된 취소 재시도를 허용한다.
- 미확정 주문은 pending 상태로 유지하고 sanitized CRITICAL alarm을 중복 폭주 없이 발행하며, daemon 재시작 뒤 reconciliation을 계속한다.
- KIS 장마감 자동취소의 무체결·부분체결·전량체결 경합을 broker 잔고와 deterministic execution identity에 따라 슬롯 및 ledger에 반영한다.
- repository-local synthetic KIS fixture와 application·adapter·persistence 회귀 테스트로 매수·매도, 날짜 rollover, 재시작, visibility grace, retry와 멱등성을 검증한다.
## Constraints
- timeout은 `1..3600`의 strict integer만 허용하며 범위 밖 또는 다른 타입을 clamp하거나 묵시적으로 변환하지 않는다.
- KIS cancel 응답이나 로컬 시간 경과만으로 체결·취소·보유량 0 또는 슬롯 재사용 가능 상태를 추론하지 않는다.
- legacy pending row에 authoritative 원주문 metadata가 없으면 현재 날짜를 추정하지 않고 네트워크 조회·terminal 전이·cancel·ledger 기록을 차단한다.
- account, token, credential, connection string, raw KIS payload와 exception 본문 등 민감하거나 비허용인 값을 alarm·로그·fixture·evidence에 포함하지 않는다.
- portfolio-hub, strategy daemon 및 다른 TREX 서비스의 wire/HTTP 계약을 변경하지 않는다.
### Current Implementation Assessment
- 이 문서는 이미 구현된 GitHub issue #58을 현재 ProofLine schema에 사후 명세화한 것이다. 이는 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻이 아니다.
- 현재 구현 기준은 local commit `843df1d6f1a642282039a57d0de4ca85204d6bee`이며, issue evidence에는 전체 `1,040 passed, 51 skipped`, 독립 read-only QC `PASS`가 기록되어 있다.
- 환경 의존 PostgreSQL 41건, RabbitMQ 9건, KIS sandbox 1건은 설정·credential 부재로 skip되었다. 이는 active AC의 규범적 상태와 별개인 비차단 검증 공백이다.
- 이 REQ의 `approved`와 AC의 `active`는 현재 specification authority를 나타내며 push, PR, main 반영, deployment, release 또는 formal lifecycle closure를 의미하지 않는다.
## Non-Goals
- KIS 거래소의 주문 유효기간·장마감 자동취소 정책 변경
- 전략의 매수·매도 조건, 가격·slippage·주문수량 정책 변경
- portfolio-hub ledger schema 또는 다른 TREX 서비스 계약 변경
- 운영계좌 실제 주문·취소, 운영 데이터 수정 또는 기존 pending 슬롯의 자동 사후 정리
- issue #45의 KIS account snapshot fallback 및 장 시작 REST 장애 복구
- terminal broker evidence 없는 유예시간 기반 강제 슬롯 해제
- timeout 범위 밖 값을 clamp하는 호환 동작
- push, PR, main 반영, 배포, release 또는 lifecycle closure
+79
View File
@@ -0,0 +1,79 @@
---
id: "dcy-0004"
status: confirmed
---
# requester-versioned·runtime-locked image publication
## Problem
현재 `.github/workflows/publish-image.yml`은 `main`과 `v*` push 모두에서 GHCR publication을 실행한다. 이 때문에 문서·governance-only `main` commit도 새 registry artifact를 만들며, publication을 요청자가 선택한 release version과 분리해서 통제할 수 없다.
repository verification은 `uv sync --extra dev --locked`를 사용하지만 현재 `Dockerfile`은 `pyproject.toml`만 복사한 뒤 `pip install .`을 실행한다. 따라서 CI 검증 환경과 built image의 runtime dependency graph가 동일한 repository-owned `uv.lock` resolution을 사용한다는 보장이 없다.
이 Discovery는 기존의 single build, built-image smoke, image ID, archive SHA-256, canary scan, OCI source revision, rebuild 없는 publish와 multi-tag same-digest 계약을 유지하면서 publication authority를 strict requester-selected version tag로 제한하고 runtime dependency identity를 lockfile에 결속한다.
## Evidence
- GitHub issue #57 `https://github.com/fourblocks2020/trex-core/issues/57`은 requester-versioned·runtime-locked publication 변경과 완료 조건을 기록한 원본 제안이다.
- 요청자는 모든 OQ에서 정확히 하나의 선택지를 선택한 뒤 이슈 본문 갱신과 Discovery 승인을 명시적으로 지시했다.
- 승인 결정에 따라 `vX.Y.Z` Git tag는 container publication version authority이며 `pyproject.toml`의 `[project].version`과 독립적이다. 두 값을 일치시키는 검증이나 package version 변경은 요구하지 않는다.
- 승인 결정에 따라 별도 비게시 CI를 추가하지 않는다. full Ruff와 full pytest는 strict version-tag publication gate에서 실행하고, 일반 `main`/branch push에는 이 Discovery가 별도 CI workflow를 추가하지 않는다.
- 조사 기준 source revision은 `1a2b50072238ea72186d99b3ec951fc9511100aa`이며 조사 시 `main`과 `origin/main`은 동기화되어 있고 worktree는 clean이었다.
- `.github/workflows/publish-image.yml`은 현재 `main`과 `v*` push를 모두 trigger로 사용하고, exact `github.sha` checkout 후 `uv sync --extra dev --locked`, full Ruff, full pytest, single Docker build, built-image smoke, image ID 기록, archive export·canary scan·SHA-256 기록과 immutable artifact handoff를 수행한다. 별도 publish job만 `packages: write`를 가지며 tested artifact를 재빌드 없이 게시한다.
- `Dockerfile`은 현재 `pyproject.toml`과 `trex_core/`만 copy한 뒤 `pip install --no-cache-dir .`을 실행하므로 `uv.lock`을 runtime dependency installation input으로 사용하지 않는다.
- `scripts/image_release.py::release_tags()`는 현재 `refs/heads/main`을 허용하고 `v`로 시작하는 넓은 tag 형식을 허용한다. publish helper는 archive checksum, tested/loaded image ID, OCI revision, registry credential 경계, 두 tag의 same-digest와 성공 evidence 생성을 검증하지만 strict `vX.Y.Z` event만 허용하지는 않는다.
- `tests/release/test_image_contracts.py`는 현재 `main`/`v*` trigger와 Dockerfile의 기존 COPY allowlist를 고정하고 있어 승인된 trigger·runtime lock 계약에 맞춰 변경해야 한다.
- `docs/operations.md`는 source-derived tag를 stable selector로, registry digest를 immutable deployment/rollback identity로 설명하고 runtime config·secret 외부 주입 및 reset 분리 경계를 보존한다. version tag 생성·push가 별도 사용자 승인 외부 쓰기라는 절차는 아직 설명하지 않는다.
- 기존 active AC 중 AC-0004는 `main` 또는 `v*` event의 verification-gated publication, AC-0006은 source tag·release tag·digest 추적성, AC-0008은 Docker COPY allowlist와 secret/config 비포함을 소유한다. AC-0005의 tested-artifact identity와 AC-0007의 built-container smoke는 이번 변경에서도 의미를 유지한다.
- 비교 기준 `trex-mega-swing-v2` revision `88e90caea7e4ab740ed785fc1f57aed8b6f2da5f`의 workflow는 strict `vX.Y.Z` tag-only trigger, event/ref/commit fail-closed validation, exact tagged commit checkout, locked verify, single build와 tested-artifact publication을 사용한다. workflow run `31468042611`은 `v0.1.0` tag에서 성공했다.
- 기준 revision에서 `proofline validate`는 exit `0`, `uv run ruff check .`은 `All checks passed!`, `uv run pytest -q`는 `1040 passed, 51 skipped`를 반환했다. skip은 환경 의존 integration 경계이며 현재 repository baseline이 승인된 변경을 이미 구현했다는 의미는 아니다.
## Scope
- AC admission은 기존 동작의 권한·식별자·build input을 변경하는 `update`와 새로운 runtime lock 동작을 정의하는 `create`다.
- `update`: AC-0004의 publication event를 일반 `main`/`v*`에서 strict version-tag-only로 변경하고 malformed/non-version event가 registry credential·write 전에 실패하도록 한다.
- `update`: AC-0006에 strict requester-selected `vX.Y.Z` version tag, source-derived tag와 version tag의 same-digest, version을 포함한 publication evidence를 반영한다.
- `update`: AC-0008의 좁은 Docker build input allowlist에 repository-owned `uv.lock`과 locked installation에 필요한 도구만 추가하면서 기존 config·credential·canary 비포함 경계를 보존한다.
- `create`: repository verification과 runtime image가 동일한 repository-owned lock resolution을 사용하고 lock mismatch 또는 unlocked runtime install을 거부하는 독립적인 장기 AC를 추가한다.
- AC-0005의 tested-artifact identity와 AC-0007의 built-container package·entrypoint smoke는 변경 없이 유지한다.
- GHCR publication workflow의 유일한 trigger는 requester가 exact source commit에 생성·push한 strict `vX.Y.Z` tag다. 각 숫자 구성요소는 `0` 또는 leading zero가 없는 non-negative decimal integer여야 하며 prerelease/build suffix, malformed tag, branch ref와 non-push event는 허용하지 않는다.
- workflow는 event·ref·40자리 lowercase source commit을 registry credential 사용 전에 fail-closed로 검증하고, exact tagged commit을 checkout했음을 다시 확인한다.
- version tag는 container publication identity이며 `pyproject.toml`의 `[project].version`과 독립적으로 취급한다. package version 일치 검사, 자동 version 증분과 package metadata 변경을 수행하지 않는다.
- 일반 `main`과 branch push는 registry login, `docker push`, `packages: write` 또는 image publication을 수행하지 않는다. 요청자 결정에 따라 이번 Scope는 일반 `main`/branch push를 위한 별도 비게시 CI workflow를 추가하지 않는다.
- 허용된 version-tag event는 exact source checkout 후 pinned `uv`와 repository-owned `uv.lock`으로 full Ruff와 full pytest를 실행하고, 성공한 경우에만 이미지를 한 번 빌드한다.
- Docker build는 `pyproject.toml`, `uv.lock`과 allowlisted package source로 runtime environment를 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 locked 방식으로 설치한다. lock mismatch, missing lockfile 또는 lock을 우회한 runtime dependency install은 build/publication 전에 실패한다.
- 동일한 tested image에 `:<source-commit-first-12>`와 `:vX.Y.Z`를 부여하고 두 tag가 동일한 registry digest를 가리키는지 확인한다. immutable deployment·rollback identity는 `@sha256:<digest>`다.
- 기존 `scripts/image_release.py`의 archive checksum, tested/loaded image ID, OCI revision, canary surface scan, no-rebuild publication, registry credential 경계와 multi-tag same-digest 검증을 유지한다.
- `latest`를 생성하지 않으며 성공 evidence에는 source revision, immutable image reference, digest와 selected version tag를 함께 기록한다.
- workflow contract와 helper tests는 일반 branch/main non-publication, strict event validation, exact checkout, lock mismatch, locked runtime build, same-digest tags, no-`latest`, no-rebuild 및 기존 failure boundaries를 검증한다.
- operator documentation은 version 선택과 Git tag 생성·push가 구현과 분리된 사용자 승인 외부 쓰기이고, tag push가 verify/publication을 시작한다는 점을 설명한다.
## Out of Scope
- 이 Discovery 또는 후속 구현 중 concrete version 선택, version tag 생성·push, 최초·후속 GHCR package write, production deployment 또는 운영 서버 pull/restart
- 일반 `main`/branch push를 위한 별도 lint/test CI workflow 신설 또는 기존 publication workflow 외 CI 정책 변경
- `pyproject.toml` package version 변경, version tag와 package version의 일치 강제 또는 자동 version 증분
- 기존 published tag·digest 삭제, 이동 또는 registry retention 변경
- live credential, 실제 account·database·RabbitMQ·Redis·KIS·portfolio-hub 연결, database migration/reset 또는 운영 데이터 변경
- image signing, SBOM, provenance attestation, multi-architecture build와 moving `latest`
- 다른 TREX repository의 workflow, image 또는 release policy 변경
## Risks and Unknowns
### Risks
- trigger 변경 후 일반 `main` push는 새 runtime image뿐 아니라 현재 publication workflow가 제공하던 full Ruff/pytest도 실행하지 않는다. 이는 요청자가 선택한 정책이며, branch protection 또는 지속적 검증이 필요해지면 registry 권한이 없는 별도 CI 변경으로 다룬다.
- version tag와 package version을 독립적으로 두면 image `:vX.Y.Z`와 installed Python package metadata version이 다를 수 있다. container publication version authority는 Git tag임을 문서화하고, package version synchronization이 필요해지면 별도 변경으로 다룬다.
- GitHub trigger glob만으로는 malformed `v*.*.*`를 모두 배제할 수 없다. workflow 내부 helper가 strict ref/event/commit을 registry credential 전에 재검증해야 한다.
- Docker build가 lockfile을 copy만 하고 실제 runtime install에 소비하지 않으면 CI lock PASS만으로 runtime reproducibility를 주장할 수 없다. build contract와 tests가 locked runtime command 및 mismatch failure를 직접 검증한다.
- version tag와 source-derived tag를 별도 build에서 만들면 같은 release identity가 서로 다른 image를 가리킬 수 있다. 단일 tested artifact를 재빌드 없이 두 tag로 push하고 registry digest equality를 확인한다.
- 기존 commit-derived selector와 immutable digest는 보존해야 하며 migration 과정에서 과거 tag를 삭제하거나 이동하지 않는다.
- tag 생성·push는 즉시 publication workflow와 registry write를 유발할 수 있다. operator 문서가 이를 별도 사용자 승인 외부 쓰기로 명시하고 구현·테스트 중 실제 tag push를 금지한다.
### Approval State
- Discovery status: `confirmed`
- 승인 근거: 요청자가 이슈의 모든 OQ에서 하나의 선택지를 명시적으로 선택한 뒤 이슈 본문 업데이트와 승인을 지시했다.
- 채택 결정: version tag와 package version은 독립적으로 취급하며, 일반 `main`/branch push용 별도 비게시 CI는 추가하지 않는다.
- 추가로 필요한 제품 결정: 없음
- 이 승인은 Discovery 범위 확인이며 Requirement/AC 승인, 구현, tag 생성·push, GHCR publication, delivery, release 또는 deployment 완료를 의미하지 않는다.
@@ -0,0 +1,59 @@
# 구현 완료 evidence
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다. 기록된 구현·테스트 결과는 로컬 기능 브랜치의 사후 증거이며 ProofLine delivery closure, publication, release 또는 deployment 완료를 의미하지 않습니다.
- Source URL: https://github.com/fourblocks2020/trex-core/issues/57#issuecomment-5290864375
- Comment ID: `5290864375`
- Node ID: `IC_kwDOSvywKc8AAAABO1wu9w`
- Author: `genichin`
- Created at: `2026-08-14T07:47:03Z`
- Updated at: `2026-08-14T07:47:03Z`
- Original body SHA-256: `2a57a0b702957cb534cc9aa35e08618e76e38d9da5d1926e8055f84973783b51`
- Captured from GitHub API: `2026-08-14T16:51:15+09:00`
## Original Comment Body
<!-- BEGIN ORIGINAL COMMENT BODY -->
## 구현 완료
승인된 #57 범위의 requester-versioned·runtime-locked image publication 구현과 독립 검증을 완료했습니다.
### 구현 내용
- GHCR publication trigger를 strict `vX.Y.Z` tag push로 제한하고 일반 `main`/branch push, non-push event, malformed·leading-zero·prerelease/build tag를 fail-closed 처리
- exact `${GITHUB_SHA}` checkout 후 실제 `git rev-parse HEAD`를 다시 비교하여 source identity 검증
- Git tag version을 `pyproject.toml` package version과 독립적인 container publication authority로 유지
- Docker runtime dependency 설치가 repository-owned `uv.lock`을 실제로 소비하도록 `uv sync --locked --no-dev --no-editable` 적용
- 기존 full Ruff·full pytest publication gate와 single build, built-image smoke, tested image ID, archive SHA-256, canary scan, OCI revision, no-rebuild publish 계약 보존
- source-derived tag와 version tag를 같은 tested image에 부여하고 동일 registry digest를 가리키는지 검증
- 성공 output·summary에 immutable image reference, digest, source commit과 selected version tag를 함께 기록하고 `latest` 생성 금지
- operator documentation에 version tag 생성·push가 구현과 분리된 별도 사용자 승인 외부 쓰기임을 명시
- 초기 후보의 checkout 재검증·version evidence 누락을 별도 후속 커밋에서 보완하여 이력을 보존
### 검증 결과
Release 집중·전체 회귀, 정적 검사, ProofLine validation, Docker build/smoke와 독립 read-only 리뷰를 모두 통과했으며 승인 차단 finding은 없습니다.
- 최종 HEAD: `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`
- 최종 tree: `ea05e23139965abed1f6a454c4ca2b5d356f8ab9`
- Release 집중 테스트: `75 passed`
- 전체 테스트: `1065 passed, 51 skipped`
- 전체 Ruff: `All checks passed!`
- `proofline validate`: exit `0`
- `git diff --check`: PASS
- checkout mismatch fail-closed 실동작: PASS
- Docker build, built-image smoke, image ID, archive export·checksum, canary scan: PASS
- image user `10001:10001`, `trex-core-daemon` entrypoint와 OCI source revision 확인
- 독립 read-only 리뷰: **PASS**, `mutation=false`, 승인 차단 finding 없음
- 기능 브랜치 `feat/issue-57-versioned-locked-publication`과 worktree: clean
### 비차단 후속 권고
- `TREX_POSTGRESQL_TEST_DSN`이 준비되면 PostgreSQL integration 41건을 실제 DB에서 재실행
- `TREX_RABBITMQ_TEST_URL`이 준비되면 RabbitMQ integration 9건을 재실행
- 안전한 KIS sandbox credentials가 준비되면 sandbox integration 1건을 재실행
- 로컬에 `actionlint`를 마련하거나 향후 GitHub workflow 실행에서 Actions-level YAML·shell 해석을 추가 확인
- version tag 생성·push와 GHCR publication은 별도 사용자 승인 후 수행하고, 게시 결과의 source/version tag가 동일 digest를 가리키는지 실제 registry evidence로 확인
> 현재 상태는 **격리 기능 브랜치의 로컬 구현·검증·커밋 완료**입니다. push·PR·main 반영·version tag 생성·GHCR 게시·배포·릴리스 완료를 의미하지 않습니다.
<!-- END ORIGINAL COMMENT BODY -->
@@ -0,0 +1,63 @@
# 구현 진행 상황 evidence
이 파일은 ProofLine canonical lifecycle artifact가 아닌 GitHub issue comment 원문 evidence입니다. 기록된 구현·테스트 결과는 로컬 기능 브랜치의 사후 증거이며 ProofLine delivery closure, publication, release 또는 deployment 완료를 의미하지 않습니다.
- Source URL: https://github.com/fourblocks2020/trex-core/issues/57#issuecomment-5290665935
- Comment ID: `5290665935`
- Node ID: `IC_kwDOSvywKc8AAAABO1knzw`
- Author: `genichin`
- Created at: `2026-08-14T07:22:00Z`
- Updated at: `2026-08-14T07:41:51Z`
- Original body SHA-256: `c9631a1402a51de699676a4d8791c206e9b6f4e197d134e16027cdc179f96c5f`
- Captured from GitHub API: `2026-08-14T16:51:15+09:00`
## Original Comment Body
<!-- BEGIN ORIGINAL COMMENT BODY -->
## 구현 진행 상황
- **대상:** [Discovery] requester-versioned·runtime-locked image publication
- **브랜치:** `feat/issue-57-versioned-locked-publication`
- **기준:** `main` (`1a2b50072238ea72186d99b3ec951fc9511100aa`)
- **상태:** 🟢 로컬 구현·검증 완료 — 독립 리뷰 PASS
- **최종 업데이트:** 2026-08-14 16:41:50 KST
### 구현 계획 및 작업 목록
- [x] **1. 기존 게시 안전장치와 변경 영향 범위 확인**
- tag trigger, 검증 순서, tested-image handoff, digest·canary·secret 경계와 기존 회귀 테스트를 기준선으로 고정합니다.
- [x] **2. 요청자 버전 태그 전용 게시 흐름 구현**
- strict `vX.Y.Z` tag push만 허용하고 일반 `main`/branch push와 malformed event는 registry write 전에 차단합니다.
- [x] **3. 컨테이너 runtime dependency lock 적용**
- repository의 `uv.lock`을 실제 runtime 설치에 사용하고 lock mismatch·unlocked 설치를 fail-closed 처리합니다.
- [x] **4. 이미지 식별자·게시 근거와 운영 문서 갱신**
- source tag와 version tag의 same-digest, no-`latest`, source/version/digest evidence 및 tag push 승인 경계를 반영합니다.
- [x] **5. 회귀 테스트와 전체 검증 수행**
- release contract 집중 테스트, 전체 Ruff, 전체 pytest, ProofLine validation과 가능한 local image build/smoke를 실제 실행합니다.
- [x] **6. 독립 검토와 최종 구현 근거 정리**
- 구현자와 분리된 read-only 검토를 받고, 반려 시 보완·재검증한 뒤 최종 commit과 검증 결과를 기록합니다.
### 진행 기록
- 승인된 #57 Discovery 본문과 OQ 결정(A: package version 독립, B: 일반 main/branch용 별도 CI 미추가)을 구현 범위의 기준선으로 고정했습니다.
- 격리 worktree와 기능 브랜치를 `1a2b50072238ea72186d99b3ec951fc9511100aa`에서 생성했습니다.
- 기준선 확인 완료: 기존 workflow의 single build→smoke→image ID→archive checksum/canary scan→tested artifact handoff→no-rebuild publish와 publish-job-only `packages: write` 경계를 확인했습니다.
- AC 영향 범위 확인: AC-0004·AC-0006·AC-0008 update, runtime lock AC create, AC-0005·AC-0007 유지 경계로 고정했습니다.
- 변경 전 기준선 검증: `proofline validate` PASS, 전체 Ruff PASS, 전체 pytest `1040 passed, 51 skipped`; 기준 branch와 worktree는 clean입니다.
- version tag 생성·push, GHCR publication, PR, main 반영, release와 deployment는 수행하지 않습니다.
- 초기 구현 commit `cb8fcee226ab71f522fe1bbef35d60197947850c` 생성: strict tag-only trigger와 runtime `uv.lock` 설치, 기존 release safety contract 및 운영 문서 변경을 포함합니다.
- 초기 후보 검증: release 집중 `71 passed`, 전체 `1061 passed, 51 skipped`, Ruff·ProofLine·diff check PASS. skip은 PostgreSQL 41건, RabbitMQ 9건, KIS sandbox 1건입니다.
- 팀장 diff 검토에서 승인 계약 누락 2건을 확인해 최종 완료를 보류했습니다: (1) checkout 후 실제 `HEAD == GITHUB_SHA` 재검증 없음, (2) 성공 output/summary에 selected version tag evidence 없음.
- 위 2건은 계획 변경이 아니라 승인된 #57의 결함 보완이며, 초기 후보 위에 별도 수정 commit을 만들고 전체 검증·독립 리뷰를 다시 수행합니다.
- 보완 commit `e3fa8243d926efd0b81463dbfc5403cf6e4889f9` 생성: checkout HEAD 재검증과 selected version tag output/summary evidence를 추가했고 초기 커밋은 보존했습니다.
- 팀장 최종 후보 재검증: release 집중 `75 passed`, 전체 `1065 passed, 51 skipped`, Ruff·ProofLine·diff check PASS, checkout mismatch fail-closed 실동작 확인.
- Docker 재검증: built-image smoke, image ID, archive export/checksum, canary scan PASS; non-root `10001:10001`, daemon entrypoint와 OCI revision을 확인했습니다.
- 구현·검증 항목 2~5를 완료했으며, 최종 후보 `e3fa824`에 고정한 독립 read-only 리뷰를 진행합니다.
- 독립 read-only 리뷰 최종 **PASS** (`mutation=false`, target `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`): release `75 passed`, 전체 `1065 passed, 51 skipped`, Ruff·ProofLine·diff check PASS, 승인 차단 finding 없음.
- skip 분류: PostgreSQL integration 41건(DSN 없음), RabbitMQ integration 9건(URL 없음), KIS sandbox 1건(credentials 없음).
- 독립 리뷰의 비차단 참고: 새 review environment는 `uv sync --extra dev --locked` 후 검증했으며, candidate Docker build/registry write는 리뷰에서 수행하지 않았습니다. Docker contract는 구현자와 팀장이 별도로 실제 build/smoke/scan으로 확인했습니다.
- 최종 기능 브랜치 HEAD와 tree는 각각 `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`, `ea05e23139965abed1f6a454c4ca2b5d356f8ab9`; worktree clean. 임시 detached review worktree는 검토 후 정리했습니다.
- 현재 완료 경계는 격리 기능 브랜치의 로컬 구현·검증·커밋입니다. push, PR, main 반영, tag 생성·push, GHCR publication, release와 deployment는 수행하지 않았습니다.
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
<!-- END ORIGINAL COMMENT BODY -->
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0004"
---
+62
View File
@@ -0,0 +1,62 @@
---
id: "req-0004"
status: approved
discovery: "dcy-0004"
criteria:
create:
- "ac-0014"
update:
- "ac-0004"
- "ac-0006"
- "ac-0008"
retire: []
satisfy: []
---
# requester-versioned·runtime-locked image publication
## Objective
GHCR image publication authority를 요청자가 exact source commit에 생성·push하는 strict `vX.Y.Z` Git tag로 제한하고, repository verification과 container runtime installation이 동일한 repository-owned `uv.lock` resolution을 사용하게 한다. 기존의 검증 선행, single build, built-image smoke, tested artifact identity, canary·secret 방어, no-rebuild publication과 multi-tag same-digest 계약은 유지한다.
## Scope
- GHCR publication workflow의 유일한 trigger를 strict `vX.Y.Z` tag push로 제한한다. 숫자 구성요소는 `0` 또는 leading zero 없는 non-negative decimal integer이며 prerelease/build suffix, malformed tag, branch ref와 non-push event를 거부한다.
- event, ref와 40자리 lowercase source commit을 registry credential 또는 write 전에 fail-closed로 검증하고, exact `${GITHUB_SHA}` checkout 뒤 실제 `HEAD`를 다시 비교한다.
- strict version-tag event에서 pinned `uv`와 repository-owned `uv.lock`으로 full Ruff와 full pytest를 실행하고, 성공한 경우에만 이미지를 한 번 빌드한다.
- 일반 `main`과 branch push에서는 registry login, `docker push`, `packages: write`와 image publication을 수행하지 않으며, 요청자 결정에 따라 별도 비게시 CI workflow를 추가하지 않는다.
- Docker runtime environment는 `pyproject.toml`, `uv.lock`과 allowlisted package source만으로 `uv sync --locked --no-dev --no-editable` 또는 의미상 동등한 locked install을 수행한다. missing/mismatched lockfile 또는 unlocked install은 build/publication 전에 실패한다.
- single built image에 source commit 앞 12자리 tag와 selected `vX.Y.Z` tag를 부여하고 두 push 결과가 동일 digest인지 확인한다. `latest`는 생성하지 않는다.
- archive checksum, tested/loaded image ID, OCI revision, built-image package·CLI·migration smoke, canary surface scan, artifact handoff와 publish-job-only registry credential 경계를 유지한다.
- push 성공과 digest 검증 뒤에만 immutable image reference, digest, 전체 source commit과 selected version tag를 output·summary evidence로 기록한다. push 후 오류를 registry write 미발생으로 표현하지 않는다.
- operator documentation은 container publication version authority가 Git tag이고 package metadata version과 독립임을 설명하며, version 선택과 tag 생성·push가 구현·검증과 분리된 requester-approved external write임을 명시한다.
- workflow/parser/helper/Docker contract tests는 strict trigger, exact checkout, lock mismatch, locked runtime install, same-digest/no-`latest`, no-rebuild와 기존 실패 경계를 독립적으로 검증한다.
## Constraints
- Git tag version과 `pyproject.toml`의 `[project].version`을 일치시키거나 자동 증분하지 않는다.
- 기존 commit-derived selector와 immutable digest를 보존하고 기존 published tag·digest를 삭제하거나 이동하지 않는다.
- repository 전체를 Docker build input으로 copy하지 않으며 runtime config, `.env`, credential, private key, 실제 connection string, ProofLine artifact와 synthetic canary를 image에 포함하지 않는다.
- package write permission과 registry credential은 검증 job이 아닌 gated publish job에만 부여한다.
- 실제 account·database·RabbitMQ·Redis·KIS·portfolio-hub 연결, 운영 migration/reset과 다른 TREX repository 계약을 변경하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 active AC가 normative specification을 소유한다.
### Current Implementation Assessment
- 이 문서는 이미 로컬 기능 브랜치에서 구현된 GitHub issue #57을 현재 ProofLine schema에 사후 명세화한 것이다. 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻이 아니다.
- 구현은 `feat/issue-57-versioned-locked-publication`의 local commit `e3fa8243d926efd0b81463dbfc5403cf6e4889f9`(tree `ea05e23139965abed1f6a454c4ca2b5d356f8ab9`)에 있으며, baseline `1a2b50072238ea72186d99b3ec951fc9511100aa` 위 두 개의 append-only commit으로 기록되었다. 현재 local `main`과 `origin/main`에는 아직 반영되지 않았다.
- issue comment evidence에는 release 집중 `75 passed`, 전체 `1065 passed, 51 skipped`, Ruff·ProofLine·diff check, checkout mismatch fail-closed, Docker build·smoke·image ID·archive checksum·canary scan과 독립 read-only review `PASS`가 기록되어 있다.
- 환경 의존 PostgreSQL 41건, RabbitMQ 9건, KIS sandbox 1건은 설정·credential 부재로 skip되었다. 로컬 `actionlint`도 실행 파일 부재로 수행되지 않았으며 향후 hosted workflow 실행 또는 별도 도구로 확인할 비차단 검증 공백이다.
- version tag 생성·push, GHCR write, PR, main 반영, runtime rollout, deployment와 release는 수행되지 않았다. 따라서 live registry same-digest와 hosted Actions 실행 결과는 현재 `unverified` evidence다.
- 이 REQ의 `approved`와 AC의 `active`는 현재 specification authority를 나타내며 구현 통합, publication, delivery closure, release 또는 deployment 완료를 의미하지 않는다.
## Non-Goals
- concrete version 선택, version tag 생성·push, 최초·후속 GHCR package write, production deployment, 운영 서버 pull/restart와 post-deployment verification
- 일반 `main`/branch push용 별도 lint/test CI workflow 또는 branch protection 정책 신설
- package version 변경, Git tag와 package version 일치 강제 또는 자동 version 증분
- 기존 published tag·digest의 삭제·이동 또는 registry retention 변경
- image signing, SBOM, provenance attestation, multi-architecture build와 moving `latest`
- live credential, 운영 account·database·broker·KIS 연결, database migration/reset 또는 운영 데이터 변경
- 다른 TREX repository의 workflow, image, wire/HTTP 계약 또는 release policy 변경
- ProofLine delivery/release lifecycle의 소급 완료 주장
+67
View File
@@ -0,0 +1,67 @@
---
id: "dcy-0005"
status: confirmed
---
# broker 실행 실패의 기록·상태 수렴 경계
## Problem
`trex-core`의 broker execution 경계는 명시적인 주문 거부, timeout·연결 단절·응답 누락처럼 결과가 불명확한 submission, 취소·부분체결·재시작을 서로 다른 authority로 처리해야 한다. 기존 계약이 이 경계를 충분히 명시하지 않으면 core가 broker failure를 retryability로 분류하거나 새 주문 attempt를 자동 생성하여 중복 주문을 만들 수 있고, strategy/TVR이 KIS 실패와 broker retry 판단을 소유하는 잘못된 결합이 생길 수 있다.
주문 venue도 같은 execution lifecycle에서 의미가 혼재될 위험이 있었다. 누락 venue의 기본값, 명시 `KRX`/`NXT`/`SOR`, 폐기된 `AUTO`, 원주문 routing venue와 실제 KRX/NXT 체결 venue를 구분하지 않으면 wall-clock routing, 취소 시 원주문 venue 손실, ledger/outbox에 실제 거래소 대신 `SOR` 기록, 기존 persisted `AUTO` 및 historical `SOR` row의 잘못된 해석이 발생할 수 있다.
기대 상태는 core가 broker-authoritative evidence로 상태를 수렴하되 자동 새 주문을 만들지 않고, strategy/TVR에는 core slot snapshot만 노출하며, 원주문 venue와 실제 체결 venue를 각각 durable하게 보존하는 것이다.
## Evidence
- 원본 승인 문서: GitHub issue #60 `https://github.com/fourblocks2020/trex-core/issues/60`의 승인된 본문은 명시적 sell rejection, unknown outcome reconciliation, TVR wire 경계, SOR 기본값, AUTO 제거와 자동 retry 금지를 정의한다.
- 연계 승인 계약: GitHub issue #61은 venue admission부터 broker request/ack/cancel과 ledger/outbox까지 explicit venue 보존 경계를 정의한다.
- 구현 진행 원본: issue #60 comment `https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5336722059`은 기준 revision, 계획, 구현·검증 진행, 두 차례의 독립 QC blocker와 교정 기록을 포함한다.
- 구현 완료 원본: issue #60 comment `https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5337338182`은 구현 내용, 검증 결과, 로컬 commit 및 push·PR·배포·릴리스·실주문 경계를 기록한다.
- 구현 revision은 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`이며 현재 local `main` HEAD는 `359ea098ff717e7f04cf4206dcbc64ad17b47755`다.
- `trex_core/application/execution.py`는 명시적 sell 미수락·미체결의 `pending_sell → holding`, 결과 불명확 submission의 durable dispatch sentinel·lookup/reconciliation, fill의 actual venue 확인과 ledger enqueue 경계를 구현한다.
- `trex_core/application/broker_port.py`는 original submission/routing venue를 가진 `OrderAck`와 nullable broker-authoritative `FillStatus.execution_venue`를 분리한다.
- `trex_core/adapters/broker/kis.py`는 주문 요청의 `KRX`/`NXT`/`SOR`를 보존하고 `ORD_EXG_GB` 및 명시 exchange-id allowlist로 실제 KRX/NXT만 파싱하며 `SOR` 단독·unknown을 추론하지 않는다.
- `trex_core/adapters/persistence/sql/023_remove_auto_order_venue.sql`은 persisted `AUTO → SOR` 변환과 이후 AUTO 저장 차단을 수행한다.
- `trex_core/adapters/persistence/sql/024_add_ledger_order_venue.sql`은 original `order_venue`와 nullable actual `venue`를 분리하고 historical SOR actual을 NULL로 보존하며 replay-safe NULL-only backfill과 DB constraint를 적용한다.
- `trex_core/adapters/persistence/postgresql.py`와 `trex_core/application/ledger_port.py`는 outbox에서 `order_venue`와 actual `venue`를 독립적으로 저장·복원한다.
- 구현자 검증은 focused unit `77 passed`, 전체 `1074 passed, 52 skipped`, disposable PostgreSQL 16 `24 passed`, Ruff `All checks passed!`였다.
- 최종 독립 read-only QC는 `359ea098ff717e7f04cf4206dcbc64ad17b47755`에서 blocker/critical/major/minor finding 없이 PASS했다. 실제 PostgreSQL 16에서 historical SOR→`order_venue=SOR`+actual NULL, 정상 actual NXT/order KRX의 replay 보존, actual SOR·AUTO·NULL order 거부를 확인했고 worktree는 clean이었다.
- 현재 `proofline validate`는 exit 0이다.
## Scope
- AC admission은 기존 active AC-0011의 timeout/cancel reconciliation 및 AC-0013의 terminal cancel·partial fill 상태 수렴과 인접하지만, 명시적 submission rejection, strategy wire ownership, venue admission/migration 및 order/execution venue 분리를 독립적으로 검증 가능한 장기 계약으로 추가하는 `create`다. 기존 AC의 의미나 status는 변경하지 않는다.
- KIS 조회로 sell 주문의 미수락·미체결이 명시적으로 확인되면 새 broker attempt 없이 실패 evidence를 기록하고 dispatch를 정리하며 broker-authoritative 보유수량으로 `pending_sell → holding` 전이한다.
- timeout, 연결 단절, 응답 누락 등 submission 결과가 불명확하면 durable sentinel을 유지하고 새 주문을 생성하지 않으며 broker lookup과 reconciliation을 먼저 수행한다.
- core는 broker failure의 retryability, retry schedule, retry budget을 판정하거나 자동 새 broker attempt를 생성하지 않는다. 이후 strategy가 독립적으로 생성한 새 intent는 이 자동 retry 금지와 구분한다.
- strategy/TVR에는 KIS failure, raw broker error, broker attempt, retryability를 노출하지 않는다. strategy/TVR은 core가 수렴한 slot snapshot만 소비한다.
- intent venue 누락은 `SOR`로 정규화하고 명시 `KRX`/`NXT`/`SOR`는 보존한다. `AUTO`는 admission과 persistence에서 거부하고 기존 persisted AUTO는 SOR로 전환하며 wall-clock venue routing을 수행하지 않는다.
- original `order_venue` KRX/NXT/SOR는 submit/ack/slot/restart/cancel에 보존한다. broker-authoritative actual `execution_venue`는 KRX/NXT 또는 미확정 NULL로 별도 취급하고 fill·ledger/outbox에 기록한다.
- KIS actual venue는 검증된 `ORD_EXG_GB`와 명시 exchange-id allowlist에서만 KRX/NXT로 변환한다. `SOR` 단독, 누락 또는 미지원 값은 KRX/NXT로 추론하지 않는다.
- historical outbox의 SOR는 original order venue로 보존하고 actual venue는 NULL로 전환한다. migration 재실행은 신규 정상 order/execution venue 조합을 오염시키지 않아야 하며 DB는 actual SOR와 invalid order venue를 거부한다.
- 관련 focused·전체 회귀, PostgreSQL migration·round-trip, lint와 독립 QC evidence를 구현 상태 평가에 포함한다.
## Out of Scope
- strategy/TVR의 KIS failure 수신, retryability 판단, broker attempt 관리 또는 자동 retry 기능
- TVR 세션·portfolio `markets` 판단 구현과 KRX/NXT session policy 자체
- 실제 KIS 주문·취소, production credential, live RabbitMQ·database·portfolio-hub 연결
- push, PR, 원격 main 반영, image publication, 운영 migration, deployment, GitHub Release 또는 실주문
- ProofLine delivery/release lifecycle이 과거 구현 당시 완료되었다는 소급 주장
## Risks and Unknowns
### Risks
- KIS 응답의 실제 venue 필드가 누락되거나 미지원 코드이면 SOR 주문의 actual venue가 NULL로 남아 ledger enqueue가 fail-closed 될 수 있다. 임의 시간 추론보다 안전하며 운영 관측과 authoritative reconciliation이 필요하다.
- migration runner는 history table 없이 SQL을 재실행하므로 migration 024의 NULL-only backfill과 replay-safe 불변식을 이후 변경에서도 유지해야 한다.
- 환경 의존 KIS·RabbitMQ 검증은 skip되었으므로 실제 운영 외부 시스템 상호작용은 별도 승인 환경에서 확인해야 한다.
### Approval State
- Discovery status: `confirmed`
- 승인 근거: 요청자 genichin이 승인 상태인 issue #60 본문을 최대한 보존하여 템플릿 기반 Discovery를 승인된 상태로 정리하라고 명시했다.
- 추가로 필요한 제품 결정: 없음
- 이 확인은 사후 specification 정리이며 구현·테스트·독립 QC의 실제 evidence는 별도 보존한다. push, PR, 배포, release 또는 실주문 승인을 뜻하지 않는다.
@@ -0,0 +1,49 @@
# GitHub issue #60 comment evidence
- Source: https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5337338182
- Author: `genichin`
- Created At: `2026-08-19T03:57:52Z`
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
---
## 구현 완료
### 구현 내용
- 명시적 주문 미수락·미체결 sell 실패를 자동 재주문 없이 `pending_sell → holding`으로 수렴시켰다.
- submission outcome unknown은 durable sentinel과 broker lookup/reconciliation을 우선하고 중복 제출하지 않는다.
- strategy RPC에서 KIS failure·broker attempt·retryability 전달을 제거했다.
- venue 누락은 SOR, 명시 KRX/NXT/SOR는 보존하고 AUTO 및 wall-clock routing을 제거했다.
- 기존 persisted AUTO를 SOR로 전환하는 migration을 추가했다.
- SOR 원주문 venue와 실제 체결 venue를 분리했다.
- slot·재시작·취소: `order_venue` KRX/NXT/SOR
- fill·ledger/outbox actual venue: KRX/NXT, 복원 불가능한 historical SOR는 NULL
- KIS `ORD_EXG_GB` 및 명시 exchange-id allowlist로 실제 KRX/NXT를 파싱하며 SOR/unknown은 추론하지 않는다.
- migration replay가 정상 `(actual=NXT, order=KRX)` 행을 오염시키지 않도록 NULL-only backfill과 DB constraint를 적용했다.
### 실제 검증 결과
- 구현자 전체 회귀: `1074 passed, 52 skipped`
- 구현자 focused unit: `77 passed`
- 구현자 disposable PostgreSQL 16: `24 passed`
- Ruff: `All checks passed!`
- 독립 QC 전체 pytest + PostgreSQL 16 검증: PASS
- 독립 QC finding: blocker/critical/major/minor 모두 없음
- 독립 QC worktree: clean
### 로컬 커밋
- `15aeae6930f8c3e6ef9cf659f41278b099a78459` — broker 실패 수렴·AUTO 제거
- `7366cadf03fc6002bfd01ecf0cba7c24486bb618` — SOR 원주문/실제 체결 venue 분리
- `359ea098ff717e7f04cf4206dcbc64ad17b47755` — actual venue DB constraint 및 replay-safe migration 보강
### 비차단 후속 권고
- migration runner는 history table 없이 SQL 전체를 재실행하므로 현재 migration의 replay-safe 불변식을 계속 유지한다.
- 실제 운영 KIS 응답·RabbitMQ·자격증명 게이트 검증은 별도 승인된 환경에서 수행한다.
### 범위 경계
현재 완료된 것은 **로컬 `main`의 구현·테스트·PostgreSQL 검증·커밋과 독립 QC PASS**다. push, PR, 원격 main 반영, 이미지 게시, 운영 배포, migration 운영 실행, GitHub Release, 실주문은 수행하지 않았으며 완료를 의미하지 않는다.
@@ -0,0 +1,55 @@
# GitHub issue #60 comment evidence
- Source: https://github.com/fourblocks2020/trex-core/issues/60#issuecomment-5336722059
- Author: `genichin`
- Created At: `2026-08-19T02:20:28Z`
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
---
## 구현 진행 상황
- **대상:** [Discovery] broker 실행 실패의 기록·상태 수렴 경계
- **브랜치:** `main`
- **기준:** `main` (`be98965f2c5d1bad933802282bd7284642203cb8`)
- **상태:** 로컬 main 구현·검증·커밋 및 독립 QC PASS — 미푸시, 미배포
- **최종 업데이트:** 2026-08-19 12:57:49 KST
### 구현 계획 및 작업 목록
- [x] 명시적으로 주문이 거부되고 미체결임이 확인된 매도 주문을 새 주문 없이 보유 상태로 복구한다.
- 주문 배정 정보를 정리하고 기존 보유수량을 유지하는 집중 검증을 통과했다.
- [x] 주문 결과가 불명확한 경우 중복 주문을 만들지 않고 조회·대조 절차를 우선한다.
- 기존 결과불명 sentinel·조회 경로와 재시작 중복 제출 방지 회귀를 확인했다.
- [x] 전략 서비스에 KIS 실패·재시도 판단 정보를 전달하지 않고, core가 자동 주문 재시도를 추가하지 않았음을 검증한다.
- sell 응답의 `retryable` 필드를 제거하고 strategy RPC 응답에 retryability가 없음을 검증했다.
- [x] venue 누락 시 SOR 기본값, 명시 venue 보존, AUTO 제거·기존 데이터 전환 계약과의 교차 회귀를 검증한다.
- 원주문 `order_venue`와 실제 `execution_venue`를 분리하고, KIS allowlist 파싱·outbox 이중 보존·실제 PostgreSQL migration 검증을 완료했다.
- [x] 관련 집중 테스트와 전체 회귀 테스트를 실행하고 결과를 기록한다.
- [x] 변경사항을 검토하고 로컬 main에 커밋한다.
- commits: `15aeae6930f8c3e6ef9cf659f41278b099a78459`, blocker fixes `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`; push·PR·배포·릴리스·실주문은 수행하지 않았다.
### 진행 기록
- 2026-08-19 11:29:51 KST — 승인된 이슈 본문과 `main` 기준 SHA를 확인하고 별도 worktree·기능 브랜치 없이 구현을 시작했다.
- 2026-08-19 11:29:51 KST — sell 명시적 거부 후 `pending_sell → holding`, dispatch 정리, 후속 자동 재주문 부재를 검증했다.
- 2026-08-19 11:29:51 KST — 결과불명 주문은 durable sentinel과 broker lookup/reconciliation 경로를 유지하며 재제출하지 않는 회귀를 확인했다.
- 2026-08-19 11:29:51 KST — venue 누락→SOR, explicit KRX/NXT/SOR 보존, AUTO admission 거부, wall-clock routing 제거, persisted AUTO→SOR migration을 구현했다.
- 2026-08-19 11:29:51 KST — 집중 테스트 `127 passed`; 전체 회귀 `1062 passed, 51 skipped`; ruff `All checks passed!`.
- 2026-08-19 11:29:51 KST — disposable PostgreSQL 16 실제 엔진 검증 `23 passed`; 검증 컨테이너를 제거했다.
- 2026-08-19 11:29:51 KST — 로컬 main commit `15aeae6930f8c3e6ef9cf659f41278b099a78459` 생성 후 집중 테스트 `127 passed`, ruff 통과, 작업트리 clean을 재확인했다.
- 2026-08-19 11:59:05 KST — 독립 QC 결과 FAIL. KIS adapter가 SOR 요청값을 ack/slot/ledger에 그대로 보존하고 일별 체결조회 row의 실제 KRX/NXT venue를 파싱하지 않아 AC 6을 충족하지 못한다. 수정·독립 재검증 전에는 delivery 검증 완료로 판정하지 않는다.
- 2026-08-19 12:47:57 KST — 승인된 해결책을 적용했다. Slot/취소에는 원주문 KRX/NXT/SOR를 유지하고, KIS fill의 `ORD_EXG_GB` 및 명시 exchange-id allowlist에서 실제 KRX/NXT를 `FillStatus.execution_venue`로 전달한다. outbox는 `order_venue`와 실제 `venue`를 분리 저장하며 SOR 단독·미지원 코드는 실제 거래소로 추론하지 않는다.
- 2026-08-19 12:47:57 KST — 전체 회귀 `1074 passed, 51 skipped`, disposable PostgreSQL 16 검증 `23 passed`, ruff 통과, migration 024 및 outbox round-trip을 확인했다.
- 2026-08-19 12:48:36 KST — blocker 수정 commit `7366cadf03fc6002bfd01ecf0cba7c24486bb618`을 로컬 main에 생성하고 worktree clean을 확인했다. 독립 재QC를 시작했다.
- 2026-08-19 12:54:15 KST — 독립 재QC에서 migration 024의 historical SOR actual 값과 replay-safe backfill 결함을 blocker로 확인했다. `order_venue IS NULL` one-time backfill, historical SOR actual→NULL, actual venue KRX/NXT/NULL DB constraint를 적용했다. 신규 actual NXT + order KRX 행이 replay 후 보존됨을 검증했다.
- 2026-08-19 12:54:15 KST — 수정 commit `359ea098ff717e7f04cf4206dcbc64ad17b47755`; 전체 `1074 passed, 52 skipped`, PostgreSQL 16 `24 passed`, ruff 통과, worktree clean. 독립 재QC를 다시 요청한다.
- 2026-08-19 12:57:49 KST — 독립 재QC PASS. blocker/critical/major/minor finding 없음. 독립 PG16에서 historical SOR→order SOR+actual NULL, 정상 NXT/KRX replay 보존, actual SOR·AUTO·NULL order 거부를 확인했다. HEAD `359ea098ff717e7f04cf4206dcbc64ad17b47755`, worktree clean.
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0005"
---
+61
View File
@@ -0,0 +1,61 @@
---
id: "req-0005"
status: approved
discovery: "dcy-0005"
criteria:
create:
- "ac-0015"
- "ac-0016"
- "ac-0017"
- "ac-0018"
- "ac-0019"
update: []
retire: []
satisfy: []
---
# broker 실행 실패의 기록·상태 수렴 경계
## Objective
`trex-core`가 broker-authoritative evidence를 기준으로 주문 실패·불명확 결과·부분체결·취소 상태를 중복 주문 없이 수렴하게 한다. core가 자동 broker retry를 생성하거나 strategy/TVR에 KIS 실패 판단을 넘기지 않도록 ownership을 제한하고, original order venue와 actual execution venue를 durable하게 분리하여 submit부터 restart·cancel·ledger/outbox까지 의미를 보존한다.
## Scope
- 명시적으로 미수락·미체결이 확인된 sell 주문은 실패 evidence를 기록하고 dispatch를 정리하며 새 broker attempt 없이 broker-authoritative 보유수량으로 `pending_sell → holding` 전이한다.
- submission timeout·연결 단절·응답 누락 등 결과 불명확 상태는 durable sentinel로 보존하고 신규 주문 없이 original order lookup과 reconciliation을 우선한다.
- core는 broker failure의 retryability, retry schedule, retry budget 또는 자동 새 broker attempt를 산출하지 않는다. 이후 별도 strategy intent는 자동 retry와 구분한다.
- strategy/TVR 응답·이벤트에는 KIS failure, raw broker error, broker attempt와 retryability를 노출하지 않는다. strategy/TVR은 core slot snapshot만 소비한다.
- intent venue 누락은 `SOR`로 정규화하고 explicit `KRX`/`NXT`/`SOR`를 그대로 보존한다. `AUTO`는 admission·broker routing·persistence에서 허용하지 않고 persisted AUTO는 SOR로 전환하며 wall-clock routing을 제거한다.
- original `order_venue`는 submit, ack, slot, restart와 cancel에 KRX/NXT/SOR로 보존한다. actual `execution_venue`는 broker-authoritative KRX/NXT 또는 미확정 NULL이며 fill, ledger와 outbox에 별도로 기록한다.
- KIS actual venue는 `ORD_EXG_GB`의 승인된 mapping과 명시 exchange-id allowlist만 사용한다. `SOR`, 누락, 미지원 값과 wall-clock time으로 actual KRX/NXT를 추론하지 않는다.
- historical outbox SOR는 `order_venue=SOR`, actual `venue=NULL`로 보존한다. migration은 NULL-only backfill로 반복 적용이 안전해야 하고 정상 `(actual=NXT, order=KRX)` 같은 조합을 오염시키지 않아야 한다.
- PostgreSQL은 order venue를 KRX/NXT/SOR non-null로 제한하고 actual venue를 KRX/NXT/NULL로 제한한다.
- focused·전체 회귀, actual PostgreSQL migration·round-trip, lint와 독립 read-only QC를 verification evidence로 유지한다.
## Constraints
- broker evidence가 없는 actual venue, 체결, 미수락 또는 terminal state를 추론하지 않는다.
- explicit order venue와 original order date·broker order identity를 restart/cancel 중 변경하지 않는다.
- strategy/TVR에 KIS failure나 retry ownership을 추가하지 않는다.
- live credential·token·connection string·raw sensitive payload를 문서나 외부 wire에 기록하지 않는다.
- existing active AC-0011의 timeout/cancel reconciliation 및 AC-0013의 terminal cancellation·partial fill 계약을 완화하거나 대체하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 active AC가 normative specification을 소유한다.
### Current Implementation Assessment
- 이 specification은 승인된 GitHub issue #60과 연결된 #61을 이미 local `main`에서 구현·검증한 결과를 사후 정리한다. 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻은 아니다.
- 구현 revision은 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`다.
- 현재 구현은 명시적 sell rejection 수렴, unknown submission sentinel·lookup, strategy wire retryability 제거, SOR default·explicit venue 보존·AUTO 제거, KIS actual venue parser와 order/execution venue 분리, replay-safe migration·DB constraint를 포함한다.
- 구현자 검증은 focused `77 passed`, 전체 `1074 passed, 52 skipped`, disposable PostgreSQL 16 `24 passed`, Ruff PASS다.
- 최종 독립 read-only QC는 actual PostgreSQL 16 probe를 포함하여 blocker/critical/major/minor finding 없이 PASS했으며 HEAD `359ea098ff717e7f04cf4206dcbc64ad17b47755`와 clean worktree를 확인했다.
- 환경 의존 KIS·RabbitMQ 검증은 skip되었고 실제 외부 system interaction은 수행하지 않았다.
- `approved` REQ와 `active` AC는 current specification authority 및 현재 implementation assessment를 나타낸다. push, PR, 원격 main 반영, deployment, release 또는 운영 주문 완료를 뜻하지 않는다.
## Non-Goals
- strategy/TVR의 KIS failure 수신, retryability 계산, broker retry schedule·budget 또는 자동 재주문
- TVR session·portfolio `markets` gate와 거래 세션 정책 구현
- live KIS 주문·취소, 운영 broker/account/database/RabbitMQ/portfolio-hub 연결
- push, PR, remote main merge, image publication, production migration, deployment, GitHub Release 또는 실주문
- ProofLine delivery·release lifecycle closure의 소급 주장
+68
View File
@@ -0,0 +1,68 @@
---
id: "dcy-0006"
status: confirmed
---
# 명시 venue 기반 주문 실행 계약
## Problem
`trex-core`의 주문 venue 계약에는 과거 `AUTO`와 기본 `KRX` 동작이 혼재했다. buy/sell intent에서 venue가 누락될 때 core가 wall-clock으로 KRX/NXT를 선택하거나 내부·저장 계층에서 AUTO를 허용하면 caller가 요청한 routing provenance를 잃고 세션 판단이 execution core로 새어 들어간다. 기존 persisted AUTO row도 현재 계약과 충돌한다.
SOR 주문에서는 원주문 routing venue와 실제 체결 거래소가 서로 다른 의미다. 이를 하나의 venue 값으로 취급하면 submit acknowledgement 뒤 slot venue를 실제 KRX/NXT로 덮어쓰거나, 반대로 fill·ledger/outbox에 실제 거래소 대신 SOR를 기록할 수 있다. 부분체결 잔량 취소와 daemon 재시작은 원주문 SOR를 필요로 하고, actual venue는 broker-authoritative KRX/NXT evidence가 있을 때만 확정되어야 한다.
기대 상태는 venue 누락/null intent를 SOR로 정규화하고 explicit KRX/NXT/SOR를 변경 없이 보존하며 AUTO와 wall-clock routing을 제거하는 것이다. original order venue와 actual execution venue는 submit·ack·cancel·fill polling·restart·ledger/outbox 전체에서 별도 의미로 durable하게 보존되어야 한다.
## Evidence
- 원본 승인 문서: GitHub issue #61 `https://github.com/fourblocks2020/trex-core/issues/61`은 SOR default, explicit venue preservation, AUTO removal, persisted AUTO→SOR migration, no wall-clock routing과 execution venue lifecycle을 승인했다.
- 승인 결정은 venue 누락의 SOR default, explicit KRX/NXT/SOR 유지, AUTO의 public wire·internal model·persistence 제거, persisted AUTO의 SOR 정규화, SOR ack·조회·취소 계약을 구현 검증으로 증명하고 실패 시 AUTO routing으로 회귀하지 않는다는 다섯 항목이다.
- 구현 진행 원본: issue #61 comment `https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337421562`는 baseline `4975d7166fa70e045096503e85c26d740ae92c55`, 한국어 계획, stale PostgreSQL DSN 실패 분류, 수정·검증·독립 QC와 최종 PASS를 기록한다.
- 구현 완료 원본: issue #61 comment `https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337469578`은 구현 내용, 실제 검증 결과, local commits와 외부 단계 경계를 기록한다.
- `trex_core/application/messaging_handlers.py`는 buy/sell wire의 누락 또는 explicit null venue를 `SOR`로 정규화하고 invalid venue를 admission 전에 거부한다.
- `trex_core/application/intents.py`는 intent venue 기본값과 validation을 소유하고 slot에 original order venue를 전달한다.
- `trex_core/adapters/broker/kis.py`는 KRX/NXT/SOR allowlist를 시간과 독립적으로 보존하여 `EXCG_ID_DVSN_CD`로 제출하고, actual execution venue는 검증된 `ORD_EXG_GB`와 explicit exchange-id allowlist에서만 KRX/NXT로 파싱한다.
- `trex_core/application/execution.py`는 slot의 original venue를 submit·restart·cancel에 사용하고 `FillStatus.execution_venue`를 ledger actual venue로 전달한다. SOR actual이 확인되지 않으면 추론하지 않고 fail-closed 한다.
- migration 023은 persisted AUTO slot/outbox row를 SOR로 전환하고 AUTO 저장을 차단한다. migration 024는 outbox `order_venue`와 nullable actual `venue`를 분리하고 historical SOR actual을 NULL로 보존하며 NULL-only replay-safe backfill과 constraint를 적용한다.
- 구현 기반 revision은 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`다. issue #61 정합 수정은 `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`, explicit null regression은 `43f6fea7ac805f27fba35586af27f0090a672222`다.
- 구현자 검증은 비-DB focused `107 passed`, omitted/null focused `8 passed`, disposable PostgreSQL 16 `24 passed`, 전체 `1074 passed, 52 skipped`, Ruff·ProofLine validate·diff check PASS다.
- 최초 combined focused의 `107 passed, 1 failed, 5 errors`는 종료된 이전 QC PostgreSQL DSN의 connection refused였으며 새 disposable PostgreSQL 16 semantic suite `24 passed`로 재검증되었다.
- 최종 독립 read-only 재QC는 omitted/null SOR normalization, broker request와 ack 이후 slot routing provenance를 확인하고 blocker 0, major 0, minor 0으로 PASS했다. HEAD `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean이었다.
- 현재 active AC-0018은 SOR default, explicit venue preservation, AUTO removal과 no wall-clock routing을 이미 소유하고, active AC-0019는 original order venue와 actual execution venue의 durable split·migration contract를 이미 소유한다.
## Scope
- AC admission은 중복 criterion을 새로 만들지 않고 기존 active AC의 의미를 변경하지 않는 `satisfy`다.
- `satisfy`: AC-0018에 issue #61의 venue 누락/null→SOR, explicit KRX/NXT/SOR 보존, AUTO 제거·migration과 no wall-clock routing 구현 evidence를 결속한다.
- `satisfy`: AC-0019에 issue #61의 ack·cancel·fill polling·restart·ledger/outbox order/execution venue 분리와 fail-closed actual venue 구현 evidence를 결속한다.
- buy/sell intent의 venue 누락과 explicit null은 SOR로 정규화하고 KIS request에 `EXCG_ID_DVSN_CD="SOR"`를 명시한다.
- caller가 explicit KRX/NXT/SOR를 제공하면 admission, slot, broker request, acknowledgement, restart와 cancel에서 변경 없이 보존한다.
- AUTO는 AMQP intent, domain admission, runtime routing, broker adapter input과 persistence에서 허용·생성·저장하지 않는다. 기존 persisted AUTO는 SOR로 idempotent하게 변환한다.
- core는 wall-clock time으로 KRX/NXT를 선택하거나 SOR를 변환하지 않는다.
- original `order_venue`는 KRX/NXT/SOR이며 submit·ack·slot·restart·cancel에 사용한다. actual `execution_venue`는 broker-authoritative KRX/NXT 또는 미확정 NULL이며 fill·ledger/outbox에 별도로 기록한다.
- SOR actual venue는 검증된 KIS code에서만 KRX/NXT로 확정한다. 누락·SOR 단독·미지원 값과 시간으로 actual venue를 추론하지 않는다.
- historical SOR actual은 order SOR와 actual NULL로 보존한다. migration replay는 정상 `(order=KRX, actual=NXT)` 같은 신규 조합을 오염시키지 않고 DB는 AUTO·invalid order와 actual SOR를 거부한다.
- repository current contract 문서와 KIS adapter 주석을 SOR normalization에 맞추고 omitted와 explicit null을 독립 regression으로 검증한다.
## Out of Scope
- TVR의 session·portfolio `markets` 전략 판단 또는 KRX/NXT session policy 변경
- SOR 주문을 실제 운영 KIS 계좌로 제출하거나 취소하는 작업
- live credential·account·database·RabbitMQ·portfolio-hub 연결
- push, PR, 원격 main 반영, image publication, production migration, deployment, release, issue closure 또는 실주문
- ProofLine delivery/release lifecycle closure의 소급 주장
## Risks and Unknowns
### Risks
- 실제 KIS 운영 응답의 actual exchange field 변형은 fixture 기반 parser 검증 범위를 벗어날 수 있다. 미지원 값은 추론하지 않고 NULL/fail-closed로 유지하며 운영 검증은 별도 승인 환경에서 수행한다.
- migration runner는 history table 없이 SQL을 재실행하므로 AUTO→SOR 및 order/execution split migration의 semantic replay-safe 불변식을 향후 변경에서도 유지해야 한다.
- cancel adapter의 내부 backward-compatible default와 intent의 public SOR default는 서로 다른 경계다. execution path는 항상 stored original venue를 명시적으로 전달해야 하며 문서가 이를 혼동하지 않아야 한다.
### Approval State
- Discovery status: `confirmed`
- 승인 근거: 요청자 genichin이 승인 상태인 issue #61 본문을 최대한 포함하여 템플릿 기반 Discovery와 REQ·AC를 승인된 상태로 사후 정리하라고 명시했다.
- Open Questions: 없음
- 이 확인은 current specification과 implementation evidence의 사후 정리다. push, PR, deployment, release, production migration, issue closure 또는 실주문 승인을 의미하지 않는다.
@@ -0,0 +1,49 @@
# GitHub issue #61 comment evidence
- Source: https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337469578
- Author: `genichin`
- Created At: `2026-08-19T04:17:48Z`
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
---
## 구현 완료
### 구현 내용
- venue가 누락되거나 `null`인 buy/sell intent를 `SOR`로 정규화하고 KIS 요청에 `EXCG_ID_DVSN_CD="SOR"`를 명시한다.
- explicit `KRX`/`NXT`/`SOR`를 submit·ack·slot·restart·cancel 전 과정에서 원주문 venue로 보존한다.
- `AUTO`를 admission에서 거부하고 wall-clock KRX/NXT routing을 제거했다.
- persisted `AUTO`를 `SOR`로 전환하고 이후 invalid venue 저장을 차단한다.
- 원주문 `order_venue`와 broker-authoritative actual execution venue를 분리한다. 취소·재시작은 원주문 venue를, ledger/outbox actual venue는 KRX/NXT 또는 미확정 NULL을 사용한다.
- historical SOR actual은 임의 추론하지 않고 NULL로 보존하며 migration replay가 정상 order/execution venue 조합을 오염시키지 않는다.
- current SOR 계약과 충돌하던 interface 문서와 KIS adapter 주석을 정정했다.
- omitted와 explicit `venue: null`을 각각 검증하는 regression을 추가했다.
### 실제 검증 결과
- 구현자 비-DB focused: `107 passed`
- 구현자 explicit omitted/null focused: `8 passed`
- 구현자 disposable PostgreSQL 16: `24 passed`
- 구현자 전체 회귀: `1074 passed, 52 skipped`
- Ruff·ProofLine validate·diff check: PASS
- 최종 독립 재QC: PASS — blocker 0 / major 0 / minor 0
- 최종 worktree: clean
최초 combined focused 실행의 `1 failed, 5 errors`는 종료된 이전 QC PostgreSQL DSN의 connection refused였으며, 새 disposable PostgreSQL 16에서 semantic migration suite를 재실행해 `24 passed`를 확인했다.
### 로컬 커밋
- 기능 구현 기반: `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`
- 계약 문서 정합: `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`
- explicit null regression: `43f6fea7ac805f27fba35586af27f0090a672222`
### 비차단 후속 권고
- 실제 KIS 운영계좌 SOR 주문과 외부 인프라 검증은 별도 승인된 환경에서 수행한다.
- migration runner의 replay-safe 불변식을 향후 migration 변경에서도 유지한다.
### 범위 경계
현재 완료된 것은 **로컬 `main` 구현·테스트·PostgreSQL 검증·커밋과 독립 QC PASS**다. push, PR, 원격 main 반영, image 게시, 운영 migration, 배포, GitHub Release, issue 종료와 실주문은 수행하지 않았다.
@@ -0,0 +1,51 @@
# GitHub issue #61 comment evidence
- Source: https://github.com/fourblocks2020/trex-core/issues/61#issuecomment-5337421562
- Author: `genichin`
- Created At: `2026-08-19T04:10:59Z`
- Evidence Role: repository-local snapshot of a non-canonical implementation comment
- Canonical Authority: GitHub source comment; this snapshot does not create ProofLine lifecycle status
---
## 구현 진행 상황
- **대상:** [Discovery] 명시 venue 기반 주문 실행 계약
- **브랜치:** `main`
- **기준:** `main` (`4975d7166fa70e045096503e85c26d740ae92c55`)
- **상태:** 로컬 main 구현·검증·커밋 및 독립 QC PASS — 미푸시, 미배포
- **최종 업데이트:** 2026-08-19 13:17:45 KST
### 구현 계획 및 작업 목록
- [x] venue가 없는 매수·매도 주문을 SOR 주문으로 제출한다.
- 누락과 `null` 모두 같은 기본값을 사용하고 KIS 요청에 SOR를 명시한다.
- [x] 사용자가 지정한 KRX·NXT·SOR venue를 주문 전 과정에서 그대로 보존한다.
- 주문 접수부터 KIS 제출, 재시작 후 조회와 취소까지 원주문 venue가 바뀌지 않아야 한다.
- [x] AUTO venue를 입력·실행·저장 경계에서 제거한다.
- AUTO 요청을 입구에서 거부하고 시간대에 따라 KRX/NXT를 선택하는 동작도 제거한다.
- [x] 기존 저장 데이터의 AUTO venue를 SOR로 안전하게 전환한다.
- 실제 PostgreSQL에서 반복 적용과 저장 제약을 함께 검증한다.
- [x] SOR 원주문 venue와 실제 체결된 KRX/NXT venue를 구분해 보존한다.
- 취소·재시작은 원주문 SOR를 사용하고 체결 원장에는 확인된 실제 거래소만 기록한다.
- [x] 문서·설정·테스트에 폐기된 runtime AUTO 계약이 남아 있지 않은지 점검한다.
- [x] 관련 집중 테스트, 전체 회귀 테스트, PostgreSQL migration 검증과 코드 품질 검사를 실행한다.
- 검증 실패가 승인 범위 안의 결함이면 수정하고, 계획의 의미나 범위를 바꿔야 하면 즉시 중지하고 재승인을 요청한다.
### 진행 기록
- 2026-08-19 13:10:40 KST — 승인된 이슈 본문, `main` branch와 clean worktree, 기준 SHA `4975d7166fa70e045096503e85c26d740ae92c55`를 확인했다. 별도 worktree나 기능 branch를 만들지 않고 구현·검증을 시작한다.
- 2026-08-19 13:12:28 KST — 기존 local main 구현에서 누락/null→SOR, explicit KRX/NXT/SOR 보존, AUTO admission 거부와 wall-clock routing 제거, order/execution venue 분리를 확인했다. 관련 비-DB 집중 테스트 `107 passed`.
- 2026-08-19 13:12:28 KST — 최초 focused 실행은 종료된 이전 QC의 stale PostgreSQL DSN(`127.0.0.1:32778`) 때문에 `107 passed, 1 failed, 5 errors`였으며 코드 실패가 아니었다. 새 disposable PostgreSQL 16에서 migration·outbox·repository `24 passed`를 확인하고 컨테이너를 제거했다.
- 2026-08-19 13:12:28 KST — current 계약과 충돌하던 interface 문서의 과거 KRX-default 설명 및 KIS adapter 주석을 SOR intent normalization 기준으로 수정했다.
- 2026-08-19 13:13:08 KST — repository 전체 AUTO 검색에서 잔존 항목은 admission 거부·migration 변환 검증과 현재 문서의 폐기 설명뿐이며 runtime AUTO 생성·허용 계약은 확인되지 않았다.
- 2026-08-19 13:13:08 KST — 전체 회귀 `1074 passed, 52 skipped`, Ruff·ProofLine validate·diff check PASS. 로컬 main commit `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`을 생성하고 worktree clean을 확인했다. push·PR·배포·릴리스·실주문은 수행하지 않았다.
- 2026-08-19 13:16:22 KST — 독립 read-only QC PASS: blocker 0, major 0, minor 1. 기능 결함은 없었고 explicit wire `venue: null`을 독립적으로 이름 붙인 테스트가 없다는 minor 검증 공백을 확인했다.
- 2026-08-19 13:16:22 KST — 승인 계획의 누락/null→SOR 검증 범위 안에서 omitted/null parameterized regression을 추가했다. focused `8 passed`, Ruff·diff check PASS, commit `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean. 독립 재QC를 요청한다.
- 2026-08-19 13:17:45 KST — 독립 재QC PASS: blocker 0, major 0, minor 0. omitted/null 두 케이스의 SOR normalization, broker request, ack 후 slot routing provenance를 독립 확인했다. 최종 HEAD `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean.
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0006"
---
+62
View File
@@ -0,0 +1,62 @@
---
id: "req-0006"
status: approved
discovery: "dcy-0006"
criteria:
create: []
update: []
retire: []
satisfy:
- "ac-0018"
- "ac-0019"
---
# 명시 venue 기반 주문 실행 계약
## Objective
buy/sell intent의 venue 누락과 explicit null을 SOR로 정규화하고 caller가 명시한 KRX/NXT/SOR를 original order venue로 보존한다. AUTO와 wall-clock venue routing을 runtime·persistence 계약에서 제거하며 기존 AUTO row를 SOR로 전환한다. SOR 원주문 venue와 broker-authoritative actual execution venue를 분리해 submit·ack·cancel·fill polling·restart·ledger/outbox 전 과정에서 routing provenance와 실제 체결 거래소를 각각 정확하게 보존한다.
## Scope
- buy/sell intent의 venue가 누락되거나 explicit null이면 `SOR`를 사용하고 KIS submit request에 `EXCG_ID_DVSN_CD="SOR"`를 명시한다.
- caller가 `KRX`, `NXT`, `SOR`를 명시하면 admission, slot persistence, KIS submit, acknowledgement, restart lookup와 cancel에서 변경하지 않는다.
- `AUTO` intent는 slot transition과 broker submission 전에 `invalid_venue`로 거부한다. runtime source·설정·문서는 AUTO를 생성하거나 허용하지 않고, negative tests와 migration history만 폐기 계약을 언급할 수 있다.
- core는 wall-clock time 또는 session 추정으로 KRX/NXT를 선택하지 않는다.
- existing persisted AUTO slot/outbox row를 SOR로 idempotent하게 전환하고 이후 invalid venue 저장을 DB constraint로 차단한다.
- original `order_venue`는 KRX/NXT/SOR이며 submit·ack·slot·restart·cancel routing authority다.
- actual `execution_venue`는 broker-authoritative KRX/NXT 또는 미확정 NULL이다. KIS `ORD_EXG_GB`와 explicit exchange-id allowlist에서만 결정하여 fill·ledger/outbox에 별도로 기록한다.
- SOR 단독, 누락, unknown code 또는 time으로 actual venue를 추론하지 않는다. SOR 주문에서 actual이 확인되지 않으면 거짓 ledger actual을 만들지 않는다.
- historical outbox의 SOR는 `order_venue=SOR`, actual `venue=NULL`로 보존한다. NULL-only backfill로 migration replay가 정상 order/execution 조합을 재해석하지 않게 한다.
- omitted와 explicit null wire input, KRX/NXT/SOR preservation, AUTO rejection, KIS payload, partial fill·cancel·restart, outbox round-trip 및 migration semantics를 repository tests와 실제 PostgreSQL에서 검증한다.
- 기존 active AC-0018과 AC-0019를 이 Requirement의 구현 evidence에 `satisfy`로 결속한다. 이후 requester가 명시 승인한 AC revision은 해당 criterion의 최신 canonical scope다.
## Constraints
- actual venue, terminal execution state 또는 broker acceptance를 wall-clock이나 default로 추론하지 않는다.
- cancel·restart는 stored original venue와 original order identity를 사용한다.
- KIS raw payload, credential, token 또는 connection string을 public wire·document evidence에 기록하지 않는다.
- 이 satisfy binding 자체만으로 active AC-0018과 AC-0019의 본문·status를 변경하지 않는다. requester가 명시 승인한 revision은 예외다.
- TVR session/markets 판단과 strategy policy는 변경하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 만족 대상으로 결속한 active AC-0018·AC-0019가 normative specification을 소유한다.
### Current Implementation Assessment
- 이 Requirement는 승인된 GitHub issue #61의 이미 완료된 local implementation을 현재 ProofLine schema에 사후 명세화한다. 과거 구현이 ProofLine delivery chronology를 따랐다는 뜻은 아니다.
- AC-0018 구현 상태: **satisfied by current local implementation evidence**. 누락/null intent→SOR, explicit KRX/NXT/SOR preservation, AUTO admission rejection, no wall-clock routing, persisted AUTO→SOR와 constraint가 구현·검증되었다.
- AC-0019 구현 상태: **satisfied by current local implementation evidence**. original order venue와 actual execution venue split, KIS fail-closed parser, cancel/restart routing provenance, ledger/outbox split, historical SOR actual NULL과 replay-safe migration이 구현·검증되었다.
- 구현 기반 commits는 `15aeae6930f8c3e6ef9cf659f41278b099a78459`, `7366cadf03fc6002bfd01ecf0cba7c24486bb618`, `359ea098ff717e7f04cf4206dcbc64ad17b47755`다.
- issue #61 계약 문서 정합 commit은 `64e4e32f9d7abf7082588b3c4c9a8c0d272762f6`, explicit null regression commit은 `43f6fea7ac805f27fba35586af27f0090a672222`다.
- 구현자 검증: 비-DB focused `107 passed`, omitted/null focused `8 passed`, disposable PostgreSQL 16 `24 passed`, 전체 `1074 passed, 52 skipped`, Ruff·ProofLine validate·diff check PASS.
- 최초 combined focused의 `1 failed, 5 errors`는 stale PostgreSQL DSN connection refused로 분류했고 새 disposable PostgreSQL 16 semantic suite `24 passed`로 재검증했다.
- 최종 독립 재QC는 omitted/null SOR normalization, broker request, ack 후 slot routing provenance를 검증하여 blocker 0, major 0, minor 0으로 PASS했다. 최종 implementation HEAD는 `43f6fea7ac805f27fba35586af27f0090a672222`, worktree clean이었다.
- 실제 KIS 운영계좌 SOR 주문, 외부 RabbitMQ·database·portfolio-hub, push·PR·remote main·production migration·deployment·release는 수행하지 않았다.
- REQ `approved`, AC `active` 및 `satisfy` binding은 current specification authority와 repository-local implementation assessment를 나타낸다. delivery closure, production validation, release 또는 deployment 완료를 의미하지 않는다.
## Non-Goals
- TVR session·portfolio markets gate, KRX/NXT session policy 또는 strategy intent 생성 판단 변경
- actual venue가 불명확할 때 KRX/NXT를 추정하거나 AUTO routing을 복원하는 fallback
- 실제 KIS 운영 주문·취소, live credential 또는 production broker/account 검증
- push, PR, remote main integration, image publication, production migration, deployment, GitHub Release 또는 issue closure
- 기존 active AC-0018·AC-0019의 의미 변경, 신규 중복 AC 생성 또는 ProofLine lifecycle closure 소급 주장
+60
View File
@@ -0,0 +1,60 @@
---
id: "dcy-0007"
status: confirmed
---
# Reconciliation 수량 불일치 알림
## Problem
`trex-core`는 broker와 core slot의 보유수량이 다른 B-case, portfolio-hub와 broker의 종목별 보유수량 합계가 다른 C-case를 reconciliation warning으로 기록한다. 그러나 두 mismatch 경로는 `AlarmEmitter.publish_alarm()`을 호출하지 않아 `trex.alerts` event가 만들어지지 않고, `trex-notifier`가 운영자에게 Telegram 알림을 전달할 수 없다. 현재 scheduler가 같은 mismatch를 반복 탐지하므로 단순 publish 추가는 scheduler tick마다 중복 메시지를 만들 수 있으며, 수량이 바뀌거나 mismatch가 해소된 사실도 별도 lifecycle event로 구분되지 않는다.
기대 상태는 B/C mismatch의 최초 탐지와 material quantity change를 정제된 `trex.alerts` event로 알리고, 기존 warning 해소 권한에 따라 mismatch가 실제로 해소되면 recovery/resolved event를 정확히 한 번 알리는 것이다. 알림 상태나 publish 실패가 reconciliation, warning persistence·clear, slot lifecycle 또는 broker/portfolio-hub 수량을 변경하거나 중단해서는 안 된다.
## Evidence
- 원본 요청: GitHub issue #64 `https://github.com/fourblocks2020/trex-core/issues/64`는 B/C mismatch의 최초 탐지·material change·해소 알림, unchanged warning 억제, 정제된 instance key·symbol·case code·비교 수량, best-effort publish와 B/C·변경·해소 테스트를 요구한다. issue는 2026-08-23 생성되었고 조사 시점에 comment 없이 open 상태였다.
- 조사 기준 `trex-core` revision은 `24a64b55d17263ade8d59f30af50d2a0e5220eae`이며 working tree는 Line 시작 전 clean이었다. issue #64 또는 reconciliation alarm을 참조하는 open/all PR 검색 결과는 없었다.
- `trex_core/application/reconciliation.py:100-199`의 `reconcile_core_holdings()`는 `alarm_emitter`를 받지만 B-case에서는 structured WARNING log와 `warn_store.set_warned(case_code="B")`만 수행한다. 같은 함수의 A-case broker-flat 경로만 `core_holding_broker_flat` alarm을 발행한다.
- `trex_core/application/reconciliation.py:202-280`의 `reconcile_positions()`는 portfolio-hub `asset_quantity`를 symbol별로 합산해 broker 수량과 비교하고 C-case warning을 저장하지만 `alarm_emitter` parameter나 publish 호출이 없다.
- `trex_core/application/snapshot_scheduler.py:306-334`는 C-case `reconcile_positions()`에 emitter를 전달하지 않고 B-case `reconcile_core_holdings()`에만 emitter를 전달한다. 두 reconciliation 호출의 예외는 scheduler를 중단하지 않도록 각각 swallow·log된다.
- `trex_core/application/alarm_emitter.py:35-93`은 allowlist된 caller fields로 `trex.alerts` envelope를 만들고 `TransportUnavailableError`와 그 밖의 exception을 swallow하는 best-effort 경계를 이미 제공한다.
- `trex_core/application/portfolio.py:356-446`, `trex_core/adapters/portfolio/reconcile_warning_store.py:14-98`, migration `016_create_slot_reconcile_warning.sql`은 active warning을 slot별 durable row로 저장하고 동일 slot의 case/reason을 upsert한다. 현재 contract에는 alert fingerprint, last-notified quantity, resolved event 또는 publish 상태가 없고 `clear_warned()`는 row를 삭제한다.
- active AC-0023은 `instance_key + symbol` 기준 C-case 합계 비교, 전략 RPC 제외, intent 차단과 authoritative 합계 재검증 후 C-case warning clear를 소유한다. active AC-0021은 별도 `ledger_skip`의 deduplicated unresolved/resolved alert lifecycle을 소유하지만 reconciliation mismatch는 범위에 포함하지 않는다. active AC-0012의 dedupe는 미확정 cancel alarm에 한정된다.
- `portfolio-hub` canonical source revision `5d6a0ad013900668930fe947f342e1ad1824edaa`의 `app/schemas/v1.py:302-338`은 `PortfolioItem.asset_quantity`와 portfolio list response를 정의하고, `app/api/v1_portfolios.py:315-363`은 service token의 daemon ownership으로 portfolio 목록을 반환한다. 이는 C-case가 소비하는 portfolio별 수량 source와 instance 경계를 확인한다. 해당 checkout에서 `uv run python -m pytest -o addopts='' -q tests/test_v1_portfolios_list.py tests/test_v1_portfolios_daemon_assignment.py`는 `37 passed, 3 warnings`였다.
- 현재 baseline focused 검증 `uv run pytest -q tests/application/test_reconcile_warning_blocking.py tests/application/test_alarm_emitter.py`는 `58 passed`였다. 기존 tests는 B/C warning·logging과 generic alarm envelope를 각각 검증하지만 B/C alarm, unchanged suppression, quantity change와 resolved event를 결합해 검증하지 않는다.
- Line scaffold preflight 전에 upstream AC-0022의 추가 `## Scope note` heading 때문에 ProofLine 0.9.1 validation이 실패했다. 문구를 바꾸지 않고 heading만 inline scope note로 정규화한 뒤 `proofline validate`와 `proofline line init --dry-run`이 통과했다. 이는 새 capability와 무관한 `housekeeping`이다.
## Scope
- AC admission은 reconciliation mismatch의 운영자-visible notification lifecycle을 독립적으로 검증할 장기 active criterion을 `create`하는 후보이며 next allocator 기준 예상 ID는 AC-0024다. AC-0023의 blocking·clear authority나 AC-0021의 ledger-skip alert 의미를 확장 해석해 `satisfy`하지 않는다.
- B-case core-slot↔broker quantity mismatch와 C-case portfolio-hub symbol aggregate↔broker mismatch의 새 탐지에서 `AlarmEmitter`를 통해 sanitized `trex.alerts` event를 발행한다.
- alert dedupe identity는 B-case에서 slot별, C-case에서 `instance_key + symbol`별이다. 동일한 active mismatch의 반복 scheduler tick은 Telegram message로 이어질 event를 재발행하지 않는다. 동일 identity의 case별 비교 quantity tuple이 바뀌면 material change event를 한 번 발행한다.
- recovery/resolved event는 기존 durable warning clear 경로인 B-case operator `sync`, C-case operator `verify`의 symbol aggregate 재검증에서만 발행한다. scheduler의 수량 일치 관찰은 durable warning을 자동 clear하거나 recovery를 발행하지 않는다.
- unchanged suppression은 process restart 뒤에도 유지하는 durable dedupe다. event fingerprint는 alarm publish가 성공한 뒤에만 기록하며 publish 실패 시 reconciliation을 중단하지 않고 다음 detection에서 다시 시도할 수 있어야 한다.
- event에는 최소 `instance_key`, `symbol`, `case_code`, broker quantity와 case별 비교 quantity를 포함하되 account number/hash, credential, token, DSN, raw broker/portfolio-hub payload와 exception 본문은 포함하지 않는다.
- alarm publish 실패는 best-effort로 swallow되어 detection, warning persistence·clear, scheduler 진행과 slot 상태에 영향을 주지 않는다.
- repository-local tests에서 B/C 최초 탐지, unchanged suppression, 비교 수량 변경, resolution, publish failure, 민감 필드 부재를 독립적으로 검증한다.
## Out of Scope
- B/C mismatch 판정식, `case_code` 의미, AC-0023의 C-case 전략 RPC 제외·intent 차단과 기존 warning clear authority 변경
- broker, core slot 또는 portfolio-hub 수량의 자동 보정과 operator `sync`/`verify` 승인 절차 변경
- A-case broker-flat 및 기존 alarm inventory의 의미 변경
- `trex-notifier` 자체의 Telegram routing·message rendering 구현, RabbitMQ topology 변경, production credential·배포·실계좌 검증
- push, PR, merge, release, issue closure와 production migration
## Risks and Unknowns
- slot별 warning row를 그대로 event identity로 사용하면 C-case의 동일 `instance_key + symbol`에 속한 여러 portfolio/slot이 Telegram 알림을 중복 생성할 수 있다. 반대로 symbol 단일 identity는 B-case에서 여러 slot의 독립 수량 차이를 잃을 수 있다.
- 현재 warning upsert는 `updated_at`과 reason을 매 tick 갱신하고 clear 시 row를 삭제하므로, 단순 timestamp 비교로는 unchanged suppression이나 restart-safe resolved event를 증명할 수 없다.
- event fingerprint를 alarm publish 성공 뒤에만 기록해야 하므로 publish 성공 판정과 durable dedupe write 사이의 failure window에서 duplicate 가능성이 있다. 이 경계는 reconciliation transaction과 결합하지 않으면서 retry 가능한 at-least-once 성질을 보존하도록 구현·검증해야 한다.
- C-case 해소는 AC-0023에 따라 symbol aggregate 재검증과 C-case-only clear가 필요하다. scheduler의 process-local `SnapshotStore.warning` clear를 durable recovery authority로 오인하면 premature recovery event가 발생할 수 있다.
### Confirmed Decisions
- B-case dedupe identity는 slot별이고 C-case dedupe identity는 `instance_key + symbol`별이다.
- recovery/resolved authority는 기존 durable clear 경로(B=`sync`, C=`verify`)로 제한한다.
- material change는 동일 identity의 case별 비교 quantity tuple 변경이다.
- unchanged suppression은 restart-safe durable dedupe이며 event fingerprint는 publish 성공 뒤에만 기록한다. 실패한 publish는 business path를 중단하지 않고 다음 detection에서 재시도한다.
- 요청자 genichin이 2026-08-25 위 결정을 명시하고 dcy-0007 승인을 요청했다. Open Questions는 모두 canonical Scope와 Risks에 반영되어 해소되었다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0007"
---
+50
View File
@@ -0,0 +1,50 @@
---
id: "req-0007"
status: approved
discovery: "dcy-0007"
criteria:
create:
- "ac-0024"
update: []
retire: []
satisfy: []
---
# Reconciliation 수량 불일치 알림
## Objective
`trex-core`가 B-case(core slot↔broker)와 C-case(portfolio-hub symbol aggregate↔broker) 수량 불일치의 최초 탐지, material quantity change와 authoritative resolution을 중복 폭주 없이 `trex.alerts`에 발행하게 한다. 알림 lifecycle은 process restart 뒤에도 이어지고 publish 실패는 다음 detection에서 재시도할 수 있어야 하며, reconciliation·warning·slot·broker·portfolio-hub의 기존 동작과 권한을 중단하거나 변경하지 않는다.
## Scope
- B-case mismatch는 stable slot identity별로 추적하고, C-case mismatch는 `instance_key + symbol`별로 추적한다. C-case의 동일 symbol에 여러 portfolio/slot이 있어도 하나의 aggregate mismatch lifecycle로 취급한다.
- B-case 비교 quantity tuple은 core slot quantity와 broker quantity이고, C-case 비교 quantity tuple은 같은 instance·symbol의 portfolio-hub `asset_quantity` 합계와 broker quantity다.
- 이전에 성공적으로 알린 fingerprint가 없는 active mismatch는 detected event를 발행한다. 같은 identity와 같은 quantity tuple의 scheduler 반복 탐지는 event를 재발행하지 않는다.
- active mismatch의 case별 quantity tuple이 바뀌면 changed event를 한 번 발행하고 성공한 새 fingerprint를 durable하게 보존한다.
- B-case recovery/resolved event는 operator `sync`가 broker-authoritative quantity로 warning을 clear하는 경로에서만 발행한다. C-case recovery/resolved event는 operator `verify`가 `instance_key + symbol` aggregate 일치를 재검증하고 C-case warning을 clear하는 경로에서만 발행한다.
- scheduler가 일치 quantity를 관찰하거나 process-local warning을 clear하는 것만으로 durable warning을 자동 clear하거나 recovery event를 발행하지 않는다.
- detected·changed·resolved event는 lifecycle state를 구분하고 `instance_key`, `symbol`, `case_code`, broker quantity 및 case별 비교 quantity를 포함한다. B-case identity에는 stable slot identifier를 포함할 수 있으나 account number/hash, credential, token, DSN, raw external payload 또는 exception 본문은 포함하지 않는다.
- successful publish 뒤에만 해당 lifecycle fingerprint를 durable하게 기록한다. detected/changed publish가 실패하면 다음 동일 detection에서 재시도한다. resolved publish가 실패해도 warning clear는 진행하며, notification state가 writable이면 pending resolved transition을 보존하여 후속 retry opportunity에서 재시도한다.
- 기존 `AlarmEmitter`와 `trex.alerts` envelope·routing contract를 재사용하고 새 event가 `trex-notifier`의 기존 alarm 소비 경계에 도달하게 한다.
- B/C 최초 탐지, C-case 다중 portfolio 단일 event, unchanged suppression, quantity change, restart, B sync resolution, C verify resolution, publish failure retry와 sensitive field 부재를 repository-local automated tests로 검증한다.
## Constraints
- AC-0023의 C-case `instance_key + symbol` aggregate 판정, strategy RPC 제외, intent 차단, C-case-only clear와 operator authority를 완화하거나 대체하지 않는다.
- B/C warning의 `case_code`, mismatch 판정식, slot lifecycle 및 broker/portfolio-hub quantity는 알림을 위해 변경하지 않는다.
- notification state는 restart-safe하고 replay-safe한 persistence로 관리해야 하며 기존 migration runner의 반복 적용을 견뎌야 한다.
- publish success와 durable fingerprint write 사이의 failure window에서는 at-least-once delivery에 따른 duplicate가 가능하지만, fingerprint를 publish 시도 전에 기록하여 event를 영구 유실시키는 방식은 허용하지 않는다.
- alarm publish 및 notification-state persistence failure는 reconciliation, warning set/clear, operator sync/verify와 scheduler를 abort시키지 않는 best-effort 경계여야 한다. resolved publish가 실패하고 notification state가 writable이면 pending transition을 보존하지만, notification-state persistence 자체가 실패하면 authoritative warning clear를 되돌리거나 지연하지 않으며 해당 event 유실 가능성을 sanitized log로 남긴다.
- 기존 alarm consumer와 wire compatibility를 유지하며 RabbitMQ topology 또는 `trex-notifier` message rendering 변경을 요구하지 않는다.
- credential, account 식별정보, token, DSN 및 raw KIS/portfolio-hub payload를 test fixture, event, log 또는 evidence에 기록하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 AC-0024가 normative specification을 소유한다.
## Non-Goals
- B/C mismatch 판정, warning persistence·blocking·clear policy 또는 AC-0023 수정
- scheduler 관찰에 의한 automatic warning clear 또는 recovery 발행
- broker/core/portfolio-hub quantity 자동 보정과 operator `sync`/`verify` 승인 절차 변경
- A-case broker-flat 및 기존 alarm inventory의 semantics 변경
- `trex-notifier`의 Telegram routing·rendering 구현, RabbitMQ topology 변경과 production delivery 검증
- live credential·account·database·RabbitMQ·portfolio-hub 연결, production migration, deployment, release 또는 issue closure
+61
View File
@@ -0,0 +1,61 @@
---
id: "dcy-0008"
status: confirmed
---
# Reconcile-warned strategy intent rejection alerts
## Problem
활성 reconciliation warning이 있는 slot에 외부 strategy가 신규 buy 또는 sell intent를 제출하면 `IntentService`는 `reconcile_warned` 사유로 안전하게 거부하지만, 현재는 RPC 응답과 `intent_log` 외에 운영자에게 즉시 전달되는 신호가 없다. Strategy가 마지막 slots RPC 이후 발생한 warning을 알지 못한 채 intent를 제출하는 race에서도 거부 자체는 정상 동작하지만, 운영자는 반복되는 거부와 전략 정체의 원인을 별도 조사 전까지 인지하기 어렵다.
기대 상태는 durable intent idempotency와 기존 reconciliation 차단 권한을 유지하면서, 새 `intent_id`가 최초로 `reconcile_warned` 최종 거부된 시점에만 allowlist 기반 `trex.alerts` 경고를 best-effort로 한 건 발행하는 것이다. 동일 `intent_id`의 retry는 저장된 결정을 그대로 반환하되 추가 경고를 만들지 않아야 하며, 알림 실패가 intent 거부·응답·저장 또는 다른 business path를 바꾸어서는 안 된다.
## Evidence
- GitHub issue [#74](https://github.com/fourblocks2020/trex-core/issues/74)는 실운영에서 warning 활성 뒤 strategy intent가 거부됐지만 즉시 알림이 없어 원인 파악이 지연된 사례를 기록하고, 최초 `reconcile_warned` 거부 알림·중복 억제·민감정보 제외·non-blocking 동작을 요구한다. 확인 시 issue는 open이고 후속 comment나 연결된 PR은 없었다.
- 현재 `trex_core/application/intents.py`의 `submit_buy()`와 `submit_sell()`은 `IntentStore.try_insert()`를 dedup gate로 호출한 뒤 admission 결과를 `finalize()`하고 반환한다. Duplicate `intent_id`는 `_dedup()`에서 기존 결정을 반환하므로 새 admission을 실행하지 않는다.
- 같은 파일의 buy·sell warning gate는 `warn_store.is_warned(slot_id)`가 참이면 `IntentDecision(status="rejected", reason="reconcile_warned")`을 반환한다. `IntentService` constructor에는 `alarm_emitter` 의존성이 없고, 이 최종 거부를 `trex.alerts`에 발행하는 호출도 없다.
- `trex_core/runtime/daemon.py`는 하나의 `AlarmEmitter`를 생성해 ledger, kill-switch, scheduler와 execution service에 주입하지만 `IntentService`에는 주입하지 않는다. 따라서 production intent path에는 재사용 가능한 publisher가 존재하지만 wiring이 빠져 있다.
- `trex_core/application/alarm_emitter.py`의 `publish_alarm()`은 `severity`로 `critical | warn | info`를 사용하고 publish exception을 삼켜 호출자의 business path를 중단하지 않는 best-effort 계약이다. `docs/interface-contract.md`도 warning wire literal을 `warn`으로 정의한다.
- `trex_core/adapters/persistence/postgresql.py`의 `PostgreSQLIntentStore`는 `intent_id` unique insert를 durable dedup gate로 사용하고 final `status`, `slot_id`, `reason`을 저장한다. 별도 notification state 없이도 “최초 처리된 intent만 발행 후보”라는 경계는 이 기존 gate로 정할 수 있다.
- `tests/application/test_reconcile_warning_blocking.py`와 `tests/application/test_slots_handler_quantity_mismatch.py`는 buy/sell `reconcile_warned` 거부와 last-RPC race를 검증하지만 alarm 발행을 단언하지 않는다. `tests/application/test_messaging_intent_consumer.py`는 duplicate `intent_id`가 저장된 동일 결정을 반환하는 idempotency를 검증한다.
- 현재 `main` revision `2e314232e3073381b6fc9ee268ae8e12fbcc8749`에서 관련 baseline인 `tests/application/test_reconcile_warning_blocking.py`, `tests/application/test_slots_handler_quantity_mismatch.py`, `tests/application/test_messaging_intent_consumer.py`, `tests/application/test_alarm_emitter.py`를 실행한 결과 `84 passed`였다.
- Git history에서 `c193388`은 external strategy buy/sell에 warning gate를 의도적으로 추가하면서 safety-sell·kill-switch·watchdog가 `IntentService`를 우회하도록 정했다. 이번 Line은 그 차단 정책이나 내부 안전 경로를 변경하지 않고, 이미 확정된 외부 intent 거부의 운영 가시성만 추가한다.
## Scope
- AC admission은 새 `AC-0025` **create**로 분류한다. 가장 가까운 `AC-0023`은 C-case strategy RPC 제외·intent blocking·warning clear 권한을 소유하고, `AC-0024`는 B/C mismatch 자체의 detected/changed/resolved lifecycle을 소유한다. 이번 동작은 “외부 strategy intent가 실제로 차단된 최초 사건”이라는 별도 운영 신호이며 독립 PASS/FAIL과 장기 active 가치가 있으므로 기존 AC의 update 또는 satisfy로 흡수하지 않는다. `update`, `retire`, `satisfy`, `release evidence` 대상은 없다.
- `IntentService`의 buy·sell 처리에서 새 `intent_id`의 최종 decision이 `status="rejected"` 및 `reason="reconcile_warned"`로 확정되고 `IntentStore.finalize()`가 성공한 뒤에만 `AlarmEmitter.publish_alarm()`을 한 번 호출한다.
- Alarm은 기존 wire 계약에 맞춰 `severity="warn"`, code `strategy_intent_rejected`를 사용한다. Fields는 최소 allowlist인 `instance_key`, `intent_id`, `intent_type`, `reason`, `broker_submitted=false`를 포함하고, 요청 또는 canonical slot에서 안전하게 확인 가능한 경우에만 `slot_id`, `portfolio_id`, `symbol`, normalized `strategy_id`를 추가한다.
- Buy의 canonical `slot_id` 입력 또는 legacy `portfolio_id` mapping과 sell의 `slot_id` 입력 모두에서 가능한 slot context를 수집하되, context 조회 실패나 optional field 부재는 원래 `reconcile_warned` decision을 바꾸거나 알림을 위해 새로운 mutation을 유발하지 않는다.
- 동일 `intent_id`의 duplicate/retry는 기존 durable `intent_log` 결정을 반환하고 alarm을 재발행하지 않는다. 최초 처리 중 관찰되는 `processing`/`duplicate_in_flight` 응답도 별도 alarm 대상이 아니다.
- Alarm publish 성공 여부와 무관하게 원래 거부 decision과 RPC 응답은 유지된다. Publisher 부재와 transport failure는 non-blocking이며 별도 durable outbox, notification-state row 또는 retry loop를 만들지 않는다.
- Alarm title·fields·rendered log에는 credential, token, account number/hash, DSN, raw strategy payload, raw broker/provider payload와 raw exception body를 포함하지 않는다.
- Production daemon은 이미 생성하는 동일 `AlarmEmitter`를 `IntentService`에 주입한다. Optional dependency를 사용하는 직접 application test와 non-daemon construction은 기존 동작을 유지한다.
- Verification은 buy와 sell의 최초 경고 각 한 건, 같은 `intent_id` retry의 추가 경고 0건, 다른 `intent_id`의 독립 경고, admitted 및 다른 rejection reason의 무발행, publish failure의 non-blocking, payload allowlist·`broker_submitted=false`, production wiring과 기존 warning blocking/idempotency regression을 포함한다.
## Out of Scope
- `AC-0023`의 B/C warning 판정, slots RPC 제외, buy/sell 차단, fail-closed 및 authoritative clear semantics 변경.
- `AC-0024`의 mismatch detected/changed/resolved alarm과 `reconcile_notification_state` 변경 또는 통합.
- Strategy retry·backoff·pause·circuit-breaker 정책, strategy 또는 TVR wire schema 변경, 새로운 broker order submit/cancel 동작.
- Safety-sell, kill-switch, watchdog 등 `IntentService`를 우회하는 내부 안전 경로의 차단 또는 알림.
- 다른 rejection reason에 대한 범용 intent rejection alarm, 알림 집계·rate limit·severity escalation.
- Alarm delivery를 위한 durable outbox, 별도 notification table, 재시작 후 재발행 또는 publish retry 보장. `finalize()` 성공과 alarm publish 사이 process crash로 인한 알림 유실 가능성은 이 Line에서 해소하지 않는다.
- `trex-notifier`의 message formatting·routing·delivery channel 변경과 운영 배포 자체.
## Risks and Unknowns
### Risks
- `IntentStore.finalize()` 뒤 best-effort publish를 수행하므로 process가 그 사이 종료되거나 transport가 실패하면 durable decision은 남지만 alarm은 유실된다. Issue가 outbox/retry를 제외했으므로 이 Line은 business path 독립성을 우선하며, delivery 보장이 필요해지면 별도 Requirement로 다룬다.
- Buy는 canonical `slot_id`와 legacy `portfolio_id` 두 입력을 허용하고 sell은 `sell_strategy_id`를 사용한다. Context 정규화가 admission logic을 복제하면 drift 위험이 있으므로, 알림은 decision에 영향을 주지 않는 helper와 allowlist output으로 제한하고 양쪽 입력 형태를 테스트해야 한다.
- 기존 `AlarmEmitter`는 여러 subsystem이 공유한다. Intent-specific 실패 처리나 logging 변경이 공용 emitter 계약을 확장하지 않도록 production wiring과 publisher failure test를 분리해야 한다.
- Rejection payload에 raw request 또는 `extra` dict를 복사하면 민감정보가 유출될 수 있다. 허용된 scalar field만 명시적으로 구성하고 forbidden key/value sentinel을 rendered payload와 log에서 검사해야 한다.
### Assumptions
- Issue #74의 “경고(WARNING)”는 현재 interface contract의 wire literal `severity="warn"`을 의미한다.
- “최초”의 canonical identity는 기존 `IntentStore.try_insert()`가 소유하는 `intent_id`이며, process-local set이나 새로운 persistence를 추가하지 않는다.
- `strategy_intent_rejected`는 issue가 제시한 이 기능의 canonical alarm code로 사용하며, notifier-side inventory 등록이 별도로 필요하더라도 core의 event contract를 변경하지 않는다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0008"
---
+51
View File
@@ -0,0 +1,51 @@
---
id: "req-0008"
status: approved
discovery: "dcy-0008"
criteria:
create:
- "ac-0025"
update: []
retire: []
satisfy: []
---
# Reconcile-warned strategy intent rejection alerts
## Objective
`trex-core`가 활성 reconciliation warning 때문에 외부 strategy의 신규 buy 또는 sell intent를 `reconcile_warned`로 최종 거부하면, 해당 `intent_id`의 최초 처리에서만 원인을 식별할 수 있는 sanitized warning event를 `trex.alerts`에 한 건 발행하게 한다. 기존 durable intent idempotency와 warning 차단 권한을 유지하고, duplicate intent 및 무관한 decision의 알림을 억제하며, 알림 실패가 intent decision·저장·응답 또는 broker 동작에 영향을 주지 않도록 한다.
## Scope
- `IntentService`가 새 `intent_id`를 처리하여 buy 또는 sell decision을 `status="rejected"`, `reason="reconcile_warned"`로 결정하고 `IntentStore.finalize()`가 성공한 뒤에만 warning event를 발행한다.
- 동일 `intent_id`의 duplicate/retry는 기존 `IntentStore.try_insert()`와 저장된 final decision을 canonical dedup authority로 사용한다. Duplicate path, 처리 중인 intent 및 process-local cache는 새 event를 만들지 않는다.
- 서로 다른 `intent_id`가 같은 warned slot을 대상으로 각각 거부되면 각 intent는 독립적인 최초 거부 event 한 건을 만든다.
- Event는 기존 `AlarmEmitter`와 `trex.alerts` envelope·routing contract를 재사용하고 `severity="warn"`, `code="strategy_intent_rejected"`를 사용한다.
- Event fields의 필수 allowlist는 `instance_key`, `intent_id`, `intent_type`, `reason="reconcile_warned"`, `broker_submitted=false`다. Canonical slot 또는 intent에서 안전하게 확인된 값이 있을 때만 `slot_id`, `portfolio_id`, `symbol`, `strategy_id`를 추가하며, unavailable optional field는 추정하거나 raw payload에서 복사하지 않고 생략한다.
- Buy의 `strategy_id`와 sell의 `sell_strategy_id`는 event의 공통 `strategy_id` field로 정규화한다. Buy의 canonical `slot_id` 또는 legacy `portfolio_id` 입력과 sell의 `slot_id` 입력 모두에서 같은 allowlist 계약을 적용한다.
- Optional context 조회 실패, publisher 부재 및 alarm transport failure는 원래 final decision과 RPC response를 변경하거나 intent 처리를 실패시키지 않는다. 알림을 위해 slot, warning, intent, broker, ledger 또는 portfolio-hub 상태를 mutation하지 않는다.
- Production daemon은 이미 생성하는 동일 `AlarmEmitter`를 `IntentService`에 연결한다. 직접 application construction과 non-daemon entry point는 optional publisher 경계에서 기존 동작과 호환된다.
- Buy·sell 최초 거부, duplicate 억제, 서로 다른 intent의 독립 발행, 다른 decision의 무발행, publish failure, optional context, payload 보안과 production wiring을 repository-local automated tests로 검증한다.
## Constraints
- AC-0023이 소유하는 B/C warning 판정, strategy RPC 제외, buy/sell `reconcile_warned` 차단, warn-store fail-closed 및 authoritative clear semantics를 완화·대체·확장하지 않는다.
- AC-0024의 mismatch detected/changed/resolved lifecycle, fingerprint, `reconcile_notification_state`와 operator `sync`/`verify` recovery authority를 변경하거나 재사용하지 않는다.
- Alarm은 `IntentStore.finalize()`보다 먼저 발행해서는 안 된다. Finalize가 실패하거나 final decision이 확정되지 않은 경우 알림을 성공한 거부로 보고해서는 안 된다.
- Publisher return value나 failure는 저장된 rejection을 rollback하거나 RPC status/reason을 바꾸지 않는다. 별도 durable outbox, notification-state persistence 또는 retry loop를 추가하지 않는다.
- Event title, fields와 이 경로가 남기는 rendered log에는 account number/hash, credential, token, password, DSN, raw strategy `extra`, raw request, raw KIS/broker/portfolio-hub payload 또는 raw exception body가 없어야 한다.
- `broker_submitted=false`는 거부가 broker submission 전에 확정됐음을 나타내며, 이 기능이 broker submit/cancel 호출을 새로 만들거나 기존 호출 순서를 바꾸어서는 안 된다.
- Safety-sell, kill-switch, watchdog처럼 `IntentService`를 우회하는 내부 안전 경로는 계속 reconciliation warning gate와 이 strategy rejection alarm의 대상이 아니다.
- 기존 intent RPC response schema, intent idempotency, slot transition과 alarm wire compatibility를 유지하며 RabbitMQ topology 또는 notifier rendering 변경을 요구하지 않는다.
- Normative Design Documents: 없음. 이 REQ와 AC-0025가 normative specification을 소유한다.
## Non-Goals
- 다른 rejection reason의 범용 strategy intent alarm, reason별 severity 정책, rate limit·aggregation·escalation
- Strategy retry·backoff·pause·circuit-breaker 정책과 strategy/TVR wire schema 변경
- B/C mismatch 탐지·해소 알림과 strategy intent 거부 알림의 lifecycle 또는 persistence 통합
- Alarm의 exactly-once delivery, process crash 복구, durable outbox, publish retry와 재시작 후 재발행
- Warning 자동 해제, quantity 자동 보정, broker order submit/cancel, ledger write 또는 portfolio-hub write
- Safety-sell·kill-switch·watchdog 내부 경로의 신규 알림
- `trex-notifier` message formatting·delivery channel, RabbitMQ topology, production deployment, release 또는 issue closure
+132
View File
@@ -0,0 +1,132 @@
---
id: "dcy-0009"
status: confirmed
---
# Persistent strategy-instance state lifecycle and identity
## Problem
`slot_strategy_state`는 trex-core가 내용을 해석하지 않는 opaque 전략 상태이지만, 현재 저장·조회·삭제 계약은 그 상태의 lifecycle과 소유권을 core의 slot occupancy 및 `strategy_id`에 결합한다. 슬롯이 어떤 경로로든 `INACTIVE`가 되면 core가 해당 슬롯의 모든 전략 상태를 삭제하고, 저장된 `occupancy_seq`가 현재 슬롯과 다르면 조회에서 숨긴다. 이 계약은 TSL peak처럼 occupancy마다 새로 시작하는 상태에는 맞지만, TVR-v2 reserve처럼 close와 재점유를 넘어 같은 전략 실행 instance가 이어서 사용해야 하는 상태를 표현하지 못한다. Opaque `data`의 의미와 만료 시점을 모르는 core가 모든 전략에 동일한 삭제 정책을 강제한다는 점도 책임 경계와 맞지 않는다.
현재 row와 RPC namespace는 `(slot_id, strategy_id)`를 사용한다. 여기서 `strategy_id`는 `tvr_v2` 같은 전략 종류를 나타내므로, 같은 종류의 서로 다른 설정·실행 instance가 같은 슬롯을 사용하면 하나의 row와 version counter를 공유한다. 한 instance의 상태가 다른 instance에 상속되거나 서로의 CAS가 충돌할 수 있다.
`strategy_instance_id`는 state namespace이면서 전략 실행 instance의 안정적인 identity다. 현재 `slots.req`는 한 슬롯에서 조회된 모든 strategy-state namespace를 map으로 반환하지만, 일반 strategy가 필요한 것은 자기 `strategy_instance_id`에 해당하는 state뿐이다. 다른 strategy instance의 state를 함께 노출하면 consumer가 잘못된 entry를 선택할 여지가 있고 wire가 불필요하게 전체 namespace를 공개한다. `strategy_instance_id`는 매 요청에서 임의로 선택하는 값이 아니라 strategy runtime의 필수·불변 non-empty string 설정이어야 한다. 누락·비문자열·빈 값처럼 잘못 설정된 instance가 실행되면 state namespace 오염보다 잘못된 slot·queue·주문 경계에서 전략이 동작하는 문제가 더 크므로, 그런 runtime은 worker나 decision cycle을 시작할 수 없어야 한다.
기대 상태는 lifecycle과 identity 변경을 함께 다루는 것이다. Strategy state는 slot occupancy에서 완전히 분리되어 close와 재점유를 넘어 보존되고, 하나의 slot-scoped `data` map 안에서 안정적인 `strategy_instance_id`별 entry와 monotonic version을 가져야 한다. State persistence와 write RPC에서 `occupancy_seq`를 제거하고 동시 write는 요청 instance entry의 version CAS만으로 조정한다. 하나의 core `instance_key`에는 여러 strategy instance가 연결될 수 있으며 core가 그 목록을 별도 registry로 저장하지 않는다. 각 strategy runtime은 필수 설정으로 확정한 자기 `strategy_instance_id`만 `slots.req`와 state write에 사용하고, core는 전체 map이 아니라 요청 ID와 정확히 일치하는 entry 하나만 slots 응답에 넣어야 한다. 잘못된 설정이면 strategy는 실행 전에 fail-closed해야 한다.
## Evidence
- GitHub issue [#77](https://github.com/fourblocks2020/trex-core/issues/77)은 close 시 자동 삭제와 read-side occupancy filtering을 제거하고 state 의미·session 만료·갱신 판단을 전략에 돌리도록 요구한다. 본문에는 이전 설계의 `expected_occupancy_seq` CAS 유지가 남아 있다.
- GitHub issue [#78](https://github.com/fourblocks2020/trex-core/issues/78)은 그 뒤 확정된 persistence/write contract로서 state namespace와 RPC의 소유 키를 안정적인 `strategy_instance_id`로 바꾸고, 저장 요청을 `{slot_id, strategy_instance_id, expected_version, data}`로 정의한다. 사용자는 #78이 `occupancy_seq`에 관한 마지막 결정이며 이 계약이 #77의 `expected_occupancy_seq` 유지 문구보다 우선한다고 확인했다. 또한 slot persistence는 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 slot-scoped map으로 저장한다고 확인했다. 따라서 이 draft는 strategy-state persistence·application model·write RPC·CAS에서 `occupancy_seq`를 제거하고 map entry별 version CAS만 유지한다.
- GitHub issue [#79](https://github.com/fourblocks2020/trex-core/issues/79)는 `slots.req` response에 관한 후속 최종 계약이다. 저장소에는 여러 `strategy_instance_id` entry가 함께 존재하지만 일반 전략 응답에는 요청의 `strategy_instance_id`와 정확히 일치하는 entry 하나만 `{version, data, updated_at}` 단일 object로 반환하고 다른 instance의 ID와 state를 포함하지 않는다. 사용자는 응답 필터 기준이 `strategy_id` 집합이 아니라 요청의 정확한 `strategy_instance_id`라고 확인했다. 이 Discovery에서는 core-side instance 목록 registry나 caller 인증을 두지 않으며, `strategy_instance_id`를 strategy runtime의 필수·불변 설정으로 사용한다.
- 현재 `main` revision `ddf7d6710bed9d0b8b91a3ce9c96ad17e129bd78`의 `trex_core/adapters/persistence/sql/015_create_slot_strategy_state.sql`은 `PRIMARY KEY (slot_id, strategy_id)`를 선언하고 `occupancy_seq`, `version`, opaque `data`를 저장한다.
- `trex_core/application/strategy_state.py`의 `StrategyStateEntry`, `StrategyStateStore`, `InMemoryStrategyStateStore`는 state를 `(slot_id, strategy_id)`로 식별한다. `get_for_slots()`는 저장된 `occupancy_seq`가 현재 값과 다르면 row를 숨기고, `delete_all(slot_id)`는 해당 slot의 모든 namespace를 삭제한다. `_cas()`는 `expected_occupancy_seq == current_occupancy_seq`와 expected/stored version 일치를 독립적으로 검사한다.
- `trex_core/adapters/persistence/postgresql.py`의 `PgStrategyStateStore`도 `(slot_id, strategy_id)`로 `SELECT ... FOR UPDATE` 및 upsert를 수행한다. Version은 기존 row가 없으면 0에서 시작하고 성공한 write마다 1 증가한다.
- `trex_core/domain/slot/service.py`의 `SlotService.transit_slot()`은 모든 `to_state is INACTIVE` 전이에서 `strategy_state_store.delete_all(slot_id)`을 호출한다. Strategy state 삭제는 slot update 및 transition-log append와 같은 transition 경로 안에서 실행된다.
- `trex_core/application/messaging_handlers.py`의 `make_slots_rpc_handler()`는 현재 occupancy와 일치하는 모든 state를 읽어 `strategy_state[strategy_id]` map 전체를 슬롯 row에 넣는다. `make_strategy_state_set_handler()`는 payload의 `strategy_id`를 필수로 받아 그대로 저장소에 전달하며 caller와 namespace의 소유 관계를 검사하지 않는다.
- 실제 write 호출은 strategy daemon이 AMQP direct-reply-to RPC로 instance별 `trex.slots.strategy_state.set[.{instance_key}]` queue에 요청하는 방식이다. 현재 TSL client는 `{slot_id, strategy_id, expected_occupancy_seq, expected_version, data}`를 보내며 `data`를 opaque JSON string으로 직렬화한다. 현재 구현의 `correlation_id`는 reply matching에만 사용되고 `expected_occupancy_seq`와 `expected_version` CAS가 각각 occupancy 전환과 version 충돌을 검사한다. 이 중 occupancy 결합은 #78의 최종 payload로 대체할 현재 baseline이다.
- `trex_core/runtime/daemon.py`는 `slots.req`와 `strategy_state.set` consumer를 `config.instance_key`별 queue에 연결하지만 두 handler에 strategy-instance registry를 주입하지 않는다. `trex_core/runtime/config.py`와 repository 전체를 `strategy_instance_id`로 검색한 결과 정의나 사용처는 0건이고, per-instance TOML에는 `instance_key`만 있다.
- `tests/domain/slot/test_occupancy_seq_and_close_delete.py`는 모든 close 경로의 자동 삭제와 재점유 후 빈 상태를 기대한다. `tests/application/test_slots_handler_strategy_state.py`는 재점유 시 이전 상태가 노출되지 않는 현재 계약을, `tests/application/test_strategy_state_set_handler.py`와 `tests/application/test_strategy_state_store.py`는 occupancy mismatch 거부와 `(slot_id, strategy_id)`별 version CAS를 검증한다.
- 위 네 focused test와 PostgreSQL 관련 test를 현재 revision에서 `strategy_state or occupancy_seq or close_delete`로 실행한 결과 `33 passed, 1 skipped, 9 deselected`였다. 이는 issue가 바꾸려는 현재 lifecycle·key·visibility baseline이 실행 가능한 상태임을 확인한다.
- Git history의 `e0d4e7130400bbfe576d5a6fc94b87dcc26f0734`는 issue #26과 legacy `dcy-028`의 “strategy state lifecycle은 slot occupancy와 일치한다”는 전제 아래 close-delete, occupancy-filtered read, `(slot_id, strategy_id)` key와 occupancy/version CAS를 함께 도입했다. 현재 `docs/runtime-flows.md` 349~359행과 `docs/interface-contract.md` 527~541행도 같은 C-1/C-2/C-3 계약을 설명한다.
- 현재 ProofLine active criteria 전체에서 `strategy_state`, `occupancy_seq`, `delete_all`을 검색한 결과 직접 소유하는 AC는 없었다. 가장 가까운 `AC-0023`은 C-case reconciliation warning에 따른 strategy-facing `slots.req` 제외와 intent blocking을 소유하며 state persistence와 namespace는 다루지 않는다.
## Scope
- 이번 Line은 strategy state의 lifecycle을 slot occupancy에서 분리하고, state namespace의 identity를 `strategy_instance_id`로 전환하는 변경을 함께 다룬다.
- 이 변경의 Acceptance Criteria는 version-independent 동작을 검증하는 서로 독립적인 신규 AC 세 건으로 분리한다.
- close·재점유를 넘어 strategy state와 version을 삭제하지 않고 보존하는 lifecycle criterion
- 슬롯별 state map에서 `strategy_instance_id`를 각 state entry의 key로 사용하고 같은 `strategy_id`의 instance별 state·version 격리를 보장하는 identity criterion
- `strategy_instance_id`를 전략용 request-scoped identity로 사용하여 `spy=false`인 `slots.req`가 해당 ID의 state만 반환하고 누락·비문자열·빈 ID 요청을 fail-closed하되, `spy=true`인 operator 조회는 `strategy_instance_id`를 요구하지 않고 watchdog을 갱신하지 않으며 각 슬롯의 전체 strategy-state map을 `strategy_instance_id`별 entry로 반환하는 criterion
- 기존 active AC의 `update`, `retire`, `satisfy`, `release evidence` 대상은 없다. 특히 `AC-0023`의 reconciliation warning 판정·slot 제외·intent blocking·clear 권한은 변경하지 않는다.
- Legacy Discovery와 REQ는 업데이트 대상이 아니다. 현재 `docs/runtime-flows.md`와 `docs/interface-contract.md`의 strategy-state baseline 문구만 새 AC가 승인된 뒤 approved change로 정합화한다.
- 어떤 경로의 `→ INACTIVE` 전이에서도 일반 lifecycle hook이 slot-scoped strategy-state map이나 그 entry를 자동 삭제하지 않게 한다. Map은 inactive와 이후 재점유 상태에서도 유지하며 현재 occupancy와 무관하게 다시 볼 수 있게 한다.
- Core는 opaque `data`를 파싱하거나 전략별 session·reserve·peak의 유효성을 판단하지 않는다. State의 session identity와 내용을 이어 쓸지 초기화할지는 전략이 결정한다.
- `occupancy_seq`는 slot-scoped strategy-state persistence schema, `StrategyStateEntry`, store protocol, read filtering, `strategy_state.set` request/response와 state CAS에서 제거한다. Slot 자체의 `occupancy_seq`와 증가 규칙은 이 Line에서 변경하지 않지만 strategy-state persistence와 write 승인에는 사용하지 않는다.
- State write는 `expected_version`과 slot map의 `data[strategy_instance_id].version`만 비교한다. 해당 entry가 없을 때 `expected_version=0`, 성공 시 그 entry의 version만 기존 값에서 1 증가하며 close·reopen은 version을 초기화하지 않는다. 다른 instance entry와 version은 변경하지 않는다. 같은 slot에서 서로 다른 instance entry를 동시에 갱신해도 각 성공 write가 모두 보존되고 어느 entry도 유실되지 않아야 한다. Slot active-state 검사와 inner opaque data size limit는 유지한다.
- 일반 lifecycle에는 strategy-state 삭제 동작을 두지 않는다. 기존의 파괴적 전체 DB reset은 별도 운영 기능으로 유지한다.
- Persistence, in-memory model, dataclass, store protocol과 PostgreSQL query/update는 슬롯별로 하나의 state map을 소유하고 `strategy_instance_id`를 각 state entry의 key로 사용하도록 전환한다. 각 entry는 `{version, data, updated_at}`를 가진다. `strategy_instance_id`가 entry의 저장·조회 key이며 별도의 `strategy_id` metadata를 entry에 중복 저장하지 않는다. 여기서 inner `data`만 전략이 소유하는 opaque string이며 outer map과 entry envelope은 core contract다. 아래 JSON은 설명용 응답 예시가 아니라 entry 전체를 그대로 보존하는 canonical persistence shape이다.
```json
{
"data": {
"tvr-v2-prod-01": {
"version": 8,
"data": "{...}",
"updated_at": "..."
},
"new-v1-prod-01": {
"version": 7,
"data": "{...}",
"updated_at": "..."
}
}
}
```
- 같은 `strategy_id`를 사용하는 서로 다른 `strategy_instance_id`는 동일 slot map에서도 독립된 entry와 version을 가져야 한다. 재시작 후 같은 instance ID를 사용하는 owner는 기존 entry를 이어 읽고, 다른 instance ID는 이를 상속하지 않는다.
- `strategy_state.set`은 `strategy_instance_id`를 필수 field로 받으며, core는 누락·비문자열·빈 값을 state 저장 없이 non-retryable `bad_request`로 거부한다. 별도의 core-side registry나 `instance_key ↔ strategy_instance_id` 목록 검증은 도입하지 않는다.
- 저장 순서는 `slots.req` read → 전략의 opaque state 계산 → `strategy_state.set` write다. 변경 후 write payload는 `{slot_id, strategy_instance_id, expected_version, data}`이며, `expected_version`은 직전 `slots.req`의 자기 `strategy_state.version`을 사용하고 entry가 없을 때만 `0`을 사용한다. Core는 active slot·해당 entry의 version CAS·inner data size limit를 모두 통과한 경우에만 slot map의 `data[strategy_instance_id]`를 create/update하고 증가한 version을 반환한다.
- `version_conflict`이면 전략은 추정한 값으로 재전송하지 않고 다음 `slots.req`에서 자기 state를 다시 읽어 재계산한다. `occupancy_changed` reason은 계약에서 제거한다. `slot_inactive`, `state_too_large`, `bad_request:*`는 해당 write를 적용하지 않는다. RPC `correlation_id`는 저장 멱등성 key로 사용하지 않는다.
- `spy=false`인 전략용 `slots.req` 요청은 `strategy_instance_id`를 필수 field로 받는다. 누락·비문자열·빈 값이면 core는 RPC-level error나 새 envelope을 만들지 않고 기존 `slots.req` response envelope으로 다음 non-retryable `bad_request`를 반환한다. `reason="bad_request:strategy_instance_id"`가 retry하지 않을 configuration error의 machine-readable 판별값이며, `slots=[]`이므로 어떤 slot이나 `strategy_state`도 노출하지 않는다.
```json
{
"slots": [],
"available_slots": null,
"degraded": false,
"reason": "bad_request:strategy_instance_id",
"message": "strategy_instance_id must be a non-empty string"
}
```
정상 전략 요청에서는 각 slot의 전체 map을 wire에 넣지 않고 `data[request.strategy_instance_id]`만 선택하여 응답의 `strategy_state`에 `{version, data, updated_at}` 단일 object로 넣는다. 해당 key가 없으면 기존 absent-state 의미를 유지해 `strategy_state` field를 생략하며, 다른 strategy instance의 state를 대신 반환해서는 안 된다. 다음 JSON은 전체 `slots.req` envelope이 아니라 `slots[]` 내부 row 예시다. `strategy_instance_id="tvr-v2-prod-01"` 요청에는 해당 state만 포함한다.
```json
{
"slot_id": "slot-001",
"strategy_state": {
"version": 8,
"data": "{...}",
"updated_at": "..."
}
}
```
- 이 single-object response는 canonical wire contract다. 응답의 `strategy_state` 안에는 `strategy_instance_id` key나 다른 instance를 열거하는 collection을 두지 않는다.
- 같은 `instance_key`에 여러 strategy instance가 연결되어도 요청 A에는 `data[A]`만, 요청 B에는 `data[B]`만 반환한다. Core는 이를 위해 strategy-instance 목록 registry를 유지하지 않고 요청의 필수 `strategy_instance_id`를 map lookup key로만 사용한다.
- `spy=true`인 operator `slots.req`는 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지한다. Operator 응답의 각 `slots[]` row에는 해당 슬롯의 전체 strategy-state map을 `strategy_instance_id`별 entry로 구성한 `strategy_state` field를 포함한다. 요청에 `strategy_instance_id`가 있더라도 operator 조회 결과를 특정 entry로 제한하지 않는다. 이 동작은 기존 `slots.req`를 사용하며 별도 operator/diagnostic API를 신설하지 않는다.
- 기존 state에 대해 별도의 `strategy_id → strategy_instance_id` mapping, backfill 또는 instance 귀속 정책을 두지 않는다. 변경된 slot-scoped `data[strategy_instance_id]` persistence contract를 그대로 적용하며, 이를 별도 migration 결정 사항으로 다시 열지 않는다.
- Verification은 close 후 map 보존, inactive/read visibility, 재점유 후 entry version 연속 증가, state persistence·model·RPC에서 `occupancy_seq` 부재, entry별 version conflict 거부와 다른 entry 무변경, 같은 종류의 두 instance 격리, 같은 slot의 서로 다른 A·B entry에 대한 동시 성공 write가 모두 보존되는 조건, 동일 slot map에 A·B state가 있을 때 전략 요청 A에는 `data[A]`만/전략 요청 B에는 `data[B]`만 반환되고 다른 ID와 전체 map이 비노출되는 조건, 요청 ID에 해당하는 state가 없을 때 `strategy_state` field 생략, 누락·비문자열·빈 `spy=false` 요청 ID가 기존 envelope의 `slots=[]`, `degraded=false`, `reason="bad_request:strategy_instance_id"`로 반환되어 slot·state를 노출하지 않는 조건과 같은 malformed state-write ID의 저장 거부, `strategy_instance_id` 없는 `spy=true` operator CLI의 기존 slot 조회 성공·watchdog 비갱신·각 슬롯의 전체 strategy-state map 반환, InMemory/PostgreSQL parity 및 production daemon wiring을 포함한다.
## Out of Scope
- `profit_buffer`, portfolio 공통 모델, slot occupancy state machine 또는 Slot 자체의 `occupancy_seq`와 증가 규칙 변경.
- TVR-v2 reserve·cost basis·processed execution 계산, TSL peak/session 해석 또는 어떤 전략의 opaque `data` schema 정의.
- Core가 strategy state의 session 만료나 occupancy 귀속을 판단하는 기능.
- Core-side strategy-instance registry, `instance_key`별 strategy 목록 저장, caller 인증 또는 AMQP producer 권한 변경.
- Operator/diagnostic용 전체 strategy-state 조회 API 신설.
- Strategy-state를 audit/event store로 전환하거나 row history, exactly-once write, multi-writer merge를 도입하는 기능.
- `AC-0023`·`AC-0024`·`AC-0025`의 reconciliation warning 및 알림 lifecycle 변경.
- Strategy consumer 통합. Consumer는 통합 전제 조건으로 runtime 설정에 required non-empty string `strategy_instance_id`를 두고 startup에서 누락·비문자열·빈 값을 검증해야 한다. 검증 실패 시 RPC worker와 decision cycle을 시작하지 않고 configuration error로 종료하며, 시작 후에는 ID를 정규화·변형하거나 요청별로 override하지 않아야 한다. 이 동작과 검증은 각 consumer repository가 소유한다.
- TVR-v2 daemon 또는 다른 전략 repository의 구현·배포. 이 Line은 trex-core persistence와 wire contract만 소유한다.
## Risks and Unknowns
### Risks
- Close-delete와 occupancy-filtered read를 제거하면 slot-scoped map과 그 entry가 장기간 남는다. 같은 `strategy_instance_id`의 반복 write는 새 entry를 늘리지 않고 기존 entry를 갱신한다.
- Lifecycle, key/wire와 strategy configuration 변경을 부분적으로 배포하면 기존 consumer와 새 core의 contract가 어긋날 수 있으므로 core와 strategy consumer를 lockstep으로 배포해야 한다.
- Consumer가 통합 전제 조건인 `strategy_instance_id` startup 검증과 불변 사용을 구현하지 않으면 잘못된 namespace로 요청할 수 있다. 이 위험의 완화와 contract test는 각 consumer repository가 소유한다.
- `slots.req` handler가 storage의 전체 map을 먼저 조립한 뒤 consumer 쪽에서 필터링하면 wire 노출은 해소되지 않는다. Core의 조회 또는 response 조립 단계에서 요청 ID 하나만 선택하고 serialized payload에 다른 ID가 없는지 검증해야 한다.
- Strategy-state에서 occupancy fence를 제거하므로 close·reopen 자체는 이전 read에서 계산된 write를 거부하는 근거가 아니다. Core는 state 내용을 해석하지 않으며 `strategy_instance_id` namespace와 version CAS만 보장한다. Occupancy/session 전환에 따라 data를 초기화해야 하는 전략은 opaque state 내부 규칙으로 처리해야 한다.
- `SlotService.transit_slot()`의 close-delete hook을 제거할 때 slot transition의 나머지 원자성과 실패 동작이 바뀌지 않아야 한다.
### Assumptions
- `strategy_instance_id`는 process PID나 매 restart마다 달라지는 runtime ID가 아니라 배포 재시작 뒤에도 같은 논리적 전략 instance가 재사용하는 안정적인 식별자다.
- 하나의 trex-core `instance_key`를 여러 strategy instance가 소비할 수 있으며, 각 strategy instance는 자기 runtime 설정에서 고유하고 안정적인 `strategy_instance_id`를 소유한다.
- 하나의 strategy instance가 같은 slot에서 소유하는 current state는 map entry 하나이며, version은 그 entry의 successful write마다 증가하는 CAS counter다. State history나 mergeable multi-writer log는 요구하지 않는다.
- Strategy-state row와 wire contract는 slot occupancy identity를 저장하거나 비교하지 않는다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0009"
---
+54
View File
@@ -0,0 +1,54 @@
---
id: "req-0009"
status: approved
discovery: "dcy-0009"
criteria:
create:
- "ac-0026"
- "ac-0027"
- "ac-0028"
update: []
retire: []
satisfy: []
---
# Persistent strategy-instance state lifecycle and identity
## Objective
`trex-core`의 opaque strategy state를 slot occupancy lifecycle과 `strategy_id` namespace에서 분리한다. 상태를 슬롯별 `strategy_instance_id` entry로 영속화하여 close와 재점유 뒤에도 같은 instance가 기존 state와 version을 이어 쓰게 하고, 서로 다른 instance의 state와 CAS를 격리한다. 전략용 `slots.req`에는 요청한 instance의 state 하나만 제공하고 operator `spy=true` 조회에는 슬롯의 전체 instance map을 제공하되, 각 경로의 identity 검증·watchdog·가시성 계약을 명확히 유지한다.
## Scope
- 어떤 경로로 slot이 `INACTIVE`가 되어도 일반 lifecycle hook이 해당 슬롯의 strategy-state map이나 entry를 자동 삭제하지 않으며, inactive 및 재점유 뒤에도 state와 version을 보존한다.
- Strategy-state persistence, application model, store protocol, read filtering, write RPC와 CAS에서 `occupancy_seq` 및 `expected_occupancy_seq`를 제거한다. Slot 자체의 `occupancy_seq`와 증가 규칙은 유지한다.
- 슬롯별 strategy-state를 `data[strategy_instance_id] = {version, data, updated_at}` 형태의 map으로 저장한다. Entry에는 `strategy_id`를 중복 저장하지 않으며 inner `data`만 core가 해석하지 않는 opaque string이다.
- `strategy_state.set` 입력을 `{slot_id, strategy_instance_id, expected_version, data}`로 정의한다. 해당 instance entry의 version만 CAS하고, 서로 다른 instance의 성공한 동시 write를 모두 보존한다.
- `spy=false` 전략용 `slots.req`는 required non-empty string `strategy_instance_id`를 받고, 각 slot에서 요청 ID와 정확히 일치하는 entry 하나만 `strategy_state: {version, data, updated_at}`로 반환한다. 해당 entry가 없으면 `strategy_state`를 생략한다.
- 누락·비문자열·빈 `strategy_instance_id`를 받은 전략용 `slots.req`는 기존 response envelope에서 `slots=[]`, `available_slots=null`, `degraded=false`, `reason="bad_request:strategy_instance_id"`, `message="strategy_instance_id must be a non-empty string"`을 반환한다. 같은 malformed ID의 state write는 저장 없이 non-retryable `bad_request`로 거부한다.
- `spy=true` operator `slots.req`는 `strategy_instance_id`를 요구하지 않고 기존 filter·slot 조회와 watchdog 비갱신 동작을 유지한다. 각 slot row에는 전체 strategy-state map을 `strategy_instance_id`별 entry로 반환하며, 요청에 ID가 있어도 특정 entry로 제한하지 않는다.
- Persistence와 RPC 변경을 InMemory/PostgreSQL parity, production daemon wiring 및 repository-local automated tests로 검증한다.
## Constraints
- State write는 active slot 검사, inner opaque data size limit 및 해당 entry의 `expected_version` CAS를 모두 통과해야 한다. Entry가 없을 때만 `expected_version=0`이며 성공할 때 해당 entry의 version만 1 증가한다.
- `version_conflict`는 write를 적용하지 않으며 다른 entry를 변경하지 않는다. Consumer는 추정 version으로 재전송하지 않고 다음 `slots.req`에서 자기 state를 다시 읽어 재계산한다.
- `occupancy_changed`는 strategy-state reject reason에서 제거한다. `slot_inactive`, `state_too_large`, `bad_request:*`는 write를 적용하지 않고 RPC `correlation_id`를 저장 멱등성 key로 사용하지 않는다.
- Core는 opaque state의 session 의미, 만료, 초기화 또는 merge를 판단하지 않으며 core-side strategy-instance registry, `instance_key ↔ strategy_instance_id` 목록 및 caller 인증을 도입하지 않는다.
- 기존 state에 대한 `strategy_id → strategy_instance_id` mapping, backfill 또는 instance 귀속 정책을 도입하지 않고 신규 persistence contract를 그대로 적용한다.
- 같은 `strategy_instance_id`는 재시작 뒤에도 같은 논리적 전략 instance가 사용하는 안정적 identity다. Consumer 통합은 각 consumer repository가 소유하며 required non-empty string 설정, startup fail-closed, 불변 사용 및 요청별 override 금지를 전제한다.
- AC-0023의 reconciliation warning 판정·slot 제외·intent blocking·clear 권한과 AC-0022의 strategy-facing broker quantity 계약을 변경하지 않는다.
- Legacy Discovery와 REQ는 업데이트하지 않는다. 새 AC 승인 뒤 현재 `docs/runtime-flows.md`와 `docs/interface-contract.md`의 strategy-state baseline 문구만 approved change에 맞게 정합화한다.
- Normative Design Documents: 없음. 이 REQ와 AC-0026~AC-0028이 이번 specification의 normative behavior를 소유한다.
## Non-Goals
- `profit_buffer`, portfolio 공통 모델, slot occupancy state machine 또는 Slot 자체의 `occupancy_seq`와 증가 규칙 변경
- TVR-v2 reserve·cost basis·processed execution, TSL peak/session 또는 다른 전략의 opaque `data` schema 정의
- Core가 strategy state의 session 만료·occupancy 귀속·초기화 여부를 판단하는 기능
- Core-side instance registry, strategy 목록 저장, caller 인증, AMQP producer 권한 또는 queue topology 변경
- 별도 operator/diagnostic strategy-state API 신설
- Strategy-state history, audit/event store, exactly-once write 또는 multi-writer merge
- Strategy consumer repository의 구현·배포 및 production rollout
- 기존 파괴적 전체 DB reset 제거 또는 변경
- Legacy Discovery·REQ 및 AC-0023~AC-0025의 lifecycle·본문 변경
+5 -2
View File
@@ -13,12 +13,15 @@ RUN test "${#SOURCE_COMMIT}" -eq 40 \
esac \
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
COPY pyproject.toml ./
COPY pyproject.toml uv.lock ./
COPY trex_core/ ./trex_core/
RUN python -m pip install --no-cache-dir . \
RUN python -m pip install --no-cache-dir "uv==0.11.14" \
&& uv sync --locked --no-dev --no-editable \
&& useradd --create-home --uid 10001 trex
ENV PATH="/opt/trex-core/.venv/bin:${PATH}"
USER 10001:10001
ENTRYPOINT ["trex-core-daemon"]
+8 -4
View File
@@ -49,9 +49,12 @@
- limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가.
- allocated_capital :: numeric? :: 배정 자본
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(AUTO/KRX/NXT/SOR). intent 단계의 AUTO는 KIS 제출 시 실제 KRX/NXT로 교체되며 주문번호와 원자 저장한다. pending 주문·취소까지 보존하고 HOLDING 진입 시 다음 자율주문의 AUTO, INACTIVE 진입 시 KRX로 초기화한다.
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니며 같은 주문의 자동 재취소를 막는다.
- cancel_requested_at :: TIMESTAMPTZ? :: 취소 요청 결과를 저장한 시각. 주문 종료·dispatch clear 때 함께 제거한다.
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 원주문/라우팅 거래소(KRX/NXT/SOR). venue 누락 intent는 SOR로 저장하고, pending 주문·재시작·취소까지 보존한다. 실제 KRX/NXT 체결 거래소는 `FillStatus.execution_venue`에서 별도로 확정해 `ledger_outbox.venue`에 기록하고 원주문은 `ledger_outbox.order_venue`에 보존한다.
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니다. requested는 재시도하지 않고, rejected/unknown만 authoritative open-row 확인과 bounded backoff 뒤 재시도할 수 있다.
- cancel_requested_at :: TIMESTAMPTZ? :: 마지막 취소 시도 예약 시각. 주문 종료·dispatch clear 때 함께 제거한다.
- order_submitted_at :: TIMESTAMPTZ? :: timeout deadline의 immutable 주문 제출 기준점. poll/cancel bookkeeping 및 daemon 재시작에서 보존하고 terminal dispatch clear 때 제거한다.
- order_date :: TEXT? :: 원주문 KST 날짜(`YYYYMMDD`). 날짜 rollover 뒤 KIS 일별주문체결조회가 현재 날짜가 아닌 원주문 날짜를 사용하도록 보존한다.
- cancel_attempts :: INTEGER NOT NULL DEFAULT 0 :: 원자적으로 예약된 취소 시도 횟수. 새 dispatch에서 0, terminal dispatch clear에서 0으로 초기화한다.
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
- portfolio_id :: str? :: 포트폴리오 식별
- forced_close_at :: datetime? :: 강제 청산 마감선
@@ -150,7 +153,8 @@
- side :: TEXT CHECK ('buy','sell')
- qty / price / fee / tax :: NUMERIC(18,4)
- executed_at :: TIMESTAMPTZ?
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 실제 제출 거래소. 기존 행은 KRX로 보존한다.
- venue :: TEXT? CHECK (NULL 또는 'KRX'/'NXT') :: broker-authoritative 실제 체결 거래소. 과거 SOR row처럼 실제 거래소를 복원할 수 없으면 NULL이며 임의 추론하지 않는다.
- order_venue :: TEXT NOT NULL DEFAULT 'KRX' CHECK ('KRX'/'NXT'/'SOR') :: 원주문/라우팅 venue. 취소·재시작 감사에 사용한다.
- status :: TEXT CHECK ('pending','completed','conflict_blocked','delivery_failed') — `delivery_failed` 추가(dcy-015, 마이그레이션 007)
- attempts :: INTEGER :: 재시도 누적
- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked','permanent_failure','dead_lettered')
+38 -21
View File
@@ -64,7 +64,7 @@
| 패턴 | 표면 | 요청 속성 | 응답 |
|---|---|---|---|
| 슬롯 RPC | `trex.slots.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{slots[], degraded, reason, message}` (REQ-073/074, BAT-035) |
| intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. **rejected sell** 응답은 additive `retryable: bool` 포함(REQ-007 AC-7/issue #27 — core 소유 reason→retryable, 미인식 기본 true, `bad_request:*` false; admitted/processing/buy 는 미포함). 체결/종료는 응답 아닌 이벤트로 |
| intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. retryability·broker attempt·KIS failure 정보는 strategy 응답에 포함하지 않는다. 체결/종료는 응답 아닌 이벤트로 |
| update_thresholds | `trex.intent.req` | `action="update_thresholds"`, `reply_to` | reply-to 큐로 `{status:updated|ignored, slot_id, reason?}` (REQ-040) |
| 이벤트 pub/sub | `trex.events` (topic) | routing key `{event_type}.{strategy_id}.{symbol}` | 전략 큐가 자기 `strategy_id` 패턴으로 바인딩 |
@@ -74,11 +74,14 @@
```
{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all"
"spy": true | false, # default false; true = watchdog 갱신 안 함(운영자 조회)
"strategy": str # optional (REQ-116 BAT-055) — exact-match on strategy_name
"strategy": str, # optional (REQ-116 BAT-055) — exact-match on strategy_name
"strategy_instance_id": str # required non-empty string when spy=false; ignored for spy=true filtering
}
```
- `filter`: 서버 측 슬롯 status 필터. `"inactive"` 는 buyable 슬롯(universe에 바인딩된 매수 가능 슬롯). 미인식 값은 `"all"` 로 처리.
- `spy=true`: 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신하지 않는다(운영자 CLI 조회가 전략 생존 신호로 오인되지 않도록 — REQ-034 CHG-01).
- `spy=false`: `strategy_instance_id`는 required non-empty string이다. 누락·비문자열·빈 값은 `{slots:[], available_slots:null, degraded:false, reason:"bad_request:strategy_instance_id", message:"strategy_instance_id must be a non-empty string"}`를 반환하며 watchdog을 갱신하지 않는다.
- strategy-state visibility: `spy=false`는 exact requested entry 하나를 `strategy_state: {version,data,updated_at}`로만 반환하고 missing entry는 생략한다. `spy=true`는 supplied ID와 무관하게 complete `{strategy_instance_id: {version,data,updated_at}}` map을 반환한다.
- `strategy` (REQ-116 additive): 지정 시 `strategy_name == strategy` 인 슬롯 행만 반환. 대소문자 exact-match. 미지정 시 기존과 완전 동일. `filter` 와 AND 조합. 매칭 없으면 `slots=[]`(에러 없음). `available_slots` 는 strategy 필터와 무관하게 인스턴스 전체 기준으로 계산(REQ-073 AC-6 불변).
코어가 reply-to 큐로 보내는 응답 payload:
@@ -88,7 +91,11 @@
{ "slot_id": "str (UUID)",
"status": "inactive | pending_buy | pending_sell | holding | error",
"symbol": "str | null",
"qty": "float | null", // slot mirror (REQ-062)
"qty": "float | null", // holding: broker authority / unavailable=null
"qty_source": "broker | unavailable | null", // non-holding=null
"broker_quantity_available": "bool", // non-holding=false
"broker_quantity_captured_at": "ISO-8601 | null", // non-holding=null
"slot_quantity": "float | null", // core daily_slot mirror (diagnostic)
"asset_quantity": "float | null", // hub authority (REQ-062)
"avg_price": "float | null",
"entry_price": "float | null",
@@ -97,7 +104,7 @@
"universe_detached": "bool",
"cash_balance": "float | null", // hub authority
"cash_estimated": "bool", // true = pending ledger outbox delta 반영
"warned": "bool", // qty != asset_quantity 시 true (REQ-062)
"warned": "bool", // slot_quantity != asset_quantity 시 true
"watchdog": { // null if watchdog store unavailable
"expired": "bool",
"last_seen": "ISO-8601 | null"
@@ -119,12 +126,16 @@
| 필드 | 권위 소스 | 비고 |
|---|---|---|
| `slot_id`, `status`, `avg_price`, `entry_price`, `forced_close_at`, `universe_detached` | slot (core) | 슬롯 row 직접 |
| `qty` | slot (core) | 슬롯 mirror — broker position |
| `qty` | broker account snapshot | holding 슬롯에서만 authoritative. unavailable/stale 시 null |
| `qty_source` | 파생(metadata) | holding 슬롯: `broker` \| `unavailable`; non-holding은 null |
| `broker_quantity_available` | 파생(metadata) | holding 슬롯 broker qty freshness 통과 여부; non-holding=false |
| `broker_quantity_captured_at` | broker account snapshot | holding 슬롯 broker qty capture 시각; non-holding=null |
| `slot_quantity` | slot (core) | 기존 daily_slot.qty mirror(진단용) |
| `symbol`, `portfolio_id` | slot (core) | 자산 바인딩 |
| `asset_quantity` | hub (SnapshotStore) | portfolio-hub 권위값 (REQ-062) |
| `cash_balance` | hub (SnapshotStore) | portfolio-hub 권위값; `cash_estimated=true` 시 ledger outbox delta 추정값 |
| `cash_estimated` | ledger outbox | pending delta 있을 때 true (REQ-029) |
| `warned` | 파생 | `qty != asset_quantity` (REQ-074 AC-2) |
| `warned` | 파생 | `slot_quantity != asset_quantity` (REQ-074 AC-2) |
| `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 |
| `paused` | PauseStore | durable pause overlay (REQ-032) |
| `strategy_name` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. portfolio-hub 정본. hub 미제공 시 null |
@@ -132,6 +143,7 @@
| `markets` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. list[str]; hub 미제공 시 [] |
- **slot-origin strategy 필드 없음** (REQ-074 AC-5, REQ-116 additive 재도입 후에도 유지): `strategy_id`, `sell_strategy_id`, `active_strategy_id` 는 응답에 포함되지 않는다. `strategy_name`/`strategy_params`/`markets` 는 PortfolioItem(hub) 출처이므로 AC-5 제외 대상과 구분된다 — REQ-116 Notes 참조.
- **Breaking change (holding `qty`) rollout**: 기존 전략 클라이언트가 `qty=slot mirror`로 가정했다면 `slot_quantity`를 읽도록 전환해야 한다. 권장 경로는 (1) 클라이언트에 `qty_source`/`broker_quantity_available` 체크 추가 → (2) fail-closed (`qty is null`) 처리 배포 → (3) `qty`를 broker 정본으로 사용하도록 cutover 순서다.
- `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호.
- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용.
@@ -396,7 +408,7 @@ resume_kill_switch 요청·응답 (REQ-046):
### Persistence
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, venue, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `venue`는 Core 내부 실제 제출 거래소이며 Portfolio Hub wire에는 아직 보내지 않는다.
- PostgreSQL: `ledger_outbox(..., venue, order_venue, ...)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `order_venue`는 원주문 KRX/NXT/SOR, `venue`는 broker fill에서 확인한 실제 KRX/NXT 체결 거래소다. Portfolio Hub wire에는 아직 보내지 않는다.
## `OrderRequest` DTO (BAT-004 / REQ-011, BAT-056 / REQ-117)
@@ -414,10 +426,10 @@ resume_kill_switch 요청·응답 (REQ-046):
### Additive Notes (REQ-117)
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환.
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 기본은 `AUTO`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. 허용값 외에는 `invalid_venue`로 admission 전 거부한다.
- KIS 어댑터는 `AUTO`를 KST 세션 기준 실제 KRX/NXT로 확정한 뒤 `EXCG_ID_DVSN_CD`로 전송한다. 결정 불가능 구간은 KRX로 대체하지 않고 주문을 거부한다.
- `OrderAck.venue`의 실제 제출 거래소는 `broker_order_id`와 함께 슬롯에 원자 저장되며 재시작·취소·Core 원장 outbox에서 재사용한다.
- `venue` 필드는 BAT-056에서 additive 추가됐으며, 현재 계약에서 기존 orb/tsl을 포함한 미지정 호출은 `SOR`로 정규화한다.
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 누락은 `SOR`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. 폐기된 `AUTO`와 그 밖의 값은 `invalid_venue`로 admission 전 거부한다.
- KIS 어댑터는 KRX/NXT/SOR를 시간과 무관하게 보존해 `EXCG_ID_DVSN_CD`로 전송한다.
- `OrderAck.venue`는 원주문 KRX/NXT/SOR를 `broker_order_id`와 함께 슬롯에 원자 저장하며 재시작·취소에 재사용한다. 실제 체결 거래소는 `FillStatus.execution_venue`의 KRX/NXT로 별도 전달해 outbox `venue`에 기록한다. SOR 또는 미지원 코드는 실제 거래소로 추론하지 않는다.
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
@@ -447,8 +459,8 @@ cancel_order(broker_order_id: str, *, side: str, symbol: str, qty: int, venue: s
- `venue`: 원주문 venue. 취소 시 원주문과 동일한 `EXCG_ID_DVSN_CD`를 사용해야 한다. 기본값 `"KRX"`로 기존 호출자 하위 호환.
- `CancelOutcome.state`: `requested`(요청 접수), `rejected`(명시적 거부), `unknown`(응답유실·전송 실패). `requested`도 최종 취소가 아니며 `poll_fill()`의 `cancelled|filled`만 주문의 최종 상태를 확정한다.
- Core는 외부 취소 호출 전에 `unknown`을 먼저 영속화하고 응답 뒤 실제 즉시 결과로 교체한다. 따라서 요청과 저장 사이에 프로세스가 종료돼도 자동 timeout 취소는 같은 주문을 재전송하지 않고, 재시작 뒤 체결조회로만 수렴한다.
- timeout과 운영자 취소 모두 `cancel_state IS NULL` 비교·설정에 성공한 한 호출만 외부 요청을 보낸다. 이후 반복 요청은 `cancel_already_recorded`로 응답하고 poll만 계속한다.
- Core는 외부 취소 호출 전에 `unknown`과 시도 횟수를 원자적으로 영속화하고 응답 뒤 실제 즉시 결과로 교체한다. 따라서 요청과 저장 사이에 프로세스가 종료돼도 재시작 시 먼저 체결조회를 수행한다.
- `requested`는 재시도하지 않는다. `rejected|unknown`은 후속 `poll_fill()`이 원주문을 open으로 확인하고 bounded backoff·최대 시도 조건을 만족한 경우에만 재시도한다. row 미가시성·missing·accepted만으로 blind retry하지 않는다. 운영자 반복 요청은 기존 `cancel_already_recorded` 경계를 유지한다.
### get_current_price
@@ -461,11 +473,15 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
### KIS 어댑터 EXCG 명시화 (REQ-118)
- `submit_order`: body에 `EXCG_ID_DVSN_CD: req.venue` 항상 명시 전송 (이전: 미전송·서버 기본값 의존 → 명시화로 견고성 향상). 실측(2026-07-06): NXT(EXCG=NXT) 주문 rt_cd=0 확인.
- `AUTO` 세션: 08:00~08:49:59 NXT, 09:00:30~15:29:59 KRX, 15:30~20:00 NXT. 그 밖의 시간은 `auto_venue_session_closed`로 fail-closed 한다.
- venue 누락은 `SOR`로 제출하며, 명시 KRX/NXT/SOR는 그대로 제출한다. core는 wall-clock으로 venue를 선택하지 않는다.
- `cancel_order`: body에 `EXCG_ID_DVSN_CD: venue` 명시 전송. 실측(2026-07-06): NXT 취소 rt_cd=0 확인.
- `poll_fill`: KIS 신형 일별주문체결조회 실전 `TTTC0081R`, 모의
`VTTC0081R`을 사용하고 `EXCG_ID_DVSN_CD=ALL`을 전달한다. 일반 주문번호
조회와 결과불명 주문 탐색이 같은 계약으로 KRX·NXT 체결을 확인한다.
일반 주문은 영속화된 `order_date`(`YYYYMMDD`)를 조회일로 사용하며,
row가 없으면 bounded 제출 직후 visibility grace 이후 `unknown`으로
fail-closed 한다. `order_submitted_at`/`order_date`를 알 수 없는 legacy
pending row는 mutable `updated_at`로 추정하지 않고 CRITICAL alarm과 pending을 유지한다.
## `FillStatus` 확장 (BAT-006 / REQ-018)
@@ -511,21 +527,21 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
- pika BlockingConnection 은 thread-safe 가 아니므로 consumer/publisher 마다 자기 connection/channel 을 가져야 한다.
- 토폴로지 선언은 멱등하지만 attribute(durable, args) 가 기존 큐와 다르면 RabbitMQ 가 PRECONDITION_FAILED 로 거부 → batch 마이그레이션 시 큐 재선언 절차 필요.
## Interface: `trex.slots.strategy_state.set` RPC (BAT-042 / dcy-028, REQ-080)
## Interface: `trex.slots.strategy_state.set` RPC (REQ-0009)
- Type: external-service (strategy daemon ↔ core write RPC)
- Consumers / Producers: 전략 데몬(produce) ↔ core 핸들러(consume). per-instance 큐(`{base}.{instance_key}`), direct reply-to, JSON.
- Purpose: 슬롯의 opaque strategy-state 를 occupancy_seq+version CAS 로 write. read 는 `trex.slots.req` 에 piggyback(별도 RPC 아님).
- Purpose: 슬롯별 opaque strategy-instance state entry를 version CAS로 write. read는 `trex.slots.req`에 piggyback(별도 RPC 아님).
### Inputs
- `{slot_id, strategy_id, expected_version:int, expected_occupancy_seq:int, data:str}`. `correlation_id = "{slot_id}:{strategy_id}:{expected_version}"`(멱등), delivery persistent. `data` 는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB).
- `{slot_id, strategy_instance_id, expected_version:int, data:str}`. `strategy_instance_id`는 required non-empty string이며 `correlation_id`는 저장 멱등성 key가 아니다. `data`는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB).
### Outputs / Outcomes
- 성공: `{ok:true, slot_id, strategy_id, version:<new>}`.
- 실패 reason 전체 집합: `occupancy_changed`(+`occupancy_seq`) / `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason 에 fail-closed.
- 성공: `{ok:true, slot_id, strategy_instance_id, version:<new>}`.
- 실패 reason 전체 집합: `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason에 fail-closed.
### Compatibility Rules
- read 표면(`trex.slots.req`)의 각 슬롯 행에 additive 로 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{<strategy_id>:{version,data,updated_at}}`)이 실린다(REQ-073/074 CHG). 기존 소비자는 unknown 필드 무시로 하위호환. `strategy_state` 는 strategy_* reasoning 필드(strategy_id 등, REQ-074 AC-5 제외 대상)와 구분되는 opaque 상태 맵이다.
- slot strategy-state는 occupancy와 독립된 slot-scoped `data[strategy_instance_id] = {version,data,updated_at}` map이다. close/reopen은 entry를 삭제하거나 version을 초기화하지 않는다. Entry에는 `strategy_id`나 `occupancy_seq` metadata가 없으며, Slot 자체의 `occupancy_seq` semantics는 별도로 유지된다.
## Shared Constraints
@@ -630,7 +646,7 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
- 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026).
- 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후).
- 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065).
- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true, `bad_request:*`/terminal false). 손절매 재시도 안전 경로 decoupling. 완전 additive(admitted/processing/buy·기존 필드 불변).
- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`을 도입했으나, GitHub #60 승인 계약으로 폐기됐다. 현재 strategy RPC는 retryability·broker attempt·KIS failure를 전달하지 않는다.
- 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 계약: 신규 write RPC `trex.slots.strategy_state.set`(occupancy_seq+version CAS, reject reason 집합 `occupancy_changed`/`version_conflict`/`slot_inactive`/`state_too_large`/`bad_request`), `trex.slots.req` 행에 additive `occupancy_seq`+`strategy_state` 맵(REQ-073 AC-7/REQ-074 AC-6, opaque UTF-8+size cap, strategy_* reasoning 필드와 구분). close(`→INACTIVE`) 시 state 자동 삭제 (REQ-079~082).
- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`.
- 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01).
@@ -641,6 +657,7 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
결과불명 주문 탐색을 KIS 신형 일별주문체결조회
(`TTTC0081R`/`VTTC0081R`, `EXCG_ID_DVSN_CD=ALL`)로 단일화했다.
- 2026-07-06 :: BAT-056 (dcy-002 FR-6, REQ-117/118) — `OrderRequest.venue: str = "KRX"` additive 추가(기존 위치 인수 순서 불변 — AMQP wire 계약 영향 없음, 브로커 어댑터 계층 DTO까지만). KIS 어댑터: submit_order/cancel_order body에 `EXCG_ID_DVSN_CD` 항상 명시 전송(기존 미전송 → KRX 명시화 견고성 향상), cancel_order/get_current_price 시그니처 venue/market 파라미터 추가(기본값 KRX/"J" — 기존 호출자 무영향). paper broker 시그니처 정합(venue/market 무시). 실측 근거: 2026-07-06 NXT 주문/취소 rt_cd=0 확인.
- 2026-08-24 :: issue #62 — `trex.slots.req` holding row `qty`를 broker holdings snapshot 정본으로 전환. `qty_source`/`broker_quantity_available`/`broker_quantity_captured_at`/`slot_quantity`를 추가하고, broker qty unavailable 또는 stale이면 fail-closed(`qty=null`, no core/hub fallback)로 응답.
## REQ-123 Contract Addendum: cost-basis canonical parsing & sync diagnostics
-8
View File
@@ -40,14 +40,6 @@ pkill -TERM -f trex-core-daemon
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
### 컨테이너 설정, 배포 및 rollback
- 이미지는 per-instance TOML, `.env`, 자격증명 또는 연결 정보를 포함하지 않는다. 인스턴스 TOML은 read-only volume으로 mount하고 `trex-core-daemon --config <mounted-path>`로 지정한다. secret은 배포 플랫폼의 secret 기능 또는 runtime environment로만 주입하고 image layer나 command line에 기록하지 않는다.
- 게시된 `ghcr.io/fourblocks2020/trex-core:<source-commit-first-12>` tag는 source commit을 찾는 stable selector일 뿐이다. 배포 전 registry가 반환한 `ghcr.io/fourblocks2020/trex-core@sha256:<64-hex>` reference를 기록하고 workload는 이 digest로 pin한다.
- rollback은 이전에 검증하고 기록한 digest reference로 workload image를 되돌린 뒤 정상적인 배포 절차로 재시작한다. tag를 다시 해석하거나 source에서 다시 build하지 않는다.
- `trex-core-reset`은 파괴적인 DB 작업이며 image rollback이나 자동 rollback 절차에 절대로 포함하지 않는다. schema/data 변경의 복구는 image rollback과 분리된 승인 절차로 수행한다.
- v0.1.1부터 image smoke는 DB에 연결하거나 migration을 적용하지 않고, installed package의 SQL migration filename inventory가 해당 source commit의 non-empty canonical inventory와 정확히 일치하는지 검증한다.
---
## 2. 운영자 CLI
+18 -18
View File
@@ -34,11 +34,12 @@
1. `trex-core-daemon --config <path>.toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `max_active_slots`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅·DB 행 스코프 분리(REQ-056).
2. setup: `apply_migrations(dsn)`(멱등; 010 inactive CHECK·011 컬럼 drop 포함) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. (일일 preallocation 은 BAT-030 에서 폐기 — 슬롯 생성은 universe reconcile 이 담당.)
3. start: 7개 백그라운드 스레드 동시 기동
3. start: 8개 백그라운드 스레드 동시 기동
- `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062). **각 틱 시작 시 `event_publisher.keepalive_tick()` 호출** (REQ-092 — pika 스레드 안전 규칙: 발행 연결 소유 스레드에서만 I/O). 틱 간격(기본 1s) < heartbeat/2(15s) → 서버 측 idle 종료 예방.
- `trex-ledger` :: ledger outbox 드레인 (REQ-019)
- `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040)
- `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036)
- `trex-slots-rpc` :: `trex.slots.req` 소비자 (spy=false 시 holding 슬롯 워치독 `last_seen_at` 갱신 REQ-034; `safety_sell_enabled=false` 시 `trex-safety-sell` 미기동 BAT-036)
- `trex-strategy-state-set` :: `trex.slots.strategy_state.set` 소비자 (slot-scoped strategy-instance state version CAS)
- `trex-control-rpc` :: `trex.control.req` 소비자 — pause/resume/list + kill_switch/resume_kill_switch (REQ-032/043)
- `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh + **universe 슬롯 reconcile**(REQ-058) + holding 불일치 reconcile(REQ-062)
- `trex-safety-sell` :: 자율 안전 매도 트리거 루프 (forced_close / stop-loss·take-profit / 장 마감, REQ-037/038/039)
@@ -138,17 +139,18 @@
`trex.slots.req` 메시지 처리 (`make_slots_rpc_handler`).
1. 요청 payload 디코드: `{filter ∈ {all, inactive, pending, holding, error}, spy: bool}`.
1. 요청 payload 디코드: `{filter ∈ {all, inactive, pending, holding, error}, spy: bool, strategy_instance_id?: str}`. `spy=false` 전략 요청은 non-empty string `strategy_instance_id`가 필수이며, malformed 값은 slots/state 노출과 watchdog 갱신 없이 `bad_request:strategy_instance_id` 기존 envelope으로 종료한다. `spy=true` 운영자 요청은 ID를 요구하거나 supplied ID로 결과를 필터하지 않는다.
2. `slot_repo.list_slots(instance_key)` → status 기준 서버 필터 적용(`inactive`/`pending`/`holding`/`error`; 미인식 = `all`).
3. `spy=false` 면 `slot_repo.list_holding_slots()` 의 모든 holding 슬롯에 `WatchdogStore.update(slot_id)`.
4. 각 슬롯에 `slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...)` 적용 → 병합 row 산출:
- slot 코어 필드: `slot_id/status/symbol/qty/avg_price/entry_price/portfolio_id/forced_close_at/universe_detached`
4. 각 슬롯에 `slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...)` 적용 후 broker holdings snapshot 오버레이를 적용해 병합 row 산출:
- slot 코어 필드: `slot_id/status/symbol/slot_quantity/avg_price/entry_price/portfolio_id/forced_close_at/universe_detached`
- holding authoritative 수량: `qty`(broker), `qty_source`, `broker_quantity_available`, `broker_quantity_captured_at` (`qty_source=unavailable`이면 `qty=null`, mirror fallback 금지)
- hub 권위 필드: `asset_quantity`/`cash_balance` (SnapshotStore)
- 파생: `warned = (qty != asset_quantity)` (REQ-074 AC-2)
- 파생: `warned = (slot_quantity != asset_quantity)` (REQ-074 AC-2)
- 추정 현금(REQ-029 OQ-4): `cash_estimated=True` + `cash_balance += pending ledger delta`(best-effort)
- 워치독: `watchdog: {expired, last_seen}` inline (WatchdogStore)
- 오버레이: `paused` (PauseStore, REQ-032)
- strategy 필드 없음 (REQ-074 AC-5)
- strategy-state: `spy=false`면 요청 `strategy_instance_id`와 정확히 일치하는 `{version,data,updated_at}` entry 하나만(없으면 생략), `spy=true`면 `{strategy_instance_id: {version,data,updated_at}}` 전체 map
5. degraded 상태 동봉: `SnapshotStore.degraded_state()` → `{degraded, reason, message}`.
6. 응답: `{slots[], degraded, reason, message}` — `slots` 는 항상 배열, null 금지.
@@ -264,7 +266,7 @@
- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): INACTIVE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→INACTIVE(clear). 롤백(→INACTIVE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가.
- **실행 정책 생애주기(dcy-022/REQ-068)**: `timeout_sec`·`slippage_pct` 는 `limit_price` 와 동일한 생애주기(PENDING_* 진입 시 set, HOLDING/INACTIVE 탈출 시 clear). 슬롯의 `updated_at` 이 dispatch 시각 proxy 로 사용된다(mark_dispatched 가 갱신, stale-alert 와 동일 방법).
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
- **venue 전달 경로(BAT-056/REQ-117/118, BAT-059/REQ-124, dcy-041)**: buy/sell intent `venue`(기본 AUTO) → pending `Slot.venue` → KIS 제출 시 KST 세션별 실제 KRX/NXT 확정 → `OrderAck.venue`와 주문번호 원자 저장 → 취소·재시작·Core outbox에서 실제 거래소 재사용. 허용값은 AUTO/KRX/NXT/SOR이며 invalid는 슬롯 전이 전 거부한다. HOLDING은 다음 자율매도를 위해 AUTO, INACTIVE는 하위 호환을 위해 KRX로 초기화한다.
- **venue 전달 경로(BAT-056/REQ-117/118, BAT-059/REQ-124, dcy-041, GitHub #61)**: buy/sell intent `venue`(누락 시 SOR) → pending `Slot.venue` → KIS에 명시 KRX/NXT/SOR 제출 → `OrderAck.venue`와 주문번호 원자 저장 → 취소·재시작·Core outbox에서 실제 거래소 재사용. AUTO와 invalid 값은 슬롯 전이 전 거부한다. HOLDING은 다음 주문을 위해 SOR, INACTIVE는 하위 호환을 위해 KRX로 초기화한다.
### 계좌조회 단기 복구 (dcy-041 / GitHub #45)
@@ -286,11 +288,11 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3).
- ledger skip 가시화(dcy-011/REQ-018, issue #11): `_record_ledger` 가 `portfolio_id` 또는 `execution_id` 누락으로 skip 하면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터**로 노출한다(슬롯 전이는 그대로 진행). portfolio_id 필수화(REQ-007)로 주원인은 제거됐고, 잔여 누락은 운영 점검 쿼리(REQ-055: closed 슬롯 ↔ `ledger_outbox` anti-join, `find_ledger_gap_slots`)로 영구 탐지한다.
- ledger skip 가시화(dcy-011/REQ-018, issue #11, issue #68): `_record_ledger` 가 completed fill을 정상 `ledger_outbox` 경로에 올릴 수 없으면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터 + durable `ledger_skip` row + best-effort `trex.alerts` alarm** 으로 남긴다(슬롯 전이는 그대로 진행). unresolved row는 `trex-core ledger skips list` 로 조회하고, manual backfill 또는 operator resolve 후 resolved 상태와 별도 resolved alarm을 남긴다.
1. fill 감지: `BrokerPort.poll_fill` 이 `state="filled"` 와 결정적 `execution_id`(paper: `paper:{broker_order_id}:{filled_qty}` / KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`), 계산된 `fee`(`calculate_fee(side, amount)`)·`tax`(`calculate_tax(side, amount)`)를 운반. fee/tax 는 단일 정책 지점(`broker_port.py`)에서 env 로 관리한다(dcy-026/REQ-018 CHG-20260612-01): `TREX_KIS_FEE_BPS`(기본 1.5 = 0.015%, 매수·매도 공통)·`TREX_KRX_TAX_SELL_BPS`(기본 20.0 = 0.2%, **매도 증권거래세**; 매수 tax=0). paper·KIS 가 동일 정책을 공유하며, KIS daily-ccld 응답은 per-fill fee/tax 필드를 제공하지 않아 정책 추정값을 사용한다(향후 노출 endpoint 도입 시 브로커 정본 우선).
2. 슬롯 전이: `pending_buy→holding`(avg_price/qty) 또는 `pending_sell→closed` 를 advisory lock 안에서 먼저 commit. 이 단계가 critical path.
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup). `portfolio_id` 부재면 ledger 기록 skip 후 슬롯 전이는 정상 유지.
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 와 결정적 `execution_id`가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재한다. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup)이다. completed fill이 `portfolio_id`/`execution_id` 누락 또는 unsupported actual venue 등 genuinely non-recordable 상태이면 `ledger_outbox` 대신 `ledger_skip` 에 unresolved 감사 row를 남기고 알람을 best-effort 발행한다. unknown actual venue alone(`order_venue=SOR`, `execution_venue=NULL`)는 계속 정상 outbox 경로다.
4. `clear_dispatch` 로 `broker_order_id` 해제.
## Flow: `ledger writer` (백그라운드, BAT-006)
@@ -345,17 +347,15 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다.
7. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지).
## Flow: `slot strategy-state 저장소` (BAT-042 / dcy-028, REQ-079~082)
## Flow: `slot strategy-state 저장소` (REQ-0009)
전략 데몬(예: trex-tsl)이 슬롯 점유 동안 누적하는 opaque 상태(예: TSL peak)를 슬롯 수명에 묶어 server-side 로 보관한다. core 는 `data` 를 해석하지 않는다(opaque UTF-8 + byte size cap 만 검증).
전략 상태는 슬롯 점유와 독립된 opaque 상태다. core는 inner `data`를 해석하지 않고 UTF-8 byte size cap만 검증하며, 하나의 슬롯별 map `data[strategy_instance_id] = {version,data,updated_at}`를 보관한다.
1. **read (piggyback, C-1)**: 데몬이 매 사이클 `trex.slots.req` 로 슬롯을 읽으면 각 행에 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{<strategy_id>: {version,data,updated_at}}`)이 함께 실린다. 맵은 핸들러가 반환 slot_id 들로 `StrategyStateStore.get_for_slots()` 1회 batched 조회 후 group by 해 조립한다(추가 왕복 없음). 슬롯 현재 occupancy_seq 와 다른 stale 엔트리는 노출하지 않는다.
2. **evaluate (pure)**: 데몬은 행의 `strategy_state[strategy_id]` 를 read-only 입력으로 `evaluate()` 에 넣는다(상태 write 는 evaluate 밖, 엔진이 수행).
3. **write (별도 RPC, C-2)**: 상태가 바뀌면 `trex.slots.strategy_state.set` 으로 `{slot_id, strategy_id, expected_version, expected_occupancy_seq, data}` 를 보낸다. core 는 슬롯의 현재 occupancy_seq·active 여부를 조회해 CAS 검증: 점유 불일치 → `occupancy_changed`, version 불일치 → `version_conflict`, inactive/미존재 → `slot_inactive`, size 초과 → `state_too_large`, 일치(없으면 expected_version=0) → commit + version+1. malformed → `bad_request`. 동일 입력 재전송은 무부작용.
4. **occupancy 가드 (C-2b, R-1)**: 슬롯은 monotonic `occupancy_seq` 를 갖고 새 점유 시작(`inactive→pending_buy`)마다 ++. 영구 슬롯 재사용 시 read v0→close→reopen→write v0 의 ABA(옛 점유 계산이 새 점유에 바인딩)를 occupancy_seq CAS 가 구조적으로 차단한다.
5. **close 삭제 (C-3)**: 슬롯이 어떤 경로로든 `→INACTIVE`(전략 매도/safety-sell/forced-close/operator) 되면 `transit_slot` 단일 전이 지점에서 해당 slot_id 의 모든 strategy_state 를 삭제한다. 재사용 슬롯은 새 occupancy_seq + 빈 상태로 시작 — 전략/데몬은 cleanup 책임이 없다(슬롯이 source of truth + GC).
1. **read (piggyback)**: `spy=false` 전략 요청은 required non-empty `strategy_instance_id`의 exact entry만 각 slot 행의 single `strategy_state` object로 받으며, 없으면 field가 생략된다. 다른 ID나 전체 map은 노출하지 않는다. `spy=true` 운영자 요청은 ID 없이도 각 slot의 complete instance map을 받고 watchdog을 갱신하지 않는다.
2. **write (별도 RPC)**: `trex.slots.strategy_state.set` payload는 `{slot_id, strategy_instance_id, expected_version, data}`다. active slot, size limit, 요청 entry version만 CAS한다. entry가 없으면 `expected_version=0`, 성공하면 그 entry version만 증가한다. `version_conflict`, `slot_inactive`, `state_too_large`, `bad_request:*`는 write를 적용하지 않는다.
3. **lifecycle/concurrency**: `→INACTIVE`와 재점유는 map을 삭제하거나 숨기지 않으며 version도 초기화하지 않는다. Slot 자체의 `occupancy_seq`는 기존대로 유지하지만 strategy state persistence/read/write에는 사용하지 않는다. 서로 다른 instance entry의 concurrent 성공 write는 모두 보존된다.
저장은 별도 `slot_strategy_state` 테이블(복합 PK `(slot_id, strategy_id)`), 슬롯 본체에는 `occupancy_seq` 컬럼 1개만 추가(full 3계층 분할은 후속 Discovery). full 3계층(occupancy 테이블 분할)·다중 writer 동시성·장기 보존은 범위 밖.
저장은 별도 `slot_strategy_state` 테이블의 slot-scoped JSON map이며 entry에는 identity metadata를 중복 저장하지 않는다. core-side instance registry, legacy mapping/backfill, caller 인증은 없다.
## Flow Constraints
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "trex-core"
version = "0.1.1"
version = "0.5.1"
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
requires-python = ">=3.12"
dependencies = [
-494
View File
@@ -1,494 +0,0 @@
#!/usr/bin/env python3
"""Deterministic verification and publication helpers for the release image."""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import subprocess
import sys
import tarfile
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, Sequence
IMAGE = "ghcr.io/fourblocks2020/trex-core"
CANARY = b"TREX_SECRET_CANARY_DO_NOT_SHIP"
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
DEFAULT_MIGRATION_DIR = (
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
)
MAX_ARCHIVE_DEPTH = 8
MAX_ARCHIVE_MEMBERS = 200_000
MAX_ARCHIVE_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
MAX_ARCHIVE_MEMBER_BYTES = 512 * 1024 * 1024
MAX_ARCHIVE_INPUT_BYTES = 1024 * 1024 * 1024
@dataclass(frozen=True)
class Result:
stdout: str = ""
stderr: str = ""
@dataclass
class _ScanBudget:
max_depth: int = MAX_ARCHIVE_DEPTH
max_members: int = MAX_ARCHIVE_MEMBERS
max_extracted_bytes: int = MAX_ARCHIVE_EXTRACTED_BYTES
max_member_bytes: int = MAX_ARCHIVE_MEMBER_BYTES
max_input_bytes: int = MAX_ARCHIVE_INPUT_BYTES
members: int = 0
extracted_bytes: int = 0
def check_depth(self, depth: int) -> None:
if depth > self.max_depth:
_scan_limit_exceeded()
def add_member(self, size: int) -> None:
self.members += 1
if self.members > self.max_members:
_scan_limit_exceeded()
if size < 0 or size > self.max_member_bytes:
_scan_limit_exceeded()
self.extracted_bytes += size
if self.extracted_bytes > self.max_extracted_bytes:
_scan_limit_exceeded()
def _scan_limit_exceeded() -> None:
raise ValueError("archive scan resource limit exceeded")
class Runner(Protocol):
def run(
self,
command: Sequence[str],
*,
input_text: str | None = None,
timeout: int | None = None,
) -> Result: ...
class SubprocessRunner:
def run(
self,
command: Sequence[str],
*,
input_text: str | None = None,
timeout: int | None = None,
) -> Result:
completed = subprocess.run(
command,
input=input_text,
text=True,
capture_output=True,
check=True,
timeout=timeout,
)
return Result(completed.stdout, completed.stderr)
def validate_commit(commit: str) -> str:
if not COMMIT_RE.fullmatch(commit):
raise ValueError("source commit must be 40 lowercase hexadecimal characters")
return commit
def release_tags(commit: str, ref: str) -> list[str]:
validate_commit(commit)
tags = [f"{IMAGE}:{commit[:12]}"]
if ref == "refs/heads/main":
return tags
prefix = "refs/tags/"
if ref.startswith(prefix) and ref.removeprefix(prefix).startswith("v"):
release = ref.removeprefix(prefix)
if not re.fullmatch(r"v[A-Za-z0-9._-]*", release):
raise ValueError("release ref contains unsupported tag characters")
tags.append(f"{IMAGE}:{release}")
return tags
raise ValueError("publication ref must be main or a v* tag")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_checksum(archive: Path, checksum: Path) -> None:
checksum.write_text(f"{sha256_file(archive)} {archive.name}\n", encoding="ascii")
def verify_checksum(archive: Path, checksum: Path) -> None:
if not archive.is_file() or not checksum.is_file():
raise ValueError("image archive or checksum evidence is missing")
fields = checksum.read_text(encoding="ascii").strip().split()
if len(fields) != 2 or fields[1] != archive.name or not re.fullmatch(r"[0-9a-f]{64}", fields[0]):
raise ValueError("checksum evidence has an invalid format")
if sha256_file(archive) != fields[0]:
raise ValueError("image archive checksum mismatch")
def validate_image_id(image_id: str) -> str:
if not IMAGE_ID_RE.fullmatch(image_id):
raise ValueError("image ID must be sha256 followed by 64 lowercase hexadecimal characters")
return image_id
def inspect_image_id(image: str, runner: Runner) -> str:
result = runner.run(["docker", "image", "inspect", "--format", "{{.Id}}", image])
return validate_image_id(result.stdout.strip())
def write_image_id(image: str, output: Path, runner: Runner) -> str:
image_id = inspect_image_id(image, runner)
output.write_text(f"{image_id}\n", encoding="ascii")
return image_id
def read_image_id(path: Path) -> str:
text = path.read_text(encoding="ascii")
if not re.fullmatch(r"sha256:[0-9a-f]{64}\n?", text):
raise ValueError("tested image ID evidence has an invalid format")
return text.rstrip("\n")
def verify_image_id(image: str, expected_path: Path, runner: Runner) -> str:
expected = read_image_id(expected_path)
actual = inspect_image_id(image, runner)
if actual != expected:
raise ValueError("loaded image ID does not match tested image ID")
return actual
SMOKE_PROBES: tuple[tuple[str, ...], ...] = (
("python", "-c", "import trex_core"),
("trex-core-daemon", "--help"),
("trex-core", "--help"),
("trex-core-reset", "--help"),
)
def canonical_migration_names(migration_dir: Path = DEFAULT_MIGRATION_DIR) -> tuple[str, ...]:
names = tuple(sorted(path.name for path in migration_dir.glob("*.sql") if path.is_file()))
if not names:
raise ValueError("canonical migration inventory is empty")
return names
def migration_inventory_code(expected: tuple[str, ...]) -> str:
return (
"from trex_core.adapters.persistence.migrate import migration_files;"
f"expected={expected!r};"
"actual=tuple(path.name for path in migration_files());"
"assert expected and actual == expected, 'installed migration inventory mismatch'"
)
def _ensure_result_clean(result: Result) -> None:
if CANARY in (result.stdout + result.stderr).encode():
raise ValueError("canary marker found on a scanned surface")
def smoke_image(
image: str,
runner: Runner,
timeout: int = 30,
migration_dir: Path = DEFAULT_MIGRATION_DIR,
) -> None:
expected_migrations = canonical_migration_names(migration_dir)
probes = (
*SMOKE_PROBES,
("python", "-I", "-c", migration_inventory_code(expected_migrations)),
)
for index, probe in enumerate(probes):
name = f"trex-core-smoke-{os.getpid()}-{index}"
command = [
"docker",
"run",
"--name",
name,
"--network",
"none",
"--read-only",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--pids-limit",
"64",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=16m",
"--entrypoint",
probe[0],
image,
*probe[1:],
]
failure: BaseException | None = None
try:
_ensure_result_clean(runner.run(command, timeout=timeout))
except BaseException as error:
failure = error
try:
_ensure_result_clean(
runner.run(["docker", "rm", "--force", name], timeout=timeout)
)
except BaseException as cleanup_error:
if failure is None:
raise RuntimeError("smoke container cleanup failed") from cleanup_error
if failure is not None:
raise RuntimeError(f"smoke probe {index + 1} failed") from failure
def _scan_stream(path: Path) -> None:
with path.open("rb") as stream:
overlap = b""
while chunk := stream.read(1024 * 1024):
data = overlap + chunk
if CANARY in data:
raise ValueError("canary marker found on a scanned surface")
overlap = data[-(len(CANARY) - 1):]
def _scan_metadata(member: tarfile.TarInfo) -> None:
values = [member.name, member.linkname, member.uname, member.gname]
for key, value in member.pax_headers.items():
values.extend((key, value))
if any(CANARY in str(value).encode("utf-8", errors="replace") for value in values):
raise ValueError("canary marker found on a scanned surface")
def _scan_tar(path: Path, budget: _ScanBudget | None = None, depth: int = 0) -> None:
budget = budget or _ScanBudget()
if path.stat().st_size > (budget.max_input_bytes if depth == 0 else budget.max_member_bytes):
_scan_limit_exceeded()
_scan_stream(path)
try:
archive = tarfile.open(path, mode="r:*")
except (tarfile.TarError, OSError, EOFError):
return
with archive:
budget.check_depth(depth)
for member in archive:
budget.add_member(member.size if member.isfile() else 0)
_scan_metadata(member)
if not member.isfile():
continue
extracted = archive.extractfile(member)
if extracted is None:
continue
with tempfile.NamedTemporaryFile() as nested:
overlap = b""
copied = 0
while chunk := extracted.read(1024 * 1024):
copied += len(chunk)
if copied > member.size or copied > budget.max_member_bytes:
_scan_limit_exceeded()
data = overlap + chunk
if CANARY in data:
raise ValueError("canary marker found on a scanned surface")
nested.write(chunk)
overlap = data[-(len(CANARY) - 1):]
nested.flush()
_scan_tar(Path(nested.name), budget, depth + 1)
def scan_image(image: str, saved_archive: Path, runner: Runner, timeout: int = 60) -> None:
for command in (
["docker", "history", "--no-trunc", image],
["docker", "image", "inspect", image],
):
_ensure_result_clean(runner.run(command, timeout=timeout))
container = runner.run(["docker", "create", "--network", "none", image], timeout=timeout)
container_id = container.stdout.strip()
if not container_id:
raise RuntimeError("docker create returned no container id")
try:
_ensure_result_clean(container)
with tempfile.TemporaryDirectory() as directory:
filesystem = Path(directory) / "filesystem.tar"
_ensure_result_clean(
runner.run(
["docker", "export", "--output", str(filesystem), container_id],
timeout=timeout,
)
)
_scan_tar(filesystem)
finally:
try:
_ensure_result_clean(
runner.run(["docker", "rm", "--force", container_id], timeout=timeout)
)
except BaseException as error:
raise RuntimeError("canary scan container cleanup failed") from error
_scan_tar(saved_archive)
def assert_clean_output(paths: Sequence[Path]) -> None:
for path in paths:
_scan_stream(path)
def _push_digest(output: str) -> str:
digests = set(DIGEST_RE.findall(output))
if len(digests) != 1:
raise ValueError("registry push did not return exactly one valid digest")
return digests.pop()
def _append(path: Path | None, text: str) -> None:
if path is None:
return
with path.open("a", encoding="utf-8") as destination:
destination.write(text)
def publish_image(
*,
archive: Path,
checksum: Path,
image_id: Path,
local_image: str,
commit: str,
ref: str,
actor: str,
token: str,
runner: Runner,
output_path: Path | None = None,
summary_path: Path | None = None,
) -> str:
tags = release_tags(commit, ref)
verify_checksum(archive, checksum)
expected_image_id = read_image_id(image_id)
loaded = runner.run(["docker", "load", "--input", str(archive)])
load_lines = {line.strip() for line in (loaded.stdout + "\n" + loaded.stderr).splitlines()}
if f"Loaded image: {local_image}" not in load_lines:
raise ValueError("docker load did not report the expected local image")
actual_image_id = inspect_image_id(local_image, runner)
if actual_image_id != expected_image_id:
raise ValueError("loaded image ID does not match tested image ID")
revision = runner.run(
[
"docker",
"image",
"inspect",
"--format",
"{{ index .Config.Labels \"org.opencontainers.image.revision\" }}",
local_image,
]
).stdout.strip()
if revision != commit:
raise ValueError("loaded image revision does not match source commit")
if not actor or not token:
raise ValueError("registry credentials are missing")
for tag in tags:
runner.run(["docker", "tag", local_image, tag])
runner.run(
["docker", "login", "ghcr.io", "--username", actor, "--password-stdin"],
input_text=token,
)
digests = []
for tag in tags:
result = runner.run(["docker", "push", tag])
digests.append(_push_digest(result.stdout + "\n" + result.stderr))
if len(set(digests)) != 1:
raise ValueError("published tags returned different registry digests")
digest = digests[0]
reference = f"{IMAGE}@{digest}"
_append(output_path, f"image={reference}\ndigest={digest}\nsource_commit={commit}\n")
_append(
summary_path,
"## Published trex-core image\n\n"
f"- Image: `{reference}`\n"
f"- Digest: `{digest}`\n"
f"- Source commit: `{commit}`\n",
)
return digest
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest="command", required=True)
smoke = commands.add_parser("smoke")
smoke.add_argument("--image", required=True)
smoke.add_argument("--timeout", type=int, default=30)
scan = commands.add_parser("scan")
scan.add_argument("--image", required=True)
scan.add_argument("--archive", type=Path, required=True)
checksum = commands.add_parser("checksum")
checksum.add_argument("--archive", type=Path, required=True)
checksum.add_argument("--output", type=Path, required=True)
record_id = commands.add_parser("record-image-id")
record_id.add_argument("--image", required=True)
record_id.add_argument("--output", type=Path, required=True)
verify_id = commands.add_parser("verify-image-id")
verify_id.add_argument("--image", required=True)
verify_id.add_argument("--expected", type=Path, required=True)
clean = commands.add_parser("assert-clean-output")
clean.add_argument("paths", nargs="+", type=Path)
publish = commands.add_parser("publish")
publish.add_argument("--archive", type=Path, required=True)
publish.add_argument("--checksum", type=Path, required=True)
publish.add_argument("--image-id", type=Path, required=True)
publish.add_argument("--local-image", required=True)
publish.add_argument("--commit", required=True)
publish.add_argument("--ref", required=True)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
runner = SubprocessRunner()
if args.command == "smoke":
smoke_image(args.image, runner, args.timeout)
elif args.command == "scan":
scan_image(args.image, args.archive, runner)
elif args.command == "checksum":
write_checksum(args.archive, args.output)
elif args.command == "record-image-id":
write_image_id(args.image, args.output, runner)
elif args.command == "verify-image-id":
verify_image_id(args.image, args.expected, runner)
elif args.command == "assert-clean-output":
assert_clean_output(args.paths)
elif args.command == "publish":
publish_image(
archive=args.archive,
checksum=args.checksum,
image_id=args.image_id,
local_image=args.local_image,
commit=args.commit,
ref=args.ref,
actor=os.environ.get("GITHUB_ACTOR", ""),
token=os.environ.get("REGISTRY_TOKEN", ""),
runner=runner,
output_path=Path(path) if (path := os.environ.get("GITHUB_OUTPUT")) else None,
summary_path=Path(path) if (path := os.environ.get("GITHUB_STEP_SUMMARY")) else None,
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
print(f"image release failed: {error}", file=sys.stderr)
raise SystemExit(1) from error
+12 -4
View File
@@ -143,7 +143,9 @@ def test_poll_fill_uses_current_all_market_contract(monkeypatch):
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get)
status = broker.poll_fill("91258:1234", side="buy", symbol="005930")
status = broker.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
assert status.state == "accepted"
assert seen["tr_id"] == "VTTC0081R"
@@ -578,8 +580,12 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail)
first = b.poll_fill("91258:1234", side="buy", symbol="005930")
immediate = b.poll_fill("91258:1234", side="buy", symbol="005930")
first = b.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
immediate = b.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
assert first.state == "unknown"
assert immediate.state == "accepted"
@@ -587,6 +593,8 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
assert len(calls) == 1
clock[0] += 31
again = b.poll_fill("91258:1234", side="buy", symbol="005930")
again = b.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
assert again.state == "unknown"
assert len(calls) == 2
+18 -6
View File
@@ -59,13 +59,17 @@ def _row(**kw):
def test_full_external_cancel_maps_to_cancelled(monkeypatch):
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="Y")))
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.state == "cancelled" and fs.is_terminal_reject and not fs.is_partial_cancel
def test_zero_fill_not_cancelled_stays_accepted(monkeypatch):
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="N")))
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.state == "accepted" and not fs.is_terminal_reject
@@ -83,7 +87,9 @@ def test_zero_fill_full_rejection_maps_to_rejected(monkeypatch):
)
),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608"
)
assert fs.state == "rejected" and fs.is_terminal_reject
@@ -93,7 +99,9 @@ def test_zero_fill_partial_rejection_does_not_infer_terminal(monkeypatch):
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="0", rjct_qty="4", cncl_yn="")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608"
)
assert fs.state == "accepted" and not fs.is_terminal_reject
@@ -103,7 +111,9 @@ def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.state == "partially_filled"
assert fs.cancelled and fs.is_partial_cancel
assert fs.filled_qty == 4 and fs.avg_price == 70000.0
@@ -116,5 +126,7 @@ def test_full_fill_unaffected_by_cancel_flag(monkeypatch):
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.is_filled and fs.filled_qty == 10 and not fs.is_partial_cancel
+28 -1
View File
@@ -25,6 +25,7 @@ class FakeBroker:
def __init__(self, balance, fill=None):
self.balance = balance
self.fill = fill or FillStatus(state="accepted")
self.poll_metadata = None
def name(self):
return "fake"
@@ -32,7 +33,16 @@ class FakeBroker:
def submit_order(self, req):
return OrderAck(accepted=True, broker_order_id="1")
def poll_fill(self, broker_order_id, *, side, symbol):
def poll_fill(
self,
broker_order_id,
*,
side,
symbol,
order_date=None,
submitted_at=None,
):
self.poll_metadata = (order_date, submitted_at)
return self.fill
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
@@ -77,6 +87,23 @@ def test_confirmed_fill_is_persisted_once_and_manual_remainder_stays_hidden(tmp_
assert StrategyOwnershipStore(store.path).snapshot()[0] == {"005930": 2}
def test_poll_preserves_original_order_metadata_through_ownership_decorator(tmp_path):
store = StrategyOwnershipStore(tmp_path / "ownership.json")
delegate = FakeBroker([])
broker = OwnershipAwareBroker(delegate, store)
submitted_at = datetime(2026, 8, 14, 6, 20, tzinfo=timezone.utc)
broker.poll_fill(
"1",
side="buy",
symbol="005930",
order_date="20260814",
submitted_at=submitted_at,
)
assert delegate.poll_metadata == ("20260814", submitted_at)
def test_strategy_quantity_above_raw_broker_total_fails_closed(tmp_path):
store = StrategyOwnershipStore(tmp_path / "ownership.json")
store.apply_fill(
+68 -42
View File
@@ -91,38 +91,28 @@ def test_order_request_venue_nxt():
assert req.venue == "NXT"
@pytest.mark.parametrize(
("at", "expected"),
[
(datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
(datetime(2026, 7, 29, 8, 49, 59, tzinfo=timezone(timedelta(hours=9))), "NXT"),
(datetime(2026, 7, 29, 9, 0, 30, tzinfo=timezone(timedelta(hours=9))), "KRX"),
(datetime(2026, 7, 29, 15, 29, 59, tzinfo=timezone(timedelta(hours=9))), "KRX"),
(datetime(2026, 7, 29, 15, 30, tzinfo=timezone(timedelta(hours=9))), "NXT"),
(datetime(2026, 7, 29, 20, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
],
)
def test_auto_venue_resolves_by_kst_session(at, expected):
assert resolve_order_venue("AUTO", at) == expected
@pytest.mark.parametrize("venue", ["KRX", "NXT", "SOR"])
@pytest.mark.parametrize(
"at",
[
datetime(2026, 7, 29, 7, 59, 59, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 8, 50, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 9, 0, 29, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 20, 0, 1, tzinfo=timezone(timedelta(hours=9))),
],
)
def test_auto_venue_fails_closed_outside_supported_sessions(at):
with pytest.raises(ValueError, match="auto_venue_session_closed"):
def test_explicit_venue_is_preserved_independent_of_time(venue, at):
assert resolve_order_venue(venue, at) == venue
def test_auto_venue_is_rejected():
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
with pytest.raises(ValueError, match="unsupported_order_venue"):
resolve_order_venue("AUTO", at)
def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
def test_submit_sor_preserves_sor(monkeypatch):
captured = {}
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
@@ -134,39 +124,75 @@ def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
ack = broker.submit_order(
OrderRequest(
slot_id="s-auto",
slot_id="s-sor",
side="buy",
symbol="005930",
qty=1,
venue="AUTO",
venue="SOR",
)
)
assert ack.accepted is True
assert ack.venue == "NXT"
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
assert ack.venue == "SOR"
assert captured["body"]["EXCG_ID_DVSN_CD"] == "SOR"
def test_submit_auto_outside_session_rejects_without_http(monkeypatch):
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
broker = _mock_broker(clock=lambda: at)
@pytest.mark.parametrize(
("row", "expected"),
[
({"ord_exg_gb": "1"}, "KRX"),
({"ord_exg_gb": "2"}, "NXT"),
({"ord_exg_gb": "3"}, "KRX"),
({"ord_exg_gb": "4"}, "NXT"),
({"excg_id_dvsn_cd": "KRX"}, "KRX"),
({"excg_id_dvsn_cd": "NXT"}, "NXT"),
({"excg_id_dvsn_cd": "SOR-KRX"}, "KRX"),
({"excg_id_dvsn_Cd": "SOR-NXT"}, "NXT"),
({"excg_id_dvsn_cd": "SOR"}, None),
({"excg_dvsn_cd": "02"}, None),
({}, None),
],
)
def test_kis_execution_venue_parser_is_allowlisted(row, expected):
assert KISBroker._execution_venue_from_row(row) == expected
def unexpected_post(*args, **kwargs):
raise AssertionError("AUTO session rejection must happen before HTTP")
monkeypatch.setattr(kis_mod.httpx, "post", unexpected_post)
ack = broker.submit_order(
OrderRequest(
slot_id="s-auto-closed",
side="buy",
symbol="005930",
qty=1,
venue="AUTO",
)
def test_kis_fill_status_carries_sor_actual_execution_venue():
status = KISBroker._fill_status_from_row(
{
"ord_qty": "10",
"tot_ccld_qty": "10",
"avg_prvs": "70000",
"ord_dt": "20260819",
"ord_gno_brno": "12345",
"odno": "0000000001",
"excg_id_dvsn_cd": "NXT",
},
side="buy",
today="20260819",
fallback_odno="0000000001",
)
assert status.execution_venue == "NXT"
def test_kis_sor_fill_without_actual_exchange_keeps_execution_venue_unknown():
status = KISBroker._fill_status_from_row(
{
"ord_qty": "10",
"tot_ccld_qty": "10",
"avg_prvs": "70000",
"ord_dt": "20260824",
"ord_gno_brno": "12345",
"odno": "0000000002",
"excg_id_dvsn_cd": "SOR",
},
side="buy",
today="20260824",
fallback_odno="0000000002",
)
assert ack.accepted is False
assert ack.reason == "auto_venue_session_closed"
assert status.is_filled
assert status.execution_venue is None
# ---------------------------------------------------------------------------
@@ -0,0 +1,163 @@
from __future__ import annotations
import threading
from datetime import UTC, date, datetime
from uuid import uuid4
from trex_core.adapters.persistence.ledger_skip_store import PostgreSQLLedgerSkipStore
def test_ledger_skip_store_round_trips_and_dedupes(pg_clean):
store = PostgreSQLLedgerSkipStore(pg_clean)
slot_id = uuid4()
record, notify = store.record_skip(
instance_key="inst-1",
slot_id=slot_id,
portfolio_id="PF-1",
symbol="005930",
order_id="O-1",
execution_id=None,
side="buy",
qty=10.0,
price=1000.0,
executed_at=datetime(2026, 8, 24, 0, 0, tzinfo=UTC),
order_venue="SOR",
execution_venue="unknown",
skip_reason="missing_execution_id",
)
again, notify_again = store.record_skip(
instance_key="inst-1",
slot_id=slot_id,
portfolio_id="PF-1",
symbol="005930",
order_id="O-1",
execution_id=None,
side="buy",
qty=10.0,
price=1000.0,
executed_at=datetime(2026, 8, 24, 0, 0, tzinfo=UTC),
order_venue="SOR",
execution_venue="unknown",
skip_reason="missing_execution_id",
)
assert notify is True
assert notify_again is False
assert again.skip_key == record.skip_key
listed = store.list_records(instance_key="inst-1", status="unresolved")
assert len(listed) == 1
assert listed[0].skip_reason == "missing_execution_id"
def test_ledger_skip_store_filters_and_resolves(pg_clean):
store = PostgreSQLLedgerSkipStore(pg_clean)
slot_id = uuid4()
record, _ = store.record_skip(
instance_key="inst-1",
slot_id=slot_id,
portfolio_id="PF-2",
symbol="005930",
order_id="O-2",
execution_id="E-2",
side="sell",
qty=5.0,
price=2000.0,
executed_at=datetime(2026, 8, 24, 2, 0, tzinfo=UTC),
order_venue="KRX",
execution_venue="NXTX",
skip_reason="unsupported_execution_venue",
)
rows = store.list_records(
instance_key="inst-1",
portfolio_id="PF-2",
on_date=date(2026, 8, 24),
status="unresolved",
)
assert [row.skip_key for row in rows] == [record.skip_key]
resolved = store.resolve_for_fill(
instance_key="inst-1",
slot_id=slot_id,
side="sell",
order_id="O-2",
execution_id="E-2",
resolution_reason="manual_ledger_backfill",
)
assert len(resolved) == 1
assert resolved[0].status == "resolved"
assert resolved[0].resolution_reason == "manual_ledger_backfill"
def test_ledger_skip_store_resolves_null_execution_id_on_backfill(pg_clean):
store = PostgreSQLLedgerSkipStore(pg_clean)
slot_id = uuid4()
record, _ = store.record_skip(
instance_key="inst-1",
slot_id=slot_id,
portfolio_id="PF-1",
symbol="005930",
order_id="O-3",
execution_id=None,
side="buy",
qty=1.0,
price=1010.0,
executed_at=datetime(2026, 8, 24, 3, 0, tzinfo=UTC),
order_venue="SOR",
execution_venue="unknown",
skip_reason="missing_execution_id",
)
resolved = store.resolve_for_fill(
instance_key="inst-1",
slot_id=slot_id,
side="buy",
order_id="O-3",
execution_id="E-3",
resolution_reason="manual_ledger_backfill",
)
assert [row.skip_key for row in resolved] == [record.skip_key]
assert resolved[0].execution_id == "E-3"
assert resolved[0].status == "resolved"
def test_ledger_skip_store_retries_unique_violation_during_concurrent_dedupe(pg_clean):
slot_id = uuid4()
start = threading.Barrier(2)
results = []
def worker():
store = PostgreSQLLedgerSkipStore(pg_clean)
start.wait()
results.append(
store.record_skip(
instance_key="inst-1",
slot_id=slot_id,
portfolio_id="PF-1",
symbol="005930",
order_id="O-4",
execution_id=None,
side="buy",
qty=1.0,
price=1111.0,
executed_at=datetime(2026, 8, 24, 4, 0, tzinfo=UTC),
order_venue="SOR",
execution_venue="unknown",
skip_reason="missing_execution_id",
)
)
t1 = threading.Thread(target=worker)
t2 = threading.Thread(target=worker)
t1.start()
t2.start()
t1.join()
t2.join()
assert len(results) == 2
assert len({record.skip_key for record, _notify in results}) == 1
assert sorted(notify for _record, notify in results) == [False, True]
listed = PostgreSQLLedgerSkipStore(pg_clean).list_records(instance_key="inst-1", status="unresolved")
assert len(listed) == 1
@@ -7,10 +7,16 @@ iteration: EXPECTED_SLOT_FIELDS (code contract) vs daily_slot actual columns
from __future__ import annotations
import psycopg
import pytest
from datetime import datetime, timezone
from trex_core.adapters.persistence.migrate import apply_migrations, migration_files
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, SlotService, SlotState
from trex_core.adapters.persistence.postgresql import (
PostgreSQLLedgerOutbox,
PostgreSQLSlotRepository,
)
from trex_core.application.ledger_port import LedgerTrade
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS
_EXPECTED_TABLES = {
"daily_slot",
@@ -20,6 +26,9 @@ _EXPECTED_TABLES = {
"ledger_outbox", # BAT-006 / REQ-019
"snapshot_pause", # BAT-008 / REQ-032
"broker_account_snapshot", # dcy-041 / GitHub #45
"ledger_skip", # GitHub #68 skipped-ledger durable audit
"reconcile_notification_state", # req-0007 / ac-0024
"slot_strategy_state", # req-0009 / ac-0027
}
@@ -51,6 +60,25 @@ def test_daily_slot_columns_match_expected_contract(pg_clean):
assert actual == set(EXPECTED_SLOT_FIELDS)
def test_strategy_state_schema_matches_instance_map_contract(pg_clean):
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT column_name, data_type, udt_name "
"FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'slot_strategy_state'"
)
columns = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
cur.execute(
"SELECT a.attname FROM pg_index i "
"JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) "
"WHERE i.indrelid = 'slot_strategy_state'::regclass AND i.indisprimary"
)
primary_key = {row[0] for row in cur.fetchall()}
assert columns == {"slot_id": ("uuid", "uuid"), "data": ("jsonb", "jsonb")}
assert primary_key == {"slot_id"}
def test_drift_detected_when_contract_extended(pg_clean):
"""Negative case: a contract field absent from the table is caught."""
actual = _columns(pg_clean, "daily_slot")
@@ -64,20 +92,90 @@ def test_apply_migrations_is_idempotent(pg_dsn):
apply_migrations(pg_dsn)
def test_migration_replay_accepts_existing_auto_venue_row(pg_clean):
def test_migration_replay_converts_existing_auto_venue_row_to_sor(pg_clean):
repo = PostgreSQLSlotRepository(pg_clean)
service = SlotService(repo, instance_key="migration-auto")
repo.reconcile_universe("migration-auto", {"pf-auto": "005930"})
slot = repo.find_slot_by_portfolio("migration-auto", "pf-auto")
service.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="migration_replay",
actor="test",
venue="AUTO",
)
assert slot is not None
# Simulate a row written before migration 023 tightened the constraint.
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
cur.execute("ALTER TABLE daily_slot DROP CONSTRAINT daily_slot_venue_check")
cur.execute(
"UPDATE daily_slot SET venue = 'AUTO' WHERE slot_id = %s",
(slot.slot_id,),
)
conn.commit()
apply_migrations(pg_clean)
assert repo.fetch_slot(slot.slot_id).venue == "AUTO"
assert repo.fetch_slot(slot.slot_id).venue == "SOR"
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
with pytest.raises(psycopg.errors.CheckViolation):
cur.execute(
"UPDATE daily_slot SET venue = 'AUTO' WHERE slot_id = %s",
(slot.slot_id,),
)
def test_migration_024_separates_legacy_sor_and_is_replay_safe(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
normal = LedgerTrade(
asset_id="PF-NORMAL",
order_id="O-NORMAL",
execution_id="E-NORMAL",
side="buy",
qty=1,
price=1000,
executed_at=datetime(2026, 8, 19, tzinfo=timezone.utc),
venue="NXT",
order_venue="KRX",
)
legacy = LedgerTrade(
asset_id="PF-LEGACY",
order_id="O-LEGACY",
execution_id="E-LEGACY",
side="buy",
qty=1,
price=1000,
executed_at=datetime(2026, 8, 19, tzinfo=timezone.utc),
venue="KRX",
order_venue="SOR",
)
outbox.enqueue(normal)
outbox.enqueue(legacy)
# Recreate a pre-024 SOR row: one venue column carried routing provenance,
# and no separate order_venue was available.
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
cur.execute("ALTER TABLE ledger_outbox DROP CONSTRAINT ledger_outbox_venue_check")
cur.execute("ALTER TABLE ledger_outbox ALTER COLUMN order_venue DROP NOT NULL")
cur.execute(
"UPDATE ledger_outbox SET venue='SOR', order_venue=NULL "
"WHERE correlation_id=%s",
(legacy.correlation_id,),
)
conn.commit()
apply_migrations(pg_clean)
apply_migrations(pg_clean)
normal_row = outbox.get(normal.correlation_id)
legacy_row = outbox.get(legacy.correlation_id)
assert normal_row is not None
assert normal_row.trade.venue == "NXT"
assert normal_row.trade.order_venue == "KRX"
assert legacy_row is not None
assert legacy_row.trade.venue is None
assert legacy_row.trade.order_venue == "SOR"
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
with pytest.raises(psycopg.errors.CheckViolation):
cur.execute(
"UPDATE ledger_outbox SET venue='SOR' WHERE correlation_id=%s",
(normal.correlation_id,),
)
@@ -20,7 +20,8 @@ def _trade(execution_id="paper:1:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="ORD-1", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc), venue="NXT",
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
venue="NXT", order_venue="SOR",
)
@@ -33,6 +34,7 @@ def test_enqueue_persists_and_is_idempotent(pg_clean):
assert len(pending) == 1
assert pending[0].status is WorkItemStatus.PENDING
assert pending[0].trade.venue == "NXT"
assert pending[0].trade.order_venue == "SOR"
def test_writer_completes_persisted_item(pg_clean):
@@ -155,45 +155,92 @@ def test_cancel_attempt_reservation_is_compare_and_set(pg_clean):
assert repo.fetch_slot(decision.slot_id).cancel_state == "requested"
# dcy-028 / REQ-079·081 (BAT-042): strategy-state store + occupancy_seq column
# REQ-0009: slot-scoped strategy-instance state map.
def test_strategy_state_store_pg(pg_clean):
from concurrent.futures import ThreadPoolExecutor
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
from trex_core.application import strategy_state as ss
repo = PostgreSQLSlotRepository(pg_clean)
store = PgStrategyStateStore(pg_clean)
svc = SlotService(repo, instance_key=INST, strategy_state_store=store)
svc = SlotService(repo, instance_key=INST)
repo.reconcile_universe(INST, {"pf-a": "005930"})
slot = repo.find_slot_by_portfolio(INST, "pf-a")
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10, avg_price=100)
# occupancy_seq persisted
held = repo.fetch_slot(slot.slot_id)
assert held.occupancy_seq == 1
# first-create
r1 = store.set(slot.slot_id, "tsl_v1", expected_version=0,
expected_occupancy_seq=1, data="peak", current_occupancy_seq=1,
slot_active=True)
r1 = store.set(slot.slot_id, "instance-a", expected_version=0, data="peak", slot_active=True)
assert r1.ok and r1.version == 1
# version conflict
r2 = store.set(slot.slot_id, "tsl_v1", expected_version=0,
expected_occupancy_seq=1, data="stale", current_occupancy_seq=1,
slot_active=True)
r2 = store.set(slot.slot_id, "instance-a", expected_version=0, data="stale", slot_active=True)
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
# batched read
got = store.get_for_slots([slot.slot_id], {slot.slot_id: 1})
assert got[slot.slot_id]["tsl_v1"].data == "peak"
missing = store.set(slot.slot_id, "instance-missing", expected_version=1, data="stale", slot_active=True)
assert not missing.ok and missing.reason == ss.REASON_VERSION_CONFLICT and missing.version == 0
store.set(slot.slot_id, "instance-b", expected_version=0, data="other", slot_active=True)
got = store.get_for_slots([slot.slot_id])
assert got[slot.slot_id]["instance-a"].data == "peak"
assert got[slot.slot_id]["instance-b"].data == "other"
with ThreadPoolExecutor(max_workers=2) as executor:
writes = list(executor.map(
lambda item: store.set(slot.slot_id, item, expected_version=0, data=item, slot_active=True),
("instance-c", "instance-d"),
))
assert all(write.ok for write in writes)
assert {"instance-c", "instance-d"} <= set(store.get_for_slots([slot.slot_id])[slot.slot_id])
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 1
# Separate connections race on the same entry/version: only one CAS wins.
with ThreadPoolExecutor(max_workers=2) as executor:
competing = list(executor.map(
lambda data: store.set(
slot.slot_id, "instance-a", expected_version=1, data=data, slot_active=True,
),
("winner-one", "winner-two"),
))
assert sum(result.ok for result in competing) == 1, competing
assert sum(result.reason == ss.REASON_VERSION_CONFLICT for result in competing) == 1
winner = next(data for data, result in zip(("winner-one", "winner-two"), competing) if result.ok)
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].data == winner
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 2
# A fresh adapter sees the byte-identical persisted entry and can continue CAS.
before_restart = store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
after_restart = PgStrategyStateStore(pg_clean).get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
assert after_restart == before_restart
# close deletes state (C-3)
svc.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t")
svc.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="sold", actor="t")
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
# reuse bumps occupancy_seq
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 2
reused = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
reason="buy", actor="t")
assert reused.occupancy_seq == 2
rehydrated = PgStrategyStateStore(pg_clean)
persisted = rehydrated.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
assert persisted == before_restart
assert rehydrated.set(
slot.slot_id, "instance-a", expected_version=2, data="continued", slot_active=True,
).version == 3
def test_strategy_state_write_cannot_follow_close(pg_clean):
"""The store's locked lifecycle read rejects a write after close wins."""
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
repo = PostgreSQLSlotRepository(pg_clean)
svc = SlotService(repo, instance_key=INST)
store = PgStrategyStateStore(pg_clean)
repo.reconcile_universe(INST, {"pf-close": "005930"})
slot = repo.find_slot_by_portfolio(INST, "pf-close")
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1)
assert store.set(slot.slot_id, "instance", expected_version=0, data="before", slot_active=True).ok
svc.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, reason="sell", actor="t")
svc.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, reason="sold", actor="t")
result = store.set(slot.slot_id, "instance", expected_version=1, data="after", slot_active=True)
assert not result.ok and result.reason == "slot_inactive"
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance"].data == "before"
@@ -36,7 +36,7 @@ _SLOT_COLUMNS = (
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at, instance_key, retired_at, "
"timeout_sec, slippage_pct, occupancy_seq, venue, "
"cancel_state, cancel_requested_at"
"cancel_state, cancel_requested_at, order_submitted_at, order_date, cancel_attempts"
)
_MANAGED_SLOT_SQL = (
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
@@ -149,6 +149,9 @@ def _row(slot: Slot) -> tuple:
slot.venue,
slot.cancel_state,
slot.cancel_requested_at,
slot.order_submitted_at,
slot.order_date,
slot.cancel_attempts,
)
@@ -0,0 +1,213 @@
"""PostgreSQLReconcileNotificationStore integration tests (req-0007, ac-0024).
Verifies migration 026 schema creation (idempotent replay), full CRUD
round-trip, and lifecycle semantics against a real PostgreSQL 16 instance.
Gated on TREX_POSTGRESQL_TEST_DSN — skipped in unit-test-only CI.
"""
from __future__ import annotations
import pytest
from trex_core.application.reconciliation import _c_identity_key
@pytest.fixture
def pg_notify_store(pg_clean):
from trex_core.adapters.portfolio.reconcile_notification_store import (
PostgreSQLReconcileNotificationStore,
)
return PostgreSQLReconcileNotificationStore(pg_clean)
# ---------------------------------------------------------------------------
# Migration 026 schema
# ---------------------------------------------------------------------------
class TestMigration026:
def test_table_exists_after_migration(self, pg_clean):
"""Migration 026 creates reconcile_notification_state (IF NOT EXISTS)."""
import psycopg
with psycopg.connect(pg_clean) as conn, conn.cursor() as cur:
cur.execute(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'reconcile_notification_state'"
)
assert cur.fetchone() is not None, "reconcile_notification_state table not found"
def test_table_has_expected_columns(self, pg_clean):
"""Migration 026 creates all required columns."""
import psycopg
with psycopg.connect(pg_clean) as conn, conn.cursor() as cur:
cur.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'reconcile_notification_state'"
)
cols = {r[0] for r in cur.fetchall()}
expected = {
"identity_key", "case_code", "instance_key", "symbol",
"quantity_a", "quantity_b", "lifecycle_state", "created_at", "updated_at",
}
assert expected <= cols, f"Missing columns: {expected - cols}"
def test_migration_is_idempotent(self, pg_clean):
"""Running migration 026 twice does not fail (IF NOT EXISTS guard)."""
from trex_core.adapters.persistence.migrate import apply_migrations
# pg_clean already ran apply_migrations once; run again to verify idempotency.
apply_migrations(pg_clean) # must not raise
# ---------------------------------------------------------------------------
# CRUD round-trip
# ---------------------------------------------------------------------------
class TestCRUDRoundTrip:
def test_set_and_get_state_b_case(self, pg_notify_store):
"""set_state → get_state round-trip for B-case (slot_id identity)."""
pg_notify_store.set_state(
"slot-uuid-001",
case_code="B",
instance_key="inst-A",
symbol="005930",
quantity_a=10,
quantity_b=7,
lifecycle_state="notified",
)
state = pg_notify_store.get_state("slot-uuid-001")
assert state is not None
assert state["case_code"] == "B"
assert state["instance_key"] == "inst-A"
assert state["symbol"] == "005930"
assert state["quantity_a"] == 10
assert state["quantity_b"] == 7
assert state["lifecycle_state"] == "notified"
def test_set_and_get_state_c_case(self, pg_notify_store):
"""set_state → get_state round-trip for C-case (SHA-256 digest identity)."""
c_key = _c_identity_key("inst-A", "005930")
pg_notify_store.set_state(
c_key,
case_code="C",
instance_key="inst-A",
symbol="005930",
quantity_a=50,
quantity_b=42,
lifecycle_state="notified",
)
state = pg_notify_store.get_state(c_key)
assert state is not None
assert state["case_code"] == "C"
assert state["symbol"] == "005930"
assert state["quantity_a"] == 50
def test_set_state_upsert(self, pg_notify_store):
"""set_state is an upsert — second call with same key updates the row."""
pg_notify_store.set_state(
"slot-uuid-002",
case_code="B",
instance_key="inst-A",
symbol="005930",
quantity_a=10,
quantity_b=7,
lifecycle_state="notified",
)
pg_notify_store.set_state(
"slot-uuid-002",
case_code="B",
instance_key="inst-A",
symbol="005930",
quantity_a=10,
quantity_b=9, # quantity changed
lifecycle_state="notified",
)
state = pg_notify_store.get_state("slot-uuid-002")
assert state["quantity_b"] == 9
def test_get_state_returns_none_for_missing_key(self, pg_notify_store):
assert pg_notify_store.get_state("does-not-exist") is None
def test_clear_state_removes_row(self, pg_notify_store):
"""clear_state deletes the row; subsequent get_state returns None."""
pg_notify_store.set_state(
"slot-uuid-003",
case_code="B",
instance_key="inst-A",
symbol="005930",
quantity_a=10,
quantity_b=7,
lifecycle_state="notified",
)
pg_notify_store.clear_state("slot-uuid-003")
assert pg_notify_store.get_state("slot-uuid-003") is None
def test_list_states_by_lifecycle(self, pg_notify_store):
"""list_states_by_lifecycle returns only rows with the given state."""
pg_notify_store.set_state(
"slot-A",
case_code="B", instance_key="inst-A", symbol="005930",
quantity_a=10, quantity_b=7, lifecycle_state="notified",
)
pg_notify_store.set_state(
"slot-B",
case_code="B", instance_key="inst-A", symbol="005930",
quantity_a=20, quantity_b=15, lifecycle_state="pending_resolved",
)
notified = pg_notify_store.list_states_by_lifecycle("notified")
pending = pg_notify_store.list_states_by_lifecycle("pending_resolved")
assert len(notified) == 1
assert notified[0]["identity_key"] == "slot-A"
assert len(pending) == 1
assert pending[0]["identity_key"] == "slot-B"
def test_durability_across_store_instances(self, pg_clean):
"""State survives a simulated process restart (new store instance, same DSN)."""
from trex_core.adapters.portfolio.reconcile_notification_store import (
PostgreSQLReconcileNotificationStore,
)
a = PostgreSQLReconcileNotificationStore(pg_clean)
a.set_state(
"slot-restart",
case_code="B", instance_key="inst-A", symbol="005930",
quantity_a=10, quantity_b=7, lifecycle_state="notified",
)
# "restart" — fresh store instance
b = PostgreSQLReconcileNotificationStore(pg_clean)
state = b.get_state("slot-restart")
assert state is not None
assert state["lifecycle_state"] == "notified"
def test_c_identity_keys_for_distinct_inputs_are_distinct(self, pg_notify_store):
"""Two distinct (instance_key, symbol) pairs must yield distinct DB rows."""
key_a = _c_identity_key("inst-A", "005930")
key_b = _c_identity_key("inst-A", "005931")
assert key_a != key_b
pg_notify_store.set_state(
key_a, case_code="C", instance_key="inst-A", symbol="005930",
quantity_a=10, quantity_b=8, lifecycle_state="notified",
)
pg_notify_store.set_state(
key_b, case_code="C", instance_key="inst-A", symbol="005931",
quantity_a=20, quantity_b=15, lifecycle_state="notified",
)
state_a = pg_notify_store.get_state(key_a)
state_b = pg_notify_store.get_state(key_b)
assert state_a["symbol"] == "005930"
assert state_b["symbol"] == "005931"
def test_crafted_separator_collision_absent(self, pg_notify_store):
"""Crafted-separator inputs ('a\\x1fC','b') vs ('a','C\\x1fb') map to distinct rows."""
key_a = _c_identity_key("a\x1fC", "b")
key_b = _c_identity_key("a", "C\x1fb")
assert key_a != key_b
pg_notify_store.set_state(
key_a, case_code="C", instance_key="a\x1fC", symbol="b",
quantity_a=1, quantity_b=2, lifecycle_state="notified",
)
pg_notify_store.set_state(
key_b, case_code="C", instance_key="a", symbol="C\x1fb",
quantity_a=3, quantity_b=4, lifecycle_state="notified",
)
state_a = pg_notify_store.get_state(key_a)
state_b = pg_notify_store.get_state(key_b)
assert state_a["instance_key"] == "a\x1fC"
assert state_b["instance_key"] == "a"
+38 -2
View File
@@ -1,7 +1,7 @@
"""Bounded account-read recovery (dcy-041 / GitHub #45)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
import pytest
@@ -36,8 +36,13 @@ class FakeAccountBroker:
return self.cash
class BrokenLoadStore(InMemoryAccountSnapshotStore):
def load(self, account_hash: str) -> AccountSnapshot | None:
raise RuntimeError("db read failed")
def _wire():
now = [datetime(2026, 7, 29, tzinfo=timezone.utc)]
now = [datetime(2026, 7, 29, tzinfo=UTC)]
broker = FakeAccountBroker()
store = InMemoryAccountSnapshotStore()
service = AccountSnapshotService(
@@ -162,3 +167,34 @@ def test_invalid_or_missing_buy_notional_fails_closed():
assert service.buy_guard(qty=1, limit_price=None) == (
"account_snapshot_price_required"
)
def test_slots_rpc_uses_snapshot_only_with_sell_ttl_freshness():
broker, _, service, now = _wire()
assert service.refresh()
broker.balance = [BalanceItem("005930", 999)]
holdings, captured_at = service.holdings_for_slots_rpc()
assert holdings == [BalanceItem("005930", 2)]
assert captured_at == now[0]
now[0] += timedelta(seconds=3601)
with pytest.raises(AccountSnapshotUnavailable) as raised:
service.holdings_for_slots_rpc()
assert raised.value.code == "expired"
def test_slots_rpc_normalizes_snapshot_store_read_failure():
broker = FakeAccountBroker()
service = AccountSnapshotService(
broker=broker,
store=BrokenLoadStore(),
buy_max_age_sec=600,
sell_max_age_sec=3600,
clock=lambda: datetime(2026, 7, 29, tzinfo=UTC),
rng=lambda: 0.5,
)
with pytest.raises(AccountSnapshotUnavailable) as raised:
service.holdings_for_slots_rpc()
assert raised.value.code == "read_failed"
+38
View File
@@ -15,6 +15,7 @@ import pytest
from trex_core.adapters.messaging.in_memory import InMemoryTransport
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.messaging import TransportUnavailableError
# ------------------------------------------------------------------ helpers
@@ -154,3 +155,40 @@ def test_alarm_emitter_no_telegram_reference():
source = inspect.getsource(ae_mod)
assert "TELEGRAM" not in source, "alarm_emitter must not reference TELEGRAM_*"
assert "telegram" not in source.lower(), "alarm_emitter must not reference telegram"
def test_publish_alarm_transport_unavailable_log_is_sanitized(
caplog: pytest.LogCaptureFixture,
):
class UnavailableTransport:
def publish_alarm_event(self, routing_key, payload):
raise TransportUnavailableError("secret-token-value")
def health(self):
from trex_core.application.messaging import TransportHealth
return TransportHealth.DISCONNECTED
emitter = AlarmEmitter(UnavailableTransport()) # type: ignore[arg-type]
with caplog.at_level(logging.ERROR, logger="trex_core.application.alarm_emitter"):
emitter.publish_alarm("strategy_intent_rejected", "alarm title")
assert any("transport unavailable" in r.message for r in caplog.records)
assert "secret-token-value" not in caplog.text
def test_publish_alarm_generic_exception_log_is_sanitized(caplog: pytest.LogCaptureFixture):
class BrokenTransport:
def publish_alarm_event(self, routing_key, payload):
raise RuntimeError("******")
def health(self):
from trex_core.application.messaging import TransportHealth
return TransportHealth.CONNECTED
emitter = AlarmEmitter(BrokenTransport()) # type: ignore[arg-type]
with caplog.at_level(logging.ERROR, logger="trex_core.application.alarm_emitter"):
emitter.publish_alarm("strategy_intent_rejected", "alarm title")
assert any("alarm publish failed" in r.message for r in caplog.records)
assert "RuntimeError" in caplog.text
assert "******" not in caplog.text
+2 -1
View File
@@ -116,7 +116,8 @@ def test_partial_fill_then_cancel_records_and_keeps_remainder():
broker.balance = {"005930": 6}
broker.responder = lambda side: FillStatus(
state="partially_filled", filled_qty=4, avg_price=1100.0,
execution_id="exec-partial", fee=0.5, tax=0.0, cancelled=True,
execution_id="exec-partial", fee=0.5, tax=0.0,
execution_venue="KRX", cancelled=True,
)
r = execution.run_once()
slot = repo.fetch_slot(sid)
@@ -148,6 +148,7 @@ def _make_filled_status(
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue="KRX",
)
@@ -167,6 +168,7 @@ def _make_partial_cancel_status(
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue="KRX",
cancelled=True,
)
+330 -27
View File
@@ -6,9 +6,15 @@ critical path while the ledger write is eventually-consistent.
"""
from __future__ import annotations
from datetime import UTC, datetime
from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.messaging.in_memory import InMemoryTransport
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.broker_port import FillStatus
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.ledger_outbox import (
@@ -17,21 +23,118 @@ from trex_core.application.ledger_outbox import (
OutboxOutcome,
WorkItemStatus,
)
from trex_core.application.ledger_skip import LedgerSkipRecord
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
class _SkipStore:
def __init__(self):
self.records = []
self._by_key = {}
def _wire(broker, *, ledger=None, clock=None):
def record_skip(self, **kwargs):
record = LedgerSkipRecord(
skip_key=f"{kwargs['slot_id']}:{kwargs['side']}:{kwargs['skip_reason']}",
instance_key=kwargs["instance_key"],
slot_id=kwargs["slot_id"],
portfolio_id=kwargs["portfolio_id"],
symbol=kwargs["symbol"],
order_id=kwargs["order_id"],
execution_id=kwargs["execution_id"],
side=kwargs["side"],
qty=kwargs["qty"],
price=kwargs["price"],
executed_at=kwargs["executed_at"],
order_venue=kwargs["order_venue"],
execution_venue=kwargs["execution_venue"],
skip_reason=kwargs["skip_reason"],
status="unresolved",
resolution_reason=None,
resolved_at=None,
created_at=kwargs["executed_at"],
updated_at=kwargs["executed_at"],
)
should_notify = record.skip_key not in self._by_key
self._by_key[record.skip_key] = record
self.records = list(self._by_key.values())
return record, should_notify
def resolve_for_fill(self, **_kwargs):
return []
def resolve_for_delivery(
self,
*,
instance_key,
portfolio_id,
side,
order_id,
execution_id,
resolution_reason,
):
resolved = []
updated = {}
for key, record in self._by_key.items():
if (
record.instance_key == instance_key
and record.portfolio_id == portfolio_id
and record.side == side
and (
record.execution_id == execution_id
or (record.execution_id is None and record.order_id == order_id)
)
):
record = LedgerSkipRecord(
skip_key=record.skip_key,
instance_key=record.instance_key,
slot_id=record.slot_id,
portfolio_id=record.portfolio_id,
symbol=record.symbol,
order_id=record.order_id,
execution_id=record.execution_id or execution_id,
side=record.side,
qty=record.qty,
price=record.price,
executed_at=record.executed_at,
order_venue=record.order_venue,
execution_venue=record.execution_venue,
skip_reason=record.skip_reason,
status="resolved",
resolution_reason=resolution_reason,
resolved_at=record.executed_at,
created_at=record.created_at,
updated_at=record.executed_at,
)
resolved.append(record)
updated[key] = record
self._by_key = updated
self.records = list(self._by_key.values())
return resolved
def _wire(broker, *, ledger=None, clock=None, alarm_emitter=None, ledger_skip_store=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
outbox = InMemoryLedgerOutbox(clock=clock) if clock else InMemoryLedgerOutbox()
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox)
writer = (
LedgerWriter(outbox, ledger or InMemoryLedger(), clock=clock)
if clock else LedgerWriter(outbox, ledger or InMemoryLedger())
execution = ExecutionService(
svc,
repo,
broker,
ledger_outbox=outbox,
alarm_emitter=alarm_emitter,
ledger_skip_store=ledger_skip_store,
)
writer = LedgerWriter(
outbox,
ledger or InMemoryLedger(),
clock=clock,
alarm_emitter=alarm_emitter,
ledger_skip_store=ledger_skip_store,
instance_key="default",
)
repo.reconcile_universe("default", {"PF-1": "SYM"})
return repo, svc, intents, execution, outbox, writer
@@ -44,10 +147,42 @@ def _buy(intents, *, iid="b1", portfolio_id="PF-1"):
))
class KisSorUnknownVenueBroker(PaperBroker):
"""KIS daily-ccld fixture with SOR routing but no actual exchange evidence."""
def poll_fill(self, broker_order_id, *, side, symbol, **kwargs):
return KISBroker._fill_status_from_row(
{
"ord_qty": "10",
"tot_ccld_qty": "10",
"avg_prvs": "1000",
"ord_dt": "20260824",
"ord_gno_brno": "12345678",
"odno": broker_order_id,
"excg_id_dvsn_cd": "SOR",
},
side=side,
today="20260824",
fallback_odno=broker_order_id,
)
class MissingExecutionIdBroker(PaperBroker):
def poll_fill(self, broker_order_id, *, side, symbol, **kwargs):
return FillStatus(
state="filled",
filled_qty=10,
avg_price=1000,
execution_venue="KRX",
)
def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it():
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
repo, _, intents, execution, _, writer = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
sid = _buy(intents).slot_id
r = execution.run_once()
assert r.filled == 1 and r.ledger_enqueued == 1
@@ -59,13 +194,46 @@ def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it():
assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0)
assert t.execution_id.startswith("paper:PAPER-")
assert t.fee > 0.0 and t.tax == 0.0
assert t.venue == "AUTO"
assert t.order_venue == "SOR"
assert t.venue == "KRX" # paper's deterministic SOR execution exchange
def test_kis_sor_fill_without_actual_venue_enqueues_and_writes_once() -> None:
"""Issues #65/#66: SOR provenance must not suppress an unknown actual venue fill."""
ledger = InMemoryLedger()
repo, _, intents, execution, outbox, writer = _wire(KisSorUnknownVenueBroker(), ledger=ledger)
sid = _buy(intents, iid="sor-unknown").slot_id
result = execution.run_once()
assert result.filled == 1
assert result.ledger_enqueued == 1
assert result.ledger_skipped == 0
assert repo.fetch_slot(sid).status is SlotState.HOLDING
[item] = outbox.all_items()
assert item.trade.venue is None
assert item.trade.order_venue == "SOR"
assert writer.run_once().completed == 1
assert ledger.count() == 1
[trade] = ledger.trades()
assert trade.execution_id == item.trade.execution_id
assert trade.venue is None
assert trade.order_venue == "SOR"
# Replay remains idempotent at the durable outbox and portfolio ledger boundaries.
outbox.enqueue(item.trade)
assert len(outbox.all_items()) == 1
assert writer.run_once().completed == 0
assert ledger.count() == 1
def test_sell_fill_enqueues_sell_ledger_item():
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
repo, _, intents, execution, _, writer = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
sid = _buy(intents).slot_id
execution.run_once() # -> holding + buy ledger item
writer.run_once() # drain buy
@@ -80,13 +248,16 @@ def test_sell_fill_enqueues_sell_ledger_item():
def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
repo, svc, _, execution, outbox, _ = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
# dcy-011: buy admission now requires portfolio_id, so create the
# pending_buy slot directly (bypass admission) to exercise the
# execution-loop ledger skip + visibility (REQ-018 FR-4).
from trex_core.domain.slot.models import Slot
from uuid import uuid4
from trex_core.domain.slot.models import Slot
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
@@ -99,14 +270,138 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
assert ledger.count() == 0
def test_missing_execution_id_still_skips_malformed_ledger_work_item() -> None:
repo, _, intents, execution, outbox, _ = _wire(MissingExecutionIdBroker())
sid = _buy(intents, iid="missing-execution-id").slot_id
assert sid is not None
result = execution.run_once()
assert result.filled == 1
assert result.ledger_enqueued == 0
assert result.ledger_skipped == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING
assert outbox.all_items() == []
def test_missing_portfolio_id_persists_skip_and_emits_alert() -> None:
transport = InMemoryTransport()
skip_store = _SkipStore()
repo, svc, _, execution, outbox, _ = _wire(
PaperBroker(fill_after_polls=0),
alarm_emitter=AlarmEmitter(transport),
ledger_skip_store=skip_store,
)
from uuid import uuid4
from trex_core.domain.slot.models import Slot
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(
anon.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
symbol="005930",
portfolio_id=None,
)
result = execution.run_once()
assert result.ledger_skipped == 1
assert outbox.all_items() == []
assert len(skip_store.records) == 1
record = skip_store.records[0]
assert record.skip_reason == "missing_portfolio_id"
assert record.order_venue == "KRX"
alarms = transport.alarm_events()
assert len(alarms) == 1
_, payload = alarms[0]
assert payload["code"] == "ledger_skip_unresolved"
assert payload["fields"]["skip_reason"] == "missing_portfolio_id"
def test_missing_execution_id_persists_skip_record() -> None:
transport = InMemoryTransport()
skip_store = _SkipStore()
repo, _, intents, execution, outbox, _ = _wire(
MissingExecutionIdBroker(),
alarm_emitter=AlarmEmitter(transport),
ledger_skip_store=skip_store,
)
sid = _buy(intents, iid="missing-execution-id").slot_id
assert sid is not None
result = execution.run_once()
assert result.ledger_skipped == 1
assert result.ledger_enqueued == 0
assert repo.fetch_slot(sid).status is SlotState.HOLDING
assert outbox.all_items() == []
assert len(skip_store.records) == 1
record = skip_store.records[0]
assert record.skip_reason == "missing_execution_id"
assert record.portfolio_id == "PF-1"
assert record.execution_id is None
def test_skip_resolves_only_after_writer_completes_delivery() -> None:
transport = InMemoryTransport()
skip_store = _SkipStore()
ledger = InMemoryLedger()
_repo, _, intents, execution, outbox, writer = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
alarm_emitter=AlarmEmitter(transport),
ledger_skip_store=skip_store,
)
sid = _buy(intents, iid="delayed-skip-resolution").slot_id
record, _ = skip_store.record_skip(
instance_key="default",
slot_id=sid,
portfolio_id="PF-1",
symbol="005930",
order_id="PAPER-000001",
execution_id=None,
side="buy",
qty=10.0,
price=1000.0,
executed_at=datetime(2026, 8, 24, 0, 0, tzinfo=UTC),
order_venue="SOR",
execution_venue="unknown",
skip_reason="missing_execution_id",
)
result = execution.run_once()
assert result.ledger_enqueued == 1
assert outbox.all_items()
assert skip_store._by_key[record.skip_key].status == "unresolved"
assert transport.alarm_events() == []
stats = writer.run_once()
assert stats.completed == 1
resolved = skip_store._by_key[record.skip_key]
assert resolved.status == "resolved"
assert resolved.execution_id is not None
assert resolved.resolution_reason == "ledger_recorded"
assert [payload["code"] for _, payload in transport.alarm_events()] == ["ledger_skip_resolved"]
def test_ledger_skip_emits_warning(caplog):
"""dcy-011 / REQ-018 FR-4 (S-3): ledger skip is logged, not silent."""
import logging
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
from trex_core.domain.slot.models import Slot
repo, svc, _, execution, _, _ = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
from uuid import uuid4
from trex_core.domain.slot.models import Slot
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
@@ -120,8 +415,10 @@ def test_ledger_skip_emits_warning(caplog):
def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger():
"""REQ-019 AC-1: same execution_id processed twice = 1 ledger entry."""
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
_, _, intents, execution, outbox, writer = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
_buy(intents)
execution.run_once()
writer.run_once()
@@ -147,10 +444,10 @@ def test_retryable_failure_then_eventually_completes():
dcy-015: retryable rows back off, so advance a shared clock past each
next_retry_at between writer passes.
"""
from datetime import datetime, timedelta, timezone
now = {"t": datetime(2026, 6, 4, tzinfo=timezone.utc)}
from datetime import datetime, timedelta
now = {"t": datetime(2026, 6, 4, tzinfo=UTC)}
ledger = InMemoryLedger(fail_n_then_succeed=2)
repo, svc, intents, execution, outbox, writer = _wire(
repo, _, intents, execution, outbox, writer = _wire(
PaperBroker(fill_after_polls=0), ledger=ledger, clock=lambda: now["t"],
)
sid = _buy(intents).slot_id
@@ -176,8 +473,10 @@ def test_conflict_blocks_without_auto_retry():
# order id is PAPER-000001 / qty=10 => execution_id = paper:PAPER-000001:10.
conflict_id = "paper:PAPER-000001:10"
ledger = InMemoryLedger(conflict_executions={conflict_id})
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
_, _, intents, execution, outbox, writer = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
_buy(intents)
execution.run_once()
stats = writer.run_once()
@@ -194,8 +493,10 @@ def test_conflict_blocks_without_auto_retry():
def test_duplicate_completed_when_reprocessing_terminal_item():
"""REQ-019 AC-4: a completed item reports duplicate_completed if re-listed."""
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
_, _, intents, execution, outbox, writer = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
_buy(intents)
execution.run_once()
writer.run_once()
@@ -213,8 +514,10 @@ def test_duplicate_completed_when_reprocessing_terminal_item():
def test_slot_transition_proceeds_when_ledger_is_down():
"""REQ-019 AC-5: portfolio-hub outage does not block the fill transition."""
ledger = InMemoryLedger(fail_n_then_succeed=100)
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
repo, _, intents, execution, outbox, writer = _wire(
PaperBroker(fill_after_polls=0),
ledger=ledger,
)
sid = _buy(intents).slot_id
execution.run_once()
assert repo.fetch_slot(sid).status is SlotState.HOLDING
+38 -7
View File
@@ -253,6 +253,33 @@ class TestOrderResultLogging:
assert all(r.levelno == logging.ERROR for r in rejects)
assert "호가단위" in rejects[0].getMessage()
def test_sell_reject_returns_to_holding_without_new_attempt(self):
broker = _RecordingBroker()
repo, _, intents, execution = _wire(broker)
slot_id = _buy_to_pending(intents)
assert slot_id is not None
execution.run_once()
holding = repo.fetch_slot(slot_id)
assert holding is not None and holding.status is SlotState.HOLDING
intents.submit_sell(SellIntent(intent_id="s1", slot_id=slot_id))
broker.submit_order = lambda req: (
broker.orders.append(req)
or OrderAck(accepted=False, reason="broker_rejected", venue=req.venue)
)
result = execution.run_once()
recovered = repo.fetch_slot(slot_id)
assert recovered is not None
assert recovered.status is SlotState.HOLDING
assert recovered.qty == 10
assert recovered.broker_order_id is None
assert result.rolled_back == 1
assert len([order for order in broker.orders if order.side == "sell"]) == 1
execution.run_once()
assert len([order for order in broker.orders if order.side == "sell"]) == 1
def test_normal_fill_emits_no_error(self, caplog):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
@@ -290,7 +317,8 @@ class TestTimeoutCancel:
# age the slot past timeout
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
s,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
r = execution.run_once()
assert r.timeout_cancelled == 1
@@ -310,13 +338,13 @@ class TestTimeoutCancel:
assert r.timeout_cancelled == 0
assert len(broker.cancel_calls) == 0
def test_no_timeout_sec_no_cancel(self):
def test_updated_at_does_not_move_hard_fallback_deadline(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=None)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# age the slot
# Mutable row bookkeeping is not the dispatch/deadline clock.
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=9999),
@@ -335,7 +363,8 @@ class TestTimeoutCancel:
execution.run_once() # dispatch
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
s,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
with caplog.at_level(logging.WARNING):
r = execution.run_once()
@@ -357,7 +386,8 @@ class TestTimeoutCancel:
execution.run_once()
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
s,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
execution.run_once()
# slot still PENDING_BUY — cancel is best-effort; rollback on next poll
@@ -376,7 +406,7 @@ class TestTimeoutCancel:
slot = repo.fetch_slot(sid)
repo._slots[sid] = replace(
slot,
updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
result = execution.run_once()
@@ -395,7 +425,8 @@ class TestTimeoutCancel:
# age the slot → cancel sent
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
s,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
r1 = execution.run_once()
assert r1.timeout_cancelled == 1
@@ -75,14 +75,14 @@ class TestIntentPayloadParsing:
assert slot.timeout_sec == 60
assert slot.slippage_pct == 1.0
def test_buy_policy_fields_absent_means_none(self):
"""Backward-compat: no policy fields in payload → None on slot."""
def test_buy_policy_fields_absent_uses_hard_fallback(self):
"""No policy field still receives the hard 3,600-second fallback."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req())
sid = UUID(resp.payload["slot_id"])
slot = repo.fetch_slot(sid)
assert slot.timeout_sec is None
assert slot.timeout_sec == 3600
assert slot.slippage_pct is None
def test_sell_timeout_sec_parsed(self):
@@ -166,13 +166,13 @@ class TestDefaultOrderTimeoutSec:
slot = repo.fetch_slot(d.slot_id)
assert slot.timeout_sec == 30
def test_no_config_default_no_intent_timeout_means_none(self):
def test_no_config_default_no_intent_timeout_uses_hard_fallback(self):
repo, _, intents = self._wire_with_default(default_timeout=None)
d = intents.submit_buy(BuyIntent(
intent_id="b1", portfolio_id="PF-1", qty=10,
))
slot = repo.fetch_slot(d.slot_id)
assert slot.timeout_sec is None
assert slot.timeout_sec == 3600
# ============================================================================
+1 -1
View File
@@ -107,7 +107,7 @@ def test_apply_cash_estimate_overlays_pending_delta():
repo = InMemorySlotRepository()
repo.reconcile_universe("default", {"PF-1": "SYM"})
handler = make_slots_rpc_handler(repo, snapshot_store=store, ledger_outbox=outbox)
resp = handler(RpcRequest(payload={"filter": "all"}, correlation_id="test"))
resp = handler(RpcRequest(payload={"filter": "all", "strategy_instance_id": "test-instance"}, correlation_id="test"))
slots = resp.payload["slots"]
pf1 = next(s for s in slots if s["portfolio_id"] == "PF-1")
assert pf1["cash_estimated"] is True
+18 -3
View File
@@ -1,7 +1,7 @@
"""LedgerOutbox + LedgerWriter direct unit tests (REQ-019)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.application.ledger_outbox import (
@@ -17,7 +17,7 @@ def _trade(execution_id="paper:PAPER-1:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="PAPER-1", execution_id=execution_id,
side="buy", qty=10, price=1000.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
executed_at=datetime(2026, 6, 4, tzinfo=UTC),
)
@@ -57,7 +57,7 @@ def test_writer_marks_conflict_blocked_and_does_not_retry():
def test_writer_retries_until_success():
# dcy-015: retryable rows now back off; share an injected clock between the
# outbox (list_pending "now") and the writer (next_retry_at) to advance past it.
now = {"t": datetime(2026, 6, 4, tzinfo=timezone.utc)}
now = {"t": datetime(2026, 6, 4, tzinfo=UTC)}
outbox = InMemoryLedgerOutbox(clock=lambda: now["t"])
ledger = InMemoryLedger(fail_n_then_succeed=1)
writer = LedgerWriter(outbox, ledger, clock=lambda: now["t"])
@@ -68,3 +68,18 @@ def test_writer_retries_until_success():
now["t"] += timedelta(seconds=600) # past the backoff window
s2 = writer.run_once()
assert s2.completed == 1 and ledger.count() == 1
def test_process_correlation_id_only_drains_requested_row():
outbox = InMemoryLedgerOutbox()
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
first = outbox.enqueue(_trade(execution_id="e-1"))
second = outbox.enqueue(_trade(execution_id="e-2"))
outcome = writer.process_correlation_id(first.correlation_id)
assert outcome is OutboxOutcome.COMPLETED
assert ledger.count() == 1
assert outbox.get(first.correlation_id).status is WorkItemStatus.COMPLETED
assert outbox.get(second.correlation_id).status is WorkItemStatus.PENDING
@@ -0,0 +1,413 @@
"""Synthetic coverage for GitHub #58 timeout/cancel reconciliation."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import UUID
import pytest
from trex_core.adapters.broker import kis as kis_mod
from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import CancelOutcome, CancelState, FillStatus
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.runtime.config import DaemonInstanceConfig
from tests.application.test_execution_policy import _RecordingBroker
_KST = timezone(timedelta(hours=9))
def _wire(*, default_timeout=None):
repo = InMemorySlotRepository()
service = SlotService(repo)
intents = IntentService(
service,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda _pid: "005930",
default_order_timeout_sec=default_timeout,
)
repo.reconcile_universe("default", {"PF-1": "005930"})
return repo, service, intents
@pytest.mark.parametrize("value", [0, -1, 3601, True, 1.5, "30"])
def test_direct_strategy_timeout_outside_integer_bounds_is_rejected(value):
repo, _, intents = _wire()
decision = intents.submit_buy(
BuyIntent(intent_id=f"b-{value!r}", portfolio_id="PF-1", qty=1, timeout_sec=value)
)
assert decision.status == "rejected"
assert decision.reason.startswith("bad_request:timeout_sec_")
assert repo.list_undispatched_pending() == []
@pytest.mark.parametrize("value", [0, -1, 3601, True, 1.5, "30"])
def test_direct_sell_timeout_outside_integer_bounds_is_rejected(value):
repo, _, intents = _wire()
decision = intents.submit_sell(
SellIntent(intent_id=f"s-{value!r}", slot_id=UUID(int=1), timeout_sec=value)
)
assert decision.status == "rejected"
assert decision.reason.startswith("bad_request:timeout_sec_")
assert repo.list_undispatched_pending() == []
@pytest.mark.parametrize("value", [0, -1, 3601, 1.5, True, "30", "not-an-int"])
@pytest.mark.parametrize("intent_type", ["buy", "sell"])
def test_wire_strategy_timeout_bad_request_before_admission(value, intent_type):
repo, _, intents = _wire()
payload = {
"intent_type": intent_type,
"intent_id": "bad-timeout",
"timeout_sec": value,
}
if intent_type == "buy":
payload.update({"portfolio_id": "PF-1", "qty": 1})
else:
payload["slot_id"] = str(UUID(int=1))
response = make_intent_rpc_handler(intents)(
RpcRequest(
correlation_id="bad-timeout",
payload=payload,
)
)
assert response.payload["status"] == "rejected"
assert response.payload["reason"].startswith("bad_request:")
assert repo.list_undispatched_pending() == []
@pytest.mark.parametrize("value", [1, 3600])
def test_strategy_timeout_inclusive_bounds_are_admitted(value):
repo, _, intents = _wire()
decision = intents.submit_buy(
BuyIntent(intent_id=f"b-{value}", portfolio_id="PF-1", qty=1, timeout_sec=value)
)
assert decision.admitted
assert repo.fetch_slot(decision.slot_id).timeout_sec == value
def test_buy_and_sell_omission_use_instance_override_or_hard_fallback():
repo, service, intents = _wire(default_timeout=120)
buy = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=1))
assert repo.fetch_slot(buy.slot_id).timeout_sec == 120
service.transit_slot(
buy.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="fixture",
actor="test",
qty=1,
avg_price=1000,
)
sell = intents.submit_sell(SellIntent(intent_id="s1", slot_id=buy.slot_id))
assert sell.admitted
assert repo.fetch_slot(buy.slot_id).timeout_sec == 120
fallback_repo, _, fallback = _wire(default_timeout=None)
fallback_buy = fallback.submit_buy(
BuyIntent(intent_id="fallback", portfolio_id="PF-1", qty=1)
)
assert fallback_repo.fetch_slot(fallback_buy.slot_id).timeout_sec == 3600
@pytest.mark.parametrize("value", [0, -1, 3601, 1.5, True, "30"])
def test_instance_timeout_outside_toml_integer_contract_fails_startup(tmp_path, value):
path = tmp_path / "instance.toml"
literal = str(value).lower() if isinstance(value, bool) else repr(value)
path.write_text(f'instance_key = "x"\ndefault_order_timeout_sec = {literal}\n')
with pytest.raises(RuntimeError, match="default_order_timeout_sec"):
DaemonInstanceConfig.from_toml(str(path))
def test_instance_timeout_omission_and_bounds(tmp_path):
path = tmp_path / "instance.toml"
path.write_text('instance_key = "x"\n')
assert DaemonInstanceConfig.from_toml(str(path)).default_order_timeout_sec == 3600
for value in (1, 3600):
path.write_text(f'instance_key = "x"\ndefault_order_timeout_sec = {value}\n')
assert DaemonInstanceConfig.from_toml(str(path)).default_order_timeout_sec == value
def test_migration_does_not_invent_legacy_submission_metadata():
migration = (
Path(__file__).parents[2]
/ "trex_core/adapters/persistence/sql/022_persist_order_timeout_anchor.sql"
).read_text(encoding="utf-8")
assert "ADD COLUMN IF NOT EXISTS order_submitted_at" in migration
assert "ADD COLUMN IF NOT EXISTS order_date" in migration
assert "UPDATE daily_slot" not in migration
assert "COALESCE(updated_at, created_at)" not in migration
def test_dispatch_anchor_and_original_date_survive_bookkeeping_and_restart():
broker = _RecordingBroker()
repo, service, intents = _wire()
execution = ExecutionService(service, repo, broker)
decision = intents.submit_buy(
BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=1, timeout_sec=60)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
dispatched = repo.fetch_slot(decision.slot_id)
submitted_at = dispatched.order_submitted_at
order_date = dispatched.order_date
assert submitted_at is not None and order_date == submitted_at.astimezone(_KST).strftime("%Y%m%d")
assert repo.replace_dispatch(dispatched.slot_id, dispatched.broker_order_id, "resolved-1")
resolved = repo.fetch_slot(dispatched.slot_id)
assert resolved.order_submitted_at == submitted_at
assert resolved.order_date == order_date
repo.mark_cancel_outcome(dispatched.slot_id, "resolved-1", "unknown")
persisted = repo.fetch_slot(dispatched.slot_id)
assert persisted.updated_at != dispatched.updated_at
assert persisted.order_submitted_at == submitted_at
assert persisted.order_date == order_date
assert ExecutionService(service, repo, broker).reconcile().filled == 0
restarted = repo.fetch_slot(dispatched.slot_id)
assert restarted.order_submitted_at == submitted_at
assert restarted.order_date == order_date
service.transit_slot(
dispatched.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="terminal-fixture",
actor="test",
qty=1,
avg_price=1000,
)
repo.clear_dispatch(dispatched.slot_id)
terminal = repo.fetch_slot(dispatched.slot_id)
assert terminal.order_submitted_at is None
assert terminal.order_date is None
assert terminal.cancel_attempts == 0
class _AlarmRecorder:
def __init__(self):
self.calls = []
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
self.calls.append((code, severity, fields))
def test_legacy_pending_without_authoritative_metadata_is_fail_closed_and_alarm_bounded():
broker = _RecordingBroker()
repo, service, intents = _wire()
alarms = _AlarmRecorder()
execution = ExecutionService(service, repo, broker, alarm_emitter=alarms)
decision = intents.submit_buy(
BuyIntent(intent_id="legacy", portfolio_id="PF-1", qty=1, timeout_sec=1)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
slot = repo.fetch_slot(decision.slot_id)
repo._slots[slot.slot_id] = replace(
slot,
order_submitted_at=None,
order_date=None,
updated_at=datetime.now(tz=timezone.utc) - timedelta(days=2),
)
broker.responder = lambda _side: FillStatus(
state="filled",
filled_qty=1,
avg_price=1000,
execution_id="wrong-current-date-order",
)
execution.run_once()
execution.run_once()
persisted = repo.fetch_slot(slot.slot_id)
assert persisted.status is SlotState.PENDING_BUY
assert broker.cancel_calls == []
metadata_alarms = [
call for call in alarms.calls if call[0] == "order_reconciliation_metadata_unknown"
]
assert len(metadata_alarms) == 1
assert set(metadata_alarms[0][2]) == {"slot_id", "symbol"}
def test_requested_cancel_terminal_unresolved_alarm_is_deduped_per_process_and_reemitted_after_restart():
broker = _RecordingBroker()
repo, service, intents = _wire()
alarms = _AlarmRecorder()
execution = ExecutionService(service, repo, broker, alarm_emitter=alarms)
decision = intents.submit_buy(
BuyIntent(intent_id="requested", portfolio_id="PF-1", qty=1, timeout_sec=1)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
slot = repo.fetch_slot(decision.slot_id)
repo._slots[slot.slot_id] = replace(
slot,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(minutes=1),
)
execution.run_once()
assert repo.fetch_slot(slot.slot_id).cancel_state == "requested"
assert len(broker.cancel_calls) == 1
broker.responder = lambda _side: FillStatus(
state="unknown",
raw={
"reason": "order_not_visible_unresolved",
"raw_payload": {"authorization": "secret"},
},
)
execution.run_once()
execution.run_once()
persisted = repo.fetch_slot(slot.slot_id)
assert persisted.status is SlotState.PENDING_BUY
assert len(broker.cancel_calls) == 1
unresolved = [call for call in alarms.calls if call[0] == "order_cancel_unresolved"]
assert len(unresolved) == 1
assert unresolved[0][1] == "critical"
assert set(unresolved[0][2]) == {"slot_id", "symbol", "cancel_state", "order_date"}
assert "secret" not in repr(unresolved[0])
ExecutionService(service, repo, broker, alarm_emitter=alarms).run_once()
assert len(broker.cancel_calls) == 1
assert len([call for call in alarms.calls if call[0] == "order_cancel_unresolved"]) == 2
def test_cancel_rejected_is_fail_closed_alarm_and_authoritative_bounded_retry():
broker = _RecordingBroker()
broker.cancel_outcome = CancelOutcome(CancelState.REJECTED, "closed_session")
repo, service, intents = _wire()
alarms = _AlarmRecorder()
execution = ExecutionService(service, repo, broker, alarm_emitter=alarms)
decision = intents.submit_buy(
BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=1, timeout_sec=1)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
slot = repo.fetch_slot(decision.slot_id)
repo._slots[slot.slot_id] = replace(
slot,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(minutes=1),
)
execution.run_once()
rejected = repo.fetch_slot(slot.slot_id)
assert rejected.status is SlotState.PENDING_BUY
assert rejected.cancel_state == "rejected"
assert len(broker.cancel_calls) == 1
assert any(code == "order_cancel_unresolved" and severity == "critical" for code, severity, _ in alarms.calls)
# No visible broker row => no blind retry, even after restart/backoff.
repo._slots[slot.slot_id] = replace(
rejected,
cancel_requested_at=datetime.now(tz=timezone.utc) - timedelta(minutes=10),
)
ExecutionService(service, repo, broker, alarm_emitter=alarms).run_once()
assert len(broker.cancel_calls) == 1
# An authoritative open row permits one backoff-gated retry.
broker.responder = lambda _side: FillStatus(
state="accepted", raw={"order_open_confirmed": True}
)
broker.cancel_outcome = CancelOutcome(CancelState.REQUESTED)
ExecutionService(service, repo, broker, alarm_emitter=alarms).run_once()
assert len(broker.cancel_calls) == 2
retried = repo.fetch_slot(slot.slot_id)
assert retried.cancel_state == "requested"
assert retried.cancel_attempts == 2
ExecutionService(service, repo, broker).run_once()
assert len(broker.cancel_calls) == 2
class _Resp:
status_code = 200
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _kis(clock):
broker = KISBroker(
"12345678-01",
app_key="k",
app_secret="s",
base_url="https://openapivts.koreainvestment.com:29443",
clock=clock,
)
broker._token = "token"
broker._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
return broker
def test_kis_rollover_uses_original_date_and_row_visibility_is_nonterminal(monkeypatch):
now = datetime(2026, 8, 15, 9, 0, tzinfo=_KST)
submitted = datetime(2026, 8, 14, 15, 20, tzinfo=_KST)
seen = {}
def fake_get(*_args, **kwargs):
seen.update(kwargs["params"])
return _Resp({"rt_cd": "0", "output1": []})
monkeypatch.setattr(kis_mod.httpx, "get", fake_get)
status = _kis(lambda: now).poll_fill(
"06010:0000123456",
side="buy",
symbol="005930",
order_date="20260814",
submitted_at=submitted,
)
assert seen["INQR_STRT_DT"] == seen["INQR_END_DT"] == "20260814"
assert status.state == "unknown"
assert status.raw["reason"] == "order_not_visible_unresolved"
assert status.raw["order_visible"] is False
def test_kis_recent_missing_row_stays_in_bounded_visibility_grace(monkeypatch):
now = datetime(2026, 8, 14, 9, 0, tzinfo=_KST)
monkeypatch.setattr(
kis_mod.httpx,
"get",
lambda *_args, **_kwargs: _Resp({"rt_cd": "0", "output1": []}),
)
status = _kis(lambda: now).poll_fill(
"06010:0000123457",
side="sell",
symbol="005930",
order_date="20260814",
submitted_at=now - timedelta(seconds=30),
)
assert status.state == "accepted"
assert status.raw["reason"] == "order_visibility_grace"
assert status.raw["order_visible"] is False
def test_kis_missing_authoritative_order_date_fails_closed_without_network(monkeypatch):
now = datetime(2026, 8, 15, 9, 0, tzinfo=_KST)
def forbidden_get(*_args, **_kwargs):
raise AssertionError("legacy order without original date must not query KIS")
monkeypatch.setattr(kis_mod.httpx, "get", forbidden_get)
status = _kis(lambda: now).poll_fill(
"06010:0000123456",
side="buy",
symbol="005930",
order_date=None,
submitted_at=None,
)
assert status.state == "unknown"
assert not status.is_filled
assert not status.is_terminal_reject
assert status.raw == {"reason": "order_reconciliation_metadata_unknown"}
@@ -0,0 +1,932 @@
"""Reconciliation mismatch notification lifecycle tests (req-0007, ac-0024).
Verifies B-case and C-case detected/changed/resolved alarm publication with
durable deduplication via ReconcileNotificationStore, including:
- first detection
- unchanged suppression (within and across simulated restarts)
- quantity change (changed event)
- multi-portfolio C-case aggregation (one event per symbol)
- B-case slot identity isolation
- B sync resolution
- C verify resolution
- publish failure: fingerprint not advanced, retry next detection
- resolved publish failure: warning clear preserved, pending_resolved set
- notification-state persistence failure: business path not aborted
- duplicate resolved: idempotent
- sensitive-field absence
"""
from __future__ import annotations
from unittest.mock import MagicMock
from uuid import uuid4
from trex_core.adapters.messaging.in_memory import InMemoryTransport
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.broker_port import BalanceItem
from trex_core.application.portfolio import (
InMemoryReconcileNotificationStore,
InMemoryReconcileWarningStore,
PortfolioItem,
SnapshotStore,
)
from trex_core.application.reconciliation import (
_c_identity_key,
reconcile_core_holdings,
reconcile_positions,
sync_slot_to_broker,
verify_slot_reconcile,
)
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.domain.slot.models import Slot
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
INSTANCE = "test-instance"
def _make_emitter() -> tuple[AlarmEmitter, InMemoryTransport]:
transport = InMemoryTransport()
return AlarmEmitter(transport), transport
def _make_failing_emitter() -> AlarmEmitter:
"""AlarmEmitter backed by a transport that always raises."""
transport = InMemoryTransport()
transport.set_available(False)
return AlarmEmitter(transport)
def _alarm_codes(transport: InMemoryTransport) -> list[str]:
return [payload["code"] for _, payload in transport.alarm_events()]
def _alarm_fields_for(transport: InMemoryTransport, code: str) -> list[dict]:
return [
payload["fields"]
for _, payload in transport.alarm_events()
if payload["code"] == code
]
def _make_slot(
status: SlotState = SlotState.HOLDING,
qty: float = 10.0,
symbol: str = "005930",
pid: str = "pf-A",
) -> Slot:
return Slot(
slot_id=uuid4(),
status=status,
symbol=symbol,
qty=qty,
portfolio_id=pid,
instance_key=INSTANCE,
)
def _snapshot(portfolio_id: str, asset_qty: float, symbol: str = "005930") -> SnapshotStore:
store = SnapshotStore()
store.swap([PortfolioItem(
portfolio_id=portfolio_id,
symbol=symbol,
cash_balance=0.0,
asset_quantity=asset_qty,
)])
return store
class FakeBroker:
def __init__(self, balances: dict[str, int]):
self._b = balances
def name(self) -> str:
return "fake"
def submit_order(self, req): ...
def poll_fill(self, *args, **kw): ...
def cancel_order(self, *args, **kw): ...
def get_current_price(self, sym): return None
def get_balance(self) -> list[BalanceItem]:
return [BalanceItem(symbol=sym, hldg_qty=qty)
for sym, qty in self._b.items() if qty > 0]
def _repo_with_slot(slot: Slot) -> InMemorySlotRepository:
repo = InMemorySlotRepository()
repo.add_slot(slot)
return repo
def _run_reconcile_positions(
hub_qty: int,
broker_qty: int,
symbol: str = "005930",
pid: str = "pf-A",
emitter: AlarmEmitter | None = None,
notification_store=None,
warn_store=None,
slot_repo=None,
) -> dict:
store = _snapshot(pid, float(hub_qty), symbol=symbol)
broker = FakeBroker({symbol: broker_qty})
return reconcile_positions(
store=store,
broker=broker,
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=notification_store,
warn_store=warn_store,
slot_repo=slot_repo,
)
# ---------------------------------------------------------------------------
# B-case: reconcile_core_holdings
# ---------------------------------------------------------------------------
def test_b_case_first_detection_publishes_detected():
"""New B-case slot mismatch → detected event published once."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
slot_service = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo,
slot_service=slot_service,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=ns,
)
codes = _alarm_codes(transport)
assert codes == ["reconcile_mismatch_detected"]
state = ns.get_state(str(slot.slot_id))
assert state is not None
assert state["lifecycle_state"] == "notified"
assert state["quantity_a"] == 10
assert state["quantity_b"] == 7
def test_b_case_unchanged_suppression():
"""Repeated tick with same qty tuple → no second event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
slot_service = SlotService(repo)
for _ in range(3):
reconcile_core_holdings(
slot_repo=repo,
slot_service=slot_service,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=ns,
)
assert len(transport.alarm_events()) == 1
def test_b_case_process_restart_suppression():
"""After restart (new store seeded with old fingerprint), unchanged tick is suppressed."""
emitter1, transport1 = _make_emitter()
ns1 = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter1, notification_store=ns1,
)
assert len(transport1.alarm_events()) == 1
# Simulate restart: new emitter + new in-memory store, but seed it from ns1 state.
emitter2, transport2 = _make_emitter()
ns2 = InMemoryReconcileNotificationStore()
state = ns1.get_state(slot_id_str)
ns2.set_state(slot_id_str, **{k: v for k, v in state.items() if k != "identity_key"})
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter2, notification_store=ns2,
)
assert len(transport2.alarm_events()) == 0 # suppressed
def test_b_case_quantity_change_publishes_changed():
"""B-case same slot, different qty tuple → changed event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
# Now broker qty changes → different fingerprint
# We need to update the slot qty in-place to simulate a changed qty scenario.
# Manually update notification state to simulate a different prior fingerprint.
ns.set_state(str(slot.slot_id), case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
# New broker qty: 5 instead of 7
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 5}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_changed" in codes
state = ns.get_state(str(slot.slot_id))
assert state["quantity_b"] == 5
def test_b_case_separate_slots_no_fingerprint_mixing():
"""Different B-case slot identities have independent fingerprints."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot_a = _make_slot(qty=10.0, symbol="005930", pid="pf-A")
slot_b = _make_slot(qty=20.0, symbol="000660", pid="pf-B")
repo = InMemorySlotRepository()
repo.add_slot(slot_a)
repo.add_slot(slot_b)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7, "000660": 15}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
# Both should get detected events
codes = _alarm_codes(transport)
assert codes.count("reconcile_mismatch_detected") == 2
state_a = ns.get_state(str(slot_a.slot_id))
state_b = ns.get_state(str(slot_b.slot_id))
assert state_a is not None
assert state_b is not None
# Fingerprints are independent
assert state_a["quantity_a"] == 10
assert state_b["quantity_a"] == 20
assert state_a["identity_key"] != state_b["identity_key"]
def test_b_case_publish_failure_fingerprint_not_advanced():
"""Transport failure → fingerprint not advanced → retry on next detection."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=failing_emitter, notification_store=ns,
)
# Fingerprint must not be advanced
assert ns.get_state(str(slot.slot_id)) is None
# Next detection should try again (detected, not changed)
emitter2, transport2 = _make_emitter()
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter2, notification_store=ns,
)
assert _alarm_codes(transport2) == ["reconcile_mismatch_detected"]
def test_b_case_no_notification_without_store():
"""Backward compat: no notification_store → no crash, no notification."""
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
emitter, transport = _make_emitter()
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
# no notification_store
)
assert str(slot.slot_id) in decisions
assert len(transport.alarm_events()) == 0
# ---------------------------------------------------------------------------
# B-case resolved: sync_slot_to_broker
# ---------------------------------------------------------------------------
def test_b_sync_resolution_publishes_resolved():
"""operator sync clears warning → resolved event published."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
# Seed notification state (simulating prior detection)
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo,
slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_resolved" in codes
# State cleared after resolved
assert ns.get_state(slot_id_str) is None
def test_b_sync_resolved_publish_failure_warning_clear_preserved():
"""Resolved publish fails → warning clear is preserved, pending_resolved set."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
result = sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo,
slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=failing_emitter,
notification_store=ns,
)
# Warning clear preserved
assert result["warning_cleared"] is True
assert not ws.is_warned(slot_id_str)
# pending_resolved set for retry
state = ns.get_state(slot_id_str)
assert state is not None
assert state["lifecycle_state"] == "pending_resolved"
def test_b_sync_resolved_retry_on_next_reconcile():
"""After pending_resolved set, next reconcile pass retries resolved publish."""
ns = InMemoryReconcileNotificationStore()
slot_id_str = "some-slot-id"
# Manually set pending_resolved state (warning already cleared)
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="pending_resolved")
emitter, transport = _make_emitter()
slot = _make_slot(qty=7.0) # qty matches broker now — not in active mismatch set
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_resolved" in codes
assert ns.get_state(slot_id_str) is None
def test_b_sync_duplicate_resolved_idempotent():
"""No notification state → resolved is a no-op (idempotent)."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
# No notification state seeded — simulate never-detected
sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo,
slot_service=ss,
broker=FakeBroker({"005930": 7}), # qty 10 != 7 so sync updates
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
# resolved alarm must not be published when no prior state
resolved = [c for c in _alarm_codes(transport) if c == "reconcile_mismatch_resolved"]
assert len(resolved) == 0
# ---------------------------------------------------------------------------
# C-case: reconcile_positions
# ---------------------------------------------------------------------------
def test_c_case_first_detection_publishes_detected():
"""New C-case symbol aggregate mismatch → detected event published once."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(
hub_qty=10, broker_qty=7,
emitter=emitter, notification_store=ns,
)
assert _alarm_codes(transport) == ["reconcile_mismatch_detected"]
c_key = _c_identity_key(INSTANCE, "005930")
state = ns.get_state(c_key)
assert state is not None
assert state["case_code"] == "C"
assert state["quantity_a"] == 10
assert state["quantity_b"] == 7
def test_c_case_multi_portfolio_single_event():
"""C-case multiple portfolios for same symbol → exactly one detected event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
# pf-A has 6, pf-B has 4 → hub total 10 ≠ broker 7
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=6.0),
PortfolioItem(portfolio_id="pf-B", symbol="005930", cash_balance=0, asset_quantity=4.0),
])
broker = FakeBroker({"005930": 7})
reconcile_positions(
store=store,
broker=broker,
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=ns,
)
assert len(transport.alarm_events()) == 1
assert _alarm_codes(transport) == ["reconcile_mismatch_detected"]
# Only one state entry for the symbol
c_key = _c_identity_key(INSTANCE, "005930")
state = ns.get_state(c_key)
assert state is not None
assert state["quantity_a"] == 10 # 6 + 4
def test_c_case_unchanged_suppression():
"""Repeated C-case ticks with same qty tuple → no second event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
for _ in range(3):
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter, notification_store=ns)
assert len(transport.alarm_events()) == 1
def test_c_case_quantity_change_publishes_changed():
"""C-case qty tuple changes → changed event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter, notification_store=ns)
assert _alarm_codes(transport) == ["reconcile_mismatch_detected"]
# Broker qty changes
_run_reconcile_positions(hub_qty=10, broker_qty=5, emitter=emitter, notification_store=ns)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_changed" in codes
def test_c_case_publish_failure_fingerprint_not_advanced():
"""Transport failure → fingerprint not advanced → retry on next detection."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(
hub_qty=10, broker_qty=7, emitter=failing_emitter, notification_store=ns,
)
c_key = _c_identity_key(INSTANCE, "005930")
assert ns.get_state(c_key) is None
# Retry with working emitter
emitter2, transport2 = _make_emitter()
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter2, notification_store=ns)
assert _alarm_codes(transport2) == ["reconcile_mismatch_detected"]
def test_c_case_no_notification_without_store():
"""Backward compat: no alarm_emitter/notification_store → no crash."""
decisions = _run_reconcile_positions(hub_qty=10, broker_qty=7)
assert "pf-A" in decisions
# ---------------------------------------------------------------------------
# C-case resolved: verify_slot_reconcile
# ---------------------------------------------------------------------------
def test_c_verify_resolution_publishes_resolved():
"""operator verify clears C-case warning → resolved event published."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="C")
# Seed notification state
c_key = _c_identity_key(INSTANCE, "005930")
ns.set_state(c_key, case_code="C", instance_key=INSTANCE,
symbol="005930", quantity_a=8, quantity_b=10, lifecycle_state="notified")
store = _snapshot("pf-A", 10.0) # hub 10 == broker 10 → match
verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=FakeBroker({"005930": 10}),
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
assert "reconcile_mismatch_resolved" in _alarm_codes(transport)
assert ns.get_state(c_key) is None
def test_c_verify_resolved_publish_failure_warning_clear_preserved():
"""Resolved publish fails → warning clear preserved, pending_resolved set."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="C")
c_key = _c_identity_key(INSTANCE, "005930")
ns.set_state(c_key, case_code="C", instance_key=INSTANCE,
symbol="005930", quantity_a=8, quantity_b=10, lifecycle_state="notified")
store = _snapshot("pf-A", 10.0)
result = verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=FakeBroker({"005930": 10}),
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=failing_emitter,
notification_store=ns,
)
# Warning cleared
assert result["result"] == "cleared"
assert not ws.is_warned(slot_id_str)
# pending_resolved set
state = ns.get_state(c_key)
assert state is not None
assert state["lifecycle_state"] == "pending_resolved"
def test_c_verify_duplicate_resolved_idempotent():
"""No prior notification state → verify resolved is a no-op."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="C")
store = _snapshot("pf-A", 10.0)
verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=FakeBroker({"005930": 10}),
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
resolved = [c for c in _alarm_codes(transport) if c == "reconcile_mismatch_resolved"]
assert len(resolved) == 0
def test_c_verify_resolved_retry_on_next_reconcile():
"""pending_resolved → next reconcile_positions pass retries resolved publish."""
ns = InMemoryReconcileNotificationStore()
c_key = _c_identity_key(INSTANCE, "005930")
ns.set_state(c_key, case_code="C", instance_key=INSTANCE,
symbol="005930", quantity_a=8, quantity_b=10, lifecycle_state="pending_resolved")
emitter, transport = _make_emitter()
# Simulate match (hub == broker) so no active mismatch
_run_reconcile_positions(hub_qty=10, broker_qty=10, emitter=emitter, notification_store=ns)
assert "reconcile_mismatch_resolved" in _alarm_codes(transport)
assert ns.get_state(c_key) is None
# ---------------------------------------------------------------------------
# Notification-state persistence failure: business path not aborted
# ---------------------------------------------------------------------------
def test_notification_state_persistence_failure_does_not_abort_reconcile():
"""notification_store raises → reconciliation completes normally."""
bad_ns = MagicMock()
bad_ns.get_state.side_effect = RuntimeError("db down")
bad_ns.set_state.side_effect = RuntimeError("db down")
emitter, _transport = _make_emitter()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=bad_ns,
)
# Business path: decisions produced, no crash
assert str(slot.slot_id) in decisions
assert decisions[str(slot.slot_id)] == "qty_warning"
def test_notification_state_persistence_failure_c_case_does_not_abort_reconcile():
"""C-case notification_store raises → reconcile_positions completes normally."""
bad_ns = MagicMock()
bad_ns.get_state.side_effect = RuntimeError("db down")
bad_ns.list_states_by_lifecycle.side_effect = RuntimeError("db down")
emitter, _transport = _make_emitter()
decisions = _run_reconcile_positions(
hub_qty=10, broker_qty=7, emitter=emitter, notification_store=bad_ns,
)
assert "pf-A" in decisions
# ---------------------------------------------------------------------------
# Sensitive-field absence
# ---------------------------------------------------------------------------
def test_b_case_alarm_fields_no_sensitive_data():
"""B-case alarm fields contain only allowlist values — no account/credential/DSN."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
events = transport.alarm_events()
assert len(events) == 1
_, payload = events[0]
fields = payload["fields"]
# Allowlist: instance_key, symbol, case_code, broker_quantity, slot_quantity, slot_id
allowed = {"instance_key", "symbol", "case_code", "broker_quantity", "slot_quantity", "slot_id"}
extra = set(fields.keys()) - allowed
assert extra == set(), f"Unexpected fields: {extra}"
# No sensitive values
all_values = " ".join(str(v) for v in fields.values())
for forbidden in ("account", "credential", "token", "dsn", "postgresql", "amqp", "secret"):
assert forbidden not in all_values.lower(), f"Sensitive token '{forbidden}' found in fields"
def test_c_case_alarm_fields_no_sensitive_data():
"""C-case alarm fields contain only allowlist values."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter, notification_store=ns)
_, payload = transport.alarm_events()[0]
fields = payload["fields"]
allowed = {"instance_key", "symbol", "case_code", "broker_quantity", "hub_quantity"}
extra = set(fields.keys()) - allowed
assert extra == set(), f"Unexpected fields: {extra}"
# hub_quantity present, slot_quantity absent
assert "hub_quantity" in fields
assert "slot_quantity" not in fields
def test_resolved_alarm_fields_b_case():
"""Resolved B-case alarm fields include allowlist values only."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter, notification_store=ns,
)
resolved_events = [
(rk, p) for rk, p in transport.alarm_events()
if p["code"] == "reconcile_mismatch_resolved"
]
assert len(resolved_events) == 1
_, payload = resolved_events[0]
fields = payload["fields"]
allowed = {"instance_key", "symbol", "case_code", "broker_quantity", "slot_quantity", "slot_id"}
extra = set(fields.keys()) - allowed
assert extra == set(), f"Unexpected fields: {extra}"
# ---------------------------------------------------------------------------
# Existing behavior preservation
# ---------------------------------------------------------------------------
def test_b_case_existing_alarm_emitter_still_works_without_notification_store():
"""AlarmEmitter existing callers: no notification_store → no regression."""
emitter, transport = _make_emitter()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
warn_store=ws,
)
# No alarm from B-case without notification_store (only existing A-case alarm path unchanged)
assert len(transport.alarm_events()) == 0
assert ws.is_warned(str(slot.slot_id))
def test_publish_alarm_returns_bool():
"""AlarmEmitter.publish_alarm returns True on success, False on failure."""
emitter, _ = _make_emitter()
result = emitter.publish_alarm("test_code", "Test title")
assert result is True
failing = _make_failing_emitter()
result2 = failing.publish_alarm("test_code", "Test title")
assert result2 is False
# ---------------------------------------------------------------------------
# Regression tests for ac-0024 blocker fixes
# ---------------------------------------------------------------------------
def test_c_identity_key_is_injective_and_postgres_safe():
"""_c_identity_key must be injective (no collisions) and PostgreSQL TEXT-safe.
Specifically verifies the crafted-separator collision that bit the \\x1f
implementation: ('a\\x1fC', 'b') and ('a', 'C\\x1fb') previously produced
the same key and suppressed the second detected event.
"""
key = _c_identity_key("my-instance", "005930")
assert "\x00" not in key, f"identity key contains NUL: {key!r}"
# Different symbols → different keys
assert key != _c_identity_key("my-instance", "005931")
# Different instances → different keys
assert key != _c_identity_key("other-instance", "005930")
# Crafted-separator collision must not exist
crafted_a = _c_identity_key("a\x1fC", "b")
crafted_b = _c_identity_key("a", "C\x1fb")
assert crafted_a != crafted_b, (
f"Separator-crafted collision: both produce {crafted_a!r}"
)
def test_retry_pending_resolved_ignores_other_instance_states():
"""_retry_pending_resolved must not touch pending states belonging to a different instance."""
from trex_core.application.reconciliation import _retry_pending_resolved
ns = InMemoryReconcileNotificationStore()
other_slot_id = "slot-other-instance"
# pending_resolved belonging to *another* instance
ns.set_state(other_slot_id, case_code="B", instance_key="other-instance",
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="pending_resolved")
emitter, transport = _make_emitter()
_retry_pending_resolved(
emitter, ns,
case_code="B",
instance_key=INSTANCE, # current instance — should NOT see other-instance states
active_identity_keys=set(),
)
# No resolved alarm published for other instance's state
codes = _alarm_codes(transport)
assert "reconcile_mismatch_resolved" not in codes
# State preserved (not consumed by wrong instance)
assert ns.get_state(other_slot_id) is not None
def test_b_sync_does_not_publish_resolved_when_clear_warned_fails():
"""Resolved alarm must NOT be published when clear_warned() raises (warning still active)."""
from trex_core.application.reconciliation import sync_slot_to_broker
class FailingWarnStore(InMemoryReconcileWarningStore):
def clear_warned(self, slot_id):
raise RuntimeError("DB unavailable")
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = FailingWarnStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
emitter, transport = _make_emitter()
result = sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo,
slot_service=ss,
broker=FakeBroker({"005930": 7}), # qty matches → clear attempt made
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
# warning_cleared is False because clear_warned raised
assert result["warning_cleared"] is False
# No resolved alarm should have been published
codes = _alarm_codes(transport)
assert "reconcile_mismatch_resolved" not in codes
# notification state untouched
state = ns.get_state(slot_id_str)
assert state is not None
assert state["lifecycle_state"] == "notified"
@@ -3,6 +3,8 @@ 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
@@ -58,6 +60,72 @@ class InMemoryIntentStore:
self._db[intent_id] = {"status": status, "slot_id": slot_id, "reason": reason}
class RecordingIntentStore(InMemoryIntentStore):
def __init__(self, trace=None, fail_finalize=False):
super().__init__()
self._trace = trace
self._fail_finalize = fail_finalize
def finalize(self, intent_id, status, slot_id, reason):
if self._trace is not None:
self._trace.append("finalize")
if self._fail_finalize:
raise RuntimeError("finalize failed")
super().finalize(intent_id, status, slot_id, reason)
class RecordingAlarmEmitter:
def __init__(self, *, trace=None, return_value=True, raise_error=False):
self.calls = []
self._trace = trace
self._return_value = return_value
self._raise_error = raise_error
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
if self._trace is not None:
self._trace.append("publish")
self.calls.append((code, title, severity, fields or {}))
if self._raise_error:
raise RuntimeError("transport down")
return self._return_value
class DuplicateInFlightIntentStore:
def try_insert(self, intent_id, intent_type, payload):
return False
def get(self, intent_id):
return None
def finalize(self, intent_id, status, slot_id, reason):
raise AssertionError("finalize must not be called for duplicate_in_flight")
class PostFinalizeContextFailingSlotRepository(InMemorySlotRepository):
"""Raise fetch_slot only after explicitly armed by finalize."""
def __init__(self):
super().__init__()
self._raise_on_fetch = False
def fetch_slot(self, slot_id):
if self._raise_on_fetch:
raise RuntimeError("context lookup failed")
return super().fetch_slot(slot_id)
class FinalizeArmingIntentStore(InMemoryIntentStore):
"""Arm repo fetch failure immediately after finalize persistence."""
def __init__(self, repo):
super().__init__()
self._repo = repo
def finalize(self, intent_id, status, slot_id, reason):
super().finalize(intent_id, status, slot_id, reason)
self._repo._raise_on_fetch = True
def _make_holding_slot(repo, svc, instance_key="default", portfolio_id="pf-A", symbol="005930", qty=10):
"""Helper: creates universe, puts slot in HOLDING state with given qty."""
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
@@ -272,6 +340,256 @@ def test_intent_service_sell_rejected_when_reconcile_warned():
assert d.reason == "reconcile_warned"
def test_reconcile_warned_buy_emits_alarm_with_allowlisted_fields_after_finalize():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
trace = []
intent_store = RecordingIntentStore(trace=trace)
alarms = RecordingAlarmEmitter(trace=trace)
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
alarm_emitter=alarms,
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-alert-001",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
strategy_id="stg-buy",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
assert trace == ["finalize", "publish"]
assert len(alarms.calls) == 1
code, title, severity, fields = alarms.calls[0]
assert code == "strategy_intent_rejected"
assert severity == "warn"
assert title
assert fields["instance_key"] == "default"
assert fields["intent_id"] == "b-alert-001"
assert fields["intent_type"] == "buy"
assert fields["reason"] == "reconcile_warned"
assert fields["broker_submitted"] is False
assert fields["slot_id"] == str(slot.slot_id)
assert fields["portfolio_id"] == "pf-A"
assert fields["symbol"] == "005930"
assert fields["strategy_id"] == "stg-buy"
for blocked in ("qty", "entry_price", "limit_price", "venue", "extra", "sell_strategy_id"):
assert blocked not in fields
text = repr((title, fields))
for forbidden in ("account", "token", "password", "secret", "dsn", "connection", "raw_payload"):
assert forbidden not in text.lower()
def test_reconcile_warned_sell_emits_alarm_with_normalized_strategy_id():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
alarms = RecordingAlarmEmitter()
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A", symbol="005930")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
alarm_emitter=alarms,
)
d = intent_svc.submit_sell(SellIntent(
intent_id="s-alert-001",
slot_id=slot.slot_id,
reason="strategy_exit",
sell_strategy_id="stg-sell",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
assert len(alarms.calls) == 1
_, _, severity, fields = alarms.calls[0]
assert severity == "warn"
assert fields["intent_type"] == "sell"
assert fields["strategy_id"] == "stg-sell"
assert fields["slot_id"] == str(slot.slot_id)
def test_reconcile_warned_alarm_dedup_by_intent_id_across_service_recreation():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
alarms = RecordingAlarmEmitter()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
s1 = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
s2 = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
first = s1.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
second = s1.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
third = s2.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
assert first.reason == "reconcile_warned"
assert second.reason == "reconcile_warned"
assert third.reason == "reconcile_warned"
assert len(alarms.calls) == 1
def test_reconcile_warned_alarm_emits_per_distinct_intent_id_on_same_slot():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
alarms = RecordingAlarmEmitter()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
d1 = intent_svc.submit_buy(BuyIntent(intent_id="b-uniq-1", slot_id=slot.slot_id))
d2 = intent_svc.submit_buy(BuyIntent(intent_id="b-uniq-2", slot_id=slot.slot_id))
assert d1.reason == "reconcile_warned"
assert d2.reason == "reconcile_warned"
assert len(alarms.calls) == 2
def test_reconcile_warned_alarm_not_published_for_duplicate_in_flight_or_other_decisions():
repo = InMemorySlotRepository()
svc = SlotService(repo)
alarms = RecordingAlarmEmitter()
duplicate = IntentService(
svc, repo, DuplicateInFlightIntentStore(), instance_key="default", alarm_emitter=alarms
)
d_dup = duplicate.submit_buy(BuyIntent(intent_id="dup", portfolio_id="pf-A"))
assert d_dup.reason == "duplicate_in_flight"
assert alarms.calls == []
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
normal = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", alarm_emitter=alarms
)
admitted = normal.submit_buy(BuyIntent(intent_id="admitted", slot_id=slot.slot_id, qty=1, entry_price=100))
assert admitted.status == "admitted"
invalid = normal.submit_buy(BuyIntent(intent_id="bad-venue", slot_id=slot.slot_id, venue="BAD"))
assert invalid.reason == "invalid_venue"
repo.reconcile_universe("default", {"pf-A": "005930", "pf-B": "000660"})
slot_b = repo.find_slot_by_portfolio("default", "pf-B")
capped = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", max_active_slots=0, alarm_emitter=alarms
)
full = capped.submit_buy(BuyIntent(intent_id="full", slot_id=slot_b.slot_id))
assert full.reason == "capacity_full"
assert alarms.calls == []
def test_reconcile_warned_alarm_finalize_failure_and_publish_failure_are_isolated():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
alarms = RecordingAlarmEmitter()
failing_store = RecordingIntentStore(fail_finalize=True)
failing_finalize = IntentService(
svc, repo, failing_store,
instance_key="default",
warn_store=warn_store,
alarm_emitter=alarms,
)
with pytest.raises(RuntimeError, match="finalize failed"):
failing_finalize.submit_buy(BuyIntent(intent_id="b-finalize-fail", slot_id=slot.slot_id))
assert alarms.calls == []
soft_fail_alarm = RecordingAlarmEmitter(return_value=False)
raising_alarm = RecordingAlarmEmitter(raise_error=True)
non_blocking_1 = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=soft_fail_alarm
)
non_blocking_2 = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=raising_alarm
)
d1 = non_blocking_1.submit_buy(BuyIntent(intent_id="b-alarm-false", slot_id=slot.slot_id))
d2 = non_blocking_2.submit_buy(BuyIntent(intent_id="b-alarm-exc", slot_id=slot.slot_id))
d3 = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=None
).submit_buy(BuyIntent(intent_id="b-no-emitter", slot_id=slot.slot_id))
assert d1.reason == "reconcile_warned"
assert d2.reason == "reconcile_warned"
assert d3.reason == "reconcile_warned"
def test_reconcile_warned_buy_context_lookup_failure_does_not_change_response():
repo = PostFinalizeContextFailingSlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = FinalizeArmingIntentStore(repo)
alarms = RecordingAlarmEmitter()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
decision = intent_svc.submit_buy(
BuyIntent(intent_id="buy-context-fail", slot_id=slot.slot_id, strategy_id="stg-buy")
)
assert decision.status == "rejected"
assert decision.reason == "reconcile_warned"
assert len(alarms.calls) == 1
_, _, _, fields = alarms.calls[0]
assert fields["intent_id"] == "buy-context-fail"
assert fields["strategy_id"] == "stg-buy"
assert "slot_id" not in fields
assert "portfolio_id" not in fields
assert "symbol" not in fields
def test_reconcile_warned_sell_context_lookup_failure_does_not_change_response():
repo = PostFinalizeContextFailingSlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = FinalizeArmingIntentStore(repo)
alarms = RecordingAlarmEmitter()
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A", symbol="005930")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
intent_svc = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
decision = intent_svc.submit_sell(
SellIntent(intent_id="sell-context-fail", slot_id=slot.slot_id, sell_strategy_id="stg-sell")
)
assert decision.status == "rejected"
assert decision.reason == "reconcile_warned"
assert len(alarms.calls) == 1
_, _, _, fields = alarms.calls[0]
assert fields["intent_id"] == "sell-context-fail"
assert fields["strategy_id"] == "stg-sell"
assert "slot_id" not in fields
assert "portfolio_id" not in fields
assert "symbol" not in fields
# REQ-104 AC-3: no warn, no pause => buy/sell admitted normally
def test_intent_service_no_warn_admitted_normally():
repo = InMemorySlotRepository()
@@ -375,7 +693,7 @@ def _wire_with_warn(warned_slot_ids=None):
def _req(filter=None):
return RpcRequest(payload={"filter": filter or "all"}, correlation_id="t")
return RpcRequest(payload={"filter": filter or "all", "spy": True}, correlation_id="t")
# REQ-104 AC-5: reconcile_warned=True for warned slot
@@ -438,7 +756,7 @@ def test_slots_rpc_existing_warned_field_unchanged():
reason="filled", actor="test", avg_price=100.0, qty=10.0)
warn_store = InMemoryReconcileWarningStore()
handler = make_slots_rpc_handler(repo, snapshot_store=store, warn_store=warn_store)
resp = handler(RpcRequest(payload={"filter": "holding"}, correlation_id="t"))
resp = handler(RpcRequest(payload={"filter": "holding", "spy": True}, correlation_id="t"))
row = resp.payload["slots"][0]
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
assert row["warned"] is True
@@ -598,3 +916,61 @@ def test_slot_to_dict_paused_and_warned_independent():
row = slot_to_dict(slot, pause_store=pause_store, warn_store=warn_store)
assert row["paused"] is True
assert row["reconcile_warned"] is True
# ===========================================================================
# AC-0023 review: warn_store.is_warned() raises → intent fail-closed
# ===========================================================================
class _RaisingWarnStore:
"""Stub warn_store whose is_warned always raises (simulates DB unavailable)."""
def is_warned(self, slot_id: str) -> bool:
raise RuntimeError("DB unavailable")
def list_c_case_warned(self) -> set:
raise RuntimeError("DB unavailable")
def test_buy_intent_fail_closed_when_warn_store_raises():
"""If warn_store.is_warned() raises, buy intent must be rejected (fail-closed)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=_RaisingWarnStore(),
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-fc-001",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "rejected"
assert d.reason == "reconcile_warning_unavailable"
def test_sell_intent_fail_closed_when_warn_store_raises():
"""If warn_store.is_warned() raises, sell intent must be rejected (fail-closed)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
slot = _make_holding_slot(repo, svc, qty=10)
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=_RaisingWarnStore(),
)
d = intent_svc.submit_sell(SellIntent(
intent_id="s-fc-001",
slot_id=slot.slot_id,
reason="strategy_exit",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warning_unavailable"
+66 -74
View File
@@ -1,18 +1,12 @@
"""rejected SellIntent `retryable` flag (issue #27 / REQ-007 CHG-20260615-01).
Additive: only on intent_type="sell" + status="rejected". Maps reason→retryable
via the core-owned table (unrecognized → True, safety default; bad_request → False).
admitted / processing / buy responses must NOT carry the field.
"""
"""Strategy RPC responses never expose broker retryability (GitHub #60)."""
from __future__ import annotations
from uuid import uuid4
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import IntentService
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import (
_sell_reject_retryable,
make_intent_rpc_handler,
)
from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@@ -22,9 +16,14 @@ INST = "default"
def _wire():
repo = InMemorySlotRepository()
svc = SlotService(repo, instance_key=INST)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
instance_key=INST, max_active_slots=5)
intents = IntentService(
svc,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
instance_key=INST,
max_active_slots=5,
)
repo.reconcile_universe(INST, {"PF-1": "SYM"})
handler = make_intent_rpc_handler(intents)
slot = repo.list_slots(INST)[0]
@@ -32,72 +31,65 @@ def _wire():
def _sell(handler, slot_id, intent_id="s1"):
return handler(RpcRequest(payload={
"intent_type": "sell", "intent_id": intent_id, "slot_id": str(slot_id),
}, correlation_id=intent_id))
return handler(
RpcRequest(
payload={
"intent_type": "sell",
"intent_id": intent_id,
"slot_id": str(slot_id),
},
correlation_id=intent_id,
)
)
class TestRetryableMapping:
def test_each_reason_maps(self):
assert _sell_reject_retryable("slot_state_changed") is True
assert _sell_reject_retryable("slot_not_holding:pending_buy") is True
assert _sell_reject_retryable("duplicate_in_flight") is True
assert _sell_reject_retryable("slot_not_found") is False
assert _sell_reject_retryable("slot_not_holding:inactive") is False
assert _sell_reject_retryable("slot_not_holding:pending_sell") is False
assert _sell_reject_retryable("slot_not_holding:error") is False
def test_unrecognized_defaults_true(self):
assert _sell_reject_retryable("some_future_reason") is True
assert _sell_reject_retryable(None) is True
def test_bad_request_is_false(self):
assert _sell_reject_retryable("bad_request:KeyError('slot_id')") is False
def test_rejected_sell_does_not_expose_retryability():
_, _, _, handler, slot = _wire()
response = _sell(handler, slot.slot_id)
assert response.payload["status"] == "rejected"
assert response.payload["reason"] == "slot_not_holding:inactive"
assert "retryable" not in response.payload
class TestResponseField:
def test_rejected_sell_inactive_slot_has_retryable_false(self):
# slot is inactive (never bought) → slot_not_holding:inactive → terminal
repo, svc, intents, handler, slot = _wire()
resp = _sell(handler, slot.slot_id)
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "slot_not_holding:inactive"
assert resp.payload["retryable"] is False
def test_unknown_slot_sell_does_not_expose_retryability():
_, _, _, handler, _ = _wire()
response = _sell(handler, uuid4())
assert response.payload["status"] == "rejected"
assert response.payload["reason"] == "slot_not_found"
assert "retryable" not in response.payload
def test_rejected_sell_unknown_slot_false(self):
from uuid import uuid4
_, _, _, handler, _ = _wire()
resp = _sell(handler, uuid4())
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "slot_not_found"
assert resp.payload["retryable"] is False
def test_bad_request_sell_carries_retryable_false(self):
_, _, _, handler, _ = _wire()
resp = handler(RpcRequest(payload={
"intent_type": "sell", "intent_id": "x", # missing slot_id
}, correlation_id="x"))
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"].startswith("bad_request")
assert resp.payload["retryable"] is False
def test_malformed_sell_does_not_expose_retryability():
_, _, _, handler, _ = _wire()
response = handler(
RpcRequest(
payload={"intent_type": "sell", "intent_id": "x"},
correlation_id="x",
)
)
assert response.payload["status"] == "rejected"
assert response.payload["reason"].startswith("bad_request")
assert "retryable" not in response.payload
def test_admitted_sell_has_no_retryable(self):
repo, svc, intents, handler, slot = _wire()
# bring slot to holding so the sell is admitted
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10, avg_price=100)
resp = _sell(handler, slot.slot_id)
assert resp.payload["status"] == "admitted"
assert "retryable" not in resp.payload
def test_buy_rejection_has_no_retryable(self):
# buy on a non-existent slot → rejected, but buy responses omit retryable
from uuid import uuid4
_, _, _, handler, _ = _wire()
resp = handler(RpcRequest(payload={
"intent_type": "buy", "intent_id": "b1", "slot_id": str(uuid4()),
}, correlation_id="b1"))
assert resp.payload["status"] == "rejected"
assert "retryable" not in resp.payload
def test_admitted_sell_does_not_expose_retryability():
_, svc, _, handler, slot = _wire()
svc.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
)
svc.transit_slot(
slot.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
qty=10,
avg_price=100,
)
response = _sell(handler, slot.slot_id)
assert response.payload["status"] == "admitted"
assert "retryable" not in response.payload
+4 -1
View File
@@ -43,6 +43,8 @@ def _req(filter=None, spy=False):
payload["filter"] = filter
if spy:
payload["spy"] = True
else:
payload["strategy_instance_id"] = "test-instance"
return RpcRequest(payload=payload, correlation_id="test-corr")
@@ -155,7 +157,8 @@ def test_warned_true_when_qty_diverges():
resp = handler(_req(filter="holding"))
s = resp.payload["slots"][0]
# slot.qty=5 vs hub asset_quantity=3 → divergence
assert s["qty"] == 5.0
assert s["qty"] is None
assert s["slot_quantity"] == 5.0
assert s["asset_quantity"] == 3.0
assert s["warned"] is True
@@ -0,0 +1,231 @@
"""Broker-authoritative qty projection in `trex.slots.req` holding rows."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Protocol
from trex_core.application.account_snapshot import (
AccountSnapshot,
AccountSnapshotService,
AccountSnapshotUnavailable,
InMemoryAccountSnapshotStore,
)
from trex_core.application.broker_port import BalanceItem
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_slots_rpc_handler
from trex_core.application.portfolio import PortfolioItem, SnapshotStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
class _Reader:
def __init__(
self,
*,
holdings: list[BalanceItem] | None = None,
captured_at: datetime | None = None,
unavailable_code: str | None = None,
) -> None:
self._holdings = holdings or []
self._captured_at = captured_at
self._unavailable_code = unavailable_code
def holdings_for_slots_rpc(self) -> tuple[list[BalanceItem], datetime]:
if self._unavailable_code is not None:
raise AccountSnapshotUnavailable(self._unavailable_code)
if self._captured_at is None:
raise AccountSnapshotUnavailable("not_ready")
return list(self._holdings), self._captured_at
class _SlotsReader(Protocol):
def holdings_for_slots_rpc(self) -> tuple[list[BalanceItem], datetime]: ...
class _BrokenLoadStore(InMemoryAccountSnapshotStore):
def load(self, account_hash: str) -> AccountSnapshot | None:
raise RuntimeError("db read failed")
class _Broker:
def account_scope_hash(self) -> str:
return "a" * 64
def get_balance(self) -> list[BalanceItem]:
return []
def get_available_cash(self, *, price_check_code: str = "005930") -> int:
return 0
def _holding_handler(
*,
broker_reader: _SlotsReader,
instance_key: str = "default",
portfolio_id: str = "pf-A",
symbol: str = "104620",
):
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
slot = repo.find_slot_by_portfolio(instance_key, portfolio_id)
pending = svc.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
symbol=symbol,
)
svc.transit_slot(
pending.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
avg_price=100.0,
qty=435.0,
)
store = SnapshotStore()
store.swap(
[PortfolioItem(portfolio_id=portfolio_id, symbol=symbol, cash_balance=0, asset_quantity=435)]
)
handler = make_slots_rpc_handler(
repo,
instance_key=instance_key,
snapshot_store=store,
broker_quantity_reader=broker_reader,
)
return repo, handler
def _req_holding():
return RpcRequest(payload={"filter": "holding", "spy": True}, correlation_id="corr")
def test_holding_qty_uses_broker_authority_with_diagnostic_fields():
_, handler = _holding_handler(
broker_reader=_Reader(
holdings=[BalanceItem(symbol="104620", hldg_qty=440)],
captured_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] == 440.0
assert row["qty_source"] == "broker"
assert row["broker_quantity_available"] is True
assert row["broker_quantity_captured_at"] == "2026-08-24T12:00:00+00:00"
assert row["slot_quantity"] == 435.0
assert row["asset_quantity"] == 435.0
def test_holding_qty_distinguishes_confirmed_broker_zero_from_unavailable():
_, handler = _holding_handler(
broker_reader=_Reader(
holdings=[],
captured_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] == 0.0
assert row["qty_source"] == "broker"
assert row["broker_quantity_available"] is True
assert row["slot_quantity"] == 435.0
def test_holding_qty_unavailable_does_not_fallback_to_slot_or_hub():
_, handler = _holding_handler(
broker_reader=_Reader(unavailable_code="not_ready"),
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] is None
assert row["qty_source"] == "unavailable"
assert row["broker_quantity_available"] is False
assert row["broker_quantity_captured_at"] is None
assert row["slot_quantity"] == 435.0
assert row["asset_quantity"] == 435.0
def test_holding_qty_stale_snapshot_is_unavailable():
_, handler = _holding_handler(
broker_reader=_Reader(unavailable_code="expired"),
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] is None
assert row["qty_source"] == "unavailable"
assert row["broker_quantity_available"] is False
def test_holding_qty_store_read_failure_is_unavailable():
_, handler = _holding_handler(
broker_reader=AccountSnapshotService(
broker=_Broker(),
store=_BrokenLoadStore(),
buy_max_age_sec=600,
sell_max_age_sec=3600,
clock=lambda: datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
rng=lambda: 0.5,
),
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] is None
assert row["qty_source"] == "unavailable"
assert row["broker_quantity_available"] is False
assert row["broker_quantity_captured_at"] is None
def test_broker_quantity_reader_is_instance_bound_per_handler():
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("inst-A", {"pf-A": "104620"})
repo.reconcile_universe("inst-B", {"pf-B": "104620"})
slot_a = repo.find_slot_by_portfolio("inst-A", "pf-A")
slot_b = repo.find_slot_by_portfolio("inst-B", "pf-B")
pending_a = svc.transit_slot(
slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="104620",
)
pending_b = svc.transit_slot(
slot_b.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="104620",
)
svc.transit_slot(
pending_a.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=435.0,
)
svc.transit_slot(
pending_b.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=435.0,
)
store = SnapshotStore()
store.swap(
[
PortfolioItem(portfolio_id="pf-A", symbol="104620", cash_balance=0, asset_quantity=435),
PortfolioItem(portfolio_id="pf-B", symbol="104620", cash_balance=0, asset_quantity=435),
]
)
captured_at = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
handler_a = make_slots_rpc_handler(
repo,
instance_key="inst-A",
snapshot_store=store,
broker_quantity_reader=_Reader(
holdings=[BalanceItem(symbol="104620", hldg_qty=440)],
captured_at=captured_at,
),
)
handler_b = make_slots_rpc_handler(
repo,
instance_key="inst-B",
snapshot_store=store,
broker_quantity_reader=_Reader(
holdings=[BalanceItem(symbol="104620", hldg_qty=111)],
captured_at=captured_at,
),
)
row_a = handler_a(_req_holding()).payload["slots"][0]
row_b = handler_b(_req_holding()).payload["slots"][0]
assert row_a["qty"] == 440.0
assert row_b["qty"] == 111.0
@@ -0,0 +1,588 @@
"""AC-0023: C-case quantity mismatch — strategy RPC exclusion, fail-closed, and clearing.
Coverage:
AC-1: single portfolio mismatch → slot excluded
AC-2: multi-portfolio same symbol: aggregate matches → both slots included
AC-3: multi-portfolio same symbol: aggregate mismatches → both slots excluded
AC-4: different symbol slots unaffected by C-case warning on another symbol
AC-5: excluded_slots.reconcile_quantity_mismatch count in response
AC-6: warn_store=None → no filtering (backward compat), no excluded_slots key
AC-7: warn_store raises → fail-closed: slots=[], degraded=true, warn_store_unavailable
AC-8: intent admitted before warning is set → rejected at intent time (race)
AC-9: in-flight (terminal) slot transitions not blocked
AC-10: verify_slot_reconcile uses symbol aggregate for multi-portfolio clear
AC-11: verify_slot_reconcile clears all C-case warnings for symbol when resolved
AC-12: C-case warning not auto-cleared by time; only cleared on quantity match
"""
from __future__ import annotations
from trex_core.application.broker_port import BalanceItem
from trex_core.application.intents import IntentService, SellIntent
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_slots_rpc_handler
from trex_core.application.portfolio import (
InMemoryPauseStore,
InMemoryReconcileWarningStore,
PortfolioItem,
SnapshotStore,
)
from trex_core.application.reconciliation import (
reconcile_positions,
verify_slot_reconcile,
)
from trex_core.application.watchdog import WatchdogStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
INSTANCE = "default"
SYM_A = "104620"
SYM_B = "005930"
def _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10, instance_key=INSTANCE):
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
slot = repo.find_slot_by_portfolio(instance_key, portfolio_id)
pending = svc.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=symbol,
)
holding = svc.transit_slot(
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=float(qty),
)
return holding
def _make_handler(repo, warn_store, snapshot_items=None):
store = SnapshotStore()
if snapshot_items:
store.swap(snapshot_items)
return make_slots_rpc_handler(
repo,
instance_key=INSTANCE,
snapshot_store=store,
pause_store=InMemoryPauseStore(),
warn_store=warn_store,
watchdog_store=WatchdogStore(),
)
def _req(filter=None):
payload = {}
payload["spy"] = True
if filter:
payload["filter"] = filter
return RpcRequest(payload=payload, correlation_id="test")
class FakeBroker:
def __init__(self, balances):
self._b = balances
def name(self): return "fake"
def submit_order(self, req): ...
def poll_fill(self, *args, **kw): ...
def cancel_order(self, *args, **kw): ...
def get_balance(self): return list(self._b)
class InMemoryIntentStore:
def __init__(self):
self._db = {}
def try_insert(self, intent_id, intent_type, payload):
if intent_id in self._db:
return False
self._db[intent_id] = {"status": None, "slot_id": None, "reason": None}
return True
def get(self, intent_id):
rec = self._db.get(intent_id)
if rec is None:
return None
class R:
def __init__(self, d): self.__dict__.update(d)
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}
# ---------------------------------------------------------------------------
# AC-1: single portfolio C-case → slot excluded from strategy RPC
# ---------------------------------------------------------------------------
def test_ac1_single_portfolio_c_case_excluded():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
handler = _make_handler(repo, warn_store)
resp = handler(_req(filter="holding"))
assert resp.payload["slots"] == []
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1
# ---------------------------------------------------------------------------
# AC-2: multi-portfolio same symbol: KIS 440, A=200, B=240 → aggregate 440 → both included
# ---------------------------------------------------------------------------
def test_ac2_multi_portfolio_aggregate_matches_both_included():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
# Build two holding slots for same symbol
repo.reconcile_universe(INSTANCE, {"pf-A": SYM_A, "pf-B": SYM_A})
slot_a_base = repo.find_slot_by_portfolio(INSTANCE, "pf-A")
slot_b_base = repo.find_slot_by_portfolio(INSTANCE, "pf-B")
p = svc.transit_slot(slot_a_base.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=SYM_A)
svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=200.0)
p = svc.transit_slot(slot_b_base.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=SYM_A)
svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=240.0)
# No C-case warnings set (aggregate matches KIS 440)
handler = _make_handler(repo, warn_store)
resp = handler(_req(filter="holding"))
portfolio_ids = {r["portfolio_id"] for r in resp.payload["slots"]}
assert "pf-A" in portfolio_ids
assert "pf-B" in portfolio_ids
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 0
def test_ac2_multi_portfolio_individual_qty_vs_kis_total_not_compared():
"""AC-2: each portfolio qty (200, 240) != KIS 440, but aggregate 440 == KIS 440 → no block."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe(INSTANCE, {"pf-A": SYM_A, "pf-B": SYM_A})
for pid, qty in [("pf-A", 200.0), ("pf-B", 240.0)]:
base = repo.find_slot_by_portfolio(INSTANCE, pid)
p = svc.transit_slot(base.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=SYM_A)
svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=qty)
# simulate reconcile_positions with broker=440 → aggregate 440 == 440 → no warning set
snapshot_items = [
PortfolioItem(portfolio_id="pf-A", symbol=SYM_A, cash_balance=0, asset_quantity=200.0),
PortfolioItem(portfolio_id="pf-B", symbol=SYM_A, cash_balance=0, asset_quantity=240.0),
]
broker = FakeBroker([BalanceItem(symbol=SYM_A, hldg_qty=440)])
store = SnapshotStore()
store.swap(snapshot_items)
reconcile_positions(store=store, broker=broker, slot_repo=repo,
instance_key=INSTANCE, warn_store=warn_store)
# No warnings should be set
assert warn_store.list_c_case_warned() == set()
handler = _make_handler(repo, warn_store, snapshot_items)
resp = handler(_req(filter="holding"))
assert len(resp.payload["slots"]) == 2
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 0
# ---------------------------------------------------------------------------
# AC-3: multi-portfolio same symbol: aggregate mismatches → all excluded
# ---------------------------------------------------------------------------
def test_ac3_multi_portfolio_aggregate_mismatch_all_excluded():
"""KIS 440, hub A=200 + B=235 → aggregate 435 ≠ 440 → both slots excluded."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe(INSTANCE, {"pf-A": SYM_A, "pf-B": SYM_A})
for pid, qty in [("pf-A", 200.0), ("pf-B", 235.0)]:
base = repo.find_slot_by_portfolio(INSTANCE, pid)
p = svc.transit_slot(base.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=SYM_A)
svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=qty)
# Set C-case warning for both
snapshot_items = [
PortfolioItem(portfolio_id="pf-A", symbol=SYM_A, cash_balance=0, asset_quantity=200.0),
PortfolioItem(portfolio_id="pf-B", symbol=SYM_A, cash_balance=0, asset_quantity=235.0),
]
broker = FakeBroker([BalanceItem(symbol=SYM_A, hldg_qty=440)])
store = SnapshotStore()
store.swap(snapshot_items)
reconcile_positions(store=store, broker=broker, slot_repo=repo,
instance_key=INSTANCE, warn_store=warn_store)
# Both should be C-case warned
assert len(warn_store.list_c_case_warned()) == 2
handler = _make_handler(repo, warn_store, snapshot_items)
resp = handler(_req(filter="holding"))
assert resp.payload["slots"] == []
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 2
# ---------------------------------------------------------------------------
# AC-4: other-symbol slots not affected by C-case warning on a different symbol
# ---------------------------------------------------------------------------
def test_ac4_other_symbol_slots_unaffected():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe(INSTANCE, {"pf-A": SYM_A, "pf-B": SYM_B})
# pf-A: SYM_A, holding, C-case warned
base_a = repo.find_slot_by_portfolio(INSTANCE, "pf-A")
p = svc.transit_slot(base_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=SYM_A)
slot_a = svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=10.0)
warn_store.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="C")
# pf-B: SYM_B, holding, no warning
base_b = repo.find_slot_by_portfolio(INSTANCE, "pf-B")
p = svc.transit_slot(base_b.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=SYM_B)
svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=200.0, qty=5.0)
handler = _make_handler(repo, warn_store)
resp = handler(_req(filter="holding"))
portfolio_ids = {r["portfolio_id"] for r in resp.payload["slots"]}
assert "pf-A" not in portfolio_ids, "C-case warned slot should be excluded"
assert "pf-B" in portfolio_ids, "unwarned symbol slot should be included"
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1
# ---------------------------------------------------------------------------
# AC-5: excluded_slots present and count is correct
# ---------------------------------------------------------------------------
def test_ac5_excluded_slots_count_zero_when_no_warnings():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
handler = _make_handler(repo, warn_store)
resp = handler(_req(filter="holding"))
assert "excluded_slots" in resp.payload
assert resp.payload["excluded_slots"] == {"reconcile_quantity_mismatch": 0}
# ---------------------------------------------------------------------------
# AC-6: warn_store=None → no filtering, no excluded_slots key (backward compat)
# ---------------------------------------------------------------------------
def test_ac6_no_warn_store_no_filtering_no_excluded_slots_key():
repo = InMemorySlotRepository()
svc = SlotService(repo)
_make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
handler = make_slots_rpc_handler(repo, instance_key=INSTANCE) # no warn_store
resp = handler(_req(filter="holding"))
# No warn_store → no filtering (slot included), no excluded_slots key
assert len(resp.payload["slots"]) == 1
assert "excluded_slots" not in resp.payload
# ---------------------------------------------------------------------------
# AC-7: warn_store raises → fail-closed
# ---------------------------------------------------------------------------
class BrokenWarnStore(InMemoryReconcileWarningStore):
"""Simulates a warn_store that fails on list_c_case_warned."""
def list_c_case_warned(self):
raise RuntimeError("db connection lost")
def test_ac7_warn_store_failure_fail_closed():
repo = InMemorySlotRepository()
svc = SlotService(repo)
_make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
broken_warn_store = BrokenWarnStore()
handler = _make_handler(repo, broken_warn_store)
resp = handler(_req())
assert resp.payload["slots"] == []
assert resp.payload["degraded"] is True
assert resp.payload["reason"] == "warn_store_unavailable"
assert "excluded_slots" in resp.payload
def test_ac23_exclusion_precedes_broker_and_strategy_state_readers():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
class ExplodingReader:
def holdings_for_slots_rpc(self):
raise AssertionError("excluded slot reached broker reader")
class ExplodingStateStore:
def get_for_slots(self, _slot_ids):
raise AssertionError("excluded slot reached state reader")
handler = make_slots_rpc_handler(
repo,
instance_key=INSTANCE,
warn_store=warn_store,
broker_quantity_reader=ExplodingReader(),
strategy_state_store=ExplodingStateStore(),
)
response = handler(_req(filter="holding")).payload
assert response["slots"] == []
assert response["excluded_slots"] == {"reconcile_quantity_mismatch": 1}
# ---------------------------------------------------------------------------
# AC-8: intent rejected if C-case warning set after last slots RPC (race)
# ---------------------------------------------------------------------------
def test_ac8_intent_rejected_when_c_case_warning_set_after_slots_rpc():
"""Even if strategy saw the slot in a previous RPC, intent is rejected at processing time."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
intent_store = InMemoryIntentStore()
intent_svc = IntentService(
svc, repo, intent_store,
instance_key=INSTANCE,
warn_store=warn_store,
)
# Warning set after strategy "saw" the slot
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
sell = SellIntent(intent_id="sell-001", slot_id=slot.slot_id, reason="test")
d = intent_svc.submit_sell(sell)
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
# ---------------------------------------------------------------------------
# AC-9: in-flight (holding→pending_sell) transition not blocked
# ---------------------------------------------------------------------------
def test_ac9_in_flight_order_terminal_transition_not_blocked():
"""C-case warning does not block slot state convergence for in-flight orders."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
# Transition to PENDING_SELL (simulating in-flight cancel) — should not raise
pending_sell = svc.transit_slot(
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="test",
)
assert pending_sell.status == SlotState.PENDING_SELL
# Further transition to INACTIVE (fill/cancel reconciliation) — should not raise
terminal = svc.transit_slot(
pending_sell.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="filled_sell", actor="test",
)
assert terminal.status == SlotState.INACTIVE
# ---------------------------------------------------------------------------
# AC-10: verify_slot_reconcile uses symbol aggregate for multi-portfolio clear
# ---------------------------------------------------------------------------
def test_ac10_verify_uses_symbol_aggregate_for_match():
"""verify uses symbol-level hub aggregate (not single portfolio qty) for comparison."""
from uuid import uuid4
from trex_core.domain.slot.models import Slot
repo = InMemorySlotRepository()
ws = InMemoryReconcileWarningStore()
# Two portfolios for same symbol
slot_a = Slot(slot_id=uuid4(), status=SlotState.HOLDING, symbol=SYM_A,
qty=200.0, portfolio_id="pf-A", instance_key=INSTANCE)
slot_b = Slot(slot_id=uuid4(), status=SlotState.HOLDING, symbol=SYM_A,
qty=240.0, portfolio_id="pf-B", instance_key=INSTANCE)
repo.add_slot(slot_a)
repo.add_slot(slot_b)
# Both warned
ws.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="C")
ws.set_warned(str(slot_b.slot_id), portfolio_id="pf-B", case_code="C")
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol=SYM_A, cash_balance=0, asset_quantity=200.0),
PortfolioItem(portfolio_id="pf-B", symbol=SYM_A, cash_balance=0, asset_quantity=240.0),
])
broker = FakeBroker([BalanceItem(symbol=SYM_A, hldg_qty=440)]) # 200+240=440 == 440 ✓
result = verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=broker,
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
)
assert result["result"] == "cleared"
assert result["hub_qty"] == 440 # aggregate, not just pf-A's 200
# ---------------------------------------------------------------------------
# AC-11: verify clears ALL C-case warnings for the symbol
# ---------------------------------------------------------------------------
def test_ac11_verify_clears_all_symbol_c_case_warnings():
from uuid import uuid4
from trex_core.domain.slot.models import Slot
repo = InMemorySlotRepository()
ws = InMemoryReconcileWarningStore()
slot_a = Slot(slot_id=uuid4(), status=SlotState.HOLDING, symbol=SYM_A,
qty=200.0, portfolio_id="pf-A", instance_key=INSTANCE)
slot_b = Slot(slot_id=uuid4(), status=SlotState.HOLDING, symbol=SYM_A,
qty=240.0, portfolio_id="pf-B", instance_key=INSTANCE)
repo.add_slot(slot_a)
repo.add_slot(slot_b)
ws.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="C")
ws.set_warned(str(slot_b.slot_id), portfolio_id="pf-B", case_code="C")
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol=SYM_A, cash_balance=0, asset_quantity=200.0),
PortfolioItem(portfolio_id="pf-B", symbol=SYM_A, cash_balance=0, asset_quantity=240.0),
])
broker = FakeBroker([BalanceItem(symbol=SYM_A, hldg_qty=440)])
verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=broker,
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
)
# Both pf-A and pf-B warnings cleared
assert not ws.is_warned(str(slot_a.slot_id))
assert not ws.is_warned(str(slot_b.slot_id))
def test_ac11_verify_mismatch_keeps_all_warnings():
"""When verify still mismatches, all warnings are kept."""
from uuid import uuid4
from trex_core.domain.slot.models import Slot
repo = InMemorySlotRepository()
ws = InMemoryReconcileWarningStore()
slot_a = Slot(slot_id=uuid4(), status=SlotState.HOLDING, symbol=SYM_A,
qty=200.0, portfolio_id="pf-A", instance_key=INSTANCE)
slot_b = Slot(slot_id=uuid4(), status=SlotState.HOLDING, symbol=SYM_A,
qty=235.0, portfolio_id="pf-B", instance_key=INSTANCE)
repo.add_slot(slot_a)
repo.add_slot(slot_b)
ws.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="C")
ws.set_warned(str(slot_b.slot_id), portfolio_id="pf-B", case_code="C")
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol=SYM_A, cash_balance=0, asset_quantity=200.0),
PortfolioItem(portfolio_id="pf-B", symbol=SYM_A, cash_balance=0, asset_quantity=235.0),
])
broker = FakeBroker([BalanceItem(symbol=SYM_A, hldg_qty=440)]) # 435 != 440
result = verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=broker,
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
)
assert result["result"] == "rejected"
# Both warnings still present
assert ws.is_warned(str(slot_a.slot_id))
assert ws.is_warned(str(slot_b.slot_id))
# ---------------------------------------------------------------------------
# AC-12: C-case warning NOT auto-cleared by time; only cleared on quantity match
# ---------------------------------------------------------------------------
def test_ac12_c_case_warning_not_auto_cleared():
"""C-case warning persists across multiple slots RPC calls without manual verify."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
handler = _make_handler(repo, warn_store)
# Call slots RPC multiple times — slot stays excluded (no auto-clear)
for _ in range(3):
resp = handler(_req(filter="holding"))
assert resp.payload["slots"] == []
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1
# Warning still present
assert warn_store.is_warned(str(slot.slot_id))
# ---------------------------------------------------------------------------
# Non-invasive: slots RPC does not mutate broker, ledger, or slot state
# ---------------------------------------------------------------------------
def test_non_invasive_slots_rpc_no_side_effects():
"""Slots RPC with C-case exclusion must not change slot status or broker state."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
handler = _make_handler(repo, warn_store)
handler(_req())
# Slot still in HOLDING state (no transition)
refreshed = repo.fetch_slot(slot.slot_id)
assert refreshed.status == SlotState.HOLDING
assert float(refreshed.qty) == 10.0
# Warning still present (not cleared by RPC)
assert warn_store.is_warned(str(slot.slot_id))
@@ -67,6 +67,8 @@ def _req(filter=None, spy=False, strategy=None):
payload["filter"] = filter
if spy:
payload["spy"] = True
else:
payload["strategy_instance_id"] = "test-instance"
if strategy is not None:
payload["strategy"] = strategy
return RpcRequest(payload=payload, correlation_id="test-corr")
@@ -1,93 +1,71 @@
"""slots.req read piggyback of strategy_state + occupancy_seq (dcy-028 C-1).
REQ-073 AC-7 / REQ-074 AC-6 / S-1: each slot row carries occupancy_seq and,
when present, a strategy_state map assembled from the store in one batched read.
strategy_* reasoning fields remain absent.
"""
"""Strategy and operator strategy-state visibility contracts for slots.req."""
from __future__ import annotations
import pytest
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_slots_rpc_handler
from trex_core.application.strategy_state import InMemoryStrategyStateStore
from trex_core.application.watchdog import WatchdogStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
INST = "default"
def _wire():
repo = InMemorySlotRepository()
store = InMemoryStrategyStateStore()
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
repo.reconcile_universe(INST, {"pf-0": "SYM"})
slot = repo.list_slots(INST)[0]
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10, avg_price=100)
handler = make_slots_rpc_handler(repo, instance_key=INST,
strategy_state_store=store)
return repo, store, service, slot, handler
repo, store, watchdog = InMemorySlotRepository(), InMemoryStrategyStateStore(), WatchdogStore()
service = SlotService(repo)
repo.reconcile_universe("default", {"pf": "SYM"})
slot = repo.list_slots("default")[0]
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
store.set(slot.slot_id, "instance-a", expected_version=0, data="a", slot_active=True)
store.set(slot.slot_id, "instance-b", expected_version=0, data="b", slot_active=True)
return slot, store, watchdog, make_slots_rpc_handler(repo, strategy_state_store=store, watchdog_store=watchdog)
def _rows(handler):
return handler(RpcRequest(payload={}, correlation_id="c")).payload["slots"]
def _call(handler, payload):
return handler(RpcRequest(payload=payload, correlation_id="c")).payload
def test_row_carries_occupancy_seq():
_, _, _, slot, handler = _wire()
row = _rows(handler)[0]
assert row["occupancy_seq"] == 1
def test_strategy_receives_only_exact_requested_entry():
_, _, _, handler = _wire()
payload = _call(handler, {"strategy_instance_id": "instance-a"})
state = payload["slots"][0]["strategy_state"]
assert state["data"] == "a" and set(state) == {"version", "data", "updated_at"}
assert "instance-b" not in repr(payload)
def test_row_carries_strategy_state_when_present():
_, store, _, slot, handler = _wire()
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
data='{"peak":100}', current_occupancy_seq=1, slot_active=True)
row = _rows(handler)[0]
assert "strategy_state" in row
assert row["strategy_state"]["tsl_v1"]["version"] == 1
assert row["strategy_state"]["tsl_v1"]["data"] == '{"peak":100}'
assert "updated_at" in row["strategy_state"]["tsl_v1"]
def test_row_omits_strategy_state_when_absent():
_, _, _, _, handler = _wire()
row = _rows(handler)[0]
def test_strategy_omits_absent_entry():
_, _, _, handler = _wire()
row = _call(handler, {"strategy_instance_id": "absent"})["slots"][0]
assert "strategy_state" not in row
def test_no_strategy_reasoning_fields():
"""REQ-074 AC-5 (REQ-116 additive 재도입 후에도 유지): slot-origin strategy fields absent.
strategy_params / strategy_name / markets are now present (REQ-116 PortfolioItem-origin);
slot-origin strategy_id / sell_strategy_id / active_strategy_id remain absent.
"""
_, store, _, slot, handler = _wire()
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
data="x", current_occupancy_seq=1, slot_active=True)
row = _rows(handler)[0]
# slot-origin fields must be absent (REQ-074 AC-5 unchanged)
for k in ("strategy_id", "sell_strategy_id", "active_strategy_id"):
assert k not in row
# PortfolioItem-origin fields now present (REQ-116 additive)
assert "strategy_params" in row # {} (safe default, no snapshot_store)
assert "strategy_name" in row # None
assert "markets" in row # []
@pytest.mark.parametrize("identity", [None, "", False, 1, []])
def test_malformed_strategy_request_has_exact_safe_envelope(identity):
_, _, watchdog, handler = _wire()
assert _call(handler, {"strategy_instance_id": identity}) == {
"slots": [], "available_slots": None, "degraded": False,
"reason": "bad_request:strategy_instance_id",
"message": "strategy_instance_id must be a non-empty string",
}
assert watchdog.last_seen("unused") is None
def test_reused_slot_shows_no_state():
"""After a full close + reuse cycle, no prior-occupancy state surfaces
(close-delete + read-side occupancy filter). occupancy_seq advances to 2."""
repo, store, service, slot, handler = _wire()
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
data="old", current_occupancy_seq=1, slot_active=True)
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="sold", actor="t")
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t") # occupancy_seq -> 2
row = _rows(handler)[0]
assert row["occupancy_seq"] == 2
assert "strategy_state" not in row
def test_operator_without_or_with_id_receives_full_map_without_watchdog_update():
slot, _, watchdog, handler = _wire()
for payload in ({"spy": True}, {"spy": True, "strategy_instance_id": "instance-a"}):
state = _call(handler, payload)["slots"][0]["strategy_state"]
assert set(state) == {"instance-a", "instance-b"}
assert watchdog.last_seen(str(slot.slot_id)) is None
@pytest.mark.parametrize("spy", ["false", "true", 1, [], {}])
def test_non_boolean_spy_is_rejected_without_visibility_or_watchdog_update(spy):
slot, _, watchdog, handler = _wire()
payload = _call(handler, {"spy": spy})
assert payload == {
"slots": [], "available_slots": None, "degraded": False,
"reason": "bad_request:spy", "message": "spy must be a boolean",
}
assert watchdog.last_seen(str(slot.slot_id)) is None
@@ -1,10 +1,8 @@
"""strategy_state.set write RPC handler tests (dcy-028 / REQ-080, BAT-042).
Covers the full reject reason set + success + bad_request, with the handler
supplying the slot's current occupancy_seq / active-ness to the store CAS.
"""
"""strategy_state.set strategy-instance wire contract tests."""
from __future__ import annotations
import pytest
from trex_core.application import strategy_state as ss
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_strategy_state_set_handler
@@ -12,109 +10,41 @@ from trex_core.application.strategy_state import InMemoryStrategyStateStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
INST = "default"
def _wire():
repo = InMemorySlotRepository()
store = InMemoryStrategyStateStore()
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
repo.reconcile_universe(INST, {"pf-0": "SYM"})
slot = repo.list_slots(INST)[0]
# activate to holding (occupancy_seq -> 1)
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10, avg_price=100)
handler = make_strategy_state_set_handler(repo, store)
return repo, store, service, slot, handler
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
service = SlotService(repo)
repo.reconcile_universe("default", {"pf": "SYM"})
slot = repo.list_slots("default")[0]
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
return repo, store, service, slot, make_strategy_state_set_handler(repo, store)
def _req(slot_id, *, ev=0, eo=1, data="peak", strat="tsl_v1"):
return RpcRequest(payload={
"slot_id": str(slot_id), "strategy_id": strat,
"expected_version": ev, "expected_occupancy_seq": eo, "data": data,
}, correlation_id=f"{slot_id}:{strat}:{ev}")
def _req(slot_id, instance="instance-a", version=0, data="state"):
return RpcRequest(payload={"slot_id": str(slot_id), "strategy_instance_id": instance, "expected_version": version, "data": data}, correlation_id="same-correlation-id")
def test_first_write_ok():
def test_write_uses_instance_identity_and_entry_cas():
_, _, _, slot, handler = _wire()
resp = handler(_req(slot.slot_id, ev=0))
assert resp.payload["ok"] is True and resp.payload["version"] == 1
assert handler(_req(slot.slot_id)).payload == {"ok": True, "slot_id": str(slot.slot_id), "strategy_instance_id": "instance-a", "version": 1}
conflict = handler(_req(slot.slot_id, version=0, data="stale")).payload
assert conflict["reason"] == ss.REASON_VERSION_CONFLICT and conflict["version"] == 1
def test_version_conflict():
_, _, _, slot, handler = _wire()
handler(_req(slot.slot_id, ev=0)) # version -> 1
resp = handler(_req(slot.slot_id, ev=0, data="stale"))
assert resp.payload["ok"] is False
assert resp.payload["reason"] == ss.REASON_VERSION_CONFLICT
assert resp.payload["version"] == 1
@pytest.mark.parametrize("identity", [None, "", 1, True, []])
def test_malformed_instance_id_is_rejected_without_write(identity):
_, store, _, slot, handler = _wire()
response = handler(_req(slot.slot_id, identity)).payload
assert response["ok"] is False
assert response["reason"].startswith("bad_request:")
assert store.get_for_slots([slot.slot_id]) == {}
def test_occupancy_changed():
_, _, _, slot, handler = _wire()
# daemon read occupancy 0 (before activation) but slot is occupancy 1 now
resp = handler(_req(slot.slot_id, ev=0, eo=0))
assert resp.payload["ok"] is False
assert resp.payload["reason"] == ss.REASON_OCCUPANCY_CHANGED
assert resp.payload["occupancy_seq"] == 1
def test_slot_inactive():
def test_inactive_and_oversize_writes_do_not_change_existing_entry():
repo, store, service, slot, handler = _wire()
# close the slot
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="sold", actor="t")
resp = handler(_req(slot.slot_id, ev=0, eo=1))
assert resp.payload["ok"] is False
assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE
def test_unknown_slot_is_slot_inactive():
from uuid import uuid4
_, _, _, _, handler = _wire()
resp = handler(_req(uuid4(), ev=0, eo=1))
assert resp.payload["ok"] is False
assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE
def test_state_too_large():
_, _, _, slot, handler = _wire()
big = "a" * (ss._STATE_CAP_BYTES + 1)
resp = handler(_req(slot.slot_id, ev=0, data=big))
assert resp.payload["ok"] is False
assert resp.payload["reason"] == ss.REASON_STATE_TOO_LARGE
def test_bad_request_missing_field():
_, _, _, slot, handler = _wire()
resp = handler(RpcRequest(payload={"slot_id": str(slot.slot_id)},
correlation_id="c"))
assert resp.payload["ok"] is False
assert resp.payload["reason"].startswith("bad_request:")
def test_bad_request_non_string_data():
_, _, _, slot, handler = _wire()
resp = handler(RpcRequest(payload={
"slot_id": str(slot.slot_id), "strategy_id": "tsl_v1",
"expected_version": 0, "expected_occupancy_seq": 1, "data": 123,
}, correlation_id="c"))
assert resp.payload["ok"] is False
assert resp.payload["reason"].startswith("bad_request:")
def test_reason_set_is_enumerated():
"""All non-ok reasons must be within the documented set (REQ-080 AC-6)."""
allowed_prefixes = {
ss.REASON_OCCUPANCY_CHANGED, ss.REASON_VERSION_CONFLICT,
ss.REASON_SLOT_INACTIVE, ss.REASON_STATE_TOO_LARGE, "bad_request",
}
_, _, _, slot, handler = _wire()
handler(_req(slot.slot_id, ev=0))
bad = handler(_req(slot.slot_id, ev=0, data="x")) # version_conflict
reason = bad.payload["reason"].split(":")[0]
assert reason in allowed_prefixes
handler(_req(slot.slot_id))
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, reason="sell", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, reason="sold", actor="t")
assert handler(_req(slot.slot_id, version=1)).payload["reason"] == ss.REASON_SLOT_INACTIVE
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 1
+35 -100
View File
@@ -1,118 +1,53 @@
"""InMemoryStrategyStateStore CAS tests (dcy-028 / REQ-079, BAT-042).
Covers the 6 CAS outcomes (occupancy+version commit / version_conflict /
occupancy_changed / slot_inactive / state_too_large / first-create), idempotent
re-send, batched read with stale-occupancy filtering, and delete-on-close.
"""
"""Slot-scoped strategy-instance state store contract tests."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
from trex_core.application import strategy_state as ss
from trex_core.application.strategy_state import InMemoryStrategyStateStore
@pytest.fixture
def store():
return InMemoryStrategyStateStore()
def _set(store, slot_id, *, ev=0, eo=1, data="x", cur_occ=1, active=True, strat="tsl_v1"):
def _set(store, slot_id, instance="instance-a", *, version=0, data="x", active=True):
return store.set(
slot_id, strat,
expected_version=ev, expected_occupancy_seq=eo, data=data,
current_occupancy_seq=cur_occ, slot_active=active,
slot_id, instance, expected_version=version, data=data, slot_active=active
)
class TestCas:
def test_first_create(self, store):
sid = uuid4()
r = _set(store, sid, ev=0, eo=1, cur_occ=1, data='{"peak":100}')
assert r.ok and r.version == 1
def test_commit_increments_version(self, store):
sid = uuid4()
_set(store, sid, ev=0)
r = _set(store, sid, ev=1, data="next")
assert r.ok and r.version == 2
def test_version_conflict_does_not_store(self, store):
sid = uuid4()
_set(store, sid, ev=0, data="first") # version=1
r = _set(store, sid, ev=0, data="stale") # expects 0, stored 1
assert not r.ok and r.reason == ss.REASON_VERSION_CONFLICT and r.version == 1
# original preserved
got = store.get_for_slots([sid], {sid: 1})[sid]["tsl_v1"]
assert got.data == "first" and got.version == 1
def test_occupancy_changed_does_not_store(self, store):
sid = uuid4()
# daemon read occupancy_seq=1 but slot is now occupancy 2
r = _set(store, sid, ev=0, eo=1, cur_occ=2)
assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED and r.occupancy_seq == 2
assert store.get_for_slots([sid], {sid: 2}) == {}
def test_slot_inactive_does_not_store(self, store):
sid = uuid4()
r = _set(store, sid, ev=0, active=False)
assert not r.ok and r.reason == ss.REASON_SLOT_INACTIVE
assert store.get_for_slots([sid], {sid: 1}) == {}
def test_state_too_large(self, store, monkeypatch):
sid = uuid4()
big = "a" * (ss._STATE_CAP_BYTES + 1)
r = _set(store, sid, ev=0, data=big)
assert not r.ok and r.reason == ss.REASON_STATE_TOO_LARGE
def test_idempotent_resend_is_conflict_not_double_apply(self, store):
sid = uuid4()
r1 = _set(store, sid, ev=0, data="v1") # -> version 1
assert r1.ok and r1.version == 1
# re-send identical expected_version=0 -> rejected (already advanced)
r2 = _set(store, sid, ev=0, data="v1")
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
def test_entry_versions_are_isolated():
store, slot_id = InMemoryStrategyStateStore(), uuid4()
assert _set(store, slot_id, "a", data="a1").version == 1
assert _set(store, slot_id, "b", data="b1").version == 1
assert _set(store, slot_id, "a", version=1, data="a2").version == 2
entries = store.get_for_slots([slot_id])[slot_id]
assert entries["a"].version == 2 and entries["a"].data == "a2"
assert entries["b"].version == 1 and entries["b"].data == "b1"
assert not hasattr(entries["a"], "strategy_id")
assert not hasattr(entries["a"], "occupancy_seq")
class TestAbaGuard:
def test_turnover_first_write_rejected(self, store):
"""dcy-028 R-1 / S-4: read v0 of occupancy 1, slot reused as occupancy 2,
stale write against occupancy 1 must not bind to occupancy 2."""
sid = uuid4()
# occupancy 2 is current; daemon computed against occupancy 1 (eo=1)
r = _set(store, sid, ev=0, eo=1, cur_occ=2, data="stale-peak")
assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED
# new occupancy starts clean
assert store.get_for_slots([sid], {sid: 2}) == {}
def test_conflict_inactive_and_size_limit_preserve_entries(monkeypatch):
store, slot_id = InMemoryStrategyStateStore(), uuid4()
_set(store, slot_id, "a", data="saved")
assert _set(store, slot_id, "a", data="stale").reason == ss.REASON_VERSION_CONFLICT
assert _set(store, slot_id, "b", active=False).reason == ss.REASON_SLOT_INACTIVE
assert _set(store, slot_id, "b", data="x" * (ss._STATE_CAP_BYTES + 1)).reason == ss.REASON_STATE_TOO_LARGE
entries = store.get_for_slots([slot_id])[slot_id]
assert set(entries) == {"a"} and entries["a"].data == "saved"
class TestBatchedRead:
def test_get_for_slots_groups_by_slot_and_strategy(self, store):
a, b = uuid4(), uuid4()
_set(store, a, ev=0, cur_occ=1, strat="tsl_v1", data="A")
_set(store, a, ev=0, cur_occ=1, strat="buy_v1", data="A2")
_set(store, b, ev=0, cur_occ=1, strat="tsl_v1", data="B")
out = store.get_for_slots([a, b], {a: 1, b: 1})
assert set(out[a]) == {"tsl_v1", "buy_v1"}
assert out[a]["tsl_v1"].data == "A"
assert out[b]["tsl_v1"].data == "B"
def test_stale_occupancy_entries_invisible(self, store):
sid = uuid4()
_set(store, sid, ev=0, cur_occ=1, data="old") # stored at occupancy 1
# slot is now occupancy 2 -> the occupancy-1 entry must not surface
assert store.get_for_slots([sid], {sid: 2}) == {}
def test_empty_slot_ids(self, store):
assert store.get_for_slots([], {}) == {}
def test_concurrent_different_entries_both_survive():
store, slot_id = InMemoryStrategyStateStore(), uuid4()
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda key: _set(store, slot_id, key, data=key), ("a", "b")))
assert all(result.ok for result in results)
assert set(store.get_for_slots([slot_id])[slot_id]) == {"a", "b"}
class TestDelete:
def test_delete_all_removes_every_strategy(self, store):
sid = uuid4()
_set(store, sid, ev=0, strat="tsl_v1")
_set(store, sid, ev=0, strat="buy_v1")
store.delete_all(sid)
assert store.get_for_slots([sid], {sid: 1}) == {}
def test_same_entry_competing_cas_has_one_winner():
store, slot_id = InMemoryStrategyStateStore(), uuid4()
_set(store, slot_id, "a", data="first")
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda data: _set(store, slot_id, "a", version=1, data=data), ("x", "y")))
assert sum(result.ok for result in results) == 1
assert store.get_for_slots([slot_id])[slot_id]["a"].version == 2
+18 -9
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from uuid import UUID
import pytest
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import (
BalanceItem,
@@ -31,7 +33,7 @@ class RecordingBroker:
return OrderAck(
accepted=True,
broker_order_id=f"order-{len(self.orders)}",
venue=self.actual_venue or req.venue,
venue=req.venue,
)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
@@ -96,7 +98,8 @@ def test_nxt_buy_wire_value_reaches_broker_order() -> None:
assert broker.orders[0].venue == "NXT"
def test_invalid_venue_is_rejected_before_slot_transition() -> None:
@pytest.mark.parametrize("invalid_venue", ["AUTO", "UNKNOWN"])
def test_invalid_venue_is_rejected_before_slot_transition(invalid_venue: str) -> None:
repo, intents, _, _ = _wire()
handler = make_intent_rpc_handler(intents)
@@ -107,7 +110,7 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
"intent_id": "bad-venue",
"portfolio_id": "PF-1",
"qty": 1,
"venue": "UNKNOWN",
"venue": invalid_venue,
},
correlation_id="bad-venue",
)
@@ -118,7 +121,10 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
assert repo.list_slots("default")[0].status is SlotState.INACTIVE
def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
@pytest.mark.parametrize("venue_field", [{}, {"venue": None}], ids=["omitted", "null"])
def test_missing_wire_venue_remains_sor_for_cancel_and_restart(
venue_field: dict[str, object],
) -> None:
repo = InMemorySlotRepository()
service = SlotService(repo)
intents = IntentService(
@@ -139,6 +145,7 @@ def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
"intent_id": "auto-buy",
"portfolio_id": "PF-1",
"qty": 1,
**venue_field,
},
correlation_id="auto-buy",
)
@@ -146,11 +153,13 @@ def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
slot_id = UUID(response.payload["slot_id"])
pending = repo.fetch_slot(slot_id)
assert pending is not None and pending.venue == "AUTO"
assert pending is not None and pending.venue == "SOR"
execution.run_once()
dispatched = repo.fetch_slot(slot_id)
assert broker.orders[0].venue == "AUTO"
assert dispatched is not None and dispatched.venue == "NXT"
assert broker.orders[0].venue == "SOR"
# Ack cannot turn SOR routing provenance into an execution venue. Actual
# KRX/NXT is established by fill polling and stored on the ledger trade.
assert dispatched is not None and dispatched.venue == "SOR"
def test_inactive_transition_resets_venue_to_krx() -> None:
@@ -180,7 +189,7 @@ def test_inactive_transition_resets_venue_to_krx() -> None:
assert reset.venue == "KRX"
def test_holding_transition_resets_next_order_venue_to_auto() -> None:
def test_holding_transition_resets_next_order_venue_to_sor() -> None:
repo, intents, _, _ = _wire()
decision = intents.submit_buy(
BuyIntent(
@@ -201,7 +210,7 @@ def test_holding_transition_resets_next_order_venue_to_auto() -> None:
qty=1,
avg_price=1000,
)
assert holding.venue == "AUTO"
assert holding.venue == "SOR"
def test_operator_cancel_uses_original_nxt_venue() -> None:
+137
View File
@@ -265,3 +265,140 @@ def test_verify_empty_snapshot_treats_hub_qty_as_zero():
assert result["result"] == "rejected"
assert result["hub_qty"] == 0
assert result["broker_qty"] == 10
# ---------------------------------------------------------------------------
# AC-0023 comment 3853379970: B-case warning preserved; non-holding C-case cleared
# ---------------------------------------------------------------------------
def test_verify_c_case_clear_preserves_b_case_warning():
"""B-case warning for same symbol must NOT be cleared by a C-case verify clear."""
sym = "005930"
# Slot A: HOLDING, C-case warning (should be cleared)
slot_c = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-c")
# Slot B: HOLDING, B-case warning (must be preserved)
slot_b = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-b")
repo = InMemorySlotRepository()
repo.add_slot(slot_c)
repo.add_slot(slot_b)
ws = InMemoryReconcileWarningStore()
ws.set_warned(str(slot_c.slot_id), portfolio_id="pf-c", case_code="C")
ws.set_warned(str(slot_b.slot_id), portfolio_id="pf-b", case_code="B")
# Aggregate hub qty across both portfolios = 10
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-c", symbol=sym, cash_balance=0.0, asset_quantity=5.0),
PortfolioItem(portfolio_id="pf-b", symbol=sym, cash_balance=0.0, asset_quantity=5.0),
])
broker = FakeBroker({sym: 10})
result = verify_slot_reconcile(
portfolio_id="pf-c",
store=store,
broker=broker,
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
)
assert result["result"] == "cleared"
# C-case slot cleared
assert not ws.is_warned(str(slot_c.slot_id))
# B-case slot untouched
assert ws.is_warned(str(slot_b.slot_id))
b_info = ws.get_warning_info(str(slot_b.slot_id))
assert b_info is not None and b_info["case_code"] == "B"
def test_verify_c_case_clear_non_holding_slot():
"""A non-HOLDING slot with C-case warning for the same symbol must also be cleared."""
sym = "005930"
slot_holding = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-hold")
# Simulate a slot that is INACTIVE (e.g. after sell filled) but still has a C-case warning
slot_inactive = _make_slot(status=SlotState.INACTIVE, symbol=sym, pid="pf-inact")
repo = InMemorySlotRepository()
repo.add_slot(slot_holding)
repo.add_slot(slot_inactive)
ws = InMemoryReconcileWarningStore()
ws.set_warned(str(slot_holding.slot_id), portfolio_id="pf-hold", case_code="C")
ws.set_warned(str(slot_inactive.slot_id), portfolio_id="pf-inact", case_code="C")
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-hold", symbol=sym, cash_balance=0.0, asset_quantity=10.0),
])
broker = FakeBroker({sym: 10})
result = verify_slot_reconcile(
portfolio_id="pf-hold",
store=store,
broker=broker,
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
)
assert result["result"] == "cleared"
assert not ws.is_warned(str(slot_holding.slot_id))
assert not ws.is_warned(str(slot_inactive.slot_id))
# ---------------------------------------------------------------------------
# AC-0023 comment 3853379979: partial clear failure → clear_failed, not cleared
# ---------------------------------------------------------------------------
class _FailAfterNStore(InMemoryReconcileWarningStore):
"""Raises on the Nth+ call to clear_warned (1-indexed)."""
def __init__(self, fail_after: int = 1) -> None:
super().__init__()
self._calls = 0
self._fail_after = fail_after
def clear_warned(self, slot_id: str) -> None:
self._calls += 1
if self._calls > self._fail_after:
raise RuntimeError("simulated DB error")
super().clear_warned(slot_id)
def test_verify_partial_clear_failure_returns_clear_failed():
"""If clear_warned raises mid-loop, result must be 'clear_failed', not 'cleared'."""
sym = "005930"
slot1 = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-1")
slot2 = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-2")
repo = InMemorySlotRepository()
repo.add_slot(slot1)
repo.add_slot(slot2)
# Fails on 2nd clear_warned call
ws = _FailAfterNStore(fail_after=1)
ws.set_warned(str(slot1.slot_id), portfolio_id="pf-1", case_code="C")
ws.set_warned(str(slot2.slot_id), portfolio_id="pf-2", case_code="C")
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-1", symbol=sym, cash_balance=0.0, asset_quantity=10.0),
PortfolioItem(portfolio_id="pf-2", symbol=sym, cash_balance=0.0, asset_quantity=0.0),
])
broker = FakeBroker({sym: 10})
result = verify_slot_reconcile(
portfolio_id="pf-1",
store=store,
broker=broker,
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
)
assert result["result"] == "clear_failed"
assert result["hub_qty"] == 10
assert result["broker_qty"] == 10
assert result["reason"] is not None and "warn_store" in result["reason"]
+3
View File
@@ -18,6 +18,7 @@ from trex_core.adapters.broker.kis import KISBroker
_PG_TABLES = (
"broker_account_snapshot",
"ledger_skip",
"killswitch_exclusion",
"snapshot_pause",
"ledger_outbox",
@@ -25,6 +26,8 @@ _PG_TABLES = (
"slot_transition_log",
"daily_symbol_entries",
"daily_slot",
"reconcile_notification_state",
"slot_strategy_state",
)
@@ -1,120 +1,23 @@
"""Slot occupancy_seq bump + close-delete hook (dcy-028 / REQ-081, BAT-042).
S-3: every close path (→INACTIVE) deletes the slot's strategy_state and a reused
slot starts with a new occupancy_seq + empty state. occupancy_seq is stable
across one holding episode and bumps on inactive→pending_buy.
"""
"""Slot occupancy remains intact while strategy state survives close/reopen."""
from __future__ import annotations
from trex_core.application.strategy_state import InMemoryStrategyStateStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
INST = "default"
def _seed(repo):
repo.reconcile_universe(INST, {"pf-0": "SYM"})
return repo.list_slots(INST)[0]
def _service(repo, store=None):
return SlotService(repo, instance_key=INST, strategy_state_store=store)
def _activate_to_holding(service, slot_id):
service.transit_slot(slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
return service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10, avg_price=100)
class TestOccupancySeq:
def test_bumps_on_inactive_to_pending_buy(self):
repo = InMemorySlotRepository()
service = _service(repo)
slot = _seed(repo)
assert slot.occupancy_seq == 0
updated = service.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
assert updated.occupancy_seq == 1
def test_stable_across_holding_episode(self):
repo = InMemorySlotRepository()
service = _service(repo)
slot = _seed(repo)
held = _activate_to_holding(service, slot.slot_id)
assert held.occupancy_seq == 1
sold = service.transit_slot(slot.slot_id, SlotState.HOLDING,
SlotState.PENDING_SELL, reason="sell", actor="t")
assert sold.occupancy_seq == 1 # unchanged within the episode
def test_increments_on_reuse(self):
repo = InMemorySlotRepository()
service = _service(repo)
slot = _seed(repo)
_activate_to_holding(service, slot.slot_id)
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="sold", actor="t")
# reuse: occupancy_seq bumps from 1 to 2
reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE,
SlotState.PENDING_BUY, reason="buy", actor="t")
assert reused.occupancy_seq == 2
class TestCloseDelete:
def _setup(self):
repo = InMemorySlotRepository()
store = InMemoryStrategyStateStore()
service = _service(repo, store)
slot = _seed(repo)
_activate_to_holding(service, slot.slot_id)
# daemon writes state during holding (occupancy_seq=1)
store.set(slot.slot_id, "tsl_v1", expected_version=0,
expected_occupancy_seq=1, data="peak", current_occupancy_seq=1,
slot_active=True)
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1})
return repo, store, service, slot
def test_sell_fill_close_deletes_state(self):
repo, store, service, slot = self._setup()
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="sold", actor="t")
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
def test_reused_slot_starts_empty_with_new_occupancy(self):
repo, store, service, slot = self._setup()
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="sold", actor="t")
reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE,
SlotState.PENDING_BUY, reason="buy", actor="t")
assert reused.occupancy_seq == 2
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 2}) == {}
def test_error_close_path_also_deletes(self):
repo, store, service, slot = self._setup()
# holding → error → inactive (operator resolve) also clears state
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.ERROR,
reason="err", actor="t")
service.transit_slot(slot.slot_id, SlotState.ERROR, SlotState.INACTIVE,
reason="resolve", actor="t")
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
def test_no_store_injected_is_noop(self):
# backward-compat: service without store does not raise on close
repo = InMemorySlotRepository()
service = _service(repo, store=None)
slot = _seed(repo)
_activate_to_holding(service, slot.slot_id)
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell", actor="t")
out = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL,
SlotState.INACTIVE, reason="sold", actor="t")
assert out.status is SlotState.INACTIVE
def test_close_preserves_state_and_reopen_continues_version():
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
service = SlotService(repo)
repo.reconcile_universe("default", {"pf": "SYM"})
slot = repo.list_slots("default")[0]
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, reason="fill", actor="t", qty=1, avg_price=1)
assert store.set(slot.slot_id, "instance", expected_version=0, data="old", slot_active=True).version == 1
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL, reason="sell", actor="t")
inactive = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, reason="sold", actor="t")
assert inactive.occupancy_seq == 1
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance"].data == "old"
reopened = service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY, reason="buy", actor="t")
assert reopened.occupancy_seq == 2
assert store.set(slot.slot_id, "instance", expected_version=1, data="new", slot_active=True).version == 2
@@ -144,7 +144,13 @@ def _wire(broker: KISBroker):
instance_key=INSTANCE,
)
repo.reconcile_universe(INSTANCE, {PORTFOLIO: SYMBOL})
execution = ExecutionService(slots, repo, broker, instance_key=INSTANCE)
execution = ExecutionService(
slots,
repo,
broker,
instance_key=INSTANCE,
clock=lambda: VIRTUAL_AT,
)
decision = intents.submit_buy(
BuyIntent(
intent_id="virtual-buy-1",
@@ -170,7 +176,13 @@ def test_timeout_after_exchange_accept_resolves_without_duplicate_and_survives_r
assert len(state.orders) == 1
state.fill_orders = True
restarted = ExecutionService(slots, repo, _broker(server), instance_key=INSTANCE)
restarted = ExecutionService(
slots,
repo,
_broker(server),
instance_key=INSTANCE,
clock=lambda: VIRTUAL_AT,
)
restarted.reconcile()
holding = repo.fetch_slot(slot_id)
-166
View File
@@ -1,166 +0,0 @@
from __future__ import annotations
from pathlib import Path
import yaml
REPO = Path(__file__).resolve().parents[2]
WORKFLOW_PATH = REPO / ".github" / "workflows" / "publish-image.yml"
def _workflow():
return yaml.load(WORKFLOW_PATH.read_text(), Loader=yaml.BaseLoader)
def _step_index(steps, name):
return next(index for index, step in enumerate(steps) if step.get("name") == name)
def test_workflow_triggers_only_main_and_v_tags():
workflow = _workflow()
assert set(workflow["on"]) == {"push"}
assert workflow["on"]["push"] == {"branches": ["main"], "tags": ["v*"]}
def test_publish_is_gated_and_has_the_only_package_write_permission():
workflow = _workflow()
verify = workflow["jobs"]["verify"]
publish = workflow["jobs"]["publish"]
assert publish["needs"] == "verify"
assert publish["permissions"]["packages"] == "write"
assert "packages" not in workflow["permissions"]
assert "packages" not in verify["permissions"]
def test_verify_canary_build_smoke_export_scan_checksum_upload_contract():
steps = _workflow()["jobs"]["verify"]["steps"]
names = [step["name"] for step in steps]
assert names.count("Create excluded synthetic canary") == 1
assert names.count("Build source image once") == 1
assert names.count("Scan all canary surfaces") == 1
canary = steps[_step_index(steps, "Create excluded synthetic canary")]["run"]
assert 'Path(".env.ci-canary")' in canary
assert "TREX_SECRET_CANARY_DO_NOT_SHIP" in canary
scan = steps[_step_index(steps, "Scan all canary surfaces")]["run"]
assert "image_release.py scan" in scan
status_contract = '''>canary-scan.stdout 2>canary-scan.stderr
scan_status=$?
set -e
python scripts/image_release.py assert-clean-output \\
canary-scan.stdout canary-scan.stderr
exit "${scan_status}"'''
assert status_contract in scan
assert scan.count("scan_status=$?") == 1
assert scan.rstrip().endswith('exit "${scan_status}"')
assert _step_index(steps, "Run repository lint") < _step_index(steps, "Build source image once")
assert _step_index(steps, "Run repository tests") < _step_index(steps, "Build source image once")
ordered = [
"Create excluded synthetic canary",
"Build source image once",
"Smoke built image",
"Export tested image",
"Scan all canary surfaces",
"Record tested archive checksum",
"Upload immutable tested image artifact",
]
assert [_step_index(steps, name) for name in ordered] == sorted(
_step_index(steps, name) for name in ordered
)
def test_repository_checks_use_the_locked_uv_environment():
steps = _workflow()["jobs"]["verify"]["steps"]
install = steps[_step_index(steps, "Install repository checks")]["run"]
lint = steps[_step_index(steps, "Run repository lint")]["run"]
tests = steps[_step_index(steps, "Run repository tests")]["run"]
assert 'python -m pip install "uv==0.11.14"' in install
assert "uv sync --extra dev --locked" in install
assert lint == "uv run ruff check ."
assert tests == "uv run pytest"
assert 'pip install ".[dev]"' not in install
def test_tested_image_id_is_recorded_after_smoke_and_transferred_to_publish():
workflow = _workflow()
verify_steps = workflow["jobs"]["verify"]["steps"]
publish_steps = workflow["jobs"]["publish"]["steps"]
smoke_index = _step_index(verify_steps, "Smoke built image")
identity_index = _step_index(verify_steps, "Record tested image ID")
export_index = _step_index(verify_steps, "Export tested image")
assert smoke_index < identity_index < export_index
identity = verify_steps[identity_index]["run"]
assert "image_release.py record-image-id" in identity
assert "--output trex-core-image.id" in identity
upload = verify_steps[_step_index(verify_steps, "Upload immutable tested image artifact")]["with"]
assert "trex-core-image.id" in upload["path"].splitlines()
publish = publish_steps[_step_index(publish_steps, "Verify, load, and publish without rebuilding")][
"run"
]
assert "--image-id tested-image/trex-core-image.id" in publish
def test_publish_job_has_only_download_then_verified_publish_helper():
steps = _workflow()["jobs"]["publish"]["steps"]
assert len(steps) == 2
assert "run" not in steps[0]
assert steps[0]["uses"].startswith("actions/download-artifact@")
run_steps = [step for step in steps if "run" in step]
assert run_steps == [steps[1]]
expected_run = '''python tested-image/scripts/image_release.py publish \\
--archive tested-image/trex-core-image.tar \\
--checksum tested-image/trex-core-image.tar.sha256 \\
--image-id tested-image/trex-core-image.id \\
--local-image "trex-core-ci:${GITHUB_SHA}" \\
--commit "${GITHUB_SHA}" \\
--ref "${GITHUB_REF}"'''
assert steps[1]["run"].strip() == expected_run
rendered = "\n".join(str(step) for step in steps).lower()
for forbidden in (
"actions/checkout",
"docker login",
"docker push",
"docker build",
"buildx",
):
assert forbidden not in rendered
def test_workflow_uses_full_sha_and_contains_no_latest_tag():
text = WORKFLOW_PATH.read_text()
assert "${GITHUB_SHA}" in text
assert "github.sha" in text
assert "--provenance=false" in text
assert ":latest" not in text.lower()
def test_docker_context_contract():
dockerfile = (REPO / "Dockerfile").read_text()
copy_lines = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("COPY ")]
assert copy_lines == ["COPY pyproject.toml ./", "COPY trex_core/ ./trex_core/"]
assert "org.opencontainers.image.revision" in dockerfile
assert "python:3.12" in dockerfile
ignored = set((REPO / ".dockerignore").read_text().splitlines())
assert {".git", ".env", ".env.*", "config/", ".proofline/"} <= ignored
def test_operator_documentation_preserves_rollback_boundaries():
operations = (REPO / "docs" / "operations.md").read_text()
assert "read-only volume" in operations
assert "runtime environment" in operations
assert "sha256:<64-hex>" in operations
assert "이전" in operations and "digest" in operations
assert "`trex-core-reset`" in operations
assert "자동 rollback 절차에 절대로 포함하지 않는다" in operations
-589
View File
@@ -1,589 +0,0 @@
from __future__ import annotations
import gzip
import io
import subprocess
import tarfile
from pathlib import Path
import pytest
from scripts import image_release
COMMIT = "a" * 40
DIGEST = "sha256:" + "b" * 64
IMAGE_ID = "sha256:" + "c" * 64
REPO = Path(__file__).resolve().parents[2]
MIGRATION_DIR = REPO / "trex_core" / "adapters" / "persistence" / "sql"
class RecordingRunner:
def __init__(
self,
*,
fail_on: tuple[str, ...] | None = None,
digest: str = DIGEST,
revision: str = COMMIT,
image_id: str = IMAGE_ID,
loaded_image: str = f"trex-core-ci:{COMMIT}",
):
self.commands: list[tuple[list[str], str | None, int | None]] = []
self.fail_on = fail_on
self.digest = digest
self.revision = revision
self.image_id = image_id
self.loaded_image = loaded_image
def run(self, command, *, input_text=None, timeout=None):
command = list(command)
self.commands.append((command, input_text, timeout))
if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on):
raise subprocess.CalledProcessError(1, command)
if command[:2] == ["docker", "load"]:
return image_release.Result(f"Loaded image: {self.loaded_image}\n")
if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command:
return image_release.Result(self.image_id + "\n")
if command[:3] == ["docker", "image", "inspect"]:
return image_release.Result(self.revision + "\n")
if command[:2] == ["docker", "push"]:
return image_release.Result(f"digest: {self.digest} size: 1234\n")
if command[:2] == ["docker", "create"]:
return image_release.Result("container-id\n")
return image_release.Result()
def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]:
archive = tmp_path / "trex-core-image.tar"
checksum = tmp_path / "trex-core-image.tar.sha256"
image_id = tmp_path / "trex-core-image.id"
archive.write_bytes(b"tested image")
image_release.write_checksum(archive, checksum)
image_id.write_text(IMAGE_ID + "\n", encoding="ascii")
return archive, checksum, image_id
def test_release_tags_are_deterministic_and_never_latest():
main = image_release.release_tags(COMMIT, "refs/heads/main")
release = image_release.release_tags(COMMIT, "refs/tags/v1.2.3")
assert main == [f"{image_release.IMAGE}:{COMMIT[:12]}"]
assert release == [*main, f"{image_release.IMAGE}:v1.2.3"]
assert all(not tag.endswith(":latest") for tag in main + release)
@pytest.mark.parametrize("commit", ["A" * 40, "a" * 39, "g" * 40, ""])
def test_invalid_source_commit_is_rejected(commit):
with pytest.raises(ValueError):
image_release.release_tags(commit, "refs/heads/main")
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
runner = RecordingRunner()
image_release.smoke_image("candidate", runner, timeout=17, migration_dir=MIGRATION_DIR)
runs = [call for call in runner.commands if call[0][:2] == ["docker", "run"]]
removals = [call for call in runner.commands if call[0][:2] == ["docker", "rm"]]
expected_migrations = tuple(sorted(path.name for path in MIGRATION_DIR.glob("*.sql")))
assert expected_migrations
assert len(runs) == len(removals) == 5
assert [call[0][call[0].index("--entrypoint") + 1 :] for call in runs] == [
["python", "candidate", "-c", "import trex_core"],
["trex-core-daemon", "candidate", "--help"],
["trex-core", "candidate", "--help"],
["trex-core-reset", "candidate", "--help"],
["python", "candidate", "-I", "-c", image_release.migration_inventory_code(expected_migrations)],
]
assert all("--network" in call[0] and "none" in call[0] for call in runs)
assert all("--read-only" in call[0] and "--cap-drop" in call[0] for call in runs)
assert all(call[2] == 17 for call in runs + removals)
assert all("--config" not in call[0] and "--yes" not in call[0] for call in runs)
def test_smoke_rejects_empty_canonical_migration_inventory(tmp_path):
with pytest.raises(ValueError, match="canonical migration inventory is empty"):
image_release.smoke_image("candidate", RecordingRunner(), migration_dir=tmp_path)
def test_migration_inventory_probe_failure_propagates_after_cleanup():
class FifthProbeFailsRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
run_count = len([call for call in self.commands if call[0][:2] == ["docker", "run"]])
if list(command)[:2] == ["docker", "run"] and run_count == 4:
self.commands.append((list(command), input_text, timeout))
raise subprocess.CalledProcessError(1, command)
return super().run(command, input_text=input_text, timeout=timeout)
runner = FifthProbeFailsRunner()
with pytest.raises(RuntimeError, match="smoke probe 5 failed"):
image_release.smoke_image("candidate", runner, migration_dir=MIGRATION_DIR)
assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"]
def test_smoke_failure_propagates_after_cleanup():
runner = RecordingRunner(fail_on=("docker", "run"))
with pytest.raises(RuntimeError, match="smoke probe 1 failed"):
image_release.smoke_image("candidate", runner)
assert [call[0][:3] for call in runner.commands] == [
["docker", "run", "--name"],
["docker", "rm", "--force"],
]
def test_smoke_fails_if_captured_probe_output_contains_canary():
class LeakingRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
if list(command)[:2] == ["docker", "run"]:
return image_release.Result(image_release.CANARY.decode())
return result
runner = LeakingRunner()
with pytest.raises(RuntimeError, match="smoke probe 1 failed"):
image_release.smoke_image("candidate", runner)
assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"]
def test_mutated_archive_stops_before_load_or_registry(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
archive.write_bytes(b"mutated")
runner = RecordingRunner()
with pytest.raises(ValueError, match="checksum mismatch"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
actor="actor",
token="fake-token",
runner=runner,
)
assert runner.commands == []
def test_load_failure_stops_before_login_and_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(fail_on=("docker", "load"))
with pytest.raises(subprocess.CalledProcessError):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands)
def test_revision_mismatch_stops_before_login_and_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(revision="c" * 40)
with pytest.raises(ValueError, match="revision does not match"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands)
def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(image_id="sha256:" + "d" * 64)
with pytest.raises(ValueError, match="image ID does not match"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(
call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"])
for call in runner.commands
)
def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(loaded_image="unrelated:image", image_id=IMAGE_ID)
with pytest.raises(ValueError, match="did not report the expected local image"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(
call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"])
for call in runner.commands
)
@pytest.mark.parametrize(
"invalid_id",
["", "sha256:" + "c" * 63, "sha256:" + "C" * 64, "sha512:" + "c" * 64],
)
def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id):
archive, checksum, image_id = _artifact(tmp_path)
image_id.write_text(invalid_id, encoding="ascii")
runner = RecordingRunner()
with pytest.raises(ValueError, match="image ID evidence"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
actor="actor",
token="fake-token",
runner=runner,
)
assert runner.commands == []
def test_record_image_id_writes_strict_inspected_id(tmp_path):
output = tmp_path / "trex-core-image.id"
runner = RecordingRunner()
assert image_release.write_image_id("candidate", output, runner) == IMAGE_ID
assert output.read_text(encoding="ascii") == IMAGE_ID + "\n"
assert runner.commands == [
(["docker", "image", "inspect", "--format", "{{.Id}}", "candidate"], None, None)
]
def test_valid_publish_records_digest_only_after_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
output = tmp_path / "output"
summary = tmp_path / "summary"
runner = RecordingRunner()
digest = image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/tags/v2.0.0",
actor="actor",
token="fake-token",
runner=runner,
output_path=output,
summary_path=summary,
)
commands = [call[0][:2] for call in runner.commands]
assert commands.index(["docker", "load"]) < commands.index(["docker", "login"])
assert commands.count(["docker", "push"]) == 2
assert digest == DIGEST
assert f"digest={DIGEST}" in output.read_text()
assert COMMIT in output.read_text()
assert f"{image_release.IMAGE}@{DIGEST}" in summary.read_text()
@pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64])
def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest):
archive, checksum, image_id = _artifact(tmp_path)
output = tmp_path / "output"
summary = tmp_path / "summary"
runner = RecordingRunner(digest=bad_digest)
with pytest.raises(ValueError, match="valid digest"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
actor="actor",
token="fake-token",
runner=runner,
output_path=output,
summary_path=summary,
)
assert any(call[0][:2] == ["docker", "push"] for call in runner.commands)
assert not output.exists()
assert not summary.exists()
def test_release_second_push_failure_preserves_nonzero_without_success_evidence(tmp_path):
class SecondPushFailsRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
pushes = [call for call in self.commands if call[0][:2] == ["docker", "push"]]
if list(command)[:2] == ["docker", "push"] and len(pushes) == 2:
return image_release.Result("digest unavailable\n")
return result
archive, checksum, image_id = _artifact(tmp_path)
output = tmp_path / "output"
summary = tmp_path / "summary"
runner = SecondPushFailsRunner()
with pytest.raises(ValueError, match="valid digest"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/tags/v2.0.0",
actor="actor",
token="fake-token",
runner=runner,
output_path=output,
summary_path=summary,
)
assert len([call for call in runner.commands if call[0][:2] == ["docker", "push"]]) == 2
assert not output.exists()
assert not summary.exists()
def test_canary_is_found_inside_nested_export_archive(tmp_path):
layer = io.BytesIO()
with tarfile.open(fileobj=layer, mode="w") as archive:
payload = image_release.CANARY
member = tarfile.TarInfo("app/data")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
saved = tmp_path / "image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = layer.getvalue()
member = tarfile.TarInfo("layer.tar")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
def test_canary_is_found_inside_compressed_digest_named_blob(tmp_path):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w") as archive:
payload = image_release.CANARY
member = tarfile.TarInfo("app/data")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
def test_canary_is_found_in_symlink_metadata_inside_compressed_digest_blob(tmp_path):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w") as archive:
member = tarfile.TarInfo("app/link")
member.type = tarfile.SYMTYPE
member.linkname = image_release.CANARY.decode()
archive.addfile(member)
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
@pytest.mark.parametrize(
"pax_headers",
[
{"comment": image_release.CANARY.decode()},
{image_release.CANARY.decode(): "safe"},
],
)
def test_canary_is_found_in_pax_metadata_inside_compressed_digest_blob(tmp_path, pax_headers):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w", format=tarfile.PAX_FORMAT) as archive:
member = tarfile.TarInfo("app/data")
member.pax_headers = pax_headers
archive.addfile(member)
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
@pytest.mark.parametrize("field", ["uname", "gname"])
def test_canary_is_found_in_owner_metadata_inside_compressed_digest_blob(tmp_path, field):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w") as archive:
member = tarfile.TarInfo("app/data")
setattr(member, field, image_release.CANARY.decode())
archive.addfile(member)
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
def test_archive_scan_rejects_excessive_nesting_depth(tmp_path):
payload = io.BytesIO()
with tarfile.open(fileobj=payload, mode="w"):
pass
nested = payload.getvalue()
for _ in range(4):
wrapped = io.BytesIO()
with tarfile.open(fileobj=wrapped, mode="w") as archive:
member = tarfile.TarInfo("blob")
member.size = len(nested)
archive.addfile(member, io.BytesIO(nested))
nested = wrapped.getvalue()
path = tmp_path / "deep.tar"
path.write_bytes(nested)
with pytest.raises(ValueError, match="archive scan resource limit exceeded") as error:
image_release._scan_tar(path, image_release._ScanBudget(max_depth=2))
assert image_release.CANARY.decode() not in str(error.value)
def test_archive_scan_rejects_excessive_aggregate_members(tmp_path):
path = tmp_path / "members.tar"
with tarfile.open(path, mode="w") as archive:
for index in range(3):
member = tarfile.TarInfo(f"directory-{index}")
member.type = tarfile.DIRTYPE
archive.addfile(member)
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, image_release._ScanBudget(max_members=2))
def test_archive_scan_rejects_excessive_aggregate_extracted_bytes(tmp_path):
path = tmp_path / "aggregate.tar"
with tarfile.open(path, mode="w") as archive:
payload = b"123456"
member = tarfile.TarInfo("payload")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
budget = image_release._ScanBudget(max_extracted_bytes=5, max_member_bytes=10)
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, budget)
def test_archive_scan_rejects_excessive_per_member_bytes(tmp_path):
path = tmp_path / "member.tar"
with tarfile.open(path, mode="w") as archive:
payload = b"123456"
member = tarfile.TarInfo("payload")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
budget = image_release._ScanBudget(max_extracted_bytes=100, max_member_bytes=5)
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, budget)
def test_archive_scan_enforces_top_level_input_bytes_and_accepts_boundary(tmp_path):
path = tmp_path / "input.tar"
with tarfile.open(path, mode="w"):
pass
input_size = path.stat().st_size
image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size))
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size - 1))
def test_canary_failure_message_does_not_disclose_marker(tmp_path):
output = tmp_path / "captured-output"
output.write_bytes(image_release.CANARY)
with pytest.raises(ValueError) as error:
image_release.assert_clean_output([output])
assert image_release.CANARY.decode() not in str(error.value)
def test_canary_scan_checks_metadata_filesystem_archive_and_cleanup(tmp_path):
class ScanRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
if list(command)[:2] == ["docker", "export"]:
output = Path(command[command.index("--output") + 1])
with tarfile.open(output, mode="w"):
pass
return result
saved = tmp_path / "image.tar"
with tarfile.open(saved, mode="w"):
pass
runner = ScanRunner()
image_release.scan_image("candidate", saved, runner)
commands = [call[0] for call in runner.commands]
assert ["docker", "history", "--no-trunc", "candidate"] in commands
assert ["docker", "image", "inspect", "candidate"] in commands
assert ["docker", "create", "--network", "none", "candidate"] in commands
assert any(command[:2] == ["docker", "export"] for command in commands)
assert ["docker", "rm", "--force", "container-id"] in commands
def test_canary_in_create_stderr_still_cleans_container(tmp_path):
class LeakingCreateRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
if list(command)[:2] == ["docker", "create"]:
return image_release.Result("container-id\n", image_release.CANARY.decode())
return result
saved = tmp_path / "image.tar"
with tarfile.open(saved, mode="w"):
pass
runner = LeakingCreateRunner()
with pytest.raises(ValueError, match="canary marker found"):
image_release.scan_image("candidate", saved, runner)
assert runner.commands[-1][0] == ["docker", "rm", "--force", "container-id"]

Some files were not shown because too many files have changed in this diff Show More