diff --git a/database/repositories.py b/database/repositories.py
index 1b57a5b6..89fa4d9b 100644
--- a/database/repositories.py
+++ b/database/repositories.py
@@ -834,6 +834,30 @@ def get_cash_flows(account_key: str = "") -> list:
session.close()
+@with_retry
+def get_recent_cash_flows(account_key: str = "", limit: int = 12) -> list:
+ """최근 입금/출금 내역 [{occurred_at, amount, note}...] 최신순 — 대시보드 표시용."""
+ session = get_session()
+ try:
+ rows = (
+ session.query(CashFlow)
+ .filter(CashFlow.account_key == (account_key or ""))
+ .order_by(CashFlow.occurred_at.desc())
+ .limit(int(limit))
+ .all()
+ )
+ return [
+ {
+ "occurred_at": str(r.occurred_at)[:16],
+ "amount": float(r.amount or 0),
+ "note": r.note or "",
+ }
+ for r in rows
+ ]
+ finally:
+ session.close()
+
+
@with_retry
def get_cash_flow_total(
account_key: str = "",
diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py
index 5879bbf2..dd3e0e0b 100644
--- a/monitoring/web_dashboard.py
+++ b/monitoring/web_dashboard.py
@@ -228,333 +228,337 @@ def get_runtime_json() -> dict:
def _html_page() -> str:
- """대시보드 단일 페이지 HTML (인라인 CSS/JS, Chart.js CDN)"""
+ """대시보드 단일 페이지 HTML — 2026-07 UI 개편(벤토 그리드·다크 글래스·Pretendard).
+
+ 원칙: ① 내 돈(바스켓 트랙)이 첫 화면 ② 웹의 쓰기 권한은 '입금 기록' 하나
+ (매매·설정 변경은 웹에 두지 않는다) ③ 폴링 경로에 네트워크 조회 없음(DB 전용 API).
+ """
return """
- 퀀트 트레이더 대시보드
+ 퀀트 트레이더
+
- 📊 퀀트 트레이더 대시보드
- 마지막 갱신: - · 자동 갱신 중 (10초 간격)
+
+
+
퀀트 트레이더paper 운영
+
갱신 -
+
+
+
+
- 시장 국면 · 리스크 · API · 루프
-
-
+ 내 자산
+
- 바스켓 트랙 — 내 돈 화면
-
+ 수익률 추이
+
- 바스켓 paper 승격 진행률
-
-
+ 승격 진행률 60영업일 트랙레코드
+
- 웹소켓 갭 모니터링
-
-
-
- | 끊김 시각 | 재연결 시각 | 갭(초) | 영향 종목 | REST 보충 | 관측 변동률 | 블랙스완 쿨다운 |
-
-
-
- 갭 이벤트 없음
- 웹소켓 정보 없음 (N/A)
+ 운영 상태
+
+
- 오늘 발생 신호
-
-
+
오늘 신호
+
-
기록된 신호 없음 (스케줄러 장전 분석 이후 누적)
-
조회 불가
-
+
+ 웹소켓 갭 · 레거시 기본 계정
+
+
+
+
+ | 끊김 | 재연결 | 갭(초) | 영향 종목 | REST 보충 | 블랙스완 |
+
+
+
+
갭 이벤트 없음
+
웹소켓 정보 없음
+
+
+
보유 종목 없음
+
+
-
- 보유 포지션
-
+
+
+
+
+
적립 입금 기록
+
paper는 기록 = 입금. 입금은 수익률(TWR)이 중화하므로 성과가 왜곡되지 않습니다. 다음 사이클이 새 현금을 흡수합니다.
+
+
+
+
+
+
+
-
보유 종목 없음
-
+
-
- 수익률 추이 (최근 30일) · 계정:
-
-
+
"""
@@ -663,6 +771,53 @@ async def handle_api_baskets(_request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)
+async def handle_api_deposit(request: web.Request) -> web.Response:
+ """적립 입금 기록 (POST {basket, amount, note?}) — CLI와 동일한 단일 검증 경로.
+
+ 웹에서 가능한 쓰기는 이것 하나다(기록·조회까지가 웹의 권한 — 매매·설정 변경은
+ 웹에 두지 않는다). occurred_at은 서버 시각 고정이라 소급 조작이 불가능하고,
+ 금액 양수·바스켓 존재·TWR 체인 보호(마지막 스냅샷 이후) 검증은 공유 함수가 한다.
+ """
+ try:
+ body = await request.json()
+ except Exception:
+ return web.json_response({"ok": False, "error": "JSON 본문이 필요합니다"}, status=400)
+ try:
+ from tools.record_deposit import record_basket_deposit
+
+ result = record_basket_deposit(
+ str(body.get("basket") or ""),
+ body.get("amount"),
+ note=str(body.get("note") or ""),
+ )
+ if not result.get("ok"):
+ return web.json_response(result, status=400)
+ logger.info(
+ "웹 입금 기록: {} +{:,.0f}원 (누적 입금 {:,.0f}원)",
+ result["account_key"], result["amount"], result["deposits_total"],
+ )
+ return web.json_response(result)
+ except Exception as e:
+ logger.exception("API /api/deposit 오류: {}", e)
+ return web.json_response({"ok": False, "error": str(e)}, status=500)
+
+
+async def handle_api_cash_flows(request: web.Request) -> web.Response:
+ """바스켓 입금 내역 (GET ?basket=) — 최근 12건."""
+ try:
+ from core.basket_rebalancer import rebalance_live_strategy_id
+ from database.repositories import get_recent_cash_flows
+
+ basket = request.query.get("basket") or ""
+ if not basket:
+ return web.json_response({"error": "basket 파라미터 필요"}, status=400)
+ key = rebalance_live_strategy_id(basket)
+ return web.json_response({"basket": basket, "flows": get_recent_cash_flows(key)})
+ except Exception as e:
+ logger.exception("API /api/cash_flows 오류: {}", e)
+ return web.json_response({"error": str(e)}, status=500)
+
+
async def handle_api_runtime(_request: web.Request) -> web.Response:
try:
return web.json_response(get_runtime_json())
@@ -739,6 +894,8 @@ def create_app() -> web.Application:
app.router.add_get("/api/runtime", handle_api_runtime)
app.router.add_get("/api/snapshots", handle_api_snapshots)
app.router.add_get("/api/baskets", handle_api_baskets)
+ app.router.add_post("/api/deposit", handle_api_deposit)
+ app.router.add_get("/api/cash_flows", handle_api_cash_flows)
app.router.add_get("/api/basket_evaluation", handle_api_basket_evaluation)
return app
diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py
index 3077bc30..4c120ed4 100644
--- a/tests/test_dashboard_baskets.py
+++ b/tests/test_dashboard_baskets.py
@@ -154,4 +154,109 @@ def test_html_page_contains_basket_tracks_section():
html = _html_page()
assert "basketTracks" in html # 섹션
assert "/api/baskets" in html # 폴링 대상
- assert "chartAccount" in html # 차트 계정 선택기
\ No newline at end of file
+ assert "chartAccount" in html # 차트 계정 선택기
+ assert "/api/deposit" in html # 웹 입금 폼
+ assert "depositOverlay" in html # 입금 모달
+
+
+@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
+class TestDepositEndpoint:
+ """POST /api/deposit — 웹의 유일한 쓰기. CLI와 동일한 검증 경로 계약."""
+
+ def _serve(self, coro):
+ import asyncio
+ asyncio.run(coro)
+
+ def test_deposit_records_and_returns_totals(self):
+ import asyncio
+ from aiohttp.test_utils import TestClient, TestServer
+ from monitoring import web_dashboard as wd
+ from database.repositories import get_cash_flow_total
+
+ name = "kr_pocket_dep"
+ init_database()
+
+ async def run():
+ with patch(
+ "core.basket_rebalancer.BasketRebalancer._load_baskets_config",
+ return_value=_cfg(name),
+ ):
+ app = wd.create_app()
+ client = TestClient(TestServer(app))
+ await client.start_server()
+ try:
+ res = await client.post(
+ "/api/deposit",
+ json={"basket": name, "amount": 100000, "note": "웹 테스트"},
+ )
+ assert res.status == 200
+ data = await res.json()
+ finally:
+ await client.close()
+ assert data["ok"] is True
+ assert data["deposits_total"] == 100000
+ assert data["principal"] == 400000 # 초기 30만 + 입금 10만
+
+ asyncio.run(run())
+ from core.basket_rebalancer import rebalance_live_strategy_id
+ assert get_cash_flow_total(
+ account_key=rebalance_live_strategy_id(name)
+ ) == pytest.approx(100_000)
+
+ def test_deposit_rejects_bad_amount_and_unknown_basket(self):
+ import asyncio
+ from aiohttp.test_utils import TestClient, TestServer
+ from monitoring import web_dashboard as wd
+
+ init_database()
+
+ async def run():
+ with patch(
+ "core.basket_rebalancer.BasketRebalancer._load_baskets_config",
+ return_value=_cfg("kr_pocket_dep2"),
+ ):
+ app = wd.create_app()
+ client = TestClient(TestServer(app))
+ await client.start_server()
+ try:
+ r1 = await client.post("/api/deposit", json={"basket": "kr_pocket_dep2", "amount": 0})
+ r2 = await client.post("/api/deposit", json={"basket": "no_such", "amount": 1000})
+ r3 = await client.post("/api/deposit", data=b"not-json")
+ assert r1.status == 400 and (await r1.json())["ok"] is False
+ assert r2.status == 400 and (await r2.json())["ok"] is False
+ assert r3.status == 400
+ finally:
+ await client.close()
+
+ asyncio.run(run())
+
+
+@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
+def test_cash_flows_endpoint_lists_recent():
+ import asyncio
+ from aiohttp.test_utils import TestClient, TestServer
+ from monitoring import web_dashboard as wd
+ from database.repositories import record_cash_flow
+ from core.basket_rebalancer import rebalance_live_strategy_id
+
+ name = "kr_pocket_flows"
+ init_database()
+ record_cash_flow(
+ 100_000, account_key=rebalance_live_strategy_id(name),
+ occurred_at=datetime(2026, 7, 6, 9, 0), note="7월 적립",
+ )
+
+ async def run():
+ app = wd.create_app()
+ client = TestClient(TestServer(app))
+ await client.start_server()
+ try:
+ res = await client.get("/api/cash_flows?basket=" + name)
+ assert res.status == 200
+ data = await res.json()
+ finally:
+ await client.close()
+ assert data["flows"][0]["amount"] == 100000
+ assert data["flows"][0]["note"] == "7월 적립"
+
+ asyncio.run(run())
\ No newline at end of file
diff --git a/tools/record_deposit.py b/tools/record_deposit.py
index c0154b02..72c0a156 100644
--- a/tools/record_deposit.py
+++ b/tools/record_deposit.py
@@ -19,6 +19,59 @@
from loguru import logger # noqa: E402
+def record_basket_deposit(basket_name: str, amount: float, note: str = "") -> dict:
+ """바스켓 적립 입금 기록 — CLI와 웹 대시보드가 공유하는 단일 검증·기록 경로.
+
+ 검증: 금액 양수, 바스켓 존재, 과거 소급 금지(마지막 스냅샷 이후만 — TWR 체인 보호).
+ occurred_at은 항상 now(서버 시각) — 웹에서 소급 조작 불가.
+ 반환: {ok, error?, account_key, flow_id?, deposits_total?, principal?}
+ """
+ from config.config_loader import Config
+ from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id
+ from database.models import init_database
+ from database.repositories import (
+ get_cash_flow_total,
+ get_latest_snapshot_summary,
+ record_cash_flow,
+ )
+
+ try:
+ amount = float(amount)
+ except (TypeError, ValueError):
+ return {"ok": False, "error": "금액이 숫자가 아닙니다"}
+ if amount <= 0:
+ return {"ok": False, "error": "입금액은 양수여야 합니다"}
+
+ init_database()
+ config = Config.get()
+ baskets = BasketRebalancer._load_baskets_config()
+ if basket_name not in baskets:
+ return {"ok": False, "error": f"바스켓 '{basket_name}' 설정 없음"}
+ account_key = rebalance_live_strategy_id(basket_name)
+ basket_capital = (baskets.get(basket_name) or {}).get("initial_capital")
+
+ now = datetime.now()
+ prev = get_latest_snapshot_summary(account_key=account_key)
+ if prev is not None:
+ boundary = prev.get("created_at") or prev.get("date")
+ if boundary is not None and now <= boundary:
+ return {"ok": False, "error": "마지막 스냅샷 이전 시각 — 과거 소급 기록 불가"}
+
+ mode = str(config.trading.get("mode", "paper")).lower()
+ flow_id = record_cash_flow(
+ amount=amount, account_key=account_key, occurred_at=now,
+ note=note or "", mode=mode,
+ )
+ deposits = get_cash_flow_total(account_key=account_key)
+ out = {
+ "ok": True, "account_key": account_key, "flow_id": int(flow_id),
+ "amount": amount, "deposits_total": float(deposits),
+ }
+ if basket_capital is not None:
+ out["principal"] = float(basket_capital) + float(deposits)
+ return out
+
+
def main() -> int:
parser = argparse.ArgumentParser(description="적립 입금 기록 (paper: 기록=입금, live: 실입금 후 기록)")
group = parser.add_mutually_exclusive_group(required=True)
@@ -28,32 +81,42 @@ def main() -> int:
parser.add_argument("--note", type=str, default="", help="메모 (예: 7월 적립)")
args = parser.parse_args()
+ if args.basket:
+ # 웹 대시보드와 동일한 단일 검증·기록 경로.
+ result = record_basket_deposit(args.basket, args.amount, note=args.note)
+ if not result.get("ok"):
+ logger.error("입금 기록 실패: {}", result.get("error"))
+ return 1
+ if result.get("principal") is not None:
+ logger.info(
+ "입금 기록 완료 (id={}): {} +{:,.0f}원 — 누적 입금 {:,.0f}원, 누적 원금 {:,.0f}원",
+ result["flow_id"], result["account_key"], args.amount,
+ result["deposits_total"], result["principal"],
+ )
+ else:
+ logger.info(
+ "입금 기록 완료 (id={}): {} +{:,.0f}원 — 누적 입금 {:,.0f}원",
+ result["flow_id"], result["account_key"], args.amount, result["deposits_total"],
+ )
+ logger.info("다음 리밸런싱 사이클이 새 현금을 목표 비중대로 흡수합니다 (회전 상한 내).")
+ return 0
+
+ # --account-key 직접 지정 경로 (바스켓 문맥 없음 — 원금 표기 생략)
if args.amount <= 0:
logger.error("입금액은 양수여야 합니다: {}", args.amount)
return 1
- from config.config_loader import Config
from database.models import init_database
from database.repositories import (
get_cash_flow_total,
get_latest_snapshot_summary,
record_cash_flow,
)
+ from config.config_loader import Config
init_database()
config = Config.get()
-
- if args.basket:
- from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id
- baskets = BasketRebalancer._load_baskets_config()
- if args.basket not in baskets:
- logger.error("바스켓 '{}' 설정 없음 (config/baskets.yaml)", args.basket)
- return 1
- account_key = rebalance_live_strategy_id(args.basket)
- basket_capital = (baskets.get(args.basket) or {}).get("initial_capital")
- else:
- account_key = args.account_key
- basket_capital = None
+ account_key = args.account_key
now = datetime.now()
# 과거 소급 금지: 마지막 스냅샷 이전 시각의 입금은 TWR 체인이 중화하지 못해
@@ -70,25 +133,14 @@ def main() -> int:
mode = str(config.trading.get("mode", "paper")).lower()
flow_id = record_cash_flow(
- amount=float(args.amount),
- account_key=account_key,
- occurred_at=now,
- note=args.note,
- mode=mode,
+ amount=float(args.amount), account_key=account_key,
+ occurred_at=now, note=args.note, mode=mode,
)
-
deposits = get_cash_flow_total(account_key=account_key)
- if basket_capital is not None:
- # 바스켓 문맥이 있어야 초기자본을 안다 — 원금(초기+입금)까지 표기.
- logger.info(
- "입금 기록 완료 (id={}): {} +{:,.0f}원 — 누적 입금 {:,.0f}원, 누적 원금 {:,.0f}원",
- flow_id, account_key, args.amount, deposits, float(basket_capital) + deposits,
- )
- else:
- logger.info(
- "입금 기록 완료 (id={}): {} +{:,.0f}원 — 누적 입금 {:,.0f}원",
- flow_id, account_key, args.amount, deposits,
- )
+ logger.info(
+ "입금 기록 완료 (id={}): {} +{:,.0f}원 — 누적 입금 {:,.0f}원",
+ flow_id, account_key, args.amount, deposits,
+ )
logger.info("다음 리밸런싱 사이클이 새 현금을 목표 비중대로 흡수합니다 (회전 상한 내).")
return 0