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:
2026-09-23 11:24:14 +09:00
parent e2d4b607cb
commit ec2fa2b423
22 changed files with 177 additions and 69 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View 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()

View File

@@ -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)

View File

@@ -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)",

View File

@@ -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 = (

View File

@@ -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()
# ═════════════════════════════════════════════════════════════════════════════

View File

@@ -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_(

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = (

View File

@@ -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,

View File

@@ -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) ──────────────────────────────────

View File

@@ -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: