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 && (
+ + + + + + + + + {readings.map(r => ( + + + + + + ))} + +
MeasureStored bookLimit
{r.label}{formatRiskValue(r.reading, r.format)}{formatRiskValue(r.limit, r.format)}
+ + )} + + {moved.length > 0 && ( +
+ + + + + + + + + + + {moved.map(s => ( + + + + + + + + ))} + +
SymbolAskedGivenMoved by +
{s.symbol}{s.asked}{s.given}{s.movedBy} + {s.category === 'risk' ? ( + + risk + + ) : ( + + rule trading rule, + not a breach + + )} +
+
+ )} + + ); +} diff --git a/algolens-frontend/src/domain/qt/desk.test.ts b/algolens-frontend/src/domain/qt/desk.test.ts index 5a1375ee..567ff036 100644 --- a/algolens-frontend/src/domain/qt/desk.test.ts +++ b/algolens-frontend/src/domain/qt/desk.test.ts @@ -28,6 +28,12 @@ import { rescindBlockedReason, rescindConfirmText, rescindOpen, + formatRiskValue, + riskBadge, + riskBindingLine, + riskBreachReason, + riskMovedSymbols, + riskReadings, serverClockOffsetMs, shouldKeepPolling, snapshotBySymbol, @@ -35,6 +41,7 @@ import { type BookPosition, type DeskCommand, type DeskDeadlines, + type DeskRisk, type DeskState, } from './desk'; @@ -558,3 +565,122 @@ describe('approvalTokenFrom', () => { expect(approvalTokenFrom('?token=')).toBeNull(); }); }); + +const risk = (over: Partial): DeskRisk => ({ + state: 'desk', + auditId: 7, + withinLimits: true, + approvedOverride: false, + breaches: [], + m: 1, + bindingTerm: 'none', + overLimitAfterRounding: '', + overLimitByHold: '', + refused: false, + symbols: [], + grossLeverage: null, + netLeverage: null, + marginCushion: null, + equityToMargin: null, + limits: [], + ...over, +}); + +const breached = risk({ + withinLimits: false, + breaches: [ + { term: 'L_g', detail: 'gross leverage bound' }, + { term: 'cap', detail: 'ZC.v.0 capped' }, + { term: 'L_g', detail: 'again' }, + ], + m: 0.8, + bindingTerm: 'L_g', +}); + +describe('the risk check (C9)', () => { + it('blocks a breach without an approved override', () => { + expect(riskBreachReason(breached)).toBe( + 'The positions breach risk limits (L_g, cap): save within the limits or request an override', + ); + }); + + it('passes no report, the model book, no book, an override and positions within the limits', () => { + expect(riskBreachReason(null)).toBeNull(); + expect(riskBreachReason(undefined)).toBeNull(); + expect(riskBreachReason(risk({ state: 'model' }))).toBeNull(); + expect(riskBreachReason(risk({ state: 'no_book' }))).toBeNull(); + expect(riskBreachReason({ ...breached, state: 'override', approvedOverride: true })).toBeNull(); + expect(riskBreachReason(risk({}))).toBeNull(); + }); + + it('fails closed when the check cannot be read', () => { + expect(riskBreachReason({ unavailable: 'UNAVAILABLE' })).toBe('The risk check is unavailable: UNAVAILABLE'); + }); + + it('closes Approve after the lock and cutoff checks', () => { + expect(approveBlockedReason(deskState({ risk: breached }))).toMatch(/^The positions breach risk limits/); + expect(approveBlockedReason(deskState({ risk: { unavailable: 'UNAVAILABLE' } }))).toMatch(/unavailable/); + expect(approveBlockedReason(deskState({ risk: risk({}) }))).toBeNull(); + // A lock wins over the risk check. + expect( + approveBlockedReason(deskState({ risk: breached, publish: command({ kind: 'publish', status: 'pending' }) })), + ).toBe(LOCKED_APPROVING); + }); + + it('names the report in its badge', () => { + expect(riskBadge(risk({}))).toEqual({ tone: 'ok', label: 'Within risk limits' }); + expect(riskBadge(breached)).toEqual({ tone: 'breach', label: 'Outside risk limits — override required' }); + expect(riskBadge({ ...breached, state: 'override', approvedOverride: true })).toEqual({ + tone: 'waived', + label: 'Override approved — breaches waived', + }); + expect(riskBadge(risk({ state: 'model' }))).toEqual({ tone: 'ok', label: 'Model book — within limits' }); + expect(riskBadge({ unavailable: 'UNAVAILABLE' }).tone).toBe('muted'); + }); + + it('says which limit bound and how far the book was cut', () => { + expect(riskBindingLine(breached)).toBe('Gross leverage bound: positions cut to 80%'); + expect(riskBindingLine(risk({}))).toBeNull(); + expect(riskBindingLine(risk({ bindingTerm: 'R_jump', m: 0.5 }))).toBe('Jump risk bound: positions cut to 50%'); + }); + + it('puts the configured limits next to the stored book', () => { + const rows = riskReadings( + risk({ + grossLeverage: 2.4, + netLeverage: -0.5, + marginCushion: 0.42, + equityToMargin: 3.1, + limits: [ + { name: 'max_gross_leverage', value: 2 }, + { name: 'max_net_leverage', value: 1 }, + { name: 'R_max', value: 0.2 }, + ], + }), + ); + expect(rows.map(r => [r.label, r.reading, r.limit, r.over])).toEqual([ + ['Gross leverage', 2.4, 2, true], + ['Net leverage', -0.5, 1, false], + ['Margin cushion', 0.42, null, false], + ['Equity / margin', 3.1, null, false], + ['R_max', null, 0.2, false], + ]); + expect(formatRiskValue(2.4, 'leverage')).toBe('2.40x'); + expect(formatRiskValue(0.42, 'percent')).toBe('42.0%'); + expect(formatRiskValue(null, 'ratio')).toBe('-'); + expect(formatRiskValue(0.2, 'number')).toBe('0.2'); + }); + + it('lists the moved symbols, risk steps first', () => { + const moved = riskMovedSymbols( + risk({ + symbols: [ + { symbol: 'ZS.v.0', asked: -1, given: 0, movedBy: 'sign_close', category: 'rule' }, + { symbol: 'ZW.v.0', asked: 2, given: 2, movedBy: 'none', category: 'none' }, + { symbol: 'ZC.v.0', asked: 10, given: 8, movedBy: 'overlay', category: 'risk' }, + ], + }), + ); + expect(moved.map(s => s.symbol)).toEqual(['ZC.v.0', 'ZS.v.0']); + }); +}); diff --git a/algolens-frontend/src/domain/qt/desk.ts b/algolens-frontend/src/domain/qt/desk.ts index 078c52d4..6c9d9cb3 100644 --- a/algolens-frontend/src/domain/qt/desk.ts +++ b/algolens-frontend/src/domain/qt/desk.ts @@ -97,8 +97,60 @@ export interface DeskState { locked?: 'published' | 'publishing' | null; /** The day's override request still in play (C2), if any. */ openOverrideRequestId?: number | null; + /** The engine's risk check of the day's qt book (C9); null before the first book. */ + risk?: DeskRiskResult | null; } +/** Which run wrote the day's qt book (desk.proto DeskRiskState). */ +export type DeskRiskState = 'no_book' | 'model' | 'desk' | 'override' | 'unspecified'; + +export interface RiskBreach { + /** An overlay term (R, R_jump, R_shock, L_g, L_n, CAP), a step (cap, overlay, clip, trim) or "refused". */ + term: string; + detail: string; +} + +export interface SymbolRisk { + symbol: string; + asked: number; + given: number; + movedBy: string; + /** "risk" (a breach), "rule" (a trading rule: reported, not a breach) or "none". */ + category: string; +} + +/** The engine's risk report of the day's qt book (GetDeskRisk, contract C9). */ +export interface DeskRisk { + state: DeskRiskState; + /** The desk save (or override decision) the report belongs to; 0 on the model's book. */ + auditId: number; + withinLimits: boolean; + /** An approved override: the ask trades as asked, the breaches are waived. */ + approvedOverride: boolean; + breaches: RiskBreach[]; + /** The overlay's multiplier (1: no cut) and the term that bound ("none"). */ + m: number; + bindingTerm: string; + overLimitAfterRounding: string; + overLimitByHold: string; + refused: boolean; + symbols: SymbolRisk[]; + /** Readings of the stored qt book; null when not recorded. */ + grossLeverage: number | null; + netLeverage: number | null; + marginCushion: number | null; + equityToMargin: number | null; + /** The portfolio's configured limits (risk.json). */ + limits: { name: string; value: number }[]; +} + +/** The risk check could not be read (the engine is down): approval is refused. */ +export interface DeskRiskUnavailable { + unavailable: string; +} + +export type DeskRiskResult = DeskRisk | DeskRiskUnavailable; + /** One row of the book an override request snapshotted (C1). */ export interface SnapshotRow { strategy_name: string; @@ -518,10 +570,130 @@ export function deskDayStatus(state: DeskState, nowMs: number): DayStatus { ); } +export function riskUnavailable(risk: DeskRiskResult | null | undefined): risk is DeskRiskUnavailable { + return !!risk && 'unavailable' in risk; +} + +/** Whether the desk's positions breach a risk limit with no approved override (C9). */ +export function riskBreached(risk: DeskRiskResult | null | undefined): risk is DeskRisk { + return !!risk && !riskUnavailable(risk) && risk.state === 'desk' && !risk.withinLimits && !risk.approvedOverride; +} + +/** + * Why the risk check blocks Approve, or null (C9). The model's book, no book, + * an approved override and positions within the limits pass; a breach, or a + * check that cannot be read, block. The server refuses the same (409). + */ +export function riskBreachReason(risk: DeskRiskResult | null | undefined): string | null { + if (!risk) return null; + if (riskUnavailable(risk)) return `The risk check is unavailable: ${risk.unavailable}`; + if (!riskBreached(risk)) return null; + const terms = [...new Set(risk.breaches.map(b => b.term))]; + return `The positions breach risk limits${terms.length ? ` (${terms.join(', ')})` : ''}: save within the limits or request an override`; +} + +/** What each overlay term limits, for the report. */ +const RISK_TERMS: Record = { + R: 'Portfolio risk', + R_jump: 'Jump risk', + R_shock: 'Shock risk', + L_g: 'Gross leverage', + L_n: 'Net leverage', + CAP: 'Per-name cap', +}; + +export function riskTermLabel(term: string): string { + return RISK_TERMS[term] ?? term; +} + +export type RiskBadgeTone = 'ok' | 'breach' | 'waived' | 'muted'; + +/** The risk report's headline. */ +export function riskBadge(risk: DeskRiskResult): { tone: RiskBadgeTone; label: string } { + if (riskUnavailable(risk)) return { tone: 'muted', label: `Risk check unavailable (${risk.unavailable})` }; + if (risk.state === 'model') return { tone: 'ok', label: 'Model book — within limits' }; + if (risk.state === 'no_book') return { tone: 'muted', label: 'No book for the day yet' }; + if (risk.state === 'override' || risk.approvedOverride) { + return { tone: 'waived', label: 'Override approved — breaches waived' }; + } + return risk.withinLimits + ? { tone: 'ok', label: 'Within risk limits' } + : { tone: 'breach', label: 'Outside risk limits — override required' }; +} + +/** "Gross leverage bound: positions cut to 80%", or null when the overlay did not cut the book. */ +export function riskBindingLine(risk: DeskRisk): string | null { + if (!risk.bindingTerm || risk.bindingTerm === 'none' || !(risk.m < 1)) return null; + return `${riskTermLabel(risk.bindingTerm)} bound: positions cut to ${Math.round(risk.m * 100)}%`; +} + +export type RiskValueFormat = 'leverage' | 'percent' | 'ratio' | 'number'; + +export interface RiskReadingRow { + label: string; + /** The stored qt book's reading; null when not recorded or not a reading. */ + reading: number | null; + /** The configured limit; null when none applies. */ + limit: number | null; + format: RiskValueFormat; + over: boolean; +} + +/** + * The configured limits next to the stored book's readings: gross and net + * leverage against max_gross_leverage and max_net_leverage, then the margin + * cushion and equity/margin (readings without a limit), then every other + * configured limit (no reading of its own). + */ +export function riskReadings(risk: DeskRisk): RiskReadingRow[] { + const limits = new Map(risk.limits.map(l => [l.name, l.value])); + const row = (label: string, reading: number | null, limit: number | null, format: RiskValueFormat) => ({ + label, + reading, + limit, + format, + over: reading !== null && limit !== null && Math.abs(reading) > limit + 1e-9, + }); + const rows: RiskReadingRow[] = [ + row('Gross leverage', risk.grossLeverage, limits.get('max_gross_leverage') ?? null, 'leverage'), + row('Net leverage', risk.netLeverage, limits.get('max_net_leverage') ?? null, 'leverage'), + row('Margin cushion', risk.marginCushion, null, 'percent'), + row('Equity / margin', risk.equityToMargin, null, 'ratio'), + ]; + for (const limit of risk.limits) { + if (limit.name === 'max_gross_leverage' || limit.name === 'max_net_leverage') continue; + rows.push(row(limit.name, null, limit.value, 'number')); + } + return rows; +} + +export function formatRiskValue(value: number | null, format: RiskValueFormat): string { + if (value === null || !Number.isFinite(value)) return '-'; + switch (format) { + case 'leverage': + case 'ratio': + return `${value.toFixed(2)}x`; + case 'percent': + return `${(value * 100).toFixed(1)}%`; + default: + return String(Number(value.toPrecision(4))); + } +} + +/** The symbols a step moved (moved_by not "none"), risk steps first. */ +export function riskMovedSymbols(risk: DeskRisk): SymbolRisk[] { + const order = (s: SymbolRisk) => (s.category === 'risk' ? 0 : 1); + return risk.symbols + .filter(s => s.movedBy && s.movedBy !== 'none') + .sort((a, b) => order(a) - order(b) || a.symbol.localeCompare(b.symbol)); +} + /** * Why Approve is closed, or null. Approval is open every day, edited or * not, until 10:00 New York (server time `nowMs`; without it, the server's - * view when it answered), unless the day is published or one is in progress. + * view when it answered), unless the day is published or one is in progress, + * or the risk check blocks it (C9: a breach without an approved override, or + * a check that cannot be read). */ export function approveBlockedReason(state: DeskState, nowMs?: number): string | null { if (!state.date) return 'No book for today yet'; @@ -531,7 +703,7 @@ export function approveBlockedReason(state: DeskState, nowMs?: number): string | return `Approval of the ${state.date} book closed at 10:00 New York; the model's book is sent instead`; } if (!state.seeded) return 'The proposal is not seeded yet'; - return null; + return riskBreachReason(state.risk); } /** Whether the day's rescind is pending or running. */ diff --git a/algolens-frontend/src/infrastructure/api/deskApi.ts b/algolens-frontend/src/infrastructure/api/deskApi.ts index d7f52a7a..8bd8767d 100644 --- a/algolens-frontend/src/infrastructure/api/deskApi.ts +++ b/algolens-frontend/src/infrastructure/api/deskApi.ts @@ -1,6 +1,7 @@ import type { DeskCommand, DeskDeadlines, + DeskRiskResult, DeskState, PublishedRecord, QuantityChange, @@ -73,6 +74,8 @@ export interface ApprovalPage { /** The server's clock (ISO, UTC) and the request day's New York deadlines. */ serverTime?: string; deadlines?: DeskDeadlines; + /** The engine's risk check of the request's day (C9): the breaches an approval waives. */ + risk?: DeskRiskResult | null; viewer: { email: string; approver_role: 'vp' | 'president' | null; is_requester: boolean }; } From cc8fe45314ca0ddf971894e2f801f0d59c0ecbe1 Mon Sep 17 00:00:00 2001 From: BonelessWater Date: Wed, 14 Oct 2026 08:00:38 -0400 Subject: [PATCH 2/2] QT desk settings: a compact table per section with a filter Each setting was a card (label, running line, input) in a two-column grid, a long wall for a strategy's config. Now each section is a collapsible table of rows (setting, running, pending, new value), opened when it holds pending or edited values or matches the filter; a filter box narrows by dotted path; a version's raw changes fold away in the history. --- .../src/components/qt/SettingsEditor.tsx | 616 ++++++++++-------- .../src/domain/qt/settings.test.ts | 241 +++---- algolens-frontend/src/domain/qt/settings.ts | 299 +++++---- 3 files changed, 619 insertions(+), 537 deletions(-) diff --git a/algolens-frontend/src/components/qt/SettingsEditor.tsx b/algolens-frontend/src/components/qt/SettingsEditor.tsx index cccc6fe1..faa4662c 100644 --- a/algolens-frontend/src/components/qt/SettingsEditor.tsx +++ b/algolens-frontend/src/components/qt/SettingsEditor.tsx @@ -1,284 +1,332 @@ -/** - * Desk settings editor (A7), ported from the closed config dashboard (#33/#34: - * ConfigDashboard + ConfigHistory) onto the settings lane: the settings the - * last live run used (live_run_metadata.settings_used), editable key by key, - * saved as a new strategy_config version that the next live run merges over - * the config files. Running and pending are shown side by side; a revert is a - * new version. The database and email sections are never shown. - */ -import React, { useCallback, useEffect, useState } from 'react'; -import { AlertCircle, Check, RotateCcw } from 'lucide-react'; -import { useTheme } from '../../adapters/react/ThemeContext'; -import { DeskService } from '../../application/qt/deskService'; -import { - buildSettingChanges, - canSubmitSettings, - displaySetting, - groupBySection, - isEditable, - pathKey, - pathLabel, - versionStatus, - type SettingField, - type SettingsState, -} from '../../domain/qt/settings'; - -export function SettingsEditor({ portfolioId }: { portfolioId: string }) { - const { theme } = useTheme(); - const dark = theme === 'dark'; - const [state, setState] = useState(null); - const [error, setError] = useState(null); - const [edits, setEdits] = useState>({}); - const [reason, setReason] = useState(''); - const [busy, setBusy] = useState(false); - const [submitError, setSubmitError] = useState(null); - const [success, setSuccess] = useState(null); - const [revertTo, setRevertTo] = useState(null); - const [revertReason, setRevertReason] = useState(''); - - const load = useCallback(async () => { - try { - setState(await DeskService.settings(portfolioId)); - setError(null); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, [portfolioId]); - - useEffect(() => { - load(); - }, [load]); - - const muted = dark ? 'text-gray-400' : 'text-gray-500'; - const card = `rounded-lg border p-3 ${dark ? 'border-gray-800 bg-gray-900' : 'border-gray-200 bg-white'}`; - const input = `w-full px-3 py-2 rounded border text-sm ${ - dark ? 'border-gray-700 bg-gray-800 text-white' : 'border-gray-300 bg-white text-gray-900' - }`; - - if (error) return

Settings unavailable: {error}

; - if (!state) return

Loading settings...

; - - const { changes, invalid } = buildSettingChanges(state.fields, edits); - const canSubmit = state.editable && canSubmitSettings(changes, invalid, reason) && !busy; - const status = versionStatus(state); - - const submit = async () => { - setBusy(true); - setSubmitError(null); - try { - const version = await DeskService.saveSettings(portfolioId, changes, reason.trim()); - setEdits({}); - setReason(''); - setSuccess(`Saved as version ${version.version}. It takes effect at the next live run.`); - await load(); - } catch (err) { - setSubmitError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - - const revert = async (version: number) => { - setBusy(true); - setSubmitError(null); - try { - const created = await DeskService.revertSettings(portfolioId, version, revertReason.trim()); - setRevertTo(null); - setRevertReason(''); - setSuccess( - `Restored ${version === 0 ? 'the config files' : `version ${version}`} as version ${created.version}. It takes effect at the next live run.`, - ); - await load(); - } catch (err) { - setSubmitError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - - const fieldCard = (field: SettingField) => { - const key = pathKey(field.path); - const editable = state.editable && isEditable(field); - const current = key in edits ? edits[key] : displaySetting(field.pending ?? field.value); - const bad = key in edits && invalid.includes(pathLabel(field.path)); - return ( -
-
- - {(field.pending !== null || field.pending_from_files) && ( - - PENDING - - )} -
-
-
- Running: {displaySetting(field.value) || 'null'} -
- {field.pending !== null && ( -
- Pending: {displaySetting(field.pending)} -
- )} - {field.pending_from_files &&
Pending: back to the config file value
} -
- {!editable ? ( -
{isEditable(field) ? '' : '(read-only)'}
- ) : field.type === 'boolean' ? ( - - ) : ( - setEdits(prev => ({ ...prev, [key]: e.target.value }))} - /> - )} - {bad &&
Invalid {field.type}
} -
- ); - }; - - return ( -
-
-
Running: {status.running}
- {status.pending &&
Pending: {status.pending}
} -
- - {state.pending && ( -
- -

Saved changes take effect at the next live run. The running values are what trades now.

-
- )} - - {!state.running && ( -

- No live run has reported the settings it used yet. Editing opens after the first run that does. -

- )} - - {groupBySection(state.fields).map(([section, fields]) => ( -
-
{section}
-
{fields.map(fieldCard)}
-
- ))} - - {state.editable && state.running && ( - <> - {changes.length > 0 && ( -
- {changes.map(c => ( -
- {pathLabel(c.path)}: {displaySetting(c.value)} -
- ))} -
- )} -