diff --git a/app/pt_hub.py b/app/pt_hub.py index a6c28a32..b889dc2b 100644 --- a/app/pt_hub.py +++ b/app/pt_hub.py @@ -17,6 +17,7 @@ import urllib.error import urllib.request from dataclasses import dataclass +from decimal import Decimal from io import BytesIO from tkinter import filedialog, messagebox, ttk from typing import Any, Dict, List, Optional, Tuple @@ -27,6 +28,20 @@ from matplotlib.ticker import FuncFormatter from matplotlib.transforms import blended_transform_factory +from pt_paper_mode import ( + PAPER_MODE_BALANCE_KEY, + PAPER_MODE_SETTING_KEY, + PaperBanner, + apply_palette_to_style, + attach_trading_section_label, + fetch_binance_btc_price, + get_palette, + install_classic_widget_defaults, + read_paper_mode_from_disk, + run_sample_scenario, + settings_path_for, +) + # Multi-exchange imports try: from pt_exchange_abstraction import ExchangeType @@ -589,6 +604,11 @@ def set_values(self, long_sig: Any, short_sig: Any) -> None: "enabled": True, # Enable alert system "notification_methods": ["gui"], # Notification methods: gui, email, webhook }, + # --- Paper Trading Mode (issue #86) --- + # When True the hub paints blue (not black) and shows a PAPER TRADING + # banner + label so the user cannot mistake it for live trading. + PAPER_MODE_SETTING_KEY: False, + PAPER_MODE_BALANCE_KEY: 10000.0, } @@ -1916,10 +1936,28 @@ def __init__(self): # Debounce map for panedwindow clamp operations self._paned_clamp_after_ids: Dict[str, str] = {} - # Force one and only one theme: dark mode everywhere. + # Paper-mode flag must be known BEFORE the first theme paint, otherwise + # the window flashes dark for one frame before settling on blue. Read + # it straight from disk - _load_settings() merges defaults later. + app_dir = os.path.abspath(os.path.dirname(__file__)) + self._paper_mode = read_paper_mode_from_disk( + settings_path_for(app_dir, SETTINGS_FILE) + ) + + # Force one and only one theme: dark (or paper-blue when enabled). self._apply_forced_dark_mode() self.settings = self._load_settings() + # Keep _paper_mode in sync with merged settings in case the file was + # missing the key entirely (default = False is then authoritative). + self._paper_mode = bool( + self.settings.get(PAPER_MODE_SETTING_KEY, self._paper_mode) + ) + + # Banner widget; built on demand by _build_paper_banner. + self._paper_banner: Optional[PaperBanner] = None + self._paper_section_label: Optional[tk.Label] = None + self._paper_account = None # lazy: only spun up when sample runs # Enhanced training status tracking with atomic operations def _write_training_status(coin: str, state: str, **kwargs) -> None: @@ -2044,6 +2082,12 @@ def _write_training_status(coin: str, state: str, **kwargs) -> None: self._build_menu() self._build_layout() + # Paper-mode banner + trading-section label are packed AFTER the layout + # exists. _build_layout assigns self.trades_frame for us. + if self._paper_mode: + self._build_paper_banner() + self._attach_paper_section_label() + # Refresh charts immediately when a timeframe is changed (don't wait for the 10s throttle). self.bind_all("<>", self._on_timeframe_changed) @@ -2309,6 +2353,136 @@ def _apply_forced_dark_mode(self) -> None: except Exception: pass + # Paper-mode overlay: re-apply the palette-driven subset of the styles + # above using the paper (blue) palette. Done last so it wins. + if getattr(self, "_paper_mode", False): + palette = get_palette(True) + install_classic_widget_defaults(self, palette) + try: + apply_palette_to_style(self, style, palette) + except tk.TclError: + pass + + # ---- paper mode ---- + + def _build_paper_banner(self) -> None: + """Pack the PAPER TRADING banner across the top of the window.""" + if self._paper_banner is not None: + return + palette = get_palette(True) + self._paper_banner = PaperBanner( + self, + palette=palette, + on_run_sample=self._run_paper_sample, + ) + self._paper_banner.pack(side="top", fill="x", before=self.winfo_children()[0]) + # Refresh price on the banner immediately, non-blocking via after(). + self.after(50, self._refresh_paper_price) + + def _refresh_paper_price(self) -> None: + if not self._paper_banner: + return + price = fetch_binance_btc_price() + try: + self._paper_banner.set_price(price) + except tk.TclError: + pass + + def _attach_paper_section_label(self) -> None: + """Drop a 'PAPER TRADING' label at the top of the trades panel.""" + frame = getattr(self, "trades_frame", None) + if frame is None or self._paper_section_label is not None: + return + label = attach_trading_section_label(frame, get_palette(True)) + label.pack(side="top", fill="x", before=frame.winfo_children()[0]) + self._paper_section_label = label + + def _remove_paper_widgets(self) -> None: + if self._paper_banner is not None: + try: + self._paper_banner.destroy() + except tk.TclError: + pass + self._paper_banner = None + if self._paper_section_label is not None: + try: + self._paper_section_label.destroy() + except tk.TclError: + pass + self._paper_section_label = None + + def _toggle_paper_mode(self) -> None: + """File-menu callback. Persists the flag and prompts a restart so + matplotlib chart facecolors and option_add classic-widget defaults + pick up the new palette cleanly (those can't be re-skinned live).""" + new_value = bool(self._paper_mode_var.get()) + self._paper_mode = new_value + self.settings[PAPER_MODE_SETTING_KEY] = new_value + try: + self._save_settings() + except Exception as exc: + messagebox.showerror( + "Paper mode", + f"Could not save paper-mode preference: {exc}", + ) + # Roll back the var so the menu reflects reality. + self._paper_mode_var.set(not new_value) + self._paper_mode = not new_value + self.settings[PAPER_MODE_SETTING_KEY] = not new_value + return + + # Immediate visible feedback - banner/label toggle without restart. + if new_value: + self._build_paper_banner() + self._attach_paper_section_label() + else: + self._remove_paper_widgets() + self._paper_account = None + + messagebox.showinfo( + "Paper mode", + ( + "Paper mode " + + ("enabled" if new_value else "disabled") + + ".\n\nRestart PowerTrader to fully repaint charts and " + "text panels in the matching palette." + ), + ) + + def _run_paper_sample(self) -> None: + """Banner button handler. Runs the live-BTC buy/sell scenario.""" + if not self._paper_mode: + return + if self._paper_account is None: + try: + from pt_paper_trading import ( # local import: heavy module + PaperTradingAccount as _PaperAcct, + ) + + balance = Decimal( + str(self.settings.get(PAPER_MODE_BALANCE_KEY, 10000.0)) + ) + self._paper_account = _PaperAcct(initial_balance=balance) + except Exception as exc: + messagebox.showerror("Paper sample", f"Account init failed: {exc}") + return + try: + result = run_sample_scenario(self._paper_account) + except Exception as exc: + messagebox.showerror("Paper sample", f"Sample run failed: {exc}") + return + if self._paper_banner is not None: + self._paper_banner.set_price( + Decimal(str(result["live_price"])) + if result.get("live_price") is not None + else None + ) + delta = result["final_balance"] - result["starting_balance"] + self._paper_banner.set_result( + f"buy ${result['buy_price']:,.2f} -> sell ${result['sell_price']:,.2f} " + f"({'+' if delta >= 0 else ''}${delta:,.4f})" + ) + # ---- settings ---- def _load_settings(self) -> dict: @@ -2425,6 +2599,16 @@ def _build_menu(self) -> None: activebackground=DARK_SELECT_BG, activeforeground=DARK_SELECT_FG, ) + # Paper mode toggle (owner spec: File menu so users don't need the CLI). + self._paper_mode_var = tk.BooleanVar(value=self._paper_mode) + m_file.add_checkbutton( + label="Paper Mode", + onvalue=True, + offvalue=False, + variable=self._paper_mode_var, + command=self._toggle_paper_mode, + ) + m_file.add_separator() m_file.add_command(label="Exit", command=self._on_close) menubar.add_cascade(label="File", menu=m_file) @@ -3157,6 +3341,9 @@ def _do_refresh_visible(): trades_frame = ttk.LabelFrame(trades_tab, text="Current Trades") trades_frame.pack(fill="both", expand=True, padx=6, pady=6) + # Exposed so the paper-mode hook can pack a "PAPER TRADING" label + # at the top of the trading section without re-walking the widget tree. + self.trades_frame = trades_frame cols = ( "coin", diff --git a/app/pt_paper_mode.py b/app/pt_paper_mode.py new file mode 100644 index 00000000..9dde2eac --- /dev/null +++ b/app/pt_paper_mode.py @@ -0,0 +1,363 @@ +""" +Paper trading mode helpers for the PowerTrader hub. + +Provides: +- Palette that paints the hub blue (instead of black) when paper mode is on, + so the user can instantly see they are not trading real money. +- "PAPER TRADING" banner label that gets injected into the trading section. +- A sample-trade runner that pulls a live BTC price from Binance's public + ticker endpoint and routes a simulated BUY then SELL through + PaperTradingAccount. +- Persistence helper so the File-menu toggle survives a restart. + +This module is deliberately small and self-contained so pt_hub.py changes +remain surgical. All side effects on the hub happen through the helper +functions exported here; pt_hub.py never reaches into Tk internals from a +paper-mode branch directly. +""" + +from __future__ import annotations + +import json +import os +import tkinter as tk +import urllib.error +import urllib.request +from decimal import Decimal +from tkinter import ttk +from typing import Any, Callable, Dict, Optional + +from pt_paper_trading import OrderSide, OrderType, PaperTradingAccount + +# --- Palettes ----------------------------------------------------------------- +# Owner spec (PR #90 comment): "paper mode changes the CSS of the background. +# Say BLUE instead of BLACK." Tk has no CSS, so we mirror the dark palette key +# names and let the hub apply whichever one matches the current mode. + +DARK_PALETTE: Dict[str, str] = { + "BG": "#070B10", + "BG2": "#0B1220", + "PANEL": "#0E1626", + "PANEL2": "#121C2F", + "BORDER": "#243044", + "FG": "#C7D1DB", + "MUTED": "#8B949E", + "ACCENT": "#00FF66", + "ACCENT2": "#00E5FF", + "SELECT_BG": "#17324A", + "SELECT_FG": "#00FF66", +} + +# Blue family for paper mode. Same key names so the same style code paths work. +# ACCENT swapped to a warm gold so "PAPER TRADING" pops against the blue. +PAPER_PALETTE: Dict[str, str] = { + "BG": "#0A1B3A", + "BG2": "#11264D", + "PANEL": "#152E5C", + "PANEL2": "#1B3870", + "BORDER": "#2E4A82", + "FG": "#E6EEF8", + "MUTED": "#9AB0CC", + "ACCENT": "#FFC400", + "ACCENT2": "#FFE082", + "SELECT_BG": "#23488A", + "SELECT_FG": "#FFC400", +} + + +def get_palette(paper_mode: bool) -> Dict[str, str]: + return PAPER_PALETTE if paper_mode else DARK_PALETTE + + +# --- Settings persistence ----------------------------------------------------- +PAPER_MODE_SETTING_KEY = "paper_mode_enabled" +PAPER_MODE_BALANCE_KEY = "paper_mode_balance" + + +def is_paper_mode(settings: Optional[Dict[str, Any]]) -> bool: + if not settings: + return False + return bool(settings.get(PAPER_MODE_SETTING_KEY, False)) + + +# --- Live BTC price (Binance public ticker, no auth) ------------------------- +BINANCE_TICKER_URL = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT" + + +def fetch_binance_btc_price( + timeout: float = 5.0, + url: str = BINANCE_TICKER_URL, + opener: Optional[Callable[[str, float], Any]] = None, +) -> Optional[Decimal]: + """ + Pull the current BTC/USDT price from Binance's public REST endpoint. + + Returns None on any failure - the sample runner falls back to the + PaperTradingAccount's own MarketDataSimulator so the demo still works + when offline. `opener` is overridable for tests. + """ + try: + if opener is None: + with urllib.request.urlopen(url, timeout=timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + else: + payload = json.loads(opener(url, timeout)) + price = payload.get("price") + if price is None: + return None + return Decimal(str(price)) + except (urllib.error.URLError, ValueError, KeyError, OSError): + return None + + +# --- Sample-scenario runner --------------------------------------------------- +def run_sample_scenario( + account: PaperTradingAccount, + quantity: Decimal = Decimal("0.001"), + symbol: str = "BTC", + price_fetcher: Callable[[], Optional[Decimal]] = fetch_binance_btc_price, +) -> Dict[str, Any]: + """ + Place a simulated BUY then SELL through the paper account, using a live + BTC price when available. Mirrors the demo flow from the now-retired + standalone demo_paper_trading.py but lives behind the hub's "Run sample" + button. + + Returns a dict the GUI can render directly. Keys: + starting_balance, final_balance, buy_price, sell_price, source + """ + starting = account.cash_balance + live_price = price_fetcher() + source = "binance_public" if live_price is not None else "simulated" + + # Seed the simulator so BUY/SELL fills land near the live spot price + # instead of the simulator's default $45k anchor. + if live_price is not None: + account.market_simulator.current_prices[symbol] = live_price + + buy_id = account.place_order( + symbol=symbol, + order_type=OrderType.MARKET, + side=OrderSide.BUY, + quantity=quantity, + ) + buy_order = account.orders[buy_id] + + sell_id = account.place_order( + symbol=symbol, + order_type=OrderType.MARKET, + side=OrderSide.SELL, + quantity=quantity, + ) + sell_order = account.orders[sell_id] + + return { + "source": source, + "starting_balance": float(starting), + "final_balance": float(account.cash_balance), + "buy_price": float(buy_order.filled_price), + "sell_price": float(sell_order.filled_price), + "buy_status": buy_order.status.value, + "sell_status": sell_order.status.value, + "live_price": float(live_price) if live_price is not None else None, + } + + +# --- Tk widgets --------------------------------------------------------------- +class PaperBanner(tk.Frame): + """ + Top-of-window banner that screams PAPER TRADING. Visible only when + paper mode is on. Single tk.Frame so we can pack/forget cheaply on + toggle without rebuilding the layout. + """ + + def __init__( + self, + parent: tk.Misc, + palette: Dict[str, str], + on_run_sample: Callable[[], None], + **kwargs: Any, + ) -> None: + super().__init__( + parent, + bg=palette["ACCENT"], + highlightthickness=0, + bd=0, + **kwargs, + ) + self._palette = palette + + label = tk.Label( + self, + text="PAPER TRADING", + bg=palette["ACCENT"], + fg=palette["BG"], + font=("Segoe UI", 14, "bold"), + padx=14, + pady=4, + ) + label.pack(side="left") + + self.price_var = tk.StringVar(value="Live BTC: --") + price_lbl = tk.Label( + self, + textvariable=self.price_var, + bg=palette["ACCENT"], + fg=palette["BG"], + font=("Segoe UI", 10), + padx=10, + ) + price_lbl.pack(side="left") + + run_btn = tk.Button( + self, + text="Run sample trade", + command=on_run_sample, + bg=palette["BG2"], + fg=palette["ACCENT"], + activebackground=palette["PANEL2"], + activeforeground=palette["ACCENT2"], + relief="flat", + padx=10, + pady=2, + ) + run_btn.pack(side="right", padx=6, pady=2) + + self.result_var = tk.StringVar(value="") + result_lbl = tk.Label( + self, + textvariable=self.result_var, + bg=palette["ACCENT"], + fg=palette["BG"], + font=("Segoe UI", 9, "italic"), + padx=8, + ) + result_lbl.pack(side="right") + + def set_price(self, price: Optional[Decimal]) -> None: + if price is None: + self.price_var.set("Live BTC: offline (using sim)") + else: + self.price_var.set(f"Live BTC: ${float(price):,.2f}") + + def set_result(self, text: str) -> None: + self.result_var.set(text) + + +def attach_trading_section_label(parent: tk.Misc, palette: Dict[str, str]) -> tk.Label: + """ + Owner spec: "in the trading section, add the words 'PAPER TRADING'. + In a contrasting font colour." Returns a tk.Label the caller packs above + the trades table. Caller is responsible for destroying it on toggle off. + """ + return tk.Label( + parent, + text="PAPER TRADING", + bg=palette["BG"], + fg=palette["ACCENT"], + font=("Segoe UI", 12, "bold"), + pady=2, + ) + + +# --- Style application ------------------------------------------------------- +def apply_palette_to_style( + root: tk.Misc, style: ttk.Style, palette: Dict[str, str] +) -> None: + """ + Re-runs the subset of pt_hub's `_apply_forced_dark_mode` styling that is + palette-driven, but using whichever palette was passed. Kept in sync with + pt_hub.py by hand - if you add a new ttk style there, mirror it here. + """ + try: + root.configure(bg=palette["BG"]) + except tk.TclError: + pass + + style.configure(".", background=palette["BG"], foreground=palette["FG"]) + + for name in ("TFrame", "TLabel", "TCheckbutton", "TRadiobutton"): + style.configure(name, background=palette["BG"], foreground=palette["FG"]) + + style.configure( + "TLabelframe", + background=palette["BG"], + foreground=palette["FG"], + bordercolor=palette["BORDER"], + ) + style.configure( + "TLabelframe.Label", + background=palette["BG"], + foreground=palette["ACCENT"], + ) + + style.configure("TSeparator", background=palette["BORDER"]) + + style.configure( + "TButton", + background=palette["BG2"], + foreground=palette["FG"], + bordercolor=palette["BORDER"], + focusthickness=1, + focuscolor=palette["ACCENT"], + padding=(3, 2), + ) + style.map( + "TButton", + background=[ + ("active", palette["PANEL2"]), + ("pressed", palette["PANEL"]), + ("disabled", palette["BG2"]), + ], + foreground=[ + ("active", palette["ACCENT"]), + ("disabled", palette["MUTED"]), + ], + ) + + +def install_classic_widget_defaults(root: tk.Misc, palette: Dict[str, str]) -> None: + """ + Tk classic widgets read their colors at creation time via option_add. + We call this once before the layout is built so newly-created Text, + Listbox and Menu widgets pick up paper-mode colors. + """ + try: + root.option_add("*Text.background", palette["PANEL"]) + root.option_add("*Text.foreground", palette["FG"]) + root.option_add("*Text.insertBackground", palette["FG"]) + root.option_add("*Text.selectBackground", palette["SELECT_BG"]) + root.option_add("*Text.selectForeground", palette["SELECT_FG"]) + + root.option_add("*Listbox.background", palette["PANEL"]) + root.option_add("*Listbox.foreground", palette["FG"]) + root.option_add("*Listbox.selectBackground", palette["SELECT_BG"]) + root.option_add("*Listbox.selectForeground", palette["SELECT_FG"]) + + root.option_add("*Menu.background", palette["BG2"]) + root.option_add("*Menu.foreground", palette["FG"]) + root.option_add("*Menu.activeBackground", palette["SELECT_BG"]) + root.option_add("*Menu.activeForeground", palette["SELECT_FG"]) + except tk.TclError: + pass + + +# --- Read settings without instantiating the hub ---------------------------- +def read_paper_mode_from_disk(settings_path: str) -> bool: + """ + Used by pt_hub before settings are loaded into self so the very first + `_apply_forced_dark_mode` call can paint the window the right color + immediately - no flash of dark theme before paper mode kicks in. + """ + try: + with open(settings_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError): + return False + if not isinstance(data, dict): + return False + return bool(data.get(PAPER_MODE_SETTING_KEY, False)) + + +def settings_path_for(app_dir: str, filename: str = "gui_settings.json") -> str: + return os.path.join(app_dir, filename) diff --git a/app/pt_paper_trading.py b/app/pt_paper_trading.py index cb64c427..f19b274c 100644 --- a/app/pt_paper_trading.py +++ b/app/pt_paper_trading.py @@ -5,7 +5,6 @@ without risking real capital. """ -import asyncio import json import os import random @@ -591,59 +590,8 @@ def save_portfolio_snapshot(self): ] -# Example usage and testing -async def demo_paper_trading(): - """Demonstrate paper trading functionality.""" - print("PowerTraderAI+ Paper Trading Demo") - print("=" * 40) - - # Create account - account = PaperTradingAccount(initial_balance=Decimal("10000")) - - # Display initial state - summary = account.get_account_summary() - print(f"Initial Balance: ${summary['cash_balance']:.2f}") - - # Place some test orders - print("\nPlacing test orders...") - - # Buy BTC - btc_order = account.place_order( - symbol="BTC", - order_type=OrderType.MARKET, - side=OrderSide.BUY, - quantity=Decimal("0.1"), - ) - print(f"BTC Buy Order: {btc_order}") - - # Buy ETH - eth_order = account.place_order( - symbol="ETH", - order_type=OrderType.MARKET, - side=OrderSide.BUY, - quantity=Decimal("2.0"), - ) - print(f"ETH Buy Order: {eth_order}") - - # Wait a bit and update prices - await asyncio.sleep(1) - account.update_market_prices() - - # Show updated portfolio - print("\nPortfolio after purchases:") - summary = account.get_account_summary() - print(f"Cash: ${summary['cash_balance']:.2f}") - print(f"Total Value: ${summary['total_value']:.2f}") - print(f"P&L: ${summary['total_pnl']:.2f} ({summary['total_return_pct']:.2f}%)") - - for symbol, pos_data in summary["positions"].items(): - print( - f" {symbol}: {pos_data['quantity']:.4f} @ ${pos_data['avg_price']:.2f} " - f"(Value: ${pos_data['market_value']:.2f}, " - f"P&L: ${pos_data['unrealized_pnl']:.2f})" - ) - - -if __name__ == "__main__": - # Run demo - asyncio.run(demo_paper_trading()) +# Note: the standalone async `demo_paper_trading()` previously living here was +# removed in favour of the in-hub "Run sample" button (see pt_paper_mode.py). +# Keeping the demo as a standalone script duplicated the buy/sell scenario and +# encouraged running paper trades outside the GUI - exactly the workflow PR #90 +# was asked to stop supporting. diff --git a/app/test_paper_mode.py b/app/test_paper_mode.py new file mode 100644 index 00000000..579c82e0 --- /dev/null +++ b/app/test_paper_mode.py @@ -0,0 +1,168 @@ +""" +Tests for pt_paper_mode helpers. + +These tests intentionally avoid spinning up a Tk root - the helpers exposed by +pt_paper_mode that touch Tk are exercised separately via test_paper_mode_gui +(opt-in) so the CI box without a display still passes. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from decimal import Decimal +from unittest import mock + +from pt_paper_mode import ( + DARK_PALETTE, + PAPER_MODE_SETTING_KEY, + PAPER_PALETTE, + fetch_binance_btc_price, + get_palette, + is_paper_mode, + read_paper_mode_from_disk, + run_sample_scenario, + settings_path_for, +) +from pt_paper_trading import PaperTradingAccount + + +class TestPalette(unittest.TestCase): + """Palette pairs must mirror each other key-for-key.""" + + def test_dark_palette_keys_match_paper_palette(self): + self.assertEqual(set(DARK_PALETTE.keys()), set(PAPER_PALETTE.keys())) + + def test_get_palette_paper_is_blue(self): + # BG hex starts with 0A1B = blueish; basic sanity check that we did not + # accidentally swap the constants. + self.assertTrue(get_palette(True)["BG"].lower().startswith("#0a1b")) + self.assertTrue(get_palette(False)["BG"].lower().startswith("#070b")) + + +class TestSettingsPersistence(unittest.TestCase): + def test_read_paper_mode_from_missing_file_is_false(self): + with tempfile.TemporaryDirectory() as d: + self.assertFalse(read_paper_mode_from_disk(os.path.join(d, "x.json"))) + + def test_read_paper_mode_from_disk_true(self): + with tempfile.TemporaryDirectory() as d: + path = settings_path_for(d, "gui_settings.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({PAPER_MODE_SETTING_KEY: True}, f) + self.assertTrue(read_paper_mode_from_disk(path)) + + def test_read_paper_mode_from_disk_garbage_is_false(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "bad.json") + with open(path, "w", encoding="utf-8") as f: + f.write("{not json") + self.assertFalse(read_paper_mode_from_disk(path)) + + def test_is_paper_mode_helper(self): + self.assertFalse(is_paper_mode(None)) + self.assertFalse(is_paper_mode({})) + self.assertFalse(is_paper_mode({PAPER_MODE_SETTING_KEY: False})) + self.assertTrue(is_paper_mode({PAPER_MODE_SETTING_KEY: True})) + + +class TestBinanceTickerFetch(unittest.TestCase): + def test_fetch_returns_decimal_on_success(self): + def fake_opener(url, timeout): + return json.dumps({"symbol": "BTCUSDT", "price": "76460.58"}) + + price = fetch_binance_btc_price(opener=fake_opener) + self.assertIsNotNone(price) + self.assertEqual(price, Decimal("76460.58")) + + def test_fetch_returns_none_on_missing_price_key(self): + def fake_opener(url, timeout): + return json.dumps({"symbol": "BTCUSDT"}) + + self.assertIsNone(fetch_binance_btc_price(opener=fake_opener)) + + def test_fetch_returns_none_on_url_error(self): + def fake_opener(url, timeout): + raise OSError("network down") + + self.assertIsNone(fetch_binance_btc_price(opener=fake_opener)) + + +class TestSampleScenario(unittest.TestCase): + def test_buy_then_sell_with_live_price(self): + account = PaperTradingAccount(initial_balance=Decimal("10000")) + seed = Decimal("76000") + result = run_sample_scenario( + account, + quantity=Decimal("0.001"), + price_fetcher=lambda: seed, + ) + self.assertEqual(result["source"], "binance_public") + self.assertEqual(result["buy_status"], "filled") + self.assertEqual(result["sell_status"], "filled") + # The PaperTradingAccount calls MarketDataSimulator.get_current_price + # multiple times per order (risk check + cost estimate + execution), + # and each call drifts the simulator by up to +-0.5%. Across both the + # buy and the sell paths that compounds to about +-3% from the seed, + # so allow 5% headroom here - the assertion is "near the live price", + # not exact. + self.assertAlmostEqual(result["buy_price"], 76000.0, delta=float(seed) * 0.05) + self.assertAlmostEqual(result["sell_price"], 76000.0, delta=float(seed) * 0.05) + + def test_falls_back_when_fetch_returns_none(self): + account = PaperTradingAccount(initial_balance=Decimal("10000")) + result = run_sample_scenario( + account, + quantity=Decimal("0.001"), + price_fetcher=lambda: None, + ) + self.assertEqual(result["source"], "simulated") + self.assertIsNone(result["live_price"]) + self.assertEqual(result["buy_status"], "filled") + self.assertEqual(result["sell_status"], "filled") + + def test_account_balance_round_trip_close_to_start(self): + # Buy then immediate sell at a live-seeded price should leave the + # account close to its starting balance. The exact delta is dominated + # by simulator drift (per-call +-0.5%, called multiple times per + # order) plus 0.1% commission on each side. On 0.001 BTC at $76k that + # is roughly $0.15 in commission + a few dollars of drift noise, so + # allow $20 of headroom to keep the test non-flaky on CI. + account = PaperTradingAccount(initial_balance=Decimal("10000")) + result = run_sample_scenario( + account, + quantity=Decimal("0.001"), + price_fetcher=lambda: Decimal("76000"), + ) + delta = abs(result["final_balance"] - result["starting_balance"]) + self.assertLess(delta, 20.0) + + +class TestUrllibIntegration(unittest.TestCase): + """Sanity check the default opener path actually calls urllib.""" + + def test_default_opener_uses_urlopen(self): + fake_payload = json.dumps({"symbol": "BTCUSDT", "price": "100.00"}).encode() + + class FakeResp: + def read(self): + return fake_payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + with mock.patch( + "pt_paper_mode.urllib.request.urlopen", return_value=FakeResp() + ) as patched: + price = fetch_binance_btc_price() + self.assertTrue(patched.called) + self.assertEqual(price, Decimal("100.00")) + + +if __name__ == "__main__": + unittest.main()