From 93dc7f90a585eb1d91ed9d0a8eef73d8e0776720 Mon Sep 17 00:00:00 2001
From: BonelessWater
Date: Wed, 14 Oct 2026 07:41:08 -0400
Subject: [PATCH 1/2] QT desk: the risk check and the approve gate (C9,
desk.proto 1.3.0)
Vendor desk.proto 1.3.0 from trade-ngin 7ec6076e (GetDeskRisk) and
regenerate the stubs (grpcio-tools 1.84.0); versions.py says desk=1.3.0.
API: DeskClient.get_desk_risk returns the engine's risk report of the day's
qt book as a camelCase dict (unset optional readings are None) or raises
DeskRiskUnavailable. The desk state and the override approval page carry it
as "risk" ({"unavailable": reason} when the call fails, None before the
first book). PublishDesk refuses (409), before it writes a row or calls the
engine, while a save is still running, while the check cannot be read (fail
closed), while the report is of an older save than the latest done one, and
while the desk's positions breach a risk limit without an approved override.
The model's book, no book and an approved override pass; the fallback
publish and the engine are unchanged.
Frontend: RiskReport shows the badge, the limit that bound, the breaches, the
configured limits against the stored book and the moved symbols (risk vs
trading rule). Approve is closed by riskBreachReason; on a breach the desk
panel points to the existing override request form. The approval page shows
the report too.
---
README.md | 3 +-
algolens-api/algogators/desk_pb2.py | 30 ++-
algolens-api/algogators/desk_pb2_grpc.py | 59 +++++
algolens-api/algogators/versions.py | 2 +-
algolens-api/algolens/adapters/http/desk.py | 8 +-
algolens-api/algolens/application/qt/ports.py | 30 ++-
.../algolens/application/qt/use_cases.py | 65 +++++-
.../algolens/infrastructure/qt/desk_client.py | 66 ++++++
algolens-api/proto/algogators/SOURCE | 2 +-
algolens-api/proto/algogators/desk.proto | 87 +++++++-
algolens-api/tests/test_qt_desk_client.py | 90 +++++++-
algolens-api/tests/test_qt_desk_routes.py | 24 +-
algolens-api/tests/test_qt_desk_use_cases.py | 205 ++++++++++++++++--
algolens-api/tests/test_qt_integration.py | 40 ++--
.../src/components/qt/ApprovalPage.tsx | 12 +
.../src/components/qt/DeskPanel.tsx | 37 +++-
.../src/components/qt/RiskReport.tsx | 146 +++++++++++++
algolens-frontend/src/domain/qt/desk.test.ts | 126 +++++++++++
algolens-frontend/src/domain/qt/desk.ts | 176 ++++++++++++++-
.../src/infrastructure/api/deskApi.ts | 3 +
20 files changed, 1142 insertions(+), 69 deletions(-)
create mode 100644 algolens-frontend/src/components/qt/RiskReport.tsx
diff --git a/README.md b/README.md
index 6aac6eaf..470b82ea 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ AlgoLens and the engine (trade-ngin) **talk directly over gRPC**, and Postgres s
| Direction | How | What |
|---|---|---|
-| AlgoLens → engine | gRPC to `engine-rpc:50051` on the private Docker network `qt` (env `ENGINE_RPC_ADDR`; `DESK_AGENT_ADDR` is still read for one release; 3 s timeout) | The desk service `algogators.desk.DeskService`: `RunDesk` (a save), `RequestOverride`, `RecordDecision`, `Publish`, `Rescind`, `GetRunStatus`. Contract: `algolens-api/proto/algogators/desk.proto`, vendored byte for byte from trade-ngin `proto/algogators/desk.proto` |
+| AlgoLens → engine | gRPC to `engine-rpc:50051` on the private Docker network `qt` (env `ENGINE_RPC_ADDR`; `DESK_AGENT_ADDR` is still read for one release; 3 s timeout) | The desk service `algogators.desk.DeskService`: `RunDesk` (a save), `RequestOverride`, `RecordDecision`, `Publish`, `Rescind`, `GetRunStatus`, `GetDeskRisk`. Contract: `algolens-api/proto/algogators/desk.proto`, vendored byte for byte from trade-ngin `proto/algogators/desk.proto` |
| Engine → AlgoLens | Postgres (`new_algo_data`) | The three books in `trading.positions` (`system`, `qt_proposal`, `qt`), the command log `trading.position_overrides` (status and result of every command), and `live_run_metadata.published_by` / `published_at` / `settings_used`, plus `publish_source` / `sent_at` once trade-ngin migration 026 is applied (read as NULL before) |
**How a command works:**
@@ -40,6 +40,7 @@ AlgoLens and the engine (trade-ngin) **talk directly over gRPC**, and Postgres s
- **E-mail:** override approval links go to the VP and the President (env `QT_APPROVERS`, set on both sides). The daily book e-mails come from the engine.
- **Daily cutoff (America/New_York, every calendar day):** the desk **approves** the day (the UI's Approve button; command kind `publish`, RPC `Publish`) by 09:30. Approved books are e-mailed at 09:30; an approval between 09:30 and 10:00 is e-mailed at once. At 10:00 an unapproved day gets the model's book, published (`publish_source='fallback'`) and e-mailed by the engine. From 10:00 AlgoLens refuses (409) to approve, save, request or decide an override for that day; the engine refuses too. The desk banner counts down on the server's clock (`serverTime` and `deadlines` in the desk state).
- **Rescind approval (contract C8, trade-ngin migration 029):** before 09:30 New York the desk may take back its own unsent approval (command kind `rescind`, RPC `Rescind`). The engine clears `published_by` / `published_at` / `publish_source`, so the day takes edits and a new approval again. AlgoLens refuses (409) from 09:30, on an engine publish (fallback, model-only), once sent, and while a publish or rescind is open; the engine refuses too.
+- **Risk check (contract C9, desk.proto 1.3.0):** a desk save runs the engine's risk pass on the desk's positions. AlgoLens reads what it did with `GetDeskRisk` (read-only) and shows it on the desk and on the override approval page: within or outside the limits, the limit that bound, the stored book's leverage and margin against the configured limits, and the symbols each step moved. A breach is a risk limit that bound (`cap`, `overlay`, `clip`, `trim`, the overlay cutting the book, a term still over its limit); trading rules (`sign_close`, `rounding`, `buffer`, `search`, `hold`) are shown, not blocking. **Approve is refused (409)** while a save is still running, while the report is of an older save than the latest done one, while the positions breach a limit and no override was approved, and while the check cannot be read (fail closed). The desk then saves within the limits or requests an override; an approved override books the ask exactly and passes. The engine's fallback publish is unchanged.
- **Feature flag:** the desk UI and its write routes sit behind `QT_DESK_ENABLED`.
- **Spec:** trade-ngin `docs/design/qt-contract.md` and `docs/design/qt-master-rulings.md`. The gRPC channel amends the master document's original "never call each other" (decided 2026-10-09).
diff --git a/algolens-api/algogators/desk_pb2.py b/algolens-api/algogators/desk_pb2.py
index 8ebc5e3b..3f917828 100644
--- a/algolens-api/algogators/desk_pb2.py
+++ b/algolens-api/algogators/desk_pb2.py
@@ -25,19 +25,21 @@
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x61lgogators/desk.proto\x12\x0f\x61lgogators.desk\x1a\x1fgoogle/protobuf/timestamp.proto\"\\\n\x0eRunDeskRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x03 \x01(\x03\x12\x14\n\x0crequested_by\x18\x04 \x01(\t\"O\n\rSymbolOutcome\x12\x0e\n\x06symbol\x18\x01 \x01(\t\x12\r\n\x05\x61sked\x18\x02 \x01(\x01\x12\r\n\x05given\x18\x03 \x01(\x01\x12\x10\n\x08moved_by\x18\x04 \x01(\t\"\xae\x01\n\x0cRunDeskReply\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.algogators.desk.CommandStatus\x12+\n\x06source\x18\x02 \x01(\x0e\x32\x1b.algogators.desk.BookSource\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x30\n\x08outcomes\x18\x04 \x03(\x0b\x32\x1e.algogators.desk.SymbolOutcome\"m\n\x0fOverrideRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x03 \x01(\x03\x12\x14\n\x0crequested_by\x18\x04 \x01(\t\x12\x0e\n\x06reason\x18\x05 \x01(\t\"V\n\x0f\x44\x65\x63isionRequest\x12\x10\n\x08\x61udit_id\x18\x01 \x01(\x03\x12\x10\n\x08\x61pprover\x18\x02 \x01(\t\x12\x10\n\x08\x61pproved\x18\x03 \x01(\x08\x12\r\n\x05token\x18\x04 \x01(\t\"\\\n\x0ePublishRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x14\n\x0cpublished_by\x18\x03 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x04 \x01(\x03\"F\n\x0eRescindRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x03 \x01(\x03\"O\n\x0c\x43ommandReply\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.algogators.desk.CommandStatus\x12\x0f\n\x07message\x18\x02 \x01(\t\"6\n\x10RunStatusRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\"\xc7\x02\n\tRunStatus\x12(\n\x05state\x18\x01 \x01(\x0e\x32\x19.algogators.desk.RunState\x12.\n\nstarted_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x66inished_at\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x11\n\tpublished\x18\x05 \x01(\x08\x12\x14\n\x0cpublished_by\x18\x06 \x01(\t\x12\x30\n\x0cpublished_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0epublish_source\x18\x08 \x01(\t\x12+\n\x07sent_at\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp*\x9c\x01\n\rCommandStatus\x12\x1e\n\x1a\x43OMMAND_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x43OMMAND_STATUS_ACCEPTED\x10\x01\x12\x17\n\x13\x43OMMAND_STATUS_DONE\x10\x02\x12\x1a\n\x16\x43OMMAND_STATUS_REFUSED\x10\x03\x12\x19\n\x15\x43OMMAND_STATUS_FAILED\x10\x04*p\n\nBookSource\x12\x1b\n\x17\x42OOK_SOURCE_UNSPECIFIED\x10\x00\x12\x15\n\x11\x42OOK_SOURCE_MODEL\x10\x01\x12\x14\n\x10\x42OOK_SOURCE_DESK\x10\x02\x12\x18\n\x14\x42OOK_SOURCE_OVERRIDE\x10\x03*\x9d\x01\n\x08RunState\x12\x19\n\x15RUN_STATE_UNSPECIFIED\x10\x00\x12\x19\n\x15RUN_STATE_NOT_STARTED\x10\x01\x12\x15\n\x11RUN_STATE_RUNNING\x10\x02\x12\x17\n\x13RUN_STATE_SUCCEEDED\x10\x03\x12\x15\n\x11RUN_STATE_REFUSED\x10\x04\x12\x14\n\x10RUN_STATE_FAILED\x10\x05\x32\xe4\x03\n\x0b\x44\x65skService\x12I\n\x07RunDesk\x12\x1f.algogators.desk.RunDeskRequest\x1a\x1d.algogators.desk.RunDeskReply\x12R\n\x0fRequestOverride\x12 .algogators.desk.OverrideRequest\x1a\x1d.algogators.desk.CommandReply\x12Q\n\x0eRecordDecision\x12 .algogators.desk.DecisionRequest\x1a\x1d.algogators.desk.CommandReply\x12I\n\x07Publish\x12\x1f.algogators.desk.PublishRequest\x1a\x1d.algogators.desk.CommandReply\x12I\n\x07Rescind\x12\x1f.algogators.desk.RescindRequest\x1a\x1d.algogators.desk.CommandReply\x12M\n\x0cGetRunStatus\x12!.algogators.desk.RunStatusRequest\x1a\x1a.algogators.desk.RunStatusb\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x61lgogators/desk.proto\x12\x0f\x61lgogators.desk\x1a\x1fgoogle/protobuf/timestamp.proto\"\\\n\x0eRunDeskRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x03 \x01(\x03\x12\x14\n\x0crequested_by\x18\x04 \x01(\t\"O\n\rSymbolOutcome\x12\x0e\n\x06symbol\x18\x01 \x01(\t\x12\r\n\x05\x61sked\x18\x02 \x01(\x01\x12\r\n\x05given\x18\x03 \x01(\x01\x12\x10\n\x08moved_by\x18\x04 \x01(\t\"\xae\x01\n\x0cRunDeskReply\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.algogators.desk.CommandStatus\x12+\n\x06source\x18\x02 \x01(\x0e\x32\x1b.algogators.desk.BookSource\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x30\n\x08outcomes\x18\x04 \x03(\x0b\x32\x1e.algogators.desk.SymbolOutcome\"m\n\x0fOverrideRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x03 \x01(\x03\x12\x14\n\x0crequested_by\x18\x04 \x01(\t\x12\x0e\n\x06reason\x18\x05 \x01(\t\"V\n\x0f\x44\x65\x63isionRequest\x12\x10\n\x08\x61udit_id\x18\x01 \x01(\x03\x12\x10\n\x08\x61pprover\x18\x02 \x01(\t\x12\x10\n\x08\x61pproved\x18\x03 \x01(\x08\x12\r\n\x05token\x18\x04 \x01(\t\"\\\n\x0ePublishRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x14\n\x0cpublished_by\x18\x03 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x04 \x01(\x03\"F\n\x0eRescindRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\x12\x10\n\x08\x61udit_id\x18\x03 \x01(\x03\"O\n\x0c\x43ommandReply\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.algogators.desk.CommandStatus\x12\x0f\n\x07message\x18\x02 \x01(\t\"6\n\x10RunStatusRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\"\xc7\x02\n\tRunStatus\x12(\n\x05state\x18\x01 \x01(\x0e\x32\x19.algogators.desk.RunState\x12.\n\nstarted_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x66inished_at\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07message\x18\x04 \x01(\t\x12\x11\n\tpublished\x18\x05 \x01(\x08\x12\x14\n\x0cpublished_by\x18\x06 \x01(\t\x12\x30\n\x0cpublished_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0epublish_source\x18\x08 \x01(\t\x12+\n\x07sent_at\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"5\n\x0f\x44\x65skRiskRequest\x12\x14\n\x0cportfolio_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\t\"*\n\nRiskBreach\x12\x0c\n\x04term\x18\x01 \x01(\t\x12\x0e\n\x06\x64\x65tail\x18\x02 \x01(\t\"^\n\nSymbolRisk\x12\x0e\n\x06symbol\x18\x01 \x01(\t\x12\r\n\x05\x61sked\x18\x02 \x01(\x01\x12\r\n\x05given\x18\x03 \x01(\x01\x12\x10\n\x08moved_by\x18\x04 \x01(\t\x12\x10\n\x08\x63\x61tegory\x18\x05 \x01(\t\"(\n\tRiskLimit\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x01\"\xb7\x04\n\x08\x44\x65skRisk\x12-\n\x05state\x18\x01 \x01(\x0e\x32\x1e.algogators.desk.DeskRiskState\x12\x10\n\x08\x61udit_id\x18\x02 \x01(\x03\x12\x15\n\rwithin_limits\x18\x03 \x01(\x08\x12\x19\n\x11\x61pproved_override\x18\x04 \x01(\x08\x12-\n\x08\x62reaches\x18\x05 \x03(\x0b\x32\x1b.algogators.desk.RiskBreach\x12\t\n\x01m\x18\x06 \x01(\x01\x12\x14\n\x0c\x62inding_term\x18\x07 \x01(\t\x12!\n\x19over_limit_after_rounding\x18\x08 \x01(\t\x12\x1a\n\x12over_limit_by_hold\x18\t \x01(\t\x12\x0f\n\x07refused\x18\n \x01(\x08\x12,\n\x07symbols\x18\x0b \x03(\x0b\x32\x1b.algogators.desk.SymbolRisk\x12\x1b\n\x0egross_leverage\x18\x0c \x01(\x01H\x00\x88\x01\x01\x12\x19\n\x0cnet_leverage\x18\r \x01(\x01H\x01\x88\x01\x01\x12\x1b\n\x0emargin_cushion\x18\x0e \x01(\x01H\x02\x88\x01\x01\x12\x1d\n\x10\x65quity_to_margin\x18\x0f \x01(\x01H\x03\x88\x01\x01\x12*\n\x06limits\x18\x10 \x03(\x0b\x32\x1a.algogators.desk.RiskLimitB\x11\n\x0f_gross_leverageB\x0f\n\r_net_leverageB\x11\n\x0f_margin_cushionB\x13\n\x11_equity_to_margin*\x9c\x01\n\rCommandStatus\x12\x1e\n\x1a\x43OMMAND_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x43OMMAND_STATUS_ACCEPTED\x10\x01\x12\x17\n\x13\x43OMMAND_STATUS_DONE\x10\x02\x12\x1a\n\x16\x43OMMAND_STATUS_REFUSED\x10\x03\x12\x19\n\x15\x43OMMAND_STATUS_FAILED\x10\x04*p\n\nBookSource\x12\x1b\n\x17\x42OOK_SOURCE_UNSPECIFIED\x10\x00\x12\x15\n\x11\x42OOK_SOURCE_MODEL\x10\x01\x12\x14\n\x10\x42OOK_SOURCE_DESK\x10\x02\x12\x18\n\x14\x42OOK_SOURCE_OVERRIDE\x10\x03*\x9d\x01\n\x08RunState\x12\x19\n\x15RUN_STATE_UNSPECIFIED\x10\x00\x12\x19\n\x15RUN_STATE_NOT_STARTED\x10\x01\x12\x15\n\x11RUN_STATE_RUNNING\x10\x02\x12\x17\n\x13RUN_STATE_SUCCEEDED\x10\x03\x12\x15\n\x11RUN_STATE_REFUSED\x10\x04\x12\x14\n\x10RUN_STATE_FAILED\x10\x05*\xa0\x01\n\rDeskRiskState\x12\x1f\n\x1b\x44\x45SK_RISK_STATE_UNSPECIFIED\x10\x00\x12\x1b\n\x17\x44\x45SK_RISK_STATE_NO_BOOK\x10\x01\x12\x19\n\x15\x44\x45SK_RISK_STATE_MODEL\x10\x02\x12\x18\n\x14\x44\x45SK_RISK_STATE_DESK\x10\x03\x12\x1c\n\x18\x44\x45SK_RISK_STATE_OVERRIDE\x10\x04\x32\xb0\x04\n\x0b\x44\x65skService\x12I\n\x07RunDesk\x12\x1f.algogators.desk.RunDeskRequest\x1a\x1d.algogators.desk.RunDeskReply\x12R\n\x0fRequestOverride\x12 .algogators.desk.OverrideRequest\x1a\x1d.algogators.desk.CommandReply\x12Q\n\x0eRecordDecision\x12 .algogators.desk.DecisionRequest\x1a\x1d.algogators.desk.CommandReply\x12I\n\x07Publish\x12\x1f.algogators.desk.PublishRequest\x1a\x1d.algogators.desk.CommandReply\x12I\n\x07Rescind\x12\x1f.algogators.desk.RescindRequest\x1a\x1d.algogators.desk.CommandReply\x12M\n\x0cGetRunStatus\x12!.algogators.desk.RunStatusRequest\x1a\x1a.algogators.desk.RunStatus\x12J\n\x0bGetDeskRisk\x12 .algogators.desk.DeskRiskRequest\x1a\x19.algogators.desk.DeskRiskb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'algogators.desk_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
- _globals['_COMMANDSTATUS']._serialized_start=1260
- _globals['_COMMANDSTATUS']._serialized_end=1416
- _globals['_BOOKSOURCE']._serialized_start=1418
- _globals['_BOOKSOURCE']._serialized_end=1530
- _globals['_RUNSTATE']._serialized_start=1533
- _globals['_RUNSTATE']._serialized_end=1690
+ _globals['_COMMANDSTATUS']._serialized_start=2067
+ _globals['_COMMANDSTATUS']._serialized_end=2223
+ _globals['_BOOKSOURCE']._serialized_start=2225
+ _globals['_BOOKSOURCE']._serialized_end=2337
+ _globals['_RUNSTATE']._serialized_start=2340
+ _globals['_RUNSTATE']._serialized_end=2497
+ _globals['_DESKRISKSTATE']._serialized_start=2500
+ _globals['_DESKRISKSTATE']._serialized_end=2660
_globals['_RUNDESKREQUEST']._serialized_start=75
_globals['_RUNDESKREQUEST']._serialized_end=167
_globals['_SYMBOLOUTCOME']._serialized_start=169
@@ -58,6 +60,16 @@
_globals['_RUNSTATUSREQUEST']._serialized_end=927
_globals['_RUNSTATUS']._serialized_start=930
_globals['_RUNSTATUS']._serialized_end=1257
- _globals['_DESKSERVICE']._serialized_start=1693
- _globals['_DESKSERVICE']._serialized_end=2177
+ _globals['_DESKRISKREQUEST']._serialized_start=1259
+ _globals['_DESKRISKREQUEST']._serialized_end=1312
+ _globals['_RISKBREACH']._serialized_start=1314
+ _globals['_RISKBREACH']._serialized_end=1356
+ _globals['_SYMBOLRISK']._serialized_start=1358
+ _globals['_SYMBOLRISK']._serialized_end=1452
+ _globals['_RISKLIMIT']._serialized_start=1454
+ _globals['_RISKLIMIT']._serialized_end=1494
+ _globals['_DESKRISK']._serialized_start=1497
+ _globals['_DESKRISK']._serialized_end=2064
+ _globals['_DESKSERVICE']._serialized_start=2663
+ _globals['_DESKSERVICE']._serialized_end=3223
# @@protoc_insertion_point(module_scope)
diff --git a/algolens-api/algogators/desk_pb2_grpc.py b/algolens-api/algogators/desk_pb2_grpc.py
index ff84f9c7..d088ed37 100644
--- a/algolens-api/algogators/desk_pb2_grpc.py
+++ b/algolens-api/algogators/desk_pb2_grpc.py
@@ -39,6 +39,9 @@ class DeskServiceStub:
after 10:00 (or on a published day) it is refused. No field changed.
1.2.0 2026-10-13 Rescind (contract C8): the desk takes back its own approval before the
09:30 send. New RPC and message only; no field changed.
+ 1.3.0 2026-10-14 GetDeskRisk (contract C9): the risk report of the day's qt book and
+ whether the desk's positions breach a risk limit. New RPC and messages
+ only; no field changed.
"""
@@ -78,6 +81,11 @@ def __init__(self, channel):
request_serializer=algogators_dot_desk__pb2.RunStatusRequest.SerializeToString,
response_deserializer=algogators_dot_desk__pb2.RunStatus.FromString,
_registered_method=True)
+ self.GetDeskRisk = channel.unary_unary(
+ '/algogators.desk.DeskService/GetDeskRisk',
+ request_serializer=algogators_dot_desk__pb2.DeskRiskRequest.SerializeToString,
+ response_deserializer=algogators_dot_desk__pb2.DeskRisk.FromString,
+ _registered_method=True)
class DeskServiceServicer:
@@ -94,6 +102,9 @@ class DeskServiceServicer:
after 10:00 (or on a published day) it is refused. No field changed.
1.2.0 2026-10-13 Rescind (contract C8): the desk takes back its own approval before the
09:30 send. New RPC and message only; no field changed.
+ 1.3.0 2026-10-14 GetDeskRisk (contract C9): the risk report of the day's qt book and
+ whether the desk's positions breach a risk limit. New RPC and messages
+ only; no field changed.
"""
@@ -155,6 +166,19 @@ def GetRunStatus(self, request, context):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
+ def GetDeskRisk(self, request, context):
+ """Since 1.3.0 (contract C9). The risk report of the day's qt book: what the engine's last run of
+ it (the model run, a desk save, an approved override) recorded in trading.live_results
+ risk_detail, the configured limits, and whether the desk's positions breach a risk limit (a
+ symbol moved by cap, overlay, clip or trim; the overlay cutting the book; the book still over
+ a limit). Trading rules (sign_close, rounding, buffer, search, hold) are reported, not
+ breaches. Read-only. AlgoLens refuses the desk's approval while within_limits is false and no
+ override was approved.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
def add_DeskServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -188,6 +212,11 @@ def add_DeskServiceServicer_to_server(servicer, server):
request_deserializer=algogators_dot_desk__pb2.RunStatusRequest.FromString,
response_serializer=algogators_dot_desk__pb2.RunStatus.SerializeToString,
),
+ 'GetDeskRisk': grpc.unary_unary_rpc_method_handler(
+ servicer.GetDeskRisk,
+ request_deserializer=algogators_dot_desk__pb2.DeskRiskRequest.FromString,
+ response_serializer=algogators_dot_desk__pb2.DeskRisk.SerializeToString,
+ ),
}
generic_handler = grpc.method_handlers_generic_handler(
'algogators.desk.DeskService', rpc_method_handlers)
@@ -210,6 +239,9 @@ class DeskService:
after 10:00 (or on a published day) it is refused. No field changed.
1.2.0 2026-10-13 Rescind (contract C8): the desk takes back its own approval before the
09:30 send. New RPC and message only; no field changed.
+ 1.3.0 2026-10-14 GetDeskRisk (contract C9): the risk report of the day's qt book and
+ whether the desk's positions breach a risk limit. New RPC and messages
+ only; no field changed.
"""
@@ -374,3 +406,30 @@ def GetRunStatus(request,
timeout,
metadata,
_registered_method=True)
+
+ @staticmethod
+ def GetDeskRisk(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_unary(
+ request,
+ target,
+ '/algogators.desk.DeskService/GetDeskRisk',
+ algogators_dot_desk__pb2.DeskRiskRequest.SerializeToString,
+ algogators_dot_desk__pb2.DeskRisk.FromString,
+ options,
+ channel_credentials,
+ insecure,
+ call_credentials,
+ compression,
+ wait_for_ready,
+ timeout,
+ metadata,
+ _registered_method=True)
diff --git a/algolens-api/algogators/versions.py b/algolens-api/algogators/versions.py
index e0d09fe2..61829da3 100644
--- a/algolens-api/algogators/versions.py
+++ b/algolens-api/algogators/versions.py
@@ -5,5 +5,5 @@
API_VERSIONS = {
"common": "1.0.0",
- "desk": "1.2.0",
+ "desk": "1.3.0",
}
diff --git a/algolens-api/algolens/adapters/http/desk.py b/algolens-api/algolens/adapters/http/desk.py
index 4cadbf6a..c1e3c87e 100644
--- a/algolens-api/algolens/adapters/http/desk.py
+++ b/algolens-api/algolens/adapters/http/desk.py
@@ -153,8 +153,8 @@ def _approver_role(user):
@desk_user
@handled
def desk_state(portfolio_id):
- repo, _agent, _settings = _desk_dependencies()
- return jsonify(GetDeskState(repo).execute(_portfolio(portfolio_id))), 200
+ repo, agent, _settings = _desk_dependencies()
+ return jsonify(GetDeskState(repo, agent).execute(_portfolio(portfolio_id))), 200
@desk_bp.route("//symbols", methods=["GET"])
@@ -256,8 +256,8 @@ def approval_lookup():
role = _approver_role(user)
if role is None and not can_use_qt_desk():
return jsonify({"error": "Insufficient permissions"}), 403
- repo, _agent, _settings = _desk_dependencies()
- result = LookupApproval(repo).execute(_body().get("token"))
+ repo, agent, _settings = _desk_dependencies()
+ result = LookupApproval(repo, agent).execute(_body().get("token"))
result["viewer"] = {
"email": user["email"],
"approver_role": role,
diff --git a/algolens-api/algolens/application/qt/ports.py b/algolens-api/algolens/application/qt/ports.py
index a1d8ecdd..3fcb9cd7 100644
--- a/algolens-api/algolens/application/qt/ports.py
+++ b/algolens-api/algolens/application/qt/ports.py
@@ -29,6 +29,17 @@ class DeskGone(ApplicationError):
"""The approval link has expired. HTTP 410."""
+class DeskRiskUnavailable(Exception):
+ """GetDeskRisk failed (the engine is down, timed out, refused the call).
+
+ Not an HTTP error by itself: the desk state shows it, and the approval
+ gate refuses while it lasts (contract C9: fail closed)."""
+
+ def __init__(self, reason: str):
+ super().__init__(reason)
+ self.reason = reason
+
+
class DeskRepositoryPort(Protocol):
def desk_date(self, portfolio_id: str) -> date | None:
"""The latest model-run date of the portfolio (system or qt_proposal)."""
@@ -123,11 +134,13 @@ def insert_config_version(
class DeskAgentPort(Protocol):
"""The engine's desk service over gRPC (infrastructure/qt/desk_client.py).
- Best effort: never raises.
+ Commands are best effort: they never raise. Each returns a short outcome
+ for the log ("ACCEPTED", "unavailable", ...). The command row is the
+ record; when a call fails the row stays pending and the engine re-drives
+ it (contract section 6).
- Each call returns a short outcome for the log ("ACCEPTED", "unavailable",
- ...). The command row is the record; when a call fails the row stays
- pending and the engine re-drives it (contract section 6).
+ get_desk_risk is a read (contract C9): it returns the report or raises
+ DeskRiskUnavailable.
"""
def run_desk(self, command: Row) -> str:
@@ -144,3 +157,12 @@ def publish(self, command: Row) -> str:
def rescind(self, command: Row) -> str:
...
+
+ def get_desk_risk(self, portfolio_id: str, day: date) -> dict[str, Any]:
+ """The risk report of the day's qt book (GetDeskRisk, desk.proto
+ 1.3.0), JSON-friendly with camelCase keys: state ('no_book', 'model',
+ 'desk', 'override'), auditId, withinLimits, approvedOverride,
+ breaches, m, bindingTerm, overLimitAfterRounding, overLimitByHold,
+ refused, symbols, grossLeverage, netLeverage, marginCushion,
+ equityToMargin (None when not recorded) and limits. Raises
+ DeskRiskUnavailable when the call fails."""
diff --git a/algolens-api/algolens/application/qt/use_cases.py b/algolens-api/algolens/application/qt/use_cases.py
index 25780ead..202c776d 100644
--- a/algolens-api/algolens/application/qt/use_cases.py
+++ b/algolens-api/algolens/application/qt/use_cases.py
@@ -20,6 +20,7 @@
DeskNotFound,
DeskNotSeeded,
DeskRepositoryPort,
+ DeskRiskUnavailable,
)
from algolens.domain.portfolio.registry import desk_edit_allowed
from algolens.domain.qt.cutoff import (
@@ -31,6 +32,7 @@
rescind_closed_message,
)
from algolens.domain.qt.desk import (
+ DONE,
OPEN_STATUSES,
OVERRIDE_DECISION,
OVERRIDE_REQUEST,
@@ -164,11 +166,57 @@ def _refuse_after_cutoff(
raise DeskConflict(closed_message(day, action))
+def _desk_risk(agent: DeskAgentPort, portfolio_id: str, day) -> dict[str, Any]:
+ """The engine's risk report of the day's qt book (contract C9), or
+ {"unavailable": } when it cannot be read."""
+ try:
+ return agent.get_desk_risk(portfolio_id, day)
+ except DeskRiskUnavailable as exc:
+ return {"unavailable": exc.reason}
+
+
+def _refuse_outside_risk_limits(
+ repo: DeskRepositoryPort, agent: DeskAgentPort, portfolio_id: str, day
+) -> None:
+ """C9: the desk may not approve positions that breach a risk limit. Refuse
+ (409) while a save is still running, while the report cannot be read (fail
+ closed), while it is not yet the report of the latest done save, and while
+ it says the desk's positions are outside the limits with no approved
+ override. The model's book, no book and an approved override pass. A
+ published day is left to the repository's refusal (C3)."""
+ if _published_record(repo, portfolio_id, day):
+ return
+ saves = [c for c in repo.commands(portfolio_id, day) if c["kind"] == SAVE]
+ running = next((c for c in reversed(saves) if c["status"] in OPEN_STATUSES), None)
+ if running is not None:
+ raise DeskConflict(
+ f"A save is still running (#{running['id']}); approve once it has finished"
+ )
+ risk = _desk_risk(agent, portfolio_id, day)
+ if "unavailable" in risk:
+ raise DeskConflict(f"The risk check is unavailable ({risk['unavailable']}); try again")
+ if risk.get("state") != "desk":
+ return
+ done = [c for c in saves if c["status"] == DONE]
+ if done and int(done[-1]["id"]) != int(risk.get("auditId") or 0):
+ raise DeskConflict(
+ f"The risk check has not caught up with save #{done[-1]['id']}; try again"
+ )
+ if not risk.get("withinLimits") and not risk.get("approvedOverride"):
+ terms = ", ".join(dict.fromkeys(b["term"] for b in risk.get("breaches") or ()))
+ raise DeskConflict(
+ f"The {day.isoformat()} positions breach risk limits"
+ + (f" ({terms})" if terms else "")
+ + ": save within the limits or request an override"
+ )
+
+
class GetDeskState:
"""Everything the desk page shows for one portfolio's book date."""
- def __init__(self, repo: DeskRepositoryPort, now=None):
+ def __init__(self, repo: DeskRepositoryPort, agent: DeskAgentPort, now=None):
self.repo = repo
+ self.agent = agent
self.now = now or _utcnow
def execute(self, entry: Mapping[str, Any]) -> dict[str, Any]:
@@ -199,6 +247,9 @@ def execute(self, entry: Mapping[str, Any]) -> dict[str, Any]:
# "published" (C3) or "publishing" (a publish is pending/running).
"locked": None,
"openOverrideRequestId": None,
+ # The engine's risk report of the day's qt book (C9), or
+ # {"unavailable": reason}; None before the first book.
+ "risk": None,
}
if day is None:
return state
@@ -239,6 +290,7 @@ def execute(self, entry: Mapping[str, Any]) -> dict[str, Any]:
"published": published,
"locked": "published" if published else "publishing" if publishing else None,
"openOverrideRequestId": open_request["id"] if open_request else None,
+ "risk": _desk_risk(self.agent, portfolio_id, day),
}
)
return state
@@ -342,10 +394,12 @@ def _request_for_token(repo: DeskRepositoryPort, token: Any, now: datetime):
class LookupApproval:
"""The approval page: the request with the book it snapshotted (C1), and
the model book (system) next to the desk's request (qt_proposal) and the
- current qt book."""
+ current qt book, and the engine's risk report of the day (C9) so the
+ approvers see what the override waives."""
- def __init__(self, repo: DeskRepositoryPort, now=None):
+ def __init__(self, repo: DeskRepositoryPort, agent: DeskAgentPort, now=None):
self.repo = repo
+ self.agent = agent
self.now = now or _utcnow
def execute(self, token: Any) -> dict[str, Any]:
@@ -379,6 +433,7 @@ def execute(self, token: Any) -> dict[str, Any]:
and current is not None
and request_matches(request, current),
"published": published,
+ "risk": _desk_risk(self.agent, portfolio_id, day),
# The desk approves the day by 09:30 New York; from 10:00 the
# model's book is sent and this request can no longer be decided.
"serverTime": now.astimezone(timezone.utc).isoformat(),
@@ -431,7 +486,8 @@ class PublishDesk:
"""The desk's approval of the day (the UI's Approve; command kind
'publish'). Before 09:30 New York the engine only freezes the day and
e-mails it at 09:30; from 09:30 to 10:00 it also e-mails at once; from
- 10:00 it is refused here (409) and by the engine."""
+ 10:00 it is refused here (409) and by the engine. Positions outside the
+ risk limits are refused here until an override is approved (C9)."""
def __init__(self, repo: DeskRepositoryPort, agent: DeskAgentPort, now=None):
self.repo = repo
@@ -443,6 +499,7 @@ def execute(self, entry: Mapping[str, Any], requested_by: str) -> dict[str, Any]
portfolio_id = entry["portfolio_id"]
day = _desk_day(self.repo, portfolio_id)
_refuse_after_cutoff(self.repo, portfolio_id, day, self.now(), "Approval")
+ _refuse_outside_risk_limits(self.repo, self.agent, portfolio_id, day)
# Published (C3) and publish-already-open are refused under the day lock.
command = self.repo.insert_publish(portfolio_id, day, requested_by)
outcome = self.agent.publish(command)
diff --git a/algolens-api/algolens/infrastructure/qt/desk_client.py b/algolens-api/algolens/infrastructure/qt/desk_client.py
index 6ab40efe..deaa51b6 100644
--- a/algolens-api/algolens/infrastructure/qt/desk_client.py
+++ b/algolens-api/algolens/infrastructure/qt/desk_client.py
@@ -7,6 +7,10 @@
written the trading.position_overrides row, the engine re-drives pending rows
on startup and every 60 s, so a failed or slow call is logged and swallowed.
It never fails the user's action. Timeout: 3 s (ENGINE_RPC_TIMEOUT_SECONDS).
+
+GetDeskRisk (contract C9) is a read, not a command: there is no row to fall
+back on, so a failed call raises DeskRiskUnavailable and the caller decides
+(the desk state shows it; the approval gate refuses, failing closed).
"""
import logging
@@ -14,6 +18,7 @@
import grpc
from algogators import desk_pb2, desk_pb2_grpc
+from algolens.application.qt.ports import DeskRiskUnavailable
from algolens.infrastructure.rpc.settings import DEFAULT_TIMEOUT_SECONDS
from algolens.infrastructure.rpc import describe_rpc_error, get_channel, is_version_mismatch, status_name
@@ -24,6 +29,50 @@ def _day(value):
return value.isoformat() if hasattr(value, "isoformat") else str(value)
+# DeskRiskState -> the report's "state".
+RISK_STATES = {
+ desk_pb2.DESK_RISK_STATE_NO_BOOK: "no_book",
+ desk_pb2.DESK_RISK_STATE_MODEL: "model",
+ desk_pb2.DESK_RISK_STATE_DESK: "desk",
+ desk_pb2.DESK_RISK_STATE_OVERRIDE: "override",
+}
+
+
+def _optional(message, field):
+ return getattr(message, field) if message.HasField(field) else None
+
+
+def risk_report(reply):
+ """A DeskRisk message as the JSON-friendly report of DeskAgentPort.get_desk_risk."""
+ return {
+ "state": RISK_STATES.get(reply.state, "unspecified"),
+ "auditId": int(reply.audit_id),
+ "withinLimits": reply.within_limits,
+ "approvedOverride": reply.approved_override,
+ "breaches": [{"term": b.term, "detail": b.detail} for b in reply.breaches],
+ "m": reply.m,
+ "bindingTerm": reply.binding_term,
+ "overLimitAfterRounding": reply.over_limit_after_rounding,
+ "overLimitByHold": reply.over_limit_by_hold,
+ "refused": reply.refused,
+ "symbols": [
+ {
+ "symbol": s.symbol,
+ "asked": s.asked,
+ "given": s.given,
+ "movedBy": s.moved_by,
+ "category": s.category,
+ }
+ for s in reply.symbols
+ ],
+ "grossLeverage": _optional(reply, "gross_leverage"),
+ "netLeverage": _optional(reply, "net_leverage"),
+ "marginCushion": _optional(reply, "margin_cushion"),
+ "equityToMargin": _optional(reply, "equity_to_margin"),
+ "limits": [{"name": limit.name, "value": limit.value} for limit in reply.limits],
+ }
+
+
class DeskClient:
def __init__(self, address, timeout=DEFAULT_TIMEOUT_SECONDS, channel_factory=None):
self.address = address
@@ -113,3 +162,20 @@ def rescind(self, command):
audit_id=int(command["id"]),
)
return self._call("Rescind", request, command["id"])
+
+ def get_desk_risk(self, portfolio_id, day):
+ request = desk_pb2.DeskRiskRequest(portfolio_id=portfolio_id, date=_day(day))
+ try:
+ stub = desk_pb2_grpc.DeskServiceStub(self.channel_factory(self.address))
+ reply = stub.GetDeskRisk(request, timeout=self.timeout)
+ except grpc.RpcError as exc:
+ # The status name is the reason the desk sees; the details go to the log only.
+ log = logger.error if is_version_mismatch(exc) else logger.warning
+ log("[DESK] GetDeskRisk %s %s failed: %s", portfolio_id, _day(day), describe_rpc_error(exc))
+ raise DeskRiskUnavailable(status_name(exc)) from exc
+ except Exception as exc:
+ logger.error(
+ "[DESK] GetDeskRisk %s %s failed: %s", portfolio_id, _day(day), exc, exc_info=True
+ )
+ raise DeskRiskUnavailable("error") from exc
+ return risk_report(reply)
diff --git a/algolens-api/proto/algogators/SOURCE b/algolens-api/proto/algogators/SOURCE
index 0fbb9cf7..dfc1d63a 100644
--- a/algolens-api/proto/algogators/SOURCE
+++ b/algolens-api/proto/algogators/SOURCE
@@ -1,2 +1,2 @@
repo: AlgoGators/trade-ngin
-commit: 94a29bf1fc4e62305b7e2de894dbb68ba4304176
+commit: 7ec6076e7217450bc24fa633464672fcfd338a55
diff --git a/algolens-api/proto/algogators/desk.proto b/algolens-api/proto/algogators/desk.proto
index 45f02b62..37671e2d 100644
--- a/algolens-api/proto/algogators/desk.proto
+++ b/algolens-api/proto/algogators/desk.proto
@@ -1,6 +1,6 @@
// AlgoGators gRPC API
// API: desk
-// Version: 1.2.0
+// Version: 1.3.0
// Compatibility: a client must send x-algogators-api-version: desk=..; the
// server refuses a different major and accepts any minor/patch (docs/design/rpc.md).
//
@@ -45,6 +45,9 @@ import "google/protobuf/timestamp.proto";
// after 10:00 (or on a published day) it is refused. No field changed.
// 1.2.0 2026-10-13 Rescind (contract C8): the desk takes back its own approval before the
// 09:30 send. New RPC and message only; no field changed.
+// 1.3.0 2026-10-14 GetDeskRisk (contract C9): the risk report of the day's qt book and
+// whether the desk's positions breach a risk limit. New RPC and messages
+// only; no field changed.
service DeskService {
// A desk save: run the desk loop on the whole qt_proposal and book the result as qt with
@@ -80,6 +83,15 @@ service DeskService {
// The model run's state for one book and day, read from trading.live_run_metadata, for the
// desk pages (AlgoLens #97). Read-only; D18: no desk window opens unless the run succeeded.
rpc GetRunStatus(RunStatusRequest) returns (RunStatus);
+
+ // Since 1.3.0 (contract C9). The risk report of the day's qt book: what the engine's last run of
+ // it (the model run, a desk save, an approved override) recorded in trading.live_results
+ // risk_detail, the configured limits, and whether the desk's positions breach a risk limit (a
+ // symbol moved by cap, overlay, clip or trim; the overlay cutting the book; the book still over
+ // a limit). Trading rules (sign_close, rounding, buffer, search, hold) are reported, not
+ // breaches. Read-only. AlgoLens refuses the desk's approval while within_limits is false and no
+ // override was approved.
+ rpc GetDeskRisk(DeskRiskRequest) returns (DeskRisk);
}
// Outcome of a command. Shared by RunDeskReply and CommandReply.
@@ -247,3 +259,76 @@ message RunStatus {
// past day caught up, or e-mail disabled).
google.protobuf.Timestamp sent_at = 9;
}
+
+message DeskRiskRequest {
+ string portfolio_id = 1;
+ // "YYYY-MM-DD".
+ string date = 2;
+}
+
+// Which run wrote the day's qt book.
+enum DeskRiskState {
+ DESK_RISK_STATE_UNSPECIFIED = 0;
+ // No qt row for the day yet: nothing to report.
+ DESK_RISK_STATE_NO_BOOK = 1;
+ // The model run's book (no desk save that day): inside the limits by construction.
+ DESK_RISK_STATE_MODEL = 2;
+ // A desk save: the pass ran on the desk's positions.
+ DESK_RISK_STATE_DESK = 3;
+ // An approved override: the ask trades as asked; the pass is a report.
+ DESK_RISK_STATE_OVERRIDE = 4;
+}
+
+message RiskBreach {
+ // The overlay term (R, R_jump, R_shock, L_g, L_n, CAP), or the step that moved a symbol (cap,
+ // overlay, clip, trim), or "refused".
+ string term = 1;
+ // A sentence for the desk.
+ string detail = 2;
+}
+
+message SymbolRisk {
+ string symbol = 1;
+ double asked = 2;
+ double given = 3;
+ // The step that moved the symbol (one_pass::attribute), "none" when given == asked.
+ string moved_by = 4;
+ // "risk" (cap, overlay, clip, trim: a breach), "rule" (sign_close, rounding, buffer, search,
+ // hold: reported only) or "none".
+ string category = 5;
+}
+
+message RiskLimit {
+ // A key of the portfolio's risk.json carver module: max_gross_leverage, max_net_leverage, R_max,
+ // R_jump_max, R_shock_max, per_name_cap, trim_max.
+ string name = 1;
+ double value = 2;
+}
+
+message DeskRisk {
+ DeskRiskState state = 1;
+ // position_overrides.id of the desk save or override decision the report belongs to (0 on a
+ // model or missing book).
+ int64 audit_id = 2;
+ // False when a risk limit bound on the desk's positions.
+ bool within_limits = 3;
+ // True on an approved override: breaches are reported but do not block.
+ bool approved_override = 4;
+ repeated RiskBreach breaches = 5;
+ // The overlay's multiplier m (1.0: no cut) and the term that bound (or "none").
+ double m = 6;
+ string binding_term = 7;
+ // ';'-separated overlay terms still over a limit, empty when none.
+ string over_limit_after_rounding = 8;
+ string over_limit_by_hold = 9;
+ // The overlay could not size the book; the held book was kept.
+ bool refused = 10;
+ repeated SymbolRisk symbols = 11;
+ // Readings of the stored qt book (trading.live_results); unset when not recorded.
+ optional double gross_leverage = 12;
+ optional double net_leverage = 13;
+ optional double margin_cushion = 14;
+ optional double equity_to_margin = 15;
+ // The configured limits (risk.json), empty when the file is not found.
+ repeated RiskLimit limits = 16;
+}
diff --git a/algolens-api/tests/test_qt_desk_client.py b/algolens-api/tests/test_qt_desk_client.py
index 23b802a1..5cab80f6 100644
--- a/algolens-api/tests/test_qt_desk_client.py
+++ b/algolens-api/tests/test_qt_desk_client.py
@@ -9,6 +9,7 @@
from algogators import desk_pb2, desk_pb2_grpc
from algogators.versions import API_VERSIONS
+from algolens.application.qt.ports import DeskRiskUnavailable
from algolens.infrastructure.qt.desk_client import DeskClient
from algolens.infrastructure.rpc import HEADER, reset_channels
@@ -19,6 +20,7 @@ def __init__(self, delay=0.0):
self.headers = []
self.delay = delay
self.refuse = None
+ self.risk = desk_pb2.DeskRisk(state=desk_pb2.DESK_RISK_STATE_MODEL, within_limits=True, m=1.0)
def _reply(self, name, request, reply, context):
time.sleep(self.delay)
@@ -53,6 +55,9 @@ def Rescind(self, request, context):
"Rescind", request, desk_pb2.CommandReply(status=desk_pb2.COMMAND_STATUS_ACCEPTED), context
)
+ def GetDeskRisk(self, request, context):
+ return self._reply("GetDeskRisk", request, self.risk, context)
+
@pytest.fixture
def desk_server():
@@ -101,7 +106,7 @@ def test_each_rpc_carries_the_command_row(desk_server):
"QT_CONSERVATIVE_PORTFOLIO", "2026-10-08", 43)
# Every call carried the version header, built from the vendored protos.
assert servicer.headers == [f"common={API_VERSIONS['common']},desk={API_VERSIONS['desk']}"] * 5
- assert API_VERSIONS["desk"] == "1.2.0"
+ assert API_VERSIONS["desk"] == "1.3.0"
def test_version_refusal_is_logged_and_swallowed(desk_server, caplog):
@@ -129,3 +134,86 @@ def test_slow_engine_times_out_and_is_swallowed(desk_server):
def test_default_timeout_is_three_seconds():
assert DeskClient("x:1").timeout == 3.0
+
+
+# --- GetDeskRisk (C9): a read, not a command ------------------------------------
+
+
+def test_get_desk_risk_maps_the_report(desk_server):
+ servicer, address = desk_server
+ servicer.risk = desk_pb2.DeskRisk(
+ state=desk_pb2.DESK_RISK_STATE_DESK,
+ audit_id=57,
+ within_limits=False,
+ approved_override=False,
+ breaches=[desk_pb2.RiskBreach(term="L_g", detail="gross leverage bound")],
+ m=0.8,
+ binding_term="L_g",
+ over_limit_after_rounding="R",
+ over_limit_by_hold="",
+ refused=False,
+ symbols=[
+ desk_pb2.SymbolRisk(symbol="ZC.v.0", asked=10, given=8, moved_by="overlay", category="risk"),
+ desk_pb2.SymbolRisk(symbol="ZS.v.0", asked=-1, given=0, moved_by="sign_close", category="rule"),
+ ],
+ gross_leverage=2.4,
+ margin_cushion=0.0,
+ limits=[desk_pb2.RiskLimit(name="max_gross_leverage", value=2.0)],
+ )
+
+ report = DeskClient(address).get_desk_risk("QT_CONSERVATIVE_PORTFOLIO", date(2026, 10, 8))
+
+ name, request = servicer.requests[-1]
+ assert (name, request.portfolio_id, request.date) == (
+ "GetDeskRisk", "QT_CONSERVATIVE_PORTFOLIO", "2026-10-08")
+ assert servicer.headers[-1] == f"common={API_VERSIONS['common']},desk={API_VERSIONS['desk']}"
+ assert report == {
+ "state": "desk",
+ "auditId": 57,
+ "withinLimits": False,
+ "approvedOverride": False,
+ "breaches": [{"term": "L_g", "detail": "gross leverage bound"}],
+ "m": pytest.approx(0.8),
+ "bindingTerm": "L_g",
+ "overLimitAfterRounding": "R",
+ "overLimitByHold": "",
+ "refused": False,
+ "symbols": [
+ {"symbol": "ZC.v.0", "asked": 10.0, "given": 8.0, "movedBy": "overlay", "category": "risk"},
+ {"symbol": "ZS.v.0", "asked": -1.0, "given": 0.0, "movedBy": "sign_close", "category": "rule"},
+ ],
+ # Unset optional readings are None; a recorded 0.0 stays 0.0.
+ "grossLeverage": pytest.approx(2.4),
+ "netLeverage": None,
+ "marginCushion": 0.0,
+ "equityToMargin": None,
+ "limits": [{"name": "max_gross_leverage", "value": 2.0}],
+ }
+
+
+@pytest.mark.parametrize(
+ "state, name",
+ [
+ (desk_pb2.DESK_RISK_STATE_NO_BOOK, "no_book"),
+ (desk_pb2.DESK_RISK_STATE_MODEL, "model"),
+ (desk_pb2.DESK_RISK_STATE_OVERRIDE, "override"),
+ ],
+)
+def test_get_desk_risk_names_each_state(desk_server, state, name):
+ servicer, address = desk_server
+ servicer.risk = desk_pb2.DeskRisk(state=state, within_limits=True)
+ assert DeskClient(address).get_desk_risk("P", date(2026, 10, 8))["state"] == name
+
+
+def test_get_desk_risk_raises_when_the_engine_refuses(desk_server):
+ servicer, address = desk_server
+ servicer.refuse = "API version mismatch"
+ with pytest.raises(DeskRiskUnavailable) as exc:
+ DeskClient(address).get_desk_risk("P", date(2026, 10, 8))
+ assert exc.value.reason == "FAILED_PRECONDITION"
+
+
+def test_get_desk_risk_raises_when_the_engine_is_unreachable():
+ with pytest.raises(DeskRiskUnavailable) as exc:
+ DeskClient("127.0.0.1:1", timeout=0.5).get_desk_risk("P", date(2026, 10, 8))
+ assert exc.value.reason in ("UNAVAILABLE", "DEADLINE_EXCEEDED")
diff --git a/algolens-api/tests/test_qt_desk_routes.py b/algolens-api/tests/test_qt_desk_routes.py
index 392f2550..19d81080 100644
--- a/algolens-api/tests/test_qt_desk_routes.py
+++ b/algolens-api/tests/test_qt_desk_routes.py
@@ -10,7 +10,7 @@
from algolens.domain.qt.desk import token_hash
from algolens.infrastructure.config.qt import load_qt_settings
from app import app
-from test_qt_desk_use_cases import NOW, FakeAgent, FakeRepo
+from test_qt_desk_use_cases import NOW, FakeAgent, FakeRepo, risk_report
REGISTRY = {
"QT_CONSERVATIVE_PORTFOLIO": {
@@ -162,6 +162,27 @@ def test_publish_and_state(client, desk):
assert client.post("/portfolio/desk/QT_CONSERVATIVE_PORTFOLIO/publish", json={}, headers=headers).status_code == 409
state = client.get("/portfolio/desk/QT_CONSERVATIVE_PORTFOLIO").get_json()
assert state["publish"]["kind"] == "publish"
+ assert state["risk"]["state"] == "model"
+
+
+def test_publish_outside_the_risk_limits_is_409_with_the_reason(client, desk):
+ repo, agent = desk
+ agent.risk = risk_report("desk", audit_id=0, within_limits=False, breaches=["L_g"])
+ headers = _login(client)
+ response = client.post("/portfolio/desk/QT_CONSERVATIVE_PORTFOLIO/publish", json={}, headers=headers)
+ assert response.status_code == 409
+ assert response.get_json() == {
+ "error": "The 2026-10-08 positions breach risk limits (L_g): save within the limits or "
+ "request an override"
+ }
+ assert repo.rows == []
+
+ agent.risk_down = "UNAVAILABLE"
+ response = client.post("/portfolio/desk/QT_CONSERVATIVE_PORTFOLIO/publish", json={}, headers=headers)
+ assert response.status_code == 409
+ assert response.get_json()["error"] == "The risk check is unavailable (UNAVAILABLE); try again"
+ state = client.get("/portfolio/desk/QT_CONSERVATIVE_PORTFOLIO").get_json()
+ assert state["risk"] == {"unavailable": "UNAVAILABLE"}
def _request_with_token(client, desk, requested_by="desk@x.com"):
@@ -188,6 +209,7 @@ def test_approval_flow_for_an_approver(client, desk):
body = page.get_json()
assert body["request"]["id"] == request["id"]
assert body["viewer"] == {"email": "P@x.com", "approver_role": "president", "is_requester": False}
+ assert body["risk"]["state"] == "model"
decided = client.post(
"/portfolio/desk/approval/decide",
diff --git a/algolens-api/tests/test_qt_desk_use_cases.py b/algolens-api/tests/test_qt_desk_use_cases.py
index 6b4635f5..91e9a315 100644
--- a/algolens-api/tests/test_qt_desk_use_cases.py
+++ b/algolens-api/tests/test_qt_desk_use_cases.py
@@ -10,6 +10,7 @@
DeskGone,
DeskNotFound,
DeskNotSeeded,
+ DeskRiskUnavailable,
)
import algolens.application.qt.use_cases as use_cases
from algolens.application.qt.use_cases import (
@@ -195,10 +196,45 @@ def send_tracking(self):
return self.tracks_sends
+def risk_report(state="model", audit_id=0, within_limits=True, approved_override=False,
+ breaches=()):
+ """A GetDeskRisk report as DeskClient.get_desk_risk returns it."""
+ return {
+ "state": state,
+ "auditId": audit_id,
+ "withinLimits": within_limits,
+ "approvedOverride": approved_override,
+ "breaches": [{"term": t, "detail": f"{t} bound"} for t in breaches],
+ "m": 1.0,
+ "bindingTerm": "none",
+ "overLimitAfterRounding": "",
+ "overLimitByHold": "",
+ "refused": False,
+ "symbols": [],
+ "grossLeverage": None,
+ "netLeverage": None,
+ "marginCushion": None,
+ "equityToMargin": None,
+ "limits": [],
+ }
+
+
class FakeAgent:
- def __init__(self, fail=False):
+ """The commands record their calls; get_desk_risk returns `risk` (the
+ model's book by default) or raises DeskRiskUnavailable(`risk_down`)."""
+
+ def __init__(self, fail=False, risk=None, risk_down=None):
self.calls = []
self.fail = fail
+ self.risk = risk if risk is not None else risk_report()
+ self.risk_down = risk_down
+ self.risk_calls = []
+
+ def get_desk_risk(self, portfolio_id, day):
+ self.risk_calls.append((portfolio_id, day))
+ if self.risk_down:
+ raise DeskRiskUnavailable(self.risk_down)
+ return self.risk
def _record(self, name, *args):
self.calls.append((name, *args))
@@ -297,13 +333,15 @@ def test_a_rescinded_approval_reopens_the_day():
repo = FakeRepo()
repo.insert_command(QT["portfolio_id"], DAY, "publish", "d@x.com")["status"] = "done"
repo.published = None
- state = GetDeskState(repo).execute(QT)
+ state = GetDeskState(repo, FakeAgent()).execute(QT)
assert state["published"] is None
assert state["locked"] is None
- SaveDeskEdit(repo, FakeAgent()).execute(
+ save = SaveDeskEdit(repo, FakeAgent()).execute(
QT, [{"symbol": "ZC.v.0", "quantity": 1, "expected": 3}], "r", "d@x.com"
- )
- assert PublishDesk(repo, FakeAgent()).execute(QT, "d@x.com")["command"]["kind"] == "publish"
+ )["command"]
+ repo.get_command(save["id"])["status"] = "done"
+ agent = FakeAgent(risk=risk_report("desk", audit_id=save["id"]))
+ assert PublishDesk(repo, agent).execute(QT, "d@x.com")["command"]["kind"] == "publish"
def test_symbol_choices_only_for_editable_books():
@@ -321,7 +359,7 @@ def test_state_shows_books_comparison_and_latest_save():
SaveDeskEdit(repo, FakeAgent()).execute(QT, [{"symbol": "ZC.v.0", "quantity": 5, "expected": 3}], "r", "d@x.com")
repo.rows[-1].update(status="done", message="cap bound", result={"book_source": "desk"})
- state = GetDeskState(repo).execute(QT)
+ state = GetDeskState(repo, FakeAgent()).execute(QT)
assert state["date"] == "2026-10-08" and state["seeded"] is True
assert state["latestSave"]["status"] == "done"
@@ -361,7 +399,7 @@ def test_approval_lookup_by_token_shows_the_three_books():
repo = FakeRepo()
request = _emailed_request(repo)
- page = LookupApproval(repo, now=lambda: NOW).execute("tok")
+ page = LookupApproval(repo, FakeAgent(), now=lambda: NOW).execute("tok")
assert page["request"]["id"] == request["id"]
assert page["decision"] is None
@@ -373,9 +411,9 @@ def test_unknown_or_expired_token():
repo = FakeRepo()
_emailed_request(repo, expires=NOW - timedelta(seconds=1))
with pytest.raises(DeskNotFound):
- LookupApproval(repo, now=lambda: NOW).execute("other")
+ LookupApproval(repo, FakeAgent(), now=lambda: NOW).execute("other")
with pytest.raises(DeskGone):
- LookupApproval(repo, now=lambda: NOW).execute("tok")
+ LookupApproval(repo, FakeAgent(), now=lambda: NOW).execute("tok")
with pytest.raises(DeskGone):
DecideOverride(repo, FakeAgent(), now=lambda: NOW).execute("tok", True, "vp@x.com", "vp")
@@ -440,7 +478,7 @@ def test_publish_refuses_a_second_open_or_done_publish_but_allows_a_retry():
def test_publish_state_comes_from_live_run_metadata():
repo = FakeRepo()
repo.published = {"published_by": "d@x.com", "published_at": NOW}
- state = GetDeskState(repo).execute(QT)
+ state = GetDeskState(repo, FakeAgent()).execute(QT)
assert state["published"] == {
"published_by": "d@x.com",
"published_at": NOW.isoformat(),
@@ -463,7 +501,7 @@ def test_a_published_day_refuses_save_override_and_publish():
with pytest.raises(DeskConflict):
PublishDesk(repo, FakeAgent()).execute(QT, "d@x.com")
assert repo.rows == []
- state = GetDeskState(repo, now=lambda: NOW).execute(QT)
+ state = GetDeskState(repo, FakeAgent(), now=lambda: NOW).execute(QT)
assert state["locked"] == "published"
@@ -480,7 +518,7 @@ def test_an_approval_of_a_changed_proposal_is_refused():
_emailed_request(repo)
SaveDeskEdit(repo, FakeAgent()).execute(
QT, [{"symbol": "ZC.v.0", "quantity": 1, "expected": 3}], "r", "d@x.com")
- page = LookupApproval(repo, now=lambda: NOW).execute("tok")
+ page = LookupApproval(repo, FakeAgent(), now=lambda: NOW).execute("tok")
assert page["snapshotMatches"] is False and page["snapshot"][0]["quantity"] == 3
with pytest.raises(DeskConflict):
DecideOverride(repo, FakeAgent(), now=lambda: NOW).execute("tok", True, "p@x.com", "president")
@@ -489,10 +527,10 @@ def test_an_approval_of_a_changed_proposal_is_refused():
def test_state_shows_the_open_request_and_the_publishing_lock():
repo = FakeRepo()
request = _emailed_request(repo)
- state = GetDeskState(repo, now=lambda: NOW).execute(QT)
+ state = GetDeskState(repo, FakeAgent(), now=lambda: NOW).execute(QT)
assert state["openOverrideRequestId"] == request["id"] and state["locked"] is None
PublishDesk(repo, FakeAgent()).execute(QT, "d@x.com")
- assert GetDeskState(repo, now=lambda: NOW).execute(QT)["locked"] == "publishing"
+ assert GetDeskState(repo, FakeAgent(), now=lambda: NOW).execute(QT)["locked"] == "publishing"
def test_a_stale_save_names_the_symbols():
@@ -603,7 +641,7 @@ def test_save_override_request_and_decision_are_closed_from_10_new_york():
def test_desk_state_carries_the_server_time_and_new_york_deadlines():
- state = GetDeskState(FakeRepo(), now=lambda: _ny(DAY, 9, 0)).execute(QT)
+ state = GetDeskState(FakeRepo(), FakeAgent(), now=lambda: _ny(DAY, 9, 0)).execute(QT)
assert state["serverTime"] == "2026-10-08T13:00:00+00:00"
assert state["deadlines"] == {
"timezone": "America/New_York",
@@ -619,17 +657,17 @@ def test_desk_state_carries_the_server_time_and_new_york_deadlines():
def test_desk_state_deadlines_on_a_dst_day_a_weekend_and_before_the_first_book():
- nov1 = GetDeskState(FakeRepo(day=date(2026, 11, 1)),
+ nov1 = GetDeskState(FakeRepo(day=date(2026, 11, 1)), FakeAgent(),
now=lambda: _ny(date(2026, 11, 1), 10, 0)).execute(QT)
assert nov1["deadlines"]["approveBy"] == "2026-11-01T09:30:00-05:00"
assert nov1["deadlines"]["approvalClosed"] is True
# No book yet (a Saturday): the deadlines are today's.
- none = GetDeskState(FakeRepo(day=None), now=lambda: _ny(date(2026, 10, 10), 6, 0)).execute(QT)
+ none = GetDeskState(FakeRepo(day=None), FakeAgent(), now=lambda: _ny(date(2026, 10, 10), 6, 0)).execute(QT)
assert none["deadlines"]["bookDate"] == "2026-10-10"
assert none["deadlines"]["approveBy"] == "2026-10-10T09:30:00-04:00"
assert none["deadlines"]["approvalClosed"] is False
# The desk still shows yesterday's book: today's deadlines come separately.
- old = GetDeskState(FakeRepo(), now=lambda: _ny(DAY + timedelta(days=1), 6, 0)).execute(QT)
+ old = GetDeskState(FakeRepo(), FakeAgent(), now=lambda: _ny(DAY + timedelta(days=1), 6, 0)).execute(QT)
assert old["deadlines"]["bookDate"] == "2026-10-08"
assert old["deadlines"]["today"] == "2026-10-09"
assert old["deadlines"]["todayApproveBy"] == "2026-10-09T09:30:00-04:00"
@@ -640,7 +678,7 @@ def test_desk_state_reads_publish_source_and_sent_at():
repo = FakeRepo()
repo.published = {"published_by": "d@x.com", "published_at": _ny(DAY, 9, 0),
"publish_source": "desk", "sent_at": _ny(DAY, 9, 30, 4)}
- published = GetDeskState(repo, now=lambda: _ny(DAY, 9, 31)).execute(QT)["published"]
+ published = GetDeskState(repo, FakeAgent(), now=lambda: _ny(DAY, 9, 31)).execute(QT)["published"]
assert published["publish_source"] == "desk"
assert published["sent_at"] == "2026-10-08T09:30:04-04:00"
@@ -659,7 +697,7 @@ def test_without_the_026_columns_the_source_is_read_from_published_by(published_
repo = FakeRepo()
repo.tracks_sends = False
repo.published = {"published_by": published_by, "published_at": _ny(DAY, 10, 1)}
- state = GetDeskState(repo, now=lambda: _ny(DAY, 10, 5)).execute(QT)
+ state = GetDeskState(repo, FakeAgent(), now=lambda: _ny(DAY, 10, 5)).execute(QT)
assert state["published"]["publish_source"] == source
assert state["published"]["sent_at"] is None
assert state["sendTracked"] is False
@@ -668,7 +706,7 @@ def test_without_the_026_columns_the_source_is_read_from_published_by(published_
def test_the_approval_page_shows_the_deadline():
repo = FakeRepo()
_emailed_request(repo)
- page = LookupApproval(repo, now=lambda: _ny(DAY, 9, 0)).execute("tok")
+ page = LookupApproval(repo, FakeAgent(), now=lambda: _ny(DAY, 9, 0)).execute("tok")
assert page["deadlines"]["approveBy"] == "2026-10-08T09:30:00-04:00"
assert page["deadlines"]["fallbackAt"] == "2026-10-08T10:00:00-04:00"
assert page["serverTime"] == "2026-10-08T13:00:00+00:00"
@@ -778,9 +816,9 @@ def test_rescind_before_the_proposal_is_seeded_is_409():
def test_desk_state_carries_the_latest_rescind_and_a_done_rescind_reopens_the_day():
repo, agent = _approved()
now = lambda: _ny(DAY, 9, 0) # noqa: E731
- assert GetDeskState(repo, now=now).execute(QT)["rescind"] is None
+ assert GetDeskState(repo, FakeAgent(), now=now).execute(QT)["rescind"] is None
command = RescindDesk(repo, agent, now=now).execute(QT, "d@x.com")["command"]
- state = GetDeskState(repo, now=now).execute(QT)
+ state = GetDeskState(repo, FakeAgent(), now=now).execute(QT)
assert state["rescind"]["id"] == command["id"] and state["rescind"]["status"] == "pending"
assert state["locked"] == "published"
# The engine clears published_at and finishes the row done.
@@ -788,8 +826,127 @@ def test_desk_state_carries_the_latest_rescind_and_a_done_rescind_reopens_the_da
status="done", message="approval of 2026-10-08 rescinded",
result={"rescinded": {"published_by": "d@x.com"}})
repo.published = None
- state = GetDeskState(repo, now=now).execute(QT)
+ state = GetDeskState(repo, FakeAgent(), now=now).execute(QT)
assert state["rescind"]["status"] == "done" and state["locked"] is None
assert state["published"] is None
second = PublishDesk(repo, FakeAgent(), now=now).execute(QT, "d@x.com")["command"]
assert second["kind"] == "publish"
+
+
+# --- the risk check (C9) ---------------------------------------------------------
+
+
+def _saved(repo, status="done"):
+ """A desk save of the day, finished with `status`."""
+ current = next(r["quantity"] for r in repo.books["qt_proposal"] if r["symbol"] == "ZC.v.0")
+ save = SaveDeskEdit(repo, FakeAgent()).execute(
+ QT, [{"symbol": "ZC.v.0", "quantity": current + 1, "expected": current}], "r", "d@x.com"
+ )["command"]
+ repo.get_command(save["id"])["status"] = status
+ return save["id"]
+
+
+def test_desk_state_carries_the_risk_report():
+ repo = FakeRepo()
+ agent = FakeAgent(risk=risk_report("desk", audit_id=4, within_limits=False, breaches=["L_g"]))
+ state = GetDeskState(repo, agent).execute(QT)
+ assert state["risk"]["state"] == "desk" and state["risk"]["withinLimits"] is False
+ assert agent.risk_calls == [("QT_CONSERVATIVE_PORTFOLIO", DAY)]
+
+
+def test_desk_state_says_when_the_risk_check_is_unavailable():
+ state = GetDeskState(FakeRepo(), FakeAgent(risk_down="UNAVAILABLE")).execute(QT)
+ assert state["risk"] == {"unavailable": "UNAVAILABLE"}
+ # The rest of the state is unchanged.
+ assert state["seeded"] is True and state["locked"] is None
+
+
+def test_desk_state_has_no_risk_before_the_first_book():
+ agent = FakeAgent()
+ state = GetDeskState(FakeRepo(day=None), agent).execute(QT)
+ assert state["risk"] is None and agent.risk_calls == []
+
+
+def test_approve_is_refused_while_the_positions_breach_a_risk_limit():
+ repo = FakeRepo()
+ save_id = _saved(repo)
+ agent = FakeAgent(risk=risk_report("desk", audit_id=save_id, within_limits=False,
+ breaches=["L_g", "cap", "L_g"]))
+ with pytest.raises(DeskConflict) as exc:
+ PublishDesk(repo, agent).execute(QT, "d@x.com")
+ assert str(exc.value) == (
+ "The 2026-10-08 positions breach risk limits (L_g, cap): save within the limits or "
+ "request an override"
+ )
+ assert [r["kind"] for r in repo.rows] == ["save"]
+ assert agent.calls == []
+
+
+def test_an_approved_override_waives_the_breaches():
+ repo = FakeRepo()
+ _saved(repo)
+ agent = FakeAgent(risk=risk_report("override", audit_id=99, within_limits=False,
+ approved_override=True, breaches=["L_g"]))
+ assert PublishDesk(repo, agent).execute(QT, "d@x.com")["command"]["kind"] == "publish"
+
+
+@pytest.mark.parametrize("state", ["model", "no_book"])
+def test_the_model_book_and_no_book_pass(state):
+ repo = FakeRepo()
+ agent = FakeAgent(risk=risk_report(state))
+ assert PublishDesk(repo, agent).execute(QT, "d@x.com")["command"]["kind"] == "publish"
+ assert agent.calls == [("Publish", 1)]
+
+
+def test_positions_within_the_limits_pass():
+ repo = FakeRepo()
+ save_id = _saved(repo)
+ agent = FakeAgent(risk=risk_report("desk", audit_id=save_id))
+ assert PublishDesk(repo, agent).execute(QT, "d@x.com")["command"]["kind"] == "publish"
+
+
+def test_approve_fails_closed_when_the_risk_check_is_unavailable():
+ repo = FakeRepo()
+ with pytest.raises(DeskConflict, match=r"The risk check is unavailable \(UNAVAILABLE\); try again"):
+ PublishDesk(repo, FakeAgent(risk_down="UNAVAILABLE")).execute(QT, "d@x.com")
+ assert repo.rows == []
+
+
+def test_a_report_of_an_older_save_is_refused():
+ repo = FakeRepo()
+ first = _saved(repo)
+ second = _saved(repo)
+ agent = FakeAgent(risk=risk_report("desk", audit_id=first))
+ with pytest.raises(DeskConflict, match=f"has not caught up with save #{second}; try again"):
+ PublishDesk(repo, agent).execute(QT, "d@x.com")
+ # A failed later save leaves the last done one as the book.
+ repo.get_command(second)["status"] = "failed"
+ assert PublishDesk(repo, agent).execute(QT, "d@x.com")["command"]["kind"] == "publish"
+
+
+@pytest.mark.parametrize("status", ["pending", "running"])
+def test_approve_is_refused_while_a_save_is_running(status):
+ repo = FakeRepo()
+ save_id = _saved(repo, status)
+ agent = FakeAgent()
+ with pytest.raises(DeskConflict, match=rf"A save is still running \(#{save_id}\)"):
+ PublishDesk(repo, agent).execute(QT, "d@x.com")
+ assert agent.risk_calls == []
+
+
+def test_a_published_day_is_refused_as_published_not_by_the_risk_check():
+ repo = FakeRepo()
+ repo.published = {"published_by": "d@x.com", "published_at": NOW}
+ with pytest.raises(DeskConflict, match="already published"):
+ PublishDesk(repo, FakeAgent(risk_down="UNAVAILABLE")).execute(QT, "d@x.com")
+
+
+def test_the_approval_page_carries_the_risk_report():
+ repo = FakeRepo()
+ request = repo.insert_command(QT["portfolio_id"], DAY, "override_request", "d@x.com", "why")
+ request.update(token_hash=token_hash("tok"), token_expires_at=NOW + timedelta(hours=1))
+ report = risk_report("desk", audit_id=1, within_limits=False, breaches=["L_g"])
+ page = LookupApproval(repo, FakeAgent(risk=report), now=lambda: NOW).execute("tok")
+ assert page["risk"] == report
+ down = LookupApproval(repo, FakeAgent(risk_down="UNAVAILABLE"), now=lambda: NOW).execute("tok")
+ assert down["risk"] == {"unavailable": "UNAVAILABLE"}
diff --git a/algolens-api/tests/test_qt_integration.py b/algolens-api/tests/test_qt_integration.py
index af111a15..32d408c3 100644
--- a/algolens-api/tests/test_qt_integration.py
+++ b/algolens-api/tests/test_qt_integration.py
@@ -235,6 +235,12 @@ def rescind(self, command):
def record_decision(self, decision, token):
return self._lost(decision, token)
+ def get_desk_risk(self, portfolio_id, day):
+ # Not a command: the risk report of the model's book, inside the limits, so the
+ # approval gate (C9) passes; the gate itself is tested against fakes.
+ return {"state": "model", "auditId": 0, "withinLimits": True, "approvedOverride": False,
+ "breaches": [], "symbols": [], "limits": []}
+
def desk_repo(factory):
from algolens.infrastructure.qt.repositories import PostgresDeskRepository
@@ -453,7 +459,7 @@ def test_desk_state_compares_asked_and_given_with_moved_by(qt_db):
result=psycopg2.extras.Json({"book_source": "desk"}),
)
- state = GetDeskState(desk_repo(qt_db)).execute(entry(qt_db))
+ state = GetDeskState(desk_repo(qt_db), NullAgent()).execute(entry(qt_db))
assert state["date"] == D2.isoformat() and state["seeded"] is True
zc = next(r for r in state["comparison"] if r["symbol"] == "ZC.v.0")
@@ -487,7 +493,7 @@ def test_override_decision_is_recorded_once(qt_db):
request_id = _emailed(qt_db)
agent = NullAgent()
- page = LookupApproval(desk_repo(qt_db)).execute("tok-123")
+ page = LookupApproval(desk_repo(qt_db), NullAgent()).execute("tok-123")
assert page["request"]["id"] == request_id
assert {r["symbol"] for r in page["table"]} == {"ZC.v.0", "ZS.v.0"}
@@ -516,7 +522,7 @@ def test_override_decision_refusals(qt_db):
def test_expired_approval_link(qt_db):
_emailed(qt_db, expires_in=timedelta(seconds=-1))
with pytest.raises(DeskGone):
- LookupApproval(desk_repo(qt_db)).execute("tok-123")
+ LookupApproval(desk_repo(qt_db), NullAgent()).execute("tok-123")
with pytest.raises(DeskGone):
DecideOverride(desk_repo(qt_db), NullAgent()).execute("tok-123", True, "p@x.com", "president")
@@ -538,7 +544,7 @@ def test_publish_row_and_publish_state(qt_db):
"VALUES (%s, %s, %s, 'desk@x.com', '2026-10-08T21:00:00Z')",
(D2, STRATEGY, QT),
)
- state = GetDeskState(desk_repo(qt_db)).execute(entry(qt_db))
+ state = GetDeskState(desk_repo(qt_db), NullAgent()).execute(entry(qt_db))
assert state["published"]["published_by"] == "desk@x.com"
assert state["publish"]["status"] == "done"
@@ -636,7 +642,7 @@ def test_approval_of_a_changed_proposal_is_refused_but_rejection_is_not(qt_db):
SaveDeskEdit(desk_repo(qt_db), NullAgent()).execute(
entry(qt_db), [{"symbol": "ZC.v.0", "quantity": 9, "expected": 3}], "r", "d@x.com"
)
- page = LookupApproval(desk_repo(qt_db)).execute("tok-123")
+ page = LookupApproval(desk_repo(qt_db), NullAgent()).execute("tok-123")
assert page["snapshotMatches"] is False
assert page["snapshot"][0]["quantity"] == 3
@@ -648,7 +654,7 @@ def test_approval_of_a_changed_proposal_is_refused_but_rejection_is_not(qt_db):
def test_approval_page_shows_the_snapshot(qt_db):
_emailed(qt_db)
- page = LookupApproval(desk_repo(qt_db)).execute("tok-123")
+ page = LookupApproval(desk_repo(qt_db), NullAgent()).execute("tok-123")
assert page["snapshotMatches"] is True
assert [(r["symbol"], r["quantity"]) for r in page["snapshot"]] == [("ZC.v.0", 3), ("ZS.v.0", -2)]
@@ -690,7 +696,7 @@ def test_a_published_day_is_frozen_even_without_a_publish_row(qt_db):
assert [c["kind"] for c in commands(qt_db)] == ["override_request"]
assert float(proposal_rows(qt_db)["ZC.v.0"]["quantity"]) == 3
- state = GetDeskState(repo).execute(entry(qt_db))
+ state = GetDeskState(repo, NullAgent()).execute(entry(qt_db))
assert state["locked"] == "published"
assert state["published"]["published_by"] == "system:non-trading-day"
@@ -704,7 +710,7 @@ def test_save_and_override_are_refused_while_a_publish_is_open(qt_db):
entry(qt_db), [{"symbol": "ZC.v.0", "quantity": 1, "expected": 3}], "r", "d@x.com")
with pytest.raises(DeskConflict, match="publish"):
RequestOverride(repo, NullAgent()).execute(entry(qt_db), "breach", "d@x.com")
- assert GetDeskState(repo).execute(entry(qt_db))["locked"] == "publishing"
+ assert GetDeskState(repo, NullAgent()).execute(entry(qt_db))["locked"] == "publishing"
def test_a_stale_save_is_refused_and_lists_the_changed_symbols(qt_db):
@@ -805,7 +811,7 @@ def test_after_025_algolens_only_inserts_into_the_command_log(qt_db):
def test_desk_state_reports_the_open_override_request(qt_db):
request_id = _emailed(qt_db)
- state = GetDeskState(desk_repo(qt_db)).execute(entry(qt_db))
+ state = GetDeskState(desk_repo(qt_db), NullAgent()).execute(entry(qt_db))
assert state["openOverrideRequestId"] == request_id
assert state["locked"] is None
assert state["overrideRequests"][0]["payload"]["proposal_sha256"]
@@ -920,7 +926,7 @@ def test_desk_state_reads_publish_source_and_sent_at_once_026_is_applied(qt_db):
(D2, STRATEGY, QT),
)
- state = GetDeskState(desk_repo(qt_db)).execute(entry(qt_db))
+ state = GetDeskState(desk_repo(qt_db), NullAgent()).execute(entry(qt_db))
assert state["sendTracked"] is with_026
assert state["published"]["published_by"] == "desk@x.com"
@@ -940,7 +946,7 @@ def test_a_fallback_publish_reads_as_fallback_with_or_without_026(qt_db):
if has_026(qt_db.version):
_exec(qt_db, "UPDATE trading.live_run_metadata SET publish_source = 'fallback', "
"sent_at = now()")
- state = GetDeskState(desk_repo(qt_db)).execute(entry(qt_db))
+ state = GetDeskState(desk_repo(qt_db), NullAgent()).execute(entry(qt_db))
assert state["published"]["publish_source"] == "fallback"
assert (state["published"]["sent_at"] is not None) is has_026(qt_db.version)
assert state["locked"] == "published"
@@ -1013,21 +1019,25 @@ def test_rescind_reopens_the_day_for_edits_and_a_second_approval(qt_db):
assert (command["kind"], command["status"], command["date"]) == ("rescind", "pending", "2026-10-08")
with pytest.raises(DeskConflict, match="rescind of the 2026-10-08 book is already pending"):
rescind.execute(entry(qt_db), "desk@x.com")
- state = GetDeskState(repo).execute(entry(qt_db))
+ state = GetDeskState(repo, NullAgent()).execute(entry(qt_db))
assert state["locked"] == "published" and state["rescind"]["id"] == command["id"]
engine_rescinds(qt_db, command["id"])
- state = GetDeskState(repo).execute(entry(qt_db))
+ state = GetDeskState(repo, NullAgent()).execute(entry(qt_db))
assert state["locked"] is None and state["published"] is None
assert state["rescind"]["status"] == "done"
# The day takes edits and a new approval; 029 dropped the one-done-publish index.
- SaveDeskEdit(repo, NullAgent()).execute(
+ save = SaveDeskEdit(repo, NullAgent()).execute(
entry(qt_db), [{"symbol": "ZC.v.0", "quantity": 1, "expected": 3}], "r", "desk@x.com")
+ # C9: no approval while the save is still running.
+ with pytest.raises(DeskConflict, match="A save is still running"):
+ PublishDesk(repo, NullAgent()).execute(entry(qt_db), "desk@x.com")
+ engine_finishes(qt_db, save["command"]["id"])
second = PublishDesk(repo, NullAgent()).execute(entry(qt_db), "desk@x.com")["command"]
engine_publishes(qt_db, second["id"])
assert [(c["kind"], c["status"]) for c in commands(qt_db)] == [
- ("publish", "done"), ("rescind", "done"), ("save", "pending"), ("publish", "done")]
+ ("publish", "done"), ("rescind", "done"), ("save", "done"), ("publish", "done")]
def test_rescind_refuses_what_is_not_an_unsent_desk_approval(qt_db):
diff --git a/algolens-frontend/src/components/qt/ApprovalPage.tsx b/algolens-frontend/src/components/qt/ApprovalPage.tsx
index 472f417f..32f9f353 100644
--- a/algolens-frontend/src/components/qt/ApprovalPage.tsx
+++ b/algolens-frontend/src/components/qt/ApprovalPage.tsx
@@ -10,6 +10,7 @@ import {
type DeskCommand,
type DeskDeadlines,
} from '../../domain/qt/desk';
+import { RiskReport } from './RiskReport';
/**
* The request day's deadline: the desk approves by 09:30 New York; from 10:00
@@ -184,6 +185,17 @@ export function ApprovalPage({ onDone }: { onDone: () => void }) {
)}
+ {page.risk && (
+ <>
+
Risk check of the desk's book
+
+ What the engine's risk pass did to the desk's positions on {page.request.date}. An approval trades the
+ request's book as asked, past these limits.
+
+
+ >
+ )}
+
The books now
diff --git a/algolens-frontend/src/components/qt/DeskPanel.tsx b/algolens-frontend/src/components/qt/DeskPanel.tsx
index 087b185b..6dec6c80 100644
--- a/algolens-frontend/src/components/qt/DeskPanel.tsx
+++ b/algolens-frontend/src/components/qt/DeskPanel.tsx
@@ -18,6 +18,7 @@ import {
publishSourceOf,
rescindBlockedReason,
rescindConfirmText,
+ riskBreached,
serverClockOffsetMs,
type ComparisonRow,
type DayPhase,
@@ -26,6 +27,7 @@ import {
type DeskState,
} from '../../domain/qt/desk';
import { DeskEditForm } from './DeskEditForm';
+import { RiskReport } from './RiskReport';
import { SettingsEditor } from './SettingsEditor';
interface DeskPanelProps {
@@ -198,6 +200,7 @@ export function DeskPanel({ portfolioId, book, reloadBook }: DeskPanelProps) {
// Only the newest state request may land: an older answer (a slow one, or
// one for the portfolio shown before) is dropped.
const requestSeq = useRef(0);
+ const overrideSection = useRef(null);
const load = useCallback(async () => {
const seq = ++requestSeq.current;
@@ -284,6 +287,9 @@ export function DeskPanel({ portfolioId, book, reloadBook }: DeskPanelProps) {
// ... and only to the user who approved it.
const rescindAllowed = rescindBlockedReason(state, serverNow, user?.email ?? null) === null;
const overrideBlocked = overrideBlockedReason(state, serverNow);
+ // C9: Approve is closed by a risk breach (not by a lock, the cutoff or an unreadable check) and an
+ // override may be requested: point the desk at the override form.
+ const riskPrompt = riskBreached(state.risk) && approveBlocked !== null && overrideBlocked === null;
const act = async (fn: () => Promise) => {
setBusy(true);
setActionError(null);
@@ -332,6 +338,34 @@ export function DeskPanel({ portfolioId, book, reloadBook }: DeskPanelProps) {
{approveBlocked}
)}
+ {riskPrompt && (
+
+
+ Positions are outside the risk limits. Request an override to trade them as asked, or edit them back
+ within the limits.
+
+ {!overrideOpen && (
+
+ )}
+
+ )}
{rescindAllowed && !confirmRescind && (
+
-
+
Override
{state.overrideRequests.map(r => (
diff --git a/algolens-frontend/src/components/qt/RiskReport.tsx b/algolens-frontend/src/components/qt/RiskReport.tsx
new file mode 100644
index 00000000..81f636f6
--- /dev/null
+++ b/algolens-frontend/src/components/qt/RiskReport.tsx
@@ -0,0 +1,146 @@
+import React from 'react';
+import { useTheme } from '../../adapters/react/ThemeContext';
+import {
+ formatRiskValue,
+ riskBadge,
+ riskBindingLine,
+ riskMovedSymbols,
+ riskReadings,
+ riskUnavailable,
+ type DeskRiskResult,
+ type RiskBadgeTone,
+} from '../../domain/qt/desk';
+
+function badgeTone(tone: RiskBadgeTone, dark: boolean): string {
+ switch (tone) {
+ case 'ok':
+ return dark ? 'bg-green-950 border-green-800 text-green-200' : 'bg-green-50 border-green-300 text-green-800';
+ case 'breach':
+ return dark ? 'bg-red-950 border-red-800 text-red-200' : 'bg-red-50 border-red-300 text-red-800';
+ case 'waived':
+ return dark ? 'bg-amber-950 border-amber-800 text-amber-200' : 'bg-amber-50 border-amber-300 text-amber-900';
+ default:
+ return dark ? 'bg-gray-900 border-gray-700 text-gray-200' : 'bg-gray-50 border-gray-300 text-gray-800';
+ }
+}
+
+/**
+ * The engine's risk check of the day's qt book (contract C9): whether the
+ * desk's positions are within the risk limits, which limit bound and by how
+ * much, the configured limits next to the stored book, and the symbols a step
+ * moved. Risk steps (cap, overlay, clip, trim) are breaches; trading rules
+ * (sign_close, rounding, buffer, search, hold) are shown but are not.
+ */
+export function RiskReport({ risk }: { risk: DeskRiskResult | null | undefined }) {
+ const { theme } = useTheme();
+ const dark = theme === 'dark';
+ if (!risk) return null;
+ const muted = dark ? 'text-gray-400' : 'text-gray-500';
+ const rule = dark ? 'border-t border-gray-800' : 'border-t border-gray-200';
+ const badge = riskBadge(risk);
+ const header =