feat(bat-053): 그날-시작 KIS 인증정보 fetch + fail-safe 로드 (REQ-112~115)

REQ-112: BrokerCredentialPayload/TradingContext 데이터클래스 + PortfolioHubReadPort.fetch_broker_credential() 포트 메서드 + hub_read 어댑터(_fetch_user_info 헬퍼 추출) + stub_read 결정론 stub
REQ-113: DaemonInstanceConfig/DaemonConfig.credential_fetch_enabled opt-in(기본 False) + TOML 파싱 + break-glass(TREX_KIS_BROKER_BREAK_GLASS) 감지
REQ-114: SnapshotScheduler._maybe_fetch_credential() — fail-closed 3조건(unavailable/broker-null/trading_disabled) + CRITICAL 알람 DAY_START_LOAD_FAILED + IntentService entry_allowed_fn 게이트(BuyIntent 차단, SellIntent 통과)
REQ-115: 그날 고정 캐시(_cached_credential/_credential_date) — 동일 날짜 재fetch 방지 + app_secret 메모리 한정(Redis 미기록)

스텁 기반 구현: 실 portfolio-hub/네트워크 없이 AC 전부 단위 테스트.
회귀: credential_fetch_enabled=False(기본) 경로 byte-for-byte 보존.
745 passed, 47 skipped (0 failed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-28 11:00:18 +09:00
co-authored by Claude Sonnet 4.6
parent f9c4fb4eba
commit e4e5c70422
16 changed files with 1175 additions and 21 deletions
@@ -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).
+1
View File
@@ -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/) |
+140 -2
View File
@@ -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
+43 -2
View File
@@ -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()
+342 -2
View File
@@ -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
+32
View File
@@ -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
+50 -2
View File
@@ -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:
+34 -1
View File
@@ -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
+23 -2
View File
@@ -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
+44 -1
View File
@@ -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)."""
+138 -1
View File
@@ -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()
+10
View File
@@ -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,
) )
+17 -8
View File
@@ -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,