Ver3.5 파이썬과 스트림릿으로NFT 데이터 분석 툴 만들기

파이썬과 스트림릿으로
NFT 데이터 분석 툴 만들기
맞음ㅋㅋ 오늘 우리가 만들 거 딱 이거야. 파이썬(Python)이랑 스트림릿(Streamlit)을 활용해서 NFT 데이터를 실시간으로 긁어오고, 시각화해서 보여주는 나만의 분석 툴 제작! 코딩 좀 해봤으면 충분히 따라올 수 있고, 완성하면 진짜 쓸만한 Web3 툴이 생기는 거임 😎
이 글에서는 OpenSea API, Alchemy API 등 실제 NFT 데이터를 제공하는 서비스들을 연동하는 방법부터, 스트림릿으로 대시보드 UI 구성하는 법, 그리고 실제 분석에 쓸 수 있는 지표들까지 전부 다룰 거야. 레츠고 🔥
NFT 데이터 분석 툴 만들 때 선택지가 꽤 많음. React로 프론트 짜고 Node.js 백엔드 붙이는 방법도 있고, 그냥 엑셀로 정리하는 사람도 있고ㅋㅋ 근데 파이썬 + 스트림릿 조합이 왜 갑인지 설명해줄게.
2. 스트림릿은 진짜 마법 — HTML/CSS/JS 하나도 몰라도 파이썬 코드 몇 줄로 웹 대시보드가 뚝딱 생김. 진짜임ㅋㅋ
3. Web3 라이브러리 풍부 — web3.py, eth-brownie 등 이더리움 연동 라이브러리가 잘 갖춰져 있음.
4. 배포 쉬움 — Streamlit Cloud에 GitHub 연동하면 무료로 배포 가능. 진입장벽 거의 없음.
스트림릿(Streamlit)은 2019년에 나온 오픈소스 파이썬 프레임워크인데, 데이터 과학자들이 빠르게 웹 앱을 만들 수 있도록 설계됐어. 2022년에 Snowflake가 8억 달러에 인수할 정도로 가치를 인정받은 툴임. 현재 GitHub 스타 수가 35,000개 이상이고, 매달 수백만 명이 사용하는 검증된 프레임워크야 💪
| 비교 항목 | 파이썬+스트림릿 | React+Node.js | Dune Analytics |
|---|---|---|---|
| 개발 속도 | ⚡ 매우 빠름 | 🐢 느림 | ✅ 빠름(단, 제한적) |
| 커스터마이징 | ✅ 높음 | ✅ 매우 높음 | ❌ 낮음 |
| 데이터 처리 | ✅ 최강 | 🔶 보통 | ✅ SQL 기반 |
| 진입장벽 | ✅ 낮음 | ❌ 높음 | ✅ 낮음 |
| 무료 배포 | ✅ 가능 | 🔶 제한적 | ✅ 가능 |
자 이제 본격적으로 세팅 들어가자. 파이썬 3.9 이상 버전이 설치되어 있다고 가정하고 진행할게. 없으면 python.org에서 최신 버전 받아와 😄
프로젝트마다 가상환경 쓰는 거 습관화하자. 패키지 충돌 방지에 필수임.
# 가상환경 생성
python -m venv nft-analyzer
# 활성화 (Windows)
nft-analyzer\Scripts\activate
# 활성화 (Mac/Linux)
source nft-analyzer/bin/activate
requirements.txt 만들어서 한 번에 설치하는 게 깔끔함.
# requirements.txt 내용
streamlit==1.28.0
pandas==2.1.0
numpy==1.24.0
requests==2.31.0
plotly==5.17.0
web3==6.11.0
python-dotenv==1.0.0
aiohttp==3.8.6
httpx==0.25.0
pillow==10.0.0
# 설치 명령어
pip install -r requirements.txt
처음부터 구조 잘 잡아야 나중에 안 고생함ㅋㅋ
nft-analyzer/
├── app.py # 메인 스트림릿 앱
├── .env # API 키 (절대 깃허브에 올리면 안됨!!)
├── .gitignore
├── requirements.txt
├── config/
│ └── settings.py # 설정값 관리
├── api/
│ ├── opensea.py # OpenSea API 연동
│ ├── alchemy.py # Alchemy API 연동
│ └── coingecko.py # 가격 데이터
├── analysis/
│ ├── floor_price.py # 바닥가 분석
│ ├── volume.py # 거래량 분석
│ └── holders.py # 홀더 분석
└── utils/
├── helpers.py # 유틸 함수들
└── cache.py # 캐싱 로직
# .env 파일 예시
OPENSEA_API_KEY=your_opensea_api_key_here
ALCHEMY_API_KEY=your_alchemy_api_key_here
ETHERSCAN_API_KEY=your_etherscan_api_key_here
COINGECKO_API_KEY=your_coingecko_api_key_here
# .gitignore에 추가
.env
__pycache__/
*.pyc
.streamlit/secrets.toml
NFT 데이터를 가져오는 방법은 여러 가지인데, 주요 API들 특징이랑 사용법 알려줄게. 각각 장단점이 있으니까 용도에 맞게 골라 써야 함 🎯
이제 각 API 연동 코드 작성해보자. 먼저 OpenSea API부터 시작할게. OpenSea는 NFT 마켓플레이스 1위라서 데이터가 제일 풍부함 👑
# api/opensea.py
import requests
import os
from dotenv import load_dotenv
import pandas as pd
import time
load_dotenv()
class OpenSeaAPI:
def __init__(self):
self.api_key = os.getenv("OPENSEA_API_KEY")
self.base_url = "https://api.opensea.io/api/v2"
self.headers = {
"accept": "application/json",
"x-api-key": self.api_key
}
def get_collection(self, collection_slug: str) -> dict:
"""컬렉션 기본 정보 가져오기"""
url = f"{self.base_url}/collections/{collection_slug}"
response = requests.get(url, headers=self.headers)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"API 오류: {response.status_code} - {response.text}")
def get_collection_stats(self, collection_slug: str) -> dict:
"""컬렉션 통계 데이터 (바닥가, 거래량 등)"""
url = f"{self.base_url}/collections/{collection_slug}/stats"
response = requests.get(url, headers=self.headers)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"통계 조회 실패: {response.status_code}")
def get_collection_events(
self,
collection_slug: str,
event_type: str = "sale",
limit: int = 50
) -> pd.DataFrame:
"""
컬렉션 이벤트 히스토리 (판매, 전송, 리스팅 등)
event_type: 'sale', 'transfer', 'listing', 'offer', 'cancel'
"""
url = f"{self.base_url}/events/collection/{collection_slug}"
params = {
"event_type": event_type,
"limit": limit
}
all_events = []
next_cursor = None
while len(all_events) < limit:
if next_cursor:
params["next"] = next_cursor
response = requests.get(url, headers=self.headers, params=params)
if response.status_code == 200:
data = response.json()
events = data.get("asset_events", [])
all_events.extend(events)
next_cursor = data.get("next")
if not next_cursor or not events:
break
time.sleep(0.25) # Rate limit 방지
else:
break
return self._events_to_dataframe(all_events)
def _events_to_dataframe(self, events: list) -> pd.DataFrame:
"""이벤트 데이터를 pandas DataFrame으로 변환"""
if not events:
return pd.DataFrame()
rows = []
for event in events:
row = {
"event_type": event.get("event_type"),
"timestamp": event.get("closing_date") or event.get("event_timestamp"),
"token_id": event.get("nft", {}).get("identifier"),
"price_eth": self._wei_to_eth(
event.get("payment", {}).get("quantity", 0)
),
"seller": event.get("seller"),
"buyer": event.get("buyer"),
"transaction": event.get("transaction")
}
rows.append(row)
df = pd.DataFrame(rows)
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="s", errors="coerce")
return df
def _wei_to_eth(self, wei_value) -> float:
"""Wei를 ETH로 변환"""
try:
return int(wei_value) / 1e18
except (ValueError, TypeError):
return 0.0
이제 Alchemy API 연동 코드도 작성해보자. Alchemy는 특히 특정 지갑이 보유한 NFT 목록 조회할 때 진짜 편함 🔥
# api/alchemy.py
import requests
import os
from dotenv import load_dotenv
import pandas as pd
load_dotenv()
class AlchemyAPI:
def __init__(self, network: str = "eth-mainnet"):
self.api_key = os.getenv("ALCHEMY_API_KEY")
self.network = network
self.base_url = f"https://{network}.g.alchemy.com/nft/v3/{self.api_key}"
def get_nfts_for_owner(self, owner_address: str) -> pd.DataFrame:
"""특정 지갑 주소가 보유한 NFT 전체 목록"""
url = f"{self.base_url}/getNFTsForOwner"
params = {
"owner": owner_address,
"withMetadata": "true",
"pageSize": 100
}
all_nfts = []
page_key = None
while True:
if page_key:
params["pageKey"] = page_key
response = requests.get(url, params=params)
if response.status_code == 200:
data = response.json()
nfts = data.get("ownedNfts", [])
all_nfts.extend(nfts)
page_key = data.get("pageKey")
if not page_key:
break
else:
break
return self._nfts_to_dataframe(all_nfts)
def get_owners_for_contract(self, contract_address: str) -> dict:
"""특정 NFT 컨트랙트의 모든 홀더 조회"""
url = f"{self.base_url}/getOwnersForContract"
params = {
"contractAddress": contract_address,
"withTokenBalances": "true"
}
response = requests.get(url, params=params)
if response.status_code == 200:
return response.json()
else:
raise Exception(f"홀더 조회 실패: {response.status_code}")
def get_nft_metadata(self, contract_address: str, token_id: str) -> dict:
"""특정 NFT의 메타데이터 조회"""
url = f"{self.base_url}/getNFTMetadata"
params = {
"contractAddress": contract_address,
"tokenId": token_id,
"refreshCache": "false"
}
response = requests.get(url, params=params)
if response.status_code == 200:
return response.json()
else:
return {}
def _nfts_to_dataframe(self, nfts: list) -> pd.DataFrame:
"""NFT 목록을 DataFrame으로 변환"""
if not nfts:
return pd.DataFrame()
rows = []
for nft in nfts:
contract = nft.get("contract", {})
row = {
"contract_address": contract.get("address"),
"collection_name": contract.get("name"),
"token_id": nft.get("tokenId"),
"token_type": contract.get("tokenType"),
"name": nft.get("name"),
"description": nft.get("description"),
"floor_price_eth": contract.get("openSeaMetadata", {}).get("floorPrice"),
"image_url": nft.get("image", {}).get("cachedUrl"),
"acquired_at": nft.get("acquiredAt", {}).get("blockTimestamp")
}
rows.append(row)
return pd.DataFrame(rows)
API로 데이터 가져왔으면 이제 분석 로직 짜야지! NFT 분석에서 제일 중요한 지표들 위주로 만들어볼게 📈
② 거래량(Volume) — 일정 기간 내 총 거래 금액. 시장 활성도 지표.
③ 홀더 분포(Holder Distribution) — 소수가 대부분 보유하면 고래 리스크 높음.
④ 리스팅 비율(Listing Rate) — 전체 공급량 대비 판매 등록 비율. 낮을수록 희소성 높음.
⑤ 희귀도(Rarity Score) — 각 NFT의 특성 조합 희귀도. 가격 프리미엄 결정.
# analysis/floor_price.py
import pandas as pd
import numpy as np
from typing import List, Dict
class FloorPriceAnalyzer:
def analyze_price_trend(self, sales_df: pd.DataFrame) -> Dict:
"""
판매 이벤트 데이터로 가격 트렌드 분석
Returns:
dict: 분석 결과 (이동평균, 변동성, 추세 등)
"""
if sales_df.empty:
return {}
# 날짜별 최저가 (바닥가 근사치)
daily_floor = (
sales_df
.groupby(sales_df["timestamp"].dt.date)["price_eth"]
.min()
.reset_index()
)
daily_floor.columns = ["date", "floor_price"]
daily_floor = daily_floor.sort_values("date")
# 7일 이동평균
daily_floor["ma7"] = (
daily_floor["floor_price"]
.rolling(window=7, min_periods=1)
.mean()
)
# 30일 이동평균
daily_floor["ma30"] = (
daily_floor["floor_price"]
.rolling(window=30, min_periods=1)
.mean()
)
# 변동성 (표준편차 / 평균)
volatility = (
daily_floor["floor_price"].std() /
daily_floor["floor_price"].mean()
) * 100
# 추세 판단 (최근 7일 vs 이전 7일)
recent_7d = daily_floor.tail(7)["floor_price"].mean()
prev_7d = daily_floor.iloc[-14:-7]["floor_price"].mean() if len(daily_floor) >= 14 else None
trend = "데이터 부족"
trend_pct = 0
if prev_7d and prev_7d > 0:
trend_pct = ((recent_7d - prev_7d) / prev_7d) * 100
if trend_pct > 5:
trend = "📈 상승"
elif trend_pct < -5:
trend = "📉 하락"
else:
trend = "➡️ 횡보"
return {
"daily_data": daily_floor,
"current_floor": daily_floor["floor_price"].iloc[-1] if not daily_floor.empty else 0,
"avg_floor_30d": daily_floor.tail(30)["floor_price"].mean(),
"max_floor": daily_floor["floor_price"].max(),
"min_floor": daily_floor["floor_price"].min(),
"volatility_pct": round(volatility, 2),
"trend": trend,
"trend_pct": round(trend_pct, 2)
}
def calculate_support_resistance(self, prices: pd.Series) -> Dict:
"""지지선/저항선 계산 (단순 분위수 기반)"""
return {
"support_1": prices.quantile(0.1),
"support_2": prices.quantile(0.25),
"resistance_1": prices.quantile(0.75),
"resistance_2": prices.quantile(0.9),
"median": prices.median()
}
# analysis/holders.py
import pandas as pd
import numpy as np
from typing import Dict
class HolderAnalyzer:
def analyze_distribution(self, owners_data: dict) -> Dict:
"""
홀더 분포 분석 - 고래 리스크 평가
"""
owners = owners_data.get("owners", [])
if not owners:
return {}
# 각 홀더의 보유량 추출
holdings = []
for owner in owners:
balances = owner.get("tokenBalances", [])
total_held = sum(int(b.get("balance", 0)) for b in balances)
holdings.append({
"address": owner.get("ownerAddress"),
"count": total_held
})
df = pd.DataFrame(holdings)
total_supply = df["count"].sum()
# 상위 홀더 비중
df_sorted = df.sort_values("count", ascending=False)
top10_count = df_sorted.head(10)["count"].sum()
top50_count = df_sorted.head(50)["count"].sum()
top100_count = df_sorted.head(100)["count"].sum()
# 지니 계수 계산 (불평등 지수, 0=완전평등, 1=완전불평등)
gini = self._calculate_gini(df["count"].values)
# 고래 리스크 평가
whale_risk = self._assess_whale_risk(
top10_pct=(top10_count / total_supply) * 100,
gini=gini
)
# 분포 구간별 홀더 수
distribution = {
"1개 보유": len(df[df["count"] == 1]),
"2-5개 보유": len(df[(df["count"] >= 2) & (df["count"] <= 5)]),
"6-10개 보유": len(df[(df["count"] >= 6) & (df["count"] <= 10)]),
"11-50개 보유": len(df[(df["count"] >= 11) & (df["count"] <= 50)]),
"51개 이상 보유": len(df[df["count"] > 50])
}
return {
"total_holders": len(df),
"total_supply": total_supply,
"avg_holding": round(df["count"].mean(), 2),
"median_holding": df["count"].median(),
"top10_pct": round((top10_count / total_supply) * 100, 2),
"top50_pct": round((top50_count / total_supply) * 100, 2),
"top100_pct": round((top100_count / total_supply) * 100, 2),
"gini_coefficient": round(gini, 4),
"whale_risk": whale_risk,
"distribution": distribution,
"top_holders": df_sorted.head(20).to_dict("records")
}
def _calculate_gini(self, values: np.ndarray) -> float:
"""지니 계수 계산"""
values = np.sort(values)
n = len(values)
if n == 0 or values.sum() == 0:
return 0
cumsum = np.cumsum(values)
return (2 * np.sum((np.arange(1, n+1)) * values) /
(n * cumsum[-1])) - (n + 1) / n
def _assess_whale_risk(self, top10_pct: float, gini: float) -> str:
"""고래 리스크 레벨 평가"""
if top10_pct > 50 or gini > 0.8:
return "🔴 매우 높음 - 고래 집중도 위험"
elif top10_pct > 35 or gini > 0.65:
return "🟠 높음 - 주의 필요"
elif top10_pct > 20 or gini > 0.5:
return "🟡 보통 - 일반적 수준"
else:
return "🟢 낮음 - 분산 양호"
드디어 제일 재밌는 파트임ㅋㅋ 스트림릿으로 실제 대시보드 UI 만들어볼 거야. 스트림릿의 진짜 매력은 파이썬 코드 그대로 쓰면서 웹 UI가 자동으로 생긴다는 거임 🤩
# app.py - 메인 스트림릿 앱
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from datetime import datetime, timedelta
import time
from api.opensea import OpenSeaAPI
from api.alchemy import AlchemyAPI
from analysis.floor_price import FloorPriceAnalyzer
from analysis.holders import HolderAnalyzer
# ===== 페이지 설정 =====
st.set_page_config(
page_title="NFT 데이터 분석 툴",
page_icon="🔮",
layout="wide",
initial_sidebar_state="expanded"
)
# ===== 커스텀 CSS =====
st.markdown("""
<style>.tx-header{background:linear-gradient(135deg,#1a1a2e 0%,#16213e 50%,#0f3460 100%);
border-radius:18px;
padding:40px 30px;
text-align:center;
margin-bottom:32px;}
.tx-header-sub{color:#a8b2d8;
font-size:1rem;
margin-top:8px;
letter-spacing:2px;}
.tx-badge{display:inline-block;
background:rgba(100,200,255,0.15);
color:#64c8ff;
border:1px solid rgba(100,200,255,0.3);
border-radius:20px;
padding:4px 14px;
font-size:0.8rem;
margin-bottom:16px;}
.tx-section-title{font-size:1.4rem;
font-weight:700;
color:#1a1a2e;
border-left:4px solid #6c63ff;
padding-left:12px;
margin:36px 0 16px 0;}
.tx-highlight{background:linear-gradient(90deg,#e8f4fd,#f0e8ff);
border-radius:10px;
padding:16px 20px;
margin:18px 0;}
.tx-tip{background:#f0fdf4;
border-left:4px solid #4ade80;
border-radius:0 10px 10px 0;
padding:14px 18px;
margin:16px 0;}
.tx-warn{background:#fff7ed;
border-left:4px solid #fb923c;
border-radius:0 10px 10px 0;
padding:14px 18px;
margin:16px 0;}
.tx-code-wrap pre{margin:0;
color:#cdd6f4;
font-size:0.88rem;
line-height:1.7;}
.tx-step{display:flex;
align-items:flex-start;
gap:14px;
margin:14px 0;}
.tx-step-num{min-width:32px;
height:32px;
background:linear-gradient(135deg,#6c63ff,#a78bfa);
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
color:#fff;
font-weight:700;
font-size:0.9rem;}
.tx-step-content{flex:1;}
.tx-divider{border:none;
border-top:1px solid #e8eaf0;
margin:32px 0;}
.tx-table{width:100%;
border-collapse:collapse;
margin:16px 0;
font-size:0.9rem;}
.tx-table th{background:#f3f0ff;
color:#6c63ff;
padding:10px 12px;
text-align:left;
border-bottom:2px solid #d8d0ff;}
.tx-table td{padding:10px 12px;
border-bottom:1px solid #f0eeff;
color:#444;}
.tx-table tr:hover td{background:#faf8ff;}
.tx-chat{background:#f8f9ff;
border-radius:14px;
padding:16px 20px;
margin:16px 0;
font-size:0.95rem;
line-height:1.8;
color:#333;}
.tx-chat-bubble{display:inline-block;
background:#6c63ff;
color:#fff;
border-radius:18px 18px 18px 4px;
padding:8px 16px;
margin:4px 0;
font-size:0.9rem;}
.tx-chat-bubble-right{display:inline-block;
background:#e8f4fd;
color:#1a1a2e;
border-radius:18px 18px 4px 18px;
padding:8px 16px;
margin:4px 0;
font-size:0.9rem;}
.tx-flow{display:flex;
flex-wrap:wrap;
gap:10px;
margin:18px 0;
align-items:center;}
.tx-flow-item{background:#f3f0ff;
border:1px solid #d8d0ff;
border-radius:8px;
padding:8px 14px;
font-size:0.85rem;
color:#6c63ff;
font-weight:600;}
.tx-flow-arrow{color:#a78bfa;
font-size:1.2rem;
font-weight:700;}
.tx-result-box{background:linear-gradient(135deg,#f8f9ff,#f0fdf4);
border:1px solid #d8d0ff;
border-radius:12px;
padding:18px 22px;
margin:18px 0;}
.tx-emoji-title{font-size:1.1rem;
font-weight:700;
color:#6c63ff;
margin-bottom:8px;}</style>
""", unsafe_allow_html=True)
# ===== 헤더 =====
st.markdown("""
<div class="main-header">
<h1 style="color: #e8f0ff; margin: 0;">🔮 NFT 데이터 분석 대시보드</h1>
<p style="color: #a8b2d8; margin: 8px 0 0 0;">
실시간 NFT 시장 데이터 분석 · Python + Streamlit
</p>
</div>
""", unsafe_allow_html=True)
# ===== 사이드바 =====
with st.sidebar:
st.markdown("### ⚙️ 분석 설정")
collection_slug = st.text_input(
"컬렉션 슬러그 입력",
value="boredapeyachtclub",
help="OpenSea URL에서 컬렉션 이름 부분 (예: boredapeyachtclub)"
)
analysis_period = st.selectbox(
"분석 기간",
options=["7일", "30일", "90일"],
index=1
)
show_whale_analysis = st.checkbox("고래 분석 포함", value=True)
show_rarity = st.checkbox("희귀도 분석 포함", value=False)
analyze_btn = st.button("🚀 분석 시작", type="primary", use_container_width=True)
st.divider()
st.markdown("### 📌 인기 컬렉션")
popular = {
"BAYC": "boredapeyachtclub",
"CryptoPunks": "cryptopunks",
"Azuki": "azuki",
"Doodles": "doodles-official",
"CloneX": "clonex"
}
for name, slug in popular.items():
if st.button(name, use_container_width=True):
collection_slug = slug
# ===== 메인 분석 로직 =====
if analyze_btn or "analysis_data" in st.session_state:
opensea = OpenSeaAPI()
alchemy = AlchemyAPI()
floor_analyzer = FloorPriceAnalyzer()
holder_analyzer = HolderAnalyzer()
# 데이터 로딩 (캐싱 활용)
@st.cache_data(ttl=300) # 5분 캐시
def load_collection_data(slug: str):
with st.spinner("📡 컬렉션 데이터 로딩 중..."):
collection = opensea.get_collection(slug)
stats = opensea.get_collection_stats(slug)
events = opensea.get_collection_events(slug, "sale", limit=200)
return collection, stats, events
try:
collection, stats, events_df = load_collection_data(collection_slug)
# ===== 컬렉션 기본 정보 =====
col_info = collection
st.markdown(f"## 📋 {col_info.get('name', collection_slug)}")
# 핵심 지표 카드 (4열)
col1, col2, col3, col4 = st.columns(4)
total_stats = stats.get("total", {})
with col1:
floor = total_stats.get("floor_price", 0)
st.metric(
label="🏷️ 바닥가",
value=f"{floor:.3f} ETH",
delta=None
)
with col2:
volume = total_stats.get("volume", 0)
st.metric(
label="📊 총 거래량",
value=f"{volume:,.1f} ETH"
)
with col3:
sales = total_stats.get("num_sales", 0)
st.metric(
label="🔄 총 거래 수",
value=f"{sales:,}건"
)
with col4:
owners = total_stats.get("num_owners", 0)
st.metric(
label="👥 홀더 수",
value=f"{owners:,}명"
)
st.divider()
# ===== 가격 추이 차트 =====
st.markdown("### 📈 가격 추이 분석")
if not events_df.empty:
price_analysis = floor_analyzer.analyze_price_trend(events_df)
if price_analysis and "daily_data" in price_analysis:
daily_df = price_analysis["daily_data"]
# Plotly 차트 생성
fig = go.Figure()
fig.add_trace(go.Scatter(
x=daily_df["date"],
y=daily_df["floor_price"],
mode="lines+markers",
name="일별 최저가",
line=dict(color="#6c63ff", width=2),
marker=dict(size=4)
))
fig.add_trace(go.Scatter(
x=daily_df["date"],
y=daily_df["ma7"],
mode="lines",
name="7일 이동평균",
line=dict(color="#f59e0b", width=2, dash="dash")
))
fig.add_trace(go.Scatter(
x=daily_df["date"],
y=daily_df["ma30"],
mode="lines",
name="30일 이동평균",
line=dict(color="#10b981", width=2, dash="dot")
))
fig.update_layout(
title="바닥가 추이 (ETH)",
xaxis_title="날짜",
yaxis_title="가격 (ETH)",
hovermode="x unified",
plot_bgcolor="rgba(0,0,0,0)",
paper_bgcolor="rgba(0,0,0,0)",
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1
)
)
st.plotly_chart(fig, use_container_width=True)
# 분석 요약
col_a, col_b, col_c = st.columns(3)
with col_a:
st.info(f"**추세**: {price_analysis.get('trend', 'N/A')}")
with col_b:
st.info(f"**변동성**: {price_analysis.get('volatility_pct', 0):.1f}%")
with col_c:
trend_pct = price_analysis.get('trend_pct', 0)
delta_str = f"+{trend_pct:.1f}%" if trend_pct >= 0 else f"{trend_pct:.1f}%"
st.info(f"**7일 변화**: {delta_str}")
# ===== 거래량 분석 =====
st.markdown("### 📊 거래량 분석")
if not events_df.empty:
# 일별 거래량
daily_volume = (
events_df
.groupby(events_df["timestamp"].dt.date)
.agg(
volume=("price_eth", "sum"),
count=("price_eth", "count"),
avg_price=("price_eth", "mean")
)
.reset_index()
)
fig_vol = px.bar(
daily_volume,
x="timestamp",
y="volume",
title="일별 거래량 (ETH)",
color="volume",
color_continuous_scale="Purples"
)
fig_vol.update_layout(
plot_bgcolor="rgba(0,0,0,0)",
paper_bgcolor="rgba(0,0,0,0)"
)
st.plotly_chart(fig_vol, use_container_width=True)
except Exception as e:
st.error(f"❌ 데이터 로딩 실패: {str(e)}")
st.info("💡 API 키 설정을 확인하거나 컬렉션 슬러그를 다시 확인해주세요.")
@st.cache_data(ttl=300) 데코레이터 쓰면 API 호출 결과를 5분간 캐싱해줌. API 요청 횟수 줄이고 속도도 빨라짐. 진짜 필수임ㅋㅋ
NFT 시장에서 고래(Whale) 추적은 진짜 중요한 알파임 🐋 대형 홀더들이 언제 팔고 사는지 알면 시장 방향성 예측에 도움이 됨. 물론 100% 맞는 건 아니지만 참고 지표로 쓸 수 있어.
# analysis/whale_tracker.py
import pandas as pd
import numpy as np
from typing import List, Dict
from api.alchemy import AlchemyAPI
from api.opensea import OpenSeaAPI
class WhaleTracker:
def __init__(self):
self.alchemy = AlchemyAPI()
self.opensea = OpenSeaAPI()
# 알려진 고래/기관 지갑 주소 (예시)
self.known_whales = {
"0x...": "유명 컬렉터 A",
"0x...": "NFT 펀드 B",
# 실제 주소로 교체 필요
}
def identify_whales(
self,
holders_df: pd.DataFrame,
threshold_pct: float = 1.0
) -> pd.DataFrame:
"""
전체 공급량의 threshold_pct% 이상 보유한 지갑을 고래로 분류
"""
total_supply = holders_df["count"].sum()
threshold = total_supply * (threshold_pct / 100)
whales = holders_df[holders_df["count"] >= threshold].copy()
whales["holding_pct"] = (whales["count"] / total_supply * 100).round(2)
whales["label"] = whales["address"].map(
self.known_whales
).fillna("미확인 고래")
return whales.sort_values("count", ascending=False)
def analyze_whale_activity(
self,
events_df: pd.DataFrame,
whale_addresses: List[str]
) -> Dict:
"""
고래들의 최근 거래 활동 분석
"""
if events_df.empty:
return {}
# 고래가 판매자인 경우
whale_sells = events_df[
events_df["seller"].isin(whale_addresses)
].copy()
# 고래가 구매자인 경우
whale_buys = events_df[
events_df["buyer"].isin(whale_addresses)
].copy()
# 최근 7일 활동
cutoff = pd.Timestamp.now() - pd.Timedelta(days=7)
recent_sells = whale_sells[whale_sells["timestamp"] >= cutoff]
recent_buys = whale_buys[whale_buys["timestamp"] >= cutoff]
# 고래 순매수/순매도 판단
buy_volume = recent_buys["price_eth"].sum()
sell_volume = recent_sells["price_eth"].sum()
net_flow = buy_volume - sell_volume
sentiment = "🐋 고래 순매수 (강세 신호)" if net_flow > 0 else "🐋 고래 순매도 (약세 신호)"
return {
"total_whale_sells_7d": len(recent_sells),
"total_whale_buys_7d": len(recent_buys),
"whale_sell_volume_7d": round(sell_volume, 3),
"whale_buy_volume_7d": round(buy_volume, 3),
"net_flow_7d": round(net_flow, 3),
"whale_sentiment": sentiment,
"recent_whale_sells": whale_sells.head(10),
"recent_whale_buys": whale_buys.head(10)
}
def get_wallet_portfolio(self, wallet_address: str) -> Dict:
"""특정 지갑의 NFT 포트폴리오 분석"""
nfts_df = self.alchemy.get_nfts_for_owner(wallet_address)
if nfts_df.empty:
return {"total_nfts": 0, "collections": {}}
# 컬렉션별 보유 현황
collection_summary = (
nfts_df
.groupby("collection_name")
.agg(
count=("token_id", "count"),
avg_floor=("floor_price_eth", "mean")
)
.reset_index()
)
# 추정 포트폴리오 가치
nfts_df["estimated_value"] = nfts_df["floor_price_eth"].fillna(0)
total_value = nfts_df["estimated_value"].sum()
return {
"total_nfts": len(nfts_df),
"total_collections": nfts_df["collection_name"].nunique(),
"estimated_value_eth": round(total_value, 4),
"collection_summary": collection_summary,
"nfts": nfts_df
}
NFT 희귀도 계산은 가격 프리미엄에 직결되는 중요한 분석임. 희귀한 특성을 가진 NFT일수록 더 비싸게 거래되는 경향이 있거든. 대표적인 희귀도 계산 방식 두 가지 알려줄게 🎲
각 특성의 출현 빈도를 곱해서 계산. 직관적이지만 특성 수가 다른 NFT 비교에 불리함.
공식:
Rarity = Π (특성 출현 횟수 / 전체 공급량)② Rarity Score (희귀도 점수)
OpenRarity, Rarity.tools 등에서 사용하는 방식. 각 특성의 역빈도 합산.
공식:
Score = Σ (전체 공급량 / 해당 특성 보유 NFT 수)
# analysis/rarity.py
import pandas as pd
import numpy as np
from typing import List, Dict
class RarityCalculator:
def __init__(self, total_supply: int):
self.total_supply = total_supply
def calculate_rarity_scores(
self,
nfts_metadata: List[Dict]
) -> pd.DataFrame:
"""
NFT 컬렉션 전체의 희귀도 점수 계산
Args:
nfts_metadata: 각 NFT의 메타데이터 리스트
[{"token_id": "1", "attributes": [{"trait_type": "Background", "value": "Blue"}, ...]}, ...]
Returns:
DataFrame: token_id, rarity_score, rarity_rank 포함
"""
# 특성별 출현 빈도 계산
trait_counts = {}
for nft in nfts_metadata:
attributes = nft.get("attributes", [])
for attr in attributes:
trait_type = attr.get("trait_type", "Unknown")
value = str(attr.get("value", "None"))
key = f"{trait_type}:{value}"
trait_counts[key] = trait_counts.get(key, 0) + 1
# 각 NFT의 희귀도 점수 계산
results = []
for nft in nfts_metadata:
token_id = nft.get("token_id") or nft.get("identifier")
attributes = nft.get("attributes", [])
# Rarity Score 방식
rarity_score = 0
trait_scores = []
for attr in attributes:
trait_type = attr.get("trait_type", "Unknown")
value = str(attr.get("value", "None"))
key = f"{trait_type}:{value}"
count = trait_counts.get(key, 1)
score = self.total_supply / count
rarity_score += score
trait_scores.append({
"trait_type": trait_type,
"value": value,
"count": count,
"frequency_pct": round((count / self.total_supply) * 100, 2),
"score": round(score, 2)
})
# 특성 없는 경우 패널티 (Missing Trait)
# 특성 종류 수 파악
all_trait_types = set(
attr.get("trait_type")
for nft_data in nfts_metadata
for attr in nft_data.get("attributes", [])
)
nft_trait_types = set(
attr.get("trait_type")
for attr in attributes
)
missing_traits = all_trait_types - nft_trait_types
# 없는 특성에 대한 점수 추가 (OpenRarity 방식)
for missing in missing_traits:
missing_key = f"{missing}:None"
missing_count = trait_counts.get(missing_key, 0)
if missing_count > 0:
rarity_score += self.total_supply / missing_count
results.append({
"token_id": token_id,
"rarity_score": round(rarity_score, 2),
"trait_count": len(attributes),
"trait_details": trait_scores
})
# DataFrame 생성 및 랭킹 부여
df = pd.DataFrame(results)
df = df.sort_values("rarity_score", ascending=False)
df["rarity_rank"] = range(1, len(df) + 1)
# 희귀도 등급 분류
df["rarity_tier"] = pd.cut(
df["rarity_rank"],
bins=[0,
len(df)*0.01,
len(df)*0.05,
len(df)*0.15,
len(df)*0.35,
len(df)],
labels=["💎 Legendary", "🔮 Epic", "🌟 Rare", "✨ Uncommon", "⚪ Common"]
)
return df
def get_collection_trait_stats(
self,
nfts_metadata: List[Dict]
) -> pd.DataFrame:
"""컬렉션 전체 특성 통계"""
rows = []
for nft in nfts_metadata:
for attr in nft.get("attributes", []):
rows.append({
"trait_type": attr.get("trait_type"),
"value": str(attr.get("value")),
"token_id": nft.get("token_id")
})
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows)
stats = (
df.groupby(["trait_type", "value"])
.agg(count=("token_id", "count"))
.reset_index()
)
stats["frequency_pct"] = (
stats["count"] / self.total_supply * 100
).round(2)
stats["rarity_score"] = (
self.total_supply / stats["count"]
).round(2)
return stats.sort_values(
["trait_type", "count"],
ascending=[True, True]
)
API 호출이 많아지면 속도가 느려지고 요금도 나올 수 있음. 캐싱 전략 잘 짜야 해 💨
# utils/cache.py
import streamlit as st
import pandas as pd
import json
import hashlib
import os
from datetime import datetime, timedelta
from typing import Any, Optional
class CacheManager:
"""
다층 캐싱 전략:
1. Streamlit 세션 캐시 (가장 빠름, 세션 내 유지)
2. 파일 캐시 (디스크, 앱 재시작 후에도 유지)
3. API 응답 캐시 (TTL 기반)
"""
def __init__(self, cache_dir: str = ".cache"):
self.cache_dir = cache_dir
os.makedirs(cache_dir, exist_ok=True)
def _get_cache_key(self, *args) -> str:
"""캐시 키 생성"""
key_str = "_".join(str(a) for a in args)
return hashlib.md5(key_str.encode()).hexdigest()
def _get_cache_path(self, key: str) -> str:
return os.path.join(self.cache_dir, f"{key}.json")
def get(self, key: str, ttl_minutes: int = 30) -> Optional[Any]:
"""캐시에서 데이터 가져오기"""
cache_path = self._get_cache_path(key)
if not os.path.exists(cache_path):
return None
with open(cache_path, "r") as f:
cached = json.load(f)
# TTL 체크
cached_at = datetime.fromisoformat(cached["cached_at"])
if datetime.now() - cached_at > timedelta(minutes=ttl_minutes):
os.remove(cache_path)
return None
return cached["data"]
def set(self, key: str, data: Any) -> None:
"""캐시에 데이터 저장"""
cache_path = self._get_cache_path(key)
# DataFrame은 JSON 직렬화 처리
if isinstance(data, pd.DataFrame):
serializable_data = data.to_dict("records")
else:
serializable_data = data
with open(cache_path, "w") as f:
json.dump({
"cached_at": datetime.now().isoformat(),
"data": serializable_data
}, f, default=str)
def clear_expired(self, ttl_minutes: int = 60) -> int:
"""만료된 캐시 정리"""
cleared = 0
for filename in os.listdir(self.cache_dir):
if filename.endswith(".json"):
filepath = os.path.join(self.cache_dir, filename)
try:
with open(filepath, "r") as f:
cached = json.load(f)
cached_at = datetime.fromisoformat(cached["cached_at"])
if datetime.now() - cached_at > timedelta(minutes=ttl_minutes):
os.remove(filepath)
cleared += 1
except Exception:
pass
return cleared
# 스트림릿 캐시 데코레이터 활용 예시
@st.cache_data(ttl=300, show_spinner="데이터 로딩 중...")
def fetch_collection_data(collection_slug: str, period_days: int):
"""
컬렉션 데이터 캐싱 (5분 TTL)
같은 파라미터로 5분 내 재호출 시 캐시 반환
"""
from api.opensea import OpenSeaAPI
opensea = OpenSeaAPI()
collection = opensea.get_collection(collection_slug)
stats = opensea.get_collection_stats(collection_slug)
events = opensea.get_collection_events(
collection_slug,
"sale",
limit=min(period_days * 10, 300)
)
return collection, stats, events
@st.cache_resource
def get_api_clients():
"""
API 클라이언트 싱글톤 (앱 전체에서 재사용)
cache_resource는 객체를 앱 수명 동안 유지
"""
from api.opensea import OpenSeaAPI
from api.alchemy import AlchemyAPI
return {
"opensea": OpenSeaAPI(),
"alchemy": AlchemyAPI()
}
@st.cache_data와 @st.cache_resource는 용도가 다름.cache_data: 데이터(DataFrame, dict 등) 캐싱 — 직렬화 가능한 객체cache_resource: 리소스(DB 연결, API 클라이언트 등) 캐싱 — 싱글톤 패턴
로컬에서 잘 돌아가면 이제 배포해서 어디서든 접근할 수 있게 만들자! Streamlit Cloud 쓰면 무료로 배포 가능함 🌐
.env 파일은 절대 올리면 안 됨! .gitignore 확인 필수
share.streamlit.io → 앱 설정 → Secrets에 API 키 입력
# .streamlit/secrets.toml (로컬 테스트용, 깃허브 올리면 안됨!)
OPENSEA_API_KEY = "your_key_here"
ALCHEMY_API_KEY = "your_key_here"
ETHERSCAN_API_KEY = "your_key_here"
# 코드에서 시크릿 접근 방법
import streamlit as st
# Streamlit Cloud 배포 시
api_key = st.secrets["OPENSEA_API_KEY"]
# 로컬 개발 시 (환경변수 폴백)
import os
api_key = st.secrets.get("OPENSEA_API_KEY") or os.getenv("OPENSEA_API_KEY")
배포 환경에서 패키지 버전 맞춰야 오류 안 남
GitHub 연동 → 레포 선택 → 브랜치 선택 → app.py 경로 입력 → Deploy!
완성된 NFT 분석 툴에는 다음 기능들이 들어가 있음 🎉
| 기능 카테고리 | 세부 기능 | 사용 API |
|---|---|---|
| 📊 가격 분석 | 바닥가 추이, 이동평균, 변동성, 지지/저항선 | OpenSea |
| 📈 거래량 분석 | 일별/주별 거래량, 평균 거래가, 거래 건수 | OpenSea |
| 👥 홀더 분석 | 홀더 분포, 지니계수, 고래 리스크 평가 | Alchemy |
| 🐋 고래 추적 | 대형 홀더 식별, 매수/매도 활동, 순매수 흐름 | Alchemy + OpenSea |
| 💎 희귀도 분석 | Rarity Score 계산, 등급 분류, 특성 통계 | Alchemy |
| 💰 가격 환산 | ETH → USD 실시간 환산 | CoinGecko |
| 🔍 지갑 분석 | 특정 지갑 NFT 포트폴리오, 추정 가치 | Alchemy |
개발하다 보면 에러 만나는 게 당연함ㅋㅋ 자주 나오는 에러들이랑 해결법 정리해줄게 🛠️
429 Too Many Requests→ 해결: API 호출 사이에
time.sleep(0.25) 추가. 캐싱 적극 활용. 유료 플랜 고려.
→ 해결:
st.session_state 활용해서 상태 관리. 무거운 연산은 @st.cache_data로 캐싱.
Object of type Timestamp is not JSON serializable→ 해결:
json.dumps(data, default=str) 사용하거나 DataFrame 저장 시 df.to_json() 활용.
ConnectionError: Could not connect to the endpoint→ 해결: Alchemy/Infura RPC URL 확인. 네트워크 이름 정확히 입력 (eth-mainnet, polygon-mainnet 등).
# 에러 핸들링 베스트 프랙티스
import streamlit as st
import logging
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def safe_api_call(func, *args, **kwargs):
"""
API 호출 안전하게 래핑
에러 발생 시 None 반환 + 사용자에게 알림
"""
try:
return func(*args, **kwargs)
except Exception as e:
error_msg = str(e)
logger.error(f"API 호출 실패: {error_msg}")
if "429" in error_msg:
st.warning("⚠️ API 요청 한도 초과. 잠시 후 다시 시도해주세요.")
elif "401" in error_msg or "403" in error_msg:
st.error("❌ API 키가 유효하지 않습니다. 설정을 확인해주세요.")
elif "404" in error_msg:
st.warning("⚠️ 컬렉션을 찾을 수 없습니다. 슬러그를 확인해주세요.")
else:
st.error(f"❌ 오류 발생: {error_msg}")
return None
# 재시도 로직 (exponential backoff)
import time
from functools import wraps
def retry_with_backoff(max_retries: int = 3, base_delay: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
if "429" in str(e) and attempt < max_retries - 1:
delay = base_delay * (2 ** attempt)
logger.info(f"Rate limit. {delay}초 후 재시도 ({attempt+1}/{max_retries})")
time.sleep(delay)
else:
raise
return wrapper
return decorator
# 사용 예시
@retry_with_backoff(max_retries=3, base_delay=1.0)
def fetch_with_retry(url: str, headers: dict) -> dict:
import requests
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response.json()
기본 툴 완성했으면 이제 더 발전시켜볼 수 있는 아이디어들 공유할게. 이걸 다 구현하면 진짜 프로 수준의 NFT 분석 툴이 됨 🚀
scikit-learn이나 Prophet 라이브러리로 바닥가 예측 모델 만들기. 과거 가격 데이터 + 거래량 + 홀더 수 변화를 피처로 사용. 단, 예측은 참고용으로만 써야 함 (투자 조언 아님!).
바닥가가 특정 가격 이하로 떨어지거나, 고래가 대량 매도할 때 텔레그램/이메일 알림 보내기. Python의 schedule 라이브러리 + Telegram Bot API 조합으로 구현 가능.
이더리움 외에 Polygon, Solana, Arbitrum 등 다른 체인의 NFT도 분석하기. Moralis API는 멀티체인을 잘 지원함. Solana는 Helius API 추천.
여러 지갑 주소를 등록해두고 전체 NFT 포트폴리오 가치를 실시간으로 추적. 수익/손실 계산, 취득 원가 대비 현재 바닥가 비교 기능.
Twitter/X API나 Reddit API로 특정 NFT 컬렉션 관련 소셜 미디어 언급량, 감성 분석(긍정/부정) 데이터를 가격 데이터와 함께 시각화. VADER나 TextBlob 라이브러리 활용.
SQLite나 PostgreSQL 연동해서 히스토리컬 데이터 축적. 장기 트렌드 분석 가능. SQLAlchemy + pandas의 to_sql() 메서드로 쉽게 구현.
자 이제 파이썬 + 스트림릿으로 NFT 데이터 분석 툴 만드는 방법 전부 다 알려줬음ㅋㅋ 처음엔 복잡해 보여도 하나씩 따라가다 보면 진짜 완성됨. 이런 툴 만들어서 재능넷(https://www.jaenung.net)에서 Web3 개발 재능으로 등록해두면 의뢰도 들어올 수 있음 💪
② OpenSea API = 가격/거래량 데이터, Alchemy API = 온체인 데이터
③ 캐싱 필수 = @st.cache_data로 API 호출 최소화
④ 에러 핸들링 = Rate Limit, 연결 오류 대비 재시도 로직 구현
⑤ 보안 중요 = API 키는 절대 깃허브에 올리지 말 것!
⑥ 데이터는 참고용 = 분석 결과는 투자 조언이 아님, 항상 DYOR(Do Your Own Research)
이 글에서 다룬 코드들은 실제 동작하는 코드 기반으로 작성됐지만, API 버전 업데이트나 정책 변경에 따라 수정이 필요할 수 있어. 공식 문서 항상 참고하는 습관 들이자 📚
Web3 개발은 빠르게 변하는 분야라 꾸준히 업데이트 따라가는 게 중요함. 스트림릿도 계속 새 기능 추가되고 있고, OpenSea API도 v2로 업그레이드되면서 기능이 많이 좋아졌어. 이 글 기준 시점은 2024년 하반기야 📅
자 이제 진짜 만들어봐! 코드 짜다가 막히는 거 있으면 공식 문서랑 스택오버플로우가 최고의 친구임ㅋㅋ 화이팅 🔥🔥🔥
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.