18 Commits
Author SHA1 Message Date
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
77 changed files with 2809 additions and 379 deletions
+16 -2
View File
@@ -2,8 +2,6 @@ name: Publish verified container image
on:
push:
branches:
- main
tags:
- "v*"
@@ -16,12 +14,26 @@ jobs:
permissions:
contents: read
steps:
- name: Reject non-version publication events
env:
PUBLICATION_EVENT: ${{ github.event_name }}
PUBLICATION_REF: ${{ github.ref }}
run: |
if [[ "${PUBLICATION_EVENT}" != "push" \
|| ! "${PUBLICATION_REF}" =~ ^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "publication requires a strict vX.Y.Z tag push" >&2
exit 1
fi
- name: Check out exact source commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Reverify exact checked out commit
run: python scripts/image_release.py verify-checkout --commit "${GITHUB_SHA}"
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
@@ -108,6 +120,7 @@ jobs:
image: ${{ steps.publish.outputs.image }}
digest: ${{ steps.publish.outputs.digest }}
source_commit: ${{ steps.publish.outputs.source_commit }}
version_tag: ${{ steps.publish.outputs.version_tag }}
steps:
- name: Download tested image artifact
uses: actions/download-artifact@v4
@@ -127,4 +140,5 @@ jobs:
--image-id tested-image/trex-core-image.id \
--local-image "trex-core-ci:${GITHUB_SHA}" \
--commit "${GITHUB_SHA}" \
--event-name "${GITHUB_EVENT_NAME}" \
--ref "${GITHUB_REF}"
@@ -0,0 +1,72 @@
name: Verify published trex-core image
on:
workflow_dispatch:
inputs:
version:
description: "Strict release tag (for example v0.2.0)"
required: true
type: string
commit:
description: "Exact 40-character lowercase source commit"
required: true
type: string
permissions:
contents: read
packages: read
jobs:
verify:
runs-on: ubuntu-24.04
steps:
- name: Validate immutable selectors and image contract
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
VERSION: ${{ inputs.version }}
COMMIT: ${{ inputs.commit }}
IMAGE: ghcr.io/fourblocks2020/trex-core
run: |
set -euo pipefail
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]]
short_sha="${COMMIT:0:12}"
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io --username "$ACTOR" --password-stdin >/dev/null
version_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$VERSION")"
source_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$short_sha")"
version_digest="sha256:$(printf '%s' "$version_raw" | sha256sum | cut -d' ' -f1)"
source_digest="sha256:$(printf '%s' "$source_raw" | sha256sum | cut -d' ' -f1)"
test "$version_digest" = "$source_digest"
docker pull "$IMAGE@$version_digest" >/dev/null
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Architecture}}')" = amd64
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Config.User}}')" = 10001:10001
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$COMMIT"
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.source"}}')" = https://github.com/fourblocks2020/trex-core
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{json .Config.Entrypoint}}')" = '["trex-core-daemon"]'
test "$(docker run --rm --entrypoint python "$IMAGE@$version_digest" -c 'from trex_core.adapters.persistence.migrate import migration_files; p=migration_files(); print(f"count={len(p)} last={p[-1].name}")')" = 'count=21 last=022_persist_order_timeout_anchor.sql'
if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then
echo "mutable latest selector exists" >&2
exit 1
fi
echo "digest=$version_digest"
echo "version_selector=$IMAGE:$VERSION"
echo "source_selector=$IMAGE:$short_sha"
echo "immutable=$IMAGE@$version_digest"
{
echo "## Verified published trex-core image"
echo
echo "- digest: \`$version_digest\`"
echo "- version selector: \`$IMAGE:$VERSION\`"
echo "- source selector: \`$IMAGE:$short_sha\`"
echo "- source commit: \`$COMMIT\`"
echo "- architecture: \`linux/amd64\`"
echo "- runtime user: \`10001:10001\`"
echo "- entrypoint: \`trex-core-daemon\`"
echo "- migration inventory: \`21\`, last \`022_persist_order_timeout_anchor.sql\`"
echo "- latest: absent"
} >> "$GITHUB_STEP_SUMMARY"
+5 -5
View File
@@ -3,14 +3,14 @@ id: "ac-0004"
status: active
---
# 검증 성공 이미지에 한정된 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로 확인한다.
+5 -5
View File
@@ -3,14 +3,14 @@ id: "ac-0006"
status: active
---
# 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로 확인한다.
+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`, 누락, 미지원 코드 또는 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)`로 기록되고 cancel/restart는 SOR를 유지한다. historical SOR는 `(SOR,NULL)`이며 정상 `(order=KRX, actual=NXT)`는 migration 반복 후 보존된다.
- 실패·경계 조건: actual SOR 저장, unknown code를 KRX/NXT로 추론, actual venue로 slot order venue 덮어쓰기, NULL actual SOR fill의 거짓 ledger 기록, AUTO·NULL order 저장 또는 migration replay 오염은 FAIL이다.
- 검사 방법: KIS parser tests, execution ledger tests, `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를 수행한다.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"next_line_number": 3,
"next_ac_number": 9
"next_line_number": 7,
"next_ac_number": 20
}
+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의 exact 의미를 변경하지 않고 이 Requirement의 구현 evidence를 해당 criterion에 `satisfy`로 결속한다.
## 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에 기록하지 않는다.
- active AC-0018과 AC-0019의 본문·status를 이 satisfy binding으로 변경하지 않는다.
- 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 소급 주장
+5 -2
View File
@@ -13,12 +13,15 @@ RUN test "${#SOURCE_COMMIT}" -eq 40 \
esac \
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
COPY pyproject.toml ./
COPY pyproject.toml uv.lock ./
COPY trex_core/ ./trex_core/
RUN python -m pip install --no-cache-dir . \
RUN python -m pip install --no-cache-dir "uv==0.11.14" \
&& uv sync --locked --no-dev --no-editable \
&& useradd --create-home --uid 10001 trex
ENV PATH="/opt/trex-core/.venv/bin:${PATH}"
USER 10001:10001
ENTRYPOINT ["trex-core-daemon"]
+8 -4
View File
@@ -49,9 +49,12 @@
- limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가.
- allocated_capital :: numeric? :: 배정 자본
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(AUTO/KRX/NXT/SOR). intent 단계의 AUTO는 KIS 제출 시 실제 KRX/NXT로 교체되며 주문번호와 원자 저장한다. pending 주문·취소까지 보존하고 HOLDING 진입 시 다음 자율주문의 AUTO, INACTIVE 진입 시 KRX로 초기화한다.
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니며 같은 주문의 자동 재취소를 막는다.
- cancel_requested_at :: TIMESTAMPTZ? :: 취소 요청 결과를 저장한 시각. 주문 종료·dispatch clear 때 함께 제거한다.
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 원주문/라우팅 거래소(KRX/NXT/SOR). venue 누락 intent는 SOR로 저장하고, pending 주문·재시작·취소까지 보존한다. 실제 KRX/NXT 체결 거래소는 `FillStatus.execution_venue`에서 별도로 확정해 `ledger_outbox.venue`에 기록하고 원주문은 `ledger_outbox.order_venue`에 보존한다.
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니다. requested는 재시도하지 않고, rejected/unknown만 authoritative open-row 확인과 bounded backoff 뒤 재시도할 수 있다.
- cancel_requested_at :: TIMESTAMPTZ? :: 마지막 취소 시도 예약 시각. 주문 종료·dispatch clear 때 함께 제거한다.
- order_submitted_at :: TIMESTAMPTZ? :: timeout deadline의 immutable 주문 제출 기준점. poll/cancel bookkeeping 및 daemon 재시작에서 보존하고 terminal dispatch clear 때 제거한다.
- order_date :: TEXT? :: 원주문 KST 날짜(`YYYYMMDD`). 날짜 rollover 뒤 KIS 일별주문체결조회가 현재 날짜가 아닌 원주문 날짜를 사용하도록 보존한다.
- cancel_attempts :: INTEGER NOT NULL DEFAULT 0 :: 원자적으로 예약된 취소 시도 횟수. 새 dispatch에서 0, terminal dispatch clear에서 0으로 초기화한다.
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
- portfolio_id :: str? :: 포트폴리오 식별
- forced_close_at :: datetime? :: 강제 청산 마감선
@@ -150,7 +153,8 @@
- side :: TEXT CHECK ('buy','sell')
- qty / price / fee / tax :: NUMERIC(18,4)
- executed_at :: TIMESTAMPTZ?
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 실제 제출 거래소. 기존 행은 KRX로 보존한다.
- venue :: TEXT? CHECK (NULL 또는 'KRX'/'NXT') :: broker-authoritative 실제 체결 거래소. 과거 SOR row처럼 실제 거래소를 복원할 수 없으면 NULL이며 임의 추론하지 않는다.
- order_venue :: TEXT NOT NULL DEFAULT 'KRX' CHECK ('KRX'/'NXT'/'SOR') :: 원주문/라우팅 venue. 취소·재시작 감사에 사용한다.
- status :: TEXT CHECK ('pending','completed','conflict_blocked','delivery_failed') — `delivery_failed` 추가(dcy-015, 마이그레이션 007)
- attempts :: INTEGER :: 재시도 누적
- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked','permanent_failure','dead_lettered')
+14 -10
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` 패턴으로 바인딩 |
@@ -396,7 +396,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 +414,10 @@ resume_kill_switch 요청·응답 (REQ-046):
### Additive Notes (REQ-117)
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환.
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 기본은 `AUTO`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. 허용값 외에는 `invalid_venue`로 admission 전 거부한다.
- KIS 어댑터는 `AUTO`를 KST 세션 기준 실제 KRX/NXT로 확정한 뒤 `EXCG_ID_DVSN_CD`로 전송한다. 결정 불가능 구간은 KRX로 대체하지 않고 주문을 거부한다.
- `OrderAck.venue`의 실제 제출 거래소는 `broker_order_id`와 함께 슬롯에 원자 저장되며 재시작·취소·Core 원장 outbox에서 재사용한다.
- `venue` 필드는 BAT-056에서 additive 추가됐으며, 현재 계약에서 기존 orb/tsl을 포함한 미지정 호출은 `SOR`로 정규화한다.
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 누락은 `SOR`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. 폐기된 `AUTO`와 그 밖의 값은 `invalid_venue`로 admission 전 거부한다.
- KIS 어댑터는 KRX/NXT/SOR를 시간과 무관하게 보존해 `EXCG_ID_DVSN_CD`로 전송한다.
- `OrderAck.venue`는 원주문 KRX/NXT/SOR를 `broker_order_id`와 함께 슬롯에 원자 저장하며 재시작·취소에 재사용한다. 실제 체결 거래소는 `FillStatus.execution_venue`의 KRX/NXT로 별도 전달해 outbox `venue`에 기록한다. SOR 또는 미지원 코드는 실제 거래소로 추론하지 않는다.
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
@@ -447,8 +447,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 +461,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)
@@ -630,7 +634,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).
+2
View File
@@ -42,6 +42,8 @@ pkill -TERM -f trex-core-daemon
### 컨테이너 설정, 배포 및 rollback
- GHCR publication은 requester가 별도로 승인하여 생성하고 push한 strict `vX.Y.Z` Git tag에서만 시작한다. 숫자 구성요소는 `0` 또는 leading zero 없는 decimal integer이며 prerelease/build suffix는 허용하지 않는다. 일반 `main`/branch push, PR, non-push event와 malformed tag는 image를 게시하지 않는다. tag 생성과 push 자체는 이 저장소의 구현·검증과 분리된 사용자 승인 외부 쓰기다.
- Git tag version은 `pyproject.toml` package version과 독립이다. 두 값의 일치를 publication 조건으로 해석하거나 package version 변경만으로 publication이 시작된다고 해석하지 않는다.
- 이미지는 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하지 않는다.
+1 -1
View File
@@ -264,7 +264,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)
+36 -15
View File
@@ -20,6 +20,7 @@ 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}$")
VERSION_TAG_RE = re.compile(r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
DEFAULT_MIGRATION_DIR = (
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
)
@@ -100,19 +101,26 @@ def validate_commit(commit: str) -> str:
return commit
def release_tags(commit: str, ref: str) -> list[str]:
def validate_publication_trigger(event_name: str, ref: str) -> str:
if event_name != "push":
raise ValueError("publication event must be a push")
if not VERSION_TAG_RE.fullmatch(ref):
raise ValueError("publication ref must be a strict vX.Y.Z tag")
return ref.removeprefix("refs/tags/")
def verify_checkout(commit: str, runner: Runner) -> str:
expected = validate_commit(commit)
actual = runner.run(["git", "rev-parse", "HEAD"]).stdout.strip()
if actual != expected:
raise ValueError("checked out HEAD does not match source commit")
return actual
def release_tags(commit: str, event_name: 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")
release = validate_publication_trigger(event_name, ref)
return [f"{IMAGE}:{commit[:12]}", f"{IMAGE}:{release}"]
def sha256_file(path: Path) -> str:
@@ -359,6 +367,7 @@ def publish_image(
image_id: Path,
local_image: str,
commit: str,
event_name: str,
ref: str,
actor: str,
token: str,
@@ -366,7 +375,8 @@ def publish_image(
output_path: Path | None = None,
summary_path: Path | None = None,
) -> str:
tags = release_tags(commit, ref)
tags = release_tags(commit, event_name, ref)
version_tag = validate_publication_trigger(event_name, ref)
verify_checksum(archive, checksum)
expected_image_id = read_image_id(image_id)
loaded = runner.run(["docker", "load", "--input", str(archive)])
@@ -406,13 +416,17 @@ def publish_image(
digest = digests[0]
reference = f"{IMAGE}@{digest}"
_append(output_path, f"image={reference}\ndigest={digest}\nsource_commit={commit}\n")
_append(
output_path,
f"image={reference}\ndigest={digest}\nsource_commit={commit}\nversion_tag={version_tag}\n",
)
_append(
summary_path,
"## Published trex-core image\n\n"
f"- Image: `{reference}`\n"
f"- Digest: `{digest}`\n"
f"- Source commit: `{commit}`\n",
f"- Source commit: `{commit}`\n"
f"- Version tag: `{version_tag}`\n",
)
return digest
@@ -425,6 +439,9 @@ def _parser() -> argparse.ArgumentParser:
smoke.add_argument("--image", required=True)
smoke.add_argument("--timeout", type=int, default=30)
verify_checkout_parser = commands.add_parser("verify-checkout")
verify_checkout_parser.add_argument("--commit", required=True)
scan = commands.add_parser("scan")
scan.add_argument("--image", required=True)
scan.add_argument("--archive", type=Path, required=True)
@@ -450,6 +467,7 @@ def _parser() -> argparse.ArgumentParser:
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("--event-name", required=True)
publish.add_argument("--ref", required=True)
return parser
@@ -459,6 +477,8 @@ def main(argv: Sequence[str] | None = None) -> int:
runner = SubprocessRunner()
if args.command == "smoke":
smoke_image(args.image, runner, args.timeout)
elif args.command == "verify-checkout":
verify_checkout(args.commit, runner)
elif args.command == "scan":
scan_image(args.image, args.archive, runner)
elif args.command == "checksum":
@@ -476,6 +496,7 @@ def main(argv: Sequence[str] | None = None) -> int:
image_id=args.image_id,
local_image=args.local_image,
commit=args.commit,
event_name=args.event_name,
ref=args.ref,
actor=os.environ.get("GITHUB_ACTOR", ""),
token=os.environ.get("REGISTRY_TOKEN", ""),
+12 -4
View File
@@ -143,7 +143,9 @@ def test_poll_fill_uses_current_all_market_contract(monkeypatch):
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get)
status = broker.poll_fill("91258:1234", side="buy", symbol="005930")
status = broker.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
assert status.state == "accepted"
assert seen["tr_id"] == "VTTC0081R"
@@ -578,8 +580,12 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail)
first = b.poll_fill("91258:1234", side="buy", symbol="005930")
immediate = b.poll_fill("91258:1234", side="buy", symbol="005930")
first = b.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
immediate = b.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
assert first.state == "unknown"
assert immediate.state == "accepted"
@@ -587,6 +593,8 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
assert len(calls) == 1
clock[0] += 31
again = b.poll_fill("91258:1234", side="buy", symbol="005930")
again = b.poll_fill(
"91258:1234", side="buy", symbol="005930", order_date="20260608"
)
assert again.state == "unknown"
assert len(calls) == 2
+18 -6
View File
@@ -59,13 +59,17 @@ def _row(**kw):
def test_full_external_cancel_maps_to_cancelled(monkeypatch):
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="Y")))
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.state == "cancelled" and fs.is_terminal_reject and not fs.is_partial_cancel
def test_zero_fill_not_cancelled_stays_accepted(monkeypatch):
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="N")))
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.state == "accepted" and not fs.is_terminal_reject
@@ -83,7 +87,9 @@ def test_zero_fill_full_rejection_maps_to_rejected(monkeypatch):
)
),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608"
)
assert fs.state == "rejected" and fs.is_terminal_reject
@@ -93,7 +99,9 @@ def test_zero_fill_partial_rejection_does_not_infer_terminal(monkeypatch):
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="0", rjct_qty="4", cncl_yn="")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608"
)
assert fs.state == "accepted" and not fs.is_terminal_reject
@@ -103,7 +111,9 @@ def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.state == "partially_filled"
assert fs.cancelled and fs.is_partial_cancel
assert fs.filled_qty == 4 and fs.avg_price == 70000.0
@@ -116,5 +126,7 @@ def test_full_fill_unaffected_by_cancel_flag(monkeypatch):
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")),
)
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608"
)
assert fs.is_filled and fs.filled_qty == 10 and not fs.is_partial_cancel
+28 -1
View File
@@ -25,6 +25,7 @@ class FakeBroker:
def __init__(self, balance, fill=None):
self.balance = balance
self.fill = fill or FillStatus(state="accepted")
self.poll_metadata = None
def name(self):
return "fake"
@@ -32,7 +33,16 @@ class FakeBroker:
def submit_order(self, req):
return OrderAck(accepted=True, broker_order_id="1")
def poll_fill(self, broker_order_id, *, side, symbol):
def poll_fill(
self,
broker_order_id,
*,
side,
symbol,
order_date=None,
submitted_at=None,
):
self.poll_metadata = (order_date, submitted_at)
return self.fill
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
@@ -77,6 +87,23 @@ def test_confirmed_fill_is_persisted_once_and_manual_remainder_stays_hidden(tmp_
assert StrategyOwnershipStore(store.path).snapshot()[0] == {"005930": 2}
def test_poll_preserves_original_order_metadata_through_ownership_decorator(tmp_path):
store = StrategyOwnershipStore(tmp_path / "ownership.json")
delegate = FakeBroker([])
broker = OwnershipAwareBroker(delegate, store)
submitted_at = datetime(2026, 8, 14, 6, 20, tzinfo=timezone.utc)
broker.poll_fill(
"1",
side="buy",
symbol="005930",
order_date="20260814",
submitted_at=submitted_at,
)
assert delegate.poll_metadata == ("20260814", submitted_at)
def test_strategy_quantity_above_raw_broker_total_fails_closed(tmp_path):
store = StrategyOwnershipStore(tmp_path / "ownership.json")
store.apply_fill(
+49 -43
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,55 @@ 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 ack.accepted is False
assert ack.reason == "auto_venue_session_closed"
assert status.execution_venue == "NXT"
# ---------------------------------------------------------------------------
@@ -7,10 +7,16 @@ iteration: EXPECTED_SLOT_FIELDS (code contract) vs daily_slot actual columns
from __future__ import annotations
import psycopg
import pytest
from datetime import datetime, timezone
from trex_core.adapters.persistence.migrate import apply_migrations, migration_files
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, SlotService, SlotState
from trex_core.adapters.persistence.postgresql import (
PostgreSQLLedgerOutbox,
PostgreSQLSlotRepository,
)
from trex_core.application.ledger_port import LedgerTrade
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS
_EXPECTED_TABLES = {
"daily_slot",
@@ -64,20 +70,90 @@ def test_apply_migrations_is_idempotent(pg_dsn):
apply_migrations(pg_dsn)
def test_migration_replay_accepts_existing_auto_venue_row(pg_clean):
def test_migration_replay_converts_existing_auto_venue_row_to_sor(pg_clean):
repo = PostgreSQLSlotRepository(pg_clean)
service = SlotService(repo, instance_key="migration-auto")
repo.reconcile_universe("migration-auto", {"pf-auto": "005930"})
slot = repo.find_slot_by_portfolio("migration-auto", "pf-auto")
service.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="migration_replay",
actor="test",
venue="AUTO",
)
assert slot is not None
# Simulate a row written before migration 023 tightened the constraint.
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
cur.execute("ALTER TABLE daily_slot DROP CONSTRAINT daily_slot_venue_check")
cur.execute(
"UPDATE daily_slot SET venue = 'AUTO' WHERE slot_id = %s",
(slot.slot_id,),
)
conn.commit()
apply_migrations(pg_clean)
assert repo.fetch_slot(slot.slot_id).venue == "AUTO"
assert repo.fetch_slot(slot.slot_id).venue == "SOR"
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
with pytest.raises(psycopg.errors.CheckViolation):
cur.execute(
"UPDATE daily_slot SET venue = 'AUTO' WHERE slot_id = %s",
(slot.slot_id,),
)
def test_migration_024_separates_legacy_sor_and_is_replay_safe(pg_clean):
outbox = PostgreSQLLedgerOutbox(pg_clean)
normal = LedgerTrade(
asset_id="PF-NORMAL",
order_id="O-NORMAL",
execution_id="E-NORMAL",
side="buy",
qty=1,
price=1000,
executed_at=datetime(2026, 8, 19, tzinfo=timezone.utc),
venue="NXT",
order_venue="KRX",
)
legacy = LedgerTrade(
asset_id="PF-LEGACY",
order_id="O-LEGACY",
execution_id="E-LEGACY",
side="buy",
qty=1,
price=1000,
executed_at=datetime(2026, 8, 19, tzinfo=timezone.utc),
venue="KRX",
order_venue="SOR",
)
outbox.enqueue(normal)
outbox.enqueue(legacy)
# Recreate a pre-024 SOR row: one venue column carried routing provenance,
# and no separate order_venue was available.
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
cur.execute("ALTER TABLE ledger_outbox DROP CONSTRAINT ledger_outbox_venue_check")
cur.execute("ALTER TABLE ledger_outbox ALTER COLUMN order_venue DROP NOT NULL")
cur.execute(
"UPDATE ledger_outbox SET venue='SOR', order_venue=NULL "
"WHERE correlation_id=%s",
(legacy.correlation_id,),
)
conn.commit()
apply_migrations(pg_clean)
apply_migrations(pg_clean)
normal_row = outbox.get(normal.correlation_id)
legacy_row = outbox.get(legacy.correlation_id)
assert normal_row is not None
assert normal_row.trade.venue == "NXT"
assert normal_row.trade.order_venue == "KRX"
assert legacy_row is not None
assert legacy_row.trade.venue is None
assert legacy_row.trade.order_venue == "SOR"
with psycopg.connect(pg_clean) as conn:
with conn.cursor() as cur:
with pytest.raises(psycopg.errors.CheckViolation):
cur.execute(
"UPDATE ledger_outbox SET venue='SOR' WHERE correlation_id=%s",
(normal.correlation_id,),
)
@@ -20,7 +20,8 @@ def _trade(execution_id="paper:1:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="ORD-1", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc), venue="NXT",
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
venue="NXT", order_venue="SOR",
)
@@ -33,6 +34,7 @@ def test_enqueue_persists_and_is_idempotent(pg_clean):
assert len(pending) == 1
assert pending[0].status is WorkItemStatus.PENDING
assert pending[0].trade.venue == "NXT"
assert pending[0].trade.order_venue == "SOR"
def test_writer_completes_persisted_item(pg_clean):
@@ -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,
)
+2 -1
View File
@@ -116,7 +116,8 @@ def test_partial_fill_then_cancel_records_and_keeps_remainder():
broker.balance = {"005930": 6}
broker.responder = lambda side: FillStatus(
state="partially_filled", filled_qty=4, avg_price=1100.0,
execution_id="exec-partial", fee=0.5, tax=0.0, cancelled=True,
execution_id="exec-partial", fee=0.5, tax=0.0,
execution_venue="KRX", cancelled=True,
)
r = execution.run_once()
slot = repo.fetch_slot(sid)
@@ -148,6 +148,7 @@ def _make_filled_status(
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue="KRX",
)
@@ -167,6 +168,7 @@ def _make_partial_cancel_status(
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue="KRX",
cancelled=True,
)
+2 -1
View File
@@ -59,7 +59,8 @@ 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_sell_fill_enqueues_sell_ledger_item():
+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
# ============================================================================
@@ -0,0 +1,413 @@
"""Synthetic coverage for GitHub #58 timeout/cancel reconciliation."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from uuid import UUID
import pytest
from trex_core.adapters.broker import kis as kis_mod
from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import CancelOutcome, CancelState, FillStatus
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.runtime.config import DaemonInstanceConfig
from tests.application.test_execution_policy import _RecordingBroker
_KST = timezone(timedelta(hours=9))
def _wire(*, default_timeout=None):
repo = InMemorySlotRepository()
service = SlotService(repo)
intents = IntentService(
service,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda _pid: "005930",
default_order_timeout_sec=default_timeout,
)
repo.reconcile_universe("default", {"PF-1": "005930"})
return repo, service, intents
@pytest.mark.parametrize("value", [0, -1, 3601, True, 1.5, "30"])
def test_direct_strategy_timeout_outside_integer_bounds_is_rejected(value):
repo, _, intents = _wire()
decision = intents.submit_buy(
BuyIntent(intent_id=f"b-{value!r}", portfolio_id="PF-1", qty=1, timeout_sec=value)
)
assert decision.status == "rejected"
assert decision.reason.startswith("bad_request:timeout_sec_")
assert repo.list_undispatched_pending() == []
@pytest.mark.parametrize("value", [0, -1, 3601, True, 1.5, "30"])
def test_direct_sell_timeout_outside_integer_bounds_is_rejected(value):
repo, _, intents = _wire()
decision = intents.submit_sell(
SellIntent(intent_id=f"s-{value!r}", slot_id=UUID(int=1), timeout_sec=value)
)
assert decision.status == "rejected"
assert decision.reason.startswith("bad_request:timeout_sec_")
assert repo.list_undispatched_pending() == []
@pytest.mark.parametrize("value", [0, -1, 3601, 1.5, True, "30", "not-an-int"])
@pytest.mark.parametrize("intent_type", ["buy", "sell"])
def test_wire_strategy_timeout_bad_request_before_admission(value, intent_type):
repo, _, intents = _wire()
payload = {
"intent_type": intent_type,
"intent_id": "bad-timeout",
"timeout_sec": value,
}
if intent_type == "buy":
payload.update({"portfolio_id": "PF-1", "qty": 1})
else:
payload["slot_id"] = str(UUID(int=1))
response = make_intent_rpc_handler(intents)(
RpcRequest(
correlation_id="bad-timeout",
payload=payload,
)
)
assert response.payload["status"] == "rejected"
assert response.payload["reason"].startswith("bad_request:")
assert repo.list_undispatched_pending() == []
@pytest.mark.parametrize("value", [1, 3600])
def test_strategy_timeout_inclusive_bounds_are_admitted(value):
repo, _, intents = _wire()
decision = intents.submit_buy(
BuyIntent(intent_id=f"b-{value}", portfolio_id="PF-1", qty=1, timeout_sec=value)
)
assert decision.admitted
assert repo.fetch_slot(decision.slot_id).timeout_sec == value
def test_buy_and_sell_omission_use_instance_override_or_hard_fallback():
repo, service, intents = _wire(default_timeout=120)
buy = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=1))
assert repo.fetch_slot(buy.slot_id).timeout_sec == 120
service.transit_slot(
buy.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="fixture",
actor="test",
qty=1,
avg_price=1000,
)
sell = intents.submit_sell(SellIntent(intent_id="s1", slot_id=buy.slot_id))
assert sell.admitted
assert repo.fetch_slot(buy.slot_id).timeout_sec == 120
fallback_repo, _, fallback = _wire(default_timeout=None)
fallback_buy = fallback.submit_buy(
BuyIntent(intent_id="fallback", portfolio_id="PF-1", qty=1)
)
assert fallback_repo.fetch_slot(fallback_buy.slot_id).timeout_sec == 3600
@pytest.mark.parametrize("value", [0, -1, 3601, 1.5, True, "30"])
def test_instance_timeout_outside_toml_integer_contract_fails_startup(tmp_path, value):
path = tmp_path / "instance.toml"
literal = str(value).lower() if isinstance(value, bool) else repr(value)
path.write_text(f'instance_key = "x"\ndefault_order_timeout_sec = {literal}\n')
with pytest.raises(RuntimeError, match="default_order_timeout_sec"):
DaemonInstanceConfig.from_toml(str(path))
def test_instance_timeout_omission_and_bounds(tmp_path):
path = tmp_path / "instance.toml"
path.write_text('instance_key = "x"\n')
assert DaemonInstanceConfig.from_toml(str(path)).default_order_timeout_sec == 3600
for value in (1, 3600):
path.write_text(f'instance_key = "x"\ndefault_order_timeout_sec = {value}\n')
assert DaemonInstanceConfig.from_toml(str(path)).default_order_timeout_sec == value
def test_migration_does_not_invent_legacy_submission_metadata():
migration = (
Path(__file__).parents[2]
/ "trex_core/adapters/persistence/sql/022_persist_order_timeout_anchor.sql"
).read_text(encoding="utf-8")
assert "ADD COLUMN IF NOT EXISTS order_submitted_at" in migration
assert "ADD COLUMN IF NOT EXISTS order_date" in migration
assert "UPDATE daily_slot" not in migration
assert "COALESCE(updated_at, created_at)" not in migration
def test_dispatch_anchor_and_original_date_survive_bookkeeping_and_restart():
broker = _RecordingBroker()
repo, service, intents = _wire()
execution = ExecutionService(service, repo, broker)
decision = intents.submit_buy(
BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=1, timeout_sec=60)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
dispatched = repo.fetch_slot(decision.slot_id)
submitted_at = dispatched.order_submitted_at
order_date = dispatched.order_date
assert submitted_at is not None and order_date == submitted_at.astimezone(_KST).strftime("%Y%m%d")
assert repo.replace_dispatch(dispatched.slot_id, dispatched.broker_order_id, "resolved-1")
resolved = repo.fetch_slot(dispatched.slot_id)
assert resolved.order_submitted_at == submitted_at
assert resolved.order_date == order_date
repo.mark_cancel_outcome(dispatched.slot_id, "resolved-1", "unknown")
persisted = repo.fetch_slot(dispatched.slot_id)
assert persisted.updated_at != dispatched.updated_at
assert persisted.order_submitted_at == submitted_at
assert persisted.order_date == order_date
assert ExecutionService(service, repo, broker).reconcile().filled == 0
restarted = repo.fetch_slot(dispatched.slot_id)
assert restarted.order_submitted_at == submitted_at
assert restarted.order_date == order_date
service.transit_slot(
dispatched.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="terminal-fixture",
actor="test",
qty=1,
avg_price=1000,
)
repo.clear_dispatch(dispatched.slot_id)
terminal = repo.fetch_slot(dispatched.slot_id)
assert terminal.order_submitted_at is None
assert terminal.order_date is None
assert terminal.cancel_attempts == 0
class _AlarmRecorder:
def __init__(self):
self.calls = []
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
self.calls.append((code, severity, fields))
def test_legacy_pending_without_authoritative_metadata_is_fail_closed_and_alarm_bounded():
broker = _RecordingBroker()
repo, service, intents = _wire()
alarms = _AlarmRecorder()
execution = ExecutionService(service, repo, broker, alarm_emitter=alarms)
decision = intents.submit_buy(
BuyIntent(intent_id="legacy", portfolio_id="PF-1", qty=1, timeout_sec=1)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
slot = repo.fetch_slot(decision.slot_id)
repo._slots[slot.slot_id] = replace(
slot,
order_submitted_at=None,
order_date=None,
updated_at=datetime.now(tz=timezone.utc) - timedelta(days=2),
)
broker.responder = lambda _side: FillStatus(
state="filled",
filled_qty=1,
avg_price=1000,
execution_id="wrong-current-date-order",
)
execution.run_once()
execution.run_once()
persisted = repo.fetch_slot(slot.slot_id)
assert persisted.status is SlotState.PENDING_BUY
assert broker.cancel_calls == []
metadata_alarms = [
call for call in alarms.calls if call[0] == "order_reconciliation_metadata_unknown"
]
assert len(metadata_alarms) == 1
assert set(metadata_alarms[0][2]) == {"slot_id", "symbol"}
def test_requested_cancel_terminal_unresolved_alarm_is_deduped_per_process_and_reemitted_after_restart():
broker = _RecordingBroker()
repo, service, intents = _wire()
alarms = _AlarmRecorder()
execution = ExecutionService(service, repo, broker, alarm_emitter=alarms)
decision = intents.submit_buy(
BuyIntent(intent_id="requested", portfolio_id="PF-1", qty=1, timeout_sec=1)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
slot = repo.fetch_slot(decision.slot_id)
repo._slots[slot.slot_id] = replace(
slot,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(minutes=1),
)
execution.run_once()
assert repo.fetch_slot(slot.slot_id).cancel_state == "requested"
assert len(broker.cancel_calls) == 1
broker.responder = lambda _side: FillStatus(
state="unknown",
raw={
"reason": "order_not_visible_unresolved",
"raw_payload": {"authorization": "secret"},
},
)
execution.run_once()
execution.run_once()
persisted = repo.fetch_slot(slot.slot_id)
assert persisted.status is SlotState.PENDING_BUY
assert len(broker.cancel_calls) == 1
unresolved = [call for call in alarms.calls if call[0] == "order_cancel_unresolved"]
assert len(unresolved) == 1
assert unresolved[0][1] == "critical"
assert set(unresolved[0][2]) == {"slot_id", "symbol", "cancel_state", "order_date"}
assert "secret" not in repr(unresolved[0])
ExecutionService(service, repo, broker, alarm_emitter=alarms).run_once()
assert len(broker.cancel_calls) == 1
assert len([call for call in alarms.calls if call[0] == "order_cancel_unresolved"]) == 2
def test_cancel_rejected_is_fail_closed_alarm_and_authoritative_bounded_retry():
broker = _RecordingBroker()
broker.cancel_outcome = CancelOutcome(CancelState.REJECTED, "closed_session")
repo, service, intents = _wire()
alarms = _AlarmRecorder()
execution = ExecutionService(service, repo, broker, alarm_emitter=alarms)
decision = intents.submit_buy(
BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=1, timeout_sec=1)
)
broker.responder = lambda _side: FillStatus(state="accepted")
execution.run_once()
slot = repo.fetch_slot(decision.slot_id)
repo._slots[slot.slot_id] = replace(
slot,
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(minutes=1),
)
execution.run_once()
rejected = repo.fetch_slot(slot.slot_id)
assert rejected.status is SlotState.PENDING_BUY
assert rejected.cancel_state == "rejected"
assert len(broker.cancel_calls) == 1
assert any(code == "order_cancel_unresolved" and severity == "critical" for code, severity, _ in alarms.calls)
# No visible broker row => no blind retry, even after restart/backoff.
repo._slots[slot.slot_id] = replace(
rejected,
cancel_requested_at=datetime.now(tz=timezone.utc) - timedelta(minutes=10),
)
ExecutionService(service, repo, broker, alarm_emitter=alarms).run_once()
assert len(broker.cancel_calls) == 1
# An authoritative open row permits one backoff-gated retry.
broker.responder = lambda _side: FillStatus(
state="accepted", raw={"order_open_confirmed": True}
)
broker.cancel_outcome = CancelOutcome(CancelState.REQUESTED)
ExecutionService(service, repo, broker, alarm_emitter=alarms).run_once()
assert len(broker.cancel_calls) == 2
retried = repo.fetch_slot(slot.slot_id)
assert retried.cancel_state == "requested"
assert retried.cancel_attempts == 2
ExecutionService(service, repo, broker).run_once()
assert len(broker.cancel_calls) == 2
class _Resp:
status_code = 200
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def _kis(clock):
broker = KISBroker(
"12345678-01",
app_key="k",
app_secret="s",
base_url="https://openapivts.koreainvestment.com:29443",
clock=clock,
)
broker._token = "token"
broker._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
return broker
def test_kis_rollover_uses_original_date_and_row_visibility_is_nonterminal(monkeypatch):
now = datetime(2026, 8, 15, 9, 0, tzinfo=_KST)
submitted = datetime(2026, 8, 14, 15, 20, tzinfo=_KST)
seen = {}
def fake_get(*_args, **kwargs):
seen.update(kwargs["params"])
return _Resp({"rt_cd": "0", "output1": []})
monkeypatch.setattr(kis_mod.httpx, "get", fake_get)
status = _kis(lambda: now).poll_fill(
"06010:0000123456",
side="buy",
symbol="005930",
order_date="20260814",
submitted_at=submitted,
)
assert seen["INQR_STRT_DT"] == seen["INQR_END_DT"] == "20260814"
assert status.state == "unknown"
assert status.raw["reason"] == "order_not_visible_unresolved"
assert status.raw["order_visible"] is False
def test_kis_recent_missing_row_stays_in_bounded_visibility_grace(monkeypatch):
now = datetime(2026, 8, 14, 9, 0, tzinfo=_KST)
monkeypatch.setattr(
kis_mod.httpx,
"get",
lambda *_args, **_kwargs: _Resp({"rt_cd": "0", "output1": []}),
)
status = _kis(lambda: now).poll_fill(
"06010:0000123457",
side="sell",
symbol="005930",
order_date="20260814",
submitted_at=now - timedelta(seconds=30),
)
assert status.state == "accepted"
assert status.raw["reason"] == "order_visibility_grace"
assert status.raw["order_visible"] is False
def test_kis_missing_authoritative_order_date_fails_closed_without_network(monkeypatch):
now = datetime(2026, 8, 15, 9, 0, tzinfo=_KST)
def forbidden_get(*_args, **_kwargs):
raise AssertionError("legacy order without original date must not query KIS")
monkeypatch.setattr(kis_mod.httpx, "get", forbidden_get)
status = _kis(lambda: now).poll_fill(
"06010:0000123456",
side="buy",
symbol="005930",
order_date=None,
submitted_at=None,
)
assert status.state == "unknown"
assert not status.is_filled
assert not status.is_terminal_reject
assert status.raw == {"reason": "order_reconciliation_metadata_unknown"}
+66 -74
View File
@@ -1,18 +1,12 @@
"""rejected SellIntent `retryable` flag (issue #27 / REQ-007 CHG-20260615-01).
Additive: only on intent_type="sell" + status="rejected". Maps reason→retryable
via the core-owned table (unrecognized → True, safety default; bad_request → False).
admitted / processing / buy responses must NOT carry the field.
"""
"""Strategy RPC responses never expose broker retryability (GitHub #60)."""
from __future__ import annotations
from uuid import uuid4
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import IntentService
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import (
_sell_reject_retryable,
make_intent_rpc_handler,
)
from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@@ -22,9 +16,14 @@ INST = "default"
def _wire():
repo = InMemorySlotRepository()
svc = SlotService(repo, instance_key=INST)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
instance_key=INST, max_active_slots=5)
intents = IntentService(
svc,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
instance_key=INST,
max_active_slots=5,
)
repo.reconcile_universe(INST, {"PF-1": "SYM"})
handler = make_intent_rpc_handler(intents)
slot = repo.list_slots(INST)[0]
@@ -32,72 +31,65 @@ def _wire():
def _sell(handler, slot_id, intent_id="s1"):
return handler(RpcRequest(payload={
"intent_type": "sell", "intent_id": intent_id, "slot_id": str(slot_id),
}, correlation_id=intent_id))
return handler(
RpcRequest(
payload={
"intent_type": "sell",
"intent_id": intent_id,
"slot_id": str(slot_id),
},
correlation_id=intent_id,
)
)
class TestRetryableMapping:
def test_each_reason_maps(self):
assert _sell_reject_retryable("slot_state_changed") is True
assert _sell_reject_retryable("slot_not_holding:pending_buy") is True
assert _sell_reject_retryable("duplicate_in_flight") is True
assert _sell_reject_retryable("slot_not_found") is False
assert _sell_reject_retryable("slot_not_holding:inactive") is False
assert _sell_reject_retryable("slot_not_holding:pending_sell") is False
assert _sell_reject_retryable("slot_not_holding:error") is False
def test_unrecognized_defaults_true(self):
assert _sell_reject_retryable("some_future_reason") is True
assert _sell_reject_retryable(None) is True
def test_bad_request_is_false(self):
assert _sell_reject_retryable("bad_request:KeyError('slot_id')") is False
def test_rejected_sell_does_not_expose_retryability():
_, _, _, handler, slot = _wire()
response = _sell(handler, slot.slot_id)
assert response.payload["status"] == "rejected"
assert response.payload["reason"] == "slot_not_holding:inactive"
assert "retryable" not in response.payload
class TestResponseField:
def test_rejected_sell_inactive_slot_has_retryable_false(self):
# slot is inactive (never bought) → slot_not_holding:inactive → terminal
repo, svc, intents, handler, slot = _wire()
resp = _sell(handler, slot.slot_id)
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "slot_not_holding:inactive"
assert resp.payload["retryable"] is False
def test_unknown_slot_sell_does_not_expose_retryability():
_, _, _, handler, _ = _wire()
response = _sell(handler, uuid4())
assert response.payload["status"] == "rejected"
assert response.payload["reason"] == "slot_not_found"
assert "retryable" not in response.payload
def test_rejected_sell_unknown_slot_false(self):
from uuid import uuid4
_, _, _, handler, _ = _wire()
resp = _sell(handler, uuid4())
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "slot_not_found"
assert resp.payload["retryable"] is False
def test_bad_request_sell_carries_retryable_false(self):
_, _, _, handler, _ = _wire()
resp = handler(RpcRequest(payload={
"intent_type": "sell", "intent_id": "x", # missing slot_id
}, correlation_id="x"))
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"].startswith("bad_request")
assert resp.payload["retryable"] is False
def test_malformed_sell_does_not_expose_retryability():
_, _, _, handler, _ = _wire()
response = handler(
RpcRequest(
payload={"intent_type": "sell", "intent_id": "x"},
correlation_id="x",
)
)
assert response.payload["status"] == "rejected"
assert response.payload["reason"].startswith("bad_request")
assert "retryable" not in response.payload
def test_admitted_sell_has_no_retryable(self):
repo, svc, intents, handler, slot = _wire()
# bring slot to holding so the sell is admitted
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t")
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10, avg_price=100)
resp = _sell(handler, slot.slot_id)
assert resp.payload["status"] == "admitted"
assert "retryable" not in resp.payload
def test_buy_rejection_has_no_retryable(self):
# buy on a non-existent slot → rejected, but buy responses omit retryable
from uuid import uuid4
_, _, _, handler, _ = _wire()
resp = handler(RpcRequest(payload={
"intent_type": "buy", "intent_id": "b1", "slot_id": str(uuid4()),
}, correlation_id="b1"))
assert resp.payload["status"] == "rejected"
assert "retryable" not in resp.payload
def test_admitted_sell_does_not_expose_retryability():
_, svc, _, handler, slot = _wire()
svc.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
)
svc.transit_slot(
slot.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
qty=10,
avg_price=100,
)
response = _sell(handler, slot.slot_id)
assert response.payload["status"] == "admitted"
assert "retryable" not in response.payload
+18 -9
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from uuid import UUID
import pytest
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import (
BalanceItem,
@@ -31,7 +33,7 @@ class RecordingBroker:
return OrderAck(
accepted=True,
broker_order_id=f"order-{len(self.orders)}",
venue=self.actual_venue or req.venue,
venue=req.venue,
)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
@@ -96,7 +98,8 @@ def test_nxt_buy_wire_value_reaches_broker_order() -> None:
assert broker.orders[0].venue == "NXT"
def test_invalid_venue_is_rejected_before_slot_transition() -> None:
@pytest.mark.parametrize("invalid_venue", ["AUTO", "UNKNOWN"])
def test_invalid_venue_is_rejected_before_slot_transition(invalid_venue: str) -> None:
repo, intents, _, _ = _wire()
handler = make_intent_rpc_handler(intents)
@@ -107,7 +110,7 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
"intent_id": "bad-venue",
"portfolio_id": "PF-1",
"qty": 1,
"venue": "UNKNOWN",
"venue": invalid_venue,
},
correlation_id="bad-venue",
)
@@ -118,7 +121,10 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
assert repo.list_slots("default")[0].status is SlotState.INACTIVE
def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
@pytest.mark.parametrize("venue_field", [{}, {"venue": None}], ids=["omitted", "null"])
def test_missing_wire_venue_remains_sor_for_cancel_and_restart(
venue_field: dict[str, object],
) -> None:
repo = InMemorySlotRepository()
service = SlotService(repo)
intents = IntentService(
@@ -139,6 +145,7 @@ def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
"intent_id": "auto-buy",
"portfolio_id": "PF-1",
"qty": 1,
**venue_field,
},
correlation_id="auto-buy",
)
@@ -146,11 +153,13 @@ def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
slot_id = UUID(response.payload["slot_id"])
pending = repo.fetch_slot(slot_id)
assert pending is not None and pending.venue == "AUTO"
assert pending is not None and pending.venue == "SOR"
execution.run_once()
dispatched = repo.fetch_slot(slot_id)
assert broker.orders[0].venue == "AUTO"
assert dispatched is not None and dispatched.venue == "NXT"
assert broker.orders[0].venue == "SOR"
# Ack cannot turn SOR routing provenance into an execution venue. Actual
# KRX/NXT is established by fill polling and stored on the ledger trade.
assert dispatched is not None and dispatched.venue == "SOR"
def test_inactive_transition_resets_venue_to_krx() -> None:
@@ -180,7 +189,7 @@ def test_inactive_transition_resets_venue_to_krx() -> None:
assert reset.venue == "KRX"
def test_holding_transition_resets_next_order_venue_to_auto() -> None:
def test_holding_transition_resets_next_order_venue_to_sor() -> None:
repo, intents, _, _ = _wire()
decision = intents.submit_buy(
BuyIntent(
@@ -201,7 +210,7 @@ def test_holding_transition_resets_next_order_venue_to_auto() -> None:
qty=1,
avg_price=1000,
)
assert holding.venue == "AUTO"
assert holding.venue == "SOR"
def test_operator_cancel_uses_original_nxt_venue() -> None:
@@ -144,7 +144,13 @@ def _wire(broker: KISBroker):
instance_key=INSTANCE,
)
repo.reconcile_universe(INSTANCE, {PORTFOLIO: SYMBOL})
execution = ExecutionService(slots, repo, broker, instance_key=INSTANCE)
execution = ExecutionService(
slots,
repo,
broker,
instance_key=INSTANCE,
clock=lambda: VIRTUAL_AT,
)
decision = intents.submit_buy(
BuyIntent(
intent_id="virtual-buy-1",
@@ -170,7 +176,13 @@ def test_timeout_after_exchange_accept_resolves_without_duplicate_and_survives_r
assert len(state.orders) == 1
state.fill_orders = True
restarted = ExecutionService(slots, repo, _broker(server), instance_key=INSTANCE)
restarted = ExecutionService(
slots,
repo,
_broker(server),
instance_key=INSTANCE,
clock=lambda: VIRTUAL_AT,
)
restarted.reconcile()
holding = repo.fetch_slot(slot_id)
+53 -3
View File
@@ -17,10 +17,43 @@ def _step_index(steps, name):
return next(index for index, step in enumerate(steps) if step.get("name") == name)
def test_workflow_triggers_only_main_and_v_tags():
def test_workflow_triggers_only_v_tag_push_candidates():
workflow = _workflow()
assert set(workflow["on"]) == {"push"}
assert workflow["on"]["push"] == {"branches": ["main"], "tags": ["v*"]}
assert workflow["on"]["push"] == {"tags": ["v*"]}
def test_workflow_rejects_non_strict_version_events_before_checkout_and_build():
steps = _workflow()["jobs"]["verify"]["steps"]
guard = steps[0]
assert guard["name"] == "Reject non-version publication events"
assert guard["env"] == {
"PUBLICATION_EVENT": "${{ github.event_name }}",
"PUBLICATION_REF": "${{ github.ref }}",
}
assert '"${PUBLICATION_EVENT}" != "push"' in guard["run"]
strict_ref = (
r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"
)
assert strict_ref in guard["run"]
assert _step_index(steps, guard["name"]) < _step_index(steps, "Check out exact source commit")
assert _step_index(steps, guard["name"]) < _step_index(steps, "Build source image once")
def test_workflow_reverifies_exact_checked_out_commit_before_repository_or_registry_work():
workflow = _workflow()
verify_steps = workflow["jobs"]["verify"]["steps"]
publish_steps = workflow["jobs"]["publish"]["steps"]
checkout_index = _step_index(verify_steps, "Check out exact source commit")
reverify_index = _step_index(verify_steps, "Reverify exact checked out commit")
assert checkout_index < reverify_index < _step_index(verify_steps, "Install repository checks")
assert verify_steps[reverify_index]["run"] == (
'python scripts/image_release.py verify-checkout --commit "${GITHUB_SHA}"'
)
assert reverify_index < _step_index(verify_steps, "Build source image once")
assert all("REGISTRY_TOKEN" not in str(step) for step in verify_steps[: reverify_index + 1])
assert _step_index(publish_steps, "Verify, load, and publish without rebuilding") > 0
def test_publish_is_gated_and_has_the_only_package_write_permission():
@@ -34,6 +67,16 @@ def test_publish_is_gated_and_has_the_only_package_write_permission():
assert "packages" not in verify["permissions"]
def test_publish_job_exposes_selected_version_tag_with_other_release_evidence():
outputs = _workflow()["jobs"]["publish"]["outputs"]
assert outputs == {
"image": "${{ steps.publish.outputs.image }}",
"digest": "${{ steps.publish.outputs.digest }}",
"source_commit": "${{ steps.publish.outputs.source_commit }}",
"version_tag": "${{ steps.publish.outputs.version_tag }}",
}
def test_verify_canary_build_smoke_export_scan_checksum_upload_contract():
steps = _workflow()["jobs"]["verify"]["steps"]
names = [step["name"] for step in steps]
@@ -123,6 +166,7 @@ def test_publish_job_has_only_download_then_verified_publish_helper():
--image-id tested-image/trex-core-image.id \\
--local-image "trex-core-ci:${GITHUB_SHA}" \\
--commit "${GITHUB_SHA}" \\
--event-name "${GITHUB_EVENT_NAME}" \\
--ref "${GITHUB_REF}"'''
assert steps[1]["run"].strip() == expected_run
@@ -148,7 +192,10 @@ def test_workflow_uses_full_sha_and_contains_no_latest_tag():
def test_docker_context_contract():
dockerfile = (REPO / "Dockerfile").read_text()
copy_lines = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("COPY ")]
assert copy_lines == ["COPY pyproject.toml ./", "COPY trex_core/ ./trex_core/"]
assert copy_lines == ["COPY pyproject.toml uv.lock ./", "COPY trex_core/ ./trex_core/"]
assert 'python -m pip install --no-cache-dir "uv==0.11.14"' in dockerfile
assert "uv sync --locked --no-dev --no-editable" in dockerfile
assert "pip install --no-cache-dir ." not in dockerfile
assert "org.opencontainers.image.revision" in dockerfile
assert "python:3.12" in dockerfile
@@ -158,6 +205,9 @@ def test_docker_context_contract():
def test_operator_documentation_preserves_rollback_boundaries():
operations = (REPO / "docs" / "operations.md").read_text()
assert "strict `vX.Y.Z` Git tag" in operations
assert "사용자 승인 외부 쓰기" in operations
assert "Git tag version은 `pyproject.toml` package version과 독립" in operations
assert "read-only volume" in operations
assert "runtime environment" in operations
assert "sha256:<64-hex>" in operations
+115 -17
View File
@@ -40,6 +40,8 @@ class RecordingRunner:
self.commands.append((command, input_text, timeout))
if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on):
raise subprocess.CalledProcessError(1, command)
if command == ["git", "rev-parse", "HEAD"]:
return image_release.Result(self.revision + "\n")
if command[:2] == ["docker", "load"]:
return image_release.Result(f"Loaded image: {self.loaded_image}\n")
if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command:
@@ -64,18 +66,65 @@ def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]:
def test_release_tags_are_deterministic_and_never_latest():
main = image_release.release_tags(COMMIT, "refs/heads/main")
release = image_release.release_tags(COMMIT, "refs/tags/v1.2.3")
release = image_release.release_tags(COMMIT, "push", "refs/tags/v1.2.3")
assert main == [f"{image_release.IMAGE}:{COMMIT[:12]}"]
assert release == [*main, f"{image_release.IMAGE}:v1.2.3"]
assert all(not tag.endswith(":latest") for tag in main + release)
assert release == [
f"{image_release.IMAGE}:{COMMIT[:12]}",
f"{image_release.IMAGE}:v1.2.3",
]
assert all(not tag.endswith(":latest") for tag in release)
@pytest.mark.parametrize("ref", ["refs/tags/v0.0.0", "refs/tags/v10.20.30"])
def test_strict_version_tags_accept_zero_and_nonzero_decimal_components(ref):
assert image_release.validate_publication_trigger("push", ref) == ref.removeprefix(
"refs/tags/"
)
@pytest.mark.parametrize(
("event_name", "ref"),
[
("workflow_dispatch", "refs/tags/v1.2.3"),
("pull_request", "refs/tags/v1.2.3"),
("push", "refs/heads/main"),
("push", "refs/heads/release"),
("push", "refs/tags/1.2.3"),
("push", "refs/tags/v1.2"),
("push", "refs/tags/v1.2.3.4"),
("push", "refs/tags/v01.2.3"),
("push", "refs/tags/v1.02.3"),
("push", "refs/tags/v1.2.03"),
("push", "refs/tags/v1.2.3-rc.1"),
("push", "refs/tags/v1.2.3+build.1"),
("push", "refs/tags/v-1.2.3"),
("push", "refs/tags/v1.a.3"),
],
)
def test_non_push_branch_and_malformed_version_triggers_are_rejected(event_name, ref):
with pytest.raises(ValueError):
image_release.validate_publication_trigger(event_name, ref)
@pytest.mark.parametrize("commit", ["A" * 40, "a" * 39, "g" * 40, ""])
def test_invalid_source_commit_is_rejected(commit):
with pytest.raises(ValueError):
image_release.release_tags(commit, "refs/heads/main")
image_release.release_tags(commit, "push", "refs/tags/v1.2.3")
def test_verify_checkout_requires_exact_git_head_match():
runner = RecordingRunner()
assert image_release.verify_checkout(COMMIT, runner) == COMMIT
assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)]
def test_verify_checkout_mismatch_fails_closed():
runner = RecordingRunner(revision="c" * 40)
with pytest.raises(ValueError, match="checked out HEAD does not match source commit"):
image_release.verify_checkout(COMMIT, runner)
assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)]
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
@@ -156,7 +205,38 @@ def test_mutated_archive_stops_before_load_or_registry(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
)
assert runner.commands == []
@pytest.mark.parametrize(
("event_name", "ref"),
[
("workflow_dispatch", "refs/tags/v1.2.3"),
("push", "refs/heads/main"),
("push", "refs/tags/v01.2.3"),
("push", "refs/tags/v1.2.3-rc.1"),
],
)
def test_invalid_publication_trigger_stops_before_artifact_load_or_registry(
tmp_path, event_name, ref
):
runner = RecordingRunner()
with pytest.raises(ValueError, match="publication"):
image_release.publish_image(
archive=tmp_path / "missing.tar",
checksum=tmp_path / "missing.sha256",
image_id=tmp_path / "missing.id",
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name=event_name,
ref=ref,
actor="actor",
token="fake-token",
runner=runner,
@@ -175,7 +255,8 @@ def test_load_failure_stops_before_login_and_push(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -194,7 +275,8 @@ def test_revision_mismatch_stops_before_login_and_push(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -213,7 +295,8 @@ def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_pa
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -235,7 +318,8 @@ def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -262,7 +346,8 @@ def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -280,7 +365,7 @@ def test_record_image_id_writes_strict_inspected_id(tmp_path):
]
def test_valid_publish_records_digest_only_after_push(tmp_path):
def test_valid_publish_records_exact_release_evidence_only_after_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
output = tmp_path / "output"
summary = tmp_path / "summary"
@@ -292,6 +377,7 @@ def test_valid_publish_records_digest_only_after_push(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v2.0.0",
actor="actor",
token="fake-token",
@@ -304,9 +390,19 @@ def test_valid_publish_records_digest_only_after_push(tmp_path):
assert commands.index(["docker", "load"]) < commands.index(["docker", "login"])
assert commands.count(["docker", "push"]) == 2
assert digest == DIGEST
assert f"digest={DIGEST}" in output.read_text()
assert COMMIT in output.read_text()
assert f"{image_release.IMAGE}@{DIGEST}" in summary.read_text()
assert output.read_text() == (
f"image={image_release.IMAGE}@{DIGEST}\n"
f"digest={DIGEST}\n"
f"source_commit={COMMIT}\n"
"version_tag=v2.0.0\n"
)
assert summary.read_text() == (
"## Published trex-core image\n\n"
f"- Image: `{image_release.IMAGE}@{DIGEST}`\n"
f"- Digest: `{DIGEST}`\n"
f"- Source commit: `{COMMIT}`\n"
"- Version tag: `v2.0.0`\n"
)
@pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64])
@@ -323,7 +419,8 @@ def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -356,6 +453,7 @@ def test_release_second_push_failure_preserves_nonzero_without_success_evidence(
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v2.0.0",
actor="actor",
token="fake-token",
+84 -26
View File
@@ -15,7 +15,7 @@ import base64
import hashlib
import json
from dataclasses import replace
from datetime import datetime, time, timedelta, timezone
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from time import monotonic
from typing import Callable, Optional
@@ -40,7 +40,7 @@ _MOCK_HINTS = ("openapivts", "virtual", "mock")
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
_SUPPORTED_ORDER_VENUES = frozenset({"AUTO", "KRX", "NXT", "SOR"})
_SUPPORTED_ORDER_VENUES = frozenset({"KRX", "NXT", "SOR"})
def _balance_http_error_code(status: Optional[int]) -> str:
@@ -54,26 +54,15 @@ def _balance_http_error_code(status: Optional[int]) -> str:
def resolve_order_venue(venue: str, at: datetime) -> str:
"""Resolve AUTO to the venue that accepts domestic-stock orders at `at`.
"""Validate and preserve an explicit KRX/NXT/SOR order venue.
The gaps are intentional fail-closed intervals. They prevent an AUTO order
from silently falling back to KRX while the opening venue is indeterminate.
Explicit KRX/NXT/SOR values are preserved.
``at`` remains in the signature for compatibility with existing callers;
venue selection is deliberately independent of wall-clock time.
"""
normalized = str(venue).strip().upper()
if normalized not in _SUPPORTED_ORDER_VENUES:
raise ValueError("unsupported_order_venue")
if normalized != "AUTO":
return normalized
current = at.astimezone(_KST).time().replace(tzinfo=None)
if time(8, 0) <= current < time(8, 50):
return "NXT"
if time(9, 0, 30) <= current < time(15, 30):
return "KRX"
if time(15, 30) <= current <= time(20, 0):
return "NXT"
raise ValueError("auto_venue_session_closed")
return normalized
def _safe_int(v: object) -> Optional[int]:
@@ -284,8 +273,8 @@ class KISBroker:
"ORD_DVSN": resolved_req.order_type,
"ORD_QTY": str(resolved_req.qty),
"ORD_UNPR": str(resolved_req.limit_price or 0),
# REQ-118: always send EXCG_ID_DVSN_CD from req.venue (default "KRX").
# Previously omitted (server defaulted to KRX); explicit is more robust.
# The intent layer normalizes missing venue to SOR. The broker
# adapter always forwards the resulting explicit order venue.
# Real-account NXT order confirmed rt_cd=0 on 2026-07-06.
"EXCG_ID_DVSN_CD": actual_venue,
}
@@ -307,6 +296,7 @@ class KISBroker:
reason=f"submit_outcome_unknown:{type(exc).__name__}",
venue=actual_venue,
raw={"error_class": type(exc).__name__},
submitted_at=submitted_at,
)
if getattr(resp, "status_code", 200) >= 500:
return OrderAck(
@@ -315,6 +305,7 @@ class KISBroker:
reason=f"submit_outcome_unknown:http_{resp.status_code}",
venue=actual_venue,
raw={"http_status": resp.status_code},
submitted_at=submitted_at,
)
if str(payload.get("rt_cd")) == "0":
out = payload.get("output") or {}
@@ -326,6 +317,7 @@ class KISBroker:
broker_order_id=bid,
venue=actual_venue,
raw=payload,
submitted_at=submitted_at,
)
return OrderAck(
accepted=False,
@@ -362,9 +354,25 @@ class KISBroker:
"EXCG_ID_DVSN_CD": "ALL",
}
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
def poll_fill(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> FillStatus:
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
if order_date is None:
# A legacy pending row has no authoritative date anchor. ODNO is
# reusable, so querying today's rows could attach another order's
# terminal evidence to this slot. Never invent an inquiry date.
return FillStatus(
state="unknown",
raw={"reason": "order_reconciliation_metadata_unknown"},
)
now = monotonic()
if now < self._fill_poll_not_before.get(broker_order_id, 0.0):
return FillStatus(
@@ -375,8 +383,8 @@ class KISBroker:
# open duplicate KIS inquiries for the same order.
self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS
odno = broker_order_id.split(":")[-1]
today = datetime.now(tz=_KST).strftime("%Y%m%d")
tr_id, params = self._daily_fill_request(today, order_number=odno)
inquiry_date = order_date
tr_id, params = self._daily_fill_request(inquiry_date, order_number=odno)
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
@@ -410,11 +418,36 @@ class KISBroker:
None,
)
if row is None:
return FillStatus(
state="accepted",
raw={"reason": "order_not_visible_in_fill_inquiry"},
visible_grace = (
submitted_at is None
or (self._clock().astimezone(timezone.utc) - submitted_at.astimezone(timezone.utc))
.total_seconds()
< _SUBMISSION_LOOKUP_GRACE_SECONDS
)
return self._fill_status_from_row(row, side=side, today=today, fallback_odno=odno)
return FillStatus(
state="accepted" if visible_grace else "unknown",
raw={
"reason": (
"order_visibility_grace"
if visible_grace
else "order_not_visible_unresolved"
),
"order_visible": False,
"inquiry_date": inquiry_date,
},
)
status = self._fill_status_from_row(
row, side=side, today=inquiry_date, fallback_odno=odno
)
return replace(
status,
raw={
**(status.raw if isinstance(status.raw, dict) else {}),
"order_visible": True,
"order_open_confirmed": status.state in {"accepted", "partially_filled"},
"inquiry_date": inquiry_date,
},
)
def _poll_unknown_submission(
self,
@@ -540,6 +573,27 @@ class KISBroker:
delta = (ordered_at - submitted_at).total_seconds()
return -5.0 <= delta <= 120.0
@staticmethod
def _execution_venue_from_row(row: dict) -> str | None:
"""Parse broker-authoritative KRX/NXT without time inference."""
notice_code = str(row.get("ord_exg_gb") or row.get("ORD_EXG_GB") or "").strip()
notice_map = {"1": "KRX", "2": "NXT", "3": "KRX", "4": "NXT"}
if notice_code in notice_map:
return notice_map[notice_code]
for key in (
"excg_id_dvsn_cd",
"excg_id_dvsn_Cd",
"EXCG_ID_DVSN_CD",
"execution_venue",
):
value = str(row.get(key) or "").strip().upper()
if value in {"KRX", "SOR-KRX"}:
return "KRX"
if value in {"NXT", "SOR-NXT"}:
return "NXT"
# SOR is routing provenance, not an execution exchange.
return None
@staticmethod
def _fill_status_from_row(
row: dict,
@@ -548,6 +602,7 @@ class KISBroker:
today: str,
fallback_odno: str,
) -> FillStatus:
execution_venue = KISBroker._execution_venue_from_row(row)
ord_qty = _safe_int(row.get("ord_qty")) or 0
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
rejected_qty = _safe_int(row.get("rjct_qty")) or 0
@@ -590,6 +645,7 @@ class KISBroker:
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue=execution_venue,
raw=row,
)
# 0 < ccld_qty < ord_qty
@@ -613,6 +669,7 @@ class KISBroker:
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue=execution_venue,
cancelled=True,
raw=row,
)
@@ -620,6 +677,7 @@ class KISBroker:
state="partially_filled",
filled_qty=ccld_qty,
avg_price=float(avg) if avg else None,
execution_venue=execution_venue,
raw=row,
)
+18 -2
View File
@@ -10,8 +10,10 @@ from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from threading import Lock
from typing import Optional
from trex_core.application.broker_port import (
BalanceItem,
@@ -126,8 +128,22 @@ class OwnershipAwareBroker:
def submit_order(self, req: OrderRequest) -> OrderAck:
return self._delegate.submit_order(req)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
result = self._delegate.poll_fill(broker_order_id, side=side, symbol=symbol)
def poll_fill(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> FillStatus:
result = self._delegate.poll_fill(
broker_order_id,
side=side,
symbol=symbol,
order_date=order_date,
submitted_at=submitted_at,
)
if result.execution_id and result.filled_qty > 0 and (
result.is_filled or result.is_partial_cancel
):
+27 -3
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import threading
from decimal import Decimal
from datetime import datetime
from typing import Optional
from trex_core.adapters.broker.seq_store import SeqStore
@@ -30,6 +31,9 @@ class _Order:
self.side = req.side
self.symbol = req.symbol
self.qty = req.qty
# Deterministic SOR simulation: paper routes SOR to KRX while retaining
# req.venue on the acknowledgement as submission provenance.
self.execution_venue = "KRX" if req.venue == "SOR" else req.venue
self.price = float(req.limit_price) if req.limit_price is not None else 1000.0
self.polls = 0
self.filled = 0
@@ -80,7 +84,15 @@ class PaperBroker:
self._orders[oid] = _Order(oid, req)
return OrderAck(accepted=True, broker_order_id=oid, venue=req.venue)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
def poll_fill(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> FillStatus:
with self._lock:
o = self._orders.get(broker_order_id)
if o is None:
@@ -91,7 +103,12 @@ class PaperBroker:
if symbol in self._reject:
return FillStatus(state="rejected", raw={"reason": "paper_reject"})
if o.polls <= self._fill_after:
return FillStatus(state="accepted", filled_qty=o.filled, avg_price=o.price)
return FillStatus(
state="accepted",
filled_qty=o.filled,
avg_price=o.price,
raw={"order_open_confirmed": True},
)
step = self._qty_per_poll if self._qty_per_poll is not None else (o.qty - o.filled)
o.filled = min(o.qty, o.filled + step)
if o.filled >= o.qty:
@@ -104,8 +121,15 @@ class PaperBroker:
return FillStatus(
state="filled", filled_qty=o.filled, avg_price=o.price,
execution_id=execution_id, fee=fee, tax=tax,
execution_venue=o.execution_venue,
)
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
return FillStatus(
state="partially_filled",
filled_qty=o.filled,
avg_price=o.price,
execution_venue=o.execution_venue,
raw={"order_open_confirmed": True},
)
def cancel_order(
self,
+53 -9
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import json
from contextlib import contextmanager
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Iterator, Optional
from uuid import UUID, uuid4
@@ -38,9 +38,11 @@ _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"
)
_KST = timezone(timedelta(hours=9))
def _require_psycopg(module: Any = None) -> Any:
if module is not None:
@@ -218,7 +220,8 @@ class PostgreSQLSlotRepository:
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
"occupancy_seq=%s, venue=%s, cancel_state=%s, "
"cancel_requested_at=%s, updated_at=%s WHERE slot_id=%s",
"cancel_requested_at=%s, order_submitted_at=%s, order_date=%s, "
"cancel_attempts=%s, updated_at=%s WHERE slot_id=%s",
(
slot.status.value,
slot.symbol,
@@ -241,6 +244,9 @@ class PostgreSQLSlotRepository:
slot.venue,
slot.cancel_state,
slot.cancel_requested_at,
slot.order_submitted_at,
slot.order_date,
slot.cancel_attempts,
slot.updated_at or datetime.now(tz=timezone.utc),
slot.slot_id,
),
@@ -295,16 +301,22 @@ class PostgreSQLSlotRepository:
broker_order_id: str,
*,
venue: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> bool:
submitted_at = submitted_at or datetime.now(tz=timezone.utc)
order_date = submitted_at.astimezone(_KST).strftime("%Y%m%d")
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=%s, "
"venue=COALESCE(%s, venue), updated_at=%s "
"venue=COALESCE(%s, venue), order_submitted_at=%s, order_date=%s, "
"cancel_attempts=0, updated_at=%s "
"WHERE slot_id=%s AND broker_order_id IS NULL",
(
broker_order_id,
venue,
submitted_at,
order_date,
datetime.now(tz=timezone.utc),
slot_id,
),
@@ -366,6 +378,31 @@ class PostgreSQLSlotRepository:
conn.commit()
return ok
def reserve_cancel_attempt(
self,
slot_id: UUID,
broker_order_id: str,
*,
retry_before: Optional[datetime] = None,
) -> bool:
"""Atomically reserve first cancel or an authoritative backoff-gated retry."""
now = datetime.now(tz=timezone.utc)
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET cancel_state='unknown', cancel_requested_at=%s, "
"cancel_attempts=cancel_attempts+1, updated_at=%s "
"WHERE slot_id=%s AND broker_order_id=%s "
"AND status IN ('pending_buy','pending_sell') "
"AND (cancel_state IS NULL OR "
"(%s IS NOT NULL AND cancel_state IN ('rejected','unknown') "
"AND cancel_attempts < 4 AND cancel_requested_at <= %s))",
(now, now, slot_id, broker_order_id, retry_before, retry_before),
)
ok = cur.rowcount == 1
conn.commit()
return ok
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
@@ -382,7 +419,8 @@ class PostgreSQLSlotRepository:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=NULL, cancel_state=NULL, "
"cancel_requested_at=NULL, updated_at=%s WHERE slot_id=%s",
"cancel_requested_at=NULL, order_submitted_at=NULL, order_date=NULL, "
"cancel_attempts=0, updated_at=%s WHERE slot_id=%s",
(datetime.now(tz=timezone.utc), slot_id),
)
conn.commit()
@@ -410,7 +448,8 @@ class PostgreSQLSlotRepository:
# created_at(14) updated_at(15) instance_key(16) retired_at(17)
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
# occupancy_seq(20) [dcy-028/REQ-081], venue(21),
# cancel_state(22), cancel_requested_at(23)
# cancel_state(22), cancel_requested_at(23), order_submitted_at(24),
# order_date(25), cancel_attempts(26)
return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
status=SlotState(row[1]),
@@ -436,6 +475,9 @@ class PostgreSQLSlotRepository:
venue=str(row[21]) if len(row) > 21 and row[21] is not None else "KRX",
cancel_state=str(row[22]) if len(row) > 22 and row[22] is not None else None,
cancel_requested_at=row[23] if len(row) > 23 else None,
order_submitted_at=row[24] if len(row) > 24 else None,
order_date=str(row[25]) if len(row) > 25 and row[25] is not None else None,
cancel_attempts=int(row[26]) if len(row) > 26 and row[26] is not None else 0,
)
@@ -506,7 +548,7 @@ class _PgIntentRecord:
_OUTBOX_COLUMNS = (
"correlation_id, asset_id, order_id, execution_id, side, qty, price, "
"fee, tax, executed_at, status, attempts, last_outcome, last_error, "
"created_at, updated_at, next_retry_at, venue"
"created_at, updated_at, next_retry_at, venue, order_venue"
)
@@ -523,8 +565,8 @@ class PostgreSQLLedgerOutbox:
cur.execute(
"INSERT INTO ledger_outbox "
"(correlation_id, asset_id, order_id, execution_id, side, "
" qty, price, fee, tax, executed_at, venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
" qty, price, fee, tax, executed_at, venue, order_venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING",
(
trade.correlation_id,
@@ -538,6 +580,7 @@ class PostgreSQLLedgerOutbox:
trade.tax,
trade.executed_at,
trade.venue,
trade.order_venue,
),
)
conn.commit()
@@ -642,6 +685,7 @@ class PostgreSQLLedgerOutbox:
tax=f(row[8]),
executed_at=row[9],
venue=row[17],
order_venue=row[18],
)
return LedgerWorkItem(
correlation_id=row[0],
@@ -0,0 +1,27 @@
-- GitHub #58: persist the effective timeout's immutable dispatch anchor and
-- original KST order date separately from mutable row bookkeeping. Existing
-- dispatched rows deliberately remain NULL: updated_at/created_at are mutable
-- bookkeeping and cannot prove the original submission timestamp or order day.
-- Runtime keeps those legacy rows fail-closed and raises a sanitized alarm.
ALTER TABLE daily_slot
ADD COLUMN IF NOT EXISTS order_submitted_at TIMESTAMPTZ;
ALTER TABLE daily_slot
ADD COLUMN IF NOT EXISTS order_date TEXT;
ALTER TABLE daily_slot
ADD COLUMN IF NOT EXISTS cancel_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE daily_slot
DROP CONSTRAINT IF EXISTS daily_slot_order_date_check;
ALTER TABLE daily_slot
ADD CONSTRAINT daily_slot_order_date_check
CHECK (order_date IS NULL OR order_date ~ '^[0-9]{8}$');
ALTER TABLE daily_slot
DROP CONSTRAINT IF EXISTS daily_slot_cancel_attempts_check;
ALTER TABLE daily_slot
ADD CONSTRAINT daily_slot_cancel_attempts_check
CHECK (cancel_attempts >= 0);
@@ -0,0 +1,16 @@
-- GitHub #61: remove Core-owned wall-clock AUTO routing.
-- Existing durable AUTO values are normalized before the stricter constraints.
UPDATE daily_slot SET venue = 'SOR' WHERE venue = 'AUTO';
UPDATE ledger_outbox SET venue = 'SOR' WHERE venue = 'AUTO';
ALTER TABLE daily_slot
DROP CONSTRAINT IF EXISTS daily_slot_venue_check;
ALTER TABLE daily_slot
ADD CONSTRAINT daily_slot_venue_check
CHECK (venue IN ('KRX', 'NXT', 'SOR'));
ALTER TABLE ledger_outbox
DROP CONSTRAINT IF EXISTS ledger_outbox_venue_check;
ALTER TABLE ledger_outbox
ADD CONSTRAINT ledger_outbox_venue_check
CHECK (venue IN ('KRX', 'NXT', 'SOR'));
@@ -0,0 +1,33 @@
-- GitHub #61: keep original submission venue separate from actual execution venue.
-- The nullable add + NULL-only backfill makes replay safe: rows written after
-- this migration already have order_venue and are never reinterpreted.
ALTER TABLE ledger_outbox
ADD COLUMN IF NOT EXISTS order_venue TEXT;
UPDATE ledger_outbox
SET order_venue = venue
WHERE order_venue IS NULL
AND venue IN ('KRX', 'NXT', 'SOR');
ALTER TABLE ledger_outbox
ALTER COLUMN order_venue SET DEFAULT 'KRX';
ALTER TABLE ledger_outbox
ALTER COLUMN order_venue SET NOT NULL;
ALTER TABLE ledger_outbox
DROP CONSTRAINT IF EXISTS ledger_outbox_order_venue_check;
ALTER TABLE ledger_outbox
ADD CONSTRAINT ledger_outbox_order_venue_check
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'));
-- Historical SOR in `venue` recorded routing provenance, not a proven KRX/NXT
-- execution exchange. Preserve SOR in order_venue and mark actual as unknown.
ALTER TABLE ledger_outbox
ALTER COLUMN venue DROP NOT NULL;
UPDATE ledger_outbox SET venue = NULL WHERE venue = 'SOR';
ALTER TABLE ledger_outbox
DROP CONSTRAINT IF EXISTS ledger_outbox_venue_check;
ALTER TABLE ledger_outbox
ADD CONSTRAINT ledger_outbox_venue_check
CHECK (venue IS NULL OR venue IN ('KRX', 'NXT'));
+17 -5
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Any, Optional, Protocol
@@ -70,11 +71,11 @@ class OrderAck:
accepted: bool
broker_order_id: Optional[str] = None
reason: Optional[str] = None
# Actual venue used for submission. KIS resolves AUTO before the HTTP call;
# execution persists this value with the broker order id so restart/cancel
# never has to infer the original venue.
# Original submission/routing venue. Execution persists this with the
# broker order id so restart/cancel never infer it from wall-clock time.
venue: Optional[str] = None
raw: dict[str, Any] = field(default_factory=dict)
submitted_at: Optional[datetime] = None
@dataclass(frozen=True)
@@ -103,7 +104,10 @@ class FillStatus:
execution_id: Optional[str] = None
fee: float = 0.0
tax: float = 0.0
# dcy-013 / REQ-012 (issue #9): an external cancel was detected. When the
# Broker-authoritative execution exchange. For SOR orders this is KRX or
# NXT; None means the polling surface did not establish it.
execution_venue: Optional[str] = None
# dcy-013 / REQ-012: an external cancel was detected.
# order had a partial fill before the cancel, state stays "partially_filled"
# and this flag is set so execution records the filled portion and returns
# the remainder to holding (is_partial_cancel). A zero-fill external cancel
@@ -135,7 +139,15 @@ class BrokerPort(Protocol):
"""Submit an order. Returns accepted + broker_order_id (no synchronous fill)."""
...
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
def poll_fill(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
order_date: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> FillStatus:
"""Observe current fill status for a previously accepted order."""
...
+132 -33
View File
@@ -23,9 +23,10 @@ single producer for ledger work items (NFR-3, single record subject).
from __future__ import annotations
import inspect
import logging
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Callable, Optional
from trex_core.application.alarm_emitter import AlarmEmitter
@@ -86,6 +87,7 @@ class ExecutionService:
instance_key: str = "default",
alarm_emitter: Optional[AlarmEmitter] = None,
balance_reader: Optional[Callable[[], list[BalanceItem]]] = None,
clock: Optional[Callable[[], datetime]] = None,
) -> None:
self._svc = slot_service
self._repo = repo
@@ -106,6 +108,11 @@ class ExecutionService:
)
# dcy-033 / REQ-100: best-effort alarm emitter (None = no alarms sent).
self._alarm_emitter = alarm_emitter
# One emission per durable unresolved state/attempt in this process.
# A restart intentionally emits once again, covering a crash between a
# durable cancel reservation and best-effort alarm publication.
self._order_alarm_keys: set[tuple[str, str, int]] = set()
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
# dcy-041 / GitHub #45: pending-sell quantity may use a bounded
# account snapshot only through this injected reader. Reconciliation
# remains wired directly to the live broker.
@@ -216,6 +223,7 @@ class ExecutionService:
slot.slot_id,
ack.broker_order_id,
venue=ack.venue,
submitted_at=ack.submitted_at,
)
r.dispatched += 1
if ack.reason and ack.reason.startswith("submit_outcome_unknown:"):
@@ -258,11 +266,19 @@ class ExecutionService:
r.rolled_back += 1
def _poll(self, r: ExecutionResult) -> None:
now = datetime.now(tz=timezone.utc)
now = self._clock().astimezone(timezone.utc)
for slot in self._repo.list_dispatched_pending(self._instance_key):
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
if not str(slot.broker_order_id or "").startswith("submission-unknown:") and (
slot.order_submitted_at is None or slot.order_date is None
):
# Legacy rows cannot be tied safely to an authoritative KIS
# inquiry date. Do not poll: a reused ODNO on today's date
# could otherwise drive a false terminal transition.
self._emit_order_metadata_unknown_alarm(slot)
continue
fs = self._poll_fill(slot, side)
resolved_order_id = (
fs.raw.get("resolved_broker_order_id") if isinstance(fs.raw, dict) else None
)
@@ -312,7 +328,18 @@ class ExecutionService:
# tick, but surface if it has been stuck too long (FR-3), and
# send a cancel if the slot-level timeout is exceeded (REQ-069).
self._check_stale_pending(slot, now, r)
self._try_timeout_cancel(slot, now, side, r)
self._try_timeout_cancel(slot, now, side, fs, r)
def _poll_fill(self, slot, side: str):
"""Use immutable order metadata when the adapter supports the new seam."""
poll = self._broker.poll_fill
parameters = inspect.signature(poll).parameters
kwargs = {"side": side, "symbol": slot.symbol or ""}
if "order_date" in parameters:
kwargs["order_date"] = slot.order_date
if "submitted_at" in parameters:
kwargs["submitted_at"] = slot.order_submitted_at
return poll(slot.broker_order_id, **kwargs)
# ------------------------------------------------------------
def _balance_map(self) -> dict:
@@ -553,39 +580,45 @@ class ExecutionService:
self._stale_threshold_sec,
)
def _try_timeout_cancel(self, slot, now: datetime, side: str, r: ExecutionResult) -> None:
"""Send cancel_order when the slot-level timeout is exceeded (dcy-022/REQ-069).
Best-effort: a failure only logs a warning — the slot is NOT changed here.
Actual rollback happens on the next poll tick when the broker confirms the
cancel (is_terminal_reject) or partial fill then cancel (is_partial_cancel).
This design is safe across the cancel-fill race: if the cancel races with a
fill, the next poll sees is_filled and follows the normal fill path (FR-4).
"""
def _try_timeout_cancel(self, slot, now: datetime, side: str, fs, r: ExecutionResult) -> None:
"""Begin or safely retry cancellation after the immutable deadline."""
if str(slot.broker_order_id or "").startswith("submission-unknown:"):
return
if slot.cancel_state is not None:
if slot.timeout_sec is None:
return
if slot.timeout_sec is None or slot.updated_at is None:
if slot.order_submitted_at is None or slot.order_date is None:
self._emit_order_metadata_unknown_alarm(slot)
return
age = (now - slot.updated_at).total_seconds()
age = (now - slot.order_submitted_at).total_seconds()
if age < slot.timeout_sec:
return
is_retry = slot.cancel_state in {
CancelState.REJECTED.value,
CancelState.UNKNOWN.value,
}
if slot.cancel_state == CancelState.REQUESTED.value:
reason = fs.raw.get("reason") if isinstance(fs.raw, dict) else None
if fs.state == "unknown" or reason == "order_not_visible_unresolved":
self._emit_unresolved_cancel_alarm(slot)
return
if slot.cancel_state is not None and not is_retry:
return
if is_retry:
self._emit_unresolved_cancel_alarm(slot)
if not (isinstance(fs.raw, dict) and fs.raw.get("order_open_confirmed") is True):
return
backoff_sec = min(300, 5 * (2 ** max(0, slot.cancel_attempts - 1)))
retry_before = now - timedelta(seconds=backoff_sec)
else:
retry_before = None
qty = int(slot.qty or 0)
# Write UNKNOWN before external I/O. If the process dies after KIS
# receives the request but before the response is persisted, restart
# polls the order instead of issuing a blind second cancellation.
if not self._repo.mark_cancel_outcome(
if not self._repo.reserve_cancel_attempt(
slot.slot_id,
str(slot.broker_order_id),
CancelState.UNKNOWN.value,
only_if_unset=True,
retry_before=retry_before,
):
_log.error(
"cancel attempt reservation conflict: slot=%s broker_order_id=%s",
slot.slot_id,
slot.broker_order_id,
)
return
try:
cancel_kwargs = {
@@ -610,8 +643,7 @@ class ExecutionService:
)
if not persisted:
_log.error(
"cancel outcome persistence conflict: slot=%s broker_order_id=%s "
"outcome=%s",
"cancel outcome persistence conflict: slot=%s broker_order_id=%s outcome=%s",
slot.slot_id,
slot.broker_order_id,
outcome.state.value,
@@ -619,6 +651,14 @@ class ExecutionService:
return
if outcome.is_requested:
r.timeout_cancelled += 1
elif outcome.state in {CancelState.REJECTED, CancelState.UNKNOWN}:
self._emit_unresolved_cancel_alarm(
replace(
slot,
cancel_state=outcome.state.value,
cancel_attempts=slot.cancel_attempts + 1,
)
)
_log.warning(
"timeout cancel result: slot=%s status=%s broker_order_id=%s "
"age_sec=%.0f timeout_sec=%d outcome=%s",
@@ -630,6 +670,52 @@ class ExecutionService:
outcome.state.value,
)
def _emit_unresolved_cancel_alarm(self, slot) -> None:
key = (str(slot.slot_id), str(slot.cancel_state), slot.cancel_attempts)
if key in self._order_alarm_keys:
return
self._order_alarm_keys.add(key)
_log.critical(
"cancel unresolved: slot=%s status=%s broker_order_id=%s outcome=%s "
"(fail-closed pending; authoritative polling continues)",
slot.slot_id,
slot.status.value,
slot.broker_order_id,
slot.cancel_state,
)
if self._alarm_emitter is not None:
self._alarm_emitter.publish_alarm(
"order_cancel_unresolved",
f"주문 취소 미확정: 슬롯 pending 유지 (slot={slot.slot_id})",
fields={
"slot_id": str(slot.slot_id),
"symbol": slot.symbol,
"cancel_state": slot.cancel_state,
"order_date": slot.order_date,
},
)
def _emit_order_metadata_unknown_alarm(self, slot) -> None:
key = (str(slot.slot_id), "order_metadata_unknown", slot.cancel_attempts)
if key in self._order_alarm_keys:
return
self._order_alarm_keys.add(key)
_log.critical(
"order reconciliation metadata unknown: slot=%s status=%s "
"(fail-closed pending; no timeout cancel retry)",
slot.slot_id,
slot.status.value,
)
if self._alarm_emitter is not None:
self._alarm_emitter.publish_alarm(
"order_reconciliation_metadata_unknown",
f"주문 원시각/원주문일 미확인: 슬롯 pending 유지 (slot={slot.slot_id})",
fields={
"slot_id": str(slot.slot_id),
"symbol": slot.symbol,
},
)
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
if self._event_emitter is None:
return
@@ -687,6 +773,20 @@ class ExecutionService:
if resolved_price is not None
else (float(fs.avg_price) if fs.avg_price is not None else 0.0)
)
execution_venue = fs.execution_venue
if execution_venue is None and slot.venue in {"KRX", "NXT"}:
execution_venue = slot.venue
if execution_venue not in {"KRX", "NXT"}:
r.ledger_skipped += 1
_log.warning(
"ledger skip: slot=%s portfolio_id=%s side=%s "
"reason=missing_execution_venue order_venue=%s",
slot.slot_id,
slot.portfolio_id,
side,
slot.venue,
)
return
trade = LedgerTrade(
asset_id=str(slot.portfolio_id),
order_id=str(broker_order_id),
@@ -697,7 +797,8 @@ class ExecutionService:
fee=float(fs.fee),
tax=float(fs.tax),
executed_at=datetime.now(tz=timezone.utc),
venue=slot.venue,
venue=execution_venue,
order_venue=slot.venue,
)
self._ledger_outbox.enqueue(trade)
r.ledger_enqueued += 1
@@ -734,11 +835,9 @@ class ExecutionService:
cancel_kwargs["venue"] = slot.venue
# Durable unknown reservation closes the crash window between the
# outbound request and saving its immediate result.
if not self._repo.mark_cancel_outcome(
if not self._repo.reserve_cancel_attempt(
slot.slot_id,
str(slot.broker_order_id),
CancelState.UNKNOWN.value,
only_if_unset=True,
):
return "cancel_already_recorded"
outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
+31 -9
View File
@@ -38,6 +38,7 @@ from trex_core.domain.slot import (
)
_MAX_ADMIT_RETRY = 8
_HARD_ORDER_TIMEOUT_SEC = 3600
@dataclass(frozen=True)
@@ -76,9 +77,8 @@ class BuyIntent:
# slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell.
timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None
# Strategy wire default. AUTO is resolved at the broker boundary so
# strategies remain session-agnostic while explicit KRX/NXT/SOR is kept.
venue: str = "AUTO"
# Missing strategy venue uses KIS SOR; explicit KRX/NXT/SOR is preserved.
venue: str = "SOR"
extra: dict[str, Any] = field(default_factory=dict)
@@ -97,7 +97,7 @@ class SellIntent:
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None
venue: str = "AUTO"
venue: str = "SOR"
extra: dict[str, Any] = field(default_factory=dict)
@@ -129,9 +129,15 @@ class IntentService:
# expected to set max_active_slots in the instance TOML — design §5).
self._instance_key = instance_key
self._max_active_slots = max_active_slots
# dcy-022 / REQ-068: default timeout when intent omits timeout_sec.
# None = OFF = indefinite wait (legacy behavior). Per-intent value overrides.
self._default_order_timeout_sec = default_order_timeout_sec
# Omission never disables timeout. Runtime validates configured values;
# this seam remains fail-safe for direct application construction.
self._default_order_timeout_sec = (
default_order_timeout_sec
if isinstance(default_order_timeout_sec, int)
and not isinstance(default_order_timeout_sec, bool)
and 1 <= default_order_timeout_sec <= _HARD_ORDER_TIMEOUT_SEC
else _HARD_ORDER_TIMEOUT_SEC
)
# BAT-050 / REQ-104: reconcile-warning gate store (Optional).
# When None, gate is skipped (backward compat, NFR-4).
self._warn_store = warn_store
@@ -219,7 +225,10 @@ class IntentService:
dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False,
BuyIntent is rejected before any slot lookup."""
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
timeout_error = self._timeout_error(intent.timeout_sec)
if timeout_error is not None:
return IntentDecision(intent.intent_id, "rejected", None, timeout_error)
if intent.venue not in {"KRX", "NXT", "SOR"}:
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
# dcy-036 / REQ-114: day-start entry gate (fail-closed).
@@ -334,7 +343,10 @@ class IntentService:
return IntentDecision(intent.intent_id, "rejected", None, "already_active")
def _admit_sell(self, intent: SellIntent) -> IntentDecision:
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
timeout_error = self._timeout_error(intent.timeout_sec)
if timeout_error is not None:
return IntentDecision(intent.intent_id, "rejected", None, timeout_error)
if intent.venue not in {"KRX", "NXT", "SOR"}:
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
slot = self._repo.fetch_slot(intent.slot_id)
@@ -385,3 +397,13 @@ class IntentService:
except SlotStateMismatchError:
return IntentDecision(intent.intent_id, "rejected", None, "slot_state_changed")
return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None)
@staticmethod
def _timeout_error(timeout_sec: object) -> Optional[str]:
if timeout_sec is None:
return None
if not isinstance(timeout_sec, int) or isinstance(timeout_sec, bool):
return "bad_request:timeout_sec_must_be_integer"
if not 1 <= timeout_sec <= _HARD_ORDER_TIMEOUT_SEC:
return "bad_request:timeout_sec_out_of_range"
return None
+5 -3
View File
@@ -57,9 +57,11 @@ class LedgerTrade:
fee: float = 0.0
tax: float = 0.0
executed_at: Optional[datetime] = None
# Actual order venue persisted in the Core outbox. Portfolio Hub remains
# unchanged until its API contract explicitly accepts this additive field.
venue: str = "KRX"
# Broker-authoritative execution exchange (KRX/NXT). Historical rows whose
# SOR execution exchange cannot be reconstructed are None.
venue: Optional[str] = None
# Original submission/routing venue (KRX/NXT/SOR), retained for audit.
order_venue: str = "KRX"
@property
def correlation_id(self) -> str:
+5 -35
View File
@@ -30,32 +30,6 @@ from trex_core.domain.slot import (
_log = logging.getLogger(__name__)
# ---- sell-reject retryability (issue #27 / REQ-007 CHG-20260615-01) ---------
# core-owned reason -> retryable mapping for the rejected SellIntent response.
# Maintained with the reject vocabulary (intents.py _admit_sell/_dedup) so a new
# reason gets a correct flag without the daemon touching a table. Unrecognized
# reasons default to retryable=True: a stranded stop-loss (held losing position)
# is worse than bounded, observable hammering on a terminal decision.
_SELL_REJECT_RETRYABLE: dict[str, bool] = {
"slot_state_changed": True,
"slot_not_holding:pending_buy": True,
"duplicate_in_flight": True,
"slot_not_found": False,
"slot_not_holding:inactive": False,
"slot_not_holding:pending_sell": False,
"slot_not_holding:error": False,
}
def _sell_reject_retryable(reason: Optional[str]) -> bool:
"""Map a rejected-sell reason to retryable (issue #27). Default True (safety)."""
if reason is None:
return True
# malformed payload: client defect — retrying the same bytes is pointless.
if reason.startswith("bad_request"):
return False
return _SELL_REJECT_RETRYABLE.get(reason, True)
# ---- intent RPC handler -----------------------------------------------------
@@ -117,7 +91,7 @@ def make_intent_rpc_handler(
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec=_opt_int(body.get("timeout_sec")),
slippage_pct=_opt_float(body.get("slippage_pct")),
venue=_opt_str(body.get("venue")) or "AUTO",
venue=_opt_str(body.get("venue")) or "SOR",
)
d = intent_service.submit_buy(buy)
elif intent_type == "sell":
@@ -133,7 +107,7 @@ def make_intent_rpc_handler(
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
timeout_sec=_opt_int(body.get("timeout_sec")),
slippage_pct=_opt_float(body.get("slippage_pct")),
venue=_opt_str(body.get("venue")) or "AUTO",
venue=_opt_str(body.get("venue")) or "SOR",
)
d = intent_service.submit_sell(sell)
else:
@@ -146,9 +120,6 @@ def make_intent_rpc_handler(
"intent_id": intent_id, "status": "rejected",
"slot_id": None, "reason": f"bad_request:{exc}",
}
# issue #27: sell-side malformed payload carries retryable=False.
if intent_type == "sell":
payload["retryable"] = _sell_reject_retryable(payload["reason"])
return RpcResponse(payload=payload)
payload = {
"intent_id": d.intent_id,
@@ -156,9 +127,6 @@ def make_intent_rpc_handler(
"slot_id": (str(d.slot_id) if d.slot_id else None),
"reason": d.reason,
}
# issue #27 / REQ-007 AC-7: additive retryable on rejected sell only.
if intent_type == "sell" and d.status == "rejected":
payload["retryable"] = _sell_reject_retryable(d.reason)
return RpcResponse(payload=payload)
return handler
@@ -608,7 +576,9 @@ def _opt_float(v) -> Optional[float]:
def _opt_int(v) -> Optional[int]:
if v is None:
return None
return int(v)
if not isinstance(v, int) or isinstance(v, bool):
raise ValueError("integer required")
return v
def _opt_dt(v) -> Optional[datetime]:
+44 -2
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import threading
from contextlib import contextmanager
from dataclasses import replace
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Iterator, Optional
from uuid import UUID, uuid4
@@ -163,6 +163,7 @@ class InMemorySlotRepository:
# --- order execution queue (BAT-004 / REQ-013, option A) ---
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
_KST = timezone(timedelta(hours=9))
def list_undispatched_pending(
self,
@@ -191,15 +192,20 @@ class InMemorySlotRepository:
broker_order_id: str,
*,
venue: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> bool:
"""Atomically persist dispatch identity and the actual broker venue."""
"""Atomically persist dispatch identity and its immutable clock."""
s = self._slots.get(slot_id)
if s is None or s.broker_order_id is not None:
return False
submitted_at = submitted_at or datetime.now(tz=timezone.utc)
self._slots[slot_id] = replace(
s,
broker_order_id=broker_order_id,
venue=venue if venue is not None else s.venue,
order_submitted_at=submitted_at,
order_date=submitted_at.astimezone(self._KST).strftime("%Y%m%d"),
cancel_attempts=0,
updated_at=datetime.now(tz=timezone.utc),
)
return True
@@ -245,6 +251,39 @@ class InMemorySlotRepository:
)
return True
def reserve_cancel_attempt(
self,
slot_id: UUID,
broker_order_id: str,
*,
retry_before: Optional[datetime] = None,
) -> bool:
s = self._slots.get(slot_id)
retry_allowed = (
retry_before is not None
and s is not None
and s.cancel_state in {"rejected", "unknown"}
and s.cancel_attempts < 4
and s.cancel_requested_at is not None
and s.cancel_requested_at <= retry_before
)
if (
s is None
or s.broker_order_id != broker_order_id
or s.status not in self._PENDING
or (s.cancel_state is not None and not retry_allowed)
):
return False
now = datetime.now(tz=timezone.utc)
self._slots[slot_id] = replace(
s,
cancel_state="unknown",
cancel_requested_at=now,
cancel_attempts=s.cancel_attempts + 1,
updated_at=now,
)
return True
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
return [
@@ -266,6 +305,9 @@ class InMemorySlotRepository:
broker_order_id=None,
cancel_state=None,
cancel_requested_at=None,
order_submitted_at=None,
order_date=None,
cancel_attempts=0,
updated_at=datetime.now(tz=timezone.utc),
)
+8
View File
@@ -64,6 +64,11 @@ class Slot:
# across ticks and restarts.
cancel_state: Optional[str] = None
cancel_requested_at: Optional[datetime] = None
# Immutable dispatch clock and original KST order date. Unlike updated_at,
# these survive polling/cancel bookkeeping and anchor deadline/recovery.
order_submitted_at: Optional[datetime] = None
order_date: Optional[str] = None
cancel_attempts: int = 0
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
@@ -95,6 +100,9 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"venue",
"cancel_state",
"cancel_requested_at",
"order_submitted_at",
"order_date",
"cancel_attempts",
})
+20 -3
View File
@@ -213,6 +213,9 @@ class SlotService:
venue="KRX",
cancel_state=None,
cancel_requested_at=None,
order_submitted_at=None,
order_date=None,
cancel_attempts=0,
updated_at=datetime.now(tz=timezone.utc),
)
@@ -229,9 +232,8 @@ class SlotService:
new_timeout_sec = None
new_slippage_pct = None
# A completed order's venue must not leak into a later autonomous
# sell. The next order starts session-aware unless its intent
# explicitly chooses KRX/NXT/SOR.
new_venue = "AUTO" if to_state is SlotState.HOLDING else slot.venue
# sell. Missing venue defaults to KIS SOR for the next order.
new_venue = "SOR" if to_state is SlotState.HOLDING else slot.venue
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
# occupancy (inactive→pending_buy). Stable across the rest of the
@@ -287,6 +289,21 @@ class SlotService:
venue=new_venue,
cancel_state=None,
cancel_requested_at=None,
order_submitted_at=(
slot.order_submitted_at
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}
else None
),
order_date=(
slot.order_date
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}
else None
),
cancel_attempts=(
slot.cancel_attempts
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}
else 0
),
updated_at=datetime.now(tz=timezone.utc),
)
+15 -10
View File
@@ -170,9 +170,8 @@ class DaemonInstanceConfig:
# admission capacity cap. BAT-030: asset-centric is the only model; absent
# means no cap (operators are expected to set it in production).
max_active_slots: Optional[int] = None
# dcy-022 / REQ-068: default order timeout in seconds (opt-in per instance).
# None = OFF = indefinite wait (legacy behavior). Per-intent timeout_sec overrides.
default_order_timeout_sec: Optional[int] = None
# Per-instance override; omission uses the hard 3,600-second fallback.
default_order_timeout_sec: int = 3600
# dcy-024 / REQ-048 CHG-20260611-01: disable autonomous safety-sell worker.
# Absent = True (safe default). False → trex-safety-sell thread not started.
safety_sell_enabled: bool = True
@@ -202,11 +201,17 @@ class DaemonInstanceConfig:
raise RuntimeError(
f"instance TOML 'max_active_slots' must be > 0, got {raw_max}: {path}"
)
raw_timeout = data.get("default_order_timeout_sec")
default_timeout = int(raw_timeout) if raw_timeout is not None else None
if default_timeout is not None and default_timeout <= 0:
raw_timeout = data.get("default_order_timeout_sec", 3600)
if not isinstance(raw_timeout, int) or isinstance(raw_timeout, bool):
raise RuntimeError(
f"instance TOML 'default_order_timeout_sec' must be > 0, got {raw_timeout}: {path}"
f"instance TOML 'default_order_timeout_sec' must be an integer in 1..3600, "
f"got {raw_timeout}: {path}"
)
default_timeout = raw_timeout
if not 1 <= default_timeout <= 3600:
raise RuntimeError(
f"instance TOML 'default_order_timeout_sec' must be in 1..3600, "
f"got {raw_timeout}: {path}"
)
raw_wd = data.get("watchdog_timeout_sec")
watchdog_timeout = int(raw_wd) if raw_wd is not None else None
@@ -258,9 +263,8 @@ class DaemonConfig:
# threshold in seconds. 0 disables (default). When >0, dispatched-pending
# slots older than this are surfaced (WARN + counter) without state change.
pending_stale_threshold_sec: int = 0
# dcy-022 / REQ-068: default order timeout (seconds). None = OFF = indefinite.
# Per-intent timeout_sec overrides. Passed to IntentService at wiring time.
default_order_timeout_sec: Optional[int] = None
# Effective fallback passed to IntentService; never disabled by omission.
default_order_timeout_sec: int = 3600
# dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). The
# daily preallocation trigger was removed with the pool model (BAT-030).
trading_timezone: str = "Asia/Seoul"
@@ -417,4 +421,5 @@ class DaemonConfig:
safety_sell_enabled=inst.safety_sell_enabled,
safety_sell=safety_sell,
credential_fetch_enabled=inst.credential_fetch_enabled,
default_order_timeout_sec=inst.default_order_timeout_sec,
)