The four hosts
The bot runs on its own. The dashboard and the API are what a customer touches.
The engine. Generates reports, scores past readings, publishes to Telegram and to the static site. Also the one-off command runner.
The Blazor web app: landing page, pricing, docs, sign-up, and the subscriber area — reports, API keys, alerts, billing.
The public REST API that subscribers call with an X-Api-Key.
Applies migrations and seeds the plan catalogue, then exits. For deployments running more than one node.
Prerequisites
.NET 10 SDK. Nothing else — the database is SQLite, created on first run.
Two credentials are yours to supply, and both are deliberately empty in
appsettings.json:
The market-data provider. Without a key the engine falls back to the bundled CSV provider, so it runs and can be measured offline.
Only if you want the Telegram channel. Everything else works without it.
Keep them out of the repository. In development:
# from QuantMarketEngine/src/QuantMarketEngine.TelegramBot dotnet user-secrets set "AlphaVantage:ApiKey" "YOUR_KEY" dotnet user-secrets set "Telegram:BotToken" "YOUR_TOKEN"
In production, environment variables: AlphaVantage__ApiKey,
Telegram__BotToken.
What you have to sign up for, and what it costs
Two external accounts. Both have a free tier this engine fits inside, and you can evaluate the whole platform without either.
Market data — required for live prices
AlphaVantage by default. Free registration, no card.
4 provider calls per symbol per report. Measured, not estimated — the bot prints its own call count at the end of every run.
25 calls a day, which is what the engine's own counter reports against.
Three symbols a day is about 12 calls — comfortably inside it. This repository ships configured for three: EUR/USD, gold and silver.
More symbols, or intraday timeframes. Their paid tiers start around $50/month.
Check their current terms before relying on this
The 25/day figure is what the engine reports against and was true when it was written. An external provider's pricing is theirs to change, not ours. You are not tied to them either — switching providers is a small job, described below.
Telegram — optional
Only if you want the publishing channel. A bot token is free from @BotFather
inside Telegram itself, in about a minute. Without it the engine runs completely
normally — it writes its reports to disk and to the static site, and simply does not
publish to Telegram.
Running with no account at all
CsvFileProvider ships with the repository along with the historical bars, so
the engine runs, produces reports and can be measured end to end with no registration
anywhere. That is how the calibration studies in this project were produced — every
one of them states "zero provider calls". Use it to evaluate the engine before signing up for
anything.
The whole monthly bill
Stripe has no monthly fee — it takes a percentage per transaction. Outbound email has free tiers of a few hundred messages a day, far more than password resets need.
Choosing which instruments to cover
EUR/USD, gold and silver are a configuration, not a design. Nothing in the analysis is written for a particular instrument — the layers receive candles and a symbol code, and never ask what the code means.
Where the list lives
In the database, managed from the web interface — /admin/symbols
in the dashboard. Add an instrument, pause one, resume one; the change takes effect on the next
scheduled run with nothing to restart and no file to edit.
Who can reach that page is Admin:Emails in the dashboard's configuration.
Leave it empty and nobody has access, including you. That is deliberate: an
empty list read as "no restriction" would open the page to every subscriber on a deployment
that simply forgot the setting.
Telegram:ActiveSymbols is now only a seed. On a first run
against an empty database its contents become the initial rows; after that the table is
authoritative and the setting is not consulted again — so switching an instrument off in the
browser is not undone by the next restart.
A one-off run can still target a single instrument regardless of the list:
dotnet run -- report GBPUSD 1D
Remember the cost: 4 provider calls per symbol per report. On a 25-a-day free allowance, three symbols is comfortable and six is the ceiling.
Adding one properly
Check your provider carries it, and under what ticker
Your vendor's code may not be GBPUSD. Translate inside the provider; the
engine keeps its own naming.
Add it on /admin/symbols
Code and, optionally, a display name. Re-adding one that is paused resumes it — its bars, reports and calibration record come back with it.
Seed its history, once
Press Fetch history on its row — the bot picks the request up within a few seconds and the result appears under Recent work, with the number of provider calls it spent.
The equivalent from a terminal, for every active instrument at once:
dotnet run -- seedhistory # full daily history, every active symbol dotnet run -- backfill # walk-forward scoring — fills the calibration cards
backfill is still command-line only: it is a long sweep over the whole
history rather than a request the page should wait on. Without either, the instrument
still produces reports — it simply has no track record yet, and says so.
Give it a quoting convention if it needs one
PriceFormatter decides decimal places from the symbol code: anything
containing JPY → 3, XAU/GOLD → 2,
XAG/SILVER → 3, everything else → 5. That
default is right for FX majors and wrong for an index or a crypto pair. It is one small
class with one method, and adding a rule is a two-line change.
"Report now" does not publish
It generates a reading and puts it in the review queue, exactly where a scheduled one goes. Nothing reaches Telegram or the site until a human approves it.
This gap is on record
Gold once printed at five decimals — 4021.26444 — because the formatter only
knew about JPY. A price a desk cannot read costs more credibility than it seems to.
What a new instrument does not have on day one
A calibration record. The engine will not claim one it has not earned —
the calibration page prints "sample too small" until 20 scored outcomes exist
for that symbol, and the confidence figure falls back to an absolute reading rather than a
rank inside a history it does not yet have. backfill shortens this by scoring the
past in one pass; after that the record accrues daily.
One real limit — and it is about data, not the engine
Gold and silver run on a feed that gives one price per day with no intraday range. The consequence is structural and worth understanding before you add similar instruments:
- Market quality settles at FAIR and stays there.
- Several readings are deliberately withheld rather than estimated from a range that does not exist.
- Silver's VaR calibration still reads "sample too small", because the outcomes it can score are fewer.
More history does not fix this —
seedhistory will not move it. It is the shape of the data. Fixing it needs a
provider that supplies intraday movement, which is a paid tier.
So when you add an instrument, ask what its feed actually gives you. A full OHLC daily bar gets you everything EUR/USD has. A single closing price gets you a working but quieter report — and the engine will tell the reader which one it is rather than paper over it.
Using a different data provider
Nothing ties this engine to AlphaVantage. The analysis layers
never see a vendor — they ask for candles through an interface, and the vendor sits behind it.
Two implementations already exist, so the pattern is not theoretical:
AlphaVantageProvider (HTTP) and CsvFileProvider (files on disk).
What a provider has to supply
Two interfaces, in Domain/Interfaces, deliberately small:
public interface IMarketDataProvider { Task<IReadOnlyList<MarketSnapshot>> FetchAsync( string symbol, string timeframe, CancellationToken ct = default); } public interface IHistoricalMarketDataProvider { Task<IReadOnlyList<MarketSnapshot>> FetchHistoryAsync( string symbol, string timeframe, CancellationToken ct = default); }
They are separate on purpose: the first fetches only the recent window the live analysis needs, the second returns as much history as the source will give for the walk-forward backtest. One class may implement both, which is what both existing providers do.
And a MarketSnapshot is what you would expect — nothing vendor-shaped in it:
Which bar this is.
The OHLC.
Nullable — FX feeds often have none, and the engine does not require it.
Your own 0–100 assessment of the bar.
The three steps
Write the class
Put it in Infrastructure/DataProviders beside the other two, and return
snapshots sorted oldest-first.
Register it
In ServiceCollectionExtensions.cs, where the two existing providers are
registered, point both interfaces at your class:
services.AddScoped<IMarketDataProvider, MyVendorProvider>(); services.AddScoped<IHistoricalMarketDataProvider, MyVendorProvider>();
Map the symbols
Your vendor's ticker for EUR/USD may not be EURUSD. Translate inside your
provider — the rest of the engine keeps using its own symbol names.
That is the whole change. No analysis layer, no renderer, no report and no test touches a vendor name.
The pre-publication price check is deliberately not your vendor
Before a report publishes, the gate cross-checks the session's close against the ECB reference rate — free, key-less, and registered outside the provider branch on purpose, so that the feed cannot certify itself. Leave it as it is: if you swap your main provider, that second opinion keeps working and keeps being independent.
A rate-limiting handler is wired for AlphaVantage specifically
If your vendor has a different limit, write the equivalent for it — or drop it if they have none. The engine counts its own calls either way, and prints the total at the end of every run.
If you have your own data
CsvFileProvider already reads OHLC from files on disk. If you have history in
CSV — from a broker export, a terminal, or a vendor you already pay for — you may not
need a new class at all: point the CSV data directory at your files and the engine
runs on them. That is exactly how every calibration study in this project was produced.
The one thing that catches people
All three hosts must point at the same SQLite file
They do not by default. Each would otherwise create its own
quant.db beside itself, and you would get a dashboard where sign-up works, no
reports ever appear, and nothing in the logs looks wrong.
This repository already wires them together by relative path:
Data Source=quant.db
Data Source=../QuantMarketEngine.TelegramBot/quant.db
Data Source=../QuantMarketEngine.TelegramBot/quant.db
That works when the three run from their project folders on one machine, which is the development layout. On a server, set all three to one absolute path — a volume the containers share, or a path on the host:
ConnectionStrings__Quant=Data Source=/data/quant.db
If you move to PostgreSQL, the Npgsql provider is already referenced.
Running it locally
Three terminals, or run whichever you need.
The engine — generates today's report for every active symbol:
# from QuantMarketEngine/src/QuantMarketEngine.TelegramBot
dotnet run -- report
The dashboard — the site a subscriber signs into:
dotnet run --project QuantMarketEngine/src/QuantMarketEngine.Dashboard --urls http://localhost:5199
The API — what an X-Api-Key calls:
dotnet run --project QuantMarketEngine/src/QuantMarketEngine.API --urls http://localhost:5006
Open http://localhost:5199, create an account, and you land on the free
Observer plan.
Migrations and seeding
On a single node you do nothing. Database:MigrateOnStartup
defaults to true, so the dashboard and the API migrate and seed on boot.
On more than one node, turn that off — otherwise every instance races
Migrate() against the same database at start-up. Set
Database__MigrateOnStartup=false on the nodes and run the migrator once as a
deploy step:
dotnet run --project QuantMarketEngine/src/QuantMarketEngine.Migrator
It is idempotent and safe to re-run.
Seeding creates the three plans the pricing page renders from — Observer $0,
Analyst $49/mo, Desk $79/mo — with their entitlements. Change a price in
SaasPlanSeeder and it reconciles on the next boot; the pricing page and
the entitlement checks both read the same catalogue, so they cannot disagree.
Deploying
Four Dockerfiles are in the repository.
The engine. No health check.
The web app. Health check :8081/health/live
The public API. Health check :8080/health/live
Migrations and seed. No health check.
Both web hosts expose /health/live (is the process up)
and /health/ready (can it reach the database) — point your orchestrator's
liveness and readiness probes at those respectively.
A minimal deployment is: run the migrator once, then the dashboard, the API and the bot,
all three with ConnectionStrings__Quant pointing at one shared volume.
Put TLS in front of it
Neither web host terminates TLS itself. Use a reverse proxy.
What is honestly incomplete
Nothing is missing from the billing path — but it has never met a live card
Stripe hosted Checkout, the hosted Billing Portal and a signature-verified webhook covering activation, status changes and cancellation are all implemented, behind a provider interface so another PSP can be swapped in. Card data never touches the platform.
It activates the moment Stripe__SecretKey,
Stripe__WebhookSecret and the price ids are set. Until they are, the dashboard
detects that, routes upgrades to manual activation, and says so to the user rather than
failing at the payment step. What you are inheriting is a built integration with no
transaction history, not an unbuilt one.
The plans and their enforcement are separate from that and fully exercised: an Observer
account really is served the delayed report, and the institutional endpoint really does
return 403 Missing entitlement: reports.institutional.
Outbound email
IEmailSender has a no-op implementation for development and an SMTP one that
activates when Email:SmtpHost is set. Until you set it, password-reset
emails become log lines — which means the reset flow silently does nothing.
Where to read next
Every engine command.
Every configuration key the code reads.
The static site publisher.
Why each engine constant has the value it has.