diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml new file mode 100644 index 0000000..8d9679f --- /dev/null +++ b/.github/workflows/backend-test.yml @@ -0,0 +1,104 @@ +name: Backend Tests + +on: + push: + branches: ["*"] + paths: + - "backend/**" + pull_request: + branches: ["*"] + paths: + - "backend/**" + +permissions: + contents: read + pull-requests: write + issues: write + checks: write + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Cache uv packages + uses: actions/cache@v3 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-uv-${{ hashFiles('backend/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-uv- + + - name: Install dependencies + run: | + uv sync --all-groups + + - name: Run tests with pytest + run: | + uv run pytest -v --tb=short --cov=app --cov-report=xml --cov-report=term --junitxml=pytest.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./backend/coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Pytest coverage comment + if: github.event_name == 'pull_request' + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-xml-coverage-path: ./backend/coverage.xml + junitxml-path: ./backend/pytest.xml + title: Backend 테스트 커버리지 리포트 + badge-title: 커버리지 + hide-badge: false + hide-report: false + create-new-comment: false + hide-comment: false + report-only-changed-files: false + coverage-path-prefix: backend/app/ + + lint: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install dependencies + run: | + uv sync --all-groups + + - name: Run ruff + run: | + uv run ruff check . || true diff --git a/.gitignore b/.gitignore index 2a4fa24..e970aea 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ wheels/ **/.claude/** **CLAUDE.md** +GEMINI.md +.gemini +CLAUDE.md +.claude diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 0000000..aa6980c Binary files /dev/null and b/backend/.coverage differ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1419d0e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +.venv + +# uv +.uv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Documentation +*.md +!README.md + +# Git +.git/ +.gitignore + +# Alembic +alembic/versions/__pycache__/ + +# Logs +*.log + +# Others +.DS_Store +Thumbs.db diff --git a/backend/.env.example b/backend/.env.example index 2d67e9b..64fc2fe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,11 +1,18 @@ -UPBIT_ACCESS_KEY= -UPBIT_SECRET_KEY= -OPENAI_API_KEY= +# Upbit API +UPBIT_ACCESS_KEY=your_upbit_access_key +UPBIT_SECRET_KEY=your_upbit_secret_key + +# OpenAI API +OPENAI_API_KEY=your_openai_api_key # FastAPI -APP_NAME= -APP_VERSION= -DATABASE_URL= -DB_POOL_SIZE= -DB_MAX_OVERFLOW= -CORS_ORIGINS= \ No newline at end of file +APP_NAME=joo-coin +APP_VERSION=0.1.0 + +# Database (Docker 환경용 기본값) +DATABASE_URL=mysql+asyncmy://root:1234@joo-coin-db:3306/joo_coin_db +DB_POOL_SIZE=5 +DB_MAX_OVERFLOW=10 + +# CORS (개발 환경용 기본값) +CORS_ORIGINS=http://localhost:3000,http://localhost:8000 diff --git a/backend/.gitignore b/backend/.gitignore index f32e31a..b766c78 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,6 @@ .idea/ .DS_Store +GEMINI.md +.gemini +CLAUDE.md +.claude diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..439cb15 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,68 @@ +# Stage 1: Builder - 의존성 설치 +FROM python:3.11-slim as builder + +# 빌드 도구 설치 (C 확장 모듈 빌드용) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + python3-dev \ + default-libmysqlclient-dev && \ + rm -rf /var/lib/apt/lists/* + +# uv 설치 +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# 작업 디렉토리 설정 +WORKDIR /app + +# Python 환경변수 설정 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +# 의존성 파일 복사 +COPY pyproject.toml uv.lock ./ + +# 의존성 설치 (가상환경에 설치) +RUN uv sync --frozen --no-dev + +# Stage 2: Runtime - 실행 환경 +FROM python:3.11-slim + +# 런타임 라이브러리 설치 (C 확장 모듈 실행용) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libmariadb3 && \ + rm -rf /var/lib/apt/lists/* + +# 작업 디렉토리 설정 +WORKDIR /app + +# Python 환경변수 설정 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/app/.venv/bin:$PATH" + +# 비루트 사용자 생성 +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +# builder에서 가상환경 복사 +COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv + +# 애플리케이션 코드 복사 +COPY --chown=appuser:appuser . . + +# 비루트 사용자로 전환 +USER appuser + +# 포트 노출 +EXPOSE 8000 + +# 헬스체크 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5)" + +# 애플리케이션 실행 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md index 598dedf..84684f7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,523 @@ -# Backend +# JOO-COIN Backend + +암호화폐 자동 거래 시스템 백엔드 API + +## 목차 + +- [프로젝트 개요](#프로젝트-개요) +- [기술 스택](#기술-스택) +- [프로젝트 구조](#프로젝트-구조) +- [모듈별 상세 설명](#모듈별-상세-설명) +- [API 명세](#api-명세) +- [데이터베이스 스키마](#데이터베이스-스키마) +- [자동 거래 시스템](#자동-거래-시스템) +- [설치 및 실행](#설치-및-실행) +- [환경변수](#환경변수) + +--- + +## 프로젝트 개요 + +JOO-COIN Backend는 Upbit 거래소와 OpenAI를 활용한 암호화폐 자동 거래 시스템입니다. + +**주요 기능:** +- AI 기반 거래 의사결정 (OpenAI GPT) +- Upbit API를 통한 자동 매수/매도 +- 거래 내역 관리 및 조회 +- 5분 주기 자동 거래 스케줄러 +- Cursor 기반 페이지네이션 + +--- + +## 기술 스택 + +| 분류 | 기술 | +|------|------| +| Framework | FastAPI | +| Server | Uvicorn (ASGI) | +| ORM | SQLAlchemy 2.0+ | +| Database | MySQL | +| Migration | Alembic | +| Scheduler | APScheduler | +| AI | OpenAI API | +| Exchange | Upbit API (pyupbit) | +| Formatter | Ruff | +| Package Manager | uv | + +--- + +## 프로젝트 구조 + +``` +app/ +├── ai/ # AI 분석 모듈 +│ ├── client/ +│ │ └── open_ai_client.py # OpenAI API 클라이언트 +│ ├── const/ +│ │ └── constans.py # AI 프롬프트 상수 +│ └── dto/ +│ └── ai_analysis_response.py # AI 응답 DTO +│ +├── ballance/ # 잔고 관리 모듈 +│ ├── model/ +│ │ └── balance.py # Balance 엔티티 +│ └── repository/ +│ └── balance_repository.py +│ +├── coin/ # 코인 관리 모듈 +│ ├── controller/ +│ │ └── my_coin_controller.py # 코인 API 라우터 +│ ├── dto/ +│ │ └── coin_dto.py # 코인 DTO +│ ├── model/ +│ │ └── coin.py # Coin 엔티티 +│ ├── repository/ +│ │ └── coin_repository.py +│ └── service/ +│ └── coin_service.py # 코인 비즈니스 로직 +│ +├── common/ # 공통 모듈 +│ ├── api/v1/ +│ │ └── v1_router.py # API v1 라우터 통합 +│ ├── model/ +│ │ └── base.py # Base 모델 +│ └── repository/ +│ └── base_repository.py # 제네릭 CRUD Repository +│ +├── configs/ # 설정 +│ ├── app.py # FastAPI 앱 설정, 스케줄러 +│ └── config.py # 환경변수 설정 +│ +├── trade/ # 거래 모듈 +│ ├── controller/ +│ │ └── trade_controller.py # 거래 API 라우터 +│ ├── dto/ +│ │ └── transaction_response.py # 거래 DTO +│ ├── model/ +│ │ ├── enums.py # TradeType, RiskLevel, TradeStatus +│ │ └── trade.py # Trade 엔티티 +│ ├── repository/ +│ │ └── trade_repository.py +│ └── service/ +│ └── trade_service.py # 거래 비즈니스 로직 +│ +├── upbit/ # Upbit API 통합 +│ ├── client/ +│ │ └── upbit_client.py # Upbit API 클라이언트 +│ ├── controller/ +│ │ └── upbit_controller.py # Upbit API 라우터 +│ ├── di/ +│ │ └── upbit_di.py # 의존성 주입 +│ └── dto/ +│ ├── coin_balance.py +│ ├── my_ballance_response.py +│ └── ohlcv_dto.py +│ +└── main.py # 애플리케이션 진입점 +``` + +--- + +## 모듈별 상세 설명 + +### AI 모듈 (`app/ai/`) + +**OpenAIClient** (`app/ai/client/open_ai_client.py`) + +OHLCV 데이터를 분석하여 거래 의사결정을 제공합니다. + +```python +async def get_bitcoin_trading_decision(df: pd.DataFrame) -> AiAnalysisResponse +``` + +**응답 형식:** +```json +{ + "decision": "buy/sell/hold", + "confidence": 0.88, + "reason": "기술적 분석 근거", + "risk_level": "none/low/medium/high", + "timestamp": "2025-11-22T10:30:00+09:00" +} +``` + +--- + +### Coin 모듈 (`app/coin/`) + +**CoinService** (`app/coin/service/coin_service.py`) + +| 메서드 | 설명 | +|--------|------| +| `get_all_active()` | 활성화된 거래 코인 목록 조회 | +| `create_coin(name)` | 코인 생성 또는 soft delete된 코인 복구 | +| `delete_coin(coin_id)` | 코인 soft delete (잔고 확인 후 삭제) | + +**삭제 제약조건:** 코인 삭제 시 Upbit 잔고를 확인하여 잔고가 남아있으면 삭제 불가 + +--- + +### Trade 모듈 (`app/trade/`) + +**TradeService** (`app/trade/service/trade_service.py`) + +| 메서드 | 설명 | +|--------|------| +| `execute()` | 모든 활성 코인에 대해 AI 분석 후 자동 거래 실행 | +| `get_transactions(cursor, limit)` | 거래 내역 조회 (Cursor 기반 페이지네이션) | + +**거래 실행 흐름:** +1. 활성화된 모든 코인 조회 +2. 현재 KRW 잔고 확인 +3. 각 코인별 OHLCV 데이터 조회 및 AI 분석 +4. BUY/SELL/HOLD 결정에 따라 거래 실행 +5. 거래 결과 DB 저장 +6. 최종 잔고 기록 + +--- + +### Upbit 모듈 (`app/upbit/`) + +**UpbitClient** (`app/upbit/client/upbit_client.py`) + +| 메서드 | 설명 | +|--------|------| +| `get_ohlcv(coin_name)` | OHLCV 데이터 조회 (DTO 반환) | +| `get_ohlcv_raw(coin_name)` | OHLCV 데이터 조회 (DataFrame 반환) | +| `get_current_price(coin_name)` | 현재 가격 조회 | +| `buy(coin_name, amount)` | 시장가 매수 | +| `sell(coin_name, amount)` | 시장가 매도 | +| `get_coin_balance(coin_name)` | 특정 코인 잔고 조회 | +| `get_krw_balance()` | KRW 잔고 조회 | +| `get_my_balance(coin_names)` | 여러 코인의 잔고 조회 | + +--- + +## API 명세 + +### Base URL +- 개발: `http://localhost:8000` +- Swagger: `http://localhost:8000/docs` + +--- + +### Coin API + +#### 활성화된 코인 목록 조회 + +```http +GET /api/v1/my/coins +``` + +**Response:** +```json +{ + "items": [ + {"id": 1, "name": "BTC"}, + {"id": 2, "name": "ETH"} + ] +} +``` + +--- + +#### 거래 코인 추가 + +```http +POST /api/v1/my/coins +Content-Type: application/json + +{ + "name": "BTC" +} +``` + +**Response:** +```json +{ + "id": 1, + "name": "BTC" +} +``` + +--- + +#### 코인 삭제 (Soft Delete) + +```http +DELETE /api/v1/my/coins/{coin_id} +``` + +**에러 케이스:** +- `400 Bad Request`: 코인에 잔고가 남아있는 경우 + +--- + +### Trade API + +#### 즉시 거래 실행 + +```http +POST /api/v1/trade +``` + +모든 활성 코인에 대해 AI 분석 및 자동 거래를 실행합니다. + +--- + +#### 거래 내역 조회 (Cursor 기반 페이지네이션) + +```http +GET /api/v1/trade/transactions?cursor={cursor}&limit={limit} +``` + +**Query Parameters:** +| 파라미터 | 타입 | 필수 | 설명 | 기본값 | +|----------|------|------|------|--------| +| cursor | integer | X | 이전 페이지의 마지막 거래 ID | - | +| limit | integer | X | 페이지당 항목 수 (1-100) | 20 | + +**Response:** +```json +{ + "items": [ + { + "id": 123, + "coin_id": 1, + "coin_name": "BTC", + "type": "buy", + "price": 50000000.0, + "amount": 0.001, + "risk_level": "medium", + "status": "success", + "timestamp": "2025-11-22 10:30:45", + "ai_reason": "상승 추세 예상", + "execution_reason": "매수 주문 실행 완료" + } + ], + "next_cursor": 100, + "has_next": true +} +``` + +**페이지네이션 로직:** +- `has_next`가 `true`이면 다음 페이지 존재 +- `next_cursor` 값을 다음 요청의 `cursor` 파라미터로 사용 +- `has_next`가 `false`이면 마지막 페이지 + +**사용 예시:** +```bash +# 첫 페이지 조회 +curl "http://localhost:8000/api/v1/trade/transactions?limit=20" + +# 다음 페이지 조회 +curl "http://localhost:8000/api/v1/trade/transactions?cursor=100&limit=20" +``` + +--- + +### Upbit API + +#### 코인 OHLCV 데이터 조회 + +```http +GET /api/v1/coins/{coin_name} +``` + +**Response:** +```json +{ + "items": [ + { + "timestamp": "2025-11-22T10:00:00", + "open": 50000000.0, + "high": 50500000.0, + "low": 49500000.0, + "close": 50200000.0, + "volume": 123.45, + "value": 6174900000.0 + } + ] +} +``` + +--- + +## 데이터베이스 스키마 + +### Coin 테이블 + +```sql +CREATE TABLE coins ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at DATETIME DEFAULT UTC_TIMESTAMP, + is_deleted BOOLEAN DEFAULT FALSE +); +``` + +--- + +### Trade 테이블 + +```sql +CREATE TABLE trades ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + coin_id BIGINT NULL, + trade_type VARCHAR(10) NULL, -- BUY/SELL/HOLD + price DECIMAL(20, 8) DEFAULT 0, + amount DECIMAL(20, 8) DEFAULT 0, + risk_level VARCHAR(10) NOT NULL, -- NONE/LOW/MEDIUM/HIGH + status VARCHAR(20) NOT NULL, -- PENDING/SUCCESS/PARTIAL_SUCCESS/FAILED/NO_ACTION + ai_reason TEXT NULL, + execution_reason TEXT NULL, + created_at DATETIME DEFAULT UTC_TIMESTAMP, + FOREIGN KEY (coin_id) REFERENCES coins(id) +); +``` + +--- + +### Balance 테이블 + +```sql +CREATE TABLE balances ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + amount DECIMAL(20, 8) NOT NULL, -- KRW 잔고 + coin_amount DECIMAL(20, 8) DEFAULT 0, -- 보유 코인의 KRW 가치 + created_at DATETIME DEFAULT UTC_TIMESTAMP +); +``` + +--- + +### Enum 정의 + +**TradeType (거래 유형)** +- `BUY`: 매수 +- `SELL`: 매도 +- `HOLD`: 보유 + +**RiskLevel (위험 수준)** +- `NONE`: 없음 +- `LOW`: 낮음 +- `MEDIUM`: 중간 +- `HIGH`: 높음 + +**TradeStatus (거래 상태)** +- `PENDING`: 진행 중 +- `SUCCESS`: 성공 +- `PARTIAL_SUCCESS`: 부분 성공 +- `FAILED`: 실패 +- `NO_ACTION`: 거래 없음 + +--- + +## 자동 거래 시스템 + +### 스케줄러 설정 + +**실행 주기:** 5분 (300초) + +**설정 위치:** `app/configs/app.py` + +### 실행 흐름 + +``` +스케줄러 (5분마다) + ↓ +TradeService.execute() + ↓ +1. 활성 코인 목록 조회 + ↓ +2. KRW 잔고 확인 + ↓ +3. 각 코인에 대해: + ├─ OHLCV 데이터 조회 (Upbit) + ├─ AI 분석 (OpenAI) + └─ BUY/SELL/HOLD 결정에 따라 거래 실행 + ↓ +4. 거래 결과 DB 저장 + ↓ +5. 최종 잔고 기록 +``` + +### 에러 처리 + +| 케이스 | 상태 | 설명 | +|--------|------|------| +| 활성 코인 없음 | NO_ACTION | 거래 가능한 코인이 없음 | +| KRW 잔고 없음 | NO_ACTION | KRW 잔고 부족 | +| AI 분석 실패 | FAILED | OpenAI API 오류 | +| 매수 잔액 부족 | FAILED | KRW 잔고 5000원 미만 | +| 매도 코인 없음 | FAILED | 보유 코인 없음 | +| 매도 금액 부족 | FAILED | 매도 예상 금액 5000원 미만 | + +--- + +## 설치 및 실행 + +### 사전 준비 + +1. `.env` 파일 생성: +```bash +cp .env.example .env +``` + +2. `.env` 파일에서 필수 환경변수 설정 + +### 로컬 실행 + +```bash +# 의존성 설치 +uv sync + +# 마이그레이션 적용 +uv run alembic upgrade head + +# 서버 실행 +uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Docker Compose로 실행 + +```bash +# 서비스 빌드 및 시작 +docker-compose up -d + +# 로그 확인 +docker-compose logs -f joo-coin-backend + +# 서비스 중지 +docker-compose down + +# 컨테이너 재빌드 +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` + +### 접속 정보 + +- **Backend API:** http://localhost:8000 +- **Swagger 문서:** http://localhost:8000/docs +- **MySQL:** localhost:3306 + +--- + +## 환경변수 + +| 변수 | 설명 | 필수 | +|------|------|------| +| `DATABASE_URL` | 데이터베이스 연결 URL | O | +| `UPBIT_ACCESS_KEY` | Upbit API 액세스 키 | O | +| `UPBIT_SECRET_KEY` | Upbit API 시크릿 키 | O | +| `OPENAI_API_KEY` | OpenAI API 키 | O | +| `DB_POOL_SIZE` | DB 커넥션 풀 크기 | X (기본값: 5) | +| `DB_MAX_OVERFLOW` | DB 오버플로우 크기 | X (기본값: 10) | +| `CORS_ORIGINS` | CORS 허용 오리진 | X (기본값: *) | + +--- ## Alembic 마이그레이션 @@ -16,3 +535,37 @@ uv run alembic downgrade -1 ```bash uv run alembic revision --autogenerate -m "설명" ``` + +### Docker 환경에서 마이그레이션 + +```bash +# 마이그레이션 적용 +docker-compose exec joo-coin-backend alembic upgrade head + +# 새 마이그레이션 생성 +docker-compose exec joo-coin-backend alembic revision --autogenerate -m "설명" +``` + +> 마이그레이션은 컨테이너 시작 시 자동으로 실행됩니다. + +--- + +## 트러블슈팅 + +### 데이터베이스 연결 오류 + +```bash +# DB 컨테이너 상태 확인 +docker-compose ps joo-coin-db + +# DB 로그 확인 +docker-compose logs joo-coin-db +``` + +### 컨테이너 재빌드 + +```bash +docker-compose down +docker-compose build --no-cache +docker-compose up -d +``` diff --git a/backend/alembic/versions/2479907a687b_add_coin_amount_to_balances.py b/backend/alembic/versions/2479907a687b_add_coin_amount_to_balances.py new file mode 100644 index 0000000..4513946 --- /dev/null +++ b/backend/alembic/versions/2479907a687b_add_coin_amount_to_balances.py @@ -0,0 +1,32 @@ +"""add_coin_amount_to_balances + +Revision ID: 2479907a687b +Revises: 7cf1639cbeec +Create Date: 2025-11-21 22:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "2479907a687b" +down_revision: Union[str, Sequence[str], None] = "7cf1639cbeec" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "balances", + sa.Column("coin_amount", sa.Numeric(20, 8), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("balances", "coin_amount") diff --git a/backend/alembic/versions/5a7839725665_make_coin_id_and_trade_type_nullable.py b/backend/alembic/versions/5a7839725665_make_coin_id_and_trade_type_nullable.py new file mode 100644 index 0000000..b3105e8 --- /dev/null +++ b/backend/alembic/versions/5a7839725665_make_coin_id_and_trade_type_nullable.py @@ -0,0 +1,67 @@ +"""make_coin_id_and_trade_type_nullable + +Revision ID: 5a7839725665 +Revises: 7309eda69c9a +Create Date: 2025-11-22 00:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5a7839725665' +down_revision: Union[str, Sequence[str], None] = '7309eda69c9a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + trades 테이블의 coin_id와 trade_type을 nullable로 변경 + - 전체 실행 상황(코인 없음, 잔액 없음 등)을 기록하기 위함 + - price, amount는 기본값 0 사용 + - risk_level은 기본값 'none' 사용 + """ + # coin_id를 nullable로 변경 + op.alter_column( + 'trades', + 'coin_id', + existing_type=sa.BigInteger(), + nullable=True + ) + + # trade_type을 nullable로 변경 + op.alter_column( + 'trades', + 'trade_type', + existing_type=sa.String(10), + nullable=True + ) + + +def downgrade() -> None: + """Downgrade schema. + + coin_id와 trade_type을 다시 NOT NULL로 변경 + 주의: NULL 값이 있는 레코드가 있으면 실패함 + """ + # NULL 값을 가진 레코드 삭제 또는 기본값 설정 필요 + # op.execute("DELETE FROM trades WHERE coin_id IS NULL OR trade_type IS NULL") + + op.alter_column( + 'trades', + 'trade_type', + existing_type=sa.String(10), + nullable=False + ) + + op.alter_column( + 'trades', + 'coin_id', + existing_type=sa.BigInteger(), + nullable=False + ) diff --git a/backend/alembic/versions/7309eda69c9a_add_status_and_execution_reason_to_trades.py b/backend/alembic/versions/7309eda69c9a_add_status_and_execution_reason_to_trades.py new file mode 100644 index 0000000..4baacba --- /dev/null +++ b/backend/alembic/versions/7309eda69c9a_add_status_and_execution_reason_to_trades.py @@ -0,0 +1,48 @@ +"""add_status_and_execution_reason_to_trades + +Revision ID: 7309eda69c9a +Revises: 9f321772914d +Create Date: 2025-11-22 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "7309eda69c9a" +down_revision: Union[str, Sequence[str], None] = "9f321772914d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + trades 테이블에 거래 상태 추적을 위한 컬럼 추가: + - status: 거래 상태 (PENDING, SUCCESS, PARTIAL_SUCCESS, FAILED) + - execution_reason: 거래 실행 과정의 상세 로그 + """ + # status 컬럼 추가 (기존 데이터는 SUCCESS로 설정) + op.add_column( + "trades", + sa.Column("status", sa.String(20), nullable=False, server_default="success"), + ) + + # execution_reason 컬럼 추가 + op.add_column("trades", sa.Column("execution_reason", sa.Text(), nullable=True)) + + # server_default 제거 (기존 데이터 처리 후 불필요) + op.alter_column("trades", "status", server_default=None) + + +def downgrade() -> None: + """Downgrade schema. + + status, execution_reason 컬럼 제거 + """ + op.drop_column("trades", "execution_reason") + op.drop_column("trades", "status") diff --git a/backend/alembic/versions/7cf1639cbeec_add_is_deleted_to_coins.py b/backend/alembic/versions/7cf1639cbeec_add_is_deleted_to_coins.py new file mode 100644 index 0000000..ff82e60 --- /dev/null +++ b/backend/alembic/versions/7cf1639cbeec_add_is_deleted_to_coins.py @@ -0,0 +1,32 @@ +"""add_is_deleted_to_coins + +Revision ID: 7cf1639cbeec +Revises: a5618c7dd872 +Create Date: 2025-11-21 21:29:47.231697 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "7cf1639cbeec" +down_revision: Union[str, Sequence[str], None] = "a5618c7dd872" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "coins", + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("coins", "is_deleted") diff --git a/backend/alembic/versions/9f321772914d_add_none_to_risk_level_enum.py b/backend/alembic/versions/9f321772914d_add_none_to_risk_level_enum.py new file mode 100644 index 0000000..ebb530c --- /dev/null +++ b/backend/alembic/versions/9f321772914d_add_none_to_risk_level_enum.py @@ -0,0 +1,46 @@ +"""add_none_to_risk_level_enum + +Revision ID: 9f321772914d +Revises: 2479907a687b +Create Date: 2025-11-21 22:51:32.187015 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "9f321772914d" +down_revision: Union[str, Sequence[str], None] = "2479907a687b" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema. + + RiskLevel enum에 'none' 값 추가 + - 기존: LOW, MEDIUM, HIGH + - 추가: NONE (ai_analysis_response.py와 동기화) + + trades 테이블의 risk_level 컬럼은 VARCHAR(10)이므로 + DB 스키마 변경 없이 새 값 저장 가능 + """ + # VARCHAR 타입이므로 실제 DB 스키마 변경 불필요 + # Python enum 코드 변경만 필요: + # app/trade/model/enums.py의 RiskLevel에 NONE = "none" 추가 + pass + + +def downgrade() -> None: + """Downgrade schema. + + RiskLevel enum에서 'none' 값 제거 + 주의: 기존 데이터에 'none' 값이 있으면 애플리케이션 오류 발생 가능 + """ + # 기존 'none' 데이터를 다른 값으로 변환해야 할 경우: + # op.execute("UPDATE trades SET risk_level = 'low' WHERE risk_level = 'none'") + pass diff --git a/backend/alembic/versions/a5618c7dd872_initial_schema.py b/backend/alembic/versions/a5618c7dd872_initial_schema.py index b2efc20..ba8c0ef 100644 --- a/backend/alembic/versions/a5618c7dd872_initial_schema.py +++ b/backend/alembic/versions/a5618c7dd872_initial_schema.py @@ -1,10 +1,11 @@ """initial_schema Revision ID: a5618c7dd872 -Revises: +Revises: Create Date: 2025-11-21 20:46:18.692991 """ + from typing import Sequence, Union from alembic import op @@ -12,7 +13,7 @@ # revision identifiers, used by Alembic. -revision: str = 'a5618c7dd872' +revision: str = "a5618c7dd872" down_revision: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,35 +22,41 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('balances', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column('amount', sa.Numeric(precision=20, scale=8), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "balances", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("amount", sa.Numeric(precision=20, scale=8), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_table('coins', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') + op.create_table( + "coins", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), ) - op.create_table('trades', - sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), - sa.Column('coin_id', sa.BigInteger(), nullable=False), - sa.Column('trade_type', sa.String(length=10), nullable=False), - sa.Column('price', sa.Numeric(precision=20, scale=8), nullable=False), - sa.Column('amount', sa.Numeric(precision=20, scale=8), nullable=False), - sa.Column('risk_level', sa.String(length=10), nullable=False), - sa.Column('ai_reason', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['coin_id'], ['coins.id'], ), - sa.PrimaryKeyConstraint('id') + op.create_table( + "trades", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("coin_id", sa.BigInteger(), nullable=False), + sa.Column("trade_type", sa.String(length=10), nullable=False), + sa.Column("price", sa.Numeric(precision=20, scale=8), nullable=False), + sa.Column("amount", sa.Numeric(precision=20, scale=8), nullable=False), + sa.Column("risk_level", sa.String(length=10), nullable=False), + sa.Column("ai_reason", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["coin_id"], + ["coins.id"], + ), + sa.PrimaryKeyConstraint("id"), ) # 인덱스 생성 - op.create_index('idx_trades_coin_id', 'trades', ['coin_id']) - op.create_index('idx_trades_created_at', 'trades', ['created_at']) - op.create_index('idx_balances_created_at', 'balances', ['created_at']) + op.create_index("idx_trades_coin_id", "trades", ["coin_id"]) + op.create_index("idx_trades_created_at", "trades", ["created_at"]) + op.create_index("idx_balances_created_at", "balances", ["created_at"]) # ### end Alembic commands ### @@ -57,11 +64,11 @@ def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### # 인덱스 삭제 - op.drop_index('idx_balances_created_at', 'balances') - op.drop_index('idx_trades_created_at', 'trades') - op.drop_index('idx_trades_coin_id', 'trades') + op.drop_index("idx_balances_created_at", "balances") + op.drop_index("idx_trades_created_at", "trades") + op.drop_index("idx_trades_coin_id", "trades") # 테이블 삭제 - op.drop_table('trades') - op.drop_table('coins') - op.drop_table('balances') + op.drop_table("trades") + op.drop_table("coins") + op.drop_table("balances") # ### end Alembic commands ### diff --git a/backend/app/ai/client/open_ai_client.py b/backend/app/ai/client/open_ai_client.py new file mode 100644 index 0000000..2bd5c11 --- /dev/null +++ b/backend/app/ai/client/open_ai_client.py @@ -0,0 +1,29 @@ +import json + +from openai import OpenAI +from pandas import DataFrame + +from app.ai.const.constans import BITCOIN_ANALYST_PROMPT, OPEN_AI_MODEL +from app.ai.dto.ai_analysis_response import AiAnalysisResponse + + +class OpenAIClient: + def __init__(self) -> None: + self.client = OpenAI() + + def get_bitcoin_trading_decision(self, df: DataFrame) -> AiAnalysisResponse: + response = self.client.chat.completions.create( + model=OPEN_AI_MODEL, + messages=[ + { + "role": "system", + "content": [{"type": "text", "text": BITCOIN_ANALYST_PROMPT}], + }, + {"role": "user", "content": [{"type": "text", "text": df.to_json()}]}, + ], + response_format={"type": "json_object"}, + ) + + content = response.choices[0].message.content or "{}" + result = AiAnalysisResponse.model_validate(json.loads(content)) + return result diff --git a/backend/app/ai/const/constans.py b/backend/app/ai/const/constans.py new file mode 100644 index 0000000..fa9133b --- /dev/null +++ b/backend/app/ai/const/constans.py @@ -0,0 +1,4 @@ +BITCOIN_ANALYST_PROMPT = """ +You are a professional Bitcoin investment analyst and trader with expertise in both technical and fundamental analysis.\nBased on the provided chart data (from the variable df), which contains recent OHLCV information for Bitcoin (KRW-BTC), analyze the current market condition and determine the safest possible action: Buy, Sell, or Hold.\nYour top priority is to avoid any potential loss and protect the principal amount under all circumstances.\nIf there is any uncertainty or risk of loss, choose Hold instead of taking action.\nEvaluate short-term momentum, trend direction, and volatility carefully.\nConclude with your final recommendation (Buy, Sell, or Hold) and provide a brief, clear explanation for your reasoning. Response in Json Format.\n\nResponse Example: \n{\n  \"decision\": \"buy\",\n  \"confidence\": 0.88,\n  \"reason\": \"Bitcoin has formed a higher low on the daily chart and just broke above the 50-day moving average with rising volume. RSI is recovering from the mid-40s, suggesting renewed bullish momentum.\",\n  \"risk_level\": \"low\",\n  \"timestamp\": \"2025-11-07T22:45:00+09:00\"\n}\n{\n  \"decision\": \"sell\",\n  \"confidence\": 0.91,\n  \"reason\": \"A double-top pattern has formed near the 100-day moving average with decreasing volume. RSI shows bearish divergence, and price failed to hold the key resistance level at 98,000,000 KRW.\",\n  \"risk_level\": \"medium\",\n  \"timestamp\": \"2025-11-07T22:45:00+09:00\"\n}\n{\n \"decision\": \"hold\",\n \"confidence\": 0.76,\n \"reason\": \"The market is currently consolidating within a narrow range between 92M and 95M KRW. No clear breakout or breakdown signal is confirmed, and volatility remains low.\",\n \"risk_level\": \"none\",\n \"timestamp\": \"2025-11-07T22:45:00+09:00\"\n} +""" +OPEN_AI_MODEL = "gpt-4.1-nano" diff --git a/backend/app/ai/dto/ai_analysis_response.py b/backend/app/ai/dto/ai_analysis_response.py new file mode 100644 index 0000000..62b82f3 --- /dev/null +++ b/backend/app/ai/dto/ai_analysis_response.py @@ -0,0 +1,31 @@ +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + + +class Decision(str, Enum): + """AI 분석 결과 결정 유형""" + + BUY = "buy" + SELL = "sell" + HOLD = "hold" + + +class RiskLevel(str, Enum): + """리스크 수준""" + + NONE = "none" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class AiAnalysisResponse(BaseModel): + """AI 비트코인 분석 결과 DTO""" + + decision: Decision = Field(description="투자 결정 (buy/sell/hold)") + confidence: float = Field(ge=0, le=1, description="신뢰도 (0~1)") + reason: str = Field(description="분석 근거") + risk_level: RiskLevel = Field(description="리스크 수준") + timestamp: datetime = Field(description="분석 시점") diff --git a/backend/app/ballance/model/balance.py b/backend/app/ballance/model/balance.py index 2d7eab4..1851712 100644 --- a/backend/app/ballance/model/balance.py +++ b/backend/app/ballance/model/balance.py @@ -18,6 +18,9 @@ class Balance(Base): id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) amount: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False) + coin_amount: Mapped[Decimal] = mapped_column( + Numeric(20, 8), nullable=False, default=0 + ) created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, nullable=False ) diff --git a/backend/app/coin/controller/my_coin_controller.py b/backend/app/coin/controller/my_coin_controller.py new file mode 100644 index 0000000..537daab --- /dev/null +++ b/backend/app/coin/controller/my_coin_controller.py @@ -0,0 +1,52 @@ +""" +Coin API Controller +""" + +from fastapi import APIRouter, Depends, status + +from app.coin.dto.coin_dto import CoinListResponse, CoinResponse, CreateCoinRequest +from app.coin.service.coin_service import CoinService + +coin_router = APIRouter(prefix="/my/coins", tags=["My Coin"]) + + +@coin_router.get( + "", + response_model=CoinListResponse, + summary="내 거래 코인 조회", + description="내 거래 코인 목록을 조회합니다.", +) +async def get_my_coins( + service: CoinService = Depends(), +) -> CoinListResponse: + """내 거래 코인 목록 조회""" + coins = await service.get_all_active() + return CoinListResponse(items=[CoinResponse.model_validate(coin) for coin in coins]) + + +@coin_router.post( + "", + status_code=status.HTTP_201_CREATED, + summary="거래 코인 추가", + description="새로운 거래 코인을 추가합니다. 이미 삭제된 코인이 있으면 복구합니다.", +) +async def create_coin( + request: CreateCoinRequest, + service: CoinService = Depends(), +) -> None: + """거래 코인 추가""" + await service.create_coin(request.name) + + +@coin_router.delete( + "/{coin_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="내 코인 정보 삭제", + description="코인을 soft delete 합니다.", +) +async def delete_coin( + coin_id: int, + service: CoinService = Depends(), +) -> None: + """코인 soft delete""" + await service.delete_coin(coin_id) diff --git a/backend/app/coin/dto/coin_dto.py b/backend/app/coin/dto/coin_dto.py new file mode 100644 index 0000000..402f061 --- /dev/null +++ b/backend/app/coin/dto/coin_dto.py @@ -0,0 +1,27 @@ +""" +Coin DTO +""" + +from pydantic import BaseModel, Field + + +class CoinResponse(BaseModel): + """코인 응답 DTO""" + + id: int = Field(description="코인 ID") + name: str = Field(description="코인 이름") + + class Config: + from_attributes = True + + +class CoinListResponse(BaseModel): + """코인 목록 응답 DTO""" + + items: list[CoinResponse] = Field(description="코인 목록") + + +class CreateCoinRequest(BaseModel): + """코인 생성 요청 DTO""" + + name: str = Field(description="코인 이름", min_length=1, max_length=100) diff --git a/backend/app/coin/model/coin.py b/backend/app/coin/model/coin.py index d62b164..c6879d8 100644 --- a/backend/app/coin/model/coin.py +++ b/backend/app/coin/model/coin.py @@ -4,7 +4,7 @@ from datetime import datetime -from sqlalchemy import BigInteger, DateTime, String +from sqlalchemy import BigInteger, Boolean, DateTime, String from sqlalchemy.orm import Mapped, mapped_column, relationship from app.common.model.base import Base @@ -20,6 +20,7 @@ class Coin(Base): created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, nullable=False ) + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # 관계 설정 trades = relationship("Trade", back_populates="coin") diff --git a/backend/app/coin/repository/coin_repository.py b/backend/app/coin/repository/coin_repository.py index 5755579..bbeb67b 100644 --- a/backend/app/coin/repository/coin_repository.py +++ b/backend/app/coin/repository/coin_repository.py @@ -2,7 +2,7 @@ Coin Repository """ -from typing import Optional +from typing import Optional, Sequence from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -21,3 +21,20 @@ async def get_by_name(self, name: str) -> Optional[Coin]: """이름으로 코인 조회""" result = await self.session.execute(select(Coin).where(Coin.name == name)) return result.scalar_one_or_none() + + async def get_all_active(self) -> list[Coin]: + """삭제되지 않은 모든 코인 조회""" + result = await self.session.execute( + select(Coin).where(Coin.is_deleted == False) # noqa: E712 + ) + return list(result.scalars().all()) + + async def get_by_name_include_deleted(self, name: str) -> Optional[Coin]: + """이름으로 코인 조회 (soft delete 포함)""" + result = await self.session.execute(select(Coin).where(Coin.name == name)) + return result.scalar_one_or_none() + + async def get_all_include_deleted(self) -> Sequence[Coin]: + """이름으로 코인 조회 (soft delete 포함)""" + result = await self.session.execute(select(Coin)) + return result.scalars().all() diff --git a/backend/app/coin/service/coin_service.py b/backend/app/coin/service/coin_service.py new file mode 100644 index 0000000..dee9caa --- /dev/null +++ b/backend/app/coin/service/coin_service.py @@ -0,0 +1,79 @@ +""" +Coin Service +""" + +from fastapi import Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.coin.model.coin import Coin +from app.coin.repository.coin_repository import CoinRepository +from app.common.model.base import get_session +from app.upbit.client.upbit_client import ( + UpbitClient, # noqa: F401 - relationship 등록용 +) + + +class CoinService: + """코인 비즈니스 로직""" + + def __init__(self, session: AsyncSession = Depends(get_session)): + self.repository = CoinRepository(session) + self.upbit_client = UpbitClient() + + async def get_all_active(self) -> list[Coin]: + """삭제되지 않은 모든 코인 조회""" + return await self.repository.get_all_active() + + async def create_coin(self, name: str) -> None: + """ + 코인 생성 또는 복구 + + Args: + name: 코인 이름 + + Raises: + HTTPException: 이미 존재하는 코인인 경우 + """ + existing_coin = await self.repository.get_by_name_include_deleted(name) + + if existing_coin: + if not existing_coin.is_deleted: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"이미 존재하는 코인입니다: {name}", + ) + # soft delete된 코인 복구 + existing_coin.is_deleted = False + await self.repository.update(existing_coin) + else: + # 새 코인 생성 + new_coin = Coin(name=name) + await self.repository.create(new_coin) + + async def delete_coin(self, coin_id: int) -> None: + """ + 코인 soft delete + + Args: + coin_id: 코인 ID + + Raises: + HTTPException: 코인을 찾을 수 없는 경우 + """ + coin = await self.repository.get_by_id(coin_id) + amount = self.upbit_client.get_krw_balance(coin.name) or 0.0 + + if amount > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="잔고가 남아있는 코인은 삭제할 수 없습니다.", + ) + + if not coin or coin.is_deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="코인을 찾을 수 없습니다.", + ) + + coin.is_deleted = True + await self.repository.update(coin) diff --git a/backend/app/common/api/v1/v1_router.py b/backend/app/common/api/v1/v1_router.py index 40e1b2d..54ccbf5 100644 --- a/backend/app/common/api/v1/v1_router.py +++ b/backend/app/common/api/v1/v1_router.py @@ -6,9 +6,14 @@ from fastapi import APIRouter +from app.coin.controller.my_coin_controller import coin_router +from app.trade.controller.trade_controller import trade_router +from app.upbit.controller.upbit_controller import upbit_router + # API 라우터 생성 -v1_router = APIRouter() +v1_router = APIRouter(prefix="/api/v1") # 도메인별 라우터 등록 -# prefix와 tags는 각 도메인의 api.py에서 이미 정의됨 -# api_router.include_router(page_base_link_crawling_router) +v1_router.include_router(coin_router) +v1_router.include_router(upbit_router) +v1_router.include_router(trade_router) diff --git a/backend/app/configs/app.py b/backend/app/configs/app.py index 83f5266..c4f5025 100644 --- a/backend/app/configs/app.py +++ b/backend/app/configs/app.py @@ -1,12 +1,16 @@ -from contextlib import asynccontextmanager from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.common.api.v1.v1_router import v1_router -from app.configs.config import settings from app.common.model.base import get_engine +from app.configs.config import settings +from app.configs.scheduling_tasks import trade_execution_job + +scheduler = AsyncIOScheduler() @asynccontextmanager @@ -20,8 +24,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: engine = get_engine() async with engine.begin() as conn: await conn.run_sync(lambda _: None) # 연결 테스트 + + # 스케줄러 시작 + scheduler.add_job(trade_execution_job, "interval", seconds=30) + scheduler.start() + yield - # 종료: 엔진 정리 + + # 종료: 스케줄러 및 엔진 정리 + scheduler.shutdown() await engine.dispose() diff --git a/backend/app/configs/scheduling_tasks.py b/backend/app/configs/scheduling_tasks.py new file mode 100644 index 0000000..4f64657 --- /dev/null +++ b/backend/app/configs/scheduling_tasks.py @@ -0,0 +1,28 @@ +import traceback +from logging import Logger + +from app.common.model.base import get_session_maker +from app.trade.service.trade_service import TradeService + +logger = Logger(__name__) + + +async def trade_execution_job() -> None: + """ + 주기적으로 실행되는 자동 거래 작업 + + 모든 활성화된 코인에 대해 AI 분석을 수행하고 거래를 실행합니다. + """ + + session_maker = get_session_maker() + + try: + async with session_maker() as session: + trade_service = TradeService(session=session) + logger.info("🚀 자동 거래 작업 시작") + await trade_service.execute() + + except Exception as e: + logger.info(f"❌ 거래 실행 중 오류 발생: {str(e)}\n{traceback.format_exc()}") + + traceback.print_exc() diff --git a/backend/app/main.py b/backend/app/main.py index 801cb22..ee0c4a1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ def main(): - uvicorn.run(app="app.main:app", host="0.0.0.0", port=8000, reload=True) + uvicorn.run(app="app.main:app", host="0.0.0.0", port=8000, reload=False) if __name__ == "__main__": diff --git a/backend/app/trade/controller/trade_controller.py b/backend/app/trade/controller/trade_controller.py new file mode 100644 index 0000000..62201ed --- /dev/null +++ b/backend/app/trade/controller/trade_controller.py @@ -0,0 +1,58 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.common.model.base import get_session +from app.trade.dto.transaction_response import TransactionsResponse +from app.trade.service.trade_service import TradeService + +trade_router = APIRouter(prefix="/trade", tags=["Trade"]) + + +@trade_router.post( + "", + summary="즉시 트레이드 실행", + description="등록 해놓은 코인을 기반으로 즉시 트레이드를 실행합니다.", +) +async def execute_trades(session: AsyncSession = Depends(get_session)) -> None: + """트레이드 실행 엔드포인트""" + trade_service = TradeService(session) + await trade_service.execute() + + +@trade_router.get("/transactions") +async def get_transactions( + cursor: Optional[int] = Query( + None, + description="이전 페이지의 마지막 거래 ID (첫 페이지 조회 시 생략)", + ), + limit: int = Query( + 20, + ge=1, + le=100, + description="페이지당 조회할 항목 수 (1-100, 기본값: 20)", + ), + session: AsyncSession = Depends(get_session), +) -> TransactionsResponse: + """ + 내 거래 내역 조회 (Cursor 기반 페이지네이션) + + 거래 내역을 최신순으로 페이지네이션하여 반환합니다. + + **사용 방법:** + 1. 첫 페이지: `GET /transactions?limit=20` + 2. 다음 페이지: 응답의 `next_cursor` 값을 사용하여 `GET /transactions?cursor={next_cursor}&limit=20` + 3. `has_next`가 `false`이면 마지막 페이지 + + **각 거래 항목 정보:** + - 코인 정보 (ID, 이름) + - 거래 유형 (buy/sell) + - 거래 가격 및 수량 + - 위험도 (none/low/medium/high) + - 거래 상태 (pending/success/partial_success/failed/no_action) + - AI 분석 결과 (ai_reason) + - 거래 실행 사유 (execution_reason): 잔고, 현재 가격, 수수료, 실패 원인 등 상세 정보 + """ + trade_service = TradeService(session) + return await trade_service.get_transactions(cursor=cursor, limit=limit) diff --git a/backend/app/trade/dto/transaction_response.py b/backend/app/trade/dto/transaction_response.py new file mode 100644 index 0000000..36b2ed7 --- /dev/null +++ b/backend/app/trade/dto/transaction_response.py @@ -0,0 +1,52 @@ +""" +Transaction Response DTO +""" + +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, Field + + +class TransactionItemResponse(BaseModel): + """거래 내역 항목 응답 DTO""" + + id: int = Field(description="거래 ID") + coin_id: Optional[int] = Field(description="코인 ID") + coin_name: Optional[str] = Field(description="코인 이름") + type: Optional[str] = Field(description="거래 타입 (buy/sell)") + price: float = Field(description="거래 가격") + amount: float = Field(description="거래 수량") + risk_level: str = Field(description="위험 수준 (none/low/medium/high)") + status: str = Field(description="거래 상태 (pending/success/partial_success/failed/no_action)") + timestamp: str = Field(description="거래 시각 (YYYY-MM-DD HH:MM:SS)") + ai_reason: Optional[str] = Field(description="AI 분석 결과") + execution_reason: Optional[str] = Field(description="거래 실행 사유 (잔고, 가격, 수량 등 상세 정보)") + + @staticmethod + def from_trade(trade, coin_name: Optional[str] = None) -> "TransactionItemResponse": + """Trade 엔티티를 TransactionItemResponse로 변환""" + return TransactionItemResponse( + id=trade.id, + coin_id=trade.coin_id, + coin_name=coin_name, + type=trade.trade_type, + price=float(trade.price) if isinstance(trade.price, Decimal) else trade.price, + amount=float(trade.amount) if isinstance(trade.amount, Decimal) else trade.amount, + risk_level=trade.risk_level, + status=trade.status, + timestamp=trade.created_at.strftime("%Y-%m-%d %H:%M:%S") if isinstance(trade.created_at, datetime) else trade.created_at, + ai_reason=trade.ai_reason, + execution_reason=trade.execution_reason, + ) + + +class TransactionsResponse(BaseModel): + """거래 내역 목록 응답 DTO (Cursor 기반 페이지네이션)""" + + items: list[TransactionItemResponse] = Field(description="거래 내역 목록") + next_cursor: Optional[int] = Field( + description="다음 페이지를 조회하기 위한 커서 (다음 페이지가 없으면 null)" + ) + has_next: bool = Field(description="다음 페이지 존재 여부") diff --git a/backend/app/trade/model/enums.py b/backend/app/trade/model/enums.py index 0eb60d2..1fca6a4 100644 --- a/backend/app/trade/model/enums.py +++ b/backend/app/trade/model/enums.py @@ -10,11 +10,23 @@ class TradeType(str, Enum): BUY = "buy" SELL = "sell" + HOLD = "hold" class RiskLevel(str, Enum): """위험 수준""" + NONE = "none" LOW = "low" MEDIUM = "medium" HIGH = "high" + + +class TradeStatus(str, Enum): + """거래 상태""" + + PENDING = "pending" # 진행 중 + SUCCESS = "success" # 성공 + PARTIAL_SUCCESS = "partial_success" # 부분 성공 + FAILED = "failed" # 실패 + NO_ACTION = "no_action" # 거래 없음 (코인 없음, 잔액 없음 등) diff --git a/backend/app/trade/model/trade.py b/backend/app/trade/model/trade.py index 42412b4..0e034ad 100644 --- a/backend/app/trade/model/trade.py +++ b/backend/app/trade/model/trade.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.common.model.base import Base -from app.trade.model.enums import RiskLevel, TradeType +from app.trade.model.enums import RiskLevel, TradeStatus, TradeType class Trade(Base): @@ -19,14 +19,22 @@ class Trade(Base): __tablename__ = "trades" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - coin_id: Mapped[int] = mapped_column( - BigInteger, ForeignKey("coins.id"), nullable=False + coin_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("coins.id"), nullable=True ) - trade_type: Mapped[str] = mapped_column(String(10), nullable=False) - price: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False) - amount: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False) - risk_level: Mapped[str] = mapped_column(String(10), nullable=False) + trade_type: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) + price: Mapped[Decimal] = mapped_column( + Numeric(20, 8), nullable=False, default=Decimal("0") + ) + amount: Mapped[Decimal] = mapped_column( + Numeric(20, 8), nullable=False, default=Decimal("0") + ) + risk_level: Mapped[str] = mapped_column( + String(10), nullable=False, default=RiskLevel.NONE.value + ) + status: Mapped[TradeStatus] = mapped_column(String(20), nullable=False) ai_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + execution_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, nullable=False ) @@ -35,9 +43,9 @@ class Trade(Base): coin = relationship("Coin", back_populates="trades") @property - def trade_type_enum(self) -> TradeType: + def trade_type_enum(self) -> Optional[TradeType]: """trade_type을 Enum으로 반환""" - return TradeType(self.trade_type) + return TradeType(self.trade_type) if self.trade_type else None @property def risk_level_enum(self) -> RiskLevel: diff --git a/backend/app/trade/repository/trade_repository.py b/backend/app/trade/repository/trade_repository.py index 88f7fb3..1a71403 100644 --- a/backend/app/trade/repository/trade_repository.py +++ b/backend/app/trade/repository/trade_repository.py @@ -2,10 +2,11 @@ Trade Repository """ -from typing import List +from typing import List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.common.repository.base_repository import BaseRepository from app.trade.model.trade import Trade @@ -25,3 +26,38 @@ async def get_by_coin_id(self, coin_id: int) -> List[Trade]: .order_by(Trade.created_at.desc()) ) return list(result.scalars().all()) + + async def get_all_with_coin(self) -> List[Trade]: + """ + 모든 거래 내역을 코인 정보와 함께 조회 + + @return: 생성 시각 기준 내림차순으로 정렬된 거래 내역 목록 + """ + result = await self.session.execute( + select(Trade) + .options(selectinload(Trade.coin)) + .order_by(Trade.created_at.desc()) + ) + return list(result.scalars().all()) + + async def get_all_with_coin_paginated( + self, cursor: Optional[int], limit: int + ) -> List[Trade]: + """ + 거래 내역을 커서 기반 페이지네이션으로 조회 + + @param cursor: 이전 페이지의 마지막 거래 ID (None이면 첫 페이지) + @param limit: 조회할 최대 개수 + @return: 생성 시각 기준 내림차순으로 정렬된 거래 내역 목록 + """ + query = select(Trade).options(selectinload(Trade.coin)) + + # cursor가 있으면 해당 ID보다 작은 항목만 조회 + if cursor is not None: + query = query.where(Trade.id < cursor) + + # created_at 기준 내림차순 정렬 + query = query.order_by(Trade.created_at.desc()).limit(limit) + + result = await self.session.execute(query) + return list(result.scalars().all()) diff --git a/backend/app/trade/service/trade_service.py b/backend/app/trade/service/trade_service.py new file mode 100644 index 0000000..7745bcf --- /dev/null +++ b/backend/app/trade/service/trade_service.py @@ -0,0 +1,433 @@ +import traceback +from decimal import Decimal +from logging import Logger +from typing import List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ai.client.open_ai_client import OpenAIClient +from app.ai.dto.ai_analysis_response import AiAnalysisResponse, Decision, RiskLevel +from app.ballance.model.balance import Balance +from app.ballance.repository.balance_repository import BalanceRepository +from app.coin.model.coin import Coin +from app.coin.service.coin_service import CoinService +from app.trade.dto.transaction_response import ( + TransactionItemResponse, + TransactionsResponse, +) +from app.trade.model.enums import TradeStatus, TradeType +from app.trade.model.trade import Trade +from app.trade.repository.trade_repository import TradeRepository +from app.upbit.client.upbit_client import UpbitClient + +logger = Logger(__name__) + + +class TradeService: + """거래 비즈니스 로직""" + + def __init__(self, session: AsyncSession): + self.session = session + self.trade_repository = TradeRepository(session) + self.balance_repository = BalanceRepository(session) + self.coin_service = CoinService(session=session) + self.upbit_client = UpbitClient() + self.ai_client = OpenAIClient() + + async def execute( + self, fee_multiplier: float = 0.9995, min_order_amount: float = 5000.0 + ) -> List[Trade]: + """ + 모든 활성화된 코인에 대해 AI 분석 후 자동 거래를 실행합니다. + + @param fee_multiplier: 수수료를 고려한 실제 매매 가능 금액 계수 (기본: 0.9995) + @param min_order_amount: 최소 주문 금액 (기본: 5000 KRW) + @return: 실행된 거래 목록 + """ + executed_trades: List[Trade] = [] + + # 1. 활성화된 모든 코인 조회 + active_coins = await self.coin_service.get_all_active() + + if not active_coins: + reason = "거래 가능한 활성화된 코인이 없습니다." + + # NO_ACTION 상태로 기록 + trade = Trade( + coin_id=None, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.NO_ACTION, + ai_reason=None, + execution_reason=reason, + ) + no_action_trade = await self.trade_repository.create(trade) + executed_trades.append(no_action_trade) + return executed_trades + + # 2. 현재 KRW 잔고 조회 + krw_balance = self.upbit_client.get_krw_balance() + + if krw_balance == 0: + reason = f"KRW 잔고가 없습니다. 매수 불가 (잔고: {krw_balance:,.0f}원)" + + # NO_ACTION 상태로 기록 + trade = Trade( + coin_id=None, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.NO_ACTION, + ai_reason=None, + execution_reason=reason, + ) + no_action_trade = await self.trade_repository.create(trade) + executed_trades.append(no_action_trade) + return executed_trades + + # 3. 각 코인에 대해 AI 분석 및 거래 실행 + for coin in active_coins: + try: + coin_trade: Optional[Trade] = await self._process_coin_trade( + coin=coin, + krw_balance=krw_balance, + fee_multiplier=fee_multiplier, + min_order_amount=min_order_amount, + ) + + if coin_trade: + executed_trades.append(coin_trade) + # 거래 후 KRW 잔고 갱신 (매수 시) + if ( + coin_trade.trade_type == TradeType.BUY.value + and coin_trade.status == TradeStatus.SUCCESS + ): + krw_balance = self.upbit_client.get_krw_balance() + + except Exception as e: + logger.error( + f"코인 {coin.name} 거래 처리 중 오류 발생: {str(e)}\ntraceback: {traceback.format_exc()}" + ) + continue + + # 4. 거래 후 최종 잔고 기록 + await self._record_balance() + + return executed_trades + + async def _process_coin_trade( + self, + coin: Coin, + krw_balance: float, + fee_multiplier: float, + min_order_amount: float, + ) -> Optional[Trade]: + """ + 개별 코인에 대한 AI 분석 및 거래 처리 + + @param coin: 거래 대상 코인 + @param krw_balance: 현재 KRW 잔고 + @param fee_multiplier: 수수료 계수 + @param min_order_amount: 최소 주문 금액 + @return: 실행된 거래 또는 None + """ + coin_name = coin.name + + try: + # 1. OHLCV 데이터 조회 + df = self.upbit_client.get_ohlcv_raw(coin_name) + + # 2. AI 분석 + ai_result: AiAnalysisResponse = self.ai_client.get_bitcoin_trading_decision( + df + ) + + except Exception as e: + # AI 분석 실패 시 FAILED 상태로 기록 + error_type = type(e).__name__ + error_message = str(e) + + # OpenAI RateLimitError 등 특정 에러 처리 + if "RateLimitError" in error_type or "429" in error_message: + reason = f"AI 분석 실패 (OpenAI API quota 초과)\n에러: {error_message}" + elif "APIError" in error_type or "OpenAI" in error_type: + reason = f"AI 분석 실패 (OpenAI API 오류)\n에러 타입: {error_type}\n에러 메시지: {error_message}" + else: + reason = f"AI 분석 실패\n에러 타입: {error_type}\n에러 메시지: {error_message}" + + trade = Trade( + coin_id=coin.id, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.FAILED, + ai_reason=None, + execution_reason=reason, + ) + return await self.trade_repository.create(trade) + + # 3. 현재 코인 잔고 조회 + coin_balance = self.upbit_client.get_coin_balance(coin_name) + + # 4. 결정에 따라 거래 실행 + if ai_result.decision == Decision.BUY: + return await self._execute_buy( + coin=coin, + coin_name=coin_name, + krw_balance=krw_balance, + fee_multiplier=fee_multiplier, + min_order_amount=min_order_amount, + ai_result=ai_result, + ) + + elif ai_result.decision == Decision.SELL: + return await self._execute_sell( + coin=coin, + coin_name=coin_name, + coin_balance=coin_balance, + fee_multiplier=fee_multiplier, + min_order_amount=min_order_amount, + ai_result=ai_result, + ) + + elif ai_result.decision == Decision.HOLD: + # HOLD 결정도 기록으로 남김 + current_price = self.upbit_client.get_current_price(coin_name) + + trade = Trade( + coin_id=coin.id, + trade_type=TradeType.HOLD.value, + price=Decimal(str(current_price)), + amount=Decimal("0"), + risk_level=ai_result.risk_level.value, + status=TradeStatus.NO_ACTION, + ai_reason=ai_result.reason, + execution_reason=f"AI HOLD 결정 (Confidence: {ai_result.confidence:.2%})", + ) + return await self.trade_repository.create(trade) + + return None + + async def _execute_buy( + self, + coin: Coin, + coin_name: str, + krw_balance: float, + fee_multiplier: float, + min_order_amount: float, + ai_result: AiAnalysisResponse, + ) -> Optional[Trade]: + """매수 실행""" + reasons = [] + reasons.append( + f"AI 매수 결정: Confidence {ai_result.confidence:.2%}, Reason: {ai_result.reason}" + ) + reasons.append(f"보유 KRW 잔고: {krw_balance:,.0f}원") + + available_buy_amount = krw_balance * fee_multiplier + + if available_buy_amount < min_order_amount: + reason = f"KRW 잔고가 {min_order_amount}원 미만입니다. 매수 불가 (가용 금액: {available_buy_amount:,.0f}원)" + reasons.append(reason) + + # 실패한 거래도 기록 + trade = Trade( + coin_id=coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=ai_result.risk_level.value, + status=TradeStatus.FAILED, + ai_reason=ai_result.reason, + execution_reason="\n".join(reasons), + ) + return await self.trade_repository.create(trade) + + # 매수 전 현재 가격 조회 + current_price = self.upbit_client.get_current_price(coin_name) + reasons.append(f"현재 {coin_name} 가격: {current_price:,.0f}원") + + # 매수한 코인 수량 계산 (수수료 고려) + coin_amount = (available_buy_amount / current_price) * fee_multiplier + reasons.append(f"매수 예정 수량: {coin_amount:.8f} {coin_name}") + + # PENDING 상태로 거래 기록 생성 + trade = Trade( + coin_id=coin.id, + trade_type=TradeType.BUY.value, + price=Decimal(str(current_price)), + amount=Decimal(str(coin_amount)), + risk_level=ai_result.risk_level.value, + status=TradeStatus.PENDING, + ai_reason=ai_result.reason, + execution_reason="\n".join(reasons), + ) + trade = await self.trade_repository.create(trade) + + try: + # 매수 실행 + self.upbit_client.buy(coin_name, available_buy_amount) + + reasons.append(f"매수 주문 실행 완료: {available_buy_amount:,.0f}원") + trade.status = TradeStatus.SUCCESS + trade.execution_reason = "\n".join(reasons) + + except Exception as e: + reasons.append(f"매수 주문 실패: {str(e)}") + trade.status = TradeStatus.FAILED + trade.execution_reason = "\n".join(reasons) + + return await self.trade_repository.update(trade) + + async def _execute_sell( + self, + coin: Coin, + coin_name: str, + coin_balance: float, + fee_multiplier: float, + min_order_amount: float, + ai_result: AiAnalysisResponse, + ) -> Optional[Trade]: + """매도 실행""" + reasons = [] + reasons.append( + f"AI 매도 결정: Confidence {ai_result.confidence:.2%}, Reason: {ai_result.reason}" + ) + reasons.append(f"보유 {coin_name} 수량: {coin_balance:.8f}") + + if coin_balance == 0: + reason = "보유 코인이 없습니다. 매도 불가" + reasons.append(reason) + + # 실패한 거래도 기록 + trade = Trade( + coin_id=coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=ai_result.risk_level.value, + status=TradeStatus.FAILED, + ai_reason=ai_result.reason, + execution_reason="\n".join(reasons), + ) + return await self.trade_repository.create(trade) + + # 현재 매도 호가 조회 + current_price = self.upbit_client.get_current_price(coin_name) + reasons.append(f"현재 {coin_name} 가격: {current_price:,.0f}원") + + # 매도 시 수수료 제외 전 총 매도 금액 + gross_amount = coin_balance * current_price + available_sell_amount = gross_amount * fee_multiplier + reasons.append( + f"매도 예상 금액: {available_sell_amount:,.0f}원 (수수료 차감 후)" + ) + + if available_sell_amount < min_order_amount: + reason = f"매도 예상 금액이 {min_order_amount}원 미만입니다. 매도 불가" + reasons.append(reason) + + # 실패한 거래도 기록 + trade = Trade( + coin_id=coin.id, + trade_type=TradeType.SELL.value, + price=Decimal(str(current_price)), + amount=Decimal(str(coin_balance)), + risk_level=ai_result.risk_level.value, + status=TradeStatus.FAILED, + ai_reason=ai_result.reason, + execution_reason="\n".join(reasons), + ) + return await self.trade_repository.create(trade) + + # PENDING 상태로 거래 기록 생성 + trade = Trade( + coin_id=coin.id, + trade_type=TradeType.SELL.value, + price=Decimal(str(current_price)), + amount=Decimal(str(coin_balance)), + risk_level=ai_result.risk_level.value, + status=TradeStatus.PENDING, + ai_reason=ai_result.reason, + execution_reason="\n".join(reasons), + ) + trade = await self.trade_repository.create(trade) + + try: + # 매도 실행 + self.upbit_client.sell(coin_name, coin_balance) + + reasons.append( + f"매도 주문 실행 완료: {coin_balance:.8f} {coin_name} (약 {available_sell_amount:,.0f}원)" + ) + trade.status = TradeStatus.SUCCESS + trade.execution_reason = "\n".join(reasons) + + except Exception as e: + reasons.append(f"매도 주문 실패: {str(e)}") + trade.status = TradeStatus.FAILED + trade.execution_reason = "\n".join(reasons) + + return await self.trade_repository.update(trade) + + async def _record_balance(self) -> None: + """현재 잔고를 데이터베이스에 기록""" + krw_balance = self.upbit_client.get_krw_balance() + + # 모든 활성 코인의 총 보유량 조회 (KRW 가치로 환산) + active_coins = await self.coin_service.get_all_active() + total_coin_value = 0.0 + + for coin in active_coins: + coin_balance = self.upbit_client.get_coin_balance(coin.name) + if coin_balance > 0: + current_price = self.upbit_client.get_current_price(coin.name) + total_coin_value += coin_balance * current_price + + # 잔고 기록 + balance = Balance( + amount=Decimal(str(krw_balance)), + coin_amount=Decimal(str(total_coin_value)), + ) + + await self.balance_repository.create(balance) + + async def get_transactions( + self, cursor: Optional[int] = None, limit: int = 20 + ) -> TransactionsResponse: + """ + 거래 내역을 커서 기반 페이지네이션으로 조회 + + @param cursor: 이전 페이지의 마지막 거래 ID (None이면 첫 페이지) + @param limit: 페이지당 조회할 항목 수 (기본: 20) + @return: 거래 내역 목록 응답 (다음 페이지 정보 포함) + """ + # limit + 1개를 조회하여 다음 페이지 존재 여부 확인 + trades = await self.trade_repository.get_all_with_coin_paginated( + cursor=cursor, limit=limit + 1 + ) + + # 다음 페이지 존재 여부 판단 + has_next = len(trades) > limit + + # 실제 반환할 항목은 limit개만 + if has_next: + trades = trades[:limit] + next_cursor = trades[-1].id if trades else None + else: + next_cursor = None + + items = [ + TransactionItemResponse.from_trade( + trade=trade, coin_name=trade.coin.name if trade.coin else None + ) + for trade in trades + ] + + return TransactionsResponse( + items=items, next_cursor=next_cursor, has_next=has_next + ) diff --git a/backend/app/upbit/client/upbit_client.py b/backend/app/upbit/client/upbit_client.py new file mode 100644 index 0000000..767db2d --- /dev/null +++ b/backend/app/upbit/client/upbit_client.py @@ -0,0 +1,111 @@ +from typing import List + +import pyupbit +from pandas import DataFrame + +from app.configs import config +from app.upbit.dto.coin_balance import CoinBalance +from app.upbit.dto.my_ballance_response import MyBallanceResponse +from app.upbit.dto.ohlcv_dto import OhlcvItem, OhlcvResponse + + +class UpbitClient: + def __init__(self): + self.access = config.Settings().UPBIT_ACCESS_KEY + self.secret = config.Settings().UPBIT_SECRET_KEY + self.upbit = pyupbit.Upbit(self.access, self.secret) + + # 시세 조회 API + def get_ohlcv(self, coin_name: str) -> OhlcvResponse: + """ + OHLCV 데이터를 조회합니다. + + @param coin_name: 티커 (예: "KRW-BTC") + @return: OhlcvResponse + @raises ValueError: 유효하지 않은 티커이거나 데이터 조회 실패 시 + """ + df: DataFrame = pyupbit.get_ohlcv(coin_name) + + if df is None: + raise ValueError( + f"'{coin_name}' 데이터를 조회할 수 없습니다. 티커 형식을 확인하세요 (예: KRW-BTC)" + ) + + items = [ + OhlcvItem( + timestamp=index.to_pydatetime(), + open=row["open"], + high=row["high"], + low=row["low"], + close=row["close"], + volume=row["volume"], + value=row["value"], + ) + for index, row in df.iterrows() + ] + + return OhlcvResponse(items=items) + + # 시세 조회 API + def get_ohlcv_raw(self, coin_name: str) -> DataFrame: + """ + OHLCV 데이터를 조회합니다. + + @param coin_name: 티커 (예: "KRW-BTC") + @return: OhlcvResponse + @raises ValueError: 유효하지 않은 티커이거나 데이터 조회 실패 시 + """ + df: DataFrame = pyupbit.get_ohlcv(coin_name) + + if df is None: + raise ValueError( + f"'{coin_name}' 데이터를 조회할 수 없습니다. 티커 형식을 확인하세요 (예: KRW-BTC)" + ) + + return df + + def get_current_price(self, coin_name: str) -> float: + # 현재 매도 호가 조회 + orderbook = pyupbit.get_orderbook(ticker=coin_name) + current_price = orderbook["orderbook_units"][0]["ask_price"] + return current_price + + def buy(self, coin_name: str, amount: float) -> None: + self.upbit.buy_market_order(coin_name, amount) + + def sell(self, coin_name: str, amount: float) -> None: + """ + 코인 시장가 매도를 실행합니다. + + @param coin_name: 티커 (예: "KRW-BTC") + @param amount: 매도할 코인 수량 + """ + self.upbit.sell_market_order(coin_name, amount) + + def get_coin_balance(self, coin_name: str) -> float: + """ + 특정 코인의 보유량을 조회합니다. + + @param coin_name: 티커 (예: "KRW-BTC") + @return: 보유 코인 수량 (없으면 0.0) + """ + balance = self.upbit.get_balance(coin_name) + return balance if balance is not None else 0.0 + + def get_krw_balance(self) -> float: + """ + KRW 잔고를 조회합니다. + + @return: KRW 잔고 (없으면 0.0) + """ + balance = self.upbit.get_balance("KRW") + return balance if balance is not None else 0.0 + + def get_my_balance(self, coin_names: list[str]) -> MyBallanceResponse: + krw = self.upbit.get_balance("KRW") or 0.0 + coin_balaces: List[CoinBalance] = [] + for coin in coin_names: + balance = self.upbit.get_balance(coin) or 0.0 + coin_balaces.append(CoinBalance(coin_name=coin, balance=balance)) + + return MyBallanceResponse(krw=krw, coin_balances=coin_balaces) diff --git a/backend/app/upbit/controller/upbit_controller.py b/backend/app/upbit/controller/upbit_controller.py new file mode 100644 index 0000000..e53fb3a --- /dev/null +++ b/backend/app/upbit/controller/upbit_controller.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends + +from app.upbit.client.upbit_client import UpbitClient +from app.upbit.di.upbit_di import get_upbit_client +from app.upbit.dto.ohlcv_dto import OhlcvResponse + +upbit_router = APIRouter(prefix="/coins", tags=["Upbit"]) + + +@upbit_router.get( + "/{coin_name}", + description="특정 코인의 OHLCV 데이터를 Upbit로부터 조회합니다. 500 Internal Server Error 발생 시 해당 코인이 존재하지 않는 것으로 간주합니다.", + summary="코인 OHLCV 조회", + responses={ + 500: { + "description": "해당 코인이 존재하지 않거나 Upbit API 호출 실패. 코인이 없는 것으로 간주하세요.", + } + }, +) +def trade_coin( + coin_name: str, upbit_client: UpbitClient = Depends(get_upbit_client) +) -> OhlcvResponse: + return upbit_client.get_ohlcv(coin_name) diff --git a/backend/app/upbit/di/upbit_di.py b/backend/app/upbit/di/upbit_di.py new file mode 100644 index 0000000..164ac94 --- /dev/null +++ b/backend/app/upbit/di/upbit_di.py @@ -0,0 +1,5 @@ +from app.upbit.client.upbit_client import UpbitClient + + +def get_upbit_client() -> UpbitClient: + return UpbitClient() diff --git a/backend/app/upbit/dto/coin_balance.py b/backend/app/upbit/dto/coin_balance.py new file mode 100644 index 0000000..71b951e --- /dev/null +++ b/backend/app/upbit/dto/coin_balance.py @@ -0,0 +1,6 @@ +from openai import BaseModel + + +class CoinBalance(BaseModel): + coin_name: str + balance: float diff --git a/backend/app/upbit/dto/my_ballance_response.py b/backend/app/upbit/dto/my_ballance_response.py new file mode 100644 index 0000000..30339ee --- /dev/null +++ b/backend/app/upbit/dto/my_ballance_response.py @@ -0,0 +1,10 @@ +from typing import List + +from pydantic import BaseModel + +from app.upbit.dto.coin_balance import CoinBalance + + +class MyBallanceResponse(BaseModel): + krw: float + coin_balances: List[CoinBalance] diff --git a/backend/app/upbit/dto/ohlcv_dto.py b/backend/app/upbit/dto/ohlcv_dto.py new file mode 100644 index 0000000..095a3be --- /dev/null +++ b/backend/app/upbit/dto/ohlcv_dto.py @@ -0,0 +1,25 @@ +""" +OHLCV DTO +""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class OhlcvItem(BaseModel): + """단일 OHLCV 데이터""" + + timestamp: datetime = Field(description="시간") + open: float = Field(description="시가") + high: float = Field(description="고가") + low: float = Field(description="저가") + close: float = Field(description="종가") + volume: float = Field(description="거래량") + value: float = Field(description="거래대금") + + +class OhlcvResponse(BaseModel): + """OHLCV 응답 DTO""" + + items: list[OhlcvItem] = Field(description="OHLCV 데이터 목록") diff --git a/backend/coin.py b/backend/coin.py index 145e414..f14da82 100644 --- a/backend/coin.py +++ b/backend/coin.py @@ -1,102 +1,91 @@ import os from dotenv import load_dotenv -load_dotenv() +load_dotenv() # 자동 실행을 위한 함수 설정 def ai_trading(): - import pyupbit - import json - from openai import OpenAI - - # 공통 설정 - client = OpenAI() - coin_name = "KRW-BTC" - fee_multiplier = 0.9995 - min_order_amount = 5000 - - df = pyupbit.get_ohlcv(coin_name, count = 30, interval = "day") - - response = client.chat.completions.create( - model="gpt-4.1-nano", - messages=[ - { - "role": "system", - "content": [ - { - "type": "text", - "text": "You are a professional Bitcoin investment analyst and trader with expertise in both technical and fundamental analysis.\nBased on the provided chart data (from the variable df), which contains recent OHLCV information for Bitcoin (KRW-BTC), analyze the current market condition and determine the safest possible action: Buy, Sell, or Hold.\nYour top priority is to avoid any potential loss and protect the principal amount under all circumstances.\nIf there is any uncertainty or risk of loss, choose Hold instead of taking action.\nEvaluate short-term momentum, trend direction, and volatility carefully.\nConclude with your final recommendation (Buy, Sell, or Hold) and provide a brief, clear explanation for your reasoning. Response in Json Format.\n\nResponse Example: \n{\n  \"decision\": \"buy\",\n  \"confidence\": 0.88,\n  \"reason\": \"Bitcoin has formed a higher low on the daily chart and just broke above the 50-day moving average with rising volume. RSI is recovering from the mid-40s, suggesting renewed bullish momentum.\",\n  \"risk_level\": \"low\",\n  \"timestamp\": \"2025-11-07T22:45:00+09:00\"\n}\n{\n  \"decision\": \"sell\",\n  \"confidence\": 0.91,\n  \"reason\": \"A double-top pattern has formed near the 100-day moving average with decreasing volume. RSI shows bearish divergence, and price failed to hold the key resistance level at 98,000,000 KRW.\",\n  \"risk_level\": \"medium\",\n  \"timestamp\": \"2025-11-07T22:45:00+09:00\"\n}\n{\n \"decision\": \"hold\",\n \"confidence\": 0.76,\n \"reason\": \"The market is currently consolidating within a narrow range between 92M and 95M KRW. No clear breakout or breakdown signal is confirmed, and volatility remains low.\",\n \"risk_level\": \"none\",\n \"timestamp\": \"2025-11-07T22:45:00+09:00\"\n}" - } - ] - }, - { - "role": "user", - "content": [ - { - "type": "text", - "text": df.to_json() - } - ] - }, - ], - response_format={ - "type": "json_object" - }, - ) - - result = response.choices[0].message.content - result = json.loads(result) - - # 로그인 기능 구현 - access = os.getenv('UPBIT_ACCESS_KEY') - secret = os.getenv('UPBIT_SECRET_KEY') - upbit = pyupbit.Upbit(access, secret) - - print("### AI Decision: ", result['decision'].upper(), "###") - print("### Reason:", result['reason'], "###") - - my_money = upbit.get_balance("KRW") - my_coin = upbit.get_balance(coin_name) # 보유 코인 조회 - print("보유 금액:", my_money) - print("보유 코인:", my_coin) - - - if my_money is None or my_money == 0: - print("⚠️ KRW 잔고가 없거나 조회 실패. 매매는 실행하지 않습니다.") - return - - available_buy_amount = my_money * fee_multiplier - - - if result['decision'] == "buy": - if available_buy_amount > min_order_amount : - print("### Buy Order Executed ###") - print(upbit.buy_market_order(coin_name, available_buy_amount)) - # print("buy: " , result["reason"]) - else : - print(f"### krw 잔고가 {min_order_amount}원 미만 입니다. ###") - - elif result['decision'] == "sell": - # 현재 매도 호가 조회 - orderbook = pyupbit.get_orderbook(ticker=coin_name) - current_price = orderbook['orderbook_units'][0]["ask_price"] - # 매도 시 수수료 제외 전 총 매도 금액 - gross = my_coin * current_price - available_sell_amount = gross * fee_multiplier - - if available_sell_amount > min_order_amount : - print("### Sell Order Executed ###") - print(upbit.sell_market_order(coin_name, my_coin)) - # print("sell: ", result["reason"]) - else : - print(f"### {coin_name} 매도 예상 금액이 {min_order_amount}원 미만입니다. ###") - - elif result['decision'] == "hold": - print("### Hold Order No Executed ###") - print("hold: ", result["reason"]) - pass - + import pyupbit + import json + from openai import OpenAI + + # 공통 설정 + client = OpenAI() + coin_name = "KRW-BTC" + fee_multiplier = 0.9995 + min_order_amount = 5000 + + df = pyupbit.get_ohlcv(coin_name, count=30, interval="day") + + response = client.chat.completions.create( + model="gpt-4.1-nano", + messages=[ + { + "role": "system", + "content": [ + { + "type": "text", + "text": 'You are a professional Bitcoin investment analyst and trader with expertise in both technical and fundamental analysis.\nBased on the provided chart data (from the variable df), which contains recent OHLCV information for Bitcoin (KRW-BTC), analyze the current market condition and determine the safest possible action: Buy, Sell, or Hold.\nYour top priority is to avoid any potential loss and protect the principal amount under all circumstances.\nIf there is any uncertainty or risk of loss, choose Hold instead of taking action.\nEvaluate short-term momentum, trend direction, and volatility carefully.\nConclude with your final recommendation (Buy, Sell, or Hold) and provide a brief, clear explanation for your reasoning. Response in Json Format.\n\nResponse Example: \n{\n  "decision": "buy",\n  "confidence": 0.88,\n  "reason": "Bitcoin has formed a higher low on the daily chart and just broke above the 50-day moving average with rising volume. RSI is recovering from the mid-40s, suggesting renewed bullish momentum.",\n  "risk_level": "low",\n  "timestamp": "2025-11-07T22:45:00+09:00"\n}\n{\n  "decision": "sell",\n  "confidence": 0.91,\n  "reason": "A double-top pattern has formed near the 100-day moving average with decreasing volume. RSI shows bearish divergence, and price failed to hold the key resistance level at 98,000,000 KRW.",\n  "risk_level": "medium",\n  "timestamp": "2025-11-07T22:45:00+09:00"\n}\n{\n "decision": "hold",\n "confidence": 0.76,\n "reason": "The market is currently consolidating within a narrow range between 92M and 95M KRW. No clear breakout or breakdown signal is confirmed, and volatility remains low.",\n "risk_level": "none",\n "timestamp": "2025-11-07T22:45:00+09:00"\n}', + } + ], + }, + {"role": "user", "content": [{"type": "text", "text": df.to_json()}]}, + ], + response_format={"type": "json_object"}, + ) + + result = response.choices[0].message.content + result = json.loads(result) + + # 로그인 기능 구현 + access = os.getenv("UPBIT_ACCESS_KEY") + secret = os.getenv("UPBIT_SECRET_KEY") + upbit = pyupbit.Upbit(access, secret) + + print("### AI Decision: ", result["decision"].upper(), "###") + print("### Reason:", result["reason"], "###") + + my_money = upbit.get_balance("KRW") + my_coin = upbit.get_balance(coin_name) # 보유 코인 조회 + print("보유 금액:", my_money) + print("보유 코인:", my_coin) + + if my_money is None or my_money == 0: + print("⚠️ KRW 잔고가 없거나 조회 실패. 매매는 실행하지 않습니다.") + return + + available_buy_amount = my_money * fee_multiplier + + if result["decision"] == "buy": + if available_buy_amount > min_order_amount: + print("### Buy Order Executed ###") + print(upbit.buy_market_order(coin_name, available_buy_amount)) + # print("buy: " , result["reason"]) + else: + print(f"### krw 잔고가 {min_order_amount}원 미만 입니다. ###") + + elif result["decision"] == "sell": + # 현재 매도 호가 조회 + orderbook = pyupbit.get_orderbook(ticker=coin_name) + current_price = orderbook["orderbook_units"][0]["ask_price"] + # 매도 시 수수료 제외 전 총 매도 금액 + gross = my_coin * current_price + available_sell_amount = gross * fee_multiplier + + if available_sell_amount > min_order_amount: + print("### Sell Order Executed ###") + print(upbit.sell_market_order(coin_name, my_coin)) + # print("sell: ", result["reason"]) + else: + print( + f"### {coin_name} 매도 예상 금액이 {min_order_amount}원 미만입니다. ###" + ) + + elif result["decision"] == "hold": + print("### Hold Order No Executed ###") + print("hold: ", result["reason"]) + pass ai_trading() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4e59bd5..86850e5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,4 +19,23 @@ dependencies = [ "sqlalchemy>=2.0.44", "uv>=0.9.11", "uvicorn>=0.38.0", + "apscheduler>=3.10.0", + "ruff>=0.14.6", ] + +[dependency-groups] +dev = [ + "mypy>=1.18.2", + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pytest-mock>=3.14.0", + "pytest-cov>=5.0.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +pythonpath = ["."] diff --git a/backend/tests/app/trade/service/test_trade_service.py b/backend/tests/app/trade/service/test_trade_service.py new file mode 100644 index 0000000..20321c3 --- /dev/null +++ b/backend/tests/app/trade/service/test_trade_service.py @@ -0,0 +1,1190 @@ +""" +TradeService 테스트 +모든 메서드와 분기를 테스트합니다. +""" + +from datetime import datetime +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.ai.dto.ai_analysis_response import AiAnalysisResponse, Decision, RiskLevel +from app.coin.model.coin import Coin +from app.trade.dto.transaction_response import ( + TransactionItemResponse, + TransactionsResponse, +) +from app.trade.model.enums import TradeStatus, TradeType +from app.trade.model.trade import Trade + + +class TestExecute: + """execute() 메서드 테스트""" + + async def test_execute_no_active_coins( + self, trade_service, mock_coin_service, mock_trade_repository + ): + """활성 코인이 없는 경우 NO_ACTION 상태로 기록""" + # Given: 활성 코인이 없음 + mock_coin_service.get_all_active.return_value = [] + + mock_trade = Trade( + coin_id=None, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.NO_ACTION, + ai_reason=None, + execution_reason="거래 가능한 활성화된 코인이 없습니다.", + ) + mock_trade_repository.create.return_value = mock_trade + + # When: execute 실행 + result = await trade_service.execute() + + # Then: NO_ACTION 거래 기록 생성 + assert len(result) == 1 + assert result[0].status == TradeStatus.NO_ACTION + assert "활성화된 코인이 없습니다" in result[0].execution_reason + mock_trade_repository.create.assert_called_once() + + async def test_execute_zero_krw_balance( + self, + trade_service, + mock_coin_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + ): + """KRW 잔고가 0인 경우 NO_ACTION 상태로 기록""" + # Given: 활성 코인은 있지만 KRW 잔고가 0 + mock_coin_service.get_all_active.return_value = [sample_coin] + mock_upbit_client.get_krw_balance.return_value = 0 + + mock_trade = Trade( + coin_id=None, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.NO_ACTION, + ai_reason=None, + execution_reason="KRW 잔고가 없습니다. 매수 불가 (잔고: 0원)", + ) + mock_trade_repository.create.return_value = mock_trade + + # When: execute 실행 + result = await trade_service.execute() + + # Then: NO_ACTION 거래 기록 생성 + assert len(result) == 1 + assert result[0].status == TradeStatus.NO_ACTION + assert "KRW 잔고가 없습니다" in result[0].execution_reason + mock_trade_repository.create.assert_called_once() + + async def test_execute_success_with_buy( + self, + trade_service, + mock_coin_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + mock_balance_repository, + sample_coin, + sample_ai_result_buy, + ): + """매수 성공 케이스""" + # Given: 활성 코인, KRW 잔고, AI 매수 결정 + mock_coin_service.get_all_active.return_value = [sample_coin] + mock_upbit_client.get_krw_balance.return_value = 100000 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.get_coin_balance.return_value = 0 + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.return_value = sample_ai_result_buy + mock_upbit_client.buy.return_value = None + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 예정", + ) + + success_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.SUCCESS, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 완료", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = success_trade + + # When: execute 실행 + result = await trade_service.execute() + + # Then: 매수 성공 + assert len(result) == 1 + assert result[0].status == TradeStatus.SUCCESS + assert result[0].trade_type == TradeType.BUY.value + mock_upbit_client.buy.assert_called_once() + mock_balance_repository.create.assert_called_once() + + async def test_execute_success_with_sell( + self, + trade_service, + mock_coin_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + mock_balance_repository, + sample_coin, + sample_ai_result_sell, + ): + """매도 성공 케이스""" + # Given: 활성 코인, 코인 잔고, AI 매도 결정 + mock_coin_service.get_all_active.return_value = [sample_coin] + mock_upbit_client.get_krw_balance.return_value = 100000 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.get_coin_balance.return_value = 0.001 + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.return_value = sample_ai_result_sell + mock_upbit_client.sell.return_value = None + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 예정", + ) + + success_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.SUCCESS, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 완료", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = success_trade + + # When: execute 실행 + result = await trade_service.execute() + + # Then: 매도 성공 + assert len(result) == 1 + assert result[0].status == TradeStatus.SUCCESS + assert result[0].trade_type == TradeType.SELL.value + mock_upbit_client.sell.assert_called_once() + mock_balance_repository.create.assert_called_once() + + async def test_execute_with_exception( + self, + trade_service, + mock_coin_service, + mock_upbit_client, + mock_ai_client, + mock_balance_repository, + sample_coin, + ): + """코인 거래 중 예외 발생 시 계속 진행""" + # Given: 코인 처리 중 예외 발생 + mock_coin_service.get_all_active.return_value = [sample_coin] + mock_upbit_client.get_krw_balance.return_value = 100000 + mock_upbit_client.get_ohlcv_raw.side_effect = Exception("API 오류") + mock_upbit_client.get_coin_balance.return_value = 0 + + # When: execute 실행 (예외가 발생해도 계속 진행) + result = await trade_service.execute() + + # Then: 예외 발생해도 잔고 기록은 수행 + mock_balance_repository.create.assert_called_once() + + +class TestProcessCoinTrade: + """_process_coin_trade() 메서드 테스트""" + + async def test_process_coin_trade_ai_analysis_failed( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + ): + """AI 분석 실패 시 FAILED 상태로 기록""" + # Given: AI 분석 중 예외 발생 + mock_upbit_client.get_ohlcv_raw.side_effect = Exception("API 오류") + + failed_trade = Trade( + coin_id=sample_coin.id, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.FAILED, + ai_reason=None, + execution_reason="AI 분석 실패\n에러 타입: Exception\n에러 메시지: API 오류", + ) + mock_trade_repository.create.return_value = failed_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: FAILED 거래 기록 생성 + assert result.status == TradeStatus.FAILED + assert "AI 분석 실패" in result.execution_reason + mock_trade_repository.create.assert_called_once() + + async def test_process_coin_trade_ai_rate_limit_error( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + ): + """AI 분석 중 RateLimitError 발생 시 FAILED 상태로 기록""" + # Given: OpenAI RateLimitError 발생 + error = Exception("RateLimitError: quota exceeded") + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.side_effect = error + + failed_trade = Trade( + coin_id=sample_coin.id, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.FAILED, + ai_reason=None, + execution_reason="AI 분석 실패 (OpenAI API quota 초과)", + ) + mock_trade_repository.create.return_value = failed_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: FAILED 거래 기록 생성 + assert result.status == TradeStatus.FAILED + assert "quota 초과" in result.execution_reason + mock_trade_repository.create.assert_called_once() + + async def test_process_coin_trade_ai_api_error( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + ): + """AI 분석 중 APIError 발생 시 FAILED 상태로 기록""" + + # Given: OpenAI APIError 발생 + class APIError(Exception): + pass + + error = APIError("API connection failed") + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.side_effect = error + + failed_trade = Trade( + coin_id=sample_coin.id, + trade_type=None, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.NONE.value, + status=TradeStatus.FAILED, + ai_reason=None, + execution_reason="AI 분석 실패 (OpenAI API 오류)", + ) + mock_trade_repository.create.return_value = failed_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: FAILED 거래 기록 생성 + assert result.status == TradeStatus.FAILED + assert "API 오류" in result.execution_reason + mock_trade_repository.create.assert_called_once() + + async def test_process_coin_trade_buy_decision( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + sample_ai_result_buy, + ): + """AI 매수 결정 시 _execute_buy 호출""" + # Given: AI가 매수 결정 + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.return_value = sample_ai_result_buy + mock_upbit_client.get_coin_balance.return_value = 0 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.buy.return_value = None + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 예정", + ) + + success_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.SUCCESS, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 완료", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = success_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: 매수 실행 + assert result.trade_type == TradeType.BUY.value + mock_upbit_client.buy.assert_called_once() + + async def test_process_coin_trade_sell_decision( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + sample_ai_result_sell, + ): + """AI 매도 결정 시 _execute_sell 호출""" + # Given: AI가 매도 결정 + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.return_value = sample_ai_result_sell + mock_upbit_client.get_coin_balance.return_value = 0.001 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.sell.return_value = None + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 예정", + ) + + success_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.SUCCESS, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 완료", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = success_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: 매도 실행 + assert result.trade_type == TradeType.SELL.value + mock_upbit_client.sell.assert_called_once() + + async def test_process_coin_trade_hold_decision( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + sample_ai_result_hold, + ): + """AI HOLD 결정 시 NO_ACTION으로 기록""" + # Given: AI가 HOLD 결정 + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.return_value = sample_ai_result_hold + mock_upbit_client.get_coin_balance.return_value = 0 + mock_upbit_client.get_current_price.return_value = 50000000 + + hold_trade = Trade( + coin_id=sample_coin.id, + trade_type=TradeType.HOLD.value, + price=Decimal("50000000"), + amount=Decimal("0"), + risk_level=RiskLevel.LOW.value, + status=TradeStatus.NO_ACTION, + ai_reason=sample_ai_result_hold.reason, + execution_reason=f"AI HOLD 결정 (Confidence: {sample_ai_result_hold.confidence:.2%})", + ) + mock_trade_repository.create.return_value = hold_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: HOLD 기록 + assert result.status == TradeStatus.NO_ACTION + assert result.trade_type == TradeType.HOLD.value + assert "HOLD 결정" in result.execution_reason + + +class TestExecuteBuy: + """_execute_buy() 메서드 테스트""" + + async def test_execute_buy_insufficient_balance( + self, + trade_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + sample_ai_result_buy, + ): + """KRW 잔고가 최소 주문 금액 미만인 경우 FAILED""" + # Given: 가용 금액이 최소 주문 금액 미만 + krw_balance = 4000 + mock_upbit_client.get_current_price.return_value = 50000000 + + async def create_trade(trade): + return trade + + mock_trade_repository.create.side_effect = create_trade + + # When: _execute_buy 실행 + result = await trade_service._execute_buy( + coin=sample_coin, + coin_name=sample_coin.name, + krw_balance=krw_balance, + fee_multiplier=0.9995, + min_order_amount=5000, + ai_result=sample_ai_result_buy, + ) + + # Then: FAILED 상태 + assert result.status == TradeStatus.FAILED + assert "5000원 미만입니다" in result.execution_reason + mock_upbit_client.buy.assert_not_called() + + async def test_execute_buy_success( + self, + trade_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + sample_ai_result_buy, + ): + """매수 성공""" + # Given: 충분한 잔고 + krw_balance = 100000 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.buy.return_value = None + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 예정", + ) + + success_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.SUCCESS, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 완료", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = success_trade + + # When: _execute_buy 실행 + result = await trade_service._execute_buy( + coin=sample_coin, + coin_name=sample_coin.name, + krw_balance=krw_balance, + fee_multiplier=0.9995, + min_order_amount=5000, + ai_result=sample_ai_result_buy, + ) + + # Then: SUCCESS 상태 + assert result.status == TradeStatus.SUCCESS + mock_upbit_client.buy.assert_called_once() + + async def test_execute_buy_exception( + self, + trade_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + sample_ai_result_buy, + ): + """매수 중 예외 발생 시 FAILED""" + # Given: 매수 실행 중 예외 발생 + krw_balance = 100000 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.buy.side_effect = Exception("주문 실패") + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 예정", + ) + + failed_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.FAILED, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 주문 실패: 주문 실패", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = failed_trade + + # When: _execute_buy 실행 + result = await trade_service._execute_buy( + coin=sample_coin, + coin_name=sample_coin.name, + krw_balance=krw_balance, + fee_multiplier=0.9995, + min_order_amount=5000, + ai_result=sample_ai_result_buy, + ) + + # Then: FAILED 상태 + assert result.status == TradeStatus.FAILED + assert "매수 주문 실패" in result.execution_reason + + +class TestExecuteSell: + """_execute_sell() 메서드 테스트""" + + async def test_execute_sell_no_coin_balance( + self, + trade_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + sample_ai_result_sell, + ): + """코인 잔고가 0인 경우 FAILED""" + # Given: 코인 잔고 0 + coin_balance = 0 + mock_upbit_client.get_current_price.return_value = 50000000 + + failed_trade = Trade( + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("0"), + amount=Decimal("0"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.FAILED, + ai_reason=sample_ai_result_sell.reason, + execution_reason="보유 코인이 없습니다", + ) + mock_trade_repository.create.return_value = failed_trade + + # When: _execute_sell 실행 + result = await trade_service._execute_sell( + coin=sample_coin, + coin_name=sample_coin.name, + coin_balance=coin_balance, + fee_multiplier=0.9995, + min_order_amount=5000, + ai_result=sample_ai_result_sell, + ) + + # Then: FAILED 상태 + assert result.status == TradeStatus.FAILED + assert "보유 코인이 없습니다" in result.execution_reason + mock_upbit_client.sell.assert_not_called() + + async def test_execute_sell_below_min_amount( + self, + trade_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + sample_ai_result_sell, + ): + """매도 예상 금액이 최소 주문 금액 미만인 경우 FAILED""" + # Given: 매도 예상 금액이 최소 주문 금액 미만 + coin_balance = 0.00001 + mock_upbit_client.get_current_price.return_value = 50000000 + + async def create_trade(trade): + return trade + + mock_trade_repository.create.side_effect = create_trade + + # When: _execute_sell 실행 + result = await trade_service._execute_sell( + coin=sample_coin, + coin_name=sample_coin.name, + coin_balance=coin_balance, + fee_multiplier=0.9995, + min_order_amount=5000, + ai_result=sample_ai_result_sell, + ) + + # Then: FAILED 상태 + assert result.status == TradeStatus.FAILED + assert "5000원 미만입니다" in result.execution_reason + mock_upbit_client.sell.assert_not_called() + + async def test_execute_sell_success( + self, + trade_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + sample_ai_result_sell, + ): + """매도 성공""" + # Given: 충분한 코인 잔고 + coin_balance = 0.001 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.sell.return_value = None + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 예정", + ) + + success_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.SUCCESS, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 완료", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = success_trade + + # When: _execute_sell 실행 + result = await trade_service._execute_sell( + coin=sample_coin, + coin_name=sample_coin.name, + coin_balance=coin_balance, + fee_multiplier=0.9995, + min_order_amount=5000, + ai_result=sample_ai_result_sell, + ) + + # Then: SUCCESS 상태 + assert result.status == TradeStatus.SUCCESS + mock_upbit_client.sell.assert_called_once() + + async def test_execute_sell_exception( + self, + trade_service, + mock_upbit_client, + mock_trade_repository, + sample_coin, + sample_ai_result_sell, + ): + """매도 중 예외 발생 시 FAILED""" + # Given: 매도 실행 중 예외 발생 + coin_balance = 0.001 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.sell.side_effect = Exception("주문 실패") + + pending_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 예정", + ) + + failed_trade = Trade( + id=1, + coin_id=sample_coin.id, + trade_type=TradeType.SELL.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.HIGH.value, + status=TradeStatus.FAILED, + ai_reason=sample_ai_result_sell.reason, + execution_reason="매도 주문 실패: 주문 실패", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = failed_trade + + # When: _execute_sell 실행 + result = await trade_service._execute_sell( + coin=sample_coin, + coin_name=sample_coin.name, + coin_balance=coin_balance, + fee_multiplier=0.9995, + min_order_amount=5000, + ai_result=sample_ai_result_sell, + ) + + # Then: FAILED 상태 + assert result.status == TradeStatus.FAILED + assert "매도 주문 실패" in result.execution_reason + + +class TestRecordBalance: + """_record_balance() 메서드 테스트""" + + async def test_record_balance( + self, + trade_service, + mock_upbit_client, + mock_coin_service, + mock_balance_repository, + sample_coin, + ): + """잔고 기록 테스트""" + # Given: KRW 잔고와 활성 코인 + mock_upbit_client.get_krw_balance.return_value = 100000 + mock_coin_service.get_all_active.return_value = [sample_coin] + mock_upbit_client.get_coin_balance.return_value = 0.001 + mock_upbit_client.get_current_price.return_value = 50000000 + mock_balance_repository.create.return_value = None + + # When: _record_balance 실행 + await trade_service._record_balance() + + # Then: 잔고 기록 생성 + mock_balance_repository.create.assert_called_once() + + # 호출된 Balance 객체 검증 + call_args = mock_balance_repository.create.call_args + balance = call_args[0][0] + assert balance.amount == Decimal("100000") + assert balance.coin_amount == Decimal("50000") # 0.001 * 50000000 + + +class TestGetTransactions: + """get_transactions() 메서드 테스트""" + + async def test_get_transactions_first_page( + self, trade_service, mock_trade_repository, sample_coin + ): + """첫 페이지 조회""" + # Given: 15개의 거래 내역 (limit=20이므로 다음 페이지 없음) + trades = [] + for i in range(15): + trade = MagicMock() + trade.id = i + 1 + trade.coin_id = sample_coin.id + trade.trade_type = TradeType.BUY.value + trade.price = Decimal("50000000") + trade.amount = Decimal("0.001") + trade.risk_level = RiskLevel.MEDIUM.value + trade.status = TradeStatus.SUCCESS + trade.ai_reason = "매수" + trade.execution_reason = "완료" + trade.created_at = datetime.utcnow() + trade.coin = sample_coin + trades.append(trade) + + mock_trade_repository.get_all_with_coin_paginated.return_value = trades + + # When: 첫 페이지 조회 + result = await trade_service.get_transactions(cursor=None, limit=20) + + # Then: 15개 반환, 다음 페이지 없음 + assert len(result.items) == 15 + assert result.has_next is False + assert result.next_cursor is None + + async def test_get_transactions_with_cursor( + self, trade_service, mock_trade_repository, sample_coin + ): + """커서로 다음 페이지 조회""" + # Given: 21개의 거래 내역 (limit=20이므로 다음 페이지 있음) + trades = [] + for i in range(21): + trade = MagicMock() + trade.id = i + 21 + trade.coin_id = sample_coin.id + trade.trade_type = TradeType.BUY.value + trade.price = Decimal("50000000") + trade.amount = Decimal("0.001") + trade.risk_level = RiskLevel.MEDIUM.value + trade.status = TradeStatus.SUCCESS + trade.ai_reason = "매수" + trade.execution_reason = "완료" + trade.created_at = datetime.utcnow() + trade.coin = sample_coin + trades.append(trade) + + mock_trade_repository.get_all_with_coin_paginated.return_value = trades + + # When: 커서로 다음 페이지 조회 + result = await trade_service.get_transactions(cursor=20, limit=20) + + # Then: 20개만 반환, 다음 페이지 있음 + assert len(result.items) == 20 + assert result.has_next is True + assert result.next_cursor == 40 # 마지막 항목의 ID + + async def test_get_transactions_last_page( + self, trade_service, mock_trade_repository, sample_coin + ): + """마지막 페이지 조회""" + # Given: 5개의 거래 내역 (limit=20이므로 다음 페이지 없음) + trades = [] + for i in range(5): + trade = MagicMock() + trade.id = i + 41 + trade.coin_id = sample_coin.id + trade.trade_type = TradeType.BUY.value + trade.price = Decimal("50000000") + trade.amount = Decimal("0.001") + trade.risk_level = RiskLevel.MEDIUM.value + trade.status = TradeStatus.SUCCESS + trade.ai_reason = "매수" + trade.execution_reason = "완료" + trade.created_at = datetime.utcnow() + trade.coin = sample_coin + trades.append(trade) + + mock_trade_repository.get_all_with_coin_paginated.return_value = trades + + # When: 마지막 페이지 조회 + result = await trade_service.get_transactions(cursor=40, limit=20) + + # Then: 5개만 반환, 다음 페이지 없음 + assert len(result.items) == 5 + assert result.has_next is False + assert result.next_cursor is None + + async def test_get_transactions_empty(self, trade_service, mock_trade_repository): + """빈 거래 내역 조회""" + # Given: 거래 내역 없음 + mock_trade_repository.get_all_with_coin_paginated.return_value = [] + + # When: 조회 + result = await trade_service.get_transactions(cursor=None, limit=20) + + # Then: 빈 목록 반환 + assert len(result.items) == 0 + assert result.has_next is False + assert result.next_cursor is None + + async def test_get_transactions_with_null_coin( + self, trade_service, mock_trade_repository + ): + """코인이 없는 거래 내역 조회""" + # Given: 코인이 없는 거래 (coin_id=None) + trade = MagicMock() + trade.id = 1 + trade.coin_id = None + trade.trade_type = None + trade.price = Decimal("0") + trade.amount = Decimal("0") + trade.risk_level = RiskLevel.NONE.value + trade.status = TradeStatus.NO_ACTION + trade.ai_reason = None + trade.execution_reason = "활성화된 코인이 없습니다" + trade.created_at = datetime.utcnow() + trade.coin = None + mock_trade_repository.get_all_with_coin_paginated.return_value = [trade] + + # When: 조회 + result = await trade_service.get_transactions(cursor=None, limit=20) + + # Then: 코인 이름이 None인 항목 반환 + assert len(result.items) == 1 + assert result.items[0].coin_name is None + assert result.items[0].coin_id is None + + +class TestExecuteMultipleCoins: + """여러 코인에 대한 execute() 테스트""" + + async def test_execute_multiple_coins_with_balance_refresh( + self, + trade_service, + mock_coin_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + mock_balance_repository, + sample_ai_result_buy, + ): + """여러 코인 매수 시 잔고 갱신 확인""" + # Given: 2개의 활성 코인 + coin1 = MagicMock() + coin1.id = 1 + coin1.name = "KRW-BTC" + + coin2 = MagicMock() + coin2.id = 2 + coin2.name = "KRW-ETH" + + mock_coin_service.get_all_active.return_value = [coin1, coin2] + # 초기, 첫 매수 후, record_balance에서 2번 호출 + mock_upbit_client.get_krw_balance.side_effect = [100000, 50000, 25000, 25000] + mock_upbit_client.get_current_price.return_value = 50000000 + mock_upbit_client.get_coin_balance.return_value = 0 + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.return_value = sample_ai_result_buy + mock_upbit_client.buy.return_value = None + + pending_trade = Trade( + id=1, + coin_id=1, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.PENDING, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 예정", + ) + + success_trade = Trade( + id=1, + coin_id=1, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.SUCCESS, + ai_reason=sample_ai_result_buy.reason, + execution_reason="매수 완료", + ) + + mock_trade_repository.create.return_value = pending_trade + mock_trade_repository.update.return_value = success_trade + + # When: execute 실행 + result = await trade_service.execute() + + # Then: 2개의 거래 실행, 잔고 갱신 호출됨 + assert len(result) == 2 + # 첫 번째 매수 후 잔고 갱신이 호출되어야 함 + assert mock_upbit_client.get_krw_balance.call_count >= 2 + + +class TestRecordBalanceEdgeCases: + """_record_balance() 엣지 케이스 테스트""" + + async def test_record_balance_no_coin_balance( + self, + trade_service, + mock_upbit_client, + mock_coin_service, + mock_balance_repository, + sample_coin, + ): + """코인 잔고가 0인 경우""" + # Given: 코인 잔고 0 + mock_upbit_client.get_krw_balance.return_value = 100000 + mock_coin_service.get_all_active.return_value = [sample_coin] + mock_upbit_client.get_coin_balance.return_value = 0 + mock_balance_repository.create.return_value = None + + # When: _record_balance 실행 + await trade_service._record_balance() + + # Then: 잔고 기록 생성 (coin_amount=0) + mock_balance_repository.create.assert_called_once() + call_args = mock_balance_repository.create.call_args + balance = call_args[0][0] + assert balance.coin_amount == Decimal("0") + + async def test_record_balance_no_active_coins( + self, + trade_service, + mock_upbit_client, + mock_coin_service, + mock_balance_repository, + ): + """활성 코인이 없는 경우""" + # Given: 활성 코인 없음 + mock_upbit_client.get_krw_balance.return_value = 100000 + mock_coin_service.get_all_active.return_value = [] + mock_balance_repository.create.return_value = None + + # When: _record_balance 실행 + await trade_service._record_balance() + + # Then: 잔고 기록 생성 (coin_amount=0) + mock_balance_repository.create.assert_called_once() + call_args = mock_balance_repository.create.call_args + balance = call_args[0][0] + assert balance.coin_amount == Decimal("0") + + +class TestProcessCoinTradeEdgeCases: + """_process_coin_trade() 엣지 케이스 테스트""" + + async def test_process_coin_trade_openai_error_type( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + ): + """AI 분석 중 OpenAI 에러 타입 발생""" + + # Given: OpenAI 타입 에러 발생 + class OpenAIError(Exception): + pass + + error = OpenAIError("Connection error") + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.side_effect = error + + async def create_trade(trade): + return trade + + mock_trade_repository.create.side_effect = create_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: FAILED 거래 기록 생성 + assert result.status == TradeStatus.FAILED + assert "OpenAI" in result.execution_reason + + async def test_process_coin_trade_429_error( + self, + trade_service, + mock_upbit_client, + mock_ai_client, + mock_trade_repository, + sample_coin, + ): + """AI 분석 중 429 에러 메시지 발생""" + # Given: 429 에러 메시지 발생 + error = Exception("Error 429: Rate limit exceeded") + mock_upbit_client.get_ohlcv_raw.return_value = MagicMock() + mock_ai_client.get_bitcoin_trading_decision.side_effect = error + + async def create_trade(trade): + return trade + + mock_trade_repository.create.side_effect = create_trade + + # When: _process_coin_trade 실행 + result = await trade_service._process_coin_trade( + coin=sample_coin, + krw_balance=100000, + fee_multiplier=0.9995, + min_order_amount=5000, + ) + + # Then: FAILED 거래 기록 생성 (quota 초과) + assert result.status == TradeStatus.FAILED + assert "quota 초과" in result.execution_reason diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..13a8feb --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,174 @@ +""" +pytest 전역 설정 및 공통 fixture +""" +from datetime import datetime +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ai.client.open_ai_client import OpenAIClient +from app.ai.dto.ai_analysis_response import AiAnalysisResponse, Decision, RiskLevel +from app.ballance.repository.balance_repository import BalanceRepository +from app.coin.model.coin import Coin +from app.coin.service.coin_service import CoinService +from app.trade.model.enums import TradeStatus, TradeType +from app.trade.model.trade import Trade +from app.trade.repository.trade_repository import TradeRepository +from app.trade.service.trade_service import TradeService +from app.upbit.client.upbit_client import UpbitClient + + +@pytest.fixture +def mock_session(): + """AsyncSession Mock""" + session = MagicMock(spec=AsyncSession) + return session + + +@pytest.fixture +def mock_trade_repository(mocker): + """TradeRepository Mock""" + repo = mocker.MagicMock(spec=TradeRepository) + repo.create = AsyncMock() + repo.update = AsyncMock() + repo.get_all_with_coin_paginated = AsyncMock() + return repo + + +@pytest.fixture +def mock_balance_repository(mocker): + """BalanceRepository Mock""" + repo = mocker.MagicMock(spec=BalanceRepository) + repo.create = AsyncMock() + return repo + + +@pytest.fixture +def mock_coin_service(mocker): + """CoinService Mock""" + service = mocker.MagicMock(spec=CoinService) + service.get_all_active = AsyncMock() + return service + + +@pytest.fixture +def mock_upbit_client(mocker): + """UpbitClient Mock""" + client = mocker.MagicMock(spec=UpbitClient) + client.get_krw_balance = MagicMock() + client.get_coin_balance = MagicMock() + client.get_current_price = MagicMock() + client.get_ohlcv_raw = MagicMock() + client.buy = MagicMock() + client.sell = MagicMock() + return client + + +@pytest.fixture +def mock_ai_client(mocker): + """OpenAIClient Mock""" + client = mocker.MagicMock(spec=OpenAIClient) + client.get_bitcoin_trading_decision = MagicMock() + return client + + +@pytest.fixture +def trade_service( + mock_session, + mock_trade_repository, + mock_balance_repository, + mock_coin_service, + mock_upbit_client, + mock_ai_client, + mocker, +): + """TradeService 인스턴스 생성 (Mock 주입)""" + # TradeService __init__에서 생성되는 객체들을 Mock으로 대체 + mocker.patch( + "app.trade.service.trade_service.TradeRepository", + return_value=mock_trade_repository, + ) + mocker.patch( + "app.trade.service.trade_service.BalanceRepository", + return_value=mock_balance_repository, + ) + mocker.patch( + "app.trade.service.trade_service.CoinService", + return_value=mock_coin_service, + ) + mocker.patch( + "app.trade.service.trade_service.UpbitClient", + return_value=mock_upbit_client, + ) + mocker.patch( + "app.trade.service.trade_service.OpenAIClient", + return_value=mock_ai_client, + ) + + service = TradeService(mock_session) + + return service + + +@pytest.fixture +def sample_coin(): + """테스트용 코인 데이터""" + coin = MagicMock(spec=Coin) + coin.id = 1 + coin.name = "KRW-BTC" + coin.is_deleted = False + return coin + + +@pytest.fixture +def sample_ai_result_buy(): + """매수 결정 AI 응답""" + return AiAnalysisResponse( + decision=Decision.BUY, + confidence=0.85, + reason="상승 추세 예상", + risk_level=RiskLevel.MEDIUM, + timestamp=datetime.utcnow(), + ) + + +@pytest.fixture +def sample_ai_result_sell(): + """매도 결정 AI 응답""" + return AiAnalysisResponse( + decision=Decision.SELL, + confidence=0.75, + reason="하락 추세 예상", + risk_level=RiskLevel.HIGH, + timestamp=datetime.utcnow(), + ) + + +@pytest.fixture +def sample_ai_result_hold(): + """보유 결정 AI 응답""" + return AiAnalysisResponse( + decision=Decision.HOLD, + confidence=0.60, + reason="관망 필요", + risk_level=RiskLevel.LOW, + timestamp=datetime.utcnow(), + ) + + +@pytest.fixture +def sample_trade(): + """테스트용 거래 데이터""" + return Trade( + id=1, + coin_id=1, + trade_type=TradeType.BUY.value, + price=Decimal("50000000"), + amount=Decimal("0.001"), + risk_level=RiskLevel.MEDIUM.value, + status=TradeStatus.SUCCESS, + ai_reason="상승 추세 예상", + execution_reason="매수 완료", + ) diff --git a/backend/uv.lock b/backend/uv.lock index 0bc6338..6c7ef32 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -91,6 +91,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, +] + [[package]] name = "asyncmy" version = "0.2.10" @@ -141,6 +153,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/7b/3f90c33daab8409498a6e57760c6bd23ba3ecef3c684b59c9c6177030073/asyncmy-0.2.10-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1586f26633c05b16bcfc46d86e9875f4941280e12afa79a741cdf77ae4ccfb4d", size = 1613533, upload-time = "2024-12-13T02:36:48.203Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -390,6 +411,234 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/4a/0dc3de1c172d35abe512332cfdcc43211b6ebce629e4cc42e6cd25ed8f4d/coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b", size = 217409, upload-time = "2025-11-18T13:31:53.122Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/086198b98db0109ad4f84241e8e9ea7e5fb2db8c8ffb787162d40c26cc76/coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c", size = 217927, upload-time = "2025-11-18T13:31:54.458Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5f/34614dbf5ce0420828fc6c6f915126a0fcb01e25d16cf141bf5361e6aea6/coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832", size = 244678, upload-time = "2025-11-18T13:31:55.805Z" }, + { url = "https://files.pythonhosted.org/packages/55/7b/6b26fb32e8e4a6989ac1d40c4e132b14556131493b1d06bc0f2be169c357/coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa", size = 246507, upload-time = "2025-11-18T13:31:57.05Z" }, + { url = "https://files.pythonhosted.org/packages/06/42/7d70e6603d3260199b90fb48b537ca29ac183d524a65cc31366b2e905fad/coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73", size = 248366, upload-time = "2025-11-18T13:31:58.362Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4a/d86b837923878424c72458c5b25e899a3c5ca73e663082a915f5b3c4d749/coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb", size = 245366, upload-time = "2025-11-18T13:31:59.572Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c2/2adec557e0aa9721875f06ced19730fdb7fc58e31b02b5aa56f2ebe4944d/coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e", size = 246408, upload-time = "2025-11-18T13:32:00.784Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/8bd1f1148260df11c618e535fdccd1e5aaf646e55b50759006a4f41d8a26/coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777", size = 244416, upload-time = "2025-11-18T13:32:01.963Z" }, + { url = "https://files.pythonhosted.org/packages/0e/13/3a248dd6a83df90414c54a4e121fd081fb20602ca43955fbe1d60e2312a9/coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553", size = 244681, upload-time = "2025-11-18T13:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/76/30/aa833827465a5e8c938935f5d91ba055f70516941078a703740aaf1aa41f/coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d", size = 245300, upload-time = "2025-11-18T13:32:04.686Z" }, + { url = "https://files.pythonhosted.org/packages/38/24/f85b3843af1370fb3739fa7571819b71243daa311289b31214fe3e8c9d68/coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef", size = 220008, upload-time = "2025-11-18T13:32:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a2/c7da5b9566f7164db9eefa133d17761ecb2c2fde9385d754e5b5c80f710d/coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022", size = 220943, upload-time = "2025-11-18T13:32:07.166Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" }, + { url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" }, + { url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" }, + { url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, + { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, + { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, + { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, + { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, + { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, + { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, + { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, + { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, + { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, + { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, + { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, + { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, + { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, + { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, + { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "46.0.3" @@ -611,6 +860,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jiter" version = "0.12.0" @@ -728,6 +1003,7 @@ dependencies = [ { name = "aiomysql" }, { name = "alembic", version = "1.16.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "alembic", version = "1.17.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "apscheduler" }, { name = "asyncmy" }, { name = "cryptography" }, { name = "fastapi" }, @@ -738,15 +1014,28 @@ dependencies = [ { name = "pymysql" }, { name = "python-dotenv" }, { name = "pyupbit" }, + { name = "ruff" }, { name = "sqlalchemy" }, { name = "uv" }, { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + [package.metadata] requires-dist = [ { name = "aiomysql", specifier = ">=0.3.2" }, { name = "alembic", specifier = ">=1.16.5" }, + { name = "apscheduler", specifier = ">=3.10.0" }, { name = "asyncmy", specifier = ">=0.2.10" }, { name = "cryptography", specifier = ">=46.0.3" }, { name = "fastapi", specifier = ">=0.121.3" }, @@ -756,11 +1045,21 @@ requires-dist = [ { name = "pymysql", specifier = ">=1.1.2" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "pyupbit", specifier = ">=0.2.34" }, + { name = "ruff", specifier = ">=0.14.6" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, { name = "uv", specifier = ">=0.9.11" }, { name = "uvicorn", specifier = ">=0.38.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.18.2" }, + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -869,6 +1168,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a6/490ff491d8ecddf8ab91762d4f67635040202f76a44171420bcbe38ceee5/mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b", size = 12807230, upload-time = "2025-09-19T00:09:49.471Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/60076fc829645d167ece9e80db9e8375648d210dab44cc98beb5b322a826/mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133", size = 11895666, upload-time = "2025-09-19T00:10:53.678Z" }, + { url = "https://files.pythonhosted.org/packages/97/4a/1e2880a2a5dda4dc8d9ecd1a7e7606bc0b0e14813637eeda40c38624e037/mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6", size = 12499608, upload-time = "2025-09-19T00:09:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/00/81/a117f1b73a3015b076b20246b1f341c34a578ebd9662848c6b80ad5c4138/mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac", size = 13244551, upload-time = "2025-09-19T00:10:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/b9f48e1714ce87c7bf0358eb93f60663740ebb08f9ea886ffc670cea7933/mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b", size = 13491552, upload-time = "2025-09-19T00:10:13.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/b2c0af3b684fa80d1b27501a8bdd3d2daa467ea3992a8aa612f5ca17c2db/mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0", size = 9765635, upload-time = "2025-09-19T00:10:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "numpy" version = "2.0.2" @@ -1093,6 +1452,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/4f/dbc0c124c40cb390508a82770fb9f6e3ed162560181a85089191a851c59a/openai-2.8.1-py3-none-any.whl", hash = "sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463", size = 1022688, upload-time = "2025-11-17T22:39:57.675Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "pandas" version = "2.3.3" @@ -1163,6 +1531,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/af/7be05277859a7bc399da8ba68b88c96b27b48740b6cf49688899c6eb4176/pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa", size = 11359119, upload-time = "2025-09-29T23:34:46.339Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1354,6 +1740,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyjwt" version = "2.10.1" @@ -1372,6 +1767,115 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1431,6 +1935,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ruff" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" }, + { url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" }, + { url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" }, + { url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" }, + { url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" }, + { url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1627,6 +2157,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" diff --git a/docker-compose.yml b/docker-compose.yml index b70fc93..3a58183 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,40 @@ services: # - ./infra/init.sql:/docker-entrypoint-initdb.d/init.sql networks: - joo-coin + healthcheck: + test: + ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234"] + interval: 10s + timeout: 5s + retries: 5 + + joo-coin-backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: joo-coin-backend + env_file: + - ./backend/.env + ports: + - "8000:8000" + volumes: + - ./backend:/app + - /app/.venv # 가상환경은 컨테이너 내부 것 사용 + depends_on: + joo-coin-db: + condition: service_healthy + networks: + - joo-coin + command: > + sh -c " + echo 'Waiting for database...' && + sleep 5 && + echo 'Running migrations...' && + alembic upgrade head && + echo 'Starting server...' && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + " + restart: unless-stopped volumes: db-data: