Files
auto-stock/backend/app/models/models.py
shuim b4a1fc79ab feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)
설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)

데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
  portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)

백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
  deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
  /portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정

프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
  계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기

테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:26:51 +09:00

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