feat: add manual signal execution to portfolio

Allow users to execute active KJB signals by selecting a portfolio,
entering quantity and price, then creating the corresponding transaction
and updating holdings. Signal status changes to 'executed' after completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zephyrdark 2026-02-19 16:10:48 +09:00
parent e6160fffc6
commit a7366d053e
4 changed files with 430 additions and 7 deletions

View File

@ -1,16 +1,18 @@
"""
KJB Signal API endpoints.
"""
from datetime import date
from datetime import date, datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.signal import Signal
from app.schemas.signal import SignalResponse
from app.models.signal import Signal, SignalStatus, SignalType
from app.models.portfolio import Holding, Transaction, TransactionType
from app.schemas.signal import SignalExecuteRequest, SignalResponse
from app.schemas.portfolio import TransactionResponse
router = APIRouter(prefix="/api/signal", tags=["signal"])
@ -54,3 +56,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,
},
}

View File

@ -6,11 +6,17 @@ from decimal import Decimal
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.schemas.portfolio import FloatDecimal
class SignalExecuteRequest(BaseModel):
portfolio_id: int
quantity: int = Field(..., gt=0)
price: FloatDecimal = Field(..., gt=0)
class SignalType(str, Enum):
BUY = "buy"
SELL = "sell"

View File

@ -9,8 +9,23 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { api } from '@/lib/api';
import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle } from 'lucide-react';
import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle, Play } from 'lucide-react';
interface Signal {
id: number;
@ -26,6 +41,11 @@ interface Signal {
created_at: string;
}
interface Portfolio {
id: number;
name: string;
}
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
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<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('');
useEffect(() => {
const init = async () => {
try {
@ -115,6 +145,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 +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[]) => (
<div className="overflow-x-auto">
<table className="w-full">
@ -154,6 +244,7 @@ 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-left 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">
@ -189,12 +280,24 @@ export default function SignalsPage() {
<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={10} className="px-4 py-8 text-center text-muted-foreground">
.
</td>
</tr>
@ -355,6 +458,105 @@ 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={setSelectedPortfolioId}>
<SelectTrigger>
<SelectValue placeholder="포트폴리오 선택" />
</SelectTrigger>
<SelectContent>
{portfolios.map((p) => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Quantity */}
<div className="space-y-2">
<Label htmlFor="exec-quantity"> ()</Label>
<Input
id="exec-quantity"
type="number"
min="1"
value={executeQuantity}
onChange={(e) => setExecuteQuantity(e.target.value)}
placeholder="매매 수량 입력"
/>
</div>
{/* Price */}
<div className="space-y-2">
<Label htmlFor="exec-price"></Label>
<Input
id="exec-price"
type="number"
min="0"
step="any"
value={executePrice}
onChange={(e) => setExecutePrice(e.target.value)}
placeholder="실제 체결 가격 입력"
/>
</div>
{executeError && (
<p className="text-sm text-red-600">{executeError}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setExecuteModalOpen(false)} disabled={executing}>
</Button>
<Button onClick={handleExecute} disabled={executing}>
{executing ? '실행 중...' : '실행'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
);
}

View File

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