과거 거래 기록 조회 추가

좌측 사이드메뉴 추가
ggitignore 업데이트 : *.pyc
This commit is contained in:
2026-07-17 13:03:35 +09:00
parent 2fcef54b23
commit d3f3931c38
6 changed files with 431 additions and 118 deletions

16
.env Normal file
View File

@@ -0,0 +1,16 @@
# 한국투자증권 Open API 설정
KIS_APP_KEY=your_app_key_here
KIS_APP_SECRET=your_app_secret_here
KIS_ACCOUNT_NO=12345678
KIS_ACCOUNT_CODE=01
KIS_HTS_ID=your_hts_id
# 서버 모드 (real: 실전투자, vps: 모의투자)
KIS_SERVER_MODE=vps
# 앱 설정
APP_HOST=0.0.0.0
APP_PORT=8000
APP_DEBUG=true
DB_PATH=./data/stock.db
LOG_LEVEL=INFO

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
./.venv ./.venv
./app/__pycache__/ ./app/__pycache__/
*.pyc

View File

@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException from datetime import date, timedelta
from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.auth import token_manager
from app.core.database import get_db from app.core.database import get_db
from app.models.stock import Trade from app.models.stock import Trade
from app.services.trading import trading_service from app.services.trading import trading_service
@@ -62,32 +65,57 @@ async def cancel_order(data: CancelRequest) -> dict:
@router.get("/history") @router.get("/history")
def get_trade_history( async def get_trade_history(
stock_code: str | None = None, stock_code: str | None = None,
limit: int = 50, start_date: str | None = None,
end_date: str | None = None,
limit: int = 100,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[dict]: ) -> dict:
source = "local"
if token_manager.is_authenticated:
if not start_date:
start_date = (date.today() - timedelta(days=30)).strftime("%Y%m%d")
if not end_date:
end_date = date.today().strftime("%Y%m%d")
try:
kis_trades = await trading_service.get_daily_ccled(
start_date=start_date,
end_date=end_date,
stock_code=stock_code or "",
)
if kis_trades:
source = "kis"
return {"source": source, "trades": kis_trades[:limit]}
except Exception:
pass
query = db.query(Trade).order_by(Trade.created_at.desc()) query = db.query(Trade).order_by(Trade.created_at.desc())
if stock_code: if stock_code:
query = query.filter(Trade.stock_code == stock_code) query = query.filter(Trade.stock_code == stock_code)
trades = query.limit(limit).all() trades = query.limit(limit).all()
return [ return {
{ "source": source,
"id": t.id, "trades": [
"order_no": t.order_no, {
"stock_code": t.stock_code, "id": t.id,
"stock_name": t.stock_name, "order_no": t.order_no,
"side": t.side, "stock_code": t.stock_code,
"qty": t.qty, "stock_name": t.stock_name,
"price": t.price, "side": t.side,
"order_type": t.order_type, "qty": t.qty,
"status": t.status, "price": t.price,
"strategy_id": t.strategy_id, "order_type": t.order_type,
"created_at": t.created_at.isoformat() if t.created_at else None, "status": t.status,
"filled_at": t.filled_at.isoformat() if t.filled_at else None, "strategy_id": t.strategy_id,
} "created_at": t.created_at.isoformat() if t.created_at else None,
for t in trades "filled_at": t.filled_at.isoformat() if t.filled_at else None,
] }
for t in trades
],
}
@router.get("/account") @router.get("/account")

View File

@@ -11,10 +11,23 @@ logger = setup_logger("trading")
_ORDER_URL = "/uapi/domestic-stock/v1/trading/order-cash" _ORDER_URL = "/uapi/domestic-stock/v1/trading/order-cash"
_MODIFY_CANCEL_URL = "/uapi/domestic-stock/v1/trading/order-rvsecncl" _MODIFY_CANCEL_URL = "/uapi/domestic-stock/v1/trading/order-rvsecncl"
_DAILY_CCLED_URL = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld"
_TR_IDS = { _TR_IDS = {
"real": {"buy": "TTTC0802U", "sell": "TTTC0801U", "modify": "TTTC0803U", "cancel": "TTTC0804U"}, "real": {
"vps": {"buy": "VTTC0802U", "sell": "VTTC0801U", "modify": "VTTC0803U", "cancel": "VTTC0804U"}, "buy": "TTTC0802U",
"sell": "TTTC0801U",
"modify": "TTTC0803U",
"cancel": "TTTC0804U",
"daily_ccled": "TTTC0012R",
},
"vps": {
"buy": "VTTC0802U",
"sell": "VTTC0801U",
"modify": "VTTC0803U",
"cancel": "VTTC0804U",
"daily_ccled": "VTTC0012R",
},
} }
@@ -173,6 +186,77 @@ class TradingService:
return result return result
async def get_daily_ccled(
self,
start_date: str,
end_date: str,
stock_code: str = "",
ccled_dvsn: str = "0",
) -> list[dict]:
"""KIS API 일일 체결 내역 조회"""
if not self._check_auth():
return []
await self._rate_limiter.acquire()
tr_id = _TR_IDS.get(settings.kis.server_mode, _TR_IDS["vps"])["daily_ccled"]
headers = token_manager.get_auth_headers(tr_id)
params = {
"CANO": settings.kis.account_no,
"ACNT_PRDT_CD": settings.kis.account_code,
"FH_PDNO": stock_code,
"CCLD_DVSN": ccled_dvsn,
"INQR_STRT_DAY": start_date,
"INQR_END_DAY": end_date,
"WCRC_FRCR_DVSN": "0",
"CTAC_TLNO": "",
"MKET_ID": "",
}
url = f"{get_base_url()}{_DAILY_CCLED_URL}"
resp = await self._client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
if data.get("rt_cd") != "0":
logger.warning("일일 체결 내역 조회 실패: %s", data.get("msg1"))
return []
trades = []
for item in data.get("output", []):
qty = int(item.get("ft_ccld_qty", 0))
if qty == 0:
continue
side_code = item.get("sll_buy_dvsn_cd", "")
if side_code in ("02", "06"):
side = "sell"
elif side_code in ("01", "03"):
side = "buy"
else:
side = "buy" if side_code == "01" else "sell"
trades.append({
"order_no": item.get("ord_gno_brno", ""),
"stock_code": item.get("pdno", ""),
"stock_name": item.get("prdt_name", ""),
"side": side,
"qty": qty,
"price": int(float(item.get("ft_ccld_unpr3", 0))),
"order_type": item.get("ord_dvsn", "00"),
"status": "filled",
"order_amount": float(item.get("ft_ord_amt", 0)),
"settlement_amount": float(item.get("ft_ccld_amt", 0)),
"tax": float(item.get("sttl_evlu_amt", 0)),
"commission": float(item.get("ft_lof_ruse_amt", 0)),
"created_at": item.get("ord_sttm", ""),
"filled_at": item.get("ft_ccld_no", ""),
})
logger.info("일일 체결 내역 조회: %d", len(trades))
return trades
async def close(self) -> None: async def close(self) -> None:
await self._client.aclose() await self._client.aclose()

View File

@@ -6,18 +6,29 @@
<title>Stock Automation Dashboard</title> <title>Stock Automation Dashboard</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; display: flex; min-height: 100vh; }
.header { background: #161b22; padding: 16px 24px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 20px; color: #58a6ff; } .sidebar { width: 200px; background: #161b22; border-right: 1px solid #30363d; display: flex; flex-direction: column; flex-shrink: 0; }
.header .status { font-size: 13px; color: #8b949e; } .sidebar-header { padding: 16px; border-bottom: 1px solid #30363d; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; } .sidebar-header h1 { font-size: 16px; color: #58a6ff; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.sidebar-nav a { display: flex; align-items: center; gap: 10px; padding: 10px 16px; color: #8b949e; text-decoration: none; font-size: 14px; transition: background 0.15s; }
.sidebar-nav a:hover { background: #1c2128; color: #c9d1d9; }
.sidebar-nav a.active { background: #1c2128; color: #58a6ff; border-right: 2px solid #58a6ff; }
.sidebar-nav a .icon { width: 18px; text-align: center; font-size: 15px; }
.sidebar-footer { padding: 12px 16px; border-top: 1px solid #30363d; font-size: 12px; color: #484f58; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { background: #161b22; padding: 12px 24px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; }
.topbar .status { font-size: 13px; color: #8b949e; display: flex; align-items: center; gap: 6px; }
.content { flex: 1; padding: 24px; overflow-y: auto; }
.page { display: none; max-width: 1200px; margin: 0 auto; }
.page.active { display: block; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 24px; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; } .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
.card h3 { font-size: 13px; color: #8b949e; margin-bottom: 8px; text-transform: uppercase; } .card h3 { font-size: 13px; color: #8b949e; margin-bottom: 8px; text-transform: uppercase; }
.card .value { font-size: 28px; font-weight: 700; } .card .value { font-size: 28px; font-weight: 700; }
.card .value.profit { color: #f85149; }
.card .value.positive { color: #3fb950; }
.card .value.negative { color: #f85149; }
.section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; } .section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 20px; }
.section h2 { font-size: 16px; margin-bottom: 12px; color: #c9d1d9; } .section h2 { font-size: 16px; margin-bottom: 12px; color: #c9d1d9; }
table { width: 100%; border-collapse: collapse; font-size: 13px; } table { width: 100%; border-collapse: collapse; font-size: 13px; }
@@ -29,7 +40,6 @@
.badge.sell { background: #3a1a1a; color: #f85149; } .badge.sell { background: #3a1a1a; color: #f85149; }
.badge.pending { background: #3a3a1a; color: #d29922; } .badge.pending { background: #3a3a1a; color: #d29922; }
.badge.filled { background: #1a2a3a; color: #58a6ff; } .badge.filled { background: #1a2a3a; color: #58a6ff; }
.actions { display: flex; gap: 8px; margin-bottom: 20px; }
.btn { padding: 8px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; font-size: 13px; } .btn { padding: 8px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #c9d1d9; cursor: pointer; font-size: 13px; }
.btn:hover { background: #30363d; } .btn:hover { background: #30363d; }
.btn.primary { background: #238636; border-color: #2ea043; color: #fff; } .btn.primary { background: #238636; border-color: #2ea043; color: #fff; }
@@ -38,97 +48,157 @@
.form-group { display: flex; flex-direction: column; gap: 4px; } .form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group label { font-size: 12px; color: #8b949e; } .form-group label { font-size: 12px; color: #8b949e; }
.form-group input, .form-group select { padding: 6px 10px; border-radius: 4px; border: 1px solid #30363d; background: #0d1117; color: #c9d1d9; font-size: 13px; } .form-group input, .form-group select { padding: 6px 10px; border-radius: 4px; border: 1px solid #30363d; background: #0d1117; color: #c9d1d9; font-size: 13px; }
#ws-status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } #ws-status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
#ws-status.connected { background: #3fb950; } #ws-status.connected { background: #3fb950; }
#ws-status.disconnected { background: #f85149; } #ws-status.disconnected { background: #f85149; }
.alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; display: none; } .alert { padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; display: none; }
.alert.warning { background: #3a2a00; border: 1px solid #d29922; color: #e3b341; } .alert.warning { background: #3a2a00; border: 1px solid #d29922; color: #e3b341; }
.alert.warning.show { display: block; } .alert.warning.show { display: block; }
.source-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
.source-tag.kis { background: #1a2a3a; color: #58a6ff; }
.source-tag.local { background: #2a2a1a; color: #d29922; }
.filter-row { display: flex; gap: 12px; align-items: end; margin-bottom: 16px; flex-wrap: wrap; }
.positive { color: #3fb950; }
.negative { color: #f85149; }
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <aside class="sidebar">
<h1>Stock Automation</h1> <div class="sidebar-header">
<div class="status"> <h1>Stock Automation</h1>
<span id="ws-status" class="disconnected"></span>
<span id="status-text">연결 중...</span>
</div> </div>
</div> <nav class="sidebar-nav">
<a href="#" class="active" data-page="trading">
<span class="icon">&#9881;</span> 실거래
</a>
<a href="#" data-page="history">
<span class="icon">&#128203;</span> 과거 기록 조회
</a>
</nav>
<div class="sidebar-footer">v0.1.0</div>
</aside>
<div class="container"> <div class="main">
<div id="auth-alert" class="alert warning"> <div class="topbar">
API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요. <div style="font-size: 14px; color: #8b949e;" id="page-title">실거래</div>
현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다. <div class="status">
</div> <span id="ws-status" class="disconnected"></span>
<span id="status-text">연결 중...</span>
<div class="grid">
<div class="card">
<h3>총 투자금</h3>
<div class="value" id="total-invested">-</div>
</div>
<div class="card">
<h3>평가금액</h3>
<div class="value" id="total-evaluated">-</div>
</div>
<div class="card">
<h3>총 수익</h3>
<div class="value" id="total-profit">-</div>
</div>
<div class="card">
<h3>수익률</h3>
<div class="value" id="profit-rate">-</div>
</div> </div>
</div> </div>
<div class="section"> <div class="content">
<h2>보유 종목</h2> <!-- 실거래 페이지 -->
<table> <div id="page-trading" class="page active">
<thead> <div id="auth-alert" class="alert warning">
<tr> API 키가 설정되지 않았거나 유효하지 않습니다. .env 파일에 KIS_APP_KEY와 KIS_APP_SECRET을 입력하세요.
<th>종목코드</th><th>종목명</th><th>수량</th> 현재 UI 표시만 가능하며, 시세 조회 및 매매 기능은 작동하지 않습니다.
<th>평균단가</th><th>현재가</th><th>수익</th><th>수익률</th> </div>
</tr>
</thead>
<tbody id="holdings-table"></tbody>
</table>
</div>
<div class="section"> <div class="grid">
<h2>최근 매매 내역</h2> <div class="card">
<table> <h3>총 투자금</h3>
<thead> <div class="value" id="total-invested">-</div>
<tr> </div>
<th>시간</th><th>종목코드</th><th>구분</th> <div class="card">
<th>수량</th><th>가격</th><th>상태</th> <h3>평가금액</h3>
</tr> <div class="value" id="total-evaluated">-</div>
</thead> </div>
<tbody id="trades-table"></tbody> <div class="card">
</table> <h3>총 수익</h3>
</div> <div class="value" id="total-profit">-</div>
</div>
<div class="card">
<h3>수익률</h3>
<div class="value" id="profit-rate">-</div>
</div>
</div>
<div class="section"> <div class="section">
<h2>매매 주문</h2> <h2>보유 종목</h2>
<div class="form-row"> <table>
<div class="form-group"> <thead>
<label>종목코드</label> <tr>
<input id="order-code" type="text" placeholder="005930" /> <th>종목코드</th><th>종목명</th><th>수량</th>
<th>평균단가</th><th>현재가</th><th>수익</th><th>수익률</th>
</tr>
</thead>
<tbody id="holdings-table"></tbody>
</table>
</div> </div>
<div class="form-group">
<label>구분</label> <div class="section">
<select id="order-side"> <h2>최근 매매 내역</h2>
<option value="buy">매수</option> <table>
<option value="sell">매도</option> <thead>
</select> <tr>
<th>시간</th><th>종목코드</th><th>구분</th>
<th>수량</th><th>가격</th><th>상태</th>
</tr>
</thead>
<tbody id="trades-table"></tbody>
</table>
</div> </div>
<div class="form-group">
<label>수량</label> <div class="section">
<input id="order-qty" type="number" value="1" min="1" /> <h2>매매 주문</h2>
<div class="form-row">
<div class="form-group">
<label>종목코드</label>
<input id="order-code" type="text" placeholder="005930" />
</div>
<div class="form-group">
<label>구분</label>
<select id="order-side">
<option value="buy">매수</option>
<option value="sell">매도</option>
</select>
</div>
<div class="form-group">
<label>수량</label>
<input id="order-qty" type="number" value="1" min="1" />
</div>
<div class="form-group">
<label>가격 (0=시장가)</label>
<input id="order-price" type="number" value="0" />
</div>
<button class="btn primary" onclick="placeOrder()">주문</button>
</div>
</div> </div>
<div class="form-group"> </div>
<label>가격 (0=시장가)</label>
<input id="order-price" type="number" value="0" /> <!-- 과거 기록 조회 페이지 -->
<div id="page-history" class="page">
<div class="section">
<h2>과거 매매 기록 조회 <span id="history-source" class="source-tag"></span></h2>
<div class="filter-row">
<div class="form-group">
<label>시작일</label>
<input id="hist-start" type="date" />
</div>
<div class="form-group">
<label>종료일</label>
<input id="hist-end" type="date" />
</div>
<div class="form-group">
<label>종목코드 (선택)</label>
<input id="hist-code" type="text" placeholder="005930" />
</div>
<button class="btn primary" onclick="loadTradeHistory()">조회</button>
</div>
<table>
<thead>
<tr>
<th>시간</th><th>종목코드</th><th>종목명</th><th>구분</th>
<th>수량</th><th>체결가</th><th>주문금액</th><th>상태</th>
</tr>
</thead>
<tbody id="history-table"></tbody>
</table>
<div id="history-empty" style="display:none; text-align:center; padding:24px; color:#8b949e;">
조회된 매매 기록이 없습니다.
</div>
</div> </div>
<button class="btn primary" onclick="placeOrder()">주문</button>
</div> </div>
</div> </div>
</div> </div>
@@ -136,34 +206,45 @@
<script> <script>
const API = ''; const API = '';
let ws; let ws;
let historyLoaded = false;
function formatPrice(n) { return n ? n.toLocaleString('ko-KR') + '원' : '-'; } function formatPrice(n) { return n ? n.toLocaleString('ko-KR') + '원' : '-'; }
function formatRate(n) { return n >= 0 ? '+' + n.toFixed(2) + '%' : n.toFixed(2) + '%'; } function formatRate(n) { return n >= 0 ? '+' + n.toFixed(2) + '%' : n.toFixed(2) + '%'; }
function formatDate(d) {
const dt = d ? new Date(d) : null;
if (!dt || isNaN(dt.getTime())) return '-';
return dt.toLocaleString('ko-KR');
}
document.querySelectorAll('.sidebar-nav a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('.sidebar-nav a').forEach(a => a.classList.remove('active'));
link.classList.add('active');
const page = link.dataset.page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.getElementById('page-title').textContent = link.textContent.trim();
if (page === 'trading') loadDashboard();
if (page === 'history' && !historyLoaded) { loadTradeHistory(); historyLoaded = true; }
});
});
async function loadDashboard() { async function loadDashboard() {
try { try {
const res = await fetch(API + '/api/dashboard/'); const res = await fetch(API + '/api/dashboard/');
const data = await res.json(); const data = await res.json();
if (!data.authenticated) document.getElementById('auth-alert').classList.add('show');
if (!data.authenticated) {
document.getElementById('auth-alert').classList.add('show');
}
const s = data.summary; const s = data.summary;
document.getElementById('total-invested').textContent = formatPrice(s.total_invested); document.getElementById('total-invested').textContent = formatPrice(s.total_invested);
document.getElementById('total-evaluated').textContent = formatPrice(s.total_evaluated); document.getElementById('total-evaluated').textContent = formatPrice(s.total_evaluated);
const profitEl = document.getElementById('total-profit'); const profitEl = document.getElementById('total-profit');
profitEl.textContent = formatPrice(s.total_profit); profitEl.textContent = formatPrice(s.total_profit);
profitEl.className = 'value ' + (s.total_profit >= 0 ? 'positive' : 'negative'); profitEl.className = 'value ' + (s.total_profit >= 0 ? 'positive' : 'negative');
const rateEl = document.getElementById('profit-rate'); const rateEl = document.getElementById('profit-rate');
rateEl.textContent = formatRate(s.profit_rate); rateEl.textContent = formatRate(s.profit_rate);
rateEl.className = 'value ' + (s.profit_rate >= 0 ? 'positive' : 'negative'); rateEl.className = 'value ' + (s.profit_rate >= 0 ? 'positive' : 'negative');
document.getElementById('holdings-table').innerHTML = data.holdings.map(h => `
const htb = document.getElementById('holdings-table');
htb.innerHTML = data.holdings.map(h => `
<tr> <tr>
<td>${h.stock_code}</td><td>${h.stock_name}</td><td>${h.qty}</td> <td>${h.stock_code}</td><td>${h.stock_name}</td><td>${h.qty}</td>
<td>${formatPrice(h.avg_price)}</td><td>${formatPrice(h.current_price)}</td> <td>${formatPrice(h.avg_price)}</td><td>${formatPrice(h.current_price)}</td>
@@ -171,9 +252,7 @@
<td class="${h.profit_rate >= 0 ? 'positive' : 'negative'}">${formatRate(h.profit_rate)}</td> <td class="${h.profit_rate >= 0 ? 'positive' : 'negative'}">${formatRate(h.profit_rate)}</td>
</tr> </tr>
`).join(''); `).join('');
document.getElementById('trades-table').innerHTML = data.recent_trades.map(t => `
const ttb = document.getElementById('trades-table');
ttb.innerHTML = data.recent_trades.map(t => `
<tr> <tr>
<td>${t.created_at ? new Date(t.created_at).toLocaleString('ko-KR') : '-'}</td> <td>${t.created_at ? new Date(t.created_at).toLocaleString('ko-KR') : '-'}</td>
<td>${t.stock_code}</td> <td>${t.stock_code}</td>
@@ -202,6 +281,58 @@
} catch (e) { alert('주문 오류: ' + e.message); } } catch (e) { alert('주문 오류: ' + e.message); }
} }
async function loadTradeHistory() {
const start = document.getElementById('hist-start').value.replace(/-/g, '');
const end = document.getElementById('hist-end').value.replace(/-/g, '');
const code = document.getElementById('hist-code').value.trim();
const params = new URLSearchParams();
if (start) params.set('start_date', start);
if (end) params.set('end_date', end);
if (code) params.set('stock_code', code);
params.set('limit', '200');
const sourceTag = document.getElementById('history-source');
const tbody = document.getElementById('history-table');
const emptyMsg = document.getElementById('history-empty');
try {
const res = await fetch(API + '/api/trading/history?' + params.toString());
const data = await res.json();
const trades = data.trades || [];
sourceTag.textContent = data.source === 'kis' ? 'KIS API' : '로컬 DB';
sourceTag.className = 'source-tag ' + data.source;
if (trades.length === 0) { tbody.innerHTML = ''; emptyMsg.style.display = 'block'; return; }
emptyMsg.style.display = 'none';
tbody.innerHTML = trades.map(t => {
const time = formatDate(t.created_at || t.filled_at || '');
const orderAmt = t.order_amount ? formatPrice(t.order_amount) : formatPrice(t.qty * t.price);
return `<tr>
<td>${time}</td>
<td>${t.stock_code}</td>
<td>${t.stock_name || '-'}</td>
<td><span class="badge ${t.side}">${t.side === 'buy' ? '매수' : '매도'}</span></td>
<td>${t.qty}</td>
<td>${formatPrice(t.price)}</td>
<td>${orderAmt}</td>
<td><span class="badge ${t.status}">${t.status === 'filled' ? '체결' : t.status}</span></td>
</tr>`;
}).join('');
} catch (e) {
console.error('매매 기록 조회 실패:', e);
tbody.innerHTML = '';
emptyMsg.style.display = 'block';
emptyMsg.textContent = '매매 기록을 불러오는 중 오류가 발생했습니다.';
}
}
function initHistoryDates() {
const today = new Date();
const monthAgo = new Date();
monthAgo.setDate(monthAgo.getDate() - 30);
document.getElementById('hist-end').value = today.toISOString().slice(0, 10);
document.getElementById('hist-start').value = monthAgo.toISOString().slice(0, 10);
}
function connectWS() { function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`); ws = new WebSocket(`${proto}://${location.host}/ws/realtime`);
@@ -223,6 +354,7 @@
loadDashboard(); loadDashboard();
setInterval(loadDashboard, 10000); setInterval(loadDashboard, 10000);
connectWS(); connectWS();
initHistoryDates();
</script> </script>
</body> </body>
</html> </html>

51
requirements.txt Normal file
View File

@@ -0,0 +1,51 @@
aiosqlite==0.22.1
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.14.2
APScheduler==3.11.3
certifi==2026.6.17
cffi==2.1.0
charset-normalizer==3.4.9
click==8.4.2
colorlog==6.10.1
cryptography==49.0.0
fastapi==0.139.2
greenlet==3.5.3
h11==0.16.0
httpcore==1.0.9
httptools==0.8.0
httpx==0.28.1
idna==3.18
iniconfig==2.3.0
Jinja2==3.1.6
MarkupSafe==3.0.3
numpy==2.5.1
packaging==26.2
pandas==3.0.3
pluggy==1.6.0
pycparser==3.0
pydantic==2.13.4
pydantic-settings==2.14.2
pydantic_core==2.46.4
Pygments==2.20.0
pytest==9.1.1
pytest-asyncio==1.4.0
python-dateutil==2.9.0.post0
python-dotenv==1.2.2
python-kis==2.1.6
PyYAML==6.0.3
requests==2.34.2
ruff==0.15.22
six==1.17.0
SQLAlchemy==2.0.51
starlette==1.3.1
ta==0.11.0
typing-inspection==0.4.2
typing_extensions==4.16.0
tzlocal==5.4.4
urllib3==2.7.0
uvicorn==0.51.0
uvloop==0.22.1
watchfiles==1.2.0
websocket-client==1.9.0
websockets==16.1