diff --git a/backend/app/api/signal.py b/backend/app/api/signal.py index be24382..4dd29c4 100644 --- a/backend/app/api/signal.py +++ b/backend/app/api/signal.py @@ -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,94 @@ 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 + signal.status = SignalStatus.EXECUTED + + 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, + }, + } diff --git a/backend/app/schemas/signal.py b/backend/app/schemas/signal.py index 1892551..e0531a5 100644 --- a/backend/app/schemas/signal.py +++ b/backend/app/schemas/signal.py @@ -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" diff --git a/frontend/src/app/signals/page.tsx b/frontend/src/app/signals/page.tsx index f91004e..cca61e4 100644 --- a/frontend/src/app/signals/page.tsx +++ b/frontend/src/app/signals/page.tsx @@ -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; @@ -26,6 +41,11 @@ interface Signal { created_at: string; } +interface Portfolio { + id: number; + name: string; +} + const signalTypeConfig: Record = { buy: { label: '매수', @@ -77,6 +97,16 @@ export default function SignalsPage() { const [filterStartDate, setFilterStartDate] = useState(''); const [filterEndDate, setFilterEndDate] = useState(''); + // Execute modal state + const [executeModalOpen, setExecuteModalOpen] = useState(false); + const [executeSignal, setExecuteSignal] = useState(null); + const [portfolios, setPortfolios] = useState([]); + const [selectedPortfolioId, setSelectedPortfolioId] = useState(''); + const [executeQuantity, setExecuteQuantity] = useState(''); + const [executePrice, setExecutePrice] = useState(''); + const [executing, setExecuting] = useState(false); + const [executeError, setExecuteError] = useState(''); + useEffect(() => { const init = async () => { try { @@ -115,6 +145,15 @@ export default function SignalsPage() { } }; + const fetchPortfolios = async () => { + try { + const data = await api.get('/api/portfolios'); + setPortfolios(data); + } catch (err) { + console.error('Failed to fetch portfolios:', err); + } + }; + const handleRefresh = async () => { setRefreshing(true); try { @@ -140,6 +179,57 @@ export default function SignalsPage() { await fetchHistorySignals(); }; + const handleOpenExecuteModal = async (signal: Signal) => { + setExecuteSignal(signal); + setExecutePrice(signal.entry_price?.toString() || ''); + setExecuteQuantity(''); + setSelectedPortfolioId(''); + setExecuteError(''); + await fetchPortfolios(); + setExecuteModalOpen(true); + }; + + 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[]) => (
@@ -154,6 +244,7 @@ export default function SignalsPage() { + @@ -189,12 +280,24 @@ export default function SignalsPage() { + ); })} {signals.length === 0 && ( - @@ -355,6 +458,105 @@ export default function SignalsPage() { )} + + {/* Execute Signal Modal */} + + + + 신호 실행 + + 매매 신호를 포트폴리오에 반영합니다. + + + + {executeSignal && ( +
+ {/* Signal info */} +
+
+ 종목 + {executeSignal.name || executeSignal.ticker} ({executeSignal.ticker}) +
+
+ 신호 유형 + + {signalTypeConfig[executeSignal.signal_type]?.label || executeSignal.signal_type} + +
+
+ 추천 진입가 + {formatPrice(executeSignal.entry_price)} +
+
+ 목표가 + {formatPrice(executeSignal.target_price)} +
+
+ 손절가 + {formatPrice(executeSignal.stop_loss_price)} +
+
+ + {/* Portfolio selection */} +
+ + +
+ + {/* Quantity */} +
+ + setExecuteQuantity(e.target.value)} + placeholder="매매 수량 입력" + /> +
+ + {/* Price */} +
+ + setExecutePrice(e.target.value)} + placeholder="실제 체결 가격 입력" + /> +
+ + {executeError && ( +

{executeError}

+ )} +
+ )} + + + + + +
+
); } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..f38593b --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}
손절가 사유 상태실행
{statConf.label} + {signal.status === 'active' && ( + + )} +
+ 신호가 없습니다.