feat: add Budget Mode with price cache infrastructure and stale price warnings (#61)

This commit is contained in:
mwisnowski 2026-03-23 16:38:18 -07:00 committed by GitHub
parent 1aa8e4d7e8
commit 8643b72108
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 6976 additions and 2753 deletions

View file

@ -0,0 +1,692 @@
"""Budget evaluation service for deck cost analysis.
Evaluates a deck against a budget constraint, identifies over-budget cards,
finds cheaper alternatives (same tags + color identity, lower price), and
produces a BudgetReport with replacements, per-card breakdown, and a
pickups list for targeted acquisition.
Priority order (highest to lowest):
exclude > include > budget > bracket
Include-list cards are never auto-replaced; their cost is reported separately
as ``include_budget_overage``.
"""
from __future__ import annotations
import logging
import math
from typing import Any, Dict, List, Optional, Set
from code.web.services.base import BaseService
from code.web.services.price_service import PriceService, get_price_service
from code import logging_util
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
# Splurge tier ceilings as a fraction of the total budget.
# S = top 20 %, M = top 10 %, L = top 5 %
_TIER_FRACTIONS = {"S": 0.20, "M": 0.10, "L": 0.05}
# How many alternatives to return per card at most.
_MAX_ALTERNATIVES = 5
# Ordered broad MTG card types — first match wins for type detection.
_BROAD_TYPES = ("Land", "Creature", "Planeswalker", "Battle", "Enchantment", "Artifact", "Instant", "Sorcery")
# M8: Build stage category order and tag patterns for category spend breakdown.
CATEGORY_ORDER = ["Land", "Ramp", "Creature", "Card Draw", "Removal", "Wipe", "Protection", "Synergy", "Other"]
_CATEGORY_COLORS: Dict[str, str] = {
"Land": "#94a3b8",
"Ramp": "#34d399",
"Creature": "#fb923c",
"Card Draw": "#60a5fa",
"Removal": "#f87171",
"Wipe": "#dc2626",
"Protection": "#06b6d4",
"Synergy": "#c084fc",
"Other": "#f59e0b",
}
# Creature is handled via broad_type fallback (after tag patterns), not listed here
_CATEGORY_PATTERNS: List[tuple] = [
("Land", ["land"]),
("Ramp", ["ramp", "mana rock", "mana dork", "mana acceleration", "mana production"]),
("Card Draw", ["card draw", "draw", "card advantage", "cantrip", "looting", "cycling"]),
("Removal", ["removal", "spot removal", "bounce", "exile"]),
("Wipe", ["board wipe", "sweeper", "wrath"]),
("Protection", ["protection", "counterspell", "hexproof", "shroud", "indestructible", "ward"]),
("Synergy", ["synergy", "combo", "payoff", "enabler"]),
]
def _fmt_price_label(price: float) -> str:
"""Short x-axis label for a histogram bin boundary."""
if price <= 0:
return "$0"
if price < 1.0:
return f"${price:.2f}"
if price < 10.0:
return f"${price:.1f}" if price != int(price) else f"${int(price)}"
return f"${price:.0f}"
# Basic land names excluded from price histogram (their prices are ~$0 and skew the chart)
_BASIC_LANDS: frozenset = frozenset({
"Plains", "Island", "Swamp", "Mountain", "Forest", "Wastes",
"Snow-Covered Plains", "Snow-Covered Island", "Snow-Covered Swamp",
"Snow-Covered Mountain", "Snow-Covered Forest", "Snow-Covered Wastes",
})
# Green → amber gradient for 10 histogram bins (cheap → expensive)
_HIST_COLORS = [
"#34d399", "#3fda8e", "#5de087", "#92e77a", "#c4e66a",
"#f0e05a", "#f5c840", "#f5ab2a", "#f59116", "#f59e0b",
]
def compute_price_category_breakdown(
items: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Aggregate per-card prices into build stage buckets for M8 stacked bar chart.
Each item should have: {card, price, tags: list[str], broad_type (optional)}.
Returns {"totals": {cat: float}, "colors": {cat: hex}, "total": float, "order": [...]}.
"""
totals: Dict[str, float] = {cat: 0.0 for cat in CATEGORY_ORDER}
for item in items:
price = item.get("price")
if price is None:
continue
tags_lower = [str(t).lower() for t in (item.get("tags") or [])]
broad_type = str(item.get("broad_type") or "").lower()
matched = "Other"
# Land check first — use broad_type or tag
if broad_type == "land" or any("land" in t for t in tags_lower):
matched = "Land"
else:
for cat, patterns in _CATEGORY_PATTERNS[1:]: # skip Land; Creature handled below
if any(any(p in t for p in patterns) for t in tags_lower):
matched = cat
break
else:
if broad_type == "creature":
matched = "Creature"
totals[matched] = round(totals[matched] + float(price), 2)
grand_total = round(sum(totals.values()), 2)
return {"totals": totals, "colors": _CATEGORY_COLORS, "total": grand_total, "order": CATEGORY_ORDER}
def compute_price_histogram(
items: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Compute a 10-bin price distribution histogram for M8.
Uses logarithmic bin boundaries when the price range spans >4x (typical for
MTG decks) so cheap cards are spread across multiple narrow bins rather than
all landing in bin 0. Bar heights use sqrt scaling for a quick-glance view
where even a bin with 1 card is still visibly present.
Items: list of {card, price, ...}. Cards without price are excluded.
Basic lands are excluded (their near-zero prices skew the distribution).
Returns [] if fewer than 2 priced cards.
Each entry: {label, range_min, range_max, x_label, count, pct, color, cards}.
"""
priced_items = [
item for item in items
if item.get("price") is not None and item.get("card", "") not in _BASIC_LANDS
]
prices = [float(item["price"]) for item in priced_items]
if len(prices) < 2:
return []
min_p = min(prices)
max_p = max(prices)
def _card_entry(item: Dict[str, Any]) -> Dict[str, Any]:
return {"name": item["card"], "price": float(item["price"])}
if max_p == min_p:
# All same price — single populated bin, rest empty
all_cards = sorted([_card_entry(it) for it in priced_items], key=lambda c: c["price"])
bins: List[Dict[str, Any]] = []
for i in range(10):
bins.append({
"label": f"{min_p:.2f}",
"range_min": min_p,
"range_max": max_p,
"x_label": _fmt_price_label(min_p) + "\u2013" + _fmt_price_label(max_p),
"count": len(prices) if i == 0 else 0,
"pct": 100 if i == 0 else 0,
"color": _HIST_COLORS[i],
"cards": all_cards if i == 0 else [],
})
return bins
# Choose bin boundary strategy: log-scale when range spans >4x, else linear.
# Clamp lower floor to 0.01 so log doesn't blow up on near-zero prices.
log_floor = max(min_p, 0.01)
use_log = (max_p / log_floor) > 4.0
if use_log:
log_lo = math.log(log_floor)
log_hi = math.log(max(max_p, log_floor * 1.001))
log_step = (log_hi - log_lo) / 10
boundaries = [math.exp(log_lo + i * log_step) for i in range(11)]
boundaries[0] = min(boundaries[0], min_p) # don't drop cards below float rounding
boundaries[10] = max_p # prevent exp(log(x)) != x float drift from losing last card
else:
step = (max_p - min_p) / 10
boundaries = [min_p + i * step for i in range(11)]
max_count = 0
raw_bins: List[Dict[str, Any]] = []
for i in range(10):
lo = boundaries[i]
hi = boundaries[i + 1]
if i < 9:
bin_items = [it for it in priced_items if lo <= float(it["price"]) < hi]
else:
bin_items = [it for it in priced_items if lo <= float(it["price"]) <= hi]
count = len(bin_items)
max_count = max(max_count, count)
raw_bins.append({
"label": f"{lo:.2f}~{hi:.2f}",
"range_min": round(lo, 2),
"range_max": round(hi, 2),
"x_label": _fmt_price_label(lo) + "\u2013" + _fmt_price_label(hi),
"count": count,
"color": _HIST_COLORS[i],
"cards": sorted([_card_entry(it) for it in bin_items], key=lambda c: c["price"]),
})
# Sqrt scaling: a bin with 1 card still shows ~13% height vs ~2% with linear.
# This gives a quick-glance shape without the tallest bar crushing small ones.
sqrt_denom = math.sqrt(max_count) if max_count > 0 else 1.0
for b in raw_bins:
b["pct"] = round(math.sqrt(b["count"]) * 100 / sqrt_denom)
return raw_bins
# ---------------------------------------------------------------------------
# Type aliases
# ---------------------------------------------------------------------------
# Replacement record: {original, replacement, original_price, replacement_price, price_diff}
Replacement = Dict[str, Any]
# Pickup record: {card, price, tier, priority, tags}
Pickup = Dict[str, Any]
# BudgetReport schema (see class docstring for full spec)
BudgetReport = Dict[str, Any]
class BudgetEvaluatorService(BaseService):
"""Evaluate a deck list against a budget and suggest replacements.
Requires access to a ``PriceService`` for price lookups and a card index
for tag-based alternative discovery (loaded lazily from the Parquet file).
Usage::
svc = BudgetEvaluatorService()
report = svc.evaluate_deck(
decklist=["Sol Ring", "Mana Crypt", ...],
budget_total=150.0,
mode="soft",
include_cards=["Mana Crypt"], # exempt from replacement
)
"""
def __init__(self, price_service: Optional[PriceService] = None) -> None:
super().__init__()
self._price_svc = price_service or get_price_service()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def evaluate_deck(
self,
decklist: List[str],
budget_total: float,
mode: str = "soft",
*,
card_ceiling: Optional[float] = None,
region: str = "usd",
foil: bool = False,
include_cards: Optional[List[str]] = None,
color_identity: Optional[List[str]] = None,
legacy_fail_open: bool = True,
) -> BudgetReport:
"""Evaluate deck cost versus budget and produce a BudgetReport.
Args:
decklist: Card names in the deck (one entry per card slot, no
duplicates for normal cards; include the same name once per slot
for multi-copy cards).
budget_total: Maximum total deck cost in USD (or EUR if ``region``
is ``"eur"``).
mode: ``"soft"`` advisory only; ``"hard"`` flags budget
violations but does not auto-replace.
card_ceiling: Optional per-card price cap. Cards priced above this
are flagged independently of the total.
region: Price region ``"usd"`` (default) or ``"eur"``.
foil: If ``True``, compare foil prices.
include_cards: Cards exempt from budget enforcement (never
auto-flagged for replacement).
color_identity: Commander color identity letters, e.g. ``["U","B"]``.
Used to filter alternatives so they remain legal.
legacy_fail_open: If ``True`` (default), cards with no price data
are skipped in the budget calculation rather than causing an
error.
Returns:
A ``BudgetReport`` dict with the following keys:
- ``total_price`` sum of all prices found
- ``budget_status`` ``"under"`` | ``"soft_exceeded"`` |
``"hard_exceeded"``
- ``overage`` amount over budget (0 if under)
- ``include_budget_overage`` cost from include-list cards
- ``over_budget_cards`` list of {card, price, ceiling_exceeded}
for cards above ceiling or contributing most to overage
- ``price_breakdown`` per-card {card, price, is_include,
ceiling_exceeded, stale}
- ``stale_prices`` cards whose price data may be outdated
- ``pickups_list`` priority-sorted acquisition suggestions
- ``replacements_available`` alternative cards for over-budget
slots (excludes include-list cards)
"""
self._validate(budget_total >= 0, "budget_total must be non-negative")
self._validate(mode in ("soft", "hard"), "mode must be 'soft' or 'hard'")
if card_ceiling is not None:
self._validate(card_ceiling >= 0, "card_ceiling must be non-negative")
include_set: Set[str] = {c.lower().strip() for c in (include_cards or [])}
names = [n.strip() for n in decklist if n.strip()]
prices = self._price_svc.get_prices_batch(names, region=region, foil=foil)
total_price = 0.0
include_overage = 0.0
breakdown: List[Dict[str, Any]] = []
over_budget_cards: List[Dict[str, Any]] = []
stale: List[str] = []
for card in names:
price = prices.get(card)
is_include = card.lower().strip() in include_set
if price is None:
if not legacy_fail_open:
raise ValueError(f"No price data for '{card}' and legacy_fail_open=False")
stale.append(card)
price_used = 0.0
else:
price_used = price
ceil_exceeded = card_ceiling is not None and price_used > card_ceiling
total_price += price_used
if is_include:
include_overage += price_used
breakdown.append({
"card": card,
"price": price_used if price is not None else None,
"is_include": is_include,
"ceiling_exceeded": ceil_exceeded,
})
if ceil_exceeded and not is_include:
over_budget_cards.append({
"card": card,
"price": price_used,
"ceiling_exceeded": True,
})
overage = max(0.0, total_price - budget_total)
if overage == 0.0:
status = "under"
elif mode == "hard":
status = "hard_exceeded"
else:
status = "soft_exceeded"
# Compute replacements for over-budget / ceiling-exceeded cards
replacements = self._find_replacements(
over_budget_cards=over_budget_cards,
all_prices=prices,
include_set=include_set,
card_ceiling=card_ceiling,
region=region,
foil=foil,
color_identity=color_identity,
)
# Build pickups list from cards not in deck, sorted by priority tier
pickups = self._build_pickups_list(
decklist=names,
region=region,
foil=foil,
budget_remaining=max(0.0, budget_total - total_price),
color_identity=color_identity,
)
return {
"total_price": round(total_price, 2),
"budget_status": status,
"overage": round(overage, 2),
"include_budget_overage": round(include_overage, 2),
"over_budget_cards": over_budget_cards,
"price_breakdown": breakdown,
"stale_prices": stale,
"pickups_list": pickups,
"replacements_available": replacements,
}
def find_cheaper_alternatives(
self,
card_name: str,
max_price: float,
*,
region: str = "usd",
foil: bool = False,
color_identity: Optional[List[str]] = None,
tags: Optional[List[str]] = None,
require_type: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Find cards that share tags with *card_name* and cost ≤ *max_price*.
Args:
card_name: The card to find alternatives for.
max_price: Maximum price for alternatives in the given region.
region: Price region.
foil: If ``True``, compare foil prices.
color_identity: If given, filter to cards legal in this identity.
tags: Tags to use for matching (skips lookup if provided).
require_type: If given, only return cards whose type_line contains
this string (e.g. "Land", "Creature"). Auto-detected from the
card index when not provided.
Returns:
List of dicts ``{name, price, tags, shared_tags}`` sorted by most
shared tags descending then price ascending, capped at
``_MAX_ALTERNATIVES``.
"""
lookup_tags = tags or self._get_card_tags(card_name)
if not lookup_tags:
return []
# Determine the broad card type for like-for-like filtering.
source_type = require_type or self._get_card_broad_type(card_name)
candidates: Dict[str, Dict[str, Any]] = {} # name → candidate dict
try:
from code.web.services.card_index import get_tag_pool, maybe_build_index
maybe_build_index()
ci_set: Optional[Set[str]] = (
{c.upper() for c in color_identity} if color_identity else None
)
for tag in lookup_tags:
for card in get_tag_pool(tag):
name = card.get("name", "")
if not name or name.lower() == card_name.lower():
continue
# Like-for-like type filter (Land→Land, Creature→Creature, etc.)
if source_type:
type_line = card.get("type_line", "")
if source_type not in type_line:
continue
# Color identity check
if ci_set is not None:
card_colors = set(card.get("color_identity_list", []))
if card_colors and not card_colors.issubset(ci_set):
continue
if name not in candidates:
candidates[name] = {
"name": name,
"tags": card.get("tags", []),
"shared_tags": set(),
}
candidates[name]["shared_tags"].add(tag)
except Exception as exc:
logger.warning("Card index unavailable for alternatives: %s", exc)
return []
if not candidates:
return []
# Batch price lookup for all candidates
candidate_names = list(candidates.keys())
prices = self._price_svc.get_prices_batch(candidate_names, region=region, foil=foil)
results = []
for name, info in candidates.items():
price = prices.get(name)
if price is None or price > max_price:
continue
results.append({
"name": name,
"price": round(price, 2),
"tags": info["tags"],
"shared_tags": sorted(info["shared_tags"]),
})
# Sort by most shared tags first (most role-matched), then price ascending.
results.sort(key=lambda x: (-len(x["shared_tags"]), x["price"]))
return results[:_MAX_ALTERNATIVES]
def calculate_tier_ceilings(self, total_budget: float) -> Dict[str, float]:
"""Compute splurge tier price ceilings from *total_budget*.
S-tier = up to 20 % of budget per card slot
M-tier = up to 10 %
L-tier = up to 5 %
Args:
total_budget: Total deck budget.
Returns:
Dict ``{"S": float, "M": float, "L": float}``.
"""
return {tier: round(total_budget * frac, 2) for tier, frac in _TIER_FRACTIONS.items()}
def generate_pickups_list(
self,
decklist: List[str],
budget_remaining: float,
*,
region: str = "usd",
foil: bool = False,
color_identity: Optional[List[str]] = None,
) -> List[Pickup]:
"""Generate a prioritized acquisition list of cards not in *decklist*.
Finds cards that fit the color identity, share tags with the current
deck, and cost *budget_remaining*, sorted by number of shared tags
(most synergistic first).
Args:
decklist: Current deck card names.
budget_remaining: Maximum price per card to include.
region: Price region.
foil: If ``True``, use foil prices.
color_identity: Commander color identity for legality filter.
Returns:
List of pickup dicts sorted by synergy score (shared tags count).
"""
return self._build_pickups_list(
decklist=decklist,
region=region,
foil=foil,
budget_remaining=budget_remaining,
color_identity=color_identity,
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_card_tags(self, card_name: str) -> List[str]:
"""Look up theme tags for a single card from the card index."""
try:
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
maybe_build_index()
needle = card_name.lower()
for cards in _CARD_INDEX.values():
for c in cards:
if c.get("name", "").lower() == needle:
return list(c.get("tags", []))
except Exception:
pass
return []
def _get_card_broad_type(self, card_name: str) -> Optional[str]:
"""Return the first matching broad MTG type for a card (e.g. 'Land', 'Creature')."""
try:
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
maybe_build_index()
needle = card_name.lower()
for cards in _CARD_INDEX.values():
for c in cards:
if c.get("name", "").lower() == needle:
type_line = c.get("type_line", "")
for broad in _BROAD_TYPES:
if broad in type_line:
return broad
return None
except Exception:
pass
return None
def _find_replacements(
self,
*,
over_budget_cards: List[Dict[str, Any]],
all_prices: Dict[str, Optional[float]],
include_set: Set[str],
card_ceiling: Optional[float],
region: str,
foil: bool,
color_identity: Optional[List[str]],
) -> List[Dict[str, Any]]:
"""Find cheaper alternatives for over-budget (non-include) cards."""
results = []
for entry in over_budget_cards:
card = entry["card"]
price = entry["price"]
if card.lower().strip() in include_set:
continue
max_alt_price = (card_ceiling - 0.01) if card_ceiling else max(0.0, price - 0.01)
alts = self.find_cheaper_alternatives(
card,
max_price=max_alt_price,
region=region,
foil=foil,
color_identity=color_identity,
)
if alts:
results.append({
"original": card,
"original_price": price,
"alternatives": alts,
})
return results
def _build_pickups_list(
self,
decklist: List[str],
region: str,
foil: bool,
budget_remaining: float,
color_identity: Optional[List[str]],
) -> List[Pickup]:
"""Build a ranked pickups list using shared-tag scoring."""
if budget_remaining <= 0:
return []
deck_set = {n.lower() for n in decklist}
# Collect all unique tags from the current deck
deck_tags: Set[str] = set()
try:
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
maybe_build_index()
for name in decklist:
needle = name.lower()
for cards in _CARD_INDEX.values():
for c in cards:
if c.get("name", "").lower() == needle:
deck_tags.update(c.get("tags", []))
break
if not deck_tags:
return []
ci_set: Optional[Set[str]] = (
{c.upper() for c in color_identity} if color_identity else None
)
# Score candidate cards not in deck by shared tags
candidates: Dict[str, Dict[str, Any]] = {}
for tag in deck_tags:
for card in _CARD_INDEX.get(tag, []):
name = card.get("name", "")
if not name or name.lower() in deck_set:
continue
if ci_set:
card_colors = set(card.get("color_identity_list", []))
if card_colors and not card_colors.issubset(ci_set):
continue
if name not in candidates:
candidates[name] = {"name": name, "tags": card.get("tags", []), "score": 0}
candidates[name]["score"] += 1
except Exception as exc:
logger.warning("Could not build pickups list: %s", exc)
return []
if not candidates:
return []
# Price filter
top_candidates = sorted(candidates.values(), key=lambda x: x["score"], reverse=True)[:200]
names = [c["name"] for c in top_candidates]
prices = self._price_svc.get_prices_batch(names, region=region, foil=foil)
tier_ceilings = self.calculate_tier_ceilings(budget_remaining)
pickups: List[Pickup] = []
for c in top_candidates:
price = prices.get(c["name"])
if price is None or price > budget_remaining:
continue
tier = "L"
if price <= tier_ceilings["L"]:
tier = "L"
if price <= tier_ceilings["M"]:
tier = "M"
if price <= tier_ceilings["S"]:
tier = "S"
pickups.append({
"card": c["name"],
"price": round(price, 2),
"tier": tier,
"priority": c["score"],
"tags": c["tags"],
})
# Sort: most synergistic first, then cheapest
pickups.sort(key=lambda x: (-x["priority"], x["price"]))
return pickups[:50]

View file

@ -108,6 +108,26 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i
"allow_illegal": bool(sess.get("allow_illegal")),
"fuzzy_matching": bool(sess.get("fuzzy_matching", True)),
}
ctx["budget_config"] = sess.get("budget_config") or {}
ctx["build_id"] = str(sess.get("build_id") or "0")
try:
from ..services.price_service import get_price_service
from code.settings import PRICE_STALE_WARNING_HOURS
_svc = get_price_service()
_svc._ensure_loaded()
ctx["price_cache"] = _svc._cache # keyed by lowercase card name → {usd, usd_foil, ...}
_stale = _svc.get_stale_cards(PRICE_STALE_WARNING_HOURS) if PRICE_STALE_WARNING_HOURS > 0 else set()
# Suppress per-card noise when >50% of the priced pool is stale
if _stale and len(_stale) > len(_svc._cache) * 0.5:
ctx["stale_prices"] = set()
ctx["stale_prices_global"] = True
else:
ctx["stale_prices"] = _stale
ctx["stale_prices_global"] = False
except Exception:
ctx["price_cache"] = {}
ctx["stale_prices"] = set()
ctx["stale_prices_global"] = False
return ctx
@ -168,6 +188,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s
partner_feature_enabled=partner_enabled,
secondary_commander=secondary_commander,
background_commander=background_choice,
budget_config=sess.get("budget_config"),
)
if set_on_session:
sess["build_ctx"] = ctx
@ -523,9 +544,166 @@ def step5_ctx_from_result(
ctx["summary_token"] = token_val
ctx["summary_ready"] = bool(sess.get("step5_summary_ready"))
ctx["synergies"] = synergies_list
# M5: Post-build budget review (only when build is done and budget mode active)
if done:
try:
_apply_budget_review_ctx(sess, res, ctx)
except Exception:
ctx.setdefault("over_budget_review", False)
ctx.setdefault("budget_review_visible", False)
ctx.setdefault("price_category_chart", None)
ctx.setdefault("price_histogram_chart", None)
else:
ctx.setdefault("over_budget_review", False)
ctx.setdefault("budget_review_visible", False)
ctx.setdefault("price_category_chart", None)
ctx.setdefault("price_histogram_chart", None)
return ctx
def _apply_budget_review_ctx(sess: dict, res: dict, ctx: dict) -> None:
"""M5: Compute end-of-build budget review data and inject into ctx.
Triggers when total deck cost exceeds budget_total by more than BUDGET_TOTAL_TOLERANCE.
Shows the most expensive non-include cards (contributors to total overage) with
cheaper alternatives drawn from find_cheaper_alternatives().
"""
budget_cfg = sess.get("budget_config") or {}
try:
budget_total = float(budget_cfg.get("total") or 0)
except Exception:
budget_total = 0.0
if budget_total <= 0:
ctx["over_budget_review"] = False
return
budget_mode = "soft"
try:
card_ceiling = float(budget_cfg.get("card_ceiling")) if budget_cfg.get("card_ceiling") else None
except Exception:
card_ceiling = None
include_cards = [str(c).strip() for c in (sess.get("include_cards") or []) if str(c).strip()]
# Extract card names from build summary + build name -> {type, role, tags} lookup
summary = res.get("summary") or {}
card_names: list[str] = []
card_meta: dict[str, dict] = {}
if isinstance(summary, dict):
tb = summary.get("type_breakdown") or {}
for type_key, type_cards_list in (tb.get("cards") or {}).items():
for c in type_cards_list:
name = c.get("name") if isinstance(c, dict) else None
if name:
sname = str(name).strip()
card_names.append(sname)
card_meta[sname] = {
"type": type_key,
"role": str(c.get("role") or "").strip(),
"tags": list(c.get("tags") or []),
}
if not card_names:
ctx["over_budget_review"] = False
return
# Persist snapshot for the swap route
sess["budget_deck_snapshot"] = list(card_names)
color_identity: list[str] | None = None
try:
ci_raw = sess.get("color_identity")
if ci_raw and isinstance(ci_raw, list):
color_identity = [str(c).upper() for c in ci_raw]
except Exception:
pass
from ..services.budget_evaluator import BudgetEvaluatorService
svc = BudgetEvaluatorService()
report = svc.evaluate_deck(
card_names,
budget_total,
mode=budget_mode,
card_ceiling=card_ceiling,
include_cards=include_cards,
color_identity=color_identity,
)
total_price = float(report.get("total_price", 0.0))
tolerance = bc.BUDGET_TOTAL_TOLERANCE
over_budget_review = total_price > budget_total * (1.0 + tolerance)
ctx["budget_review_visible"] = over_budget_review # only shown when deck total exceeds tolerance
ctx["over_budget_review"] = over_budget_review
ctx["budget_review_total"] = round(total_price, 2)
ctx["budget_review_cap"] = round(budget_total, 2)
ctx["budget_overage_pct"] = 0.0
ctx["over_budget_cards"] = []
overage = total_price - budget_total
if over_budget_review:
ctx["budget_overage_pct"] = round(overage / budget_total * 100, 1)
include_set = {c.lower().strip() for c in include_cards}
# Use price_breakdown sorted by price desc — most expensive cards are the biggest
# contributors to the total overage regardless of any per-card ceiling.
breakdown = report.get("price_breakdown") or []
priced = sorted(
[e for e in breakdown
if not e.get("is_include") and (e.get("price") is not None) and float(e.get("price") or 0.0) > 0],
key=lambda x: -float(x.get("price") or 0.0),
)
over_cards_out: list[dict] = []
for entry in priced[:6]:
name = entry.get("card", "")
price = float(entry.get("price") or 0.0)
is_include = name.lower().strip() in include_set
meta = card_meta.get(name, {})
try:
# Any cheaper alternative reduces the total; use price - 0.01 as the ceiling
alts_raw = svc.find_cheaper_alternatives(
name,
max_price=max(0.0, price - 0.01),
region="usd",
color_identity=color_identity,
tags=meta.get("tags") or None,
require_type=meta.get("type") or None,
)
except Exception:
alts_raw = []
over_cards_out.append({
"name": name,
"price": price,
"swap_disabled": is_include,
"card_type": meta.get("type", ""),
"card_role": meta.get("role", ""),
"card_tags": meta.get("tags", []),
"alternatives": [
{"name": a["name"], "price": a.get("price"), "shared_tags": a.get("shared_tags", [])}
for a in alts_raw[:3]
],
})
ctx["over_budget_cards"] = over_cards_out
# M8: Price charts — category breakdown + histogram
try:
from ..services.budget_evaluator import compute_price_category_breakdown, compute_price_histogram
_breakdown = report.get("price_breakdown") or []
_enriched = [
{**item, "tags": card_meta.get(item.get("card", ""), {}).get("tags", [])}
for item in _breakdown
]
ctx["price_category_chart"] = compute_price_category_breakdown(_enriched)
ctx["price_histogram_chart"] = compute_price_histogram(_breakdown)
except Exception:
ctx.setdefault("price_category_chart", None)
ctx.setdefault("price_histogram_chart", None)
def step5_error_ctx(
request: Request,
sess: dict,

View file

@ -82,6 +82,7 @@ def maybe_build_index() -> None:
color_id = str(row.get(COLOR_IDENTITY_COL) or "").strip()
mana_cost = str(row.get(MANA_COST_COL) or "").strip()
rarity = _normalize_rarity(str(row.get(RARITY_COL) or ""))
type_line = str(row.get("type") or row.get("type_line") or "").strip()
for tg in tags:
if not tg:
@ -92,6 +93,7 @@ def maybe_build_index() -> None:
"tags": tags,
"mana_cost": mana_cost,
"rarity": rarity,
"type_line": type_line,
"color_identity_list": [c.strip() for c in color_id.split(',') if c.strip()],
"pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}],
})

View file

@ -2037,6 +2037,9 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
budget_cfg = getattr(b, 'budget_config', None)
if isinstance(budget_cfg, dict) and budget_cfg.get('total'):
meta['budget_config'] = budget_cfg
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)
@ -2516,6 +2519,7 @@ def start_build_ctx(
partner_feature_enabled: bool | None = None,
secondary_commander: str | None = None,
background_commander: str | None = None,
budget_config: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
logs: List[str] = []
@ -2667,6 +2671,15 @@ def start_build_ctx(
except Exception:
pass
# Stages
try:
b.budget_config = dict(budget_config) if isinstance(budget_config, dict) else None
except Exception:
b.budget_config = None
# M4: Apply budget pool filter now that budget_config is set
try:
b.apply_budget_pool_filter()
except Exception:
pass
stages = _make_stages(b)
ctx = {
"builder": b,
@ -2867,6 +2880,9 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
budget_cfg = getattr(b, 'budget_config', None)
if isinstance(budget_cfg, dict) and budget_cfg.get('total'):
meta['budget_config'] = budget_cfg
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)
@ -3718,6 +3734,9 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
custom_base = None
if isinstance(custom_base, str) and custom_base.strip():
meta["name"] = custom_base.strip()
budget_cfg = getattr(b, 'budget_config', None)
if isinstance(budget_cfg, dict) and budget_cfg.get('total'):
meta['budget_config'] = budget_cfg
payload = {"meta": meta, "summary": summary}
with open(sidecar, 'w', encoding='utf-8') as f:
_json.dump(payload, f, ensure_ascii=False, indent=2)

View file

@ -0,0 +1,504 @@
"""Price service for card price lookups.
Loads prices from the local Scryfall bulk data file (one card per line),
caches results in a compact JSON file under card_files/, and provides
thread-safe batch lookups for budget evaluation.
Cache strategy:
- On first access, load from prices_cache.json if < TTL hours old
- If cache is stale or missing, rebuild by streaming the bulk data file
- In-memory dict (normalized lowercase key) is kept for fast lookups
- Background refresh available via refresh_cache_background()
"""
from __future__ import annotations
import json
import os
import threading
import time
from typing import Any, Callable, Dict, List, Optional
from code.path_util import card_files_dir, card_files_raw_dir
from code.web.services.base import BaseService
from code import logging_util
logger = logging_util.logging.getLogger(__name__)
logger.setLevel(logging_util.LOG_LEVEL)
logger.addHandler(logging_util.file_handler)
logger.addHandler(logging_util.stream_handler)
_CACHE_TTL_SECONDS = 86400 # 24 hours
_BULK_DATA_FILENAME = "scryfall_bulk_data.json"
_PRICE_CACHE_FILENAME = "prices_cache.json"
class PriceService(BaseService):
"""Service for card price lookups backed by Scryfall bulk data.
Reads prices from the local Scryfall bulk data file that the setup
pipeline already downloads. A compact JSON cache is written to
card_files/ so subsequent startups load instantly without re-scanning
the 500 MB bulk file.
All public methods are thread-safe.
"""
def __init__(
self,
*,
bulk_data_path: Optional[str] = None,
cache_path: Optional[str] = None,
cache_ttl: int = _CACHE_TTL_SECONDS,
) -> None:
super().__init__()
self._bulk_path: str = bulk_data_path or os.path.join(
card_files_raw_dir(), _BULK_DATA_FILENAME
)
self._cache_path: str = cache_path or os.path.join(
card_files_dir(), _PRICE_CACHE_FILENAME
)
self._ttl: int = cache_ttl
# {normalized_card_name: {"usd": float, "usd_foil": float, "eur": float, "eur_foil": float}}
self._cache: Dict[str, Dict[str, float]] = {}
self._lock = threading.RLock()
self._loaded = False
self._last_refresh: float = 0.0
self._hit_count = 0
self._miss_count = 0
self._refresh_thread: Optional[threading.Thread] = None
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_price(
self,
card_name: str,
region: str = "usd",
foil: bool = False,
) -> Optional[float]:
"""Return the price for *card_name* or ``None`` if not found.
Args:
card_name: Card name (case-insensitive).
region: Price region - ``"usd"`` or ``"eur"``.
foil: If ``True`` return foil price.
Returns:
Price as float or ``None`` when missing / card unknown.
"""
self._ensure_loaded()
price_key = region + ("_foil" if foil else "")
entry = self._cache.get(card_name.lower().strip())
self.queue_lazy_refresh(card_name)
with self._lock:
if entry is not None:
self._hit_count += 1
return entry.get(price_key)
self._miss_count += 1
return None
def get_prices_batch(
self,
card_names: List[str],
region: str = "usd",
foil: bool = False,
) -> Dict[str, Optional[float]]:
"""Return a mapping of card name → price for all requested cards.
Missing cards map to ``None``. Preserves input ordering and
original case in the returned keys.
Args:
card_names: List of card names to look up.
region: Price region - ``"usd"`` or ``"eur"``.
foil: If ``True`` return foil prices.
Returns:
Dict mapping each input name to its price (or ``None``).
"""
self._ensure_loaded()
price_key = region + ("_foil" if foil else "")
result: Dict[str, Optional[float]] = {}
hits = 0
misses = 0
for name in card_names:
entry = self._cache.get(name.lower().strip())
if entry is not None:
result[name] = entry.get(price_key)
hits += 1
else:
result[name] = None
misses += 1
with self._lock:
self._hit_count += hits
self._miss_count += misses
return result
def cache_stats(self) -> Dict[str, Any]:
"""Return telemetry snapshot about cache performance.
Returns:
Dict with ``total_entries``, ``hit_count``, ``miss_count``,
``hit_rate``, ``last_refresh``, ``loaded``, ``cache_path``.
"""
self._ensure_loaded()
with self._lock:
total = self._hit_count + self._miss_count
return {
"total_entries": len(self._cache),
"hit_count": self._hit_count,
"miss_count": self._miss_count,
"hit_rate": (self._hit_count / total) if total > 0 else 0.0,
"last_refresh": self._last_refresh,
"loaded": self._loaded,
"cache_path": self._cache_path,
"bulk_data_available": os.path.exists(self._bulk_path),
}
def refresh_cache_background(self) -> None:
"""Spawn a daemon thread to rebuild the price cache asynchronously.
If a refresh is already in progress, this call is a no-op.
"""
with self._lock:
if self._refresh_thread and self._refresh_thread.is_alive():
logger.debug("Price cache background refresh already running")
return
t = threading.Thread(
target=self._rebuild_cache,
daemon=True,
name="price-cache-refresh",
)
self._refresh_thread = t
t.start()
def get_cache_built_at(self) -> str | None:
"""Return a human-readable price cache build date, or None if unavailable."""
try:
if os.path.exists(self._cache_path):
import datetime
built = os.path.getmtime(self._cache_path)
if built:
dt = datetime.datetime.fromtimestamp(built, tz=datetime.timezone.utc)
return dt.strftime("%B %d, %Y")
except Exception:
pass
return None
def start_daily_refresh(self, hour: int = 1, on_after_rebuild: Optional[Callable] = None) -> None:
"""Start a daemon thread that rebuilds prices once daily at *hour* UTC.
Checks every 30 minutes. Safe to call multiple times only one
scheduler thread will be started.
Args:
hour: UTC hour (023) at which to run the nightly rebuild.
on_after_rebuild: Optional callable invoked after each successful
rebuild (e.g., to update the parquet files).
"""
with self._lock:
if getattr(self, "_daily_thread", None) and self._daily_thread.is_alive(): # type: ignore[attr-defined]
return
def _loop() -> None:
import datetime
last_date: "datetime.date | None" = None
while True:
try:
now = datetime.datetime.now(tz=datetime.timezone.utc)
today = now.date()
if now.hour >= hour and today != last_date:
logger.info("Scheduled price refresh running (daily at %02d:00 UTC) …", hour)
self._rebuild_cache()
last_date = today
if on_after_rebuild:
try:
on_after_rebuild()
except Exception as exc:
logger.error("on_after_rebuild callback failed: %s", exc)
logger.info("Scheduled price refresh complete.")
except Exception as exc:
logger.error("Daily price refresh error: %s", exc)
time.sleep(1800)
t = threading.Thread(target=_loop, daemon=True, name="price-daily-refresh")
self._daily_thread = t # type: ignore[attr-defined]
t.start()
logger.info("Daily price refresh scheduler started (hour=%d UTC)", hour)
def start_lazy_refresh(self, stale_days: int = 7) -> None:
"""Start a background worker that refreshes per-card prices from the
Scryfall API when they have not been updated within *stale_days* days.
Queuing: call queue_lazy_refresh(card_name) to mark a card as stale.
The worker runs every 60 seconds, processes up to 20 cards per cycle,
and respects Scryfall's 100 ms rate-limit guideline.
"""
with self._lock:
if getattr(self, "_lazy_thread", None) and self._lazy_thread.is_alive(): # type: ignore[attr-defined]
return
self._lazy_stale_seconds: float = stale_days * 86400
self._lazy_queue: set[str] = set()
self._lazy_ts: dict[str, float] = self._load_lazy_ts()
self._lazy_lock = threading.Lock()
def _worker() -> None:
while True:
try:
time.sleep(60)
with self._lazy_lock:
batch = list(self._lazy_queue)[:20]
self._lazy_queue -= set(batch)
if batch:
self._fetch_lazy_batch(batch)
except Exception as exc:
logger.error("Lazy price refresh error: %s", exc)
t = threading.Thread(target=_worker, daemon=True, name="price-lazy-refresh")
self._lazy_thread = t # type: ignore[attr-defined]
t.start()
logger.info("Lazy price refresh worker started (stale_days=%d)", stale_days)
def queue_lazy_refresh(self, card_name: str) -> None:
"""Mark *card_name* for a lazy per-card price update if its cached
price is stale or missing. No-op when lazy mode is not enabled."""
if not hasattr(self, "_lazy_queue"):
return
key = card_name.lower().strip()
ts = self._lazy_ts.get(key)
if ts is None or (time.time() - ts) > self._lazy_stale_seconds:
with self._lazy_lock:
self._lazy_queue.add(card_name.strip())
def _fetch_lazy_batch(self, names: list[str]) -> None:
"""Fetch fresh prices for *names* from the Scryfall named-card API."""
import urllib.request as _urllib
import urllib.parse as _urlparse
now = time.time()
updated: dict[str, float] = {}
for name in names:
try:
url = "https://api.scryfall.com/cards/named?" + _urlparse.urlencode({"exact": name, "format": "json"})
req = _urllib.Request(url, headers={"User-Agent": "MTGPythonDeckbuilder/1.0"})
with _urllib.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
raw_prices: dict = data.get("prices") or {}
entry = self._extract_prices(raw_prices)
if entry:
key = name.lower()
with self._lock:
self._cache[key] = entry
updated[key] = now
logger.debug("Lazy refresh: %s → $%.2f", name, entry.get("usd", 0))
except Exception as exc:
logger.debug("Lazy price fetch skipped for %s: %s", name, exc)
time.sleep(0.1) # 100 ms — Scryfall rate-limit guideline
if updated:
self._lazy_ts.update(updated)
self._save_lazy_ts()
# Also persist the updated in-memory cache to the JSON cache file
try:
self._persist_cache_snapshot()
except Exception:
pass
def _load_lazy_ts(self) -> dict[str, float]:
"""Load per-card timestamps from companion file."""
ts_path = self._cache_path + ".ts"
try:
if os.path.exists(ts_path):
with open(ts_path, "r", encoding="utf-8") as fh:
return json.load(fh)
except Exception:
pass
return {}
def _save_lazy_ts(self) -> None:
"""Atomically persist per-card timestamps."""
ts_path = self._cache_path + ".ts"
tmp = ts_path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self._lazy_ts, fh, separators=(",", ":"))
os.replace(tmp, ts_path)
except Exception as exc:
logger.warning("Failed to save lazy timestamps: %s", exc)
def get_stale_cards(self, threshold_hours: int = 24) -> set[str]:
"""Return the set of card names whose cached price is older than *threshold_hours*.
Uses the per-card timestamp sidecar (``prices_cache.json.ts``). If the
sidecar is absent, all priced cards are considered stale (safe default).
Returns an empty set when *threshold_hours* is 0 (warnings disabled).
Card names are returned in their original (display-name) casing as stored
in ``self._cache``.
"""
import time as _t
if threshold_hours <= 0:
return set()
cutoff = _t.time() - threshold_hours * 3600
with self._lock:
ts_map: dict[str, float] = dict(self._lazy_ts)
cached_keys: set[str] = set(self._cache.keys())
stale: set[str] = set()
for key in cached_keys:
ts = ts_map.get(key)
if ts is None or ts < cutoff:
stale.add(key)
return stale
def _persist_cache_snapshot(self) -> None:
"""Write the current in-memory cache to the JSON cache file (atomic)."""
import time as _t
with self._lock:
snapshot = dict(self._cache)
built = self._last_refresh or _t.time()
cache_data = {"prices": snapshot, "built_at": built}
tmp_path = self._cache_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(cache_data, fh, separators=(",", ":"))
os.replace(tmp_path, self._cache_path)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _ensure_loaded(self) -> None:
"""Lazy-load the price cache on first access (double-checked lock)."""
if self._loaded:
return
with self._lock:
if self._loaded:
return
self._load_or_rebuild()
self._loaded = True
def _load_or_rebuild(self) -> None:
"""Load from JSON cache if fresh; otherwise rebuild from bulk data."""
if os.path.exists(self._cache_path):
try:
age = time.time() - os.path.getmtime(self._cache_path)
if age < self._ttl:
self._load_from_cache_file()
logger.info(
"Loaded %d prices from cache (age %.1fh)",
len(self._cache),
age / 3600,
)
return
logger.info("Price cache stale (%.1fh old), rebuilding", age / 3600)
except Exception as exc:
logger.warning("Price cache unreadable, rebuilding: %s", exc)
self._rebuild_cache()
def _load_from_cache_file(self) -> None:
"""Deserialize the compact prices cache JSON into memory."""
with open(self._cache_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
self._cache = data.get("prices", {})
self._last_refresh = data.get("built_at", 0.0)
def _rebuild_cache(self) -> None:
"""Stream the Scryfall bulk data file and extract prices.
Writes a compact cache JSON then swaps the in-memory dict.
Uses an atomic rename so concurrent readers see a complete file.
"""
if not os.path.exists(self._bulk_path):
logger.warning("Scryfall bulk data not found at %s", self._bulk_path)
return
logger.info("Building price cache from %s ...", self._bulk_path)
new_cache: Dict[str, Dict[str, float]] = {}
built_at = time.time()
try:
with open(self._bulk_path, "r", encoding="utf-8") as fh:
for raw_line in fh:
line = raw_line.strip().rstrip(",")
if not line or line in ("[", "]"):
continue
try:
card = json.loads(line)
except json.JSONDecodeError:
continue
name: str = card.get("name", "")
prices: Dict[str, Any] = card.get("prices") or {}
if not name:
continue
entry = self._extract_prices(prices)
if not entry:
continue
# Index by both the combined name and each face name
names_to_index = [name]
if " // " in name:
names_to_index += [part.strip() for part in name.split(" // ")]
for idx_name in names_to_index:
key = idx_name.lower()
existing = new_cache.get(key)
# Prefer cheapest non-foil USD price across printings
new_usd = entry.get("usd", 9999.0)
if existing is None or new_usd < existing.get("usd", 9999.0):
new_cache[key] = entry
except Exception as exc:
logger.error("Failed to parse bulk data: %s", exc)
return
# Write compact cache atomically
try:
cache_data = {"prices": new_cache, "built_at": built_at}
tmp_path = self._cache_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(cache_data, fh, separators=(",", ":"))
os.replace(tmp_path, self._cache_path)
logger.info(
"Price cache written: %d cards → %s", len(new_cache), self._cache_path
)
except Exception as exc:
logger.error("Failed to write price cache: %s", exc)
with self._lock:
self._cache = new_cache
self._last_refresh = built_at
# Stamp all keys as fresh so get_stale_cards() reflects the rebuild
for key in new_cache:
self._lazy_ts[key] = built_at
self._save_lazy_ts()
@staticmethod
def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]:
"""Convert raw Scryfall prices dict to {region_key: float} entries."""
result: Dict[str, float] = {}
for key in ("usd", "usd_foil", "eur", "eur_foil"):
raw = prices.get(key)
if raw is not None and raw != "":
try:
result[key] = float(raw)
except (ValueError, TypeError):
pass
return result
# ---------------------------------------------------------------------------
# Module-level singleton (lazy)
# ---------------------------------------------------------------------------
_INSTANCE: Optional[PriceService] = None
_INSTANCE_LOCK = threading.Lock()
def get_price_service() -> PriceService:
"""Return the shared PriceService singleton, creating it on first call."""
global _INSTANCE
if _INSTANCE is None:
with _INSTANCE_LOCK:
if _INSTANCE is None:
_INSTANCE = PriceService()
return _INSTANCE