Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 151 additions & 1 deletion backend/app/ai/const/constans.py
Original file line number Diff line number Diff line change
@@ -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"
53 changes: 53 additions & 0 deletions backend/app/ballance/controller/balance_controller.py
Original file line number Diff line number Diff line change
@@ -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)
43 changes: 43 additions & 0 deletions backend/app/ballance/dto/balance_response.py
Original file line number Diff line number Diff line change
@@ -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="다음 페이지 존재 여부")
21 changes: 20 additions & 1 deletion backend/app/ballance/repository/balance_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
49 changes: 49 additions & 0 deletions backend/app/ballance/service/balance_service.py
Original file line number Diff line number Diff line change
@@ -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
)
2 changes: 2 additions & 0 deletions backend/app/common/api/v1/v1_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
7 changes: 6 additions & 1 deletion backend/app/trade/controller/trade_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down