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 =