Which environment you are running in
Read this before anything else on the page. Two behaviours depend on the environment and neither one announces itself.
dotnet run falls back to Production when a project has no launch profile
Each project's Properties/launchSettings.json picks the environment. With
no such file, .NET chooses Production — and then:
- User secrets load in Development only for the API and Dashboard
hosts. Under Production they are simply absent: a setting you know you stored is
missing, with no error. (The bot host calls
AddUserSecretsexplicitly, so it reads them in any environment.) - The Dashboard refuses to start in Production without
DataProtection:KeysDirectory.
The Dashboard and API ship a launch profile setting
ASPNETCORE_ENVIRONMENT=Development, so a local run behaves.
On a server, set every value through environment variables and do not
rely on the secret store.
Secrets
production: environment variables onlyNone of these has a safe default on someone else's infrastructure.
Enables the ops surface (/api/v1/admin and the legacy api/*
routes). Unset, every ops endpoint answers 503. Compared in constant time.
Live market data. Unset, the engine falls back to the bundled CSV provider — which is for development only, but does mean the engine runs and can be measured with no account anywhere.
Telegram publishing and review.
SQLite path. Docker default Data Source=/data/quant.db on a shared volume.
Unset, billing is disabled everywhere and the dashboard falls back to manual upgrades — and says so to the user rather than failing silently.
Signing secret for POST /api/v1/billing/webhook. The endpoint answers
503 until it is set.
Stripe price ids per plan code. Only mapped plans are purchasable.
SMTP delivery, any provider. With the host unset, emails become log lines — which means the password-reset flow silently does nothing.
Billing lifecycle
Stripe-hosted throughout. Card data never touches the platform.
Stripe-hosted payment page
checkout.session.completed — activates the paid plan, superseding the free-tier row (one live subscription per user)
customer.subscription.updated — mirrors status and period end, covering past_due
customer.subscription.deleted — cancels and drops the account back to the free tier
Every transition is audit-logged with actor billing.
Webhook configuration in Stripe: endpoint
https://<api-host>/api/v1/billing/webhook, events: the three above.
Scaling
all hostsSqlite for a single node, or Postgres via Npgsql. Postgres
needs its own migration set.
Set false on multi-node deployments and run the Migrator once first, so
nodes don't race Migrate().
When set, the API quota and the login lockout use Redis — shared across nodes — instead of in-process counters. Required for horizontal scaling.
Scheduled verified backups in the bot host. Turn off only where an external process snapshots the volume.
Relative paths resolve next to the database. Set an absolute path on a separate volume in production — a same-disk copy dies with the disk.
Copies kept. Older ones are pruned after each run, ordered by the UTC timestamp in the filename.
Gap between automatic runs. One always runs at host startup.
The Migrator is a one-shot console (Dockerfile.migrator)
that applies migrations and seeds plans regardless of the flag — the pre-deploy schema
step for a cluster.
Backups are SQLite-specific (VACUUM INTO) and are not registered when the
provider is Postgres; that deployment backs up with the server's own tooling.
Dashboard host
QuantMarketEngine.DashboardThe only way anyone becomes an operator. A string array of email addresses; a signed-in user whose address is on it reaches the admin pages, and nobody else does.
Empty means nobody. AdminAccess fails closed, and the admin page says it
is unconfigured rather than pretending the account lacks permission.
It is configuration and not a database row on purpose: an attacker who reaches the database through the application still cannot grant themselves the operator pages. It ships empty, and the buyer sets their own address.
Must point at the same database as the API and the bot — /data/quant.db
under Docker. All three hosts ship pointing at the bot's file:
../QuantMarketEngine.TelegramBot/quant.db from the API and the Dashboard,
quant.db from the bot itself.
Getting this wrong does not error. The host attaches to a different file, and the interface looks empty while working perfectly.
The host refuses to start without it outside Development. It
persists cookie-encryption keys across restarts — /data/dp-keys under Docker.
Unset, ASP.NET Core generates a key ring per process, so every restart signs out every subscriber with nothing in the logs to say why.
Shown on the Billing and Support pages. Set the real address before launch.
Full path to Chrome, Edge or Chromium. Leave empty to search the usual install locations.
false switches PDF export off even where a browser is installed.
Starts the browser with its sandbox disabled. Needed where a container runs as root without the kernel namespaces Chrome requires — there it will not start at all.
Off by default because the flag makes the feature work everywhere and weakens it everywhere, and that is the host's call rather than ours. The log names this setting when a failed print looks like a sandbox refusal.
Ceiling on one print before the browser is killed. A real print takes about a second.
PDF report export. The Reports page offers each archived session as a styled PDF — tables plus the probability-cone chart — printed by an installed Chromium-family browser in headless mode. Nothing installs that browser for you: on a host without one, the PDF link hides itself, the endpoint answers 503, and the Arabic, English and JSON exports are unaffected. The Docker images do not include a browser, so PDF export is off in a default container until one is installed.
The PDF is English only. Arabic in a PDF needs bidirectional layout,
and the rasteriser this project uses reverses digits inside a mixed Arabic/Latin run — it
printed 1.16805 as 50861.1. The Arabic report is still delivered
as text.
Auth model. Email and password
(UserCredential, PBKDF2) exchanged for a 7-day sliding HttpOnly session
cookie. Self-serve registration provisions the free observer plan. A per-IP
failed-login damper allows 8 failures in 10 minutes. API keys remain the machine
credential; the dashboard mints and revokes them, and the raw key is rendered exactly once
from the POST response.
Analysis layers
all hostsFour layers of the pipeline are configurable, and two of them ship
switched off. That is a decision rather than an omission, and each says why.
CrossAsset and EconomicIndicators appear in no
appsettings.json at all — they run on the code defaults below, which is
worth knowing before you go looking for a section that is not there.
CrossAsset
On by defaultWhat each analysed symbol is correlated against — the layer behind the correlation bars in the report.
CrossAsset:Enabled · default true. false skips
the layer entirely.
CrossAsset:Map · default [], meaning the built-in EUR/USD
institutional set: DXY, gold, the US 10-year yield. Entries are
{ BaseSymbol, Symbol, Label }.
An instrument the data provider cannot serve is skipped at run time rather than failing the report, so listing an index or a yield here is safe even on a forex-only free tier.
EconomicIndicators
On by defaultThe US macro backdrop: inflation, policy rate, labour.
Enabled true · Timeframe MONTHLY ·
FreshnessDays 45 · AttemptIntervalDays
1 · Indicators [] for the built-in dollar-macro set.
AttemptIntervalDays is the setting that governs cost, not
FreshnessDays. US macro publishes on a one-to-two-month lag, so the newest
print available is routinely older than any sane freshness gate and the data can never
make itself look current. Before this existed, four indicators were re-fetched on every
single run and returned the same figures each time.
Cost: at most one provider call per indicator per day — a few calls a month.
EconomicCalendar
Off in code · on as shippedA hand-maintained list of upcoming high-impact releases, used to warn that a reading sits inside a blackout window. The code default is off because it is a list somebody has to keep writing: it has no feed behind it, and a stale calendar is worse than none because it warns about the wrong days.
The bot's appsettings.json turns it on and carries 14
central-bank and US releases through 17 December 2026. When that list runs out
the layer goes quiet rather than wrong — but it is a maintenance task with a date on it,
and the buyer should be told which date.
BlackoutWindowHours 24 ·
MinimumImpact High · Events entries are
{ Name, ScheduledAt (UTC), Impact, Currencies[] }, where empty currencies
means global.
FundamentalAnalysis
Off, and fenced even when onEnabled false · DataFilePath empty. Point the
path at a hand-filled JSON file of releases, rates and stances to feed the layer with
test data.
Turning it on does not put hand-entered figures in front of a client.
The provider reports IsLiveSource=false and the output mapper drops the
values. The layer exists to be developed against, and the fence is in code so that a
future contributor cannot enable it into production by flipping a flag.
Bot host
QuantMarketEngine.TelegramBotFrom @BotFather. Empty and the bot stays idle — no
receiver, no scheduler. The rest of the host still runs.
Private chat id of the human reviewer. This is the approval gate's inbox.
Channel id or @username where approved reports are published.
Symbols the scheduled worker analyses each cycle. Intentionally empty in
code: the .NET binder appends to a non-empty default array, so a
default of ["EURUSD"] plus the same value in appsettings binds to
["EURUSD","EURUSD"] and generates every report twice. The value comes from
configuration only.
Timeframe the scheduled worker requests.
How often the scheduled worker runs.
ReviewerChatId and PublicChannelId currently sit in the bot's
appsettings.json for convenience. They are deploy-specific: move them to
environment variables anywhere shared.
Background workers
bot hostBoth loops have safe defaults and run without any configuration.
The event-driven watcher. It runs more often than the scheduled cycle but only queues a report when it detects a structural change or a regime flip.
The bot's appsettings.json switches it off. This is the
"event-driven trigger ships disabled" line in the sale material — and it is
a configuration value, not missing code.
Minutes between detection passes. Clamped to a minimum of 1.
Lets startup migrations and the data load settle first.
The self-scoring loop — the background VaR backtest that produces the calibration record. Turning it off stops the product's central claim from being earned.
Clamped to a minimum of 1.
Lets startup migrations and the data load settle first.
Maximum analyses scored per pass.
Static site
Renders approved reports to flat HTML for GitHub, Cloudflare or Netlify Pages.
Empty disables web publishing. Set it and approved reports are written there.
Used on the Arabic pages, where it carries the Arabic tagline.
Kept separately rather than derived from the above: printing the Arabic tagline on an English page was the last thing left untranslated when the site went bilingual.
The template is educational content only — it carries the neutral disclaimer and never a subscription or price call-to-action.
API host
QuantMarketEngine.APIFixed-window per-IP perimeter limit; 429 beyond it. Health endpoints are exempt.
CORS origins. Empty means no CORS at all.
TTL of the in-process analysis cache, per symbol and timeframe. 0
disables it.
Standard Serilog configuration section.
The symbol catalogue. Shared by every host, so a symbol added here is a symbol the whole platform knows about.
Security model
Three tiers, checked at the endpoint rather than assumed from the route.
/api/v1/plans · /api/symbols ·
/health/live · /health/ready · /openapi/v1.json
X-Api-Key scheme —
/api/v1/intelligence/* and /api/v1/alerts (quota-metered),
/api/v1/account (quota-free)
X-Admin-Key scheme — /api/v1/admin/* and
all legacy routes: api/analysis, api/history,
api/reports/*, api/review, api/calibration.
These are internal ops tools; the customer surface is /api/v1
only, and the legacy routes are not part of the public contract.
Every privileged action writes an AuditLogEntries row. Every quota-bearing
subscriber call writes or increments an ApiUsageRecords row — the source of
truth for both billing and the dashboard.
Observability and operations
Process liveness, with no dependencies.
Includes a database round-trip.
Logs are structured via Serilog and every request carries one, honouring an inbound
X-Correlation-Id.
Migrations apply automatically at API startup, on a single-instance assumption — revisit before scaling out.
SQLite backups snapshot the mounted /data volume, or use
VACUUM INTO, on a schedule. A managed PostgreSQL migration is the path beyond
one node.
CI builds, tests, and fails on any known-vulnerable NuGet package.