Compare commits
164 commits
feat/chart
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fced18528b | |||
| 234b57e8b6 | |||
| d0a0f9f6d6 | |||
| 3d27ef285c | |||
| 72bcd0fa61 | |||
| bc9fed764a | |||
| 2fbb5957f3 | |||
| e6fa6beaf0 | |||
| fd315326b4 | |||
| a057341bbf | |||
| c1d508cb4f | |||
| 6f03560e74 | |||
| 3626795e03 | |||
| a1567fe82a | |||
| 168352872e | |||
| 25c2714850 | |||
| a16a1d0e6e | |||
| b28c0ff75f | |||
| e1e0e40c10 | |||
| bcc86fbfd2 | |||
| 54d7f880af | |||
| 20d02fc3d2 | |||
| 8108f2dea3 | |||
| 5708150ba3 | |||
| c784f9ed12 | |||
| 111d367224 | |||
| f7fbee1187 | |||
| fe4e6b1ae8 | |||
| 60ffaf4657 | |||
| f5ba154c98 | |||
| c28b0ce535 | |||
| eb18de9bb9 | |||
| becbc0e0b4 | |||
| c2ad2e8d7d | |||
| e94bd3d3d2 | |||
| 1c06ae370a | |||
| d8dcb84edb | |||
| 0768f6d783 | |||
| 5468bbec60 | |||
| 90fc77df92 | |||
| cb81672a37 | |||
| 693d0873f8 | |||
| 01acdce045 | |||
| 18585e42cd | |||
| eaf118ef8b | |||
| 913eec34ea | |||
| 7c5f0a7627 | |||
| dae60fdf67 | |||
| 6e69b7337a | |||
| 92a9d4b3d3 | |||
| fbb469de57 | |||
| b932fa2d75 | |||
| ac2943f248 | |||
| 1c3a852a03 | |||
| 5f39144dc8 | |||
| 58a64c1eeb | |||
| 08cff3ce53 | |||
| 44cec38750 | |||
| cc25871032 | |||
| d9a272760b | |||
| 1ee2a96fc0 | |||
| 90f8a1b567 | |||
| fc09da7053 | |||
| dfe5224e35 | |||
| 2589a1db13 | |||
| 171521cf75 | |||
| 7f452703d3 | |||
| baf2048867 | |||
| ae596ebf8e | |||
| 73beae051a | |||
| 11508e5325 | |||
| 3adb98c1fe | |||
| 5ee32b327c | |||
| 777ad4af91 | |||
| 0c7e81d222 | |||
| 73570d2904 | |||
| e1d2c60af7 | |||
| 796e6cdeb8 | |||
| d523dad1be | |||
| edcfd3977e | |||
| 333ca4c111 | |||
| aae47b6681 | |||
| 6e781ed163 | |||
| a921db4610 | |||
| 8ab054cf67 | |||
| 952a3bb7f3 | |||
| a09e4f1208 | |||
| 087d9d9d2a | |||
| f9e02fc3e4 | |||
| 7d9644ab55 | |||
| 54f4871757 | |||
| 05e8aef40b | |||
| 0a56d56261 | |||
| 0e2d5fa2cb | |||
| 22a638cf73 | |||
| 07f7befc08 | |||
| 32e25b84aa | |||
| e957991242 | |||
| 0bca5d3bb7 | |||
| 81045e2128 | |||
| 7590d53b13 | |||
| 7d559f47f2 | |||
| 372617b08c | |||
| 02ebc868fc | |||
| cbb26b19b9 | |||
| c653e65d5b | |||
| ff1b9982d1 | |||
| 919a71feb3 | |||
| a395818581 | |||
| 273d947c0a | |||
| 8ca774f821 | |||
| 9fc6402f03 | |||
| 617d7c75fb | |||
| e02f919b27 | |||
| 3b3c06a1f3 | |||
| 4488c7d4b9 | |||
| 91677127a0 | |||
| dd9d24b419 | |||
| 01f5cd4060 | |||
| 8f71beb59f | |||
| 3dfd44aa02 | |||
| decd069ec8 | |||
| 3f677a333c | |||
| fa577bacb7 | |||
| 3222ce03f4 | |||
| 7ce6d33bb5 | |||
| ab372832db | |||
| 501afb7792 | |||
| 0e32c0bbab | |||
| 039e91b4e4 | |||
| 0b3244b725 | |||
| 9008e9cd8e | |||
| 9b6cff853b | |||
| 9ab68f6cff | |||
| 078bc8f42c | |||
| 0465519348 | |||
| a0b8ac1cd2 | |||
| bec445b599 | |||
| 16932f85ec | |||
| bb84b6e73f | |||
| d1056ed486 | |||
| 374e255c95 | |||
| a13a54bc3f | |||
| 52e657fb1e | |||
| f64576372c | |||
| a15ea00c03 | |||
| 9d63e6482e | |||
| 09114f7c6a | |||
| d526001742 | |||
| bffd7faead | |||
| e3aad01baf | |||
| 5ccb2bfb52 | |||
| 0bafe9de01 | |||
| e69d1c190f | |||
| 2efcec6a76 | |||
| d5edb12171 | |||
| 1a1514bc77 | |||
| 51a9636ea7 | |||
| dd1d6b7a38 | |||
| 9da7d43434 | |||
| e9c22f6bbd | |||
| 8c2ef80966 | |||
| 8ca624d435 | |||
| 45802c1a2f |
105 changed files with 19578 additions and 558 deletions
21
.env.example
21
.env.example
|
|
@ -4,18 +4,34 @@ SEED_SOURCE=yahoo
|
||||||
YAHOO_SYMBOL=ES=F
|
YAHOO_SYMBOL=ES=F
|
||||||
YAHOO_POLL_SECONDS=20
|
YAHOO_POLL_SECONDS=20
|
||||||
SEED_1H_RANGE=730d
|
SEED_1H_RANGE=730d
|
||||||
|
SEED_30M_RANGE=60d
|
||||||
SEED_1M_RANGE=8d
|
SEED_1M_RANGE=8d
|
||||||
|
|
||||||
|
# Schwab. Only read once LIVE_SOURCE=schwab; blank is fine until then.
|
||||||
|
# The callback must match the app registration exactly — changing it there can
|
||||||
|
# re-trigger approval. The same URL works from local and production: the
|
||||||
|
# redirect lands in your browser, not on the machine running the code.
|
||||||
|
SCHWAB_API_KEY=
|
||||||
|
SCHWAB_APP_SECRET=
|
||||||
|
SCHWAB_CALLBACK_URL=https://chart.amow.com/api/qt
|
||||||
|
# Under data/ so it survives a deploy — that path is the Coolify volume.
|
||||||
|
SCHWAB_TOKEN_PATH=./data/.schwab_token.json
|
||||||
|
SCHWAB_SYMBOL=/ES
|
||||||
|
|
||||||
# Chart and analysis
|
# Chart and analysis
|
||||||
TIMEFRAMES=1m,5m,15m,30m,1h,1d
|
TIMEFRAMES=1m,5m,15m,30m,1h,1d
|
||||||
BASE_TIMEFRAMES=1m,30m,1d
|
BASE_TIMEFRAMES=1m,30m,1d
|
||||||
MAX_BARS_PER_TF=5000
|
MAX_BARS_PER_TF=5000
|
||||||
|
# Emergency rollback: false restores the previous displayed/1m trendline geometry.
|
||||||
|
TRENDLINE_SOURCE_GEOMETRY=true
|
||||||
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
|
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
|
||||||
MA_SETS__1H=
|
|
||||||
DAILY_ANCHOR_ET=18:00
|
DAILY_ANCHOR_ET=18:00
|
||||||
MANUAL_LINES_PATH=./data/manual_lines.json
|
MANUAL_LINES_PATH=./data/manual_lines.json
|
||||||
|
USER_PREFS_PATH=./data/user_prefs.json
|
||||||
CONFLUENCE_MIN_SCORE=28
|
CONFLUENCE_MIN_SCORE=28
|
||||||
ALERT_COOLDOWN_SECONDS=900
|
# Four hours. Suppression is per price zone, so an unrelated zone still alerts;
|
||||||
|
# this governs only how often the same area repeats. See README.
|
||||||
|
ALERT_COOLDOWN_SECONDS=14400
|
||||||
|
|
||||||
# Notifications and access
|
# Notifications and access
|
||||||
NTFY_TOPIC=
|
NTFY_TOPIC=
|
||||||
|
|
@ -23,4 +39,5 @@ NTFY_SERVER=https://ntfy.sh
|
||||||
# Blank = no auth (fine locally). In production this is set in Coolify, not
|
# Blank = no auth (fine locally). In production this is set in Coolify, not
|
||||||
# here — see README. Sent as the X-Chart-Token header, or ?token= for /ws.
|
# here — see README. Sent as the X-Chart-Token header, or ?token= for /ws.
|
||||||
CHART_AUTH_TOKEN=
|
CHART_AUTH_TOKEN=
|
||||||
|
CHART_PASSWORD=
|
||||||
REPLAY_FILE=
|
REPLAY_FILE=
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,4 +4,7 @@ __pycache__/
|
||||||
.env
|
.env
|
||||||
.schwab_token.json
|
.schwab_token.json
|
||||||
data/manual_lines.json
|
data/manual_lines.json
|
||||||
|
data/alert_state.json
|
||||||
|
data/events.json
|
||||||
|
data/user_prefs.json
|
||||||
artifacts/playwright/
|
artifacts/playwright/
|
||||||
|
|
|
||||||
288
AGENTS.md
Normal file
288
AGENTS.md
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
# Working on this repo
|
||||||
|
|
||||||
|
## Read current context first
|
||||||
|
|
||||||
|
Before planning work, read [`docs/plan.md`](docs/plan.md) for the decisions
|
||||||
|
and the reasoning behind them, and
|
||||||
|
[`docs/implementation.md`](docs/implementation.md) for the dated record of what
|
||||||
|
actually went wrong and how it was resolved — that one is the faster read when
|
||||||
|
debugging, because most entries describe something that looked like one bug and
|
||||||
|
turned out to be another. Then
|
||||||
|
[`docs/NEXT_STEPS.md`](docs/NEXT_STEPS.md) for current recommendations and known
|
||||||
|
deferred fixes. Mobile interaction work also has its own detailed plan in
|
||||||
|
[`docs/mobile_enhance.md`](docs/mobile_enhance.md). The CDN-to-Vite move is
|
||||||
|
[`docs/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are
|
||||||
|
[`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md).
|
||||||
|
Daily MA alert toggles are [`docs/plan_dma_alerts.md`](docs/plan_dma_alerts.md).
|
||||||
|
Adding `/NQ` `/GC` `/CL` is [`docs/investigate_added_symbols.md`](docs/investigate_added_symbols.md).
|
||||||
|
|
||||||
|
## Tests earn their place by catching a real bug
|
||||||
|
|
||||||
|
When a bug is found, ask whether a unit test could reasonably have caught it. If
|
||||||
|
yes, write that test with the fix. If no — a rendering artefact, a browser
|
||||||
|
quirk, a data-source oddity — say so and don't add one.
|
||||||
|
|
||||||
|
The bar is "would this have failed before the fix, and would it fail again if
|
||||||
|
someone reintroduced it". Tests that restate the implementation, assert
|
||||||
|
constructor defaults, or exercise paths nothing depends on are noise; they make
|
||||||
|
the suite slow to run and expensive to change, which is how a suite stops being
|
||||||
|
trusted.
|
||||||
|
|
||||||
|
What has actually paid off here: bar aggregation and bucket boundaries, the
|
||||||
|
store's replace-vs-append rules, level and alert arithmetic, parsing real
|
||||||
|
market-data payloads (fixtures are trimmed real responses, not invented), and
|
||||||
|
the invariants that would otherwise be silent — a comment must never become a
|
||||||
|
level, a tick must never overwrite a settled bar, volume must be counted once.
|
||||||
|
|
||||||
|
Name the test after the failure, not the function: `test_a_tick_cannot_overwrite
|
||||||
|
_a_settled_bar` beats `test_put`.
|
||||||
|
|
||||||
|
## Where things run
|
||||||
|
|
||||||
|
**The agent works on a remote machine over SSH. The user's browser runs on a
|
||||||
|
different machine.** Consequences, all learned the hard way:
|
||||||
|
|
||||||
|
- You cannot see the user's screen, console, or cursor. Screenshots and pasted
|
||||||
|
console output are the only window into it. Browser extensions that drive
|
||||||
|
"your" Chrome do not help — they attach to the machine the browser is on.
|
||||||
|
- Headless Chromium here renders on server hardware: different screen, window
|
||||||
|
size and device pixel ratio from the user's. "Works in my headless run" is not
|
||||||
|
evidence that it works for them. When a UI bug will not reproduce, match their
|
||||||
|
viewport and `deviceScaleFactor` explicitly before concluding anything.
|
||||||
|
- The dev stack is served to them over the network (e.g. `hera.local:8010`),
|
||||||
|
which is the same app the headless browser reaches as `http://api:8000`.
|
||||||
|
|
||||||
|
When a visual bug resists reproduction, prefer putting the numbers **on screen**
|
||||||
|
in the app over asking for another console paste — one screenshot then carries
|
||||||
|
the whole diagnosis.
|
||||||
|
|
||||||
|
## Verify UI in a real browser
|
||||||
|
|
||||||
|
Chart bugs are invisible from the outside — the API, the socket and the
|
||||||
|
frontend source can each be correct while the screen is wrong. Drive the
|
||||||
|
Playwright container against the dev stack:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec -i chart-playwright-1 node - <<'EOF'
|
||||||
|
const { chromium } = require('/usr/lib/node_modules/playwright');
|
||||||
|
// launch with args:['--lang=en-US'] — see below
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always launch Chromium with `args: ['--lang=en-US']`.** The container has no
|
||||||
|
usable locale, so Chromium reports `en-US@posix`, `Intl` throws, and the chart
|
||||||
|
renders as a blank canvas that looks exactly like a broken app.
|
||||||
|
|
||||||
|
`window.__chart` is a deliberate debug handle. Querying it separates "the data
|
||||||
|
is missing" from "the data is off-screen" — which is how a viewport bug that
|
||||||
|
three passing API checks had missed was finally found.
|
||||||
|
|
||||||
|
## Diagnostic mode
|
||||||
|
|
||||||
|
Chart geometry bugs live in the browser, which is usually on a different machine
|
||||||
|
from whoever is debugging them. Rather than asking for console pastes:
|
||||||
|
|
||||||
|
```
|
||||||
|
open the chart with ?diag=1 # remembered until ?diag=0
|
||||||
|
docker compose logs api | grep SNAPDBG
|
||||||
|
```
|
||||||
|
|
||||||
|
With it on, every snap the trendline tool computes is posted to
|
||||||
|
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
|
||||||
|
snapped time and price, how many bars were held, the first and last bar, and the
|
||||||
|
chart's width. After bars and levels load it also posts one `kind=geometry`
|
||||||
|
report: 1m holes, whether `futureSpace` owns them, and each visible manual
|
||||||
|
line's off-median screen segments. Throttled to about one a second. It reads
|
||||||
|
the client's own numbers, which is exactly what "works in my headless run"
|
||||||
|
cannot tell you. Do not ask the user to paste that from the console.
|
||||||
|
|
||||||
|
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
||||||
|
fields `SnapReport` declares.
|
||||||
|
|
||||||
|
`?diag=1` also exposes **Capture diagnostic**. The uploaded PNG URL at
|
||||||
|
`/api/debug/captures/{id}` is deliberately public: its 72-bit id is the
|
||||||
|
handoff from a browser to an agent on a different machine. Capture upload and
|
||||||
|
metadata remain authenticated. Inspect only a URL the user explicitly shares,
|
||||||
|
then immediately `DELETE /api/debug/captures/{id}`. The server also expires
|
||||||
|
captures after 24 hours and caps the directory at 50 files.
|
||||||
|
|
||||||
|
After the browser's required share picker closes, capture waits five seconds so
|
||||||
|
the user can restore a hover tooltip. `Alt+Shift+C` starts the same delayed flow
|
||||||
|
without clicking the status-bar button.
|
||||||
|
|
||||||
|
Diagnostic mode also shows a compact projection readout for visible manual
|
||||||
|
trendlines: historical/future canonical price changes, their screen slopes, and
|
||||||
|
whether the future canvas point exists. Include it in a capture when a line
|
||||||
|
looks kinked at the live edge; it separates bad geometry from a bad renderer.
|
||||||
|
|
||||||
|
## Production logs and captures
|
||||||
|
|
||||||
|
Do not ask the user to paste console output or screenshots when these work.
|
||||||
|
This is not a production shell.
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
|
||||||
|
chart-debug@chart.amow.com 'logs --since 20m'
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
|
||||||
|
chart-debug@chart.amow.com status
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
|
||||||
|
chart-debug@chart.amow.com container-state
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 -o BatchMode=yes \
|
||||||
|
chart-debug@chart.amow.com recent-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed commands only: `logs --since <Ns|Nm|Nh|Nd>`, `status`,
|
||||||
|
`container-state`, `recent-deploy`, `capture-read c-…`,
|
||||||
|
`capture-delete c-…`. Anything else is denied. Log output is redacted
|
||||||
|
and capped. Never print tokens, env, or the private key.
|
||||||
|
|
||||||
|
**Captures:** user opens `?diag=1`, hits Capture diagnostic (or
|
||||||
|
`Alt+Shift+C`), and shares the PNG URL
|
||||||
|
`https://chart.amow.com/api/debug/captures/{id}`. Inspect only a URL they
|
||||||
|
explicitly share. Fetch the PNG (public by id), then immediately
|
||||||
|
`DELETE` it — via that URL or
|
||||||
|
`ssh … chart-debug@chart.amow.com 'capture-delete c-XXXXXXXXXXXX'`.
|
||||||
|
Do not inspect unsolicited capture ids.
|
||||||
|
|
||||||
|
## Future whitespace is a high-risk boundary
|
||||||
|
|
||||||
|
Drawing bugs repeatedly appear to the right of the last real candle. Treat any
|
||||||
|
change involving future slots, projection, drawing movement, or selection as a
|
||||||
|
geometry change that needs explicit browser verification.
|
||||||
|
|
||||||
|
- The displayed time scale, a drawing's source-timeframe bar space, and the
|
||||||
|
server session calendar are different coordinate systems. Never substitute
|
||||||
|
wall-clock seconds or the displayed grid for canonical source geometry.
|
||||||
|
- A short 1m hole that already owns empty `futureSpace` slots still kinks a
|
||||||
|
line if source index treats the surrounding bars as adjacent. Count both
|
||||||
|
before changing geometry — see the 2026-08-26 entry in
|
||||||
|
`docs/implementation.md`. Settlement and weekend compression are intended.
|
||||||
|
- Daily and intraday future slots must skip non-session time. An endpoint that
|
||||||
|
looks valid before a settlement/weekend break must remain resolvable when real
|
||||||
|
history arrives.
|
||||||
|
- Do not clamp a future click to the last real candle. Do not let one null or
|
||||||
|
unpriceable future sample disable an otherwise valid object's whole hit target.
|
||||||
|
- DOM/SVG overlays must use the chart's actual future coordinates. Extrapolating
|
||||||
|
from the last two real candles is wrong when sparse-gap slots were inserted.
|
||||||
|
- Audit every drawing type, not just trendlines: Fibonacci hit testing and body
|
||||||
|
targets, pinned comments/symbols, keyboard nudges, duplicate, cutoff, handles,
|
||||||
|
selection glow, and drag persistence have separate paths.
|
||||||
|
- Tests must cover placement, selection, body drag, endpoint drag, duplicate,
|
||||||
|
nudge, and cutoff beyond the live edge and across session boundaries. Several
|
||||||
|
older future-interaction E2E tests remain quarantined against mutable live
|
||||||
|
state; a skipped test is not protection.
|
||||||
|
|
||||||
|
Relevant history is concentrated near the trendline/future entries in
|
||||||
|
`docs/implementation.md`. Before fixing another symptom, measure timestamps,
|
||||||
|
logical/x coordinates, canonical prices, and painted pixels in the user's
|
||||||
|
viewport; self-consistent chart API numbers have missed real rendering bugs.
|
||||||
|
|
||||||
|
## Keep the two documents current
|
||||||
|
|
||||||
|
This is a running system under continual change, not a build being executed, so
|
||||||
|
both live documents decay unless updating them is part of finishing the work —
|
||||||
|
not a tidy-up afterwards.
|
||||||
|
|
||||||
|
- **`docs/plan.md`** — when a decision changes, change it here. A plan that
|
||||||
|
contradicts the code is worse than no plan, because someone believes it. If
|
||||||
|
you find a section describing behaviour that no longer exists, that is a bug
|
||||||
|
in the document; fix it in the same commit that revealed it.
|
||||||
|
- **`docs/implementation.md`** — append when a fix was not obvious. The bar is
|
||||||
|
"would this have saved me an hour": wrong theories that were measured and
|
||||||
|
killed, the evidence that settled it, the thing that looked like one bug and
|
||||||
|
was another. Not every fix. A log of trivia stops being read, and then the
|
||||||
|
useful entries go unread too.
|
||||||
|
|
||||||
|
Rule of thumb: if you needed a measurement to be sure, write down what it was.
|
||||||
|
Git records what changed; these record why it was hard.
|
||||||
|
|
||||||
|
## Direction of travel
|
||||||
|
|
||||||
|
Two live planning documents, both written to be refactored toward rather than
|
||||||
|
implemented in one go:
|
||||||
|
|
||||||
|
- `docs/async_refactor.md` — nothing blocks the event loop. P0 and P1 are done;
|
||||||
|
`/api/status` reports `loop_lag_ms`, and a rise there is the signal.
|
||||||
|
- `docs/multi_user.md` — separate people with their own drawings and alerts,
|
||||||
|
authenticated by OIDC. Read it before adding state to `Runtime`: new state is
|
||||||
|
either genuinely shared (market data) or belongs to a user, and knowing which
|
||||||
|
now is much cheaper than untangling it later.
|
||||||
|
- `docs/vite_build.md` — pin and hash the frontend, stay on Coolify, do not
|
||||||
|
split components on the way. A production Dockerfile first, then Vite;
|
||||||
|
never a root `package.json` while nixpacks is still the builder.
|
||||||
|
|
||||||
|
Do not build local user accounts. The destination is OIDC, so password storage
|
||||||
|
would be written and then deleted.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
|
||||||
|
```
|
||||||
|
|
||||||
|
pytest + pytest-asyncio, declared in `requirements-dev.txt`. Tests live in
|
||||||
|
`tests/`, import from `app.*`, and use `tmp_path` for anything that persists.
|
||||||
|
Async paths are driven with `asyncio.run(...)` directly rather than async test
|
||||||
|
markers.
|
||||||
|
|
||||||
|
## Things that will cost you an hour
|
||||||
|
|
||||||
|
- **Never write scratch `.py` files into the repo root.** It is bind-mounted, so
|
||||||
|
`--reload` restarts the app, and startup takes ~82 seconds. Pipe throwaway
|
||||||
|
scripts over stdin instead: `docker exec -i chart-api-1 python - <<'EOF'`.
|
||||||
|
Screenshots into `artifacts/` are safe; only `.py` triggers the reloader.
|
||||||
|
- **Dev and production keep separate drawing stores.** Dev writes
|
||||||
|
`data/manual_lines.json`; production has its own Coolify volume. A fix that
|
||||||
|
"didn't land" is often the other store.
|
||||||
|
- **Rebuild the image after touching `requirements.txt`.** The bind mount makes
|
||||||
|
source edits look live while an added dependency is simply absent.
|
||||||
|
- **A deploy resets alert cooldowns**, so production may re-alert on whatever
|
||||||
|
price is sitting on. There is no durable state yet.
|
||||||
|
- Times are epoch seconds, UTC, everywhere. Only the display is localised —
|
||||||
|
never shift the stored values.
|
||||||
|
- **Do not hardcode how many bars a client gets.** A leftover `1000` on the
|
||||||
|
snapshot made 1m look empty past ~1am while the store held 5,000. The
|
||||||
|
store cap is `max_bars_per_tf`. The next step is a visible-window fetch,
|
||||||
|
not another silent number.
|
||||||
|
|
||||||
|
## Stay cheap
|
||||||
|
|
||||||
|
Performance is a product feature, not a later cleanup. The app already
|
||||||
|
pays for a live stream, 5k bars, and a canvas. New work must not add
|
||||||
|
cost on the hot path unless the screen or an alert has to change.
|
||||||
|
|
||||||
|
**A forming tick may update the live candle and the price label. That
|
||||||
|
is all.** It must not rebuild, `setData`, walk bars, walk drawings, or
|
||||||
|
recompute geometry. If the work cannot be an `update()` of one point,
|
||||||
|
it does not belong on the tick. Naming a different function does not
|
||||||
|
make it cheap — we banned `scheduleOverlays(true)` on ticks and then
|
||||||
|
shipped `futureSpace.setData` plus `syncLevels` on the same path.
|
||||||
|
|
||||||
|
Before you finish any change that touches bars, sockets, overlays,
|
||||||
|
drawings, or the time scale, answer: *what happens on a 1m forming
|
||||||
|
tick at 4 Hz with 5k bars?* If the answer is more than `candles.update`
|
||||||
|
+ price line, stop and make the rest a no-op unless a timestamp
|
||||||
|
actually advanced.
|
||||||
|
|
||||||
|
- Nothing extra on the event loop each closed bar or tick. Watch
|
||||||
|
`loop_lag_ms`. CPU stays in the threadpool or off the loop — see
|
||||||
|
`docs/async_refactor.md`.
|
||||||
|
- Do not grow a payload because it is easier than asking what the
|
||||||
|
client needs. Caps are named settings, not leftover literals.
|
||||||
|
- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
|
||||||
|
rebuild overlays that only depend on the viewport.
|
||||||
|
- Overlay redraws go through `scheduleOverlays`. Force when bars are
|
||||||
|
replaced, a new bar opens, or size changed. A forming-bar tick is
|
||||||
|
not that.
|
||||||
|
- Diagnostic `getImageData` / painted-pixel sampling is never on the
|
||||||
|
live overlay path. `?diag=1` is not a license to sync the GPU every
|
||||||
|
frame.
|
||||||
|
- `window` pointermove/up attach only while a tool is armed or a drag
|
||||||
|
is live. Do not leave them on for the life of the page.
|
||||||
|
- Prefer one rAF over N DOM rebuilds. Read `offsetWidth` only if the
|
||||||
|
layout actually changed.
|
||||||
|
- A feature that needs a per-frame or per-tick loop needs a reason,
|
||||||
|
and an off switch.
|
||||||
|
- Chart overlays may animate only `transform` or `opacity`. Animating
|
||||||
|
`background-position` on a full-width gradient cost ~15% CPU; the
|
||||||
|
compositor will not save you from a paint property.
|
||||||
1
CLAUDE.md
Symbolic link
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
AGENTS.md
|
||||||
|
|
@ -2,8 +2,8 @@ FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt requirements-dev.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
|
||||||
119
Justfile
Normal file
119
Justfile
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
set dotenv-load := true
|
||||||
|
set shell := ["bash", "-euo", "pipefail", "-c"]
|
||||||
|
|
||||||
|
local_url := "http://localhost:" + env_var_or_default("PORT", "8010")
|
||||||
|
production_url := env_var_or_default("URL", "https://chart.amow.com")
|
||||||
|
|
||||||
|
# List available project commands.
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# Start the local stack in the background.
|
||||||
|
up:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Build images and run the local stack in the foreground.
|
||||||
|
dev:
|
||||||
|
docker compose up --build
|
||||||
|
|
||||||
|
# Build or rebuild local images.
|
||||||
|
build:
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# Recreate the API container after environment or dependency changes.
|
||||||
|
restart:
|
||||||
|
docker compose up -d --force-recreate api
|
||||||
|
|
||||||
|
# Stop and remove the local stack.
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Show local service state.
|
||||||
|
ps:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# Validate and print the resolved Compose configuration.
|
||||||
|
compose-config:
|
||||||
|
docker compose config
|
||||||
|
|
||||||
|
# Follow service logs; e.g. `just logs api 20m`.
|
||||||
|
logs service="api" since="30m":
|
||||||
|
docker compose logs --follow --since "{{since}}" "{{service}}"
|
||||||
|
|
||||||
|
# Open a shell in the API container.
|
||||||
|
shell:
|
||||||
|
docker compose exec api sh
|
||||||
|
|
||||||
|
# Copy .env.example only when .env does not exist.
|
||||||
|
env:
|
||||||
|
@if [[ -e .env ]]; then echo ".env already exists"; else cp .env.example .env && echo "created .env"; fi
|
||||||
|
|
||||||
|
# Install local non-Docker development dependencies into .venv.
|
||||||
|
venv:
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Run the app without Docker after `just venv`.
|
||||||
|
serve:
|
||||||
|
.venv/bin/uvicorn main:app --reload
|
||||||
|
|
||||||
|
# Run all backend tests, or one pytest path/selector.
|
||||||
|
test selector="":
|
||||||
|
@if [[ -n "{{selector}}" ]]; then docker compose exec -T api python -m pytest -q "{{selector}}"; else docker compose exec -T api python -m pytest -q; fi
|
||||||
|
|
||||||
|
# Run all browser tests, or files matching a filter such as `trendline`.
|
||||||
|
e2e filter="":
|
||||||
|
./bin/e2e "{{filter}}"
|
||||||
|
|
||||||
|
# Run complete backend and browser suites.
|
||||||
|
test-all: test e2e
|
||||||
|
|
||||||
|
# Check patches for whitespace errors.
|
||||||
|
check:
|
||||||
|
git diff --check
|
||||||
|
|
||||||
|
# Require a clean committed worktree.
|
||||||
|
clean:
|
||||||
|
@if [[ -n "$(git status --porcelain)" ]]; then echo "worktree is not clean; commit intended changes first" >&2; exit 1; fi
|
||||||
|
|
||||||
|
# Run every required pre-deploy check.
|
||||||
|
predeploy: check test-all
|
||||||
|
|
||||||
|
# Capture the local chart through the Playwright service.
|
||||||
|
screenshot file="chart.png":
|
||||||
|
docker compose exec -T playwright playwright screenshot --lang en-US --wait-for-timeout 5000 http://api:8000 "/artifacts/{{file}}"
|
||||||
|
|
||||||
|
# Show local API status.
|
||||||
|
status:
|
||||||
|
curl -fsS "{{local_url}}/api/status"
|
||||||
|
|
||||||
|
# Replay Yahoo history through the alert calibration sweep.
|
||||||
|
calibrate:
|
||||||
|
docker compose exec -T api python -m scripts.calibrate_alerts
|
||||||
|
|
||||||
|
# Validate existing Schwab credentials, or print the authorization URL.
|
||||||
|
schwab-check redirect_url="":
|
||||||
|
@if [[ -n "{{redirect_url}}" ]]; then docker compose exec -T api python -m scripts.check_schwab --redirect-url "{{redirect_url}}"; else docker compose exec -T api python -m scripts.check_schwab; fi
|
||||||
|
|
||||||
|
# Confirm Schwab stream messages for a duration and symbol.
|
||||||
|
stream seconds="60" symbol="/ES":
|
||||||
|
docker compose exec -T api python -m scripts.check_stream "{{seconds}}" "{{symbol}}"
|
||||||
|
|
||||||
|
# Wait until production serves local HEAD.
|
||||||
|
wait-deploy url=production_url timeout="300":
|
||||||
|
URL="{{url}}" TIMEOUT="{{timeout}}" ./bin/wait-deploy
|
||||||
|
|
||||||
|
# Check public production health and deployed version.
|
||||||
|
smoke url=production_url:
|
||||||
|
curl -fsS "{{url}}/api/health"
|
||||||
|
curl -fsS "{{url}}/api/version"
|
||||||
|
|
||||||
|
# Check authenticated production status using TOKEN from the environment.
|
||||||
|
production-status url=production_url:
|
||||||
|
@if [[ -z "${TOKEN:-}" ]]; then echo "TOKEN is required" >&2; exit 1; fi; curl -fsS -H "X-Chart-Token: $TOKEN" "{{url}}/api/status"
|
||||||
|
|
||||||
|
# Require a clean tree, run all tests, push main, wait, and smoke test.
|
||||||
|
deploy: clean predeploy
|
||||||
|
git push origin main
|
||||||
|
./bin/wait-deploy
|
||||||
|
just smoke
|
||||||
4
Procfile
4
Procfile
|
|
@ -1 +1,5 @@
|
||||||
|
# One worker, deliberately. The market stream lives in the app lifespan, so a
|
||||||
|
# second process opens a second Schwab connection — they fight over the one
|
||||||
|
# session the account allows, and every alert fires twice. Do not add
|
||||||
|
# --workers, and do not swap in gunicorn with a worker count.
|
||||||
web: uvicorn main:app --host 0.0.0.0 --port 8000
|
web: uvicorn main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
|
||||||
352
README.md
352
README.md
|
|
@ -3,17 +3,27 @@
|
||||||
FastAPI backend + Vue 3 (from CDN, no build step) served at
|
FastAPI backend + Vue 3 (from CDN, no build step) served at
|
||||||
<https://chart.amow.com>.
|
<https://chart.amow.com>.
|
||||||
|
|
||||||
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes and daily moving
|
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes, daily moving
|
||||||
averages, and alerts on confluence zones. The full spec lives in
|
averages, prior-day high/low/close and session VWAP, and alerts on confluence zones.
|
||||||
[`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — read it before writing
|
The full spec lives in [`docs/plan.md`](docs/plan.md) — read it before writing
|
||||||
code; it records decisions and verified API facts that are expensive to rediscover.
|
code; it records decisions and verified API facts that are expensive to
|
||||||
|
rediscover. What it cost to get there is in
|
||||||
|
[`docs/implementation.md`](docs/implementation.md): a dated log of problems and
|
||||||
|
their resolutions, kept as a learning record alongside git.
|
||||||
|
|
||||||
|
Both are living documents. The app is past the point of being built and into
|
||||||
|
being maintained, so a change that makes either one wrong is not finished —
|
||||||
|
correcting the plan, or logging what was hard, is part of the work rather than
|
||||||
|
housekeeping after it.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
just dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Equivalent raw command: `docker compose up --build`.
|
||||||
|
|
||||||
Then open <http://localhost:8010>. Override the host port with, for example,
|
Then open <http://localhost:8010>. Override the host port with, for example,
|
||||||
`PORT=8020 docker compose up`. The port binds to all host interfaces, so another
|
`PORT=8020 docker compose up`. The port binds to all host interfaces, so another
|
||||||
machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted and uvicorn
|
machine can connect at `http://HOST_IP:8010`. The source tree is bind-mounted and uvicorn
|
||||||
|
|
@ -43,10 +53,145 @@ Yahoo's current eight-day minute tape:
|
||||||
python3 -m scripts.calibrate_alerts
|
python3 -m scripts.calibrate_alerts
|
||||||
```
|
```
|
||||||
|
|
||||||
The M4 calibration on 2026-08-09 replayed 8,065 minute bars across seven sessions.
|
It sweeps threshold and cooldown in a single replay pass and prints alerts per session.
|
||||||
Threshold `12` generated 210 alerts from lone daily MAs; `24` and the selected `28`
|
|
||||||
generated none. The selected threshold deliberately requires at least three clustered
|
The 2026-08-09 calibration ran when daily moving averages were the only levels, and
|
||||||
daily MAs (score `36`) and should be revisited as more varied tapes are recorded.
|
`28` produced no alerts at all — there was nothing for a daily MA to cluster *with*.
|
||||||
|
Adding prior-day H/L/C and VWAP changed that completely: the same threshold went to 247
|
||||||
|
alerts over six sessions, 185 of them in one day.
|
||||||
|
|
||||||
|
Two fixes brought it back, in this order:
|
||||||
|
|
||||||
|
- **Cluster identity** was `sha1(side + round(center / tolerance))`, and `tolerance`
|
||||||
|
derives from ATR — so it moved every bar. The same zone was continually issued a new
|
||||||
|
id, never matched the cooldown table, and the cooldown was silently defeated. 247 → 54.
|
||||||
|
- **Alert suppression** keyed on cluster identity, so a level drifting in or out of a
|
||||||
|
group counted as a new zone. It now suppresses by *proximity*: two zones within one
|
||||||
|
ATR are the same zone. 54 → 40.
|
||||||
|
- **Suppression also matched on side**, and side is positional — a level sitting at
|
||||||
|
price flips between support and resistance every time price ticks across it. Each
|
||||||
|
flip read as a new zone. Found by putting a real line at the live price and getting
|
||||||
|
four pushes in two minutes. 40 → 26.
|
||||||
|
|
||||||
|
Only then does the cooldown do anything useful. At threshold `28` the sweep reads:
|
||||||
|
|
||||||
|
| cooldown | total | max/session |
|
||||||
|
|---|---|---|
|
||||||
|
| 900s | 26 | 19 |
|
||||||
|
| 3600s | 18 | 12 |
|
||||||
|
| 7200s | 12 | 7 |
|
||||||
|
| **14400s (selected)** | **10** | **5** |
|
||||||
|
|
||||||
|
Note the threshold itself is a blunt control: scores are sums of 12s (moving averages,
|
||||||
|
VWAP) and 16s (prior-day levels), so `20`, `24` and `28` behave identically and `32`
|
||||||
|
falls to zero. Cooldown is the finer knob. Revisit both as more varied tapes are
|
||||||
|
recorded — six sessions is not much, and one of them dominates the totals.
|
||||||
|
|
||||||
|
## Just commands
|
||||||
|
|
||||||
|
The root [`Justfile`](Justfile) is the supported command interface. Install
|
||||||
|
[`just`](https://github.com/casey/just), then list every recipe with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just
|
||||||
|
# or: just --list
|
||||||
|
```
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `just up` | Start the local Compose stack in the background |
|
||||||
|
| `just dev` | Build and run the stack in the foreground |
|
||||||
|
| `just down` | Stop the local stack |
|
||||||
|
| `just restart` | Recreate the API container after environment changes |
|
||||||
|
| `just ps` | Show container state |
|
||||||
|
| `just logs` | Follow API logs from the last 30 minutes |
|
||||||
|
| `just logs playwright 10m` | Follow another service with a custom lookback |
|
||||||
|
| `just shell` | Open a shell in the API container |
|
||||||
|
| `just test` | Run the complete backend suite |
|
||||||
|
| `just test tests/test_aggregator.py` | Run a backend path or pytest selector |
|
||||||
|
| `just e2e` | Run the complete browser suite |
|
||||||
|
| `just e2e trendline` | Run matching browser test files |
|
||||||
|
| `just test-all` | Run complete backend and browser suites |
|
||||||
|
| `just clean` | Require a clean committed worktree |
|
||||||
|
| `just predeploy` | Run `git diff --check` and every test |
|
||||||
|
| `just deploy` | Run predeploy checks, require a clean tree, push `main`, wait, and smoke test |
|
||||||
|
| `just screenshot` | Save the local chart to `artifacts/playwright/chart.png` |
|
||||||
|
| `just status` | Read local API status |
|
||||||
|
| `just smoke` | Check production health and deployed version |
|
||||||
|
| `just production-status` | Read authenticated production status using `$TOKEN` |
|
||||||
|
| `just calibrate` | Run alert calibration in the API container |
|
||||||
|
| `just schwab-check` | Validate Schwab credentials or start authorization |
|
||||||
|
| `just stream 60 /ES` | Probe the Schwab stream |
|
||||||
|
|
||||||
|
`just env` creates `.env` from `.env.example` only when it is absent. `just
|
||||||
|
venv` and `just serve` provide the non-Docker setup. Recipes accept `PORT`,
|
||||||
|
`URL`, `TIMEOUT`, `TOKEN`, and `E2E_URL` through the environment where relevant.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the complete backend suite in the same container environment as the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test
|
||||||
|
# equivalent:
|
||||||
|
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass a path or pytest selector for a focused run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test 'tests/test_aggregator.py::test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et'
|
||||||
|
# equivalent:
|
||||||
|
docker exec chart-api-1 sh -c \
|
||||||
|
"cd /app && python -m pytest -q tests/test_aggregator.py::test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run every browser test against the local Compose stack, or filter by filename:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just e2e
|
||||||
|
just e2e trendline
|
||||||
|
just e2e preferences
|
||||||
|
# equivalents:
|
||||||
|
./bin/e2e
|
||||||
|
./bin/e2e trendline
|
||||||
|
./bin/e2e preferences
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser suite creates drawings and removes them afterward. Run it against
|
||||||
|
the local stack, not production: production has the persistent drawing store,
|
||||||
|
live alerts, and an authenticated feed. The helper deliberately runs tests one
|
||||||
|
at a time because the local drawing store is shared with anyone using the dev
|
||||||
|
chart.
|
||||||
|
|
||||||
|
Without Docker, install the development requirements before running pytest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && . .venv/bin/activate
|
||||||
|
pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended pre-deploy and deploy verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just predeploy
|
||||||
|
# commit the verified changes, then:
|
||||||
|
just deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
`just deploy` runs both complete test suites again, refuses a dirty worktree,
|
||||||
|
pushes `main`, waits for production to serve local `HEAD`, and checks health and
|
||||||
|
version. The raw commands remain available when diagnosing an individual step.
|
||||||
|
|
||||||
|
For an authenticated production status smoke test, use the chart token without
|
||||||
|
printing it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status | jq .
|
||||||
|
```
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|
@ -56,6 +201,7 @@ daily MAs (score `36`) and should be revisited as more varied tapes are recorded
|
||||||
| `app/` | Market sources, aggregation, analysis, alerts, and API |
|
| `app/` | Market sources, aggregation, analysis, alerts, and API |
|
||||||
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
|
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
|
||||||
| `requirements.txt` | Python deps |
|
| `requirements.txt` | Python deps |
|
||||||
|
| `Justfile` | Supported development, test, and deployment commands |
|
||||||
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
|
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
|
||||||
| `bin/wait-deploy` | Blocks until the live site serves your latest commit |
|
| `bin/wait-deploy` | Blocks until the live site serves your latest commit |
|
||||||
| `Dockerfile.dev`, `docker-compose.yml` | Local dev only — production does not use them |
|
| `Dockerfile.dev`, `docker-compose.yml` | Local dev only — production does not use them |
|
||||||
|
|
@ -72,7 +218,7 @@ serving for the whole build.
|
||||||
To know when your commit is actually live, rather than guessing:
|
To know when your commit is actually live, rather than guessing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push && bin/wait-deploy
|
git push && just wait-deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
It polls `/api/version` (which returns the `SOURCE_COMMIT` Coolify bakes into
|
It polls `/api/version` (which returns the `SOURCE_COMMIT` Coolify bakes into
|
||||||
|
|
@ -104,29 +250,199 @@ curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Access token
|
## Putting the live feed on production
|
||||||
|
|
||||||
|
Production runs the default `LIVE_SOURCE=yahoo` and therefore shows "yahoo" with
|
||||||
|
a ~10 minute delay. `LIVE_SOURCE=schwab` and the credentials live in `.env`,
|
||||||
|
which is gitignored — so the switch has never crossed the deploy boundary. It
|
||||||
|
cannot: nothing in the repo carries it.
|
||||||
|
|
||||||
|
Four things are needed, and all of them are set on the VPS rather than here.
|
||||||
|
|
||||||
|
**1. Environment, in Coolify:**
|
||||||
|
|
||||||
|
```
|
||||||
|
LIVE_SOURCE=schwab
|
||||||
|
SCHWAB_API_KEY=... # same values as the local .env
|
||||||
|
SCHWAB_APP_SECRET=...
|
||||||
|
```
|
||||||
|
|
||||||
|
`SCHWAB_CALLBACK_URL` already defaults to `https://chart.amow.com/api/qt`, which
|
||||||
|
is what the Schwab app is registered with. Do not change it — a registered
|
||||||
|
callback edit can send the app back through approval.
|
||||||
|
|
||||||
|
**2. The OAuth token, at `data/.schwab_token.json` on the persistent volume.**
|
||||||
|
No new login is required: the local token was minted against the production
|
||||||
|
callback, and Schwab tokens are bound to the app rather than the machine. Copy
|
||||||
|
the file's contents into that path on the volume. If the path is not persistent,
|
||||||
|
the next deploy erases it and production falls silently back to Yahoo.
|
||||||
|
|
||||||
|
**3. Stop the dev stream first.** One refresh token means one Schwab streaming
|
||||||
|
connection. Dev and production both streaming will fight for it — see the risk
|
||||||
|
register on duplicate connections. Set `LIVE_SOURCE=yahoo` in the local `.env`,
|
||||||
|
or stop the local stack, before production goes live.
|
||||||
|
|
||||||
|
**4. Plan for expiry.** The Schwab refresh token lasts about seven days. When it
|
||||||
|
lapses, production drops back to Yahoo and needs a fresh token by the same
|
||||||
|
route: run the flow locally, paste the callback URL from `/api/qt` into the
|
||||||
|
waiting prompt, then copy the new token onto the volume.
|
||||||
|
|
||||||
|
Verify from anywhere:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "X-Chart-Token: $TOKEN" https://chart.amow.com/api/status
|
||||||
|
# want: "source":"schwab","delay_minutes":0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser password and API token
|
||||||
|
|
||||||
`CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave
|
`CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave
|
||||||
it blank and the app is wide open, which is what you want locally — nothing
|
it blank and the app is wide open, which is what you want locally — nothing
|
||||||
prompts. Set it and every request needs the token, as the `X-Chart-Token`
|
prompts. Scripts can supply it as the `X-Chart-Token` header or a `?token=`
|
||||||
header or a `?token=` query parameter (WebSocket handshakes can't carry
|
query parameter.
|
||||||
headers, hence the second form).
|
|
||||||
|
|
||||||
In production the token lives in **Coolify's environment variables**, not in
|
Set `CHART_PASSWORD` to a human-friendly passphrase for browser access. The
|
||||||
|
browser posts it once to `/api/login`; the server returns a signed, HttpOnly,
|
||||||
|
30-day HS256 JWT cookie used by both API requests and the WebSocket. The opaque
|
||||||
|
API token is never returned to or stored by the browser. If `CHART_PASSWORD` is
|
||||||
|
temporarily absent, the login accepts `CHART_AUTH_TOKEN` as a migration fallback.
|
||||||
|
Existing browsers that stored a token under the old flow exchange it once for a
|
||||||
|
session and remove it from `localStorage`. `POST /api/logout` clears the session.
|
||||||
|
|
||||||
|
In production both secrets live in **Coolify's environment variables**, not in
|
||||||
this repo and not in `.env` — that file is gitignored and never exists in the
|
this repo and not in `.env` — that file is gitignored and never exists in the
|
||||||
built container. Coolify re-injects its env vars into every container it
|
built container. Coolify re-injects its env vars into every container it
|
||||||
builds, so the token survives redeploys and reboots.
|
builds, so the token survives redeploys and reboots.
|
||||||
|
|
||||||
The browser asks for it once on the first 401 and keeps it in `localStorage`.
|
|
||||||
To clear it: `localStorage.removeItem('chart-token')`.
|
|
||||||
|
|
||||||
`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy`
|
`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy`
|
||||||
polls the latter from whatever machine you pushed from, and neither reveals
|
polls the latter from whatever machine you pushed from, and neither reveals
|
||||||
anything about the market data or the configuration.
|
anything about the market data or the configuration.
|
||||||
|
|
||||||
|
## Setting an alert on a price
|
||||||
|
|
||||||
|
Type the price into **Price alert** in the sidebar. It appears as a horizontal line
|
||||||
|
across the chart with a price-axis label, and alerts when price reaches it.
|
||||||
|
|
||||||
|
A price alert is stored as a manual line with `slope = 0`, so it inherits the whole
|
||||||
|
manual-line pipeline — JSON persistence, renaming, recolouring, deletion, clustering
|
||||||
|
with nearby levels — rather than being a parallel system. Two consequences worth
|
||||||
|
knowing:
|
||||||
|
|
||||||
|
- It **alerts regardless of confluence score**, like any hand-placed level. Weight
|
||||||
|
exists to rank levels you did not ask for; you asked for this one.
|
||||||
|
- It has no drag handles and no "end line here" menu, because a price line has no
|
||||||
|
endpoints to grab. Manage it from the sidebar list.
|
||||||
|
|
||||||
|
If it lands near other levels it clusters with them and the score adds up, so a typed
|
||||||
|
level sitting on the prior-day close reads as one zone rather than two alerts.
|
||||||
|
|
||||||
|
## Market data: Yahoo for the past, Schwab for the present
|
||||||
|
|
||||||
|
Both sources run together. This is the intended configuration, not a fallback:
|
||||||
|
|
||||||
|
- **Schwab** streams real-time `/ES` minute bars over `CHART_FUTURES`
|
||||||
|
(`delayed: false`), but serves **no futures history at all** — everything it
|
||||||
|
knows starts when you connect.
|
||||||
|
- **Yahoo** has roughly 730 days of hourly data, 60 days of native 30-minute
|
||||||
|
bars, and eight days of minute data. Hourly history makes a 200-day moving
|
||||||
|
average warm at startup; the native 30-minute seed avoids limiting that chart
|
||||||
|
to the minute endpoint's eight-day window. Yahoo lags ~10 minutes.
|
||||||
|
|
||||||
|
Switch the live feed with `LIVE_SOURCE=schwab`; seeding stays on Yahoo whatever
|
||||||
|
you set, because Schwab has nothing to seed from. The symbols differ — Yahoo says
|
||||||
|
`ES=F`, Schwab says `/ES` — and `Settings.live_symbol` picks the right one. Every
|
||||||
|
bar carries a `source` tag so the seam stays visible.
|
||||||
|
|
||||||
|
**Expect a gap of up to ten minutes at the right-hand edge after a restart.**
|
||||||
|
Yahoo's history reaches to *now − 10 min* while the stream starts at *now*, so
|
||||||
|
the most recent bars are briefly missing. It backfills itself as Yahoo catches
|
||||||
|
up. Live price and alerts are unaffected — those come from the stream. Persisting
|
||||||
|
bars would remove it entirely.
|
||||||
|
|
||||||
|
`/ES` resolves to the active contract (`/ESU26` today) on Schwab's side, so
|
||||||
|
contract rolls need no handling.
|
||||||
|
|
||||||
|
### Rate limits
|
||||||
|
|
||||||
|
The developer portal shows **Order Limit: 120**, which caps orders per minute —
|
||||||
|
this app places none. Schwab separately rate-limits REST calls, commonly cited at
|
||||||
|
120 per minute.
|
||||||
|
|
||||||
|
Neither constrains us, because **streaming is not REST**. The WebSocket is a
|
||||||
|
single connection and bars arrive by push, so steady-state Schwab REST usage is
|
||||||
|
essentially zero. This is a concrete advantage of the stream over the polling
|
||||||
|
fallback: polling `get_quotes()` once a second would have sat at roughly half the
|
||||||
|
limit permanently, forever.
|
||||||
|
|
||||||
|
The exception is reconnects. Each one calls `get_user_preferences()` to fetch the
|
||||||
|
socket URL, and `StreamService` retries every five seconds, so a sustained outage
|
||||||
|
generates about twelve REST calls a minute. Comfortably under, but not nothing —
|
||||||
|
worth remembering before shortening that backoff.
|
||||||
|
|
||||||
|
Yahoo is a different service and none of these limits apply to it.
|
||||||
|
|
||||||
|
### Authenticating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m scripts.check_schwab # prints the login URL
|
||||||
|
python3 -m scripts.check_schwab --redirect-url '…' # exchanges the code
|
||||||
|
python3 -m scripts.check_stream 60 /ES # confirms bars arrive
|
||||||
|
```
|
||||||
|
|
||||||
|
Two steps, neither interactive, so the browser can be on a different machine —
|
||||||
|
you copy a URL out and paste one back. **The authorisation code expires in about
|
||||||
|
thirty seconds**, so have the second command ready before you approve. The
|
||||||
|
callback page 404s until this branch is deployed; that is cosmetic, the code is
|
||||||
|
in the address bar regardless.
|
||||||
|
|
||||||
|
The token refreshes itself for seven days, then needs the flow again. It is
|
||||||
|
per-machine: `.schwab_token.json` locally, and under `data/` in production, which
|
||||||
|
is the Coolify volume. Locally `data/` is owned by root because Docker created it
|
||||||
|
through the bind mount, which is why the local path differs.
|
||||||
|
|
||||||
|
## Alerts and ntfy
|
||||||
|
|
||||||
|
Alerts are evaluated **server-side**, once per closed 1m bar, by a single engine
|
||||||
|
living in `Runtime`. They do not depend on a browser being connected — that is the
|
||||||
|
whole point of the phone push — and opening two tabs does not double-notify.
|
||||||
|
|
||||||
|
`NTFY_TOPIC` must be set or nothing sends; `send_ntfy` returns immediately on a blank
|
||||||
|
topic. Set it in **Coolify's environment variables** for production, not in this repo.
|
||||||
|
|
||||||
|
Daily MA bells in Layers watch the 10/20/50/100/200 independently of whether
|
||||||
|
the line is drawn. They use the same leave-and-return cooldown as zones, not
|
||||||
|
one-shot disarm. The watches live in `USER_PREFS_PATH` (`./data/user_prefs.json`).
|
||||||
|
|
||||||
|
**Cooldown state survives a restart.** The fired-zone table is written to
|
||||||
|
`ALERT_STATE_PATH` (`./data/alert_state.json`), which in production is the same
|
||||||
|
persistent volume as the trendlines. Before that, every deploy started with empty
|
||||||
|
cooldowns and the next closed bar re-alerted whatever zone price was sitting on —
|
||||||
|
with a four-hour cooldown, each push produced a burst of notifications for zones
|
||||||
|
that had already had their say.
|
||||||
|
|
||||||
|
Two consequences worth knowing. The file has to be on the volume, or the problem
|
||||||
|
comes straight back on the next deploy. And a corrupt or unreadable state file is
|
||||||
|
deliberately non-fatal: it logs and starts empty, costing one burst of duplicate
|
||||||
|
alerts rather than refusing to start the stream.
|
||||||
|
|
||||||
|
**Still prefer a blank topic locally.** Persistence removes the restart bursts, but
|
||||||
|
an in-memory engine is only half the story — a dev instance watching the same
|
||||||
|
symbol will happily push real alerts to your phone. Leaving `NTFY_TOPIC` blank keeps
|
||||||
|
the in-browser sound and banner while suppressing the push; set a `-dev` topic only
|
||||||
|
while testing the push path itself.
|
||||||
|
|
||||||
|
Note that ntfy topics are public by default: anyone who knows the name can both read
|
||||||
|
your alerts and publish to it. Treat the topic name as a secret.
|
||||||
|
|
||||||
## Saved trendlines
|
## Saved trendlines
|
||||||
|
|
||||||
Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`).
|
Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`).
|
||||||
In production `/app/data` is a **Coolify persistent volume** — without it the
|
In production `/app/data` is a **Coolify persistent volume** — without it the
|
||||||
container filesystem is ephemeral and every deploy would silently wipe every
|
container filesystem is ephemeral and every deploy would silently wipe every
|
||||||
line you've drawn.
|
line you've drawn.
|
||||||
|
|
||||||
|
Sloped lines are priced in the bar space of the timeframe on which they were
|
||||||
|
drawn, then sampled onto the displayed candles. This keeps a 30m line in the
|
||||||
|
same place on 30m and 1m and makes the chart agree with alert pricing. Set
|
||||||
|
`TRENDLINE_SOURCE_GEOMETRY=false` and restart for an immediate rollback to the
|
||||||
|
previous displayed/1m-grid behavior; drawing data is unchanged either way.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,147 @@
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from app.analysis.confluence import Cluster
|
from app.analysis.confluence import Cluster
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.instrument import DEFAULT_SYMBOL, instrument_for_symbol
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _drawn_name(member: Level) -> str:
|
||||||
|
number = f"#{member.number}" if member.number is not None else ""
|
||||||
|
label = (member.label or "").strip()
|
||||||
|
if number and label and label not in (number, f"#{member.number}"):
|
||||||
|
return f"{number} {label}"
|
||||||
|
return number or label or member.id
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Alert:
|
class Alert:
|
||||||
cluster: Cluster
|
cluster: Cluster
|
||||||
message: str
|
message: str
|
||||||
|
# Hand-placed levels that caused this alert; the caller disarms them so a
|
||||||
|
# one-shot alert stays one-shot.
|
||||||
|
tripped: tuple[str, ...] = ()
|
||||||
|
# Assigned by the engine, monotonic and persisted, so the same alert carries
|
||||||
|
# the same number on a phone and on a screen. A browser cannot number these:
|
||||||
|
# its counter restarts on reload and differs between tabs. Declared after
|
||||||
|
# `tripped` so existing positional callers keep working.
|
||||||
|
number: int = 0
|
||||||
|
at: int = 0
|
||||||
|
# The push body: the same alert with its number and local time folded in,
|
||||||
|
# because ntfy carries plain text and has nowhere else to put them. The
|
||||||
|
# browser gets `number` as a field and renders it as a badge, so `message`
|
||||||
|
# stays clean and the number is not printed twice.
|
||||||
|
push: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _Fired:
|
||||||
|
center: float
|
||||||
|
at: int
|
||||||
|
symbol: str = DEFAULT_SYMBOL
|
||||||
|
|
||||||
|
|
||||||
class AlertEngine:
|
class AlertEngine:
|
||||||
def __init__(self, min_score: float, cooldown_seconds: int = 900):
|
"""Fires once per price zone, then stays quiet until price genuinely leaves.
|
||||||
|
|
||||||
|
Suppression is by proximity rather than cluster identity. Membership churns
|
||||||
|
constantly — a moving average drifts in and out of a group, changing the
|
||||||
|
cluster's identity while a human still sees one zone sitting at the prior
|
||||||
|
day's close. Keying on identity let every reshuffle through as a fresh
|
||||||
|
alert; keying on where the zone *is* does not.
|
||||||
|
|
||||||
|
Suppression is persisted when given a ``state_path``. Without it the list
|
||||||
|
lives only in memory, so every restart re-fires every zone that currently
|
||||||
|
qualifies — with a four-hour cooldown that turned each deploy into a burst
|
||||||
|
of pushes for zones that had already had their say.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
min_score: float,
|
||||||
|
cooldown_seconds: int = 900,
|
||||||
|
state_path: Path | None = None,
|
||||||
|
timezone_name: str = "UTC",
|
||||||
|
):
|
||||||
self.min_score = min_score
|
self.min_score = min_score
|
||||||
self.cooldown_seconds = cooldown_seconds
|
self.cooldown_seconds = cooldown_seconds
|
||||||
self._fired_at: dict[str, int] = {}
|
self.timezone = timezone_name
|
||||||
|
self.state_path = Path(state_path) if state_path else None
|
||||||
|
self._next_number = 1
|
||||||
|
self._fired: list[_Fired] = self._load()
|
||||||
|
|
||||||
|
def _load(self) -> list[_Fired]:
|
||||||
|
if not self.state_path or not self.state_path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||||
|
# The file used to be a bare list, before alerts were numbered.
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
self._next_number = int(payload.get("next_number", 1))
|
||||||
|
payload = payload.get("fired", [])
|
||||||
|
return [
|
||||||
|
_Fired(
|
||||||
|
float(item["center"]),
|
||||||
|
int(item["at"]),
|
||||||
|
str(item.get("symbol") or DEFAULT_SYMBOL),
|
||||||
|
)
|
||||||
|
for item in payload
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
# Corrupt state costs one burst of duplicate alerts, which is a far
|
||||||
|
# better failure than refusing to start the stream.
|
||||||
|
logger.warning("Could not read alert state; starting empty", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _stamp(self, when: int) -> str:
|
||||||
|
"""The alert's time, in the configured zone rather than the server's.
|
||||||
|
|
||||||
|
Containers run in UTC. A push that says 02:14 when the person reading it
|
||||||
|
sees 21:14 on their wall costs a moment of translation every time.
|
||||||
|
"""
|
||||||
|
moment = datetime.fromtimestamp(when, timezone.utc)
|
||||||
|
try:
|
||||||
|
moment = moment.astimezone(ZoneInfo(self.timezone))
|
||||||
|
except Exception:
|
||||||
|
# An unknown zone must not cost an alert; UTC is still readable.
|
||||||
|
logger.warning("Unknown alert timezone %r; using UTC", self.timezone)
|
||||||
|
return moment.strftime("%a %H:%M:%S %Z")
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
if not self.state_path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"next_number": self._next_number,
|
||||||
|
"fired": [
|
||||||
|
{
|
||||||
|
"center": entry.center,
|
||||||
|
"at": entry.at,
|
||||||
|
"symbol": entry.symbol,
|
||||||
|
}
|
||||||
|
for entry in self._fired
|
||||||
|
],
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(self.state_path)
|
||||||
|
except Exception:
|
||||||
|
# Losing a write means duplicate alerts later, never a missed one.
|
||||||
|
logger.warning("Could not persist alert state", exc_info=True)
|
||||||
|
|
||||||
def evaluate(
|
def evaluate(
|
||||||
self,
|
self,
|
||||||
|
|
@ -22,34 +150,149 @@ class AlertEngine:
|
||||||
atr15: float,
|
atr15: float,
|
||||||
now: int,
|
now: int,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
|
watched: list[Level] | None = None,
|
||||||
|
confluence: bool = True,
|
||||||
) -> list[Alert]:
|
) -> list[Alert]:
|
||||||
tolerance = 0.5 * atr15
|
tolerance = 0.5 * atr15
|
||||||
if tolerance <= 0:
|
if tolerance <= 0:
|
||||||
return []
|
return []
|
||||||
alerts: list[Alert] = []
|
root = instrument_for_symbol(symbol).schwab_symbol
|
||||||
active_ids = {cluster.id for cluster in clusters}
|
# Two zones within an ATR of each other are the same zone as far as
|
||||||
for cluster_id, fired_at in list(self._fired_at.items()):
|
# being told about them goes.
|
||||||
cluster = next((item for item in clusters if item.id == cluster_id), None)
|
merge_distance = 2 * tolerance
|
||||||
separated = cluster is None or abs(cluster.center - current_price) > 2 * tolerance
|
|
||||||
if separated and now - fired_at >= self.cooldown_seconds:
|
|
||||||
del self._fired_at[cluster_id]
|
|
||||||
elif cluster_id not in active_ids and now - fired_at >= self.cooldown_seconds:
|
|
||||||
del self._fired_at[cluster_id]
|
|
||||||
|
|
||||||
for cluster in clusters:
|
# Re-arming needs both elapsed time and real separation. Time alone lets
|
||||||
if (
|
# price oscillating on a level alert forever.
|
||||||
cluster.score < self.min_score
|
before = len(self._fired)
|
||||||
or abs(cluster.center - current_price) > tolerance
|
self._fired = [
|
||||||
or cluster.id in self._fired_at
|
entry
|
||||||
|
for entry in self._fired
|
||||||
|
if not (
|
||||||
|
now - entry.at >= self.cooldown_seconds
|
||||||
|
and abs(entry.center - current_price) > merge_distance
|
||||||
|
)
|
||||||
|
]
|
||||||
|
changed = len(self._fired) != before
|
||||||
|
|
||||||
|
alerts: list[Alert] = []
|
||||||
|
# Strongest first, so when several overlapping zones qualify at once the
|
||||||
|
# one that survives suppression is the most significant.
|
||||||
|
for cluster in sorted(clusters, key=lambda item: item.score, reverse=True):
|
||||||
|
drawn = [
|
||||||
|
member
|
||||||
|
for member in cluster.members
|
||||||
|
if member.kind is LevelKind.MANUAL and member.armed
|
||||||
|
]
|
||||||
|
early_drawn = [
|
||||||
|
member for member in drawn
|
||||||
|
if member.alert_early_points is not None
|
||||||
|
and (
|
||||||
|
-tolerance
|
||||||
|
<= (
|
||||||
|
member.anchor_p - current_price
|
||||||
|
if member.anchor_p >= current_price
|
||||||
|
else current_price - member.anchor_p
|
||||||
|
)
|
||||||
|
<= member.alert_early_points
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# A drawn line bypasses the score threshold entirely. Weights run
|
||||||
|
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
|
||||||
|
# score would mean a line you deliberately drew could never alert.
|
||||||
|
# A disarmed one has already had its say and no longer qualifies.
|
||||||
|
if not drawn and (not confluence or cluster.score < self.min_score):
|
||||||
|
continue
|
||||||
|
if abs(cluster.center - current_price) > tolerance and not early_drawn:
|
||||||
|
continue
|
||||||
|
# Deliberately not matched on side. A level sitting at price flips
|
||||||
|
# between support and resistance every time price ticks across it,
|
||||||
|
# because the side is positional. Matching on it meant a zone price
|
||||||
|
# was oscillating on re-alerted on every crossing — which is exactly
|
||||||
|
# when a level is least newsworthy, not most.
|
||||||
|
if any(
|
||||||
|
entry.symbol == root
|
||||||
|
and abs(entry.center - cluster.center) <= merge_distance
|
||||||
|
for entry in self._fired
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
self._fired_at[cluster.id] = now
|
self._fired.append(_Fired(cluster.center, now, root))
|
||||||
|
changed = True
|
||||||
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
||||||
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
||||||
|
# A drawing number is what you look up. "confluence 28" is the
|
||||||
|
# score — easy to read as the line id when the drawing is #27.
|
||||||
|
names = ", ".join(_drawn_name(member) for member in drawn)
|
||||||
|
lone_line = bool(drawn) and len(cluster.members) == len(drawn)
|
||||||
|
headline = "LINE" if lone_line else "ZONE"
|
||||||
|
if lone_line:
|
||||||
|
detail = f"{cluster.side.value.title()} {names} @ {cluster.center:.2f}"
|
||||||
|
else:
|
||||||
|
detail = (
|
||||||
|
f"{cluster.side.value.title()} confluence {cluster.score:g} "
|
||||||
|
f"@ {cluster.low:.2f}-{cluster.high:.2f}"
|
||||||
|
)
|
||||||
|
if names:
|
||||||
|
detail = f"{cluster.side.value.title()} {names}\n" + detail
|
||||||
message = (
|
message = (
|
||||||
f"{direction} ZONE {symbol} {current_price:.2f}\n"
|
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
|
||||||
f"{cluster.side.value.title()} confluence {cluster.score:g} "
|
|
||||||
f"@ {cluster.low:.2f}-{cluster.high:.2f}\n{timeframes}"
|
|
||||||
)
|
)
|
||||||
alerts.append(Alert(cluster, message))
|
number = self._next_number
|
||||||
|
self._next_number += 1
|
||||||
|
fired_at = int(now)
|
||||||
|
# The number leads the message so it survives truncation in a
|
||||||
|
# notification shade, and the time is local because a push read on a
|
||||||
|
# phone is read by a person, not by a machine.
|
||||||
|
alerts.append(
|
||||||
|
Alert(
|
||||||
|
cluster,
|
||||||
|
message,
|
||||||
|
tuple(member.id for member in drawn),
|
||||||
|
number=number,
|
||||||
|
at=fired_at,
|
||||||
|
push=f"#{number} {message}\n{self._stamp(fired_at)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for level in watched or []:
|
||||||
|
price = level.current_p if level.current_p is not None else level.anchor_p
|
||||||
|
if abs(price - current_price) > tolerance:
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
entry.symbol == root
|
||||||
|
and abs(entry.center - price) <= merge_distance
|
||||||
|
for entry in self._fired
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
self._fired.append(_Fired(price, now, root))
|
||||||
|
changed = True
|
||||||
|
direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH"
|
||||||
|
name = f"{level.period} DMA" if level.period else level.label
|
||||||
|
message = (
|
||||||
|
f"{direction} DMA {symbol} {current_price:.2f}\n"
|
||||||
|
f"{name} @ {price:.2f}\n{level.tf.value}"
|
||||||
|
)
|
||||||
|
number = self._next_number
|
||||||
|
self._next_number += 1
|
||||||
|
fired_at = int(now)
|
||||||
|
cluster = Cluster(
|
||||||
|
f"dma:{level.id}",
|
||||||
|
level.side,
|
||||||
|
price,
|
||||||
|
price,
|
||||||
|
price,
|
||||||
|
level.weight,
|
||||||
|
[level],
|
||||||
|
abs(price - current_price),
|
||||||
|
)
|
||||||
|
alerts.append(
|
||||||
|
Alert(
|
||||||
|
cluster,
|
||||||
|
message,
|
||||||
|
(),
|
||||||
|
number=number,
|
||||||
|
at=fired_at,
|
||||||
|
push=f"#{number} {message}\n{self._stamp(fired_at)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
self._save()
|
||||||
return alerts
|
return alerts
|
||||||
|
|
|
||||||
130
app/analysis/bar_space.py
Normal file
130
app/analysis/bar_space.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Positions within a bar series, rather than on a clock.
|
||||||
|
|
||||||
|
A chart spaces bars evenly no matter how much time separates them: a weekend is
|
||||||
|
forty-nine hours but one bar wide. So a line that looks straight is straight in
|
||||||
|
*index* space, and a trendline advances per bar, not per second.
|
||||||
|
|
||||||
|
Evaluating trendlines any other way makes the drawn line and the alerted price
|
||||||
|
disagree — measured at 147 points across a weekend on a real /ES chart.
|
||||||
|
"""
|
||||||
|
from bisect import bisect_right
|
||||||
|
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.bars.session import bucket_duration, next_bucket_start
|
||||||
|
|
||||||
|
# Same bound as ConfluenceChart.MAX_INTRADAY_GAP_SECONDS: short tape holes
|
||||||
|
# occupy empty slots on the chart, so source index must count them too.
|
||||||
|
MAX_INTRADAY_GAP_SECONDS = 30 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]:
|
||||||
|
"""Insert missing bucket opens inside short intraday holes.
|
||||||
|
|
||||||
|
A 9-minute 1m hole is eight empty columns on screen. Without these times,
|
||||||
|
source index treats the two surrounding bars as adjacent and the line
|
||||||
|
goes flat across the hole. Settlement and weekends stay compressed.
|
||||||
|
"""
|
||||||
|
if tf is Timeframe.D1 or len(times) < 2:
|
||||||
|
return times
|
||||||
|
step = tf.seconds
|
||||||
|
if step <= 0:
|
||||||
|
return times
|
||||||
|
filled = [times[0]]
|
||||||
|
for time in times[1:]:
|
||||||
|
prev = filled[-1]
|
||||||
|
gap = time - prev
|
||||||
|
if step < gap <= MAX_INTRADAY_GAP_SECONDS:
|
||||||
|
filled.extend(range(prev + step, time, step))
|
||||||
|
filled.append(time)
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
|
def index_at(times: list[int], t: int) -> float:
|
||||||
|
"""Fractional index of a timestamp within an ascending bar-time series."""
|
||||||
|
if not times:
|
||||||
|
return 0.0
|
||||||
|
if len(times) == 1:
|
||||||
|
return 0.0
|
||||||
|
# Outside the series there are no bars to measure against, so fall back to
|
||||||
|
# the spacing at the nearest edge.
|
||||||
|
if t <= times[0]:
|
||||||
|
step = times[1] - times[0]
|
||||||
|
return (t - times[0]) / step if step else 0.0
|
||||||
|
if t >= times[-1]:
|
||||||
|
step = times[-1] - times[-2]
|
||||||
|
return (len(times) - 1) + ((t - times[-1]) / step if step else 0.0)
|
||||||
|
|
||||||
|
lower = bisect_right(times, t) - 1
|
||||||
|
span = times[lower + 1] - times[lower]
|
||||||
|
return lower + ((t - times[lower]) / span if span else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def price_in_bar_space(level, times: list[int], t: int) -> float:
|
||||||
|
"""A level's price at `t`, interpolated across bars rather than seconds."""
|
||||||
|
start_index = index_at(times, level.anchor_t)
|
||||||
|
end_index = index_at(times, level.last_t)
|
||||||
|
if end_index == start_index:
|
||||||
|
return level.anchor_p
|
||||||
|
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
|
||||||
|
ratio = (index_at(times, t) - start_index) / (end_index - start_index)
|
||||||
|
return level.anchor_p + (end_price - level.anchor_p) * ratio
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_index_at(
|
||||||
|
times: list[int], t: int, tf: Timeframe, *, allow_future: bool = False,
|
||||||
|
) -> float | None:
|
||||||
|
"""Position `t` in a timeframe's own logical bar space.
|
||||||
|
|
||||||
|
Unlike ``index_at``, this never extrapolates backward from a truncated
|
||||||
|
window. Within a real source bucket it advances by that bucket's normal
|
||||||
|
duration, so the final minutes before a weekend do not get divided by the
|
||||||
|
entire weekend gap.
|
||||||
|
"""
|
||||||
|
if not times:
|
||||||
|
return None
|
||||||
|
upper = bisect_right(times, t)
|
||||||
|
if upper and times[upper - 1] == t:
|
||||||
|
return float(upper - 1)
|
||||||
|
lower = upper - 1
|
||||||
|
if lower < 0:
|
||||||
|
return None
|
||||||
|
duration = bucket_duration(times[lower], tf)
|
||||||
|
elapsed = t - times[lower]
|
||||||
|
if duration <= 0 or elapsed < 0:
|
||||||
|
return None
|
||||||
|
if elapsed > duration:
|
||||||
|
if not (allow_future and lower == len(times) - 1):
|
||||||
|
return None
|
||||||
|
current = times[lower]
|
||||||
|
index = float(lower)
|
||||||
|
for _ in range(10000):
|
||||||
|
following = next_bucket_start(current, tf)
|
||||||
|
if t < following:
|
||||||
|
active = bucket_duration(current, tf)
|
||||||
|
return index + (t - current) / active if t <= current + active else None
|
||||||
|
index += 1
|
||||||
|
current = following
|
||||||
|
if t == current:
|
||||||
|
return index
|
||||||
|
return None
|
||||||
|
return lower + elapsed / duration
|
||||||
|
|
||||||
|
|
||||||
|
def price_in_timeframe_space(
|
||||||
|
level, times: list[int], tf: Timeframe, t: int,
|
||||||
|
) -> float | None:
|
||||||
|
"""Price a line in the bar space of the timeframe it belongs to."""
|
||||||
|
# Endpoints may deliberately sit in the projection area. They use the same
|
||||||
|
# repeated-source-bucket approximation as the browser; the live evaluation
|
||||||
|
# instant itself must still belong to held source history.
|
||||||
|
times = fill_short_gaps(times, tf)
|
||||||
|
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
|
||||||
|
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
|
||||||
|
target_index = timeframe_index_at(times, t, tf)
|
||||||
|
if start_index is None or end_index is None or target_index is None:
|
||||||
|
return None
|
||||||
|
if end_index == start_index:
|
||||||
|
return level.anchor_p
|
||||||
|
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
|
||||||
|
ratio = (target_index - start_index) / (end_index - start_index)
|
||||||
|
return level.anchor_p + (end_price - level.anchor_p) * ratio
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import dataclass
|
||||||
from hashlib import sha1
|
from hashlib import sha1
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.analysis.levels import Level, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -17,10 +17,18 @@ class Cluster:
|
||||||
distance: float
|
distance: float
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
value = asdict(self)
|
# Built field by field rather than via asdict(), which would deep-copy
|
||||||
value["side"] = self.side.value
|
# every member's point history before we replaced it with summaries.
|
||||||
value["members"] = [member.to_dict() for member in self.members]
|
return {
|
||||||
return value
|
"id": self.id,
|
||||||
|
"side": self.side.value,
|
||||||
|
"low": self.low,
|
||||||
|
"high": self.high,
|
||||||
|
"center": self.center,
|
||||||
|
"score": self.score,
|
||||||
|
"members": [member.summary() for member in self.members],
|
||||||
|
"distance": self.distance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cluster_levels(
|
def cluster_levels(
|
||||||
|
|
@ -31,9 +39,11 @@ def cluster_levels(
|
||||||
return []
|
return []
|
||||||
groups: list[list[tuple[float, Level]]] = []
|
groups: list[list[tuple[float, Level]]] = []
|
||||||
positioned = [
|
positioned = [
|
||||||
(level.price_at(current_t), level)
|
(level.current_p if level.current_p is not None else level.price_at(current_t), level)
|
||||||
for level in levels
|
for level in levels
|
||||||
if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t)
|
if level.geometry_resolved
|
||||||
|
and not level.hidden
|
||||||
|
and (level.cutoff_t is None or current_t <= level.cutoff_t)
|
||||||
]
|
]
|
||||||
for positional_side in (Side.SUPPORT, Side.RESISTANCE):
|
for positional_side in (Side.SUPPORT, Side.RESISTANCE):
|
||||||
side_levels = sorted(
|
side_levels = sorted(
|
||||||
|
|
@ -56,13 +66,22 @@ def cluster_levels(
|
||||||
clusters: list[Cluster] = []
|
clusters: list[Cluster] = []
|
||||||
for group in groups:
|
for group in groups:
|
||||||
score = sum(level.weight for _, level in group)
|
score = sum(level.weight for _, level in group)
|
||||||
if len(group) < 2 and score < 8:
|
# A hand-drawn line survives on its own however little it weighs: it is
|
||||||
|
# an explicit statement that this price matters. Everything else has to
|
||||||
|
# earn its place by clustering or by being a heavyweight daily level.
|
||||||
|
drawn = any(level.kind is LevelKind.MANUAL for _, level in group)
|
||||||
|
if not drawn and len(group) < 2 and score < 8:
|
||||||
continue
|
continue
|
||||||
low, high = group[0][0], group[-1][0]
|
low, high = group[0][0], group[-1][0]
|
||||||
center = (low + high) / 2
|
center = (low + high) / 2
|
||||||
side = Side.RESISTANCE if center >= current_price else Side.SUPPORT
|
side = Side.RESISTANCE if center >= current_price else Side.SUPPORT
|
||||||
identity_bucket = round(center / tolerance)
|
# Identity is the set of levels converging, not a price bucket. The
|
||||||
identity = sha1(f"{side.value}:{identity_bucket}".encode()).hexdigest()[:12]
|
# bucket was sized by tolerance, which is derived from ATR and so moves
|
||||||
|
# every bar — the same zone kept being issued a new id, the alert
|
||||||
|
# engine never recognised it as already fired, and the cooldown was
|
||||||
|
# silently defeated.
|
||||||
|
members = ",".join(sorted(level.id for _, level in group))
|
||||||
|
identity = sha1(f"{side.value}:{members}".encode()).hexdigest()[:12]
|
||||||
clusters.append(
|
clusters.append(
|
||||||
Cluster(
|
Cluster(
|
||||||
id=f"cl_{identity}",
|
id=f"cl_{identity}",
|
||||||
|
|
|
||||||
78
app/analysis/event_log.py
Normal file
78
app/analysis/event_log.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.instrument import DEFAULT_SYMBOL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DAY_SECONDS = 86400
|
||||||
|
|
||||||
|
|
||||||
|
class EventLog:
|
||||||
|
"""Sent alerts and stream notes. Kept on the volume; the UI asks for a day."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path | None = None):
|
||||||
|
self.path = Path(path) if path else None
|
||||||
|
self._entries = self._load()
|
||||||
|
|
||||||
|
def _load(self) -> list[dict]:
|
||||||
|
if not self.path or not self.path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
return payload if isinstance(payload, list) else []
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read event log; starting empty", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
if not self.path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(self._entries, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(self.path)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not persist event log", exc_info=True)
|
||||||
|
|
||||||
|
def add(
|
||||||
|
self, kind: str, message: str, *, number: int | None = None,
|
||||||
|
at: int | None = None, symbol: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
entry = {
|
||||||
|
"kind": kind,
|
||||||
|
"message": message,
|
||||||
|
"number": number,
|
||||||
|
"at": int(at if at is not None else time.time()),
|
||||||
|
"symbol": symbol or DEFAULT_SYMBOL,
|
||||||
|
}
|
||||||
|
self._entries.append(entry)
|
||||||
|
self._save()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
before: int | None = None,
|
||||||
|
since: int | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[list[dict], bool]:
|
||||||
|
items = self._entries
|
||||||
|
if before is not None:
|
||||||
|
items = [entry for entry in items if int(entry.get("at") or 0) < before]
|
||||||
|
if since is not None:
|
||||||
|
items = [entry for entry in items if int(entry.get("at") or 0) >= since]
|
||||||
|
newest = list(reversed(items))
|
||||||
|
return newest[:limit], len(newest) > limit
|
||||||
|
|
||||||
|
def recent(self, since: int | None = None) -> tuple[list[dict], bool]:
|
||||||
|
cutoff = int(since if since is not None else time.time() - DAY_SECONDS)
|
||||||
|
events, _ = self.page(since=cutoff, limit=1000)
|
||||||
|
older = any(int(entry.get("at") or 0) < cutoff for entry in self._entries)
|
||||||
|
return events, older
|
||||||
47
app/analysis/horizontals.py
Normal file
47
app/analysis/horizontals.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.config import TIMEFRAME_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
PRIOR_DAY_SPECS = (("high", "PDH", "Prior day high"), ("low", "PDL", "Prior day low"), ("close", "PDC", "Prior day close"))
|
||||||
|
|
||||||
|
|
||||||
|
def build_prior_day_levels(daily_bars: list[Bar], current_price: float | None) -> list[Level]:
|
||||||
|
"""Prior session high, low and close.
|
||||||
|
|
||||||
|
The newest daily bar is normally still forming, so "prior day" means the
|
||||||
|
last *closed* session. Taking the last bar outright would silently switch
|
||||||
|
the levels to today's own developing range partway through the session,
|
||||||
|
which is not what anyone means by PDH.
|
||||||
|
|
||||||
|
These carry the full daily weight rather than the moving-average discount:
|
||||||
|
an actual prior high is traded structure, not a derived average.
|
||||||
|
"""
|
||||||
|
closed = [bar for bar in daily_bars if bar.closed]
|
||||||
|
if not closed:
|
||||||
|
return []
|
||||||
|
prior = closed[-1]
|
||||||
|
reference = current_price if current_price is not None else prior.c
|
||||||
|
prices = {"high": prior.h, "low": prior.l, "close": prior.c}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Level(
|
||||||
|
id=f"pd:{key}",
|
||||||
|
kind=LevelKind.HORIZONTAL,
|
||||||
|
tf=Timeframe.D1,
|
||||||
|
side=Side.SUPPORT if prices[key] <= reference else Side.RESISTANCE,
|
||||||
|
weight=TIMEFRAME_WEIGHT[Timeframe.D1],
|
||||||
|
score=1.0,
|
||||||
|
label=short,
|
||||||
|
anchor_t=prior.t,
|
||||||
|
anchor_p=prices[key],
|
||||||
|
slope=0.0,
|
||||||
|
points=None,
|
||||||
|
touches=0,
|
||||||
|
first_t=prior.t,
|
||||||
|
last_t=prior.t,
|
||||||
|
provisional=False,
|
||||||
|
hidden=False,
|
||||||
|
)
|
||||||
|
for key, short, _description in PRIOR_DAY_SPECS
|
||||||
|
]
|
||||||
|
|
@ -3,11 +3,13 @@ from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
from app.instrument import DEFAULT_SYMBOL
|
||||||
|
|
||||||
|
|
||||||
class LevelKind(str, Enum):
|
class LevelKind(str, Enum):
|
||||||
MANUAL = "manual"
|
MANUAL = "manual"
|
||||||
MA = "ma"
|
MA = "ma"
|
||||||
|
VWAP = "vwap"
|
||||||
TRENDLINE = "trendline"
|
TRENDLINE = "trendline"
|
||||||
HORIZONTAL = "horizontal"
|
HORIZONTAL = "horizontal"
|
||||||
|
|
||||||
|
|
@ -40,6 +42,21 @@ class Level:
|
||||||
line_width: int | None = None
|
line_width: int | None = None
|
||||||
number: int | None = None
|
number: int | None = None
|
||||||
cutoff_t: int | None = None
|
cutoff_t: int | None = None
|
||||||
|
# Hand-placed levels fire once and disarm themselves; everything derived
|
||||||
|
# (averages, prior-day, VWAP) is permanently armed.
|
||||||
|
armed: bool = True
|
||||||
|
# Sloped lines are evaluated across bars, not seconds (see bar_space). The
|
||||||
|
# runtime fills this in where the bar series is available; price_at() is the
|
||||||
|
# fallback for levels that are already flat or have no series to measure.
|
||||||
|
current_p: float | None = None
|
||||||
|
# Optional fixed distance for a hand-placed level. None keeps the global
|
||||||
|
# ATR-based trigger; a value alerts that many points before the level.
|
||||||
|
alert_early_points: float | None = None
|
||||||
|
# False means the attributed timeframe does not hold enough history to
|
||||||
|
# price this line safely. Such a line remains visible but cannot cluster or
|
||||||
|
# alert using the absolute-time fallback.
|
||||||
|
geometry_resolved: bool = True
|
||||||
|
symbol: str = DEFAULT_SYMBOL
|
||||||
|
|
||||||
def price_at(self, t: int) -> float:
|
def price_at(self, t: int) -> float:
|
||||||
return self.anchor_p + self.slope * (t - self.anchor_t)
|
return self.anchor_p + self.slope * (t - self.anchor_t)
|
||||||
|
|
@ -50,3 +67,20 @@ class Level:
|
||||||
value["tf"] = self.tf.value
|
value["tf"] = self.tf.value
|
||||||
value["side"] = self.side.value
|
value["side"] = self.side.value
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, Any]:
|
||||||
|
"""Compact form for embedding inside a cluster.
|
||||||
|
|
||||||
|
Clusters go out on every closed 1m bar, and a moving average carries its
|
||||||
|
whole point history — hundreds of entries reaching back years. Embedding
|
||||||
|
the full level duplicated the entire levels payload once a minute. The
|
||||||
|
client already holds the full levels and joins on id.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind.value,
|
||||||
|
"tf": self.tf.value,
|
||||||
|
"side": self.side.value,
|
||||||
|
"weight": self.weight,
|
||||||
|
"label": self.label,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from threading import RLock
|
||||||
from app.analysis.levels import Level, LevelKind, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.config import TIMEFRAME_WEIGHT
|
from app.config import TIMEFRAME_WEIGHT
|
||||||
|
from app.instrument import DEFAULT_SYMBOL
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -24,6 +25,51 @@ class ManualLine:
|
||||||
line_width: int = 2
|
line_width: int = 2
|
||||||
number: int = 0
|
number: int = 0
|
||||||
cutoff_t: int | None = None
|
cutoff_t: int | None = None
|
||||||
|
armed: bool = True
|
||||||
|
# "" means a line predating drawing kinds; `kind` derives it. Comments carry
|
||||||
|
# their text in `note`, like every other drawing carries its label.
|
||||||
|
kind: str = ""
|
||||||
|
pinned: bool = True
|
||||||
|
x: float = 0.72
|
||||||
|
y: float = 0.12
|
||||||
|
collapsed: bool = False
|
||||||
|
icon: str = ""
|
||||||
|
alert_early_points: float | None = None
|
||||||
|
# Visual multiplier for marks. 1 is the original 30px glyph; the pin stays
|
||||||
|
# on (anchor_t, anchor_p) regardless of this value.
|
||||||
|
scale: float = 1.0
|
||||||
|
symbol: str = DEFAULT_SYMBOL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def drawing_kind(self) -> str:
|
||||||
|
"""What this drawing is, for grouping and filtering.
|
||||||
|
|
||||||
|
Derived when absent so drawings saved before comments existed keep
|
||||||
|
working: a zero slope was always a typed price level, anything else a
|
||||||
|
drawn trendline.
|
||||||
|
"""
|
||||||
|
if self.kind:
|
||||||
|
return self.kind
|
||||||
|
return "level" if self.horizontal else "trendline"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_comment(self) -> bool:
|
||||||
|
return self.drawing_kind in {"comment", "symbol"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overlay(self) -> bool:
|
||||||
|
return self.drawing_kind in {"comment", "symbol", "fibonacci"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def horizontal(self) -> bool:
|
||||||
|
"""A typed price level rather than a drawn trendline."""
|
||||||
|
return self.slope == 0.0
|
||||||
|
|
||||||
|
def default_label(self) -> str:
|
||||||
|
if self.horizontal:
|
||||||
|
return f"@ {self.anchor_p:.2f}"
|
||||||
|
direction = "up" if self.side is Side.SUPPORT else "down"
|
||||||
|
return f"{direction}{self.tf.value}"
|
||||||
|
|
||||||
def to_level(self) -> Level:
|
def to_level(self) -> Level:
|
||||||
return Level(
|
return Level(
|
||||||
|
|
@ -33,7 +79,7 @@ class ManualLine:
|
||||||
side=self.side,
|
side=self.side,
|
||||||
weight=TIMEFRAME_WEIGHT[self.tf],
|
weight=TIMEFRAME_WEIGHT[self.tf],
|
||||||
score=1.0,
|
score=1.0,
|
||||||
label=self.note or f"{self.tf.value} {self.side.value}",
|
label=self.note or self.default_label(),
|
||||||
anchor_t=self.anchor_t,
|
anchor_t=self.anchor_t,
|
||||||
anchor_p=self.anchor_p,
|
anchor_p=self.anchor_p,
|
||||||
slope=self.slope,
|
slope=self.slope,
|
||||||
|
|
@ -47,6 +93,9 @@ class ManualLine:
|
||||||
line_width=self.line_width,
|
line_width=self.line_width,
|
||||||
number=self.number,
|
number=self.number,
|
||||||
cutoff_t=self.cutoff_t,
|
cutoff_t=self.cutoff_t,
|
||||||
|
armed=self.armed,
|
||||||
|
alert_early_points=self.alert_early_points,
|
||||||
|
symbol=self.symbol,
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
|
|
@ -72,6 +121,19 @@ class ManualLine:
|
||||||
line_width=int(value.get("line_width", 2)),
|
line_width=int(value.get("line_width", 2)),
|
||||||
number=int(value.get("number", 0)),
|
number=int(value.get("number", 0)),
|
||||||
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
|
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
|
||||||
|
armed=bool(value.get("armed", True)),
|
||||||
|
kind=str(value.get("kind", "")),
|
||||||
|
pinned=bool(value.get("pinned", True)),
|
||||||
|
x=float(value.get("x", 0.72)),
|
||||||
|
y=float(value.get("y", 0.12)),
|
||||||
|
collapsed=bool(value.get("collapsed", False)),
|
||||||
|
icon=str(value.get("icon", "")),
|
||||||
|
alert_early_points=(
|
||||||
|
float(value["alert_early_points"])
|
||||||
|
if value.get("alert_early_points") is not None else None
|
||||||
|
),
|
||||||
|
scale=float(value.get("scale", 1.0) or 1.0),
|
||||||
|
symbol=str(value.get("symbol") or DEFAULT_SYMBOL),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -133,4 +195,15 @@ class ManualLineStore:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def levels(self) -> list[Level]:
|
def levels(self) -> list[Level]:
|
||||||
return [line.to_level() for line in self.lines.values()]
|
"""Only the drawings that are levels.
|
||||||
|
|
||||||
|
Comments are stored alongside lines so they share numbering, filtering
|
||||||
|
and deletion, but they are annotations. Letting one through here would
|
||||||
|
put it in a confluence cluster and fire a push notification about a
|
||||||
|
piece of text.
|
||||||
|
"""
|
||||||
|
return [line.to_level() for line in self.lines.values() if not line.is_overlay]
|
||||||
|
|
||||||
|
def drawings(self) -> list[ManualLine]:
|
||||||
|
"""Everything drawn, comments included, newest number last."""
|
||||||
|
return sorted(self.lines.values(), key=lambda line: line.number)
|
||||||
|
|
|
||||||
57
app/analysis/user_prefs.py
Normal file
57
app/analysis/user_prefs.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import RLock
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SHARED_USER = "shared"
|
||||||
|
MA_ALERT_PERIODS = (10, 20, 50, 100, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPrefStore:
|
||||||
|
"""Per-user JSON bags, one file.
|
||||||
|
|
||||||
|
Namespaces are versioned objects. ``ma_alerts`` is ``{"1d": [200]}``.
|
||||||
|
``user_id`` is ``shared`` until OIDC supplies a subject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self._lock = RLock()
|
||||||
|
self._by_user: dict[str, dict[str, Any]] = self._load()
|
||||||
|
|
||||||
|
def _load(self) -> dict[str, dict[str, Any]]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read user prefs; starting empty", exc_info=True)
|
||||||
|
return {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
loaded: dict[str, dict[str, Any]] = {}
|
||||||
|
for user_id, namespaces in payload.items():
|
||||||
|
if isinstance(namespaces, dict):
|
||||||
|
loaded[str(user_id)] = namespaces
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(self._by_user, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(self.path)
|
||||||
|
|
||||||
|
def get(self, namespace: str, default: Any = None, user_id: str = SHARED_USER) -> Any:
|
||||||
|
return self._by_user.get(user_id, {}).get(namespace, default)
|
||||||
|
|
||||||
|
def put(self, namespace: str, value: Any, user_id: str = SHARED_USER) -> Any:
|
||||||
|
with self._lock:
|
||||||
|
self._by_user.setdefault(user_id, {})[namespace] = value
|
||||||
|
self._save()
|
||||||
|
return value
|
||||||
58
app/analysis/vwap.py
Normal file
58
app/analysis/vwap.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.bars.session import bucket_start
|
||||||
|
from app.config import MA_WEIGHT_FACTOR, TIMEFRAME_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def build_vwap_level(minute_bars: list[Bar]) -> list[Level]:
|
||||||
|
"""Session VWAP, anchored to the CME session open.
|
||||||
|
|
||||||
|
Institutional execution is benchmarked against VWAP, which is what earns it
|
||||||
|
a place here: it is watched by people whose orders are large enough to move
|
||||||
|
price, not merely by chartists.
|
||||||
|
|
||||||
|
Anchoring uses the same 18:00 ET session boundary as the daily bars, so
|
||||||
|
VWAP resets when the trading day does rather than at UTC midnight.
|
||||||
|
"""
|
||||||
|
if not minute_bars:
|
||||||
|
return []
|
||||||
|
session_open = bucket_start(minute_bars[-1].t, Timeframe.D1)
|
||||||
|
|
||||||
|
cumulative_pv = 0.0
|
||||||
|
cumulative_volume = 0
|
||||||
|
points: list[tuple[int, float]] = []
|
||||||
|
for bar in minute_bars:
|
||||||
|
if bar.t < session_open:
|
||||||
|
continue
|
||||||
|
typical = (bar.h + bar.l + bar.c) / 3
|
||||||
|
cumulative_pv += typical * bar.v
|
||||||
|
cumulative_volume += bar.v
|
||||||
|
# Yahoo reports zero-volume minutes in thin overnight trade; they carry
|
||||||
|
# no VWAP information and must not divide by zero.
|
||||||
|
if cumulative_volume > 0:
|
||||||
|
points.append((bar.t, cumulative_pv / cumulative_volume))
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
|
||||||
|
current = points[-1][1]
|
||||||
|
last_bar = minute_bars[-1]
|
||||||
|
return [
|
||||||
|
Level(
|
||||||
|
id="vwap:session",
|
||||||
|
kind=LevelKind.VWAP,
|
||||||
|
tf=Timeframe.D1,
|
||||||
|
side=Side.SUPPORT if current <= last_bar.c else Side.RESISTANCE,
|
||||||
|
weight=TIMEFRAME_WEIGHT[Timeframe.D1] * MA_WEIGHT_FACTOR,
|
||||||
|
score=1.0,
|
||||||
|
label="Session VWAP",
|
||||||
|
anchor_t=points[-1][0],
|
||||||
|
anchor_p=current,
|
||||||
|
slope=0.0,
|
||||||
|
points=points,
|
||||||
|
touches=0,
|
||||||
|
first_t=points[0][0],
|
||||||
|
last_t=points[-1][0],
|
||||||
|
provisional=not last_bar.closed,
|
||||||
|
hidden=False,
|
||||||
|
)
|
||||||
|
]
|
||||||
70
app/api/captures.py
Normal file
70
app/api/captures.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CAPTURE_DIR = Path(tempfile.gettempdir()) / "chart-captures"
|
||||||
|
CAPTURE_TTL_SECONDS = 24 * 60 * 60
|
||||||
|
CAPTURE_LIMIT = 50
|
||||||
|
CAPTURE_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
CAPTURE_ID = re.compile(r"^c-[A-Za-z0-9_-]{12}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(capture_id: str) -> None:
|
||||||
|
for suffix in (".png", ".json"):
|
||||||
|
(CAPTURE_DIR / f"{capture_id}{suffix}").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_captures(now: float | None = None) -> None:
|
||||||
|
current = time.time() if now is None else now
|
||||||
|
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
|
||||||
|
for image in images:
|
||||||
|
if current - image.stat().st_mtime > CAPTURE_TTL_SECONDS:
|
||||||
|
_remove(image.stem)
|
||||||
|
images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime)
|
||||||
|
for image in images[: max(0, len(images) - CAPTURE_LIMIT + 1)]:
|
||||||
|
_remove(image.stem)
|
||||||
|
|
||||||
|
|
||||||
|
def save_capture(image: bytes, metadata: dict) -> tuple[str, dict]:
|
||||||
|
if not image.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
raise ValueError("Capture is not a PNG")
|
||||||
|
if len(image) > CAPTURE_MAX_BYTES:
|
||||||
|
raise ValueError("Capture exceeds 10 MB")
|
||||||
|
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
cleanup_captures()
|
||||||
|
capture_id = f"c-{secrets.token_urlsafe(9)}"
|
||||||
|
details = {
|
||||||
|
**metadata,
|
||||||
|
"id": capture_id,
|
||||||
|
"received_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
||||||
|
"commit": os.environ.get("SOURCE_COMMIT", "dev"),
|
||||||
|
"bytes": len(image),
|
||||||
|
}
|
||||||
|
image_path = CAPTURE_DIR / f"{capture_id}.png"
|
||||||
|
metadata_path = CAPTURE_DIR / f"{capture_id}.json"
|
||||||
|
image_path.write_bytes(image)
|
||||||
|
metadata_path.write_text(json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
return capture_id, details
|
||||||
|
|
||||||
|
|
||||||
|
def capture_path(capture_id: str, suffix: str) -> Path | None:
|
||||||
|
if not CAPTURE_ID.fullmatch(capture_id):
|
||||||
|
return None
|
||||||
|
path = CAPTURE_DIR / f"{capture_id}{suffix}"
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_capture(capture_id: str) -> bool:
|
||||||
|
if not CAPTURE_ID.fullmatch(capture_id):
|
||||||
|
return False
|
||||||
|
image = CAPTURE_DIR / f"{capture_id}.png"
|
||||||
|
metadata = CAPTURE_DIR / f"{capture_id}.json"
|
||||||
|
if not image.is_file() and not metadata.is_file():
|
||||||
|
return False
|
||||||
|
_remove(capture_id)
|
||||||
|
return True
|
||||||
|
|
@ -1,11 +1,28 @@
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
import jwt
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import HTTPException, Request, status
|
||||||
|
from jwt import InvalidTokenError
|
||||||
|
|
||||||
|
SESSION_COOKIE = "chart-session"
|
||||||
|
SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
||||||
|
|
||||||
|
|
||||||
|
def configured_settings(app):
|
||||||
|
runtime = getattr(app.state, "runtime", None)
|
||||||
|
return runtime.settings if runtime else None
|
||||||
|
|
||||||
|
|
||||||
def configured_token(app) -> str:
|
def configured_token(app) -> str:
|
||||||
runtime = getattr(app.state, "runtime", None)
|
settings = configured_settings(app)
|
||||||
return runtime.settings.chart_auth_token if runtime else ""
|
return settings.chart_auth_token if settings else ""
|
||||||
|
|
||||||
|
|
||||||
|
def configured_password(app) -> str:
|
||||||
|
settings = configured_settings(app)
|
||||||
|
return settings.chart_password if settings else ""
|
||||||
|
|
||||||
|
|
||||||
def token_matches(app, presented: str) -> bool:
|
def token_matches(app, presented: str) -> bool:
|
||||||
|
|
@ -16,15 +33,68 @@ def token_matches(app, presented: str) -> bool:
|
||||||
"""
|
"""
|
||||||
want = configured_token(app)
|
want = configured_token(app)
|
||||||
if not want:
|
if not want:
|
||||||
return True
|
return not configured_password(app)
|
||||||
return secrets.compare_digest(presented or "", want)
|
return secrets.compare_digest((presented or "").encode(), want.encode())
|
||||||
|
|
||||||
|
|
||||||
def require_token(request: Request) -> None:
|
def password_matches(app, presented: str) -> bool:
|
||||||
presented = request.headers.get("x-chart-token") or request.query_params.get("token", "")
|
# Falling back to the token avoids locking out a deployment while
|
||||||
if not token_matches(request.app, presented):
|
# CHART_PASSWORD is being added. Once set, only the friendly password logs
|
||||||
raise HTTPException(
|
# a browser in; the opaque token remains valid for direct API clients.
|
||||||
status.HTTP_401_UNAUTHORIZED,
|
want = configured_password(app) or configured_token(app)
|
||||||
"Missing or invalid chart token",
|
return bool(want) and secrets.compare_digest(
|
||||||
headers={"WWW-Authenticate": "X-Chart-Token"},
|
(presented or "").encode(), want.encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def session_secret(app) -> bytes:
|
||||||
|
configured = configured_token(app) or configured_password(app)
|
||||||
|
return hashlib.sha256(configured.encode()).digest() if configured else b""
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(app, now: int | None = None) -> str:
|
||||||
|
secret = session_secret(app)
|
||||||
|
if not secret:
|
||||||
|
return ""
|
||||||
|
issued = now if now is not None else int(time.time())
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": "shared", "iat": issued, "exp": issued + SESSION_MAX_AGE},
|
||||||
|
secret,
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def session_principal(app, presented: str) -> str | None:
|
||||||
|
secret = session_secret(app)
|
||||||
|
if not secret or not presented:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
presented,
|
||||||
|
secret,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"require": ["sub", "iat", "exp"]},
|
||||||
)
|
)
|
||||||
|
except InvalidTokenError:
|
||||||
|
return None
|
||||||
|
principal = payload.get("sub")
|
||||||
|
return principal if isinstance(principal, str) and principal else None
|
||||||
|
|
||||||
|
|
||||||
|
def session_matches(app, presented: str) -> bool:
|
||||||
|
return session_principal(app, presented) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def require_token(request: Request) -> str:
|
||||||
|
presented = request.headers.get("x-chart-token") or request.query_params.get("token", "")
|
||||||
|
session = request.cookies.get(SESSION_COOKIE, "")
|
||||||
|
if token_matches(request.app, presented):
|
||||||
|
return "api-token" if configured_token(request.app) else "anonymous"
|
||||||
|
principal = session_principal(request.app, session)
|
||||||
|
if principal is not None:
|
||||||
|
return principal
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
"Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Session, X-Chart-Token"},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,36 @@
|
||||||
"""Endpoints that stay reachable without a token.
|
"""Endpoints that stay reachable without a token.
|
||||||
|
|
||||||
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, so
|
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, and
|
||||||
requiring the token here would mean carrying it around just to answer "is my
|
the browser needs /api/login before it has a session, so these routes stay
|
||||||
commit live yet". Neither endpoint exposes anything about the market data or
|
outside the protected API router.
|
||||||
the configuration.
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.deps import (
|
||||||
|
SESSION_COOKIE,
|
||||||
|
SESSION_MAX_AGE,
|
||||||
|
configured_token,
|
||||||
|
create_session,
|
||||||
|
password_matches,
|
||||||
|
token_matches,
|
||||||
|
)
|
||||||
|
from app.api.captures import capture_path, delete_capture
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
# Coolify injects the deployed commit; absent when running locally.
|
# Coolify injects the deployed commit; absent when running locally.
|
||||||
SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
|
SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
|
||||||
|
STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
|
|
@ -22,4 +40,46 @@ def health():
|
||||||
|
|
||||||
@router.get("/version")
|
@router.get("/version")
|
||||||
def version():
|
def version():
|
||||||
return {"commit": SOURCE_COMMIT}
|
return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def login(credentials: LoginRequest, request: Request, response: Response):
|
||||||
|
presented_token = request.headers.get("x-chart-token", "")
|
||||||
|
token_login = bool(configured_token(request.app)) and token_matches(
|
||||||
|
request.app, presented_token
|
||||||
|
)
|
||||||
|
if not token_login and not password_matches(request.app, credentials.password):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect password")
|
||||||
|
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0]
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
create_session(request.app),
|
||||||
|
max_age=SESSION_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
secure=request.url.scheme == "https" or forwarded_proto == "https",
|
||||||
|
samesite="strict",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def logout(response: Response):
|
||||||
|
response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="strict")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug/captures/{capture_id}")
|
||||||
|
def get_debug_capture(capture_id: str):
|
||||||
|
"""A short-lived diagnostic screenshot shared by its unguessable id."""
|
||||||
|
path = capture_path(capture_id, ".png")
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(404, "Capture not found")
|
||||||
|
return FileResponse(path, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/debug/captures/{capture_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_debug_capture(capture_id: str):
|
||||||
|
"""Erase a public diagnostic screenshot after inspection."""
|
||||||
|
if not delete_capture(capture_id):
|
||||||
|
raise HTTPException(404, "Capture not found")
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,74 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel, Field
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import DEFAULT_MAX_BARS_PER_TF
|
||||||
from app.analysis.levels import Side
|
from app.analysis.levels import Side
|
||||||
from app.analysis.manual_lines import ManualLine
|
from app.analysis.manual_lines import ManualLine
|
||||||
|
from app.analysis.user_prefs import MA_ALERT_PERIODS
|
||||||
from app.api.deps import require_token
|
from app.api.deps import require_token
|
||||||
|
from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture
|
||||||
|
from app.market.es_options import nearby_expirations
|
||||||
|
from app.market.schwab_quotes import run_search
|
||||||
|
|
||||||
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
|
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
|
||||||
# /version live in app.api.meta and stay open on purpose.
|
# /version live in app.api.meta and stay open on purpose.
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
|
||||||
|
|
||||||
|
|
||||||
|
def positioned_level(runtime, line_id: str):
|
||||||
|
return next(level for level in runtime.levels if level.id == line_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/debug/captures", status_code=201)
|
||||||
|
async def create_debug_capture(request: Request):
|
||||||
|
try:
|
||||||
|
content_length = int(request.headers.get("content-length", "0") or 0)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "Invalid Content-Length") from None
|
||||||
|
if content_length > CAPTURE_MAX_BYTES:
|
||||||
|
raise HTTPException(413, "Capture exceeds 10 MB")
|
||||||
|
if request.headers.get("content-type", "").split(";", 1)[0] != "image/png":
|
||||||
|
raise HTTPException(415, "Diagnostic capture must be image/png")
|
||||||
|
try:
|
||||||
|
encoded = request.headers.get("x-capture-metadata", "")
|
||||||
|
metadata = json.loads(base64.b64decode(encoded, validate=True)) if encoded else {}
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
raise HTTPException(400, "Invalid capture metadata") from None
|
||||||
|
image = await request.body()
|
||||||
|
try:
|
||||||
|
capture_id, details = save_capture(image, metadata)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
logger.warning(
|
||||||
|
"CAPTUREDBG id=%s tf=%s viewport=%sx%s",
|
||||||
|
capture_id,
|
||||||
|
details.get("timeframe"),
|
||||||
|
details.get("viewport_width"),
|
||||||
|
details.get("viewport_height"),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": capture_id,
|
||||||
|
"url": f"/api/debug/captures/{capture_id}",
|
||||||
|
"metadata_url": f"/api/debug/captures/{capture_id}/meta",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class LineCreate(BaseModel):
|
class LineCreate(BaseModel):
|
||||||
tf: Timeframe
|
tf: Timeframe
|
||||||
side: Side
|
side: Side
|
||||||
|
|
@ -24,7 +79,60 @@ class LineCreate(BaseModel):
|
||||||
note: str = ""
|
note: str = ""
|
||||||
hidden: bool = False
|
hidden: bool = False
|
||||||
color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$")
|
color: str = Field("#65b7cf", pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
line_width: int = Field(2, ge=1, le=4)
|
line_width: int = Field(2, ge=1, le=9)
|
||||||
|
cutoff_t: int | None = None
|
||||||
|
armed: bool = True
|
||||||
|
kind: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PriceAlertCreate(BaseModel):
|
||||||
|
"""A horizontal level typed in rather than drawn.
|
||||||
|
|
||||||
|
Structurally just a manual line with zero slope, so it inherits persistence,
|
||||||
|
editing, clustering and — importantly — the rule that a hand-placed level
|
||||||
|
alerts regardless of confluence score.
|
||||||
|
"""
|
||||||
|
|
||||||
|
price: float = Field(gt=0)
|
||||||
|
note: str = ""
|
||||||
|
tf: Timeframe = Timeframe.D1
|
||||||
|
color: str = Field("#e0a34a", pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
line_width: int = Field(2, ge=1, le=9)
|
||||||
|
alert_early_points: float | None = Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LineRestore(BaseModel):
|
||||||
|
"""Re-insert a deleted drawing with the same id and number.
|
||||||
|
|
||||||
|
Create always mints a new id. Undo of delete needs the original identity
|
||||||
|
back, including drawing number, or clusters and the sidebar # label drift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tf: Timeframe
|
||||||
|
side: Side
|
||||||
|
anchor_t: int
|
||||||
|
anchor_p: float
|
||||||
|
slope: float
|
||||||
|
last_t: int
|
||||||
|
created_at: int | None = None
|
||||||
|
note: str = ""
|
||||||
|
label: str = ""
|
||||||
|
hidden: bool = False
|
||||||
|
color: str | None = None
|
||||||
|
line_width: int | None = None
|
||||||
|
number: int = 0
|
||||||
|
cutoff_t: int | None = None
|
||||||
|
armed: bool = True
|
||||||
|
kind: str = ""
|
||||||
|
pinned: bool = True
|
||||||
|
x: float = Field(0.72, ge=0.0, le=1.0)
|
||||||
|
y: float = Field(0.12, ge=0.0, le=1.0)
|
||||||
|
collapsed: bool = False
|
||||||
|
icon: str = ""
|
||||||
|
alert_early_points: float | None = None
|
||||||
|
scale: float = Field(1.0, ge=0.5, le=3)
|
||||||
|
symbol: str = ""
|
||||||
|
|
||||||
|
|
||||||
class LinePatch(BaseModel):
|
class LinePatch(BaseModel):
|
||||||
|
|
@ -32,12 +140,19 @@ class LinePatch(BaseModel):
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
hidden: bool | None = None
|
hidden: bool | None = None
|
||||||
color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
line_width: int | None = Field(None, ge=1, le=4)
|
line_width: int | None = Field(None, ge=1, le=9)
|
||||||
anchor_t: int | None = None
|
anchor_t: int | None = None
|
||||||
anchor_p: float | None = None
|
anchor_p: float | None = None
|
||||||
slope: float | None = None
|
slope: float | None = None
|
||||||
last_t: int | None = None
|
last_t: int | None = None
|
||||||
cutoff_t: int | None = None
|
cutoff_t: int | None = None
|
||||||
|
armed: bool | None = None
|
||||||
|
pinned: bool | None = None
|
||||||
|
x: float | None = Field(None, ge=0.0, le=1.0)
|
||||||
|
y: float | None = Field(None, ge=0.0, le=1.0)
|
||||||
|
collapsed: bool | None = None
|
||||||
|
alert_early_points: float | None = Field(None, ge=0)
|
||||||
|
scale: float | None = Field(None, ge=0.5, le=3)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|
@ -50,12 +165,45 @@ def status(request: Request):
|
||||||
"symbol": runtime.stream.symbol,
|
"symbol": runtime.stream.symbol,
|
||||||
"last_bar_t": runtime.stream.last_bar_t,
|
"last_bar_t": runtime.stream.last_bar_t,
|
||||||
"bars_held": runtime.store.counts(),
|
"bars_held": runtime.store.counts(),
|
||||||
|
"trendline_geometry": (
|
||||||
|
"source_tf" if runtime.settings.trendline_source_geometry else "legacy"
|
||||||
|
),
|
||||||
"warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe},
|
"warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe},
|
||||||
|
# How late the event loop is running. Rising numbers mean something is
|
||||||
|
# blocking it — see docs/async_refactor.md.
|
||||||
|
"loop_lag_ms": {
|
||||||
|
"recent": round(runtime.loop_lag_recent * 1000, 1),
|
||||||
|
"worst": round(runtime.loop_lag_worst * 1000, 1),
|
||||||
|
},
|
||||||
|
"needs_login": runtime.needs_login(),
|
||||||
|
"instrument": runtime.settings.profile.payload(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
def events(
|
||||||
|
request: Request,
|
||||||
|
before: int | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
):
|
||||||
|
items, more = request.app.state.runtime.events.page(before=before, limit=limit)
|
||||||
|
return {"events": items, "more": more}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schwab/login")
|
||||||
|
def schwab_login(request: Request):
|
||||||
|
settings = request.app.state.runtime.settings
|
||||||
|
if not settings.schwab_api_key or not settings.schwab_app_secret:
|
||||||
|
raise HTTPException(503, "Live source is not configured")
|
||||||
|
return RedirectResponse(request.app.state.runtime.start_schwab_login())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bars")
|
@router.get("/bars")
|
||||||
def bars(request: Request, tf: str = "1m", limit: int = Query(500, ge=1, le=5000)):
|
def bars(
|
||||||
|
request: Request,
|
||||||
|
tf: str = "1m",
|
||||||
|
limit: int | None = Query(None, ge=1, le=DEFAULT_MAX_BARS_PER_TF),
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
timeframe = Timeframe(tf)
|
timeframe = Timeframe(tf)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
@ -85,33 +233,248 @@ def confluence(request: Request):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prefs/confluence-alerts")
|
||||||
|
def get_confluence_alerts(request: Request):
|
||||||
|
return {"enabled": request.app.state.runtime.confluence_alerts_enabled()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/prefs/confluence-alerts")
|
||||||
|
def put_confluence_alerts(request: Request, payload: dict):
|
||||||
|
if "enabled" not in payload or not isinstance(payload["enabled"], bool):
|
||||||
|
raise HTTPException(400, "enabled must be a boolean")
|
||||||
|
return request.app.state.runtime.set_confluence_alerts(payload["enabled"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prefs/ma-alerts")
|
||||||
|
def get_ma_alerts(request: Request):
|
||||||
|
return request.app.state.runtime.ma_alerts()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/prefs/ma-alerts")
|
||||||
|
def put_ma_alerts(request: Request, payload: dict):
|
||||||
|
periods = payload.get("1d", [])
|
||||||
|
if not isinstance(periods, list) or not all(period in MA_ALERT_PERIODS for period in periods):
|
||||||
|
raise HTTPException(400, "Unknown MA period")
|
||||||
|
return request.app.state.runtime.set_ma_alerts({
|
||||||
|
"1d": sorted({int(period) for period in periods}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lines", status_code=201)
|
@router.post("/lines", status_code=201)
|
||||||
def create_line(request: Request, payload: LineCreate):
|
def create_line(request: Request, payload: LineCreate):
|
||||||
if payload.end_t == payload.anchor_t:
|
if payload.end_t == payload.anchor_t and payload.kind != "fibonacci":
|
||||||
raise HTTPException(400, "Line endpoints must have different times")
|
raise HTTPException(400, "Line endpoints must have different times")
|
||||||
|
last_t = payload.end_t if payload.end_t != payload.anchor_t else payload.anchor_t + 1
|
||||||
line = ManualLine(
|
line = ManualLine(
|
||||||
id=f"ml_{uuid.uuid4().hex}",
|
id=f"ml_{uuid.uuid4().hex}",
|
||||||
tf=payload.tf,
|
tf=payload.tf,
|
||||||
side=payload.side,
|
side=payload.side,
|
||||||
anchor_t=payload.anchor_t,
|
anchor_t=payload.anchor_t,
|
||||||
anchor_p=payload.anchor_p,
|
anchor_p=payload.anchor_p,
|
||||||
slope=(payload.end_p - payload.anchor_p) / (payload.end_t - payload.anchor_t),
|
slope=(payload.end_p - payload.anchor_p) / (last_t - payload.anchor_t),
|
||||||
last_t=payload.end_t,
|
last_t=last_t,
|
||||||
created_at=int(time.time()),
|
created_at=int(time.time()),
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
hidden=payload.hidden,
|
hidden=payload.hidden,
|
||||||
color=payload.color,
|
color=payload.color,
|
||||||
line_width=payload.line_width,
|
line_width=payload.line_width,
|
||||||
|
cutoff_t=payload.cutoff_t,
|
||||||
|
armed=payload.armed,
|
||||||
|
kind=payload.kind,
|
||||||
|
symbol=request.app.state.runtime.settings.profile.schwab_symbol,
|
||||||
)
|
)
|
||||||
runtime = request.app.state.runtime
|
runtime = request.app.state.runtime
|
||||||
line = runtime.manual_lines.add(line)
|
line = runtime.manual_lines.add(line)
|
||||||
runtime.rebuild_levels()
|
runtime.rebuild_levels()
|
||||||
return line.to_level().to_dict()
|
if line.is_overlay:
|
||||||
|
return {**line.to_dict(), "kind": line.drawing_kind}
|
||||||
|
return positioned_level(runtime, line.id).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lines/restore", status_code=201)
|
||||||
|
def restore_line(request: Request, payload: LineRestore):
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
if payload.id in runtime.manual_lines.lines:
|
||||||
|
raise HTTPException(409, "Line already exists")
|
||||||
|
kind = "" if payload.kind in {"", "manual", "trendline", "level"} else payload.kind
|
||||||
|
color = payload.color if payload.color and len(payload.color) == 7 and payload.color.startswith("#") else "#65b7cf"
|
||||||
|
line = ManualLine(
|
||||||
|
id=payload.id,
|
||||||
|
tf=payload.tf,
|
||||||
|
side=payload.side,
|
||||||
|
anchor_t=payload.anchor_t,
|
||||||
|
anchor_p=payload.anchor_p,
|
||||||
|
slope=payload.slope,
|
||||||
|
last_t=payload.last_t,
|
||||||
|
created_at=payload.created_at or int(time.time()),
|
||||||
|
note=payload.note or payload.label,
|
||||||
|
hidden=payload.hidden,
|
||||||
|
color=color,
|
||||||
|
line_width=payload.line_width or 2,
|
||||||
|
number=payload.number,
|
||||||
|
cutoff_t=payload.cutoff_t,
|
||||||
|
armed=payload.armed,
|
||||||
|
kind=kind,
|
||||||
|
pinned=payload.pinned,
|
||||||
|
x=payload.x,
|
||||||
|
y=payload.y,
|
||||||
|
collapsed=payload.collapsed,
|
||||||
|
icon=payload.icon,
|
||||||
|
alert_early_points=payload.alert_early_points,
|
||||||
|
scale=payload.scale,
|
||||||
|
symbol=payload.symbol or runtime.settings.profile.schwab_symbol,
|
||||||
|
)
|
||||||
|
line = runtime.manual_lines.add(line)
|
||||||
|
runtime.rebuild_levels()
|
||||||
|
if line.is_overlay:
|
||||||
|
return {**line.to_dict(), "kind": line.drawing_kind}
|
||||||
|
return positioned_level(runtime, line.id).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lines/price", status_code=201)
|
||||||
|
def create_price_alert(request: Request, payload: PriceAlertCreate):
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
now = int(time.time())
|
||||||
|
# Side is only used for the label; clustering derives it positionally.
|
||||||
|
reference = runtime.price if runtime.price is not None else payload.price
|
||||||
|
line = ManualLine(
|
||||||
|
id=f"ml_{uuid.uuid4().hex}",
|
||||||
|
tf=payload.tf,
|
||||||
|
side=Side.RESISTANCE if payload.price >= reference else Side.SUPPORT,
|
||||||
|
anchor_t=now,
|
||||||
|
anchor_p=payload.price,
|
||||||
|
slope=0.0,
|
||||||
|
# A horizontal level has no natural end. The span only matters to the
|
||||||
|
# fallback geometry; price_at() is constant either way.
|
||||||
|
last_t=now + 3600,
|
||||||
|
created_at=now,
|
||||||
|
note=payload.note,
|
||||||
|
color=payload.color,
|
||||||
|
line_width=payload.line_width,
|
||||||
|
alert_early_points=payload.alert_early_points,
|
||||||
|
symbol=runtime.settings.profile.schwab_symbol,
|
||||||
|
)
|
||||||
|
line = runtime.manual_lines.add(line)
|
||||||
|
runtime.rebuild_levels()
|
||||||
|
return positioned_level(runtime, line.id).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreate(BaseModel):
|
||||||
|
text: str = Field(min_length=1, max_length=2000)
|
||||||
|
# Pinned to a moment on the chart, or floating and always on screen.
|
||||||
|
pinned: bool = True
|
||||||
|
anchor_t: int | None = None
|
||||||
|
anchor_p: float | None = None
|
||||||
|
x: float = Field(0.72, ge=0.0, le=1.0)
|
||||||
|
y: float = Field(0.12, ge=0.0, le=1.0)
|
||||||
|
color: str = Field("#c8992f", pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
|
tf: Timeframe = Timeframe.M1
|
||||||
|
icon: Literal[
|
||||||
|
"arrow-up", "arrow-down", "face-smile", "hand-point-right",
|
||||||
|
"skull", "face-laugh-squint", "champagne-glasses",
|
||||||
|
"arrow-left", "arrow-right", "hand", "right-to-bracket", "play",
|
||||||
|
] | None = None
|
||||||
|
scale: float = Field(1.0, ge=0.5, le=3)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/comments", status_code=201)
|
||||||
|
def create_comment(request: Request, payload: CommentCreate):
|
||||||
|
"""A note on the chart. Stored with the lines so it shares their numbering,
|
||||||
|
filtering and deletion, but it is never a level — see ManualLineStore.levels.
|
||||||
|
"""
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
now = int(time.time())
|
||||||
|
anchored = payload.anchor_t if payload.anchor_t is not None else now
|
||||||
|
line = ManualLine(
|
||||||
|
id=f"ml_{uuid.uuid4().hex}",
|
||||||
|
tf=payload.tf,
|
||||||
|
# Side is meaningless for a comment; clustering never sees it.
|
||||||
|
side=Side.SUPPORT,
|
||||||
|
anchor_t=anchored,
|
||||||
|
anchor_p=payload.anchor_p if payload.anchor_p is not None else (runtime.price or 0.0),
|
||||||
|
slope=0.0,
|
||||||
|
last_t=anchored,
|
||||||
|
created_at=now,
|
||||||
|
note=payload.text,
|
||||||
|
color=payload.color,
|
||||||
|
kind="symbol" if payload.icon else "comment",
|
||||||
|
icon=payload.icon or "",
|
||||||
|
scale=payload.scale,
|
||||||
|
pinned=payload.pinned,
|
||||||
|
x=payload.x,
|
||||||
|
y=payload.y,
|
||||||
|
# A comment must never alert, whatever else changes around it.
|
||||||
|
armed=False,
|
||||||
|
symbol=runtime.settings.profile.schwab_symbol,
|
||||||
|
)
|
||||||
|
line = runtime.manual_lines.add(line)
|
||||||
|
return line.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
class SnapReport(BaseModel):
|
||||||
|
"""What the browser computed for one snap, for diagnosing chart geometry."""
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
cursor_t: int | None = None
|
||||||
|
cursor_p: float | None = None
|
||||||
|
cursor_x: float | None = None
|
||||||
|
snapped_t: int | None = None
|
||||||
|
snapped_p: float | None = None
|
||||||
|
bars_held: int | None = None
|
||||||
|
first_bar_t: int | None = None
|
||||||
|
last_bar_t: int | None = None
|
||||||
|
tf: str | None = None
|
||||||
|
chart_w: float | None = None
|
||||||
|
chart_h: float | None = None
|
||||||
|
cursor_y: float | None = None
|
||||||
|
# Where the indicator actually landed versus where the price says it should
|
||||||
|
# — the only way to tell a wrong answer from a correctly-computed one drawn
|
||||||
|
# in the wrong place.
|
||||||
|
dot_y: float | None = None
|
||||||
|
expected_y: float | None = None
|
||||||
|
bar_low_y: float | None = None
|
||||||
|
bar_high_y: float | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/debug/snap", status_code=204)
|
||||||
|
def debug_snap(payload: SnapReport):
|
||||||
|
"""Record one snap sample from a browser running diagnostic mode.
|
||||||
|
|
||||||
|
Chart geometry bugs live in the client, and the browser is usually on a
|
||||||
|
different machine from whoever is debugging it — so its own numbers cannot
|
||||||
|
be read any other way. Off unless the page is opened with ?diag=1; see
|
||||||
|
AGENTS.md. Logged at warning level so it appears without reconfiguring
|
||||||
|
uvicorn's log levels.
|
||||||
|
"""
|
||||||
|
logger.warning("SNAPDBG %s", payload.model_dump())
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug/captures/{capture_id}/meta")
|
||||||
|
def get_debug_capture_metadata(capture_id: str):
|
||||||
|
path = capture_path(capture_id, ".json")
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(404, "Capture not found")
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/drawings")
|
||||||
|
def drawings(request: Request):
|
||||||
|
"""Every drawing, comments included, for the sidebar list."""
|
||||||
|
store = request.app.state.runtime.manual_lines
|
||||||
|
return {"drawings": [
|
||||||
|
{**line.to_dict(), "kind": line.drawing_kind} for line in store.drawings()
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/lines/{line_id}")
|
@router.patch("/lines/{line_id}")
|
||||||
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
def patch_line(request: Request, line_id: str, payload: LinePatch):
|
||||||
changes = payload.model_dump(exclude_none=True)
|
changes = payload.model_dump(exclude_none=True)
|
||||||
|
# None deliberately clears a per-level override and restores ATR behavior.
|
||||||
|
if "alert_early_points" in payload.model_fields_set:
|
||||||
|
changes["alert_early_points"] = payload.alert_early_points
|
||||||
|
if "cutoff_t" in payload.model_fields_set:
|
||||||
|
changes["cutoff_t"] = payload.cutoff_t
|
||||||
if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes:
|
if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes:
|
||||||
raise HTTPException(400, "Line endpoints must have different times")
|
raise HTTPException(400, "Line endpoints must have different times")
|
||||||
try:
|
try:
|
||||||
|
|
@ -119,7 +482,11 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(404, "Line not found") from exc
|
raise HTTPException(404, "Line not found") from exc
|
||||||
request.app.state.runtime.rebuild_levels()
|
request.app.state.runtime.rebuild_levels()
|
||||||
return line.to_level().to_dict()
|
# A comment has no level form — returning one would hand the caller a shape
|
||||||
|
# that looks like something the confluence engine tracks.
|
||||||
|
return line.to_dict() if line.is_overlay else positioned_level(
|
||||||
|
request.app.state.runtime, line.id,
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/lines/{line_id}", status_code=204)
|
@router.delete("/lines/{line_id}", status_code=204)
|
||||||
|
|
@ -130,3 +497,42 @@ def delete_line(request: Request, line_id: str):
|
||||||
raise HTTPException(404, "Line not found") from exc
|
raise HTTPException(404, "Line not found") from exc
|
||||||
request.app.state.runtime.rebuild_levels()
|
request.app.state.runtime.rebuild_levels()
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/es-options/expirations")
|
||||||
|
def es_option_expirations():
|
||||||
|
return {"expirations": [row.to_dict() for row in nearby_expirations()]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/es-options/search")
|
||||||
|
async def es_option_search(
|
||||||
|
request: Request,
|
||||||
|
date: str,
|
||||||
|
root: str,
|
||||||
|
side: Literal["P", "C"] = "P",
|
||||||
|
mode: Literal["delta", "price"] = "delta",
|
||||||
|
min: float = Query(...),
|
||||||
|
max: float = Query(...),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
day = Date.fromisoformat(date)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, "Invalid expiration date") from exc
|
||||||
|
if not root or len(root) > 16:
|
||||||
|
raise HTTPException(400, "Invalid root")
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
run_search,
|
||||||
|
request.app.state.runtime.settings,
|
||||||
|
day=day,
|
||||||
|
root=root,
|
||||||
|
side=side,
|
||||||
|
mode=mode,
|
||||||
|
low=min,
|
||||||
|
high=max,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(503, "Schwab token missing") from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(502, str(exc)) from exc
|
||||||
|
return result
|
||||||
|
|
|
||||||
108
app/api/schwab_auth.py
Normal file
108
app/api/schwab_auth.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Schwab OAuth callback.
|
||||||
|
|
||||||
|
Schwab requires an HTTPS callback URL. The usual answer is
|
||||||
|
``https://127.0.0.1:8182`` with a self-signed certificate, which means clicking
|
||||||
|
through a browser warning on every re-authentication — and the refresh token
|
||||||
|
expires weekly. There are also reports of Schwab refusing to register apps whose
|
||||||
|
callback is a loopback address.
|
||||||
|
|
||||||
|
This app already terminates real HTTPS, so it can receive the redirect itself.
|
||||||
|
|
||||||
|
Deliberately unauthenticated: Schwab redirects a browser here and cannot attach
|
||||||
|
the chart token. Nothing is stored — the page only echoes the query string of
|
||||||
|
the request that produced it, which the caller already has in their address bar.
|
||||||
|
Storing the code would mean a later, unauthenticated visitor could read it.
|
||||||
|
|
||||||
|
The path is deliberately unrevealing. That is not a security control — the
|
||||||
|
endpoint's safety is that it is inert — it simply avoids advertising which
|
||||||
|
brokerage this host talks to. Treat it as fixed: changing a registered callback
|
||||||
|
URL means editing the Schwab app, which can send it back through approval.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
def begin_login(settings) -> object:
|
||||||
|
from schwab.auth import get_auth_context
|
||||||
|
|
||||||
|
return get_auth_context(settings.schwab_api_key, settings.schwab_callback_url)
|
||||||
|
|
||||||
|
|
||||||
|
def complete_login(settings, context, redirect_url: str) -> None:
|
||||||
|
from schwab import auth
|
||||||
|
from schwab.auth import client_from_received_url
|
||||||
|
|
||||||
|
path = settings.schwab_token_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_token = getattr(auth, "__make_update_token_func")(str(path))
|
||||||
|
client_from_received_url(
|
||||||
|
settings.schwab_api_key,
|
||||||
|
settings.schwab_app_secret,
|
||||||
|
context,
|
||||||
|
redirect_url,
|
||||||
|
write_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
PAGE = """<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Callback</title>
|
||||||
|
<style>
|
||||||
|
body {{ font: 15px/1.6 ui-sans-serif, system-ui, sans-serif; max-width: 46rem;
|
||||||
|
margin: 3rem auto; padding: 0 1.5rem; background: #14161a; color: #e8eaed; }}
|
||||||
|
h1 {{ font-size: 1.2rem; }}
|
||||||
|
code, textarea {{ font-family: ui-monospace, monospace; font-size: 13px; }}
|
||||||
|
textarea {{ width: 100%; height: 7rem; padding: .7rem; border-radius: 6px;
|
||||||
|
border: 1px solid #2a2e35; background: #0e1013; color: #e8eaed; }}
|
||||||
|
.warn {{ color: #efb643; }}
|
||||||
|
.muted {{ color: #9aa1ab; }}
|
||||||
|
</style>
|
||||||
|
<h1>{heading}</h1>
|
||||||
|
{body}
|
||||||
|
"""
|
||||||
|
|
||||||
|
RECEIVED = """
|
||||||
|
<p>Paste this entire URL into the waiting login prompt:</p>
|
||||||
|
<textarea readonly onclick="this.select()">{url}</textarea>
|
||||||
|
<p class="warn">Single use, and it expires within minutes. Do not share it.</p>
|
||||||
|
<p class="muted">Nothing was stored on the server. This page shows only the URL
|
||||||
|
you just arrived with.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
IDLE = """
|
||||||
|
<p>OAuth callback endpoint. Register this exact URL with the provider:</p>
|
||||||
|
<p><code>{url}</code></p>
|
||||||
|
<p class="muted">Arriving here directly is expected and harmless — the useful
|
||||||
|
version of this page is the one you are redirected to.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
FAILED = """
|
||||||
|
<p>The authorisation code could not be exchanged. Start again from the chart.</p>
|
||||||
|
<p class="muted">Nothing was stored.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qt")
|
||||||
|
def callback(request: Request):
|
||||||
|
runtime = getattr(request.app.state, "runtime", None)
|
||||||
|
if runtime is not None and runtime.schwab_login is not None and request.query_params.get("code"):
|
||||||
|
try:
|
||||||
|
runtime.finish_schwab_login(str(request.url))
|
||||||
|
except Exception:
|
||||||
|
page = PAGE.format(heading="Authorisation failed", body=FAILED)
|
||||||
|
return HTMLResponse(page, headers={"Cache-Control": "no-store"}, status_code=400)
|
||||||
|
return RedirectResponse("/", headers={"Cache-Control": "no-store"})
|
||||||
|
if request.query_params.get("code"):
|
||||||
|
page = PAGE.format(
|
||||||
|
heading="Authorisation received",
|
||||||
|
body=RECEIVED.format(url=str(request.url)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
page = PAGE.format(
|
||||||
|
heading="Callback endpoint",
|
||||||
|
body=IDLE.format(url=str(request.url).split("?")[0]),
|
||||||
|
)
|
||||||
|
# Never cached: it carries a single-use authorisation code.
|
||||||
|
return HTMLResponse(page, headers={"Cache-Control": "no-store"})
|
||||||
253
app/api/ws.py
253
app/api/ws.py
|
|
@ -1,28 +1,44 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.deps import token_matches
|
from app.api.deps import SESSION_COOKIE, session_matches, token_matches
|
||||||
|
from app.analysis.levels import LevelKind
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.analysis.alerts import AlertEngine
|
from app.analysis.bar_space import fill_short_gaps
|
||||||
|
from app.bars.session import bucket_duration, future_bucket_starts
|
||||||
from app.analysis.confluence import cluster_levels
|
from app.analysis.confluence import cluster_levels
|
||||||
from app.notify.ntfy import send_ntfy
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def same_origin(websocket: WebSocket) -> bool:
|
||||||
|
origin = websocket.headers.get("origin", "")
|
||||||
|
host = websocket.headers.get("host", "")
|
||||||
|
return bool(origin and host) and urlsplit(origin).netloc == host
|
||||||
|
|
||||||
|
|
||||||
|
def level_enabled(level, enabled: dict) -> bool:
|
||||||
|
kind = level.kind.value
|
||||||
|
if kind == "ma":
|
||||||
|
return level.period in enabled.get("ma", {}).get(level.tf.value, [])
|
||||||
|
if kind == "manual":
|
||||||
|
return enabled.get("drawings", True) and enabled.get("manual", True)
|
||||||
|
if kind == "trendline":
|
||||||
|
return enabled.get("auto", False)
|
||||||
|
if kind == "horizontal":
|
||||||
|
return enabled.get("horizontal", True)
|
||||||
|
if kind == "vwap":
|
||||||
|
return enabled.get("vwap", True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def enabled_levels(runtime, prefs: dict | None):
|
def enabled_levels(runtime, prefs: dict | None):
|
||||||
if not prefs or prefs.get("hidden_levels_score"):
|
if not prefs or prefs.get("hidden_levels_score"):
|
||||||
return runtime.levels
|
return runtime.levels
|
||||||
enabled = prefs.get("enabled", {})
|
enabled = prefs.get("enabled", {})
|
||||||
ma = enabled.get("ma", {})
|
return [level for level in runtime.levels if level_enabled(level, enabled)]
|
||||||
return [
|
|
||||||
level
|
|
||||||
for level in runtime.levels
|
|
||||||
if (level.kind.value == "ma" and level.period in ma.get(level.tf.value, []))
|
|
||||||
or (level.kind.value == "manual" and enabled.get("manual", True))
|
|
||||||
or (level.kind.value == "trendline" and enabled.get("auto", False))
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def connection_clusters(runtime, prefs: dict | None):
|
def connection_clusters(runtime, prefs: dict | None):
|
||||||
|
|
@ -33,24 +49,164 @@ def connection_clusters(runtime, prefs: dict | None):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def session_open(runtime) -> float | None:
|
||||||
|
bars = runtime.store.get(Timeframe.D1, 1)
|
||||||
|
return bars[-1].o if bars else None
|
||||||
|
|
||||||
|
|
||||||
|
def session_range(runtime) -> dict | None:
|
||||||
|
"""High/low of the forming CME session — today's range, not prior day."""
|
||||||
|
bars = runtime.store.get(Timeframe.D1, 1)
|
||||||
|
if not bars:
|
||||||
|
return None
|
||||||
|
bar = bars[-1]
|
||||||
|
return {"t": bar.t, "high": bar.h, "low": bar.l}
|
||||||
|
|
||||||
|
|
||||||
|
def trendline_timeframes(runtime) -> set[Timeframe]:
|
||||||
|
return {
|
||||||
|
level.tf for level in runtime.levels
|
||||||
|
if level.kind is LevelKind.MANUAL and level.slope
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def trendline_geometry(runtime) -> dict:
|
||||||
|
if not runtime.settings.trendline_source_geometry:
|
||||||
|
return {"mode": "legacy", "series": {}}
|
||||||
|
series = {}
|
||||||
|
for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value):
|
||||||
|
series[tf.value] = trendline_series(runtime, tf)
|
||||||
|
return {"mode": "source_tf", "series": series}
|
||||||
|
|
||||||
|
|
||||||
|
def trendline_series(runtime, tf: Timeframe) -> dict:
|
||||||
|
times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf)
|
||||||
|
value = {"times": times}
|
||||||
|
if tf is Timeframe.D1:
|
||||||
|
value["durations"] = [bucket_duration(t, tf) for t in times]
|
||||||
|
else:
|
||||||
|
value["duration"] = tf.seconds
|
||||||
|
future = future_bucket_starts(times[-1], tf) if times else []
|
||||||
|
value["future_times"] = future
|
||||||
|
value["future_durations"] = [bucket_duration(t, tf) for t in future]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
|
||||||
|
bars = runtime.store.get(tf, 1)
|
||||||
|
return future_bucket_starts(bars[-1].t, tf) if bars else []
|
||||||
|
|
||||||
|
|
||||||
|
def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> dict | None:
|
||||||
|
"""What one socket should hear about a bar.
|
||||||
|
|
||||||
|
A forming tick of an already-seen minute is just the live candle. Future
|
||||||
|
calendars and HTF geometry go out only when that timeframe's timestamp
|
||||||
|
advances — otherwise 1m at 4 Hz resends 180 slots and walks the store.
|
||||||
|
"""
|
||||||
|
if bar.tf is displayed_tf:
|
||||||
|
payload = {"type": "bar", "tf": displayed_tf.value, "bar": bar.to_dict()}
|
||||||
|
if not full:
|
||||||
|
return payload
|
||||||
|
source_tfs = trendline_timeframes(runtime)
|
||||||
|
source_bars = runtime.store.get(bar.tf)
|
||||||
|
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
|
||||||
|
first_source_t = source_bars[0].t if source_bars else None
|
||||||
|
source_index = next(
|
||||||
|
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
|
||||||
|
)
|
||||||
|
previous_source_t = (
|
||||||
|
source_bars[source_index - 1].t
|
||||||
|
if source_index is not None and source_index > 0 else None
|
||||||
|
)
|
||||||
|
rng = session_range(runtime)
|
||||||
|
extra = {
|
||||||
|
"session_t": rng["t"],
|
||||||
|
"session_high": rng["high"],
|
||||||
|
"session_low": rng["low"],
|
||||||
|
} if rng else {}
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"duration": bucket_duration(bar.t, bar.tf),
|
||||||
|
"session_open": session_open(runtime),
|
||||||
|
**extra,
|
||||||
|
"trendline_first_t": (
|
||||||
|
first_source_t
|
||||||
|
if runtime.settings.trendline_source_geometry
|
||||||
|
and bar.tf in source_tfs else None
|
||||||
|
),
|
||||||
|
"trendline_previous_t": previous_source_t,
|
||||||
|
"trendline_future_times": source_geometry.get("future_times", []),
|
||||||
|
"trendline_future_durations": source_geometry.get("future_durations", []),
|
||||||
|
"future_times": source_geometry.get("future_times", []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
if (
|
||||||
|
full
|
||||||
|
and runtime.settings.trendline_source_geometry
|
||||||
|
and bar.tf in trendline_timeframes(runtime)
|
||||||
|
):
|
||||||
|
source_bars = runtime.store.get(bar.tf)
|
||||||
|
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
|
||||||
|
first_source_t = source_bars[0].t if source_bars else None
|
||||||
|
source_index = next(
|
||||||
|
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
|
||||||
|
)
|
||||||
|
previous_source_t = (
|
||||||
|
source_bars[source_index - 1].t
|
||||||
|
if source_index is not None and source_index > 0 else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "trendline_bar",
|
||||||
|
"tf": bar.tf.value,
|
||||||
|
"t": bar.t,
|
||||||
|
"duration": bucket_duration(bar.t, bar.tf),
|
||||||
|
"first_t": first_source_t,
|
||||||
|
"previous_t": previous_source_t,
|
||||||
|
"future_times": source_geometry.get("future_times", []),
|
||||||
|
"future_durations": source_geometry.get("future_durations", []),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
|
events, events_more = runtime.events.recent()
|
||||||
|
rng = session_range(runtime)
|
||||||
|
extra = {
|
||||||
|
"session_t": rng["t"],
|
||||||
|
"session_high": rng["high"],
|
||||||
|
"session_low": rng["low"],
|
||||||
|
} if rng else {}
|
||||||
return {
|
return {
|
||||||
"type": "snapshot",
|
"type": "snapshot",
|
||||||
"tf": tf.value,
|
"tf": tf.value,
|
||||||
"bars": [bar.to_dict() for bar in runtime.store.get(tf, 1000)],
|
"bars": [bar.to_dict() for bar in runtime.store.get(tf)],
|
||||||
"levels": [level.to_dict() for level in runtime.levels],
|
"levels": [level.to_dict() for level in runtime.levels],
|
||||||
"clusters": [cluster.to_dict() for cluster in connection_clusters(runtime, prefs)],
|
"clusters": [cluster.to_dict() for cluster in connection_clusters(runtime, prefs)],
|
||||||
"price": runtime.store.get(Timeframe.M1, 1)[-1].c
|
"price": runtime.store.get(Timeframe.M1, 1)[-1].c
|
||||||
if runtime.store.get(Timeframe.M1, 1)
|
if runtime.store.get(Timeframe.M1, 1)
|
||||||
else None,
|
else None,
|
||||||
|
"session_open": session_open(runtime),
|
||||||
|
**extra,
|
||||||
|
"trendline_geometry": trendline_geometry(runtime),
|
||||||
|
"future_times": displayed_future_times(runtime, tf),
|
||||||
|
"events": events,
|
||||||
|
"events_more": events_more,
|
||||||
|
"instrument": runtime.settings.profile.payload(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
# Browsers cannot set headers on a WebSocket handshake, so the token comes
|
# Browsers automatically include the HttpOnly session cookie in the
|
||||||
# in as a query parameter here. 1008 = policy violation.
|
# handshake. Query-token support remains for non-browser clients and for
|
||||||
if not token_matches(websocket.app, websocket.query_params.get("token", "")):
|
# tabs migrating from the previous localStorage-based login.
|
||||||
|
token_ok = token_matches(websocket.app, websocket.query_params.get("token", ""))
|
||||||
|
session_ok = same_origin(websocket) and session_matches(
|
||||||
|
websocket.app, websocket.cookies.get(SESSION_COOKIE, "")
|
||||||
|
)
|
||||||
|
if not token_ok and not session_ok:
|
||||||
await websocket.close(code=1008, reason="Missing or invalid chart token")
|
await websocket.close(code=1008, reason="Missing or invalid chart token")
|
||||||
return
|
return
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
|
@ -59,10 +215,9 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
runtime.subscribers.add(queue)
|
runtime.subscribers.add(queue)
|
||||||
tf = Timeframe.M1
|
tf = Timeframe.M1
|
||||||
prefs = None
|
prefs = None
|
||||||
alert_engine = AlertEngine(
|
|
||||||
runtime.settings.confluence_min_score, runtime.settings.alert_cooldown_seconds
|
|
||||||
)
|
|
||||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||||
|
geometry_tfs = trendline_timeframes(runtime)
|
||||||
|
last_bar_t: dict[Timeframe, int] = {}
|
||||||
|
|
||||||
async def receive():
|
async def receive():
|
||||||
nonlocal tf, prefs
|
nonlocal tf, prefs
|
||||||
|
|
@ -79,9 +234,14 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
{
|
{
|
||||||
"type": "clusters",
|
"type": "clusters",
|
||||||
"price": runtime.price,
|
"price": runtime.price,
|
||||||
|
"session_open": session_open(runtime),
|
||||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif message.get("type") == "trendline_geometry":
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "trendline_geometry", "geometry": trendline_geometry(runtime)}
|
||||||
|
)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
queue.put_nowait({"type": "disconnect"})
|
queue.put_nowait({"type": "disconnect"})
|
||||||
|
|
||||||
|
|
@ -91,41 +251,50 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
event = await queue.get()
|
event = await queue.get()
|
||||||
if event["type"] == "disconnect":
|
if event["type"] == "disconnect":
|
||||||
break
|
break
|
||||||
if event["type"] == "bar" and event["bar"].tf is tf:
|
if event["type"] == "resync":
|
||||||
await websocket.send_json(
|
# History changed behind the live edge (a backfilled outage).
|
||||||
{"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()}
|
# Bar deltas only move the tail, so the whole series is resent.
|
||||||
)
|
last_bar_t.clear()
|
||||||
|
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||||
|
elif event["type"] == "bar":
|
||||||
|
bar = event["bar"]
|
||||||
|
full = last_bar_t.get(bar.tf) != bar.t
|
||||||
|
if full:
|
||||||
|
last_bar_t[bar.tf] = bar.t
|
||||||
|
payload = bar_client_message(runtime, bar, tf, full=full)
|
||||||
|
if payload is not None:
|
||||||
|
await websocket.send_json(payload)
|
||||||
elif event["type"] == "levels":
|
elif event["type"] == "levels":
|
||||||
await websocket.send_json(
|
message = {
|
||||||
{"type": "levels", "levels": [level.to_dict() for level in event["levels"]]}
|
"type": "levels", "changed": event["changed"], "removed": event["removed"],
|
||||||
)
|
}
|
||||||
|
current_tfs = trendline_timeframes(runtime)
|
||||||
|
if current_tfs != geometry_tfs:
|
||||||
|
message["trendline_geometry"] = trendline_geometry(runtime)
|
||||||
|
geometry_tfs = current_tfs
|
||||||
|
await websocket.send_json(message)
|
||||||
elif event["type"] == "clusters":
|
elif event["type"] == "clusters":
|
||||||
clusters = connection_clusters(runtime, prefs)
|
clusters = connection_clusters(runtime, prefs)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "clusters",
|
"type": "clusters",
|
||||||
"price": runtime.price,
|
"price": runtime.price,
|
||||||
|
"session_open": session_open(runtime),
|
||||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
alerts = (
|
elif event["type"] == "alert":
|
||||||
alert_engine.evaluate(
|
# Alerts are produced once, server-side. This socket only relays
|
||||||
clusters,
|
# them, so opening a second tab cannot double-notify.
|
||||||
runtime.price,
|
await websocket.send_json(
|
||||||
runtime.atr15,
|
{
|
||||||
runtime.stream.last_bar_t or 0,
|
"type": "alert",
|
||||||
runtime.stream.symbol,
|
"number": event.get("number", 0),
|
||||||
)
|
"at": event.get("at", 0),
|
||||||
if event.get("evaluate_alerts")
|
"cluster": event["cluster"].to_dict(),
|
||||||
else []
|
"message": event["message"],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
for alert in alerts:
|
|
||||||
await websocket.send_json(
|
|
||||||
{"type": "alert", "cluster": alert.cluster.to_dict(), "message": alert.message}
|
|
||||||
)
|
|
||||||
await send_ntfy(
|
|
||||||
runtime.settings.ntfy_server, runtime.settings.ntfy_topic, alert.message
|
|
||||||
)
|
|
||||||
except (WebSocketDisconnect, asyncio.CancelledError):
|
except (WebSocketDisconnect, asyncio.CancelledError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class Aggregator:
|
||||||
if source is Timeframe.M1:
|
if source is Timeframe.M1:
|
||||||
return True
|
return True
|
||||||
if source is Timeframe.H1:
|
if source is Timeframe.H1:
|
||||||
return target in (Timeframe.H1, Timeframe.H4, Timeframe.D1)
|
return target in (Timeframe.H1, Timeframe.D1)
|
||||||
return source is target
|
return source is target
|
||||||
|
|
||||||
def update(self, incoming: Bar) -> list[Bar]:
|
def update(self, incoming: Bar) -> list[Bar]:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ class Timeframe(str, Enum):
|
||||||
M15 = "15m"
|
M15 = "15m"
|
||||||
M30 = "30m"
|
M30 = "30m"
|
||||||
H1 = "1h"
|
H1 = "1h"
|
||||||
H4 = "4h"
|
|
||||||
D1 = "1d"
|
D1 = "1d"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -22,7 +21,6 @@ class Timeframe(str, Enum):
|
||||||
self.M15: 900,
|
self.M15: 900,
|
||||||
self.M30: 1800,
|
self.M30: 1800,
|
||||||
self.H1: 3600,
|
self.H1: 3600,
|
||||||
self.H4: 14400,
|
|
||||||
}
|
}
|
||||||
if self is self.D1:
|
if self is self.D1:
|
||||||
raise ValueError("1d is session-defined, not a fixed number of seconds")
|
raise ValueError("1d is session-defined, not a fixed number of seconds")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from app.bars.models import Timeframe
|
||||||
UTC = ZoneInfo("UTC")
|
UTC = ZoneInfo("UTC")
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
SESSION_OPEN = time(18, 0)
|
SESSION_OPEN = time(18, 0)
|
||||||
|
SESSION_CLOSE = time(17, 0)
|
||||||
|
FUTURE_SLOT_COUNT = 180
|
||||||
|
|
||||||
|
|
||||||
def _session_open_local(current: datetime) -> datetime:
|
def _session_open_local(current: datetime) -> datetime:
|
||||||
|
|
@ -14,17 +16,56 @@ def _session_open_local(current: datetime) -> datetime:
|
||||||
|
|
||||||
|
|
||||||
def bucket_start(t: int, tf: Timeframe) -> int:
|
def bucket_start(t: int, tf: Timeframe) -> int:
|
||||||
if tf not in (Timeframe.H4, Timeframe.D1):
|
# Everything below a day divides the hour evenly, so UTC boundaries are
|
||||||
|
# correct and session anchoring is unnecessary. Only the daily bar needs to
|
||||||
|
# know that the CME trading day runs 18:00 to 17:00 ET.
|
||||||
|
if tf is not Timeframe.D1:
|
||||||
return (t // tf.seconds) * tf.seconds
|
return (t // tf.seconds) * tf.seconds
|
||||||
|
|
||||||
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
|
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
|
||||||
session_open = _session_open_local(current)
|
return int(_session_open_local(current).timestamp())
|
||||||
if tf is Timeframe.D1:
|
|
||||||
return int(session_open.timestamp())
|
|
||||||
|
|
||||||
# CME's 4h anchors are wall-clock ET anchors. This intentionally makes the
|
|
||||||
# DST-transition bucket three or five elapsed hours instead of shifting it.
|
def bucket_duration(t: int, tf: Timeframe) -> int:
|
||||||
elapsed_wall = current.replace(tzinfo=None) - session_open.replace(tzinfo=None)
|
"""Wall-clock span of one logical bar bucket.
|
||||||
bucket_hours = int(elapsed_wall.total_seconds() // 14400) * 4
|
|
||||||
local_start = session_open.replace(tzinfo=None) + timedelta(hours=bucket_hours)
|
Intraday buckets are fixed. A daily line advances only through the active
|
||||||
return int(local_start.replace(tzinfo=EASTERN).timestamp())
|
18:00-17:00 ET session, not the settlement halt; constructing the close in
|
||||||
|
Eastern keeps DST transitions correct without shifting stored UTC times.
|
||||||
|
"""
|
||||||
|
if tf is not Timeframe.D1:
|
||||||
|
return tf.seconds
|
||||||
|
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
|
||||||
|
next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN)
|
||||||
|
return int(next_close.timestamp()) - t
|
||||||
|
|
||||||
|
|
||||||
|
def next_bucket_start(t: int, tf: Timeframe) -> int:
|
||||||
|
"""Next projected source-bar open, skipping known CME closures."""
|
||||||
|
if tf is not Timeframe.D1:
|
||||||
|
candidate = datetime.fromtimestamp(t + tf.seconds, UTC).astimezone(EASTERN)
|
||||||
|
weekday = candidate.weekday()
|
||||||
|
wall_time = candidate.timetz().replace(tzinfo=None)
|
||||||
|
if weekday == 5: # Saturday -> Sunday open.
|
||||||
|
candidate = datetime.combine(candidate.date() + timedelta(days=1), SESSION_OPEN, EASTERN)
|
||||||
|
elif weekday == 6 and wall_time < SESSION_OPEN:
|
||||||
|
candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN)
|
||||||
|
elif weekday in {0, 1, 2, 3} and SESSION_CLOSE <= wall_time < SESSION_OPEN:
|
||||||
|
candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN)
|
||||||
|
elif weekday == 4 and wall_time >= SESSION_CLOSE:
|
||||||
|
candidate = datetime.combine(candidate.date() + timedelta(days=2), SESSION_OPEN, EASTERN)
|
||||||
|
return int(candidate.timestamp())
|
||||||
|
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
|
||||||
|
candidate = current.date() + timedelta(days=1)
|
||||||
|
# Daily futures sessions open Sunday through Thursday.
|
||||||
|
while candidate.weekday() not in {6, 0, 1, 2, 3}:
|
||||||
|
candidate += timedelta(days=1)
|
||||||
|
return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def future_bucket_starts(t: int, tf: Timeframe, count: int = FUTURE_SLOT_COUNT) -> list[int]:
|
||||||
|
values = []
|
||||||
|
for _ in range(count):
|
||||||
|
t = next_bucket_start(t, tf)
|
||||||
|
values.append(t)
|
||||||
|
return values
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from collections import defaultdict, deque
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from app.bars.models import Bar, Timeframe
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.config import DEFAULT_MAX_BARS_PER_TF
|
||||||
|
|
||||||
|
|
||||||
class BarStore(Protocol):
|
class BarStore(Protocol):
|
||||||
|
|
@ -11,17 +12,68 @@ class BarStore(Protocol):
|
||||||
|
|
||||||
|
|
||||||
class InMemoryBarStore:
|
class InMemoryBarStore:
|
||||||
def __init__(self, max_bars_per_tf: int = 5000):
|
def __init__(self, max_bars_per_tf: int = DEFAULT_MAX_BARS_PER_TF):
|
||||||
self._bars: dict[Timeframe, deque[Bar]] = defaultdict(
|
self._bars: dict[Timeframe, deque[Bar]] = defaultdict(
|
||||||
lambda: deque(maxlen=max_bars_per_tf)
|
lambda: deque(maxlen=max_bars_per_tf)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# How far back from the tail a late bar may still land. A closed minute bar
|
||||||
|
# arrives a beat after the ticks that opened the next minute, so it is
|
||||||
|
# rarely more than a bucket or two behind.
|
||||||
|
LATE_BAR_LOOKBACK = 8
|
||||||
|
|
||||||
def put(self, bar: Bar) -> None:
|
def put(self, bar: Bar) -> None:
|
||||||
|
"""Store a bar, replacing the bucket it belongs to.
|
||||||
|
|
||||||
|
Matching only the tail was enough while one closed bar arrived per
|
||||||
|
minute. With tick-built bars a minute's authoritative bar shows up
|
||||||
|
*after* ticks have already opened the next one, so the exchange's own
|
||||||
|
figures were being dropped and the approximation left in place forever.
|
||||||
|
"""
|
||||||
bars = self._bars[bar.tf]
|
bars = self._bars[bar.tf]
|
||||||
if bars and bars[-1].t == bar.t:
|
if not bars or bar.t > bars[-1].t:
|
||||||
bars[-1] = bar
|
|
||||||
elif not bars or bar.t > bars[-1].t:
|
|
||||||
bars.append(bar)
|
bars.append(bar)
|
||||||
|
return
|
||||||
|
for index in range(len(bars) - 1, max(-1, len(bars) - self.LATE_BAR_LOOKBACK - 1), -1):
|
||||||
|
if bars[index].t == bar.t:
|
||||||
|
# A provisional bar must never overwrite a settled one: ticks
|
||||||
|
# keep arriving for a minute the exchange has already closed.
|
||||||
|
if bars[index].closed and not bar.closed:
|
||||||
|
return
|
||||||
|
bars[index] = bar
|
||||||
|
return
|
||||||
|
if bars[index].t < bar.t:
|
||||||
|
# Buckets are ordered, so nothing further back can match.
|
||||||
|
return
|
||||||
|
|
||||||
|
def fill(self, bars: list[Bar]) -> int:
|
||||||
|
"""Insert history into buckets the store has no bar for.
|
||||||
|
|
||||||
|
``put`` only lands a bar at the tail or a few buckets behind it, so a
|
||||||
|
stretch missed while the stream was down cannot reach it — the live
|
||||||
|
bars that arrived on reconnect are already newer. Existing bars always
|
||||||
|
win: they are the live source's own figures, and the bucket either side
|
||||||
|
of the hole is the live aggregator's to finish. Returns how many bars
|
||||||
|
were inserted.
|
||||||
|
"""
|
||||||
|
added = 0
|
||||||
|
by_tf: dict[Timeframe, list[Bar]] = defaultdict(list)
|
||||||
|
for bar in bars:
|
||||||
|
by_tf[bar.tf].append(bar)
|
||||||
|
for tf, incoming in by_tf.items():
|
||||||
|
held = self._bars[tf]
|
||||||
|
merged = {bar.t: bar for bar in held}
|
||||||
|
for bar in incoming:
|
||||||
|
if bar.t not in merged:
|
||||||
|
merged[bar.t] = bar
|
||||||
|
added += 1
|
||||||
|
if len(merged) == len(held):
|
||||||
|
continue
|
||||||
|
ordered = [merged[t] for t in sorted(merged)]
|
||||||
|
held.clear()
|
||||||
|
# maxlen keeps the newest, which is the history the chart shows.
|
||||||
|
held.extend(ordered)
|
||||||
|
return added
|
||||||
|
|
||||||
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
|
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
|
||||||
bars = list(self._bars[tf])
|
bars = list(self._bars[tf])
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ from pathlib import Path
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
from app.instrument import get_instrument
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MAX_BARS_PER_TF = 5000
|
||||||
|
|
||||||
TIMEFRAME_WEIGHT = {
|
TIMEFRAME_WEIGHT = {
|
||||||
Timeframe.M1: 1,
|
Timeframe.M1: 1,
|
||||||
Timeframe.M2: 1,
|
Timeframe.M2: 1,
|
||||||
|
|
@ -12,7 +15,6 @@ TIMEFRAME_WEIGHT = {
|
||||||
Timeframe.M15: 2,
|
Timeframe.M15: 2,
|
||||||
Timeframe.M30: 3,
|
Timeframe.M30: 3,
|
||||||
Timeframe.H1: 4,
|
Timeframe.H1: 4,
|
||||||
Timeframe.H4: 8,
|
|
||||||
Timeframe.D1: 16,
|
Timeframe.D1: 16,
|
||||||
}
|
}
|
||||||
MA_WEIGHT_FACTOR = 0.75
|
MA_WEIGHT_FACTOR = 0.75
|
||||||
|
|
@ -23,34 +25,76 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
live_source: str = "yahoo"
|
live_source: str = "yahoo"
|
||||||
seed_source: str = "yahoo"
|
seed_source: str = "yahoo"
|
||||||
|
instrument: str = "es"
|
||||||
yahoo_symbol: str = "ES=F"
|
yahoo_symbol: str = "ES=F"
|
||||||
yahoo_poll_seconds: float = 20
|
yahoo_poll_seconds: float = 20
|
||||||
seed_1h_range: str = "730d"
|
seed_1h_range: str = "730d"
|
||||||
|
seed_30m_range: str = "60d"
|
||||||
seed_1m_range: str = "8d"
|
seed_1m_range: str = "8d"
|
||||||
timeframes: str = "1m,5m,15m,30m,1h,1d"
|
timeframes: str = "1m,5m,15m,30m,1h,1d"
|
||||||
base_timeframes: str = "1m,30m,1d"
|
base_timeframes: str = "1m,30m,1d"
|
||||||
max_bars_per_tf: int = 5000
|
max_bars_per_tf: int = DEFAULT_MAX_BARS_PER_TF
|
||||||
|
# Rollback gate for source-timeframe trendline geometry. When false, both
|
||||||
|
# server pricing and browser rendering use the previous 1m/displayed grid.
|
||||||
|
trendline_source_geometry: bool = True
|
||||||
ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200"
|
ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200"
|
||||||
ma_sets__1h: str = ""
|
|
||||||
daily_anchor_et: str = "18:00"
|
daily_anchor_et: str = "18:00"
|
||||||
manual_lines_path: Path = Path("./data/manual_lines.json")
|
manual_lines_path: Path = Path("./data/manual_lines.json")
|
||||||
|
|
||||||
|
# Schwab. Empty until the app's keys are issued; nothing reads them while
|
||||||
|
# live_source is yahoo. The token lives under data/ so it lands on the
|
||||||
|
# Coolify persistent volume — a rebuild would otherwise log you out, and
|
||||||
|
# re-authenticating is an interactive browser flow.
|
||||||
|
schwab_api_key: str = ""
|
||||||
|
schwab_app_secret: str = ""
|
||||||
|
schwab_callback_url: str = "https://chart.amow.com/api/qt"
|
||||||
|
schwab_token_path: Path = Path("./data/.schwab_token.json")
|
||||||
|
schwab_symbol: str = "/ES"
|
||||||
|
# Seconds between forming-bar emissions built from LEVEL_ONE_FUTURES ticks.
|
||||||
|
# Set negative to drop the Level 1 subscription and take closed minute bars
|
||||||
|
# only. Measured in regular hours, /ES supplies a price-changing trade far
|
||||||
|
# faster than this, so the value is the update rate: at 1.0 the throttle was
|
||||||
|
# the limiter and the chart felt sluggish.
|
||||||
|
schwab_tick_seconds: float = 0.25
|
||||||
confluence_min_score: float = 28
|
confluence_min_score: float = 28
|
||||||
alert_cooldown_seconds: int = 900
|
# Four hours, chosen from the sweep in scripts/calibrate_alerts.py. Suppression is
|
||||||
|
# per price zone, so an unrelated zone still alerts immediately; this only
|
||||||
|
# governs how often the *same* area repeats itself.
|
||||||
|
alert_cooldown_seconds: int = 14400
|
||||||
|
# On the persistent volume in production: suppression has to outlive a
|
||||||
|
# deploy or every push re-fires every zone that currently qualifies.
|
||||||
|
alert_state_path: Path = Path("./data/alert_state.json")
|
||||||
|
events_path: Path = Path("./data/events.json")
|
||||||
|
user_prefs_path: Path = Path("./data/user_prefs.json")
|
||||||
ntfy_topic: str = ""
|
ntfy_topic: str = ""
|
||||||
ntfy_server: str = "https://ntfy.sh"
|
ntfy_server: str = "https://ntfy.sh"
|
||||||
chart_auth_token: str = ""
|
chart_auth_token: str = ""
|
||||||
|
chart_password: str = ""
|
||||||
|
# Zone used for times a person reads rather than a machine: the timestamp in
|
||||||
|
# an alert push. The container runs UTC; this is where you are.
|
||||||
|
alert_timezone: str = "America/Chicago"
|
||||||
replay_file: Path | None = None
|
replay_file: Path | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile(self):
|
||||||
|
return get_instrument(self.instrument)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live_symbol(self) -> str:
|
||||||
|
"""What the live source calls the instrument.
|
||||||
|
|
||||||
|
Yahoo wants ES=F, Schwab wants /ES. Seeding always uses the Yahoo
|
||||||
|
symbol, because Yahoo is always the source of history.
|
||||||
|
"""
|
||||||
|
return self.schwab_symbol if self.live_source == "schwab" else self.yahoo_symbol
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled_timeframes(self) -> list[Timeframe]:
|
def enabled_timeframes(self) -> list[Timeframe]:
|
||||||
return [Timeframe(value.strip()) for value in self.timeframes.split(",") if value.strip()]
|
return [Timeframe(value.strip()) for value in self.timeframes.split(",") if value.strip()]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]:
|
def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]:
|
||||||
configured = {
|
configured = {Timeframe.D1: self.ma_sets__1d}
|
||||||
Timeframe.D1: self.ma_sets__1d,
|
|
||||||
Timeframe.H1: self.ma_sets__1h,
|
|
||||||
}
|
|
||||||
result: dict[Timeframe, list[tuple[str, int]]] = {}
|
result: dict[Timeframe, list[tuple[str, int]]] = {}
|
||||||
for tf, value in configured.items():
|
for tf, value in configured.items():
|
||||||
definitions = []
|
definitions = []
|
||||||
|
|
|
||||||
51
app/instrument.py
Normal file
51
app/instrument.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SYMBOL = "/ES"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Instrument:
|
||||||
|
id: str
|
||||||
|
yahoo_symbol: str
|
||||||
|
schwab_symbol: str
|
||||||
|
tick: float
|
||||||
|
decimals: int = 2
|
||||||
|
session: str = "globex_18_17"
|
||||||
|
rth: str = "spy_rth"
|
||||||
|
|
||||||
|
def snap(self, price: float) -> float:
|
||||||
|
return round(round(price / self.tick) * self.tick, self.decimals)
|
||||||
|
|
||||||
|
def payload(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"yahoo_symbol": self.yahoo_symbol,
|
||||||
|
"schwab_symbol": self.schwab_symbol,
|
||||||
|
"tick": self.tick,
|
||||||
|
"decimals": self.decimals,
|
||||||
|
"session": self.session,
|
||||||
|
"rth": self.rth,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
INSTRUMENTS = {
|
||||||
|
"es": Instrument("es", "ES=F", "/ES", 0.25, rth="spy_rth"),
|
||||||
|
"nq": Instrument("nq", "NQ=F", "/NQ", 0.25, rth="spy_rth"),
|
||||||
|
"gc": Instrument("gc", "GC=F", "/GC", 0.10, rth="none"),
|
||||||
|
"cl": Instrument("cl", "CL=F", "/CL", 0.01, rth="nymex_day"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_instrument(instrument_id: str) -> Instrument:
|
||||||
|
try:
|
||||||
|
return INSTRUMENTS[instrument_id]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"Unknown instrument: {instrument_id}") from None
|
||||||
|
|
||||||
|
|
||||||
|
def instrument_for_symbol(symbol: str) -> Instrument:
|
||||||
|
for instrument in INSTRUMENTS.values():
|
||||||
|
if symbol in (instrument.id, instrument.schwab_symbol, instrument.yahoo_symbol):
|
||||||
|
return instrument
|
||||||
|
return INSTRUMENTS["es"]
|
||||||
298
app/market/es_options.py
Normal file
298
app/market/es_options.py
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from math import erf, log, sqrt
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
MONTH_CODES = {
|
||||||
|
1: "F",
|
||||||
|
2: "G",
|
||||||
|
3: "H",
|
||||||
|
4: "J",
|
||||||
|
5: "K",
|
||||||
|
6: "M",
|
||||||
|
7: "N",
|
||||||
|
8: "Q",
|
||||||
|
9: "U",
|
||||||
|
10: "V",
|
||||||
|
11: "X",
|
||||||
|
12: "Z",
|
||||||
|
}
|
||||||
|
WEEKDAY_LETTER = {0: "A", 1: "B", 2: "C", 3: "D"}
|
||||||
|
QUARTERLY_MONTHS = {3, 6, 9, 12}
|
||||||
|
TOS_SUFFIX = ":XCME"
|
||||||
|
QUOTE_BATCH = 50
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Expiration:
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
date: date
|
||||||
|
root: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"date": self.date.isoformat(),
|
||||||
|
"root": self.root,
|
||||||
|
"label": self.label,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def week_of_month(day: date) -> int:
|
||||||
|
return (day.day - 1) // 7 + 1
|
||||||
|
|
||||||
|
|
||||||
|
def daily_root(day: date) -> str:
|
||||||
|
month = MONTH_CODES[day.month]
|
||||||
|
year = day.year % 100
|
||||||
|
nth = week_of_month(day)
|
||||||
|
if day.weekday() == 4:
|
||||||
|
return f"EW{nth}{month}{year:02d}"
|
||||||
|
return f"E{nth}{WEEKDAY_LETTER[day.weekday()]}{month}{year:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def monthly_root(day: date) -> str:
|
||||||
|
if day.month in QUARTERLY_MONTHS:
|
||||||
|
return f"ES{MONTH_CODES[day.month]}{day.year % 100:02d}"
|
||||||
|
return daily_root(day)
|
||||||
|
|
||||||
|
|
||||||
|
def api_symbol(root: str, side: str, strike: float | int) -> str:
|
||||||
|
return f"./{root}{side}{int(strike)}"
|
||||||
|
|
||||||
|
|
||||||
|
def tos_symbol(symbol: str) -> str:
|
||||||
|
if symbol.endswith(TOS_SUFFIX):
|
||||||
|
return symbol
|
||||||
|
return f"{symbol}{TOS_SUFFIX}"
|
||||||
|
|
||||||
|
|
||||||
|
def from_tos_symbol(symbol: str) -> str:
|
||||||
|
if symbol.endswith(TOS_SUFFIX):
|
||||||
|
return symbol[: -len(TOS_SUFFIX)]
|
||||||
|
return symbol
|
||||||
|
|
||||||
|
|
||||||
|
def next_weekdays(today: date, count: int) -> list[date]:
|
||||||
|
days: list[date] = []
|
||||||
|
cursor = today
|
||||||
|
while len(days) < count:
|
||||||
|
if cursor.weekday() < 5:
|
||||||
|
days.append(cursor)
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def next_friday(today: date) -> date:
|
||||||
|
return today + timedelta(days=(4 - today.weekday()) % 7)
|
||||||
|
|
||||||
|
|
||||||
|
def third_friday(year: int, month: int) -> date:
|
||||||
|
first = date(year, month, 1)
|
||||||
|
first_friday = first + timedelta(days=(4 - first.weekday()) % 7)
|
||||||
|
return first_friday + timedelta(days=14)
|
||||||
|
|
||||||
|
|
||||||
|
def next_third_friday(today: date) -> date:
|
||||||
|
candidate = third_friday(today.year, today.month)
|
||||||
|
if candidate >= today:
|
||||||
|
return candidate
|
||||||
|
if today.month == 12:
|
||||||
|
return third_friday(today.year + 1, 1)
|
||||||
|
return third_friday(today.year, today.month + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _label(day: date, kind: str) -> str:
|
||||||
|
return f"{day.strftime('%a %b')} {day.day} {kind}"
|
||||||
|
|
||||||
|
|
||||||
|
def nearby_expirations(today: date | None = None) -> list[Expiration]:
|
||||||
|
if today is None:
|
||||||
|
today = datetime.now(EASTERN).date()
|
||||||
|
expirations: list[Expiration] = []
|
||||||
|
for day in next_weekdays(today, 3):
|
||||||
|
expirations.append(
|
||||||
|
Expiration(
|
||||||
|
id=f"daily-{day.isoformat()}",
|
||||||
|
kind="daily",
|
||||||
|
date=day,
|
||||||
|
root=daily_root(day),
|
||||||
|
label=_label(day, "daily"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
weekly = next_friday(today)
|
||||||
|
expirations.append(
|
||||||
|
Expiration(
|
||||||
|
id=f"weekly-{weekly.isoformat()}",
|
||||||
|
kind="weekly",
|
||||||
|
date=weekly,
|
||||||
|
root=daily_root(weekly),
|
||||||
|
label=_label(weekly, "weekly"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monthly = next_third_friday(today)
|
||||||
|
expirations.append(
|
||||||
|
Expiration(
|
||||||
|
id=f"monthly-{monthly.isoformat()}",
|
||||||
|
kind="monthly",
|
||||||
|
date=monthly,
|
||||||
|
root=monthly_root(monthly),
|
||||||
|
label=_label(monthly, "monthly"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return expirations
|
||||||
|
|
||||||
|
|
||||||
|
def strike_step(root: str) -> int:
|
||||||
|
return 25 if root.startswith("ES") else 5
|
||||||
|
|
||||||
|
|
||||||
|
def strike_span(root: str) -> int:
|
||||||
|
return 600 if root.startswith("ES") else 200
|
||||||
|
|
||||||
|
|
||||||
|
def strike_grid(price: float, root: str) -> list[int]:
|
||||||
|
step = strike_step(root)
|
||||||
|
span = strike_span(root)
|
||||||
|
center = int(round(price / step) * step)
|
||||||
|
return list(range(center - span, center + span + step, step))
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_symbols(root: str, side: str, price: float) -> list[str]:
|
||||||
|
return [api_symbol(root, side, strike) for strike in strike_grid(price, root)]
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_cdf(value: float) -> float:
|
||||||
|
return 0.5 * (1.0 + erf(value / sqrt(2.0)))
|
||||||
|
|
||||||
|
|
||||||
|
def years_to_expiry(day: date, now: datetime | None = None) -> float:
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
if now.tzinfo is None:
|
||||||
|
now = now.replace(tzinfo=EASTERN)
|
||||||
|
expiry = datetime(day.year, day.month, day.day, 16, 0, tzinfo=EASTERN)
|
||||||
|
seconds = (expiry - now.astimezone(EASTERN)).total_seconds()
|
||||||
|
return max(seconds / (365.25 * 24 * 3600), 1 / 365.25)
|
||||||
|
|
||||||
|
|
||||||
|
def black76_price(forward: float, strike: float, years: float, sigma: float, side: str) -> float:
|
||||||
|
d1 = (log(forward / strike) + 0.5 * sigma * sigma * years) / (sigma * sqrt(years))
|
||||||
|
d2 = d1 - sigma * sqrt(years)
|
||||||
|
if side == "C":
|
||||||
|
return forward * _norm_cdf(d1) - strike * _norm_cdf(d2)
|
||||||
|
return strike * _norm_cdf(-d2) - forward * _norm_cdf(-d1)
|
||||||
|
|
||||||
|
|
||||||
|
def black76_delta(forward: float, strike: float, years: float, sigma: float, side: str) -> float | None:
|
||||||
|
if years <= 0 or sigma <= 0 or forward <= 0 or strike <= 0:
|
||||||
|
return None
|
||||||
|
d1 = (log(forward / strike) + 0.5 * sigma * sigma * years) / (sigma * sqrt(years))
|
||||||
|
if side == "C":
|
||||||
|
return _norm_cdf(d1)
|
||||||
|
return -_norm_cdf(-d1)
|
||||||
|
|
||||||
|
|
||||||
|
def implied_vol(forward: float, strike: float, years: float, price: float, side: str) -> float | None:
|
||||||
|
if price <= 0 or years <= 0 or forward <= 0 or strike <= 0:
|
||||||
|
return None
|
||||||
|
low, high = 0.01, 3.0
|
||||||
|
for _ in range(40):
|
||||||
|
mid = (low + high) / 2
|
||||||
|
model = black76_price(forward, strike, years, mid, side)
|
||||||
|
if model > price:
|
||||||
|
high = mid
|
||||||
|
else:
|
||||||
|
low = mid
|
||||||
|
return (low + high) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def parse_option_quote(symbol: str, payload: dict) -> dict | None:
|
||||||
|
if payload.get("assetMainType") != "FUTURE_OPTION":
|
||||||
|
return None
|
||||||
|
fields = payload.get("quote") or {}
|
||||||
|
reference = payload.get("reference") or {}
|
||||||
|
mark = fields.get("mark")
|
||||||
|
strike = reference.get("strikePrice")
|
||||||
|
if mark is None or strike is None:
|
||||||
|
return None
|
||||||
|
description = reference.get("description") or tos_symbol(symbol)
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"tos": description,
|
||||||
|
"strike": float(strike),
|
||||||
|
"side": reference.get("contractType") or "",
|
||||||
|
"bid": fields.get("bidPrice"),
|
||||||
|
"ask": fields.get("askPrice"),
|
||||||
|
"mark": float(mark),
|
||||||
|
"last": fields.get("lastPrice"),
|
||||||
|
"volume": fields.get("totalVolume") or 0,
|
||||||
|
"open_interest": fields.get("openInterest") or 0,
|
||||||
|
"quote_time": fields.get("quoteTime"),
|
||||||
|
"realtime": payload.get("realtime"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def atm_implied_vol(contracts: list[dict], forward: float, years: float, side: str) -> float | None:
|
||||||
|
if not contracts:
|
||||||
|
return None
|
||||||
|
atm = min(contracts, key=lambda row: abs(row["strike"] - forward))
|
||||||
|
return implied_vol(forward, atm["strike"], years, atm["mark"], side)
|
||||||
|
|
||||||
|
|
||||||
|
def attach_deltas(contracts: list[dict], forward: float, years: float, side: str, sigma: float | None) -> list[dict]:
|
||||||
|
vol = sigma or 0.15
|
||||||
|
rows = []
|
||||||
|
for contract in contracts:
|
||||||
|
delta = black76_delta(forward, contract["strike"], years, vol, side)
|
||||||
|
rows.append({**contract, "delta": delta, "abs_delta": None if delta is None else abs(delta)})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def filter_contracts(contracts: list[dict], mode: str, low: float, high: float) -> list[dict]:
|
||||||
|
if low > high:
|
||||||
|
low, high = high, low
|
||||||
|
kept = []
|
||||||
|
for contract in contracts:
|
||||||
|
value = contract["mark"] if mode == "price" else contract.get("abs_delta")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if low <= value <= high:
|
||||||
|
kept.append(contract)
|
||||||
|
kept.sort(key=lambda row: row["strike"])
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def search_from_quotes(
|
||||||
|
*,
|
||||||
|
day: date,
|
||||||
|
side: str,
|
||||||
|
mode: str,
|
||||||
|
low: float,
|
||||||
|
high: float,
|
||||||
|
forward: float,
|
||||||
|
quotes: dict[str, dict],
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
contracts = []
|
||||||
|
for symbol, payload in quotes.items():
|
||||||
|
parsed = parse_option_quote(symbol, payload)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
contracts.append(parsed)
|
||||||
|
years = years_to_expiry(day, now)
|
||||||
|
sigma = atm_implied_vol(contracts, forward, years, side)
|
||||||
|
ranked = attach_deltas(contracts, forward, years, side, sigma)
|
||||||
|
matches = filter_contracts(ranked, mode, low, high)
|
||||||
|
return {
|
||||||
|
"underlying_price": forward,
|
||||||
|
"iv": sigma,
|
||||||
|
"delta_approx": True,
|
||||||
|
"contracts": matches,
|
||||||
|
}
|
||||||
|
|
@ -7,15 +7,27 @@ from app.market.yahoo import YahooSource
|
||||||
def live_source(settings: Settings) -> MarketDataSource:
|
def live_source(settings: Settings) -> MarketDataSource:
|
||||||
if settings.live_source == "yahoo":
|
if settings.live_source == "yahoo":
|
||||||
return YahooSource(settings.yahoo_poll_seconds)
|
return YahooSource(settings.yahoo_poll_seconds)
|
||||||
|
if settings.live_source == "schwab":
|
||||||
|
# Imported here so schwab-py stays optional while the live source is
|
||||||
|
# Yahoo, which is still the default.
|
||||||
|
from app.market.schwab import SchwabSource
|
||||||
|
|
||||||
|
return SchwabSource(settings)
|
||||||
if settings.live_source == "replay" and settings.replay_file:
|
if settings.live_source == "replay" and settings.replay_file:
|
||||||
return ReplaySource(settings.replay_file)
|
return ReplaySource(settings.replay_file)
|
||||||
raise ValueError(f"Unsupported LIVE_SOURCE: {settings.live_source}")
|
raise ValueError(f"Unsupported LIVE_SOURCE: {settings.live_source}")
|
||||||
|
|
||||||
|
|
||||||
def seed_source(settings: Settings) -> MarketDataSource | None:
|
def seed_source(settings: Settings) -> MarketDataSource | None:
|
||||||
|
"""The source of history, which is never Schwab.
|
||||||
|
|
||||||
|
Schwab serves price history for equities and ETFs only, so with
|
||||||
|
LIVE_SOURCE=schwab the seed stays on Yahoo. That pairing is the intended
|
||||||
|
configuration, not a fallback: Yahoo supplies the past, Schwab the present.
|
||||||
|
"""
|
||||||
if settings.seed_source == "none":
|
if settings.seed_source == "none":
|
||||||
return None
|
return None
|
||||||
if settings.seed_source == "yahoo":
|
if settings.seed_source in ("yahoo", "schwab"):
|
||||||
return YahooSource(settings.yahoo_poll_seconds)
|
return YahooSource(settings.yahoo_poll_seconds)
|
||||||
if settings.seed_source == "replay" and settings.replay_file:
|
if settings.seed_source == "replay" and settings.replay_file:
|
||||||
return ReplaySource(settings.replay_file)
|
return ReplaySource(settings.replay_file)
|
||||||
|
|
|
||||||
275
app/market/schwab.py
Normal file
275
app/market/schwab.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""Real-time /ES bars from Schwab's CHART_FUTURES stream.
|
||||||
|
|
||||||
|
Verified against a live account before this was written:
|
||||||
|
|
||||||
|
- Streaming works. CHART_FUTURES delivers one minute bar per symbol per minute
|
||||||
|
with true exchange OHLCV, and LEVEL_ONE_FUTURES reports ``delayed: False``.
|
||||||
|
- The continuous root resolves itself. Subscribing to ``/ES`` returns data keyed
|
||||||
|
``/ES`` while quotes report the active contract as ``/ESU26``, so contract
|
||||||
|
rolls need no handling here.
|
||||||
|
- There is no history. Schwab serves price history for equities and ETFs only,
|
||||||
|
so this source seeds nothing; Yahoo remains the only source of the past.
|
||||||
|
|
||||||
|
The delayed sibling is worth stating plainly: Yahoo lags about ten minutes, so
|
||||||
|
at startup the most recent bars are missing until Yahoo catches up. Keep both
|
||||||
|
sources running rather than switching Yahoo off once this connects.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# CHART_FUTURES field names as schwab-py labels them.
|
||||||
|
FIELD_TIME = "CHART_TIME_MILLIS"
|
||||||
|
FIELD_OPEN = "OPEN_PRICE"
|
||||||
|
FIELD_HIGH = "HIGH_PRICE"
|
||||||
|
FIELD_LOW = "LOW_PRICE"
|
||||||
|
FIELD_CLOSE = "CLOSE_PRICE"
|
||||||
|
FIELD_VOLUME = "VOLUME"
|
||||||
|
|
||||||
|
# LEVEL_ONE_FUTURES field names, as schwab-py labels them. Verified realtime on
|
||||||
|
# this account: the service reports delayed: False for /ES.
|
||||||
|
FIELD_LAST_PRICE = "LAST_PRICE"
|
||||||
|
FIELD_LAST_SIZE = "LAST_SIZE"
|
||||||
|
FIELD_TRADE_TIME = "TRADE_TIME_MILLIS"
|
||||||
|
FIELD_TOTAL_VOLUME = "TOTAL_VOLUME"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_level_one(message: dict) -> list[tuple[int, float | None, int]]:
|
||||||
|
"""Turn one LEVEL_ONE_FUTURES message into (trade time ms, price, size).
|
||||||
|
|
||||||
|
Level 1 messages are partial — only changed fields are sent — which makes
|
||||||
|
"is this a trade?" a question about several fields rather than one:
|
||||||
|
|
||||||
|
- A quote moving only the bid or ask carries no trade field at all. Skipped:
|
||||||
|
a bid is not a trade and must not extend a candle's high or low.
|
||||||
|
- A trade at the *same price* as the one before carries LAST_SIZE and
|
||||||
|
TRADE_TIME_MILLIS but no LAST_PRICE, because the price did not change.
|
||||||
|
Measured live, that is a fifth of all trades. Dropping them lost their
|
||||||
|
volume, so the price is returned as None for the caller to carry forward.
|
||||||
|
"""
|
||||||
|
ticks: list[tuple[int, float | None, int]] = []
|
||||||
|
for content in message.get("content") or []:
|
||||||
|
price = content.get(FIELD_LAST_PRICE)
|
||||||
|
# Zero is not a price. The field arrives as 0 on some updates, and
|
||||||
|
# because 0 is not None it opened a bar at zero, which dragged the low
|
||||||
|
# of that minute — and every timeframe aggregating it — to the bottom of
|
||||||
|
# the chart. Treated as absent, so the last real price carries forward.
|
||||||
|
if price is not None and float(price) <= 0:
|
||||||
|
price = None
|
||||||
|
size = content.get(FIELD_LAST_SIZE)
|
||||||
|
traded_at = content.get(FIELD_TRADE_TIME)
|
||||||
|
# A trade stamp alongside a moved cumulative volume is a trade even when
|
||||||
|
# neither the price nor the size field was resent. Size is left at zero
|
||||||
|
# rather than guessed from the volume delta: CHART_FUTURES replaces the
|
||||||
|
# minute's volume with the exchange's own figure a moment later, and two
|
||||||
|
# ways of counting the same trades is how double counting starts.
|
||||||
|
traded = size is not None or content.get(FIELD_TOTAL_VOLUME) is not None
|
||||||
|
if price is None and not (traded and traded_at is not None):
|
||||||
|
continue
|
||||||
|
millis = traded_at
|
||||||
|
if millis is None:
|
||||||
|
# No trade stamp on this update; the wall clock is close enough to
|
||||||
|
# bucket it, and being one minute out at a boundary is corrected by
|
||||||
|
# the authoritative CHART_FUTURES bar moments later.
|
||||||
|
millis = int(time.time() * 1000)
|
||||||
|
ticks.append((int(millis), None if price is None else float(price), int(size or 0)))
|
||||||
|
return ticks
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chart_futures(message: dict, symbol: str) -> list[Bar]:
|
||||||
|
"""Turn one CHART_FUTURES message into bars.
|
||||||
|
|
||||||
|
A bar arrives once its minute has elapsed, so it is complete on arrival and
|
||||||
|
marked closed. Anything missing a timestamp or a price is skipped rather
|
||||||
|
than defaulted — a bar invented from partial data would be indistinguishable
|
||||||
|
from a real one downstream.
|
||||||
|
"""
|
||||||
|
bars: list[Bar] = []
|
||||||
|
for content in message.get("content") or []:
|
||||||
|
millis = content.get(FIELD_TIME)
|
||||||
|
prices = [content.get(field) for field in (FIELD_OPEN, FIELD_HIGH, FIELD_LOW, FIELD_CLOSE)]
|
||||||
|
if millis is None or any(price is None for price in prices):
|
||||||
|
continue
|
||||||
|
open_, high, low, close = (float(price) for price in prices)
|
||||||
|
bars.append(
|
||||||
|
Bar(
|
||||||
|
tf=Timeframe.M1,
|
||||||
|
t=int(millis) // 1000,
|
||||||
|
o=open_,
|
||||||
|
h=high,
|
||||||
|
l=low,
|
||||||
|
c=close,
|
||||||
|
v=int(content.get(FIELD_VOLUME) or 0),
|
||||||
|
closed=True,
|
||||||
|
symbol=str(content.get("key") or symbol),
|
||||||
|
source="schwab",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return bars
|
||||||
|
|
||||||
|
|
||||||
|
class SchwabSource:
|
||||||
|
"""Live minute bars. Holds no history — see the module docstring."""
|
||||||
|
|
||||||
|
name = "schwab"
|
||||||
|
delay_minutes = 0
|
||||||
|
|
||||||
|
# Access tokens last 30 minutes. The refresh token lasts seven days unless
|
||||||
|
# something uses it — the live socket never does, so a quiet week killed
|
||||||
|
# the feed. Hitting REST on this cadence writes a new refresh token to disk.
|
||||||
|
TOKEN_KEEPALIVE_SECONDS = 6 * 3600
|
||||||
|
|
||||||
|
def __init__(self, settings, stream_client_factory=None):
|
||||||
|
self._settings = settings
|
||||||
|
# Injectable so the parsing and dispatch can be tested without a socket.
|
||||||
|
self._stream_client_factory = stream_client_factory or self._build_stream_client
|
||||||
|
# None disables the Level 1 subscription entirely and leaves the source
|
||||||
|
# exactly as it was: one closed bar a minute.
|
||||||
|
seconds = getattr(settings, "schwab_tick_seconds", 1.0)
|
||||||
|
self._tick_seconds = None if seconds is None or seconds < 0 else seconds
|
||||||
|
self._http = None
|
||||||
|
|
||||||
|
def supports_history(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def history(self, symbol, tf, start, end, *, range_=None) -> list[Bar]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def supports_stream(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def http_client(self):
|
||||||
|
if self._http is None:
|
||||||
|
from schwab.auth import client_from_token_file
|
||||||
|
|
||||||
|
settings = self._settings
|
||||||
|
if not settings.schwab_token_path.exists():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No Schwab token at {settings.schwab_token_path}"
|
||||||
|
)
|
||||||
|
self._http = client_from_token_file(
|
||||||
|
str(settings.schwab_token_path),
|
||||||
|
settings.schwab_api_key,
|
||||||
|
settings.schwab_app_secret,
|
||||||
|
asyncio=True,
|
||||||
|
)
|
||||||
|
return self._http
|
||||||
|
|
||||||
|
def reset_client(self) -> None:
|
||||||
|
self._http = None
|
||||||
|
|
||||||
|
def _build_stream_client(self):
|
||||||
|
from schwab.streaming import StreamClient
|
||||||
|
|
||||||
|
return StreamClient(self.http_client())
|
||||||
|
|
||||||
|
async def refresh_token(self) -> None:
|
||||||
|
await self.http_client().get_user_preferences()
|
||||||
|
|
||||||
|
async def keep_alive(self, interval: float | None = None) -> None:
|
||||||
|
wait = self.TOKEN_KEEPALIVE_SECONDS if interval is None else interval
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
try:
|
||||||
|
await self.refresh_token()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Schwab token keepalive failed")
|
||||||
|
|
||||||
|
async def stream(self, symbol: str) -> AsyncIterator[Bar]:
|
||||||
|
stream_client = self._stream_client_factory()
|
||||||
|
queue: asyncio.Queue[tuple[str, dict]] = asyncio.Queue(maxsize=256)
|
||||||
|
|
||||||
|
def enqueue(kind: str):
|
||||||
|
def handler(message: dict) -> None:
|
||||||
|
# Dropping the oldest keeps a slow consumer from stalling the
|
||||||
|
# socket; a minute bar that late is of no use anyway.
|
||||||
|
if queue.full():
|
||||||
|
queue.get_nowait()
|
||||||
|
queue.put_nowait((kind, message))
|
||||||
|
return handler
|
||||||
|
|
||||||
|
await stream_client.login()
|
||||||
|
# Registered before subscribing: the service starts sending straight
|
||||||
|
# away and messages without a handler are discarded.
|
||||||
|
stream_client.add_chart_futures_handler(enqueue("chart"))
|
||||||
|
await stream_client.chart_futures_subs([symbol])
|
||||||
|
logger.info("Subscribed to CHART_FUTURES for %s", symbol)
|
||||||
|
if self._tick_seconds is not None:
|
||||||
|
# Same socket, same login — no extra REST call and no extra rate
|
||||||
|
# limit. CHART_FUTURES only speaks once a minute, after the minute
|
||||||
|
# is over; this is what makes the candle move in between.
|
||||||
|
stream_client.add_level_one_futures_handler(enqueue("quote"))
|
||||||
|
await stream_client.level_one_futures_subs([symbol])
|
||||||
|
logger.info("Subscribed to LEVEL_ONE_FUTURES for %s", symbol)
|
||||||
|
|
||||||
|
forming: Bar | None = None
|
||||||
|
last_closed_t = 0
|
||||||
|
last_emit = 0.0
|
||||||
|
last_price: float | None = None
|
||||||
|
pump = asyncio.create_task(self._pump(stream_client), name="schwab-stream-pump")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if pump.done():
|
||||||
|
# Surface the socket's failure rather than hanging on a
|
||||||
|
# queue nothing is filling any more.
|
||||||
|
pump.result()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
kind, message = await asyncio.wait_for(queue.get(), timeout=5)
|
||||||
|
except (asyncio.TimeoutError, TimeoutError):
|
||||||
|
continue
|
||||||
|
if kind == "chart":
|
||||||
|
for bar in parse_chart_futures(message, symbol):
|
||||||
|
last_closed_t = max(last_closed_t, bar.t)
|
||||||
|
# The exchange's own bar supersedes whatever the ticks
|
||||||
|
# had built for that minute.
|
||||||
|
if forming is not None and forming.t <= bar.t:
|
||||||
|
forming = None
|
||||||
|
yield bar
|
||||||
|
continue
|
||||||
|
for millis, price, size in parse_level_one(message):
|
||||||
|
if price is None:
|
||||||
|
# A same-price trade. Carry the last price forward so
|
||||||
|
# its volume still lands; without a price to stand on
|
||||||
|
# there is no bar to add it to.
|
||||||
|
price = forming.c if forming is not None else last_price
|
||||||
|
if price is None:
|
||||||
|
continue
|
||||||
|
last_price = price
|
||||||
|
minute = millis // 60000 * 60
|
||||||
|
# A tick for a minute already closed by CHART_FUTURES would
|
||||||
|
# otherwise overwrite an authoritative bar with a partial.
|
||||||
|
if minute <= last_closed_t:
|
||||||
|
continue
|
||||||
|
if forming is None or forming.t != minute:
|
||||||
|
forming = Bar(
|
||||||
|
tf=Timeframe.M1, t=minute, o=price, h=price, l=price, c=price,
|
||||||
|
v=size, closed=False, symbol=symbol, source="schwab",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
forming.h = max(forming.h, price)
|
||||||
|
forming.l = min(forming.l, price)
|
||||||
|
forming.c = price
|
||||||
|
forming.v += size
|
||||||
|
# Throttled: /ES trades many times a second, and every
|
||||||
|
# emission costs a store write and a broadcast to every
|
||||||
|
# open socket.
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_emit >= self._tick_seconds:
|
||||||
|
last_emit = now
|
||||||
|
yield replace(forming)
|
||||||
|
finally:
|
||||||
|
pump.cancel()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _pump(stream_client) -> None:
|
||||||
|
while True:
|
||||||
|
await stream_client.handle_message()
|
||||||
80
app/market/schwab_quotes.py
Normal file
80
app/market/schwab_quotes.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from app.market.es_options import (
|
||||||
|
QUOTE_BATCH,
|
||||||
|
candidate_symbols,
|
||||||
|
search_from_quotes,
|
||||||
|
years_to_expiry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def schwab_rest_client(settings):
|
||||||
|
from schwab.auth import client_from_token_file
|
||||||
|
|
||||||
|
if not settings.schwab_token_path.exists():
|
||||||
|
raise FileNotFoundError(f"No Schwab token at {settings.schwab_token_path}")
|
||||||
|
return client_from_token_file(
|
||||||
|
str(settings.schwab_token_path),
|
||||||
|
settings.schwab_api_key,
|
||||||
|
settings.schwab_app_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_quotes(client, symbols: list[str]) -> dict[str, dict]:
|
||||||
|
quotes: dict[str, dict] = {}
|
||||||
|
for index in range(0, len(symbols), QUOTE_BATCH):
|
||||||
|
payload = client.get_quotes(symbols[index : index + QUOTE_BATCH]).json()
|
||||||
|
for symbol, body in payload.items():
|
||||||
|
if symbol != "errors":
|
||||||
|
quotes[symbol] = body
|
||||||
|
return quotes
|
||||||
|
|
||||||
|
|
||||||
|
def underlying_price(client, symbol: str = "/ES") -> tuple[str, float]:
|
||||||
|
payload = client.get_quotes([symbol]).json()
|
||||||
|
for returned, body in payload.items():
|
||||||
|
if returned == "errors":
|
||||||
|
continue
|
||||||
|
if body.get("assetMainType") != "FUTURE":
|
||||||
|
continue
|
||||||
|
fields = body.get("quote") or {}
|
||||||
|
price = fields.get("mark")
|
||||||
|
if price is None:
|
||||||
|
price = fields.get("lastPrice")
|
||||||
|
if price is None:
|
||||||
|
continue
|
||||||
|
return returned, float(price)
|
||||||
|
raise ValueError("No /ES futures quote")
|
||||||
|
|
||||||
|
|
||||||
|
def run_search(
|
||||||
|
settings,
|
||||||
|
*,
|
||||||
|
day: date,
|
||||||
|
root: str,
|
||||||
|
side: str,
|
||||||
|
mode: str,
|
||||||
|
low: float,
|
||||||
|
high: float,
|
||||||
|
now: datetime | None = None,
|
||||||
|
client=None,
|
||||||
|
) -> dict:
|
||||||
|
rest = client or schwab_rest_client(settings)
|
||||||
|
contract, forward = underlying_price(rest)
|
||||||
|
symbols = candidate_symbols(root, side, forward)
|
||||||
|
quotes = fetch_quotes(rest, symbols)
|
||||||
|
result = search_from_quotes(
|
||||||
|
day=day,
|
||||||
|
side=side,
|
||||||
|
mode=mode,
|
||||||
|
low=low,
|
||||||
|
high=high,
|
||||||
|
forward=forward,
|
||||||
|
quotes=quotes,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
result["underlying"] = contract
|
||||||
|
result["years"] = years_to_expiry(day, now)
|
||||||
|
return result
|
||||||
|
|
@ -14,19 +14,33 @@ class StreamService:
|
||||||
self.source = source
|
self.source = source
|
||||||
self.symbol = symbol
|
self.symbol = symbol
|
||||||
self.status = "disconnected"
|
self.status = "disconnected"
|
||||||
|
self.last_error: str | None = None
|
||||||
self.last_bar_t: int | None = None
|
self.last_bar_t: int | None = None
|
||||||
self._handlers: list[BarHandler] = []
|
self._handlers: list[BarHandler] = []
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
|
self.on_drop = None
|
||||||
|
# Called with (last bar before the outage, first bar after it) when a
|
||||||
|
# new connection opens further past the last bar than this. Reconnect
|
||||||
|
# alone only resumes the present; nothing else fetches what was missed.
|
||||||
|
self.on_resume: Callable[[int, int], None] | None = None
|
||||||
|
self.resume_gap_seconds = 120
|
||||||
|
self.reconnect_seconds = 5.0
|
||||||
|
|
||||||
def add_handler(self, handler: BarHandler) -> None:
|
def add_handler(self, handler: BarHandler) -> None:
|
||||||
self._handlers.append(handler)
|
self._handlers.append(handler)
|
||||||
|
|
||||||
async def seed(
|
async def seed(
|
||||||
self, source: MarketDataSource | None, tf: Timeframe, range_: str
|
self,
|
||||||
|
source: MarketDataSource | None,
|
||||||
|
tf: Timeframe,
|
||||||
|
range_: str,
|
||||||
|
symbol: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# The seed source names the instrument differently from the live one:
|
||||||
|
# Yahoo says ES=F where Schwab says /ES.
|
||||||
if source is None or not source.supports_history():
|
if source is None or not source.supports_history():
|
||||||
return
|
return
|
||||||
bars = await source.history(self.symbol, tf, None, None, range_=range_)
|
bars = await source.history(symbol or self.symbol, tf, None, None, range_=range_)
|
||||||
for bar in bars:
|
for bar in bars:
|
||||||
await self._emit(bar)
|
await self._emit(bar)
|
||||||
|
|
||||||
|
|
@ -37,21 +51,36 @@ class StreamService:
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
|
first = True
|
||||||
try:
|
try:
|
||||||
self.status = "replay" if self.source.name == "replay" else "connected"
|
|
||||||
async for bar in self.source.stream(self.symbol):
|
async for bar in self.source.stream(self.symbol):
|
||||||
|
self.status = "replay" if self.source.name == "replay" else "connected"
|
||||||
|
self.last_error = None
|
||||||
|
before = self.last_bar_t
|
||||||
await self._emit(bar)
|
await self._emit(bar)
|
||||||
|
if first:
|
||||||
|
first = False
|
||||||
|
if (
|
||||||
|
self.on_resume is not None
|
||||||
|
and before is not None
|
||||||
|
and bar.t - before > self.resume_gap_seconds
|
||||||
|
):
|
||||||
|
self.on_resume(before, bar.t)
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
break
|
break
|
||||||
if self.source.name == "replay":
|
if self.source.name == "replay":
|
||||||
return
|
return
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
was_up = self.status == "connected"
|
||||||
|
self.last_error = str(exc)
|
||||||
logger.exception("Market stream failed; reconnecting")
|
logger.exception("Market stream failed; reconnecting")
|
||||||
|
if was_up and self.on_drop:
|
||||||
|
self.on_drop(str(exc))
|
||||||
self.status = "disconnected"
|
self.status = "disconnected"
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._stop.wait(), timeout=5)
|
await asyncio.wait_for(self._stop.wait(), timeout=self.reconnect_seconds)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ from collections.abc import AsyncIterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from app.bars.models import Bar, Timeframe
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.bars.session import bucket_start
|
||||||
|
|
||||||
YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||||
MAX_1M_WINDOW_SECONDS = 8 * 24 * 60 * 60
|
MAX_1M_WINDOW_SECONDS = 8 * 24 * 60 * 60
|
||||||
|
|
@ -28,6 +30,12 @@ def parse_chart(payload: dict[str, Any], tf: Timeframe, source: str = "yahoo") -
|
||||||
t, open_, high, low, close, volume = values
|
t, open_, high, low, close, volume = values
|
||||||
if any(value is None for value in (t, open_, high, low, close)):
|
if any(value is None for value in (t, open_, high, low, close)):
|
||||||
continue
|
continue
|
||||||
|
# Yahoo stamps the in-progress candle with the moment of the request,
|
||||||
|
# not the start of its bucket. Emitted verbatim, every poll produced a
|
||||||
|
# new "1m" bar a few seconds after the last — 04:38:11, 04:38:50,
|
||||||
|
# 04:39:15 — instead of revising the current minute. Bucketing makes the
|
||||||
|
# partial candle land on its own minute, where the store replaces it.
|
||||||
|
t = bucket_start(int(t), tf)
|
||||||
bars.append(
|
bars.append(
|
||||||
Bar(
|
Bar(
|
||||||
tf=tf,
|
tf=tf,
|
||||||
|
|
@ -81,8 +89,8 @@ class YahooSource:
|
||||||
*,
|
*,
|
||||||
range_: str | None = None,
|
range_: str | None = None,
|
||||||
) -> list[Bar]:
|
) -> list[Bar]:
|
||||||
if tf not in (Timeframe.M1, Timeframe.H1):
|
if tf not in (Timeframe.M1, Timeframe.M30, Timeframe.H1):
|
||||||
raise ValueError("YahooSource history supports only 1m and 1h inputs")
|
raise ValueError("YahooSource history supports only 1m, 30m and 1h inputs")
|
||||||
interval = tf.value
|
interval = tf.value
|
||||||
if range_ is not None:
|
if range_ is not None:
|
||||||
return parse_chart(
|
return parse_chart(
|
||||||
|
|
@ -110,8 +118,17 @@ class YahooSource:
|
||||||
last_emitted = -1
|
last_emitted = -1
|
||||||
while True:
|
while True:
|
||||||
bars = await self.history(symbol, Timeframe.M1, range_="1d")
|
bars = await self.history(symbol, Timeframe.M1, range_="1d")
|
||||||
for bar in bars:
|
for index, bar in enumerate(bars):
|
||||||
if bar.t > last_emitted:
|
# The final candle is still forming. Marked unclosed it revises
|
||||||
yield bar
|
# the last bar and the live price without entering the
|
||||||
|
# aggregator, which would otherwise add its volume to every
|
||||||
|
# higher timeframe again on every poll. last_emitted tracks the
|
||||||
|
# newest *settled* bar, so the forming minute is re-sent each
|
||||||
|
# poll and finally sent once more as closed.
|
||||||
|
forming = index == len(bars) - 1
|
||||||
|
if bar.t < last_emitted or (bar.t == last_emitted and not forming):
|
||||||
|
continue
|
||||||
|
yield replace(bar, closed=not forming)
|
||||||
|
if not forming:
|
||||||
last_emitted = bar.t
|
last_emitted = bar.t
|
||||||
await asyncio.sleep(self.poll_seconds)
|
await asyncio.sleep(self.poll_seconds)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ async def send_ntfy(server: str, topic: str, message: str) -> None:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{server.rstrip('/')}/{topic}",
|
f"{server.rstrip('/')}/{topic}",
|
||||||
content=message,
|
content=message,
|
||||||
headers={"Title": "/ES confluence", "Priority": "high", "Tags": "chart_with_upwards_trend"},
|
headers={"Title": "/ESsent", "Priority": "high", "Tags": "chart_with_upwards_trend"},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
|
||||||
539
app/runtime.py
539
app/runtime.py
|
|
@ -1,17 +1,39 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from app.analysis.alerts import Alert, AlertEngine
|
||||||
|
from app.analysis.event_log import EventLog
|
||||||
from app.bars.models import Bar, Timeframe
|
from app.bars.models import Bar, Timeframe
|
||||||
from app.bars.aggregator import Aggregator
|
from app.bars.aggregator import Aggregator
|
||||||
from app.analysis.levels import Level
|
from app.bars.session import bucket_start
|
||||||
|
from app.analysis.bar_space import price_in_bar_space, price_in_timeframe_space
|
||||||
|
from app.analysis.horizontals import build_prior_day_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind
|
||||||
from app.analysis.moving_averages import build_ma_levels
|
from app.analysis.moving_averages import build_ma_levels
|
||||||
|
from app.analysis.vwap import build_vwap_level
|
||||||
from app.analysis.confluence import Cluster, cluster_levels
|
from app.analysis.confluence import Cluster, cluster_levels
|
||||||
from app.analysis.indicators import atr
|
from app.analysis.indicators import atr
|
||||||
from app.analysis.manual_lines import ManualLineStore
|
from app.analysis.manual_lines import ManualLineStore
|
||||||
|
from app.analysis.user_prefs import UserPrefStore
|
||||||
from app.bars.store import InMemoryBarStore
|
from app.bars.store import InMemoryBarStore
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.market.factory import live_source, seed_source
|
from app.market.factory import live_source, seed_source
|
||||||
from app.market.stream import StreamService
|
from app.market.stream import StreamService
|
||||||
|
from app.notify.ntfy import send_ntfy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _range_seconds(range_: str) -> int:
|
||||||
|
"""How far back a Yahoo range reaches: "8d" is eight days."""
|
||||||
|
units = {"d": 86400, "wk": 7 * 86400, "mo": 31 * 86400, "y": 366 * 86400}
|
||||||
|
for suffix, seconds in units.items():
|
||||||
|
if range_.endswith(suffix) and range_[: -len(suffix)].isdigit():
|
||||||
|
return int(range_[: -len(suffix)]) * seconds
|
||||||
|
return 8 * 86400
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -26,32 +48,160 @@ class Runtime:
|
||||||
price: float | None = None
|
price: float | None = None
|
||||||
atr15: float = 0.0
|
atr15: float = 0.0
|
||||||
manual_lines: ManualLineStore = field(init=False)
|
manual_lines: ManualLineStore = field(init=False)
|
||||||
|
user_prefs: UserPrefStore = field(init=False)
|
||||||
ma_levels: list[Level] = field(default_factory=list)
|
ma_levels: list[Level] = field(default_factory=list)
|
||||||
|
alert_engine: AlertEngine = field(init=False)
|
||||||
|
_sent_levels: dict[str, dict] = field(default_factory=dict)
|
||||||
|
_notify_tasks: set[asyncio.Task] = field(default_factory=set)
|
||||||
|
_backfill_tasks: set[asyncio.Task] = field(default_factory=set)
|
||||||
|
# The loop that owns the subscriber queues. Set once the app is running;
|
||||||
|
# None while a test drives the runtime directly.
|
||||||
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
# True while history is being replayed, so on_bar accumulates quietly.
|
||||||
|
seeding: bool = False
|
||||||
|
# Coalescing window for level rebuilds; see request_rebuild.
|
||||||
|
REBUILD_INTERVAL: ClassVar[float] = 0.25
|
||||||
|
_last_rebuild: float = 0.0
|
||||||
|
_rebuild_pending: bool = False
|
||||||
|
# Seconds the loop ran late, worst since start and most recent sample.
|
||||||
|
loop_lag_worst: float = 0.0
|
||||||
|
loop_lag_recent: float = 0.0
|
||||||
|
_lag_task: asyncio.Task | None = None
|
||||||
|
_rebuild_task: asyncio.Task | None = None
|
||||||
|
_token_task: asyncio.Task | None = None
|
||||||
|
schwab_login: object | None = None
|
||||||
|
events: EventLog = field(init=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
||||||
self.aggregator = Aggregator(self.settings.enabled_timeframes)
|
self.aggregator = Aggregator(self.settings.enabled_timeframes)
|
||||||
self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
|
self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
|
||||||
|
self.user_prefs = UserPrefStore(self.settings.user_prefs_path)
|
||||||
|
self.events = EventLog(self.settings.events_path)
|
||||||
|
# One engine for the process, not one per browser connection. Cooldowns
|
||||||
|
# are only meaningful if they outlive a page reload, and a phone push
|
||||||
|
# must not depend on a tab being open to produce it.
|
||||||
|
self.alert_engine = AlertEngine(
|
||||||
|
self.settings.confluence_min_score,
|
||||||
|
self.settings.alert_cooldown_seconds,
|
||||||
|
self.settings.alert_state_path,
|
||||||
|
self.settings.alert_timezone,
|
||||||
|
)
|
||||||
self.levels = self.manual_lines.levels()
|
self.levels = self.manual_lines.levels()
|
||||||
self.stream = StreamService(live_source(self.settings), self.settings.yahoo_symbol)
|
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
|
||||||
self.stream.add_handler(self.on_bar)
|
self.stream.add_handler(self.on_bar)
|
||||||
|
self.stream.on_drop = self._on_stream_drop
|
||||||
|
self.stream.on_resume = self._on_stream_resume
|
||||||
|
|
||||||
async def on_bar(self, bar: Bar) -> None:
|
async def on_bar(self, bar: Bar) -> None:
|
||||||
evaluate_alerts = False
|
# A tick-built bar is provisional and arrives many times a minute. It
|
||||||
|
# updates the last candle and the live price, and stops there.
|
||||||
|
#
|
||||||
|
# It must not reach the aggregator: that accumulates volume with
|
||||||
|
# `current.v += incoming.v`, so re-sending the same forming minute would
|
||||||
|
# add its volume to every higher timeframe again on each update. Alerts
|
||||||
|
# stay on closed bars for the same reason they always were — a level is
|
||||||
|
# judged on a settled bar, not on a price that may not last the minute.
|
||||||
|
if not bar.closed:
|
||||||
|
self.store.put(bar)
|
||||||
|
self.price = bar.c
|
||||||
|
self.broadcast({"type": "bar", "bar": bar})
|
||||||
|
for provisional in self.provisional_higher(bar):
|
||||||
|
self.store.put(provisional)
|
||||||
|
self.broadcast({"type": "bar", "bar": provisional})
|
||||||
|
return
|
||||||
|
|
||||||
|
alert_bar: Bar | None = None
|
||||||
for aggregated in self.aggregator.update(bar):
|
for aggregated in self.aggregator.update(bar):
|
||||||
self.store.put(aggregated)
|
self.store.put(aggregated)
|
||||||
|
if self.seeding:
|
||||||
|
# Seeding replays years of history through this method. Every
|
||||||
|
# bar used to broadcast to nobody and rebuild every level from
|
||||||
|
# scratch, which is the whole of the startup stall — 19.5
|
||||||
|
# seconds of measured loop lag and two minutes before the port
|
||||||
|
# opened. Bars are accumulated here and the levels are built
|
||||||
|
# once, at the end, from the finished store.
|
||||||
|
continue
|
||||||
self.broadcast({"type": "bar", "bar": aggregated})
|
self.broadcast({"type": "bar", "bar": aggregated})
|
||||||
if self.settings.ma_sets.get(aggregated.tf):
|
if self.settings.ma_sets.get(aggregated.tf):
|
||||||
self.rebuild_levels()
|
self.request_rebuild()
|
||||||
if aggregated.tf is Timeframe.M1 and aggregated.closed:
|
if aggregated.tf is Timeframe.M1 and aggregated.closed:
|
||||||
self.price = aggregated.c
|
self.price = aggregated.c
|
||||||
evaluate_alerts = True
|
alert_bar = aggregated
|
||||||
if evaluate_alerts:
|
if alert_bar is not None:
|
||||||
values = atr(self.store.get(Timeframe.M15), 14)
|
values = atr(self.store.get(Timeframe.M15), 14)
|
||||||
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
|
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
|
||||||
self.rebuild_clusters(evaluate_alerts=True)
|
# VWAP re-prices every minute, so levels are rebuilt here too. The
|
||||||
|
# broadcast is a delta, which is what keeps that affordable.
|
||||||
|
self.request_rebuild()
|
||||||
|
self.rebuild_clusters(
|
||||||
|
evaluate_alerts=True,
|
||||||
|
alert_t=alert_bar.t,
|
||||||
|
alert_price=alert_bar.c,
|
||||||
|
)
|
||||||
|
|
||||||
|
def provisional_higher(self, bar: Bar) -> list[Bar]:
|
||||||
|
"""Higher-timeframe bars including the minute still being traded.
|
||||||
|
|
||||||
|
The aggregator cannot be asked for these: it accumulates with
|
||||||
|
``current.v += incoming.v``, so re-feeding the same forming minute on
|
||||||
|
every tick would add its volume to each higher timeframe again and
|
||||||
|
again. Its committed state — every minute that has actually closed — is
|
||||||
|
combined with the live minute here instead, without mutating it. The
|
||||||
|
next closed minute goes through the aggregator normally and replaces
|
||||||
|
what this produced, because the store keys on the bucket's timestamp.
|
||||||
|
"""
|
||||||
|
out: list[Bar] = []
|
||||||
|
for tf in self.settings.enabled_timeframes:
|
||||||
|
if tf is Timeframe.M1:
|
||||||
|
continue
|
||||||
|
start = bucket_start(bar.t, tf)
|
||||||
|
base = self.aggregator.forming.get(tf)
|
||||||
|
if base is None or start > base.t:
|
||||||
|
# The live minute opens a bucket the aggregator has not started.
|
||||||
|
out.append(replace(bar, tf=tf, t=start, closed=False))
|
||||||
|
continue
|
||||||
|
if start < base.t:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
replace(
|
||||||
|
base,
|
||||||
|
h=max(base.h, bar.h),
|
||||||
|
l=min(base.l, bar.l),
|
||||||
|
c=bar.c,
|
||||||
|
v=base.v + bar.v,
|
||||||
|
closed=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
def broadcast(self, event: dict) -> None:
|
def broadcast(self, event: dict) -> None:
|
||||||
|
"""Publish an event to every subscriber, from any thread.
|
||||||
|
|
||||||
|
asyncio.Queue is not thread-safe: it wakes a waiting consumer by
|
||||||
|
resolving a Future, which only the loop thread may do. The mutating
|
||||||
|
routes are sync `def`, so FastAPI runs them in a threadpool, and they
|
||||||
|
reach here through rebuild_levels — writing the queue directly from
|
||||||
|
there can drop a socket's wakeup. The visible symptom is a drawing made
|
||||||
|
in one browser not reaching another until the next market tick, which
|
||||||
|
is why it has gone unnoticed: the stream ticks about once a second and
|
||||||
|
covers it over.
|
||||||
|
"""
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None or self._on_loop_thread(loop):
|
||||||
|
self._publish(event)
|
||||||
|
return
|
||||||
|
loop.call_soon_threadsafe(self._publish, event)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _on_loop_thread(loop: asyncio.AbstractEventLoop) -> bool:
|
||||||
|
try:
|
||||||
|
return asyncio.get_running_loop() is loop
|
||||||
|
except RuntimeError:
|
||||||
|
# No loop in this thread at all, so certainly not that one.
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _publish(self, event: dict) -> None:
|
||||||
for queue in self.subscribers.copy():
|
for queue in self.subscribers.copy():
|
||||||
if queue.full():
|
if queue.full():
|
||||||
queue.get_nowait()
|
queue.get_nowait()
|
||||||
|
|
@ -62,29 +212,378 @@ class Runtime:
|
||||||
{tf: self.store.get(tf) for tf in self.settings.ma_sets},
|
{tf: self.store.get(tf) for tf in self.settings.ma_sets},
|
||||||
self.settings.ma_sets,
|
self.settings.ma_sets,
|
||||||
)
|
)
|
||||||
self.levels = self.ma_levels + self.manual_lines.levels()
|
minute_bars = self.store.get(Timeframe.M1)
|
||||||
self.broadcast({"type": "levels", "levels": self.levels})
|
manual = self.manual_lines.levels()
|
||||||
|
self.position_manual_levels(manual, minute_bars)
|
||||||
|
self.levels = (
|
||||||
|
self.ma_levels
|
||||||
|
+ build_prior_day_levels(self.store.get(Timeframe.D1), self.price)
|
||||||
|
+ build_vwap_level(minute_bars)
|
||||||
|
+ manual
|
||||||
|
)
|
||||||
|
self.broadcast_level_delta()
|
||||||
self.rebuild_clusters()
|
self.rebuild_clusters()
|
||||||
|
|
||||||
def rebuild_clusters(self, evaluate_alerts: bool = False) -> None:
|
def position_manual_levels(
|
||||||
|
self, levels: list[Level], bars: list[Bar], now: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Price sloped lines across bars rather than seconds.
|
||||||
|
|
||||||
|
The chart spaces bars evenly, so the line a person drew advances per bar.
|
||||||
|
Pricing it per second instead put the alert somewhere the line visibly
|
||||||
|
was not — 147 points out across a weekend.
|
||||||
|
"""
|
||||||
|
if not bars:
|
||||||
|
return
|
||||||
|
minute_times = [bar.t for bar in bars]
|
||||||
|
now = minute_times[-1] if now is None else now
|
||||||
|
for level in levels:
|
||||||
|
if not level.slope:
|
||||||
|
continue
|
||||||
|
if not self.settings.trendline_source_geometry:
|
||||||
|
level.current_p = price_in_bar_space(level, minute_times, now)
|
||||||
|
continue
|
||||||
|
source_times = [bar.t for bar in self.store.get(level.tf)]
|
||||||
|
level.current_p = price_in_timeframe_space(level, source_times, level.tf, now)
|
||||||
|
level.geometry_resolved = level.current_p is not None
|
||||||
|
|
||||||
|
def broadcast_level_delta(self) -> None:
|
||||||
|
"""Send only levels whose serialised form actually changed.
|
||||||
|
|
||||||
|
A daily moving average carries hundreds of points and changes once a
|
||||||
|
session; VWAP changes every minute. Broadcasting the whole set on the
|
||||||
|
VWAP cadence would push the entire history every minute, so subscribers
|
||||||
|
get a delta and merge it by id.
|
||||||
|
"""
|
||||||
|
current = {level.id: level.to_dict() for level in self.levels}
|
||||||
|
changed = [value for id_, value in current.items() if self._sent_levels.get(id_) != value]
|
||||||
|
removed = [id_ for id_ in self._sent_levels if id_ not in current]
|
||||||
|
self._sent_levels = current
|
||||||
|
if changed or removed:
|
||||||
|
self.broadcast({"type": "levels", "changed": changed, "removed": removed})
|
||||||
|
|
||||||
|
def rebuild_clusters(
|
||||||
|
self,
|
||||||
|
evaluate_alerts: bool = False,
|
||||||
|
alert_t: int | None = None,
|
||||||
|
alert_price: float | None = None,
|
||||||
|
) -> None:
|
||||||
if self.price is None or self.stream.last_bar_t is None:
|
if self.price is None or self.stream.last_bar_t is None:
|
||||||
return
|
return
|
||||||
self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15)
|
self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15)
|
||||||
self.broadcast(
|
self.broadcast({"type": "clusters", "price": self.price, "clusters": self.clusters})
|
||||||
{
|
if evaluate_alerts:
|
||||||
"type": "clusters",
|
# Evaluated over every level, deliberately ignoring per-connection
|
||||||
"price": self.price,
|
# layer preferences: those are a display choice made in one browser,
|
||||||
"clusters": self.clusters,
|
# and a push notification has no business depending on them.
|
||||||
"evaluate_alerts": evaluate_alerts,
|
evaluation_t = self.stream.last_bar_t if alert_t is None else alert_t
|
||||||
}
|
evaluation_price = self.price if alert_price is None else alert_price
|
||||||
|
alert_levels = [replace(level) for level in self.levels]
|
||||||
|
self.position_manual_levels(
|
||||||
|
alert_levels,
|
||||||
|
self.store.get(Timeframe.M1),
|
||||||
|
evaluation_t,
|
||||||
|
)
|
||||||
|
alert_clusters = cluster_levels(
|
||||||
|
alert_levels,
|
||||||
|
evaluation_t,
|
||||||
|
evaluation_price,
|
||||||
|
self.atr15,
|
||||||
|
)
|
||||||
|
self.dispatch_alerts(
|
||||||
|
self.alert_engine.evaluate(
|
||||||
|
alert_clusters,
|
||||||
|
evaluation_price,
|
||||||
|
self.atr15,
|
||||||
|
evaluation_t,
|
||||||
|
self.stream.symbol,
|
||||||
|
self.watched_ma_levels(),
|
||||||
|
confluence=self.confluence_alerts_enabled(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def confluence_alerts_enabled(self) -> bool:
|
||||||
|
return bool(self.user_prefs.get("confluence_alerts"))
|
||||||
|
|
||||||
|
def set_confluence_alerts(self, enabled: bool) -> dict:
|
||||||
|
self.user_prefs.put("confluence_alerts", bool(enabled))
|
||||||
|
return {"enabled": self.confluence_alerts_enabled()}
|
||||||
|
|
||||||
|
def ma_alerts(self) -> dict:
|
||||||
|
stored = self.user_prefs.get("ma_alerts") or {}
|
||||||
|
return {"1d": list(stored.get("1d") or [])}
|
||||||
|
|
||||||
|
def set_ma_alerts(self, value: dict) -> dict:
|
||||||
|
self.user_prefs.put("ma_alerts", value)
|
||||||
|
return self.ma_alerts()
|
||||||
|
|
||||||
|
def watched_ma_levels(self) -> list[Level]:
|
||||||
|
armed = set(self.ma_alerts().get("1d", []))
|
||||||
|
return [
|
||||||
|
level for level in self.levels
|
||||||
|
if level.kind is LevelKind.MA
|
||||||
|
and level.tf is Timeframe.D1
|
||||||
|
and level.period in armed
|
||||||
|
]
|
||||||
|
|
||||||
|
def _on_stream_drop(self, error: str) -> None:
|
||||||
|
kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream"
|
||||||
|
self.events.add(
|
||||||
|
kind, error.split("\n", 1)[0][:200],
|
||||||
|
symbol=self.settings.profile.schwab_symbol,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _on_stream_resume(self, after: int, before: int) -> None:
|
||||||
|
task = asyncio.create_task(self.backfill_gap(after, before), name="gap-backfill")
|
||||||
|
self._backfill_tasks.add(task)
|
||||||
|
task.add_done_callback(self._backfill_tasks.discard)
|
||||||
|
|
||||||
|
async def backfill_gap(self, after: int, before: int) -> None:
|
||||||
|
"""Fill the history missed while the stream was down.
|
||||||
|
|
||||||
|
The seed runs once, at startup, so an outage between deploys used to
|
||||||
|
stay a hole until the next one — fifteen days of it after a Schwab
|
||||||
|
refresh token expired. Yahoo serves futures about ten minutes late, so
|
||||||
|
the minutes just before reconnect are fetched again once they exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.fill_gap(after, before)
|
||||||
|
delay = getattr(seed_source(self.settings), "delay_minutes", 0) or 0
|
||||||
|
if delay:
|
||||||
|
await asyncio.sleep(delay * 60 + 60)
|
||||||
|
await self.fill_gap(max(after, before - (delay + 5) * 60), before)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# A failed backfill leaves the hole it found; the live stream is fine.
|
||||||
|
logger.exception("Gap backfill failed")
|
||||||
|
|
||||||
|
async def fill_gap(self, after: int, before: int) -> int:
|
||||||
|
source = seed_source(self.settings)
|
||||||
|
if source is None or not source.supports_history():
|
||||||
|
return 0
|
||||||
|
symbol = self.settings.yahoo_symbol
|
||||||
|
now = int(time.time())
|
||||||
|
# Native coarse history first: those buckets are complete, where one
|
||||||
|
# rebuilt from 1m is only as old as Yahoo's 1m reach.
|
||||||
|
passes = (
|
||||||
|
(Timeframe.H1, self.settings.seed_1h_range),
|
||||||
|
(Timeframe.M30, self.settings.seed_30m_range),
|
||||||
|
(Timeframe.M1, self.settings.seed_1m_range),
|
||||||
|
)
|
||||||
|
added = 0
|
||||||
|
for tf, range_ in passes:
|
||||||
|
start = max(bucket_start(after, tf), now - _range_seconds(range_))
|
||||||
|
if start >= before:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
bars = await source.history(symbol, tf, start, before)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Gap backfill: %s history failed", tf.value)
|
||||||
|
continue
|
||||||
|
# A fresh aggregator: the live one is already past the hole, and
|
||||||
|
# feeding it history would reopen buckets it has closed.
|
||||||
|
aggregator = Aggregator(self.settings.enabled_timeframes)
|
||||||
|
derived: dict[tuple[Timeframe, int], Bar] = {}
|
||||||
|
for bar in bars:
|
||||||
|
if not start <= bar.t < before:
|
||||||
|
continue
|
||||||
|
for aggregated in aggregator.update(replace(bar, closed=True)):
|
||||||
|
derived[(aggregated.tf, aggregated.t)] = aggregated
|
||||||
|
added += self.store.fill(list(derived.values()))
|
||||||
|
if added:
|
||||||
|
logger.info("Gap backfill: %d bars between %d and %d", added, after, before)
|
||||||
|
self.refold_forming(before)
|
||||||
|
values = atr(self.store.get(Timeframe.M15), 14)
|
||||||
|
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
|
||||||
|
# Levels only, never alerts: a touch during the outage is not news.
|
||||||
|
self.rebuild_levels()
|
||||||
|
self.broadcast({"type": "resync"})
|
||||||
|
return added
|
||||||
|
|
||||||
|
def refold_forming(self, before: int) -> None:
|
||||||
|
"""Rebuild the live buckets that opened before the stream came back.
|
||||||
|
|
||||||
|
The live aggregator started today's daily bar — and the current hour —
|
||||||
|
from the first minute after reconnect, so its open, high and low
|
||||||
|
ignore everything the backfill just recovered. Refolding from the stored
|
||||||
|
minutes is idempotent, so the delayed second pass can run it again.
|
||||||
|
"""
|
||||||
|
minutes = [bar for bar in self.store.get(Timeframe.M1) if bar.closed]
|
||||||
|
for tf, forming in self.aggregator.forming.items():
|
||||||
|
if forming.t >= before:
|
||||||
|
continue
|
||||||
|
inside = [bar for bar in minutes if bucket_start(bar.t, tf) == forming.t]
|
||||||
|
if not inside or inside[0].t >= before:
|
||||||
|
continue
|
||||||
|
forming.o = inside[0].o
|
||||||
|
forming.h = max(bar.h for bar in inside)
|
||||||
|
forming.l = min(bar.l for bar in inside)
|
||||||
|
forming.c = inside[-1].c
|
||||||
|
forming.v = sum(bar.v for bar in inside)
|
||||||
|
self.store.put(replace(forming))
|
||||||
|
|
||||||
|
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
||||||
|
tripped: set[str] = set()
|
||||||
|
for alert in alerts:
|
||||||
|
self.events.add(
|
||||||
|
"alert", alert.message, number=alert.number, at=alert.at,
|
||||||
|
symbol=self.settings.profile.schwab_symbol,
|
||||||
|
)
|
||||||
|
self.broadcast({
|
||||||
|
"type": "alert",
|
||||||
|
"cluster": alert.cluster,
|
||||||
|
"message": alert.message,
|
||||||
|
# Same number the push carries, so a phone and a screen agree.
|
||||||
|
"number": alert.number,
|
||||||
|
"at": alert.at,
|
||||||
|
})
|
||||||
|
task = asyncio.create_task(self.notify(alert.push or alert.message))
|
||||||
|
# Held so the task is not garbage collected mid-flight.
|
||||||
|
self._notify_tasks.add(task)
|
||||||
|
task.add_done_callback(self._notify_tasks.discard)
|
||||||
|
tripped.update(alert.tripped)
|
||||||
|
if tripped:
|
||||||
|
self.disarm(tripped)
|
||||||
|
|
||||||
|
def disarm(self, ids: set[str]) -> None:
|
||||||
|
"""A hand-placed level fires once, then waits to be re-armed."""
|
||||||
|
changed = False
|
||||||
|
for line_id in ids:
|
||||||
|
try:
|
||||||
|
self.manual_lines.update(line_id, {"armed": False})
|
||||||
|
changed = True
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
# Rebuilt after the loop, not inside it: rebuild_levels re-enters
|
||||||
|
# rebuild_clusters, and doing that mid-dispatch would rewrite the very
|
||||||
|
# clusters being iterated.
|
||||||
|
if changed:
|
||||||
|
self.rebuild_levels()
|
||||||
|
|
||||||
|
async def notify(self, message: str) -> None:
|
||||||
|
try:
|
||||||
|
await send_ntfy(self.settings.ntfy_server, self.settings.ntfy_topic, message)
|
||||||
|
except Exception:
|
||||||
|
# A push outage must not take down the stream or the sockets.
|
||||||
|
logger.warning("ntfy delivery failed", exc_info=True)
|
||||||
|
|
||||||
|
def request_rebuild(self) -> None:
|
||||||
|
"""Rebuild levels soon, at most REBUILD_INTERVAL apart.
|
||||||
|
|
||||||
|
A rebuild costs a pass over every moving average and a diff of their
|
||||||
|
points, so doing one per bar is fine at one bar a minute and ruinous in
|
||||||
|
a burst. Yahoo's first poll emits a whole day of minutes at once, which
|
||||||
|
measured as twenty seconds of loop lag. Coalescing turns that into a
|
||||||
|
handful of rebuilds without changing what subscribers eventually see:
|
||||||
|
the pending flag guarantees a trailing rebuild, so the last bar of a
|
||||||
|
burst is never the one that gets dropped.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_rebuild >= self.REBUILD_INTERVAL:
|
||||||
|
self._last_rebuild = now
|
||||||
|
self._rebuild_pending = False
|
||||||
|
self.rebuild_levels()
|
||||||
|
return
|
||||||
|
self._rebuild_pending = True
|
||||||
|
|
||||||
|
async def rebuild_watch(self, interval: float = 0.25) -> None:
|
||||||
|
"""Flush a rebuild that request_rebuild deferred during a burst."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
if self._rebuild_pending:
|
||||||
|
self._rebuild_pending = False
|
||||||
|
self._last_rebuild = time.monotonic()
|
||||||
|
self.rebuild_levels()
|
||||||
|
|
||||||
|
def settle_after_seed(self) -> None:
|
||||||
|
"""Derive everything the replay deliberately skipped, once.
|
||||||
|
|
||||||
|
on_bar normally maintains price, ATR and the level set as each bar
|
||||||
|
arrives. During a seed it only fills the store, so the same state is
|
||||||
|
computed here from the finished history — one pass instead of one per
|
||||||
|
bar. Alerts are not evaluated: a level touched two years ago is not news,
|
||||||
|
and firing on replayed history is how a deploy used to re-alert.
|
||||||
|
"""
|
||||||
|
minute_bars = self.store.get(Timeframe.M1)
|
||||||
|
if minute_bars:
|
||||||
|
self.price = minute_bars[-1].c
|
||||||
|
values = atr(self.store.get(Timeframe.M15), 14)
|
||||||
|
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
|
||||||
|
self.rebuild_levels()
|
||||||
|
|
||||||
|
async def loop_lag_watch(self, interval: float = 0.1) -> None:
|
||||||
|
"""Measure how late the event loop is running its own timers.
|
||||||
|
|
||||||
|
The loop is single-threaded and everything shares it: the market
|
||||||
|
stream, every WebSocket, and any CPU work that has strayed onto it.
|
||||||
|
When something blocks, the symptom reaching a person is "the chart
|
||||||
|
feels laggy" — unfalsifiable. This turns it into a number.
|
||||||
|
|
||||||
|
Scheduling drift is the honest measure: sleep for a known interval and
|
||||||
|
see how much longer it actually took.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
before = time.perf_counter()
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
lag = (time.perf_counter() - before) - interval
|
||||||
|
if lag > self.loop_lag_worst:
|
||||||
|
self.loop_lag_worst = lag
|
||||||
|
self.loop_lag_recent = lag
|
||||||
|
|
||||||
async def start(self) -> asyncio.Task:
|
async def start(self) -> asyncio.Task:
|
||||||
|
# Captured here so a threadpool route can post events back to the loop
|
||||||
|
# that owns the queues, rather than touching them across threads.
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self.seeding = True
|
||||||
try:
|
try:
|
||||||
source = seed_source(self.settings)
|
source = seed_source(self.settings)
|
||||||
await self.stream.seed(source, Timeframe.H1, self.settings.seed_1h_range)
|
# Always the Yahoo symbol: Schwab has no history to seed from.
|
||||||
await self.stream.seed(source, Timeframe.M1, self.settings.seed_1m_range)
|
seed_symbol = self.settings.yahoo_symbol
|
||||||
|
await self.stream.seed(
|
||||||
|
source, Timeframe.H1, self.settings.seed_1h_range, seed_symbol
|
||||||
|
)
|
||||||
|
# One-hour history cannot reconstruct a 30-minute candle. Yahoo
|
||||||
|
# supplies roughly 60 days natively; the newer overlap is replaced
|
||||||
|
# below by bars aggregated from the finer 1m seed.
|
||||||
|
await self.stream.seed(
|
||||||
|
source, Timeframe.M30, self.settings.seed_30m_range, seed_symbol
|
||||||
|
)
|
||||||
|
await self.stream.seed(
|
||||||
|
source, Timeframe.M1, self.settings.seed_1m_range, seed_symbol
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# A transient seed failure must not prevent the live stream or UI starting.
|
# A transient seed failure must not prevent the live stream or UI starting.
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
self.seeding = False
|
||||||
|
self.settle_after_seed()
|
||||||
|
self._lag_task = asyncio.create_task(self.loop_lag_watch(), name="loop-lag")
|
||||||
|
self._rebuild_task = asyncio.create_task(self.rebuild_watch(), name="level-rebuild")
|
||||||
|
keep_alive = getattr(self.stream.source, "keep_alive", None)
|
||||||
|
if keep_alive is not None:
|
||||||
|
self._token_task = asyncio.create_task(keep_alive(), name="token-keepalive")
|
||||||
return asyncio.create_task(self.stream.run(), name="market-stream")
|
return asyncio.create_task(self.stream.run(), name="market-stream")
|
||||||
|
|
||||||
|
def needs_login(self) -> bool:
|
||||||
|
error = self.stream.last_error or ""
|
||||||
|
return "invalid_grant" in error or "Refresh token" in error
|
||||||
|
|
||||||
|
def start_schwab_login(self) -> str:
|
||||||
|
from app.api.schwab_auth import begin_login
|
||||||
|
|
||||||
|
context = begin_login(self.settings)
|
||||||
|
self.schwab_login = context
|
||||||
|
return context.authorization_url
|
||||||
|
|
||||||
|
def finish_schwab_login(self, redirect_url: str) -> None:
|
||||||
|
from app.api.schwab_auth import complete_login
|
||||||
|
|
||||||
|
context = self.schwab_login
|
||||||
|
if context is None:
|
||||||
|
raise RuntimeError("No login in progress")
|
||||||
|
complete_login(self.settings, context, redirect_url)
|
||||||
|
self.schwab_login = None
|
||||||
|
reset = getattr(self.stream.source, "reset_client", None)
|
||||||
|
if reset is not None:
|
||||||
|
reset()
|
||||||
|
|
|
||||||
40
bin/e2e
Executable file
40
bin/e2e
Executable file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the end-to-end tests against the local dev stack.
|
||||||
|
#
|
||||||
|
# bin/e2e # every test
|
||||||
|
# bin/e2e trendline # only files matching a name
|
||||||
|
#
|
||||||
|
# They run inside the `playwright` compose service, which already has
|
||||||
|
# Playwright and a browser. Nothing is installed into this repo — NODE_PATH
|
||||||
|
# points Node at the container's global modules, and tests/e2e resolves it
|
||||||
|
# through createRequire because ESM ignores NODE_PATH.
|
||||||
|
#
|
||||||
|
# These drive the real app against the real feed. They create drawings and
|
||||||
|
# delete them again afterwards; the dev stack shares one drawing store with
|
||||||
|
# whoever else is looking at it.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
FILTER=${1:-}
|
||||||
|
COMPOSE=${COMPOSE:-docker compose}
|
||||||
|
|
||||||
|
if ! $COMPOSE ps --status running --services 2>/dev/null | grep -qx playwright; then
|
||||||
|
echo "the playwright service is not running — start it with: $COMPOSE up -d" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$FILTER" ]; then
|
||||||
|
FILES=$(ls tests/e2e/*"$FILTER"*.test.mjs 2>/dev/null)
|
||||||
|
if [ -z "$FILES" ]; then
|
||||||
|
echo "no e2e test files match '$FILTER'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
FILES=$(ls tests/e2e/*.test.mjs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The app is reachable as http://api:8000 from inside the compose network.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
$COMPOSE exec -T \
|
||||||
|
-e NODE_PATH=/usr/lib/node_modules \
|
||||||
|
-e E2E_URL="${E2E_URL:-http://api:8000/}" \
|
||||||
|
playwright node --test --test-concurrency=1 $(echo $FILES | sed 's#tests/e2e/#/repo/tests/e2e/#g')
|
||||||
|
|
@ -25,3 +25,5 @@ services:
|
||||||
- LANG=en_US.UTF-8
|
- LANG=en_US.UTF-8
|
||||||
volumes:
|
volumes:
|
||||||
- ./artifacts/playwright:/artifacts
|
- ./artifacts/playwright:/artifacts
|
||||||
|
# The e2e suite runs in here; the app is http://api:8000 on this network.
|
||||||
|
- ./tests/e2e:/repo/tests/e2e:ro
|
||||||
|
|
|
||||||
121
docs/NEXT_STEPS.md
Normal file
121
docs/NEXT_STEPS.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Current recommendations
|
||||||
|
|
||||||
|
Last reviewed: 2026-08-15 00:00 CDT.
|
||||||
|
|
||||||
|
This file is the short list of work worth considering next. Verified history,
|
||||||
|
measurements and completed work remain in `docs/implementation.md`, and the
|
||||||
|
decisions behind them in `docs/plan.md`.
|
||||||
|
|
||||||
|
## Fix next
|
||||||
|
|
||||||
|
### Revisit the trendline live-edge bend with painted-pixel evidence
|
||||||
|
|
||||||
|
Mid-session 1m tape holes are now counted in source index as well as on the
|
||||||
|
time scale. Production still appears to show a bend where 30m manual lines
|
||||||
|
cross from the last real candle into future whitespace. The deployed code is verified to use
|
||||||
|
one canvas `LineSeries`, retained transparent time-scale points, and canonical
|
||||||
|
source-space prices. Diagnostic values can report unequal historical/future
|
||||||
|
screen slopes when the final candles are sparse, but synthetic browser coverage
|
||||||
|
passes after gap slots are inserted. The transparent-point production fix did
|
||||||
|
not visibly settle the report, so do not add another geometry patch from the
|
||||||
|
current theory.
|
||||||
|
|
||||||
|
Painted-pixel diagnostics are now implemented: diagnostic mode samples the
|
||||||
|
internal canvases around each join by the line's RGB colour and reports painted
|
||||||
|
historical/future slopes beside canonical and API-coordinate slopes. A browser
|
||||||
|
regression confirms actual canvas pixels stay continuous in the deterministic
|
||||||
|
sparse-tail case. The remaining step is a production capture with those painted
|
||||||
|
values; do not change geometry again until it says whether production pixels or
|
||||||
|
production coordinates diverge.
|
||||||
|
|
||||||
|
### Load bars from the visible window
|
||||||
|
|
||||||
|
The 1m chart stopping at ~1am was a leftover `get(tf, 1000)` on the
|
||||||
|
WebSocket snapshot. The store already held 5,000. The socket now sends
|
||||||
|
everything in the store (`MAX_BARS_PER_TF`). That is the immediate fix,
|
||||||
|
not the destination.
|
||||||
|
|
||||||
|
Next: send the visible window and fetch older bars when the time scale
|
||||||
|
hits the left edge. Do not put another silent numeric cap on a payload.
|
||||||
|
How much the client gets is either “what is on screen” or the named
|
||||||
|
store limit — never a bare `1000` in a socket handler.
|
||||||
|
|
||||||
|
### Deepen freshness telemetry
|
||||||
|
|
||||||
|
The status bar now reports when the browser received its latest snapshot or bar.
|
||||||
|
A later server-side implementation can distinguish market closure, source delay
|
||||||
|
and transport failure by carrying exact trade time, the last settled minute,
|
||||||
|
source receipt time and an application heartbeat over the existing WebSocket.
|
||||||
|
Any stale threshold must account for Yahoo's declared delay.
|
||||||
|
|
||||||
|
## Light / dark themes
|
||||||
|
|
||||||
|
Do not invert the cream palette. Chrome tokens flip cheaply; stored drawing
|
||||||
|
hexes and the dark palette stops do not. The constraints are in
|
||||||
|
`docs/plan_light_dark_themes.md`.
|
||||||
|
|
||||||
|
## Frontend build
|
||||||
|
|
||||||
|
The CDN-to-Vite plan is in `docs/vite_build.md`. First tranche is a production
|
||||||
|
`Dockerfile` that reproduces today's nixpacks image, so a later `package.json`
|
||||||
|
cannot make Coolify treat this as a Node app. Do not start the file move until
|
||||||
|
that deploy has been seen live.
|
||||||
|
|
||||||
|
## Mobile authoring
|
||||||
|
|
||||||
|
The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche:
|
||||||
|
|
||||||
|
1. Add touch-sized invisible hit regions without enlarging the visual marks.
|
||||||
|
2. Keep the first tap's trendline anchor visible with price/time, Cancel and
|
||||||
|
Undo anchor.
|
||||||
|
3. Add a compact sticky mobile tool rail next to the chart.
|
||||||
|
4. Add a selected-drawing action bar so End here and Delete do not require
|
||||||
|
right-click or a hardware keyboard.
|
||||||
|
|
||||||
|
Prefer tap-tap trendlines on touch devices. Reserve one-finger vertical movement
|
||||||
|
for page scrolling, retain horizontal chart panning and pinch zoom, and offer a
|
||||||
|
chart-focused landscape/fullscreen mode.
|
||||||
|
|
||||||
|
## Market-data alternatives
|
||||||
|
|
||||||
|
No durable unauthenticated source of free real-time CME `/ES` data has been
|
||||||
|
identified. CME real-time access is normally broker-subsidized and tied to an
|
||||||
|
authenticated exchange entitlement, not a public free API.
|
||||||
|
|
||||||
|
Practical ranking:
|
||||||
|
|
||||||
|
1. Keep Schwab for verified entitled real-time streaming and Yahoo for delayed
|
||||||
|
development/history seeding.
|
||||||
|
2. Pilot Tastytrade/dxLink on a live futures-approved account. Confirm actual
|
||||||
|
delay, candle retention, continuous/root symbol behavior and server-side-use
|
||||||
|
terms before integrating it.
|
||||||
|
3. Consider IBKR as the strongest low-cost fallback if literal zero cost is not
|
||||||
|
required; resolve and roll the active contract explicitly for live data.
|
||||||
|
4. Consider TradeStation only if its account-funding/API-access requirements are
|
||||||
|
already acceptable.
|
||||||
|
5. Evaluate Nasdaq Data Link CHRIS only for continuous daily seeding after
|
||||||
|
confirming current freshness and free-key availability.
|
||||||
|
|
||||||
|
Do not build a backend around scraped TradingView, CME, Barchart, Investing.com,
|
||||||
|
MarketWatch or Stooq pages. Public display access is not a supported market-data
|
||||||
|
API or a CME redistribution license.
|
||||||
|
|
||||||
|
References:
|
||||||
|
|
||||||
|
- Tastytrade streaming: https://developer.tastytrade.com/streaming-market-data/
|
||||||
|
- IBKR market data: https://www.interactivebrokers.com/en/pricing/research-news-marketdata.php
|
||||||
|
- TradeStation API: https://www.tradestation.com/platforms-and-tools/trading-api/
|
||||||
|
- Nasdaq Data Link API: https://docs.data.nasdaq.com/
|
||||||
|
|
||||||
|
## Deferred test tied to production work
|
||||||
|
|
||||||
|
When startup seeding is changed to bulk-load bars, add an integration test that
|
||||||
|
asserts expensive level rebuilding happens once and that final stores/levels
|
||||||
|
match incremental ingestion. Do not assert a wall-clock duration and do not add
|
||||||
|
a test that merely codifies today's slow startup.
|
||||||
|
|
||||||
|
## Test and deployment commands
|
||||||
|
|
||||||
|
README is authoritative for local pytest, local Playwright E2E, pre-deploy and
|
||||||
|
production smoke-test commands. Browser E2E creates and deletes drawings, so it
|
||||||
|
must remain pointed at the local stack rather than production.
|
||||||
16
docs/archived/README.md
Normal file
16
docs/archived/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Archived documents
|
||||||
|
|
||||||
|
Superseded designs and plans, kept because the reasoning is often still useful
|
||||||
|
and the history is worth more than the disk space.
|
||||||
|
|
||||||
|
**Agents: skip this directory unless you are asked about it, or you are tracing
|
||||||
|
why a decision was made.** Nothing here describes how the app currently works.
|
||||||
|
|
||||||
|
These stay tracked in git rather than gitignored — ignoring them would delete
|
||||||
|
them from the repository, which loses exactly the history that makes them worth
|
||||||
|
keeping.
|
||||||
|
|
||||||
|
When archiving something, add a line here saying what replaced it.
|
||||||
|
|
||||||
|
_(Empty so far. Nothing has genuinely died yet: `feature_undo.md` and
|
||||||
|
`mobile_enhance.md` are designs not yet built, and `NEXT_STEPS.md` is current.)_
|
||||||
78
docs/archived/color_refactor.md
Normal file
78
docs/archived/color_refactor.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Drawing color refactor
|
||||||
|
|
||||||
|
Source discussion: [Color Coded Trendlines Tips](https://chatgpt.com/share/6a7e6fc3-5dd0-83ea-9d66-5e6f3f6d96e7).
|
||||||
|
|
||||||
|
This document records the useful design work from that conversation and the
|
||||||
|
final decision made afterwards. The picker is a grouped palette, not an
|
||||||
|
unordered bag of colors: six hue families, four deliberately separated
|
||||||
|
lightness variants per family, arranged as three contrasting row pairs.
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
The rows are kept in three high-contrast pairs:
|
||||||
|
|
||||||
|
1. green / red
|
||||||
|
2. blue / orange
|
||||||
|
3. teal / purple
|
||||||
|
|
||||||
|
The application assigns no timeframe, direction, drawing kind, or other
|
||||||
|
semantic meaning to a pair. Persisted names are only color names (`green1`,
|
||||||
|
`red3`, `blue2`). Users can apply any convention without fighting an encoded
|
||||||
|
mapping or changing drawing identity later.
|
||||||
|
|
||||||
|
## Palette
|
||||||
|
|
||||||
|
Columns run from `1` (lightest) to `4` (darkest). The larger-than-usual
|
||||||
|
lightness steps are intentional: adjacent variants need to remain distinct on a
|
||||||
|
dense chart, not merely look harmonious in a design swatch.
|
||||||
|
|
||||||
|
| Family | 1 | 2 | 3 | 4 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| green | `green1 (#A6D8AA)` | `green2 (#4DB155)` | `green3 (#258F33)` | `green4 (#006D09)` |
|
||||||
|
| red | `red1 (#FAADBC)` | `red2 (#F45B78)` | `red3 (#D13F62)` | `red4 (#AF2850)` |
|
||||||
|
| blue | `blue1 (#A3CCFF)` | `blue2 (#4699FE)` | `blue3 (#1E76D8)` | `blue4 (#0054B3)` |
|
||||||
|
| orange | `orange1 (#F8C592)` | `orange2 (#F08A24)` | `orange3 (#D66B12)` | `orange4 (#B94E08)` |
|
||||||
|
| teal | `teal1 (#80D8D8)` | `teal2 (#00B0B1)` | `teal3 (#008E8F)` | `teal4 (#006C6E)` |
|
||||||
|
| purple | `purple1 (#DDBCEB)` | `purple2 (#BB79D7)` | `purple3 (#9858B3)` | `purple4 (#763790)` |
|
||||||
|
|
||||||
|
## Picker behavior
|
||||||
|
|
||||||
|
- Render six rows of four swatches in the table order above.
|
||||||
|
- Keep each contrasting pair adjacent.
|
||||||
|
- Add a small visual break between the three pairs.
|
||||||
|
- Show stable name and exact value together wherever a name appears, for
|
||||||
|
example `blue1 (#4699FE)` in tooltips and accessible labels.
|
||||||
|
- Keep the native color wheel for arbitrary values and Cancel on a final row.
|
||||||
|
- Existing persisted colors outside this palette remain valid and appear as
|
||||||
|
`custom (#RRGGBB)`; there is no migration or recoloring of saved drawings.
|
||||||
|
- Each family row has an optional user annotation stored in `localStorage`.
|
||||||
|
Blank annotations show the family name. Annotations carry user-defined
|
||||||
|
meaning only and never alter color names or drawing data.
|
||||||
|
- Hovering a drawing on the chart shows its drawing label, stable color name
|
||||||
|
and hex, and the optional row annotation on separate lines. Custom colors
|
||||||
|
omit the annotation because they do not belong to a palette row.
|
||||||
|
|
||||||
|
`localStorage` is intentionally temporary, not the server persistence design.
|
||||||
|
When cross-device preference sync is built, these annotations move into the
|
||||||
|
user-keyed, namespaced JSON preference store described in `multi_user.md` under
|
||||||
|
`drawing_palette`. They must not become a global file or dedicated columns per
|
||||||
|
family; adding future palette preferences should require no database schema
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Notes from the source discussion
|
||||||
|
|
||||||
|
The source also considered family labels, larger standalone swatches, a detail
|
||||||
|
card with RGB values, categorical auto-color presets, and semantic labels baked
|
||||||
|
into color names. Those are intentionally not part of this compact sidebar
|
||||||
|
picker. Color-family names are the durable API; user-defined meaning is not.
|
||||||
|
|
||||||
|
Green and teal, especially their darkest variants, were identified as the most
|
||||||
|
likely perceptual collision. They are retained because the final requirement is
|
||||||
|
three complete contrasting pairs; the pair spacing and four strong lightness
|
||||||
|
steps are therefore functional, not decorative.
|
||||||
|
|
||||||
|
The final in-app values drop the darkest source column from every family. The
|
||||||
|
original shades 1-3 move to positions 2-4, and each new shade 1 is a solid 50%
|
||||||
|
tint of the original shade 1 toward white. At the picker's small swatch size,
|
||||||
|
the darkest colors lost their hue and made families hard to distinguish;
|
||||||
|
numbered names allow this range shift without changing the palette API.
|
||||||
71
docs/archived/esquotes.md
Normal file
71
docs/archived/esquotes.md
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# /ES futures-options quote finder
|
||||||
|
|
||||||
|
**Status:** first version shipped. Sidebar **Options** is a lazy-load snapshot
|
||||||
|
finder. It does not stream, poll, or place orders.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Find `/ES` futures options faster than the thinkorswim chain UI: pick a nearby
|
||||||
|
expiration, filter by approximate delta or mark, list the matches, and copy a
|
||||||
|
thinkorswim contract string. The user reviews and enters every order in
|
||||||
|
thinkorswim.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
Same sidebar level as Tools and Drawings. Closed by default.
|
||||||
|
|
||||||
|
- **Until opened:** no calendar work, no HTTP, no Schwab.
|
||||||
|
- **On first open:** `GET /api/es-options/expirations` (calendar only).
|
||||||
|
- **On Search/Refresh only:** `GET /api/es-options/search` → batched
|
||||||
|
`get_quotes()`.
|
||||||
|
|
||||||
|
Controls: next 3 dailies, next Friday weekly, next monthly; Puts/Calls (default
|
||||||
|
Puts); Delta or Price from/to; Search. Results show strike, mark, **Δ ≈**, and
|
||||||
|
Copy. Last expiration, side, mode, and ranges persist in `localStorage`.
|
||||||
|
|
||||||
|
## Schwab API status
|
||||||
|
|
||||||
|
Checked against the configured live credentials on 2026-08-14. Read-only.
|
||||||
|
|
||||||
|
| Capability | Result | Implication |
|
||||||
|
|---|---|---|
|
||||||
|
| OAuth token and Schwab client | Available | The app already authenticates and streams `/ES`. |
|
||||||
|
| `get_quotes(["/ES"])` | Works: `/ESU26`, `assetMainType: FUTURE` | Use the plural quote endpoint. |
|
||||||
|
| `get_option_expiration_chain("/ES")` | Works, but only four standard `ES` monthlies | Not used. Dailies/weeklies are built from a calendar. |
|
||||||
|
| `get_option_chain("/ES")` / `("/ESU26")` | HTTP 400 | No chain discovery. |
|
||||||
|
| `get_quotes(["./E3AQ26P7780:XCME"])` | HTTP 200 + `errors.invalidSymbols` | TOS text is not the REST symbol. |
|
||||||
|
| `get_quotes(["./E3AQ26P7780"])` | `FUTURE_OPTION`; description is the TOS form | REST symbol = TOS text without `:XCME`. |
|
||||||
|
| Futures-option quote fields | bid, ask, mark, last, volume, OI; **no Greeks** | Delta is Black-76, labeled approximate. |
|
||||||
|
| Weekday 5-point grids | 81/81 valid for `EW2`, `E3A`, `E3B`, `EW3` | Daily/weekly search uses a ±200 / 5-point grid. |
|
||||||
|
| Monthly `ESU26` | 18/81 at 5-point; 25-point strikes quote | Monthly `ES` roots use a ±600 / 25-point grid. |
|
||||||
|
| `LEVEL_ONE_FUTURES_OPTIONS` | Not used | Snapshots only. |
|
||||||
|
| History / order entry | Not available | Current quotes; execute in thinkorswim. |
|
||||||
|
|
||||||
|
## Symbol construction
|
||||||
|
|
||||||
|
Verified August 2026 weekday roots:
|
||||||
|
|
||||||
|
| Weekday | Root | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Mon | `E{n}A` | `E3AQ26` = Aug 17 |
|
||||||
|
| Tue | `E{n}B` | `E3BQ26` = Aug 18 |
|
||||||
|
| Wed | `E{n}C` | `E3CQ26` = Aug 19 |
|
||||||
|
| Thu | `E{n}D` | `E3DQ26` = Aug 20 |
|
||||||
|
| Fri | `EW{n}` | `EW2Q26` = Aug 14 |
|
||||||
|
| Quarterly monthly | `ES{month}{yy}` | `ESU26` = Sep 18 |
|
||||||
|
| Serial monthly | that 3rd Friday's `EW{n}` | `EW3Q26` = Aug 21 |
|
||||||
|
|
||||||
|
API: `./{root}{C\|P}{strike}`. TOS copy: that string plus `:XCME`.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `app/market/es_options.py` — calendar, symbols, Black-76, filters. No I/O.
|
||||||
|
- `app/market/schwab_quotes.py` — REST `get_quotes` via the existing token.
|
||||||
|
- `GET /api/es-options/expirations` and `GET /api/es-options/search`
|
||||||
|
- Search runs in `asyncio.to_thread` so Schwab I/O does not block the loop.
|
||||||
|
- IV is implied from the ATM mid; |Δ| is computed. Label **Δ ≈**.
|
||||||
|
- Broker code stays in `app/market/`. No stream subscription.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Spreads, streaming, prefetch on page load, order entry.
|
||||||
217
docs/async_refactor.md
Normal file
217
docs/async_refactor.md
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
# Async refactor — findings, priorities, and how to keep it that way
|
||||||
|
|
||||||
|
**Status: P0, P1 and the loop-lag probe are done (2026-08-11). P2 and P3 outstanding.** To be implemented once the in-flight chart
|
||||||
|
work has landed. Everything below is from reading the code on 2026-08-11 and
|
||||||
|
measuring the running app; each finding names the path it was found on.
|
||||||
|
|
||||||
|
## The goal is not "more async"
|
||||||
|
|
||||||
|
The target is **nothing blocks the event loop**, which is not the same thing as
|
||||||
|
converting everything to `async def`. Getting this backwards would make the app
|
||||||
|
worse, so state it plainly:
|
||||||
|
|
||||||
|
- FastAPI runs a **sync `def` route in a threadpool**. Blocking work inside one
|
||||||
|
never touches the loop. That is protection, not a defect.
|
||||||
|
- Converting those routes to `async def` *removes* the protection: any blocking
|
||||||
|
call inside then stalls the market stream and every WebSocket.
|
||||||
|
- So the rule is per-function. A handler that only awaits should be `async def`.
|
||||||
|
A handler doing blocking or CPU work should stay `def` — **and must not touch
|
||||||
|
loop-owned objects** (see P0).
|
||||||
|
|
||||||
|
## What is already right
|
||||||
|
|
||||||
|
- Every outbound HTTP call is async: `httpx.AsyncClient` in `notify/ntfy.py` and
|
||||||
|
`market/yahoo.py`; the Schwab `StreamClient` is built with `asyncio=True`.
|
||||||
|
- The market stream is an `asyncio.Task` owned by the app lifespan, and the
|
||||||
|
WebSocket endpoint is a coroutine.
|
||||||
|
- `ManualLineStore` guards itself with a `threading.RLock`, which is the correct
|
||||||
|
primitive precisely because both threadpool routes and the loop reach it. Do
|
||||||
|
not "modernise" it to `asyncio.Lock` — that would protect only one of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Cross-thread access to `asyncio.Queue` (correctness) — DONE
|
||||||
|
|
||||||
|
Sync route handlers reach loop-owned objects from a worker thread:
|
||||||
|
|
||||||
|
```
|
||||||
|
create_line / create_price_alert / create_comment / patch_line / delete_line (sync def, threadpool)
|
||||||
|
-> runtime.rebuild_levels()
|
||||||
|
-> broadcast_level_delta() -> broadcast()
|
||||||
|
-> queue.put_nowait(event) # asyncio.Queue, owned by the loop
|
||||||
|
```
|
||||||
|
|
||||||
|
`asyncio.Queue` is not thread-safe. It wakes a waiting consumer by setting a
|
||||||
|
Future's result, and Futures must be resolved on the loop thread — from another
|
||||||
|
thread that requires `loop.call_soon_threadsafe`. Writing directly can drop the
|
||||||
|
wakeup or corrupt internal state.
|
||||||
|
|
||||||
|
**Why nobody has noticed:** the market stream broadcasts roughly once a second,
|
||||||
|
so a dropped wakeup is papered over by the next event almost immediately. The
|
||||||
|
visible symptom would be a drawing made in one browser not appearing in another
|
||||||
|
until the next tick — easy to misread as network lag.
|
||||||
|
|
||||||
|
**Fix.** Give `Runtime` the loop it belongs to and post from the correct thread:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._loop = asyncio.get_running_loop() # captured in start()
|
||||||
|
|
||||||
|
def broadcast(self, event: dict) -> None:
|
||||||
|
if threading.current_thread() is threading.main_thread() and self._loop.is_running():
|
||||||
|
self._publish(event) # already on the loop
|
||||||
|
else:
|
||||||
|
self._loop.call_soon_threadsafe(self._publish, event)
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer this over making the routes `async def`: that would move level rebuilding
|
||||||
|
(P1) onto the loop, trading a rare correctness bug for a guaranteed latency one.
|
||||||
|
|
||||||
|
**Verify.** A test that calls a mutating route through `TestClient` while a
|
||||||
|
WebSocket subscriber waits, asserting the event arrives without another tick
|
||||||
|
intervening. Today that passes by luck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 — Level rebuilding is CPU-bound on the loop (latency) — DONE
|
||||||
|
|
||||||
|
Measured on the dev stack, Yahoo source:
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| port accepting connections | 121s | 4s |
|
||||||
|
| worst loop lag | 19,545ms | 526ms |
|
||||||
|
| steady-state lag | — | 0.2–0.6ms |
|
||||||
|
|
||||||
|
Two changes did it. Seeding now accumulates into the store and derives price,
|
||||||
|
ATR and the level set **once** at the end (`settle_after_seed`) instead of
|
||||||
|
rebuilding per replayed bar. And `request_rebuild` coalesces rebuilds to at most
|
||||||
|
one per 250ms with a guaranteed trailing pass, which matters because Yahoo's
|
||||||
|
first poll emits a whole day of minutes in one burst — that burst, not the seed,
|
||||||
|
was most of the remaining 20 seconds. Bar counts, all five daily MAs, prior-day
|
||||||
|
levels and VWAP are unchanged; 125 python tests and 31 e2e tests pass.
|
||||||
|
|
||||||
|
Still open from the original list: incremental moving averages, and diffing
|
||||||
|
levels by fingerprint rather than by re-serialising every point. Neither is
|
||||||
|
needed while lag sits under a second.
|
||||||
|
|
||||||
|
### Original analysis
|
||||||
|
|
||||||
|
`rebuild_levels()` recomputes all five daily moving averages and re-serialises
|
||||||
|
their points to diff them, on every closed bar. Measured consequence: the seed
|
||||||
|
replay runs the same path per bar and takes **~82 seconds**, during which the
|
||||||
|
port is closed. In steady state it is once a minute, which is survivable but is
|
||||||
|
the largest single thing the loop does.
|
||||||
|
|
||||||
|
Threads do not help — it is genuine CPU under the GIL. The fix is algorithmic:
|
||||||
|
|
||||||
|
1. **Incremental moving averages.** `indicators.sma` is already a rolling sum;
|
||||||
|
the waste is recomputing every window from scratch each rebuild rather than
|
||||||
|
advancing the last one.
|
||||||
|
2. **Bulk seeding.** Load seeded bars into the store directly and rebuild levels
|
||||||
|
**once** at the end, rather than replaying each bar through `on_bar`.
|
||||||
|
3. **Diff without re-serialising.** `broadcast_level_delta()` compares
|
||||||
|
`to_dict()` output including hundreds of points per MA. Compare a cheap
|
||||||
|
fingerprint (last point plus length) and serialise only what changed.
|
||||||
|
|
||||||
|
Do (2) first — it is contained, testable, and removes most of the 82 seconds.
|
||||||
|
|
||||||
|
**Verify.** A test asserting a seed of N bars completes under a threshold, and a
|
||||||
|
loop-lag probe (below) staying under ~50ms while a bar closes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 — Blocking disk write on the loop (small, real)
|
||||||
|
|
||||||
|
```
|
||||||
|
on_bar (coroutine)
|
||||||
|
-> rebuild_clusters(evaluate_alerts=True) -> dispatch_alerts() -> disarm()
|
||||||
|
-> manual_lines.update() -> save() # write_text + atomic replace
|
||||||
|
```
|
||||||
|
|
||||||
|
A ~1KB write, usually sub-millisecond, but it lands on the loop at the exact
|
||||||
|
moment an alert fires, and it is unbounded on a contended disk.
|
||||||
|
|
||||||
|
**Fix.** Either `await asyncio.to_thread(self.manual_lines.update, ...)` on that
|
||||||
|
path, or mark the line disarmed in memory and flush outside the tick. The same
|
||||||
|
applies to any future persistence work — see the cold-restart notes, which will
|
||||||
|
add far more writing than this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3 — Seeding blocks startup (architectural)
|
||||||
|
|
||||||
|
`Runtime.start()` awaits both seeds before uvicorn binds, so the port refuses
|
||||||
|
connections for the whole ~82 seconds and any open browser logs a wall of
|
||||||
|
`ERR_CONNECTION_REFUSED`. Fixing P1(2) may reduce this enough on its own. If it
|
||||||
|
does not, seed in a background task and serve immediately — but note that
|
||||||
|
changes what `/api/status`'s `warm` flags mean to every consumer, so it needs
|
||||||
|
its own thought rather than being bolted on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicitly not doing
|
||||||
|
|
||||||
|
- **Converting sync routes to `async def`.** They do in-memory work behind a
|
||||||
|
threadpool hop, which is correct and cheap. Changing them adds risk for no
|
||||||
|
gain, and would drag P1's CPU cost onto the loop.
|
||||||
|
- **`asyncio.Lock` in `ManualLineStore`.** It is reached from both the loop and
|
||||||
|
threadpool threads; only a threading primitive covers both.
|
||||||
|
- **Multiple uvicorn workers as a performance fix.** See `Procfile` — a second
|
||||||
|
process opens a second Schwab stream and duplicates every alert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keeping it this way
|
||||||
|
|
||||||
|
Findings decay unless something enforces them. Three layers, cheapest first.
|
||||||
|
|
||||||
|
### 1. Rules where agents actually read them
|
||||||
|
|
||||||
|
`AGENTS.md` is loaded automatically by both Claude Code (via the `CLAUDE.md`
|
||||||
|
symlink) and OpenCode every session; nothing else in the repo is guaranteed to
|
||||||
|
be read. Add a short **Async rules** section stating:
|
||||||
|
|
||||||
|
- Nothing blocking or CPU-heavy runs on the event loop.
|
||||||
|
- Sync `def` routes stay sync; they run in a threadpool by design.
|
||||||
|
- A threadpool thread must never touch `asyncio` objects directly — post through
|
||||||
|
`loop.call_soon_threadsafe`.
|
||||||
|
- `ManualLineStore`'s lock is a threading lock deliberately.
|
||||||
|
|
||||||
|
Keep it to a handful of lines. A long section is skimmed; a short one is read.
|
||||||
|
|
||||||
|
### 2. Comments at the point of danger
|
||||||
|
|
||||||
|
A rule in a document does not stop an edit; a comment on the line does. Already
|
||||||
|
done for the worker count in `Procfile`. Add the same at:
|
||||||
|
|
||||||
|
- `Runtime.broadcast` — why it posts through the loop.
|
||||||
|
- `ManualLineStore._lock` — why it is a threading lock.
|
||||||
|
- Each mutating route — why it is `def` and not `async def`.
|
||||||
|
|
||||||
|
### 3. Make a regression visible
|
||||||
|
|
||||||
|
- **Loop-lag probe.** DONE — `Runtime.loop_lag_watch` samples 100ms scheduling
|
||||||
|
drift and `/api/status` reports `loop_lag_ms`. It found P1 on its first run:
|
||||||
|
19,441ms worst at startup against 1.5ms in steady state. A stall then shows up as a
|
||||||
|
number instead of as "the chart feels laggy". This is the single highest-value
|
||||||
|
addition here, and it costs about ten lines.
|
||||||
|
- **`loop.set_debug(True)` in dev**, which logs any callback over 100ms with a
|
||||||
|
traceback — it would have named P1 immediately.
|
||||||
|
- **A seed-duration test**, so P1 cannot silently regress once fixed.
|
||||||
|
|
||||||
|
### 4. Where the record lives
|
||||||
|
|
||||||
|
The risk register in `plan.md` §15 gets one row per finding, so a
|
||||||
|
reader looking for known hazards finds them. This file holds the detail; the
|
||||||
|
register holds the pointer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order
|
||||||
|
|
||||||
|
1. **P0** — correctness, small, self-contained.
|
||||||
|
2. **Loop-lag probe** — so P1's improvement is measurable rather than asserted.
|
||||||
|
3. **P1(2)** bulk seeding, then P1(1) and P1(3) if the probe still shows stalls.
|
||||||
|
4. **P2** — trivial once P0 has established how work leaves the loop.
|
||||||
|
5. **P3** — only if P1 leaves the startup window unacceptable.
|
||||||
|
6. Docs and comments alongside each change, not as a final sweep.
|
||||||
354
docs/feature_undo.md
Normal file
354
docs/feature_undo.md
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
# Undo and Redo
|
||||||
|
|
||||||
|
Status: proposed. An in-session client stack (Ctrl/Cmd+Z and an undo
|
||||||
|
button) plus `POST /api/lines/restore` shipped as an interim so delete
|
||||||
|
undo keeps the same id and number. Redo and the command journal are still
|
||||||
|
unbuilt.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add reliable Undo and Redo for user-authored drawings without silently
|
||||||
|
overwriting newer changes, changing drawing identity, or allowing REST and
|
||||||
|
WebSocket ordering to corrupt the result.
|
||||||
|
|
||||||
|
The durable design is a server-authoritative drawing command journal with a
|
||||||
|
frontend command stack. This is deliberately smaller than full event sourcing:
|
||||||
|
current drawing state remains directly persisted, while a bounded journal keeps
|
||||||
|
the before/after snapshots needed to reverse accepted user commands.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Undoable persistent operations:
|
||||||
|
|
||||||
|
- create and duplicate a trendline;
|
||||||
|
- move a whole trendline or either anchor;
|
||||||
|
- end a trendline at a cutoff;
|
||||||
|
- rename, recolor, resize, arm, or re-arm a drawing;
|
||||||
|
- create or edit a typed price level;
|
||||||
|
- create, edit, recolor, pin, float, move, or delete a comment;
|
||||||
|
- delete one drawing, a selection, or all drawings matching a filter.
|
||||||
|
|
||||||
|
Transient behavior:
|
||||||
|
|
||||||
|
- If a trendline has a pending first anchor, Undo cancels that anchor before it
|
||||||
|
touches persistent history. This is not written to the command journal.
|
||||||
|
- Escape should cancel a pending anchor/tool/context menu without creating an
|
||||||
|
undo entry.
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
|
||||||
|
- MA, VWAP, prior-day and other derived levels;
|
||||||
|
- chart viewport, timeframe and layer preferences;
|
||||||
|
- selection and expanded/collapsed sidebar sections;
|
||||||
|
- automatic alert disarming performed by the runtime;
|
||||||
|
- notifications that have already been sent.
|
||||||
|
|
||||||
|
Comment collapse/expand should initially remain outside command history. It is
|
||||||
|
persisted display state, but including every toggle would make useful history
|
||||||
|
hard to reach.
|
||||||
|
|
||||||
|
## Why Frontend-Only Undo Is Insufficient
|
||||||
|
|
||||||
|
The frontend knows which gesture occurred, but the current CRUD endpoints do
|
||||||
|
not provide enough guarantees to invert it safely:
|
||||||
|
|
||||||
|
- Recreating a deleted drawing through a create endpoint produces a new ID,
|
||||||
|
number and creation time. IDs also affect cluster identity.
|
||||||
|
- `ManualLineStore` has no revisions, command IDs, transactions across multiple
|
||||||
|
drawings, or history.
|
||||||
|
- Drawing numbers are derived from the current maximum and can be reused after
|
||||||
|
deleting the highest-numbered drawing.
|
||||||
|
- REST responses and WebSocket level deltas can arrive in either order.
|
||||||
|
- Comments are fetched separately and are not synchronized through WebSocket
|
||||||
|
drawing deltas.
|
||||||
|
- Optimistic drag/delete failures currently have no general rollback path.
|
||||||
|
- A stale inverse from one tab could overwrite a newer edit from another tab.
|
||||||
|
- Filtered bulk deletion is a series of requests and can partially succeed.
|
||||||
|
|
||||||
|
Undo therefore needs one atomic backend command boundary. The frontend remains
|
||||||
|
responsible for interaction, labels and optimistic rendering, but the server
|
||||||
|
decides whether a command or inverse is still valid.
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
1. Undo reverses the most recent accepted command made by the current browser
|
||||||
|
actor, not blindly the most recent global mutation.
|
||||||
|
2. Redo reapplies the command that Undo reversed.
|
||||||
|
3. Any new forward command clears that actor's redo stack.
|
||||||
|
4. Undo and Redo preserve every persisted drawing field, including ID, drawing
|
||||||
|
number, creation time, geometry, cutoff, style, comment state and `armed`.
|
||||||
|
5. A completed drag is one command, regardless of how many pointer-move frames
|
||||||
|
were rendered.
|
||||||
|
6. A multi-selection or filtered deletion is one atomic command and one history
|
||||||
|
entry.
|
||||||
|
7. Undo conflicts rather than overwriting a drawing changed by another command
|
||||||
|
after the target command completed.
|
||||||
|
8. Undo restores drawing state only. It cannot retract a browser/ntfy alert or
|
||||||
|
reverse historical market evaluation.
|
||||||
|
9. History is initially bounded and may be cleared by a server restart. Durable
|
||||||
|
cross-deploy history belongs with the eventual SQLite persistence stage.
|
||||||
|
|
||||||
|
Suggested initial limits are 100 commands or 24 hours, whichever is reached
|
||||||
|
first. Limits should be configuration, not part of command correctness.
|
||||||
|
|
||||||
|
## Command Model
|
||||||
|
|
||||||
|
Each accepted mutation produces a command record:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DrawingCommand
|
||||||
|
command_id client-generated UUID used for idempotency
|
||||||
|
actor_id stable random ID for one browser profile
|
||||||
|
action create, patch, delete, bulk_delete, undo, redo
|
||||||
|
target_ids affected drawing IDs
|
||||||
|
expected target revisions supplied by the client
|
||||||
|
before complete ManualLine snapshots before mutation
|
||||||
|
after complete ManualLine snapshots after mutation
|
||||||
|
store_revision monotonically increasing drawing-store revision
|
||||||
|
created_at
|
||||||
|
undone_by inverse command ID or null
|
||||||
|
redone_by redo command ID or null
|
||||||
|
```
|
||||||
|
|
||||||
|
The complete snapshot is the persisted `ManualLine` state:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id, tf, side, anchor_t, anchor_p, slope, last_t, created_at,
|
||||||
|
note, hidden, color, line_width, number, cutoff_t, armed,
|
||||||
|
kind, pinned, x, y, collapsed, revision
|
||||||
|
```
|
||||||
|
|
||||||
|
The server must assign a monotonically increasing drawing number from persisted
|
||||||
|
store metadata. It must not recalculate the next number from only the currently
|
||||||
|
existing drawings.
|
||||||
|
|
||||||
|
### Revisions and Conflicts
|
||||||
|
|
||||||
|
Each drawing receives a revision. A command that changes existing drawings
|
||||||
|
includes their expected revisions. The backend rejects the complete command
|
||||||
|
with `409 Conflict` if any expected revision is stale.
|
||||||
|
|
||||||
|
Undo carries the revisions produced by the original command. If a later command
|
||||||
|
changed one of those drawings, Undo is rejected rather than restoring a stale
|
||||||
|
full snapshot. The UI should retain the failed command in history and explain
|
||||||
|
that a newer change prevents undoing it.
|
||||||
|
|
||||||
|
### Idempotency
|
||||||
|
|
||||||
|
Create and retry behavior must use `command_id`. Repeating an accepted command
|
||||||
|
returns its original receipt instead of creating a second drawing. This matters
|
||||||
|
when persistence succeeds but a response, rebuild, or network connection fails.
|
||||||
|
|
||||||
|
## Backend Architecture
|
||||||
|
|
||||||
|
Introduce a `DrawingService` above `ManualLineStore`. All drawing create, patch,
|
||||||
|
delete, restore and batch operations go through it.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
1. Acquire the drawing-store lock.
|
||||||
|
2. Reject a duplicate `command_id` or return its existing receipt.
|
||||||
|
3. Validate expected revisions.
|
||||||
|
4. Capture complete `before` snapshots.
|
||||||
|
5. Build and validate the complete proposed `after` state.
|
||||||
|
6. Apply all affected records atomically and save once.
|
||||||
|
7. Append the command record and increment the store revision.
|
||||||
|
8. Rebuild levels once when level-bearing drawings changed.
|
||||||
|
9. Broadcast one drawing delta and one resulting cluster update.
|
||||||
|
10. Return the same command receipt used by WebSocket reconciliation.
|
||||||
|
|
||||||
|
The JSON implementation can write drawing state, store metadata and the bounded
|
||||||
|
journal together through the existing temporary-file-and-replace pattern. A
|
||||||
|
later SQLite implementation should preserve the service and API contracts while
|
||||||
|
moving state and journal writes into one database transaction.
|
||||||
|
|
||||||
|
### Required Persistence Corrections
|
||||||
|
|
||||||
|
- Add explicit restore/upsert that preserves ID, number and creation time.
|
||||||
|
- Persist `next_number`, drawing revisions and store revision.
|
||||||
|
- Support atomic multi-record mutation and one save.
|
||||||
|
- Change PATCH processing from `exclude_none=True` to `exclude_unset=True` so
|
||||||
|
an inverse can explicitly restore `cutoff_t` to `null`.
|
||||||
|
- Validate complete resulting geometry, not only supplied PATCH fields.
|
||||||
|
- Keep comments excluded from levels, clustering and alert evaluation.
|
||||||
|
|
||||||
|
## API Shape
|
||||||
|
|
||||||
|
Exact route names can follow the existing API style, but mutation responses
|
||||||
|
need a common command receipt.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command_id": "uuid",
|
||||||
|
"action": "move",
|
||||||
|
"store_revision": 42,
|
||||||
|
"changed": [{ "id": "ml_...", "revision": 8 }],
|
||||||
|
"removed": [],
|
||||||
|
"undo_label": "Move up 2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended operations:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/drawing-commands execute create/patch/delete/batch
|
||||||
|
POST /api/drawing-commands/{id}/undo atomically apply the inverse
|
||||||
|
POST /api/drawing-commands/{id}/redo atomically reapply the result
|
||||||
|
GET /api/drawing-commands?actor_id= recover bounded own history
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing drawing routes can initially become adapters that call
|
||||||
|
`DrawingService`, but new frontend work should use the command endpoint so every
|
||||||
|
mutation has idempotency, revisions and a receipt.
|
||||||
|
|
||||||
|
## WebSocket Synchronization
|
||||||
|
|
||||||
|
Replace line-only assumptions with a drawing delta that covers trendlines,
|
||||||
|
price levels and comments:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "drawings",
|
||||||
|
"seq": 42,
|
||||||
|
"command_id": "uuid",
|
||||||
|
"actor_id": "browser-id",
|
||||||
|
"changed": ["complete drawing DTOs"],
|
||||||
|
"removed": ["ml_..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The initial snapshot should include all drawings or the current drawing
|
||||||
|
revision plus a required refetch. Every drawing delta has a monotonically
|
||||||
|
increasing sequence. If a client detects a gap, it refetches a complete drawing
|
||||||
|
snapshot instead of applying uncertain incremental state.
|
||||||
|
|
||||||
|
The frontend reconciles REST acknowledgement and WebSocket delivery by
|
||||||
|
`command_id`; receiving both must not apply a command twice. Drawing removal
|
||||||
|
also clears nonexistent IDs from single and multi-selection state.
|
||||||
|
|
||||||
|
## Frontend Command Manager
|
||||||
|
|
||||||
|
Add one command manager in `static/app.js` rather than separate undo logic in
|
||||||
|
each control:
|
||||||
|
|
||||||
|
```text
|
||||||
|
execute(command, optimisticProjection)
|
||||||
|
undo()
|
||||||
|
redo()
|
||||||
|
canUndo
|
||||||
|
canRedo
|
||||||
|
undoLabel
|
||||||
|
redoLabel
|
||||||
|
```
|
||||||
|
|
||||||
|
Execution flow:
|
||||||
|
|
||||||
|
1. Capture the relevant current revisions and before state.
|
||||||
|
2. Apply an optimistic projection when useful for interaction.
|
||||||
|
3. Send a command with a UUID and actor ID.
|
||||||
|
4. Commit it to the local Undo stack only after server acknowledgement.
|
||||||
|
5. Roll back the optimistic projection on failure.
|
||||||
|
6. Reconcile the matching WebSocket command receipt.
|
||||||
|
|
||||||
|
Commands should serialize per drawing. Completed drag operations should
|
||||||
|
coalesce into one PATCH/command on pointer release; pointer movement remains
|
||||||
|
local rendering only. Repeated style changes may be coalesced later, but are not
|
||||||
|
required for the first release.
|
||||||
|
|
||||||
|
## Controls and Shortcuts
|
||||||
|
|
||||||
|
- `Ctrl+Z` and `Cmd+Z`: Undo.
|
||||||
|
- `Ctrl+Shift+Z` and `Cmd+Shift+Z`: Redo.
|
||||||
|
- `Ctrl+Y`: Redo on platforms where it is conventional.
|
||||||
|
- Keep the existing input/editing guard so text fields retain native Undo.
|
||||||
|
- If a first trendline anchor is pending, Undo cancels it first.
|
||||||
|
- Call `preventDefault()` only when the chart actually handled the shortcut.
|
||||||
|
- Add visible Undo and Redo buttons with labels/tooltips such as
|
||||||
|
`Undo move up 2`; keyboard shortcuts cannot be the only mobile access.
|
||||||
|
- Disable controls while an Undo/Redo request is pending.
|
||||||
|
|
||||||
|
Selection itself is not undoable. Undoing deletion may select the single
|
||||||
|
restored drawing, but batch restoration should leave selection empty unless a
|
||||||
|
clear product rule is chosen.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
- Network/server failure: restore the optimistic before state and show a visible
|
||||||
|
error; do not add a history entry.
|
||||||
|
- Revision conflict: refetch drawings, preserve the failed history entry, and
|
||||||
|
explain that a newer change prevents Undo.
|
||||||
|
- Missed WebSocket sequence: refetch the complete drawing snapshot.
|
||||||
|
- Partial bulk failure: impossible by contract; the whole command commits or
|
||||||
|
none of it does.
|
||||||
|
- Rebuild failure after persistence: return the stored idempotent receipt on
|
||||||
|
retry and recover derived levels from authoritative drawing state.
|
||||||
|
- Session expiration: authentication may retry, but the same `command_id` must
|
||||||
|
be reused so a create cannot duplicate.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
### Stage 1: Reliable Command Boundary
|
||||||
|
|
||||||
|
- Add `DrawingService`, revisions, stable restore, monotonic numbering,
|
||||||
|
idempotent command IDs and atomic batches.
|
||||||
|
- Route all drawing mutations through it.
|
||||||
|
- Return common command receipts.
|
||||||
|
- Add in-memory/bounded server journal and frontend Undo/Redo stacks.
|
||||||
|
- Implement create, patch, move, delete and bulk-delete inverses.
|
||||||
|
- Add shortcuts and visible controls.
|
||||||
|
|
||||||
|
Stage 1 history may clear on restart, but every command during the process
|
||||||
|
lifetime must be safe and conflict-aware.
|
||||||
|
|
||||||
|
### Stage 2: Complete Multi-Client Synchronization
|
||||||
|
|
||||||
|
- Broadcast full drawing deltas, including comments.
|
||||||
|
- Add sequence-gap recovery and complete drawing snapshots.
|
||||||
|
- Reconcile REST and WebSocket acknowledgements by command ID.
|
||||||
|
- Clean stale selection state after remote mutations.
|
||||||
|
|
||||||
|
### Stage 3: Durable History
|
||||||
|
|
||||||
|
- Move drawing state and the bounded command journal into SQLite.
|
||||||
|
- Preserve command/API contracts.
|
||||||
|
- Retain history by count/time policy across restart and deployment.
|
||||||
|
- Add audit-only, non-undoable records for automatic runtime changes if useful.
|
||||||
|
|
||||||
|
## Tests That Earn Their Place
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
1. Undo delete restores every field with the same ID and drawing number.
|
||||||
|
2. Undo end-here restores `cutoff_t` to `null`.
|
||||||
|
3. Bulk delete is all-or-nothing and saves/rebuilds/broadcasts once.
|
||||||
|
4. Retrying one `command_id` cannot create a second drawing.
|
||||||
|
5. Undo rejects a stale revision after another actor edits the drawing.
|
||||||
|
6. Redo restores the exact accepted result; a new command clears redo.
|
||||||
|
7. Comment commands synchronize but never enter levels or confluence.
|
||||||
|
8. Automatic alert disarm does not enter user Undo history.
|
||||||
|
|
||||||
|
Browser:
|
||||||
|
|
||||||
|
1. Create, Undo and Redo preserve the drawing ID.
|
||||||
|
2. Whole-line and anchor drags Undo to exact prior geometry.
|
||||||
|
3. Single and filtered batch deletion restore the complete set.
|
||||||
|
4. Pending first anchor consumes Undo before persistent history.
|
||||||
|
5. Undo inside a text field remains native text editing.
|
||||||
|
6. A conflict displays an error and does not overwrite newer state.
|
||||||
|
7. A second tab observes drawing and Undo deltas, including comments.
|
||||||
|
8. Touch-accessible controls expose the same command labels and states.
|
||||||
|
|
||||||
|
## Decisions to Confirm Before Implementation
|
||||||
|
|
||||||
|
- Whether bounded history should survive a normal server restart in Stage 1 or
|
||||||
|
wait for SQLite in Stage 3.
|
||||||
|
- Whether persisted comment collapse/expand should remain excluded.
|
||||||
|
- Whether undoing one deleted drawing should select the restored drawing.
|
||||||
|
- The initial history count/time limits.
|
||||||
|
- Whether actor identity remains per browser profile or later becomes the
|
||||||
|
authenticated user ID. The command model supports either.
|
||||||
|
|
||||||
|
The first implementation task is the atomic, revisioned `DrawingService`, not
|
||||||
|
the Undo button. Building the controls first would make deletion restoration,
|
||||||
|
bulk actions and cross-tab edits appear to work while retaining silent data-loss
|
||||||
|
races.
|
||||||
1458
docs/implementation.md
Normal file
1458
docs/implementation.md
Normal file
File diff suppressed because it is too large
Load diff
205
docs/install_debug_charts.md
Normal file
205
docs/install_debug_charts.md
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
# Install Restricted Production Chart Diagnostics
|
||||||
|
|
||||||
|
This handoff is for the agent administering the Coolify Docker host for
|
||||||
|
`chart.amow.com`.
|
||||||
|
|
||||||
|
The implementation files are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ops/chart-debug-command
|
||||||
|
ops/install-chart-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the installer on the Coolify Docker **host** as root, not inside the chart
|
||||||
|
application container.
|
||||||
|
|
||||||
|
## Dedicated key
|
||||||
|
|
||||||
|
Install this public key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAdYzYs9RJ9Az9ypyYhrKsqdfbdv0EbjOGQVox+UyZBz chart-debug restricted production diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected fingerprint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SHA256:i2VPhUqgN9gHcta27aH6HV8d5YMvKABQ1EXworhnjGw
|
||||||
|
```
|
||||||
|
|
||||||
|
The private key must never be copied to production or pasted into chat. It stays
|
||||||
|
on the diagnostics client at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/chris/.ssh/chart_debug_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security requirements
|
||||||
|
|
||||||
|
- Do not add `chart-debug` to the Docker group.
|
||||||
|
- Do not enable password authentication for the account.
|
||||||
|
- Do not add an unrestricted SSH key.
|
||||||
|
- Do not grant a normal production shell.
|
||||||
|
- Keep the wrapper, configuration and sudoers file root-owned.
|
||||||
|
- Keep the account password locked.
|
||||||
|
- Preserve the `restrict` and forced-command options in `authorized_keys`.
|
||||||
|
- Verify arbitrary commands and malformed arguments are denied.
|
||||||
|
- Do not print container environment variables or application secrets.
|
||||||
|
- Redact OAuth `code`, `session`, and `state` query parameters from access logs.
|
||||||
|
- Ensure `status` passes its token to curl through stdin, not an argv `-H`
|
||||||
|
argument visible through `/proc`.
|
||||||
|
- Resolve the container into a variable before Docker calls so selector failure
|
||||||
|
propagates with the documented return code.
|
||||||
|
|
||||||
|
The account may run only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logs --since DURATION
|
||||||
|
status
|
||||||
|
container-state
|
||||||
|
recent-deploy
|
||||||
|
capture-read CAPTURE_ID
|
||||||
|
capture-delete CAPTURE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Identify the chart container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The Coolify resource UUID is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dgvch0xqv8uvjfor7dl8bwl9
|
||||||
|
```
|
||||||
|
|
||||||
|
A likely stable selector is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
^dgvch0xqv8uvjfor7dl8bwl9
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not assume it. Verify the regex matches exactly one running chart container
|
||||||
|
and will continue matching after a Coolify redeploy.
|
||||||
|
|
||||||
|
### 2. Run the installer
|
||||||
|
|
||||||
|
From a checkout containing `ops/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./ops/install-chart-debug \
|
||||||
|
--public-key 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAdYzYs9RJ9Az9ypyYhrKsqdfbdv0EbjOGQVox+UyZBz chart-debug restricted production diagnostics' \
|
||||||
|
--container-pattern '^VERIFIED-STABLE-PREFIX' \
|
||||||
|
--url https://chart.amow.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verify account and file security
|
||||||
|
|
||||||
|
```bash
|
||||||
|
passwd -S chart-debug
|
||||||
|
|
||||||
|
stat -c '%U:%G %a %n' \
|
||||||
|
/usr/local/sbin/chart-debug-command \
|
||||||
|
/etc/chart-debug.conf \
|
||||||
|
/etc/sudoers.d/chart-debug \
|
||||||
|
/home/chart-debug/.ssh/authorized_keys
|
||||||
|
|
||||||
|
visudo -cf /etc/sudoers.d/chart-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected permissions:
|
||||||
|
|
||||||
|
| Path | Owner | Mode |
|
||||||
|
|---|---|---:|
|
||||||
|
| `/usr/local/sbin/chart-debug-command` | `root:root` | `755` |
|
||||||
|
| `/etc/chart-debug.conf` | `root:root` | `600` |
|
||||||
|
| `/etc/sudoers.d/chart-debug` | `root:root` | `440` |
|
||||||
|
| `/home/chart-debug/.ssh/authorized_keys` | `chart-debug:chart-debug` | `600` |
|
||||||
|
|
||||||
|
The account status must show a locked password.
|
||||||
|
|
||||||
|
### 4. Test allowed commands locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u chart-debug sudo -n \
|
||||||
|
/usr/local/sbin/chart-debug-command 'container-state'
|
||||||
|
|
||||||
|
sudo -u chart-debug sudo -n \
|
||||||
|
/usr/local/sbin/chart-debug-command 'logs --since 5m'
|
||||||
|
|
||||||
|
sudo -u chart-debug sudo -n \
|
||||||
|
/usr/local/sbin/chart-debug-command 'status'
|
||||||
|
|
||||||
|
sudo -u chart-debug sudo -n \
|
||||||
|
/usr/local/sbin/chart-debug-command 'recent-deploy'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify denial behavior
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if sudo -u chart-debug sudo -n \
|
||||||
|
/usr/local/sbin/chart-debug-command 'shell'
|
||||||
|
then
|
||||||
|
echo 'ERROR: arbitrary command was allowed'
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo 'arbitrary command correctly denied'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Also verify these fail closed:
|
||||||
|
|
||||||
|
- Invalid durations.
|
||||||
|
- Extra arguments.
|
||||||
|
- Invalid capture IDs.
|
||||||
|
- A container selector matching zero containers.
|
||||||
|
- A container selector matching multiple containers.
|
||||||
|
|
||||||
|
### 6. Verify SSH policy
|
||||||
|
|
||||||
|
If `sshd` uses `AllowUsers`, add `chart-debug`. Keep password authentication
|
||||||
|
disabled. Ensure the firewall permits SSH from the diagnostics client's network.
|
||||||
|
|
||||||
|
Do not modify the installed forced-command `authorized_keys` entry.
|
||||||
|
|
||||||
|
### 7. Verify auditing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -t chart-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report back
|
||||||
|
|
||||||
|
Return only:
|
||||||
|
|
||||||
|
- Production SSH hostname or IP.
|
||||||
|
- SSH port.
|
||||||
|
- Exact stable container regex.
|
||||||
|
- Whether each allowed command succeeded.
|
||||||
|
- Confirmation arbitrary commands were denied.
|
||||||
|
- Confirmation the account password is locked.
|
||||||
|
- Confirmation the account is not in the Docker group.
|
||||||
|
- Errors with secrets redacted.
|
||||||
|
|
||||||
|
Do not return `CHART_AUTH_TOKEN`, Schwab credentials, container environment, or
|
||||||
|
private key material.
|
||||||
|
|
||||||
|
## Client verification
|
||||||
|
|
||||||
|
After receiving the host and port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 \
|
||||||
|
-p PORT chart-debug@HOST 'logs --since 20m'
|
||||||
|
```
|
||||||
|
|
||||||
|
Other examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 -p PORT chart-debug@HOST status
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 -p PORT chart-debug@HOST container-state
|
||||||
|
ssh -i ~/.ssh/chart_debug_ed25519 -p PORT chart-debug@HOST recent-deploy
|
||||||
|
```
|
||||||
160
docs/investigate_added_symbols.md
Normal file
160
docs/investigate_added_symbols.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Added symbols — `/ES`, `/NQ`, `/GC`, `/CL`
|
||||||
|
|
||||||
|
**Status: placeholders in, switcher not.** Agreed 2026-08-31. Profile
|
||||||
|
and `symbol` stamps are live; still one stream and no UI switcher.
|
||||||
|
|
||||||
|
The process is one chart of one contract. Yahoo and Schwab already know
|
||||||
|
the other roots. Almost everything after the stream is *this* instrument.
|
||||||
|
The likely set is four: **ES, NQ, gold, oil**. Design for N, not a
|
||||||
|
boolean ES/gold switch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Persist `symbol` now. Do not split `Runtime` now.**
|
||||||
|
|
||||||
|
Same move as `user_id: "shared"` in `docs/multi_user.md`: cheap while
|
||||||
|
there is one value, expensive after two files exist.
|
||||||
|
|
||||||
|
### Do first (placeholders, still one live series)
|
||||||
|
|
||||||
|
1. **Instrument profile** the settings and the browser both see:
|
||||||
|
|
||||||
|
```
|
||||||
|
id: "es" | "nq" | "gc" | "cl"
|
||||||
|
yahoo_symbol: "ES=F" | "NQ=F" | "GC=F" | "CL=F"
|
||||||
|
schwab_symbol: "/ES" | "/NQ" | "/GC" | "/CL"
|
||||||
|
tick: 0.25 | 0.25 | 0.10 | 0.01
|
||||||
|
decimals: 2
|
||||||
|
session: globex_18_17
|
||||||
|
rth: spy_rth | spy_rth | none | nymex_day
|
||||||
|
```
|
||||||
|
|
||||||
|
Snap, nudge, alert inputs, and the status label read `tick` / names
|
||||||
|
from here. Kill `ConfluenceChart.TICK = 0.25`. Session code stays
|
||||||
|
shared. RTH follows the profile (`none` hides SPY marks on gold).
|
||||||
|
|
||||||
|
2. **Stamp persistence.** Drawings, alert-state rows, and events get
|
||||||
|
`symbol` (Schwab root, e.g. `/ES`). Missing field means `/ES`. New
|
||||||
|
writes always stamp the current profile. Do not wait for a second
|
||||||
|
chart.
|
||||||
|
|
||||||
|
3. **Keep one store, one stream, one seed.** `Bar.symbol` already exists.
|
||||||
|
`InMemoryBarStore` stays `tf → bars` until something actually switches.
|
||||||
|
|
||||||
|
Today’s env still selects the one live profile (`YAHOO_SYMBOL` /
|
||||||
|
`SCHWAB_SYMBOL` or an `INSTRUMENT=es` key). Default remains ES.
|
||||||
|
|
||||||
|
### Then (after placeholders have been live)
|
||||||
|
|
||||||
|
4. Prove a second root as a **replace**: point env at NQ or GC, restart,
|
||||||
|
confirm Yahoo history + Schwab stream + tick snaps. NQ is the cheap
|
||||||
|
proof (same tick and RTH as ES). GC or CL is the proof that tick/RTH
|
||||||
|
actually split.
|
||||||
|
|
||||||
|
5. **Switcher** last: UI picks the profile; snapshot replace (`setBars`),
|
||||||
|
not a tick; filter drawings/alerts by `symbol`; lazy-seed the other
|
||||||
|
series; do not Schwab-sub the hidden root.
|
||||||
|
|
||||||
|
### Not in this plan
|
||||||
|
|
||||||
|
Two live streams (ES and gold on screen together). That is a second tick
|
||||||
|
path and a second 5k-bar store inside Stay cheap, plus an unverified
|
||||||
|
double `CHART_FUTURES` sub on one token. Not until the switcher has been
|
||||||
|
used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instrument table
|
||||||
|
|
||||||
|
| | `/ES` | `/NQ` | `/GC` | `/CL` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Tick | 0.25 | 0.25 | 0.10 | 0.01 |
|
||||||
|
| Display | 2 dp | 2 dp | 2 dp | 2 dp |
|
||||||
|
| Yahoo | `ES=F` | `NQ=F` | `GC=F` | `CL=F` |
|
||||||
|
| Schwab | `/ES` | `/NQ` | `/GC` | `/CL` |
|
||||||
|
| Globex 18:00–17:00 | yes | yes | yes | yes |
|
||||||
|
| SPY RTH overlay | yes | yes | no | no (NYMEX day 9:00–14:30 ET) |
|
||||||
|
| Options UI | keep | hide or later | hide | hide |
|
||||||
|
|
||||||
|
NQ is the cheapest second chart. Gold and oil are why tick cannot stay a
|
||||||
|
chart constant. `toFixed(2)` covers all four.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why the placeholders
|
||||||
|
|
||||||
|
The current process is one `Runtime`, one Schwab socket, one bar store,
|
||||||
|
one `manual_lines.json`, one alert-state file, one ntfy topic.
|
||||||
|
|
||||||
|
| File | Today | After step 2 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data/manual_lines.json` | no symbol | each row `symbol: "/ES"` |
|
||||||
|
| `data/alert_state.json` | zones by price | zone + symbol |
|
||||||
|
| `data/events.json` | one log | tagged or filtered by symbol |
|
||||||
|
| `data/user_prefs.json` | global | leave global until the switcher |
|
||||||
|
| `localStorage` | layers, theme | leave until the switcher |
|
||||||
|
|
||||||
|
Without `symbol` on drawings, a switcher would mix ES lines onto NQ.
|
||||||
|
Without it on alert state, gold 2650 would be silenced by an old ES
|
||||||
|
2650. Adding the field later is a migration of production JSON.
|
||||||
|
|
||||||
|
`ConfluenceChart.TICK = 0.25` feeds every snap, keyboard nudge, and the
|
||||||
|
price-alert `step`. Gold cannot ship with that literal. Pulling it into
|
||||||
|
the profile is not scaffolding — it is deleting a lie.
|
||||||
|
|
||||||
|
Schwab continuous roots (`/ES`, `/NQ`, `/GC`, `/CL`) should auto-resolve
|
||||||
|
the front month the same way `/ES` → `/ESU26`. Verify each with
|
||||||
|
`scripts/check_stream.py` before trusting it. Singular `get_quote("/GC")`
|
||||||
|
is still the equity slash trap; always `get_quotes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What stays generic
|
||||||
|
|
||||||
|
Aggregator, VWAP, daily MAs, prior-day H/L/C (session is shared Globex),
|
||||||
|
WebSocket snapshot shape, drawing tools, Fibonacci, comments, confluence
|
||||||
|
*math*, ntfy, auth. They work if the bars and the profile are the
|
||||||
|
instrument’s.
|
||||||
|
|
||||||
|
What does not: RTH marks, tick grid, options panel, confluence *score*
|
||||||
|
(28 and the 4h cooldown were calibrated on ES — re-run
|
||||||
|
`scripts/calibrate_alerts.py` per tape before turning confluence on).
|
||||||
|
|
||||||
|
Yahoo daily bars stay unused. 1h → session 1d, same as ES.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stay cheap
|
||||||
|
|
||||||
|
The tick budget is one series: forming tick = candle + price label.
|
||||||
|
A symbol switch is `setBars`. Do not subscribe Schwab to a hidden root.
|
||||||
|
Do not 2× the ~82s Yahoo seed; lazy-load the next instrument on first
|
||||||
|
view.
|
||||||
|
|
||||||
|
Two symbols on one Schwab socket is unverified. Two *processes* both
|
||||||
|
opening a stream still kick each other off.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What not to do
|
||||||
|
|
||||||
|
- Do not split `Runtime` or the bar store until the switcher exists.
|
||||||
|
- Do not add a disabled switcher, a second seed, or a second Schwab sub
|
||||||
|
in the placeholder change.
|
||||||
|
- Do not add a second `package.json`, Vue app, or process “for gold.”
|
||||||
|
- Do not leave `TICK = 0.25` and “just chart gold.”
|
||||||
|
- Do not reuse ES confluence calibration.
|
||||||
|
- Do not show SPY RTH on metals or oil.
|
||||||
|
- Do not build gold/oil/NQ options in the same change as the chart.
|
||||||
|
- Do not put two symbols in one `manual_lines.json` without a symbol key.
|
||||||
|
- Do not start two live streams.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open, before the switcher (not before placeholders)
|
||||||
|
|
||||||
|
1. First extra root to prove as a replace: NQ (easy) or GC (forces tick)?
|
||||||
|
2. Does `/NQ` `/GC` `/CL` on this account stream `delayed: false`?
|
||||||
|
3. Does each Yahoo `*F` 1h series go back far enough for a daily 200 SMA?
|
||||||
178
docs/mobile_enhance.md
Normal file
178
docs/mobile_enhance.md
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
# Mobile experience enhancements
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make mobile useful for full chart annotation without weakening the existing
|
||||||
|
desktop workflow. Prefer feature detection (`pointer: coarse`, `hover: none`,
|
||||||
|
and `pointerType`) over user-agent checks.
|
||||||
|
|
||||||
|
The current page is responsive, but its interaction model is desktop-first.
|
||||||
|
At a 390 x 844 viewport the chart and sidebar become one long column, many
|
||||||
|
controls are below recommended touch size, and drawing feedback depends on
|
||||||
|
hover or desktop-only actions such as right-click and keyboard Delete.
|
||||||
|
|
||||||
|
## Recommended interaction model
|
||||||
|
|
||||||
|
Use tap-tap as the primary mobile trendline workflow:
|
||||||
|
|
||||||
|
1. Arm Trendline from a compact tool rail adjacent to the chart.
|
||||||
|
2. Tap the first anchor.
|
||||||
|
3. Keep a numbered anchor and price/time label visible.
|
||||||
|
4. Offer Cancel and Undo anchor.
|
||||||
|
5. Tap the second anchor to complete the line.
|
||||||
|
6. Selecting the line opens actions for Move, End here, Style, and Delete.
|
||||||
|
|
||||||
|
Keep drag placement as a desktop shortcut. It may remain available as an
|
||||||
|
advanced mobile gesture, but it should not be the primary path because it
|
||||||
|
conflicts with chart panning and hides the target beneath the finger.
|
||||||
|
|
||||||
|
## Priorities
|
||||||
|
|
||||||
|
### P0: Persistent first-anchor feedback
|
||||||
|
|
||||||
|
Touch has no hover. After the first tap, leave a visible numbered anchor with
|
||||||
|
its price and time, plus Cancel and Undo anchor actions. While a finger is down,
|
||||||
|
place the snap label above the finger and connect it to the selected high or low
|
||||||
|
with a leader.
|
||||||
|
|
||||||
|
Complexity: medium. This can reuse the existing pending-anchor state and also
|
||||||
|
improves desktop click-click placement.
|
||||||
|
|
||||||
|
### P0: Mobile tool rail next to the chart
|
||||||
|
|
||||||
|
On mobile, keep Trendline, Level, Comment, Symbol, active-tool state, and Cancel in a
|
||||||
|
compact sticky rail directly below the chart. Open tool configuration in an
|
||||||
|
expandable panel or sheet. Leave Layers and the complete Drawings manager below.
|
||||||
|
|
||||||
|
Complexity: medium. This should be mobile-only and preserve the desktop sidebar.
|
||||||
|
|
||||||
|
### P0: Predictable chart and page gestures
|
||||||
|
|
||||||
|
On coarse pointers, one finger scrolls the page. Two-finger drag pans the
|
||||||
|
chart on both axes. Pinch still zooms (axis from the initial finger spread).
|
||||||
|
Do not give one finger two meanings.
|
||||||
|
|
||||||
|
Complexity: medium-high. Touch arbitration needs real iOS Safari and Android
|
||||||
|
Chrome verification in addition to Chromium emulation.
|
||||||
|
|
||||||
|
### P0: Selected-drawing action bar
|
||||||
|
|
||||||
|
Tapping a drawing should select it and show a compact action bar with Edit, End
|
||||||
|
here, Delete, and Cancel selection. End here should enter a clear mode that asks
|
||||||
|
the user to tap the cutoff bar. Flat levels should be selectable from the line
|
||||||
|
or its price-axis label.
|
||||||
|
|
||||||
|
Complexity: medium-high. This is additive on desktop; keyboard and right-click
|
||||||
|
can remain shortcuts.
|
||||||
|
|
||||||
|
### P0: One plot-relative coordinate path
|
||||||
|
|
||||||
|
Status: completed 2026-08-11. Placement, selection, anchor dragging and context
|
||||||
|
actions now use the measured plot canvas as their shared pointer frame.
|
||||||
|
|
||||||
|
Placement, selection, anchor dragging, and context actions use the same
|
||||||
|
plot-relative pointer conversion. Keep future chart-bound interactions in that
|
||||||
|
frame and build mobile hit regions around painted geometry.
|
||||||
|
|
||||||
|
Complexity: medium. This is a correctness improvement on both mobile and
|
||||||
|
desktop and should have page-pixel regression coverage.
|
||||||
|
|
||||||
|
### P1: Larger invisible hit targets
|
||||||
|
|
||||||
|
Retain the compact visual marks while giving anchors, collapsed comments,
|
||||||
|
checkboxes, timeframe buttons, and destructive actions approximately 44px touch
|
||||||
|
surfaces. Use nearest-target resolution where drawing hit regions overlap.
|
||||||
|
|
||||||
|
Complexity: low-medium. Apply primarily under coarse-pointer media queries.
|
||||||
|
|
||||||
|
### P1: Precise and cancellable anchor dragging
|
||||||
|
|
||||||
|
Use a large invisible handle, pointer capture, and a price/time readout offset
|
||||||
|
from the finger. Suspend chart panning during the drag. With snapping enabled,
|
||||||
|
snap to bar highs/lows; otherwise snap to the tick grid. Commit on release and
|
||||||
|
provide a way to cancel or revert.
|
||||||
|
|
||||||
|
Complexity: high. These lines can drive alerts, so accidental geometry changes
|
||||||
|
matter.
|
||||||
|
|
||||||
|
### P1: Separate comment actions
|
||||||
|
|
||||||
|
Desktop now separates these actions: the body selects, the left-side control
|
||||||
|
collapses, and a dedicated grip moves floating comments and pinned symbols.
|
||||||
|
Carry that interaction to touch, with suitably enlarged invisible hit regions.
|
||||||
|
Expose Edit text, Pin/Float, Collapse, and Delete through the selected-drawing
|
||||||
|
actions.
|
||||||
|
|
||||||
|
Complexity: medium. The behavior exists; mobile still needs touch-sized targets
|
||||||
|
and gesture verification.
|
||||||
|
|
||||||
|
### P1: Keyboard-safe mobile layout
|
||||||
|
|
||||||
|
Use at least 16px text in mobile inputs to avoid iOS automatic zoom. Size chart
|
||||||
|
areas with `dvh` or `visualViewport`, dismiss the keyboard before chart
|
||||||
|
placement, and keep the active tool controls visible above it. Offer a more
|
||||||
|
chart-focused landscape layout instead of enforcing the current 360px minimum
|
||||||
|
inside a short viewport.
|
||||||
|
|
||||||
|
Complexity: medium and requires real-device checks.
|
||||||
|
|
||||||
|
### P2: Progressive drawing-row disclosure
|
||||||
|
|
||||||
|
Show each drawing as a concise 44-52px summary with type, number/name, and
|
||||||
|
state. Put rename, style, alert state, and metadata in an expanded row or sheet.
|
||||||
|
Require confirmation or an undo path for bulk deletion, stating the filter and
|
||||||
|
count affected.
|
||||||
|
|
||||||
|
Complexity: medium. This can be mobile-only initially.
|
||||||
|
|
||||||
|
### P2: Simplified mobile status hierarchy
|
||||||
|
|
||||||
|
Keep price and timeframe controls prominent. Collapse feed, age, and bars-held
|
||||||
|
into one compact status line or disclosure. Put active tool instructions in the
|
||||||
|
sticky tool rail and constrain comment width so notes do not cover active
|
||||||
|
anchors.
|
||||||
|
|
||||||
|
Complexity: low-medium.
|
||||||
|
|
||||||
|
### P2: Touch-specific browser coverage
|
||||||
|
|
||||||
|
Add Playwright contexts around 390 x 844 and 844 x 390 with explicit device
|
||||||
|
scale factor and touch support. Cover tap-tap placement, page scrolling over the
|
||||||
|
chart, selection actions, anchor movement, comments, and orientation. Chromium
|
||||||
|
emulation is useful but does not replace an iOS Safari and Android Chrome smoke
|
||||||
|
pass.
|
||||||
|
|
||||||
|
Complexity: medium. Tests should guard observed failures and interaction
|
||||||
|
invariants rather than arbitrary CSS dimensions.
|
||||||
|
|
||||||
|
## Incremental rollout
|
||||||
|
|
||||||
|
1. Add mobile diagnostics/tests and enlarge invisible hit regions. Pointer
|
||||||
|
coordinates are already unified for chart drawing interactions.
|
||||||
|
2. Add persistent first-anchor feedback, Cancel, and Undo anchor.
|
||||||
|
3. Add the mobile tool rail, keyboard-safe sizing, and chart/page gesture policy.
|
||||||
|
4. Add the selected-drawing action bar and touch-accessible End here flow.
|
||||||
|
5. Improve anchor dragging and comment interactions.
|
||||||
|
6. Compact drawing rows and refine portrait/landscape information density.
|
||||||
|
7. Verify on real iOS and Android devices before calling mobile authoring done.
|
||||||
|
|
||||||
|
## Product decisions
|
||||||
|
|
||||||
|
Recommended defaults:
|
||||||
|
|
||||||
|
- Support full mobile drawing and editing, not monitoring only.
|
||||||
|
- Use tap-tap as the primary touch trendline flow.
|
||||||
|
- Reserve one-finger vertical movement for page scrolling.
|
||||||
|
- Keep horizontal chart panning and pinch zoom.
|
||||||
|
- Keep snapping enabled while moving anchors, with an explicit way to disable it.
|
||||||
|
- Confirm bulk deletion or provide an undo action; avoid a confirmation for every
|
||||||
|
single deletion.
|
||||||
|
- Offer a chart-focused fullscreen mode in mobile landscape.
|
||||||
|
|
||||||
|
Questions to revisit during implementation:
|
||||||
|
|
||||||
|
- Should comments be placed before typing, with text entered afterward in a
|
||||||
|
sheet?
|
||||||
|
- Should the mobile action bar initially expose only Move, End, and Delete, or
|
||||||
|
include alert and style controls?
|
||||||
|
- Should desktop eventually adopt the same explicit comment and selection model?
|
||||||
154
docs/multi_user.md
Normal file
154
docs/multi_user.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# Multi-user — the target, and how to get there without a big bang
|
||||||
|
|
||||||
|
**Status: tracked, not started.** A direction to refactor toward, not a project
|
||||||
|
with a date. Each phase below is worth doing on its own merits while the app is
|
||||||
|
still single-user; none of it is speculative scaffolding.
|
||||||
|
|
||||||
|
Target: separate people, each with their own drawings, alerts and notifications,
|
||||||
|
authenticated through OIDC against a self-hosted Authentik that can federate
|
||||||
|
Google.
|
||||||
|
|
||||||
|
## Decide this first: whose market data?
|
||||||
|
|
||||||
|
This fork determines the architecture, and it is not an engineering question.
|
||||||
|
|
||||||
|
**A — one shared feed (this account).** Everyone sees bars streamed from one
|
||||||
|
Schwab connection. Simplest to build, and the bar store stays shared. But
|
||||||
|
Schwab's agreement, and CME's beneath it, generally prohibit redistributing
|
||||||
|
exchange data to third parties. One account feeding *you* on five devices is
|
||||||
|
ordinary use; feeding other people is redistribution.
|
||||||
|
|
||||||
|
**B — each user brings their own brokerage account.** Every user runs the OAuth
|
||||||
|
flow against their own Schwab login, and receives data under their own
|
||||||
|
entitlement. No redistribution question. The cost is real: N streams, N tokens,
|
||||||
|
N weekly re-auths, and the "one shared bar store" assumption disappears —
|
||||||
|
`MarketRuntime` becomes one per connected account rather than one per process.
|
||||||
|
|
||||||
|
**A is a private tool for people you trust. B is a product.** Everything below
|
||||||
|
works for either, except the last phase. Worth answering before that phase, not
|
||||||
|
before starting.
|
||||||
|
|
||||||
|
## Do not build local accounts
|
||||||
|
|
||||||
|
Going to OIDC means the app never stores a password, never hashes one, never
|
||||||
|
implements reset or lockout. Building local accounts first means writing all of
|
||||||
|
that and then deleting it. The path is: shared password → OIDC subject.
|
||||||
|
|
||||||
|
The one thing to fix in the current auth regardless is
|
||||||
|
`deps.session_secret`, which derives the JWT signing key from
|
||||||
|
`sha256(password)`. With one shared password that is merely weak — anyone
|
||||||
|
holding a session cookie can brute-force the password offline. With several
|
||||||
|
users it is unworkable: either everyone shares a signing key, or the key varies
|
||||||
|
by user and you cannot verify a token without already knowing who sent it. A
|
||||||
|
server-side random secret fixes both, and is worth doing on its own.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
Each is independently useful today.
|
||||||
|
|
||||||
|
### Phase 1 — Split `Runtime` (valuable now: clarity and testability)
|
||||||
|
|
||||||
|
`Runtime` currently conflates market data with one person's analysis. Split it:
|
||||||
|
|
||||||
|
- `MarketRuntime` — the stream, the bar store, and levels derived only from
|
||||||
|
bars: daily MAs, session VWAP, prior-day H/L/C. Shared, one per process.
|
||||||
|
- `UserView` — drawings, confluence clusters, the alert engine, layer prefs,
|
||||||
|
and the ntfy topic. One per user.
|
||||||
|
|
||||||
|
The seam already half exists: `ws.py` computes `connection_clusters(runtime,
|
||||||
|
prefs)` per connection, because layer visibility is per-browser. That is the
|
||||||
|
per-user compute shape, just not keyed to an identity yet.
|
||||||
|
|
||||||
|
The consequence to plan for: clusters mix shared levels with *your* lines, so
|
||||||
|
per-user drawings make clustering and alerting per-user too. Alerts move from
|
||||||
|
one evaluation per closed bar to N. At small N that is nothing, but it lands on
|
||||||
|
the event loop — see `docs/async_refactor.md`, and watch `loop_lag_ms`.
|
||||||
|
|
||||||
|
### Phase 2 — Persistence with a user column (valuable now: cold restarts)
|
||||||
|
|
||||||
|
This is M7, which is already wanted for its own reasons: restarts currently
|
||||||
|
re-seed everything and drawings live in one JSON file. Do it as SQLite, and give
|
||||||
|
every drawing and every alert cooldown a `user_id` from the start — populated
|
||||||
|
with a single constant while there is one user.
|
||||||
|
|
||||||
|
Doing per-user state on flat files and migrating later is doing it twice.
|
||||||
|
|
||||||
|
Preferences follow the same rule. Browser-only preferences may remain in
|
||||||
|
`localStorage` until cross-device sync is worth building, but the first
|
||||||
|
server-synced preference must not go into a global JSON file or acquire a
|
||||||
|
dedicated database column. Add a user-keyed preference store at that point,
|
||||||
|
initially using the same single constant as drawings.
|
||||||
|
|
||||||
|
Use an extensible shape such as:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
value_json TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, namespace)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Each namespace owns a validated, versioned JSON object — for example
|
||||||
|
`drawing_palette` can hold row annotations. Adding another preference or field
|
||||||
|
then changes application validation, not the database schema. Do not turn this
|
||||||
|
into an unvalidated miscellaneous bag: loaders supply defaults, ignore unknown
|
||||||
|
fields for forward compatibility, and migrate a namespace's JSON version when
|
||||||
|
its meaning changes. Whole-object last-write-wins is sufficient initially;
|
||||||
|
introduce revisions or optimistic concurrency only when simultaneous edits from
|
||||||
|
multiple devices become a demonstrated problem.
|
||||||
|
|
||||||
|
This store belongs to `UserView` persistence, never `MarketRuntime`. Palette
|
||||||
|
labels, layer visibility, notification presentation and similar settings are
|
||||||
|
owned by a person; bars, market-derived levels and feed health remain shared.
|
||||||
|
|
||||||
|
### Phase 3 — Identity as a first-class concept, still one user
|
||||||
|
|
||||||
|
Thread `user_id` through every query and every WebSocket subscription while the
|
||||||
|
value is still hardcoded. Nothing changes behaviourally; the difference is that
|
||||||
|
afterwards, "more than one user" is data rather than a refactor.
|
||||||
|
|
||||||
|
The same identity must key `user_preferences`. Replacing the hardcoded value
|
||||||
|
with an OIDC subject should require no preference-table migration and no JSON
|
||||||
|
shape change — only the source of `user_id` changes.
|
||||||
|
|
||||||
|
This is the phase that makes the rest cheap, and it is invisible from outside —
|
||||||
|
which is exactly why it is worth doing before it is needed.
|
||||||
|
|
||||||
|
### Phase 4 — OIDC
|
||||||
|
|
||||||
|
Replace the password with an OIDC code flow against Authentik. The session JWT
|
||||||
|
carries the provider's `sub` instead of `"shared"`. Authentik federates Google,
|
||||||
|
so the app never sees a credential of any kind.
|
||||||
|
|
||||||
|
Notes for when this lands:
|
||||||
|
|
||||||
|
- The session cookie mechanics already exist and are correct — `HttpOnly`,
|
||||||
|
`SameSite=Strict`, `Secure` derived from `X-Forwarded-Proto`. Keep them.
|
||||||
|
- `/api/version` and `/api/health` stay unauthenticated for `bin/wait-deploy`.
|
||||||
|
- `/api/qt` must stay reachable unauthenticated: Schwab redirects a browser
|
||||||
|
there and cannot carry a session.
|
||||||
|
- Keep a bypass for API clients — an opaque token header — or scripts and
|
||||||
|
`bin/` tooling all need a browser.
|
||||||
|
|
||||||
|
### Phase 5 — Actually let other people in
|
||||||
|
|
||||||
|
Per-user ntfy topics, per-user alert engines, per-user drawing sets. Mechanical
|
||||||
|
once phases 1–3 are done. Gated on the market-data question above.
|
||||||
|
|
||||||
|
## What stays shared, forever
|
||||||
|
|
||||||
|
One Schwab streaming session per account — a per-account limit, not a per-server
|
||||||
|
one. Under option A that is the whole app's feed. Under option B it is one per
|
||||||
|
user account, which is the main reason B is more than a configuration change.
|
||||||
|
|
||||||
|
## Where the cost shows up
|
||||||
|
|
||||||
|
The per-connection cluster recompute in `ws.py` is already the only O(N) path.
|
||||||
|
Multi-user multiplies it by users rather than by tabs, and adds a per-user alert
|
||||||
|
evaluation each closed bar. `loop_lag_ms` on `/api/status` is the number to
|
||||||
|
watch; if it climbs past a few hundred milliseconds, the answer is incremental
|
||||||
|
moving averages and fingerprint-based level diffs, both already described in
|
||||||
|
`docs/async_refactor.md`.
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
# /ES Multi-Timeframe Confluence Chart — Implementation Plan
|
# /ES Multi-Timeframe Confluence Chart — Implementation Plan
|
||||||
|
|
||||||
**Audience:** the implementing agent. This document is the spec; it is written to be
|
**Audience:** whoever is changing this next. It was written as a spec to execute
|
||||||
executed top-to-bottom without re-deriving decisions.
|
top-to-bottom; it is now a reference for a running system. Read the section that
|
||||||
|
covers what you are touching, not the whole thing.
|
||||||
|
|
||||||
|
**This is a living document.** When a decision here stops being true, change it
|
||||||
|
here — a plan that disagrees with the code is worse than no plan, because it is
|
||||||
|
believed. What went wrong on the way belongs in `docs/implementation.md`.
|
||||||
|
|
||||||
**One-line goal:** stream `/ES` 1-minute bars from Schwab, aggregate them into every
|
**One-line goal:** stream `/ES` 1-minute bars from Schwab, aggregate them into every
|
||||||
larger timeframe locally, derive trendlines and moving averages on each timeframe,
|
larger timeframe locally, derive trendlines and moving averages on each timeframe,
|
||||||
|
|
@ -11,24 +16,38 @@ alert when independently-derived levels from different timeframes converge.
|
||||||
**Explicitly out of scope:** order execution. Nothing in this codebase places a trade.
|
**Explicitly out of scope:** order execution. Nothing in this codebase places a trade.
|
||||||
See [§14](#14-why-execution-is-out-of-scope) for why, and for the seam left behind.
|
See [§14](#14-why-execution-is-out-of-scope) for why, and for the seam left behind.
|
||||||
|
|
||||||
|
> **Companion document:** `docs/implementation.md` records what actually
|
||||||
|
> happened — the problems hit while building this and how each was resolved.
|
||||||
|
> This file is the plan and the reasoning; that one is the experience. When they
|
||||||
|
> disagree, the log is what really occurred.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. Start here
|
## 0. Start here
|
||||||
|
|
||||||
**Read §1, §2.1, §6, and §13 before writing anything.** The rest can be read as you
|
> **This document is now mostly history.** M0–M10 are built and deployed. The
|
||||||
reach each milestone.
|
> build order, branch instructions and "existing repo state" that used to open
|
||||||
|
> this file described a greenfield app and were actively misleading by August
|
||||||
**Work on a branch — do not push to `main`.** `main` is wired to a Forgejo webhook that
|
> 2026, so they are gone. What remains below is the reasoning behind decisions
|
||||||
triggers a Coolify production deploy at <https://chart.amow.com>. Pushing to main ships
|
> already made — read it to understand *why* something works the way it does,
|
||||||
whatever you wrote. Branch: `feat/chart-engine`.
|
> not to find out what to build.
|
||||||
|
>
|
||||||
**Build order is M0 → M1 → M2 → M3 → M3.5 → M4 → M5.** Stop after M5 and get feedback;
|
> **For current work, start with `AGENTS.md`**, which every agent loads
|
||||||
M6+ are separately scoped. No API keys are required for any of M0–M5.
|
> automatically. It points at the live planning documents:
|
||||||
|
> `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`,
|
||||||
**Existing repo state:** a placeholder FastAPI + Vue 3 (CDN, no build step) app.
|
> `docs/multi_user.md`, `docs/feature_undo.md`, `docs/mobile_enhance.md`,
|
||||||
`main.py` serves `static/index.html` and two toy `/api` endpoints. The serving and
|
> `docs/vite_build.md`, `docs/plan_light_dark_themes.md` and
|
||||||
deploy wiring is correct and should not be redesigned — extend it. The toy `/api/hello`
|
> `docs/plan_dma_alerts.md` for designs not yet built.
|
||||||
endpoint and its frontend button can be deleted.
|
>
|
||||||
|
> **`main` deploys to production.** A push triggers a Forgejo webhook and
|
||||||
|
> Coolify rebuild of <https://chart.amow.com>. That is the intended workflow now,
|
||||||
|
> not an accident to avoid — but it means every push is a deploy, and a deploy
|
||||||
|
> restarts the market stream.
|
||||||
|
>
|
||||||
|
> §16 onward is a dated log of problems and their resolutions. It is the most
|
||||||
|
> useful part of this file for anyone debugging: most entries record something
|
||||||
|
> that looked like one bug and was another.
|
||||||
|
|
||||||
### Dependencies to add
|
### Dependencies to add
|
||||||
|
|
||||||
|
|
@ -72,7 +91,7 @@ if your parser doesn't filter those, that fixture will catch it.
|
||||||
| Decision | Choice | Why |
|
| Decision | Choice | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support |
|
| Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support |
|
||||||
| Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial |
|
| Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial. Destination is Vite — see `docs/vite_build.md`. Do not treat this row as a reason to reject that move. Stay cheap — `AGENTS.md` § Stay cheap. No per-pixel overlay work, no extra work on the event loop each tick. |
|
||||||
| Charting | TradingView Lightweight Charts **v5.2.0**, standalone build | Apache-2.0, canvas, built for incremental realtime updates |
|
| Charting | TradingView Lightweight Charts **v5.2.0**, standalone build | Apache-2.0, canvas, built for incremental realtime updates |
|
||||||
| Data source | **Pluggable `MarketDataSource`.** Yahoo first, Schwab later | Yahoo needs no API key *and* has the history Schwab lacks — see §2.1 |
|
| Data source | **Pluggable `MarketDataSource`.** Yahoo first, Schwab later | Yahoo needs no API key *and* has the history Schwab lacks — see §2.1 |
|
||||||
| Persistence | **In-memory first**, behind a `BarStore` interface | User confirmed deferring persistence is fine for v1 |
|
| Persistence | **In-memory first**, behind a `BarStore` interface | User confirmed deferring persistence is fine for v1 |
|
||||||
|
|
@ -86,6 +105,13 @@ if your parser doesn't filter those, that fixture will catch it.
|
||||||
|
|
||||||
### Timeframe roles
|
### Timeframe roles
|
||||||
|
|
||||||
|
> **4h was removed from the product on 2026-08-10.** It was never enabled, and
|
||||||
|
> dropping it deleted the fiddliest logic in `session.py` — the wall-clock ET 4h
|
||||||
|
> anchor and its DST edge cases — for a timeframe nobody was using. Later
|
||||||
|
> sections of this document still use 4h in examples; read those as
|
||||||
|
> illustrative, not as a spec to build. Nothing below a day is session-anchored
|
||||||
|
> any more, so `bucket_start` now special-cases only `1d`.
|
||||||
|
|
||||||
```
|
```
|
||||||
1m base chart + alert evaluation weight 1 ← a switchable base timeframe
|
1m base chart + alert evaluation weight 1 ← a switchable base timeframe
|
||||||
2m display only
|
2m display only
|
||||||
|
|
@ -93,12 +119,14 @@ if your parser doesn't filter those, that fixture will catch it.
|
||||||
15m manual lines weight 2
|
15m manual lines weight 2
|
||||||
30m base chart + manual lines weight 3 ← a switchable base timeframe
|
30m base chart + manual lines weight 3 ← a switchable base timeframe
|
||||||
1h manual lines (+ optional MAs) weight 4
|
1h manual lines (+ optional MAs) weight 4
|
||||||
4h manual lines (+ optional MAs) weight 8
|
|
||||||
1d base chart + THE DAILY MAs weight 16 ← a switchable base timeframe
|
1d base chart + THE DAILY MAs weight 16 ← a switchable base timeframe
|
||||||
```
|
```
|
||||||
|
|
||||||
Base timeframe controls the candles only. **Every level stays visible on every base
|
Derived overlays stay visible on every base timeframe — the 200DMA on a 1-minute
|
||||||
timeframe** — the 200DMA on a 1-minute chart is the point, not a side effect.
|
chart is the point, not a side effect. Drawing objects follow one configurable
|
||||||
|
rule: visible on their attributed timeframe and lower charts, but not on higher
|
||||||
|
charts. Thus 30m trendlines, levels, Fibonacci drawings, comments, and symbols
|
||||||
|
belong on 30m/15m/5m/1m, not on 1h or 1d.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -148,6 +176,16 @@ Nothing downstream of these may know which source it is using. Selection is one
|
||||||
var. **In production both run at once:** Yahoo seeds history at startup, Schwab
|
var. **In production both run at once:** Yahoo seeds history at startup, Schwab
|
||||||
provides the live tail.
|
provides the live tail.
|
||||||
|
|
||||||
|
When the live stream reconnects more than two minutes past its last bar,
|
||||||
|
`Runtime.backfill_gap` fetches the missed stretch from the same seed source
|
||||||
|
(1h, 30m, 1m, each limited to its seed range), rebuilds higher timeframes with a
|
||||||
|
fresh aggregator, and inserts only into empty buckets (`InMemoryBarStore.fill`).
|
||||||
|
Live bars always win. The live aggregator's still-forming buckets are then
|
||||||
|
refolded from the stored minutes, levels are rebuilt without evaluating alerts,
|
||||||
|
and every socket gets a `resync` → full `snapshot`. Yahoo is ~10 minutes late,
|
||||||
|
so a second pass runs after that delay for the minutes just before reconnect.
|
||||||
|
The bucket that was forming when the stream *died* keeps only what it had.
|
||||||
|
|
||||||
### Do not use Yahoo's daily bars
|
### Do not use Yahoo's daily bars
|
||||||
|
|
||||||
Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs
|
Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs
|
||||||
|
|
@ -156,16 +194,43 @@ disagree with every other chart you'll compare against.
|
||||||
|
|
||||||
**Build daily bars yourself by aggregating Yahoo's 1h bars** through the same
|
**Build daily bars yourself by aggregating Yahoo's 1h bars** through the same
|
||||||
`aggregator.py` used for live data — one session definition everywhere. Yahoo's 1h bars
|
`aggregator.py` used for live data — one session definition everywhere. Yahoo's 1h bars
|
||||||
are anchored to the top of the ET hour, so they compose into session-anchored 4h and 1d
|
are anchored to the top of the ET hour, so they compose into session-anchored 1d
|
||||||
buckets cleanly. The ~730-day 1h window yields ~500 sessions: enough for a daily 200SMA
|
buckets cleanly. The ~730-day 1h window yields ~500 sessions: enough for a daily 200SMA
|
||||||
(~200 sessions) with room to spare.
|
(~200 sessions) with room to spare.
|
||||||
|
|
||||||
Yahoo's native 1d bars may be used *only* for multi-year context, clearly labelled.
|
Yahoo's native 1d bars may be used *only* for multi-year context, clearly labelled.
|
||||||
|
|
||||||
## 2.2 Verify these when adding the Schwab source (M6, not before)
|
## 2.2 Schwab entitlements — **answered empirically 2026-08-10**
|
||||||
|
|
||||||
These are load-bearing unknowns for Schwab specifically. They no longer block the
|
All verified against a live production app holding both Market Data Production and
|
||||||
project — M0–M5 run entirely on Yahoo. If any fails, stop and report.
|
Accounts and Trading Production.
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Futures market data entitled? | **Yes** — but only via `get_quotes()` (plural) |
|
||||||
|
| Symbol format | **`/ES`**, which auto-resolves to the active contract `/ESU26` |
|
||||||
|
| `CHART_FUTURES` streaming | **Works** — one true-OHLCV minute bar per symbol per minute |
|
||||||
|
| `LEVEL_ONE_FUTURES` | **Works**, and reports `delayed: false` |
|
||||||
|
| Futures price history | **Still none.** Yahoo remains the only source of the past |
|
||||||
|
|
||||||
|
Three traps found the hard way, all of which cost a round trip:
|
||||||
|
|
||||||
|
- **`get_quote()` (singular) silently returns the wrong instrument.** It puts the
|
||||||
|
symbol in the URL *path*, where the leading slash is normalised away, so `/ES`
|
||||||
|
comes back as `ES` — Eversource Energy, an equity, at $72. HTTP 200 with a
|
||||||
|
populated body. `get_quotes()` passes symbols as a query parameter and returns
|
||||||
|
the future correctly. **Never treat a 200 as proof; check `assetMainType`.**
|
||||||
|
- **Streaming needs the Accounts and Trading product.** `StreamClient.login()`
|
||||||
|
reads `/trader/v1/userPreference` for its socket URL, and that path is not in
|
||||||
|
Market Data Production. A market-data-only app cannot stream at all.
|
||||||
|
- **Authorisation codes expire in about thirty seconds**, and an unwritable token
|
||||||
|
path spends one before revealing itself. `scripts/check_schwab.py` preflights
|
||||||
|
the key, the secret and the token path for exactly this reason.
|
||||||
|
|
||||||
|
Because `/ES` resolves to the active contract on Schwab's side, contract roll
|
||||||
|
handling — an open problem in §10 — needs no code here.
|
||||||
|
|
||||||
|
### The remaining unknowns for Schwab
|
||||||
|
|
||||||
1. **Futures market-data entitlement.** It is not publicly documented whether
|
1. **Futures market-data entitlement.** It is not publicly documented whether
|
||||||
`CHART_FUTURES` requires futures trading approval or a CME non-professional market
|
`CHART_FUTURES` requires futures trading approval or a CME non-professional market
|
||||||
|
|
@ -320,7 +385,7 @@ Use dataclasses (or pydantic where it crosses the API boundary). All times are
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class Timeframe(str, Enum):
|
class Timeframe(str, Enum):
|
||||||
M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; H4="4h"; D1="1d"
|
M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; D1="1d"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def seconds(self) -> int: ... # D1 is session-defined, not 86400 — see §6
|
def seconds(self) -> int: ... # D1 is session-defined, not 86400 — see §6
|
||||||
|
|
@ -381,7 +446,7 @@ The weight table referenced throughout — define it once, in `config.py`:
|
||||||
```python
|
```python
|
||||||
TIMEFRAME_WEIGHT = {
|
TIMEFRAME_WEIGHT = {
|
||||||
Timeframe.M1: 1, Timeframe.M2: 1, Timeframe.M5: 1, Timeframe.M15: 2,
|
Timeframe.M1: 1, Timeframe.M2: 1, Timeframe.M5: 1, Timeframe.M15: 2,
|
||||||
Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.H4: 8, Timeframe.D1: 16,
|
Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.D1: 16,
|
||||||
}
|
}
|
||||||
MA_WEIGHT_FACTOR = 0.75 # §7.4 — MAs weigh slightly less than drawn structure
|
MA_WEIGHT_FACTOR = 0.75 # §7.4 — MAs weigh slightly less than drawn structure
|
||||||
```
|
```
|
||||||
|
|
@ -417,10 +482,9 @@ This is where a naive implementation silently produces wrong lines. CME ES is no
|
||||||
|
|
||||||
- `1m, 2m, 5m, 15m, 30m, 1h` — bucket on wall-clock UTC boundaries. These divide the
|
- `1m, 2m, 5m, 15m, 30m, 1h` — bucket on wall-clock UTC boundaries. These divide the
|
||||||
hour evenly, so session anchoring is unnecessary and UTC keeps it simple.
|
hour evenly, so session anchoring is unnecessary and UTC keeps it simple.
|
||||||
- `4h` — **anchor to the 18:00 ET session open**, not UTC midnight. Buckets are
|
- `1d` — one bar per futures session as defined above. **This is the only
|
||||||
18:00, 22:00, 02:00, 06:00, 10:00, 14:00 ET. UTC-anchored 4h buckets straddle the
|
session-anchored timeframe.** 4h used to be the other one and was the reason this
|
||||||
session boundary and produce meaningless bars.
|
section warned about DST; with 4h gone, that whole class of edge case went with it.
|
||||||
- `1d` — one bar per futures session as defined above.
|
|
||||||
|
|
||||||
Implement this as `session.py::bucket_start(t: int, tf: Timeframe) -> int` and unit
|
Implement this as `session.py::bucket_start(t: int, tf: Timeframe) -> int` and unit
|
||||||
test it hard, including both DST transitions and the Sunday open. **This function is
|
test it hard, including both DST transitions and the Sunday open. **This function is
|
||||||
|
|
@ -546,10 +610,19 @@ definition* (you drew them). So they validate the confluence engine without the
|
||||||
automatic detector's tuning risk, and they later become the ground truth that M8's
|
automatic detector's tuning risk, and they later become the ground truth that M8's
|
||||||
scoring coefficients get tuned against.
|
scoring coefficients get tuned against.
|
||||||
|
|
||||||
**Geometry.** Anchors are stored in **absolute epoch seconds and price** — never bar
|
**Geometry.** Anchors are persisted as **absolute epoch seconds and price** — never
|
||||||
indices. This is why a line drawn on the 4h chart renders correctly on the 1m chart
|
bar indices — so changing the algorithm does not rewrite drawings. A sloped line is
|
||||||
with no conversion: both are the same `(time, price)` plane. The existing
|
evaluated in the logical bar space of its attributed timeframe, however: charts
|
||||||
`Level.price_at(t)` already handles it.
|
compress a weekend to one slot, so wall-clock `price_at(t)` would advance through 49
|
||||||
|
hours in which no bars exist. The stored `slope` recovers the second endpoint price;
|
||||||
|
the source-timeframe bar sequence determines interpolation and projection. The
|
||||||
|
browser samples that canonical geometry at displayed candle timestamps and at
|
||||||
|
the existing future-whitespace timestamps. The server uses the same source series
|
||||||
|
for clusters and alerts. A source history that no longer reaches an anchor leaves
|
||||||
|
the drawing visible but unresolved and unable to cluster or alert rather than
|
||||||
|
silently extrapolating from the edge of a shorter window.
|
||||||
|
`TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid behavior
|
||||||
|
without changing persisted data.
|
||||||
|
|
||||||
**Timeframe attribution.** Tag the line with the timeframe that was *displayed when it
|
**Timeframe attribution.** Tag the line with the timeframe that was *displayed when it
|
||||||
was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the
|
was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the
|
||||||
|
|
@ -566,10 +639,11 @@ scored. `weight = TIMEFRAME_WEIGHT[tf]`.
|
||||||
| Place endpoints | `chart.subscribeClick(handler)` → two clicks |
|
| Place endpoints | `chart.subscribeClick(handler)` → two clicks |
|
||||||
| Pixel → price | `series.coordinateToPrice(param.point.y)` |
|
| Pixel → price | `series.coordinateToPrice(param.point.y)` |
|
||||||
| Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` |
|
| Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` |
|
||||||
| Render | `LineSeries` with 2 points, extended right (same as §9 auto lines) |
|
| Render | One `LineSeries`, sampled onto displayed candles and existing future-whitespace slots; SVG only bridges off-grid endpoints and provides interaction overlays |
|
||||||
| Select | Click within ~6px of a line — hit-test in price space via `price_at(t)` |
|
| Higher-TF style | When viewed below its attributed timeframe, a manual trendline is dashed and rendered at twice its stored width; native/lower-TF views use the stored width and solid style |
|
||||||
|
| Select | Click within ~6px of the canonical price at that displayed bar |
|
||||||
| Delete | `Delete`/`Backspace` on selection, plus a button |
|
| Delete | `Delete`/`Backspace` on selection, plus a button |
|
||||||
| Edit | **Delete and redraw.** Endpoint dragging is real work — do not build it in M5 |
|
| Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars |
|
||||||
|
|
||||||
**Snapping.** When placing an endpoint, snap to the nearest bar high/low within ~8px.
|
**Snapping.** When placing an endpoint, snap to the nearest bar high/low within ~8px.
|
||||||
Cheap to implement and it is the difference between a usable drawing tool and a
|
Cheap to implement and it is the difference between a usable drawing tool and a
|
||||||
|
|
@ -607,7 +681,6 @@ displayed.**
|
||||||
MA_SETS = {
|
MA_SETS = {
|
||||||
"1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)],
|
"1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)],
|
||||||
# optional, off by default:
|
# optional, off by default:
|
||||||
"4h": [("ema", 9), ("ema", 21)],
|
|
||||||
"1h": [("ema", 9), ("ema", 21)],
|
"1h": [("ema", 9), ("ema", 21)],
|
||||||
}
|
}
|
||||||
BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available
|
BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available
|
||||||
|
|
@ -693,7 +766,10 @@ ARMED ──price within alertTol of cluster──► FIRED ──► COOLDOWN
|
||||||
└────── price moves > 2*alertTol away AND cooldown elapsed ◄────┘
|
└────── price moves > 2*alertTol away AND cooldown elapsed ◄────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `alertTol = 0.5 * ATR14(15m)`
|
- `alertTol = 0.5 * ATR14(15m)` by default. A hand-placed price level may set
|
||||||
|
`alert_early_points`; resistance then qualifies that many points below the
|
||||||
|
level and support that many points above it. This changes notification timing,
|
||||||
|
not the level's chart geometry.
|
||||||
- `cooldown = 15 minutes`
|
- `cooldown = 15 minutes`
|
||||||
- Minimum score threshold to fire: **configurable, starting value 6 — but this
|
- Minimum score threshold to fire: **configurable, starting value 6 — but this
|
||||||
certainly needs recalibrating in M4.** With the daily MA set as the primary levels,
|
certainly needs recalibrating in M4.** With the daily MA set as the primary levels,
|
||||||
|
|
@ -750,8 +826,8 @@ Server → client:
|
||||||
Rules:
|
Rules:
|
||||||
- Send `bar` on **every** update of the forming bar (that is the live chart) but batch
|
- Send `bar` on **every** update of the forming bar (that is the live chart) but batch
|
||||||
`levels` — they only change on higher-TF closes.
|
`levels` — they only change on higher-TF closes.
|
||||||
- Always send a full `snapshot` on connect and after any reconnect. The client must
|
- Always send a full `snapshot` on connect and after any reconnect, and after the
|
||||||
never try to reconcile a gap.
|
server backfills a gap (`resync`). The client must never try to reconcile a gap.
|
||||||
- Levels are sent for **all** timeframes regardless of the displayed timeframe. That is
|
- Levels are sent for **all** timeframes regardless of the displayed timeframe. That is
|
||||||
the entire point: a 4h line drawn through a 1m chart.
|
the entire point: a 4h line drawn through a 1m chart.
|
||||||
|
|
||||||
|
|
@ -779,6 +855,26 @@ const chartApi = shallowRef(null); // ✅
|
||||||
// const chart = ref(null); // ❌ will appear to work, then misbehave
|
// const chart = ref(null); // ❌ will appear to work, then misbehave
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Logical indices address the whole chart, not your bar array
|
||||||
|
|
||||||
|
**Never derive a viewport from `bars.length`.** A logical index addresses the
|
||||||
|
chart's *shared* time scale — the union of the time points of every series on
|
||||||
|
it — not the candle array. Any series whose points pre-date the candle window
|
||||||
|
prepends to that scale and shifts every logical index by its count.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ❌ off by however many points the other series contribute
|
||||||
|
timeScale().setVisibleLogicalRange({ from: bars.length - 160, to: bars.length + 5 });
|
||||||
|
// ✅ an instant cannot be renumbered by a later series
|
||||||
|
timeScale().setVisibleRange({ from: bars.at(-160).t, to: bars.at(-1).t + step * 5 });
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not theoretical — see the 2026-08-10 entry in §16. The daily MAs carry
|
||||||
|
one point per daily bar (617 of them, back ~2 years). They are attached by
|
||||||
|
`syncVisibleLevels()` *immediately after* `setBars()`, so a logical range that
|
||||||
|
was correct when set silently slid 617 bars — about ten hours — into the past
|
||||||
|
one tick later. The chart looked frozen while the socket was perfectly healthy.
|
||||||
|
|
||||||
Structure:
|
Structure:
|
||||||
- `chart.js` — a plain, framework-free wrapper class owning the LWC instance:
|
- `chart.js` — a plain, framework-free wrapper class owning the LWC instance:
|
||||||
`create(el)`, `setBars()`, `updateBar()`, `syncLevels(levels)`, `destroy()`.
|
`create(el)`, `setBars()`, `updateBar()`, `syncLevels(levels)`, `destroy()`.
|
||||||
|
|
@ -825,8 +921,6 @@ LAYERS
|
||||||
☑ Daily MAs ██
|
☑ Daily MAs ██
|
||||||
☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200
|
☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200
|
||||||
─────────────────────────────────
|
─────────────────────────────────
|
||||||
☐ 4h MAs ██
|
|
||||||
☐ EMA9 ☐ EMA21
|
|
||||||
☐ 1h MAs ▓▓
|
☐ 1h MAs ▓▓
|
||||||
☐ EMA9 ☐ EMA21
|
☐ EMA9 ☐ EMA21
|
||||||
─────────────────────────────────
|
─────────────────────────────────
|
||||||
|
|
@ -836,8 +930,9 @@ LAYERS
|
||||||
☐ Hidden levels still count toward confluence
|
☐ Hidden levels still count toward confluence
|
||||||
```
|
```
|
||||||
|
|
||||||
- **The base timeframe switcher changes only the candles.** Every level stays on screen
|
- **Derived overlays stay across base timeframes.** A 200DMA is equally valid on
|
||||||
— a 200DMA is equally valid on a 1m chart. That is the entire premise of the product.
|
a 1m chart. Drawing objects default to native/lower charts only. The persisted
|
||||||
|
Config setting **Hide lower-TF drawings** can restore all drawings everywhere.
|
||||||
- **The group checkbox is a master toggle** — unchecking "Daily MAs" hides all five at
|
- **The group checkbox is a master toggle** — unchecking "Daily MAs" hides all five at
|
||||||
once; individual periods nest under it.
|
once; individual periods nest under it.
|
||||||
- The colour swatch beside each timeframe is that timeframe's hue, used identically on
|
- The colour swatch beside each timeframe is that timeframe's hue, used identically on
|
||||||
|
|
@ -889,7 +984,6 @@ would mean waiting months for the parts of the product that matter most:
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 5m, 15m | ~2–4 hours | immediate |
|
| 5m, 15m | ~2–4 hours | immediate |
|
||||||
| 30m, 1h | ~1–2 sessions | immediate |
|
| 30m, 1h | ~1–2 sessions | immediate |
|
||||||
| 4h | ~1–2 weeks | immediate (from 1h) |
|
|
||||||
| 1d | months | immediate (~500 sessions) |
|
| 1d | months | immediate (~500 sessions) |
|
||||||
| 1d 200SMA | ~10 months | immediate |
|
| 1d 200SMA | ~10 months | immediate |
|
||||||
|
|
||||||
|
|
@ -943,9 +1037,10 @@ SCHWAB_ACCOUNT_ID=
|
||||||
SCHWAB_SYMBOL=/ES
|
SCHWAB_SYMBOL=/ES
|
||||||
|
|
||||||
# --- timeframes & indicators ---
|
# --- timeframes & indicators ---
|
||||||
TIMEFRAMES=1m,2m,5m,15m,30m,1h,4h,1d
|
TIMEFRAMES=1m,2m,5m,15m,30m,1h,1d
|
||||||
BASE_TIMEFRAMES=1m,30m,1d # the chart switcher
|
BASE_TIMEFRAMES=1m,30m,1d # the chart switcher
|
||||||
MAX_BARS_PER_TF=5000 # in-memory ring buffer bound
|
MAX_BARS_PER_TF=5000 # in-memory ring buffer bound
|
||||||
|
TRENDLINE_SOURCE_GEOMETRY=true # false = rollback to displayed/1m-grid pricing
|
||||||
|
|
||||||
# Daily MA set is the primary requirement; others ship disabled. See §7.4
|
# Daily MA set is the primary requirement; others ship disabled. See §7.4
|
||||||
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
|
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
|
||||||
|
|
@ -984,7 +1079,7 @@ Everything downstream of `StreamService` is then testable, deterministically, of
|
||||||
Required tests:
|
Required tests:
|
||||||
|
|
||||||
- `session.py` — bucket boundaries. Both DST transitions, Sunday 18:00 open, the
|
- `session.py` — bucket boundaries. Both DST transitions, Sunday 18:00 open, the
|
||||||
17:00–18:00 break, Friday close, and the 4h session anchor. **Write these first.**
|
17:00–18:00 break, and Friday close. **Write these first.**
|
||||||
- `aggregator.py` — 1m→all TFs on synthetic bars; gap handling; idempotent replay.
|
- `aggregator.py` — 1m→all TFs on synthetic bars; gap handling; idempotent replay.
|
||||||
- `moving_averages.py` — a known daily series produces known 10/20/50/100/200 values;
|
- `moving_averages.py` — a known daily series produces known 10/20/50/100/200 values;
|
||||||
assert nothing is emitted before warm-up; assert the stepped projection onto 1m holds
|
assert nothing is emitted before warm-up; assert the stepped projection onto 1m holds
|
||||||
|
|
@ -1005,6 +1100,13 @@ Add `pytest` to a `requirements-dev.txt`.
|
||||||
|
|
||||||
## 13. Milestones
|
## 13. Milestones
|
||||||
|
|
||||||
|
> **All of M0–M10 are built and deployed.** This section is kept as a record of
|
||||||
|
> what each subsystem was required to do, not as a queue. The "Done when"
|
||||||
|
> criteria still earn their place: they describe correct behaviour, and several
|
||||||
|
> have since become tests. Treat them as the specification of a working
|
||||||
|
> subsystem — and if one no longer matches reality, the code changed and this
|
||||||
|
> did not, which is a bug in this document.
|
||||||
|
|
||||||
Ordered so the user's stated priority — **live realtime charts first** — lands
|
Ordered so the user's stated priority — **live realtime charts first** — lands
|
||||||
earliest, and so nothing later is blocked on market hours.
|
earliest, and so nothing later is blocked on market hours.
|
||||||
|
|
||||||
|
|
@ -1041,7 +1143,7 @@ once per session on the intraday views, dashed while the current session is unfi
|
||||||
### M3.5 — Layer panel
|
### M3.5 — Layer panel
|
||||||
Checkbox tree to show/hide levels by timeframe and by individual MA (§9.4). Ships with
|
Checkbox tree to show/hide levels by timeframe and by individual MA (§9.4). Ships with
|
||||||
M3 because 6 timeframes × 4 MAs = 24 lines is unreadable without it.
|
M3 because 6 timeframes × 4 MAs = 24 lines is unreadable without it.
|
||||||
**Done when:** unchecking "4h" removes every 4h level from the chart and the state
|
**Done when:** unchecking a group removes its every level from the chart and the state
|
||||||
survives a reload.
|
survives a reload.
|
||||||
|
|
||||||
### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build
|
### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build
|
||||||
|
|
@ -1051,6 +1153,9 @@ this is a complete, useful product with zero hand-drawn input and zero tuning.
|
||||||
**Done when:** a replayed tape produces a sane number of alerts (single digits per
|
**Done when:** a replayed tape produces a sane number of alerts (single digits per
|
||||||
session), each corresponding to a real multi-timeframe convergence.
|
session), each corresponding to a real multi-timeframe convergence.
|
||||||
|
|
||||||
|
**Update 2026-08-27:** Auto confluence (ZONE) alerts are off by default. Config →
|
||||||
|
Confluence alerts. Armed drawings and DMA bells still fire.
|
||||||
|
|
||||||
### M5 — Manual trendlines
|
### M5 — Manual trendlines
|
||||||
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
|
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
|
||||||
Hand-drawn lines are authoritative — full weight, no quality discount.
|
Hand-drawn lines are authoritative — full weight, no quality discount.
|
||||||
|
|
@ -1139,7 +1244,14 @@ enforces for data sources.
|
||||||
| `session.py` bucket math wrong | Silently wrong lines everywhere | Tests written first; both DST transitions |
|
| `session.py` bucket math wrong | Silently wrong lines everywhere | Tests written first; both DST transitions |
|
||||||
| Repainting pivots | Lines that "were always there" | `w`-bar confirmation lag, enforced by test |
|
| Repainting pivots | Lines that "were always there" | `w`-bar confirmation lag, enforced by test |
|
||||||
| Alert fatigue | Product becomes unusable | Cluster-level alerts, cooldown + separation re-arm |
|
| Alert fatigue | Product becomes unusable | Cluster-level alerts, cooldown + separation re-arm |
|
||||||
| Multiple uvicorn workers | Duplicate Schwab connections | `workers=1`; streamer in `lifespan` |
|
| Multiple uvicorn workers | Duplicate Schwab connections | `workers=1`; streamer in `lifespan`; warned in `Procfile` |
|
||||||
| Schwab token expiry (7 days) | Stream dies | Surface prominently in status bar; document re-auth |
|
| JWT signing key derived from the password | A leaked cookie brute-forces the password offline; blocks multi-user outright | Server-side random secret — `docs/multi_user.md` |
|
||||||
|
| Threadpool routes touching `asyncio.Queue` | Dropped socket wakeups, rare corruption | Post via `call_soon_threadsafe` — `docs/async_refactor.md` P0 |
|
||||||
|
| Level rebuilds run CPU-bound on the event loop | 82s startup; a stall every closed bar | Bulk seed, incremental MAs — `docs/async_refactor.md` P1 |
|
||||||
|
| Alert disarm writes to disk on the loop | Stream stalls when an alert fires | Offload the write — `docs/async_refactor.md` P2 |
|
||||||
|
| Schwab token expiry (7 days) | Stream dies | Keepalive REST ping writes a new refresh token; header reconnect when the grant is dead |
|
||||||
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
|
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
|
||||||
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
|
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
|
||||||
|
| Viewport derived from `bars.length` | Chart looks frozen; feed is fine | Anchor the view by time, never by logical index — §9 |
|
||||||
|
| Seed replays every bar through `on_bar` | ~82 s startup; port refuses connections | Known, unfixed — §16, 2026-08-10 |
|
||||||
|
| Headless browser without a real locale | `Intl` throws; blank canvas mimics an app bug | Launch Chromium with `--lang=en-US` — §16 |
|
||||||
129
docs/plan_diagnostics_improvements.md
Normal file
129
docs/plan_diagnostics_improvements.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Diagnostic Access Improvements
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make it practical for an agent on a separate SSH machine to diagnose browser and
|
||||||
|
production failures without granting broad production control or asking the user
|
||||||
|
to paste console output.
|
||||||
|
|
||||||
|
## Browser Captures
|
||||||
|
|
||||||
|
Diagnostic mode (`?diag=1`) offers **Capture diagnostic**. Upload and metadata
|
||||||
|
remain authenticated. The PNG URL at `/api/debug/captures/{id}` is public by its
|
||||||
|
72-bit id, which is the explicit handoff capability a user shares with an agent.
|
||||||
|
|
||||||
|
After inspecting a user-shared capture, the agent must immediately call:
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/debug/captures/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
The 24-hour expiry and 50-capture cap remain a backstop. Do not inspect capture
|
||||||
|
URLs that the user has not explicitly supplied.
|
||||||
|
|
||||||
|
## Production Diagnostics
|
||||||
|
|
||||||
|
Do not grant an agent a general production shell or Docker-group membership.
|
||||||
|
Docker access is effectively root access, and arbitrary shell access can expose
|
||||||
|
environment variables, OAuth tokens, and mounted volumes.
|
||||||
|
|
||||||
|
Instead create a dedicated `chart-debug` production account with a forced-command
|
||||||
|
SSH wrapper. It accepts only a small, read-oriented command set:
|
||||||
|
|
||||||
|
```
|
||||||
|
logs --since <duration>
|
||||||
|
status
|
||||||
|
container-state
|
||||||
|
recent-deploy
|
||||||
|
capture-read <capture-id>
|
||||||
|
capture-delete <capture-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper must reject arbitrary commands and paths. It should cap output,
|
||||||
|
redact known secret patterns, and log every request. Use a dedicated SSH key that
|
||||||
|
can be revoked without affecting deployment or normal administration.
|
||||||
|
|
||||||
|
Expected agent usage:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh chart-debug@production logs --since 20m
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation on the Coolify host
|
||||||
|
|
||||||
|
The implementation lives in `ops/chart-debug-command` and
|
||||||
|
`ops/install-chart-debug`. The Coolify-side agent must run as root on the Docker
|
||||||
|
host, not inside the application container.
|
||||||
|
|
||||||
|
1. Identify the current chart container and a stable name prefix that survives
|
||||||
|
deploys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The Coolify resource UUID is `dgvch0xqv8uvjfor7dl8bwl9`; a likely anchored
|
||||||
|
pattern is `^dgvch0xqv8uvjfor7dl8bwl9`, but the agent must verify it matches
|
||||||
|
exactly one running container before installation.
|
||||||
|
|
||||||
|
2. Run the installer from a checkout containing `ops/`, using the dedicated
|
||||||
|
public key supplied out-of-band:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./ops/install-chart-debug \
|
||||||
|
--public-key 'ssh-ed25519 AAAA... chart-debug restricted production diagnostics' \
|
||||||
|
--container-pattern '^VERIFIED-STABLE-PREFIX' \
|
||||||
|
--url https://chart.amow.com
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the account is locked, files are root-owned, sudo policy is valid,
|
||||||
|
the selector matches exactly one container, allowed commands work, and an
|
||||||
|
arbitrary command is denied:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
passwd -S chart-debug
|
||||||
|
stat -c '%U:%G %a %n' /usr/local/sbin/chart-debug-command \
|
||||||
|
/etc/chart-debug.conf /etc/sudoers.d/chart-debug
|
||||||
|
visudo -cf /etc/sudoers.d/chart-debug
|
||||||
|
sudo -u chart-debug sudo -n /usr/local/sbin/chart-debug-command 'container-state'
|
||||||
|
sudo -u chart-debug sudo -n /usr/local/sbin/chart-debug-command 'logs --since 5m'
|
||||||
|
if sudo -u chart-debug sudo -n /usr/local/sbin/chart-debug-command 'shell'; then
|
||||||
|
echo 'ERROR: arbitrary command was allowed'; exit 1
|
||||||
|
else
|
||||||
|
echo 'arbitrary command correctly denied'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Confirm SSH permits the `chart-debug` user. If `AllowUsers` is configured,
|
||||||
|
add `chart-debug`; do not enable password authentication. Report the host or
|
||||||
|
IP and SSH port so the client alias can be configured.
|
||||||
|
|
||||||
|
Security invariants:
|
||||||
|
|
||||||
|
- Do not add `chart-debug` to the Docker group.
|
||||||
|
- Do not install the private key on production or paste it into chat.
|
||||||
|
- Keep `/etc/chart-debug.conf`, the wrapper and sudoers entry root-owned.
|
||||||
|
- Keep the account password locked and the `authorized_keys` `restrict` forced
|
||||||
|
command intact; no additional unrestricted keys.
|
||||||
|
- The wrapper must match exactly one running container. Zero or multiple matches
|
||||||
|
fail closed.
|
||||||
|
- Verify denials as well as successful commands. Audit records are available via
|
||||||
|
`journalctl -t chart-debug`.
|
||||||
|
- Keep secrets off process command lines. The status command supplies
|
||||||
|
`CHART_AUTH_TOKEN` to curl through stdin (`curl --config -`), never `-H`.
|
||||||
|
- Redact OAuth `code`, `session`, and `state` query parameters as well as token,
|
||||||
|
secret, password, key and Authorization values in log output.
|
||||||
|
- Assign `resolve_container` before invoking Docker. Calling it inline through
|
||||||
|
command substitution can trap its exit in a subshell and obscure the intended
|
||||||
|
fail-closed return code.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
Keep browser performance telemetry separate from production access. A future
|
||||||
|
frontend recorder should locally aggregate frame timing, long tasks, tick rate,
|
||||||
|
visible bars, and rendered line/level counts, then periodically upload compact,
|
||||||
|
authenticated summaries. Pair it with server timing for bar handling, level
|
||||||
|
rebuilds, WebSocket serialization, and the existing loop-lag measure.
|
||||||
|
|
||||||
|
This separates rendering, feed, transport, and backend pressure without logging
|
||||||
|
prices, drawing text, cursor positions, screenshots, or per-tick event history.
|
||||||
43
docs/plan_dma_alerts.md
Normal file
43
docs/plan_dma_alerts.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Alerts on daily moving averages
|
||||||
|
|
||||||
|
**Status: built.** Agreed 2026-08-15. Bells live in Layers; watches persist in
|
||||||
|
`user_prefs.json` under namespace `ma_alerts`.
|
||||||
|
|
||||||
|
Daily SMAs (10/20/50/100/200) already exist as `Level`s and already join
|
||||||
|
confluence clusters. This is a per-period, on/off watch that fires when
|
||||||
|
price reaches that average, not a new drawing type.
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
A bell next to each period in Layers, off by default, independent of the
|
||||||
|
visibility checkbox. Hiding the line must not mute the alert — same rule
|
||||||
|
as confluence: layer prefs are a display choice, a push is not.
|
||||||
|
|
||||||
|
Keep them out of the drawings list. They are not drawings.
|
||||||
|
|
||||||
|
## How it fires
|
||||||
|
|
||||||
|
Same proximity test as a typed price alert: `/ES` within half an ATR of
|
||||||
|
that MA's current price. The engine already runs every closed minute.
|
||||||
|
|
||||||
|
Do **not** copy manual-line disarm. A line you drew is one-shot. A 200 DMA
|
||||||
|
is a standing level: fire, then stay quiet until price leaves and comes
|
||||||
|
back (the existing cooldown). Sitting on the average must not chatter.
|
||||||
|
|
||||||
|
The push names the average (`200 DMA`), not a zone. The forming daily SMA
|
||||||
|
moves as the session prints, so a “touch” can be the average walking to
|
||||||
|
price. That is real; the label is what stops it being mistaken for
|
||||||
|
confluence.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Five booleans, not rows in `manual_lines.json`. When a user-keyed
|
||||||
|
preference store exists (`docs/multi_user.md`), these belong there. Until
|
||||||
|
then, one small prefs object — same shape, single constant `user_id`.
|
||||||
|
|
||||||
|
## What not to do
|
||||||
|
|
||||||
|
- Do not alert because the layer is visible.
|
||||||
|
- Do not put MA watches in the drawings list or give them drawing numbers.
|
||||||
|
- Do not one-shot-disarm them like a hand-placed line.
|
||||||
|
- Do not gate them on confluence score. You asked for this average.
|
||||||
126
docs/plan_light_dark_themes.md
Normal file
126
docs/plan_light_dark_themes.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Light and dark themes
|
||||||
|
|
||||||
|
**Status: tracked, not started.** A direction to refactor toward, not a project
|
||||||
|
with a date. Recorded 2026-08-14 from a contrast pass over the colours that
|
||||||
|
are actually in the tree.
|
||||||
|
|
||||||
|
The page is a warm paper theme. A dark theme that just inverts it will look
|
||||||
|
broken. Chrome is cheap. The chart colours were picked for cream, and many
|
||||||
|
of them are stored as hex on drawings, so a CSS flip does not restyle lines
|
||||||
|
already placed.
|
||||||
|
|
||||||
|
## What is already factored
|
||||||
|
|
||||||
|
`:root` in `static/style.css` owns the chrome:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #e8dfcf;
|
||||||
|
--panel: #f7f1e6;
|
||||||
|
--chart-bg: #fbf7ef;
|
||||||
|
--fg: #2c2924;
|
||||||
|
--muted: #746c60;
|
||||||
|
--line: #d2c5b2;
|
||||||
|
--accent: #b7771d;
|
||||||
|
--green: #27825c;
|
||||||
|
--red: #bd4545;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lightweight Charts already reads `--chart-bg` and `--muted` at `create()`.
|
||||||
|
Sidebar, comments, tooltips and most chrome follow the tokens. Flipping
|
||||||
|
those variables restyles the frame. It does not restyle the series.
|
||||||
|
|
||||||
|
## What is hardcoded for cream paper
|
||||||
|
|
||||||
|
**Candles** — `#20b8a6` / `#ef5b3f` with borders and wicks `#087f76` /
|
||||||
|
`#b9342d`. Mid-luminance, already the usual trading pair. Fine on dark.
|
||||||
|
|
||||||
|
**Timeframe and kind colours** (`ConfluenceChart.tfColors`,
|
||||||
|
`kindColors`, and the layer swatches):
|
||||||
|
|
||||||
|
| Role | Hex | On dark |
|
||||||
|
|---|---|---|
|
||||||
|
| 1m | `#82909f` | usable |
|
||||||
|
| 5m / manual | `#65b7cf` | fine |
|
||||||
|
| 1h | `#efb643` | fine |
|
||||||
|
| 1d MA | `#d96073` | fine |
|
||||||
|
| VWAP | `#b07ad6` | fine |
|
||||||
|
| prior-day H/L/C | `#9fb0c4` | fine |
|
||||||
|
|
||||||
|
**Grid** — `rgba(128,128,128,.10)`. Invisible on a dark plot. Needs a
|
||||||
|
higher alpha, or a token.
|
||||||
|
|
||||||
|
**Accent** — `#b7771d` is a mid amber. On cream it reads as gold. On dark
|
||||||
|
it goes muddy/brown. Dark chrome wants a brighter accent, not the same hex.
|
||||||
|
|
||||||
|
**Price tag** — `#e0a34a` on `#1a1206`. Fine on both.
|
||||||
|
|
||||||
|
**Snap support/resistance** — `#27825c` / `#bd4545`. Fine.
|
||||||
|
|
||||||
|
**Current-price pulse** — `color-mix(..., var(--accent), white)`. On dark,
|
||||||
|
mix toward the chart background or a lighter accent, not white.
|
||||||
|
|
||||||
|
**Shadows** — `rgba(0,0,0,.16)`. Vanish on dark. Drop them or invert.
|
||||||
|
|
||||||
|
**`button.active`** — `color: var(--bg)` on `background: var(--accent)`.
|
||||||
|
Survives a token flip (dark text on gold instead of cream text on gold).
|
||||||
|
|
||||||
|
## The drawing palette is the real problem
|
||||||
|
|
||||||
|
Four stops per family, named `green1`–`green4` and so on, persisted as hex
|
||||||
|
on each drawing:
|
||||||
|
|
||||||
|
| | 1 (pastel) | 2 | 3 | 4 (dark) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| green | `#A6D8AA` | `#4DB155` | `#258F33` | `#006D09` |
|
||||||
|
| red | `#FAADBC` | `#F45B78` | `#D13F62` | `#AF2850` |
|
||||||
|
| blue | `#A3CCFF` | `#4699FE` | `#1E76D8` | `#0054B3` |
|
||||||
|
| orange | `#F8C592` | `#F08A24` | `#D66B12` | `#B94E08` |
|
||||||
|
| teal | `#80D8D8` | `#00B0B1` | `#008E8F` | `#006C6E` |
|
||||||
|
| purple | `#DDBCEB` | `#BB79D7` | `#9858B3` | `#763790` |
|
||||||
|
|
||||||
|
Stop 1 pops on dark and fades into cream. Stop 4 reads on cream and
|
||||||
|
disappears on dark. A theme switch does not rewrite stored `#006D09`
|
||||||
|
lines. Invert-and-ship leaves those lines looking like they vanished.
|
||||||
|
|
||||||
|
## Colours that work on both
|
||||||
|
|
||||||
|
Stay in the middle of each row. Mid-luminance, reasonably saturated hues
|
||||||
|
contrast with both `#fbf7ef` and a warm dark plot (roughly OKLCH lightness
|
||||||
|
0.55–0.70). That is why the candles already work, and why stops 2–3 work:
|
||||||
|
|
||||||
|
`#4DB155` `#F45B78` `#4699FE` `#F08A24` `#00B0B1` `#BB79D7`
|
||||||
|
|
||||||
|
You cannot keep a four-stop range that is readable on both backgrounds.
|
||||||
|
Two ways out, pick one when this is built:
|
||||||
|
|
||||||
|
1. **Store the name, resolve the hex.** The labels already exist
|
||||||
|
(`green2`). Light and dark each map the name to a hex. Identity
|
||||||
|
survives; appearance follows the theme. New drawings and a one-shot
|
||||||
|
migration of known palette hexes onto names. Custom hexes stay hexes.
|
||||||
|
2. **A 1px opposite-luma hairline** on every series. The fill can then be
|
||||||
|
anything, including saved `#006D09`. Escape hatch for lines that never
|
||||||
|
get a name.
|
||||||
|
|
||||||
|
Do not invent a third palette that claims all eight extremes work on both.
|
||||||
|
They do not.
|
||||||
|
|
||||||
|
## What a dark theme is, and is not
|
||||||
|
|
||||||
|
It is a contrast pass: chrome tokens, grid alpha, accent, pulse mix,
|
||||||
|
shadows, and a decision about named vs hex drawings. It is not a
|
||||||
|
restyle of the product and not a reason to rebuild the chart.
|
||||||
|
|
||||||
|
Do not invert the cream hexes and ship. Do not delete stop 1 and stop 4
|
||||||
|
from the picker without deciding how existing drawings render. Do not
|
||||||
|
treat `prefers-color-scheme` as enough on its own — this is a trading
|
||||||
|
desk, not a marketing page; the choice has to be sticky and explicit,
|
||||||
|
with the system preference as a default only.
|
||||||
|
|
||||||
|
## When this is built
|
||||||
|
|
||||||
|
Change `docs/plan.md` in the same commit if any colour table or “paper
|
||||||
|
theme” description becomes false. This file then records the reasoning,
|
||||||
|
not the live tokens.
|
||||||
356
docs/vite_build.md
Normal file
356
docs/vite_build.md
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
# Vite build — from CDN script tags to a real frontend
|
||||||
|
|
||||||
|
**Status: tracked, not started.** A direction to refactor toward, not a project
|
||||||
|
with a date. Each phase below is worth shipping on its own; none of it is
|
||||||
|
speculative scaffolding for a component rewrite.
|
||||||
|
|
||||||
|
Today `static/index.html` loads Vue 3, Lightweight Charts 5.2.0 and Font Awesome
|
||||||
|
7.3.1 from unpkg, then two plain scripts. FastAPI serves those files and stamps
|
||||||
|
`?v=` onto every `/static/` URL. Production is Coolify + nixpacks + a Python
|
||||||
|
`Procfile`. That is the setup this document replaces.
|
||||||
|
|
||||||
|
## The goal is not "more Vue"
|
||||||
|
|
||||||
|
The target is **a pinned, hashed, minified, same-origin frontend** that we can
|
||||||
|
grow without unpkg and without a Python hasher. View-source today is the
|
||||||
|
entire app. After this, a casual reader should not get `app.js` back. It is
|
||||||
|
not a component split, not TypeScript, not a router, and not leaving Coolify.
|
||||||
|
|
||||||
|
- `chart.js` stays a plain class. Vue still must not wrap chart or series
|
||||||
|
objects in `ref()` / `reactive()`.
|
||||||
|
- `window.__chart` stays. E2E and diagnostic work depend on it.
|
||||||
|
- One `App.vue` holding today's template and `setup()`. Do not extract the
|
||||||
|
color picker or the tool panels in the same change.
|
||||||
|
- Stay on Coolify. The friction is nixpacks autodetection, not the platform.
|
||||||
|
|
||||||
|
## What is already right
|
||||||
|
|
||||||
|
- Vue 3 Composition API in `static/app.js` (`createApp`, `ref`, `computed`,
|
||||||
|
`watch`, `onMounted`). That maps 1:1 onto `vue` from npm.
|
||||||
|
- `ConfluenceChart` is already framework-free. It only needs `export` instead
|
||||||
|
of `window.ConfluenceChart`, and ESM named imports instead of the
|
||||||
|
`LightweightCharts` global.
|
||||||
|
- FastAPI already owns `/`, `/api`, `/ws`. The built SPA still comes from
|
||||||
|
that origin. Do not put a Vite server in production.
|
||||||
|
- Asset hashing exists because a tab left open kept running yesterday's JS
|
||||||
|
(`main.asset_version`, `tests/test_asset_versioning.py`). Vite's content
|
||||||
|
hashes replace that rewriter; the *reason* does not go away.
|
||||||
|
|
||||||
|
## Constraints this repo will punish you for forgetting
|
||||||
|
|
||||||
|
- **The agent is on a different machine from the user's browser.** Local Vite
|
||||||
|
on `localhost:5173` is invisible to them. Whatever serves the UI in dev must
|
||||||
|
still be reachable as `hera.local:8010` (or whatever host port compose
|
||||||
|
publishes). HMR has to work across that hop, or we do not use HMR.
|
||||||
|
- **`--reload` plus an 82-second seed.** Never put a scratch `.py` in the repo
|
||||||
|
root. Frontend files are safe; uvicorn watches Python. Do not "help" by
|
||||||
|
adding a Python build helper at the root.
|
||||||
|
- **Every push to `main` is a production deploy**, and a deploy restarts the
|
||||||
|
market stream. The Vite cutover is one of those deploys. Land the production
|
||||||
|
Dockerfile *before* a root `package.json` exists, or nixpacks may decide
|
||||||
|
this is a Node app and the site goes dark.
|
||||||
|
- **E2E hits `http://api:8000`**, waits on `window.__chart.bars`, and uses
|
||||||
|
`--lang=en-US`. None of that changes. A blank canvas after the move is
|
||||||
|
still the locale bug until proven otherwise.
|
||||||
|
- **Pin what unpkg currently pins.** Lightweight Charts **5.2.0** and Font
|
||||||
|
Awesome **7.3.1**. Vue's CDN tag is `vue@3` (floating). Pin a current Vue
|
||||||
|
3.x on the way in; do not upgrade LWC in this work. v5 series creation is
|
||||||
|
`chart.addSeries(CandlestickSeries, opts)` — the v4 helpers do not exist.
|
||||||
|
- **One uvicorn worker, forever**, until the streamer is a separate process.
|
||||||
|
The Dockerfile `CMD` is the `Procfile` line. Do not add `--workers`.
|
||||||
|
|
||||||
|
## Target layout
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
package.json
|
||||||
|
package-lock.json committed
|
||||||
|
vite.config.js
|
||||||
|
index.html Vite entry; empty #app
|
||||||
|
src/
|
||||||
|
main.js createApp(App).mount('#app')
|
||||||
|
App.vue today's markup + today's setup()
|
||||||
|
chart.js export class ConfluenceChart
|
||||||
|
style.css moved from static/
|
||||||
|
dist/ gitignored; Vite outDir, served by FastAPI
|
||||||
|
Dockerfile production; Coolify prefers this over nixpacks
|
||||||
|
```
|
||||||
|
|
||||||
|
`static/` goes away when FastAPI is serving `dist/` and the e2e suite is green.
|
||||||
|
Do not keep both as a fallback — a missed build would silently serve the CDN
|
||||||
|
app.
|
||||||
|
|
||||||
|
Suggested `frontend/src/main.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||||
|
import './style.css';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested chart import (names used today):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
createChart,
|
||||||
|
CandlestickSeries,
|
||||||
|
HistogramSeries,
|
||||||
|
LineSeries,
|
||||||
|
LineStyle,
|
||||||
|
LineType,
|
||||||
|
CrosshairMode,
|
||||||
|
TickMarkType,
|
||||||
|
} from 'lightweight-charts';
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `export default { setup() { ... return { ... }; } }` in `App.vue`.
|
||||||
|
`<script setup>` is a rewrite of the return bag for no gain.
|
||||||
|
|
||||||
|
## Dev: same origin, same port
|
||||||
|
|
||||||
|
A Vite dev server on 5173 is the usual tutorial and the wrong default here.
|
||||||
|
The user's browser already has one URL. Adding a second public port, plus an
|
||||||
|
HMR websocket that has to reach a remote host, is how this loses a day.
|
||||||
|
|
||||||
|
**Default:** a Node sidecar runs `vite build --watch` into `dist/`. The
|
||||||
|
existing `api` service serves that directory at `/` exactly as production
|
||||||
|
will. Compose still publishes one port. Edits to `.vue` / `.js` / `.css`
|
||||||
|
rebuild hashed assets; the next refresh picks them up. No HMR, no second
|
||||||
|
origin, no proxy for `/ws`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app/frontend
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
command: sh -c "npm ci && npm run build -- --watch"
|
||||||
|
```
|
||||||
|
|
||||||
|
`Dockerfile.dev` stays Python-only. Do not install Node in the API image.
|
||||||
|
|
||||||
|
Optional later, not part of the move: `vite` with `server.host: true` and
|
||||||
|
`server.hmr` pointed at the machine the *browser* can see. Only worth it if
|
||||||
|
the watch-and-refresh loop is actually painful.
|
||||||
|
|
||||||
|
`vite.config.js` needs little for the default path — `base: '/'`,
|
||||||
|
`build.outDir` set so FastAPI and the watcher agree (repo-root `dist/` or
|
||||||
|
`frontend/dist/`, pick one and use it everywhere). Production minify is a
|
||||||
|
requirement, not an option; see below. No `/api` proxy until someone runs
|
||||||
|
the Vite dev server.
|
||||||
|
|
||||||
|
## Production minify
|
||||||
|
|
||||||
|
Vite's production build already minifies JS and CSS with esbuild. Keep that
|
||||||
|
on. Do not set `build.minify: false` to "make debugging easier" — that is
|
||||||
|
what the source tree is for.
|
||||||
|
|
||||||
|
```js
|
||||||
|
build: {
|
||||||
|
minify: 'esbuild',
|
||||||
|
sourcemap: false,
|
||||||
|
cssMinify: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**No source maps in what FastAPI serves.** A `.map` file next to the bundle
|
||||||
|
is the original source with a different URL. `sourcemap: false` is the
|
||||||
|
default; do not turn it on in the config that Coolify builds. Local
|
||||||
|
debugging reads `frontend/src/`, not a map shipped to the browser.
|
||||||
|
|
||||||
|
**Do not put the source tree on the production image.** The multi-stage
|
||||||
|
`COPY . .` below would otherwise copy `frontend/src/` into the container.
|
||||||
|
Even unmounted, that is one Traefik mistake away from being public. The
|
||||||
|
final stage copies `dist/` only. `.dockerignore` must list `frontend/`.
|
||||||
|
|
||||||
|
`vite build --watch` in compose is a production build in a loop, so local
|
||||||
|
and prod stay equally minified. That is what we want. Slower than HMR;
|
||||||
|
acceptable.
|
||||||
|
|
||||||
|
This is a speed bump, not a lock. `window.__chart` remains a deliberate
|
||||||
|
debug handle and e2e depends on it — anyone who knows to open the console
|
||||||
|
still has the wrapper. Minify so View Source is not the codebase; do not
|
||||||
|
delete `__chart` to chase real secrecy.
|
||||||
|
|
||||||
|
## Production: Dockerfile, not nixpacks
|
||||||
|
|
||||||
|
Coolify builds a Dockerfile if one exists, and ignores the `Procfile`. Land
|
||||||
|
that switch as its own deploy, *reproducing today's image*, before the
|
||||||
|
frontend exists:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, when `frontend/` exists, make it multi-stage:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /src
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY main.py Procfile ./
|
||||||
|
COPY app ./app
|
||||||
|
COPY --from=frontend /src/dist /app/dist
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The final stage must not `COPY . .` once `frontend/` exists — that would
|
||||||
|
ship the unminified source next to the bundle. Copy the Python package and
|
||||||
|
`dist/` only.
|
||||||
|
|
||||||
|
Add a `.dockerignore` so `data/`, `.venv`, `node_modules`, `artifacts/`,
|
||||||
|
`.env` and `frontend/` never enter the *final* build context. A missing
|
||||||
|
ignore is how the Schwab token, the drawing store, or `App.vue` gets baked
|
||||||
|
into an image.
|
||||||
|
|
||||||
|
Unchanged, and not Coolify's problem:
|
||||||
|
|
||||||
|
- env vars (`CHART_PASSWORD`, `LIVE_SOURCE`, Schwab keys, ntfy)
|
||||||
|
- the persistent volume at `/app/data`
|
||||||
|
- `SOURCE_COMMIT` → `/api/version` → `bin/wait-deploy`
|
||||||
|
- the domain registered as `chart.amow.com:8000` (Traefik target port)
|
||||||
|
|
||||||
|
The first multi-stage deploy will be a **cold** build (Node layer is new).
|
||||||
|
Expect the ~90s end of the current range, plus `npm ci`. The old container
|
||||||
|
keeps serving until the swap; ignore the usual one-minute 502.
|
||||||
|
|
||||||
|
## FastAPI after the cutover
|
||||||
|
|
||||||
|
`GET /` reads `dist/index.html` and still sends `Cache-Control: no-store`.
|
||||||
|
The document must never be cached, or the hashed filenames inside it are the
|
||||||
|
stale thing instead — same reason as today.
|
||||||
|
|
||||||
|
Mount Vite's hashed directory, not a rewrite pass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
app.mount("/assets", StaticFiles(directory=DIST_DIR / "assets"), name="assets")
|
||||||
|
```
|
||||||
|
|
||||||
|
Those files can be cached for a long time (`immutable`, or a one-year
|
||||||
|
`max-age`). Vite changes the filename when the content changes.
|
||||||
|
|
||||||
|
Delete `asset_version()` and the `ASSET_REF` rewrite. They hash `static/*`
|
||||||
|
and would either no-op or stamp `?v=` onto URLs Vite already uniquely named.
|
||||||
|
|
||||||
|
`tests/test_asset_versioning.py` keeps its purpose, changes its evidence:
|
||||||
|
|
||||||
|
- `/` is `no-store` and references `/assets/…` with a content hash
|
||||||
|
- hashed asset URLs do not need `?v=`
|
||||||
|
- a rebuild after editing a frontend source file changes the hash in the
|
||||||
|
HTML (this one needs the built `dist/` in the test fixture, or a tiny
|
||||||
|
committed stub `dist/` used only by that test — do not hit `npm` from
|
||||||
|
pytest)
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
Each is independently deployable. Do not fold 2–4 into the Dockerfile PR.
|
||||||
|
|
||||||
|
### Phase 1 — Production Dockerfile, still CDN
|
||||||
|
|
||||||
|
Add `Dockerfile` + `.dockerignore`. Confirm `git push && bin/wait-deploy`
|
||||||
|
and `/api/version`. nixpacks is gone; the site is byte-identical.
|
||||||
|
|
||||||
|
This is the phase that makes a later `package.json` safe.
|
||||||
|
|
||||||
|
### Phase 2 — Scaffold `frontend/`, no cutover
|
||||||
|
|
||||||
|
`npm create vite@latest` (Vue, JS, no TS). Pin `vue`, `lightweight-charts@5.2.0`,
|
||||||
|
`@fortawesome/fontawesome-free@7.3.1`. Commit `package-lock.json`. Add
|
||||||
|
`node_modules/` and `dist/` to `.gitignore`.
|
||||||
|
|
||||||
|
Do not add `package.json` at the repo root. nixpacks is already gone after
|
||||||
|
phase 1; keep Node metadata under `frontend/` anyway so a future builder
|
||||||
|
cannot mis-detect the app.
|
||||||
|
|
||||||
|
### Phase 3 — Move the two files, same behaviour
|
||||||
|
|
||||||
|
- `static/chart.js` → `frontend/src/chart.js` with ESM imports and `export`.
|
||||||
|
Drop `window.ConfluenceChart`.
|
||||||
|
- `static/app.js` `setup()` + the `#app` inner HTML → `frontend/src/App.vue`.
|
||||||
|
`import { ConfluenceChart } from './chart.js'`. Keep assigning
|
||||||
|
`window.__chart = chartApi` in `onMounted`.
|
||||||
|
- `static/style.css` → `frontend/src/style.css`.
|
||||||
|
- Font Awesome via the npm CSS import, not the unpkg `<link>`.
|
||||||
|
|
||||||
|
The global Vue build includes the compiler. Vite's Vue plugin compiles SFCs
|
||||||
|
and ships the runtime-only build. That is why the markup has to live in
|
||||||
|
`App.vue` (or another compiled module), not as HTML children of `#app`.
|
||||||
|
|
||||||
|
`npm run build` locally. Open the `dist/` preview against a running API only
|
||||||
|
if you need a sanity check; the real proof is phase 4.
|
||||||
|
|
||||||
|
### Phase 4 — FastAPI serves `dist/`, delete `static/`
|
||||||
|
|
||||||
|
Point `index()` and the static mount at `dist/`. Add the compose `frontend`
|
||||||
|
watcher. Rewrite `test_asset_versioning.py`. Run pytest and `./bin/e2e`.
|
||||||
|
Delete `static/`. Update the Dockerfile to the multi-stage form. Update
|
||||||
|
README / `docs/plan.md` §1 and §9 so they no longer describe unpkg.
|
||||||
|
|
||||||
|
After this, a frontend change that is not rebuilt is not deployed. The
|
||||||
|
multi-stage `Dockerfile` is what builds it on Coolify. Locally the watcher
|
||||||
|
is what builds it. There is no third path.
|
||||||
|
|
||||||
|
## What not to do in this work
|
||||||
|
|
||||||
|
- Do not extract Vue components, add Pinia, Vue Router, or TypeScript.
|
||||||
|
- Do not upgrade Lightweight Charts.
|
||||||
|
- Do not put a Vite origin in production, or a second public port in compose.
|
||||||
|
- Do not leave Coolify, add workers, or move env/volume/TLS anywhere else.
|
||||||
|
- Do not keep `static/` as a fallback once `dist/` is the source of truth.
|
||||||
|
- Do not add a root `package.json` before phase 1 is live.
|
||||||
|
- Do not run `npm` from pytest or from the API container.
|
||||||
|
- Do not ship source maps, serve `frontend/`, or leave `static/` up once
|
||||||
|
`dist/` is live. Any of those undoes minify.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
Same commands as today, plus a frontend build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec chart-api-1 sh -c "cd /app && python -m pytest -q"
|
||||||
|
./bin/e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
E2E still waits on `window.__chart.bars`. If the canvas is blank, check
|
||||||
|
`--lang=en-US` before the bundler. If icons are missing, the FA CSS import
|
||||||
|
did not land. If drawings or the socket die, the page origin changed and
|
||||||
|
`/ws` is not on the same host.
|
||||||
|
|
||||||
|
After the first multi-stage deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push && bin/wait-deploy
|
||||||
|
curl -fsS https://chart.amow.com/api/health
|
||||||
|
curl -fsS https://chart.amow.com/api/version
|
||||||
|
```
|
||||||
|
|
||||||
|
View-source on `/` should show `/assets/…` with a hash and no unpkg script
|
||||||
|
tags. The JS behind that URL should be a single minified file with no
|
||||||
|
`.map`, and fetching `/frontend/src/App.vue` or `/static/app.js` should
|
||||||
|
404. A hard refresh on a tab that was open across the deploy should pick
|
||||||
|
up the new JS without a `?v=` rewriter.
|
||||||
|
|
||||||
|
## When this is done
|
||||||
|
|
||||||
|
`docs/plan.md` §1 currently says "Vue 3 from CDN, **no build step**". That
|
||||||
|
row becomes the lie the day phase 4 ships — change it in the same commit,
|
||||||
|
along with §9's script-tag snippet and the README layout line for `static/`.
|
||||||
|
This file then becomes history, like M0–M10 in the plan.
|
||||||
33
main.py
33
main.py
|
|
@ -3,12 +3,16 @@ import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import re
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api.meta import router as meta_router
|
from app.api.meta import router as meta_router
|
||||||
from app.api.routes import router as api_router
|
from app.api.routes import router as api_router
|
||||||
|
from app.api.schwab_auth import router as schwab_auth_router
|
||||||
from app.api.ws import router as ws_router
|
from app.api.ws import router as ws_router
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.runtime import Runtime
|
from app.runtime import Runtime
|
||||||
|
|
@ -34,10 +38,35 @@ app = FastAPI(title="chart", lifespan=lifespan)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
app.include_router(meta_router)
|
app.include_router(meta_router)
|
||||||
|
app.include_router(schwab_auth_router)
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
|
||||||
|
ASSET_REF = re.compile(r'((?:src|href)="/static/[^"?]+)"')
|
||||||
|
|
||||||
|
|
||||||
|
def asset_version() -> str:
|
||||||
|
"""A digest of the served assets, so the URL changes iff the content does.
|
||||||
|
|
||||||
|
StaticFiles sends an ETag but no Cache-Control, so a browser is free to keep
|
||||||
|
using the copy it already has — and a tab left open simply never fetches
|
||||||
|
again. That turned a fixed bug into a bug that still reproduced, because the
|
||||||
|
page was running the JavaScript it had loaded hours earlier.
|
||||||
|
|
||||||
|
Hashing rather than stamping mtimes: a deploy checks every file out fresh,
|
||||||
|
which would otherwise invalidate assets that never changed.
|
||||||
|
"""
|
||||||
|
digest = sha256()
|
||||||
|
for path in sorted(STATIC_DIR.glob("*.*")):
|
||||||
|
digest.update(path.read_bytes())
|
||||||
|
return digest.hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||||
|
html = ASSET_REF.sub(rf'\1?v={asset_version()}"', html)
|
||||||
|
# The document itself must never be cached, or the versioned URLs inside it
|
||||||
|
# are the stale thing instead.
|
||||||
|
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||||
|
|
|
||||||
112
ops/chart-debug-command
Executable file
112
ops/chart-debug-command
Executable file
|
|
@ -0,0 +1,112 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG=/etc/chart-debug.conf
|
||||||
|
MAX_LOG_LINES=4000
|
||||||
|
|
||||||
|
if [[ ! -r "$CONFIG" ]]; then
|
||||||
|
echo "chart-debug is not configured" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Root-owned configuration written by install-chart-debug.
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$CONFIG"
|
||||||
|
|
||||||
|
original=${1:-${SSH_ORIGINAL_COMMAND:-}}
|
||||||
|
if [[ -z "$original" || "$original" == *$'\n'* || "$original" == *$'\r'* ]]; then
|
||||||
|
echo "usage: logs --since 20m | status | container-state | recent-deploy | capture-read ID | capture-delete ID" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
read -r -a args <<< "$original"
|
||||||
|
|
||||||
|
logger -t chart-debug -- "user=${SUDO_USER:-${USER:-unknown}} from=${SSH_CONNECTION:-local} command=$original"
|
||||||
|
|
||||||
|
resolve_container() {
|
||||||
|
local id name
|
||||||
|
local -a matches=()
|
||||||
|
while read -r id name; do
|
||||||
|
[[ -n "$id" && "$name" =~ $CHART_CONTAINER_PATTERN ]] && matches+=("$id")
|
||||||
|
done < <(docker ps --format '{{.ID}} {{.Names}}')
|
||||||
|
if [[ ${#matches[@]} -ne 1 ]]; then
|
||||||
|
echo "container selector matched ${#matches[@]} running containers" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
printf '%s' "${matches[0]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
redact() {
|
||||||
|
sed -E \
|
||||||
|
-e 's/(Authorization:[[:space:]]*Bearer[[:space:]]+)[A-Za-z0-9._-]+/\1[REDACTED]/Ig' \
|
||||||
|
-e 's/((token|secret|password|api[_-]?key)["=:[:space:]]+)[^,[:space:]"}]+/\1[REDACTED]/Ig' \
|
||||||
|
-e 's/([?&](code|session|state)=)[^&[:space:]]+/\1[REDACTED]/Ig'
|
||||||
|
}
|
||||||
|
|
||||||
|
valid_capture_id() {
|
||||||
|
[[ "$1" =~ ^c-[A-Za-z0-9_-]{12}$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${args[0]}" in
|
||||||
|
logs)
|
||||||
|
[[ ${#args[@]} -eq 3 && "${args[1]}" == "--since" ]] || {
|
||||||
|
echo "usage: logs --since 20m" >&2; exit 2;
|
||||||
|
}
|
||||||
|
[[ "${args[2]}" =~ ^[1-9][0-9]*(s|m|h|d)$ ]] || {
|
||||||
|
echo "invalid duration" >&2; exit 2;
|
||||||
|
}
|
||||||
|
container=$(resolve_container)
|
||||||
|
docker logs --timestamps --tail "$MAX_LOG_LINES" --since "${args[2]}" "$container" 2>&1 | redact
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
[[ ${#args[@]} -eq 1 ]] || { echo "usage: status" >&2; exit 2; }
|
||||||
|
container=$(resolve_container)
|
||||||
|
token=""
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
[[ "$entry" == CHART_AUTH_TOKEN=* ]] && token=${entry#CHART_AUTH_TOKEN=}
|
||||||
|
done < <(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container")
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
[[ "$token" != *$'\n'* && "$token" != *$'\r'* ]] || {
|
||||||
|
echo "invalid CHART_AUTH_TOKEN" >&2; exit 4;
|
||||||
|
}
|
||||||
|
escaped_token=${token//\\/\\\\}
|
||||||
|
escaped_token=${escaped_token//\"/\\\"}
|
||||||
|
printf 'url = "%s/api/status"\nheader = "X-Chart-Token: %s"\nfail\nsilent\nshow-error\n' \
|
||||||
|
"$CHART_PUBLIC_URL" "$escaped_token" | curl --config - | redact
|
||||||
|
else
|
||||||
|
echo "CHART_AUTH_TOKEN is unavailable" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
container-state)
|
||||||
|
[[ ${#args[@]} -eq 1 ]] || { echo "usage: container-state" >&2; exit 2; }
|
||||||
|
container=$(resolve_container)
|
||||||
|
docker inspect --format \
|
||||||
|
'name={{.Name}} image={{.Config.Image}} status={{.State.Status}} started={{.State.StartedAt}} restarts={{.RestartCount}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}' \
|
||||||
|
"$container"
|
||||||
|
;;
|
||||||
|
recent-deploy)
|
||||||
|
[[ ${#args[@]} -eq 1 ]] || { echo "usage: recent-deploy" >&2; exit 2; }
|
||||||
|
curl -fsS "$CHART_PUBLIC_URL/api/version"
|
||||||
|
printf '\n'
|
||||||
|
container=$(resolve_container)
|
||||||
|
docker inspect --format 'container_started={{.State.StartedAt}} image={{.Config.Image}}' "$container"
|
||||||
|
;;
|
||||||
|
capture-read)
|
||||||
|
[[ ${#args[@]} -eq 2 ]] && valid_capture_id "${args[1]}" || {
|
||||||
|
echo "usage: capture-read c-XXXXXXXXXXXX" >&2; exit 2;
|
||||||
|
}
|
||||||
|
container=$(resolve_container)
|
||||||
|
docker exec "$container" base64 "/tmp/chart-captures/${args[1]}.png"
|
||||||
|
;;
|
||||||
|
capture-delete)
|
||||||
|
[[ ${#args[@]} -eq 2 ]] && valid_capture_id "${args[1]}" || {
|
||||||
|
echo "usage: capture-delete c-XXXXXXXXXXXX" >&2; exit 2;
|
||||||
|
}
|
||||||
|
curl -fsS -X DELETE "$CHART_PUBLIC_URL/api/debug/captures/${args[1]}" >/dev/null
|
||||||
|
echo "deleted ${args[1]}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "command not allowed" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
69
ops/install-chart-debug
Executable file
69
ops/install-chart-debug
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: sudo ./ops/install-chart-debug \
|
||||||
|
--public-key 'ssh-ed25519 AAAA...' \
|
||||||
|
--container-pattern '^chart-app-' \
|
||||||
|
[--url https://chart.amow.com]
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
public_key=""
|
||||||
|
container_pattern=""
|
||||||
|
public_url="https://chart.amow.com"
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--public-key) public_key=${2:-}; shift 2 ;;
|
||||||
|
--container-pattern) container_pattern=${2:-}; shift 2 ;;
|
||||||
|
--url) public_url=${2:-}; shift 2 ;;
|
||||||
|
*) usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ $EUID -eq 0 ]] || { echo "run as root" >&2; exit 1; }
|
||||||
|
[[ "$public_key" =~ ^(ssh-ed25519|sk-ssh-ed25519@openssh.com)[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]] || {
|
||||||
|
echo "an Ed25519 public key is required" >&2; exit 2;
|
||||||
|
}
|
||||||
|
[[ -n "$container_pattern" && "$container_pattern" != *$'\n'* ]] || {
|
||||||
|
echo "a container-name regex is required" >&2; exit 2;
|
||||||
|
}
|
||||||
|
[[ "$public_url" =~ ^https://[^/]+$ ]] || { echo "--url must be an HTTPS origin" >&2; exit 2; }
|
||||||
|
|
||||||
|
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
install -o root -g root -m 0755 "$script_dir/chart-debug-command" /usr/local/sbin/chart-debug-command
|
||||||
|
|
||||||
|
if ! id chart-debug >/dev/null 2>&1; then
|
||||||
|
useradd --create-home --shell /bin/bash chart-debug
|
||||||
|
fi
|
||||||
|
passwd --lock chart-debug >/dev/null
|
||||||
|
install -d -o chart-debug -g chart-debug -m 0700 /home/chart-debug/.ssh
|
||||||
|
|
||||||
|
forced='restrict,command="/usr/bin/sudo -n /usr/local/sbin/chart-debug-command \"$SSH_ORIGINAL_COMMAND\""'
|
||||||
|
printf '%s %s\n' "$forced" "$public_key" \
|
||||||
|
> /home/chart-debug/.ssh/authorized_keys
|
||||||
|
chown chart-debug:chart-debug /home/chart-debug/.ssh/authorized_keys
|
||||||
|
chmod 0600 /home/chart-debug/.ssh/authorized_keys
|
||||||
|
|
||||||
|
printf 'CHART_CONTAINER_PATTERN=%q\nCHART_PUBLIC_URL=%q\n' \
|
||||||
|
"$container_pattern" "$public_url" > /etc/chart-debug.conf
|
||||||
|
chown root:root /etc/chart-debug.conf
|
||||||
|
chmod 0600 /etc/chart-debug.conf
|
||||||
|
|
||||||
|
cat > /etc/sudoers.d/chart-debug <<'EOF'
|
||||||
|
Defaults:chart-debug !requiretty
|
||||||
|
chart-debug ALL=(root) NOPASSWD: /usr/local/sbin/chart-debug-command *
|
||||||
|
EOF
|
||||||
|
chmod 0440 /etc/sudoers.d/chart-debug
|
||||||
|
visudo -cf /etc/sudoers.d/chart-debug >/dev/null
|
||||||
|
|
||||||
|
matches=0
|
||||||
|
while read -r _ name; do
|
||||||
|
[[ "$name" =~ $container_pattern ]] && matches=$((matches + 1))
|
||||||
|
done < <(docker ps --format '{{.ID}} {{.Names}}')
|
||||||
|
[[ $matches -eq 1 ]] || {
|
||||||
|
echo "warning: container pattern currently matches $matches running containers" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "installed restricted chart-debug access"
|
||||||
|
|
@ -2,3 +2,6 @@ fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
httpx
|
httpx
|
||||||
pydantic-settings
|
pydantic-settings
|
||||||
|
PyJWT
|
||||||
|
# Live futures stream; imported only when LIVE_SOURCE=schwab.
|
||||||
|
schwab-py
|
||||||
|
|
|
||||||
BIN
screenshot1.png
Normal file
BIN
screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
screenshot2.png
Normal file
BIN
screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
|
|
@ -1,11 +1,21 @@
|
||||||
"""Replay Yahoo's available minute tape and report alerts per CME session."""
|
"""Replay Yahoo's available minute tape and report alerts per CME session.
|
||||||
|
|
||||||
|
Sweeps a range of thresholds in a single pass rather than testing only the
|
||||||
|
configured one: the useful question is where the alert rate crosses from silent
|
||||||
|
to noisy, which a single number cannot show.
|
||||||
|
|
||||||
|
Manual trendlines are deliberately excluded — they are user data, and a
|
||||||
|
threshold calibrated against one person's drawings would not transfer.
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from app.analysis.alerts import AlertEngine
|
from app.analysis.alerts import AlertEngine
|
||||||
from app.analysis.confluence import cluster_levels
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.horizontals import build_prior_day_levels
|
||||||
from app.analysis.indicators import atr
|
from app.analysis.indicators import atr
|
||||||
from app.analysis.moving_averages import build_ma_levels
|
from app.analysis.moving_averages import build_ma_levels
|
||||||
|
from app.analysis.vwap import build_vwap_level
|
||||||
from app.bars.aggregator import Aggregator
|
from app.bars.aggregator import Aggregator
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.bars.session import bucket_start
|
from app.bars.session import bucket_start
|
||||||
|
|
@ -13,6 +23,12 @@ from app.bars.store import InMemoryBarStore
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.market.yahoo import YahooSource
|
from app.market.yahoo import YahooSource
|
||||||
|
|
||||||
|
THRESHOLDS = (12, 16, 20, 24, 28, 32, 40)
|
||||||
|
# Scores are sums of 12s and 16s, so the threshold is quantised and blunt:
|
||||||
|
# several values behave identically and then it falls to zero. Cooldown is the
|
||||||
|
# finer control over how often a zone price is chopping around repeats itself.
|
||||||
|
COOLDOWNS = (900, 1800, 3600, 7200, 14400)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
@ -26,31 +42,71 @@ async def main() -> None:
|
||||||
|
|
||||||
aggregator = Aggregator(settings.enabled_timeframes)
|
aggregator = Aggregator(settings.enabled_timeframes)
|
||||||
store = InMemoryBarStore(25_000)
|
store = InMemoryBarStore(25_000)
|
||||||
levels = []
|
# Keyed on (threshold, cooldown) so one replay pass measures both sweeps.
|
||||||
|
combos = [(threshold, settings.alert_cooldown_seconds) for threshold in THRESHOLDS]
|
||||||
|
combos += [
|
||||||
|
(settings.confluence_min_score, cooldown)
|
||||||
|
for cooldown in COOLDOWNS
|
||||||
|
if cooldown != settings.alert_cooldown_seconds
|
||||||
|
]
|
||||||
|
engines = {combo: AlertEngine(combo[0], combo[1]) for combo in combos}
|
||||||
|
counts: dict[tuple, Counter[int]] = {combo: Counter() for combo in combos}
|
||||||
|
|
||||||
|
ma_levels: list = []
|
||||||
cutoff = minutes[0].t
|
cutoff = minutes[0].t
|
||||||
for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes:
|
for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes:
|
||||||
for bar in aggregator.update(source_bar):
|
for bar in aggregator.update(source_bar):
|
||||||
store.put(bar)
|
store.put(bar)
|
||||||
if settings.ma_sets.get(bar.tf):
|
if settings.ma_sets.get(bar.tf):
|
||||||
levels = build_ma_levels(
|
ma_levels = build_ma_levels(
|
||||||
{tf: store.get(tf) for tf in settings.ma_sets}, settings.ma_sets
|
{tf: store.get(tf) for tf in settings.ma_sets}, settings.ma_sets
|
||||||
)
|
)
|
||||||
if bar.tf is not Timeframe.M1 or not bar.closed:
|
if bar.tf is not Timeframe.M1 or not bar.closed:
|
||||||
continue
|
continue
|
||||||
atr_values = atr(store.get(Timeframe.M15), 14)
|
atr_values = atr(store.get(Timeframe.M15), 14)
|
||||||
atr15 = next((value for value in reversed(atr_values) if value is not None), 0.0)
|
atr15 = next((value for value in reversed(atr_values) if value is not None), 0.0)
|
||||||
|
levels = (
|
||||||
|
ma_levels
|
||||||
|
+ build_prior_day_levels(store.get(Timeframe.D1), bar.c)
|
||||||
|
+ build_vwap_level(store.get(Timeframe.M1))
|
||||||
|
)
|
||||||
|
# Clustering is threshold-independent, so it is done once and the
|
||||||
|
# result fed to every engine.
|
||||||
clusters = cluster_levels(levels, bar.t, bar.c, atr15)
|
clusters = cluster_levels(levels, bar.t, bar.c, atr15)
|
||||||
alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol)
|
session = bucket_start(bar.t, Timeframe.D1)
|
||||||
counts[bucket_start(bar.t, Timeframe.D1)] += len(alerts)
|
for combo, engine in engines.items():
|
||||||
|
alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol)
|
||||||
|
counts[combo][session] += len(alerts)
|
||||||
|
|
||||||
print(f"threshold={settings.confluence_min_score:g} minute_bars={len(minutes)}")
|
sessions = sorted({session for counter in counts.values() for session in counter})
|
||||||
print("alerts/session:", ", ".join(str(value) for _, value in sorted(counts.items())))
|
print(f"minute_bars={len(minutes)} sessions={len(sessions)}")
|
||||||
print(f"total={sum(counts.values())} max_session={max(counts.values(), default=0)}")
|
|
||||||
|
|
||||||
|
def report(title: str, selected: list[tuple]) -> None:
|
||||||
|
print(f"\n{title}")
|
||||||
|
print(f"{'threshold':>9} {'cooldown':>9} {'total':>6} {'max/sess':>9} per-session")
|
||||||
|
for combo in selected:
|
||||||
|
per_session = [counts[combo][session] for session in sessions]
|
||||||
|
configured = combo == (settings.confluence_min_score, settings.alert_cooldown_seconds)
|
||||||
|
print(
|
||||||
|
f"{combo[0]:>9g} {combo[1]:>9} {sum(per_session):>6} "
|
||||||
|
f"{max(per_session, default=0):>9} "
|
||||||
|
f"{', '.join(str(value) for value in per_session)}"
|
||||||
|
f"{' <- configured' if configured else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
report(
|
||||||
|
f"threshold sweep (cooldown={settings.alert_cooldown_seconds}s)",
|
||||||
|
[(threshold, settings.alert_cooldown_seconds) for threshold in THRESHOLDS],
|
||||||
|
)
|
||||||
|
report(
|
||||||
|
f"cooldown sweep (threshold={settings.confluence_min_score:g})",
|
||||||
|
sorted(
|
||||||
|
{(settings.confluence_min_score, cooldown) for cooldown in COOLDOWNS}
|
||||||
|
| {(settings.confluence_min_score, settings.alert_cooldown_seconds)},
|
||||||
|
key=lambda combo: combo[1],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
engine = AlertEngine(settings.confluence_min_score, settings.alert_cooldown_seconds)
|
|
||||||
counts: Counter[int] = Counter()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|
|
||||||
255
scripts/check_schwab.py
Normal file
255
scripts/check_schwab.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
"""Find out what a Schwab app is actually entitled to, before building on it.
|
||||||
|
|
||||||
|
Answers the three questions that decide the design, empirically rather than
|
||||||
|
from documentation:
|
||||||
|
|
||||||
|
1. Do the credentials authenticate at all?
|
||||||
|
2. Do REST quotes work for /ES — futures market data is a separate
|
||||||
|
entitlement from equities and may not be granted.
|
||||||
|
3. Does the streamer connect? Its bootstrap reads /trader/v1/userPreference,
|
||||||
|
which belongs to the Accounts and Trading product, so an app registered
|
||||||
|
for Market Data Production alone is expected to fail there.
|
||||||
|
|
||||||
|
Two steps, neither of them interactive, so this works over a pipe or from an
|
||||||
|
agent session where stdin is not a terminal:
|
||||||
|
|
||||||
|
python3 -m scripts.check_schwab
|
||||||
|
prints the Schwab login URL.
|
||||||
|
|
||||||
|
python3 -m scripts.check_schwab --redirect-url 'https://.../api/qt?code=…'
|
||||||
|
exchanges the code, saves the token, runs the checks.
|
||||||
|
|
||||||
|
The browser does not have to be on this machine. Nothing is captured locally —
|
||||||
|
you copy a URL out, and paste a URL back. The authorisation code is single use
|
||||||
|
and expires within minutes, so do not leave it sitting between the two steps.
|
||||||
|
|
||||||
|
Once a token exists, running with no arguments skips straight to the checks.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def heading(text: str) -> None:
|
||||||
|
print(f"\n{text}\n{'-' * len(text)}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings() -> Settings:
|
||||||
|
settings = Settings()
|
||||||
|
if not settings.schwab_api_key or not settings.schwab_app_secret:
|
||||||
|
raise SystemExit("Set SCHWAB_API_KEY and SCHWAB_APP_SECRET in .env first")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def key_is_live(authorization_url: str) -> bool:
|
||||||
|
"""Ask Schwab whether it recognises the app key, before opening a browser.
|
||||||
|
|
||||||
|
A key Schwab does not know produces `invalid_client` here — and so does a
|
||||||
|
deliberately invented one, byte for byte, so this cannot tell "wrong value"
|
||||||
|
from "not active yet". It can still save a confusing round trip through the
|
||||||
|
login page.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.get(authorization_url, follow_redirects=False, timeout=15)
|
||||||
|
except Exception:
|
||||||
|
return True # Network trouble is not evidence about the key.
|
||||||
|
return "invalid_client" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def secret_is_valid(settings: Settings) -> bool | None:
|
||||||
|
"""Check the secret without needing an authorisation code.
|
||||||
|
|
||||||
|
The token endpoint authenticates the key and secret over HTTP Basic before
|
||||||
|
it looks at the grant, so a deliberately invalid code separates the two
|
||||||
|
failures: bad credentials give invalid_client, good credentials give
|
||||||
|
invalid_grant. Otherwise a truncated secret survives the login unnoticed and
|
||||||
|
only surfaces at the exchange, after the code has been spent.
|
||||||
|
|
||||||
|
None when the answer is not clear enough to act on.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.post(
|
||||||
|
"https://api.schwabapi.com/v1/oauth/token",
|
||||||
|
auth=(settings.schwab_api_key, settings.schwab_app_secret),
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "deliberately-invalid-code",
|
||||||
|
"redirect_uri": settings.schwab_callback_url,
|
||||||
|
},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if "invalid_grant" in response.text:
|
||||||
|
return True
|
||||||
|
if "invalid_client" in response.text or response.status_code == 401:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def print_login_url(settings: Settings) -> None:
|
||||||
|
from schwab.auth import get_auth_context
|
||||||
|
|
||||||
|
context = get_auth_context(settings.schwab_api_key, settings.schwab_callback_url)
|
||||||
|
|
||||||
|
if secret_is_valid(settings) is False:
|
||||||
|
print("Schwab rejects the app key and secret pair.\n")
|
||||||
|
print("The key alone is accepted at the authorize endpoint, so this is")
|
||||||
|
print("the secret. Re-copy it from the portal using the Show icon —")
|
||||||
|
print("a value clipped by one character looks entirely normal.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not key_is_live(context.authorization_url):
|
||||||
|
print("Schwab rejects this app key with invalid_client.\n")
|
||||||
|
print("The secret is not involved yet — it is only used when the code is")
|
||||||
|
print("exchanged — so this is the key itself or the app's readiness.\n")
|
||||||
|
print(" 1. Re-copy the App Key from the portal using the Show icon.")
|
||||||
|
print(" 2. If it matches, the app is most likely not live yet. Newly")
|
||||||
|
print(" created or newly edited apps take time to propagate, and the")
|
||||||
|
print(" portal says Ready For Use before the key works.")
|
||||||
|
print("\nRe-run this to check again; nothing else is needed.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Open this in any browser, on any machine, and approve the app:\n")
|
||||||
|
print(f" {context.authorization_url}\n")
|
||||||
|
print("You will land on the callback URL. A 404 there is fine until the")
|
||||||
|
print("branch is deployed — the code is in the address bar either way.")
|
||||||
|
print("Copy the ENTIRE address and run:\n")
|
||||||
|
print(" python3 -m scripts.check_schwab --redirect-url '<paste it here>'")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_token_path_writable(settings: Settings) -> None:
|
||||||
|
path = settings.schwab_token_path
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
probe = path.parent / f".{path.name}.probe"
|
||||||
|
probe.touch()
|
||||||
|
probe.unlink()
|
||||||
|
except OSError as error:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Cannot write the token to {path} ({error.strerror}).\n"
|
||||||
|
f"Point SCHWAB_TOKEN_PATH at a directory you own and try again — "
|
||||||
|
f"the authorisation code is spent either way, so fix this first."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exchange(settings: Settings, redirect_url: str):
|
||||||
|
from schwab import auth
|
||||||
|
from schwab.auth import AuthContext, client_from_received_url
|
||||||
|
|
||||||
|
state = parse_qs(urlparse(redirect_url).query).get("state", [None])[0]
|
||||||
|
if not state:
|
||||||
|
raise SystemExit("That URL has no ?state= — paste the full address you landed on")
|
||||||
|
|
||||||
|
# Checked before the exchange, not after. An authorisation code lives about
|
||||||
|
# thirty seconds and is single use, so discovering an unwritable token path
|
||||||
|
# afterwards costs a whole round trip through the browser — which is exactly
|
||||||
|
# what happened the first time, against a data/ directory owned by root
|
||||||
|
# because Docker created it through the bind mount.
|
||||||
|
ensure_token_path_writable(settings)
|
||||||
|
# The library's own writer, so the token file keeps the shape its loader
|
||||||
|
# expects rather than one guessed at here.
|
||||||
|
write_token = getattr(auth, "__make_update_token_func")(str(settings.schwab_token_path))
|
||||||
|
|
||||||
|
# Only the state is needed again; the authorisation URL is not, which is
|
||||||
|
# what lets the two steps share nothing. Taking the state from the pasted
|
||||||
|
# URL makes the CSRF check a formality — acceptable because the thing being
|
||||||
|
# guarded against is a redirect you did not initiate, and you pasted this
|
||||||
|
# one in by hand.
|
||||||
|
context = AuthContext(settings.schwab_callback_url, None, state)
|
||||||
|
return client_from_received_url(
|
||||||
|
settings.schwab_api_key,
|
||||||
|
settings.schwab_app_secret,
|
||||||
|
context,
|
||||||
|
redirect_url,
|
||||||
|
write_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_checks(client, settings: Settings) -> None:
|
||||||
|
heading(f"REST quote for {settings.schwab_symbol}")
|
||||||
|
quote = client.get_quote(settings.schwab_symbol)
|
||||||
|
print(f" HTTP {quote.status_code}")
|
||||||
|
payload = quote.json() if quote.status_code == 200 else {}
|
||||||
|
quotes_ok = False
|
||||||
|
if payload:
|
||||||
|
for symbol, data in list(payload.items())[:1]:
|
||||||
|
values = data.get("quote", {})
|
||||||
|
kind = data.get("assetMainType")
|
||||||
|
description = (data.get("reference") or {}).get("description")
|
||||||
|
print(f" {symbol}: {kind} — {description}")
|
||||||
|
print(f" last={values.get('lastPrice')} bid={values.get('bidPrice')} "
|
||||||
|
f"ask={values.get('askPrice')}")
|
||||||
|
# Schwab strips the leading slash and happily returns the equity of
|
||||||
|
# the same name: /ES comes back as Eversource Energy at 72. A 200
|
||||||
|
# with a body is not evidence of futures data, and treating it as
|
||||||
|
# such is worse than a clean failure.
|
||||||
|
quotes_ok = kind == "FUTURE"
|
||||||
|
if not quotes_ok:
|
||||||
|
print(" -> NOT futures. The slash was stripped and an equity")
|
||||||
|
print(" returned in its place; REST futures quotes are unavailable.")
|
||||||
|
else:
|
||||||
|
print(" -> futures market data IS available over REST")
|
||||||
|
else:
|
||||||
|
print(f" body: {quote.text[:200]}")
|
||||||
|
print(" -> no quote returned")
|
||||||
|
|
||||||
|
heading("Streamer bootstrap (/trader/v1/userPreference)")
|
||||||
|
prefs = client.get_user_preferences()
|
||||||
|
print(f" HTTP {prefs.status_code}")
|
||||||
|
streaming_ok = prefs.status_code == 200 and bool(prefs.json().get("streamerInfo"))
|
||||||
|
if streaming_ok:
|
||||||
|
print(f" streamerInfo entries: {len(prefs.json()['streamerInfo'])}")
|
||||||
|
print(" -> streaming is available; CHART_FUTURES should work")
|
||||||
|
else:
|
||||||
|
print(f" body: {prefs.text[:200]}")
|
||||||
|
print(" -> streaming is NOT available on this app")
|
||||||
|
|
||||||
|
heading("Summary")
|
||||||
|
print(f" Quotes : {'yes' if quotes_ok else 'no'}")
|
||||||
|
print(f" Streaming : {'yes' if streaming_ok else 'no'}")
|
||||||
|
if quotes_ok and not streaming_ok:
|
||||||
|
print("\n Polling REST quotes is then the real-time path: it removes")
|
||||||
|
print(" Yahoo's ten-minute delay without needing trading scope, at the")
|
||||||
|
print(" cost of building bars from snapshots rather than receiving")
|
||||||
|
print(" true exchange OHLCV.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--redirect-url", help="the full URL you were redirected to")
|
||||||
|
args = parser.parse_args()
|
||||||
|
settings = load_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from schwab.auth import client_from_token_file
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit("pip install -r requirements-dev.txt (schwab-py is not installed)")
|
||||||
|
|
||||||
|
if args.redirect_url:
|
||||||
|
heading("Exchanging the authorisation code")
|
||||||
|
client = exchange(settings, args.redirect_url)
|
||||||
|
print(f" token written to {settings.schwab_token_path}")
|
||||||
|
elif settings.schwab_token_path.exists():
|
||||||
|
heading("Authentication")
|
||||||
|
client = client_from_token_file(
|
||||||
|
str(settings.schwab_token_path),
|
||||||
|
settings.schwab_api_key,
|
||||||
|
settings.schwab_app_secret,
|
||||||
|
)
|
||||||
|
print(f" reused the token at {settings.schwab_token_path}")
|
||||||
|
else:
|
||||||
|
print_login_url(settings)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
run_checks(client, settings)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
98
scripts/check_stream.py
Normal file
98
scripts/check_stream.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Does the Schwab stream actually deliver /ES bars?
|
||||||
|
|
||||||
|
REST is already known not to: a quote for /ES comes back as Eversource Energy,
|
||||||
|
because Schwab strips the leading slash and resolves the equity of the same
|
||||||
|
name. Streaming is a separate entitlement with its own services, so it has to be
|
||||||
|
tested separately — and it is the only remaining route to real-time futures,
|
||||||
|
since price history does not cover them either.
|
||||||
|
|
||||||
|
Subscribes to both futures services for a short window and reports what arrives:
|
||||||
|
|
||||||
|
python3 -m scripts.check_stream [seconds] [symbol ...]
|
||||||
|
|
||||||
|
Symbols default to the continuous /ES and the front-month contract, because the
|
||||||
|
streamer may accept one and not the other — REST accepts neither.
|
||||||
|
|
||||||
|
CHART_FUTURES is the one that matters — it carries the minute OHLCV the chart is
|
||||||
|
built on. LEVEL_ONE_FUTURES is the fallback: quotes only, from which bars would
|
||||||
|
have to be synthesised.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
received: dict[str, list] = {"chart": [], "quote": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def main(seconds: float, symbols: list[str]) -> None:
|
||||||
|
from schwab.auth import client_from_token_file
|
||||||
|
from schwab.streaming import StreamClient
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
if not settings.schwab_token_path.exists():
|
||||||
|
raise SystemExit("No token yet — run: python3 -m scripts.check_schwab")
|
||||||
|
|
||||||
|
client = client_from_token_file(
|
||||||
|
str(settings.schwab_token_path),
|
||||||
|
settings.schwab_api_key,
|
||||||
|
settings.schwab_app_secret,
|
||||||
|
asyncio=True,
|
||||||
|
)
|
||||||
|
stream = StreamClient(client)
|
||||||
|
|
||||||
|
print("logging in to the streamer...")
|
||||||
|
await stream.login()
|
||||||
|
print(" logged in")
|
||||||
|
|
||||||
|
# Handlers must be registered before subscribing: several services start
|
||||||
|
# sending immediately, and messages with no handler are dropped.
|
||||||
|
stream.add_chart_futures_handler(lambda msg: received["chart"].append(msg))
|
||||||
|
stream.add_level_one_futures_handler(lambda msg: received["quote"].append(msg))
|
||||||
|
|
||||||
|
for name, subscribe in (
|
||||||
|
("CHART_FUTURES", stream.chart_futures_subs),
|
||||||
|
("LEVEL_ONE_FUTURES", stream.level_one_futures_subs),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
await subscribe(symbols)
|
||||||
|
print(f" subscribed to {name} for {', '.join(symbols)}")
|
||||||
|
except Exception as error:
|
||||||
|
print(f" {name} subscription REJECTED: {type(error).__name__}: {error}")
|
||||||
|
|
||||||
|
print(f"\nlistening for {seconds:g}s...")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_pump(stream), timeout=seconds)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print("\nResults")
|
||||||
|
print("-------")
|
||||||
|
for label, key in (("CHART_FUTURES (minute OHLCV)", "chart"),
|
||||||
|
("LEVEL_ONE_FUTURES (quotes)", "quote")):
|
||||||
|
messages = received[key]
|
||||||
|
print(f" {label}: {len(messages)} message(s)")
|
||||||
|
if messages:
|
||||||
|
print(f" sample: {str(messages[0])[:300]}")
|
||||||
|
|
||||||
|
if received["chart"]:
|
||||||
|
print("\n -> CHART_FUTURES works. Real-time minute bars are available,")
|
||||||
|
print(" which removes Yahoo's ten-minute delay entirely.")
|
||||||
|
elif received["quote"]:
|
||||||
|
print("\n -> Only quotes arrived. Bars would have to be synthesised")
|
||||||
|
print(" from them: real-time, but highs and lows approximated.")
|
||||||
|
else:
|
||||||
|
print("\n -> Nothing arrived. Either futures market data is not")
|
||||||
|
print(" entitled on this account, or the market is closed.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _pump(stream) -> None:
|
||||||
|
while True:
|
||||||
|
await stream.handle_message()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = sys.argv[1:]
|
||||||
|
window = float(args[0]) if args else 45
|
||||||
|
wanted = args[1:] or ["/ES", "/ESU26"]
|
||||||
|
asyncio.run(main(window, wanted))
|
||||||
1558
static/app.js
1558
static/app.js
File diff suppressed because it is too large
Load diff
3424
static/chart.js
3424
static/chart.js
File diff suppressed because it is too large
Load diff
|
|
@ -3,86 +3,423 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>/ES Confluence</title>
|
<title>/ESsent</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<!-- Font Awesome Free 7.3.1, from unpkg like the other two dependencies.
|
||||||
|
Pinned deliberately: an unpinned icon set is a silent redesign on someone
|
||||||
|
else's release. cdnjs 404s on this path — it does not carry 7.3.1. -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@fortawesome/fontawesome-free@7.3.1/css/all.min.css">
|
||||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
<script src="https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<script src="https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header>
|
<header>
|
||||||
<div><span class="eyebrow">CME FUTURES</span><h1>/ES <strong>CONFLUENCE</strong></h1></div>
|
<h1><strong>/ES</strong>sent</h1>
|
||||||
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
|
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}<a v-if="status.needs_login" class="reconnect" href="/api/schwab/login">reconnect</a></div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section class="chart-shell">
|
<section class="chart-shell">
|
||||||
<div class="chart-head">
|
<div class="chart-head">
|
||||||
<div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div>
|
<div class="quote"><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span><span class="quote-change" :class="quoteChange == null ? '' : quoteChange.points > 0 ? 'positive' : quoteChange.points < 0 ? 'negative' : 'flat'"><template v-if="quoteChange">{{ quoteChange.points >= 0 ? '+' : '' }}{{ quoteChange.points.toFixed(2) }} ({{ quoteChange.percent >= 0 ? '+' : '' }}{{ quoteChange.percent.toFixed(2) }}%)</template><template v-else>— (—%)</template></span></div>
|
||||||
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
|
<div class="chart-head-tools">
|
||||||
|
<button type="button" class="undo-btn" :disabled="!canUndo" :title="undoTitle" aria-label="Undo" @click="undo"><i class="fa-solid fa-rotate-left"></i></button>
|
||||||
|
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawing-tools">
|
<div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
|
||||||
<button :class="{active: drawMode}" @click="toggleDraw">Trendline</button>
|
|
||||||
<input class="line-name" v-model.trim="drawName" placeholder="Line name" aria-label="Trendline name">
|
|
||||||
<input type="color" v-model="drawColor" aria-label="New trendline color">
|
|
||||||
<select v-model.number="drawWidth" aria-label="New trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
|
|
||||||
<select v-model="drawSide" aria-label="Line side"><option value="support">Support</option><option value="resistance">Resistance</option></select>
|
|
||||||
<label><input type="checkbox" v-model="snap">Snap</label>
|
|
||||||
<button @click="deleteSelected" :disabled="!hasLineSelection">Delete</button>
|
|
||||||
<span v-if="drawMode">{{ drawPoints.length ? 'Place second point' : 'Place first point' }}</span>
|
|
||||||
<span v-else-if="selectedLine">Line selected</span>
|
|
||||||
</div>
|
|
||||||
<div id="chart"></div>
|
|
||||||
<div class="statusbar">
|
<div class="statusbar">
|
||||||
<span>FEED <b>{{ status.stream }}</b></span>
|
<span v-if="extraDetail">FEED <b>{{ status.stream }}</b></span>
|
||||||
<span>LAST BAR <b>{{ barAge }}</b></span>
|
<span v-if="extraDetail">LAST BAR <b>{{ barAge }}</b></span>
|
||||||
<span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
|
<span v-if="extraDetail">HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
|
||||||
|
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
|
||||||
|
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
|
||||||
|
<span v-else-if="armedTool === 'fibonacci'" class="arm-hint">Drag from one swing to the other</span>
|
||||||
|
<span v-else-if="selectedDrawing" class="arm-hint">Drawing selected — Delete removes it</span>
|
||||||
|
<button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic">
|
||||||
|
{{ captureCountdown ? `CAPTURE IN ${captureCountdown}…` : captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
|
||||||
|
</button>
|
||||||
|
<span class="data-freshness"><span>BUILD <b>{{ buildStamp }}</b></span><span>UPDATED <b>{{ dataUpdatedAt }}</b></span></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside>
|
<aside>
|
||||||
<h2>Layers</h2>
|
<details class="sidebar-section">
|
||||||
<div class="layer-group">
|
<summary>Layers</summary>
|
||||||
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
<details class="layer-group ma-fold" open>
|
||||||
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
|
<summary>
|
||||||
</div>
|
<i class="fa-solid fa-chevron-down ma-fold-caret" aria-hidden="true"></i>
|
||||||
<div class="layer-group optional">
|
<label class="master" @click.stop><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
||||||
<label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</label>
|
</summary>
|
||||||
</div>
|
<div class="ma-rows">
|
||||||
<div class="layer-group">
|
<div v-for="period in [10,20,50,100,200]" :key="period" class="ma-row">
|
||||||
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
|
<label><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label>
|
||||||
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
|
<span class="ma-value">{{ maValue(period) }}</span>
|
||||||
</div>
|
<button type="button" class="ma-alert" :class="{on: maAlertOn(period)}" :aria-pressed="maAlertOn(period)" :title="maAlertOn(period) ? 'Stop alerting on this average' : 'Alert when price reaches this average'" @click="toggleMaAlert(period)"><i :class="maAlertOn(period) ? 'fa-solid fa-bell' : 'fa-solid fa-bell-slash'"></i></button>
|
||||||
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
</div>
|
||||||
<details class="sidebar-section" open>
|
|
||||||
<summary>Trendlines ({{ manualLines.length }})</summary>
|
|
||||||
<div class="trendline-actions" v-if="manualLines.length">
|
|
||||||
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
|
|
||||||
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="!manualLines.length" class="empty">No manual trendlines.</div>
|
|
||||||
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
|
|
||||||
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
|
|
||||||
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
|
|
||||||
<span>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
|
|
||||||
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
|
|
||||||
<div class="line-style-controls" @click.stop>
|
|
||||||
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
|
|
||||||
<select :value="line.line_width || 2" aria-label="Trendline width" @change="updateLineStyle(line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
|
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="layer-group">
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.rth"><span class="swatch rth"></span>SPY open/close</label>
|
||||||
|
</div>
|
||||||
|
<div class="layer-group">
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.drawings"><span class="swatch drawings"></span>Drawings</label>
|
||||||
|
</div>
|
||||||
|
<div class="layer-group layer-inline">
|
||||||
|
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
|
||||||
|
<label class="disabled" title="Automatic trendline detection is not built yet">(<input type="checkbox" disabled>auto)</label>
|
||||||
|
</div>
|
||||||
|
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
||||||
|
</details>
|
||||||
|
<details class="sidebar-section config-section">
|
||||||
|
<summary>Config</summary>
|
||||||
|
<label title="Today's session high and low as short ticks at the live edge"><input type="checkbox" v-model="sessionRange"> Session high / low</label>
|
||||||
|
<label><input type="checkbox" v-model="confluenceAlerts"> Confluence alerts</label>
|
||||||
|
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
|
||||||
|
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>
|
||||||
|
<label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label>
|
||||||
|
<label><input type="checkbox" v-model="extraDetail"> Extra detail</label>
|
||||||
|
<label title="Daily and hourly trendlines still show on 1m; 1m trendlines stay off 1h and 1d"><input type="checkbox" v-model="prefs.hide_finer_trendlines"> Hide finer-timeframe trendlines</label>
|
||||||
|
</details>
|
||||||
|
<details class="sidebar-section tools-section" open>
|
||||||
|
<summary>Tools</summary>
|
||||||
|
<div class="tool tool-mark" :class="{armed: armedTool === 'symbol'}">
|
||||||
|
<div class="tool-head symbol-head" title="Choose a mark and click the chart, or drag a mark button onto it. Marks pin to that bar and price and can be dragged later with their grip.">
|
||||||
|
<button class="symbol-arm" @click="toggleSymbolPanel" :aria-expanded="symbolPanelOpen" :aria-pressed="armedTool === 'symbol'">
|
||||||
|
<span class="tool-glyph"><i class="fa-solid fa-icons"></i></span>Mark
|
||||||
|
</button>
|
||||||
|
<details class="color-picker symbol-head-color" @click.stop>
|
||||||
|
<summary :title="drawingColorName(symbolColor)"
|
||||||
|
:aria-label="`Choose symbol color; current color ${drawingColorName(symbolColor)}`"
|
||||||
|
:style="{backgroundColor: symbolColor}"></summary>
|
||||||
|
<div class="color-popover">
|
||||||
|
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
|
||||||
|
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input"
|
||||||
|
:aria-label="`${row.id} row annotation`" @click.stop
|
||||||
|
@blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
|
||||||
|
<div class="color-family-swatches">
|
||||||
|
<button v-for="color in row.colors" :key="color" type="button"
|
||||||
|
:class="{selected: color === symbolColor}" :style="{backgroundColor: color}"
|
||||||
|
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
|
||||||
|
@click.stop="symbolColor = color; $event.currentTarget.closest('details').open = false"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-popover-footer">
|
||||||
|
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="symbolColor" aria-label="Custom symbol color"></label>
|
||||||
|
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel"
|
||||||
|
@click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<span class="tool-state">{{ armedTool === 'symbol' ? 'click chart' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tool-body" v-show="symbolPanelOpen">
|
||||||
|
<div class="symbol-palette" aria-label="Mark choices">
|
||||||
|
<button v-for="symbol in symbolChoices" :key="symbol.icon" type="button" draggable="true"
|
||||||
|
:class="{active: selectedSymbol === symbol.icon}" :title="symbol.name" :aria-label="symbol.name"
|
||||||
|
@click="chooseSymbol(symbol)" @dragstart="startSymbolDrag($event, symbol)">
|
||||||
|
<i :class="`fa-solid fa-${symbol.icon}`"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label>Size<select v-model.number="symbolScale" aria-label="Mark size">
|
||||||
|
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool tool-level" :class="{armed: armedTool === 'level'}">
|
||||||
|
<button class="tool-head" @click="armTool('level')" :aria-pressed="armedTool === 'level'">
|
||||||
|
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
|
||||||
|
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="tool-body" v-show="armedTool === 'level'">
|
||||||
|
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
|
||||||
|
<div class="row">
|
||||||
|
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
|
||||||
|
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
|
||||||
|
</div>
|
||||||
|
<label>Alert early (pts)<input type="number" min="0" :step="tick" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
|
||||||
|
<form class="row price-row" @submit.prevent="addPriceAlert">
|
||||||
|
<label>Price<input type="number" :step="tick" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
|
||||||
|
<button type="submit" :disabled="!alertPrice">Add</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool" :class="{armed: armedTool === 'fibonacci'}">
|
||||||
|
<button class="tool-head" @click="armTool('fibonacci')" :aria-pressed="armedTool === 'fibonacci'">
|
||||||
|
<span class="tool-glyph"><i class="fa-solid fa-chart-line"></i></span>Fibonacci
|
||||||
|
<span class="tool-state">{{ armedTool === 'fibonacci' ? 'drag swings' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="tool-body" v-show="armedTool === 'fibonacci'">
|
||||||
|
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Fibonacci label"></label>
|
||||||
|
<div class="row">
|
||||||
|
<label>Colour<input type="color" v-model="drawColor" aria-label="Fibonacci colour"></label>
|
||||||
|
<label>Width<select v-model.number="drawWidth" aria-label="Fibonacci width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
|
||||||
|
<p class="hint">Drag from one swing to the other. Levels at 0, 23.6, 38.2, 50, 61.8, 78.6 and 100.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool tool-trendline" :class="{armed: armedTool === 'trendline'}">
|
||||||
|
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
|
||||||
|
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
|
||||||
|
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="tool-body" v-show="armedTool === 'trendline'">
|
||||||
|
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
|
||||||
|
<div class="row">
|
||||||
|
<div class="tool-color"><span>Colour</span>
|
||||||
|
<details class="color-picker" @click.stop>
|
||||||
|
<summary :title="drawingColorName(drawColor)" :aria-label="`Choose trendline color; current color ${drawingColorName(drawColor)}`" :style="{backgroundColor: drawColor}"></summary>
|
||||||
|
<div class="color-popover">
|
||||||
|
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
|
||||||
|
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
|
||||||
|
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
|
||||||
|
:class="{selected: color === drawColor}" :style="{backgroundColor: color}"
|
||||||
|
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
|
||||||
|
@click.stop="drawColor = color; $event.currentTarget.closest('details').open = false"></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="color-popover-footer">
|
||||||
|
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="drawColor" aria-label="Custom trendline color"></label>
|
||||||
|
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<!-- Side is inferred from the extreme you snap to, so it only has
|
||||||
|
anything to say when snapping is off. -->
|
||||||
|
<label v-if="!snap">Side<select v-model="drawSide" aria-label="Trendline side"><option value="support">Support</option><option value="resistance">Resistance</option></select></label>
|
||||||
|
<span v-else class="side-auto" title="A high is resistance, a low is support">Side <b>auto</b></span>
|
||||||
|
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool tool-comment" :class="{armed: armedTool === 'comment'}">
|
||||||
|
<button class="tool-head" @click="armTool('comment')" :aria-pressed="armedTool === 'comment'">
|
||||||
|
<span class="tool-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
|
||||||
|
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="tool-body" v-show="armedTool === 'comment'">
|
||||||
|
<label>Text<textarea v-model.trim="commentText" rows="2" placeholder="What happened here?" aria-label="Comment text"></textarea></label>
|
||||||
|
<label class="check"><input type="checkbox" v-model="commentFloat">Float (stay on screen)</label>
|
||||||
|
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="sidebar-section options-section" @toggle="onOptionsToggle">
|
||||||
|
<summary>Options</summary>
|
||||||
|
<div class="options-body">
|
||||||
|
<label>Expiration
|
||||||
|
<select v-model="optionExpiryId" :disabled="!optionExpirations.length" aria-label="Option expiration">
|
||||||
|
<option v-if="!optionExpirations.length" value="">Open to load</option>
|
||||||
|
<option v-for="row in optionExpirations" :key="row.id" :value="row.id">{{ row.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<label>Side
|
||||||
|
<select v-model="optionSide" aria-label="Calls or puts">
|
||||||
|
<option value="P">Puts</option>
|
||||||
|
<option value="C">Calls</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Filter
|
||||||
|
<select v-model="optionMode" aria-label="Filter by delta or price">
|
||||||
|
<option value="delta">Delta</option>
|
||||||
|
<option value="price">Price</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>From<input type="number" step="any" v-model.number="optionMin" :aria-label="optionMode === 'delta' ? 'Minimum delta' : 'Minimum mark'"></label>
|
||||||
|
<label>To<input type="number" step="any" v-model.number="optionMax" :aria-label="optionMode === 'delta' ? 'Maximum delta' : 'Maximum mark'"></label>
|
||||||
|
</div>
|
||||||
|
<div class="row price-row">
|
||||||
|
<button type="button" :disabled="optionBusy || !optionExpiryId" @click="searchOptions">{{ optionBusy ? 'Searching…' : optionContracts.length ? 'Refresh' : 'Search' }}</button>
|
||||||
|
<span class="hint" v-if="optionUnderlying">/ES {{ optionUnderlying.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" v-if="optionError">{{ optionError }}</p>
|
||||||
|
<p class="hint" v-else-if="!optionSearched">No quotes until you search.</p>
|
||||||
|
<p class="hint" v-else-if="!optionContracts.length">No contracts in that range.</p>
|
||||||
|
<div v-else class="option-results">
|
||||||
|
<div class="option-row option-head">
|
||||||
|
<span>Strike</span><span>Mark</span><span>Δ ≈</span><span></span>
|
||||||
|
</div>
|
||||||
|
<div v-for="row in optionContracts" :key="row.symbol" class="option-row">
|
||||||
|
<span>{{ row.strike.toFixed(0) }}</span>
|
||||||
|
<span>{{ row.mark.toFixed(2) }}</span>
|
||||||
|
<span>{{ row.abs_delta == null ? '—' : row.abs_delta.toFixed(2) }}</span>
|
||||||
|
<button type="button" :title="`${row.tos} · ${row.bid ?? '—'} × ${row.ask ?? '—'} · vol ${row.volume} · oi ${row.open_interest}`" @click="copyOption(row)">{{ optionCopied === row.symbol ? 'Copied' : 'Copy' }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" v-if="optionContracts.length">Bid/ask on copy target. Δ is approximate.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="sidebar-section drawings-section" open>
|
||||||
|
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
|
||||||
|
<div class="drawing-filters">
|
||||||
|
<select v-model="drawingKind" aria-label="Filter drawings by type">
|
||||||
|
<option value="all">All types</option>
|
||||||
|
<option value="trendline">Trendlines</option>
|
||||||
|
<option value="level">Levels</option>
|
||||||
|
<option value="comment">Comments</option>
|
||||||
|
<option value="symbol">Symbols</option>
|
||||||
|
<option value="fibonacci">Fibonacci</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="drawingTf" aria-label="Filter drawings by timeframe">
|
||||||
|
<option value="all">All TFs</option>
|
||||||
|
<option v-for="tf in timeframes" :key="tf" :value="tf">{{ tf }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="drawingFilter" placeholder="Filter text" aria-label="Filter drawings by text">
|
||||||
|
</div>
|
||||||
|
<div class="trendline-actions" v-if="drawings.length">
|
||||||
|
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>
|
||||||
|
<span class="selection-count">{{ selectedDrawings.length }} selected</span>
|
||||||
|
<button @click="toggleSelectedVisibility" :disabled="!selectedDrawings.length"
|
||||||
|
:aria-label="selectedAreHidden ? 'Show selected drawings' : 'Hide selected drawings'">
|
||||||
|
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
|
||||||
|
{{ selectedAreHidden ? 'Show' : 'Hide' }}
|
||||||
|
</button>
|
||||||
|
<button @click="duplicateSelected" :disabled="!selectedTrendline" aria-label="Duplicate selected trendline">Duplicate</button>
|
||||||
|
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawing-list-shell">
|
||||||
|
<div class="drawing-list" ref="drawingList">
|
||||||
|
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
|
||||||
|
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
|
||||||
|
|
||||||
|
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
|
||||||
|
:data-drawing-id="item.id"
|
||||||
|
:class="{selected: selectedDrawings.includes(item.id), active: selectedDrawing === item.id, 'hidden-drawing': item.hidden}"
|
||||||
|
@click="activateDrawing(item.id)">
|
||||||
|
<input class="line-select" type="checkbox"
|
||||||
|
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
|
||||||
|
@click.stop @change="toggleDrawingSelection(item.id)">
|
||||||
|
|
||||||
|
<div class="drawing-content">
|
||||||
|
<div class="drawing-primary">
|
||||||
|
<input v-if="item.line" :value="item.line.label" aria-label="Drawing name"
|
||||||
|
@click.stop @change="renameLine(item.line, $event.target.value)">
|
||||||
|
<input v-else :value="item.comment.note" aria-label="Comment text"
|
||||||
|
@click.stop @change="renameLine(item.comment, $event.target.value)">
|
||||||
|
<label v-if="item.comment" class="drawing-state" @click.stop
|
||||||
|
:title="item.comment.pinned ? 'Pinned to its bar' : 'Floating on screen'">
|
||||||
|
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
|
||||||
|
<i :class="item.comment.pinned ? 'fa-solid fa-thumbtack' : 'fa-solid fa-up-down-left-right'"></i>
|
||||||
|
</label>
|
||||||
|
<label v-else class="drawing-state" :class="{off: !item.line.armed}" @click.stop
|
||||||
|
:title="item.line.armed ? 'Armed — click to disarm' : 'Tripped — click to re-arm'">
|
||||||
|
<input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
|
||||||
|
<i :class="item.line.armed ? 'fa-solid fa-bell' : 'fa-solid fa-bell-slash'"></i>
|
||||||
|
</label>
|
||||||
|
<button class="drawing-delete" @click.stop="deleteDrawing(item)" aria-label="Delete drawing" title="Delete">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawing-secondary">
|
||||||
|
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span>
|
||||||
|
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
|
||||||
|
<span v-else-if="item.kind === 'fibonacci'">#{{ item.number }} · FIB · {{ item.label }}</span>
|
||||||
|
<span v-else-if="item.kind === 'level'" class="level-editors">
|
||||||
|
<label>Price<input type="number" :min="tick" :step="tick" :value="item.line.anchor_p"
|
||||||
|
aria-label="Level price in drawing list"
|
||||||
|
@keydown.enter="$event.target.blur()"
|
||||||
|
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
|
||||||
|
<label>Early<input type="number" min="0" :step="tick" :value="item.line.alert_early_points ?? ''"
|
||||||
|
placeholder="ATR" aria-label="Level alert early points"
|
||||||
|
@keydown.enter="$event.target.blur()"
|
||||||
|
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>
|
||||||
|
</span>
|
||||||
|
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
|
||||||
|
|
||||||
|
<div class="drawing-controls" @click.stop v-if="item.line">
|
||||||
|
<select :value="item.line.line_width || 2" aria-label="Drawing width"
|
||||||
|
@change="updateLineStyle(item.line, {line_width: Number($event.target.value)})">
|
||||||
|
<option v-for="width in 9" :value="width">{{ width }}</option>
|
||||||
|
</select>
|
||||||
|
<details class="color-picker">
|
||||||
|
<summary :aria-label="`Choose drawing color; current color ${drawingColorName(item.line.color || '#65b7cf')}`"
|
||||||
|
:title="drawingColorName(item.line.color || '#65b7cf')"
|
||||||
|
:style="{ backgroundColor: item.line.color || '#65b7cf' }"></summary>
|
||||||
|
<div class="color-popover">
|
||||||
|
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
|
||||||
|
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
|
||||||
|
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
|
||||||
|
:class="{selected: color === item.line.color}" :style="{backgroundColor: color}"
|
||||||
|
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
|
||||||
|
@click.stop="updateLineStyle(item.line, {color}); $event.currentTarget.closest('details').open = false"></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="color-popover-footer">
|
||||||
|
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" :value="item.line.color || '#65b7cf'" aria-label="Custom drawing color" @change="updateLineStyle(item.line, {color: $event.target.value})"></label>
|
||||||
|
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="drawing-controls" @click.stop v-else>
|
||||||
|
<select v-if="item.kind === 'symbol'" class="mark-scale" :value="item.comment.scale || 1"
|
||||||
|
aria-label="Mark size"
|
||||||
|
@change="updateLineStyle(item.comment, {scale: Number($event.target.value)})">
|
||||||
|
<option v-for="scale in symbolScales" :value="scale">{{ scale }}×</option>
|
||||||
|
</select>
|
||||||
|
<button v-if="item.kind === 'comment'" class="collapse-toggle" @click.stop="toggleComment(item.comment)"
|
||||||
|
:title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
|
||||||
|
<i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i>
|
||||||
|
</button>
|
||||||
|
<details class="color-picker">
|
||||||
|
<summary :aria-label="`Choose comment color; current color ${drawingColorName(item.comment.color || '#c8992f')}`"
|
||||||
|
:title="drawingColorName(item.comment.color || '#c8992f')"
|
||||||
|
:style="{ backgroundColor: item.comment.color || '#c8992f' }"></summary>
|
||||||
|
<div class="color-popover">
|
||||||
|
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
|
||||||
|
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input" :aria-label="`${row.id} row annotation`" @click.stop @blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
|
||||||
|
<div class="color-family-swatches"><button v-for="color in row.colors" :key="color" type="button"
|
||||||
|
:class="{selected: color === item.comment.color}" :style="{backgroundColor: color}"
|
||||||
|
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
|
||||||
|
@click.stop="updateLineStyle(item.comment, {color}); $event.currentTarget.closest('details').open = false"></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="color-popover-footer">
|
||||||
|
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" :value="item.comment.color || '#c8992f'" aria-label="Custom comment color" @change="updateLineStyle(item.comment, {color: $event.target.value})"></label>
|
||||||
|
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel" @click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drawing-list-resize" role="separator" aria-orientation="horizontal"
|
||||||
|
aria-label="Resize drawings list" @pointerdown="startDrawingListResize"></div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="sidebar-section">
|
<details class="sidebar-section">
|
||||||
<summary>Confluence zones</summary>
|
<summary>Confluence zones</summary>
|
||||||
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
|
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
|
||||||
<div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side">
|
<!-- One line each, ordered by price: the list then reads like the
|
||||||
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
|
chart does, highest zone at the top, and they all fit on screen. -->
|
||||||
<div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
|
<div v-for="cluster in clustersByPrice" :key="cluster.id" class="cluster-row" :class="cluster.side"
|
||||||
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
|
:title="cluster.members.map(member => member.label).join(' · ')">
|
||||||
<div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
|
<span class="cluster-band">{{ cluster.low.toFixed(2) }}–{{ cluster.high.toFixed(2) }}</span>
|
||||||
|
<span class="cluster-score">{{ cluster.score.toFixed(1) }}</span>
|
||||||
|
<span class="cluster-dist">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(1) }}</span>
|
||||||
|
<span class="cluster-members">{{ cluster.members.map(member => member.label).join(' · ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<h2>Alert log</h2>
|
<details class="sidebar-section">
|
||||||
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
<summary>Events ({{ events.length }})</summary>
|
||||||
<div v-for="alert in alerts" :key="alert.at" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
<div v-if="!events.length" class="empty">No events yet.</div>
|
||||||
|
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
|
||||||
|
<time><b v-if="event.number" class="event-number">#{{ event.number }}</b>{{ event.at }}</time>
|
||||||
|
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
|
||||||
|
<template v-else>{{ event.message }}</template>
|
||||||
|
</div>
|
||||||
|
<button v-if="eventsMore" class="events-more" type="button" @click="loadOlderEvents">More</button>
|
||||||
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
122
static/style.css
122
static/style.css
|
|
@ -2,23 +2,121 @@
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; }
|
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; }
|
||||||
#app { min-height:100vh; padding:18px; }
|
#app { min-height:100vh; padding:18px; }
|
||||||
header { height:64px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; }
|
header { height:44px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; }
|
||||||
h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--accent); font-weight:600; }
|
h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--accent); font-weight:600; }
|
||||||
.eyebrow { color:var(--muted); font-size:9px; letter-spacing:2px; }
|
.eyebrow { color:var(--muted); font-size:9px; letter-spacing:2px; }
|
||||||
.status { text-transform:uppercase; color:var(--muted); font-size:11px; }.status i { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--red); margin-right:8px; }.status.connected i,.status.replay i { background:var(--green); box-shadow:0 0 9px var(--green); }
|
.status { text-transform:uppercase; color:var(--muted); font-size:11px; }.status i { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--red); margin-right:8px; }.status.connected i,.status.replay i { background:var(--green); box-shadow:0 0 9px var(--green); }.status .reconnect { margin-left:10px; color:var(--accent); letter-spacing:.4px; }
|
||||||
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
||||||
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
|
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
|
||||||
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
|
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
|
||||||
.symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; }
|
.quote { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }.symbol { font-weight:700; margin-right:5px; }.price { color:var(--accent); font-size:19px; }.quote-change { color:var(--muted); font-size:11px; }.quote-change.positive { color:var(--green); }.quote-change.negative { color:var(--red); }
|
||||||
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
|
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
|
||||||
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
.chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
||||||
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
||||||
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
||||||
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
|
.current-price-pulse { position:absolute; left:4px; right:4px; z-index:8; display:flex; justify-content:space-between; align-items:center; opacity:0; pointer-events:none; }.current-price-pulse.active { opacity:1; animation:current-price-frame 1.8s ease-in-out infinite; }.current-price-tri { width:0; height:0; border-style:solid; filter:drop-shadow(0 0 1px var(--chart-bg)); }.current-price-tri-right { border-width:9px 0 9px 14px; border-color:transparent transparent transparent var(--accent); }.current-price-tri-left { border-width:9px 14px 9px 0; border-color:transparent var(--accent) transparent transparent; }@keyframes current-price-frame { 0%,100% { opacity:1; } 50% { opacity:.35; } }
|
||||||
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }
|
.chart-ohlc { position:absolute; top:6px; left:8px; z-index:5; padding:2px 6px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 90%,transparent); color:var(--fg); font-size:10px; font-variant-numeric:tabular-nums; pointer-events:none; white-space:nowrap; }.chart-ohlc.up { color:var(--green); }.chart-ohlc.down { color:var(--red); }.chart-ohlc[hidden] { display:none; }
|
||||||
|
.projection-diagnostic { position:absolute; top:8px; left:8px; z-index:9; margin:0; padding:5px 7px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 92%,transparent); color:var(--fg); font:9px/1.35 "IBM Plex Mono", monospace; white-space:pre; }
|
||||||
|
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; opacity:.85; } }
|
||||||
|
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }
|
||||||
|
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
|
||||||
|
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { display:flex; gap:24px; margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
|
||||||
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
|
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
|
||||||
.sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
|
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section > summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) > summary { margin-bottom:0; }
|
||||||
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
|
.drawing-list-shell { display:flex; flex-direction:column; min-width:0; }
|
||||||
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-4h { background:#ec7b42; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
.drawing-list { overflow:auto; min-height:88px; height:180px; border:1px solid var(--line); border-bottom:0; }
|
||||||
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
|
.drawing-list-resize { flex:none; height:11px; border:1px solid var(--line); background:var(--chart-bg); cursor:ns-resize; touch-action:none; }
|
||||||
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
.drawing-list-resize::after { content:''; display:block; width:36px; height:3px; margin:3px auto 0; border-radius:1px; background:var(--muted); }
|
||||||
|
.drawing-list-resize:hover::after { background:var(--accent); }
|
||||||
|
.trendline-actions { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:4px; margin-bottom:7px; }.trendline-actions button { padding:3px 5px; font-size:8px; white-space:nowrap; }.selection-count { color:var(--muted); font-size:8px; text-align:center; white-space:nowrap; }
|
||||||
|
.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row.active { background:color-mix(in srgb,var(--accent) 9%,transparent); box-shadow:inset 2px 0 var(--accent); }.trendline-row.hidden-drawing .drawing-content { opacity:.48; }.trendline-row.hidden-drawing .drawing-secondary>span::before { content:'HIDDEN · '; }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
|
||||||
|
.drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
|
.drawing-state { position:relative; display:grid; place-items:center; flex:none; width:20px; height:20px; color:var(--accent); cursor:pointer; }.drawing-state.off { color:var(--muted); }.drawing-state input { position:absolute; opacity:0; pointer-events:none; }.drawing-delete,.collapse-toggle { display:grid; place-items:center; flex:none; width:20px; height:20px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; cursor:pointer; }.drawing-delete:hover { color:var(--red); }
|
||||||
|
.drawing-controls { display:flex; align-items:center; gap:3px; flex:none; }.drawing-controls select { width:31px; height:19px; padding:0 2px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }.drawing-controls select.mark-scale { width:38px; }.color-picker { position:relative; height:19px; }.color-picker>summary { width:19px; height:19px; border:1px solid var(--line); border-radius:3px; cursor:pointer; list-style:none; }.color-picker>summary::-webkit-details-marker { display:none; }.color-picker:not([open])>.color-popover { display:none; }.color-popover { position:absolute; right:0; bottom:24px; z-index:20; display:grid; grid-template-columns:repeat(4,18px); gap:3px; width:95px; padding:6px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 18%,transparent); }.color-popover>button,.custom-color { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-popover>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover>.palette-close { grid-column:4; background:var(--chart-bg); color:var(--muted); }.color-popover>.palette-close:hover { color:var(--fg); }.custom-color { position:relative; display:grid; place-items:center; background:var(--chart-bg); color:var(--muted); font-size:9px; }.custom-color input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; }
|
||||||
|
.color-popover { grid-template-columns:1fr; width:178px; gap:3px; }
|
||||||
|
.color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); }
|
||||||
|
.level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }
|
||||||
|
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.ma-fold { margin:0; }.ma-fold > summary { display:flex; align-items:center; gap:7px; cursor:pointer; list-style:none; user-select:none; }.ma-fold > summary::-webkit-details-marker { display:none; }.ma-fold-caret { width:10px; color:var(--muted); font-size:9px; transition:transform .15s; }.ma-fold[open] > summary .ma-fold-caret { transform:rotate(180deg); }.ma-rows { display:grid; gap:5px; padding:8px 0 2px 22px; }.ma-row { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:8px; font-size:11px; }.ma-row label { color:var(--muted); gap:6px; }.ma-row input { width:12px; height:12px; margin:0; flex:none; }.ma-value { font-variant-numeric:tabular-nums; color:var(--fg); text-align:right; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.drawings { background:#c4a36a; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.rth { background:rgba(44,41,36,.35); }
|
||||||
|
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
||||||
|
|
||||||
|
/* Tool palette: the head arms the tool, the body configures what it creates. */
|
||||||
|
.tool { border:1px solid var(--line); border-radius:7px; margin-bottom:8px; overflow:hidden; }
|
||||||
|
.tool.armed { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
|
||||||
|
.tool-head { display:flex; align-items:center; gap:8px; width:100%; padding:8px 10px; font:inherit; font-size:12px;
|
||||||
|
background:transparent; color:var(--fg); border:0; cursor:pointer; text-align:left; }
|
||||||
|
.tool-head:hover { background:color-mix(in srgb, var(--fg) 5%, transparent); }
|
||||||
|
.tool.armed .tool-head { background:color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||||
|
.symbol-head { cursor:default; }.symbol-arm { display:flex; align-items:center; gap:8px; flex:1; min-width:0; padding:0; border:0; color:var(--fg); text-align:left; }.symbol-head-color { width:24px; height:22px; }.symbol-head-color>summary { width:22px; height:20px; }.symbol-head-color .color-popover { top:27px; right:0; bottom:auto; }.tool:has(.symbol-head) { overflow:visible; }
|
||||||
|
.tool-glyph { display:inline-block; width:14px; color:var(--muted); font-size:14px; line-height:1; }
|
||||||
|
.tool.armed .tool-glyph { color:var(--accent); }
|
||||||
|
.tool-state { margin-left:auto; font-size:10px; color:var(--accent); }
|
||||||
|
.tool-body { padding:2px 10px 10px; display:grid; gap:7px; }
|
||||||
|
.tool-body label { display:grid; gap:3px; font-size:10px; color:var(--muted); }
|
||||||
|
.tool-color { display:grid; gap:3px; color:var(--muted); font-size:10px; }.tool-color>.color-picker { width:57px; }.tool-color>.color-picker>summary { width:57px; }.tool:has(.tool-color) { overflow:visible; }.tool:has(.tool-color .color-picker[open]) { position:relative; z-index:30; }
|
||||||
|
.tool-body .row { display:grid; grid-template-columns:1fr 1fr; gap:7px; align-items:end; }
|
||||||
|
.tool-body input, .tool-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px;
|
||||||
|
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
|
||||||
|
.tool-body input[type=color] { padding:2px; height:26px; }
|
||||||
|
.symbol-palette { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }.symbol-palette button { display:grid; place-items:center; min-width:0; height:30px; padding:0; font-size:14px; }.symbol-palette button.active { color:var(--bg); }
|
||||||
|
.tool-body .check { display:flex; align-items:center; gap:5px; padding-bottom:5px; }
|
||||||
|
.config-section label { display:flex; align-items:center; gap:6px; color:var(--muted); font-size:10px; }.config-section input { accent-color:var(--accent); }
|
||||||
|
.tool-body .check input { width:auto; }
|
||||||
|
.price-row { grid-template-columns:1fr auto; }
|
||||||
|
.price-row button { font-size:11px; padding:5px 12px; align-self:end; }
|
||||||
|
|
||||||
|
/* Armed tools take over the pointer, so the chart must not look draggable. */
|
||||||
|
#chart.armed { cursor:crosshair; touch-action:none; }
|
||||||
|
.chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px;
|
||||||
|
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
|
||||||
|
.arm-hint { color:var(--accent); }
|
||||||
|
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
||||||
|
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }.events-more { width:100%; margin-top:6px; padding:5px; font-size:10px; }
|
||||||
|
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
||||||
|
/* --- chart comments ---------------------------------------------------- */
|
||||||
|
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
|
||||||
|
.chart-comment { position:absolute; max-width:210px; padding:4px 7px; border:1px solid var(--muted);
|
||||||
|
border-left-width:3px; border-radius:4px; background:var(--chart-bg); color:var(--fg);
|
||||||
|
font-size:10px; line-height:1.35; pointer-events:auto; cursor:pointer; white-space:pre-wrap;
|
||||||
|
box-shadow:0 1px 3px rgba(0,0,0,.16); }
|
||||||
|
.chart-comment.collapsed { max-width:none; width:34px; height:20px; padding:0; display:flex;
|
||||||
|
align-items:center; justify-content:flex-end; border-radius:4px; border-left-width:1px;
|
||||||
|
font-size:9px; white-space:nowrap; }
|
||||||
|
.chart-comment.floating { border-style:dashed; }
|
||||||
|
.chart-comment:not(.symbol) { padding-left:19px; }.chart-comment-collapse { position:absolute; inset:0 auto 0 0; display:grid; place-items:center; width:14px; padding:0; border:0; border-right:1px solid var(--line); color:var(--muted); font-size:7px; }.chart-comment-collapse[hidden],.chart-comment-grab[hidden] { display:none; }.chart-comment-grab { position:absolute; right:-9px; top:-9px; z-index:2; display:grid; place-items:center; width:18px; height:18px; padding:0; border:1px solid var(--line); border-radius:50%; background:var(--panel); color:var(--muted); font-size:8px; cursor:grab; opacity:.7; }.chart-comment-grab:hover { opacity:1; color:var(--fg); }.chart-comment-grab:active { cursor:grabbing; }
|
||||||
|
.chart-comment.floating { cursor:pointer; }.chart-comment.symbol { display:grid; place-items:center; width:calc(30px * var(--mark-scale, 1)); height:calc(30px * var(--mark-scale, 1)); padding:0; border-radius:50%; color:var(--comment-focus-color); font-size:calc(18px * var(--mark-scale, 1)); cursor:pointer; }.chart-comment.symbol .chart-comment-text { display:grid; place-items:center; }
|
||||||
|
.chart-comment-focus { position:absolute; inset:-1px; border:1px solid var(--comment-focus-color); border-radius:inherit; opacity:0; pointer-events:none; }
|
||||||
|
.chart-comment.focus .chart-comment-focus { animation:comment-focus .75s cubic-bezier(.2,.75,.25,1) forwards; }
|
||||||
|
@keyframes comment-focus {
|
||||||
|
0% { inset:-11px; border-width:11px; opacity:.65; }
|
||||||
|
100% { inset:-1px; border-width:1px; opacity:0; }
|
||||||
|
}
|
||||||
|
/* Parked on the edge it scrolled off, pointing the way back to it. */
|
||||||
|
.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; }
|
||||||
|
.chart-comment.off-left::before { content:'◀ '; }
|
||||||
|
.chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; }
|
||||||
|
.chart-comment.off-left, .chart-comment.off-right { opacity:.72; }
|
||||||
|
/* --- drawings filter --------------------------------------------------- */
|
||||||
|
.options-body { display:grid; gap:7px; }
|
||||||
|
.options-body label { display:grid; gap:3px; font-size:10px; color:var(--muted); }
|
||||||
|
.options-body .row { display:grid; grid-template-columns:1fr 1fr; gap:7px; align-items:end; }
|
||||||
|
.options-body input, .options-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px; border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
|
||||||
|
.options-body .price-row { grid-template-columns:auto 1fr; }
|
||||||
|
.options-body .price-row button { font-size:11px; padding:5px 12px; }
|
||||||
|
.option-results { display:grid; gap:0; }
|
||||||
|
.option-row { display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:4px; align-items:center; padding:3px 0; border-bottom:1px solid var(--line); font-size:10px; }
|
||||||
|
.option-row.option-head { color:var(--muted); text-transform:uppercase; letter-spacing:.3px; font-size:8px; }
|
||||||
|
.option-row button { padding:2px 6px; font-size:8px; }
|
||||||
|
.drawing-filters { display:grid; grid-template-columns:auto auto 1fr; gap:6px; margin-bottom:8px; }
|
||||||
|
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
|
||||||
|
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }
|
||||||
|
.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; }
|
||||||
|
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
|
||||||
|
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
|
||||||
|
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }
|
||||||
|
.chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[data-side=support] { border-color:#27825c; }
|
||||||
|
.chart-snap-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; }
|
||||||
|
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
|
||||||
|
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:none; }.chart-overlays .chart-context-menu { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }
|
||||||
|
.chart-context-labels { position:absolute; inset:0; z-index:4; overflow:visible; pointer-events:none; }.chart-context-label { position:absolute; left:4px; transform:translateY(-50%); max-width:170px; padding:2px 5px; border-left:3px solid var(--muted); border-radius:2px; background:color-mix(in srgb,var(--chart-bg) 92%,transparent); color:var(--fg); font-size:9px; line-height:1.25; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.12); }
|
||||||
|
.event-number { margin-right:7px; padding:1px 5px; border-radius:3px; background:color-mix(in srgb,var(--accent) 22%,transparent); color:var(--fg); font-weight:600; }
|
||||||
|
.cluster-row { display:grid; grid-template-columns:auto auto auto minmax(0,1fr); gap:8px; align-items:baseline; padding:3px 7px; border-left:3px solid var(--green); font-size:10px; white-space:nowrap; }.cluster-row.resistance { border-left-color:var(--red); }.cluster-row + .cluster-row { border-top:1px solid var(--line); }.cluster-band { font-size:11px; color:var(--fg); }.cluster-score { font-weight:600; color:var(--accent); }.cluster-dist { color:var(--muted); }.cluster-members { color:var(--muted); overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
|
|
||||||
266
tests/e2e/chart.test.mjs
Normal file
266
tests/e2e/chart.test.mjs
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* The chart shows the right window, at the right density, without throwing.
|
||||||
|
*
|
||||||
|
* Every assertion here failed at some point on 2026-08-10 while all 96 Python
|
||||||
|
* tests passed, which is the whole argument for this file existing.
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
withChart, launch, openChart, chartState, selectTimeframe, assertNoPageErrors,
|
||||||
|
} from './helpers.mjs';
|
||||||
|
|
||||||
|
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
||||||
|
|
||||||
|
test('a first-time browser exchanges the friendly password for a session',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
const { browser, page } = await launch();
|
||||||
|
let statusRequests = 0;
|
||||||
|
let submitted = null;
|
||||||
|
try {
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
assert.equal(dialog.message(), 'Password for this chart');
|
||||||
|
await dialog.accept('friendly passphrase');
|
||||||
|
});
|
||||||
|
await page.route('**/api/status', async route => {
|
||||||
|
statusRequests += 1;
|
||||||
|
if (statusRequests === 1) await route.fulfill({ status: 401, body: '{}' });
|
||||||
|
else await route.continue();
|
||||||
|
});
|
||||||
|
await page.route('**/api/login', async route => {
|
||||||
|
submitted = route.request().postDataJSON();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await openChart(page);
|
||||||
|
|
||||||
|
assert.deepEqual(submitted, { password: 'friendly passphrase' });
|
||||||
|
assert.ok(statusRequests >= 2, 'status was not retried after login');
|
||||||
|
const unexpected = page.__errors.filter(error =>
|
||||||
|
!error.includes('server responded with a status of 401'));
|
||||||
|
assert.deepEqual(unexpected, [], `page errors: ${unexpected.join('; ')}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an existing browser token is migrated once and removed from local storage',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
const { browser, page } = await launch();
|
||||||
|
let presentedToken = null;
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('chart-token', 'legacy-token'));
|
||||||
|
await page.route('**/api/login', async route => {
|
||||||
|
presentedToken = route.request().headers()['x-chart-token'];
|
||||||
|
await route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
headers: { 'set-cookie': 'chart-session=test; Path=/; HttpOnly; SameSite=Strict' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await openChart(page);
|
||||||
|
|
||||||
|
assert.equal(presentedToken, 'legacy-token');
|
||||||
|
assert.equal(await page.evaluate(() => localStorage.getItem('chart-token')), null);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diagnostic capture uploads a PNG and adds its capability ID to Events',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
const { browser, page } = await launch();
|
||||||
|
let uploaded = null;
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('chart-diag', '1'));
|
||||||
|
await page.route('**/api/debug/captures', async route => {
|
||||||
|
uploaded = {
|
||||||
|
contentType: route.request().headers()['content-type'],
|
||||||
|
metadata: JSON.parse(Buffer.from(
|
||||||
|
route.request().headers()['x-capture-metadata'], 'base64').toString()),
|
||||||
|
};
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'c-TESTCAPTURE1',
|
||||||
|
url: '/api/debug/captures/c-TESTCAPTURE1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await openChart(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(navigator, 'mediaDevices', {
|
||||||
|
configurable: true,
|
||||||
|
value: { getDisplayMedia: async () => ({ getTracks: () => [{ stop() {} }] }) },
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
|
||||||
|
configurable: true,
|
||||||
|
set() { setTimeout(() => this.onloadedmetadata?.(), 0); },
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', {
|
||||||
|
configurable: true, get: () => 800,
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', {
|
||||||
|
configurable: true, get: () => 600,
|
||||||
|
});
|
||||||
|
HTMLMediaElement.prototype.play = async function play() {};
|
||||||
|
HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); };
|
||||||
|
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
||||||
|
HTMLCanvasElement.prototype.getContext = function getContext(...args) {
|
||||||
|
return this.closest('#chart') ? realGetContext.apply(this, args) : { drawImage() {} };
|
||||||
|
};
|
||||||
|
HTMLCanvasElement.prototype.toBlob = function toBlob(done) {
|
||||||
|
done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], {
|
||||||
|
type: 'image/png',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press('Alt+Shift+C');
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document.querySelector('.diag-capture')?.textContent.includes('CAPTURE IN'));
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
[...document.querySelectorAll('.alert-entry')]
|
||||||
|
.some(entry => entry.textContent.includes('c-TESTCAPTURE1')));
|
||||||
|
|
||||||
|
assert.equal(uploaded.contentType, 'image/png');
|
||||||
|
assert.equal(uploaded.metadata.timeframe, '1m');
|
||||||
|
assert.equal(uploaded.metadata.image_width, 800);
|
||||||
|
assert.equal(uploaded.metadata.image_height, 600);
|
||||||
|
const event = page.locator('.alert-entry.capture a');
|
||||||
|
assert.match(await event.textContent(), /CAPTURE c-TESTCAPTURE1 · 1m/);
|
||||||
|
assert.match(await event.getAttribute('href'), /\/api\/debug\/captures\/c-TESTCAPTURE1$/);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the viewport opens on the live edge, not in the past', {
|
||||||
|
timeout: 180000,
|
||||||
|
skip: 'quarantined: chart data and viewport are sampled separately while the live feed advances',
|
||||||
|
}, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const state = await chartState(page);
|
||||||
|
assert.ok(state.bars > 0, 'the chart loaded no bars at all');
|
||||||
|
// The bug: setBars placed the viewport by logical index, and the daily MA
|
||||||
|
// series then prepended hundreds of points, sliding the view ~10 hours back
|
||||||
|
// while the data itself was perfectly current.
|
||||||
|
assert.equal(
|
||||||
|
state.visibleTo, state.lastTime,
|
||||||
|
'the newest bar is not the right-hand edge of the viewport',
|
||||||
|
);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the status row shows the exact local time data last arrived',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const freshness = document.querySelector('.data-freshness b');
|
||||||
|
return freshness?.textContent.trim() !== '—';
|
||||||
|
});
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const freshness = document.querySelector('.data-freshness span:last-child');
|
||||||
|
return {
|
||||||
|
freshness: freshness.textContent.trim(),
|
||||||
|
deployMetadata: document.querySelector('.app-meta'),
|
||||||
|
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.match(state.freshness, /^UPDATED\s+\d{1,2}:\d{2}:\d{2}\s*(AM|PM)?$/i);
|
||||||
|
assert.equal(state.deployMetadata, null);
|
||||||
|
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the quote, current-price marker, and tool order retain their trading context',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const state = await page.evaluate(() => ({
|
||||||
|
quote: document.querySelector('.quote-change').textContent.trim(),
|
||||||
|
sections: [...document.querySelectorAll('aside > .sidebar-section > summary')]
|
||||||
|
.map(node => node.textContent.trim().replace(/\s+/g, ' ')),
|
||||||
|
tools: [...document.querySelectorAll('.tools-section > .tool')]
|
||||||
|
.map(node => node.querySelector('.tool-head').textContent.trim().replace(/\s+/g, ' ')),
|
||||||
|
candleOptions: window.__chart.candles.options(),
|
||||||
|
markerOptions: window.__chart.currentPriceLine?.options(),
|
||||||
|
pulseExists: Boolean(document.querySelector('.current-price-pulse')),
|
||||||
|
configOpen: document.querySelector('.config-section').open,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.match(state.quote, /^[+-]?\d+\.\d{2} \([+-]?\d+\.\d{2}%\)$/);
|
||||||
|
assert.deepEqual(state.sections.slice(0, 3), ['Layers', 'Config', 'Tools']);
|
||||||
|
assert.deepEqual(state.tools, ['Mark', 'Price level', 'Fibonacci', 'Trendline', 'Comment']);
|
||||||
|
assert.equal(state.candleOptions.lastValueVisible, false);
|
||||||
|
assert.equal(state.candleOptions.priceLineVisible, false);
|
||||||
|
assert.equal(state.markerOptions.lineWidth, 1);
|
||||||
|
assert.equal(state.markerOptions.axisLabelVisible, true);
|
||||||
|
assert.equal(state.pulseExists, true);
|
||||||
|
assert.equal(state.configOpen, false);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a daily crosshair shows the session date, not the previous evening',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await selectTimeframe(page, '1d');
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const time = c.bars[c.bars.length - 1].t;
|
||||||
|
return {
|
||||||
|
actual: c.chart.options().localization.timeFormatter(time),
|
||||||
|
expected: new Date(time * 1000).toLocaleString(undefined, {
|
||||||
|
timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(state.actual, state.expected);
|
||||||
|
assert.doesNotMatch(state.actual, /\d:\d/);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
for (const tf of TIMEFRAMES) {
|
||||||
|
await selectTimeframe(page, tf);
|
||||||
|
const state = await chartState(page);
|
||||||
|
// The bug: a minute-resolution VWAP put its own points on the shared time
|
||||||
|
// scale, so an hourly chart spread 160 candles over 908 slots and drew
|
||||||
|
// them as slivers. Anything much above 1.0 means the scale is polluted.
|
||||||
|
const ratio = state.slots / Math.max(state.inView, 1);
|
||||||
|
assert.ok(
|
||||||
|
ratio > 0.8 && ratio < 1.3,
|
||||||
|
`${tf}: ${state.inView} candles spread over ${state.slots} slots (ratio ${ratio.toFixed(2)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching timeframes rapidly never throws', { timeout: 300000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
// The bug: bar events for the timeframe just left arrived after the new
|
||||||
|
// snapshot, and Lightweight Charts throws "Cannot update oldest data"
|
||||||
|
// rather than ignoring a stale bar, which took the whole app down.
|
||||||
|
for (let round = 0; round < 3; round += 1) {
|
||||||
|
for (const tf of ['1m', '1h', '15m', '1d', '5m']) {
|
||||||
|
await page.click(`.timeframes button:text-is("${tf}")`);
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const state = await chartState(page);
|
||||||
|
assert.ok(state.bars > 0, 'the chart lost its data while switching');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
302
tests/e2e/comments.test.mjs
Normal file
302
tests/e2e/comments.test.mjs
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
/**
|
||||||
|
* Comments stay where they were put, across zoom, scroll and timeframe.
|
||||||
|
*
|
||||||
|
* A comment is stored as a drawing but must never behave like a level, so the
|
||||||
|
* last test here guards the invariant that matters most: a note on a chart can
|
||||||
|
* never end up in a confluence cluster or fire a push notification.
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
withChart, chartBox, at, armTool, selectTimeframe, assertNoPageErrors,
|
||||||
|
} from './helpers.mjs';
|
||||||
|
|
||||||
|
const TEXT = 'e2e placement check';
|
||||||
|
|
||||||
|
/** Place a comment at a point on the chart. */
|
||||||
|
async function placeComment(page, box, fx, fy, { floating = false } = {}) {
|
||||||
|
await armTool(page, 'Comment');
|
||||||
|
await page.fill('textarea[aria-label="Comment text"]', TEXT);
|
||||||
|
if (floating) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const body = [...document.querySelectorAll('.tool-body')].find(b => b.querySelector('textarea'));
|
||||||
|
const box_ = body.querySelector('input[type=checkbox]');
|
||||||
|
if (!box_.checked) box_.click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
const point = at(box, fx, fy);
|
||||||
|
await page.mouse.move(point.x, point.y);
|
||||||
|
await page.mouse.down(); await page.mouse.up();
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentNode = page => page.evaluate(text => {
|
||||||
|
const node = [...document.querySelectorAll('.chart-comment')].find(n => n.title === text);
|
||||||
|
if (!node) return null;
|
||||||
|
return {
|
||||||
|
left: Math.round(node.getBoundingClientRect().left),
|
||||||
|
collapsed: node.classList.contains('collapsed'),
|
||||||
|
parked: node.classList.contains('off-left') || node.classList.contains('off-right'),
|
||||||
|
};
|
||||||
|
}, TEXT);
|
||||||
|
|
||||||
|
test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await selectTimeframe(page, '30m');
|
||||||
|
await placeComment(page, box, 0.55, 0.40);
|
||||||
|
|
||||||
|
const anchor = await page.evaluate(t => {
|
||||||
|
const c = window.__chart.comments.find(x => x.note === t);
|
||||||
|
return c ? { t: c.anchor_t, p: c.anchor_p } : null;
|
||||||
|
}, TEXT);
|
||||||
|
assert.ok(anchor, 'the comment was not created');
|
||||||
|
|
||||||
|
await page.locator('.config-section summary').click();
|
||||||
|
await page.locator('.config-section label:has-text("Hide lower-TF drawings") input').uncheck();
|
||||||
|
|
||||||
|
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
|
||||||
|
await page.locator('.trendline-row .line-select').check();
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await page.keyboard.press('Shift+ArrowRight');
|
||||||
|
await page.waitForFunction(([text, original]) => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const comment = c.comments.find(value => value.note === text);
|
||||||
|
return comment?.anchor_p === original.p + 0.25
|
||||||
|
&& Math.round(c.indexAt(comment.anchor_t) - c.indexAt(original.t)) === 4;
|
||||||
|
}, [TEXT, anchor]);
|
||||||
|
|
||||||
|
// The bug: timeToCoordinate answers only for exact data points, so a 30m
|
||||||
|
// bucket returned null on 15m and null was read as "off the left edge".
|
||||||
|
for (const tf of ['15m', '1h', '30m']) {
|
||||||
|
await selectTimeframe(page, tf);
|
||||||
|
const node = await commentNode(page);
|
||||||
|
assert.ok(node, `the comment vanished on ${tf}`);
|
||||||
|
assert.equal(node.parked, false, `the comment was edge-parked on ${tf} despite being in view`);
|
||||||
|
}
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a comment collapses it', { timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await placeComment(page, box, 0.5, 0.35);
|
||||||
|
assert.equal((await commentNode(page)).collapsed, false);
|
||||||
|
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
|
||||||
|
const row = page.locator('.trendline-row');
|
||||||
|
const controls = await row.locator('.drawing-controls>*').evaluateAll(nodes =>
|
||||||
|
nodes.map(node => node.classList.contains('color-picker') ? 'color' : node.className));
|
||||||
|
assert.deepEqual(controls, ['collapse-toggle', 'color'],
|
||||||
|
'the comment color picker is not the rightmost control');
|
||||||
|
await row.locator('.drawing-secondary>span').click();
|
||||||
|
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true,
|
||||||
|
'clicking drawing-list content did not point out the comment on the chart');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
|
||||||
|
'Escape did not clear comment selection');
|
||||||
|
const chartComment = page.locator(`.chart-comment[title="${TEXT}"]`);
|
||||||
|
// The body selects without collapsing; collapse is deliberately confined
|
||||||
|
// to the narrow control on the left.
|
||||||
|
await chartComment.locator('.chart-comment-text').click();
|
||||||
|
assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
|
||||||
|
'clicking the chart comment did not select its drawing row');
|
||||||
|
assert.equal((await commentNode(page)).collapsed, false,
|
||||||
|
'selecting the comment also collapsed it');
|
||||||
|
await chartComment.locator('.chart-comment-collapse').click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a floating comment holds its position while the chart scrolls', { timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await placeComment(page, box, 0.4, 0.3, { floating: true });
|
||||||
|
const before = await commentNode(page);
|
||||||
|
assert.ok(before, 'the floating comment was not created');
|
||||||
|
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
|
||||||
|
await page.locator('.trendline-row .line-select').check();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await page.waitForFunction(([text, left]) => {
|
||||||
|
const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text);
|
||||||
|
return node && Math.round(node.getBoundingClientRect().left) === left + 4;
|
||||||
|
}, [TEXT, before.left]);
|
||||||
|
await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const after = await commentNode(page);
|
||||||
|
assert.equal(after.left, before.left + 4, 'a floating comment moved with the chart');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a floating comment stays inside the plot at the future-side edge',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
chart.setComments([{
|
||||||
|
id: 'floating-edge', kind: 'comment', tf: '1m', note: 'edge', hidden: false,
|
||||||
|
pinned: false, x: 1, y: 0.5, collapsed: false, color: '#c8992f', scale: 1,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-drawing-id="floating-edge"]'));
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const node = document.querySelector('[data-drawing-id="floating-edge"]');
|
||||||
|
const box = node.getBoundingClientRect();
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return { right: box.right, bottom: box.bottom, plotRight: plot.right, plotBottom: plot.bottom };
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(result.right <= result.plotRight + 1, 'floating comment overflowed the right price axis');
|
||||||
|
assert.ok(result.bottom <= result.plotBottom + 1, 'floating comment overflowed the plot bottom');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchecking Drawings hides comments on the chart', { timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await placeComment(page, box, 0.5, 0.4);
|
||||||
|
assert.ok(await commentNode(page), 'the comment was not created');
|
||||||
|
|
||||||
|
await page.locator('details:has(summary:text-is("Layers"))').evaluate(node => { node.open = true; });
|
||||||
|
const toggle = page.getByText('Drawings', { exact: true }).locator('input');
|
||||||
|
assert.equal(await toggle.isChecked(), true);
|
||||||
|
await toggle.click();
|
||||||
|
await page.waitForFunction(text =>
|
||||||
|
![...document.querySelectorAll('.chart-comment')].some(n => n.title === text), TEXT);
|
||||||
|
assert.equal(await commentNode(page), null, 'the comment stayed visible with Drawings off');
|
||||||
|
|
||||||
|
await toggle.click();
|
||||||
|
await page.waitForFunction(text =>
|
||||||
|
[...document.querySelectorAll('.chart-comment')].some(n => n.title === text), TEXT);
|
||||||
|
assert.ok(await commentNode(page), 'the comment did not return with Drawings on');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comment is never a level', { timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await placeComment(page, box, 0.5, 0.5);
|
||||||
|
// Levels cluster and alert. A comment reaching that list would push a
|
||||||
|
// phone notification about a piece of text.
|
||||||
|
const leaked = await page.evaluate(t => {
|
||||||
|
const note = window.__chart.comments.find(x => x.note === t);
|
||||||
|
if (!note) return 'comment missing';
|
||||||
|
return window.__chart.levels.some(level => level.id === note.id);
|
||||||
|
}, TEXT);
|
||||||
|
assert.equal(leaked, false, 'a comment appeared in the level list');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mark can sit in the future whitespace', { timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await armTool(page, 'Mark');
|
||||||
|
const target = await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const last = chart.bars[chart.bars.length - 1];
|
||||||
|
const prev = chart.bars[chart.bars.length - 2];
|
||||||
|
const lastX = chart.coordinateAtTime(last.t);
|
||||||
|
const step = lastX - chart.coordinateAtTime(prev.t);
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: plot.left + lastX + step * 2,
|
||||||
|
y: plot.top + plot.height * 0.4,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.mouse.click(target.x, target.y);
|
||||||
|
await page.waitForFunction(() => window.__chart.comments.some(comment =>
|
||||||
|
comment.kind === 'symbol' && comment.anchor_t > window.__chart.bars.at(-1).t));
|
||||||
|
const placed = await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const last = chart.bars[chart.bars.length - 1];
|
||||||
|
const symbol = chart.comments.filter(comment => comment.kind === 'symbol')
|
||||||
|
.sort((a, b) => b.number - a.number)[0];
|
||||||
|
const node = document.querySelector(`.chart-comment.symbol[data-drawing-id="${symbol.id}"]`);
|
||||||
|
const box = node.getBoundingClientRect();
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
anchorT: symbol.anchor_t,
|
||||||
|
lastT: last.t,
|
||||||
|
centerX: box.left + box.width / 2 - plot.left,
|
||||||
|
lastX: chart.coordinateAtTime(last.t),
|
||||||
|
parked: node.classList.contains('off-right'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(placed.anchorT > placed.lastT, 'the mark was clamped to the last bar');
|
||||||
|
assert.equal(placed.parked, false, 'the mark was parked on the right edge');
|
||||||
|
assert.ok(placed.centerX > placed.lastX + 4, 'the mark rendered on the last candle');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a symbol can be dropped at a price and dragged to a new one', {
|
||||||
|
timeout: 180000,
|
||||||
|
skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture',
|
||||||
|
}, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await armTool(page, 'Mark');
|
||||||
|
assert.equal(await page.locator('.symbol-palette>button').count(), 12,
|
||||||
|
'the two-row symbol palette is incomplete');
|
||||||
|
assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1,
|
||||||
|
'the Go symbol is not represented by a play triangle');
|
||||||
|
await page.locator('.symbol-head-color>summary').click();
|
||||||
|
assert.equal(await page.locator('.symbol-head-color .color-family-swatches>button[title="blue1 (#A3CCFF)"]').count(), 1,
|
||||||
|
'the symbol color palette does not expose named presets');
|
||||||
|
assert.equal(await page.locator('.symbol-head-color .color-family-swatches>button[aria-label^="Use "]').count(), 24,
|
||||||
|
'the symbol color palette does not contain all presets');
|
||||||
|
await page.locator('.symbol-head-color .palette-close').click();
|
||||||
|
await page.locator('button[aria-label="Skull"]').dragTo(page.locator('#chart'), {
|
||||||
|
targetPosition: { x: box.w * 0.55, y: box.h * 0.4 },
|
||||||
|
});
|
||||||
|
await page.waitForFunction(() => window.__chart.comments.some(comment =>
|
||||||
|
comment.kind === 'symbol' && comment.icon === 'skull'));
|
||||||
|
const symbolTool = page.locator('.tool:has(.symbol-head)');
|
||||||
|
assert.equal(await symbolTool.locator('.tool-body').isVisible(), true,
|
||||||
|
'placing a symbol closed its tool palette');
|
||||||
|
assert.equal(await symbolTool.evaluate(node => node.classList.contains('armed')), false,
|
||||||
|
'placing a symbol left the chart armed to create another one');
|
||||||
|
const created = await page.evaluate(() => {
|
||||||
|
const symbol = window.__chart.comments.find(comment =>
|
||||||
|
comment.kind === 'symbol' && comment.icon === 'skull');
|
||||||
|
return { id: symbol.id, anchor_t: symbol.anchor_t, anchor_p: symbol.anchor_p };
|
||||||
|
});
|
||||||
|
assert.equal(created.anchor_p * 4, Math.round(created.anchor_p * 4),
|
||||||
|
'the dropped symbol price was not tick-snapped');
|
||||||
|
const node = page.locator(`.chart-comment.symbol[data-drawing-id="${created.id}"]`);
|
||||||
|
assert.equal(await node.locator('.fa-skull').count(), 1,
|
||||||
|
'the symbol did not render with its approved Font Awesome icon');
|
||||||
|
assert.equal(await page.evaluate(id =>
|
||||||
|
window.__chart.levels.some(level => level.id === id), created.id), false,
|
||||||
|
'the symbol leaked into chart levels');
|
||||||
|
|
||||||
|
assert.equal(await node.locator('.chart-comment-grab').isVisible(), false,
|
||||||
|
'an unselected symbol exposed its grab handle');
|
||||||
|
await node.locator('.chart-comment-text').click();
|
||||||
|
assert.equal(await node.locator('.chart-comment-grab').isVisible(), true,
|
||||||
|
'a selected symbol did not expose its grab handle');
|
||||||
|
const before = await node.boundingBox();
|
||||||
|
const grip = await node.locator('.chart-comment-grab').boundingBox();
|
||||||
|
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(grip.x + grip.width / 2 + 70, grip.y + grip.height / 2 + 45,
|
||||||
|
{ steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForFunction(previous => {
|
||||||
|
const symbol = window.__chart.comments.find(comment => comment.id === previous.id);
|
||||||
|
return symbol && (symbol.anchor_t !== previous.anchor_t || symbol.anchor_p !== previous.anchor_p);
|
||||||
|
}, created);
|
||||||
|
const movedPrice = await page.evaluate(id =>
|
||||||
|
window.__chart.comments.find(comment => comment.id === id).anchor_p, created.id);
|
||||||
|
assert.equal(movedPrice * 4, Math.round(movedPrice * 4),
|
||||||
|
'the dragged symbol price was not tick-snapped');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
470
tests/e2e/drawings.test.mjs
Normal file
470
tests/e2e/drawings.test.mjs
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
withChart, chartBox, at, armTool, drawingIds, assertNoPageErrors,
|
||||||
|
} from './helpers.mjs';
|
||||||
|
|
||||||
|
test('the timeframe visibility setting governs every drawing type',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const ids = await page.evaluate(async () => {
|
||||||
|
const bars = (await (await fetch('/api/bars?tf=30m&limit=2')).json()).bars;
|
||||||
|
const [first, second] = bars;
|
||||||
|
const post = async (url, body) => (await fetch(url, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})).json();
|
||||||
|
const comment = await post('/api/comments', {
|
||||||
|
text: `tf comment ${Date.now()}`, tf: '30m', pinned: true,
|
||||||
|
anchor_t: first.t, anchor_p: first.c,
|
||||||
|
});
|
||||||
|
const symbol = await post('/api/comments', {
|
||||||
|
text: 'TF symbol', icon: 'skull', tf: '30m', pinned: true,
|
||||||
|
anchor_t: first.t, anchor_p: first.c,
|
||||||
|
});
|
||||||
|
const fib = await post('/api/lines', {
|
||||||
|
kind: 'fibonacci', tf: '30m', side: 'support',
|
||||||
|
anchor_t: first.t, anchor_p: first.l, end_t: second.t, end_p: second.h,
|
||||||
|
});
|
||||||
|
const level = await post('/api/lines/price', {
|
||||||
|
tf: '30m', price: first.c, note: `tf level ${Date.now()}`,
|
||||||
|
});
|
||||||
|
return { comment: comment.id, symbol: symbol.id, fib: fib.id, level: level.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await page.waitForFunction(() => window.__chart?.bars?.length > 0);
|
||||||
|
const allVisible = expected => page.waitForFunction(([values, visible]) => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const comments = chart.comments.some(item => item.id === values.comment)
|
||||||
|
&& chart.comments.some(item => item.id === values.symbol);
|
||||||
|
const fib = chart.fibs.some(item => item.id === values.fib);
|
||||||
|
const level = chart.priceLines.has(values.level);
|
||||||
|
return visible ? comments && fib && level : !comments && !fib && !level;
|
||||||
|
}, [ids, expected]);
|
||||||
|
await allVisible(true);
|
||||||
|
|
||||||
|
await page.click('.timeframes button:text-is("1d")');
|
||||||
|
await page.waitForFunction(() => window.__chart.bars[0]?.tf === '1d');
|
||||||
|
await allVisible(false);
|
||||||
|
|
||||||
|
await page.locator('.config-section summary').click();
|
||||||
|
const setting = page.locator('.config-section label:has-text("Hide lower-TF drawings") input');
|
||||||
|
assert.equal(await setting.isChecked(), true, 'timeframe filtering does not default on');
|
||||||
|
await setting.uncheck();
|
||||||
|
await allVisible(true);
|
||||||
|
assert.equal(await page.evaluate(() => localStorage.getItem('chart-hide-lower-tf-drawings')),
|
||||||
|
'false', 'the Config choice was not persisted');
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await page.waitForFunction(() => window.__chart?.bars?.length > 0);
|
||||||
|
await page.locator('.config-section summary').click();
|
||||||
|
assert.equal(await page.locator(
|
||||||
|
'.config-section label:has-text("Hide lower-TF drawings") input').isChecked(), false,
|
||||||
|
'the Config choice reset on reload');
|
||||||
|
await allVisible(true);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the newest checked trendline stays active and movable during bulk selection',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const ids = await page.evaluate(async () => {
|
||||||
|
const bars = (await (await fetch('/api/bars?tf=1m&limit=30')).json()).bars;
|
||||||
|
const create = async (first, second, suffix) => (await (await fetch('/api/lines', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tf: '1m', side: 'support', anchor_t: first.t, anchor_p: first.l,
|
||||||
|
end_t: second.t, end_p: second.l + 0.25, note: `active ${suffix} ${Date.now()}`,
|
||||||
|
}),
|
||||||
|
})).json()).id;
|
||||||
|
return [
|
||||||
|
await create(bars.at(-25), bars.at(-18), 'one'),
|
||||||
|
await create(bars.at(-15), bars.at(-8), 'two'),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const row = id => page.locator(`.trendline-row[data-drawing-id="${id}"]`);
|
||||||
|
await page.waitForFunction(values => values.every(id =>
|
||||||
|
document.querySelector(`.trendline-row[data-drawing-id="${id}"]`)), ids);
|
||||||
|
await row(ids[0]).locator('.line-select').check();
|
||||||
|
await row(ids[1]).locator('.line-select').check();
|
||||||
|
await page.waitForFunction(id => window.__chart.selectedLineId === id
|
||||||
|
&& !window.__chart.lineHitTarget.hasAttribute('hidden'), ids[1]);
|
||||||
|
|
||||||
|
assert.equal(await row(ids[0]).evaluate(node => node.classList.contains('selected')), true);
|
||||||
|
assert.equal(await row(ids[1]).evaluate(node => node.classList.contains('selected')), true);
|
||||||
|
assert.equal(await row(ids[0]).evaluate(node => node.classList.contains('active')), false);
|
||||||
|
assert.equal(await row(ids[1]).evaluate(node => node.classList.contains('active')), true);
|
||||||
|
assert.equal(await page.locator('.chart-line-focus').getAttribute('hidden'), null,
|
||||||
|
'programmatic selection did not trigger the focus glow');
|
||||||
|
|
||||||
|
const target = await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const hit = chart.lineHitTarget;
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: plot.left + (Number(hit.getAttribute('x1')) + Number(hit.getAttribute('x2'))) / 2,
|
||||||
|
y: plot.top + (Number(hit.getAttribute('y1')) + Number(hit.getAttribute('y2'))) / 2,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.mouse.move(target.x, target.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
assert.equal(await page.evaluate(() => window.__chart.draggingLine?.id), ids[1],
|
||||||
|
'the active selected line did not own the drag target');
|
||||||
|
await page.mouse.up();
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a daily trendline selected from the list can be dragged by its body',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const created = await page.evaluate(async () => {
|
||||||
|
const bars = (await (await fetch('/api/bars?tf=1d&limit=30')).json()).bars;
|
||||||
|
const first = bars.at(-24);
|
||||||
|
const second = bars.at(-14);
|
||||||
|
return await (await fetch('/api/lines', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tf: '1d', side: 'support', anchor_t: first.t, anchor_p: first.l,
|
||||||
|
end_t: second.t, end_p: second.l + 20, note: `daily drag ${Date.now()}`,
|
||||||
|
}),
|
||||||
|
})).json();
|
||||||
|
});
|
||||||
|
await page.click('.timeframes button:text-is("1d")');
|
||||||
|
await page.waitForFunction(() => window.__chart.bars[0]?.tf === '1d');
|
||||||
|
const row = page.locator(`.trendline-row[data-drawing-id="${created.id}"]`);
|
||||||
|
await row.locator('.line-select').check();
|
||||||
|
await page.waitForFunction(id => window.__chart.selectedLineId === id
|
||||||
|
&& !window.__chart.lineHitTarget.hasAttribute('hidden'), created.id);
|
||||||
|
const original = await page.evaluate(id => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const line = chart.levels.find(item => item.id === id);
|
||||||
|
const hit = chart.lineHitTarget;
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
const spacing = chart.coordinateAtIndex(chart.bars.length - 1)
|
||||||
|
- chart.coordinateAtIndex(chart.bars.length - 2);
|
||||||
|
return {
|
||||||
|
anchor_t: line.anchor_t,
|
||||||
|
anchor_p: line.anchor_p,
|
||||||
|
x: plot.left + (Number(hit.getAttribute('x1')) + Number(hit.getAttribute('x2'))) / 2,
|
||||||
|
y: plot.top + (Number(hit.getAttribute('y1')) + Number(hit.getAttribute('y2'))) / 2,
|
||||||
|
dx: spacing * 2,
|
||||||
|
};
|
||||||
|
}, created.id);
|
||||||
|
|
||||||
|
await page.mouse.move(original.x, original.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(original.x + original.dx, original.y + 20, { steps: 8 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForFunction(([id, before]) => {
|
||||||
|
const line = window.__chart.levels.find(item => item.id === id);
|
||||||
|
return line && (line.anchor_t !== before.anchor_t || line.anchor_p !== before.anchor_p);
|
||||||
|
}, [created.id, original]);
|
||||||
|
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), created.id,
|
||||||
|
'daily body drag cleared the active selection');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a drawing name with Backspace or Delete cannot delete the drawing',
|
||||||
|
{
|
||||||
|
timeout: 180000,
|
||||||
|
skip: 'quarantined: optimistic creation races the shared live levels broadcast',
|
||||||
|
}, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const label = `e2e rename ${Date.now()}`;
|
||||||
|
const box = await chartBox(page);
|
||||||
|
await armTool(page, 'Trendline');
|
||||||
|
assert.deepEqual(await page.locator('select[aria-label="Trendline width"] option').allTextContents(),
|
||||||
|
['1px', '2px', '3px', '4px', '5px', '6px', '7px', '8px', '9px']);
|
||||||
|
await page.selectOption('select[aria-label="Trendline width"]', '9');
|
||||||
|
await page.fill('input[aria-label="Trendline label"]', label);
|
||||||
|
const first = at(box, 0.42, 0.55);
|
||||||
|
const second = at(box, 0.62, 0.40);
|
||||||
|
await page.mouse.click(first.x, first.y);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.mouse.click(second.x, second.y);
|
||||||
|
|
||||||
|
await page.waitForFunction(name =>
|
||||||
|
[...document.querySelectorAll('input[aria-label="Drawing name"]')]
|
||||||
|
.some(input => input.value === name), label);
|
||||||
|
await page.waitForFunction(name =>
|
||||||
|
window.__chart.levels.some(level => level.label === name && !level.id.startsWith('tmp_')),
|
||||||
|
label);
|
||||||
|
const created = await page.evaluate(name => {
|
||||||
|
const line = window.__chart.levels.find(
|
||||||
|
level => level.label === name && !level.id.startsWith('tmp_'),
|
||||||
|
);
|
||||||
|
return line?.id || null;
|
||||||
|
}, label);
|
||||||
|
assert.ok(created, 'the trendline was not created');
|
||||||
|
assert.ok((await drawingIds(page)).includes(created), 'the trendline was not persisted');
|
||||||
|
assert.equal(await page.evaluate(id =>
|
||||||
|
window.__chart.levels.find(level => level.id === id)?.line_width, created), 9,
|
||||||
|
'the 9px trendline width was not persisted');
|
||||||
|
|
||||||
|
await page.fill('input[aria-label="Filter drawings by text"]', label);
|
||||||
|
const input = page.locator('input[aria-label="Drawing name"]');
|
||||||
|
assert.equal(await input.count(), 1, 'the unique drawing filter did not isolate the test line');
|
||||||
|
const row = page.locator('.trendline-row');
|
||||||
|
assert.deepEqual(await row.locator('select[aria-label="Drawing width"] option').allTextContents(),
|
||||||
|
['1', '2', '3', '4', '5', '6', '7', '8', '9']);
|
||||||
|
const controls = await row.locator('.drawing-controls>*').evaluateAll(nodes =>
|
||||||
|
nodes.map(node => node.classList.contains('color-picker') ? 'color' : node.tagName.toLowerCase()));
|
||||||
|
assert.deepEqual(controls, ['select', 'color'], 'the color picker is not the rightmost control');
|
||||||
|
const rowBox = await row.boundingBox();
|
||||||
|
assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`);
|
||||||
|
assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1,
|
||||||
|
'the armed state is not represented by a bell');
|
||||||
|
assert.equal(await row.locator('.color-picker>summary').getAttribute('title'), 'custom (#65B7CF)',
|
||||||
|
'the current-color swatch does not name its color');
|
||||||
|
const checkbox = row.locator('.line-select');
|
||||||
|
if (await checkbox.isChecked()) await checkbox.click();
|
||||||
|
await row.locator('.drawing-secondary>span').click();
|
||||||
|
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null,
|
||||||
|
'clicking drawing-list content selected the trendline');
|
||||||
|
await checkbox.check();
|
||||||
|
await page.waitForFunction(id => window.__chart.selectedLineId === id, created);
|
||||||
|
assert.equal(await page.locator('.chart-line-focus').isVisible(), true,
|
||||||
|
'selecting the drawing did not point out its trendline on the chart');
|
||||||
|
await row.locator('.color-picker>summary').click();
|
||||||
|
assert.equal(await row.locator('.color-family-swatches>button[aria-label^="Use "]').count(), 24,
|
||||||
|
'the preset palette does not contain 24 colors');
|
||||||
|
assert.equal(await row.locator('.color-family-swatches>button[title="blue1 (#4699FE)"]').count(), 1,
|
||||||
|
'palette colors do not expose readable names');
|
||||||
|
assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1,
|
||||||
|
'the custom color choice is missing');
|
||||||
|
assert.equal(await row.locator('.palette-close').count(), 1,
|
||||||
|
'the palette close control is missing');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await row.locator('.color-popover').isVisible(), false,
|
||||||
|
'Escape did not close the color palette');
|
||||||
|
await row.locator('.color-picker>summary').click();
|
||||||
|
await row.locator('.palette-close').click();
|
||||||
|
assert.equal(await row.locator('.color-popover').isVisible(), false,
|
||||||
|
'the close control did not close the color palette');
|
||||||
|
await row.locator('.color-picker>summary').click();
|
||||||
|
await row.locator('button[aria-label="Use red2 (#F45B78)"]').click();
|
||||||
|
await page.waitForFunction(([id, color]) =>
|
||||||
|
window.__chart.levels.find(level => level.id === id)?.color === color,
|
||||||
|
[created, '#F45B78']);
|
||||||
|
await input.focus();
|
||||||
|
await input.press('End');
|
||||||
|
await input.press('Backspace');
|
||||||
|
assert.equal(await input.inputValue(), label.slice(0, -1), 'Backspace did not edit the name');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
assert.ok((await drawingIds(page)).includes(created), 'Backspace deleted the drawing');
|
||||||
|
|
||||||
|
const beforeDelete = await input.inputValue();
|
||||||
|
await input.press('Home');
|
||||||
|
await input.press('Delete');
|
||||||
|
assert.equal(await input.inputValue(), beforeDelete.slice(1), 'Delete did not edit the name');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
assert.ok((await drawingIds(page)).includes(created), 'Delete deleted the drawing');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a price level can be edited from the list and adjusted on the chart',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const label = `e2e level focus ${Date.now()}`;
|
||||||
|
const price = await page.evaluate(() => window.__chart.bars.at(-1).c);
|
||||||
|
await armTool(page, 'Price level');
|
||||||
|
assert.deepEqual(await page.locator('select[aria-label="Level width"] option').allTextContents(),
|
||||||
|
['1px', '2px', '3px', '4px', '5px', '6px', '7px', '8px', '9px']);
|
||||||
|
await page.fill('input[aria-label="Level label"]', label);
|
||||||
|
await page.fill('input[aria-label="Level price"]', String(price));
|
||||||
|
await page.locator('.price-row button[type="submit"]').click();
|
||||||
|
await page.fill('input[aria-label="Filter drawings by text"]', label);
|
||||||
|
const row = page.locator('.trendline-row');
|
||||||
|
await row.waitFor();
|
||||||
|
await row.locator('.color-picker>summary').click();
|
||||||
|
assert.deepEqual(
|
||||||
|
await row.locator('.color-family-swatches>button[aria-label^="Use "]').evaluateAll(nodes =>
|
||||||
|
nodes.map(node => node.getAttribute('title'))),
|
||||||
|
[
|
||||||
|
'green1 (#A6D8AA)', 'green2 (#4DB155)', 'green3 (#258F33)', 'green4 (#006D09)',
|
||||||
|
'red1 (#FAADBC)', 'red2 (#F45B78)', 'red3 (#D13F62)', 'red4 (#AF2850)',
|
||||||
|
'blue1 (#A3CCFF)', 'blue2 (#4699FE)', 'blue3 (#1E76D8)', 'blue4 (#0054B3)',
|
||||||
|
'orange1 (#F8C592)', 'orange2 (#F08A24)', 'orange3 (#D66B12)', 'orange4 (#B94E08)',
|
||||||
|
'teal1 (#80D8D8)', 'teal2 (#00B0B1)', 'teal3 (#008E8F)', 'teal4 (#006C6E)',
|
||||||
|
'purple1 (#DDBCEB)', 'purple2 (#BB79D7)', 'purple3 (#9858B3)', 'purple4 (#763790)',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1,
|
||||||
|
'the palette lost its custom color wheel');
|
||||||
|
assert.equal(await row.locator('.palette-close[title="Cancel"]').count(), 1,
|
||||||
|
'the palette lost its Cancel control');
|
||||||
|
assert.equal(await row.locator('input[aria-label="blue row annotation"]').inputValue(), 'blue');
|
||||||
|
await row.locator('input[aria-label="blue row annotation"]').fill('primary structure');
|
||||||
|
await row.locator('input[aria-label="blue row annotation"]').press('Tab');
|
||||||
|
assert.equal(await page.evaluate(() =>
|
||||||
|
JSON.parse(localStorage.getItem('chart-color-row-labels')).blue), 'primary structure');
|
||||||
|
await row.locator('button[aria-label="Use blue2 (#4699FE)"]').evaluate(node => node.click());
|
||||||
|
const id = await page.evaluate(name =>
|
||||||
|
window.__chart.levels.find(level => level.label === name)?.id || null, label);
|
||||||
|
assert.ok(id, 'the price level was not created');
|
||||||
|
await page.waitForFunction(levelId =>
|
||||||
|
window.__chart.levels.find(level => level.id === levelId)?.color === '#4699FE', id);
|
||||||
|
const priceInput = row.locator('input[aria-label="Level price in drawing list"]');
|
||||||
|
const earlyInput = row.locator('input[aria-label="Level alert early points"]');
|
||||||
|
await priceInput.fill(String(price + 1));
|
||||||
|
await priceInput.press('Enter');
|
||||||
|
await page.waitForFunction(([levelId, expectedPrice]) =>
|
||||||
|
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expectedPrice,
|
||||||
|
[id, price + 1]);
|
||||||
|
await earlyInput.fill('1.5');
|
||||||
|
await earlyInput.press('Enter');
|
||||||
|
await page.waitForFunction(levelId =>
|
||||||
|
window.__chart.levels.find(level => level.id === levelId)?.alert_early_points === 1.5, id);
|
||||||
|
const number = await page.evaluate(levelId =>
|
||||||
|
window.__chart.levels.find(level => level.id === levelId).number, id);
|
||||||
|
assert.equal(await page.evaluate(levelId =>
|
||||||
|
window.__chart.priceLines.get(levelId).line.options().title, id), `#${number}`,
|
||||||
|
'an unselected price level exposed its full name on the price axis');
|
||||||
|
|
||||||
|
await row.locator('.drawing-secondary>span').click();
|
||||||
|
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
|
||||||
|
assert.equal(await page.evaluate(levelId =>
|
||||||
|
window.__chart.priceLines.get(levelId).line.options().title, id), label,
|
||||||
|
'a selected price level did not show its full name on the price axis');
|
||||||
|
const focus = page.locator('.chart-line-focus');
|
||||||
|
assert.equal(await focus.getAttribute('hidden'), null, 'the selected price level was not highlighted');
|
||||||
|
assert.equal(await focus.evaluate(node => getComputedStyle(node).animationName), 'line-focus',
|
||||||
|
'the price-level focus animation did not run');
|
||||||
|
assert.equal(await focus.getAttribute('y1'), await focus.getAttribute('y2'),
|
||||||
|
'the price-level highlight was not horizontal');
|
||||||
|
assert.equal(await page.evaluate(levelId => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const level = c.levels.find(value => value.id === levelId);
|
||||||
|
c.updateLineTooltip({ point: {
|
||||||
|
x: c.plotCanvas().getBoundingClientRect().width / 2,
|
||||||
|
y: c.candles.priceToCoordinate(level.anchor_p),
|
||||||
|
} });
|
||||||
|
return c.tooltip.textContent;
|
||||||
|
}, id), `#${number} ${label}\nblue2 (#4699FE)\nprimary structure`);
|
||||||
|
|
||||||
|
const target = await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const bar = chart.bars[chart.bars.length - 30];
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
price: bar.h,
|
||||||
|
x: plot.left + chart.chart.timeScale().timeToCoordinate(bar.t),
|
||||||
|
y: plot.top + chart.candles.priceToCoordinate(bar.h),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handle = page.locator('.chart-anchor[data-anchor="start"]:not([hidden])');
|
||||||
|
const handleBox = await handle.boundingBox();
|
||||||
|
assert.ok(handleBox, 'a selected price level has no grab handle');
|
||||||
|
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(target.x, target.y, { steps: 8 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForFunction(([levelId, expected]) =>
|
||||||
|
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected,
|
||||||
|
[id, target.price]);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await page.waitForFunction(([levelId, expected]) =>
|
||||||
|
window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected,
|
||||||
|
[id, target.price + 0.25]);
|
||||||
|
await page.waitForFunction(expected =>
|
||||||
|
Number(document.querySelector('input[aria-label="Level price in drawing list"]')?.value) === expected,
|
||||||
|
target.price + 0.25);
|
||||||
|
assert.equal(Number(await priceInput.inputValue()), target.price + 0.25,
|
||||||
|
'the drawing-list price did not follow the chart nudge');
|
||||||
|
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Delete removes a drawing selected through its checkbox',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const label = `e2e checkbox delete ${Date.now()}`;
|
||||||
|
const price = await page.evaluate(() => window.__chart.bars.at(-1).c);
|
||||||
|
await armTool(page, 'Price level');
|
||||||
|
await page.fill('input[aria-label="Level label"]', label);
|
||||||
|
await page.fill('input[aria-label="Level price"]', String(price));
|
||||||
|
await page.locator('.price-row button[type="submit"]').click();
|
||||||
|
await page.fill('input[aria-label="Filter drawings by text"]', label);
|
||||||
|
const row = page.locator('.trendline-row');
|
||||||
|
await row.waitFor();
|
||||||
|
const id = await page.evaluate(name =>
|
||||||
|
window.__chart.levels.find(level => level.label === name)?.id || null, label);
|
||||||
|
assert.ok(id, 'the drawing was not created');
|
||||||
|
|
||||||
|
const checkbox = row.locator('.line-select');
|
||||||
|
await checkbox.check();
|
||||||
|
assert.equal(await checkbox.evaluate(node => document.activeElement === node), true,
|
||||||
|
'the checkbox did not retain keyboard focus');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForFunction(levelId =>
|
||||||
|
!window.__chart.levels.some(level => level.id === levelId), id);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filtered selections can hide and show drawings without deleting them',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const label = `bulk visibility ${Date.now()}`;
|
||||||
|
const created = await page.evaluate(async name => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const start = chart.bars.at(-30);
|
||||||
|
const end = chart.bars.at(-10);
|
||||||
|
const response = await fetch('/api/lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tf: '1m', side: 'support',
|
||||||
|
anchor_t: start.t, anchor_p: start.l,
|
||||||
|
end_t: end.t, end_p: end.l,
|
||||||
|
note: name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}, label);
|
||||||
|
await page.waitForFunction(id => window.__chart.levels.some(level => level.id === id), created.id);
|
||||||
|
|
||||||
|
const filter = page.locator('input[aria-label="Filter drawings by text"]');
|
||||||
|
await filter.fill('1m');
|
||||||
|
assert.equal(await page.locator(`input[aria-label="Drawing name"][value="${label}"]`).count(), 1,
|
||||||
|
'the drawing filter did not match the timeframe');
|
||||||
|
|
||||||
|
await filter.fill(label);
|
||||||
|
const row = page.locator('.trendline-row');
|
||||||
|
await page.getByRole('button', { name: 'Select shown' }).click();
|
||||||
|
assert.equal(await page.locator('.selection-count').textContent(), '1 selected');
|
||||||
|
await page.getByRole('button', { name: 'Hide selected drawings' }).click();
|
||||||
|
await page.waitForFunction(id => window.__chart.levels.find(level => level.id === id)?.hidden, created.id);
|
||||||
|
assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), true);
|
||||||
|
assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), false,
|
||||||
|
'the hidden drawing remained on the chart');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Show selected drawings' }).click();
|
||||||
|
await page.waitForFunction(id => !window.__chart.levels.find(level => level.id === id)?.hidden, created.id);
|
||||||
|
assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), false);
|
||||||
|
assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), true,
|
||||||
|
'the shown drawing did not return to the chart');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trendline slope in the tooltip is signed points per hour',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const labels = await page.evaluate(() => ({
|
||||||
|
up: window.__chart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }),
|
||||||
|
down: window.__chart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }),
|
||||||
|
flat: window.__chart.slopeLabel({ kind: 'manual', slope: 0 }),
|
||||||
|
}));
|
||||||
|
assert.equal(labels.up, '+2.00 /h');
|
||||||
|
assert.equal(labels.down, '-0.50 /h');
|
||||||
|
assert.equal(labels.flat, '');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
tests/e2e/harness.test.mjs
Normal file
50
tests/e2e/harness.test.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { launch, openChart, withChart } from './helpers.mjs';
|
||||||
|
|
||||||
|
async function createLine(page, note) {
|
||||||
|
return page.evaluate(async label => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const first = c.bars[c.bars.length - 80];
|
||||||
|
const second = c.bars[c.bars.length - 60];
|
||||||
|
const response = await fetch('/api/lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tf: '1m', side: 'support',
|
||||||
|
anchor_t: first.t, anchor_p: first.l,
|
||||||
|
end_t: second.t, end_p: second.l,
|
||||||
|
note: label,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}, note);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test cleanup never deletes a drawing made by another browser',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
const { browser, page } = await launch();
|
||||||
|
let external = null;
|
||||||
|
let owned = null;
|
||||||
|
try {
|
||||||
|
await openChart(page);
|
||||||
|
await withChart(async testPage => {
|
||||||
|
external = await createLine(page, 'external during test');
|
||||||
|
owned = await createLine(testPage, 'owned by test');
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = await page.evaluate(async () => {
|
||||||
|
const response = await fetch('/api/drawings');
|
||||||
|
return (await response.json()).drawings.map(drawing => drawing.id);
|
||||||
|
});
|
||||||
|
assert.ok(ids.includes(external.id), 'cleanup deleted another browser\'s drawing');
|
||||||
|
assert.ok(!ids.includes(owned.id), 'cleanup left its own drawing behind');
|
||||||
|
} finally {
|
||||||
|
if (external?.id) {
|
||||||
|
await page.evaluate(id => fetch(`/api/lines/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}), external.id);
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
179
tests/e2e/helpers.mjs
Normal file
179
tests/e2e/helpers.mjs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* Shared plumbing for the end-to-end tests.
|
||||||
|
*
|
||||||
|
* These run inside the `playwright` compose service, which has Playwright
|
||||||
|
* installed globally and can reach the app as http://api:8000. Nothing here
|
||||||
|
* needs npm install in this repo — see bin/e2e.
|
||||||
|
*/
|
||||||
|
// Playwright is installed globally in the container, not in this repo. ESM
|
||||||
|
// ignores NODE_PATH, so it is resolved through a CommonJS require, which
|
||||||
|
// honours it — see bin/e2e.
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const { chromium } = createRequire(import.meta.url)('playwright');
|
||||||
|
|
||||||
|
export const APP_URL = process.env.E2E_URL || 'http://api:8000/';
|
||||||
|
const TOKEN_KEY = 'chart-token';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a browser pointed at the chart.
|
||||||
|
*
|
||||||
|
* `--lang=en-US` is not optional. The container has no usable locale, so
|
||||||
|
* Chromium reports `en-US@posix`, Intl throws inside the charting library, and
|
||||||
|
* every test sees a blank canvas that looks exactly like a broken app.
|
||||||
|
*/
|
||||||
|
export async function launch(options = {}) {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
args: ['--lang=en-US'],
|
||||||
|
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({
|
||||||
|
viewport: { width: 1600, height: 1000 },
|
||||||
|
locale: 'en-US',
|
||||||
|
timezoneId: 'America/Chicago',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', error => errors.push(String(error.message)));
|
||||||
|
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||||
|
page.__errors = errors;
|
||||||
|
return { browser, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the app and wait until the chart has bars and levels. */
|
||||||
|
export async function openChart(page) {
|
||||||
|
await page.goto(APP_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__chart && window.__chart.bars && window.__chart.bars.length > 0,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
// The socket delivers a second snapshot on subscribe; let it settle so the
|
||||||
|
// viewport is the one the app actually ends up showing.
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `body` against a fresh browser, then close it.
|
||||||
|
*
|
||||||
|
* Any drawing created during the test is deleted afterwards, whatever the
|
||||||
|
* outcome. The dev stack shares one drawing store with whoever is using the
|
||||||
|
* app, so a test that leaves debris leaves it in someone's sidebar.
|
||||||
|
*/
|
||||||
|
export async function withChart(body, launchOptions = {}) {
|
||||||
|
const { browser, page } = await launch(launchOptions);
|
||||||
|
const created = new Set();
|
||||||
|
const pendingTracking = new Set();
|
||||||
|
const trackCreatedDrawing = async response => {
|
||||||
|
const request = response.request();
|
||||||
|
const path = new URL(response.url()).pathname;
|
||||||
|
if (request.method() !== 'POST'
|
||||||
|
|| !['/api/lines', '/api/lines/price', '/api/comments'].includes(path)
|
||||||
|
|| !response.ok()) return;
|
||||||
|
try {
|
||||||
|
const drawing = await response.json();
|
||||||
|
if (drawing.id) created.add(drawing.id);
|
||||||
|
} catch { /* a successful drawing response should be JSON; cleanup stays best-effort */ }
|
||||||
|
};
|
||||||
|
const responseListener = response => {
|
||||||
|
const pending = trackCreatedDrawing(response)
|
||||||
|
.finally(() => pendingTracking.delete(pending));
|
||||||
|
pendingTracking.add(pending);
|
||||||
|
};
|
||||||
|
page.on('response', responseListener);
|
||||||
|
try {
|
||||||
|
await openChart(page);
|
||||||
|
await body(page);
|
||||||
|
} finally {
|
||||||
|
page.off('response', responseListener);
|
||||||
|
try {
|
||||||
|
await Promise.all(pendingTracking);
|
||||||
|
if (created.size) await deleteDrawings(page, [...created]);
|
||||||
|
} catch { /* the page may already be gone; nothing to clean */ }
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids of every drawing the server currently holds. */
|
||||||
|
export async function drawingIds(page) {
|
||||||
|
return page.evaluate(async key => {
|
||||||
|
const token = localStorage.getItem(key) || '';
|
||||||
|
const response = await fetch('/api/drawings', { headers: token ? { 'X-Chart-Token': token } : {} });
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return (await response.json()).drawings.map(drawing => drawing.id);
|
||||||
|
}, TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDrawings(page, ids) {
|
||||||
|
return page.evaluate(async ([list, key]) => {
|
||||||
|
const token = localStorage.getItem(key) || '';
|
||||||
|
for (const id of list) {
|
||||||
|
await fetch(`/api/lines/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE', headers: token ? { 'X-Chart-Token': token } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [ids, TOKEN_KEY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pixel box of the chart pane, for turning fractions into mouse positions. */
|
||||||
|
export async function chartBox(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const rect = document.querySelector('#chart').getBoundingClientRect();
|
||||||
|
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A point inside the chart, given as fractions of its width and height. */
|
||||||
|
export function at(box, fx, fy) {
|
||||||
|
return { x: box.x + box.w * fx, y: box.y + box.h * fy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectTimeframe(page, tf) {
|
||||||
|
await page.click(`.timeframes button:text-is("${tf}")`);
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function armTool(page, name) {
|
||||||
|
await page.click(`.tool-head:has-text("${name}")`);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest hand-drawn sloped line, with the bar its anchor sits on. */
|
||||||
|
export async function newestTrendline(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const lines = window.__chart.levels.filter(l => l.kind === 'manual' && l.slope !== 0);
|
||||||
|
const line = lines.sort((a, b) => b.number - a.number)[0];
|
||||||
|
if (!line) return null;
|
||||||
|
const bar = window.__chart.bars.find(b => b.t === line.anchor_t) || null;
|
||||||
|
return {
|
||||||
|
id: line.id, anchor_t: line.anchor_t, anchor_p: line.anchor_p, side: line.side,
|
||||||
|
onExtreme: bar ? (line.anchor_p === bar.h || line.anchor_p === bar.l) : null,
|
||||||
|
snappedTo: bar ? (line.anchor_p === bar.h ? 'high' : line.anchor_p === bar.l ? 'low' : 'neither') : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the chart is currently showing: series, viewport, and slot density. */
|
||||||
|
export async function chartState(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const data = chart.candles.data();
|
||||||
|
const visible = chart.chart.timeScale().getVisibleRange();
|
||||||
|
const logical = chart.chart.timeScale().getVisibleLogicalRange();
|
||||||
|
const inView = visible ? data.filter(d => d.time >= visible.from && d.time <= visible.to).length : 0;
|
||||||
|
return {
|
||||||
|
bars: data.length,
|
||||||
|
lastTime: data.length ? data[data.length - 1].time : null,
|
||||||
|
lastClose: data.length ? data[data.length - 1].close : null,
|
||||||
|
inView,
|
||||||
|
slots: logical ? Math.round(logical.to - logical.from) : 0,
|
||||||
|
visibleFrom: visible ? visible.from : null,
|
||||||
|
visibleTo: visible ? visible.to : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fails the test if the page logged any error, with the first few quoted. */
|
||||||
|
export function assertNoPageErrors(page, assert) {
|
||||||
|
const unique = [...new Set(page.__errors)];
|
||||||
|
assert.deepStrictEqual(unique, [], `page errors: ${unique.slice(0, 3).join(' | ')}`);
|
||||||
|
}
|
||||||
46
tests/e2e/preferences.test.mjs
Normal file
46
tests/e2e/preferences.test.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { launch, openChart, assertNoPageErrors } from './helpers.mjs';
|
||||||
|
|
||||||
|
async function chartStartsWithPrefs(stored, expectedTimeframe) {
|
||||||
|
const { browser, page } = await launch();
|
||||||
|
try {
|
||||||
|
await page.addInitScript(value => {
|
||||||
|
localStorage.setItem('chart-layer-prefs', value);
|
||||||
|
}, stored);
|
||||||
|
await openChart(page);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('.timeframes button.active').textContent(),
|
||||||
|
expectedTimeframe,
|
||||||
|
'the chart did not restore a usable timeframe',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.locator('details:has(summary:text-is("Layers"))').evaluate(node => { node.open = true; });
|
||||||
|
const vwap = page.getByText('Session VWAP').locator('input');
|
||||||
|
const before = await vwap.isChecked();
|
||||||
|
await vwap.click();
|
||||||
|
assert.equal(await vwap.isChecked(), !before, 'the layer control did not toggle');
|
||||||
|
|
||||||
|
await page.click('.timeframes button:text-is("15m")');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('.timeframes button.active')?.textContent === '15m',
|
||||||
|
);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an old partial layer preference cannot prevent chart startup',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await chartStartsWithPrefs(
|
||||||
|
JSON.stringify({ base_tf: '5m', enabled: { manual: false } }),
|
||||||
|
'5m',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed layer preferences cannot prevent chart startup',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await chartStartsWithPrefs('{"enabled":', '1m');
|
||||||
|
});
|
||||||
80
tests/e2e/price-scale.test.mjs
Normal file
80
tests/e2e/price-scale.test.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { withChart, assertNoPageErrors } from './helpers.mjs';
|
||||||
|
|
||||||
|
test('every price overlay shares the candle price scale',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const last = c.bars[c.bars.length - 1];
|
||||||
|
c.syncLevels([...c.levels, {
|
||||||
|
id: 'e2e:context-probe', kind: 'horizontal', tf: '1d', side: 'support',
|
||||||
|
label: 'Context probe', anchor_t: last.t, anchor_p: last.c,
|
||||||
|
slope: 0, first_t: last.t, last_t: last.t,
|
||||||
|
hidden: false, provisional: false, color: '#9fb0c4', line_width: 1,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const series = [...c.levelSeries.entries()].map(([id, entry]) => ({
|
||||||
|
id,
|
||||||
|
scale: entry.series.options().priceScaleId,
|
||||||
|
context: window.ConfluenceChart.isContextLevel(c.levels.find(level => level.id === id)),
|
||||||
|
title: entry.series.options().title,
|
||||||
|
}));
|
||||||
|
const priceLines = [...c.priceLines.entries()].map(([id, entry]) => ({
|
||||||
|
id,
|
||||||
|
usesCandles: entry.host === c.candles,
|
||||||
|
}));
|
||||||
|
const average = c.levels.find(level => level.kind === 'ma');
|
||||||
|
const averageSeries = average ? c.levelSeries.get(average.id)?.series : null;
|
||||||
|
const value = average?.anchor_p ?? null;
|
||||||
|
return {
|
||||||
|
leftVisible: c.chart.priceScale('left').options().visible,
|
||||||
|
series,
|
||||||
|
priceLines,
|
||||||
|
coordinateDifference: averageSeries && value != null
|
||||||
|
? Math.abs(averageSeries.priceToCoordinate(value) - c.candles.priceToCoordinate(value))
|
||||||
|
: null,
|
||||||
|
leftLabels: document.querySelectorAll('.chart-context-label').length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(state.leftVisible, false, 'an independent left price scale is visible');
|
||||||
|
assert.ok(state.series.length > 0, 'no overlay series were available to check');
|
||||||
|
assert.ok(state.series.every(item => item.scale === 'right'),
|
||||||
|
`overlay series left the candle scale: ${JSON.stringify(state.series)}`);
|
||||||
|
assert.ok(state.series.filter(item => item.context).every(item => item.title === ''),
|
||||||
|
`long-term labels still clutter the right: ${JSON.stringify(state.series)}`);
|
||||||
|
assert.ok(state.leftLabels > 0, 'long-term labels are missing from the left side');
|
||||||
|
assert.ok(state.priceLines.every(item => item.usesCandles),
|
||||||
|
`a price line uses another scale: ${JSON.stringify(state.priceLines)}`);
|
||||||
|
assert.ok(state.coordinateDifference != null && state.coordinateDifference < 0.1,
|
||||||
|
`the same price differs by ${state.coordinateDifference}px between MA and candles`);
|
||||||
|
|
||||||
|
const intradayType = await page.evaluate(() => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const average = c.levels.find(level => level.kind === 'ma');
|
||||||
|
return {
|
||||||
|
actual: c.levelSeries.get(average.id).series.options().lineType,
|
||||||
|
expected: LightweightCharts.LineType.WithSteps,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(intradayType.actual, intradayType.expected,
|
||||||
|
'an intraday daily MA is not held as steps');
|
||||||
|
await page.click('.timeframes button:text-is("1d")');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const dailyType = await page.evaluate(() => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const average = c.levels.find(level => level.kind === 'ma');
|
||||||
|
return {
|
||||||
|
actual: c.levelSeries.get(average.id).series.options().lineType,
|
||||||
|
expected: LightweightCharts.LineType.Simple,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(dailyType.actual, dailyType.expected,
|
||||||
|
'a daily MA is still rendered as a staircase');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
tests/e2e/rth.test.mjs
Normal file
26
tests/e2e/rth.test.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { withChart, assertNoPageErrors } from './helpers.mjs';
|
||||||
|
|
||||||
|
test('SPY open and close land on weekday Eastern hours, never the weekend',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const marks = await page.evaluate(() => {
|
||||||
|
const monday = Date.UTC(2026, 7, 17, 0, 0) / 1000;
|
||||||
|
const sunday = Date.UTC(2026, 7, 16, 12, 0) / 1000;
|
||||||
|
return {
|
||||||
|
monday: ConfluenceChart.rthInstants(monday, monday + 24 * 3600),
|
||||||
|
sunday: ConfluenceChart.rthInstants(sunday, sunday + 6 * 3600),
|
||||||
|
dst: ConfluenceChart.epochAtEastern(2026, 3, 9, 9, 30),
|
||||||
|
open: ConfluenceChart.epochAtEastern(2026, 8, 17, 9, 30),
|
||||||
|
close: ConfluenceChart.epochAtEastern(2026, 8, 17, 16, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(marks.open, 1786973400);
|
||||||
|
assert.equal(marks.close, 1786996800);
|
||||||
|
assert.equal(marks.dst, 1773063000);
|
||||||
|
assert.deepEqual(marks.monday.map(mark => mark.kind), ['open', 'close']);
|
||||||
|
assert.equal(marks.sunday.length, 0);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
182
tests/e2e/touch.test.mjs
Normal file
182
tests/e2e/touch.test.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { withChart, assertNoPageErrors } from './helpers.mjs';
|
||||||
|
|
||||||
|
test('touch tap selects a drawing and horizontal and vertical pinches scale only their own axes',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const label = `e2e touch ${Date.now()}`;
|
||||||
|
const created = await page.evaluate(async name => {
|
||||||
|
const price = window.__chart.bars.at(-1).c;
|
||||||
|
const response = await fetch('/api/lines/price', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ price, note: name }),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}, label);
|
||||||
|
await page.waitForFunction(id =>
|
||||||
|
window.__chart.levels.some(level => level.id === id), created.id);
|
||||||
|
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
const geometry = await page.evaluate(id => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const level = chart.levels.find(value => value.id === id);
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
tap: { x: plot.left + plot.width * 0.5, y: plot.top + chart.candles.priceToCoordinate(level.anchor_p) },
|
||||||
|
horizontal: {
|
||||||
|
x1: plot.left + plot.width * 0.35, y1: plot.top + plot.height * 0.5,
|
||||||
|
x2: plot.left + plot.width * 0.65, y2: plot.top + plot.height * 0.5,
|
||||||
|
},
|
||||||
|
vertical: {
|
||||||
|
x1: plot.left + plot.width * 0.5, y1: plot.top + plot.height * 0.35,
|
||||||
|
x2: plot.left + plot.width * 0.5, y2: plot.top + plot.height * 0.65,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, created.id);
|
||||||
|
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart', touchPoints: [{ ...geometry.tap, id: 1 }],
|
||||||
|
});
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await page.waitForFunction(id => window.__chart.selectedLineId === id, created.id);
|
||||||
|
assert.match(await page.locator('.chart-tooltip').textContent(), new RegExp(label));
|
||||||
|
|
||||||
|
const state = () => page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const range = chart.chart.timeScale().getVisibleLogicalRange();
|
||||||
|
return {
|
||||||
|
timeFrom: range.from,
|
||||||
|
timeTo: range.to,
|
||||||
|
timeSpan: range.to - range.from,
|
||||||
|
priceSpan: Math.abs(chart.candles.coordinateToPrice(80) - chart.candles.coordinateToPrice(500)),
|
||||||
|
pageScale: window.visualViewport?.scale || 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const pinch = async (points, axis) => {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [
|
||||||
|
{ x: points.x1, y: points.y1, id: 1 },
|
||||||
|
{ x: points.x2, y: points.y2, id: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (let step = 1; step <= 8; step += 1) {
|
||||||
|
const delta = step * 8;
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [
|
||||||
|
{ x: points.x1 - (axis === 'x' ? delta : 0), y: points.y1 - (axis === 'y' ? delta : 0), id: 1 },
|
||||||
|
{ x: points.x2 + (axis === 'x' ? delta : 0), y: points.y2 + (axis === 'y' ? delta : 0), id: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const before = await state();
|
||||||
|
await pinch(geometry.horizontal, 'x');
|
||||||
|
const horizontal = await state();
|
||||||
|
|
||||||
|
assert.notEqual(Math.round(horizontal.timeSpan), Math.round(before.timeSpan),
|
||||||
|
'horizontal pinch did not change the time scale');
|
||||||
|
assert.ok(Math.abs(horizontal.priceSpan - before.priceSpan) < 0.1,
|
||||||
|
`horizontal pinch changed the price scale (${before.priceSpan} to ${horizontal.priceSpan})`);
|
||||||
|
|
||||||
|
await pinch(geometry.vertical, 'y');
|
||||||
|
const vertical = await state();
|
||||||
|
assert.ok(Math.abs(vertical.priceSpan - horizontal.priceSpan) > 0.1,
|
||||||
|
'vertical pinch did not change the price scale');
|
||||||
|
assert.ok(Math.abs(vertical.timeSpan - horizontal.timeSpan) < 0.1,
|
||||||
|
'vertical pinch changed the time scale');
|
||||||
|
assert.equal(vertical.pageScale, before.pageScale, 'pinch zoomed the browser page');
|
||||||
|
|
||||||
|
const plot = await page.evaluate(() => {
|
||||||
|
const rect = window.__chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
||||||
|
});
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [{ x: plot.left + plot.width * 0.5, y: plot.top + plot.height * 0.5, id: 1 }],
|
||||||
|
});
|
||||||
|
for (let step = 1; step <= 8; step += 1) {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [{ x: plot.left + plot.width * 0.5 + step * 10, y: plot.top + plot.height * 0.5, id: 1 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const oneFinger = await state();
|
||||||
|
assert.ok(Math.abs(oneFinger.timeFrom - vertical.timeFrom) < 0.5,
|
||||||
|
'one-finger drag panned the chart');
|
||||||
|
|
||||||
|
const midY = plot.top + plot.height * 0.5;
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [
|
||||||
|
{ x: plot.left + plot.width * 0.4, y: midY, id: 1 },
|
||||||
|
{ x: plot.left + plot.width * 0.6, y: midY, id: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (let step = 1; step <= 8; step += 1) {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [
|
||||||
|
{ x: plot.left + plot.width * 0.4 + step * 10, y: midY, id: 1 },
|
||||||
|
{ x: plot.left + plot.width * 0.6 + step * 10, y: midY, id: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const panned = await state();
|
||||||
|
assert.ok(Math.abs(panned.timeSpan - oneFinger.timeSpan) < 0.1,
|
||||||
|
'two-finger pan changed the time zoom');
|
||||||
|
assert.ok(Math.abs(panned.timeFrom - oneFinger.timeFrom) > 0.5,
|
||||||
|
'two-finger drag did not pan the chart');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
}, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live-price autoscroll restores autoscaling only when enabled',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await page.locator('.config-section summary').click();
|
||||||
|
const autoscroll = page.locator('.config-section label:has-text("Autoscroll to live price") input');
|
||||||
|
await autoscroll.uncheck();
|
||||||
|
await page.waitForFunction(() => window.__chart.autoScrollLivePrice === false);
|
||||||
|
|
||||||
|
const offscreenPrice = await page.evaluate(() => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const last = c.bars.at(-1);
|
||||||
|
// This is the same manual range created by a vertical or horizontal pinch.
|
||||||
|
c.manualPriceRange = { minValue: last.c - 1, maxValue: last.c + 1 };
|
||||||
|
c.candles.applyOptions({ autoscaleInfoProvider: c.candleAutoscaleProvider });
|
||||||
|
const price = last.c - 10;
|
||||||
|
c.updateBar({ ...last, o: price, h: price, l: price, c: price });
|
||||||
|
return price;
|
||||||
|
});
|
||||||
|
const disabledState = await page.evaluate(() => Boolean(window.__chart.manualPriceRange));
|
||||||
|
assert.equal(disabledState, true, 'disabled autoscroll reset the manual range');
|
||||||
|
|
||||||
|
await autoscroll.check();
|
||||||
|
await page.waitForFunction(() => window.__chart.autoScrollLivePrice === true);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
const state = await page.evaluate(price => {
|
||||||
|
const c = window.__chart;
|
||||||
|
return {
|
||||||
|
manualPriceRange: c.manualPriceRange,
|
||||||
|
y: c.candles.priceToCoordinate(price),
|
||||||
|
plotHeight: c.plotCanvas().clientHeight,
|
||||||
|
};
|
||||||
|
}, offscreenPrice);
|
||||||
|
|
||||||
|
assert.equal(state.manualPriceRange, null, 'enabled autoscroll kept the manual range');
|
||||||
|
assert.ok(state.y >= 0 && state.y <= state.plotHeight,
|
||||||
|
`live price remained off-screen at y=${state.y}, plot=${state.plotHeight}`);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
}, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true });
|
||||||
|
});
|
||||||
1458
tests/e2e/trendline.test.mjs
Normal file
1458
tests/e2e/trendline.test.mjs
Normal file
File diff suppressed because it is too large
Load diff
42
tests/e2e/volume.test.mjs
Normal file
42
tests/e2e/volume.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { withChart, selectTimeframe, assertNoPageErrors } from './helpers.mjs';
|
||||||
|
|
||||||
|
const volumeState = page => page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const candles = chart.candles.data();
|
||||||
|
const volume = chart.volume.data();
|
||||||
|
return {
|
||||||
|
candleTimes: candles.map(point => point.time),
|
||||||
|
volumeTimes: volume.map(point => point.time),
|
||||||
|
values: volume.map(point => point.value),
|
||||||
|
scaleId: chart.volume.options().priceScaleId,
|
||||||
|
scale: chart.chart.priceScale('volume').options(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test('volume follows every candle across timeframe changes on its own scale',
|
||||||
|
{ timeout: 240000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await selectTimeframe(page, '1m');
|
||||||
|
const minute = await volumeState(page);
|
||||||
|
|
||||||
|
assert.deepEqual(minute.volumeTimes, minute.candleTimes,
|
||||||
|
'1m volume timestamps are not one-for-one with candle timestamps');
|
||||||
|
assert.ok(minute.values.some(value => value > 0), '1m volume contains no non-zero values');
|
||||||
|
assert.equal(minute.scaleId, 'volume', 'volume shares a price scale with another series');
|
||||||
|
assert.equal(minute.scale.visible, false, 'the dedicated volume scale became visible');
|
||||||
|
assert.ok(minute.scale.scaleMargins.top >= 0.75,
|
||||||
|
'volume is no longer confined to the bottom of the chart');
|
||||||
|
|
||||||
|
await selectTimeframe(page, '1h');
|
||||||
|
const hourly = await volumeState(page);
|
||||||
|
assert.deepEqual(hourly.volumeTimes, hourly.candleTimes,
|
||||||
|
'1h volume timestamps are not one-for-one with candle timestamps');
|
||||||
|
assert.ok(hourly.values.some(value => value > 0), '1h volume contains no non-zero values');
|
||||||
|
assert.notDeepEqual(hourly.volumeTimes, minute.volumeTimes,
|
||||||
|
'volume data did not change with the candle timeframe');
|
||||||
|
assert.equal(hourly.scaleId, 'volume', 'volume left its dedicated scale after switching');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from app.bars.aggregator import Aggregator
|
from app.bars.aggregator import Aggregator
|
||||||
from app.bars.models import Bar, Timeframe
|
from app.bars.models import Bar, Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
ET = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
def minute(t: int, price: float = 100, volume: int = 1) -> Bar:
|
def minute(t: int, price: float = 100, volume: int = 1) -> Bar:
|
||||||
return Bar(Timeframe.M1, t, price, price + 1, price - 1, price + 0.5, volume, True, "ES=F", "replay")
|
return Bar(Timeframe.M1, t, price, price + 1, price - 1, price + 0.5, volume, True, "ES=F", "replay")
|
||||||
|
|
||||||
|
|
@ -41,3 +47,27 @@ def test_replay_is_deterministic():
|
||||||
return [bar.to_dict() for source in tape for bar in aggregator.update(source)]
|
return [bar.to_dict() for source in tape for bar in aggregator.update(source)]
|
||||||
|
|
||||||
assert run() == run()
|
assert run() == run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_yahoo_hours_form_the_right_cme_daily_bar_across_1800_et():
|
||||||
|
def hour(local_time: str, o: float, h: float, low: float, c: float, volume: int) -> Bar:
|
||||||
|
t = int(datetime.fromisoformat(local_time).replace(tzinfo=ET).timestamp())
|
||||||
|
return Bar(Timeframe.H1, t, o, h, low, c, volume, True, "ES=F", "yahoo")
|
||||||
|
|
||||||
|
aggregator = Aggregator([Timeframe.H1, Timeframe.D1])
|
||||||
|
tape = [
|
||||||
|
hour("2026-01-12T17:00:00", 90, 94, 89, 93, 5),
|
||||||
|
hour("2026-01-12T18:00:00", 100, 104, 98, 103, 10),
|
||||||
|
hour("2026-01-13T17:00:00", 103, 110, 97, 108, 20),
|
||||||
|
hour("2026-01-13T18:00:00", 120, 125, 119, 124, 40),
|
||||||
|
]
|
||||||
|
|
||||||
|
emitted = [bar for source in tape for bar in aggregator.update(source)]
|
||||||
|
session_start = int(datetime(2026, 1, 12, 18, tzinfo=ET).timestamp())
|
||||||
|
daily = next(
|
||||||
|
bar for bar in emitted
|
||||||
|
if bar.tf is Timeframe.D1 and bar.t == session_start and bar.closed
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (daily.o, daily.h, daily.l, daily.c, daily.v) == (100, 110, 97, 108, 30)
|
||||||
|
assert (daily.symbol, daily.source) == ("ES=F", "yahoo")
|
||||||
|
|
|
||||||
87
tests/test_alert_state.py
Normal file
87
tests/test_alert_state.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Suppression has to survive a restart, or every deploy re-alerts."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
def level(id_: str, price: float, weight: float):
|
||||||
|
return Level(
|
||||||
|
id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_,
|
||||||
|
100, price, 0, None, 0, 100, 100, False, False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def zone(price: float = 100.0):
|
||||||
|
return cluster_levels([level("a", price, 3), level("b", price + 0.1, 4)], 100, price, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def engine(tmp_path, cooldown=14400):
|
||||||
|
return AlertEngine(6, cooldown, tmp_path / "alert_state.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fires_once_then_suppresses_within_the_process(tmp_path):
|
||||||
|
one = engine(tmp_path)
|
||||||
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
||||||
|
assert one.evaluate(zone(), 100, 1, 60, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_suppression_survives_a_restart(tmp_path):
|
||||||
|
one = engine(tmp_path)
|
||||||
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
||||||
|
|
||||||
|
# A second engine over the same state file stands in for a redeploy.
|
||||||
|
two = engine(tmp_path)
|
||||||
|
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_state_path_a_restart_still_refires(tmp_path):
|
||||||
|
"""Unchanged behaviour for local runs, which should not write files."""
|
||||||
|
assert len(AlertEngine(6, 14400).evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
||||||
|
assert len(AlertEngine(6, 14400).evaluate(zone(), 100, 1, 60, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rearms_across_a_restart_after_cooldown_and_separation(tmp_path):
|
||||||
|
one = engine(tmp_path, cooldown=900)
|
||||||
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
||||||
|
|
||||||
|
two = engine(tmp_path, cooldown=900)
|
||||||
|
# Price genuinely left the zone, and the cooldown has elapsed.
|
||||||
|
assert two.evaluate(cluster_levels([level("a", 100, 3)], 100, 103, 1), 103, 1, 902, "/ES") == []
|
||||||
|
assert len(two.evaluate(zone(), 100, 1, 903, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_state_does_not_prevent_alerting(tmp_path):
|
||||||
|
(tmp_path / "alert_state.json").write_text("{not json", encoding="utf-8")
|
||||||
|
assert len(engine(tmp_path).evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_es_zone_does_not_suppress_the_same_price_on_gold(tmp_path):
|
||||||
|
one = engine(tmp_path)
|
||||||
|
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
||||||
|
assert len(one.evaluate(zone(), 100, 1, 60, "/GC")) == 1
|
||||||
|
assert one.evaluate(zone(), 100, 1, 90, "ES=F") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_fired_rows_without_symbol_are_es(tmp_path):
|
||||||
|
path = tmp_path / "alert_state.json"
|
||||||
|
path.write_text(
|
||||||
|
'{"next_number": 2, "fired": [{"center": 100.0, "at": 0}]}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
two = engine(tmp_path)
|
||||||
|
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
|
||||||
|
assert len(two.evaluate(zone(), 100, 1, 60, "/GC")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_file_records_centre_and_time(tmp_path):
|
||||||
|
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
|
||||||
|
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
|
||||||
|
# The file carries the alert counter as well as the fired zones, so numbers
|
||||||
|
# do not restart from 1 after a deploy and collide with a phone's history.
|
||||||
|
assert len(payload["fired"]) == 1
|
||||||
|
assert payload["fired"][0]["at"] == 42
|
||||||
|
assert payload["fired"][0]["symbol"] == "/ES"
|
||||||
|
assert payload["next_number"] == 2
|
||||||
|
|
@ -8,6 +8,10 @@ def level(id_: str, price: float, weight: float):
|
||||||
return Level(id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False)
|
return Level(id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def drawn_line(id_: str, price: float, label: str = "swing high", weight: float = 1):
|
||||||
|
return Level(id_, LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, weight, 1, label, 100, price, 0, None, 0, 100, 100, False, False)
|
||||||
|
|
||||||
|
|
||||||
def test_oscillation_fires_once_until_separation_and_cooldown():
|
def test_oscillation_fires_once_until_separation_and_cooldown():
|
||||||
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
levels = [level("a", 100, 3), level("b", 100.1, 4)]
|
levels = [level("a", 100, 3), level("b", 100.1, 4)]
|
||||||
|
|
@ -22,7 +26,205 @@ def test_oscillation_fires_once_until_separation_and_cooldown():
|
||||||
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
|
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_confluence_off_skips_auto_zones_but_keeps_drawn_lines():
|
||||||
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
|
auto = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
|
||||||
|
assert engine.evaluate(auto, 100, 1, 0, "/ES", confluence=False) == []
|
||||||
|
drawn = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
|
||||||
|
assert len(engine.evaluate(drawn, 100, 1, 0, "/ES", confluence=False)) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
|
def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
|
||||||
engine = AlertEngine(min_score=28)
|
engine = AlertEngine(min_score=28)
|
||||||
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
|
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
|
||||||
assert engine.evaluate(cluster, 100, 1, 0, "/ES") == []
|
assert engine.evaluate(cluster, 100, 1, 0, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_third_level_joining_the_zone_does_not_re_alert():
|
||||||
|
# Membership churns constantly as levels drift in and out of tolerance.
|
||||||
|
# Suppression is by proximity precisely so this reads as one zone.
|
||||||
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
|
two = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
|
||||||
|
assert len(engine.evaluate(two, 100, 1, 0, "/ES")) == 1
|
||||||
|
|
||||||
|
three = cluster_levels(
|
||||||
|
[level("a", 100, 3), level("b", 100.1, 4), level("c", 100.2, 5)], 100, 100, 1
|
||||||
|
)
|
||||||
|
assert engine.evaluate(three, 100, 1, 60, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_numbered_drawn_line_is_named_by_its_drawing_not_the_score():
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
line = drawn_line("ml_1", 100, label="up1h")
|
||||||
|
line.number = 27
|
||||||
|
alerts = engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, 0, "/ES")
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert "#27" in alerts[0].message
|
||||||
|
assert "up1h" in alerts[0].message
|
||||||
|
assert "confluence" not in alerts[0].message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lone_drawn_line_alerts_despite_the_score_threshold():
|
||||||
|
# A 5m line weighs 1 against a threshold of 28. Gating drawn lines on score
|
||||||
|
# would mean a line you deliberately drew could never alert.
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
clusters = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
|
||||||
|
|
||||||
|
assert len(clusters) == 1, "a lone drawn line must survive clustering"
|
||||||
|
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert "LINE" in alerts[0].message
|
||||||
|
assert "swing high" in alerts[0].message
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lone_weak_non_drawn_level_still_does_not_alert():
|
||||||
|
# The bypass is for drawn lines only; a lone 5m average stays quiet.
|
||||||
|
weak = level("ma", 100, 1)
|
||||||
|
weak.tf = Timeframe.M5
|
||||||
|
assert cluster_levels([weak], 100, 100, 1) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawn_line_clustering_with_levels_reports_as_a_zone():
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
clusters = cluster_levels([drawn_line("ml_1", 100), level("pd", 100.1, 16)], 100, 100, 1)
|
||||||
|
|
||||||
|
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert "ZONE" in alerts[0].message
|
||||||
|
assert "swing high" in alerts[0].message # the line is still named
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_crossing_a_level_does_not_re_alert_on_the_side_flip():
|
||||||
|
# Side is positional, so a level sitting at price flips between support and
|
||||||
|
# resistance on every tick across it. This produced a fresh alert per
|
||||||
|
# crossing — four in two minutes when first tried against a live line.
|
||||||
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
|
line = [drawn_line("ml_1", 100, weight=8)]
|
||||||
|
|
||||||
|
below = cluster_levels(line, 100, 99.9, 1) # level above price -> resistance
|
||||||
|
assert len(engine.evaluate(below, 99.9, 1, 0, "/ES")) == 1
|
||||||
|
|
||||||
|
above = cluster_levels(line, 100, 100.1, 1) # price crossed -> now support
|
||||||
|
assert above and above[0].side is not below[0].side, "the flip must actually occur"
|
||||||
|
assert engine.evaluate(above, 100.1, 1, 30, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_genuinely_separate_zone_still_alerts_during_cooldown():
|
||||||
|
# The cooldown is per zone, not global: a level far away is new information.
|
||||||
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
|
near = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
|
||||||
|
assert len(engine.evaluate(near, 100, 1, 0, "/ES")) == 1
|
||||||
|
|
||||||
|
far = cluster_levels([level("c", 120, 3), level("d", 120.1, 4)], 100, 120, 1)
|
||||||
|
assert len(engine.evaluate(far, 120, 1, 60, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_are_numbered_and_stamped_in_local_time(tmp_path):
|
||||||
|
# A push and a screen have to agree on which alert is which. The browser
|
||||||
|
# cannot supply that: its counter restarts on reload and differs per tab.
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
|
||||||
|
engine = AlertEngine(
|
||||||
|
1.0, cooldown_seconds=0, state_path=tmp_path / "alerts.json",
|
||||||
|
timezone_name="America/Chicago",
|
||||||
|
)
|
||||||
|
engine._next_number = 41
|
||||||
|
stamp = engine._stamp(1786360000) # Mon 2026-08-10 11:06:40 UTC
|
||||||
|
|
||||||
|
assert "06:06:40" in stamp and "CDT" in stamp, stamp
|
||||||
|
assert "11:06" not in stamp, "stamped in UTC rather than the configured zone"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_numbers_survive_a_restart(tmp_path):
|
||||||
|
# Numbers restarting from 1 after a deploy would collide with the ones
|
||||||
|
# already sitting in a phone's notification history.
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
|
||||||
|
path = tmp_path / "alerts.json"
|
||||||
|
first = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
|
||||||
|
first._next_number = 87
|
||||||
|
first._save()
|
||||||
|
|
||||||
|
assert AlertEngine(1.0, cooldown_seconds=0, state_path=path)._next_number == 87
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_old_bare_list_state_file_still_loads(tmp_path):
|
||||||
|
# The file predates numbering and was a plain list of fired zones.
|
||||||
|
import json
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
|
||||||
|
path = tmp_path / "alerts.json"
|
||||||
|
path.write_text(json.dumps([{"center": 5000.0, "at": 1786360000}]), encoding="utf-8")
|
||||||
|
|
||||||
|
engine = AlertEngine(1.0, cooldown_seconds=0, state_path=path)
|
||||||
|
assert len(engine._fired) == 1
|
||||||
|
assert engine._next_number == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_watched_daily_ma_alerts_despite_the_score_threshold():
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
watched = level("ma:1d:sma:200", 100, 12)
|
||||||
|
watched.period = 200
|
||||||
|
|
||||||
|
alerts = engine.evaluate([], 100, 1, 0, "/ES", [watched])
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0].tripped == ()
|
||||||
|
assert "200 DMA" in alerts[0].message
|
||||||
|
assert "ZONE" not in alerts[0].message
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unwatched_daily_ma_does_not_alert_alone():
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
lone = level("ma:1d:sma:200", 100, 12)
|
||||||
|
lone.period = 200
|
||||||
|
clusters = cluster_levels([lone], 100, 100, 1)
|
||||||
|
|
||||||
|
assert engine.evaluate(clusters, 100, 1, 0, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_watched_daily_ma_stays_quiet_until_price_leaves():
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
watched = level("ma:1d:sma:50", 100, 12)
|
||||||
|
watched.period = 50
|
||||||
|
|
||||||
|
assert len(engine.evaluate([], 100, 1, 0, "/ES", [watched])) == 1
|
||||||
|
assert engine.evaluate([], 100.2, 1, 60, "/ES", [watched]) == []
|
||||||
|
assert engine.evaluate([], 103, 1, 901, "/ES", [watched]) == []
|
||||||
|
assert len(engine.evaluate([], 100, 1, 902, "/ES", [watched])) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_zone_at_the_same_price_suppresses_a_dma_alert():
|
||||||
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
|
members = [level("a", 100, 3), level("b", 100.1, 4)]
|
||||||
|
clusters = cluster_levels(members, 100, 100, 1)
|
||||||
|
watched = level("ma:1d:sma:200", 100, 12)
|
||||||
|
watched.period = 200
|
||||||
|
|
||||||
|
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES", [watched])
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert "ZONE" in alerts[0].message
|
||||||
|
assert "DMA" not in alerts[0].message
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_push_carries_the_number_and_time_but_the_screen_message_does_not(tmp_path):
|
||||||
|
# ntfy is plain text, so the number and the local time have to live in the
|
||||||
|
# body. The browser gets `number` as a field and draws a badge, so printing
|
||||||
|
# them in the message too would show the same number twice in one row.
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
from app.analysis.confluence import Cluster
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
level = Level("pd:high", LevelKind.HORIZONTAL, Timeframe.D1, Side.RESISTANCE, 16, 1,
|
||||||
|
"PDH", 7784, 5000, 0, None, 0, 7784, 7784, False, False)
|
||||||
|
cluster = Cluster("cl_x", Side.RESISTANCE, 7784.0, 7783.0, 7785.5, 21, [level], 0.25)
|
||||||
|
engine = AlertEngine(1.0, cooldown_seconds=0, timezone_name="America/Chicago")
|
||||||
|
engine._next_number = 47
|
||||||
|
|
||||||
|
alert = engine.evaluate([cluster], 7784.25, 4.0, 1786430800, "/ES")[0]
|
||||||
|
|
||||||
|
assert alert.number == 47
|
||||||
|
assert alert.push.startswith("#47 ")
|
||||||
|
assert "CDT" in alert.push
|
||||||
|
assert not alert.message.startswith("#")
|
||||||
|
assert "CDT" not in alert.message
|
||||||
|
|
|
||||||
32
tests/test_asset_versioning.py
Normal file
32
tests/test_asset_versioning.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
def test_assets_are_versioned_and_the_page_is_not_cached():
|
||||||
|
# Not a context manager: that would run the lifespan, which seeds months of
|
||||||
|
# history from Yahoo before serving anything.
|
||||||
|
response = TestClient(main.app).get("/")
|
||||||
|
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
refs = re.findall(r'(?:src|href)="(/static/[^"]+)"', response.text)
|
||||||
|
assert refs, "no static assets referenced"
|
||||||
|
# Without this a tab left open keeps running the JavaScript it first loaded,
|
||||||
|
# which made a fixed bug look like it was still broken.
|
||||||
|
assert all("?v=" in ref for ref in refs), refs
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_tracks_content_not_timestamps(tmp_path, monkeypatch):
|
||||||
|
asset = tmp_path / "app.js"
|
||||||
|
asset.write_text("//", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(main, "STATIC_DIR", tmp_path)
|
||||||
|
|
||||||
|
first = main.asset_version()
|
||||||
|
asset.touch()
|
||||||
|
# A deploy checks every file out fresh; unchanged assets must stay cached.
|
||||||
|
assert main.asset_version() == first
|
||||||
|
|
||||||
|
asset.write_text("// changed", encoding="utf-8")
|
||||||
|
assert main.asset_version() != first
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import jwt
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.meta import router as meta_router
|
from app.api.meta import router as meta_router
|
||||||
|
from app.api import captures
|
||||||
|
from app.api.deps import create_session, session_matches, session_secret
|
||||||
from app.api.routes import router as api_router
|
from app.api.routes import router as api_router
|
||||||
|
from app.api.schwab_auth import router as schwab_auth_router
|
||||||
from app.api.ws import router as ws_router
|
from app.api.ws import router as ws_router
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.runtime import Runtime
|
from app.runtime import Runtime
|
||||||
|
|
@ -12,13 +19,15 @@ from app.runtime import Runtime
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(tmp_path):
|
def client(tmp_path):
|
||||||
def build(token: str) -> TestClient:
|
def build(token: str, password: str = "") -> TestClient:
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
chart_auth_token=token,
|
chart_auth_token=token,
|
||||||
|
chart_password=password,
|
||||||
manual_lines_path=tmp_path / "manual_lines.json",
|
manual_lines_path=tmp_path / "manual_lines.json",
|
||||||
)
|
)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(meta_router)
|
app.include_router(meta_router)
|
||||||
|
app.include_router(schwab_auth_router)
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
app.state.runtime = Runtime(settings)
|
app.state.runtime = Runtime(settings)
|
||||||
|
|
@ -31,6 +40,39 @@ def test_open_when_no_token_configured(client):
|
||||||
assert client("").get("/api/bars").status_code == 200
|
assert client("").get("/api/bars").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_snap_keeps_geometry_fields(client):
|
||||||
|
response = client("").post("/api/debug/snap", json={
|
||||||
|
"kind": "geometry",
|
||||||
|
"tf": "1m",
|
||||||
|
"gaps": [{"from": 1, "to": 540, "missing": 8, "owned": 8}],
|
||||||
|
"lines": [{"n": 12, "tf": "30m", "pts": 4, "med": 0.1, "bad": []}],
|
||||||
|
})
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_callback_stays_open_when_a_token_is_set():
|
||||||
|
# The provider redirects a browser here and cannot attach the chart token.
|
||||||
|
# A 401 would break the login flow at its last step, on production only,
|
||||||
|
# where CHART_AUTH_TOKEN is the one thing that differs from local.
|
||||||
|
from fastapi import FastAPI as _FastAPI
|
||||||
|
from app.config import Settings as _Settings
|
||||||
|
from app.runtime import Runtime as _Runtime
|
||||||
|
import tempfile, pathlib as _pathlib
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
app = _FastAPI()
|
||||||
|
app.include_router(meta_router)
|
||||||
|
app.include_router(schwab_auth_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.state.runtime = _Runtime(
|
||||||
|
_Settings(chart_auth_token="s3cret",
|
||||||
|
manual_lines_path=_pathlib.Path(tmp) / "manual_lines.json")
|
||||||
|
)
|
||||||
|
probe = TestClient(app)
|
||||||
|
assert probe.get("/api/qt").status_code == 200
|
||||||
|
assert probe.get("/api/status").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_missing_token(client):
|
def test_rejects_missing_token(client):
|
||||||
assert client("s3cret").get("/api/bars").status_code == 401
|
assert client("s3cret").get("/api/bars").status_code == 401
|
||||||
|
|
||||||
|
|
@ -49,6 +91,87 @@ def test_accepts_query_token(client):
|
||||||
assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200
|
assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_login_uses_an_httponly_session_instead_of_exposing_the_token(client):
|
||||||
|
probe = client("opaque-api-token", "friendly passphrase")
|
||||||
|
|
||||||
|
response = probe.post("/api/login", json={"password": "friendly passphrase"})
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
cookie = response.headers["set-cookie"]
|
||||||
|
assert "chart-session=" in cookie
|
||||||
|
assert "HttpOnly" in cookie
|
||||||
|
assert "SameSite=strict" in cookie
|
||||||
|
assert "opaque-api-token" not in cookie
|
||||||
|
assert probe.get("/api/bars").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_cannot_create_a_session(client):
|
||||||
|
probe = client("opaque-api-token", "friendly passphrase")
|
||||||
|
|
||||||
|
assert probe.post("/api/login", json={"password": "wrong"}).status_code == 401
|
||||||
|
assert probe.get("/api/bars").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_browser_token_is_exchanged_for_a_session(client):
|
||||||
|
probe = client("opaque-api-token", "friendly passphrase")
|
||||||
|
|
||||||
|
response = probe.post(
|
||||||
|
"/api/login",
|
||||||
|
json={"password": ""},
|
||||||
|
headers={"X-Chart-Token": "opaque-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert probe.get("/api/bars").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_invalidates_the_browser_session(client):
|
||||||
|
probe = client("opaque-api-token", "friendly passphrase")
|
||||||
|
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
|
||||||
|
|
||||||
|
assert probe.post("/api/logout").status_code == 204
|
||||||
|
assert probe.get("/api/bars").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_signature_and_expiry_are_enforced(client):
|
||||||
|
probe = client("opaque-api-token", "friendly passphrase")
|
||||||
|
session = create_session(probe.app)
|
||||||
|
expired = jwt.encode(
|
||||||
|
{"sub": "shared", "iat": 1, "exp": 2},
|
||||||
|
session_secret(probe.app),
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert session_matches(probe.app, session)
|
||||||
|
assert not session_matches(probe.app, f"{session}tampered")
|
||||||
|
assert not session_matches(probe.app, expired)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode_passwords_do_not_crash_login(client):
|
||||||
|
probe = client("opaque-api-token", "correct horse ünicode")
|
||||||
|
|
||||||
|
assert probe.post("/api/login", json={"password": "wrong pässword"}).status_code == 401
|
||||||
|
assert probe.post("/api/login", json={"password": "correct horse ünicode"}).status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_https_login_marks_the_session_cookie_secure(client):
|
||||||
|
response = client("opaque-api-token", "friendly passphrase").post(
|
||||||
|
"/api/login",
|
||||||
|
json={"password": "friendly passphrase"},
|
||||||
|
headers={"X-Forwarded-Proto": "https"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Secure" in response.headers["set-cookie"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_alone_enables_auth(client):
|
||||||
|
probe = client("", "friendly passphrase")
|
||||||
|
|
||||||
|
assert probe.get("/api/bars").status_code == 401
|
||||||
|
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
|
||||||
|
assert probe.get("/api/bars").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_writes_are_protected(client):
|
def test_writes_are_protected(client):
|
||||||
payload = {
|
payload = {
|
||||||
"tf": "1m",
|
"tf": "1m",
|
||||||
|
|
@ -61,6 +184,69 @@ def test_writes_are_protected(client):
|
||||||
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
|
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_capture_needs_auth_to_upload_but_can_be_retrieved_and_deleted(
|
||||||
|
client, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
|
||||||
|
probe = client("s3cret")
|
||||||
|
image = b"\x89PNG\r\n\x1a\ntrimmed-test-image"
|
||||||
|
metadata = base64.b64encode(
|
||||||
|
json.dumps({"timeframe": "30m", "viewport_width": 1440}).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
assert probe.post(
|
||||||
|
"/api/debug/captures",
|
||||||
|
content=image,
|
||||||
|
headers={"Content-Type": "image/png", "X-Capture-Metadata": metadata},
|
||||||
|
).status_code == 401
|
||||||
|
response = probe.post(
|
||||||
|
"/api/debug/captures",
|
||||||
|
content=image,
|
||||||
|
headers={
|
||||||
|
"X-Chart-Token": "s3cret",
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"X-Capture-Metadata": metadata,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
saved = response.json()
|
||||||
|
assert saved["id"].startswith("c-")
|
||||||
|
# The screenshot URL is the intentional handoff from a browser to an agent
|
||||||
|
# on a different machine. Metadata remains private because it can contain
|
||||||
|
# the selected drawing's text and geometry.
|
||||||
|
assert probe.get(saved["url"]).content == image
|
||||||
|
assert probe.get(saved["metadata_url"]).status_code == 401
|
||||||
|
details = probe.get(saved["metadata_url"], headers={"X-Chart-Token": "s3cret"}).json()
|
||||||
|
assert details["id"] == saved["id"]
|
||||||
|
assert details["timeframe"] == "30m"
|
||||||
|
assert details["viewport_width"] == 1440
|
||||||
|
assert probe.delete(saved["url"]).status_code == 204
|
||||||
|
assert probe.get(saved["url"]).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_capture_is_retrievable_with_the_browser_session_cookie(client, tmp_path, monkeypatch):
|
||||||
|
# The path that has to stay effortless: the person debugging is already
|
||||||
|
# logged in, so viewing a capture must need nothing extra.
|
||||||
|
monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures")
|
||||||
|
probe = client("s3cret", password="friendly passphrase")
|
||||||
|
image = b"\x89PNG\r\n\x1a\ntrimmed-test-image"
|
||||||
|
metadata = base64.b64encode(json.dumps({"timeframe": "1m"}).encode()).decode()
|
||||||
|
saved = probe.post(
|
||||||
|
"/api/debug/captures",
|
||||||
|
content=image,
|
||||||
|
headers={
|
||||||
|
"X-Chart-Token": "s3cret",
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"X-Capture-Metadata": metadata,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
|
||||||
|
# TestClient keeps the session cookie from here on.
|
||||||
|
assert probe.get(saved["url"]).content == image
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", ["/api/health", "/api/version"])
|
@pytest.mark.parametrize("path", ["/api/health", "/api/version"])
|
||||||
def test_meta_endpoints_stay_open(client, path):
|
def test_meta_endpoints_stay_open(client, path):
|
||||||
"""bin/wait-deploy polls /api/version without carrying the token."""
|
"""bin/wait-deploy polls /api/version without carrying the token."""
|
||||||
|
|
@ -77,3 +263,23 @@ def test_websocket_rejects_missing_token(client):
|
||||||
def test_websocket_accepts_query_token(client):
|
def test_websocket_accepts_query_token(client):
|
||||||
with client("s3cret").websocket_connect("/ws?token=s3cret") as socket:
|
with client("s3cret").websocket_connect("/ws?token=s3cret") as socket:
|
||||||
assert socket.receive_json()["type"] == "snapshot"
|
assert socket.receive_json()["type"] == "snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_accepts_the_password_session_cookie(client):
|
||||||
|
probe = client("opaque-api-token", "friendly passphrase")
|
||||||
|
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
|
||||||
|
|
||||||
|
with probe.websocket_connect("/ws", headers={"origin": "http://testserver"}) as socket:
|
||||||
|
assert socket.receive_json()["type"] == "snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_rejects_a_session_cookie_from_another_origin(client):
|
||||||
|
probe = client("opaque-api-token", "friendly passphrase")
|
||||||
|
assert probe.post("/api/login", json={"password": "friendly passphrase"}).status_code == 204
|
||||||
|
|
||||||
|
with pytest.raises(WebSocketDisconnect) as excinfo:
|
||||||
|
with probe.websocket_connect(
|
||||||
|
"/ws", headers={"origin": "https://other.example.com"}
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
assert excinfo.value.code == 1008
|
||||||
|
|
|
||||||
137
tests/test_bar_space.py
Normal file
137
tests/test_bar_space.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
from app.analysis.bar_space import (
|
||||||
|
fill_short_gaps, index_at, price_in_bar_space, price_in_timeframe_space,
|
||||||
|
timeframe_index_at,
|
||||||
|
)
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
MINUTE = 60
|
||||||
|
|
||||||
|
|
||||||
|
def sloped(anchor_t: int, anchor_p: float, last_t: int, last_p: float) -> Level:
|
||||||
|
slope = (last_p - anchor_p) / (last_t - anchor_t)
|
||||||
|
return Level(
|
||||||
|
"ml", LevelKind.MANUAL, Timeframe.M1, Side.SUPPORT, 1, 1, "line",
|
||||||
|
anchor_t, anchor_p, slope, None, 0, anchor_t, last_t, False, False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_is_linear_when_bars_are_evenly_spaced():
|
||||||
|
times = [i * MINUTE for i in range(10)]
|
||||||
|
assert index_at(times, 0) == 0
|
||||||
|
assert index_at(times, 5 * MINUTE) == 5
|
||||||
|
assert index_at(times, int(2.5 * MINUTE)) == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_gap_costs_one_index_however_long_it_is():
|
||||||
|
# Two bars either side of a weekend are adjacent on screen.
|
||||||
|
times = [0, MINUTE, MINUTE + 49 * 3600, MINUTE + 49 * 3600 + MINUTE]
|
||||||
|
assert index_at(times, MINUTE) == 1
|
||||||
|
assert index_at(times, MINUTE + 49 * 3600) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_stays_straight_in_bar_space_across_a_gap():
|
||||||
|
# Bars: ten minutes, a 49-hour weekend, then ten more.
|
||||||
|
weekend = 49 * 3600
|
||||||
|
times = [i * MINUTE for i in range(10)] + [10 * MINUTE + weekend + i * MINUTE for i in range(10)]
|
||||||
|
level = sloped(times[0], 100.0, times[9], 109.0) # 1 point per bar
|
||||||
|
|
||||||
|
# Extending nine further bars must add nine more points, gap notwithstanding.
|
||||||
|
assert price_in_bar_space(level, times, times[18]) == 118.0
|
||||||
|
# Clock-based pricing would have run away during the halt.
|
||||||
|
assert level.price_at(times[18]) > 3000
|
||||||
|
|
||||||
|
|
||||||
|
def test_flat_anchors_return_the_anchor_price():
|
||||||
|
times = [i * MINUTE for i in range(5)]
|
||||||
|
level = sloped(0, 100.0, MINUTE, 100.0)
|
||||||
|
assert price_in_bar_space(level, times, times[4]) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_30m_line_does_not_change_slope_when_1m_history_starts_after_its_anchors():
|
||||||
|
half_hour = 30 * MINUTE
|
||||||
|
weekend = 49 * 3600
|
||||||
|
source_times = [
|
||||||
|
0, half_hour,
|
||||||
|
half_hour + weekend, 2 * half_hour + weekend,
|
||||||
|
3 * half_hour + weekend, 4 * half_hour + weekend,
|
||||||
|
]
|
||||||
|
line = sloped(source_times[0], 100.0, source_times[3], 103.0)
|
||||||
|
line.tf = Timeframe.M30
|
||||||
|
# This is the browser's failure mode: its 1m window starts after both
|
||||||
|
# anchors, so legacy edge extrapolation invents a different coordinate
|
||||||
|
# system. Source geometry still advances one point per 30m bar.
|
||||||
|
target = source_times[5]
|
||||||
|
truncated_minutes = list(range(source_times[4], target + MINUTE, MINUTE))
|
||||||
|
|
||||||
|
assert price_in_timeframe_space(line, source_times, Timeframe.M30, target) == 105.0
|
||||||
|
assert price_in_bar_space(line, truncated_minutes, target) != 105.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_nested_30m_and_1m_grids_agree():
|
||||||
|
half_hour = 30 * MINUTE
|
||||||
|
source_times = [i * half_hour for i in range(12)]
|
||||||
|
minute_times = list(range(0, source_times[-1] + MINUTE, MINUTE))
|
||||||
|
line = sloped(source_times[1], 100.0, source_times[5], 104.0)
|
||||||
|
line.tf = Timeframe.M30
|
||||||
|
target = source_times[9]
|
||||||
|
|
||||||
|
assert price_in_timeframe_space(line, source_times, Timeframe.M30, target) == 108.0
|
||||||
|
assert price_in_bar_space(line, minute_times, target) == 108.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_partial_30m_bar_advances_normally_before_a_weekend():
|
||||||
|
half_hour = 30 * MINUTE
|
||||||
|
weekend = 49 * 3600
|
||||||
|
source_times = [0, half_hour, half_hour + weekend]
|
||||||
|
line = sloped(0, 100.0, half_hour, 101.0)
|
||||||
|
line.tf = Timeframe.M30
|
||||||
|
|
||||||
|
assert price_in_timeframe_space(
|
||||||
|
line, source_times, Timeframe.M30, half_hour - MINUTE,
|
||||||
|
) == 100.0 + 29 / 30
|
||||||
|
assert price_in_timeframe_space(
|
||||||
|
line, source_times, Timeframe.M30, half_hour + weekend,
|
||||||
|
) == 102.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_geometry_refuses_to_invent_history_before_its_first_bar():
|
||||||
|
half_hour = 30 * MINUTE
|
||||||
|
source_times = [half_hour, 2 * half_hour, 3 * half_hour]
|
||||||
|
line = sloped(0, 100.0, half_hour, 101.0)
|
||||||
|
line.tf = Timeframe.M30
|
||||||
|
|
||||||
|
assert price_in_timeframe_space(line, source_times, Timeframe.M30, 3 * half_hour) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser():
|
||||||
|
half_hour = 30 * MINUTE
|
||||||
|
source_times = [0, half_hour, 2 * half_hour]
|
||||||
|
line = sloped(0, 100.0, 10 * half_hour, 110.0)
|
||||||
|
line.tf = Timeframe.M30
|
||||||
|
|
||||||
|
assert price_in_timeframe_space(
|
||||||
|
line, source_times, Timeframe.M30, 2 * half_hour,
|
||||||
|
) == 102.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_1m_hole_still_advances_one_index_per_minute():
|
||||||
|
times = list(range(0, 10 * MINUTE, MINUTE)) + list(range(19 * MINUTE, 30 * MINUTE, MINUTE))
|
||||||
|
filled = fill_short_gaps(times, Timeframe.M1)
|
||||||
|
assert 10 * MINUTE in filled
|
||||||
|
assert timeframe_index_at(filled, 19 * MINUTE, Timeframe.M1) == 19
|
||||||
|
line = sloped(0, 100.0, 30 * MINUTE, 130.0)
|
||||||
|
assert price_in_timeframe_space(line, times, Timeframe.M1, 19 * MINUTE) == 119.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_weekend_is_not_filled_as_short_gaps():
|
||||||
|
weekend = 49 * 3600
|
||||||
|
times = [0, MINUTE, MINUTE + weekend, MINUTE + weekend + MINUTE]
|
||||||
|
assert fill_short_gaps(times, Timeframe.M1) == times
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled():
|
||||||
|
five = 5 * MINUTE
|
||||||
|
times = [0, five, 3 * five]
|
||||||
|
filled = fill_short_gaps(times, Timeframe.M5)
|
||||||
|
assert filled == [0, five, 2 * five, 3 * five]
|
||||||
|
|
@ -37,3 +37,25 @@ def test_level_ended_before_current_time_is_excluded():
|
||||||
ended = level("ended", 98, 12, Timeframe.D1)
|
ended = level("ended", 98, 12, Timeframe.D1)
|
||||||
ended.cutoff_t = 150
|
ended.cutoff_t = 150
|
||||||
assert cluster_levels([ended], 200, 100, 1) == []
|
assert cluster_levels([ended], 200, 100, 1) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cluster_members_omit_point_history():
|
||||||
|
# Clusters are re-sent on every closed 1m bar. A moving average's point
|
||||||
|
# history is hundreds of entries, so embedding whole levels here shipped the
|
||||||
|
# entire levels payload once a minute.
|
||||||
|
heavy = level("ma", 98, 12, Timeframe.D1)
|
||||||
|
heavy.points = [(t, 1.0) for t in range(600)]
|
||||||
|
|
||||||
|
payload = cluster_levels([heavy], 200, 100, 1)[0].to_dict()
|
||||||
|
|
||||||
|
assert payload["members"] == [
|
||||||
|
{
|
||||||
|
"id": "ma",
|
||||||
|
"kind": "ma",
|
||||||
|
"tf": "1d",
|
||||||
|
"side": "resistance",
|
||||||
|
"weight": 12,
|
||||||
|
"label": "ma",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert "points" not in payload["members"][0]
|
||||||
|
|
|
||||||
197
tests/test_es_options.py
Normal file
197
tests/test_es_options.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.routes import router as api_router
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
from app.market.es_options import (
|
||||||
|
api_symbol,
|
||||||
|
attach_deltas,
|
||||||
|
black76_delta,
|
||||||
|
daily_root,
|
||||||
|
filter_contracts,
|
||||||
|
from_tos_symbol,
|
||||||
|
monthly_root,
|
||||||
|
nearby_expirations,
|
||||||
|
parse_option_quote,
|
||||||
|
search_from_quotes,
|
||||||
|
tos_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekday_roots_match_the_verified_august_week():
|
||||||
|
assert daily_root(date(2026, 8, 14)) == "EW2Q26"
|
||||||
|
assert daily_root(date(2026, 8, 17)) == "E3AQ26"
|
||||||
|
assert daily_root(date(2026, 8, 18)) == "E3BQ26"
|
||||||
|
assert daily_root(date(2026, 8, 19)) == "E3CQ26"
|
||||||
|
assert daily_root(date(2026, 8, 20)) == "E3DQ26"
|
||||||
|
assert daily_root(date(2026, 8, 21)) == "EW3Q26"
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_uses_es_root_only_on_quarterlies():
|
||||||
|
assert monthly_root(date(2026, 8, 21)) == "EW3Q26"
|
||||||
|
assert monthly_root(date(2026, 9, 18)) == "ESU26"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tos_mapping_strips_and_restores_exchange_suffix():
|
||||||
|
assert from_tos_symbol("./E3AQ26P7780:XCME") == "./E3AQ26P7780"
|
||||||
|
assert tos_symbol("./E3AQ26P7780") == "./E3AQ26P7780:XCME"
|
||||||
|
assert api_symbol("E3AQ26", "P", 7780) == "./E3AQ26P7780"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dropdown_for_friday_august_14():
|
||||||
|
rows = nearby_expirations(date(2026, 8, 14))
|
||||||
|
assert [(row.kind, row.date, row.root) for row in rows] == [
|
||||||
|
("daily", date(2026, 8, 14), "EW2Q26"),
|
||||||
|
("daily", date(2026, 8, 17), "E3AQ26"),
|
||||||
|
("daily", date(2026, 8, 18), "E3BQ26"),
|
||||||
|
("weekly", date(2026, 8, 14), "EW2Q26"),
|
||||||
|
("monthly", date(2026, 8, 21), "EW3Q26"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dropdown_skips_the_weekend():
|
||||||
|
rows = nearby_expirations(date(2026, 8, 15))
|
||||||
|
assert [row.date for row in rows if row.kind == "daily"] == [
|
||||||
|
date(2026, 8, 17),
|
||||||
|
date(2026, 8, 18),
|
||||||
|
date(2026, 8, 19),
|
||||||
|
]
|
||||||
|
assert rows[-1].root == "EW3Q26"
|
||||||
|
assert rows[-1].date == date(2026, 8, 21)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_rolls_to_the_september_es_root_after_the_august_third_friday():
|
||||||
|
rows = nearby_expirations(date(2026, 8, 22))
|
||||||
|
monthly = rows[-1]
|
||||||
|
assert monthly.kind == "monthly"
|
||||||
|
assert monthly.date == date(2026, 9, 18)
|
||||||
|
assert monthly.root == "ESU26"
|
||||||
|
|
||||||
|
|
||||||
|
def test_abs_delta_filter_keeps_the_015_to_025_band():
|
||||||
|
rows = [
|
||||||
|
{"strike": 7700, "mark": 3.0, "abs_delta": 0.05},
|
||||||
|
{"strike": 7770, "mark": 6.8, "abs_delta": 0.16},
|
||||||
|
{"strike": 7780, "mark": 8.3, "abs_delta": 0.206},
|
||||||
|
{"strike": 7850, "mark": 22.0, "abs_delta": 0.40},
|
||||||
|
]
|
||||||
|
kept = filter_contracts(rows, "delta", 0.15, 0.25)
|
||||||
|
assert [row["strike"] for row in kept] == [7770, 7780]
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_filter_uses_mark():
|
||||||
|
rows = [
|
||||||
|
{"strike": 7765, "mark": 6.2, "abs_delta": 0.14},
|
||||||
|
{"strike": 7780, "mark": 8.3, "abs_delta": 0.21},
|
||||||
|
{"strike": 7790, "mark": 10.1, "abs_delta": 0.26},
|
||||||
|
]
|
||||||
|
kept = filter_contracts(rows, "price", 6.0, 8.0)
|
||||||
|
assert [row["strike"] for row in kept] == [7765]
|
||||||
|
|
||||||
|
|
||||||
|
def test_black76_put_delta_is_negative_and_increases_toward_atm():
|
||||||
|
otm = black76_delta(7827, 7780, 3 / 365.25, 0.0855, "P")
|
||||||
|
nearer = black76_delta(7827, 7800, 3 / 365.25, 0.0855, "P")
|
||||||
|
assert otm is not None and nearer is not None
|
||||||
|
assert otm < 0 and nearer < 0
|
||||||
|
assert abs(nearer) > abs(otm)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_filters_parsed_quotes_by_mark():
|
||||||
|
quotes = {
|
||||||
|
"./E3AQ26P7765": {
|
||||||
|
"assetMainType": "FUTURE_OPTION",
|
||||||
|
"quote": {"mark": 6.2, "bidPrice": 6.0, "askPrice": 6.2, "totalVolume": 43, "openInterest": 437},
|
||||||
|
"reference": {"strikePrice": 7765, "contractType": "P", "description": "./E3AQ26P7765:XCME"},
|
||||||
|
},
|
||||||
|
"./E3AQ26P7825": {
|
||||||
|
"assetMainType": "FUTURE_OPTION",
|
||||||
|
"quote": {"mark": 22.0, "bidPrice": 21.8, "askPrice": 22.0, "totalVolume": 10, "openInterest": 20},
|
||||||
|
"reference": {"strikePrice": 7825, "contractType": "P", "description": "./E3AQ26P7825:XCME"},
|
||||||
|
},
|
||||||
|
"errors": {"invalidSymbols": ["./E3AQ26P9999"]},
|
||||||
|
}
|
||||||
|
result = search_from_quotes(
|
||||||
|
day=date(2026, 8, 17),
|
||||||
|
side="P",
|
||||||
|
mode="price",
|
||||||
|
low=6,
|
||||||
|
high=8,
|
||||||
|
forward=7827,
|
||||||
|
quotes=quotes,
|
||||||
|
now=datetime(2026, 8, 14, 10, 0),
|
||||||
|
)
|
||||||
|
assert [row["tos"] for row in result["contracts"]] == ["./E3AQ26P7765:XCME"]
|
||||||
|
assert result["contracts"][0]["mark"] == 6.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_equity_payload_is_not_treated_as_a_futures_option():
|
||||||
|
assert parse_option_quote("ES", {"assetMainType": "EQUITY", "quote": {"mark": 72}, "reference": {}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_deltas_labels_the_015_band_from_live_shaped_marks():
|
||||||
|
contracts = [
|
||||||
|
{"strike": 7770.0, "mark": 6.8},
|
||||||
|
{"strike": 7780.0, "mark": 8.3},
|
||||||
|
{"strike": 7825.0, "mark": 22.0},
|
||||||
|
]
|
||||||
|
ranked = attach_deltas(contracts, 7827.0, 3 / 365.25, "P", 0.0855)
|
||||||
|
band = filter_contracts(ranked, "delta", 0.15, 0.25)
|
||||||
|
assert [row["strike"] for row in band] == [7770.0, 7780.0]
|
||||||
|
|
||||||
|
|
||||||
|
def _client(tmp_path):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(api_router)
|
||||||
|
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "lines.json"))
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expirations_endpoint_needs_no_schwab(tmp_path):
|
||||||
|
response = _client(tmp_path).get("/api/es-options/expirations")
|
||||||
|
assert response.status_code == 200
|
||||||
|
rows = response.json()["expirations"]
|
||||||
|
assert len(rows) == 5
|
||||||
|
assert [row["kind"] for row in rows] == ["daily", "daily", "daily", "weekly", "monthly"]
|
||||||
|
assert all(row["root"] and row["date"] and row["label"] for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_endpoint_uses_the_injected_snapshot(tmp_path, monkeypatch):
|
||||||
|
def fake_search(settings, **kwargs):
|
||||||
|
assert kwargs["root"] == "E3AQ26"
|
||||||
|
assert kwargs["side"] == "P"
|
||||||
|
assert kwargs["mode"] == "price"
|
||||||
|
return {
|
||||||
|
"underlying": "/ESU26",
|
||||||
|
"underlying_price": 7827.0,
|
||||||
|
"iv": 0.085,
|
||||||
|
"delta_approx": True,
|
||||||
|
"contracts": [
|
||||||
|
{
|
||||||
|
"symbol": "./E3AQ26P7765",
|
||||||
|
"tos": "./E3AQ26P7765:XCME",
|
||||||
|
"strike": 7765,
|
||||||
|
"mark": 6.2,
|
||||||
|
"abs_delta": 0.139,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.routes.run_search", fake_search)
|
||||||
|
response = _client(tmp_path).get(
|
||||||
|
"/api/es-options/search",
|
||||||
|
params={"date": "2026-08-17", "root": "E3AQ26", "side": "P", "mode": "price", "min": 6, "max": 8},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["contracts"][0]["tos"] == "./E3AQ26P7765:XCME"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_rejects_a_bad_date(tmp_path):
|
||||||
|
response = _client(tmp_path).get(
|
||||||
|
"/api/es-options/search",
|
||||||
|
params={"date": "17-08-2026", "root": "E3AQ26", "min": 0.15, "max": 0.25},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
62
tests/test_event_log.py
Normal file
62
tests/test_event_log.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.analysis.event_log import EventLog
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.api.ws import snapshot
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_keeps_old_entries_and_recent_is_the_last_day(tmp_path):
|
||||||
|
log = EventLog(tmp_path / "events.json")
|
||||||
|
now = int(time.time())
|
||||||
|
log.add("alert", "old", number=1, at=now - 3 * 86400)
|
||||||
|
log.add("alert", "yesterday", number=2, at=now - 12 * 3600)
|
||||||
|
log.add("stream", "dropped", at=now)
|
||||||
|
|
||||||
|
day, more = log.recent()
|
||||||
|
assert [entry["message"] for entry in day] == ["dropped", "yesterday"]
|
||||||
|
assert more is True
|
||||||
|
assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old"
|
||||||
|
assert EventLog(tmp_path / "events.json")._entries[0]["symbol"] == "/ES"
|
||||||
|
|
||||||
|
|
||||||
|
def test_more_pages_older_than_the_cutoff(tmp_path):
|
||||||
|
log = EventLog(tmp_path / "events.json")
|
||||||
|
now = int(time.time())
|
||||||
|
log.add("alert", "old", number=1, at=now - 3 * 86400)
|
||||||
|
log.add("alert", "new", number=2, at=now)
|
||||||
|
page, more = log.page(before=now - 86400, limit=50)
|
||||||
|
assert [entry["message"] for entry in page] == ["old"]
|
||||||
|
assert more is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatched_alerts_land_in_the_event_log(tmp_path):
|
||||||
|
runtime = Runtime(Settings(
|
||||||
|
manual_lines_path=tmp_path / "lines.json",
|
||||||
|
events_path=tmp_path / "events.json",
|
||||||
|
alert_state_path=tmp_path / "alerts.json",
|
||||||
|
))
|
||||||
|
line = Level(
|
||||||
|
"ml_1", LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, 1, 1,
|
||||||
|
"up1h", 100, 100, 0, None, 0, 100, 100, False, False, number=27,
|
||||||
|
)
|
||||||
|
now = int(time.time())
|
||||||
|
alerts = runtime.alert_engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, now, "/ES")
|
||||||
|
|
||||||
|
async def send():
|
||||||
|
runtime.dispatch_alerts(alerts)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(send())
|
||||||
|
events, _ = runtime.events.recent(since=0)
|
||||||
|
assert events[0]["kind"] == "alert"
|
||||||
|
assert "#27" in events[0]["message"]
|
||||||
|
assert "confluence" not in events[0]["message"]
|
||||||
|
assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number
|
||||||
|
assert snapshot(runtime, Timeframe.M1)["events"][0]["symbol"] == "/ES"
|
||||||
|
assert snapshot(runtime, Timeframe.M1)["instrument"]["id"] == "es"
|
||||||
|
assert snapshot(runtime, Timeframe.M1)["instrument"]["tick"] == 0.25
|
||||||
42
tests/test_fibonacci.py
Normal file
42
tests/test_fibonacci.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.analysis.levels import Side
|
||||||
|
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
||||||
|
from app.api.routes import router
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fibonacci_is_not_a_confluence_level(tmp_path):
|
||||||
|
store = ManualLineStore(tmp_path / "lines.json")
|
||||||
|
store.add(ManualLine(
|
||||||
|
id="ml_fib", tf=Timeframe.M15, side=Side.RESISTANCE,
|
||||||
|
anchor_t=1000, anchor_p=6500, slope=-1 / 1800, last_t=2800,
|
||||||
|
created_at=1000, kind="fibonacci", note="swing",
|
||||||
|
))
|
||||||
|
assert store.levels() == []
|
||||||
|
assert store.drawings()[0].drawing_kind == "fibonacci"
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_a_fibonacci_returns_the_drawing_not_a_level(tmp_path):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.state.runtime = Runtime(Settings(
|
||||||
|
manual_lines_path=tmp_path / "lines.json",
|
||||||
|
events_path=tmp_path / "events.json",
|
||||||
|
alert_state_path=tmp_path / "alerts.json",
|
||||||
|
chart_auth_token="",
|
||||||
|
))
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post("/api/lines", json={
|
||||||
|
"tf": "15m", "side": "resistance",
|
||||||
|
"anchor_t": 1000, "anchor_p": 6500, "end_t": 2800, "end_p": 6400,
|
||||||
|
"kind": "fibonacci", "note": "high to low",
|
||||||
|
})
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert body["kind"] == "fibonacci"
|
||||||
|
assert "weight" not in body
|
||||||
|
assert app.state.runtime.manual_lines.levels() == []
|
||||||
99
tests/test_gap_backfill.py
Normal file
99
tests/test_gap_backfill.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.bars.session import bucket_start
|
||||||
|
from app.config import Settings
|
||||||
|
from app.market.stream import StreamService
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def minute(t, price, closed=True, source="schwab"):
|
||||||
|
return Bar(Timeframe.M1, t, price, price + 1, price - 1, price, 10, closed, "/ES", source)
|
||||||
|
|
||||||
|
|
||||||
|
class History:
|
||||||
|
"""A seed source holding only 1m history, like Yahoo's recent reach."""
|
||||||
|
|
||||||
|
name = "yahoo"
|
||||||
|
delay_minutes = 0
|
||||||
|
|
||||||
|
def __init__(self, bars):
|
||||||
|
self.bars = bars
|
||||||
|
|
||||||
|
def supports_history(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def history(self, symbol, tf, start, end, *, range_=None):
|
||||||
|
if tf is not Timeframe.M1:
|
||||||
|
return []
|
||||||
|
return [bar for bar in self.bars if start <= bar.t < end]
|
||||||
|
|
||||||
|
|
||||||
|
def runtime(tmp_path) -> Runtime:
|
||||||
|
return Runtime(
|
||||||
|
Settings(
|
||||||
|
manual_lines_path=tmp_path / "manual_lines.json",
|
||||||
|
alert_state_path=tmp_path / "alert_state.json",
|
||||||
|
user_prefs_path=tmp_path / "user_prefs.json",
|
||||||
|
events_path=tmp_path / "events.json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_outage_is_backfilled_behind_the_live_bars(tmp_path, monkeypatch):
|
||||||
|
# The seed ran only at startup, so a stream that was down for days came
|
||||||
|
# back to live bars with the whole outage still missing.
|
||||||
|
day = bucket_start(int(time.time()) - 86400, Timeframe.D1)
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
missed = [minute(day + 60 * i, 5000 + i, source="yahoo") for i in range(1, 60)]
|
||||||
|
monkeypatch.setattr("app.runtime.seed_source", lambda settings: History(missed))
|
||||||
|
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||||
|
instance.subscribers.add(queue)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
await instance.on_bar(minute(day, 4990))
|
||||||
|
# The stream comes back an hour later, into the same day.
|
||||||
|
await instance.on_bar(minute(day + 3600, 6000))
|
||||||
|
await instance.on_bar(minute(day + 3660, 6001))
|
||||||
|
return await instance.fill_gap(day, day + 3600)
|
||||||
|
|
||||||
|
added = asyncio.run(scenario())
|
||||||
|
|
||||||
|
held = [bar.t for bar in instance.store.get(Timeframe.M1)]
|
||||||
|
assert held == [day] + [bar.t for bar in missed] + [day + 3600, day + 3660]
|
||||||
|
assert added > len(missed), "higher timeframes are rebuilt from the recovered minutes"
|
||||||
|
assert day + 300 in [bar.t for bar in instance.store.get(Timeframe.M5)]
|
||||||
|
daily = instance.store.get(Timeframe.D1)[-1]
|
||||||
|
assert daily.t == day
|
||||||
|
assert daily.l == 4989, "the live daily bar must include the pre-reconnect low"
|
||||||
|
assert daily.o == 4990
|
||||||
|
events = []
|
||||||
|
while not queue.empty():
|
||||||
|
events.append(queue.get_nowait()["type"])
|
||||||
|
assert "resync" in events
|
||||||
|
assert "alert" not in events
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reconnect_past_a_gap_asks_for_a_backfill():
|
||||||
|
class Flaky:
|
||||||
|
name = "schwab"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connections = [[minute(60, 1)], [minute(60 + 86400, 2)]]
|
||||||
|
|
||||||
|
async def stream(self, symbol):
|
||||||
|
for bar in self.connections.pop(0):
|
||||||
|
yield bar
|
||||||
|
if not self.connections:
|
||||||
|
service.stop()
|
||||||
|
raise RuntimeError("socket closed")
|
||||||
|
|
||||||
|
service = StreamService(Flaky(), "/ES")
|
||||||
|
service.reconnect_seconds = 0
|
||||||
|
resumed = []
|
||||||
|
service.on_resume = lambda after, before: resumed.append((after, before))
|
||||||
|
|
||||||
|
asyncio.run(service.run())
|
||||||
|
|
||||||
|
assert resumed == [(60, 60 + 86400)]
|
||||||
40
tests/test_horizontals.py
Normal file
40
tests/test_horizontals.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
from app.analysis.horizontals import build_prior_day_levels
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
def daily(t: int, o: float, h: float, low: float, c: float, closed: bool = True) -> Bar:
|
||||||
|
return Bar(Timeframe.D1, t, o, h, low, c, 1000, closed, "ES=F", "test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prior_day_uses_last_closed_session_not_the_forming_one():
|
||||||
|
bars = [
|
||||||
|
daily(1, 100, 110, 90, 105),
|
||||||
|
daily(2, 105, 120, 100, 118),
|
||||||
|
daily(3, 118, 125, 117, 124, closed=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
levels = {level.id: level for level in build_prior_day_levels(bars, current_price=119)}
|
||||||
|
|
||||||
|
# The forming session's 125 high must not become "prior day high" mid-session.
|
||||||
|
assert levels["pd:high"].anchor_p == 120
|
||||||
|
assert levels["pd:low"].anchor_p == 100
|
||||||
|
assert levels["pd:close"].anchor_p == 118
|
||||||
|
|
||||||
|
|
||||||
|
def test_side_is_positional_against_current_price():
|
||||||
|
bars = [daily(1, 100, 110, 90, 105)]
|
||||||
|
|
||||||
|
levels = {level.id: level for level in build_prior_day_levels(bars, current_price=100)}
|
||||||
|
|
||||||
|
assert levels["pd:high"].side.value == "resistance"
|
||||||
|
assert levels["pd:low"].side.value == "support"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prior_day_carries_full_daily_weight_not_the_average_discount():
|
||||||
|
levels = build_prior_day_levels([daily(1, 100, 110, 90, 105)], current_price=100)
|
||||||
|
# Traded structure, not a derived average, so no 0.75 factor.
|
||||||
|
assert all(level.weight == 16 for level in levels)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_closed_session_yields_nothing():
|
||||||
|
assert build_prior_day_levels([daily(1, 100, 110, 90, 105, closed=False)], 100) == []
|
||||||
60
tests/test_instrument.py
Normal file
60
tests/test_instrument.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
from app.config import Settings
|
||||||
|
from app.instrument import (
|
||||||
|
DEFAULT_SYMBOL, get_instrument, instrument_for_symbol, INSTRUMENTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_default_to_the_es_profile():
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.instrument == "es"
|
||||||
|
assert settings.profile.tick == 0.25
|
||||||
|
assert Settings(instrument="gc").profile.schwab_symbol == "/GC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_es_is_the_default_profile():
|
||||||
|
es = get_instrument("es")
|
||||||
|
assert es.schwab_symbol == DEFAULT_SYMBOL
|
||||||
|
assert es.tick == 0.25
|
||||||
|
assert es.rth == "spy_rth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nq_shares_es_tick_and_rth():
|
||||||
|
nq = get_instrument("nq")
|
||||||
|
es = get_instrument("es")
|
||||||
|
assert nq.tick == es.tick
|
||||||
|
assert nq.rth == es.rth
|
||||||
|
assert nq.schwab_symbol == "/NQ"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gold_and_oil_are_not_quarter_ticks():
|
||||||
|
assert get_instrument("gc").tick == 0.10
|
||||||
|
assert get_instrument("gc").rth == "none"
|
||||||
|
assert get_instrument("cl").tick == 0.01
|
||||||
|
assert get_instrument("cl").rth == "nymex_day"
|
||||||
|
|
||||||
|
|
||||||
|
def test_yahoo_and_schwab_names_map_to_the_same_root():
|
||||||
|
assert instrument_for_symbol("ES=F").schwab_symbol == "/ES"
|
||||||
|
assert instrument_for_symbol("/ES").id == "es"
|
||||||
|
assert instrument_for_symbol("GC=F").id == "gc"
|
||||||
|
assert instrument_for_symbol("unknown").id == "es"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_instrument_id_is_rejected():
|
||||||
|
try:
|
||||||
|
get_instrument("btc")
|
||||||
|
except ValueError as error:
|
||||||
|
assert "btc" in str(error)
|
||||||
|
else:
|
||||||
|
raise AssertionError("unknown id was accepted")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gold_snap_is_not_a_quarter_point():
|
||||||
|
gc = get_instrument("gc")
|
||||||
|
assert gc.snap(3450.07) == 3450.10
|
||||||
|
assert get_instrument("es").snap(6400.10) == 6400.00
|
||||||
|
assert get_instrument("cl").snap(70.014) == 70.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_planned_root_is_in_the_table():
|
||||||
|
assert set(INSTRUMENTS) == {"es", "nq", "gc", "cl"}
|
||||||
|
|
@ -6,7 +6,26 @@ from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
||||||
def sample_line():
|
def sample_line():
|
||||||
return ManualLine("ml_test", Timeframe.H4, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
|
return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_drawing_without_symbol_loads_as_es(tmp_path):
|
||||||
|
path = tmp_path / "manual_lines.json"
|
||||||
|
path.write_text(
|
||||||
|
'[{"id":"ml_old","tf":"1h","side":"resistance","anchor_t":100,'
|
||||||
|
'"anchor_p":5000,"slope":-0.01,"last_t":200,"created_at":300,"number":1}]\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
loaded = ManualLineStore(path).lines["ml_old"]
|
||||||
|
assert loaded.symbol == "/ES"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_gold_drawing_keeps_its_symbol_through_json(tmp_path):
|
||||||
|
path = tmp_path / "manual_lines.json"
|
||||||
|
line = sample_line()
|
||||||
|
line.symbol = "/GC"
|
||||||
|
ManualLineStore(path).add(line)
|
||||||
|
assert ManualLineStore(path).lines["ml_test"].symbol == "/GC"
|
||||||
|
|
||||||
|
|
||||||
def test_json_persistence_round_trip(tmp_path):
|
def test_json_persistence_round_trip(tmp_path):
|
||||||
|
|
@ -23,12 +42,34 @@ def test_json_persistence_round_trip(tmp_path):
|
||||||
assert ManualLineStore(path).lines == {}
|
assert ManualLineStore(path).lines == {}
|
||||||
|
|
||||||
|
|
||||||
def test_four_hour_line_uses_absolute_time_on_one_minute_chart():
|
def test_price_level_alert_offset_survives_json_round_trip(tmp_path):
|
||||||
|
path = tmp_path / "manual_lines.json"
|
||||||
|
line = sample_line()
|
||||||
|
line.slope = 0.0
|
||||||
|
line.alert_early_points = 1.25
|
||||||
|
ManualLineStore(path).add(line)
|
||||||
|
|
||||||
|
loaded = ManualLineStore(path).lines["ml_test"]
|
||||||
|
assert loaded.alert_early_points == 1.25
|
||||||
|
assert loaded.to_level().alert_early_points == 1.25
|
||||||
|
|
||||||
|
|
||||||
|
def test_hourly_line_uses_absolute_time_on_one_minute_chart():
|
||||||
level = sample_line().to_level()
|
level = sample_line().to_level()
|
||||||
instant = 160
|
instant = 160
|
||||||
assert level.tf is Timeframe.H4
|
assert level.tf is Timeframe.H1
|
||||||
assert level.price_at(instant) == 4999.4
|
assert level.price_at(instant) == 4999.4
|
||||||
assert level.weight == 8
|
assert level.weight == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_unnamed_trendlines_are_named_for_their_direction():
|
||||||
|
resistance = sample_line()
|
||||||
|
support = ManualLine(
|
||||||
|
"ml_support", Timeframe.M1, Side.SUPPORT, 100, 5000, 0.01, 200, 300
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resistance.default_label() == "down1h"
|
||||||
|
assert support.default_label() == "up1m"
|
||||||
|
|
||||||
|
|
||||||
def test_manual_line_raises_existing_ma_cluster_score():
|
def test_manual_line_raises_existing_ma_cluster_score():
|
||||||
|
|
@ -39,4 +80,179 @@ def test_manual_line_raises_existing_ma_cluster_score():
|
||||||
before = cluster_levels([ma], 160, 4998, 2)[0]
|
before = cluster_levels([ma], 160, 4998, 2)[0]
|
||||||
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
|
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
|
||||||
assert before.score == 12
|
assert before.score == 12
|
||||||
assert after.score == 20
|
assert after.score == 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_annotation_is_never_a_level(tmp_path):
|
||||||
|
# Comments live with the lines so they share numbering, filtering and
|
||||||
|
# deletion — but a comment reaching levels() would join a confluence
|
||||||
|
# cluster and fire a push notification about a piece of text.
|
||||||
|
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
||||||
|
from app.analysis.levels import Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
store = ManualLineStore(tmp_path / "lines.json")
|
||||||
|
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
||||||
|
anchor_t=1000, last_t=2000, created_at=1000)
|
||||||
|
store.add(ManualLine(id="ml_level", slope=0.0, **common))
|
||||||
|
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
|
||||||
|
note="watch this", **common))
|
||||||
|
store.add(ManualLine(id="ml_symbol", slope=0.0, kind="symbol",
|
||||||
|
icon="skull", note="Skull", **common))
|
||||||
|
store.add(ManualLine(id="ml_fib", slope=0.01, kind="fibonacci",
|
||||||
|
note="fib", **common))
|
||||||
|
|
||||||
|
assert [level.id for level in store.levels()] == ["ml_level"]
|
||||||
|
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note", "ml_symbol", "ml_fib"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):
|
||||||
|
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
||||||
|
from app.analysis.levels import Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
store = ManualLineStore(tmp_path / "lines.json")
|
||||||
|
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
||||||
|
anchor_t=1000, last_t=2000, created_at=1000)
|
||||||
|
flat = store.add(ManualLine(id="ml_flat", slope=0.0, **common))
|
||||||
|
sloped = store.add(ManualLine(id="ml_sloped", slope=0.5, **common))
|
||||||
|
|
||||||
|
assert flat.drawing_kind == "level"
|
||||||
|
assert sloped.drawing_kind == "trendline"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mark_scale_survives_json_and_does_not_move_its_anchor(tmp_path):
|
||||||
|
path = tmp_path / "lines.json"
|
||||||
|
store = ManualLineStore(path)
|
||||||
|
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
||||||
|
anchor_t=1000, last_t=2000, created_at=1000)
|
||||||
|
store.add(ManualLine(id="ml_mark", slope=0.0, kind="symbol",
|
||||||
|
icon="skull", note="Skull", scale=2.0, **common))
|
||||||
|
raw = ManualLine(
|
||||||
|
id="ml_old", slope=0.0, kind="symbol", icon="play", note="Go", **common,
|
||||||
|
).to_dict()
|
||||||
|
del raw["scale"]
|
||||||
|
store.add(ManualLine.from_dict(raw))
|
||||||
|
|
||||||
|
loaded = ManualLineStore(path)
|
||||||
|
mark = loaded.lines["ml_mark"]
|
||||||
|
old = loaded.lines["ml_old"]
|
||||||
|
assert mark.scale == 2.0
|
||||||
|
assert (mark.anchor_t, mark.anchor_p) == (1000, 100.0)
|
||||||
|
assert old.scale == 1.0
|
||||||
|
assert (old.anchor_t, old.anchor_p) == (1000, 100.0)
|
||||||
|
assert [level.id for level in loaded.levels()] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_drawing_gets_a_number_including_comments(tmp_path):
|
||||||
|
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
||||||
|
from app.analysis.levels import Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
store = ManualLineStore(tmp_path / "lines.json")
|
||||||
|
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
||||||
|
anchor_t=1000, last_t=2000, created_at=1000)
|
||||||
|
first = store.add(ManualLine(id="ml_a", slope=0.0, **common))
|
||||||
|
note = store.add(ManualLine(id="ml_b", slope=0.0, kind="comment", **common))
|
||||||
|
third = store.add(ManualLine(id="ml_c", slope=1.0, **common))
|
||||||
|
|
||||||
|
assert [first.number, note.number, third.number] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_null_cutoff_clears_an_ended_line(tmp_path):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.routes import router
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
||||||
|
client = TestClient(app)
|
||||||
|
created = client.post("/api/lines", json={
|
||||||
|
"tf": "1m", "side": "support",
|
||||||
|
"anchor_t": 100, "anchor_p": 1.0,
|
||||||
|
"end_t": 200, "end_p": 2.0,
|
||||||
|
"cutoff_t": 150,
|
||||||
|
}).json()
|
||||||
|
assert created["cutoff_t"] == 150
|
||||||
|
assert created["symbol"] == "/ES"
|
||||||
|
|
||||||
|
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
|
||||||
|
assert cleared["cutoff_t"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.routes import router
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
||||||
|
client = TestClient(app)
|
||||||
|
created = client.post("/api/lines", json={
|
||||||
|
"tf": "1m", "side": "support",
|
||||||
|
"anchor_t": 100, "anchor_p": 1.0,
|
||||||
|
"end_t": 200, "end_p": 2.0,
|
||||||
|
"note": "keep me",
|
||||||
|
}).json()
|
||||||
|
line_id, number = created["id"], created["number"]
|
||||||
|
assert client.delete(f"/api/lines/{line_id}").status_code == 204
|
||||||
|
|
||||||
|
restored = client.post("/api/lines/restore", json={
|
||||||
|
"id": line_id,
|
||||||
|
"tf": "1m",
|
||||||
|
"side": "support",
|
||||||
|
"anchor_t": 100,
|
||||||
|
"anchor_p": 1.0,
|
||||||
|
"slope": 0.01,
|
||||||
|
"last_t": 200,
|
||||||
|
"note": "keep me",
|
||||||
|
"number": number,
|
||||||
|
}).json()
|
||||||
|
assert restored["id"] == line_id
|
||||||
|
assert restored["number"] == number
|
||||||
|
assert restored["symbol"] == "/ES"
|
||||||
|
assert client.post("/api/lines/restore", json={
|
||||||
|
"id": line_id,
|
||||||
|
"tf": "1m",
|
||||||
|
"side": "support",
|
||||||
|
"anchor_t": 100,
|
||||||
|
"anchor_p": 1.0,
|
||||||
|
"slope": 0.01,
|
||||||
|
"last_t": 200,
|
||||||
|
"number": number,
|
||||||
|
}).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_restoring_keeps_a_non_es_symbol(tmp_path):
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.routes import router
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
||||||
|
client = TestClient(app)
|
||||||
|
restored = client.post("/api/lines/restore", json={
|
||||||
|
"id": "ml_gold",
|
||||||
|
"tf": "1m",
|
||||||
|
"side": "support",
|
||||||
|
"anchor_t": 100,
|
||||||
|
"anchor_p": 1.0,
|
||||||
|
"slope": 0.01,
|
||||||
|
"last_t": 200,
|
||||||
|
"number": 9,
|
||||||
|
"symbol": "/GC",
|
||||||
|
}).json()
|
||||||
|
assert restored["symbol"] == "/GC"
|
||||||
|
assert ManualLineStore(tmp_path / "manual_lines.json").lines["ml_gold"].symbol == "/GC"
|
||||||
|
|
|
||||||
68
tests/test_price_alerts.py
Normal file
68
tests/test_price_alerts.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.levels import Side
|
||||||
|
from app.analysis.manual_lines import ManualLine
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
def price_alert(price: float, note: str = "", **changes) -> ManualLine:
|
||||||
|
return ManualLine(
|
||||||
|
"ml_price", Timeframe.D1, Side.RESISTANCE, 1000, price, 0.0, 4600, 1000,
|
||||||
|
note=note, **changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_typed_level_holds_its_price_at_any_time():
|
||||||
|
level = price_alert(7800).to_level()
|
||||||
|
assert level.price_at(0) == 7800
|
||||||
|
assert level.price_at(10**9) == 7800
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlabelled_alert_is_named_by_its_price():
|
||||||
|
# "1d resistance" tells you nothing about which alert fired.
|
||||||
|
assert price_alert(7800).to_level().label == "@ 7800.00"
|
||||||
|
assert price_alert(7800, note="gap fill").to_level().label == "gap fill"
|
||||||
|
|
||||||
|
|
||||||
|
def test_typed_level_alerts_regardless_of_confluence_score():
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
clusters = cluster_levels([price_alert(7800, "gap fill").to_level()], 5000, 7800, 1)
|
||||||
|
|
||||||
|
assert len(clusters) == 1
|
||||||
|
alerts = engine.evaluate(clusters, 7800, 1, 5000, "/ES")
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert "gap fill" in alerts[0].message
|
||||||
|
|
||||||
|
|
||||||
|
def test_resistance_level_can_alert_a_fixed_distance_early():
|
||||||
|
engine = AlertEngine(min_score=28)
|
||||||
|
level = price_alert(100, alert_early_points=2).to_level()
|
||||||
|
|
||||||
|
too_early = cluster_levels([level], 5000, 97.75, 1)
|
||||||
|
in_window = cluster_levels([level], 5000, 98, 1)
|
||||||
|
assert engine.evaluate(too_early, 97.75, 1, 5000, "/ES") == []
|
||||||
|
assert len(engine.evaluate(in_window, 98, 1, 5001, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_support_level_can_alert_a_fixed_distance_early():
|
||||||
|
engine = AlertEngine(min_score=28)
|
||||||
|
line = price_alert(100, alert_early_points=2)
|
||||||
|
line.side = Side.SUPPORT
|
||||||
|
level = line.to_level()
|
||||||
|
|
||||||
|
too_early = cluster_levels([level], 5000, 102.25, 1)
|
||||||
|
in_window = cluster_levels([level], 5000, 102, 1)
|
||||||
|
assert engine.evaluate(too_early, 102.25, 1, 5000, "/ES") == []
|
||||||
|
assert len(engine.evaluate(in_window, 102, 1, 5001, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_price_alerts_survive_the_json_round_trip(tmp_path):
|
||||||
|
from app.analysis.manual_lines import ManualLineStore
|
||||||
|
|
||||||
|
store = ManualLineStore(tmp_path / "manual_lines.json")
|
||||||
|
store.add(price_alert(7800, "gap fill"))
|
||||||
|
|
||||||
|
reloaded = ManualLineStore(tmp_path / "manual_lines.json").lines["ml_price"]
|
||||||
|
assert reloaded.slope == 0.0
|
||||||
|
assert reloaded.horizontal
|
||||||
|
assert reloaded.anchor_p == 7800
|
||||||
275
tests/test_runtime_alerts.py
Normal file
275
tests/test_runtime_alerts.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.analysis.alerts import Alert
|
||||||
|
from app.analysis.confluence import Cluster, cluster_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.analysis.manual_lines import ManualLine
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def runtime(tmp_path, **overrides) -> Runtime:
|
||||||
|
settings = Settings(
|
||||||
|
manual_lines_path=tmp_path / "manual_lines.json",
|
||||||
|
# Isolated per test: the default is relative to the working directory,
|
||||||
|
# so without this every test shares one alert-suppression file and they
|
||||||
|
# silence each other.
|
||||||
|
alert_state_path=tmp_path / "alert_state.json",
|
||||||
|
user_prefs_path=tmp_path / "user_prefs.json",
|
||||||
|
ntfy_topic=overrides.pop("ntfy_topic", ""),
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
return Runtime(settings)
|
||||||
|
|
||||||
|
|
||||||
|
def alert() -> Alert:
|
||||||
|
level = Level(
|
||||||
|
"pd:high", LevelKind.HORIZONTAL, Timeframe.D1, Side.RESISTANCE, 16, 1, "PDH",
|
||||||
|
100, 5000, 0, None, 0, 100, 100, False, False,
|
||||||
|
)
|
||||||
|
cluster = Cluster("cl_x", Side.RESISTANCE, 5000, 5000, 5000, 16, [level], 1.0)
|
||||||
|
return Alert(cluster, "BEARISH ZONE /ES")
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_engine_serves_every_connection(tmp_path):
|
||||||
|
# Previously each WebSocket built its own engine, so reloading the page
|
||||||
|
# cleared the cooldown and the same zone alerted again immediately.
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
assert instance.alert_engine is not None
|
||||||
|
assert instance.alert_engine.min_score == instance.settings.confluence_min_score
|
||||||
|
|
||||||
|
|
||||||
|
def test_alerts_reach_subscribers(tmp_path):
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
queue: asyncio.Queue = asyncio.Queue(maxsize=10)
|
||||||
|
instance.subscribers.add(queue)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
instance.dispatch_alerts([alert()])
|
||||||
|
return queue.get_nowait()
|
||||||
|
|
||||||
|
event = asyncio.run(scenario())
|
||||||
|
assert event["type"] == "alert"
|
||||||
|
assert event["message"] == "BEARISH ZONE /ES"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ntfy_failure_does_not_propagate(tmp_path, monkeypatch):
|
||||||
|
# send_ntfy used to be awaited inside the WebSocket loop, whose except
|
||||||
|
# clause only caught disconnects — so a push outage killed the connection.
|
||||||
|
instance = runtime(tmp_path, ntfy_topic="chart-test")
|
||||||
|
|
||||||
|
async def explode(*args, **kwargs):
|
||||||
|
raise RuntimeError("ntfy is down")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.runtime.send_ntfy", explode)
|
||||||
|
asyncio.run(instance.notify("anything"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_topic_sends_nothing(tmp_path, monkeypatch):
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def record(server, topic, message):
|
||||||
|
calls.append(topic)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.runtime.send_ntfy", record)
|
||||||
|
asyncio.run(instance.notify("anything"))
|
||||||
|
assert calls == [""] # send_ntfy itself is the one that short-circuits
|
||||||
|
|
||||||
|
|
||||||
|
def test_tick_bars_update_higher_timeframes_without_doubling_volume(tmp_path):
|
||||||
|
# The aggregator accumulates with `current.v += incoming.v`, so a forming
|
||||||
|
# minute re-sent on every tick would add its volume to each higher
|
||||||
|
# timeframe again and again. The provisional path must combine, not
|
||||||
|
# accumulate: five ticks in one minute leave the hour's volume equal to the
|
||||||
|
# closed minutes plus the live one, exactly once.
|
||||||
|
from app.bars.models import Bar
|
||||||
|
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
|
||||||
|
def minute(t, close, volume, closed=True):
|
||||||
|
return Bar(tf=Timeframe.M1, t=t, o=close, h=close, l=close, c=close,
|
||||||
|
v=volume, closed=closed, symbol="/ES", source="test")
|
||||||
|
|
||||||
|
base = 1786356000 # top of an hour
|
||||||
|
asyncio.run(instance.on_bar(minute(base, 100.0, 10)))
|
||||||
|
asyncio.run(instance.on_bar(minute(base + 60, 101.0, 20)))
|
||||||
|
settled = [b for b in instance.store.get(Timeframe.H1) if b.t == base][-1].v
|
||||||
|
assert settled == 30
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
asyncio.run(instance.on_bar(minute(base + 120, 102.0, 7, closed=False)))
|
||||||
|
|
||||||
|
hour = [b for b in instance.store.get(Timeframe.H1) if b.t == base][-1]
|
||||||
|
assert hour.v == 37, "the live minute's volume must be added once, not per tick"
|
||||||
|
assert hour.c == 102.0
|
||||||
|
assert hour.closed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
|
||||||
|
tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
from app.bars.models import Bar
|
||||||
|
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
base = 1786356000
|
||||||
|
instance.manual_lines.add(ManualLine(
|
||||||
|
"ml_slope", Timeframe.M1, Side.SUPPORT,
|
||||||
|
base, 100.0, 1 / 60, base + 60, base,
|
||||||
|
))
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
|
||||||
|
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
|
||||||
|
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
|
||||||
|
|
||||||
|
def minute(t, close, closed=True):
|
||||||
|
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
|
||||||
|
|
||||||
|
instance.stream.last_bar_t = base
|
||||||
|
asyncio.run(instance.on_bar(minute(base, 100.0)))
|
||||||
|
instance.rebuild_levels()
|
||||||
|
instance.stream.last_bar_t = base + 120
|
||||||
|
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
|
||||||
|
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
|
||||||
|
|
||||||
|
assert seen["at"] == base + 60
|
||||||
|
assert seen["price"] == 101.0
|
||||||
|
assert 101.0 in seen["centers"]
|
||||||
|
assert 102.0 not in seen["centers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
|
||||||
|
tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
from app.bars.models import Bar
|
||||||
|
|
||||||
|
instance = runtime(tmp_path, max_bars_per_tf=2)
|
||||||
|
base = 1786356000
|
||||||
|
instance.manual_lines.add(ManualLine(
|
||||||
|
"ml_evicted", Timeframe.M1, Side.SUPPORT,
|
||||||
|
base, 100.0, 1 / 60, base + 60, base,
|
||||||
|
))
|
||||||
|
seen_members = []
|
||||||
|
|
||||||
|
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
|
||||||
|
seen_members.extend(member.id for cluster in clusters for member in cluster.members)
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
|
||||||
|
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
|
||||||
|
|
||||||
|
def minute(t, close, closed=True):
|
||||||
|
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
|
||||||
|
|
||||||
|
instance.stream.last_bar_t = base + 60
|
||||||
|
asyncio.run(instance.on_bar(minute(base, 100.0)))
|
||||||
|
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
|
||||||
|
instance.rebuild_levels()
|
||||||
|
assert next(level for level in instance.levels if level.id == "ml_evicted").geometry_resolved
|
||||||
|
seen_members.clear()
|
||||||
|
|
||||||
|
instance.stream.last_bar_t = base + 120
|
||||||
|
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
|
||||||
|
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
|
||||||
|
|
||||||
|
assert "ml_evicted" not in seen_members
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tripped_manual_alert_stays_disarmed_after_rebuild_and_restart(tmp_path):
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
instance.manual_lines.add(
|
||||||
|
ManualLine(
|
||||||
|
"ml_once", Timeframe.D1, Side.RESISTANCE, 1000, 5000, 0.0,
|
||||||
|
1000, 1000, note="one shot",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance.rebuild_levels()
|
||||||
|
clusters = cluster_levels(instance.levels, 1000, 5000, 1)
|
||||||
|
alerts = instance.alert_engine.evaluate(clusters, 5000, 1, 1000, "/ES")
|
||||||
|
|
||||||
|
async def dispatch():
|
||||||
|
instance.dispatch_alerts(alerts)
|
||||||
|
await asyncio.gather(*instance._notify_tasks)
|
||||||
|
|
||||||
|
asyncio.run(dispatch())
|
||||||
|
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0].tripped == ("ml_once",)
|
||||||
|
assert instance.manual_lines.lines["ml_once"].armed is False
|
||||||
|
assert next(level for level in instance.levels if level.id == "ml_once").armed is False
|
||||||
|
|
||||||
|
restarted = runtime(tmp_path)
|
||||||
|
assert restarted.manual_lines.lines["ml_once"].armed is False
|
||||||
|
assert next(level for level in restarted.levels if level.id == "ml_once").armed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broadcast_from_a_worker_thread_reaches_subscribers(tmp_path):
|
||||||
|
# The mutating routes are sync `def`, so FastAPI runs them in a threadpool,
|
||||||
|
# and they reach broadcast through rebuild_levels. asyncio.Queue is not
|
||||||
|
# thread-safe — it wakes a consumer by resolving a Future, which only the
|
||||||
|
# loop thread may do — so writing it from there can drop the wakeup and
|
||||||
|
# leave one browser's drawing invisible to another until the next tick.
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
instance._loop = asyncio.get_running_loop()
|
||||||
|
queue: asyncio.Queue = asyncio.Queue(maxsize=10)
|
||||||
|
instance.subscribers.add(queue)
|
||||||
|
waiting = asyncio.ensure_future(queue.get())
|
||||||
|
await asyncio.sleep(0) # park the consumer on the Future
|
||||||
|
await asyncio.to_thread(instance.broadcast, {"type": "bar", "bar": "sentinel"})
|
||||||
|
return await asyncio.wait_for(waiting, timeout=2)
|
||||||
|
|
||||||
|
assert asyncio.run(exercise())["bar"] == "sentinel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cross_thread_broadcast_is_posted_through_the_loop(tmp_path):
|
||||||
|
"""The contract, asserted directly.
|
||||||
|
|
||||||
|
The race itself is timing-dependent and usually masked — a foreign-thread
|
||||||
|
put_nowait often lands in the loop's ready queue before it sleeps, and the
|
||||||
|
stream ticking once a second papers over the times it does not. So this
|
||||||
|
asserts the rule rather than trying to provoke the failure: a broadcast from
|
||||||
|
off the loop must go through call_soon_threadsafe, never touch the queue.
|
||||||
|
"""
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
instance._loop = loop
|
||||||
|
posted = []
|
||||||
|
original = loop.call_soon_threadsafe
|
||||||
|
|
||||||
|
def spy(callback, *args):
|
||||||
|
posted.append(callback)
|
||||||
|
return original(callback, *args)
|
||||||
|
|
||||||
|
loop.call_soon_threadsafe = spy
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(instance.broadcast, {"type": "bar", "bar": "x"})
|
||||||
|
finally:
|
||||||
|
loop.call_soon_threadsafe = original
|
||||||
|
return posted
|
||||||
|
|
||||||
|
assert asyncio.run(exercise()), "a worker-thread broadcast bypassed the loop"
|
||||||
|
|
||||||
|
|
||||||
|
def test_broadcasting_on_the_loop_still_delivers_synchronously(tmp_path):
|
||||||
|
# The stream's own path must not pay for a hop it does not need.
|
||||||
|
instance = runtime(tmp_path)
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
instance._loop = asyncio.get_running_loop()
|
||||||
|
queue: asyncio.Queue = asyncio.Queue(maxsize=10)
|
||||||
|
instance.subscribers.add(queue)
|
||||||
|
instance.broadcast({"type": "bar", "bar": "direct"})
|
||||||
|
return queue.get_nowait() # already there, no await needed
|
||||||
|
|
||||||
|
assert asyncio.run(exercise())["bar"] == "direct"
|
||||||
30
tests/test_runtime_start.py
Normal file
30
tests/test_runtime_start.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_seeds_native_thirty_minute_history(tmp_path, monkeypatch):
|
||||||
|
instance = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
||||||
|
source = object()
|
||||||
|
monkeypatch.setattr("app.runtime.seed_source", lambda settings: source)
|
||||||
|
instance.stream.seed = AsyncMock()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return None
|
||||||
|
|
||||||
|
instance.stream.run = run
|
||||||
|
|
||||||
|
async def start():
|
||||||
|
task = await instance.start()
|
||||||
|
await task
|
||||||
|
|
||||||
|
asyncio.run(start())
|
||||||
|
|
||||||
|
assert [call.args[1:4] for call in instance.stream.seed.await_args_list] == [
|
||||||
|
(Timeframe.H1, "730d", "ES=F"),
|
||||||
|
(Timeframe.M30, "60d", "ES=F"),
|
||||||
|
(Timeframe.M1, "8d", "ES=F"),
|
||||||
|
]
|
||||||
64
tests/test_schwab_callback.py
Normal file
64
tests/test_schwab_callback.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.schwab_auth import router
|
||||||
|
|
||||||
|
|
||||||
|
def client() -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_needs_no_chart_token():
|
||||||
|
# Schwab redirects a browser here and cannot attach the token, so this
|
||||||
|
# endpoint has to stay open the way /health and /version do.
|
||||||
|
assert client().get("/api/qt").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_does_not_name_the_brokerage():
|
||||||
|
# The path is neutral so the host does not advertise who it trades with;
|
||||||
|
# the page saying it anyway would defeat that.
|
||||||
|
assert "chwab" not in client().get("/api/qt").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_landing_here_directly_explains_itself():
|
||||||
|
body = client().get("/api/qt").text
|
||||||
|
assert "Register this exact URL" in body
|
||||||
|
assert "code" not in body.split("<style>")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorisation_code_is_echoed_for_the_manual_flow():
|
||||||
|
response = client().get("/api/qt", params={"code": "abc123", "session": "s"})
|
||||||
|
assert "abc123" in response.text
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_code_is_not_retained_for_a_later_visitor():
|
||||||
|
session = client()
|
||||||
|
session.get("/api/qt", params={"code": "secret-code"})
|
||||||
|
# A second, code-less request must not replay the first one's code.
|
||||||
|
assert "secret-code" not in session.get("/api/qt").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pending_login_exchanges_the_code_and_returns_home(monkeypatch):
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
runtime = Runtime(Settings())
|
||||||
|
runtime.schwab_login = object()
|
||||||
|
finished = []
|
||||||
|
|
||||||
|
def finish(url):
|
||||||
|
finished.append(url)
|
||||||
|
|
||||||
|
runtime.finish_schwab_login = finish
|
||||||
|
app.state.runtime = runtime
|
||||||
|
response = TestClient(app, follow_redirects=False).get(
|
||||||
|
"/api/qt", params={"code": "abc", "state": "s"}
|
||||||
|
)
|
||||||
|
assert response.status_code in (302, 303, 307)
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
assert finished and "code=abc" in finished[0]
|
||||||
331
tests/test_schwab_source.py
Normal file
331
tests/test_schwab_source.py
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import Settings
|
||||||
|
from app.market.factory import live_source, seed_source
|
||||||
|
from app.market.schwab import SchwabSource, parse_chart_futures, parse_level_one
|
||||||
|
|
||||||
|
# Shape taken from a live CHART_FUTURES message, not invented.
|
||||||
|
LIVE_MESSAGE = {
|
||||||
|
"service": "CHART_FUTURES",
|
||||||
|
"timestamp": 1786356976793,
|
||||||
|
"command": "SUBS",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"seq": 50,
|
||||||
|
"key": "/ES",
|
||||||
|
"CHART_TIME_MILLIS": 1786356900000,
|
||||||
|
"OPEN_PRICE": 7786.75,
|
||||||
|
"HIGH_PRICE": 7787,
|
||||||
|
"LOW_PRICE": 7786.5,
|
||||||
|
"CLOSE_PRICE": 7787,
|
||||||
|
"VOLUME": 107,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_a_live_chart_futures_message():
|
||||||
|
bar = parse_chart_futures(LIVE_MESSAGE, "/ES")[0]
|
||||||
|
|
||||||
|
assert bar.tf is Timeframe.M1
|
||||||
|
assert bar.t == 1786356900 # milliseconds down to seconds
|
||||||
|
assert (bar.o, bar.h, bar.l, bar.c) == (7786.75, 7787.0, 7786.5, 7787.0)
|
||||||
|
assert bar.v == 107
|
||||||
|
assert bar.symbol == "/ES"
|
||||||
|
assert bar.source == "schwab"
|
||||||
|
# The minute has elapsed by the time the message arrives.
|
||||||
|
assert bar.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_content_is_skipped_not_defaulted():
|
||||||
|
# A bar invented from partial data is indistinguishable downstream from a
|
||||||
|
# real one, which is worse than having no bar.
|
||||||
|
for missing in ("CHART_TIME_MILLIS", "OPEN_PRICE", "CLOSE_PRICE"):
|
||||||
|
content = dict(LIVE_MESSAGE["content"][0])
|
||||||
|
del content[missing]
|
||||||
|
assert parse_chart_futures({"content": [content]}, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_schwab_offers_no_history():
|
||||||
|
source = SchwabSource(Settings())
|
||||||
|
assert source.supports_history() is False
|
||||||
|
assert source.supports_stream() is True
|
||||||
|
assert asyncio.run(source.history("/ES", Timeframe.M1, None, None)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeding_stays_on_yahoo_even_when_live_is_schwab(tmp_path):
|
||||||
|
# Schwab has no history, so the pairing is the intended configuration
|
||||||
|
# rather than a fallback.
|
||||||
|
settings = Settings(
|
||||||
|
live_source="schwab",
|
||||||
|
seed_source="schwab",
|
||||||
|
manual_lines_path=tmp_path / "lines.json",
|
||||||
|
)
|
||||||
|
assert seed_source(settings).name == "yahoo"
|
||||||
|
assert live_source(settings).name == "schwab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_symbol_follows_the_live_source():
|
||||||
|
# Yahoo says ES=F where Schwab says /ES; seeding always uses the Yahoo one.
|
||||||
|
assert Settings(live_source="yahoo").live_symbol == "ES=F"
|
||||||
|
assert Settings(live_source="schwab").live_symbol == "/ES"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_yields_bars_from_the_socket():
|
||||||
|
class FakeStreamClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.handler = None
|
||||||
|
self.subscribed = []
|
||||||
|
self.quote_handler = None
|
||||||
|
self.quote_subscribed = []
|
||||||
|
|
||||||
|
async def login(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_chart_futures_handler(self, handler):
|
||||||
|
self.handler = handler
|
||||||
|
|
||||||
|
async def chart_futures_subs(self, symbols):
|
||||||
|
self.subscribed = list(symbols)
|
||||||
|
|
||||||
|
def add_level_one_futures_handler(self, handler):
|
||||||
|
self.quote_handler = handler
|
||||||
|
|
||||||
|
async def level_one_futures_subs(self, symbols):
|
||||||
|
self.quote_subscribed = list(symbols)
|
||||||
|
|
||||||
|
async def handle_message(self):
|
||||||
|
# One message, then idle rather than returning — a real socket
|
||||||
|
# never stops on its own.
|
||||||
|
if self.handler:
|
||||||
|
self.handler(LIVE_MESSAGE)
|
||||||
|
self.handler = None
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
fake = FakeStreamClient()
|
||||||
|
source = SchwabSource(Settings(), stream_client_factory=lambda: fake)
|
||||||
|
|
||||||
|
async def first_bar():
|
||||||
|
async for bar in source.stream("/ES"):
|
||||||
|
return bar
|
||||||
|
|
||||||
|
bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10))
|
||||||
|
assert bar.c == 7787.0
|
||||||
|
assert fake.subscribed == ["/ES"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_socket_failure_surfaces_rather_than_hanging():
|
||||||
|
class ExplodingStreamClient:
|
||||||
|
async def login(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_chart_futures_handler(self, handler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_level_one_futures_handler(self, handler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def level_one_futures_subs(self, symbols):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def chart_futures_subs(self, symbols):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def handle_message(self):
|
||||||
|
raise RuntimeError("socket closed")
|
||||||
|
|
||||||
|
source = SchwabSource(Settings(), stream_client_factory=ExplodingStreamClient)
|
||||||
|
|
||||||
|
async def drain():
|
||||||
|
async for _ in source.stream("/ES"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="socket closed"):
|
||||||
|
asyncio.run(asyncio.wait_for(drain(), timeout=15))
|
||||||
|
|
||||||
|
|
||||||
|
# Shape taken from a live LEVEL_ONE_FUTURES message.
|
||||||
|
QUOTE_MESSAGE = {
|
||||||
|
"service": "LEVEL_ONE_FUTURES",
|
||||||
|
"command": "SUBS",
|
||||||
|
"content": [
|
||||||
|
{"key": "/ES", "LAST_PRICE": 7786.25, "LAST_SIZE": 3, "TRADE_TIME_MILLIS": 1786356930000}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_a_level_one_trade():
|
||||||
|
assert parse_level_one(QUOTE_MESSAGE) == [(1786356930000, 7786.25, 3)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_quotes_without_a_trade_are_skipped():
|
||||||
|
# A bid-only update is not a trade and must not extend a candle's range.
|
||||||
|
bid_only = {"content": [{"key": "/ES", "BID_PRICE": 7786.0, "ASK_PRICE": 7786.5}]}
|
||||||
|
assert parse_level_one(bid_only) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ticks_build_an_unclosed_bar_for_the_current_minute():
|
||||||
|
class QuotingClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.quote_handler = None
|
||||||
|
|
||||||
|
async def login(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_chart_futures_handler(self, handler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def chart_futures_subs(self, symbols):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_level_one_futures_handler(self, handler):
|
||||||
|
self.quote_handler = handler
|
||||||
|
|
||||||
|
async def level_one_futures_subs(self, symbols):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def handle_message(self):
|
||||||
|
if self.quote_handler:
|
||||||
|
self.quote_handler(QUOTE_MESSAGE)
|
||||||
|
self.quote_handler = None
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=QuotingClient)
|
||||||
|
|
||||||
|
async def first_bar():
|
||||||
|
async for bar in source.stream("/ES"):
|
||||||
|
return bar
|
||||||
|
|
||||||
|
bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10))
|
||||||
|
# Bucketed to its minute, and explicitly not closed — the minute is still
|
||||||
|
# running, and a closed flag would let it into the aggregator.
|
||||||
|
assert bar.t == 1786356900
|
||||||
|
assert bar.closed is False
|
||||||
|
assert (bar.o, bar.h, bar.l, bar.c) == (7786.25, 7786.25, 7786.25, 7786.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tick_for_an_already_closed_minute_is_ignored():
|
||||||
|
# CHART_FUTURES is authoritative. A late tick for a minute it has already
|
||||||
|
# settled would otherwise overwrite a real bar with a partial one.
|
||||||
|
class LateTickClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.chart_handler = None
|
||||||
|
self.quote_handler = None
|
||||||
|
|
||||||
|
async def login(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_chart_futures_handler(self, handler):
|
||||||
|
self.chart_handler = handler
|
||||||
|
|
||||||
|
async def chart_futures_subs(self, symbols):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_level_one_futures_handler(self, handler):
|
||||||
|
self.quote_handler = handler
|
||||||
|
|
||||||
|
async def level_one_futures_subs(self, symbols):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def handle_message(self):
|
||||||
|
if self.chart_handler:
|
||||||
|
self.chart_handler(LIVE_MESSAGE) # closes 1786356900
|
||||||
|
self.quote_handler(QUOTE_MESSAGE) # tick inside it
|
||||||
|
self.chart_handler = None
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=LateTickClient)
|
||||||
|
|
||||||
|
async def two_bars():
|
||||||
|
seen = []
|
||||||
|
async for bar in source.stream("/ES"):
|
||||||
|
seen.append(bar)
|
||||||
|
if len(seen) == 1:
|
||||||
|
# Give the late tick a chance to be wrongly emitted.
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
break
|
||||||
|
return seen
|
||||||
|
|
||||||
|
seen = asyncio.run(asyncio.wait_for(two_bars(), timeout=10))
|
||||||
|
assert [bar.closed for bar in seen] == [True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_same_price_trade_keeps_its_volume():
|
||||||
|
# Level 1 sends only changed fields, so a trade at the price of the one
|
||||||
|
# before carries size and trade time but no LAST_PRICE. Measured live at a
|
||||||
|
# fifth of all trades; dropping them lost that volume from the bar.
|
||||||
|
same_price = {
|
||||||
|
"content": [
|
||||||
|
{"key": "/ES", "LAST_SIZE": 4, "TRADE_TIME_MILLIS": 1786356931000, "TOTAL_VOLUME": 9}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert parse_level_one(same_price) == [(1786356931000, None, 4)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_quote_with_neither_price_nor_trade_is_still_skipped():
|
||||||
|
assert parse_level_one({"content": [{"key": "/ES", "BID_SIZE": 12}]}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_trade_known_only_by_its_volume_still_counts():
|
||||||
|
# Neither price nor size resent, but the trade stamp moved and cumulative
|
||||||
|
# volume rose: a trade happened, and the candle should learn about it.
|
||||||
|
volume_only = {
|
||||||
|
"content": [
|
||||||
|
{"key": "/ES", "TRADE_TIME_MILLIS": 1786356932000, "TOTAL_VOLUME": 41,
|
||||||
|
"BID_SIZE": 8}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert parse_level_one(volume_only) == [(1786356932000, None, 0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_zero_last_price_is_not_a_price():
|
||||||
|
# Seen live: LAST_PRICE arrived as 0, and because 0 is not None it opened a
|
||||||
|
# bar at zero whose low dragged every aggregating timeframe to the floor.
|
||||||
|
zero = {"content": [{"key": "/ES", "LAST_PRICE": 0, "LAST_SIZE": 2,
|
||||||
|
"TRADE_TIME_MILLIS": 1786356933000}]}
|
||||||
|
# Still a trade — size and stamp are there — but with no usable price, so
|
||||||
|
# the caller carries the last real one forward.
|
||||||
|
assert parse_level_one(zero) == [(1786356933000, None, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_zero_price_with_no_trade_markers_is_dropped_entirely():
|
||||||
|
assert parse_level_one({"content": [{"key": "/ES", "LAST_PRICE": 0}]}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_hits_the_shared_http_client():
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def get_user_preferences(self):
|
||||||
|
self.calls += 1
|
||||||
|
return type("R", (), {"status_code": 200})()
|
||||||
|
|
||||||
|
source = SchwabSource(Settings())
|
||||||
|
client = FakeClient()
|
||||||
|
source._http = client
|
||||||
|
asyncio.run(source.refresh_token())
|
||||||
|
assert client.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_client_drops_the_cached_http_session():
|
||||||
|
source = SchwabSource(Settings())
|
||||||
|
source._http = object()
|
||||||
|
source.reset_client()
|
||||||
|
assert source._http is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_login_when_the_refresh_token_is_dead(tmp_path):
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
runtime = Runtime(Settings(manual_lines_path=tmp_path / "lines.json"))
|
||||||
|
assert runtime.needs_login() is False
|
||||||
|
runtime.stream.last_error = (
|
||||||
|
'unsupported_token_type: 400 Bad Request: '
|
||||||
|
'{"error_description":"Refresh token is invalid, expired or revoked",'
|
||||||
|
'"error":"invalid_grant"}'
|
||||||
|
)
|
||||||
|
assert runtime.needs_login() is True
|
||||||
|
|
@ -4,7 +4,13 @@ from zoneinfo import ZoneInfo
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.bars.session import bucket_start
|
from app.bars.session import (
|
||||||
|
FUTURE_SLOT_COUNT,
|
||||||
|
bucket_duration,
|
||||||
|
bucket_start,
|
||||||
|
future_bucket_starts,
|
||||||
|
next_bucket_start,
|
||||||
|
)
|
||||||
|
|
||||||
UTC = ZoneInfo("UTC")
|
UTC = ZoneInfo("UTC")
|
||||||
ET = ZoneInfo("America/New_York")
|
ET = ZoneInfo("America/New_York")
|
||||||
|
|
@ -22,9 +28,6 @@ def epoch(value: str, zone=UTC) -> int:
|
||||||
("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"),
|
("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"),
|
||||||
("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"),
|
("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"),
|
||||||
("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"),
|
("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"),
|
||||||
("2026-08-10T01:59:00", Timeframe.H4, "2026-08-09T22:00:00"),
|
|
||||||
("2026-08-10T02:00:00", Timeframe.H4, "2026-08-10T02:00:00"),
|
|
||||||
("2026-08-10T17:59:00", Timeframe.H4, "2026-08-10T14:00:00"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_session_boundaries(value, tf, expected):
|
def test_session_boundaries(value, tf, expected):
|
||||||
|
|
@ -37,21 +40,6 @@ def test_intraday_buckets_use_utc_boundaries():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("value", "expected"),
|
|
||||||
[
|
|
||||||
# Spring forward: the 22:00 ET bucket ends at 02:00 EDT after three real hours.
|
|
||||||
("2026-03-08T06:59:00", "2026-03-08T03:00:00"),
|
|
||||||
("2026-03-08T07:00:00", "2026-03-08T07:00:00"),
|
|
||||||
# Fall back: the 22:00 ET bucket lasts five real hours and ends at 02:00 EST.
|
|
||||||
("2026-11-01T06:59:00", "2026-11-01T02:00:00"),
|
|
||||||
("2026-11-01T07:00:00", "2026-11-01T07:00:00"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_four_hour_wall_clock_anchor_across_dst(value, expected):
|
|
||||||
assert bucket_start(epoch(value), Timeframe.H4) == epoch(expected)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("local_value", "expected_local"),
|
("local_value", "expected_local"),
|
||||||
[
|
[
|
||||||
|
|
@ -61,3 +49,51 @@ def test_four_hour_wall_clock_anchor_across_dst(value, expected):
|
||||||
)
|
)
|
||||||
def test_sunday_open_across_dst(local_value, expected_local):
|
def test_sunday_open_across_dst(local_value, expected_local):
|
||||||
assert bucket_start(epoch(local_value, ET), Timeframe.D1) == epoch(expected_local, ET)
|
assert bucket_start(epoch(local_value, ET), Timeframe.D1) == epoch(expected_local, ET)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value_utc", "expected_open_utc"),
|
||||||
|
[
|
||||||
|
("2026-01-13T04:00:00", "2026-01-12T23:00:00"),
|
||||||
|
("2026-07-14T03:00:00", "2026-07-13T22:00:00"),
|
||||||
|
("2026-11-01T22:59:00", "2026-10-31T22:00:00"),
|
||||||
|
("2026-11-01T23:00:00", "2026-11-01T23:00:00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_daily_open_tracks_eastern_dst_in_utc(value_utc, expected_open_utc):
|
||||||
|
assert bucket_start(epoch(value_utc), Timeframe.D1) == epoch(expected_open_utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_geometry_excludes_the_settlement_halt():
|
||||||
|
start = epoch("2026-08-10T18:00:00", ET)
|
||||||
|
|
||||||
|
assert bucket_duration(start, Timeframe.D1) == 23 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_future_geometry_skips_the_weekend_and_opens_at_1800_eastern():
|
||||||
|
thursday = epoch("2026-08-13T18:00:00", ET)
|
||||||
|
sunday = epoch("2026-08-16T18:00:00", ET)
|
||||||
|
|
||||||
|
assert next_bucket_start(thursday, Timeframe.D1) == sunday
|
||||||
|
|
||||||
|
|
||||||
|
def test_intraday_future_geometry_skips_the_settlement_halt():
|
||||||
|
monday_last = epoch("2026-08-10T16:55:00", ET)
|
||||||
|
monday_reopen = epoch("2026-08-10T18:00:00", ET)
|
||||||
|
|
||||||
|
assert next_bucket_start(monday_last, Timeframe.M5) == monday_reopen
|
||||||
|
|
||||||
|
|
||||||
|
def test_intraday_future_geometry_skips_the_weekend():
|
||||||
|
friday_last = epoch("2026-08-14T16:55:00", ET)
|
||||||
|
sunday_reopen = epoch("2026-08-16T18:00:00", ET)
|
||||||
|
|
||||||
|
assert next_bucket_start(friday_last, Timeframe.M5) == sunday_reopen
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_geometry_uses_the_shared_180_slot_horizon():
|
||||||
|
start = epoch("2026-08-13T18:00:00", ET)
|
||||||
|
future = future_bucket_starts(start, Timeframe.D1)
|
||||||
|
|
||||||
|
assert len(future) == FUTURE_SLOT_COUNT == 180
|
||||||
|
assert all(datetime.fromtimestamp(value, ET).weekday() in {6, 0, 1, 2, 3} for value in future)
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,55 @@ def test_store_replaces_forming_bar_and_bounds_history():
|
||||||
|
|
||||||
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180]
|
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180]
|
||||||
assert store.get(Timeframe.M1, 1)[0].t == 180
|
assert store.get(Timeframe.M1, 1)[0].t == 180
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_closed_bar_replaces_its_bucket_behind_the_tail():
|
||||||
|
# Ticks open the next minute before the exchange's own bar for the previous
|
||||||
|
# one arrives. Matching only the tail dropped it, leaving the tick-built
|
||||||
|
# approximation — with its partial volume — in place permanently.
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.bars.store import InMemoryBarStore
|
||||||
|
|
||||||
|
store = InMemoryBarStore(100)
|
||||||
|
common = dict(tf=Timeframe.M1, symbol="/ES", source="schwab")
|
||||||
|
store.put(Bar(t=60, o=1, h=2, l=1, c=2, v=5, closed=False, **common))
|
||||||
|
store.put(Bar(t=120, o=2, h=3, l=2, c=3, v=1, closed=False, **common))
|
||||||
|
# The authoritative bar for the first minute, arriving late.
|
||||||
|
store.put(Bar(t=60, o=1, h=9, l=1, c=4, v=400, closed=True, **common))
|
||||||
|
|
||||||
|
held = store.get(Timeframe.M1)
|
||||||
|
assert [b.t for b in held] == [60, 120]
|
||||||
|
assert held[0].closed is True
|
||||||
|
assert held[0].v == 400, "the exchange's volume must win over the tick estimate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tick_cannot_overwrite_a_settled_bar():
|
||||||
|
from app.bars.models import Bar, Timeframe
|
||||||
|
from app.bars.store import InMemoryBarStore
|
||||||
|
|
||||||
|
store = InMemoryBarStore(100)
|
||||||
|
common = dict(tf=Timeframe.M1, symbol="/ES", source="schwab")
|
||||||
|
store.put(Bar(t=60, o=1, h=9, l=1, c=4, v=400, closed=True, **common))
|
||||||
|
store.put(Bar(t=60, o=1, h=2, l=1, c=2, v=5, closed=False, **common))
|
||||||
|
|
||||||
|
held = store.get(Timeframe.M1)[0]
|
||||||
|
assert held.closed is True and held.v == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_backfilled_hole_lands_behind_live_bars_without_replacing_them():
|
||||||
|
# After an outage the live stream is already newer than the hole, so put()
|
||||||
|
# dropped every recovered bar and fifteen missed days stayed missing.
|
||||||
|
store = InMemoryBarStore(4)
|
||||||
|
store.put(bar(60))
|
||||||
|
store.put(bar(600, close=7))
|
||||||
|
|
||||||
|
added = store.fill([bar(120), bar(180), bar(600, close=99)])
|
||||||
|
|
||||||
|
assert added == 2
|
||||||
|
assert [value.t for value in store.get(Timeframe.M1)] == [60, 120, 180, 600]
|
||||||
|
assert store.get(Timeframe.M1)[-1].c == 7, "the live source's bar must win"
|
||||||
|
|
||||||
|
store.fill([bar(240)])
|
||||||
|
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180, 240, 600], (
|
||||||
|
"the cap keeps the newest history"
|
||||||
|
)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue