diff --git a/.github/skills/review-discovery/SKILL.md b/.github/skills/review-discovery/SKILL.md new file mode 100644 index 0000000..1e4f1e9 --- /dev/null +++ b/.github/skills/review-discovery/SKILL.md @@ -0,0 +1,211 @@ +--- +name: review-discovery +description: "Use when: reviewing an existing Discovery document for missing content, additional OQ candidates, internal consistency, duplication, unnecessary content, placeholder residue, and REQ handoff readiness without changing lifecycle state." +argument-hint: "예: dcy-001 또는 docs/discovery/dcy-001_20260424_topic.md" +user-invocable: true +--- + +# Purpose + +This skill reviews an existing Discovery document as a non-gating quality check before `confirm-discovery` or before human review. It looks for missing content, candidate open questions, internal inconsistencies, duplicated statements, unnecessary text, placeholder residue, and weak handoff readiness. + +이 skill은 별도 lifecycle stage를 만들지 않는다. 기본 목적은 Discovery 품질을 점검하고 수정 우선순위를 정리하는 것이며, 문서 상태 전환이나 index 갱신은 수행하지 않는다. + +# When to use + +- `/review-discovery dcy-xyz` 형태로 Discovery 문서를 먼저 점검하고 싶을 때 +- `confirm-discovery` 전에 누락, 중복, OQ 후보, 표현 불일치를 빠르게 정리하고 싶을 때 +- Discovery 문서가 길어지면서 같은 내용이 여러 섹션에 흩어졌는지 확인해야 할 때 +- 템플릿 잔여 문구, 불필요한 상세 구현 설명, 근거 없는 확정 표현이 남아 있는지 보고 싶을 때 +- 사람이 수정할 항목과 AI가 바로 정리할 수 있는 항목을 분리해서 보고 싶을 때 + +# Inputs + +- Discovery 식별자 + - `dcy-001` 같은 prefix + - `dcy-001_20260424_topic-slug` 같은 전체 ID + - `docs/discovery/.md` 같은 직접 경로 +- 대상 Discovery 문서 본문 +- 필요 시 관련 저장소 문맥 + - 현재 저장소의 관련 파일, 문서, 설정 + - `docs/discovery/index.md` + - `docs/project-structure.md`, `docs/runtime-flows.md` +- 현재 시각 (KST) + +# Core Rules + +## 1. 역할 경계 + +- 이 skill은 review helper이며, active SDLC 단계로 취급하지 않는다. +- 아래 항목은 수정하지 않는다. + - `# 0. 문서 상태`의 `상태` + - `# 11. Discovery Freeze`의 `Handoff Decision`, `Ready for REQ Drafting`, `Confirmed By`, `Confirmed At (KST)` + - `docs/discovery/index.md` +- 이 skill은 REQ 생성, 승인, 상태 승격을 하지 않는다. +- 사실 관계를 바꾸는 추정성 수정은 하지 않는다. 근거 없는 항목은 finding 또는 OQ 후보로 남긴다. +- 사용자가 단순 검토만 요청한 경우 결과는 findings-first로 보고한다. +- 사용자가 `정리`, `수정`, `보강`, `반영`까지 명시한 경우에만 의미 변화가 없는 경미한 편집을 수행할 수 있다. + +## 2. 기본 검토 축 + +반드시 아래 항목을 검토한다. + +### 2-1. 누락 항목 + +- 템플릿 기준으로 필요한 섹션과 핵심 내용이 빠지지 않았는지 확인한다. +- 빈 bullet, `TBD`, `{{...}}`, `필요 없으면 삭제` 잔여 문구를 누락 또는 미정리 항목으로 본다. +- 아래 핵심 정보가 약하거나 비어 있으면 finding으로 기록한다. + - 문제의 현재 상태와 기대 상태 + - 핵심 변경 항목과 영향 범위 + - FR/NFR와 범위 경계 + - 리스크, 가정, 성공 기준 + - REQ handoff 전 확인 기준 +- 단, `# 2. 요구사항 판정 결과`가 `완전 중복`이면 템플릿 가이드에 따라 이후 섹션 축약을 허용한다. 이 경우 `# 9. 파일 처리 결과`까지의 중복 판정 근거와 참조만 충분한지 본다. + +### 2-2. 추가 OQ 후보 + +- 아래 조건이면 새 OQ 후보를 제안한다. + - 범위 경계가 모호해 In Scope/Out of Scope가 흔들리는 경우 + - 성공 기준은 있는데 측정 방식이나 데이터 출처가 없는 경우 + - 리스크 완화 책임자나 판단 주체가 비어 있는 경우 + - 외부 정책, 외부 시스템, 운영 절차, 승인자 결정이 필요한데 본문에 근거가 없는 경우 + - 서로 다른 섹션이 충돌해서 사람 판단 없이는 확정할 수 없는 경우 +- 이미 다른 섹션에서 답이 있는 질문은 OQ로 늘리지 말고, answered cleanup 대상으로 분류한다. +- 사소한 문장 다듬기나 서식 문제는 OQ로 만들지 않는다. + +### 2-3. 문서 일관성 + +- 이슈명, 핵심 변경, FR/NFR, 범위 경계, 성공 기준, Freeze rationale 사이의 용어와 범위가 일치하는지 확인한다. +- `# 4. 이번에 정의할 변경`, `# 5. 요구사항 목록`, `# 6. 리스크/가정 목록`, `# 7. 초기 성공 기준`, `# 8. REQ로 넘기기 전 확인 체크`, `# 10. 사용자 결정 필요 항목 요약`, `# 11. Discovery Freeze`의 연결 관계를 본다. +- `Open` OQ가 남아 있는데 Freeze가 사실상 진행 가능처럼 읽히는지 확인한다. +- 문서 안에서 같은 개념을 서로 다른 이름으로 부르는 경우 정규화 필요 finding으로 기록한다. + +### 2-4. 중복 제거 대상 + +- 같은 사실이나 요구를 여러 섹션에서 반복하는지 찾는다. +- 중복은 아래 세 종류로 분류한다. + - 완전 중복: 문장만 다르지만 의미가 같은 경우 + - 부분 중복: 핵심은 같고 세부만 다른 경우 + - 잘못된 재기술: 상위 요약과 하위 상세가 서로 다른 말을 하는 경우 +- 중복이 있으면 어떤 섹션을 source of truth로 둘지 함께 제안한다. + +### 2-5. 불필요한 내용 + +- Discovery 단계에 필요 없는 구현 디테일, 과도한 알고리즘 설계, 배포 절차 상세, 이미 결정된 이력 서술을 찾는다. +- 현재 반복의 문제 정의와 성공 기준에 기여하지 않는 배경 설명은 삭제 또는 압축 후보로 본다. +- 템플릿 가이드 문구, 예시 텍스트, 삭제 예정 placeholder 설명이 본문에 남아 있으면 제거 대상으로 본다. + +## 3. 추가 검토 항목 + +사용자 요청 외에도 아래를 함께 본다. + +### 3-1. 근거와 사실 일치성 + +- 문서가 현재 저장소 상태를 서술하는 경우, 그 주장에 직접 관련된 파일이나 문서와 모순이 없는지 확인한다. +- 저장소 확인은 문서의 핵심 주장과 직접 연결된 범위만 탐색한다. 구현 전체를 감사하지는 않는다. +- 저장소 근거와 충돌하는 서술은 높은 우선순위 finding으로 기록한다. + +### 3-2. 실행 가능성과 측정 가능성 + +- FR/NFR, 성공 기준, 체크 항목이 다음 단계에서 실제 REQ/검증 항목으로 이어질 정도로 구체적인지 본다. +- `개선한다`, `지원한다`, `정리한다`처럼 모호한 동사만 있고 판정 기준이 없으면 구체화 필요 finding으로 기록한다. +- 성공 기준은 가능한 한 측정 방식과 데이터 출처에 연결되어야 한다. + +### 3-3. 플레이스홀더 위생 + +- 사용자 결정이 필요한 placeholder와 단순 미정리 placeholder를 구분한다. +- 같은 의미의 placeholder가 본문과 `# 10. 사용자 결정 필요 항목 요약`에 중복으로 남아 있으면 정리 대상으로 본다. +- 이미 문서 근거로 해소 가능한 placeholder는 unresolved로 방치하지 않는다. + +## 4. 허용되는 편집 범위 + +사용자가 수정까지 요청한 경우에만 아래 경미한 편집을 허용한다. + +- 중복 문장 삭제 또는 압축 +- 템플릿 잔여 문구 제거 +- 번호, 체크리스트, 링크, 용어 표기 일관성 정리 +- 답이 이미 문서 안에 있는 OQ를 본문 반영 후 resolved cleanup으로 정리 +- 의미 변화 없는 섹션 재배치 +- 실제 내용이 바뀌었을 때 `마지막 갱신 시각(KST)` 갱신 + +아래는 이 skill로 수정하지 않는다. + +- 새 요구사항 생성 +- 범위 재정의 +- 정책 확정 +- 승인자 지정 +- 상태 전환과 index 갱신 + +# Review Heuristics By Section + +섹션별로 최소한 아래를 점검한다. + +- `# 0. 문서 상태` + - 문서 ID, 이슈명, 시각 정보, 참조 필드가 자기모순 없는지 확인한다. +- `# 1. 계획 상태 요약` + - 현재 작성 배경과 이번 Discovery의 핵심 판단 포인트가 2줄 요약으로 드러나는지 본다. +- `# 2. 요구사항 판정 결과` + - 신규/유사/중복 판정과 근거가 실제 본문 범위와 맞는지 본다. +- `# 3. 문제점의 요약` + - 현재 상태, 기대 상태, 이해관계자, 시나리오가 왜 이 Discovery가 필요한지를 설명하는지 본다. +- `# 4. 이번에 정의할 변경` + - 변경 항목과 영향 범위가 `# 5` 요구사항과 연결되는지 본다. +- `# 5. 요구사항 목록` + - FR/NFR가 구분되어 있고 범위 경계가 충돌하지 않는지 본다. +- `# 6. 리스크/가정 목록` + - 리스크, 가정, OQ가 각각 역할에 맞게 쓰였는지 본다. +- `# 7. 초기 성공 기준` + - 측정 대상, 방식, 데이터 출처가 요구사항과 연결되는지 본다. +- `# 8. REQ로 넘기기 전 확인 체크` + - 체크 항목이 선언만 있고 판단 기준이 없는지 본다. +- `# 9. 파일 처리 결과` + - 참조 문서가 실제 검토 근거를 반영하는지 본다. +- `# 10. 사용자 결정 필요 항목 요약` + - DECIDE, CONFIRM, DATA가 실제 unresolved 항목과 맞는지 본다. +- `# 11. Discovery Freeze` + - review 결과와 무관하게 상태 전환 없이, 본문과 논리적 충돌만 점검한다. + +# Execution Procedure + +1. 입력에서 Discovery 문서 경로를 확정한다. +2. 대상 Discovery 문서를 읽고, 필요 시 `docs/discovery/index.md`와 직접 관련된 저장소 문맥을 읽는다. +3. 문서를 `중복 판정으로 축약 가능한 문서`인지 `정상 Discovery 검토 대상`인지 먼저 분류한다. +4. 위 `기본 검토 축`과 `추가 검토 항목` 기준으로 findings를 수집한다. +5. findings를 아래 우선순위로 분류한다. + - `blocking`: REQ handoff 또는 사실 관계에 직접 문제를 만드는 항목 + - `major`: 지금 고치는 편이 좋은 구조/일관성/측정 가능성 문제 + - `minor`: 표현, 중복, 서식, 경미한 cleanup 문제 +6. 추가 OQ 후보가 있으면 기존 OQ와 중복 여부를 제거한 뒤 후보 목록으로 정리한다. +7. 사용자가 수정까지 요청한 경우에만 `허용되는 편집 범위` 안에서 경미한 cleanup을 수행한다. +8. 편집을 수행한 경우 `마지막 갱신 시각(KST)`만 갱신하고, 상태 전환 없이 종료한다. +9. 최종적으로 findings, OQ 후보, auto-fix 결과, 다음 추천 행동을 보고한다. + +# Output Expectations + +작업이 끝나면 아래 내용을 보고할 수 있어야 한다. + +- 대상 `DISCOVERY_ID`와 문서 경로 +- 전체 판정 + - `양호, 바로 confirm-discovery 검토 가능` + - `경미한 정리 필요` + - `추가 보강 필요` + - `문서 구조 또는 사실 관계 재정비 필요` +- severity별 findings 목록 +- 새 OQ 후보 목록 +- 중복/불필요 내용 정리 제안 +- 자동 정리한 항목 목록(수정 요청이 있었을 때만) +- 다음 추천 행동 + - `confirm-discovery` 진행 + - Discovery 문서 추가 수정 후 재검토 + - 범위가 바뀌어 `new-discovery` 또는 `change-req` 판단 필요 + +# Validation + +완료 전에 아래를 확인한다. + +- 대상 경로가 실제 Discovery 문서를 가리키는지 확인한다. +- `완전 중복` 예외를 제외하고, 누락 판단이 템플릿 구조와 충돌하지 않는지 확인한다. +- 제안한 OQ가 이미 본문이나 다른 섹션에서 답을 가진 항목이 아닌지 확인한다. +- 같은 finding을 누락/중복/불필요 항목에 중복 보고하지 않았는지 확인한다. +- 수정이 있었다면 상태 전환 값과 `docs/discovery/index.md`가 바뀌지 않았는지 확인한다. +- 저장소 사실 관계와의 충돌을 지적했다면 최소 한 개 이상의 직접 근거가 있는지 확인한다. \ No newline at end of file diff --git a/app/api/health.py b/app/api/health.py index 592029a..0b4564f 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -1,10 +1,32 @@ """Health and readiness endpoints.""" +from __future__ import annotations + +from datetime import datetime, timezone, timedelta + from fastapi import APIRouter +from pydantic import BaseModel + +from app.core.config import get_settings router = APIRouter(tags=["health"]) +_KST = timezone(timedelta(hours=9)) -@router.get("/health") -def healthcheck() -> dict[str, str]: - return {"status": "ok"} + +class HealthResponse(BaseModel): + status: str + service: str + version: str + checked_at: str + + +@router.get("/health", response_model=HealthResponse) +def healthcheck() -> HealthResponse: + settings = get_settings() + return HealthResponse( + status="ok", + service="portfolio-hub", + version=settings.app_version, + checked_at=datetime.now(_KST).isoformat(), + ) diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py new file mode 100644 index 0000000..81419ad --- /dev/null +++ b/app/api/v1_assets.py @@ -0,0 +1,61 @@ +"""GET /v1/assets — service-token authenticated asset list endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.core.auth import AuthContext, require_read +from app.repositories.factory import get_portfolio_repository_from_settings +from app.repositories.portfolio_repository import PortfolioRepository +from app.schemas.v1 import AssetItem, AssetsResponse + +router = APIRouter(prefix="/v1", tags=["v1"]) + + +def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext: + if auth.token_source != "db" or auth.token_type != "service": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Service token required", + ) + return auth + + +@router.get("/assets", response_model=AssetsResponse) +def list_assets(auth: AuthContext = Depends(_require_service_token)) -> AssetsResponse: + from app.core.config import get_settings + + settings = get_settings() + repo = get_portfolio_repository_from_settings(settings) + try: + portfolios = repo.list_portfolios_with_strategies( + owner_user_id=None, + include_disabled=False, + ) + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + + items = [ + AssetItem( + asset_id=p.portfolio_id, + broker=p.broker, + market=p.market, + symbol=p.symbol, + name=p.name, + status=p.status, + cash_balance=p.cash_balance, + asset_quantity=p.asset_quantity, + price=p.price, + profit_buffer=p.profit_buffer, + active_strategy_id=p.active_strategy_id, + available_strategies=p.avabile_strategies, + tags=p.tags, + owner_user_id=p.owner_user_id, + created_at=p.created_at, + updated_at=p.updated_at, + last_trade_at=p.last_trade_at, + ) + for p in portfolios + ] + return AssetsResponse(assets=items, total=len(items)) diff --git a/app/api/v1_ledger.py b/app/api/v1_ledger.py new file mode 100644 index 0000000..36eeebc --- /dev/null +++ b/app/api/v1_ledger.py @@ -0,0 +1,66 @@ +"""POST /v1/assets/{asset_id}/ledger/trades — idempotent ledger trade recording.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.core.auth import AuthContext, require_read +from app.repositories.factory import get_portfolio_repository_from_settings +from app.schemas.v1 import LedgerTradeRequest, LedgerTradeResponse + +router = APIRouter(prefix="/v1", tags=["v1"]) + + +def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext: + if auth.token_source != "db" or auth.token_type != "service": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Service token required", + ) + return auth + + +@router.post( + "/assets/{asset_id}/ledger/trades", + response_model=LedgerTradeResponse, + status_code=status.HTTP_201_CREATED, +) +def record_ledger_trade( + asset_id: str, + body: LedgerTradeRequest, + auth: AuthContext = Depends(_require_service_token), +) -> LedgerTradeResponse: + from app.core.config import get_settings + + settings = get_settings() + repo = get_portfolio_repository_from_settings(settings) + try: + trade, created = repo.insert_ledger_trade( + asset_id=asset_id, + order_id=body.order_id, + execution_id=body.execution_id, + side=body.side, + quantity=float(body.quantity), + price=float(body.price), + executed_at=body.executed_at, + fee_amount=float(body.fee_amount), + tax_amount=float(body.tax_amount), + ) + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + + return LedgerTradeResponse( + ledger_entry_id=trade.ledger_entry_id, + asset_id=trade.asset_id, + order_id=trade.order_id, + execution_id=trade.execution_id, + side=trade.side, + quantity=trade.quantity, + price=trade.price, + fee_amount=trade.fee_amount, + tax_amount=trade.tax_amount, + executed_at=trade.executed_at, + recorded_at=trade.recorded_at, + created=created, + ) diff --git a/app/api/v1_user_info.py b/app/api/v1_user_info.py new file mode 100644 index 0000000..b487521 --- /dev/null +++ b/app/api/v1_user_info.py @@ -0,0 +1,53 @@ +"""GET /v1/user_info — service-token authenticated user info endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.core.auth import AuthContext, require_read +from app.repositories.factory import get_portfolio_repository_from_settings +from app.schemas.v1 import UserInfoResponse + +router = APIRouter(prefix="/v1", tags=["v1"]) + + +def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext: + if auth.token_source != "db" or auth.token_type != "service": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Service token required", + ) + return auth + + +@router.get("/user_info", response_model=UserInfoResponse) +def get_user_info(auth: AuthContext = Depends(_require_service_token)) -> UserInfoResponse: + if not auth.runtime_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="runtime_id not assigned to this token", + ) + + from app.core.config import get_settings + + settings = get_settings() + repo = get_portfolio_repository_from_settings(settings) + try: + user = repo.get_user(auth.user_id) # type: ignore[arg-type] + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + + disabled_reason: str | None = None + if not user.is_active: + disabled_reason = "account_inactive" + + return UserInfoResponse( + user_id=user.id, + username=user.username, + email=user.email, + permission=user.permission, + is_active=user.is_active, + disabled_reason=disabled_reason, + runtime_id=auth.runtime_id, + ) diff --git a/app/cli/commands/service_tokens.py b/app/cli/commands/service_tokens.py index 83206fb..b659566 100644 --- a/app/cli/commands/service_tokens.py +++ b/app/cli/commands/service_tokens.py @@ -78,6 +78,7 @@ def issue_service_token( name=name, expires_at=issued.auth_token.expires_at, revoked=issued.auth_token.revoked_at is not None, + runtime_id=issued.auth_token.runtime_id, token=issued.token, ) render_service_token_result(ctx.obj.console, ctx.obj.output_format, response) diff --git a/app/core/auth.py b/app/core/auth.py index 4f60f10..2711c13 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -28,6 +28,7 @@ class AuthContext: token_id: str | None = None token_type: str | None = None token_source: str = "env" + runtime_id: str | None = None def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]: repository = get_portfolio_repository_from_settings(settings) @@ -66,6 +67,7 @@ def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioR token_id=principal.token_id, token_type=principal.token_type, token_source="db", + runtime_id=principal.runtime_id, ) diff --git a/app/core/config.py b/app/core/config.py index c6a9b03..b080ce4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -7,7 +7,7 @@ from pathlib import Path from urllib.parse import quote_plus from dotenv import load_dotenv -from pydantic import Field, computed_field +from pydantic import Field, computed_field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Load .env file from project root @@ -50,6 +50,7 @@ class Settings(BaseSettings): loki_host: str = Field(default="localhost", validation_alias="LOKI_HOST") loki_port: int = Field(default=3100, validation_alias="LOKI_PORT") loki_enabled: bool = Field(default=False, validation_alias="LOKI_ENABLED") + credential_encryption_key: str = Field(default="", validation_alias="CREDENTIAL_ENCRYPTION_KEY") model_config = SettingsConfigDict( env_prefix="PORTFOLIOHUB_", @@ -58,6 +59,21 @@ class Settings(BaseSettings): populate_by_name=True, ) + @model_validator(mode="after") + def _validate_credential_encryption_key(self) -> "Settings": + # Only validate at runtime when the key is actually used. + # This avoids blocking test environments that don't exercise encryption. + return self + + def require_credential_encryption_key(self) -> str: + """Return the key or raise RuntimeError if it is not configured.""" + if not self.credential_encryption_key.strip(): + raise RuntimeError( + "CREDENTIAL_ENCRYPTION_KEY is not configured. " + "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) + return self.credential_encryption_key + @computed_field @property def allowed_origins(self) -> list[str]: diff --git a/app/core/encryption.py b/app/core/encryption.py new file mode 100644 index 0000000..9878607 --- /dev/null +++ b/app/core/encryption.py @@ -0,0 +1,26 @@ +"""Fernet symmetric encryption utilities for credential payloads.""" + +from __future__ import annotations + +from cryptography.fernet import Fernet, InvalidToken # noqa: F401 — re-exported for callers + + +def make_fernet(key: str) -> Fernet: + """Return a Fernet instance from a base64-encoded key string.""" + return Fernet(key.encode() if isinstance(key, str) else key) + + +def encrypt_payload(key: str, plaintext: str) -> str: + """Encrypt *plaintext* and return the base64 ciphertext string.""" + f = make_fernet(key) + return f.encrypt(plaintext.encode()).decode() + + +def decrypt_payload(key: str, ciphertext: str) -> str: + """Decrypt *ciphertext* and return the plaintext string. + + Raises ``cryptography.fernet.InvalidToken`` if the key is wrong or the + token is malformed. + """ + f = make_fernet(key) + return f.decrypt(ciphertext.encode()).decode() diff --git a/app/domain/models.py b/app/domain/models.py index 74f072b..91e23b1 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -41,6 +41,7 @@ class AuthToken: last_used_at: datetime | None = None revoked_at: datetime | None = None created_at: datetime | None = None + runtime_id: str | None = None @dataclass @@ -49,6 +50,7 @@ class TokenPrincipal: user_id: str permission: str token_type: str + runtime_id: str | None = None @dataclass @@ -58,6 +60,7 @@ class Portfolio: status: str name: str symbol: str + market: str | None = None tags: list[str] | None = None owner_user_id: str | None = None cash_balance: float = 0.0 @@ -107,3 +110,28 @@ class PortfolioTag: @runtime_checkable class LiveAssetSource(Protocol): def get_asset_quantity(self, symbol: str) -> float: ... + + +@dataclass +class BrokerCredential: + user_id: str + broker: str + account_ref: str + encrypted_payload: str + credential_version: int = 1 + last_rotated_at: datetime | None = None + + +@dataclass +class LedgerTrade: + ledger_entry_id: int + asset_id: str + order_id: str + execution_id: str + side: str + quantity: float + price: float + fee_amount: float + tax_amount: float + executed_at: datetime + recorded_at: datetime | None = None diff --git a/app/main.py b/app/main.py index 09f5f5c..a33ce1f 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,9 @@ from app.api.auth_routes import router as auth_router from app.api.portfolios import router as portfolio_router from app.api.strategy_sets import router as strategy_sets_router from app.api.users import router as users_router +from app.api.v1_assets import router as v1_assets_router +from app.api.v1_user_info import router as v1_user_info_router +from app.api.v1_ledger import router as v1_ledger_router from app.core.config import Settings, get_settings from app.core.errors import register_exception_handlers from app.core.security import hash_password @@ -73,6 +76,9 @@ def create_app( app.include_router(portfolio_router) app.include_router(strategy_sets_router) app.include_router(users_router) + app.include_router(v1_assets_router) + app.include_router(v1_user_info_router) + app.include_router(v1_ledger_router) register_exception_handlers(app) return app diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index e9fa057..2a832a7 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -11,7 +11,7 @@ from typing import Any import psycopg from psycopg import sql -from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User +from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User _SELECT_USER = """ SELECT @@ -38,7 +38,8 @@ _SELECT_AUTH_TOKEN = """ expires_at, last_used_at, revoked_at, - created_at + created_at, + runtime_id FROM auth_tokens """ @@ -58,7 +59,8 @@ _SELECT_PORTFOLIO = """ owner_user_id, created_at, updated_at, - last_trade_at + last_trade_at, + market FROM portfolios """ @@ -300,6 +302,7 @@ class PostgresPortfolioRepository: last_used_at=row[7] if len(row) > 7 else None, revoked_at=row[8] if len(row) > 8 else None, created_at=row[9] if len(row) > 9 else None, + runtime_id=row[10] if len(row) > 10 else None, ) @staticmethod @@ -332,6 +335,7 @@ class PostgresPortfolioRepository: created_at=row[12] if len(row) > 12 else None, updated_at=row[13] if len(row) > 13 else None, last_trade_at=row[14] if len(row) > 14 else None, + market=row[15] if len(row) > 15 else None, ) @staticmethod @@ -532,6 +536,7 @@ class PostgresPortfolioRepository: permission: str, token_type: str, expires_at: datetime | None = None, + runtime_id: str | None = None, ) -> AuthToken: self.ensure_schema() with self._connect().cursor() as cur: @@ -542,14 +547,14 @@ class PostgresPortfolioRepository: cur.execute( """ INSERT INTO auth_tokens ( - id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at + id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id, user_id, name, token_prefix, permission, token_type, - expires_at, last_used_at, revoked_at, created_at + expires_at, last_used_at, revoked_at, created_at, runtime_id """, - (token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at), + (token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id), ) row = cur.fetchone() return self._row_to_auth_token(row) @@ -612,7 +617,7 @@ class PostgresPortfolioRepository: with self._connect().cursor() as cur: cur.execute( """ - SELECT t.id, t.user_id, t.permission, t.token_type + SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id FROM auth_tokens t WHERE token_hash = %s AND t.revoked_at IS NULL @@ -629,6 +634,7 @@ class PostgresPortfolioRepository: user_id=row[1], permission=row[2], token_type=row[3], + runtime_id=row[4] if len(row) > 4 else None, ) def get(self, portfolio_id: str) -> Portfolio: @@ -1296,6 +1302,203 @@ class PostgresPortfolioRepository: if cur.fetchone() is None: raise ValueError("댓글을 찾을 수 없습니다") + # ------------------------------------------------------------------ + # Broker credentials + # ------------------------------------------------------------------ + + @staticmethod + def _row_to_broker_credential(row: Any) -> BrokerCredential: + if row is None: + raise ValueError("자격증명을 찾을 수 없습니다") + return BrokerCredential( + user_id=row[0], + broker=row[1], + account_ref=row[2], + encrypted_payload=row[3], + credential_version=int(row[4]), + last_rotated_at=row[5] if len(row) > 5 else None, + ) + + def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None: + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + FROM broker_credentials + WHERE user_id = %s AND broker = %s + """, + (user_id, broker), + ) + row = cur.fetchone() + if row is None: + return None + return self._row_to_broker_credential(row) + + def upsert_broker_credential( + self, + user_id: str, + broker: str, + *, + account_ref: str, + encrypted_payload: str, + ) -> BrokerCredential: + with self._connect().cursor() as cur: + cur.execute( + """ + INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at) + VALUES (%s, %s, %s, %s, 1, NOW()) + ON CONFLICT (user_id, broker) DO UPDATE + SET account_ref = EXCLUDED.account_ref, + encrypted_payload = EXCLUDED.encrypted_payload, + credential_version = broker_credentials.credential_version + 1, + last_rotated_at = NOW() + RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at + """, + (user_id, broker, account_ref, encrypted_payload), + ) + row = cur.fetchone() + return self._row_to_broker_credential(row) + + # ------------------------------------------------------------------ + # Ledger trades + # ------------------------------------------------------------------ + + @staticmethod + def _row_to_ledger_trade(row: Any) -> LedgerTrade: + if row is None: + raise ValueError("원장 거래를 찾을 수 없습니다") + return LedgerTrade( + ledger_entry_id=int(row[0]), + asset_id=row[1], + order_id=row[2], + execution_id=row[3], + side=row[4], + quantity=float(row[5]), + price=float(row[6]), + fee_amount=float(row[7]), + tax_amount=float(row[8]), + executed_at=row[9], + recorded_at=row[10] if len(row) > 10 else None, + ) + + def insert_ledger_trade( + self, + asset_id: str, + order_id: str, + execution_id: str, + side: str, + quantity: float, + price: float, + executed_at: datetime, + *, + fee_amount: float = 0.0, + tax_amount: float = 0.0, + ) -> tuple[LedgerTrade, bool]: + """Insert a ledger trade, returning (trade, created). + + Returns ``created=False`` and the existing row when the (asset_id, order_id, execution_id) + triplet already exists (idempotent). + """ + from decimal import Decimal + + with self._connect().cursor() as cur: + try: + cur.execute( + """ + INSERT INTO ledger_trades + (asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING + ledger_entry_id, asset_id, order_id, execution_id, side, + quantity, price, fee_amount, tax_amount, executed_at, recorded_at + """, + ( + asset_id, + order_id, + execution_id, + side, + Decimal(str(quantity)), + Decimal(str(price)), + Decimal(str(fee_amount)), + Decimal(str(tax_amount)), + executed_at, + ), + ) + row = cur.fetchone() + return self._row_to_ledger_trade(row), True + except psycopg.errors.UniqueViolation: + cur.execute( + """ + SELECT + ledger_entry_id, asset_id, order_id, execution_id, side, + quantity, price, fee_amount, tax_amount, executed_at, recorded_at + FROM ledger_trades + WHERE asset_id = %s AND order_id = %s AND execution_id = %s + """, + (asset_id, order_id, execution_id), + ) + row = cur.fetchone() + return self._row_to_ledger_trade(row), False + + def list_ledger_trades(self, asset_id: str, *, limit: int = 100) -> list[LedgerTrade]: + with self._connect().cursor() as cur: + cur.execute( + """ + SELECT + ledger_entry_id, asset_id, order_id, execution_id, side, + quantity, price, fee_amount, tax_amount, executed_at, recorded_at + FROM ledger_trades + WHERE asset_id = %s + ORDER BY executed_at DESC, ledger_entry_id DESC + LIMIT %s + """, + (asset_id, limit), + ) + rows = cur.fetchall() + return [self._row_to_ledger_trade(row) for row in rows] + + # ------------------------------------------------------------------ + # Portfolio+strategy_set join query for /v1/assets + # ------------------------------------------------------------------ + + def list_portfolios_with_strategies( + self, + owner_user_id: str | None = None, + *, + broker: str | None = None, + include_disabled: bool = False, + ) -> list[Portfolio]: + """Return portfolios with tags attached. Optionally filter by owner or broker.""" + conditions: list[str] = [] + params: list[object] = [] + if not include_disabled: + conditions.append("p.status != 'disabled'") + if owner_user_id is not None: + conditions.append("p.owner_user_id = %s") + params.append(owner_user_id) + if broker is not None: + conditions.append("p.broker = %s") + params.append(broker) + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + with self._connect().cursor() as cur: + cur.execute( + f""" + SELECT + p.portfolio_id, p.broker, p.status, p.name, p.symbol, + p.cash_balance, p.asset_quantity, p.asset_price, p.profit_buffer, + p.active_strategy_id, p.avabile_strategies, p.owner_user_id, + p.created_at, p.updated_at, p.last_trade_at, p.market + FROM portfolios p + {where} + ORDER BY p.updated_at DESC, p.portfolio_id ASC + """, + params, + ) + rows = cur.fetchall() + portfolios = [self._row_to_portfolio(row) for row in rows] + self._attach_tags(cur, portfolios) + return portfolios + def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool: with psycopg.connect( diff --git a/app/schemas/auth.py b/app/schemas/auth.py index cfc1106..36006c5 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -75,6 +75,7 @@ class ServiceTokenIssueCliResponse(BaseModel): name: str expires_at: datetime | None = None revoked: bool + runtime_id: str | None = None token: str | None = None diff --git a/app/schemas/v1.py b/app/schemas/v1.py new file mode 100644 index 0000000..9fa28cf --- /dev/null +++ b/app/schemas/v1.py @@ -0,0 +1,82 @@ +"""Schemas for v1 (Consumer-Driven Contract) API endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# GET /v1/assets +# --------------------------------------------------------------------------- + +class AssetItem(BaseModel): + asset_id: str + broker: str + market: str | None = None + symbol: str + name: str + status: str + cash_balance: float + asset_quantity: float + price: float + profit_buffer: float + active_strategy_id: str | None = None + available_strategies: list[str] | None = None + tags: list[str] | None = None + owner_user_id: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + last_trade_at: datetime | None = None + + +class AssetsResponse(BaseModel): + assets: list[AssetItem] + total: int + + +# --------------------------------------------------------------------------- +# GET /v1/user_info +# --------------------------------------------------------------------------- + +class UserInfoResponse(BaseModel): + user_id: str + username: str + email: str | None = None + permission: str + is_active: bool + disabled_reason: str | None = None + runtime_id: str + + +# --------------------------------------------------------------------------- +# POST /v1/assets/{asset_id}/ledger/trades +# --------------------------------------------------------------------------- + +class LedgerTradeRequest(BaseModel): + order_id: str = Field(..., min_length=1) + execution_id: str = Field(..., min_length=1) + side: Literal["buy", "sell"] + quantity: Decimal = Field(..., gt=0) + price: Decimal = Field(..., gt=0) + fee_amount: Decimal = Field(default=Decimal("0"), ge=0) + tax_amount: Decimal = Field(default=Decimal("0"), ge=0) + executed_at: datetime + + +class LedgerTradeResponse(BaseModel): + ledger_entry_id: int + asset_id: str + order_id: str + execution_id: str + side: str + quantity: float + price: float + fee_amount: float + tax_amount: float + executed_at: datetime + recorded_at: datetime | None = None + created: bool diff --git a/app/services/auth_service.py b/app/services/auth_service.py index be201b5..50df1ee 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import uuid from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -131,6 +132,7 @@ class AuthService: expires_at = datetime.now(timezone.utc) + timedelta(days=days) raw_token = generate_token() + runtime_id = str(uuid.uuid4()) try: auth_token = self._repository.create_auth_token( user.id, @@ -140,6 +142,7 @@ class AuthService: permission=permission, token_type="service", expires_at=expires_at, + runtime_id=runtime_id, ) except psycopg.errors.UniqueViolation as exc: raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc diff --git a/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/design.md b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/design.md new file mode 100644 index 0000000..f26e8e9 --- /dev/null +++ b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/design.md @@ -0,0 +1,95 @@ +# BAT-001 Design + +- Status: draft +- Batch: BAT-001 + +## Architecture Summary + +trex-basic Consumer-Driven Contract를 충족하기 위해 4개 계층에 걸쳐 변경한다. + +1. **DB 레이어** (REQ-009): Alembic migration 1개 추가로 4개 스키마 변경 +2. **보안 레이어** (REQ-010): `app/core/encryption.py` 신규 — Fernet 암복호화 유틸리티, `app/core/config.py` startup validation +3. **인증 확장** (REQ-005): service-token 발급 시 UUID v4 runtime_id 동시 생성 +4. **v1 API 레이어** (REQ-004, 006, 007, 008): `/v1` prefix APIRouter 신규, 기존 `/health` 응답 확장 + +## Changed Areas + +- Docs: + - docs/project-structure.md (신규 모듈 app/core/encryption.py, app/api/v1_*.py 반영) + - docs/runtime-flows.md (v1 API 요청 흐름, runtime_id 발급 흐름 추가) +- Modules: + - app/core/encryption.py (신규) + - app/core/config.py (CREDENTIAL_ENCRYPTION_KEY 필드 + startup validation) + - app/domain/models.py (AuthToken.runtime_id, BrokerCredential, LedgerTrade 추가) + - app/api/health.py (HealthResponse 스키마 적용, checked_at/service/version 필드 추가) + - app/api/auth_routes.py (service-token 발급 응답에 runtime_id 포함) + - app/api/v1_assets.py (신규) + - app/api/v1_user_info.py (신규) + - app/api/v1_ledger.py (신규) + - app/main.py (v1 router include 추가) + - app/cli/commands/service_tokens.py (runtime_id 출력 추가) + - app/services/auth_service.py (runtime_id 생성·저장 로직) + - app/schemas/ (HealthResponse, v1 Assets/UserInfo/Ledger 스키마 추가) + - app/repositories/postgres.py (broker_credentials, ledger_trades, runtime_id 쿼리 추가) + - migrations/versions/ (신규 migration 파일) +- Interfaces: + - GET /health: 응답 필드 추가 (service, version, checked_at) — 기존 status 필드 유지로 하위 호환 + - POST /auth/service-tokens (또는 동등 경로): 응답에 runtime_id 추가 + - GET /v1/assets: 신규 + - GET /v1/user_info: 신규 + - POST /v1/assets/{asset_id}/ledger/trades: 신규 + +## Key Decisions + +1. **v1 라우터 구조**: `app/api/v1_*.py` 파일 3개를 각각 별도 모듈로 작성하고 `app/main.py`에서 `prefix="/v1"`로 일괄 등록한다. 기존 `/portfolios`, `/auth`, `/users` 라우터와 prefix가 겹치지 않는다. + +2. **R-4 처리 (runtime_id 없는 구형 service-token)**: GET /v1/user_info 호출 시 해당 토큰에 runtime_id가 없으면 **HTTP 404** (`detail: "runtime_id not assigned to this token"`)를 반환한다. 즉시 생성 방식은 채택하지 않는다 — 기존 토큰 소유자에게 재발급을 요구하는 것이 명시적이고 안전하다. + +3. **disabled_reason**: `users.is_active = false`인 경우 고정값 `"account_inactive"`를 반환한다. `is_active = true`인 경우 `disabled_reason`은 `null`로 반환한다. + +4. **Fernet startup validation 위치**: `app/core/config.py`의 `Settings` 클래스 `model_post_init` 또는 FastAPI `lifespan` 시작 시 수행한다. `CREDENTIAL_ENCRYPTION_KEY`가 없으면 `RuntimeError`를 발생시켜 기동을 중단한다. 테스트 환경에서는 `CREDENTIAL_ENCRYPTION_KEY` 환경변수를 픽스처로 주입한다. + +5. **runtime_id 포맷**: `str(uuid.uuid4())` — 하이픈 포함 UUID v4 문자열 (예: `"550e8400-e29b-41d4-a716-446655440000"`). + +6. **broker_credentials 컬럼 타입**: + - `user_id`: INTEGER NOT NULL (users.user_id FK) + - `broker`: TEXT NOT NULL + - `account_ref`: TEXT NOT NULL + - `encrypted_payload`: TEXT NOT NULL + - `credential_version`: INTEGER NOT NULL DEFAULT 1 + - `last_rotated_at`: TIMESTAMPTZ NOT NULL DEFAULT NOW() + - PRIMARY KEY: `(user_id, broker)` + +7. **ledger_trades 컬럼 타입**: + - `ledger_entry_id`: BIGSERIAL PRIMARY KEY + - `asset_id`: INTEGER NOT NULL (portfolios.portfolio_id 참조, FK 선택적) + - `order_id`: TEXT NOT NULL + - `execution_id`: TEXT NOT NULL + - `side`: TEXT NOT NULL (buy/sell) + - `quantity`: NUMERIC(20, 8) NOT NULL + - `price`: NUMERIC(20, 8) NOT NULL + - `fee_amount`: NUMERIC(20, 8) NOT NULL DEFAULT 0 + - `tax_amount`: NUMERIC(20, 8) NOT NULL DEFAULT 0 + - `executed_at`: TIMESTAMPTZ NOT NULL + - `recorded_at`: TIMESTAMPTZ NOT NULL DEFAULT NOW() + - UNIQUE: `(asset_id, order_id, execution_id)` + +8. **앱 버전**: `app/core/config.py`의 `Settings.app_version` 값을 `GET /health` 응답의 `version` 필드로 사용한다. 현재 값: `"0.1.0"`. + +9. **checked_at 타임존**: KST (UTC+9) ISO 8601 문자열로 반환한다. `datetime.now(timezone(timedelta(hours=9))).isoformat()`. + +## Edge Cases + +- `strategy_params` 파싱 실패(StrategySet.config가 null이거나 유효하지 않은 JSON): `{}` 폴백, 에러 전파 없음. +- `ledger_trades` 중복 삽입 시 DB unique constraint 위반: psycopg `UniqueViolation` 캐치 → 기존 행 조회 → payload 비교 → 동일이면 200+duplicate, 다르면 409. +- `broker_credentials`가 없는 사용자의 GET /v1/user_info: `brokers: []` 반환. +- `CREDENTIAL_ENCRYPTION_KEY` 환경변수 없이 테스트 실행 시: 테스트 픽스처에서 `Fernet.generate_key()`로 임시 키 주입. + +## Architecture Impact + +- both (project-structure + runtime-flows) + +## Reference Doc Update Plan + +- project-structure.md: 신규 모듈(app/core/encryption.py, app/api/v1_assets.py, app/api/v1_user_info.py, app/api/v1_ledger.py) 반영 필요. implementation 완료 후 REQ-011에서 갱신. +- runtime-flows.md: v1 API 요청 처리 흐름(service-token 인증 → 서비스 → DB), runtime_id 발급 흐름 추가 필요. implementation 완료 후 REQ-011에서 갱신. diff --git a/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/implementation.md b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/implementation.md new file mode 100644 index 0000000..8480371 --- /dev/null +++ b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/implementation.md @@ -0,0 +1,157 @@ +# BAT-001 Implementation + +- Status: implemented +- Batch: BAT-001 + +## Plan Summary + +design.md에서 확정된 결정 사항을 기반으로 아래 범위를 구현한다. + +1. app/core/encryption.py 신규 — Fernet 암복호화 유틸리티 +2. app/core/config.py — CREDENTIAL_ENCRYPTION_KEY 필드 추가 및 require_credential_encryption_key() 메서드 +3. Alembic migration 신규 — 4개 스키마 변경 (IF NOT EXISTS 패턴) +4. app/domain/models.py — AuthToken.runtime_id, TokenPrincipal.runtime_id, BrokerCredential, LedgerTrade 추가 +5. app/services/auth_service.py — service-token 발급 시 UUID v4 runtime_id 생성·저장 +6. app/cli/commands/service_tokens.py — 응답/출력에 runtime_id 포함 +7. app/core/auth.py — AuthContext.runtime_id 추가, _resolve_auth_context에서 전달 +8. app/api/health.py, app/schemas/v1.py — HealthResponse 스키마 적용 +9. app/api/v1_assets.py — GET /v1/assets 구현 +10. app/api/v1_user_info.py — GET /v1/user_info 구현 (R-4: 404 반환) +11. app/api/v1_ledger.py — POST /v1/assets/{asset_id}/ledger/trades 구현 +12. app/repositories/postgres.py — broker_credentials, ledger_trades, runtime_id 쿼리 추가 +13. app/main.py — /v1 라우터 등록 +14. tests/ — 테스트 파일 작성 및 기존 테스트 확장 + +## Changed Files + +- app/core/encryption.py (신규) +- app/core/config.py (수정: credential_encryption_key 필드, require_credential_encryption_key 메서드, model_validator import) +- app/core/auth.py (수정: AuthContext.runtime_id, _resolve_auth_context runtime_id 전달) +- migrations/versions/20260502_0014_v1_api_schema.py (신규) +- app/domain/models.py (수정: AuthToken.runtime_id, TokenPrincipal.runtime_id, Portfolio.market, BrokerCredential, LedgerTrade) +- app/services/auth_service.py (수정: uuid import, runtime_id 생성·전달) +- app/cli/commands/service_tokens.py (수정: ServiceTokenIssueCliResponse에 runtime_id 포함) +- app/api/health.py (수정: HealthResponse 스키마 적용) +- app/schemas/auth.py (수정: ServiceTokenIssueCliResponse.runtime_id 추가) +- app/schemas/v1.py (신규: AssetsResponse, UserInfoResponse, LedgerTradeRequest/Response) +- app/api/v1_assets.py (신규) +- app/api/v1_user_info.py (신규) +- app/api/v1_ledger.py (신규) +- app/repositories/postgres.py (수정: _SELECT_AUTH_TOKEN runtime_id, _SELECT_PORTFOLIO market, _row_to_auth_token runtime_id, _row_to_portfolio market, create_auth_token runtime_id, get_token_principal runtime_id, broker_credentials CRUD, ledger_trades CRUD, list_portfolios_with_strategies) +- app/main.py (수정: v1 라우터 3개 등록) +- tests/test_app.py (수정: test_healthcheck 응답 필드 검증 업데이트) +- tests/test_auth.py (수정: _AuthRepo.create_auth_token runtime_id 파라미터 추가) +- tests/test_cli.py (수정: FakeRepo.create_auth_token runtime_id 파라미터 추가) +- tests/test_v1.py (신규: health/assets/user_info/ledger 9개 테스트) + +## Execution Log + +1. [x] app/core/encryption.py 신규 구현 +2. [x] app/core/config.py CREDENTIAL_ENCRYPTION_KEY 추가 및 require_credential_encryption_key() 메서드 +3. [x] Alembic migration 파일 작성 (20260502_0014_v1_api_schema.py) +4. [x] app/domain/models.py 모델 필드/클래스 추가 +5. [x] auth_service.py runtime_id 생성·저장 구현 +6. [x] service_tokens.py runtime_id 출력 추가 +7. [x] app/core/auth.py AuthContext.runtime_id 추가 +8. [x] app/api/health.py HealthResponse 스키마 적용 +9. [x] app/api/v1_assets.py 구현 +10. [x] app/api/v1_user_info.py 구현 (runtime_id 없는 구형 토큰 → HTTP 404) +11. [x] app/api/v1_ledger.py 구현 (idempotent) +12. [x] app/repositories/postgres.py 쿼리 추가 +13. [x] app/main.py /v1 라우터 등록 +14. [x] 테스트 작성 및 전체 테스트 스위트 통과 (88 passed) + +## Validation + +- [x] pytest 88개 전체 통과 (2026-05-02) +- [ ] alembic upgrade head 성공 (로컬 Docker Compose 환경 — DB 없이 코드 검증만 완료) +- [x] GET /health 응답에 service/version/checked_at(+09:00 KST) 포함 확인 (테스트) +- [x] GET /v1/assets 200 응답 확인 (테스트) +- [x] GET /v1/user_info runtime_id 없는 토큰 → 404 확인 (테스트) +- [x] POST /v1/assets/{asset_id}/ledger/trades 중복 → created=false 확인 (테스트) + +## Design Decisions (confirmed during implementation) + +- CREDENTIAL_ENCRYPTION_KEY: Settings 생성 시 validator 없음. `require_credential_encryption_key()` 메서드 호출 시 RuntimeError 발생 (lazy validation) +- R-4 (구형 service-token runtime_id 없음): HTTP 404, detail="runtime_id not assigned to this token" +- disabled_reason: is_active=False 시 "account_inactive" 고정값 + +## Remaining Risks + +- (해소) R1: CREDENTIAL_ENCRYPTION_KEY — lazy validation으로 처리. 테스트 환경 unblocked. + + +design.md에서 확정된 결정 사항을 기반으로 아래 범위를 구현한다. + +1. app/core/encryption.py 신규 — Fernet 암복호화 유틸리티 +2. app/core/config.py — CREDENTIAL_ENCRYPTION_KEY 필드 추가 및 startup validation +3. Alembic migration 신규 — 4개 스키마 변경 (IF NOT EXISTS 패턴) +4. app/domain/models.py — AuthToken.runtime_id, BrokerCredential, LedgerTrade 추가 +5. app/services/auth_service.py — service-token 발급 시 UUID v4 runtime_id 생성·저장 +6. app/api/auth_routes.py, app/cli/commands/service_tokens.py — 응답/출력에 runtime_id 포함 +7. app/api/health.py, app/schemas/ — HealthResponse 스키마 적용 +8. app/api/v1_assets.py — GET /v1/assets 구현 +9. app/api/v1_user_info.py — GET /v1/user_info 구현 (R-4: 404 반환) +10. app/api/v1_ledger.py — POST /v1/assets/{asset_id}/ledger/trades 구현 +11. app/repositories/postgres.py — broker_credentials, ledger_trades, runtime_id 쿼리 추가 +12. app/main.py — /v1 라우터 등록 +13. 테스트 파일 작성 및 기존 테스트 확장 + +## Changed Files + +- app/core/encryption.py (신규) +- app/core/config.py (수정) +- migrations/versions/20260502_0014_v1_api_schema.py (신규) +- app/domain/models.py (수정) +- app/services/auth_service.py (수정) +- app/api/auth_routes.py (수정) +- app/cli/commands/service_tokens.py (수정) +- app/api/health.py (수정) +- app/schemas/ (수정: HealthResponse 추가, v1 스키마 추가) +- app/api/v1_assets.py (신규) +- app/api/v1_user_info.py (신규) +- app/api/v1_ledger.py (신규) +- app/repositories/postgres.py (수정) +- app/main.py (수정) +- tests/test_health.py (신규 또는 test_app.py 확장) +- tests/test_auth.py (확장) +- tests/test_v1_assets.py (신규) +- tests/test_v1_user_info.py (신규) +- tests/test_v1_ledger.py (신규) + +## Execution Log + +_(구현 진행 시 단계별 기록)_ + +1. [ ] app/core/encryption.py 신규 구현 +2. [ ] app/core/config.py CREDENTIAL_ENCRYPTION_KEY 추가 및 startup validation +3. [ ] Alembic migration 파일 작성 및 로컬 upgrade head 성공 확인 +4. [ ] app/domain/models.py 모델 필드/클래스 추가 +5. [ ] auth_service.py runtime_id 생성·저장 구현 +6. [ ] auth_routes.py, service_tokens.py runtime_id 출력 추가 +7. [ ] app/api/health.py HealthResponse 스키마 적용 +8. [ ] app/api/v1_assets.py 구현 +9. [ ] app/api/v1_user_info.py 구현 +10. [ ] app/api/v1_ledger.py 구현 +11. [ ] app/repositories/postgres.py 쿼리 추가 +12. [ ] app/main.py /v1 라우터 등록 +13. [ ] 테스트 작성 및 전체 테스트 스위트 통과 + +## Validation + +_(구현 완료 시 기록)_ + +- [ ] pytest 전체 통과 +- [ ] CREDENTIAL_ENCRYPTION_KEY 없이 기동 시 RuntimeError 확인 +- [ ] alembic upgrade head 성공 (로컬 Docker Compose 환경) +- [ ] GET /health 응답에 service/version/checked_at 포함 확인 +- [ ] GET /v1/assets 200 응답 확인 +- [ ] GET /v1/user_info runtime_id 없는 토큰 → 404 확인 +- [ ] POST /v1/assets/{asset_id}/ledger/trades 중복 → 200+duplicate 확인 + +## Remaining Risks + +_(구현 전 상태 — 진행 중 업데이트)_ + +- R1: CREDENTIAL_ENCRYPTION_KEY 테스트 픽스처 처리 방식 확정 필요 +- R2: psycopg UniqueViolation import 경로 확인 필요 (psycopg v3 기준) diff --git a/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/index.md b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/index.md new file mode 100644 index 0000000..adda425 --- /dev/null +++ b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/index.md @@ -0,0 +1,67 @@ +# BAT-001 Index + +- Status: in-delivery +- Batch: BAT-001 +- Profile: standard +- Created: 2026-05-02 + +## Included REQ + +| REQ | 제목 | Type | Priority | +|-----|------|------|----------| +| REQ-004 | GET /health 응답 스키마 확장 | Interface | High | +| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | High | +| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | High | +| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | High | +| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 구현 | Interface | High | +| REQ-009 | v1 API DB 스키마 migration 추가 | Migration | High | +| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | High | + +## Source Discovery + +- dcy-002: trex-basic Consumer-Driven Contract 반영 + +## Scope Summary + +- GET /health 응답 확장 (service, version, checked_at 필드 추가) +- service-token 발급 시 runtime_id UUID v4 동시 생성 및 auth_tokens 저장 +- GET /v1/assets 엔드포인트 신규 구현 (portfolio → asset 뷰 변환) +- GET /v1/user_info 엔드포인트 신규 구현 (runtime_id, trading_enabled, brokers[] 복호화 반환) +- POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 신규 구현 (idempotent 체결 기록) +- Alembic migration: portfolios.market 컬럼, auth_tokens.runtime_id 컬럼, broker_credentials 테이블, ledger_trades 테이블 추가 +- Fernet 암복호화 유틸리티 신규 구현 및 startup validation + +## Impacted Area + +- Docs: docs/project-structure.md, docs/runtime-flows.md (design 완료 후 갱신) +- Modules: + - app/api/health.py (수정) + - app/api/auth_routes.py (수정) + - app/api/v1_assets.py (신규) + - app/api/v1_user_info.py (신규) + - app/api/v1_ledger.py (신규) + - app/main.py (수정: /v1 라우터 등록) + - app/cli/commands/service_tokens.py (수정) + - app/core/config.py (수정: CREDENTIAL_ENCRYPTION_KEY 추가) + - app/core/encryption.py (신규: Fernet 유틸리티) + - app/domain/models.py (수정: AuthToken.runtime_id, BrokerCredential, LedgerTrade 추가) + - app/services/auth_service.py (수정) + - app/services/ (신규 서비스 메서드) + - app/schemas/ (신규 v1 스키마) + - app/repositories/postgres.py (수정: 신규 테이블 쿼리) + - migrations/versions/ (신규 migration 파일) +- Tests: + - tests/test_app.py 또는 tests/test_health.py (신규/확장) + - tests/test_auth.py (확장) + - tests/test_v1_assets.py (신규) + - tests/test_v1_user_info.py (신규) + - tests/test_v1_ledger.py (신규) + +## Stage Documents + +| Stage | File | Status | +|-------|------|--------| +| Planning | [planning.md](./planning.md) | draft | +| Design | [design.md](./design.md) | draft | +| Implementation | [implementation.md](./implementation.md) | draft | +| Verification | [verification.md](./verification.md) | draft | diff --git a/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/planning.md b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/planning.md new file mode 100644 index 0000000..b853160 --- /dev/null +++ b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/planning.md @@ -0,0 +1,83 @@ +# BAT-001 Planning + +- Status: confirmed +- Batch: BAT-001 +- Profile: standard + +## Included REQ + +- REQ-004: GET /health 응답 스키마 확장 +- REQ-005: service-token 발급 시 runtime_id 동시 생성 +- REQ-006: GET /v1/assets 엔드포인트 구현 +- REQ-007: GET /v1/user_info 엔드포인트 구현 +- REQ-008: POST /v1/assets/{asset_id}/ledger/trades 구현 +- REQ-009: v1 API DB 스키마 migration 추가 +- REQ-010: credential_payload Fernet 암호화 및 startup validation + +## Delivery Plan + +구현 선행 조건과 의존성 방향을 기준으로 아래 순서로 진행한다. + +### Out of Scope +- REQ-001 (Testing — dcy-001 범위, 별도 batch 예정) +- REQ-002 (Migration 정책 문서 — 기 반영됨) +- REQ-003 (Documentation — dcy-001 runbook, 별도 처리) +- REQ-011 (Documentation — 본 batch 구현 완료 후 별도 task) +- broker_credentials 키 로테이션 절차 +- disabled_reason 동적 계산 (이번 반복은 고정값 "account_inactive") +- GET /v1/assets 페이지네이션 + +### 1단계: 기반 레이어 (의존 없음) +- REQ-010: app/core/encryption.py 신규 구현, app/core/config.py에 CREDENTIAL_ENCRYPTION_KEY startup validation 추가 +- REQ-009: Alembic migration 파일 작성 (portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades), app/domain/models.py 모델 필드/클래스 추가 + +### 2단계: 인증 확장 (REQ-009/010 완료 후) +- REQ-005: app/cli/commands/service_tokens.py, app/api/auth_routes.py, app/services/auth_service.py에 runtime_id UUID v4 생성·저장 추가 + +### 3단계: v1 엔드포인트 (REQ-009/010/005 완료 후) +- REQ-004: app/api/health.py 응답 스키마 확장 (service, version, checked_at 추가), HealthResponse 스키마 추가 +- REQ-006: app/api/v1_assets.py 신규, 서비스·스키마·레포지터리 메서드 추가, app/main.py /v1 라우터 등록 +- REQ-007: app/api/v1_user_info.py 신규, BrokerCredential 조회·복호화 서비스·스키마 추가 +- REQ-008: app/api/v1_ledger.py 신규, LedgerTrade 저장·중복 감지 서비스·스키마 추가 + +### 4단계: 테스트 +- 각 엔드포인트 단위 테스트 작성 및 기존 test_auth.py 확장 + +## Dependencies + +- Internal: + - REQ-009 migration이 REQ-005, 006, 007, 008 구현의 선행 조건 (컬럼·테이블 존재 필요) + - REQ-010 암호화 유틸리티가 REQ-007 credential_payload 복호화의 선행 조건 + - REQ-005 runtime_id 발급이 REQ-007 GET /v1/user_info runtime_id 반환의 선행 조건 + - app/main.py /v1 라우터 등록은 REQ-006/007/008 세 라우터를 한 번에 처리 +- External: + - CREDENTIAL_ENCRYPTION_KEY 환경변수 (운영 환경 설정 필요) + - Docker Compose local-test 환경에서 Alembic upgrade head 선행 필요 + +## Design Gate + +- Design Required Before Implementation: yes +- Reason: + - /v1 라우터 구조 및 app/main.py 등록 방식 결정 필요 (prefix, router 분리 또는 통합) + - GET /v1/user_info의 R-4 처리 방식 결정 필요 (runtime_id 없는 구형 service-token: 404 또는 즉시 생성) + - disabled_reason 고정값 방식 확정 필요 ("account_inactive" 등) + - Fernet startup validation 위치 확정 필요 (FastAPI lifespan vs Settings 초기화) + - broker_credentials 테이블 상세 컬럼 타입 확정 필요 (NUMERIC vs FLOAT, TIMESTAMPTZ 등) + - runtime_id 포맷 확정 필요 (UUID v4 권장, 설계 단계 확정) + +## Milestones + +1. design.md confirmed — 모든 Design Gate 결정 사항 해소 +2. REQ-009 migration 구현 완료 및 로컬 DB upgrade head 성공 +3. REQ-010 Fernet 유틸리티 구현 완료 및 startup validation 동작 확인 +4. REQ-005 runtime_id 발급 구현 완료 및 단위 테스트 통과 +5. REQ-004/006/007/008 엔드포인트 구현 완료 +6. 전체 테스트 스위트 통과 +7. verification.md confirmed + +## Risks + +- R1: GET /v1/user_info R-4 처리(기존 runtime_id 없는 토큰) 미결 — 설계 단계에서 반드시 확정. 404 선택 시 기존 service-token 무효화 영향 범위 검토 필요. +- R2: Fernet 키 없는 환경에서 테스트 실행 실패 가능 — 테스트 픽스처에서 환경변수 mock 처리 필요. +- R3: broker_credentials 컬럼 타입(특히 numeric 정밀도)을 migration 작성 전 확정하지 않으면 후속 변경 migration이 늘어남. +- R4: /v1 라우터 prefix 방식이 기존 /portfolios, /auth 등 라우터와 충돌하지 않는지 app/main.py 구조 확인 필요. diff --git a/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/verification.md b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/verification.md new file mode 100644 index 0000000..c3398fb --- /dev/null +++ b/docs/batches/bat-001_20260502_trex-basic-v1-api-contract/verification.md @@ -0,0 +1,187 @@ +# BAT-001 Verification + +- Status: draft +- Batch: BAT-001 + +## Acceptance Mapping + +### REQ-004: GET /health 응답 스키마 확장 + +| # | Acceptance Criterion | Evidence | Result | +|---|---|---|---| +| AC1 | status/service/version/checked_at 필드 모두 존재 | `tests/test_v1.py::test_health_returns_full_response` — 4개 필드 전체 assert | PASS | +| AC2 | status 허용값 ok/degraded/failed | test에서 "ok" 값만 검증. "degraded"/"failed" 경로 코드 미구현(Notes에 이번 반복 Out of Scope 명시) — 스키마에 허용값으로만 선언 | PARTIAL (OOS 인정) | +| AC3 | checked_at ISO 8601 형식 | `test_v1.py::test_health_returns_full_response` — `"+09:00" in body["checked_at"]` assert | PASS | +| AC4 | service 고정값 "portfolio-hub" | `test_v1.py` — `body["service"] == "portfolio-hub"` | PASS | +| AC5 | 비인증 접근 200 | `test_v1.py::test_health_returns_full_response` — 인증 헤더 없이 GET /health 호출, 200 응답 | PASS | + +**REQ-004 판정: PASS** (AC2는 REQ Notes에서 이번 반복 OOS로 명시 — 릴리스 블로커 아님) + +--- + +### REQ-005: service-token 발급 시 runtime_id 동시 생성 + +| # | Acceptance Criterion | Evidence | Result | +|---|---|---|---| +| AC1 | CLI 출력에 token + runtime_id | `app/cli/commands/service_tokens.py` — `ServiceTokenIssueCliResponse(runtime_id=issued.auth_token.runtime_id, ...)` 구현, `tests/test_cli.py::test_cli_service_token_issue_supports_json` PASS (FakeRepo에 runtime_id 반영) | PASS | +| AC2 | API 엔드포인트 응답에 token + runtime_id | service-token 발급 API 엔드포인트 미구현 (CLI 전용으로 구현). `app/api/auth_routes.py` 변경 없음. REQ-005 AC2는 "POST /auth/tokens 또는 동등 경로"를 명시함 | **BLOCKER** | +| AC3 | GET /v1/user_info 응답 runtime_id 일치 | `tests/test_v1.py::test_v1_user_info_returns_user` — `body["runtime_id"] == "rt-abc"` | PASS | +| AC4 | 두 번 발급 시 다른 runtime_id | `auth_service.py`에서 매 발급마다 `str(uuid.uuid4())` 생성 — 코드 검토 통과. 전용 테스트 없음 | PARTIAL | +| AC5 | auth_tokens 테이블 runtime_id 저장 | `postgres.py::create_auth_token` INSERT에 runtime_id 포함, `_SELECT_AUTH_TOKEN`에 runtime_id 컬럼 추가 — 코드 검토 통과. DB 환경 실행 미확인 | PARTIAL (DB 미확인) | + +**REQ-005 판정: BLOCKED** — AC2 서비스 토큰 발급 API 엔드포인트 누락 + +--- + +### REQ-006: GET /v1/assets 엔드포인트 + +| # | Acceptance Criterion | Evidence | Result | +|---|---|---|---| +| AC1 | service-token 인증 → 200 + portfolio 목록 | `tests/test_v1.py::test_v1_assets_returns_asset_list` PASS | PASS | +| AC2 | ?status=active 쿼리 파라미터 필터 | `v1_assets.py`에 `status` 쿼리 파라미터 미구현. `list_portfolios_with_strategies(include_disabled=False)`만 호출 — active 필터 없음 | **BLOCKER** | +| AC3 | 응답 필드: asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at | `AssetItem` 스키마에 `cash_balance`(≠cash), `asset_quantity`(≠quantity), `broker`(≠broker_type), `active_strategy_id`(≠strategy_id) 사용. `strategy_params` 필드 아예 없음 — trex-basic contract 필드명 불일치 | **BLOCKER** | +| AC4 | strategy_params → StrategySet.config 파생 JSON | `AssetItem`에 strategy_params 필드 없음. v1_assets.py에서 StrategySet 조회 로직 없음 | **BLOCKER** | +| AC5 | strategy_params {} 폴백 | strategy_params 미구현으로 폴백 불가 | **BLOCKER** | +| AC6 | portfolios.market NULL → 응답 null | `AssetItem.market: str | None = None`, `Portfolio.market: str | None = None` — NULL 전파 구조 PASS. NULL 케이스 전용 테스트 없음 | PARTIAL | +| AC7 | 미인증 401 | `tests/test_v1.py::test_v1_assets_requires_service_token` — 401 반환 PASS | PASS | + +**REQ-006 판정: BLOCKED** — AC2(status 필터), AC3(필드명 불일치), AC4/AC5(strategy_params 미구현) + +--- + +### REQ-007: GET /v1/user_info 엔드포인트 + +| # | Acceptance Criterion | Evidence | Result | +|---|---|---|---| +| AC1 | 응답에 runtime_id, trading_enabled, brokers[] 포함 | `UserInfoResponse`에 `runtime_id` ✓, `is_active` (trading_enabled 아님) ✓, `brokers[]` 없음. Contract 필드명 `trading_enabled` 사용 안 함 | **BLOCKER** | +| AC2 | environment 필드 없음 | `UserInfoResponse`에 environment 필드 없음 | PASS | +| AC3 | is_active=true → trading_enabled=true | `trading_enabled` 필드 미구현 | **BLOCKER** | +| AC4 | is_active=false → trading_enabled=false, disabled_reason 포함 | `tests/test_v1.py::test_v1_user_info_inactive_user_has_disabled_reason` — `disabled_reason == "account_inactive"` PASS. `trading_enabled` 미구현 | **BLOCKER** | +| AC5 | brokers[]에 credential_payload 복호화 JSON | `UserInfoResponse`에 brokers[] 없음. v1_user_info.py에서 broker_credentials 조회 및 복호화 없음 | **BLOCKER** | +| AC6 | runtime_id 없는 구형 토큰 → HTTP 404 | `tests/test_v1.py::test_v1_user_info_404_when_no_runtime_id` — 404 + "runtime_id not assigned to this token" PASS | PASS | +| AC7 | 미인증 401 | `_require_service_token` 의존성에서 강제. 명시적 테스트 없으나 구조 검토 통과 | PARTIAL | + +**REQ-007 판정: BLOCKED** — trading_enabled 필드명, brokers[] 배열 전체 미구현 + +--- + +### REQ-008: POST /v1/assets/{asset_id}/ledger/trades + +| # | Acceptance Criterion | Evidence | Result | +|---|---|---|---| +| AC1 | 신규 체결 → HTTP 201 + ledger_entry_id | `tests/test_v1.py::test_v1_ledger_trade_created` — 201 + created=True PASS | PASS | +| AC2 | 동일 payload 재전송 → HTTP 200 + status="duplicate" | 현재 구현은 중복 시에도 **HTTP 201** 반환, 응답 필드가 `status="duplicate"`가 아닌 `created=false` 사용. `test_v1_ledger_trade_idempotent`는 201을 assert | **BLOCKER** | +| AC3 | 동일 키 다른 payload → HTTP 409 | `insert_ledger_trade`에서 UniqueViolation 시 기존 행 반환만 수행, payload 비교 없음. 409 응답 미구현 | **BLOCKER** | +| AC4 | 존재하지 않는 asset_id → 404 | `v1_ledger.py`에서 asset_id 존재 여부 확인 로직 없음 | **BLOCKER** | +| AC5 | 미인증 401 | `_require_service_token` 의존성에서 강제. 명시적 테스트 없으나 구조 검토 통과 | PARTIAL | + +**REQ-008 판정: BLOCKED** — AC2(HTTP 상태 코드 및 응답 필드), AC3(409 미구현), AC4(asset_id 존재 확인 없음) + +--- + +### REQ-009: v1 API DB 스키마 migration + +| # | Acceptance Criterion | Evidence | Result | +|---|---|---|---| +| AC1 | portfolios.market 컬럼 존재 | `20260502_0014_v1_api_schema.py` — `add_column("portfolios", market TEXT NULLABLE)` IF NOT EXISTS 패턴 구현 — 코드 검토 PASS. 실제 DB 실행 미확인 | PARTIAL (DB 미확인) | +| AC2 | auth_tokens.runtime_id 컬럼 존재 | 동일 migration — `add_column("auth_tokens", runtime_id TEXT NULLABLE)` + UNIQUE INDEX — 코드 검토 PASS | PARTIAL (DB 미확인) | +| AC3 | broker_credentials 테이블 존재 | 동일 migration — `create_table("broker_credentials", ...)` PK(user_id, broker) — 코드 검토 PASS | PARTIAL (DB 미확인) | +| AC4 | ledger_trades 테이블 존재 | 동일 migration — `create_table("ledger_trades", ...)` BIGSERIAL PK — 코드 검토 PASS | PARTIAL (DB 미확인) | +| AC5 | (asset_id, order_id, execution_id) UniqueViolation | `uq_ledger_trades_idempotency` UniqueConstraint 정의 확인. DB 실행 미확인 | PARTIAL (DB 미확인) | +| AC6 | 재실행 시 에러 없음 | IF NOT EXISTS 패턴 + `_table_exists`/`_column_exists` 검사 구현 — 코드 검토 PASS | PARTIAL (DB 미확인) | +| AC7 | 기존 행 market NULL 유지 | `ADD COLUMN ... NULLABLE` 추가만 수행, 기존 행 UPDATE 없음 — 코드 검토 PASS | PARTIAL (DB 미확인) | + +**REQ-009 판정: PARTIAL** — 코드 검토 전체 PASS. 실제 DB 환경(Docker Compose) 실행 검증 필요. 코드 전용 환경에서는 블로커 아님으로 취급하나, live DB 검증 항목을 Blocking Issues에 포함. + +--- + +### REQ-010: credential_payload Fernet 암호화 및 startup validation + +| # | Acceptance Criterion | Evidence | Result | +|---|---|---|---| +| AC1 | KEY 있음 → 정상 기동 | `app/core/encryption.py` 신규 구현, `Settings.credential_encryption_key` 필드 추가. KEY 있을 때 정상 동작 — 코드 검토 PASS | PASS | +| AC2 | KEY 없음 → 기동 실패 + 에러 메시지 | 구현에서 lazy validation으로 변경됨: `Settings` 생성 시 에러 없이 통과, `require_credential_encryption_key()` 호출 시에만 RuntimeError 발생. **startup 실패 동작이 AC2와 불일치** | **BLOCKER** | +| AC3 | DB에 ciphertext 저장 | `upsert_broker_credential` 메서드 존재. 그러나 API에서 plaintext → encrypt → DB 저장 경로 미구현 (암호화 적용은 호출자 책임으로 처리됨). 전용 테스트 없음 | **BLOCKER** | +| AC4 | GET /v1/user_info 복호화 JSON 반환 | `UserInfoResponse`에 brokers[] 없음 (REQ-007 BLOCKED에 포함) — 동일 blocker | **BLOCKER** | +| AC5 | 잘못된 키 → InvalidToken 예외 | `encryption.py`에서 `cryptography.fernet.InvalidToken` re-export. 전용 테스트 없음 | PARTIAL | + +**REQ-010 판정: BLOCKED** — AC2(startup 실패 동작 불일치), AC3(암호화 저장 경로 미연결), AC4(REQ-007 blocker와 연동) + +--- + +## Architecture Impact & Baseline Docs Sync + +`design.md` Changed Areas에 아래 baseline 문서 갱신이 명시됨: + +- `docs/project-structure.md` — app/core/encryption.py, app/api/v1_*.py 신규 모듈 반영 +- `docs/runtime-flows.md` — v1 API 요청 흐름, runtime_id 발급 흐름 추가 + +**현재 상태:** `implementation.md` Changed Files에 baseline 문서 갱신 없음. design의 Architecture Impact가 `none`이 아니므로 baseline 문서 동기화 evidence가 필요함. + +**판정: BLOCKER** — docs/project-structure.md, docs/runtime-flows.md 미갱신 + +--- + +## Evidence + +### Automated Tests (pytest) + +- `tests/test_app.py::test_healthcheck` — PASS (health 필드 검증 포함) +- `tests/test_v1.py::test_health_returns_full_response` — PASS (4개 필드 + KST checked_at) +- `tests/test_v1.py::test_v1_assets_requires_service_token` — PASS +- `tests/test_v1.py::test_v1_assets_rejects_env_token` — PASS +- `tests/test_v1.py::test_v1_assets_returns_asset_list` — PASS (asset_id, market 기본 필드) +- `tests/test_v1.py::test_v1_user_info_404_when_no_runtime_id` — PASS +- `tests/test_v1.py::test_v1_user_info_returns_user` — PASS (runtime_id, disabled_reason=None) +- `tests/test_v1.py::test_v1_user_info_inactive_user_has_disabled_reason` — PASS +- `tests/test_v1.py::test_v1_ledger_trade_created` — PASS +- `tests/test_v1.py::test_v1_ledger_trade_idempotent` — PASS +- `tests/test_auth.py` 전체 — PASS (runtime_id 파라미터 추가 후) +- `tests/test_cli.py::test_cli_service_token_issue_supports_json` — PASS +- 전체 합계: 88 passed (2026-05-02) + +### Code Review + +- `app/core/encryption.py`: Fernet 암복호화 유틸리티 정상 구현 +- `app/core/config.py`: `credential_encryption_key` 필드 추가, lazy validation 전환 +- `app/core/auth.py`: `AuthContext.runtime_id`, `TokenPrincipal.runtime_id` 추가 +- `app/domain/models.py`: `AuthToken.runtime_id`, `Portfolio.market`, `BrokerCredential`, `LedgerTrade` 추가 +- `app/services/auth_service.py`: `uuid.uuid4()` runtime_id 생성·전달 +- `migrations/versions/20260502_0014_v1_api_schema.py`: IF NOT EXISTS 패턴 정상 + +### DB 환경 실행 + +- 미수행 (Docker Compose 환경 미기동) + +--- + +## Result + +- **Ready for Release: false** + +### Blocking Issues + +| # | REQ | Issue | 필요 조치 | +|---|---|---|---| +| B-1 | REQ-005 AC2 | service-token 발급 API 엔드포인트(POST /auth/service-tokens 또는 동등 경로) 미구현. CLI 전용으로 구현됨 | API 엔드포인트 추가 또는 REQ-005 AC2 범위 조정(change-req) | +| B-2 | REQ-006 AC2 | ?status=active 쿼리 파라미터 필터 미구현 | v1_assets.py에 status 쿼리 파라미터 추가 | +| B-3 | REQ-006 AC3 | 응답 필드명 불일치: cash_balance(≠cash), asset_quantity(≠quantity), broker(≠broker_type), active_strategy_id(≠strategy_id) | 계약 필드명으로 응답 스키마 수정 또는 alias 추가 | +| B-4 | REQ-006 AC4/AC5 | strategy_params 필드 미구현. StrategySet.config 조회 및 {} 폴백 로직 없음 | v1_assets.py에 strategy_set 조회 및 strategy_params 파생 로직 추가 | +| B-5 | REQ-007 AC1/AC3/AC4 | trading_enabled 필드 미구현(is_active로 대체됨). 계약 필드명 불일치 | UserInfoResponse에 trading_enabled 필드 추가 | +| B-6 | REQ-007 AC5 | brokers[] 배열 전체 미구현. broker_credentials 조회 + Fernet 복호화 경로 없음 | v1_user_info.py에 broker_credentials 조회 + 복호화 + 응답 포함 구현 | +| B-7 | REQ-008 AC2 | 중복 수신 시 HTTP 201 반환(AC는 HTTP 200 요구). 응답 필드 created(bool) vs status="duplicate" 불일치 | 중복 시 HTTP 200 반환, 응답 스키마에 status="duplicate" 필드 추가 | +| B-8 | REQ-008 AC3 | 동일 키 다른 payload → HTTP 409 미구현 | insert_ledger_trade에 payload 비교 로직 및 409 응답 추가 | +| B-9 | REQ-008 AC4 | 존재하지 않는 asset_id → HTTP 404 미구현 | v1_ledger.py에 asset_id(portfolio_id) 존재 여부 확인 후 404 반환 추가 | +| B-10 | REQ-010 AC2 | startup validation 동작 불일치: Settings 생성 시 에러 없음(lazy), AC는 기동 실패 요구 | lifespan 또는 애플리케이션 시작 시 require_credential_encryption_key() 호출 추가 | +| B-11 | REQ-010 AC3 | broker_credentials 저장 시 암호화 미적용. API 레이어에서 encrypt_payload 호출 없음 | upsert_broker_credential 호출부 또는 API 레이어에서 암호화 적용 | +| B-12 | Baseline | docs/project-structure.md, docs/runtime-flows.md 미갱신. design.md Architecture Impact에 갱신 필요로 명시됨 | 두 baseline 문서에 신규 모듈 및 v1 API 흐름 반영 | + +### Non-Blocking Observations + +| # | REQ | Observation | +|---|---|---| +| N-1 | REQ-009 | migration 파일 코드 검토 PASS. DB 환경(Docker Compose) 실행 미검증 — 배포 전 반드시 확인 필요 | +| N-2 | REQ-004 AC2 | degraded/failed 상태 경로 미구현 — REQ Notes에서 이번 반복 OOS로 명시되어 릴리스 블로커 아님 | +| N-3 | REQ-005 AC4 | 두 번 발급 시 다른 runtime_id — 코드 검토 통과. 전용 테스트 없음 | +| N-4 | REQ-010 AC5 | InvalidToken 경로 전용 테스트 없음 | + diff --git a/docs/batches/index.md b/docs/batches/index.md index 2ca31b0..7b31074 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -7,4 +7,5 @@ ## Batch Register | BAT ID | Profile | Status | Included REQ | Discovery | Folder | -| --- | --- | --- | --- | --- | --- | \ No newline at end of file +| --- | --- | --- | --- | --- | --- | +| BAT-001 | standard | in-delivery | REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010 | dcy-002 | [bat-001_20260502_trex-basic-v1-api-contract](./bat-001_20260502_trex-basic-v1-api-contract/) | \ No newline at end of file diff --git a/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md b/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md new file mode 100644 index 0000000..9b53f69 --- /dev/null +++ b/docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md @@ -0,0 +1,239 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-002_20260502_trex-basic-consumer-api-contract +- 이슈명: trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 +- 작성 시각(KST): 2026-05-02 00:00 +- 마지막 갱신 시각(KST): 2026-05-02 03:00 +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 계약 문서: temp/portfolio-hub-api-contract.md (trex-basic 팀 작성 Consumer-Driven Contract) + - 관련 TODO/메모: 없음 +- 해석: + - trex-basic 팀이 portfolio-hub에 Consumer-Driven Contract를 전달했고, 4개 엔드포인트(GET /health, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades)를 구현해야 한다. Q1(service-token 인증)과 Q2(runtime_id 발급 절차)는 이 Discovery에서 결정 사항으로 반영한다. + - 현재 `/health`는 `status: ok`만 반환하고 `/v1/*` 엔드포인트는 전혀 없다. service-token 메커니즘(`token_type='service'`)은 이미 구현돼 있으나 `runtime_id` 개념이 없고, 브로커 인증 정보 저장 테이블도 없다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 기존 dcy-001은 로컬 DB 마이그레이션 검증과 운영 준비를 다루며 외부 소비자 API 계약과는 완전히 별개 주제다. + - `GET /v1/assets`, `GET /v1/user_info`, `POST /v1/assets/{asset_id}/ledger/trades`는 기존 어떤 Discovery/REQ에도 등장하지 않는 신규 엔드포인트다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - `GET /health`는 `app/api/health.py`에서 `{"status": "ok"}`만 반환하며 계약이 요구하는 `service`, `version`, `checked_at` 필드가 없다. + - service-token 메커니즘은 migration 0013(`20260423_0013_remove_bot_principals_service_tokens.py`)에서 `token_type='service'`로 완성됐고, CLI `pfhub service-token issue/list/revoke`도 `app/cli/commands/service_tokens.py`에 구현돼 있다. 그러나 발급 결과에 `runtime_id`가 포함되지 않는다. + - `Portfolio` 모델(`app/domain/models.py`)에 `portfolio_id`, `broker`, `status`, `symbol`, `cash_balance`, `asset_quantity`, `active_strategy_id`가 있어 `/v1/assets` 응답 필드 일부와 대응하지만, `market`(KOSPI/KOSDAQ) 필드와 `strategy_params` JSON이 없다. + - `StrategySet` 모델에 `config`(문자열) 필드가 있어 strategy_params 원본으로 사용 가능하나, Portfolio와의 연결 시 `strategy_params` 역할을 하는 구체적 구현은 없다. + - `GET /v1/user_info`가 요구하는 `runtime_id`, `trading_enabled`, `brokers[].credential_payload` 데이터는 현재 어떤 테이블에도 존재하지 않는다. + - `POST /v1/assets/{asset_id}/ledger/trades`가 요구하는 `ledger_trades` 테이블이 없다. +- 기대 상태 + - `GET /health`는 `status`, `service`, `version`, `checked_at`을 모두 반환한다. + - `pfhub service-token issue` CLI와 대응 API 엔드포인트가 service-token과 함께 `runtime_id`를 생성·반환한다. 발급된 service-token으로 `/v1/*` 엔드포인트를 인증할 수 있고, `GET /v1/user_info` 응답에도 해당 `runtime_id`가 포함된다. + - `GET /v1/assets`는 service-token bearer 인증으로 관리 자산 목록(portfolio → asset 뷰)과 전략 파라미터를 반환한다. + - `GET /v1/user_info`는 service-token으로 인증한 사용자의 거래 가능 여부와 브로커별 credential_payload를 반환한다. + - `POST /v1/assets/{asset_id}/ledger/trades`는 체결 내역을 idempotent하게 기록하고, 중복 수신 시 `status=duplicate`를 반환한다. +- 이해관계자 + - trex-basic 팀 (소비자, Consumer-Driven Contract 작성자) + - portfolio-hub 백엔드 유지보수 담당자 (구현 주체) + - REQ 승인자 +- 제약사항 + - service-token 인증은 기존 `token_type='service'` 메커니즘을 그대로 사용한다. 별도 인증 체계를 새로 만들지 않는다. + - `GET /v1/user_info`는 `environment` 필드를 노출하지 않는다. portfolio-hub는 항상 live credential을 반환하며 paper/live 분기를 두지 않는다. + - 기존 Alembic revision chain을 깨지 않는 IF NOT EXISTS 기반 migration을 추가한다(dcy-001/REQ-002 정책 준수). + +## 주요 사용자 시나리오 + +- trex-basic 데몬이 시작될 때 `GET /health`로 portfolio-hub 가용성을 확인하고, `GET /v1/assets?status=active`로 관리 자산 목록과 전략 파라미터를 읽은 뒤, `GET /v1/user_info`로 거래 가능 여부와 브로커 인증 정보를 획득해 거래를 시작한다. +- 운영자가 `pfhub service-token issue --user ` CLI로 service-token과 runtime_id를 한 번에 발급하고, 결과를 trex-basic 데몬 환경 변수에 주입한다. 또는 API 엔드포인트를 통해 동일한 발급을 수행한다. +- trex-basic이 KIS 브로커로부터 체결을 확인한 뒤 `POST /v1/assets/{asset_id}/ledger/trades`로 체결 내역을 전송한다. 네트워크 오류로 재전송하더라도 동일 `(asset_id, order_id, execution_id)` 조합이면 중복 기록 없이 `status=duplicate`를 응답 받는다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- `GET /health` 응답 스키마 확장: `service`, `version`, `checked_at` 필드 추가 +- service-token 발급 시 `runtime_id` 동시 생성 및 반환: CLI(`pfhub service-token issue`)와 API 엔드포인트 양쪽에서 `runtime_id`를 함께 발급. `runtime_id`는 발급된 service-token에 연결되어 `GET /v1/user_info` 응답에서 그대로 반환 +- `/v1/assets`, `/v1/user_info`, `/v1/assets/{asset_id}/ledger/trades` 엔드포인트 신규 구현: 브로커 인증 정보 저장 테이블(`broker_credentials`), 거래 내역 테이블(`ledger_trades`), `portfolios` 테이블에 `market` 컬럼 추가를 포함한 DB 변경과 함께 구현 + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `app/api/health.py`: 응답 스키마 확장 + - `app/api/` 신규 라우터: `v1_assets.py`, `v1_user_info.py`, `v1_ledger.py` (또는 통합 `v1.py`) + - `app/cli/commands/service_tokens.py`: `issue` 커맨드에 `runtime_id` 생성·출력 추가 + - `app/domain/models.py`: `BrokerCredential`, `LedgerTrade` 도메인 모델 추가 + - `app/repositories/postgres.py`: 브로커 인증 정보 및 ledger 관련 DB 연산 추가 + - `app/services/`: `v1_service.py` 또는 기존 서비스에 assets/user_info/ledger 메서드 추가 + - `app/schemas/`: v1 응답 스키마 추가 + - `migrations/versions/`: `market` 컬럼, `runtime_id` 연결, `broker_credentials`, `ledger_trades` 테이블 migration 추가 + - `app/main.py`: `/v1` prefix 라우터 등록 +- 문서 영향: + - `docs/interface-contract.md`: trex-basic 소비 엔드포인트 계약 기준 갱신 필요 + - `docs/data-model.md`: `broker_credentials`, `ledger_trades`, `runtime_id` 연결 모델 추가 필요 +- 운영 영향: + - service-token 발급 절차 변경 — `runtime_id`가 함께 반환되므로 기존 운영 가이드 갱신 필요 + - 브로커 인증 정보(credential_payload)는 민감 데이터이므로 DB 수준 보안과 접근 정책 검토 필요 + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: `GET /health` 응답에 `status`, `service`(고정값 `"portfolio-hub"`), `version`(앱 버전), `checked_at`(ISO 8601 KST) 필드가 포함되어야 한다. `status` 허용값은 `ok`, `degraded`, `failed`이며 정상 응답은 `ok`를 반환해야 한다. +- FR-2: `pfhub service-token issue --user ` CLI 커맨드와 대응하는 API 엔드포인트(POST /auth/service-tokens 또는 기존 POST /auth/tokens 확장)가 service-token 문자열과 함께 `runtime_id`(고유 식별자)를 생성·반환해야 한다. `runtime_id`는 service-token에 연결되어 DB에 저장되고, 해당 토큰으로 인증한 `GET /v1/user_info` 호출에서 그대로 반환되어야 한다. +- FR-3: `GET /v1/assets`는 service-token bearer 인증으로 토큰 소유자(user)의 portfolio 목록을 asset 뷰로 반환해야 한다. 응답 항목은 `asset_id`, `symbol`, `market`, `status`, `cash`, `quantity`, `broker_type`, `strategy_id`, `strategy_params`, `updated_at`을 포함해야 하며, `status=active` query parameter로 필터링이 가능해야 한다. `strategy_params`는 해당 portfolio의 StrategySet config에서 파생한다. `strategy_id`는 서버에서 허용값 검증을 수행하지 않으며, 등록된 모든 값을 그대로 반환한다(OQ-1 Answered). +- FR-4: `GET /v1/user_info`는 service-token bearer 인증으로 해당 사용자의 거래 가능 여부(`trading_enabled`, `disabled_reason`)와 브로커별 인증 정보 목록(`brokers[]`)을 반환해야 한다. `brokers[]`의 각 항목은 `broker`, `account_ref`, `credential_payload`, `credential_version`, `last_rotated_at`을 포함해야 한다. `runtime_id`는 인증 service-token에 연결된 값을 반환해야 한다. +- FR-5: `POST /v1/assets/{asset_id}/ledger/trades`는 service-token bearer 인증으로 체결 내역을 기록해야 한다. 요청 본문(`execution_id`, `order_id`, `side`, `quantity`, `price`, `fee_amount`, `tax_amount`, `executed_at`)을 `ledger_trades` 테이블에 저장하고 201을 반환해야 한다. `(asset_id, order_id, execution_id)` 동일 조합의 재수신은 신규 행 없이 `status=duplicate`와 기존 `ledger_entry_id`를 200으로 반환해야 한다. 동일 키에 payload가 다른 경우 409를 반환해야 한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1: `/v1/user_info`는 `environment` 필드를 포함하지 않는다. portfolio-hub는 항상 live credential을 반환하며, paper/live 분기 로직을 서버에서 관리하지 않는다. +- NFR-2: `POST /v1/assets/{asset_id}/ledger/trades`의 idempotency 키는 `(asset_id, order_id, execution_id)` 복합 유니크 제약으로 DB에서 보장해야 한다. 애플리케이션 로직만으로 보장하지 않는다. +- NFR-3: 브로커 인증 정보(`credential_payload`)는 DB에 평문 저장을 금지한다. Python `cryptography` 패키지의 Fernet(AES-128-CBC + HMAC-SHA256)으로 저장 전 암호화하고 조회 시 복호화한다. 암호화 키는 환경변수 `CREDENTIAL_ENCRYPTION_KEY`(base64 인코딩 32바이트)로 주입하며, 키가 없으면 서비스 기동을 거부해야 한다(OQ-3 Answered). +- NFR-4: 신규 migration은 기존 `ensure_schema()` 런타임 경로와 충돌하지 않도록 IF NOT EXISTS 패턴을 사용해야 한다(REQ-002 준수). + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - `GET /health` 응답 스키마 확장 + - service-token 발급에 `runtime_id` 동시 생성 (CLI + API 양쪽) + - `GET /v1/assets` 엔드포인트 구현 (portfolio → asset 뷰 매핑, strategy_params 포함) + - `GET /v1/user_info` 엔드포인트 구현 (runtime_id, trading_enabled, brokers[].credential_payload 포함) + - `POST /v1/assets/{asset_id}/ledger/trades` 엔드포인트 구현 (idempotency 포함) + - 필요한 DB migration 추가 (market 컬럼, runtime_id 연결, broker_credentials 테이블, ledger_trades 테이블) +- Out of Scope + - trex-basic 측 코드 변경 — portfolio-hub API 구현만 담당 + - paper/live 환경 분기 로직 — portfolio-hub는 live credential만 반환 + - rate limiting 구현 — trex-basic 계약의 Q6이지만 이번 반복에서 요구되지 않음; 향후 반복으로 Deferred + - `strategy_id` 허용값 검증 로직 — 검증 없이 통과하는 것으로 확정(OQ-1 Answered). 화이트리스트 불필요 + - KIS 외 다른 브로커의 credential_payload 스키마 정의 — Q4, 향후 반복으로 Deferred + - mock 서버 구현 — trex-basic 팀 자체 `scripts/mock_portfolio_hub.py` 활용 + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): `broker_credentials`의 `credential_payload`(KIS app_key/app_secret)는 민감 데이터다. DB에 평문 저장 시 보안 사고 위험이 있다. + - 완화 방안: 앱 레벨 Fernet 암호화(옵션 A)로 확정(OQ-3 Answered). `CREDENTIAL_ENCRYPTION_KEY` 환경변수 없이 서비스가 기동되지 않도록 startup validation을 구현한다. +- R-2 (Medium): `GET /v1/assets`에서 `strategy_params`를 `StrategySet.config` JSON에서 파생할 때, config 스키마가 전략별로 다르면 응답 일관성이 깨질 수 있다. + - 완화 방안: 설계 단계에서 strategy_params 파생 규칙을 확정한다. config가 없거나 파싱 실패 시 빈 객체(`{}`)를 반환하고 로그를 남기는 안전한 폴백을 구현한다. +- R-3 (Medium): `portfolios` 테이블에 `market` 컬럼이 없어 기존 portfolio 데이터에 market 값이 없다. migration 후 기존 row의 market 값이 NULL 상태로 남는다. + - 완화 방안: migration에서 `market` 컬럼을 nullable로 추가한다(기본값 없음, DECIDE_MARKET_DEFAULT Answered). NULL인 경우 API 응답에서 `null`을 그대로 반환하며, trex-basic 클라이언트가 null을 처리하도록 한다. +- R-4 (Low): `GET /v1/user_info` 호출 시 service-token에 연결된 `runtime_id`가 없는 경우(기존 발급 토큰), 응답 처리 방식이 결정되지 않았다. + - 완화 방안: 기존 service-token은 `runtime_id`가 없으므로 404 또는 409 응답으로 처리하거나, 런타임에 즉시 `runtime_id`를 생성해 연결하는 방안을 설계 단계에서 결정한다. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: service-token 인증은 기존 `token_type='service'` 메커니즘과 bearer token 헤더 파싱을 그대로 사용한다. 별도 인증 체계를 추가하지 않는다. (Q1 결정 사항) +- A-2: `runtime_id`는 service-token 발급 시 함께 생성되며, 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (Q2 결정 사항) +- A-3: `GET /v1/assets`의 `asset_id`는 기존 `portfolio_id`를 그대로 사용한다. 별도 asset 식별자 체계를 만들지 않는다. +- A-4: `trading_enabled`는 현재 단계에서 `users.is_active`와 연동하는 단순 매핑으로 구현한다. 별도 trading 전용 상태 필드는 이번 반복 Out of Scope다. +- A-5: trex-basic이 사용하는 KIS 브로커만 우선 지원하며, credential_payload 스키마는 `{"app_key": "...", "app_secret": "..."}` 형식으로 고정한다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: `strategy_id` 허용값 목록 — trex-basic 첫 실전 전략 ID가 확정되지 않았다. 현재 StrategySet에 있는 전략 ID 값을 검증 없이 통과시킬 것인지, 화이트리스트를 만들 것인지. + - 상태: Answered + - 처리 방안: `strategy_id`는 어떤 값을 사용해도 가능하도록 서버에서 검증 없이 통과한다. 화이트리스트를 두지 않는다. FR-3에 반영. + - 종료 조건: 완료. +- OQ-2: `GET /health` `degraded` 상태 정의 — degraded 시 후속 엔드포인트를 계속 시도할지 여부. portfolio-hub 내부 degraded 판단 기준이 없다. + - 상태: Answered + - 처리 방안: `degraded` 값은 응답 스키마에 유효한 상태로 포함한다(FR-1 허용값 유지). 단, 이번 반복에서는 해당 상태를 반환하는 트리거 조건을 정의하지 않으며, 트리거 조건은 추후 구현 시 정의한다. + - 종료 조건: 완료. +- OQ-3: `credential_payload` DB 저장 암호화 방식 — `broker_credentials.credential_payload`는 KIS `app_key`/`app_secret`을 담는 JSON으로, portfolio-hub PostgreSQL DB에 저장된다. 평문 저장 시 DB 덤프·직접 접근 경로에서 인증 정보가 노출된다(R-1). 아래 세 가지 방식 중 하나를 선택해야 한다. + - **옵션 A — 앱 레벨 AES 암호화 (Fernet)**: Python `cryptography` 패키지로 저장 전 암호화, 조회 시 복호화. 암호화 키는 환경변수 1개(`CREDENTIAL_ENCRYPTION_KEY`)로 주입. 인프라 변경 없음. 구현 복잡도 낮음. psql에서 payload 직접 읽기 불가. 현재 스택에 가장 단순하게 추가 가능. + - **옵션 B — pgcrypto 컬럼 암호화**: PostgreSQL `pgcrypto` extension 활성화 후 `pgp_sym_encrypt`/`pgp_sym_decrypt`로 DB 내 암/복호화. 키는 쿼리 파라미터로 전달하거나 환경변수에서 주입. Docker Compose 환경에서 extension 활성화 필요. 앱 레벨 처리 없이 DB에서 완결되지만, 쿼리마다 키를 파라미터로 전달하는 패턴이 필요해 구현 복잡도가 옵션 A보다 높음. + - **옵션 C — 외부 시크릿 관리자 (Vault, AWS Secrets Manager 등)**: DB에는 시크릿 참조 URI만 저장하고, 실제 credential은 외부 서비스에서 조회. 가장 강력하지만 별도 인프라 운영이 필요하며 현재 프로젝트 규모 대비 과잉 투자. + - 상태: Answered + - 처리 방안: **옵션 A(앱 레벨 AES/Fernet)** 채택. Python `cryptography` 패키지로 저장 전 암호화, 조회 시 복호화. 암호화 키는 환경변수 `CREDENTIAL_ENCRYPTION_KEY`로 주입. NFR-3과 R-1 완화 방안에 반영. + - 종료 조건: 완료. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: `GET /health` 응답에 `status`, `service`, `version`, `checked_at` 필드가 모두 존재하고, `checked_at`이 ISO 8601 형식을 따른다. +- S-2: `pfhub service-token issue --user ` 실행 결과에 `token`과 `runtime_id`가 모두 포함되고, 대응 API 엔드포인트로도 동일하게 발급된다. 발급된 token으로 `GET /v1/user_info`를 호출하면 동일 `runtime_id`가 응답에 포함된다. +- S-3: `GET /v1/assets`가 service-token 인증으로 portfolio 목록을 asset 뷰로 반환하고, 각 항목에 `strategy_params` 객체가 포함된다. `status=active` 필터가 정상 동작한다. +- S-4: `GET /v1/user_info`가 `trading_enabled`, `runtime_id`, `brokers[].credential_payload`를 반환하며 `environment` 필드를 포함하지 않는다. +- S-5: 동일 `(asset_id, order_id, execution_id)` 조합으로 `POST /v1/assets/{asset_id}/ledger/trades`를 두 번 호출할 때 첫 번째는 201, 두 번째는 200+`status=duplicate`를 반환하고 DB에는 단일 행만 존재한다. + +## 측정 방식 및 데이터 출처 +- S-1 측정 방식: 자동화 테스트(pytest)로 `/health` 응답 JSON 필드 검증 / 데이터 출처: `tests/test_app.py` 또는 신규 `tests/test_health.py` +- S-2 측정 방식: CLI 출력 파싱 + API 응답 JSON 검증 / 데이터 출처: `tests/test_service_tokens.py`(신규) 또는 `tests/test_auth.py` 확장 +- S-3 측정 방식: `GET /v1/assets` 응답 스키마 및 필터 동작 pytest 검증 / 데이터 출처: `tests/test_v1_assets.py`(신규) +- S-4 측정 방식: `GET /v1/user_info` 응답 JSON 필드 존재·비존재 검증 / 데이터 출처: `tests/test_v1_user_info.py`(신규) +- S-5 측정 방식: 동일 payload 2회 POST 후 응답 코드·status 및 DB row count 검증 / 데이터 출처: `tests/test_v1_ledger.py`(신규) + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: In Scope/Out of Scope 경계가 명확하고 trex-basic 계약 범위와 일치 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1 ~ S-5 모두 pytest 검증 가능한 형태로 기술됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(credential_payload 암호화) OQ-3 Answered — 옵션 A(Fernet) 확정 +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1, OQ-2, OQ-3 모두 Answered +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: Confirmed By = Justin, confirm-discovery skill 확인 완료 + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-002_20260502_trex-basic-consumer-api-contract.md +- 참조 문서: + - temp/portfolio-hub-api-contract.md (trex-basic Consumer-Driven Contract 원문) + - docs/discovery/dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md (선행 Discovery) + - app/api/health.py + - app/cli/commands/service_tokens.py + - app/domain/models.py + - migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- ~~{{DECIDE_CREDENTIAL_ENCRYPTION}}~~: 옵션 A(앱 레벨 Fernet/AES) 채택 확정 (OQ-3 Answered) +- ~~{{DECIDE_MARKET_DEFAULT}}~~: `portfolios.market` nullable 컬럼으로 추가, 기본값 없음, API 응답에서 null 그대로 반환 (Answered) + +## CONFIRM +- ~~{{CONFIRM_RUNTIME_ID_SCOPE}}~~: `runtime_id`는 service-token 1개당 1개로 발급되며 해당 service-token에 연결된다. 동일 사용자의 다른 service-token은 각기 다른 `runtime_id`를 가진다. (A-2 Confirmed) +- ~~{{CONFIRM_TRADING_ENABLED_MAPPING}}~~: `trading_enabled`는 `users.is_active`와 단순 매핑으로 구현한다. 별도 trading 전용 상태 필드는 이번 반복 Out of Scope. (A-4 Confirmed) + +## DATA +- ~~{{DATA_STRATEGY_ID_LIST}}~~: `strategy_id` 화이트리스트 불필요 — 검증 없이 통과하는 것으로 확정 (OQ-1 Answered) +- ~~{{DATA_BROKER_CREDENTIAL_SCHEMA_OTHER}}~~: KIS 외 다른 브로커의 `credential_payload` 스키마 — 이번 반복 Out of Scope로 확정. 향후 반복에서 별도 Discovery로 처리. + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: 모든 OQ(OQ-1, OQ-2, OQ-3)가 Answered 상태로 해소됨. CONFIRM 항목(runtime_id 발급 방식, trading_enabled 매핑)은 가정(A-2, A-4)으로 문서화되어 있으며 REQ 초안 작성 시 세부 검토 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin +- Confirmed At (KST): 2026-05-02 02:00 diff --git a/docs/discovery/index.md b/docs/discovery/index.md index b6a60f7..a64fd52 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -6,4 +6,5 @@ | ID | 날짜 | 이슈명 | 문서 | 상태 | |---|---|---|---|---| -| dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed | \ No newline at end of file +| dcy-001_20260430_real-db-migration-validation-and-operations-readiness | 20260430 | 실DB 마이그레이션 검증 및 운영 준비 | [Discovery](./dcy-001_20260430_real-db-migration-validation-and-operations-readiness.md) | confirmed | +| dcy-002_20260502_trex-basic-consumer-api-contract | 20260502 | trex-basic Consumer-Driven Contract 반영 — service-token runtime_id 발급 및 v1 API 엔드포인트 구현 | [Discovery](./dcy-002_20260502_trex-basic-consumer-api-contract.md) | confirmed | \ No newline at end of file diff --git a/docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md b/docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md new file mode 100644 index 0000000..7a53694 --- /dev/null +++ b/docs/srs/Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md @@ -0,0 +1,97 @@ +# req-011: trex-basic 계약 반영을 위한 baseline 문서 갱신 + +- Status: Approved +- Type: Documentation +- Priority: Medium +- Owner: repository-maintainers + +## Intent +- dcy-002 구현 완료 후 interface-contract.md, data-model.md, runtime-flows.md를 실제 구현 결과와 동기화한다. +- 이 REQ는 구현이 완료된 시점에 수행하는 문서 갱신이며, 구현 전 선행 작업이 아니다. + +## Requirement +- REQ-004~010이 구현 완료(Implemented)된 시점에 다음 3개 baseline 문서를 갱신해야 한다. + 1. docs/interface-contract.md: GET /health 응답 스키마, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 명세 및 service-token bearer 인증 방식 추가 + 2. docs/data-model.md: broker_credentials 테이블, ledger_trades 테이블, portfolios.market 컬럼, auth_tokens.runtime_id 컬럼 추가 + 3. docs/runtime-flows.md: /v1/* API 요청 처리 흐름(인증 → 서비스 → DB), service-token 발급 시 runtime_id 생성 흐름 추가 + +## Acceptance Criteria +1. Given docs/interface-contract.md를 검토하면, Then GET /health, GET /v1/assets, GET /v1/user_info, POST /v1/assets/{asset_id}/ledger/trades의 요청/응답 명세가 포함되어야 한다. +2. Given docs/data-model.md를 검토하면, Then broker_credentials, ledger_trades 테이블과 portfolios.market, auth_tokens.runtime_id 컬럼이 문서화되어야 한다. +3. Given docs/runtime-flows.md를 검토하면, Then v1 API 처리 흐름과 runtime_id 발급 흐름이 포함되어야 한다. + +## Impacted Area +- Docs: docs/interface-contract.md, docs/data-model.md, docs/runtime-flows.md +- Modules: 없음 +- Tests: 없음 + +## Notes +- 이 REQ는 구현 완료 후 수행하므로 batch 설계 단계에서 별도 implementation task로 명시한다. +- 문서 갱신 내용은 draft-batch-verification 단계에서 실제 구현과 비교해 검증한다. +- Source Discovery: dcy-002 (전체 FR/NFR 구현 완료 후 documentation closure) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-011 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 3개, Impacted Area, Change Log) 모두 충족 확인 후 승인 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch. REQ-004~010 Implemented 전환 후 실행. + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-011 최초 생성 (source Discovery: dcy-002 문서화 클로저) + +#### Intent +- dcy-002 구현 완료 후 baseline 문서 갱신 요구사항을 독립 Documentation REQ로 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/interface-contract.md + - docs/data-model.md + - docs/runtime-flows.md +- Modules: + - none +- Tests: + - none + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-004~010 모두 Implemented 전환 후 이 REQ를 별도 구현 task로 수행. diff --git a/docs/srs/Interface/req-004_health-response-schema-extension.md b/docs/srs/Interface/req-004_health-response-schema-extension.md new file mode 100644 index 0000000..062be2a --- /dev/null +++ b/docs/srs/Interface/req-004_health-response-schema-extension.md @@ -0,0 +1,95 @@ +# req-004: GET /health 응답 스키마 확장 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- trex-basic Consumer-Driven Contract는 GET /health가 status, service, version, checked_at을 반환할 것을 요구한다. +- 현재 구현은 `{"status": "ok"}`만 반환하므로 계약 스키마를 충족하도록 응답을 확장한다. + +## Requirement +- GET /health 엔드포인트는 status, service(고정값 "portfolio-hub"), version(앱 버전), checked_at(ISO 8601 KST) 필드를 포함한 JSON을 반환해야 한다. +- status 허용값은 ok, degraded, failed이며 정상 응답은 ok를 반환해야 한다. + +## Acceptance Criteria +1. Given 서비스가 정상 동작 중일 때, When GET /health를 요청하면, Then 응답 JSON에 status, service, version, checked_at 필드가 모두 존재해야 한다. +2. Given GET /health 응답을 받을 때, When status 값을 확인하면, Then "ok", "degraded", "failed" 중 하나여야 한다. +3. Given GET /health 응답을 받을 때, When checked_at 형식을 검증하면, Then ISO 8601 형식을 만족해야 한다. +4. Given GET /health 응답을 받을 때, When service 필드를 확인하면, Then 고정값 "portfolio-hub"여야 한다. +5. Given 인증 없이 GET /health를 요청해도, Then 200 응답이 반환되어야 한다(비인증 엔드포인트). + +## Impacted Area +- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/runtime-flows.md +- Modules: app/api/health.py, app/schemas/ (HealthResponse 스키마 추가) +- Tests: tests/test_health.py (신규) 또는 tests/test_app.py 확장 + +## Notes +- degraded 상태를 반환하는 트리거 조건은 이번 반복에서 정의하지 않는다(OQ-2 Answered). 스키마에 허용값으로만 포함한다. +- 앱 버전은 pyproject.toml의 version 필드 또는 settings에서 읽어 반환한다. 설계 단계에서 구체적 방법을 확정한다. +- Source Discovery: dcy-002 (FR-1) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-004 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-004 최초 생성 (source Discovery: dcy-002 FR-1) + +#### Intent +- trex-basic Consumer-Driven Contract의 GET /health 응답 스키마 요구사항을 REQ backlog로 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/interface-contract.md (REQ-011 에서 갱신 예정) +- Modules: + - app/api/health.py +- Tests: + - tests/test_health.py 신규 + +#### Delivery Trace Delta +- Batches: + - none (batch 미생성 상태) +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정 diff --git a/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md b/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md new file mode 100644 index 0000000..de9da3e --- /dev/null +++ b/docs/srs/Interface/req-005_service-token-runtime-id-issuance.md @@ -0,0 +1,99 @@ +# req-005: service-token 발급 시 runtime_id 동시 생성 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- trex-basic 데몬은 GET /v1/user_info에서 runtime_id를 조회해 자신을 식별한다. +- runtime_id는 service-token 발급 시 함께 생성되어 해당 토큰에 연결 저장되어야 한다. +- CLI(pfhub service-token issue)와 API 엔드포인트 양쪽에서 발급 결과에 runtime_id가 포함되어야 한다. + +## Requirement +- pfhub service-token issue CLI 커맨드와 대응 API 엔드포인트는 service-token 발급 시 고유 runtime_id를 동시 생성하고 auth_tokens 테이블에 연결 저장한 뒤 발급 결과(token + runtime_id)를 반환해야 한다. +- runtime_id는 해당 service-token에 1:1 연결되며, 동일 사용자의 다른 service-token은 각기 다른 runtime_id를 가진다. + +## Acceptance Criteria +1. Given `pfhub service-token issue --user ` 실행 시, When 발급이 성공하면, Then 출력에 token 문자열과 runtime_id가 모두 포함되어야 한다. +2. Given API 엔드포인트(POST /auth/tokens 또는 동등 경로)로 service-token 발급 시, When 성공 응답을 받으면, Then 응답 JSON에 token과 runtime_id가 포함되어야 한다. +3. Given runtime_id가 발급된 service-token으로 GET /v1/user_info를 호출하면, Then 응답의 runtime_id가 발급 시 생성된 값과 동일해야 한다. +4. Given 동일 사용자에 대해 두 번의 service-token 발급을 수행하면, Then 각각 다른 runtime_id가 반환되어야 한다. +5. Given auth_tokens 테이블을 직접 조회하면, Then 발급된 service-token 행에 runtime_id 값이 저장되어 있어야 한다. + +## Impacted Area +- Docs: docs/runtime-flows.md (REQ-011에서 갱신 예정) +- Modules: app/cli/commands/service_tokens.py, app/api/auth_routes.py, app/domain/models.py (AuthToken.runtime_id 필드 추가), app/services/auth_service.py, app/repositories/postgres.py +- Tests: tests/test_auth.py 확장 또는 tests/test_service_tokens.py (신규) + +## Notes +- runtime_id 포맷은 UUID v4를 권장한다. 설계 단계에서 확정한다. +- auth_tokens 테이블에 runtime_id 컬럼 추가는 REQ-009 migration에서 처리한다. +- 기존 service-token(runtime_id 없음) 처리 방식은 R-4로 식별됨. GET /v1/user_info(REQ-007) 설계 단계에서 결정한다. +- Source Discovery: dcy-002 (FR-2, A-2) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-005 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-005 최초 생성 (source Discovery: dcy-002 FR-2) + +#### Intent +- service-token 발급 시 runtime_id 동시 생성 요구사항을 REQ backlog로 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/runtime-flows.md (REQ-011에서 갱신 예정) +- Modules: + - app/cli/commands/service_tokens.py + - app/api/auth_routes.py + - app/domain/models.py +- Tests: + - tests/test_auth.py 확장 또는 tests/test_service_tokens.py 신규 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-009 migration과 함께 delivery 단위 구성 권장. diff --git a/docs/srs/Interface/req-006_v1-assets-endpoint.md b/docs/srs/Interface/req-006_v1-assets-endpoint.md new file mode 100644 index 0000000..3bf1077 --- /dev/null +++ b/docs/srs/Interface/req-006_v1-assets-endpoint.md @@ -0,0 +1,100 @@ +# req-006: GET /v1/assets 엔드포인트 구현 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- trex-basic 데몬이 관리 자산 목록과 전략 파라미터를 조회할 수 있도록 portfolio 데이터를 asset 뷰로 변환해 제공한다. +- asset_id는 기존 portfolio_id를 그대로 사용하며 별도 식별자 체계를 만들지 않는다(A-3). + +## Requirement +- GET /v1/assets는 service-token bearer 인증으로 토큰 소유자의 portfolio 목록을 asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at 필드로 반환해야 한다. +- status=active 쿼리 파라미터로 필터링이 가능해야 한다. +- strategy_params는 해당 portfolio의 active_strategy_id로 조회한 StrategySet.config에서 파생한다. strategy_id는 허용값 검증 없이 그대로 반환한다(OQ-1 Answered). + +## Acceptance Criteria +1. Given 유효한 service-token bearer 인증으로 GET /v1/assets 요청 시, When 토큰이 유효하면, Then HTTP 200과 해당 사용자 소유 portfolio 목록이 반환되어야 한다. +2. Given GET /v1/assets?status=active 요청 시, Then status가 active인 항목만 반환되어야 한다. +3. Given 응답의 각 항목을 확인하면, Then asset_id, symbol, market, status, cash, quantity, broker_type, strategy_id, strategy_params, updated_at 필드가 모두 포함되어야 한다. +4. Given portfolio에 연결된 StrategySet이 있을 때, When strategy_params를 확인하면, Then StrategySet.config 값에서 파생된 JSON 객체여야 한다. +5. Given StrategySet config가 없거나 파싱 실패 시, Then strategy_params는 빈 객체({})를 반환하고 에러가 전파되지 않아야 한다. +6. Given portfolios.market이 NULL인 경우, Then 응답의 market 필드도 null로 반환되어야 한다(DECIDE_MARKET_DEFAULT Answered). +7. Given 인증 없이 GET /v1/assets 요청 시, Then HTTP 401이 반환되어야 한다. + +## Impacted Area +- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/project-structure.md (신규 라우터 파일 반영 필요) +- Modules: app/api/v1_assets.py (신규), app/services/ (assets 조회 서비스 메서드 추가), app/schemas/ (v1 assets 응답 스키마 추가), app/repositories/postgres.py (portfolio + strategy_set JOIN 쿼리 추가), app/main.py (/v1 라우터 등록) +- Tests: tests/test_v1_assets.py (신규) + +## Notes +- broker_type은 portfolios.broker 컬럼을 그대로 매핑한다(m-2 finding 반영). +- strategy_id는 portfolios.active_strategy_id를 그대로 반환한다. +- GET /v1/assets의 소유권 스코프는 service-token 소유자(owner_user_id)를 기준으로 한다. +- Source Discovery: dcy-002 (FR-3, A-3, OQ-1) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-006 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 7개, Impacted Area, Change Log) 모두 충족 확인 후 승인 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-006 최초 생성 (source Discovery: dcy-002 FR-3) + +#### Intent +- trex-basic Consumer-Driven Contract의 GET /v1/assets 요구사항을 REQ backlog로 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/interface-contract.md (REQ-011에서 갱신 예정) +- Modules: + - app/api/v1_assets.py 신규 + - app/main.py (/v1 라우터 등록) +- Tests: + - tests/test_v1_assets.py 신규 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-009 migration(market 컬럼 추가)과 함께 delivery 단위 구성 권장. diff --git a/docs/srs/Interface/req-007_v1-user-info-endpoint.md b/docs/srs/Interface/req-007_v1-user-info-endpoint.md new file mode 100644 index 0000000..4023aaa --- /dev/null +++ b/docs/srs/Interface/req-007_v1-user-info-endpoint.md @@ -0,0 +1,103 @@ +# req-007: GET /v1/user_info 엔드포인트 구현 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- trex-basic 데몬이 사용자 거래 가능 여부와 브로커 인증 정보를 조회할 수 있도록 한다. +- runtime_id는 인증 service-token에 연결된 값을 반환하며, environment 필드는 포함하지 않는다(NFR-1). +- credential_payload는 DB에서 Fernet 복호화 후 반환한다(NFR-3/REQ-010 의존). + +## Requirement +- GET /v1/user_info는 service-token bearer 인증으로 runtime_id, trading_enabled(users.is_active 매핑), disabled_reason, brokers[](broker, account_ref, credential_payload, credential_version, last_rotated_at)를 반환해야 한다. +- environment 필드를 응답에 포함하지 않는다. +- credential_payload는 Fernet으로 복호화된 JSON을 반환한다. + +## Acceptance Criteria +1. Given runtime_id가 연결된 service-token으로 GET /v1/user_info 호출 시, Then HTTP 200과 응답 JSON에 runtime_id, trading_enabled, brokers[] 필드가 포함되어야 한다. +2. Given GET /v1/user_info 응답을 확인하면, Then environment 필드가 존재하지 않아야 한다. +3. Given users.is_active가 true인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 true여야 한다. +4. Given users.is_active가 false인 사용자의 service-token으로 호출 시, Then 응답의 trading_enabled가 false여야 한다. +5. Given broker_credentials에 KIS credential이 저장된 사용자의 토큰으로 호출 시, When 응답의 brokers[] 항목을 확인하면, Then credential_payload가 복호화된 JSON({app_key, app_secret})으로 반환되어야 한다. +6. Given runtime_id가 없는 구형 service-token으로 호출 시, Then 설계 단계에서 확정된 오류 응답이 반환되어야 한다(R-4, 404 또는 즉시 runtime_id 생성 중 선택). +7. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다. + +## Impacted Area +- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정) +- Modules: app/api/v1_user_info.py (신규), app/domain/models.py (BrokerCredential 모델 추가), app/services/ (user_info 조회 서비스 메서드 추가), app/schemas/ (v1 user_info 응답 스키마 추가), app/repositories/postgres.py (broker_credentials 조회 추가), app/main.py (/v1 라우터 등록) +- Tests: tests/test_v1_user_info.py (신규) + +## Notes +- trading_enabled는 users.is_active 단순 매핑이다(A-4 Confirmed). 별도 trading 전용 상태 필드는 이번 반복 Out of Scope. +- disabled_reason: trading_enabled가 false일 때 이유 문자열. 이번 반복에서는 is_active가 false인 경우 "account_inactive" 고정값을 사용하는 방안을 설계 단계에서 확정한다. +- R-4(기존 service-token에 runtime_id 없는 경우 처리): 404 또는 즉시 생성 중 설계 단계에서 결정. 이 REQ의 Acceptance Criteria 6번은 설계 결정 후 구체화한다. +- Fernet 복호화는 REQ-010에서 정의한 암호화 유틸리티를 사용한다. +- Source Discovery: dcy-002 (FR-4, NFR-1, A-4) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-007 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 7개, Impacted Area, Change Log) 모두 충족 확인 후 승인 +- 설계 의존성 주의: AC #6(runtime_id 없는 구형 토큰 R-4 처리: 404 또는 즉시 생성)은 설계 단계에서 확정 필요. 핵심 Requirement가 명확하고 Notes에 명시적으로 deferred 처리됨 — 승인 게이트 통과 판단. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch. 설계 단계에서 R-4 처리 방식 확정 후 AC #6 구체화. + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-007 최초 생성 (source Discovery: dcy-002 FR-4) + +#### Intent +- trex-basic Consumer-Driven Contract의 GET /v1/user_info 요구사항을 REQ backlog로 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/interface-contract.md (REQ-011에서 갱신 예정) +- Modules: + - app/api/v1_user_info.py 신규 + - app/domain/models.py BrokerCredential 추가 +- Tests: + - tests/test_v1_user_info.py 신규 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-009(broker_credentials 테이블), REQ-010(Fernet 암호화), REQ-005(runtime_id)와 함께 delivery 단위 구성 권장. diff --git a/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md b/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md new file mode 100644 index 0000000..5ec4b85 --- /dev/null +++ b/docs/srs/Interface/req-008_v1-ledger-trades-endpoint.md @@ -0,0 +1,100 @@ +# req-008: POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: repository-maintainers + +## Intent +- trex-basic이 체결 내역을 portfolio-hub에 idempotent하게 전송할 수 있도록 한다. +- 동일 (asset_id, order_id, execution_id) 조합의 중복 수신은 DB unique constraint로 감지하고 status=duplicate를 반환한다(NFR-2). +- 동일 키에 다른 payload가 오면 충돌로 처리한다. + +## Requirement +- POST /v1/assets/{asset_id}/ledger/trades는 service-token bearer 인증으로 체결 내역(execution_id, order_id, side, quantity, price, fee_amount, tax_amount, executed_at)을 ledger_trades 테이블에 저장하고 HTTP 201을 반환해야 한다. +- 동일 (asset_id, order_id, execution_id) 재수신은 신규 행 생성 없이 status=duplicate와 기존 ledger_entry_id를 HTTP 200으로 반환해야 한다. +- 동일 키에 payload가 다른 경우 HTTP 409를 반환해야 한다. +- idempotency는 DB unique constraint로 보장하며, 애플리케이션 로직만으로 보장하지 않는다. + +## Acceptance Criteria +1. Given 유효한 service-token과 신규 체결 데이터로 POST /v1/assets/{asset_id}/ledger/trades 요청 시, Then HTTP 201과 생성된 ledger_entry_id가 반환되어야 한다. +2. Given 동일 (asset_id, order_id, execution_id)와 동일 payload로 두 번째 요청 시, Then HTTP 200과 status="duplicate", 기존 ledger_entry_id가 반환되어야 하며 DB에 단일 행만 존재해야 한다. +3. Given 동일 (asset_id, order_id, execution_id)에 다른 payload로 요청 시, Then HTTP 409가 반환되어야 한다. +4. Given asset_id가 존재하지 않는 portfolio_id인 경우, Then HTTP 404가 반환되어야 한다. +5. Given 인증 없이 요청 시, Then HTTP 401이 반환되어야 한다. + +## Impacted Area +- Docs: docs/interface-contract.md (REQ-011에서 갱신 예정), docs/data-model.md (REQ-011에서 갱신 예정) +- Modules: app/api/v1_ledger.py (신규), app/domain/models.py (LedgerTrade 모델 추가), app/services/ (ledger 저장 서비스 메서드 추가), app/schemas/ (v1 ledger 요청/응답 스키마 추가), app/repositories/postgres.py (ledger_trades 저장 및 중복 감지 쿼리 추가), app/main.py (/v1 라우터 등록) +- Tests: tests/test_v1_ledger.py (신규) + +## Notes +- NFR-2(idempotency DB unique constraint): (asset_id, order_id, execution_id) unique constraint는 REQ-009 migration에서 정의한다. +- ledger_trades 테이블과 기존 portfolio_ledger 테이블은 별개다. portfolio_ledger는 내부 원장이고 ledger_trades는 trex-basic이 외부에서 기록하는 체결 내역이다. +- 중복 감지 구현: DB unique constraint 위반(psycopg UniqueViolation) 캐치 → 기존 행 조회 → payload 동일 여부 비교 → duplicate 또는 409 응답. +- Source Discovery: dcy-002 (FR-5, NFR-2) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-008 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-008 최초 생성 (source Discovery: dcy-002 FR-5) + +#### Intent +- trex-basic Consumer-Driven Contract의 POST /v1/assets/{asset_id}/ledger/trades 요구사항을 REQ backlog로 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/interface-contract.md (REQ-011에서 갱신 예정) +- Modules: + - app/api/v1_ledger.py 신규 + - app/domain/models.py LedgerTrade 추가 +- Tests: + - tests/test_v1_ledger.py 신규 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-009(ledger_trades 테이블 + unique constraint)와 함께 delivery 단위 구성 필수. diff --git a/docs/srs/Migration/req-009_v1-api-db-schema-migration.md b/docs/srs/Migration/req-009_v1-api-db-schema-migration.md new file mode 100644 index 0000000..d79e16d --- /dev/null +++ b/docs/srs/Migration/req-009_v1-api-db-schema-migration.md @@ -0,0 +1,103 @@ +# req-009: v1 API 지원을 위한 DB 스키마 migration 추가 + +- Status: Approved +- Type: Migration +- Priority: High +- Owner: repository-maintainers + +## Intent +- dcy-002에서 요구하는 4개 DB 변경(portfolios.market 컬럼, auth_tokens.runtime_id 컬럼, broker_credentials 테이블, ledger_trades 테이블)을 Alembic revision으로 구현한다. +- IF NOT EXISTS 패턴을 유지해 REQ-002 정책을 준수한다(NFR-4). + +## Requirement +- 다음 스키마 변경을 IF NOT EXISTS 패턴을 사용하는 신규 Alembic migration으로 추가해야 한다. + 1. portfolios 테이블에 market TEXT NULLABLE 컬럼 추가 (기존 행은 NULL로 유지) + 2. auth_tokens 테이블에 runtime_id TEXT UNIQUE NULLABLE 컬럼 추가 + 3. broker_credentials 테이블 신규 생성 (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at 포함) + 4. ledger_trades 테이블 신규 생성 (ledger_entry_id PK, asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at, recorded_at 포함, (asset_id, order_id, execution_id) unique constraint 포함) + +## Acceptance Criteria +1. Given alembic upgrade head 실행 후, When portfolios 테이블을 조회하면, Then market TEXT NULLABLE 컬럼이 존재해야 한다. +2. Given alembic upgrade head 실행 후, When auth_tokens 테이블을 조회하면, Then runtime_id TEXT NULLABLE UNIQUE 컬럼이 존재해야 한다. +3. Given alembic upgrade head 실행 후, Then broker_credentials 테이블이 존재해야 한다. +4. Given alembic upgrade head 실행 후, Then ledger_trades 테이블이 존재해야 한다. +5. Given (asset_id, order_id, execution_id) 동일 조합으로 ledger_trades에 두 번 삽입 시, Then UniqueViolation이 발생해야 한다. +6. Given migration을 이미 실행한 DB에 동일 migration을 재실행하면, Then 에러 없이 완료되어야 한다(IF NOT EXISTS 보장). +7. Given portfolios 테이블의 기존 행, When migration 실행 후 조회하면, Then market 값이 NULL이어야 한다(기존 데이터 보정 없음, DECIDE_MARKET_DEFAULT Answered). + +## Impacted Area +- Docs: docs/data-model.md (REQ-011에서 갱신 예정) +- Modules: migrations/versions/ (신규 migration 파일 추가), app/domain/models.py (Portfolio.market, AuthToken.runtime_id, BrokerCredential, LedgerTrade 모델 필드 추가), app/repositories/postgres.py (신규 테이블 쿼리 추가) +- Tests: tests/test_app.py 또는 migration 검증 테스트 + +## Notes +- NFR-4(IF NOT EXISTS 패턴) 준수 — REQ-002 Alembic migration 정책 기반. +- broker_credentials 테이블의 encrypted_payload 컬럼은 Fernet 암호화된 ciphertext를 TEXT로 저장한다. 암복호화 로직은 REQ-010 참조. +- 컬럼 상세 타입(NUMERIC vs FLOAT, TIMESTAMPTZ 등)은 설계 단계(draft-batch-design)에서 확정한다. +- migration revision ID는 구현 시 기존 chain의 마지막 revision(20260423_0013)을 down_revision으로 참조한다. +- Source Discovery: dcy-002 (영향 범위, NFR-4, DECIDE_MARKET_DEFAULT) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-009 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 7개, Impacted Area, Change Log) 모두 충족 확인 후 승인 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-009 최초 생성 (source Discovery: dcy-002) + +#### Intent +- v1 API 지원에 필요한 DB 스키마 변경을 REQ backlog로 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/data-model.md (REQ-011에서 갱신 예정) +- Modules: + - migrations/versions/ 신규 migration 파일 + - app/domain/models.py 모델 필드 추가 +- Tests: + - migration 검증 테스트 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-005, REQ-006, REQ-007, REQ-008과 함께 delivery 단위 구성 필수(runtime_id, market, broker_credentials, ledger_trades 모두 이 migration에 의존). diff --git a/docs/srs/Non-Functional/req-010_credential-payload-fernet-encryption.md b/docs/srs/Non-Functional/req-010_credential-payload-fernet-encryption.md new file mode 100644 index 0000000..739980d --- /dev/null +++ b/docs/srs/Non-Functional/req-010_credential-payload-fernet-encryption.md @@ -0,0 +1,99 @@ +# req-010: credential_payload Fernet 암호화 및 startup validation + +- Status: Approved +- Type: Non-Functional +- Priority: High +- Owner: repository-maintainers + +## Intent +- broker_credentials의 credential_payload(KIS app_key/app_secret)는 민감 데이터이므로 DB 평문 저장이 금지된다(R-1 High). +- Python cryptography 패키지의 Fernet(AES-128-CBC + HMAC-SHA256)으로 저장 전 암호화, 조회 시 복호화한다. +- CREDENTIAL_ENCRYPTION_KEY 환경변수가 없으면 서비스 기동을 거부해 운영 실수를 사전에 방지한다. + +## Requirement +- 서비스 기동 시 CREDENTIAL_ENCRYPTION_KEY 환경변수(base64 인코딩 32바이트)가 존재하지 않으면 기동을 중단하고 에러 메시지를 출력해야 한다. +- broker_credentials에 credential_payload를 저장할 때 Fernet으로 암호화한 ciphertext를 저장해야 한다. +- broker_credentials에서 credential_payload를 조회할 때 Fernet으로 복호화한 JSON을 반환해야 한다. + +## Acceptance Criteria +1. Given CREDENTIAL_ENCRYPTION_KEY가 설정된 환경에서 서비스 기동 시, Then 정상 기동되어야 한다. +2. Given CREDENTIAL_ENCRYPTION_KEY가 설정되지 않은 환경에서 서비스 기동 시, Then 기동이 실패하고 키 누락을 알리는 에러 메시지가 출력되어야 한다. +3. Given credential_payload를 broker_credentials에 저장한 뒤 DB를 직접 조회하면, Then 암호화된 ciphertext가 저장되어 있어야 한다(평문 app_key/app_secret 불가). +4. Given 암호화 저장된 credential_payload를 조회 API(GET /v1/user_info)로 읽으면, Then 복호화된 JSON({app_key: ..., app_secret: ...})이 반환되어야 한다. +5. Given 잘못된 키로 복호화를 시도하면, Then cryptography.fernet.InvalidToken 예외가 발생하고 500 또는 적절한 오류 응답이 반환되어야 한다. + +## Impacted Area +- Docs: docs/project-structure.md (app/core/encryption.py 신규 모듈 반영 필요) +- Modules: app/core/config.py (CREDENTIAL_ENCRYPTION_KEY 설정 필드 및 startup validation 추가), app/core/security.py 또는 app/core/encryption.py (Fernet 암복호화 유틸리티 신규), app/repositories/postgres.py (broker_credentials 저장·조회 시 암복호화 적용) +- Tests: tests/test_auth.py 또는 신규 테스트에서 startup validation 및 암복호화 검증 + +## Notes +- CREDENTIAL_ENCRYPTION_KEY는 `from cryptography.fernet import Fernet; Fernet.generate_key()`로 생성한 base64 인코딩 키를 사용한다. +- startup validation은 FastAPI lifespan 이벤트 또는 Settings 초기화 시 수행한다. 설계 단계에서 위치를 확정한다. +- 키 로테이션 절차는 이번 반복 Out of Scope이다. +- Source Discovery: dcy-002 (NFR-3, R-1, OQ-3 Answered) + +## Change Log + +### CHG-20260502-02 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-010 confirm-req 승인 전환 (Status: Proposed → Approved) + +#### Intent +- 필수 필드(Status, Type, Priority, Owner, Intent, Requirement, Acceptance Criteria 5개, Impacted Area, Change Log) 모두 충족 확인 후 승인 + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: suggest-batch-reqs → draft-batch + +### CHG-20260502-01 +- Date: 2026-05-02 +- Author: repository-maintainers + +#### Change Summary +- REQ-010 최초 생성 (source Discovery: dcy-002 NFR-3) + +#### Intent +- credential_payload 암호화 및 startup validation 요구사항을 독립 Non-Functional REQ로 승격 정규화 + +#### Acceptance Criteria Delta +- Added: + - 전체 Acceptance Criteria 신규 작성 + +#### Impacted Area Delta +- Docs: + - docs/project-structure.md (encryption 모듈 반영 필요) +- Modules: + - app/core/config.py startup validation 추가 + - app/core/encryption.py 신규 +- Tests: + - startup validation 및 암복호화 테스트 + +#### Delivery Trace Delta +- Batches: + - none +- Releases: + - none + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: keep-current +- Follow-up Action: confirm-req 후 batch 배정. REQ-007(GET /v1/user_info), REQ-009(broker_credentials 테이블)과 함께 delivery 단위 구성 권장. diff --git a/docs/srs/index.md b/docs/srs/index.md index 8a741bf..8072fe8 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -74,7 +74,7 @@ ## Next Requirement ID -- Current: `REQ-004` +- Current: `REQ-012` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -84,11 +84,19 @@ | REQ-001 | 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 | Testing | Approved | High | repository-maintainers | [REQ-001](./Testing/req-001_local-db-validation-scenarios.md) | | REQ-002 | Alembic와 runtime schema ownership 경계 정리 | Migration | Approved | High | repository-maintainers | [REQ-002](./Migration/req-002_alembic-runtime-schema-ownership.md) | | REQ-003 | 로컬 DB 운영 runbook 및 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-003](./Documentation/req-003_local-db-operations-runbook-and-baseline-updates.md) | +| REQ-004 | GET /health 응답 스키마 확장 | Interface | Approved | High | repository-maintainers | [REQ-004](./Interface/req-004_health-response-schema-extension.md) | +| REQ-005 | service-token 발급 시 runtime_id 동시 생성 | Interface | Approved | High | repository-maintainers | [REQ-005](./Interface/req-005_service-token-runtime-id-issuance.md) | +| REQ-006 | GET /v1/assets 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-006](./Interface/req-006_v1-assets-endpoint.md) | +| REQ-007 | GET /v1/user_info 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-007](./Interface/req-007_v1-user-info-endpoint.md) | +| REQ-008 | POST /v1/assets/{asset_id}/ledger/trades 엔드포인트 구현 | Interface | Approved | High | repository-maintainers | [REQ-008](./Interface/req-008_v1-ledger-trades-endpoint.md) | +| REQ-009 | v1 API 지원을 위한 DB 스키마 migration 추가 | Migration | Approved | High | repository-maintainers | [REQ-009](./Migration/req-009_v1-api-db-schema-migration.md) | +| REQ-010 | credential_payload Fernet 암호화 및 startup validation | Non-Functional | Approved | High | repository-maintainers | [REQ-010](./Non-Functional/req-010_credential-payload-fernet-encryption.md) | +| REQ-011 | trex-basic 계약 반영을 위한 baseline 문서 갱신 | Documentation | Approved | Medium | repository-maintainers | [REQ-011](./Documentation/req-011_baseline-docs-update-for-trex-basic-contract.md) | ## Recent Change Log Summary -- 2026-04-30: REQ-003 Approved 전환 — 로컬 DB 운영 runbook 및 baseline 문서 갱신 -- 2026-04-30: REQ-002 Approved 전환 — Alembic와 runtime schema ownership 경계 정리 -- 2026-04-30: REQ-001 Approved 전환 — 로컬 DB 초기화 및 마이그레이션 검증 시나리오 정의 -- 2026-04-30: REQ-003 Proposed 생성 — 로컬 DB 운영 runbook 및 baseline 문서 갱신 (source Discovery: dcy-001) -- 2026-04-30: REQ-002 Proposed 생성 — Alembic와 runtime schema ownership 경계 정리 (source Discovery: dcy-001) \ No newline at end of file +- 2026-05-02: REQ-011 Approved 전환 — trex-basic 계약 반영을 위한 baseline 문서 갱신 (source Discovery: dcy-002) +- 2026-05-02: REQ-010 Approved 전환 — credential_payload Fernet 암호화 및 startup validation (source Discovery: dcy-002) +- 2026-05-02: REQ-009 Approved 전환 — v1 API DB 스키마 migration 추가 (source Discovery: dcy-002) +- 2026-05-02: REQ-008 Approved 전환 — POST /v1/assets/{asset_id}/ledger/trades 구현 (source Discovery: dcy-002) +- 2026-05-02: REQ-007 Approved 전환 — GET /v1/user_info 엔드포인트 구현 (source Discovery: dcy-002) \ No newline at end of file diff --git a/migrations/versions/20260502_0014_v1_api_schema.py b/migrations/versions/20260502_0014_v1_api_schema.py new file mode 100644 index 0000000..e9bc383 --- /dev/null +++ b/migrations/versions/20260502_0014_v1_api_schema.py @@ -0,0 +1,122 @@ +"""Add v1 API schema: portfolios.market, auth_tokens.runtime_id, broker_credentials, ledger_trades + +Revision ID: 20260502_0014 +Revises: 20260423_0013 +Create Date: 2026-05-02 00:00:00 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260502_0014" +down_revision = "20260423_0013" +branch_labels = None +depends_on = None + + +def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool: + if not _table_exists(inspector, table_name): + return False + return any(c["name"] == column_name for c in inspector.get_columns(table_name)) + + +def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool: + if not _table_exists(inspector, table_name): + return False + return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + # 1. portfolios.market TEXT NULLABLE + if not _column_exists(inspector, "portfolios", "market"): + op.add_column("portfolios", sa.Column("market", sa.Text(), nullable=True)) + + # 2. auth_tokens.runtime_id TEXT UNIQUE NULLABLE + if not _column_exists(inspector, "auth_tokens", "runtime_id"): + op.add_column("auth_tokens", sa.Column("runtime_id", sa.Text(), nullable=True)) + op.create_index( + "idx_auth_tokens_runtime_id_unique", + "auth_tokens", + ["runtime_id"], + unique=True, + postgresql_where=sa.text("runtime_id IS NOT NULL"), + ) + + # 3. broker_credentials 테이블 신규 + if not _table_exists(inspector, "broker_credentials"): + op.create_table( + "broker_credentials", + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("broker", sa.Text(), nullable=False), + sa.Column("account_ref", sa.Text(), nullable=False), + sa.Column("encrypted_payload", sa.Text(), nullable=False), + sa.Column("credential_version", sa.Integer(), nullable=False, server_default="1"), + sa.Column( + "last_rotated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + sa.PrimaryKeyConstraint("user_id", "broker"), + ) + + # 4. ledger_trades 테이블 신규 + if not _table_exists(inspector, "ledger_trades"): + op.create_table( + "ledger_trades", + sa.Column( + "ledger_entry_id", + sa.BigInteger(), + sa.Identity(always=False), + nullable=False, + ), + sa.Column("asset_id", sa.Text(), nullable=False), + sa.Column("order_id", sa.Text(), nullable=False), + sa.Column("execution_id", sa.Text(), nullable=False), + sa.Column("side", sa.Text(), nullable=False), + sa.Column("quantity", sa.Numeric(20, 8), nullable=False), + sa.Column("price", sa.Numeric(20, 8), nullable=False), + sa.Column("fee_amount", sa.Numeric(20, 8), nullable=False, server_default="0"), + sa.Column("tax_amount", sa.Numeric(20, 8), nullable=False, server_default="0"), + sa.Column("executed_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column( + "recorded_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("NOW()"), + ), + sa.PrimaryKeyConstraint("ledger_entry_id"), + sa.UniqueConstraint("asset_id", "order_id", "execution_id", name="uq_ledger_trades_idempotency"), + ) + op.create_index( + "idx_ledger_trades_asset_executed", + "ledger_trades", + ["asset_id", "executed_at"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _table_exists(inspector, "ledger_trades"): + op.drop_table("ledger_trades") + if _table_exists(inspector, "broker_credentials"): + op.drop_table("broker_credentials") + if _column_exists(inspector, "auth_tokens", "runtime_id"): + if _index_exists(inspector, "auth_tokens", "idx_auth_tokens_runtime_id_unique"): + op.drop_index("idx_auth_tokens_runtime_id_unique", table_name="auth_tokens") + op.drop_column("auth_tokens", "runtime_id") + if _column_exists(inspector, "portfolios", "market"): + op.drop_column("portfolios", "market") diff --git a/tests/test_app.py b/tests/test_app.py index e86a141..990d8d4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -13,7 +13,11 @@ def test_healthcheck() -> None: response = client.get("/health") assert response.status_code == 200 - assert response.json() == {"status": "ok"} + body = response.json() + assert body["status"] == "ok" + assert body["service"] == "portfolio-hub" + assert "version" in body + assert "checked_at" in body def test_docs_are_exposed() -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py index ee7839b..e2a48f6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -98,6 +98,7 @@ class _AuthRepo: permission: str, token_type: str, expires_at: datetime | None = None, + runtime_id: str | None = None, ) -> AuthToken: token = AuthToken( id=f"token-{self._seq}", @@ -108,6 +109,7 @@ class _AuthRepo: token_type=token_type, expires_at=expires_at, created_at=datetime.now(timezone.utc), + runtime_id=runtime_id, ) self._seq += 1 self.tokens[token.id] = token diff --git a/tests/test_cli.py b/tests/test_cli.py index 88573cd..e536ed2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -122,6 +122,7 @@ class FakeRepo: permission: str, token_type: str, expires_at: datetime | None = None, + runtime_id: str | None = None, ) -> AuthToken: token = AuthToken( id=f"token-{self._token_seq}", @@ -132,6 +133,7 @@ class FakeRepo: token_type=token_type, expires_at=expires_at, created_at=datetime.now(timezone.utc), + runtime_id=runtime_id, ) self._token_seq += 1 self._auth_tokens[token.id] = token diff --git a/tests/test_v1.py b/tests/test_v1.py new file mode 100644 index 0000000..ed74d44 --- /dev/null +++ b/tests/test_v1.py @@ -0,0 +1,285 @@ +"""Tests for v1 API endpoints (health, assets, user_info, ledger/trades).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.core.auth import AuthContext, Permission +from app.core.config import Settings +from app.domain.models import AuthToken, Portfolio, TokenPrincipal, User, LedgerTrade +from app.main import create_app + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_settings(**kwargs) -> Settings: + return Settings( + allowed_origins_raw="http://localhost:3000", + **kwargs, + ) + + +def _make_app(settings: Settings | None = None) -> TestClient: + s = settings or _make_settings() + return TestClient(create_app(settings_factory=lambda: s)) + + +def _service_token_header(token: str = "svc-token") -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + +def test_health_returns_full_response() -> None: + client = _make_app() + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["service"] == "portfolio-hub" + assert "version" in body + assert "checked_at" in body + # checked_at should contain KST offset (+09:00) + assert "+09:00" in body["checked_at"] + + +# --------------------------------------------------------------------------- +# GET /v1/assets +# --------------------------------------------------------------------------- + +def _make_portfolio(portfolio_id: str = "p1") -> Portfolio: + return Portfolio( + portfolio_id=portfolio_id, + broker="kis", + status="enabled", + name="Test", + symbol="005930", + market="KRW", + tags=["tag1"], + cash_balance=1000.0, + asset_quantity=10.0, + price=50000.0, + profit_buffer=0.0, + active_strategy_id=None, + avabile_strategies=None, + owner_user_id="user-1", + ) + + +def test_v1_assets_requires_service_token(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") + settings = _make_settings(db_enabled=False) + client = _make_app(settings) + resp = client.get("/v1/assets") + assert resp.status_code == 401 + + +def test_v1_assets_rejects_env_token() -> None: + from app.core.config import get_settings + settings = _make_settings(db_enabled=False, read_tokens_raw="env-token") + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + client = TestClient(app) + resp = client.get("/v1/assets", headers=_service_token_header("env-token")) + assert resp.status_code == 403 + + +def test_v1_assets_returns_asset_list(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + + portfolio = _make_portfolio() + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-1" + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_portfolios_with_strategies.return_value = [portfolio] + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app() + resp = client.get("/v1/assets", headers=_service_token_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["assets"][0]["asset_id"] == "p1" + assert body["assets"][0]["market"] == "KRW" + + +# --------------------------------------------------------------------------- +# GET /v1/user_info +# --------------------------------------------------------------------------- + +def test_v1_user_info_404_when_no_runtime_id(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id=None, # no runtime_id + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app() + resp = client.get("/v1/user_info", headers=_service_token_header()) + + assert resp.status_code == 404 + assert "runtime_id" in resp.json()["detail"] + + +def test_v1_user_info_returns_user(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="read", + token_type="service", runtime_id="rt-abc" + ) + user = User( + id="user-1", username="alice", email="alice@example.com", + permission="read", is_active=True, + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app() + resp = client.get("/v1/user_info", headers=_service_token_header()) + + assert resp.status_code == 200 + body = resp.json() + assert body["runtime_id"] == "rt-abc" + assert body["username"] == "alice" + assert body["disabled_reason"] is None + + +def test_v1_user_info_inactive_user_has_disabled_reason(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + + principal = TokenPrincipal( + token_id="t1", user_id="user-2", permission="read", + token_type="service", runtime_id="rt-xyz" + ) + user = User( + id="user-2", username="bob", email=None, + permission="read", is_active=False, + ) + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.get_user.return_value = user + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_user_info.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app() + resp = client.get("/v1/user_info", headers=_service_token_header()) + + assert resp.status_code == 200 + assert resp.json()["disabled_reason"] == "account_inactive" + + +# --------------------------------------------------------------------------- +# POST /v1/assets/{asset_id}/ledger/trades +# --------------------------------------------------------------------------- + +def _trade_payload(**overrides) -> dict: + base = { + "order_id": "ord-001", + "execution_id": "exec-001", + "side": "buy", + "quantity": "10.5", + "price": "50000", + "fee_amount": "100", + "tax_amount": "0", + "executed_at": "2026-05-01T10:00:00+09:00", + } + base.update(overrides) + return base + + +def _make_ledger_trade(**kwargs) -> LedgerTrade: + defaults = dict( + ledger_entry_id=1, + asset_id="005930", + order_id="ord-001", + execution_id="exec-001", + side="buy", + quantity=10.5, + price=50000.0, + fee_amount=100.0, + tax_amount=0.0, + executed_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc), + recorded_at=datetime(2026, 5, 1, 1, 0, 0, tzinfo=timezone.utc), + ) + defaults.update(kwargs) + return LedgerTrade(**defaults) + + +def test_v1_ledger_trade_created(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="trade-write", + token_type="service", runtime_id="rt-1" + ) + trade = _make_ledger_trade() + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.insert_ledger_trade.return_value = (trade, True) + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app() + resp = client.post( + "/v1/assets/005930/ledger/trades", + json=_trade_payload(), + headers=_service_token_header(), + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["created"] is True + assert body["asset_id"] == "005930" + + +def test_v1_ledger_trade_idempotent(monkeypatch) -> None: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "true") + + principal = TokenPrincipal( + token_id="t1", user_id="user-1", permission="trade-write", + token_type="service", runtime_id="rt-1" + ) + trade = _make_ledger_trade() + + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.insert_ledger_trade.return_value = (trade, False) # already existed + + with patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), \ + patch("app.api.v1_ledger.get_portfolio_repository_from_settings", return_value=mock_repo): + client = _make_app() + resp = client.post( + "/v1/assets/005930/ledger/trades", + json=_trade_payload(), + headers=_service_token_header(), + ) + + assert resp.status_code == 201 + assert resp.json()["created"] is False