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영업일 트랙레코드

+
-

웹소켓 갭 모니터링

-
- - - +

운영 상태

+
+

-

오늘 발생 신호

-
- - - -
시각종목신호점수출처
+

오늘 신호

+
+
+ + + +
시각종목신호점수출처
+
+ +
- -
-
+
+ 웹소켓 갭 · 레거시 기본 계정 +
+
+ + + +
+
+ + + +
종목수량평균가현재가평가액수익률
+
+ +
+
-
-

보유 포지션

-
- - - -
종목수량평균가현재가평가액수익률
+
+ + +
+ - -
+
-
-

수익률 추이 (최근 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