feat: implement scenario gap analysis - core loop completion
All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s

Phase 1 (Critical):
- Add bulk rebalance apply API + UI with confirmation modal
- Add strategy results to portfolio targets flow (shared component)

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-19 18:18:15 +09:00
parent ea93e09059
commit eb06dfc48b
16 changed files with 608 additions and 4 deletions

View File

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

View File

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.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,
) )

View File

@ -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)

View File

@ -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,
) )

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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">&#9888;</span>
<div>
<p className="font-medium">DC형 </p>
<p className="text-sm mt-1">
: <strong>{portfolio.risk_asset_ratio.toFixed(1)}%</strong> ( 한도: 70%).
/ ETF .
</p>
</div>
</div>
)}
{/* Summary Cards */} {/* 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>

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

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