diff --git a/keel/commands/positions.py b/keel/commands/positions.py
new file mode 100644
index 0000000..8c60717
--- /dev/null
+++ b/keel/commands/positions.py
@@ -0,0 +1,347 @@
+"""`keel`'s positions report -- what is held, what it is worth, and how close it is to its stop.
+
+**Strictly read-only.** No broker call, no write, no rail touched: a pure view over
+`repo.get_open_positions()`, the candle cache, and `keel.data.freshness`. Same two layers as
+`status.py`/`orders.py` -- a pure `gather_positions(repo, config, now_ts) -> PositionsReport`
+that any renderer can call, and no click dependency in the builder.
+
+WHY THIS MODULE EXISTS RATHER THAN MORE FIELDS ON `OpenPositionStatus` (#701). `web/payload.py`'s
+`_position_payload` carries no P&L and says why: "`OpenPositionStatus` carries neither, so
+emitting one would mean this layer multiplied `qty` by `entry_price`... the fix, if it is wanted,
+is upstream." This is that fix. It is a separate report rather than a wider status row because
+the arithmetic here needs the candle cache and the config's granularities, which `gather_status`
+does not read and should not start reading for the sake of one table inside it.
+
+THE MARK IS THE RAILS' MARK, AND THAT IS THE POINT. `agent._mark_to_market_parts` values a
+holding at `repo.get_candles(product, finest)[-1].close`, and so does this, through the same
+`_finest_granularity`. A positions page quoting a different current price would be a second
+answer to "what is this worth" -- and since the first answer is the one that moved rail 11's
+drawdown scalars, the page would be the wrong one. Everything derived below (market value,
+unrealized P&L, stop distance) is derived from THAT number or from nothing at all.
+
+WHAT IS ABSENT STAYS ABSENT. A product with no cached candle has no mark, and a row with no mark
+reports `None` for every figure that depends on one -- never zero. Zero market value renders a
+held position as a total loss, which is the most alarming thing this page could say, and it
+would be saying it about missing data. `initial_stop` is `None` for a DCA leg or a pre-v12
+tranche, so its distance is `None` too: a distance measured against a substituted zero would
+read as a position comfortably clear of a stop it does not have.
+
+NO CLOSE ACTION, EVER (#701's own refusal). This module places nothing and cancels nothing.
+Exits degrade to the typed-phrase friction path; a panic tap on a table row must never be the
+last line of defence.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from decimal import Decimal, DivisionByZero, InvalidOperation
+from typing import Any
+
+from keel_core.types import Granularity
+
+from keel import agent as agent_mod
+from keel.config import Config
+from keel.data import freshness as freshness_mod
+from keel.data.repository import Repository
+
+
+@dataclass(frozen=True)
+class PositionRow:
+ """One open TRANCHE, projected onto what a reader needs.
+
+ `qty` is what is STILL HELD -- `reduce_position` shrinks it on a scale-out and carries the
+ sold legs in the `realized_*` accumulators, so the two sides together are the tranche's whole
+ story and either alone understates it.
+ """
+
+ id: int
+ product_id: str
+ rule_name: str
+ opened_at: int
+
+ #: The quantity still held, after any partial exits.
+ qty: Decimal
+
+ #: What this tranche paid, and what it paid to get in. The fee is here because `keel status`
+ #: never showed it and it is the half of a round trip a reader most often forgets.
+ entry_fill: Decimal
+ entry_fee: Decimal
+
+ #: The latest close of the FINEST configured series -- the agent's own mark. `None` when the
+ #: product has no cached candle at all: not observed, never "worth nothing".
+ mark: Decimal | None
+
+ #: The instant that mark was recorded, so a reader can see how old the valuation is without
+ #: this module choosing a staleness threshold (`freshness` owns that judgement; see `ready`).
+ mark_ts: int | None
+
+ #: `qty * mark`, and `qty * (mark - entry_fill)`. Both `None` without a mark.
+ market_value: Decimal | None
+ unrealized_pnl: Decimal | None
+
+ #: The stop this tranche was SIZED against (`positions.initial_stop`). `None` means "not on
+ #: this row" -- a DCA leg, or a tranche predating v12 -- and NOT "no stop".
+ initial_stop: Decimal | None
+
+ #: `mark - initial_stop`, SIGNED: a tranche trading THROUGH its stop is the state an operator
+ #: most needs to see, and an absolute distance would render it identically to one safely
+ #: above. `stop_distance_pct` is that distance as a fraction OF THE MARK. Both `None` when
+ #: either the stop or the mark is missing.
+ stop_distance: Decimal | None
+ stop_distance_pct: Decimal | None
+
+ #: The legs already sold (#502). Zero rather than `None` when nothing has been: the
+ #: repository's own convention, because "never partially exited" and "has realized nothing"
+ #: are the same fact.
+ realized_qty: Decimal
+ realized_proceeds: Decimal
+ realized_fees: Decimal
+
+ #: `entry_bar_ready`'s verdict for this product's entry-gate series -- the gate outcome the
+ #: agent itself would compute, not a data age. `ready_reason` is `"missing" | "behind" |
+ #: "unconfirmed"`, or `None` when ready.
+ ready: bool
+ ready_reason: str | None
+
+
+@dataclass(frozen=True)
+class PositionsReport:
+ now_ts: int
+ rows: tuple[PositionRow, ...]
+
+ @property
+ def open_count(self) -> int:
+ """How many tranches this report holds.
+
+ Derived rather than stored, for the reason `OrdersReport.shown_count` is: a stored count
+ can drift from the list it describes, and `keel/web/payload.py` may not call `len()`
+ (Rule 6e of `tests/commands/test_console_thinness.py`).
+ """
+ return len(self.rows)
+
+ @property
+ def products(self) -> tuple[str, ...]:
+ """Every product with an open tranche, in first-seen order -- what a grouped view keys
+ on. Held here so no renderer builds its own list and reaches a different one."""
+ seen: list[str] = []
+ for row in self.rows:
+ if row.product_id not in seen:
+ seen.append(row.product_id)
+ return tuple(seen)
+
+
+def _safe_ratio(numerator: Decimal, denominator: Decimal) -> Decimal | None:
+ """`numerator / denominator`, or `None` when that is not a finite answer.
+
+ `_mark_for` already refuses a non-positive close, so the only caller cannot pass a zero
+ denominator today -- this is the guard for the day a second caller arrives, and for the
+ non-finite results `Decimal` can produce from values this module did not write. Stated as
+ a guard rather than removed because a read-only page must not 500 over one bad row.
+ """
+ if denominator == 0:
+ return None
+ try:
+ ratio = numerator / denominator
+ except (DivisionByZero, InvalidOperation):
+ return None
+ return ratio if ratio.is_finite() else None
+
+
+def _mark_for(
+ repo: Repository, product_id: str, granularity: Granularity | None
+) -> tuple[Decimal | None, int | None]:
+ """The agent's mark for `product_id`: the newest close of the finest configured series.
+
+ Deliberately the same read `agent._mark_to_market_parts` makes. `None` when no series is
+ configured or nothing is cached -- and `None` also when the cached close is non-positive,
+ because a zero or negative price is not a valuation this page should publish as one.
+ """
+ if granularity is None:
+ return None, None
+ candles = repo.get_candles(product_id, granularity)
+ if not candles:
+ return None, None
+ newest = candles[-1]
+ if newest.close <= 0:
+ # No mark AND no time for it. Returning the bar's ts beside a `None` close would put a
+ # valuation time on the row for a valuation the row says it does not have.
+ return None, None
+ return newest.close, newest.ts
+
+
+def _readiness_for(
+ repo: Repository,
+ product_id: str,
+ granularity: Granularity | None,
+ config: Config,
+ now_ts: int,
+) -> tuple[bool, str | None]:
+ """`entry_bar_ready`'s verdict for one product on ONE gate granularity, as `(ready, reason)`.
+
+ The ENTRY-GATE question, not a staleness alert: `freshness.assess` tolerates the normal
+ forming-bar lag on purpose, and `entry_bar_ready` deliberately does not, because a one-bar-
+ late finer series is exactly the condition that produces a duplicate real-money order. A
+ positions page showing the softer verdict would tell a reader the feed is fine while the
+ agent's own gate is refusing to trade on it.
+
+ `granularity` is the caller's, and it must be the one `_entry_gate_granularity` would pick
+ for THIS position's rule -- see `_gate_granularity_for`. Asking about the coarsest series for
+ every row (the first version of this) reports that function's FALLBACK as though it were its
+ answer: correct for a daily deployment, wrong for any rule seeded on a finer timeframe, and
+ worded with the same confidence either way.
+ """
+ granularities = config.market_data.granularities
+ if granularity is None or not granularities:
+ return False, "missing"
+ candles_by_tf = {g: repo.get_candles(product_id, g) for g in granularities}
+ verdict = freshness_mod.entry_bar_ready(candles_by_tf, granularity, now_ts)
+ return verdict.ready, verdict.reason
+
+
+def _gate_granularities(repo: Repository, config: Config) -> dict[str, Granularity | None]:
+ """`{rule name: the granularity the agent's entry gate would use}`, built ONCE.
+
+ Keyed on the rule's `name` and NOT on `rules.kind`, because `positions.rule_name` holds the
+ name -- a constructor argument that defaults to the kind and is not the same field. Keyed by
+ kind, two `turtle_breakout` rows on different timeframes (a configuration this codebase
+ supports) collapse to whichever row was read last, and one tranche silently inherits the
+ other's granularity.
+
+ Each rule is built once here rather than once per tranche: `_build_rule` runs the rule's real
+ constructor with its validation, and a DCA book is one rule with many tranches, so per-row
+ building is the common case rather than the edge one.
+
+ A NAME THAT ANSWERS TO TWO DIFFERENT GRANULARITIES maps to `None`. `rules.name` is not unique
+ in the schema, so which row opened a given tranche is genuinely unknowable from `rule_name`
+ alone -- and a chip that picked one and stated it with the same confidence as a resolved one
+ would be asserting something nobody can check. `None` sends the caller to the fallback, which
+ is what the agent itself uses for a rule that declares nothing.
+
+ A row whose params no longer build -- a renamed field, a kind since removed -- is skipped for
+ the same reason: unknowable, so fall back rather than raise. A chip is not worth a 500.
+ """
+ granularities = list(config.market_data.granularities)
+ gates: dict[str, Granularity | None] = {}
+ seen: set[str] = set()
+ for row in repo.get_rules():
+ try:
+ rule = agent_mod._build_rule(row)
+ except Exception:
+ continue
+ name = str(getattr(rule, "name", "") or row.get("kind") or "")
+ if not name:
+ continue
+ gate = agent_mod._entry_gate_granularity(rule, granularities)
+ if name in seen and gates.get(name) != gate:
+ gates[name] = None
+ else:
+ gates[name] = gate
+ seen.add(name)
+ return gates
+
+
+def _fallback_granularity(config: Config) -> Granularity | None:
+ """What the agent gates a rule on when the rule declares nothing: the COARSEST configured
+ series. `_entry_gate_granularity`'s own fallback, and for its reason -- `Dca` reads the daily
+ bar directly, so gating it on the finest would miss a weeks-stale daily bar entirely."""
+ granularities = list(config.market_data.granularities)
+ if not granularities:
+ return None
+ return max(granularities, key=_granularity_rank)
+
+
+def _granularity_rank(granularity: Granularity) -> int:
+ """Ordering over granularities, read from the agent's own table so the two cannot disagree
+ about which series is finer."""
+ return agent_mod._GRANULARITY_ORDER.get(granularity, 0)
+
+
+def gather_positions(repo: Repository, config: Config, *, now_ts: int) -> PositionsReport:
+ """Every OPEN tranche, marked and judged.
+
+ Open only: a closed tranche is a `trade_outcomes` row and belongs to the journal, which
+ already reports it with its realised P&L. Showing both here would put one trade in two places
+ with two different figures for it.
+
+ The candle cache is read ONCE PER PRODUCT rather than once per tranche -- a book can hold
+ several tranches of the same product (that is what tranches are for), and a per-row read
+ would be one query per tranche for one answer they all share.
+ """
+ mark_granularity = agent_mod._finest_granularity(list(config.market_data.granularities))
+ # ONE read of the rules table and ONE build per rule, before the row loop -- see
+ # `_gate_granularities`. A lookup per tranche would be one query and one constructor call per
+ # row for an answer the rows share, and would be invisible on any fixture small enough to
+ # read.
+ gates = _gate_granularities(repo, config)
+ fallback = _fallback_granularity(config)
+
+ marks: dict[str, tuple[Decimal | None, int | None]] = {}
+ # Keyed on (product, gate granularity), NOT on product alone: one product can hold tranches
+ # opened by rules on different timeframes, and a per-product cache would hand the second
+ # tranche the first one's verdict.
+ readiness: dict[tuple[str, Granularity | None], tuple[bool, str | None]] = {}
+ rows: list[PositionRow] = []
+ for raw in repo.get_open_positions():
+ product_id = str(raw.get("product_id") or "")
+ if product_id not in marks:
+ marks[product_id] = _mark_for(repo, product_id, mark_granularity)
+ # `.get(...) or fallback` collapses the two unresolvable cases onto one answer: a name
+ # nothing matches, and a name two rules answer to with different granularities.
+ gate = gates.get(str(raw.get("rule_name") or "")) or fallback
+ key = (product_id, gate)
+ if key not in readiness:
+ readiness[key] = _readiness_for(repo, product_id, gate, config, now_ts)
+ mark, mark_ts = marks[product_id]
+ ready, ready_reason = readiness[key]
+ rows.append(_row_from_dict(raw, mark, mark_ts, ready, ready_reason))
+ return PositionsReport(now_ts=now_ts, rows=tuple(rows))
+
+
+def _row_from_dict(
+ raw: dict[str, Any],
+ mark: Decimal | None,
+ mark_ts: int | None,
+ ready: bool,
+ ready_reason: str | None,
+) -> PositionRow:
+ """One repository dict, projected. Every judgement this report makes is made here, once, so
+ no renderer has to make it twice."""
+ # Direct reads, not `raw.get(...) or Decimal("0")`. These three columns are NOT NULL in the
+ # `positions` DDL, so the fallback could only ever rewrite a zero as itself -- while quietly
+ # substituting one the day a column became nullable. That is the substitution
+ # `_position_row_to_dict` deliberately refuses for `initial_stop`, for the same reason.
+ qty = raw["qty"]
+ entry_fill = raw["entry_fill"]
+ initial_stop = raw.get("initial_stop")
+
+ market_value = None if mark is None else qty * mark
+ unrealized = None if mark is None else qty * (mark - entry_fill)
+
+ if mark is None or initial_stop is None:
+ stop_distance: Decimal | None = None
+ stop_distance_pct: Decimal | None = None
+ else:
+ stop_distance = mark - initial_stop
+ stop_distance_pct = _safe_ratio(stop_distance, mark)
+
+ return PositionRow(
+ id=int(raw["id"]),
+ product_id=str(raw.get("product_id") or ""),
+ rule_name=str(raw.get("rule_name") or ""),
+ opened_at=int(raw["opened_at"]),
+ qty=qty,
+ entry_fill=entry_fill,
+ entry_fee=raw["entry_fee"],
+ mark=mark,
+ mark_ts=mark_ts,
+ market_value=market_value,
+ unrealized_pnl=unrealized,
+ initial_stop=initial_stop,
+ stop_distance=stop_distance,
+ stop_distance_pct=stop_distance_pct,
+ realized_qty=raw.get("realized_qty") or Decimal("0"),
+ realized_proceeds=raw.get("realized_proceeds") or Decimal("0"),
+ realized_fees=raw.get("realized_fees") or Decimal("0"),
+ ready=ready,
+ ready_reason=ready_reason,
+ )
diff --git a/keel/web/api.py b/keel/web/api.py
index 668974b..845adfd 100644
--- a/keel/web/api.py
+++ b/keel/web/api.py
@@ -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.
@@ -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,
diff --git a/keel/web/payload.py b/keel/web/payload.py
index e69d9fc..1420fc3 100644
--- a/keel/web/payload.py
+++ b/keel/web/payload.py
@@ -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,
@@ -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
diff --git a/keel/web/static/index.html b/keel/web/static/index.html
index fbe8c97..0aac406 100644
--- a/keel/web/static/index.html
+++ b/keel/web/static/index.html
@@ -118,6 +118,7 @@
Setup
Activity
Orders
+ Positions
Insights
Rules
Venues
diff --git a/keel/web/static/js/main.js b/keel/web/static/js/main.js
index 108b2d8..123975e 100644
--- a/keel/web/static/js/main.js
+++ b/keel/web/static/js/main.js
@@ -45,6 +45,7 @@ import {
insightsView,
modeBadge,
ordersView,
+ positionsView,
refusedView,
rulesView,
setupView,
@@ -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"] },
@@ -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);
diff --git a/keel/web/static/js/render.js b/keel/web/static/js/render.js
index a9e089d..ff9cce7 100644
--- a/keel/web/static/js/render.js
+++ b/keel/web/static/js/render.js
@@ -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.
*
diff --git a/keel/web/staticfiles.py b/keel/web/staticfiles.py
index a0e0ad5..abac86d 100644
--- a/keel/web/staticfiles.py
+++ b/keel/web/staticfiles.py
@@ -141,6 +141,7 @@ def resolve_static_asset(root: Path, url_path: str) -> Path | None:
"setup",
"activity",
"orders",
+ "positions",
"insights",
"rules",
"venues",
diff --git a/tests/commands/test_positions.py b/tests/commands/test_positions.py
new file mode 100644
index 0000000..8614eee
--- /dev/null
+++ b/tests/commands/test_positions.py
@@ -0,0 +1,503 @@
+"""The positions report -- issue #701.
+
+`positions` stores one row per TRANCHE, and until now the only projection of it was the small
+table inside `keel status`: id, product, rule, qty, entry price, bracket. `web/payload.py`'s
+`_position_payload` says in its own docstring why it carries no P&L -- "`OpenPositionStatus`
+carries neither, so emitting one would mean this layer multiplied `qty` by `entry_price`... the
+fix, if it is wanted, is upstream". This module is that fix, upstream.
+
+The property the whole thing rests on is that the MARK is the one the rails used. `agent`
+values equity from `repo.get_candles(product, finest)[-1].close`; so does this. A positions page
+showing a different current price from the one that moved the drawdown scalars would be two
+answers to "what is this worth", and the page would be the wrong one.
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+import pytest
+from keel_core.types import Candle, Granularity
+
+from keel import agent as agent_mod
+from keel.commands.positions import gather_positions
+from keel.config import Config
+from keel.data.db import connect, migrate
+from keel.data.repository import Repository
+from tests.conftest import VALID_CONFIG_YAML
+
+NOW_TS = 1_800_000_000
+DAY = 86_400
+PRODUCT = "BTC-USD"
+
+
+@pytest.fixture()
+def repo(tmp_path: Path) -> Repository:
+ conn = connect(str(tmp_path / "keel.db"))
+ migrate(conn)
+ return Repository(conn)
+
+
+def _config(tmp_path: Path) -> Config:
+ from keel.config import load_config
+
+ path = tmp_path / "config.yaml"
+ path.write_text(VALID_CONFIG_YAML, encoding="utf-8")
+ return load_config(str(path))
+
+
+def _candle(ts: int, close: str) -> Candle:
+ price = Decimal(close)
+ return Candle(ts=ts, open=price, high=price, low=price, close=price, volume=Decimal("1"))
+
+
+def _open_tranche(repo: Repository, **overrides: Any) -> int:
+ row: dict[str, Any] = {
+ "product_id": PRODUCT,
+ "rule_name": "turtle_breakout",
+ "opened_at": NOW_TS - DAY,
+ "qty": Decimal("2"),
+ "entry_fill": Decimal("100"),
+ "entry_fee": Decimal("1.20"),
+ "initial_stop": Decimal("90"),
+ }
+ row.update(overrides)
+ return repo.open_position(**row)
+
+
+#: The FINEST series `tests/conftest.py::VALID_CONFIG_YAML` configures, and therefore the one
+#: the agent marks against. Not hardcoded out of habit: a helper that wrote any other series
+#: would leave every mark `None` and every assertion below would fail for the wrong reason.
+FINEST = Granularity.FIFTEEN_MINUTE
+
+
+def _mark(repo: Repository, close: str, granularity: Granularity = FINEST) -> None:
+ repo.upsert_candles(PRODUCT, granularity, [_candle(NOW_TS - 900, close)])
+
+
+# -- the mark, and everything derived from it ---------------------------------------------------
+
+
+def test_a_tranche_is_marked_at_the_latest_close(repo: Repository, tmp_path: Path) -> None:
+ _open_tranche(repo)
+ _mark(repo, "150")
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.mark == Decimal("150")
+ assert row.market_value == Decimal("2") * Decimal("150")
+ assert row.unrealized_pnl == Decimal("2") * (Decimal("150") - Decimal("100"))
+
+
+def test_an_unmarked_product_reports_no_mark_rather_than_a_zero(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """No candle is "not observed", never "worth nothing". A zero here would render a held
+ position as a total loss, which is the single most alarming thing a positions page could say
+ and it would be saying it about missing data."""
+ _open_tranche(repo)
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.mark is None
+ assert row.market_value is None
+ assert row.unrealized_pnl is None
+
+
+def test_unrealized_is_negative_while_the_position_is_under_water(
+ repo: Repository, tmp_path: Path
+) -> None:
+ _open_tranche(repo)
+ _mark(repo, "80")
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.unrealized_pnl == Decimal("-40")
+
+
+def test_the_mark_is_the_one_the_rails_valued_the_account_at(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """THE acceptance criterion. `agent._mark_to_market_parts` values a holding at
+ `get_candles(product, finest)[-1].close`; a positions page quoting a different current price
+ would disagree with the equity that moved the drawdown scalars.
+
+ Two granularities are stored with DIFFERENT closes, so a reader of the wrong series fails.
+ """
+ _open_tranche(repo)
+ repo.upsert_candles(PRODUCT, Granularity.ONE_DAY, [_candle(NOW_TS - DAY, "111")])
+ repo.upsert_candles(PRODUCT, Granularity.ONE_HOUR, [_candle(NOW_TS - 3600, "222")])
+ repo.upsert_candles(PRODUCT, FINEST, [_candle(NOW_TS - 900, "333")])
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.mark == Decimal("333"), "the FINEST configured series is the agent's mark"
+
+
+def test_the_entry_fee_is_carried(repo: Repository, tmp_path: Path) -> None:
+ """What the tranche cost to open, beside what it is worth. `keel status` never showed it and
+ the fee is the half of a round trip a reader most often forgets."""
+ _open_tranche(repo, entry_fee=Decimal("1.20"))
+
+ assert gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0].entry_fee == Decimal(
+ "1.20"
+ )
+
+
+# -- the stop -----------------------------------------------------------------------------------
+
+
+def test_the_stop_distance_is_reported_as_a_price_and_a_fraction(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """How far this tranche is from the protection it was sized against. Both forms, because
+ "$60 away" and "40% away" answer different questions and neither is derivable in the
+ browser."""
+ _open_tranche(repo, initial_stop=Decimal("90"))
+ _mark(repo, "150")
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.stop_distance == Decimal("60")
+ assert row.stop_distance_pct == Decimal("60") / Decimal("150")
+
+
+def test_a_tranche_with_no_recorded_stop_reports_no_distance(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """`initial_stop` is NULL for a DCA leg or a pre-v12 tranche -- "not on this row", not "no
+ stop". A distance computed against a substituted zero would read as a position 100% clear of
+ its stop, which is the most reassuring possible rendering of missing data."""
+ _open_tranche(repo, initial_stop=None)
+ _mark(repo, "150")
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.initial_stop is None
+ assert row.stop_distance is None
+ assert row.stop_distance_pct is None
+
+
+def test_no_mark_means_no_stop_distance_either(repo: Repository, tmp_path: Path) -> None:
+ """The distance is measured from the CURRENT price. With no mark there is nothing to measure
+ from, and measuring from the entry instead would quietly answer a different question."""
+ _open_tranche(repo, initial_stop=Decimal("90"))
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.stop_distance is None
+
+
+def test_a_position_below_its_stop_reports_a_negative_distance(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """Signed, not absolute. A tranche trading THROUGH its stop is the state an operator most
+ needs to see, and an absolute distance would render it identically to one safely above."""
+ _open_tranche(repo, initial_stop=Decimal("90"))
+ _mark(repo, "85")
+
+ assert gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0].stop_distance == (
+ Decimal("-5")
+ )
+
+
+# -- partial exits --------------------------------------------------------------------------------
+
+
+def test_realized_legs_are_reported_beside_the_running_position(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """A scaled-out tranche is one trade with legs already booked. `qty` is what is STILL held,
+ so without the realized side the row understates what the tranche has done."""
+ position_id = _open_tranche(repo, qty=Decimal("2"))
+ repo.reduce_position(
+ position_id,
+ remaining_qty=Decimal("1"),
+ realized_qty=Decimal("1"),
+ realized_proceeds=Decimal("140"),
+ realized_fees=Decimal("0.50"),
+ )
+ _mark(repo, "150")
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.qty == Decimal("1"), "qty is what is still held"
+ assert row.realized_qty == Decimal("1")
+ assert row.realized_proceeds == Decimal("140")
+ assert row.realized_fees == Decimal("0.50")
+ # And the unrealized side is measured on the REMAINING quantity only.
+ assert row.unrealized_pnl == Decimal("1") * (Decimal("150") - Decimal("100"))
+
+
+def test_a_tranche_that_never_partially_exited_reports_zeros_not_absences(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """The repository's own convention (`_position_row_to_dict`): there is no difference between
+ "never partially exited" and "has realized nothing", so zero invents nothing here."""
+ _open_tranche(repo)
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.realized_qty == Decimal("0")
+ assert row.realized_proceeds == Decimal("0")
+
+
+# -- what the report covers ------------------------------------------------------------------------
+
+
+def test_only_open_tranches_are_reported(repo: Repository, tmp_path: Path) -> None:
+ open_id = _open_tranche(repo)
+ closed_id = _open_tranche(repo)
+ repo.close_position(closed_id, closed_at=NOW_TS)
+
+ rows = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows
+
+ assert [row.id for row in rows] == [open_id]
+
+
+def test_an_empty_book_is_a_real_answer(repo: Repository, tmp_path: Path) -> None:
+ report = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert report.rows == ()
+ assert report.open_count == 0
+
+
+# -- the acceptance criterion: one mark, two readers ---------------------------------------------
+
+
+def test_the_unrealized_total_reconciles_with_the_agents_own_equity_read(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """#701's acceptance criterion, checked across the module boundary rather than asserted.
+
+ The two readers do NOT share a source for quantity: `agent._mark_to_market_parts` counts
+ inventory from the FILLED ORDERS log (never from `positions`, deliberately -- see its own
+ note on the phantom-drawdown bug that caused), while this report reads the `positions`
+ tranche ledger. They share only the MARK. So this test is what says the two ledgers agree
+ about what is held, and it fails the day they drift -- which is the failure that would put a
+ different unrealized figure on the page from the one inside rail 11's equity.
+ """
+ from tests.test_agent import FakeBroker, _seed_open_position
+
+ _seed_open_position(
+ repo,
+ PRODUCT,
+ Decimal("2"),
+ Decimal("100"),
+ ts=NOW_TS - DAY,
+ rule_name="turtle_breakout",
+ )
+ _mark(repo, "150")
+
+ rows = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows
+ page_total = sum((row.unrealized_pnl or Decimal("0") for row in rows), Decimal("0"))
+
+ parts = agent_mod._mark_to_market_parts(
+ repo, FakeBroker(), [PRODUCT], {PRODUCT: Decimal("150")}, "USD"
+ )
+
+ assert parts is not None
+ assert page_total == parts.unrealized == Decimal("100")
+
+
+# -- the chip must ask the question the AGENT asks (#701 review finding) ------------------------
+#
+# `_entry_gate_granularity` gates a rule on the timeframe the rule DECLARES, and falls back to
+# the coarsest configured series only when it declares none. A chip that always asked about the
+# coarsest would be reporting the fallback as though it were the answer -- right for a daily
+# deployment, wrong for any rule seeded on a finer timeframe, and confidently worded either way.
+
+
+def test_the_gate_verdict_follows_the_rules_own_declared_timeframe(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """A rule seeded on ONE_HOUR is gated on ONE_HOUR. Here the DAILY series is present and the
+ HOURLY one is absent, so the two possible readings disagree: the coarsest series says
+ something other than "missing", and the rule's own series says "missing"."""
+ repo.insert_rule("turtle_breakout", {"product_id": PRODUCT, "granularity": "ONE_HOUR"})
+ _open_tranche(repo, rule_name="turtle_breakout")
+ repo.upsert_candles(PRODUCT, Granularity.ONE_DAY, [_candle(NOW_TS - DAY, "100")])
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.ready is False
+ assert row.ready_reason == "missing", "the ONE_HOUR series this rule trades on has no bars"
+
+
+def test_a_rule_that_declares_nothing_falls_back_to_the_coarsest_series(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """`_entry_gate_granularity`'s own fallback, and the reason for it: a rule silent about its
+ timeframe is most likely keying off the coarsest series (DCA reads the daily bar directly),
+ so gating on the finest would miss a weeks-stale daily bar entirely."""
+ repo.insert_rule("dca", {"product_id": PRODUCT})
+ _open_tranche(repo, rule_name="dca")
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.ready is False
+ assert row.ready_reason == "missing"
+
+
+def test_an_unmatched_rule_name_degrades_to_the_coarsest_rather_than_crashing(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """`positions.rule_name` is the rule's `name`, which is a separate constructor argument from
+ the `kind` the rules table is keyed on -- they coincide by default and are not guaranteed to.
+ An unmatched name must leave the chip on the safe fallback, not take the page down."""
+ _open_tranche(repo, rule_name="a_rule_no_longer_in_the_book")
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ assert row.ready_reason == "missing"
+
+
+def test_two_tranches_of_one_product_on_different_timeframes_get_their_own_verdicts(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """The per-product cache the first version used cannot express this: one product, two rules,
+ two gate granularities, two answers. Caching by product alone would give the second tranche
+ the first one's verdict."""
+ repo.insert_rule("turtle_breakout", {"product_id": PRODUCT, "granularity": "ONE_DAY"})
+ repo.insert_rule("pullback_continuation", {"product_id": PRODUCT, "granularity": "ONE_HOUR"})
+ _open_tranche(repo, rule_name="turtle_breakout")
+ _open_tranche(repo, rule_name="pullback_continuation")
+ # Only the DAILY series exists, and it is at its expected bar.
+ repo.upsert_candles(PRODUCT, Granularity.ONE_DAY, [_candle(NOW_TS - DAY, "100")])
+
+ rows = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows
+ by_rule = {row.rule_name: row for row in rows}
+
+ assert by_rule["pullback_continuation"].ready_reason == "missing"
+ assert by_rule["turtle_breakout"].ready_reason != "missing"
+
+
+def test_the_rules_table_is_read_once_however_many_tranches(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """Resolving a gate granularity per row would be one rules read per tranche. The map is built
+ once, like `gather_orders`' rule-name map."""
+ repo.insert_rule("turtle_breakout", {"product_id": PRODUCT, "granularity": "ONE_DAY"})
+ for _ in range(6):
+ _open_tranche(repo, rule_name="turtle_breakout")
+
+ reads = {"n": 0}
+ original = repo.get_rules
+
+ def counting(*args: Any, **kwargs: Any) -> Any:
+ reads["n"] += 1
+ return original(*args, **kwargs)
+
+ repo.get_rules = counting # type: ignore[method-assign]
+ gather_positions(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert reads["n"] == 1
+
+
+# -- the rule lookup is by NAME, and names can collide (#701 review) ----------------------------
+
+
+def test_two_rules_of_one_kind_on_different_timeframes_keep_their_own_verdicts(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """The map is keyed on what `positions.rule_name` actually holds -- the rule's `name`, a
+ constructor argument -- and NOT on `rules.kind`. Two `turtle_breakout` rows on different
+ timeframes is a configuration this codebase supports; keyed by kind they collapse to
+ whichever row was read last, and one tranche silently inherits the other's granularity."""
+ repo.insert_rule(
+ "turtle_breakout",
+ {"product_id": PRODUCT, "granularity": "ONE_DAY", "name": "turtle_daily"},
+ )
+ repo.insert_rule(
+ "turtle_breakout",
+ {"product_id": PRODUCT, "granularity": "ONE_HOUR", "name": "turtle_hourly"},
+ )
+ _open_tranche(repo, rule_name="turtle_daily")
+ _open_tranche(repo, rule_name="turtle_hourly")
+ repo.upsert_candles(PRODUCT, Granularity.ONE_DAY, [_candle(NOW_TS - DAY, "100")])
+
+ rows = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows
+ by_rule = {row.rule_name: row for row in rows}
+
+ assert by_rule["turtle_hourly"].ready_reason == "missing", "gated on the absent ONE_HOUR"
+ assert by_rule["turtle_daily"].ready_reason != "missing", "gated on the present ONE_DAY"
+
+
+def test_two_rules_sharing_a_name_degrade_to_the_fallback(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """`name` is not unique in the schema. When two rows answer to one name with DIFFERENT gate
+ granularities, which one opened a tranche is genuinely unknowable from `rule_name` alone --
+ so the chip takes the fallback rather than picking one and stating it with confidence."""
+ repo.insert_rule(
+ "turtle_breakout", {"product_id": PRODUCT, "granularity": "ONE_DAY", "name": "same"}
+ )
+ repo.insert_rule(
+ "turtle_breakout", {"product_id": PRODUCT, "granularity": "ONE_HOUR", "name": "same"}
+ )
+ _open_tranche(repo, rule_name="same")
+ repo.upsert_candles(PRODUCT, Granularity.ONE_DAY, [_candle(NOW_TS - DAY, "100")])
+
+ row = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows[0]
+
+ # The fallback is the COARSEST series, which is present here -- so "not missing" is the
+ # observable consequence of having declined to guess.
+ assert row.ready_reason != "missing"
+
+
+def test_each_rule_is_built_once_however_many_tranches_it_opened(
+ repo: Repository, tmp_path: Path, monkeypatch: Any
+) -> None:
+ """`_build_rule` runs a rule's real constructor, with its validation. Doing that per TRANCHE
+ is the same waste as a per-row query, and on a DCA book (one rule, many tranches) it is the
+ common case rather than the edge one."""
+ repo.insert_rule(
+ "turtle_breakout",
+ {"product_id": PRODUCT, "granularity": "ONE_DAY", "name": "turtle_breakout"},
+ )
+ for _ in range(6):
+ _open_tranche(repo, rule_name="turtle_breakout")
+
+ builds = {"n": 0}
+ original = agent_mod._build_rule
+
+ def counting(row: Any) -> Any:
+ builds["n"] += 1
+ return original(row)
+
+ monkeypatch.setattr(agent_mod, "_build_rule", counting)
+ gather_positions(repo, _config(tmp_path), now_ts=NOW_TS)
+
+ assert builds["n"] == 1, f"built the rule {builds['n']} times for 6 tranches"
+
+
+def test_the_mark_cache_does_not_leak_between_products(
+ repo: Repository, tmp_path: Path
+) -> None:
+ """A cache keyed carelessly would hand product B product A's price, and every figure derived
+ from it would be confidently wrong with nothing in the row to show it."""
+ _open_tranche(repo, product_id="BTC-USD")
+ _open_tranche(repo, product_id="ETH-USD")
+ repo.upsert_candles("BTC-USD", FINEST, [_candle(NOW_TS - 900, "150")])
+ repo.upsert_candles("ETH-USD", FINEST, [_candle(NOW_TS - 900, "20")])
+
+ rows = gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).rows
+ by_product = {row.product_id: row for row in rows}
+
+ assert by_product["BTC-USD"].mark == Decimal("150")
+ assert by_product["ETH-USD"].mark == Decimal("20")
+
+
+def test_the_products_list_is_in_first_seen_order(repo: Repository, tmp_path: Path) -> None:
+ """What a grouped view renders sections from. Sorted or set-ordered, the page would reorder
+ itself between reads for no reason a reader could see."""
+ _open_tranche(repo, product_id="SOL-USD")
+ _open_tranche(repo, product_id="BTC-USD")
+ _open_tranche(repo, product_id="SOL-USD")
+
+ assert gather_positions(repo, _config(tmp_path), now_ts=NOW_TS).products == (
+ "SOL-USD",
+ "BTC-USD",
+ )
diff --git a/tests/web/test_api.py b/tests/web/test_api.py
index 578d759..c36e804 100644
--- a/tests/web/test_api.py
+++ b/tests/web/test_api.py
@@ -58,6 +58,7 @@
"/api/setup",
"/api/activity",
"/api/orders",
+ "/api/positions",
"/api/insights",
"/api/journal",
"/api/rules",
@@ -66,6 +67,22 @@
)
+def test_this_module_pins_every_route_the_server_serves() -> None:
+ """`API_ROUTES` above is hand-written, and everything in this file is parametrised over it --
+ the envelope, the no-JSON-number walk, the cache headers, the nosniff header and the
+ POST refusal. A route left out of it is served with none of those checked.
+
+ That happened: `/api/positions` (#701) shipped outside the tuple, so its reader was executed
+ by no test at all while its payload builder was well covered -- and this module's own header
+ says those are not the same statement. Cross-checked here so the next one cannot.
+ """
+ from keel.web import api as web_api
+
+ assert set(API_ROUTES) == set(web_api.API_ROUTES), (
+ "API_ROUTES here must name every route the server actually serves"
+ )
+
+
# -- a real server ------------------------------------------------------------------------------
diff --git a/tests/web/test_client_assets.py b/tests/web/test_client_assets.py
index d5565a2..6f4f81b 100644
--- a/tests/web/test_client_assets.py
+++ b/tests/web/test_client_assets.py
@@ -1495,3 +1495,156 @@ def test_every_table_emits_one_cell_per_declared_header() -> None:
assert len(pairs) >= 6, f"the table scan found only {len(pairs)} tables; it has stopped working"
mismatched = [(headers, cells) for headers, cells in pairs if headers != cells]
assert mismatched == [], f"(headers, cells) mismatches: {mismatched}"
+
+
+#: Globals the browser provides, which no module declares and every module may call.
+#:
+#: Deliberately short. A long list is a list that absorbs a typo -- the whole value of the scan
+#: below is that an undefined name is loud, so anything added here should be a real browser API
+#: someone can point at.
+_BROWSER_GLOBALS = frozenset(
+ {
+ "Array",
+ "Boolean",
+ "Date",
+ "FormData",
+ "JSON",
+ "Map",
+ "Math",
+ "Number",
+ "Object",
+ "Set",
+ "String",
+ "console",
+ "document",
+ "window",
+ }
+)
+
+#: Keywords that are followed by a parenthesis and are not calls.
+_NOT_CALLS = frozenset(
+ {"await", "catch", "delete", "for", "function", "if", "instanceof", "new", "return",
+ "super", "switch", "typeof", "void", "while"}
+)
+
+
+def _scannable_functions(code: str) -> list[tuple[str, str]]:
+ """Every function body the call scan walks, as `(parameter list, body)`.
+
+ Both shapes this codebase writes: `function name(params) { ... }` and the module-level
+ `const name = (params) => { ... }`. The parameter list is taken by BALANCED PARENTHESES
+ rather than `([^)]*)`, because a default value (`function f(a = el())`) closes the character
+ class early -- and the failure mode of that was not a false positive but a silent DROP: the
+ function stopped being scanned at all, quietly, which is the worst way for a guard to fail.
+ """
+ out: list[tuple[str, str]] = []
+ for match in re.finditer(r"\bfunction\s+[A-Za-z_$][\w$]*\s*\(", code):
+ params, after = _balanced_block(code, match.end() - 1)
+ brace = code.find("{", after)
+ if brace == -1:
+ continue
+ out.append((params, _balanced_block(code, brace)[0]))
+ for match in re.finditer(r"\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*\(", code):
+ params, after = _balanced_block(code, match.end() - 1)
+ arrow = code[after : after + 4]
+ if "=>" not in arrow:
+ continue
+ brace = code.find("{", after)
+ if brace == -1:
+ continue
+ out.append((params, _balanced_block(code, brace)[0]))
+ return out
+
+
+def _undefined_calls(name: str) -> list[str]:
+ """Bare-identifier calls in `name` that are not in scope where they are made.
+
+ Scoped PER FUNCTION, which is the part that took two attempts. A first version collected
+ every function's parameters into one module-wide set, so `sorting` -- a parameter of `table`
+ and of `headerCell` -- counted as defined inside `positionsView`, and that is exactly the bug
+ this scan exists to catch. Parameters are in scope in their own function only.
+
+ KNOWN BLIND SPOTS, stated rather than implied. Object-literal and class methods are not
+ walked (this codebase writes neither in these two modules). Arrow PARAMETERS are admitted
+ function-wide rather than per-arrow, so a callback argument sharing a name with a missing
+ function would mask it -- real JavaScript scoping needs a parser, and a regex that pretended
+ to do it would be worse than one whose limits are written down. What the scan does cover is
+ the mistake that actually shipped: calling a name that exists in the file as a typedef, a
+ parameter of some other function, or nothing at all.
+ """
+ code = _code_only(_source(name))
+
+ module_level = set(re.findall(r"\bfunction\s+([A-Za-z_$][\w$]*)", code))
+ module_level |= set(re.findall(r"^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=", code, re.M))
+ for block in re.findall(r"import\s*\{([^}]*)\}", code):
+ for imported in block.split(","):
+ imported = imported.strip().split(" as ")[-1].strip()
+ if imported:
+ module_level.add(imported)
+
+ undefined: set[str] = set()
+ for params, body in _scannable_functions(code):
+ in_scope = set(module_level)
+ for param in params[1:-1].split(","):
+ param = param.split("=")[0].strip()
+ if param:
+ in_scope.add(param)
+ in_scope |= set(re.findall(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)", body))
+ in_scope |= set(re.findall(r"\(([A-Za-z_$][\w$]*)\)\s*=>", body))
+ in_scope |= set(re.findall(r"\b([A-Za-z_$][\w$]*)\s*=>", body))
+ called = {m.group(1) for m in re.finditer(r"(? None:
+ """No JavaScript runs in this suite, so a call to a function that does not exist ships.
+
+ It is not hypothetical. #701's positions table was written calling `sorting(sort, onSort)` --
+ `sorting` is a TYPEDEF and a parameter name in this file, never a function -- and every gate
+ stayed green: mypy does not read JavaScript, ruff does not either, and the view tests assert
+ over source text rather than executing it. The page would have thrown `ReferenceError` on
+ first render.
+
+ Scoped to the two derivation-free modules rather than the whole client: they are the ones
+ that touch almost no browser API, so the short `_BROWSER_GLOBALS` list stays honest. Widening
+ it to `main.js` would mean listing `fetch`, `setTimeout`, `URL` and friends, and a list long
+ enough to cover those is long enough to hide a typo.
+ """
+ scanned = _scannable_functions(_code_only(_source(name)))
+ assert len(scanned) >= 5, (
+ f"the call scan found only {len(scanned)} functions in {name}; it has stopped working"
+ )
+ assert _undefined_calls(name) == []
+
+
+@pytest.mark.parametrize(
+ "snippet",
+ [
+ "function planted() { return nope(); }",
+ # The default value that used to close `([^)]*)` early and silently drop the function.
+ "function planted(a = el()) { return nope(); }",
+ # A module-level arrow, which the first version never walked at all.
+ "const planted = (a) => { return nope(); };",
+ ],
+)
+def test_the_call_scanner_sees_every_shape_this_codebase_writes(snippet: str) -> None:
+ """The premise, per shape. A scan that silently skipped one of these would report a clean
+ file while the missing call sat inside it -- which is how the guard fails without saying so.
+ """
+ code = _code_only(snippet)
+ found: set[str] = set()
+ for params, body in _scannable_functions(code):
+ called = {m.group(1) for m in re.finditer(r"(? None:
+ """The premise. A scanner whose regex missed every call would pass any file."""
+ code = "function a() { return b(1); }"
+ called = {m.group(1) for m in re.finditer(r"(? list[tuple[str, Any]]:
+ """Every leaf in a parsed JSON document, with the path that reaches it."""
+ if isinstance(node, dict):
+ out: list[tuple[str, Any]] = []
+ for key, value in node.items():
+ out.extend(_walk(value, f"{path}.{key}"))
+ return out
+ if isinstance(node, list):
+ out = []
+ for index, value in enumerate(node):
+ out.extend(_walk(value, f"{path}[{index}]"))
+ return out
+ return [(path, node)]
+
+
+# -- the positions payload (#701) ---------------------------------------------------------------
+
+
+def _positions_report(tmp_path: Path, **overrides: Any) -> Any:
+ from tests.commands.test_positions import _config, _mark, _open_tranche
+
+ conn = connect(str(tmp_path / "positions.db"))
+ migrate(conn)
+ repo = Repository(conn)
+ _open_tranche(repo, **overrides.pop("tranche", {}))
+ if "mark" in overrides:
+ mark = overrides.pop("mark")
+ if mark is not None:
+ _mark(repo, mark)
+ else:
+ _mark(repo, "150")
+ return gather_positions(repo, _config(tmp_path), now_ts=POS_NOW_TS)
+
+
+def test_no_wire_value_in_the_positions_payload_is_ever_a_json_number(tmp_path: Path) -> None:
+ """#533's contract, over this payload. Positions carry more money per row than any other
+ view -- entry, fee, mark, market value, unrealized, stop, realized -- so it is the one most
+ likely to leak a float."""
+ document = json.loads(
+ json.dumps(web_payload.positions_payload(_positions_report(tmp_path)))
+ )
+ numbers = [
+ path
+ for path, leaf in _walk(document)
+ if not isinstance(leaf, bool) and isinstance(leaf, (int, float))
+ ]
+ assert numbers == [], numbers
+
+
+def test_the_unrealized_leg_carries_its_verdict_and_the_balances_do_not(tmp_path: Path) -> None:
+ """Rule 3, applied per figure. Unrealized P&L is a gain-or-loss and gets the glyph and the
+ state; a market value is a magnitude -- an account is not "good" for being worth something,
+ and a green number beside every held position would make the one that matters invisible."""
+ row = web_payload.positions_payload(_positions_report(tmp_path))["rows"][0]
+
+ assert row["unrealized"]["state"] == "good"
+ assert row["unrealized"]["display"].startswith("\u25b2")
+ assert row["market_value"]["state"] == "neutral"
+ assert row["entry_fill"]["state"] == "neutral"
+
+
+def test_a_losing_tranche_reads_as_bad_without_the_client_seeing_a_minus(
+ tmp_path: Path,
+) -> None:
+ row = web_payload.positions_payload(_positions_report(tmp_path, mark="80"))["rows"][0]
+
+ assert row["unrealized"]["state"] == "bad"
+
+
+def test_an_unmarked_tranche_crosses_as_absent_not_as_zero(tmp_path: Path) -> None:
+ """The distinction the service drew, surviving serialisation. `$0.00` market value would
+ render a held position as a total loss on the strength of a missing candle."""
+ row = web_payload.positions_payload(_positions_report(tmp_path, mark=None))["rows"][0]
+
+ assert row["mark"]["state"] == "unknown"
+ assert row["market_value"]["state"] == "unknown"
+ assert row["unrealized"]["state"] == "unknown"
+ # The entry side is still known -- it was recorded when the tranche opened.
+ assert row["entry_fill"]["value"] == "100"
+
+
+def test_the_stop_distance_crosses_as_both_a_price_and_a_fraction(tmp_path: Path) -> None:
+ """Two questions, two fields. The fraction crosses UNRESCALED and with no `%`, the same
+ posture `ratio` documents for the drawdown scalars -- multiplying by 100 here would be the
+ serialiser inventing a figure the report never held."""
+ row = web_payload.positions_payload(_positions_report(tmp_path))["rows"][0]
+
+ assert row["stop_distance"]["value"] == "60"
+ assert row["stop_distance_pct"]["value"] == "0.4"
+ assert "%" not in json.dumps(row)
+
+
+def test_a_tranche_through_its_stop_is_judged_bad(tmp_path: Path) -> None:
+ """The state an operator most needs to find by scanning. Below the stop the distance is
+ negative, and the sign is turned into a verdict HERE so no client reads a minus."""
+ row = web_payload.positions_payload(_positions_report(tmp_path, mark="85"))["rows"][0]
+
+ assert row["stop_distance"]["state"] == "bad"
+
+
+def test_a_tranche_with_no_recorded_stop_says_so_rather_than_showing_zero(
+ tmp_path: Path,
+) -> None:
+ report = _positions_report(tmp_path, tranche={"initial_stop": None})
+ row = web_payload.positions_payload(report)["rows"][0]
+
+ assert row["initial_stop"]["state"] == "unknown"
+ assert row["stop_distance"]["state"] == "unknown"
+
+
+def test_the_freshness_chip_carries_the_entry_gate_verdict(tmp_path: Path) -> None:
+ """The gate outcome, not a data age. `missing`/`behind`/`unconfirmed` are the agent's own
+ words for why it would refuse to trade this product right now, and a chip that showed hours
+ since the last candle instead would answer a softer question than the engine asks."""
+ row = web_payload.positions_payload(_positions_report(tmp_path))["rows"][0]
+
+ assert row["freshness"]["value"] in ("ready", "missing", "behind", "unconfirmed")
+ assert row["freshness"]["state"] in ("good", "warn", "bad", "neutral", "unknown")
+ assert row["freshness"]["display"], "the chip must say something a human can read"
+
+
+def test_an_unready_product_is_a_warning_not_a_neutral_note(tmp_path: Path) -> None:
+ """A product the entry gate would refuse is a fact about whether keel can act, so it is
+ styled as one. Neutral would leave it indistinguishable from an ordinary row."""
+ report = _positions_report(tmp_path, mark=None)
+ row = web_payload.positions_payload(report)["rows"][0]
+
+ assert row["freshness"]["value"] == "missing"
+ assert row["freshness"]["state"] == "warn"
+
+
+def test_the_report_counts_and_products_cross_from_the_report(tmp_path: Path) -> None:
+ """Rule 6e: `payload.py` may not call `len()`, so both come off the report."""
+ built = web_payload.positions_payload(_positions_report(tmp_path))
+
+ assert built["open_count"]["value"] == "1"
+ assert built["products"] == ["BTC-USD"]
+
+
+# -- the view, in the client ----------------------------------------------------------------------
+#
+# Source-text assertions: there is no JavaScript runtime in this suite, so what is pinned is that
+# the declarations exist and read the right keys -- not that the DOM behaves.
+
+
+def _source(name: str) -> str:
+ from keel.web import staticfiles
+
+ return (Path(staticfiles.__file__).parent / "static" / "js" / name).read_text(encoding="utf-8")
+
+
+def _code(name: str) -> str:
+ from tests.web.test_client_assets import _comments_only
+
+ return _comments_only(_source(name))
+
+
+def _function_body(name: str, function: str) -> str:
+ """The source of ONE exported function, ending where the next one begins.
+
+ A fixed `[:4000]` slice ran 1362 characters past `positionsView` into `ordersView`, which
+ made the no-close-action assertion below partly a statement about a different view: it would
+ have started failing, or passing, on edits to code it does not describe.
+ """
+ after = _code(name).split("export function " + function)[1]
+ end = after.find("export function ")
+ return after if end == -1 else after[:end]
+
+
+def test_the_positions_view_is_wired_into_the_client_router() -> None:
+ """A view in `main.js` alone routes on a click and 404s on a reload; a route in Python alone
+ is a page with nothing to render. Both tables and the renderer, or none of them."""
+ from keel.web import staticfiles
+
+ assert "positions" in staticfiles.CLIENT_ROUTES
+ main_code = _code("main.js")
+ assert "positionsView" in main_code
+ assert 'route.name === "positions"' in main_code
+ assert "export function positionsView" in _code("render.js")
+
+
+def test_the_positions_view_has_no_close_action_anywhere() -> None:
+ """#701's central refusal, pinned rather than trusted.
+
+ "No close button. Ever." A position is closed through the typed-phrase friction of the exit
+ path, because a panic tap on a table row must never be the last line of defence. This is the
+ kind of affordance that arrives later as an obvious convenience, so the absence is asserted
+ on the source and will fail the build the day someone adds one.
+ """
+ view = _function_body("render.js", "positionsView")
+ for banned in ("close", "sell", "exit", "cancel", "liquidate"):
+ assert banned not in view.lower(), f"the positions view must offer no {banned} action"
+
+
+def test_the_positions_view_groups_by_the_reports_own_product_list() -> None:
+ """`data.products`, not a list the client assembles. Two answers to "which products does this
+ book hold" is one too many, and the report already holds the ordered one."""
+ assert "data.products" in _code("render.js")
+
+
+def test_the_positions_view_shows_the_freshness_chip_per_row() -> None:
+ """The entry-gate verdict, on the page. It is the thing that explains an idle deployment, so
+ a view that carried every figure and not this one would leave the most common question
+ unanswered.
+
+ PER ROW, not per product: the gate granularity comes from the RULE that opened the tranche,
+ so one product holding two tranches from rules on different timeframes has two verdicts. A
+ chip read off the first row and captioned for the whole product would state one tranche's
+ verdict over another's."""
+ view = _function_body("render.js", "positionsView")
+
+ assert '"freshness"' in view, "the entry-gate verdict must be a column of the table"
+ assert "held[0]" not in view, (
+ "a per-product chip read off one row cannot represent tranches whose rules gate on "
+ "different granularities"
+ )
+
+
+def test_the_positions_view_names_the_stop_distance_both_ways() -> None:
+ view = _function_body("render.js", "positionsView")
+ assert "stop_distance" in view
+ assert "stop_distance_pct" in view