과거 거래 기록 조회 추가
좌측 사이드메뉴 추가 ggitignore 업데이트 : *.pyc
This commit is contained in:
16
.env
Normal file
16
.env
Normal 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
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
./.venv
|
./.venv
|
||||||
./app/__pycache__/
|
./app/__pycache__/
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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">⚙</span> 실거래
|
||||||
|
</a>
|
||||||
|
<a href="#" data-page="history">
|
||||||
|
<span class="icon">📋</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
51
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user