Files
auto-stock/backend/app/models/models.py
shuim ec2fa2b423 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>
2026-09-23 11:24:14 +09:00

468 lines
21 KiB
Python

"""
SQLAlchemy ORM 모델 정의
- StockDailyPrice: 일별 주가 데이터
- Portfolio: 포트폴리오 보유 종목
- TradeHistory: 주문/체결 이력
- TradingRule: 매매 규칙 설정
- StrategyConfig: 전략 파라미터 DB 관리 (하드코딩 대체)
- ExchangeRate: 환율 이력
- UsStockUniverse: 미국 주식 종목 유니버스
- User: 대시보드 로그인 계정 (bcrypt 해시, 관리자 승인)
- UserKisCredential: 사용자별 KIS 자격증명 (암호화 저장, 멀티테넌트 1단계)
- StockBanV2: 사용자별 3연패 밴 (StockBan 은 legacy owner 전용 단일 테이블로 유지)
"""
from datetime import datetime, date
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):
"""KRX 일봉 히스토리컬 데이터 (pykrx 수집)"""
__tablename__ = "stock_daily_prices"
__table_args__ = (
UniqueConstraint('stock_code', 'trade_date', name='uq_stock_daily'),
)
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100))
trade_date = Column(Date, nullable=False, index=True)
open_price = Column(Integer)
high_price = Column(Integer)
low_price = Column(Integer)
close_price = Column(Integer)
volume = Column(BigInteger)
change_rate = Column(Numeric(10, 4))
market_cap = Column(BigInteger, nullable=True)
class TradingSession(Base):
"""🔥 일일 트레이딩 세션 관리 (리스크 관리)"""
__tablename__ = "trading_sessions"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable — 기존 session_date unique 는 유지 (0012에서 (user_id, session_date)로 전환)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
session_date = Column(Date, default=date.today, unique=True, index=True)
consecutive_losses = Column(Integer, default=0)
consecutive_wins = Column(Integer, default=0)
daily_profit_loss = Column(Numeric(15, 2), default=0)
daily_trades_count = Column(Integer, default=0)
is_paused = Column(Boolean, default=False)
pause_reason = Column(String(200), nullable=True)
starting_balance = Column(Numeric(15, 2))
ending_balance = Column(Numeric(15, 2))
peak_balance = Column(Numeric(15, 2))
created_at = Column(DateTime, default=now_kst)
updated_at = Column(DateTime, default=now_kst, onupdate=now_kst)
class TradingRule(Base):
__tablename__ = "trading_rules"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text)
rule_type = Column(String(50), nullable=False)
conditions = Column(JSONB, nullable=False)
actions = Column(JSONB, nullable=False)
is_active = Column(Boolean, default=True)
priority = Column(Integer, default=0)
created_at = Column(DateTime, default=now_kst)
updated_at = Column(DateTime, default=now_kst, onupdate=now_kst)
class StrategyConfig(Base):
"""전략 파라미터 DB 관리 테이블
config.py의 하드코딩된 전략 파라미터를 DB에서 관리.
- 재시작 없이 파라미터 실시간 변경 가능
- DB 값 없으면 config.py 기본값 자동 fallback
- strategy_config_service.get() 으로 접근
[그룹 분류]
- INDICATOR : RSI, 볼린저, MA, ATR, ADX 등 기술적 지표 파라미터
- RISK : 손절/익절, MDD, 포지션 크기, 연속손실 등 리스크 파라미터
- FILTER : 과열필터, 하락장필터, 트렌드필터 등 매매 필터
- DAY_TRADE : 데이트레이딩 세부 파라미터
- MEAN_REVERSION : 평균회귀 전략 파라미터
- GENERAL : 기타 일반 파라미터
"""
__tablename__ = "strategy_configs"
__table_args__ = (
UniqueConstraint('key', name='uq_strategy_config_key'),
)
id = Column(Integer, primary_key=True, index=True)
# 파라미터 식별
key = Column(String(100), nullable=False, unique=True, index=True) # ex: RSI_PERIOD
group = Column(String(50), nullable=False, index=True) # INDICATOR / RISK / FILTER / DAY_TRADE / MEAN_REVERSION / GENERAL
label = Column(String(200), nullable=False) # UI 표시 이름 (한글 가능)
description = Column(Text, nullable=True) # 파라미터 설명
# 값 관리
value_type = Column(String(20), nullable=False) # int / float / bool / str
value = Column(String(500), nullable=False) # 실제 값 (문자열로 저장, 타입 캐스팅은 서비스 레이어)
default_value = Column(String(500), nullable=False) # config.py 기본값 (참조용)
# 유효성 검증
min_value = Column(String(100), nullable=True) # 최솟값 (선택)
max_value = Column(String(100), nullable=True) # 최댓값 (선택)
# 상태
is_active = Column(Boolean, default=True, nullable=False) # False면 기본값 사용
updated_by = Column(String(100), nullable=True) # 마지막 수정자
created_at = Column(DateTime, default=now_kst)
updated_at = Column(DateTime, default=now_kst, onupdate=now_kst)
class TradeHistory(Base):
__tablename__ = "trade_history"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable — MULTI_TENANT_ENABLED=False 인 동안은 채우지 않음(legacy 컨텍스트 조회 시 필터 미적용)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
rule_id = Column(Integer)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100))
order_type = Column(String(20), nullable=False)
order_price = Column(Numeric(15, 2))
order_quantity = Column(Integer, nullable=False)
executed_price = Column(Numeric(15, 2))
executed_quantity = Column(Integer)
total_amount = Column(Numeric(15, 2))
status = Column(String(50), nullable=False)
order_id = Column(String(100))
message = Column(Text)
entry_reason = Column(Text)
exit_reason = Column(Text)
signals = Column(JSONB)
entry_trade_id = Column(Integer, nullable=True)
slippage = Column(Numeric(15, 2), nullable=True)
commission = Column(Numeric(15, 2), nullable=True)
net_profit_loss = Column(Numeric(15, 2), nullable=True)
profit_loss_rate = Column(Numeric(10, 4), nullable=True)
# 전략 구분: 'swing' | 'daytrading' | 'kosdaq_scan' | 'short_term'
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)
fx_rate = Column(Numeric(10, 4), nullable=True) # 체결 시점 환율
created_at = Column(DateTime, default=now_kst, index=True)
executed_at = Column(DateTime)
class Portfolio(Base):
__tablename__ = "portfolio"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable, 기존 unique(stock_code)는 이번 단계에서 유지 (0012에서 (user_id, stock_code)로 전환)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
stock_code = Column(String(20), nullable=False, unique=True)
stock_name = Column(String(100))
quantity = Column(Integer, nullable=False, default=0)
average_price = Column(Numeric(15, 2))
current_price = Column(Numeric(15, 2))
profit_loss = Column(Numeric(15, 2))
profit_loss_rate = Column(Numeric(10, 4))
market = Column(String(10), nullable=True, default="KRX")
# 전략 구분: 'swing' | 'daytrading'
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=now_kst, onupdate=now_kst)
class MarketData(Base):
__tablename__ = "market_data"
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, index=True)
price = Column(Numeric(15, 2), nullable=False)
change_rate = Column(Numeric(10, 4))
volume = Column(BigInteger)
high_price = Column(Numeric(15, 2))
low_price = Column(Numeric(15, 2))
open_price = Column(Numeric(15, 2))
timestamp = Column(DateTime, default=now_kst, index=True)
class News(Base):
__tablename__ = "news"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(500), nullable=False)
content = Column(Text)
source = Column(String(100))
url = Column(Text)
stock_codes = Column(ARRAY(String))
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=now_kst)
class ClaudeAnalysisLog(Base):
__tablename__ = "claude_analysis_log"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # 감사 추적용, 필수 아님
rule_id = Column(Integer)
stock_code = Column(String(20))
analysis_type = Column(String(50))
input_data = Column(JSONB)
analysis_result = Column(JSONB)
decision = Column(String(50))
confidence = Column(Numeric(5, 4))
created_at = Column(DateTime, default=now_kst, index=True)
class MCPToolCall(Base):
__tablename__ = "mcp_tool_calls"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # 감사 추적용, 필수 아님
tool_name = Column(String(100), nullable=False)
parameters = Column(JSONB)
result = Column(JSONB)
status = Column(String(50))
execution_time_ms = Column(Integer)
created_at = Column(DateTime, default=now_kst, index=True)
class StockAnalysis(Base):
"""매일 배치로 저장되는 종목 분석 데이터"""
__tablename__ = "stock_analysis"
__table_args__ = (
UniqueConstraint("stock_code", "analysis_date", name="uq_stock_analysis_code_date"),
)
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100), nullable=False)
analysis_date = Column(Date, nullable=False, index=True)
current_price = Column(Integer, nullable=False)
change_rate = Column(Numeric(10, 4))
volume = Column(BigInteger)
rsi = Column(Numeric(10, 4))
ma5 = Column(Numeric(15, 2))
ma20 = Column(Numeric(15, 2))
ma60 = Column(Numeric(15, 2))
stochastic_k = Column(Numeric(10, 4))
stochastic_d = Column(Numeric(10, 4))
bollinger_upper = Column(Numeric(15, 2))
bollinger_lower = Column(Numeric(15, 2))
bollinger_middle = Column(Numeric(15, 2))
signals = Column(JSONB)
score = Column(Integer, default=0)
atr_14 = Column(Numeric(15, 2), nullable=True)
adx_14 = Column(Numeric(10, 2), nullable=True)
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=now_kst)
updated_at = Column(DateTime, default=now_kst, onupdate=now_kst)
class ActiveTrade(Base):
"""현재 활성화된(보유 중인) 거래를 추적하기 위한 모델"""
__tablename__ = "active_trades"
id = Column(Integer, primary_key=True, index=True)
# 멀티테넌트 1단계: nullable — db_worker upsert 키에 user_id 추가는 2단계
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
stock_code = Column(String(20), nullable=False, index=True) # [FIX] 인덱스 추가
stock_name = Column(String(100))
quantity = Column(Integer, nullable=False)
entry_price = Column(Numeric(15, 2), nullable=False)
current_price = Column(Numeric(15, 2))
profit_loss = Column(Numeric(15, 2))
profit_loss_rate = Column(Numeric(10, 4))
stop_loss = Column(Numeric(15, 2))
take_profit = Column(Numeric(15, 2))
order_id = Column(String(100))
entry_reason = Column(Text)
status = Column(String(20), default="active", index=True) # [FIX] 인덱스 추가
remaining_pct = Column(Numeric(5, 2), default=1.0)
highest_since_entry = Column(Numeric(15, 2), nullable=True)
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=now_kst)
updated_at = Column(DateTime, default=now_kst, onupdate=now_kst)
class ExchangeRate(Base):
"""환율 이력 테이블"""
__tablename__ = "exchange_rates"
id = Column(Integer, primary_key=True, index=True)
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=now_kst, index=True)
class UsStockUniverse(Base):
"""미국 주식 종목 유니버스"""
__tablename__ = "us_stock_universe"
__table_args__ = (
UniqueConstraint('ticker', name='uq_us_ticker'),
)
id = Column(Integer, primary_key=True, index=True)
ticker = Column(String(10), nullable=False, index=True)
company_name = Column(String(200), nullable=False)
exchange = Column(String(10), nullable=False) # NASD, NYSE, AMEX
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=now_kst, onupdate=now_kst)
class TradeJournal(Base):
__tablename__ = "trade_journals"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
trade_id = Column(Integer)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100))
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=now_kst)
updated_at = Column(DateTime, default=now_kst, onupdate=now_kst)
class StockBan(Base):
"""레거시(단일 계좌) 3연패 밴 테이블. PK=stock_code 라 사용자별 분리 불가 →
멀티테넌트에서는 [[StockBanV2]] 사용, 이 테이블은 legacy owner 전용으로 유지."""
__tablename__ = "stock_bans"
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=now_kst, onupdate=now_kst)
class StockBanV2(Base):
"""사용자별 3연패 밴 (멀티테넌트). stock_bans 와 달리 PK 대신 (user_id, stock_code) unique.
§3.2 근거: stock_bans.PK=stock_code 라 사용자 분리 시 PK 변경이 필요한데, 이는
기존 시그니처 변경에 해당하므로 새 테이블로 분리. 0011 백필로 stock_bans → 여기로
legacy owner 행을 복제한다(양쪽 유지, legacy 경로는 계속 stock_bans 사용).
"""
__tablename__ = "stock_bans_v2"
__table_args__ = (
UniqueConstraint("user_id", "stock_code", name="uq_stock_bans_v2_user_stock"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
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=now_kst, onupdate=now_kst)
class WatchlistStock(Base):
__tablename__ = "watchlist_stocks"
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100), nullable=False)
added_at = Column(DateTime, nullable=False)
added_reason = Column(String(50), nullable=False, default="morning_batch")
score_at_add = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True, nullable=False, index=True)
removed_at = Column(DateTime, nullable=True)
removed_reason = Column(String(50), nullable=True)
class WatchlistHistory(Base):
__tablename__ = "watchlist_history"
id = Column(Integer, primary_key=True, index=True)
stock_code = Column(String(20), nullable=False, index=True)
stock_name = Column(String(100), nullable=False)
recorded_at = Column(DateTime, nullable=False, index=True)
score = Column(Integer, nullable=True)
current_price = Column(Numeric(15, 2), nullable=True)
change_rate = Column(Numeric(10, 4), nullable=True)
rsi = Column(Numeric(10, 4), nullable=True)
signals = Column(JSONB, nullable=True)
class StrategyOptimizationLog(Base):
"""전략 자동 최적화 실행 이력"""
__tablename__ = "strategy_optimization_logs"
id = Column(Integer, primary_key=True, index=True)
run_date = Column(Date, nullable=False, index=True)
trigger = Column(String(20), nullable=False, default="auto") # auto / manual
period_days = Column(Integer, nullable=False, default=90)
best_params = Column(JSONB, nullable=True)
best_sharpe = Column(Numeric(10, 4), nullable=True)
best_win_rate = Column(Numeric(10, 4), nullable=True)
best_mdd = Column(Numeric(10, 4), nullable=True)
current_sharpe = Column(Numeric(10, 4), nullable=True)
previous_params = Column(JSONB, nullable=True)
applied = Column(Boolean, default=False, nullable=False)
apply_reason = Column(Text, nullable=True)
total_combinations = Column(Integer, nullable=True)
created_at = Column(DateTime, default=now_kst, index=True)
class User(Base):
"""대시보드 로그인 계정.
- 첫 가입자는 자동으로 관리자(is_admin) + 활성(is_active)
- 이후 가입자는 is_active=False 로 생성 → 관리자 승인 후 로그인 가능
(SIGNUP_AUTO_APPROVE=True 면 즉시 활성)
"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(32), unique=True, nullable=False, index=True)
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=now_kst, nullable=False)
last_login_at = Column(DateTime, nullable=True)
class UserKisCredential(Base):
"""사용자별 KIS(한국투자증권) 자격증명 (멀티테넌트 1단계).
- app_key/app_secret/account_no 는 [[app.core.crypto]] 로 암호화해 *_enc 컬럼에 저장.
- app_key_hash: 동일 앱키 중복 등록 방지용 sha256 hex (평문 앱키의 해시, 조회만 목적 — 복호화 불가).
- 1단계는 사용자당 1세트(user_id unique). 모의/실전 동시 등록은 2단계에서 UniqueConstraint(user_id, mode)로 완화.
"""
__tablename__ = "user_kis_credentials"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
mode = Column(String(10), nullable=False) # virtual | real
app_key_enc = Column(Text, nullable=False)
app_secret_enc = Column(Text, nullable=False)
account_no_enc = Column(Text, nullable=False) # 하이픈 제거 10자리 (계좌 8자리 + 상품코드 2자리)
account_product_code = Column(String(2), nullable=False, default="01")
app_key_hash = Column(String(64), unique=True, nullable=False) # sha256(app_key) hex — 중복 등록 방지
app_key_hint = Column(String(8), nullable=True) # 마스킹 표시용 앞 6자
is_enabled = Column(Boolean, default=False, nullable=False) # 자동매매 대상 여부 (연결 테스트 성공 시 True 허용)
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=now_kst, nullable=False)
updated_at = Column(DateTime, default=now_kst, onupdate=now_kst)