Compare commits
5 Commits
e6160fffc6
...
eb06dfc48b
| Author | SHA1 | Date | |
|---|---|---|---|
| eb06dfc48b | |||
| ea93e09059 | |||
| c97bb8595e | |||
| 7cff4003d7 | |||
| a7366d053e |
3
.gitignore
vendored
3
.gitignore
vendored
@ -61,3 +61,6 @@ data/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
@ -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')
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
147
docs/plans/2026-02-19-scenario-gap-analysis-design.md
Normal file
147
docs/plans/2026-02-19-scenario-gap-analysis-design.md
Normal 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 흐름의 자연스러움 (페이지 간 연결)
|
||||
- 에러 처리 및 안정성
|
||||
- 데이터 정합성 (계산 정확성)
|
||||
395
docs/plans/2026-02-19-scenario-gap-analysis-plan.md
Normal file
395
docs/plans/2026-02-19-scenario-gap-analysis-plan.md
Normal 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 분석 | 백테스트 기간 분할 검증 |
|
||||
| 최소 거래 단위 | 리밸런싱 시 최소 거래 금액 고려 |
|
||||
| 신호 실행 취소 | 실행된 신호 되돌리기 (거래 삭제 포함) |
|
||||
| 실현/미실현 수익 분리 | 매도 시 실현 수익 계산 및 별도 추적 |
|
||||
| 포지션 사이징 가이드 | 신호 실행 시 추천 수량/최대 포지션 표시 |
|
||||
@ -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>
|
||||
|
||||
@ -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">⚠</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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
138
frontend/src/components/strategy/apply-to-portfolio.tsx
Normal file
138
frontend/src/components/strategy/apply-to-portfolio.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user