feat: add signal execution cancel with transaction rollback and holding restore
This commit is contained in:
parent
213f03a8e5
commit
65618cd957
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user