zypchn commited on
Commit
9bcdc1a
·
verified ·
1 Parent(s): b1cfdc4

Create tools.py

Browse files
Files changed (1) hide show
  1. 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
+ }