| 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'} |
|
|
| |
| |
| |
| 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}") |
|
|
| |
| |
| |
| 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) |
| |
| |
| 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() |
|
|
| |
| |
| |
| async def monitor_pending_order(order_id, market, side, original_price, volume): |
| for i in range(1, 6): |
| await asyncio.sleep(60) |
| |
| 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)] |
| |
| |
| 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 |
|
|
| |
| |
| |
| @router.post("/webhook") |
| async def bithumb_webhook(request: Request, bg_tasks: BackgroundTasks): |
| try: |
| data = await request.json() |
| |
| |
| if data.get("id", "")[:4] != WEBHOOK_PASS: |
| return {"status": "error", "msg": "Unauthorized"} |
|
|
| |
| symbol = data.get("ticker", "").upper().replace("KRW", "").replace("-", "").replace("/", "").strip() |
| market = f"KRW-{symbol}" |
| action = data.get("action", "").lower() |
|
|
| |
| depth = get_market_depth(market) |
| if not depth: return {"status": "error", "msg": "orderbook_fetch_failed"} |
|
|
| current_bid = float(depth[0]['bid_price']) |
| current_ask = float(depth[0]['ask_price']) |
|
|
| |
| |
| |
| if "buy" in action or "bid" in action: |
| |
| send_slack_message(f"[๐buy-signal]{symbol}, ํธ๊ฐ {current_ask:,.2f}") |
| |
| price = float(depth[3]['bid_price']) |
| amount_krw = float(data.get("amount", 5000)) |
| volume = math.floor((amount_krw / price) * 10000) / 10000 |
| |
| resp = place_order(market, 'bid', price, volume) |
| |
| |
| 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}") |
| |
| |
| bg_tasks.add_task(monitor_pending_order, order_id, market, 'bid', price, volume) |
|
|
| |
| |
| |
| 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: |
| |
| 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: |
| |
| 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}%") |
| |
| |
| 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)} |
|
|
| |
| |
| |
| @router.post("/slack/interaction") |
| async def slack_interaction(payload: str = Form(...)): |
| try: |
| data = json.loads(payload) |
| |
| |
| action = data['actions'][0] |
| action_type = action['type'] |
| |
| |
| if action_type == 'button': |
| action_val = action['value'] |
| else: |
| action_val = action['selected_option']['value'] |
| |
| parts = action_val.split('|') |
| cmd = parts[0] |
| ticks = int(parts[1]) |
| market = parts[2] |
| order_id = parts[3] |
|
|
| |
| 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']) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |