ns-card-tracker / app.py
davidpcm's picture
Upload folder using huggingface_hub
4f5f0d9 verified
Raw
History Blame Contribute Delete
23.9 kB
"""
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()