diff --git a/.gitignore b/.gitignore
index db62682..f8908f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,7 @@
__pycache__/
*.pyc
venv/
-.venv/
\ No newline at end of file
+.venv/
+.DS_Store
+logs/
+charts/
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b886d17
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,23 @@
+# PolyBot Changelog
+
+Standard sections: **Strategy** (parameter/model/filter changes), **Bug fixes** (things that were broken), **Dashboard** (visualization/UI), **Infrastructure** (refactoring, deployment, docs, tooling). Sections omitted when empty.
+
+---
+
+## v16 — Data Collection Refactor
+
+### Dashboard
+- **Interactive dashboard**: `dashboard.py` — self-contained HTTP server (no external deps beyond stdlib) with Chart.js frontend. Equity curve, win rate by version, P&L waterfall, skipped signal analysis, recent trades table. Version filter buttons. Run with `python dashboard.py`.
+- **Trade trajectory drill-down**: Click any trade in the recent trades table to see 4 trajectory charts (BTC delta %, model probability, unrealized P&L, sell price) from hold_ticks.csv data.
+- **Dynamic version colors**: Dashboard auto-assigns colors to any version string. Supports date-based versions (e.g. `v2026.3.26`) alongside legacy `v13`/`v14`/`v15`.
+
+### Infrastructure
+- **TradeRecord dataclass**: Replaced dict-based `_current_trade` in tracker.py with a typed `TradeRecord` dataclass. Entry and hold fields are populated during the trade lifecycle; exit and resolution fields are computed at close.
+- **Centralized profit computation**: `close_trade()` computes profit for all 5 resolution paths (exited, market_price, claim_sell, balance_check, binance_fallback). Bot.py no longer calculates profit — it uses the returned value from `close_trade()`.
+- **Lifecycle API rename**: `log_trade_entry()` → `open_trade()`, `log_trade_resolve()` → `close_trade()`. Clearer intent, matches the open/update/close lifecycle pattern.
+- **Dead code removed**: `log_trade_exit()` removed from tracker.py (never called from bot.py).
+- **Hold trajectory logging**: New `logs/hold_ticks.csv` records every position-check tick (~3s intervals) with BTC price, model probability, sell price, and unrealized P&L. Enables per-trade trajectory analysis and reversion tracking.
+- **Pending buy logging fix**: Late-fill trades detected at window boundary now call `open_trade()` so they appear in trades.csv.
+- **Dashboard guide**: Added `DASHBOARD_GUIDE.md` — explains how to read every chart and metric for filter tuning decisions.
+- **Date-based versioning**: `BOT_VERSION` changed from integer (`15`) to date string (`"2026.3.26"`). Version recorded in trades.csv, displayed as `v2026.3.26` in dashboard.
+- **Gitignore**: Added `logs/`, `charts/`, `.DS_Store`.
diff --git a/DASHBOARD_GUIDE.md b/DASHBOARD_GUIDE.md
new file mode 100644
index 0000000..697450d
--- /dev/null
+++ b/DASHBOARD_GUIDE.md
@@ -0,0 +1,129 @@
+# PolyBot Dashboard — Chart & Metric Guide
+
+How to read each metric and chart to decide which filter settings produce the best results.
+
+## Stat Cards
+
+### Total Trades
+- **What**: Count of executed trades, with W/L breakdown.
+- **Why it matters**: More trades = more data = more confidence in the numbers. Below ~30 trades, any metric could be noise. Compare across versions to see if tighter filters reduce volume too much.
+
+### Win Rate
+- **What**: Wins / Total Trades as a percentage.
+- **Why it matters**: In binary markets (shares pay $1 or $0), you need roughly **~70% win rate to break even** at typical entry prices ($0.60-$0.80). Below 70%, you're losing money even if individual wins feel good. The breakeven threshold depends on your average entry price — lower entry prices (more edge) lower the breakeven WR.
+- **Formula**: `win_rate = wins / total_trades × 100`
+
+### Total P&L
+- **What**: Sum of all trade profits (wins minus losses).
+- **Why it matters**: The bottom line. But don't compare raw P&L across versions with different trade counts — use EV/Trade instead.
+
+### EV / Trade (Expected Value per Trade)
+- **What**: Average profit per trade, including both wins and losses.
+- **Why it matters**: The single best metric for comparing versions. A version with fewer trades but higher EV/Trade is better — it means every trade you take is worth more. If EV/Trade is negative, you're losing money on average and no amount of volume fixes that.
+- **Formula**: `ev_per_trade = total_pnl / total_trades`
+- **Decision rule**: Higher EV/Trade = better filter settings. Even if trade count drops.
+
+### Avg Win
+- **What**: Average profit on winning trades only.
+- **Why it matters**: Shows how much you make when you're right. In binary markets this is `(1 - entry_price) × shares`. Lower entry prices = bigger wins. If tighter filters force you into higher-priced entries (less edge), avg win shrinks.
+- **Formula**: `avg_win = sum(profit for wins) / count(wins)`
+
+### Avg Loss
+- **What**: Average loss on losing trades (shown as positive number for readability).
+- **Why it matters**: In binary markets with hold-to-resolution, every loss is -100% of entry cost. So avg loss ≈ avg entry cost on losing trades. The "X wins to recover" subtitle tells you how many winning trades it takes to make back one loss. If this number is >3, your losses are too expensive relative to your wins.
+- **Formula**: `avg_loss = |sum(profit for losses)| / count(losses)`
+- **Recovery ratio**: `wins_to_recover = avg_loss / avg_win`
+
+### Profit Factor
+- **What**: Total dollars won / Total dollars lost.
+- **Why it matters**: The efficiency of your edge. Profit factor > 1.0 means you're profitable. > 2.0 is strong. > 3.0 is exceptional. Unlike win rate, this accounts for *how much* you win vs lose, not just how often. A version with 70% WR but profit factor 3.0 is better than 90% WR with profit factor 1.5.
+- **Formula**: `profit_factor = sum(profits on wins) / |sum(profits on losses)|`
+- **Decision rule**: Compare profit factor across versions. Higher = more efficient edge.
+
+### Skipped
+- **What**: Signals that passed the strategy model but were blocked by pre-trade checks (edge_gone, slippage, btc_reversed), with count of how many would have won.
+- **Why it matters**: If most skipped signals would have won, your pre-trade filters may be too aggressive — you're leaving money on the table. If most would have lost, the filters are saving you money. Track the would-have-won rate over time to calibrate.
+
+---
+
+## Charts
+
+### Equity Curve
+- **What**: Cumulative P&L over time, with each trade as a point. Color-coded by version, green/red dots for win/loss.
+- **Why it matters**: Shows the *trajectory* of your bankroll. A smooth upward curve = consistent edge. Sharp drops = losing streaks. Compare the slope across version segments — steeper upward slope = better version. Also reveals if a version started strong then degraded (market regime change).
+- **What to look for**:
+ - Steady upward slope (good)
+ - Flat or declining segments (filter settings not working for current market)
+ - Big drops from single losses (motivates tighter filters)
+
+### Win Rate by Version
+- **What**: Bar chart comparing win rate across v13, v14, v15.
+- **Why it matters**: Direct comparison of how accurate each version is. The dashed line at 70% marks approximate breakeven. Versions above the line are profitable on win rate alone. But win rate isn't everything — a version could have high WR with tiny wins and still underperform.
+- **What to look for**: Versions consistently above 70% line. If a version is below, its filters or model calibration need work.
+
+### Total P&L by Version
+- **What**: Bar chart showing total dollars made/lost per version.
+- **Why it matters**: The final scoreboard. But misleading if versions ran for different durations. A version that ran for 2 hours and made $20 is better than one that ran for 24 hours and made $30. Cross-reference with trade count.
+
+### Per-Trade P&L Waterfall
+- **What**: Every trade as a green (win) or red (loss) bar, in chronological order.
+- **Why it matters**: Visualizes loss clustering. If you see red bars grouped together, that's a losing streak — check what market conditions caused it (time of day, BTC volatility). Also shows the magnitude asymmetry: red bars are typically taller than green bars because losses are -100% of entry cost while wins are +(1/price - 1) × cost.
+- **What to look for**:
+ - Tall red bars = expensive losses (high entry cost on wrong trades)
+ - Clusters of red = regime where the strategy fails
+ - Version boundaries = did the new version improve the pattern?
+
+### Edge vs Outcome (Scatter)
+- **What**: Every trade plotted with edge (prob - price) on X-axis and profit on Y-axis. Wins are green circles, losses are red X markers.
+- **Why it matters**: **The most important chart for choosing min_edge.** Visually shows where losses cluster on the edge spectrum. If all losses are below edge 0.08, that's your filter threshold. If losses are spread evenly, edge isn't predictive and you need a different filter.
+- **How to read it**:
+ - Losses clustered at low edge → raise min_edge to that threshold
+ - Losses at high edge → edge isn't the problem; look at btc_delta or time_remaining instead
+ - Clear separation between win zone and loss zone → strong filter signal
+- **Decision rule**: Draw a mental vertical line where losses stop appearing. That's your optimal min_edge.
+
+### Avg Win / Avg Loss by Version
+- **What**: Grouped bar chart showing average win (green) and average loss (red) side by side for each version. Tooltip shows profit factor.
+- **Why it matters**: Tells the "recovery story" per version. If the red bar is 2x the green bar, each loss erases 2 wins — you need 75%+ WR just to break even. The ideal pattern is green bar growing (bigger wins) while red bar shrinks (smaller or fewer losses). This is how you measure whether filter changes are actually improving the economics.
+- **What to look for**:
+ - Green bar > red bar (each win covers each loss — only need >50% WR)
+ - Red bar >> green bar (dangerous — need very high WR to survive)
+ - Profit factor in tooltip: >2.0 is strong, >3.0 is excellent
+
+### Skipped Signals by Reason
+- **What**: Bar chart showing count of would-have-won vs would-have-lost for each skip reason (edge_gone, slippage, btc_reversed).
+- **Why it matters**: Tells you which filters are helping and which are costing money.
+ - **edge_gone**: If mostly would-have-won → the market is repricing correctly but BTC continues trending. Your edge_gone check might be too strict, or you need faster execution.
+ - **slippage**: If mostly would-have-won → your slippage threshold (0.09) might be too tight. If mixed → it's working as intended.
+ - **btc_reversed**: Should mostly be would-have-lost. If it's blocking wins, the BTC direction re-check is too sensitive.
+
+### Skipped: Would-Have-Won Rate (Pie)
+- **What**: Doughnut chart showing overall ratio of skipped signals that would have won vs lost.
+- **Why it matters**: Quick gut check. If >70% of skips would have won, you're being too cautious — loosening filters would capture profitable trades. If <50% would have won, your filters are saving you money. Target: ~50-60% would-have-won rate means your filters are optimally calibrated (blocking the bad while letting most good through).
+
+### Recent Trades Table
+- **What**: Last 20 trades with time, version, side, entry price, edge %, cost, shares, result, profit, and return %.
+- **Why it matters**: Quick scan for patterns. Look at the Edge % column alongside Result — do all your losses have low edge? That confirms the min_edge filter thesis. Also useful for spotting entry price patterns (are you buying too expensive on losses?).
+
+---
+
+## How to Compare Versions
+
+Use this checklist when deciding which filter settings to keep:
+
+1. **EV/Trade** — Higher is better. The primary metric.
+2. **Profit Factor** — Higher is better. Above 2.0 is strong.
+3. **Win Rate** — Higher is better, but only matters relative to avg entry price.
+4. **Wins to Recover** — Lower is better. Below 2.0 means each loss only costs ~2 wins.
+5. **Edge scatter** — Losses should cluster at low edge (filterable), not spread randomly.
+6. **Skipped would-have-won rate** — 50-60% is the sweet spot. Much higher = too strict.
+7. **Trade volume** — More is better *if EV/Trade stays positive*. Don't sacrifice EV for volume.
+
+### The Binary Market Rule
+
+In binary markets where every loss is -100%, the math is asymmetric:
+- Losing a $7.70 trade costs you $7.70
+- Missing a $3.89 win only costs you $3.89
+- **The cost of a false positive (bad trade) is ~2x the cost of a false negative (missed trade)**
+
+This means **accuracy > volume** for binary markets. When in doubt, be more selective.
diff --git a/bot.py b/bot.py
index 0fec773..e9337b2 100644
--- a/bot.py
+++ b/bot.py
@@ -43,6 +43,8 @@
from tracker import Tracker
+BOT_VERSION = "2026.03.26"
+
FORCED_EXIT_START = 5
FORCED_EXIT_END = 1
POSITION_CHECK_INTERVAL = 3
@@ -354,6 +356,17 @@ def _manage_position(self, btc_price: float, seconds_remaining: float, now: floa
unrealized_pnl = current_value - self._trade_cost
return_pct = (current_sell_price - self._trade_price) / self._trade_price if self._trade_price > 0 else 0
+ # Log tick to hold_ticks.csv for trajectory analysis
+ self.tracker.log_hold_tick(
+ seconds_remaining=seconds_remaining,
+ btc_price=btc_price,
+ btc_delta_pct=btc_delta_pct,
+ prob=our_prob,
+ sell_price=current_sell_price,
+ unrealized_pnl=unrealized_pnl,
+ return_pct=return_pct,
+ )
+
# Status line (monitoring only — no exits)
d = "↑" if btc_delta_pct > 0 else "↓" if btc_delta_pct < 0 else "→"
pnl_emoji = "📈" if unrealized_pnl > 0 else "📉"
@@ -439,8 +452,11 @@ def _on_new_window(self, window_ts: int, closing_btc_price: float = 0.0):
if real_bal > 0 and self._balance_before_buy > 0:
spent = self._balance_before_buy - real_bal
if spent > 1.0:
+ # Cap at intended cost — concurrent settlements can inflate delta
+ intended_cost = self._pending_buy_shares * self._pending_buy_price if self._pending_buy_price > 0 else spent
+ spent = min(spent, intended_cost)
# The buy DID go through — retroactively track it
- est_shares = spent / self._pending_buy_price if self._pending_buy_price > 0 else 0
+ est_shares = min(spent / self._pending_buy_price, self._pending_buy_shares) if self._pending_buy_price > 0 else 0
print(f"\n 👻 LATE FILL: balance dropped ${spent:.2f} since buy attempt")
print(f" Retroactively tracking: ~{est_shares:.0f} shares "
f"{self._pending_buy_side} @ ${self._pending_buy_price:.3f}")
@@ -455,6 +471,18 @@ def _on_new_window(self, window_ts: int, closing_btc_price: float = 0.0):
self._last_real_balance = real_bal
self.stats.hourly.record_trade(
self._pending_buy_edge, self._pending_buy_delta)
+ self.tracker.open_trade(
+ window_ts=self._current_window,
+ side=self._pending_buy_side,
+ entry_price=self._pending_buy_price,
+ entry_shares=est_shares,
+ entry_cost=spent,
+ edge=self._pending_buy_edge,
+ prob=0.0,
+ btc_delta=self._pending_buy_delta,
+ seconds_remaining=0.0,
+ version=BOT_VERSION,
+ )
self.stats.hourly.record_window(self._traded)
if self._traded:
@@ -714,7 +742,7 @@ def _execute_trade(self, sig, seconds_remaining: float):
actual_edge=sig.true_prob - result.price,
fill_price=result.price,
)
- self.tracker.log_trade_entry(
+ self.tracker.open_trade(
window_ts=self._current_window,
side=sig.side,
entry_price=result.price,
@@ -726,6 +754,7 @@ def _execute_trade(self, sig, seconds_remaining: float):
seconds_remaining=seconds_remaining,
entry_delta_pct=sig.btc_delta_pct,
entry_seconds_remaining=seconds_remaining,
+ version=BOT_VERSION,
)
mode = "PAPER" if self.dry_run else "LIVE"
@@ -768,29 +797,30 @@ def _execute_trade(self, sig, seconds_remaining: float):
def _resolve_previous_trade(self):
if self._exited:
- profit = self._exit_revenue - self._trade_cost
- if profit > 0:
+ btc_price, _ = self.price_feed.get_price()
+ result = self.tracker.close_trade(
+ btc_final_price=btc_price,
+ opening_price=self._opening_price,
+ won=True, # close_trade overrides based on profit for "exited"
+ exit_revenue=self._exit_revenue,
+ resolution_method="exited",
+ bankroll=self.stats.bankroll,
+ )
+ profit = result["profit"]
+ won = result["won"]
+ if won:
self.stats.record_win(profit)
else:
self.stats.record_loss(abs(profit))
- result_emoji = "✅ WIN" if profit > 0 else "❌ LOSS"
+ result_emoji = "✅ WIN" if won else "❌ LOSS"
residual_note = f" (~{self._residual_shares:.0f} residual)" if self._residual_shares >= 1 else ""
print(f" {result_emoji} (exited{residual_note}) ${profit:+.2f} | "
f"P&L: ${self.stats.total_pnl:+.2f} | "
f"Bank: ${self.stats.bankroll:.2f}")
- if profit > 0:
+ if won:
self.telegram.win_alert(profit, self.stats.total_pnl)
else:
self.telegram.loss_alert(abs(profit), self.stats.total_pnl)
- btc_price, _ = self.price_feed.get_price()
- self.tracker.log_trade_resolve(
- btc_final_price=btc_price,
- opening_price=self._opening_price,
- won=profit > 0,
- profit=profit,
- exit_revenue=self._exit_revenue,
- resolution_method="exited",
- )
return
original_cost = self._trade_cost
@@ -829,24 +859,25 @@ def _resolve_previous_trade(self):
# Short-circuit: if last observed sell price is below $0.50, market has
# already priced these shares as worthless — skip the claim API call.
if self._last_sell_price_seen > 0 and self._last_sell_price_seen < 0.50:
- net_loss = original_cost - self._exit_revenue
- profit = -net_loss
- self.stats.record_loss(net_loss)
- partial_note = f" (partial exit ${self._exit_revenue:.2f})" if self._exit_revenue > 0 else ""
- print(f" ❌ LOSS{partial_note} -${net_loss:.2f} [market_price] | "
- f"P&L: ${self.stats.total_pnl:+.2f} | "
- f"Bank: ${self.stats.bankroll:.2f}")
- self.telegram.loss_alert(net_loss, self.stats.total_pnl)
btc_price, _ = self.price_feed.get_price()
- self.tracker.log_trade_resolve(
+ result = self.tracker.close_trade(
btc_final_price=btc_price,
opening_price=self._opening_price,
won=False,
- profit=profit,
exit_revenue=self._exit_revenue,
+ remaining_shares=remaining_shares,
resolution_method="market_price",
claim_result="skipped_losing",
+ bankroll=self.stats.bankroll,
)
+ profit = result["profit"]
+ net_loss = abs(profit)
+ self.stats.record_loss(net_loss)
+ partial_note = f" (partial exit ${self._exit_revenue:.2f})" if self._exit_revenue > 0 else ""
+ print(f" ❌ LOSS{partial_note} -${net_loss:.2f} [market_price] | "
+ f"P&L: ${self.stats.total_pnl:+.2f} | "
+ f"Bank: ${self.stats.bankroll:.2f}")
+ self.telegram.loss_alert(net_loss, self.stats.total_pnl)
return
if live_token and claim_notional >= 5.0:
@@ -920,15 +951,27 @@ def _record_resolution(
resolution_method: str, claim_revenue: float, claim_result: str = "not_attempted",
):
"""Apply win/loss to stats, print result, alert Telegram, log to tracker."""
+ # Bankroll adjustment for unclaimed wins (claimed wins already added by caller)
+ if won and claim_revenue <= 0:
+ resolution_payout = remaining_shares * 1.0
+ self.stats.bankroll += resolution_payout
+ self._unclaimed_winnings += resolution_payout
+
+ btc_price, _ = self.price_feed.get_price()
+ result = self.tracker.close_trade(
+ btc_final_price=btc_price,
+ opening_price=self._opening_price,
+ won=won,
+ exit_revenue=self._exit_revenue,
+ claim_revenue=claim_revenue,
+ remaining_shares=remaining_shares,
+ resolution_method=resolution_method,
+ claim_result=claim_result,
+ bankroll=self.stats.bankroll,
+ )
+ profit = result["profit"]
+
if won:
- if claim_revenue > 0:
- total_received = self._exit_revenue + claim_revenue
- else:
- resolution_payout = remaining_shares * 1.0
- total_received = self._exit_revenue + resolution_payout
- self.stats.bankroll += resolution_payout
- self._unclaimed_winnings += resolution_payout
- profit = total_received - original_cost
self.stats.record_win(profit)
partial_note = f" (partial exit ${self._exit_revenue:.2f})" if self._exit_revenue > 0 else ""
claimed_note = " (claimed)" if claim_revenue > 0 else " (unclaimed)"
@@ -937,8 +980,7 @@ def _record_resolution(
f"Bank: ${self.stats.bankroll:.2f}")
self.telegram.win_alert(profit, self.stats.total_pnl)
else:
- net_loss = original_cost - self._exit_revenue
- profit = -net_loss
+ net_loss = abs(profit)
self.stats.record_loss(net_loss)
partial_note = f" (partial exit ${self._exit_revenue:.2f})" if self._exit_revenue > 0 else ""
print(f" ❌ LOSS{partial_note} -${net_loss:.2f} [{resolution_method}] | "
@@ -946,17 +988,6 @@ def _record_resolution(
f"Bank: ${self.stats.bankroll:.2f}")
self.telegram.loss_alert(net_loss, self.stats.total_pnl)
- btc_price, _ = self.price_feed.get_price()
- self.tracker.log_trade_resolve(
- btc_final_price=btc_price,
- opening_price=self._opening_price,
- won=won,
- profit=profit,
- exit_revenue=self._exit_revenue,
- resolution_method=resolution_method,
- claim_result=claim_result,
- )
-
# ── Hourly + shutdown ───────────────────────────────────────────
def _check_hourly_summary(self):
diff --git a/dashboard.py b/dashboard.py
new file mode 100644
index 0000000..4ba8785
--- /dev/null
+++ b/dashboard.py
@@ -0,0 +1,688 @@
+#!/usr/bin/env python3
+"""PolyBot Trading Dashboard — live charts from CSV data.
+
+Usage:
+ python dashboard.py # opens http://localhost:8050
+ python dashboard.py 9000 # custom port
+"""
+
+import csv
+import json
+import os
+import sys
+import webbrowser
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+
+LOG_DIR = "logs"
+PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8050
+
+# ── CSV loading ────────────────────────────────────────────────────
+
+def load_csv(path):
+ if not os.path.exists(path):
+ return []
+ with open(path, newline="", encoding="utf-8-sig") as f:
+ return list(csv.DictReader(f))
+
+def get_all_trades():
+ trades = []
+ # v13
+ for row in load_csv(os.path.join(LOG_DIR, "trades_v13_pre_fix.csv")):
+ row["version"] = "v13"
+ trades.append(row)
+ # v14
+ for row in load_csv(os.path.join(LOG_DIR, "trades_v14.csv")):
+ row["version"] = "v14"
+ trades.append(row)
+ # v15+ (includes schema migration archive if it exists)
+ for f in ["trades_pre_schema_change.csv", "trades.csv"]:
+ for row in load_csv(os.path.join(LOG_DIR, f)):
+ v = row.get("version", "15")
+ row["version"] = f"v{v}" if not str(v).startswith("v") else v
+ trades.append(row)
+ trades.sort(key=lambda r: float(r.get("timestamp", 0)))
+ return trades
+
+def get_skipped():
+ return load_csv(os.path.join(LOG_DIR, "skipped.csv"))
+
+def get_hold_ticks():
+ return load_csv(os.path.join(LOG_DIR, "hold_ticks.csv"))
+
+# ── HTTP handler ───────────────────────────────────────────────────
+
+class DashboardHandler(SimpleHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/api/trades":
+ self._json_response(get_all_trades())
+ elif self.path == "/api/skipped":
+ self._json_response(get_skipped())
+ elif self.path == "/api/hold_ticks":
+ self._json_response(get_hold_ticks())
+ elif self.path == "/" or self.path == "/index.html":
+ self._html_response(DASHBOARD_HTML)
+ else:
+ self.send_error(404)
+
+ def _json_response(self, data):
+ body = json.dumps(data).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", len(body))
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _html_response(self, html):
+ body = html.encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html")
+ self.send_header("Content-Length", len(body))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, format, *args):
+ pass # silence request logs
+
+# ── Dashboard HTML ─────────────────────────────────────────────────
+
+DASHBOARD_HTML = r"""
+
+
+
+
+PolyBot Dashboard
+
+
+
+
+
+
+
PolyBot Dashboard
+
+
+
+
+ Filter:
+
+
+
+
+
+
Equity Curve
+
Win Rate by Version
+
Total P&L by Version
+
Per-Trade P&L Waterfall
+
Skipped Signals by Reason
+
Skipped: Would-Have-Won Rate
+
Recent Trades (click a row to see hold trajectory)
+
+
+
+
+
+"""
+
+# ── Main ───────────────────────────────────────────────────────────
+
+if __name__ == "__main__":
+ server = HTTPServer(("0.0.0.0", PORT), DashboardHandler)
+ print(f" PolyBot Dashboard running at http://localhost:{PORT}")
+ print(f" Press Ctrl+C to stop\n")
+ webbrowser.open(f"http://localhost:{PORT}")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\n Dashboard stopped.")
+ server.server_close()
diff --git a/executor.py b/executor.py
index 10cb9d3..5b68ae6 100644
--- a/executor.py
+++ b/executor.py
@@ -54,7 +54,11 @@ class OrderResult:
def calculate_order_size(price: float, max_usd: float) -> tuple[float, float]:
- """Integer shares × price = clean 2-decimal USD amount."""
+ """Integer shares × price = clean 2-decimal USD amount.
+
+ If flooring to integer shares drops below POLY_MIN_NOTIONAL ($5),
+ round UP one share so the order isn't rejected.
+ """
if price <= 0 or max_usd <= 0:
return 0.0, 0.0
price_cents = round(price * 100)
@@ -70,6 +74,12 @@ def calculate_order_size(price: float, max_usd: float) -> tuple[float, float]:
shares = int(max_shares)
spend = shares * price_cents / 100.0
+
+ # If integer-share rounding dropped below $5 min, bump up one share
+ if spend < POLY_MIN_NOTIONAL and shares >= MIN_SHARES:
+ shares += 1
+ spend = shares * price_cents / 100.0
+
if shares < MIN_SHARES:
return 0.0, 0.0
return float(shares), spend
@@ -250,7 +260,10 @@ def buy(self, token_id: str, amount_usd: float, price: float = 0.0) -> OrderResu
spent = balance_before - balance_after if balance_before > 0 else 0
if spent > 1.0:
- actual_shares = spent / market_price if market_price > 0 else 0
+ # Cap at intended cost — concurrent settlements can inflate delta
+ intended_cost = shares * market_price
+ spent = min(spent, intended_cost)
+ actual_shares = min(spent / market_price, shares) if market_price > 0 else 0
print(f" 👻 GHOST BUY: balance dropped ${spent:.2f} despite error")
return OrderResult(
success=True, order_id="ghost-buy",
@@ -280,7 +293,10 @@ def _verify_buy_via_balance(
spent = balance_before - balance_after if balance_before > 0 else 0
if spent > 0.50:
- actual_shares = spent / price if price > 0 else shares
+ # Cap at intended cost — concurrent settlements can inflate delta
+ intended_cost = shares * price
+ spent = min(spent, intended_cost)
+ actual_shares = min(spent / price, shares) if price > 0 else shares
suffix = f" (attempt {attempt+1})" if attempt > 0 else ""
print(f" ✓ Balance verified{suffix}: spent ${spent:.2f} "
f"(~{actual_shares:.0f} shares @ ${price:.3f})")
@@ -398,8 +414,10 @@ def sell(self, token_id: str, shares: float, price: float = 0.0) -> OrderResult:
received = balance_after - balance_before
if received > 0.10: # Got some USDC back
- # Estimate shares sold from received amount
- shares_sold = received / price if price > 0 else 0
+ # Cap at intended revenue — concurrent settlements can inflate delta
+ intended_revenue = sell_shares * price
+ received = min(received, intended_revenue)
+ shares_sold = min(received / price, sell_shares) if price > 0 else 0
shares_left = max(0, sell_shares - shares_sold)
status = FILLED if shares_left < 1 else PARTIAL
@@ -444,7 +462,10 @@ def sell(self, token_id: str, shares: float, price: float = 0.0) -> OrderResult:
balance_after = self.get_balance()
received = balance_after - balance_before
if received > 0.10:
- shares_sold = received / price if price > 0 else 0
+ # Cap at intended revenue — concurrent settlements can inflate delta
+ intended_revenue = sell_shares * price
+ received = min(received, intended_revenue)
+ shares_sold = min(received / price, sell_shares) if price > 0 else 0
shares_left = max(0, sell_shares - shares_sold)
print(f" 👻 Ghost sell! Got ${received:.2f} despite error")
return OrderResult(
diff --git a/proxy.py b/proxy.py
index 3827889..ff26051 100644
--- a/proxy.py
+++ b/proxy.py
@@ -276,6 +276,16 @@ def _patched_async_client_init(self, *args, **kwargs):
httpx.AsyncClient.__init__ = _patched_async_client_init
+ # Also patch the module-level singleton in py_clob_client's http_helpers.
+ # This client is created at import time (before our patch), so it never
+ # gets the proxy. Replace it with a new proxied client.
+ try:
+ from py_clob_client.http_helpers import helpers as _clob_helpers
+ _clob_helpers._http_client = httpx.Client(http2=True, proxy=proxy_url)
+ logger.info("Patched py_clob_client singleton httpx client with proxy")
+ except Exception as e:
+ logger.warning(f"Could not patch py_clob_client singleton: {e}")
+
logger.info("httpx (CLOB) proxy patch applied — trading calls routed through proxy")
except ImportError:
pass # httpx not installed — py_clob_client won't be usable anyway
diff --git a/reconcile.py b/reconcile.py
new file mode 100644
index 0000000..e08a8c5
--- /dev/null
+++ b/reconcile.py
@@ -0,0 +1,303 @@
+#!/usr/bin/env python3
+"""
+reconcile.py — Cross-check PolyBot tracking against Polymarket CSV exports.
+
+Usage:
+ # Audit internal consistency of trades.csv
+ python reconcile.py
+
+ # Cross-check against Polymarket export (download from Polymarket UI → History → Export)
+ python reconcile.py polymarket_export.csv
+
+Reports:
+ 1. Internal audit: verifies profit math for every trade
+ 2. Claim sell analysis: shows revenue leaked by selling at market vs $1 resolution
+ 3. Cross-check (if Polymarket CSV provided): matches bot trades to on-chain transactions
+"""
+
+import csv
+import sys
+from pathlib import Path
+from datetime import datetime, timezone
+
+
+TRADES_PATH = Path(__file__).parent / "logs" / "trades.csv"
+
+
+def load_trades(path: str = None) -> list[dict]:
+ p = Path(path) if path else TRADES_PATH
+ if not p.exists():
+ print(f" Error: {p} not found")
+ sys.exit(1)
+ with open(p, "r", encoding="utf-8-sig") as f:
+ return list(csv.DictReader(f))
+
+
+def audit_internal(trades: list[dict], version_filter: str = None):
+ """Check that profit = f(claim_revenue, exit_revenue, entry_cost, won) for each trade."""
+ print("\n" + "=" * 70)
+ print(" INTERNAL AUDIT — Profit Math Verification")
+ print("=" * 70)
+
+ mismatches = []
+ for r in trades:
+ if version_filter and version_filter not in r.get("version", ""):
+ continue
+
+ won = r["won_resolution"] == "True"
+ cost = float(r["entry_cost"] or 0)
+ profit = float(r["profit"] or 0)
+ shares = float(r["entry_shares"] or 0)
+ exit_rev = float(r.get("exit_revenue", 0) or 0)
+ payout = float(r.get("resolution_payout", 0) or 0)
+ method = r.get("resolution_method", "")
+
+ # Reconstruct what profit SHOULD be given the resolution method
+ if method == "exited":
+ expected = exit_rev - cost
+ elif won and method == "claim_sell":
+ # claim_revenue = profit + cost - exit_rev (reverse-engineer)
+ # Can't independently verify — just check payout vs shares
+ claim_rev = profit - exit_rev + cost
+ # Flag if claim_rev is negative (impossible) or > shares
+ if claim_rev < 0:
+ mismatches.append((r, f"negative claim_revenue={claim_rev:.2f}"))
+ elif claim_rev > shares * 1.05: # small tolerance
+ mismatches.append((r, f"claim_revenue {claim_rev:.2f} > shares*$1.05={shares*1.05:.2f}"))
+ continue # Can't verify further without independent claim data
+ elif won:
+ # balance_check / binance_fallback: profit = exit_rev + shares*$1 - cost
+ expected = exit_rev + payout - cost
+ else:
+ expected = exit_rev - cost
+
+ if abs(profit - expected) > 0.05:
+ mismatches.append((r, f"profit={profit:+.2f} expected={expected:+.2f} diff={profit-expected:+.2f}"))
+
+ if mismatches:
+ print(f"\n {len(mismatches)} MISMATCHES found:\n")
+ for r, msg in mismatches:
+ print(f" {r.get('window_time', '?')} {r.get('side', '?')} — {msg}")
+ else:
+ print("\n All trades pass internal consistency check.")
+
+
+def claim_sell_analysis(trades: list[dict], version_filter: str = None):
+ """Analyze revenue leaked by claim sells vs full $1 resolution."""
+ print("\n" + "=" * 70)
+ print(" CLAIM SELL ANALYSIS — Revenue Leakage")
+ print("=" * 70)
+
+ filtered = [r for r in trades if not version_filter or version_filter in r.get("version", "")]
+ wins = [r for r in filtered if r["won_resolution"] == "True"]
+ losses = [r for r in filtered if r["won_resolution"] != "True"]
+ claim_sells = [r for r in wins if r.get("resolution_method") == "claim_sell"]
+
+ total_pnl = sum(float(r["profit"] or 0) for r in filtered)
+ total_cost = sum(float(r["entry_cost"] or 0) for r in filtered)
+ win_pnl = sum(float(r["profit"] or 0) for r in wins)
+ loss_pnl = sum(float(r["entry_cost"] or 0) for r in losses)
+
+ # Theoretical: all wins at $1/share
+ theoretical_win_pnl = sum(float(r["entry_shares"]) - float(r["entry_cost"]) for r in wins)
+ theoretical_pnl = theoretical_win_pnl - loss_pnl
+ leaked = theoretical_pnl - total_pnl
+
+ # Per-trade claim sell breakdown
+ claim_revenues = []
+ for r in claim_sells:
+ cost = float(r["entry_cost"] or 0)
+ profit = float(r["profit"] or 0)
+ exit_rev = float(r.get("exit_revenue", 0) or 0)
+ shares = float(r["entry_shares"] or 0)
+ claim_rev = profit - exit_rev + cost
+ theoretical = shares * 0.99
+ fill_pct = claim_rev / theoretical * 100 if theoretical > 0 else 0
+ claim_revenues.append({
+ "time": r.get("window_time", "?"),
+ "shares": shares,
+ "claim_rev": claim_rev,
+ "theoretical": theoretical,
+ "fill_pct": fill_pct,
+ "lost": theoretical - claim_rev,
+ })
+
+ print(f"\n Trades: {len(filtered)} ({len(wins)}W / {len(losses)}L = {len(wins)/len(filtered)*100:.0f}% WR)")
+ print(f" Total deployed: ${total_cost:.2f}")
+ print(f" Actual P&L: ${total_pnl:+.2f}")
+ print(f" Theoretical P&L: ${theoretical_pnl:+.2f} (all wins at $1/share)")
+ print(f" Revenue leaked: ${leaked:.2f} ({leaked/theoretical_pnl*100:.0f}% of theoretical profit)")
+ print(f" Avg leak/trade: ${leaked/len(claim_sells):.2f} ({len(claim_sells)} claim sells)")
+
+ # Flag worst fills
+ bad = [c for c in claim_revenues if c["fill_pct"] < 92]
+ if bad:
+ print(f"\n Worst fills (<92%):")
+ for c in sorted(bad, key=lambda x: x["fill_pct"]):
+ print(f" {c['time']} — {c['shares']:.0f} shares, "
+ f"got ${c['claim_rev']:.2f} / ${c['theoretical']:.2f} "
+ f"({c['fill_pct']:.1f}%, lost ${c['lost']:.2f})")
+
+
+def cross_check_polymarket(trades: list[dict], pm_path: str, version_filter: str = None):
+ """Match bot trades against Polymarket CSV export."""
+ print("\n" + "=" * 70)
+ print(" CROSS-CHECK vs Polymarket Export")
+ print("=" * 70)
+
+ # Load Polymarket CSV (BOM-encoded)
+ with open(pm_path, "r", encoding="utf-8-sig") as f:
+ pm_rows = list(csv.DictReader(f))
+
+ if not pm_rows:
+ print(" Error: Polymarket CSV is empty")
+ return
+
+ # Show available columns
+ print(f"\n Polymarket CSV: {len(pm_rows)} transactions")
+ print(f" Columns: {', '.join(pm_rows[0].keys())}")
+
+ # Filter to BTC Up/Down 5-min markets
+ btc_trades = [r for r in pm_rows if "BTC" in r.get("marketName", "")
+ and ("5 minute" in r.get("marketName", "").lower()
+ or "five minute" in r.get("marketName", "").lower()
+ or "5-min" in r.get("marketName", "").lower()
+ or "5min" in r.get("marketName", "").lower())]
+
+ if not btc_trades:
+ # Try broader match
+ btc_trades = [r for r in pm_rows if "BTC" in r.get("marketName", "")]
+ if btc_trades:
+ print(f"\n No '5 minute' BTC trades found. Showing all BTC trades ({len(btc_trades)}):")
+ markets = set(r.get("marketName", "?") for r in btc_trades)
+ for m in sorted(markets):
+ print(f" - {m}")
+ else:
+ print("\n No BTC trades found in Polymarket export.")
+ return
+ else:
+ print(f" BTC 5-min trades: {len(btc_trades)}")
+
+ # Summarize Polymarket-side P&L
+ buys = [r for r in btc_trades if r.get("action", "").lower() == "buy"]
+ sells = [r for r in btc_trades if r.get("action", "").lower() == "sell"]
+ redeems = [r for r in btc_trades if r.get("action", "").lower() == "redeem"]
+
+ buy_total = sum(float(r.get("usdcAmount", 0) or 0) for r in buys)
+ sell_total = sum(float(r.get("usdcAmount", 0) or 0) for r in sells)
+ redeem_total = sum(float(r.get("usdcAmount", 0) or 0) for r in redeems)
+ pm_pnl = sell_total + redeem_total - buy_total
+
+ print(f"\n Polymarket-side totals:")
+ print(f" Buys: {len(buys)} txns, ${buy_total:.2f} spent")
+ print(f" Sells: {len(sells)} txns, ${sell_total:.2f} received")
+ print(f" Redeems: {len(redeems)} txns, ${redeem_total:.2f} received")
+ print(f" Net P&L: ${pm_pnl:+.2f}")
+
+ # Compare to bot tracking
+ filtered = [r for r in trades if not version_filter or version_filter in r.get("version", "")]
+ bot_pnl = sum(float(r["profit"] or 0) for r in filtered)
+ bot_deployed = sum(float(r["entry_cost"] or 0) for r in filtered)
+
+ print(f"\n Bot-side totals (trades.csv):")
+ print(f" Trades: {len(filtered)}")
+ print(f" Cost: ${bot_deployed:.2f}")
+ print(f" P&L: ${bot_pnl:+.2f}")
+
+ drift = pm_pnl - bot_pnl
+ print(f"\n Drift (Polymarket - Bot): ${drift:+.2f}")
+ if abs(drift) > 1.0:
+ print(f" ⚠️ Significant drift detected!")
+ if drift > 0:
+ print(f" Bot is UNDERCOUNTING profit by ${drift:.2f}")
+ print(f" Likely cause: claim sell partial fills + uncaptured resolution payouts")
+ else:
+ print(f" Bot is OVERCOUNTING profit by ${abs(drift):.2f}")
+ print(f" Likely cause: ghost fills or balance-delta contamination")
+ else:
+ print(f" ✓ Tracking within $1 tolerance")
+
+
+def summary_stats(trades: list[dict], version_filter: str = None):
+ """Print key performance stats."""
+ filtered = [r for r in trades if not version_filter or version_filter in r.get("version", "")]
+ if not filtered:
+ print("\n No trades found for filter.")
+ return
+
+ print("\n" + "=" * 70)
+ print(" PERFORMANCE SUMMARY")
+ print("=" * 70)
+
+ wins = [r for r in filtered if r["won_resolution"] == "True"]
+ losses = [r for r in filtered if r["won_resolution"] != "True"]
+ n = len(filtered)
+ wr = len(wins) / n * 100
+
+ profits = [float(r["profit"] or 0) for r in filtered]
+ total_pnl = sum(profits)
+ total_deployed = sum(float(r["entry_cost"] or 0) for r in filtered)
+ roi = total_pnl / total_deployed * 100 if total_deployed > 0 else 0
+
+ gross_wins = sum(p for p in profits if p > 0)
+ gross_losses = abs(sum(p for p in profits if p < 0))
+ pf = gross_wins / gross_losses if gross_losses > 0 else float("inf")
+ avg_win = gross_wins / len(wins) if wins else 0
+ avg_loss = gross_losses / len(losses) if losses else 0
+ expectancy = total_pnl / n
+
+ # Max drawdown
+ peak = cum = max_dd = 0
+ for p in profits:
+ cum += p
+ if cum > peak:
+ peak = cum
+ dd = peak - cum
+ if dd > max_dd:
+ max_dd = dd
+
+ print(f" Record: {len(wins)}W / {len(losses)}L ({wr:.0f}% WR)")
+ print(f" P&L: ${total_pnl:+.2f}")
+ print(f" ROI: {roi:+.1f}% on ${total_deployed:.0f} deployed")
+ print(f" Profit F: {pf:.2f}")
+ print(f" Avg Win: ${avg_win:+.2f}")
+ print(f" Avg Loss: ${avg_loss:.2f}")
+ print(f" Expectancy: ${expectancy:+.2f}/trade")
+ print(f" Max DD: ${max_dd:.2f}")
+
+
+def main():
+ pm_csv = None
+ version_filter = None
+
+ for arg in sys.argv[1:]:
+ if arg.startswith("--version="):
+ version_filter = arg.split("=", 1)[1]
+ elif arg.endswith(".csv"):
+ pm_csv = arg
+ elif arg in ("-h", "--help"):
+ print(__doc__)
+ return
+
+ trades = load_trades()
+ print(f"\n Loaded {len(trades)} trades from {TRADES_PATH}")
+
+ if version_filter:
+ count = sum(1 for t in trades if version_filter in t.get("version", ""))
+ print(f" Filter: version contains '{version_filter}' ({count} trades)")
+
+ summary_stats(trades, version_filter)
+ audit_internal(trades, version_filter)
+ claim_sell_analysis(trades, version_filter)
+
+ if pm_csv:
+ cross_check_polymarket(trades, pm_csv, version_filter)
+ else:
+ print("\n" + "-" * 70)
+ print(" Tip: Export your Polymarket history CSV and run:")
+ print(" python reconcile.py polymarket_export.csv")
+ print(" to cross-check bot tracking against on-chain transactions.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tracker.py b/tracker.py
index 23dd355..4ed5500 100644
--- a/tracker.py
+++ b/tracker.py
@@ -1,24 +1,24 @@
"""Quant-grade performance tracker for PolyBot.
-Three log files, all append-only CSV:
+Log files (append-only CSV):
1. signals.csv — Every signal the strategy evaluates (traded or not).
Answers: Are our edges real? What are we missing?
-2. trades.csv — Full lifecycle of every trade (entry → hold → exit → resolve).
+2. trades.csv — Full lifecycle of every trade (entry → hold → resolve).
Answers: How well do we execute? Where does P&L leak?
-3. executions.csv — Every API call with timing.
+3. executions.csv — Every API call with timing (optional).
Answers: How fast are we? Where's the latency?
Usage:
tracker = Tracker(log_dir="logs")
- tracker.log_signal(...) # Every evaluate() result
- tracker.log_trade_entry(...) # On buy fill
- tracker.log_trade_exit(...) # On sell/stop/TP
- tracker.log_trade_resolve(...)# At window close
- tracker.log_execution(...) # Every API call
- tracker.session_summary() # On shutdown
+ tracker.log_signal(...) # Every evaluate() result
+ tracker.open_trade(...) # On buy fill
+ tracker.update_hold_stats(...) # Each position check tick
+ tracker.close_trade(...) # At window close — computes profit, writes CSV
+ tracker.log_execution(...) # Every API call (optional)
+ tracker.session_summary() # On shutdown
"""
import os
@@ -66,8 +66,7 @@
"max_sell_price_seen", # Best exit we saw
"min_sell_price_seen", # Worst exit we saw
# Exit
- "exit_type", # "take-profit", "prob-stop", "price-stop",
- # "forced-exit", "resolution", "hold-to-resolution"
+ "exit_type", # "hold-to-resolution", "forced-exit"
"exit_price", "exit_shares_sold", "exit_revenue",
"residual_shares", "residual_value",
"exit_latency_ms",
@@ -80,8 +79,43 @@
# P&L
"profit", "return_pct",
"profit_if_held", # What we'd have made holding to resolution
+ # Meta
+ "version",
+ "bankroll", # Portfolio balance after this trade resolves
]
+
+@dataclass
+class TradeRecord:
+ """In-memory state for a single trade's lifecycle (entry + hold).
+
+ Created by open_trade(), updated by update_hold_stats(),
+ consumed by close_trade() which adds resolution fields and writes CSV.
+ """
+ timestamp: float = 0.0
+ window_ts: int = 0
+ window_time: str = ""
+ trade_id: int = 0
+ side: str = ""
+ entry_price: float = 0.0
+ entry_shares: float = 0.0
+ entry_cost: float = 0.0
+ edge_at_entry: float = 0.0
+ prob_at_entry: float = 0.0
+ btc_delta_at_entry: float = 0.0
+ seconds_remaining_at_entry: float = 0.0
+ entry_delta_pct: float = 0.0
+ entry_seconds_remaining: float = 0.0
+ entry_latency_ms: float = 0.0
+ # Hold (updated live via update_hold_stats)
+ max_prob_during_hold: float = 0.0
+ min_prob_during_hold: float = 1.0
+ max_sell_price_seen: float = 0.0
+ min_sell_price_seen: float = 999.0
+ # Meta
+ version: str = ""
+
+
# ── Execution record ────────────────────────────────────────────────
EXECUTION_FIELDS = [
@@ -94,6 +128,16 @@
"details", # JSON-safe string with relevant params
]
+# ── Hold tick record (one row per position check, ~every 3s) ───────
+
+HOLD_TICK_FIELDS = [
+ "timestamp", "trade_id", "window_ts",
+ "seconds_remaining",
+ "btc_price", "btc_delta_pct",
+ "prob", "sell_price",
+ "unrealized_pnl", "return_pct",
+]
+
SESSION_FIELDS = [
"start_time", "end_time",
"start_balance", "end_balance",
@@ -113,17 +157,19 @@ def __init__(self, log_dir: str = "logs", log_executions: bool = False):
self._signal_path = os.path.join(log_dir, "signals.csv")
self._trade_path = os.path.join(log_dir, "trades.csv")
+ self._tick_path = os.path.join(log_dir, "hold_ticks.csv")
self._exec_path = os.path.join(log_dir, "executions.csv")
self._session_path = os.path.join(log_dir, "sessions.csv")
self._ensure_headers(self._signal_path, SIGNAL_FIELDS)
self._ensure_headers(self._trade_path, TRADE_FIELDS)
+ self._ensure_headers(self._tick_path, HOLD_TICK_FIELDS)
self._ensure_headers(self._session_path, SESSION_FIELDS)
if self.log_executions:
self._ensure_headers(self._exec_path, EXECUTION_FIELDS)
- # In-memory state for current trade
- self._current_trade: dict = {}
+ # Active trade (TradeRecord dataclass, replaces old dict)
+ self._active_trade: Optional[TradeRecord] = None
self._trade_counter: int = 0
# Session stats
@@ -208,7 +254,7 @@ def log_signal(
# ── Trade lifecycle ─────────────────────────────────────────────
- def log_trade_entry(
+ def open_trade(
self,
window_ts: int,
side: str,
@@ -222,115 +268,157 @@ def log_trade_entry(
latency_ms: float = 0.0,
entry_delta_pct: float = 0.0,
entry_seconds_remaining: float = 0.0,
+ version: str = "",
):
+ """Record trade entry. Creates a TradeRecord that tracks hold-period stats."""
self._trade_counter += 1
- self._current_trade = {
- "timestamp": time.time(),
- "window_ts": window_ts,
- "window_time": time.strftime("%H:%M", time.localtime(window_ts)),
- "trade_id": self._trade_counter,
- "side": side,
- "entry_price": round(entry_price, 4),
- "entry_shares": round(entry_shares, 1),
- "entry_cost": round(entry_cost, 2),
- "edge_at_entry": round(edge, 4),
- "prob_at_entry": round(prob, 4),
- "btc_delta_at_entry": round(btc_delta, 4),
- "seconds_remaining_at_entry": round(seconds_remaining, 1),
- "entry_latency_ms": round(latency_ms, 0),
- "entry_delta_pct": round(entry_delta_pct, 4),
- "entry_seconds_remaining": round(entry_seconds_remaining, 1),
- # Hold tracking — updated live
- "max_prob_during_hold": round(prob, 4),
- "min_prob_during_hold": round(prob, 4),
- "max_sell_price_seen": 0.0,
- "min_sell_price_seen": 999.0,
- }
+ self._active_trade = TradeRecord(
+ timestamp=time.time(),
+ window_ts=window_ts,
+ window_time=time.strftime("%H:%M", time.localtime(window_ts)),
+ trade_id=self._trade_counter,
+ side=side,
+ entry_price=round(entry_price, 4),
+ entry_shares=round(entry_shares, 1),
+ entry_cost=round(entry_cost, 2),
+ edge_at_entry=round(edge, 4),
+ prob_at_entry=round(prob, 4),
+ btc_delta_at_entry=round(btc_delta, 4),
+ seconds_remaining_at_entry=round(seconds_remaining, 1),
+ entry_latency_ms=round(latency_ms, 0),
+ entry_delta_pct=round(entry_delta_pct, 4),
+ entry_seconds_remaining=round(entry_seconds_remaining, 1),
+ max_prob_during_hold=round(prob, 4),
+ min_prob_during_hold=round(prob, 4),
+ version=version,
+ )
def update_hold_stats(self, prob: float, sell_price: float):
"""Call on each position check tick to track hold-period extremes."""
- if not self._current_trade:
+ if not self._active_trade:
return
- if prob > self._current_trade["max_prob_during_hold"]:
- self._current_trade["max_prob_during_hold"] = round(prob, 4)
- if prob < self._current_trade["min_prob_during_hold"]:
- self._current_trade["min_prob_during_hold"] = round(prob, 4)
+ t = self._active_trade
+ if prob > t.max_prob_during_hold:
+ t.max_prob_during_hold = round(prob, 4)
+ if prob < t.min_prob_during_hold:
+ t.min_prob_during_hold = round(prob, 4)
if sell_price > 0:
- if sell_price > self._current_trade["max_sell_price_seen"]:
- self._current_trade["max_sell_price_seen"] = round(sell_price, 4)
- if sell_price < self._current_trade["min_sell_price_seen"]:
- self._current_trade["min_sell_price_seen"] = round(sell_price, 4)
+ if sell_price > t.max_sell_price_seen:
+ t.max_sell_price_seen = round(sell_price, 4)
+ if sell_price < t.min_sell_price_seen:
+ t.min_sell_price_seen = round(sell_price, 4)
- def log_trade_exit(
+ def log_hold_tick(
self,
- exit_type: str,
- exit_price: float,
- exit_shares_sold: float,
- exit_revenue: float,
- residual_shares: float,
- latency_ms: float = 0.0,
+ seconds_remaining: float,
+ btc_price: float,
+ btc_delta_pct: float,
+ prob: float,
+ sell_price: float,
+ unrealized_pnl: float,
+ return_pct: float,
):
- if not self._current_trade:
+ """Log one position-check tick to hold_ticks.csv (~every 3s)."""
+ if not self._active_trade:
return
- self._current_trade["exit_type"] = exit_type
- self._current_trade["exit_price"] = round(exit_price, 4)
- self._current_trade["exit_shares_sold"] = round(exit_shares_sold, 1)
- self._current_trade["exit_revenue"] = round(exit_revenue, 2)
- self._current_trade["residual_shares"] = round(residual_shares, 1)
- self._current_trade["residual_value"] = round(residual_shares * exit_price, 2)
- self._current_trade["exit_latency_ms"] = round(latency_ms, 0)
-
- def log_trade_resolve(
+ row = {
+ "timestamp": time.time(),
+ "trade_id": self._active_trade.trade_id,
+ "window_ts": self._active_trade.window_ts,
+ "seconds_remaining": round(seconds_remaining, 1),
+ "btc_price": round(btc_price, 2),
+ "btc_delta_pct": round(btc_delta_pct, 4),
+ "prob": round(prob, 4),
+ "sell_price": round(sell_price, 4),
+ "unrealized_pnl": round(unrealized_pnl, 2),
+ "return_pct": round(return_pct, 4),
+ }
+ self._append_row(self._tick_path, row, HOLD_TICK_FIELDS)
+
+ def close_trade(
self,
btc_final_price: float,
opening_price: float,
won: bool,
- profit: float,
exit_revenue: float = 0.0,
+ claim_revenue: float = 0.0,
+ remaining_shares: float = 0.0,
resolution_method: str = "binance_fallback",
claim_result: str = "not_attempted",
- ):
- if not self._current_trade:
- return
-
- entry_cost = self._current_trade.get("entry_cost", 0)
- entry_shares = self._current_trade.get("entry_shares", 0)
- entry_price = self._current_trade.get("entry_price", 0)
- side = self._current_trade.get("side", "")
-
+ bankroll: float = 0.0,
+ ) -> dict:
+ """Close the active trade, compute profit, write CSV row.
+
+ Profit computation (centralized here — bot.py no longer computes):
+ - Exited early: profit = exit_revenue - entry_cost
+ - Won + claimed: profit = exit_revenue + claim_revenue - entry_cost
+ - Won + unclaimed: profit = exit_revenue + remaining_shares * $1 - entry_cost
+ - Lost: profit = exit_revenue - entry_cost
+
+ Returns {"profit", "won", "return_pct"} so bot.py can use the
+ values for stats/alerts without recomputing.
+ """
+ if not self._active_trade:
+ return {"profit": 0.0, "won": won, "return_pct": 0.0}
+
+ t = self._active_trade
+ entry_cost = t.entry_cost
+ entry_shares = t.entry_shares
+ shares_at_resolution = remaining_shares if remaining_shares > 0 else entry_shares
+
+ # ── Compute profit ────────────────────────────────────────
+ if resolution_method == "exited":
+ # Sold before resolution — profit is just exit proceeds
+ profit = exit_revenue - entry_cost
+ won = profit > 0
+ resolution_payout = 0.0
+ elif won:
+ resolution_payout = shares_at_resolution * 1.0
+ if claim_revenue > 0:
+ profit = exit_revenue + claim_revenue - entry_cost
+ else:
+ profit = exit_revenue + resolution_payout - entry_cost
+ else:
+ resolution_payout = 0.0
+ profit = exit_revenue - entry_cost
+
+ return_pct = (profit / entry_cost * 100) if entry_cost > 0 else 0.0
btc_delta = ((btc_final_price - opening_price) / opening_price * 100) if opening_price > 0 else 0
- resolution_payout = entry_shares * 1.0 if won else 0.0
- profit_if_held = resolution_payout - entry_cost
-
- self._current_trade["btc_final_price"] = round(btc_final_price, 2)
- self._current_trade["btc_final_delta_pct"] = round(btc_delta, 4)
- self._current_trade["won_resolution"] = won
- self._current_trade["resolution_payout"] = round(resolution_payout, 2)
- self._current_trade["resolution_method"] = resolution_method
- self._current_trade["claim_result"] = claim_result
- self._current_trade["profit"] = round(profit, 2)
- self._current_trade["return_pct"] = round(
- (profit / entry_cost * 100) if entry_cost > 0 else 0, 2
- )
- self._current_trade["profit_if_held"] = round(profit_if_held, 2)
-
- # Set defaults for missing exit fields (held to resolution)
- if "exit_type" not in self._current_trade:
- self._current_trade["exit_type"] = "resolution"
- self._current_trade["exit_price"] = 0.0
- self._current_trade["exit_shares_sold"] = 0.0
- self._current_trade["exit_revenue"] = round(exit_revenue, 2)
- self._current_trade["residual_shares"] = round(entry_shares, 1)
- self._current_trade["residual_value"] = 0.0
- self._current_trade["exit_latency_ms"] = 0
+ profit_if_held = (entry_shares * 1.0 - entry_cost) if won else -entry_cost
# Fix min_sell_price sentinel
- if self._current_trade.get("min_sell_price_seen", 999) >= 999:
- self._current_trade["min_sell_price_seen"] = 0.0
-
- # Write the complete trade record
- self._append_row(self._trade_path, self._current_trade, TRADE_FIELDS)
- self._current_trade = {}
+ min_sell = t.min_sell_price_seen if t.min_sell_price_seen < 999 else 0.0
+
+ # Build complete row: entry+hold from TradeRecord, exit+resolution computed here
+ row = asdict(t)
+ row["min_sell_price_seen"] = round(min_sell, 4)
+ row.update({
+ # Exit (defaults — bot.py doesn't currently track exit details to tracker)
+ "exit_type": "forced-exit" if resolution_method == "exited" else "hold-to-resolution",
+ "exit_price": 0.0,
+ "exit_shares_sold": 0.0,
+ "exit_revenue": round(exit_revenue, 2),
+ "residual_shares": round(shares_at_resolution, 1),
+ "residual_value": 0.0,
+ "exit_latency_ms": 0,
+ # Resolution
+ "btc_final_price": round(btc_final_price, 2),
+ "btc_final_delta_pct": round(btc_delta, 4),
+ "won_resolution": won,
+ "resolution_payout": round(resolution_payout, 2),
+ "resolution_method": resolution_method,
+ "claim_result": claim_result,
+ # P&L
+ "profit": round(profit, 2),
+ "return_pct": round(return_pct, 2),
+ "profit_if_held": round(profit_if_held, 2),
+ "bankroll": round(bankroll, 2),
+ })
+
+ self._append_row(self._trade_path, row, TRADE_FIELDS)
+ self._active_trade = None
+
+ return {"profit": round(profit, 2), "won": won, "return_pct": round(return_pct, 2)}
# ── Execution logging ───────────────────────────────────────────
@@ -448,6 +536,19 @@ def _ensure_headers(self, path: str, fields: list):
with open(path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
+ return
+
+ # Check if existing header matches current schema — if not, archive and recreate
+ with open(path, "r", newline="") as f:
+ reader = csv.reader(f)
+ existing_header = next(reader, None)
+ if existing_header and existing_header != fields:
+ archive = path.replace(".csv", "_pre_schema_change.csv")
+ os.rename(path, archive)
+ print(f"[tracker] Schema changed — archived {path} → {archive}")
+ with open(path, "w", newline="") as f:
+ writer = csv.DictWriter(f, fieldnames=fields)
+ writer.writeheader()
def _append_row(self, path: str, row: dict, fields: list):
# Only write fields that exist in the schema