Merge pull request #42 from fourblocks2020/feat/dcy-037-operator-broker-truth-hotfix

fix(broker): enforce operator broker truth boundary
This commit is contained in:
2026-07-15 08:47:37 +09:00
committed by GitHub
35 changed files with 1894 additions and 40 deletions
@@ -0,0 +1,277 @@
# BAT-057 Design: operator broker truth 및 managed divergence emergency hotfix
- Status: confirmed
- Batch: BAT-057
- Design scope: REQ-119, REQ-120, REQ-121, REQ-122
- Implementation authorization: 본 문서는 구현 설계만 정의한다. `sync --live`, production target 선택, credential/secret 접근 및 서비스 실행은 범위 밖이다.
## Architecture Summary
### 1. 설계 목표와 현재 결함
현재 daemon은 `runtime.daemon._build_broker(config)`에서 env/TOML `KISConfig`로 broker를 먼저 만들고, `SnapshotScheduler._maybe_fetch_credential()` 성공 후에만 동일 객체의 `reinitialize()`를 호출한다. 반면 CLI `sync`/`verify`도 `_build_broker(cfg)`를 직접 호출하므로 fetched mode에서도 operator read가 env/TOML source로 시작한다. 또한 `KISBroker.get_balance()`은 token/HTTP/JSON/KIS `rt_cd` 오류를 `[]`로 변환하여 실제 empty balance와 구별하지 못한다. 결과적으로 operator가 잘못된 source 또는 실패를 `broker_qty_zero`로 해석하고 write/skip/성공 판단으로 이어갈 수 있다.
본 설계는 다음을 고정한다.
1. daemon과 모든 operator broker-read (`sync`, `verify`, 이후 같은 provider를 쓰는 read path)가 하나의 credential-resolution 및 broker-construction 경계를 사용한다.
2. fetched mode의 credential fetch 또는 balance read 실패는 명시적 break-glass가 없는 한 **broker가 없는 실패**로 종료한다. env/config으로 조용히 fallback하거나 empty holdings로 바꾸지 않는다.
3. `get_balance()` 성공 empty와 실패를 typed/sanitized result로 분리하고, 실패 결과가 reconciliation, `sync`, `verify`의 write/clear/skip 판단에 도달하지 못하게 한다.
4. unmatched broker holding만 instance-scoped managed-slot 존재 여부를 조회하여 true unmanaged와 managed non-holding divergence를 additive하게 분류한다.
## Changed Areas
### 2. 모듈별 변경과 소유권
| 모듈 | 변경/소유권 | 구현 경계 |
| --- | --- | --- |
| `trex_core/application/broker_provider.py` (신규) | `BrokerProvider` protocol, source metadata, credential resolution, broker reuse/invalidation policy의 application ownership | portfolio-hub read port와 KIS/paper construction을 조립하되 CLI 출력/DB write를 소유하지 않는다. |
| `trex_core/runtime/broker_factory.py` (신규) 또는 `runtime/daemon.py`의 명시적 factory | `DaemonConfig` → provider assembly. 기존 `_build_broker` 직접 사용자를 provider로 이관 | runtime composition만 담당하며 credential 값을 로그/JSON으로 만들지 않는다. 구현 시 순환 import가 생기면 factory를 별도 runtime module로 둔다. |
| `trex_core/runtime/daemon.py` | daemon은 process-lifetime provider에서 broker를 획득하고 scheduler에 같은 provider/reload callback을 주입 | daemon이 별도 env broker를 만들거나 scheduler와 다른 credential source를 해석하지 않는다. |
| `trex_core/application/snapshot_scheduler.py` | day-start fetched credential 성공/실패 및 broker reload를 provider contract로 호출 | scheduler의 entry gate 역할은 유지하되, fetched failure 뒤 env credential으로 trading/broker-read를 계속하는 현재 fallback은 제거한다. |
| `trex_core/runtime/cli.py` | `--config`가 있으면 daemon과 동일한 `DaemonConfig.from_config(toml_path, env_file=...)`로 instance TOML을 로드하고, `_cmd_sync`/`_cmd_verify`가 그 config의 같은 provider를 호출 | broker read가 성공하기 전 slot/warning write, `broker_qty_zero`, verify success를 만들지 않으며 CLI가 TOML `credential_fetch_enabled`를 env-only default로 덮어쓰지 않는다. |
| `trex_core/adapters/broker/kis.py` | typed sanitized `BrokerBalanceError`, strict `get_balance()`, token/error mapping | credential/token/header/raw account/payload를 error message, log, CLI JSON에 싣지 않는다. |
| `trex_core/application/reconciliation.py` | balance failure 전파와 managed non-holding decision 분기 | REQ-121 분류 자체로 slot/warning persistence를 쓰거나 상태를 바꾸지 않는다. 기존 A/B/C behavior는 strict balance 성공 뒤에만 유지한다. |
| `trex_core/domain/slot/repository.py` | managed-slot representative lookup protocol 추가 | query는 `instance_key`와 symbol만 입력으로 받고 status/retired 여부를 임의로 제외하지 않으며, safe triage context를 위한 deterministic `Slot` 한 건 또는 `None`을 반환한다. |
| `trex_core/domain/slot/in_memory_repository.py`, `trex_core/adapters/persistence/postgresql.py` | 같은 managed-slot lookup 구현 | PostgreSQL은 instance predicate를 반드시 SQL에 넣고, InMemory와 동일한 representative-slot 의미를 제공한다. |
| `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/operations.md`, 필요 시 `docs/project-structure.md` | REQ-122 baseline 갱신 대상 | REQ-119~121 implementation/QC evidence가 확정되기 전에는 갱신하지 않는다. |
## Key Decisions
### 3. 공통 provider 계약 (REQ-119)
### D1. source mode와 안전한 식별자
신규 application contract는 다음과 같이 제안한다. 실제 파일 위치는 implementation에서 import-cycle 검증 후 위 표의 신규 module 중 하나로 고정한다.
```python
@dataclass(frozen=True)
class BrokerSource:
mode: Literal["env", "fetched", "break_glass"]
identity_label: str # stable, non-secret, masked/fingerprinted only
class BrokerProvider(Protocol):
def get_broker_for_read(self) -> tuple[BrokerPort, BrokerSource]:
"""Return a broker whose credential source was resolved successfully.
Raise BrokerCredentialUnavailable on fetched resolution failure; never
return an env broker as an implicit replacement for fetched mode.
"""
...
def refresh_day_start_credential(self) -> BrokerSource:
"""Resolve/reload the day-start source for daemon ownership.
Raise BrokerCredentialUnavailable on a required fetched-source failure.
"""
...
```
`BrokerSource.identity_label`은 credential version과 one-way masked fingerprint 등 비밀이 아닌 stable value만 허용한다. raw `account_ref`, app key, app secret, access token, Authorization header, raw credential JSON은 provider return value, exception, log, CLI JSON, fixture에 넣지 않는다. label 산출은 단일 helper로 소유하고 fixture의 고정 synthetic label도 같은 형식을 사용한다.
### D2. source resolution 순서와 failure
`credential_fetch_enabled`는 KIS credential source 정책이며 Paper broker의 의미를 바꾸지 않는다. provider는 먼저 `config.broker`를 분기한다.
- `config.broker != "kis"`이면 기존 `PaperBroker`를 구성해 `mode="env"`의 safe source로 반환한다. flag가 `True`여도 portfolio-hub credential endpoint를 호출하거나 KIS로 재구성하지 않으며, 이 조합은 existing backward-compatible configuration으로 유지한다.
- `config.broker == "kis" and credential_fetch_enabled=False`이면 provider는 기존 env/TOML KIS construction을 사용하고 portfolio-hub credential endpoint를 호출하지 않는다. 이는 backward-compatible mode이다.
- `config.broker == "kis" and credential_fetch_enabled=True`이면 다음 순서만 허용한다.
1. 명시적 `TREX_KIS_BROKER_BREAK_GLASS`가 활성화되면 env/TOML source를 선택하고 `mode="break_glass"`로 표시한다. fetch를 시도하지 않으며 activation fact만 secret-safe하게 관찰 가능해야 한다.
2. break-glass가 아니면 portfolio-hub `fetch_broker_credential()`을 호출해 `TradingContext.broker`와 `trading_enabled`를 검증한다.
3. fetch unavailable, malformed/absent credential, `trading_enabled=False`, provider/KIS 재구성 실패는 `BrokerCredentialUnavailable(code=...)`으로 종료한다. env broker를 만들거나 기존 env credential을 계속 사용하지 않는다.
4. 성공한 fetched payload로 KIS broker를 최초 생성하거나 기존 process broker를 `reinitialize()`한다. return source는 `mode="fetched"`이다.
따라서 daemon과 CLI는 source mode가 아니라 provider API를 호출한다. daemon의 day-start scheduler는 `refresh_day_start_credential()`을 호출하고, CLI는 command마다 `get_broker_for_read()`을 호출한다. 같은 invocation에서 `sync`가 여러 slot을 순회하거나 `verify`가 여러 warning을 순회할 때는 provider 반환 broker 하나를 재사용한다.
### D3. lifecycle/reuse 및 retry 결정
- **Ownership:** daemon은 하나의 process-lifetime provider와 그 provider가 소유하는 broker 하나를 갖는다. CLI는 command invocation당 provider 하나와 broker 하나를 갖는다. caller는 broker를 재생성하지 않는다.
- **Reuse scope:** KISBroker의 기존 L1 token과 existing token-cache L2(설정된 경우)를 그대로 reuse한다. credential version/source fingerprint 변경 또는 명시적 fetched refresh 성공 시에만 `reinitialize()`로 L1을 invalidate하고 새 `(account_ref, app_key)` cache key를 사용한다.
- **근거:** 현 코드의 `KISBroker._ensure_token()`은 L1→L2→issuance 경로와 credential-keyed L2를 이미 제공한다. BAT-057은 process-local cache나 새 shared cache를 선결정하지 않고, provider가 fresh broker/object를 반복 만드는 우회 경로를 제거한다.
- **Retry:** token issuance/auth authorization/KIS business failure/malformed response는 즉시 sanitized failure이며 동일 read 안에서 retry하지 않는다. transport timeout/connection failure의 retry 횟수·backoff는 existing HTTP client retry 정책이 없으므로 implementation에서 새 implicit retry를 넣지 않는다(0회). 상위 command가 재시도하려면 별도 invocation/explicit operator action이어야 한다. credential fetch도 이 batch에서는 one attempt fail-loud이다.
- **Invalidation:** source credential 성공 refresh만 credential/token invalidation trigger이다. token cache backend miss/error는 token issuance를 시도할 수 있으나, issuance 실패를 empty balance로 변환하지 않는다.
## Edge Cases
### 4. KIS balance 실패 계약과 no-write barrier (REQ-120)
### D4. typed sanitized failure
`KISBroker.get_balance()`의 반환 계약을 성공 list와 실패가 구별되게 변경한다. 권장 signature는 breaking change를 명시적으로 수행한다.
```python
class BrokerBalanceError(RuntimeError):
def __init__(self, code: Literal[
"token_issuance_failed", "authentication_failed", "authorization_failed",
"transport_failed", "malformed_response", "broker_rejected"
]) -> None: ...
def get_balance(self) -> list[BalanceItem]:
"""Return holdings only after a validated successful KIS balance response.
Raise BrokerBalanceError(code) for any token/auth/transport/shape/KIS failure.
Return [] only for a validated successful response with no positive holdings.
"""
```
`BrokerPort`의 protocol/documentation 및 모든 fake broker를 이 exception contract에 맞춘다. `httpx.HTTPStatusError`와 token JSON/key errors는 `token_issuance_failed`; balance HTTP 401/403은 authentication/authorization; other network/timeout은 `transport_failed`; JSON, `rt_cd` shape, `output1` type/row shape 오류는 `malformed_response` 또는 `broker_rejected`로 map한다. code는 stable allow-list이며 upstream response text, URL query, header, body를 포함하지 않는다.
### D5. propagation and barrier
`reconcile_core_holdings`, `reconcile_positions`, `sync_slot_to_broker`, `verify_slot_reconcile`은 `broker.get_balance()`를 호출하기 전에 write를 하지 않으며 `BrokerBalanceError`를 swallowing하지 않는다. CLI command boundary만 이를 catch하여 `{ "error": {"code": ..., "message": "broker balance unavailable"} }` 형태(기존 `--json` 형식에 맞춰 조정 가능) 또는 stderr를 내고 exit code `1`을 반환한다. error object에는 source `mode`와 safe label은 포함할 수 있으나 secret은 포함하지 않는다.
이 barrier로 다음은 failure path에서 금지된다.
- `sync_slot_to_broker()`의 `broker_qty_zero`/`skip_reason` 생성, slot transition/update, `clear_warned()` 호출
- `verify_slot_reconcile()`의 `cleared`, `not_warned`를 제외한 broker-derived success 판단 및 `clear_warned()` 호출
- reconciliation의 false-flat A-case error quarantine, B/C warning write/clear, empty-holdings/unmanaged classification
- CLI의 verify success 또는 dry-run broker quantity output
실제 successful empty `[]`만 기존 A-case/broker-zero/normal empty semantics에 도달한다. 이 구분은 `sync --dry-run`에도 동일하며 dry-run이라도 실패를 zero preview로 보여주지 않는다.
## 5. managed non-holding divergence (REQ-121)
### D6. repository parity contract
`SlotRepository`에 다음 read-only query를 추가한다. REQ-121 AC-2의 triage context가 필요하므로 boolean existence API가 아니라 representative slot을 반환한다.
```python
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]:
"""Return one deterministic managed slot for exact instance+symbol, or None.
Caller invokes only for unmatched broker holdings. The result is read-only;
it must not imply that the slot is holding, eligible, or remediated.
"""
...
```
동일 instance에 historical duplicate symbol rows가 가능하므로 deterministic parity를 위해 PostgreSQL은 `ORDER BY updated_at DESC NULLS LAST, slot_id ASC LIMIT 1`로 고정하고, InMemory도 `updated_at is None`을 가장 오래된 값으로 취급한 동일 key `(updated_at DESC NULLS LAST, slot_id ASC)`를 적용한다. PostgreSQL query는 `WHERE instance_key = %s AND symbol = %s`만 사용하여 holding/inactive/error/pending/retired rows를 포함하며, InMemory도 same exact predicate를 전수 비교한다. `symbol is None` 또는 empty broker symbol은 caller input validation issue이며, query는 read-only라 schema/migration은 필요 없다. index 추가는 실제 production query-plan evidence가 나오기 전 본 hotfix에서 요구하지 않는다. 모든 candidate를 triage 해야 하는 requirement가 아니므로 one representative slot만 structured context에 사용한다. PostgreSQL과 InMemory가 같은 fixture에서 같은 slot identity를 반환하도록 test한다.
### D7. reconciliation 분기와 decision code
`reconcile_core_holdings()`의 holding-slot loop와 기존 A/B quantity handling은 그대로 유지한다. broker holdings loop에서 `bq > 0 and sym not in matched`일 때만 D6 query를 실행한다.
```python
managed = slot_repo.find_managed_slot_by_symbol(instance_key, sym)
if managed is not None:
decisions[f"broker:{sym}"] = "managed_non_holding_divergence"
_log.warning(
"managed non-holding divergence: slot_id=%s portfolio_id=%s "
"symbol=%s status=%s instance_key=%s",
managed.slot_id, managed.portfolio_id, sym, managed.status.value, instance_key,
)
else:
decisions[f"broker:{sym}"] = "unmanaged_position"
_log.warning("unmanaged position: symbol=%s qty=%d instance_key=%s", sym, bq, instance_key)
```
최종 분기는 다음 4-case matrix를 유지한다.
| Input | Existing holding path | Expected decision | Write effect |
| --- | --- | --- | --- |
| broker holding, matching holding slot and same qty | match | `ok` | 없음 |
| broker holding, matching holding slot and qty mismatch | existing B case | `qty_warning` | 기존 warning write만 가능 |
| broker holding, target instance에 어떤 managed slot도 없음 | unmatched | `unmanaged_position` | 없음(기존 safe warning/log만) |
| broker holding, target instance에 `inactive`/`error` 등 non-holding managed slot 존재 | unmatched + managed query hit | `managed_non_holding_divergence` | 없음 |
`managed_non_holding_divergence`는 new additive telemetry/structured warning code이다. slot state transition, warning 자동 해제/생성, auto trade/liquidation, unmanaged auto-slot creation, `sync` invocation을 하지 않는다. convergence는 release-approved existing `sync` path에만 남긴다. 다른 instance에 같은 symbol/portfolio slot이 있어도 query miss여야 한다.
## 6. CLI와 daemon wiring
1. CLI `main()`은 `--config`가 있으면 `resolve_config_toml()` 뒤 `DaemonConfig.from_config(toml_path, env_file=args.env_file)`를 호출하고, config가 없을 때만 `DaemonConfig.from_env()`를 쓴다. 따라서 `sync`/`verify`는 daemon과 같은 instance key, portfolio-hub credential setting 및 `credential_fetch_enabled`를 받는다.
2. `daemon.Daemon.__init__`은 provider를 한 번 assemble하고 `broker, _source = provider.get_broker_for_read()`로 얻은 broker를 `self.broker`에 보관한다. fetched mode startup에서 fetch가 불가능하면 daemon은 explicit safe degraded/entry-blocked state로 남고 env broker로 진행하지 않는다. 기존 scheduler의 `entry_allowed` alarm model은 유지하되 provider failure code를 secret-safe reason으로 전달한다.
3. `SnapshotScheduler`에는 raw `kis_reinit_callback` 대신 `credential_refresh: Callable[[], BrokerSource]` 또는 provider 자체를 dependency로 주입한다. fetched refresh success는 same long-lived broker reinitialize; failure는 `_entry_allowed=False` 및 safe alarm이며 silent env continuation이 아니다. Paper provider는 refresh에서 no-op safe source를 반환하며 fetch/reinitialize를 시도하지 않는다.
4. `_cmd_sync`와 `_cmd_verify`는 `build_broker_provider(cfg).get_broker_for_read()`를 command 초기에 한 번 호출한다. provider acquisition failure면 DB slot target enumeration, cost-basis fetch, warning mutation 이전에 return `1`한다.
5. `sync`의 live double-ack remains before any live write. BAT-057은 flag syntax, CLI command name, wire API를 추가/변경하지 않는다. `verify`는 read-centric라는 기존 boundary를 유지하되 failed read를 success/degrade로 바꾸지 않는다.
6. CLI structured output must use codes/source mode/safe label only. 정확한 output field name은 implementation test와 `--help` evidence 후 REQ-122 baseline에 기록한다.
## 7. 테스트 seam과 검증 설계
| Seam | Double/fixture | Required assertions |
| --- | --- | --- |
| `BrokerProvider` | fake portfolio read with fetched credential, absent credential, fetch exception, break-glass, env-only KIS, Paper | daemon-equivalent/`sync --dry-run`/`verify` source label 및 holding set parity; no implicit fallback; env-only KIS and Paper do not call fetch; break-glass is distinguishable; enabled+Paper keeps PaperBroker. |
| CLI config loading | temp instance TOML with `credential_fetch_enabled=true`, config-dir `.env`, and monkeypatched provider factory | `--config` CLI config equals daemon `from_config()` result; `sync`/`verify` take fetched path when KIS enabled, while no-config stays existing env-only path. |
| KIS adapter | monkeypatched `httpx.post/get` for token, 401/403, timeout, invalid JSON, invalid `rt_cd`/`output1`, successful `[]` | each failure raises only allow-listed `BrokerBalanceError.code`; successful empty returns `[]`; token/header/account/payload never appears in exception/log/JSON. |
| CLI command boundary | provider factory seam injected/monkeypatched; fake repos/warn store recording writes | each failure returns nonzero; no `broker_qty_zero`, slot update/transition, warning clear/write, verify cleared/success; `sync --dry-run` also fails loud. |
| lifecycle | counted KIS/token endpoint + provider call sequence + `InMemoryTokenCache` | one broker per command/daemon lifecycle, L1/L2 reuse prevents fresh issuance; source refresh invalidates/rekeys as intended; throttle/transport still returns sanitized failure. |
| reconciliation | fake broker yielding balance or raising; `InMemorySlotRepository` and recording `SlotService`/warn store | 4-case table exactly; existing match/mismatch unchanged; divergence only logs/decision, no state/warning/trade/remediation writes. |
| repository parity | same fixtures against InMemory and PostgreSQL integration DB | same found/not-found and representative-slot identity; `inactive`/`error` hits; no slot and other-instance-only miss; query performs no write. |
| redaction | caplog/capsys/CLI JSON and source fixtures | deny-list scan/assertion for app key, app secret, token, `Authorization`, raw account number and raw credential payload. |
PostgreSQL parity test may use the repository-local test database fixture only; it must not use production DSN, live broker, or credentials. If local PostgreSQL fixture is unavailable, implementation cannot claim REQ-121 AC-5 complete and must record the environment blocker in verification.
## 8. invariants, compatibility, non-goals, rollback
### Invariants
- fetched mode without explicit break-glass: fetch failure means no broker truth and no env/config fallback.
- any broker balance failure: nonzero operator outcome and no broker-derived state/warning/slot write or false-zero classification.
- actual successful empty holdings remain semantically distinct and preserve existing behavior.
- `config.broker != "kis"` (Paper 포함)은 `credential_fetch_enabled` 값과 무관하게 existing broker를 유지하고 portfolio-hub credential fetch/KIS reinitialize를 하지 않는다.
- `config.broker == "kis" and credential_fetch_enabled=False`는 env-only로, portfolio-hub credential request와 CLI syntax regression 없이 유지된다.
- CLI `--config`는 daemon과 같은 `DaemonConfig.from_config()` path를 사용하므로 instance-level fetched-mode policy를 잃지 않는다.
- managed non-holding is read-first additive triage only; all persistence mutations remain on pre-existing validated flows.
- every repository managed-slot lookup is instance-scoped; no cross-instance ownership inference.
- external portfolio-hub wire contract, DB schema, AMQP payload, slot state enum, automatic remediation and live sync authorization do not change.
### Non-goals
- portfolio-hub credential storage/API redesign, token cache infrastructure replacement, global retry framework, bulk/global sync, automatic slot creation, auto trade/liquidation/clear, TVR strategy changes, TVR redeploy, production read/live sync execution.
### Rollback
- The code change must be independently revertible as a single hotfix commit/PR. Revert restores prior construction and decision behavior; it does not mutate existing slots or warnings because the new provider/failure and divergence branches are read-first.
- If fetched provider rollout fails, operator remediation is to stop broker-read/sync, retain evidence with safe codes, and use the separately approved break-glass policy only when an operator explicitly enables it. Do not silently toggle configuration or run `sync --live` as rollback.
- Baseline documentation update is rolled back with the implementation change if implementation evidence is rejected; REQ-122 must not describe unlanded source mode/code/command behavior.
## Architecture Impact
- **both**: `runtime-flows`는 daemon/CLI 공통 provider resolution, KIS balance fail-loud hold 및 두 divergence decision 흐름을 반영해야 한다. `project-structure`는 provider/factory가 stable module로 landed한 경우에만 새 ownership boundary를 반영한다.
- `interface-contract.md`와 `operations.md`도 REQ-122 범위에서 갱신 대상이나, portfolio-hub/AMQP 외부 wire 계약, DB schema 및 live-sync authorization은 변경하지 않는다.
- 실제 baseline 수정은 구현과 독립 QC evidence가 확정된 뒤에만 수행한다.
## 9. REQ acceptance traceability
| REQ / AC | Design element | Required verification evidence |
| --- | --- | --- |
| REQ-119 AC-1 | D1-D3 common provider; §6 same assembly | mocked fetched credentials; daemon-equivalent, `sync --dry-run`, `verify` same safe source label and holding set |
| REQ-119 AC-2 | D2 failure order; D5 barrier | fetch failure/no break-glass → safe error, nonzero, no env broker/qty inference |
| REQ-119 AC-3 | D1-D2 break-glass `mode` and safe observability | explicit break-glass fixture shows distinct safe mode/activation basis |
| REQ-119 AC-4 | D2의 env-only KIS/Paper branches 및 §6 CLI config loading | env-only KIS와 enabled+Paper fetch call count zero, existing Paper regression, `--config` CLI/daemon instance-policy parity |
| REQ-119 AC-5 | D1, §7 redaction seam | logs/JSON/fixtures deny-list assertions |
| REQ-120 AC-1 | D4-D5 strict `get_balance` and CLI boundary | token/auth/transport/malformed fixtures yield distinct sanitized failure and nonzero CLI |
| REQ-120 AC-2 | D5 no-write barrier | recording repo/warn store proves no zero skip, success, clear or slot write for every failure class |
| REQ-120 AC-3 | D4 successful-empty contract | valid empty `output1` gives `[]` and preserves normal empty path |
| REQ-120 AC-4 | D3 lifecycle ownership/reuse | repeated fetched reads counted for broker/token construction; throttle error remains fail-loud |
| REQ-120 AC-5 | D3 policy | implementation/design test records ownership, reuse/invalidation and 0-retry policy without requiring cache mechanism replacement |
| REQ-120 AC-6 | D1/D4/D5 and §7 redaction | failure logs/JSON/fixtures contain only safe code/mode/label |
| REQ-121 AC-1 | D7 unmatched miss | no managed slot → `unmanaged_position`, existing safe warning semantics |
| REQ-121 AC-2 | D6 richer repository lookup + D7 | inactive/error same-instance slot → `managed_non_holding_divergence` with required safe triage context |
| REQ-121 AC-3 | D7 4-case matrix | match and qty mismatch remain `ok`/`qty_warning`, no reclassification |
| REQ-121 AC-4 | D7 invariants and recording persistence seam | divergence creates no slot/warning state write, clear, trade, liquidation or auto-slot |
| REQ-121 AC-5 | D6 repository parity | PostgreSQL/InMemory same found/miss and selected representative result |
| REQ-121 AC-6 | D6 exact instance predicate | other-instance-only matching symbol/portfolio is a miss |
| REQ-122 AC-1 | §10 runtime baseline plan | post-implementation runtime flow matches provider, strict failure, two divergence codes/no-auto-remediation |
| REQ-122 AC-2 | §10 interface baseline plan | actual CLI exit/output safe fields and repository/reconciliation contract; wire-change statement |
| REQ-122 AC-3 | §10 operations baseline plan | dry-run → cross-check → explicit approval → one target live sync → re-reconcile sequence, with no target/command authorization claim |
| REQ-122 AC-4 | §7 redaction + §10 doc secret scan | baseline examples/log/JSON samples pass deny-list review |
| REQ-122 AC-5 | §10 evidence-before-doc rule | CLI help, implemented decision codes/tests, references cross-checked; unresolved facts not documented as policy |
## Reference Doc Update Plan (identify only; do not update in this gate)
REQ-119~121 implementation and independent QC evidence가 확정된 뒤에만 REQ-122 delivery에서 다음을 갱신한다.
- `docs/runtime-flows.md`: fetched/env/break-glass provider resolution, daemon/CLI parity, balance failure hold, successful-empty distinction, `unmanaged_position`/`managed_non_holding_divergence` flow와 no-auto-remediation 경계.
- `docs/interface-contract.md`: actual `sync`/`verify` broker-read failure exit semantics, safe output fields, `BrokerBalanceError`/repository lookup의 internal contract, AMQP/portfolio-hub external wire contract unchanged 여부.
- `docs/operations.md`: masked source/account reference evidence, failure hold, dry-run → broker/ledger/slot cross-check → 사장님 explicit approval → single-target `sync --live --yes` → re-reconcile release gate. 이 문서는 live target을 선택하거나 실행을 승인하지 않는다.
- `docs/project-structure.md`: provider/factory가 신규 stable module로 landed한 경우에만 dependency/ownership 경계를 반영한다.
- `docs/srs/index.md` 및 REQ-119~122 status/Change Log: merge/main and verification evidence 이후 StagePilot 절차에 따라 갱신한다. 본 design gate에서 상태를 변경하지 않는다.
- REQ-112, REQ-113, REQ-115, REQ-103, REQ-105 references: implemented source priority, day-start lifecycle, reconcile decision terminology와 충돌 여부를 evidence 기반으로 cross-check한다.
## 11. Design-gate exit conditions
- [ ] provider source/failure/reuse contract과 break-glass boundary가 implementation owner에게 명확하다.
- [ ] strict balance exception mapping 및 CLI no-write barrier test seam이 확정되었다.
- [ ] repository lookup의 deterministic rich return, PostgreSQL/InMemory parity, instance isolation rule이 확정되었다.
- [ ] 4-case reconciliation matrix가 existing A/B/C behavior와 additive divergence effect를 구분한다.
- [ ] REQ-119~122 AC별 test/document evidence가 §9에 연결되었다.
- [ ] live service, credentials, production DB, `sync --live`를 사용하지 않았고 implementation/baseline update authorization과 혼동하지 않았다.
@@ -0,0 +1,20 @@
# BAT-057 Implementation
- Status: draft
- Batch: BAT-057
## Plan Summary
- Implementation stage에서 REQ-119~121의 broker source parity, KIS fail-loud/no-write 및 managed non-holding divergence 분류를 feature worktree/PR flow로 구현하고, REQ-122는 검증 evidence 이후 문서화한다.
## Changed Files
- To be determined at design gate; no implementation authorized in this planning handoff.
## Execution Log
1. Not started. Batch planning only.
## Validation
- Not started. Independent QC must re-run the implementation evidence before verification confirmation.
## Remaining Risks
- Design gate 미완료 상태에서 provider/lifecycle/security 계약을 추정하지 않는다.
- `sync --live` 및 production command는 이 batch 단계에서 실행하지 않는다.
@@ -0,0 +1,34 @@
# BAT-057: operator broker truth 및 managed divergence emergency hotfix
- Status: verified (`confirm-batch-verification`, lead, 2026-07-15; independent QC PASS-WITH-NOTES, blocker 0)
- Profile: standard
- Source Discovery: dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix
- Included REQ: REQ-119, REQ-120, REQ-121, REQ-122
- Excluded REQ: 없음
## Summary
- dcy-037에서 승인한 #39/#40/#41을 하나의 emergency delivery 단위로 묶어 fetched credential broker truth 정합, auth/token 실패 fail-loud, managed non-holding divergence 분류 및 baseline 문서 정합을 함께 계획한다.
- `sync --live` 실행과 production target 선택은 이 batch에서 승인하거나 수행하지 않고 별도 release approval gate로 유지한다.
## Scope
- In Scope:
- REQ-119: daemon/operator broker-read 공통 fetched credential provider와 no-silent-fallback/secret-safe 관찰성
- REQ-120: KIS balance auth/token/transport/malformed failure와 실제 empty balance의 분리, no-write 및 lifecycle 설계 제약
- REQ-121: instance-scoped managed-slot existence query와 `unmanaged_position`/`managed_non_holding_divergence` additive 분류
- REQ-122: 구현·검증 evidence 이후 runtime/interface/operations baseline 정합 및 remediation release gate 문서화
- Out of Scope:
- portfolio-hub credential API·저장 모델 재설계
- `sync --live` 자동 실행, 전역/bulk sync, unmanaged position 자동 slot 생성 또는 자동 remediation/trading
- TVR 전략 로직 변경 및 TVR 재배포
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- Cross-service contract, security, migration 또는 scope trade-off가 발견되면 구현을 진행하지 않고 lead gate로 중단한다.
- 모든 evidence는 credential, token, app key/secret, raw account number/payload를 포함하지 않는 masked label/fingerprint만 사용한다.
- `confirm-batch-verification` 완료: PR #42 independent QC가 PASS-WITH-NOTES (acceptance blocker 0)로 판정했다. fake-driver repository parity는 REQ-121 AC evidence로 수용되며, 실제 PostgreSQL engine integration은 환경 커버리지 gap으로 추적한다.
- 다음 gate는 PR #42의 사용자 승인 및 `main` merge다. merge 뒤에만 `confirm-req-implemented`를 기록한다. `sync --live`, release 또는 production remediation은 이 batch의 별도 release approval gate 밖에서만 수행한다.
@@ -0,0 +1,45 @@
# BAT-057 Planning
- Status: confirmed
- Batch: BAT-057
- Profile: standard
## Included REQ
- REQ-119 (Approved): fetched credential 공통 broker provider와 operator CLI truth 정합
- REQ-120 (Approved): KIS balance 인증 실패 fail-loud 및 broker/token lifecycle 정책
- REQ-121 (Approved): managed non-holding divergence 분류와 slot 존재 조회 계약
- REQ-122 (Approved): operator broker truth HOTFIX baseline 및 remediation 문서 정합
## Delivery Plan
1. 공통 broker provider/factory와 daemon·`sync`·`verify`의 fetched credential source parity 경계를 설계한다. fetched mode fetch 실패 시 silent env/config fallback을 금지하고, env-only mode 회귀를 보존한다.
2. KIS balance failure class를 actual empty balance와 분리하고, operator broker-read의 non-zero/no-write semantics 및 token/broker lifecycle ownership·reuse·retry 정책을 evidence 기반으로 확정한다.
3. reconciliation의 unmatched broker symbol에 한해 instance-scoped managed-slot existence query를 추가하고, 기존 holding match/mismatch와 true unmanaged semantics를 보존하면서 additive divergence code를 도입한다.
4. REQ-119~121 구현 및 independent QC evidence가 준비된 후 REQ-122의 runtime/interface/operations baseline을 실제 계약과 대조해 갱신한다.
5. unit/integration/CLI regression과 secret-redaction 검증을 구현 lane 및 독립 QC lane에서 각각 수행한다. production command 또는 `sync --live`는 실행하지 않는다.
## Dependencies
- Internal:
- 기존 REQ-112 credential read port, REQ-113 source priority/break-glass, REQ-115 day-start credential resilience
- `trex_core/runtime/daemon.py`, `trex_core/runtime/cli.py`, `trex_core/adapters/broker/kis.py`, `trex_core/application/reconciliation.py`
- SlotRepository protocol 및 PostgreSQL/InMemory 구현
- 기존 sync/verify gate와 broker/ledger/slot read semantics
- External:
- portfolio-hub fetched credential authority는 기존 계약을 사용한다. 계약 변경이 필요하면 본 batch 범위를 중단하고 cross-service gate로 전환한다.
## Design Gate
- Design Required Before Implementation: yes
- Reason: 공통 provider 경계, KIS token/broker lifecycle ownership·reuse·retry 정책, failure type/no-write semantics, repository query 계약 및 interface/runtime 영향이 구현 전에 확정되어야 한다. 특히 REQ-120 OQ-2는 process-local/shared cache/other provider 중 방식을 근거 없이 선결정할 수 없다.
## Milestones
1. Batch 문서 및 planning 확정: 승인된 REQ-119~122와 dcy-037 scope traceability를 고정한다.
2. Design gate: provider/lifecycle/failure/reconciliation 계약과 cross-service·security 영향 확인.
3. Feature worktree implementation: REQ-119~121 코드·회귀 테스트 구현, REQ-122는 evidence 이후 문서 반영.
4. Independent QC: fetched source parity, fail-loud/no-write, true-empty 분리, 4-case divergence matrix, repository parity, instance isolation, secret scan을 재실행.
5. Release gate: 배포 전 dry-run 및 broker/ledger/slot cross-check 후 lead/사장님 명시 승인 없이는 `sync --live`를 실행하지 않는다.
## Risks
- R1 High: fetched credential source/account identity mismatch가 다른 계좌 truth로 이어질 수 있음. masked source identity parity와 dry-run evidence로 완화한다.
- R2 High: auth/token/transport failure가 false zero로 변환되면 잘못된 state convergence가 발생함. typed/sanitized failure와 no-write assertion을 고정한다.
- R3 Medium: managed-slot existence query가 기존 holding mismatch/unmanaged semantics를 바꿀 수 있음. unmatched symbol에만 적용하고 4-case matrix를 유지한다.
- R4 Medium: 반복 fresh broker/token 생성이 throttle을 유발할 수 있음. lifecycle reuse 범위와 failure-class별 retry/no-retry를 design evidence로 결정한다.
- R5 High: hotfix 후 승인 없는 live sync가 외부 상태를 변경할 수 있음. `sync --live`를 별도 release approval gate로 유지하고 이번 batch에서 실행하지 않는다.
@@ -0,0 +1,40 @@
# BAT-057 Verification
- Status: confirmed (`confirm-batch-verification`, lead, 2026-07-15)
- Batch: BAT-057
- Delivery ref: PR #42 — `feat/dcy-037-operator-broker-truth-hotfix` (committed PR head at evidence drafting)
- Scope of this artifact: implementation and independent-QC evidence. `confirm-batch-verification` is recorded above; it does not mark any REQ Implemented or make a Release Ready claim.
## Evidence Boundary
- No live broker, PostgreSQL database, deployment, production target, credential, or `sync --live` execution was performed.
- The focused suite used an in-process `pika` stub. Its latest recorded result, including the REQ-121 fake-driver parity regression, is **69 passed, 1 skipped, 1 deselected**.
- `compileall`, changed-file `ruff`, `git diff --check`, and the documentation secret scan passed.
- The subprocess case `test_config_required_cli_exits_nonzero` remains blocked solely because `pika` is absent in the isolated worktree subprocess. The subprocess cannot inherit the in-process stub. This is an environment/dependency-setup blocker, not a recorded behavioral failure.
## Acceptance Mapping
| REQ / acceptance coverage | Draft implementation evidence | Remaining verification boundary |
| --- | --- | --- |
| REQ-119 — fetched provider parity, no silent fallback, break-glass/env-only compatibility, safe source identity | Focused provider/CLI regression coverage passed under the in-process `pika` stub; changed-file lint/diff and secret scan passed. | Independent QC must rerun the PR #42 evidence and assess the blocked subprocess case in an environment with `pika` available. No live broker evidence exists. |
| REQ-120 — fail-loud balance failures, nonzero/no-write barrier, true-empty separation, lifecycle/redaction | Focused adapter and CLI failure-path regression coverage passed under the stub; secret scan passed. | Independent QC must rerun failure/no-write and true-empty evidence. The unavailable subprocess prevents a complete isolated CLI-process confirmation. |
| REQ-121 — four-case divergence classification, instance isolation, no auto-remediation, repository parity | Focused reconciliation/InMemory regression coverage plus a fake-driver parity test passed: it executes the PostgreSQL adapter's managed-slot SQL/params and row mapping against the same fixture. | **Environmental coverage gap:** no live/local PostgreSQL fixture was executed. The fake-driver unit contract establishes adapter-query parity, not real database integration behavior. Independent QC must retain this integration gap or obtain an approved non-production PostgreSQL fixture. No live broker or database was used. |
| REQ-122 — runtime/interface/operations alignment, safe examples, release-gated remediation | Baseline documentation changes at the PR #42 tip were included in changed-file diff checks; documentation secret scan passed. | Independent QC must cross-check the rendered baseline text, actual CLI/help and decision-code behavior against the committed PR diff. This artifact does not authorize a production command or deployment. |
## Draft Validation Results
| Check | Result | Notes |
| --- | --- | --- |
| Focused suite | PASS — 69 passed, 1 skipped, 1 deselected | Includes the REQ-121 fake-driver parity regression; in-process `pika` stub used. |
| `compileall` | PASS | Recorded implementation-lane validation. |
| Changed-file `ruff` | PASS | Recorded implementation-lane validation. |
| `git diff --check` | PASS | Recorded implementation-lane validation. |
| Documentation secret scan | PASS | No credentials, tokens, raw account numbers, or raw credential payloads in scanned documentation. |
| `test_config_required_cli_exits_nonzero` subprocess | BLOCKED (environment) | Isolated worktree lacks `pika`; subprocess cannot inherit the in-process stub. |
| PostgreSQL repository parity/live database coverage | FAKE-DRIVER PARITY PASS; live database NOT EXECUTED | The injected fake psycopg driver executed the actual adapter query/params and row mapping against the InMemory fixture. No local/live PostgreSQL integration was run, so database-engine behavior remains an environmental coverage gap. |
## Confirmation and Next Gate
- Independent QC reran the exact PR #42 head and returned **PASS-WITH-NOTES** (acceptance blockers: 0). The note records that the fake-driver repository parity test is accepted AC evidence, while real PostgreSQL engine integration remains an explicitly documented environmental coverage gap.
- `confirm-batch-verification` is complete: the batch is verification-confirmed, but **REQ-119~122 remain not Implemented** until the approved PR is merged to `main` and that post-merge gate is recorded.
- The next action is PR approval and merge decision. `sync --live`, live broker/database access, deployment, and production remediation remain outside this batch verification and require their separate release approval gate.
+1
View File
@@ -62,3 +62,4 @@
| BAT-052 | standard | release-candidate | REQ-109, REQ-110, REQ-111 | dcy-035 | [bat-052](./bat-052_20260626_wac-realized-pnl-report/) |
| BAT-053 | standard | release-candidate | REQ-112, REQ-113, REQ-114, REQ-115 | dcy-036 | [bat-053](./bat-053_20260628_daystart-credential-fetch-failsafe/) |
| BAT-054 | standard | in-progress | REQ-113 (AC-2/AC-4 보강), REQ-115 (AC-1) | dcy-036 | [bat-054](./bat-054_20260628_daystart-credential-kis-inject/) |
| BAT-057 | standard | in-delivery | REQ-119, REQ-120, REQ-121, REQ-122 | dcy-037 | [bat-057](./bat-057_20260714_operator-broker-truth-hotfix/) |
@@ -5,9 +5,9 @@
- 문서 ID: dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart
- 이슈명: 슬롯↔실계좌 차단형 reconcile + sync/verify 명령 — 중장기 cold-start (기존 보유 포지션 onboarding)
- 작성 시각(KST): 2026-06-25 (KST)
- 마지막 갱신 시각(KST): 2026-06-25 (KST, confirm-discovery — OQ-1~5 전부 Answered)
- 마지막 갱신 시각(KST): 2026-07-14 (KST, dcy-037 HOTFIX follow-up link)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 후속 Discovery 참조: `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md` (REQ-105/106 operator broker truth 및 managed non-holding divergence 운영 환류)
- 생성된 REQ 참조: REQ-102 ~ REQ-108 (draft-req 2026-06-25, Proposed — confirm-req 대기)
# 1. 계획 상태 요약
@@ -5,9 +5,9 @@
- 문서 ID: dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load
- 이슈명: 그날-시작 KIS 인증정보 fetch(portfolio-hub) + fail-safe 거래 로드 — 진실원천 부재 시 신규 거래 미개시, 장중 무중단 (교차 발원 portfolio-hub dcy-010)
- 작성 시각(KST): 2026-06-28
- 마지막 갱신 시각(KST): 2026-06-28 (draft-req)
- 마지막 갱신 시각(KST): 2026-07-14 (KST, dcy-037 HOTFIX follow-up link)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 후속 Discovery 참조: `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md` (fetched credential operator broker truth/token lifecycle 운영 환류)
- 생성된 REQ 참조: REQ-112, REQ-113, REQ-114, REQ-115
# 1. 계획 상태 요약
@@ -0,0 +1,197 @@
# 0. 문서 상태
- 상태: confirmed
- 문서 ID: dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix
- 이슈명: HOTFIX — fetched credential 운영자 broker truth 정합 및 managed non-holding divergence 분리 (#39, #40, #41)
- 작성 시각(KST): 2026-07-14 21:52:01 KST
- 마지막 갱신 시각(KST): 2026-07-14 22:24:41 KST (draft-req)
- 대체됨: 없음
- 후속 Discovery 참조: 없음
- 생성된 REQ 참조: REQ-119, REQ-120, REQ-121, REQ-122
# 1. 계획 상태 요약
- 연계 문서 현황:
- 관련 점수 파일: 미생성
- 관련 TODO/메모: GitHub `fourblocks2020/trex-core` issue [#39](https://github.com/fourblocks2020/trex-core/issues/39), [#40](https://github.com/fourblocks2020/trex-core/issues/40), [#41](https://github.com/fourblocks2020/trex-core/issues/41). 선행 Discovery `dcy-034`(차단형 reconcile/sync/verify) 및 `dcy-036`(fetched credential day-start fail-safe).
- 해석:
- `credential_fetch_enabled=true`인 `genichin-longterm`에서 daemon, operator CLI, broker token lifecycle이 같은 계좌 잔고 truth를 보장하지 못하여 `sync`/`verify` 결과를 운영 복구 판단에 사용할 수 없다.
- 이 HOTFIX는 잘못된 broker truth를 빈 잔고로 해석하는 경로를 차단하고, 이미 관리되는 inactive/error 슬롯을 진짜 unmanaged position과 구분해 안전한 운영자 triage와 이후 sync를 가능하게 한다.
# 2. 요구사항 판정 결과
- 판정: 신규
- 근거
- `dcy-034`/REQ-105·106은 `sync`/`verify`의 상태 동기화 의미론을 정의했지만, fetched credential 모드에서 CLI가 daemon과 동일한 credential source 및 token lifecycle을 사용해야 한다는 운영 불변식은 정의하지 않았다.
- `dcy-036`/REQ-113·115는 day-start fetched credential과 fail-safe entry gate를 다루지만, direct-DB operator CLI의 credential 재해석·token throttle 및 inactive 슬롯의 reconcile 분류는 후속 운영 환류로 독립 추적하는 편이 명확하다.
# 3. 문제점의 요약
- 현재 상태
- issue #39 재현에서 daemon은 portfolio-hub fetched credential로 계좌 A를 사용하지만, `sync`/`verify`는 `_build_broker(cfg)`로 env/config 계좌 B를 생성했다. 동일 instance의 `005930`은 daemon broker truth에서 qty=5이나 CLI dry-run은 `broker_qty_zero`로 skip했다. 계좌 식별자는 문서·로그에서 안전하게 마스킹한다.
- issue #40의 `KISBroker.get_balance()`는 token/auth HTTP 오류를 잡아 `[]`로 반환한다. fresh fetched-credential broker가 KIS token throttle(`EGW00133`)을 받으면 실제 보유가 없는 것처럼 보이며, `broker_qty_zero` 같은 상태 판단으로 이어질 수 있다.
- issue #41의 `reconcile_core_holdings()`는 `list_holding_slots(instance_key)`만 대조한다. 따라서 broker가 보유한 symbol에 대해 같은 instance의 managed slot이 `inactive` 또는 `error`로 존재해도 `unmanaged position`으로 로그를 남긴다.
- 기대 상태
- fetched credential 모드에서 daemon과 `sync`/`verify`는 동일한 portfolio-hub credential source와 명시적인 token/broker lifecycle 정책으로 broker truth를 조회하며, 인증·token 실패는 empty balance와 절대 동치가 아니다.
- reconciliation은 `slot 없음`의 true unmanaged position과 `slot 존재 + non-holding`의 managed state divergence를 별도 code/log/운영 조치로 구분한다.
- 이해관계자
- 사장님: hotfix 범위·production 배포 및 실계좌 `sync --live` 최종 승인자
- 팀장: Discovery/REQ/Batch 및 release gate 관리
- trex-core-dev: broker 구성, reconciliation, CLI 및 회귀 테스트 delivery 담당
- qc-reviewer: credential source, throttle fail-closed, divergence 분류의 독립 검증 담당
- 운영자: dry-run 검증 후 승인된 live sync를 수행하는 주체
- 제약사항
- app key/app secret/token 및 raw credential payload는 로그, JSON 출력, 테스트 fixture에 기록하지 않는다.
- `sync --live`는 외부 실계좌/DB 상태를 변경하므로 본 HOTFIX 배포 후에도 별도 운영 승인 없이는 실행하지 않는다.
- 기존 `credential_fetch_enabled=false` env-only instance와 AMQP wire 계약은 회귀 없이 유지한다.
## 주요 사용자 시나리오
- 운영자가 `genichin-longterm`의 inactive slot과 broker-held `005930`을 확인한다. `sync --dry-run`은 fetched credential source로 broker qty=5를 확인하고, token 오류가 나면 0 보유로 판단하지 않고 비0 종료와 안전한 오류를 반환한다.
- daemon reconcile이 `inactive` managed slot + broker qty>0을 발견하면 `managed_non_holding_divergence`로 구조화해 경고한다. slot이 전혀 없는 broker-held symbol만 기존 `unmanaged_position` 경로를 탄다.
- hotfix QC와 release 검증 후 운영자가 dry-run 결과, portfolio-hub 장부, broker truth를 교차 확인하고 명시 승인된 단일 대상에만 `sync --live --yes`를 실행한다.
# 4. 이번에 정의할 변경
- 변경 1 — daemon/CLI 공통 broker truth 해석: `credential_fetch_enabled=true`일 때 operator CLI도 portfolio-hub fetched credential을 사용하고, raw env/config credential은 명시적인 break-glass 정책 외에는 조용히 fallback하지 않는다.
- 변경 2 — token/auth 실패 fail-loud: `get_balance()`와 operator 표면에서 token issuance/auth/transport 실패를 actual empty holdings와 분리하고, 반복 조회에 대한 broker/token 재사용 정책을 명확히 한다.
- 변경 3 — managed non-holding divergence 분류: holding-only 수량 대조는 보존하되, 미매칭 broker symbol에 대해 전체 slot 존재를 확인해 `unmanaged_position`과 `managed_non_holding_divergence`를 구분한다.
- 변경 4 — baseline/운영 문서 정합: credential source, error semantics, reconcile decision code, dry-run 및 live remediation gate를 `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/operations.md`에 실제 구현과 일치하게 반영한다.
## 영향 범위
- 코드 영향:
- `trex_core/runtime/daemon.py`, `trex_core/runtime/cli.py`, `trex_core/application/snapshot_scheduler.py` 또는 공통 broker factory: fetched credential source 및 CLI broker 생성 경로.
- `trex_core/adapters/broker/kis.py`: token/auth failure와 actual empty balance의 구분, KIS token lifecycle.
- `trex_core/application/reconciliation.py`, SlotRepository protocol 및 PostgreSQL/InMemory 구현: non-holding managed-slot existence check와 구조화 classification.
- 문서 영향:
- `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/operations.md`, 신규 REQ/Batch/verification 문서.
- `dcy-034`, `dcy-036`: 본 HOTFIX follow-up 관계 기록.
- 운영 영향:
- 배포 전 dry-run에서 credential source·account reference masking·broker query 결과·분류 code를 확인한다.
- 배포 후 live sync는 single portfolio/slot 대상, 사전 dry-run 및 사장님 승인으로 제한한다.
# 5. 요구사항 목록
## 기능 요구사항
- FR-1: `credential_fetch_enabled=true` instance의 daemon, `sync`, `verify` 및 해당 operator broker-read 경로는 동일한 portfolio-hub fetched credential source를 해석해야 한다. fetch 실패 시 명시적 break-glass가 활성화되지 않은 한 env/config credential로 조용히 fallback하지 않는다.
- FR-2: token issuance, auth, HTTP transport 또는 malformed broker response 실패는 `get_balance()==[]`로 변환하지 않는다. operator CLI는 안전하게 sanitize된 오류와 비0 종료로 중단하며, `broker_qty_zero`/sync skip/verify 결과를 만들지 않는다.
- FR-3: repeated fetched-credential broker read는 구현된 token/broker lifecycle 정책에 따라 안정적으로 동작해야 하며, fresh object 생성으로 인한 token throttle을 실제 빈 계좌로 오판하지 않아야 한다.
- FR-4: `reconcile_core_holdings()`는 broker-held symbol이 holding slot에 매칭되지 않을 때 (a) instance에 어떤 slot도 없는 `unmanaged_position`, (b) slot은 있으나 `inactive`/`error` 등 non-holding인 `managed_non_holding_divergence`를 구분해 구조화 로그/decision code를 남겨야 한다.
- FR-5: `managed_non_holding_divergence`는 자동 slot 전이, 자동 warning 해제, 자동 매매 또는 자동 청산을 유발하지 않는다. 상태 수렴은 검증된 `sync`와 기존 운영 gate를 통해서만 수행한다.
- FR-6: runtime/interface/operations baseline은 credential source 우선순위, safe error semantics, reconciliation 분류 및 operator remediation 절차를 구현과 동기화해야 한다.
## 비기능 요구사항
- NFR-1 (안전성): credential, access token, app secret, raw account number 및 HTTP authorization payload는 로그·CLI JSON·테스트 artifact에 노출되지 않는다. account reference가 필요한 운영 식별은 마스킹 또는 비밀이 아닌 stable label을 사용한다.
- NFR-2 (회귀안전): `credential_fetch_enabled=false` env-only instance, 정상 holding match, holding qty mismatch, slot 없는 unmanaged position, 기존 `sync`/`verify` live double-ack 의미론은 보존한다.
- NFR-3 (관측성): source mode, safe failure code, reconciliation decision code, slot_id/portfolio_id/symbol/status가 구조화 로그로 추적 가능해야 한다.
- NFR-4 (검증): unit/integration/CLI regression에 #39·#40·#41 재현 조건과 existing reconciliation/sync/verify 경로를 포함하고, independent QC가 실제 suite를 재실행한다.
## 범위 경계
- In Scope
- #39 fetched credential source mismatch, #40 token throttle/empty balance masking, #41 managed non-holding divergence 분류 및 이들의 tests/docs.
- operator dry-run evidence 및 post-release single-target remediation runbook.
- Out of Scope
- portfolio-hub credential API 계약 또는 credential 저장 모델의 재설계; 필요한 계약 변화가 발견되면 cross-service gate로 중단한다.
- `sync --live`의 자동 실행, 전역 bulk sync, unmanaged broker position 자동 slot 생성, automatic remediation/trading.
- TVR 전략 로직 변경 또는 TVR 재배포. TVR은 core truth/slot 상태가 정합된 뒤 기존 `inactive,holding` read 의미론으로 재평가한다.
# 6. 리스크/가정 목록
## 리스크
- R-1 (High): credential source 또는 account identity를 잘못 연결하면 다른 실계좌의 broker truth를 기준으로 sync 판단을 할 수 있다.
- 완화 방안: fetched mode CLI source를 daemon과 공통화하고, dry-run에서 비밀 없는 source/account fingerprint 일치 assertion을 테스트한다. live sync는 사전 dry-run과 명시적 승인 없이는 실행하지 않는다.
- R-2 (High): auth/token 오류를 empty balance로 취급하면 false zero 기반의 잘못된 상태 수렴이 발생할 수 있다.
- 완화 방안: typed/sanitized failure를 도입하고 balance가 성공적으로 조회된 경우에만 qty=0 판단을 허용한다. throttling 재현 테스트를 추가한다.
- R-3 (Medium): non-holding slot existence check가 holding 수량 mismatch 또는 true unmanaged warning 의미론을 바꿀 수 있다.
- 완화 방안: primary holding compare를 유지한 뒤 unmatched broker symbols에만 second-pass existence lookup을 적용하고, 4-case regression matrix를 고정한다.
- R-4 (Medium): HOTFIX 배포 직후 운영자가 빠르게 live sync를 실행하면서 장부/브로커/slot 증거 교차 확인을 건너뛸 수 있다.
- 완화 방안: release runbook에 dry-run → broker/ledger cross-check → single-target explicit approval → live sync → re-reconcile 순서를 필수 gate로 기록한다.
## 가정
- A-1: portfolio-hub fetched credential path는 daemon의 현재 정상 운행에서 authority source로 사용 가능하며, service-token 접근 계약은 변경하지 않는다.
- A-2: `sync`는 REQ-105의 기존 원칙대로 managed slot만 브로커 truth에 수렴시키며, slot 없는 broker holdings를 자동 편입하지 않는다.
- A-3: `genichin-longterm`의 `005930` 사례는 generic regression fixture로 추상화할 수 있고, 실제 계좌 식별값/credential은 test data에 포함하지 않는다.
## 오픈 질문
- OQ-1: #39, #40, #41을 하나의 emergency delivery batch로 묶어 single release candidate로 배포할지, credential hotfix(#39/#40)와 telemetry 분류(#41)를 분리할지?
- 상태: Answered (2026-07-14, 사장님)
- 결정: #39·#40·#41을 하나의 emergency batch로 확정한다. #41은 자동 state write가 없는 additive classification으로 유지해 rollback blast radius를 제한한다.
- 종료 근거: 사장님이 2026-07-14에 단일 emergency batch를 명시 승인했다.
- OQ-2: operator CLI의 fetched credential broker/token lifecycle을 process-local reuse, shared token cache, 또는 다른 공통 provider 중 무엇으로 고정할지?
- 상태: Deferred to REQ
- 처리 방안: delivery-runner/trex-core-dev가 current KISBroker token cache·daemon reinitialization·CLI process lifetime을 분석해 secret-safe alternatives와 retry/throttle behavior를 REQ draft에 제시한다.
- 인계 조건: REQ가 token lifecycle ownership, error type, retry/no-retry policy 및 regression test seam을 명시한다. 이는 구현 설계 선택이며 Discovery 범위·성공 기준을 바꾸지 않는다.
- OQ-3: hotfix release 뒤 `genichin-longterm`의 live sync 실행 대상을 `005930` 단일 slot으로 확정할지?
- 상태: Deferred to release gate
- 처리 방안: hotfix deployment와 dry-run 결과 후 사장님이 broker/ledger/slot cross-check evidence를 보고 승인한다.
- 인계 조건: release approval에 target portfolio/slot, executed command class, before/after evidence, rollback/hold plan이 기록된다. 본 Discovery는 live sync 실행을 승인하지 않는다.
# 7. 초기 성공 기준
- S-1: fetched credential enabled fixture에서 daemon-equivalent path와 `sync --dry-run`/`verify`가 동일한 masked source identity와 동일 broker holding set을 사용한다.
- S-2: KIS token throttle/auth failure fixture에서 CLI가 non-zero failure와 sanitized code를 반환하고, empty balance/`broker_qty_zero`/state write를 만들지 않는다.
- S-3: 4-case reconciliation regression이 통과한다: slot 없음→unmanaged, inactive slot+broker holding→managed_non_holding_divergence, matching holding→ok, holding qty mismatch→기존 mismatch path.
- S-4: 실제 deployed hotfix에서 approved dry-run이 `genichin-longterm` 대상 broker truth와 divergence classification을 확인하며, live sync 전에는 DB 상태가 변경되지 않는다.
## 측정 방식 및 데이터 출처
- S-1 측정 방식: daemon/CLI 공통 provider와 mocked fetched credential/broker fixture의 account fingerprint·balance 결과 비교 테스트 / 데이터 출처: 신규 runtime CLI 및 broker factory tests.
- S-2 측정 방식: token issuance error를 주입한 `get_balance`/CLI test에서 exit code, sanitized error, no-write assertion 확인 / 데이터 출처: KIS adapter 및 CLI tests.
- S-3 측정 방식: `reconcile_core_holdings` application tests와 PostgreSQL/InMemory repository parity tests 실행 / 데이터 출처: `tests/application/test_holding_divergence.py` 및 추가 regression tests.
- S-4 측정 방식: release runbook에 따른 production dry-run command output, broker/ledger/slot readback, audit logs 확인 / 데이터 출처: deployment evidence 및 운영자 기록. 실계좌 write는 승인 후 별도 증거로만 수행한다.
# 8. REQ로 넘기기 전 확인 체크
- [x] 범위(In Scope/Out of Scope) 확정
- 근거: 사장님이 2026-07-14 #39/#40/#41 단일 emergency batch를 승인했고, cross-service 계약 변경은 발견 시 별도 gate로 중단한다.
- [x] 성공 지표의 측정 방식/데이터 출처 확정
- 근거: S-1~S-4에 test seam, safe production dry-run evidence, independent QC 재실행 요구가 명시되었다. runtime 계측값은 REQ/release evidence로 수집한다.
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
- 근거: R-1/R-2 delivery owner=delivery-runner/trex-core-dev, independent verifier=qc-reviewer, live operation approver=사장님으로 고정한다.
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
- 근거: OQ-1 Answered, OQ-2는 REQ 설계로 Deferred, OQ-3은 release approval gate로 Deferred 상태다.
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
- 근거: 사장님(genichin)이 2026-07-14 HOTFIX scope를 confirm-discovery에서 승인했다. live sync 실행은 별도 release approval 대상이다.
# 9. 파일 처리 결과
- 처리 결과: 생성
- 생성 경로: `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md`
- 참조 문서:
- `docs/discovery/dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart.md`
- `docs/discovery/dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load.md`
- GitHub issues #39, #40, #41; `trex_core/runtime/daemon.py`, `trex_core/runtime/cli.py`, `trex_core/adapters/broker/kis.py`, `trex_core/application/reconciliation.py`
- `.stage-pilot/templates/runtime-flows.md`, `.stage-pilot/templates/project-structure.md`
# 10. 사용자 결정 필요 항목 요약
## 확정된 결정
- HOTFIX delivery boundary: #39·#40·#41을 하나의 emergency batch로 구현·검증해 single release candidate로 관리한다 (2026-07-14, 사장님 승인).
- Production safety gate: hotfix 배포 뒤 `sync --live`는 dry-run 및 broker/ledger/slot cross-check evidence가 준비된 경우에도 사장님 명시 release approval이 있어야만 실행한다.
- Post-deploy evidence: target instance의 masked credential source, broker holdings, slot 상태, portfolio-hub ledger readback은 REQ verification 및 release gate에서 수집한다. 현재는 구현 전이므로 실제 운영 데이터는 없다.
# 11. Discovery Freeze
## 섹션 참조
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
## Freeze 플래그
- Handoff Decision: 승인
- Handoff Rationale: 사장님이 #39/#40/#41 단일 emergency batch scope를 승인했다. OQ-2는 REQ에서 구현 설계로 구체화하며, OQ-3은 배포 후 별도 release approval gate이므로 REQ drafting을 막지 않는다.
- Ready for REQ Drafting: true
- Confirmed By: genichin
- Confirmed At (KST): 2026-07-14 22:19:29 KST
+1
View File
@@ -41,3 +41,4 @@
| dcy-033_20260621_suite-alarm-delivery-notifier | 20260621 | 제품군 알람 전달 체계 — 모듈 발신 + 중앙 notifier(trex-notifier) → 텔레그램(P1 아웃바운드). envelope 계약+전송(trex.alerts AMQP 확정)+등급/상관억제/하트비트 | [Discovery](./dcy-033_20260621_suite-alarm-delivery-notifier.md) | confirmed |
| dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart | 20260625 | 슬롯↔실계좌 차단형 reconcile + sync/verify 명령 — 중장기 cold-start(기존 보유 onboarding). B/C 불일치→슬롯 차단(durable warning) + pause∨warning 게이트 + 운영자 sync/verify (M3) | [Discovery](./dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart.md) | confirmed |
| dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load | 20260628 | 그날-시작 KIS 인증정보 fetch(portfolio-hub) + fail-safe 거래 로드 — 진실원천 부재 시 신규 거래 미개시, 장중 무중단 (교차 발원 portfolio-hub dcy-010) | [Discovery](./dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load.md) | confirmed |
| dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix | 20260714 | HOTFIX — fetched credential 운영자 broker truth 정합 및 managed non-holding divergence 분리 (#39, #40, #41) | [Discovery](./dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md) | confirmed |
+9 -2
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-21 (BAT-049 — dcy-033 알람 채널 trex.alerts 추가)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002
- Last Updated (KST): 2026-07-15 (BAT-057 — operator broker truth hotfix baseline alignment)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002; operator broker truth = dcy-037 / BAT-057
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
## Project Summary
@@ -420,6 +420,13 @@ resume_kill_switch 요청·응답 (REQ-046):
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
### Balance-read failure and reconciliation boundary (BAT-057 / REQ-120/121)
- `get_balance()`는 검증된 `list[BalanceItem]`만 반환한다. KIS의 token/auth/authorization/transport/response/rejection 실패는 빈 목록으로 축소되지 않고 `BrokerBalanceError`를 raise한다. 안정 code allow-list는 `token_issuance_failed`, `authentication_failed`, `authorization_failed`, `transport_failed`, `malformed_response`, `broker_rejected`다.
- `sync`와 `verify` CLI는 첫 balance read를 완료한 뒤 cache한 결과를 해당 invocation에 전달한다. 이 read 또는 broker credential resolution이 실패하면 exit code `1`이며, `--json` stderr는 `{ "error": {"code": "…", "message": "broker balance unavailable"} }` 형태의 안전한 값만 낸다. upstream payload, credential, account 값은 계약상 출력하지 않는다.
- `reconcile_core_holdings()`의 broker-only 결과는 `unmanaged_position`(대상 instance의 managed slot 없음) 또는 `managed_non_holding_divergence`(동일 instance의 non-holding managed slot 존재)다. `find_managed_slot_by_symbol(instance_key, symbol)` 조회는 instance-scoped이며, 이 두 분류 자체는 slot/warning write나 자동 주문·청산·slot 생성을 하지 않는다.
- 이 변경은 AMQP 또는 portfolio-hub external wire contract와 DB schema를 바꾸지 않는다.
### cancel_order
```
+7
View File
@@ -61,6 +61,13 @@ pkill -TERM -f trex-core-daemon
- enter/exit 안전장치: 확인 프롬프트 기본 ON, `--yes`(paper 생략), live(`broker=kis`)는 `--yes --live` 이중 ack, `--dry-run` 미리보기. `--json`은 비대화형이라 enter/exit에 `--yes` 필수.
- 인스턴스 라우팅: `--config <toml>` 또는 `--instance <key>`, 미지정 시 `default`.
### `sync` / `verify` broker truth 확인과 release gate (BAT-057)
- `sync`와 `verify`는 broker balance를 먼저 한 번 읽어 해당 실행 안에서 재사용한다. balance/credential read 실패는 안전한 error code와 함께 exit `1`로 종료하며, 빈 broker balance를 추정하지 않는다. 성공한 빈 목록만 flat balance로 취급한다.
- source evidence에는 mode(`fetched`/`env`/`break_glass`)와 비밀 없는 label만 기록한다. fetched mode는 KIS + `credential_fetch_enabled=true`에서만 사용하고, break-glass는 명시적 운영자 환경 설정으로 fetch를 건너뛴다. Paper와 env KIS 경로는 fetch하지 않는다.
- reconciliation에서 `unmanaged_position` 또는 `managed_non_holding_divergence`가 보이면 자동 보정·자동 주문·자동 청산이 없다. broker, ledger, slot evidence를 교차 확인하고 warning/slot 상태를 보존한 채 triage한다.
- remediation은 **dry-run → broker/ledger/slot 교차 확인 → 사장님 명시 승인 → 단일 대상 `sync --live --yes` → 재-reconcile** 순서의 별도 release gate다. 이 문서는 실제 target 선택, live 실행 또는 승인을 부여하지 않는다.
### `report` — 일일 운영 리포트 (REQ-088 / dcy-029, REQ-110/111 / dcy-035)
```bash
.venv/bin/trex-core report # 사람용 표
+1
View File
@@ -92,6 +92,7 @@
│ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
│ ├── config.py # DaemonConfig(+instance_key, max_active_slots) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013/028/030)
│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown. _execution_loop 각 틱 시작 시 event_publisher.keepalive_tick() 호출(REQ-092). _make_event_emitter 발행 최종 실패 시 ERROR 로그(REQ-093).
│ ├── broker_provider.py # daemon/CLI 공용 broker source resolution: KIS fetched/env/break-glass와 day-scoped fetched credential 재사용 (BAT-057 / REQ-119)
│ ├── reset.py # trex-core-reset: slot-core 테이블 wipe + 자산 중심 재초기화 (REQ-064, BAT-030)
│ ├── logging_setup.py # 구조화 로깅(ISO8601 KST·레벨·Loki JSON·correlation id) + configure_logging (BAT-045 / REQ-084, dcy-029)
│ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit/cancel + report(REQ-088) (BAT-008/020/046 / REQ-032)
+9 -3
View File
@@ -4,8 +4,8 @@
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-21 (BAT-049 — dcy-033 알람 발신 흐름 추가)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
- Last Updated (KST): 2026-07-15 (BAT-057 — operator broker truth hotfix baseline alignment)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002; operator broker truth = dcy-037 / BAT-057
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
## Project Summary
@@ -98,7 +98,13 @@
1. 신규 자산(portfolio_id 에 슬롯 없음) → `inactive` 영구 슬롯 생성(멱등 — `ON CONFLICT (instance_key, portfolio_id)` partial unique).
2. universe 이탈 + inactive → soft-retire(`retired_at` set; 행 보존, 운영 뷰 `universe_detached`). 이탈 + holding/pending → tombstone(그대로 유지 — safety-sell 경로 보존).
3. 가드: universe 빈 응답이면 reconcile skip(degrade 가드); 직전 대비 ≥50% 축소면 retire 보류(`allow_retire=False`, 대량 소실 가드 — dcy-020 OQ-2 D3).
4. 이어서 `reconcile_core_holdings`(REQ-062): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유/슬롯 없음 → WARN(unmanaged). 자동 청산 없음.
4. 이어서 `reconcile_core_holdings`(REQ-062/121): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유인데 대상 instance에 관리 slot이 없으면 `unmanaged_position`, 동일 instance에 `inactive`/`error` 등 non-holding 관리 slot이 있으면 `managed_non_holding_divergence`를 WARN으로 남긴다. 두 broker-only 분류는 상태·warning persistence·주문·청산·slot 생성의 자동 수정을 하지 않는다.
### Broker provider and operator balance-read barrier (BAT-057 / REQ-119/120)
1. daemon과 `sync`/`verify` CLI는 같은 broker provider를 통해 브로커를 얻는다. `broker=kis` 및 `credential_fetch_enabled=true`일 때만 portfolio-hub의 유효한 인증정보를 fetched source로 사용한다. daemon의 day-start refresh는 이미 같은 KST 날짜에 얻은 source가 있으면 이를 재사용한다.
2. 명시적 `TREX_KIS_BROKER_BREAK_GLASS`는 fetched source를 건너뛰고 env KIS 설정을 사용하는 별도 source다. Paper와 `credential_fetch_enabled=false` KIS 경로는 env/config source이며 credential fetch를 하지 않는다.
3. `sync`/`verify`는 실행당 잔고를 한 번 먼저 검증해 cache한 뒤 그 값만 사용한다. KIS 잔고 읽기 실패는 빈 잔고로 해석하거나 후속 DB/slot write로 진행하지 않고 command boundary에서 안전하게 실패한다. 성공한 빈 잔고(`[]`)만 검증된 flat balance다.
## Flow: `포지션 정합 검증` (BAT-008 / REQ-031)
@@ -0,0 +1,118 @@
# REQ-121: managed non-holding divergence 분류와 slot 존재 조회 계약
- Status: Approved
- Type: Data
- Priority: High
- Owner: trex-core-dev
## Intent
- broker-held symbol을 holding slot만으로 비교하면 동일 instance에 `inactive` 또는 `error` managed slot이 존재하는 경우도 true unmanaged position으로 잘못 분류된다.
- holding 수량 대조의 기존 의미론을 보존하면서, unmatched broker holding의 managed slot 존재 여부를 repository parity로 확인해 안전한 triage classification을 제공한다.
## Requirement
- 시스템은 `reconcile_core_holdings()`에서 broker-held symbol이 holding slot에 매칭되지 않을 때 동일 instance의 관리 slot 존재 여부를 조회해야 한다.
- slot이 전혀 없으면 기존 `unmanaged_position` 분류를 유지하고, slot은 존재하지만 non-holding 상태이면 `managed_non_holding_divergence`로 별도 분류해야 한다.
- 이 분류는 slot 상태 전이, warning 자동 해제, 자동 매매, 자동 청산 또는 unmanaged holding의 자동 slot 생성을 유발하지 않아야 하며, 상태 수렴은 기존 승인 gate의 검증된 `sync` 경로에만 맡겨야 한다.
- SlotRepository Protocol과 PostgreSQL/InMemory 구현은 instance-scoped managed-slot existence query에 대해 동등한 결과를 제공해야 한다.
## Acceptance Criteria
1. Given broker-held symbol에 대해 대상 instance에 어떤 slot도 없음, When `reconcile_core_holdings()`를 실행하면, Then `unmanaged_position` decision code와 기존 safe warning semantics가 유지된다.
2. Given broker-held symbol에 대해 같은 instance의 slot이 `inactive` 또는 `error`이고 holding slot은 없음, When `reconcile_core_holdings()`를 실행하면, Then `managed_non_holding_divergence` decision code와 `slot_id`, `portfolio_id`, `symbol`, `status`, `instance_key`의 구조화·비밀 없는 triage context가 남는다.
3. Given matching holding slot 또는 holding qty mismatch, When `reconcile_core_holdings()`를 실행하면, Then 각각 기존 match 및 mismatch path가 유지되고 non-holding divergence로 재분류되지 않는다.
4. Given AC-1~3 fixture, When reconciliation 후 slot 및 warning persistence를 검사하면, Then managed non-holding 분류만으로 slot state write, warning 자동 해제, 자동 매매, 자동 청산 또는 unmanaged slot 생성이 발생하지 않는다.
5. Given PostgreSQL 및 InMemory SlotRepository, When 같은 instance/symbol/portfolio fixture로 managed-slot existence query를 실행하면, Then 두 구현이 같은 존재 여부와 instance isolation 결과를 반환한다.
6. Given 다른 instance에만 같은 symbol 또는 portfolio_id slot이 존재함, When reconciliation을 실행하면, Then 그것은 대상 instance의 managed slot으로 취급되지 않는다.
## Impacted Area
- Docs: `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/operations.md`, `docs/srs/Integration/req-103_blocking-reconcile-b-c-cases.md`, `docs/srs/Interface/req-105_sync-cli-command.md`
- Modules: `trex_core/application/reconciliation.py`, `trex_core/domain/slot/repository.py`, `trex_core/adapters/persistence/postgresql.py`, `trex_core/domain/slot/in_memory_repository.py`
- Tests: reconciliation 4-case regression matrix; PostgreSQL/InMemory repository parity; instance isolation; no-auto-write/no-auto-remediation assertions
## Notes
- Source Discovery: `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md` (FR-4~5, NFR-2~4; GitHub #41).
- Existing `list_holding_slots(instance_key)` is holding-only evidence; this REQ adds the separate managed-slot existence decision needed only after an unmatched broker holding.
- This is additive classification. `sync --live` target selection and execution remain release-gated and out of scope.
## Change Log
### CHG-20260714-01
- Date: 2026-07-14
- Author: delivery-runner
#### Change Summary
- dcy-037 #41의 managed non-holding divergence와 repository query parity를 Proposed REQ로 초안화했다.
#### Intent
- 진짜 unmanaged broker holding과 이미 관리되는 non-holding slot divergence를 혼동하지 않도록 한다.
#### Acceptance Criteria Delta
- Added:
- 4-case reconciliation matrix, repository parity/instance isolation, additive no-auto-remediation constraints.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- reconcile decision 및 operator triage baseline.
- Modules:
- reconciliation 및 slot repository protocol/implementations.
- Tests:
- application classification 및 DB/in-memory parity regression.
#### Delivery Trace Delta
- Batches:
- none (draft only; dcy-037 emergency scope의 향후 입력).
- Releases:
- none (production read/write 또는 live sync 실행 없음).
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 전 decision-code naming과 existing warning/alert inventory compatibility를 검토한다.
#### Validation Plan
- isolated four-case fixture와 PostgreSQL/InMemory parity suite에서 classification, structured context, instance scope 및 no-write invariant를 검증한다.
### CHG-20260714-02
- Date: 2026-07-14
- Author: Justin (TREX lead)
#### Change Summary
- Proposed에서 Approved로 승인했다.
#### Intent
- dcy-037 #41의 additive classification을 기존 holding mismatch와 unmanaged semantics를 보존하는 delivery input으로 확정한다.
#### Acceptance Criteria Delta
- Added:
- 없음.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- 없음.
- Modules:
- 없음.
- Tests:
- 없음.
#### Delivery Trace Delta
- Batches:
- dcy-037의 #39/#40/#41 단일 emergency batch (아직 미생성).
- Releases:
- none; `sync --live`는 별도 release approval gate 유지.
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs에서 REQ-119/120과 함께 emergency batch로 편성한다.
#### Validation Plan
- batch verification과 독립 QC에서 4-case matrix, repository parity, instance isolation 및 no-auto-remediation AC를 재검증한다.
@@ -0,0 +1,116 @@
# REQ-122: operator broker truth HOTFIX baseline 및 remediation 문서 정합
- Status: Approved
- Type: Documentation
- Priority: High
- Owner: trex-core-dev
## Intent
- fetched credential source, balance failure semantics 및 managed/non-managed divergence의 의미가 runtime, interface, operations baseline에 없으면 운영자가 dry-run 결과를 잘못 해석하거나 승인 없는 live sync를 실행할 수 있다.
- 구현된 계약을 기준으로 operator triage와 release gate를 문서화하되, production live sync 실행 자체는 이 REQ에 포함하지 않는다.
## Requirement
- 시스템 문서는 구현 완료 후 daemon/operator fetched credential source parity, sanitized broker-read failure semantics, token/broker lifecycle 운영 제약, reconciliation decision code 및 managed non-holding triage 절차를 실제 구현과 일치하게 기술해야 한다.
- 운영 문서는 dry-run → broker/ledger/slot cross-check → 사장님 명시 approval → single-target `sync --live --yes` → re-reconcile 순서를 release gate로 명시하고, live sync 실행 명령이나 특정 production target을 승인·자동화하지 않아야 한다.
- 문서의 예시, 로그 키 및 fixture 설명은 credential, token, app secret, raw account number 및 raw credential payload를 포함하지 않아야 한다.
## Acceptance Criteria
1. Given REQ-119~121 구현과 검증 evidence, When `docs/runtime-flows.md`를 갱신하면, Then daemon/operator broker-read 공통 provider, failure가 empty balance가 아님, reconciliation의 `unmanaged_position`/`managed_non_holding_divergence` 분기와 no-auto-remediation 경계가 구현과 일치한다.
2. Given REQ-119~121 구현과 검증 evidence, When `docs/interface-contract.md`를 갱신하면, Then operator CLI의 broker-read failure exit semantics, safe observable fields 및 repository/reconciliation 계약 변화가 구현과 일치하고 외부 wire 계약의 변경 여부가 명시된다.
3. Given 운영자 remediation 문서, When `docs/operations.md`를 갱신하면, Then dry-run evidence, source/account reference masking, failure hold, broker/ledger/slot cross-check 및 explicit release approval gate가 순서대로 제시되고 승인 없는 `sync --live` 실행을 지시하지 않는다.
4. Given 문서 예시와 로그/JSON 샘플, When secret scan 또는 리뷰를 수행하면, Then token, authorization header, app key/app secret, raw credential payload 및 raw account number가 없다.
5. Given 문서의 명령/decision code/reference, When implementation tests 및 CLI help와 대조하면, Then 존재하지 않는 명령·code·source mode를 사실처럼 기술하지 않고, unresolved lifecycle implementation choice는 검증된 정책으로 확정된 뒤에만 구체화한다.
## Impacted Area
- Docs: `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/operations.md`, `docs/project-structure.md`, `docs/srs/index.md`, REQ-119~121
- Modules: none (documentation-only requirement; implementation facts are supplied by REQ-119~121)
- Tests: markdown link/reference check; documented CLI/help and decision-code review; secret scan; implementation-evidence cross-check
## Notes
- Source Discovery: `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md` (FR-6, S-4; GitHub #39/#40/#41).
- OQ-2 lifecycle mechanism is documented only after an evidence-backed implementation decision; this REQ does not mandate process-local reuse, shared cache, or another mechanism.
- OQ-3 production live sync target is a release approval record, not a documentation authorization or execution task.
## Change Log
### CHG-20260714-01
- Date: 2026-07-14
- Author: delivery-runner
#### Change Summary
- dcy-037 #39/#40/#41 HOTFIX의 baseline/운영 정합 문서를 독립 Proposed REQ로 초안화했다.
#### Intent
- 안전한 broker truth triage를 구현 사실 및 release gate와 동기화한다.
#### Acceptance Criteria Delta
- Added:
- runtime/interface/operations alignment, approval-gated remediation, secret-safe examples 및 evidence-only lifecycle documentation.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- runtime, interface, operations, project structure 및 SRS traceability.
- Modules:
- none.
- Tests:
- markdown/reference, CLI/code evidence 및 secret scan review.
#### Delivery Trace Delta
- Batches:
- none (draft only; dcy-037 emergency scope의 향후 입력).
- Releases:
- none (release gate 문서화만 포함; production live sync 실행 없음).
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: REQ-119~121 구현 verification evidence가 준비된 뒤 baseline 문서를 갱신하고 cross-check한다.
#### Validation Plan
- implemented CLI/help, structured decision codes, test evidence 및 markdown 링크를 대조하고 secret scan을 통과시킨다.
### CHG-20260714-02
- Date: 2026-07-14
- Author: Justin (TREX lead)
#### Change Summary
- Proposed에서 Approved로 승인했다.
#### Intent
- dcy-037의 runtime/interface/operations alignment와 release-gated remediation 경계를 implementation evidence 이후 갱신할 delivery input으로 확정한다.
#### Acceptance Criteria Delta
- Added:
- 없음.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- 없음.
- Modules:
- 없음.
- Tests:
- 없음.
#### Delivery Trace Delta
- Batches:
- dcy-037의 #39/#40/#41 단일 emergency batch (아직 미생성).
- Releases:
- none; documentation은 live sync 실행을 승인하지 않는다.
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: implementation/verification evidence가 준비된 뒤 baseline과 runbook을 갱신한다.
#### Validation Plan
- batch verification과 독립 QC에서 actual CLI/help, decision code, tests, markdown links 및 secret scan으로 AC를 재검증한다.
@@ -0,0 +1,117 @@
# REQ-120: KIS balance 인증 실패 fail-loud 및 broker/token lifecycle 정책
- Status: Approved
- Type: Exception
- Priority: High
- Owner: trex-core-dev
## Intent
- 현재 KIS balance 조회의 token/auth/transport 실패가 빈 잔고와 같은 `[]`로 해석되면 `broker_qty_zero`, sync skip 또는 잘못된 reconcile 판단으로 이어질 수 있다.
- 실제 empty holdings와 broker-read 실패를 명확히 분리하고, fetched credential operator 경로의 token/broker lifecycle을 throttle-safe·secret-safe 제약 아래 설계 가능하게 한다.
## Requirement
- 시스템은 KIS token issuance, authentication/authorization, HTTP transport 및 malformed balance response 실패를 성공적인 empty balance와 구별되는 typed 또는 동등하게 식별 가능한 sanitized failure로 표면화해야 한다.
- `sync`/`verify`를 포함한 operator broker-read 경로는 이러한 실패 시 비0 종료하고 `broker_qty_zero`, sync skip, verify success 또는 slot state write를 생성하지 않아야 한다.
- fetched credential broker/token lifecycle의 구현 설계는 다음 제약을 충족해야 한다: ownership 경계와 재사용 범위가 명시될 것, 반복 read가 매번 fresh broker/token을 강제해 throttle을 유발하지 않을 것, retry 여부·횟수·backoff/즉시 실패 기준이 failure class별로 문서화될 것, token/credential은 로그·CLI JSON·fixture에 기록되지 않을 것.
## Acceptance Criteria
1. Given KIS token issuance, auth/authorization, transport 또는 malformed response failure fixture, When `get_balance()`와 `sync --dry-run` 또는 `verify`를 실행하면, Then 성공 empty balance와 구별되는 sanitized failure가 반환되고 CLI는 비0으로 종료한다.
2. Given AC-1의 각 failure, When 결과와 persistence/log side effect를 검사하면, Then `broker_qty_zero`, sync skip, verify success, slot state write 또는 empty-holdings 판정이 생성되지 않는다.
3. Given 정상적으로 성공한 KIS balance response가 실제로 holdings 없이 반환됨, When 동일 경로를 실행하면, Then success empty balance semantics는 auth/transport failure와 구별되어 기존 정상 판단이 유지된다.
4. Given 반복 fetched-credential broker read와 token throttle 재현 fixture, When 구현된 lifecycle 정책을 적용해 읽기를 수행하면, Then fresh-object 생성이 반복되어 throttle을 유발하는 경로에 의존하지 않고, throttle이 발생해도 fail-loud semantics가 유지된다.
5. Given lifecycle 설계 검토, When implementation-ready decision을 기록하면, Then ownership/reuse scope, cache 또는 reuse 무효화 조건, failure class별 retry/no-retry 정책 및 regression test seam이 명시되며 process-local reuse, shared cache 또는 다른 공통 provider 중 특정 방식을 사전 근거 없이 요구하지 않는다.
6. Given 오류 출력, 구조화 로그 및 테스트 artifact, When auth/token failure를 검증하면, Then token, authorization header, app key/app secret, raw credential payload 및 raw account number가 노출되지 않는다.
## Impacted Area
- Docs: `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/operations.md`, `docs/srs/Non-Functional/req-115_daystart-intraday-resilience-credential-cache.md`
- Modules: `trex_core/adapters/broker/kis.py`, `trex_core/runtime/cli.py`, `trex_core/runtime/daemon.py`, 공통 broker provider/factory
- Tests: KIS adapter unit tests; CLI failure/no-write tests; lifecycle/throttle regression fixture; sanitized logging/JSON assertions
## Notes
- Source Discovery: `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md` (FR-2~3, OQ-2; GitHub #40).
- REQ-115의 day-start/intraday cache 목표와 충돌하지 않아야 한다. 본 REQ는 OQ-2의 구현 방식을 확정하지 않고, 설계 선택의 안전 제약과 검증 가능한 결과를 고정한다.
- Production live sync target 또는 실행은 OQ-3 release gate로서 범위 밖이다.
## Change Log
### CHG-20260714-01
- Date: 2026-07-14
- Author: delivery-runner
#### Change Summary
- dcy-037 #40의 auth/token failure empty-balance masking과 OQ-2 lifecycle 제약을 Proposed REQ로 초안화했다.
#### Intent
- false-zero 기반 상태 수렴을 막고 lifecycle 구현 선택을 근거 기반 설계 단계로 남긴다.
#### Acceptance Criteria Delta
- Added:
- fail-loud/no-write, true-empty 분리, throttle 회귀, lifecycle decision constraints 및 secret-safe 증거.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- runtime/interface/operations 및 REQ-115 정합 대상.
- Modules:
- KIS adapter와 daemon/CLI broker-read 경계.
- Tests:
- failure injection, no-write, throttle 및 redaction regression.
#### Delivery Trace Delta
- Batches:
- none (draft only; dcy-037 emergency scope의 향후 입력).
- Releases:
- none (production 접근 또는 live sync 실행 없음).
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 전 현재 KIS token cache와 daemon/CLI lifetime evidence를 설계 결정에 연결한다.
#### Validation Plan
- mocked KIS failure classes와 repeated-read throttle fixture에서 exit code, semantic result, no-write 및 artifact redaction을 확인한다.
### CHG-20260714-02
- Date: 2026-07-14
- Author: Justin (TREX lead)
#### Change Summary
- Proposed에서 Approved로 승인했다.
#### Intent
- dcy-037 #40의 false-zero 방지와 OQ-2 lifecycle 설계 제약을 구현 가능한 safety contract로 확정한다.
#### Acceptance Criteria Delta
- Added:
- 없음.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- 없음.
- Modules:
- 없음.
- Tests:
- 없음.
#### Delivery Trace Delta
- Batches:
- dcy-037의 #39/#40/#41 단일 emergency batch (아직 미생성).
- Releases:
- none; `sync --live`는 별도 release approval gate 유지.
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: batch design에서 lifecycle ownership/reuse와 failure-class별 retry 정책을 code evidence로 결정한다.
#### Validation Plan
- batch verification과 독립 QC에서 failure/no-write·true-empty·throttle·redaction AC를 실제 fixture로 재검증한다.
@@ -0,0 +1,116 @@
# REQ-119: fetched credential 공통 broker provider와 operator CLI truth 정합
- Status: Approved
- Type: Integration
- Priority: High
- Owner: trex-core-dev
## Intent
- `credential_fetch_enabled=true` 인스턴스에서 daemon과 operator `sync`/`verify`가 서로 다른 credential source로 브로커를 생성하면, 같은 인스턴스의 broker truth를 신뢰할 수 없다.
- daemon과 모든 operator broker-read 경로가 동일한 portfolio-hub fetched credential authority를 해석하도록 하되, 자격증명·계좌 식별 비밀은 노출하지 않는다.
## Requirement
- 시스템은 fetched credential 모드에서 daemon, `sync`, `verify` 및 같은 broker-read provider를 사용하는 operator 경로가 공통 broker provider를 통해 동일한 portfolio-hub credential source를 해석하도록 해야 한다.
- fetch 실패 시 명시적으로 활성화된 break-glass 정책이 없는 한 env/config credential로 조용히 fallback하지 않아야 하며, `credential_fetch_enabled=false` 인스턴스의 기존 env-only 동작은 유지해야 한다.
- provider가 source/account identity 정합 증거를 노출해야 할 경우 비밀이 아닌 stable label 또는 마스킹된 fingerprint만 사용해야 한다.
## Acceptance Criteria
1. Given `credential_fetch_enabled=true`와 mocked portfolio-hub credential 응답, When daemon-equivalent broker 구성과 `sync --dry-run` 및 `verify`의 broker-read 구성을 실행하면, Then 모두 같은 fetched source identity(안전한 label/fingerprint)와 같은 broker holding set을 사용한다.
2. Given fetched credential 모드에서 portfolio-hub fetch가 실패하고 break-glass가 비활성화됨, When operator broker-read 경로를 실행하면, Then env/config credential fallback이나 broker qty 기반 판단 없이 안전한 오류로 중단한다.
3. Given fetched credential 모드에서 명시적인 break-glass 정책이 활성화됨, When fetch가 실패하면, Then fallback의 활성화 근거와 source mode는 비밀 없이 추적 가능하고, 명시적 정책이 없을 때와 구별된다.
4. Given `credential_fetch_enabled=false` env-only instance, When daemon과 기존 operator broker-read 경로를 실행하면, Then fetched credential 요청 없이 기존 동작 및 CLI 호환성이 유지된다.
5. Given CLI 출력·구조화 로그·테스트 fixture, When source 정합 검증을 수행하면, Then app key, app secret, access token, raw credential payload 및 raw account number가 포함되지 않는다.
## Impacted Area
- Docs: `docs/runtime-flows.md`, `docs/interface-contract.md`, `docs/operations.md`, `docs/srs/Integration/req-112_portfolio-hub-broker-credential-read-adapter.md`, `docs/srs/Configuration/req-113_kisconfig-fetch-source-priority-and-break-glass.md`
- Modules: `trex_core/runtime/daemon.py`, `trex_core/runtime/cli.py`, `trex_core/application/snapshot_scheduler.py` 또는 공통 broker provider/factory, portfolio-hub credential read adapter
- Tests: daemon/provider/CLI integration regression; fetched source parity, fetch failure/no-silent-fallback, opt-in off env-only compatibility, secret-redaction assertions
## Notes
- Source Discovery: `docs/discovery/dcy-037_20260714_operator-broker-truth-and-managed-slot-divergence-hotfix.md` (FR-1, NFR-1~4; GitHub #39).
- Dependencies: REQ-112 implemented credential read port; REQ-113 approved source priority/break-glass policy; REQ-115 approved day-start credential resilience.
- OQ-3의 production live sync target 선정·실행은 release gate이며 본 REQ 범위가 아니다.
## Change Log
### CHG-20260714-01
- Date: 2026-07-14
- Author: delivery-runner
#### Change Summary
- dcy-037 #39의 fetched credential daemon/operator broker truth mismatch를 독립 Proposed REQ로 초안화했다.
#### Intent
- fetched credential authority를 공통 provider 경계로 정렬하여 dry-run/verify 결과가 daemon broker truth와 동일한 source를 사용하게 한다.
#### Acceptance Criteria Delta
- Added:
- fetched provider source parity, no-silent-fallback, break-glass 구별, env-only 회귀 및 secret-safe 관찰성 기준.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- runtime/interface/operations baseline 및 REQ-112/113 참조 정합 대상.
- Modules:
- daemon/CLI 및 공통 broker 구성 경계.
- Tests:
- mocked fetched credential 기반 parity와 회귀 테스트.
#### Delivery Trace Delta
- Batches:
- none (draft only; dcy-037 #39/#40/#41 단일 emergency batch의 향후 입력).
- Releases:
- none (production release/live sync 승인 대상 아님).
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: confirm-req 전 공통 provider 경계와 REQ-113/115 의존 관계를 검토한다.
#### Validation Plan
- isolated fixture에서 daemon-equivalent, `sync --dry-run`, `verify`의 source identity와 holding set을 비교하고, fetch failure·break-glass·env-only 분기를 회귀 검증한다.
### CHG-20260714-02
- Date: 2026-07-14
- Author: Justin (TREX lead)
#### Change Summary
- Proposed에서 Approved로 승인했다.
#### Intent
- dcy-037에서 확정된 #39 broker truth 정합 범위를 독립 delivery input으로 전환한다.
#### Acceptance Criteria Delta
- Added:
- 없음.
- Updated:
- 없음.
- Removed:
- 없음.
#### Impacted Area Delta
- Docs:
- 없음.
- Modules:
- 없음.
- Tests:
- 없음.
#### Delivery Trace Delta
- Batches:
- dcy-037의 #39/#40/#41 단일 emergency batch (아직 미생성).
- Releases:
- none; `sync --live`는 별도 release approval gate 유지.
#### Revalidation Impact
- Reverification Needed: yes
- Existing Implementation Invalidated: no
- Status Recommendation: keep-current
- Follow-up Action: suggest-batch-reqs에서 REQ-119~122를 단일 emergency batch로 편성한다.
#### Validation Plan
- batch verification과 독립 QC에서 parity·fallback·redaction AC를 실제 fixture와 CLI 회귀로 재검증한다.
+7 -1
View File
@@ -74,7 +74,7 @@
## Next Requirement ID
- Current: `REQ-119`
- Current: `REQ-123`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register
@@ -199,9 +199,15 @@
| REQ-116 | `trex.slots.req` 전략 필드 노출 + `strategy` 요청 필터 (additive) | Interface | Implemented | High | trex-core-dev | [req-116](./Interface/req-116_slots-req-strategy-field-and-filter.md) |
| REQ-117 | OrderRequest venue additive 필드 (KRX/NXT 거래소구분 포트 DTO) | Integration | Implemented | High | trex-core-dev | [req-117](./Integration/req-117_order-request-venue-field.md) |
| REQ-118 | KIS 브로커 어댑터 venue 파라미터화 — EXCG_ID_DVSN_CD 전송 + NXT 지원 | Integration | Implemented | High | trex-core-dev | [req-118](./Integration/req-118_kis-broker-venue-parameterization.md) |
| REQ-119 | fetched credential 공통 broker provider와 operator CLI truth 정합 | Integration | Approved | High | trex-core-dev | [req-119](./Integration/req-119_fetched-credential-broker-provider-cli-parity.md) |
| REQ-120 | KIS balance 인증 실패 fail-loud 및 broker/token lifecycle 정책 | Exception | Approved | High | trex-core-dev | [req-120](./Exception/req-120_kis-balance-auth-failure-fail-loud-lifecycle-policy.md) |
| REQ-121 | managed non-holding divergence 분류와 slot 존재 조회 계약 | Data | Approved | High | trex-core-dev | [req-121](./Data/req-121_managed-non-holding-divergence-classification-query.md) |
| REQ-122 | operator broker truth hotfix baseline 문서 정합 | Documentation | Approved | High | trex-core-dev | [req-122](./Documentation/req-122_operator-broker-truth-hotfix-baseline-alignment.md) |
## Recent Change Log Summary
- 2026-07-14 :: REQ-119~122 **승인(Approved, confirm-req, dcy-037)**: lead 검토로 각 REQ의 owner·priority·scope·검증 가능 AC와 Notes의 non-blocking 설계 제약을 확인했다. #39/#40/#41은 다음 `suggest-batch-reqs`에서 단일 emergency batch 후보이며, `sync --live`는 여전히 별도 release approval gate다.
- 2026-07-14 :: REQ-119~122 **신규 생성(Proposed, draft-req, dcy-037)**: #39 fetched credential daemon/operator broker truth parity, #40 token/auth fail-loud empty-balance 분리, #41 managed non-holding divergence classification, runtime/interface/operations baseline 정합. #39/#40/#41 단일 emergency batch 및 `sync --live` 별도 release approval gate를 유지한다.
- 2026-07-06 :: REQ-117/118 **신규 생성(Implemented, draft-req, trex-tvr-v1 dcy-002 FR-6)**: KIS 브로커 venue 파라미터화 — REQ-117 OrderRequest.venue additive 필드(기본 KRX, 포트 DTO 계층), REQ-118 KIS 어댑터 EXCG_ID_DVSN_CD 전송 + cancel venue 의미론 + poll_fill NXT 체결 보장 + get_current_price market 파라미터화. 실측 근거: 2026-07-06 실전 NXT 주문/취소 rt_cd=0. 고위험(라이브 주문 경로) — worktree+PR+QC(opus) 필수. confirm-req 대기.
- 2026-07-04 :: REQ-116 **신규 생성(Implemented, draft-req, trex-tvr-v1 dcy-001/dcy-002 v3 FR-8)**: `trex.slots.req` additive 확장 — 행 `strategy_name`·`strategy_params`·`markets` 필드 노출(hub 정본, safe-default) + 요청 `strategy` 필터(exact-match, status 필터 AND 조합, available_slots 불변). REQ-074 AC-5 additive 재도입(dcy-025 OQ-4 후속). 라이브 wire 계약 고위험 — worktree+PR+QC 정식 플로, 배포 선행조건=portfolio-hub REQ① 먼저.
- 2026-06-28 :: REQ-112/114 **`Approved` → `Implemented`**, REQ-113/115 **`Approved` 유지(confirm-req-implemented, dcy-036/BAT-053)**: BAT-053 PR #34 머지(01655ab). QC PASS-WITH-NOTES(opus, blocker 0). 745 passed/0 회귀. REQ-112(fetch 포트+어댑터+stub AC-1~8 완료), REQ-114(fail-closed 게이트 AC-1~9, BuyIntent 차단/SellIntent 통과 비대칭·CRITICAL 알람·opt-in off 비활성 완료). REQ-113/115 스캐폴드 완료 — KISBroker 런타임 재조립(REQ-113 AC-2, REQ-115 AC-1) M3 후속 batch 연기.
+36 -1
View File
@@ -10,7 +10,7 @@ import os
import pytest
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.broker_port import OrderRequest
from trex_core.application.broker_port import BrokerBalanceError, OrderRequest
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
@@ -75,3 +75,38 @@ def test_sandbox_token_and_submit():
assert ack.broker_order_id
else:
assert ack.reason # rejection carries a reason (e.g., 장 미개장/잔고부족)
def test_get_balance_rejects_failure_instead_of_false_empty(monkeypatch):
"""BAT-057: a rejected KIS response is not a successful empty balance."""
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
def raise_for_status(self): pass
def json(self): return {"rt_cd": "1", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
with pytest.raises(BrokerBalanceError, match="broker_rejected"):
b.get_balance()
def test_get_balance_accepts_validated_empty_balance(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
def raise_for_status(self): pass
def json(self): return {"rt_cd": "0", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
assert b.get_balance() == []
def test_get_balance_sanitizes_token_issuance_failure(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret")))
with pytest.raises(BrokerBalanceError) as raised:
b.get_balance()
assert raised.value.code == "token_issuance_failed"
assert "secret" not in str(raised.value)
@@ -0,0 +1,198 @@
"""REQ-121 AC-5 fake-driver parity coverage for managed-slot lookup.
This is a unit contract test: the injected psycopg fake exercises the actual
PostgreSQL adapter query and row mapping without requiring a PostgreSQL server.
"""
from __future__ import annotations
import sys
import types
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID
# This worktree lacks pika, but importing the PostgreSQL adapter traverses the
# application alarm-emitter import path. Install only the import-time minimum;
# this test exercises neither RabbitMQ nor a real PostgreSQL connection.
_pika = types.ModuleType("pika")
_pika.__path__ = []
_pika.URLParameters = lambda *args, **kwargs: None
_pika.BlockingConnection = object
_pika.BasicProperties = object
_pika_exceptions = types.ModuleType("pika.exceptions")
_pika_exceptions.AMQPError = Exception
_pika.exceptions = _pika_exceptions
sys.modules.setdefault("pika", _pika)
sys.modules.setdefault("pika.exceptions", _pika_exceptions)
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository # noqa: E402
from trex_core.domain.slot import Slot, SlotState # noqa: E402
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository # noqa: E402
_SLOT_COLUMNS = (
"slot_id, status, symbol, qty, avg_price, limit_price, "
"order_id, strategy_id, broker_order_id, portfolio_id, "
"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"
)
_MANAGED_SLOT_SQL = (
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
"WHERE instance_key = %s AND symbol = %s "
"ORDER BY updated_at DESC NULLS LAST, slot_id ASC LIMIT 1"
)
class _FakeCursor:
def __init__(self, rows: list[tuple], executions: list[tuple[str, tuple[str, str]]]) -> None:
self._rows = rows
self._executions = executions
self._row: tuple | None = None
def __enter__(self) -> _FakeCursor:
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, params: tuple[str, str]) -> None:
self._executions.append((query, params))
instance_key, symbol = params
candidates = [
row for row in self._rows if row[16] == instance_key and row[2] == symbol
]
candidates.sort(key=lambda row: (
row[15] is None,
-row[15].timestamp() if row[15] is not None else 0,
str(UUID(str(row[0]))),
))
self._row = candidates[0] if candidates else None
def fetchone(self) -> tuple | None:
return self._row
class _FakeConnection:
def __init__(self, rows: list[tuple], executions: list[tuple[str, tuple[str, str]]]) -> None:
self._rows = rows
self._executions = executions
def __enter__(self) -> _FakeConnection:
return self
def __exit__(self, *args: object) -> None:
return None
def cursor(self) -> _FakeCursor:
return _FakeCursor(self._rows, self._executions)
class _FakePsycopg:
def __init__(self, rows: list[tuple]) -> None:
self._rows = rows
self.connect_calls: list[tuple[str, dict[str, object]]] = []
self.executions: list[tuple[str, tuple[str, str]]] = []
def connect(self, dsn: str, **kwargs: object) -> _FakeConnection:
self.connect_calls.append((dsn, kwargs))
return _FakeConnection(self._rows, self.executions)
def _slot(slot_id: str, instance_key: str, symbol: str, updated_at: datetime | None) -> Slot:
return Slot(
slot_id=UUID(slot_id),
status=SlotState.HOLDING,
symbol=symbol,
qty=10.5,
avg_price=101.25,
limit_price=100.0,
order_id="order-1",
strategy_id="buy-v1",
broker_order_id="broker-1",
portfolio_id="portfolio-1",
sell_strategy_id="sell-v1",
created_at=datetime(2026, 7, 14, tzinfo=timezone.utc),
updated_at=updated_at,
instance_key=instance_key,
timeout_sec=30,
slippage_pct=1.5,
occupancy_seq=3,
)
def _row(slot: Slot) -> tuple:
"""Return a driver-shaped row, including values converted by _row_to_slot."""
return (
str(slot.slot_id),
slot.status.value,
slot.symbol,
Decimal(str(slot.qty)),
Decimal(str(slot.avg_price)),
Decimal(str(slot.limit_price)),
slot.order_id,
slot.strategy_id,
slot.broker_order_id,
slot.portfolio_id,
slot.sell_strategy_id,
slot.forced_close_at,
None,
None,
slot.created_at,
slot.updated_at,
slot.instance_key,
slot.retired_at,
slot.timeout_sec,
Decimal(str(slot.slippage_pct)),
slot.occupancy_seq,
)
def test_find_managed_slot_by_symbol_postgresql_inmemory_parity_with_fake_driver():
newest = datetime(2026, 7, 15, 12, tzinfo=timezone.utc)
older = datetime(2026, 7, 15, 11, tzinfo=timezone.utc)
slots = [
# Same newest timestamp: UUID ASC must pick the first fixture slot.
_slot("00000000-0000-0000-0000-000000000001", "instance-a", "AAA", newest),
_slot("00000000-0000-0000-0000-000000000002", "instance-a", "AAA", newest),
_slot("00000000-0000-0000-0000-000000000003", "instance-a", "AAA", older),
_slot("00000000-0000-0000-0000-000000000004", "instance-a", "AAA", None),
# Same symbol in a different instance must never leak into instance-a.
_slot("00000000-0000-0000-0000-000000000005", "instance-b", "AAA", newest),
# Same instance with another symbol must not satisfy an AAA query.
_slot("00000000-0000-0000-0000-000000000006", "instance-a", "BBB", newest),
]
in_memory = InMemorySlotRepository()
for slot in slots:
in_memory.add_slot(slot)
fake_psycopg = _FakePsycopg([_row(slot) for slot in slots])
postgresql = PostgreSQLSlotRepository("postgresql://fake", psycopg_module=fake_psycopg)
lookups = [
("instance-a", "AAA"),
("instance-b", "AAA"),
("instance-a", "BBB"),
("instance-a", "missing"),
("missing-instance", "AAA"),
]
for instance_key, symbol in lookups:
postgres_slot = postgresql.find_managed_slot_by_symbol(instance_key, symbol)
in_memory_slot = in_memory.find_managed_slot_by_symbol(instance_key, symbol)
assert (postgres_slot.slot_id if postgres_slot else None) == (
in_memory_slot.slot_id if in_memory_slot else None
)
selected = postgresql.find_managed_slot_by_symbol("instance-a", "AAA")
assert selected is not None
assert selected.slot_id == UUID("00000000-0000-0000-0000-000000000001")
assert selected.qty == 10.5
assert selected.avg_price == 101.25
assert selected.slippage_pct == 1.5
assert selected.occupancy_seq == 3
expected_executions = [
(_MANAGED_SLOT_SQL, lookup) for lookup in [*lookups, ("instance-a", "AAA")]
]
assert fake_psycopg.executions == expected_executions
assert fake_psycopg.connect_calls == [("postgresql://fake", {})] * len(expected_executions)
+83 -1
View File
@@ -1,7 +1,10 @@
"""dcy-020 / REQ-062 (BAT-029): core holding ↔ broker divergence reconcile."""
from __future__ import annotations
from dataclasses import replace
from trex_core.application.broker_port import BalanceItem
from trex_core.application.portfolio import InMemoryReconcileWarningStore
from trex_core.application.reconciliation import reconcile_core_holdings
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@@ -36,6 +39,18 @@ def _wire():
return repo, SlotService(repo, instance_key=INST)
class TrackingRepository(InMemorySlotRepository):
"""Records writes so reconciliation-only classification stays read-only."""
def __init__(self):
super().__init__()
self.update_calls = 0
def update_slot(self, slot):
self.update_calls += 1
return super().update_slot(slot)
def test_A_core_holding_broker_flat_goes_error():
repo, svc = _wire()
slot = _hold(repo, svc, "pf-a", "005930", 10)
@@ -66,4 +81,71 @@ def test_B_broker_holds_unmanaged_warns():
repo, svc = _wire()
broker = FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]) # core has none
d = reconcile_core_holdings(slot_repo=repo, slot_service=svc, broker=broker, instance_key=INST)
assert d["broker:000660"] == "unmanaged_warning"
assert d["broker:000660"] == "unmanaged_position"
def test_broker_held_symbol_with_same_instance_non_holding_slot_is_read_only_divergence():
repo = TrackingRepository()
svc = SlotService(repo, instance_key=INST)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe(INST, {"pf-a": "000660"})
slot = repo.find_slot_by_portfolio(INST, "pf-a")
assert slot is not None
writes_before = repo.update_calls
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc,
broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]),
instance_key=INST, warn_store=warn_store,
)
assert decisions == {"broker:000660": "managed_non_holding_divergence"}
assert repo.fetch_slot(slot.slot_id) == slot
assert repo.update_calls == writes_before
assert warn_store.list_warned() == set()
assert repo.list_slots(INST) == [slot]
def test_broker_held_symbol_with_same_instance_error_slot_is_read_only_divergence():
repo = TrackingRepository()
svc = SlotService(repo, instance_key=INST)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe(INST, {"pf-a": "000660"})
inactive = repo.find_slot_by_portfolio(INST, "pf-a")
assert inactive is not None
error = replace(inactive, status=SlotState.ERROR)
repo.update_slot(error)
writes_before = repo.update_calls
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc,
broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]),
instance_key=INST, warn_store=warn_store,
)
assert decisions == {"broker:000660": "managed_non_holding_divergence"}
assert repo.fetch_slot(error.slot_id) == error
assert repo.update_calls == writes_before
assert warn_store.list_warned() == set()
def test_other_instance_only_managed_slot_remains_unmanaged_position_without_writes():
repo = TrackingRepository()
svc = SlotService(repo, instance_key=INST)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe("other-instance", {"pf-a": "000660"})
other = repo.find_slot_by_portfolio("other-instance", "pf-a")
assert other is not None
writes_before = repo.update_calls
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc,
broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]),
instance_key=INST, warn_store=warn_store,
)
assert decisions == {"broker:000660": "unmanaged_position"}
assert repo.fetch_slot(other.slot_id) == other
assert repo.update_calls == writes_before
assert warn_store.list_warned() == set()
assert repo.list_slots(INST) == []
@@ -130,7 +130,7 @@ def test_reconcile_core_holdings_matching_qty_no_warn(caplog):
instance_key="default", warn_store=warn_store,
)
assert warn_store.list_warned() == set() # S-4: no false positive
assert all(v == "ok" for v in decisions.values() if v != "unmanaged_warning")
assert all(v == "ok" for v in decisions.values() if v != "unmanaged_position")
# REQ-103 AC-4: A case (broker 0) => ERROR transition, NOT warn_store
+83
View File
@@ -0,0 +1,83 @@
"""REQ-119 provider source and refresh lifecycle regressions."""
from types import SimpleNamespace
import pytest
from trex_core.runtime.broker_provider import (
BrokerCredentialUnavailable,
ConfigBrokerProvider,
)
from trex_core.runtime.config import DaemonConfig, KISConfig, PortfolioHubConfig
def _cfg(*, fetched=True, broker="kis"):
return DaemonConfig(
dsn="postgresql://test/test", broker=broker, execution_interval_sec=1,
kis=KISConfig("https://openapivts.example", "env-key", "env-secret", "env-account", 1, False),
portfolio_hub=PortfolioHubConfig("http://hub"), credential_fetch_enabled=fetched,
)
class _Broker:
def __init__(self): self.credentials = []
def reinitialize(self, credential): self.credentials.append(credential)
class _Read:
def __init__(self, ctx=None, error=None): self.ctx, self.error, self.calls = ctx, error, 0
def fetch_broker_credential(self):
self.calls += 1
if self.error:
raise self.error
return self.ctx
def _ctx(version="v1"):
return SimpleNamespace(trading_enabled=True, broker=SimpleNamespace(
account_ref="synthetic-account", app_key="synthetic-key", app_secret="synthetic-secret",
credential_version=version,
))
def test_fetched_provider_constructs_once_reuses_and_refreshes_without_env_fallback(monkeypatch):
broker = _Broker()
monkeypatch.setattr("trex_core.runtime.broker_provider._make_env_broker", lambda cfg: broker)
read = _Read(_ctx())
provider = ConfigBrokerProvider(_cfg(), read_port=read)
got, source = provider.get_broker_for_read()
assert got is broker
assert source.mode == "fetched"
assert source.identity_label == "credential-version:v1"
assert broker.credentials[0].app_key == "synthetic-key"
assert read.calls == 1
assert provider.refresh_day_start_credential() == source
assert read.calls == 1 # same day cache, no broker/token churn
def test_fetched_provider_failure_is_sanitized_and_never_constructs_env_broker(monkeypatch):
monkeypatch.setattr("trex_core.runtime.broker_provider._make_env_broker", lambda cfg: pytest.fail("env fallback"))
provider = ConfigBrokerProvider(_cfg(), read_port=_Read(ctx=None, error=RuntimeError("secret payload")))
with pytest.raises(BrokerCredentialUnavailable) as raised:
provider.get_broker_for_read()
assert raised.value.code == "portfolio_hub_unavailable"
assert "secret" not in str(raised.value)
def test_enabled_paper_never_fetches_kis_credentials(monkeypatch):
read = _Read(_ctx())
provider = ConfigBrokerProvider(_cfg(broker="paper"), read_port=read)
_, source = provider.get_broker_for_read()
assert source.mode == "env"
assert read.calls == 0
def test_break_glass_is_distinct_and_skips_fetch(monkeypatch):
broker = _Broker()
monkeypatch.setenv("TREX_KIS_BROKER_BREAK_GLASS", "true")
monkeypatch.setattr("trex_core.runtime.broker_provider._make_env_broker", lambda cfg: broker)
read = _Read(_ctx())
_, source = ConfigBrokerProvider(_cfg(), read_port=read).get_broker_for_read()
assert source.mode == "break_glass"
assert source.identity_label == "operator-break-glass"
assert read.calls == 0
+53
View File
@@ -0,0 +1,53 @@
"""REQ-120 CLI failure boundary regression tests."""
import sys
import types
from types import SimpleNamespace
# This worktree intentionally lacks pika. CLI imports only messaging topology for
# these tests; install a minimal import-time stub without contacting a broker.
_pika = types.ModuleType("pika")
_pika.__path__ = []
_pika.URLParameters = lambda *a, **k: None
_pika.BlockingConnection = object
_pika.BasicProperties = object
_pika_exceptions = types.ModuleType("pika.exceptions")
_pika_exceptions.AMQPError = Exception
_pika.exceptions = _pika_exceptions
sys.modules.setdefault("pika", _pika)
sys.modules.setdefault("pika.exceptions", _pika_exceptions)
from trex_core.application.broker_port import BrokerBalanceError # noqa: E402
from trex_core.runtime import cli # noqa: E402
from trex_core.runtime.config import DaemonConfig # noqa: E402
def _args(*, as_json=True):
return SimpleNamespace(portfolio_id="PF-1", all=False, json=as_json, dry_run=True,
yes=True, live=False, instance=None, config=None, config_path=None)
def test_sync_balance_failure_is_sanitized_and_happens_before_db(monkeypatch, capsys):
class Provider:
def get_broker_for_read(self):
return SimpleNamespace(get_balance=lambda: (_ for _ in ()).throw(BrokerBalanceError("authentication_failed"))), None
monkeypatch.setattr("trex_core.runtime.broker_provider.build_broker_provider", lambda cfg: Provider())
# A DB object being constructed would prove the boundary arrived too late.
monkeypatch.setattr("trex_core.adapters.portfolio.reconcile_warning_store.PostgreSQLReconcileWarningStore",
lambda *a: (_ for _ in ()).throw(AssertionError("DB touched")))
assert cli._cmd_sync(_args(), DaemonConfig("dsn", "paper", 1), lambda _: True) == 1
captured = capsys.readouterr()
assert '"authentication_failed"' in captured.err
assert "broker balance unavailable" in captured.err
def test_verify_balance_failure_never_leaks_payload_or_opens_db(monkeypatch, capsys):
class Provider:
def get_broker_for_read(self):
return SimpleNamespace(get_balance=lambda: (_ for _ in ()).throw(BrokerBalanceError("transport_failed"))), None
monkeypatch.setattr("trex_core.runtime.broker_provider.build_broker_provider", lambda cfg: Provider())
monkeypatch.setattr("trex_core.adapters.persistence.postgresql.PostgreSQLSlotRepository",
lambda *a: (_ for _ in ()).throw(AssertionError("DB touched")))
assert cli._cmd_verify(_args(), DaemonConfig("dsn", "paper", 1)) == 1
captured = capsys.readouterr()
assert '"transport_failed"' in captured.err
assert "payload-secret" not in captured.err
+33 -8
View File
@@ -18,6 +18,7 @@ import httpx
from trex_core.application.broker_port import (
BalanceItem,
BrokerBalanceError,
FillStatus,
OrderAck,
OrderRequest,
@@ -285,8 +286,9 @@ class KISBroker:
"""KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`).
Aggregates `output1[*].pdno` + `hldg_qty`, drops zero-qty rows
(D-2 leftover, Design Decision 7). Network/credential errors map to
an empty list (caller treats as no broker positions).
(D-2 leftover, Design Decision 7). Only a validated `rt_cd=0` response
may return an empty list; token/HTTP/shape/KIS failures raise a sanitized
:class:`BrokerBalanceError` so reconciliation cannot infer false zero.
"""
cano, product = self._account()
tr_id = "VTTC8434R" if self._mock else "TTTC8434R"
@@ -298,21 +300,44 @@ class KISBroker:
"PRCS_DVSN": "01",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
}
try:
headers = self._headers(tr_id)
except (httpx.HTTPError, KeyError, TypeError, ValueError):
# Token issuance/header construction is distinct from a rejected
# balance endpoint response and must never look like an empty book.
raise BrokerBalanceError("token_issuance_failed") from None
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance",
headers=self._headers(tr_id), params=params, timeout=self._timeout,
headers=headers, params=params, timeout=self._timeout,
)
resp.raise_for_status()
payload = resp.json()
except (httpx.HTTPError, ValueError):
return []
except httpx.HTTPStatusError as exc:
status = exc.response.status_code if exc.response is not None else None
code = "authentication_failed" if status == 401 else (
"authorization_failed" if status == 403 else "transport_failed"
)
raise BrokerBalanceError(code) from None
except httpx.HTTPError:
raise BrokerBalanceError("transport_failed") from None
except (ValueError, TypeError):
raise BrokerBalanceError("malformed_response") from None
if not isinstance(payload, dict):
raise BrokerBalanceError("malformed_response")
if str(payload.get("rt_cd")) != "0":
return []
rows = payload.get("output1") or []
raise BrokerBalanceError("broker_rejected")
rows = payload.get("output1")
if not isinstance(rows, list):
raise BrokerBalanceError("malformed_response")
out: list[BalanceItem] = []
for row in rows:
if not isinstance(row, dict):
raise BrokerBalanceError("malformed_response")
symbol = str(row.get("pdno") or "").strip()
qty = _safe_int(row.get("hldg_qty")) or 0
qty = _safe_int(row.get("hldg_qty"))
if qty is None:
raise BrokerBalanceError("malformed_response")
if not symbol or qty <= 0:
continue
out.append(BalanceItem(symbol=symbol, hldg_qty=qty))
@@ -134,6 +134,18 @@ class PostgreSQLSlotRepository:
)
return [self._row_to_slot(r) for r in cur.fetchall()]
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
"WHERE instance_key = %s AND symbol = %s "
"ORDER BY updated_at DESC NULLS LAST, slot_id ASC LIMIT 1",
(instance_key, symbol),
)
row = cur.fetchone()
return self._row_to_slot(row) if row else None
def reconcile_universe(
self, instance_key: str, universe: dict[str, str],
*, allow_retire: bool = True,
+15
View File
@@ -13,6 +13,21 @@ from decimal import Decimal
from typing import Any, Optional, Protocol
class BrokerBalanceError(RuntimeError):
"""Sanitized failure distinct from a validated empty broker balance."""
_CODES = frozenset({
"token_issuance_failed", "authentication_failed", "authorization_failed",
"transport_failed", "malformed_response", "broker_rejected",
})
def __init__(self, code: str) -> None:
if code not in self._CODES:
raise ValueError(f"unsupported broker balance error code: {code}")
self.code = code
super().__init__(f"broker balance unavailable ({code})")
@dataclass(frozen=True)
class OrderRequest:
slot_id: str # originating slot (audit/correlation)
+16 -5
View File
@@ -58,7 +58,8 @@ def reconcile_core_holdings(
→ set by reconcile_positions
warn_store: Optional — when None, WARN-log-only (backward compat, REQ-103 AC-5).
Returns {key: 'error'|'qty_warning'|'ok'|'unmanaged_warning'} for telemetry.
Returns {key: 'error'|'qty_warning'|'ok'|'unmanaged_position'|
'managed_non_holding_divergence'} for telemetry.
"""
broker_qty: dict[str, int] = {
item.symbol: int(item.hldg_qty) for item in broker.get_balance()
@@ -114,12 +115,22 @@ def reconcile_core_holdings(
)
else:
decisions[str(slot.slot_id)] = "ok"
for sym, bq in broker_qty.items(): # unmanaged broker position
for sym, bq in broker_qty.items(): # unmatched broker position
if bq > 0 and sym not in matched:
decisions[f"broker:{sym}"] = "unmanaged_warning"
managed = slot_repo.find_managed_slot_by_symbol(instance_key, sym)
if managed is not None:
decisions[f"broker:{sym}"] = "managed_non_holding_divergence"
_log.warning(
"broker holds %s qty=%d with no core holding slot (unmanaged position)",
sym, bq,
"managed non-holding divergence: slot_id=%s portfolio_id=%s "
"symbol=%s status=%s instance_key=%s",
managed.slot_id, managed.portfolio_id, sym,
managed.status.value, instance_key,
)
else:
decisions[f"broker:{sym}"] = "unmanaged_position"
_log.warning(
"unmanaged position: symbol=%s qty=%d instance_key=%s",
sym, bq, instance_key,
)
return decisions
@@ -51,6 +51,7 @@ class SnapshotScheduler:
warn_store: Optional[ReconcileWarningStore] = None,
credential_fetch_enabled: bool = False,
kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None,
credential_refresh: Optional[Callable[[], object]] = None,
) -> None:
self._read = read_port
self._store = store
@@ -77,6 +78,8 @@ class SnapshotScheduler:
# KISBroker with the fetched day-start credential. None = no reinit (paper
# broker or credential_fetch_enabled=False). Set by daemon.py assembly.
self._kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = kis_reinit_callback
# REQ-119: provider-owned refresh supersedes the legacy raw fetch hook.
self._credential_refresh = credential_refresh
# REQ-113: detect break-glass at init time (env var set by operator).
self._break_glass: bool = (
os.environ.get("TREX_KIS_BROKER_BREAK_GLASS", "").lower()
@@ -250,7 +253,17 @@ class SnapshotScheduler:
# Must run before portfolio snapshot so entry_allowed is set before
# the first BuyIntent can arrive from the strategy daemon.
if self._credential_fetch_enabled:
if self._credential_refresh is None:
self._maybe_fetch_credential()
else:
try:
self._credential_refresh()
self._entry_allowed = True
except Exception as exc: # provider exposes only sanitized failure
_log.error("DAY_START_LOAD_FAILED: broker credential refresh failed: %s (instance=%s)",
exc.__class__.__name__, self._instance_key)
self._entry_allowed = False
self._emit_day_start_alarm(reason="broker_credential_unavailable")
if self._daemon_id is None:
try:
@@ -89,6 +89,19 @@ class InMemorySlotRepository:
if s.instance_key == instance_key and s.status is SlotState.HOLDING
]
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]:
candidates = [
s for s in self._slots.values()
if s.instance_key == instance_key and s.symbol == symbol
]
# PostgreSQL parity: updated_at DESC NULLS LAST, slot_id ASC.
candidates.sort(key=lambda s: (
s.updated_at is None,
-s.updated_at.timestamp() if s.updated_at is not None else 0,
str(s.slot_id),
))
return candidates[0] if candidates else None
def reconcile_universe(
self, instance_key: str, universe: dict[str, str],
*, allow_retire: bool = True,
+7
View File
@@ -72,6 +72,13 @@ class SlotRepository(Protocol):
reconcile; safety-sell / kill-switch sweeps)."""
...
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]:
"""Return one deterministic managed slot for exact instance+symbol.
Read-only reconciliation triage; this must not imply remediation.
"""
...
def reconcile_universe(
self, instance_key: str, universe: dict[str, str],
*, allow_retire: bool = True,
+147
View File
@@ -0,0 +1,147 @@
"""Common broker credential resolution and lifecycle boundary (REQ-119/120)."""
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from typing import Literal, Protocol
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.application.broker_port import BrokerPort
from trex_core.application.portfolio import PortfolioHubUnavailableError
from trex_core.runtime.config import DaemonConfig
_KST = timezone(timedelta(hours=9))
@dataclass(frozen=True)
class BrokerSource:
"""Non-secret credential-source metadata safe for CLI/log output."""
mode: Literal["env", "fetched", "break_glass"]
identity_label: str
class BrokerCredentialUnavailable(RuntimeError):
_CODES = frozenset({"portfolio_hub_unavailable", "broker_absent", "trading_disabled", "malformed_credential", "broker_initialization_failed"})
def __init__(self, code: str) -> None:
if code not in self._CODES:
raise ValueError(f"unsupported broker credential error code: {code}")
self.code = code
super().__init__("broker credential unavailable")
class BrokerProvider(Protocol):
def get_broker_for_read(self) -> tuple[BrokerPort, BrokerSource]: ...
def refresh_day_start_credential(self) -> BrokerSource: ...
def _break_glass_enabled() -> bool:
return os.environ.get("TREX_KIS_BROKER_BREAK_GLASS", "").lower() in {"1", "true", "yes"}
def _make_env_broker(config: DaemonConfig) -> BrokerPort:
if config.broker != "kis":
seq_store = None
if config.paper.redis_url:
from trex_core.adapters.broker.seq_store import RedisSeqStore
seq_store = RedisSeqStore(config.paper.redis_url, key=f"trex:broker:paper:{config.instance_key}:order-seq")
return PaperBroker(fill_after_polls=config.paper.fill_after_polls, qty_per_poll=config.paper.qty_per_poll,
reject_symbols=set(config.paper.reject_symbols), seq_store=seq_store)
from trex_core.adapters.broker.kis import KISBroker
k = config.kis
if k is None:
raise BrokerCredentialUnavailable("broker_initialization_failed")
token_cache = None
if k.redis_url:
from trex_core.adapters.broker.token_cache import RedisTokenCache
token_cache = RedisTokenCache(k.redis_url)
return KISBroker(k.account_ref, app_key=k.app_key, app_secret=k.app_secret, base_url=k.base_url,
timeout=k.timeout, allow_live=k.allow_live, token_cache=token_cache)
class ConfigBrokerProvider:
"""One provider owns one process/command broker and fetched-source refresh."""
def __init__(self, config: DaemonConfig, *, read_port=None, clock=None) -> None:
self._config = config
self._read = read_port
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
self._broker: BrokerPort | None = None
self._source: BrokerSource | None = None
self._credential_date: date | None = None
def _read_port(self):
if self._read is None:
if self._config.portfolio_hub is None:
raise BrokerCredentialUnavailable("portfolio_hub_unavailable")
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
p = self._config.portfolio_hub
self._read = PortfolioHubReadAdapter(p.base_url, token=p.token or None, timeout=p.timeout)
return self._read
def _fetch(self) -> BrokerSource:
try:
ctx = self._read_port().fetch_broker_credential()
except PortfolioHubUnavailableError:
raise BrokerCredentialUnavailable("portfolio_hub_unavailable") from None
except BrokerCredentialUnavailable:
raise
except Exception:
raise BrokerCredentialUnavailable("portfolio_hub_unavailable") from None
if ctx is None or getattr(ctx, "broker", None) is None:
raise BrokerCredentialUnavailable("broker_absent")
if not getattr(ctx, "trading_enabled", False):
raise BrokerCredentialUnavailable("trading_disabled")
credential = ctx.broker
if not all(isinstance(getattr(credential, x, None), str) and getattr(credential, x).strip()
for x in ("account_ref", "app_key", "app_secret")):
raise BrokerCredentialUnavailable("malformed_credential")
try:
if self._broker is None:
broker = _make_env_broker(self._config)
reinitialize = getattr(broker, "reinitialize", None)
if not callable(reinitialize):
raise RuntimeError("KIS broker lacks credential reload")
reinitialize(credential)
self._broker = broker
else:
self._broker.reinitialize(credential) # type: ignore[attr-defined]
except Exception:
raise BrokerCredentialUnavailable("broker_initialization_failed") from None
self._credential_date = self._clock().astimezone(_KST).date()
# Version is designated non-secret by the portfolio contract; no account/key material.
return BrokerSource("fetched", f"credential-version:{getattr(credential, 'credential_version', 'unknown')}")
def get_broker_for_read(self) -> tuple[BrokerPort, BrokerSource]:
if self._config.broker != "kis":
if self._broker is None:
self._broker = _make_env_broker(self._config)
self._source = BrokerSource("env", "paper-config")
return self._broker, self._source
if not self._config.credential_fetch_enabled:
if self._broker is None:
self._broker = _make_env_broker(self._config)
self._source = BrokerSource("env", "kis-config")
return self._broker, self._source
if _break_glass_enabled():
if self._broker is None:
self._broker = _make_env_broker(self._config)
self._source = BrokerSource("break_glass", "operator-break-glass")
return self._broker, self._source
source = self._fetch()
self._source = source
return self._broker, source # type: ignore[return-value]
def refresh_day_start_credential(self) -> BrokerSource:
# Paper/env/break-glass paths deliberately do no portfolio-hub call.
if self._config.broker != "kis" or not self._config.credential_fetch_enabled or _break_glass_enabled():
return self.get_broker_for_read()[1]
today = self._clock().astimezone(_KST).date()
if self._broker is not None and self._credential_date == today and self._source is not None:
return self._source
_, source = self.get_broker_for_read()
return source
def build_broker_provider(config: DaemonConfig, *, read_port=None) -> ConfigBrokerProvider:
return ConfigBrokerProvider(config, read_port=read_port)
+54 -10
View File
@@ -329,6 +329,29 @@ def _fail(message: str, as_json: bool) -> None:
print(f"error: {message}", file=sys.stderr)
def _broker_read_failure(exc, as_json: bool) -> int:
"""REQ-120 command boundary: expose code only, never upstream payloads."""
code = getattr(exc, "code", "broker_unavailable")
payload = {"error": {"code": code, "message": "broker balance unavailable"}}
if as_json:
print(_json.dumps(payload), file=sys.stderr)
else:
print(f"broker balance unavailable ({code})", file=sys.stderr)
return 1
class _CachedBalanceBroker:
"""One validated balance read per operator invocation; delegates everything else."""
def __init__(self, broker, balance) -> None:
self._broker, self._balance = broker, balance
def get_balance(self):
return self._balance
def __getattr__(self, name):
return getattr(self._broker, name)
# --------------------------------------------------------------------------- #
# arg parsing
# --------------------------------------------------------------------------- #
@@ -446,7 +469,6 @@ def _build_parser() -> argparse.ArgumentParser:
def _cmd_sync(args, cfg, confirm) -> int:
"""REQ-105 (dcy-034, BAT-051): sync slot qty to broker ground truth."""
import psycopg
from trex_core.adapters.portfolio.reconcile_warning_store import (
PostgreSQLReconcileWarningStore,
@@ -476,11 +498,18 @@ def _cmd_sync(args, cfg, confirm) -> int:
print("aborted")
return 1
warn_store = PostgreSQLReconcileWarningStore(cfg.dsn)
# REQ-119/120: resolve and validate broker truth before opening/enumerating DB.
from trex_core.runtime.broker_provider import (
BrokerCredentialUnavailable, build_broker_provider,
)
from trex_core.application.broker_port import BrokerBalanceError
try:
broker_port, _source = build_broker_provider(cfg).get_broker_for_read()
broker_port = _CachedBalanceBroker(broker_port, broker_port.get_balance())
except (BrokerCredentialUnavailable, BrokerBalanceError) as exc:
return _broker_read_failure(exc, args.json)
# Build broker port
from trex_core.runtime.daemon import _build_broker
broker_port = _build_broker(cfg)
warn_store = PostgreSQLReconcileWarningStore(cfg.dsn)
# Build slot_repo + slot_service
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
@@ -498,6 +527,7 @@ def _cmd_sync(args, cfg, confirm) -> int:
)
# Determine target slot_ids
import psycopg
if args.portfolio_id:
with psycopg.connect(cfg.dsn) as conn:
with conn.cursor() as cur:
@@ -564,7 +594,7 @@ def _cmd_sync(args, cfg, confirm) -> int:
print(f" after: status={after.get('status')} "
f"qty={after.get('qty')} avg_price={after.get('avg_price')}")
if cleared:
print(f" reconcile-warning CLEARED")
print(" reconcile-warning CLEARED")
return exit_code
@@ -579,12 +609,20 @@ def _cmd_verify(args, cfg) -> int:
from trex_core.application.reconciliation import verify_slot_reconcile
instance = _resolve_instance(args)
# REQ-120 no-write/no-enumeration barrier also applies to verify.
from trex_core.runtime.broker_provider import (
BrokerCredentialUnavailable, build_broker_provider,
)
from trex_core.application.broker_port import BrokerBalanceError
try:
broker_port, _source = build_broker_provider(cfg).get_broker_for_read()
broker_port = _CachedBalanceBroker(broker_port, broker_port.get_balance())
except (BrokerCredentialUnavailable, BrokerBalanceError) as exc:
return _broker_read_failure(exc, args.json)
warn_store = PostgreSQLReconcileWarningStore(cfg.dsn)
slot_repo = PostgreSQLSlotRepository(cfg.dsn)
from trex_core.runtime.daemon import _build_broker
broker_port = _build_broker(cfg)
# Build a minimal snapshot store (we only need asset_quantity for C-case check).
# For verify we use a fresh SnapshotStore populated from warn_store metadata.
# The hub_qty is read from the snapshot; if snapshot is empty we fall back to 0
@@ -787,7 +825,13 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
else None
)
_load_dotenv(_toml, getattr(args, "env_file", None))
cfg = DaemonConfig.from_env()
cfg = (
DaemonConfig.from_config(
resolve_config_toml(args.config, args.config_path), env_file=args.env_file
)
if args.config
else DaemonConfig.from_env()
)
confirm = confirm or _default_confirm
# REQ-088 (dcy-029): operator report — direct-DB point-in-time aggregate.
+8 -1
View File
@@ -74,6 +74,7 @@ _log = logging.getLogger(__name__)
def _build_broker(config: DaemonConfig) -> BrokerPort:
"""Backward-compatible env-only factory; new callers use broker_provider."""
if config.broker == "kis":
from trex_core.adapters.broker.kis import KISBroker
@@ -180,7 +181,7 @@ class Daemon:
instance_key=config.instance_key,
strategy_state_store=self.strategy_state_store,
)
self.broker = _build_broker(config)
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
# BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler.
# (Created before the ledger writer so option-D write-back can target it.)
@@ -193,6 +194,11 @@ class Daemon:
# BAT-012: day-scoped kill-switch exclusion store + service.
self.killswitch_store = PostgreSQLKillSwitchStore(config.dsn)
self.portfolio_read = _build_portfolio_read(config)
# REQ-119: one provider owns source resolution and the long-lived broker.
# Fetched mode never starts from an implicit env broker.
from trex_core.runtime.broker_provider import build_broker_provider
self.broker_provider = build_broker_provider(config, read_port=self.portfolio_read)
self.broker, self.broker_source = self.broker_provider.get_broker_for_read()
# Event publisher transport — one dedicated instance for the
# execution-loop thread (pika is not thread-safe).
@@ -237,6 +243,7 @@ class Daemon:
alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100
warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning
credential_fetch_enabled=config.credential_fetch_enabled, # dcy-036 / REQ-113
credential_refresh=self.broker_provider.refresh_day_start_credential,
# dcy-036 / REQ-113 AC-2 / REQ-115 AC-1: KISBroker reinit callback.
# None when broker!=kis or credential_fetch_enabled=False (backward compat).
kis_reinit_callback=_build_kis_reinit_callback(config, self.broker),