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
347 changes: 347 additions & 0 deletions keel/commands/positions.py

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions keel/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,33 @@ def read_orders(cfg: ServeConfig, query: Query, _state: Any, _now_ts: int) -> di
return payload.orders_payload(report)


def read_positions(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> dict[str, Any]:
"""What is held right now, marked at the price the rails used (#701).

**READ ONLY, and there is no write route on this path.** #701's own refusal: a position is
closed through the typed-phrase friction of the exit path, never a table-row tap. A panic tap
must not be the last line of defence, so the affordance does not exist here to be tapped.

No `?limit=` and no `?scope=`. Both exist on `/api/orders` because a book of orders grows
without bound; OPEN tranches do not -- the number is bounded by what the deployment holds
right now, and rail 4's concurrent-position cap bounds that. A cap here would hide a position
an operator is looking for, which is the one thing this page must never do.

`config` is loaded because the mark comes from the FINEST configured series -- the same read
`agent._mark_to_market_parts` makes. Reading a different series would put a different current
price on the page from the one that moved rail 11's drawdown scalars.
"""
from keel.commands.positions import gather_positions

repo = open_repo(cfg.db_path)
try:
config = load_config(cfg.config_path)
report = gather_positions(repo, config, now_ts=now_ts)
finally:
close_repo(repo)
return payload.positions_payload(report)


def read_insights(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> dict[str, Any]:
"""The per-rule track records, the promotion-gate distances, and the account-equity series.

Expand Down Expand Up @@ -553,6 +580,28 @@ class ApiRoute:
"created_at",
),
),
"/api/positions": ApiRoute(
html_route="/positions",
read=read_positions,
collection="rows",
# The figures an operator scans by. `unrealized` and `stop_distance` first in intent:
# "what is losing" and "what is closest to its stop" are the two questions this page
# exists to answer, and both are sorted server-side because both are Decimals that a
# browser would compare as doubles.
sortable=(
"product_id",
"rule_name",
"opened_at",
"qty",
"entry_fill",
"mark",
"market_value",
"unrealized",
"initial_stop",
"stop_distance",
"stop_distance_pct",
),
),
"/api/rules": ApiRoute(
html_route="/rules",
read=read_rules,
Expand Down
112 changes: 112 additions & 0 deletions keel/web/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
RuleTrackRecord,
)
from keel.commands.orders import OrderRow, OrdersReport
from keel.commands.positions import PositionRow, PositionsReport
from keel.commands.status import (
AutonomyStatus,
MarketSessionStatus,
Expand Down Expand Up @@ -1526,6 +1527,117 @@ def orders_payload(report: OrdersReport) -> dict[str, Any]:
}


#: The entry-gate verdict, styled (#701). Named `_ENTRY_GATE_*` and not `_READINESS_*`: this
#: module already has a `_READINESS_STATE` for `VenueReadiness`, an unrelated vocabulary, and
#: two names one character apart in one file is a mis-edit waiting to happen.
#:
#: `entry_bar_ready`'s vocabulary, not a staleness one: these words say why the AGENT would
#: refuse to open a position on this product right now.
#:
#: All three are WARN rather than BAD. None of them is a loss or a broken deployment -- a feed
#: catches up, an unconfirmed bar confirms -- but each one means keel cannot act on this product
#: at this moment, and a reader scanning for "why did nothing happen" must be able to find them.
_ENTRY_GATE_STATES: Mapping[str, str] = {
"missing": WARN,
"behind": WARN,
"unconfirmed": WARN,
}

#: What each verdict means, spelled out. The word alone is a term of art; the sentence is what a
#: reader who has not read `freshness.py` can act on.
_ENTRY_GATE_NOTES: Mapping[str, str] = {
"missing": "no cached bar for the entry-gate series -- keel would not open here",
"behind": "the entry-gate series is behind its expected bar -- keel would not open here",
"unconfirmed": "the newest bar is not confirmed closed by a finer series -- keel would wait",
}


def _readiness_field(ready: bool, reason: str | None) -> Field:
"""The freshness chip: the ENTRY GATE's own verdict for one product.

Deliberately not `_freshness_payload`'s age. That one answers "how old is this data", which
`freshness.assess` tolerates a forming bar for; this answers "would the agent trade on it",
which `entry_bar_ready` refuses a one-bar-late finer series for -- because that lag is
exactly the condition that produces a duplicate real-money order. A page showing the softer
number would tell a reader the feed is fine while the engine's own gate is refusing it.

A `ready` row says so plainly rather than going blank: "nothing is wrong" is a finding on a
page whose other rows explain why keel is idle.
"""
if ready:
return label("ready", display="entry gate ready", state=GOOD)
word = reason or "unknown"
return label(
word,
display=_ENTRY_GATE_NOTES.get(word, "the entry gate would not open here"),
state=_ENTRY_GATE_STATES.get(word, UNKNOWN),
)


def _position_row_payload(row: PositionRow) -> dict[str, Any]:
"""One open tranche, placed. Nothing is decided here.

Every judgement was made by `keel/commands/positions.py`: what the mark is, whether there is
one, what the stop distance is and whether the entry gate would open. This function chooses a
`state` word and a symbol for each figure, which is all -- and is what makes this page and
`keel status` incapable of disagreeing about the same tranche.

**`unrealized` and `stop_distance` are the only two figures that carry a verdict**, because
they are the only two whose sign means something. A market value is a magnitude: an account
is not good for being worth something, and a glyph on every balance would hide the one figure
that matters. On `stop_distance`, negative means the tranche is trading THROUGH the
protection it was sized against -- keel is cash-spot and long-only (`CashAccountRequired`,
#372), so a stop always sits below the mark and the sign has one meaning.

**No notional, no cost basis, no ratio this layer computed.** Everything here is on the
report. `stop_distance_pct` crosses as the raw FRACTION with no `%`, the same posture `ratio`
documents for the drawdown scalars -- rescaling it by 100 would be arithmetic Rule 2 forbids.
"""
return {
"id": str(row.id),
"product_id": row.product_id,
"rule_name": row.rule_name,
"opened_at": moment(row.opened_at),
"qty": quantity(row.qty),
"entry_fill": money(row.entry_fill),
"entry_fee": money(row.entry_fee),
"mark": money(row.mark),
"mark_at": moment(row.mark_ts),
"market_value": money(row.market_value),
"unrealized": money(row.unrealized_pnl, signed=True),
"initial_stop": money(row.initial_stop),
"stop_distance": money(row.stop_distance, signed=True),
# FOUR places, not `ratio`'s default two. At two, a tranche 0.2% through its stop and one
# 0.2% above it render as "-0.00" and "0.00" side by side -- illegible in exactly the
# range this column exists to show, since a position near its stop is the one worth
# finding. The paired `stop_distance` still carries the verdict.
"stop_distance_pct": ratio(row.stop_distance_pct, places=4),
"realized_qty": quantity(row.realized_qty),
"realized_proceeds": money(row.realized_proceeds),
"realized_fees": money(row.realized_fees),
"freshness": _readiness_field(row.ready, row.ready_reason),
}


def positions_payload(report: PositionsReport) -> dict[str, Any]:
"""`gather_positions`'s `PositionsReport`, as JSON.

`open_count` and `products` are READ from the report, never measured here -- Rule 6e bans
`len()` in this module, and both properties exist on the report so the ban costs nothing.

`products` is the grouping key a view renders sections from. It is the report's own list, in
its own order, so a client cannot build a second one and reach a different answer about which
products this book holds.
"""
return {
"as_of": iso(report.now_ts),
"generated_at": moment(report.now_ts),
"open_count": count(report.open_count),
"products": [str(product) for product in report.products],
"rows": [_position_row_payload(row) for row in report.rows],
}


# -- the envelope (#534) -------------------------------------------------------------------------
#
# Every `GET /api/*` success is wrapped in the same four keys, so #536's single `fetch` wrapper
Expand Down
1 change: 1 addition & 0 deletions keel/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
<li><a href="/setup">Setup</a></li>
<li><a href="/activity">Activity</a></li>
<li><a href="/orders">Orders</a></li>
<li><a href="/positions">Positions</a></li>
<li><a href="/insights">Insights</a></li>
<li><a href="/rules">Rules</a></li>
<li><a href="/venues">Venues</a></li>
Expand Down
3 changes: 3 additions & 0 deletions keel/web/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
insightsView,
modeBadge,
ordersView,
positionsView,
refusedView,
rulesView,
setupView,
Expand Down Expand Up @@ -97,6 +98,7 @@ const ROUTES = [
{ name: "setup", label: "Setup", endpoints: ["setup"] },
{ name: "activity", label: "Activity", endpoints: ["activity"] },
{ name: "orders", label: "Orders", endpoints: ["orders"] },
{ name: "positions", label: "Positions", endpoints: ["positions"] },
{ name: "insights", label: "Insights", endpoints: ["insights", "journal"] },
{ name: "rules", label: "Rules", endpoints: ["rules"] },
{ name: "venues", label: "Venues", endpoints: ["venues"] },
Expand Down Expand Up @@ -424,6 +426,7 @@ function mount(route, readings) {
sorter(route, route.endpoints[1]),
);
}
if (route.name === "positions") return positionsView(data, primary.sort, onSort);
if (route.name === "rules") return rulesView(data, primary.sort, onSort);
if (route.name === "venues") return venuesView(data, primary.sort, onSort);
if (route.name === "gates") return gatesView(data);
Expand Down
124 changes: 124 additions & 0 deletions keel/web/static/js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,130 @@ function jobPanel(job) {
* @param {(scope: string) => void} onScope
* @returns {DocumentFragment}
*/
/**
* The Positions view (#701): what is held, what it is worth, and how close it is to its stop.
*
* ── NO CLOSE ACTION, AND THAT IS THE DESIGN ──────────────────────────────────────────────────
*
* Alpaca's positions page has a per-row close. This one does not, ever. An exit goes through the
* typed-phrase friction of the terminal path, because a panic tap on a table row must not be the
* last line of defence between an operator and an unplanned market sell. The absence is pinned by
* `tests/web/test_positions_view.py::test_the_positions_view_has_no_close_action_anywhere`, so it
* survives the day it looks like an obvious convenience to add.
*
* ── GROUPED BY THE REPORT'S OWN PRODUCT LIST ─────────────────────────────────────────────────
*
* `data.products` rather than a set this file assembles from the rows: two answers to "which
* products does this book hold" is one too many, and the ordered one is already on the report.
* A product holds several TRANCHES -- that is what tranches are for -- so each section is a
* table of them rather than one row pretending to be the position.
*
* ── THE CHIP EXPLAINS THE IDLE DEPLOYMENT ────────────────────────────────────────────────────
*
* `freshness` is the ENTRY GATE's verdict, not a data age: `missing`/`behind`/`unconfirmed` are
* the agent's own reasons for refusing to open here. It is the most common answer to "why has
* nothing happened", which is why it sits beside the money rather than under a disclosure.
*
* It is a COLUMN and not a per-product chip, because the gate granularity is the one the RULE
* that opened the tranche declares. Two tranches of one product, opened by rules on different
* timeframes, have two verdicts -- and a chip above the table would have to pick one.
*
* @param {any} data `/api/positions`'s `data`.
* @param {any} sort
* @param {(column: string) => void} onSort
* @returns {DocumentFragment}
*/
export function positionsView(data, sort, onSort) {
const fragment = document.createDocumentFragment();
fragment.append(el("h1", undefined, "Positions"));

const sub = el("p", "sub");
sub.append(field(data.generated_at), " · ");
sub.append(field(data.open_count), " open tranche(s)");
fragment.append(sub);

const rows = data.rows || [];
if (rows.length === 0) {
// A real answer, not a blank panel: an account holding nothing is an ordinary state for a
// daily agent between entries, and it is not the same as a page that failed to load.
fragment.append(el("p", "empty", "No open positions. keel is holding nothing right now."));
return fragment;
}

for (const product of data.products || []) {
const held = rows.filter(/** @param {any} row */ (row) => row.product_id === product);
const id = ["h-pos", product].join("-");
fragment.append(heading(id, product));

// The chip belongs to the PRODUCT, not the tranche: the entry gate asks about a series, so
// every tranche of one product shares one verdict and repeating it per row would suggest
// they could differ.
if (held.length === 0) continue;

fragment.append(
table(
id,
[
{ label: "opened (UTC)", numeric: false, key: "opened_at" },
{ label: "rule", numeric: false, key: "rule_name" },
{ label: "qty held", numeric: true, key: "qty" },
{ label: "entry", numeric: true, key: "entry_fill" },
{ label: "entry fee", numeric: true, key: "entry_fee" },
{ label: "mark", numeric: true, key: "mark" },
{ label: "value", numeric: true, key: "market_value" },
{ label: "unrealized", numeric: true, key: "unrealized" },
{ label: "stop", numeric: true, key: "initial_stop" },
{ label: "to stop", numeric: true, key: "stop_distance" },
{ label: "to stop %", numeric: true, key: "stop_distance_pct" },
// PER TRANCHE, not per product. The gate granularity comes from the RULE that opened
// this tranche (`_gate_granularity_for`), so one product holding tranches from rules
// on different timeframes has two verdicts -- a single chip above the table would
// state one of them over the other.
{ label: "entry gate", numeric: false, key: "freshness" },
],
held.map(/** @param {any} row */ (row) => [
row.opened_at,
plain(row.rule_name) || "—",
row.qty,
row.entry_fill,
row.entry_fee,
row.mark,
row.market_value,
row.unrealized,
row.initial_stop,
row.stop_distance,
row.stop_distance_pct,
row.freshness,
]),
"No open tranches for this product.",
{ sort: sort, onSort: onSort },
),
);

for (const row of held) {
// The realized side, under a disclosure: a scaled-out tranche has legs already booked, and
// they belong beside the running position rather than in the journal's separate account of
// the same trade. Collapsed because most tranches have never been scaled out.
const node = el("details", "row");
const summary = el("summary");
summary.append("tranche ", plain(row.id), " · realized legs");
node.append(summary);
node.append(
gridCard([
kv("realized qty", row.realized_qty),
kv("realized proceeds", row.realized_proceeds),
kv("realized fees", row.realized_fees),
kv("marked at", row.mark_at),
]),
);
fragment.append(node);
}
}

return fragment;
}


/**
* The orders view (#659): what keel actually bought and sold, and whether anybody agreed to it.
*
Expand Down
1 change: 1 addition & 0 deletions keel/web/staticfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ def resolve_static_asset(root: Path, url_path: str) -> Path | None:
"setup",
"activity",
"orders",
"positions",
"insights",
"rules",
"venues",
Expand Down
Loading
Loading