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/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)} -
- ))} -
- )} -