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

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