""" NationStates High-Value Trading Card Tracker — Gradio Web App ============================================================== Deployed to Hugging Face Spaces. Uses core.py for all shared logic. COMPLIANCE: This tool is purely read-only. No automated trades. """ import gradio as gr import sqlite3 import time import os import pandas as pd from datetime import datetime from core import ( init_db, fetch_card_info, fetch_card_market, fetch_full_market, fetch_card_trades, check_nation_exists, scan_low_id_cards, scan_cte_cards, scan_historical_cards, check_target_prices, record_price_snapshot, calculate_trade_score, suggest_bid_price, SEASON, RATE_LIMIT_SECONDS, THRESHOLDS, HISTORICAL_NAMES, USER_AGENT ) # ========================================== # CONFIGURATION # ========================================== DB_PATH = "/tmp/cards_tracker.db" # ========================================== # SMART TRADING UI HANDLERS # ========================================== def run_smart_analysis(max_cards): """Analyze all targets and score them for trading opportunity.""" init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(""" SELECT card_id, season, name, rarity, flag_reason, lowest_ask FROM targets ORDER BY CASE rarity WHEN 'legendary' THEN 1 WHEN 'epic' THEN 2 ELSE 3 END LIMIT ? """, (int(max_cards),)) targets = c.fetchall() conn.close() if not targets: return "No targets found. Run a scan first.", pd.DataFrame() results = [] errors = 0 for card_id, season, name, rarity, flag_reason, lowest_ask in targets: snapshot = record_price_snapshot(DB_PATH, card_id, season) if snapshot.get("error"): errors += 1 trades = fetch_card_trades(card_id, season) if trades: conn = sqlite3.connect(DB_PATH) c = conn.cursor() for t in trades: c.execute("""INSERT OR IGNORE INTO trade_history (card_id, season, timestamp, price, buyer, seller) VALUES (?, ?, ?, ?, ?, ?)""", (card_id, season, t["timestamp"], t["price"], t["buyer"], t["seller"])) conn.commit() conn.close() score = calculate_trade_score(DB_PATH, card_id, season) bid_suggestions = suggest_bid_price(DB_PATH, card_id, season) suggested_bid = "" if bid_suggestions: if "aggressive_bid" in bid_suggestions: suggested_bid = f"{bid_suggestions['aggressive_bid']:.2f}" elif "below_mv_20pct" in bid_suggestions: suggested_bid = f"{bid_suggestions['below_mv_20pct']:.2f}" results.append({ "Card ID": card_id, "Name": name, "Rarity": rarity, "Reason": flag_reason or "", "Ask": f"{snapshot['lowest_ask']:.2f}" if snapshot["lowest_ask"] else "—", "Bid": f"{snapshot['highest_bid']:.2f}" if snapshot["highest_bid"] else "—", "Score": score or 0, "Suggested Bid": suggested_bid, "URL": f"https://www.nationstates.net/page=deck/card={card_id}/season={season}", }) results.sort(key=lambda x: x["Score"], reverse=True) df = pd.DataFrame(results) top_buys = [r for r in results if r["Score"] >= 70] summary = f"**Analyzed {len(results)} cards.**\n\n" if errors: summary += f"⚠️ {errors} API errors encountered during analysis.\n\n" if top_buys: summary += f"🎯 **{len(top_buys)} HIGH-OPPORTUNITY cards (score ≥ 70):**\n\n" for r in top_buys[:10]: summary += f"- **{r['Name']}** (ID:{r['Card ID']}) — {r['Rarity']} | Score: **{r['Score']}** | Ask: {r['Ask']} | Suggest bid: {r['Suggested Bid']}\n" else: summary += "No high-opportunity cards found at this time. Keep monitoring." return summary, df def run_card_deep_dive(card_id): """Deep analysis of a single card.""" init_db(DB_PATH) card_id = int(card_id) card_info = fetch_card_info(card_id, SEASON) if not card_info: return "❌ Card not found. The API may be unavailable or the card ID is invalid.", pd.DataFrame() market = fetch_full_market(card_id, SEASON) trades = fetch_card_trades(card_id, SEASON) # Store data conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("INSERT OR REPLACE INTO cards VALUES (?, ?, ?, ?, ?, ?, ?)", (card_info["card_id"], card_info["season"], card_info["name"], card_info["rarity"], card_info["region"], card_info["market_value"], card_info.get("flag", ""))) c.execute("""INSERT INTO price_history (card_id, season, timestamp, lowest_ask, highest_bid, market_value, num_asks, num_bids) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (card_id, SEASON, datetime.now().isoformat(), market["asks"][0]["price"] if market["asks"] else None, market["bids"][0]["price"] if market["bids"] else None, card_info["market_value"], len(market["asks"]), len(market["bids"]))) for t in trades: c.execute("""INSERT OR IGNORE INTO trade_history (card_id, season, timestamp, price, buyer, seller) VALUES (?, ?, ?, ?, ?, ?)""", (card_id, SEASON, t["timestamp"], t["price"], t["buyer"], t["seller"])) conn.commit() conn.close() score = calculate_trade_score(DB_PATH, card_id, SEASON) bid_suggestions = suggest_bid_price(DB_PATH, card_id, SEASON) # Build report report = f"## 📊 Deep Dive: {card_info['name']} (ID: {card_id})\n\n" report += f"**Rarity:** {card_info['rarity'].upper()} | **Region:** {card_info['region']} | **Market Value:** {card_info['market_value']:.2f}\n\n" if market.get("error"): report += f"⚠️ *Market data may be incomplete: {market['error']}*\n\n" report += f"### Market State\n" if market["asks"]: report += f"- **Lowest Ask:** {market['asks'][0]['price']:.2f} ({len(market['asks'])} total asks)\n" else: report += f"- **No asks** (nobody selling)\n" if market["bids"]: report += f"- **Highest Bid:** {market['bids'][0]['price']:.2f} ({len(market['bids'])} total bids)\n" else: report += f"- **No bids** (nobody buying)\n" if market["asks"] and market["bids"]: spread = market["asks"][0]["price"] - market["bids"][0]["price"] spread_pct = (spread / market["asks"][0]["price"]) * 100 report += f"- **Spread:** {spread:.2f} ({spread_pct:.0f}%)\n" report += f"\n### Trading Score: **{score}/100**\n" if score and score >= 70: report += "🎯 **HIGH OPPORTUNITY** — Strong buy signal\n" elif score and score >= 50: report += "⚡ **MODERATE** — Worth watching\n" else: report += "⏸️ **LOW** — Not ideal timing\n" report += f"\n### Suggested Bid Prices\n" if bid_suggestions: for label, price in bid_suggestions.items(): nice_label = label.replace("_", " ").title() report += f"- **{nice_label}:** {price:.2f}\n" else: report += "- Not enough data for suggestions yet.\n" report += f"\n### Recent Trades ({len(trades)} found)\n" trade_data = [] if trades: for t in trades[:10]: ts = t["timestamp"] try: ts_display = datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M") if ts.isdigit() else ts[:16] except (ValueError, TypeError): ts_display = str(ts)[:16] trade_data.append({ "Date": ts_display, "Price": f"{t['price']:.2f}", "Buyer": t["buyer"], "Seller": t["seller"], }) report += f"\n[🔗 View on NS](https://www.nationstates.net/page=deck/card={card_id}/season={SEASON})\n" df = pd.DataFrame(trade_data) if trade_data else pd.DataFrame(columns=["Date", "Price", "Buyer", "Seller"]) return report, df def add_to_watchlist(card_id, max_bid, notes): """Add a card to the watchlist.""" init_db(DB_PATH) card_id = int(card_id) max_bid = float(max_bid) if max_bid else 0 card_info = fetch_card_info(card_id, SEASON) if not card_info: return "❌ Card not found. Check the ID or try again later.", get_watchlist() conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("""INSERT OR REPLACE INTO watchlist (card_id, season, name, rarity, max_bid, notes, added_at) VALUES (?, ?, ?, ?, ?, ?, ?)""", (card_id, SEASON, card_info["name"], card_info["rarity"], max_bid, notes, datetime.now().isoformat())) conn.commit() conn.close() return f"✅ Added **{card_info['name']}** (ID:{card_id}) to watchlist. Max bid: {max_bid:.2f}", get_watchlist() def remove_from_watchlist(card_id): """Remove a card from the watchlist.""" init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("DELETE FROM watchlist WHERE card_id = ? AND season = ?", (int(card_id), SEASON)) conn.commit() conn.close() return f"✅ Removed card {card_id} from watchlist.", get_watchlist() def get_watchlist(): """Get current watchlist.""" init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("""SELECT card_id, name, rarity, max_bid, notes, added_at FROM watchlist ORDER BY CASE rarity WHEN 'legendary' THEN 1 WHEN 'epic' THEN 2 ELSE 3 END""") rows = c.fetchall() conn.close() if rows: return pd.DataFrame(rows, columns=["Card ID", "Name", "Rarity", "Max Bid", "Notes", "Added"]) return pd.DataFrame(columns=["Card ID", "Name", "Rarity", "Max Bid", "Notes", "Added"]) def check_watchlist_alerts(): """Check watchlist cards for buy opportunities.""" init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("SELECT card_id, season, name, rarity, max_bid FROM watchlist") items = c.fetchall() conn.close() if not items: return "Watchlist is empty. Add cards first.", pd.DataFrame() alerts = [] errors = 0 for card_id, season, name, rarity, max_bid in items: market = fetch_full_market(card_id, season) if market.get("error"): errors += 1 lowest_ask = market["asks"][0]["price"] if market["asks"] else None if lowest_ask and max_bid and lowest_ask <= max_bid: alerts.append({ "Card ID": card_id, "Name": name, "Rarity": rarity, "Lowest Ask": f"{lowest_ask:.2f}", "Your Max Bid": f"{max_bid:.2f}", "Status": "🚨 BUY NOW", "URL": f"https://www.nationstates.net/page=deck/card={card_id}/season={season}", }) elif lowest_ask: alerts.append({ "Card ID": card_id, "Name": name, "Rarity": rarity, "Lowest Ask": f"{lowest_ask:.2f}", "Your Max Bid": f"{max_bid:.2f}" if max_bid else "—", "Status": "⏳ Waiting", "URL": f"https://www.nationstates.net/page=deck/card={card_id}/season={season}", }) else: alerts.append({ "Card ID": card_id, "Name": name, "Rarity": rarity, "Lowest Ask": "No asks", "Your Max Bid": f"{max_bid:.2f}" if max_bid else "—", "Status": "📭 No sellers", "URL": f"https://www.nationstates.net/page=deck/card={card_id}/season={season}", }) df = pd.DataFrame(alerts) buy_now = [a for a in alerts if "BUY NOW" in a["Status"]] summary = f"**Checked {len(items)} watchlist cards.**\n\n" if errors: summary += f"⚠️ {errors} API errors encountered.\n\n" if buy_now: summary += f"🚨 **{len(buy_now)} cards available at or below your max bid!**\n\n" for a in buy_now: summary += f"- **{a['Name']}** — Ask: {a['Lowest Ask']} ≤ Your max: {a['Your Max Bid']} → [🛒 BUY]({a['URL']})\n" else: summary += "No cards at your target price yet. Keep checking." return summary, df # ========================================== # SCAN UI HANDLERS # ========================================== def run_low_id_scan(max_id): """UI handler for Low ID scan.""" init_db(DB_PATH) found = scan_low_id_cards(DB_PATH, int(max_id)) if found: df = pd.DataFrame(found) df["URL"] = df["card_id"].apply(lambda x: f"https://www.nationstates.net/page=deck/card={x}/season={SEASON}") df = df[["card_id", "name", "rarity", "region", "market_value", "URL"]] df.columns = ["Card ID", "Name", "Rarity", "Region", "Market Value", "URL"] return f"✅ Found **{len(found)}** Legendary/Epic cards with ID ≤ {int(max_id)}", df return "No Legendary/Epic cards found in that range.", pd.DataFrame(columns=["Card ID", "Name", "Rarity", "Region", "Market Value", "URL"]) def run_cte_scan(): """UI handler for CTE scan.""" init_db(DB_PATH) cte = scan_cte_cards(DB_PATH) if cte: df = pd.DataFrame(cte) return f"✅ Found **{len(cte)}** CTE (dead nation) cards", df return "No CTE cards found (all nations still active or no cards in DB). Run a Low ID scan first.", pd.DataFrame(columns=["card_id", "name", "rarity"]) def run_historical_scan(): """UI handler for Historical name scan.""" init_db(DB_PATH) hist = scan_historical_cards(DB_PATH) if hist: df = pd.DataFrame(hist) return f"✅ Found **{len(hist)}** cards matching historical names", df return "No historical name matches found. Run a Low ID scan first to populate the card database.", pd.DataFrame(columns=["card_id", "name", "rarity", "match"]) def run_price_check(max_checks): """UI handler for price check.""" init_db(DB_PATH) alerts, errors = check_target_prices(DB_PATH, int(max_checks)) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(""" SELECT card_id, name, rarity, flag_reason, lowest_ask, last_checked, card_url FROM targets WHERE last_checked IS NOT NULL ORDER BY lowest_ask ASC """) rows = c.fetchall() conn.close() df = pd.DataFrame(rows, columns=["Card ID", "Name", "Rarity", "Flag Reason", "Ask Price", "Last Checked", "URL"]) alert_text = f"**Checked prices for targets.**\n\n" if errors: alert_text += f"⚠️ {errors} API errors encountered (some cards may have incomplete data).\n\n" if alerts: alert_text += f"🚨 **{len(alerts)} UNDERVALUED CARDS!**\n\n" for a in alerts: alert_text += f"- **{a['name']}** (ID: {a['card_id']}) — {a['rarity'].upper()}\n" alert_text += f" Reason: {a['reason']} | Ask: **{a['ask']:.2f}** (threshold: {a['threshold']})\n" alert_text += f" [🛒 Buy Link]({a['url']})\n\n" else: alert_text += "✅ No undervalued cards below threshold." return alert_text, df def get_all_targets(): """Get all current targets from DB.""" init_db(DB_PATH) conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(""" SELECT card_id, name, rarity, flag_reason, lowest_ask, last_checked, card_url FROM targets ORDER BY CASE rarity WHEN 'legendary' THEN 1 WHEN 'epic' THEN 2 ELSE 3 END, card_id """) rows = c.fetchall() conn.close() if rows: return pd.DataFrame(rows, columns=["Card ID", "Name", "Rarity", "Flag Reason", "Ask Price", "Last Checked", "URL"]) return pd.DataFrame(columns=["Card ID", "Name", "Rarity", "Flag Reason", "Ask Price", "Last Checked", "URL"]) # ========================================== # BUILD APP # ========================================== with gr.Blocks(title="NS High-Value Card Tracker", theme=gr.themes.Soft()) as app: gr.Markdown("# 🏆 NationStates High-Value Trading Card Tracker") gr.Markdown("Identifies elite-tier undervalued cards via the NationStates API. **No automated trades** — manual purchase only via links.") gr.Markdown(f"*Season {SEASON} | Rate limit: {RATE_LIMIT_SECONDS}s/request | Compliance: Read-only*") with gr.Tab("🔍 Low ID Scan"): gr.Markdown("Scan cards by ID range to find Legendary/Epic cards with low IDs (rare & valuable).") max_id_input = gr.Number(label="Scan cards 1 to:", value=10000, minimum=1, maximum=10000, precision=0) gr.Markdown("*⏱️ Estimated time: ~1.2 seconds per card ID*") scan_btn = gr.Button("🔍 Start Low ID Scan", variant="primary") scan_status = gr.Markdown("") scan_results = gr.Dataframe(label="Found Cards", interactive=False) scan_btn.click(fn=run_low_id_scan, inputs=[max_id_input], outputs=[scan_status, scan_results]) with gr.Tab("💀 CTE Scan"): gr.Markdown("Check if flagged cards belong to dead/deleted nations (Ceased to Exist). These are scarcer and more valuable.") gr.Markdown("*Requires Low ID scan to be run first to populate the card database.*") cte_btn = gr.Button("💀 Check for CTE Cards", variant="primary") cte_status = gr.Markdown("") cte_results = gr.Dataframe(label="CTE Cards", interactive=False) cte_btn.click(fn=run_cte_scan, outputs=[cte_status, cte_results]) with gr.Tab("📜 Historical Names"): gr.Markdown("Find cards matching famous NationStates names (regions, moderators, historic players).") gr.Markdown(f"*Monitoring {len(HISTORICAL_NAMES)} names: {', '.join(HISTORICAL_NAMES[:10])}...*") hist_btn = gr.Button("📜 Scan Historical Names", variant="primary") hist_status = gr.Markdown("") hist_results = gr.Dataframe(label="Historical Cards", interactive=False) hist_btn.click(fn=run_historical_scan, outputs=[hist_status, hist_results]) with gr.Tab("💰 Price Check"): gr.Markdown("Check live market prices for all flagged targets. Alerts if price is below threshold.") gr.Markdown(f"*Thresholds: Legendary < {THRESHOLDS['legendary']}, Epic < {THRESHOLDS['epic']}, Ultra-Rare < {THRESHOLDS['ultra-rare']}*") max_price_input = gr.Number(label="Max cards to check:", value=30, minimum=1, maximum=200, precision=0) price_btn = gr.Button("💰 Check Live Prices", variant="primary") price_alerts = gr.Markdown("") price_table = gr.Dataframe(label="Targets with Prices", interactive=False) price_btn.click(fn=run_price_check, inputs=[max_price_input], outputs=[price_alerts, price_table]) with gr.Tab("📋 All Targets"): gr.Markdown("View all currently flagged high-value targets.") refresh_btn = gr.Button("🔄 Refresh") targets_table = gr.Dataframe(label="All Targets", interactive=False) refresh_btn.click(fn=get_all_targets, outputs=[targets_table]) with gr.Tab("🧠 Smart Trading"): gr.Markdown("""### Smart Trading Advisor Analyzes targets using multiple factors to score buy opportunities (0-100): - **Price vs Market Value** — Is it undervalued? - **Price Trend** — Is the price dropping? - **Spread Analysis** — Room to negotiate? - **Rarity & Scarcity** — CTE/Historical bonuses - **Suggested Bids** — Optimal bid prices based on trade history """) smart_max = gr.Number(label="Max cards to analyze:", value=20, minimum=1, maximum=100, precision=0) gr.Markdown("*⏱️ ~3.6s per card (3 API calls each)*") smart_btn = gr.Button("🧠 Run Smart Analysis", variant="primary") smart_summary = gr.Markdown("") smart_table = gr.Dataframe(label="Trading Opportunities (sorted by score)", interactive=False) smart_btn.click(fn=run_smart_analysis, inputs=[smart_max], outputs=[smart_summary, smart_table]) with gr.Tab("🔬 Card Deep Dive"): gr.Markdown("Get detailed analysis of a specific card — market state, trade history, score, and bid suggestions.") dive_card_id = gr.Number(label="Card ID:", value=1, minimum=1, precision=0) dive_btn = gr.Button("🔬 Analyze Card", variant="primary") dive_report = gr.Markdown("") dive_trades = gr.Dataframe(label="Recent Trades", interactive=False) dive_btn.click(fn=run_card_deep_dive, inputs=[dive_card_id], outputs=[dive_report, dive_trades]) with gr.Tab("👁️ Watchlist"): gr.Markdown("""### Personal Watchlist Add specific cards you want to buy. Set your max bid price and get alerted when asks drop to your target. """) with gr.Row(): wl_card_id = gr.Number(label="Card ID:", minimum=1, precision=0) wl_max_bid = gr.Number(label="Max bid price:", minimum=0, value=5.0) wl_notes = gr.Textbox(label="Notes:", placeholder="e.g. CTE legendary, want for collection") with gr.Row(): wl_add_btn = gr.Button("➕ Add to Watchlist", variant="primary") wl_remove_id = gr.Number(label="Remove Card ID:", minimum=1, precision=0) wl_remove_btn = gr.Button("🗑️ Remove", variant="secondary") wl_status = gr.Markdown("") wl_table = gr.Dataframe(label="Your Watchlist", interactive=False) wl_add_btn.click(fn=add_to_watchlist, inputs=[wl_card_id, wl_max_bid, wl_notes], outputs=[wl_status, wl_table]) wl_remove_btn.click(fn=remove_from_watchlist, inputs=[wl_remove_id], outputs=[wl_status, wl_table]) gr.Markdown("---") gr.Markdown("### Check Watchlist Prices") wl_check_btn = gr.Button("👁️ Check Watchlist Alerts", variant="primary") wl_alerts = gr.Markdown("") wl_alert_table = gr.Dataframe(label="Watchlist Status", interactive=False) wl_check_btn.click(fn=check_watchlist_alerts, outputs=[wl_alerts, wl_alert_table]) wl_refresh = gr.Button("🔄 Refresh Watchlist") wl_refresh.click(fn=get_watchlist, outputs=[wl_table]) with gr.Tab("⚙️ Config"): gr.Markdown(f""" ### Settings - **Season:** {SEASON} - **Rate Limit:** {RATE_LIMIT_SECONDS}s between API requests - **User-Agent:** `{USER_AGENT}` - **Max Scan ID:** 10,000 ### Price Alert Thresholds | Rarity | Alert if Ask Below | |---|---| | Legendary | {THRESHOLDS['legendary']} | | Epic | {THRESHOLDS['epic']} | | Ultra-Rare | {THRESHOLDS['ultra-rare']} | ### Smart Trading Score Factors | Factor | Max Points | Description | |---|---|---| | Price vs MV | 25 | Lower ask relative to market value = higher score | | Price Trend | 15 | Dropping prices = buy opportunity | | Spread | 10 | Larger ask-bid gap = negotiation room | | Rarity | 15 | Legendary > Epic > Ultra-Rare | | CTE Bonus | 10 | Dead nation = scarcer supply | | Historical | 5 | Famous NS names | ### Historical Names ({len(HISTORICAL_NAMES)} total) {', '.join(HISTORICAL_NAMES)} ### Compliance ⚠️ This tool is **read-only**. No automated trades, buys, or bids are executed. All purchases must be completed manually via the generated URL links. """) app.launch()