Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
458 changes: 452 additions & 6 deletions keel/commands/orders.py

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions keel/data/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,21 @@ def get_open_positions(self, product_id: str | None = None) -> list[dict[str, An
sql += " ORDER BY opened_at, id"
return [self._position_row_to_dict(row) for row in self._conn.execute(sql, params)]

def open_bracket_order_ids(self) -> frozenset[int]:
"""Every order id an OPEN tranche relies on for protection, in one query.

The batch form of `get_position_for_bracket` below, for a caller classifying a page of
orders: that method is a query per row, and the orders page is capped at 2,000. Same
predicate, same `status = 'open'` -- `commands/orders.py::classify_cancel` takes this set
when it has one and falls back to the single lookup when it does not, so there is one
rule rather than a fast one and a careful one that can disagree.
"""
rows = self._conn.execute(
"SELECT bracket_order_id FROM positions "
"WHERE bracket_order_id IS NOT NULL AND status = 'open'"
).fetchall()
return frozenset(int(row["bracket_order_id"]) for row in rows)

def get_position_for_bracket(self, bracket_order_id: int) -> dict[str, Any] | None:
"""The OPEN tranche whose bracket is `bracket_order_id`, or `None`.

Expand Down
66 changes: 66 additions & 0 deletions keel/web/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -1672,6 +1672,62 @@ def activity_payload(feed: ActivityFeed) -> dict[str, Any]:
}


#: The state each cancel kind carries. `entry` is NEUTRAL and never GOOD -- cancelling one is
#: ordinary, not an achievement -- and `protective` warns because acting on it removes a stop.
_CANCEL_STATES: Mapping[str, str] = {
"entry": NEUTRAL,
"exit": WARN,
"protective": WARN,
"unknown": UNKNOWN,
}


def _cancel_payload(decision: Any) -> dict[str, Any]:
"""How an operator cancels this order, and what cancelling it would mean (#707).

**There is no route behind any of this, and that is the design rather than a gap.** `keel
serve` holds no venue credential and no broker handle; the whole console is a loopback reader
of a SQLite file. Cancelling reaches a venue, so it happens in a terminal invocation the
operator started -- and what the console does instead is CLASSIFY (a read) and hand over the
exact command.

The `invocation` is composed in `commands/orders.py` and placed here unread. A client building
the command itself could print one that does not exist; a payload that omitted it would leave
the operator to reconstruct an order id from a table.

`kind` carries its judgement as a state (Rule 3) because the two readings are not symmetric: an
entry is ordinary, and a protective leg is the one an operator should hesitate over.
"""
from keel.commands.orders import CANCEL_NOTES

return {
"headline": decision.headline,
"kind": label(
decision.kind,
display=decision.kind,
state=_CANCEL_STATES.get(decision.kind, UNKNOWN),
),
"note": CANCEL_NOTES.get(decision.kind, ""),
# A flag rather than a bare bool: "this cannot be cancelled" and "this can" are different
# sentences, and the reason belongs beside the second one.
"cancellable": flag(
bool(decision.cancellable),
on="resting — cancellable from the terminal",
off=decision.reason or "not cancellable",
on_state=NEUTRAL,
off_state=UNKNOWN,
),
"typed": flag(
bool(decision.typed),
on="typed phrase required",
off="asks once",
on_state=WARN,
off_state=NEUTRAL,
),
"invocation": decision.invocation,
}


def _order_row_payload(row: OrderRow) -> dict[str, Any]:
"""One `OrderRow`, placed. Nothing is decided here.

Expand Down Expand Up @@ -1789,6 +1845,9 @@ def _order_row_payload(row: OrderRow) -> dict[str, Any]:
"rule_id": count(row.rule_id),
"created_at": moment(row.created_at),
"updated_at": moment(row.updated_at),
# #707: how to cancel this, and what cancelling it would mean. No route behind it --
# see `_cancel_payload`.
"cancel": _cancel_payload(row.cancel),
}


Expand All @@ -1807,6 +1866,8 @@ def orders_payload(report: OrdersReport) -> dict[str, Any]:
`modes` is every mode present in the whole book. A deployment book holds one in practice, so
stating which means a reader never concludes it from an empty section.
"""
from keel.commands.orders import WEB_READ_ONLY_BADGE, WEB_READ_ONLY_NOTE

return {
"as_of": iso(report.now_ts),
"generated_at": moment(report.now_ts),
Expand All @@ -1826,6 +1887,11 @@ def orders_payload(report: OrdersReport) -> dict[str, Any]:
"modes": [str(mode) for mode in report.modes],
"empty_reason": report.empty_reason,
"empty_note": _EMPTY_NOTES.get(report.empty_reason, ""),
# The badge the console wears on this page (#707). Written here, not in the client: it is a
# claim about how this deployment is built, and Rule 2 keeps claims in Python.
"write_posture": label(
WEB_READ_ONLY_BADGE, display=WEB_READ_ONLY_NOTE, state=NEUTRAL
),
"rows": [_order_row_payload(row) for row in report.rows],
}

Expand Down
52 changes: 51 additions & 1 deletion keel/web/static/css/keel.css
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,51 @@ header .mode-paper { color: var(--muted); }
header .mode-confirm,
header .mode-live { color: var(--accent); border-color: var(--accent); }

/* THE CANCEL-HELP MODAL (#707).
*
* A `<dialog>`, so focus trapping, Escape and the backdrop are the browser's rather than
* hand-built: a modal that traps focus badly is worse than none on a page an operator reaches with
* a keyboard.
*
* `.linklike` is a button that does not look like a filled action, because none of these buttons
* DOES anything to the deployment -- one opens instructions, one copies text, one closes. The
* filled `button` rule is reserved for a control that changes something on the server, and there
* is no such control anywhere in this console. */
.cancelhelp {
max-width: 34rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--card);
color: var(--fg);
padding: 1.25rem;
}
.cancelhelp::backdrop { background: rgba(0, 0, 0, 0.45); }
.cancelhelp h2 { margin: 0 0 0.5rem; font-size: 1rem; }
/* The command itself: selectable, wrapping, and monospaced, so an operator who cannot use the
* clipboard button can still read and select it. The copy button is a convenience, never the only
* way to get the command. */
.invocation {
margin: 0.75rem 0;
padding: 0.6rem 0.75rem;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--bg);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
user-select: all;
}
.linklike {
background: none;
border: none;
padding: 0;
color: var(--accent);
font: inherit;
text-decoration: underline;
cursor: pointer;
}

/* THE PLANS PAGE's claim list (#706).
*
* A quoted sentence with its citation beside it, not beneath it: a citation a reader has to go
Expand Down Expand Up @@ -331,7 +376,12 @@ header .sessionpart:empty { display: none; }
margin: 0;
padding: 0.45rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
/* `--card`, not `--surface`. #704 shipped `var(--surface)`, which this stylesheet does not
define -- the site's name for the token is `--surface` and keel.css's is `--card`, and an
undefined custom property falls back to transparent, so the banner has been painting no
background at all. Found while adding the modal below, which reached for the same wrong
name. */
background: var(--card);
font-size: 0.85rem;
letter-spacing: 0.01em;
text-align: center;
Expand Down
100 changes: 100 additions & 0 deletions keel/web/static/js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,10 @@ export function ordersView(data, sort, onSort, onScope, onStatus) {

const sub = el("p", "sub");
sub.append(field(data.generated_at));
// #707. The badge, on the page where an operator would otherwise expect a Cancel that acts.
// It is not an apology for a missing feature: `keel serve` holds no venue credentials, which
// is what keeps the worst case of a bug in this layer at "reads a local file".
if (data.write_posture) sub.append(" · ", field(data.write_posture));
fragment.append(sub);

fragment.append(scopeSwitch(plain(data.scope), onScope, "Orders scope"));
Expand Down Expand Up @@ -1919,6 +1923,8 @@ export function ordersView(data, sort, onSort, onScope, onStatus) {
{ label: "divergence", numeric: true, key: "fill_divergence" },
{ label: "fee", numeric: true, key: "fee" },
{ label: "placed (UTC)", numeric: false, key: "created_at" },
// No `key`: there is nothing to sort by, and the column is an action rather than a fact.
{ label: "cancel", numeric: false },
],
rows.map(
/** @param {any} row */ (row) => [
Expand All @@ -1938,6 +1944,7 @@ export function ordersView(data, sort, onSort, onScope, onStatus) {
row.fill_divergence,
row.fee,
row.created_at,
cancelCell(row),
],
),
// Never reached when `rows` is empty, because `emptyOrders` below answers first with the
Expand Down Expand Up @@ -1981,6 +1988,99 @@ function emptyOrders(data) {
* @param {any} row
* @returns {HTMLElement}
*/
/**
* The cancel cell: a button that opens instructions, never a button that cancels (#707).
*
* **This console cannot cancel anything, and that is the design.** `keel serve` holds no venue
* credential and no broker handle — the whole application is a loopback reader of a SQLite file —
* so the worst case of a bug in this layer stays "reads a local database" rather than becoming
* "exfiltrates live trading keys". Unlocking the keychain and signing a request to a venue happens
* inside a terminal invocation the operator started, never from an ambient daemon.
*
* So what the button does is CLASSIFY and hand over the exact command. The classification is a
* read, it comes off the payload, and it is the same `classify_cancel` the terminal gates on — a
* console that decided for itself could call an order a frictionless entry while `keel orders
* cancel` demanded the typed phrase for it.
*
* An order that cannot be cancelled gets the reason instead of a button. Offering a command that
* would be refused is worse than offering none.
*
* @param {any} row
* @returns {HTMLElement}
*/
function cancelCell(row) {
const cancel = row.cancel;
if (!cancel) return el("span", "muted", "—");
// The INVOCATION's presence is the fact, not a `.value` read: `classify_cancel` composes a
// command only for an order that can actually take one, so an empty string here means the
// terminal would refuse it too. `render.js` may place `display` and style by `state` and may
// never inspect `value` -- and this is a bare string, not a `Field`.
if (!plain(cancel.invocation)) {
return el("span", "muted", plain(cancel.cancellable.display));
}
const open = el("button", "linklike", "Cancel…");
open.setAttribute("type", "button");
open.addEventListener("click", () => openCancelHelp(row));
return open;
}

/**
* The modal that tells an operator how to cancel, and copies the command (#707).
*
* The heading says WHAT the order is, because that is the decision: cancelling an entry refuses
* risk, and cancelling a protective leg removes a stop from a position that is relying on it. Both
* sentences come from the payload (Rule 2) and both invocations are the same command — the
* asymmetry lives in the terminal, where an exit will ask for a typed phrase.
*
* `<dialog>` rather than a hand-built overlay: focus trapping, Escape, and the backdrop are the
* browser's, and a modal that traps focus badly is worse than none on a page an operator reaches
* with a keyboard.
*
* @param {any} row
*/
function openCancelHelp(row) {
const cancel = row.cancel;
// Any dialog still open belongs to a previous read. The view repaints every 15 seconds and
// `main.js` replaces `#content`, which this node is deliberately outside of -- so without this
// an open modal survives the repaint and can go on offering a command for an order that has
// since filled. The CLI would refuse it by name, but a console showing a stale instruction is
// the console being wrong rather than the terminal being careful.
for (const stale of document.querySelectorAll("dialog.cancelhelp")) stale.remove();
const dialog = el("dialog", "cancelhelp");

// Composed in Python: "Entry order #42" and "Protective bracket #43 — live protection" are two
// different warnings, and choosing between them is a judgement (Rule 2).
dialog.append(el("h2", undefined, plain(cancel.headline)));

dialog.append(el("p", undefined, plain(cancel.note)));

const command = el("pre", "invocation", plain(cancel.invocation));
dialog.append(command);

const actions = el("p", "note");
const copy = el("button", "linklike", "Copy command");
copy.setAttribute("type", "button");
copy.addEventListener("click", () => {
// Clipboard writes are permitted on a secure context, and `localhost` is one. Where it is
// refused the command is still selectable text above — this is a convenience, never the only
// way to get the command.
if (navigator.clipboard) void navigator.clipboard.writeText(plain(cancel.invocation));
copy.textContent = "Copied";
});
actions.append(copy, " ");
actions.append(field(cancel.typed));
dialog.append(actions);

const close = el("button", "linklike", "Close");
close.setAttribute("type", "button");
close.addEventListener("click", () => dialog.close());
dialog.append(close);

dialog.addEventListener("close", () => dialog.remove());
document.body.append(dialog);
dialog.showModal();
}

function orderDetail(row) {
const node = el("details", "cycle");
const summary = el("summary");
Expand Down
14 changes: 14 additions & 0 deletions tests/commands/test_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ def get_orders(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
self.calls.append((args, kwargs))
return list(self.rows)

def open_bracket_order_ids(self) -> frozenset[int]:
"""#707. Deliberately NOT recorded in `calls`: this pin is about the arguments
`get_orders` is handed, and a second read appearing there would make the assertion below
about two calls rather than one."""
return frozenset()

def get_rules(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
# `gather_orders` resolves rule NAMES off this (#700). Recorded nowhere: this stub
# exists to pin how `get_orders` is called, and that pin is unchanged.
Expand Down Expand Up @@ -830,11 +836,19 @@ def __init__(self, orders: list[dict[str, Any]], rules: list[dict[str, Any]]) ->
self._rules = rules
self.order_reads = 0
self.rule_reads = 0
self.bracket_reads = 0

def get_orders(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
self.order_reads += 1
return list(self._orders)

def open_bracket_order_ids(self) -> frozenset[int]:
"""#707's protective links, batched. Counted like the others: one read for the page, never
one per row -- which is the property `classify_cancel`'s `bracket_ids` argument exists
for."""
self.bracket_reads += 1
return frozenset()

def get_rules(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
self.rule_reads += 1
return list(self._rules)
Expand Down
Loading
Loading