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:
@@ -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).
|
||||||
@@ -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/) |
|
||||||
|
|||||||
@@ -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