diff --git a/backend/app/api/signal.py b/backend/app/api/signal.py index c718560..69f4155 100644 --- a/backend/app/api/signal.py +++ b/backend/app/api/signal.py @@ -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, + } diff --git a/backend/tests/e2e/test_kjb_flow.py b/backend/tests/e2e/test_kjb_flow.py index 0655968..5d152e9 100644 --- a/backend/tests/e2e/test_kjb_flow.py +++ b/backend/tests/e2e/test_kjb_flow.py @@ -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 diff --git a/frontend/src/app/signals/page.tsx b/frontend/src/app/signals/page.tsx index 2896434..2b9669d 100644 --- a/frontend/src/app/signals/page.tsx +++ b/frontend/src/app/signals/page.tsx @@ -117,6 +117,13 @@ export default function SignalsPage() { const [executeError, setExecuteError] = useState(''); const [currentHoldings, setCurrentHoldings] = useState([]); + // Cancel modal state + const [cancelModalOpen, setCancelModalOpen] = useState(false); + const [cancelSignal, setCancelSignal] = useState(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[]) => (
@@ -334,6 +375,16 @@ export default function SignalsPage() { 실행 )} + {signal.status === 'executed' && ( + + )} ); @@ -617,6 +668,69 @@ export default function SignalsPage() { + + {/* 신호 취소 확인 모달 */} + + + + 신호 실행 취소 + + 실행된 신호를 취소합니다. 연결된 거래가 삭제되고 보유량이 복원됩니다. + + + + {cancelSignal && ( +
+
+
+ 종목 + {cancelSignal.name || cancelSignal.ticker} ({cancelSignal.ticker}) +
+
+ 체결가 + {cancelSignal.executed_price ? cancelSignal.executed_price.toLocaleString() : '-'}원 +
+
+ 체결 수량 + {cancelSignal.executed_quantity?.toLocaleString() || '-'}주 +
+
+ +
+ + +
+ + {cancelError && ( +

{cancelError}

+ )} +
+ )} + + + + + +
+
); }