Skip to content

feat(setup): market data as a background job, so the last mechanical step can be a button - #467

Merged
eaitbrahim merged 1 commit into
mainfrom
feat/d4-market-data-job
Aug 20, 2026
Merged

feat(setup): market data as a background job, so the last mechanical step can be a button#467
eaitbrahim merged 1 commit into
mainfrom
feat/d4-market-data-job

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Refs #437 (D4). Milestone: Desktop distribution (#18).

The last step left in NOT_AUTOMATED_YET, with the reason recorded there:

a request that blocks that long is not a button, it is a background job with progress and cancellation — so it stays keel fetch until there is somewhere for such a job to live

This builds that somewhere. NOT_AUTOMATED_YET is now empty — and it stays, because the next step someone decides not to automate should have a place to say why.

Exactly one slot, deliberately

Not a queue, not a pool.

  • Two concurrent fetches write candles to one SQLite database and race each other.
  • A setup flow has no use for concurrency.
  • "Is something running?" with one answer is a question a page can render honestly.

A second start is refused and says so, rather than silently dropped or silently queued — both of which look identical to a user watching a page that is not changing.

A failed job stays visible

Not cleared on read, not cleared by time. The entire point of running something in the background is that nobody was watching when it broke, so the failure has to still be there when they look. It is replaced only when the next job starts.

The recorded error is the exception's type and message, never a traceback — a traceback in a browser page is a stack of file paths from someone else's machine.

run_fetch needed no adapting

Its echo parameter is already documented as the progress stream and emits the same lines the CLI prints; its build_client is already a lazy factory, so nothing constructs a broker until the fetch needs one. The job module knows nothing about fetching.

Blank separator lines are dropped — in a bounded tail they would push real output out of view.

Refresh only while running

A finished page that kept reloading would fight a reader; a running one that did not would be a progress display that never progresses. The zero-JS meta refresh is still the only mechanism available to a page that ships no scripts.

The step's button also disappears while its job runs: offering it again would invite a second start that the slot refuses anyway, which reads as the page ignoring the click.

Verified end to end, on the realistic first-run mistake

Starting a fetch with no credentials configured:

POST /setup/market_data  →  303, immediately

job panel:
  market_data · failed · 0s elapsed
  AuthenticationError: Unauthenticated request to private endpoint. If you wish to access
  private endpoints, you must provide your API key and secret ...
  MISSING BTC-USD ONE_DAY

meta refresh: present while running, absent once finished

That is the venue's own error, in front of the operator, with the progress that preceded it — and the checklist directly above it already names the credential step.

mypy caught a bug no test would have

The fetch closure imported keel.strategy.freshness; the module is keel.data.freshness. Every import in that closure is lazy and runs only on the background thread, so the suite was green and the job would have died with a ModuleNotFoundError the first time anyone pressed the button.

Verification

4083 passed, 3 skipped (12 new). ruff check clean repo-wide, mypy clean over keel + packages.

Where D4 stands

Every mechanical step of a first run is now reachable from the browser, with no command typed: config, database, rule library, credential, market data. What remains outstanding is by design — the judgement steps (asset attestations, promoting a rule) and the off-venue ones, which #437 says a wizard must present rather than perform.

🤖 Generated with Claude Code

…step can be a button

This was the one step left in `NOT_AUTOMATED_YET`, with the reason recorded there: "a request that
blocks that long is not a button, it is a background job with progress and cancellation -- so it
stays `keel fetch` until there is somewhere for such a job to live". This builds that somewhere.

`keel/commands/jobs.py` is deliberately the smallest thing that works: EXACTLY ONE SLOT. Not a
queue, not a pool. Two concurrent fetches write candles to one SQLite database and race each
other; a setup flow has no use for concurrency; and "is something running?" with one answer is a
question a page can render honestly. A second start is REFUSED and says so, rather than being
silently dropped or silently queued -- which look identical to a user watching a page that is not
changing.

A FAILED JOB STAYS VISIBLE. Not cleared on read, not cleared by time. The entire point of running
something in the background is that nobody was watching when it broke, so the failure has to still
be there when they look; it is replaced only when the next job starts. The error recorded is the
exception's type and message, never a traceback -- a traceback in a browser page is a stack of
file paths from someone else's machine.

The progress is a bounded tail of the same lines the CLI prints. `run_fetch` needed no adapting at
all: its `echo` parameter is already documented as the progress stream, and its `build_client` is
already a lazy factory, so nothing constructs a broker until the fetch needs one. Blank separator
lines are dropped -- in a bounded tail they would push real output out of view.

The page auto-refreshes ONLY while something is running. A finished page that kept reloading would
fight a reader; a running one that did not would be a progress display that never progresses. The
zero-JS meta refresh remains the only mechanism available to a page that ships no scripts. And the
step's button disappears while its job runs: offering it again would invite a second start that
the slot refuses anyway, which reads as the page ignoring the click.

VERIFIED END TO END against a live server, on the realistic first-run mistake -- starting a fetch
with no credentials configured:

  POST /setup/market_data -> 303, immediately
  job panel -> market_data · failed · AuthenticationError: Unauthenticated request to private
               endpoint. If you wish to access private endpoints, you must provide your API key
               and secret ... MISSING BTC-USD ONE_DAY
  refresh    -> present while running, absent once finished

That is the venue's own error, in front of the operator, with the progress that preceded it -- and
the checklist directly above it already names the credential step.

MYPY CAUGHT A BUG NO TEST WOULD HAVE. The fetch closure imported `keel.strategy.freshness`; the
module is `keel.data.freshness`. Every import in that closure is lazy and runs only on the
background thread, so the suite was green and the job would have died with a ModuleNotFoundError
the first time anyone pressed the button.

4083 passed, 3 skipped (12 new). ruff clean repo-wide; mypy clean over keel + packages.

`NOT_AUTOMATED_YET` is now empty, which is a fine value for it -- and it stays, because the next
step someone decides not to automate should have somewhere to say why.

Refs #437, #18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 691e2ff into main Aug 20, 2026
3 of 5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/d4-market-data-job branch August 20, 2026 22:22
eaitbrahim added a commit that referenced this pull request Aug 20, 2026
…rned out to be the right one

Two JUDGEMENT steps live in the PAPER stage -- attesting an asset, promoting a rule -- so the rule
I wrote two changes ago ("no judgement step may ever be an action") did not make anything safer.
It made a terminal-free paper deployment impossible, which is the entire point of the milestone.
This fixes the rule, not the goal.

THE INVARIANT MOVES TO WHERE IT ALWAYS BELONGED: the capability registry. Not one of the eleven
actions in `keel/capabilities.py` may be reachable from `keel/web/`, and that was already scanned
and pinned independently. `StepKind` was a poor proxy for it, tried twice -- "MECHANICAL only",
then "MECHANICAL or OPERATOR_INPUT" -- and wrong both times. The test now asserts by IDENTITY:
every action's step is checked against the eleven invocations, and `withdrawals attest --enabled`
is named as the judgement step that consequently has no form. That is the line the code already
drew; taste had nothing to do with it.

The step-kind rule becomes the secondary policy it should always have been: **a wizard may record
what the operator supplies; it may never supply it.** An action over a judgement step must declare
inputs, every one required, with nothing pre-filled and no select opening on a valid option.

THAT LAST CLAUSE IS NOT PEDANTRY. `pays_yield` as a checkbox would open unticked, and unticked is
`no` -- the PERMISSIVE answer, since a yield-bearing asset fails the screen (KB §28.4). A form
whose default answer is the compliant one attests on the operator's behalf. So it is a select that
opens on a disabled "choose…" and is `required`: it cannot be submitted without someone answering.
`backing` is the same shape.

Exposing an attestation is safe for a reason its own CLI command states: "an attestation cannot
itself place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that
`guards.py` rail 1 still enforces per-trade". It is not one of the eleven and the CLI imposes no
ceremony on it beyond the mandatory source.

PROMOTION HAS NO FORCE FIELD, and that is load-bearing rather than an omission.
`attempt_promotion`'s docstring is explicit: force "carries no gate HERE ... the O3 contract is the
front-end's to keep, never the service's to assume." The console keeps that contract with a typed
terminal confirmation. A browser cannot, so this front-end simply has no force path -- and
force-promote is itself one of the eleven. `force=False` is hard-wired and pinned by test.
Promotion re-runs a backtest, so it runs as a job for the same reason a fetch does.

The backing choices are literals to keep `keel.compliance.screen` off the CLI's import path; that
duplication is pinned against `KNOWN_BACKINGS`, because a backing kind added there and missing
here would be silently un-attestable from the browser.

VERIFIED END TO END against a live server: three assets attested from the browser and recorded
with exactly the supplied sector, backing, yield answer, source and attester; a submission with a
blank source recorded NOTHING; the checklist then names the assets still outstanding; and
`withdrawals attest --enabled` still shows its CLI command rather than a form.

4087 passed, 3 skipped (5 new). ruff clean repo-wide; mypy clean over keel + packages.

Stacked on #467 -- it builds directly on that PR's `Action`/`ActionInput` and job runner.

Refs #437, #436, #18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 20, 2026
…rned out to be the right one (#469)

Two JUDGEMENT steps live in the PAPER stage -- attesting an asset, promoting a rule -- so the rule
I wrote two changes ago ("no judgement step may ever be an action") did not make anything safer.
It made a terminal-free paper deployment impossible, which is the entire point of the milestone.
This fixes the rule, not the goal.

THE INVARIANT MOVES TO WHERE IT ALWAYS BELONGED: the capability registry. Not one of the eleven
actions in `keel/capabilities.py` may be reachable from `keel/web/`, and that was already scanned
and pinned independently. `StepKind` was a poor proxy for it, tried twice -- "MECHANICAL only",
then "MECHANICAL or OPERATOR_INPUT" -- and wrong both times. The test now asserts by IDENTITY:
every action's step is checked against the eleven invocations, and `withdrawals attest --enabled`
is named as the judgement step that consequently has no form. That is the line the code already
drew; taste had nothing to do with it.

The step-kind rule becomes the secondary policy it should always have been: **a wizard may record
what the operator supplies; it may never supply it.** An action over a judgement step must declare
inputs, every one required, with nothing pre-filled and no select opening on a valid option.

THAT LAST CLAUSE IS NOT PEDANTRY. `pays_yield` as a checkbox would open unticked, and unticked is
`no` -- the PERMISSIVE answer, since a yield-bearing asset fails the screen (KB §28.4). A form
whose default answer is the compliant one attests on the operator's behalf. So it is a select that
opens on a disabled "choose…" and is `required`: it cannot be submitted without someone answering.
`backing` is the same shape.

Exposing an attestation is safe for a reason its own CLI command states: "an attestation cannot
itself place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that
`guards.py` rail 1 still enforces per-trade". It is not one of the eleven and the CLI imposes no
ceremony on it beyond the mandatory source.

PROMOTION HAS NO FORCE FIELD, and that is load-bearing rather than an omission.
`attempt_promotion`'s docstring is explicit: force "carries no gate HERE ... the O3 contract is the
front-end's to keep, never the service's to assume." The console keeps that contract with a typed
terminal confirmation. A browser cannot, so this front-end simply has no force path -- and
force-promote is itself one of the eleven. `force=False` is hard-wired and pinned by test.
Promotion re-runs a backtest, so it runs as a job for the same reason a fetch does.

The backing choices are literals to keep `keel.compliance.screen` off the CLI's import path; that
duplication is pinned against `KNOWN_BACKINGS`, because a backing kind added there and missing
here would be silently un-attestable from the browser.

VERIFIED END TO END against a live server: three assets attested from the browser and recorded
with exactly the supplied sector, backing, yield answer, source and attester; a submission with a
blank source recorded NOTHING; the checklist then names the assets still outstanding; and
`withdrawals attest --enabled` still shows its CLI command rather than a form.

4087 passed, 3 skipped (5 new). ruff clean repo-wide; mypy clean over keel + packages.

Stacked on #467 -- it builds directly on that PR's `Action`/`ActionInput` and job runner.

Refs #437, #436, #18.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant