diff --git a/src/app/api/market-order/route.ts b/src/app/api/market-order/route.ts new file mode 100644 index 0000000..2b9a4fe --- /dev/null +++ b/src/app/api/market-order/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { marketOrderAPI } from "@/entities"; +import { Json, createClient } from "@/shared"; + +export async function POST(request: NextRequest) { + try { + const { market } = await request.json(); + + if (!market) { + return NextResponse.json({ error: "Market is required" }, { status: 400 }); + } + + // Upbit API에서 데이터 가져오기 + const upbitData = await marketOrderAPI(market); + const marketData = upbitData[0]; + + if (!marketData) { + return NextResponse.json({ error: "No data from Upbit API" }, { status: 404 }); + } + + // Supabase에 저장 (upsert) + const supabase = createClient(); + const { data, error } = await supabase + .from("market_orders") + .upsert( + { + market: marketData.market, + orderbook_units: marketData.orderbook_units as unknown as Json, + timestamp: marketData.timestamp, + }, + { + onConflict: "market", + }, + ) + .select() + .single(); + + if (error) { + console.error("Supabase upsert error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ success: true, data }); + } catch (error) { + console.error("Sync error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// GET 요청으로 특정 마켓 조회 +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const market = searchParams.get("market"); + + if (!market) { + return NextResponse.json({ error: "Market is required" }, { status: 400 }); + } + + const supabase = createClient(); + const { data, error } = await supabase.from("market_orders").select("*").eq("market", market).single(); + + if (error) { + if (error.code === "PGRST116") { + return NextResponse.json({ error: "Market not found" }, { status: 404 }); + } + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data); + } catch (error) { + console.error("Get error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/entities/market/model/apis/index.ts b/src/entities/market/model/apis/index.ts index d62e325..3112ea3 100644 --- a/src/entities/market/model/apis/index.ts +++ b/src/entities/market/model/apis/index.ts @@ -1,2 +1,3 @@ export * from "./market-all.api"; +export * from "./market-order.api"; export * from "./ticker.api"; diff --git a/src/entities/market/model/apis/market-order.api.ts b/src/entities/market/model/apis/market-order.api.ts new file mode 100644 index 0000000..d8dbd4d --- /dev/null +++ b/src/entities/market/model/apis/market-order.api.ts @@ -0,0 +1,39 @@ +import { UPBIT_URL } from "@/shared"; + +import { OrderbookUnit } from "../types"; + +export interface MarketOrderResponse { + market: string; + orderbook_units: OrderbookUnit[]; + timestamp: number; +} + +export const marketOrderAPI = async (markets: string): Promise => { + const response = await fetch(`${UPBIT_URL}/orderbook?markets=${markets}`, { + cache: "no-store", + }); + + if (!response.ok) { + console.error("Upbit API error", response.status, response.statusText); + throw new Error("Failed to fetch market order data"); + } + + const data = await response.json(); + return data; +}; + +export const syncMarketOrderAPI = async (market: string): Promise => { + const response = await fetch("/api/market-order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ market }), + }); + + if (!response.ok) { + console.error("Sync failed:", response.status, response.statusText); + throw new Error("Failed to fetch sync market order data"); + } + + const data = await response.json(); + return data; +}; diff --git a/src/entities/market/model/hooks/index.ts b/src/entities/market/model/hooks/index.ts new file mode 100644 index 0000000..50b35e8 --- /dev/null +++ b/src/entities/market/model/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useGetMarketOrder"; +export * from "./useMarketOrderRealtime"; diff --git a/src/entities/market/model/hooks/useGetMarketOrder.ts b/src/entities/market/model/hooks/useGetMarketOrder.ts new file mode 100644 index 0000000..e0f94c8 --- /dev/null +++ b/src/entities/market/model/hooks/useGetMarketOrder.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; + +import { marketOrderAPI } from "../apis"; + +export const useGetMarketOrder = (markets: string) => { + return useQuery({ + queryKey: ["market-order", markets], + queryFn: () => marketOrderAPI(markets), + + refetchInterval: 1000, + }); +}; diff --git a/src/entities/market/model/hooks/useMarketOrderRealtime.ts b/src/entities/market/model/hooks/useMarketOrderRealtime.ts new file mode 100644 index 0000000..09b1d4c --- /dev/null +++ b/src/entities/market/model/hooks/useMarketOrderRealtime.ts @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { createClient } from "@/shared"; + +import { OrderbookUnit } from "../types"; + +export interface MarketOrderRealtimeData { + market: string; + orderbook_units: OrderbookUnit[]; + timestamp: number; +} + +export const useMarketOrderRealtime = (market: string) => { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const supabase = createClient(); + + // 초기 데이터 로드 + const fetchInitialData = async () => { + try { + const { data: marketData, error: fetchError } = await supabase + .from("market_orders") + .select("*") + .eq("market", market) + .single(); + + if (fetchError) { + if (fetchError.code !== "PGRST116") { + // 데이터가 없는 경우가 아니라면 에러 처리 + throw fetchError; + } + } else if (marketData) { + setData({ + market: marketData.market, + orderbook_units: marketData.orderbook_units as OrderbookUnit[], + timestamp: marketData.timestamp, + }); + } + setIsLoading(false); + } catch (err) { + setError(err as Error); + setIsLoading(false); + } + }; + + fetchInitialData(); + + // Realtime 구독 설정 + const channel = supabase + .channel(`market_orders:${market}`) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "market_orders", + filter: `market=eq.${market}`, + }, + (payload) => { + if (payload.eventType === "INSERT" || payload.eventType === "UPDATE") { + const newData = payload.new as { + market: string; + orderbook_units: unknown; + timestamp: number; + }; + + setData({ + market: newData.market, + orderbook_units: newData.orderbook_units as OrderbookUnit[], + timestamp: newData.timestamp, + }); + } + }, + ) + .subscribe(); + + // Cleanup + return () => { + supabase.removeChannel(channel); + }; + }, [market]); + + return { data, isLoading, error }; +}; diff --git a/src/entities/market/model/index.ts b/src/entities/market/model/index.ts index 1063c8d..a1c1e7c 100644 --- a/src/entities/market/model/index.ts +++ b/src/entities/market/model/index.ts @@ -1,2 +1,3 @@ export * from "./apis"; export * from "./types"; +export * from "./hooks"; diff --git a/src/entities/market/model/types/index.ts b/src/entities/market/model/types/index.ts index 0406812..ece66b6 100644 --- a/src/entities/market/model/types/index.ts +++ b/src/entities/market/model/types/index.ts @@ -1 +1,2 @@ export * from "./market-info.type"; +export * from "./orderbook-units.type"; diff --git a/src/entities/market/model/types/orderbook-units.type.ts b/src/entities/market/model/types/orderbook-units.type.ts new file mode 100644 index 0000000..474b4f4 --- /dev/null +++ b/src/entities/market/model/types/orderbook-units.type.ts @@ -0,0 +1,6 @@ +export type OrderbookUnit = { + ask_price: number; + bid_price: number; + ask_size: number; + bid_size: number; +}; diff --git a/src/features/home/components/features/order/OrderTable.tsx b/src/features/home/components/features/order/OrderTable.tsx index 23a81dc..f6f3366 100644 --- a/src/features/home/components/features/order/OrderTable.tsx +++ b/src/features/home/components/features/order/OrderTable.tsx @@ -1,83 +1,115 @@ +"use client"; + +import { useEffect } from "react"; + +import { syncMarketOrderAPI, useMarketOrderRealtime } from "@/entities"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared"; +import { useMutation } from "@tanstack/react-query"; export const OrderTable = () => { + const market = "KRW-WAXP"; + const { data: marketOrder, isLoading, error } = useMarketOrderRealtime(market); + + const { mutate: syncData } = useMutation({ + mutationFn: () => syncMarketOrderAPI(market), + onError: (error) => { + console.error("Sync failed:", error); + }, + onSuccess: () => { + console.log("Sync successful"); + }, + }); + + // 주기적으로 Upbit 데이터를 Supabase에 동기화 + useEffect(() => { + // 즉시 실행 + syncData(); + + // 1초마다 동기화 + const interval = setInterval(() => { + syncData(); + }, 1000); + + return () => clearInterval(interval); + }, [market, syncData]); + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading || !marketOrder) { + return
Loading...
; + } + + // 호가 데이터를 매도/매수로 분리 (최대 15개씩) + const orderbookUnits = marketOrder.orderbook_units || []; + const askOrders = orderbookUnits.slice(0, 15).reverse(); // 매도 호가 + const bidOrders = orderbookUnits.slice(0, 15); // 매수 호가 + + // 최대 거래량 계산 (그래프 바 비율 계산용) + const maxBidSize = Math.max(...bidOrders.map((o) => o.bid_size), 0); + const maxAskSize = Math.max(...askOrders.map((o) => o.ask_size), 0); + const maxVolume = Math.max(maxBidSize, maxAskSize); + return ( - - + + 호가 - - 324,130.913 - 17.9 - - - - 324,130.913 - 17.9 - - - - 324,130.913 - 17.9 - - - - 324,130.913 - 17.9 - - - - 324,130.913 - 17.9 - - - - 324,130.913 - 17.9 - - - - - - 17.9 - 324,130.913 - + {/* 매도 호가 (Ask) - 파란색 */} + {askOrders.map((order, index) => { + const volumePercent = maxVolume > 0 ? (order.ask_size / maxVolume) * 100 : 0; + return ( + + + {/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */} +
+
+ {order.ask_size.toLocaleString()} +
+ + +
+ {order.ask_price.toLocaleString()} +
+
+ + + ); + })} - - - 17.9 - 324,130.913 - - - - 17.9 - 324,130.913 - - - - 17.9 - 324,130.913 - - - - 17.9 - 324,130.913 - - - - 17.9 - 324,130.913 - - - - 17.9 - 324,130.913 - + {/* 매수 호가 (Bid) - 빨간색 */} + {bidOrders.map((order, index) => { + const volumePercent = maxVolume > 0 ? (order.bid_size / maxVolume) * 100 : 0; + return ( + + + +
+ {order.bid_price.toLocaleString()} +
+
+ + {/* 그래프 바 배경 (오른쪽에서 왼쪽으로) */} +
+
+ {order.bid_size.toLocaleString()} +
+ + + ); + })}
); diff --git a/src/shared/types/database.type.ts b/src/shared/types/database.type.ts index 46b8fe6..8a94570 100644 --- a/src/shared/types/database.type.ts +++ b/src/shared/types/database.type.ts @@ -14,6 +14,92 @@ export type Database = { } public: { Tables: { + chart: { + Row: { + candle_timestamp: string + close: number + coin_id: string + high: number + id: number + low: number + open: number + volume: number + } + Insert: { + candle_timestamp?: string + close: number + coin_id: string + high: number + id?: number + low: number + open: number + volume: number + } + Update: { + candle_timestamp?: string + close?: number + coin_id?: string + high?: number + id?: number + low?: number + open?: number + volume?: number + } + Relationships: [ + { + foreignKeyName: "chart_coin_id_fkey" + columns: ["coin_id"] + isOneToOne: false + referencedRelation: "coins" + referencedColumns: ["market_id"] + }, + ] + } + coins: { + Row: { + english_name: string | null + korean_name: string + market_id: string + } + Insert: { + english_name?: string | null + korean_name: string + market_id: string + } + Update: { + english_name?: string | null + korean_name?: string + market_id?: string + } + Relationships: [] + } + market_orders: { + Row: { + created_at: string | null + id: number + market: string + orderbook_units: Json + timestamp: number + updated_at: string | null + } + Insert: { + created_at?: string | null + id?: number + market: string + orderbook_units: Json + timestamp: number + updated_at?: string | null + } + Update: { + created_at?: string | null + id?: number + market?: string + orderbook_units?: Json + timestamp?: number + updated_at?: string | null + } + Relationships: [] + } profiles: { Row: { avatar_url: string | null @@ -41,6 +127,122 @@ export type Database = { } Relationships: [] } + trade: { + Row: { + amount: number + coin_id: string + created_at: string + id: number + price: number + total_krw: number + trade_type: string + user_id: string + } + Insert: { + amount: number + coin_id: string + created_at: string + id?: number + price: number + total_krw: number + trade_type: string + user_id?: string + } + Update: { + amount?: number + coin_id?: string + created_at?: string + id?: number + price?: number + total_krw?: number + trade_type?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "trade_coin_id_fkey" + columns: ["coin_id"] + isOneToOne: false + referencedRelation: "coins" + referencedColumns: ["market_id"] + }, + { + foreignKeyName: "trade_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + transactions: { + Row: { + amount: number + created_at: string + id: number + transaction_type: string + user_id: string + } + Insert: { + amount: number + created_at?: string + id?: number + transaction_type: string + user_id?: string + } + Update: { + amount?: number + created_at?: string + id?: number + transaction_type?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "transactions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } + wallet: { + Row: { + amount: number + coin_id: string + id: number + user_id: string + } + Insert: { + amount: number + coin_id: string + id?: number + user_id?: string + } + Update: { + amount?: number + coin_id?: string + id?: number + user_id?: string + } + Relationships: [ + { + foreignKeyName: "wallet_coin_id_fkey" + columns: ["coin_id"] + isOneToOne: false + referencedRelation: "coins" + referencedColumns: ["market_id"] + }, + { + foreignKeyName: "wallet_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } } Views: { [_ in never]: never diff --git a/supabase/migrations/create_market_orders.sql b/supabase/migrations/create_market_orders.sql new file mode 100644 index 0000000..b3acd4d --- /dev/null +++ b/supabase/migrations/create_market_orders.sql @@ -0,0 +1,48 @@ +-- Create market_orders table for real-time orderbook data +CREATE TABLE IF NOT EXISTS market_orders ( + id BIGSERIAL PRIMARY KEY, + market VARCHAR(20) NOT NULL UNIQUE, + orderbook_units JSONB NOT NULL, + timestamp BIGINT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create index for faster market lookup +CREATE INDEX IF NOT EXISTS idx_market_orders_market ON market_orders(market); +CREATE INDEX IF NOT EXISTS idx_market_orders_timestamp ON market_orders(timestamp); + +-- Enable Row Level Security +ALTER TABLE market_orders ENABLE ROW LEVEL SECURITY; + +-- Create policy to allow all operations for authenticated users +CREATE POLICY "Allow all operations for authenticated users" + ON market_orders + FOR ALL + TO authenticated + USING (true) + WITH CHECK (true); + +-- Create policy to allow read access for anonymous users +CREATE POLICY "Allow read access for anonymous users" + ON market_orders + FOR SELECT + TO anon + USING (true); + +-- Enable Realtime +ALTER PUBLICATION supabase_realtime ADD TABLE market_orders; + +-- Create updated_at trigger +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_market_orders_updated_at + BEFORE UPDATE ON market_orders + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column();