""" 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 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=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow, 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=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow, 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=datetime.utcnow) 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=datetime.utcnow, 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=datetime.utcnow, 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=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow, 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=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow) 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) 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=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow, onupdate=datetime.utcnow) 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=datetime.utcnow, 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=datetime.utcnow, 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=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)