## 재기동 내구성 - scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그 (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다) - 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화 날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합 (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다) - db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은 DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다) - 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단 - swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning ## DT 진입 복구 - 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어 5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전 발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리 (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경) - 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽) 임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐 - 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아 개장 직후 게이트가 자동 통과하는 것을 방지 - 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그 - watchlist 30분 갱신 시 ORB 보유 종목 우선 유지 ## 유입 잔고 격리 - sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로 편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리 (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요) - 격리분 손절선 이탈은 집행 없이 warning + summary 노출 - 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운 ## 4% 상한 - market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프 - 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증) ## 프론트 - 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시 - cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제 테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음) 전략 임계값 변경 0건 ⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어 미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후 "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
471 lines
21 KiB
Python
471 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' | '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)
|