Spaces:
Sleeping
Sleeping
Create tools.py
Browse files- src/tools.py +316 -0
src/tools.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
from rapidfuzz import process, fuzz
|
| 3 |
+
|
| 4 |
+
# Data Loading ---------------------------------------------------------------
|
| 5 |
+
try:
|
| 6 |
+
knowledge_base = pd.read_csv("data/mcp_knowledge_base.csv")
|
| 7 |
+
knowledge_base_latest = pd.read_csv("data/mcp_knowledge_base_latest.csv")
|
| 8 |
+
ALL_PROD_NAMES = knowledge_base.prod_name.values
|
| 9 |
+
ALL_ARTIST_NAMES = knowledge_base.artist.values
|
| 10 |
+
ALL_SET_NAMES = knowledge_base.set_name.values
|
| 11 |
+
|
| 12 |
+
except Exception as e:
|
| 13 |
+
# Handle data loading error
|
| 14 |
+
print(f"ERROR loading data for tools: {e}")
|
| 15 |
+
knowledge_base = pd.DataFrame()
|
| 16 |
+
knowledge_base_latest = pd.DataFrame()
|
| 17 |
+
ALL_PROD_NAMES = []
|
| 18 |
+
ALL_ARTIST_NAMES = []
|
| 19 |
+
ALL_SET_NAMES = []
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class PokemonAdvisorTools():
|
| 24 |
+
"""
|
| 25 |
+
A central class to house all data retrieval and analysis tools
|
| 26 |
+
for the cAsh MCP Robo-Advisor.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
knowledge_base = knowledge_base
|
| 30 |
+
knowledge_base_latest = knowledge_base_latest
|
| 31 |
+
ALL_PROD_NAMES = ALL_PROD_NAMES
|
| 32 |
+
ALL_ARTIST_NAMES = ALL_ARTIST_NAMES
|
| 33 |
+
ALL_SET_NAMES = ALL_SET_NAMES
|
| 34 |
+
|
| 35 |
+
def list_card_names(self, name_query: str) -> list:
|
| 36 |
+
"""
|
| 37 |
+
Retrieves a list of card names from the database.
|
| 38 |
+
Use this tool when the user says the card you provided is not what they are looking for.
|
| 39 |
+
Args:
|
| 40 |
+
name_query (str): The name of the card to search for (e.g., "Umbreon GX").
|
| 41 |
+
The tool uses fuzzy matching, so exact spelling is not required.
|
| 42 |
+
Returns:
|
| 43 |
+
list: A list of 'prod_name's that matches the 'name_query'
|
| 44 |
+
"""
|
| 45 |
+
if not self.ALL_PROD_NAMES.any(): return {"error": "Data not loaded."}
|
| 46 |
+
|
| 47 |
+
prod_names_match = process.extract(name_query, self.ALL_PROD_NAMES, scorer=fuzz.WRatio, limit=5)
|
| 48 |
+
return [name[0].replace("_", " ") for name in prod_names_match]
|
| 49 |
+
|
| 50 |
+
def get_card_info(self, name_query: str) -> dict:
|
| 51 |
+
"""
|
| 52 |
+
Retrieves comprehensive financial and metadata for a specific Pokemon card.
|
| 53 |
+
|
| 54 |
+
Use this tool when you need to know the current price, 6-month trend, or
|
| 55 |
+
general details of a card.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
name_query (str): The name of the card to search for (e.g., "Charizard VMAX").
|
| 59 |
+
The tool uses fuzzy matching, so exact spelling is not required.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
dict: A dictionary containing 'used_price', 'graded_price', 'trend_6',
|
| 63 |
+
and other key metrics. Returns an 'error' key if not found.
|
| 64 |
+
"""
|
| 65 |
+
if not self.ALL_PROD_NAMES.any(): return {"error": "Data not loaded."}
|
| 66 |
+
|
| 67 |
+
match = process.extractOne(name_query, self.ALL_PROD_NAMES, scorer=fuzz.WRatio)
|
| 68 |
+
if not match or match[1] < 70:
|
| 69 |
+
return {"error": f"Card '{name_query}' not found. Please check spelling."}
|
| 70 |
+
|
| 71 |
+
prod_name = match[0]
|
| 72 |
+
card_df = self.knowledge_base_latest[self.knowledge_base_latest["prod_name"] == prod_name]
|
| 73 |
+
|
| 74 |
+
if card_df.empty:
|
| 75 |
+
return {"error": f"Data missing for '{prod_name}'."}
|
| 76 |
+
|
| 77 |
+
return card_df.to_dict(orient="records")[0]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def find_grading_opportunities(self, max_price: float = 100, min_profit: float = 20) -> list:
|
| 81 |
+
"""
|
| 82 |
+
Scans the market for 'Arbitrage' opportunities where the gap between the Raw
|
| 83 |
+
and Graded price is largest.
|
| 84 |
+
|
| 85 |
+
Use this tool when the user asks for "buying recommendations," "profitable cards,"
|
| 86 |
+
or "what should I grade?".
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
max_price (float): The maximum price willing to pay for the raw card. Default is 100.
|
| 90 |
+
min_profit (float): The minimum profit (Graded Price - Raw Price - Fees) desired. Default is 20.
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
list: A list of dictionaries representing the top 10 most profitable opportunities,
|
| 94 |
+
sorted by 'grade_profit' descending.
|
| 95 |
+
"""
|
| 96 |
+
profitable_grades = self.knowledge_base_latest[self.knowledge_base_latest["is_grade_profitable"] == True]
|
| 97 |
+
profitable_grades = profitable_grades[profitable_grades["used_price"] <= max_price]
|
| 98 |
+
min_profit_grades = profitable_grades[profitable_grades["grade_profit"] >= min_profit]
|
| 99 |
+
min_profit_grades = min_profit_grades.sort_values(
|
| 100 |
+
by="grade_profit", ascending=False
|
| 101 |
+
).head(10)
|
| 102 |
+
output_columns = [
|
| 103 |
+
"prod_name",
|
| 104 |
+
"used_price",
|
| 105 |
+
"graded_price",
|
| 106 |
+
"grade_profit",
|
| 107 |
+
"grade_profit_ratio",
|
| 108 |
+
"is_popular_pokemon",
|
| 109 |
+
"artist"
|
| 110 |
+
]
|
| 111 |
+
min_profit_grades = min_profit_grades[output_columns]
|
| 112 |
+
return min_profit_grades.to_dict(orient="records")
|
| 113 |
+
|
| 114 |
+
def get_market_movers(self, sort_by: str ="uptrend", interval: int = 6, market_type: str ="used") -> list:
|
| 115 |
+
"""
|
| 116 |
+
Identifies cards with the strongest positive or negative price trends over a sustained period (3 or 6 months).
|
| 117 |
+
|
| 118 |
+
Use this tool when users ask about "long-term growth," "steady winners," "market crashers,"
|
| 119 |
+
or "which cards are consistently losing value."
|
| 120 |
+
|
| 121 |
+
NOTE: Use this for TRENDS. Use `get_recent_price_spikes` for sudden, short-term JUMPS.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
sort_by (str): "uptrend" to find biggest gainers, "downtrend" to find biggest losers. Default is "uptrend".
|
| 125 |
+
interval (int): The time period in months to analyze (3 or 6). Default is 6.
|
| 126 |
+
market_type (str): "used" (Raw) or "graded" (Slab). Default is "used".
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
list: A list of the top 10 cards matching the trend criteria, including their percentage change.
|
| 130 |
+
"""
|
| 131 |
+
market_move_data = self.knowledge_base_latest.sort(by=f"{market_type}_trend_{interval}", ascending=(not sort_by=="uptrend")).head(10)
|
| 132 |
+
output_columns = ["prod_name", "used_price", "graded_price"]
|
| 133 |
+
market_move_data = market_move_data[output_columns]
|
| 134 |
+
return market_move_data.to_dict(orient="records")
|
| 135 |
+
|
| 136 |
+
def _calculate_risk_label(self, vol, low_threshold, high_threshold):
|
| 137 |
+
"""Helper function for volatility assessment tool."""
|
| 138 |
+
if vol < low_threshold:
|
| 139 |
+
return "🟢 Low Volatility (Stable/Blue Chip)"
|
| 140 |
+
elif vol > high_threshold:
|
| 141 |
+
return "🔴 High Volatility (Speculative)"
|
| 142 |
+
else:
|
| 143 |
+
return "🟡 Medium Volatility"
|
| 144 |
+
|
| 145 |
+
def assess_risk_volatility(self, card_name: str, interval: int = 6) -> dict:
|
| 146 |
+
"""
|
| 147 |
+
Calculates the risk profile of a card based on its price volatility over time.
|
| 148 |
+
|
| 149 |
+
ALWAYS use this tool before recommending an investment.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
card_name (str): The name of the card to analyze.
|
| 153 |
+
interval (int): The time period in months to analyze (must be 3 or 6). Default is 6.
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
dict: Contains 'volatility_assessment' (Low/Medium/High) and raw metrics.
|
| 157 |
+
"""
|
| 158 |
+
try:
|
| 159 |
+
interval = int(interval)
|
| 160 |
+
except ValueError:
|
| 161 |
+
return {"error": "Invalid 'interval' value. Must be 3 or 6."}
|
| 162 |
+
|
| 163 |
+
card_info = self.get_card_info(card_name)
|
| 164 |
+
if not card_info:
|
| 165 |
+
return {"error": f"Card not found for query: {card_name}"}
|
| 166 |
+
|
| 167 |
+
if interval not in [3, 6]:
|
| 168 |
+
return {"error": f"Invalid interval requested: {interval}. Only 3 or 6 months are supported."}
|
| 169 |
+
|
| 170 |
+
if interval == 3:
|
| 171 |
+
# 3-Month Thresholds
|
| 172 |
+
used_vol_low_threshold = 0.533
|
| 173 |
+
used_vol_high_threshold = 4.969
|
| 174 |
+
graded_vol_low_threshold = 0.982
|
| 175 |
+
graded_vol_high_threshold = 4.367
|
| 176 |
+
used_volatility = card_info.get("used_vol_3")
|
| 177 |
+
graded_volatility = card_info.get("graded_vol_3")
|
| 178 |
+
|
| 179 |
+
elif interval == 6:
|
| 180 |
+
# 6-Month Threshold
|
| 181 |
+
used_vol_low_threshold = 0.785
|
| 182 |
+
used_vol_high_threshold = 9.092
|
| 183 |
+
graded_vol_low_threshold = 2.250
|
| 184 |
+
graded_vol_high_threshold = 11.905
|
| 185 |
+
used_volatility = card_info.get("used_vol_6")
|
| 186 |
+
graded_volatility = card_info.get("graded_vol_6")
|
| 187 |
+
|
| 188 |
+
if used_volatility is None or graded_volatility is None:
|
| 189 |
+
return {"error": f"Volatility data missing for {card_name} at {interval} months. Check if card exists in the full knowledge base."}
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
f"used_volatility": used_volatility,
|
| 193 |
+
f"graded_volatility": graded_volatility,
|
| 194 |
+
f"used_volatility_assesment_{interval}_months": self._calculate_risk_label(used_volatility, used_vol_low_threshold, used_vol_high_threshold),
|
| 195 |
+
f"graded_volatility_assesment_{interval}_months": self._calculate_risk_label(graded_volatility, graded_vol_low_threshold, graded_vol_high_threshold),
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
def get_roi_metrics(self, card_name: str) -> dict:
|
| 199 |
+
"""
|
| 200 |
+
Retrieves the historical Return on Investment (ROI) percentages.
|
| 201 |
+
|
| 202 |
+
Use this tool to show how a card has performed in the past (e.g., "Is it going up?").
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
card_name (str): The name of the card.
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
dict: Returns 3-month and 6-month ROI percentages for both Used and Graded conditions.
|
| 209 |
+
"""
|
| 210 |
+
card_info = self.get_card_info(card_name)
|
| 211 |
+
if not card_info:
|
| 212 |
+
return {"error": f"Card not found for query: {card_name}. Cannot calculate ROI."}
|
| 213 |
+
return {
|
| 214 |
+
"used_price": card_info.get("used_price"),
|
| 215 |
+
"used_return_3_months": card_info.get("used_return_3"),
|
| 216 |
+
"used_return_6_months": card_info.get("used_return_6"),
|
| 217 |
+
"graded_return_3_months": card_info.get("graded_return_3"),
|
| 218 |
+
"graded_return_6_months": card_info.get("graded_return_6")
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
def get_recent_price_spikes(self, market_type: str = "used") -> list:
|
| 222 |
+
"""
|
| 223 |
+
Identifies cards that have recently experienced a significant price jump ("Spike").
|
| 224 |
+
|
| 225 |
+
Use this tool when users ask about "market movers," "hype," or "what is popping right now."
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
market_type (str): Either "used" (Raw) or "graded" (Slab). Default is "used".
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
list: Top 20 cards with the highest recent positive price change.
|
| 232 |
+
"""
|
| 233 |
+
market_type = market_type.lower().strip()
|
| 234 |
+
if market_type == "used":
|
| 235 |
+
jump_data = self.knowledge_base_latest[self.knowledge_base_latest["used_jump_up"] == True]
|
| 236 |
+
jump_data = jump_data.sort_values("used_price", ascending=False).head(20)
|
| 237 |
+
output_columns = ["prod_name", "set_name", "used_price"]
|
| 238 |
+
return jump_data[output_columns].to_dict(orient="records")
|
| 239 |
+
|
| 240 |
+
elif market_type == "graded":
|
| 241 |
+
jump_data = self.knowledge_base_latest[self.knowledge_base_latest["graded_jump_up"] == True]
|
| 242 |
+
jump_data = jump_data.sort_values("graded_price", ascending=False).head(20)
|
| 243 |
+
output_columns = ["prod_name", "set_name", "graded_price"]
|
| 244 |
+
return jump_data[output_columns].to_dict(orient="records")
|
| 245 |
+
|
| 246 |
+
# --- Error Handling ---
|
| 247 |
+
else:
|
| 248 |
+
return {"error": f"Invalid market_type '{market_type}'. Please use 'used' or 'graded'."}
|
| 249 |
+
|
| 250 |
+
def find_cards_by_artist(self, artist_name: str) -> dict:
|
| 251 |
+
"""
|
| 252 |
+
Finds profitable or popular cards illustrated by a specific artist.
|
| 253 |
+
|
| 254 |
+
Use this for "Niche" requests or when users ask about art styles.
|
| 255 |
+
|
| 256 |
+
Args:
|
| 257 |
+
artist_name (str): The artist's name limited to ['Akira Egawa', 'Shinji Kanda', 'HYOGONOSUKE', 'sowsow', 'Tomokazu Komiya'].
|
| 258 |
+
|
| 259 |
+
Returns:
|
| 260 |
+
dict: A list of cards by that artist, sorted by profitability.
|
| 261 |
+
"""
|
| 262 |
+
artist_match = process.extractOne(artist_name, self.ALL_ARTIST_NAMES, scorer=fuzz.WRatio)
|
| 263 |
+
|
| 264 |
+
if not artist_match or artist_match[1] < 75:
|
| 265 |
+
return {"error": f"Artist '{artist_name}' not found or matched with low confidence."}
|
| 266 |
+
|
| 267 |
+
artist_name_match = artist_match[0]
|
| 268 |
+
artist_card_data = self.knowledge_base_latest[self.knowledge_base_latest["artist"] == artist_name_match]
|
| 269 |
+
profitable_cards = artist_card_data[artist_card_data["is_grade_profitable"] == True]
|
| 270 |
+
profitable_cards = profitable_cards.sort_values(by="grade_profit", ascending=False).head(20)
|
| 271 |
+
|
| 272 |
+
output_columns = [
|
| 273 |
+
"prod_name",
|
| 274 |
+
"set_name",
|
| 275 |
+
"used_price",
|
| 276 |
+
"grade_profit",
|
| 277 |
+
"grade_profit_ratio"
|
| 278 |
+
]
|
| 279 |
+
|
| 280 |
+
# --- Error Handling ---
|
| 281 |
+
if profitable_cards.empty:
|
| 282 |
+
return {"result": f"No currently profitable cards found by artist {artist_name_match} in the latest data."}
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
"artist": artist_name_match,
|
| 286 |
+
"cards": profitable_cards[output_columns].to_dict(orient="records")
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
def analyze_set_performance(self, set_name: str) -> dict:
|
| 290 |
+
"""
|
| 291 |
+
Aggregates data to analyze the overall health and sentiment of a specific Card Set.
|
| 292 |
+
|
| 293 |
+
Use this when users ask about broad trends like "How is Evolving Skies doing?"
|
| 294 |
+
rather than specific cards.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
set_query (str): The name of the set (e.g., "Sun & Moon"). Fuzzy matched.
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
dict: Average trends, average profitability, and the set's 'Chase Card'.
|
| 301 |
+
"""
|
| 302 |
+
set_name_match = process.extractOne(set_name.lower(), self.ALL_SET_NAMES, scorer=fuzz.WRatio)[0]
|
| 303 |
+
set_card_data = self.knowledge_base_latest[self.knowledge_base_latest["set_name"] == set_name_match]
|
| 304 |
+
total_cards = len(set_card_data)
|
| 305 |
+
avg_trend_6 = set_card_data["used_trend_6"].mean()
|
| 306 |
+
avg_grade_profit = set_card_data["grade_profit"].mean()
|
| 307 |
+
chase_card_row = set_card_data.sort_values('used_price', ascending=False).iloc[0]
|
| 308 |
+
|
| 309 |
+
return {
|
| 310 |
+
"set_name": set_name_match.replace("-", " "),
|
| 311 |
+
"total_cards_tracked": total_cards,
|
| 312 |
+
"market_sentiment_6mo": f"{avg_trend_6:.2f}%",
|
| 313 |
+
"avg_grading_profit": f"${avg_grade_profit:.2f}",
|
| 314 |
+
"chase_card": chase_card_row['prod_name'],
|
| 315 |
+
"chase_card_price": chase_card_row['used_price']
|
| 316 |
+
}
|