feat: implement scenario gap analysis - core loop completion
All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s
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>
This commit is contained in:
parent
ea93e09059
commit
eb06dfc48b
@ -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.core.database import get_db
|
||||||
from app.api.deps import CurrentUser
|
from app.api.deps import CurrentUser
|
||||||
from app.models.portfolio import Portfolio, PortfolioType, Target, Holding, Transaction, TransactionType
|
from app.models.portfolio import Portfolio, PortfolioType, Target, Holding, Transaction, TransactionType
|
||||||
|
from app.models.stock import ETF
|
||||||
from app.schemas.portfolio import (
|
from app.schemas.portfolio import (
|
||||||
PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail,
|
PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail,
|
||||||
TargetCreate, TargetResponse,
|
TargetCreate, TargetResponse,
|
||||||
@ -17,6 +18,7 @@ from app.schemas.portfolio import (
|
|||||||
TransactionCreate, TransactionResponse,
|
TransactionCreate, TransactionResponse,
|
||||||
RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
|
RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
|
||||||
RebalanceCalculateRequest, RebalanceCalculateResponse,
|
RebalanceCalculateRequest, RebalanceCalculateResponse,
|
||||||
|
RebalanceApplyRequest, RebalanceApplyResponse,
|
||||||
)
|
)
|
||||||
from app.services.rebalance import RebalanceService
|
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)
|
@router.get("/{portfolio_id}/detail", response_model=PortfolioDetail)
|
||||||
async def get_portfolio_detail(
|
async def get_portfolio_detail(
|
||||||
portfolio_id: int,
|
portfolio_id: int,
|
||||||
@ -410,6 +496,23 @@ async def get_portfolio_detail(
|
|||||||
if total_value > 0:
|
if total_value > 0:
|
||||||
h.current_ratio = (h.value / total_value * 100).quantize(Decimal("0.01"))
|
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(
|
return PortfolioDetail(
|
||||||
id=portfolio.id,
|
id=portfolio.id,
|
||||||
user_id=portfolio.user_id,
|
user_id=portfolio.user_id,
|
||||||
@ -422,4 +525,5 @@ async def get_portfolio_detail(
|
|||||||
total_value=total_value,
|
total_value=total_value,
|
||||||
total_invested=total_invested,
|
total_invested=total_invested,
|
||||||
total_profit_loss=total_value - total_invested,
|
total_profit_loss=total_value - total_invested,
|
||||||
|
risk_asset_ratio=risk_asset_ratio,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -126,8 +126,11 @@ async def execute_signal(
|
|||||||
if holding.quantity == 0:
|
if holding.quantity == 0:
|
||||||
db.delete(holding)
|
db.delete(holding)
|
||||||
|
|
||||||
# 6. Update signal status to executed
|
# 6. Update signal status to executed with execution details
|
||||||
signal.status = SignalStatus.EXECUTED
|
signal.status = SignalStatus.EXECUTED
|
||||||
|
signal.executed_price = data.price
|
||||||
|
signal.executed_quantity = data.quantity
|
||||||
|
signal.executed_at = datetime.utcnow()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(transaction)
|
db.refresh(transaction)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.api.deps import CurrentUser
|
from app.api.deps import CurrentUser
|
||||||
from app.models.portfolio import Portfolio, PortfolioSnapshot, SnapshotHolding
|
from app.models.portfolio import Portfolio, PortfolioSnapshot, SnapshotHolding
|
||||||
|
from app.models.stock import ETFPrice
|
||||||
from app.schemas.portfolio import (
|
from app.schemas.portfolio import (
|
||||||
SnapshotListItem, SnapshotResponse, SnapshotHoldingResponse,
|
SnapshotListItem, SnapshotResponse, SnapshotHoldingResponse,
|
||||||
ReturnsResponse, ReturnDataPoint,
|
ReturnsResponse, ReturnDataPoint,
|
||||||
@ -239,6 +240,22 @@ async def get_returns(
|
|||||||
data=[],
|
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
|
# Calculate returns
|
||||||
data_points = []
|
data_points = []
|
||||||
first_value = Decimal(str(snapshots[0].total_value))
|
first_value = Decimal(str(snapshots[0].total_value))
|
||||||
@ -259,11 +276,18 @@ async def get_returns(
|
|||||||
else:
|
else:
|
||||||
cumulative_return = Decimal("0")
|
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(
|
data_points.append(ReturnDataPoint(
|
||||||
date=snapshot.snapshot_date,
|
date=snapshot.snapshot_date,
|
||||||
total_value=current_value,
|
total_value=current_value,
|
||||||
daily_return=daily_return,
|
daily_return=daily_return,
|
||||||
cumulative_return=cumulative_return,
|
cumulative_return=cumulative_return,
|
||||||
|
benchmark_return=benchmark_return,
|
||||||
))
|
))
|
||||||
|
|
||||||
prev_value = current_value
|
prev_value = current_value
|
||||||
@ -275,6 +299,7 @@ async def get_returns(
|
|||||||
|
|
||||||
total_return = None
|
total_return = None
|
||||||
cagr = None
|
cagr = None
|
||||||
|
benchmark_total_return = None
|
||||||
|
|
||||||
if first_value > 0:
|
if first_value > 0:
|
||||||
total_return = ((last_value - first_value) / first_value * 100).quantize(Decimal("0.01"))
|
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_value = (float(ratio) ** (1 / float(years)) - 1) * 100
|
||||||
cagr = Decimal(str(cagr_value)).quantize(Decimal("0.01"))
|
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(
|
return ReturnsResponse(
|
||||||
portfolio_id=portfolio_id,
|
portfolio_id=portfolio_id,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
total_return=total_return,
|
total_return=total_return,
|
||||||
cagr=cagr,
|
cagr=cagr,
|
||||||
|
benchmark_total_return=benchmark_total_return,
|
||||||
data=data_points,
|
data=data_points,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -38,3 +38,7 @@ class Signal(Base):
|
|||||||
reason = Column(String(200))
|
reason = Column(String(200))
|
||||||
status = Column(SQLEnum(SignalStatus), default=SignalStatus.ACTIVE)
|
status = Column(SQLEnum(SignalStatus), default=SignalStatus.ACTIVE)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
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_value: FloatDecimal | None = None
|
||||||
total_invested: FloatDecimal | None = None
|
total_invested: FloatDecimal | None = None
|
||||||
total_profit_loss: FloatDecimal | None = None
|
total_profit_loss: FloatDecimal | None = None
|
||||||
|
risk_asset_ratio: FloatDecimal | None = None
|
||||||
|
|
||||||
|
|
||||||
# Snapshot schemas
|
# Snapshot schemas
|
||||||
@ -153,6 +154,7 @@ class ReturnDataPoint(BaseModel):
|
|||||||
total_value: FloatDecimal
|
total_value: FloatDecimal
|
||||||
daily_return: FloatDecimal | None = None
|
daily_return: FloatDecimal | None = None
|
||||||
cumulative_return: FloatDecimal | None = None
|
cumulative_return: FloatDecimal | None = None
|
||||||
|
benchmark_return: FloatDecimal | None = None
|
||||||
|
|
||||||
|
|
||||||
class ReturnsResponse(BaseModel):
|
class ReturnsResponse(BaseModel):
|
||||||
@ -162,6 +164,7 @@ class ReturnsResponse(BaseModel):
|
|||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
total_return: FloatDecimal | None = None
|
total_return: FloatDecimal | None = None
|
||||||
cagr: FloatDecimal | None = None
|
cagr: FloatDecimal | None = None
|
||||||
|
benchmark_total_return: FloatDecimal | None = None
|
||||||
data: List[ReturnDataPoint] = []
|
data: List[ReturnDataPoint] = []
|
||||||
|
|
||||||
|
|
||||||
@ -227,3 +230,20 @@ class RebalanceCalculateResponse(BaseModel):
|
|||||||
total_assets: FloatDecimal
|
total_assets: FloatDecimal
|
||||||
available_to_buy: FloatDecimal | None = None
|
available_to_buy: FloatDecimal | None = None
|
||||||
items: List[RebalanceCalculateItem]
|
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
|
||||||
|
|||||||
@ -41,6 +41,9 @@ class SignalResponse(BaseModel):
|
|||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
executed_price: Optional[FloatDecimal] = None
|
||||||
|
executed_quantity: Optional[int] = None
|
||||||
|
executed_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@ -111,3 +111,50 @@ def test_calculate_rebalance_without_prices_fallback(client: TestClient, auth_he
|
|||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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
|
||||||
|
|||||||
@ -35,6 +35,7 @@ interface ReturnDataPoint {
|
|||||||
total_value: string;
|
total_value: string;
|
||||||
daily_return: string | null;
|
daily_return: string | null;
|
||||||
cumulative_return: string | null;
|
cumulative_return: string | null;
|
||||||
|
benchmark_return: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReturnsData {
|
interface ReturnsData {
|
||||||
@ -43,6 +44,7 @@ interface ReturnsData {
|
|||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
total_return: string | null;
|
total_return: string | null;
|
||||||
cagr: string | null;
|
cagr: string | null;
|
||||||
|
benchmark_total_return: string | null;
|
||||||
data: ReturnDataPoint[];
|
data: ReturnDataPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,7 +171,7 @@ export default function PortfolioHistoryPage() {
|
|||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
{returns && returns.total_return !== null && (
|
{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>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-sm text-muted-foreground">총 수익률</div>
|
<div className="text-sm text-muted-foreground">총 수익률</div>
|
||||||
@ -198,6 +200,22 @@ export default function PortfolioHistoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-sm text-muted-foreground">시작일</div>
|
<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 className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||||
누적 수익률
|
누적 수익률
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-muted-foreground uppercase">
|
||||||
|
벤치마크(KOSPI)
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-background divide-y divide-border">
|
<tbody className="bg-background divide-y divide-border">
|
||||||
@ -358,6 +379,15 @@ export default function PortfolioHistoryPage() {
|
|||||||
>
|
>
|
||||||
{formatPercent(point.cumulative_return)}
|
{formatPercent(point.cumulative_return)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -51,6 +51,7 @@ interface PortfolioDetail {
|
|||||||
total_value: number | null;
|
total_value: number | null;
|
||||||
total_invested: number | null;
|
total_invested: number | null;
|
||||||
total_profit_loss: number | null;
|
total_profit_loss: number | null;
|
||||||
|
risk_asset_ratio: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHART_COLORS = [
|
const CHART_COLORS = [
|
||||||
@ -219,6 +220,22 @@ export default function PortfolioDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@ -60,6 +60,10 @@ export default function RebalancePage() {
|
|||||||
const [calculating, setCalculating] = useState(false);
|
const [calculating, setCalculating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [nameMap, setNameMap] = useState<Record<string, string>>({});
|
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(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@ -175,6 +179,43 @@ export default function RebalancePage() {
|
|||||||
const getHoldingQty = (ticker: string) =>
|
const getHoldingQty = (ticker: string) =>
|
||||||
holdings.find((h) => h.ticker === ticker)?.quantity ?? 0;
|
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;
|
if (loading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -386,8 +427,72 @@ export default function RebalancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,9 @@ interface Signal {
|
|||||||
reason: string | null;
|
reason: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
executed_price: number | null;
|
||||||
|
executed_quantity: number | null;
|
||||||
|
executed_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Portfolio {
|
interface Portfolio {
|
||||||
@ -46,6 +49,12 @@ interface Portfolio {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Holding {
|
||||||
|
ticker: string;
|
||||||
|
quantity: number;
|
||||||
|
avg_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
|
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
|
||||||
buy: {
|
buy: {
|
||||||
label: '매수',
|
label: '매수',
|
||||||
@ -106,6 +115,7 @@ export default function SignalsPage() {
|
|||||||
const [executePrice, setExecutePrice] = useState('');
|
const [executePrice, setExecutePrice] = useState('');
|
||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
const [executeError, setExecuteError] = useState('');
|
const [executeError, setExecuteError] = useState('');
|
||||||
|
const [currentHoldings, setCurrentHoldings] = useState<Holding[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@ -185,10 +195,35 @@ export default function SignalsPage() {
|
|||||||
setExecuteQuantity('');
|
setExecuteQuantity('');
|
||||||
setSelectedPortfolioId('');
|
setSelectedPortfolioId('');
|
||||||
setExecuteError('');
|
setExecuteError('');
|
||||||
|
setCurrentHoldings([]);
|
||||||
await fetchPortfolios();
|
await fetchPortfolios();
|
||||||
setExecuteModalOpen(true);
|
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 () => {
|
const handleExecute = async () => {
|
||||||
if (!executeSignal || !selectedPortfolioId || !executeQuantity || !executePrice) {
|
if (!executeSignal || !selectedPortfolioId || !executeQuantity || !executePrice) {
|
||||||
setExecuteError('모든 필드를 입력해주세요.');
|
setExecuteError('모든 필드를 입력해주세요.');
|
||||||
@ -243,6 +278,8 @@ 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-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-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>
|
||||||
<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>
|
</tr>
|
||||||
@ -277,6 +314,12 @@ export default function SignalsPage() {
|
|||||||
<td className="px-4 py-3 text-sm max-w-xs truncate" title={signal.reason || ''}>
|
<td className="px-4 py-3 text-sm max-w-xs truncate" title={signal.reason || ''}>
|
||||||
{signal.reason || '-'}
|
{signal.reason || '-'}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3 text-center">
|
||||||
<Badge className={statConf.style}>{statConf.label}</Badge>
|
<Badge className={statConf.style}>{statConf.label}</Badge>
|
||||||
</td>
|
</td>
|
||||||
@ -297,7 +340,7 @@ export default function SignalsPage() {
|
|||||||
})}
|
})}
|
||||||
{signals.length === 0 && (
|
{signals.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={10} className="px-4 py-8 text-center text-muted-foreground">
|
<td colSpan={12} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
신호가 없습니다.
|
신호가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -500,7 +543,7 @@ export default function SignalsPage() {
|
|||||||
{/* Portfolio selection */}
|
{/* Portfolio selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>포트폴리오</Label>
|
<Label>포트폴리오</Label>
|
||||||
<Select value={selectedPortfolioId} onValueChange={setSelectedPortfolioId}>
|
<Select value={selectedPortfolioId} onValueChange={handlePortfolioChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="포트폴리오 선택" />
|
<SelectValue placeholder="포트폴리오 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -514,6 +557,23 @@ export default function SignalsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 */}
|
{/* Quantity */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="exec-quantity">수량 (주)</Label>
|
<Label htmlFor="exec-quantity">수량 (주)</Label>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio';
|
||||||
|
|
||||||
interface StockFactor {
|
interface StockFactor {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@ -224,6 +225,9 @@ export default function MultiFactorPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<ApplyToPortfolio stocks={result.stocks.map((s) => ({ ticker: s.ticker, name: s.name }))} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio';
|
||||||
|
|
||||||
interface StockFactor {
|
interface StockFactor {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@ -196,6 +197,9 @@ export default function QualityStrategyPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<ApplyToPortfolio stocks={result.stocks.map((s) => ({ ticker: s.ticker, name: s.name }))} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio';
|
||||||
|
|
||||||
interface StockFactor {
|
interface StockFactor {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@ -204,6 +205,9 @@ export default function ValueMomentumPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<ApplyToPortfolio stocks={result.stocks.map((s) => ({ ticker: s.ticker, name: s.name }))} />
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</DashboardLayout>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user