← Back
"""
Resolution tracker — fetches actual weather data and resolves trades.

After a market's date passes:
1. Fetch actual temperature from Open-Meteo Historical API
2. Determine outcome (YES/NO) for each bracket
3. Calculate P&L for each trade
"""
import time
import requests
from datetime import datetime, timedelta, timezone
from loguru import logger
from config import CITIES_BY_NAME, RESOLVE_MIN_HOURS, CLOB_API_URL, TRADE_PROXY
import db


# Open-Meteo Historical Weather API (free, no key)
HISTORICAL_URL = "https://archive-api.open-meteo.com/v1/archive"


STALE_ORDER_MINUTES = 30  # Cancel unfilled orders older than this


def _check_pending_fills(conn):
    """
    Check pending orders and update status to 'filled' or 'cancelled'.
    Uses CLOB API to verify order fill status.
    Also cancels stale orders older than STALE_ORDER_MINUTES to free capital.
    """
    pending = conn.execute("""
        SELECT t.id, t.order_id, t.market_id, t.side, t.price, t.size, t.status, t.created_at
        FROM trades t
        WHERE t.status IN ('pending', 'unverified') AND t.outcome IS NULL AND t.dry_run = 0
    """).fetchall()

    if not pending:
        return

    logger.info(f"Checking {len(pending)} pending orders for fill status...")

    try:
        from executor import get_client, cancel_order
        client = get_client()
        if not client:
            logger.warning("CLOB client unavailable — cannot check fills")
            return

        now = datetime.now(timezone.utc)

        for trade in pending:
            try:
                order_id = trade["order_id"]

                # Check age for stale detection
                created_at = trade["created_at"]
                is_stale = False
                age_minutes = 0
                if created_at:
                    try:
                        created_dt = datetime.fromisoformat(created_at).replace(tzinfo=timezone.utc)
                        age_minutes = (now - created_dt).total_seconds() / 60
                        is_stale = age_minutes > STALE_ORDER_MINUTES
                    except (ValueError, TypeError):
                        logger.warning(f"Could not parse created_at={created_at!r} for trade #{trade['id']}")
                        is_stale = True  # assume stale if unparseable

                # No order_id = order never reached CLOB; cancel if stale
                if not order_id:
                    if is_stale:
                        conn.execute(
                            "UPDATE trades SET status = 'cancelled', outcome = 'cancelled', pnl = 0 WHERE id = ?",
                            (trade["id"],)
                        )
                        logger.info(f"Unverified trade #{trade['id']} has no order_id and is {age_minutes:.0f}min old → cancelled")
                    continue

                # Try to get order status from CLOB
                size_matched = 0
                try:
                    order = client.get_order(order_id)
                    if order:
                        status = order.get("status", "").lower()
                        size_matched = float(order.get("size_matched", 0) or 0)
                    else:
                        status = "not_found"
                except Exception:
                    # Order lookup failed — don't assume cancelled, recheck next cycle
                    status = "unknown"

                if status in ("matched", "filled"):
                    conn.execute(
                        "UPDATE trades SET status = 'filled', filled_at = datetime('now') WHERE id = ?",
                        (trade["id"],)
                    )
                    logger.info(f"Order {order_id[:16]} FILLED → trade #{trade['id']}")
                elif status in ("cancelled", "expired"):
                    # Check for partial fills — CLOB returns "cancelled" for remainder
                    # but size_matched > 0 means some shares were actually filled
                    if size_matched > 0:
                        actual_cost = round(size_matched * trade["price"], 4)
                        conn.execute(
                            "UPDATE trades SET status = 'filled', filled_at = datetime('now'), size = ? WHERE id = ?",
                            (actual_cost, trade["id"])
                        )
                        logger.info(
                            f"Order {order_id[:16]} partial fill: {size_matched:.2f} shares "
                            f"(${actual_cost:.2f}) → trade #{trade['id']} marked filled"
                        )
                    else:
                        conn.execute(
                            "UPDATE trades SET status = 'cancelled', outcome = 'cancelled', pnl = 0 WHERE id = ?",
                            (trade["id"],)
                        )
                        logger.info(f"Order {order_id[:16]} {status} → trade #{trade['id']} cancelled")
                elif status == "not_found":
                    # Order not found on CLOB — only cancel if stale
                    if is_stale:
                        conn.execute(
                            "UPDATE trades SET status = 'cancelled', outcome = 'cancelled', pnl = 0 WHERE id = ?",
                            (trade["id"],)
                        )
                        logger.info(f"Order {order_id[:16]} not found + stale ({age_minutes:.0f}min) → cancelled")
                    else:
                        logger.debug(f"Order {order_id[:16]} not found, not stale — keeping pending")
                elif is_stale:
                    # Order is still open but stale — cancel it to free capital
                    logger.info(f"Order {order_id[:16]} stale ({age_minutes:.0f}min old) → cancelling...")
                    if cancel_order(order_id):
                        conn.execute(
                            "UPDATE trades SET status = 'cancelled', outcome = 'cancelled', pnl = 0 WHERE id = ?",
                            (trade["id"],)
                        )
                        logger.info(f"Stale order {order_id[:16]} cancelled → trade #{trade['id']} freed")
                    else:
                        logger.warning(f"Failed to cancel stale order {order_id[:16]}")
                # else: still open and not yet stale, leave as pending

            except Exception as e:
                logger.warning(f"Fill check error for trade #{trade['id']}: {e}")

        conn.commit()

    except Exception as e:
        logger.error(f"Fill check error: {e}")


def _reconcile_cancelled_positions(conn):
    """
    Reconcile cancelled trades against on-chain CTF balances.
    If a trade was marked cancelled but we hold shares on-chain,
    it means CLOB filled the order and we missed it — fix the DB.
    Runs once per resolve cycle, only checks unique token_ids.
    """
    from executor import get_ctf_balance

    cancelled = conn.execute("""
        SELECT id, token_id, price, size, order_id
        FROM trades
        WHERE status = 'cancelled' AND order_id IS NOT NULL
          AND dry_run = 0 AND token_id IS NOT NULL
        ORDER BY id DESC
    """).fetchall()

    if not cancelled:
        return 0

    # Group by token_id — one on-chain call per unique token
    token_trades = {}
    for t in cancelled:
        tid = t["token_id"]
        if tid not in token_trades:
            token_trades[tid] = []
        token_trades[tid].append(t)

    fixed = 0
    for token_id, trades in token_trades.items():
        try:
            balance = get_ctf_balance(token_id)
            if balance <= 0:
                continue  # No shares on-chain — cancellation was real

            # We hold shares that aren't tracked — find the most recent cancelled trade
            # and mark it as filled with the on-chain balance
            trade = trades[0]  # most recent (ORDER BY id DESC)
            actual_cost = round(balance * trade["price"], 4)
            conn.execute(
                """UPDATE trades
                   SET status = 'filled', filled_at = datetime('now'),
                       size = ?, outcome = NULL
                   WHERE id = ?""",
                (actual_cost, trade["id"])
            )
            fixed += 1
            logger.warning(
                f"RECONCILE: trade #{trade['id']} was cancelled but {balance:.2f} shares "
                f"on-chain (${actual_cost:.2f}) → marked filled"
            )
            time.sleep(0.3)  # throttle RPC calls
        except Exception as e:
            logger.debug(f"Reconcile error for token {token_id[:16]}: {e}")

    if fixed > 0:
        conn.commit()
        logger.info(f"Reconciliation: fixed {fixed} ghost position(s)")

    return fixed


def cancel_stale_orders(max_age_minutes: int = None) -> int:
    """
    Cancel all stale pending/unverified orders.
    Can be called on startup or manually to free locked capital.
    Returns count of cancelled orders.
    """
    if max_age_minutes is None:
        max_age_minutes = STALE_ORDER_MINUTES

    conn = db.get_conn()
    pending = conn.execute("""
        SELECT t.id, t.order_id, t.price, t.size, t.status, t.created_at
        FROM trades t
        WHERE t.status IN ('pending', 'unverified') AND t.outcome IS NULL AND t.dry_run = 0
    """).fetchall()

    if not pending:
        return 0

    from executor import cancel_order

    now = datetime.now(timezone.utc)
    cancelled = 0

    for trade in pending:
        order_id = trade["order_id"]
        if not order_id:
            continue

        # Check age (use fromisoformat — consistent with _check_pending_fills)
        created_at = trade["created_at"]
        if created_at:
            try:
                created_dt = datetime.fromisoformat(created_at).replace(tzinfo=timezone.utc)
                age_minutes = (now - created_dt).total_seconds() / 60
                if age_minutes < max_age_minutes:
                    continue
            except (ValueError, TypeError):
                logger.warning(f"Could not parse created_at={created_at!r} for trade #{trade['id']} — assuming stale")

        logger.info(f"Cancelling stale order {order_id[:16]} (${trade['size']:.2f} @ ${trade['price']:.2f})")
        if cancel_order(order_id):
            conn.execute(
                "UPDATE trades SET status = 'cancelled', outcome = 'cancelled', pnl = 0 WHERE id = ?",
                (trade["id"],)
            )
            cancelled += 1
            logger.info(f"Cancelled stale order → trade #{trade['id']}")
        else:
            logger.warning(f"Failed to cancel order {order_id[:16]}")

    conn.commit()
    logger.info(f"Stale order cleanup: {cancelled}/{len(pending)} cancelled")
    return cancelled


def _fetch_clob_market(condition_id: str) -> dict | None:
    """
    Fetch market data from Polymarket CLOB API.
    Returns dict with 'closed', 'tokens' (with 'winner' field) or None on error.
    Tries TRADE_PROXY first, falls back to direct if proxy fails.
    """
    url = f"{CLOB_API_URL}/markets/{condition_id}"

    # Try with proxy first (if configured)
    if TRADE_PROXY:
        try:
            proxies = {"http": TRADE_PROXY, "https": TRADE_PROXY}
            resp = requests.get(url, timeout=10, proxies=proxies)
            if resp.status_code == 200:
                return resp.json()
        except Exception as e:
            logger.debug(f"CLOB proxy failed for {condition_id[:16]}: {e}")

    # Fallback: direct request (works from Canada/US)
    try:
        resp = requests.get(url, timeout=10)
        if resp.status_code != 200:
            return None
        return resp.json()
    except Exception as e:
        logger.debug(f"CLOB fetch error for {condition_id[:16]}: {e}")
        return None


def _resolve_trade_from_clob(trade: dict, clob_data: dict) -> tuple:
    """
    Determine outcome and P&L from Polymarket CLOB resolution.

    CLOB tokens have winner=True/False when market is resolved.
    Our trade has side (YES/NO) and we check if our side won.

    Returns: (outcome: 'win'|'loss'|None, pnl: float)
    """
    tokens = clob_data.get("tokens", [])
    if not tokens:
        return None, 0

    # Check all tokens have resolution data
    for token in tokens:
        winner = token.get("winner")
        if winner is None:
            return None, 0  # Market not yet resolved

    side = trade["side"]
    price = trade["price"]
    size = trade["size"]
    trade_token_id = trade.get("token_id")

    # Strategy 1: Match by token_id (most reliable)
    our_side_won = None
    if trade_token_id:
        for token in tokens:
            if token.get("token_id") == trade_token_id:
                winner = token.get("winner")
                our_side_won = winner is True or winner == "true"
                break

    # Strategy 2: Fallback to YES/NO label matching
    if our_side_won is None:
        yes_winner = None
        for token in tokens:
            if token.get("outcome", "").lower() == "yes":
                winner = token.get("winner")
                yes_winner = winner is True or winner == "true"
                break  # take the first YES token; don't let a later token overwrite it
        if yes_winner is None:
            return None, 0
        our_side_won = yes_winner if side == "YES" else not yes_winner

    # P&L: size = actual dollar cost (shares × price), shares = size / price
    # Polymarket fee: fee_rate × (price × (1-price)) per share
    PLATFORM_FEE_RATE = 0.02  # 2% standard Polymarket fee
    shares = size / price if price > 0 else 0
    est_fee = shares * PLATFORM_FEE_RATE * (price * (1.0 - price))

    if our_side_won:
        pnl = size * (1.0 - price) / price - est_fee
        outcome = "win"
    else:
        pnl = -(size + est_fee)
        outcome = "loss"

    return outcome, round(pnl, 4)


def resolve_trades():
    """
    Main resolution cycle:
    1. Check pending orders for fill status
    2. Find all unresolved trades where market date has passed
    3. Check Polymarket CLOB API for market resolution (winner field)
    4. Fallback to Open-Meteo historical weather if CLOB not resolved yet
    """
    conn = db.get_conn()

    # First: check pending orders for fill status
    _check_pending_fills(conn)

    # Second: reconcile cancelled trades vs on-chain balances
    _reconcile_cancelled_positions(conn)

    # Find ALL unresolved trades where market date is in the past (no more RESOLVE_MIN_HOURS cutoff for CLOB)
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    trades = conn.execute("""
        SELECT t.id, t.market_id, t.side, t.price, t.size, t.edge, t.edge_tier,
               t.model_prob, t.market_prob,
               m.city, m.date, m.metric, m.threshold, m.threshold_unit,
               m.operator, m.question,
               m.threshold_low, m.threshold_high
        FROM trades t
        JOIN markets m ON t.market_id = m.id
        WHERE m.date < ?
          AND t.outcome IS NULL
          AND t.status IN ('filled', 'simulated')
        ORDER BY m.city, m.date
    """, (today,)).fetchall()

    if not trades:
        logger.debug(f"No trades to resolve (today: {today})")
        return {"resolved": 0, "wins": 0, "losses": 0, "pnl": 0}

    logger.info(f"Resolving {len(trades)} trades (checking Polymarket CLOB first, fallback Open-Meteo)...")

    total_resolved = 0
    total_wins = 0
    total_losses = 0
    total_pnl = 0.0

    # Phase 1: Try CLOB API resolution (one call per unique market_id)
    clob_cache = {}  # market_id → clob_data
    clob_resolved_ids = set()
    fallback_trades = []

    for idx, trade in enumerate(trades):
        market_id = trade["market_id"]

        # Fetch CLOB data (cached per market_id)
        if market_id not in clob_cache:
            if idx > 0:
                time.sleep(0.2)  # Throttle CLOB API
            clob_cache[market_id] = _fetch_clob_market(market_id)

        clob_data = clob_cache[market_id]

        if clob_data and clob_data.get("closed"):
            outcome, pnl = _resolve_trade_from_clob(dict(trade), clob_data)
            if outcome:
                update_trade_outcome(trade["id"], outcome, pnl, actual_value=None)
                total_resolved += 1
                total_pnl += pnl
                clob_resolved_ids.add(trade["id"])
                if outcome == "win":
                    total_wins += 1
                else:
                    total_losses += 1
                logger.info(
                    f"  CLOB resolved: {trade['city']} {trade['date']} | "
                    f"{trade['side']} @ {trade['price']} → {outcome.upper()} ${pnl:+.2f} | "
                    f"{trade['question'][:50]}"
                )
            else:
                fallback_trades.append(trade)
        else:
            fallback_trades.append(trade)

    # Phase 2: Fallback to Open-Meteo for trades not resolved via CLOB
    if fallback_trades:
        # Only try Open-Meteo once the forecast day is fully over (UTC midnight after
        # the date) plus RESOLVE_MIN_HOURS, so the archive has finalized (~0.5F shift).
        # Full-datetime math avoids the off-by-one of truncating now-Nh to a date string.
        now_utc = datetime.now(timezone.utc)
        meteo_trades = []
        for t in fallback_trades:
            try:
                day_after = datetime.fromisoformat(t["date"]).replace(tzinfo=timezone.utc) + timedelta(days=1)
                if now_utc - day_after >= timedelta(hours=RESOLVE_MIN_HOURS):
                    meteo_trades.append(t)
            except Exception:
                continue

        if meteo_trades:
            logger.info(f"Fallback: {len(meteo_trades)} trades via Open-Meteo (>= {RESOLVE_MIN_HOURS}h after day end)...")

            # Group by city+date+metric → one API call per group
            groups = {}
            for t in meteo_trades:
                key = f"{t['city']}|{t['date']}|{t['metric']}"
                if key not in groups:
                    groups[key] = {
                        "city": t["city"],
                        "date": t["date"],
                        "metric": t["metric"],
                        "trades": [],
                    }
                groups[key]["trades"].append(dict(t))

            for idx, (key, group) in enumerate(groups.items()):
                city = group["city"]
                date = group["date"]
                metric = group["metric"]

                if idx > 0:
                    time.sleep(0.5)

                actual_value = fetch_actual_weather(city, date, metric)
                if actual_value is None:
                    logger.warning(f"Could not fetch actual weather for {city} on {date}")
                    continue

                logger.info(f"Actual {metric} for {city} on {date}: {actual_value:.1f}°F")
                _save_calibration_for_group(group, actual_value)

                for trade in group["trades"]:
                    outcome, pnl = resolve_single_trade(trade, actual_value)
                    if outcome:
                        update_trade_outcome(trade["id"], outcome, pnl, actual_value)
                        total_resolved += 1
                        total_pnl += pnl
                        if outcome == "win":
                            total_wins += 1
                        else:
                            total_losses += 1

    # === Auto-redeem: ONE pass. _retry_unredeemed_wins() already selects every
    # win with redeemed=0 (newly-resolved included), so the previous extra
    # _auto_redeem_resolved(trades) call only sent duplicate on-chain redeem txs.
    # (_auto_redeem_resolved is now unused dead code — left in place pending review.)
    redeemed = _retry_unredeemed_wins()

    # === Auto-wrap: convert redeemed USDC.e → pUSD for CLOB ===
    if redeemed > 0:
        from executor import wrap_usdc_to_pusd
        wrapped = wrap_usdc_to_pusd()
        if wrapped > 0:
            logger.info(f"💱 Auto-wrapped ${wrapped:.2f} USDC.e → pUSD")

    result = {
        "resolved": total_resolved,
        "wins": total_wins,
        "losses": total_losses,
        "pnl": round(total_pnl, 2),
        "win_rate": round(total_wins / total_resolved * 100, 1) if total_resolved else 0,
        "redeemed": redeemed,
    }

    logger.info(
        f"Resolution complete: {total_resolved} trades | "
        f"W:{total_wins} L:{total_losses} ({result['win_rate']}% WR) | "
        f"P&L: ${total_pnl:+.2f} | Redeemed: {redeemed}"
    )

    return result


def _auto_redeem_resolved(trades: list) -> int:
    """
    Auto-redeem resolved positions on Polymarket.
    Groups trades by condition_id to avoid duplicate redeems.
    Only redeems non-dry-run filled trades.
    """
    from executor import redeem_position

    # Collect unique condition_ids from resolved trades
    redeemed_ids = set()
    redeemed_count = 0

    for t in trades:
        market_id = t["market_id"]
        if market_id in redeemed_ids:
            continue

        # Skip unresolved, lost, or dry-run trades — only winners have tokens to redeem
        conn = db.get_conn()
        trade_row = conn.execute(
            "SELECT dry_run, token_id FROM trades WHERE market_id = ? AND outcome = 'win' AND dry_run = 0 LIMIT 1",
            (market_id,)
        ).fetchone()

        if not trade_row:
            continue  # not resolved, lost, or dry-run — nothing to redeem

        token_id = trade_row["token_id"]
        redeemed_ids.add(market_id)

        try:
            result = redeem_position(condition_id=market_id, token_id=token_id)
            if result["success"]:
                redeemed_count += 1
                _mark_redeemed(market_id)
        except Exception as e:
            logger.error(f"Redeem error for {market_id[:16]}: {e}")

        # Small delay between redeems to avoid nonce issues
        time.sleep(2)

    if redeemed_count > 0:
        logger.info(f"Auto-redeemed {redeemed_count}/{len(redeemed_ids)} positions")

    return redeemed_count


def _mark_redeemed(market_id: str):
    """Mark all trades for this market as redeemed"""
    conn = db.get_conn()
    conn.execute("UPDATE trades SET redeemed = 1 WHERE market_id = ? AND dry_run = 0", (market_id,))
    conn.commit()


def _retry_unredeemed_wins() -> int:
    """Retry redeem for past wins that failed to redeem"""
    from executor import redeem_position

    conn = db.get_conn()
    rows = conn.execute(
        "SELECT DISTINCT market_id, token_id FROM trades "
        "WHERE outcome = 'win' AND dry_run = 0 AND (redeemed IS NULL OR redeemed = 0)"
    ).fetchall()

    if not rows:
        return 0

    logger.info(f"Retrying redeem for {len(rows)} unredeemed win(s)...")
    redeemed = 0
    for row in rows:
        market_id = row["market_id"]
        token_id = row["token_id"]
        try:
            result = redeem_position(condition_id=market_id, token_id=token_id)
            if result["success"]:
                redeemed += 1
                _mark_redeemed(market_id)
        except Exception as e:
            logger.debug(f"Redeem retry failed for {market_id[:16]}: {e}")
        time.sleep(2)

    if redeemed > 0:
        logger.info(f"Retry-redeemed {redeemed}/{len(rows)} positions")
    return redeemed


def _save_calibration_for_group(group: dict, actual_value: float):
    """
    Save calibration record: forecast_value vs actual for this city+date.
    Uses the first trade's model_prob as representative.
    """
    try:
        trades = group["trades"]
        if not trades:
            return

        # Get forecast_value from forecast cache
        city = group["city"]
        date = group["date"]
        metric = group["metric"]

        cache_key = f"{city.lower()}|{date}|{metric}"
        cached = db.get_cached_forecast(cache_key, ttl_seconds=86400 * 7)  # 7 day TTL for calibration

        forecast_value = None
        source = None
        days_ahead = None

        if cached:
            forecast_value = cached.get("forecast_value")
            source = cached.get("source")
            days_ahead = cached.get("days_ahead")

        if forecast_value is None:
            logger.debug(f"No cached forecast for {city}/{date} — skipping calibration")
            return

        # Use first trade's model_prob for Brier score
        first_trade = trades[0]
        model_prob = first_trade.get("model_prob")
        side = first_trade.get("side")
        if model_prob is None:
            logger.debug(f"No model_prob for {city}/{date} — skipping calibration")
            return

        # Determine binary outcome for the first trade's bracket
        threshold = first_trade.get("threshold")
        operator = first_trade.get("operator")
        unit = first_trade.get("threshold_unit", "F")

        if threshold is None:
            return

        threshold_f = threshold * 9/5 + 32 if unit == "C" else threshold

        if operator == "gte":
            outcome_binary = 1 if actual_value >= threshold_f else 0
        elif operator == "lte":
            outcome_binary = 1 if actual_value <= threshold_f else 0
        else:
            outcome_binary = None  # Skip Brier for between/eq brackets

        # model_prob in DB is already P(YES) from forecaster — no inversion needed
        model_prob_yes = model_prob

        db.save_calibration({
            "city": city,
            "date": date,
            "metric": metric,
            "forecast_value": forecast_value,
            "actual_value": actual_value,
            "model_prob": model_prob_yes,
            "outcome_binary": outcome_binary,
            "days_ahead": days_ahead,
            "source": source,
        })

        logger.debug(
            f"Calibration saved: {city} {date} forecast={forecast_value:.1f}°F "
            f"actual={actual_value:.1f}°F error={abs(forecast_value - actual_value):.1f}°F"
        )

    except Exception as e:
        logger.error(f"Calibration save error: {e}")


def fetch_actual_weather(city: str, date: str, metric: str = "high_temp") -> float | None:
    """
    Fetch actual historical weather from Open-Meteo Archive API.
    Returns actual high/low temp in Fahrenheit.
    """
    city_lower = city.lower()
    city_data = CITIES_BY_NAME.get(city_lower)
    if not city_data:
        logger.warning(f"Unknown city for resolution: {city}")
        return None

    try:
        # daily variables based on metric
        if metric in ("high_temp",):
            daily_var = "temperature_2m_max"
        elif metric in ("low_temp",):
            daily_var = "temperature_2m_min"
        else:
            daily_var = "temperature_2m_max"

        params = {
            "latitude": city_data["lat"],
            "longitude": city_data["lon"],
            "start_date": date,
            "end_date": date,
            "daily": daily_var,
            "temperature_unit": "fahrenheit",
            "timezone": city_data["tz"],
        }

        resp = requests.get(HISTORICAL_URL, params=params, timeout=10)
        resp.raise_for_status()
        data = resp.json()

        daily = data.get("daily", {})
        values = daily.get(daily_var, [])

        if values and values[0] is not None:
            temp = float(values[0])
            # Sanity check: reject obviously wrong temperatures
            if temp < -60 or temp > 140:  # °F — no city on Earth outside this range
                logger.warning(f"Suspicious temp {temp}°F for {city}/{date} — rejecting")
                return None
            return temp

        return None

    except Exception as e:
        logger.error(f"Historical weather fetch error for {city}/{date}: {e}")
        return None


def resolve_single_trade(trade: dict, actual_value: float) -> tuple:
    """
    Determine outcome and P&L for a single trade.

    Returns: (outcome: 'win'|'loss', pnl: float)

    P&L calculation (prediction markets):
    - size = dollar cost (not shares). shares = size / price.
    - WIN:  payout = shares × $1.00, profit = payout - cost = size × (1 - price) / price
    - LOSS: lose entire cost basis = -size
    """
    threshold = trade["threshold"]
    operator = trade["operator"]
    unit = trade.get("threshold_unit", "F")
    side = trade["side"]
    price = trade["price"]
    size = trade["size"]

    # Convert threshold to °F if needed
    if unit == "C":
        threshold_f = threshold * 9/5 + 32
        low_f = (trade.get("threshold_low") or threshold) * 9/5 + 32 if trade.get("threshold_low") is not None else None
        high_f = (trade.get("threshold_high") or threshold) * 9/5 + 32 if trade.get("threshold_high") is not None else None
    else:
        threshold_f = threshold
        low_f = trade.get("threshold_low")
        high_f = trade.get("threshold_high")

    # Determine if bracket is YES (event happened)
    if operator == "gte":
        event_yes = actual_value >= threshold_f
    elif operator == "lte":
        event_yes = actual_value <= threshold_f
    elif operator in ("between", "eq"):
        # Polymarket rounds the ACTUAL temperature in the market's native unit, then
        # checks integer bounds. For Celsius markets we must round in C: comparing
        # F-converted bounds against round(actual_F) misclassifies nearly every
        # Celsius market (e.g. 12.8C rounds to 13C = YES, but 55F rounds to a NO).
        if unit == "C":
            low_c = trade.get("threshold_low") if trade.get("threshold_low") is not None else threshold - 0.5
            high_c = trade.get("threshold_high") if trade.get("threshold_high") is not None else threshold + 0.5
            actual_rounded = round((actual_value - 32) * 5 / 9)  # actual_value is in F
            event_yes = low_c <= actual_rounded <= high_c
        else:
            low = low_f if low_f is not None else threshold_f - 0.5
            high = high_f if high_f is not None else threshold_f + 0.5
            actual_rounded = round(actual_value)
            event_yes = low <= actual_rounded <= high
    else:
        # Unknown operator — skip
        return None, 0

    # Calculate P&L based on our side vs actual outcome
    # size = actual dollar cost (shares × price), shares = size / price
    # Polymarket fee: fee_rate × (price × (1-price)) per share
    # WIN:  payout = shares × $1.00, profit = payout - size - fee
    # LOSS: lose entire cost basis + fee = -(size + fee)
    PLATFORM_FEE_RATE = 0.02  # 2% standard Polymarket fee
    shares = size / price if price > 0 else 0
    est_fee = shares * PLATFORM_FEE_RATE * (price * (1.0 - price))

    if side == "YES":
        if event_yes:
            pnl = size * (1.0 - price) / price - est_fee
            outcome = "win"
        else:
            pnl = -(size + est_fee)
            outcome = "loss"
    else:  # side == "NO"
        if not event_yes:
            pnl = size * (1.0 - price) / price - est_fee
            outcome = "win"
        else:
            pnl = -(size + est_fee)
            outcome = "loss"

    return outcome, round(pnl, 4)


def update_trade_outcome(trade_id: int, outcome: str, pnl: float, actual_value: float):
    """Update trade in DB with resolution"""
    conn = db.get_conn()
    conn.execute("""
        UPDATE trades
        SET outcome = ?, pnl = ?, resolved_at = datetime('now'),
            status = ?
        WHERE id = ?
    """, (outcome, pnl, outcome, trade_id))
    conn.commit()


def get_resolution_stats() -> dict:
    """Get overall resolution statistics (live trades only — dry-run excluded)"""
    from config import STATS_START_DATE
    conn = db.get_conn()

    total = conn.execute("SELECT COUNT(*) FROM trades WHERE outcome IN ('win','loss') AND created_at >= ? AND dry_run = 0", (STATS_START_DATE,)).fetchone()[0]
    wins = conn.execute("SELECT COUNT(*) FROM trades WHERE outcome = 'win' AND created_at >= ? AND dry_run = 0", (STATS_START_DATE,)).fetchone()[0]
    losses = conn.execute("SELECT COUNT(*) FROM trades WHERE outcome = 'loss' AND created_at >= ? AND dry_run = 0", (STATS_START_DATE,)).fetchone()[0]
    total_pnl = conn.execute("SELECT COALESCE(SUM(pnl), 0) FROM trades WHERE outcome IN ('win','loss') AND created_at >= ? AND dry_run = 0", (STATS_START_DATE,)).fetchone()[0]
    unresolved = conn.execute("""
        SELECT COUNT(*) FROM trades t JOIN markets m ON t.market_id = m.id
        WHERE m.date < date('now') AND t.outcome IS NULL AND t.created_at >= ? AND t.dry_run = 0
    """, (STATS_START_DATE,)).fetchone()[0]

    # By edge tier
    by_tier = conn.execute("""
        SELECT edge_tier,
               COUNT(*) as total,
               SUM(CASE WHEN outcome='win' THEN 1 ELSE 0 END) as wins,
               COALESCE(SUM(pnl), 0) as pnl,
               AVG(edge) as avg_edge
        FROM trades WHERE outcome IN ('win','loss') AND created_at >= ? AND dry_run = 0
        GROUP BY edge_tier
    """, (STATS_START_DATE,)).fetchall()

    # By city
    by_city = conn.execute("""
        SELECT m.city,
               COUNT(*) as total,
               SUM(CASE WHEN t.outcome='win' THEN 1 ELSE 0 END) as wins,
               COALESCE(SUM(t.pnl), 0) as pnl
        FROM trades t JOIN markets m ON t.market_id = m.id
        WHERE t.outcome IN ('win','loss') AND t.created_at >= ? AND t.dry_run = 0
        GROUP BY m.city
        ORDER BY pnl DESC
        LIMIT 50
    """, (STATS_START_DATE,)).fetchall()

    # Don't close thread-local connection — it's reused

    return {
        "total": total,
        "wins": wins,
        "losses": losses,
        "win_rate": round(wins / total * 100, 1) if total else 0,
        "pnl": round(total_pnl, 2),
        "unresolved": unresolved,
        "by_tier": [
            {
                "tier": r["edge_tier"],
                "total": r["total"],
                "wins": r["wins"],
                "win_rate": round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0,
                "pnl": round(r["pnl"], 2),
                "avg_edge": round(r["avg_edge"] * 100, 1),
            }
            for r in by_tier
        ],
        "by_city": [
            {
                "city": r["city"],
                "total": r["total"],
                "wins": r["wins"],
                "win_rate": round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0,
                "pnl": round(r["pnl"], 2),
            }
            for r in by_city
        ],
    }

📜 Git History

058de34fix(audit): chunk 4 - minor robustness, display, calibration5 weeks ago
3de9313fix(audit): chunk 3 - executor robustness, scheduler, redeem, edge logic5 weeks ago
16f2ea8fix(audit): chunk 1 - critical money/correctness bugs5 weeks ago
8fca132chore: initial commit — version control setup5 weeks ago
Show last diff
Loading...