From 483963b463f5c7961c8f71bf643deffb2a26a9a9 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 7 Mar 2026 03:44:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=82=B0=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=9A=A8=EC=9C=A8=20=EC=A6=9D=EA=B0=80=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/db.py | 42 ++++++++++++++++++ stock-lab/app/holidays.json | 18 ++++++++ stock-lab/app/main.py | 87 ++++++++++++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 stock-lab/app/holidays.json diff --git a/stock-lab/app/db.py b/stock-lab/app/db.py index 9cadc33..cdb0ed6 100644 --- a/stock-lab/app/db.py +++ b/stock-lab/app/db.py @@ -55,6 +55,17 @@ def init_db(): ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS asset_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT UNIQUE NOT NULL, + total_eval INTEGER NOT NULL, + total_cash INTEGER NOT NULL, + total_assets INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now','localtime')) + ) + """) + def save_articles(articles: List[Dict[str, str]]) -> int: count = 0 with _conn() as conn: @@ -159,3 +170,34 @@ def delete_broker_cash(broker: str) -> bool: with _conn() as conn: cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,)) return cur.rowcount > 0 + + +# --- Asset Snapshot CRUD --- + +def upsert_asset_snapshot(date: str, total_eval: int, total_cash: int, total_assets: int) -> None: + now = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with _conn() as conn: + conn.execute(""" + INSERT INTO asset_snapshots (date, total_eval, total_cash, total_assets, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(date) DO UPDATE SET + total_eval = excluded.total_eval, + total_cash = excluded.total_cash, + total_assets = excluded.total_assets, + created_at = excluded.created_at + """, (date, total_eval, total_cash, total_assets, now)) + + +def get_asset_snapshots(days: int = 30) -> List[Dict[str, Any]]: + with _conn() as conn: + if days == 0: + rows = conn.execute( + "SELECT date, total_eval, total_cash, total_assets FROM asset_snapshots ORDER BY date ASC" + ).fetchall() + else: + rows = conn.execute( + "SELECT date, total_eval, total_cash, total_assets FROM asset_snapshots ORDER BY date DESC LIMIT ?", + (days,) + ).fetchall() + rows = list(reversed(rows)) + return [dict(r) for r in rows] diff --git a/stock-lab/app/holidays.json b/stock-lab/app/holidays.json new file mode 100644 index 0000000..3d923ed --- /dev/null +++ b/stock-lab/app/holidays.json @@ -0,0 +1,18 @@ +[ + "2026-01-01", + "2026-01-28", + "2026-01-29", + "2026-01-30", + "2026-03-01", + "2026-05-05", + "2026-05-25", + "2026-06-06", + "2026-08-15", + "2026-09-24", + "2026-09-25", + "2026-09-26", + "2026-10-03", + "2026-10-09", + "2026-12-25", + "2026-12-31" +] diff --git a/stock-lab/app/main.py b/stock-lab/app/main.py index a8227d4..ccee92f 100644 --- a/stock-lab/app/main.py +++ b/stock-lab/app/main.py @@ -1,7 +1,8 @@ import os import json +from datetime import date as date_type from typing import Optional -from fastapi import FastAPI +from fastapi import FastAPI, Query from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware import requests @@ -13,6 +14,7 @@ from .db import ( add_portfolio_item, get_all_portfolio, get_portfolio_item, update_portfolio_item, delete_portfolio_item, upsert_broker_cash, get_all_broker_cash, get_broker_cash, delete_broker_cash, + upsert_asset_snapshot, get_asset_snapshots, ) from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news from .price_fetcher import get_current_prices @@ -33,12 +35,52 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) # Windows AI Server URL (NAS .env에서 설정) WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000") +# 공휴일 목록 로드 +_HOLIDAYS_PATH = os.path.join(os.path.dirname(__file__), "holidays.json") +try: + with open(_HOLIDAYS_PATH, "r") as f: + _HOLIDAYS: set = set(json.load(f)) +except Exception: + _HOLIDAYS = set() + +def is_market_open(d: date_type) -> bool: + return d.weekday() < 5 and d.strftime("%Y-%m-%d") not in _HOLIDAYS + + +def save_daily_snapshot(): + today = date_type.today() + if not is_market_open(today): + print(f"[Snapshot] {today} 휴장일 — 스킵") + return + + today_str = today.strftime("%Y-%m-%d") + items = get_all_portfolio() + cash_rows = get_all_broker_cash() + total_cash = sum(r["cash"] for r in cash_rows) + + if items: + tickers = list({item["ticker"] for item in items}) + prices = get_current_prices(tickers) + total_eval = sum( + prices.get(item["ticker"], item["avg_price"]) * item["quantity"] + for item in items + ) + else: + total_eval = 0 + + total_assets = total_eval + total_cash + upsert_asset_snapshot(today_str, total_eval, total_cash, total_assets) + print(f"[Snapshot] {today_str} 저장 완료: eval={total_eval}, cash={total_cash}, total={total_assets}") + @app.on_event("startup") def on_startup(): init_db() # 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행) scheduler.add_job(run_scraping_job, "cron", hour="8", minute="0") + + # 평일 15:40 총 자산 스냅샷 저장 + scheduler.add_job(save_daily_snapshot, "cron", day_of_week="mon-fri", hour=15, minute=40) # 앱 시작 시에도 한 번 실행 (데이터 없으면) if not get_latest_articles(1): @@ -276,3 +318,46 @@ def delete_portfolio(item_id: int): return {"ok": True} +# --- Asset Snapshot API --- + +@app.post("/api/portfolio/snapshot") +def create_snapshot(): + """총 자산 스냅샷 수동 저장 (오늘 날짜 기준)""" + today = date_type.today() + today_str = today.strftime("%Y-%m-%d") + + items = get_all_portfolio() + cash_rows = get_all_broker_cash() + total_cash = sum(r["cash"] for r in cash_rows) + + if items: + tickers = list({item["ticker"] for item in items}) + prices = get_current_prices(tickers) + total_eval = sum( + prices.get(item["ticker"], item["avg_price"]) * item["quantity"] + for item in items + ) + else: + total_eval = 0 + + total_assets = total_eval + total_cash + upsert_asset_snapshot(today_str, total_eval, total_cash, total_assets) + + return { + "ok": True, + "snapshot": { + "date": today_str, + "total_eval": total_eval, + "total_cash": total_cash, + "total_assets": total_assets, + }, + } + + +@app.get("/api/portfolio/snapshot/history") +def get_snapshot_history(days: int = Query(30, ge=0)): + """총 자산 스냅샷 이력 조회 (days=0: 전체, days=N: 최근 N일)""" + snapshots = get_asset_snapshots(days) + return {"snapshots": snapshots} + +