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
---------

.. list-table::
   :header-rows: 1

   * - 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.

.. list-table::
   :header-rows: 1

   * - 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 :doc:`reporting </reporting>`; the runs come from :doc:`P&L </pnl>` and
:doc:`risk </risk>`. When ``calc_date`` is omitted it defaults to the latest price date,
as in ``scripts/run_eod.py``.

``POST /blotter`` fields
~~~~~~~~~~~~~~~~~~~~~~~~

.. list-table::
   :header-rows: 1

   * - 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
:doc:`/database/trade` for the booking rules.

``POST /market`` fields
~~~~~~~~~~~~~~~~~~~~~~~

.. list-table::
   :header-rows: 1

   * - 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
:doc:`/database/market` and :doc:`/simulation`.

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>"}``:

.. list-table::
   :header-rows: 1

   * - 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 :doc:`/reporting`).
- ``POST /pnl`` and ``POST /risk`` run synchronously inside the request; there is no
  job queue, scheduler or run-locking (see :doc:`/pnl`).

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.
