File size: 16,505 Bytes
9bcdc1a
 
896c7d9
 
9bcdc1a
 
 
 
 
 
 
896c7d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dea58ed
d4958cf
d94aa26
 
417c323
 
d94aa26
896c7d9
d94aa26
417c323
d94aa26
9bcdc1a
 
 
 
 
 
 
 
 
 
896c7d9
 
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
 
9bcdc1a
 
 
 
 
 
 
 
 
896c7d9
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
896c7d9
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
 
 
 
 
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
 
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
9bcdc1a
 
896c7d9
 
 
 
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
 
 
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
9bcdc1a
 
 
 
896c7d9
 
 
9bcdc1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896c7d9
9bcdc1a
 
 
896c7d9
 
 
 
 
 
 
 
 
 
9bcdc1a
896c7d9
 
 
 
9bcdc1a
 
 
 
 
 
896c7d9
9bcdc1a
 
 
 
896c7d9
9bcdc1a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
import pandas as pd
from rapidfuzz import process, fuzz
import os
from pathlib import Path

class PokemonAdvisorTools():
    """
    A central class to house all data retrieval and analysis tools
    for the cAsh MCP Robo-Advisor.
    """

    def __init__(self, data_dir: str = None):
        """
        Initialize the tools with data loading.
        
        Args:
            data_dir: Directory containing the CSV files. If None, uses current directory.
        """
        # Determine data directory
        if data_dir is None:
            data_dir = Path(__file__).parent  # Same directory as tools.py
        else:
            data_dir = Path(data_dir)
        
        # Construct file paths
        kb_path = data_dir / "mcp_knowledge_base.csv"
        kb_latest_path = data_dir / "mcp_knowledge_base_latest.csv"
        
        # Load data with error handling
        try:
            print(f"Loading data from: {data_dir}")
            print(f"Looking for: {kb_path}")
            print(f"Looking for: {kb_latest_path}")
            
            if not kb_path.exists():
                raise FileNotFoundError(f"File not found: {kb_path}")
            if not kb_latest_path.exists():
                raise FileNotFoundError(f"File not found: {kb_latest_path}")
            
            self.knowledge_base = pd.read_csv(kb_path)
            self.knowledge_base_latest = pd.read_csv(kb_latest_path)
            
            # Extract unique values
            self.ALL_PROD_NAMES = self.knowledge_base['prod_name'].values
            self.ALL_ARTIST_NAMES = self.knowledge_base['artist'].values
            self.ALL_SET_NAMES = self.knowledge_base['set_name'].values
            
            print(f"βœ“ Successfully loaded {len(self.knowledge_base)} records from knowledge_base")
            print(f"βœ“ Successfully loaded {len(self.knowledge_base_latest)} records from knowledge_base_latest")
            print(f"βœ“ Found {len(self.ALL_SET_NAMES)} unique sets")
            
        except Exception as e:
            print(f"❌ ERROR loading data: {e}")
            print(f"Current working directory: {os.getcwd()}")
            print(f"Files in data directory: {list(data_dir.glob('*.csv')) if data_dir.exists() else 'Directory not found'}")
            
            # Initialize empty DataFrames as fallback
            self.knowledge_base = pd.DataFrame()
            self.knowledge_base_latest = pd.DataFrame()
            self.ALL_PROD_NAMES = []
            self.ALL_ARTIST_NAMES = []
            self.ALL_SET_NAMES = []
            
            raise RuntimeError(f"Failed to load Pokemon card data: {e} Current working directory: {os.getcwd()}")

    def get_data_shape(self) -> int:
        """
        Retrieves the number of set names.
        Use this when user asks about the number of unique sets.
        Returns:
            int: length of knowledge base
        """
        return len(self.ALL_SET_NAMES)

    def list_card_names(self, name_query: str) -> list:
        """
        Retrieves a list of card names from the database.
        Use this tool when the user says the card you provided is not what they are looking for.
        Args:
            name_query (str): The name of the card to search for (e.g., "Umbreon GX").
                              The tool uses fuzzy matching, so exact spelling is not required.
        Returns:
            list: A list of 'prod_name's that matches the 'name_query'              
        """
        if len(self.ALL_PROD_NAMES) == 0:
            return {"error": "Data not loaded."}

        prod_names_match = process.extract(name_query, self.ALL_PROD_NAMES, scorer=fuzz.WRatio, limit=5)
        return [name[0].replace("_", " ") for name in prod_names_match]

    def get_card_info(self, name_query: str) -> dict:
        """
        Retrieves comprehensive financial and metadata for a specific Pokemon card.
        
        Use this tool when you need to know the current price, 6-month trend, or
        general details of a card.
        
        Args:
            name_query (str): The name of the card to search for (e.g., "Charizard VMAX").
                              The tool uses fuzzy matching, so exact spelling is not required.
        
        Returns:
            dict: A dictionary containing 'used_price', 'graded_price', 'trend_6',
                  and other key metrics. Returns an 'error' key if not found.
        """
        if len(self.ALL_PROD_NAMES) == 0:
            return {"error": "Data not loaded."}

        match = process.extractOne(name_query, self.ALL_PROD_NAMES, scorer=fuzz.WRatio)
        if not match or match[1] < 70:
            return {"error": f"Card '{name_query}' not found. Please check spelling."}
            
        prod_name = match[0]
        card_df = self.knowledge_base_latest[self.knowledge_base_latest["prod_name"] == prod_name]
        
        if card_df.empty:
            return {"error": f"Data missing for '{prod_name}'."}
             
        return card_df.to_dict(orient="records")[0]

    def find_grading_opportunities(self, max_price: float = 100, min_profit: float = 20) -> list:
        """
        Scans the market for 'Arbitrage' opportunities where the gap between the Raw 
        and Graded price is largest.
        
        Use this tool when the user asks for "buying recommendations," "profitable cards,"
        or "what should I grade?".
        
        Args:
            max_price (float): The maximum price willing to pay for the raw card. Default is 100.
            min_profit (float): The minimum profit (Graded Price - Raw Price - Fees) desired. Default is 20.
            
        Returns:
            list: A list of dictionaries representing the top 10 most profitable opportunities,
                  sorted by 'grade_profit' descending.
        """        
        profitable_grades = self.knowledge_base_latest[self.knowledge_base_latest["is_grade_profitable"] == True]
        profitable_grades = profitable_grades[profitable_grades["used_price"] <= max_price]
        min_profit_grades = profitable_grades[profitable_grades["grade_profit"] >= min_profit]
        min_profit_grades = min_profit_grades.sort_values(
            by="grade_profit", ascending=False
        ).head(10)
        output_columns = [
            "prod_name",
            "used_price",
            "graded_price",
            "grade_profit",
            "grade_profit_ratio",
            "is_popular_pokemon",
            "artist"
        ]
        min_profit_grades = min_profit_grades[output_columns]
        return min_profit_grades.to_dict(orient="records")

    def get_market_movers(self, sort_by: str = "uptrend", interval: int = 6, market_type: str = "used") -> list:
        """
        Identifies cards with the strongest positive or negative price trends over a sustained period (3 or 6 months).
        
        Use this tool when users ask about "long-term growth," "steady winners," "market crashers," 
        or "which cards are consistently losing value."
        
        NOTE: Use this for TRENDS. Use `get_recent_price_spikes` for sudden, short-term JUMPS.
        
        Args:
            sort_by (str): "uptrend" to find biggest gainers, "downtrend" to find biggest losers. Default is "uptrend".
            interval (int): The time period in months to analyze (3 or 6). Default is 6.
            market_type (str): "used" (Raw) or "graded" (Slab). Default is "used".
            
        Returns:
            list: A list of the top 10 cards matching the trend criteria, including their percentage change.
        """        
        market_move_data = self.knowledge_base_latest.sort_values(
            by=f"{market_type}_trend_{interval}", 
            ascending=(sort_by != "uptrend")
        ).head(10)
        output_columns = ["prod_name", "used_price", "graded_price", f"{market_type}_trend_{interval}"]
        market_move_data = market_move_data[output_columns]
        return market_move_data.to_dict(orient="records")

    def _calculate_risk_label(self, vol, low_threshold, high_threshold):
        """Helper function for volatility assessment tool."""
        if vol < low_threshold:
            return "🟒 Low Volatility (Stable/Blue Chip)"
        elif vol > high_threshold:
            return "πŸ”΄ High Volatility (Speculative)"
        else:
            return "🟑 Medium Volatility"

    def assess_risk_volatility(self, card_name: str, interval: int = 6) -> dict:
        """
        Calculates the risk profile of a card based on its price volatility over time.
        
        ALWAYS use this tool before recommending an investment.
        
        Args:
            card_name (str): The name of the card to analyze.
            interval (int): The time period in months to analyze (must be 3 or 6). Default is 6.
            
        Returns:
            dict: Contains 'volatility_assessment' (Low/Medium/High) and raw metrics.
        """        
        try:
            interval = int(interval)
        except ValueError:
            return {"error": "Invalid 'interval' value. Must be 3 or 6."}

        card_info = self.get_card_info(card_name)
        if "error" in card_info:
            return card_info

        if interval not in [3, 6]:
            return {"error": f"Invalid interval requested: {interval}. Only 3 or 6 months are supported."}

        if interval == 3:
            # 3-Month Thresholds
            used_vol_low_threshold = 0.533
            used_vol_high_threshold = 4.969
            graded_vol_low_threshold = 0.982
            graded_vol_high_threshold = 4.367
            used_volatility = card_info.get("used_vol_3")
            graded_volatility = card_info.get("graded_vol_3")

        elif interval == 6:
            # 6-Month Threshold
            used_vol_low_threshold = 0.785
            used_vol_high_threshold = 9.092
            graded_vol_low_threshold = 2.250
            graded_vol_high_threshold = 11.905
            used_volatility = card_info.get("used_vol_6")
            graded_volatility = card_info.get("graded_vol_6")

        if used_volatility is None or graded_volatility is None:
            return {"error": f"Volatility data missing for {card_name} at {interval} months."}

        return {
            "used_volatility": used_volatility,
            "graded_volatility": graded_volatility,
            f"used_volatility_assessment_{interval}_months": self._calculate_risk_label(used_volatility, used_vol_low_threshold, used_vol_high_threshold),
            f"graded_volatility_assessment_{interval}_months": self._calculate_risk_label(graded_volatility, graded_vol_low_threshold, graded_vol_high_threshold),
        }

    def get_roi_metrics(self, card_name: str) -> dict:
        """
        Retrieves the historical Return on Investment (ROI) percentages.
        
        Use this tool to show how a card has performed in the past (e.g., "Is it going up?").
        
        Args:
            card_name (str): The name of the card.
            
        Returns:
            dict: Returns 3-month and 6-month ROI percentages for both Used and Graded conditions.
        """        
        card_info = self.get_card_info(card_name)
        if "error" in card_info:
            return card_info
            
        return {
            "used_price": card_info.get("used_price"),
            "used_return_3_months": card_info.get("used_return_3"),
            "used_return_6_months": card_info.get("used_return_6"),
            "graded_return_3_months": card_info.get("graded_return_3"),
            "graded_return_6_months": card_info.get("graded_return_6")
        }

    def get_recent_price_spikes(self, market_type: str = "used") -> list:
        """
        Identifies cards that have recently experienced a significant price jump ("Spike").
        
        Use this tool when users ask about "market movers," "hype," or "what is popping right now."
        
        Args:
            market_type (str): Either "used" (Raw) or "graded" (Slab). Default is "used".
            
        Returns:
            list: Top 20 cards with the highest recent positive price change.
        """        
        market_type = market_type.lower().strip()
        if market_type == "used":
            jump_data = self.knowledge_base_latest[self.knowledge_base_latest["used_jump_up"] == True]
            jump_data = jump_data.sort_values("used_price", ascending=False).head(20)
            output_columns = ["prod_name", "set_name", "used_price"]
            return jump_data[output_columns].to_dict(orient="records")

        elif market_type == "graded":
            jump_data = self.knowledge_base_latest[self.knowledge_base_latest["graded_jump_up"] == True]
            jump_data = jump_data.sort_values("graded_price", ascending=False).head(20)
            output_columns = ["prod_name", "set_name", "graded_price"]
            return jump_data[output_columns].to_dict(orient="records")

        else:
            return {"error": f"Invalid market_type '{market_type}'. Please use 'used' or 'graded'."}

    def find_cards_by_artist(self, artist_name: str) -> dict:
        """
        Finds profitable or popular cards illustrated by a specific artist.
        
        Use this for "Niche" requests or when users ask about art styles.
        
        Args:
            artist_name (str): The artist's name.
            
        Returns:
            dict: A list of cards by that artist, sorted by profitability.
        """
        if len(self.ALL_ARTIST_NAMES) == 0:
            return {"error": "Data not loaded."}
            
        artist_match = process.extractOne(artist_name, self.ALL_ARTIST_NAMES, scorer=fuzz.WRatio)

        if not artist_match or artist_match[1] < 75:
            return {"error": f"Artist '{artist_name}' not found or matched with low confidence."}

        artist_name_match = artist_match[0]
        artist_card_data = self.knowledge_base_latest[self.knowledge_base_latest["artist"] == artist_name_match]
        profitable_cards = artist_card_data[artist_card_data["is_grade_profitable"] == True]
        profitable_cards = profitable_cards.sort_values(by="grade_profit", ascending=False).head(20)

        output_columns = [
            "prod_name",
            "set_name",
            "used_price",
            "grade_profit",
            "grade_profit_ratio"
        ]

        if profitable_cards.empty:
            return {"result": f"No currently profitable cards found by artist {artist_name_match} in the latest data."}

        return {
            "artist": artist_name_match,
            "cards": profitable_cards[output_columns].to_dict(orient="records")
        }

    def analyze_set_performance(self, set_name: str) -> dict:
        """
        Aggregates data to analyze the overall health and sentiment of a specific Card Set.
        
        Use this when users ask about broad trends like "How is Evolving Skies doing?" 
        rather than specific cards.
        
        Args:
            set_name (str): The name of the set (e.g., "Evolving Skies"). Fuzzy matched.
            
        Returns:
            dict: Average trends, average profitability, and the set's 'Chase Card'.
        """
        if len(self.ALL_SET_NAMES) == 0:
            return {"error": "Data not loaded."}
            
        set_match = process.extractOne(set_name, self.ALL_SET_NAMES, scorer=fuzz.WRatio)
        
        if not set_match or set_match[1] < 70:
            return {"error": f"Set '{set_name}' not found. Available sets: {list(self.ALL_SET_NAMES[:5])}"}
        
        set_name_match = set_match[0]
        set_card_data = self.knowledge_base_latest[self.knowledge_base_latest["set_name"] == set_name_match]
        
        if set_card_data.empty:
            return {"error": f"No data found for set: {set_name_match}"}
            
        total_cards = len(set_card_data)
        avg_trend_6 = set_card_data["used_trend_6"].mean()
        avg_grade_profit = set_card_data["grade_profit"].mean()
        chase_card_row = set_card_data.sort_values('used_price', ascending=False).iloc[0]

        return {
            "set_name": set_name_match.replace("_", " "),
            "total_cards_tracked": total_cards,
            "market_sentiment_6mo": f"{avg_trend_6:.2f}%",
            "avg_grading_profit": f"${avg_grade_profit:.2f}",
            "chase_card": chase_card_row['prod_name'],
            "chase_card_price": f"${chase_card_row['used_price']:.2f}"
        }