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>
This commit is contained in:
@@ -17,6 +17,15 @@ Supported task types:
|
||||
- upsert_portfolio : dict → Portfolio INSERT/UPDATE (strategy_type 포함)
|
||||
- upsert_active_trade : dict → ActiveTrade UPSERT (strategy_name 포함)
|
||||
- close_active_trade : dict → ActiveTrade 상태를 'closed'로 변경
|
||||
- upsert_stock_ban_v2 : dict → StockBanV2 UPSERT (user_id, stock_code 스코프 — 멀티테넌트)
|
||||
|
||||
멀티테넌트(사용자 스코프) 확장 (docs/multi_tenant_design.md §3.4):
|
||||
- insert_trade_history / update_trade_history(_by_order_id) / upsert_portfolio /
|
||||
upsert_active_trade / close_active_trade / insert_trade_journal 는 payload
|
||||
data["user_id"] 가 있으면 조회 필터·생성 컬럼에 반영하고, 없으면(None) 기존과
|
||||
100% 동일하게 동작한다(하위 호환). 새 task 타입을 추가하지 않는다.
|
||||
- stock_bans 는 PK가 stock_code 단일이라 사용자 스코프로 확장 불가 → 신규 테이블
|
||||
stock_bans_v2(user_id, stock_code UNIQUE 쌍) + 신규 task upsert_stock_ban_v2 사용.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -183,6 +192,25 @@ async def enqueue_stock_ban_upsert(stock_code: str, consecutive_losses: int, ban
|
||||
})
|
||||
|
||||
|
||||
async def enqueue_stock_ban_upsert_v2(
|
||||
user_id: int, stock_code: str, consecutive_losses: int, ban_until
|
||||
) -> None:
|
||||
"""[v2] StockBanV2 UPSERT 를 큐에 등록 (사용자 스코프 loss_tracker에서 호출).
|
||||
|
||||
stock_bans(PK=stock_code, 단일 계정 전제)와 달리 stock_bans_v2 는
|
||||
UNIQUE(user_id, stock_code) 이므로 동일 종목을 사용자별로 독립 추적한다.
|
||||
"""
|
||||
await db_task_queue.put({
|
||||
"type": "upsert_stock_ban_v2",
|
||||
"data": {
|
||||
"user_id": user_id,
|
||||
"stock_code": stock_code,
|
||||
"consecutive_losses": consecutive_losses,
|
||||
"ban_until": ban_until,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async def enqueue_portfolio_upsert(data: dict) -> None:
|
||||
"""Portfolio INSERT/UPDATE 를 큐에 등록.
|
||||
|
||||
@@ -211,6 +239,20 @@ async def enqueue_close_active_trade(stock_code: str, strategy_name: str | None
|
||||
})
|
||||
|
||||
|
||||
async def enqueue_close_active_trade_v2(
|
||||
user_id: int, stock_code: str, strategy_name: str | None = None
|
||||
) -> None:
|
||||
"""[v2] 사용자 스코프 ActiveTrade 를 'closed' 상태로 변경하는 태스크를 큐에 등록.
|
||||
|
||||
close_active_trade 와 동일 task type("close_active_trade")을 재사용한다 —
|
||||
핸들러가 data.get("user_id")로 필터를 추가하므로 새 타입이 필요 없다.
|
||||
"""
|
||||
await db_task_queue.put({
|
||||
"type": "close_active_trade",
|
||||
"data": {"stock_code": stock_code, "strategy_name": strategy_name, "user_id": user_id},
|
||||
})
|
||||
|
||||
|
||||
async def enqueue_update_trade_history_by_order_id(order_id: str, updates: dict) -> None:
|
||||
"""KIS 주문번호(order_id)로 TradeHistory UPDATE 를 큐에 등록.
|
||||
|
||||
@@ -288,10 +330,12 @@ async def db_writer_worker() -> None:
|
||||
# ── 2. TradeHistory UPDATE (체결 확인 후 슬리피지/수량 반영) ──
|
||||
elif task_type == "update_trade_history":
|
||||
trade_id = data.get("id")
|
||||
user_id = data.get("user_id")
|
||||
if trade_id:
|
||||
row = await session.execute(
|
||||
select(TradeHistory).where(TradeHistory.id == trade_id)
|
||||
)
|
||||
query = select(TradeHistory).where(TradeHistory.id == trade_id)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
row = await session.execute(query)
|
||||
trade = row.scalar_one_or_none()
|
||||
if trade:
|
||||
for key, val in data.items():
|
||||
@@ -307,10 +351,12 @@ async def db_writer_worker() -> None:
|
||||
# ── 2b. TradeHistory UPDATE by order_id (KIS 주문번호 기준) ──
|
||||
elif task_type == "update_trade_history_by_order_id":
|
||||
order_id = data.get("order_id")
|
||||
user_id = data.get("user_id")
|
||||
if order_id:
|
||||
row = await session.execute(
|
||||
select(TradeHistory).where(TradeHistory.order_id == order_id)
|
||||
)
|
||||
query = select(TradeHistory).where(TradeHistory.order_id == order_id)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
row = await session.execute(query)
|
||||
trade = row.scalar_one_or_none()
|
||||
if trade:
|
||||
for key, val in data.items():
|
||||
@@ -350,10 +396,12 @@ async def db_writer_worker() -> None:
|
||||
# ── 4. StockBan UPSERT ────────────────────────────────────────
|
||||
elif task_type == "upsert_stock_ban":
|
||||
from app.models.models import StockBan
|
||||
from app.core.dt_utils import to_naive_utc
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
consecutive_losses = data.get("consecutive_losses")
|
||||
ban_until = data.get("ban_until")
|
||||
# stock_bans.ban_until은 tz-naive 컬럼 — tz-aware 값이 들어와도 안전하게 저장.
|
||||
ban_until = to_naive_utc(data.get("ban_until"))
|
||||
|
||||
result = await session.execute(select(StockBan).where(StockBan.stock_code == stock_code))
|
||||
record = result.scalar_one_or_none()
|
||||
@@ -377,6 +425,42 @@ async def db_writer_worker() -> None:
|
||||
ban_until.isoformat() if ban_until else "None",
|
||||
)
|
||||
|
||||
# ── 4v2. StockBanV2 UPSERT (user_id, stock_code 스코프) ────────
|
||||
elif task_type == "upsert_stock_ban_v2":
|
||||
from app.models.models import StockBanV2
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from app.core.dt_utils import to_naive_utc
|
||||
|
||||
user_id = data.get("user_id")
|
||||
stock_code = data.get("stock_code")
|
||||
consecutive_losses = data.get("consecutive_losses")
|
||||
# stock_bans_v2.ban_until도 tz-naive 컬럼 — tz-aware 값이 들어와도 안전하게 저장.
|
||||
ban_until = to_naive_utc(data.get("ban_until"))
|
||||
|
||||
# PostgreSQL INSERT ... ON CONFLICT DO UPDATE — 여러 워커/사용자
|
||||
# 사이클이 동시에 같은 (user_id, stock_code)를 갱신해도 경쟁 안전.
|
||||
stmt = pg_insert(StockBanV2).values(
|
||||
user_id=user_id,
|
||||
stock_code=stock_code,
|
||||
consecutive_losses=consecutive_losses,
|
||||
ban_until=ban_until,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint="uq_stock_bans_v2_user_stock",
|
||||
set_={
|
||||
"consecutive_losses": stmt.excluded.consecutive_losses,
|
||||
"ban_until": stmt.excluded.ban_until,
|
||||
"updated_at": datetime.utcnow(),
|
||||
},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"✅ [Worker] StockBanV2 upsert: user=%s %s (losses=%s, ban_until=%s)",
|
||||
user_id, stock_code, consecutive_losses,
|
||||
ban_until.isoformat() if ban_until else "None",
|
||||
)
|
||||
|
||||
# ── 4b. News INSERT (중복 URL 무시) ────────────────────────────
|
||||
elif task_type == "insert_news":
|
||||
from app.models.models import News
|
||||
@@ -417,9 +501,11 @@ async def db_writer_worker() -> None:
|
||||
from app.models.models import Portfolio
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
result = await session.execute(
|
||||
select(Portfolio).where(Portfolio.stock_code == stock_code)
|
||||
)
|
||||
user_id = data.get("user_id")
|
||||
query = select(Portfolio).where(Portfolio.stock_code == stock_code)
|
||||
if user_id is not None:
|
||||
query = query.where(Portfolio.user_id == user_id)
|
||||
result = await session.execute(query)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
@@ -443,6 +529,7 @@ async def db_writer_worker() -> None:
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
strategy_name = data.get("strategy_name")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
query = select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
@@ -450,9 +537,17 @@ async def db_writer_worker() -> None:
|
||||
)
|
||||
if strategy_name:
|
||||
query = query.where(ActiveTrade.strategy_name == strategy_name)
|
||||
|
||||
result = await session.execute(query)
|
||||
record = result.scalar_one_or_none()
|
||||
if user_id is not None:
|
||||
query = query.where(ActiveTrade.user_id == user_id)
|
||||
result = await session.execute(query)
|
||||
record = result.scalar_one_or_none()
|
||||
else:
|
||||
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
|
||||
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
|
||||
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
|
||||
if record is None:
|
||||
# remaining_pct_multiply는 신규 생성 시 무시
|
||||
@@ -486,6 +581,7 @@ async def db_writer_worker() -> None:
|
||||
|
||||
stock_code = data.get("stock_code")
|
||||
strategy_name = data.get("strategy_name")
|
||||
user_id = data.get("user_id")
|
||||
|
||||
query = select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
@@ -493,9 +589,17 @@ async def db_writer_worker() -> None:
|
||||
)
|
||||
if strategy_name:
|
||||
query = query.where(ActiveTrade.strategy_name == strategy_name)
|
||||
|
||||
result = await session.execute(query)
|
||||
record = result.scalar_one_or_none()
|
||||
if user_id is not None:
|
||||
query = query.where(ActiveTrade.user_id == user_id)
|
||||
result = await session.execute(query)
|
||||
record = result.scalar_one_or_none()
|
||||
else:
|
||||
# legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active
|
||||
# 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이
|
||||
# MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선.
|
||||
query = query.order_by(ActiveTrade.id.desc()).limit(1)
|
||||
result = await session.execute(query)
|
||||
record = result.scalars().first()
|
||||
|
||||
if record:
|
||||
record.status = "closed"
|
||||
@@ -543,6 +647,7 @@ async def db_writer_worker() -> None:
|
||||
"upsert_stock_analysis",
|
||||
"bulk_upsert_stock_analysis",
|
||||
"upsert_stock_ban",
|
||||
"upsert_stock_ban_v2",
|
||||
"upsert_portfolio",
|
||||
"upsert_active_trade",
|
||||
"close_active_trade",
|
||||
|
||||
@@ -216,6 +216,61 @@ class KoreaInvestmentAPI:
|
||||
instance._load_token_from_cache()
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def for_user(
|
||||
cls,
|
||||
user_id: int,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
account_no: str,
|
||||
mode: str,
|
||||
) -> "KoreaInvestmentAPI":
|
||||
"""사용자별 주문·잔고 전용 인스턴스 생성 (멀티테넌트 1단계, §5.5).
|
||||
|
||||
시세 조회는 `_quote_delegate()`가 전역 `real_market_api`(공용, 계좌 무관)로
|
||||
자동 위임하므로, 이 인스턴스의 앱키는 주문·잔고·체결확인에만 소비된다.
|
||||
`place_order`/`get_balance`/`cancel_order` 등은 모두 `self.base_url`
|
||||
(`"vts" in base_url`)로 모의/실전을 판별하므로, 전역 `settings.KIS_MODE`를
|
||||
건드리지 않고 인스턴스별 `mode`가 그대로 적용된다(최소 침습).
|
||||
자격증명 복호화는 호출 측(Registry/KISClientPool)이 수행해 평문으로 전달한다 —
|
||||
이 클래스는 DB 세션을 열지 않는다.
|
||||
"""
|
||||
if mode not in ("virtual", "real"):
|
||||
raise ValueError(f"지원하지 않는 mode: {mode!r} (virtual|real)")
|
||||
instance = cls.__new__(cls)
|
||||
instance.base_url = settings.KIS_REAL_URL if mode == "real" else settings.KIS_VIRTUAL_URL
|
||||
instance.app_key = app_key
|
||||
instance.app_secret = app_secret
|
||||
instance.account_number = account_no
|
||||
instance.mode = mode
|
||||
instance.user_id = user_id
|
||||
instance.access_token = None
|
||||
instance.token_expires_at = None
|
||||
instance.last_request_time = None
|
||||
instance._throttle_lock = asyncio.Lock()
|
||||
instance._http_client = None
|
||||
instance._http_client_lock = asyncio.Lock()
|
||||
token_dir = Path.home() / ".cache" / "auto-stock" / "users" / str(user_id)
|
||||
token_dir.mkdir(parents=True, exist_ok=True)
|
||||
instance.TOKEN_CACHE_FILE = token_dir / f"kis_token_{mode}.json"
|
||||
instance.min_request_interval = (
|
||||
0.5 if mode == "virtual" else getattr(settings, "KIS_REAL_MIN_REQUEST_INTERVAL", 0.1)
|
||||
)
|
||||
instance.max_retries = settings.API_MAX_RETRIES
|
||||
instance.retry_delay = settings.API_RETRY_DELAY
|
||||
instance.retry_backoff = settings.API_RETRY_BACKOFF
|
||||
instance.timeout = settings.API_TIMEOUT
|
||||
instance.semaphore = asyncio.Semaphore(settings.API_MAX_CONCURRENT_REQUESTS)
|
||||
instance._token_lock = asyncio.Lock()
|
||||
instance.monitor = APICallMonitor(window_size=settings.API_MONITORING_WINDOW)
|
||||
instance.circuit_breaker = CircuitBreaker(failure_threshold=7, recovery_timeout=120)
|
||||
logger.info(
|
||||
"🔧 KIS 사용자별 API 초기화: user_id=%s mode=%s base_url=%s app_key=%s...",
|
||||
user_id, mode, instance.base_url, app_key[:6] if app_key else ""
|
||||
)
|
||||
instance._load_token_from_cache()
|
||||
return instance
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.KOREA_INVESTMENT_API_BASE_URL
|
||||
self.app_key = settings.KOREA_INVESTMENT_APP_KEY
|
||||
|
||||
@@ -11,10 +11,17 @@ from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# tz-aware/naive 경계 정규화 — app/core/dt_utils 참고 (db_worker.py와 공유)
|
||||
from app.core.dt_utils import to_naive_utc as _to_naive_utc, to_aware_utc as _to_aware_utc # noqa: E402
|
||||
|
||||
|
||||
class ConsecutiveLossTracker:
|
||||
"""종목별 연속 손실 추적 및 쿨링오프 관리"""
|
||||
|
||||
# 멀티테넌트: risk_factory.create_loss_tracker_for_user()가 인스턴스 생성 후
|
||||
# 세팅하는 소유자 user_id. 레거시 싱글톤은 None으로 유지(전역 stock_bans 사용).
|
||||
user_id: Optional[int] = None
|
||||
|
||||
def __init__(self, ban_count: int = 3, ban_calendar_days: int = 42):
|
||||
"""
|
||||
Args:
|
||||
@@ -67,15 +74,80 @@ class ConsecutiveLossTracker:
|
||||
return
|
||||
|
||||
# 큐에 UPSERT 작업 등록 (db_worker가 비동기적으로 처리)
|
||||
# ban_until 컬럼은 tz-naive이므로 저장 직전 naive-UTC로 정규화한다.
|
||||
await enqueue_stock_ban_upsert(
|
||||
stock_code=stock_code,
|
||||
consecutive_losses=entry["consecutive_losses"],
|
||||
ban_until=entry["ban_until"],
|
||||
ban_until=_to_naive_utc(entry["ban_until"]),
|
||||
)
|
||||
logger.debug(f"loss_tracker DB 저장 큐 등록: {stock_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"loss_tracker DB 저장 큐 등록 실패 ({stock_code}): {e}")
|
||||
|
||||
async def save_to_db_v2(self, user_id: int, stock_code: str = None):
|
||||
"""[v2] 사용자 스코프 인메모리 상태를 stock_bans_v2 에 영속화.
|
||||
|
||||
save_to_db()와 동일 패턴이지만 db_worker의 upsert_stock_ban_v2 task로
|
||||
(user_id, stock_code) 단위 UPSERT한다. 기존 save_to_db()는 불변.
|
||||
"""
|
||||
if stock_code is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.db_worker import enqueue_stock_ban_upsert_v2
|
||||
|
||||
entry = self._tracker.get(stock_code)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
# stock_bans_v2.ban_until 도 tz-naive 컬럼 — 저장 직전 naive-UTC로 정규화.
|
||||
await enqueue_stock_ban_upsert_v2(
|
||||
user_id=user_id,
|
||||
stock_code=stock_code,
|
||||
consecutive_losses=entry["consecutive_losses"],
|
||||
ban_until=_to_naive_utc(entry["ban_until"]),
|
||||
)
|
||||
logger.debug(f"loss_tracker_v2 DB 저장 큐 등록: user={user_id} {stock_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"loss_tracker_v2 DB 저장 큐 등록 실패 (user={user_id}, {stock_code}): {e}")
|
||||
|
||||
async def load_from_db_v2(self, db, user_id: int):
|
||||
"""[v2] 앱 시작/컨텍스트 생성 시 stock_bans_v2 에서 사용자 스코프 ban 상태 복원."""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.models import StockBanV2
|
||||
|
||||
result = await db.execute(select(StockBanV2).where(StockBanV2.user_id == user_id))
|
||||
records = result.scalars().all()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
loaded = 0
|
||||
for rec in records:
|
||||
# DB의 ban_until은 tz-naive → tz-aware now와 비교하기 전 정규화.
|
||||
ban_until = _to_aware_utc(rec.ban_until)
|
||||
if ban_until and now >= ban_until:
|
||||
ban_until = None
|
||||
consecutive_losses = 0
|
||||
else:
|
||||
consecutive_losses = rec.consecutive_losses
|
||||
|
||||
self._tracker[rec.stock_code] = {
|
||||
"consecutive_losses": consecutive_losses,
|
||||
"ban_until": ban_until,
|
||||
}
|
||||
if ban_until:
|
||||
loaded += 1
|
||||
logger.info(
|
||||
f"ban_v2 복원: user={user_id} {rec.stock_code} → "
|
||||
f"{ban_until.strftime('%Y-%m-%d')}까지 진입 금지"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"loss_tracker_v2 DB 로드 완료: user={user_id} {len(records)}건 중 {loaded}건 활성 ban"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(f"loss_tracker_v2 DB 로드 실패 (user={user_id})")
|
||||
|
||||
async def load_from_db(self, db):
|
||||
"""앱 시작 시 DB에서 ban 상태 복원"""
|
||||
try:
|
||||
@@ -88,8 +160,8 @@ class ConsecutiveLossTracker:
|
||||
now = datetime.now(timezone.utc)
|
||||
loaded = 0
|
||||
for rec in records:
|
||||
# 만료된 ban은 초기화
|
||||
ban_until = rec.ban_until
|
||||
# 만료된 ban은 초기화. DB의 ban_until은 tz-naive → 비교 전 정규화.
|
||||
ban_until = _to_aware_utc(rec.ban_until)
|
||||
if ban_until and now >= ban_until:
|
||||
ban_until = None
|
||||
consecutive_losses = 0
|
||||
@@ -107,8 +179,8 @@ class ConsecutiveLossTracker:
|
||||
)
|
||||
|
||||
logger.info(f"loss_tracker DB 로드 완료: {len(records)}건 중 {loaded}건 활성 ban")
|
||||
except Exception as e:
|
||||
logger.error(f"loss_tracker DB 로드 실패: {e}")
|
||||
except Exception:
|
||||
logger.exception("loss_tracker DB 로드 실패")
|
||||
|
||||
def is_banned(self, stock_code: str) -> bool:
|
||||
"""해당 종목이 진입 금지 상태인지 확인"""
|
||||
|
||||
@@ -10,6 +10,8 @@ import json
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.core.json_helpers import _dumps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MDD_REDIS_KEY = "mdd:state"
|
||||
@@ -41,7 +43,7 @@ class MDDManager:
|
||||
"_reduction_executed_10pct": self._reduction_executed_10pct,
|
||||
"_reduction_executed_20pct": self._reduction_executed_20pct,
|
||||
}
|
||||
await r.set(_MDD_REDIS_KEY, json.dumps(state))
|
||||
await r.set(_MDD_REDIS_KEY, _dumps(state))
|
||||
except Exception as e:
|
||||
logger.warning(f"[MDD] Redis 저장 실패: {e}")
|
||||
|
||||
@@ -138,6 +140,52 @@ class MDDManager:
|
||||
await self.save_to_redis()
|
||||
return {"action": "NONE", "drawdown": drawdown_pct}
|
||||
|
||||
async def save_to_redis_v2(self, user_id: int) -> None:
|
||||
"""[v2] 사용자 스코프 MDD 상태를 `mdd:state:{user_id}` 키로 영속화.
|
||||
|
||||
save_to_redis()와 동일 직렬화 포맷 재사용, 키 네임스페이스만 사용자별로 분리.
|
||||
레거시 save_to_redis()는 불변(전역 "mdd:state" 키 그대로).
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
r = get_redis()
|
||||
if r:
|
||||
state = {
|
||||
"peak_balance": self.peak_balance,
|
||||
"is_paused": self.is_paused,
|
||||
"is_short_term_strategy_paused": self.is_short_term_strategy_paused,
|
||||
"reduction_applied": self.reduction_applied,
|
||||
"_reduction_executed_10pct": self._reduction_executed_10pct,
|
||||
"_reduction_executed_20pct": self._reduction_executed_20pct,
|
||||
}
|
||||
await r.set(f"{_MDD_REDIS_KEY}:{user_id}", _dumps(state))
|
||||
except Exception as e:
|
||||
logger.warning(f"[MDD v2] Redis 저장 실패 (user={user_id}): {e}")
|
||||
|
||||
async def load_from_redis_v2(self, user_id: int) -> bool:
|
||||
"""[v2] `mdd:state:{user_id}` 키에서 사용자 스코프 MDD 상태 복원."""
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
r = get_redis()
|
||||
if r:
|
||||
raw = await r.get(f"{_MDD_REDIS_KEY}:{user_id}")
|
||||
if raw:
|
||||
state = json.loads(raw)
|
||||
self.peak_balance = float(state.get("peak_balance", 0.0))
|
||||
self.is_paused = bool(state.get("is_paused", False))
|
||||
self.is_short_term_strategy_paused = bool(state.get("is_short_term_strategy_paused", False))
|
||||
self.reduction_applied = state.get("reduction_applied")
|
||||
self._reduction_executed_10pct = bool(state.get("_reduction_executed_10pct", False))
|
||||
self._reduction_executed_20pct = bool(state.get("_reduction_executed_20pct", False))
|
||||
logger.info(
|
||||
f"[MDD v2] Redis에서 복원: user={user_id} peak={self.peak_balance:,.0f}, "
|
||||
f"paused={self.is_paused}, reduction={self.reduction_applied}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[MDD v2] Redis 복원 실패 (user={user_id}): {e}")
|
||||
return False
|
||||
|
||||
async def can_open_new_position(self) -> bool:
|
||||
"""신규 포지션 진입 가능 여부 (thread-safe)"""
|
||||
async with self._lock:
|
||||
@@ -320,6 +368,165 @@ class MDDManager:
|
||||
setattr(self, executed_key, False)
|
||||
return []
|
||||
|
||||
async def execute_reduction_v2(self, mdd_action: dict, kis, user_id: int) -> list[dict]:
|
||||
"""[v2] 사용자 스코프 포지션 축소 매도 실행.
|
||||
|
||||
execute_reduction()과 동일 로직이나 전역 korea_investment_api 대신 주입된
|
||||
kis(사용자 인스턴스)를 사용하고, trade_history/active_trade 큐 payload에
|
||||
user_id를 포함해 db_worker가 해당 사용자 레코드만 갱신하도록 한다.
|
||||
기존 execute_reduction()은 완전히 불변.
|
||||
"""
|
||||
action = mdd_action.get("action", "NONE")
|
||||
if action == "NONE":
|
||||
return []
|
||||
|
||||
reduce_pct = mdd_action.get("reduce_pct", 0.0)
|
||||
prioritize_leverage = mdd_action.get("prioritize_leverage", False)
|
||||
|
||||
if reduce_pct <= 0:
|
||||
return []
|
||||
|
||||
executed_key: Optional[str] = None
|
||||
|
||||
async with self._lock:
|
||||
if self.reduction_applied is None:
|
||||
logger.debug(f"[MDD v2] user={user_id} reduction_applied=None — 축소 불필요, 스킵")
|
||||
return []
|
||||
executed_key = f"_reduction_executed_{self.reduction_applied}"
|
||||
if getattr(self, executed_key, False):
|
||||
logger.debug(f"[MDD v2] user={user_id} {action} 축소 이미 실행됨 — 스킵")
|
||||
return []
|
||||
setattr(self, executed_key, True)
|
||||
|
||||
try:
|
||||
from app.services.db_worker import (
|
||||
enqueue_trade_history,
|
||||
enqueue_close_active_trade_v2,
|
||||
enqueue_active_trade_upsert,
|
||||
)
|
||||
|
||||
balance = await kis.get_balance()
|
||||
holdings = balance.get("output1", [])
|
||||
if not holdings:
|
||||
logger.info(f"[MDD v2] user={user_id} {action} — 보유 종목 없음, 축소 불필요")
|
||||
return []
|
||||
|
||||
LEVERAGE_KEYWORDS = {"레버리지", "2X", "3X", "인버스", "TIGER 200선물", "KODEX 인버스", "곱버스"}
|
||||
if prioritize_leverage:
|
||||
def _is_leverage(h):
|
||||
name = h.get("prdt_name", "")
|
||||
return any(kw in name for kw in LEVERAGE_KEYWORDS)
|
||||
holdings = sorted(holdings, key=lambda h: (not _is_leverage(h),))
|
||||
|
||||
results = []
|
||||
skipped = 0
|
||||
for h in holdings:
|
||||
stock_code = h.get("pdno", "")
|
||||
stock_name = h.get("prdt_name", stock_code)
|
||||
quantity = int(h.get("hldg_qty", 0) or 0)
|
||||
current_price = float(h.get("prpr", 0) or 0)
|
||||
|
||||
if quantity <= 0 or current_price <= 0:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sell_qty = max(int(quantity * reduce_pct), 1)
|
||||
sell_qty = min(sell_qty, quantity)
|
||||
|
||||
try:
|
||||
# UserContext 조립 완료(트레이딩팀) — 종목별 동시 주문 락
|
||||
# (_order_semaphores/Redis 분산락)을 거치도록 safe_place_order_v2 사용.
|
||||
# SL/사이징 로직은 그대로, 주문 호출 경로만 교체.
|
||||
from app.services.tenant.context import UserContext
|
||||
from app.services.trade_executor import safe_place_order_v2
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
|
||||
reduction_ctx = UserContext(
|
||||
user_id=user_id,
|
||||
username="",
|
||||
mode=getattr(kis, "mode", _settings.KIS_MODE),
|
||||
kis=kis,
|
||||
mdd=self,
|
||||
loss_tracker=None,
|
||||
)
|
||||
order_resp = await safe_place_order_v2(
|
||||
reduction_ctx,
|
||||
stock_code=stock_code,
|
||||
order_type="SELL",
|
||||
quantity=sell_qty,
|
||||
price=None, # 시장가
|
||||
)
|
||||
if order_resp and order_resp.get("rt_cd") == "0":
|
||||
order_id = order_resp.get("output", {}).get("ODNO", "")
|
||||
avg_price = float(h.get("pchs_avg_pric", current_price))
|
||||
profit_loss = (current_price - avg_price) * sell_qty
|
||||
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
|
||||
|
||||
# MDD 강제매도는 시스템적 리스크 축소 — loss_tracker 미기록 (execute_reduction과 동일 정책)
|
||||
await enqueue_trade_history({
|
||||
"user_id": user_id,
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"order_type": "SELL",
|
||||
"order_price": current_price,
|
||||
"order_quantity": sell_qty,
|
||||
"executed_price": current_price,
|
||||
"executed_quantity": sell_qty,
|
||||
"total_amount": current_price * sell_qty,
|
||||
"status": "COMPLETED",
|
||||
"order_id": order_id,
|
||||
"exit_reason": f"MDD {action} ({reduce_pct*100:.0f}% 축소)",
|
||||
"net_profit_loss": profit_loss,
|
||||
"strategy_type": "mdd_reduction",
|
||||
})
|
||||
|
||||
remaining_qty = quantity - sell_qty
|
||||
if remaining_qty <= 0:
|
||||
await enqueue_close_active_trade_v2(user_id, stock_code, strategy_name=None)
|
||||
else:
|
||||
sell_ratio = remaining_qty / quantity
|
||||
await enqueue_active_trade_upsert({
|
||||
"user_id": user_id,
|
||||
"stock_code": stock_code,
|
||||
"remaining_pct_multiply": sell_ratio,
|
||||
})
|
||||
|
||||
results.append({
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"sell_quantity": sell_qty,
|
||||
"reason": action,
|
||||
})
|
||||
logger.warning(
|
||||
f"[MDD v2] user={user_id} {action} 매도: {stock_code} {stock_name} "
|
||||
f"{sell_qty}주 (총 {quantity}주 중 {reduce_pct*100:.0f}%)"
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.3) # KIS API 레이트 리밋
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MDD v2] user={user_id} 축소 매도 실패 ({stock_code}): {e}")
|
||||
|
||||
failed_count = len(holdings) - len(results) - skipped
|
||||
if failed_count > 0 and executed_key is not None:
|
||||
async with self._lock:
|
||||
setattr(self, executed_key, False)
|
||||
logger.warning(
|
||||
f"[MDD v2] user={user_id} {action} 부분 완료: {len(results)}종목 성공, "
|
||||
f"{failed_count}종목 실패 → 다음 주기 재시도 예정"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[MDD v2] user={user_id} {action} 완료: {len(results)}종목 축소 매도")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MDD v2] user={user_id} execute_reduction_v2 실패: {e}", exc_info=True)
|
||||
if executed_key is not None:
|
||||
async with self._lock:
|
||||
setattr(self, executed_key, False)
|
||||
return []
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
mdd_manager = MDDManager()
|
||||
|
||||
5
backend/app/services/tenant/__init__.py
Normal file
5
backend/app/services/tenant/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""멀티테넌트(사용자별 매매) 서비스 계층 — 1단계.
|
||||
|
||||
- bootstrap: 앱 최초 기동 시 관리자 계정·KIS 자격증명 이관 시드
|
||||
- (2단계 예정) context.py / registry.py / kis_pool.py
|
||||
"""
|
||||
174
backend/app/services/tenant/bootstrap.py
Normal file
174
backend/app/services/tenant/bootstrap.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""앱 기동 시 멀티테넌트 부트스트랩 (관리자 시드 + legacy 자격증명 이관 + 백필).
|
||||
|
||||
`main.py` lifespan 에서 `create_all()` 직후 1회 호출된다(§ CLAUDE.md 예외: 부트스트랩
|
||||
시드·자격증명 저장은 단일 트랜잭션 직접 커밋 허용 — 실시간 계층 아님).
|
||||
|
||||
모든 단계는 독립적으로 try/except 로 감싸 실패해도 앱 부팅을 막지 않는다.
|
||||
alembic 0011 과 동일한 백필 로직을 idempotent 하게 재수행 — alembic 이 CLI 로만
|
||||
실행되는 배포 환경에서도(예: 최초 로그인 전) 앱 기동 시 스스로 보정한다.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.crypto import current_key_version, encrypt, is_encryption_configured
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import User, UserKisCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# alembic 0011 과 동일한 백필 대상 테이블 목록
|
||||
_BACKFILL_TABLES = [
|
||||
"portfolio",
|
||||
"trade_history",
|
||||
"active_trades",
|
||||
"trade_journals",
|
||||
"trading_sessions",
|
||||
"mcp_tool_calls",
|
||||
"claude_analysis_log",
|
||||
]
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
try:
|
||||
from app.api.endpoints.auth import _hash_password as _auth_hash_password
|
||||
|
||||
return _auth_hash_password(password)
|
||||
except ImportError:
|
||||
# 순환 임포트 등으로 재사용 불가할 때만 동일 로직 복제 (auth.py:_hash_password 와 동치)
|
||||
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
async def _seed_admin_if_empty(session) -> None:
|
||||
"""users 테이블이 비어 있고 BOOTSTRAP_ADMIN_PASSWORD 가 설정되어 있으면 관리자 시드."""
|
||||
if not settings.BOOTSTRAP_ADMIN_PASSWORD:
|
||||
return
|
||||
total = (await session.execute(select(User))).scalars().first()
|
||||
if total is not None:
|
||||
return # 이미 사용자 존재 — 시드 불필요
|
||||
|
||||
user = User(
|
||||
username=settings.BOOTSTRAP_ADMIN_USERNAME,
|
||||
password_hash=_hash_password(settings.BOOTSTRAP_ADMIN_PASSWORD),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
logger.info(f"[Bootstrap] 부트스트랩 관리자 계정 생성 완료: {settings.BOOTSTRAP_ADMIN_USERNAME}")
|
||||
|
||||
|
||||
async def _get_legacy_owner(session) -> User | None:
|
||||
result = await session.execute(
|
||||
select(User).where(User.is_admin.is_(True)).order_by(User.id.asc()).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _seed_legacy_kis_credential(session, owner: User) -> None:
|
||||
"""legacy owner 에게 .env KIS 자격증명이 아직 이관되지 않았으면 암호화해 저장."""
|
||||
existing = await session.execute(
|
||||
select(UserKisCredential).where(UserKisCredential.user_id == owner.id)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
return # 이미 이관됨
|
||||
|
||||
app_key = settings.KOREA_INVESTMENT_APP_KEY
|
||||
app_secret = settings.KOREA_INVESTMENT_APP_SECRET
|
||||
account_no = (settings.KOREA_INVESTMENT_ACCOUNT_NUMBER or "").replace("-", "")
|
||||
|
||||
if not (app_key and app_secret and account_no):
|
||||
logger.info("[Bootstrap] .env KIS 자격증명 미설정 — legacy 자격증명 이관 스킵")
|
||||
return
|
||||
if not is_encryption_configured():
|
||||
logger.warning("[Bootstrap] CREDENTIALS_ENCRYPTION_KEY/SECRET_KEY 미설정 — legacy 자격증명 이관 스킵")
|
||||
return
|
||||
|
||||
import hashlib
|
||||
|
||||
app_key_hash = hashlib.sha256(app_key.encode()).hexdigest()
|
||||
dup = await session.execute(
|
||||
select(UserKisCredential).where(UserKisCredential.app_key_hash == app_key_hash)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
logger.warning("[Bootstrap] 동일 앱키가 이미 등록되어 있어 legacy 이관 스킵")
|
||||
return
|
||||
|
||||
account_product_code = account_no[8:10] if len(account_no) >= 10 else "01"
|
||||
|
||||
credential = UserKisCredential(
|
||||
user_id=owner.id,
|
||||
mode=settings.KIS_MODE,
|
||||
app_key_enc=encrypt(app_key),
|
||||
app_secret_enc=encrypt(app_secret),
|
||||
account_no_enc=encrypt(account_no),
|
||||
account_product_code=account_product_code,
|
||||
app_key_hash=app_key_hash,
|
||||
app_key_hint=app_key[:6],
|
||||
is_enabled=True,
|
||||
auto_trade_enabled=settings.AUTO_TRADE_ENABLED,
|
||||
last_verified_at=None,
|
||||
key_version=current_key_version(),
|
||||
)
|
||||
session.add(credential)
|
||||
await session.commit()
|
||||
logger.info(f"[Bootstrap] legacy KIS 자격증명 이관 완료 (owner={owner.username}, app_key={app_key[:6]}…)")
|
||||
|
||||
|
||||
async def _backfill_legacy_owner_ids(session, owner_id: int) -> None:
|
||||
"""alembic 0011 과 동일한 백필을 idempotent 하게 재수행 (user_id IS NULL 인 행만 대상).
|
||||
|
||||
M8: PostgreSQL은 트랜잭션 내 한 문장이 실패(예: 테이블/컬럼 미존재)하면 세션이
|
||||
`InFailedSqlTransaction` 상태가 되어 이후 모든 문장이 연쇄 실패한다. 문장별로
|
||||
`session.begin_nested()`(SAVEPOINT)로 감싸 한 테이블의 실패가 나머지 테이블
|
||||
백필을 막지 않게 한다.
|
||||
"""
|
||||
for table in _BACKFILL_TABLES:
|
||||
try:
|
||||
async with session.begin_nested():
|
||||
await session.execute(
|
||||
text(f"UPDATE {table} SET user_id = :owner WHERE user_id IS NULL"),
|
||||
{"owner": owner_id},
|
||||
)
|
||||
except Exception as e: # 컬럼/테이블이 아직 없는 초기 상태 등 — 개별 실패는 무시하고 계속
|
||||
logger.debug(f"[Bootstrap] {table} 백필 스킵: {e}")
|
||||
|
||||
try:
|
||||
async with session.begin_nested():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO stock_bans_v2 (user_id, stock_code, consecutive_losses, ban_until, updated_at)
|
||||
SELECT :owner, stock_code, consecutive_losses, ban_until, updated_at
|
||||
FROM stock_bans
|
||||
ON CONFLICT (user_id, stock_code) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"owner": owner_id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Bootstrap] stock_bans_v2 백필 스킵: {e}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def ensure_bootstrap_admin() -> None:
|
||||
"""앱 기동 시 1회 호출. 실패해도 예외를 전파하지 않는다(로그만 남김)."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await _seed_admin_if_empty(session)
|
||||
except Exception as e:
|
||||
logger.error(f"[Bootstrap] 관리자 계정 시드 실패 (non-critical): {e}")
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
owner = await _get_legacy_owner(session)
|
||||
if owner is None:
|
||||
logger.info("[Bootstrap] 관리자 계정 없음 — legacy 자격증명 이관/백필 스킵")
|
||||
return
|
||||
await _seed_legacy_kis_credential(session, owner)
|
||||
await _backfill_legacy_owner_ids(session, owner.id)
|
||||
except Exception as e:
|
||||
logger.error(f"[Bootstrap] legacy 자격증명 이관/백필 실패 (non-critical): {e}")
|
||||
62
backend/app/services/tenant/context.py
Normal file
62
backend/app/services/tenant/context.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""멀티테넌트 사용자 실행 컨텍스트 (설계 문서 §2, §5.6).
|
||||
|
||||
`UserContext`는 사용자 1명의 매매 실행에 필요한 모든 상태(KIS 클라이언트,
|
||||
리스크 매니저, 데이트레이딩 상태 등)를 한데 묶는다. 실제 조립(레지스트리)은
|
||||
dev-team/risk-monitor-agent가 담당하는 `registry.py`에서 이루어지며, 이 파일은
|
||||
데이터 구조만 정의한다 — import 순환을 피하기 위해 mdd/loss_tracker/dt_state는
|
||||
구체 타입을 import하지 않고 `Any`로 둔다.
|
||||
|
||||
이 모듈은 DB 세션을 열지 않는다(자격증명 복호화는 호출 측이 수행해 넘겨준다).
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserContext:
|
||||
"""사용자 1명의 매매 실행 컨텍스트.
|
||||
|
||||
Attributes:
|
||||
user_id: `users.id`. legacy(단일 계좌) 컨텍스트는 legacy owner id 또는 None.
|
||||
username: 표시/로그용.
|
||||
mode: "virtual" | "real" — `kis.base_url`과 동일해야 함.
|
||||
kis: 이 사용자의 주문·잔고 전용 `KoreaInvestmentAPI` 인스턴스
|
||||
(`KoreaInvestmentAPI.for_user(...)` 또는 legacy 전역 `korea_investment_api`).
|
||||
mdd: 사용자별 `MDDManager` 인스턴스 (risk-monitor-agent 담당, 타입은 순환 방지 위해 Any).
|
||||
loss_tracker: 사용자별 `ConsecutiveLossTracker` 인스턴스 (Any, 위와 동일 이유).
|
||||
dt_state: 사용자별 `DayTradingState` 인스턴스 (2단계에서 사용, 1단계는 None 허용).
|
||||
portfolio_cache: 사용자별 `PortfolioCache` 인스턴스 (2단계, 1단계는 None 허용).
|
||||
auto_trade_enabled: 사용자 단위 자동매매 on/off.
|
||||
is_legacy: True면 기존 전역 싱글톤(korea_investment_api/mdd_manager/loss_tracker)을
|
||||
그대로 감싼 컨텍스트 — Redis 락/키 네임스페이스가 하위 호환 경로(user_id 미포함)를 쓴다.
|
||||
"""
|
||||
|
||||
user_id: Optional[int]
|
||||
username: str
|
||||
mode: str
|
||||
kis: KoreaInvestmentAPI
|
||||
mdd: Any
|
||||
loss_tracker: Any
|
||||
dt_state: Any = None
|
||||
portfolio_cache: Any = None
|
||||
auto_trade_enabled: bool = False
|
||||
is_legacy: bool = False
|
||||
|
||||
@property
|
||||
def effective_auto_trade_enabled(self) -> bool:
|
||||
"""`auto_trade_enabled`의 실시간 버전.
|
||||
|
||||
legacy 컨텍스트는 `registry.legacy()`가 조립 시점에 `settings.AUTO_TRADE_ENABLED`를
|
||||
스냅샷해 최대 60초(TTL) 동안 고정한다(L4) — 런타임에 `.env`/설정을 토글해도
|
||||
캐시가 갱신되기 전까지 반영되지 않는 문제. legacy 컨텍스트에서는 필드 대신 이
|
||||
프로퍼티로 `settings.AUTO_TRADE_ENABLED`를 매번 다시 읽어 즉시 반영한다.
|
||||
사용자 컨텍스트(is_legacy=False)는 DB에 저장된 사용자별 토글이 필드값이므로
|
||||
그대로 반환한다. portfolio_v2 buy 가드는 `ctx.auto_trade_enabled` 대신 이
|
||||
프로퍼티 사용을 권장.
|
||||
"""
|
||||
if self.is_legacy:
|
||||
from app.core.config import settings
|
||||
return settings.AUTO_TRADE_ENABLED
|
||||
return self.auto_trade_enabled
|
||||
75
backend/app/services/tenant/kis_pool.py
Normal file
75
backend/app/services/tenant/kis_pool.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""사용자별 `KoreaInvestmentAPI` 인스턴스 풀 (설계 문서 §5.5).
|
||||
|
||||
- 자격증명 복호화는 호출 측(Registry/credentials API, B그룹)이 수행해
|
||||
평문(app_key/app_secret/account_no)을 넘겨준다 — 이 모듈은 DB 세션을 열지 않는다.
|
||||
- 캐시 키는 `user_id` 하나뿐이다(1단계는 사용자당 자격증명 1세트 전제, §3.1).
|
||||
자격증명이 바뀌면 호출 측이 반드시 `invalidate(user_id)`를 먼저 호출해야 한다
|
||||
(여기서는 캐시가 있으면 그대로 재사용 — 자격증명 변경 감지를 하지 않음).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI
|
||||
from app.services import korea_investment_api as _kis_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KISClientPool:
|
||||
"""사용자별 `KoreaInvestmentAPI` 인스턴스 캐시 (asyncio.Lock으로 동시 생성 방지)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._instances: Dict[int, KoreaInvestmentAPI] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_or_create(
|
||||
self,
|
||||
user_id: int,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
account_no: str,
|
||||
mode: str,
|
||||
) -> KoreaInvestmentAPI:
|
||||
"""캐시에 있으면 반환, 없으면 `KoreaInvestmentAPI.for_user(...)`로 생성 후 캐시."""
|
||||
existing = self._instances.get(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
async with self._lock:
|
||||
existing = self._instances.get(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
instance = KoreaInvestmentAPI.for_user(
|
||||
user_id=user_id,
|
||||
app_key=app_key,
|
||||
app_secret=app_secret,
|
||||
account_no=account_no,
|
||||
mode=mode,
|
||||
)
|
||||
self._instances[user_id] = instance
|
||||
return instance
|
||||
|
||||
async def invalidate(self, user_id: int) -> None:
|
||||
"""캐시된 인스턴스를 정리(httpx 클라이언트 close)하고 제거한다.
|
||||
|
||||
자격증명 교체/삭제 시 credentials API(B그룹)가 호출해야 한다.
|
||||
"""
|
||||
async with self._lock:
|
||||
instance = self._instances.pop(user_id, None)
|
||||
if instance is not None:
|
||||
try:
|
||||
await instance.close()
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ KISClientPool.invalidate: user_id=%s close 실패: %s", user_id, e)
|
||||
|
||||
def get_legacy(self) -> KoreaInvestmentAPI:
|
||||
"""전역 legacy 싱글톤 반환 (`MULTI_TENANT_ENABLED=False` 또는 legacy owner 경로용)."""
|
||||
return _kis_module.korea_investment_api
|
||||
|
||||
def active_user_ids(self) -> list:
|
||||
"""현재 캐시된(생성된) 사용자 id 목록."""
|
||||
return list(self._instances.keys())
|
||||
|
||||
|
||||
# 모듈 싱글톤
|
||||
kis_client_pool = KISClientPool()
|
||||
251
backend/app/services/tenant/registry.py
Normal file
251
backend/app/services/tenant/registry.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""사용자 실행 컨텍스트 레지스트리 (설계 문서 §2, §5.6).
|
||||
|
||||
`UserContext` 조립(자격증명 복호화 → KIS 클라이언트 풀 → 리스크 매니저 팩토리 →
|
||||
상태 복원)을 한곳에 모아 캐싱한다. FastAPI 의존성 `get_user_context()`도 여기서
|
||||
제공한다(트레이딩 실행/엔드포인트 양쪽에서 재사용).
|
||||
|
||||
- DB 세션은 항상 호출 측이 넘겨준다 — 이 모듈은 `AsyncSessionLocal()`을 직접
|
||||
열지 않는다(§CLAUDE.md 아키텍처 원칙 1). `iter_active()`만 예외적으로 스케줄러가
|
||||
세션을 만들어 넘겨주는 것을 전제로 `db` 인자를 필수로 받는다.
|
||||
- 복호화 실패 등 개별 사용자 오류는 그 사용자만 None/스킵 처리하고 다른 사용자에
|
||||
전파하지 않는다.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.crypto import CredentialDecryptError, decrypt
|
||||
from app.core.database import get_db
|
||||
from app.core.tenant_auth import CurrentUser, get_current_user, get_legacy_owner
|
||||
from app.models.models import UserKisCredential
|
||||
from app.services.tenant.context import UserContext
|
||||
from app.services.tenant.kis_pool import kis_client_pool
|
||||
from app.services.tenant.risk_factory import (
|
||||
create_loss_tracker_for_user,
|
||||
create_mdd_manager_for_user,
|
||||
load_user_risk_state,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LEGACY_OWNER_TTL_SEC = 60
|
||||
_ACTIVE_LIST_TTL_SEC = 60
|
||||
|
||||
|
||||
class UserContextRegistry:
|
||||
"""사용자별 `UserContext` 캐시 + 조립 로직."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._contexts: dict[int, UserContext] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
self._legacy_ctx: UserContext | None = None
|
||||
self._legacy_owner_id: int | None = None
|
||||
self._legacy_checked_at: float = 0.0
|
||||
self._legacy_lock = asyncio.Lock()
|
||||
self._legacy_owner_confirmed: bool = False
|
||||
|
||||
self._active_cache: list[UserContext] | None = None
|
||||
self._active_cached_at: float = 0.0
|
||||
|
||||
# ── 사용자별 컨텍스트 조립 ────────────────────────────────────────────
|
||||
async def get(self, user_id: int, db: AsyncSession) -> UserContext | None:
|
||||
"""캐시 히트면 즉시 반환, 미스면 DB에서 자격증명을 읽어 새로 조립한다.
|
||||
|
||||
M4: `asyncio.Lock` 은 캐시 dict 갱신 구간(더블체크 패턴)만 감싼다 — DB I/O,
|
||||
PBKDF2 복호화(스레드로 위임), KIS 클라이언트 생성, Redis/DB 리스크 상태 복원은
|
||||
모두 락 밖에서 수행해 이벤트 루프 직렬화를 피한다. 동시에 같은 user_id 로 호출되면
|
||||
중복 조립이 일어날 수 있으나(락 밖 조립) 마지막에 dict 갱신만 락으로 보호하고
|
||||
먼저 캐시된 인스턴스를 우선한다.
|
||||
"""
|
||||
cached = self._contexts.get(user_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
result = await db.execute(
|
||||
select(UserKisCredential).where(UserKisCredential.user_id == user_id)
|
||||
)
|
||||
cred = result.scalar_one_or_none()
|
||||
if cred is None or not cred.is_enabled:
|
||||
logger.debug(f"[UserContextRegistry] user={user_id} 자격증명 없음/비활성 — None 반환")
|
||||
return None
|
||||
|
||||
try:
|
||||
app_key = await asyncio.to_thread(decrypt, cred.app_key_enc, key_version=cred.key_version)
|
||||
app_secret = await asyncio.to_thread(decrypt, cred.app_secret_enc, key_version=cred.key_version)
|
||||
account_no = await asyncio.to_thread(decrypt, cred.account_no_enc, key_version=cred.key_version)
|
||||
except CredentialDecryptError as e:
|
||||
logger.error(f"[UserContextRegistry] user={user_id} 자격증명 복호화 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="자격증명 복호화 실패 — 서버 키 변경 여부 확인",
|
||||
) from e
|
||||
|
||||
try:
|
||||
kis = await kis_client_pool.get_or_create(
|
||||
user_id=user_id,
|
||||
app_key=app_key,
|
||||
app_secret=app_secret,
|
||||
account_no=account_no,
|
||||
mode=cred.mode,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[UserContextRegistry] user={user_id} KIS 클라이언트 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
mdd = create_mdd_manager_for_user(user_id)
|
||||
loss_tracker = create_loss_tracker_for_user(user_id)
|
||||
await load_user_risk_state(user_id, mdd, loss_tracker, db)
|
||||
|
||||
ctx = UserContext(
|
||||
user_id=user_id,
|
||||
username="",
|
||||
mode=cred.mode,
|
||||
kis=kis,
|
||||
mdd=mdd,
|
||||
loss_tracker=loss_tracker,
|
||||
auto_trade_enabled=bool(cred.auto_trade_enabled),
|
||||
is_legacy=False,
|
||||
)
|
||||
async with self._lock:
|
||||
existing = self._contexts.get(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
self._contexts[user_id] = ctx
|
||||
return ctx
|
||||
|
||||
# ── legacy(단일 계좌) 컨텍스트 ────────────────────────────────────────
|
||||
async def legacy(self, db: AsyncSession | None = None) -> UserContext:
|
||||
"""기존 전역 싱글톤(`korea_investment_api`/`mdd_manager`/`loss_tracker`)을 감싼 컨텍스트.
|
||||
|
||||
owner id는 60초 TTL로 재확인한다(관리자 계정이 나중에 생기는 초기 부팅 구간 대응).
|
||||
owner 가 아직 없으면(관리자 미존재) `user_id=None`으로 반환하고 TTL 캐시를 걸지
|
||||
않는다(M1) — `users.id=0`은 존재하지 않는 FK 위반 값이라 0 대신 None 으로 통일한다.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._legacy_ctx is not None
|
||||
and self._legacy_owner_confirmed
|
||||
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
|
||||
):
|
||||
return self._legacy_ctx
|
||||
|
||||
async with self._legacy_lock:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._legacy_ctx is not None
|
||||
and self._legacy_owner_confirmed
|
||||
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
|
||||
):
|
||||
return self._legacy_ctx
|
||||
|
||||
owner_id: int | None = None
|
||||
if db is not None:
|
||||
try:
|
||||
owner = await get_legacy_owner(db)
|
||||
if owner is not None:
|
||||
owner_id = owner.id
|
||||
except Exception as e:
|
||||
logger.warning(f"[UserContextRegistry] legacy owner 조회 실패: {e}")
|
||||
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
|
||||
self._legacy_ctx = UserContext(
|
||||
user_id=owner_id,
|
||||
username="legacy",
|
||||
mode=settings.KIS_MODE,
|
||||
kis=korea_investment_api,
|
||||
mdd=mdd_manager,
|
||||
loss_tracker=loss_tracker,
|
||||
auto_trade_enabled=settings.AUTO_TRADE_ENABLED,
|
||||
is_legacy=True,
|
||||
)
|
||||
self._legacy_owner_id = owner_id
|
||||
self._legacy_owner_confirmed = owner_id is not None
|
||||
self._legacy_checked_at = now
|
||||
return self._legacy_ctx
|
||||
|
||||
# ── 인증 사용자 → 컨텍스트 해석 ───────────────────────────────────────
|
||||
async def resolve(self, current_user: CurrentUser, db: AsyncSession) -> UserContext:
|
||||
"""`CurrentUser` → `UserContext`. 실패 시 400 HTTPException."""
|
||||
legacy_ctx = await self.legacy(db)
|
||||
|
||||
use_legacy = (
|
||||
not settings.MULTI_TENANT_ENABLED
|
||||
or current_user.is_api_key
|
||||
or (current_user.id is not None and current_user.id == legacy_ctx.user_id)
|
||||
)
|
||||
if use_legacy:
|
||||
return legacy_ctx
|
||||
|
||||
if current_user.id is None:
|
||||
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
|
||||
|
||||
ctx = await self.get(current_user.id, db)
|
||||
if ctx is None:
|
||||
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
|
||||
return ctx
|
||||
|
||||
# ── 2단계 스케줄러용: 활성 사용자 전체 순회 ───────────────────────────
|
||||
async def iter_active(self, db: AsyncSession) -> list[UserContext]:
|
||||
"""`is_enabled and auto_trade_enabled` 인 모든 사용자의 컨텍스트 (60초 TTL 캐시).
|
||||
|
||||
지금은 조립 로직만 제공 — 실제 2단계 스케줄러 연결은 이번 범위 밖.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._active_cache is not None and (now - self._active_cached_at) < _ACTIVE_LIST_TTL_SEC:
|
||||
return self._active_cache
|
||||
|
||||
result = await db.execute(
|
||||
select(UserKisCredential).where(
|
||||
UserKisCredential.is_enabled.is_(True),
|
||||
UserKisCredential.auto_trade_enabled.is_(True),
|
||||
)
|
||||
)
|
||||
creds = result.scalars().all()
|
||||
|
||||
contexts: list[UserContext] = []
|
||||
for cred in creds:
|
||||
try:
|
||||
ctx = await self.get(cred.user_id, db)
|
||||
except Exception as e:
|
||||
logger.error(f"[UserContextRegistry] iter_active: user={cred.user_id} 조립 실패: {e}")
|
||||
ctx = None
|
||||
if ctx is not None:
|
||||
contexts.append(ctx)
|
||||
|
||||
self._active_cache = contexts
|
||||
self._active_cached_at = now
|
||||
return contexts
|
||||
|
||||
# ── 캐시 무효화 ───────────────────────────────────────────────────────
|
||||
async def invalidate(self, user_id: int) -> None:
|
||||
"""자격증명 변경/삭제 시 credentials API가 호출 — 컨텍스트 캐시 + KIS 풀 정리."""
|
||||
async with self._lock:
|
||||
self._contexts.pop(user_id, None)
|
||||
await kis_client_pool.invalidate(user_id)
|
||||
self._active_cache = None
|
||||
|
||||
async def invalidate_all(self) -> None:
|
||||
async with self._lock:
|
||||
self._contexts.clear()
|
||||
self._active_cache = None
|
||||
|
||||
|
||||
# 모듈 싱글톤
|
||||
user_context_registry = UserContextRegistry()
|
||||
|
||||
|
||||
async def get_user_context(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserContext:
|
||||
"""FastAPI 의존성 — 세션/API 키 인증 사용자를 `UserContext`로 해석."""
|
||||
return await user_context_registry.resolve(user, db)
|
||||
71
backend/app/services/tenant/risk_factory.py
Normal file
71
backend/app/services/tenant/risk_factory.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""사용자 스코프 리스크 매니저 팩토리 (설계 문서 §5.6).
|
||||
|
||||
`UserContextRegistry`(dev-team/trading-agent 조립 담당)가 사용자 컨텍스트를
|
||||
만들 때 이 팩토리로 `MDDManager`/`ConsecutiveLossTracker` 인스턴스를 새로
|
||||
생성하고 영속화 상태(Redis `mdd:state:{user_id}`, `stock_bans_v2`)를 복원한다.
|
||||
|
||||
기존 전역 싱글톤(`app.services.mdd_manager.mdd_manager`,
|
||||
`app.services.loss_tracker.loss_tracker`)은 legacy 컨텍스트 전용으로 그대로
|
||||
남겨두고 이 모듈에서 재사용하지 않는다 — 여러 사용자가 동일 인스턴스를 공유하면
|
||||
peak_balance/연속손실 카운트가 뒤섞이기 때문이다.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from app.services.mdd_manager import MDDManager
|
||||
from app.services.loss_tracker import ConsecutiveLossTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_mdd_manager_for_user(user_id: int) -> MDDManager:
|
||||
"""사용자 전용 `MDDManager` 신규 인스턴스 생성.
|
||||
|
||||
peak_balance/is_paused 등은 기본값(0/False)에서 시작 — 실제 복원은
|
||||
`load_user_risk_state()`가 `load_from_redis_v2(user_id)`로 수행한다.
|
||||
"""
|
||||
return MDDManager()
|
||||
|
||||
|
||||
def create_loss_tracker_for_user(user_id: int) -> ConsecutiveLossTracker:
|
||||
"""사용자 전용 `ConsecutiveLossTracker` 신규 인스턴스 생성.
|
||||
|
||||
`ban_count`/`ban_calendar_days`는 전역 설정(§CLAUDE.md 5.1: 3연패/42일)을
|
||||
그대로 따른다 — 사용자별 오버라이드는 이번 범위 밖.
|
||||
인스턴스의 `user_id` 속성을 세팅해 두면 호출 측(스케줄러/엔드포인트)이
|
||||
`ctx.loss_tracker.save_to_db_v2(ctx.loss_tracker.user_id, stock_code)`처럼
|
||||
컨텍스트에서 user_id를 다시 꺼낼 필요 없이 인스턴스에서 바로 참조할 수 있다.
|
||||
"""
|
||||
try:
|
||||
from app.core.config import settings
|
||||
tracker = ConsecutiveLossTracker(
|
||||
ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT,
|
||||
ban_calendar_days=42,
|
||||
)
|
||||
except Exception:
|
||||
tracker = ConsecutiveLossTracker()
|
||||
tracker.user_id = user_id
|
||||
return tracker
|
||||
|
||||
|
||||
async def load_user_risk_state(
|
||||
ctx_user_id: int,
|
||||
mdd: MDDManager,
|
||||
tracker: ConsecutiveLossTracker,
|
||||
db,
|
||||
) -> None:
|
||||
"""사용자 컨텍스트 생성 직후 1회 호출 — Redis(MDD) + DB(ban) 상태 복원.
|
||||
|
||||
두 복원 모두 실패해도 예외를 전파하지 않는다(각 `_v2` 메서드 내부에서
|
||||
이미 try/except 처리됨) — 신규 사용자는 그냥 초기 상태로 시작한다.
|
||||
"""
|
||||
try:
|
||||
restored = await mdd.load_from_redis_v2(ctx_user_id)
|
||||
if not restored:
|
||||
logger.debug(f"[risk_factory] user={ctx_user_id} MDD 상태 없음 — 초기값으로 시작")
|
||||
except Exception as e:
|
||||
logger.warning(f"[risk_factory] user={ctx_user_id} MDD 복원 실패: {e}")
|
||||
|
||||
try:
|
||||
await tracker.load_from_db_v2(db, ctx_user_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[risk_factory] user={ctx_user_id} loss_tracker 복원 실패: {e}")
|
||||
@@ -208,6 +208,111 @@ async def safe_place_order(
|
||||
await _release_order_lock(stock_code, order_type)
|
||||
|
||||
|
||||
# ── [멀티테넌트 1단계 §5.8] 사용자 스코프 주문 락/래퍼 ────────────────────────
|
||||
# 기존 safe_place_order/_acquire_order_lock/_auto_cancel_if_pending는 시그니처
|
||||
# 불변 유지. 사용자별 계좌(ctx.kis)로 주문하는 경로는 아래 _v2 함수를 사용한다.
|
||||
async def _acquire_order_lock_v2(user_id, stock_code: str, order_type: str) -> bool:
|
||||
"""Redis 분산락 획득 — 사용자 네임스페이스 키(`order_lock:{user_id}:{code}:{type}`).
|
||||
|
||||
구조는 `_acquire_order_lock`과 동일(SETNX+TTL 60초). legacy 컨텍스트(user_id가
|
||||
None 또는 legacy owner id)도 이 함수를 쓰면 키에 user_id가 섞이므로, legacy
|
||||
경로는 기존 `_acquire_order_lock`(user_id 없는 키)을 그대로 사용해야 한다.
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import get_redis
|
||||
client = get_redis()
|
||||
if not client:
|
||||
return True
|
||||
key = f"order_lock:{user_id}:{stock_code}:{order_type}"
|
||||
result = await client.set(key, "1", nx=True, ex=_ORDER_LOCK_TTL)
|
||||
return result is True
|
||||
except Exception as e:
|
||||
logger.debug(f"[order_lock_v2] user_id={user_id} 획득 오류 (패스): {e}")
|
||||
return True
|
||||
|
||||
|
||||
async def _release_order_lock_v2(user_id, stock_code: str, order_type: str) -> None:
|
||||
"""Redis 분산락 해제 — `_acquire_order_lock_v2`와 짝을 이루는 사용자 네임스페이스 키."""
|
||||
try:
|
||||
from app.core.redis import delete_key
|
||||
await delete_key(f"order_lock:{user_id}:{stock_code}:{order_type}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def safe_place_order_v2(
|
||||
ctx, # UserContext (순환 임포트 회피 — 타입은 app.services.tenant.context.UserContext)
|
||||
stock_code: str,
|
||||
order_type: str,
|
||||
quantity: int,
|
||||
price: float | None = None,
|
||||
is_domestic: bool = True,
|
||||
exchange_code: str = "NASD",
|
||||
) -> dict:
|
||||
"""`safe_place_order`의 사용자 스코프 버전 (설계 문서 §5.8).
|
||||
|
||||
- `ctx.is_legacy`면 기존 전역 경로(`_acquire_order_lock`/`_release_order_lock`,
|
||||
`_get_order_semaphore(stock_code)`)를 그대로 사용 — swing/daytrading exit_service
|
||||
등 기존 스케줄러가 잡는 락과 같은 키여야 같은 계좌·종목 동시 매도가 직렬화된다.
|
||||
사용자 컨텍스트(`is_legacy=False`)만 사용자 네임스페이스 키(`{user_id}:{code}`)를 쓴다.
|
||||
- 세마포어: `_get_order_semaphore(f"{ctx.user_id}:{stock_code}")` — 기존 함수 재사용,
|
||||
키 문자열만 사용자 네임스페이스로 바꿈(시그니처 불변).
|
||||
- Redis 락: `_acquire_order_lock_v2`/`_release_order_lock_v2` (legacy는 v1 그대로).
|
||||
- 주문 실행: `ctx.kis.place_order(...)`/`ctx.kis.place_overseas_order(...)`.
|
||||
- 반환 형식은 `safe_place_order`와 동일.
|
||||
"""
|
||||
if ctx.is_legacy:
|
||||
semaphore_key = stock_code
|
||||
lock_acquired = await _acquire_order_lock(stock_code, order_type)
|
||||
else:
|
||||
semaphore_key = f"{ctx.user_id}:{stock_code}"
|
||||
lock_acquired = await _acquire_order_lock_v2(ctx.user_id, stock_code, order_type)
|
||||
|
||||
if not lock_acquired:
|
||||
logger.warning(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} — 분산락 획득 실패 (이미 처리 중)")
|
||||
return {"rt_cd": "-1", "msg1": "order_lock_busy"}
|
||||
|
||||
try:
|
||||
async with _get_order_semaphore(semaphore_key):
|
||||
if is_domestic:
|
||||
result = await ctx.kis.place_order(
|
||||
stock_code=stock_code,
|
||||
order_type=order_type,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
)
|
||||
else:
|
||||
result = await ctx.kis.place_overseas_order(
|
||||
symbol=stock_code,
|
||||
order_type=order_type,
|
||||
quantity=quantity,
|
||||
price=price or 0,
|
||||
exchange_code=exchange_code,
|
||||
)
|
||||
return result or {"rt_cd": "-1", "msg1": "no_response"}
|
||||
except Exception as e:
|
||||
logger.error(f"[SAFE_ORDER_V2] user_id={ctx.user_id} {stock_code} {order_type} 실패: {e}")
|
||||
return {"rt_cd": "-1", "msg1": str(e)}
|
||||
finally:
|
||||
if ctx.is_legacy:
|
||||
await _release_order_lock(stock_code, order_type)
|
||||
else:
|
||||
await _release_order_lock_v2(ctx.user_id, stock_code, order_type)
|
||||
|
||||
|
||||
async def _auto_cancel_if_pending_v2(ctx, order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
|
||||
"""`_auto_cancel_if_pending`의 사용자 스코프 버전 — `ctx.kis.cancel_order`를 사용."""
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
result = await ctx.kis.cancel_order(order_id, quantity)
|
||||
if result.get("rt_cd") == "0":
|
||||
logger.info(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} 미체결 자동 취소 완료: {stock_code} order_id={order_id}")
|
||||
else:
|
||||
logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
|
||||
except Exception as e:
|
||||
logger.debug(f"[AUTO_CANCEL_V2] user_id={ctx.user_id} {stock_code} — {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# ── 미체결 주문 1분 후 자동 취소 ─────────────────────────────────────────────
|
||||
async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, delay: int = 60) -> None:
|
||||
"""delay초 후에도 PENDING 상태면 자동 취소.
|
||||
|
||||
Reference in New Issue
Block a user