fix: DB 타임스탬프 KST 통일 + KIS 토큰 캐시 영속화
시간대 혼재: 모델 기본값은 datetime.utcnow(UTC), 수동 매매 등 일부 경로는 _get_kst_now(KST)로 저장해 같은 테이블에 9시간 차가 섞였다(화면 매매시각 02:14 등). 조회 구간(utcnow - days)도 KST 저장분과 비교돼 어긋났고, 추가매수 쿨다운은 (UTC now - KST created_at)이 음수라 상시 발동했다. app.core.timeutil.now_kst() 도입 후 모델 기본값 27곳과 app/ 전역 utcnow() 호출을 모두 KST 기준으로 통일(잔존 0건, 테스트로 고정). KIS 토큰 캐시(~/.cache/auto-stock)가 컨테이너에만 있어 배포마다 사라지고 토큰을 새로 발급받다 KIS가 EGW00103(유효하지 않은 AppKey)로 거부 → 502·잔고 0 유발. 볼륨 마운트로 재배포 후에도 재사용. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
26
backend/app/core/timeutil.py
Normal file
26
backend/app/core/timeutil.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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_(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ──────────────────────────────────
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user