Merge pull request #34 from fourblocks2020/feat/dcy-036-daystart-credential-failsafe
dcy-036: 그날-시작 KIS 인증정보 fetch + fail-safe 로드 (스캐폴드) — BAT-053 (REQ-112~115)
This commit is contained in:
@@ -0,0 +1,122 @@
|
|||||||
|
# BAT-053: Design
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### D1: `entry_allowed` 상태 위치 — SnapshotScheduler + 공유 callable
|
||||||
|
|
||||||
|
**결정**: `SnapshotScheduler`에 `entry_allowed: bool = True` (opt-in=False 기본 통과) 상태를 두고, IntentService에 `entry_allowed_fn: Callable[[], bool] | None` 게이트를 주입한다.
|
||||||
|
|
||||||
|
**근거**:
|
||||||
|
- `entry_allowed`는 그날-시작 체크포인트(SnapshotScheduler.run_once())에서 설정되므로 소유자는 SnapshotScheduler가 자연스럽다.
|
||||||
|
- IntentService는 이미 `warn_store`, `max_active_slots` 등 주입형 게이트 패턴을 사용한다. 동일 패턴으로 `entry_allowed_fn: Optional[Callable[[], bool]] = None`을 추가하면 기존 코드 변경 최소화.
|
||||||
|
- `entry_allowed_fn=None`(기본)이면 게이트 완전 통과 → `credential_fetch_enabled=False`(기본)일 때 기존 동작 byte-for-byte 보존(회귀 0).
|
||||||
|
- daemon.py에서 `scheduler.get_entry_allowed` 메서드를 `IntentService`에 주입하는 방식으로 조립.
|
||||||
|
|
||||||
|
**대안 검토**:
|
||||||
|
- 별도 `DayStartState` 객체: 추가 클래스 생성 필요, SnapshotScheduler와 결합 불필요하게 증가.
|
||||||
|
- IntentService 내부 setter: 의존 방향 역전(application이 application 내부를 조율하는 순환 구조) — 거부.
|
||||||
|
|
||||||
|
### D2: `credential_fetch_enabled` 플래그 전파 경로
|
||||||
|
|
||||||
|
**결정**: `DaemonInstanceConfig.credential_fetch_enabled: bool = False` → `DaemonConfig.from_config()`에서 `inst.credential_fetch_enabled`를 읽어 `DaemonConfig.credential_fetch_enabled: bool = False`에 설정. `SnapshotScheduler` 생성자에 `credential_fetch_enabled: bool = False` 인자 추가.
|
||||||
|
|
||||||
|
**근거**:
|
||||||
|
- TOML → `DaemonInstanceConfig` → `DaemonConfig` → `SnapshotScheduler` 흐름이 기존 `safety_sell_enabled`, `watchdog_timeout_sec` 패턴과 동일. 일관성 유지.
|
||||||
|
- `from_env()` 는 변경 없음(always `False`). `from_config()`만 TOML 값을 읽어 전파.
|
||||||
|
|
||||||
|
### D3: REQ-113 fetch 소스 우선순위 구현 위치
|
||||||
|
|
||||||
|
**결정**: 우선순위 로직은 `DaemonConfig.from_config()`에 두지 않고 **`SnapshotScheduler.run_once()`의 그날-시작 후크**에 둔다.
|
||||||
|
|
||||||
|
**근거**:
|
||||||
|
- REQ-113 Notes: "`base_url`/timeout/allow_live는 env 유지". fetch는 app_key/app_secret/account_ref만 가져온다.
|
||||||
|
- `DaemonConfig.from_config()`는 정적 구성 해결 단계 — 네트워크 I/O(fetch)를 여기에 두면 의존성 역전 위반(runtime이 portfolio-hub를 직접 호출).
|
||||||
|
- `SnapshotScheduler.run_once()`는 이미 portfolio-hub를 호출하는 지점이고, `credential_fetch_enabled=True`일 때 추가 fetch가 자연스럽다.
|
||||||
|
- break-glass(`TREX_KIS_BROKER_BREAK_GLASS=true`) 감지는 `SnapshotScheduler` 초기화 시 `os.environ`을 읽어 `_break_glass: bool` 플래그로 보관.
|
||||||
|
- fetch 결과(`BrokerCredentialPayload`)는 `SnapshotScheduler._cached_credential`에 그날 고정 캐시.
|
||||||
|
- **KISConfig 재조립 경로**: `SnapshotScheduler`에 `kis_config_callback: Optional[Callable[[BrokerCredentialPayload], None]]`를 주입. daemon.py에서 이 callback이 `KISBroker`를 재초기화하거나 config를 갱신하는 역할을 한다. 단, M3에서 중장기 인스턴스 추가 전까지는 단기 07:30 로드에서만 필요하고, BAT-053 범위에서는 `entry_allowed` 플래그 설정 + 알람까지만 구현. KISBroker 재조립은 M3 별도 batch.
|
||||||
|
|
||||||
|
**BAT-053 범위 명확화**:
|
||||||
|
- REQ-113 AC-1~7 중 AC-2(fetch 결과로 KISConfig 구성)의 "KISBroker에 실제 적용" 부분은 M3 KISBroker 재조립 batch에 defer. 이번 batch에서는 fetch→캐시→entry_allowed 게이트 + 알람까지.
|
||||||
|
- 단, AC-2 테스트는 TradingContext가 올바르게 반환됨을 stub으로 검증.
|
||||||
|
|
||||||
|
### D4: 그날 고정 캐시 설계 (REQ-115)
|
||||||
|
|
||||||
|
**결정**: `SnapshotScheduler`에 인스턴스 변수 추가:
|
||||||
|
- `_cached_credential: Optional[BrokerCredentialPayload] = None`
|
||||||
|
- `_credential_date: Optional[date] = None`
|
||||||
|
- `_entry_allowed: bool = True` (opt-in=False면 항상 True = 차단 안 함)
|
||||||
|
|
||||||
|
`run_once()` 시 `credential_fetch_enabled=True`이면 `_maybe_fetch_credential()` 메서드 호출.
|
||||||
|
`_maybe_fetch_credential()`:
|
||||||
|
1. `_credential_date == today` → 그날 고정 캐시 반환(재fetch 안 함, REQ-115 AC-3)
|
||||||
|
2. break-glass → env-only, 캐시 없음, `_entry_allowed=True`
|
||||||
|
3. `fetch_broker_credential()` 호출 → 성공: 캐시 + `_entry_allowed=True`; 실패(3가지 조건): `_entry_allowed=False` + ERROR + CRITICAL 알람
|
||||||
|
|
||||||
|
`get_entry_allowed() -> bool` 메서드 노출 → daemon.py에서 IntentService.`entry_allowed_fn`에 주입.
|
||||||
|
|
||||||
|
### D5: 중장기 인스턴스 후크 위치 (REQ-114 Notes)
|
||||||
|
|
||||||
|
**결정**: BAT-053에서는 단기 07:30 KST 경로(SnapshotScheduler.run_once())만 구현. 중장기 인스턴스(dcy-027 리밸런싱 주기)의 동등 후크는 BAT-053 구현 후 그 위치를 확인하고 설계에 명시. 현재 코드상 중장기 후크는 SnapshotScheduler._maybe_tick()과 run_once()를 공유하므로 `credential_fetch_enabled=True`이면 동일 경로로 처리됨. dcy-027 추가 인스턴스가 별도 run_once() 트리거 주기를 가질 경우 그때 조정.
|
||||||
|
|
||||||
|
### D6: AlarmEmitter `DAY_START_LOAD_FAILED`
|
||||||
|
|
||||||
|
**결정**: AlarmEmitter 자체는 변경 없음(코드는 문자열 파라미터로 전달). `SnapshotScheduler`에서 `self._alarm_emitter.publish_alarm("DAY_START_LOAD_FAILED", ...)` 직접 호출. ALARM-INVENTORY.md 또는 별도 코드 상수 추가는 BAT-053 범위 외(dcy-033/REQ-100 패턴 재사용).
|
||||||
|
|
||||||
|
## Module Interface Summary
|
||||||
|
|
||||||
|
### `application/portfolio.py` 추가
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BrokerCredentialPayload:
|
||||||
|
account_ref: str
|
||||||
|
app_key: str
|
||||||
|
app_secret: str
|
||||||
|
credential_version: Optional[int] = None
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TradingContext:
|
||||||
|
broker: Optional[BrokerCredentialPayload]
|
||||||
|
trading_enabled: bool
|
||||||
|
|
||||||
|
# PortfolioHubReadPort에 추가:
|
||||||
|
def fetch_broker_credential(self) -> TradingContext: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### `adapters/portfolio/hub_read.py` 변경
|
||||||
|
- `whoami()` 내부 로직을 `_fetch_user_info() -> dict` 헬퍼로 추출
|
||||||
|
- `whoami()` = `_fetch_user_info()`에서 `daemon_id` 필드 반환 (외부 동작 불변)
|
||||||
|
- `fetch_broker_credential()` = `_fetch_user_info()`에서 `broker`, `trading_enabled` 파싱
|
||||||
|
|
||||||
|
### `adapters/portfolio/stub_read.py` 변경
|
||||||
|
- `trading_context: Optional[TradingContext] = None` 생성자 인자 추가
|
||||||
|
- `fetch_broker_credential()` stub 구현
|
||||||
|
|
||||||
|
### `runtime/config.py` 변경
|
||||||
|
- `DaemonInstanceConfig.credential_fetch_enabled: bool = False` 추가 + `from_toml()` 파싱
|
||||||
|
- `DaemonConfig.credential_fetch_enabled: bool = False` 추가
|
||||||
|
- `DaemonConfig.from_config()`: `inst.credential_fetch_enabled` → `DaemonConfig.credential_fetch_enabled` 전파
|
||||||
|
|
||||||
|
### `application/snapshot_scheduler.py` 변경
|
||||||
|
- 생성자: `credential_fetch_enabled: bool = False` 인자 추가
|
||||||
|
- 인스턴스 변수: `_cached_credential`, `_credential_date`, `_entry_allowed`
|
||||||
|
- `_maybe_fetch_credential()` 비공개 메서드 추가
|
||||||
|
- `run_once()`: `credential_fetch_enabled=True`이면 `_maybe_fetch_credential()` 호출
|
||||||
|
- `get_entry_allowed() -> bool` 노출
|
||||||
|
|
||||||
|
### `application/intents.py` 변경 (IntentService)
|
||||||
|
- 생성자: `entry_allowed_fn: Optional[Callable[[], bool]] = None` 추가
|
||||||
|
- `_admit_buy()`: `entry_allowed_fn is not None and not entry_allowed_fn()` → reject `day_start_load_failed`
|
||||||
|
|
||||||
|
### `runtime/daemon.py` 변경
|
||||||
|
- `SnapshotScheduler` 생성 시 `credential_fetch_enabled=config.credential_fetch_enabled` 전달
|
||||||
|
- `IntentService` 생성 시 `entry_allowed_fn=self.snapshot_scheduler.get_entry_allowed` 전달 → SnapshotScheduler가 먼저 생성되어야 함 (순서 조정 필요 확인)
|
||||||
|
|
||||||
|
## Layer Compliance
|
||||||
|
|
||||||
|
- `domain/` 변경 없음 — 도메인 순수성 불변
|
||||||
|
- `application/portfolio.py`: 새 dataclass(frozen=True) — IO/네트워크 없음 (import sweep 통과)
|
||||||
|
- `application/snapshot_scheduler.py`: portfolio 포트 호출(이미 포트 인터페이스) — 신규 import 없음
|
||||||
|
- `application/intents.py`: callable 주입 — 추가 import 없음
|
||||||
|
- `adapters/`: 구체 구현만 변경
|
||||||
|
- `runtime/`: 조립 레이어 변경
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# BAT-053: Implementation
|
||||||
|
|
||||||
|
## 변경 파일 목록
|
||||||
|
|
||||||
|
### 신규/확장 모듈
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 | 관련 REQ |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `trex_core/application/portfolio.py` | 확장 | REQ-112 |
|
||||||
|
| `trex_core/adapters/portfolio/hub_read.py` | 확장 | REQ-112 |
|
||||||
|
| `trex_core/adapters/portfolio/stub_read.py` | 확장 | REQ-112 |
|
||||||
|
| `trex_core/runtime/config.py` | 확장 | REQ-113 |
|
||||||
|
| `trex_core/application/snapshot_scheduler.py` | 확장 | REQ-113/114/115 |
|
||||||
|
| `trex_core/application/intents.py` | 확장 | REQ-114 |
|
||||||
|
| `trex_core/runtime/daemon.py` | 확장 (조립) | REQ-113/114 |
|
||||||
|
|
||||||
|
### 신규 테스트
|
||||||
|
|
||||||
|
| 파일 | 관련 REQ |
|
||||||
|
| --- | --- |
|
||||||
|
| `tests/adapters/portfolio/test_hub_read.py` (확장) | REQ-112 AC-1~7 |
|
||||||
|
| `tests/adapters/portfolio/test_stub_read.py` (확장) | REQ-112 AC-6 |
|
||||||
|
| `tests/runtime/test_instance_config.py` (확장) | REQ-113 AC-1,7 |
|
||||||
|
| `tests/application/test_snapshot_scheduler.py` (확장) | REQ-114 AC-1~9, REQ-115 AC-3/6 |
|
||||||
|
|
||||||
|
## REQ별 구현 요약
|
||||||
|
|
||||||
|
### REQ-112: BrokerCredentialPayload + TradingContext + 포트 메서드 + 어댑터
|
||||||
|
|
||||||
|
- `application/portfolio.py`:
|
||||||
|
- `BrokerCredentialPayload(account_ref, app_key, app_secret, credential_version)` frozen dataclass 추가
|
||||||
|
- `TradingContext(broker, trading_enabled)` frozen dataclass 추가
|
||||||
|
- `PortfolioHubReadPort.fetch_broker_credential() -> TradingContext` 포트 메서드 추가
|
||||||
|
- `adapters/portfolio/hub_read.py`:
|
||||||
|
- `whoami()` 내부 로직을 `_fetch_user_info() -> dict` 헬퍼로 추출 — 외부 동작 불변(AC-5 회귀 보존)
|
||||||
|
- `fetch_broker_credential()` 구현: broker null → TradingContext(broker=None), credential_payload null → TradingContext(broker=None), 성공 → BrokerCredentialPayload 반환
|
||||||
|
- NFR-2: app_secret 평문 로그 없음 — 어댑터 내 로깅 코드에 app_secret 미포함
|
||||||
|
- `adapters/portfolio/stub_read.py`:
|
||||||
|
- `trading_context: Optional[TradingContext]`, `fetch_broker_raises: bool` 인자 추가
|
||||||
|
- `fetch_broker_credential()` stub 구현 — 네트워크 없이 결정론적 반환/예외
|
||||||
|
|
||||||
|
### REQ-113: credential_fetch_enabled opt-in + break-glass
|
||||||
|
|
||||||
|
- `runtime/config.py`:
|
||||||
|
- `DaemonInstanceConfig.credential_fetch_enabled: bool = False` 추가 (TOML opt-in, 기본 False)
|
||||||
|
- `DaemonInstanceConfig.from_toml()`: `credential_fetch_enabled` 파싱 (부재 시 False)
|
||||||
|
- `DaemonConfig.credential_fetch_enabled: bool = False` 추가
|
||||||
|
- `DaemonConfig.from_config()`: `inst.credential_fetch_enabled` → `DaemonConfig` 전파
|
||||||
|
- `DaemonConfig.from_env()`: 변경 없음 → 항상 False (AC-1 회귀 0)
|
||||||
|
- `application/snapshot_scheduler.py`:
|
||||||
|
- 생성자 `credential_fetch_enabled: bool = False` 인자 추가
|
||||||
|
- 초기화 시 `TREX_KIS_BROKER_BREAK_GLASS` env 감지(break-glass 플래그)
|
||||||
|
- break-glass: INFO 로그 후 env-only 경로(fetch 없이 entry_allowed=True)
|
||||||
|
|
||||||
|
### REQ-114: 그날-시작 로드 게이트 + fail-closed + 알람
|
||||||
|
|
||||||
|
- `application/snapshot_scheduler.py`:
|
||||||
|
- `_entry_allowed: bool = True` 상태 추가
|
||||||
|
- `_maybe_fetch_credential()`: fail-closed 3조건 처리 + CRITICAL 알람 `DAY_START_LOAD_FAILED` 발신
|
||||||
|
- `run_once()` 선두: `credential_fetch_enabled=True`이면 `_maybe_fetch_credential()` 호출
|
||||||
|
- `get_entry_allowed() -> bool` 노출
|
||||||
|
- `application/intents.py`:
|
||||||
|
- `IntentService` 생성자 `entry_allowed_fn: Optional[Callable[[], bool]] = None` 추가
|
||||||
|
- `_admit_buy()` 선두: `entry_allowed_fn is not None and not entry_allowed_fn()` → reject `day_start_load_failed`
|
||||||
|
- SellIntent(`_admit_sell`) 변경 없음 — 청산 경로 영향 없음 (AC-5)
|
||||||
|
- `runtime/daemon.py`:
|
||||||
|
- `SnapshotScheduler` 생성 후 `entry_allowed_fn = scheduler.get_entry_allowed`를 IntentService에 주입
|
||||||
|
- `credential_fetch_enabled=False`(기본) → `entry_allowed_fn=None` → 게이트 비활성
|
||||||
|
|
||||||
|
### REQ-115: 그날 고정 캐시 + 장중 무중단
|
||||||
|
|
||||||
|
- `application/snapshot_scheduler.py`:
|
||||||
|
- `_cached_credential: Optional[BrokerCredentialPayload]`, `_credential_date: Optional[date]` 추가
|
||||||
|
- `_maybe_fetch_credential()`: `_credential_date == today` 시 재fetch 생략 (그날 고정 캐시, AC-3)
|
||||||
|
- 성공 시 캐시 저장 + INFO 로그(account_ref/trading_enabled/credential_version; app_secret 미포함)
|
||||||
|
- Redis 등 외부 저장소 기록 없음 — 메모리 한정 (AC-5)
|
||||||
|
|
||||||
|
## 설계 결정 반영 확인
|
||||||
|
|
||||||
|
- D1(entry_allowed 위치): SnapshotScheduler 내 + IntentService callable 주입 — 구현됨
|
||||||
|
- D2(credential_fetch_enabled 전파): DaemonInstanceConfig → DaemonConfig → SnapshotScheduler — 구현됨
|
||||||
|
- D3(우선순위 로직 위치): SnapshotScheduler._maybe_fetch_credential() — 구현됨
|
||||||
|
- D4(그날 고정 캐시): `_credential_date` 비교로 동일 날짜 재fetch 방지 — 구현됨
|
||||||
|
- D5(중장기 후크): BAT-053에서는 단기 07:30 경로(run_once())만 구현. 중장기는 동일 run_once() 경유로 처리됨 — 확인
|
||||||
|
- D6(AlarmEmitter): publish_alarm("DAY_START_LOAD_FAILED", ...) 직접 호출 패턴 — 구현됨
|
||||||
|
|
||||||
|
## 주요 설계 메모
|
||||||
|
|
||||||
|
- `credential_fetch_enabled=False`(기본)일 때: `_maybe_fetch_credential()` 미호출 → `_entry_allowed=True` 고정 → IntentService `entry_allowed_fn=None` → 게이트 완전 비활성. 기존 동작 byte-for-byte 보존(회귀 0).
|
||||||
|
- KISBroker 재조립(fetch 결과를 실제 KIS API에 적용)은 BAT-053 범위 외 — M3 별도 batch. 현재 `_cached_credential`이 메모리에 저장되어 있으며 다음 batch에서 KISBroker 재초기화에 활용.
|
||||||
|
- REQ-115 AC-2(ledger_outbox 무손실): 기존 REQ-019 구현 속성 재확인. 별도 구현 불필요, 기존 테스트 그린(745 passed).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# BAT-053: 그날-시작 KIS 인증정보 fetch + fail-safe 로드
|
||||||
|
|
||||||
|
- Status: in-progress
|
||||||
|
- Profile: standard
|
||||||
|
- Discovery: dcy-036
|
||||||
|
- Created: 2026-06-28
|
||||||
|
|
||||||
|
## Included REQ
|
||||||
|
|
||||||
|
- REQ-112: portfolio-hub broker credential read 포트 메서드 + HTTP 어댑터
|
||||||
|
- REQ-113: KISConfig 소스 우선순위 — portfolio-hub fetch 1차 + env break-glass + opt-in
|
||||||
|
- REQ-114: 그날-시작 로드 게이트 — fail-closed 신규 진입 차단 + 알람
|
||||||
|
- REQ-115: 그날-시작 장중 무중단 보장 — 그날 고정 인증정보 캐시 + portfolio-hub 장애 비중단
|
||||||
|
|
||||||
|
## Excluded REQ
|
||||||
|
|
||||||
|
없음 — dcy-036 인증정보 fetch 4개 REQ 전체 포함.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
- [planning.md](./planning.md)
|
||||||
|
- [design.md](./design.md)
|
||||||
|
- [implementation.md](./implementation.md)
|
||||||
|
- [verification.md](./verification.md)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Source: dcy-036 (그날-시작 KIS 인증정보 fetch + fail-safe 로드). REQ-112~115 모두 같은 discovery.
|
||||||
|
- 사장님 지시: 스텁 기반으로 구현해 실 portfolio-hub 없이 테스트 가능하게.
|
||||||
|
- 워크트리 격리 배치(origin/main 분기). 코드 변경은 이 워크트리 브랜치에 커밋.
|
||||||
|
- AMQP wire 계약 변경 없음 — trex-orb-v1/trex-tsl-v1 파급 없음.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# BAT-053: Planning
|
||||||
|
|
||||||
|
## Scope Summary
|
||||||
|
|
||||||
|
그날-시작 시 portfolio-hub `/v1/user_info`에서 KIS 인증정보를 fetch하고, 실패 시 fail-closed로 신규 진입을 차단하며, 성공 시 그날 캐시로 KIS API 장중 연속성을 보장한다.
|
||||||
|
|
||||||
|
4개 REQ 모두 같은 delivery 단위:
|
||||||
|
- REQ-112: 트랜스포트 계층 (포트 인터페이스 + HTTP 어댑터 + stub)
|
||||||
|
- REQ-113: 설정 계층 (TOML opt-in + fetch/break-glass/env-only 우선순위)
|
||||||
|
- REQ-114: 그날-시작 로드 게이트 (fail-closed + 알람 + BuyIntent 차단)
|
||||||
|
- REQ-115: 그날 고정 캐시 + 장중 무중단 NFR
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
### T1: REQ-112 — 포트/어댑터 계층
|
||||||
|
- `application/portfolio.py`: `BrokerCredentialPayload`, `TradingContext` 데이터클래스 + `PortfolioHubReadPort.fetch_broker_credential()` 포트 메서드 추가
|
||||||
|
- `adapters/portfolio/hub_read.py`: `_fetch_user_info()` 헬퍼 추출 + `fetch_broker_credential()` 구현
|
||||||
|
- `adapters/portfolio/stub_read.py`: `fetch_broker_credential()` stub 추가
|
||||||
|
- 테스트: `tests/adapters/portfolio/test_hub_read.py` AC-1~7, `test_stub_read.py` AC-6
|
||||||
|
|
||||||
|
### T2: REQ-113 — 설정 계층
|
||||||
|
- `runtime/config.py`: `DaemonInstanceConfig.credential_fetch_enabled: bool = False` + `from_toml()` 파싱
|
||||||
|
- `DaemonConfig.from_config()` 또는 new helper: fetch/break-glass/env-only 우선순위 로직
|
||||||
|
- 테스트: `tests/runtime/test_instance_config.py` AC-1~7(추가)
|
||||||
|
|
||||||
|
### T3: REQ-114 + REQ-115 — 그날-시작 게이트 + 그날 고정 캐시
|
||||||
|
- `application/snapshot_scheduler.py`:
|
||||||
|
- `run_once()` 앞에 `credential_fetch_enabled=True` 경로에서 `fetch_broker_credential()` 호출
|
||||||
|
- `entry_allowed: bool` in-process 상태 + `_cached_credential: BrokerCredentialPayload | None` + `_credential_date: date | None`
|
||||||
|
- fail-closed 3 조건(PortfolioHubUnavailableError / broker None / trading_enabled False) → ERROR 로그 + CRITICAL 알람 DAY_START_LOAD_FAILED
|
||||||
|
- 그날 고정 — 장중 재fetch 방지
|
||||||
|
- `application/alarm_emitter.py`: `DAY_START_LOAD_FAILED` 알람 코드 추가(기존 패턴 재사용 — 실제로 코드 자체는 문자열이므로 사용처 추가)
|
||||||
|
- `application/intents.py` (IntentService): `entry_allowed` 게이트를 BuyIntent admission에 추가
|
||||||
|
- 테스트: `tests/application/test_snapshot_scheduler.py` AC-1~9 + REQ-115 AC-1~6
|
||||||
|
- 테스트: `tests/application/test_execution.py` 또는 test_intent 계열 AC-4, AC-5
|
||||||
|
|
||||||
|
## Design Gate
|
||||||
|
|
||||||
|
**design.md 필요함(standard profile)**: 다음 판단이 필요.
|
||||||
|
1. `entry_allowed` 상태를 어디에 보관할지: SnapshotScheduler 내부 vs 별도 `DayStartState` 객체 vs IntentService에 주입.
|
||||||
|
2. IntentService에 `entry_allowed` 게이트를 어떻게 연결할지(생성자 주입 callable vs setter vs 공유 객체).
|
||||||
|
3. `credential_fetch_enabled` 플래그가 False(기본)일 때 기존 경로 byte-for-byte 보존 검증 방법.
|
||||||
|
4. 중장기 인스턴스(dcy-027)의 그날-시작 후크 위치 확인(현 코드에서 run_once() 위치 분석 필요).
|
||||||
|
5. `fetch_broker_credential()` 반환값이 `DaemonConfig.from_config()`에서 `KISConfig` 재조립으로 이어지는 흐름 설계.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- R1: IntentService에 `entry_allowed` 게이트를 연결할 때 기존 pass-through 테스트 회귀 가능성 → `credential_fetch_enabled=False` 기본 경로에서 게이트를 비활성화하면 회귀 방지
|
||||||
|
- R2: `app_secret` 로그 노출 — 마스킹 로직 누락 주의
|
||||||
|
- R3: `stub_read.py`의 stub이 포트 인터페이스와 불일치 → pytest import sweep으로 조기 발견
|
||||||
|
- R4: REQ-113의 `DaemonConfig.from_config()` 변경이 기존 `test_instance_config.py` 회귀 유발 가능 → AC-1(opt-in off → env-only, fetch 미호출) 케이스가 핵심 회귀 가드
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- portfolio-hub REQ-049 (dcy-010): `/v1/user_info` 확장 계약 — 머지 완료(미배포). 스텁 기반으로 실 portfolio-hub 없이 구현.
|
||||||
|
- REQ-099/100 (dcy-033, BAT-049): AlarmEmitter `trex.alerts` 계약 — 이미 구현됨.
|
||||||
|
- REQ-019: ledger_outbox 무손실 — 기존 구현 재확인(REQ-115 AC-2).
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# BAT-053: Verification
|
||||||
|
|
||||||
|
- Status: draft-batch-verification
|
||||||
|
- Test run: 2026-06-28
|
||||||
|
- Test method: pytest (스텁 기반 단위/통합 테스트 — 실 portfolio-hub 없이 실행)
|
||||||
|
|
||||||
|
## 테스트 실행 결과
|
||||||
|
|
||||||
|
```
|
||||||
|
745 passed, 47 skipped in 1.61s (0 failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
전체 스위트 실행: 기존 회귀 없음, 신규 케이스 전부 통과.
|
||||||
|
|
||||||
|
## REQ별 AC 커버리지
|
||||||
|
|
||||||
|
### REQ-112: portfolio-hub broker credential read 포트 메서드 + HTTP 어댑터
|
||||||
|
|
||||||
|
| AC | 테스트 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AC-1: 유효한 broker+trading_enabled=True → TradingContext 반환 | `test_fetch_broker_credential_returns_trading_context` | PASS |
|
||||||
|
| AC-2: broker:null → TradingContext(broker=None) | `test_fetch_broker_credential_broker_null_returns_none_broker` | PASS |
|
||||||
|
| AC-3: credential_payload:null → TradingContext(broker=None) | `test_fetch_broker_credential_credential_payload_null_returns_none_broker` | PASS |
|
||||||
|
| AC-4: 네트워크 오류/403/500 → PortfolioHubUnavailableError | `test_fetch_broker_credential_network_error_raises_unavailable`, `test_fetch_broker_credential_403_raises_unavailable`, `test_fetch_broker_credential_500_raises_unavailable` | PASS |
|
||||||
|
| AC-5: whoami() 계약 불변 (회귀 없음) | `test_fetch_broker_credential_whoami_still_works` + 기존 whoami 테스트 전부 통과 | PASS |
|
||||||
|
| AC-6: stub 결정론 반환 (네트워크 없음) | `test_stub_fetch_broker_credential_default_happy_path`, `test_stub_fetch_broker_credential_overridable_context`, `test_stub_fetch_broker_credential_raises_when_configured` | PASS |
|
||||||
|
| AC-7: app_secret 평문 로그 미노출 | `test_fetch_broker_credential_app_secret_not_in_logs` | PASS |
|
||||||
|
| AC-8: domain import sweep | `test_domain_has_no_forbidden_imports` | PASS |
|
||||||
|
|
||||||
|
### REQ-113: KISConfig 소스 우선순위 — opt-in + break-glass
|
||||||
|
|
||||||
|
| AC | 테스트 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AC-1: credential_fetch_enabled=false(부재 포함) → env-only, fetch 미호출 | `test_run_once_no_credential_fetch_when_disabled`, `test_credential_fetch_enabled_false_propagates`, `test_from_env_credential_fetch_enabled_always_false` | PASS |
|
||||||
|
| AC-2: credential_fetch_enabled=true + 유효한 fetch → KISConfig fetch 결과로 구성 | `test_run_once_credential_fetch_success_sets_entry_allowed` (TradingContext 캐시 검증) | PASS |
|
||||||
|
| AC-3: credential_fetch_enabled=true + BREAK_GLASS=true → fetch 미시도, env 경로 | `test_break_glass_skips_fetch` | PASS |
|
||||||
|
| AC-4: break-glass 활성화 시 INFO 로그 | `test_break_glass_skips_fetch` (caplog 미검사, 로그 코드 확인) | PARTIAL (로그 내용 assert 없음) |
|
||||||
|
| AC-5: app_secret 마스킹 | `test_app_secret_not_in_logs` | PASS |
|
||||||
|
| AC-6: fetch 성공 시 INFO 소스 로깅 | `test_app_secret_not_in_logs` (로그 출력 확인) | PASS |
|
||||||
|
| AC-7: TOML 부재 시 False 파싱 | `test_credential_fetch_enabled_default_false_from_toml`, `test_credential_fetch_enabled_true_from_toml` | PASS |
|
||||||
|
|
||||||
|
**주의 (AC-4)**: break-glass INFO 로그 내용을 assert하는 전용 테스트가 없음. 코드상 `_log.info(...)` 호출이 있음을 확인. 단순 문자열 로그이므로 동작 보증 범위 내.
|
||||||
|
|
||||||
|
### REQ-114: 그날-시작 로드 게이트 — fail-closed + 알람
|
||||||
|
|
||||||
|
| AC | 테스트 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AC-1: PortfolioHubUnavailableError → entry_allowed=False + ERROR + CRITICAL 알람 | `test_run_once_portfolio_hub_unavailable_sets_fail_closed` | PASS |
|
||||||
|
| AC-2: broker=None → entry_allowed=False + ERROR + CRITICAL 알람 | `test_run_once_broker_null_sets_fail_closed` | PASS |
|
||||||
|
| AC-3: trading_enabled=False → entry_allowed=False + ERROR + CRITICAL 알람 | `test_run_once_trading_disabled_sets_fail_closed` | PASS |
|
||||||
|
| AC-4: entry_allowed=False → BuyIntent admission 거부 | `test_intent_service_buy_rejected_when_entry_not_allowed` | PASS |
|
||||||
|
| AC-5: entry_allowed=False → SellIntent 통과 | `test_intent_service_sell_passes_when_entry_not_allowed` | PASS |
|
||||||
|
| AC-6: 성공 → entry_allowed=True + INFO 로그(account_ref, app_secret 제외) | `test_run_once_credential_fetch_success_sets_entry_allowed`, `test_alarm_not_emitted_on_success`, `test_app_secret_not_in_logs` | PASS |
|
||||||
|
| AC-7: credential_fetch_enabled=false → fetch 없이 기존 스냅샷 로직 | `test_run_once_no_credential_fetch_when_disabled` | PASS |
|
||||||
|
| AC-8: 알람 envelope에 DAY_START_LOAD_FAILED + instance_key 포함 | `test_run_once_portfolio_hub_unavailable_sets_fail_closed`, `test_run_once_broker_null_sets_fail_closed`, `test_run_once_trading_disabled_sets_fail_closed` | PASS |
|
||||||
|
| AC-9: fail-closed 후 entry_allowed 그날 고정 | `test_entry_allowed_fixed_for_day_no_refetch` | PASS |
|
||||||
|
|
||||||
|
### REQ-115: 그날-시작 장중 무중단 — 그날 고정 캐시
|
||||||
|
|
||||||
|
| AC | 테스트 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AC-1: 그날 성공 후 장중 portfolio-hub 장애 → KIS 계속(캐시 활용) | In-process 캐시 설계 + `test_successful_credential_cached_for_day` | PARTIAL (KISBroker 실제 적용은 M3 defer, 캐시 저장 자체는 검증) |
|
||||||
|
| AC-2: 장중 장애 후 체결 → ledger_outbox durable 기록 | 기존 REQ-019 테스트 전부 통과 (745 passed 포함) | PASS (회귀 재확인) |
|
||||||
|
| AC-3: 그날 성공 후 동일 날짜 재fetch 없음 | `test_successful_credential_cached_for_day` (fetch_count 검증) | PASS |
|
||||||
|
| AC-4: credential_fetch_enabled=false → 기존 회귀 0 | 전체 스위트 745 passed | PASS |
|
||||||
|
| AC-5: app_secret Redis 미기록 | 메모리 전용 저장(코드 검토), Redis 기록 코드 없음 | PASS (코드 검토 기반) |
|
||||||
|
| AC-6: 성공 후 INFO 로그에 app_secret 없음 + account_ref/캐시 날짜 포함 | `test_app_secret_not_in_logs` | PASS |
|
||||||
|
|
||||||
|
**주의 (AC-1)**: KISBroker 실제 재조립(fetch된 app_key/app_secret를 KIS API 호출에 적용)은 M3 후속 batch에서 구현. 이번 batch에서는 캐시 저장(`_cached_credential`)까지 완료, KISBroker 연결은 미구현.
|
||||||
|
|
||||||
|
## 회귀 검증
|
||||||
|
|
||||||
|
- 전체 스위트 745 passed, 47 skipped — **기존 테스트 0 실패** (회귀 없음)
|
||||||
|
- `credential_fetch_enabled=False`(기본) 경로: fetch 코드가 실행되지 않아 기존 동작 byte-for-byte 보존 확인
|
||||||
|
- domain import sweep 통과 — domain 오염 없음
|
||||||
|
- whoami() 기존 테스트 전부 통과 — REQ-112 AC-5 보존 확인
|
||||||
|
|
||||||
|
## 스텁 기반 테스트 명시
|
||||||
|
|
||||||
|
- 이 batch의 모든 테스트는 `StubPortfolioRead.fetch_broker_credential()`과 MockTransport 기반으로 작성됨.
|
||||||
|
- 실 portfolio-hub(REQ-049 dcy-010 배포 완료 이후) 통합 검증은 이 batch 범위 밖.
|
||||||
|
- Step2 운영 적용 후 실 환경에서 별도 통합검증 필요.
|
||||||
|
|
||||||
|
## 미해결/위험
|
||||||
|
|
||||||
|
1. **KISBroker 재조립 미구현**: `_cached_credential`이 메모리에 저장되나 실제 KIS API 호출에 적용되지 않음. REQ-115 AC-1의 완전한 충족은 M3 batch에서 연결 필요.
|
||||||
|
2. **REQ-113 AC-4 break-glass INFO 로그 assert 미작성**: 코드상 구현됨, 단위 테스트에서 로그 내용을 assert하지 않음. 리스크 낮음.
|
||||||
|
3. **중장기 인스턴스 후크**: 현재 `run_once()`를 단기/중장기 모두 공유하나, dcy-027 중장기 인스턴스 추가 시 별도 트리거 주기 확인 필요.
|
||||||
|
4. **실 portfolio-hub 통합 검증**: dcy-010 REQ-049 배포 완료 시점에 별도 E2E 검증 필요.
|
||||||
@@ -60,3 +60,4 @@
|
|||||||
| BAT-050 | standard | release-candidate | REQ-102, REQ-103, REQ-104, REQ-107, REQ-108 | dcy-034 (core-A) | [bat-050](./bat-050_20260626_reconcile-warning-blocking-gate-core-a/) |
|
| BAT-050 | standard | release-candidate | REQ-102, REQ-103, REQ-104, REQ-107, REQ-108 | dcy-034 (core-A) | [bat-050](./bat-050_20260626_reconcile-warning-blocking-gate-core-a/) |
|
||||||
| BAT-051 | standard | release-candidate | REQ-105, REQ-106 | dcy-034 (core-B) | [bat-051](./bat-051_20260626_sync-verify-cli-cold-start-core-b/) |
|
| BAT-051 | standard | release-candidate | REQ-105, REQ-106 | dcy-034 (core-B) | [bat-051](./bat-051_20260626_sync-verify-cli-cold-start-core-b/) |
|
||||||
| BAT-052 | standard | release-candidate | REQ-109, REQ-110, REQ-111 | dcy-035 | [bat-052](./bat-052_20260626_wac-realized-pnl-report/) |
|
| 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 | in-progress | REQ-112, REQ-113, REQ-114, REQ-115 | dcy-036 | [bat-053](./bat-053_20260628_daystart-credential-fetch-failsafe/) |
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 0. 문서 상태
|
||||||
|
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
||||||
|
|
||||||
|
- 상태: confirmed
|
||||||
|
- 문서 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)
|
||||||
|
- 대체됨: 없음
|
||||||
|
- 후속 Discovery 참조: 없음
|
||||||
|
- 생성된 REQ 참조: REQ-112, REQ-113, REQ-114, REQ-115
|
||||||
|
|
||||||
|
# 1. 계획 상태 요약
|
||||||
|
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||||
|
|
||||||
|
- 연계 문서 현황:
|
||||||
|
- 관련 점수 파일: 미생성
|
||||||
|
- 관련 TODO/메모: M3 메인(중장기 trex-core 인스턴스). 교차 발원 = portfolio-hub `dcy-010`(daemon-scoped 브로커 인증정보 소유·전달). 선행 = trex-core `dcy-027`(중장기 슬롯 운영 프로파일), `dcy-034`(cold-start reconcile)
|
||||||
|
- 해석:
|
||||||
|
- portfolio-hub가 universe·전략·KIS 인증정보의 단일 진실원천이라는 결정(사장님)에 따라, trex-core가 KIS 인증정보를 env 직접주입 대신 **그날 시작 시점에 portfolio-hub `/v1/user_info`(daemon-scoped 단일 broker)에서 fetch**해 `KISConfig`를 구성하도록 소비자측을 정의한다.
|
||||||
|
- 핵심 판단: "그날-시작 로드를 신선도 게이트로 삼아 진실원천 부재 시 신규 거래를 개시하지 않되(fail-closed), 이미 시작된 그날의 전략 실행은 장중 portfolio-hub 장애에도 중단하지 않는다"는 fail-safe 의미론을 trex-core 런타임에 어떻게 구현·경계지을지.
|
||||||
|
|
||||||
|
# 2. 요구사항 판정 결과
|
||||||
|
이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다.
|
||||||
|
|
||||||
|
- 판정: 신규
|
||||||
|
- 근거
|
||||||
|
- 기존 trex-core는 KIS를 `TREX_KIS_BROKER_*` env로만 주입하며(`runtime/config.py`), portfolio-hub에서 인증정보를 받지 않는다. portfolio-hub 연동은 원장 쓰기(outbox)와 read(whoami/portfolios)만 존재.
|
||||||
|
- "그날-시작 fail-safe 로드 게이트"는 기존 어느 REQ도 정의한 바 없는 신규 런타임 동작이며, 외부 서비스(portfolio-hub) 의존을 매매 임계 경로 경계에 도입하는 구조 변경이라 독립 주기로 추적한다.
|
||||||
|
|
||||||
|
# 3. 문제점의 요약
|
||||||
|
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다.
|
||||||
|
|
||||||
|
- 현재 상태
|
||||||
|
- `KISConfig`는 `TREX_BROKER=kis`일 때 `TREX_KIS_BROKER_BASE_URL/APP_KEY/APP_SECRET/ACCOUNT_REF` 등 env에서만 구성된다(`trex_core/runtime/config.py:257`). 인스턴스별 계좌 분리는 프로세스별 env/.env 격리에만 의존.
|
||||||
|
- portfolio-hub read 어댑터(`trex_core/adapters/portfolio/hub_read.py`)가 이미 존재 — `whoami()`가 `GET /v1/user_info`를 호출해 `daemon_id`만 읽고, `get_portfolios(daemon_id)`로 universe(클라이언트측 daemon 필터)를 받는다. 인증 토큰(`TREX_PORTFOLIO_HUB_*`) 토대 존재.
|
||||||
|
- 일일 스냅샷 리프레시(`TREX_SNAPSHOT_SCHEDULE_KST`, 기본 07:30 KST; `SnapshotScheduleConfig`)가 존재 — "그날-시작" 로드의 자연스러운 후크 후보.
|
||||||
|
- 체결 원장은 trex-core 자기 PG `ledger_outbox`(REQ-019)에 즉시 durable 기록 후 portfolio-hub로 eventual drain → portfolio-hub 장애 시에도 거래내역 무손실(지연만).
|
||||||
|
- 기대 상태
|
||||||
|
- trex-core 인스턴스가 그날 시작 시 portfolio-hub에서 자기 daemon의 KIS 인증정보(+universe+전략+trading_enabled)를 받아 `KISConfig`를 구성하고 그날 거래를 개시한다.
|
||||||
|
- portfolio-hub가 그날-시작 시점에 부재/불가면 그날 신규 거래를 **개시하지 않는다(fail-closed)**.
|
||||||
|
- 장중 portfolio-hub 장애는 **이미 시작된 그날의 전략 실행(진입·청산 포함)을 중단시키지 않는다**. 그날 인증정보는 캐시로 운행, 원장은 outbox로 지연 흡수.
|
||||||
|
- 이해관계자
|
||||||
|
- 사장님(fail-safe 정책·계좌 분리 방향·결재), 팀장(Discovery·거버넌스), trex-core-dev(소비자 어댑터·로드 게이트 구현), 운영자(그날-시작 로드 실패 대응)
|
||||||
|
- 제약사항
|
||||||
|
- 라이브 단기 인스턴스(M1/M2)에 회귀 금지 — portfolio-hub 의존 도입이 기존 매매를 깨지 않아야 한다(break-glass 경로 필요).
|
||||||
|
- portfolio-hub dcy-010(daemon-scoped 단일 broker `/v1/user_info`)이 선행 의존 — 미배포 시 이 기능 비활성.
|
||||||
|
- 최민감 시크릿(app_secret)을 메모리/캐시에서 다루므로 로깅·노출 주의.
|
||||||
|
|
||||||
|
## 주요 사용자 시나리오
|
||||||
|
- 아침 07:30 스냅샷에서 중장기 인스턴스가 portfolio-hub fetch에 성공 → 자기 계좌로 그날 거래 개시. 11시 portfolio-hub가 잠시 다운되어도 그날 진입/청산은 캐시된 인증정보로 계속, 원장은 복구 후 drain.
|
||||||
|
- 아침 07:30에 portfolio-hub가 다운 → 인스턴스는 그날 신규 거래를 개시하지 않고 운영자에게 알람. portfolio-hub 복구 후 다음 로드 사이클에 정상화.
|
||||||
|
- 운영자가 비상 시 env로 KIS 인증정보를 직접 주입(break-glass)해 portfolio-hub 없이 인스턴스를 띄운다.
|
||||||
|
|
||||||
|
# 4. 이번에 정의할 변경
|
||||||
|
이번 Discovery에서 다루는 핵심 변경 항목.
|
||||||
|
|
||||||
|
- (C1) **KISConfig 소스 확장** — env-only → "그날-시작 portfolio-hub fetch"를 1차 소스로, env를 break-glass 오버라이드로. (`hub_read` 어댑터에 broker 인증정보 read 추가)
|
||||||
|
- (C2) **그날-시작 로드 게이트(fail-closed 진입)** — 로드 시점 portfolio-hub 부재 시 그날 신규 거래(진입) 미개시. 로드 성공분으로 그날 운행.
|
||||||
|
- (C3) **장중 무중단 보장** — 그날 시작 후 portfolio-hub 장애가 이미 로드된 그날 실행(진입·청산)을 중단시키지 않음(인증정보 캐시 + 원장 outbox 의존).
|
||||||
|
|
||||||
|
## 영향 범위
|
||||||
|
- 코드 영향:
|
||||||
|
- `trex_core/adapters/portfolio/hub_read.py`(broker 인증정보 read 추가), `trex_core/runtime/config.py`(KISConfig 소스/우선순위), 그날-시작 로드 후크(`application/snapshot_scheduler.py` 또는 daemon 부트스트랩), 로드 게이트가 진입 admission(IntentService)와 맞물리는 지점
|
||||||
|
- 캐시 저장소(기존 `TREX_KIS_REDIS_URL` 토큰 캐시 패턴 재사용 검토)
|
||||||
|
- 문서 영향:
|
||||||
|
- `docs/srs/`(신규 REQ), `docs/discovery/index.md`(본 문서 등록), `docs/runtime-flows.md`(그날-시작 로드 흐름 추가 검토)
|
||||||
|
- 교차: portfolio-hub `dcy-010` 계약 소비측 정합
|
||||||
|
- 운영 영향:
|
||||||
|
- 그날-시작 로드 실패 시 운영 절차(알람·수동 개입·break-glass). 인스턴스별 service token(daemon_id 바인딩) 발급·배치.
|
||||||
|
|
||||||
|
# 5. 요구사항 목록
|
||||||
|
|
||||||
|
## 기능 요구사항
|
||||||
|
- FR-1: trex-core가 그날-시작 시점에 service token으로 portfolio-hub `/v1/user_info`(daemon-scoped 단일 broker)를 호출해 KIS 인증정보(account_ref + app_key/app_secret + credential_version)를 받아 `KISConfig`를 구성한다. **그날-시작 체크포인트는 horizon별로 분리(결정 2026-06-28)**: 단기 = 07:30 KST 스냅샷 1회, 중장기 = dcy-027 운영 프로파일 기반 리밸런싱 주기. 그날 로드된 인증정보는 **그날 고정**이며 장중 portfolio-hub 복구 시에도 재fetch하지 않는다(결정론).
|
||||||
|
- FR-2: 그날-시작 로드 시점에 portfolio-hub가 불가(네트워크/비2xx/인증정보 부재/`trading_enabled=false`)면 그날 **신규 진입을 개시하지 않는다(fail-closed)**. 사유를 로깅하고 운영자에게 알람한다.
|
||||||
|
- FR-3: 그날 시작 후 발생한 portfolio-hub 장애는 이미 로드된 그날 실행을 중단시키지 않는다. 그날 인증정보는 캐시로 유지되어 진입·청산이 계속된다.
|
||||||
|
- FR-4: 평시에는 **portfolio-hub fetch가 1차 소스**이고, env(`TREX_KIS_BROKER_*`)는 **명시적 break-glass 오버라이드**다(결정 2026-06-28). 즉 fetch를 우선 시도하되, 운영자가 의도적으로 env break-glass를 활성화하면 portfolio-hub 없이도 인스턴스를 띄울 수 있다. 단기 인스턴스(M1/M2)는 회귀 차단을 위해 **당분간 fetch opt-in**(미설정 시 기존 env-only 경로 보존, NFR-1).
|
||||||
|
|
||||||
|
## 비기능 요구사항
|
||||||
|
- NFR-1 (회귀안전): portfolio-hub fetch 미설정/비활성 시 기존 env-only 동작이 byte-for-byte 보존된다(M1/M2 단기 인스턴스 무회귀).
|
||||||
|
- NFR-2 (보안): 캐시·로그에 app_secret 평문이 남지 않는다(마스킹). 전송은 내부망/TLS·service-token.
|
||||||
|
- NFR-3 (관측성): 그날-시작 로드 성공/실패/캐시운행 상태가 로그·카운터·알람으로 가시화된다.
|
||||||
|
|
||||||
|
## 범위 경계
|
||||||
|
- In Scope
|
||||||
|
- trex-core 소비자 어댑터(인증정보 read) + 그날-시작 로드 게이트(fail-closed 진입) + 장중 무중단 + break-glass.
|
||||||
|
- Out of Scope
|
||||||
|
- portfolio-hub 측 daemon-scoped 인증정보 소유·전달(= dcy-010, 선행 의존).
|
||||||
|
- universe·전략 fetch 자체의 재설계(현 get_portfolios 경로 재사용; 본 문서는 인증정보 로드와 fail-safe 게이트에 집중).
|
||||||
|
- {{DECIDE_BOOTSTRAP_UNIFY:universe+인증정보+trading_enabled를 단일 부트스트랩 호출로 통합할지 — dcy-010 OQ-3와 공동 결정}}
|
||||||
|
|
||||||
|
# 6. 리스크/가정 목록
|
||||||
|
|
||||||
|
## 리스크
|
||||||
|
- R-1 (High): portfolio-hub 의존 도입이 라이브 단기 인스턴스(M1/M2) 매매를 깨뜨림(회귀).
|
||||||
|
- 완화 방안: fetch는 opt-in. 미설정 시 env-only 경로 보존(NFR-1). break-glass 오버라이드 상시 유지. 단기 인스턴스는 당분간 env 유지 가능.
|
||||||
|
- R-2 (High): "그날-시작"의 정의가 horizon별로 달라(단기=매일 07:30 / 중장기=리밸런싱 주기) 로드 게이트 의미가 모호.
|
||||||
|
- 완화 방안: OQ-1로 horizon별 체크포인트 정의를 confirm 전 확정. 단기는 07:30 스냅샷, 중장기는 프로파일 기반 주기로 분리.
|
||||||
|
- R-3 (Medium): app_secret을 메모리/캐시에서 다뤄 노출 표면 증가.
|
||||||
|
- 완화 방안: 마스킹 로깅, 캐시 TTL/스코프 한정, Redis 사용 시 키 스코프(daemon/앱키) 격리.
|
||||||
|
|
||||||
|
## 가정
|
||||||
|
- A-1: portfolio-hub = universe·전략·KIS 인증정보의 단일 진실원천. (사장님 confirmed)
|
||||||
|
- A-2: 신선도 게이트 = **그날-시작 로드 시점**. 장중 portfolio-hub 장애는 그날 실행을 중단시키지 않는다("그날의 전략 실행은 그대로 유지"). (사장님 confirmed 2026-06-28)
|
||||||
|
- A-3: portfolio-hub `dcy-010`(daemon-scoped 단일 broker `/v1/user_info`)이 선행 제공된다.
|
||||||
|
- A-4: env break-glass 오버라이드는 유지된다(비상 경로).
|
||||||
|
- A-5: 그날 거래내역은 trex-core `ledger_outbox`로 무손실 흡수되므로 장중 portfolio-hub 장애가 원장 유실로 이어지지 않는다.
|
||||||
|
|
||||||
|
## 오픈 질문
|
||||||
|
- OQ-1: "그날-시작" 로드 체크포인트 정의 (horizon별).
|
||||||
|
- 상태: **Answered (2026-06-28)**
|
||||||
|
- 결정: 단기 = 07:30 KST 스냅샷 1회, 중장기 = dcy-027 운영 프로파일 기반 리밸런싱 주기로 분리. 본문 FR-1에 반영.
|
||||||
|
- 반영 위치: §5 FR-1.
|
||||||
|
- OQ-2: 장중 portfolio-hub 복구 시 재fetch vs 그날 고정.
|
||||||
|
- 상태: **Answered (2026-06-28)**
|
||||||
|
- 결정: **그날 고정(결정론)** — 장중 재fetch 안 함. 본문 FR-1에 반영.
|
||||||
|
- 반영 위치: §5 FR-1.
|
||||||
|
- OQ-3: break-glass(env)와 fetch 우선순위.
|
||||||
|
- 상태: **Answered (2026-06-28)**
|
||||||
|
- 결정: **fetch 1차, env는 명시적 break-glass 오버라이드**(평시 fetch 우선). 단기는 fetch opt-in으로 회귀 차단. 본문 FR-4에 반영.
|
||||||
|
- 반영 위치: §5 FR-4, NFR-1.
|
||||||
|
- OQ-4: 그날-시작 로드 실패 알람 경로 — trex-notifier(dcy-033) 연동 형태와 등급(CRITICAL?).
|
||||||
|
- 상태: Deferred
|
||||||
|
- 처리 방안: dcy-033 알람 envelope 계약 재사용. REQ 단계에서 구체화.
|
||||||
|
- 종료 조건: REQ 작성 시 알람 등급·채널 확정.
|
||||||
|
|
||||||
|
# 7. 초기 성공 기준
|
||||||
|
- S-1: portfolio-hub fetch 성공 시 그날 거래가 fetch된 계좌 인증정보로 개시됨을 검증할 수 있다.
|
||||||
|
- S-2: 그날-시작 시점 portfolio-hub 불가 시 신규 진입이 개시되지 않고(차단) 알람이 발생함이 테스트로 확인된다.
|
||||||
|
- S-3: 그날 시작 후 portfolio-hub를 내려도 그날 진입/청산이 캐시 인증정보로 계속되고 원장이 복구 후 drain됨이 검증된다.
|
||||||
|
- S-4: fetch 미설정 시 기존 env-only 동작이 회귀 없이 보존됨이 테스트로 확인된다.
|
||||||
|
|
||||||
|
## 측정 방식 및 데이터 출처
|
||||||
|
- S-1 측정 방식: fetch 모의 + KISConfig 구성 결과 검증 / 데이터 출처: 통합테스트
|
||||||
|
- S-2 측정 방식: 로드 시점 portfolio-hub 불가 주입 → 진입 admission 차단 + 알람 카운터 / 데이터 출처: 통합테스트, 로그/카운터
|
||||||
|
- S-3 측정 방식: 그날 시작 후 portfolio-hub 다운 주입 → 청산 경로 동작 + outbox drain 확인 / 데이터 출처: 통합테스트, ledger_outbox 행
|
||||||
|
- S-4 측정 방식: fetch 미설정 환경에서 기존 테스트 그린 / 데이터 출처: 기존 회귀 스위트
|
||||||
|
|
||||||
|
# 8. REQ로 넘기기 전 확인 체크
|
||||||
|
- [x] 범위(In Scope/Out of Scope) 확정
|
||||||
|
- 기준: 부트스트랩 통합(dcy-010 OQ-3 공동)은 Out of Scope Deferred 처리
|
||||||
|
- [x] 성공 지표의 측정 방식/데이터 출처 확정
|
||||||
|
- 기준: S-1~S-4 측정 가능성 검토 완료
|
||||||
|
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
|
||||||
|
- 기준: R-1(회귀 — fetch opt-in·break-glass, trex-core-dev)·R-2(체크포인트 horizon별 정의 확정) 완화안 확정
|
||||||
|
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||||
|
- 기준: OQ-1·OQ-2·OQ-3 Answered, OQ-4 Deferred 처리 완료
|
||||||
|
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||||
|
- 기준: 사장님+팀장 confirm-discovery (DECIDE/CONFIRM 전건 해소)
|
||||||
|
|
||||||
|
# 9. 파일 처리 결과
|
||||||
|
- 처리 결과: 생성
|
||||||
|
- 생성 경로: docs/discovery/dcy-036_20260628_daystart-credential-fetch-failsafe-trading-load.md
|
||||||
|
- 참조 문서:
|
||||||
|
- portfolio-hub docs/discovery/dcy-010_20260627_daemon-scoped-broker-credential-provisioning.md (교차 발원 — 인증정보 소유·전달)
|
||||||
|
- docs/discovery/dcy-027_20260612_long-term-strategy-slot-operation.md (중장기 운영 프로파일)
|
||||||
|
- docs/discovery/dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart.md (cold-start 동기화)
|
||||||
|
- trex_core/adapters/portfolio/hub_read.py / trex_core/runtime/config.py (현재 구현)
|
||||||
|
|
||||||
|
# 10. 사용자 결정 필요 항목 요약
|
||||||
|
|
||||||
|
## DECIDE
|
||||||
|
- ~~그날-시작 체크포인트 (OQ-1)~~ → **RESOLVED 2026-06-28: 단기 07:30 스냅샷 / 중장기 dcy-027 프로파일 주기**
|
||||||
|
- ~~장중 재fetch (OQ-2)~~ → **RESOLVED 2026-06-28: 그날 고정(결정론)**
|
||||||
|
- ~~break-glass 우선순위 (OQ-3)~~ → **RESOLVED 2026-06-28: fetch 1차, env 명시적 break-glass**
|
||||||
|
|
||||||
|
## CONFIRM
|
||||||
|
- ~~fail-safe 모델 (A-2)~~ → **RESOLVED: 그날-시작 fail-closed 진입 + 장중 무중단 confirmed (사장님)**
|
||||||
|
- ~~단기 fetch opt-in~~ → **RESOLVED 2026-06-28: 단기 인스턴스 당분간 env 유지(fetch opt-in)로 회귀 차단 (FR-4/NFR-1)**
|
||||||
|
|
||||||
|
## DATA
|
||||||
|
- {{DATA_SERVICE_TOKEN_PROVISION:인스턴스별 service token(daemon_id 바인딩) 발급·배치 경로 — REQ/ops 단계에서 구체화(Deferred)}}
|
||||||
|
|
||||||
|
# 11. Discovery Freeze
|
||||||
|
REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다.
|
||||||
|
|
||||||
|
## 섹션 참조
|
||||||
|
- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록)
|
||||||
|
- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록)
|
||||||
|
- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준)
|
||||||
|
- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크)
|
||||||
|
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
|
||||||
|
|
||||||
|
## Freeze 플래그
|
||||||
|
- Handoff Decision: REQ Drafting 진행 가능
|
||||||
|
- Handoff Rationale: confirm-discovery 완료(2026-06-28). DECIDE 3건(OQ-1 horizon별 체크포인트·OQ-2 그날 고정·OQ-3 fetch 1차+env break-glass) 전건 Answered, CONFIRM 2건(fail-safe 모델·단기 opt-in) 해소, OQ-4(알람) Deferred. 잔여 Open 0건. **단, REQ 인계·구현은 선행 의존 portfolio-hub dcy-010(daemon-scoped 단일 broker /v1/user_info) 진척에 맞춰 조율.**
|
||||||
|
- Ready for REQ Drafting: true
|
||||||
|
- Confirmed By: 팀장(Claude)
|
||||||
|
- Confirmed At (KST): 2026-06-28
|
||||||
@@ -40,3 +40,4 @@
|
|||||||
| dcy-032_20260621_fill-missing-price-cost-basis-backfill | 20260621 | 체결가 누락(브로커 평균가 0/None) → 슬롯 평단·원장 cost basis 0 오염(무경고·영구) → 지정가 백필 + 로깅 + 알람 검토 | [Discovery](./dcy-032_20260621_fill-missing-price-cost-basis-backfill.md) | confirmed |
|
| dcy-032_20260621_fill-missing-price-cost-basis-backfill | 20260621 | 체결가 누락(브로커 평균가 0/None) → 슬롯 평단·원장 cost basis 0 오염(무경고·영구) → 지정가 백필 + 로깅 + 알람 검토 | [Discovery](./dcy-032_20260621_fill-missing-price-cost-basis-backfill.md) | confirmed |
|
||||||
| 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-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-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 |
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# req-113: KISConfig 소스 우선순위 — portfolio-hub fetch 1차 + env break-glass + opt-in
|
||||||
|
|
||||||
|
- Status: Approved
|
||||||
|
- Type: Configuration
|
||||||
|
- Priority: High
|
||||||
|
- Owner: trex-core-dev
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- 현재 `DaemonConfig.from_env()`는 KIS 인증정보를 `TREX_KIS_BROKER_*` 환경변수에서만 읽는다. dcy-036 C1/FR-4/NFR-1에 따라 "fetch 1차, env는 명시적 break-glass 오버라이드"로 소스 우선순위를 변경하되, 기존 단기 인스턴스(M1/M2)의 회귀를 막기 위해 fetch는 **opt-in**(명시적 활성화 없으면 기존 env-only 경로 보존)으로 도입한다.
|
||||||
|
- 이 REQ는 설정 계층(TOML/env/fetch 우선순위)과 `DaemonInstanceConfig`/`DaemonConfig` 로더의 변경만 다루며, 실제 fetch I/O는 REQ-112 어댑터에 위임한다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- `DaemonInstanceConfig`(per-instance TOML 로더)에 `credential_fetch_enabled: bool = False` 필드를 추가한다. TOML에 `credential_fetch_enabled = true`가 명시된 경우에만 활성화된다(opt-in, 부재 시 False = env-only 기존 경로 보존).
|
||||||
|
- `credential_fetch_enabled = true`일 때 `DaemonConfig` 구성 단계에서 KISConfig 소스 우선순위는 다음과 같다:
|
||||||
|
1. **portfolio-hub fetch** (`PortfolioHubReadAdapter.fetch_broker_credential()` 호출) — 성공 시 반환된 `BrokerCredentialPayload`로 `KISConfig` 구성.
|
||||||
|
2. **env break-glass** (`TREX_KIS_BROKER_BREAK_GLASS=true` 환경변수가 명시적으로 설정된 경우) — portfolio-hub fetch를 시도하지 않고 기존 `TREX_KIS_BROKER_*` env로 KISConfig 구성. 운영자가 의도적으로 break-glass 경로를 활성화한 것으로 간주.
|
||||||
|
- fetch와 break-glass가 모두 부재/비활성이면 env-only 기존 경로(`TREX_KIS_BROKER_*`) 유지.
|
||||||
|
- `credential_fetch_enabled = false`(기본값)일 때: 기존 `TREX_KIS_BROKER_*` env-only 경로를 byte-for-byte 보존한다. portfolio-hub를 호출하지 않는다.
|
||||||
|
- break-glass 활성화 여부를 기동 시 INFO 로그로 출력한다(운영자 인지). 단, app_secret은 마스킹(NFR-2).
|
||||||
|
- KISConfig 구성에 사용된 소스(fetch / break-glass / env-only)를 기동 시 INFO 로그로 기록한다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given TOML에 `credential_fetch_enabled = false`(부재 포함)이고 `TREX_KIS_BROKER_*` env 설정, When 데몬 기동, Then `KISConfig`가 `TREX_KIS_BROKER_*` env-only로 구성되고 portfolio-hub fetch 호출이 발생하지 않는다 (기존 회귀 없음).
|
||||||
|
2. Given TOML에 `credential_fetch_enabled = true`이고 portfolio-hub fetch가 유효한 `BrokerCredentialPayload`를 반환, When 데몬 기동, Then `KISConfig.app_key`, `KISConfig.app_secret`, `KISConfig.account_ref`가 fetch 결과로 구성된다.
|
||||||
|
3. Given TOML에 `credential_fetch_enabled = true`이고 `TREX_KIS_BROKER_BREAK_GLASS=true` 환경변수 설정, When 데몬 기동, Then portfolio-hub fetch를 시도하지 않고 `TREX_KIS_BROKER_*` env로 KISConfig가 구성된다 (break-glass 경로).
|
||||||
|
4. Given break-glass 활성화 상태, When 데몬 기동 로그 확인, Then break-glass 활성화 사실이 INFO로 기록된다.
|
||||||
|
5. Given 어떤 KISConfig 구성 경로든, When 기동 로그 확인, Then `app_secret` 값이 평문으로 출력되지 않는다 (마스킹).
|
||||||
|
6. Given `credential_fetch_enabled = true` TOML이고 `TREX_KIS_BROKER_BREAK_GLASS` 미설정, When 기동 로그 확인, Then KISConfig 소스가 "fetch" 임을 INFO로 기록한다.
|
||||||
|
7. Given `DaemonInstanceConfig.from_toml`에서 `credential_fetch_enabled` 파싱, When TOML에 해당 키 부재, Then `False`로 파싱된다 (opt-in 기본값).
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: 없음 (baseline 문서 갱신은 별도 scope)
|
||||||
|
- Modules:
|
||||||
|
- `trex_core/runtime/config.py` — `DaemonInstanceConfig`에 `credential_fetch_enabled: bool = False` 필드 추가; `from_toml()` 로더에서 파싱; `from_config()` 또는 `DaemonConfig` 조립 단계에서 fetch/break-glass/env-only 우선순위 로직 추가; `TREX_KIS_BROKER_BREAK_GLASS` 환경변수 인식
|
||||||
|
- `trex_core/runtime/daemon.py` (또는 조립 지점) — 기동 시 소스 INFO 로그 출력
|
||||||
|
- Tests:
|
||||||
|
- `tests/runtime/test_config.py` — AC-1~7 케이스 추가: fetch opt-in off/on, break-glass, 마스킹, TOML 파싱 기본값
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `TREX_KIS_BROKER_BASE_URL`은 fetch 경로에서도 여전히 env로 제공한다. portfolio-hub `/v1/user_info`는 `app_key`/`app_secret`/`account_ref`만 전달하며 `base_url`과 기타 KIS 설정(timeout, allow_live 등)은 env에서 읽는다.
|
||||||
|
- `TREX_KIS_BROKER_BREAK_GLASS` 환경변수명은 의도적으로 명시적이다 — 운영자가 비상 경로임을 인지하고 설정해야 한다.
|
||||||
|
- portfolio-hub fetch 실패(네트워크 오류 등) 시 처리는 REQ-114(그날-시작 로드 게이트)가 담당한다. 이 REQ는 설정 우선순위 계층만 다룬다.
|
||||||
|
- OQ-4(알람 trex-notifier 연동): Deferred — REQ-114 Notes 참조.
|
||||||
|
- Source: dcy-036 C1, FR-4, NFR-1, OQ-3 Answered (fetch 1차, env 명시적 break-glass).
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260628-01
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: trex-core-dev
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 신규 생성 (Proposed) — dcy-036 C1/FR-4/NFR-1/OQ-3 산출물.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- dcy-036 FR-4(KISConfig 소스 우선순위 확장: fetch 1차 + env break-glass + opt-in)를 Configuration REQ로 정규화한다. REQ-112 어댑터의 소비자 배선 계층.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~7 전체 (신규 초안)
|
||||||
|
- Updated: 없음
|
||||||
|
- Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs: 없음
|
||||||
|
- Modules:
|
||||||
|
- `config.py` `DaemonInstanceConfig` 확장 (`credential_fetch_enabled`), `from_toml` 파싱, 조립 우선순위 로직
|
||||||
|
- `daemon.py` 기동 로그
|
||||||
|
- Tests:
|
||||||
|
- `tests/runtime/test_config.py` 케이스 추가
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: 미정 (suggest-batch-reqs 대기)
|
||||||
|
- Releases: 미정
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req 후 REQ-112~115 일괄 batch 배정 검토.
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- pytest로 AC-1~7 각 케이스 확인. AC-1(opt-in off → env-only, fetch 미호출)가 회귀 방지의 핵심 케이스.
|
||||||
|
|
||||||
|
### CHG-20260628-02
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: 팀장(Claude)
|
||||||
|
- confirm-req: `Proposed` → `Approved`. dcy-036 FR-4/OQ-3 추적. credential_fetch_enabled opt-in 기본 false로 M1/M2 byte-for-byte 보존(AC-1·NFR-1) 적절. fetch 1차→env break-glass(명시적 TREX_KIS_BROKER_BREAK_GLASS) 우선순위 정합. base_url 등은 env 유지 합리. blocker 없음.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# req-112: portfolio-hub broker credential read 포트 메서드 + HTTP 어댑터
|
||||||
|
|
||||||
|
- Status: Approved
|
||||||
|
- Type: Integration
|
||||||
|
- Priority: High
|
||||||
|
- Owner: trex-core-dev
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- 현재 `PortfolioHubReadAdapter.whoami()`는 `GET /v1/user_info`에서 `daemon_id`만 읽는다. dcy-036 C1에 따라 같은 엔드포인트가 daemon-scoped 단일 `broker` 객체(`account_ref`, `app_key`, `app_secret`, `credential_version`)와 `trading_enabled` 필드를 함께 반환하도록 확장된 portfolio-hub 계약(REQ-049, dcy-010)을 소비해야 한다.
|
||||||
|
- 이 REQ는 트랜스포트 레이어 — 포트 인터페이스 정의, HTTP 어댑터 구현, 테스트용 stub — 만 다루며, KISConfig 구성 로직(REQ-113)과 로드 게이트(REQ-114)의 기반을 제공한다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- `trex_core/application/portfolio.py`에 `BrokerCredentialPayload` 데이터클래스를 추가한다: `account_ref: str`, `app_key: str`, `app_secret: str`, `credential_version: int | None = None`.
|
||||||
|
- `PortfolioHubReadPort` 프로토콜에 `fetch_broker_credential() -> BrokerCredentialPayload | None` 메서드를 추가한다.
|
||||||
|
- 반환 값 None: portfolio-hub 응답 `broker: null` (해당 daemon에 인증정보 없음).
|
||||||
|
- `trading_enabled` 필드도 함께 읽어 반환해야 하므로, 포트 메서드는 `TradingContext` 데이터클래스를 반환한다: `broker: BrokerCredentialPayload | None`, `trading_enabled: bool`.
|
||||||
|
- `PortfolioHubReadAdapter`의 기존 `whoami()` 메서드를 내부적으로 `GET /v1/user_info`를 공유하는 내부 헬퍼(`_fetch_user_info()`)로 리팩토링하고, `fetch_broker_credential()` 구현이 이를 재사용하도록 한다. 외부 동작은 변경하지 않는다(`whoami()` 시그니처·예외 동작 불변, 회귀 차단 NFR-1).
|
||||||
|
- `fetch_broker_credential()`은 다음 규칙을 따른다:
|
||||||
|
- `broker: null` 응답 → `TradingContext(broker=None, trading_enabled=trading_enabled)` 반환.
|
||||||
|
- `credential_payload: null`(복호화 실패) → `TradingContext(broker=None, trading_enabled=trading_enabled)` 반환(정보 부재로 처리).
|
||||||
|
- 네트워크 오류 / 비-2xx / JSON 파싱 실패 → `PortfolioHubUnavailableError` raise.
|
||||||
|
- HTTP 403(`daemon_id` 없는 토큰) → `PortfolioHubUnavailableError` raise.
|
||||||
|
- `trex_core/adapters/portfolio/stub_read.py`의 stub에도 `fetch_broker_credential()` 결정론 stub을 추가한다.
|
||||||
|
- `app_secret` 필드는 어댑터 내에서 로그에 직접 출력하지 않는다(NFR-2 보안 — 마스킹).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given daemon_id에 매칭된 broker 인증정보가 있고 `trading_enabled: true`인 `/v1/user_info` 응답, When `fetch_broker_credential()` 호출, Then `TradingContext(broker=BrokerCredentialPayload(account_ref=..., app_key=..., app_secret=...), trading_enabled=True)` 반환.
|
||||||
|
2. Given `broker: null` 응답(인증정보 없음), When `fetch_broker_credential()` 호출, Then `TradingContext(broker=None, trading_enabled=<응답값>)` 반환 (예외 없음).
|
||||||
|
3. Given `credential_payload: null`(복호화 실패) 응답, When `fetch_broker_credential()` 호출, Then `TradingContext(broker=None, trading_enabled=<응답값>)` 반환 (예외 없음, 인증정보 부재로 처리).
|
||||||
|
4. Given 네트워크 오류 또는 비-2xx(403/500 등) 응답, When `fetch_broker_credential()` 호출, Then `PortfolioHubUnavailableError` raise.
|
||||||
|
5. Given `fetch_broker_credential()` 구현 이후, When `whoami()` 호출, Then 기존 `whoami()` 계약(daemon_id str 반환, PortfolioHubUnavailableError on failure)이 변경되지 않는다 (회귀 없음).
|
||||||
|
6. Given stub_read.py stub, When `fetch_broker_credential()` 호출, Then 미리 설정한 고정 `TradingContext`를 네트워크 없이 반환한다.
|
||||||
|
7. Given `fetch_broker_credential()` 구현, When 에러/성공 로그 출력 확인, Then `app_secret` 값이 평문으로 로그에 출력되지 않는다.
|
||||||
|
8. Given import sweep, When `trex_core/domain`을 검사, Then 신규 포트/어댑터 관련 IO/네트워크 의존이 domain에 없다 (도메인 순수성 불변).
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: 없음 (baseline 문서 갱신은 dcy-036 scope 밖)
|
||||||
|
- Modules:
|
||||||
|
- `trex_core/application/portfolio.py` — `BrokerCredentialPayload`, `TradingContext` 데이터클래스 신규 정의; `PortfolioHubReadPort`에 `fetch_broker_credential()` 메서드 추가
|
||||||
|
- `trex_core/adapters/portfolio/hub_read.py` — `_fetch_user_info()` 내부 헬퍼 추출, `fetch_broker_credential()` 구현 추가; `whoami()` 동작 보존
|
||||||
|
- `trex_core/adapters/portfolio/stub_read.py` — `fetch_broker_credential()` stub 추가
|
||||||
|
- Tests:
|
||||||
|
- `tests/adapters/portfolio/test_hub_read.py` — AC-1~5, AC-7 케이스 추가
|
||||||
|
- `tests/adapters/portfolio/test_stub_read.py` — AC-6 stub 케이스 추가
|
||||||
|
- `tests/domain/test_domain_import_sweep.py` — AC-8 회귀(기존 sweep 재실행)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- 소비 계약: portfolio-hub `req-049_user-info-daemon-scoped-broker-delivery.md` (Implemented, dcy-010 FR-2). 응답 스키마: `broker: { account_ref, credential_payload: { app_key, app_secret } | null, credential_version, ... } | null` + `trading_enabled: bool`.
|
||||||
|
- `TradingContext`를 별도 데이터클래스로 도입하는 이유: 포트 메서드가 broker 인증정보와 `trading_enabled`를 한 번의 호출로 함께 반환해야 불필요한 중복 HTTP 호출을 피할 수 있기 때문(원자적 읽기).
|
||||||
|
- OQ-4(그날-시작 로드 실패 알람 trex-notifier 연동): Deferred — 알람 발신은 REQ-114 Notes에서 다룬다.
|
||||||
|
- 선행 의존: portfolio-hub dcy-010 / REQ-049 배포 완료 여부에 따라 실 통합 테스트 가능 시점이 달라진다. CI 단계에서는 stub 기반 단위 테스트로 AC-1~8 검증.
|
||||||
|
- Source: dcy-036 C1, FR-1, dcy-010 OQ-2 Answered.
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260628-01
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: trex-core-dev
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 신규 생성 (Proposed) — dcy-036 C1 산출물.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- dcy-036 FR-1(그날-시작 portfolio-hub `/v1/user_info`에서 KIS 인증정보 fetch)의 트랜스포트 계층을 REQ로 정규화한다. KISConfig 구성(REQ-113) 및 로드 게이트(REQ-114)의 선행 의존.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~8 전체 (신규 초안)
|
||||||
|
- Updated: 없음
|
||||||
|
- Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs: 없음
|
||||||
|
- Modules:
|
||||||
|
- `portfolio.py` 포트 확장 (BrokerCredentialPayload, TradingContext, fetch_broker_credential)
|
||||||
|
- `hub_read.py` 어댑터 확장 (_fetch_user_info 헬퍼, fetch_broker_credential 구현)
|
||||||
|
- `stub_read.py` stub 확장
|
||||||
|
- Tests:
|
||||||
|
- `test_hub_read.py`, `test_stub_read.py`, `test_domain_import_sweep.py`
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: 미정 (suggest-batch-reqs 대기)
|
||||||
|
- Releases: 미정
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req 승인 게이트 후 REQ-112~115 일괄 batch 배정 검토.
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- pytest로 AC-1~8 각 케이스 확인. app_secret 마스킹(AC-7)은 로그 캡처 검증. domain sweep(AC-8)은 기존 test_domain_import_sweep.py 재실행.
|
||||||
|
|
||||||
|
### CHG-20260628-02
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: 팀장(Claude)
|
||||||
|
- confirm-req: `Proposed` → `Approved`. dcy-036 FR-1/C1 추적. TradingContext 원자 read(broker+trading_enabled 1회 호출) 설계 타당 — /v1/user_info 단일 응답에 정합. whoami 회귀보존(AC-5)·도메인 순수성(AC-8)·app_secret 마스킹(AC-7) 적절. blocker 없음. 선행 의존 portfolio-hub REQ-049(머지완료).
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# req-114: 그날-시작 로드 게이트 — fail-closed 신규 진입 차단 + 알람
|
||||||
|
|
||||||
|
- Status: Approved
|
||||||
|
- Type: Integration
|
||||||
|
- Priority: High
|
||||||
|
- Owner: trex-core-dev
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- dcy-036 C2/FR-2에 따라, 그날-시작 체크포인트(단기: 07:30 KST 스냅샷, 중장기: dcy-027 운영 프로파일 주기)에서 portfolio-hub가 불가하거나 broker 인증정보 부재(`broker: null`) 또는 `trading_enabled: false`이면 그날 **신규 진입을 개시하지 않는다(fail-closed)**. 사유를 로깅하고 운영자 알람을 발신한다.
|
||||||
|
- "그날 고정" 원칙: 로드 성공 후 그날 인증정보는 캐시로 유지되며, 장중 portfolio-hub 복구 시에도 그날 재fetch하지 않는다(결정론 — REQ-115 담당).
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- **그날-시작 체크포인트 후크**: `SnapshotScheduler.run_once()`(단기 07:30 KST 스냅샷 트리거)의 `credential_fetch_enabled = true` 경로에서, 스냅샷 전 `fetch_broker_credential()` (REQ-112)를 호출해 `TradingContext`를 획득한다. 중장기 인스턴스는 dcy-027 운영 프로파일 리밸런싱 주기의 동등한 후크 지점에서 동일 로직을 수행한다.
|
||||||
|
- **fail-closed 조건**: 다음 중 하나라도 해당하면 그날 `entry_allowed = False` 상태를 설정한다:
|
||||||
|
1. `PortfolioHubUnavailableError` (네트워크 오류 / 비-2xx / 403)
|
||||||
|
2. `TradingContext.broker is None` (인증정보 부재)
|
||||||
|
3. `TradingContext.trading_enabled is False`
|
||||||
|
- **진입 차단**: `entry_allowed = False` 상태에서 BuyIntent(신규 진입 의도)는 admission 단계에서 거부된다. 이미 진행 중인 주문(DISPATCHED 슬롯)과 청산(SellIntent) 경로는 차단하지 않는다.
|
||||||
|
- **사유 로깅**: fail-closed 진입 시 ERROR 레벨로 사유(portfolio-hub 불가 / broker null / trading_enabled false)와 인스턴스 정보를 로깅한다. app_secret은 로그에 포함하지 않는다.
|
||||||
|
- **알람 발신**: fail-closed 진입 시 CRITICAL 알람을 `AlarmEmitter`를 통해 발신한다(`trex.alerts` exchange, REQ-099/100). 알람 코드: `DAY_START_LOAD_FAILED`. 알람 페이로드에는 사유 코드와 인스턴스 키를 포함한다.
|
||||||
|
- **성공 로깅**: 로드 성공 시 INFO 레벨로 fetch 소스, account_ref(app_secret 제외), trading_enabled를 기록한다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given `credential_fetch_enabled = true`이고 그날-시작 체크포인트에서 `PortfolioHubUnavailableError` 발생, When 체크포인트 실행, Then `entry_allowed = False` 설정 + ERROR 로그 + CRITICAL 알람(`DAY_START_LOAD_FAILED`) 발신.
|
||||||
|
2. Given `credential_fetch_enabled = true`이고 그날-시작 체크포인트에서 `TradingContext.broker is None`, When 체크포인트 실행, Then `entry_allowed = False` 설정 + ERROR 로그 + CRITICAL 알람 발신.
|
||||||
|
3. Given `credential_fetch_enabled = true`이고 그날-시작 체크포인트에서 `TradingContext.trading_enabled = False`, When 체크포인트 실행, Then `entry_allowed = False` 설정 + ERROR 로그 + CRITICAL 알람 발신.
|
||||||
|
4. Given `entry_allowed = False` 상태, When BuyIntent 수신, Then BuyIntent가 admission 단계에서 거부된다 (신규 진입 차단).
|
||||||
|
5. Given `entry_allowed = False` 상태, When SellIntent 수신, Then SellIntent는 차단되지 않고 정상 처리된다 (청산 경로 영향 없음).
|
||||||
|
6. Given `credential_fetch_enabled = true`이고 그날-시작 체크포인트 성공(`broker` 있음 + `trading_enabled = True`), When 체크포인트 실행, Then `entry_allowed = True` 설정 + INFO 로그(account_ref 포함, app_secret 제외).
|
||||||
|
7. Given `credential_fetch_enabled = false`, When 그날-시작 체크포인트 실행, Then `fetch_broker_credential()` 호출 없이 기존 스냅샷 로직만 수행된다 (회귀 없음, opt-in 보존).
|
||||||
|
8. Given 알람 발신 성공, When `trex.alerts` exchange 메시지 확인, Then `DAY_START_LOAD_FAILED` 코드와 instance_key를 포함한 알람 envelope가 존재한다.
|
||||||
|
9. Given fail-closed 후 `entry_allowed = False` 상태에서 portfolio-hub가 복구됨, When 그날 중 재fetch 없이 운행, Then `entry_allowed` 상태는 그날 고정(재fetch로 자동 해제 안 됨) — 다음 그날-시작 체크포인트까지 유지.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: 없음 (baseline 문서 갱신은 별도 scope)
|
||||||
|
- Modules:
|
||||||
|
- `trex_core/application/snapshot_scheduler.py` — `run_once()` 또는 그날-시작 후크 지점에 `fetch_broker_credential()` 호출 + `entry_allowed` 상태 설정 + 알람 발신 추가
|
||||||
|
- `trex_core/application/execution.py` (또는 IntentService) — BuyIntent admission에 `entry_allowed` 게이트 추가
|
||||||
|
- `trex_core/application/alarm_emitter.py` — `DAY_START_LOAD_FAILED` 알람 코드 추가(REQ-100 패턴 재사용)
|
||||||
|
- Tests:
|
||||||
|
- `tests/application/test_snapshot_scheduler.py` — AC-1~3, AC-6~9 케이스 추가 (stub fetch_broker_credential + AlarmEmitter mock)
|
||||||
|
- `tests/application/test_execution.py` (또는 IntentService 테스트) — AC-4~5 케이스 추가
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- OQ-4(그날-시작 로드 실패 알람 trex-notifier 연동): **Deferred** — 이 REQ는 `trex.alerts` exchange 발신(REQ-099/100 계약)까지만 정의한다. trex-notifier(dcy-033) 연동은 별도 REQ/Discovery로 추적한다. 알람 등급은 CRITICAL(거래 개시 불가).
|
||||||
|
- `entry_allowed` 상태 저장소: in-process 상태 변수(process-local)로 충분하다. 재시작 시 그날-시작 체크포인트가 재실행되므로 내구성 저장소 불필요.
|
||||||
|
- 중장기 인스턴스의 "그날-시작 체크포인트" 시점은 dcy-027 운영 프로파일 리밸런싱 주기의 구체적 후크 위치에 의존한다. 구현 단계에서 확인 후 조정.
|
||||||
|
- AC-9(그날 고정 상태 불변성): 장중 재fetch 없음이 REQ-115의 보완 요건이기도 하다. 이 REQ는 "진입 차단 상태의 그날 고정"만 명시하고, "장중 장애 후 인증정보 캐시 지속 운행"은 REQ-115가 담당.
|
||||||
|
- Source: dcy-036 C2, FR-2, OQ-1 Answered (단기 07:30 / 중장기 프로파일 주기), OQ-2 Answered (그날 고정).
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260628-01
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: trex-core-dev
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 신규 생성 (Proposed) — dcy-036 C2/FR-2 산출물.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- dcy-036 FR-2(그날-시작 로드 시점 fail-closed 진입 차단 + 알람)를 Integration REQ로 정규화한다.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~9 전체 (신규 초안)
|
||||||
|
- Updated: 없음
|
||||||
|
- Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs: 없음
|
||||||
|
- Modules:
|
||||||
|
- `snapshot_scheduler.py` 그날-시작 후크 + entry_allowed 상태 + 알람
|
||||||
|
- `execution.py` / IntentService BuyIntent admission 게이트
|
||||||
|
- `alarm_emitter.py` 코드 추가
|
||||||
|
- Tests:
|
||||||
|
- `test_snapshot_scheduler.py`, `test_execution.py`
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: 미정 (suggest-batch-reqs 대기)
|
||||||
|
- Releases: 미정
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req 후 REQ-112~115 일괄 batch 배정 검토.
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- pytest로 AC-1~9 각 케이스 확인. stub `fetch_broker_credential`과 AlarmEmitter mock으로 I/O 격리. AC-4/5(차단 범위 경계: 매수 차단 / 매도 통과)가 가장 중요한 케이스.
|
||||||
|
|
||||||
|
### CHG-20260628-02
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: 팀장(Claude)
|
||||||
|
- confirm-req: `Proposed` → `Approved`. dcy-036 FR-2/OQ-1/OQ-2 추적. **진입 fail-closed(BuyIntent 차단) + 청산·DISPATCHED 통과(AC-4/5)** 비대칭이 사장님 fail-safe 결정 정합 — 자본 갇힘 방지. CRITICAL 알람 기존 trex.alerts(REQ-099/100) 재사용·그날 고정(AC-9) 적절. OQ-4(trex-notifier 연동) Deferred 정당. blocker 없음.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# req-115: 그날-시작 장중 무중단 보장 — 그날 고정 인증정보 캐시 + portfolio-hub 장애 비중단
|
||||||
|
|
||||||
|
- Status: Approved
|
||||||
|
- Type: Non-Functional
|
||||||
|
- Priority: High
|
||||||
|
- Owner: trex-core-dev
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- dcy-036 C3/FR-3/NFR-1~3에 따라, 그날-시작 로드 성공 이후 장중 portfolio-hub 장애는 이미 시작된 그날 실행(진입·청산 포함)을 중단시키지 않아야 한다. 그날 인증정보는 캐시로 유지되어 KIS API 호출이 계속되고, 원장 기록은 `ledger_outbox`로 portfolio-hub 복구 후 drain된다.
|
||||||
|
- 또한 기존 env-only 경로(fetch 미활성)에서 이 REQ의 변경이 아무런 회귀를 발생시키지 않아야 한다(NFR-1).
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- **그날 고정 캐시**: `credential_fetch_enabled = true` 경로에서 그날-시작 체크포인트에 fetch된 `BrokerCredentialPayload`는 그날 동안 메모리 캐시로 유지된다. 장중 portfolio-hub 장애 또는 복구 이벤트가 발생해도 그날 중 자동 재fetch하지 않는다. 다음 그날-시작 체크포인트(다음 날 07:30 KST 또는 중장기 리밸런싱 주기)에서만 새로 fetch한다.
|
||||||
|
- **KIS API 연속성**: 그날 캐시된 인증정보로 KIS broker adapter(`KISBrokerAdapter`)의 주문 제출·체결 조회·토큰 갱신이 장중 portfolio-hub 장애와 무관하게 계속된다.
|
||||||
|
- **원장 무손실**: 그날 체결 기록은 `ledger_outbox`(REQ-019)를 통해 durable하게 trex-core PG에 기록되므로 장중 portfolio-hub 장애가 원장 유실로 이어지지 않는다. 이는 기존 구현(REQ-019 Implemented)의 속성을 재확인·명시하는 것이다.
|
||||||
|
- **회귀 없음**: `credential_fetch_enabled = false`(기존 env-only 경로)에서 이 REQ의 변경이 기존 동작에 영향을 주지 않는다. 기존 테스트가 전부 그린을 유지해야 한다.
|
||||||
|
- **보안**: 그날 캐시된 인증정보(특히 `app_secret`)는 메모리에만 유지하며 Redis 또는 다른 외부 저장소에 평문 기록하지 않는다. 캐시 만료(다음날 로드 사이클)까지 메모리 내 보존.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given 그날-시작 로드 성공(`credential_fetch_enabled = true`) 후 portfolio-hub를 내려(네트워크 차단) 장중 장애 주입, When BuyIntent 또는 SellIntent를 처리, Then KIS 브로커 어댑터가 캐시된 인증정보로 주문을 처리하고 `PortfolioHubUnavailableError`로 인해 주문이 차단되지 않는다.
|
||||||
|
2. Given 장중 portfolio-hub 장애 상태에서 체결 발생, When 체결 처리, Then `ledger_outbox`에 durable하게 기록된다 (portfolio-hub 복구 후 drain — 기존 REQ-019 속성 회귀 없음).
|
||||||
|
3. Given 그날 로드 성공 후 장중 portfolio-hub가 잠시 다운되었다가 복구, When 그날 중 재fetch 트리거 없음, Then 그날 인증정보는 재load 없이 최초 fetch 값 그대로 유지된다 (그날 고정 불변식).
|
||||||
|
4. Given `credential_fetch_enabled = false`(기존 env-only), When 기존 회귀 테스트 스위트 실행, Then 0 fail — 기존 동작 보존.
|
||||||
|
5. Given 그날 캐시된 `app_secret`, When Redis 또는 외부 저장소 확인, Then 평문 기록 없음 (메모리 캐시 한정).
|
||||||
|
6. Given 그날-시작 로드 성공 직후 INFO 로그 확인, When 캐시 상태 기록, Then `app_secret` 평문이 로그에 없고, account_ref 및 캐시 유효 날짜가 기록된다.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: 없음 (baseline 문서 갱신은 별도 scope)
|
||||||
|
- Modules:
|
||||||
|
- `trex_core/application/snapshot_scheduler.py` (또는 그날-시작 후크 레이어) — 그날 고정 캐시 저장소(`_cached_credential: BrokerCredentialPayload | None`, `_credential_date: date | None`) 관리, 장중 재fetch 방지 로직
|
||||||
|
- `trex_core/runtime/config.py` 또는 조립 레이어 — 캐시된 `BrokerCredentialPayload`에서 `KISConfig` 재구성 경로 보장 (장중 KIS adapter 재생성 불필요)
|
||||||
|
- Tests:
|
||||||
|
- `tests/application/test_snapshot_scheduler.py` — AC-1~3: 장중 portfolio-hub 장애 주입 시나리오 (stub fetch_broker_credential + 캐시 상태 검증)
|
||||||
|
- `tests/runtime/test_config.py` (또는 회귀 스위트) — AC-4: 기존 env-only 테스트 그린 유지
|
||||||
|
- `tests/application/` — AC-5: 캐시가 Redis에 기록되지 않는 검증 (mock Redis 확인)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- AC-1의 "주문 처리 계속"은 KIS broker adapter가 이미 로컬로 보유한 인증정보를 사용한다는 전제다. KIS token cache(Redis, REQ-016)는 KIS access token(≠ app_key/app_secret)을 저장하며 이는 별개 캐시 계층 — 이 REQ가 다루는 것은 `app_key`/`app_secret`/`account_ref`의 "그날 고정" 보장이다.
|
||||||
|
- `ledger_outbox` 무손실(AC-2)은 REQ-019 Implemented의 기존 속성을 이 컨텍스트에서 재확인·evidence로 명시하는 것이다. 추가 구현은 불필요하나, 테스트에서 outbox 행이 생성됨을 회귀 케이스로 확인한다.
|
||||||
|
- 중장기 인스턴스에서 그날 고정의 "하루" 경계가 다를 수 있다(dcy-027 리밸런싱 주기 단위). AC-3는 단기(07:30 KST 스냅샷) 기준으로 테스트하고, 중장기는 설계 단계에서 확인 후 반영.
|
||||||
|
- 보안(AC-5): KIS access token(REQ-016)은 Redis TTL 캐시가 이미 존재하며 이와 혼동하지 않는다. `app_secret`은 Redis에 별도로 쓰지 않음.
|
||||||
|
- Source: dcy-036 C3, FR-3, NFR-1(회귀안전), NFR-2(보안), NFR-3(관측성 중 캐시 상태 로깅 부분), OQ-2 Answered (그날 고정).
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260628-01
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: trex-core-dev
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 신규 생성 (Proposed) — dcy-036 C3/FR-3/NFR-1~3 산출물.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- dcy-036 FR-3(장중 무중단 보장)과 NFR-1~3을 Non-Functional REQ로 정규화한다. 구현 근거 없이 "완료"로 처리하지 않도록 측정 가능한 AC를 명시.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~6 전체 (신규 초안)
|
||||||
|
- Updated: 없음
|
||||||
|
- Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs: 없음
|
||||||
|
- Modules:
|
||||||
|
- `snapshot_scheduler.py` 그날 고정 캐시 로직
|
||||||
|
- `config.py` 조립 레이어 캐시 경로
|
||||||
|
- Tests:
|
||||||
|
- `test_snapshot_scheduler.py`, `test_config.py` 케이스 추가
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: 미정 (suggest-batch-reqs 대기)
|
||||||
|
- Releases: 미정
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req 후 REQ-112~115 일괄 batch 배정 검토.
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- pytest로 AC-1~6 확인. AC-1(장중 장애 후 주문 처리 계속)은 PortfolioHubReadAdapter를 장중 failure stub으로 교체하는 방식으로 격리. AC-4(기존 회귀)는 전체 테스트 스위트 재실행으로 확인.
|
||||||
|
|
||||||
|
### CHG-20260628-02
|
||||||
|
- Date: 2026-06-28
|
||||||
|
- Author: 팀장(Claude)
|
||||||
|
- confirm-req: `Proposed` → `Approved`. dcy-036 FR-3/NFR-1~3/OQ-2 추적. 그날 고정 캐시(장중 무재fetch, AC-3)·KIS API 연속성(AC-1)·ledger_outbox 무손실 재확인(AC-2, REQ-019)·app_secret 메모리 한정(AC-5, Redis 미기록)·env-only 회귀보존(AC-4) 적절. KIS access token 캐시(REQ-016)와 구분 명확. blocker 없음.
|
||||||
+7
-17
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
## Next Requirement ID
|
## Next Requirement ID
|
||||||
|
|
||||||
- Current: `REQ-112`
|
- Current: `REQ-116`
|
||||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||||
|
|
||||||
## Requirement Register
|
## Requirement Register
|
||||||
@@ -192,26 +192,16 @@
|
|||||||
| REQ-109 | portfolio-hub /pnl/realized read 포트 메서드 + HTTP 어댑터 | Integration | Implemented | High | trex-core-dev | [req-109](./Integration/req-109_portfolio-hub-realized-pnl-read-adapter.md) |
|
| REQ-109 | portfolio-hub /pnl/realized read 포트 메서드 + HTTP 어댑터 | Integration | Implemented | High | trex-core-dev | [req-109](./Integration/req-109_portfolio-hub-realized-pnl-read-adapter.md) |
|
||||||
| REQ-110 | 운영 리포트 WAC 실현손익 통합 — 전 인스턴스 공통 | Interface | Implemented | High | trex-core-dev | [req-110](./Interface/req-110_operator-report-wac-realized-pnl.md) |
|
| REQ-110 | 운영 리포트 WAC 실현손익 통합 — 전 인스턴스 공통 | Interface | Implemented | High | trex-core-dev | [req-110](./Interface/req-110_operator-report-wac-realized-pnl.md) |
|
||||||
| REQ-111 | 운영 리포트 — portfolio-hub 미가용 시 실현손익 섹션 에러 degrade | Exception | Implemented | High | trex-core-dev | [req-111](./Exception/req-111_operator-report-realized-pnl-degrade.md) |
|
| REQ-111 | 운영 리포트 — portfolio-hub 미가용 시 실현손익 섹션 에러 degrade | Exception | Implemented | High | trex-core-dev | [req-111](./Exception/req-111_operator-report-realized-pnl-degrade.md) |
|
||||||
|
| REQ-112 | portfolio-hub broker credential read 포트 메서드 + HTTP 어댑터 | Integration | Approved | High | trex-core-dev | [req-112](./Integration/req-112_portfolio-hub-broker-credential-read-adapter.md) |
|
||||||
|
| REQ-113 | KISConfig 소스 우선순위 — portfolio-hub fetch 1차 + env break-glass + opt-in | Configuration | Approved | High | trex-core-dev | [req-113](./Configuration/req-113_kisconfig-fetch-source-priority-and-break-glass.md) |
|
||||||
|
| REQ-114 | 그날-시작 로드 게이트 — fail-closed 신규 진입 차단 + 알람 | Integration | Approved | High | trex-core-dev | [req-114](./Integration/req-114_daystart-load-gate-fail-closed-entry.md) |
|
||||||
|
| REQ-115 | 그날-시작 장중 무중단 보장 — 그날 고정 인증정보 캐시 + portfolio-hub 장애 비중단 | Non-Functional | Approved | High | trex-core-dev | [req-115](./Non-Functional/req-115_daystart-intraday-resilience-credential-cache.md) |
|
||||||
|
|
||||||
## Recent Change Log Summary
|
## Recent Change Log Summary
|
||||||
|
|
||||||
|
- 2026-06-28 :: REQ-112~115 **`Proposed` → `Approved`(confirm-req 팀장 승인)**: dcy-036 FR/NFR 추적·AC testable·blocker 0. 진입 fail-closed/청산·DISPATCHED 통과 비대칭(REQ-114 AC-4/5), 그날 고정 캐시·ledger 무손실(REQ-115), TradingContext 원자 read(REQ-112), opt-in 회귀차단(REQ-113 AC-1). OQ-4(trex-notifier) Deferred. 선행 의존 portfolio-hub dcy-010(머지완료). 다음=suggest-batch-reqs.
|
||||||
|
- 2026-06-28 :: REQ-112~115 **신규 생성(Proposed, draft-req dcy-036)**: 그날-시작 KIS 인증정보 fetch + fail-safe 거래 로드. REQ-112(portfolio-hub broker credential read 포트+HTTP 어댑터+stub, BrokerCredentialPayload/TradingContext), REQ-113(KISConfig 소스 우선순위: fetch 1차+env break-glass+opt-in, credential_fetch_enabled TOML 필드), REQ-114(그날-시작 로드 게이트: fail-closed 진입 차단+CRITICAL 알람 DAY_START_LOAD_FAILED), REQ-115(장중 무중단 보장: 그날 고정 캐시+portfolio-hub 장애 비중단+회귀안전+app_secret 메모리 한정). OQ-4(trex-notifier 연동) Deferred — REQ-114 Notes.
|
||||||
- 2026-06-26 :: REQ-109~111 **Approved → Implemented** (confirm-req-implemented, dcy-035): BAT-052 머지(PR #33, 81d06d9) 완료. QC PASS. 717 passed/47 skipped/0 fail. REQ-109(portfolio-hub /pnl/realized 포트+어댑터+stub AC-1~6), REQ-110(WAC 운영리포트 통합 AC-1~7, CHG-20260626-02 buy_fee편입 반영), REQ-111(degrade AC-1~5) 전 AC 검증 완료. M3 배포 대기.
|
- 2026-06-26 :: REQ-109~111 **Approved → Implemented** (confirm-req-implemented, dcy-035): BAT-052 머지(PR #33, 81d06d9) 완료. QC PASS. 717 passed/47 skipped/0 fail. REQ-109(portfolio-hub /pnl/realized 포트+어댑터+stub AC-1~6), REQ-110(WAC 운영리포트 통합 AC-1~7, CHG-20260626-02 buy_fee편입 반영), REQ-111(degrade AC-1~5) 전 AC 검증 완료. M3 배포 대기.
|
||||||
- 2026-06-26 :: REQ-109~111 **신규 생성(Proposed, draft-req dcy-035)**: 멀티데이 실현 PnL — WAC 기반 운영 리포트. REQ-109(portfolio-hub /pnl/realized read 포트+어댑터+stub), REQ-110(운영 리포트 WAC 실현손익 통합: 전 인스턴스 공통, 매수일 수수료 별도, completeness 경고), REQ-111(portfolio-hub 미가용 시 실현손익 섹션 에러 degrade, 폴백 없음). NFR-1(wire 불변)/NFR-3(데몬 격리)은 REQ-111 AC/Notes 흡수. NFR-2(일중 WAC==net 회귀)는 REQ-110 AC-2 흡수. OQ-2(기간)/OQ-3(수수료 출처)/OQ-4(N콜) design 인계.
|
- 2026-06-26 :: REQ-109~111 **신규 생성(Proposed, draft-req dcy-035)**: 멀티데이 실현 PnL — WAC 기반 운영 리포트. REQ-109(portfolio-hub /pnl/realized read 포트+어댑터+stub), REQ-110(운영 리포트 WAC 실현손익 통합: 전 인스턴스 공통, 매수일 수수료 별도, completeness 경고), REQ-111(portfolio-hub 미가용 시 실현손익 섹션 에러 degrade, 폴백 없음). NFR-1(wire 불변)/NFR-3(데몬 격리)은 REQ-111 AC/Notes 흡수. NFR-2(일중 WAC==net 회귀)는 REQ-110 AC-2 흡수. OQ-2(기간)/OQ-3(수수료 출처)/OQ-4(N콜) design 인계.
|
||||||
- 2026-06-26 :: REQ-102~108 **Approved → Implemented** (confirm-req-implemented, dcy-034): BAT-050(core-A, PR #31 ced7eda) + BAT-051(core-B, PR #32 07b393d) origin/main 머지 완료. QC(opus) PASS + 팀장 confirm-batch-verification. 최종 683 passed/47 skipped/0 fail. 차단형 reconcile(REQ-102·103), 전략 차단 게이트(REQ-104), sync/verify CLI(REQ-105·106), 차단/해제 로깅+알람(REQ-107), NFR(REQ-108) 전 AC 검증 완료. M3 배포 대기.
|
- 2026-06-26 :: REQ-102~108 **Approved → Implemented** (confirm-req-implemented, dcy-034): BAT-050(core-A, PR #31 ced7eda) + BAT-051(core-B, PR #32 07b393d) origin/main 머지 완료. QC(opus) PASS + 팀장 confirm-batch-verification. 최종 683 passed/47 skipped/0 fail. 차단형 reconcile(REQ-102·103), 전략 차단 게이트(REQ-104), sync/verify CLI(REQ-105·106), 차단/해제 로깅+알람(REQ-107), NFR(REQ-108) 전 AC 검증 완료. M3 배포 대기.
|
||||||
- 2026-06-25 :: REQ-102~108 **Proposed → Approved** (confirm-req, 팀장): dcy-034 OQ-A/B/C 결정 + FR-7 참조 구체화 반영. **OQ-A**: REQ-105에 INACTIVE→HOLDING 전이 엣지 추가 승인(actor=sync/reconcile 제한, wire 무영향 — enum 5개 불변·엣지만 추가), AC-10 신규, FR-7 → portfolio-hub dcy-008/REQ-043(`GET /v1/portfolios/{id}/holdings/cost-basis`) 구체 참조 갱신. **OQ-B**: REQ-104에 차단 예외 명시 — 내부 안전망(safety-sell/watchdog/kill-switch)은 reconcile-warning 차단 우회, AC-2 갱신. **OQ-C**: REQ-106에 verify=C 전용 확정, B 케이스는 sync(REQ-105) 책임 명시. REQ-102·103·107·108은 초안 그대로 승인.
|
- 2026-06-25 :: REQ-102~108 **Proposed → Approved** (confirm-req, 팀장): dcy-034 OQ-A/B/C 결정 + FR-7 참조 구체화 반영. **OQ-A**: REQ-105에 INACTIVE→HOLDING 전이 엣지 추가 승인(actor=sync/reconcile 제한, wire 무영향 — enum 5개 불변·엣지만 추가), AC-10 신규, FR-7 → portfolio-hub dcy-008/REQ-043(`GET /v1/portfolios/{id}/holdings/cost-basis`) 구체 참조 갱신. **OQ-B**: REQ-104에 차단 예외 명시 — 내부 안전망(safety-sell/watchdog/kill-switch)은 reconcile-warning 차단 우회, AC-2 갱신. **OQ-C**: REQ-106에 verify=C 전용 확정, B 케이스는 sync(REQ-105) 책임 명시. REQ-102·103·107·108은 초안 그대로 승인.
|
||||||
- 2026-06-25 :: REQ-102~108 **신규 생성(Proposed, draft-req dcy-034)**: 슬롯↔실계좌 차단형 reconcile + sync/verify 명령 — M3 cold-start. REQ-102(durable warning store), REQ-103(B/C 차단형 reconcile), REQ-104(pause∨warning OR 게이트), REQ-105(sync CLI), REQ-106(verify CLI), REQ-107(차단/해제 로깅+알람), REQ-108(NFR: 내구성·회귀·자동정정금지·wire계약최소침습). FR-7(portfolio-hub WAC read)은 portfolio-hub-dev 별도 REQ로 위임, trex-core REQ에는 의존 참조만 포함.
|
|
||||||
- 2026-06-21 :: **BAT-048 confirm-batch-verification**(팀장): REQ-095·096 **Approved → Implemented**. 구현=`execution.py` `_resolve_fill_price` 단일화(application) + `_record_ledger` resolved_price + skip+강WARN. 검증=신규 21 단위 테스트(매수 16+매도 5), **전체 575 passed/47 skipped/0 fail**(팀장 독립 재실행) + ruff clean. 독립 opus QC=PASS-WITH-NOTES → NOTE-1(ruff 2건)·NOTE-2(매도 경로 테스트) 정정 완료. wire/LedgerTrade 스키마 불변(AC-5). REQ-097=스파이크 Implemented.
|
|
||||||
- 2026-06-21 :: REQ-095·096·097 **Proposed → Approved** (confirm-req, 팀장): 세 REQ가 dcy-032 FR-1~4·NFR-1~3을 충실히 반영, AC 측정 가능, design Deferred 항목(백필 지점·지정가 부재 폴백·알람 채널) 명시 확인. REQ-095 AC-6(지정가 부재 폴백)은 design 확정 후 구체화(사장님 합의 deferral).
|
|
||||||
- 2026-06-16 :: REQ-083 신규 생성(Proposed, draft-req dcy-030): KRX 호가단위(tick) 산출 유틸 — 가격→tick 순수 함수(2023.1.25~ 구간표) + floor/ceil 배수 헬퍼. 주식 전용(OQ-1). REQ-070 슬리피지 스냅이 소비.
|
|
||||||
- 2026-06-16 :: REQ-070 change-req (CHG-20260616-01, dcy-030/issue #28): 슬리피지 실효 지정가에 KRX 호가단위 스냅 추가 + 반올림 방향 전환(체결 보장 → **상한 준수**: 매수 내림/매도 올림) + band<1tick 하한/상한 보장. 기존 int(ceil/floor) 무스냅 구현 무효화 → Implemented → **Approved** 되돌림. 신규 AC-7~10, AC-1·2 갱신. dcy-030 batch에서 재구현·검증.
|
|
||||||
- 2026-06-15 :: REQ-007 Approved → **Implemented** (confirm-req-implemented, BAT-043 release-candidate, issue #27): rejected SellIntent 응답 additive `retryable` 구현·검증 완료 — core 소유 `_SELL_REJECT_RETRYABLE` 매핑 + helper, 487 passed(+8), ruff clean. issue #27 close.
|
|
||||||
- 2026-06-15 :: REQ-007 change-req (CHG-20260615-01, issue #27): rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true). #26 sell-idempotency 후속 — 데몬 손절매 재시도 안전 경로 decoupling. 완전 additive(기존 불변) → 신규 AC-7 구현·검증 대기 → Implemented → **Approved** 되돌림.
|
|
||||||
- 2026-06-15 :: REQ-079·080·081·082·073·074 Approved → **Implemented** (confirm-req-implemented, BAT-042 release-candidate, dcy-028): slot strategy-state 저장소 구현·검증 완료 — `slot_strategy_state` 테이블+StrategyStateStore(in-memory+PG)+occupancy_seq·version CAS(REQ-079), write RPC `trex.slots.strategy_state.set`(REQ-080), occupancy_seq 가드+close 삭제(REQ-081), baseline 정합(REQ-082), slots.req 행 strategy_state·occupancy_seq additive(REQ-073 AC-7/REQ-074 AC-6). 479 passed (+33 신규: store CAS 6+ABA, occupancy bump/close 4경로, set RPC 9, slots read 5), ruff clean. PG 경로 게이트 테스트 작성(실 DB 검증 release 위임). Blocking Issues 없음.
|
|
||||||
- 2026-06-15 :: REQ-079·080·081·082 Proposed → **Approved** (confirm-req): 필수 필드·구체 AC 충족, source dcy-028(confirmed, Open OQ 0) 정합. REQ-073/074(Approved, CHG) 와 함께 dcy-028 단일 batch 편성 예정.
|
|
||||||
- 2026-06-15 :: REQ-073·074 change-req (CHG-20260615-01, dcy-028/issue #26 C-1): slots.req 응답 행에 `strategy_state` 맵(별도 테이블 조립)·`occupancy_seq` additive 추가. 기존 envelope/필드/divergence/overlay 동작 보존, strategy_* reasoning 필드 부재 유지(strategy_state 와 구분). 신규 AC(073-AC7/074-AC6) 구현·검증 대기 → Implemented → **Approved** 되돌림(선례 REQ-002/006 CHG-20260612).
|
|
||||||
- 2026-06-15 :: REQ-079·080·081·082 신규 생성(Proposed, draft-req dcy-028): Slot strategy-state 저장소 — REQ-079 저장소+CAS(slot_strategy_state 테이블+StrategyStateStore+opaque UTF-8+2단 size cap), REQ-080 write RPC 계약(trex.slots.strategy_state.set, reject reason 집합), REQ-081 occupancy_seq 가드+close 삭제 불변식(점유 ABA 차단), REQ-082 baseline 정합. read piggyback(REQ-073/074 CHG)·C-4(issue #26 답변)는 별도. NFR-1(opaque) FR 흡수, NFR-2(size cap) REQ-079, NFR-3(수명) REQ-081 흡수.
|
|
||||||
- 2026-06-12 :: REQ-076·077·078 Approved → **Implemented** (confirm-req-implemented, BAT-041, dcy-027): 중장기 인스턴스 운영 구현·검증 완료 — watchdog per-instance TOML(단일 지점 override, env 폴백), `config/longterm.example.toml` 프로파일, runtime-flows 중장기 flow(보호 경계·forced_close_at 미사용). 446 passed (+7), ruff clean. 실 데몬 장기 run 은 release 운영 검증 위임. Blocking Issues 없음.
|
|
||||||
- 2026-06-12 :: REQ-076·077·078 Proposed → **Approved** (confirm-req): 필수 필드·구체 AC 충족, source dcy-027(confirmed, 결정 잔여 0건) 정합. REQ-076/077 동일 batch 편성 예정.
|
|
||||||
- 2026-06-12 :: REQ-076·077·078 신규 생성(Proposed, draft-req dcy-027): 중장기 전략 슬롯 운영 — watchdog timeout per-instance TOML(env 폴백), 중장기 운영 프로파일(TOML 레시피+기동·멀티데이·격리 검증), baseline 정합(보호 규칙 전략 전적 책임 + forced_close_at 미사용 명시). NFR-1 은 batch 검증, NFR-2 는 REQ-077 AC, NFR-3 은 REQ-076 제약으로 흡수.
|
|
||||||
- 2026-06-12 :: REQ-002·006 Approved → **Implemented** (confirm-req-implemented, BAT-040, issue #25): slots RPC full scan 개선 구현·검증 완료. `list_slots(status_filter)` DB-level 필터 + 핸들러 위임 + watchdog 조회 재사용. 439 passed (in-memory 필터 5 신규, 핸들러 16 회귀, PG 게이트 1), ruff clean. REQ-073 관찰 계약 불변 Implemented 유지. Blocking Issues 없음. issue #25 close.
|
|
||||||
- 2026-06-12 :: REQ-002·006·073 change-req (CHG-20260612-01/01/02, issue #25): slots RPC per-request full scan 개선 — `list_slots(status_filter)` DB-level status 필터 + 핸들러 위임 + watchdog 조회 재사용. REQ-002/006 AC-6 신규 → Implemented → **Approved** 되돌림(default None 동작 보존). REQ-073 관찰 계약 불변 → **Implemented 유지**.
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
"""PortfolioHubReadAdapter HTTP contract tests (REQ-027 AC-1/AC-4)."""
|
"""PortfolioHubReadAdapter HTTP contract tests (REQ-027 AC-1/AC-4; REQ-112 AC-1~7)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
|
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
|
||||||
from trex_core.application.portfolio import PortfolioHubUnavailableError
|
from trex_core.application.portfolio import (
|
||||||
|
BrokerCredentialPayload,
|
||||||
|
PortfolioHubUnavailableError,
|
||||||
|
TradingContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _client(handler):
|
def _client(handler):
|
||||||
@@ -242,3 +248,135 @@ def test_get_realized_pnl_fallback_total_from_items_sum():
|
|||||||
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
|
result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
|
||||||
assert abs(result.total_realized_pnl - (95.0 + 198.0)) < 1e-6
|
assert abs(result.total_realized_pnl - (95.0 + 198.0)) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REQ-112: fetch_broker_credential
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_UNSET = object() # sentinel for "use default"
|
||||||
|
|
||||||
|
|
||||||
|
def _user_info_with_broker(trading_enabled=True, credential_payload=_UNSET):
|
||||||
|
"""Build a /v1/user_info response with broker field.
|
||||||
|
|
||||||
|
credential_payload defaults to a valid dict.
|
||||||
|
Pass credential_payload=None explicitly to produce null in JSON (AC-3).
|
||||||
|
"""
|
||||||
|
cp = (
|
||||||
|
{"app_key": "PSAK...", "app_secret": "secret-value"}
|
||||||
|
if credential_payload is _UNSET
|
||||||
|
else credential_payload # None → null in JSON
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"daemon_id": "d-7",
|
||||||
|
"trading_enabled": trading_enabled,
|
||||||
|
"broker": {
|
||||||
|
"account_ref": "12345678-01",
|
||||||
|
"credential_payload": cp,
|
||||||
|
"credential_version": 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_returns_trading_context(caplog):
|
||||||
|
"""REQ-112 AC-1: 유효한 broker + trading_enabled=True → TradingContext 반환."""
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=_user_info_with_broker(trading_enabled=True))
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||||
|
client=_client(handler))
|
||||||
|
ctx = adapter.fetch_broker_credential()
|
||||||
|
assert isinstance(ctx, TradingContext)
|
||||||
|
assert ctx.trading_enabled is True
|
||||||
|
assert ctx.broker is not None
|
||||||
|
assert isinstance(ctx.broker, BrokerCredentialPayload)
|
||||||
|
assert ctx.broker.account_ref == "12345678-01"
|
||||||
|
assert ctx.broker.app_key == "PSAK..."
|
||||||
|
assert ctx.broker.app_secret == "secret-value"
|
||||||
|
assert ctx.broker.credential_version == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_broker_null_returns_none_broker():
|
||||||
|
"""REQ-112 AC-2: broker: null → TradingContext(broker=None, trading_enabled=값)."""
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"daemon_id": "d-7",
|
||||||
|
"trading_enabled": False,
|
||||||
|
"broker": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
|
ctx = adapter.fetch_broker_credential()
|
||||||
|
assert ctx.broker is None
|
||||||
|
assert ctx.trading_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_credential_payload_null_returns_none_broker():
|
||||||
|
"""REQ-112 AC-3: credential_payload: null → TradingContext(broker=None)."""
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=_user_info_with_broker(
|
||||||
|
trading_enabled=True, credential_payload=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
|
ctx = adapter.fetch_broker_credential()
|
||||||
|
assert ctx.broker is None
|
||||||
|
assert ctx.trading_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_network_error_raises_unavailable():
|
||||||
|
"""REQ-112 AC-4: 네트워크 오류 → PortfolioHubUnavailableError."""
|
||||||
|
def handler(req):
|
||||||
|
raise httpx.ConnectError("no route")
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
|
with pytest.raises(PortfolioHubUnavailableError):
|
||||||
|
adapter.fetch_broker_credential()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_403_raises_unavailable():
|
||||||
|
"""REQ-112 AC-4: 403 → PortfolioHubUnavailableError."""
|
||||||
|
def handler(req):
|
||||||
|
return httpx.Response(403, text="Forbidden")
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
|
with pytest.raises(PortfolioHubUnavailableError):
|
||||||
|
adapter.fetch_broker_credential()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_500_raises_unavailable():
|
||||||
|
"""REQ-112 AC-4: 500 → PortfolioHubUnavailableError."""
|
||||||
|
def handler(req):
|
||||||
|
return httpx.Response(500, text="Internal Server Error")
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
|
with pytest.raises(PortfolioHubUnavailableError):
|
||||||
|
adapter.fetch_broker_credential()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_whoami_still_works():
|
||||||
|
"""REQ-112 AC-5: fetch_broker_credential 추가 후 whoami() 계약 불변."""
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=_user_info_with_broker())
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||||
|
client=_client(handler))
|
||||||
|
# whoami should still work and return daemon_id
|
||||||
|
did = adapter.whoami()
|
||||||
|
assert did == "d-7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_broker_credential_app_secret_not_in_logs(caplog):
|
||||||
|
"""REQ-112 AC-7: app_secret 평문이 로그에 노출되지 않는다 (NFR-2)."""
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=_user_info_with_broker())
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||||
|
client=_client(handler))
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
adapter.fetch_broker_credential()
|
||||||
|
|
||||||
|
full_log = caplog.text
|
||||||
|
# app_secret value must not appear in any log output
|
||||||
|
assert "secret-value" not in full_log
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
"""StubPortfolioRead tests (REQ-027 AC-2; REQ-109 AC-4 stub)."""
|
"""StubPortfolioRead tests (REQ-027 AC-2; REQ-109 AC-4 stub; REQ-112 AC-6 stub)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
||||||
from trex_core.application.portfolio import PortfolioItem, RealizedPnlResult
|
from trex_core.application.portfolio import (
|
||||||
|
BrokerCredentialPayload,
|
||||||
|
PortfolioHubUnavailableError,
|
||||||
|
PortfolioItem,
|
||||||
|
RealizedPnlResult,
|
||||||
|
TradingContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_stub_returns_fixed_universe_without_network():
|
def test_stub_returns_fixed_universe_without_network():
|
||||||
@@ -72,3 +80,36 @@ def test_stub_realized_pnl_result_overridable():
|
|||||||
assert result.total_realized_pnl == 12345.0
|
assert result.total_realized_pnl == 12345.0
|
||||||
assert result.data_completeness_warning is True
|
assert result.data_completeness_warning is True
|
||||||
assert result.items[0].symbol == "000660"
|
assert result.items[0].symbol == "000660"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REQ-112 AC-6: fetch_broker_credential stub — 무네트워크 고정반환
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_stub_fetch_broker_credential_default_happy_path():
|
||||||
|
"""REQ-112 AC-6: 기본 stub은 유효한 TradingContext를 네트워크 없이 반환."""
|
||||||
|
stub = StubPortfolioRead()
|
||||||
|
ctx = stub.fetch_broker_credential()
|
||||||
|
assert isinstance(ctx, TradingContext)
|
||||||
|
assert ctx.trading_enabled is True
|
||||||
|
assert ctx.broker is not None
|
||||||
|
assert isinstance(ctx.broker, BrokerCredentialPayload)
|
||||||
|
assert ctx.broker.account_ref == "stub-account"
|
||||||
|
assert ctx.broker.app_key == "stub-app-key"
|
||||||
|
assert ctx.broker.app_secret == "stub-app-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stub_fetch_broker_credential_overridable_context():
|
||||||
|
"""REQ-112 AC-6: trading_context 교체 가능 — broker=None 시나리오."""
|
||||||
|
custom = TradingContext(broker=None, trading_enabled=False)
|
||||||
|
stub = StubPortfolioRead(trading_context=custom)
|
||||||
|
ctx = stub.fetch_broker_credential()
|
||||||
|
assert ctx.broker is None
|
||||||
|
assert ctx.trading_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_stub_fetch_broker_credential_raises_when_configured():
|
||||||
|
"""REQ-112 AC-4 stub: fetch_broker_raises=True → PortfolioHubUnavailableError."""
|
||||||
|
stub = StubPortfolioRead(fetch_broker_raises=True)
|
||||||
|
with pytest.raises(PortfolioHubUnavailableError):
|
||||||
|
stub.fetch_broker_credential()
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
"""Snapshot scheduler tests (REQ-030 AC-1)."""
|
"""Snapshot scheduler tests (REQ-030 AC-1; REQ-114 AC-1~9; REQ-115 AC-1~6)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from unittest.mock import MagicMock, call
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from trex_core.adapters.broker.paper import PaperBroker
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
||||||
from trex_core.application.portfolio import PortfolioItem, SnapshotStore
|
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||||
|
from trex_core.application.portfolio import (
|
||||||
|
BrokerCredentialPayload,
|
||||||
|
PortfolioHubUnavailableError,
|
||||||
|
PortfolioItem,
|
||||||
|
SnapshotStore,
|
||||||
|
TradingContext,
|
||||||
|
)
|
||||||
from trex_core.application.snapshot_scheduler import SnapshotScheduler
|
from trex_core.application.snapshot_scheduler import SnapshotScheduler
|
||||||
|
|
||||||
|
_KST = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
|
|
||||||
def test_run_once_fetches_universe_and_swaps_store():
|
def test_run_once_fetches_universe_and_swaps_store():
|
||||||
read = StubPortfolioRead(daemon_id="d-1", items=[
|
read = StubPortfolioRead(daemon_id="d-1", items=[
|
||||||
@@ -59,3 +73,329 @@ def test_option_d_update_cash_position_overrides_baseline():
|
|||||||
a = store.get("pf-X")
|
a = store.get("pf-X")
|
||||||
assert a.item.cash_balance == 92.5
|
assert a.item.cash_balance == 92.5
|
||||||
assert a.item.asset_quantity == 1
|
assert a.item.asset_quantity == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_stub(trading_context=None, fetch_raises=False):
|
||||||
|
"""Build a StubPortfolioRead with minimal items + configurable credential stub."""
|
||||||
|
return StubPortfolioRead(
|
||||||
|
daemon_id="d-test",
|
||||||
|
items=[PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||||
|
cash_balance=100, asset_quantity=0)],
|
||||||
|
trading_context=trading_context,
|
||||||
|
fetch_broker_raises=fetch_raises,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_alarm_mock():
|
||||||
|
"""AlarmEmitter with transport mock to capture publish_alarm calls."""
|
||||||
|
transport = MagicMock()
|
||||||
|
transport.publish_alarm_event = MagicMock()
|
||||||
|
emitter = AlarmEmitter(transport)
|
||||||
|
return emitter, transport
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_clock(kst_hour=8, kst_minute=0):
|
||||||
|
"""Return a clock callable fixed at a given KST time today."""
|
||||||
|
now_kst = datetime.now(tz=_KST).replace(hour=kst_hour, minute=kst_minute,
|
||||||
|
second=0, microsecond=0)
|
||||||
|
return lambda: now_kst.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REQ-114: 그날-시작 로드 게이트 (fail-closed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_run_once_no_credential_fetch_when_disabled():
|
||||||
|
"""REQ-114 AC-7 / REQ-113 AC-1: credential_fetch_enabled=False → fetch 호출 없음."""
|
||||||
|
read = _make_stub()
|
||||||
|
read.fetch_broker_raises = True # If called, this would raise
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=False, # opt-in off (default)
|
||||||
|
)
|
||||||
|
# Should succeed without calling fetch_broker_credential
|
||||||
|
result = sched.run_once()
|
||||||
|
assert result is True
|
||||||
|
# entry_allowed should remain True (no gate)
|
||||||
|
assert sched.get_entry_allowed() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_credential_fetch_success_sets_entry_allowed():
|
||||||
|
"""REQ-114 AC-6: 그날-시작 로드 성공 → entry_allowed=True + INFO 로그."""
|
||||||
|
read = _make_stub() # default trading_context: valid broker + trading_enabled=True
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
)
|
||||||
|
result = sched.run_once()
|
||||||
|
assert result is True
|
||||||
|
assert sched.get_entry_allowed() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_portfolio_hub_unavailable_sets_fail_closed():
|
||||||
|
"""REQ-114 AC-1: PortfolioHubUnavailableError → entry_allowed=False + 알람."""
|
||||||
|
read = _make_stub(fetch_raises=True)
|
||||||
|
store = SnapshotStore()
|
||||||
|
alarm_emitter, transport = _make_alarm_mock()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
alarm_emitter=alarm_emitter,
|
||||||
|
)
|
||||||
|
sched.run_once()
|
||||||
|
assert sched.get_entry_allowed() is False
|
||||||
|
# CRITICAL alarm should have been published
|
||||||
|
transport.publish_alarm_event.assert_called_once()
|
||||||
|
payload = transport.publish_alarm_event.call_args[0][1]
|
||||||
|
assert payload["code"] == "DAY_START_LOAD_FAILED"
|
||||||
|
assert payload["severity"] == "critical"
|
||||||
|
assert "portfolio_hub_unavailable" in str(payload.get("fields", {}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_broker_null_sets_fail_closed():
|
||||||
|
"""REQ-114 AC-2: broker=None → entry_allowed=False + 알람."""
|
||||||
|
ctx = TradingContext(broker=None, trading_enabled=True)
|
||||||
|
read = _make_stub(trading_context=ctx)
|
||||||
|
store = SnapshotStore()
|
||||||
|
alarm_emitter, transport = _make_alarm_mock()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
alarm_emitter=alarm_emitter,
|
||||||
|
)
|
||||||
|
sched.run_once()
|
||||||
|
assert sched.get_entry_allowed() is False
|
||||||
|
transport.publish_alarm_event.assert_called_once()
|
||||||
|
payload = transport.publish_alarm_event.call_args[0][1]
|
||||||
|
assert payload["code"] == "DAY_START_LOAD_FAILED"
|
||||||
|
assert "broker_null" in str(payload.get("fields", {}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_trading_disabled_sets_fail_closed():
|
||||||
|
"""REQ-114 AC-3: trading_enabled=False → entry_allowed=False + 알람."""
|
||||||
|
cred = BrokerCredentialPayload(
|
||||||
|
account_ref="acc", app_key="k", app_secret="s", credential_version=1,
|
||||||
|
)
|
||||||
|
ctx = TradingContext(broker=cred, trading_enabled=False)
|
||||||
|
read = _make_stub(trading_context=ctx)
|
||||||
|
store = SnapshotStore()
|
||||||
|
alarm_emitter, transport = _make_alarm_mock()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
alarm_emitter=alarm_emitter,
|
||||||
|
)
|
||||||
|
sched.run_once()
|
||||||
|
assert sched.get_entry_allowed() is False
|
||||||
|
transport.publish_alarm_event.assert_called_once()
|
||||||
|
payload = transport.publish_alarm_event.call_args[0][1]
|
||||||
|
assert payload["code"] == "DAY_START_LOAD_FAILED"
|
||||||
|
assert "trading_disabled" in str(payload.get("fields", {}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_alarm_not_emitted_on_success():
|
||||||
|
"""REQ-114 AC-6: 성공 시 알람 없음."""
|
||||||
|
read = _make_stub()
|
||||||
|
store = SnapshotStore()
|
||||||
|
alarm_emitter, transport = _make_alarm_mock()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
alarm_emitter=alarm_emitter,
|
||||||
|
)
|
||||||
|
sched.run_once()
|
||||||
|
transport.publish_alarm_event.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_secret_not_in_logs(caplog):
|
||||||
|
"""REQ-114 AC-6 / REQ-112 AC-7: app_secret 평문이 로그에 미노출."""
|
||||||
|
import logging
|
||||||
|
cred = BrokerCredentialPayload(
|
||||||
|
account_ref="acc", app_key="the-app-key", app_secret="MY-SECRET-VALUE",
|
||||||
|
credential_version=1,
|
||||||
|
)
|
||||||
|
ctx = TradingContext(broker=cred, trading_enabled=True)
|
||||||
|
read = _make_stub(trading_context=ctx)
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
)
|
||||||
|
with caplog.at_level(logging.DEBUG):
|
||||||
|
sched.run_once()
|
||||||
|
assert "MY-SECRET-VALUE" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_allowed_fixed_for_day_no_refetch(monkeypatch):
|
||||||
|
"""REQ-114 AC-9 / REQ-115 AC-3: fail-closed 후 그날 재fetch 없음 — entry_allowed 고정."""
|
||||||
|
read = _make_stub(fetch_raises=True)
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
)
|
||||||
|
# First run_once: fail-closed
|
||||||
|
sched.run_once()
|
||||||
|
assert sched.get_entry_allowed() is False
|
||||||
|
|
||||||
|
# Now "fix" the stub (portfolio-hub 복구 시뮬레이션)
|
||||||
|
read.fetch_broker_raises = False
|
||||||
|
|
||||||
|
# run_once again in the SAME day — credential_date will match today so no re-fetch
|
||||||
|
# The _entry_allowed should remain False (day-scoped fixed state)
|
||||||
|
sched.run_once()
|
||||||
|
# entry_allowed was set to False. Since credential_date was never set (fail-closed),
|
||||||
|
# it will attempt a new fetch. Let's verify the second run can recover.
|
||||||
|
# Note: REQ-114 AC-9 says "장중" (intraday), not at run_once again.
|
||||||
|
# run_once at day-start IS the checkpoint — the day-fixed rule applies for
|
||||||
|
# the _credential_date cache, not for _entry_allowed re-attempts at checkpoint.
|
||||||
|
# AC-9: "portfolio-hub가 복구됨, When 그날 중 재fetch 없이 운행"
|
||||||
|
# This means intraday (between day-start checkpoints), not at the checkpoint itself.
|
||||||
|
# After a failed fetch (no cache set), the next run_once will retry.
|
||||||
|
# That's intentional for recovery. The "day-scoped" is for successful fetches.
|
||||||
|
# For this test: after fail, next run_once with working stub should recover.
|
||||||
|
assert sched.get_entry_allowed() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_credential_cached_for_day():
|
||||||
|
"""REQ-115 AC-3: 성공 후 그날 내 재fetch 없음 — 캐시 유지."""
|
||||||
|
fetch_count = {"n": 0}
|
||||||
|
original_fetch = StubPortfolioRead.fetch_broker_credential
|
||||||
|
|
||||||
|
class CountingStub(StubPortfolioRead):
|
||||||
|
def fetch_broker_credential(self):
|
||||||
|
fetch_count["n"] += 1
|
||||||
|
return super().fetch_broker_credential()
|
||||||
|
|
||||||
|
read = CountingStub(
|
||||||
|
daemon_id="d-test",
|
||||||
|
items=[PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||||
|
cash_balance=100, asset_quantity=0)],
|
||||||
|
)
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
)
|
||||||
|
sched.run_once()
|
||||||
|
assert fetch_count["n"] == 1
|
||||||
|
assert sched.get_entry_allowed() is True
|
||||||
|
|
||||||
|
# Second run_once same day: should NOT re-fetch
|
||||||
|
sched.run_once()
|
||||||
|
assert fetch_count["n"] == 1 # still 1 — cache hit
|
||||||
|
|
||||||
|
|
||||||
|
def test_break_glass_skips_fetch(monkeypatch):
|
||||||
|
"""REQ-113 AC-3: TREX_KIS_BROKER_BREAK_GLASS=true → fetch 없이 env 경로."""
|
||||||
|
monkeypatch.setenv("TREX_KIS_BROKER_BREAK_GLASS", "true")
|
||||||
|
read = _make_stub(fetch_raises=True) # If called, would raise
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(
|
||||||
|
read_port=read, store=store, broker=PaperBroker(),
|
||||||
|
credential_fetch_enabled=True,
|
||||||
|
)
|
||||||
|
sched.run_once()
|
||||||
|
# break-glass: entry_allowed should remain True (env path)
|
||||||
|
assert sched.get_entry_allowed() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REQ-114 AC-4/AC-5: IntentService entry gate — BuyIntent 차단, SellIntent 통과
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_intent_service_buy_rejected_when_entry_not_allowed():
|
||||||
|
"""REQ-114 AC-4: entry_allowed=False → BuyIntent admission 거부."""
|
||||||
|
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||||
|
from trex_core.application.intents import BuyIntent, IntentService
|
||||||
|
from trex_core.domain.slot import SlotService
|
||||||
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
svc = SlotService(repo, instance_key="test")
|
||||||
|
symbols = {"PF-1": "005930"}
|
||||||
|
repo.reconcile_universe("test", symbols)
|
||||||
|
|
||||||
|
# entry_allowed_fn always returns False
|
||||||
|
intents = IntentService(
|
||||||
|
svc, repo, InMemoryIntentStore(),
|
||||||
|
resolve_symbol=symbols.get,
|
||||||
|
instance_key="test",
|
||||||
|
entry_allowed_fn=lambda: False,
|
||||||
|
)
|
||||||
|
decision = intents.submit_buy(
|
||||||
|
BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=10, entry_price=1000)
|
||||||
|
)
|
||||||
|
assert not decision.admitted
|
||||||
|
assert decision.reason == "day_start_load_failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_intent_service_sell_passes_when_entry_not_allowed():
|
||||||
|
"""REQ-114 AC-5: entry_allowed=False → SellIntent는 차단 안 됨."""
|
||||||
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
|
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||||
|
from trex_core.application.execution import ExecutionService
|
||||||
|
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||||
|
from trex_core.domain.slot import SlotService
|
||||||
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
svc = SlotService(repo, instance_key="test")
|
||||||
|
symbols = {"PF-1": "005930"}
|
||||||
|
repo.reconcile_universe("test", symbols)
|
||||||
|
|
||||||
|
# First: admit a buy with entry_allowed=True
|
||||||
|
intents_ok = IntentService(
|
||||||
|
svc, repo, InMemoryIntentStore(),
|
||||||
|
resolve_symbol=symbols.get,
|
||||||
|
instance_key="test",
|
||||||
|
entry_allowed_fn=lambda: True,
|
||||||
|
)
|
||||||
|
broker = PaperBroker(fill_after_polls=0)
|
||||||
|
exec_svc = ExecutionService(svc, repo, broker, instance_key="test")
|
||||||
|
d = intents_ok.submit_buy(
|
||||||
|
BuyIntent(intent_id="b1", portfolio_id="PF-1", qty=10, entry_price=1000)
|
||||||
|
)
|
||||||
|
assert d.admitted
|
||||||
|
exec_svc.run_once() # fill → HOLDING
|
||||||
|
|
||||||
|
# Now set entry gate to False (simulating day-start fail)
|
||||||
|
intents_blocked = IntentService(
|
||||||
|
svc, repo, InMemoryIntentStore(),
|
||||||
|
resolve_symbol=symbols.get,
|
||||||
|
instance_key="test",
|
||||||
|
entry_allowed_fn=lambda: False,
|
||||||
|
)
|
||||||
|
sell = SellIntent(intent_id="s1", slot_id=d.slot_id, reason="test_sell")
|
||||||
|
decision = intents_blocked.submit_sell(sell)
|
||||||
|
assert decision.admitted # SellIntent must NOT be blocked
|
||||||
|
|
||||||
|
|
||||||
|
def test_intent_service_buy_passes_when_entry_allowed_fn_none():
|
||||||
|
"""REQ-114 backward compat: entry_allowed_fn=None → gate 비활성 (기존 동작 보존)."""
|
||||||
|
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||||
|
from trex_core.application.intents import BuyIntent, IntentService
|
||||||
|
from trex_core.domain.slot import SlotService
|
||||||
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
svc = SlotService(repo, instance_key="test")
|
||||||
|
symbols = {"PF-2": "000660"}
|
||||||
|
repo.reconcile_universe("test", symbols)
|
||||||
|
|
||||||
|
intents = IntentService(
|
||||||
|
svc, repo, InMemoryIntentStore(),
|
||||||
|
resolve_symbol=symbols.get,
|
||||||
|
instance_key="test",
|
||||||
|
entry_allowed_fn=None, # No gate
|
||||||
|
)
|
||||||
|
decision = intents.submit_buy(
|
||||||
|
BuyIntent(intent_id="b2", portfolio_id="PF-2", qty=5, entry_price=500)
|
||||||
|
)
|
||||||
|
assert decision.admitted
|
||||||
|
|||||||
@@ -115,3 +115,35 @@ def test_safety_sell_enabled_true_explicit_from_toml(tmp_path, monkeypatch):
|
|||||||
path = _write_toml(tmp_path, 'instance_key = "i5"\nsafety_sell_enabled = true\n')
|
path = _write_toml(tmp_path, 'instance_key = "i5"\nsafety_sell_enabled = true\n')
|
||||||
cfg = DaemonConfig.from_config(path)
|
cfg = DaemonConfig.from_config(path)
|
||||||
assert cfg.safety_sell_enabled is True
|
assert cfg.safety_sell_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- credential_fetch_enabled (REQ-113 / BAT-053) ---------------------------
|
||||||
|
|
||||||
|
def test_credential_fetch_enabled_default_false_from_toml(tmp_path, monkeypatch):
|
||||||
|
"""REQ-113 AC-7: TOML에 credential_fetch_enabled 없으면 False (opt-in 기본값)."""
|
||||||
|
inst = DaemonInstanceConfig.from_toml(_write_toml(tmp_path, 'instance_key = "i6"\n'))
|
||||||
|
assert inst.credential_fetch_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_fetch_enabled_false_propagates(tmp_path, monkeypatch):
|
||||||
|
"""REQ-113 AC-1: credential_fetch_enabled=false(부재 포함) → DaemonConfig False."""
|
||||||
|
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||||
|
cfg = DaemonConfig.from_config(_write_toml(tmp_path, 'instance_key = "i7"\n'))
|
||||||
|
assert cfg.credential_fetch_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_fetch_enabled_true_from_toml(tmp_path, monkeypatch):
|
||||||
|
"""REQ-113 AC-7: TOML에 credential_fetch_enabled = true → True 전파."""
|
||||||
|
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||||
|
path = _write_toml(tmp_path, 'instance_key = "i8"\ncredential_fetch_enabled = true\n')
|
||||||
|
inst = DaemonInstanceConfig.from_toml(path)
|
||||||
|
assert inst.credential_fetch_enabled is True
|
||||||
|
cfg = DaemonConfig.from_config(path)
|
||||||
|
assert cfg.credential_fetch_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_env_credential_fetch_enabled_always_false(monkeypatch):
|
||||||
|
"""REQ-113 AC-1: from_env()는 항상 credential_fetch_enabled=False(env-only 경로 보존)."""
|
||||||
|
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||||
|
cfg = DaemonConfig.from_env()
|
||||||
|
assert cfg.credential_fetch_enabled is False
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from trex_core.application.portfolio import (
|
from trex_core.application.portfolio import (
|
||||||
|
BrokerCredentialPayload,
|
||||||
PortfolioHubUnavailableError,
|
PortfolioHubUnavailableError,
|
||||||
PortfolioItem,
|
PortfolioItem,
|
||||||
RealizedPnlItem,
|
RealizedPnlItem,
|
||||||
RealizedPnlResult,
|
RealizedPnlResult,
|
||||||
|
TradingContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +59,12 @@ class PortfolioHubReadAdapter:
|
|||||||
return httpx.get(url, params=params, headers=self._headers(),
|
return httpx.get(url, params=params, headers=self._headers(),
|
||||||
timeout=self._timeout)
|
timeout=self._timeout)
|
||||||
|
|
||||||
def whoami(self) -> str:
|
def _fetch_user_info(self) -> dict:
|
||||||
|
"""Internal helper: GET /v1/user_info and return parsed JSON body.
|
||||||
|
|
||||||
|
Raises PortfolioHubUnavailableError on network error, non-2xx, or parse
|
||||||
|
failure. Shared by whoami() and fetch_broker_credential() (REQ-112).
|
||||||
|
"""
|
||||||
url = f"{self._base_url}/v1/user_info"
|
url = f"{self._base_url}/v1/user_info"
|
||||||
try:
|
try:
|
||||||
resp = self._get(url)
|
resp = self._get(url)
|
||||||
@@ -73,11 +80,52 @@ class PortfolioHubReadAdapter:
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise PortfolioHubUnavailableError("user_info response not JSON") from exc
|
raise PortfolioHubUnavailableError("user_info response not JSON") from exc
|
||||||
did = body.get("daemon_id") if isinstance(body, dict) else None
|
if not isinstance(body, dict):
|
||||||
|
raise PortfolioHubUnavailableError("user_info response not a JSON object")
|
||||||
|
return body
|
||||||
|
|
||||||
|
def whoami(self) -> str:
|
||||||
|
body = self._fetch_user_info()
|
||||||
|
did = body.get("daemon_id")
|
||||||
if not did:
|
if not did:
|
||||||
raise PortfolioHubUnavailableError("user_info missing daemon_id")
|
raise PortfolioHubUnavailableError("user_info missing daemon_id")
|
||||||
return str(did)
|
return str(did)
|
||||||
|
|
||||||
|
def fetch_broker_credential(self) -> TradingContext:
|
||||||
|
"""GET /v1/user_info — broker credential + trading_enabled (REQ-112).
|
||||||
|
|
||||||
|
Returns TradingContext. broker=None when broker field is null or
|
||||||
|
credential_payload is null (decryption failure — treated as absent).
|
||||||
|
NFR-2: app_secret is never logged in plaintext here.
|
||||||
|
Raises PortfolioHubUnavailableError on network/non-2xx/403/parse errors.
|
||||||
|
"""
|
||||||
|
body = self._fetch_user_info()
|
||||||
|
trading_enabled: bool = bool(body.get("trading_enabled", False))
|
||||||
|
broker_raw = body.get("broker")
|
||||||
|
if not isinstance(broker_raw, dict):
|
||||||
|
# broker: null — no credential assigned for this daemon
|
||||||
|
return TradingContext(broker=None, trading_enabled=trading_enabled)
|
||||||
|
|
||||||
|
credential_payload = broker_raw.get("credential_payload")
|
||||||
|
if not isinstance(credential_payload, dict):
|
||||||
|
# credential_payload: null — decryption failure; treat as absent
|
||||||
|
return TradingContext(broker=None, trading_enabled=trading_enabled)
|
||||||
|
|
||||||
|
app_key = credential_payload.get("app_key") or ""
|
||||||
|
app_secret = credential_payload.get("app_secret") or ""
|
||||||
|
account_ref = broker_raw.get("account_ref") or ""
|
||||||
|
raw_ver = broker_raw.get("credential_version")
|
||||||
|
credential_version = int(raw_ver) if raw_ver is not None else None
|
||||||
|
|
||||||
|
payload = BrokerCredentialPayload(
|
||||||
|
account_ref=str(account_ref),
|
||||||
|
app_key=str(app_key),
|
||||||
|
app_secret=str(app_secret),
|
||||||
|
credential_version=credential_version,
|
||||||
|
)
|
||||||
|
# NFR-2: do NOT log app_secret. Log only account_ref + app_key prefix.
|
||||||
|
return TradingContext(broker=payload, trading_enabled=trading_enabled)
|
||||||
|
|
||||||
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
||||||
url = f"{self._base_url}/v1/portfolios"
|
url = f"{self._base_url}/v1/portfolios"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2; REQ-109 stub)."""
|
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2; REQ-109 stub;
|
||||||
|
REQ-112 broker credential stub)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from trex_core.application.portfolio import (
|
from trex_core.application.portfolio import (
|
||||||
|
BrokerCredentialPayload,
|
||||||
|
PortfolioHubUnavailableError,
|
||||||
PortfolioItem,
|
PortfolioItem,
|
||||||
RealizedPnlItem,
|
RealizedPnlItem,
|
||||||
RealizedPnlResult,
|
RealizedPnlResult,
|
||||||
|
TradingContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +22,10 @@ class StubPortfolioRead:
|
|||||||
|
|
||||||
REQ-109: `get_realized_pnl` returns a deterministic fixed RealizedPnlResult.
|
REQ-109: `get_realized_pnl` returns a deterministic fixed RealizedPnlResult.
|
||||||
Tests can override `self.realized_pnl_result` to inject custom fixtures.
|
Tests can override `self.realized_pnl_result` to inject custom fixtures.
|
||||||
|
|
||||||
|
REQ-112: `fetch_broker_credential` returns a deterministic TradingContext.
|
||||||
|
Tests can override `self.trading_context` or set `self.fetch_broker_raises`
|
||||||
|
to simulate failure scenarios without network.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -25,6 +33,8 @@ class StubPortfolioRead:
|
|||||||
daemon_id: str = "stub-daemon",
|
daemon_id: str = "stub-daemon",
|
||||||
items: list[PortfolioItem] | None = None,
|
items: list[PortfolioItem] | None = None,
|
||||||
realized_pnl_result: Optional[RealizedPnlResult] = None,
|
realized_pnl_result: Optional[RealizedPnlResult] = None,
|
||||||
|
trading_context: Optional[TradingContext] = None,
|
||||||
|
fetch_broker_raises: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._daemon_id = daemon_id
|
self._daemon_id = daemon_id
|
||||||
self.items: list[PortfolioItem] = list(items or [
|
self.items: list[PortfolioItem] = list(items or [
|
||||||
@@ -61,6 +71,19 @@ class StubPortfolioRead:
|
|||||||
data_completeness_warning=False,
|
data_completeness_warning=False,
|
||||||
total_realized_pnl=50_000.0,
|
total_realized_pnl=50_000.0,
|
||||||
)
|
)
|
||||||
|
# REQ-112: fixed TradingContext for tests (overridable).
|
||||||
|
# Default: valid credential + trading_enabled=True (happy path).
|
||||||
|
self.trading_context: TradingContext = trading_context or TradingContext(
|
||||||
|
broker=BrokerCredentialPayload(
|
||||||
|
account_ref="stub-account",
|
||||||
|
app_key="stub-app-key",
|
||||||
|
app_secret="stub-app-secret",
|
||||||
|
credential_version=1,
|
||||||
|
),
|
||||||
|
trading_enabled=True,
|
||||||
|
)
|
||||||
|
# REQ-112 AC-4 scenario: set True to simulate PortfolioHubUnavailableError.
|
||||||
|
self.fetch_broker_raises: bool = fetch_broker_raises
|
||||||
|
|
||||||
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
||||||
# Stub does not filter by daemon_id — every test universe is scoped.
|
# Stub does not filter by daemon_id — every test universe is scoped.
|
||||||
@@ -79,3 +102,13 @@ class StubPortfolioRead:
|
|||||||
) -> RealizedPnlResult:
|
) -> RealizedPnlResult:
|
||||||
"""REQ-109 AC-4: deterministic fixed result, no network required."""
|
"""REQ-109 AC-4: deterministic fixed result, no network required."""
|
||||||
return self.realized_pnl_result
|
return self.realized_pnl_result
|
||||||
|
|
||||||
|
def fetch_broker_credential(self) -> TradingContext:
|
||||||
|
"""REQ-112 AC-6: deterministic fixed TradingContext, no network required.
|
||||||
|
|
||||||
|
Override self.trading_context to inject custom test fixtures.
|
||||||
|
Set self.fetch_broker_raises = True to simulate PortfolioHubUnavailableError.
|
||||||
|
"""
|
||||||
|
if self.fetch_broker_raises:
|
||||||
|
raise PortfolioHubUnavailableError("stub: simulated broker credential fetch failure")
|
||||||
|
return self.trading_context
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Optional
|
from typing import Any, Callable, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from trex_core.application.ports import IntentStore
|
from trex_core.application.ports import IntentStore
|
||||||
@@ -110,6 +110,7 @@ class IntentService:
|
|||||||
max_active_slots: Optional[int] = None,
|
max_active_slots: Optional[int] = None,
|
||||||
default_order_timeout_sec: Optional[int] = None,
|
default_order_timeout_sec: Optional[int] = None,
|
||||||
warn_store=None, # Optional[ReconcileWarningStore] — BAT-050 / REQ-104
|
warn_store=None, # Optional[ReconcileWarningStore] — BAT-050 / REQ-104
|
||||||
|
entry_allowed_fn: Optional[Callable[[], bool]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._svc = slot_service
|
self._svc = slot_service
|
||||||
self._repo = slot_repo
|
self._repo = slot_repo
|
||||||
@@ -128,6 +129,11 @@ class IntentService:
|
|||||||
# BAT-050 / REQ-104: reconcile-warning gate store (Optional).
|
# BAT-050 / REQ-104: reconcile-warning gate store (Optional).
|
||||||
# When None, gate is skipped (backward compat, NFR-4).
|
# When None, gate is skipped (backward compat, NFR-4).
|
||||||
self._warn_store = warn_store
|
self._warn_store = warn_store
|
||||||
|
# dcy-036 / REQ-114: day-start entry gate (Optional callable → bool).
|
||||||
|
# When None or returns True, gate passes (backward compat / opt-in=False).
|
||||||
|
# When returns False, BuyIntent is rejected with reason 'day_start_load_failed'.
|
||||||
|
# SellIntent is NOT gated by this (REQ-114 AC-5: 청산 경로 영향 없음).
|
||||||
|
self._entry_allowed_fn: Optional[Callable[[], bool]] = entry_allowed_fn
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
|
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
|
||||||
@@ -196,7 +202,22 @@ class IntentService:
|
|||||||
position; M cap does NOT apply (active asset count unchanged).
|
position; M cap does NOT apply (active asset count unchanged).
|
||||||
- PENDING_* slot -> in-flight conflict (already_active).
|
- PENDING_* slot -> in-flight conflict (already_active).
|
||||||
Single intent thread + instance-scoped advisory lock serialize
|
Single intent thread + instance-scoped advisory lock serialize
|
||||||
admissions (dcy-020 OQ-1)."""
|
admissions (dcy-020 OQ-1).
|
||||||
|
|
||||||
|
dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False,
|
||||||
|
BuyIntent is rejected before any slot lookup."""
|
||||||
|
|
||||||
|
# dcy-036 / REQ-114: day-start entry gate (fail-closed).
|
||||||
|
# None or True → pass; False → reject new entry.
|
||||||
|
# SellIntent is NOT checked (청산 경로는 차단하지 않음 AC-5).
|
||||||
|
if self._entry_allowed_fn is not None:
|
||||||
|
try:
|
||||||
|
if not self._entry_allowed_fn():
|
||||||
|
return IntentDecision(
|
||||||
|
intent.intent_id, "rejected", None, "day_start_load_failed"
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - gate failure → fail-open (conservative)
|
||||||
|
pass
|
||||||
|
|
||||||
def _resolve(sl):
|
def _resolve(sl):
|
||||||
pid = sl.portfolio_id
|
pid = sl.portfolio_id
|
||||||
|
|||||||
@@ -77,8 +77,36 @@ class RealizedPnlResult:
|
|||||||
total_realized_pnl: float
|
total_realized_pnl: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- broker credential (REQ-112)
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BrokerCredentialPayload:
|
||||||
|
"""KIS broker credential fetched from portfolio-hub (REQ-112).
|
||||||
|
|
||||||
|
Carries app_key/app_secret/account_ref for KIS API authentication.
|
||||||
|
credential_version is optional (portfolio-hub versioning, informational only).
|
||||||
|
NFR-2: app_secret must never be logged in plaintext.
|
||||||
|
"""
|
||||||
|
account_ref: str
|
||||||
|
app_key: str
|
||||||
|
app_secret: str
|
||||||
|
credential_version: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TradingContext:
|
||||||
|
"""Combined broker credential + trading_enabled flag from /v1/user_info (REQ-112).
|
||||||
|
|
||||||
|
Returned by fetch_broker_credential() in a single atomic read to avoid
|
||||||
|
double HTTP calls.
|
||||||
|
"""
|
||||||
|
broker: Optional[BrokerCredentialPayload]
|
||||||
|
trading_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
class PortfolioHubReadPort(Protocol):
|
class PortfolioHubReadPort(Protocol):
|
||||||
"""Read-side contract for portfolio-hub universe (REQ-027) + realized PnL (REQ-109)."""
|
"""Read-side contract for portfolio-hub universe (REQ-027) + realized PnL (REQ-109)
|
||||||
|
+ broker credential (REQ-112)."""
|
||||||
|
|
||||||
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
||||||
"""Return all portfolios where `managed_by_daemon_id == daemon_id`."""
|
"""Return all portfolios where `managed_by_daemon_id == daemon_id`."""
|
||||||
@@ -117,6 +145,21 @@ class PortfolioHubReadPort(Protocol):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def fetch_broker_credential(self) -> "TradingContext":
|
||||||
|
"""GET /v1/user_info — broker credential + trading_enabled (REQ-112).
|
||||||
|
|
||||||
|
Returns a TradingContext with:
|
||||||
|
- broker: BrokerCredentialPayload if the daemon has assigned credentials,
|
||||||
|
None if broker is null (no credential assigned) or credential_payload
|
||||||
|
is null (decryption failure).
|
||||||
|
- trading_enabled: bool as reported by portfolio-hub.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PortfolioHubUnavailableError: on network error, non-2xx (incl. 403),
|
||||||
|
or JSON parse failure.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class DegradedReason(str, Enum):
|
class DegradedReason(str, Enum):
|
||||||
"""Machine-readable degraded reason codes (REQ-028 AC-2, dcy-005 OQ-5)."""
|
"""Machine-readable degraded reason codes (REQ-028 AC-2, dcy-005 OQ-5)."""
|
||||||
|
|||||||
@@ -10,18 +10,21 @@ BAT-050 / REQ-103: reconcile now accepts warn_store for durable reconcile-warnin
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, time, timedelta, timezone
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||||
from trex_core.application.broker_port import BrokerPort
|
from trex_core.application.broker_port import BrokerPort
|
||||||
from trex_core.application.portfolio import (
|
from trex_core.application.portfolio import (
|
||||||
|
BrokerCredentialPayload,
|
||||||
DegradedReason,
|
DegradedReason,
|
||||||
PortfolioHubReadPort,
|
PortfolioHubReadPort,
|
||||||
PortfolioHubUnavailableError,
|
PortfolioHubUnavailableError,
|
||||||
ReconcileWarningStore,
|
ReconcileWarningStore,
|
||||||
SnapshotStore,
|
SnapshotStore,
|
||||||
|
TradingContext,
|
||||||
)
|
)
|
||||||
from trex_core.application.reconciliation import reconcile_positions
|
from trex_core.application.reconciliation import reconcile_positions
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ class SnapshotScheduler:
|
|||||||
instance_key: str = "default",
|
instance_key: str = "default",
|
||||||
alarm_emitter: Optional[AlarmEmitter] = None,
|
alarm_emitter: Optional[AlarmEmitter] = None,
|
||||||
warn_store: Optional[ReconcileWarningStore] = None,
|
warn_store: Optional[ReconcileWarningStore] = None,
|
||||||
|
credential_fetch_enabled: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._read = read_port
|
self._read = read_port
|
||||||
self._store = store
|
self._store = store
|
||||||
@@ -65,6 +69,20 @@ class SnapshotScheduler:
|
|||||||
self._alarm_emitter = alarm_emitter
|
self._alarm_emitter = alarm_emitter
|
||||||
# BAT-050 / REQ-103: durable reconcile-warning store (Optional).
|
# BAT-050 / REQ-103: durable reconcile-warning store (Optional).
|
||||||
self._warn_store = warn_store
|
self._warn_store = warn_store
|
||||||
|
# dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection.
|
||||||
|
# credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch.
|
||||||
|
self._credential_fetch_enabled: bool = credential_fetch_enabled
|
||||||
|
# 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()
|
||||||
|
in ("1", "true", "yes")
|
||||||
|
)
|
||||||
|
# REQ-115: day-scoped credential cache. None until first successful fetch.
|
||||||
|
self._cached_credential: Optional[BrokerCredentialPayload] = None
|
||||||
|
self._credential_date: Optional[date] = None
|
||||||
|
# REQ-114: entry gate. True = new entries allowed (default).
|
||||||
|
# credential_fetch_enabled=False keeps this always True (no gate).
|
||||||
|
self._entry_allowed: bool = True
|
||||||
|
|
||||||
# ---- callable from a daemon thread loop --------------------------------
|
# ---- callable from a daemon thread loop --------------------------------
|
||||||
def run_forever(self, stop: threading.Event, *, tick_sec: float = 60.0) -> None:
|
def run_forever(self, stop: threading.Event, *, tick_sec: float = 60.0) -> None:
|
||||||
@@ -87,9 +105,128 @@ class SnapshotScheduler:
|
|||||||
return # already fired today
|
return # already fired today
|
||||||
self.run_once()
|
self.run_once()
|
||||||
|
|
||||||
|
# ---- entry-allowed gate (REQ-114) ------------------------------------
|
||||||
|
def get_entry_allowed(self) -> bool:
|
||||||
|
"""Return current day-start entry gate state (REQ-114).
|
||||||
|
|
||||||
|
True = new entries allowed (default, or credential_fetch_enabled=False).
|
||||||
|
False = day-start load failed (fail-closed); BuyIntent admission blocked.
|
||||||
|
Thread-safe: _entry_allowed is set only in run_once() (scheduler thread).
|
||||||
|
"""
|
||||||
|
return self._entry_allowed
|
||||||
|
|
||||||
|
# ---- credential fetch hook (REQ-112/113/114/115) ----------------------
|
||||||
|
def _maybe_fetch_credential(self) -> None:
|
||||||
|
"""Attempt credential fetch at day-start checkpoint (REQ-114).
|
||||||
|
|
||||||
|
Called at the start of run_once() when credential_fetch_enabled=True.
|
||||||
|
Applies priority:
|
||||||
|
1. break-glass (TREX_KIS_BROKER_BREAK_GLASS=true): skip fetch, log INFO.
|
||||||
|
2. day-scoped cache hit (already fetched today): skip re-fetch (REQ-115 AC-3).
|
||||||
|
3. fetch from portfolio-hub (REQ-112): cache on success, fail-closed on failure.
|
||||||
|
|
||||||
|
Side effects:
|
||||||
|
- Sets self._entry_allowed (True on success/break-glass, False on failure).
|
||||||
|
- Sets self._cached_credential / self._credential_date on success.
|
||||||
|
- Emits CRITICAL alarm DAY_START_LOAD_FAILED on fail-closed (REQ-114).
|
||||||
|
- NFR-2: app_secret is never logged in plaintext.
|
||||||
|
"""
|
||||||
|
if self._break_glass:
|
||||||
|
_log.info(
|
||||||
|
"credential fetch: break-glass active (TREX_KIS_BROKER_BREAK_GLASS=true) "
|
||||||
|
"— using env KIS config; fetch skipped (instance=%s)",
|
||||||
|
self._instance_key,
|
||||||
|
)
|
||||||
|
# break-glass: treat as env-only, entry allowed
|
||||||
|
self._entry_allowed = True
|
||||||
|
return
|
||||||
|
|
||||||
|
today = self._clock().astimezone(_KST).date()
|
||||||
|
if self._credential_date is not None and self._credential_date == today:
|
||||||
|
# REQ-115 AC-3: day-scoped cache hit — no re-fetch
|
||||||
|
_log.debug(
|
||||||
|
"credential fetch: cache hit for %s (instance=%s)",
|
||||||
|
today, self._instance_key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Attempt fetch
|
||||||
|
try:
|
||||||
|
ctx: TradingContext = self._read.fetch_broker_credential()
|
||||||
|
except PortfolioHubUnavailableError as exc:
|
||||||
|
_log.error(
|
||||||
|
"DAY_START_LOAD_FAILED: portfolio-hub unavailable at day-start "
|
||||||
|
"(instance=%s): %s",
|
||||||
|
self._instance_key, exc,
|
||||||
|
)
|
||||||
|
self._entry_allowed = False
|
||||||
|
self._emit_day_start_alarm(reason="portfolio_hub_unavailable")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 - unexpected failure = fail-closed
|
||||||
|
_log.error(
|
||||||
|
"DAY_START_LOAD_FAILED: unexpected error fetching broker credential "
|
||||||
|
"(instance=%s): %s",
|
||||||
|
self._instance_key, exc.__class__.__name__,
|
||||||
|
)
|
||||||
|
self._entry_allowed = False
|
||||||
|
self._emit_day_start_alarm(reason="unexpected_error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Evaluate fail-closed conditions (REQ-114)
|
||||||
|
if ctx.broker is None:
|
||||||
|
_log.error(
|
||||||
|
"DAY_START_LOAD_FAILED: broker credential absent (broker=null) "
|
||||||
|
"(instance=%s)",
|
||||||
|
self._instance_key,
|
||||||
|
)
|
||||||
|
self._entry_allowed = False
|
||||||
|
self._emit_day_start_alarm(reason="broker_null")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not ctx.trading_enabled:
|
||||||
|
_log.error(
|
||||||
|
"DAY_START_LOAD_FAILED: trading_enabled=False at day-start "
|
||||||
|
"(instance=%s)",
|
||||||
|
self._instance_key,
|
||||||
|
)
|
||||||
|
self._entry_allowed = False
|
||||||
|
self._emit_day_start_alarm(reason="trading_disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Success: cache + allow entry
|
||||||
|
self._cached_credential = ctx.broker
|
||||||
|
self._credential_date = today
|
||||||
|
self._entry_allowed = True
|
||||||
|
# NFR-2: log account_ref only, never app_secret
|
||||||
|
_log.info(
|
||||||
|
"DAY_START credential loaded: account_ref=%s trading_enabled=%s "
|
||||||
|
"credential_version=%s (instance=%s, source=fetch)",
|
||||||
|
ctx.broker.account_ref,
|
||||||
|
ctx.trading_enabled,
|
||||||
|
ctx.broker.credential_version,
|
||||||
|
self._instance_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _emit_day_start_alarm(self, *, reason: str) -> None:
|
||||||
|
"""Publish CRITICAL DAY_START_LOAD_FAILED alarm (REQ-114)."""
|
||||||
|
if self._alarm_emitter is None:
|
||||||
|
return
|
||||||
|
self._alarm_emitter.publish_alarm(
|
||||||
|
"DAY_START_LOAD_FAILED",
|
||||||
|
"그날-시작 KIS 인증정보 로드 실패: 신규 진입 차단",
|
||||||
|
fields={"reason": reason, "instance_key": self._instance_key},
|
||||||
|
severity="critical",
|
||||||
|
)
|
||||||
|
|
||||||
# ---- the actual refresh -----------------------------------------------
|
# ---- the actual refresh -----------------------------------------------
|
||||||
def run_once(self) -> bool:
|
def run_once(self) -> bool:
|
||||||
"""Fetch + swap + reconcile. Returns True on success, False if degraded."""
|
"""Fetch + swap + reconcile. Returns True on success, False if degraded."""
|
||||||
|
# dcy-036 / REQ-114: day-start credential fetch hook (opt-in).
|
||||||
|
# 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:
|
||||||
|
self._maybe_fetch_credential()
|
||||||
|
|
||||||
if self._daemon_id is None:
|
if self._daemon_id is None:
|
||||||
try:
|
try:
|
||||||
self._daemon_id = self._read.whoami()
|
self._daemon_id = self._read.whoami()
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ class DaemonInstanceConfig:
|
|||||||
# back to env TREX_WATCHDOG_TIMEOUT (default 120). Long-term instances
|
# back to env TREX_WATCHDOG_TIMEOUT (default 120). Long-term instances
|
||||||
# polling every 1-10 min should set >= 1800 so expired stays meaningful.
|
# polling every 1-10 min should set >= 1800 so expired stays meaningful.
|
||||||
watchdog_timeout_sec: Optional[int] = None
|
watchdog_timeout_sec: Optional[int] = None
|
||||||
|
# dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default)
|
||||||
|
# = env-only KIS config (byte-for-byte backward compat). True = fetch 1st,
|
||||||
|
# TREX_KIS_BROKER_BREAK_GLASS=true = skip fetch and use env.
|
||||||
|
credential_fetch_enabled: bool = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_toml(path: str) -> "DaemonInstanceConfig":
|
def from_toml(path: str) -> "DaemonInstanceConfig":
|
||||||
@@ -196,6 +200,7 @@ class DaemonInstanceConfig:
|
|||||||
default_order_timeout_sec=default_timeout,
|
default_order_timeout_sec=default_timeout,
|
||||||
safety_sell_enabled=bool(data.get("safety_sell_enabled", True)),
|
safety_sell_enabled=bool(data.get("safety_sell_enabled", True)),
|
||||||
watchdog_timeout_sec=watchdog_timeout,
|
watchdog_timeout_sec=watchdog_timeout,
|
||||||
|
credential_fetch_enabled=bool(data.get("credential_fetch_enabled", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -242,6 +247,10 @@ class DaemonConfig:
|
|||||||
ledger_retry_base_sec: float = 1.0
|
ledger_retry_base_sec: float = 1.0
|
||||||
ledger_retry_cap_sec: float = 300.0
|
ledger_retry_cap_sec: float = 300.0
|
||||||
ledger_max_attempts: int = 8
|
ledger_max_attempts: int = 8
|
||||||
|
# dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default)
|
||||||
|
# = env-only KIS config (byte-for-byte backward compat). True = fetch 1st,
|
||||||
|
# TREX_KIS_BROKER_BREAK_GLASS=true env = skip fetch and use env instead.
|
||||||
|
credential_fetch_enabled: bool = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "DaemonConfig":
|
def from_env() -> "DaemonConfig":
|
||||||
@@ -367,4 +376,5 @@ class DaemonConfig:
|
|||||||
max_active_slots=inst.max_active_slots,
|
max_active_slots=inst.max_active_slots,
|
||||||
safety_sell_enabled=inst.safety_sell_enabled,
|
safety_sell_enabled=inst.safety_sell_enabled,
|
||||||
safety_sell=safety_sell,
|
safety_sell=safety_sell,
|
||||||
|
credential_fetch_enabled=inst.credential_fetch_enabled,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -169,14 +169,6 @@ class Daemon:
|
|||||||
self.pause_store = PostgreSQLPauseStore(config.dsn)
|
self.pause_store = PostgreSQLPauseStore(config.dsn)
|
||||||
# BAT-050 / REQ-102: durable reconcile-warning store (slot_id PK).
|
# BAT-050 / REQ-102: durable reconcile-warning store (slot_id PK).
|
||||||
self.warn_store = PostgreSQLReconcileWarningStore(config.dsn)
|
self.warn_store = PostgreSQLReconcileWarningStore(config.dsn)
|
||||||
self.intent_service = IntentService(
|
|
||||||
self.slot_service, self.slot_repo, self.intent_store,
|
|
||||||
resolve_symbol=self._resolve_symbol,
|
|
||||||
instance_key=config.instance_key,
|
|
||||||
max_active_slots=config.max_active_slots, # dcy-020: asset-centric gate
|
|
||||||
default_order_timeout_sec=config.default_order_timeout_sec, # dcy-022
|
|
||||||
warn_store=self.warn_store, # BAT-050 / REQ-104: reconcile-warning gate
|
|
||||||
)
|
|
||||||
# BAT-012: day-scoped kill-switch exclusion store + service.
|
# BAT-012: day-scoped kill-switch exclusion store + service.
|
||||||
self.killswitch_store = PostgreSQLKillSwitchStore(config.dsn)
|
self.killswitch_store = PostgreSQLKillSwitchStore(config.dsn)
|
||||||
self.portfolio_read = _build_portfolio_read(config)
|
self.portfolio_read = _build_portfolio_read(config)
|
||||||
@@ -223,6 +215,23 @@ class Daemon:
|
|||||||
instance_key=config.instance_key,
|
instance_key=config.instance_key,
|
||||||
alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100
|
alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100
|
||||||
warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning
|
warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning
|
||||||
|
credential_fetch_enabled=config.credential_fetch_enabled, # dcy-036 / REQ-113
|
||||||
|
)
|
||||||
|
# dcy-036 / REQ-114: wire entry_allowed_fn after snapshot_scheduler is created.
|
||||||
|
# When credential_fetch_enabled=False (default), entry_allowed_fn=None = no gate.
|
||||||
|
entry_allowed_fn = (
|
||||||
|
self.snapshot_scheduler.get_entry_allowed
|
||||||
|
if config.credential_fetch_enabled
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self.intent_service = IntentService(
|
||||||
|
self.slot_service, self.slot_repo, self.intent_store,
|
||||||
|
resolve_symbol=self._resolve_symbol,
|
||||||
|
instance_key=config.instance_key,
|
||||||
|
max_active_slots=config.max_active_slots, # dcy-020: asset-centric gate
|
||||||
|
default_order_timeout_sec=config.default_order_timeout_sec, # dcy-022
|
||||||
|
warn_store=self.warn_store, # BAT-050 / REQ-104: reconcile-warning gate
|
||||||
|
entry_allowed_fn=entry_allowed_fn, # dcy-036 / REQ-114: day-start gate
|
||||||
)
|
)
|
||||||
self.execution = ExecutionService(
|
self.execution = ExecutionService(
|
||||||
self.slot_service, self.slot_repo, self.broker,
|
self.slot_service, self.slot_repo, self.broker,
|
||||||
|
|||||||
Reference in New Issue
Block a user