diff --git a/backend/app/ai/const/constans.py b/backend/app/ai/const/constans.py index fa9133b..58c48c2 100644 --- a/backend/app/ai/const/constans.py +++ b/backend/app/ai/const/constans.py @@ -1,4 +1,154 @@ 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} +# Role + +You are an aggressive yet disciplined Bitcoin trader specializing in technical analysis. +Your goal is to maximize risk-adjusted returns while maintaining strict risk management. +You seek high-probability setups with minimum 1:2 risk-reward ratio. +Only recommend Buy/Sell when multiple indicators align (confluence). Otherwise, Hold. + +--- + +# Technical Indicators + +## RSI (Relative Strength Index) - 14 Period + +| RSI Value | Condition | Signal | Action | +|-----------|-----------|--------|--------| +| < 30 | Oversold | Bullish | Consider Buy | +| 30-40 | Approaching oversold | Weak bullish | Watch for reversal | +| 40-60 | Neutral | None | Hold | +| 60-70 | Approaching overbought | Weak bearish | Watch for reversal | +| > 70 | Overbought | Bearish | Consider Sell | + +**Divergence:** +- Bullish divergence (price down, RSI up) = Strong buy signal +- Bearish divergence (price up, RSI down) = Strong sell signal + +## MACD (12, 26, 9) + +| Condition | Signal | Strength | +|-----------|--------|----------| +| MACD crosses above Signal | Buy | Strong when below zero line | +| MACD crosses below Signal | Sell | Strong when above zero line | +| Histogram > 0 and increasing | Bullish momentum | Trend continuation | +| Histogram < 0 and decreasing | Bearish momentum | Trend continuation | +| MACD > 0 | Uptrend | Medium-term bullish | +| MACD < 0 | Downtrend | Medium-term bearish | + +## Bollinger Bands (20 SMA, 2σ) + +| Price Position | Condition | Signal | +|----------------|-----------|--------| +| Below lower band | Oversold | Mean reversion buy | +| Above upper band | Overbought | Mean reversion sell | +| Band squeeze (narrow) | Low volatility | Breakout imminent | +| Band expansion | High volatility | Trend in progress | + +## Moving Averages + +| Pattern | Condition | Signal | Timeframe | +|---------|-----------|--------|-----------| +| Golden Cross | 50 MA > 200 MA | Strong Buy | Medium-term | +| Death Cross | 50 MA < 200 MA | Strong Sell | Medium-term | +| Short Golden | 9 EMA > 21 EMA | Buy | Short-term | +| Short Death | 9 EMA < 21 EMA | Sell | Short-term | +| Price > 20 MA | Above trend | Bullish bias | Short-term | +| Price < 20 MA | Below trend | Bearish bias | Short-term | + +--- + +# Combined Strategy + +## Strong Buy Conditions (need 3+ signals) +1. RSI < 35 (oversold zone) +2. Price at or below lower Bollinger Band +3. MACD histogram turning positive (momentum shift) +4. Volume > 20-day average (confirmation) +5. Price bouncing off key support level +6. Bullish divergence present + +## Strong Sell Conditions (need 3+ signals) +1. RSI > 65 (overbought zone) +2. Price at or above upper Bollinger Band +3. MACD histogram turning negative (momentum shift) +4. Volume > 20-day average (confirmation) +5. Price rejected at key resistance level +6. Bearish divergence present + +## Hold Conditions +- Conflicting signals between indicators +- RSI in neutral zone (40-60) with no divergence +- Price consolidating within Bollinger Bands +- Low volume with no clear trend +- Waiting for breakout confirmation + +--- + +# Risk Management + +- **Stop-Loss**: Set at recent swing low/high or 1-2% from entry +- **Take-Profit**: Minimum 1:2 risk-reward ratio +- **Position Sizing**: Risk max 1-2% of capital per trade +- **Confirmation**: Wait for candle close above/below key levels + +--- + +# Response Format + +Respond in JSON format with the following structure: + +```json +{ + "decision": "buy" | "sell" | "hold", + "confidence": 0.0-1.0, + "reason": "Detailed explanation including technical analysis results", + "risk_level": "none" | "low" | "medium" | "high", + "timestamp": "ISO 8601 format" +} +``` + +**Field descriptions:** +- `decision`: Trading action to take +- `confidence`: Confidence level (0.0 to 1.0) +- `reason`: Include all technical analysis details (RSI value, MACD status, Bollinger position, MA trend, volume, entry/exit prices, risk-reward ratio) +- `risk_level`: "none" for hold, "low"/"medium"/"high" for buy/sell +- `timestamp`: Analysis timestamp in ISO 8601 format + +--- + +# Examples + +## Buy Example +```json +{ + "decision": "buy", + "confidence": 0.85, + "reason": "Strong bullish confluence detected. RSI: 32 (oversold) with bullish divergence. MACD: histogram turned positive with bullish crossover. Bollinger: price at lower band. MA: neutral trend. Volume: 1.8x average confirms reversal. Entry: 135,000,000 KRW, Stop-loss: 132,000,000 KRW (2.2% risk), Take-profit: 142,500,000 KRW (5.5% gain). Risk-reward ratio: 1:2.5", + "risk_level": "medium", + "timestamp": "2025-11-22T14:30:00+09:00" +} +``` + +## Sell Example +```json +{ + "decision": "sell", + "confidence": 0.82, + "reason": "Bearish confluence detected. RSI: 73 (overbought) with bearish divergence. MACD: histogram turned negative with bearish crossover. Bollinger: price rejected at upper band. MA: bullish but momentum fading. Volume: above average confirms distribution. Entry: 145,000,000 KRW, Stop-loss: 147,000,000 KRW (1.4% risk), Take-profit: 141,000,000 KRW (2.8% gain). Risk-reward ratio: 1:2", + "risk_level": "medium", + "timestamp": "2025-11-22T14:30:00+09:00" +} +``` + +## Hold Example +```json +{ + "decision": "hold", + "confidence": 0.70, + "reason": "Market in consolidation phase. RSI: 52 (neutral), no divergence. MACD: weak positive histogram, no crossover, neutral trend. Bollinger: price within bands, squeeze forming indicates imminent volatility expansion. MA: neutral trend. Volume: below average suggests lack of conviction. Wait for breakout confirmation before taking position.", + "risk_level": "none", + "timestamp": "2025-11-22T14:30:00+09:00" +} +``` """ OPEN_AI_MODEL = "gpt-4.1-nano" diff --git a/backend/app/ballance/controller/balance_controller.py b/backend/app/ballance/controller/balance_controller.py new file mode 100644 index 0000000..055ca99 --- /dev/null +++ b/backend/app/ballance/controller/balance_controller.py @@ -0,0 +1,53 @@ +""" +Balance Controller +""" + +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ballance.dto.balance_response import BalancesResponse +from app.ballance.service.balance_service import BalanceService +from app.common.model.base import get_session + +balance_router = APIRouter(prefix="/balance", tags=["Balance"]) + + +@balance_router.get( + "/history", + summary="잔고 변화 내역 조회", + description="잔고 변화 내역을 최신순으로 페이지네이션하여 반환합니다.", + response_model=BalancesResponse, +) +async def get_balance_history( + cursor: Optional[int] = Query( + None, + description="이전 페이지의 마지막 잔고 ID (첫 페이지 조회 시 생략)", + ), + limit: int = Query( + 20, + ge=1, + le=100, + description="페이지당 조회할 항목 수 (1-100, 기본값: 20)", + ), + session: AsyncSession = Depends(get_session), +) -> BalancesResponse: + """ + 잔고 변화 내역 조회 (Cursor 기반 페이지네이션) + + 잔고 변화 내역을 최신순으로 페이지네이션하여 반환합니다. + + **사용 방법:** + 1. 첫 페이지: `GET /balance/history?limit=20` + 2. 다음 페이지: 응답의 `next_cursor` 값을 사용하여 `GET /balance/history?cursor={next_cursor}&limit=20` + 3. `has_next`가 `false`이면 마지막 페이지 + + **각 잔고 항목 정보:** + - KRW 잔고 + - 코인 보유량 (KRW 가치로 환산) + - 총 자산 (KRW + 코인) + - 기록 시각 + """ + balance_service = BalanceService(session) + return await balance_service.get_balances(cursor=cursor, limit=limit) diff --git a/backend/app/ballance/dto/balance_response.py b/backend/app/ballance/dto/balance_response.py new file mode 100644 index 0000000..2ea9084 --- /dev/null +++ b/backend/app/ballance/dto/balance_response.py @@ -0,0 +1,43 @@ +""" +Balance Response DTO +""" + +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, Field + + +class BalanceItemResponse(BaseModel): + """잔고 내역 항목 응답 DTO""" + + id: int = Field(description="잔고 기록 ID") + amount: float = Field(description="KRW 잔고") + coin_amount: float = Field(description="코인 보유량 (KRW 가치)") + total_amount: float = Field(description="총 자산 (KRW + 코인)") + created_at: str = Field(description="기록 시각 (YYYY-MM-DD HH:MM:SS)") + + @staticmethod + def from_balance(balance) -> "BalanceItemResponse": + """Balance 엔티티를 BalanceItemResponse로 변환""" + amount = float(balance.amount) if isinstance(balance.amount, Decimal) else balance.amount + coin_amount = float(balance.coin_amount) if isinstance(balance.coin_amount, Decimal) else balance.coin_amount + + return BalanceItemResponse( + id=balance.id, + amount=amount, + coin_amount=coin_amount, + total_amount=amount + coin_amount, + created_at=balance.created_at.strftime("%Y-%m-%d %H:%M:%S") if isinstance(balance.created_at, datetime) else balance.created_at, + ) + + +class BalancesResponse(BaseModel): + """잔고 내역 목록 응답 DTO (Cursor 기반 페이지네이션)""" + + items: list[BalanceItemResponse] = Field(description="잔고 내역 목록") + next_cursor: Optional[int] = Field( + description="다음 페이지를 조회하기 위한 커서 (다음 페이지가 없으면 null)" + ) + has_next: bool = Field(description="다음 페이지 존재 여부") diff --git a/backend/app/ballance/repository/balance_repository.py b/backend/app/ballance/repository/balance_repository.py index 106376d..d706fce 100644 --- a/backend/app/ballance/repository/balance_repository.py +++ b/backend/app/ballance/repository/balance_repository.py @@ -2,7 +2,7 @@ Balance Repository """ -from typing import Optional +from typing import List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -23,3 +23,22 @@ async def get_latest(self) -> Optional[Balance]: select(Balance).order_by(Balance.created_at.desc()).limit(1) ) return result.scalar_one_or_none() + + async def get_all_paginated( + self, cursor: Optional[int] = None, limit: int = 20 + ) -> List[Balance]: + """ + 잔고 내역을 커서 기반 페이지네이션으로 조회 + + @param cursor: 이전 페이지의 마지막 잔고 ID (None이면 첫 페이지) + @param limit: 조회할 항목 수 + @return: 잔고 내역 목록 + """ + query = select(Balance).order_by(Balance.id.desc()) + + if cursor is not None: + query = query.where(Balance.id < cursor) + + query = query.limit(limit) + result = await self.session.execute(query) + return list(result.scalars().all()) diff --git a/backend/app/ballance/service/balance_service.py b/backend/app/ballance/service/balance_service.py new file mode 100644 index 0000000..0d5f562 --- /dev/null +++ b/backend/app/ballance/service/balance_service.py @@ -0,0 +1,49 @@ +""" +Balance Service +""" + +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ballance.dto.balance_response import BalanceItemResponse, BalancesResponse +from app.ballance.repository.balance_repository import BalanceRepository + + +class BalanceService: + """잔고 비즈니스 로직""" + + def __init__(self, session: AsyncSession): + self.session = session + self.balance_repository = BalanceRepository(session) + + async def get_balances( + self, cursor: Optional[int] = None, limit: int = 20 + ) -> BalancesResponse: + """ + 잔고 내역을 커서 기반 페이지네이션으로 조회 + + @param cursor: 이전 페이지의 마지막 잔고 ID (None이면 첫 페이지) + @param limit: 페이지당 조회할 항목 수 (기본: 20) + @return: 잔고 내역 목록 응답 (다음 페이지 정보 포함) + """ + # limit + 1개를 조회하여 다음 페이지 존재 여부 확인 + balances = await self.balance_repository.get_all_paginated( + cursor=cursor, limit=limit + 1 + ) + + # 다음 페이지 존재 여부 판단 + has_next = len(balances) > limit + + # 실제 반환할 항목은 limit개만 + if has_next: + balances = balances[:limit] + next_cursor = balances[-1].id if balances else None + else: + next_cursor = None + + items = [BalanceItemResponse.from_balance(balance) for balance in balances] + + return BalancesResponse( + items=items, next_cursor=next_cursor, has_next=has_next + ) diff --git a/backend/app/common/api/v1/v1_router.py b/backend/app/common/api/v1/v1_router.py index 54ccbf5..b84f17e 100644 --- a/backend/app/common/api/v1/v1_router.py +++ b/backend/app/common/api/v1/v1_router.py @@ -6,6 +6,7 @@ from fastapi import APIRouter +from app.ballance.controller.balance_controller import balance_router 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 @@ -17,3 +18,4 @@ v1_router.include_router(coin_router) v1_router.include_router(upbit_router) v1_router.include_router(trade_router) +v1_router.include_router(balance_router) diff --git a/backend/app/trade/controller/trade_controller.py b/backend/app/trade/controller/trade_controller.py index 62201ed..1a4d1c1 100644 --- a/backend/app/trade/controller/trade_controller.py +++ b/backend/app/trade/controller/trade_controller.py @@ -21,7 +21,12 @@ async def execute_trades(session: AsyncSession = Depends(get_session)) -> None: await trade_service.execute() -@trade_router.get("/transactions") +@trade_router.get( + "/transactions", + summary="내 거래 내역 조회", + description="내 거래 내역을 최신순으로 페이지네이션하여 반환합니다.", + response_model=TransactionsResponse, +) async def get_transactions( cursor: Optional[int] = Query( None,