121 Commits
Author SHA1 Message Date
Hermes Agent bbdacb121c chore(release): prepare v0.8.0 2026-09-15 16:44:53 +09:00
Hermes Agent c70ac0fb24 feat: auto-reconcile stale holding mirrors
Closes #94
2026-09-14 12:55:04 +09:00
Hermes Agent adf131f19c chore(release): prepare v0.7.0 2026-09-13 17:01:20 +09:00
genichin 3569d864c9 Merge pull request #93 from fourblocks2020/copilot/ops-release-preserve-audit-checkout
ops(release): add restart/audit provenance gate with clean-checkout enforcement
2026-09-13 16:45:27 +09:00
Hermes Agent 7dc2f65455 fix(ops): make release provenance gate fail closed 2026-09-13 16:34:44 +09:00
copilot-swe-agent[bot]andgenichin 8378e03dcc perf(ops): streamline log capture and git error reporting
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 16:12:55 +09:00
copilot-swe-agent[bot]andgenichin 619feff5f4 fix(ops): address provenance gate review findings
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 16:12:55 +09:00
copilot-swe-agent[bot]andgenichin 8d9d627b1c fix(ops): harden provenance gate path and command parsing
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 16:12:55 +09:00
copilot-swe-agent[bot]andgenichin f3a6b2c410 feat(ops): add release provenance gate and audit checkout isolation
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 16:12:55 +09:00
copilot-swe-agent[bot] a6ae800346 Initial plan 2026-09-13 16:12:55 +09:00
genichin 6dbc92bfa7 Merge pull request #92 from fourblocks2020/copilot/ops-broker-track-kis-balance-failure
ops(broker): KIS 잔고 실패 lifecycle 추적 및 worker별 fail-closed/backoff 분리
2026-09-13 16:09:25 +09:00
Hermes Agent 8577f54ae6 chore(proofline): activate approved AC-0031 2026-09-13 16:07:19 +09:00
Hermes Agent 635a9a9f9f fix(snapshot): avoid duplicate scheduled balance retry 2026-09-13 15:49:06 +09:00
copilot-swe-agent[bot]andgenichin 8b3e7ac753 fix(broker): cap outage backoff overflow and expand worker matrix coverage
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 06:40:06 +00:00
copilot-swe-agent[bot]andgenichin 5eef76d59e chore(proofline): keep AC-0031 as draft pending user exact approval
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 06:28:33 +00:00
copilot-swe-agent[bot]andgenichin 1f728f4d7d fix(broker): restore main wiring and add production retry cadence + status surface
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 06:25:28 +00:00
copilot-swe-agent[bot]andgenichin 2b5d9043b9 chore: plan for review blockers follow-up
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 06:21:10 +00:00
genichin 19af864bf3 Merge pull request #91 from fourblocks2020/copilot/feat-reconcile-unmanaged-position-durable-lifecycl
feat(reconcile): durably track unmanaged position lifecycle by instance and symbol
2026-09-13 15:14:37 +09:00
copilot-swe-agent[bot]andgenichin 3d46485a62 Add AC-0030 unmanaged lifecycle criterion
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 06:06:08 +00:00
copilot-swe-agent[bot]andgenichin 8b1de8e26a Fence unmanaged lifecycle by snapshot time
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 05:54:51 +00:00
copilot-swe-agent[bot]andgenichin 84d81658a1 Resolve main merge and add unmanaged store race tests
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 02:58:18 +00:00
copilot-swe-agent[bot]andgenichin c73e382b70 Merge remote-tracking branch 'origin/main' into copilot/feat-reconcile-unmanaged-position-durable-lifecycl
# Conflicts:
#	.proofline/criteria/ac-0029.md
#	.proofline/identities.json
#	tests/adapters/persistence/test_migration_and_drift.py

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 02:53:22 +00:00
copilot-swe-agent[bot]andgenichin 591db7adb7 Fix unmanaged reconciliation review blockers
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 02:21:37 +00:00
genichin 3cb0fdc303 Merge pull request #90 from fourblocks2020/copilot/bug-ledger-shared-outbox-consumption
Isolate ledger outbox delivery by instance and add lease-based claims
2026-09-13 10:48:57 +09:00
Hermes Agent 762a953c95 fix: fence ledger outbox claims by instance 2026-09-13 09:41:51 +09:00
genichin 60a835fde1 Merge pull request #89 from fourblocks2020/copilot/bugfix-reconcile-warning-issue
fix(reconcile): preserve B/C warning causes per slot and unify strategy blocking with admission
2026-09-12 23:09:39 +09:00
Hermes Agent f1e718e468 docs: align reconcile warning acceptance criteria 2026-09-12 23:06:29 +09:00
Hermes Agent 853943f676 fix: fail closed on unknown reconcile warnings 2026-09-12 22:58:11 +09:00
copilot-swe-agent[bot]andgenichin 9c5a01ed67 fix(health): preserve transient failure codes through stale snapshot fallback
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:37:45 +00:00
copilot-swe-agent[bot]andgenichin 4a2e7dd893 fix(snapshot): preserve reconcile failure signaling after health tracking
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:36:14 +00:00
copilot-swe-agent[bot]andgenichin e3142017c9 fix(account-snapshot): treat timeout/http5xx as transient fallback failures
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:34:51 +00:00
copilot-swe-agent[bot]andgenichin 084e05a97d feat(ops): track broker balance worker failure lifecycle and backoff
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:33:12 +00:00
copilot-swe-agent[bot]andgenichin f9284d1de8 fix: preserve unmanaged first-seen timestamps
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:33:05 +00:00
copilot-swe-agent[bot]andgenichin be2e73a2d6 fix: harden unmanaged position lifecycle writes
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:31:32 +00:00
copilot-swe-agent[bot]andgenichin f801c53e0c test: cover targeted and expired ledger claims
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:30:50 +00:00
copilot-swe-agent[bot]andgenichin 5a13c0494b fix: guard unmanaged resolution on authoritative balances
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:29:45 +00:00
copilot-swe-agent[bot]andgenichin 0b5b4660e5 fix: guard ledger outbox outcomes by lease
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:29:05 +00:00
copilot-swe-agent[bot]andgenichin 310b6f6dec fix: align warning-case blocking semantics across reconcile paths
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:28:40 +00:00
copilot-swe-agent[bot] 59bb492883 Initial plan 2026-09-12 13:27:27 +00:00
copilot-swe-agent[bot]andgenichin 31d6e5c02b test: cover stale ledger outbox lease updates
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:27:27 +00:00
copilot-swe-agent[bot]andgenichin f2b825e720 test: cover unmanaged position lifecycle flow
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:27:12 +00:00
copilot-swe-agent[bot]andgenichin 09b1b4f4c3 fix: isolate ledger outbox delivery by instance
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:24:58 +00:00
copilot-swe-agent[bot]andgenichin fe51d211d9 feat: persist unmanaged position lifecycle state
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:24:51 +00:00
copilot-swe-agent[bot]andgenichin 45a5e4157d fix: preserve multi-cause reconcile warnings and align slot blocking
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-12 13:22:40 +00:00
copilot-swe-agent[bot] ae2e598209 Initial plan 2026-09-12 13:18:03 +00:00
copilot-swe-agent[bot] 304a06f97c Initial plan 2026-09-12 13:17:22 +00:00
copilot-swe-agent[bot] e47bebacaf Initial plan 2026-09-12 13:16:43 +00:00
genichin 729abdd946 Merge pull request #88 from fourblocks2020/fix/issue-81-kis-correction-reconciliation
fix: reconcile KIS correction order chains
2026-09-12 22:15:21 +09:00
Hermes Agent 06c3f06b5d fix: reconcile KIS correction order chains 2026-09-12 22:12:19 +09:00
Hermes Agent 6017bde744 chore(release): bump version to 0.6.0 2026-09-02 18:24:06 +09:00
genichin 869664aa4a Merge pull request #82 from fourblocks2020/copilot/fix-cancel-sql-error-and-pending-issue
fix(execution): cancel SQL IndeterminateDatatype, KIS child cancel detection, worker exception visibility
2026-09-01 23:21:01 +09:00
copilot-swe-agent[bot]andgenichin f3fbc179b0 fix(kis): collect all parent candidates before child evaluation; boundary case tests
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-01 13:35:12 +00:00
copilot-swe-agent[bot]andgenichin 584e08eb36 fix(execution): bind parent row to request identity before child evaluation; add parent mismatch and race tests
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-01 13:21:20 +00:00
copilot-swe-agent[bot]andgenichin 65df10542b fix(execution): KIS child cancel identity binding, sanitized logging, PG cancel SQL tests
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-01 12:06:45 +00:00
copilot-swe-agent[bot]andgenichin f0ac909958 fix(execution): cancel SQL nullable type error, KIS child cancel detection, worker exception logging
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-01 03:33:38 +00:00
copilot-swe-agent[bot] 2b412fd786 Initial plan 2026-09-01 03:27:51 +00:00
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
183 changed files with 18053 additions and 2942 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에 따라 B 또는 C reconcile warning으로 제외된 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에 따라 B 또는 C reconcile warning(`case_code=B` 또는 `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가 없어야 한다.
+28
View File
@@ -0,0 +1,28 @@
---
id: "ac-0023"
status: active
---
# B/C reconcile warning — strategy RPC exclusion and intent blocking
## Criterion
`case_code=B`(core slot↔KIS 수량 불일치) 또는 `case_code=C`(portfolio-hub↔KIS 수량 불일치) reconcile-warning이 활성인 `holding` slot은 `trex.slots.req.<instance>` 응답에서 제외해야 한다. 동일 slot의 B/C 원인은 서로 덮어쓰지 않고 동시에 보존하며, 응답에는 제외된 slot 수와 활성 case별 count를 포함한다. 신규 buy/sell intent는 같은 durable warning 원인 집합을 재확인하여 `reconcile_warned` 사유로 거부한다. warn-store를 읽지 못하거나 warning metadata가 비어 있거나 지원하지 않는 case를 포함하면 안전한 eligibility를 입증할 수 없으므로 slots RPC는 `degraded=true` / `reason="warn_store_unavailable"`, intent는 `reconcile_warning_unavailable`(조회 실패) 또는 `reconcile_warned`(empty/unsupported metadata)로 fail-closed 처리해야 한다. B-case는 수량 일치 확인을 마친 operator `sync` 또는 AC-0032~AC-0034를 모두 만족해 qty·avg_price·audit·B warning clear가 한 transaction으로 commit된 `auto_reconcile`만 해제할 수 있다. `verify`는 instance+symbol 단위 KIS와 portfolio-hub 합계가 다시 일치한 경우 C-case만 해제하여 다른 활성 원인을 보존한다. C-case 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 이 경로는 주문 submit/cancel, portfolio-hub 또는 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`, `active_reconcile_warning_cases.C=2`. 다른 symbol 정상 slot은 같은 응답에 포함된다.
- 복합 원인: 동일 slot에 B와 C가 동시에 활성일 때 두 원인이 모두 조회·차단되며, B 또는 C 하나를 해제해도 다른 원인은 보존된다.
- B-case: B warning이 활성인 holding slot은 slots RPC에서 제외되고 신규 buy/sell intent도 거부된다.
- 단일 portfolio 불일치: hub 단일 portfolio qty != KIS → 해당 symbol 전체 slot 제외.
- fail-closed (slots RPC): warn-store 조회가 실패하거나 empty/unknown case metadata를 반환하면 `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`로 거부. unknown case도 거래 허용 근거로 사용하지 않는다.
- 해제: symbol 합계가 다시 일치하면 해당 symbol의 모든 **C-case 경고만** 함께 해제. 같은 symbol의 B-case 등 다른 case 경고는 보존해야 한다.
- 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다.
- 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패).
- 시간이 지났다는 이유만으로 자동 해제하지 않음.
- 자동 해제: AC-0032의 authoritative evidence와 eligibility를 통과하고 AC-0033 transaction이 성공한 경우에만 B-case를 해제한다. CAS 실패·불완전 evidence·write 실패에서는 B-case를 유지한다.
- 비침습 조건: slots RPC 호출 전후로 slot 상태 전이, broker mutation, ledger write side effect가 없어야 한다.
- AC-0022 우선순위: B 또는 C reconcile warning으로 제외된 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` 또는 AC-0033 transaction이 성공한 `auto_reconcile`, 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/auto_reconcile, 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` 또는 성공한 `auto_reconcile`과 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 회귀 테스트를 함께 실행한다.
+21
View File
@@ -0,0 +1,21 @@
---
id: "ac-0029"
status: active
---
# Ledger outbox instance ownership and lease fencing
## Criterion
`trex-core`의 durable ledger outbox row는 enqueue 시점의 required non-empty `instance_key`를 immutable owner로 저장해야 하며, daemon·operator backfill·targeted processing을 포함한 모든 delivery 경로는 자기 instance의 row만 claim하고 처리해야 한다. Shared PostgreSQL에서 claim은 pending·retry-due·unclaimed 또는 lease-expired row를 원자적으로 선택해 worker별 bounded lease를 설정해야 하고, 성공·중복·retryable·conflict·permanent·dead-letter outcome은 해당 claim의 fencing 정보가 일치할 때만 반영해야 한다. 다른 instance, legacy `instance_key IS NULL`, 아직 유효한 foreign lease 또는 이미 새 worker가 reclaim한 row는 처리·상태 변경·성공 통계·ledger-skip resolution 없이 fail closed 해야 한다.
Claim owner가 crash하면 lease 만료 후 같은 instance의 다른 worker가 row를 reclaim할 수 있어야 한다. Outcome CAS가 0건이면 claim 상실로 명시적으로 처리하고 성공 outcome으로 보고해서는 안 된다. InMemory와 PostgreSQL outbox는 instance filtering, active lease exclusion, expiry recovery와 stale-claim rejection에서 같은 의미를 가져야 한다. Claim·outcome 관측 정보에는 `instance_key`, sanitized result/reason과 correlation identity만 허용하며 credential, account identifier, token, password, DSN, provider payload 또는 exception 원문을 로그·metric·alert에 포함해서는 안 된다.
## Verification
- 격리 조건: 서로 다른 `instance_key`의 daemon/writer 두 개가 한 PostgreSQL을 공유할 때 각 writer는 자기 row만 claim·전송·집계한다. `list_pending`, `pending_cash_delta`, batch claim과 correlation-targeted claim 모두 foreign row와 `instance_key IS NULL` legacy row를 제외한다. 누락·빈 instance key로 outbox 또는 writer를 생성하면 외부 연결이나 row 생성 전에 거부된다.
- lease 조건: worker A가 claim한 row는 lease 동안 worker B가 claim하지 못한다. A가 outcome을 기록하면 lease가 해제되고 terminal 또는 retry 상태가 정확히 저장된다. A가 crash한 경우 expiry 전에는 B가 claim하지 못하고 expiry 뒤에는 B가 reclaim할 수 있다. Reclaim 뒤 A의 stale outcome은 typed claim-lost 결과로 거부되며 B의 row 상태, outcome, attempt count, next retry와 lease를 변경하지 않는다.
- outcome 조건: adapter의 신규 성공과 `DUPLICATE_OK`, retryable, conflict, permanent 및 dead-letter 모든 분기는 claim fencing을 전달한다. CAS 실패 시 completion/retry counter, 성공 로그, snapshot write-back 이후의 ledger-skip resolution을 실행하지 않는다. Adapter duplicate는 row를 `completed/duplicate_completed`로 만들고 lease를 즉시 해제하여 재전송되지 않는다.
- InMemory parity: 두 번의 동시 claim에서 첫 claim만 성공하고 active lease row는 pending scan에서 제외된다. clock을 이동하면 expiry recovery가 가능하며 이전 claim token으로 outcome을 기록할 수 없다. Instance filter와 targeted processing의 fail-closed 동작이 PostgreSQL과 같다.
- 보안 조건: exception에 credential·account·token·password·DSN·provider payload를 식별하는 sentinel을 넣어 claim 실패, retry, permanent, dead-letter와 write-back 오류를 실행해도 rendered log, metric key/value와 alert에 sentinel 또는 exception 원문이 나타나지 않는다.
- migration 및 회귀: migration 번호는 저장소에서 유일하고 기존 row를 자동 귀속시키지 않으며 `instance_key IS NULL`로 격리한다. 빈 DB와 기존 schema 모두에서 migration apply/replay, PostgreSQL claim round-trip, restart/expiry 회수, daemon·CLI wiring을 검증한다. 전체 repository test, Ruff, compileall, ProofLine validation과 `git diff --check`를 실행한다.
+22
View File
@@ -0,0 +1,22 @@
---
id: "ac-0030"
status: active
---
# Unmanaged position durable lifecycle, authority fencing, and operator audit
## Criterion
`trex-core`는 broker 계정에 존재하지만 대상 instance의 managed slot/ledger가 소유하지 않는 종목을 `(instance_key, symbol)` 단위의 durable unmanaged-position lifecycle로 추적해야 한다. Lifecycle state는 instance 간에 섞이지 않아야 하며, 최소 `detected`, `quantity_changed`, `resolved`, `reopened` 전이와 `first_seen_at`, `last_seen_at`, `quantity_changed_at`, `resolved_at` 보존을 제공해야 한다. 같은 수량의 반복 authoritative 관측은 `last_seen_at`만 갱신하고 lifecycle audit/alarm을 다시 만들지 않아야 한다.
absence resolution은 explicit authoritative full-balance snapshot에서만 허용되며 projected/partial/non-authoritative broker view 또는 broker read failure는 resolve authority가 아니다. observe와 resolve는 reconcile pass 전체에 전달된 동일 snapshot timestamp 또는 monotonic generation으로 fenced 되어야 하며 stale observe/stale resolve는 더 최신 state나 audit를 reopen, overwrite 또는 resolved 처리하지 못해야 한다. 최종 state와 append-only audit는 항상 가장 최신 authoritative snapshot이 결정해야 한다.
운영자 disposition은 explicit evidence reference가 있는 명령으로만 기록되고 inspectable append-only audit trail에 남아야 한다. 이 기능은 broker order submit/cancel, slot 생성·상태 변경, synthetic ledger mutation 또는 warning 자동 삭제를 수행해서는 안 된다.
## Verification
- identity/lifecycle: 같은 symbol을 서로 다른 `instance_key`에서 독립 state로 저장하고 first detect, quantity change, resolved, resolved 뒤 reopened, first/last-seen 보존을 InMemory/PostgreSQL parity로 검증한다.
- authority boundary: missing marker, `False`, ownership/projected broker, partial reader, broker read failure에서는 absence resolution이 발생하지 않고 explicit authoritative full-balance snapshot에서만 resolved가 기록됨을 검증한다.
- temporal fencing: 더 오래된 absent snapshot이 더 최신 observe 뒤늦게 commit 되어도 resolved 되지 않고, 더 오래된 observe가 더 최신 resolved/observe를 reopen 또는 overwrite하지 않으며 final state/audit가 최신 authoritative snapshot 순서를 따른다는 PostgreSQL concurrency/interleaving 회귀를 검증한다.
- suppression/audit: 같은 수량 반복 관측은 duplicate lifecycle alarm/audit를 만들지 않고 `last_seen_at`만 갱신한다. operator `record`는 `evidence_ref`를 요구하고 `unmanaged audit`로 append-only history를 조회할 수 있어야 한다.
- safety boundary: unmanaged lifecycle path가 broker order, slot mutation, ledger write 또는 warning auto-clear를 수행하지 않음을 existing regression surface와 focused tests로 유지한다.
+27
View File
@@ -0,0 +1,27 @@
---
id: "ac-0031"
status: active
---
# Broker balance failure lifecycle observability and worker-isolated retry
## Criterion
`trex-core`는 broker balance snapshot을 사용하는 worker(`execution`, `position_reconciliation`, `core_holding_reconciliation`)별로 독립 상태를 유지해야 한다. 상태는 최소 `state(ok|unavailable|stale)`, `last_success_at`, `consecutive_failures`, sanitized `failure_code`, `next_retry_at`를 포함해야 하며, 한 worker의 성공/실패가 다른 worker의 상태를 덮어쓰면 안 된다.
balance read failure는 `timeout`, `http_5xx`, `transport_failed`, `authentication_failed`, `token_issuance_failed`(credential failure), `authorization_failed`, `malformed_response`, `broker_rejected`, `ownership_mismatch`를 구분해야 하며, validated empty success(`[]`)는 failure로 취급하면 안 된다. live read transient failure 뒤 snapshot fallback이 stale/expired여도 downstream 상태에는 transient failure 분류가 보존되어야 한다.
daily snapshot cadence와 balance-worker retry cadence는 분리되어야 한다. daily refresh가 이미 수행된 날짜에도 failure worker는 `next_retry_at` 이후에만 재시도되어야 하며(backoff 중 broker read 금지), 성공 시 lifecycle recovery와 정상 cadence로 자동 복귀해야 한다. fail-closed 경계는 유지되어 unavailable/stale snapshot에서 신규 주문/경고 해제 경로가 정상 snapshot으로 해석되어서는 안 된다.
health tracker의 상태는 process-local로 유지되며 daemon restart 시 초기화된다. restart 이후 첫 failure는 새 lifecycle `detected`로 다시 기록되어야 한다.
operator-visible status surface는 instance+worker별 상태를 노출해야 하며 account/token/credential/raw broker payload/예외 원문을 포함해서는 안 된다.
## Verification
- worker isolation: execution/position/core-holding 중 하나만 실패시켜도 다른 worker의 상태가 변경되지 않음을 검증한다.
- retry cadence: production scheduler path(`run_forever`/`_maybe_tick`)에서 daily refresh 여부와 무관하게 due worker만 backoff deadline 이후 재시도되는지 검증한다.
- backoff/lifecycle: 장기 outage에서 backoff가 cap에 고정되고, detected/ongoing/recovered lifecycle이 중복 경고 없이 전이되는지 검증한다.
- classification/fail-closed: empty success, timeout, HTTP 5xx, auth/credential(`authentication_failed`, `token_issuance_failed`), malformed, pagination-middle failure를 구분하고 각 worker의 fail-closed 결과와 health state를 검증한다.
- restart semantics: daemon restart(새 tracker 인스턴스) 후 이전 health/backoff가 초기화되고 첫 failure가 detected로 재발행되는지 검증한다.
- sanitization/visibility: operator status 응답에 instance+worker 상태 필드는 노출되지만 민감정보/원시 payload/예외 문자열은 노출되지 않음을 검증한다.
+18
View File
@@ -0,0 +1,18 @@
---
id: "ac-0032"
status: active
---
# B-case auto-reconcile authoritative evidence와 eligibility
## Criterion
`reconcile_core_holdings`는 하나의 성공한 broker balance snapshot에서 `broker_qty > 0`이고, 같은 scheduler refresh의 portfolio-hub item이 slot의 `instance_key`·`portfolio_id`·`symbol`과 일치하며 `asset_quantity == broker_qty`일 때만 B-case 자동 동기화를 검토한다. 별도 canonical cost-basis 응답은 같은 portfolio·symbol·current quantity를 증명하고, fetch 시각이 60초 이내이며, `data_completeness_warning`이 없고, 양의 finite `cost_basis_per_unit`과 수량×평단에 일치하는 양의 finite `total_cost_basis`를 제공해야 한다. 대상 slot은 `holding`, 미퇴역, broker dispatch/pending metadata 없음이어야 한다. 첫 구현에서 `inactive`, `pending_buy`, `pending_sell`, `error`, retired slot은 자동 동기화하지 않는다. 어느 evidence든 missing·stale·invalid·불일치이거나 read/set-warning가 실패하면 fail-closed하여 slot과 warning을 변경하지 않는다. Operator `sync`의 legacy/graceful-degrade 계약은 변경하지 않는다.
## Verification
- hub·broker 수량과 canonical cost-basis identity/current quantity가 모두 일치하는 holding만 통과한다.
- hub/broker mismatch, cost-basis warning·stale·legacy-only·missing·non-finite·0 이하·identity mismatch·total mismatch는 모두 자동 변경 없이 B warning을 유지한다.
- broker balance는 reconcile pass당 한 번만 읽고 판단과 write가 같은 snapshot을 사용한다.
- pending/dispatch metadata, retired 또는 holding 외 상태는 fail-closed한다.
- 기존 `sync_slot_to_broker`의 canonical/legacy diagnostics와 inactive onboarding 회귀 테스트는 그대로 통과한다.
+18
View File
@@ -0,0 +1,18 @@
---
id: "ac-0033"
status: active
---
# Holding mirror qty·avg_price·B warning·audit 원자적 갱신
## Criterion
AC-0032를 통과한 자동 동기화는 instance advisory lock과 database transaction 안에서 관측한 slot identity/status/qty/avg_price 및 dispatch metadata를 CAS 재검증하고, `qty=broker_qty`, `avg_price=portfolio-hub cost_basis_per_unit` 갱신, `case_code=B` warning만 삭제, `actor=auto_reconcile`·`reason=hub_broker_matched_core_mirror_stale` transition audit 추가를 하나의 commit으로 수행해야 한다. Audit extra에는 allowlist된 before/after qty·avg_price와 hub/broker quantity, portfolio_id, symbol만 포함한다. CAS 실패, B warning 부재 또는 어느 write든 실패하면 네 변경 모두 없어야 한다. C/unknown warning과 다른 instance·symbol·slot은 보존한다. 같은 evidence 재실행은 no-op이며 audit를 중복 추가하지 않는다.
## Verification
- 성공 시 slot status는 holding으로 유지되고 qty·avg_price, B clear, audit 한 건이 함께 반영된다.
- C와 기타 warning은 보존된다.
- CAS race와 강제 예외에서 transaction rollback 후 slot·warning·audit가 모두 이전 상태다.
- PostgreSQL 실제 transaction 테스트와 in-memory parity 테스트로 commit, rollback, isolation, idempotency를 검증한다.
- broker order submit/cancel, portfolio-hub write, ledger/outbox write는 발생하지 않는다.
+17
View File
@@ -0,0 +1,17 @@
---
id: "ac-0034"
status: active
---
# Auto-reconcile scheduler·daemon wiring과 오류 격리
## Criterion
Production daemon은 기존 portfolio-hub read adapter, authoritative reconciliation broker, slot persistence, durable warning/notification store를 `SnapshotScheduler`의 B-case auto-reconcile 경로에 명시적으로 연결해야 한다. Scheduler는 성공한 universe refresh의 items와 core-holding worker가 한 번 읽은 complete broker snapshot을 사용하며 ad-hoc broker/Hub write client 또는 legacy cost-basis fallback을 만들지 않는다. Auto-reconcile evidence/read/CAS/transaction 오류는 해당 slot을 `qty_warning`으로 유지하고 scheduler·다른 slot·C-case reconciliation을 중단하지 않는다. 성공 commit 뒤에만 B resolved notification을 발행하며 publish 실패의 pending retry·fingerprint·instance isolation 및 sanitized payload 계약은 AC-0024를 유지한다.
## Verification
- daemon assembly test가 read port, reconciliation broker, atomic store, warn/notification store의 production 연결을 확인한다.
- scheduler test가 현재 refresh items 전달, broker 단일 read, auto-sync 성공/실패 격리와 다음 tick idempotency를 확인한다.
- resolved notification은 commit/B clear 뒤 한 번 발생하고 publish 실패는 business commit을 되돌리지 않으며 pending retry된다.
- synthetic 회귀: symbol `019685`, hub=broker=726, core=1452, canonical WAC≈2172.46829187에서 core만 726/WAC로 갱신되고 B만 해제되며 주문·Hub·ledger side effect가 없다.
+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": 35
}
+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·본문 변경
+12 -3
View File
@@ -1,8 +1,11 @@
FROM python:3.12-slim
ARG SOURCE_COMMIT
ARG SOURCE_VERSION
LABEL org.opencontainers.image.source="https://github.com/fourblocks2020/trex-core" \
org.opencontainers.image.revision="${SOURCE_COMMIT}"
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.version="${SOURCE_VERSION}" \
org.opencontainers.image.title="trex-core"
WORKDIR /opt/trex-core
@@ -13,12 +16,18 @@ 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 test "$(python -c 'import pathlib,tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" = "${SOURCE_VERSION}" \
|| { echo "SOURCE_VERSION must match pyproject.toml" >&2; exit 1; }
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')
+54 -24
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,19 +104,26 @@
"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"
},
"paused": "bool",
"reconcile_warned": "bool", // durable B/C warning 또는 unknown case 존재
"reconcile_warning_cases": ["B", "C"], // 활성 case code 목록
"trade_eligible": "bool", // known blocking/unknown case가 없을 때만 true
"strategy_name": "str | null", // REQ-116 additive (BAT-055) — portfolio-hub PortfolioItem 출처
"strategy_params": "dict", // REQ-116 additive (BAT-055) — portfolio-hub opaque dict; {} if absent
"markets": ["str"] // REQ-116 additive (BAT-055) — portfolio-hub markets list; [] if absent
}
],
"excluded_slots": {
"reconcile_quantity_mismatch": "int",
"active_reconcile_warning_cases": {"B": "int", "C": "int"}
},
"degraded": "bool",
"reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | null",
"reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | warn_store_unavailable | null",
"message": "str | null" // sanitized — no credentials/raw exceptions
}
```
@@ -119,19 +133,27 @@
| 필드 | 권위 소스 | 비고 |
|---|---|---|
| `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) |
| `reconcile_warned`, `reconcile_warning_cases` | ReconcileWarningStore | 동일 slot의 durable B/C 원인을 동시에 보존·노출 |
| `trade_eligible` | 파생(safety gate) | 활성 B/C 또는 지원하지 않는 warning case가 있으면 false |
| `strategy_name` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. portfolio-hub 정본. hub 미제공 시 null |
| `strategy_params` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. opaque dict; hub 미제공 시 {} |
| `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 참조.
- **Reconcile warning fail-closed**: B/C warning이 활성인 holding slot은 응답에서 제외된다. warning store 조회 실패, empty metadata, unknown case는 `slots=[]`, `degraded=true`, `reason="warn_store_unavailable"`로 처리한다. intent admission도 동일 durable source를 재확인하며 unknown case를 거래 허용 근거로 사용하지 않는다.
- **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 +418,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 +436,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)
@@ -425,8 +447,11 @@ resume_kill_switch 요청·응답 (REQ-046):
- `get_balance()`는 검증된 `list[BalanceItem]`만 반환한다. KIS의 token/auth/authorization/transport/response/rejection 실패는 빈 목록으로 축소되지 않고 `BrokerBalanceError`를 raise한다. 안정 code allow-list는 `token_issuance_failed`, `authentication_failed`, `authorization_failed`, `transport_failed`, `malformed_response`, `broker_rejected`, `ownership_mismatch`다.
- `sync`와 `verify` CLI는 첫 balance read를 완료한 뒤 cache한 결과를 해당 invocation에 전달한다. 이 read 또는 broker credential resolution이 실패하면 exit code `1`이며, `--json` stderr는 `{ "error": {"code": "…", "message": "broker balance unavailable"} }` 형태의 안전한 값만 낸다. upstream payload, credential, account 값은 계약상 출력하지 않는다.
- `reconcile_core_holdings()`의 broker-only 결과는 `unmanaged_position`(대상 instance의 managed slot 없음) 또는 `managed_non_holding_divergence`(동일 instance의 non-holding managed slot 존재)다. `find_managed_slot_by_symbol(instance_key, symbol)` 조회는 instance-scoped이며, 이 두 분류 자체는 slot/warning write나 자동 주문·청산·slot 생성을 하지 않는다.
- 이 변경은 AMQP 또는 portfolio-hub external wire contract와 DB schema를 바꾸지 않는다.
- `reconcile_core_holdings()`의 broker-only 결과는 `unmanaged_position`(대상 instance의 managed slot 없음) 또는 `managed_non_holding_divergence`(동일 instance의 non-holding managed slot 존재)다. `find_managed_slot_by_symbol(instance_key, symbol)` 조회는 instance-scoped이며, 두 분류 모두 slot 생성, 자동 주문·청산을 하지 않는다.
- `managed_non_holding_divergence`는 기존 reconcile warning lifecycle을 유지한다. 반면 `unmanaged_position`은 `(instance_key, symbol)` keyed durable state로 추적되며 `quantity`, `first_seen_at`, `last_seen_at`, `quantity_changed_at`, `resolved_at`, lifecycle state와 latest operator evidence/disposition을 저장한다.
- `unmanaged_position`의 absence resolution은 explicit authoritative full-balance snapshot reader에서만 허용된다. Ownership/projected broker 또는 marker가 없는 balance reader는 detect/update 입력일 수 있어도 resolved authority는 아니다.
- Operator surface는 `trex-core unmanaged list`, `trex-core unmanaged audit <symbol>`, `trex-core unmanaged record <symbol> <action> --evidence-ref ...`이며 audit actor는 CLI의 trusted local identity에서 결정된다. 임의 caller-supplied actor string은 current contract가 아니다.
- 이 변경은 AMQP 또는 portfolio-hub external wire contract는 바꾸지 않지만, internal PostgreSQL persistence에는 unmanaged-position state/audit schema를 추가한다.
### Account snapshot recovery (dcy-041 / GitHub #45)
@@ -447,8 +472,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 +486,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 +540,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 +659,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 +670,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
+39 -6
View File
@@ -40,13 +40,45 @@ pkill -TERM -f trex-core-daemon
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
### 컨테이너 설정, 배포 및 rollback
### Release image build
- 이미지는 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와 정확히 일치하는지 검증한다.
Release tag의 clean checkout에서 `./scripts/build_release_image.sh trex-core:<version>`을
실행한다. 이 경로는 project version과 image tag를 대조하고 OCI version/revision labels,
non-root user 및 container `--version`을 검증한다. 전체 명령과 registry 사용법은
[`docs/ops/release-image-build.md`](ops/release-image-build.md)를 따른다.
### 배포/복구/수동 재기동 provenance gate (issue #87)
배포, 복구, 수동 stop/start/recreate 전에 아래 gate를 실행해 **검증 가능한 release record**를 남긴다.
```bash
# PRE (대상 인스턴스 1개만 지정; POST도 같은 operation-id로 별도 실행)
python scripts/release_provenance_gate.py \
--actor "<operator>" \
--reason "<ticket-or-incident>" \
--instance "<instance_key>" \
--operation-id "<deployment-or-incident-id>" \
--phase "pre" \
--command-class "manual-stop" \
--expected-oci-revision "<40-character-running-image-oci-revision>" \
--image-digest "sha256:<64-hex-running-image-digest>" \
--runtime-file "trex_core/runtime/daemon.py" \
--runtime-file "trex_core/runtime/cli.py" \
--capture-command "pending_slots=.venv/bin/trex-core slots --status pending --instance <instance_key> --json" \
--capture-command "reconciliation_slots=.venv/bin/trex-core slots --status all --instance <instance_key> --json" \
--capture-command "queue_consumers=rabbitmqctl list_consumers"
```
- gate는 audit checkout이 detached HEAD인지 확인하고 NUL 구분 `git status --porcelain=v1`로 clean 여부를 검사한다. branch checkout 또는 dirty checkout이면 운영 상태 조회 명령도 실행하지 않고 종료 코드 `2`로 중단하며, dirty 변경 파일은 audit checkout 밖의 `<checkout>-release-provenance-isolation/`에 작업별 디렉터리로 복사한다. 우회 옵션은 없다.
- `checkout.head_revision`과 필수 `--expected-oci-revision` 일치 여부를 검사해 audit checkout HEAD가 실행 이미지 OCI revision과 같은지 검증한다.
- 필수 `--image-digest` 형식과 하나 이상의 `--runtime-file` 검증 결과(sha256 + revision 일치 여부)가 JSON record에 저장된다.
- actor/timestamp/instance/operation-id/phase/command-class/reason이 JSON record에 함께 저장되어 PRE/POST 및 stop/start/recreate 감사 추적이 가능하다. actor/reason에 credential 형태의 key/value가 포함되면 기록 전에 거부한다.
- PRE/POST 모두 실행해 재기동 전후의 `pending_slots`, `reconciliation_slots`, `queue_consumers`, broker-side-effect 로그(`order_submit/fill/reject/cancel`)를 자동 캡처한다. 필수 조회 또는 로그 캡처가 누락·실패하면 gate는 실패한다.
- capture command는 위의 인스턴스 지정 `trex-core slots` 두 형식과 `rabbitmqctl list_consumers`만 허용한다. 주문 제출·취소, DB 복구, 자동 sync/verify live 실행은 거부한다.
- broker-side-effect 로그 입력은 기본 `/tmp/trex-core.log`를 사용하며, 로그 파일 위치가 다르면 `--broker-log-path <path>`로 명시한다. record에는 원문 대신 파일 sha256·스캔 행 수와 event 종류·행 번호·행 sha256만 남긴다. 파일을 정상 스캔했지만 일치 이벤트가 없으면 `captured_empty`, 파일 누락·읽기 실패는 각각 `unavailable`·`error`로 기록하며 후자는 gate를 차단한다.
- record와 격리 디렉터리는 audit checkout 밖에 둔다. 기본 record 위치는 `<checkout>-release-provenance/`이며, 충돌 없는 파일명을 exclusive-create하고 read-only mode로 전환해 기존 record를 덮어쓰지 않는다.
재기동 후에는 같은 `--operation-id`, `--phase "post"`, 해당 command class(예: `manual-start`)로 POST record를 추가 저장한다.
---
@@ -74,6 +106,7 @@ pkill -TERM -f trex-core-daemon
- `sync`와 `verify`는 broker balance를 먼저 한 번 읽어 해당 실행 안에서 재사용한다. balance/credential read 실패는 안전한 error code와 함께 exit `1`로 종료하며, 빈 broker balance를 추정하지 않는다. 성공한 빈 목록만 flat balance로 취급한다.
- source evidence에는 mode(`fetched`/`env`/`break_glass`)와 비밀 없는 label만 기록한다. fetched mode는 KIS + `credential_fetch_enabled=true`에서만 사용하고, break-glass는 명시적 운영자 환경 설정으로 fetch를 건너뛴다. Paper와 env KIS 경로는 fetch하지 않는다.
- reconciliation에서 `unmanaged_position` 또는 `managed_non_holding_divergence`가 보이면 자동 보정·자동 주문·자동 청산이 없다. broker, ledger, slot evidence를 교차 확인하고 warning/slot 상태를 보존한 채 triage한다.
- `unmanaged_position` triage는 `trex-core unmanaged list`로 현재 lifecycle state를, `trex-core unmanaged audit <symbol>`로 lifecycle/operator audit trail을 확인한 뒤 `trex-core unmanaged record <symbol> <action> --evidence-ref ...`로만 disposition을 남긴다.
- remediation은 **dry-run → broker/ledger/slot 교차 확인 → 사장님 명시 승인 → 단일 대상 `sync --live --yes` → 재-reconcile** 순서의 별도 release gate다. 이 문서는 실제 target 선택, live 실행 또는 승인을 부여하지 않는다.
### `report` — 일일 운영 리포트 (REQ-088 / dcy-029, REQ-110/111 / dcy-035)
+25
View File
@@ -0,0 +1,25 @@
# Release image build
운영팀은 release tag의 clean checkout에서 아래 명령으로 이미지를 빌드한다.
```bash
git checkout v0.8.0
./scripts/build_release_image.sh trex-core:0.8.0
```
스크립트는 `pyproject.toml`의 canonical version과 image tag가 일치하는지 확인하고 현재
commit을 OCI `org.opencontainers.image.revision` label로, package version을
`org.opencontainers.image.version` label로 기록한다. Dirty checkout, version이 다른 image
tag, label 불일치, non-root user 불일치 또는 container `--version` smoke test 실패 시 종료
코드 0을 반환하지 않는다.
Registry 경로를 사용할 때도 version tag를 유지한다.
```bash
./scripts/build_release_image.sh registry.example/trex-core:0.8.0
docker push registry.example/trex-core:0.8.0
```
배포 전 `docker image inspect`에서 나온 revision과 image digest를
[`release-provenance-standard.md`](release-provenance-standard.md)의 PRE/POST gate에 기록한다.
이 저장소는 registry publication을 자동화하지 않으므로 push 대상과 권한은 운영팀이 관리한다.
+20 -19
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)
@@ -98,13 +99,14 @@
1. 신규 자산(portfolio_id 에 슬롯 없음) → `inactive` 영구 슬롯 생성(멱등 — `ON CONFLICT (instance_key, portfolio_id)` partial unique).
2. universe 이탈 + inactive → soft-retire(`retired_at` set; 행 보존, 운영 뷰 `universe_detached`). 이탈 + holding/pending → tombstone(그대로 유지 — safety-sell 경로 보존).
3. 가드: universe 빈 응답이면 reconcile skip(degrade 가드); 직전 대비 ≥50% 축소면 retire 보류(`allow_retire=False`, 대량 소실 가드 — dcy-020 OQ-2 D3).
4. 이어서 `reconcile_core_holdings`(REQ-062/121): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유인데 대상 instance에 관리 slot이 없으면 `unmanaged_position`, 동일 instance에 `inactive`/`error` 등 non-holding 관리 slot이 있으면 `managed_non_holding_divergence`를 WARN으로 남긴다. 두 broker-only 분류는 상태·warning persistence·주문·청산·slot 생성의 자동 수정을 하지 않는다.
4. 이어서 `reconcile_core_holdings`(REQ-062/121): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유인데 대상 instance에 관리 slot이 없으면 `unmanaged_position`, 동일 instance에 `inactive`/`error` 등 non-holding 관리 slot이 있으면 `managed_non_holding_divergence`를 WARN으로 남긴다. `managed_non_holding_divergence`는 기존 warning 경로를 유지하고, `unmanaged_position`은 `(instance_key, symbol)` durable lifecycle state/audit에 기록된다. 같은 수량 반복 관측은 `last_seen_at`만 갱신하고 새 detect/change alarm을 만들지 않는다. 어떤 broker-only 분류도 주문·청산·slot 생성·warning 자동 삭제를 하지 않는다.
### Broker provider and operator balance-read barrier (BAT-057 / REQ-119/120)
1. daemon과 `sync`/`verify` CLI는 같은 broker provider를 통해 브로커를 얻는다. `broker=kis` 및 `credential_fetch_enabled=true`일 때만 portfolio-hub의 유효한 인증정보를 fetched source로 사용한다. daemon의 day-start refresh는 이미 같은 KST 날짜에 얻은 source가 있으면 이를 재사용한다.
2. 명시적 `TREX_KIS_BROKER_BREAK_GLASS`는 fetched source를 건너뛰고 env KIS 설정을 사용하는 별도 source다. Paper와 `credential_fetch_enabled=false` KIS 경로는 env/config source이며 credential fetch를 하지 않는다.
3. `sync`/`verify`는 실행당 잔고를 한 번 먼저 검증해 cache한 뒤 그 값만 사용한다. KIS 잔고 읽기 실패는 빈 잔고로 해석하거나 후속 DB/slot write로 진행하지 않고 command boundary에서 안전하게 실패한다. 성공한 빈 잔고(`[]`)만 검증된 flat balance다.
4. Ownership projection이 켜진 mixed-account KIS 경로에서는 주문/전략 조회용 broker가 strategy-owned quantity만 보이도록 balance를 투영할 수 있다. 이때 unmanaged reconciliation은 provider가 별도로 넘기는 authoritative full-balance reader를 사용한다. Missing authority marker, projected/partial reader, self-report failure 또는 balance read failure는 absence resolution authority가 아니며 active unmanaged row를 `resolved`로 바꾸지 않는다.
## Flow: `포지션 정합 검증` (BAT-008 / REQ-031)
@@ -138,17 +140,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 +267,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 +289,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 +348,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.8.0"
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
requires-python = ">=3.12"
dependencies = [
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo_root"
version=$(python3 -c 'import pathlib,tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')
image_ref=${1:-trex-core:$version}
source_commit=$(git rev-parse HEAD)
if [ -n "$(git status --porcelain)" ]; then
echo "release image build requires a clean checkout" >&2
exit 2
fi
case "$image_ref" in
*:"$version") ;;
*) echo "image reference must use the project version tag :$version" >&2; exit 2 ;;
esac
docker build \
--build-arg "SOURCE_COMMIT=$source_commit" \
--build-arg "SOURCE_VERSION=$version" \
--tag "$image_ref" \
.
actual_revision=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image_ref")
actual_version=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image_ref")
actual_user=$(docker image inspect --format '{{ .Config.User }}' "$image_ref")
[ "$actual_revision" = "$source_commit" ]
[ "$actual_version" = "$version" ]
[ "$actual_user" = "10001:10001" ]
docker run --rm "$image_ref" --version
printf 'image=%s\nversion=%s\nrevision=%s\nimage_id=%s\n' \
"$image_ref" "$actual_version" "$actual_revision" \
"$(docker image inspect --format '{{ .Id }}' "$image_ref")"
-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
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Collect release/restart provenance and enforce audit-checkout gates."""
from __future__ import annotations
import argparse
import json
import re
import shlex
import sys
import uuid
from datetime import UTC, datetime
from pathlib import Path
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from trex_core.adapters.persistence.ops_checks import (
collect_release_provenance,
isolate_dirty_checkout_files,
)
_COMMAND_CLASSES = ("deploy", "recovery", "manual-stop", "manual-start", "recreate")
_REQUIRED_CAPTURE_KEYS = frozenset(
{"pending_slots", "reconciliation_slots", "queue_consumers"}
)
_SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
def _record_path(
output_dir: Path,
*,
operation_id: str,
phase: str,
instance: str,
command_class: str,
) -> Path:
for value in (operation_id, phase, instance, command_class):
if not _SAFE_ID.fullmatch(value):
raise ValueError("record filename components must be safe identifiers")
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S.%fZ")
nonce = uuid.uuid4().hex[:12]
return output_dir / (
f"{stamp}--{operation_id}--{phase}--{instance}--{command_class}--{nonce}.json"
)
def _parse_capture_command(raw: str) -> tuple[str, list[str]]:
if "=" not in raw:
raise ValueError("capture command must be KEY=command")
key, command = raw.split("=", 1)
key = key.strip()
parts = shlex.split(command.strip())
if not key or not parts:
raise ValueError("capture command must be KEY=command")
return key, parts
def _validate_capture_command(key: str, command: list[str], *, instance: str) -> None:
executable = Path(command[0]).name
args = command[1:]
expected = {
"pending_slots": ["slots", "--status", "pending", "--instance", instance, "--json"],
"reconciliation_slots": ["slots", "--status", "all", "--instance", instance, "--json"],
"queue_consumers": ["list_consumers"],
}
if key not in expected:
raise ValueError(f"unsupported capture key: {key}")
if key == "queue_consumers":
valid = executable == "rabbitmqctl" and args == expected[key]
else:
valid = executable == "trex-core" and args == expected[key]
if not valid:
raise ValueError(f"capture command is not the approved read-only form for {key}")
def _outside_checkout(path: Path, checkout: Path, *, label: str) -> Path:
resolved = path.resolve()
try:
resolved.relative_to(checkout.resolve())
except ValueError:
return resolved
raise ValueError(f"{label} must be outside the audit checkout")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--actor", required=True)
parser.add_argument("--reason", required=True)
parser.add_argument("--instance", required=True)
parser.add_argument("--operation-id", required=True)
parser.add_argument("--phase", required=True, choices=("pre", "post"))
parser.add_argument("--command-class", required=True, choices=_COMMAND_CLASSES)
parser.add_argument("--checkout-dir", default=".")
parser.add_argument("--expected-oci-revision", required=True)
parser.add_argument("--image-digest", required=True)
parser.add_argument("--runtime-file", action="append", required=True)
parser.add_argument("--broker-log-path", default="/tmp/trex-core.log")
parser.add_argument("--capture-command", action="append", required=True)
parser.add_argument("--output-dir")
parser.add_argument("--isolate-dir")
args = parser.parse_args(argv)
try:
capture_commands: dict[str, list[str]] = {}
for raw in args.capture_command:
key, command = _parse_capture_command(raw)
if key in capture_commands:
raise ValueError(f"duplicate capture key: {key}")
_validate_capture_command(key, command, instance=args.instance)
capture_commands[key] = command
if set(capture_commands) != _REQUIRED_CAPTURE_KEYS:
missing = sorted(_REQUIRED_CAPTURE_KEYS - set(capture_commands))
extra = sorted(set(capture_commands) - _REQUIRED_CAPTURE_KEYS)
raise ValueError(f"capture command keys mismatch: missing={missing}, extra={extra}")
checkout = Path(args.checkout_dir).resolve()
output_default = checkout.parent / f"{checkout.name}-release-provenance"
isolate_default = checkout.parent / f"{checkout.name}-release-provenance-isolation"
output_dir = _outside_checkout(
Path(args.output_dir) if args.output_dir else output_default,
checkout,
label="output directory",
)
isolate_dir = _outside_checkout(
Path(args.isolate_dir) if args.isolate_dir else isolate_default,
checkout,
label="isolation directory",
)
report = collect_release_provenance(
actor=args.actor,
reason=args.reason,
instance=args.instance,
command_class=args.command_class,
operation_id=args.operation_id,
phase=args.phase,
checkout_dir=checkout,
expected_oci_revision=args.expected_oci_revision,
image_digest=args.image_digest,
runtime_files=args.runtime_file,
capture_commands=capture_commands,
broker_log_path=args.broker_log_path,
)
except (RuntimeError, ValueError) as exc:
parser.error(str(exc))
output_dir.mkdir(parents=True, exist_ok=True)
output_path = _record_path(
output_dir,
operation_id=args.operation_id,
phase=args.phase,
instance=args.instance,
command_class=args.command_class,
)
dirty_checkout = bool(report["checkout"]["dirty_paths"])
if dirty_checkout:
report["dirty_checkout_isolation"] = isolate_dirty_checkout_files(
checkout_dir=checkout,
isolate_dir=isolate_dir,
)
# Exclusive create prevents a provenance record from being overwritten.
with output_path.open("x", encoding="utf-8") as fp:
fp.write(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
output_path.chmod(0o444)
print(str(output_path))
if dirty_checkout:
return 2
return 0 if report["gate"]["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -32,7 +32,7 @@ def main() -> int:
)
repo.reconcile_universe(instance, {portfolio_id: symbol})
broker = PaperBroker(fill_after_polls=0)
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
ledger = InMemoryLedger()
execution = ExecutionService(
slots,
+113 -9
View File
@@ -7,6 +7,7 @@ Unit tests run always; the sandbox integration test runs only when KIS
from __future__ import annotations
import os
from datetime import UTC, datetime, timedelta
import httpx
import pytest
@@ -58,7 +59,8 @@ def test_account_scope_hash_separates_endpoint_and_application_identity():
(403, "authorization_failed"),
(422, "broker_rejected"),
(429, "transport_failed"),
(500, "transport_failed"),
(500, "http_5xx"),
(504, "timeout"),
(None, "broker_rejected"),
],
)
@@ -143,7 +145,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"
@@ -347,8 +351,8 @@ def test_get_balance_fails_closed_on_later_transport_and_http_errors(monkeypatch
request = httpx.Request("GET", "https://example.test/balance")
response = httpx.Response(503, request=request)
for failure, expected_code in [
(httpx.ConnectTimeout("unavailable"), "transport_failed"),
(httpx.HTTPStatusError("bad gateway", request=request, response=response), "transport_failed"),
(httpx.ConnectTimeout("unavailable"), "timeout"),
(httpx.HTTPStatusError("bad gateway", request=request, response=response), "http_5xx"),
]:
broker, calls = _balance_broker(monkeypatch, [
_BalanceResponse(_balance_payload([{"pdno": "005930", "hldg_qty": "2"}], fk="A", nk="1"), "F"),
@@ -404,6 +408,29 @@ def test_get_available_cash_uses_cash_only_amount(monkeypatch):
assert b.get_available_cash() == 695_591
@pytest.mark.parametrize(
("failure", "expected_code"),
[
(httpx.ReadTimeout("timed out"), "timeout"),
(
httpx.HTTPStatusError(
"bad gateway",
request=httpx.Request("GET", "https://example.test"),
response=httpx.Response(502, request=httpx.Request("GET", "https://example.test")),
),
"http_5xx",
),
],
)
def test_get_available_cash_classifies_timeout_and_http_5xx(monkeypatch, failure, expected_code):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: (_ for _ in ()).throw(failure))
with pytest.raises(BrokerBalanceError) as raised:
b.get_available_cash()
assert raised.value.code == expected_code
def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
@@ -562,7 +589,9 @@ def test_submit_unknown_confirmed_absent_becomes_retryable_reject(monkeypatch):
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert status.state == "rejected"
assert status.raw == {"reason": "submission_confirmed_absent"}
assert status.raw["reason"] == "submission_confirmed_absent"
assert status.raw["poll_performed"] is True
assert status.raw["poll_count"] == 1
def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
@@ -578,15 +607,90 @@ 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 first.raw["reason"] == "fill_inquiry_transport"
assert "timed out" not in str(first.raw)
assert immediate.state == "accepted"
assert immediate.raw == {"reason": "fill_poll_backoff"}
assert immediate.raw["reason"] == "fill_poll_backoff"
assert immediate.raw["poll_performed"] is False
assert immediate.raw["poll_count"] == 1
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
def test_unresolved_fill_poll_uses_elapsed_bounded_backoff_and_converges(monkeypatch):
wall_now = datetime(2026, 9, 10, 12, 0, tzinfo=UTC)
b = KISBroker(
"12345678-01",
app_key="k",
app_secret="s",
base_url=_MOCK_URL,
clock=lambda: wall_now,
)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
mono = [100.0]
monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: mono[0])
calls = []
terminal = [False]
class Response:
status_code = 200
def json(self):
calls.append(1)
parent = {
"odno": "1234", "orgn_odno": "", "ord_qty": "10",
"tot_ccld_qty": "0", "avg_prvs": "0", "cncl_yn": "",
"rjct_qty": "0", "rmn_qty": "0", "ord_dt": "20260910",
"ord_gno_brno": "91258", "pdno": "005930",
}
child = {
**parent, "odno": "1235", "orgn_odno": "1234",
"cnc_cfrm_qty": "10" if terminal[0] else "0",
}
return {"rt_cd": "0", "output1": [parent, child]}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
submitted_at = wall_now - timedelta(hours=7)
first = b.poll_fill(
"91258:1234", side="buy", symbol="005930",
order_date="20260910", submitted_at=submitted_at,
)
assert first.state == "unknown"
assert first.raw["poll_performed"] is True
assert first.raw["poll_count"] == 1
assert first.raw["backoff_sec"] == 300.0
assert first.raw["stuck_duration_sec"] == 7 * 3600
terminal[0] = True
mono[0] += 299
skipped = b.poll_fill(
"91258:1234", side="buy", symbol="005930",
order_date="20260910", submitted_at=submitted_at,
)
assert skipped.state == "accepted"
assert skipped.raw["last_result_state"] == "unknown"
assert len(calls) == 1
mono[0] += 1
resolved = b.poll_fill(
"91258:1234", side="buy", symbol="005930",
order_date="20260910", submitted_at=submitted_at,
)
assert resolved.state == "cancelled"
assert resolved.raw["poll_count"] == 2
assert len(calls) == 2
+740 -8
View File
@@ -7,7 +7,7 @@ pre-seeded, httpx.get faked) and assert the FillStatus mapping.
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from trex_core.adapters.broker import kis as kis_mod
from trex_core.adapters.broker.kis import KISBroker
@@ -23,7 +23,7 @@ def _broker():
base_url="https://openapivts.koreainvestment.com:29443", # mock endpoint
)
b._token = "tok"
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
b._token_exp = datetime.now(tz=UTC) + timedelta(hours=1)
return b
@@ -52,6 +52,7 @@ def _row(**kw):
"rjct_qty": "0",
"ord_dt": "20260608",
"ord_gno_brno": "06010",
"pdno": "005930",
}
base.update(kw)
return {"rt_cd": "0", "output1": [base]}
@@ -59,13 +60,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 +88,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 +100,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 +112,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 +127,726 @@ 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
# ── Regression: KIS child cancel row (orgn_odno) ─────────────────────────────
def _payload_with_child_cancel(parent_odno: str, child_odno: str, ord_qty: str = "89", symbol: str = "225190"):
"""Simulate KIS returning original row (cncl_yn='') + separate cancel child."""
parent = {
"odno": parent_odno,
"orgn_odno": "",
"ord_qty": ord_qty,
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"rmn_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": symbol,
}
child = {
"odno": child_odno,
"orgn_odno": parent_odno,
"ord_qty": ord_qty,
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"rmn_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": symbol,
}
return {"rt_cd": "0", "output1": [parent, child]}
def test_child_cancel_row_orgn_odno_maps_to_cancelled(monkeypatch):
"""KIS child row with orgn_odno=<original> + cncl_yn=Y → full cancel."""
payload = _payload_with_child_cancel(ODNO, "0000123457")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "cancelled", f"expected cancelled, got {fs.state}"
assert fs.is_terminal_reject
def test_correction_child_row_not_misclassified_as_cancel(monkeypatch):
"""Correction child (cncl_yn!='Y') must not be treated as a cancel.
Issue #81 AC: a correction/replacement child means the order is superseded
but we cannot safely determine terminal status without the replacement
identity → fail-closed as 'unknown' (not 'accepted/open_confirmed').
"""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
correction_child = {
"odno": "0000123458",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "N", # correction, not cancel
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, correction_child]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
# Correction child means replacement identity is unresolved → fail-closed
assert fs.state == "unknown", f"expected unknown, got {fs.state}"
raw = fs.raw if isinstance(fs.raw, dict) else {}
assert raw.get("reason") == "correction_child_present_unresolved"
# Must NOT be open_confirmed — that would allow new orders on the same slot
assert not raw.get("order_open_confirmed", False)
def test_correction_child_cancel_confirmed_quantity_maps_to_cancelled(monkeypatch):
"""KIS cancel-confirm quantity is terminal evidence even when cncl_yn is blank."""
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
parent, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="31")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert fs.state == "cancelled"
assert fs.filled_qty == 0
assert fs.raw["reason"] == "replacement_chain_terminal_cancel"
assert "resolved_broker_order_id" not in fs.raw
def test_live_replacement_remains_fail_closed(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458")
_, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="10", ord_qty="10")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "replacement_live_unresolved"
assert not fs.raw.get("order_open_confirmed", False)
def test_replacement_partial_fill_then_confirmed_cancel(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
_, child = payload["output1"]
child.update(
cncl_yn="",
cnc_cfrm_qty="6",
tot_ccld_qty="4",
rmn_qty="0",
avg_prvs="70100",
)
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "partially_filled"
assert fs.filled_qty == 4
assert fs.avg_price == 70100.0
assert fs.is_partial_cancel
assert fs.raw["reason"] == "replacement_chain_terminal_cancel"
def test_repeated_cumulative_fill_across_generations_is_not_ambiguous(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
parent, child = payload["output1"]
parent.update(tot_ccld_qty="4", rmn_qty="0", avg_prvs="70100")
child.update(
cncl_yn="",
cnc_cfrm_qty="6",
tot_ccld_qty="4",
rmn_qty="0",
avg_prvs="70100",
)
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "partially_filled"
assert fs.filled_qty == 4
assert fs.avg_price == 70100.0
assert fs.is_partial_cancel
def test_multi_generation_replacement_chain_terminal_cancel(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
parent, replacement = payload["output1"]
replacement.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
terminal = {
**replacement,
"odno": "0000123459",
"orgn_odno": replacement["odno"],
"cnc_cfrm_qty": "10",
}
payload["output1"] = [terminal, parent, replacement]
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "cancelled"
assert fs.raw["replacement_depth"] == 2
def test_replacement_chain_branching_is_ambiguous(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
parent, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="10")
sibling = {**child, "odno": "0000123459"}
payload["output1"] = [parent, child, sibling]
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "replacement_chain_ambiguous"
def test_zero_remaining_without_cancel_confirmation_remains_unknown(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
_, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "correction_child_present_unresolved"
def test_stable_zero_fill_replacement_chain_converges_after_grace(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
parent, child = payload["output1"]
parent.update(rmn_qty="0")
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
monotonic_now = [100.0]
monkeypatch.setattr(kis_mod, "monotonic", lambda: monotonic_now[0])
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
broker = _broker()
first = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert first.state == "unknown"
assert first.raw["reason"] == "correction_child_present_unresolved"
monotonic_now[0] += 90
confirmed = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert confirmed.state == "cancelled"
assert confirmed.raw["reason"] == "replacement_chain_stable_zero_terminal"
def test_zero_fill_terminal_grace_resets_when_chain_changes(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
parent, child = payload["output1"]
parent.update(rmn_qty="0")
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
monotonic_now = [100.0]
monkeypatch.setattr(kis_mod, "monotonic", lambda: monotonic_now[0])
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
broker = _broker()
first = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert first.state == "unknown"
monotonic_now[0] += 90
child["rmn_qty"] = "31"
changed = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert changed.raw["reason"] == "replacement_live_unresolved"
monotonic_now[0] += 90
child["rmn_qty"] = "0"
restarted_grace = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert restarted_grace.state == "unknown"
monotonic_now[0] += 90
confirmed = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert confirmed.state == "cancelled"
def test_incomplete_cancel_confirmation_remains_unknown(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
_, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="3", rmn_qty="0")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "correction_child_present_unresolved"
def test_partial_fill_with_child_cancel_row(monkeypatch):
"""Partial fill on parent + cancel child → partial_cancel."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "4",
"avg_prvs": "70000",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
child = {
"odno": "0000123459",
"orgn_odno": ODNO,
"ord_qty": "6",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "partially_filled"
assert fs.cancelled and fs.is_partial_cancel
assert fs.filled_qty == 4
# ── Regression: identity mismatch / ambiguous child rows ─────────────────────
def test_child_cancel_mismatched_branch_is_ignored(monkeypatch):
"""Cancel child from a different branch (ord_gno_brno) must not affect parent."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
child_wrong_branch = {
"odno": "0000199999",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "99999", # different branch
"pdno": "225190",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child_wrong_branch]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
# Child has wrong branch → not a matching cancel → parent stays accepted
assert fs.state == "accepted", f"expected accepted (mismatch branch), got {fs.state}"
def test_child_cancel_mismatched_symbol_is_ignored(monkeypatch):
"""Cancel child with different symbol (pdno) must not affect parent."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
child_wrong_symbol = {
"odno": "0000199998",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "000660", # different symbol
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child_wrong_symbol]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "accepted", f"expected accepted (mismatch symbol), got {fs.state}"
def test_child_cancel_mismatched_date_is_ignored(monkeypatch):
"""Cancel child with different ord_dt must not affect parent."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
child_wrong_date = {
"odno": "0000199997",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"ord_dt": "20260831", # different date
"ord_gno_brno": "06010",
"pdno": "225190",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child_wrong_date]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "accepted", f"expected accepted (mismatch date), got {fs.state}"
def test_ambiguous_multiple_child_cancel_rows_return_unknown(monkeypatch):
"""Two matching cancel children → ambiguous identity → fail-closed as unknown."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
child1 = {**parent, "odno": "0000123460", "orgn_odno": ODNO, "cncl_yn": "Y", "ord_qty": "10"}
child2 = {**parent, "odno": "0000123461", "orgn_odno": ODNO, "cncl_yn": "Y", "ord_qty": "10"}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent, child1, child2]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown", f"expected unknown (ambiguous children), got {fs.state}"
raw = fs.raw if isinstance(fs.raw, dict) else {}
assert raw.get("reason") == "child_cancel_ambiguous"
# ── Regression: parent row identity mismatch with request ────────────────────
def test_parent_row_wrong_branch_returns_unknown(monkeypatch):
"""Parent row with wrong ord_gno_brno (branch) relative to broker_order_id → unknown."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "99999", # wrong branch — request has 06010
"pdno": "225190",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent]})
)
# broker_order_id encodes branch 06010, but row has 99999
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown", f"expected unknown (wrong parent branch), got {fs.state}"
raw = fs.raw if isinstance(fs.raw, dict) else {}
assert "mismatch" in raw.get("reason", "")
def test_parent_row_wrong_date_returns_unknown(monkeypatch):
"""Parent row with wrong ord_dt relative to order_date arg → unknown."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260831", # wrong date — request has 20260901
"ord_gno_brno": "06010",
"pdno": "225190",
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown", f"expected unknown (wrong parent date), got {fs.state}"
raw = fs.raw if isinstance(fs.raw, dict) else {}
assert "mismatch" in raw.get("reason", "")
def test_parent_row_wrong_symbol_returns_unknown(monkeypatch):
"""Parent row with wrong pdno (symbol) relative to symbol arg → unknown."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "000660", # wrong symbol — request has 225190
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown", f"expected unknown (wrong parent symbol), got {fs.state}"
raw = fs.raw if isinstance(fs.raw, dict) else {}
assert "mismatch" in raw.get("reason", "")
def test_correction_child_wrong_identity_is_not_treated_as_correction(monkeypatch):
"""Correction child with wrong branch/date/symbol must not trigger correction_child_present_unresolved."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
unrelated_correction = {
"odno": "0000199990",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "N",
"rjct_qty": "0",
"ord_dt": "20260831", # different date → not related to our request
"ord_gno_brno": "99999", # different branch
"pdno": "000660", # different symbol
}
monkeypatch.setattr(
kis_mod.httpx,
"get",
_fake_get({"rt_cd": "0", "output1": [parent, unrelated_correction]}),
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
# Unrelated correction must not cause correction_child_present_unresolved
assert fs.state == "accepted", f"expected accepted (unrelated correction), got {fs.state}"
raw = fs.raw if isinstance(fs.raw, dict) else {}
assert raw.get("reason") != "correction_child_present_unresolved"
# ── Regression: parent identity boundary cases (missing / wrong-first / ambiguous) ─
def test_parent_missing_identity_fields_returns_unknown(monkeypatch):
"""Parent row with missing pdno and ord_dt → treated as identity mismatch (fail-closed)."""
parent_no_identity = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y", # would be cancelled if accepted
"rjct_qty": "0",
# ord_dt, ord_gno_brno, pdno intentionally absent
}
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent_no_identity]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
# Missing identity → fail-closed, not terminal
assert fs.state == "unknown", f"expected unknown (missing identity), got {fs.state}"
assert not fs.is_terminal_reject
def test_child_cancel_missing_identity_fields_not_accepted_as_cancel(monkeypatch):
"""Cancel child with missing identity fields must NOT be accepted as matching child."""
parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
child_no_identity = {
"odno": "0000123461",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
# ord_dt, ord_gno_brno, pdno intentionally absent
}
monkeypatch.setattr(
kis_mod.httpx,
"get",
_fake_get({"rt_cd": "0", "output1": [parent, child_no_identity]}),
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
# Child has missing identity → not a matching cancel; parent has no fill → accepted
assert fs.state == "accepted", f"expected accepted (child missing identity), got {fs.state}"
def test_wrong_parent_first_exact_parent_later_finds_cancel(monkeypatch):
"""When the wrong-branch parent comes first, the exact parent+cancel child later must win."""
wrong_parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "99999", # wrong branch — comes first in list
"pdno": "225190",
}
exact_parent = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010", # correct branch — comes second
"pdno": "225190",
}
child = {
"odno": "0000123462",
"orgn_odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "Y",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
monkeypatch.setattr(
kis_mod.httpx,
"get",
_fake_get({"rt_cd": "0", "output1": [wrong_parent, exact_parent, child]}),
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
# Must find the exact parent + cancel child even though wrong parent comes first
assert fs.state == "cancelled", f"expected cancelled (exact parent later), got {fs.state}"
def test_ambiguous_exact_parents_returns_unknown(monkeypatch):
"""Two rows with identical exact identity → ambiguous parent → unknown."""
parent1 = {
"odno": ODNO,
"orgn_odno": "",
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "",
"rjct_qty": "0",
"ord_dt": "20260901",
"ord_gno_brno": "06010",
"pdno": "225190",
}
parent2 = {**parent1} # exact duplicate
monkeypatch.setattr(
kis_mod.httpx, "get", _fake_get({"rt_cd": "0", "output1": [parent1, parent2]})
)
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown", f"expected unknown (ambiguous parents), got {fs.state}"
raw = fs.raw if isinstance(fs.raw, dict) else {}
assert raw.get("reason") == "parent_identity_ambiguous"
+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
# ---------------------------------------------------------------------------
@@ -94,7 +94,8 @@ def test_client_errors_are_permanent(status):
def test_outcome_parses_cash_quantity_after_on_new():
"""dcy-018 / REQ-017: option D authoritative cash/quantity from 201 response."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(201, json={"cash_balance_after": 300000.0,
return httpx.Response(201, json={"status": "new",
"cash_balance_after": 300000.0,
"asset_quantity_after": 10})
out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade())
@@ -102,12 +103,21 @@ def test_outcome_parses_cash_quantity_after_on_new():
assert out.cash_balance_after == 300000.0 and out.asset_quantity_after == 10.0
def test_outcome_parses_after_on_duplicate():
@pytest.mark.parametrize(
"duplicate_marker",
[
{"status": "duplicate"}, # portfolio-hub canonical response
{"duplicate": True}, # backward-compatible legacy response
],
)
def test_outcome_parses_after_on_duplicate(duplicate_marker):
"""duplicate(200) also carries current authoritative values (OQ-2 b)."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"duplicate": True,
"cash_balance_after": 50000.0,
"asset_quantity_after": 3})
return httpx.Response(200, json={
**duplicate_marker,
"cash_balance_after": 50000.0,
"asset_quantity_after": 3,
})
out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade())
assert out.result is LedgerWriteResult.DUPLICATE_OK
@@ -0,0 +1,275 @@
"""PostgreSQL auto-reconcile transaction sequencing tests."""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from uuid import uuid4
import psycopg
import pytest
from trex_core.adapters.persistence.auto_reconcile import PostgreSQLAutoReconcileStore
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
from trex_core.adapters.portfolio.reconcile_warning_store import (
PostgreSQLReconcileWarningStore,
)
from trex_core.application.auto_reconcile import AutoReconcileCommand
from trex_core.domain.slot import SlotService, SlotState
class FakeCursor:
def __init__(self, rowcounts=(1, 1), fail_on_insert=False):
self.rowcounts = iter(rowcounts)
self.rowcount = -1
self.fail_on_insert = fail_on_insert
self.calls = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, sql, params=None):
normalized = " ".join(sql.split())
self.calls.append((normalized, params))
if normalized.startswith("UPDATE daily_slot"):
self.rowcount = next(self.rowcounts)
elif normalized.startswith("DELETE FROM slot_reconcile_warning"):
self.rowcount = next(self.rowcounts)
elif normalized.startswith("INSERT INTO slot_transition_log") and self.fail_on_insert:
raise RuntimeError("audit insert failed")
class FakeConnection:
def __init__(self, cursor):
self._cursor = cursor
self.commits = 0
self.rollbacks = 0
def cursor(self):
return self._cursor
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
def close(self):
pass
class FakeConnectionContext:
def __init__(self, conn):
self.conn = conn
def __enter__(self):
return self.conn
def __exit__(self, exc_type, exc, tb):
return False
def _command():
return AutoReconcileCommand(
slot_id=uuid4(),
instance_key="inst-a",
portfolio_id="pf-a",
symbol="019685",
expected_qty=1452.0,
expected_avg_price=2100.0,
target_qty=726.0,
target_avg_price=2172.46829187,
hub_quantity=726.0,
broker_quantity=726,
evidence_observed_at=datetime.now(tz=UTC),
)
def _install(monkeypatch, conn):
module = SimpleNamespace(connect=lambda dsn, autocommit=False: FakeConnectionContext(conn))
monkeypatch.setattr(
"trex_core.adapters.persistence.auto_reconcile._require_psycopg",
lambda _module=None: module,
)
def test_postgresql_auto_reconcile_commits_update_b_clear_and_audit(monkeypatch):
cursor = FakeCursor(rowcounts=(1, 1))
conn = FakeConnection(cursor)
_install(monkeypatch, conn)
assert PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
assert conn.commits == 1
assert conn.rollbacks == 0
statements = [sql for sql, _params in cursor.calls]
assert statements[0].startswith("SELECT pg_advisory_xact_lock")
assert "status='holding'" in statements[1]
assert "avg_price IS NOT DISTINCT FROM" in statements[1]
assert "broker_order_id IS NULL" in statements[1]
assert statements[2].startswith("DELETE FROM slot_reconcile_warning")
assert cursor.calls[2][1][1] == "B"
assert statements[3].startswith("INSERT INTO slot_transition_log")
assert cursor.calls[3][1][2:4] == (
"hub_broker_matched_core_mirror_stale",
"auto_reconcile",
)
def test_postgresql_auto_reconcile_cas_failure_rolls_back_without_warning_or_audit(monkeypatch):
cursor = FakeCursor(rowcounts=(0,))
conn = FakeConnection(cursor)
_install(monkeypatch, conn)
assert not PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
assert conn.commits == 0
assert conn.rollbacks == 1
assert len(cursor.calls) == 2
def test_postgresql_auto_reconcile_audit_failure_rolls_back_slot_and_warning(monkeypatch):
cursor = FakeCursor(rowcounts=(1, 1), fail_on_insert=True)
conn = FakeConnection(cursor)
_install(monkeypatch, conn)
with pytest.raises(RuntimeError, match="audit insert failed"):
PostgreSQLAutoReconcileStore("postgresql://unused").apply_holding_mirror(_command())
assert conn.commits == 0
assert conn.rollbacks == 1
assert len(cursor.calls) == 4
def _seed_real_transaction_state(pg_clean):
repo = PostgreSQLSlotRepository(pg_clean)
service = SlotService(repo, instance_key="inst-a")
warning = PostgreSQLReconcileWarningStore(pg_clean)
repo.reconcile_universe("inst-a", {"pf-a": "019685"})
slot = repo.find_slot_by_portfolio("inst-a", "pf-a")
assert slot is not None
pending = service.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
symbol="019685",
)
holding = service.transit_slot(
pending.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
qty=1452.0,
avg_price=2100.0,
)
warning.set_warned(
str(holding.slot_id),
portfolio_id="pf-a",
case_code="B",
actor="reconcile",
)
warning.set_warned(
str(holding.slot_id),
portfolio_id="pf-a",
case_code="C",
actor="reconcile",
)
command = AutoReconcileCommand(
slot_id=holding.slot_id,
instance_key="inst-a",
portfolio_id="pf-a",
symbol="019685",
expected_qty=1452.0,
expected_avg_price=2100.0,
target_qty=726.0,
target_avg_price=2172.46829187,
hub_quantity=726.0,
broker_quantity=726,
evidence_observed_at=datetime.now(tz=UTC),
)
return repo, warning, holding, command
def test_postgresql_auto_reconcile_real_transaction_commit(pg_clean):
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
assert PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
current = repo.fetch_slot(holding.slot_id)
assert current is not None
assert current.status is SlotState.HOLDING
assert current.qty == 726.0
assert current.avg_price == pytest.approx(2172.46829187)
info = warning.get_warning_info(str(holding.slot_id))
assert info is not None
assert info["case_codes"] == ["C"]
with psycopg.connect(pg_clean) as conn:
row = conn.execute(
"SELECT actor, reason FROM slot_transition_log "
"WHERE slot_id = %s ORDER BY ts DESC LIMIT 1",
(holding.slot_id,),
).fetchone()
assert row == ("auto_reconcile", "hub_broker_matched_core_mirror_stale")
def test_postgresql_auto_reconcile_real_transaction_audit_failure_rolls_back(pg_clean):
repo, warning, holding, command = _seed_real_transaction_state(pg_clean)
with psycopg.connect(pg_clean) as conn:
before_audit_row = conn.execute(
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
(holding.slot_id,),
).fetchone()
assert before_audit_row is not None
before_audit_count = before_audit_row[0]
with psycopg.connect(pg_clean, autocommit=True) as conn:
conn.execute(
"""
CREATE FUNCTION trex_test_fail_auto_reconcile_audit()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'forced auto-reconcile audit failure';
END
$$
"""
)
conn.execute(
"""
CREATE TRIGGER trex_test_fail_auto_reconcile_audit
BEFORE INSERT ON slot_transition_log
FOR EACH ROW WHEN (NEW.actor = 'auto_reconcile')
EXECUTE FUNCTION trex_test_fail_auto_reconcile_audit()
"""
)
try:
with pytest.raises(psycopg.errors.RaiseException, match="forced auto-reconcile"):
PostgreSQLAutoReconcileStore(pg_clean).apply_holding_mirror(command)
current = repo.fetch_slot(holding.slot_id)
assert current is not None
assert current.qty == 1452.0
assert current.avg_price == 2100.0
info = warning.get_warning_info(str(holding.slot_id))
assert info is not None
assert info["case_codes"] == ["B", "C"]
with psycopg.connect(pg_clean) as conn:
after_audit_row = conn.execute(
"SELECT count(*) FROM slot_transition_log WHERE slot_id = %s",
(holding.slot_id,),
).fetchone()
assert after_audit_row is not None
after_audit_count = after_audit_row[0]
assert after_audit_count == before_audit_count
finally:
with psycopg.connect(pg_clean, autocommit=True) as conn:
conn.execute(
"DROP TRIGGER IF EXISTS trex_test_fail_auto_reconcile_audit ON slot_transition_log"
)
conn.execute("DROP FUNCTION IF EXISTS trex_test_fail_auto_reconcile_audit()")
@@ -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
@@ -6,11 +6,18 @@ iteration: EXPECTED_SLOT_FIELDS (code contract) vs daily_slot actual columns
"""
from __future__ import annotations
from datetime import UTC, datetime
import psycopg
import pytest
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,28 +27,31 @@ _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
"unmanaged_position_state", # GitHub #85 unmanaged lifecycle state
"unmanaged_position_audit", # GitHub #85 unmanaged lifecycle audit
}
def _columns(dsn: str, table: str) -> set[str]:
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = %s",
(table,),
)
return {r[0] for r in cur.fetchall()}
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = %s",
(table,),
)
return {r[0] for r in cur.fetchall()}
def test_migrations_create_expected_tables(pg_clean):
assert migration_files(), "no migration files found"
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT table_name FROM information_schema.tables WHERE table_schema='public'"
)
tables = {r[0] for r in cur.fetchall()}
with psycopg.connect(pg_clean) as conn, conn.cursor() as cur:
cur.execute(
"SELECT table_name FROM information_schema.tables WHERE table_schema='public'"
)
tables = {r[0] for r in cur.fetchall()}
assert _EXPECTED_TABLES <= tables
@@ -51,6 +61,24 @@ 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, 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,132 @@ 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_sequence_includes_canonical_unmanaged_position_number():
names = [path.name for path in migration_files()]
assert "030_create_unmanaged_position_state.sql" in names
assert "028_create_unmanaged_position_state.sql" not in names
prefixes = [name.split("_", 1)[0] for name in names]
assert len(prefixes) == len(set(prefixes))
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,
conn.cursor() as cur,
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, instance_key="test-instance")
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=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=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,
conn.cursor() as cur,
pytest.raises(psycopg.errors.CheckViolation),
):
cur.execute(
"UPDATE ledger_outbox SET venue='SOR' WHERE correlation_id=%s",
(normal.correlation_id,),
)
def test_migration_029_fail_closes_unowned_legacy_outbox_rows(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
trade = LedgerTrade(
asset_id="PF-LEGACY",
order_id="O-LEGACY",
execution_id="E-LEGACY",
side="buy",
qty=1,
price=1000,
executed_at=datetime(2026, 9, 12, tzinfo=UTC),
)
outbox.enqueue(trade)
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
cur.execute("ALTER TABLE ledger_outbox ALTER COLUMN instance_key DROP NOT NULL")
cur.execute("ALTER TABLE ledger_outbox ALTER COLUMN instance_key DROP DEFAULT")
cur.execute(
"UPDATE ledger_outbox SET instance_key=NULL WHERE correlation_id=%s",
(trade.correlation_id,),
)
conn.commit()
apply_migrations(pg_clean)
stored = outbox.get(trade.correlation_id)
assert stored is not None
assert stored.instance_key == "__unowned__"
assert outbox.list_pending() == []
+223 -4
View File
@@ -7,13 +7,21 @@ find_ledger_gap_slots detects only the gaps.
"""
from __future__ import annotations
import subprocess
import uuid
from datetime import date, datetime, timezone
from datetime import UTC, date, datetime
from pathlib import Path
from trex_core.adapters.persistence.ops_checks import find_ledger_gap_slots
import pytest
from trex_core.adapters.persistence.ops_checks import (
collect_release_provenance,
find_ledger_gap_slots,
isolate_dirty_checkout_files,
)
_DAY = date(2026, 6, 10)
_TS = datetime(2026, 6, 10, 6, 0, tzinfo=timezone.utc)
_TS = datetime(2026, 6, 10, 6, 0, tzinfo=UTC)
def _insert_slot(conn, *, status, portfolio_id, symbol="005930"):
@@ -79,10 +87,221 @@ def test_no_gaps_when_all_recorded(pg_clean):
def test_date_scope_filters_by_sell_fill_day(pg_clean):
import psycopg
dsn = pg_clean
other_day_ts = datetime(2026, 6, 9, 6, 0, tzinfo=timezone.utc)
other_day_ts = datetime(2026, 6, 9, 6, 0, tzinfo=UTC)
with psycopg.connect(dsn, autocommit=True) as conn:
sid = _insert_slot(conn, status="inactive", portfolio_id="PF-OLD")
_insert_sell_fill(conn, sid, ts=other_day_ts)
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
gaps = find_ledger_gap_slots(dsn, trading_date=date(2026, 6, 9))
assert {str(g["slot_id"]) for g in gaps} == {sid}
def _init_git_repo(tmp_path: Path) -> tuple[Path, str]:
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True, capture_output=True)
(repo / "tracked.txt").write_text("v1\n", encoding="utf-8")
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True)
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
subprocess.run(["git", "checkout", "--detach", head], cwd=repo, check=True, capture_output=True)
return repo, head
def _provenance_kwargs(repo: Path, head: str) -> dict:
broker_log = repo.parent / "broker.log"
broker_log.write_text("event=order_submit token=must-not-leak\n", encoding="utf-8")
bindir = repo.parent / "bin"
bindir.mkdir(exist_ok=True)
trex = bindir / "trex-core"
rabbit = bindir / "rabbitmqctl"
trex.write_text("#!/bin/sh\nprintf '{}\\n'\n", encoding="utf-8")
rabbit.write_text("#!/bin/sh\nprintf 'consumer\\n'\n", encoding="utf-8")
trex.chmod(0o755)
rabbit.chmod(0o755)
instance = "inst-a"
return {
"actor": "operator-a",
"reason": "deploy",
"instance": instance,
"command_class": "manual-start",
"operation_id": "op-1",
"phase": "pre",
"checkout_dir": repo,
"expected_oci_revision": head,
"image_digest": "sha256:" + ("a" * 64),
"runtime_files": ["tracked.txt"],
"capture_commands": {
"pending_slots": [
str(trex), "slots", "--status", "pending", "--instance", instance, "--json"
],
"reconciliation_slots": [
str(trex), "slots", "--status", "all", "--instance", instance, "--json"
],
"queue_consumers": [str(rabbit), "list_consumers"],
},
"broker_log_path": broker_log,
}
def test_collect_release_provenance_passes_on_clean_checkout(tmp_path):
repo, head = _init_git_repo(tmp_path)
report = collect_release_provenance(**_provenance_kwargs(repo, head))
assert report["checkout"]["git_clean"] is True
assert report["checkout"]["detached_head"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is True
assert report["runtime_file_verification"][0]["matches_expected_revision"] is True
assert report["gate"]["passed"] is True
assert report["operation_id"] == "op-1"
assert report["phase"] == "pre"
rendered = str(report)
assert "must-not-leak" not in rendered
assert report["state_capture"]["broker_side_effect_logs"]["matches"][0]["event"] == "order_submit"
def test_collect_release_provenance_blocks_attached_checkout(tmp_path):
repo, head = _init_git_repo(tmp_path)
subprocess.run(["git", "switch", "-c", "audit-attached"], cwd=repo, check=True, capture_output=True)
report = collect_release_provenance(**_provenance_kwargs(repo, head))
assert report["checkout"]["git_clean"] is True
assert report["checkout"]["detached_head"] is False
assert report["state_capture"]["command_outputs"] == {}
assert report["gate"]["passed"] is False
def test_collect_release_provenance_blocks_on_revision_or_runtime_mismatch(tmp_path):
repo, old_head = _init_git_repo(tmp_path)
(repo / "tracked.txt").write_text("v2\n", encoding="utf-8")
subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "v2"], cwd=repo, check=True, capture_output=True)
kwargs = _provenance_kwargs(repo, old_head)
report = collect_release_provenance(**kwargs)
assert report["checkout"]["git_clean"] is True
assert report["artifact_identity"]["oci_revision_matches_head"] is False
assert report["runtime_file_verification"][0]["matches_expected_revision"] is False
assert report["gate"]["passed"] is False
def test_isolate_dirty_checkout_files_copies_dirty_and_untracked(tmp_path):
repo, _ = _init_git_repo(tmp_path)
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
(repo / "new.txt").write_text("new\n", encoding="utf-8")
isolated = tmp_path / "isolated"
manifest = isolate_dirty_checkout_files(checkout_dir=repo, isolate_dir=isolated)
assert set(manifest["dirty_paths"]) == {"tracked.txt", "new.txt"}
assert set(manifest["copied_paths"]) == {"tracked.txt", "new.txt"}
snapshot = Path(manifest["isolate_dir"])
assert snapshot.parent == isolated
assert (snapshot / "tracked.txt").read_text(encoding="utf-8") == "dirty\n"
assert (snapshot / "new.txt").read_text(encoding="utf-8") == "new\n"
assert (snapshot / "manifest.json").is_file()
def test_collect_release_provenance_rejects_runtime_path_escape(tmp_path):
repo, head = _init_git_repo(tmp_path)
kwargs = _provenance_kwargs(repo, head)
kwargs["runtime_files"] = ["../outside.txt"]
report = collect_release_provenance(**kwargs)
runtime = report["runtime_file_verification"][0]
assert runtime["path_within_checkout"] is False
assert runtime["matches_expected_revision"] is False
assert report["gate"]["passed"] is False
def test_collect_release_provenance_reports_git_failure_context(tmp_path):
not_repo = tmp_path / "not-repo"
not_repo.mkdir()
trex = tmp_path / "trex-core"
rabbit = tmp_path / "rabbitmqctl"
with pytest.raises(RuntimeError, match="git command failed in checkout="):
collect_release_provenance(
actor="operator-a",
reason="deploy",
instance="inst-a",
command_class="manual-start",
operation_id="op-1",
phase="pre",
checkout_dir=not_repo,
expected_oci_revision="0" * 40,
image_digest="sha256:" + ("a" * 64),
runtime_files=["tracked.txt"],
capture_commands={
"pending_slots": [
str(trex), "slots", "--status", "pending", "--instance", "inst-a", "--json"
],
"reconciliation_slots": [
str(trex), "slots", "--status", "all", "--instance", "inst-a", "--json"
],
"queue_consumers": [str(rabbit), "list_consumers"],
},
)
def test_dirty_checkout_blocks_before_capture_commands_run(tmp_path):
repo, head = _init_git_repo(tmp_path)
marker = tmp_path / "capture-ran"
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
kwargs = _provenance_kwargs(repo, head)
for command in kwargs["capture_commands"].values():
executable = Path(command[0])
executable.write_text(f"#!/bin/sh\ntouch {marker}\n", encoding="utf-8")
executable.chmod(0o755)
report = collect_release_provenance(**kwargs)
assert report["gate"]["passed"] is False
assert report["gate"]["deployment_blocked"] is True
assert report["state_capture"]["command_outputs"] == {}
assert not marker.exists()
def test_capture_failure_or_missing_broker_log_blocks_gate(tmp_path):
repo, head = _init_git_repo(tmp_path)
kwargs = _provenance_kwargs(repo, head)
trex = Path(kwargs["capture_commands"]["pending_slots"][0])
trex.write_text("#!/bin/sh\nexit 3\n", encoding="utf-8")
trex.chmod(0o755)
kwargs["broker_log_path"] = tmp_path / "missing.log"
report = collect_release_provenance(**kwargs)
assert report["gate"]["passed"] is False
assert report["gate"]["state_captures_succeeded"] is False
assert report["gate"]["broker_log_captured"] is False
def test_empty_broker_log_is_explicitly_captured_as_no_matching_events(tmp_path):
repo, head = _init_git_repo(tmp_path)
kwargs = _provenance_kwargs(repo, head)
Path(kwargs["broker_log_path"]).write_bytes(b"")
report = collect_release_provenance(**kwargs)
broker = report["state_capture"]["broker_side_effect_logs"]
assert broker["capture_status"] == "captured_empty"
assert broker["file_sha256"] == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
assert broker["matches"] == []
assert report["gate"]["passed"] is True
@pytest.mark.parametrize(
("field", "value"),
[
("expected_oci_revision", None),
("image_digest", "not-a-digest"),
("runtime_files", []),
("capture_commands", {}),
("instance", "../escape"),
("reason", "password=must-not-be-recorded"),
],
)
def test_required_provenance_inputs_fail_closed(tmp_path, field, value):
repo, head = _init_git_repo(tmp_path)
kwargs = _provenance_kwargs(repo, head)
kwargs[field] = value
with pytest.raises((TypeError, ValueError)):
collect_release_provenance(**kwargs)
@@ -4,41 +4,53 @@ Skipped without TREX_POSTGRESQL_TEST_DSN (same gate as other PG suites).
"""
from __future__ import annotations
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
import pytest
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.postgresql import PostgreSQLLedgerOutbox
from trex_core.application.ledger_outbox import (
LedgerWriter,
OutboxClaimLostError,
OutboxOutcome,
WorkItemStatus,
)
from trex_core.application.ledger_port import LedgerTrade
from trex_core.application.ledger_port import (
LedgerRetryableError,
LedgerTrade,
LedgerWriteOutcome,
LedgerWriteResult,
)
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=UTC),
venue="NXT", order_venue="SOR",
)
def test_enqueue_persists_and_is_idempotent(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
a = outbox.enqueue(_trade())
b = outbox.enqueue(_trade())
assert a.correlation_id == b.correlation_id
pending = outbox.list_pending()
assert len(pending) == 1
assert pending[0].status is WorkItemStatus.PENDING
assert pending[0].instance_key == "inst-a"
assert pending[0].trade.venue == "NXT"
assert pending[0].trade.order_venue == "SOR"
def test_writer_completes_persisted_item(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
writer = LedgerWriter(outbox, ledger, instance_key="inst-a")
outbox.enqueue(_trade(execution_id="pg-1"))
stats = writer.run_once()
assert stats.completed == 1 and ledger.count() == 1
@@ -48,9 +60,9 @@ def test_writer_completes_persisted_item(pg_clean):
def test_writer_marks_conflict_blocked_on_pg(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
ledger = InMemoryLedger(conflict_executions={"pg-x"})
writer = LedgerWriter(outbox, ledger)
writer = LedgerWriter(outbox, ledger, instance_key="inst-a")
outbox.enqueue(_trade(execution_id="pg-x"))
writer.run_once()
row = outbox.get("PF-1:ORD-1:pg-x")
@@ -59,10 +71,10 @@ def test_writer_marks_conflict_blocked_on_pg(pg_clean):
def test_writer_retries_on_pg(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
ledger = InMemoryLedger(fail_n_then_succeed=1)
# dcy-015: base 0 => next_retry_at == now, so the row is immediately due again.
writer = LedgerWriter(outbox, ledger, retry_base_sec=0.0)
writer = LedgerWriter(outbox, ledger, retry_base_sec=0.0, instance_key="inst-a")
outbox.enqueue(_trade(execution_id="pg-r"))
writer.run_once()
row1 = outbox.get("PF-1:ORD-1:pg-r")
@@ -77,9 +89,9 @@ def test_writer_retries_on_pg(pg_clean):
def test_writer_marks_delivery_failed_permanent_on_pg(pg_clean):
"""dcy-015 / REQ-019: 404/permanent → terminal delivery_failed persisted
(migration 007 status enum + last_outcome). No retry."""
outbox = PostgreSQLLedgerOutbox(pg_clean)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
ledger = InMemoryLedger(permanent_executions={"pg-perm"})
writer = LedgerWriter(outbox, ledger)
writer = LedgerWriter(outbox, ledger, instance_key="inst-a")
outbox.enqueue(_trade(execution_id="pg-perm"))
s = writer.run_once()
assert s.delivery_failed == 1
@@ -92,22 +104,199 @@ def test_writer_marks_delivery_failed_permanent_on_pg(pg_clean):
def test_pending_cash_delta_on_pg(pg_clean):
"""dcy-018 / REQ-029: estimated cash delta of pending rows summed by asset (SQL)."""
outbox = PostgreSQLLedgerOutbox(pg_clean)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
outbox.enqueue(LedgerTrade(asset_id="PF-1", order_id="O-1", execution_id="cd-buy",
side="buy", qty=10, price=1000.0, fee=1.0, tax=0.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc)))
executed_at=datetime(2026, 6, 4, tzinfo=UTC)))
deltas = outbox.pending_cash_delta()
assert deltas["PF-1"] == -(10 * 1000.0 + 1.0) # buy => cash down by notional+fee
def test_backoff_row_skipped_by_pending_scan_on_pg(pg_clean):
"""dcy-015 / REQ-019: a future next_retry_at excludes the row from list_pending."""
outbox = PostgreSQLLedgerOutbox(pg_clean)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
ledger = InMemoryLedger(fail_n_then_succeed=99)
# large base => next_retry_at well in the future
writer = LedgerWriter(outbox, ledger, retry_base_sec=600.0, max_attempts=99)
writer = LedgerWriter(
outbox, ledger, retry_base_sec=600.0, max_attempts=99, instance_key="inst-a"
)
outbox.enqueue(_trade(execution_id="pg-bo"))
writer.run_once() # -> retryable, next_retry_at ~ now + 600s
row = outbox.get("PF-1:ORD-1:pg-bo")
assert row.status is WorkItemStatus.PENDING and row.next_retry_at is not None
assert outbox.list_pending() == [] # still backing off -> skipped
def test_pending_claims_are_isolated_per_instance(pg_clean):
ledger = InMemoryLedger()
outbox_a = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
outbox_b = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-b")
writer_a = LedgerWriter(outbox_a, ledger, instance_key="inst-a")
writer_b = LedgerWriter(outbox_b, ledger, instance_key="inst-b")
row_a = outbox_a.enqueue(_trade(execution_id="pg-a"))
row_b = outbox_b.enqueue(_trade(execution_id="pg-b"))
stats_a = writer_a.run_once()
assert stats_a.completed == 1 and ledger.count() == 1
assert outbox_a.get(row_a.correlation_id).status is WorkItemStatus.COMPLETED
assert outbox_b.get(row_b.correlation_id).status is WorkItemStatus.PENDING
stats_b = writer_b.run_once()
assert stats_b.completed == 1 and ledger.count() == 2
assert outbox_b.get(row_b.correlation_id).status is WorkItemStatus.COMPLETED
def test_claim_expiry_allows_safe_recovery_after_worker_crash(pg_clean):
now = {"t": datetime(2026, 6, 4, tzinfo=UTC)}
outbox_a = PostgreSQLLedgerOutbox(
pg_clean,
instance_key="inst-a",
claim_ttl_sec=5.0,
worker_id="worker-a",
clock=lambda: now["t"],
)
outbox_b = PostgreSQLLedgerOutbox(
pg_clean,
instance_key="inst-a",
claim_ttl_sec=5.0,
worker_id="worker-b",
clock=lambda: now["t"],
)
row = outbox_a.enqueue(_trade(execution_id="pg-crash"))
claimed_a = outbox_a.claim_pending()
assert [item.correlation_id for item in claimed_a] == [row.correlation_id]
assert outbox_b.claim_pending() == []
now["t"] = datetime(2026, 6, 4, 0, 0, 6, tzinfo=UTC)
claimed_b = outbox_b.claim_pending()
assert [item.correlation_id for item in claimed_b] == [row.correlation_id]
def test_stale_claim_cannot_overwrite_new_owner_outcome(pg_clean):
now = {"t": datetime(2026, 6, 4, tzinfo=UTC)}
outbox_a = PostgreSQLLedgerOutbox(
pg_clean,
instance_key="inst-a",
claim_ttl_sec=5.0,
worker_id="worker-a",
clock=lambda: now["t"],
)
outbox_b = PostgreSQLLedgerOutbox(
pg_clean,
instance_key="inst-a",
claim_ttl_sec=5.0,
worker_id="worker-b",
clock=lambda: now["t"],
)
row = outbox_a.enqueue(_trade(execution_id="pg-stale"))
[claim_a] = outbox_a.claim_pending()
now["t"] = datetime(2026, 6, 4, 0, 0, 6, tzinfo=UTC)
[claim_b] = outbox_b.claim_pending()
outbox_b.mark_outcome(
row.correlation_id,
OutboxOutcome.COMPLETED,
expected_claim_expires_at=claim_b.claim_expires_at,
)
with pytest.raises(OutboxClaimLostError):
outbox_a.mark_outcome(
row.correlation_id,
OutboxOutcome.RETRYABLE_FAILURE,
error="stale owner",
next_retry_at=now["t"],
expected_claim_expires_at=claim_a.claim_expires_at,
)
stored = outbox_a.get(row.correlation_id)
assert stored is not None
assert stored.status is WorkItemStatus.COMPLETED
assert stored.last_outcome is OutboxOutcome.COMPLETED
@pytest.mark.parametrize("instance_key", ["", " "])
def test_postgresql_outbox_rejects_empty_instance_before_connect(pg_clean, instance_key):
with pytest.raises(ValueError, match="non-empty"):
PostgreSQLLedgerOutbox(pg_clean, instance_key=instance_key)
def test_original_owner_can_finalize_expired_unreclaimed_claim(pg_clean):
now = {"t": datetime(2026, 6, 4, tzinfo=UTC)}
outbox = PostgreSQLLedgerOutbox(
pg_clean,
instance_key="inst-a",
claim_ttl_sec=5.0,
worker_id="worker-a",
clock=lambda: now["t"],
)
row = outbox.enqueue(_trade(execution_id="pg-expired"))
[claim] = outbox.claim_pending()
now["t"] = datetime(2026, 6, 4, 0, 0, 6, tzinfo=UTC)
stored = outbox.mark_outcome(
row.correlation_id,
OutboxOutcome.COMPLETED,
expected_claim_expires_at=claim.claim_expires_at,
)
assert stored.status is WorkItemStatus.COMPLETED
assert stored.last_outcome is OutboxOutcome.COMPLETED
def test_writer_records_retryable_403_without_cross_instance_delivery(pg_clean):
class _ForbiddenLedger:
def __init__(self):
self.calls: list[str] = []
def record_trade(self, trade):
self.calls.append(trade.execution_id)
raise LedgerRetryableError("portfolio-hub 403 Forbidden")
outbox_a = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
outbox_b = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-b")
writer_b = LedgerWriter(
outbox_b, _ForbiddenLedger(), retry_base_sec=0.0, instance_key="inst-b"
)
row_a = outbox_a.enqueue(_trade(execution_id="pg-403-a"))
row_b = outbox_b.enqueue(_trade(execution_id="pg-403-b"))
stats_b = writer_b.run_once()
assert stats_b.retryable == 1
assert outbox_b.get(row_b.correlation_id).last_outcome is OutboxOutcome.RETRYABLE_FAILURE
assert outbox_a.get(row_a.correlation_id).status is WorkItemStatus.PENDING
def test_concurrent_workers_claim_each_row_at_most_once(pg_clean):
outbox_a = PostgreSQLLedgerOutbox(
pg_clean, instance_key="inst-a", worker_id="worker-a"
)
outbox_b = PostgreSQLLedgerOutbox(
pg_clean, instance_key="inst-a", worker_id="worker-b"
)
row = outbox_a.enqueue(_trade(execution_id="pg-concurrent"))
with ThreadPoolExecutor(max_workers=2) as pool:
claims = list(pool.map(lambda box: box.claim_pending(), (outbox_a, outbox_b)))
claimed_ids = [item.correlation_id for batch in claims for item in batch]
assert claimed_ids == [row.correlation_id]
def test_writer_marks_duplicate_completed_from_adapter_response_on_pg(pg_clean):
class _DuplicateLedger:
def record_trade(self, _trade):
return LedgerWriteOutcome(LedgerWriteResult.DUPLICATE_OK)
outbox = PostgreSQLLedgerOutbox(pg_clean, instance_key="inst-a")
writer = LedgerWriter(outbox, _DuplicateLedger(), instance_key="inst-a")
row = outbox.enqueue(_trade(execution_id="pg-dup"))
outcome = writer.process_correlation_id(row.correlation_id)
assert outcome is OutboxOutcome.DUPLICATE_COMPLETED
stored = outbox.get(row.correlation_id)
assert stored is not None
assert stored.status is WorkItemStatus.COMPLETED
assert stored.last_outcome is OutboxOutcome.DUPLICATE_COMPLETED
@@ -2,6 +2,8 @@
Skipped without TREX_POSTGRESQL_TEST_DSN."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore # noqa: F401
from trex_core.adapters.persistence.postgresql import (
PostgreSQLIntentStore,
@@ -155,45 +157,169 @@ 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"
# ── Regression: reserve_cancel_attempt() SQL paths (issue #81 Fix A) ─────────
# These tests exercise the real PostgreSQL code path that previously failed with
# IndeterminateDatatype when retry_before=None was bound to %s IS NOT NULL.
def test_reserve_cancel_first_attempt_retry_before_none_pg(pg_clean):
"""First reservation with retry_before=None must succeed exactly once."""
repo, _, intents = _wire(pg_clean)
repo.reconcile_universe(INST, {"PF-1": "005930"})
d = intents.submit_buy(BuyIntent(intent_id="pg-can-first", qty=1, portfolio_id="PF-1"))
repo.mark_dispatched(d.slot_id, "ord-first")
# First reservation: retry_before=None — must not raise IndeterminateDatatype
assert repo.reserve_cancel_attempt(d.slot_id, "ord-first") is True
slot = repo.fetch_slot(d.slot_id)
assert slot.cancel_state == "unknown"
assert slot.cancel_attempts == 1
assert slot.cancel_requested_at is not None
# Duplicate CAS must be rejected (cancel_state is no longer NULL)
assert repo.reserve_cancel_attempt(d.slot_id, "ord-first") is False
assert repo.fetch_slot(d.slot_id).cancel_attempts == 1 # not incremented
def test_reserve_cancel_retry_backoff_gated_pg(pg_clean):
"""Retry path with retry_before is accepted when backoff window passes."""
import psycopg
repo, _, intents = _wire(pg_clean)
repo.reconcile_universe(INST, {"PF-1": "005930"})
d = intents.submit_buy(BuyIntent(intent_id="pg-can-retry", qty=1, portfolio_id="PF-1"))
repo.mark_dispatched(d.slot_id, "ord-retry")
# Simulate a previous cancel attempt that left state=rejected a while ago
past = datetime.now(tz=UTC) - timedelta(seconds=60)
with psycopg.connect(pg_clean, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET cancel_state='rejected', cancel_requested_at=%s, "
"cancel_attempts=1 WHERE slot_id=%s",
(past, d.slot_id),
)
conn.commit()
# retry_before = now means backoff window has elapsed
retry_before = datetime.now(tz=UTC)
assert repo.reserve_cancel_attempt(d.slot_id, "ord-retry", retry_before=retry_before) is True
slot = repo.fetch_slot(d.slot_id)
assert slot.cancel_state == "unknown"
assert slot.cancel_attempts == 2
def test_reserve_cancel_retry_max_attempts_blocked_pg(pg_clean):
"""Retry is rejected when cancel_attempts >= 4."""
import psycopg
repo, _, intents = _wire(pg_clean)
repo.reconcile_universe(INST, {"PF-1": "005930"})
d = intents.submit_buy(BuyIntent(intent_id="pg-can-max", qty=1, portfolio_id="PF-1"))
repo.mark_dispatched(d.slot_id, "ord-max")
past = datetime.now(tz=UTC) - timedelta(seconds=120)
with psycopg.connect(pg_clean, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET cancel_state='rejected', cancel_requested_at=%s, "
"cancel_attempts=4 WHERE slot_id=%s",
(past, d.slot_id),
)
conn.commit()
retry_before = datetime.now(tz=UTC)
assert repo.reserve_cancel_attempt(d.slot_id, "ord-max", retry_before=retry_before) is False
assert repo.fetch_slot(d.slot_id).cancel_attempts == 4 # unchanged
@@ -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,
)
+52
View File
@@ -117,6 +117,58 @@ def test_get_portfolios_network_error_raises_unavailable():
# REQ-123: cost-basis parsing (canonical + diagnostics)
# ---------------------------------------------------------------------------
def test_get_cost_basis_evidence_parses_identity_quantity_and_complete_basis():
def handler(req: httpx.Request) -> httpx.Response:
assert req.url.path == "/v1/portfolios/pf-1/holdings/cost-basis"
return httpx.Response(200, json={
"portfolio_id": "pf-1",
"symbol": "019685",
"current_quantity": "726",
"cost_basis_per_unit": "2172.46829187",
"total_cost_basis": str(726 * 2172.46829187),
"data_completeness_warning": None,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
evidence = adapter.get_cost_basis_evidence("pf-1")
assert evidence.portfolio_id == "pf-1"
assert evidence.symbol == "019685"
assert evidence.current_quantity == 726.0
assert evidence.cost_basis_per_unit == pytest.approx(2172.46829187)
assert evidence.data_completeness_warning is None
assert evidence.observed_at.tzinfo is not None
@pytest.mark.parametrize("payload", [
{"cost_basis_per_unit": 1, "total_cost_basis": 1},
{
"portfolio_id": "wrong", "symbol": "019685", "current_quantity": 1,
"cost_basis_per_unit": 1, "total_cost_basis": 1,
},
{
"portfolio_id": "pf-1", "symbol": "019685", "current_quantity": 1,
"cost_basis_per_unit": "NaN", "total_cost_basis": 1,
},
])
def test_get_cost_basis_evidence_rejects_missing_mismatched_or_nonfinite(payload):
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_cost_basis_evidence("pf-1")
def test_get_cost_basis_evidence_never_uses_legacy_fields():
def handler(req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"avg_cost": 1234, "wac": 1234})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_cost_basis_evidence("pf-1")
def test_get_cost_basis_with_diagnostics_prefers_canonical_when_valid():
"""REQ-123 AC-1: cost_basis_per_unit가 있으면 우선 사용."""
@@ -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"
@@ -0,0 +1,42 @@
"""PostgreSQLReconcileWarningStore integration tests."""
from __future__ import annotations
from trex_core.adapters.portfolio.reconcile_warning_store import (
PostgreSQLReconcileWarningStore,
)
def test_warning_store_preserves_multiple_cases_for_same_slot(pg_clean):
store = PostgreSQLReconcileWarningStore(pg_clean)
store.set_warned("slot-1", portfolio_id="pf-A", case_code="B", reason="b")
store.set_warned("slot-1", portfolio_id="pf-A", case_code="C", reason="c")
assert store.is_warned("slot-1") is True
info = store.get_warning_info("slot-1")
assert info is not None
assert info["case_codes"] == ["B", "C"]
def test_warning_store_case_specific_clear_and_full_clear(pg_clean):
store = PostgreSQLReconcileWarningStore(pg_clean)
store.set_warned("slot-2", portfolio_id="pf-A", case_code="B")
store.set_warned("slot-2", portfolio_id="pf-A", case_code="C")
store.clear_warned("slot-2", case_code="C")
info = store.get_warning_info("slot-2")
assert info is not None
assert info["case_codes"] == ["B"]
store.clear_warned("slot-2")
assert store.is_warned("slot-2") is False
def test_warning_store_is_durable_across_reinstantiation(pg_clean):
a = PostgreSQLReconcileWarningStore(pg_clean)
a.set_warned("slot-restart", portfolio_id="pf-A", case_code="B")
a.set_warned("slot-restart", portfolio_id="pf-A", case_code="C")
b = PostgreSQLReconcileWarningStore(pg_clean)
info = b.get_warning_info("slot-restart")
assert info is not None
assert info["case_codes"] == ["B", "C"]
@@ -0,0 +1,268 @@
"""PostgreSQLUnmanagedPositionStore integration tests."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
import pytest
@pytest.fixture
def pg_unmanaged_store(pg_clean):
from trex_core.adapters.portfolio.unmanaged_position_store import (
PostgreSQLUnmanagedPositionStore,
)
return PostgreSQLUnmanagedPositionStore(pg_clean)
class TestMigration030:
def test_tables_exist_after_migration(self, pg_clean):
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 IN ('unmanaged_position_state', 'unmanaged_position_audit')"
)
assert {row[0] for row in cur.fetchall()} == {
"unmanaged_position_state",
"unmanaged_position_audit",
}
class TestCRUDRoundTrip:
def test_observe_change_resolve_and_audit(self, pg_unmanaged_store):
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
first = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t1,
)
repeat = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t2,
)
changed = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t3,
)
recorded = pg_unmanaged_store.record_operator_action(
instance_key="inst-A",
symbol="008110",
action="approved_exception",
evidence_ref="kis:2026-09-11",
actor="cli",
note="broker-confirmed",
)
resolved = pg_unmanaged_store.resolve_absent(
instance_key="inst-A",
observed_symbols=set(),
observed_at=t3 + timedelta(minutes=1),
)
assert first["transition"] == "detected"
assert repeat["transition"] == "unchanged"
assert changed["transition"] == "quantity_changed"
assert recorded is not None
assert len(resolved) == 1
state = pg_unmanaged_store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert state["quantity"] == 5
assert state["first_seen_at"] == t1
assert state["last_seen_at"] == t3
assert state["quantity_changed_at"] == t3
assert state["lifecycle_state"] == "resolved"
assert state["evidence_ref"] == "kis:2026-09-11"
audit = pg_unmanaged_store.list_audit(instance_key="inst-A", symbol="008110")
assert [row["event_type"] for row in audit] == [
"detected",
"quantity_changed",
"operator_action",
"resolved",
]
def test_same_symbol_isolated_by_instance(self, pg_unmanaged_store):
pg_unmanaged_store.observe(instance_key="inst-A", symbol="008110", quantity=3)
pg_unmanaged_store.observe(instance_key="inst-B", symbol="008110", quantity=7)
pg_unmanaged_store.resolve_absent(instance_key="inst-A", observed_symbols=set())
rows_a = pg_unmanaged_store.list_states(instance_key="inst-A", lifecycle_state="all")
rows_b = pg_unmanaged_store.list_states(instance_key="inst-B", lifecycle_state="all")
assert len(rows_a) == 1 and rows_a[0]["lifecycle_state"] == "resolved"
assert len(rows_b) == 1 and rows_b[0]["quantity"] == 7
def test_reactivated_state_preserves_original_first_seen(self, pg_unmanaged_store):
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t1,
)
pg_unmanaged_store.record_operator_action(
instance_key="inst-A",
symbol="008110",
action="investigating",
evidence_ref="kis:2026-09-07",
actor="cli:test",
note="before resolve",
)
pg_unmanaged_store.resolve_absent(
instance_key="inst-A",
observed_symbols=set(),
observed_at=t2,
)
reopened = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t3,
)
assert reopened["transition"] == "detected"
assert reopened["state"]["first_seen_at"] == t1
assert reopened["state"]["last_seen_at"] == t3
assert reopened["state"]["operator_action"] is None
assert reopened["state"]["evidence_ref"] is None
def test_concurrent_first_observe_keeps_single_detected_audit(self, pg_clean):
from trex_core.adapters.portfolio.unmanaged_position_store import (
PostgreSQLUnmanagedPositionStore,
)
def _observe():
return PostgreSQLUnmanagedPositionStore(pg_clean).observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
)
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _n: _observe(), range(2)))
assert sorted(result["transition"] for result in results) == ["detected", "unchanged"]
store = PostgreSQLUnmanagedPositionStore(pg_clean)
state = store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert state["lifecycle_state"] == "active"
audit = store.list_audit(instance_key="inst-A", symbol="008110")
assert [row["event_type"] for row in audit] == ["detected"]
def test_older_absent_snapshot_applied_after_newer_observation_is_ignored(self, pg_unmanaged_store):
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t1,
)
observed = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t3,
)
resolved = pg_unmanaged_store.resolve_absent(
instance_key="inst-A",
observed_symbols=set(),
observed_at=t2,
)
assert observed["transition"] == "quantity_changed"
assert resolved == []
state = pg_unmanaged_store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert state["lifecycle_state"] == "active"
assert state["quantity"] == 5
assert [row["event_type"] for row in pg_unmanaged_store.list_audit(
instance_key="inst-A",
symbol="008110",
)] == ["detected", "quantity_changed"]
def test_older_observation_applied_after_newer_resolution_is_ignored(self, pg_unmanaged_store):
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=3,
observed_at=t1,
)
pg_unmanaged_store.resolve_absent(
instance_key="inst-A",
observed_symbols=set(),
observed_at=t3,
)
stale = pg_unmanaged_store.observe(
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t2,
)
assert stale["transition"] == "stale"
state = pg_unmanaged_store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert state["lifecycle_state"] == "resolved"
assert state["quantity"] == 3
assert [row["event_type"] for row in pg_unmanaged_store.list_audit(
instance_key="inst-A",
symbol="008110",
)] == ["detected", "resolved"]
def test_concurrent_observe_and_resolve_respects_newest_snapshot_timestamp(self, pg_clean):
from trex_core.adapters.portfolio.unmanaged_position_store import (
PostgreSQLUnmanagedPositionStore,
)
store = PostgreSQLUnmanagedPositionStore(pg_clean)
t1 = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
t2 = t1 + timedelta(minutes=1)
t3 = t2 + timedelta(minutes=1)
store.observe(instance_key="inst-A", symbol="008110", quantity=3, observed_at=t1)
with ThreadPoolExecutor(max_workers=2) as pool:
observe_future = pool.submit(
PostgreSQLUnmanagedPositionStore(pg_clean).observe,
instance_key="inst-A",
symbol="008110",
quantity=5,
observed_at=t3,
)
resolve_future = pool.submit(
PostgreSQLUnmanagedPositionStore(pg_clean).resolve_absent,
instance_key="inst-A",
observed_symbols=set(),
observed_at=t2,
)
observe_result = observe_future.result()
resolve_result = resolve_future.result()
state = store.get_state(instance_key="inst-A", symbol="008110")
assert state is not None
assert observe_result["transition"] == "quantity_changed"
assert resolve_result == []
assert state["lifecycle_state"] == "active"
assert state["quantity"] == 5
assert [row["event_type"] for row in store.list_audit(
instance_key="inst-A",
symbol="008110",
)] == ["detected", "quantity_changed"]
+63 -4
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(
@@ -70,10 +75,11 @@ def test_refresh_persists_independent_holdings_and_cash_capture_times():
assert second.cash_captured_at == first.cash_captured_at
def test_sell_falls_back_only_for_transport_failure_and_within_sell_ttl():
@pytest.mark.parametrize("error_code", ["transport_failed", "timeout", "http_5xx"])
def test_sell_falls_back_for_transient_live_read_failures_within_sell_ttl(error_code):
broker, _, service, now = _wire()
assert service.refresh()
broker.balance_error = "transport_failed"
broker.balance_error = error_code
now[0] += timedelta(seconds=601)
assert service.holdings_for_sell() == [BalanceItem("005930", 2)]
@@ -121,6 +127,16 @@ def test_buy_guard_accounts_for_existing_pending_cash_and_never_renews_age():
)
@pytest.mark.parametrize("error_code", ["transport_failed", "timeout", "http_5xx"])
def test_buy_guard_keeps_existing_cash_snapshot_on_transient_cash_refresh_failure(error_code):
broker, _, service, _ = _wire()
assert service.refresh()
broker.cash_error = error_code
assert service.refresh() is False
assert service.buy_guard(qty=2, limit_price=100_000) is None
def test_current_account_hash_is_required_on_every_consume():
broker, _, service, _ = _wire()
assert service.refresh()
@@ -162,3 +178,46 @@ 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"
def test_sell_stale_snapshot_preserves_transient_live_failure_detail_code():
broker, _, service, now = _wire()
assert service.refresh()
broker.balance_error = "timeout"
now[0] += timedelta(seconds=3601)
with pytest.raises(AccountSnapshotUnavailable) as raised:
service.holdings_for_sell()
assert raised.value.code == "expired"
assert raised.value.detail_code == "timeout"
+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
+326
View File
@@ -0,0 +1,326 @@
"""Issue #94 holding mirror auto-reconciliation tests."""
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
import pytest
from trex_core.adapters.persistence.auto_reconcile import InMemoryAutoReconcileStore
from trex_core.application.auto_reconcile import (
AutoReconcileCommand,
HoldingsCostBasisEvidence,
)
from trex_core.application.broker_port import BalanceItem
from trex_core.application.portfolio import (
InMemoryReconcileNotificationStore,
InMemoryReconcileWarningStore,
PortfolioItem,
)
from trex_core.application.reconciliation import reconcile_core_holdings
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
class CountingBroker:
def __init__(self, quantity: int = 726) -> None:
self.quantity = quantity
self.balance_reads = 0
self.order_calls = 0
def name(self):
return "counting"
def get_balance(self):
self.balance_reads += 1
return [BalanceItem(symbol="019685", hldg_qty=self.quantity)]
def get_current_price(self, symbol):
return 1.0
def submit_order(self, request):
self.order_calls += 1
raise AssertionError("auto reconcile must not submit orders")
def poll_fill(self, *args, **kwargs):
raise AssertionError("auto reconcile must not poll orders")
def cancel_order(self, *args, **kwargs):
raise AssertionError("auto reconcile must not cancel orders")
class EvidencePort:
def __init__(self, evidence: HoldingsCostBasisEvidence) -> None:
self.evidence = evidence
self.calls = 0
def get_cost_basis_evidence(self, portfolio_id: str) -> HoldingsCostBasisEvidence:
self.calls += 1
assert portfolio_id == "pf-019685"
return self.evidence
class RecordingAlarm:
def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = []
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
self.calls.append((code, fields or {}))
return True
def _holding(repo: InMemorySlotRepository, svc: SlotService):
repo.reconcile_universe("default", {"pf-019685": "019685"})
slot = repo.find_slot_by_portfolio("default", "pf-019685")
assert slot is not None
pending = svc.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
symbol="019685",
)
return svc.transit_slot(
pending.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
avg_price=2100.0,
qty=1452.0,
)
def _evidence(**overrides) -> HoldingsCostBasisEvidence:
values = {
"portfolio_id": "pf-019685",
"symbol": "019685",
"current_quantity": 726.0,
"cost_basis_per_unit": 2172.46829187,
"total_cost_basis": 726.0 * 2172.46829187,
"data_completeness_warning": None,
"observed_at": datetime.now(tz=UTC),
}
values.update(overrides)
return HoldingsCostBasisEvidence(**values)
def _run(
*,
evidence=None,
mutate_slot=None,
auto_store_wrapper=None,
notification=False,
portfolio_items=None,
):
if portfolio_items is None:
portfolio_items = [PortfolioItem("pf-019685", "019685", 0, 726.0)]
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn = InMemoryReconcileWarningStore()
slot = _holding(repo, svc)
if mutate_slot is not None:
repo.update_slot(mutate_slot(slot))
slot = repo.fetch_slot(slot.slot_id)
assert slot is not None
warn.set_warned(
str(slot.slot_id),
portfolio_id="pf-019685",
case_code="C",
actor="test",
reason="preserve-me",
)
base_store = InMemoryAutoReconcileStore(repo, warn)
auto_store = auto_store_wrapper(base_store, repo) if auto_store_wrapper else base_store
broker = CountingBroker()
alarm = RecordingAlarm() if notification else None
notifications = InMemoryReconcileNotificationStore() if notification else None
port = EvidencePort(evidence or _evidence())
decisions = reconcile_core_holdings(
slot_repo=repo,
slot_service=svc,
broker=broker, # type: ignore[arg-type]
instance_key="default",
warn_store=warn,
alarm_emitter=alarm, # type: ignore[arg-type]
notification_store=notifications,
portfolio_items=portfolio_items,
cost_basis_port=port,
auto_reconcile_store=auto_store,
)
return repo, warn, slot, broker, port, decisions, alarm
def test_auto_reconcile_updates_qty_avg_audit_and_only_clears_b():
repo, warn, slot, broker, port, decisions, _alarm = _run()
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "auto_synced"
assert current.qty == 726.0
assert current.avg_price == pytest.approx(2172.46829187)
assert current.status is SlotState.HOLDING
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["C"]
audit = repo.transition_log_for(slot.slot_id)[-1]
assert audit.actor == "auto_reconcile"
assert audit.reason == "hub_broker_matched_core_mirror_stale"
assert audit.extra["before_qty"] == 1452.0
assert audit.extra["after_qty"] == 726.0
assert broker.balance_reads == 1
assert broker.order_calls == 0
assert port.calls == 1
def test_auto_reconcile_repeat_is_idempotent():
repo, warn, slot, broker, port, decisions, _alarm = _run()
audit_count = len(repo.transition_log_for(slot.slot_id))
second = reconcile_core_holdings(
slot_repo=repo,
slot_service=SlotService(repo),
broker=broker, # type: ignore[arg-type]
instance_key="default",
warn_store=warn,
portfolio_items=[PortfolioItem("pf-019685", "019685", 0, 726.0)],
cost_basis_port=port,
auto_reconcile_store=InMemoryAutoReconcileStore(repo, warn),
)
assert second[str(slot.slot_id)] == "ok"
assert len(repo.transition_log_for(slot.slot_id)) == audit_count
assert decisions[str(slot.slot_id)] == "auto_synced"
@pytest.mark.parametrize(
"evidence",
[
_evidence(data_completeness_warning="history incomplete"),
_evidence(symbol="WRONG"),
_evidence(current_quantity=725.0, total_cost_basis=725.0 * 2172.46829187),
_evidence(observed_at=datetime.now(tz=UTC) - timedelta(minutes=2)),
_evidence(cost_basis_per_unit=float("nan"), total_cost_basis=float("nan")),
],
)
def test_auto_reconcile_invalid_evidence_fails_closed(evidence):
repo, warn, slot, broker, port, decisions, _alarm = _run(evidence=evidence)
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "qty_warning"
assert current.qty == 1452.0
assert current.avg_price == 2100.0
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
assert repo.transition_log_for(slot.slot_id)[-1].actor == "test"
assert broker.balance_reads == 1
assert broker.order_calls == 0
assert port.calls == 1
def test_auto_reconcile_pending_dispatch_metadata_fails_closed_without_evidence_read():
repo, warn, slot, _broker, port, decisions, _alarm = _run(
mutate_slot=lambda value: replace(value, broker_order_id="pending-order")
)
assert decisions[str(slot.slot_id)] == "qty_warning"
assert repo.fetch_slot(slot.slot_id).qty == 1452.0
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
assert port.calls == 0
def test_auto_reconcile_duplicate_hub_symbol_fails_closed():
repo, warn, slot, _broker, port, decisions, _alarm = _run(
portfolio_items=[
PortfolioItem("pf-019685", "019685", 0, 726.0),
PortfolioItem("pf-other", "019685", 0, 0.0),
]
)
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "qty_warning"
assert current.qty == 1452.0
warning_info = warn.get_warning_info(str(slot.slot_id))
assert warning_info is not None
assert warning_info["case_codes"] == ["B", "C"]
assert port.calls == 0
def test_auto_reconcile_cas_race_preserves_intervening_state_and_records():
class RacingStore:
def __init__(self, delegate, repo):
self.delegate = delegate
self.repo = repo
def apply_holding_mirror(self, command):
current = self.repo.fetch_slot(command.slot_id)
self.repo.update_slot(replace(current, avg_price=9999.0))
return self.delegate.apply_holding_mirror(command)
repo, warn, slot, _broker, _port, decisions, _alarm = _run(
auto_store_wrapper=lambda delegate, repo: RacingStore(delegate, repo)
)
current = repo.fetch_slot(slot.slot_id)
assert current is not None
assert decisions[str(slot.slot_id)] == "qty_warning"
assert current.qty == 1452.0
assert current.avg_price == 9999.0
assert warn.get_warning_info(str(slot.slot_id))["case_codes"] == ["B", "C"]
assert repo.transition_log_for(slot.slot_id)[-1].actor == "test"
def test_in_memory_auto_reconcile_audit_failure_rolls_back_all_records(monkeypatch):
repo = InMemorySlotRepository()
service = SlotService(repo)
warning = InMemoryReconcileWarningStore()
slot = _holding(repo, service)
assert slot.portfolio_id is not None
assert slot.symbol is not None
assert slot.qty is not None
warning.set_warned(
str(slot.slot_id), portfolio_id=slot.portfolio_id, case_code="B", actor="reconcile"
)
warning.set_warned(
str(slot.slot_id), portfolio_id=slot.portfolio_id, case_code="C", actor="reconcile"
)
before_slot = repo.fetch_slot(slot.slot_id)
before_warning = warning.get_warning_info(str(slot.slot_id))
before_log = repo.transition_log_for(slot.slot_id)
def fail_audit(_record):
raise RuntimeError("audit insert failed")
monkeypatch.setattr(repo, "append_transition_log", fail_audit)
with pytest.raises(RuntimeError, match="audit insert failed"):
InMemoryAutoReconcileStore(repo, warning).apply_holding_mirror(
AutoReconcileCommand(
slot_id=slot.slot_id,
instance_key=slot.instance_key,
portfolio_id=slot.portfolio_id,
symbol=slot.symbol,
expected_qty=slot.qty,
expected_avg_price=slot.avg_price,
target_qty=726.0,
target_avg_price=2172.46829187,
hub_quantity=726.0,
broker_quantity=726,
evidence_observed_at=datetime.now(tz=UTC),
)
)
assert repo.fetch_slot(slot.slot_id) == before_slot
assert warning.get_warning_info(str(slot.slot_id)) == before_warning
assert repo.transition_log_for(slot.slot_id) == before_log
def test_auto_reconcile_emits_detected_then_one_authoritative_resolved_event():
_repo, _warn, slot, _broker, _port, decisions, alarm = _run(notification=True)
assert decisions[str(slot.slot_id)] == "auto_synced"
assert [code for code, _fields in alarm.calls] == [
"reconcile_mismatch_detected",
"reconcile_mismatch_resolved",
]
assert all("account" not in str(fields).lower() for _code, fields in alarm.calls)
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from trex_core.application.balance_health import BalanceHealthTracker
def test_long_outage_backoff_is_capped_and_status_stays_valid():
now = {"t": datetime(2026, 9, 12, tzinfo=UTC)}
tracker = BalanceHealthTracker(clock=lambda: now["t"], base_backoff_sec=15, max_backoff_sec=300)
backoffs: list[float] = []
for _ in range(20):
lifecycle, backoff = tracker.record_failure("execution", code="http_5xx")
backoffs.append(backoff)
assert lifecycle in {None, "detected", "ongoing"}
now["t"] += timedelta(seconds=301)
assert max(backoffs) == 300.0
state = tracker.status("execution", stale_after_sec=600)
assert state["state"] == "unavailable"
assert state["consecutive_failures"] == 20
assert state["failure_code"] == "http_5xx"
def test_long_outage_over_1100_failures_never_overflows_and_stays_capped():
now = {"t": datetime(2026, 9, 12, tzinfo=UTC)}
tracker = BalanceHealthTracker(clock=lambda: now["t"], base_backoff_sec=15, max_backoff_sec=300)
last_lifecycle = None
for i in range(1_100):
lifecycle, backoff = tracker.record_failure("execution", code="http_5xx")
last_lifecycle = lifecycle
if i >= 5:
assert backoff == 300.0
else:
assert backoff <= 300.0
now["t"] += timedelta(seconds=301)
state = tracker.status("execution", stale_after_sec=600)
assert state["state"] == "unavailable"
assert state["consecutive_failures"] == 1_100
assert state["failure_code"] == "http_5xx"
assert state["next_retry_at"] is not None
assert last_lifecycle in {None, "ongoing"}
def test_restart_resets_process_local_health_and_re_emits_detected():
now = {"t": datetime(2026, 9, 12, tzinfo=UTC)}
before_restart = BalanceHealthTracker(clock=lambda: now["t"])
before_restart.record_success("execution")
lifecycle, _ = before_restart.record_failure("execution", code="timeout")
assert lifecycle == "detected"
after_restart = BalanceHealthTracker(clock=lambda: now["t"])
lifecycle, _ = after_restart.record_failure("execution", code="timeout")
assert lifecycle == "detected"
state = after_restart.status("execution", stale_after_sec=600)
assert state["last_success_at"] is None
assert state["consecutive_failures"] == 1
+155
View File
@@ -5,8 +5,13 @@ In-memory adapters; deterministic paper double drives the full async path.
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.account_snapshot import AccountSnapshotUnavailable
from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
@@ -214,6 +219,156 @@ def test_pending_sell_uses_injected_bounded_balance_reader():
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
def test_pending_sell_balance_failure_is_fail_closed_with_backoff_and_recovery():
now = {"t": datetime(2026, 9, 12, tzinfo=UTC)}
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, _ = _wire(broker)
sid = _buy(intents, qty=3).slot_id
execution = ExecutionService(
svc,
repo,
broker,
instance_key=INST,
clock=lambda: now["t"],
)
execution.run_once()
svc.sell(sid, actor="t", reason="full")
broker._fill_after = 5
calls = {"n": 0}
reader_state = {"ok": False}
def failing_reader():
calls["n"] += 1
if not reader_state["ok"]:
raise AccountSnapshotUnavailable("expired")
return [BalanceItem("005931", 3)]
execution = ExecutionService(
svc,
repo,
broker,
instance_key=INST,
balance_reader=failing_reader,
clock=lambda: now["t"],
)
first = execution.run_once()
assert first.sell_skipped_no_balance == 1
assert calls["n"] == 1
health = execution.balance_worker_health(stale_after_sec=600)
assert health["state"] == "unavailable"
assert health["failure_code"] == "stale_snapshot"
assert health["consecutive_failures"] == 1
second = execution.run_once()
assert second.sell_skipped_no_balance == 1
assert calls["n"] == 1 # bounded backoff prevented another live read
now["t"] += timedelta(seconds=16)
reader_state["ok"] = True
third = execution.run_once()
assert third.dispatched == 1
health = execution.balance_worker_health(stale_after_sec=600)
assert health["state"] == "ok"
assert health["consecutive_failures"] == 0
def test_pending_sell_health_preserves_non_transient_snapshot_failure_code():
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, _ = _wire(broker)
sid = _buy(intents, qty=3).slot_id
ExecutionService(
svc,
repo,
broker,
instance_key=INST,
).run_once()
svc.sell(sid, actor="t", reason="full")
execution = ExecutionService(
svc,
repo,
broker,
instance_key=INST,
balance_reader=lambda: (_ for _ in ()).throw(
AccountSnapshotUnavailable(
"live_read_failed",
detail_code="authentication_failed",
)
),
)
result = execution.run_once()
assert result.sell_skipped_no_balance == 1
health = execution.balance_worker_health(stale_after_sec=600)
assert health["state"] == "unavailable"
assert health["failure_code"] == "authentication_failed"
@pytest.mark.parametrize(
"error_code",
[
"timeout",
"http_5xx",
"authentication_failed",
"token_issuance_failed",
"malformed_response",
],
ids=["timeout", "http_5xx", "auth", "token", "pagination_middle_malformed"],
)
def test_execution_wrapper_classification_fail_closed_matrix(error_code):
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, _ = _wire(broker)
sid = _buy(intents, qty=3).slot_id
ExecutionService(svc, repo, broker, instance_key=INST).run_once()
svc.sell(sid, actor="t", reason="full")
calls = {"n": 0}
def fail_reader():
calls["n"] += 1
raise AccountSnapshotUnavailable("live_read_failed", detail_code=error_code)
execution = ExecutionService(
svc,
repo,
broker,
instance_key=INST,
balance_reader=fail_reader,
)
result = execution.run_once()
assert result.dispatched == 0
assert result.sell_skipped_no_balance == 1
assert calls["n"] == 1
health = execution.balance_worker_health(stale_after_sec=600)
assert health["state"] == "unavailable"
assert health["consecutive_failures"] == 1
assert health["failure_code"] == error_code
def test_execution_wrapper_treats_validated_empty_success_as_success_not_failure():
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, _ = _wire(broker)
sid = _buy(intents, qty=3).slot_id
ExecutionService(svc, repo, broker, instance_key=INST).run_once()
svc.sell(sid, actor="t", reason="full")
execution = ExecutionService(
svc,
repo,
broker,
instance_key=INST,
balance_reader=lambda: [],
)
result = execution.run_once()
assert result.dispatched == 0
assert result.sell_skipped_no_balance == 1 # broker flat is valid success
health = execution.balance_worker_health(stale_after_sec=600)
assert health["state"] == "ok"
assert health["consecutive_failures"] == 0
assert health["failure_code"] is None
def test_partial_sell_qty_is_min_intent_broker():
"""dcy-021/REQ-062 (D4): partial sell dispatches min(intent qty, broker);
remainder stays holding (broker-authoritative qty), cost basis preserved."""
+167 -2
View File
@@ -70,7 +70,7 @@ def _wire(broker, *, stale_sec=None):
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
ledger = InMemoryLedger()
execution = ExecutionService(
svc, repo, broker, ledger_outbox=outbox,
@@ -104,6 +104,58 @@ def test_full_external_cancel_returns_slot_to_holding():
assert r.rolled_back == 1
def test_unresolved_poll_observability_is_sanitized_and_deduplicated(caplog):
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(broker)
sid = _buy_to_holding(intents, execution)
assert sid is not None
intents.submit_sell(SellIntent(intent_id="s-poll-observe", slot_id=sid))
samples = iter([
("correction_child_present_unresolved", 10),
("correction_child_present_unresolved", 10),
("correction_child_present_unresolved", 61),
("correction_child_present_unresolved", 61),
("parent_identity_mismatch", 61),
])
def unresolved(side):
del side
reason, age = next(samples)
return FillStatus(
state="unknown",
raw={
"poll_performed": True,
"poll_count": broker.polls,
"stuck_duration_sec": age,
"last_result_reason": reason,
"backoff_sec": 15.0,
"raw_payload": {"authorization": "secret-token"},
"broker_order_id": "sensitive-order-id",
},
)
broker.responder = unresolved
with caplog.at_level(logging.INFO):
for _ in range(5):
execution.run_once()
observations = [
record for record in caplog.records
if getattr(record, "event", None) == "order_poll"
]
warnings = [
record for record in caplog.records
if getattr(record, "event", None) == "order_poll_unresolved"
]
assert len(observations) == 5
assert len(warnings) == 3 # first, long-duration threshold, reason change
assert observations[-1].poll_count == 6
assert observations[-1].stuck_duration_sec == 61
assert observations[-1].result_reason == "parent_identity_mismatch"
assert "secret-token" not in caplog.text
assert "sensitive-order-id" not in caplog.text
def test_partial_fill_then_cancel_records_and_keeps_remainder():
broker = ProgrammableBroker()
repo, svc, intents, execution, ledger, writer = _wire(broker)
@@ -116,7 +168,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)
@@ -178,3 +231,115 @@ def test_operator_cancel_is_sent_once_across_repeat_and_restart():
assert restarted.cancel_pending_slot(sid) == "cancel_already_recorded"
assert broker.cancel_calls == 1
assert repo.fetch_slot(sid).cancel_state == CancelState.REQUESTED.value
# ── Regression: first-cancel reservation (retry_before=None) ─────────────────
def test_first_cancel_reservation_with_no_retry_before_succeeds():
"""First cancel reservation with retry_before=None must reserve exactly once (fix for
PostgreSQL IndeterminateDatatype; the in-memory repo mirrors the corrected SQL path)."""
broker = ProgrammableBroker()
repo, svc, intents, execution, _, _ = _wire(broker)
sid = _buy_to_holding(intents, execution)
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# First operator cancel — retry_before=None path
result = execution.cancel_pending_slot(sid)
assert result == "ok", f"expected ok, got {result}"
assert broker.cancel_calls == 1
# Second call must be idempotent (cancel_state already set)
result2 = execution.cancel_pending_slot(sid)
assert result2 == "cancel_already_recorded"
assert broker.cancel_calls == 1 # no second broker call
def test_timeout_cancel_first_attempt_with_retry_before_none():
"""Timeout cancel's first attempt passes retry_before=None; must call broker once."""
broker = ProgrammableBroker()
repo, svc, intents, execution, _, _ = _wire(broker)
sid = _buy_to_holding(intents, execution)
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
broker.responder = lambda side: FillStatus(
state="accepted",
raw={"order_open_confirmed": True},
)
execution.run_once() # dispatch
# Simulate timeout: set order_submitted_at well in the past
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=120),
order_date="20260901",
timeout_sec=30,
)
execution.run_once() # should trigger timeout cancel
assert broker.cancel_calls == 1
# ── Regression: concurrent timeout/operator cancel race — broker call count 1 ─
def test_timeout_operator_race_broker_called_exactly_once():
"""Timeout cancel loop and operator cancel_pending_slot race to the same first
reservation. The repository CAS guarantees exactly one winner; broker must
receive cancel_order exactly once regardless of scheduling order.
Uses a threading barrier so both paths enter reserve_cancel_attempt
simultaneously, making the race deterministic.
"""
import threading
broker = ProgrammableBroker()
repo, _, intents, execution, _, _ = _wire(broker)
sid = _buy_to_holding(intents, execution)
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
broker.responder = lambda side: FillStatus(
state="accepted",
raw={"order_open_confirmed": True},
)
execution.run_once() # dispatch sell
# Backdate so timeout_cancel fires in the next run_once()
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=120),
order_date="20260901",
timeout_sec=30,
)
barrier = threading.Barrier(2)
original_reserve = repo.reserve_cancel_attempt
def _barrier_reserve(slot_id, broker_order_id, *, retry_before=None):
barrier.wait(timeout=5)
return original_reserve(slot_id, broker_order_id, retry_before=retry_before)
repo.reserve_cancel_attempt = _barrier_reserve
errors = []
def _operator():
try:
execution.cancel_pending_slot(sid)
except Exception as exc: # noqa: BLE001
errors.append(exc)
t = threading.Thread(target=_operator)
t.start()
# Execution loop's timeout cancel is the second concurrent caller.
try:
execution.run_once()
except Exception as exc: # noqa: BLE001
errors.append(exc)
t.join(timeout=5)
assert not errors, f"thread errors: {errors}"
# CAS ensures exactly one winner — broker cancel must be called once.
assert broker.cancel_calls == 1, (
f"expected exactly 1 broker cancel call, got {broker.cancel_calls}"
)
@@ -120,7 +120,7 @@ def _wire(broker):
resolve_symbol=lambda pid: "005930",
instance_key=INST,
)
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox, instance_key=INST)
repo.reconcile_universe(INST, {"PF-1": "005930"})
return repo, svc, intents, execution, outbox
@@ -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,
)
@@ -964,7 +966,7 @@ class TestPaperBrokerRegression:
resolve_symbol=lambda pid: "005930",
instance_key=INST,
)
outbox2 = InMemoryLedgerOutbox()
outbox2 = InMemoryLedgerOutbox(instance_key="test-instance")
execution2 = ExecutionService(svc2, repo2, paper, ledger_outbox=outbox2, instance_key=INST)
repo2.reconcile_universe(INST, {"PF-1": "005930"})
+336 -29
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,122 @@ 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())
outbox = (
InMemoryLedgerOutbox(clock=clock, instance_key="default")
if clock
else InMemoryLedgerOutbox(instance_key="default")
)
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 +151,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 +198,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 +252,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 +274,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 +419,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()
@@ -133,7 +434,7 @@ def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger():
# And even if the writer somehow re-processes (simulated by a fresh outbox
# row for the same execution_id, e.g. after a manual replay), the ledger
# adapter dedup'd it server-side and the outcome is duplicate_completed.
replay_outbox = InMemoryLedgerOutbox()
replay_outbox = InMemoryLedgerOutbox(instance_key="test-instance")
replay_writer = LedgerWriter(replay_outbox, ledger)
replay_outbox.enqueue(first.trade)
stats = replay_writer.run_once()
@@ -147,10 +448,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 +477,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 +497,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 +518,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
# ============================================================================
+9 -9
View File
@@ -39,7 +39,7 @@ def _store_with(pid="PF-1", cash=1_000_000.0, qty=0.0):
# --- write-back (REQ-019/030) ----------------------------------------------
def test_writeback_updates_snapshot_on_new():
store = _store_with()
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
ledger = InMemoryLedger(cash_after=300_000.0, quantity_after=10.0)
writer = LedgerWriter(outbox, ledger, snapshot_store=store)
outbox.enqueue(_trade())
@@ -50,7 +50,7 @@ def test_writeback_updates_snapshot_on_new():
def test_writeback_applies_on_duplicate_too():
store = _store_with(cash=1_000_000.0)
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
# execution already recorded once → second write is duplicate, *_after still authoritative
ledger = InMemoryLedger(cash_after=300_000.0, quantity_after=10.0)
ledger.record_trade(_trade(execution_id="dup")) # pre-record → next is duplicate
@@ -62,7 +62,7 @@ def test_writeback_applies_on_duplicate_too():
def test_writeback_skipped_when_after_missing():
store = _store_with(cash=1_000_000.0)
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
ledger = InMemoryLedger() # no cash_after/quantity_after → None
writer = LedgerWriter(outbox, ledger, snapshot_store=store)
outbox.enqueue(_trade())
@@ -74,7 +74,7 @@ def test_writeback_best_effort_does_not_block_completion():
class _BoomStore:
def update_cash_position(self, *a, **k):
raise RuntimeError("boom")
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
ledger = InMemoryLedger(cash_after=1.0, quantity_after=1.0)
writer = LedgerWriter(outbox, ledger, snapshot_store=_BoomStore())
outbox.enqueue(_trade())
@@ -83,7 +83,7 @@ def test_writeback_best_effort_does_not_block_completion():
def test_no_writeback_without_store():
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
ledger = InMemoryLedger(cash_after=1.0, quantity_after=1.0)
writer = LedgerWriter(outbox, ledger) # no snapshot_store
outbox.enqueue(_trade())
@@ -92,7 +92,7 @@ def test_no_writeback_without_store():
# --- estimate overlay (REQ-029 / OQ-4) — via make_slots_rpc_handler ----------
def test_pending_cash_delta_sums_by_asset():
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0))
deltas = outbox.pending_cash_delta()
assert deltas["PF-1"] == trade_cash_delta(_trade(side="buy", qty=10, price=1000.0, fee=1.0))
@@ -101,13 +101,13 @@ def test_pending_cash_delta_sums_by_asset():
def test_apply_cash_estimate_overlays_pending_delta():
"""Cash-estimated overlay applied through slots handler (REQ-029 OQ-4)."""
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0))
store = _store_with(pid="PF-1", cash=1_000_000.0, qty=0.0)
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
@@ -117,7 +117,7 @@ def test_apply_cash_estimate_overlays_pending_delta():
def test_estimate_clears_after_writeback():
"""Once the ledger write completes (no longer pending), the estimate is gone."""
store = _store_with(cash=1_000_000.0)
outbox = InMemoryLedgerOutbox()
outbox = InMemoryLedgerOutbox(instance_key="test-instance")
ledger = InMemoryLedger(cash_after=989_999.0, quantity_after=10.0)
writer = LedgerWriter(outbox, ledger, snapshot_store=store)
outbox.enqueue(_trade(side="buy", qty=10, price=1000.0, fee=1.0))

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