Compare commits

...

5 Commits

Author SHA1 Message Date
eb06dfc48b feat: implement scenario gap analysis - core loop completion
All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s
Phase 1 (Critical):
- Add bulk rebalance apply API + UI with confirmation modal
- Add strategy results to portfolio targets flow (shared component)

Phase 2 (Important):
- Show current holdings in signal execute modal with auto-fill
- Add DC pension risk asset ratio warning (70% limit)
- Add KOSPI benchmark comparison to portfolio returns
- Track signal execution details (price, quantity, timestamp)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:18:15 +09:00
ea93e09059 chore: add .worktrees/ to .gitignore 2026-02-19 16:51:15 +09:00
c97bb8595e docs: add scenario gap analysis results and implementation plan
Expert team identified 12 gaps across 6 scenarios. Prioritized into
3 phases: Critical (rebalance apply, strategy-to-portfolio link),
Important (signal holdings, DC limits, benchmark, signal PnL),
and Nice-to-have (comparison UI, walk-forward, undo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:44:40 +09:00
7cff4003d7 docs: add scenario-based gap analysis design for DC pension management
Define 6 core usage scenarios and expert team composition for
evaluating feature completeness of the quant portfolio management system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:40:35 +09:00
a7366d053e feat: add manual signal execution to portfolio
Allow users to execute active KJB signals by selecting a portfolio,
entering quantity and price, then creating the corresponding transaction
and updating holdings. Signal status changes to 'executed' after completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:10:48 +09:00
20 changed files with 1580 additions and 8 deletions

3
.gitignore vendored
View File

@ -61,3 +61,6 @@ data/
.coverage
htmlcov/
.pytest_cache/
# Worktrees
.worktrees/

View File

@ -0,0 +1,30 @@
"""add signal execution tracking fields
Revision ID: a1b2c3d4e5f6
Revises: 6c09aa4368e5
Create Date: 2026-02-19 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '6c09aa4368e5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('signals', sa.Column('executed_price', sa.Numeric(precision=12, scale=2), nullable=True))
op.add_column('signals', sa.Column('executed_quantity', sa.Integer(), nullable=True))
op.add_column('signals', sa.Column('executed_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('signals', 'executed_at')
op.drop_column('signals', 'executed_quantity')
op.drop_column('signals', 'executed_price')

View File

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.portfolio import Portfolio, PortfolioType, Target, Holding, Transaction, TransactionType
from app.models.stock import ETF
from app.schemas.portfolio import (
PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail,
TargetCreate, TargetResponse,
@ -17,6 +18,7 @@ from app.schemas.portfolio import (
TransactionCreate, TransactionResponse,
RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
RebalanceCalculateRequest, RebalanceCalculateResponse,
RebalanceApplyRequest, RebalanceApplyResponse,
)
from app.services.rebalance import RebalanceService
@ -363,6 +365,90 @@ async def calculate_rebalance_manual(
)
@router.post("/{portfolio_id}/rebalance/apply", response_model=RebalanceApplyResponse, status_code=status.HTTP_201_CREATED)
async def apply_rebalance(
portfolio_id: int,
data: RebalanceApplyRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""리밸런싱 결과를 적용하여 거래를 일괄 생성한다."""
from datetime import datetime
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
transactions = []
service = RebalanceService(db)
for item in data.items:
tx_type = TransactionType(item.action)
transaction = Transaction(
portfolio_id=portfolio_id,
ticker=item.ticker,
tx_type=tx_type,
quantity=item.quantity,
price=item.price,
executed_at=datetime.utcnow(),
memo="리밸런싱 적용",
)
db.add(transaction)
# Update holding
holding = db.query(Holding).filter(
Holding.portfolio_id == portfolio_id,
Holding.ticker == item.ticker,
).first()
if tx_type == TransactionType.BUY:
if holding:
total_value = (holding.quantity * holding.avg_price) + (item.quantity * item.price)
new_quantity = holding.quantity + item.quantity
holding.quantity = new_quantity
holding.avg_price = total_value / new_quantity if new_quantity > 0 else 0
else:
holding = Holding(
portfolio_id=portfolio_id,
ticker=item.ticker,
quantity=item.quantity,
avg_price=item.price,
)
db.add(holding)
elif tx_type == TransactionType.SELL:
if not holding or holding.quantity < item.quantity:
raise HTTPException(status_code=400, detail=f"Insufficient quantity for {item.ticker}")
holding.quantity -= item.quantity
if holding.quantity == 0:
db.delete(holding)
transactions.append(transaction)
db.commit()
for tx in transactions:
db.refresh(tx)
# Resolve stock names
tickers = list({tx.ticker for tx in transactions})
names = service.get_stock_names(tickers)
tx_responses = [
TransactionResponse(
id=tx.id,
ticker=tx.ticker,
name=names.get(tx.ticker),
tx_type=tx.tx_type.value,
quantity=tx.quantity,
price=tx.price,
executed_at=tx.executed_at,
memo=tx.memo,
)
for tx in transactions
]
return RebalanceApplyResponse(
transactions=tx_responses,
holdings_updated=len(transactions),
)
@router.get("/{portfolio_id}/detail", response_model=PortfolioDetail)
async def get_portfolio_detail(
portfolio_id: int,
@ -410,6 +496,23 @@ async def get_portfolio_detail(
if total_value > 0:
h.current_ratio = (h.value / total_value * 100).quantize(Decimal("0.01"))
# Calculate risk asset ratio for pension portfolios
risk_asset_ratio = None
if portfolio.portfolio_type == PortfolioType.PENSION and total_value > 0:
# Look up ETF asset classes
etf_tickers = [h.ticker for h in holdings_with_value]
etfs = db.query(ETF).filter(ETF.ticker.in_(etf_tickers)).all() if etf_tickers else []
safe_classes = {"bond", "gold"}
etf_class_map = {e.ticker: e.asset_class.value for e in etfs}
risk_value = Decimal("0")
for h in holdings_with_value:
asset_class = etf_class_map.get(h.ticker)
if asset_class not in safe_classes:
risk_value += h.value or Decimal("0")
risk_asset_ratio = (risk_value / total_value * 100).quantize(Decimal("0.01"))
return PortfolioDetail(
id=portfolio.id,
user_id=portfolio.user_id,
@ -422,4 +525,5 @@ async def get_portfolio_detail(
total_value=total_value,
total_invested=total_invested,
total_profit_loss=total_value - total_invested,
risk_asset_ratio=risk_asset_ratio,
)

View File

@ -1,16 +1,18 @@
"""
KJB Signal API endpoints.
"""
from datetime import date
from datetime import date, datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.signal import Signal
from app.schemas.signal import SignalResponse
from app.models.signal import Signal, SignalStatus, SignalType
from app.models.portfolio import Holding, Transaction, TransactionType
from app.schemas.signal import SignalExecuteRequest, SignalResponse
from app.schemas.portfolio import TransactionResponse
router = APIRouter(prefix="/api/signal", tags=["signal"])
@ -54,3 +56,97 @@ async def get_signal_history(
.all()
)
return signals
@router.post("/{signal_id}/execute", response_model=dict)
async def execute_signal(
signal_id: int,
data: SignalExecuteRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Execute a signal by creating a portfolio transaction and updating signal status."""
from app.api.portfolio import _get_portfolio
# 1. Look up the signal and verify it's active
signal = db.query(Signal).filter(Signal.id == signal_id).first()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
if signal.status != SignalStatus.ACTIVE:
raise HTTPException(status_code=400, detail="Signal is not active")
# 2. Verify portfolio ownership
portfolio = _get_portfolio(db, data.portfolio_id, current_user.id)
# 3. Map signal type to transaction type
if signal.signal_type == SignalType.BUY:
tx_type = TransactionType.BUY
else:
tx_type = TransactionType.SELL
# 4. Create transaction (reuse portfolio transaction logic)
transaction = Transaction(
portfolio_id=data.portfolio_id,
ticker=signal.ticker,
tx_type=tx_type,
quantity=data.quantity,
price=data.price,
executed_at=datetime.utcnow(),
memo=f"KJB signal #{signal.id}: {signal.signal_type.value}",
)
db.add(transaction)
# 5. Update holding
holding = db.query(Holding).filter(
Holding.portfolio_id == data.portfolio_id,
Holding.ticker == signal.ticker,
).first()
if tx_type == TransactionType.BUY:
if holding:
total_value = (holding.quantity * holding.avg_price) + (data.quantity * data.price)
new_quantity = holding.quantity + data.quantity
holding.quantity = new_quantity
holding.avg_price = total_value / new_quantity if new_quantity > 0 else 0
else:
holding = Holding(
portfolio_id=data.portfolio_id,
ticker=signal.ticker,
quantity=data.quantity,
avg_price=data.price,
)
db.add(holding)
elif tx_type == TransactionType.SELL:
if not holding or holding.quantity < data.quantity:
raise HTTPException(
status_code=400,
detail=f"Insufficient quantity for {signal.ticker}"
)
holding.quantity -= data.quantity
if holding.quantity == 0:
db.delete(holding)
# 6. Update signal status to executed with execution details
signal.status = SignalStatus.EXECUTED
signal.executed_price = data.price
signal.executed_quantity = data.quantity
signal.executed_at = datetime.utcnow()
db.commit()
db.refresh(transaction)
db.refresh(signal)
return {
"transaction": {
"id": transaction.id,
"ticker": transaction.ticker,
"tx_type": transaction.tx_type.value,
"quantity": transaction.quantity,
"price": float(transaction.price),
"executed_at": transaction.executed_at.isoformat(),
},
"signal": {
"id": signal.id,
"status": signal.status.value,
},
}

View File

@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.portfolio import Portfolio, PortfolioSnapshot, SnapshotHolding
from app.models.stock import ETFPrice
from app.schemas.portfolio import (
SnapshotListItem, SnapshotResponse, SnapshotHoldingResponse,
ReturnsResponse, ReturnDataPoint,
@ -239,6 +240,22 @@ async def get_returns(
data=[],
)
# Get benchmark (KOSPI ETF 069500) prices for the same date range
snapshot_dates = [s.snapshot_date for s in snapshots]
benchmark_ticker = "069500" # KODEX 200 (KOSPI benchmark)
benchmark_prices = (
db.query(ETFPrice)
.filter(
ETFPrice.ticker == benchmark_ticker,
ETFPrice.date.in_(snapshot_dates),
)
.all()
)
benchmark_map = {bp.date: Decimal(str(bp.close)) for bp in benchmark_prices}
# Get first benchmark price for cumulative calculation
first_benchmark = benchmark_map.get(snapshots[0].snapshot_date)
# Calculate returns
data_points = []
first_value = Decimal(str(snapshots[0].total_value))
@ -259,11 +276,18 @@ async def get_returns(
else:
cumulative_return = Decimal("0")
# Benchmark cumulative return
benchmark_return = None
bench_price = benchmark_map.get(snapshot.snapshot_date)
if bench_price and first_benchmark and first_benchmark > 0:
benchmark_return = ((bench_price - first_benchmark) / first_benchmark * 100).quantize(Decimal("0.01"))
data_points.append(ReturnDataPoint(
date=snapshot.snapshot_date,
total_value=current_value,
daily_return=daily_return,
cumulative_return=cumulative_return,
benchmark_return=benchmark_return,
))
prev_value = current_value
@ -275,6 +299,7 @@ async def get_returns(
total_return = None
cagr = None
benchmark_total_return = None
if first_value > 0:
total_return = ((last_value - first_value) / first_value * 100).quantize(Decimal("0.01"))
@ -289,11 +314,17 @@ async def get_returns(
cagr_value = (float(ratio) ** (1 / float(years)) - 1) * 100
cagr = Decimal(str(cagr_value)).quantize(Decimal("0.01"))
# Benchmark total return
last_benchmark = benchmark_map.get(end_date)
if first_benchmark and last_benchmark and first_benchmark > 0:
benchmark_total_return = ((last_benchmark - first_benchmark) / first_benchmark * 100).quantize(Decimal("0.01"))
return ReturnsResponse(
portfolio_id=portfolio_id,
start_date=start_date,
end_date=end_date,
total_return=total_return,
cagr=cagr,
benchmark_total_return=benchmark_total_return,
data=data_points,
)

View File

@ -38,3 +38,7 @@ class Signal(Base):
reason = Column(String(200))
status = Column(SQLEnum(SignalStatus), default=SignalStatus.ACTIVE)
created_at = Column(DateTime, default=datetime.utcnow)
# Execution tracking fields
executed_price = Column(Numeric(12, 2), nullable=True)
executed_quantity = Column(Integer, nullable=True)
executed_at = Column(DateTime, nullable=True)

View File

@ -109,6 +109,7 @@ class PortfolioDetail(PortfolioResponse):
total_value: FloatDecimal | None = None
total_invested: FloatDecimal | None = None
total_profit_loss: FloatDecimal | None = None
risk_asset_ratio: FloatDecimal | None = None
# Snapshot schemas
@ -153,6 +154,7 @@ class ReturnDataPoint(BaseModel):
total_value: FloatDecimal
daily_return: FloatDecimal | None = None
cumulative_return: FloatDecimal | None = None
benchmark_return: FloatDecimal | None = None
class ReturnsResponse(BaseModel):
@ -162,6 +164,7 @@ class ReturnsResponse(BaseModel):
end_date: date | None = None
total_return: FloatDecimal | None = None
cagr: FloatDecimal | None = None
benchmark_total_return: FloatDecimal | None = None
data: List[ReturnDataPoint] = []
@ -227,3 +230,20 @@ class RebalanceCalculateResponse(BaseModel):
total_assets: FloatDecimal
available_to_buy: FloatDecimal | None = None
items: List[RebalanceCalculateItem]
# Rebalance apply schemas
class RebalanceApplyItem(BaseModel):
ticker: str
action: str # "buy" or "sell"
quantity: int = Field(..., gt=0)
price: FloatDecimal = Field(..., gt=0)
class RebalanceApplyRequest(BaseModel):
items: List[RebalanceApplyItem]
class RebalanceApplyResponse(BaseModel):
transactions: List[TransactionResponse]
holdings_updated: int

View File

@ -6,11 +6,17 @@ from decimal import Decimal
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.schemas.portfolio import FloatDecimal
class SignalExecuteRequest(BaseModel):
portfolio_id: int
quantity: int = Field(..., gt=0)
price: FloatDecimal = Field(..., gt=0)
class SignalType(str, Enum):
BUY = "buy"
SELL = "sell"
@ -35,6 +41,9 @@ class SignalResponse(BaseModel):
reason: Optional[str] = None
status: str
created_at: datetime
executed_price: Optional[FloatDecimal] = None
executed_quantity: Optional[int] = None
executed_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@ -111,3 +111,50 @@ def test_calculate_rebalance_without_prices_fallback(client: TestClient, auth_he
headers=auth_headers,
)
assert response.status_code == 200
def test_apply_rebalance(client: TestClient, auth_headers):
"""리밸런싱 결과를 적용하면 거래가 일괄 생성된다."""
pid = _setup_portfolio_with_holdings(client, auth_headers)
response = client.post(
f"/api/portfolios/{pid}/rebalance/apply",
json={
"items": [
{"ticker": "069500", "action": "buy", "quantity": 5, "price": 50000},
{"ticker": "148070", "action": "sell", "quantity": 2, "price": 110000},
]
},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert len(data["transactions"]) == 2
assert data["transactions"][0]["tx_type"] == "buy"
assert data["transactions"][1]["tx_type"] == "sell"
assert data["holdings_updated"] == 2
# Verify holdings were updated
holdings_resp = client.get(
f"/api/portfolios/{pid}/holdings",
headers=auth_headers,
)
holdings = {h["ticker"]: h for h in holdings_resp.json()}
assert holdings["069500"]["quantity"] == 15 # 10 + 5
assert holdings["148070"]["quantity"] == 3 # 5 - 2
def test_apply_rebalance_insufficient_quantity(client: TestClient, auth_headers):
"""매도 수량이 보유량을 초과하면 400 에러."""
pid = _setup_portfolio_with_holdings(client, auth_headers)
response = client.post(
f"/api/portfolios/{pid}/rebalance/apply",
json={
"items": [
{"ticker": "148070", "action": "sell", "quantity": 10, "price": 110000},
]
},
headers=auth_headers,
)
assert response.status_code == 400

View File

@ -0,0 +1,147 @@
# DC형 퇴직연금 퀀트 포트폴리오 관리 시나리오 기반 갭 분석
## 목적
현재 구현된 Galaxy-PO 시스템이 개인 퀀트 투자자의 DC형 퇴직연금 + 일반 포트폴리오 관리 시나리오를 완전히 지원하는지 검증한다. 전문가 팀이 실제 사용 시나리오를 단계별로 분해하고, 각 단계에서 필요한 기능의 구현 여부를 판정하여 Gap을 식별한다.
## 대상 사용자
- 개인 퀀트 투자자 (본인)
- DC형 확정기여형 퇴직연금 운용
- 퀀트 전략 기반 종목 선정 + 체계적 리밸런싱
## 전문가 팀 구성
| 역할 | ID | 담당 영역 | 핵심 관점 |
|------|-----|----------|-----------|
| 퀀트 전략가 | Q | 전략 선택, 백테스트, KJB 신호 | 전략의 통계적 유효성, 파라미터 민감도 |
| 포트폴리오 매니저 | PM | 포트폴리오 구성, 리밸런싱, 거래 기록 | 자산 배분의 합리성, 실행 효율 |
| DC형 퇴직연금 전문가 | P | DC형 규제, 세제, 상품 범위 | 위험자산 70% 제한 등 규제 준수 |
| 시스템 엔지니어 | SE | 데이터 수집, API 안정성, 에러 처리 | 데이터 정확성, 시스템 안정성 |
| Devil's Advocate | DA | 전 시나리오 비판적 검토 | 약점, 리스크, 누락된 엣지 케이스 |
## 핵심 시나리오
### 시나리오 1: 초기 설정 (온보딩)
DC형 퇴직연금 포트폴리오를 처음 만들고 기존 보유 종목을 입력하는 과정.
| 단계 | 설명 | 필요 API/UI | 구현 여부 |
|------|------|-------------|-----------|
| 1.1 | 로그인 | POST /api/auth/login, /login 페이지 | |
| 1.2 | 새 포트폴리오 생성 (유형: pension) | POST /api/portfolios, /portfolio/new 페이지 | |
| 1.3 | 목표 배분 설정 (ETF/종목 비중) | PUT /api/portfolios/{id}/targets | |
| 1.4 | 현재 보유 종목 입력 | PUT /api/portfolios/{id}/holdings | |
| 1.5 | 현재 상태 확인 (평가금액 등) | GET /api/portfolios/{id}/detail | |
**전문가별 검토 포인트:**
- PM: 보유 종목 일괄 입력 UX, 평균단가 입력 방식
- P: pension 유형에 대한 위험자산 비율 제한 경고 기능
- DA: 기존 보유 종목의 매수 이력이 없으면 수익률 계산이 부정확
### 시나리오 2: 전략 탐색 및 종목 선정
멀티팩터/퀄리티/밸류모멘텀 전략을 실행하여 투자 후보 종목을 선정.
| 단계 | 설명 | 필요 API/UI | 구현 여부 |
|------|------|-------------|-----------|
| 2.1 | 전략 페이지 진입 | /strategy 페이지 | |
| 2.2 | 멀티팩터 전략 실행 | POST /api/strategy/multi-factor | |
| 2.3 | 결과 확인 (추천 종목) | 전략 결과 UI | |
| 2.4 | 다른 전략과 비교 | 전략 결과 비교 UI | |
| 2.5 | 종목 상세 정보 확인 | GET /api/market/stocks/{ticker} | |
| 2.6 | 전략 결과를 포트폴리오 목표에 반영 | 전략 → 포트폴리오 연결 | |
**전문가별 검토 포인트:**
- Q: 전략 결과의 팩터 점수 투명성, 유니버스 필터 설정
- P: DC형에 투자 가능한 종목/ETF만 필터링하는 기능
- DA: 전략 결과를 포트폴리오에 반영하는 연결 흐름이 있는지
### 시나리오 3: 백테스트 검증
선정한 전략을 과거 데이터로 검증.
| 단계 | 설명 | 필요 API/UI | 구현 여부 |
|------|------|-------------|-----------|
| 3.1 | 백테스트 생성 | POST /api/backtest, /backtest 페이지 | |
| 3.2 | 백테스트 실행 및 대기 | 백그라운드 워커, 상태 폴링 | |
| 3.3 | 성과 지표 확인 | GET /api/backtest/{id} (metrics) | |
| 3.4 | 에쿼티 커브 분석 | GET /api/backtest/{id}/equity-curve | |
| 3.5 | 벤치마크 대비 비교 | 벤치마크 수익률 포함 여부 | |
| 3.6 | 리밸런싱 시점별 종목 확인 | GET /api/backtest/{id}/holdings | |
| 3.7 | 거래 내역 확인 | GET /api/backtest/{id}/transactions | |
**전문가별 검토 포인트:**
- Q: 수수료/슬리피지 반영, 리밸런싱 주기 옵션
- SE: 대량 데이터 처리 시 타임아웃, 백테스트 실패 시 에러 처리
- DA: 백테스트 결과가 과적합(overfitting)인지 판단할 수 있는 정보가 부족하지 않은지
### 시나리오 4: 리밸런싱 실행
분기마다 포트폴리오를 목표 비중으로 리밸런싱.
| 단계 | 설명 | 필요 API/UI | 구현 여부 |
|------|------|-------------|-----------|
| 4.1 | 포트폴리오 상세 진입 | /portfolio/[id] 페이지 | |
| 4.2 | 리밸런싱 계산 요청 | GET /api/portfolios/{id}/rebalance | |
| 4.3 | 매수/매도 주문 확인 | 리밸런싱 결과 UI | |
| 4.4 | 추가 투자금 시뮬레이션 | POST /api/portfolios/{id}/rebalance/simulate | |
| 4.5 | 수동 가격 입력으로 재계산 | POST /api/portfolios/{id}/rebalance/calculate | |
| 4.6 | 거래 기록 입력 | POST /api/portfolios/{id}/transactions | |
| 4.7 | 리밸런싱 후 상태 확인 | GET /api/portfolios/{id}/detail | |
**전문가별 검토 포인트:**
- PM: 리밸런싱 결과에서 바로 거래를 실행하는 흐름, 최소 거래 금액 고려
- P: 리밸런싱 후 위험자산 비율 초과 여부 경고
- DA: 리밸런싱 계산과 실제 체결 사이의 가격 변동 리스크
### 시나리오 5: KJB 신호 기반 단기 매매
KJB 매매 신호를 확인하고 실제 포트폴리오에 반영.
| 단계 | 설명 | 필요 API/UI | 구현 여부 |
|------|------|-------------|-----------|
| 5.1 | 오늘의 신호 확인 | GET /api/signal/kjb/today, /signals 페이지 | |
| 5.2 | 매수 신호 실행 모달 | 실행 버튼 + 모달 UI | |
| 5.3 | 포트폴리오 선택, 수량/가격 입력 | 모달 내 폼 | |
| 5.4 | 실행 → 포트폴리오 반영 | POST /api/signal/{id}/execute | |
| 5.5 | 매도 신호 시 동일 과정 | 매도 실행 + 보유 수량 검증 | |
| 5.6 | 신호 이력 조회 | GET /api/signal/kjb/history | |
| 5.7 | 실행된 신호의 성과 추적 | 신호별 PnL 확인 | |
**전문가별 검토 포인트:**
- Q: 신호 생성 로직의 투명성, 신호 정확도 추적
- PM: 포지션 사이징, 최대 포지션 수 제한
- DA: 신호 실행 후 성과를 어떻게 추적하는지, 신호 실행 취소/수정 기능
### 시나리오 6: 성과 추적 및 모니터링
월간/분기 단위로 포트폴리오 성과를 확인하고 기록.
| 단계 | 설명 | 필요 API/UI | 구현 여부 |
|------|------|-------------|-----------|
| 6.1 | 포트폴리오 스냅샷 생성 | POST /api/portfolios/{id}/snapshots | |
| 6.2 | 수익률 추이 확인 | GET /api/portfolios/{id}/returns | |
| 6.3 | 거래 내역 조회 | GET /api/portfolios/{id}/transactions | |
| 6.4 | 종목/ETF 가격 확인 | 데이터 탐색기 /admin/data/explorer | |
| 6.5 | 전체 자산 현황 파악 | 대시보드 또는 포트폴리오 목록 | |
| 6.6 | 기간별 성과 비교 | 스냅샷 간 비교 | |
**전문가별 검토 포인트:**
- PM: 수익률 계산 방식 (TWR vs MWR), 벤치마크 대비 표시
- SE: 스냅샷 자동 생성 안정성, 가격 데이터 누락 시 처리
- DA: 실현 수익 vs 평가 수익 구분, 세금/수수료 포함 여부
## 산출물
1. **시나리오별 기능 매핑표**: 각 단계의 구현 여부 (구현됨/미구현/부분구현)
2. **Gap 목록**: 우선순위 (Critical / Important / Nice-to-have)
3. **전문가별 의견 정리**: 각 전문가의 핵심 지적 사항
4. **개선 제안서**: Gap을 해소하기 위한 구현 계획
## 평가 범위
- 기능 존재 여부 (API + UI)
- UX 흐름의 자연스러움 (페이지 간 연결)
- 에러 처리 및 안정성
- 데이터 정합성 (계산 정확성)

View File

@ -0,0 +1,395 @@
# 시나리오 갭 분석 결과 및 구현 계획
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** DC형 퇴직연금 퀀트 포트폴리오 관리의 6대 시나리오에서 발견된 Gap을 해소하여 end-to-end 사용 흐름을 완성한다.
**Architecture:** 기존 FastAPI + Next.js 구조를 유지하면서, 누락된 API 엔드포인트와 프론트엔드 UI를 추가한다. 백엔드 변경은 기존 모델/서비스를 확장하고, 프론트엔드는 기존 컴포넌트 패턴을 따른다.
**Tech Stack:** FastAPI, SQLAlchemy, Next.js 15, React 19, TypeScript, Radix UI, Recharts
---
## 전문가 팀 갭 분석 결과
### 종합 매핑표
| # | 요구사항 | 상태 | 우선순위 |
|---|---------|------|----------|
| 1.1 | 퇴직연금 포트폴리오 생성 | IMPLEMENTED | - |
| 1.2 | DC형 위험자산 70% 제한 경고 | MISSING | Important |
| 1.3 | 보유종목 일괄 입력 UI | PARTIAL (API만 존재) | Important |
| 1.4 | 실시간 평가금액 표시 | IMPLEMENTED | - |
| 2.1 | 전략 결과 → 포트폴리오 목표 연결 | MISSING | Critical |
| 2.2 | 팩터 점수/투명성 | IMPLEMENTED | - |
| 2.3 | DC 투자가능 종목 필터 | MISSING | Nice-to-have |
| 2.4 | 전략 결과 비교 UI | PARTIAL | Nice-to-have |
| 3.1 | 전 전략 백테스트 지원 | IMPLEMENTED | - |
| 3.2 | Out-of-sample/Walk-forward | MISSING | Nice-to-have |
| 3.3 | 백테스트 에러 처리 | PARTIAL | Nice-to-have |
| 4.1 | 리밸런싱 결과 → 일괄 거래 생성 | MISSING | Critical |
| 4.2 | 최소 거래 금액 고려 | MISSING | Nice-to-have |
| 4.3 | 리밸런싱 후 위험자산 비율 체크 | MISSING | Important |
| 5.1 | 신호별 PnL 추적 | MISSING | Important |
| 5.2 | 실행된 신호 취소/수정 | MISSING | Nice-to-have |
| 5.3 | 실행 모달에 현재 보유량 표시 | MISSING | Important |
| 5.4 | 포지션 사이징 가이드 | PARTIAL | Nice-to-have |
| 6.1 | TWR 수익률 계산 | IMPLEMENTED | - |
| 6.2 | 포트폴리오 벤치마크 비교 | PARTIAL (백테스트만) | Important |
| 6.3 | 스냅샷 데이터 충분성 | IMPLEMENTED | - |
| 6.4 | 실현/미실현 수익 구분 | MISSING | Important |
| 6.5 | 대시보드 전체 현황 | IMPLEMENTED | - |
### 전문가별 핵심 의견
**Q (퀀트 전략가):**
- 전략 실행 → 포트폴리오 반영 연결이 완전히 끊겨 있음. 전략을 실행해도 결과를 수동으로 옮겨야 하므로 시스템의 핵심 가치가 반감됨.
- 신호 기반 매매의 성과를 추적할 수 없어 전략 개선이 불가능함.
**PM (포트폴리오 매니저):**
- 리밸런싱 계산 후 "적용" 기능이 없어, 계산 결과를 보고 다시 수동으로 거래를 하나씩 입력해야 함. 핵심 워크플로우의 마지막 단계가 빠져있음.
- 실현/미실현 수익 구분이 없으면 세금 신고나 성과 분석이 부정확함.
**P (DC형 퇴직연금 전문가):**
- DC형 퇴직연금의 위험자산 70% 제한은 법적 요건. 경고 없이 운용하면 퇴직연금사업자의 제재 대상이 될 수 있음. 최소한 경고 수준은 필수.
- 단, 이 시스템은 개인용이므로 제재라기보다 자기 관리 차원의 알림이면 충분.
**SE (시스템 엔지니어):**
- 데이터 파이프라인과 백테스트 엔진은 안정적으로 구현됨.
- 신호 실행 시 동시성 문제 (같은 신호 중복 실행) 검토 필요하나, 단일 사용자 시스템이므로 당장 Critical은 아님.
**DA (Devil's Advocate):**
- "전략 → 포트폴리오 → 리밸런싱 → 성과 추적"의 핵심 루프가 중간중간 끊겨 있음. 각 기능이 개별적으로는 존재하지만 **연결 흐름이 없으면 Excel 스프레드시트보다 나을 게 없다.**
- 특히 리밸런싱 결과 적용과 전략→목표 연결은 이 시스템의 존재 이유. 이것 없이는 단순 조회 도구에 불과.
- 신호 실행 취소가 없으면 실수 한 번에 데이터 정합성이 깨질 수 있음. 최소한 거래 삭제 기능은 필요.
- 벤치마크 비교가 백테스트에만 있고 실제 포트폴리오에 없는 것은 "과거는 분석하면서 현재는 분석 못하는" 아이러니.
---
## 구현 우선순위
DA의 의견을 반영하여, **"핵심 루프 완성"**에 집중합니다.
### Phase 1: Critical - 핵심 루프 연결 (Task 1-2)
### Phase 2: Important - 실용성 강화 (Task 3-6)
### Phase 3: Nice-to-have - 고도화 (별도 계획)
---
## Task 1: 리밸런싱 결과 일괄 적용 API + UI
리밸런싱 계산 결과에서 "적용" 버튼을 누르면 매수/매도 거래가 일괄 생성되는 기능.
**Files:**
- Modify: `backend/app/api/portfolio.py` (리밸런싱 적용 엔드포인트 추가)
- Modify: `backend/app/schemas/portfolio.py` (적용 요청/응답 스키마)
- Modify: `frontend/src/app/portfolio/[id]/rebalance/page.tsx` (적용 버튼 + 확인 모달)
- Test: `backend/tests/e2e/test_rebalance_flow.py`
**Step 1: Write the failing test**
```python
# backend/tests/e2e/test_rebalance_flow.py - 기존 파일에 추가
def test_apply_rebalance(client, auth_headers, db_session):
"""리밸런싱 결과를 적용하면 거래가 일괄 생성된다."""
# Setup: portfolio with targets and holdings
# ...
response = client.post(
f"/api/portfolios/{portfolio_id}/rebalance/apply",
json={
"items": [
{"ticker": "005930", "action": "buy", "quantity": 10, "price": 70000},
{"ticker": "000660", "action": "sell", "quantity": 5, "price": 150000},
]
},
headers=auth_headers,
)
assert response.status_code == 201
data = response.json()
assert len(data["transactions"]) == 2
assert data["transactions"][0]["tx_type"] == "buy"
assert data["transactions"][1]["tx_type"] == "sell"
```
**Step 2: Run test to verify it fails**
Run: `backend/.venv/bin/pytest backend/tests/e2e/test_rebalance_flow.py::test_apply_rebalance -v`
Expected: FAIL (endpoint does not exist)
**Step 3: Add backend schema**
```python
# backend/app/schemas/portfolio.py에 추가
class RebalanceApplyItem(BaseModel):
ticker: str
action: str # "buy" or "sell"
quantity: int = Field(..., gt=0)
price: FloatDecimal = Field(..., gt=0)
class RebalanceApplyRequest(BaseModel):
items: List[RebalanceApplyItem]
class RebalanceApplyResponse(BaseModel):
transactions: List[TransactionResponse]
holdings_updated: int
```
**Step 4: Add backend endpoint**
```python
# backend/app/api/portfolio.py에 추가
@router.post("/{portfolio_id}/rebalance/apply", response_model=RebalanceApplyResponse, status_code=status.HTTP_201_CREATED)
async def apply_rebalance(
portfolio_id: int,
data: RebalanceApplyRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""리밸런싱 결과를 적용하여 거래를 일괄 생성한다."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
transactions = []
for item in data.items:
tx_type = TransactionType(item.action)
transaction = Transaction(
portfolio_id=portfolio_id,
ticker=item.ticker,
tx_type=tx_type,
quantity=item.quantity,
price=item.price,
executed_at=datetime.utcnow(),
memo="리밸런싱 적용",
)
db.add(transaction)
# Update holding (기존 add_transaction 로직 재사용)
holding = db.query(Holding).filter(
Holding.portfolio_id == portfolio_id,
Holding.ticker == item.ticker,
).first()
if tx_type == TransactionType.BUY:
if holding:
total_value = (holding.quantity * holding.avg_price) + (item.quantity * item.price)
new_quantity = holding.quantity + item.quantity
holding.quantity = new_quantity
holding.avg_price = total_value / new_quantity if new_quantity > 0 else 0
else:
holding = Holding(
portfolio_id=portfolio_id,
ticker=item.ticker,
quantity=item.quantity,
avg_price=item.price,
)
db.add(holding)
elif tx_type == TransactionType.SELL:
if not holding or holding.quantity < item.quantity:
raise HTTPException(status_code=400, detail=f"Insufficient quantity for {item.ticker}")
holding.quantity -= item.quantity
if holding.quantity == 0:
db.delete(holding)
transactions.append(transaction)
db.commit()
for tx in transactions:
db.refresh(tx)
return RebalanceApplyResponse(
transactions=transactions,
holdings_updated=len(transactions),
)
```
**Step 5: Run test to verify it passes**
Run: `backend/.venv/bin/pytest backend/tests/e2e/test_rebalance_flow.py::test_apply_rebalance -v`
Expected: PASS
**Step 6: Add frontend "적용" 버튼 + 확인 모달**
`frontend/src/app/portfolio/[id]/rebalance/page.tsx`에 추가:
- 리밸런싱 계산 결과 테이블 아래에 "리밸런싱 적용" 버튼
- 클릭 시 확인 모달 표시 (매수/매도 요약)
- 사용자가 각 항목의 실제 체결가를 수정 가능
- "적용" 시 POST /api/portfolios/{id}/rebalance/apply 호출
- 성공 시 포트폴리오 상세 페이지로 이동
**Step 7: Commit**
```bash
git add backend/app/api/portfolio.py backend/app/schemas/portfolio.py \
frontend/src/app/portfolio/\[id\]/rebalance/page.tsx \
backend/tests/e2e/test_rebalance_flow.py
git commit -m "feat: add bulk rebalance apply endpoint and UI"
```
---
## Task 2: 전략 결과 → 포트폴리오 목표 연결
전략 실행 결과에서 "포트폴리오에 적용" 버튼으로 목표 배분을 설정하는 기능.
**Files:**
- Modify: `frontend/src/app/strategy/multi-factor/page.tsx` (적용 버튼 추가)
- Modify: `frontend/src/app/strategy/quality/page.tsx` (적용 버튼 추가)
- Modify: `frontend/src/app/strategy/value-momentum/page.tsx` (적용 버튼 추가)
- Test: 프론트엔드 E2E 또는 수동 테스트
**Step 1: 전략 결과 페이지에 "포트폴리오에 적용" 버튼 추가**
각 전략 결과 페이지에:
- 포트폴리오 선택 드롭다운 (GET /api/portfolios)
- "목표 배분으로 적용" 버튼
- 클릭 시: 전략 결과의 종목 + 동일 비중을 PUT /api/portfolios/{id}/targets로 전송
- 기존 목표를 덮어쓸지 확인 모달
**Step 2: 공통 컴포넌트 생성**
```tsx
// frontend/src/components/strategy/apply-to-portfolio.tsx
// 포트폴리오 선택 + 적용 버튼 + 확인 모달
// 3개 전략 페이지에서 공유
```
**Step 3: 각 전략 페이지에 컴포넌트 배치**
**Step 4: 수동 테스트**
전략 실행 → 결과에서 포트폴리오 선택 → 적용 → 포트폴리오 목표 확인
**Step 5: Commit**
```bash
git add frontend/src/components/strategy/apply-to-portfolio.tsx \
frontend/src/app/strategy/multi-factor/page.tsx \
frontend/src/app/strategy/quality/page.tsx \
frontend/src/app/strategy/value-momentum/page.tsx
git commit -m "feat: add strategy results to portfolio targets flow"
```
---
## Task 3: 신호 실행 모달에 현재 보유량 표시
**Files:**
- Modify: `frontend/src/app/signals/page.tsx`
**Step 1: 실행 모달 열 때 선택된 포트폴리오의 보유 종목 조회**
포트폴리오 선택 시 GET /api/portfolios/{id}/holdings 호출하여 해당 종목의 현재 보유 수량을 표시.
```tsx
// 모달 내 신호 정보 섹션에 추가:
// "현재 보유: {quantity}주 (평균단가: {avg_price}원)" 또는 "미보유"
```
**Step 2: 매도 신호일 때 보유 수량 기반 기본값 설정**
매도/부분매도 신호의 경우, 수량 기본값을 현재 보유량(또는 보유량의 50%)으로 설정.
**Step 3: Commit**
```bash
git add frontend/src/app/signals/page.tsx
git commit -m "feat: show current holdings in signal execute modal"
```
---
## Task 4: DC형 위험자산 비율 경고
**Files:**
- Modify: `backend/app/api/portfolio.py` (비율 계산 유틸)
- Modify: `frontend/src/app/portfolio/[id]/page.tsx` (경고 배너)
**Step 1: 백엔드에 위험자산 비율 계산 추가**
포트폴리오 상세 응답에 `risk_asset_ratio` 필드 추가. ETF 중 채권형/MMF를 안전자산, 나머지를 위험자산으로 분류. pension 유형일 때만 계산.
**Step 2: 프론트엔드에 경고 배너 표시**
pension 유형 포트폴리오에서 위험자산 비율이 70%를 초과하면 상단에 경고 배너 표시.
**Step 3: Commit**
```bash
git commit -m "feat: add risk asset ratio warning for DC pension portfolios"
```
---
## Task 5: 포트폴리오 벤치마크 비교
**Files:**
- Modify: `backend/app/api/snapshot.py` (returns 엔드포인트에 벤치마크 추가)
- Modify: `backend/app/services/returns_calculator.py` (벤치마크 데이터 조회)
- Modify: `frontend/src/app/portfolio/[id]/history/page.tsx` (벤치마크 차트)
**Step 1: returns API에 벤치마크 수익률 추가**
GET /api/portfolios/{id}/returns 응답에 벤치마크(KOSPI) 누적수익률을 함께 반환.
**Step 2: 프론트엔드 수익률 차트에 벤치마크 라인 추가**
기존 포트폴리오 수익률 라인 차트에 KOSPI 수익률 라인을 오버레이.
**Step 3: Commit**
```bash
git commit -m "feat: add benchmark comparison to portfolio returns"
```
---
## Task 6: 신호 성과 추적 (Signal PnL)
**Files:**
- Modify: `backend/app/models/signal.py` (executed_price, exit_price, pnl 필드 추가)
- Create: Alembic migration
- Modify: `backend/app/api/signal.py` (신호 성과 조회 엔드포인트)
- Modify: `backend/app/schemas/signal.py` (성과 관련 필드)
- Modify: `frontend/src/app/signals/page.tsx` (성과 컬럼 추가)
**Step 1: Signal 모델에 실행 관련 필드 추가**
```python
# backend/app/models/signal.py에 추가
executed_price = Column(Numeric(12, 2)) # 실제 체결가
executed_quantity = Column(Integer) # 실행 수량
executed_at = Column(DateTime) # 실행 시점
```
**Step 2: 신호 실행 시 필드 업데이트**
POST /api/signal/{id}/execute에서 executed_price, executed_quantity, executed_at 저장.
**Step 3: 신호 응답에 성과 필드 포함**
SignalResponse에 executed_price, executed_quantity를 추가하여 프론트엔드에서 표시.
**Step 4: 프론트엔드 신호 테이블에 체결가/수량 컬럼 추가**
executed 상태 신호에 체결가, 수량 표시.
**Step 5: Commit**
```bash
git commit -m "feat: track signal execution details for PnL analysis"
```
---
## Phase 3: Nice-to-have (별도 계획 필요)
아래 항목은 핵심 루프 완성 후 별도 계획으로 진행:
| 항목 | 설명 |
|------|------|
| 전략 결과 비교 UI | 멀티팩터/퀄리티/밸류모멘텀 결과를 나란히 비교 |
| DC 투자가능 종목 필터 | ETF-only 필터, DC 적격 종목만 표시 |
| Walk-forward 분석 | 백테스트 기간 분할 검증 |
| 최소 거래 단위 | 리밸런싱 시 최소 거래 금액 고려 |
| 신호 실행 취소 | 실행된 신호 되돌리기 (거래 삭제 포함) |
| 실현/미실현 수익 분리 | 매도 시 실현 수익 계산 및 별도 추적 |
| 포지션 사이징 가이드 | 신호 실행 시 추천 수량/최대 포지션 표시 |

View File

@ -35,6 +35,7 @@ interface ReturnDataPoint {
total_value: string;
daily_return: string | null;
cumulative_return: string | null;
benchmark_return: string | null;
}
interface ReturnsData {
@ -43,6 +44,7 @@ interface ReturnsData {
end_date: string | null;
total_return: string | null;
cagr: string | null;
benchmark_total_return: string | null;
data: ReturnDataPoint[];
}
@ -169,7 +171,7 @@ export default function PortfolioHistoryPage() {
{/* Summary Cards */}
{returns && returns.total_return !== null && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"> </div>
@ -198,6 +200,22 @@ export default function PortfolioHistoryPage() {
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"> (KOSPI)</div>
<div
className={`text-2xl font-bold ${
parseFloat(returns.benchmark_total_return || '0') >= 0
? 'text-green-600'
: 'text-red-600'
}`}
>
{returns.benchmark_total_return !== null
? formatPercent(returns.benchmark_total_return)
: '-'}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground"></div>
@ -329,6 +347,9 @@ export default function PortfolioHistoryPage() {
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
(KOSPI)
</th>
</tr>
</thead>
<tbody className="bg-background divide-y divide-border">
@ -358,6 +379,15 @@ export default function PortfolioHistoryPage() {
>
{formatPercent(point.cumulative_return)}
</td>
<td
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
parseFloat(point.benchmark_return || '0') >= 0
? 'text-green-600'
: 'text-red-600'
}`}
>
{formatPercent(point.benchmark_return)}
</td>
</tr>
))}
</tbody>

View File

@ -51,6 +51,7 @@ interface PortfolioDetail {
total_value: number | null;
total_invested: number | null;
total_profit_loss: number | null;
risk_asset_ratio: number | null;
}
const CHART_COLORS = [
@ -219,6 +220,22 @@ export default function PortfolioDetailPage() {
</Button>
</div>
{/* DC Risk Asset Warning */}
{portfolio.portfolio_type === 'pension' &&
portfolio.risk_asset_ratio !== null &&
portfolio.risk_asset_ratio > 70 && (
<div className="bg-amber-50 border border-amber-300 text-amber-800 dark:bg-amber-950 dark:border-amber-700 dark:text-amber-200 px-4 py-3 rounded mb-4 flex items-start gap-2">
<span className="text-lg">&#9888;</span>
<div>
<p className="font-medium">DC형 </p>
<p className="text-sm mt-1">
: <strong>{portfolio.risk_asset_ratio.toFixed(1)}%</strong> ( 한도: 70%).
/ ETF .
</p>
</div>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card>

View File

@ -60,6 +60,10 @@ export default function RebalancePage() {
const [calculating, setCalculating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nameMap, setNameMap] = useState<Record<string, string>>({});
const [showApplyModal, setShowApplyModal] = useState(false);
const [applyPrices, setApplyPrices] = useState<Record<string, string>>({});
const [applying, setApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
@ -175,6 +179,43 @@ export default function RebalancePage() {
const getHoldingQty = (ticker: string) =>
holdings.find((h) => h.ticker === ticker)?.quantity ?? 0;
const openApplyModal = () => {
if (!result) return;
const initialPrices: Record<string, string> = {};
for (const item of result.items) {
if (item.action !== 'hold' && item.diff_quantity !== 0) {
initialPrices[item.ticker] = String(item.current_price);
}
}
setApplyPrices(initialPrices);
setApplyError(null);
setShowApplyModal(true);
};
const applyRebalance = async () => {
if (!result) return;
setApplying(true);
setApplyError(null);
try {
const items = result.items
.filter((item) => item.action !== 'hold' && item.diff_quantity !== 0)
.map((item) => ({
ticker: item.ticker,
action: item.action,
quantity: Math.abs(item.diff_quantity),
price: parseFloat(applyPrices[item.ticker] || String(item.current_price)),
}));
await api.post(`/api/portfolios/${portfolioId}/rebalance/apply`, { items });
setShowApplyModal(false);
router.push(`/portfolio/${portfolioId}`);
} catch (err) {
setApplyError(err instanceof Error ? err.message : '적용 실패');
} finally {
setApplying(false);
}
};
if (loading) return null;
return (
@ -386,8 +427,72 @@ export default function RebalancePage() {
</div>
</CardContent>
</Card>
{/* 적용 버튼 */}
{result.items.some((item) => item.action !== 'hold' && item.diff_quantity !== 0) && (
<div className="mt-4 flex justify-end">
<Button onClick={openApplyModal} size="lg">
</Button>
</div>
)}
</>
)}
{/* 적용 확인 모달 */}
{showApplyModal && result && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg shadow-lg max-w-lg w-full mx-4 max-h-[80vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-lg font-bold mb-4"> </h2>
<p className="text-sm text-muted-foreground mb-4">
. .
</p>
{applyError && (
<div className="bg-destructive/10 border border-destructive text-destructive px-3 py-2 rounded mb-4 text-sm">
{applyError}
</div>
)}
<div className="space-y-3">
{result.items
.filter((item) => item.action !== 'hold' && item.diff_quantity !== 0)
.map((item) => (
<div key={item.ticker} className="flex items-center gap-3 p-3 border rounded">
<div className="flex-1">
<div className="font-medium">{item.name || item.ticker}</div>
<div className="text-sm text-muted-foreground">
{getActionBadge(item.action)} {Math.abs(item.diff_quantity)}
</div>
</div>
<div className="w-32">
<Label htmlFor={`apply-price-${item.ticker}`} className="text-xs"></Label>
<Input
id={`apply-price-${item.ticker}`}
type="number"
value={applyPrices[item.ticker] || ''}
onChange={(e) =>
setApplyPrices((prev) => ({ ...prev, [item.ticker]: e.target.value }))
}
/>
</div>
</div>
))}
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="outline" onClick={() => setShowApplyModal(false)}>
</Button>
<Button onClick={applyRebalance} disabled={applying}>
{applying ? '적용 중...' : '적용'}
</Button>
</div>
</div>
</div>
</div>
)}
</DashboardLayout>
);
}

View File

@ -9,8 +9,23 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { api } from '@/lib/api';
import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle } from 'lucide-react';
import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle, Play } from 'lucide-react';
interface Signal {
id: number;
@ -24,6 +39,20 @@ interface Signal {
reason: string | null;
status: string;
created_at: string;
executed_price: number | null;
executed_quantity: number | null;
executed_at: string | null;
}
interface Portfolio {
id: number;
name: string;
}
interface Holding {
ticker: string;
quantity: number;
avg_price: number;
}
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
@ -77,6 +106,17 @@ export default function SignalsPage() {
const [filterStartDate, setFilterStartDate] = useState('');
const [filterEndDate, setFilterEndDate] = useState('');
// Execute modal state
const [executeModalOpen, setExecuteModalOpen] = useState(false);
const [executeSignal, setExecuteSignal] = useState<Signal | null>(null);
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
const [selectedPortfolioId, setSelectedPortfolioId] = useState('');
const [executeQuantity, setExecuteQuantity] = useState('');
const [executePrice, setExecutePrice] = useState('');
const [executing, setExecuting] = useState(false);
const [executeError, setExecuteError] = useState('');
const [currentHoldings, setCurrentHoldings] = useState<Holding[]>([]);
useEffect(() => {
const init = async () => {
try {
@ -115,6 +155,15 @@ export default function SignalsPage() {
}
};
const fetchPortfolios = async () => {
try {
const data = await api.get<Portfolio[]>('/api/portfolios');
setPortfolios(data);
} catch (err) {
console.error('Failed to fetch portfolios:', err);
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
@ -140,6 +189,82 @@ export default function SignalsPage() {
await fetchHistorySignals();
};
const handleOpenExecuteModal = async (signal: Signal) => {
setExecuteSignal(signal);
setExecutePrice(signal.entry_price?.toString() || '');
setExecuteQuantity('');
setSelectedPortfolioId('');
setExecuteError('');
setCurrentHoldings([]);
await fetchPortfolios();
setExecuteModalOpen(true);
};
const handlePortfolioChange = async (portfolioId: string) => {
setSelectedPortfolioId(portfolioId);
if (portfolioId) {
try {
const holdings = await api.get<Holding[]>(`/api/portfolios/${portfolioId}/holdings`);
setCurrentHoldings(holdings);
// 매도/부분매도 신호일 때 보유량 기반 기본값 설정
if (executeSignal) {
const holding = holdings.find((h) => h.ticker === executeSignal.ticker);
if (holding && (executeSignal.signal_type === 'sell' || executeSignal.signal_type === 'partial_sell')) {
const defaultQty = executeSignal.signal_type === 'sell'
? holding.quantity
: Math.floor(holding.quantity / 2);
setExecuteQuantity(String(defaultQty));
}
}
} catch {
setCurrentHoldings([]);
}
} else {
setCurrentHoldings([]);
}
};
const handleExecute = async () => {
if (!executeSignal || !selectedPortfolioId || !executeQuantity || !executePrice) {
setExecuteError('모든 필드를 입력해주세요.');
return;
}
const quantity = parseInt(executeQuantity);
const price = parseFloat(executePrice);
if (isNaN(quantity) || quantity <= 0) {
setExecuteError('수량은 0보다 커야 합니다.');
return;
}
if (isNaN(price) || price <= 0) {
setExecuteError('가격은 0보다 커야 합니다.');
return;
}
setExecuting(true);
setExecuteError('');
try {
await api.post(`/api/signal/${executeSignal.id}/execute`, {
portfolio_id: parseInt(selectedPortfolioId),
quantity,
price,
});
setExecuteModalOpen(false);
// Refresh signals list
if (showHistory) {
await fetchHistorySignals();
} else {
await fetchTodaySignals();
}
} catch (err) {
setExecuteError(err instanceof Error ? err.message : '실행에 실패했습니다.');
} finally {
setExecuting(false);
}
};
const renderSignalTable = (signals: Signal[]) => (
<div className="overflow-x-auto">
<table className="w-full">
@ -153,7 +278,10 @@ export default function SignalsPage() {
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@ -186,15 +314,33 @@ export default function SignalsPage() {
<td className="px-4 py-3 text-sm max-w-xs truncate" title={signal.reason || ''}>
{signal.reason || '-'}
</td>
<td className="px-4 py-3 text-sm text-right font-mono">
{signal.executed_price ? formatPrice(signal.executed_price) : '-'}
</td>
<td className="px-4 py-3 text-sm text-right">
{signal.executed_quantity ? signal.executed_quantity.toLocaleString() : '-'}
</td>
<td className="px-4 py-3 text-center">
<Badge className={statConf.style}>{statConf.label}</Badge>
</td>
<td className="px-4 py-3 text-center">
{signal.status === 'active' && (
<Button
variant="outline"
size="sm"
onClick={() => handleOpenExecuteModal(signal)}
>
<Play className="h-3 w-3 mr-1" />
</Button>
)}
</td>
</tr>
);
})}
{signals.length === 0 && (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
<td colSpan={12} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>
@ -355,6 +501,122 @@ export default function SignalsPage() {
</CardContent>
</Card>
)}
{/* Execute Signal Modal */}
<Dialog open={executeModalOpen} onOpenChange={setExecuteModalOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
{executeSignal && (
<div className="space-y-4">
{/* Signal info */}
<div className="rounded-md border p-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{executeSignal.name || executeSignal.ticker} ({executeSignal.ticker})</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<Badge className={signalTypeConfig[executeSignal.signal_type]?.style || 'bg-muted'}>
{signalTypeConfig[executeSignal.signal_type]?.label || executeSignal.signal_type}
</Badge>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> </span>
<span className="font-mono">{formatPrice(executeSignal.entry_price)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-mono text-green-600">{formatPrice(executeSignal.target_price)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-mono text-red-600">{formatPrice(executeSignal.stop_loss_price)}</span>
</div>
</div>
{/* Portfolio selection */}
<div className="space-y-2">
<Label></Label>
<Select value={selectedPortfolioId} onValueChange={handlePortfolioChange}>
<SelectTrigger>
<SelectValue placeholder="포트폴리오 선택" />
</SelectTrigger>
<SelectContent>
{portfolios.map((p) => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Current holdings info */}
{selectedPortfolioId && executeSignal && (() => {
const holding = currentHoldings.find((h) => h.ticker === executeSignal.ticker);
return (
<div className="rounded-md border p-3 text-sm">
<span className="text-muted-foreground"> : </span>
{holding ? (
<span className="font-medium">
{holding.quantity.toLocaleString()} (: {formatPrice(holding.avg_price)})
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
);
})()}
{/* Quantity */}
<div className="space-y-2">
<Label htmlFor="exec-quantity"> ()</Label>
<Input
id="exec-quantity"
type="number"
min="1"
value={executeQuantity}
onChange={(e) => setExecuteQuantity(e.target.value)}
placeholder="매매 수량 입력"
/>
</div>
{/* Price */}
<div className="space-y-2">
<Label htmlFor="exec-price"></Label>
<Input
id="exec-price"
type="number"
min="0"
step="any"
value={executePrice}
onChange={(e) => setExecutePrice(e.target.value)}
placeholder="실제 체결 가격 입력"
/>
</div>
{executeError && (
<p className="text-sm text-red-600">{executeError}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setExecuteModalOpen(false)} disabled={executing}>
</Button>
<Button onClick={handleExecute} disabled={executing}>
{executing ? '실행 중...' : '실행'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
);
}

View File

@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio';
interface StockFactor {
ticker: string;
@ -224,6 +225,9 @@ export default function MultiFactorPage() {
</table>
</div>
</CardContent>
<div className="px-4 pb-4">
<ApplyToPortfolio stocks={result.stocks.map((s) => ({ ticker: s.ticker, name: s.name }))} />
</div>
</Card>
)}
</DashboardLayout>

View File

@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio';
interface StockFactor {
ticker: string;
@ -196,6 +197,9 @@ export default function QualityStrategyPage() {
</table>
</div>
</CardContent>
<div className="px-4 pb-4">
<ApplyToPortfolio stocks={result.stocks.map((s) => ({ ticker: s.ticker, name: s.name }))} />
</div>
</Card>
)}
</DashboardLayout>

View File

@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { api } from '@/lib/api';
import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio';
interface StockFactor {
ticker: string;
@ -204,6 +205,9 @@ export default function ValueMomentumPage() {
</table>
</div>
</CardContent>
<div className="px-4 pb-4">
<ApplyToPortfolio stocks={result.stocks.map((s) => ({ ticker: s.ticker, name: s.name }))} />
</div>
</Card>
)}
</DashboardLayout>

View File

@ -0,0 +1,138 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';
interface Portfolio {
id: number;
name: string;
portfolio_type: string;
}
interface TargetItem {
ticker: string;
target_ratio: number;
}
interface ApplyToPortfolioProps {
stocks: { ticker: string; name: string }[];
}
export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) {
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
const load = async () => {
try {
const data = await api.get<Portfolio[]>('/api/portfolios');
setPortfolios(data);
if (data.length > 0) setSelectedId(data[0].id);
} catch {
// ignore
}
};
load();
}, []);
const apply = async () => {
if (!selectedId || stocks.length === 0) return;
setApplying(true);
setError(null);
try {
const ratio = parseFloat((100 / stocks.length).toFixed(2));
const targets: TargetItem[] = stocks.map((s, i) => ({
ticker: s.ticker,
target_ratio: i === stocks.length - 1
? parseFloat((100 - ratio * (stocks.length - 1)).toFixed(2))
: ratio,
}));
await api.put(`/api/portfolios/${selectedId}/targets`, targets);
setShowConfirm(false);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : '적용 실패');
} finally {
setApplying(false);
}
};
if (portfolios.length === 0) return null;
return (
<div className="mt-4">
<div className="flex items-end gap-3">
<div>
<Label htmlFor="portfolio-select"> </Label>
<select
id="portfolio-select"
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedId ?? ''}
onChange={(e) => setSelectedId(Number(e.target.value))}
>
{portfolios.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.portfolio_type === 'pension' ? '퇴직연금' : '일반'})
</option>
))}
</select>
</div>
<Button onClick={() => setShowConfirm(true)}>
</Button>
</div>
{success && (
<div className="mt-2 text-sm text-green-600 dark:text-green-400">
.
</div>
)}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg shadow-lg max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto">
<div className="p-6">
<h2 className="text-lg font-bold mb-2"> </h2>
<p className="text-sm text-muted-foreground mb-4">
.
{stocks.length} ({(100 / stocks.length).toFixed(2)}%) .
</p>
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-3 py-2 rounded mb-4 text-sm">
{error}
</div>
)}
<div className="max-h-48 overflow-y-auto mb-4 border rounded p-2">
{stocks.map((s) => (
<div key={s.ticker} className="text-sm py-1 flex justify-between">
<span>{s.name || s.ticker}</span>
<span className="text-muted-foreground">{(100 / stocks.length).toFixed(2)}%</span>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowConfirm(false)}>
</Button>
<Button onClick={apply} disabled={applying}>
{applying ? '적용 중...' : '적용'}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}