Changelog
All notable changes to the Demetra Trading Journal project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
[1.0.79] - 2026-07-31
Fixed — MT5 auto-sync (investor password)
- Long-held trades are no longer skipped. A position held longer than about three days could be missed entirely by the incremental sync and never reach the journal. Such positions are now re-read in full before import.
- Syncing no longer logs your terminal out of your trading account. A sync could switch the running MetaTrader 5 terminal to the read-only investor account and leave it there, so trading stopped working until you logged back in. The sync now leaves the terminal alone when it is already on the right account, and restores the previous account when it has to switch.
- The app no longer freezes during a sync. The pull runs in the background instead of blocking the interface for the length of a broker login.
- Auto-sync is finally automatic: connected accounts re-sync on their own every 15 minutes while the app is open, in addition to the pull on connect and the manual "Синхронизировать".
- Connecting a credential whose login does not match the account is now rejected, instead of silently importing another account's history.
Fixed — accounts
- Creating a duplicate account now explains why ("Счёт с такой платформой, логином и сервером уже существует") instead of the unexplained "Failed to create account".
[1.0.73] - 2026-07-11
Fixed — offline resilience for cloud mode + clearer mode switching
- Cloud mode with no internet now shows a friendly "Нет подключения к серверу" screen with Retry (and "Перейти в Private-режим" when this device has an activation) instead of the webview's raw ERR_INTERNET_DISCONNECTED browser error. Startup probes the backend; a runtime watchdog swaps the screen in when the connection drops mid-session (3 consecutive failures, ~30 s).
- Switching storage mode now offers "Перезапустить сейчас" — the old "restart it yourself" alert left users thinking the switch had already applied.
- "Switch this device to Cloud" asks for confirmation and explains that the local journal stays on disk but is hidden until Private returns.
- Settings (Private mode) shows the offline reserve: the signed-lease expiry date and days left without reconnecting.
[1.0.72] - 2026-07-11
Added — Private Desktop early-access launch
- Private (local-only) mode is live: free during early access, granted by request. Request button in Settings appeared with the release clock (2026-07-11 00:00 +05).
- First installer that bundles DemetraBackend.exe — the local backend behind Private mode (installer grew accordingly, ~192 MB).
- Login page links the Windows installer directly.
- All product/legal copy reworded: free early access by request, not a paid subscription. Desktop lock screen and activation errors updated.
- CSV export uses the universal price format (no forced five decimals).
[1.0.71] - 2026-06-30
Changed — Private entitlement plumbing + dashboard polish
- Signed offline leases (Ed25519, device-bound) for Private Desktop: online entitlement checks, 7-day offline grace, clock-rollback detection, runtime lease enforcement in the local backend.
- Dashboard login flow polish and price display fixes.
[1.0.70] - 2026-06-07
Added — drag-to-update price + OCR chroma filter + tighter psychology insights
Manual chart annotation (`backtest_detail.html`)
- When OCR detects ≥2 axis ticks the modal now builds a least- squares linear calibration `price = a*y + b` between image-pixel Y and price. Each guide-line gets a live price chip (left side) that updates as the user drags, the matching input field auto-fills with the projected price, and RR recomputes on every move tick. Conversely, typing a price into Entry/SL/TP moves the corresponding line. Lines also snap to the OCR-prefilled prices on open instead of the old fixed 25/50/75% defaults. Without a calibration (0-1 detected ticks) the drag stays visual-only, same as before.
OCR pipeline (`screenshot_ocr_routes.py`)
- New chromatic-saturation filter: after `_find_highlight_boxes` we sample the peak `max(R,G,B) - min(R,G,B)` inside each candidate. Boxes with peak chroma below ~50 are dropped — TV gridline labels (white text on dim gray axis) get filtered out before we waste tesseract cycles on them; colored level pills (Entry blue, SL red, TP green) all survive.
- New near-Y dedup pass: collapses entries within 24 px whose prices round to within 0.1 %. Eliminates the "4400 detected twice" symptom from JPEG anti-aliased pill edges.
Psychology block (`analytics.html`)
- Empty state is now an actionable two-step CTA in Russian (link to /app/backtests, brief explanation of which data drives the panel) instead of a generic "no data" dash.
- Raised the per-setup / per-killzone insight threshold from 5 trades to 10 — below ~10 the rankings are pure variance.
- Added two new observations: average-win vs average-loss asymmetry (catches loose stops / oversized losers), and peak-to-trough P&L drawdown depth.
- Removed the fortune-cookie fallback ("Results are mixed, reduce unplanned trades") — when there's nothing data-driven to say, we say so explicitly with the current sample size.
Backend-only release. APP_VERSION marker bumped to 1.0.70.
No installer rebuild, no manifest bump.
[1.0.69] - 2026-06-07
Added — editable per-slot screenshot label (timeframe tagging)
Backtest screenshot slots can now be tagged with a free-form
label (typically a timeframe like H1 / M15 / M3 / M1), so a
five-slot top-down analysis reads as the actual TF stack
instead of "Slot 1 … Slot 5".
- New column `backtest_trades.screenshot_labels_json` (JSONB on PG, JSON on SQLite), parallel to `screenshots_json` and keyed by the same slot strings (`"1"`-`"5"`). Migration 0026 adds it idempotently.
- New endpoint `PATCH /backtests/{trade_id}/screenshot-label` with `{slot, label, csrf_token}`. Max 32 chars (server trims), empty string clears the label.
- `BacktestService.set_screenshot_label` handles normalization.
- `BacktestService.set_screenshot` now also clears the matching label when a slot is cleared — no dangling "H1" on an empty slot. Re-uploading to a populated slot keeps its label.
- `backtest_detail.html` slot header now hosts an inline `<input class="slot-label-input">`. Saves on blur, debounced 400 ms while typing, or immediately on Enter. Falls back to the localised "Slot N" placeholder when empty. Disabled for unsaved (new) backtests — no trade_id to PATCH against.
Backend-only release. No desktop or installer changes, no
manifest bump.
[1.0.68] - 2026-06-07
Fixed — emergency rollback of v1.0.67 PyInstaller version resource
The 1.0.67 release added `version=*_version_info.txt` to both
`demetra.spec` and `bridge.spec` to embed a Windows VS_VERSIONINFO
resource for SmartScreen reputation. That broke `Demetra.exe` at
runtime: the bootloader spawned, Python initialized to ~34 MB, but
the process hung silently before reaching `_setup_logging()`. No
log writes, no UI, no Application Error event, no crash dump.
Reproducible against the freshly-built `dist/Demetra/Demetra.exe`
before Inno Setup ran, so the regression was in the PyInstaller
output itself — not the installer.
- Removed `version=` from both `EXE(...)` blocks in `demetra.spec` and `bridge.spec`. `Demetra.exe` and `DemetraBridge.exe` are unsigned again from SmartScreen's POV; reputation will have to come from Authenticode signing (deferred B3) rather than resource embedding.
- `demetra_version_info.txt` and `bridge_version_info.txt` are kept on disk but no longer referenced by the build — they're dormant. Removing them entirely is a follow-up.
- CLAUDE.md "Version sync" section reverted to 4 places.
No backend, EA, or behavioural changes from 1.0.67 → 1.0.68. This
is a pure rebuild to ship a launchable desktop binary.
[1.0.67] - 2026-05-23
Fixed — backend + EA + ops cleanup pass
Backend
- `POST /backtests/{trade_id}/request-broker-bars` now dedups against an already-pending request for the same `(tenant, account, symbol, timeframe)`. Before this, a double-click or a stuck polling loop could insert duplicate `chart_bar_requests` rows that all raced the same EA poll cycle — the EA honours one per cycle, so the rest stayed `pending` forever and polluted the status endpoint's most-recent fallback. Response now includes a `deduplicated` boolean so the frontend can show "already in flight" copy when appropriate.
MT5 EAs
- `DemetraReplayBackfill.mq5` paces requests with a configurable `InpThrottleSleepMs` (default 250 ms) between batches. A year-long backfill (~500 positions) used to fire 500 WebRequests back-to-back, saturating the local bridge's SQLite write lock on the spool DB and stalling the live connector on the same machine.
Infra / ops
- `docker-compose.prod.yml` now sets `stop_grace_period: 60s` on the backend and `30s` on the worker. The Docker default is 10s — shorter than uvicorn's 30s graceful timeout — so slow OCR / replay-bar requests were SIGKILL'd mid-flight on deploy, leaving clients with a connection reset. 60s matches Caddy's `lb_try_duration` ceiling for end-to-end zero-downtime.
Build / installer
- PyInstaller specs now embed a Windows version resource (`demetra_version_info.txt` / `bridge_version_info.txt`) on both `Demetra.exe` and `DemetraBridge.exe`. Populates the Explorer "Details" tab with real CompanyName / FileDescription / ProductVersion fields, and gives SmartScreen something to hash for reputation accrual — fresh installs of unsigned-but- versioned binaries clear the SmartScreen prompt much faster than truly anonymous ones. These two files become a sixth and seventh version-sync site (CompanyName/ProductName stay stable; only filevers / FileVersion / ProductVersion need to move with releases).
[1.0.66] - 2026-05-23
Fixed — second-wave medium-priority cleanup
Backtest correctness
- BT2: `PUT /app/backtests/{id}` no longer zeroes out `setup_id` / `setup_name` when those fields are omitted from the body. Uses Pydantic `model_fields_set` to distinguish "not provided" from "explicitly cleared".
- BT8: `request_broker_bars` now does `ORDER BY created_at ASC` when picking the default account; previously the implicit LIMIT 1 without ordering sent multi-account tenant requests to a non-deterministic MT5.
Updater
- `parse_version` now strips semver pre-release / build suffixes (`-rc1`, `+build`) instead of returning `(0,0,0)` on any non-numeric segment. A future `1.0.67-rc1` no longer silently looks older than the user's `1.0.67` and breaks the update channel.
MT5 EAs
- `DemetraBackfillMaeMfe.mq5` now emits a deterministic `event_id = "maemfe_backfill:{ticket}:{close_time}"`. Previously the absent event_id forced the bridge to fall back to a timestamp-based id, so re-running the backfill EA wrote a fresh duplicate `raw_event` row per deal each run.
Dashboard UX
- Removed hardcoded `$` from the equity chart tooltip. Account currency is per-MT5-account and not yet on the equity payload — drop the symbol and show a locale-formatted signed number until it's plumbed through.
Schema hardening (migration 0025)
- Partial index `(tenant_id, available_at) WHERE status = 'pending'` on `rls.normalization_jobs` — worker fairness query was scanning the entire status range; this lets the planner use the index efficiently at 50+ tenants.
- CHECK constraint `connectors.status IN ('active','archived','rotating')` — schema-level guard so a bug in service code can't write a typo'd status that breaks the UI ranking.
- CHECK constraint `open_positions.side IN ('BUY','SELL')`.
Normalizer dedup
- `Trade.external_ticket` falls back to `f"re:{raw_event.id}"` when the MT5 payload omits the ticket. PostgreSQL treats `NULL != NULL` in UNIQUE constraints, so two NULL-ticket rows on the same `(account_id, close_time)` were both allowed, producing phantom duplicate trades.
Caddy
- Long-lived `Cache-Control: public, max-age=31536000, immutable` on versioned `DemetraSetup-X.Y.Z.exe` URLs (bytes never change per version). Short 1-hour `must-revalidate` on the unversioned alias and `manifest.json`. Cuts bandwidth dramatically when multiple users on the same network update.
Changed
- All version sites bumped to 1.0.66.
[1.0.65] - 2026-05-23
Fixed — second-wave review (5 parallel agents)
Punch list from the wider code review that covered Desktop, DB,
Backtest/replay, Frontend templates, and Build/deploy — areas the
first 3-agent pass didn't touch.
**XSS holes (ship-blocking, 3 sites)**
- F1 `trade_detail.html` — `legacySetupName` injected via Jinja HTML-escape into a single-quoted JS string literal. Setup name `'); alert(1); //` would break out and execute. Switched to `{{ ... | tojson }}` which emits a properly-escaped JS literal.
- F2 `trade_detail.html` + `backtest_detail.html` — OCR `d.raw` (raw Tesseract output from a user-uploaded screenshot) was concatenated into `innerHTML`. Replaced with `createElement` + `textContent` DOM building so an OCR'd `<img src=x onerror=...>` payload can never execute.
- F3 `analytics.html` — setup name (user-supplied) injected into table rows via template-string interpolation into `innerHTML`. Same fix: `createElement` + `textContent`.
**Desktop security**
- D1 `app.py:get_all_connectors` was missing the `_origin_blocked()` guard every other token-touching method has. A compromised page could call `pywebview.api.get_all_connectors()` and exfiltrate every connector's bearer token at once. Added the guard.
- D2 `updater.py` host-allowlist check used `.endswith("demetra-journal.com")`, which let `evildemetra-journal.com` pass. Replaced with exact match OR `.endswith(".demetra-journal.com")` for actual subdomains.
- D3 added origin guards to 5 more state-touching API methods that were missing them: `install_mt5_ea`, `select_mt5_folder`, `check_for_updates`, `skip_update`, `minimize_window`, `close_window`.
**Frontend regressions / footguns**
- F4 `register.html` resurrected `-webkit-app-region: drag` and a custom `.window-titlebar` HTML block from a long-reverted frameless attempt. CLAUDE.md explicitly bans this — removed.
- F5 `base.html` legacy token-save fallback branch (line ~1330) didn't set `window.__demetraAutoSetupTokenSaved = true`, reintroducing the v1.0.26→v1.0.27 race where account_setup's save would then trigger `rotate_token()` and invalidate the fresh token. Added the flag in the fallback path.
**Backtest / broker-bars feature (1.0.63 follow-up)**
- BT1 `broker-bars-status` was scoped only by `(tenant, symbol)`, so two tabs with backtests on the same asset would see each other's completion. Endpoint now accepts a `?request_id=` query param; the frontend remembers the id returned from the POST and passes it to polling. Legacy `(tenant, symbol)` lookup retained as fallback for old clients.
- BT7 `_normalize_levels` silently accepted partial level combinations with geometrically-wrong pairs (e.g. `LONG` with `SL > Entry`), which then rendered the replay chart upside-down. Now validates every detectable pairwise relationship and rejects with a clear error.
**DB**
- DB3 `ConnectorService.list_connectors` filters out archived connectors by default (new `include_archived=False` param). Old behaviour leaked archived rows into the connectors UI.
**Build / dev**
- B1 `start.bat`, `dev.bat`, `start-desktop.bat` referenced a non-existent `requirements.txt` (project uses `pyproject.toml` with `requirements.lock`). Switched all three to `pip install -q -e .[dev]` so fresh local checkouts work.
Known follow-ups (not in this release)
- B3 installer code-signing — needs a real Authenticode cert; tracked.
- B5 docker-compose `stop_grace_period` / true zero-downtime — Caddy retry covers the gap for now.
- Various medium findings batched for a 1.0.66 cleanup pass (JWT-in-localStorage exfil window, SRI on CDN scripts, partial index for worker fairness query, `Trade.external_ticket` NULL, CHECK constraints on Connector.status / OpenPosition.side, etc).
Changed
- All version sites bumped to 1.0.65.
[1.0.64] - 2026-05-21
Fixed — auto-update "doesn't do anything" bug (root cause)
Users on 1.0.61–1.0.63 reported that clicking "Update" appeared to
do nothing: UAC prompt accepted, no progress visible, app closes,
but on relaunch the version stayed the same OR the app simply
never re-opened.
Root cause: the updater launched the installer with `/VERYSILENT`,
which is incompatible with Inno Setup's `[Run]` step combination
of `postinstall` + `runasoriginaluser` we use to drop privileges
back to the logged-in user after the elevated install. In
`/VERYSILENT` mode Inno can't switch contexts, so the post-install
auto-launch was silently skipped — the user got no window after
the install and assumed it had failed.
Files DID get replaced. The user could see the new version if they
manually re-opened Demetra from Start menu or the desktop icon.
But that's a confusing UX, especially since the user reasonably
expects "click update → app comes back with new features".
Fix:
- Switched installer launch from `/VERYSILENT` to `/SILENT /SUPPRESSMSGBOXES`. Difference:
- `/SILENT`: shows the progress bar AND the final "Finish" page with the "Launch Demetra" checkbox (ticked by default). User clicks Finish and the new Demetra opens immediately.
- `/SUPPRESSMSGBOXES`: still no questions / prompts during install.
- Added diagnostic update log at `%LOCALAPPDATA%\Demetra\update.log`. Every install attempt appends a timestamped line with ShellExecute return + error code, so if "update doesn't work" recurs the user can attach that file and we can see exactly which step failed (UAC declined, ShellExecute access denied, installer missing, etc).
Important — one-time manual install required
Because the broken `/VERYSILENT` line lives inside the *existing*
desktop app's bundled updater.py, this fix only takes effect once
the user has installed 1.0.64. Users currently on 1.0.61–1.0.63
need to download the installer manually from
https://demetra-journal.com/downloads/DemetraSetup-1.0.64.exe and
run it once. After that, all subsequent auto-updates work as
expected.
Changed
- Version sites bumped to 1.0.64.
[1.0.63] - 2026-05-21
Added — broker bars for backtest replay (on-demand fetch via MT5)
Lets users replace Yahoo Finance's spot fid (the default replay data
source) with bars pulled directly from their MT5 broker. Eliminates
the 2-10 pip drift between Yahoo and the broker chart they actually
took the trade on.
Flow:
1. User opens a backtest's chart-replay block (already in 1.0.61).
2. Below the chart, clicks the new "📡 Запросить из MT5" button.
3. Backend creates a `chart_bar_requests` row (status=pending) for
the user's MT5 account.
4. EA polls `bridge/bar-requests` every 60s (proxied to backend via
HMAC-signed request). Sees the pending row, runs `CopyRates()`
for the (symbol, M1, from, to) window, ships the bars as a
`CHART_BARS_BATCH` event (already-existing path → `rls.chart_bars`).
5. EA POSTs to `bridge/bar-requests/{id}/complete` to mark the
request done.
6. Frontend polls `/broker-bars-status` every 10s; once `done` it
reloads the page and the replay chart now renders broker bars.
Components:
- Migration `0024_chart_bar_requests` (composite indexes by `(account_id, status)` and `(tenant_id, created_at)`).
- New `ChartBarRequest` SQLAlchemy model.
- Backend endpoints: `POST /app/backtests/{id}/request-broker-bars`, `GET /app/backtests/{id}/broker-bars-status`, `GET /v1/connectors/bar-requests` (EA polling), `POST /v1/connectors/bar-requests/{id}/complete`.
- Bridge proxy: `GET /bar-requests` + `POST /bar-requests/{id}/complete` inside the bridge receiver. Forwards to backend with the connector token's HMAC signature. Required because MT5 WebRequest is whitelisted to localhost only.
- MT5 EA: `PollAndProcessBarRequests` called from OnTimer every 60s. Walks bridge response, runs `CopyRates` per request, ships `CHART_BARS_BATCH`, marks complete. Lightweight JSON parsing (no MQ5 JSON lib).
- Frontend: "Запросить из MT5" button + 10s status poller.
Caveats
- Broker bars only cover what the broker keeps. Most retail brokers hold ~30-60 days of M1; older windows fall through to Yahoo.
- The EA must be running for the fetch to complete. If MT5 is closed, the request stays pending; backend doesn't time it out.
- Symbol must match the broker's notation exactly (no auto-mapping on this path — Yahoo's `EURUSD=X` aliases don't apply).
Changed
- Version sites bumped to 1.0.63 across all 6 places.
[1.0.62] - 2026-05-21
Fixed — full-chain hardening (3-agent code review punch list)
Closes the remaining critical + medium-priority findings from the
end-to-end EA → Bridge → Backend review.
**Backend**
- `auth_routes._get_client_ip` now delegates to the CIDR-aware `get_client_ip` from `dependencies.py`. Previous set-membership-only trusted-proxy check left X-Forwarded-For ignored behind Caddy on the `172.16.0.0/12` Docker subnet → per-IP rate-limit collapsed into one global bucket for ALL users on login/register/forgot/reset.
- `/app/api/auto-setup` now requires CSRF when rotating a connector token (when `need_token=true` or `account_id` is passed). Read-only identifier-lookup calls remain CSRF-free. `base.html` auto-setup fetch sends `X-CSRF-Token` header + body field.
- OCR endpoint (`POST /app/api/screenshots/extract-levels`) moved off the event loop via `asyncio.to_thread` — was blocking other async requests for 500-750ms each.
- `TRADE_MAE_MFE_UPDATE` normalizer now REFUSES updates when `account_id` is unresolved, instead of silently falling back to a tenant-wide lookup that could overwrite the wrong MT5 account (broker ticket reuse).
- Bulk CSV import streams the upload and aborts when running total exceeds `MAX_UPLOAD_SIZE`. Was buffering the entire file before size-checking.
- `app.common.settings.get_settings()` is now a process-wide singleton. Previously two competing entry-points (this one + the `dependencies.py` singleton) could surface different JWT/pepper secrets to two code paths in the same request.
**Bridge**
- Receiver middleware streams request bodies chunk-by-chunk and aborts when running total exceeds 256 KB cap. Previously `await request.body()` buffered everything before the size check, giving a chunked-POST-without-Content-Length memory-DoS window. Captured body is re-injected so downstream handlers can read it.
- `mark_sent` / `mark_failed` now REFUSE the update when `connector_fingerprint` is missing instead of falling back to a `WHERE event_id = ?` query that could mark rows belonging to a different tenant (multi-tenant on single MT5).
- Orphan cleanup now sweeps `pending` events older than 30 days with stale fingerprint too. Previously only `dead_letter` were cleaned, so pending sироты from a disconnected account accumulated forever.
- Persisted disabled-token list (token prefixes, not full tokens) written to `<spool_dir>/.disabled_tokens.json`. Survives bridge restart so a known-invalid token doesn't get re-spammed at the backend until the next health-check tick.
- Mixed-batch backoff fix: `consecutive_failures` now increments whenever ≥ 50% of a batch failed. Old logic only triggered when ZERO events succeeded, so 99 of 100 failing never tripped the backoff.
- 401-counter resets on a successful event upload (in addition to the periodic health check). A transient 401 during token rotation doesn't sit on the counter for 5 minutes anymore.
**MT5 EA**
- B3: non-retryable failures (4xx including 401 during token rotation) write the dropped deal to `MQL5\Files\demetra_deadletter.log` (one TSV line per deal) AND bump a `DeadLetterCount` counter exposed in heartbeat payloads. Backend can now surface "N deals dropped on this EA" in the connector health UI.
- B4: a single 401 from the bridge is now classified as retryable. Matches the CLAUDE.md soften-rule. `IncrementDealFailCount` + `InpMaxSendRetriesPerDeal` still cap total retries so this won't loop forever.
- B1: heartbeat no longer gated on "no pending deals". A single failing deal at the queue head used to block heartbeats indefinitely, making the backend mark the connector as stale while the EA itself was perfectly alive.
- B2: INOUT (reverse-position) deals now look up the new reverse position by symbol via `PositionSelectBySymbol` instead of reusing the INOUT deal's `DEAL_POSITION_ID` (which points at the OLD closed side). `ProcessReverseOpenAfterInout` is the new helper; old `ProcessOpenPosition(ticket)` call removed from the INOUT branch.
- B5: MAE/MFE sampling now runs from both `OnTick` AND `OnTimer` (1-second floor), and `SymbolSelect(symbol, true)` is called on the first sample so MT5 keeps the tick stream alive even when the EA's chart is on a different symbol. Previously sampling rate was tied to chart-symbol tick rate — cross-symbol positions on illiquid chart pairs missed MAE/MFE peaks between rare ticks.
- C2: when `PositionSelectByTicket` fails on a sub-second close, open-event handler now recovers SL/TP from order history instead of emitting `SL=TP=0`.
Changed
- All five version sites bumped to 1.0.62: `installer.iss`, `desktop/updater.py`, `app_routes.APP_VERSION`, `DemetraConnector.mq5` + banner, and both backfill EA banners.
- `CLAUDE.md` migrations note updated from `001..0017` to `001..0023` (head is `0023_market_bars`).
[1.0.61] - 2026-05-20
Added — Lightweight-Charts trade replay (Wave 2 of 2)
The visible half of the trade-replay feature. Where 1.0.60 wired up
the data path, 1.0.61 ships the actual TV-style chart viewer on both
the live-trade and backtest detail pages.
**Backtest replay (new):**
- `POST /app/backtests/{id}/fetch-bars` pulls OHLC bars from Yahoo Finance (via `yfinance`) for the backtest's asset + trade-date and persists them into the new `rls.market_bars` table. Walks a timeframe fallback chain (M1 → M5 → M15 → H1 → D1) so old backtests still get something even when M1 retention has lapsed. Returns the resolved Yahoo ticker, the timeframe that landed, and how many bars were inserted vs already present.
- `GET /app/backtests/{id}/bars` returns the persisted bars plus the entry/SL/TP/direction overlay data in a single payload, ready for Lightweight-Charts.
- Broker → Yahoo symbol mapping table (`EURUSD` → `EURUSD=X`, `US30` → `^DJI`, `BTCUSD` → `BTC-USD`, …) with suffix-stripping (`XAUUSD.m` → `XAUUSD=X`) and a heuristic fallback for the long tail. Manual override via `manual_symbol` body field for symbols we can't auto-resolve.
- New `rls.market_bars` table (migration `0023_market_bars`). Tenant-scoped composite PK `(tenant_id, symbol, timeframe, time)`, same `INSERT … ON CONFLICT DO NOTHING` dedup semantics as `chart_bars`. `source` column tags the provider for diagnostics.
**Live trade replay (new):**
- `GET /app/trades/{id}/bars` session-authed parallel of the v1 API endpoint, returning ChartBar rows for live MT5 trades plus the entry/SL/TP overlay metadata. Same response shape as the backtest endpoint so the frontend uses one component for both.
**Frontend (new):**
- `static/replay-chart.js` — shared Lightweight-Charts wrapper: candles, SL/TP/Entry horizontal price lines, entry-direction marker (▲ BUY / ▼ SELL), replay scrubber with play/pause and 0.5x–8x speed controls. Auto-detects dark/light theme.
- "Реплей графика" section appended below screenshots on both `backtest_detail.html` and `trade_detail.html`. On backtest pages the section includes a "Загрузить реплей" button (POST trigger); on live-trade pages it loads automatically.
- Lightweight Charts library loaded from unpkg CDN (`lightweight-charts@4.2.3`). Defer-loaded so it never blocks initial page render. Vendoring locally is a follow-on if we need offline replay.
**Dependencies:**
- `yfinance>=0.2.40` added to backend `pyproject.toml`. Heavy import (drags pandas + lxml), deferred behind the `fetch_bars_for_backtest` call so paths that never hit a replay don't pay the cost.
Caveats
- yfinance ToS is "personal, non-commercial use". For commercial scale-out we'd swap to a paid provider (Twelve Data / Polygon / Databento). The fetch path is isolated in `backtest/market_bars.py` so that swap stays small.
- Yahoo only retains M1 for ~30 days and M5/M15 for ~60 days. Older backtests fall back to H1/D1 automatically; UI shows the chosen timeframe in the status line.
- Some broker symbols don't map cleanly (e.g. broker-specific CFDs). The UI surfaces an "укажите Yahoo тикер вручную" input when auto- mapping returns None.
Changed
- Bumped `APP_VERSION`, `installer.iss`, `desktop/updater.py`, and all three EA version banners to `1.0.61`.
Pre-release review hardening (4-agent code review)
Pulled in fixes after a parallel backend / security / frontend / MT5
review pass. None of these are user-visible behaviour changes; they
close concrete attack-surface and reliability gaps before this
release reaches production.
**Backend**
- Switched to the canonical async-session `await db.get_bind()` pattern in `_persist_chart_bars` and `fetch_bars_for_backtest`. The previous `self.db.bind` form could fall through to SQLite SQL in production if the session hadn't talked to the engine yet.
- `MAX_BARS_PER_BATCH = 50_000` cap on `CHART_BARS_BATCH` so a compromised connector token can't OOM the worker with a megabatch.
- Bar timestamps clamped to `2000-01-01..2100-01-01`; `OSError` now in the per-bar exception tuple so a negative POSIX value on Windows hosts can't abort the whole batch.
- Empty `CHART_BARS_BATCH` payloads now rejected at the request boundary (400) instead of writing a `raw_events` stub.
- Per-tenant rate limit of 10 fetch-bars / minute on `POST /app/backtests/{id}/fetch-bars` (Redis-backed, same posture as login rate limiter). Fails closed in production if Redis unreachable.
- `manual_symbol` field validated against `^[A-Za-z0-9][A-Za-z0-9.=^-]{0,23}$` — defence-in-depth against SSRF / storage-abuse via crafted Yahoo ticker strings.
- Generic error message for fetch-bars failures (raw yfinance / pandas / DB errors logged server-side only).
- `asyncio.CancelledError` / `KeyboardInterrupt` re-raised before the broad `except Exception` in the yfinance fetcher (per the project rule from CLAUDE.md). Added 25-second `asyncio.wait_for` budget so a hung Yahoo connection can't pin a worker thread.
- `age_days` now measured from the FAR end of the fetch window so trades on the M1-retention boundary don't silently get half-empty bar sets.
- Minimum-bar-count guard (≥10 for intraday, ≥3 for daily) before declaring a timeframe successful — stops a single weekend-holiday bar from poisoning the fallback chain.
- Broker→Yahoo symbol mapping now checks the curated table FIRST (before normalising suffixes) so symbols like `WTI` don't get clipped to `WT`. The `.I` / `pro` non-dotted suffix stripper was also too aggressive — narrowed to dotted suffixes plus full-word `CASH` / `PRO`.
**MT5 EA**
- `DoPost` gained an `affectGate` parameter. `SendChartBars` calls it with `affectGate=false` so a slow chart-bar upload can't poison `BridgeUp` and stall the trade-event timer thread.
- Doc/code mismatch on `SendChartBars` return values fixed.
**Frontend**
- Theme detection now reads CSS custom properties from the resolved `:root` style instead of looking for a `.dark` class (Demetra doesn't use one — themes are CSS-variable based via the server-rendered `theme` setting).
- `replay-chart.js` retries up to 5 seconds waiting for the Lightweight-Charts CDN script instead of bailing permanently on first miss (defer-order race).
- Scrubber events coalesced via `requestAnimationFrame` — eliminates the per-pixel re-paint cost on 1000+ bar replays.
- "Play" after end-of-replay now rewinds to near the entry marker rather than no-op'ing. NaN OHLC values dropped before `setData()` so a bad bar can't blank the chart.
- ARIA labels on all replay controls + `aria-live="polite"` on the status div for screen readers.
- New translation keys for both `en` and `ru` so the inline fallback strings no longer leak Russian into English UI.
Known follow-ups (deferred to v1.0.62)
- Vendor Lightweight Charts locally (drop the unpkg dependency for offline-resilient replays).
- SRI hashes on remaining external `<script src>` tags (`htmx` in `base.html` plus the LWC CDN).
- Replay-controller status strings inside `replay-chart.js` are hardcoded Russian; pass through `t()` once the JS layer has an i18n bridge.
[1.0.60] - 2026-05-20
Added — trade-replay foundation (Wave 1 of 2)
Wire-level groundwork for the upcoming Lightweight-Charts trade-replay
viewer. This release only ships the **data path** — the actual replay UI
lands in 1.0.61. After installing this build the EA starts emitting bars
for every newly-closed trade; the new backfill EA covers history.
- **New `chart_bars` table** (`alembic 0022_chart_bars`). Composite PK on `(tenant_id, account_id, symbol, timeframe, time)` so re-sends upsert as a natural no-op. All OHLC columns `Numeric(20, 8)`, `volume` nullable.
- **New event type `CHART_BARS_BATCH`** (`app/ingestion/service.py`). Persisted directly into `rls.chart_bars` via `INSERT ... ON CONFLICT DO NOTHING`; never enters the normalizer pipeline (no Trade derivation needed). Per-bar timestamps are accepted as POSIX seconds, ISO 8601 strings, or datetimes; malformed bars are dropped individually with a debug log, total counts are logged at INFO.
- **New API endpoint `GET /v1/trades/{id}/bars`** returns the bars covering a trade's lifetime (plus a small cushion) in Lightweight-Charts wire format: `[{time: posix_sec, open, high, low, close, volume?}, ...]`. Frontend in 1.0.61 will feed this straight into `chart.setData()`.
- **MT5 EA emits bars on `TRADE_CLOSED`** (`mt5/DemetraConnector.mq5`). Captures M1 bars over `[open_time − 15min, close_time + 15min]` via `CopyRates` and posts them in a single `CHART_BARS_BATCH` event. Failures are best-effort — they never block the trade itself or trigger retries (replay data is cosmetic).
- **New one-shot replay-backfill EA** (`mt5/DemetraReplayBackfill.mq5`). Iterates the last `InpBackfillDays` (default 365) days of closed positions and ships M1 bar batches for each. Re-runnable (composite PK deduplicates at the DB level + deterministic `event_id` per position deduplicates at `raw_events`). Bundled by `demetra.spec` and copied into each MT5 install's `Experts/Demetra/` folder by `config_manager.install_mt5_ea`.
Changed
- Bumped `APP_VERSION`, `installer.iss`, `desktop/updater.py`, the main EA banner, and the MAE/MFE backfill EA banner to `1.0.60`.
Notes
- No frontend changes in this release — `/trades/{id}/bars` will return an empty `bars[]` until the EA (live or backfill) populates the table. Bar data is invisible to users until 1.0.61.
- The new event type is rejected by older backends with the existing `Unknown event type` error, so the EA upgrade is best paired with the backend upgrade. Both ship in this same release.
[1.0.59] - 2026-05-20
Added (F5 from the 5-feature roadmap — bulk CSV import)
- **`POST /app/backtests/import-csv`** endpoint accepts a CSV file (multipart upload, 5 MB cap) with one backtest trade per row. Required columns: `asset`, `direction`, `outcome`, `trade_date`. Optional: `rr`, `killzone`, `setup_name`, `entry_price`, `stop_loss`, `take_profit`, `notes`.
Tolerant of common synonyms — `symbol`/`pair` accepted for `asset`,
`side`/`buy_sell` for `direction`, `result`/`win_loss` for `outcome`,
`session` for `killzone`, `r`/`risk_reward` for `rr`. Direction values
accept `LONG`/`SHORT`/`BUY`/`SELL`/`L`/`S`/`B`; outcome accepts
`TP`/`SL`/`WIN`/`LOSS`/`TARGET`/`STOPLOSS`. Date column tries 7
common formats (ISO, dd.mm.yyyy, mm/dd/yyyy etc.) plus ISO-with-
offset.
When `entry_price` / `stop_loss` / `take_profit` are all present in
a row, RR is auto-derived from the geometry (overriding any explicit
`rr` column). Geometry-mismatched rows fail with a friendly message.
Encoding: tries UTF-8-BOM first, falls back to CP1251 (Excel
Russian default). Delimiter is auto-sniffed (comma, semicolon, tab,
pipe) — works with both Excel and Google Sheets exports.
Bad rows are skipped and reported individually
(`{row, error}` list in the response) so a typo in row 12 doesn't
abort the other 49 trades. Successful rows commit together; if
zero succeed, transaction rolls back.
- **`backtests.html` UI**: "Import CSV" button next to "+ New Trade". Opens a modal with file picker, "Download template CSV" link (gives a working 2-row example), and an Import button. Result shown as alert with the first 10 failed rows + a final count (`Imported 47 / 50 trades`); page reloads on partial / full success so newly-imported rows appear in the list.
- 5 new i18n keys (`backtests.import_csv`, `import_csv_title`, `import_csv_help`, `download_template`, `import_button`) in en + ru.
F1–F5 roadmap complete
v1.0.55 (TV link auto-attach) → v1.0.56 (retro-annotate live trades)
→ v1.0.57 (OCR confidence + multi-PSM) → v1.0.58 (canvas manual
fallback) → v1.0.59 (bulk CSV) closes the 5-feature roadmap from
the post-v1.0.54 planning session.
[1.0.58] - 2026-05-20
Added (F2 from the 5-feature roadmap — canvas manual fallback for OCR)
- **Manual annotation modal** on `backtest_detail.html` for cases where OCR couldn't extract all 3 levels (or extracted wrong ones). Opens automatically when `extract-levels` returns fewer than 3 detections, OR explicitly via a "Switch to manual annotation →" link in the OCR confirm modal. Inside:
- The pasted screenshot rendered at full size (max-height 75 vh) inside a positioned container.
- **Three colored draggable dashed guide-lines** (Entry blue, SL red, TP green) — purely visual aids. Lines are repositioned on Y-drag with the mouse; their colours and labels follow the current direction (LONG → top=TP / mid=Entry / bot=SL; SHORT → mirror).
- Sidebar with **three manual price inputs** + a live RR readout that updates as the user types or changes direction. Geometry- mismatch produces "⚠ Levels inconsistent with LONG" instead of a fake RR value.
- **Partial OCR results pre-fill the inputs** — if OCR detected, e.g., 2 out of 3 lines, those are placed in the matching role by direction so the user only types the missing one.
- Apply writes the 3 values into the backtest form's `bt-entry-price` / `bt-stop-loss` / `bt-take-profit` inputs and triggers the existing `recomputeRR` so the auto-from-levels chip updates immediately.
Memory hygiene: the pasted image is held as a single
`URL.createObjectURL(blob)` reference for the modal session and
revoked on close so re-opening doesn't leak.
- 3 new i18n keys (`backtest_detail.tv_switch_manual`, `tv_manual_title`, `tv_manual_help`) in en + ru.
Notes for F2
- This wave covers the backtest path only — the live-trade retro-annotate flow (1.0.56) gets the same fallback in a follow-up wave to keep this one bounded. If the user reports they need it there too I'll wire the same modal into trade_detail.html next.
[1.0.57] - 2026-05-20
Changed (F4 from the 5-feature roadmap — OCR robustness)
- **Per-box OCR confidence reported in the API response and shown in the confirmation modal.** Previously the modal listed detected prices with no indicator of how certain tesseract was about each one — users had to trust or visually compare against the screenshot. Now every detection carries a `confidence` field (0-100, average per-glyph from `pytesseract.image_to_data`) and the frontend renders a coloured dot next to each price:
- **🟢 green** ≥ 70 — high-confidence, safe to trust
- **🟡 amber** 40 – 69 — borderline, glance at the raw text column
- **🔴 red** < 40 — likely misread, verify before applying
- **⚫ grey** missing — fallback / unknown
- **Multi-PSM retry.** `_ocr_box` first runs with PSM 7 (single text line) — correct for our cropped axis labels 90 %+ of the time. If the result is unparseable OR confidence is below 60, we retry the same box with PSM 6 (uniform block of text), PSM 8 (single word), PSM 11 (sparse text), PSM 13 (raw line bypassing layout analysis) and keep the highest-confidence parseable result. Adds ~30-50 ms per low-confidence box; high-confidence boxes still complete on the first attempt.
- **Smarter default-check policy in the confirmation modal.** When exactly 3 boxes are detected, all 3 stay default-checked (common case, zero clicks). When *more* than 3 are detected (e.g. current- price label slipped in), the top 3 by confidence are default- checked — low-confidence boxes stay unchecked so the user has to positively opt in to use them.
- **Optional debug dump** controlled by `OCR_DEBUG_DUMP=true` env var. When enabled, every extract-levels call writes the cropped axis strip + each highlight box + a metadata file to `/tmp/demetra-ocr-debug/<timestamp>-*`. Lets us reproduce a user's failure case offline: ask them to re-paste with the flag on, copy the dump, replay with adjusted thresholds. Off by default — no privacy impact on regular operation.
[1.0.56] - 2026-05-19
Added (F3 from the 5-feature roadmap)
- **Retro-annotate planned levels on live trades from a TV screenshot.** Use case: trader executed an MT5 trade manually without an algo that pre-sets SL/TP, so the EA only reported entry_price + close_time + net P&L. They could never compute planned RR for those trades — analytics had a blind spot. Now on the trade-detail page:
- If both `stop_loss` and `take_profit` are NULL → a paste-zone appears next to the risk-tracking stats: "Retro-annotate planned levels".
- User pastes a TradingView screenshot showing the 3 highlighted Entry / SL / TP price lines (same flow as backtest in v1.0.54).
- We reuse the `/app/api/screenshots/extract-levels` OCR endpoint, show the same confirm modal with the 3 detected prices, and assign them to entry/SL/TP **by trade side**: BUY (long) gets top=TP / mid=Entry / bot=SL; SELL (short) is mirrored.
- On confirm, `PUT /app/trades/{id}/levels` is called — new endpoint that:
- Refuses if `stop_loss` or `take_profit` are already set by the EA (returns 409 — we don't silently overwrite broker truth)
- Validates direction-aware geometry: BUY requires SL < Entry < TP, SELL the mirror; 422 on bad order with explanatory msg
- Updates entry/SL/TP + auto-computes `rr_planned` from the geometry (overwrites any existing rr_planned)
- Tenant-scoped via `journal_service.get_trade()`
- Live-traded trades that already have EA-reported SL/TP show no paste-zone (zero risk of accidental overwrite).
The image-paste handler is gated on the retro-zone being rendered,
so on EA-completed trades a stray Ctrl+V can't accidentally try
to OCR something — it falls through to the existing 1.0.55
TV-link text-paste path.
Five new i18n keys (`trade_detail.retro_*`) in en + ru.
[1.0.55] - 2026-05-19
Added (F1 from the 5-feature roadmap)
- **TradingView share-link → auto-download to screenshot slot.** Trader copies "Chart image link" in TV (gets `tradingview.com/x/<id>/`), pastes either:
- directly into the document on the trade-detail or backtest-detail page (Ctrl+V) — clipboard text is detected by a TV-link regex and auto-attached, OR
- via the new "📋 TV link" button on `trade_detail.html` (prompt fallback for keyboard / non-clipboard contexts).
Backend resolves the share page's `og:image` meta tag (more stable
than the snapshot URL pattern), downloads the PNG via httpx with a
10 s timeout and 5 MB cap, magic-byte verifies it, and saves to the
standard upload dir. Two thin endpoints:
- `POST /app/trades/{id}/screenshot-from-tv-link` — body `{url, timeframe, csrf_token}`, attaches to the given timeframe; deletes the old screenshot for that timeframe first.
- `POST /app/backtests/{id}/screenshot-from-tv-link` — body `{url, slot, csrf_token}`, attaches to one of the 5 slots.
Both reuse `_validate_image_signature`, `_secure_path_join`,
`UPLOAD_DIR`, `MAX_UPLOAD_SIZE`, `BACKTEST_SCREENSHOT_SLOTS` so the
download path has identical hardening to the existing UploadFile
routes. Defense-in-depth on the resolved snapshot URL — we only
accept `https://s3.tradingview.com/snapshots/...` even though the
share page is trusted, so an attacker who somehow inserted a
different og:image can't steer us to download arbitrary files.
Direct snapshot URLs (`s3.tradingview.com/snapshots/<l>/<id>.png`)
are accepted too — saves the extra HTTP roundtrip for users who
already have the direct URL.
On the backtest page, the existing 1.0.54 paste-zone now does
double duty: image paste → OCR (extract Entry / SL / TP);
text paste matching the TV regex → download + attach. The
i18n hint copy was updated to reflect both modes.
Changed
- backtest_detail.html: paste-zone label updated to "Paste TradingView screenshot or share-link" (was "...screenshot").
- New en + ru i18n keys: `trade_detail.tv_link_paste_title`, updated `backtest_detail.tv_paste_*` strings.
[1.0.54] - 2026-05-19
Added
- **TradingView screenshot OCR for backtest entries.** Trader draws Entry / SL / TP horizontal lines in TradingView, screenshots the chart (TV highlights those 3 prices as filled labels on the right price axis), pastes the screenshot into the backtest form via Ctrl+V — the app extracts the 3 prices, sorts them top-to-bottom, and assigns them to Entry / SL / TP based on the chosen direction:
- LONG → top = TP, middle = Entry, bottom = SL
- SHORT → mirror (top = SL, bottom = TP)
Implemented as:
- `POST /app/api/screenshots/extract-levels` accepts a multipart PNG/JPEG/WebP (max 5 MB), crops the right-axis strip, detects highlighted rows by **contrast against the user's axis bg** (theme-agnostic — works on light / dark / coloured TV themes), runs tesseract on each box with `--psm 7 -c tessedit_char_whitelist=0123456789.,` plus auto-invert for dark-bg highlights and 2× upscale for small text. Returns the detected boxes sorted top-to-bottom with their parsed prices.
- `backtest_detail.html` paste-zone listens for `paste` events on the document (Ctrl+V from anywhere on the page works). Shows a confirmation modal with the detected prices as checkboxes — user ticks the 3 they want (defaults to all 3 if exactly 3 were found), we auto-fill the form by direction, live-compute RR.
- Migration **0021** adds `entry_price` / `stop_loss` / `take_profit` (`Numeric(20, 8)`, nullable) to `backtest_trades`. Idempotent. Live `trades` already had these columns since 0006.
- `BacktestService.create_trade` / `update_trade` derive RR automatically when all three levels are present: `(TP − Entry) / (Entry − SL)` for LONG, mirror for SHORT. Computed RR wins over any explicitly-supplied `rr` value — the screenshot is ground truth. Validation requires `SL < Entry < TP` for LONG and the mirror for SHORT, with a useful error if not.
Backend image deps: `Pillow`, `pytesseract` in pyproject + lock;
`tesseract-ocr` apt package in the backend Dockerfile (+ ~40 MB
image size, no language data packs needed — digit-only OCR).
Changed
- **Live RR display** in the backtest form. Whenever Entry / SL / TP / direction change, the `rr` input is recomputed and the label shows "auto from levels: 1.76R" (green) or "⚠ levels inconsistent with LONG" (red) so the user gets immediate feedback that the geometry matches the direction.
[1.0.53] - 2026-05-18
Security
- **CRITICAL — fixed DPAPI credential leak introduced in v1.0.51.** `config_manager.save_web_session_id()` was calling `_atomic_write_json(self.connectors_file, self._connectors)` directly. But `_load_connectors()` decrypts every connector token in `self._connectors` on load, so the in-memory dict holds plaintext tokens. Writing it raw to disk leaked all connector bearer tokens in plaintext to `%LOCALAPPDATA%\Demetra\connectors.json` on every page navigation (because `_capture_session_cookie` in `app.py` fires on every `loaded` event). Anyone with file-system access to the user's profile could read live connector tokens.
Fix: route saves through the existing `_save_connectors()` re-
encryption pipeline; both `_load_connectors` and `_save_connectors`
now SKIP the `web_session_id_envelope` key entirely so the envelope's
already-encrypted token is carried opaquely (never decrypted to
memory, never re-encrypted on save).
**Action required:** delete `%LOCALAPPDATA%\Demetra\connectors.json`
and re-enroll connectors, OR rotate every connector token via the
Connectors page in the UI. Existing tokens on disk are compromised.
- **Argon2 parameters pinned** (`common/security.py:16`). Was using passlib defaults; a future passlib upgrade could silently weaken them. Now `memory_cost=65536, time_cost=3, parallelism=4` — meets/exceeds OWASP minimums. Existing hashes keep working; rehash triggers on next successful login if these change.
- **`/v1/auth/login` rate-limit split into dual buckets** (IP-only and email-hash-only) instead of the composite `(IP, email)` key. The composite let an attacker IP get a fresh 10-attempt window per target email — credential-stuffing in slow motion. Now both buckets must pass.
- **`/app/auth/restore` explicit per-IP rate limit** (30 req / 60 s). Brute-force was infeasible against a 128-bit session_id anyway, but this stops log-noise from a hostile peer hammering the endpoint with random sids.
- **CSP at the Caddy edge** in addition to the FastAPI middleware. Defence-in-depth — a misconfigured backend can't accidentally drop the header.
Fixed
- **`/v1/analytics/backtest-vs-live` AttributeError on first call.** `analytics/service.py:744` referenced `BacktestOutcome.WIN` but the enum only defines `TP` / `SL`. Changed to `BacktestOutcome.TP`.
- **`/v1/dashboard/summary` and `/v1/dashboard/equity` 500 on invalid `account_id`.** Raw UUID propagated to asyncpg. Now validated up front via `_validated_account_id()`; bad values return 422.
- **Normalizer worker `tenant_id` filter** added to three queries that previously relied solely on globally-unique `account_id` UUIDs: `normalizer_runner.py:214` (OpenPosition lookup on TRADE_OPEN), `:432` (OpenPosition lookup on TRADE_CLOSED), `:503` (Trade idempotency lookup). Architectural defence-in-depth.
- **`TRADE_MAE_MFE_UPDATE` no-match re-queued instead of marked done.** If the back-fill event arrived BEFORE its paired TRADE_CLOSED had been normalised, the job was getting marked `done` and the MAE/MFE values silently dropped. Now re-queued with a 5-minute backoff so the late-arriving trade gets its values.
- **MQ5 `DemetraBackfillMaeMfe.mq5` Print banner** now correctly says v1.0.53 (was stuck at v1.0.44 since the EA was first added in 1.0.44).
- **`build.bat`** now copies `DemetraBackfillMaeMfe.mq5` into `dist\Demetra\` alongside the main connector. Previous version only copied the main EA → next ISCC compile would fail or ship a stale v1.0.44 EA. Also promoted both copy steps to hard failure (`exit /b 1`) instead of just printing a warning.
- **`backend/scripts/start.sh` and `scripts/deploy-prod.sh`** CRLF stripped. Repo files were Windows-formatted; fresh `docker compose build` would fail to boot with `env: 'sh\r': No such file`. Added `.gitattributes` enforcing LF on `*.sh`, `*.py`, `Dockerfile` and CRLF on `*.iss`, `*.mq5`, `*.bat`, `*.cmd`, `*.ps1` so we never hit this again.
- **Desktop `_origin_blocked` now fails closed** when the webview hasn't finished attaching to the backend origin. Previously returned `None` (allowed) during boot, which meant a preload page could fire state-changing JS through `pywebview.api`. Now rejects until the window is on the expected origin.
- **First-trade monitor stops on logout.** `stop_bridge()` now also clears the cached session token + refresh token + `first_trade_ notified` flag. Previous behaviour kept the monitor calling `/v1/analytics/overview` with a stale Bearer token every 10 s until app exit.
Changed
- **uvicorn workers** bumped from 1 → 4 (`UVICORN_WORKERS` env var overrides). Single-process was capping throughput at one core. Also added `--proxy-headers --forwarded-allow-ips='*'` since we're behind Caddy and trust `X-Forwarded-For` via the `TRUSTED_PROXIES` env on the FastAPI side.
- **Worker container healthcheck** via `pgrep -f normalizer_runner` every 30 s. Catches the dead-process case (already-known footgun: worker silently stalls when a model relationship import is missing). A queue-lag probe would be richer but requires DB access from the healthcheck.
[1.0.52] - 2026-05-18
Added
- **Auto-update download retry-with-backoff.** `updater.download_update` now wraps a single attempt in a retry loop (default 3 attempts, 2 s / 5 s / 10 s backoff). Classifies failures:
- **Transient → retry**: `socket.timeout`, `URLError` (DNS / connection-refused / SSL), HTTP 5xx, truncated transfer (got < `Content-Length`).
- **Permanent → no retry**: HTTP 4xx, oversize (> 500 MB), checksum mismatch, disk full / permission denied.
- URL validation runs once up front — doesn't change between attempts.
Closes the "Failed to download. Check your connection" pop-up that
Russian → Frankfurt users were hitting when a single chunk fell off
mid-transfer on flaky home internet.
Changed
- **JS i18n bulk** for the three remaining English-hardcoded templates:
- `account_setup.html` — 4 autoconnect-status strings (`ea_install_failed_inline`, `autoconnect_failed_inline`, `desktop_api_unavailable`, `browser_fallback`).
- `accounts.html` — 5 enrollment-flow strings (4 new keys; reused existing `accounts.waiting_agent`).
- `trade_detail.html` — 30 strings covering MAE/MFE labels + hints, exit-efficiency block, MAE/risk conditional hints, killzone-auto hint + reset link, mistakes section + 10 mistake tag labels, notes Edit/Preview tabs, "No notes yet", lightbox keyboard hint, 3 `{tf}`-interpolated JS template-literals (per-timeframe screenshot fallback).
Both `en.json` and `ru.json` extended (+41 lines each, parse-validated).
Dropzone toasts at trade_detail.html:1366-1371 reuse the existing
`I18N.screenshot_uploaded` / `I18N.upload_failed` keys — no
duplicates introduced.
Docs
- `UX_FIXES_TRACKER.md` cleaned: stale ⏳ entries moved to ✅ with the versions that shipped them. Backlog now genuinely only contains external blockers (S3 bucket provisioning, GitHub SSH deploy key registration).
- `OPERATIONS.md` extended with a "Web session persistence (1.0.51 — DPAPI restore)" section (how it works, how to force a fresh login, triage runbook) and a "WebView2 cache clear" subsection for saported users who get stuck on a cached old page after a deploy. Three new footguns documented: literal Jinja block-tags inside CSS/JS comments, htmx-target-must-not-be-#main-content (Chart.js scripts live outside it), WebView2-cookie-persistence-unreliable.
[1.0.50] - 2026-05-13
Fixed
- **Cookies actually persist across app restarts now.** v1.0.46 added `private_mode=False, storage_path=…` to `webview.start()`, but on Windows with the WebView2 backend that kwarg doesn't reliably propagate to the underlying Microsoft WebView2 runtime — different pywebview versions wire it through different code paths and at least one of those paths silently ignores it. Result: the user's session cookie still got wiped on every close.
Fix: set `WEBVIEW2_USER_DATA_FOLDER` env var BEFORE calling
`webview.start()`. That env var is Microsoft's canonical mechanism
and is honored by every WebView2 build regardless of which pywebview
knob it was attached to. Belt-and-suspenders with the existing
`storage_path` kwarg so cookies persist whichever path pywebview
actually uses.
The folder is `%LOCALAPPDATA%\Demetra\webview` (same as before).
To reset your session deliberately, delete that folder.
Server-side hotfix included from 1.0.49
- Jinja `|tojson` now handles `Decimal` and `datetime`/`date` so `/app/dashboard` and `/app/analytics` no longer 500 with `TypeError: Object of type Decimal is not JSON serializable`. (This was hot-patched onto prod under the 1.0.49 tag; recording it here for the next clean install snapshot.)
[1.0.49] - 2026-05-13
Fixed
- **Installer no longer fails with "DeleteFile failed; code 32".** The previous Inno Setup script relied on `CloseApplications=force` to close the running `Demetra.exe` before replacing files. That setting uses the Windows Restart Manager, which doesn't reliably close PyInstaller-bundled apps fast enough: the Python interpreter keeps `_internal\base_library.zip` mmap'd, and the first `DeleteFile` call fails with code 32 ("file is in use by another process"). Users then saw a retry/skip/cancel dialog and ended up with a half-updated installation that crashed or rendered raw API JSON errors in the desktop window.
Fix: `InitializeSetup` now runs `taskkill /F /T /IM Demetra.exe`
and `taskkill /F /T /IM DemetraBridge.exe` BEFORE any file
operation, then sleeps 1.5 s to let Windows flush the file
mappings. `/T` kills the whole process tree, so any pywebview
helper or child process also dies.
No code change in the desktop or backend — this is purely an
installer-side fix. After installing 1.0.49 once, all future
in-app auto-updates (1.0.49 → 1.0.50 and beyond) will perform
the taskkill cleanly without prompting the user.
[1.0.48] - 2026-05-13
Fixed
- **Auto-update download progress bar is now REAL.** Previously the bar was a CSS-animated lie — it filled to 90% in 3 seconds via `Math.random() * 15` and froze there, so every working download looked stuck. The bar now reflects actual bytes-from-server, polled from the desktop via `pywebview.api.get_download_progress()` every 250 ms, and shows `12.4 MB / 97.5 MB (12%)` text underneath.
- **Stall detection.** If no bytes arrive for 60 seconds, the modal surfaces the manual download URL so the user has a way out instead of staring at a frozen bar.
- **Per-read socket timeout (30 s).** The previous 300 s overall timeout meant a stalled TCP connection hung the updater for 5 minutes before erroring. Now individual chunk reads time out aggressively.
- **64 KB chunks (was 8 KB).** 8× fewer progress callbacks → smoother UI on slow connections; less Python/JS bridge overhead.
- **Stale installer cleanup.** The temp file (`%TEMP%\demetra_update\ DemetraSetup.exe`) is now unlinked BEFORE the new download starts. A leftover with a Windows file-lock from a zombie process used to block the next attempt.
- **Truncated-download detection.** If the network drops mid-transfer and we got fewer bytes than `Content-Length` promised, the partial file is deleted and the user gets a clear error instead of an installer that silently fails.
- **Differentiated install() error messages.** `ShellExecute` return codes are now mapped to human strings — UAC cancelled (1223), access denied (5), sharing violation (26), file not found (2), etc. The previous code returned a generic "Failed to launch installer" for every failure mode. Mapped errors propagate to the modal's alert dialog.
- **Manual fallback URL surfaced everywhere.** Any update failure now shows `https://demetra-journal.com/downloads/DemetraSetup.exe` so the user can complete the update via a browser even when in-app auto-update is broken.
Internal
- New `DemetraAPI.get_download_progress()` and `DemetraAPI._set_progress(**fields)` for thread-safe shared state.
- New `updater.install_update_with_detail()` / `AutoUpdater.install_with_detail()` returning `{success, code, error}` instead of a bool. The old `install()` is preserved for backwards compatibility.
[1.0.47] - 2026-05-13
Fixed
- **Desktop version chip on login/register actually shows the installed version now.** The v1.0.46 JS overlay called `pywebview.api.get_version()` but the Python method was named `get_app_version()`, so the call silently no-op'd and the chip remained on the backend version. Added a `get_version` alias on the `DemetraAPI` class so both names work. The original `get_app_version` is preserved for backwards compatibility with anything already calling the longer name.
Security
- **`/metrics` endpoint blocked at the edge.** Caddy now returns 404 for external `/metrics` requests. The Prometheus exposition was publicly reachable and leaked request counters, route cardinality and `python_gc_*` internals. Internal scraping inside the Docker network still works because that traffic never hits Caddy. To enable authenticated external scraping later, swap the `respond ... 404` block in `infra/Caddyfile` for a `basic_auth` handler.
[1.0.46] - 2026-05-13
Fixed
- **Cookies survive app restart.** `webview.start()` was missing `private_mode=False`, so pywebview used an ephemeral WebView2 profile and the `demetra_session` cookie was wiped every time the user closed the app. We now pin a persistent storage directory at `%LOCALAPPDATA%\Demetra\webview`, so a 24-hour session actually lasts 24 hours across closes/reopens.
- **Update modal stops eating clicks.** The auto-update modal was triggered on every page navigation (each nav click reloads `base.html`, which fires `pywebviewready`, which runs `checkForUpdates`, which reopens the modal). Its full-screen z-index 9999 backdrop then intercepted every nav-bar click. The modal is now gated by `sessionStorage` keyed by `new_version + mandatory-flag`: it auto-opens at most once per browser session per version, and the "Update available" chip in the navbar lets users open it explicitly. Mandatory updates always re-open (no "Later" button anyway).
- **Login/register version chip now shows the DESKTOP-installed version when running inside the pywebview shell.** Previously the chip always showed the *backend* version, which always reads "fresh" — so users couldn't tell whether their local installer actually applied. When desktop and backend disagree, the chip becomes `v{desktop} • server v{backend}` so the mismatch is obvious.
- **No backend / DB changes in this release.**
[1.0.45] - 2026-05-12
Added
- **Visible version chip on login + register screens.** Users had no way to tell whether the auto-update they had just clicked actually applied — the UI looked identical before and after. The brand heading now renders a small pill `v1.0.45` next to "Demetra" sourced from the backend's `APP_VERSION` (single source of truth), so the running version is visible on first paint, before any auth.
Fixed
- **Sticky "Service temporarily unavailable" alert on login/register.** The error banner had no dismiss affordance and no auto-clear, so a 503 caught during a redeploy window left a permanent-looking error on screen even after the backend recovered. The alert now:
- has an explicit `×` close button (keyboard-accessible, `aria-label="Dismiss"`),
- fades out automatically the first time the user focuses or types into the email/password fields,
- keeps `role="alert"` so screen readers still announce it on the initial render.
Pure UI change — no backend or auth-flow modifications.
[1.0.44] - 2026-05-12
Added
- **MAE / MFE back-fill from M1 history**. Closes the last open item from the original trader-UX audit: trades closed before 1.0.35 (when per-tick MAE/MFE capture shipped) had `mae=NULL`/`mfe=NULL` with no recovery path.
- New event_type `TRADE_MAE_MFE_UPDATE` accepted at `/v1/ingest/events`. Payload: `{ ticket, account_login, account_server, open_time, close_time, symbol, mae, mfe }`. Bridge ingests via the existing connector token.
- **Normalizer worker handler** finds the matching Trade by `(tenant_id, account_id, external_ticket)` and writes MAE/MFE **only when the column is NULL**. Never overwrites a more precise per-tick value, so the back-fill is safe to re-run.
- **New utility EA** `DemetraBackfillMaeMfe.mq5` (shipped in the installer alongside the main connector and auto-installed by the desktop app). User drags it onto any chart once, it scans `InpBackfillDays` (default 365) of MT5 history, computes MAE/MFE per closed position from `iHigh`/`iLow` on M1, posts a `TRADE_MAE_MFE_UPDATE` event for each, prints progress every 25 events and `DONE` when finished. Re-runnable.
- **OPERATIONS.md** documents the procedure end-to-end.
[1.0.43] - 2026-05-11
Changed
- **`Connector.enforce_account_binding` migrated from String("true"/"false") to a real Boolean** column (migration 0019). The old encoding had a real hazard: `if connector.enforce_account_binding:` was truthy on the string `"false"`, which only worked because every reader funneled through the `_is_account_binding_enforced` helper. Postgres uses an in-place `ALTER COLUMN ... USING CASE` cast; SQLite goes through `batch_alter_table` with a normalisation update first. Idempotent — skips if already Boolean.
Added
- **Off-host S3 backups** — `scripts/backup-prod.sh` now optionally uploads each gzipped `pg_dump` to S3 when `S3_BACKUP_BUCKET` is configured. Uses `STANDARD_IA` storage class by default (override with `S3_STORAGE_CLASS`) and tags objects with source + date metadata. Local retention now configurable via `BACKUP_RETENTION_DAYS` (default 7). Off-host disabled cleanly when env var is missing — no behaviour change for existing setup.
- **`docs/OPERATIONS.md`** — runbook covering routine deploy, hot-patch templates flow, installer upload, backup + restore, code/installer rollback, on-call checklist, S3 bucket policy template, and the known operational footguns (CRLF on .sh, bridge.spec onefile, BridgeUp=false, CASCADE→SET NULL, 4-place version sync).
[1.0.42] - 2026-05-11
Added
- **Supply-chain lockfiles** for backend + bridge. `requirements.lock` generated via `pip-compile pyproject.toml` pins every transitive so a breaking change in an upstream dep (like the Starlette TemplateResponse signature drop we hit at 1.0.30) can no longer slip in on the next `docker build`. Dockerfiles install from the lockfile, then re-install the project as `-e . --no-deps` for editable imports.
- **JS i18n bulk** — `trade_detail.html` now has an `I18N` bundle for inline toasts: screenshot upload/delete confirmations, analysis save status, "Saving…" button text. Falls back to English when a translation key is missing (`getting_started.html` and `account_setup.html` already used this pattern).
- **SSH deploy key generated server-side** — `/home/ubuntu/.ssh/demetra_deploy` (ed25519, no passphrase). To finish the migration off PAT, the public key must be registered as a Deploy Key on the GitHub repo (read-only) and `git remote set-url origin git@github.com:NecomaV/Fomo.git`. Pubkey is in the server's `~/.ssh/demetra_deploy.pub`.
[1.0.41] - 2026-05-11
Added
- **Period-vs-period comparison** on the analytics overview tab. New `GET /v1/analytics/period-compare` endpoint takes `from_date`/`to_date` (and optional explicit `compare_from`/`compare_to`; defaults to the same-length window immediately preceding the current range) and returns current + previous `AnalyticsOverview` plus per-metric delta. UI shows a 4-column table (Metric / Current / Previous / Δ) with arrow indicators whose colour reflects whether the change is good (e.g. ▼ on max drawdown is green). Toggled via "Compare to previous period" button above the analytics tabs.
- **Currency rollout — trades_table.** Net P&L cells now render with the scoped account's currency symbol instead of hardcoded `$`. trades_list view computes `display_currency` from the scoped account, with same rules as the dashboard.
- **E2E multi-tenant isolation tests** — `tests/test_multi_tenant_isolation.py`:
- tenant A cannot see tenant B's trades via `JournalService.list_trades`.
- `list_trades(account_id="not-a-uuid")` raises instead of leaking an asyncpg `InvalidTextRepresentation` 500 (regression guard for 1.0.29).
- `AccountService.get_account` refuses to return foreign-tenant accounts.
[1.0.40] - 2026-05-11
Added
- **Single-instance lock** in the desktop app. Windows named mutex (`Global\DemetraDesktopAppSingleInstanceMutex`) refuses a second launch; POSIX fallback is a per-user PID file in the data dir. Second-launch shows a MessageBoxW telling the user to look in the tray. Fixes the race where double-clicking the icon spawned a second backend port-bind attempt, a duplicate Job Object, and two log files in mode="w".
- **`/metrics` Prometheus endpoint** on the backend via `prometheus-fastapi-instrumentator`. Self-contained — request count, latency histogram, response-size buckets, per-route labels. Excluded from OpenAPI; toggleable via `METRICS_ENABLED=false`.
- **Bridge Sentry wiring**. SDK init in `bridge/__main__.py` activates when `SENTRY_DSN` (or `BRIDGE_SENTRY_DSN`) is set. Conservative sample rates (0.05 traces). `_scrub_sentry_event` redacts `Authorization` / `X-Demetra-Signature` / `X-Demetra-Timestamp` / `Cookie` / `X-API-Key` headers, drops request body / query / cookies, and truncates breadcrumb messages to 500 chars so accidentally-debug-logged payloads don't leak.
- **Migration smoke tests** — `tests/test_migrations.py`:
- `alembic upgrade head` on a fresh SQLite DB; fails the suite if a migration is non-portable or non-idempotent.
- `alembic heads` must return exactly one head — catches diverged migration history before merge.
[1.0.39] - 2026-05-11
Added
- **Settings page expansion** — new Profile section showing email / account ID / member-since date and a Change Password form. POST handler verifies the current password, enforces the same complexity rules as registration (≥ 8 chars, upper + lower + digit), refuses re-using the current password, and surfaces inline success / error flashes via query-string.
- **One-shot `/v1/auth/setup`** — Redis keys (`user:setup:<nonce>` and the `user:setup_nonce:<user_id>` pointer) are deleted after the first successful read. A leaked JWT in the 1-hour TTL window can no longer fetch the connector token twice.
[1.0.38] - 2026-05-11
Added
- **Backtest-vs-Live setup comparison** on analytics. For every setup_name in both `BacktestTrade` and `Trade`, surface trade counts, win rates, average R-multiple, and the **expectancy gap** (backtest_avg_r − live_avg_r). Positive gap = live underperforms backtest (strategy decay or execution problem). Sorted by absolute gap descending.
- New schemas `SetupBacktestVsLive` + `AnalyticsBacktestVsLive`, service `AnalyticsService.get_backtest_vs_live`, and `GET /v1/analytics/backtest-vs-live`.
[1.0.37] - 2026-05-11
Added
- **Currency rollout — trade_detail**. `display_currency` from `account.currency` threads through net_profit, profit, commission, swap, entry_balance, close_balance, risk_amount displays.
- **JS i18n bulk extract** for `account_setup.html` autoconnect flow. 12 keys with English fallbacks; 11 hardcoded strings replaced with `I18N.*`.
- **Canvas a11y completion** — `role="img"` + `aria-label` on the remaining 4 analytics canvases (setupBar, execDoughnut, execBar, psyDoughnut).
[1.0.36] - 2026-05-10
Added
- **Currency rollout (dashboard)** — non-USD accounts now see €/£/¥/CHF/A$/C$ in balance card, KPI net P&L, expectancy, and max-drawdown.
- **State-aware onboarding wizard Step 2/3** — new `/app/api/onboarding/status` endpoint returns `{bridge_alive, first_trade}`. Step 2 flips to completed when bridge heartbeat is < 60 s. 5-min timeout shows a recovery panel with FAQ + Retry button instead of silently stopping.
- **Settings sidebar dropped** — 240 px sidebar with a single "Design" link replaced by single-pane layout.
- **Polish** — `login.html` "Sign up free" CTA renamed to "Create account" to match the other signup link on the same page. `getting_started.html` hardcoded English ("MT5 not detected" / "Install the desktop app") switched to `t()` lookups.
[1.0.35] - 2026-05-09
Added
- **MAE / MFE — Maximum Adverse / Favorable Excursion**. Tracked per-tick by the EA over the life of every open position; emitted in the TRADE_CLOSED payload as `mae` and `mfe` (price-distance, both ≥ 0). Stored on `Trade.mae` / `Trade.mfe` (Numeric(20,8)).
- **Migration 0018_add_mae_mfe** — adds columns idempotently.
- **Trade detail** stat grid renders MAE, MFE, **Exit Efficiency %** (how much of the available favourable move was captured) and **MAE / risk %** ("stop nearly tagged" / "comfortable" / "moderate heat").
- **CSV export** now includes `mae` and `mfe` columns.
- New EA helpers: `TrackPositionMaeMfe`, `GetMaeMfeForPosition`, `DropMaeMfePosition`. `OnTick` (was empty `{}`) now updates the running max/min for every open position. Tracker entry is dropped when the position closes.
[1.0.34] - 2026-05-09
Added
- **Insight engine** rewritten on analytics — multi-dim data-driven instead of fortune-cookie if/else. Surfaces best vs worst killzone and setup expectancy, plus streak nudges only when actually relevant.
- **Charts data-table fallback** for analytics equity / pnl / drawdown canvases. Screen readers + copy-paste users can access the underlying numbers.
- **Heatmap timezone selector** (UTC / Local). Cells shifted by browser tz offset before bucketing; hint footer shows the resolved tz name.
- **Prop-firm rule tracking** (client-side, no migration). Per-account localStorage stores max-daily-loss / max-overall-DD / profit-target / min-trading-days. Banner above dashboard KPIs reads totalPnl + maxDD from rendered KPIs and renders ok/warn/bad pills against thresholds.
- **Inline tutorial card** on getting-started replaces the broken "Watch tutorial" external-URL button.
- **i18n quick win** — `account_setup.html` "Copied!" toast now uses `t('common.copied')`.
[1.0.33] - 2026-05-09
Added
- **Active-account chip** in navbar with cookie-backed session scope. Selecting an account on any page now persists across navigation (dashboard ↔ trades ↔ analytics) instead of resetting per-URL. New `POST /app/api/active-account` endpoint and `get_effective_account_id()` helper.
- **Mistake tagging** on trade detail — 10 structured pills (moved-stop / FOMO / revenge / averaged loser / no-plan / no-SL / overleveraged / chased / news / early exit) reuse `tags_json` with `mistake:` prefix. No schema migration.
- **Screenshot lightbox** — click chart image to zoom; ESC / backdrop close; focus-trap; `role="dialog" aria-modal="true"`.
- **Currency-aware money macro** — `currency_symbol(code)` maps USD/EUR/GBP/JPY/ CHF/AUD/CAD to their symbols (was hardcoded `$` everywhere).
Changed
- **CSP tightened** — added `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`, `upgrade-insecure-requests`. `'unsafe-inline'` retained pending bigger template refactor.
- **Empty-state UX** on `accounts.html` — auto-focus account-name input.
[1.0.32] - 2026-05-08
Added
- **R-multiple distribution histogram** on analytics overview tab.
- **Recovery Factor** KPI on analytics overview (Net P&L / |Max DD|).
- **Notes Markdown** Edit / Preview tabs on trade detail (no library, full HTML-escape).
- New schemas `RDistribution` + `RDistributionBin` and `GET /v1/analytics/r-distribution`.
- Theme-aware tooltip + profit/loss colors in analytics.
Removed
- Stale specs (`Demetra.spec`, `DemetraBridge.spec`, `bridge/bridge_build.spec`).
[1.0.31] - 2026-05-08
Added
- **Carousel A11y** — pause control, `role="tab"`/`aria-selected`, auto-pause on hover/focus, disabled by default with `prefers-reduced-motion`.
- **Trade detail polish** — killzone "auto-detected" hint, direction auto-derive (BUY⇒LONG/SELL⇒SHORT), account name (not UUID), `for=` on all dropdown labels.
- **Dashboard empty-state CTA** → `/app/getting-started`.
- **Equity chart A11y** — `role="img"` + descriptive `aria-label` + collapsed `<details>` data-table fallback.
- **State-aware onboarding Step 1** — pending until pywebview confirms install.
- **Analytics tabs ARIA** — all 8 tabs got `role="tab"`/`tablist`/`tabpanel`, `aria-selected`, roving tabindex, Arrow/Home/End nav.
Removed
- Duplicate "Manage Accounts / View Connectors / App Configuration" `<details>` block.
[1.0.30] - 2026-05-08
Added — Foundation & A11y
- **Skip-link** + `<header role="banner">` + `<main id="main-content" tabindex="-1">`.
- **Global `:focus-visible`**, **`tabular-nums`** on financial cells, **`prefers-reduced-motion`** kills decorative animations.
- **`aria-current="page"`** on active nav links.
- **Update modal** — `role="dialog"`, ESC, focus-trap, mandatory updates remove Later button from DOM.
- **Form A11y** — `autocomplete="email/current-password/new-password"`, `aria-invalid` + `aria-describedby` on errors, `role="alert"`.
- **Bridge-status chip** in navbar (10s poll).
- **Logout confirm** + bridge stop. **Token rotation** warning copy.
Added — Trader dashboard (the killer change)
- **Profit Factor / Expectancy / Max Drawdown** replace vanity Best/Worst trade.
- **Date-range presets** (Today / Week / Month / YTD / All time) on dashboard, trades, analytics.
- **Equity chart**: drawdown shading + high-water-mark line.
- **Chart.js theme helper** reads CSS-vars at runtime — works on all 6 themes.
Added — Trade table & journal
- **Sortable column headers** via `aria-sort` + hidden form input.
- **Columns**: killzone, setup, RR, screenshot icon.
- **Win/loss row tinting**, **outcome filter** (winners/losers only).
- **CSV export** at `/app/trades.csv` — streamed, 50K cap.
- **`has_next`** from server-side total (was `len() >= 50` false-positive).
Changed
- Removed dead `static/style.css` link (789 lines fully overridden).
- All 20 `templates.TemplateResponse(...)` migrated to new `(request, name, ctx)` signature (older Starlette dropped support).
- `DashboardSummary` +`profit_factor` +`expectancy` +`max_drawdown`.
- `TRUSTED_PROXIES` env wired with CIDR support; HSTS + X-Real-IP in Caddy.
- Session-fixation defeated; login `except` narrowed.
[1.0.29] - 2026-05-07
Fixed
- **DemetraError TypeError** in ingestion (3 raise sites had 1-arg constructor).
- **Per-tenant fairness** in normalizer worker — `ROW_NUMBER() OVER PARTITION BY tenant_id`.
- **Migrations skipped** on every redeploy (`start.sh` now always runs `alembic upgrade head`).
- **`account_id` UUID validation** at route + service boundary.
- **Bridge `cleanup_old_events`** undefined `self._max_retries`.
- **Shared httpx client** used in `_process_pending` hot path.
- **Desktop logs** — `RotatingFileHandler` preserves prior session.
- **`connectors.json`** atomic write (`tmp + os.replace`) with `.bak` fallback.
- **JS-API origin guard** extended to all 9 state-changing methods.
- **MT5 EA `SyncHistory`** gates on `BridgeUp` (was freezing thread ~70min).
- **MT5 EA `SaveDealCursorToState`** atomic via tmp + `FileMove FILE_REWRITE`.
[1.0.28] - 2026-04-30
Added
- Multiple themes — Obsidian Gold, Arctic Cyan, Ivory Gold, Emerald Terminal.
[1.0.26-1.0.27] - 2026-04-15
Fixed
- Multi-account isolation hardening: spool `get_pending_events` filters by `connector_fingerprint`; `has_saved_token()` made tenant-aware.
- Connector FK changed from CASCADE to SET NULL (migration 0015).
- Bridge stability: `stop_bridge()` on logout with `_bridge_intentionally_stopped` flag so health monitor won't auto-restart.
- 401 single-failure no longer kills the bridge in ~75s.
- Token rotation race between `account_setup.html` and `base.html` auto-setup fixed via mutual-exclusion JS flags.
[1.0.18] - 2026-03-20
Fixed
- Installer `[Run]` needs `runasoriginaluser` — without it post-install launch inherits admin privileges, bridge runs elevated, subsequent non-elevated app launches can't kill it.
[1.0.7] - 2026-02-12
Fixed
#### Auto-Setup Token Rotation Storm (Critical)
- **Problem**: The auto-setup JavaScript in `base.html` ran on every page load and called `rotate_token()` each time, invalidating the bridge's saved connector token. This caused the bridge uploader to get 404 errors on every event ingestion attempt. Connectors accumulated token_versions of 47, 61, etc.
- **Fix (three-part)**: 1. `base.html` JS now calls `has_saved_token()` before requesting auto-setup — skips entirely if token exists 2. Auto-setup endpoint only rotates when `need_token=true` is explicitly passed in request body 3. Desktop `app.py` exposes new `has_saved_token()` method to the JS layer
- **Files**: `backend/app/web/templates/base.html`, `backend/app/web/routers.py`, `desktop/app.py`
#### Legacy Token Verification Fallback
- **Problem**: `derive_connector_pepper()` was introduced but existing connectors had token hashes computed with the base pepper directly (`SHA256(base_pepper + token)`). These connectors could no longer authenticate.
- **Fix**: Added legacy base-pepper fallback in `verify_connector_token()`. Tries `SHA256(base_pepper + token)` as a last resort and auto-upgrades the connector hash to v2 with derived pepper on successful match.
- **Files**: `backend/app/connectors/service.py`
#### Updater Uses ShellExecute for UAC Elevation
- **Problem**: `subprocess.Popen` cannot request UAC elevation on Windows, so the installer would fail silently when launched from the desktop app.
- **Fix**: Switched to `ctypes.windll.shell32.ShellExecuteW` with `"runas"` verb for proper UAC elevation.
- **Files**: `desktop/updater.py`
Changed
- Version bumped to 1.0.7 across all four tracking files (installer.iss, DemetraConnector.mq5, desktop/updater.py, backend/app/api/app_routes.py)
[1.0.6] - 2026-02-08
Fixed
#### EA Code Review Fixes
- Fixed volume parameter to use `INOUT` modifier for proper MQL5 reference passing
- Fixed retry logic to use `-1` return code for send failures (was using `0` which meant "skip")
- Added history context validation before processing deals
- Added bridge-down cooldown to prevent 42-second WebRequest blocking on every timer tick
#### Bridge pydantic Import Crash
- **Problem**: Built `DemetraBridge.exe` crashed on startup with pydantic import errors
- **Fix**: Added `collect_submodules('pydantic') + collect_submodules('pydantic_core')` to `bridge.spec` hidden imports
#### EA Startup Blocking
- **Problem**: `BridgeUp` starting as `true` caused first heartbeat to block timer for ~42 seconds
- **Fix**: `BridgeUp` now starts `false`
[1.0.5] - 2026-02-01
Added - Invisible Flow (99% Automatic Setup)
#### Auto-Account Creation
- Accounts now automatically create from first trade event
- Uses `(login, server)` from trade payload as unique identifier
- Supports unlimited MT5 accounts per user
- No manual account setup required
- Backward compatible with manual account creation
#### Getting Started Page
- New `/app/getting-started` route for onboarding
- Live monitoring for first trade (polls every 5 seconds)
- Shows success message and redirects to dashboard when trade detected
- Includes EA installation instructions and FAQ
#### Smart Redirect Logic
- Login: Redirects to getting started if user has 0 trades, dashboard otherwise
- Register: Always redirects to getting started for new users
#### Desktop App First-Trade Notification
- Background monitoring thread polls for trades every 10 seconds
- Cross-platform notifications (Windows, macOS, Linux)
- Auto-stops after notification shown
Changed
- User onboarding flow: Reduced manual steps from 5 to 1 (just drag EA to chart)
[Previous Releases]
Security & Robustness Updates
- Token hashing v2 with per-connector derived pepper
- HMAC signature verification with replay protection
- 409 status handling for duplicate events
- Payload schema validation
- Idempotency checks for trade normalization
- Quarantine system for failed account lookups
- Rate limiting with Redis (10 attempts / 5 min per IP)
Core Features
- MT5 trade ingestion via EA
- Bridge architecture (receiver → spool → uploader)
- Multi-tenant support with RLS schema
- Real-time trade normalization workers
- Analytics dashboard with equity curve
- Screenshot management (multi-timeframe)
- Trade tagging and analysis
- Desktop app with embedded backend
- Auto-update system with UAC elevation
Migration Guide
Upgrading to 1.0.7
If you're experiencing trades not appearing after updating:
1. Close the desktop app
2. Delete `%APPDATA%\Demetra\connectors.json` and `%APPDATA%\Demetra\bridge.yaml`
3. Reopen the app and re-login
4. Auto-setup will provision a fresh connector token
No database migrations required.
For New Users
1. Install desktop app
2. Register
3. Drag EA to MT5 chart
4. Start trading
5. Everything else is automatic!
Known Issues
1. **NotFoundError returns 404 instead of 401** — Failed token verification raises `NotFoundError` which the global error handler maps to HTTP 404. Bridge handles this correctly.
2. **Rate limit shared IP in production** — All connectors behind Caddy reverse proxy share a single Docker internal IP for rate limiting purposes.
3. **AppData persists across updates/uninstalls** — `%APPDATA%\Demetra\` is not cleaned by the installer or uninstaller. Stale tokens can cause issues after server-side changes.
4. **Multiple accounts with same MT5 login** — Using the same MT5 login across multiple Demetra accounts can cause ambiguous auto-detection.