feat: 대시보드 오픈 포지션 패널 추가 (#193)
Some checks failed
CI / test (pull_request) Has been cancelled

- /api/positions 엔드포인트 신설: 마지막 거래가 BUY인 종목을 오픈 포지션으로 반환
- _connect()에 WAL 모드 + busy_timeout=8000 추가 (트레이딩 루프와 동시 읽기 안전)
- init_db()에 idx_trades_stock_market_ts 인덱스 추가 (포지션 쿼리 최적화)
- index.html: 카드와 P&L 차트 사이에 포지션 패널 삽입 (종목/시장/수량/진입가/보유시간)
- 포지션 패널 테스트 3개 추가 (open BUY 반환, SELL 제외, 빈 DB 처리)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
agentson
2026-02-21 20:52:51 +09:00
parent af5bfbac24
commit 40ea41cf3c
4 changed files with 183 additions and 1 deletions

View File

@@ -123,6 +123,32 @@
.rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
.empty-row td { text-align: center; color: var(--muted); padding: 24px; }
/* Positions panel */
.positions-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 20px;
}
.positions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
.positions-table th {
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
}
.positions-table td {
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
vertical-align: middle; white-space: nowrap;
}
.positions-table tr:last-child td { border-bottom: none; }
.positions-table tr:hover td { background: rgba(255,255,255,0.02); }
.pos-empty { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
.pos-count {
display: inline-block; background: rgba(60, 179, 113, 0.12);
color: var(--accent); font-size: 11px; font-weight: 700;
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
}
/* Spinner */
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -163,6 +189,30 @@
</div>
</div>
<!-- Open Positions -->
<div class="positions-panel">
<div class="panel-header">
<span class="panel-title">
현재 보유 포지션
<span class="pos-count" id="positions-count">0</span>
</span>
</div>
<table class="positions-table">
<thead>
<tr>
<th>종목</th>
<th>시장</th>
<th>수량</th>
<th>진입가</th>
<th>보유 시간</th>
</tr>
</thead>
<tbody id="positions-body">
<tr><td colspan="5" class="pos-empty"><span class="spinner"></span></td></tr>
</tbody>
</table>
</div>
<!-- P&L Chart -->
<div class="chart-panel">
<div class="panel-header">
@@ -242,6 +292,39 @@
</div>`;
}
function fmtPrice(v, market) {
if (v === null || v === undefined) return '--';
const n = parseFloat(v);
const sym = market === 'KR' ? '₩' : market === 'JP' ? '¥' : market === 'HK' ? 'HK$' : '$';
return sym + n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
async function fetchPositions() {
const tbody = document.getElementById('positions-body');
const countEl = document.getElementById('positions-count');
try {
const r = await fetch('/api/positions');
if (!r.ok) throw new Error('fetch failed');
const d = await r.json();
countEl.textContent = d.count ?? 0;
if (!d.positions || d.positions.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">현재 보유 중인 포지션 없음</td></tr>';
return;
}
tbody.innerHTML = d.positions.map(p => `
<tr>
<td><strong>${p.stock_code || '--'}</strong></td>
<td><span style="color:var(--muted);font-size:11px">${p.market || '--'}</span></td>
<td>${p.quantity ?? '--'}</td>
<td>${fmtPrice(p.entry_price, p.market)}</td>
<td style="color:var(--muted);font-size:11px">${p.held || '--'}</td>
</tr>
`).join('');
} catch {
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">데이터 로드 실패</td></tr>';
}
}
async function fetchStatus() {
try {
const r = await fetch('/api/status');
@@ -383,6 +466,7 @@
await Promise.all([
fetchStatus(),
fetchPerformance(),
fetchPositions(),
fetchPnlHistory(currentDays),
fetchDecisions(currentMarket),
]);