Web layer#

A Flask application exposing the system as JSON. It is a thin layer: it parses and validates requests, calls a service, and serializes the result. Business rules live in domain/ and services/, never in routes.

Code: src/trade_engine/web/. Launcher: scripts/run_app.py.

Structure#

Module

Responsibility

web/__init__.py

create_app(services=None) application factory

web/routes/

One blueprint per resource: dashboard, blotter, market, pnl, risk

web/schemas.py

Request parsing and validation at the boundary

web/serializers.py

Explicit JSON shape of each persisted row

web/errors.py

Maps application errors to HTTP responses

web/deps.py

get_services(): access to the service container for the current app

Dependency injection. create_app takes a ServiceContainer (the composition root in container.py) or builds one from the environment, and stores it on app.extensions. Routes obtain services through get_services(); nothing is global. Tests pass a container pointing at a temporary root.

Endpoints#

Bodies may be JSON or form-encoded. A JSON body that is not an object is rejected.

Method and path

Purpose

Body

Success response

GET /

Dashboard

none

{latest_pnl, latest_risk, recent_breaches}

GET /blotter

Recent trades with instrument description and asset class

none

list of trades

POST /blotter

Book a trade

see below

201 {status: "created", trade_id}

GET /market

Recent price bars, including source and data_origin

none

list of bars

POST /market

Load a price CSV

see below

{status, symbol, origin, loaded, skipped}

GET /pnl

EOD P&L history

none

list of P&L rows

POST /pnl

Run EOD P&L

optional calc_date

{status, calc_date, rows}

GET /risk

Limits, risk metrics, breaches

none

{limits, metrics, breaches}

POST /risk

Run EOD risk

optional calc_date

{status, calc_date, metrics, breaches}

Reads come from reporting; the runs come from P&L and risk. When calc_date is omitted it defaults to the latest price date, as in scripts/run_eod.py.

POST /blotter fields#

Field

Required

Rule

side

yes

BUY or SELL (case-insensitive); there is no default

quantity, price

yes

finite numbers greater than zero

symbol

no (default EURUSD)

upper-cased; must be a known, active instrument

trade_date

no (default today)

YYYY-MM-DD

value_date

no

YYYY-MM-DD; not before trade_date

ccy

no

must equal the instrument’s quote currency; defaults to it

book

no (default MAIN)

upper-cased; must exist

trader

no (default SYSTEM)

must exist

notes

no

up to 512 characters

Status is not accepted: new trades are always ACTIVE. See Trade database (trade.db) for the booking rules.

POST /market fields#

Field

Required

Rule

origin

yes

OBSERVED or SIMULATED; there is no default

symbol

no (default EURUSD)

upper-cased

csv_path

no (default data/raw/<symbol>.csv)

a .csv file under data/raw or data/generated; relative paths resolve against the project root

source

no (default csv)

provenance label, up to 128 characters

Files under data/generated can only be loaded as SIMULATED. See Market database (market.db) and Simulated data: why and how.

The request stages the file in Redis and then processes the queue before responding, so a 400 is still returned for an invalid file (the batch is moved to the rejected stream, not retried) and Redis must be reachable.

Validation and errors#

Input is validated at this boundary and again by the domain and services, so no caller can bypass the rules. Failures return JSON {"error": "<message>"}:

Status

Raised for

400

Invalid input: bad date or number, unknown side, missing origin, unknown instrument, book or trader, bad CSV contents, disallowed csv_path

404

A CSV that does not exist. The message is deliberately generic because the underlying one contains a server path

409

Market data unavailable (for example running EOD before any prices are loaded, or a traded symbol with no price)

422

Unsupported instrument, such as a non-USD instrument in the risk run

503

The Redis staging area is unreachable. The message is deliberately generic

500

Anything unexpected; Flask’s default handler returns no internals

Security posture#

Implemented:

  • CSV loads are restricted to data/raw and data/generated, and .. traversal is rejected because paths are resolved before the check.

  • The launcher binds to 127.0.0.1, and debug mode is off unless FLASK_DEBUG=1.

  • Error responses do not leak server paths.

Not implemented (required before real use): authentication and authorization, CSRF protection, TLS, rate limiting, and a production WSGI server instead of Flask’s development server. State-changing endpoints (POST) are currently open to anyone who can reach the port, and POST /market should become an admin-only action or a batch process.

Current limitations#

  • JSON only: there are no HTML pages, forms or charts yet.

  • List endpoints are capped and not paginated (see Reporting).

  • POST /pnl and POST /risk run synchronously inside the request; there is no job queue, scheduler or run-locking (see P&L and the EOD batch).

Adding an endpoint#

  1. Put the behaviour in a service (and pure logic in domain/ if it is a calculation); do not put it in the route.

  2. Add request parsing to web/schemas.py and the JSON shape to web/serializers.py.

  3. Add a blueprint module under web/routes/ and list it in ALL_BLUEPRINTS in web/routes/__init__.py.

  4. Raise DataValidationError (or another TradeEngineError) for expected failures so web/errors.py turns them into responses.

  5. Add a test in tests/test_web.py using the injected container.