From 8f58caf49fd6fb91d2d6caf19e969600d15a1ff6 Mon Sep 17 00:00:00 2001 From: Omar Alaaeldein Date: Sun, 13 Sep 2026 04:39:27 -0400 Subject: [PATCH 1/2] fix: isolate GUI requests and correct scan inputs and persistence --- .github/workflows/release.yml | 17 +- build_macos.sh | 4 + core/expiry_time.py | 37 ++++ core/graph_service.py | 31 +-- core/scan_service.py | 74 +++++--- core/stock_graph.py | 9 +- docs/RELIABILITY_REVIEW.md | 39 ++++ docs/github-actions-release.yml | 17 +- main/app.py | 218 +++++++++++++-------- main/cli.py | 1 + main/graph_cli.py | 15 +- requirements-ci.txt | 1 + requirements-release.txt | 1 + requirements.txt | 21 +- scripts/build_release.ps1 | 4 + scripts/build_release.sh | 8 + scripts/resolve_release_tag.sh | 13 ++ tests/test_review_regressions.py | 317 +++++++++++++++++++++++++++++++ ui/chart.py | 5 +- ui/options_3d.py | 34 ++-- ui/options_explorer.py | 18 +- ui/prefs.py | 13 +- ui/stock_graph.py | 2 +- ui/storage.py | 37 ++++ ui/watchlist.py | 9 +- 25 files changed, 745 insertions(+), 200 deletions(-) create mode 100644 core/expiry_time.py create mode 100644 docs/RELIABILITY_REVIEW.md create mode 100644 scripts/resolve_release_tag.sh create mode 100644 tests/test_review_regressions.py create mode 100644 ui/storage.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82cc3c2..9ecfe83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,20 +98,9 @@ jobs: - name: Resolve tag name id: tag shell: bash - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG="${{ github.event.inputs.version }}" - else - TAG="${GITHUB_REF_NAME}" - fi - # Normalize: strip leading whitespace - TAG="${TAG# }" - if [[ -z "$TAG" ]]; then - echo "Empty tag" >&2 - exit 1 - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Using tag: $TAG" + env: + DISPATCH_VERSION: ${{ github.event.inputs.version }} + run: bash scripts/resolve_release_tag.sh - name: Download all build artifacts uses: actions/download-artifact@v4 diff --git a/build_macos.sh b/build_macos.sh index 54823ba..63abe2b 100755 --- a/build_macos.sh +++ b/build_macos.sh @@ -205,6 +205,10 @@ PYI_ARGS=( --exclude-module matplotlib.backends.backend_gtk4cairo --exclude-module matplotlib.backends.backend_nbagg --exclude-module matplotlib.backends.backend_cairo + --collect-all pandas_market_calendars + --collect-all exchange_calendars + --collect-all tzdata + --add-data "$ROOT/data:data" --collect-submodules matplotlib ) diff --git a/core/expiry_time.py b/core/expiry_time.py new file mode 100644 index 0000000..5ce9535 --- /dev/null +++ b/core/expiry_time.py @@ -0,0 +1,37 @@ +"""US equity-option remaining session time (NYSE calendar proxy). + +Volatility and rates use a common 252 x 6.5-hour trading-year clock, preserving +Sentinel's trading-day convention. This is not an ACT/365 rate conversion. Early +closes contribute only their actual session duration. AM-settled/index products +need a product-specific calendar/settlement adapter and are not supported here. +""" +from datetime import datetime, timezone +from functools import lru_cache + +import pandas as pd + + +@lru_cache(maxsize=64) +def _schedule(start, end, calendar_name): + import pandas_market_calendars as calendars + return calendars.get_calendar(calendar_name).schedule(start_date=start, end_date=end) + + +def remaining_years(expiry, valuation_time=None, *, calendar_name="NYSE"): + """Remaining open-session seconds through expiry close; zero after expiry. + + Explicit valuation times must be timezone-aware. A non-session expiry uses + the preceding session's close. All comparisons use UTC, independent of host TZ. + """ + now = pd.Timestamp(valuation_time if valuation_time is not None else datetime.now(timezone.utc)) + if now.tzinfo is None: + raise ValueError("valuation_time must be timezone-aware") + now = now.tz_convert("UTC") + start = now.tz_convert("America/New_York").date() + end = pd.Timestamp(expiry).date() + if end < start: + return 0.0 + schedule = _schedule(start.isoformat(), end.isoformat(), calendar_name) + seconds = sum(max(0.0, (row.market_close - max(now, row.market_open)).total_seconds()) + for row in schedule.itertuples()) + return seconds / (252.0 * 6.5 * 3600.0) diff --git a/core/graph_service.py b/core/graph_service.py index 5e8e8dd..e37f845 100644 --- a/core/graph_service.py +++ b/core/graph_service.py @@ -30,25 +30,27 @@ def categorize_peers(graph: StockGraph, ticker: str) -> Dict[str, Any]: node = graph.get_node(sym) if not node: raise ValueError(f"Ticker {sym!r} not found in stock graph.") - neighbors = graph.get_neighbors(sym, depth=1) categorized: Dict[str, List[dict]] = {} - for n, edge in neighbors: - categorized.setdefault(edge.relation, []).append( - { - "ticker": n.ticker, - "name": n.name, - "sector": n.sector, - "sub_industry": n.sub_industry, - "description": edge.description, - "weight": edge.weight, - } - ) + for edge in graph.edges: + if sym not in (edge.source, edge.target): + continue + peer = edge.target if edge.source == sym else edge.source + n = graph.get_node(peer) + if n is None: + continue + categorized.setdefault(edge.relation, []).append({ + "ticker": n.ticker, "name": n.name, "sector": n.sector, + "sub_industry": n.sub_industry, "description": edge.description, + "weight": edge.weight, "source": edge.source, "target": edge.target, + "direction": "bidirectional" if edge.bidirectional else "outgoing" if edge.source == sym else "incoming", + }) return { "action": "peers", "ticker": sym, "node": node.to_dict(), "peer_categories": categorized, - "peer_count": sum(len(v) for v in categorized.values()), + "peer_count": len({p["ticker"] for peers in categorized.values() for p in peers}), + "relationship_count": sum(len(v) for v in categorized.values()), } @@ -62,6 +64,7 @@ def show_connections( "action": "summary", "total_nodes": len(nodes), "total_edges": len(graph.edges), + "import_status": getattr(graph, "import_status", {"status": "not requested"}), "sectors": sorted({n["sector"] for n in nodes}), "tickers": sorted(n["ticker"] for n in nodes), } @@ -77,6 +80,8 @@ def show_connections( "name": n.name, "sector": n.sector, "relation": edge.relation, + "source": edge.source, + "target": edge.target, "description": edge.description, "weight": edge.weight, } diff --git a/core/scan_service.py b/core/scan_service.py index e291d82..1289820 100644 --- a/core/scan_service.py +++ b/core/scan_service.py @@ -16,6 +16,7 @@ import pandas as pd from core.pricing import VegaChimpCore +from core.expiry_time import remaining_years from core.technicals import calculate_technicals from core.vol_models import ( blend_forecast_vol, @@ -145,7 +146,7 @@ def _as_decimal_yield(value: Any, *, percent_units: bool) -> Optional[float]: return d -def resolve_dividend_yield(data_provider, stock_obj) -> float: +def resolve_dividend_yield(data_provider, stock_obj, *, status=None) -> float: """Dividend yield as a decimal fraction. Per-source units (verified against live yfinance quotes, 2026-09): @@ -155,27 +156,29 @@ def resolve_dividend_yield(data_provider, stock_obj) -> float: old ``> 1`` heuristic misread as 98% / 34%. Priority: fast_info → info.dividendYield → trailing; else 0.0. """ + status = {} if status is None else status + status.update(source="missing", errors=[]) try: fast_info = data_provider.get_fast_info(stock_obj) - fast_div = _as_decimal_yield( - fast_info.get("dividend_yield") - if isinstance(fast_info, dict) - else getattr(fast_info, "get", lambda *a: None)("dividend_yield"), - percent_units=False, - ) - if fast_div is not None: - return fast_div + value = _as_decimal_yield(fast_info.get("dividend_yield"), percent_units=False) + if value is not None: + status["source"] = "fast_info.dividend_yield" + return value + except Exception as exc: + status["errors"].append({"source": "fast_info", "error": str(exc)}) + try: info = data_provider.get_info(stock_obj) - div = _as_decimal_yield(info.get("dividendYield"), percent_units=True) - if div is not None: - return div - div = _as_decimal_yield( - info.get("trailingAnnualDividendYield"), percent_units=False - ) - if div is not None: - return div - except Exception: - pass + except Exception as exc: + status["errors"].append({"source": "info", "error": str(exc)}) + info = {} + for field, percent in (("dividendYield", True), ("trailingAnnualDividendYield", False)): + try: + value = _as_decimal_yield(info.get(field), percent_units=percent) + if value is not None: + status["source"] = f"info.{field}" + return value + except Exception as exc: + status["errors"].append({"source": field, "error": str(exc)}) return 0.0 @@ -264,6 +267,10 @@ class OptionScanRow: verdict: str is_earnings: bool = False tag: str = "" + bid: float = 0.0 + ask: float = 0.0 + category: Optional[str] = None + probability_model: str = "risk-neutral lognormal, market IV, long entry at ask" def tree_vals(self) -> tuple: """Tuple matching Options Explorer column order.""" @@ -288,7 +295,9 @@ def tree_vals(self) -> tuple: ) def to_dict(self) -> dict: - return asdict(self) + data = asdict(self) + data["category"] = self.category or self.verdict.rstrip(" !") + return data @dataclass @@ -298,6 +307,7 @@ class ScanResult: forecast_vol: float = 0.0 spot: float = 0.0 dividend_yield: float = 0.0 + dividend_status: dict = field(default_factory=dict) rules_log: str = SCAN_RULES_LOG errors: List[dict] = field(default_factory=list) requested_expiries: int = 0 @@ -443,6 +453,7 @@ def scan_option_chains( long_rate: Optional[float] = None, log: Optional[LogFn] = None, on_ui_batch: Optional[UiBatchFn] = None, + valuation_time: Optional[datetime] = None, ) -> ScanResult: """Run the Options Finder scan over ``dates``. @@ -455,8 +466,9 @@ def _log(msg: str) -> None: log(msg) spot = float(spot) + dividend_status = {"source": "override", "errors": []} if dividend_yield is None: - dividend_yield = resolve_dividend_yield(data_provider, stock) + dividend_yield = resolve_dividend_yield(data_provider, stock, status=dividend_status) DIV_YIELD = float(dividend_yield) if short_rate is None or long_rate is None: @@ -472,7 +484,7 @@ def _log(msg: str) -> None: ) opt_type = (option_type or "all").strip().lower() - today = datetime.now().date() + valuation_time = valuation_time or pd.Timestamp.now(tz="UTC") ui_batch: List[Tuple[tuple, str]] = [] under_rows: List[Tuple[float, OptionScanRow]] = [] rows_out: List[OptionScanRow] = [] @@ -495,13 +507,10 @@ def flush_ui() -> None: _log(SCAN_RULES_LOG) rules_logged = True - exp_date = datetime.strptime(date, "%Y-%m-%d").date() - trading_days = int(np.busday_count(today, exp_date)) - if trading_days < 0: + T = remaining_years(date, valuation_time) + if T <= 0: _log(f"Skipping expired contract {date}") continue - # 0DTE (expires today): half a session remains on average. - T = max(trading_days / 252.0, 0.5 / 252.0) RFR = interpolate_rfr(_short_rate, _long_rate, T) chain = data_provider.get_option_chain(stock, date) @@ -644,9 +653,9 @@ def flush_ui() -> None: try: if kind_str == "call": - breakeven_price = strike + market_price + breakeven_price = strike + a else: - breakeven_price = strike - market_price + breakeven_price = strike - a if breakeven_price <= 0: pop = 0.0 elif kind_str == "call": @@ -680,9 +689,10 @@ def flush_ui() -> None: "vol": float(vol_v[j]), "is_earnings": is_earnings, "is_good": is_undervalued, + "verdict": verdict, }) - breakeven = strike + market_price if kind_str == "call" else strike - market_price + breakeven = strike + a if kind_str == "call" else strike - a tag = "" if is_undervalued: tag = "green" @@ -699,6 +709,8 @@ def flush_ui() -> None: volume=float(vol_v[j]), oi=oi, mid=market_price, + bid=b, + ask=a, spread_pct=sp, breakeven=breakeven, iv=iv, @@ -711,6 +723,7 @@ def flush_ui() -> None: vega=float(greeks["vega"]), pop=float(pop), verdict=display_verdict, + category=verdict, is_earnings=is_earnings, tag=tag, ) @@ -746,6 +759,7 @@ def flush_ui() -> None: forecast_vol=forecast_vol, spot=spot, dividend_yield=DIV_YIELD, + dividend_status=dividend_status, rules_log=SCAN_RULES_LOG, errors=errors, requested_expiries=len(dates), diff --git a/core/stock_graph.py b/core/stock_graph.py index 71dc49b..06e88e4 100644 --- a/core/stock_graph.py +++ b/core/stock_graph.py @@ -149,6 +149,8 @@ def get_neighbors( relation_types: Optional[Sequence[str]] = None, ) -> List[Tuple[StockNode, GraphEdge]]: """Find neighboring nodes up to a certain depth.""" + if not isinstance(depth, int) or depth < 1: + raise ValueError("depth must be a positive integer") sym = ticker.upper() if sym not in self._nodes: return [] @@ -373,9 +375,8 @@ def build_default_graph(*, include_sectivia: bool = True) -> StockGraph: try: from core.sectivia_import import merge_cached_sectivia - merge_cached_sectivia(g, optional=True) - except Exception: - # Offline / missing optional deps must not break default graph. - pass + g.import_status = merge_cached_sectivia(g, optional=True) + except Exception as exc: + g.import_status = {"status": "error", "error": str(exc)} return g diff --git a/docs/RELIABILITY_REVIEW.md b/docs/RELIABILITY_REVIEW.md new file mode 100644 index 0000000..cb9148f --- /dev/null +++ b/docs/RELIABILITY_REVIEW.md @@ -0,0 +1,39 @@ +# September reliability fixes + +Options scans now capture GUI inputs before launching workers. Only callbacks for +the current ticker, options window and scan request can publish rows; opening a +new options window replaces the old one. Fundamentals compute on isolated state +and publish atomically on the Tk thread. Existing 3D windows keep their ticker +snapshot for subsequent redraws and exports. + +Dividend resolution records its source and individual source failures in CLI JSON +(`dividend_status`); failed fast-info requests still try ordinary/trailing yield. +Missing yield is distinguishable from a confirmed zero. Option rows expose bid, +ask and a canonical category separately from the display verdict. Long breakeven +and risk-neutral PoP use ask as entry price. Fair contracts remain Fair in 3D views +and have their own filter. + +Remaining expiry time uses the NYSE session calendar, including holidays and early +closes. `scan_option_chains(valuation_time=...)` accepts an aware valuation time for +repeatable calculations. All rate and volatility terms share the existing trading +year convention: remaining session seconds divided by 252 × 6.5 hours. This is an +explicit model convention, not an ACT/365 conversion or an independently calibrated +pricing model. After expiry close a chain is skipped. This calendar applies to US +equity options; AM-settled/index contracts require a product-specific adapter. + +GUI settings use `SENTINEL_CONFIG_DIR`, or platform user storage (Application Support, +APPDATA or XDG_CONFIG_HOME). JSON replacement is atomic; failed saves are observable, +and an intentionally empty watchlist stays empty. Legacy settings in the source +folder can be copied to the new directory once to preserve prior customizations. + +News requests retain HTTPS certificate checks and normalize timestamps to UTC. +Chart date labels retain actual session dates and earnings markers stay inside the +visible date range. Peer payloads retain all direct relationship edges and expose +source, target and direction; CLI/GUI displays show endpoints. Default graph summary +JSON exposes Sectivia cache merge status and attribution. Native build scripts include +the cached graph data and the new exchange calendar resources. + +Offline regressions cover stale/closed GUI requests, dividend fallbacks, ask-based +metrics, calendar boundaries, chart labels, storage failure, relationship direction, +Fair plot categories, TLS failure and shell input validation. Native builds and +live provider behavior still require platform/live verification. diff --git a/docs/github-actions-release.yml b/docs/github-actions-release.yml index 03a1d42..ef46159 100644 --- a/docs/github-actions-release.yml +++ b/docs/github-actions-release.yml @@ -94,20 +94,9 @@ jobs: - name: Resolve tag name id: tag shell: bash - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG="${{ github.event.inputs.version }}" - else - TAG="${GITHUB_REF_NAME}" - fi - # Normalize: strip leading whitespace - TAG="${TAG# }" - if [[ -z "$TAG" ]]; then - echo "Empty tag" >&2 - exit 1 - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Using tag: $TAG" + env: + DISPATCH_VERSION: ${{ github.event.inputs.version }} + run: bash scripts/resolve_release_tag.sh - name: Download all build artifacts uses: actions/download-artifact@v4 diff --git a/main/app.py b/main/app.py index 68ea6cd..0d74e73 100644 --- a/main/app.py +++ b/main/app.py @@ -9,12 +9,12 @@ import numpy as np import math import threading -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +import copy import time import requests import xml.etree.ElementTree as ET import os -import urllib3 import webbrowser import csv import re @@ -31,8 +31,6 @@ except Exception: # pragma: no cover - defensive PLOTLY_AVAILABLE = False -# Suppress SSL warnings -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # --- Charting Libraries --- from matplotlib.figure import Figure @@ -78,6 +76,7 @@ CAT_EARN_OVER, ) from ui.prefs import load_prefs, save_prefs +from ui.storage import config_dir from ui.watchlist import load_watchlist, add_ticker as watchlist_add, remove_ticker as watchlist_remove from ui.stock_graph import open_stock_graph_window, resolve_graph_ticker @@ -108,7 +107,7 @@ def __init__(self, root): self.use_sentiment = False # Vol / Greek experiments (EWMA path preserved unless blend flags are on) - self._prefs_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + self._prefs_root = config_dir() _prefs = load_prefs(self._prefs_root) self.use_garch_blend = bool(_prefs.get("use_garch_blend", False)) self.use_smile_vol = bool(_prefs.get("use_smile_vol", False)) @@ -382,7 +381,7 @@ def _vol_why_text(self): return "\n".join(lines) def _persist_vol_prefs(self): - save_prefs( + saved = save_prefs( self._prefs_root, use_garch_blend=self.use_garch_blend, use_smile_vol=self.use_smile_vol, @@ -392,6 +391,9 @@ def _persist_vol_prefs(self): show_earnings=self.show_earnings, ) + if not saved: + self.log("Could not save preferences; settings will not survive restart.") + def _refresh_vol_label(self): """Update HV/EWMA/GARCH label marks immediately (no network reload).""" hv = float(getattr(self, "hv_30", 0.0) or 0.0) @@ -474,7 +476,11 @@ def _watchlist_add_current(self): sym = self.entry_ticker.get().upper().strip() if not sym: return - self._watchlist = watchlist_add(self._prefs_root, sym) + try: + self._watchlist = watchlist_add(self._prefs_root, sym) + except OSError as exc: + messagebox.showerror("Watchlist", f"Could not save watchlist: {exc}") + return self._refresh_watchlist_combo() self.var_watch.set(sym) self.log(f"Watchlist + {sym} ({len(self._watchlist)} tickers)") @@ -483,7 +489,11 @@ def _watchlist_remove_current(self): sym = (self.var_watch.get() or self.entry_ticker.get() or "").upper().strip() if not sym: return - self._watchlist = watchlist_remove(self._prefs_root, sym) + try: + self._watchlist = watchlist_remove(self._prefs_root, sym) + except OSError as exc: + messagebox.showerror("Watchlist", f"Could not save watchlist: {exc}") + return self._refresh_watchlist_combo() if self._watchlist: self.var_watch.set(self._watchlist[0]) @@ -617,7 +627,9 @@ def load_data(self, period="5d", interval="5m"): self.btn_graph.config(state="disabled") # Start background fundamental fetch - threading.Thread(target=self.get_info, daemon=True).start() + self._fundamental_request_id = getattr(self, "_fundamental_request_id", 0) + 1 + self._close_options_window() + threading.Thread(target=self.get_info, args=(self.stock, new_ticker, self._fundamental_request_id), daemon=True).start() self.log(f"Ticker changed: {new_ticker}. Session reused.") # Always refresh the chart (light logic) @@ -863,7 +875,7 @@ def get_google_news_rss(self, ticker): for q in queries: try: url = f"https://news.google.com/rss/search?q={q}&hl=en-US&gl=US&ceid=US:en" - resp = requests.get(url, headers=headers, timeout=5, verify=False) + resp = requests.get(url, headers=headers, timeout=5) if resp.status_code == 200: root = ET.fromstring(resp.content) @@ -892,9 +904,9 @@ def _elem_text(elem): if title and title not in seen_titles: seen_titles.add(title) try: - dt = pd.to_datetime(pub_date_str) + dt = pd.to_datetime(pub_date_str, utc=True) except: - dt = datetime.now() + dt = datetime.now(timezone.utc) news_items.append({ 'title': title, @@ -912,6 +924,14 @@ def _elem_text(elem): return news_items + def _chart_news(self, ticker, stock_obj): + """News is optional: its failure must not discard usable chart history.""" + try: + return self.calculate_sentiment(ticker, stock_obj) + except Exception as exc: + self.log(f"News refresh skipped: {exc}") + return None, [] + def calculate_sentiment(self, ticker, stock_obj): # 1. Check Cache with self._sent_cache_lock: @@ -933,7 +953,7 @@ def calculate_sentiment(self, ticker, stock_obj): if not title.strip(): continue ts = n.get('providerPublishTime', time.time()) - dt = datetime.fromtimestamp(ts) + dt = datetime.fromtimestamp(ts, timezone.utc) summary = n.get('summary') or f"Source: {n.get('publisher', 'Yahoo')}" all_news.append({ @@ -948,13 +968,18 @@ def calculate_sentiment(self, ticker, stock_obj): # B. Google RSS (Backup) if len(all_news) < 5: - google_news = self.get_google_news_rss(ticker) - all_news.extend(google_news) + try: + all_news.extend(self.get_google_news_rss(ticker)) + except Exception as exc: + self.log(f"News unavailable: {exc}") if not all_news: return None, [] # 3. Sort: Newest First + for item in all_news: + stamp = pd.to_datetime(item.get('published'), utc=True, errors='coerce') + item['published'] = stamp if not pd.isna(stamp) else pd.Timestamp.now(tz='UTC') all_news.sort(key=lambda x: x['published'], reverse=True) all_news = all_news[:self.headline_limit] @@ -1192,7 +1217,7 @@ def fetch_and_plot(self, ticker, period, interval, stock=None, request_id=None): garch_vol, garch_info = garch11_vol_forecast(log_rets) # --- 4. Sentiment Analysis --- - sentiment_score, headlines = self.calculate_sentiment(ticker, stock) + sentiment_score, headlines = self._chart_news(ticker, stock) # --- 5. UI Updates --- last_copy = last.copy() @@ -1421,6 +1446,7 @@ def update_technicals(self, data, hv, ewma, sentiment, period_return, garch_vol= def visualize_3d(self, option_type): """Interactive 3D landscape: Days × Strike × EV@Ask ($) with readable chrome.""" + ticker = self.current_ticker with self._scan_lock: if not getattr(self, 'scan_data', None): messagebox.showinfo("3D Plot", "No data to plot. Please run a Scan first.") @@ -1431,7 +1457,7 @@ def visualize_3d(self, option_type): return vis_win = Toplevel(self.root) - vis_win.title(f"3D Analysis: {self.current_ticker} {option_type}s — EV@Ask ($)") + vis_win.title(f"3D Analysis: {ticker} {option_type}s — EV@Ask ($)") vis_win.geometry("1100x900") vis_win.configure(bg=APP_BG) @@ -1445,6 +1471,7 @@ def visualize_3d(self, option_type): var_earn_over = tk.BooleanVar(value=True) var_reg_under = tk.BooleanVar(value=True) var_reg_over = tk.BooleanVar(value=True) + var_fair = tk.BooleanVar(value=True) fig = Figure(figsize=(9, 6.5), dpi=110, facecolor=APP_BG) ax = fig.add_subplot(111, projection='3d') @@ -1490,6 +1517,7 @@ def refresh_plot(): show_earn_over=var_earn_over.get(), show_under=var_reg_under.get(), show_over=var_reg_over.get(), + show_fair=var_fair.get(), ) export_state["rows"] = rows @@ -1497,7 +1525,7 @@ def refresh_plot(): dates_x, strikes, evs, colors, sizes = [], [], [], [], [] all_evs = [float(r['ev']) for r in base_data if r.get('ev') is not None] if not all_evs: - style_mpl_3d_axes(ax, self.current_ticker, option_type) + style_mpl_3d_axes(ax, ticker, option_type) canvas.draw() return cmap = plt.get_cmap('RdYlGn') @@ -1540,8 +1568,8 @@ def refresh_plot(): except Exception: pass - style_mpl_3d_axes(ax, self.current_ticker, option_type) - btn_export.config(command=lambda: self.save_3d_html(option_type, export_state["rows"])) + style_mpl_3d_axes(ax, ticker, option_type) + btn_export.config(command=lambda: self.save_3d_html(option_type, export_state["rows"], ticker=ticker)) canvas.draw() ttk.Label(ctrl_frame, text="[Cyan]", foreground="#00e6e6").pack(side="left") @@ -1560,10 +1588,13 @@ def refresh_plot(): ctrl_frame, text="Over (regular)", variable=var_reg_over, command=refresh_plot, ).pack(side="left", padx=8) + ttk.Checkbutton(ctrl_frame, text="Fair (no edge)", variable=var_fair, + command=refresh_plot).pack(side="left", padx=8) refresh_plot() - def save_3d_html(self, option_type, rows): + def save_3d_html(self, option_type, rows, *, ticker=None): """Export Plotly HTML with EV@Ask colorbar, hover, camera, optional heatmap.""" + ticker = ticker or self.current_ticker if not PLOTLY_AVAILABLE: messagebox.showerror("Error", "Plotly not installed.") return @@ -1577,7 +1608,7 @@ def save_3d_html(self, option_type, rows): return filename = filedialog.asksaveasfilename( - initialfile=f"{self.current_ticker}_{option_type}_3D_Analysis.html", + initialfile=f"{ticker}_{option_type}_3D_Analysis.html", defaultextension=".html", filetypes=[("HTML Files", "*.html")] ) @@ -1600,7 +1631,7 @@ def save_3d_html(self, option_type, rows): continue fig = build_plotly_figure( - self.current_ticker, + ticker, option_type, days, strikes, @@ -1692,35 +1723,41 @@ def scan_all_undervalued(self): # Search ALL dates, but enable filtering for "Under" only if hasattr(self, 'all_exps'): self.log(f"Scanning {len(self.all_exps)} chains for value...") - threading.Thread(target=self.fetch_options_batch, args=(self.all_exps, True), daemon=True).start() + self._start_options_scan(self.all_exps, True) def _normalize_div_yield(self, div): """Normalize a dividend yield to decimal form (e.g. 0.0294 for 2.94%).""" return normalize_div_yield(div) - def get_info(self): - """Consolidated fundamental fetch called when ticker changes.""" + def get_info(self, stock=None, ticker=None, request_id=None): + """Compute on isolated state; only publish for the current request on Tk.""" + stock = self.stock if stock is None else stock + ticker = self.current_ticker if ticker is None else ticker + request_id = getattr(self, "_fundamental_request_id", 0) if request_id is None else request_id + worker = copy.copy(self) + worker.current_ticker = ticker + worker.stock = stock + worker.valuation_status = {} try: - stock = self.stock info = self.data_provider.get_info(stock) - - # 1. Basic Fundamental Extraction - self.pe_fwd = self._to_finite_float(info.get('forwardPE')) - self.pe_ttm = self._to_finite_float(info.get('trailingPE')) - self.peg_ratio = self._to_finite_float(info.get('trailingPegRatio')) - self.earnings_growth = self._to_finite_float(info.get('earningsGrowth')) - - # 2. Compute derived valuation metrics - self.calculate_pe_percentile(stock) - self.compute_peg_ratio() - - # 3. Force UI update now that data is ready - self.root.after(0, self.update_pe_display) - - except Exception as e: - self.log(f"Fundamental fetch error: {e}") - self.root.after(0, self.update_pe_display) + worker.pe_fwd = self._to_finite_float(info.get('forwardPE')) + worker.pe_ttm = self._to_finite_float(info.get('trailingPE')) + worker.peg_ratio = self._to_finite_float(info.get('trailingPegRatio')) + worker.earnings_growth = self._to_finite_float(info.get('earningsGrowth')) + worker.calculate_pe_percentile(stock) + worker.compute_peg_ratio() + except Exception as exc: + self.log(f"Fundamental fetch error: {exc}") + return + + def publish(): + if ticker != self.current_ticker or request_id != getattr(self, "_fundamental_request_id", 0): + return + for name in ("pe_fwd", "pe_ttm", "peg_ratio", "earnings_growth", "pe_percentile", "valuation_status"): + setattr(self, name, getattr(worker, name)) + self.update_pe_display() + self.root.after(0, publish) def get_smart_dividend(self, stock_obj): """Dividend yield as decimal via shared ``resolve_dividend_yield``.""" @@ -1731,6 +1768,7 @@ def get_smart_dividend(self, stock_obj): def open_options_window(self): if not self.current_ticker: return + self._close_options_window() refs = build_options_explorer( self.root, self.current_ticker, @@ -1742,10 +1780,14 @@ def open_options_window(self): on_exp_select=self.on_exp_select, on_sort_column=self.treeview_sort_column, ) + self._options_window = refs["win"] + self._options_window.protocol("WM_DELETE_WINDOW", self._close_options_window) + self.all_exps = [] + self.scan_data = [] self.entry_date = refs["entry_date"] self.exp_list = refs["exp_list"] self.tree = refs["tree"] - threading.Thread(target=self.load_expirations, daemon=True).start() + threading.Thread(target=self.load_expirations, args=(self.stock, self.current_ticker, self.exp_list), daemon=True).start() def export_to_csv(self): @@ -1785,10 +1827,27 @@ def export_to_csv(self): messagebox.showerror("Export Error", f"Failed to save CSV:\n{e}") self.log(f"Export Error: {e}") - def load_expirations(self): - stock = self.stock - self.all_exps = self.data_provider.get_option_expirations(stock) - self.root.after(0, lambda: self.update_exp_list(self.all_exps)) + def _close_options_window(self): + self._options_request_id = getattr(self, "_options_request_id", 0) + 1 + win = getattr(self, "_options_window", None) + if win is not None: + try: + win.destroy() + except tk.TclError: + pass + self._options_window = None + + def load_expirations(self, stock, ticker, exp_list): + try: + expirations = list(self.data_provider.get_option_expirations(stock)) + except Exception as exc: + self.log(f"Expiration fetch failed: {exc}") + return + def publish(): + if ticker == self.current_ticker and exp_list is self.exp_list and exp_list.winfo_exists(): + self.all_exps = expirations + self.update_exp_list(expirations) + self.root.after(0, publish) def update_exp_list(self, exp_list): self.exp_list.delete(0, "end") @@ -1811,7 +1870,7 @@ def on_exp_select(self, event): dates = [self.exp_list.get(i) for i in sel] if not dates: return for i in self.tree.get_children(): self.tree.delete(i) - threading.Thread(target=self.fetch_options_batch, args=(dates,), daemon=True).start() + self._start_options_scan(dates) def _fetch_rate_curve(self): """Fetches ^IRX (short) and ^TNX (long) rates once. Returns (short_rate, long_rate).""" @@ -1862,34 +1921,43 @@ def _flush_option_rows(self, rows): for vals, tag in rows: tree.insert("", "end", values=vals, tags=(tag,)) - def fetch_options_batch(self, dates, filter_under_only=False): - """Options Finder batch — delegates pricing/filters to ``core.scan_service``.""" - with self._scan_lock: - self.scan_data = [] - - def on_ui_batch(items): - batch = list(items) - if batch: - self.root.after(0, lambda b=batch: self._flush_option_rows(b)) - - result = scan_option_chains( - data_provider=self.data_provider, - stock=self.stock, - spot=float(self.current_price), - dates=dates, - all_exps=getattr(self, "all_exps", None), - projected_earnings=self.projected_earnings, - ewma_vol=getattr(self, "ewma_vol", 0.0), - garch_vol=getattr(self, "garch_vol", 0.0), + def _start_options_scan(self, dates, filter_under_only=False): + self._options_request_id = getattr(self, "_options_request_id", 0) + 1 + token = self._options_request_id + ticker, tree = self.current_ticker, self.tree + inputs = dict( + data_provider=self.data_provider, stock=self.stock, spot=float(self.current_price), + dates=tuple(dates), all_exps=tuple(getattr(self, "all_exps", ())), + projected_earnings=tuple(self.projected_earnings), + ewma_vol=getattr(self, "ewma_vol", 0.0), garch_vol=getattr(self, "garch_vol", 0.0), hv_30=getattr(self, "hv_30", 0.0), use_garch_blend=getattr(self, "use_garch_blend", False), use_smile_vol=getattr(self, "use_smile_vol", False), use_american_greeks=getattr(self, "use_american_greeks", True), under_only=filter_under_only, - dividend_yield=self.get_smart_dividend(self.stock), - log=self.log, - on_ui_batch=on_ui_batch, ) - if result.scan_buf: - with self._scan_lock: - self.scan_data.extend(result.scan_buf) + self.scan_data = [] + threading.Thread(target=self.fetch_options_batch, args=(inputs, ticker, tree, token), daemon=True).start() + + def _options_request_current(self, ticker, tree, token): + return (ticker == self.current_ticker and tree is self.tree + and token == self._options_request_id and bool(tree.winfo_exists())) + + def fetch_options_batch(self, inputs, ticker, tree, token): + """Workers receive immutable input snapshots; Tk owns all publication.""" + def publish_batch(batch): + if self._options_request_current(ticker, tree, token): + for values, tag in batch: + tree.insert("", "end", values=values, tags=(tag,)) + def on_ui_batch(items): + self.root.after(0, lambda batch=list(items): publish_batch(batch)) + try: + result = scan_option_chains(**inputs, log=self.log, on_ui_batch=on_ui_batch) + except Exception as exc: + self.log(f"Options scan failed: {exc}") + return + def publish_result(): + if self._options_request_current(ticker, tree, token): + with self._scan_lock: + self.scan_data = list(result.scan_buf) + self.root.after(0, publish_result) diff --git a/main/cli.py b/main/cli.py index c32ba45..ae1b9e3 100644 --- a/main/cli.py +++ b/main/cli.py @@ -173,6 +173,7 @@ def _print_scan(analysis, result, *, under_only: bool, as_json: bool, "analysis": analysis.to_dict(), "forecast_vol": result.forecast_vol, "dividend_yield": result.dividend_yield, + "dividend_status": result.dividend_status, "rules": result.rules_log, "count": len(rows), "total_count": len(result.rows), diff --git a/main/graph_cli.py b/main/graph_cli.py index 1f8b999..8ad723e 100644 --- a/main/graph_cli.py +++ b/main/graph_cli.py @@ -15,6 +15,13 @@ from core.stock_graph import StockGraph +def _positive_depth(value): + depth = int(value) + if depth < 1: + raise argparse.ArgumentTypeError("depth must be a positive integer") + return depth + + def add_commands(sub: argparse._SubParsersAction) -> None: graph_parser = sub.add_parser( "graph", @@ -25,7 +32,7 @@ def add_commands(sub: argparse._SubParsersAction) -> None: # 1. show show = commands.add_parser("show", help="Inspect network nodes and connections") show.add_argument("ticker", nargs="?", help="Center ticker to inspect (omit for entire network summary)") - show.add_argument("--depth", type=int, default=1, help="Neighbor traversal depth (default: 1)") + show.add_argument("--depth", type=_positive_depth, default=1, help="Neighbor traversal depth (default: 1)") common_flags(show) # 2. peers @@ -43,7 +50,7 @@ def add_commands(sub: argparse._SubParsersAction) -> None: exp = commands.add_parser("export", help="Export interactive Plotly network graph to standalone HTML") exp.add_argument("ticker", nargs="?", help="Center ticker for subgraph (omit for full market network)") exp.add_argument("--html", required=True, metavar="PATH", help="Destination HTML file path") - exp.add_argument("--depth", type=int, default=1, help="Neighbor traversal depth for center ticker") + exp.add_argument("--depth", type=_positive_depth, default=1, help="Neighbor traversal depth for center ticker") exp.add_argument("--dim", choices=["2d", "3d"], default="2d", help="Visualization dimensionality (2d or 3d)") common_flags(exp) @@ -119,7 +126,7 @@ def render(data: dict, as_json: bool) -> None: print("Connected Companies:") for c in data["connections"]: rel = c['relation'].replace('_', ' ') - print(f" [{rel}] {c['neighbor']} ({c['name']})") + print(f" [{rel}: {c['source']} → {c['target']}] {c['neighbor']} ({c['name']})") if c['description']: print(f" Details: {c['description']}") @@ -129,7 +136,7 @@ def render(data: dict, as_json: bool) -> None: for rel, peer_list in data["peer_categories"].items(): print(f"\n{rel.replace('_', ' ').upper()}:") for p in peer_list: - print(f" * {p['ticker']} ({p['name']}) — {p['sector']}") + print(f" * {p['ticker']} ({p['name']}) — {p['sector']} [{p['source']} → {p['target']}]") if p['description']: print(f" {p['description']}") diff --git a/requirements-ci.txt b/requirements-ci.txt index 07ef3a2..6e1fafb 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -8,3 +8,4 @@ urllib3>=2.7.0 plotly>=5.18.0 pytest>=9.0.0 lxml +pandas-market-calendars>=5.1.0 diff --git a/requirements-release.txt b/requirements-release.txt index b3ad9f2..b2c8e60 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -9,3 +9,4 @@ urllib3>=2.7.0 plotly>=5.18.0 lxml pyinstaller>=6.0.0 +pandas-market-calendars>=5.1.0 diff --git a/requirements.txt b/requirements.txt index 20f8ffe..2020cfb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,11 @@ -numpy>=2.5.2 -pandas>=2.1.0 -yfinance>=0.2.40 -matplotlib>=3.8.0 -requests>=2.34.2 -urllib3>=2.7.0 -transformers>=4.36.0 -torch>=2.14.0 -plotly>=5.18.0 -lxml \ No newline at end of file +numpy>=2.5.2 +pandas>=2.1.0 +yfinance>=0.2.40 +matplotlib>=3.8.0 +requests>=2.34.2 +urllib3>=2.7.0 +transformers>=4.36.0 +torch>=2.14.0 +plotly>=5.18.0 +lxml +pandas-market-calendars>=5.1.0 diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 index a1a537b..476011f 100644 --- a/scripts/build_release.ps1 +++ b/scripts/build_release.ps1 @@ -74,6 +74,10 @@ $PyiArgs = @( "--noconsole", "--name", "Sentinel", "--collect-submodules", "matplotlib", + "--collect-all", "pandas_market_calendars", + "--collect-all", "exchange_calendars", + "--collect-all", "tzdata", + "--add-data", "$Root/data;data", "--collect-submodules", "core", "--collect-submodules", "main" ) + $Exclude diff --git a/scripts/build_release.sh b/scripts/build_release.sh index 23d35cb..e769659 100755 --- a/scripts/build_release.sh +++ b/scripts/build_release.sh @@ -140,6 +140,10 @@ if [[ "$PLATFORM" == "linux" ]]; then --strip --name Sentinel --collect-submodules matplotlib + --collect-all pandas_market_calendars + --collect-all exchange_calendars + --collect-all tzdata + --add-data "$ROOT/data:data" --collect-submodules core --collect-submodules main "${EXCLUDE_ARGS[@]}" @@ -174,6 +178,10 @@ elif [[ "$PLATFORM" == "macos" ]]; then --strip --name Sentinel --collect-submodules matplotlib + --collect-all pandas_market_calendars + --collect-all exchange_calendars + --collect-all tzdata + --add-data "$ROOT/data:data" --collect-submodules core --collect-submodules main "${EXCLUDE_ARGS[@]}" diff --git a/scripts/resolve_release_tag.sh b/scripts/resolve_release_tag.sh new file mode 100644 index 0000000..1801a72 --- /dev/null +++ b/scripts/resolve_release_tag.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then + tag="${DISPATCH_VERSION:-}" +else + tag="${GITHUB_REF_NAME:-}" +fi +if [[ ! "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?(-[0-9A-Za-z][0-9A-Za-z.-]*)?(\+[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then + echo "Invalid release tag: expected a numeric version, optionally prefixed with v." >&2 + exit 1 +fi +printf 'tag=%s\n' "$tag" >> "$GITHUB_OUTPUT" +printf 'Using tag: %s\n' "$tag" diff --git a/tests/test_review_regressions.py b/tests/test_review_regressions.py new file mode 100644 index 0000000..c5a9dab --- /dev/null +++ b/tests/test_review_regressions.py @@ -0,0 +1,317 @@ +"""Offline regression coverage for the September reliability review.""" +from datetime import datetime, timezone +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +import pandas as pd +import pytest +import requests +from matplotlib.figure import Figure + +from core.expiry_time import remaining_years +from core.graph_service import categorize_peers +from core.scan_service import resolve_dividend_yield, scan_option_chains +from core.stock_graph import StockGraph, GraphEdge, build_default_graph +from main.app import MarketApp +from ui.chart import apply_tick_labels, draw_earnings_markers +from ui.options_3d import filter_rows_for_plot, build_plotly_figure +from ui.prefs import load_prefs, save_prefs +from ui.storage import config_dir +from ui.watchlist import load_watchlist, save_watchlist + + +def test_dividend_source_failure_continues_to_info(): + provider = SimpleNamespace(get_fast_info=Mock(side_effect=RuntimeError("offline")), + get_info=Mock(return_value={"dividendYield": 2})) + status = {} + assert resolve_dividend_yield(provider, object(), status=status) == .02 + assert status["source"] == "info.dividendYield" + assert status["errors"][0]["source"] == "fast_info" + + +def test_missing_dividend_is_distinguishable_from_confirmed_zero(): + provider = SimpleNamespace(get_fast_info=Mock(return_value={}), get_info=Mock(return_value={})) + status = {} + assert resolve_dividend_yield(provider, object(), status=status) == 0 + assert status["source"] == "missing" + provider.get_info.return_value = {"dividendYield": 0} + assert resolve_dividend_yield(provider, object(), status=status) == 0 + assert status["source"] == "info.dividendYield" + + +@pytest.mark.parametrize("stamp,expected_sessions", [ + ("2026-09-11T09:30:00-04:00", 1), + ("2026-09-11T15:59:00-04:00", 1 / 390), + ("2026-09-11T16:00:00-04:00", 0), + ("2026-09-11T18:00:00-04:00", 0), +]) +def test_expiry_clock_tracks_remaining_session(stamp, expected_sessions): + assert remaining_years("2026-09-11", pd.Timestamp(stamp)) == pytest.approx(expected_sessions / 252) + + +def test_expiry_calendar_excludes_holidays_and_counts_early_close(): + # July 3 is the observed Independence Day holiday in 2026. + assert remaining_years("2026-07-03", pd.Timestamp("2026-07-02T16:00:00-04:00")) == 0 + # Black Friday closes at 13:00 ET: 3.5 hours, not the regular 6.5. + assert remaining_years("2026-11-27", pd.Timestamp("2026-11-27T09:30:00-05:00")) == pytest.approx(3.5 / 6.5 / 252) + assert remaining_years("2026-11-27", pd.Timestamp("2026-11-27T13:00:00-05:00")) == 0 + + +def test_expiry_clock_is_timezone_independent_and_requires_aware_input(): + utc = pd.Timestamp("2026-09-11T18:00:00Z") + assert remaining_years("2026-09-11", utc) == remaining_years("2026-09-11", utc.tz_convert("Asia/Tokyo")) + with pytest.raises(ValueError, match="timezone-aware"): + remaining_years("2026-09-11", datetime(2026, 9, 11)) + + +def test_ask_based_breakeven_and_pop_respond_to_spread_at_same_mid(): + def scan(bid, ask): + calls = pd.DataFrame({"strike": [100.0], "bid": [bid], "ask": [ask], + "volume": [100], "openInterest": [500], "impliedVolatility": [.3]}) + provider = SimpleNamespace(get_option_chain=lambda *_: SimpleNamespace(calls=calls, puts=calls.iloc[:0])) + return scan_option_chains(data_provider=provider, stock=object(), spot=100, + dates=["2026-10-16"], valuation_time=pd.Timestamp("2026-09-11T14:00:00Z"), + dividend_yield=0, short_rate=.04, long_rate=.04, + ewma_vol=.3, use_american_greeks=False).rows[0] + narrow, wide = scan(4.9, 5.1), scan(4.6, 5.4) + assert narrow.mid == wide.mid == 5 + assert narrow.breakeven == 105.1 + assert wide.breakeven == 105.4 + assert narrow.pop > wide.pop + assert narrow.to_dict()["bid"] == 4.9 + assert narrow.to_dict()["category"] in ("Under", "Over", "Fair") + assert "risk-neutral" in narrow.to_dict()["probability_model"] + + +def test_fair_plot_rows_are_independent_of_over_filter(): + rows = [{"verdict": verdict, "is_good": False, "is_earnings": earnings} + for verdict in ("Fair", "Over") for earnings in (False, True)] + result = filter_rows_for_plot(rows, show_under=False, show_over=False, + show_earn_under=False, show_earn_over=False, show_fair=True) + assert [r["category"] for r in result] == ["fair", "earnings_fair"] + assert filter_rows_for_plot(rows, show_under=False, show_over=False, + show_earn_under=False, show_earn_over=False, show_fair=False) == [] + fig = build_plotly_figure("TEST", "CALL", [1, 2], [100, 105], [-.1, -.2], + ["2026-09-11"] * 2, [10, 20], ["fair", "earnings_fair"]) + assert all("no tradeable edge" in text and "sell-side edge" not in text for text in fig.data[0].text) + + +def test_peer_payload_preserves_multiple_edges_and_direction(): + graph = StockGraph() + graph.add_edge(GraphEdge("A", "B", "SUPPLIER_TO")) + graph.add_edge(GraphEdge("A", "B", "COMPETITOR", bidirectional=True)) + peers = categorize_peers(graph, "B") + assert set(peers["peer_categories"]) == {"SUPPLIER_TO", "COMPETITOR"} + supplier = peers["peer_categories"]["SUPPLIER_TO"][0] + assert (supplier["source"], supplier["target"], supplier["direction"]) == ("A", "B", "incoming") + for depth in (0, -1): + with pytest.raises(ValueError, match="positive"): + graph.get_neighbors("B", depth=depth) + + +def test_cached_graph_import_status_is_exposed(): + graph = build_default_graph() + assert graph.import_status["skipped"] is False + assert graph.import_status["edges_added"] > 0 + assert "Sectivia" in graph.import_status["attribution"] + + +def test_preferences_use_user_storage_and_preserve_empty_watchlist(tmp_path, monkeypatch): + monkeypatch.setenv("SENTINEL_CONFIG_DIR", str(tmp_path / "config")) + root = config_dir() + assert save_prefs(root, show_fib=True) + assert load_prefs(root)["show_fib"] is True + assert save_watchlist(root, []) == [] + assert load_watchlist(root) == [] + + +def test_failed_atomic_replace_keeps_previous_settings(tmp_path, monkeypatch): + assert save_prefs(str(tmp_path), show_fib=True) + monkeypatch.setattr("ui.storage.os.replace", Mock(side_effect=OSError("read-only"))) + with pytest.warns(RuntimeWarning, match="Could not save"): + assert save_prefs(str(tmp_path), show_fib=False) is False + assert load_prefs(str(tmp_path))["show_fib"] is True + with pytest.raises(OSError): + save_watchlist(str(tmp_path), ["TEST"]) + assert list(tmp_path.glob(".settings-*")) == [] + + +def test_chart_keeps_friday_date_and_skips_old_earnings(): + ax = Figure().subplots() + times = pd.DatetimeIndex(["2026-09-11T15:55:00-04:00"]) + apply_tick_labels(ax, times, "5d") + assert [x.get_text() for x in ax.get_xticklabels()] == ["2026-09-11"] + draw_earnings_markers(ax, times, [0], ["2026-01-01", "2026-09-12"]) + assert len(ax.lines) == 0 + draw_earnings_markers(ax, times, [0], ["2026-09-11"]) + assert len(ax.lines) == 1 + + +def _app(): + app = object.__new__(MarketApp) + app.current_ticker = "A" + app.stock = SimpleNamespace(ticker="A") + app._fundamental_request_id = 1 + app._options_request_id = 1 + app._scan_lock = threading.Lock() + app.log = Mock() + app.pending = [] + app.root = SimpleNamespace(after=lambda delay, callback: app.pending.append(callback)) + app.update_pe_display = Mock() + app.pe_fwd = app.pe_ttm = app.peg_ratio = app.pe_percentile = app.earnings_growth = None + app.valuation_status = {} + return app + + +def test_old_fundamentals_cannot_publish_after_new_request(monkeypatch): + app = _app() + app.data_provider = SimpleNamespace(get_info=lambda stock: {"forwardPE": 12 if stock.ticker == "A" else 24}) + monkeypatch.setattr(MarketApp, "calculate_pe_percentile", lambda self, stock: None) + monkeypatch.setattr(MarketApp, "compute_peg_ratio", lambda self: None) + app.get_info(app.stock, "A", 1) + app.current_ticker = "B" + app.stock = SimpleNamespace(ticker="B") + app._fundamental_request_id = 2 + app.get_info(app.stock, "B", 2) + # Complete B before A; neither worker changed shared valuation fields. + assert app.pe_fwd is None + app.pending.pop()() + app.pending.pop()() + assert app.pe_fwd == 24 + app.update_pe_display.assert_called_once() + + +@pytest.mark.parametrize("invalidate", ["ticker", "request", "closed", "window"]) +def test_stale_scan_callbacks_do_not_touch_new_window(monkeypatch, invalidate): + app = _app() + app.tree = SimpleNamespace(winfo_exists=lambda: True, insert=Mock()) + old_tree = app.tree + app.scan_data = [] + def scan(**kwargs): + kwargs["on_ui_batch"]([(("A",), "green")]) + return SimpleNamespace(scan_buf=[{"ticker": "A"}]) + monkeypatch.setattr("main.app.scan_option_chains", scan) + app.fetch_options_batch({}, "A", old_tree, 1) + if invalidate == "ticker": + app.current_ticker = "B" + elif invalidate == "request": + app._options_request_id += 1 + elif invalidate == "closed": + old_tree.winfo_exists = lambda: False + else: + app.tree = SimpleNamespace(winfo_exists=lambda: True, insert=Mock()) + for callback in app.pending: + callback() + old_tree.insert.assert_not_called() + assert app.scan_data == [] + + +def test_current_scan_publishes_rows_and_buffer(monkeypatch): + app = _app() + app.tree = SimpleNamespace(winfo_exists=lambda: True, insert=Mock()) + def scan(**kwargs): + kwargs["on_ui_batch"]([(("A",), "green")]) + return SimpleNamespace(scan_buf=[{"ticker": "A"}]) + monkeypatch.setattr("main.app.scan_option_chains", scan) + app.fetch_options_batch({}, "A", app.tree, 1) + for callback in app.pending: + callback() + app.tree.insert.assert_called_once() + assert app.scan_data == [{"ticker": "A"}] + + +def test_mixed_news_timestamps_are_aware_and_sorted(): + app = _app() + app._sent_cache_lock = threading.Lock() + app.sent_cache = {} + app.SENT_CACHE_DURATION = 60 + app.SENT_CACHE_MAX_TICKERS = 5 + app.headline_limit = 10 + app.use_sentiment = False + app.get_google_news_rss = lambda ticker: [{"title": "RSS", "published": datetime(2026, 9, 12)}] + yahoo = SimpleNamespace(news=[{"title": "Yahoo", "providerPublishTime": 1789171200}]) + _, items = app.calculate_sentiment("A", yahoo) + assert len(items) == 2 + assert all(item["published"].tzinfo is not None for item in items) + assert items[0]["published"] >= items[1]["published"] + + +def test_rss_certificate_failure_is_isolated_and_verification_enabled(monkeypatch): + app = _app() + app.headline_limit = 10 + get = Mock(side_effect=requests.exceptions.SSLError("invalid certificate")) + monkeypatch.setattr("main.app.requests.get", get) + assert app.get_google_news_rss("TEST") == [] + assert all(call.kwargs.get("verify", True) is True for call in get.call_args_list) + + +def test_scan_inputs_are_captured_before_worker_starts(monkeypatch): + app = _app() + app.tree = SimpleNamespace(winfo_exists=lambda: True) + app.current_price = 100 + app.projected_earnings = [pd.Timestamp("2026-10-01")] + app.all_exps = ["2026-10-16"] + app.data_provider = object() + thread = Mock() + monkeypatch.setattr("main.app.threading.Thread", thread) + dates = ["2026-10-16"] + app._start_options_scan(dates) + inputs, ticker, tree, token = thread.call_args.kwargs["args"] + app.current_ticker, app.current_price = "B", 200 + app.all_exps.clear() + dates.clear() + assert (ticker, inputs["spot"], inputs["dates"], inputs["all_exps"]) == ("A", 100, ("2026-10-16",), ("2026-10-16",)) + + +def test_closed_expiration_window_ignores_worker_results(): + app = _app() + app.exp_list = SimpleNamespace(winfo_exists=lambda: False) + app.all_exps = [] + app.update_exp_list = Mock() + app.data_provider = SimpleNamespace(get_option_expirations=lambda stock: ["2026-10-16"]) + app.load_expirations(app.stock, "A", app.exp_list) + app.pending.pop()() + assert app.all_exps == [] + app.update_exp_list.assert_not_called() + + +@pytest.mark.parametrize("version,success", [ + ("v2.4.0", True), ("1.8", True), ("v2.4.0-rc.1", True), + ('2.4.0"; touch injected; #', False), ("$(touch injected)", False), + ("2.4.0\ntag=evil", False), ("../../bad", False), ("", False), +]) +def test_release_version_is_validated_as_data(tmp_path, version, success): + import os + from pathlib import Path + import subprocess + script = Path(__file__).resolve().parents[1] / "scripts/resolve_release_tag.sh" + output = tmp_path / "output" + process = subprocess.run(["bash", str(script)], cwd=tmp_path, + env={**os.environ, "GITHUB_EVENT_NAME": "workflow_dispatch", + "DISPATCH_VERSION": version, "GITHUB_OUTPUT": str(output)}, + capture_output=True, text=True) + assert (process.returncode == 0) == success + assert not (tmp_path / "injected").exists() + if success: + assert output.read_text() == f"tag={version}\n" + else: + assert not output.exists() + + +def test_graph_cli_rejects_nonpositive_depth(): + import argparse + from main.graph_cli import add_commands + parser = argparse.ArgumentParser() + add_commands(parser.add_subparsers()) + for command in (["graph", "show", "A", "--depth", "0"], + ["graph", "export", "A", "--html", "test.html", "--depth", "-1"]): + with pytest.raises(SystemExit): + parser.parse_args(command) + + +def test_unexpected_news_failure_does_not_abort_chart_refresh(): + app = _app() + app.calculate_sentiment = Mock(side_effect=RuntimeError("model unavailable")) + assert app._chart_news("A", app.stock) == (None, []) + app.log.assert_called_once() diff --git a/ui/chart.py b/ui/chart.py index 088664f..faea4d3 100644 --- a/ui/chart.py +++ b/ui/chart.py @@ -115,8 +115,6 @@ def apply_tick_labels(ax, times_for_labels, period: str) -> None: if period == "1d": label = ts.strftime("%H:%M") else: - if ts.hour == 15 and ts.minute == 55: - ts = ts + timedelta(days=1) label = ts.strftime("%Y-%m-%d") final_labels.append(label) @@ -157,7 +155,8 @@ def draw_earnings_markers( labeled = False for ed in earnings_dates: ed_date = _to_date(ed) - if ed_date is None: + visible = [d for d in bar_dates if d is not None] + if ed_date is None or not visible or not min(visible) <= ed_date <= max(visible): continue # nearest bar on/after earnings date (or exact match) hit = None diff --git a/ui/options_3d.py b/ui/options_3d.py index a87f91e..9a7078d 100644 --- a/ui/options_3d.py +++ b/ui/options_3d.py @@ -13,13 +13,22 @@ CAT_EARN_OVER = "earnings_over" CAT_UNDER = "under" CAT_OVER = "over" +CAT_FAIR = "fair" +CAT_EARN_FAIR = "earnings_fair" # Distinct marker accents for earnings (EV colorbar still drives regular points) EARN_UNDER_RGB = (0, 230, 230) # cyan EARN_OVER_RGB = (200, 80, 255) # magenta -def categorize_row(is_earnings: bool, is_undervalued: bool) -> str: +def categorize_row(is_earnings: bool, is_undervalued: bool, verdict=None) -> str: + if verdict is not None: + if "Under" in verdict: + is_undervalued = True + elif "Over" in verdict: + is_undervalued = False + else: + return CAT_EARN_FAIR if is_earnings else CAT_FAIR if is_earnings: return CAT_EARN_UNDER if is_undervalued else CAT_EARN_OVER return CAT_UNDER if is_undervalued else CAT_OVER @@ -104,6 +113,8 @@ def build_plotly_figure( CAT_EARN_OVER: "Earnings Over (sell-side edge near earnings)", CAT_UNDER: "Under — Fair beats Ask (green row)", CAT_OVER: "Over — Bid beats Fair (red row)", + CAT_FAIR: "Fair — no tradeable edge", + CAT_EARN_FAIR: "Fair — no tradeable edge near earnings", } hover = [] for i in range(len(days_a)): @@ -323,23 +334,18 @@ def filter_rows_for_plot( show_earn_over: bool, show_under: bool, show_over: bool, + show_fair: bool = True, ) -> List[dict]: """Apply checkbox filters; attach ``category`` for Plotly/matplotlib.""" out = [] for row in base_data: - is_earn = bool(row.get("is_earnings")) - is_good = bool(row.get("is_good")) # undervalued / Under - if is_earn: - if is_good and not show_earn_under: - continue - if (not is_good) and not show_earn_over: - continue - else: - if is_good and not show_under: - continue - if (not is_good) and not show_over: - continue + category = categorize_row(bool(row.get("is_earnings")), bool(row.get("is_good")), row.get("verdict")) + enabled = {CAT_EARN_UNDER: show_earn_under, CAT_EARN_OVER: show_earn_over, + CAT_UNDER: show_under, CAT_OVER: show_over, + CAT_FAIR: show_fair, CAT_EARN_FAIR: show_fair} + if not enabled[category]: + continue item = dict(row) - item["category"] = categorize_row(is_earn, is_good) + item["category"] = category out.append(item) return out diff --git a/ui/options_explorer.py b/ui/options_explorer.py index 05ef7f3..15a5103 100644 --- a/ui/options_explorer.py +++ b/ui/options_explorer.py @@ -34,7 +34,7 @@ "red = Over (Bid beats Fair enough to sell) · no tint = Fair (no tradeable edge).\n" "EV@Ask $ = Fair − Ask (buy-side edge). Mid $ = (bid+ask)/2. " "Fair $ uses forecast vol only (EWMA ± optional GARCH), not contract IV. " - "Imp Vol is market IV (display / Greeks)." + "Imp Vol is market IV (display / Greeks). BE $ assumes entry at Ask; POP is risk-neutral at that breakeven." ) COLUMN_HELP = { @@ -43,9 +43,9 @@ "EV@Ask $": "Fair $ − Ask. Positive = buy-side tradeable edge before hurdles.", "Verdict": "Under / Over / Fair after dollar + % hurdles vs the tradeable side of the quote. See docs/LOGIC_REVIEW.md.", "Spread%": "(Ask − Bid) / Mid. Liquidity filter rejects spreads > 20%.", - "BE $": "Breakeven underlying price at Mid $ (call: K+mid, put: K−mid).", + "BE $": "Long-entry breakeven at Ask $ (call: K+ask, put: K−ask).", "Imp Vol": "Listed / smile-smoothed IV for display and Greeks — not used for Fair $.", - "POP": "Rough risk-neutral probability of finishing beyond breakeven (market IV).", + "POP": "Risk-neutral probability of finishing beyond ask-based long breakeven (market IV); not a real-world forecast.", } @@ -132,10 +132,6 @@ def build_options_explorer( command=lambda _c=c: on_sort_column(tree, _c, False), ) tree.column(c, width=col_widths.get(c, 64), anchor="center") - help_txt = COLUMN_HELP.get(c) - if help_txt: - # Heading widgets are not first-class; bind identity via column id on motion. - pass scr = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview) tree.configure(yscroll=scr.set) @@ -186,6 +182,14 @@ def build_options_explorer( ) tip_lbl.pack(fill="x", pady=(6, 0)) + def show_column_help(event): + if tree.identify_region(event.x, event.y) == "heading": + column = tree.identify_column(event.x) + index = int(column[1:]) - 1 + if 0 <= index < len(OPTION_COLS): + tip_lbl.config(text=COLUMN_HELP.get(OPTION_COLS[index], ANALYZER_LEGEND)) + tree.bind("", show_column_help, add="+") + return { "win": win, "entry_date": entry_date, diff --git a/ui/prefs.py b/ui/prefs.py index 8aa8d92..fc0fce8 100644 --- a/ui/prefs.py +++ b/ui/prefs.py @@ -4,6 +4,8 @@ import json import os from typing import Any, Dict +import warnings +from ui.storage import atomic_json DEFAULT_PREFS: Dict[str, Any] = { "use_garch_blend": False, @@ -34,14 +36,15 @@ def load_prefs(root_dir: str) -> Dict[str, Any]: return data -def save_prefs(root_dir: str, **kwargs) -> None: +def save_prefs(root_dir: str, **kwargs) -> bool: path = prefs_path(root_dir) data = load_prefs(root_dir) for k, v in kwargs.items(): if k in DEFAULT_PREFS: data[k] = bool(v) try: - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - except OSError: - pass + atomic_json(path, data) + return True + except OSError as exc: + warnings.warn(f"Could not save preferences: {exc}", RuntimeWarning) + return False diff --git a/ui/stock_graph.py b/ui/stock_graph.py index f2987a9..2a474bf 100644 --- a/ui/stock_graph.py +++ b/ui/stock_graph.py @@ -155,7 +155,7 @@ def _load_divergence(): "", "end", values=( - rel.replace("_", " "), + f"{rel.replace('_', ' ')} ({p.get('source', ticker)} → {p.get('target', p['ticker'])})", p["ticker"], p["name"], p["sector"], diff --git a/ui/storage.py b/ui/storage.py new file mode 100644 index 0000000..9261d9c --- /dev/null +++ b/ui/storage.py @@ -0,0 +1,37 @@ +"""Writable per-user settings and crash-safe JSON replacement.""" +import json +import os +from pathlib import Path +import sys +import tempfile + + +def config_dir(): + override = os.environ.get("SENTINEL_CONFIG_DIR") + if override: + return str(Path(override).expanduser()) + if sys.platform == "win32": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) + elif sys.platform == "darwin": + base = Path.home() / "Library/Application Support" + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return str(base / "Sentinel-Chimp") + + +def atomic_json(path, data): + """Raise OSError on failure; never truncate the previous settings file.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + name = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, + prefix=".settings-", delete=False) as stream: + name = stream.name + json.dump(data, stream, indent=2, allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + finally: + if name and os.path.exists(name): + os.unlink(name) diff --git a/ui/watchlist.py b/ui/watchlist.py index fdeb815..bac6785 100644 --- a/ui/watchlist.py +++ b/ui/watchlist.py @@ -5,6 +5,7 @@ import os import re from typing import List +from ui.storage import atomic_json _TICKER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9.\-]{0,15}$") DEFAULT_WATCHLIST: List[str] = ["AMD", "AAPL", "MSFT", "NVDA", "SPY"] @@ -39,7 +40,7 @@ def load_watchlist(root_dir: str) -> List[str]: if _valid_symbol(sym) and sym not in seen: seen.add(sym) out.append(sym) - return out if out else list(DEFAULT_WATCHLIST) + return out if out or not loaded else list(DEFAULT_WATCHLIST) except (OSError, json.JSONDecodeError, TypeError, ValueError): return list(DEFAULT_WATCHLIST) @@ -54,11 +55,7 @@ def save_watchlist(root_dir: str, tickers: List[str]) -> List[str]: seen.add(sym) out.append(sym) path = watchlist_path(root_dir) - try: - with open(path, "w", encoding="utf-8") as f: - json.dump({"tickers": out}, f, indent=2) - except OSError: - pass + atomic_json(path, {"tickers": out}) return out From aa9e15ac9c4de23951d322429503b6e9991b6ea1 Mon Sep 17 00:00:00 2001 From: Omar Alaaeldein Date: Tue, 15 Sep 2026 07:19:26 -0400 Subject: [PATCH 2/2] fix: align numpy and CI Python to 3.11-compatible floor - numpy>=2.5.2 -> numpy>=2.0 in requirements*.txt (Sentinel core/testing uses only sliding_window_view and std numpy APIs; nothing numpy-2.5-only) - CI/release workflows (and docs templates) move python-version 3.12 -> 3.11 so Python 3.11 venvs (e.g. chimp-stack root .venv) stay installable --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 ++-- docs/github-actions-ci.yml | 2 +- docs/github-actions-release.yml | 4 ++-- requirements-ci.txt | 2 +- requirements-release.txt | 2 +- requirements.txt | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a40e1a7..2f33896 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.11" cache: pip cache-dependency-path: requirements-ci.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ecfe83..9a84e7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,10 +37,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.11" cache: pip cache-dependency-path: requirements-release.txt diff --git a/docs/github-actions-ci.yml b/docs/github-actions-ci.yml index 9ffd60e..fe40c15 100644 --- a/docs/github-actions-ci.yml +++ b/docs/github-actions-ci.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.11" cache: pip cache-dependency-path: requirements-ci.txt diff --git a/docs/github-actions-release.yml b/docs/github-actions-release.yml index ef46159..dda3187 100644 --- a/docs/github-actions-release.yml +++ b/docs/github-actions-release.yml @@ -39,10 +39,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.11" cache: pip cache-dependency-path: requirements-release.txt diff --git a/requirements-ci.txt b/requirements-ci.txt index 6e1fafb..968209f 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,5 +1,5 @@ # Lean CI deps — no torch / transformers / accelerate (FinBERT optional at runtime). -numpy>=2.5.2 +numpy>=2.0 pandas>=2.1.0 yfinance>=0.2.40 matplotlib>=3.8.0 diff --git a/requirements-release.txt b/requirements-release.txt index b2c8e60..a470e8c 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -1,6 +1,6 @@ # Lite Mode packaging deps — no torch / transformers / accelerate / tensorflow. # Keep pins loose like requirements.txt for CI PyInstaller builds. -numpy>=2.5.2 +numpy>=2.0 pandas>=2.1.0 yfinance>=0.2.40 matplotlib>=3.8.0 diff --git a/requirements.txt b/requirements.txt index 2020cfb..b5ce170 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -numpy>=2.5.2 +numpy>=2.0 pandas>=2.1.0 yfinance>=0.2.40 matplotlib>=3.8.0