Theowise / apps /trading.py
hoon1018's picture
Update apps/trading.py
aace119 verified
import os
import requests
import json
import jwt
import uuid
import hashlib
import time
import math
import asyncio
import pandas as pd
from datetime import datetime
from urllib.parse import urlencode
from fastapi import APIRouter, Request, BackgroundTasks, Form, Response
router = APIRouter()
# ---------------------------------------------------------
# [์„ค์ •] ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ๋กœ๋“œ
# ---------------------------------------------------------
BITHUMB_ACCESS_KEY = os.environ.get("BITHUMB_ACCESS_KEY")
BITHUMB_SECRET_KEY = os.environ.get("BITHUMB_SECRET_KEY")
WEBHOOK_PASS = os.environ.get("WEBHOOK_PASS")
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")
BITHUMB_API_URL = 'https://api.bithumb.com'
def get_auth_headers(params=None):
payload = {
'access_key': BITHUMB_ACCESS_KEY,
'nonce': str(uuid.uuid4()),
'timestamp': round(time.time() * 1000),
}
if params:
query = urlencode(params).encode()
hash = hashlib.sha512()
hash.update(query)
payload['query_hash'] = hash.hexdigest()
payload['query_hash_alg'] = 'SHA512'
jwt_token = jwt.encode(payload, BITHUMB_SECRET_KEY)
return {'Authorization': f'Bearer {jwt_token}', 'Content-Type': 'application/json'}
# ---------------------------------------------------------
# [๊ณตํ†ต] Slack ๋ฉ”์‹œ์ง€ ๋ฐœ์†ก ํ•จ์ˆ˜ (๋ธ”๋กํ‚ท ์ง€์›)
# ---------------------------------------------------------
def send_slack_message(text, blocks=None):
if not SLACK_WEBHOOK_URL: return
payload = {"text": text}
if blocks: payload["blocks"] = blocks
try:
requests.post(SLACK_WEBHOOK_URL, json=payload, timeout=3)
except Exception as e:
print(f"Slack ์—๋Ÿฌ: {e}")
# ---------------------------------------------------------
# [๊ณตํ†ต] ํ˜ธ๊ฐ€ ๋‹จ์œ„(Tick Size) ๊ณ„์‚ฐ ํ•จ์ˆ˜
# ---------------------------------------------------------
def get_tick_size(price):
if price >= 1000000: return 1000.0
elif price >= 500000: return 500.0
elif price >= 100000: return 100.0
elif price >= 10000: return 10.0
elif price >= 5000: return 5.0
elif price >= 100: return 1.0
elif price >= 10: return 0.01
else: return 0.001
# ---------------------------------------------------------
# [๊ธฐ๋Šฅ] ๋ฏธ์ฒด๊ฒฐ ๋งค์ˆ˜ ์ฃผ๋ฌธ ์ทจ์†Œ
# ---------------------------------------------------------
def cancel_pending_buy_orders(market):
try:
params = {'market': market, 'state': 'wait'}
headers = get_auth_headers(params)
res = requests.get(f"{BITHUMB_API_URL}/v1/orders", params=params, headers=headers)
orders = res.json()
if not isinstance(orders, list): return
for order in orders:
if order.get('side') == 'bid':
cancel_params = {'order_id': order['uuid']}
cancel_headers = get_auth_headers(cancel_params)
c_res = requests.delete(f"{BITHUMB_API_URL}/v2/orders", params=cancel_params, headers=cancel_headers)
# Bithumb API ํ™•์ธ ํ›„ ์•Œ๋ฆผ
if 'uuid' in c_res.json():
price = float(order.get('price', 0))
vol = float(order.get('remaining_volume', 0))
send_slack_message(f"๐Ÿšซ *[๋งค์ˆ˜ ์ทจ์†Œ ์™„๋ฃŒ]* {market}\n๊ฐ€๊ฒฉ: {price:,.2f} | ์ˆ˜๋Ÿ‰: {vol}")
except Exception as e: print(f"๐Ÿšจ ์ทจ์†Œ ์—๋Ÿฌ: {e}")
# ---------------------------------------------------------
# [๊ธฐ๋Šฅ] ์ „์ฒด ํ˜ธ๊ฐ€์ฐฝ & ์ž์‚ฐ ์กฐํšŒ
# ---------------------------------------------------------
def get_market_depth(market):
try:
res = requests.get(f"{BITHUMB_API_URL}/v1/orderbook?markets={market}", timeout=2)
return res.json()[0]['orderbook_units']
except: return None
def get_asset_info(currency):
try:
headers = get_auth_headers()
res = requests.get(f"{BITHUMB_API_URL}/v1/accounts", headers=headers, timeout=2)
for acc in res.json():
if acc['currency'] == currency.upper():
return float(acc['balance']), float(acc.get('avg_buy_price', 0))
except: pass
return 0.0, 0.0
def place_order(market, side, price, volume, order_type='limit'):
params = {'market': market, 'side': side, 'order_type': order_type, 'volume': str(volume)}
if order_type == 'limit':
params['price'] = str(price)
headers = get_auth_headers(params)
res = requests.post(f"{BITHUMB_API_URL}/v2/orders", data=json.dumps(params), headers=headers, timeout=3)
return res.json()
# ---------------------------------------------------------
# [๊ธฐ๋Šฅ] 1๋ถ„ ๊ฒฝ๊ณผ ๋ชจ๋‹ˆํ„ฐ๋ง ๋ฐ Slack 1~10ํ˜ธ๊ฐ€ ๋“œ๋กญ๋‹ค์šด UI ์ƒ์„ฑ (+์•ˆ์ „์žฅ์น˜ ์ถ”๊ฐ€)
# ---------------------------------------------------------
async def monitor_pending_order(order_id, market, side, original_price, volume):
for i in range(1, 6):
await asyncio.sleep(60) # 1๋ถ„ ๋Œ€๊ธฐ
params = {'market': market, 'uuid': order_id}
headers = get_auth_headers(params)
res = requests.get(f"{BITHUMB_API_URL}/v1/order", params=params, headers=headers)
if res.status_code == 200:
order_info = res.json()
if order_info.get('state') == 'wait':
side_kr = "๋งค์ˆ˜" if side == "bid" else "๋งค๋„"
up_options = [{"text": {"type": "plain_text", "text": f"+{t}ํ˜ธ๊ฐ€"}, "value": f"up|{t}|{market}|{order_id}"} for t in range(1, 11)]
down_options = [{"text": {"type": "plain_text", "text": f"-{t}ํ˜ธ๊ฐ€"}, "value": f"down|{t}|{market}|{order_id}"} for t in range(1, 11)]
# ๐Ÿ›ก๏ธ ์•ˆ์ „์žฅ์น˜: ์ „์†ก ํ™•์ • ํŒ์—… UI ์ •์˜
confirm_dialog = {
"title": {"type": "plain_text", "text": "์ฃผ๋ฌธ ์ •์ • ํ™•์ธ"},
"text": {"type": "mrkdwn", "text": "์„ ํƒํ•˜์‹  ํ˜ธ๊ฐ€๋กœ ๊ธฐ์กด ์ฃผ๋ฌธ์„ ์ทจ์†Œํ•˜๊ณ  *์ƒˆ๋กœ ์ •์ • ์ฃผ๋ฌธ*์„ ์ „์†กํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?"},
"confirm": {"type": "plain_text", "text": "์ „์†ก (ํ™•์ •)"},
"deny": {"type": "plain_text", "text": "์ทจ์†Œ"}
}
blocks = [
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"โณ *[{i}๋ถ„ ๊ฒฝ๊ณผ] {market} {side_kr} ๋Œ€๊ธฐ์ค‘*\n๊ฐ€๊ฒฉ: {original_price:,.2f} | ์ˆ˜๋Ÿ‰: {volume}\n์ฃผ๋ฌธID: `{order_id}`"}
},
{
"type": "actions",
"elements": [
# ์ทจ์†Œ ๋ฒ„ํŠผ์—๋„ ์•ˆ์ „์žฅ์น˜ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ!
{
"type": "button",
"text": {"type": "plain_text", "text": "๐Ÿšซ ์ฃผ๋ฌธ ์ทจ์†Œ"},
"style": "danger",
"value": f"cancel|0|{market}|{order_id}",
"confirm": {
"title": {"type": "plain_text", "text": "์ฃผ๋ฌธ ์ทจ์†Œ ํ™•์ธ"},
"text": {"type": "mrkdwn", "text": "์ •๋ง๋กœ ๋Œ€๊ธฐ ์ค‘์ธ ์ฃผ๋ฌธ์„ ์ทจ์†Œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?"},
"confirm": {"type": "plain_text", "text": "์ทจ์†Œ ์ง„ํ–‰"},
"deny": {"type": "plain_text", "text": "๋‹ซ๊ธฐ"}
}
},
# + ํ˜ธ๊ฐ€ ๋“œ๋กญ๋‹ค์šด (+ ํŒ์—…์ฐฝ ์—ฐ๊ฒฐ)
{
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "๐Ÿ“ˆ +ํ˜ธ๊ฐ€ ์˜ฌ๋ฆฌ๊ธฐ"},
"options": up_options,
"confirm": confirm_dialog
},
# - ํ˜ธ๊ฐ€ ๋“œ๋กญ๋‹ค์šด (+ ํŒ์—…์ฐฝ ์—ฐ๊ฒฐ)
{
"type": "static_select",
"placeholder": {"type": "plain_text", "text": "๐Ÿ“‰ -ํ˜ธ๊ฐ€ ๋‚ด๋ฆฌ๊ธฐ"},
"options": down_options,
"confirm": confirm_dialog
}
]
}
]
send_slack_message(f"โณ {market} {side_kr} ๋Œ€๊ธฐ์ค‘", blocks=blocks)
elif order_info.get('state') == 'done':
side_kr = "๋งค์ˆ˜" if side == "bid" else "๋งค๋„"
send_slack_message(f"โœ… *[{side_kr} ์ฒด๊ฒฐ ์™„๋ฃŒ]* {market}\n๊ฐ€๊ฒฉ: {original_price:,.2f} | ์ˆ˜๋Ÿ‰: {volume}")
break
else:
break # ์ทจ์†Œ๋˜์—ˆ๊ฑฐ๋‚˜ ์˜ˆ์™ธ ์ƒํƒœ
# ---------------------------------------------------------
# [API] Webhook ์—”๋“œํฌ์ธํŠธ
# ---------------------------------------------------------
@router.post("/webhook")
async def bithumb_webhook(request: Request, bg_tasks: BackgroundTasks):
try:
data = await request.json()
# [1] ๋ณด์•ˆ ๊ฒ€์ฆ
if data.get("id", "")[:4] != WEBHOOK_PASS:
return {"status": "error", "msg": "Unauthorized"}
# [2] ๋ฐ์ดํ„ฐ ์ถ”์ถœ
symbol = data.get("ticker", "").upper().replace("KRW", "").replace("-", "").replace("/", "").strip()
market = f"KRW-{symbol}"
action = data.get("action", "").lower()
# [3] ํ˜ธ๊ฐ€ ์กฐํšŒ
depth = get_market_depth(market)
if not depth: return {"status": "error", "msg": "orderbook_fetch_failed"}
current_bid = float(depth[0]['bid_price']) # ๋งค์ˆ˜ 1ํ˜ธ๊ฐ€
current_ask = float(depth[0]['ask_price']) # ๋งค๋„ 1ํ˜ธ๊ฐ€
# ------------------------------------------
# 1. [๋งค์ˆ˜]
# ------------------------------------------
if "buy" in action or "bid" in action:
# ํŠธ๋ ˆ์ด๋”ฉ๋ทฐ ํŠธ๋ฆฌ๊ฑฐ ์•Œ๋ฆผ
send_slack_message(f"[๐Ÿ“ˆbuy-signal]{symbol}, ํ˜ธ๊ฐ€ {current_ask:,.2f}")
price = float(depth[3]['bid_price']) # ๋งค์ˆ˜ 4ํ˜ธ๊ฐ€
amount_krw = float(data.get("amount", 5000))
volume = math.floor((amount_krw / price) * 10000) / 10000
resp = place_order(market, 'bid', price, volume)
# API ํฌ๋กœ์Šค์ฒดํฌ ๋ฐ ์‹คํŒจ ์•Œ๋ฆผ
if 'error' in resp:
err_msg = resp['error'].get('message', '์•Œ์ˆ˜์—†์Œ')
send_slack_message(f"โŒ *[๋งค์ˆ˜ ์‹คํŒจ]* {market}\n์‹œ๋„ ๊ฐ€๊ฒฉ: {price:,.2f} | ์ˆ˜๋Ÿ‰: {volume}\n์‚ฌ์œ : {err_msg}")
return {"status": "error", "msg": err_msg}
order_id = resp.get('uuid')
send_slack_message(f"๐Ÿ“ฅ *[๋งค์ˆ˜ ๋Œ€๊ธฐ]* {market} (4ํ˜ธ๊ฐ€)\n๊ฐ€๊ฒฉ: {price:,.2f} | ์ˆ˜๋Ÿ‰: {volume}")
# ๋ฐฑ๊ทธ๋ผ์šด๋“œ 1๋ถ„ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹คํ–‰
bg_tasks.add_task(monitor_pending_order, order_id, market, 'bid', price, volume)
# ------------------------------------------
# 2. [๋งค๋„]
# ------------------------------------------
else:
# ํŠธ๋ ˆ์ด๋”ฉ๋ทฐ ํŠธ๋ฆฌ๊ฑฐ ์•Œ๋ฆผ
send_slack_message(f"[๐Ÿ“‰signal-sell]{symbol}, ํ˜ธ๊ฐ€ {current_bid:,.2f}")
balance, avg_buy_price = get_asset_info(symbol)
volume = math.floor(balance * 10000) / 10000
cancel_pending_buy_orders(market)
if volume > 0 and (volume * current_bid) >= 5000:
profit_rate = ((current_bid - avg_buy_price) / avg_buy_price) * 100 if avg_buy_price > 0 else 0
if profit_rate >= 5.0:
# ์ˆ˜์ต 5% ์ด์ƒ -> ์‹œ์žฅ๊ฐ€ ์ „๋Ÿ‰ ๋งค๋„
resp = place_order(market, 'ask', None, volume, order_type='market')
if 'error' in resp:
send_slack_message(f"โŒ *[์‹œ์žฅ๊ฐ€ ๋งค๋„ ์‹คํŒจ]* {market}\n์‚ฌ์œ : {resp['error'].get('message', '')}")
else:
send_slack_message(f"๐Ÿš€ *[์‹œ์žฅ๊ฐ€ ๋งค๋„ ์™„๋ฃŒ]* {market}\n์ˆ˜์ต๋ฅ : {profit_rate:.2f}% | ์ˆ˜๋Ÿ‰: {volume}")
else:
# ์ˆ˜์ต 5% ๋ฏธ๋งŒ -> ์‹œ๊ทธ๋„ ๋ฐœ์ƒ ๊ฐ€๊ฒฉ์˜ -1ํ˜ธ๊ฐ€๋กœ ์ง€์ •๊ฐ€ ๋งค๋„
target_price = current_bid - get_tick_size(current_bid)
resp = place_order(market, 'ask', target_price, volume, order_type='limit')
if 'error' in resp:
send_slack_message(f"โŒ *[๋งค๋„ ์‹คํŒจ (-1ํ˜ธ๊ฐ€)]* {market}\n์‚ฌ์œ : {resp['error'].get('message', '')}")
else:
order_id = resp.get('uuid')
send_slack_message(f"๐Ÿ“ค *[๋งค๋„ ๋Œ€๊ธฐ (-1ํ˜ธ๊ฐ€)]* {market}\n๊ฐ€๊ฒฉ: {target_price:,.2f} | ์ˆ˜๋Ÿ‰: {volume}\n์ˆ˜์ต๋ฅ : {profit_rate:.2f}%")
# ๋ฐฑ๊ทธ๋ผ์šด๋“œ 1๋ถ„ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹คํ–‰
bg_tasks.add_task(monitor_pending_order, order_id, market, 'ask', target_price, volume)
else:
send_slack_message(f"โš ๏ธ *[๋งค๋„ ํŒจ์Šค]* {market}\n์‚ฌ์œ : ์ž”๊ณ  ๋ถ€์กฑ ๋˜๋Š” ์ตœ์†Œ์ฃผ๋ฌธ๊ธˆ์•ก ๋ฏธ๋‹ฌ")
return {"status": "success"}
except Exception as e:
return {"status": "error", "msg": str(e)}
# ---------------------------------------------------------
# [API] Slack ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ๋“œ๋กญ๋‹ค์šด & ๋ฒ„ํŠผ ์ฒ˜๋ฆฌ
# ---------------------------------------------------------
@router.post("/slack/interaction")
async def slack_interaction(payload: str = Form(...)):
try:
data = json.loads(payload)
# ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๊ฑฐ๋‚˜ ๋“œ๋กญ๋‹ค์šด์„ ์„ ํƒํ–ˆ์„ ๋•Œ ๋„˜์–ด์˜ค๋Š” ๊ฐ’ ์ถ”์ถœ
action = data['actions'][0]
action_type = action['type']
# ๋ฒ„ํŠผ์ด๋ฉด 'value', ๋“œ๋กญ๋‹ค์šด์ด๋ฉด 'selected_option'์—์„œ ๊ฐ’ ์ถ”์ถœ
if action_type == 'button':
action_val = action['value']
else:
action_val = action['selected_option']['value']
parts = action_val.split('|')
cmd = parts[0] # cancel, up, down
ticks = int(parts[1]) # ๋ณ€๊ฒฝํ•  ํ˜ธ๊ฐ€ ์นธ ์ˆ˜ (1~10)
market = parts[2]
order_id = parts[3]
# 1. Bithumb API์—์„œ ๊ธฐ์กด ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ (์ตœ์‹  ์ž”์—ฌ ์ˆ˜๋Ÿ‰ ๋ฐ ๊ฐ€๊ฒฉ ํ™•์ธ์šฉ)
params = {'market': market, 'uuid': order_id}
headers = get_auth_headers(params)
res = requests.get(f"{BITHUMB_API_URL}/v1/order", params=params, headers=headers)
order_info = res.json()
if order_info.get('state') != 'wait':
send_slack_message(f"โš ๏ธ ์ด๋ฏธ ์ฒด๊ฒฐ๋˜์—ˆ๊ฑฐ๋‚˜ ์ทจ์†Œ๋œ ์ฃผ๋ฌธ์ž…๋‹ˆ๋‹ค.")
return Response(status_code=200)
old_side = order_info['side']
old_price = float(order_info['price'])
remain_volume = float(order_info['remaining_volume']) # ๊ทธ์‚ฌ์ด ์ผ๋ถ€๊ฐ€ ์ฒด๊ฒฐ๋˜์—ˆ์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋‚จ์€ ์ˆ˜๋Ÿ‰๋งŒ ๊ฐ€์ ธ์˜ด
# 2. ๊ธฐ์กด ์ฃผ๋ฌธ ๋จผ์ € ์ทจ์†Œ
cancel_params = {'order_id': order_id}
cancel_headers = get_auth_headers(cancel_params)
requests.delete(f"{BITHUMB_API_URL}/v2/orders", params=cancel_params, headers=cancel_headers)
if cmd == "cancel":
send_slack_message(f"โœ… *[์ˆ˜๋™ ์ทจ์†Œ ์™„๋ฃŒ]* {market}")
return Response(status_code=200)
# 3. ํ˜ธ๊ฐ€ ์žฌ๊ณ„์‚ฐ (ex: 9995์›์—์„œ 3ํ˜ธ๊ฐ€ ์˜ฌ๋ฆด ๋•Œ 10000์›์„ ๋„˜์–ด๊ฐ€๋ฉด ํ˜ธ๊ฐ€ ๋‹จ์œ„๊ฐ€ ๋ฐ”๋€Œ๋Š” ๊ฒƒ๊นŒ์ง€ ์•ˆ์ „ํ•˜๊ฒŒ ๊ณ„์‚ฐ)
new_price = old_price
for _ in range(ticks):
tick_size = get_tick_size(new_price)
if cmd == "up":
new_price += tick_size
elif cmd == "down":
new_price -= tick_size
# 4. ์ƒˆ๋กœ์šด ๊ฐ€๊ฒฉ์œผ๋กœ ์žฌ์ฃผ๋ฌธ ์‹คํ–‰
order_res = place_order(market, old_side, new_price, remain_volume, 'limit')
if 'error' in order_res:
send_slack_message(f"โŒ *[ํ˜ธ๊ฐ€ ์ •์ • ์‹คํŒจ]* {market}\n์‚ฌ์œ : {order_res['error'].get('message')}")
else:
side_kr = "๋งค์ˆ˜" if old_side == "bid" else "๋งค๋„"
send_slack_message(f"๐Ÿ”„ *[{side_kr} {ticks}ํ˜ธ๊ฐ€ ์ •์ • ์™„๋ฃŒ]* {market}\n๊ธฐ์กด: {old_price:,.2f} โž” ๋ณ€๊ฒฝ: {new_price:,.2f} | ์ˆ˜๋Ÿ‰: {remain_volume}")
return Response(status_code=200)
except Exception as e:
print(f"Slack Interaction Error: {e}")
return Response(status_code=200)