diff --git a/backend/app/api/endpoints/analysis.py b/backend/app/api/endpoints/analysis.py index c3ede59..d15f531 100644 --- a/backend/app/api/endpoints/analysis.py +++ b/backend/app/api/endpoints/analysis.py @@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db, AsyncSessionLocal from app.models.models import StrategyOptimizationLog, TradeJournal +from app.core.timeutil import now_kst from app.services.trade_analyzer import ( analyze_performance, build_insights, @@ -71,7 +72,7 @@ async def get_insights( "performance_summary": perf.get("summary", {}), "last_optimization_date": last_opt_date.isoformat() if last_opt_date else None, "period_days": days, - "generated_at": datetime.utcnow().isoformat(), + "generated_at": now_kst().isoformat(), } except Exception as e: logger.error(f"insights 생성 실패: {e}", exc_info=True) diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 7888fef..6000d08 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -42,6 +42,7 @@ from app.core.security import ( verify_session_token, ) from app.models.models import User +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) router = APIRouter() @@ -246,7 +247,7 @@ async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends ) _failures.pop(ip, None) - user.last_login_at = datetime.utcnow() + user.last_login_at = now_kst() await db.commit() token, expires_at = create_session_token(user.username) diff --git a/backend/app/api/endpoints/kis_credentials.py b/backend/app/api/endpoints/kis_credentials.py index b99a032..6e6b9c9 100644 --- a/backend/app/api/endpoints/kis_credentials.py +++ b/backend/app/api/endpoints/kis_credentials.py @@ -27,6 +27,7 @@ from app.core.tenant_auth import CurrentUser, get_current_user from app.models.models import UserKisCredential from app.services.korea_investment_api import KoreaInvestmentAPI from app.services.tenant.registry import user_context_registry +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) router = APIRouter() @@ -172,7 +173,7 @@ async def put_credentials( if dup.scalar_one_or_none() is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="이미 다른 사용자가 등록한 앱키입니다.") - now = datetime.utcnow() + now = now_kst() cred = await _get_own(db, user_id) is_new = cred is None if is_new: @@ -278,7 +279,7 @@ async def verify_credentials( "stock_value": _pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"]), } - now = datetime.utcnow() + now = now_kst() cred.last_verified_at = now cred.is_enabled = True cred.updated_at = now @@ -306,7 +307,7 @@ async def patch_auto_trade( ) cred.auto_trade_enabled = body.enabled - cred.updated_at = datetime.utcnow() + cred.updated_at = now_kst() await db.commit() # H3: 캐시된 UserContext.auto_trade_enabled 스냅샷이 갱신되도록 무효화(다음 호출에서 재조립). await user_context_registry.invalidate(user_id) diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 7da8f13..dd19a8c 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -29,6 +29,7 @@ from app.services.utils import _is_domestic import asyncio import logging from datetime import datetime, timedelta +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -1428,7 +1429,7 @@ async def _create_journal_background( "trade_id": trade_id, "stock_code": stock_code, "stock_name": stock_name, - "journal_date": datetime.utcnow(), + "journal_date": now_kst(), "content": content, "strategy_name": "AUTO_TRADE", "mood": mood, @@ -1699,7 +1700,7 @@ async def update_active_trade_status( # 상태 업데이트 active_trade.status = new_status - active_trade.updated_at = datetime.fromisoformat(request.updated_at) if request.updated_at else datetime.utcnow() + active_trade.updated_at = datetime.fromisoformat(request.updated_at) if request.updated_at else now_kst() # 부분 매도 시 잔여 비율 갱신 (CLAUDE.md 5.2: +2R 50% 분할 매도 후 잔량 추적 유지) if request.remaining_pct is not None: diff --git a/backend/app/api/endpoints/risk_management.py b/backend/app/api/endpoints/risk_management.py index d4ad9f0..adcf1a2 100644 --- a/backend/app/api/endpoints/risk_management.py +++ b/backend/app/api/endpoints/risk_management.py @@ -28,6 +28,7 @@ def _today_kst() -> date: from app.core.config import settings from app.core.security import require_api_key from app.models.models import TradeHistory, TradingSession, Portfolio +from app.core.timeutil import now_kst router = APIRouter() logger = logging.getLogger(__name__) @@ -54,7 +55,7 @@ async def get_risk_metrics( - 현재 계좌 상태 """ try: - since_date = datetime.utcnow() - timedelta(days=days) + since_date = now_kst() - timedelta(days=days) # 매도 완료된 거래만 조회 (실현 손익) result = await db.execute( @@ -149,7 +150,7 @@ async def get_sharpe_ratio( - < 1.0: 개선 필요 """ try: - since_date = datetime.utcnow() - timedelta(days=days) + since_date = now_kst() - timedelta(days=days) result = await db.execute( select(TradeHistory) @@ -443,7 +444,7 @@ async def get_win_rate( 📊 승률 및 손익 통계 """ try: - since_date = datetime.utcnow() - timedelta(days=days) + since_date = now_kst() - timedelta(days=days) result = await db.execute( select(TradeHistory) diff --git a/backend/app/api/endpoints/trades.py b/backend/app/api/endpoints/trades.py index 37396e9..9086652 100644 --- a/backend/app/api/endpoints/trades.py +++ b/backend/app/api/endpoints/trades.py @@ -17,6 +17,7 @@ import re from app.core.database import get_db from app.models.models import TradeHistory from pydantic import BaseModel +from app.core.timeutil import now_kst router = APIRouter() logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ async def get_trade_history( query = select(TradeHistory) # 날짜 필터 - since_date = datetime.utcnow() - timedelta(days=days) + since_date = now_kst() - timedelta(days=days) query = query.where(TradeHistory.created_at >= since_date) # 종목 코드 필터 @@ -158,7 +159,7 @@ async def get_trade_stats( db: AsyncSession = Depends(get_db) ): """거래 통계 조회""" - since_date = datetime.utcnow() - timedelta(days=days) + since_date = now_kst() - timedelta(days=days) result = await db.execute( select(TradeHistory).where(TradeHistory.created_at >= since_date) diff --git a/backend/app/api/endpoints/trades_v2.py b/backend/app/api/endpoints/trades_v2.py index e89fec2..87f50e0 100644 --- a/backend/app/api/endpoints/trades_v2.py +++ b/backend/app/api/endpoints/trades_v2.py @@ -22,6 +22,7 @@ from app.core.tenant_auth import CurrentUser, get_current_user from app.models.models import TradeHistory from app.api.endpoints.portfolio_v2 import _resolve_target_user_id, _user_scope_filter from app.api.endpoints.trades import TradeHistoryResponse +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) router = APIRouter() @@ -44,7 +45,7 @@ async def get_trade_history_v2( query = select(TradeHistory).where(_user_scope_filter(TradeHistory.user_id, target_user_id)) - since_date = datetime.utcnow() - timedelta(days=days) + since_date = now_kst() - timedelta(days=days) query = query.where(TradeHistory.created_at >= since_date) if stock_code: diff --git a/backend/app/api/endpoints/trading_rules.py b/backend/app/api/endpoints/trading_rules.py index 92a7d1e..d3c0e9f 100644 --- a/backend/app/api/endpoints/trading_rules.py +++ b/backend/app/api/endpoints/trading_rules.py @@ -18,6 +18,7 @@ from app.core.database import get_db from app.core.security import require_api_key from app.models.models import TradingRule from pydantic import BaseModel +from app.core.timeutil import now_kst router = APIRouter() @@ -118,7 +119,7 @@ async def update_trading_rule( for field, value in update_data.items(): setattr(db_rule, field, value) - db_rule.updated_at = datetime.utcnow() + db_rule.updated_at = now_kst() await db.commit() await db.refresh(db_rule) return db_rule @@ -148,7 +149,7 @@ async def toggle_trading_rule(rule_id: int, db: AsyncSession = Depends(get_db)): raise HTTPException(status_code=404, detail="Trading rule not found") db_rule.is_active = not db_rule.is_active - db_rule.updated_at = datetime.utcnow() + db_rule.updated_at = now_kst() await db.commit() await db.refresh(db_rule) return db_rule diff --git a/backend/app/core/timeutil.py b/backend/app/core/timeutil.py new file mode 100644 index 0000000..5cfe625 --- /dev/null +++ b/backend/app/core/timeutil.py @@ -0,0 +1,26 @@ +"""timeutil — 애플리케이션 전역 시각 기준. + +[2026-09-23] DB 타임스탬프가 UTC/KST 로 섞여 있었다. + - 모델 기본값(`default=datetime.utcnow`)으로 저장된 행 → UTC + - `_get_kst_now()` 를 명시한 경로(수동 매매 등) → KST +같은 테이블 안에서 9시간 차이가 나 화면의 매매 시각이 뒤죽박죽이었고, +`now_kst()` 로 만든 조회 구간과 비교하는 로직도 9시간씩 어긋났다 +(예: 추가매수 쿨다운이 항상 발동). + +국내 증시만 다루고 모든 비교가 KST 거래일 기준이므로 **KST naive** 로 통일한다. +""" +from datetime import date, datetime + +import pytz + +KST = pytz.timezone("Asia/Seoul") + + +def now_kst() -> datetime: + """현재 한국 시간 (timezone-naive) — DB 저장·비교의 유일한 기준.""" + return datetime.now(KST).replace(tzinfo=None) + + +def today_kst() -> date: + """오늘 날짜 (KST).""" + return now_kst().date() diff --git a/backend/app/models/models.py b/backend/app/models/models.py index d47df66..037b9e9 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -17,6 +17,7 @@ from typing import Optional, List from sqlalchemy import Column, Integer, BigInteger, String, Numeric, Boolean, DateTime, Text, ARRAY, Date, ForeignKey, UniqueConstraint from sqlalchemy.dialects.postgresql import JSONB from app.core.database import Base +from app.core.timeutil import now_kst class StockDailyPrice(Base): @@ -57,8 +58,8 @@ class TradingSession(Base): starting_balance = Column(Numeric(15, 2)) ending_balance = Column(Numeric(15, 2)) peak_balance = Column(Numeric(15, 2)) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=now_kst) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class TradingRule(Base): @@ -72,8 +73,8 @@ class TradingRule(Base): actions = Column(JSONB, nullable=False) is_active = Column(Boolean, default=True) priority = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=now_kst) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class StrategyConfig(Base): @@ -118,8 +119,8 @@ class StrategyConfig(Base): is_active = Column(Boolean, default=True, nullable=False) # False면 기본값 사용 updated_by = Column(String(100), nullable=True) # 마지막 수정자 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=now_kst) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class TradeHistory(Base): @@ -153,7 +154,7 @@ class TradeHistory(Base): currency_code = Column(String(3), nullable=False, default="KRW") exchange_code = Column(String(10), nullable=True) fx_rate = Column(Numeric(10, 4), nullable=True) # 체결 시점 환율 - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=now_kst, index=True) executed_at = Column(DateTime) @@ -175,7 +176,7 @@ class Portfolio(Base): strategy_type = Column(String(20), nullable=False, default="swing", index=True) currency_code = Column(String(3), nullable=False, default="KRW") exchange_code = Column(String(10), nullable=True) # NASD, NYSE, AMEX, KRX - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class MarketData(Base): @@ -189,7 +190,7 @@ class MarketData(Base): high_price = Column(Numeric(15, 2)) low_price = Column(Numeric(15, 2)) open_price = Column(Numeric(15, 2)) - timestamp = Column(DateTime, default=datetime.utcnow, index=True) + timestamp = Column(DateTime, default=now_kst, index=True) class News(Base): @@ -204,7 +205,7 @@ class News(Base): sentiment = Column(String(20)) # positive / negative / neutral sentiment_score = Column(Numeric(5, 3), nullable=True) # -1.000 ~ +1.000 published_at = Column(DateTime, index=True) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=now_kst) class ClaudeAnalysisLog(Base): @@ -219,7 +220,7 @@ class ClaudeAnalysisLog(Base): analysis_result = Column(JSONB) decision = Column(String(50)) confidence = Column(Numeric(5, 4)) - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=now_kst, index=True) class MCPToolCall(Base): @@ -232,7 +233,7 @@ class MCPToolCall(Base): result = Column(JSONB) status = Column(String(50)) execution_time_ms = Column(Integer) - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=now_kst, index=True) class StockAnalysis(Base): @@ -265,8 +266,8 @@ class StockAnalysis(Base): ml_score = Column(Integer, nullable=True) ml_confidence = Column(Numeric(5, 4), nullable=True) ml_signal = Column(String(10), nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=now_kst) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class ActiveTrade(Base): @@ -293,8 +294,8 @@ class ActiveTrade(Base): strategy_name = Column(String(100), nullable=True) currency_code = Column(String(3), nullable=False, default="KRW") exchange_code = Column(String(10), nullable=True) # NASD, NYSE, AMEX, KRX - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=now_kst) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class ExchangeRate(Base): @@ -305,7 +306,7 @@ class ExchangeRate(Base): currency_pair = Column(String(7), nullable=False, index=True) # USD/KRW rate = Column(Numeric(12, 4), nullable=False) source = Column(String(50), default="KIS") - recorded_at = Column(DateTime, default=datetime.utcnow, index=True) + recorded_at = Column(DateTime, default=now_kst, index=True) class UsStockUniverse(Base): @@ -322,7 +323,7 @@ class UsStockUniverse(Base): sector = Column(String(100), nullable=True) market_cap = Column(BigInteger, nullable=True) is_active = Column(Boolean, default=True, index=True) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class TradeJournal(Base): @@ -333,14 +334,14 @@ class TradeJournal(Base): trade_id = Column(Integer) stock_code = Column(String(20), nullable=False, index=True) stock_name = Column(String(100)) - journal_date = Column(DateTime, default=datetime.utcnow) + journal_date = Column(DateTime, default=now_kst) content = Column(Text) strategy_name = Column(String(100)) mood = Column(String(50)) tags = Column(JSONB) screenshot_url = Column(Text) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=now_kst) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class StockBan(Base): @@ -351,7 +352,7 @@ class StockBan(Base): stock_code = Column(String(20), primary_key=True) consecutive_losses = Column(Integer, default=0, nullable=False) ban_until = Column(DateTime, nullable=True) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class StockBanV2(Base): @@ -371,7 +372,7 @@ class StockBanV2(Base): stock_code = Column(String(20), nullable=False, index=True) consecutive_losses = Column(Integer, default=0, nullable=False) ban_until = Column(DateTime, nullable=True) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) class WatchlistStock(Base): @@ -419,7 +420,7 @@ class StrategyOptimizationLog(Base): applied = Column(Boolean, default=False, nullable=False) apply_reason = Column(Text, nullable=True) total_combinations = Column(Integer, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=now_kst, index=True) class User(Base): @@ -436,7 +437,7 @@ class User(Base): password_hash = Column(String(128), nullable=False) is_active = Column(Boolean, default=False, nullable=False) is_admin = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=now_kst, nullable=False) last_login_at = Column(DateTime, nullable=True) @@ -462,5 +463,5 @@ class UserKisCredential(Base): auto_trade_enabled = Column(Boolean, default=False, nullable=False) last_verified_at = Column(DateTime, nullable=True) key_version = Column(Integer, default=1, nullable=False) # 암호화 키 로테이션용 - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=now_kst, nullable=False) + updated_at = Column(DateTime, default=now_kst, onupdate=now_kst) diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index 5c0de52..00447d9 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -38,6 +38,7 @@ from sqlalchemy import select from app.core.database import AsyncSessionLocal from app.models.models import TradeHistory +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -291,7 +292,7 @@ async def enqueue_trade_journal(trade, content: str) -> None: "trade_id": trade.id, "stock_code": trade.stock_code, "stock_name": trade.stock_name, - "journal_date": trade.executed_at or trade.created_at or datetime.utcnow(), + "journal_date": trade.executed_at or trade.created_at or now_kst(), "content": content, "strategy_name": strategy, "mood": mood, @@ -450,7 +451,7 @@ async def db_writer_worker() -> None: set_={ "consecutive_losses": stmt.excluded.consecutive_losses, "ban_until": stmt.excluded.ban_until, - "updated_at": datetime.utcnow(), + "updated_at": now_kst(), }, ) await session.execute(stmt) @@ -612,7 +613,7 @@ async def db_writer_worker() -> None: if record: record.status = "closed" - record.updated_at = _dt.utcnow() + record.updated_at = now_kst() await session.commit() logger.debug( "✅ [Worker] ActiveTrade closed: %s (strategy=%s)", diff --git a/backend/app/services/ml_retrain_pipeline.py b/backend/app/services/ml_retrain_pipeline.py index ebcd2a8..37dcb9b 100644 --- a/backend/app/services/ml_retrain_pipeline.py +++ b/backend/app/services/ml_retrain_pipeline.py @@ -33,6 +33,7 @@ import pytz from app.core.config import settings from app.core.database import AsyncSessionLocal +from app.core.timeutil import now_kst from app.services.feature_engineering import ( extract_features, db_data_to_dataframe, @@ -397,11 +398,11 @@ async def _load_trade_history( from sqlalchemy import select, and_ from app.models.models import TradeHistory - # [FIX] TradeHistory.created_at은 timestamp WITHOUT time zone(naive UTC, datetime.utcnow + # [2026-09-23] TradeHistory.created_at 은 KST naive (app.core.timeutil.now_kst) # 저장)이므로, tz-aware인 datetime.now(KST)로 비교하면 asyncpg가 # "can't subtract offset-naive and offset-aware datetimes" DataError를 낸다. # 컬럼과 동일하게 naive UTC로 맞춘다. - cutoff = datetime.utcnow() - timedelta(days=months * 30) + cutoff = now_kst() - timedelta(days=months * 30) async with AsyncSessionLocal() as session: stmt = ( diff --git a/backend/app/services/news_sentiment.py b/backend/app/services/news_sentiment.py index 2826c0d..ac91d98 100644 --- a/backend/app/services/news_sentiment.py +++ b/backend/app/services/news_sentiment.py @@ -24,6 +24,7 @@ from bs4 import BeautifulSoup from app.core.config import settings from app.core.redis import setex_json, get_json from app.services.db_worker import db_task_queue +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -168,7 +169,7 @@ def _compute_aggregate_score(articles: List[Dict[str, Any]]) -> float: if not articles: return 0.0 - now = datetime.utcnow() + now = now_kst() weighted_sum = 0.0 weight_total = 0.0 @@ -327,7 +328,7 @@ async def collect_market_news(limit: int = 0) -> List[Dict[str, Any]]: "stock_codes": [], # 시장 뉴스는 특정 종목 없음 "sentiment": label, "sentiment_score": score, - "published_at": datetime.utcnow(), # 파싱 불가 시 현재 시간 + "published_at": now_kst(), # 파싱 불가 시 현재 시간 }) if len(articles) >= limit: @@ -392,7 +393,7 @@ async def collect_newsapi_news( try: published_at = datetime.fromisoformat(pub_str.replace("Z", "+00:00")) except Exception: - published_at = datetime.utcnow() + published_at = now_kst() score, label = analyze_sentiment_keyword(title, content) @@ -567,7 +568,7 @@ def _parse_naver_date(date_str: str) -> datetime: 형식: "2026.03.24 09:15" 또는 "2026.03.24" """ if not date_str: - return datetime.utcnow() + return now_kst() date_str = date_str.strip() for fmt in ("%Y.%m.%d %H:%M", "%Y.%m.%d"): @@ -576,7 +577,7 @@ def _parse_naver_date(date_str: str) -> datetime: except ValueError: continue - return datetime.utcnow() + return now_kst() # ═════════════════════════════════════════════════════════════════════════════ diff --git a/backend/app/services/schedulers.py b/backend/app/services/schedulers.py index edcbcff..f13ef38 100644 --- a/backend/app/services/schedulers.py +++ b/backend/app/services/schedulers.py @@ -13,6 +13,7 @@ from datetime import datetime as _dt, date as _date, timedelta as _td from app.core.config import settings from app.core.database import AsyncSessionLocal +from app.core.timeutil import now_kst from app.core.market_schedule import ( is_market_open, seconds_until_market_open, is_pre_market_window, ) @@ -224,7 +225,6 @@ async def post_market_analyzer_scheduler(): ) from app.models.models import TradeHistory from sqlalchemy import select, func, and_ - from datetime import datetime as _utcnow_dt async with AsyncSessionLocal() as db: uncovered = await get_uncovered_trades(db, days=7) @@ -246,7 +246,7 @@ async def post_market_analyzer_scheduler(): try: from app.services.telegram_notifier import telegram_notifier # 당일 SELL 체결 내역으로 PnL 계산 - _today_start = _utcnow_dt.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + _today_start = now_kst().replace(hour=0, minute=0, second=0, microsecond=0) _today_sells = await db.execute( select(TradeHistory).where( and_( @@ -282,7 +282,7 @@ async def post_market_analyzer_scheduler(): except Exception as _tg_err: logger.debug(f"[PostMarket] Telegram 리포트 전송 실패: {_tg_err}") - since_7d = _utcnow_dt.utcnow() - _td(days=7) + since_7d = now_kst() - _td(days=7) trade_count_result = await db.execute( select(func.count(TradeHistory.id)).where( and_( diff --git a/backend/app/services/stock_pattern_memory.py b/backend/app/services/stock_pattern_memory.py index f7e4013..3cdf357 100644 --- a/backend/app/services/stock_pattern_memory.py +++ b/backend/app/services/stock_pattern_memory.py @@ -27,6 +27,7 @@ from datetime import datetime, date, timedelta from typing import Optional, List, Dict, Tuple from app.core.config import settings +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -791,7 +792,7 @@ async def build_stock_profile(stock_code: str) -> Optional[StockProfile]: total_trades=trade_stats["total_trades"], beta=beta, sector="", # 별도 섹터 DB 필요 시 확장 - last_updated=datetime.utcnow().isoformat(), + last_updated=now_kst().isoformat(), ) return profile @@ -899,7 +900,7 @@ async def refresh_all_profiles_with_percentile(stock_codes: List[str]) -> int: # 해당 종목의 순위 → 백분위 rank = sorted_ranges.index(p.avg_daily_range_pct) p.volatility_percentile = round(rank / n * 100, 1) if n > 1 else 50.0 - p.last_updated = datetime.utcnow().isoformat() + p.last_updated = now_kst().isoformat() # 3단계: 캐시 저장 success = 0 diff --git a/backend/app/services/strategy_config_service.py b/backend/app/services/strategy_config_service.py index b2b55b4..45e48b6 100644 --- a/backend/app/services/strategy_config_service.py +++ b/backend/app/services/strategy_config_service.py @@ -29,6 +29,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from app.core.database import AsyncSessionLocal from app.models.models import StrategyConfig +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -219,7 +220,7 @@ class StrategyConfigService: cfg.value = value cfg.updated_by = updated_by - cfg.updated_at = __import__("datetime").datetime.utcnow() + cfg.updated_at = __import__("datetime").now_kst() await session.commit() await session.refresh(cfg) diff --git a/backend/app/services/strategy_optimizer.py b/backend/app/services/strategy_optimizer.py index 5373706..90e480f 100644 --- a/backend/app/services/strategy_optimizer.py +++ b/backend/app/services/strategy_optimizer.py @@ -27,6 +27,7 @@ from app.core.database import AsyncSessionLocal from app.models.models import StrategyOptimizationLog, StockDailyPrice from app.services.strategy_config_service import strategy_cfg from app.services.backtest_engine import BacktestEngine, BacktestConfig +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -193,7 +194,7 @@ async def _get_representative_stocks(period_days: int = 90) -> List[str]: from sqlalchemy import func, and_ from datetime import datetime, timedelta - since = datetime.utcnow() - timedelta(days=period_days) + since = now_kst() - timedelta(days=period_days) async with AsyncSessionLocal() as db: stmt = ( diff --git a/backend/app/services/trade_analyzer.py b/backend/app/services/trade_analyzer.py index c8b3e04..4816831 100644 --- a/backend/app/services/trade_analyzer.py +++ b/backend/app/services/trade_analyzer.py @@ -20,6 +20,7 @@ from sqlalchemy import select, and_, func from sqlalchemy.ext.asyncio import AsyncSession from app.models.models import TradeHistory, TradeJournal +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -380,7 +381,7 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any "generated_at": ISO 문자열 } """ - since = datetime.utcnow() - timedelta(days=days) + since = now_kst() - timedelta(days=days) stmt = select(TradeHistory).where( and_( @@ -408,7 +409,7 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any "drift_alerts": [], "period_days": days, "total_trades": 0, - "generated_at": datetime.utcnow().isoformat(), + "generated_at": now_kst().isoformat(), } # ── 전체 통계 ────────────────────────────────────────────────────────── @@ -526,7 +527,7 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any "drift_alerts": drift_alerts, "period_days": days, "total_trades": len(trades), - "generated_at": datetime.utcnow().isoformat(), + "generated_at": now_kst().isoformat(), } @@ -637,7 +638,7 @@ def build_insights(perf: Dict[str, Any]) -> List[Dict[str, str]]: async def get_uncovered_trades(db: AsyncSession, days: int = 7) -> List[TradeHistory]: """일지가 아직 없는 완료 SELL 거래 목록을 반환.""" - since = datetime.utcnow() - timedelta(days=days) + since = now_kst() - timedelta(days=days) # 이미 일지가 있는 trade_id 목록 covered_stmt = select(TradeJournal.trade_id).where(TradeJournal.trade_id.is_not(None)) @@ -753,7 +754,7 @@ async def create_journal_record( trade_id=trade.id, stock_code=trade.stock_code, stock_name=trade.stock_name, - journal_date=trade.executed_at or trade.created_at or datetime.utcnow(), + journal_date=trade.executed_at or trade.created_at or now_kst(), content=content, strategy_name=strategy, mood=mood, diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 5de2b6d..4209226 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -32,6 +32,7 @@ from app.services.technical_indicators import ( from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS from app.services.correlation_filter import validate_correlation_filter from app.services.exchange_detector import is_us_stock, detect_exchange, get_market_info +from app.core.timeutil import now_kst from app.services.trade_helpers import ( check_opening_range_volatility, check_surge_prevention, check_trend_filter, calculate_cooldown_minutes, get_daily_cutoff_utc, get_leverage_check_keywords, @@ -725,7 +726,7 @@ async def _execute_buy( if available_cash > 0: _set_account_cache(available_cash, total_assets) - # [이슈1 수정] TradeHistory.created_at은 UTC 저장(datetime.utcnow)이므로 + # [2026-09-23] TradeHistory.created_at 은 KST 저장(now_kst)이므로 # daily_cutoff도 UTC 기준으로 계산해야 9시간 오차가 없음. daily_cutoff = get_daily_cutoff_utc() # 오늘 00:00:00 UTC @@ -770,9 +771,9 @@ async def _execute_buy( ) recent_buys = cooldown_check.scalars().all() # 마지막 매수 이후 쿨다운 시간이 지나지 않았으면 HOLD - # [이슈1 수정] created_at은 UTC이므로 현재 시각도 UTC로 비교 - from datetime import datetime as _dt_utc2 - if recent_buys and (_dt_utc2.utcnow() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: + # created_at·현재시각 모두 KST naive 기준 (예전에는 UTC now 와 KST created_at 을 + # 비교해 차이가 음수가 되어 쿨다운이 항상 발동했다) + if recent_buys and (now_kst() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} # ── 6.5. 상관관계 필터 (CLAUDE.md 섹션 5.4) ────────────────────────────────── diff --git a/backend/app/services/trade_helpers.py b/backend/app/services/trade_helpers.py index 489a110..976d73a 100644 --- a/backend/app/services/trade_helpers.py +++ b/backend/app/services/trade_helpers.py @@ -19,6 +19,7 @@ from app.services.technical_indicators import ( from app.core.market_schedule import is_opening_range_period from app.services.market_analyzer import _get_kst_now from app.services.exchange_detector import get_market_info +from app.core.timeutil import now_kst logger = logging.getLogger(__name__) @@ -214,7 +215,7 @@ def get_daily_cutoff_utc() -> _dt_utc: Returns: datetime: Today's 00:00:00 UTC """ - return _dt_utc.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + return now_kst().replace(hour=0, minute=0, second=0, microsecond=0) def get_leverage_check_keywords() -> set: diff --git a/backend/tests/test_timezone_consistency.py b/backend/tests/test_timezone_consistency.py new file mode 100644 index 0000000..ec30d40 --- /dev/null +++ b/backend/tests/test_timezone_consistency.py @@ -0,0 +1,61 @@ +"""DB 타임스탬프 KST 통일 검증. + +2026-09-23: 모델 기본값은 datetime.utcnow(UTC), 일부 경로는 _get_kst_now(KST)로 저장해 +같은 테이블 안에서 9시간이 섞였다(화면 매매 시각이 02:14 처럼 표시). 조회 구간도 +utcnow 기준이라 비교가 9시간 어긋났다(추가매수 쿨다운 상시 발동). +""" +import inspect +import pathlib +from datetime import datetime, timedelta + +import pytz + +from app.core.timeutil import KST, now_kst, today_kst +from app.models.models import ActiveTrade, Portfolio, TradeHistory + + +def test_now_kst_is_naive_korean_time(): + v = now_kst() + assert v.tzinfo is None + real = datetime.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None) + assert abs((real - v).total_seconds()) < 5 + assert today_kst() == real.date() + + +def test_now_kst_differs_from_utc_by_nine_hours(): + delta = now_kst() - datetime.utcnow() + assert 8.9 * 3600 < delta.total_seconds() < 9.1 * 3600 + + +def test_model_defaults_use_kst(): + for model, cols in [ + (TradeHistory, ["created_at"]), + (ActiveTrade, ["created_at", "updated_at"]), + (Portfolio, ["updated_at"]), + ]: + for col in cols: + default = model.__table__.columns[col].default + assert default is not None, f"{model.__name__}.{col} 기본값 없음" + assert getattr(default.arg, "__name__", "") == "now_kst", ( + f"{model.__name__}.{col} 이 KST 기준이 아님" + ) + + +def test_no_utcnow_left_in_app_code(): + """app/ 전체에서 utcnow() 호출이 남아 있으면 다시 섞인다.""" + root = pathlib.Path(__file__).resolve().parents[1] / "app" + offenders = [] + for f in root.rglob("*.py"): + for i, line in enumerate(f.read_text().splitlines(), 1): + code = line.split("#", 1)[0] + if "utcnow()" in code: + offenders.append(f"{f.relative_to(root)}:{i}") + assert not offenders, f"utcnow() 잔존: {offenders}" + + +def test_cooldown_compares_same_basis(): + from app.services import trade_executor as te + src = inspect.getsource(te) + i = src.index("recent_buys[0].created_at") + window = src[max(0, i - 300): i + 100] + assert "now_kst()" in window, "쿨다운 비교가 KST 기준이 아님" diff --git a/docker-compose.yml b/docker-compose.yml index 1703933..9fe230f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -114,6 +114,9 @@ services: - "127.0.0.1:8000:8000" volumes: - ./logs:/logs + # KIS 액세스 토큰 캐시 — 컨테이너 재생성 때마다 사라지면 매 배포마다 토큰을 새로 + # 발급받게 되고, KIS가 잦은 재발급을 거부한다(EGW00103 "유효하지 않은 AppKey"). + - ./token_cache:/root/.cache/auto-stock networks: - autostock-network depends_on: