Analyzed: 2026-03-17 Status: Unresolved — no changes made yet
File: app.py — Lines 175, 252, 324
Impact: 3× full stock history downloads per action (trade, state poll, results view)
Every major route independently calls fetch_daily_ohlcv() without sharing the result within the same request lifecycle. On a cache miss this hits yfinance and downloads period="max" — potentially years of daily OHLCV data — just to serve one API response. Even on a cache hit, the file is read and deserialized from disk three separate times per user interaction.
Root cause: No request-scoped caching or memoization of the fetched dataset.
Affected routes: /api/scenario/state, /api/scenario/trade, /results
File: templates/simulation.html — Lines 297–301
Impact: Plotly redraws the entire candlestick chart on every auto-play tick
updateChart() is called every 2 seconds. It passes the complete candles dataset to Plotly.react(), which diffs and re-renders the whole trace. As the simulation progresses and the candle array grows, this gets progressively more expensive. The correct approach is to use Plotly.extendTraces() to append only the new candle.
Root cause: No incremental chart update — full dataset re-rendered each tick.
File: app.py — Lines 202, 416–431
Impact: O(trades) computation runs every 2 seconds during auto-play
_avg_cost() iterates through the entire trades list to compute the average cost basis on every /api/scenario/state poll. The trades list only changes when a new trade is made, but the function is called unconditionally on every state request including read-only polls.
Root cause: No caching of derived values; recalculated even when input hasn't changed.
File: data_service.py — Lines 226–233
Impact: Fetches and deserializes full OHLCV history just to return the latest close
get_current_price() calls fetch_daily_ohlcv() internally, then does sorted(data.keys())[-1] to find the latest date. This is called on the index page, setup page, briefing page, and simulation page — meaning every page load downloads/deserializes the full history to get one number.
Root cause: No lightweight "latest price" path; reuses the expensive full-history fetch.
Affected pages: index (line 75), setup (line 102), briefing (line 151), simulation (line 161)
File: templates/simulation.html — Lines 267, 279, 290
Impact: Stacking interval timers cause continuous DOM reflows every 2 seconds
animateNumber() creates a setInterval (12 steps × 20ms = 240ms) for every number it animates. It is called for portfolio value, P&L, and unrealized P&L on every state fetch. If a prior animation hasn't finished before the next tick fires, multiple timers run simultaneously on the same DOM elements, causing layout thrashing and visible jank.
Root cause: No cancellation of previous animation before starting a new one; no requestAnimationFrame.
File: templates/simulation.html — Lines 318–331
Impact: Full DOM replacement of trade list every 2 seconds
renderTradeLog() calls [...trades].reverse().map(...) to generate HTML for every trade, then sets innerHTML on the container. Every state poll — including polls where no new trade occurred — triggers a full teardown and rebuild of the trade log DOM.
Root cause: No diffing or incremental append; replaces entire list unconditionally.
File: app.py — Lines 42–56
Impact: Full table scan on every leaderboard query
The leaderboard table is created with no indexes. SELECT * FROM leaderboard ORDER BY profit_pct DESC LIMIT 10 performs a full table scan and sort on every results page load and every /api/leaderboard call. Missing index: CREATE INDEX idx_leaderboard_profit ON leaderboard(profit_pct DESC).
Root cause: Table DDL has no indexes defined.
File: data_service.py — Lines 160–178
Impact: Iterates the entire STOCK_META dictionary on every search keystroke
The search function loops through every entry in STOCK_META doing string matching with no prebuilt index or prefix tree. As the metadata grows, this scales linearly. This is called on every debounced keystroke from the search box.
Root cause: No inverted index, trie, or sorted lookup structure for ticker/name search.
File: app.py — Lines 235–236, 305–306
Impact: Full session re-serialization and storage write every 2 seconds
session.modified = True is set on every state change, even on read-only state polls. This forces Flask to re-serialize the entire session object and write it to storage every 2 seconds during auto-play.
Root cause: Overly broad session modification flag; should only be set when session data actually changes.
File: templates/index.html — Lines 179–181, 222
Impact: Slow CDN responses delay page load; no lazy loading
Multiple <img> tags load company logos from an external CDN synchronously. If the CDN is slow or a request times out, it stalls the browser's rendering pipeline. There is no loading="lazy" attribute or placeholder strategy.
Root cause: No lazy loading; external resource fetched eagerly on page load.
File: templates/results.html — Lines 153–155, 170–172
Impact: indexOf() called on the full candles array for every buy/sell point
When rendering the results chart, the code calls candles.x.indexOf(date) for every trade marker. With a large candles array and many trades, this is O(n×m). A pre-built {date: index} map would reduce this to O(1) per lookup.
Root cause: No pre-built index; linear search repeated per trade.
File: scenario_engine.py — Lines 46, 87
Impact: Redundant sort operations on the same dataset
_sorted_dates() sorts the full data keys in both pick_scenario() and _generate_briefing(). The sort is O(n log n) and the same data is sorted twice unnecessarily. The result should be computed once and passed through.
Root cause: No shared sorted-dates variable between functions that operate on the same dataset.
File: templates/results.html — Lines 226–241
Impact: One interval per .pnl-big element, 30 steps × 40ms = 1200ms per element
Same pattern as issue #5 but on the results page. A setInterval is created for each P&L element with no cleanup. If the page is navigated away from or re-rendered before the interval completes, timers leak.
Root cause: No clearInterval before creating new timers; no cleanup on unmount.
File: static/app.js — Lines 48–105
Impact: Full DOM removal and HTML string rebuild on every modal open
document.getElementById('lb-modal').remove() is called first, then the entire modal HTML is regenerated and injected. There is no reuse of the existing modal element. The modal should be hidden/shown and only its data updated.
Root cause: No modal state management; recreate instead of update pattern.
File: static/app.js — Lines 18–28
Impact: perspective(800px) rotateY() rotateX() recalculated on every mousemove event
3D CSS transforms are recalculated synchronously on every mousemove event with no requestAnimationFrame throttle. At 60+ events/second during mouse movement, this is unnecessary GPU/layout work.
Root cause: No requestAnimationFrame wrapping; no event throttle.
| # | Severity | Issue | File | Lines |
|---|---|---|---|---|
| 1 | CRITICAL | fetch_daily_ohlcv() called 3× per action |
app.py | 175, 252, 324 |
| 2 | CRITICAL | Full Plotly chart rebuild every 2s | simulation.html | 297–301 |
| 3 | CRITICAL | _avg_cost() recalculated every state poll |
app.py | 202, 416–431 |
| 4 | CRITICAL | get_current_price() downloads full history |
data_service.py | 226–233 |
| 5 | HIGH | animateNumber() stacks interval timers |
simulation.html | 267–290 |
| 6 | HIGH | renderTradeLog() full DOM rebuild every tick |
simulation.html | 318–331 |
| 7 | HIGH | No DB index on leaderboard | app.py | 42–56 |
| 8 | HIGH | search_ticker() O(n) linear scan |
data_service.py | 160–178 |
| 9 | HIGH | Session marked modified on every poll | app.py | 235–236, 305–306 |
| 10 | MEDIUM | CDN logos block page render | index.html | 179–181, 222 |
| 11 | MEDIUM | Buy/sell point lookup O(n×m) | results.html | 153–155, 170–172 |
| 12 | MEDIUM | _sorted_dates() called redundantly |
scenario_engine.py | 46, 87 |
| 13 | MEDIUM | P&L animation timer leak on results page | results.html | 226–241 |
| 14 | MEDIUM | Leaderboard modal fully recreated on open | app.js | 48–105 |
| 15 | MEDIUM | Unbounded mousemove 3D transform |
app.js | 18–28 |