""" 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' | 'manual' | 'us_swing' | 'external' # - 'external' = 시스템이 매수한 적 없는 유입 잔고(계좌 교체 등). 스윙 자동 # 손절·익절 대상에서 제외되고 수동 판단만 받는다 (portfolio.sync_portfolio). # 컬럼 길이 String(20) 안이라 스키마 변경(alembic) 불필요. 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)