Oracle3
Open-source trading engine and MCP server for prediction markets. Fee-aware no-arbitrage trading across related contracts, live on Kalshi, Polymarket and Solana or on paper.
Any backtest can be tuned until it looks good. Finertia scores your parameters on data they were never fitted to.
Translation pending
FinancePython · JavaScriptGitHub @hulagerushikeshOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
Production-grade backtesting platform for momentum, MACD, and Bollinger mean-reversion strategies. All signal, position, and metric logic is hand-written pure Python (pandas + numpy). Zero external backtesting libraries — you can read the exact lines that produce your Sharpe ratio.
Beyond a single backtest, Finertia answers the two questions that decide whether a result means anything:Read the full README ↗Production-grade backtesting platform for momentum, MACD, and Bollinger mean-reversion strategies. All signal, position, and metric logic is hand-written pure Python (pandas + numpy). Zero external backtesting libraries — you can read the exact lines that produce your Sharpe ratio.
finertia.hulage.in/demo, no account needed. Re-record with frontend/design/record-demo-gif.mjs.
Beyond a single backtest, Finertia answers the two questions that decide whether a result means anything:
Four more checks sit behind those, each built because the previous one was found to be lying in a specific way:
deflated.py, Bailey & López de Prado 2014) — the walk-forward winner is the max of N grid cells, and the max of N draws is inflated even with zero edge. DSR measures the winner against that bar instead of against zero.trials.py, Li & Ji 2005; López de Prado & Lewis 2019) — N is not the grid size: a 20-day and a 25-day lookback are nearly the same trial. Measured two ways from the candidates' in-sample returns (eigenvalue count, correlation clustering); the deflation is reported under the raw N and the measured one, and the headline takes the larger estimate, because lowering N is the direction that flatters a result. On AAPL 2018-01-01 → 2024-01-01 the momentum grid of 16 measures as 6 (clusters say 3), Bollinger's 12 as 7 (clusters say 2); no verdict changes, the deflated Sharpe moves by +0.07 to +0.12.pbo.py, CSCV) — is selecting on the in-sample score better than picking at random, across all 70 balanced splits of the period.purge.py) — the trade straddling the in/out-of-sample cut cannot earn on both sides.bootstrap.py) — on every metric, every plan; coverage measured on GARCH paths, the two metrics that fail are flagged.rolling.py) — the single 70/30 split is one draw, and the verdict below flips when the window moves. So the split is walked forward: anchored in-sample stretch, the grid re-optimised at each of four folds, each winner scored only on the segment that follows, the market's own return and volatility beside every fold, and the segments stitched into one out-of-sample curve with a bootstrap interval. A strategy that earned in 2020–21 and lost in 2022 is reported as exactly that, not as an average.sharpe_test.py) — the strategy's Sharpe against buy-and-hold's with a p-value between them, not just side by side. Ledoit-Wolf (2008): the two series are paired (the strategy trades the benchmark's own asset), the standard error is HAC so autocorrelation and volatility clustering are not assumed away, and the headline p-value comes from a studentised bootstrap rather than a normal approximation. Two-sided, so a strategy significantly worse than holding is called too. On every backtest and every portfolio run.regimes.py) — every bar labelled by the market's trailing 21-day realised volatility, cut into terciles of the period, and by its direction: the t-statistic of the trailing 60-day mean return, up / flat / down at ±1σ. The strategy's Sharpe, contribution, and time in market on each label, with the market's own Sharpe beside it, and a 3 × 3 vol × trend grid with Sharpe per cell. On every backtest and on the stitched out-of-sample record. "Earns in calm markets, gives it back in turbulent ones" becomes three numbers — and the grid says whether the turbulent loss was chop or a bear market.snooping.py, White 2000; Hansen 2005; Romano & Wolf 2005) — every other check judges the winner. This takes every cell of the grid against buy-and-hold in one joint bootstrap: the Reality Check and SPA p-values for "does anything here beat the market once the search is accounted for", and a Romano-Wolf stepdown for "which cells". On AAPL 2018→2024, five other tickers and both windows, no cell survives at 5% — and the best cell's p-value alone versus inside its grid is the size of the snooping, printed.See learning/03-validation-methods.md for formulas, traps, and where each lives.
The full write-up, with every figure re-run and checked by a script: finertia.hulage.in/writeup (source: planning/write-up.md).
Those two checks are only worth building if they change the answer. Run against AAPL, 2018-01-01 → 2024-01-01, they do — ranking the three strategies on the data they were tuned on gives you exactly the wrong order.
| Strategy | In-sample Sharpe | Out-of-sample Sharpe | Verdict |
|---|---|---|---|
| Momentum | 0.949 | −0.303 | Failed |
| MACD | 0.511 | −0.300 | Failed |
| Bollinger | 0.558 | 1.169 | Held up |
(Figures as of 15 Sep 2026, with purge/embargo applied; the pre-purge run in August read 0.889 / −0.242 and 0.553 / 1.367 — same order, same verdicts.)
The best in-sample result was the worst out-of-sample one. Momentum looked like the clear winner and had no edge at all on data it had not been fitted to.
And the verdict depends on where you cut. Extend the same run by one year, to 2025-01-01, and it inverts: momentum holds up (0.766 → 0.547) and Bollinger fails (0.686 → −0.157). Nothing about the strategies changed — the split moved from February 2022 to October 2022, and the out-of-sample half moved with it. A single walk-forward split is one draw from a distribution of splits; that is why CSCV (every balanced split) sits beside it, and why "regime-aware walk-forward" is the next research item.
Walking the split forward instead shows what the single draw was made of. Four folds, the grid re-fitted at each, every segment scored by parameters chosen before it:
| Out-of-sample segment | Market | Momentum | Bollinger |
|---|---|---|---|
| Jun 2020 → Apr 2021 | +54% | 0.27 | 0.57 |
| May 2021 → Mar 2022 | +30% | 1.10 | −0.60 |
| Apr 2022 → Feb 2023 | −15% | −0.80 | 1.90 |
| Feb 2023 → Dec 2023 | +31% | 0.88 | −1.73 |
| Stitched, 95% CI | 0.12 [−0.8, 1.3] | 0.44 [−0.4, 1.2] |
Momentum's "failed" was the one segment where the market fell; Bollinger's
"held up" was that same segment carrying the one after it. Momentum's winning
parameters also changed at every re-fit, so it was never one strategy. Both
read regime_dependent, which is the honest verdict for either.
Label the same out-of-sample bars by the market's realised volatility and the dependence is explicit. Sharpe on each third of the period:
| Regime (21-day vol) | Market | Momentum | MACD | Bollinger |
|---|---|---|---|---|
| Low (≤ 22%) | 2.49 | 2.26 | 0.66 | −1.83 |
| Mid | 0.70 | 0.70 | 2.61 | 0.80 |
| High (> 32%) | 0.60 | −0.76 | −1.76 | 1.31 |
Momentum's whole out-of-sample return came from calm markets, where it was mostly the market's own return; in turbulent stretches it lost while the market did not. Bollinger is the mirror image. The strategies are not "good" or "bad" — they are conditional on a regime nobody chose.
This is one ticker over one period, so it demonstrates the method rather than
proving mean reversion beats trend following. The /demo page leads with a
losing strategy for the same reason.
learning/ — how to understand this project from price data up to the validation papers, with each concept mapped to the file that uses it. Start at learning/README.md.planning/ — current status, the milestone in progress, backlog, and the decision log. Start at planning/STATUS.md; the full history is planning/PROGRESS.md.| Layer | Tech |
|---|---|
| Backend | Python · FastAPI · pandas · numpy |
| Data | yfinance (OHLCV, auto-cached) |
| Frontend | React 18 · Vite · Tailwind CSS · Recharts |
| Auth | Firebase Authentication (email/password) |
| Database | Cloud Firestore |
| Hosting | Vercel (frontend, same-origin /api/* proxy) · Cloud Run asia-south1 (backend) |
.env.FIREBASE_SERVICE_ACCOUNT_JSON.After registering your account through the app UI:
users collection → find your document (doc ID = your UID).role field → change from "user" to "admin".cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../.env.example .env # fill in FIREBASE_SERVICE_ACCOUNT_JSON + ALLOWED_ORIGINS
uvicorn main:app --reload --port 8000
API will be live at http://localhost:8000. Swagger docs at http://localhost:8000/docs.
cd frontend
npm install
cp ../.env.example .env # fill in VITE_FIREBASE_* vars; VITE_API_BASE_URL=http://localhost:8000
npm run dev
App will be live at http://localhost:5174. The port is pinned in vite.config.js so it always matches ALLOWED_ORIGINS on the backend — otherwise requests fail CORS with no obvious cause.
This is how the live site is shipped. Anything else — Firebase Hosting, a different container host — works too, but is not what runs.
Every push to main deploys https://finertia.hulage.in. The Vercel project has
Root Directory = frontend; frontend/vercel.json
carries the SPA fallback and rewrites /api/* to the Cloud Run service, so
the browser never calls *.run.app directly (ad blockers block it, and every
cross-origin call needed a preflight).
Set the VITE_* values from .env.example as Vercel environment variables.
VITE_API_BASE_URL is the site's own origin (https://finertia.hulage.in),
not the Cloud Run URL — that is what makes the proxy do its job. Pull requests
get a preview URL; the preview still proxies to production Cloud Run.
The backend does not auto-deploy. After merging a backend change, run the Deploy workflow so a change never silently turns on a meter:
gh workflow run deploy.yml --ref main
It runs the tests, deploys with the command below, waits for /api/health to
return 200, then moves the backend-deployed tag. It authenticates as
finertia-deployer through Workload Identity Federation — no key exists
anywhere — and the provider only accepts tokens from this repo on
refs/heads/main. The one-time GCP + secrets setup is
.github/scripts/setup-wif.sh (run 28 Sep 2026;
first workflow deploy was rev 00011-cwn).
To deploy by hand instead (the fallback if Actions is down):
cd backend
gcloud run deploy finertia-api \
--source . \
--region asia-south1 \
--project momentbacktracking \
--min-instances 0 --max-instances 2 --memory 512Mi --cpu 1 --concurrency 40 \
--service-account [email protected] \
--set-secrets FIREBASE_SERVICE_ACCOUNT_JSON=finertia-sa:latest \
--update-env-vars SENTRY_DSN=https://3e79d3553ba15e22d80c2b964eb2bc47@o4512247279976448.ingest.us.sentry.io/4512247309402112,GIT_SHA=$(git rev-parse HEAD) \
--allow-unauthenticated
Deploy from a clean checkout of the commit you mean: GIT_SHA is what
/api/health reports as commit, and the release check compares it with the
backend-deployed tag.
The service-account JSON lives in Secret Manager, never in an env var or the
repo. The service runs as finertia-api-runtime, a service account with no
project roles at all — its only grant is secretmanager.secretAccessor on
finertia-sa (the Firebase Admin SDK authenticates with the mounted key, not
with the runtime identity). Keep --service-account in the command so a
fresh deploy of the service never lands on the default compute account, which
can no longer read the secret; on an existing service the flag, like
ALLOWED_ORIGINS=https://finertia.hulage.in and LOG_LEVEL, persists across
deploys. backend/.gcloudignore keeps .env
and tests/ out of the source upload. The caps (min-instances 0, max 2,
512Mi) are the cost ceiling — idle costs nothing; do not raise them casually.
Afterwards, check the new revision is serving (commit should be the SHA you
deployed), or run the full post-deploy check from RELEASE.md:
curl -s https://finertia.hulage.in/api/health
Then mark what production is serving — the backend-deployed tag is how
backend-drift.yml knows. It comments on every merged PR that leaves
backend/ ahead of the tag, and keeps doing so until the tag moves
(the Deploy workflow moves it on its own):
git tag -f backend-deployed <the sha you deployed> && git push -f origin backend-deployed
If the Cloud Run URL ever changes (it does not on redeploys, only on
service recreation), update the rewrite destination in frontend/vercel.json.
Finertia/
├── backend/
│ ├── main.py # FastAPI app + all routes
│ ├── schemas.py # request models + validation (no Firebase dependency)
│ ├── data.py # yfinance behind two cache tiers
│ ├── signals.py # momentum, MACD, and Bollinger signal generation
│ ├── strategies.py # strategy registry — dispatch, warm-up, param grids
│ ├── engine.py # position logic + equity curve
│ ├── metrics.py # all performance metrics
│ ├── analytics.py # monthly, annual, and rolling views
│ ├── validation.py # walk-forward + permutation test
│ ├── deflated.py # Deflated Sharpe Ratio
│ ├── trials.py # effective number of trials (eigen + clusters)
│ ├── pbo.py # Probability of Backtest Overfitting (CSCV)
│ ├── purge.py # purge + embargo at the split
│ ├── bootstrap.py # stationary block bootstrap, BCa intervals
│ ├── rolling.py # anchored rolling walk-forward, one verdict per fold
│ ├── regimes.py # realised-vol terciles × trend label, Sharpe per regime and per cell
│ ├── sharpe_test.py # Ledoit-Wolf paired Sharpe-difference test vs buy-and-hold
│ ├── price_store.py # Firestore / in-memory price cache tiers
│ ├── risk.py # stop-loss / take-profit + volatility sizing
│ ├── portfolio.py # alignment, weighting, aggregation, attribution
│ ├── plans.py # subscription tiers + monthly quota accounting
│ ├── billing.py # Stripe checkout + webhook signature verification
│ ├── ratelimit.py # sliding-window rate limiter
│ ├── logging_config.py # structured JSON logging
│ ├── firebase_admin_init.py # Firebase Admin SDK init + auth helpers
│ ├── tests/ # pytest suite (no credentials required)
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── pages/ # LandingPage, Login, Register, Dashboard, History, Profile, Admin/*
│ │ ├── components/ # ConfigPanel, MetricsGrid, charts, ValidationPanel, Toast
│ │ ├── context/ # AuthContext, ToastContext
│ │ ├── hooks/ # useAuth, useToast
│ │ ├── utils/ # csv export, permalink encode/decode
│ │ ├── api.js # typed API client (auto-injects Firebase token)
│ │ └── firebase.js # Firebase client SDK init
│ ├── index.html
│ ├── vite.config.js
│ └── package.json
├── firestore.rules
├── firebase.json
├── .env.example
└── README.md
Every generator ends with .shift(1) — a signal derived from a bar's close can only be acted on at the next bar. Doing that shift in one place is what keeps lookahead bias out of the whole engine. Transaction costs are deducted in proportion to turnover — see Risk overlays for why.
| Condition | Signal |
|---|---|
momentum > threshold AND close > MA | +1 Long |
momentum < -threshold AND close < MA | −1 Short |
| Otherwise | 0 Flat |
Holds the crossover state rather than trading only the crossing bar, so the position series stays continuous.
| Condition | Signal |
|---|---|
| MACD line above signal line | +1 Long |
| MACD line below signal line | −1 Short |
| Within the warm-up period | 0 Flat |
The opposite stance to the other two — it fades extremes instead of following them, and is flat most of the time.
| Condition | Signal |
|---|---|
close < lower band | +1 Long |
close > upper band | −1 Short |
| Inside the envelope | 0 Flat |
backend/risk.py sits between signals and the engine: signals decide direction, the overlays decide whether to stay in and how large. Every strategy gets both for free.
Stop-loss / take-profit. Genuinely path-dependent — whether you are still in a trade on Tuesday depends on Monday — so this is an explicit loop rather than a vectorised expression. Two properties worth knowing:
Volatility targeting. Sizes each position at target_vol / trailing_realised_vol, capped by max_leverage. The trailing window is shifted one bar, exactly like the signals — sizing today may only use volatility measured to yesterday's close. Warm-up bars and flat price runs size to zero rather than to infinity.
Because sizing produces fractional positions, transaction cost is charged on turnover (|Δposition| × cost) rather than as a flat fee per change. For plain 0 → ±1 signals this is identical; for a −1 → +1 flip it correctly charges two trades' worth, and it stops a 2% size adjustment being billed as a full round trip.
Write a generator in signals.py, then add one entry to STRATEGIES in strategies.py with its build function, warm-up calculation, and walk-forward grid. Routes, engine, and validation all dispatch through build_positions / longest_window / param_grid, so nothing else changes.
All computed from scratch — no TA-Lib, no quantstats:
Plus time-sliced views: monthly returns heatmap, annual returns against buy-and-hold, and rolling 60-day Sharpe. Equity curve, trades, and metrics all export to CSV from the dashboard.
Switch the config panel's Universe to Portfolio, add 2–10 holdings, and POST /api/portfolio runs the selected strategy on each name and combines the legs. Each leg is a complete backtest — its own signals, stops, sizing, and transaction costs — so the portfolio aggregates honest single results rather than being a separate model with its own assumptions.
Alignment is an inner join. Only days every holding traded are used. The alternatives are worse: forward-filling a missing bar invents a flat return on a day the asset didn't trade, which lowers measured volatility and flatters every risk metric; a union with gaps lets a leg contribute NaN to a weighted sum, and a NaN portfolio return silently becomes zero.
The cost is real — one holding that listed late truncates the whole portfolio — so the response reports dropped_bars and the limiting_ticker, and the UI names both. That's measured against the longest-history leg, not against the requested start date; comparing to the request would fire the warning whenever a start date landed on a weekend or a holiday, and a warning that shows on nearly every run is one nobody reads.
| Weighting | Behaviour |
|---|---|
equal | 1/N in each name, rebalanced every bar back to target |
inverse_vol | Weight ∝ 1 / trailing volatility, so no single volatile name dominates the portfolio's risk |
Inverse-vol weights shift the trailing window one bar, like everything else in the engine. Warm-up bars fall back to equal weight rather than to zero exposure, which would silently shorten the period being measured. max_weight caps concentration by pushing freed weight out to the holdings that still have room and repeating — clipping and renormalising would scale the capped asset straight back up and achieve nothing.
Reported alongside the usual metrics: each leg's average weight, its contribution (arithmetic, so the column sums to the total), its standalone return, and a diversification ratio — weighted average leg volatility over portfolio volatility. 1.0 means the holdings move as one and you bought no diversification; it reports as undefined rather than 0.0 when the portfolio has no volatility, since perfectly offsetting legs are maximally diversified, not minimally.
POST /api/portfolio/validate (portfolio_validation.py) asks the same questions of the whole book. Two things had to be decided, not derived:
legs block — the winner's in- and out-of-sample Sharpe per ticker — because a book that held up may have done so on one name.A one-leg book with weight 1 reproduces /api/validate key for key, so a verdict means the same thing on either universe. Legs are built from the raw signal without the stop and sizing overlays, as /api/validate does. Same Pro gate and rate budget as /api/validate, plus the plan's basket-size cap. The dashboard's Validation tab calls it in portfolio mode; the verdict card is the same one, and the walk-forward and timing sections each gain a per-name table.
On AAPL+MSFT+GOOGL 2018→2024, equal weight: momentum fails walk-forward (0.42 → −0.51), no leg beats random timing but AAPL; Bollinger holds up (0.83 → 0.77), all three legs beat random timing (book p = 0.002) — and the best grid cell still trails holding the basket by 18.7 % a year, SPA p = 1.0. The book was timed; it was not worth timing.
Tick two to four runs in History and hit Compare. POST /api/compare reloads each run's config and recomputes its equity curve — curves are never stored, since they are a few thousand floats derivable from the config. Documents stay small, and an engine fix retroactively corrects every historical chart.
Runs may cover different tickers and periods, so the merged series is keyed on date and a run with no bar for a date is simply absent rather than zero. The response reports overlapping_days, and the UI says so plainly when two runs never overlap — otherwise the chart would imply a comparison that isn't there.
The dashboard mirrors the active configuration into the URL query string, and Share config copies a link that reopens it. Only the selected strategy's parameters are written, so a link stays readable rather than carrying all fourteen fields. Unknown keys and unparseable numbers are dropped on read — a truncated link still opens the app with defaults filled in.
Prices come from yfinance behind two cache tiers: a per-process dict, and one
Firestore document per (ticker, year) in the prices collection so a cold
Cloud Run instance never has to call Yahoo for a range it has seen. Every
cached year of a ticker comes from the same download — yfinance returns
adjusted prices as of the fetch date, so stitching years from different
downloads would put a phantom jump (or a 4x split) at the boundary. A year
still in progress is refreshed after six hours; past years never expire.
If Yahoo is down and the cache holds the range, the run is served from cache
and the response carries "data_source": "cache-stale". If nothing is cached,
the API returns 503 rather than pretending the symbol does not exist.
Warm it for the suggested tickers:
cd backend && .venv/bin/python scripts/prewarm_prices.py
Set PRICE_CACHE=off to run without the Firestore tier (local dev without a
service account already falls back automatically).
The source itself is pluggable (backend/price_providers.py). PRICE_PROVIDER
picks it; yfinance is the default and, today, the only one. Yahoo's data is
not licensed for commercial use, which is why the seam exists (DECISIONS,
13 Oct). Each cached doc records its provider. After a switch, every ticker
is fetched again on first use rather than mixed with the old vendor's bars,
and an unknown provider name stops the app instead of quietly falling back
to Yahoo.
Two layers, both sliding-window and both hand-written (backend/ratelimit.py) so they are unit-testable without a running server:
| Limit | Default | Keyed on | Why |
|---|---|---|---|
RATE_LIMIT_IP_PER_MIN | 120 | client IP | Stops an unauthenticated flood before token verification, which is itself a network call |
RATE_LIMIT_BACKTEST_PER_MIN | 20 | user id | A backtest fetches market data |
RATE_LIMIT_VALIDATE_PER_MIN | 5 | user id | A validation run sweeps a parameter grid and then runs 500 permutations |
Compute limits are per account, not per address — otherwise an office behind one NAT would share a single budget. A rejected request is not recorded against the window, so a client that keeps retrying does not lock itself out indefinitely.
The two per-user limits hold across instances: their counts live in Firestore (ratelimits collection), so a second Cloud Run instance does not double a user's budget. Each check is one round trip — the database (nam5) is far from the API (asia-south1), so there is no transaction: one atomic batch increments this minute's counter by 1 and the previous minute's by 0, and Firestore returns both values. The estimate is the sliding-window counter: the previous minute weighted by how much of it is still inside the window, plus this minute. If Firestore fails the check falls back to the in-process limiter rather than failing the request. Without credentials or the emulator (dev, unit tests) they are in-process; RATE_LIMIT_STORE=memory forces that.
The per-IP limit stays in-process — it runs on every request, and a round trip on each page load is not worth it for a flood guard, so its ceiling is N × limit. None of these is a billing quota; the monthly run quota lives on the user's profile.
Every line is JSON on stdout, using the severity and message keys Cloud Logging parses (backend/logging_config.py). Each request gets an id, echoed as X-Request-Id and included in the body of any 500. A user reporting a failure can quote that id and it leads straight to the traceback:
{"severity": "INFO", "message": "request", "logger": "finertia", "request_id": "d011d05ae7c3", "method": "POST", "path": "/api/backtest", "status": 200, "duration_ms": 412}
Unhandled exceptions return a generic message rather than a stack trace, with the request id attached.
Optional. With SENTRY_DSN set, unhandled exceptions are reported; without it the SDK is never imported and nothing changes. send_default_pii is off deliberately — request bodies here contain users' strategy parameters, which do not belong in a third-party error tracker. The startup log line reports whether it is enabled.
backend/plans.py holds the tiers and quota rules, free of any Stripe or Firebase import so entitlements are testable without credentials or a network.
| Free | Pro | |
|---|---|---|
| Backtests / month | 50 | Unlimited |
| Walk-forward + permutation tests | FREE_VALIDATIONS_PER_MONTH (default 0) | ✓ |
| Portfolio holdings | 3 | 10 |
Quotas count per calendar month in UTC — not local time, or a user crossing a date line could reset their own. A counter stamped with a previous month reads as zero rather than being reset by a write, so nothing has to run at midnight on the first. Exceeding a quota returns 402, distinct from the rate limiter's 429, so the client can tell "slow down" apart from "upgrade".
The free plan's validations are a deployment setting, FREE_VALIDATIONS_PER_MONTH, so they can stay at 0 (the Pro wall) while a tester round runs and be switched on with an env var afterwards. A validation costs hundreds of backtests, so the slot is taken in a Firestore transaction before the work starts (backend/validation_allowance.py): of several requests racing for the last one, only one gets it. It is handed back if the run produces no result, a bad ticker or a crash, so a failure does not cost the user their month's validation. The counters (validationsThisPeriod, validationPeriod, totalValidations) are server-owned and pinned by the Firestore rules. /api/me/usage reports validations_used, validations_limit and validations_remaining; a free validation's response carries free_validations_remaining.
Hidden switches. Work finished ahead of the tester round's end ships behind VITE_FEATURES, a comma-separated build-time list in frontend/src/lib/features.js: free-validation (the counter in the Validation tab and the usage meter), onboarding (examples in the workspace, History empty states) account-data (Profile → download my data, delete my account, and the /account-deleted page) and share-cards (the copy-link button copies /api/share?…, a preview page whose card shows the run's figures; the API serves it only with SHARE_CARDS=1). Unset on Vercel production, so the live product does not change. They hide UI only; the API enforces anything that matters. The E2E suite runs a second app and API pair with every switch on (ports 5176 and 8002, e2e/tests/switched-on/), so the hidden screens are tested end to end before they are shown.
Billing is entirely optional. With no STRIPE_SECRET_KEY the app runs unchanged: the pricing page renders, the upgrade button says billing is not enabled, and every account stays free.
The webhook is necessarily unauthenticated — Stripe cannot present a Firebase token — so the signature check is the authentication. Without it, anyone who learned the URL could POST a completed-checkout event and upgrade themselves. It is implemented in billing.py rather than taken from the SDK so the constant-time comparison and the 5-minute replay window are visible and testable. Events that are valid but unrecognised return 200; a non-2xx would make Stripe retry them forever.
Registration sends a Firebase verification email, and signed-in users with an unconfirmed address see a dismissible banner on the app pages with a resend button on a one-minute cooldown.
It is deliberately a nudge and not a gate. No API route requires a verified address, so blocking the dashboard would invent a restriction the backend does not enforce. What verification actually protects is the password-reset path, which is useless if the address is wrong.
A failed send does not fail registration — the account and its profile document already exist by that point, and reporting an error there would read as "your account was not created". The banner is where it surfaces instead, since it appears precisely when the address is unverified.
/support is public and lists the errors the API actually returns alongside what causes each one — a bad ticker suffix, a date range shorter than the indicator warm-up, a 402 quota vs a 429 burst limit. Quoted messages use N rather than real numbers so the page cannot drift out of step with plans.py; the pricing page reads the live allowance from /api/plans.
The contact address is [email protected] (frontend/src/lib/contact.js), which Cloudflare Email Routing forwards to the author, so no personal inbox is published. VITE_SUPPORT_EMAIL overrides it for a self-hosted deployment; leave it unset on Vercel.
GET /api/me/export returns everything stored about the caller (profile and
every saved run) as a JSON download. DELETE /api/me with
{"confirm": "<the account's email>"} deletes, in order, the user's runs,
their rate-limit counters, the profile, and then the Firebase Auth account.
The account goes last, so a failure part way leaves a user who can still sign
in and retry. Admins cannot delete themselves, and neither can an account with
a live Stripe subscription (409 for both). The Profile buttons come in M12
sprint 2. backend/account.py explains what is kept and why: request logs
and Sentry events carry the uid only, and age out.
The API is not the only door into the database. A signed-in browser holds a real Firebase token and can talk to Firestore directly, so any check that exists only in FastAPI is advisory — the rules are what actually enforce it.
The client's sole legitimate access is its own profile document: it reads that one doc, creates it at registration, and may change displayName. Nothing else. role, plan, isActive, totalRuns, and the quota counters are server-owned, and the runs collection is closed to clients entirely — runs are created and read through the API.
Locking this down costs the backend nothing: the Firebase Admin SDK bypasses security rules, so every server-side write still works, and admin tooling needs no client-side grant.
Fields are also pinned at creation. Registration is a browser write, so without constraints the very first document is a free hand — a new account could name itself an admin or arrive already on the Pro plan.
cd firestore-tests && npm install && npm test
27 tests against the Firestore emulator, covering both directions: that registration, profile reads, and display-name edits still work, and that privilege escalation, cross-account access, run forgery, and tampering with rate-limit counters do not. emulators:exec starts and stops the emulator itself, so the only prerequisite is Java.
Both halves report unhandled errors to Sentry: the API (backend/monitoring.py, project python-fastapi) and the browser (frontend/src/lib/monitoring.js, project finertia-web). Errors only — no tracing, profiling or session replay — and both are off in dev, in tests, and against the Firebase emulators.
What leaves is deliberately small, and tests assert it against the real SDKs:
X-Request-Id the user sees), the Cloud Run revision as the release, and the Firebase uid. No request body, no Authorization header, no IP, no email — send_default_pii=False and a before_send that keeps four harmless headers. 4xx answers (402, 429, 403) are the product working and are not reported.An admin can prove the pipe end to end with POST /api/admin/sentry-test, which raises on purpose and returns a 500 with a request id.
firestore.indexes.json is the source of truth for indexes (firebase deploy --only firestore:indexes). It holds the composite indexes the run listings need — runs by uid, by ticker, and by both, each newest first — and turns indexing off for fields nothing queries: the price arrays (dates, open … volume; about 1,500 index entries per cached year otherwise) and the rate-limit counters.
The admin panel's reads are in backend/admin_queries.py and are bounded: counts and averages are Firestore aggregation queries (count(), avg()) rather than streaming every user and run, lists page in the database, user search is an email prefix, and "top tickers" covers the last 500 runs. One trap pinned by a test: in a single aggregation request Firestore only considers documents that have every averaged field, so count() runs on its own or runs without metrics vanish from the total. tests/test_admin_queries_firestore.py runs these against the emulator in CI.
.github/workflows/ci.yml runs on every push and PR: backend tests, frontend unit tests and build, the end-to-end suite, and a secret scan that fails if a .env or service-account key is ever tracked. None of them need credentials — a pipeline that requires secrets is one that silently stops running.
deploy.yml is backend-only (Vercel deploys the frontend on its own) and manual (workflow_dispatch) rather than push-triggered, so shipping is always a decision. It runs the same gcloud run deploy as the Deploy section, flag for flag, after the tests pass. It authenticates to GCP by Workload Identity Federation rather than a long-lived key in a repo secret, and polls /api/health afterwards (a deploy that "succeeded" but serves 500s is not a successful deploy). The WIF secrets have been set since 28 Sep 2026 (.github/scripts/setup-wif.sh); the workflow is the normal way to deploy.
cd backend
pytest tests/ -q
840 tests (plus 22 that need the Firestore emulator, which CI runs) covering signals, engine, metrics, analytics, strategies, validation, the research layer (deflated Sharpe, PBO, purge and embargo, block-bootstrap intervals, effective trials, rolling walk-forward, volatility regimes, whole-grid inference, basket validation, the Sharpe-difference test), the Firestore price cache, request schemas, risk overlays, portfolio construction, plan entitlements, Stripe webhook verification, the rate limiter, the log formatter, and the HTTP layer. No Firebase credentials needed, so they run in CI unmodified.
test_routes.py covers the part that decides who may call everything else: token handling, suspended accounts, admin gating, quota and entitlement enforcement, rate limiting, and how failures become status codes. verify_token, the profile lookup, the Firestore client, and the price fetch are all replaced, so no network or credentials are involved.
Two distinctions it pins down, because getting either wrong puts the wrong message in front of a user: 402 means upgrade and 429 means wait, and role is read from the Firestore profile, never from the token — a token claiming role: admin is refused.
The suite includes an explicit lookahead-bias guard: a position must not be open on a price-spike bar.
cd frontend
npm test
140 Vitest + Testing Library tests in jsdom, about 5 s. They cover the logic that lives in the browser: how api.js turns FastAPI errors (including 422 arrays) into readable messages and carries status, request id and Retry-After; permalink encode/decode; CSV escaping; the write-up's markdown renderer, including that it never injects HTML; the validation verdict card; the usage meter; the config panel's blocking rules; and DashboardPage's run snapshot — Validation checks the run on screen, not the edited panel (tester bugs #6/#7), and a 402 points to pricing rather than "Try again". The API, the toast hook and the Recharts charts are mocked; demoData.json stands in for a backtest response. CI runs them in the "Frontend build" job, before the build.
cd e2e
npm install && npx playwright install chromium
npm test
26 Playwright tests drive the real app in Chromium against the real API and the real Firestore security rules, with Firebase's Auth and Firestore emulators standing in for the project. npm test starts all three (emulators, API on :8001, app on :5175), and backend/scripts/seed_e2e.py seeds two fixture accounts (e2e/fixtures/users.json) and deterministic synthetic prices so no run ever reaches Yahoo. Needs Java for the emulators.
They cover the logged-in paths the unit tests cannot: register with the unverified-email banner, a backtest, Validation on the run on screen with the Pro wall for a free user, the too-short-range error, History re-run, Profile save, the admin Pro grant and what it unlocks, and that a member cannot open the admin panel; plus the public pages, /demo's verdict, and phone-width overflow. CI runs them on every PR (the "E2E (Playwright)" job). Emulator mode is refused on Cloud Run (firebase_admin_init.emulator_mode).