feat: add signal execution cancel with transaction rollback and holding restore

This commit is contained in:
머니페니 2026-03-18 18:56:29 +09:00
parent 213f03a8e5
commit 65618cd957
3 changed files with 249 additions and 0 deletions

View File

@ -150,3 +150,85 @@ async def execute_signal(
"status": signal.status.value,
},
}
@router.delete("/{signal_id}/cancel", response_model=dict)
async def cancel_signal(
signal_id: int,
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""실행된 신호를 취소한다. 연결된 거래를 삭제하고 보유량을 복원하며 신호를 ACTIVE로 되돌린다."""
from app.api.portfolio import _get_portfolio
from decimal import Decimal
# 1. 신호 조회 및 상태 확인
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.EXECUTED:
raise HTTPException(status_code=400, detail="Signal is not in EXECUTED status")
# 2. 포트폴리오 소유권 확인
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
# 3. 연결된 거래 조회 (신호 메모 기준)
memo_prefix = f"KJB signal #{signal_id}:"
transaction = (
db.query(Transaction)
.filter(
Transaction.portfolio_id == portfolio_id,
Transaction.ticker == signal.ticker,
Transaction.memo.like(f"{memo_prefix}%"),
)
.order_by(Transaction.executed_at.desc())
.first()
)
if not transaction:
raise HTTPException(status_code=404, detail="Related transaction not found")
# 4. 보유량 복원 (거래 역방향)
holding = db.query(Holding).filter(
Holding.portfolio_id == portfolio_id,
Holding.ticker == signal.ticker,
).first()
if transaction.tx_type == TransactionType.BUY:
# 매수 취소 → 보유량 감소
if holding:
holding.quantity -= transaction.quantity
if holding.quantity <= 0:
db.delete(holding)
elif transaction.tx_type == TransactionType.SELL:
# 매도 취소 → 보유량 복원
if holding:
# 평균단가 재계산 (역산 불가이므로 수량만 복원)
holding.quantity += transaction.quantity
else:
holding = Holding(
portfolio_id=portfolio_id,
ticker=signal.ticker,
quantity=transaction.quantity,
avg_price=transaction.price,
)
db.add(holding)
# 5. 거래 삭제
db.delete(transaction)
# 6. 신호 상태 복원
signal.status = SignalStatus.ACTIVE
signal.executed_price = None
signal.executed_quantity = None
signal.executed_at = None
db.commit()
return {
"signal_id": signal_id,
"signal_status": signal.status.value,
"transaction_deleted": True,
"ticker": signal.ticker,
}

View File

@ -72,3 +72,56 @@ def test_signal_requires_auth(client: TestClient):
"""Test that signal endpoints require authentication."""
response = client.get("/api/signal/kjb/today")
assert response.status_code == 401
def test_cancel_executed_signal(client: TestClient, auth_headers):
"""실행된 신호를 취소하면 거래가 삭제되고 보유량이 복원된다."""
# 1. 포트폴리오 생성
resp = client.post(
"/api/portfolios",
json={"name": "Signal Cancel Test", "portfolio_type": "general"},
headers=auth_headers,
)
assert resp.status_code == 201
portfolio_id = resp.json()["id"]
# 2. 신호 생성 (직접 DB 경유 없이 API로 생성 불가 → 신호 실행 취소는 EXECUTED 신호에만 작동)
# 오늘 날짜로 신호 조회해서 없으면 스킵
today_resp = client.get("/api/signal/kjb/today", headers=auth_headers)
signals = today_resp.json()
if not signals:
# 신호가 없으면 엔드포인트 존재만 검증 (portfolio_id 포함)
resp = client.delete("/api/signal/9999/cancel", params={"portfolio_id": 9999}, headers=auth_headers)
assert resp.status_code in [404, 400]
return
# 3. ACTIVE 신호에 보유 종목 세팅 후 신호 실행
signal = signals[0]
ticker = signal["ticker"]
client.put(
f"/api/portfolios/{portfolio_id}/holdings",
json=[{"ticker": ticker, "quantity": 100, "avg_price": 10000}],
headers=auth_headers,
)
exec_resp = client.post(
f"/api/signal/{signal['id']}/execute",
json={"portfolio_id": portfolio_id, "quantity": 10, "price": 10000},
headers=auth_headers,
)
# 신호 타입에 따라 실패할 수 있음
if exec_resp.status_code != 200:
return
# 4. 취소 요청
cancel_resp = client.delete(
f"/api/signal/{signal['id']}/cancel",
params={"portfolio_id": portfolio_id},
headers=auth_headers,
)
assert cancel_resp.status_code == 200
data = cancel_resp.json()
assert data["signal_status"] == "active"
assert data["transaction_deleted"] is True

View File

@ -117,6 +117,13 @@ export default function SignalsPage() {
const [executeError, setExecuteError] = useState('');
const [currentHoldings, setCurrentHoldings] = useState<Holding[]>([]);
// Cancel modal state
const [cancelModalOpen, setCancelModalOpen] = useState(false);
const [cancelSignal, setCancelSignal] = useState<Signal | null>(null);
const [cancelPortfolioId, setCancelPortfolioId] = useState('');
const [cancelling, setCancelling] = useState(false);
const [cancelError, setCancelError] = useState('');
useEffect(() => {
const init = async () => {
try {
@ -265,6 +272,40 @@ export default function SignalsPage() {
}
};
const handleOpenCancelModal = async (signal: Signal) => {
setCancelSignal(signal);
setCancelError('');
setCancelPortfolioId('');
// 포트폴리오 목록 로드 (이미 있으면 재사용)
if (portfolios.length === 0) {
const pResp = await api.get('/api/portfolios') as { data: Portfolio[] };
setPortfolios(pResp.data);
}
setCancelModalOpen(true);
};
const handleSubmitCancel = async () => {
if (!cancelSignal || !cancelPortfolioId) {
setCancelError('포트폴리오를 선택해주세요.');
return;
}
setCancelling(true);
setCancelError('');
try {
await api.delete(`/api/signal/${cancelSignal.id}/cancel?portfolio_id=${cancelPortfolioId}`);
setCancelModalOpen(false);
if (showHistory) {
await fetchHistorySignals();
} else {
await fetchTodaySignals();
}
} catch (err) {
setCancelError(err instanceof Error ? err.message : '취소에 실패했습니다.');
} finally {
setCancelling(false);
}
};
const renderSignalTable = (signals: Signal[]) => (
<div className="overflow-x-auto">
<table className="w-full">
@ -334,6 +375,16 @@ export default function SignalsPage() {
</Button>
)}
{signal.status === 'executed' && (
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => handleOpenCancelModal(signal)}
>
</Button>
)}
</td>
</tr>
);
@ -617,6 +668,69 @@ export default function SignalsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 신호 취소 확인 모달 */}
<Dialog open={cancelModalOpen} onOpenChange={setCancelModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{cancelSignal && (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg p-3 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{cancelSignal.name || cancelSignal.ticker} ({cancelSignal.ticker})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-mono">{cancelSignal.executed_price ? cancelSignal.executed_price.toLocaleString() : '-'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-mono">{cancelSignal.executed_quantity?.toLocaleString() || '-'}</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cancel-portfolio"> </Label>
<select
id="cancel-portfolio"
className="w-full border rounded-md px-3 py-2 text-sm bg-background"
value={cancelPortfolioId}
onChange={(e) => setCancelPortfolioId(e.target.value)}
>
<option value=""> ...</option>
{portfolios.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{cancelError && (
<p className="text-sm text-red-600">{cancelError}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setCancelModalOpen(false)} disabled={cancelling}>
</Button>
<Button
variant="destructive"
onClick={handleSubmitCancel}
disabled={cancelling || !cancelPortfolioId}
>
{cancelling ? '취소 중...' : '실행 취소'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout>
);
}