Skip to content

Commit 3a9a816

Browse files
eaitbrahimclaude
andauthored
feat(web): the remaining six views, an SVG curve nobody has to see, and a stream that says when it stops (#550)
The bulk of the port: `/setup`, `/activity`, `/insights`, `/rules`, `/venues` and `/gates` as client views over #534's API, plus the equity curve and live updates. ── PARITY IS ON THE INFORMATION, AND TWO MORE #548s TURNED UP ON THE WAY ────── Every view is judged against the rendered pages AND `tests/commands/test_tui.py`, and where those disagree the divergence is written into the view's docstring rather than reproduced. Porting found two more instances of #548's pattern -- a lookup that misses, defaulted, with nothing raised: * `render.py::_STEP_KIND_NOTE` has three entries against `StepKind`'s four. The missing one is `operator_input`, the kind of the real `credentials` step, so the rendered `/setup` prints an EMPTY note on the step where "what may a wizard do for you" matters most. `payload._STEP_KIND_NOTES` carries all four. * `render.py::pct` appends `%` to four values that are FRACTIONS. The config ships `max_total_dd_pct: 0.20` and rail 11 compares the raw drawdown against it, so a 20% ceiling renders as "0.20%" on every rendered page and a 5% drawdown as "0.05%" -- a hundredfold understatement of a risk limit. `payload.ratio`'s docstring names this exact trap; the client shows its value. ── THE CHART: THE COORDINATES ARE COMPUTED IN PYTHON ────────────────────────── `build_equity_curve` returns plot coordinates, not just figures, and that is the decision rather than an accident of layering. Where a vertical axis starts is what makes a $3 wobble look like a collapse; keel does not let a front-end decide whether a number is bad, and the axis it is drawn against is the same delegation wearing different clothes. So the baseline is always zero, in Python, where a test can read it. The consequence is checkable: `chart.js` needs no arithmetic, so the lexer scan that guards `render.js` now runs over it too -- proven by mutation, not by reading. The curve is `role="img"` named by its own `<figcaption>`, one string in one element, with the journal table beside it for the figures. ── LIVE UPDATES: A DROPPED CONNECTION IS THE POINT ──────────────────────────── `/api/events` streams an envelope whose `data` holds one revision marker and no figures at all -- `api.js` stays the only place data enters this client, which is the property a reader audits by opening one file. The agent runs daily, so this is not a feed: it is how a dead server stops looking like an unchanged one. Driving it in a real browser found four bugs no test here could have: * the marker watched `keel.db` only, and `keel serve` runs WAL (#470), so a committed write moves nothing. The feature was inert and its own comment said so approvingly. It watches `-wal` now, and not `-shm`, which readers touch. * a tick repainted the banner green over a view saying the report could not be built. The view's own read now wins; a loss of contact still shows fast. * a sort press deferred itself, because the click focused a button inside the view and the rebuild was waiting for focus to leave. Pressing a control and watching nothing happen is indistinguishable from a broken control. * `/activity` and `/gates` scrolled the document sideways at 360px -- on a log path and a dotted call site, in two paragraphs, with every table already scrolling correctly inside itself. ── `/setup` KEEPS ITS GATE, AND ITS ACTION SET IS UNCHANGED ─────────────────── `keel.commands.setup.ACTIONS` is a 0-line diff, `keel/web/security.py` is a 0-line diff, and nothing under `/api/*` answers a POST. The client lists every action keel offers, with its inputs; the button stays on the page that holds the write token, because `/api/setup` ships no CSRF token on purpose and inventing a way to hand the client one would be widening the write surface. A client that hides a button is not a gate, so nothing is hidden -- only the button is elsewhere. #540 deletes that page, and where a browser client gets a write token from is a decision that belongs to the milestone, not to this commit. Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 268fe06 commit 3a9a816

15 files changed

Lines changed: 3469 additions & 156 deletions

File tree

‎keel/commands/insights.py‎

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
import json
4242
import time
43+
from collections.abc import Sequence
4344
from dataclasses import asdict, dataclass
4445
from datetime import UTC, datetime
4546
from decimal import Decimal
@@ -108,6 +109,212 @@ def shown_count(self) -> int:
108109
return len(self.entries)
109110

110111

112+
# -- the equity curve (#537) --------------------------------------------------------------------
113+
#
114+
# The browser's one chart. It lives HERE, in the report layer, rather than in `keel/web/` -- and
115+
# the coordinates live here too, which is the part that looks misplaced and is not.
116+
#
117+
# **Scaling a chart is a judgement about the data, not a drawing detail.** Where the vertical
118+
# axis starts decides what the picture says: a curve plotted between its own min and max makes a
119+
# $3 wobble look like a collapse, and one plotted from zero does not. keel already refuses to let
120+
# a front-end decide whether a number is good (`keel/web/payload.py`'s closed `state`
121+
# vocabulary); letting one decide the axis it is drawn against would be the same delegation
122+
# wearing different clothes. So `_BASELINE` below is included in the range unconditionally, in
123+
# Python, where a `Decimal` is in scope and a test can read it.
124+
#
125+
# The second reason is arithmetic. Normalising a series means subtracting, dividing and comparing
126+
# it, and the client is the one place in this system where those operations happen in IEEE-754
127+
# doubles over values that were exact `Decimal`s a moment earlier. `keel/web/static/js/chart.js`
128+
# consequently contains no arithmetic at all and `tests/web/test_client_assets.py` scans it for
129+
# the absence, exactly as it scans `render.js` -- which is only possible because the numbers it
130+
# draws with arrive finished.
131+
#
132+
# What it would take to move: a chart with a zoom or a pan control. That is interaction over a
133+
# range the server did not choose, and at that point the range becomes a client concern and this
134+
# builder becomes a service the client re-asks with new bounds. Nothing here is that today.
135+
136+
#: The coordinate box the curve is expressed in, and the reason it is not pixels.
137+
#:
138+
#: An SVG `viewBox` is unitless: the browser scales it to whatever width the card ends up, so
139+
#: these numbers are a fixed internal grid and never a size on screen. 1000x300 rather than
140+
#: 100x100 because the coordinates are QUANTIZED to 2dp below, and a taller grid means the
141+
#: rounding is a smaller fraction of a pixel at any realistic rendered size.
142+
PLOT_WIDTH = Decimal("1000")
143+
PLOT_HEIGHT = Decimal("300")
144+
145+
#: The value the vertical axis always contains. Zero, because the figure plotted is CUMULATIVE
146+
#: NET P&L -- the line between "this rule has made money" and "this rule has lost money" -- and a
147+
#: curve drawn without it on the canvas cannot show which side of it you are on.
148+
_BASELINE = Decimal("0")
149+
150+
#: Coordinate precision. Two decimal places on a 1000-wide grid is a hundred-thousandth of the
151+
#: width: far finer than any display, and short enough that a 50-point `points` attribute stays
152+
#: readable in view-source, which is the whole argument for this interface.
153+
_COORD = Decimal("0.01")
154+
155+
156+
@dataclass(frozen=True)
157+
class EquityPoint:
158+
"""One closed trade's contribution to the curve, and where it is drawn.
159+
160+
`pnl` and `cumulative` are the exact figures; `x` and `y` are the plot coordinates in the
161+
`PLOT_WIDTH` x `PLOT_HEIGHT` box. `y` grows DOWNWARD, because SVG's does: emitting a
162+
mathematical y here and flipping it in the browser would be one subtraction, performed on the
163+
one side of the wire that is not allowed to perform any.
164+
"""
165+
166+
index: int
167+
closed_at: int | None
168+
product_id: str
169+
rule_name: str | None
170+
pnl: Decimal
171+
cumulative: Decimal
172+
x: Decimal
173+
y: Decimal
174+
175+
176+
@dataclass(frozen=True)
177+
class EquityCurve:
178+
"""The cumulative net-P&L curve over a journal's closed trades, ready to draw.
179+
180+
`low`/`high` are the axis bounds actually used, `_BASELINE` included -- so they are the range
181+
a reader should be told about, not merely the extremes of the data. `baseline_y` is where
182+
zero sits in the box, which is what lets the chart draw the one gridline that carries meaning.
183+
184+
Empty is a real answer and is not an error: a deployment with no closed trades has no curve,
185+
and `points == []` is how that is said. There is no synthetic flat line at zero, because a
186+
flat line at zero is what a run of break-even trades looks like and the two must not be
187+
confused.
188+
"""
189+
190+
points: list[EquityPoint]
191+
low: Decimal
192+
high: Decimal
193+
baseline_y: Decimal
194+
width: Decimal
195+
height: Decimal
196+
197+
@property
198+
def point_count(self) -> int:
199+
"""How many points the curve holds.
200+
201+
A derived reading rather than a stored field, for the reason `JournalReport.shown_count`
202+
is: a second field holding `len(self.points)` is state that can drift from the list it
203+
describes. It exists at all because `keel/web/payload.py` may not call `len()` -- Rule 6e
204+
of `tests/commands/test_console_thinness.py` bans it there, so every count on the wire is
205+
one a report already holds.
206+
"""
207+
return len(self.points)
208+
209+
210+
def _plot_y(value: Decimal, *, low: Decimal, span: Decimal) -> Decimal:
211+
"""`value` mapped into `0..PLOT_HEIGHT`, with the top of the box being `low + span`."""
212+
fraction = (value - low) / span
213+
return (PLOT_HEIGHT - PLOT_HEIGHT * fraction).quantize(_COORD)
214+
215+
216+
def build_equity_curve(entries: Sequence[JournalEntry]) -> EquityCurve:
217+
"""The cumulative net-P&L curve over `entries`, oldest first.
218+
219+
**Rows with no recorded net are SKIPPED, never counted as zero.** `JournalEntry.pnl_net` is
220+
`None` when the ledger has no net for that trade, and folding a `None` into a running total as
221+
`0` would draw a flat segment that asserts "this trade broke even" -- the same collapse of
222+
"not recorded" into "recorded as zero" that `keel/web/payload.py`'s `ABSENT` note traces back
223+
to #198's always-passing fee rail. A skipped row still appears in the journal table beside the
224+
chart with its own dash, so the omission is visible rather than silent.
225+
226+
**The horizontal axis is trade ORDER, not time.** Spacing points by `closed_at` would give a
227+
long quiet week the same visual weight as fifty trades, which is a statement about the
228+
calendar and not about the track record; `keel insights journal` reads the same way, oldest
229+
first. It also means a row whose `closed_at` is unusable still has a position on the axis.
230+
231+
Callers pass the entries they are displaying, so the curve and the table beneath it can never
232+
disagree about which trades they describe.
233+
"""
234+
running = _BASELINE
235+
plotted: list[tuple[JournalEntry, Decimal, Decimal]] = []
236+
for entry in entries:
237+
net = entry.pnl_net
238+
if net is None:
239+
continue
240+
running = running + net
241+
plotted.append((entry, net, running))
242+
243+
if not plotted:
244+
return EquityCurve(
245+
points=[],
246+
low=_BASELINE,
247+
high=_BASELINE,
248+
baseline_y=PLOT_HEIGHT,
249+
width=PLOT_WIDTH,
250+
height=PLOT_HEIGHT,
251+
)
252+
253+
totals = [total for _entry, _net, total in plotted]
254+
low = min([*totals, _BASELINE])
255+
high = max([*totals, _BASELINE])
256+
span = high - low
257+
if span == _BASELINE:
258+
# Every trade broke even, so the curve is a horizontal line and there is no range to
259+
# normalise against. Drawn on the baseline rather than at the top or the bottom of the
260+
# box: the figure IS zero, and zero is where the baseline is.
261+
middle = (PLOT_HEIGHT / 2).quantize(_COORD)
262+
return EquityCurve(
263+
points=[
264+
EquityPoint(
265+
index=index,
266+
closed_at=entry.closed_at,
267+
product_id=entry.product_id,
268+
rule_name=entry.rule_name,
269+
pnl=net,
270+
cumulative=total,
271+
x=_plot_x(index, len(plotted)),
272+
y=middle,
273+
)
274+
for index, (entry, net, total) in enumerate(plotted)
275+
],
276+
low=low,
277+
high=high,
278+
baseline_y=middle,
279+
width=PLOT_WIDTH,
280+
height=PLOT_HEIGHT,
281+
)
282+
283+
points = [
284+
EquityPoint(
285+
index=index,
286+
closed_at=entry.closed_at,
287+
product_id=entry.product_id,
288+
rule_name=entry.rule_name,
289+
pnl=net,
290+
cumulative=total,
291+
x=_plot_x(index, len(plotted)),
292+
y=_plot_y(total, low=low, span=span),
293+
)
294+
for index, (entry, net, total) in enumerate(plotted)
295+
]
296+
return EquityCurve(
297+
points=points,
298+
low=low,
299+
high=high,
300+
baseline_y=_plot_y(_BASELINE, low=low, span=span),
301+
width=PLOT_WIDTH,
302+
height=PLOT_HEIGHT,
303+
)
304+
305+
306+
def _plot_x(index: int, total: int) -> Decimal:
307+
"""The horizontal position of point `index` of `total`, in `0..PLOT_WIDTH`.
308+
309+
A single point is CENTRED rather than placed at `x=0`. One trade drawn hard against the left
310+
edge reads as the beginning of a line that has been cut off, which is a claim about missing
311+
data; centred, it reads as the one observation it is.
312+
"""
313+
if total == 1:
314+
return (PLOT_WIDTH / 2).quantize(_COORD)
315+
return (PLOT_WIDTH * Decimal(index) / Decimal(total - 1)).quantize(_COORD)
316+
317+
111318
@dataclass(frozen=True)
112319
class GateDistance:
113320
rule_name: str

‎keel/web/api.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,18 @@ def read_insights(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) ->
216216

217217

218218
def read_journal(cfg: ServeConfig, query: Query, _state: Any, now_ts: int) -> dict[str, Any]:
219-
from keel.commands.insights import build_journal_report
219+
"""The trade journal, and the equity curve drawn from the SAME entries.
220+
221+
`build_equity_curve` is called on `report.entries` -- not on the ledger, and not on a second
222+
read -- so the chart and the table under it describe one list of trades. A curve built from
223+
its own query could disagree with the rows beside it after a `?limit=`, and a chart that
224+
disagrees with the table below it is worse than no chart.
225+
226+
The curve is NOT part of `route.collection`, so `?sort=` reorders `entries` and leaves the
227+
curve alone. That is deliberate: the curve's horizontal axis is trade order, and a curve
228+
redrawn in `pnl` order would be a cumulative total of a sequence that never happened.
229+
"""
230+
from keel.commands.insights import build_equity_curve, build_journal_report
220231

221232
limit = _journal_limit(query)
222233
repo = open_repo(cfg.db_path)
@@ -226,7 +237,7 @@ def read_journal(cfg: ServeConfig, query: Query, _state: Any, now_ts: int) -> di
226237
)
227238
finally:
228239
close_repo(repo)
229-
return payload.journal_payload(report)
240+
return payload.journal_payload(report, curve=build_equity_curve(report.entries))
230241

231242

232243
def read_rules(cfg: ServeConfig, _query: Query, _state: Any, _now_ts: int) -> dict[str, Any]:

0 commit comments

Comments
 (0)