Audited: 2026-03-17 Status: Unresolved — no changes made yet
File: static/app.js (search logic), data_service.py:160–223 (search_ticker())
What breaks: Typing any query (e.g., "AAPL", "Apple", "TSLA") returns zero results. The dropdown never populates.
Root cause:
The search index in data_service.py is built with lowercase prefixes (e.g., "a", "ap", "app", "appl", "apple"). The search_ticker() function converts the query to lowercase via key = query.lower().strip() on line 202, then does an exact lookup _SEARCH_INDEX.get(key, []). However, the prefix-matching logic only stores whole-word prefixes — it does not store partial mid-word matches or substring matches. So a search for "ppl" (mid-word) returns nothing. More critically, the search depends on the index being populated at startup via _build_index(), but if that function fails silently or the index is empty, all searches return nothing.
User experience: User types in the search box on the home page, waits 300ms for the debounce, sees no results. Dropdown stays empty. There is no error message — just silence.
File: templates/base.html:22, static/app.js:30–37
What breaks: Clicking "Leaderboard" in the navigation header navigates the browser to /api/leaderboard, displaying raw JSON on a blank page instead of opening the modal popup.
Root cause:
The <a> tag has href="/api/leaderboard" — a direct link to the JSON API endpoint. The JavaScript event listener on line 33 calls e.preventDefault() and then fetchAndShowLeaderboard(). However, if JavaScript is not fully initialized at click time, or if there is any JS error before the click handler runs, the browser falls through and follows the href, navigating to the JSON endpoint. Even when JS is working, the pattern is fragile — href should be "#" or "javascript:void(0)".
User experience: User clicks "Leaderboard" and sees a page of raw JSON like [{"ticker":"AAPL","difficulty":"intermediate","profit_pct":12.3,...}] in the browser with no styling or context. The modal never appears.
File: templates/simulation.html:576–585
What breaks: The UI hints (B = Buy, S = Sell) imply pressing these keys will execute a trade, but they only move keyboard focus to the input field.
Root cause: Lines 582–583:
case 'b': case 'B': document.getElementById('trade-input').focus(); break;
case 's': case 'S': document.getElementById('trade-input').focus(); break;Both cases call .focus() only. There is no logic to set the trade action or submit a trade. A correct implementation would call executeTrade('buy') / executeTrade('sell'), or at minimum set the action type and await a quantity from the user before submitting.
User experience: User presses 'B' expecting to buy instantly. Instead, the cursor just moves into the quantity field. The BUY/SELL button still needs to be clicked manually. The keyboard shortcut hint on screen is effectively misleading.
File: app.py:139–145, app.py:251–266
What breaks: If a user's session expires or is cleared mid-simulation (server restart, browser cookie cleared, session timeout), all progress — trades, portfolio value, current day — is permanently lost. The user is silently redirected to the home page with no message explaining why.
Root cause:
The entire simulation state (trades, cash, shares, current day) is stored exclusively in Flask's session cookie. There is no server-side persistence of in-progress simulations. When _get_sim() returns None (session missing), the route silently calls redirect(url_for("index")) with no flash message or error context.
User experience: Mid-simulation, the page suddenly reloads to the home screen. The user has no idea their session expired, their trades are gone, and there is no way to recover. This will happen to anyone who leaves the simulation open for a long time.
File: app.py:174–183 (/setup/<ticker>)
What breaks: A user who manually navigates to /setup/RANDOMFAKETICKER can reach the setup page and configure a difficulty. When they click Start, the app calls pick_scenario("RANDOMFAKETICKER", ...), which fetches data that doesn't exist, then renders an error page — but by this point Flask may leave a partial session state.
Root cause:
The /setup/<ticker> route does not validate that the ticker exists in STOCK_META or that OHLCV data is available before rendering the setup page. The error is only caught later in /start.
User experience: User can land on a setup page for a fake ticker, choose difficulty, and click Start — only to be shown an error. It should reject the invalid ticker at the /setup step, not after the user fills out the form.
File: scenario_engine.py:29–41
What breaks: For stocks with very limited historical data (just over the minimum 60-date threshold), the scenario engine can produce scenarios as short as 1–5 trading days, which are functionally unplayable.
Root cause: Lines 32–33:
if len(all_dates) < trading_days + 10:
trading_days = len(all_dates) - 10If len(all_dates) is 11, trading_days becomes 1. There is no lower bound enforced on trading_days after this reduction. The only guard is if max_start < 1: return None, but that check only prevents negative start indices — it does not prevent scenarios of 1, 2, or 3 days from being created.
User experience: On certain obscure or newly listed tickers, the simulation starts and immediately ends, or finishes after 1–2 advance clicks.
File: templates/simulation.html:483–527, app.py (/api/scenario/trade)
What breaks: Multiple rapid clicks on BUY/SELL (or simulated calls from the browser console) fire multiple simultaneous trade requests to the server. The server processes all of them, leading to unintended over-buying or over-selling.
Root cause:
executeTrade() has no in-flight request guard. After clicking BUY, the button is not disabled, so a second click fires a second request before the first completes. The server also has no per-session trade rate limiting.
User experience: Double-clicking BUY accidentally executes two trades. If a user is clicking fast, they can accidentally buy more shares than intended, or over-sell into a negative position (though the server-side validation should catch the "insufficient shares" case).
File: static/app.js:62–75
What breaks: If any leaderboard row has a null or undefined value for profit_pct or final_value (e.g., from a corrupted database row), calling .toFixed(2) or .toLocaleString() on it throws a JavaScript TypeError, crashing the entire modal render.
Root cause: Lines 68–72 directly call:
r.profit_pct.toFixed(2)
r.final_value.toLocaleString(...)There is no null-check or fallback before these calls. One bad database row breaks the entire leaderboard modal.
User experience: User opens Leaderboard, sees a blank/broken modal or a console error. No leaderboard data is displayed.
File: templates/simulation.html:360–365
What breaks: If the server takes longer than 2 seconds to respond to a /api/scenario/advance call (e.g., due to a cache miss triggering a full yfinance download), the next auto-play interval fires before the first response arrives. Two advance requests are now in-flight simultaneously, potentially advancing the simulation by 2 days at once and desynchronizing the UI state.
Root cause:
The auto-play uses setInterval(..., 2000) without checking whether the previous request has completed. There is no isRequesting flag to skip the tick if a request is in progress.
User experience: Simulation skips days during auto-play when the server is slow. The chart jumps 2 or more candles at once instead of advancing one at a time.
File: templates/results.html:197–208
What breaks: The replay form constructs the ticker value from the Jinja2 template variable {{ stock.ticker }}. If the ticker is stored in mixed case (e.g., "Aapl"), it submits that case to /start, which then calls ticker.upper() in app.py — but only on the /start route, not on the /setup route. So the replay correctly starts, but the setup URL could be case-inconsistent.
Root cause:
Ticker case normalization is applied inconsistently. app.py does ticker = ticker.upper() in some routes but not all.
User experience: Minor: the ticker may appear in wrong case on the setup page when replaying, but functionally the simulation loads correctly because /start normalizes it.
File: templates/simulation.html:287–288
What breaks: If current_day ever exceeds total_days (due to a state bug), the progress bar CSS width exceeds 100%. CSS rendering will likely clip it, but the DOM value will be wrong.
((s.current_day + 1) / s.total_days * 100) + '%' // no Math.min(100, ...)File: templates/simulation.html:267–290
What breaks: If updateUI() fires twice in quick succession (e.g., a fast click on Advance while auto-play is running), two animation timers run on the same DOM element simultaneously, producing flickering numbers.
Root cause: No clearInterval call before creating a new animation on the same element.
File: templates/simulation.html:318–331
What breaks: Every state poll (every 2 seconds) fully rebuilds the trade log DOM even when no trades were made. This causes existing scroll position on the trade list to reset every 2 seconds.
User experience: If a user has scrolled down to see older trades, the view snaps back to the top every 2 seconds during auto-play.
File: templates/index.html:179–181, 222
What breaks: Stock logo images are loaded from an external CDN. If the CDN is unreachable, the browser opens a connection that hangs until the OS TCP timeout (could be 30–60s), blocking the browser's parallel request limit.
Root cause: No loading="lazy", no timeout, no fallback beyond onerror="this.style.display='none'".
| # | Severity | Description | File | Lines |
|---|---|---|---|---|
| 1 | CRITICAL | Search bar returns no results | data_service.py / app.js | 160–223 |
| 2 | CRITICAL | Leaderboard link shows raw JSON | base.html / app.js | 22 / 30–37 |
| 3 | CRITICAL | B/S keyboard shortcuts don't execute trades | simulation.html | 576–585 |
| 4 | HIGH | Session loss silently wipes progress | app.py | 139–145, 251–266 |
| 5 | HIGH | Invalid ticker not rejected at setup page | app.py | 174–183 |
| 6 | HIGH | Very short scenarios (1–5 days) possible | scenario_engine.py | 29–41 |
| 7 | MODERATE | Trade spam — no rate limit or deduplication guard | simulation.html / app.py | 483–527 |
| 8 | MODERATE | Leaderboard modal crashes on null DB fields | app.js | 62–75 |
| 9 | MODERATE | Auto-play overlapping advance requests | simulation.html | 360–365 |
| 10 | MODERATE | Replay button ticker case inconsistency | results.html | 197–208 |
| 11 | MINOR | Progress bar no upper bound clamp | simulation.html | 287–288 |
| 12 | MINOR | animateNumber() no timer cancel before restart | simulation.html | 267–290 |
| 13 | MINOR | Trade log scroll position resets every 2s | simulation.html | 318–331 |
| 14 | MINOR | Logo CDN requests can hang page load | index.html | 179–181, 222 |